网络IO管理
网络IO,会涉及到两个系统对象,一个是用户看见调用IO的进程或线程,另一个是内核空间的内核系统,比如发送IO操作read时,它会经历两个阶段:
- 等待数据准备就绪
- 将数据从内核拷贝到进程或线程中。
因为在以上两个阶段各有不同的情况,所以出现了多种网路IO模型。
一、五种IO网络模型
阻塞IO(blocking IO)
在 linux 中,默认情况下所有的 socket 都是 blocking。
一个典型的读操作流程:
当用户进程调用了 read 这个系统调用,kernel 就开始了 IO 的第一个阶段:准备数据。对于network io 来说,很多时候数据在一开始还没有到达(比如,还没有收到一个完整的数据包),这个时候 kernel 就要等待足够的数据到来。而在用户进程这边,整个进程会被阻塞。当 kernel一直等到数据准备好了,它就会将数据从 kernel 中拷贝到用户内存,然后 kernel 返回结果,用户进程才解除 block 的状态,重新运行起来。
所以,blocking IO 的特点就是在 IO 执行的两个阶段(等待数据和拷贝数据两个阶段)都被 block 了。
大部分的 socket 接口都是阻塞型的。所谓阻塞型接口是指系统调用(一般是 IO 接口)不返回调用结果并让当前线程一直阻塞,只有当该系统调用获得结果或者超时出错时才返回。
实际上,除非特别指定,几乎所有的 IO 接口 ( 包括 socket 接口 ) 都是阻塞型的。这给网络编程带来了一个很大的问题,如在调用 send()的同时,线程将被阻塞,在此期间,线程将无法执行任何运算或响应任何的网络请求。
一个简单的改进方案是在服务器端使用多线程(或多进程)。多线程(或多进程)的目的是让每个连接都拥有独立的线程(或进程),这样任何一个连接的阻塞都不会影响其他的连接。具体使用多进程还是多线程,并没有一个特定的模式。传统意义上,进程的开销要远远大于线程,所以如果需要同时为较多的客户机提供服务,则不推荐使用多进程;如果单个服务执行体需要消耗较多的 CPU 资源,譬如需要进行大规模或长时间的数据运算或文件访问,则进程较为安全。通常,使用 pthread_create ()创建新线程,fork()创建新进程。
对于进线程的管理我们可以使用“线程池”或“连接池”。所谓“池”始终有其上限,当请求大大超过上限时,“池”构成的系统对外界的响应并不比没有池的时候效果好多少。所以使用“池”必须考虑其面临的响应规模,并根据响应规模调整“池”的大小。
多线程模型可以方便高效的解决小规模的服务请求(C10K),但面对大规模的服务请求,多线程模型也会遇到瓶颈,可以用非阻塞接口来尝试解决这个问题。
非阻塞IO(non-blocking IO)
Linux 下,可以通过设置 socket 使其变为 non-blocking。当对一个 non-blocking socket 执行读操作时,流程是这个样子:
从图中可以看出,当用户进程发出 read 操作时,如果 kernel 中的数据还没有准备好,那么它并不会 block 用户进程,而是立刻返回一个 error。从用户进程角度讲 ,它发起一个read 操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个 error时,它就知道数据还没有准备好,于是它可以再次发送 read 操作。一旦 kernel 中的数据准备好了,并且又再次收到了用户进程的 system call,那么它马上就将数据拷贝到了用户内存,然后返回,所以,在非阻塞式 IO 中,用户进程其实是需要不断的主动询问 kernel数据准备好了没有。
在非阻塞状态下,recv() 接口在被调用后立即返回,返回值代表了不同的含义。如在本例中,
-
recv() 返回值大于 0,表示接受数据完毕,返回值即是接受到的字节数
-
recv() 返回 0,表示连接已经正常断开
-
recv() 返回 -1,且 errno 等于 EAGAIN,表示 recv 操作还没执行完成
-
recv() 返回 -1,且 errno 不等于 EAGAIN,表示 recv 操作遇到系统错误 errno
非阻塞的接口相比于阻塞型接口的显著差异在于,在被调用之后立即返回。使用如下的函数可以将某句柄 fd 设为非阻塞状态。
但是当 kernel 中数据准备好的时候,read 会将数据 从 kernel 拷贝到用户内存中,这个时候进程是被 block 了,在这段时间内进程是被 block 的。
尽管通过非阻塞IO我们可以在单个线程内实现对所有连接的数据接收工作,但我们绝不推荐上述模型,因为循环调用recv()将大幅度推高CPU的占用率。
此外在此方案中recv()更多的是起到检测“操作是否完成”的作用,实际上操作系统提供了更为高效的检测“操作是否完成”作用的接口,例如select()多路复用模式,可以一次检测多个连接是否活跃。
多路复用IO(IO multiplexing)
IO multiplexing又称事件驱动IO(event driven IO),比较典型的代表是select/epoll。我们都知道,select/epoll 的好处就在于单个 process 就可以同时处理多个网络连接的 IO。它的基本原理就是select/epoll 这个 function会不断的轮询所负责的所有 socket,当某个 socket 有数据到达了,就通知用户进程。它的流程如图:
当用户进程调用了 select,那么整个进程会被 block,而同时,kernel 会“监视”所有 select 负责的 socket,当任何一个 socket 中的数据准备好了,select 就会返回。这个时候用户进程再调用 read 操作,将数据从 kernel 拷贝到用户进程。
这个图和 blocking IO 的图其实并没有太大的不同,事实上还更差一些。因为这里需 要使用两个系统调用(select 和 read),而 blocking IO 只调用了一个系统调用(read)。 但是使用 select 以后最大的优势是用户可以在一个线程内同时处理多个 socket 的 IO 请 求。用户可以注册多个 socket,然后不断地调用 select 读取被激活的 socket,即可达到 在同一个线程内同时处理多个 IO 请求的目的。而在同步阻塞模型中,必须通过多线程的方 式才能达到这个目的。
所以,如果处理的连接数不是很高的话,使用 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 IO)
Linux 下的 asynchronous IO 用在磁盘 IO 读写操作,不用于网络 IO,从内核 2.6 版本才开始引 入。先看一下它的流程
用户进程发起 read 操作之后,立刻就可以开始去做其它的事。而另一方面,从 kernel 的角度,当它受到一个 asynchronous read 之后,首先它会立刻返回,所以不会对用户进 程产生任何 block。然后,kernel 会等待数据准备完成,然后将数据拷贝到用户内存,当 这一切都完成之后,kernel 会给用户进程发送一个 signal,告诉它 read 操作完成了。
异步 IO 是真 正非阻塞的,它不会对请求进程产生任何的阻塞,因此对高并发的网络服务器实现至关重要。
与上面的non-blocking IO相比,在 non-blocking IO 中,虽然进程大部分时间都不会被 block,但是它仍然要求进程去主动的 check, 并且当数据准备完成以后,也需要进程主动的再次调用 recvfrom 来将数据拷贝到用户内存。而 asynchronous IO 则完全不同。它就像是用户进程将整个 IO 操作交给了他人(kernel)完 成,然后他人做完后发信号通知。在此期间,用户进程不需要去检查 IO 操作的状态,也不 需要主动的去拷贝数据。
信号驱动 IO(signal driven I/O, SIGIO)
首先我们允许套接口进行信号驱动 I/O,并安装一个信号处理函数,进程继续运行并不阻 塞。当数据准备好时,进程会收到一个 SIGIO 信号,可以在信号处理函数中调用 I/O 操作函 数处理数据。当数据报准备好读取时,内核就为该进程产生一个 SIGIO 信号。我们随后既可 以在信号处理函数中调用 read 读取数据报,并通知主循环数据已准备好待处理,也可以立 即通知主循环,让它来读取数据报。无论如何处理 SIGIO 信号,这种模型的优势在于等待数 据报到达(第一阶段)期间,进程可以继续执行,不被阻塞。免去了 select 的阻塞与轮询,当 有活跃套接字时,由注册的 handler 处理。
二、 多路复用模型
select模型
1. select接口的原型
#include <sys/select.h>
FD_ZERO(int fd, fd_set* fds)
FD_SET(int fd, fd_set* fds)
FD_ISSET(int fd, fd_set* fds)
FD_CLR(int fd, fd_set* fds)
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout)
2. 参数介绍
这儿,fd_set类型可以简单的理解为按bit位标记句柄的队列,例如要在某 fd_set 中标记一个值为 16 的句柄,则该 fd_set 的第 16 个 bit 位被标记为 1。
具体的置位、验证 可使用 FD_SET、FD_ISSET 等宏实现。
在 select()函数中,readfds、writefds 和 exceptfds 同时作为输入参数和输出参数。
作为输入参数,readfds 应该标记所有的需要探测的“可读事件”的句柄,其中永远要包括那个监听socket的句柄(listenfd),writefds 和 exceptfds应该标记所有需要探测的“可写事件”和“错误事件”的句柄 ( 使用 FD_SET() 标记 )。
作为输出参数,readfds、writefds 和 exceptfds 中的保存了 select() 捕捉到的 所有事件的句柄值。程序员需要检查的所有的标记位 ( 使用 FD_ISSET()检查 ),以确定到底哪些句柄发生了事件。
select()函数返回三个描述符集合中包含的文件描述符中已经准备就绪的句柄的数量,如果在发生任何触发事情之前超时,则该数字可能为零。
3. 一问一答简单服务器程序
对于一问一答式的服务流程,如果 select()发现某句柄捕捉到了“可读事件”,服务器程序应及时做recv()操作,并根据接收到的数据准备好待发送数据, 并将对应的句柄值加入 writefds,准备下一次的“可写事件”的 select()探测。同样,如 果 select()发现某句柄捕捉到“可写事件”,则程序应及时做 send()操作,并准备好下一 次的“可读事件”探测准备。
相比其他模型,使用 select() 的事件驱动模型只用单线程(进程)执行,占用资源 少,不消耗太多 CPU,同时能够为多客户端提供服务。如果试图建立一个简单的事件驱动的服务器程序,这个模型有一定的参考价值。
4. select模型的优点
select具备很好的可移植性,很多操作系统都提供了select接口。
5. select模型的缺点
select()接口并不是实现“事件驱动”的最好选择。 因为当需要探测的句柄值较大时,select()接口本身需要消耗大量时间去轮询各个句柄。 很多操作系统提供了更为高效的接口,如linux提供了epoll。
当探测句柄较多时,select需要将这些句柄复制到内核系统中,会耗费大量时间。
同时,该模型将事件探测和事件响应夹杂在一起,一旦事件响应的执行体庞大,则对整个模型是灾难性的。
select有最大文件描述符数量的限制,由参数FD_SETSIZE管理。因此fd_set是一个定长的队列。
select可以突破C10K的限制,但是无法突破C1000K的限制。
poll模型
poll的机制与select类似,与select在本质上没有多大差别,管理多个描述符也是进行轮询,根据描述符的状态进行处理,但是poll基于链表来存储,没有最大文件描述符数量的限制。poll和select同样存在一个缺点就是,包含大量文件描述符的数组被整体复制于用户态和内核的地址空间之间,而不论这些文件描述符是否就绪,它的开销随着文件描述符数量的增加而线性增大。
1. poll接口的原型
#include <sys/poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout)
2. 参数介绍
- fds:指向一个结构体数组的第0个元素的指针,每个数组元素都是一个struct pollfd结构,用于指定测试某个给定的fd的条件
- nfds:表示fds结构体数组中有效元素的个数,即poll探查fds的最大数量
- timeout:表示poll函数的超时时间,单位是毫秒,可以输入-1
- 返回值:
- 返回值小于0,表示出错
- 返回值等于0,表示poll函数等待超时
- 返回值大于0,表示poll由于监听的文件描述符就绪返回,并且返回结果就是就绪的文件描述符的个数。
3. 结构体 struct pollfd
结构体成员:
struct pollfd { int fd; /* file descriptor */ short events; /* requested events */ short revents; /* returned events */ };
成员变量说明:
- fd:每一个 pollfd 结构体指定了一个被监视的文件描述符
- events:表示要告诉操作系统需要监测fd的事件(输入、输出、错误),每一个事件有多个取值
- revents:revents 域是文件描述符的操作结果事件,内核在调用返回时设置这个域。events 域中请求的任何事件都可能在 revents 域中返回。
events&revents的取值如下:
事件 | 描述 |
是否可作为输入 (events) |
是否可作为输出 (revents) |
POLLIN | 数据可读(包括普通数据&优先数据) | 是 | 是 |
POLLOUT | 数据可写(普通数据&优先数据) | 是 | 是 |
POLLRDNORM | 普通数据可读 | 是 | 是 |
POLLRDBAND | 优先级带数据可读(linux不支持) | 是 | 是 |
POLLPRI | 高优先级数据可读,比如TCP带外数据 | 是 | 是 |
POLLWRNORM | 普通数据可写 | 是 | 是 |
POLLWRBAND | 优先级带数据可写 | 是 | 是 |
POLLRDHUP | TCP连接被对端关闭,或者关闭了写操作,由GNU引入 | 是 | 是 |
POLLHUP | 挂起 | 否 | 是 |
POLLERR | 错误 | 否 | 是 |
POLLNVAL | 文件描述符没有打开 | 否 | 是 |
每个结构体的 events 域是由用户来设置,告诉内核我们关注的是什么,而 revents 域是返回时内核设置的,以说明对该描述符发生了什么事件
epoll模型
epoll是Linux系统提供的多路复用模型。它没有最大连接数的限制,它基于红黑树存储。它会把哪个流发生了怎样的I/O事件通知我们,相当于event poll。
1. epoll接口的原型
#include <sys/epoll.h>
int epoll_create(int size)
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout)
2. 参数介绍
2.1 epoll_create()
在使用epoll模型时,我们需要先调用epoll_create()创建一个epoll,它会返回该epoll的fd。在Linux 2.6.8以后,epoll_create()的形参已经失去了原本的意义,只要是大于0的数即可。
2.2 epoll_ctl()
该函数用于控制epoll中需要监测的socket。
- 参数一是epoll对象的fd
- 参数二是对socket做的行为,分别有EPOLL_CTL_ADD、EPOLL_CTL_MOD、EPOLL_CTL_DEL
- 参数三是监测的socket的fd
- 参数四是一个结构体,它包含了需要监测的fd和需要监测的行为。
- 返回值为0表示函数执行成功
2.3 结构体 struct epoll_event
typedef union epoll_data { //注意,此处是一个联合体,而不是一个结构体 void *ptr; int fd; uint32_t u32; uint64_t u64; } epoll_data_t; struct epoll_event { uint32_t events; /* Epoll events */ epoll_data_t data; /* User data variable */ };
events和poll的类似,epoll的事件是EPOLLIN等。
2.4 epoll_wait()
该函数有两个版本,一个是使用定时,一个是使用信号。在这儿我们讲解定时版本。
- 参数一是epoll对象的fd
- 参数二是传出参数,传出有事件的fd以及对应的事件
- 参数三是一次wait能接收的最大events(epoll没有最大连接数限制,但是一次处理有最大events的限制)
- 参数四是限时设置,单位是毫秒,指定为-1则为阻塞到有事件触发,指定为0则会立即返回,即使没有事件触发。
- 返回值为传出参数events的数量
3. 水平触发和边触发
3.1 水平触发
对于读操作
- 只要缓冲内容不为空,LT模式返回读就绪。
对于写操作
- 只要缓冲区还不满,LT模式会返回写就绪。
3.2 边缘触发
对于读操作
- 当缓冲区由不可读变为可读的时候,即缓冲区由空变为不空的时候。
- 当有新数据到达时,即缓冲区中的待读数据变多的时候。
- 当缓冲区有数据可读,且应用进程对相应的描述符进行EPOLL_CTL_MOD 修改EPOLLIN事件时。
对于写操作
- 当缓冲区由不可写变为可写时。
- 当有旧数据被发送走,即缓冲区中的内容变少的时候。
- 当缓冲区有空间可写,且应用进程对相应的描述符进行EPOLL_CTL_MOD 修改EPOLLOUT事件时。
3.3 模式选择
select和poll都是水平触发,而epoll默认是水平触发,但是也提供了边缘触发模式供选择。
- EPOLLLT 水平触发
- EPOLLET 边缘触发
监听套接字listenfd建议使用水平触发,因为listenfd处理数据是用accept(),此接口一次只会处理一个连接请求。
对于写操作,一般写缓冲区比较大,使用水平触发很容易被触发,所以,需要有开关EPOLLOUT的操作。当存在容易触发LT开关EPOLLOUT事件的情景,建议使用边缘触发。
select模型可移植性强但不够高效,epoll模型高效但是只能用在Linux系统。幸运的是,有很多高效的事件驱动库可以屏蔽上述的困难,常见的事件驱动库有 libevent 库,还有作为 libevent 替代者的 libev 库。这些库会根据操作系统的特点选择 最合适的事件探测接口,并且加入了信号(signal) 等技术以支持异步响应,这使得这些库成为构建事件驱动模型的不二选择。实际上,Linux 内核从 2.6 开始,也引入了支持异步响应的 IO 操作,如 aio_read, aio_write。
----------------------------------------------------------------------------------------------------------------------
github:https://github.com/illusorycat/IO-Multiplexing.git
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列01:轻松3步本地部署deepseek,普通电脑可用
· 25岁的心里话
· 按钮权限的设计及实现