epoll、多线程模型

我怕爱的太早我们不能终老 提交于 2019-12-29 14:50:12

如何切换epoll

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)

可以修改 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 (

样例程序


#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_wait 只处理 acceptor 套接字的事件,表示的是连接的建立;反应堆子线程的 epoll_wait 主要处理的是已连接套接字的读写事件。文稿中的这幅图详细解释了这部分逻辑。
在这里插入图片描述

epoll 的性能分析

第一个角度是事件集合。在每次使用 poll 或 select 之前,都需要准备一个感兴趣的事件集合,系统内核拿到事件集合,进行分析并在内核空间构建相应的数据结构来完成对事件集合的注册。而 epoll 则不是这样,epoll 维护了一个全局的事件集合,通过 epoll 句柄,可以操纵这个事件集合,增加、删除或修改这个事件集合里的某个元素。要知道在绝大多数情况下,事件集合的变化没有那么的大,这样操纵系统内核就不需要每次重新扫描事件集合,构建内核空间数据结构。

第二个角度是就绪列表。每次在使用 poll 或者 select 之后,应用程序都需要扫描整个感兴趣的事件集合,从中找出真正活动的事件,这个列表如果增长到 10K 以上,每次扫描的时间损耗也是惊人的。事实上,很多情况下扫描完一圈,可能发现只有几个真正活动的事件。而 epoll 则不是这样,epoll 返回的直接就是活动的事件列表,应用程序减少了大量的扫描时间。

条件触发、边缘触发
如果某个套接字有 100 个字节可以读,边缘触发(edge-triggered)和条件触发(level-triggered)都会产生 read ready notification 事件,如果应用程序只读取了 50 个字节,边缘触发就会陷入等待;而条件触发则会因为还有 50 个字节没有读取完,不断地产生 read ready notification 事件。

在条件触发下(level-triggered),如果某个套接字缓冲区可以写,会无限次返回 write ready notification 事件,在这种情况下,如果应用程序没有准备好,不需要发送数据,一定需要解除套接字上的 ready notification 事件,否则 CPU 就直接跪了。

我们简单地总结一下,边缘触发只会产生一次活动事件,性能和效率更高。不过,程序处理起来要更为小心。

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!