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

32 | 自己动手写高性能HTTP服务器(一):设计和思路

32 | 自己动手写高性能HTTP服务器(一):设计和思路-极客时间

32 | 自己动手写高性能HTTP服务器(一):设计和思路

讲述:冯永吉

时长10:21大小9.48M

你好,我是盛延敏,这里是网络编程实战第 32 讲,欢迎回来。
从这一讲开始,我们进入实战篇,开启一个高性能 HTTP 服务器的编写之旅。
在开始编写高性能 HTTP 服务器之前,我们先要构建一个支持 TCP 的高性能网络编程框架,完成这个 TCP 高性能网络框架之后,再增加 HTTP 特性的支持就比较容易了,这样就可以很快开发出一个高性能的 HTTP 服务器程序。

设计需求

在第三个模块性能篇中,我们已经使用这个网络编程框架完成了多个应用程序的开发,这也等于对网络编程框架提出了编程接口方面的需求。综合之前的使用经验,TCP 高性能网络框架需要满足的需求有以下三点。
第一,采用 reactor 模型,可以灵活使用 poll/epoll 作为事件分发实现。
第二,必须支持多线程,从而可以支持单线程单 reactor 模式,也可以支持多线程主 - 从 reactor 模式。可以将套接字上的 I/O 事件分离到多个线程上。
第三,封装读写操作到 Buffer 对象中。
按照这三个需求,正好可以把整体设计思路分成三块来讲解,分别包括反应堆模式设计、I/O 模型和多线程模型设计、数据读写封装和 buffer。今天我们主要讲一下主要的设计思路和数据结构,以及反应堆模式设计。

主要设计思路

反应堆模式设计

反应堆模式,按照性能篇的讲解,主要是设计一个基于事件分发和回调的反应堆框架。这个框架里面的主要对象包括:
event_loop
你可以把 event_loop 这个对象理解成和一个线程绑定的无限事件循环,你会在各种语言里看到 event_loop 这个抽象。这是什么意思呢?简单来说,它就是一个无限循环着的事件分发器,一旦有事件发生,它就会回调预先定义好的回调函数,完成事件的处理。
具体来说,event_loop 使用 poll 或者 epoll 方法将一个线程阻塞,等待各种 I/O 事件的发生。
channel
对各种注册到 event_loop 上的对象,我们抽象成 channel 来表示,例如注册到 event_loop 上的监听事件,注册到 event_loop 上的套接字读写事件等。在各种语言的 API 里,你都会看到 channel 这个对象,大体上它们表达的意思跟我们这里的设计思路是比较一致的。
acceptor
acceptor 对象表示的是服务器端监听器,acceptor 对象最终会作为一个 channel 对象,注册到 event_loop 上,以便进行连接完成的事件分发和检测。
event_dispatcher
event_dispatcher 是对事件分发机制的一种抽象,也就是说,可以实现一个基于 poll 的 poll_dispatcher,也可以实现一个基于 epoll 的 epoll_dispatcher。在这里,我们统一设计一个 event_dispatcher 结构体,来抽象这些行为。
channel_map
channel_map 保存了描述字到 channel 的映射,这样就可以在事件发生时,根据事件类型对应的套接字快速找到 channel 对象里的事件处理函数。

I/O 模型和多线程模型设计

I/O 线程和多线程模型,主要解决 event_loop 的线程运行问题,以及事件分发和回调的线程执行问题。
thread_pool
thread_pool 维护了一个 sub-reactor 的线程列表,它可以提供给主 reactor 线程使用,每次当有新的连接建立时,可以从 thread_pool 里获取一个线程,以便用它来完成对新连接套接字的 read/write 事件注册,将 I/O 线程和主 reactor 线程分离。
event_loop_thread
event_loop_thread 是 reactor 的线程实现,连接套接字的 read/write 事件检测都是在这个线程里完成的。

Buffer 和数据读写

buffer
buffer 对象屏蔽了对套接字进行的写和读的操作,如果没有 buffer 对象,连接套接字的 read/write 事件都需要和字节流直接打交道,这显然是不友好的。所以,我们也提供了一个基本的 buffer 对象,用来表示从连接套接字收取的数据,以及应用程序即将需要发送出去的数据。
tcp_connection
tcp_connection 这个对象描述的是已建立的 TCP 连接。它的属性包括接收缓冲区、发送缓冲区、channel 对象等。这些都是一个 TCP 连接的天然属性。
tcp_connection 是大部分应用程序和我们的高性能框架直接打交道的数据结构。我们不想把最下层的 channel 对象暴露给应用程序,因为抽象的 channel 对象不仅仅可以表示 tcp_connection,前面提到的监听套接字也是一个 channel 对象,后面提到的唤醒 socketpair 也是一个 channel 对象。所以,我们设计了 tcp_connection 这个对象,希望可以提供给用户比较清晰的编程入口。

反应堆模式设计

概述

下面,我们详细讲解一下以 event_loop 为核心的反应堆模式设计。这里有一张 event_loop 的运行详图,你可以对照这张图来理解。
当 event_loop_run 完成之后,线程进入循环,首先执行 dispatch 事件分发,一旦有事件发生,就会调用 channel_event_activate 函数,在这个函数中完成事件回调函数 eventReadcallback 和 eventWritecallback 的调用,最后再进行 event_loop_handle_pending_channel,用来修改当前监听的事件列表,完成这个部分之后,又进入了事件分发循环。

event_loop 分析

说 event_loop 是整个反应堆模式设计的核心,一点也不为过。先看一下 event_loop 的数据结构。
在这个数据结构中,最重要的莫过于 event_dispatcher 对象了。你可以简单地把 event_dispatcher 理解为 poll 或者 epoll,它可以让我们的线程挂起,等待事件的发生。
这里有一个小技巧,就是 event_dispatcher_data,它被定义为一个 void * 类型,可以按照我们的需求,任意放置一个我们需要的对象指针。这样,针对不同的实现,例如 poll 或者 epoll,都可以根据需求,放置不同的数据对象。
event_loop 中还保留了几个跟多线程有关的对象,如 owner_thread_id 是保留了每个 event loop 的线程 ID,mutex 和 con 是用来进行线程同步的。
socketPair 是父线程用来通知子线程有新的事件需要处理。pending_head 和 pending_tail 是保留在子线程内的需要处理的新事件。
struct event_loop {
int quit;
const struct event_dispatcher *eventDispatcher;
/** 对应的event_dispatcher的数据. */
void *event_dispatcher_data;
struct channel_map *channelMap;
int is_handle_pending;
struct channel_element *pending_head;
struct channel_element *pending_tail;
pthread_t owner_thread_id;
pthread_mutex_t mutex;
pthread_cond_t cond;
int socketPair[2];
char *thread_name;
};
下面我们看一下 event_loop 最主要的方法 event_loop_run 方法,前面提到过,event_loop 就是一个无限 while 循环,不断地在分发事件。
/**
*
* 1.参数验证
* 2.调用dispatcher来进行事件分发,分发完回调事件处理函数
*/
int event_loop_run(struct event_loop *eventLoop) {
assert(eventLoop != NULL);
struct event_dispatcher *dispatcher = eventLoop->eventDispatcher;
if (eventLoop->owner_thread_id != pthread_self()) {
exit(1);
}
yolanda_msgx("event loop run, %s", eventLoop->thread_name);
struct timeval timeval;
timeval.tv_sec = 1;
while (!eventLoop->quit) {
//block here to wait I/O event, and get active channels
dispatcher->dispatch(eventLoop, &timeval);
//handle the pending channel
event_loop_handle_pending_channel(eventLoop);
}
yolanda_msgx("event loop end, %s", eventLoop->thread_name);
return 0;
}
代码很明显地反映了这一点,这里我们在 event_loop 不退出的情况下,一直在循环,循环体中调用了 dispatcher 对象的 dispatch 方法来等待事件的发生。

event_dispacher 分析

为了实现不同的事件分发机制,这里把 poll、epoll 等抽象成了一个 event_dispatcher 结构。event_dispatcher 的具体实现有 poll_dispatcher 和 epoll_dispatcher 两种,实现的方法和性能篇2122 讲类似,这里就不再赘述,你如果有兴趣的话,可以直接研读代码。
/** 抽象的event_dispatcher结构体,对应的实现如select,poll,epoll等I/O复用. */
struct event_dispatcher {
/** 对应实现 */
const char *name;
/** 初始化函数 */
void *(*init)(struct event_loop * eventLoop);
/** 通知dispatcher新增一个channel事件*/
int (*add)(struct event_loop * eventLoop, struct channel * channel);
/** 通知dispatcher删除一个channel事件*/
int (*del)(struct event_loop * eventLoop, struct channel * channel);
/** 通知dispatcher更新channel对应的事件*/
int (*update)(struct event_loop * eventLoop, struct channel * channel);
/** 实现事件分发,然后调用event_loop的event_activate方法执行callback*/
int (*dispatch)(struct event_loop * eventLoop, struct timeval *);
/** 清除数据 */
void (*clear)(struct event_loop * eventLoop);
};

channel 对象分析

channel 对象是用来和 event_dispather 进行交互的最主要的结构体,它抽象了事件分发。一个 channel 对应一个描述字,描述字上可以有 READ 可读事件,也可以有 WRITE 可写事件。channel 对象绑定了事件处理函数 event_read_callback 和 event_write_callback。
typedef int (*event_read_callback)(void *data);
typedef int (*event_write_callback)(void *data);
struct channel {
int fd;
int events; //表示event类型
event_read_callback eventReadCallback;
event_write_callback eventWriteCallback;
void *data; //callback data, 可能是event_loop,也可能是tcp_server或者tcp_connection
};

channel_map 对象分析

event_dispatcher 在获得活动事件列表之后,需要通过文件描述字找到对应的 channel,从而回调 channel 上的事件处理函数 event_read_callback 和 event_write_callback,为此,设计了 channel_map 对象。
/**
* channel映射表, key为对应的socket描述字
*/
struct channel_map {
void **entries;
/* The number of entries available in entries */
int nentries;
};
channel_map 对象是一个数组,数组的下标即为描述字,数组的元素为 channel 对象的地址。
比如描述字 3 对应的 channel,就可以这样直接得到。
struct chanenl * channel = map->entries[3];
这样,当 event_dispatcher 需要回调 channel 上的读、写函数时,调用 channel_event_activate 就可以,下面是 channel_event_activate 的实现,在找到了对应的 channel 对象之后,根据事件类型,回调了读函数或者写函数。注意,这里使用了 EVENT_READ 和 EVENT_WRITE 来抽象了 poll 和 epoll 的所有读写事件类型。
int channel_event_activate(struct event_loop *eventLoop, int fd, int revents) {
struct channel_map *map = eventLoop->channelMap;
yolanda_msgx("activate channel fd == %d, revents=%d, %s", fd, revents, eventLoop->thread_name);
if (fd < 0)
return 0;
if (fd >= map->nentries)return (-1);
struct channel *channel = map->entries[fd];
assert(fd == channel->fd);
if (revents & (EVENT_READ)) {
if (channel->eventReadCallback) channel->eventReadCallback(channel->data);
}
if (revents & (EVENT_WRITE)) {
if (channel->eventWriteCallback) channel->eventWriteCallback(channel->data);
}
return 0;
}

增加、删除、修改 channel event

那么如何增加新的 channel event 事件呢?下面这几个函数是用来增加、删除和修改 channel event 事件的。
int event_loop_add_channel_event(struct event_loop *eventLoop, int fd, struct channel *channel1);
int event_loop_remove_channel_event(struct event_loop *eventLoop, int fd, struct channel *channel1);
int event_loop_update_channel_event(struct event_loop *eventLoop, int fd, struct channel *channel1);
前面三个函数提供了入口能力,而真正的实现则落在这三个函数上:
int event_loop_handle_pending_add(struct event_loop *eventLoop, int fd, struct channel *channel);
int event_loop_handle_pending_remove(struct event_loop *eventLoop, int fd, struct channel *channel);
int event_loop_handle_pending_update(struct event_loop *eventLoop, int fd, struct channel *channel);
我们看一下其中的一个实现,event_loop_handle_pending_add 在当前 event_loop 的 channel_map 里增加一个新的 key-value 对,key 是文件描述字,value 是 channel 对象的地址。之后调用 event_dispatcher 对象的 add 方法增加 channel event 事件。注意这个方法总在当前的 I/O 线程中执行。
// in the i/o thread
int event_loop_handle_pending_add(struct event_loop *eventLoop, int fd, struct channel *channel) {
yolanda_msgx("add channel fd == %d, %s", fd, eventLoop->thread_name);
struct channel_map *map = eventLoop->channelMap;
if (fd < 0)
return 0;
if (fd >= map->nentries) {
if (map_make_space(map, fd, sizeof(struct channel *)) == -1)
return (-1);
}
//第一次创建,增加
if ((map)->entries[fd] == NULL) {
map->entries[fd] = channel;
//add channel
struct event_dispatcher *eventDispatcher = eventLoop->eventDispatcher;
eventDispatcher->add(eventLoop, channel);
return 1;
}
return 0;
}

总结

在这一讲里,我们介绍了高性能网络编程框架的主要设计思路和基本数据结构,以及反应堆设计相关的具体做法。在接下来的章节中,我们将继续编写高性能网络编程框架的线程模型以及读写 Buffer 部分。

思考题

和往常一样,给你留两道思考题:
第一道,如果你有兴趣,不妨实现一个 select_dispatcher 对象,用 select 方法实现定义好的 event_dispatcher 接口;
第二道,仔细研读 channel_map 实现中的 map_make_space 部分,说说你的理解。
欢迎你在评论区写下你的思考,也欢迎把这篇文章分享给你的朋友或者同事,一起交流一下。
分享给需要的人,Ta购买本课程,你将得18
生成海报并分享

赞 7

提建议

上一篇
31丨性能篇答疑:epoll源码深度剖析
下一篇
33 | 自己动手写高性能HTTP服务器(二):I/O模型和多线程模型实现
unpreview
 写留言

精选留言(27)

  • LDxy
    2019-10-21
    event_loop_handle_pending_add函数中, map->entries[fd] = calloc(1, sizeof(struct channel *)); map->entries[fd] = channel; 这两行都给map->entries[fd] 赋值,后一行不是覆盖上一行的赋值了么?有何用意?

    作者回复: 这块是我的疏忽,应该直接赋值的,可能是开始我撰写的时候channel对象的初始化放到了event_loop_handle_pending_add函数中,后来把channel对象的初始化重构到外面,这里忘记删掉了。 已经更新文稿(待周一编辑更新)和github代码,感谢指正。

    7
  • 吴小智
    2019-10-22
    map_make_space() 函数里 realloc() 和 memset() 两个函数用的很巧妙啊,realloc() 用来扩容,且把旧的内容搬过去,memset() 用来给新申请的内存赋 0 值。赞,C 语言太强大了。

    作者回复: 显然是看进去了。

    共 2 条评论
    7
  • 传说中的成大大
    2019-10-21
    第二道题 就是一个扩容啊 类似std的vector自动扩容 而且每次成倍的增长
    3
  • 酸葡萄
    2019-12-01
    老师你好,问个基础的问题: epoll_dispatcher和poll_dispatcher都有,在添加,删除,更新事件时都有如下的逻辑,其中if条件中的判断怎么理解啊? if (channel1->events & EVENT_READ) { events = events | POLLRDNORM; } if (channel1->events & EVENT_WRITE) { events = events | POLLWRNORM; }
    展开

    作者回复: 这里是位与操作,举个例子,EVENT_READ可能为二进制的00000010,如果有可读事件发生,那么在这个位上的bit值一定位1,这样位与的结果就说明这个事件发生了。之所以采用位与,而不是位或,是因为只需要关心这一种类型的事件。

    共 2 条评论
    2
  • 凌空飞起的剪刀腿
    2021-06-07
    int map_make_space(struct channel_map *map, int slot, int msize) { if (map->nentries <= slot) { int nentries = map->nentries ? map->nentries : 32; void **tmp; while (nentries <= slot) nentries <<= 1; tmp = (void **) realloc(map->entries, nentries * msize); if (tmp == NULL) return (-1); memset(&tmp[map->nentries], 0, (nentries - map->nentries) * msize); map->nentries = nentries; map->entries = tmp; } return (0); } 老师,fd不一定是连续的吧,这样会浪费内存存储空间吧?
    展开

    作者回复: 很好的问题。 第一,你可以看看实际分配的fd,大概会是什么样子; 第二,除了这个方法,你有别的更高效的方法吗?因为从fd来查找数据,需要非常的快; 我个人的判断,这点内存不算什么,因为我在设计这个结构时,大部分数据都是指针类型的。

    1
  • 谁家内存泄露了
    2021-04-12
    老师好,请问您的代码中关于锁的使用,我想知道您关于每个loop都设计了一个锁,可是这几个mutex都是局部变量吧?他们的作用范围是什么样的呢?这里想不清楚,请指点一下!

    作者回复: 所有的作用范围是全局的,而mutex锁看情况,有些是线程级别的,比如这里: pthread_mutex_lock(&eventLoopThread->mutex);

    1
  • Steiner
    2021-02-18
    如果Channel是一个管道,他连接着哪两个对象?

    作者回复: 连接着client端的套接字和server端的套接字。

    1
  • 沉淀的梦想
    2019-10-22
    看到map_make_space里面的realloc函数,突然有个疑问,既然操作系统底层支持直接在原数组上扩充内存,为什么Java不支持直接在原数组上扩容呢,ArrayList每次扩容都要重新拷贝一份原来的数据。

    作者回复: 好问题,我试着解读一下: 第一,Java有JVM实现,在Java的世界里,它的对象是统一被JVM管理的,包括GC,对象管理,基于这一层考虑,它不可能使用系统级别的对象内存管理,这两个没有办法调和,就像你举的例子,如果我们创建一个类似ReallocList对象,那么这个对象的内存管理完全不是JVM那套了; 第二,JVM是一个跨OS的实现,我不知道是否Windows也有类似realloc函数,这样就需要JVM做到跨OS的直接内存接管,这和Java的思想是不一致的。

    共 3 条评论
    1
  • 功夫熊猫
    2022-09-27 来自江苏
    老师,套接字不是用于进城通信的嘛,线程也能用?
  • 菜鸡互啄
    2022-07-26
    再来看看 重温重温。感谢老师 这个教程 是我入门网络编程的领路人。我是做iOS开发的 因为一些原因转到网络这边 一开始一头懵逼 学习了老师的教程就清晰了很多。后面接触到的知识 老师的教程都能引申到 真的很赞。
  • 漠博嵩
    2022-05-24
    感觉就是仿照netty框架做的

    作者回复: 还真不是。

  • 菜鸡
    2022-05-08
    第二个问题有点疑问。channel_map中元素的空间大小是与fd的值正相关的,而不是跟当前在线的连接数量正相关,这样做是不是有点浪费内存?比如经历了很多次连接、断开之后,fd返回的值比较大,而此时只有几个未断开的连接,那么channel_map有必要申请那么大的内存空间嘛?

    作者回复: 还好吧,channel_map中的元素没几个字节,比起复杂的压缩算法,这点实在是微不足道。而且,你也没办法预测后面的连接情况,准备好一个上限比较大的fd存储空间,其实是效率比较高的。

  • 群书
    2021-11-12
    用sock对通知 唤醒会不会增加逻辑线程或主线程的系统调用次数 限制了吞吐量呢

    作者回复: 不会。因为唤醒是kernel干的,只不过是多了一路I/O而已。

  • Steiner
    2021-02-18
    我看了下定义,channel_element就像是个链表节点,为什么不用C++来做这块呢?

    作者回复: 因为是C的代码,通篇都是C语言,不需要学习C++知识。

  • YUAN
    2020-11-05
    老师请问这个channel就相当于libevent中的event结构体吧?

    作者回复: 嗯,差不多的意思。

  • spark
    2020-09-25
    盛老师好: 为什么要在下面这个函数中lock和unlock? 不是每个线程都对应一个自己的event_loop吗? 这样的话event_loop就不是shared resource。 int event_loop_handle_pending_channel(struct event_loop *eventLoop) { //get the lock pthread_mutex_lock(&eventLoop->mutex); eventLoop->is_handle_pending = 1; struct channel_element *channelElement = eventLoop->pending_head; while (channelElement != NULL) { //save into event_map struct channel *channel = channelElement->channel; int fd = channel->fd; if (channelElement->type == 1) { event_loop_handle_pending_add(eventLoop, fd, channel); } else if (channelElement->type == 2) { event_loop_handle_pending_remove(eventLoop, fd, channel); } else if (channelElement->type == 3) { event_loop_handle_pending_update(eventLoop, fd, channel); } channelElement = channelElement->next; } eventLoop->pending_head = eventLoop->pending_tail = NULL; eventLoop->is_handle_pending = 0; //release the lock pthread_mutex_unlock(&eventLoop->mutex); return 0; }
    展开

    作者回复: 是的呀,因为每个线程对应自己的event_loop对象,而发起event_loop_handle_pending_channel操作的线程可能是不同的线程,所以使用的是线程级别的lock,也就是event_loop里面的lock。

    共 2 条评论
  • 衬衫的价格是19美元
    2020-07-23
    channel_map这里map->entries是一个数组,数组的下标是fd,数组的元素是channel的地址,如果新增的fd跳变很大的话比如从3变成了100,会不会浪费了很多的空间

    作者回复: 是一个问题。不过,第一,跳变不是非常巨大,这个可以从实际的程序运行可以看到fd的增长,一个合理的解释是OS也是在"智能"的分配文件描述字;第二,即使跳变,一个channel地址也就是8个字节(64bit OS),占用的内存也不是特别多。 好处fd到channel的查询,是非常非常快的。

    共 2 条评论
  • 2020-05-04
    问个c语言的问题,比如event_loop_handle_pending_channel这个函数,返回值是int类型,但是除了函数最后是个return 0,其他地方没有错误处理,为什么要返回0?还是就是一种习惯?

    作者回复: 好问题。 按照道理说,返回值非0表示有错误信息,只是这里我没有返回而已。个人习惯哈,你可以改成无返回值。

  • chs
    2019-12-24
    老师,您为了支持poll和epoll,抽象出了struct event_dispatcher结构体,然后在epoll_dispatcher.c 和poll_dispatcher.c中分别实现struct event_dispatcher中定义的接口。请问epoll_dispatcher.c中的 const struct event_dispatcher epoll_dispatcher变量 和poll_dispatcher.c中的const struct event_dispatcher poll_dispatcher变量怎样让其他文件知道其定义的。我自己写的提示上边两个变量未定义。

    作者回复: 这个是通过下面的方式在头文件中声明: extern const struct event_dispatcher poll_dispatcher; extern const struct event_dispatcher epoll_dispatcher;

  • Steiner
    2019-10-22
    请问channel里的fd也需要设置为非阻塞吗

    作者回复: channel里的fd是在有连接建立时创建好的,当然,也是被设置为非阻塞的,这里channle对象不需要关系fd的属性。