[toc]
操作系统
熟练使用 Linux 命令行 -> 使用 Linux 进行程序设计 -> 了解 Linux 内核机制 -> 阅读 Linux 内核代码 -> 实验定制 Linux 组件 -> 以及最后落到生产实践上
鼠标双击会触发一个中断,操作系统里面就是调用中断处理函数,分析中断,并执行对应的程序。
在操作系统中,进程的执行需要分配CPU进行执行,也就是按照程序里面的二进制代码一行一行地执行。众多进程交替使用CPU,为了管理进程,就需要一个进程管理子系统。同样CPU并发的运行多个进程,也需要CPU的调度能力。
对于 QQ 来讲,由于键盘闪啊闪的焦点在QQ这个对话框上,因而操作系统知道,这个事件是给这个进程的。QQ的代码里面肯定有遇到这种事件如何处理的代码,就会执行。一般是记录下客户的输入,并且告知显卡驱动程序,在那个地方画一个“a”。显卡画完了,客户看到了,就觉得自己的输入成功了。
当用户输入完毕之后,回车一下,还是会通过键盘驱动程序告诉操作系统,操作系统还是会找到QQ,QQ会将用户的输入发送到网络上。QQ进程是不能直接发送网络包的,需要调用系统调用,内核使用网卡驱动程序进行发送。
Linux 基础命令
--help
man
passwd
useradd [hikari] 默认就会创建一个同名的组。
rpm -qa 安装的软件列表
rpm -qa | more和rpm -qa | less 们可以将很长的结果分页展示出来 z 下一页 w 上一页。jk上一条下一条
rpm -e和dpkg -r。-e 就是 erase,-r 就是 remove
因为 Linux 现在常用的有两大体系,一个是 CentOS 体系,一个是 Ubuntu 体系,前者使用 rpm,后者使用 deb。CentOS 下面使用rpm -i jdk-XXX_linux-x64_bin.rpm
进行安装,Ubuntu 下面使用dpkg -i jdk-XXX_linux-x64_bin.deb
。其中 -i 就是 install 的意思
以上是没有软件管家安装软件的方式。后来Linux 也有自己的软件管家,CentOS 下面是 yum,Ubuntu 下面是 apt-get。ubuntu 中现在用apt替换了apt-get
在软件管家中搜索对应的内容
yum search jdk和apt-cache search jdk
通过软件管家安装:
yum install java-11-openjdk.x86_64
apt-get install openjdk-9-jdk来进行安装
通过软件管家进行卸载
yum erase java-11-openjdk.x86_64
apt-get purge openjdk-9-jdk
Linux 允许我们配置从哪里下载这些软件的,地点就在配置文件里面。对于 CentOS 来讲,配置文件在/etc/yum.repos.d/CentOS-Base.repo
[base]
name=CentOS-$releasever - Base - 163.com
baseurl=http://mirrors.163.com/centos/$releasever/os/$basearch/
gpgcheck=1
gpgkey=http://mirrors.163.com/centos/RPM-GPG-KEY-CentOS-7
对于 Ubuntu 来讲,配置文件在/etc/apt/sources.list里。
deb http://mirrors.163.com/ubuntu/ xenial main restricted universe multiverse
deb http://mirrors.163.com/ubuntu/ xenial-security main restricted universe multiverse
deb http://mirrors.163.com/ubuntu/ xenial-updates main restricted universe multiverse
deb http://mirrors.163.com/ubuntu/ xenial-proposed main restricted universe multiverse
deb http://mirrors.163.com/ubuntu/ xenial-backports main restricted universe multiverse
其实无论是先下载再安装,还是通过软件管家进行安装,都是下载一些文件,然后将这些文件放在某个路径下,然后在相应的配置文件中配置一下 在 Linux 里面会放的散一点。例如,主执行文件会放在 /usr/bin 或者 /usr/sbin 下面,其他的库文件会放在 /var 下面,配置文件会放在 /etc 下面。
也可以选择直接将安装好的路径直接下载下来,然后解压缩成为一个整的路径。如:一个jdk-XXX_linux-x64_bin.tar.gz
这样的文件。需要注意的是,通过这种方式下载的内容需要在环境变量进行相应的配置。
先通过wget命令下载上对应的压缩包。
wget jdk-XXX_linux-x64_bin.tar.gz
对应着tar.gz这种格式的压缩包,直接通过tar命令进行解压
tar xvzf jdk-XXX_linux-x64_bin.tar.gz
配置环境变量。export 命令仅在当前命令行的会话中管用,一旦退出重新登录进来,就不管用了
export JAVA_HOME=/root/jdk-XXX_linux-x64
export PATH=$JAVA_HOME/bin:$PATH
在当前用户的默认工作目录,例如 /root 或者 /home/cliu8 下面,有一个.bashrc 文件,这个文件是以点开头的,这个文件默认看不到,需要 ls -la 才能看到,a 就是 all。每次登录的时候,这个文件都会运行,因而把它放在这里。这样登录进来就会自动执行。当然也可以通过 source .bashrc 手动执行。
Linux 通过./filename
运行这个程序。如果放在 PATH 里设置的路径下面,就不用./
了,直接输入文件名就可以运行了,Linux 会帮你找。比如 mysql
交互命令行退出,程序还在运行:
- 使用nohup命令。这个命令的意思是 no hang up(不挂起)。
- 这个时候,程序不能霸占交互命令行,而是应该在后台运行。最后加一个 &,就表示后台运行
nohup &
-----
最终命令的一般形式:nohup command >out.file 2>&1 &
这里面,“1”表示文件描述符 1,表示标准输出,“2”表示文件描述符 2,意思是标准错误输出,“2>&1”表示标准输出和错误输出合并了。合并到到 out.file 这个文件里了。
启动的程序如何退出
ps -ef |grep 关键字 |awk '{print $2}'|xargs kill -9
- ps -ef 可以单独执行,列出所有正在运行的程序
- grep 通过关键字找到刚才启动的程序。
- awk 工具可以很灵活地对文本进行处理,这里的 awk '{print $2}'是指第二列的内容,是运行的程序 ID
- 可以通过 xargs 传递给 kill -9,也就是发给这个运行的程序一个信号,让它关闭
如果你已经知道运行的程序 ID,可以直接使用 kill 关闭运行的程序
Linux 中的程序也可以以服务的方式运行 例如MySQL
Ubuntu:
启动 MySQL:systemctl start mysql
设置开机启动:systemctl enable mysql
Ubuntu下之所以成为服务并且能够开机启动,是因为在 /lib/systemd/system 目录下会创建一个 XXX.service 的配置文件,里面定义了如何启动、如何关闭。
CentOS - MariaDB
进行安装:yum install mariadb-server mariadb
启动:systemctl start mariadb
设置开机启动:systemctl enable mariadb
Linux 挂机和重启
现在就关机:shutdown -h now
重启:reboot
.bash_profile
是系统配置信息存储文件,写在里面的系统变量是所有用户共用的,而.bashrc
是个人的配置信息存储文件,只是单用户有效。也就是说,配置了.bashrc
后切换用户可能需要重新配置系统变量。
系统调用
创建进程的系统调用叫fork。在Linux里,要创建一个新的进程,需要一个老的进程调用fork来实现,其中老的进程叫作父进程,新的进程叫作子进程。由于在Linux中创建一个进程需要协调的系统资源很多,步骤也很繁琐。所以Linux干脆直接在原来父进程的基础上fork一份子进程出来即可,原模原样。这样简单也快速。
当父进程调用 fork 创建子进程的时候,子进程将各个为父进程创建的数据结构也全部拷贝了一份,甚至连程序代码也是拷贝过来的。按理说,如果不进行特殊的处理,父进程和子进程都按相同的程序代码进行下去,这样就没有意义了。所以,我们往往会这样处理:对于 fork 系统调用的返回值,如果当前进程是子进程,就返回 0;如果当前进程是父进程,就返回子进程的进程号。这样首先在返回值这里就有了一个区分,然后如果是父进程,还接着做原来应该做的事情;如果是子进程,需要请求另一个系统调用execve来执行另一个程序,这个时候,子进程和父进程就彻底分道扬镳了,也就产生了一个分支(fork)了。
对于Linux系统,启动的时候会先创建一个所有用户进程的“祖宗进程”。 父进程可以通过系统调用waitpid。父进程可以调用它,将子进程的进程号作为参数传给它,这样父进程就知道子进程运行完了没有,成功与否。
各进程有独立的内存空间,对于进程的内存空间来讲,放程序代码的这部分,称为代码段。放进程运行中产生数据的这部分,称为数据段。其中局部变量的部分,在当前函数执行的时候起作用,当进入另一个函数时,这个变量就释放了;也有动态分配的,会较长时间保存,指明才销毁的,这部分称为堆。
一个进程在32位的计算机中最大内存空间是4G。不会给所有进程在创建的时候就分配好这么大的内存,都是在内存使用的过程中才进行增量创建的。并且进程只有真的写入数据的时候,发现没有对应物理内存,才会触发一个中断,现分配物理内存。
堆中分配内存的系统调用:
- brk:当分配的内存数量比较小的时候,使用 brk,会和原来的堆的数据连在一起
- mmap:当分配的内存数量比较大的时候,使用 mmap,会重新划分一块区域
文件操作的6个系统调用:
- open,close:对于已经有的文件,可以使用open打开这个文件,close关闭这个文件;
- creat:对于没有的文件,可以使用creat创建文件
- lseek:打开文件以后,可以使用lseek跳到文件的某个位置;
- read,write:可以对文件的内容进行读写,读的系统调用是read,写是write
Linux 系统下,万物皆文件。每个文件,Linux都会分配一个文件描述符,这是一个整数。有了这个文件描述符,就可以使用系统调用,切入进程,查看或者干预进程运行的方方面面。
信号
信号有以下几种:
- 在执行一个程序的时候,在键盘输入“CTRL+C”,这就是中断的信号,正在执行的命令就会中止退出;
- 硬件故障,设备出了问题,通知操作系统;
- 用户进程通过kill函数,将一个用户信号发送给另一个进程。
每种信号都定义了默认的动作,例如硬件故障,默认终止;也可以提供信号处理函数,可以通过sigaction系统调用,注册一个信号处理函数。
进程间通信
- 消息队列:进程间交互的数据不大的时候可以通过队列的方式进行数据交换。
- 共享内存:当两个进程间互相通信的内容较多的时候,可以使用共享内存的方式。这样数据就不需要进行拷贝了。共享内存间多进程访问修改的时候就会有竞争的问题,通过信号量Semaphore的机制来保证。
- 信号量Semaphore:对于只允许一个人访问的情况,将信号量设为1。先调用
sem_wait
。如果这时候没有人访问,则占用这个信号量,他就可以开始访问了,信号量的值此时为0。如果这个时候另一个人要访问,也会调用sem_wait
。由于前一个人已经在访问了,所以后面这个人就必须等待上一个人访问完之后才能访问。当上一个人访问完毕后,会调用sem_post将信号量释放,于是下一个人等待结束,可以访问这个资源了。
-跨网络通信:多机之间进程通信需要涉及到网络通信,就需要遵循相同的网络协议(TCP/IP 网络协议栈)。Linux内核中有对于网络协议的实现。网络服务是通过套接字Socket来提供的,可以看成两台物理机的插槽。通过网线来接通两机之间的电信号来进行通信。通信前双方都需要对接口的通信规则来进行定义,即双方需要创建一个Socket。我们可以通过Socket系统调用建立一个Socket。Socket也是一个文件,也有一个文件描述符,也可以通过读写函数进行通信。
- 信号量Semaphore:对于只允许一个人访问的情况,将信号量设为1。先调用
Glibc
虽然Linux提供了有如上这么多甚至更多的系统调用,但是对于开发来说任然不是很友好。调用起来还是会有难度,所以在Linux之上,有了中介,Glibc。Glibc最重要的是封装了操作系统提供的系统服务,即系统调用的封装。 有些系统调用太零碎了,整合起来可以完成一个更有效的功能。
x86架构
CPU
- 运算单元:只管算,例如做加法、做位移等。但是,它不知道应该算哪些数据,运算结果应该放在哪里。运算单元计算的数据如果每次都要经过总线,到内存里面现拿,这样就太慢了,所以就有了数据单元(寄存器)。
- 数据单元:数据单元包括CPU内部的多级缓存缓存和寄存器组,空间很小,但是速度飞快,可以暂时存放数据和运算结果。有了放待运算数据的地方,也有了算的地方,还需要有个指挥调度的逻辑单元,这就是控制单元。
- 控制单元:控制单元是一个统一的指挥中心,它可以获得下一条指令,然后执行这条指令。这个指令会指导运算单元取出数据单元中的某几个数据,计算出个结果,然后放在数据单元的某个地方。控制指令从哪里获取,运算完填入到哪里。
CPU 的控制单元里面,有一个指令指针寄存器,它里面存放的是下一条指令在内存中的地址。控制单元会不停地将代码段的指令拿进来,先放入指令寄存器。然后交给运算单元去执行。CPU和内存来来回回传数据,靠的都是总线。总线有地址总线,数据总线两类。
CPU 里有两个寄存器,专门保存当前处理进程的代码段的起始地址,以及数据段的起始地址。这里面写的都是进程 A,那当前执行的就是进程 A 的指令,等切换成进程 B,就会执行 B 的指令了,这个过程叫作进程切换。
CPU在运算的时候为了暂存数据,以8086为例有8个16位的通用寄存器。也就是CPU的数据单元,分别是AX、BX、CX、DX、SP、BP、SI、DI。这些寄存器主要用于在计算过程中暂存数据。这些寄存器比较灵活,其中 AX、BX、CX、DX 可以分成两个 8 位的寄存器来使用,分别是 AH、AL、BH、BL、CH、CL、DH、DL。(H代表高位,L代表低位的意思。)这样,比较长的数据也能暂存,比较短的数据也能暂存。
IP 寄存器就是指令指针寄存器,指向代码段中下一条指令的内存地址。为了指向不同进程的地址空间,有四个 16 位的段寄存器,分别是 CS、DS、SS、ES。
- CS:代码段寄存器,通过它可以找到代码在内存中的位置
- DS:数据段的寄存器,通过它可以找到数据在内存中的位置
- SS:栈寄存器,凡是与函数调用相关的操作,都与栈紧密相关。
如果运算中需要加载内存中的数据,需要通过DS找到内存中的数据,加载到通用寄存器中。对于一个段,有一个起始的地址,而段内的具体位置,称为偏移量。在CS和DS中都存放着一个段的起始地址。代码段的偏移量在IP寄存器中,数据段的偏移量会放在通用寄存器中。
需要注意的就是在x86刚兴起的时代,CS和DS都是16位的,也就是说起始地址都是16位的。IP寄存器和通用寄存器也都是16位的,即偏移量也是 16 位的,但是8086处理器的寻址目标是1M大的内存空间。所以它的的地址总线地址是20位。借助段加偏移的方式就能成功凑够20位的数据地址。方法:起始地址 *16+ 偏移量
,也就是把 CS 和 DS 中的值左移 4 位,变成 20 位的,然后加上 16 位的偏移量。
后来32位的计算机问世,在32位处理器中有32根地址总线,处理器能访问的内存空间2^32=4G。原来的8个16位的通用寄存器变成了8个32位的通用寄存器,同时为了向下兼容,依然保留16位的和8位的使用方式。但过去的段寄存器CS、SS、DS、ES,不再是段的起始地址,段的起始地址存储于内存中的表格中。表格中的每一项是段描述符,这里面才是真正的段的起始地址。段寄存器里面保存的是表格中的哪一项,称为选择子。
这样,将一个从段寄存器直接拿到的段起始地址,就变成了先间接地从段寄存器找到表格中的一项,再从表格中的一项中拿到段起始地址。
这两种模式,前一种称为实模式,后一种模式称为保护模式。当系统刚刚启动的时候,CPU是处于实模式的,这个时候和原来的模式是兼容的,当需要更多内存的时候,你可以遵循一定的规则,进行一系列的操作,然后切换到保护模式,就能够用到 32 位 CPU 更强大的能力。此时不能无缝兼容,但是通过切换模式兼容。
BIOS
系统刚启动时,在 x86 系统中,将1M空间最上面的0xF0000到0xFFFFF这64K映射给ROM,也就是说,到这部分地址访问的时候,会访问ROM。当电脑刚加电的时候,主板通电,处理器会做一些重置的工作,将 CS 设置为 0xFFFF,将 IP 设置为 0x0000,所以第一条指令就会指向 0xFFFF0,正是在 ROM 的范围内。在这里,有一个 JMP 命令会跳到 ROM 中做初始化工作的代码,于是,BIOS 开始进行初始化的工作。
初始化工作包括:
- 检查一下系统的硬件
- 要建立一个中断向量表和中断服务程序 -> 服务于后续的鼠标键盘
- 内存空间映射显存的空间 -> 在显示器上显示一些字符
- 查看BIOS中的启动盘选项,找到启动盘。执行后续加载操作系统的预处理代码
基本上BIOS阶段的初始化工作做完,系统就会从实模式切换到保护模式中。切换到保护模式开始进行内存的访问方式的配置,建立分段分页,打开32位地址线,内存分块等。这一系列初始化工作完成,会到选择操作系统的列表页面,最后启动内核。
内核初始化
内核初始化过程:
- 进程管理模块:系统创建第一个进程,0号进程,也是唯一一个没有通过 fork 产生的进程,是进程列表的第一个。
- 中断处理模块:里面设置了很多中断门,用于处理各种中断,系统调用也是通过发送中断的方式进行的。
- 内存管理模块
- 初始化进程调度模块
- 虚拟文件系统
- 以及一些小的其他的初始化模块,由无实际意义的0号进程fork而来。
- 初始化 1 号进程:用户进程
- 创建 2 号进程:内核态的进程
用户进程承载用户态的所有工作,当一个用户态的程序运行到一半,要访问一个核心资源,例如访问网卡发一个网络包,就需要暂停当前的运行,调用系统调用,接下来就轮到内核中的代码运行了。内核将从系统调用传过来的包,在网卡上排队,轮到的时候就发送。发送完了,系统调用就结束了,返回用户态,让暂停运行的程序接着运行。进程暂停的那一刻,要把当时 CPU的寄存器的值全部暂存到一个地方,这个地方可以放在进程管理系统很容易获取的地方。当系统调用完毕,返回的时候,再从这个地方将寄存器的值恢复回去,就能接着运行了
过程如下:用户态 - 系统调用 - 保存寄存器 - 内核态执行系统调用(寄存器的使用) - 恢复寄存器 - 返回用户态,然后接着原步骤运行。
从内核态来看,无论是进程,还是线程,我们都可以统称为任务,都使用相同的数据结构Task,平放在同一个链表中。
系统调用
32 位系统调用过程:
- 将请求参数放在寄存器
- 根据系统调用的名称,得到系统调用号,放在寄存器 eax 里面
- 执行ENTER_KERNEL,触发一个软中断,通过它就可以陷入(trap)内核。
- 软终端的陷入门接手,保存当前用户态的寄存器。保存所有的寄存器
- 将系统调用号从 eax 里面取出来,然后根据系统调用号,在系统调用表中找到相应的函数进行调用。并将寄存器中保存的参数取出来,作为函数参数(第1步存入的参数)。
- 系统调用结束,执行INTERRUPT_RETURN,中断返回。将原来用户态保存的现场恢复回来,包含代码段、指令指针寄存器等。这时候用户态进程恢复执行。
64 位系统调用过程:
- 将请求参数放在寄存器,有区别与32位系统调用,(特殊模块寄存器)
- 根据系统调用的名称,得到系统调用号,放在寄存器 eax 里面
- 真正的系统调用,非中断方式。通过syscall指令。
- 执行entry_SYSCALL_64,保存了很多寄存器,例如用户态的代码段、数据段、保存参数的寄存器。
- 从 rax 里面拿出系统调用号,然后根据系统调用号,在系统调用表中找到相应的函数进行调用。并将寄存器中保存的参数取出来,作为函数参数。
- 系统调用结束,执行USERGS_SYSRET64。
无论32位还是64位的系统调用过程都用到了系统调用表。32位和64位系统调用表的区别
- 32:系统调用表定义在 arch/x86/entry/syscalls/syscall_32.tbl
- 64:系统调用表定义在 arch/x86/entry/syscalls/syscall_64.tbl
32和64的系统调用函数 open 的定义区别:
系统调用号 系统调用的名字 系统调用在内核的实现函数
32 5 i386 open sys_open compat_sys_open
64 2 common open sys_open
进程
在Linux下,处理器能执行的二进制的程序格式是ELF(可执行与可链接格式)。ELF二进制文件格式:
- .text:放编译好的二进制可执行代码
- .data:已经初始化好的全局变量
- .rodata:只读数据,例如字符串常量、const 的变量
- .bss:未初始化全局变量,运行时会置 0
- .symtab:符号表,记录的则是函数和变量
- .strtab:字符串表、字符串常量和变量名
局部变量是放在栈里面的,是程序运行过程中随时分配空间,随时释放的,所以不会在程序的二进制文件中体现,由于二进制文件还没启动,所以二进制文件中只需要考虑在哪里保存全局变量。
- 静态链接库:静态链接库被代码链接进去,代码和变量都合并了,因而程序运行的时候,就不依赖于这个库是否存在。但是这样有一个缺点,就是相同的代码段,如果被多个程序使用的话,在内存里面就有多份,而且一旦静态链接库更新了,如果二进制执行文件不重新编译,也不随着更新。
- 动态链接库:当一个动态链接库被链接到一个程序文件中的时候,最后的程序文件并不包括动态链接库中的代码,而仅仅包括对动态链接库的引用,并且不保存动态链接库的全路径,仅仅保存动态链接库的名称。当运行这个程序的时候,首先寻找动态链接库,然后加载它。
系统启动之后,init 进程会启动很多的daemon进程,为系统运行提供服务。,然后就是启动getty,让用户登录,登录后运行 shell,用户启动的进程都是通过 shell 运行的,从而形成了一棵进程树。
[root@deployer ~]# ps -ef
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 2018 ? 00:00:29 /usr/lib/systemd/systemd --system --deserialize 21
root 2 0 0 2018 ? 00:00:00 [kthreadd]
root 3 2 0 2018 ? 00:00:00 [ksoftirqd/0]
root 5 2 0 2018 ? 00:00:00 [kworker/0:0H]
root 9 2 0 2018 ? 00:00:40 [rcu_sched]
......
root 337 2 0 2018 ? 00:00:01 [kworker/3:1H]
root 380 1 0 2018 ? 00:00:00 /usr/lib/systemd/systemd-udevd
root 415 1 0 2018 ? 00:00:01 /sbin/auditd
root 498 1 0 2018 ? 00:00:03 /usr/lib/systemd/systemd-logind
......
root 852 1 0 2018 ? 00:06:25 /usr/sbin/rsyslogd -n
root 2580 1 0 2018 ? 00:00:00 /usr/sbin/sshd -D
root 29058 2 0 Jan03 ? 00:00:01 [kworker/1:2]
root 29672 2 0 Jan04 ? 00:00:09 [kworker/2:1]
root 30467 1 0 Jan06 ? 00:00:00 /usr/sbin/crond -n
root 31574 2 0 Jan08 ? 00:00:01 [kworker/u128:2]
......
root 32792 2580 0 Jan10 ? 00:00:00 sshd: root@pts/0
root 32794 32792 0 Jan10 pts/0 00:00:00 -bash
root 32901 32794 0 00:01 pts/0 00:00:00 ps -ef
PID 1 的进程就是init 进程 systemd,PID2的进程是内核线程kthreadd,其中用户态的不带中括号,内核态的带中括号。所有带中括号的内核态的进程,祖先都是2号进程。而用户态的进程,祖先都是1号进程。tty那一列,是问号的,说明不是前台启动的,一般都是后台的服务。
首先通过文件编译过程,生成 so 文件和可执行文件,放在硬盘上。用户态的进程 A 执行 fork,创建进程 B,在进程 B 的处理逻辑中,执行 exec 系列系统调用。这个系统调用会通过load_elf_binary方法,将刚才生成的可执行文件,加载到进程 B 的内存中执行。
有的进程只有一个线程,有的进程有多个线程,它们都需要由内核分配 CPU 来干活。在 Linux 里面,无论是进程,还是线程,到了内核里面,统一都叫任务(Task),由一个统一的结构 task_struct 进行管理。
Linux中各进程的执行状态:
进程的状态切换往往涉及调度,进程常见状态:
- TASK_RUNNING:表示进程在时刻准备运行的状态,并非正在运行。当处于这个状态的进程获得时间片的时候,就是在运行中;如果没有获得时间片,就说明它被其他进程抢占了,在等待再次分配时间片。 在运行中的进程,一旦要进行一些 I/O 操作,需要等待 I/O 完毕,这个时候会释放 CPU,进入睡眠状态。
- TASK_INTERRUPTIBLE:可中断的睡眠状态,浅睡眠的状态。虽然在睡眠,等待I/O完成,但是这个时候一个信号来的时候,进程还是要被唤醒,不需要死等IO操作完成。唤醒后,进行信号处理,怎么处理取决于信号处理函数。像IO正常结束即程序原样继续运行。
- TASK_UNINTERRUPTIBLE:不可中断的睡眠状态,深度睡眠状态。不可被信号唤醒,只能死等 I/O 操作完成。一旦 I/O 操作因为特殊原因不能完成,这个时候,谁也叫不醒这个进程了。即便kill也不行,因为kill本身就属于一个信号。 除非重启电脑,所以一般不建议设置成该状态。
- TASK_KILLABLE:与TASK_UNINTERRUPTIBLE原理类似,但能响应致命信号。
- TASK_STOPPED:是在进程接收到 SIGSTOP、SIGTTIN、SIGTSTP 或者 SIGTTOU 信号之后进入该状态。
- EXIT_ZOMBIE:一旦一个进程要结束,先进入的是 EXIT_ZOMBIE 状态,但是这个时候它的父进程还没有使用 wait() 等系统调用来获知它的终止信息,此时进程就成了僵尸进程。
- EXIT_DEAD:是进程的最终状态。
每个进程都有自己独立的虚拟内存空间,这需要有一个数据结构来表示,就是mm_struct。每个进程有一个文件系统的数据结构,还有一个打开文件的数据结构。
在进程的内存空间里面,栈是一个从高地址到低地址,往下增长的结构,也就是上面是栈底,下面是栈顶,入栈和出栈的操作都是从下面的栈顶开始的。
64位内核栈区别于32位内核栈,每个CPU运行的task_struct不再通过thread_info获取,而是直接放在 Per CPU 变量里面了。多核情况下,CPU是同时运行的,但是它们共同使用其他的硬件资源的时候,需要解决多个 CPU 之间的同步问题。Per CPU 变量就是为每个CPU构造一个变量的副本,这样多个CPU各自操作自己的副本,互不干涉。如果进程运行时想要知道当前的task_struct在何处时就不需要再通过调用thread_info结构来找了,各CPU的Pre CPU都有保存该信息。也即不需要CPU间多核情况并行运行时的同步问题了。
进程的创建
之前阐述过每个进程在操作系统中都是通过fork函数创建的,fork系统调用函数首先做的就是corp_process。把父进程的进程变量和数据结构和文件系统相关变量还有信号相关的变量等都重新初始化一遍。
- 进程打开的文件信息。这些信息用一个结构 files_struct 来维护,每个打开的文件都有一个文件描述符。子进程fork的时候复制的也主要是这些结构。
- 进程的信号相关变量的数据结构 sighand_struct。这里最主要的是维护信号处理函数,信号处理函数会从父进程复制到子进程。
- 进程内存空间相关的数据结构 mm_struct。
- copy_process 后续还会分配 pid,设置 tid,group_leader,并且建立进程之间的亲缘关系。
进程初始化的内容不止上述内容,仅仅做简单记录,corp_process函数执行完进程初始化结束后,就需要唤醒进程了。
- 设置该进程的状态TASK_RUNNING
- 根据不同的调度类来进入到不同调度类对应的进程队列中。更新队列的进程数
- 检查该进程能否抢占当前本进程。即父进程,从上述内容知道创建子进程是通过fork函数来的,这是一个系统调用,所以从内核态返回到用户态的时候父进程会判断被强占变量是否需要抢占,来执行__schedule函数让出给子进程运行。
线程
对于任何一个进程来讲,即便没有主动去创建线程,进程也是默认有一个主线程的。线程可以将项目并行起来,加快进度,但是也带来的负面影响,数据的处理。
线程栈的大小可以通过命令 ulimit -a 查看,默认情况下线程栈大小为 8192(8MB)。可以使用命令 ulimit -s 修改。主线程在内存中有一个栈空间,其他线程栈也拥有独立的栈空间。为了避免线程之间的栈空间踩踏,线程栈之间还会有小块区域,用来隔离保护各自的栈空间。一旦另一个线程踏入到这个隔离区,就会引发段错误。
线程间协作互斥有两种方式,一种是抢占锁和被动等待。一种是抢占锁通知。通知的方式明显好于被动等待的运行模式,会减少CPU的开销且减少线程的无效等待时间。这种方式也是条件变量和互斥锁是配合使用的
线程的创建
每一个进程或者线程都有一个 task_struct 结构,在用户态也有一个用于维护线程的结构,就是 pthread 结构。凡是涉及函数的调用,都要使用到栈。每个线程也有自己的栈。所以需要先为线程来创建线程栈。搞定了用户态栈的问题,其实用户态的事情基本搞定了一半。
内容中真正创建线程的是调用 create_thread 函数。该函数除了针对线程栈执行了一些特定的操作后,最后调用的仍是_do_fork函数和进程创建时调用的一样。需要注意的就是do_fork函数中很多变量和数据结构的初始化有区别于进程初始化过程,五大结构仅仅是引用计数加一,也即线程共享进程的数据结构。
在用户的函数执行完毕之后,会释放这个线程相关的数据。例如,线程本地数据 thread_local variables,线程数目也减一。如果这是最后一个线程了,就直接退出进程, 线程的线程栈要从当前使用线程栈的列表 stack_used 中拿下来,放到缓存的线程栈列表 stack_cache 中。
调度
Linux 里面,进程分成两种,通过task_struct 中的两个成员变量来区分,调度策略,优先级。
- 实时进程:需要尽快执行返回结果。优先级较高
- 普通进程:大部分的进程其实都是这种。
实时调度策略:
- SCHED_FIFO:高优先级的进程可以抢占低优先级的进程,而相同优先级的进程,遵循先来先得
- SCHED_RR(轮循):采用时间片,相同优先级的任务当用完时间片会被放到队列尾部,以保证公平性,而高优先级的任务也是可以抢占低优先级的任务。
- SCHED_DEADLINE:按照任务的deadline进行调度。当产生一个调度点的时候,DL 调度器总是选择其 deadline距离当前时间点最近的那个任务,并调度它执行。
普通调度策略:
- SCHED_NORMAL:普通的进程
- SCHED_BATCH:后台进程,几乎不需要和前端进行交互。这类项目可以默默执行,不要影响需要交互的进程,可以降低它的优先级。
- SCHED_IDLE:特别空闲的时候才跑的进程
对于CPU执行的大部分任务都是普通进程,普通进程使用的调度策略是fair_sched_class,公平调度策略。针对此策略的算法实现即CFS 的调度算法,即记录下各进程运行的时间。CPU会提供一个时钟,过一段时间就触发一个时钟中断,即tick。CFS会为每一个进程安排一个虚拟运行时间vruntime。如果一个进程在运行,随着时间的增长,也就是一个个 tick 的到来,进程的 vruntime将不断增大。++没有得到执行的进程vruntime不变。所以会优先运行vruntime小的进++程。
需要考虑不同优先级的进程,虚拟运行时间的计算规则如下:
虚拟运行时间 vruntime += 实际运行时间 delta_exec * NICE_0_LOAD/ 权重
各调度策略都有自己的一个数据结构用来进行排序。各进程根据自己是实时的,还是普通的类型,通过这个成员变量,将自己挂在某一个数据结构里面,和其他的进程排序,等待被调度。 所有可运行的进程通过不断地插入操作最终都存储在以时间为顺序的红黑树中,vruntime 最小的在树的左侧,vruntime 最多的在树的右侧。 CFS 调度策略会选择红黑树最左边的叶子节点作为下一个将获得 CPU 的任务。
在每个 CPU 上都有一个队列 rq,这个队列里面包含多个子队列,不同的队列有不同的实现方式,cfs_rq 就是用红黑树实现的。当有一天,某个CPU需要找下一个任务执行的时候,会按照优先级依次调用调度类,不同的调度类操作不同的队列。当然 rt_sched_class 先被调用,它会在rt_rq上找下一个任务,只有找不到的时候,才轮到 fair_sched_class 被调用,它会在 cfs_rq 上找下一个任务。这样保证了实时任务的优先级永远大于普通任务。
抢占式调度
进程间常常会发生抢占式调度。一个进程执行时间太长了,就是时候切换到另一个进程了。计算机中有一个时钟,会过一段时间触发一次时钟中断,通知操作系统,时间又过去一个时钟周期,这是个很好的方式,可以查看是否是需要抢占的时间点。如上述当内容中,当一次时钟中断过来后,触发更新进程的vruntime,后续会调用check_preempt_tick。顾名思义就是,检查是否是时候被抢占了。当发现当前进程应该被抢占,不能直接把它踢下来,而是把它标记为应该被抢占。 一定要等待正在运行的进程调用 __schedule 才行,所以这里只能先标记一下。
另外一个可能抢占的场景是当一个进程被唤醒的时候。当被唤醒的进程优先级高于当前运行的进程的时候就会发生抢占。这里的抢占也并不是立即踢掉当前进程,而是将当前进程标记为可抢占。
抢占时机
用户态的抢占时机
真正的抢占还需要时机,也就是需要那么一个时刻,让正在运行中的进程有机会调用一下__schedule。对于用户态的进程来讲,从系统调用中返回的那个时刻,是一个被抢占的时机从内核态返回到用户态的时候,内核态中的函数会对当前进程的标记为进行检查,如果需要被抢占即会执行__schedule函数。对于用户态的进程来讲,从中断中返回的那个时刻,也是一个被抢占的时机。 和从内核态返回到用户态函数代码的检查逻辑一样,中断处理函数执行后也会对该标志位进行判断。
内核态的抢占时机
在内核态的执行中,有的操作是不能被中断的,所以在进行这些操作之前,总是先调用 preempt_disable() 关闭抢占,当再次打开的时候(内核态代码打开逻辑写在了特定函数执行步骤时),就是一次内核态代码被抢占的机会。在内核态也会遇到中断的情况,当中断返回的时候,返回的仍然是内核态。这个时候也是一个执行抢占的时机,和用户态中断返回触发抢占逻辑一致。
内存管理
进程无法直接操作物理内存的原因:不同进程并行运行的时候,同一地址的物理内存被多个进程同时修改,会出现丢失修改的情况。
出于对上述风险的规避,操作系统并不会给每个进程内存地址的实际物理地址。内存实际的物理地址对于进程来说是不可见的,但操作系统会给每个进程分配一个虚拟地址。所有进程看到的这个地址都是一样的,里面的内存都是从 0 开始编号。
在程序里面,指令写入的地址是虚拟地址。操作系统会提供一种机制,将不同进程的虚拟地址和不同内存的物理地址映射起来。 当程序要访问虚拟地址的时候,由内核的数据结构进行转换,转换成不同的物理地址,这样不同的进程运行的时候,写入的是不同的物理地址,这样就不会冲突了
简单的程序在使用内存时的几种方式:用户态阶段
- 代码需要放在内存里面;
- 全局变量,例如 max_length;
- 常量字符串"Input the string length : ";
- 函数栈,例如局部变量 num 是作为参数传给 generate 函数的,这里面涉及了函数调用,局部变量,函数参数等都是保存在函数栈上面的;
- 堆,malloc 分配的内存在堆里面;
- 这里面涉及对 glibc 的调用,所以 glibc 的代码是以 so 文件的形式存在的,也需要放在内存里面。
内核部分还需要分配内存:
- 内核中也有全局变量;
- 每个进程都要有一个 task_struct;
- 每个进程还有一个内核栈;
- 在内核里面也有动态分配的内存;
- 虚拟地址到物理地址的映射表
用户态和内核态都是通过虚拟地址来访问物理内存的。如果是 32 位的机器,有 2^32 = 4G 的内存空间都是进程可以操作的内存空间,虽然是虚拟的。 这部分内存空间,用户态内存在内存地址的低地址,内核态内存在内存地址高地址。而且对于普通进程无权限访问内核内存地址。所以,用户态只能通过系统调用进入内核态,进入内核态之后各进程之间就成了互相可见的状态,虽然内核栈是各用各的,但其他的数据结构是共同的同一批数据结构,所以需要进行锁保护。并且内核态也无法访问内核态的内存空间。
虚拟地址
分段机制下的虚拟地址由两部分组成,段选择子和段内偏移量。段选择子里面最重要的是段号,用作段表的索引,段表里面保存的是这个段的基地址、段的界限和特权等级等。段基地址加上段内偏移量得到物理内存地址。但在 Linux 操作系统中,并没有使用到全部的分段功能。只有部分权限审核的时候才会用到。
Linux 倾向于另外一种从虚拟地址到物理地址的转换方式,称为分页。对于物理内存,操作系统把它分成一块一块大小相同的页,这样更方便管理,例如有的内存页面长时间不用了,可以暂时写到硬盘上,称为换出。一旦需要的时候,再加载进来,叫做换入。这样可以扩大可用物理内存的大小,提高物理内存的利用率。
换入和换出都是以页为单位的。页面的大小一般为 4KB,为了能够定位和访问每个页,需要有个页表,保存每个页的起始地址,再加上在页内的偏移量,组成线性地址,就能对于内存中的每个位置进行访问了。
虚拟地址分为两部分,页号和页内偏移。页号作为页表某一具体虚拟页号的索引,页表包含物理页每页所在物理内存的基地址。这个基地址与页内偏移的组合就形成了物理内存地址。物理内存地址 = 物理内存的基地址 + 页内偏移
。
虚拟内存中的页通过页表映射为了物理内存中的页:32 位环境下,虚拟地址空间共 4GB。如果分成 4KB 一个页,那就是 1M 个页。页表的每个页表项需要 4 个字节来存储,那么整个 4GB 虚拟内存空间的映射就需要 4MB 的内存来存储映射表。如果每个进程都有自己的映射表,100 个进程就需要 400MB 的内存。对于内核来讲,有点大了 。页表中所有页表项必须提前建好,并且要求是连续的。如果不连续,就没有办法通过虚拟地址里面的页号找到对应的页表项了。
后续改进:将页表再分页,4GB 的空间需要 4MB 的页表来存储映射。我们把这 4M 分成 1K(1024)个 4KB,每个 4KB 又能放在一页里面,这样 1K 个 4KB 就是 1K 个页,这 1K 个页也需要一个表进行管理,我们称为页目录表,这个页目录表里面有 1K 项,每项 4 个字节,页目录表大小也是 4K。
页目录有 1K 项,用 10 位就可以表示访问页目录的哪一项。这一项其实对应的是一整页的页表项,也即 4K 的页表项。每个页表项也是 4 个字节,因而一整页的页表项定位其实需要 1K 个地址。再用 10 位就可以表示访问页表项的哪一项。
页表项中的一项对应的就是一个页,是存放数据的页,这个页的大小是4K,用12位可以定位这个页内的任何一个位置。
这样加起来正好 32 位,也就是用前 10 位定位到页目录表中的一项。将这一项对应的页表取出来共 1k项,再用中间 10 位定位到页表中的一项,将这一项对应的存放数据的页取出来,再用最后12位定位到页中的具体位置访问数据(页内偏移)。
这样虽然看上去一个为访问一个进程的内存空间。所需要的消耗的虚拟地址空间更大了,但其实我们并不会给每个进程分配这么大的内存空间。所以其实页表项虽然可以有1M个,即我们为进程要分配1M个数据页的话,每个4byte总共4MB。页目录项最多1k个,每个4byte总共4kb。即4Mb+4kb。<< 400Mb
但综上,如果只给进程分配了一个数据页的话,即4kb的内存,那我们理论上也只需要4byte的页表项即1个页表项就能定位到该内存地址。但由于页目录项,1个页目录项单位对应页表项1kb个。所以我们需要的最少其实是1个页目录项,也即,1个页目录项->4kb页表项。即4kb的空间来表示内存空间即可。
当然对于 64 位的系统,两级肯定不够了,就变成了四级目录,分别是全局页目录项PGD、上层页目录项PUD、中间页目录项 PMD和页表项 PTE。
内存管理系统精细化为下面三件事情:
- 虚拟内存空间的管理,将虚拟内存分成大小相等的页;
- 物理内存的管理,将物理内存分成大小相等的页;
- 内存映射,将虚拟内存页和物理内存页映射起来,并且在内存紧张的时候可以换出到硬盘中。
整个进程的虚拟内存空间要一分为二,一部分是用户态地址空间,一部分是内核态地址空间。对于 32位系统,最大能够寻址2^32=4G,其中用户态虚拟地址空间是 3G,内核态是 1G。对于 64 位系统,虚拟地址只使用了 48 位。其中,用户态空间和内核空间都是 128T。内核空间和用户空间之间隔着很大的空隙,以此来进行隔离。
我们知道,这么大的虚拟地址空间,不可能都有真实内存对应,所以这里是映射的数目。当内存吃紧的时候,有些页可以换出到硬盘上,有的页因为比较重要,不能换出。虚拟内存区域可以映射到物理内存,也可以映射到文件,映射到物理内存的时候称为匿名映射。
进程的用户态内存空间中各数据结构的结构图及操作系统对应函数:
内核态的虚拟空间和某一个进程没有关系,所有进程通过系统调用进入到内核之后,看到的虚拟地址空间都是一样的。内核态所以进程公用虚拟地址空间。
进程状态在64位操作系统下关系:
CPU访问内存的两种模式:
- SMP(对称多处理器模式):CPU 会有多个,在总线的一侧。所有的内存条组成一大片内存,在总线的另一侧,所有的 CPU 访问内存都要过总线,而且距离都是一样的。缺点是,总线会成为瓶颈,因为所有的数据访问都走它。
- NUMA(非一致内存访问):在这种模式下,内存不是一整块。每个CPU都有自己的本地内存,CPU访问本地内存不用过总线,因而速度要快很多,每个CPU和内存在一起,称为一个NUMA节点。但是,在本地内存不足的情况下,每个CPU都可以去另外的NUMA节点申请内存,这个时候访问延时就会比较长。NUMA往往是非连续内存模型。以上也就是L1,L2,L3CPU三季缓存的场景。
页面换出
每个进程都有自己的虚拟地址空间,无论是32位还是64位,虚拟地址空间都非常大,物理内存不可能有这么多的空间放得下。所以,一般情况下,页面只有在被使用的时候,才会放在物理内存中。++如果过了一段时间不被使用++,即便用户进程并没有释放它,物理内存管理会将这些物理内存中的页面换出到硬盘上去;将空出的物理内存,交给活跃的进程去使用
页面换出的触发时机:
- 分配内存的时候,发现没有地方了,就试图回收一下。
- 内核线程 kswapd,当内存紧张时,会检查一下内存,看看是否需要换出一些内存页。不紧张时,无限循环,并不做实际处理。基本原理是通过LRU算法来找出最不活跃的内存页来进行换出。
文件系统
当使用系统调用 open 打开一个文件时,操作系统会创建一些数据结构来表示这个被打开的文件。在进程中,我们会为这个打开的文件分配一个文件描述符 fd,文件描述符,就是用来区分一个进程打开的多个文件的。它的作用域就是当前进程,出了当前进程这个文件描述符就没有意义了。我们对这个文件的所有操作都要靠这个 fd,包括最后关闭文件。
磁盘上下多层分多个盘片,每一层里分多个磁道,每个磁道分多个扇区,每个扇区是 512 个字节。硬盘会分成相同大小的单元,称为块。一块的大小是扇区大小的整数倍,默认是 4K。
一种特殊的文件格式,硬链接和软链接。ln -s 创建的是软链接,不带 -s 创建的是硬链接
- 硬链接与原始文件共用一个 inode 的,但是 inode 是不跨文件系统的,每个文件系统都有自己的 inode 列表,因而硬链接是没有办法跨文件系统的。
- 软链接不同,软链接相当于重新创建了一个文件。这个文件也有独立的 inode,只不过打开这个文件看里面内容的时候,内容指向另外的一个文件。这就很灵活了。我们可以跨文件系统,甚至目标文件被删除了,链接文件还是在的,只不过指向的文件找不到了而已。
缓存其实就是内存中的一块空间。因为内存比硬盘快得多,Linux为了改进性能,有时候会选择不直接操作硬盘,而是读写都在内存中,然后批量读取或者写入硬盘。一旦能够命中内存,读写效率就会大幅度提高。
根据是否使用内存做缓存,可以把文件的 I/O 操作分为两种类型。
- 缓存 I/O:大多数文件系统的默认 I/O 操作都是缓存 I/O。对于读操作来讲,操作系统会先检查,内核的缓冲区有没有需要的数据。如果已经缓存了,那就直接从缓存中返回;否则从磁盘中读取,然后缓存在操作系统的缓存中。对于写操作来讲,操作系统会先将数据从用户空间复制到内核空间的缓存中。这时对用户程序来说,写操作就已经完成。至于什么时候再写到磁盘中由操作系统决定,除非显式地调用了 sync 同步命令。 文件编辑中
Ctrl S
- 直接 IO:应用程序直接访问磁盘数据,而不经过内核缓冲区,从而减少了在内核缓存和用户程序之间数据复制。
输入输出设备
CPU 并不直接和设备打交道,它们中间有一个叫作设备控制器的组件,例如硬盘有磁盘控制器、USB有USB控制器、显示器有视频控制器等。这些控制器就像不同地区的代理商一样,它们知道如何应对硬盘、鼠标、键盘、显示器等不同类型的外接设备的行为。
这些控制器有它自己的寄存器。这样CPU就可以通过写这些寄存器,对控制器下发指令,通过读这些寄存器,查看控制器对于设备的操作状态。CPU 对于寄存器的读写,要比直接控制硬件,要标准和轻松很多。
输入输出设备大致可以分为两类:块设备和字符设备。
- 块设备将信息存储在固定大小的块中,每个块都有自己的地址。硬盘就是常见的块设备。
- 字符设备发送或接收的是字节流。而不用考虑任何块结构,没有办法寻址。鼠标就是常见的字符设备
由于块设备传输的数据量比较大,++控制器里往往会有缓冲区。CPU写入缓冲区的数据攒够一部分,才会发给设备++++。CPU读取的数据,也需要在缓冲区攒够一部分,才拷贝到内存++
CPU同控制器的寄存器和数据缓冲区进行通信的方式
- 每个控制寄存器被分配一个 I/O 端口,通过特殊的汇编指令操作这些寄存器。
- 数据缓冲区,可内存映射I/O,可以分配一段内存空间给它,就像读写内存一样读写数据缓冲区。
CPU和设备读取数据交互的方式,控制器的寄存器一般会有状态标志位,可以通过检测状态标志位,来确定输入或者输出操作是否完成。
- 轮询等待:就是一直查,一直查,直到完成。当然这种方式很不好。
- 中断的方式:通知操作系统输入输出操作已经完成。
为了响应中断,我们一般会有一个硬件的中断控制器,当设备完成任务后触发中断到中断控制器,中断控制器就通知 CPU,一个中断产生了,CPU 需要停下当前手里的事情来处理中断。
有的设备需要读取或者写入大量数据。如果所有过程都让CPU协调的话,就需要占用CPU大量的时间,比方说,磁盘就是这样的。这种类型的设备需要支持 DMA 功能,也就是说,允许设备在CPU不参与的情况下,能够自行完成对内存的读写。实现 DMA 机制需要有个 DMA 控制器帮你的 CPU 来做协调。
CPU 只需要对 DMA控制器下指令,说它想读取多少数据,放在内存的某个地方就可以了,接下来DMA控制器会发指令给磁盘控制器,读取磁盘上的数据到指定的内存位置,传输完毕之后,DMA 控制器发中断通知 CPU 指令完成,CPU 就可以直接用内存里面现成的数据了。
这里需要注意的是,设备控制器不属于操作系统的一部分,但是设备驱动程序属于操作系统的一部分。操作系统的内核代码可以像调用本地代码一样调用驱动程序的代码,而驱动程序的代码需要发出特殊的面向设备控制器的指令,才能操作设备控制器。一个设备驱动程序初始化的时候,要先注册一个该设备的中断处理函数
进程间通信
- 管道模型(命令行中常用):前一个命令的输出,作为后一个命令的输入。管道是一种单向传输数据的机制,它其实是一段缓存,里面的数据只能从一端写入,从另一端读出。
ps -ef | grep 关键字 | awk '{print $2}' | xargs kill -9
“|” 表示的管道称为匿名管道。竖线代表的管道随着命令的执行自动创建、自动销毁。
- 消息队列模型(不常用):发送数据时,会分成一个一个独立的数据单元,也就是消息体,每个消息体都是固定大小的存储块,在字节流上不连续。
- 共享内存模型(常用):弥补消息队列需要数据传递,消息传送还是不及时。如果一个进程想要访问这一段共享内存,需要将这个内存加载到自己的虚拟地址空间的某个位置。 需要和信号量一起使用。
- 信号量(常用):防止共享内存同一时间,两进程写同一内存空间产生冲突的情况。所以,信号量和共享内存往往要配合使用。信号量其实是一个计数器,主要用于实现进程间的互斥与同步,而不是用于存储进程间通信数据。
- 信号(常用):信号可以在任何时候发送给某一进程,进程需要为这个信号配置信号处理函数。当某个信号发生的时候,就默认执行这个函数就可以了。
Linux 操作系统中,为了响应各种各样的事件,也是定义了非常多的信号。
管道:无论是匿名管道,还是命名管道,在内核都是一个文件。只要是文件就要有一个 inode。管道的inode++对应内存里面的缓存++。写入一个 pipe 就是从 struct file 结构找到缓存写入,读取一个 pipe 就是从 struct file 结构找到缓存读出。
网络
一台机器将自己想要表达的内容,按照某种约定好的格式发送出去,当另外一台机器收到这些信息后,也能够按照约定好的格式解析出来,从而准确、可靠地获得发送方想要表达的内容。这种约定好的格式就是网络协议。
网络协议模型有两种,一种是OSI 的标准七层模型,一种是业界标准的 TCP/IP 模型。其中也有五层模型,其实就是删除了表示层和会话层。网络分层的原因是,网络环境过于复杂,不是一个能够集中控制的体系。
网络层
ip地址:192.168.1.100/24
,斜杠前面是 IP 地址,这个地址被.
分隔为四个部分,每个部分 8 位,总共是 32 位。斜线后面 24 的意思是,32位中,前24位是网络号,后8位是主机号。IP地址类似互联网上的邮寄地址,是有全局定位功能的。
路由协议:将网络包从一个网络转发给另一个网络的设备称为路由器。网络包从一个起始的 IP 地址,沿着路由协议指的道儿,经过多个网络,通过多次路由器转发,到达目标 IP 地址。
数据链路层
第二层。也叫MAC层,MAC主要做的就是网络包在本地网络中的服务器之间定位及通信的机制。所谓MAC,就是每个网卡都有的唯一的硬件地址(不绝对唯一,相对大概率唯一即可,类比UUID)。这虽然也是一个地址,但是这个地址是没有在整个互联网中全局定位功能的,只能在本地网络中通过ARP协议来定位。
MAC 地址的定位功能局限在一个网络里面,也即同一个网络号下的 IP 地址之间,可以通过 MAC 进行定位和通信。从 IP 地址获取 MAC 地址要通过 ARP 协议,是通过在本地发送广播包,获得的 MAC 地址。MAC 地址的作用范围不能出本地网络,所以一旦跨网络通信,虽然 IP 地址保持不变,但是 MAC 地址每经过一个路由器就要换一次。
如图服务器 A 发送网络包给服务器B,原IP地址始终是192.168.1.100,目标IP地址始终是192.168.2.100,但是在网络 1 里面,原 MAC 地址是 MAC1,目标MAC地址是路由器的MAC2。路由器内部也从MAC2转发到了MAC3,路由器转发之后,原 MAC 地址是路由器的 MAC3,目标 MAC 地址是 MAC4。
传输层
第四层,传输层,这里有两个著名的协议TCP 和 UDP。在 IP 层的代码逻辑中,仅仅负责数据从一个 IP 地址发送给另一个 IP 地址,丢包、乱序、重传、拥塞,这些 IP 层都不管。处理这些问题的代码逻辑写在了传输层的 TCP 协议里面。 从第一层到第三层都不可靠,网络包说丢就丢,是 TCP 这一层通过各种编号、重传等机制,让本来不可靠的网络对于更上层来讲,变得“看起来”可靠。
二层到四层都是在 Linux 内核里面处理的,应用层例如浏览器、Nginx、Tomcat 都是用户态的。内核里面对于网络包的处理是不区分应用的。从四层再往上,就需要区分网络包发给哪个应用。在传输层的 TCP 和 UDP 协议里面,都有端口的概念,不同的应用监听不同的端口。
网络调用时通过操作系统的socket来进行的。在网络协议纸上,用户态的应用层和内核态进行互通就需要借助系统调用socket来进行。网络分完层之后,对于数据包的发送,就是层层封装的过程。
如图在 Linux 服务器 B 上部署的服务端 Nginx 和 Tomcat,都是通过 Socket 监听 80 和 8080 端口。这个时候,内核的数据结构就知道了。如果遇到发送到这两个端口的,就发送给这两个进程。在 Linux 服务器 A 上的客户端,打开一个 Firefox 连接 Ngnix。也是通过 Socket,客户端会被分配一个随机端口 12345。同理,打开一个 Chrome 连接 Tomcat,同样通过 Socket 分配随机端口 12346。
在客户端浏览器,我们将请求封装为 HTTP 协议,通过 Socket 发送到内核。内核的网络协议栈里面,在 TCP 层创建用于维护连接、序列号、重传、拥塞控制的数据结构,将 HTTP 包加上 TCP 头,发送给 IP 层,IP 层加上 IP 头,发送给 MAC 层,MAC 层加上 MAC 头,从硬件网卡由数字信号转化成光或电信号发出去。
交换机:网络包会先到达网络1的交换机。或称二层设备,这是因为,交换机只会处理到第二层,然后它会将网络包的 MAC 头拿下来,看看该网络包的目标MAC在该网络内的出口是哪里,对应关系都记忆在了交换机中ARP表中。发现目标MAC是在自己右面的网口,于是就从这个网口发出去。
路由器:网络包会到达中间的 Linux 路由器,它左面的网卡会收到网络包,发现 MAC 地址匹配,就交给 IP 层,在 IP 层根据 IP 头中的信息,在路由表中查找。下一跳在哪里,应该从哪个网口发出去。路由器也称为三层设备,因为它只会处理到第三层。
最终网络包会被转发到 Linux 服务器 B,它发现 MAC 地址匹配,就将 MAC 头取下来,交给上一层。IP 层发现 IP 地址匹配,将 IP 头取下来,交给上一层。TCP 层会根据 TCP 头中的序列号等信息,发现它是一个正确的网络包,就会将网络包++缓存起来++,等待应用层的读取。
应用层通过 Socket 监听某个端口,因而读取的时候,内核会根据 TCP 头中的端口号,将网络包发给相应的应用。HTTP 层的头和正文,是应用层来解析的。当应用层处理完 HTTP 的请求,会将结果仍然封装为 HTTP 的网络包,通过 Socket 接口,发送给内核。
内核会经过层层封装,从物理网口发送出去,经过网络 2 的交换机,Linux 路由器到达网络 1,经过网络 1 的交换机,到达 Linux 服务器 A。在 Linux 服务器 A 上,经过层层解封装,通过 socket 接口,根据客户端的随机端口号,发送给客户端的应用程序,浏览器。于是浏览器就能够显示出一个绚丽多彩的页面了。
集线器&交换机区别:总的来说是,广播和记忆分发,减少局域网链路中广播消息冲突。最开始的网络设备主要有集线器,交换器,路由器,分为对应着一层设备,二层设备,三层设备,可以想象一下,集线器和交换器是把多个独立的计算机连接在一个网路中,然后通过路由器连接多个网络来形成目前的主流的网络拓扑结构,在集线器和交换器上面有多个网口,这样多个计算机就可以简单的通过网线分别连接某一个网口就可以组成一个局域网来进行彼此交流,这个时候集线器工作机制很简单,就是简单的进行广播,这样所有的计算机都会收到,但是只有一个会进行回复,但是这种方式会造成链路中的消息的冲突严重从而影响我们交流的效率,尤其是如果计算机越来越多就更严重了,所以这个使用就产生了交换器,交换器有记忆功能,会根据消息的发送情况动态的进行学习,记录哪一个网口对应的计算机的mac地址,这样一来当再次发送的时候,只需要查询一下找到对应的网口进行直接交付就可以完成交流,从而避免了大量的广播消息冲突。这样通过集线器和交换器就可以完成在一个网络(同一个局域网)中的机器之间的通信。
Socket
socket 接口大多数情况下操作的是传输层,即两个主流的协议 TCP 和 UDP,更底层的协议不用它来操心。从本质上来讲,所谓的建立连接,其实是为了在客户端和服务端维护连接,而建立一定的数据结构来维护双方交互的状态,并用这样的数据结构来保证面向连接的特性。所谓的连接,就是两端数据结构状态的协同,两边的状态能够对得上。符合TCP协议的规则,就认为连接存在;两面状态对不上,连接就算断了。 数据结构属性的协同。
流量控制和拥塞控制其实就是根据收到的对端的网络包,调整两端数据结构的状态。TCP 协议的设计理论上认为,这样调整了数据结构的状态,就能进行流量控制和拥塞控制了。所谓的可靠,也是两端的数据结构做的事情。不丢失其实是数据结构在“点名”,顺序到达其实是数据结构在“排序”,面向数据流其实是数据结构将零散的包,按照顺序捏成一个流发给应用层。
TCP和UDP的区别:
- TCP 是面向连接的,UDP 是面向无连接的。
- TCP 提供可靠交付,无差错、不丢失、不重复、并且按序到达;UDP 不提供可靠交付,不保证不丢失,不保证按顺序到达。
- TCP 是面向字节流的,发送时发的是一个流,没头没尾;UDP 是面向数据报的,一个一个地发送。
- TCP 是可以提供流量控制和拥塞控制的,既防止对端被压垮,也防止网络被压垮。
无论是用 socket 操作 TCP,还是 UDP,首先都要调用 socket 函数。socket 函数用于创建一个 socket 的文件描述符,唯一标识一个 socket。 通信结束后,我们还要像关闭文件一样,关闭 socket。
int socket(int domain, int type, int protocol);
socket 函数有三个参数:
- domain:表示使用什么 IP 层协议。AF_INET 表示 IPv4,AF_INET6 表示 IPv6。
- type:表示 socket 类型。SOCK_STREAM,顾名思义就是 TCP 面向流的,SOCK_DGRAM 就是 UDP 面向数据报的,SOCK_RAW 可以直接操作 IP 层,或者非 TCP 和 UDP 的协议。例如 ICMP。
- protocol 表示的协议,包括 IPPROTO_TCP、IPPTOTO_UDP。
面向TCP:
TCP 的服务端要先监听一个端口,一般是先调用 bind 函数,给这个 socket 赋予一个端口和 IP 地址。服务端所在的服务器可能有多个网卡、多个地址,可以选择监听在一个地址,也可以监听 0.0.0.0 表示所有的地址都监听, 客户端要访问服务端,肯定事先要知道服务端的端口。客户端不需要 bind,因为浏览器,随机分配一个端口就可以了,只有你主动去连接别人,别人不会主动连接你,没有人关心客户端监听到了哪里。
如果在网络上传输超过 1 Byte 的类型,就要区分大端(Big Endian)和小端。最低位放在最后一个位置,我们叫作小端,最低位放在第一个位置,叫作大端。TCP/IP 栈是按照大端来设计的,而 x86 机器多按照小端来设计,因而发出去时需要做一个转换。
TCP链接:三次握手,其实就是将客户端和服务端的状态通过三次网络交互,达到初始状态是协同的状态。
服务端要调用 listen 进入 LISTEN 状态,等待客户端进行连接。
int listen(int sockfd, int backlog);
连接的建立过程,也即三次握手,是 TCP 层的动作,是在内核完成的,应用层不需要参与。
接着服务端只需要调用 accept,等待内核完成了至少一个连接的建立,才返回。如果没有一个连接完成了三次握手,accept 就一直等待;如果有多个客户端发起连接,并且在内核里面完成了多个三次握手,建立了多个连接,++这些连接会被放在一个队列里面++。++accept 会从队列里面取出一个来进行处理。如果想进一步处理其他连接,需要调用多次 accept,所以 accept 往往在一个循环里面++。
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
接下来,客户端可以通过 connect 函数发起连接。
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
在参数中指明要连接的IP地址和端口号,然后发起三次握手。内核会给客户端分配一个临时的端口。一旦握手成功,服务端的 accept 就会返回另一个 socket。这里需要注意的是,服务端监听的 socket 和真正用来传送数据的 socket,是两个 socket,一个叫作监听socket,一个叫作已连接socket。仔细一想这两个也必然不能为一个socket,功能和对应数据结构中的内容也不同。一个负责为服务端接受各种客户端发来的链接,一个负责为服务端和对应不同客户端的通信工作。成功连接建立之后,双方开始通过read和write函数来读写数据,就像往一个文件流里面写东西一样。
对于UDP:
UDP 是没有连接的,所以不需要三次握手,也就不需要调用 listen 和 connect,但是 UDP 的交互仍然需要 IP 地址和端口号,因而也需要 bind。 对于 UDP 来讲,没有所谓的连接维护,也没有所谓的连接的发起方和接收方,甚至都不存在客户端和服务端的概念,大家就都是客户端,也同时都是服务端。
只要有一个 socket,多台机器就可以任意通信,不存在哪两台机器是属于一个连接的概念。因此,每一个 UDP 的 socket 都需要 bind。每次通信时,调用 sendto 和 recvfrom,都要传入 IP 地址和端口。
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);
整个交互过程:
- 服务端和客户端都调用 socket,得到文件描述符;
- 服务端调用 listen,进行监听;
- 服务端调用 accept,等待客户端连接;
- 客户端调用 connect,连接服务端;
- 服务端 accept 返回用于传输的 socket 的文件描述符;
- 客户端调用 write 写入数据;
- 服务端调用 read 读取数据。
发送网络包
VFS->IP
MSS:Max Segment Size,最大分段大小
MTU:Maximum Transmission Unit,最大传输单元
cwnd:congestion window,拥塞窗口
slow start,慢启动
rwnd:receive window,滑动窗口
调用系统调用write函数后:声明了一个变量copied,初始化为0,这表示拷贝了多少数据。如果用户的数据没有发送完毕,就一直循环。循环里声明了一个copy变量,表示这次拷贝的数值,在循环的最后有copied+=copy,将每次拷贝的数量都加起来。用来记录用户发送了多少数据。接着:
- tcp_write_queue_tail 从 TCP 写入队列 sk_write_queue 中拿出最后一个 struct sk_buff,在这个写入队列中排满了要发送的 struct sk_buff,这里面只有最后一个,才会因为上次用户给的数据太少,而没有填满。
- tcp_send_mss 会计算 MSS。即在网络上传输的网络包的大小是有限制的,而这个限制在最底层开始就有。
- MTU是二层的一个定义。以以太网为例,MTU 为 1500 个 Byte,前面有 6 个 Byte 的目标 MAC 地址,6 个 Byte 的源 MAC 地址,2 个 Byte 的类型,后面有 4 个 Byte 的 CRC 校验,共 1518 个 Byte。
- 在 IP 层,一个 IP 数据报在以太网中传输,如果它的长度大于该 MTU 值,就要进行分片传输。在 TCP 层有个 MSS,等于 MTU 减去 IP 头,再减去 TCP 头。也就是,在不分片的情况下,TCP 里面放的最大内容。
- tcp_write_xmit 发送网络包:如果发送的网络包非常大,要进行分段,MSS限制。分段这个事情可以由协议栈代码在内核做,但是缺点是比较费 CPU,另一种方式是延迟到硬件网卡去做,需要网卡支持对大数据包进行自动分段,可以降低 CPU 负载。在接下来的函数中算出来要分成多个段,然后看是在协议栈的代码里面分好,还是等待到了底层网卡再分。
- cwnd:为了避免拼命发包,把网络塞满了,定义一个窗口的概念,在这个窗口之内的才能发送,超过这个窗口的就不能发送,来控制发送的频率。
- 窗口大小:一开始的窗口只有一个 mss 大小叫作 cwnd。一开始的增长速度的很快的,翻倍增长。一旦到达一个临界值 ssthresh,就变成线性增长,我们就称为拥塞避免。 但只有出现真正丢包的时候,才算真正拥塞的时候。而一旦丢包,两种处理方式
- 马上降回到一个mss,然后重复先翻倍再线性对的过程(有点儿无脑)。
- 第二种方式降到当前 cwnd 的一半,然后进行线性增长(还可以)。
- 在代码中,tcp_cwnd_test 会将当前的 snd_cwnd,减去已经在窗口里面尚未发送完毕的网络包,那就是剩下的可用的窗口大小cwnd_quota,也即就能发送这么多了。
- 窗口大小:一开始的窗口只有一个 mss 大小叫作 cwnd。一开始的增长速度的很快的,翻倍增长。一旦到达一个临界值 ssthresh,就变成线性增长,我们就称为拥塞避免。 但只有出现真正丢包的时候,才算真正拥塞的时候。而一旦丢包,两种处理方式
- receive window滑动窗口rwnd:拥塞窗口是为了怕把网络塞满,在出现丢包的时候减少发送速度,那么滑动窗口就是为了怕把接收方塞满,而控制发送速度。滑动窗口,其实就是接收方告诉发送方自己的网络包的接收能力,超过这个能力,我就受不了了。
- 因为滑动窗口的存在,将发送方的缓存分成了四个部分。
- 第一部分:发送了并且已经确认的。这部分是已经发送完毕的网络包,这部分没有用了,可以回收。
- 第二部分:发送了但尚未确认的。这部分,发送方要等待,万一发送不成功,还要重新发送,所以不能删除。
- 第三部分:没有发送,但是已经等待发送的。这部分是接收方空闲的能力,可以马上发送,接收方收得了。
- 第四部分:没有发送,并且暂时还不会发送的。这部分已经超过了接收方的接收能力,再发送接收方就不了了。
- 因为滑动窗口的存在,接收方的缓存也要分成了三个部分。在网络包的交互过程中,接收方会将第二部分的大小,作为 AdvertisedWindow 发送给发送方,发送方就可以根据他来调整发送速度了。
- 第一部分:接受并且确认过的任务。这部分完全接收成功了,可以交给应用层了。
- 第二部分:还没接收,但是马上就能接收的任务。这部分有的网络包到达了,但是还没确认,不算完全完毕,有的还没有到达,那就是接收方能够接受的最大的网络包数量。
- 第三部分:还没接收,也没法接收的任务。这部分已经超出接收方能力。
- 因为滑动窗口的存在,将发送方的缓存分成了四个部分。
-
tcp_transmit_skb,真的去发送一个网络包:填充 TCP 头
IP->MAC
FIB:Forwarding Information Base,转发信息表即路由表
ip_queue_xmit 函数开始
- 选取路由,也即我要发送这个包应该从哪个网卡出去,依据FIB。路由表可以有多个,一般会有一个主表
- 路由:路由就是在 Linux 服务器上的路由表里面配置的一条一条规则。这些规则大概是这样的:想访问某个网段,从某个网卡出去,下一跳是某个 IP
- 机器的路由表通过 ip route 命令查看。
# Linux服务器A default via 192.168.1.1 dev eth0 192.168.1.0/24 dev eth0 proto kernel scope link src 192.168.1.100 metric 100 # Linux服务器B default via 192.168.2.1 dev eth0 192.168.2.0/24 dev eth0 proto kernel scope link src 192.168.2.100 metric 100 # Linux服务器做路由器 192.168.1.0/24 dev eth0 proto kernel scope link src 192.168.1.1 192.168.2.0/24 dev eth1 proto kernel scope link src 192.168.2.1
对于两端的服务器来讲,我们没有太多路由可以选,但是对于中间的 Linux 服务器做路由器来讲,这里有两条路可以选,一个是往左面转发,一个是往右面转发,就需要路由表的查找。因为路由表要按照前缀进行查询,希望找到最长匹配的那一个,例如 192.168.2.0/24 和 192.168.0.0/16 都能匹配 192.168.2.100/24。但是,基于匹配规则应该使用 192.168.2.0/24 的这一条。基本原理是Trie树结构。找到了路由,就知道了应该从哪个网卡发出去。
-
准备 IP 层的头,往里面填充内容。
- 标识位里面设置是否允许分片 frag_off。如果不允许,而遇到 MTU 太小过不去的情况,就发送 ICMP 报错。
- TTL 是这个包的存活时间,为了防止一个 IP 包迷路以后一直存活下去,每经过一个路由器 TTL 都减一,减为零则“死去”。
- 设置 protocol,指的是更上层的协议,这里是 TCP。
- 调用 ip_local_out 发送 IP 包。内核如果需要通过某个网络接口发送数据包,都需要按照为这个接口配置的 qdisc(排队规则)把数据包加入队列。
- 最简单的qdisc是pfifo,它不对进入的数据包做任何的处理,数据包采用先入先出的方式通过队列。
- pfifo_fast 稍微复杂一些,它的队列包括三个波段(band)。在每个波段里面,使用先进先出规则。三个波段的优先级也不相同。band 0 的优先级最高,band 2 的最低。如果 band 0 里面有数据包,系统就不会处理 band 1 里面的数据包,band 1 和 band 2 之间也是一样。数据包是按照服务类型(Type of Service,TOS)被分配到三个波段里面的。TOS 是 IP 头里面的一个字段,代表了当前的包是高优先级的,还是低优先级的。
- 后续真正从队列中取出网络包进行发送的函数,如果发送不成功,会返回 NETDEV_TX_BUSY。这说明网络卡很忙,会重新放入队列。队列中的网络包取出后如果成功分发,是会被送到设备驱动层的。对应处理函数,会得到这个网卡对应的适配器,然后将其放入硬件网卡的队列中。
接收网络包
网卡作为一个硬件,接收到网络包,会触发一个中断通知操作系统。但为了防止CPU被无休止的中断打断,当一些网络包到来触发了中断,内核处理完这些网络包之后,我们可以先进入主动轮询poll网卡的方式,主动去接收到来的网络包。如果一直有,就一直处理,等处理告一段落,就返回干其他的事情。当再有下一批网络包到来的时候,再中断,再轮询poll。这样就会大大减少中断的数量,提升网络处理的效率,这种处理方式我们称为 NAPI。
网卡驱动程序初始化的时候会注册一个驱动,并创建一个struct net_device 表示这个网络设备,并且会为这个网络设备注册一个轮询 poll 函数。将来一旦出现网络包的时候,就是要通过它来轮询了。当一个网卡被激活的时候,会在这里面注册一个硬件的中断处理函数。
如果一个网络包到来,触发了硬件中断。会触发相关的中断处理函数,当它被调用的时候,中断是暂时关闭的。主要做的事情是有一个循环,在 poll_list 里面取出网络包到达的设备,然后调用 napi_poll 来轮询这些设备。在网络设备的驱动层,有一个用于接收网络包的 rx_ring。它是一个环,从网卡硬件接收的包会放在这个环里面。这个环里面的 buffer_info[]是一个数组,存放的是网络包的内容。
通过检索网络包中二层的头里面的protocol字段来判断这是一个什么包。如果是个IP包,则后续会调用IP包对应的处理函数。这些函数中会判断如果IP进行了分段,就重新进行组合。至此进入第三层。
具体流程:
- 硬件网卡接收到网络包之后,通过 DMA 技术,将网络包放入 Ring Buffer。
- 硬件网卡通过中断通知CPU新的网络包的到来。
- 网卡驱动程序会注册中断处理函数 ixgb_intr。
- 中断处理函数处理完需要暂时屏蔽中断的核心流程之后,通过软中断 NET_RX_SOFTIRQ 触发接下来的处理过程。
- NET_RX_SOFTIRQ 软中断处理函数 net_rx_action,net_rx_action 会调用 napi_poll,进而调用 ixgb_clean_rx_irq,从 Ring Buffer 中读取数据到内核 struct sk_buff。
- 调用 netif_receive_skb 进入内核网络协议栈,进行一些关于 VLAN 的二层逻辑处理后,调用 ip_rcv 进入三层 IP 层。
- 在 IP 层,会处理 iptables 规则,然后调用 ip_local_deliver,交给更上层 TCP 层。
- 在 TCP 层调用 tcp_v4_rcv。
tcp_v4_rcv 中,得到 TCP 的头,TCP 层是分状态的,状态被维护在数据结构 struct sock 里面,要根据 IP 地址以及 TCP 头里面的内容,找到这个包对应的 struct sock,从而得到这个包对应的连接的状态。后续需要根据不同的状态做不同的处理,CP_LISTEN、TCP_NEW_SYN_RECV 状态属于连接建立过程中。TCP_TIME_WAIT 状态是连接结束的时候的状态。
后续处理函数对于收到的网络包,要分情况进行处理。
- seq == tp->rcv_nxt,说明来的网络包正是我服务端期望的下一个网络包。这个时候我们判断用户进程是否也是正在等待读取,这种情况下,就直接将网络包拷贝给用户进程就可以了。如果用户进程没有正在等待读取,或者因为内存原因没有能够拷贝成功,会将网络包放入 sk_receive_queue 队列。
接下来,当前的网络包接收成功后,更新下一个期待的网络包序号。然后检测乱序队列中的包会不会因为这个新的网络包的到来,也能放入到 sk_receive_queue 队列中。乱序的包不能进入 sk_receive_queue 队列。因为一旦进入到这个队列,意味着可以发送给用户进程。 按照 TCP 的定义,用户进程应该是按顺序收到包的,没有排好序,就不能给用户进程。所以提前与当前序列到达的序列号更大的网络包会暂存到乱序队列中,直到前置网络包到达后,并且检测乱序队列的时候,才能将暂存于此的网络包放入sk_receive_queue 队列。
- 服务端期望网络包 5。但是,来了一个网络包3。情况是:那客户端就认为网络包 3 没有发送成功,于是又发送了一遍,这种情况下,要赶紧给客户端再发送一次 ACK,表示早就收到了。
- seq 不小于 rcv_nxt + tcp_receive_window。这说明客户端发送得太猛了。本来 seq 肯定应该在接收窗口里面的,这样服务端才来得及处理,结果现在超出了接收窗口,说明客户端一下子把服务端给塞满了。这种情况下,服务端不能再接收数据包了,只能发送 ACK了,在ACK中会将接收窗口为0的情况告知客户端,客户端就知道不能再发送了。这个时候双方只能交互窗口探测数据包,直到服务端因为用户进程把数据读走了,空出接收窗口,才能在ACK里面再次告诉客户端,又有窗口了,又能发送数据包了。
- seq 小于 rcv_nxt,但是 end_seq 大于 rcv_nxt,这说明从 seq 到 rcv_nxt 这部分网络包原来的 ACK 客户端没有收到,所以重新发送了一次,从 rcv_nxt 到 end_seq 时新发送的,可以放入 sk_receive_queue 队列。
当前四种情况都排除掉了,说明网络包一定是一个乱序包了。当接收的网络包进入各种队列之后,接下来就等待用户进程去读取它们了。通过系统调用read来读取该socket。
用户进程在读取socket的过程中,是根据receive_queue 队列、prequeue 队列和 backlog 队列这个优先级来进行的。把前一个队列处理完毕,才处理后一个队列。对应处理函数 tcp_recvmsg 里面有一个 while 循环,不断地读取网络包。会先处理 sk_receive_queue 队列。如果找到了网络包,将网络包拷贝到用户进程中,然后直接进入下一层循环继续读取处理。最后,哪里都没有网络包,我们只好调用 sk_wait_data,继续等待在哪里,等待网络包的到来。
总体顺序:
- 硬件网卡接收到网络包之后,通过 DMA 技术,将网络包放入 Ring Buffer;
- 硬件网卡通过中断通知 CPU 新的网络包的到来;
- 网卡驱动程序会注册中断处理函数 ixgb_intr;
- 中断处理函数处理完需要暂时屏蔽中断的核心流程之后,通过软中断 NET_RX_SOFTIRQ 触发接下来的处理过程;
- NET_RX_SOFTIRQ 软中断处理函数 net_rx_action,net_rx_action 会调用 napi_poll,进而调用 ixgb_clean_rx_irq,从 Ring Buffer 中读取数据到内核 struct sk_buff;
- 调用 netif_receive_skb 进入内核网络协议栈,进行一些关于 VLAN 的二层逻辑处理后,调用 ip_rcv 进入三层 IP 层;
- 在 IP 层,会处理 iptables 规则,然后调用 ip_local_deliver 交给更上层 TCP 层;
- 在 TCP 层调用 tcp_v4_rcv,这里面有三个队列需要处理,如果当前的 Socket 不是正在被读;取,则放入 backlog 队列,如果正在被读取,不需要很实时的话,则放入 prequeue 队列,其他情况调用 tcp_v4_do_rcv;
- 在 tcp_v4_do_rcv 中,如果是处于 TCP_ESTABLISHED 状态,调用 tcp_rcv_established,其他的状态,调用 tcp_rcv_state_process;
- 在 tcp_rcv_established 中,调用 tcp_data_queue,如果序列号能够接的上,则放入 sk_receive_queue 队列;如果序列号接不上,则暂时放入 out_of_order_queue 队列,等序列号能够接上的时候,再放入 sk_receive_queue 队列。