分享对epoll的理解
1 简介Epoll是个很老的知识点,是后端工程师的经典必修课。这种知识具备的特点就是研究的人多,所以研究的趋势就会越来越深。当然分享的人也多,由于分享者水平参差不齐,也产生的大量错误理解。今天再次分享 epoll,肯定不会列个表格,对比一下差异,那就太无聊了。我将从线程阻塞的原理,中断优化,网卡处理数据过程出发,深入的介绍 epoll 背后的原理,最后还会 diss 一些流行的观点。相信无论你是否已经熟悉 epoll,本文都会对你有价值。
2 引言
正文开始前,先问大家几个问题。
1、epoll 性能到底有多高。很多文章介绍 epoll 可以轻松处理几十万个连接。而传统 IO 只能处理几百个连接 是不是说 epoll 的性能就是传统 IO 的千倍呢?
2、很多文章把网络 IO 划分为阻塞,非阻塞,同步,异步。并表示:非阻塞的性能比阻塞性能好,异步的性能比同步性能好。
如果说阻塞导致性能低,那传统 IO 为什么要阻塞呢?
epoll 是否需要阻塞呢?
Java 的 NIO 和 AIO 底层都是 epoll 实现的,这又怎么理解同步和异步的区别?
3、都是 IO 多路复用
既生瑜何生亮,为什么会有 select,poll 和 epoll 呢?
为什么 epoll 比 select 性能高?
PS:本文共包含三大部分:初识 epoll、epoll 背后的原理 、Diss 环节。
本文的重点是介绍原理,建议读者的关注点尽量放在:“为什么”。
Linux 下进程和线程的区别其实并不大,尤其是在讨论原理和性能问题时,因此本文中“进程”和“线程”两个词是混用的。
3 初识 epoll
epoll 是 Linux 内核的可扩展 I/O 事件通知机制,其最大的特点就是性能优异。下图是 libevent(一个知名的异步事件处理软件库)对 select,poll,epoll ,kqueue 这几个 I/O 多路复用技术做的性能测试。

很多文章在描述 epoll 性能时都引用了这个基准测试,但少有文章能够清晰的解释这个测试结果。
这是一个限制了100个活跃连接的基准测试,每个连接发生1000次读写操作为止。纵轴是请求的响应时间,横轴是持有的 socket 句柄数量。随着句柄数量的增加,epoll 和 kqueue 响应时间几乎无变化,而 poll 和 select 的响应时间却增长了非常多。
可以看出来,epoll 性能是很高的,并且随着监听的文件描述符的增加,epoll 的优势更加明显。不过,这里限制的100个连接很重要。epoll 在应对大量网络连接时,只有活跃连接很少的情况下才能表现的性能优异。换句话说,epoll 在处理大量非活跃的连接时性能才会表现的优异。如果15000个 socket 都是活跃的,epoll 和 select 其实差不了太多。
为什么 epoll 的高性能有这样的局限性?
问题好像越来越多了,看来需要更深入的研究了。
libevent 是一款跨平台、高性能的 I/O 事件通知库,而 epoll 是 Linux 内核提供的 原生 I/O 多路复用机制—— 二者是「上层封装库」与「底层实现」的关系:libevent 以 epoll 为 Linux 平台的核心驱动,同时兼容其他操作系统的 I/O 多路复用机制(如 Windows 的 IOCP、BSD 的 kqueue),为开发者提供统一的事件驱动编程接口。
epoll 是Linux内核从2.5.44引入的可扩展I/O事件通知机制。它设计目的旨在取代既有POSIX select(2)与poll(2)系统函数,让需要大量操作文件描述符的程序得以发挥更优异的性能(举例来说:旧有的系统函数所花费的时间复杂度为O(n),epoll的时间复杂度O(log n))。其实现的功能与 poll 类似,都是监听多个文件描述符上的事件。
epoll与FreeBSD的[kqueue]类似,底层都是由可配置的操作系统内核对象建构而成,并以文件描述符(file descriptor)的形式呈现于用户空间。epoll通过使用红黑树(RB-tree)搜索被监控的文件描述符(file descriptor)。在 epoll 实例上注册事件时,其会将该事件添加到 epoll 实例的红黑树上并注册一个回调函数,当事件发生时会将事件添加到就绪链表中。
其高效关键点
1、内部管理 fd 使用了高效的红黑树结构管理,做到了增删改之后性能的优化和平衡;
2、epoll 池添加 fd 的时候,调用 file_operations->poll ,把这个 fd 就绪之后的回调路径安排好。通过事件通知的形式,做到最高效的运行
3、epoll 池核心的两个数据结构:红黑树和就绪列表
红黑树是为了应对用户的增删改需求,就绪列表是 fd 事件就绪之后放置的特殊地点,epoll 池只需要遍历这个就绪链表,就能给用户返回所有已经就绪的 fd 数组。
哪些 fd 可以用 epoll 来管理?
再来思考另外一个问题:由于并不是所有的 fd 对应的文件系统都实现了 poll 接口,所以自然并不是所有的 fd 都可以放进 epoll 池,那么有哪些文件系统的 file_operations 实现了 poll 接口?
首先说,类似 ext2-4,xfs 这种常规的文件系统是没有实现的,换句话说,这些常见的、真的是文件的文件系统反倒是用不了 epoll 机制的。那谁支持呢?
最常见的就是网络套接字:socket。网络也是 epoll 池最常见的应用地点。Linux 下万物皆文件,socket 实现了一套socket_file_operations的逻辑( net/socket.c ):
static const struct file_operations socket_file_ops = {
.read_iter = sock_read_iter,
.write_iter = sock_write_iter,
.poll = sock_poll,
// ...
};
可以看到 socket 实现了 poll 调用,所以 socket fd 是天然可以放到 epoll 池管理的。还有支持的吗?
还有很多。其实 Linux 下还有两个很典型的 fd,常常也会放到 epoll 池里:
1.eventfd:eventfd 实现非常简单,故名思义就是专门用来做事件通知用的。使用系统调用eventfd创建,这种文件 fd 无法传输数据,只用来传输事件,常常用于生产消费者模式的事件实现;
2.timerfd:这是一种定时器 fd,使用timerfd_create创建,到时间点触发可读事件;
ext2-4,xfs 等这种真正的文件系统的 fd ,无法使用 epoll 管理;
socket fd,eventfd,timerfd 这些实现了 poll 调用的可以放到 epoll 池进行管理;
其实在 Linux 的模块划分中,eventfd,timerfd,epoll 池都是文件系统的一种模块实现。
小结
1、IO 多路复用的原始实现很简单,就是一个 1 对多的服务模式,一个 loop 对应处理多个 fd;
2、IO 多路复用想要做到真正的高效,必须要内核机制提供。因为 IO 的处理和完成是在内核,如果内核不帮忙,用户态的程序根本无法精确的抓到处理时机;
3、fd 记得要设置成非阻塞的;
4、epoll 池通过高效的内部管理结构,并且结合操作系统提供的 poll 事件注册机制,实现了高效的 fd 事件管理,为高并发的 IO 处理提供了前提条件;
5、epoll 全名 eventpoll,在 Linux 内核下以一个文件系统模块的形式实现,所以有人常说 epoll 其实本身就是文件系统也是对的;
6、socketfd,eventfd,timerfd 这三种”文件“fd 实现了 poll 接口,所以网络 fd,事件fd,定时器fd 都可以使用 epoll_ctl 注册到池子里。我们最常见的就是网络fd的多路复用;
7、ext2-4,xfs 这种真正意义的文件系统反倒没有提供 poll 接口实现,所以不能用 epoll 池来管理其句柄。那文件就无法使用 epoll 机制了吗?不是的,有一个库叫做 libaio ,通过这个库可以间接的让文件使用 epoll 通知事件。
4 epoll背后的原理
4.1 阻塞
4.1.1 为什么阻塞
我们以网卡接收数据举例,回顾一下之前我分享过的网卡接收数据的过程。

为了方便理解,我尽量简化技术细节,可以把接收数据的过程分为4步:
NIC(网卡) 接收到数据,通过 DMA 方式写入内存(Ring Buffer 和 sk_buff)。
NIC 发出中断请求(IRQ),告诉内核有新的数据过来了。
Linux 内核响应中断,系统切换为内核态,处理 Interrupt Handler,从RingBuffer 拿出一个 Packet, 并处理协议栈,填充 Socket 并交给用户进程。
系统切换为用户态,用户进程处理数据内容。
网卡何时接收到数据是依赖发送方和传输路径的,这个延迟通常都很高,是毫秒(ms)级别的。而应用程序处理数据是纳秒(ns)级别的。也就是说整个过程中,内核态等待数据,处理协议栈是个相对很慢的过程。这么长的时间里,用户态的进程是无事可做的,因此用到了“阻塞(挂起)”。
4.1.2 阻塞不占用 cpu
阻塞是进程调度的关键一环,指的是进程在等待某事件发生之前的等待状态。请看下表,在 Linux 中,进程状态大致有7种(在 include/linux/sched.h 中有更多状态):

从说明中其实就可以发现,“可运行状态”会占用 CPU 资源,另外创建和销毁进程也需要占用 CPU 资源(内核)。重点是,当进程被"阻塞/挂起"时,是不会占用 CPU 资源的。
换个角度来讲。为了支持多任务,Linux 实现了进程调度的功能(CPU 时间片的调度)。而这个时间片的切换,只会在“可运行状态”的进程间进行。因此“阻塞/挂起”的进程是不占用 CPU 资源的。
另外讲个知识点,为了方便时间片的调度,所有“可运行状态”状态的进程,会组成一个队列,就叫“工作队列”。
4.1.3 阻塞的恢复
内核当然可以很容易的修改一个进程的状态,问题是网络 IO 中,内核该修改那个进程的状态。

socket 结构体,包含了两个重要数据:进程 ID 和端口号。进程 ID 存放的就是执行 connect,send,read 函数,被挂起的进程。在 socket 创建之初,端口号就被确定了下来,操作系统会维护一个端口号到 socket 的数据结构。
当网卡接收到数据时,数据中一定会带着端口号,内核就可以找到对应的 socket,并从中取得“挂起”进程的 ID。将进程的状态修改为“可运行状态”(加入到工作队列)。此时内核代码执行完毕,将控制权交还给用户态。通过正常的“CPU 时间片的调度”,用户进程得以处理数据。
4.1.4 进程模型
上面介绍的整个过程,基本就是 BIO(阻塞 IO)的基本原理了。用户进程都是独立的处理自己的业务,这其实是一种符合进程模型的处理方式。
4.2 上下文切换的优化
上面介绍的过程中,有两个地方会造成频繁的上下文切换,效率可能会很低。
如果频繁的收到数据包,NIC 可能频繁发出中断请求(IRQ)。CPU 也许在用户态,也许在内核态,也许还在处理上一条数据的协议栈。但无论如何,CPU 都要尽快的响应中断。这么做实际上非常低效,造成了大量的上下文切换,也可能导致用户进程长时间无法获得数据。(即使是多核,每次协议栈都没有处理完,自然无法交给用户进程)
每个 Packet 对应一个 socket,每个 socket 对应一个用户态的进程。这些用户态进程转为“可运行状态”,必然要引起进程间的上下文切换。
4.2.1 网卡驱动的 NAPI 机制
在 NIC 上,解决频繁 IRQ 的技术叫做 New API(NAPI) 。原理其实特别简单,把 Interrupt Handler 分为两部分。
函数名为 napi_schedule,专门快速响应 IRQ,只记录必要信息,并在合适的时机发出软中断 softirq。
函数名为 netrxaction,在另一个进程中执行,专门响应 napi_schedule 发出的软中断,批量的处理 RingBuffer 中的数据。
所以使用了 NAPI 的驱动,接收数据过程可以简化描述为:

NIC 接收到数据,通过 DMA 方式写入内存(Ring Buffer 和 sk_buff)。
NIC 发出中断请求(IRQ),告诉内核有新的数据过来了。
driver 的 napi_schedule 函数响应 IRQ,并在合适的时机发出软中断(NET_RX_SOFTIRQ)
driver 的 net_rx_action 函数响应软中断,从 Ring Buffer 中批量拉取收到的数据。并处理协议栈,填充 Socket 并交给用户进程。
系统切换为用户态,多个用户进程切换为“可运行状态”,按 CPU 时间片调度,处理数据内容。
一句话概括就是:等着收到一批数据,再一次批量的处理数据。
4.2.2 单线程的 IO 多路复用
内核优化“进程间上下文切换”的技术叫的“IO 多路复用”,思路和 NAPI 是很接近的。
每个 socket 不再阻塞读写它的进程,而是用一个专门的线程,批量的处理用户态数据,这样就减少了线程间的上下文切换。

作为 IO 多路复用的一个实现,select 的原理也很简单。所有的 socket 统一保存执行 select 函数的(监视进程)进程 ID。任何一个 socket 接收了数据,都会唤醒“监视进程”。内核只要告诉“监视进程”,那些 socket 已经就绪,监视进程就可以批量处理了。
4.3 IO 多路复用的进化
4.3.1 对比 epoll 与 select
select,poll 和 epoll 都是“IO 多路复用”,那为什么还会有性能差距呢?篇幅限制,这里我们只简单对比 select 和 epoll 的基本原理差异。
对于内核,同时处理的 socket 可能有很多,监视进程也可能有多个。所以监视进程每次“批量处理数据”,都需要告诉内核它“关心的 socket”。内核在唤醒监视进程时,就可以把“关心的 socket”中,就绪的 socket 传给监视进程。
换句话说,在执行系统调用 select 或 epoll_create 时,入参是“关心的 socket”,出参是“就绪的 socket”。
而 select 与 epoll 的区别在于:
select (一次O(n)查找)
每次传给内核一个用户空间分配的 fd_set 用于表示“关心的 socket”。其结构(相当于 bitset)限制了只能保存1024个 socket。
每次 socket 状态变化,内核利用 fd_set 查询O(1),就能知道监视进程是否关心这个 socket。
内核是复用了 fd_set 作为出参,返还给监视进程(所以每次 select 入参需要重置)。
然而监视进程必须遍历一遍 socket 数组O(n),才知道哪些 socket 就绪了。
epoll (全是O(1)查找)
每次传给内核一个实例句柄。这个句柄是在内核分配的红黑树 rbr+双向链表 rdllist。只要句柄不变,内核就能复用上次计算的结果。
每次 socket 状态变化,内核就可以快速从 rbr 查询O(1),监视进程是否关心这个 socket。同时修改 rdllist,所以 rdllist 实际上是“就绪的 socket”的一个缓存。
内核复制 rdllist 的一部分或者全部(LT 和 ET),到专门的 epoll_event 作为出参。
所以监视进程,可以直接一个个处理数据,无需再遍历确认。
Select 示例代码

Epoll 示例代码

另外,epoll_create 底层实现,到底是不是红黑树,其实也不太重要(完全可以换成 hashtable)。重要的是 efd 是个指针,其数据结构完全可以对外透明的修改成任意其他数据结构。
4.3.2 API 发布的时间线
另外,我们再来看看网络 IO 中,各个 api 的发布时间线。就可以得到两个有意思的结论。
1983,socket 发布在 Unix(4.2 BSD)
1983,select 发布在 Unix(4.2 BSD)
1994,Linux的1.0,已经支持socket和select
1997,poll 发布在 Linux 2.1.23
2002,epoll发布在 Linux 2.5.44
1、socket 和 select 是同时发布的。这说明了,select 不是用来代替传统 IO 的。这是两种不同的用法(或模型),适用于不同的场景。
2、select、poll 和 epoll,这三个“IO 多路复用 API”是相继发布的。这说明了,它们是 IO 多路复用的3个进化版本,因为 API 设计缺陷,无法在不改变 API 的前提下优化内部逻辑;所以用 poll 替代 select,再用 epoll 替代 poll。
4.4 总结
我们花了三个章节,阐述 Epoll 背后的原理,现在用三句话总结一下。
基于数据收发的基本原理,系统利用阻塞提高了 CPU 利用率。
为了优化上线文切换,设计了“IO 多路复用”(和 NAPI)。
为了优化“内核与监视进程的交互”,设计了三个版本的 API(select,poll,epoll)。
5 Diss 环节
讲完“Epoll 背后的原理”,已经可以回答最初的几个问题。这已经是一个完整的文章,很多人劝我删掉下面的 diss 环节。
我的观点是:学习就是个研究+理解的过程。上面是研究,下面再讲一下我的个人“理解”,欢迎指正。
5.1 关于 IO 模型的分类
关于阻塞,非阻塞,同步,异步的分类,这么分自然有其道理。但是在操作系统的角度来看“这样分类,容易产生误解,并不好”。

5.1.1 阻塞和非阻塞
Linux 下所有的 IO 模型都是阻塞的,这是收发数据的基本原理导致的。阻塞用户线程是一种高效的方式。
你当然可以写一个程序,socket 设置成非阻塞模式,在不使用监视器的情况下,依靠死循环完成一次 IO 操作。但是这样做的效率实在是太低了,完全没有实际意义。
换句话说,阻塞不是问题,运行才是问题,运行才会消耗 CPU。IO 多路复用不是减少了阻塞,是减少了运行。上下文切换才是问题,IO 多路复用,通过减少运行的进程,有效的减少了上下文切换。
5.1.2 同步和异步
Linux 下所有的 IO 模型都是同步的。BIO 是同步的,select 同步的,poll 同步的,epoll 还是同步的。
Java 提供的 AIO,也许可以称作“异步”的。但是 JVM 是运行在用户态的,Linux 没有提供任何的异步支持。因此 JVM 提供的异步支持,和你自己封装成“异步”的框架是没有本质区别的(你完全可以使用 BIO 封装成异步框架)。
所谓的“同步“和”异步”只是两种事件分发器(event dispatcher)或者说是两个设计模式(Reactor 和 Proactor)。都是运行在用户态的,两个设计模式能有多少性能差异呢?
Reactor 对应 java 的 NIO,也就是 Channel,Buffer 和 Selector 构成的核心的 API。
Proactor对应 java 的 AIO,也就是 Async 组件和 Future 或 Callback 构成的核心的 API。
5.1.3 我的分类
我认为 IO 模型只分两类:
更加符合程序员理解和使用的,进程模型;
更加符合操作系统处理逻辑的,IO 多路复用模型。
对于“IO多路复用”的事件分发,又分为两类:Reactor 和 Proactor。
5.2 关于 mmap
epoll 到底用没用到 mmap?
答案:没有!
这是个以讹传讹的谣言。其实很容易证明的,用 epoll 写个 demo,strace 一下就清楚了。
上文转自:深入理解 epoll,原作者:冯志明
剖析 Linux 网络 IO 与 epoll
本节分享自华为云社区《高性能网络设计秘笈:深入剖析 Linux 网络 IO 与 epoll》,作者: Lion Long 。
一、epoll 简介
epoll 是 Linux 内核中一种可扩展的 IO 事件处理机制,可替代 select 和 poll 的系统调用。处理百万级并发访问性能更佳。
二、select 的局限性
(1) 文件描述符越多,性能越差。 单个进程中能够监视的文件描述符存在最大的数量,默认是 1024(在 linux 内核头文件中定义有 #define _FD_SETSIZE 1024), 当然也可以修改,但是文件描述符数量越多,性能越差。
(2)开销巨大 ,select 需要复制大量的句柄数据结构,产生了巨大的开销(内核 / 用户空间内存拷贝问题)。
(3)select 需要遍历整个句柄数组才能知道哪些句柄有事件。
(4)如果没有完成对一个已经就绪的文件描述符的 IO 操作,那么每次调用 select 还是会将这些文件描述符通知进程,即水平触发。
(5)poll 使用链表保存监视的文件描述符,虽然没有了监视文件数量的限制,但是其他缺点依旧存在。
由于以上缺点,基于 select 模型的服务器程序,要达到十万以上的并发访问,是很难完成的。因此,epoll 出场了。
三、epoll 的优点
(1)不需要轮询所有的文件描述符
(2)每次取就绪集合,都在固定位置
(3)事件的就绪和 IO 触发可以异步解耦
四、epoll 函数原型
4.1、epoll_create(int size)
#include <sys/epoll.h>
int epoll_create(int size);
功能:创建 epoll 的文件描述符。
参数说明:size 表示内核需要监控的最大数量,但是这个参数内核已经不会用到,只要传入一个大于 0 的值即可。 当 size<=0 时,会直接返回不可用,这是历史原因保留下来的,最早的 epoll_create 是需要定义一次性就绪的最大数量;后来使用了链表以便便维护和扩展,就不再需要使用传入的参数。
返回:返回该对象的描述符,注意要使用 close 关闭该描述符。
4.2、epoll_ctl
#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
// epoll_ctl对应系统调用sys_epoll_ctl
功能:操作 epoll 的文件描述符,主要是对 epoll 的红黑树节点进行操作,比如节点的增删改查。
参数说明:
| 参数 | 含义 |
|---|---|
| epfd | 通过 epoll_create 创建的文件描述符 |
| op | 对红黑树的操作,比如节点的增加、修改、删除,分别对应EPOLL_CTL_ADD、EPOLL_CTL_MOD、EPOLL_CTL_DEL |
| fd | 需要添加监听的文件描述符 |
| event | 事件信息 |
4.2.1、event 参数说明
struct epoll_event 结构体原型
typedef union epoll_data{
void* ptr;
int fd;
uint32_t u32;
uint64_t u64
};
struct epoll_event{
uint32_t events;
epoll_data_t data;
}
events 成员代表要监听的 epoll 事件类型
events 成员:
| 成员变量 | 含义 |
|---|---|
| EPOLLIN | 监听fd的读事件 |
| EPOLLOUT | 监听fd的写事件 |
| EPOLLRI | 监听紧急数据可读事件(带外数据到来) |
| EPOLLRDHUP | 监听套接字关闭或半关闭事件 |
| EPOLLET | 将EPOLL设为边缘触发(Edge Triggered)模式 |
data 成员:
data 成员时一个联合体类型,可以在调用 epoll_ctl 给 fd 添加 / 修改描述符监听的事件时携带一些数据,方便后面的 epoll_wait 可以取出信息使用。
4.2.2、扩展说明:SYSCALL_DEFINE 数字 的宏定义
跟着的数字代表函数需要的参数数量,比如 SYSCALL_DEFINE1 代表函数需要一个参数、SYSCALL_DEFINE4 代表函数需要 4 个参数。
4.2.3、注意
epoll_ctl 是非阻塞的,不会被挂起。
4.3、epoll_wait
函数原型
#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
功能:阻塞一段时间,等待事件发生
返回:返回事件数量,事件集添加到 events 数组中。也就是遍历红黑树中的双向链表,把双向链表中的节点数据拷贝出来,拷贝完毕后把节点从双向链表中移除。
| 返回值 | 含义 |
|---|---|
| 大于0 | 事件个数 |
| 等于0 | 超时时间timeout到了 |
| 小于0 | 出错,可通过errno查看出错原因 |
参数说明:
| 参数 | 含义 |
|---|---|
| epfd | 通过 epoll_create 创建的文件描述符 |
| events | 存放就绪的事件集合,是输出参数 |
| maxevents | 最大可存放事件数量,events数组大小 |
| timeout | 阻塞等待的时间长短,单位是毫秒,-1表示一直阻塞等待 |
五、epoll 使用步骤
step 1:创建 epoll 文件描述符
int epfd = epoll_create(1);
step 2:创建 struct epoll_event 结构体
struct epoll_event ev;
ev.data.fd=listenfd;//保存监听的fd,以便epoll_wait的后续操作
ev.events=EPOLLIN;//设置监听fd的可读事件
step 3:添加事件监听
epoll_ctl(epfd,EPOLL_CTL_ADD,listenfd,&ev);
step 4:等待事件
struct epoll_event events[EVENTS_LENGTH];
char rbuffer[MAX_BUFF]={ 0 };
char wbuffer[MAX_BUFF]={ 0 };
while(1){
int nready = epoll_wait(epfd,events,EVENTS_LENGTH,-1);//-1表示阻塞等待
int i=0;
for(i=0;i<nready;i++){
int clientfd=events[i].data.fd;
if(clientfd==listenfd){
struct sockaddr_in client;
int len=sizeof(client);
int confd=accept(listenfd,(struct sockaddr*)&client,&len);
//step 2:创建struct epoll_event结构体
struct epoll_event evt;
evt.data.fd=confd;//保存监听的fd,以便epoll_wait的后续操作
evt.events=EPOLLIN;//设置监听fd的可读事件
// step 3:添加事件监听
epoll_ctl(epfd,EPOLL_CTL_ADD,confd,&evt);
}
else if(events[i].events &EPOLLIN){
int ret = recv(clientfd,rbuffer,MAX_BUFF,0);
if(ret>0){
rbuffer[ret]='\0';//剔除干扰数据
printf("recv: %s\n",rbuffer);
memcpy(wbuffer,rbuffer,MAX_BUFF);//拷贝数据,做回传示例
//step 2:创建struct epoll_event结构体
struct epoll_event evt;
evt.data.fd=clientfd;//保存监听的fd,以便epoll_wait的后续操作
evt.events=EPOLLOUT;//设置监听fd的可写事件
// step 3:修改事件监听
epoll_ctl(epfd,EPOLL_CTL_MOD,clientfd,&evt);
}
}else if(events[i].events &EPOLLOUT){
int ret = send(clientfd,wbuffer,MAX_BUFF,0);
printf("send: %s\n",wbuffer);
//step 2:创建struct epoll_event结构体
struct epoll_event evt;
evt.data.fd=clientfd;//保存监听的fd,以便epoll_wait的后续操作
evt.events=EPOLLIN;//设置监听fd的可读事件
// step 3:修改事件监听
epoll_ctl(epfd,EPOLL_CTL_MOD,clientfd,&evt);
}
}
}
六、完整示例代码
#include <stdio.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <fcntl.h>
#include <unistd.h>
#include <pthread.h>
#include <sys/epoll.h>
#include <string.h>
#define BUFFER_LENGTH 128
#define EVENTS_LENGTH 128
char rbuff[BUFFER_LENGTH] = { 0 };
char wbuff[BUFFER_LENGTH] = { 0 };
int main() {
// block
int listenfd = socket(AF_INET, SOCK_STREAM, 0); //
if (listenfd == -1) return -1;
// listenfd
struct sockaddr_in servaddr;
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(9999);
if (-1 == bind(listenfd, (struct sockaddr*)&servaddr, sizeof(servaddr))) {
return -2;
}
#if 0 // nonblock
int flag = fcntl(listenfd, F_GETFL, 0);
flag |= O_NONBLOCK;
fcntl(listenfd, F_SETFL, flag);
#endif
listen(listenfd, 10);
int epfd = epoll_create(1);
struct epoll_event ev, events[EVENTS_LENGTH];
ev.events = EPOLLIN;
ev.data.fd = listenfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &ev);
printf("epfd : %d\n", epfd);
while (1){
int nready = epoll_wait(epfd, events, EVENTS_LENGTH, -1);
printf("nready --> %d\n",nready);
int i;
for (i = 0; i < nready;i++){
int clientfd = events[i].data.fd;
if (listenfd == clientfd){
// accept
struct sockaddr_in client;
int len = sizeof(client);
int conffd = accept(clientfd, (struct sockaddr*)&client,&len);
printf("conffd --> %d\n",conffd);
ev.events = EPOLLIN;
ev.data.fd = conffd;
epoll_ctl(epfd, EPOLL_CTL_ADD, conffd, &ev);
}
else if(events[i].events & EPOLLIN)//client{
int ret=recv(clientfd, rbuff, BUFFER_LENGTH, 0);
if (ret > 0){
rbuff[ret] = '\0';
printf("recv buffer: %s\n", rbuff);
/*
int j;
for (j = 0; j < BUFFER_LENGTH;j++){
buff[j] = 'a' + (j % 26);
}
send(clientfd, buff, BUFFER_LENGTH, 0);
*/
memcpy(wbuff, rbuff, BUFFER_LENGTH);
ev.events = EPOLLOUT;
ev.data.fd = clientfd;
epoll_ctl(epfd, EPOLL_CTL_MOD, clientfd, &ev);
}
}
else if (events[i].events & EPOLLOUT){
send(clientfd, wbuff, BUFFER_LENGTH, 0);
printf("send --> %s\n",wbuff);
ev.events = EPOLLIN;
ev.data.fd = clientfd;
epoll_ctl(epfd, EPOLL_CTL_MOD, clientfd, &ev);
}
}
}
return 0;
}
七、epoll 的缺点
读写使用相同的缓冲区。比如上述的示例中,wbuffer 和 rbuffer 是使用同一个缓冲区的,所以需要 rbuff [ret] = ‘\0’; 去除杂数据。
八、水平触发(LT)与边沿触发 (ET)
8.1、两者差异
1、水平触发可以一次 recv,边沿触发需要用循环来 recv;
2、水平触发可以使用阻塞模式,边沿模式不能
3、两者性能差异非常小,一般小数据使用水平触发 LT,大数据使用边沿触发 ET
4、listen fd 最好使用水平触发,尽量不要边沿触发
5、当 recv 的 buffer 小于接受的数据时:
(1)水平触发是只要有数据就一直触发,直到数据读完;
(2)边沿触发是来一次连接触发一次,如果接受数据的 buffer 不够大,则数据会保留在缓冲区,下次触发继续从缓冲区读出来;
6、一般,水平触发只需要一个 recv,边沿触发需要搭配 while 从缓冲区读完数据。
8.2、设置触发模式
默认是水平触发模式,在事件中设置中 | EPOLLET 就可以设置边沿触发,不设置则默认是水平触发。例如:
ev.events=EPOLL_IN | EPOLLET
九、常见疑惑问题
9.1、为什么提前先定义一个事件?
我们需要注册,内核才会有事件来的时候通知进程。比如生活中要退一个快递,那么我们需要注册一个快递公司的账户,然后发送一个退快递请求时快递公司才能找到你并取快递。
9.2、epoll events 超出 EVENTS_LENGTH?
epoll 会循环拷贝红黑树结构体中的双向链表节点,读取节点数据,直到没有事件。
9.3、缓冲区有多大空间时才返回可读/可写?
只要缓冲区有空间就返回可读、可写,不管空间多少。比如缓冲区是 1024,但是有 1023 有数据了,这种极端条件也会返回可读、可写。
9.4、recv 和 send 放在一起时,有什么问题?
发送给客户端数据很大的时候(大于内核缓冲区),就可能出现 send 不全,客户端 recv 不全,最好用 EPOLLOUT 单独处理发送数据事件。
上面介绍了网络 IO 模型,引入了 epoll 作为 Linux 系统中高性能网络编程的核心工具。通过分析 epoll 的特点与优势,并给出使用 epoll 的注意事项和实践技巧,为读者提供了宝贵的指导。通过掌握这些知识,读者能够构建高效、可扩展和稳定的网络应用,提供出色的用户体验。