IO多路复用 select,poll,epoll详解
1. 前置知识
1.1 进程阻塞
进程的阻塞是指一个进程等待某个条件满足或某个事件发生,这个进程的执行被暂停进入一种不可执行的状态,直到满足该条件重新获得运行的机会
进程的生命周期:
- 就绪:进程具备执行条件,等待CPU调度
- 运行:进程正在被CPU执行
- 阻塞:进程因为某种原因无法继续执行,只能等待某个事件或资源
当一个进程进入阻塞状态时,操作系统会将其从就绪队列中移除,并加入到等待队列中,直到被某个事件(如I/O操作完成、信号到达)唤醒后,重新进入就绪状态等待调度。
导致进程阻塞的原因
- IO操作
进程在执行文件读写操作时,需要等待硬盘或网络数据,这些操作速度通常比CPU速度慢很多,因此进程等待IO操作完成会被阻塞 - 请求资源
进程再运行过程中请求的资源被别的进程占用时会进入阻塞状态:锁,内存页,共享变量 - 进程之间的同步:
在多进程环境中,进程之间可能需要同步操作,比如通过信号量或条件变量进行同步。如果一个进程等待另一个进程的通知(如信号),它会进入阻塞状态。 - 等待子进程完成:
一个父进程可能需要等待其子进程完成后再继续执行,比如调用wait()
系统调用等待子进程结束。如果子进程尚未完成,父进程会进入阻塞状态。
1.2 文件描述符
文件描述符(File Descriptor,简称FD) 是一个非负整数,用于标识和访问系统中的文件或其他I/O资源。文件描述符是操作系统内核为进程分配的用于表示打开的文件、管道、套接字等I/O资源的标识符。每个文件描述符指向一个文件表项,其中包含文件的位置、状态和属性等信息。
1.3 缓存I/O
缓存 I/O(Buffered I/O),也称为 标准 I/O,是指在进行文件或 I/O 设备读写操作时,操作系统使用内存中的缓冲区来暂存数据,以提高 I/O 操作的效率和性能。这是一种最常见的 I/O 方式,广泛应用于 Linux/Unix 系统和大多数编程语言的标准库中。
缓存 I/O 的核心理念是通过减少磁盘和内存之间频繁的数据传输来提高效率。磁盘 I/O 的速度通常远低于 CPU 和内存的速度,而使用缓存可以减少直接的磁盘访问次数,进而提高整体系统的性能。
缓冲区:在缓存 I/O 中,系统会在内存中开辟一个称为缓冲区的区域,用来存储读写数据的中间状态。
- 当执行读操作时,数据会先从磁盘读取到内核的缓冲区,然后再从缓冲区传输到用户空间,这样可以减少频繁的磁盘操作。
- 当执行写操作时,数据会先被写入缓冲区,然后再由系统异步地将缓冲区中的数据刷到磁盘。
异步 I/O 操作:由于缓存 I/O 是异步操作,进程在将数据写入缓冲区后,I/O 操作并不会立即发生,而是由操作系统负责将缓冲区中的数据在适当的时候写入磁盘。这种方式能够减少等待时间,提高 I/O 效率。
2. IO模式
对于一次IO操作,数据会先被copy到操作系统内核态的缓存区,然后从操作系统缓冲区再copy到应用程序的地址空间。
不同场景中,IO操作的效率和性能需求不同,操作系统支持多种IO模式。
- 阻塞IO
- 非阻塞IO
- IO多路复用
- 异步IO
2.1 阻塞I/O
客户端向Linux服务器发送一段数据的C语言实现。以及Linux服务器如何通过C语言代码接收客户端的连接和发送数据:
1 | int main(){ |
阻塞IO的数据接收流程:
- 用户进程A创建Socket,调用socket()函数陷入内核态调用socket系统调用创建socket内核对象,包括两个结构体,进程等待队列和数据接收队列
- 进程A调用recv()函数接收数据,进入recvfrom系统调用,发现socket数据接收队列没有他所需要的数据到达时,让出CPU进入阻塞状态,进程A的fd和回调函数被放进进程等待队列
- 客户端发送数据到服务器网卡
- 网卡将数据通过DMA复制到环形缓存区中存储
- 网卡向CPU发出硬中断
- CPU向内核中断进程发出软中断
- 内核终端进程收到软中断后,将网卡复制到内存的数据,根据IP地址和端口号拷贝到socket数据接收队列
- 进程A被回调函数唤醒,继续执行recvfrom函数将数据拷贝到用户态
- 回到用户态执行用户程序,解除阻塞
一次数据道德会进行两次进程切换,一次数据读取有两处阻塞,不适合高并发
2.2 非阻塞I/O
进程在发起IO请求后,无论数据是否准备好,操作系统都会从返回,不会阻塞。进程需要不断地重复轮询数据是否准备好。如果数据已经到达内核空间的socket等待队列,用户进程还是要等待将数据从内核空间拷贝到用户空间。
两次进程切换,一处阻塞。在高并发场景下依旧性能不好
2.3 I/O多路复用
IO多路复用允许单个进程处理多个TCP连接,也就是同时管理多个IO操作,可以降低服务器处理请求的进程数,不需要再像之前一样每一个进程的数据到达时就进行进程切换,进程通过一个系统调用同时监视多个文件描述符
工作流程:
- 进程通过
select
、poll
或epoll
发起一个请求来监视多个文件描述符的状态。 - 如果没有描述符准备好,进程可以被阻塞,直到有描述符准备好(也可以选择不阻塞)。
- 当有描述符准备好时,进程被通知,进而对相应的文件描述符执行实际的 I/O 操作。
3. IO多路复用: Select, poll, epoll
3.1 select
1 | int select( |
select
函数的返回值是一个整数,用于指示准备好的文件描述符数量:
- 返回正数:表示有多少个文件描述符准备好(可读,可写或有异常)
- 返回0:表示在超时时间内没有任何文件描述符变为可用
- 返回-1:表示出错
工作原理:
select
使用一个固定大小的bitmap表示所监视的文件描述符集合,每一位代表每个文件描述符的状态- 调用
select
函数时:- 用户态将文件描述符集合传递给内核态
- 内核态通过遍历的方式扫描每个文件描述符,检查其状态
- 如果有任何一个文件描述符变为可读/可写状态,内核更新bitmap,并将控制权交给用户进程
优点
1. 简单易用,实现简单,API容易理解
2. 几乎所有平台都支持,是一种标准化的机制
缺点
1. 最大文件描述符限制: 由于bitmap的大小限制,select的文件描述符数量上限为1024,在编译内核时就确定了并且无法更改
2. 性能开销大
1. 每次调用都需要在用户态和内核态之间传递文件描述符集合,会带来较大的内存拷贝开销
2. 进程被唤醒后,不知道哪些连接已就绪为收到了数据的状态,需要遍历整个传进来的bitmap,时间复杂度为O(n)
3.2 poll
poll
是对select
的改进,去除了select
中对文件描述符数量的限制,使用链表替代bitmap
。从而可以处理更多的文件描述符
在工作原理方面和select
没有什么区别。只是将fd_set
改为了pollfd
结构
1 | struct pollfd { |
3.3 epoll
epoll
是Linux
特有的IO多路复用机制,解决了select
和poll
在高并发场景下的性能问题,epoll
可以高效解决大量并发连接epoll
特点:
- 使用红黑树结构存储一份文件描述集合,每个文件描述符只需要在添加时传入,不需要每次用户使用都传入。解决了
select
中重复拷贝到内核的问题 - 不再使用遍历的手段找到就绪的文件描述符,而是使用异步IO事件
- 不再返回所有的文件描述符,而是使用队列存储就绪的文件描述符,并且按需返回,不需要用户态再次遍历
核心数据结构
- 红黑树用于管理所有需要监控的文件描述符,可以提供高效的CURD操作
- 就绪链表用于存储有IO事件就绪的文件描述符
相关函数
epoll_create
创建一个epoll
实例,返回一个文件描述符,标识这个epoll
实例epoll_ctl
管理文件描述符,向epoll
实例中添加,修改和删除需要监视的文件描述符和事件epoll_wait
等待文件描述符变为就绪状态,返回有时间发生的文件描述符集合
** epoll
的工作流程**
创建
epoll
实例:- 调用
epoll_create
创建一个epoll
实例,用于管理和监视多个文件描述符,得到一个epoll
文件描述符(如 **epfd
**)。
- 调用
注册文件描述符:
- 通过
epoll_ctl
函数,使用EPOLL_CTL_ADD
操作将需要监视的文件描述符(例如 socket)注册到epoll
实例中,并指定需要监视的事件(如可读、可写)。
- 通过
事件发生时:
- 内核通过 红黑树 管理注册到
epoll
实例中的所有文件描述符。 - 当某个文件描述符的状态发生变化(如有数据到达时变为可读状态),内核会将该文件描述符加入就绪链表。
- 由于所有就绪的文件描述符都放在链表中,
epoll
只需在链表中查找,而无需遍历所有文件描述符,大幅度提高了查询效率。
- 内核通过 红黑树 管理注册到
等待就绪事件:
- 调用
epoll_wait
函数阻塞等待,直到有文件描述符变为就绪,或等待的超时时间到达。 epoll_wait
返回就绪的文件描述符集合,开发者可以根据需要进行相应的 I/O 操作。
- 调用
事件处理:
- 对返回的就绪文件描述符集合逐一进行处理(如读取或写入操作),然后可以通过
epoll_ctl
继续更新或删除这些文件描述符。
- 对返回的就绪文件描述符集合逐一进行处理(如读取或写入操作),然后可以通过