https://www.zhihu.com/question/22064431
Linux下高性能的网络库中大多使用的Reactor 模式去实现, Boost Asio在Linux下用epoll和select去模拟proactor模式,影响了它的效率和实现复杂度,
看陈硕的自己的Linux下Reactor网络库和ASIO的性能对比,大概比asio性能(吞吐量)高1/5.既然Linux下网络库用Reactor性能才高,为什么Boost ASIO Linux下要用模拟的Proactor模式?
或者说为什么ASIO不在win和linux都用Reactor模式?这样的选择是不是可以性能更好?和更加适应市场?服务器端毕竟大量都是Linux.
Windows 有自己的一套高效异步IO模型(几乎等同于Proactor),同时支持文件IO和网络IO;
Glibc AIO: http://www.ibm.com/developerworks/linux/library/l-async/
Kernel Native AIO: http://lse.sourceforge.net/io/aio.html
我们用Glibc 的AIO 做个小实验,写一个简单的程序:异步方式读取一个文件,并注册异步回调函数:
int main()
{
struct aiocb my_aiocb;
fd = open("file.txt", O_RDONLY);
...
my_aiocb.aio_sigevent.sigev_notify_function = aio_completion_handler;
…
ret = aio_read(&my_aiocb);
…
write(1, "caller thread\n", 14);
sleep(5);
}
void aio_completion_handler(sigval_t sigval)
{
write(1, "callback\n", 9);
struct aiocb *req;
...
req = (struct aiocb *)sigval.sival_ptr;
printf("data: %s\n" ,req->aio_buf);
return;
}
我们用 strace 来跟踪调用,得到以下结果 (只保留主要语句):
23908 open("file.txt", O_RDONLY) = 3
23908 clone(...) = 23909
23908 write(1, "caller thread\n", 14) = 14
23908 nanosleep({5, 0}, <unfinished ...>
...
23909 pread(3, "hello, world\n", 1024, 0) = 13
23909 clone(..)= 23910
23909 futex(0x3d3a4082a4, FUTEX_WAIT_PRIVATE, 1, {0, 999942000} <unfinished ...>
...
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.
location /video {
aio on;
directio 512;
output_buffers 1 128k;
}
听起来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.
同时注意上面的橙色字部分,启用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 ,此处更详细的补充一下下)
int eio_init (void (*want_poll)(void), void (*done_poll)(void))
初始化时设定两个回调函数,它有两个全局的数据结构 : req 存放请求队列,res 存放已经完成的队列 当我,当你提交一个异步请求时(eio_submit),其实是放入req队列中,然后向线程池中处于信号等待的线程发送信号量(如果线程池中没有线程就创建一个),获得信号的线程会执行如下代码:
ETP_EXECUTE (self, req);
X_LOCK (reslock);
++npending;
if (!reqq_push (&res_queue, req) && want_poll_cb)
want_poll_cb ();
X_UNLOCK (reslock);
ETP_EXECUTE 就是实际的阻塞调用,比如read,open,,sendfile之类的,当函数返回时,表明操作完成,此时加锁方式向完成队列添加一项 ,然后调用 want_pool ,这个函数是我们eio_init时候设置的,然后释放锁。
注意:每次完成任务时,都要调用want_poll ,所以这个函数应该是线程安全且尽量短促,实际上我们为了避免陷入多线程的泥淖,我们往往配合eio使用事件轮询机制,比如:我们创建一对管道,我们把“读”端的管道加入 epoll 监控结构中,want_poll 函向“写”端管道写数入一个字节或字长 ,所以当下次epoll_wait 返回时,我们会执行 “读” 端管道的 回调函数,类似如下:
void r_pipe_cb(){
...
eio_poll();
}
在eio_poll 中 有类似以下代码:
for(;;){
X_LOCK (reslock);
req = reqq_shift (&res_queue);
if (req){
if (!res_queue.size && done_poll_cb)
done_poll_cb ();
}
X_UNLOCK (reslock);
res = ETP_FINISH (req);
...
if(empty) break;
}
eio_poll 函数就是从完成队列res 依次shift ,依次执行我们的回调函数(ETP_FINISH 就是执行用户回调),在取出完成队列的最后一项但还没有执行用户回调之前,调用我们设定的done_poll ,对res队列的操作当然也是加锁的,注意此时我们自定义的异步回调函数是在我们的主线程中执行的!这才是我们的最终目的!
在eio 线程池中,默认最多4个线程,在高性能的程序中,过多的进程/线程往往也是一个瓶颈,
寄存器的进出栈还是其次,进程虚存地址切换、各级cache 的miss ,这才是最昂贵的,所以,最理想的情形就是:有几个cpu ,就有同样数目的active 线程/进程,但因为io线程往往会陷入sleep模式,所以,还是需要额外的待切换的线程的,作为经验法则,线程池的数量最好是cpu 的数目 X 2(参见windows 核心编程 IOCP卷).
libeio 虽不完美,但目前还是将就着用用吧 ...
Proactor ? Why Proactor
在给 c++ 老人会的引荐信里,asio 爸爸仔细阐述了asio的设计抉择回答了围绕 asio 的设计提出的很多问题。
为什么 Proactor 会是最佳模型?
- 跨平台------ 许多操作系统都有异步API,即便是没有异步API的Linux, 通过 epoll 也能模拟 Proactor 模式。
- 支持回调函数组合-----将一系列异步操作进行组合,封装成对外的一个异步调用。这个只有Proactor能做到,Reactor 做不到。意味着如果asio使用Reactor模式,就对不起他“库” 之名。
- 相比 Reactor 可以实现 Zero-copy
- 和线程解耦----- 长时间执行的过程总是有系统异步完成,应用程序无需为此开启线程
Proactor 也并非全无缺点,缺点就是内存占用比 Reactor 大。Proactor 需要先分配内存而后处理IO, 而 Reactor 是先等待 IO 而后分配内存。相对的Proactor却获得了Zero-copy好处。因为内存已经分配好了,因此操作系统可以将接受到的网络数据直接从网络接口拷贝到应用程序内存,而无需经过内核中转。
Proactor 模式需要一个 loop ,这个 loop asio 将其封装为 io_service.他不仅是 asio的核心,更是一切基于asio设计的程序的核心。
宇宙级异步核心
io_service 脱胎于 IO 但不仅用于 IO. Christopher Kohlhoff 在给委员会的另一份编号 N3747 的信上上说它是 ~~宇宙级异步模型~~ Universal Asynchronous Model。在宇宙级异步模型里,一个异步操作由三部分构成
- 发起 ----按照 asio 的编码规范,所有的发起操作都使用 async_ 前缀,使用 async_动词 的形式作为函数名。
- 执行 ----异步过程在发起的时候被executor执行(系统可以是支持 AIO 的内核,不支持 AIO 的系统则是 aiso 用户层模拟)
- 完成并回调 ----在发起 async_* 操作的时候,总是携带一个回调的闭包。asio使用闭包作为异步事件完成的处理回调,而不是C式的回调函数。asio的宇宙异步模型里,回调总是在执行 io_service::run 的线程里执行。asio绝不会在内部线程里调用回调。
在回调里发起新的异步操作,一轮套一轮。整个程序就围绕着 io_service::run 运转起来了。
io_service 不仅仅能用于异步 IO ,还可以用来投递任意闭包,实现作为线程池的功能。这一通用型异步模型彻底击败微软 PPL 提案,致使微软转而研究协程。然而微软在协程上同样面临 asio 的绞杀。
闭包和协程
宇宙级 asio 使用闭包作为回调,而 C 库只能使用函数+void*, ACE 虽然使用的 C++语言,却不知道闭包为何物,使用的是 虚函数作为回调,需要大量的从 ACE 的对象继承。
以闭包为回调,asio更是支持了一种叫“无栈协程”的强悍武器。【不用栈保存状态,用闭包通过不停的拷贝更新状态】
asio的无栈协程,仅仅通过库的形式,不论是在性能上,还是易用性上,还是简洁性上,甚至是B格上,都超过了微软易于修改语言而得的 await提案。
微软、乃至 ACE ,并不是不知道闭包,而是在c++里实现闭包的宇宙级executor —— 也就是 io_service,需要对模板技术的精通。
asio “把困难留给自己,把方便带给大家”,以地球人无法理解的方式硬是在 c++98 上实现了宇宙级异步核心。
当然,如果 c++11 早点出现,如果 c++17 早点出现,实现 asio 的宇宙模型会更加的简单 —— 其实这也是 c++ 的理念,增加语言特性,只是为了让语言用起来更简单。
buffers
有了闭包的支持,内存管理也变得轻轻松松起来。
ASIO 本身并不管理内存,所有的IO操作,只提交对用户管理的内存的引用,称 Buffers。asio::buffers 引用了用户提交的内存,保持整个 IO 期间,这块内存的有效性是用户的责任。然而这并不难!
因为回调是一个闭包。通过闭包持有内存,只要 asio 还未回调,闭包就在,闭包在,内存在。asio 在调用完回调后才删除相应的闭包。因此资源管理的责任可以丢给闭包,而闭包可以通过智能指针精确的控制内存。
不是 GC , 胜于 GC 千百倍!益于c++的 RAII机制,再无内存泄漏之忧!
进入 ASIO 的世界
对 C++ 网络库的历史也介绍到差不多了,接下来的章节里,带你深入理解asio , 让你同时获得开发效率和执行效率。
一些在本章里,可能对许多人来说都是初次见到的技术,将在本书剩余章节里详细介绍。
翻开下一页,进入 ASIO 的世界,领略 C++ 的博大精深,享受网络遨游的快感吧!