最近在研究 Linux 内核的时间子系统,为下一篇长文《服务器程序中的日期与时间》做准备,无意中注意到了 Linux 新增的几个系统调用的对编写服务器代码的影响,先大致记录在这里。这篇博客也可算作前一篇《多线程服务器的常用编程模型》的一个注脚。
新的创建文件描述符的 syscall 一般都支持额外的 flags 参数,可以直接指定 O_NONBLOCK 和 FD_CLOEXEC,例如:
以上 6 个 syscalls,除了最后一个是新功能,其余的都是增强原有的调用,把数字尾号去掉就是原来的 syscall。
O_NONBLOCK 的功能是开启“非阻塞IO”,而文件描述符默认是阻塞的。
这些创建文件描述符的系统调用能直接设定 O_NONBLOCK 选项,或许能反映当前 Linux (服务端)开发的风向,那就是我在前一篇博客《多线程服务器的常用编程模型》里推荐的 one loop per thread + (non-blocking IO with IO multiplexing)。从这些内核改动来看,non-blocking IO 已经主流到让内核增加 syscall 以节省一次 fcntl(2) 调用的程度了。
另外,以下新系统调用可以在创建文件描述符时开启 FD_CLOEXEC 选项:
FD_CLOEXEC 的功能是让程序 fork() 时,子进程会自动关闭这个文件描述符(见下面的更正)。而文件描述默认是被子进程继承的(这是传统 Unix 的一种典型 IPC,比如用 pipe(2) 在父子进程间单向通信)。
以上 8 个新 syscalls 都允许直接指定 FD_CLOEXEC,或许说明 fork() 的主要目的已经不再是创建 worker process 并通过共享的文件描述符和父进程保持通信,而是像 Windows 的 CreateProcess 那样创建“干净”的进程,其与父进程没有多少瓜葛。
以上两个 flags 在我看来,说明 Linux 服务器开发的主流模型正在由 fork() + worker processes 模型转变为我前文推荐的多线程模型。fork() 的使用频度会大大降低,将来或许只有专门负责启动别的进程的“看门狗程序”才会调用 fork(),而一般的服务器程序(此处“服务器程序”的定义见我前一篇文章)不会再 fork() 出子进程了。原因之一是,fork() 一般不能在多线程程序中调用,因为 Linux 的 fork() 只克隆当前线程的 thread of control,不克隆其他线程。也就是说不能一下子 fork() 出一个和父进程一样的多线程子进程,Linux 没有 forkall() 这样的系统调用。forkall() 其实也是很难办的(从语意上),因为其他线程可能等在 condition variable 上,可能阻塞在系统调用上,可能等这 mutex 以跨入临界区,还可能在密集的计算中,这些都不好全盘搬到子进程里。由此可见,“看门狗程序”应该是单进程的,而且能捕获 SIGCHLD,如果 signal 能像“文件”一样读就能大大简化开发,下面第 2 点正好印证了。
既然如此,那么在 fork() 时关闭不相干的文件描述符就成了常见的需求,干脆做到系统调用里得了。
signal 处理是 Unix 编程的难点,因为 signal 是异步的,而且发生在“当前线程”里,会遇到“可重入”的难题。其实“线程”是 1993 才加入到 Unix 中,之前的 20 多年根本就没有“主线程”一说,我这里的意思是 signal handler 是像 coroutine 一样被调用的,而不是通常的 subroutine。Raymond Chen 有一篇文章谈到了这个问题。
在 Unix/Linux 支持线程以后,signal 就更难处理了,规则变得晦涩(想想 signal delivery 的对象)。而且它不符合“every thing is a file” 的 Unix 哲学,不能把 signal 事件当成文件来读。不过 2.6.22 加入的 signalfd 让事情有了转机,程序能像处理文件一样处理 signal,可以 read,也可以 select/poll/epoll,能融入标准的 IO multiplexing 框架中,而不需要在程序里另外用一对 pipe 来把 signal 转为 IO event。(libev 似乎是这么做的,另外还有 GHC http://hackage.haskell.org/trac/ghc/ticket/1520 )
这下多线程程序与 signals 打交道容易多了,一个 event loop 就能搞定 IO 和 timer 和 signals,完美。
我下一篇博客会详细分析 Linux 服务器程序中的日期与时间,其中一块内容是“定时”,也就是程序借助定时器在未来某个时刻做特定的事情。在 Linux 下办法很多,基于阻塞的 sleep/nanosleep/clock_nanosleep, 基于 signals 的 rtsignal/timer_create,还有我喜欢的基于 IO multiplexing 的 poll/epoll。不过 poll/epoll 的理论定时精度最多只有毫秒(函数的参数就是毫秒数,不能指定更高的时间精度),实际等待精度取决于 kernel HZ 等。
如果需要在 event loop 里做无阻塞的高精度定时,现在可以用 timerfd 了。而且它既然是个 fd,就能很方便地和 non-blocking IO 与 IO multiplexing 融合到一起,浑然天成。当然,文件描述符是稀缺资源,如果每个 event loop 都采用 timerfd 来做 timer/timeout 似乎是一种浪费(每个 timer 一个 timerfd 更是巨大浪费,因为不是每个 timer 都需要高精度定时),我宁愿采用传统的优先队列办法来管理等待到期的 timers(毫秒级的定时精度已经能满足我的需要),只在特殊场合动用 timerfd。
《多线程服务器的常用编程模型》 提到进程间通信只用 TCP,而 pipe 的惟一作用是异步唤醒 event loop,现在有了 eventfd,pipe 连这个作用都没有了。eventfd 是一个比 pipe 更高效的线程间事件通知机制,一方面它比 pipe 少用一个 file descriper,节省了资源;另一方面,eventfd 的缓冲区管理也简单得多,全部“buffer”一共只有 8 bytes,不像 pipe 那样可能有不定长的真正 buffer。
pipe 将来的作用或许主要是被“看门狗程序”用来截获子进程的 stdout/stderr。
综上,我前面一篇博客中提倡的 one loop per thread + (non-blocking IO with IO multiplexing) 服务器模型依赖一个优质的基于 Reactor 模式的网络库。如果要编写一个话,最好能用 2.6.22 以后的新内核,预计编程会简化不少(至少 eventfd 和 signalfd 能发挥很大作用),我准备写一个简单的试试。
最后,我研究 Linux kernel,目的是为了更好地编写 Linux 的服务器应用程序。我不是 kernel 专家,也不打算成为专家。
2010-Feb-27 更正:前面说“FD_CLOEXEC 的功能是让程序 fork() 时,子进程会自动关闭这个文件描述符”,这是错误的,FD_CLOEXEC 顾名思义是在执行 exec() 调用时关闭文件描述符,防止文件描述符泄漏给子进程。我对fork()的第一反应是立即执行exec(),故造成了误解。