首先,本人没有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)

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

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

当用户进程调用了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)

用户进程发起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)数据从内核态拷贝到进制中,这两个阶段的。那对于阶段一,操作系统是如何准备的呢?如下图

为了方便理解,尽量简化技术细节,可以把接收数据的过程分为4步:
- NIC(网卡) 接收到数据,通过 DMA 方式写入内存(Ring Buffer 和 sk_buff)。
- NIC 发出中断请求(IRQ),告诉内核有新的数据过来了。
- Linux 内核响应中断,系统切换为内核态,处理 Interrupt Handler,从RingBuffer 拿出一个 Packet, 并处理协议栈,填充 Socket 并交给用户进程。
- 系统切换为用户态,用户进程处理数据内容。
再谈多路复用
目前有哪些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);
功能:轮询扫描多个描述符中的任一描述符是否发生响应,或经过一段时间后唤醒

其中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)

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优越的。