Linux AIO(异步IO)那点事儿

转载 windyrobin   2012-12-16 20:11  

转一篇讲异步IO的文章,是在看libev时翻到的,作者对异步IO的了解还是很深的。

在高性能的服务器编程中,IO模型理所当然的是重中之重,需要谨慎选型的,对于网络套接字,我们可以采用 epoll 的方式来轮询,尽管 epoll 也有一些缺陷,但总体来说还是很高效的,尤其来大量套接字的场景下;但对于一般文件来说,是不能够用采用 poll/epoll 的,即 O_NOBLOCK 方式对于传统文件句柄是无效的,也就是说我们的 open, read, mkdir 之类的一般文件操作必定会导致阻塞。在多线程、多进程模型中,可以选择以同步阻塞的方式来进行IO操作,任务调度由操作系统来保证公平性,但在单进程/线程模型中,以nodejs为例,假如我们需要在一个用户请求中处理10个文件:

1 function fun() {
2     fs.readFileSync();
3     fs.readFileSync();
4     ...
5 }

这时候进程至少会阻塞10次,而这可能会导致其他的上千个用户请求得不到处理,这当然是不能接受的。

Linux AIO早就被提上议程,目前比较知名的有Glibc的AIO与Kernel Native AIO:

我们用Glibc的AIO做个小实验,写一个简单的程序:异步方式读取一个文件,并注册异步回调函数:

 1 int main()
 2 {
 3     struct aiocb my_aiocb;
 4     fd = open("file.txt", O_RDONLY);
 5     ...
 6     my_aiocb.aio_sigevent.sigev_notify_function = aio_completion_handler;
 7     ...
 8     ret = aio_read(&my_aiocb);
 9     ...
10     write(1, "caller thread\n", 14);
11     sleep(5);
12 }
13 
14 void aio_completion_handler(sigval_t sigval)
15 {
16     write(1, "callback\n", 9);
17     struct aiocb *req;
18     ...
19     req = (struct aiocb *)sigval.sival_ptr;
20     printf("data: %s\n", req->aio_buf);
21 }

我们用 strace 来跟踪调用,得到以下结果(只保留主要语句):

23908 open("file.txt", O_RDONLY)        = 3
23908 clone(...) = 23909
23908 write(1, "caller thread\n", 14)   = 14
23908 nanosleep({5, 0},
...
23909 pread(3, "hello, world\n", 1024, 0) = 13
23909 clone(..)= 23910
23909 futex(0x3d3a4082a4, FUTEX_WAIT_PRIVATE, 1, {0, 999942000}
...
23910 write(1, "callback\n", 9)         = 9
23910 write(1, "data: hello, world\n", 19) = 19
23910 write(1, "\n", 1)                 = 1
23910 _exit(0)                          = ?
23909 <... futex resumed> )             = -1 ETIMEDOUT (Connection timed out)
23909 futex(0x3d3a408200, FUTEX_WAKE_PRIVATE, 1) = 0
23909 _exit(0)                          = ?
23908 <... nanosleep resumed> {5, 0})   = 0
23908 exit_group(0)                     = ?

在Glibc AIO的实现中,用多线程同步来模拟异步IO,以上述代码为例,它牵涉了3个线程, 主线程(23908)新建一个线程(23909)来调用阻塞的 pread 函数,当 pread 返回时,又创建了一个线程(23910)来执行我们预设的异步回调函数,23909等待23910结束返回,然后23909也结束执行。

实际上,为了避免线程的频繁创建、销毁,当有多个请求时,Glibc AIO会使用线程池,但以上原理是不会变的,尤其要注意的是:我们的回调函数是在一个单独线程中执行的。

Glibc AIO广受非议,存在一些难以忍受的缺陷和bug,饱受诟病,是极不推荐使用的。详见:http://davmac.org/davpage/linux/async-io.html

在Linux 2.6.22+系统上,还有一种Kernel AIO的实现,与Glibc的多线程模拟不同,它是真正的做到内核的异步通知,比如在较新版本的Nginx服务器上,已经添加了AIO方式的支持。 http://wiki.nginx.org/HttpCoreModule

aio
  syntax:   aio [on|off|sendfile]
  default:  off
  context:  http, server, location

This directive is usable as of Linux kernel 2.6.22. For Linux it is required to use directio, this automatically disables sendfile support.

1 location /video {
2     aio on;
3     directio 512;
4     output_buffers 1 128k;
5 }

听起来Kernel Native AIO几乎提供了近乎完美的异步方式,但如果你对它抱有太高期望的话,你会再一次感到失望.

目前的Kernel AIO仅支持 O_DIRECT 方式来对磁盘读写,这意味着,你无法利用系统的缓存,同时它要求读写的的大小和偏移要以区块的方式对齐,参考Nginx的作者Igor Sysoev的评论: http://forum.nginx.org/read.php?2,113524,113587#msg-113587

nginx supports file AIO only in 0.8.11+, but the file AIO is functional on FreeBSD only. On Linux AIO is supported by nginx only on kerenl 2.6.22+ (although, CentOS 5.5 has backported the required AIO features). Anyway, on Linux AIO works only if file offset and size are aligned to a disk block size (usually 512 bytes) and this data can not be cached in OS VM cache (Linux AIO requires DIRECTIO that bypass OS VM cache). I believe a cause of so strange AIO implementaion is that AIO in Linux was developed mainly for databases by Oracle and IBM.

同时注意“this automatically disables sendfile support”,启用AIO就会关闭 sendfile —— 这是显而易见的,当你用Nginx作为静态服务器,你要么选择以AIO读取文件到用户缓冲区,然后发送到套接口,要么直接调用 sendfile 发送到套接口, sendfile 虽然会导致短暂的阻塞,但开启AIO却无法充分的利用缓存,也丧失了零拷贝的特征;当你用Nginx作为动态服务器,比如fastcgi + php时,这时php脚本中文件的读写是由php的文件接口来操作的,这时候是多进程+同步阻塞模型,和文件异步模式扯不上关系的。

所以现在Linux上,没有比较完美的异步文件IO方案,这时候苦逼程序员的价值就充分体现出来了,libev的作者Marc Alexander Lehmann老大就重新实现了一个AIO library: http://software.schmorp.de/pkg/libeio.html

其实它还是采用线程池+同步模拟出来的,和Glibc的AIO比较像,用作者的话说,这个库相比与Glibc的实现,开销更小,bug更少(不然重新造个轮子还有毛意义呢?反正我是信了),不过这个轮子的代码可读性实在不敢恭维,Marc老大自己也说了:

Currently in BETA! Its code, documentation, integration and portability quality is currently below that of libev, but should soon be ready for use in production environments.

(其实libev代码和文档可读性也不咋地,貌似驱动内核搞多了都这样?)好吧,腹诽完了,我们还是阅读下它的源码,来稍微分析一下它的原理:

(这个文章的流程图还是蛮靠谱的: http://cnodejs.org/blog/?p=244 ,此处更详细的补充一下下)

1 int eio_init (void (*want_poll)(void), void (*done_poll)(void))

初始化时设定两个回调函数,它有两个全局的数据结构: req 存放请求队列, res 存放已经完成的队列,当你提交一个异步请求时( eio_submit ),其实是放入 req 队列中,然后向线程池中处于信号等待的线程发送信号量(如果线程池中没有线程就创建一个),获得信号的线程会执行如下代码:

1 ETP_EXECUTE (self, req);
2 X_LOCK (reslock);
3 ++npending;
4 if (!reqq_push (&res_queue, req) && want_poll_cb)
5     want_poll_cb ();
6 X_UNLOCK (reslock);

ETP_EXECUTE 就是实际的阻塞调用,比如 readopensendfile 之类的,当函数返回时,表明操作完成,此时加锁方式向完成队列添加一项,然后调用 want_pool ,这个函数是我们 eio_init 时候设置的,然后释放锁。

注意:每次完成任务时,都要调用 want_poll ,所以这个函数应该是线程安全且尽量短促,实际上我们为了避免陷入多线程的泥淖,我们往往配合eio使用事件轮询机制,比如:我们创建一对管道,我们把“读”端的管道加入 epoll 监控结构中, want_poll 函向“写”端管道写数入一个字节或字长,所以当下次 epoll_wait 返回时,我们会执行“读”端管道的回调函数,类似如下:

1 void r_pipe_cb() {
2     ...
3     eio_poll();
4 }

eio_poll 中 有类似以下代码:

 1 for (;;) {
 2     X_LOCK (reslock);
 3     req = reqq_shift (&res_queue);
 4     if (req) {
 5         if (!res_queue.size && done_poll_cb)
 6             done_poll_cb ();
 7     }
 8     X_UNLOCK (reslock);
 9     res = ETP_FINISH (req);
10     ...
11     if (empty) break;
12 }

eio_poll 函数就是从完成队列 res 依次shift,依次执行我们的回调函数( ETP_FINISH 就是执行用户回调),在取出完成队列的最后一项但还没有执行用户回调之前,调用我们设定的 done_poll ,对 res 队列的操作当然也是加锁的,注意此时我们自定义的异步回调函数是在我们的主线程中执行的!这才是我们的最终目的!

在eio线程池中,默认最多4个线程,在高性能的程序中,过多的进程/线程往往也是一个瓶颈,寄存器的进出栈还是其次,进程虚存地址切换、各级cache的miss,这才是最昂贵的,所以,最理想的情形就是:有几个cpu,就有同样数目的active线程/进程,但因为IO线程往往会陷入sleep模式,所以,还是需要额外的待切换的线程的,作为经验法则,线程池的数量最好是cpu 的数目x2(参见Windows核心编程IOCP卷)。

libeio虽不完美,但目前还是将就着用用吧……

http://cnodejs.org/topic/4f16442ccae1f4aa270010a7

http://www.yeolar.com/note/2012/12/16/linux-aio/

http://www.yeolar.com/note/2012/12/16/linux-aio/