使用 kqueue 在 FreeBSD 上开发高性能应用服务器

本文出处:http://www.ibm.com/developerworks/cn/aix/library/1105_huangrg_kqueue/

概述

kqueue FreeBSD 上的一种的多路复用机制。它是针对传统的 select/poll 处理大量的文件描述符性能较低效而开发出来的。注册一堆描述符到 kqueue 以后,当其中的描述符状态发生变化时, kqueue 将一次性通知应用程序哪些描述符可读、可写或出错了。

 

kqueue 支持多种类型的文件描述符,包括 socket 、信号、定时器、 AIO VNODE PIPE 。本文重点讨论 kqueue 如何控制 socket 描述符。其中 kqueue AIO POSIX 的异步 IO 系列的支持,是异步行为完成通知机制之一。另外两种常见的机制是异步信号和线程例程。用 kqueue 的明显好处是完成事件的处理线程可以灵活地指定。

 

本文重点在于 kqueue 技术本身。一些基础的知识点,比如 socket API 和常用的 unix 数据结构将不作讲解,有需要的读者请先阅读 UNIX网络编程方书籍

 

kqueue APIs

kqueue 提供 kqueue() kevent() 两个系统调用和 struct kevent 结构。

 

kqueue 主要功能

通过 kevent() 提供三个主要的行为功能。在下面小节中将会用到这两个主要功能。

 

Ø         注册 / 反注册

注意 kevent() 中的 neventlist 这个输入参数,当将其设为 0 ,且传入合法的 changelist nchangelist ,就会将 changelist 中的事件注册到 kqueue 中。

当关闭某文件描述符时,与之关联的事件会被自动地从 kqueue 移除。

 

Ø         允许 / 禁止过滤器事件

通过 flags EV_ENABLE EV_DISABLE 使过滤器事件有效或无效。这个功能在利用 EVFILT_WRITE 发送数据时非常有用。

 

Ø         等待事件通知

nchangelist 设置成 0 ,当然要传入其它合法的参数,当 kevent 非错误和超时返回时,在在 eventlist neventlist 中就保存可用事件集合。

 

kqueue()

int kqueue(void)

生成一个内核事件队列,返回该队列的文件描述索。其它 API 通过该描述符操作这个 kqueue 。生成的多个 kqueue 的结构类似图 1 所示。

 

1 kqueue 队列结构

kevent()

int kevent(int kq, const struct kevent *changelist, int nchanges,

struct kevent *eventlist, int nevents,

const struct timespec *timeout);

 

kevent 提供向内核注册 / 反注册事件和返回就绪事件或错误事件 .

 

kq: kqueue 的文件描述符。

changelist: 要注册 / 反注册的事件数组;

nchanges: changelist 的元素个数。

eventlist: 满足条件的通知事件数组;

nevents: eventlist 的元素个数。

timeout: 等待事件到来时的超时时间, 0 ,立刻返回; NULL ,一直等待;有一个具体值,等待 timespec 时间值。

 

返回值:可用事件的个数。

 

struct kevent

struct kevent {

     uintptr_t ident;       /* 事件 ID */

     short     filter;       /* 事件过滤器 */

     u_short   flags;        /* 行为标识 */

     u_int     fflags;       /* 过滤器标识值 */

     intptr_t  data;         /* 过滤器数据 */

     void      *udata;       /* 应用透传数据 */

};

在一个 kqueue 中, {ident, filter} 确定一个唯一的事件。

 

Ø         ident

事件的 id ,实际应用中,一般设置为文件描述符。

 

Ø         filter

可以将 kqueue filter 看作事件。内核检测 ident 上注册的 filter 的状态,状态发生了变化,就通知应用程序。 kqueue 定义了较多的 filter ,本文只介绍 Socket 读写相关的 filter

²        EVFILT_READ

TCP 监听 socket ,如果在完成的连接队列 ( 已收三次握手最后一个 ACK) 中有数据,此事件将被通知。收到该通知的应用一般调用 accept() ,且可通过 data 获得完成队列的节点个数。

 

流或数据报 socket ,当协议栈的 socket 层接收缓冲区有数据时,该事件会被通知,并且 data 被设置成可读数据的字节数。

 

²        EVFILT_WRITE

socket 层的写入缓冲区可写入时,该事件将被通知; data 指示目前缓冲区有多少字节空闲空间。

 

Ø         flags

²        EV_ADD

指示加入事件到 kqueue

 

²        EV_DELETE

指示将传入的事件从 kqueue 中移除。

 

²        EV_ENABLE

过滤器事件可用,注册一个事件时,默认是可用的。

 

²        EV_DISABLE

过滤器事件不可用,当内部描述可读或可写时,将不通知应用程序。第 5 小节有这个 flag 的用法介绍。

 

²        EV_ERROR

一个输出参数,当 changelist 中对应的描述符处理出错时,将输出这个 flag 。应用程序要判断这个 flag ,否则可能出现 kevent 不断地提示某个描述符出错,却没将这个描述符从 kq 中清除。处理 EV_ERROR 类似下面的代码:

    if (events[i].flags & EV_ERROR)

          close(events[i].ident);

 

Ø         fflags

过滤器相关的一个输入输出类型标识,有时候和 data 结合使用。

 

Ø         data

过滤器相关的数据值,请看 EVFILT_READ EVFILT_WRITE 描述。

 

Ø         udata

应用自定义数据,注册的时候传给 kernel kernel 不会改变此数据,当有事件通知时,此数据会跟着返回给应用。

 

Ø         EV_SET

EV_SET(&kev, ident, filter, flags, fflags, data, udata);

struct kevent 的初始化的辅助操作。

 

一个服务器示例

 

例子实现了一个只有较简单通信功能的但有性能保证的服务器。在下面各个清单中只写出关键性的代码,错误处理的代码未写出,完整的代码请参考附带的源码: kqueue.cpp

 

Ø         注册事件到 kqueue

清单 1

73 bool Register(int kq, int fd)

74 {

75     struct kevent changes[1];

76     EV_SET(&changes[0], fd, EVFILT_READ, EV_ADD, 0, 0, NULL);

77

78     int ret = kevent(kq, changes, 1, NULL, 0, NULL);

81

82     return true;

83 }

 

Register fd 注册到 kq 中。注册的方法是通过 kevent() eventlist neventlist 置成 NULL 0 来达到的。

 

Ø         创建监听 socket kqueue ,等待内核事件通知

清单 2

27 int main(int argc, char* argv[])

28 {

29     listener_ = CreateListener();

32

33     int kq = kqueue();

34     if (!Register(kq, listener_))

39

40     WaitEvent(kq);

41

42     return 0;

43 }

 

85 void WaitEvent(int kq)

86 {

87     struct kevent events[MAX_EVENT_COUNT];

88     while (true)

89     {

90         int ret = kevent(kq, NULL, 0, events, MAX_EVENT_COUNT, NULL);

96

97         HandleEvent(kq, events, ret);

98     }

99 }

 

29~40 ,创建监听 socket ,将监听 socket 注册到 kq ,然后等待事件。

90 ,这一行就是 kevent 事件等待方法,将 changelist nchangelist 分别置成 NULL 0 ,并且传一个足够大的 eventlist 空间给内核。当有事件过来时, kevent 返回,这时调用 HandleEvent 处理可用事件。

 

Ø         struct kevent data 字段在 accept recv 时的用法

清单 3

101 void HandleEvent(int kq, struct kevent* events, int nevents)

102 {

103     for (int i = 0; i < nevents; i++)

104     {

105         int sock = events[i].ident;

106         int data = events[i].data;

107

108         if (sock == listener_)

109             Accept(kq, data);

110         else

111             Receive(sock, data);

112     }

113 }

114

115 void Accept(int kq, int connSize)

116 {

117     for (int i = 0; i < connSize; i++)

118     {

119         int client = accept(listener_, NULL, NULL);

125

126         if (!Register(kq, client))

131     }

132 }

133

134 void Receive(int sock, int availBytes)

135 {

136     int bytes = recv(sock, buf_, availBytes, 0);

145     Enqueue(buf_, bytes);

146 }

 

108~111 ,根据 events.ident 的类型来调用 Accept() Receive() 。这里要注意的是 events[i].data

 

117~126 ,对于监听 socket data 表示连接完成队列中的元素 ( 已经收到三次握手最后一个 ACK) 个数。 119 行演示了这种用法, accept data 次。 126 行将 accept 成功的 socket 注册到 kq

 

136~145 ,对于流 socket data 表示协议栈 socket 层的接收缓冲区可读数据的字节数。 recv 时显示地指定接收 availBytes 字节 ( 就是 data) 。这个功能点将对 recv send 的性能提升有积极的作用,第 4 小节将这方面的讨论。 145 行表示将收到的数据入缓冲队列。

 

EVFILT_WRITE 用法

上面的例子没有涉及写事件的用法,这一小节简单介绍一下通过 WRITE 事件自动地实现发送数据的方法。

kqueue 默认是水平触发模式,当某个描述符的事件满足某种条件时,如果应用程序不处理对应的事件, kqueue 将会不断地通知应用程序此描述符满足某种状态了。以 EVFILT_WRITE 举例,见图 2

2 WRITE 通知流程

在某种情形下,应用程序须要禁止 kqueue 不断地通知某个描述符的“可写”状态。将已注册的 {ident, filter} flags 设置成 EV_DISABLE 就达到这个目的。实现方法类似清单 4

清单 4

struct kevent changes[1];

EV_SET(&changes[0], fd, EVFILT_WRITE, EV_DISABLE, 0, 0, NULL);

kevent(kq, changes, 1, NULL, 0, NULL);

 

将上面代码中的 EV_DISABLE 替换成 EV_ENABLE 表示事件是可用的。

 

接下来,考虑一个实际的服务器应用,请见图 3

3 某个服务器应用

逻辑处理线程将处理结果写到发送队列,通信线程将其读出并通过 kqueue EVFILT_WRITE 机制发送。二者具体流程请见图 4

 

4 逻辑流程

 

具体的代码相对较大,将不在这里列出。在 Speed demos/fb_tcp_server 有这种用法的代码例子。特别强调一下,两个线程中 writeEnable 变量和 EVFILTE_WRITE 状态的设置是有严格的顺序要求的。现代编译器优化和处理器执行指令时都有可能打乱指令顺序。有一种叫内存屏障( memory barrier )的技术可以保证程序语句的编译和执行顺序,在 Linux内核设计与实现 中介绍了这一技术。

另外,这个例子可以做性能优化,当发送队列为空时,将一定长度的数据直接通过 send () API 非阻塞地发送,未发送完的数据再写入到发送队列。这样避免了大部分的数据拷贝。

阻塞与非阻塞 IO

用过 select epoll 的读者,一般将 socket IO 设置成非阻塞模式,以提高读写性能的同时,避免 IO 读写不小心被锁定。

 

为了达到某种目的,甚至有人会通过 getsocketopt 来偷看 socket 读缓冲区的数据大小或写缓区

可用空间的大小。 kqueue 开发人员考虑到这些现状,在 kevent 返回时,将读写缓冲区的可读字

节数或可写空间大小告诉应用程序。基于这个特性,使用 kqueue 的应用一般不使用非阻塞 IO 。每次读时,根据 kevent 返回的可读字节大小,将接收缓冲区中的数据一次性读完;而发送数据时,也根据 kevent 返回的写缓冲区可写空间的大小,一次只发可写空间大小的数据。

 

结束语

本文介绍了 FreeBSD kqueue 这种多路复用 IO 模型的用法,重点介绍了 kqueue Sockets IO 的控制和事件通知过程。有一定网络编程基础的程序员学习本文后,结合给出的例子就能开发出有一定性能保证的 FreeBSD 应用服务器了。

posted on 2010-12-01 10:13  daemonh  阅读(472)  评论(0编辑  收藏  举报

导航