之前关于调度器的对比分析的文章,在结束时遗留了一些问题:当系统出现高并发的IO访问时,如一个网络服务器通常要并发处理成百上千的链接,每个链接可能都是由一个用户任务执行的,那么将会出现大量阻塞的IO操作,如果为每个阻塞操作都单独分配一个OS线程,那么系统很容易就会退化成多OS线程的系统,轻量任务的优势将无从谈起。本文试图回答这个问题,通过分析Go和Erlang对于IO、特别是网络IO的优化机制,了解其对调度器乃至整个系统性能的影响。
由于Go是一门主要面向互联网环境的分布式语言,相对于一般的IO,如文件读写等,网络IO的并发性能更加重要。对于一般IO,Go的处理方式就是按上篇所说的,将执行Syscall的OS线程剥离。通常应用场景下,不会出现大量并发Goroutine去同时读写文件的情况,因而上面的方式并不会真正造成调度器的退化。因此主要的IO优化都是针对io/net库的。
无独有偶,Erlang在实现上同样对网络IO提供了不同于一般IO的高效处理方式,后面再作介绍。
Go实现中利用了OS提供的非阻塞IO访问模式,并配合epll
/kqueue
等IO事件监控机制;但是为了弥合OS的异步机制与Go接口的差异,Go在其库中做了一些封装,并在runtime层提供了一种叫做netpoller,“网络轮询器”的机制,来实现网络IO优化。具体来说:
NONBLOCKING
模式。(Go语言库)PollServer
的AddFd()
将对应文件句柄加入netpoller的监控池,并将当前Goroutine阻塞。(Go语言库、netpoll.goc中)在Erlang中,所有IO操作都需要以Port驱动的形式提供,所谓Port驱动包含一组C回调函数,用来响应用户进程的访问;用户进程则通过通用的消息传递机制与Port交互。Erlang虚拟机会把Port当做一种特殊的任务加以调度。
真正的系统调用,如read/write/flush等阻塞式操作都被封装在Port的回调函数之中,当调度器调度执行响应的Port时,就会导致当前的调度器执行线程被OS阻塞,从而影响系统的并行性。
Erlang解决该问题的办法是提供了一组OS线程作为异步线程池,阻塞的IO操作(以函数指针形式)会被Port注册到异步线程池的操作队列中。异步线程则执行循环操作,取出当前任务队列的IO任务并执行阻塞操作。
这种方式类似Go对非net类IO及执行阻塞式Syscall的调度方式:用一个单独的OS线程去执行阻塞操作。
Erlang的file IO基本上就是以上述方式实现的。
因为 Erlang 将调度器映射到一个OS线程而说其调度是1:1的其实是不准确的。基于对阻塞IO的异步处理及上篇讲到的负载平衡机制,使得Erlang实际上也实现了M:N的调度,只不过Erlang的官方文档并没有这么说,只是说单纯增加调度器数不会对性能造成影响。
如前所述,无论时Erlang还是Go,都是针对服务器端设计的语言,因此都提供了不同于一般IO的特殊机制来处理网络IO。
Erlang的做法是提供一种特别的调度单元 —— System Level Activities,来调度异步IO事件。它的思想和Go的netpoller非常类似:
NONBLOCKING
状态;check_io
操作来检查已注册的IO事件是否已经到来(利用OS的poll操作),并唤醒响应事件阻塞的用户任务(进程或Port)。值得注意的是,Erlang虚拟机在处理IO事件时,还采用了一种 stealing 的机制。具体来说,当一个driver的函数调用IO操作时,如果对应IO事件没有到来时,还会主动调用 select_steal()
窃取其他已注册的IO事件,如果该事件已触发,则完成相应的读/写操作,并通知上层进行后续处理。
作为Go语言的前身,Libtask库同样实现了异步IO机制,并且实现方式更加简洁。
与Go类似,在Libtask中,为用户级task封装了IO操作,提供了fdread/fdwrite/fdwait/fdnoblock等接口实现异步IO。(在libtask提供的例子中,所有IO操作都是针对网络IO的,因此仅就网络IO情况加以分析。)
fdblock()
被设为NONBLOCKING
态;fdread
/fdwrite
时,一旦返回EAGAIN
,则调用fdwait
,注册等待IO事件并将自身调出;fdtask
,该任务通过调用poll系统调用检查新到来的IO事件,并将对应任务重新加到就绪队列中。通过上文分析,了解到IO优化对调度器乃至语言本身性能的影响。这与两种语言的应用背景——服务器端编程有很大关系。
通常来说,应用程序必须通过Syscall 访问操作的特定功能,这就会涉及底层 OS 的调度机制,作为用户态的任务调度器,Erlang虚拟机或Go的运行时系统都必须对内核调度引入的不确定性加以控制。特别是 IO 操作这类特殊并且会大量访问到的Syscall,必须设计有针对性的优化方案,才能确保高的并发性能。
Go 和 Erlang 的实现方案随不尽相同,但核心的思想都是类似的,通过异步IO 优化基于Socket的操作,而对于一般的文件读写,则直接让执行线程及运行的用户任务阻塞,调度器再将其他可以执行的任务绑定到其他OS线程继续执行。
这篇文章除了参考了Erlang/OTP及Go语言的源代码外,还参考了以下资料: