极客时间已完结课程限时免费阅读

29 | 渐入佳境:使用epoll和多线程模型

29 | 渐入佳境:使用epoll和多线程模型-极客时间

29 | 渐入佳境:使用epoll和多线程模型

讲述:冯永吉

时长08:06大小7.41M

你好,我是盛延敏,这里是网络编程实战第 29 讲,欢迎回来。
在前面的第 27 讲和第 28 讲中,我介绍了基于 poll 事件分发的 reactor 反应堆模式,以及主从反应堆模式。我们知道,和 poll 相比,Linux 提供的 epoll 是一种更为高效的事件分发机制。在这一讲里,我们将切换到 epoll 实现的主从反应堆模式,并且分析一下为什么 epoll 的性能会强于 poll 等传统的事件分发机制。

如何切换到 epoll

我已经将所有的代码已经放置到GitHub上,你可以自行查看或下载。
我们的网络编程框架是可以同时支持 poll 和 epoll 机制的,那么如何开启 epoll 的支持呢?
lib/event_loop.c 文件的 event_loop_init_with_name 函数是关键,可以看到,这里是通过宏 EPOLL_ENABLE 来决定是使用 epoll 还是 poll 的。
struct event_loop *event_loop_init_with_name(char *thread_name) {
...
#ifdef EPOLL_ENABLE
yolanda_msgx("set epoll as dispatcher, %s", eventLoop->thread_name);
eventLoop->eventDispatcher = &epoll_dispatcher;
#else
yolanda_msgx("set poll as dispatcher, %s", eventLoop->thread_name);
eventLoop->eventDispatcher = &poll_dispatcher;
#endif
eventLoop->event_dispatcher_data = eventLoop->eventDispatcher->init(eventLoop);
...
}
在根目录下的 CMakeLists.txt 文件里,引入 CheckSymbolExists,如果系统里有 epoll_create 函数和 sys/epoll.h,就自动开启 EPOLL_ENABLE。如果没有,EPOLL_ENABLE 就不会开启,自动使用 poll 作为默认的事件分发机制。
# check epoll and add config.h for the macro compilation
include(CheckSymbolExists)
check_symbol_exists(epoll_create "sys/epoll.h" EPOLL_EXISTS)
if (EPOLL_EXISTS)
# Linux下设置为epoll
set(EPOLL_ENABLE 1 CACHE INTERNAL "enable epoll")
# Linux下也设置为poll
# set(EPOLL_ENABLE "" CACHE INTERNAL "not enable epoll")
else ()
set(EPOLL_ENABLE "" CACHE INTERNAL "not enable epoll")
endif ()
但是,为了能让编译器使用到这个宏,需要让 CMake 往 config.h 文件里写入这个宏的最终值,configure_file 命令就是起这个作用的。其中 config.h.cmake 是一个模板文件,已经预先创建在根目录下。同时还需要让编译器 include 这个 config.h 文件。include_directories 可以帮我们达成这个目标。
configure_file(${CMAKE_CURRENT_SOURCE_DIR}/config.h.cmake
${CMAKE_CURRENT_BINARY_DIR}/include/config.h)
include_directories(${CMAKE_CURRENT_BINARY_DIR}/include)
这样,在 Linux 下,就会默认使用 epoll 作为事件分发。
那么前面的27 讲28 讲中的程序案例如何改为使用 poll 的呢?
我们可以修改 CMakeLists.txt 文件,把 Linux 下设置为 poll 的那段注释下的命令打开,同时关闭掉原先设置为 1 的命令就可以了。 下面就是具体的示例代码。
# check epoll and add config.h for the macro compilation
include(CheckSymbolExists)
check_symbol_exists(epoll_create "sys/epoll.h" EPOLL_EXISTS)
if (EPOLL_EXISTS)
# Linux下也设置为poll
set(EPOLL_ENABLE "" CACHE INTERNAL "not enable epoll")
else ()
set(EPOLL_ENABLE "" CACHE INTERNAL "not enable epoll")
endif (
不管怎样,现在我们得到了一个 Linux 下使用 epoll 作为事件分发的版本,现在让我们使用它来编写程序吧。

样例程序

我们的样例程序和第 28 讲的一模一样,只是现在我们的事件分发机制从 poll 切换到了 epoll。
#include <lib/acceptor.h>
#include "lib/common.h"
#include "lib/event_loop.h"
#include "lib/tcp_server.h"
char rot13_char(char c) {
if ((c >= 'a' && c <= 'm') || (c >= 'A' && c <= 'M'))
return c + 13;
else if ((c >= 'n' && c <= 'z') || (c >= 'N' && c <= 'Z'))
return c - 13;
else
return c;
}
//连接建立之后的callback
int onConnectionCompleted(struct tcp_connection *tcpConnection) {
printf("connection completed\n");
return 0;
}
//数据读到buffer之后的callback
int onMessage(struct buffer *input, struct tcp_connection *tcpConnection) {
printf("get message from tcp connection %s\n", tcpConnection->name);
printf("%s", input->data);
struct buffer *output = buffer_new();
int size = buffer_readable_size(input);
for (int i = 0; i < size; i++) {
buffer_append_char(output, rot13_char(buffer_read_char(input)));
}
tcp_connection_send_buffer(tcpConnection, output);
return 0;
}
//数据通过buffer写完之后的callback
int onWriteCompleted(struct tcp_connection *tcpConnection) {
printf("write completed\n");
return 0;
}
//连接关闭之后的callback
int onConnectionClosed(struct tcp_connection *tcpConnection) {
printf("connection closed\n");
return 0;
}
int main(int c, char **v) {
//主线程event_loop
struct event_loop *eventLoop = event_loop_init();
//初始化acceptor
struct acceptor *acceptor = acceptor_init(SERV_PORT);
//初始tcp_server,可以指定线程数目,这里线程是4,说明是一个acceptor线程,4个I/O线程,没一个I/O线程
//tcp_server自己带一个event_loop
struct TCPserver *tcpServer = tcp_server_init(eventLoop, acceptor, onConnectionCompleted, onMessage,
onWriteCompleted, onConnectionClosed, 4);
tcp_server_start(tcpServer);
// main thread for acceptor
event_loop_run(eventLoop);
}
关于这个程序,之前一直没有讲到的部分是缓冲区对象 buffer。这其实也是网络编程框架应该考虑的部分。
我们希望框架可以对应用程序封装掉套接字读和写的部分,转而提供的是针对缓冲区对象的读和写操作。这样一来,从套接字收取数据、处理异常、发送数据等操作都被类似 buffer 这样的对象所封装和屏蔽,应用程序所要做的事情就会变得更加简单,从 buffer 对象中可以获取已接收到的字节流再进行应用层处理,比如这里通过调用 buffer_read_char 函数从 buffer 中读取一个字节。
另外一方面,框架也必须对应用程序提供套接字发送的接口,接口的数据类型类似这里的 buffer 对象,可以看到,这里先生成了一个 buffer 对象,之后将编码后的结果填充到 buffer 对象里,最后调用 tcp_connection_send_buffer 将 buffer 对象里的数据通过套接字发送出去。
这里像 onMessage、onConnectionClosed 几个回调函数都是运行在子反应堆线程中的,也就是说,刚刚提到的生成 buffer 对象,encode 部分的代码,是在子反应堆线程中执行的。这其实也是回调函数的内涵,回调函数本身只是提供了类似 Handlder 的处理逻辑,具体执行是由事件分发线程,或者说是 event loop 线程发起的。
框架通过一层抽象,让应用程序的开发者只需要看到回调函数,回调函数中的对象,也都是如 buffer 和 tcp_connection 这样封装过的对象,这样像套接字、字节流等底层实现的细节就完全由框架来完成了。
框架帮我们做了很多事情,那这些事情是如何做到的?在第四篇实战篇,我们将一一揭开答案。如果你有兴趣,不妨先看看实现代码。

样例程序结果

启动服务器,可以从屏幕输出上看到,使用的是 epoll 作为事件分发器。
$./epoll-server-multithreads
[msg] set epoll as dispatcher, main thread
[msg] add channel fd == 5, main thread
[msg] set epoll as dispatcher, Thread-1
[msg] add channel fd == 9, Thread-1
[msg] event loop thread init and signal, Thread-1
[msg] event loop run, Thread-1
[msg] event loop thread started, Thread-1
[msg] set epoll as dispatcher, Thread-2
[msg] add channel fd == 12, Thread-2
[msg] event loop thread init and signal, Thread-2
[msg] event loop run, Thread-2
[msg] event loop thread started, Thread-2
[msg] set epoll as dispatcher, Thread-3
[msg] add channel fd == 15, Thread-3
[msg] event loop thread init and signal, Thread-3
[msg] event loop run, Thread-3
[msg] event loop thread started, Thread-3
[msg] set epoll as dispatcher, Thread-4
[msg] add channel fd == 18, Thread-4
[msg] event loop thread init and signal, Thread-4
[msg] event loop run, Thread-4
[msg] event loop thread started, Thread-4
[msg] add channel fd == 6, main thread
[msg] event loop run, main thread
开启多个 telnet 客户端,连接上该服务器, 通过屏幕输入和服务器端交互。
$telnet 127.0.0.1 43211
Trying 127.0.0.1...
Connected to 127.0.0.1.
Escape character is '^]'.
fafaf
snsns
^]
telnet> quit
Connection closed.
服务端显示不断地从 epoll_wait 中返回处理 I/O 事件。
[msg] epoll_wait wakeup, main thread
[msg] get message channel fd==6 for read, main thread
[msg] activate channel fd == 6, revents=2, main thread
[msg] new connection established, socket == 19
connection completed
[msg] epoll_wait wakeup, Thread-1
[msg] get message channel fd==9 for read, Thread-1
[msg] activate channel fd == 9, revents=2, Thread-1
[msg] wakeup, Thread-1
[msg] add channel fd == 19, Thread-1
[msg] epoll_wait wakeup, Thread-1
[msg] get message channel fd==19 for read, Thread-1
[msg] activate channel fd == 19, revents=2, Thread-1
get message from tcp connection connection-19
afasf
[msg] epoll_wait wakeup, main thread
[msg] get message channel fd==6 for read, main thread
[msg] activate channel fd == 6, revents=2, main thread
[msg] new connection established, socket == 20
connection completed
[msg] epoll_wait wakeup, Thread-2
[msg] get message channel fd==12 for read, Thread-2
[msg] activate channel fd == 12, revents=2, Thread-2
[msg] wakeup, Thread-2
[msg] add channel fd == 20, Thread-2
[msg] epoll_wait wakeup, Thread-2
[msg] get message channel fd==20 for read, Thread-2
[msg] activate channel fd == 20, revents=2, Thread-2
get message from tcp connection connection-20
asfasfas
[msg] epoll_wait wakeup, Thread-2
[msg] get message channel fd==20 for read, Thread-2
[msg] activate channel fd == 20, revents=2, Thread-2
connection closed
[msg] epoll_wait wakeup, main thread
[msg] get message channel fd==6 for read, main thread
[msg] activate channel fd == 6, revents=2, main thread
[msg] new connection established, socket == 21
connection completed
[msg] epoll_wait wakeup, Thread-3
[msg] get message channel fd==15 for read, Thread-3
[msg] activate channel fd == 15, revents=2, Thread-3
[msg] wakeup, Thread-3
[msg] add channel fd == 21, Thread-3
[msg] epoll_wait wakeup, Thread-3
[msg] get message channel fd==21 for read, Thread-3
[msg] activate channel fd == 21, revents=2, Thread-3
get message from tcp connection connection-21
dfasfadsf
[msg] epoll_wait wakeup, Thread-1
[msg] get message channel fd==19 for read, Thread-1
[msg] activate channel fd == 19, revents=2, Thread-1
connection closed
[msg] epoll_wait wakeup, main thread
[msg] get message channel fd==6 for read, main thread
[msg] activate channel fd == 6, revents=2, main thread
[msg] new connection established, socket == 22
connection completed
[msg] epoll_wait wakeup, Thread-4
[msg] get message channel fd==18 for read, Thread-4
[msg] activate channel fd == 18, revents=2, Thread-4
[msg] wakeup, Thread-4
[msg] add channel fd == 22, Thread-4
[msg] epoll_wait wakeup, Thread-4
[msg] get message channel fd==22 for read, Thread-4
[msg] activate channel fd == 22, revents=2, Thread-4
get message from tcp connection connection-22
fafaf
[msg] epoll_wait wakeup, Thread-4
[msg] get message channel fd==22 for read, Thread-4
[msg] activate channel fd == 22, revents=2, Thread-4
connection closed
[msg] epoll_wait wakeup, Thread-3
[msg] get message channel fd==21 for read, Thread-3
[msg] activate channel fd == 21, revents=2, Thread-3
connection closed
其中主线程的 epoll_wait 只处理 acceptor 套接字的事件,表示的是连接的建立;反应堆子线程的 epoll_wait 主要处理的是已连接套接字的读写事件。这幅图详细解释了这部分逻辑。

epoll 的性能分析

epoll 的性能凭什么就要比 poll 或者 select 好呢?这要从两个角度来说明。
第一个角度是事件集合。在每次使用 poll 或 select 之前,都需要准备一个感兴趣的事件集合,系统内核拿到事件集合,进行分析并在内核空间构建相应的数据结构来完成对事件集合的注册。而 epoll 则不是这样,epoll 维护了一个全局的事件集合,通过 epoll 句柄,可以操纵这个事件集合,增加、删除或修改这个事件集合里的某个元素。要知道在绝大多数情况下,事件集合的变化没有那么的大,这样操纵系统内核就不需要每次重新扫描事件集合,构建内核空间数据结构。
第二个角度是就绪列表。每次在使用 poll 或者 select 之后,应用程序都需要扫描整个感兴趣的事件集合,从中找出真正活动的事件,这个列表如果增长到 10K 以上,每次扫描的时间损耗也是惊人的。事实上,很多情况下扫描完一圈,可能发现只有几个真正活动的事件。而 epoll 则不是这样,epoll 返回的直接就是活动的事件列表,应用程序减少了大量的扫描时间。
此外, epoll 还提供了更高级的能力——边缘触发。第 23 讲通过一个直观的例子,讲解了边缘触发和条件触发的区别。
这里再举一个例子说明一下。
如果某个套接字有 100 个字节可以读,边缘触发(edge-triggered)和条件触发(level-triggered)都会产生 read ready notification 事件,如果应用程序只读取了 50 个字节,边缘触发就会陷入等待;而条件触发则会因为还有 50 个字节没有读取完,不断地产生 read ready notification 事件。
在条件触发下(level-triggered),如果某个套接字缓冲区可以写,会无限次返回 write ready notification 事件,在这种情况下,如果应用程序没有准备好,不需要发送数据,一定需要解除套接字上的 ready notification 事件,否则 CPU 就直接跪了。
我们简单地总结一下,边缘触发只会产生一次活动事件,性能和效率更高。不过,程序处理起来要更为小心。

总结

本讲我们将程序框架切换到了 epoll 的版本,和 poll 版本相比,只是底层的框架做了更改,上层应用程序不用做任何修改,这也是程序框架强大的地方。和 poll 相比,epoll 从事件集合和就绪列表两个方面加强了程序性能,是 Linux 下高性能网络程序的首选。

思考题

最后我给你布置两道思考题:
第一道,说说你对边缘触发和条件触发的理解。
第二道,对于边缘触发和条件触发,onMessage 函数处理要注意什么?
欢迎你在评论区写下你的思考,也欢迎把这篇文章分享给你的朋友或者同事,一起交流进步。
分享给需要的人,Ta购买本课程,你将得18
生成海报并分享

赞 7

提建议

上一篇
28 | I/O多路复用进阶:子线程使用poll处理连接I/O事件
下一篇
30 | 真正的大杀器:异步I/O探索
unpreview
 写留言

精选留言(22)

  • 沉淀的梦想
    2019-10-15
    在ET的情况下,write ready notification只会在套接字可写的时候通知一次的话,那个时候应用还没准备好数据,等到应用准备好数据时,却又没有通知了,会不会导致数据滞留发不出去?这种情况是怎么解决的呢?

    作者回复: 你可以再次注册这个write ready的事件啊,不是说只能注册一次就结束了,而是你注册了一次,它就通知你一次;而LT的情况下,可能你注册了一次,它通知你好多次。

    17
  • LiYanbin
    2020-01-29
    源代码看起来有点花了点时间,将这部分的代码从抽离了出来,便于大家跟踪代码理解,同时写了简单的makefile。代码地址:https://github.com/kevinrsa/epoll_server_multithreads 。如有不妥,联系删除

    作者回复: makefile写得不错:)

    12
  •  JJ
    2019-10-14
    边缘条件,当套接字缓冲区可写,会不断触发ready notification事件,不是应该条件触发才是这样吗?

    作者回复: 笔误,已经让编辑勘误了,感谢指正。

    共 2 条评论
    7
  • rongyefeng
    2020-05-22
    如果应用程序只读取了 50 个字节,边缘触发就会陷入等待; 这里的陷入等待是什么意思呢

    作者回复: 不会再继续发送read_notification事件,必须等所有的100个字节被读完,才会发送下一个read_notification事件。

    共 2 条评论
    5
  • 张三说
    2019-12-13
    老师,一直没搞懂ET和LT的性能区别,仅仅因为LT会多提醒一些次数就与ET相差明显的性能吗?一直很纠结这个问题

    作者回复: 有没有跑例子程序呢?其实不用纠结,最新的测试表明,两者差别其实没有那么大。但是非要比一个差距的话,ET还是效率好一些,但是对应用程序开发者的要求高一些。

    共 2 条评论
    5
  • 流浪地球
    2019-10-17
    细读了下老师git上的代码,套接字都是设置为非阻塞模式的,但并没有对返回值做判断处理,看上去好像是阻塞式的用法,求解?

    作者回复: 可能是考虑不周,有可能的话麻烦提一个MR或者issue,大家一起来改。

    4
  • 郑祖煌
    2020-07-08
    27章以及以后源代码的难度提升了一个等级了。看了相当吃力呀。

    作者回复: 多读两遍会好很多

    共 2 条评论
    1
  • Joker
    2020-04-16
    老师,这个就绪列表是建立在事件集合之上的对吧。

    作者回复: 是的,是所有感兴趣的事件集合。

    1
  • ray
    2020-04-12
    老师好, 针对第2题,目前想到onMessage函数应该要注意,如果当前程序无法处理该通知,应该要想办法再次注册该事件。 只是具体程序实现就不知道应该怎么写了,可能还要请老师说明一下 哈哈XD 谢谢老师^^
    展开

    作者回复: 当前的实现并不会主动把I/O读写事件从事件通道上摘除哦,所以并不需要重新注册该事件,onMessage就是一个简单的报文解析函数,所要做到的就是在条件触发情况下读完所有的字节,避免不断的再次被事件驱动。

    共 2 条评论
    1
  • 丁小明
    2020-03-10
    为什么 socket已经有缓冲区了,应用层还要缓冲区呢,比如发送,socket也会合并发送

    作者回复: 很简单,应用层需要对接收到的byte字节流进行编解码,为了方便,在应用层进行缓冲,之后进行编解码的操作,再送给业务逻辑层来处理。

    共 2 条评论
    1
  • 传说中的成大大
    2019-10-16
    看到CMake我就完全懵逼。。。。

    作者回复: 还好吧,看一下CMake的文档,以前我一直用的Makefile, CMake也是现学的。

    1
  • Steiner
    2019-10-14
    老师能不能为这个框架写一份README.md,我对这个实现很感兴趣

    作者回复: 你需要什么样的README.md呢?第四篇会详细讲解这个框架的设计,也行你读完之后,可以写一个README.md push到git上呢?

    共 3 条评论
    1
  • P
    2023-02-12 来自浙江
    只提一点,所有关于Reactor的图片都不太准确。流程应该是client->Acceptor->Poller(select/poll/epoll),然而文章中所有的Acceptor都放在了后面,令人疑惑。
  • Running man
    2022-09-30 来自浙江
    event_loop.c编译链接不上pthread库,有哪位朋友知道如何修改cmakelist,gcc版本是11.2.0 ubuntu系统版本是11.2.0,对应内核版本5.15.0-41
  • vv_test
    2021-06-28
    性能对比第一点,是否可以这样理解。select、poll在用户态声明的事件拷贝(我在这里理解拷贝,不是注册,因为下一次调用依旧要传入)到内核态,大量操作copy的情况下耗时不容小觑。而epoll是已经注册到对应的epoll实例。主要是省去了这个copy的时间

    作者回复: 嗯,也是有这方面的考虑,不过更多的还是事件处理的机制和效率的问题。

  • Steiner
    2021-02-18
    有个疑问,这个程序与下一章的HTTP服务器的设计,处理连接的时候,服务器什么时候会关闭对端的连接? 是不断与客户端交互,客户端发送关闭请求才关闭;还是处理完客户端的请求后,发送响应,再关闭

    作者回复: 第一种。代码如下: int handle_read(void *data) { struct tcp_connection *tcpConnection = (struct tcp_connection *) data; struct buffer *input_buffer = tcpConnection->input_buffer; struct channel *channel = tcpConnection->channel; if (buffer_socket_read(input_buffer, channel->fd) > 0) { //应用程序真正读取Buffer里的数据 if (tcpConnection->messageCallBack != NULL) { tcpConnection->messageCallBack(input_buffer, tcpConnection); } } else { handle_connection_closed(tcpConnection); } }

    共 2 条评论
  • нáпの゛
    2020-09-10
    老师,所以不删除写事件,就不需要重新注册是吗?每次缓冲区由满变成可写都会通知一次,是这样理解吗?

    作者回复: 是的,这样的写效率会变低。

  • fedwing
    2020-08-17
    第一个角度是事件集合。在每次使用 poll 或 select 之前,都需要准备一个感兴趣的事件集合,系统内核拿到事件集合,进行分析并在内核空间构建相应的数据结构来完成对事件集合的注册。而 epoll 则不是这样,epoll 维护了一个全局的事件集合,通过 epoll 句柄,可以操纵这个事件集合,增加、删除或修改这个事件集合里的某个元素。要知道在绝大多数情况下,事件集合的变化没有那么的大,这样操纵系统内核就不需要每次重新扫描事件集合,构建内核空间数据结构。 老师,这个不是很理解,看了下,前面的epoll实例代码,epoll_wait时,还是需要传入一个events(看起来是初始化了下)的,这个是做什么用的,我理解,epoll对象本身不是已经有它所关联的事件信息了吗(通过epoll_ctrl add进去)
    展开

    作者回复: epoll_wait返回给用户空间需要处理的 I/O 事件,用这个events来表示,这样我们才知道具体发生了什么事件。具体的例子可以参考第23章。

  • fedwing
    2020-08-17
    老师,请问下,我看用poll实现里的结构配图,可以用threadpool来解耦具体业务逻辑,epoll里的配图,没有这个,其实也是可以加的吧,本质上线程池解耦业务这部分应该是通用吧,只是在事件触发, 事件分发机制上的差别吧?

    作者回复: 是的,完全正确。

  • 林林
    2019-12-02
    文稿中的框架示意图,我看到main reactor 和 sub reactor都各自运行了epoll,请问是否各自处理不同的socket? 如果处理了相同的socket会发生什么吗?

    作者回复: main reactor处理的是监听套接字上的事件,sub reactor处理的是已连接套接字上的事件,两个是不重合的。 如果处理了相同的socket,那么肯定需要通过锁-并发来控制,无形中就增加了处理的开销,降低了程序处理的效率。