网络IO总结与分析

首先,本人没有linux、驱动开发经验。平时的工作语言是java、python,c、c++的知识也停留在大学时代。所以关于网络IO,我查阅了很多的文章才基本明白。文中可能会有错误,或者不足的地方,希望谅解。所以参考的文章在末尾都一一列出,大家也可以查阅。

网络io种类

  • blocking IO
  • nonblocking IO
  • IO multiplexing
  • signal driven IO
  • asynchronous IO
    由signal driven IO在实际中并不常用,所以主要介绍其余四种IO Model。

对于一个network IO (这里我们以read举例),它会涉及到两个系统对象,一个是调用这个IO的process (or thread),另一个就是系统内核(kernel)。当一个read操作发生时,它会经历两个阶段:
1)等待数据准备 (Waiting for the data to be ready)
2)将数据从内核拷贝到进程中(Copying the data from the kernel to the process)
记住这两点很重要,因为这些IO模型的区别就是在两个阶段上各有不同的情况。

阻塞IO(blocking IO)

image.png

blocking IO的特点就是在IO执行的两个阶段(等待数据和拷贝数据两个阶段)都被block了。

非阻塞IO(non-blocking IO)

image.png

non-blocking IO 在等待数据阶段是非阻塞的,但是需要一直轮训。但是在数据拷贝阶段同样也是阻塞的

多路复用IO(IO multiplexing)

image.png

当用户进程调用了select,那么整个进程会被block,而同时,kernel会“监视”所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回。这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程。
这个图和blocking IO的图其实并没有太大的不同,事实上还更差一些。因为这里需要使用两个系统调用(select和recvfrom),而blocking IO只调用了一个系统调用(recvfrom)。但是,用select的优势在于它可以同时处理多个connection。(多说一句:所以,如果处理的连接数不是很高的话,使用select/epoll的web server不一定比使用multi-threading + blocking IO的web server性能更好,可能延迟还更大。select/epoll的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。)
在多路复用模型中,对于每一个socket,一般都设置成为non-blocking,但是,如上图所示,整个用户的process其实是一直被block的。只不过process是被select这个函数block,而不是被socket IO给block。因此select()与非阻塞IO类似。

异步IO(Asynchronous I/O)

image.png

用户进程发起read操作之后,立刻就可以开始去做其它的事。而另一方面,从kernel的角度,当它受到一个asynchronous read之后,首先它会立刻返回,所以不会对用户进程产生任何block。然后,kernel会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel会给用户进程发送一个signal,告诉它read操作完成了。注意这里不用用户进程主动的去将数据从内核态拷贝到用户态

  • blocking与non-blocking区别
    1.调用blocking IO会一直block住对应的进程直到操作完成
    2.non-blocking IO在kernel还在准备数据的情况下会立刻返回
  • synchronous IO和asynchronous IO的区别
    Stevens给出的定义(其实是POSIX的定义)如下:

A synchronous I/O operation causes the requesting process to be blocked until that I/O operation completes;
An asynchronous I/O operation does not cause the requesting process to be blocked;

其中关键就在于是否将用户process阻塞。asynchronous IO则不一样,当进程发起IO操作之后,就直接返回再也不理睬了,直到kernel发送一个信号,告诉进程说IO完成。在这整个过程中,进程完全没有被block。按照这个定义,之前所述的blocking IO,non-blocking IO,IO multiplexing都属于synchronous IO

网卡接收数据的过程

文章开头提到,不同io的主要区别是如何处理(1)等待数据准备(2)数据从内核态拷贝到进制中,这两个阶段的。那对于阶段一,操作系统是如何准备的呢?如下图


image.png

为了方便理解,尽量简化技术细节,可以把接收数据的过程分为4步:

  1. NIC(网卡) 接收到数据,通过 DMA 方式写入内存(Ring Buffer 和 sk_buff)。
  2. NIC 发出中断请求(IRQ),告诉内核有新的数据过来了。
  3. Linux 内核响应中断,系统切换为内核态,处理 Interrupt Handler,从RingBuffer 拿出一个 Packet, 并处理协议栈,填充 Socket 并交给用户进程。
  4. 系统切换为用户态,用户进程处理数据内容。

再谈多路复用

目前有哪些IO多路复用的方案

  • Linux: select、poll、epoll
  • MacOS/FreeBSD: kqueue
  • Windows/Solaris: IOCP
    本文仅讨linux场景下的select、poll、epoll的使用和区别。上文有提到过,多路复用是用本来需要用户进程自已等待数据准备好(一阶段,会阻塞)委托给其他系统调用(就是这里的select、poll、epoll)。所以就减少了用户进程自已的等待时间和系统开销,那么就可以创建、维护更多的链接,从而提高系统性能。

select

select函数介绍:
int select(int maxfd, fd_set *readset, fd_set *writeset, fd_set *exceptset, const struct timeval *timeout);

功能:轮询扫描多个描述符中的任一描述符是否发生响应,或经过一段时间后唤醒

image.png

其中fd_set数据结构如下:

   #define __FD_SETSIZE    1024
   typedef __kernel_fd_set     fd_set;
   typedef struct {
       unsigned long fds_bits[__FD_SETSIZE / (8 * sizeof(long))];
   } 

所以我们看到,fd_set实际上一个long类型数组,大小等于1024/(8*4) = 32。如果数组的大小是32,那么是不是意味着内核最多只能监听32个文件标识符呢?肯定不是的,这里用到了bitmap的思路。实际是用long类型的每一位来表示一个文件描述符,一个long32个bit,那么一个long就能监听32个文件描述符,32个long就能监听1024个文件描述符。所以这是select的缺点,poll就是改进了这个缺点,采用了链表的方式来突破这个限制。其他和select一样,我们下面就不说了。如何把文件描述符添加大fd_set中呢?这里需要其他几个方法

/初始化描述符集  
void FD_ZERO(fd_set *fdset);  
  
//将一个描述符添加到描述符集  
void FD_SET(int fd, fd_set *fdset);  
  
//将一个描述符从描述符集中删除  
void FD_CLR(int fd, fd_set *fdset);  
  
//检测指定的描述符是否有事件发生  
int FD_ISSET(int fd, fd_set *fdset);  

下面我们来看一个例子(来自参考文献6)

while(1)  
{  
    fd_set rset;//创建一个描述符集rset  
    FD_ZERO(&rset);//对描述符集rset清零  
    FD_SET(0, &rset);//将描述符0加入到描述符集rset中  
    FD_SET(4, &rset);//将描述符4加入到描述符集rset中  
    FD_SET(5, &rset);//将描述符5加入到描述符集rset中  
      
    if(select(5+1, &rset, NULL, NULL, NULL) > 0)  
    {  
        if(FD_ISSET(0, &rset))  
        {  
            //描述符0可读及相应的处理代码  
        }  
          
        if(FD_ISSET(4, &rset))  
        {  
            //描述符4可读及相应的处理代码  
        }  
        if(FD_ISSET(5, &rset))  
        {  
            //描述符5可读及相应的处理代码  
        }  
    }  
}  

下面是另外一个例子(来自参考文献2)

image.png

epoll

epoll是用来改进select的缺点,缺点我下面会总结一下,epoll设计三个系统调用

int epoll_create(int size);

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
//epfd: 由 epoll_create 生成的epoll专用的文件描述符;
//op: 要进行的操作例如注册事件,可能的取值EPOLL_CTL_ADD 注册、EPOLL_CTL_MOD 修 改、EPOLL_CTL_DEL 删除
//fd: 关联的文件描述符
//event: 指向epoll_event的指针;
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

具体的含义如下:

(1)epoll_create调用后,内核帮我们做了两件事情

  • 在内核cache里建了个红黑树用于存储以后epoll_ctl传来的socket;
  • 建立一个list链表,用于存储准备就绪的事件

(2)epoll_ctl

  • 把socket放到epoll文件系统里file对象对应的红黑树上
  • 给内核中断处理程序注册一个回调函数,告诉内核,如果这个句柄的中断到了,就把它放到准备就绪list链表里

(3)epoll_wait

  • 察list链表里有没有数据。有数据就返回,没有数据就sleep,等到timeout时间到后即使链表没数据也返回。而且,通常情况下即使我们要监控百万计的句柄,大多一次也只返回很少量的准备就绪句柄而已,所以,epoll_wait仅需要从内核态copy少量的句柄到用户态而已。

总结如下:

一颗红黑树,一张准备就绪句柄链表,少量的内核cache,解决了大并发下的socket处理问题。

  • 执行epoll_create时,创建了红黑树和就绪链表;
  • 执行epoll_ctl时,如果增加socket句柄,则检查在红黑树中是否存在,存在立即返回,不存在则添加到树干上,然后向内核注册回调函数,用于当中断事件来临时向准备就绪链表中插入数据;
  • 执行epoll_wait时立刻返回准备就绪链表里的数据即可。

select、epoll对比

  • select 监听描述符有数量限制,epoll没有
  • select 将fd_set 传给内核,内核需要变量来将数据准备就绪的对应位置set为1,同样的,由于内核共用的fd_set, 用户进程同样也需要扫描fd_set来看那个socket准备好了,这里有两个问题
    1.时间复杂度O(n)
    2.每次都需要将fd_set 内存拷贝,如果连接几百万个(虽然一个fd_set是1024个,我们可以用多个fd_set嘛),那么空间消耗大 ;
    但是epoll是用的红黑树,时间复杂度低O(1),用了回调函数将准备就绪的文件描述符放到了一个链表中,一般情况下,这个链表比较小。所以空间复杂度也比较低
    所以epoll无论在时间复杂度还是空间复杂度上是都是比select优越的。

参考文献

  1. 5种网络IO模型
  2. 深入理解 epoll
  3. 一文看懂IO多路复用
  4. select poll epoll的区别
  5. select深入理解
  6. Linux网络编程——I/O复用之select详解
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容