Linux 新增系统调用的启示
最近在研究 Linux 内核的时间子系统,为下一篇长文《服务器程序中的日期与时间》做准备,无意中注意到了 Linux 新增的几个系统调用的对编写服务器代码的影响,先大致记录在这里。这篇博客也可算作前一篇《多线程服务器的常用编程模型》的一个注脚。
1. 服务器程序的风格可能在变
新的创建文件描述符的 syscall 一般都支持额外的 flags 参数,可以直接指定 O_NONBLOCK 和 FD_CLOEXEC,例如:
- accept4 – 2.6.28
- eventfd2 – 2.6.27
- inotify_init1 – 2.6.27
- pipe2 – 2.6.27
- signalfd4 – 2.6.27
- timerfd_create 2.6.25
以上 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 选项:
- dup3 – 2.6.27
- epoll_create1 – 2.6.27
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() 时关闭不相干的文件描述符就成了常见的需求,干脆做到系统调用里得了。
2. Kernel 2.6.22 加入的 signalfd 让 signal handling 有了新办法。
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,完美。
3. Kernel 2.6.25 加入的 timerfd 让程序的“定时任务”有了新办法。
我下一篇博客会详细分析 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。
4. Kernel 2.6.22 加入的 eventfd 让“线程间事件通知”有了新办法。
《多线程服务器的常用编程模型》 提到进程间通信只用 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(),故造成了误解。