Golang和Erlang的IO调度浅析

司空叶五
2023-12-01

之前关于调度器的对比分析的文章,在结束时遗留了一些问题:当系统出现高并发的IO访问时,如一个网络服务器通常要并发处理成百上千的链接,每个链接可能都是由一个用户任务执行的,那么将会出现大量阻塞的IO操作,如果为每个阻塞操作都单独分配一个OS线程,那么系统很容易就会退化成多OS线程的系统,轻量任务的优势将无从谈起。本文试图回答这个问题,通过分析Go和Erlang对于IO、特别是网络IO的优化机制,了解其对调度器乃至整个系统性能的影响。

Go的IO优化机制 —— netpoller

由于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优化。具体来说:

  • 首先,无论何时,当在Go中打开或接收到一个链接时,其文件句柄都会被设为NONBLOCKING模式。(Go语言库
  • 当调用相应的Read/Write等操作时,无论是否成功,都会直接返回而不会阻塞。当返回值是EAGAIN时,表示IO事件还没有到达,需要等待。这时,Go库函数调用PollServerAddFd()将对应文件句柄加入netpoller的监控池,并将当前Goroutine阻塞。(Go语言库、netpoll.goc中
  • 当系统中存在空闲 P & M (参见这里) 时,runtime 会首先查找本地就绪队列,若其空,则调用netpoller; netpoller通过OS提供的epoll或kqueue机制,检查已到达的IO事件,并唤醒对应的Goroutine返回给runtime,将其再度执行。(runtime/proc.c:findrunnable()
  • 最后,Goroutine再次回到Go语言库上下文时,再调用Read/Write等IO操作时,就可以顺利返回了。(Go语言库)

Erlang的IO优化机制之一 —— “Async Threads Pool”

在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的IO优化机制之二 —— “System Level Activities”

如前所述,无论时Erlang还是Go,都是针对服务器端设计的语言,因此都提供了不同于一般IO的特殊机制来处理网络IO。

Erlang的做法是提供一种特别的调度单元 —— System Level Activities,来调度异步IO事件。它的思想和Go的netpoller非常类似:

  • 首先,网络链接对应的句柄会被设为NONBLOCKING状态;
  • 一次IO操作如果在响应事件到来前被调用,则会将其等待的事件注册到Erlang虚拟机的IO事件链中;
  • 调度器在调度时,会周期性的调用check_io操作来检查已注册的IO事件是否已经到来(利用OS的poll操作),并唤醒响应事件阻塞的用户任务(进程或Port)。

值得注意的是,Erlang虚拟机在处理IO事件时,还采用了一种 stealing 的机制。具体来说,当一个driver的函数调用IO操作时,如果对应IO事件没有到来时,还会主动调用 select_steal()窃取其他已注册的IO事件,如果该事件已触发,则完成相应的读/写操作,并通知上层进行后续处理。

Libtask中的异步IO机制

作为Go语言的前身,Libtask库同样实现了异步IO机制,并且实现方式更加简洁。

与Go类似,在Libtask中,为用户级task封装了IO操作,提供了fdread/fdwrite/fdwait/fdnoblock等接口实现异步IO。(在libtask提供的例子中,所有IO操作都是针对网络IO的,因此仅就网络IO情况加以分析。

  • 链接句柄首先会通过调用fdblock()被设为NONBLOCKING态;
  • 之后调用fdread/fdwrite时,一旦返回EAGAIN,则调用fdwait,注册等待IO事件并将自身调出;
  • Libtask会在第一次接收到IO事件注册后建立一个系统任务fdtask,该任务通过调用poll系统调用检查新到来的IO事件,并将对应任务重新加到就绪队列中。

总结及参考

通过上文分析,了解到IO优化对调度器乃至语言本身性能的影响。这与两种语言的应用背景——服务器端编程有很大关系。

通常来说,应用程序必须通过Syscall 访问操作的特定功能,这就会涉及底层 OS 的调度机制,作为用户态的任务调度器,Erlang虚拟机或Go的运行时系统都必须对内核调度引入的不确定性加以控制。特别是 IO 操作这类特殊并且会大量访问到的Syscall,必须设计有针对性的优化方案,才能确保高的并发性能。

Go 和 Erlang 的实现方案随不尽相同,但核心的思想都是类似的,通过异步IO 优化基于Socket的操作,而对于一般的文件读写,则直接让执行线程及运行的用户任务阻塞,调度器再将其他可以执行的任务绑定到其他OS线程继续执行。

这篇文章除了参考了Erlang/OTP及Go语言的源代码外,还参考了以下资料:

 类似资料: