网络包接收流程
01.当网络数据帧
通过网络传输到达网卡时,网卡会将网络数据帧
通过DMA
拷贝的方式放到DMA环形缓冲区RingBuffer
中;
环形缓冲区
RingBuffer
是网卡在启动的时候分配和初始化的环形缓冲队列;当RingBuffer
满的时候,新来的数据包就会被丢弃;我们可以通过ifconfig
命令查看网卡收发数据包的情况,其中overruns
数据项表示当RingBuffer
满时被丢弃的数据包,如果发现出现丢包情况,可以通过ethtool
命令来增大RingBuffer
长度 ;
02.当DMA
操作完成时,网卡会向CPU
发起一个硬中断
,告诉CPU
有网络数据到达;CPU
调用网卡驱动注册的硬中断响应程序
; 网卡硬中断响应程序
会为网络数据帧创建内核数据结构sk_buffer
,并将网络数据帧
拷贝到sk_buffer
中;然后发起软中断
请求,通知内核有新的网络数据帧到达 ;
缓冲区
sk_buff
,是一个维护网络帧结构的双向链表,链表中的每一个元素都是一个网络帧
;虽然TCP/IP
协议栈分了好几层,但上下不同层之间的传递,实际上只需要操作这个数据结构中的指针,而无需进行数据复制;
03.内核线程ksoftirqd
发现软中断
请求,调用网卡驱动注册的poll
函数;poll
函数将sk_buffer
中的网络数据包
送到内核协议栈
中注册的ip_rcv
函数中;
每个
CPU
会绑定一个ksoftirqd
内核线程专门用来处理软中断
响应;2个CPU
时,就会有ksoftirqd/0
和ksoftirqd/1
这两个内核线程 ;这里有个事情需要注意下: 网卡接收到数据后,当
DMA拷贝完成
时,向CPU发出硬中断
,这时哪个CPU
上响应了这个硬中断
,那么在网卡硬中断响应程序
中发出的软中断
请求也会在这个CPU
绑定的ksoftirqd
线程中响应;所以如果发现Linux
软中断,CPU
消耗都集中在一个核上的话,那么就需要调整硬中断的CPU
亲和性,来将硬中断打散到不同的CPU
核上去
04.在ip_rcv
函数即网络层中,取出数据包的IP头
,判断该数据包下一跳的走向,如果数据包是发送给本机的,则取出传输层的协议类型(TCP
或者UDP
),并去掉数据包的IP头
,将数据包交给传输层处理;
传输层的处理函数:
TCP协议
对应内核协议栈中注册的tcp_rcv函数
,UDP协议
对应内核协议栈中注册的udp_rcv
函数;
05.当我们采用的是TCP
协议时,数据包到达传输层时,会在内核协议栈中的tcp_rcv
函数处理,在tcp_rcv
函数中去掉TCP
头,根据四元组(源IP,源端口,目的IP,目的端口)
查找对应的Socket
,如果找到对应的Socket
则将网络数据包
中的传输数据拷贝到Socket
中的接收缓冲区
中;如果没有找到,则发送一个目标不可达的icmp
包;
06.当我们程序通过系统调用read
读取Socket接收缓冲区
中的数据时,如果接收缓冲区中没有数据,那么应用程序就会在系统调用上阻塞,直到Socket
接收缓冲区有数据,然后CPU
将内核空间
(Socket
接收缓冲区)的数据拷贝
到用户空间,最后系统调用read
返回,应用程序读取数据 ;
网络包发送流程
01.当我们在应用程序中调用send
系统调用发送数据时,由于是系统调用所以线程会发生一次用户态到内核态的转换,在内核中首先根据fd
将真正的Socket
找出,这个Socket
对象中记录着各种协议栈的函数地址,然后构造struct msghdr
对象,将用户需要发送的数据全部封装在这个struct msghdr
结构体中;
02.调用内核协议栈
函数inet_sendmsg
,发送流程进入内核协议栈
处理;在进入到内核协议栈
之后,内核会找到Socket
上的具体协议的发送函数;比如:我们使用的是TCP协议
,对应的TCP协议
发送函数是tcp_sendmsg
,如果是UDP协议
的话,对应的发送函数为udp_sendmsg
;
03.在TCP协议
的发送函数tcp_sendmsg
中,创建内核数据结构sk_buffer
,将struct msghdr
结构体中的发送数据拷贝到sk_buffer
中;调用tcp_write_queue_tail
函数获取Socket
发送队列中的队尾元素,将新创建的sk_buffer
添加到Socket
发送队列(双向链表
)的尾部;
发送流程走到这里,用户要发送的数据总算是从
用户空间
拷贝到了内核
中,这时虽然发送数据已经拷贝
到了内核Socket
中的发送队列中,但并不代表内核会开始发送,因为TCP协议
的流量控制
和拥塞控制
,用户要发送的数据包并不一定会立马被发送出去,需要符合TCP协议
的发送条件;如果没有达到发送条件,那么本次send
系统调用就会直接返回 ; 如果符合发送条件,则开始调用tcp_write_xmit
内核函数;在这个函数中,会循环获取Socket
发送队列中待发送的sk_buffer
,然后进行拥塞控制
以及滑动窗口
的管理, 将从Socket
发送队列中获取到的sk_buffer
重新拷贝一份,设置sk_buffer
副本中的TCP HEADER
;其实在sk_buffer
内部其实包含了网络协议中所有的header
,在设置TCP HEADER
的时候,只是把指针指向sk_buffer
的合适位置,后面再设置IP HEADER
的时候,在把指针移动一下就行,避免频繁的内存申请和拷贝,效率很高;
04.当设置完TCP头
后,内核协议栈
的传输层
的事情就做完了,下面通过调用ip_queue_xmit
内核函数,正式来到内核协议栈网络层
的处理;
- 将
sk_buffer
中的指针移动到IP头
位置上,设置IP
头 ;
- 执行
netfilters
过滤,过滤通过之后,如果数据大于MTU
的话,则执行分片 ;
- 检查
Socket
中是否有缓存路由表,如果没有的话,则查找路由项,并缓存到Socket
中;接着在把路由表设置到sk_buffer
中 ;
05.内核协议栈网络层
的事情处理完后,现在发送流程进入了到了邻居子系统
,邻居子系统
位于内核协议栈中的网络层
和网络接口层
之间,用于发送ARP请求
获取MAC地址
,然后将sk_buffer
中的指针移动到MAC头
位置,填充MAC头
;
06.经过邻居子系统
的处理,现在sk_buffer
中已经封装了一个完整的数据帧
,随后内核将sk_buffer
交给网络设备子系统
进行处理;
- 选择发送队列(
RingBuffer
),因为网卡拥有多个发送队列,所以在发送前需要选择一个发送队列
- 将
sk_buffer
添加到发送队列中;
- 循环从发送队列(
RingBuffer
)中取出sk_buffer
,调用内核函数sch_direct_xmit
发送数据,其中会调用网卡驱动程序来发送数据 ;
07.现在发送流程到了网卡真实发送数据的阶段,无论是用户线程的内核态还是触发NET_TX_SOFTIRQ
类型的软中断在发送数据的时候最终会调用到网卡的驱动程序函数dev_hard_start_xmit
来发送数据;在网卡驱动程序函数dev_hard_start_xmit
中会将sk_buffer
映射到网卡可访问的内存 DMA
区域,最终网卡驱动程序通过DMA
的方式将数据帧
通过物理网卡发送出去;
08.当数据发送完毕后,还有最后一项重要的工作,就是清理工作;数据发送完毕后,网卡设备会向CPU
发送一个硬中断,CPU
调用网卡驱动程序注册的硬中断响应程序
,在硬中断响应中触发NET_RX_SOFTIRQ
类型的软中断,在软中断的回调函数igb_poll
中清理释放 sk_buffer
,清理网卡发送队列(RingBuffer
),解除 DMA
映射;
无论硬 中断是因为有数据要接收,还是说发送完成通知,从硬中断触发的软中断都是
NET_RX_SOFTIRQ
; 这里释放清理的只是sk_buffer
的副本,真正的sk_buffer
现在还是存放在Socket
的发送队列中;它得等收到对方ACK
之后才会真正删除;
阻塞非阻塞
将接收网络包流程分为数据准备阶段
和数据拷贝阶段
;阻塞与非阻塞的区别主要发生在数据准备阶段
,同步与异步的区别主要发生在数据拷贝阶段
;
数据准备阶段:在这个阶段,网络数据包到达网卡,通过
DMA
的方式将数据包拷贝到内存中,然后经过硬中断,软中断,接着通过内核线程ksoftirqd
经过内核协议栈的处理,最终将数据发送到内核Socket
的接收缓冲区中;数据拷贝阶段:当数据到达内核
Socket
的接收缓冲区中时,此时数据存在于内核空间中,需要将数据拷贝到用户空间中,才能够被应用程序读取;
阻塞: 当应用程序发起系统调用read
时,线程从用户态
转为内核态
,读取内核Socket
的接收缓冲区中的网络数据;如果这时内核Socket
的接收缓冲区没有数据,那么线程就会一直等待,直到Socket
接收缓冲区有数据为止;随后将数据从内核空间拷贝到用户空间,系统调用read
返回 ;
非阻塞:当应用程序发起系统调用read
时,线程从用户态
转为内核态
,读取内核Socket
的接收缓冲区中的网络数据;如果这时内核Socket
的接收缓冲区没有数据,应用程序不会等待,系统调用直接返回错误标志EWOULDBLOCK
,直到Socket
接收缓冲区有数据为止;随后将数据从内核空间拷贝到用户空间,系统调用read
返回 ;
同步与异步
同步:在数据准备好后,是由用户线程的内核态
来执行第二阶段;所以应用程序会在第二阶段发生阻塞,直到数据从内核空间
拷贝到用户空间
,系统调用才会返回;Linux
下的 epoll
和Mac
下的 kqueue
都属于同步 IO
;
异步:在数据准备好后,是由内核
来执行第二阶段的数据拷贝操作;当内核
执行完第二阶段,会通知用户线程IO
操作已经完成,并将数据回调给用户线程;所以在异步模式下 数据准备阶段
和数据拷贝阶段
均是由内核
来完成,不会对应用程序造成任何阻塞;
网络IO模型
在进行网络IO
操作时,用什么样的IO
模型来读写数据将在很大程度上决定了网络框架的IO
性能,所以IO
模型的选择是构建一个高性能网络框架的基础,在《UNIX 网络编程
》一书中介绍了五种IO
模型:阻塞IO
,非阻塞IO
,IO多路复用
,信号驱动IO
,异步IO
,每一种IO
模型的出现都是对前一种的升级优化;
阻塞IO(BIO)
阻塞读: 当用户线程发起
read
系统调用,用户线程从用户态
切换到内核态
,在内核中去查看Socket
接收缓冲区是否有数据到来 ;Socket
接收缓冲区中有数据 ,则用户线程在内核态将内核空间中的数据拷贝到用户空间,系统IO
调用返回;Socket
接收缓冲区中无数据,则用户线程让出CPU
,进入阻塞状态,当数据到达Socket
接收缓冲区后,内核唤醒阻塞状态中的用户线程进入就绪状态,随后经过CPU
的调度获取到CPU quota
进入运行状态,将内核空间的数据拷贝到用户空间,随后系统调用返回;阻塞写: 当用户线程发起
send
系统调用时,用户线程从用户态切换到内核态,将发送数据从用户空间拷贝到内核空间中的Socket
发送缓冲区中; 当Socket
发送缓冲区能够容纳下发送数据时,用户线程会将全部的发送数据写入Socket
缓冲区,然后执行后续流程,最后返回; 当Socket
发送缓冲区空间不够,无法容纳下全部发送数据时,用户线程让出CPU
,进入阻塞状态,直到Socket
发送缓冲区能够容纳下全部发送数据时,内核唤醒用户线程,执行后续发送流程,最后返回;
阻塞IO
模型:由于阻塞IO
的读写特点,所以导致在阻塞IO
模型下,每个请求都需要被一个独立的线程处理;一个线程在同一时刻只能与一个连接绑定,来一个请求,服务端就需要创建一个线程用来处理请求,当客户端请求的并发量突然增大时,服务端在一瞬间就会创建出大量的线程,而创建线程是需要系统资源开销的,这样一来就会一瞬间占用大量的系统资源;如果客户端创建好连接后,但是一直不发数据,那么在空闲的这段时间内,服务端线程就会一直处于阻塞状态,无法干其他的事情,CPU
也无法得到充分的发挥,同时还会导致大量线程切换的开销;
非阻塞IO(NIO)
非阻塞读: 当用户线程发起非阻塞
read
系统调用时,用户线程从用户态转为内核态,在内核中去查看Socket
接收缓冲区是否有数据到来;Socket
接收缓冲区中无数据,系统调用立马返回,并带有一个EWOULDBLOCK
或EAGAIN
错误,这个阶段用户线程不会阻塞,也不会让出CPU
,而是会继续轮训直到Socket
接收缓冲区中有数据为止;Socket
接收缓冲区中有数据,用户线程在内核态会将内核空间中的数据拷贝到用户空间,注意:这个数据拷贝阶段,应用程序是阻塞的,当数据拷贝完成,系统调用返回 ;非阻塞写: 当用户线程发起非阻塞
send
系统调用时,当发送缓冲区中没有足够的空间容纳全部发送数据时,非阻塞写的特点是能写多少写多少,写不下了,就立即返回,并将写入到发送缓冲区的字节数返回给应用程序,方便用户线程不断的轮训尝试将剩下的数据写入发送缓冲区中 ;
非阻塞IO
模型:阻塞IO
模型最大的问题就是一个线程只能处理一个连接,如果这个连接上没有数据的话,那么这个线程就只能阻塞在系统IO
调用上,不能干其他的事情,这对系统资源来说,是一种极大的浪费,同时大量的线程上下文切换,也是一个巨大的系统开销 , 基于这个需求,第一种解决方案非阻塞IO
就出现了 ;基于以上非阻塞IO
的特点,我们就不必像阻塞IO
那样为每个请求分配一个线程去处理连接上的读写了,我们可以利用一个线程或者很少的线程,去不断地轮询每个Socket
的接收缓冲区是否有数据到达,如果没有数据,不必阻塞线程,而是接着去轮询下一个Socket
接收缓冲区,直到轮询到数据后,处理连接上的读写或者交给业务线程池去处理),轮询线程则继续轮询其他的Socket
接收缓冲区;
IO多路复用
select:
select
是操作系统内核提供给我们使用的一个系统调用,它解决了在非阻塞IO
模型中需要不断的发起系统IO
调用去轮询各个连接上的Socket
接收缓冲区所带来的用户空间
与内核空间
不断切换的系统开销,转而交给内核来帮我们完成:
- 首先用户线程在发起
select
系统调用的时候会阻塞在select
系统调用上,此时用户线程从用户态
切换到了内核态
完成了一次上下文切换;
- 用户线程将需要监听的
Socket
对应的文件描述符fd
数组通过select
系统调用传递给内核,此时用户线程将用户空间中的文件描述符fd
数组拷贝到内核空间;
- 当用户线程调用完
select
后开始进入阻塞状态,内核开始轮询遍历fd
数组,查看fd
对应的Socket
接收缓冲区中是否有数据到来,如果有数据到来,则将fd
对应BitMap
的值设置为1
,如果没有数据到来,则保持值为0
;
- 内核遍历一遍
fd
数组后,如果发现有些fd
上有IO
数据到来,则将修改后的fd
数组返回给用户线程,此时会将fd
数组从内核空间
拷贝到用户空间
;
- 当内核将修改后的
fd
数组返回给用户线程后,用户线程解除阻塞,由用户线程开始遍历fd
数组然后找出fd
数组中值为1
的Socket
文件描述符,最后对这些Socket
发起系统调用读取数据 ;
- 由于内核在遍历的过程中已经修改了
fd
数组,所以在用户线程遍历完fd
数组后获取到IO
就绪的Socket
后,就需要重置
fd数组,并重新调用select
传入重置后的fd
数组,让内核发起新的一轮遍历轮询 ;虽然
select
解决了非阻塞IO
模型中频繁发起系统调用的问题,但是在整个select
工作过程中,我们还是看出了select
有些不足的地方:(1)在发起select
系统调用以及返回时,用户线程各发生了一次用户态
到内核态
以及内核态
到用户态
的上下文切换开销,发生2次上下文切换;(2)在发起select
系统调用以及返回时,用户线程在内核态需要将文件描述符集合从用户空间拷贝到内核空间,以及在内核修改完文件描述符集合后,又要将它从内核空间拷贝到用户空间,发生2次文件描述符集合的拷贝;(3)虽然由原来在用户空间发起轮询优化成了在内核空间发起轮询但select
不会告诉用户线程到底是哪些Socket
上发生了IO
就绪事件,只是对IO
就绪的Socket
作了标记,用户线程依然要遍历文件描述符集合去查找具体IO
就绪的Socket
,时间复杂度依然为O(n)
;(4)内核会对原始的文件描述符集合进行修改,导致每次在用户空间重新发起select
调用时,都需要对文件描述符集合进行重置; (5)BitMap
结构的文件描述符集合,长度为固定的1024
,所以只能监听0~1023
的文件描述符 ;(6)select
系统调用不是线程安全的;
poll:
poll
相当于是改进版的select
,select
中使用的文件描述符集合是采用的固定长度为1024的BitMap
结构的fd_set
,而poll
换成了pollfd
结构没有固定长度的数组,这样就没有了最大描述符数量的限制 ;poll
只是改进了select
只能监听1024
个文件描述符的数量限制,但是并没有在性能方面做出改进,和select
上本质并没有多大差别 , 依然无法解决C10K
问题 ;
epoll:
IO多路复用模型:虽然非阻塞IO
模型与阻塞IO
模型相比,减少了很大一部分的资源消耗和系统开销,但是它仍然有很大的性能问题,因为在非阻塞IO
模型下,需要用户线程去不断地发起系统调用去轮训Socket
接收缓冲区,这就需要用户线程不断地从用户态切换到内核态,内核态切换到用户态,随着并发量的增大,这个上下文切换的开销也是巨大的,所以单纯的非阻塞IO
模型还是无法适用于高并发的场景,只能适用于C10K
以下的场景;这就需要操作系统的内核来支持这样的操作,我们可以把频繁的轮询操作交给操作系统内核来替我们完成,这样就避免了在用户空间频繁的去使用系统调用来轮询所带来的性能开销;
信号驱动IO
在信号驱动IO
模型下,用户进程操作通过系统调用 sigaction
函数发起一个IO
请求,在对应的socket
注册一个信号回调,此时不阻塞用户进程,进程会继续工作,当内核数据就绪时,内核就为该进程生成一个 SIGIO
信号,通过信号回调通知进程进行相关IO
操作 ; 信号驱动 IO
模型 相比于前三种 IO
模型,实现了在等待数据就绪时,进程不被阻塞,主循环可以继续工作,所以理论上性能更佳 ; 但是实际上,使用TCP
协议通信时,信号驱动IO
模型几乎不会被采用,因为信号IO
在大量IO
操作时可能会因为信号队列溢出导致没法通知 , SIGIO
信号是一种 Unix
信号,信号没有附加信息,如果一个信号源有多种产生信号的原因,信号接收者就无法确定究竟发生了什么,而 TCP socket
生产的信号事件有七种之多,这样应用程序收到 SIGIO
,根本无从区分处理 ; 但信号驱动IO
模型可以用在 UDP
通信上,因为UDP 只有一个数据请求事件,这也就意味着在正常情况下 UDP 进程只要捕获 SIGIO
信号,就调用 read
系统调用读取到达的数据,如果出现异常,就返回一个异常错误 ;
这里需要注意的是信号驱动式
IO
模型依然是同步IO
,因为它虽然可以在等待数据的时候不被阻塞,也不会频繁的轮询,但是当数据就绪,内核信号通知后,用户进程依然要自己去读取数据,在数据拷贝阶段发生阻塞
异步IO(AIO)
异步IO
的系统调用需要操作系统内核来支持,目前只有Window
中的IOCP
实现了非常成熟的异步IO
机制 ;