网络IO模型

网络包接收流程

网络包接收流程.jpg

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/0ksoftirqd/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返回,应用程序读取数据 ;


网络包发送流程

网络包发送流程.jpg

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内核函数,正式来到内核协议栈网络层的处理;

  1. sk_buffer中的指针移动到IP头位置上,设置IP头 ;
  1. 执行netfilters过滤,过滤通过之后,如果数据大于 MTU的话,则执行分片 ;
  1. 检查Socket中是否有缓存路由表,如果没有的话,则查找路由项,并缓存到Socket中;接着在把路由表设置到sk_buffer中 ;

05.内核协议栈网络层的事情处理完后,现在发送流程进入了到了邻居子系统邻居子系统位于内核协议栈中的网络层网络接口层之间,用于发送ARP请求获取MAC地址,然后将sk_buffer中的指针移动到MAC头位置,填充MAC头


06.经过邻居子系统的处理,现在sk_buffer中已经封装了一个完整的数据帧,随后内核将sk_buffer交给网络设备子系统进行处理;

  1. 选择发送队列(RingBuffer),因为网卡拥有多个发送队列,所以在发送前需要选择一个发送队列
  1. sk_buffer添加到发送队列中;
  1. 循环从发送队列(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下的 epollMac 下的 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接收缓冲区中无数据,系统调用立马返回,并带有一个 EWOULDBLOCKEAGAIN错误,这个阶段用户线程不会阻塞,也不会让出CPU,而是会继续轮训直到Socket接收缓冲区中有数据为止; Socket接收缓冲区中有数据,用户线程在内核态会将内核空间中的数据拷贝到用户空间,注意:这个数据拷贝阶段,应用程序是阻塞的,当数据拷贝完成,系统调用返回 ;

非阻塞写: 当用户线程发起非阻塞send系统调用时,当发送缓冲区中没有足够的空间容纳全部发送数据时,非阻塞写的特点是能写多少写多少,写不下了,就立即返回,并将写入到发送缓冲区的字节数返回给应用程序,方便用户线程不断的轮训尝试将剩下的数据写入发送缓冲区中 ;

非阻塞IO模型:阻塞IO模型最大的问题就是一个线程只能处理一个连接,如果这个连接上没有数据的话,那么这个线程就只能阻塞在系统IO调用上,不能干其他的事情,这对系统资源来说,是一种极大的浪费,同时大量的线程上下文切换,也是一个巨大的系统开销 , 基于这个需求,第一种解决方案非阻塞IO就出现了 ;基于以上非阻塞IO的特点,我们就不必像阻塞IO那样为每个请求分配一个线程去处理连接上的读写了,我们可以利用一个线程或者很少的线程,去不断地轮询每个Socket的接收缓冲区是否有数据到达,如果没有数据,不必阻塞线程,而是接着去轮询下一个Socket接收缓冲区,直到轮询到数据后,处理连接上的读写或者交给业务线程池去处理),轮询线程则继续轮询其他的Socket接收缓冲区;


IO多路复用

select: select是操作系统内核提供给我们使用的一个系统调用,它解决了在非阻塞IO模型中需要不断的发起系统IO调用去轮询各个连接上的Socket接收缓冲区所带来的用户空间内核空间不断切换的系统开销,转而交给内核来帮我们完成:

  1. 首先用户线程在发起select系统调用的时候会阻塞在select系统调用上,此时用户线程从用户态切换到了内核态完成了一次上下文切换;
  1. 用户线程将需要监听的Socket对应的文件描述符fd数组通过select系统调用传递给内核,此时用户线程将用户空间中的文件描述符fd数组拷贝到内核空间;
  1. 当用户线程调用完select后开始进入阻塞状态,内核开始轮询遍历fd数组,查看fd对应的Socket接收缓冲区中是否有数据到来,如果有数据到来,则将fd对应BitMap的值设置为1,如果没有数据到来,则保持值为0
  1. 内核遍历一遍fd数组后,如果发现有些fd上有IO数据到来,则将修改后的fd数组返回给用户线程,此时会将fd数组从内核空间拷贝到用户空间
  1. 当内核将修改后的fd数组返回给用户线程后,用户线程解除阻塞,由用户线程开始遍历fd数组然后找出fd数组中值为1Socket文件描述符,最后对这些Socket发起系统调用读取数据 ;
  1. 由于内核在遍历的过程中已经修改了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相当于是改进版的selectselect中使用的文件描述符集合是采用的固定长度为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机制 ;


你可能感兴趣的:(网络IO模型)