在包含网络通讯的程序中,都需要管理描述符。描述符的管理也是服务器程序性能的关键点之一。很多开源软件,比如libevent和HAProxy都有自己的方式。但是他们都有相似的地方,并且目标都是一致的:把性能发挥到极致。因为当前的工作中也使用到了网络通讯,因此希望能从这些开源软件中借鉴学习一些东西,并将他们汇总起来。下面描述一下我对HAProxy的描述符管理的理解和总结。
HAProxy使用统一的接口对描述符进行管理,不同的事件检测接口,提供不同的实现方式。描述符的管理一般包括增删改查,并在此基础上实现事件的触发和处理。
HAProxy的描述符相关代码主要在include/types/fd.h、include/proto/fd.h和src/fd.c中。
数据结构
为了性能的考虑,首先要设计一个合适的数据结构。HAProxy使用一个数组存放描述符,在fd.c中有如下定义:
struct fdtab *fdtab = NULL; /* array of all the file descriptors */
HAProxy直接使用fd(描述符)的值做索引,能够很快的检索到描述符信息。不管是增删改查,效率都是O(1),但这也是用空间换时间,不过还好,系统在给进程分配描述符的时候都是从0开始的,并且会优先从小的未使用的开始(至少Linux是这样)。
struct fdtab的定义如下:
struct fdtab {
int (*iocb)(int fd); /* IO处理器,返回 FD_WAIT_* */
void *owner; /* the connection or listener associated with this fd, NULL if closed */
unsigned int cache; /* position+1 in the FD cache. 0=not in cache. */
unsigned char state; /* FD state for read and write directions (2*3 bits) */
unsigned char ev; /* event seen in return of poll() : FD_POLL_* */
unsigned char new:1; /* 1 if this fd has just been created */
unsigned char updated:1; /* 1 if this fd is already in the update list */
unsigned char linger_risk:1; /* 1 if we must kill lingering before closing */
unsigned char cloned:1; /* 1 if a cloned socket, requires EPOLL_CTL_DEL on close */
};
HAProxy中的poller
poller的定义
HAProxy的描述符数据结构设计的很好,实现了最快的增删改查,但是描述符的最重要的功能是检测新事件的发生。在众多操作系统中,都提供一些异步检测机制,比如poll、epoll等,HAProxy统称之为poller:
struct poller {
void *private; /* any private data for the poller */
void REGPRM1 (*clo)(const int fd); /* mark <fd> as closed */
void REGPRM2 (*poll)(struct poller *p, int exp); /* the poller itself */
int REGPRM1 (*init)(struct poller *p); /* poller initialization */
void REGPRM1 (*term)(struct poller *p); /* termination of this poller */
int REGPRM1 (*test)(struct poller *p); /* pre-init check of the poller */
int REGPRM1 (*fork)(struct poller *p); /* post-fork re-opening */
const char *name; /* poller name */
int pref; /* try pollers with higher preference first */
};
pref:默认为0,意思是这个poller不可用。init函数失败的时候要设置为0,term函数也要设置为0.一个普通的未优化的select() poller要设置为100。
pref类似于一个poller的权重,值越大,权重越大。在使用时,优先选用权重较大的。haproxy支持的有epoll, kqueue, poll和select,他们的pref分别是300,300,300和150。
private:init函数初始化时创建的,在term中销毁
clo:poller标记fd被关闭的
HAProxy使用一个数组记录所有的poller:
struct poller pollers[MAX_POLLERS];
int nbpollers = 0; // 记录当前poller的个数
poller的初始化
这些poller的初始化很奇特,都是在main函数之前做的,比如poll类型的poller。
__attribute__((constructor))
static void _do_register(void)
{
struct poller *p;
if (nbpollers >= MAX_POLLERS)
return;
p = &pollers[nbpollers++]; // 在这里加到全局数组中,nbpoller记录个数
p->name = "poll";
p->pref = 200;
p->private = NULL;
p->clo = __fd_clo;
p->test = _do_test;
p->init = _do_init;
p->term = _do_term;
p->poll = _do_poll;
}
根据代码中的介绍,__attribute__((constructor))的含义是这是一个在main函数前调用的构造函数,是一种GCC(版本2.95或以后)特性。当然,这种函数中不能使用写一些未初始化的数据。
找一个poller的例子来观察描述符管理者的行为,比如poll类型的poller。
首先看看这么多poller都是怎么初始化出来的,并假设最后选取了poll类型的poller。
在fd.c中,有poller的初始化函数:
int init_pollers()
{
int p;
struct poller *bp;
// fd_cache和fd_updt也是管理描述符用的数据结构,慢慢说。
if ((fd_cache = (uint32_t *)calloc(1, sizeof(uint32_t) * global.maxsock)) == NULL)
goto fail_cache;
if ((fd_updt = (uint32_t *)calloc(1, sizeof(uint32_t) * global.maxsock)) == NULL)
goto fail_updt;
// 就是用一个循环,按照poller的pref值作为权重,选取最好的poller
// 然后复制到当前的一个全局变量cur_poller中,以后所有的执行,
// 都是调用cur_poller,不明白为啥不把写成指针类型。
do {
bp = NULL;
// 找到一个pref最大的poller
// pollers是在编译时就添加的,比如在ev_poll.c中
for (p = 0; p < nbpollers; p++)
if (!bp || (pollers[p].pref > bp->pref))
bp = &pollers[p];
if (!bp || bp->pref == 0)
break;
if (bp->init(bp)) {
memcpy(&cur_poller, bp, sizeof(*bp));
return 1;
}
} while (!bp || bp->pref == 0);
return 0;
fail_updt:
free(fd_cache);
fail_cache:
return 0;
}
选取出了最好的poller,接下来就可以看下poller是怎么工作的(假设大家已经知道了poll的工作方式,如果不知道可以参考http://blog.chinaunix.net/uid-23722297-id-1624846.html)。
1. poll类型poller的初始化
static int _do_init(struct poller *p)
{
\_\_label__ fail_swevt, fail_srevt, fail_pe;
int fd_evts_bytes;
p->private = NULL;
// global.maxsock在haproxy.c中初始化为10
fd_evts_bytes = (global.maxsock + sizeof(**fd_evts) - 1) / sizeof(**fd_evts) * sizeof(**fd_evts);
// 这个就是存放每次需要检测的描述符和事件类型的数组
poll_events = calloc(1, sizeof(struct pollfd) * global.maxsock);
if (poll_events == NULL)
goto fail_pe;
// fd_evts[DIR_RD]和fd_evts[DIR_WR]是两个数组,分别存放对应的描述符
// 是否有读/写事件,也是以描述符作为索引
if ((fd_evts[DIR_RD] = calloc(1, fd_evts_bytes)) == NULL)
goto fail_srevt;
if ((fd_evts[DIR_WR] = calloc(1, fd_evts_bytes)) == NULL)
goto fail_swevt;
return 1;
fail_swevt:
free(fd_evts[DIR_RD]);
fail_srevt:
free(poll_events);
fail_pe:
p->pref = 0;
return 0;
}
其中fd_evts[DIR_RD]和fd_evts[DIR_WR]的定义如下:
static unsigned int *fd_evts[2]; // 一个用来读,一个用来写
0是读(宏定义DIR_RD),1是写(宏定义DIR_WR)fd_evts[DIR_RD]和fd_evts[DIR_WR]是两个数组,分别存放对应的描述符是否有读/写事件,也是以描述符作为索引,不过是bit位,就是一个字节8个数据。
2. 异步事件检测: poll操作
这里面会牵涉到一些陌生的东西,描述的会比较混乱,不过总体还是很简单的,如果耐心一点,花两三分钟,也是能够理解的。
001 static void _do_poll(struct poller *p, int exp)
002 {
003 int status;
004 int fd, nbfd;
005 int wait_time;
006 int updt_idx, en, eo;
007 int fds, count;
008 int sr, sw;
009 unsigned rn, wn; /* read new, write new */
010
011 // 从更新列表中查找是否有需要重新poll检测的描述字
012 for (updt_idx = 0; updt_idx < fd_nbupdt; updt_idx++) {
013 fd = fd_updt[updt_idx]; // 更新列表一样是将fd作为索引
014 fdtab[fd].updated = 0;
015 fdtab[fd].new = 0;
016
017 if (!fdtab[fd].owner)
018 continue;
019
020 eo = fdtab[fd].state;
021 en = fd_compute_new_polled_status(eo); // 判断是否需要执行poll操作
022
023 if ((eo ^ en) & FD_EV_POLLED_RW) { // 判断需要检测读写事件
024 fdtab[fd].state = en;
025
026 // 根据具体的值修改数据,取消读、增加读或者取消写、增加写
027 if ((eo & ~en) & FD_EV_POLLED_R)
028 hap_fd_clr(fd, fd_evts[DIR_RD]);
029 else if ((en & ~eo) & FD_EV_POLLED_R)
030 hap_fd_set(fd, fd_evts[DIR_RD]);
031
032 if ((eo & ~en) & FD_EV_POLLED_W)
033 hap_fd_clr(fd, fd_evts[DIR_WR]);
034 else if ((en & ~eo) & FD_EV_POLLED_W)
035 hap_fd_set(fd, fd_evts[DIR_WR]);
036 }
037 }
038 fd_nbupdt = 0;
039
040 // 将描述符复制到待检测的struct pollfd 数组中
041 nbfd = 0;
042 for (fds = 0; (fds * 8*sizeof(**fd_evts)) < maxfd; fds++) {
043 rn = fd_evts[DIR_RD][fds];
044 wn = fd_evts[DIR_WR][fds];
045
046 // 一个字节表示8个数据,一次性可以跳过8个
047 if (!(rn|wn))
048 continue;
049
050 for (count = 0, fd = fds * 8*sizeof(**fd_evts); count < 8*sizeof(**fd_evts) && fd < maxfd; count++, fd++) {
051 sr = (rn >> count) & 1;
052 sw = (wn >> count) & 1;
053 if ((sr|sw)) {
054 poll_events[nbfd].fd = fd;
055 poll_events[nbfd].events = (sr ? POLLIN : 0) | (sw ? POLLOUT : 0);
056 nbfd++;
057 }
058 }
059 }
060
061 // 这些是无关的先不关心
062 if (fd_cache_num || run_queue || signal_queue_len)
063 wait_time = 0;
064 else if (!exp)
065 wait_time = MAX_DELAY_MS;
066 else if (tick_is_expired(exp, now_ms))
067 wait_time = 0;
068 else {
069 wait_time = TICKS_TO_MS(tick_remain(now_ms, exp)) + 1;
070 if (wait_time > MAX_DELAY_MS)
071 wait_time = MAX_DELAY_MS;
072 }
073
074 // 开始做poll操作
075 gettimeofday(&before_poll, NULL);
076 status = poll(poll_events, nbfd, wait_time);
077 tv_update_date(wait_time, status);
078 measure_idle();
079
080 // 根据检测的结果更新缓存
081 for (count = 0; status > 0 && count < nbfd; count++) {
082 int e = poll_events[count].revents;
083 fd = poll_events[count].fd;
084
085 if (!(e & ( POLLOUT | POLLIN | POLLERR | POLLHUP )))
086 continue;
087
088 /* ok, we found one active fd */
089 status--;
090
091 if (!fdtab[fd].owner)
092 continue;
093
094 fdtab[fd].ev &= FD_POLL_STICKY; // 清掉除了错误标记外的标记
095 // 看似多次一举,其实这是HAProxy根据GCC的特性做的一个优化,
096 // 在GCC中常数的比较可以直接在编译期间优化,当然如果这些常数是相等的话
097 // HAProxy不放过一个优化的细节
098 if (POLLIN == FD_POLL_IN && POLLOUT == FD_POLL_OUT &&
099 POLLERR == FD_POLL_ERR && POLLHUP == FD_POLL_HUP) {
100 fdtab[fd].ev |= e & (POLLIN|POLLOUT|POLLERR|POLLHUP);
101 }
102 else {
103 fdtab[fd].ev |=
104 ((e & POLLIN ) ? FD_POLL_IN : 0) |
105 ((e & POLLOUT) ? FD_POLL_OUT : 0) |
106 ((e & POLLERR) ? FD_POLL_ERR : 0) |
107 ((e & POLLHUP) ? FD_POLL_HUP : 0);
108 }
109
110 // 检测描述符的读写事件,更新fd_cache
111 if (fdtab[fd].ev & (FD_POLL_IN | FD_POLL_HUP | FD_POLL_ERR))
112 fd_may_recv(fd);
113
114 if (fdtab[fd].ev & (FD_POLL_OUT | FD_POLL_ERR))
115 fd_may_send(fd);
116 }
117
118 }
这个函数有点长,不过这还不是HAProxy中算长的函数,HAProxy中有的函数长达千行!函数虽然长,逻辑还是很清晰的,虽然里面有些结构的含义不清楚,但是很容易猜到。
函数中最主要的步骤可以分为下面步:
1. 从更新列表中查找是否有需要使用poll做异步事件检测的描述符;
2. 将待检测的描述符复制到struct pollfd数组中并调用poll系统接口;
3. 根据系统返回结果,更新描述符关联的数据fd_cache。
这个函数中涉及到了描述符管理时用到的两个数据:fd_updt和fd_cache。这也是两个数组,但不是以描述符为索引的数组了。里面每个元素就是一个描述符,分别存储”需要做事件检测处理的描述符(即需要做poll检测)”和”当前有哪些描述符有事件需要处理(即当前有数据可读或可写,或者连接关闭、新连接等)”。其中struct fdtab使用一个字段cache记录一个描述符在fd_cache中的索引位置。同时对应的有两个整数,表示两个数组的大小:fd_nbupdt和fd_cache_num。
了解了这两个数据,在看struct fdtab.state成员。从表面含义上看,它指一个描述符关联的状态,对,就是一个状态。HAProxy对此做了详细的说明(HAProxy这点做得特别好,代码的注释写的非常详细)。但是为了容易简单,可以先将这个状态理解为fdtab用来记录fd当前的读写状态,比如是否可读,是否可写。
理解了这三个数据,再回过头来读一遍代码就很容易理解了。代码第23行,根据原先的状态判断它之后的状态(这个也许不容易明白他是怎么运作的,后面会有更详细的解释,包含了HAProxy对状态的描述),判断是否需要做poll操作,包括读和写,并更新fd_evts中对应的标记位。
检测完更新的描述符,根据刚计算出来的需要检测的事件状态,开始准备做poll操作的数据,struct pollfd数组poll_events。接着就是调用poll(中间忽略了一部分)。poll系统调用会根据描述符关联的信息,判断是否有变更,比如有数据可读,连接关闭,或者收到新连接等,将这些有事件发生的描述符和发生的事件类型(读recv或写send)更新到fd_cache中,函数112和115行。
下面插入一些碎片。
插曲
fdtab.state
一个描述符的事件状态,就是fdtab.state,包含了全部类型。状态字段这样构成的:低4字节表示读(R),高4字节表示写(W),描述如下:
| 7 | 6 | 5 | 4 | 3| 2 | 1 | 0 |
| 0 | PW | RW | AW | 0 | PR | RR | AR |
A* = active *R = read
P* = polled *W = write
R* = ready
当描述符可用时标记为”active”;
在polling(需要异步事件检测)时标记为”polled”;
当最近一次唤醒时没有遇到EAGAIN的错误时标记为”ready”(不管polling是否改变,这只是上次EAGAIN的缓存)。
(可能这些不好理解,但是看到后面代码执行的动作后,就很好理解了。)
基于这3个标记,对每种类型(读和写)就可以有8中可能的状态:
+---+---+---+----------+---------------------------------------------+
| P | R | A | State | Description |
+---+---+---+----------+---------------------------------------------+
| 0 | 0 | 0 | DISABLED | No activity desired, not ready. |
| 0 | 0 | 1 | MUSTPOLL | Activity desired via polling. |
| 0 | 1 | 0 | STOPPED | End of activity without polling. |
| 0 | 1 | 1 | ACTIVE | Activity desired without polling. |
| 1 | 0 | 0 | ABORT | Aborted poll(). Not frequently seen. |
| 1 | 0 | 1 | POLLED | FD is being polled. |
| 1 | 1 | 0 | PAUSED | FD was paused while ready (eg: buffer full) |
| 1 | 1 | 1 | READY | FD was marked ready by poll() |
+---+---+---+----------+---------------------------------------------+
为这些状态转换提供的接口也很简单:
fd_want_*() : set flag A
fd_stop_*() : clear flag A
fd_cant_*() : clear flag R (when facing EAGAIN)
fd_may_*() : set flag R (upon return from poll())
sync() : if (A) { if (!R) P := 1 } else { P := 0 }
状态PAUSED,ABORT和MUSTPULL是水平触发的poller转换的(关于水平触发和边缘触发,可以搜索,对于poll机制,就是水平触发,epoll通过设置,也可以支持水平触发),并在poller开始时通过sync接口调整。对于事件触发的poller,只有MUSTPOLL状态才会转换,ABORT会直接变成PAUSED。ACTIVE状态是唯一稳定的状态,它的P != A。
READY是一个特殊的状态,可能被poller或cache(有一个fd_cache)激活。一些多层的协议(比如SSL)会需要用到这个状态,因为它们的连接活动不是100%与FD的活动状态关联的(FD的活动状态只是传输层的)。如果开启/关闭一个描述符比较容易,一些poller可能选择使用ACTIVE来实现这个状态。对于一个分配的cache来说,READY和ACTIVE是两个状态。
状态转换图如下。polling开启的状态只有4个。
(POLLED=0) (POLLED=1)
+----------+ sync +-------+
| DISABLED | <----- | ABORT | (READY=0, ACTIVE=0)
+----------+ +-------+
clr | ^ set | ^
| | | |
v | set v | clr
+----------+ sync +--------+
| MUSTPOLL | -----> | POLLED | (READY=0, ACTIVE=1)
+----------+ +--------+
^ poll | ^
| | |
| EAGAIN v | EAGAIN
+--------+ +-------+
| ACTIVE | | READY | (READY=1, ACTIVE=1)
+--------+ +-------+
clr | ^ set | ^
| | | |
v | set v | clr
+---------+ sync +--------+
| STOPPED | <------ | PAUSED | (READY=1, ACTIVE=0)
+---------+ +--------+
因为本人英语功底实在有限,翻译成中文可能会有很多错误,原文在src/fd.c的开头注释的地方。
处理fd_cache中的事件
void fd_process_cached_events()
{
int fd, entry, e;
for (entry = 0; entry < fd_cache_num; ) {
fd = fd_cache[entry];
e = fdtab[fd].state;
fdtab[fd].ev &= FD_POLL_STICKY;
// 根据fdtab的状态分析需要处理的事件
if ((e & (FD_EV_READY_R | FD_EV_ACTIVE_R)) == (FD_EV_READY_R | FD_EV_ACTIVE_R))
fdtab[fd].ev |= FD_POLL_IN;
if ((e & (FD_EV_READY_W | FD_EV_ACTIVE_W)) == (FD_EV_READY_W | FD_EV_ACTIVE_W))
fdtab[fd].ev |= FD_POLL_OUT;
if (fdtab[fd].iocb && fdtab[fd].owner && fdtab[fd].ev)
fdtab[fd].iocb(fd); // 调用回调函数
else
fd_release_cache_entry(fd);
// fd_cache的操作机制是删除之后,用最后一个补充上
if (entry < fd_cache_num && fd_cache[entry] != fd)
continue;
entry++;
}
}
总结
- HAProxy利用了描述符都是尽量使用小数字的特性,直接用一个以描述符作为索引的数组,这样操作的效率最高;
- 使用”状态”记录和判断当前描述符应该执行什么操作,或者触发什么事件,比如读、写、poll等;
- 直接将需要做异步事件检测的描述符放到数组中(fd_updt),poller根据这里的数据更新描述符数据;
- poller将检测后需要进一步处理的描述符放到fd_cache数组中,并与fdtab关联。
HAProxy的这些处理,是事件管理的基础,也是程序效率的关键点之一。这也许不适合一些应用场景,但是也有值得借鉴和学习的地方。