前言
从python的twisted,到之后Java的NIO,Netty,以及Nodejs带着底层libuv的横空出世,以及现在热议的Golang。异步/多线程/协程编程已经成为了一个耳熟能详的编程范式,它从理论上可以最大化cpu利用率和I/O资源,在计算机核数越来越多,服务器端性能要求越来越高的环境下,熟练掌握异步编程模式已经成为了基本要求。
本文我们来梳理一下异步编程到底是怎么回事。
计算机本身即是同步也是异步
波粒二象性
一台裸机,配上主板,内存,CPU。这是同步模型还是异步模型?都是,说异步,因为CPU被内部的时钟驱动,每次时钟信号会驱使CPU累进当前读取地址,从内存读出新的内容,接着CPU计算和输出。从这一点看,CPU是被皮鞭不断抽打着前进的异步模式。
然而,它又是同步的,因为皮鞭挥动速度实在太快,i7 CPU频率到了4Ghz,每秒挥动4,000,000,000次。指令如流水一样的按序执行,在逻辑上我们通常把它当做一个同步模型来理解。
是不是有点像光的波粒二象性?-
中断
中断是计算机跟外界交互的基础。如果CPU只能累进地址,那么所有的编程模型将都会是同步---如同穿孔纸带那样顺次排列code执行。
中断是CPU提供的一种机制,当某固定针脚上的信号发生变化时,CPU跳跃执行另外一套指令。有了这套机制,我们才可能使用各种I/O(硬盘,键盘,显示器)设备。
比如说,当我们从键盘上敲入一个字符"A",系统会受到一个中断,CPU就跳到键盘的中断处理程序上,读取了输入内容"A",然后将这个事件继续传递给显示程序,显示程序就在屏幕上打出了一个"A"。 操作系统是一个异步系统
操作系统为了方便人操作机器而产生的,它的使命就是接受各种I/O操作产生各种输出,它天然就是一个异步模型。从某种程度上来说,操作系统就像一个大型的中断处理库。
程序是同步模型还是异步模型
- 程序本质是同步的
虽然操作系统是异步,但是单个程序是同步。。。我们的程序交给操作系统执行时,操作系统会构建堆栈,页表,然后把代码复制到内存中,然后用CPU顺序执行,从这个角度讲,跟打在穿孔纸带上没啥区别。 - 系统调用
但是我们也可以调用I/O等基于中断机制的操作?那是你以为,其实程序本身是无权调用I/O程序的,只能向操作系统发出请求,说让我读一下网卡?操作系统收到请求后,就会停止当前程序,然后自己去读取网卡,等网卡向操作系统发出中断表示数据准备完毕后,操作系统会把数据转移到程序的某个位置,然后再“唤醒”程序。在网卡收到读取请求和发出中断这之间的时间,原本的程序会被“冻结”,操作系统会调度其它的程序占用CPU继续执行。
实际上来看,程序是被异步执行。但程序本身的感觉却是同步,当它发出I/O请求的那一刻,它的“时间”被凝固了,直到操作系统重新打个响指,程序反应过来时,数据已经被放在眼前,仿佛魔术一般。由于不可感知,所以我们写程序实际只能是基于同步模型。 - 多进程/线程-大并发高性能计算的要求
操作系统这么做是为了平衡CPU和I/O资源,这样一个进程在等待I/O时,另一个进程可以利用CPU进行计算。从它的角度看这么做无可厚非,但是现在很多服务器就是为了单一功能而存在的,它只需要最大化某一个程序的CPU,I/O利用率,这种被迫的让出并不是它期待的结果。
因此有一个办法就是用进程/线程“淹没”操作系统,比如Apache的webserver,每一个请求另外启动一个进程/线程去处理。于是挂在I/O上的是webserver的listen进程/线程,占用CPU的则是webserver的child进程/线程,最后提高了系统的吞吐率。
异步-更高性能和更大并发的结果
上述方案够吗?不够,因为人总是贪心的,交给操作系统去做切换是非常耗时的操作,另外大量的进程/线程的创建销毁也是不小的开销。程序开发者想直接控制这些过程,而不是交给笨重的操作系统。那怎么办呢?
- 重新打造一个事件驱动引擎
程序本来是被CPU驱动的,当有I/O请求时这个驱动没了---CPU被抽走执行其它程序去了。那我现在需要操作系统全力执行我这个程序,怎么办?
无论python twisted, Java Netty, Nodejs libuv,所做的事情是一样的,做一个自己的事件驱动引擎:
#Python twisted
class _SignalReactorMixin(object):
def startRunning(self, installSignalHandlers=True):
self._installSignalHandlers = installSignalHandlers
ReactorBase.startRunning(self) def run(self, installSignalHandlers=True):
self.startRunning(installSignalHandlers=installSignalHandlers)
self.mainLoop()
def mainLoop(self):
while self._started:
try:
while self._started:
# Advance simulation time in delayed event
# processors.
self.runUntilCurrent()
t2 = self.timeout()
t = self.running and t2
self.doIteration(t)
except:
log.msg("Unexpected error in main loop.")
log.err()
else: log.msg('Main loop terminated.')
Nodejs使用Libuv库作为主线程引擎.
Java Netty框架同样自己写了一个EventLoop
//Java Netty
protected void run() {
for (;;) {
try {
int strategy = selectStrategy.calculateStrategy(selectNowSupplier, hasTasks());
switch (strategy) {
case SelectStrategy.CONTINUE:
continue;
case SelectStrategy.SELECT:
strategy = epollWait(WAKEN_UP_UPDATER.getAndSet(this, 0) == 1);
if (wakenUp == 1) {
Native.eventFdWrite(eventFd.intValue(), 1L);
}
default:
// fallthrough
}
final int ioRatio = this.ioRatio;
if (ioRatio == 100) {
if (strategy > 0) {
processReady(events, strategy);
}
runAllTasks();
} else {
final long ioStartTime = System.nanoTime();
if (strategy > 0) {
processReady(events, strategy);
}
final long ioTime = System.nanoTime() - ioStartTime;
runAllTasks(ioTime * (100 - ioRatio) / ioRatio);
}
if (allowGrowing && strategy == events.length()) {
//increase the size of the array as we needed the whole space for the events
events.increase();
}
if (isShuttingDown()) {
closeAll();
if (confirmShutdown()) {
break;
}
}
} catch (Throwable t) {
logger.warn("Unexpected exception in the selector loop.", t);
// Prevent possible consecutive immediate failures that lead to
// excessive CPU consumption.
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
// Ignore.
}
}
}
}
这三个框架的事件引擎实现从本质来说是一样的,就像CPU通过内置的晶振来驱动一样,引擎通过设置短超时时长,高频率的调用select/poll/epoll。I/O事件或者超时返回成为了引擎的驱动力。每次调用返回时,就检查对应的I/O端口上是否有事件,如果有则执行绑定的回调,或者另外开线程处理回调,另外它们还会单独实现一个事件队列用来存储非I/O绑定事件,在每次轮询返回时也会检查该事件队列并处理到期的事件。
假设设置的超时时长是50ms,当任何I/O操作都没有的时候,事件引擎就退化成一个50ms执行一次检查的事件队列。
轮询实质上是同步操作,不过也没办法,因为前面讲过了,程序本身就是同步模型。在低并发量的时候,这种异步框架效率还会低于同步框架,因为浪费了大量CPU时间在轮询等待上,但是在高并发量的时候,每次轮询都会立即返回,几乎变成一个纯异步模型,但不同于直接调用系统API,返回的处理控制逻辑被掌握在引擎手里。因此可以通过很多技术和调整来提高程序效率。
- Golang
值得单独一提的是Golang,Golang牛逼哄哄的自己实现了一个调度器!对,你没看错,它自己写了一个调度器。
因为即使是线程,也有很多context,切换起来也非常耗时。Golang为了给使用者提供便捷的Goroutine模型,干脆自己写了个调度器,每一个Goroutine有自己单独的栈,instruction pointer等,这些信息都保存在堆上。当某个Goroutine被调度执行时,就从堆上恢复其context,调用实际的系统物理线程执行。
在这种模式下,实际物理线程不需要频繁切换了,它实际执行的是一个routine队列,队列的调度器每次选出Goroutine交付给线程执行,Goroutine是超轻量级的异步并发执行单元。
当Golang中某一个Goroutine调用系统I/O时,调度器会调用对应I/O的non blocking版本,然后将该Goroutine转入等待状态,并切换另一个Goroutine执行,同时也通过一个轮询机制等该I/O返回时唤醒对应Goroutine,从而实现CPU和I/O资源的高效利用。
总结
其实从某种程度来说,异步框架是程序试图跳出操作系统界定的同步模型,重新虚拟出一套执行机制,让框架的使用者看起来像一个异步模型。另外通过把很多依赖操作系统实现的笨重功能换到程序内部使用更轻量级的实现。
其实怎么看操作系统都像是累赘了。。。也许有一个完美的解决方案是直接写一个专门的异步式操作系统,可以实现性能的最大化利用。