For the complete documentation index, see llms.txt. This page is also available as Markdown.

Kqueue 狂想

不久前我受邀参与一个 TCP 性能增强代理(Performance Enhancing Proxy,PEP)的开发。PEP 背后的理念是把一条 TCP 连接拆分为三条独立的连接。第一条连接(1)是从客户端到服务器的正常 TCP 连接(客户端通常并未察觉自己的连接并未直达终端服务器)。下一条连接(2)位于两台中转盒(M1 和 M2)之间,第一台中转盒(M1)冒充服务器终结客户端的连接,并用另一条连接与末端中转盒(M2)通信。这条中间连接为端到端连接提供“增强”服务。最后一条连接(3)位于末端中转盒(M2)与实际服务器之间。下图展示了这样一条连接的示意。

(1)        (2)        (3)
Client —— M1 ———————— M2 —— Server
              一条穿过 PEP 的连接

可以想见,如果你的 PEP 非常繁忙,M1 和 M2 上管理的 TCP 连接可能多达数千条。在这种环境下,使用 poll(2)select(2) 会付出惨痛代价。每次 I/O 事件完成,那数千条连接都要被一一检查以查看是否发生了事件,然后相应结构也要被重置以等待下一次事件。采用这种设置的进程很快会发现自己把大部分时间都花在查看并摆弄轮询或选择所涉及的数据结构上,几乎没有时间做别的(比如真正处理连接这件事)。

我加入这个项目较晚,但当时正在做的团队已经选择使用 kqueue(2) 而非 select(2)poll(2)。这当然是个明智之举,因为他们想把进程优化到能处理数千条连接。团队使用了多个 kqueue 和多个线程以达成目标,但本身又带来了另一个问题(到了调试进程的时候尤甚):每个 kqueue 对应多个线程,且整个架构中有大量 kqueue。根据硬件核数,线程数会增减(每个 kqueue 每个核一个线程)。这意味着在软件的第一个版本中,一个进程可能有超过一百个线程。在帮助稳定并交付产品之后,我意识到我们必须改进这一点,也正是从这里开始,我一头扎进了 kqueue 狂想之中。

在重新设计架构时,我决定:

  1. 保持代理本身所做的工作不变。

  2. 把线程数减到与机器核数匹配。

  3. 在代理之下插入一个框架,可日后复用于其他用途。

这促使我精心设计了一个“分发器”框架,帮助我达成全部三个目标。

在重写到一半时,框架大体完成、代理也跑起来之后,我开始在长期压力测试中遇到问题。经过深入调试,我意识到其中一个问题是:我并未真正理解 kqueue 与套接字之间的交互。我不禁自问:

a) 如果我删除一个套接字描述符的 kqueue 事件,却不关闭该套接字,会发生什么? b) 我有没有可能看到尚未读取的陈旧排队事件? c) connect(2) 如何与 kqueue 交互? d) listen(2) 呢? e) kqueue 上可以附加到事件上的各种 flag 有何区别?何时该用哪一个? f) 某些错误返回(EV_ERROR)是什么含义?这是否覆盖了我所有错误情形? g) 当 TCP 连接被对方优雅关闭时,我是否总能从 kqueue 得到 EOF 条件?

这些问题都没真正得到回答,对我而言唯一有意义的事就是写一个专门的测试程序,用来测试 kqueue 的各种特性,从而回答我们基于 FreeBSD 9.2 的操作系统发行版上的这些问题。我决定写这篇文章,让你不必再苦思这些问题,也不必再写一个 kqueue 测试程序。(如果你感兴趣,可在 http://people.freebsd.org/~rrs/kqueue_test.c 找到我的测试程序。)

Kqueue 基础

本节讨论基本的 kqueue(2)kevent(2) 调用,以及你需要了解的所有“过滤器”、“标志”和其他细节,以便把套接字程序改造为基于 kqueue 的程序。其中不少信息也见诸 kqueue(2) 手册页(强烈推荐阅读的信息源)。

第一件要做的事就是真正创建 kqueue。kqueue 也是一个文件描述符,你像创建套接字一样用一个特殊调用 kqueue() 打开它。该调用没什么特别之处,与创建套接字一样,它返回一个文件描述符索引,供后续所有调用使用。与套接字不同的是,它没有任何参数。它可能失败,与任何系统调用一样,失败时返回 -1,errno 会给出原因。一旦 kqueue() 成功返回描述符,你就用它配合 kevent() 系统调用让一切发生。

kevent() 调用接受六个参数:

  • Kq——你通过 kqueue() 创建的实际 kq。

  • Changelist——指向一个 kevent 结构体数组的指针,描述你请求 kqueue 做出的变更。

  • Nchanges——Changelist 数组中变更的条数。

  • Eventlist——另一个指向 kevent 结构体数组的指针,由操作系统填写,告诉你已触发的事件。

  • Nevents——Eventlist 的边界大小,让内核知道它最多能告诉你多少事件。

  • Timeout——一个 struct timespec,表示超时值。

poll(2)select(2) 一样,Timeout 字段让你控制调用的“阻塞”程度。如果设为 NULL,调用将永远等待。如果 Timeout 非 NULL,它被解释为一个 timespectv_sectv_nsec),表示返回前最长延迟时间。需要注意的一点是,如果你在 Nevents 字段中指定零,那么即使指定了 Timeout,kevent() 调用也不会延迟。

kevent() 调用可以三种方式使用:

  • 作为输入,告诉内核你感兴趣的事件(例如你正在设置一批套接字描述符,但暂时还不打算处理事件)。此时把 Changelist 设为至少一个(可能多个)kevent 的数组,Nchanges 设为数组长度。Eventlist 设为 NULL,Nevents 设为 0。

  • 仅作为输出,找出已发生的事件(例如你在事件循环中处理事件,但可能不设置新事件)。此时把 Changelist 指针设为 NULL,Nchanges 设为 0,但把 Eventlist 设为一个你想让内核填写的 kevent 数组指针,Nevents 设为该数组长度。

  • 最后一种用法是同时填充 in 集和 out 集,这样你在进入并等待事件(或多个事件)时,可以顺便变更所等待的内容。这在事件循环中很有用,可以最小化内核系统调用次数。

简言之,上述就是各调用及其用法。但你一直在说的那个 kevent 结构到底长什么样?kevent 结构如下:

事件系统还有一个宏,是一个帮助设置该结构的工具函数,叫 EV_SET()。但先让我们逐字段说明你该如何填入以得到想要的结果:

  • ident——你希望被监视的标识符。对套接字而言就是套接字描述符。对其他 kqueue 调用,它可能是描述符之外的东西。每种要监视的事件都指定了 ident 由何组成(一般是某种文件描述符,但有些不是——例如一处用的是进程 ID)。

  • filter——你请求内核监视的实际请求。当前可设置的过滤器如下:

    • EVFILT_READ——读过滤器,监视文件或套接字上的读事件。这是我们后面会详细讨论的过滤器之一。

    • EVFILT_WRITE——写过滤器,监视何时可向文件或套接字描述符写入。这也是我们会仔细看的过滤器类型之一。

    • EVFILT_AIO——异步 I/O 过滤器,与 aio 调用(aio_read()/aio_write())配合使用。本文不讨论此 kqueue 事件。

    • EVFILT_VNODE——某个文件上的文件系统变更。这是一个非常有用的事件(例如 tail 工具在执行 tail -f 时使用),但本文不讨论。

    • EVFILT_PROC——进程事件,比如子进程死亡或子进程 fork。同样是非常有用的事件,尤其在编写进程管理器时,但本文不涉及。

    • EVFILT_SIGNAL——信号发生时触发的事件(在信号到达之后)。这同样留给读者探索或留给未来文章 ;-)

    • EVFILT_USER——用户生成的事件,可用于在线程间发送信号,或因某种特定用户定义的原因唤醒 kqueue()(我实际上是用它来关闭我的框架)。本文不深入展开,但鼓励好奇者自行研究。

  • flags——此字段在输入(I)时告诉内核你想做什么,在输出(O)时告诉你发生了什么。当前定义的标志有:

    • EV_ADD(I)——用于把你的事件(对我们而言是套接字描述符)添加到 kqueue。

    • EV_DELETE(I)——从 kqueue 删除此前添加的事件。

    • EV_ENABLE(I)——启用一个 kqueue 事件。这看似冗余,实则不然,因为添加事件时除非指定该 enable 标志,否则不会被监视。事件触发后想再次启用它时(如果它不会自动重新启用自身),也用此标志。

    • EV_DISABLE(I)——与 enable 相反,可对一个此前启用的过滤器发送此标志以禁用事件,但 kqueue 内核内部结构不会被移除(因此随时可由 EV_ENABLE 重新启用)。

    • EV_DISPATCH(I)——此标志告诉内核:在向你发送一个事件之后,将其禁用,直到你再次启用它。

    • EV_ONESHOT(I)——此标志告诉内核:当事件发生且用户取回事件后,自动从内核中删除(EV_DELETE)该 kqueue 条目。

    • EV_CLEAR(I)——此标志在你取回事件后立即切换过滤器状态,使其自动“重新启用”。注意这可能引发大量事件快速发生,因此应谨慎使用此标志。

    • EV_EOF(O)——此标志指示套接字或描述符已达到 EOF 条件。对 TCP 而言,这相当于从套接字读到 0 字节(即对端不再发送数据并已发送 FIN)。

    • EV_ERROR(O)——此标志指示发生错误,通常 data 字段会提供更多信息。例如当对端以 RST“重置”TCP 连接时,你会得到 EV_ERRORdata 设为 ECONNRESETECONNABORTED(也可能是其他 errno)。

  • fflags——是过滤器在输入和输出时特定的标志。例如对套接字,可用它修改 SO_RCVLOWAT 值(data 字段会保存新值)。在输出时如果设置了 EV_ERRORfflags 会像我们钟爱的 errno 一样保存错误码。

  • data——此字段按过滤器分别用于提供附加信息(如 SO_RCVLOWAT 标记)。

  • udata——这是一个便利的小指针,传入 kqueue 调用并随事件返回。它非常适合用来把状态与某个特定事件关联。

向内核发送 kevent 以便其监视时,必须意识到:对内核而言,过滤器与 ident 的组合构成一个唯一条目。因此举例来说,如果我分别为套接字描述符 10 发送 EV_READEV_WRITE,它们在内核中是两个独立的过滤器。

掌握了这些基础,就可以写出一个简单的事件循环,大致如下:

使用 kqueue 看起来相当直接,但让我们再深入看看套接字与 kqueue 如何交互,以及在多线程事件循环中需要注意的事项。

Kqueue、套接字调用、多线程与其他谜团

套接字程序首先想做的事,是用 connect(2) 调用连接到服务器。在通常情形下,这是一个阻塞调用,如果对端在互联网上的某处、距你很远,可能在超时返回错误前要等一段时间。为避免这一点,通常会设置非阻塞 I/O,然后对其做 select(2)poll(2)。对我们的 kqueue 而言,可以做同样的事。但要记住,connect 调用上你不是在选择 EV_READ,而是在选择 EV_WRITE。现在我知道这个小事实后看似显而易见——你 connect 是为了能向服务器写请求,不是吗?当然,当我最初玩 kqueue 时这并不那么显而易见,是反复读了第二遍(或第三遍?)手册页才偶然撞见这点智慧的。

另一个套接字小技巧是 kqueue 调用的放置位置:当你在服务器端为非阻塞 listen(2) 做准备时,必须把 kqueue(2) 调用放在 listen(2) 之前。如果不这样做,即使挂起等待接受的套接字已堆满,你的 listen(2) 也不会在 kqueue 上被唤醒。这又是一个“啊哈”时刻,发生在我读手册读到第三遍时。

另一个谜团:我在读 Unangst 的文章时似乎读到一种暗示,如果我得到一个读事件却未读完事件让我读的全部数据(事件 .data 字段在套接字为 kqueue 读唤醒时给出就绪可读字节数),我可能不会再被唤醒,除非再有数据到来。我当然拼了命确保自己总是把全部数量读尽,以免被某个慢吞吞的发送方“搁浅”。我甚至尝试设置 EV_CLEAR 希望借此不必担心这种“竞争条件”。然而设置 EV_CLEAR 实际上演变成灾难:这意味着由于调度和上下文切换延迟,多个线程会在同一事件上醒来。多数套接字应用真正需要的是 EV_DISPATCH:读取你想读(或能读)的内容,然后当你想再次启用时再启用。在我用那个小程序(前文提到)的测试中,我证明了:

  • 添加 EV_CLEAR 会持续送入事件,直到你把数据读完或关闭该事件。

  • 使用 EV_DISPATCH 且只读部分消息,会在事件再次启用后再引发一次 kqueue 唤醒。

EV_ERROREV_EOF 是另一组谜团。这些美妙的标志何时才会被应用?结果发现:对端发出 FIN 后,EV_EOF 就会返回。更确切地说,每当套接字上发生 kqueue 事件且套接字的接收缓冲区上设置了标志 SBS_CANTRCVMORE 时就会返回。这意味着只要还有数据可读,你会在随后的每次 kevent 中持续看到标志 EV_EOFEV_ERROR 则略有不同。通常当套接字进入错误状态(ECONNRESETETIMEDOUT)时得到。基本上,一旦 EV_ERROR 被设置,该套接字就基本报废了。

还有一个奇怪现象:我开始看到在一个我已禁用的事件上出现唤醒。这怎么可能?是不是尚未读取的排队事件可能仍有事件留待我稍后读取?这可能给我带来大麻烦,因为 event.udata 中那个小指针正被用来携带一个我可能已经 free 掉的指针。在某些负载条件下持续看到这一现象后,我不得不刨根问底。借助我的小程序我再次证明:删除一个事件时,该套接字的所有未读事件都会被删除(呼)。所以我看到的怪现象必定是别的原因,或者并非如此?

如果你记得我被分配的任务——一个 TCP 代理——任何代理对一条流都有两个 TCP 描述符。这正是我的头疼之处。完全有可能(虽罕见)两个套接字描述符同时唤醒,其中一个被“重置”,如果该描述符先于另一个被处理,由于两个线程的加锁顺序,灾难就可能降临。实际上,线程 1 正在销毁流,而线程 2 正耐心等待线程 1 持有的该流上的锁。于是,在销毁前的锁释放那一刻,线程 2 会醒来并开始访问已释放的内存。这一个谜题以一种非常有趣的方式得到了解决,但我把它留给读者去破解。

结论

从这一切狂想中能得出什么结论?

  • 首先,如果你打算玩 kqueue,最好在动手前完全熟悉它们(要么就找到比我当年在网上找到的更好的阅读材料)。

  • 多线程 kqueue,尤其是多个文件描述符引用同一对象的情形,可能带来有趣的挑战。

  • kqueue 与套接字交互时有一些微妙之处,写软件时必须考虑(顺序非常重要,你对哪种事件感兴趣也很重要)。

  • 正确使用 kqueue 可让你得到非常高效的程序,能以最少开销处理数千个文件描述符的任务。

  • 理解并测试 kqueue 的功能(用前面提到的程序)无疑能让你免于发疯。

参考文献


Randall Stewart 现就职于 Adara Networks Inc.,任杰出工程师。当前职责包括架构、设计和原型设计 Adara 下一代路由与交换平台。此前 Stewart 先生曾任 Cisco Systems 杰出工程师。在其他生涯中,他还曾就职于 Motorola、NYNEX S&T、Nortel 和 AT&T Communications。整个职业生涯中,他的关注点集中在操作系统开发、容错以及呼叫控制信令协议。Stewart 先生也是一名 FreeBSD committer,负责 FreeBSD 内的 SCTP 参考实现。

最后更新于