epoll and multiplexing I O
Epoll
epoll是Linux内核提供的一种多路复用I/O事件通知的机制。它与select、poll类似,但拥有更高的伸缩性和性能。
fd是什么?
在 epoll 中,fd(file descriptor,文件描述符)是指向一个系统资源的整数标识符,这个资源可以是文件、套接字、管道、设备等。这些文件描述符的具体类型取决于操作系统中某个进程所使用的文件或网络资源。
文件描述符(fd)是一个非负整数,用于标识和访问操作系统中的资源(如文件、网络连接、设备等)。每个进程都有一个文件描述符表,进程可以通过文件描述符来操作这些资源。
在 Linux 中,文件描述符通常是以下几种类型:
-
标准输入(stdin):文件描述符 0
-
标准输出(stdout):文件描述符 1
-
标准错误输出(stderr):文件描述符 2
-
文件:通过 open() 系统调用返回的文件描述符
-
网络套接字:通过 socket() 系统调用返回的文件描述符
当你使用 epoll 来监控某些文件描述符时,你实际上是在告诉操作系统:你关心这些描述符上发生的特定事件(如数据可读、可写等)。
Epoll和Poll的区别是什么?
poll每次调用时需要传入一个文件描述符数组,内核需要遍历整个数组来检查事件。这在连接数很大时会有较高的开销。
epoll使用内核中维护的数据结构(红黑树和事件队列),在注册事件时就将感兴趣的文件描述符加入到epoll内核结构中,之后只需调用epoll_wait()等待事件发生,不需要重复传入所有FD列表。这样在大量连接(数以万计)时仍能高效运行,减少了无谓的事件检查开销。
为什么epoll_wait阻塞?
epoll_wait() 是IO复用的核心,当没有事件发生时,线程在这里阻塞可以节省CPU资源。当有事件发生(文件描述符可读可写或有定时器事件等),epoll_wait返回,处理相应的事件回调。这种事件驱动模型避免了忙轮询,提高效率。
为什么使用异步唤醒?
当EventLoop在 epoll_wait() 中阻塞等待事件时,如果需要在其他线程中给这个EventLoop增加一个新的任务(如注册新的fd),就必须唤醒epoll_wait()使其立刻执行。唤醒的方式就是往eventfd写入数据,引发EPOLLIN事件,让eventLoop苏醒过来
忙轮询(Busy Polling)是什么?
忙轮询是指线程不断地积极检查某个条件是否满足,而不进行阻塞等待的方式。比如,在没有使用epoll的原始阻塞I/O模型下,如果想不停地读数据但又不想阻塞,你可能不断调用 read()看看有没有数据,没有就继续调用,这就是一种忙轮询的行为。忙轮询会消耗大量CPU,因为线程不会休眠而是不断空转检查。
epoll_create1 和 epoll_create 分别是什么?
都是用于在 Linux 系统中创建一个 epoll 实例的系统调用
epoll_create:
只能接受一个 size 参数,无法直接设置文件描述符的 close-on-exec 标志。如果需要设置 close-on-exec,需要使用 fcntl 手动修改文件描述符标志。
epoll_create1:
提供了直接设置文件描述符标志的能力(例如,EPOLL_CLOEXEC),使得代码更简洁、更安全,减少了潜在的竞态条件(race conditions)。如果不需要设置任何标志,可以将 flags 参数设为 0,其行为与 epoll_create 类似。
epoll_create1 提供了更灵活和安全的接口,尤其是在需要设置 close-on-exec 标志的场景下。它简化了代码,并减少了出错的可能性。
由于 epoll_create 已经被 epoll_create1 所取代,epoll_create1 被认为是更现代和更好的选择。
为什么使用ET(边缘触发)?
ET模式在高并发场景下有更高的效率,因为事件只在状态变化时触发,不需要重复通知。 但使用ET模式需要保证非阻塞I/O,并且在读取或写入数据时一次性尽可能读完或写完缓冲区数据。
I/O复用与非阻塞I/O
I/O复用是指在一个(或少数几个) 线程中同时监控 多个文件描述符(如socket、管道、timerfd等)的I/O事件,一旦某个文件描述符变得可读或可写,就会通过内核提供的机制(如select、poll、epoll)通知我们。这意味着我们不需要为每个连接创建一个线程在阻塞式读写上等待,而是通过一个统一的复用接口(如epoll_wait)来管理众多的I/O事件。
在传统的阻塞I/O模型下,为了等待某个socket有数据可读,线程可能一直阻塞在 recv() 或 read() 调用上。当有成千上万个连接时,如果为每个连接分配一个线程,那么成本极高。IO复用则允许你用少量的线程(甚至一个线程)管理大量连接的I/O事件,大幅度提高资源利用率和扩展性。
使用 epoll_wait ,主线程可以阻塞在 epoll_wait 中。当某个连接有数据到来(可读事件)或发送缓冲区空闲(可写事件)时, epoll_wait 返回一个已就绪事件列表,我们再处理对应的文件描述符。这就是IO复用的核心思想——多路事件集中管理和分发。
非阻塞I/O是指对文件描述符(如socket)设置非阻塞标志后,对它进行读写操作时,如果数据暂不可用,不会让调用者阻塞等待数据到达或缓冲区可用,而是立即返回一个错误码(例如EAGAIN),告诉你“现在没数据”或者“现在写不了”。这样,你的线程不会因为一次I/O操作而停顿下来,依然可以处理其他任务或等待下一个I/O事件就绪。
在高并发服务器中,非阻塞I/O与IO复用是最佳搭档:
IO复用机制(如epoll)告诉你这个fd已经可读或可写了,你这时执行非阻塞读或写操作。如果读的时候还没读完数据,就继续循环读,直到EAGAIN出现,表示读空了;写的时候也是写到不能再写为止。这样通过一次epoll事件触发,你尽可能彻底地完成数据的传输。这就是为什么ET(边缘触发)模式下需要非阻塞I/O,因为你不会重复收到事件通知,必须一次性把能读的全部读完。
epoll + 非阻塞IO是什么?
epoll是Linux下高效的IO复用机制,它能在大量文件描述符中快速找到有事件发生的描述符,并返回给用户态程序进行处理。非阻塞I/O则是指socket在读写时不会阻塞进程。
组合在一起的工作方式是:
- 将多个socket(非阻塞模式)注册到epoll中。
- 主循环调用
epoll_wait()阻塞等待事件。 - 一旦
epoll_wait()返回,说明某些socket变得可读或者可写。此时我们对这些就绪的socket调用read或write,读取或发送尽可能多的数据(在非阻塞模式下,如果数据还没来或缓冲区满了,会返回EAGAIN)。
这样我们在一次事件触发中就能“尽可能”地完成I/O操作,提高效率,避免重复事件或重复等待。
上下文切换是什么?
上下文切换是当操作系统的CPU从执行一个线程(或进程)切换到执行另一个线程(或进程)时,需要保存当前线程的CPU寄存器、程序计数器等状态,然后加载另一个线程的状态,以继续执行该线程的指令的过程。
频繁上下文切换导致性能降低的原因是什么?
上下文切换有开销,包括内存页表切换、CPU缓存失效(cache miss)以及内核管理数据结构的处理。过多的切换会消耗大量CPU时间在管理线程状态上,而不是在实际的工作负载(业务处理)上。
为什么一个线程能管理很多连接?
一个线程并不需要对每个连接都进行"同步"的阻塞等待。借助非阻塞I/O和事件驱动模型(如Reactor),一个线程可以通过epoll等待多个socket事件,并在这些事件就绪时再进行处理。这样,一个线程就可以同时"管理"成百上千个连接。
线程其实有"容量"限制,但这个限制不是严格由线程本身决定的,而是由I/O模型和内存/CPU资源决定的。在事件驱动框架下,只要处理逻辑足够简单,单线程就可以高效地循环处理许多连接的I/O事件。
线程通过epoll管理多个事件时,如果多个事件同时就绪怎么办?多线程又是如何应对高并发场景的?
单线程 + epoll的工作流程: 一个线程调用 epoll_wait() 等待事件就绪。当 epoll_wait() 返回时,会给出一个事件列表(多个就绪的文件描述符及其事件类型)。然后该线程会按照一定策略(通常是顺序或基于优先级)对这些就绪事件进行处理。
若在处理一个事件的过程中,其他连接再次有新事件就绪,这些事件不会丢失——内核会将它们记录在epoll的事件队列中,待当前处理结束后,线程再次调用 epoll_wait() (或者在循环中)会再次获得这些事件并进行处理。
简而言之,当多个事件几乎同时就绪时, epoll_wait() 一次返回中就会包含它们,线程会依次处理。当正在处理一个事件时又有新事件就绪,这些新事件将会在下一次 epoll_wait() 调用中被返回;如果你是循环调用 epoll_wait() 的话,会在下一轮中被处理。一个线程并非同时处理多个事件,而是串行地依次处理就绪事件队列里的事件。
多线程 + epoll的场景:
在高并发服务器中,可以有多种策略:
-
主线程监听,工作线程处理: 主线程负责调用
epoll_wait()获取就绪事件,然后将事件分发给线程池中的工作线程异步处理。这相当于把事件的处理从监听线程中解耦开来。 -
每个工作线程各自拥有epoll实例(或使用
epoll_create()创建的多路复用器,muduo就是这样): 多线程各自监听一部分socket或使用某种负载均衡机制来分配FD给不同线程管理。这样每个线程处理自己的事件集。
不管哪种方式,通过适当的负载均衡和线程池机制,可以同时处理大量连接。即使有多个事件同时就绪,不同线程分工明确,一次 epoll_wait() 返回的多个事件可分配给多个线程并行处理,从而应对高并发。