异步IO应该怎么理解

自从前端工程师开始跨入牛逼的 全栈 时代,异步IO这个词就会经常听到。比如前端工程师最爱的Node.js就号称是 None Blocked Event Driven IO model,即使用了非阻塞的、事件驱动的IO模型。实际上从JavaScript和Node.js的编程方式上来看,我们真的可以认为JavaScript在浏览器平台和Node.js平台大部分的情况下其IO都是异步、非阻塞的。

那么,如何理解异步 同步,以及阻塞 非阻塞 这些概念呢?在Unix系统网络编程中,介绍了操作系统提供的几种文件描述符的IO操作方式。其中使用到了 同步异步阻塞非阻塞几个概念,并且掺杂在一起,带来了理解上的混淆和难度。 本文首先分析下Unix文件IO的这几种分类方式,然后给出我对这几个概念的理解。

Unix系统中文件IO的四种经典类型

其实同步异步不仅可以指IO,他是一种表达执行某个任务方式,也就是说在任务与任务(一般是主从任务)之间关系。

我们这里就以Unix系统中讲到的IO类型为例进行分析。对于同步异步,我认为可以用一个简单的方式来理解:

  1. 这个过程中–是不是主线程发起调用IO的API后,需要主线程主动去获取结果。 比如主线程要么阻塞在这里(同步阻塞)等,要么”要不断回来重复轮询调用下它(同步非阻塞)”。

  2. 如果发起IO任务后,主线程就再也不管这个IO任务了,而是做其他事情,那些发起的IO任务完成好之后通过适当的方式告诉主线程: 比如IO调用后,主线程去调用另外一个函数select来靠select进行通知(异步阻塞,这个不太好理解,后文会细讲);或者比如IO调用后,主线程就靠接收信号or函数回调来接收通知(异步非阻塞的2种实现方式)。 https://www.ibm.com/developerworks/cn/linux/l-async/

下面我们分别看下这4中IO类型。

同步阻塞

这种最容易理解,是我们平常最容易接触到的编程模式。也就是指的调用一个IO操作,直到IO完成获取到数据才返回给左侧变量(恢复主线程的执行),主线程代码才会往下执行。

1
2
3
var a = ajax()
var b = readFile()
console.log(“result是:”,a, b)

这种最传统的编程方式,主线程会在等待IO时被系统挂起,这大大浪费了CPU资源。而电脑硬件在底层的DMA技术本来就可以实现异步IO的,但系统内核将IO的API封装成这种同步阻塞的方式,就让调用者主线程白白等待,尤其是你的程序是一个处理网络IO的server时,进程被Hold住就相当于白白浪费了你Server的处理并发的能力。

想一下,如果你的Server进程可以在等待IO操作的同时,去响应其他请求,是不是就提高了并发处理效率。也正因此,Node.js和Nginx这种最大化使用 异步非阻塞IO 的Server框架才有其价值。

同步非阻塞

这种方式指的是调用IO操作后,返回一个错误或其他,但不阻塞你的线程。这样主线程可以继续执行其他代码,但需要通过不断轮询去获得IO状态,然后把数据move到用户态。

如果非要写个JavaScript的例子表达这种任务之间的关系,可以这样写:

1
2
3
4
// while辛苦地轮询API接口
while(a = ajax() && b = readFile()) {
console.log(a, b)
}

也就是说,IO依然无法主动来回调我主线程,从而导致主线程仿佛依然无法并行去处理其他事情,主线程依然要想办法不停地去检测IO状态,从而拉到数据。你可以写个while死循环,来监听状态,然后回调一个自己线程内的函数… 这貌似是自己主动编程来做,不能算是被动的!我们傲娇的主线程此时是不开心的!

这种同步非阻塞的方式使用while忙等待,还有下面要讲的 “异步非阻塞” 里的主线程在select阻塞,都有一个共性: 由于主线程都做不到 “被动接受通知”,所以 “主线程调用IO时不管阻塞不阻塞自己,都并没有完全放弃对此次IO调用的状态管理”。所以主线程确实没太有空去做其他额外的操作(如响应界面,做其他计算等)。 但是这种 同步非阻塞 的IO方式,最大的好处是可以实现同时发起多个IO任务了, 所以假如在单独一个非主线程里运行,从而就可以实现: 尽管在本线程内是无暇顾及其他工作的,但是可以让主线程调用我,从而让主线程调用IO时表现为异步的样子。所以这里也说明了同步、异步是一种任务执行方式,并不依赖于具体你怎样实现它

异步阻塞

这个简直让人难以理解,因为这种IO方式的过程中用到了非阻塞的IO,但它却是异步的。那它跟上面的 同步非阻塞有什么区别?

原来,它通过另外一个系统调用 select 来去获取底层IO事件的通知,从而表现为被另外一个任务回调通知了!主线程因此傲娇起来,再也不像 同步非阻塞 那样要自己去做这些事了。 所以,我们经过仔细观察,可以发现书本上之所以叫这种方式为异步的是因为整个IO生命过程中,是因为整体上看调用IO操作时没阻塞,而且获取IO数据是通过select系统调用的回调来通知主线程的,总体来看可以认为是异步的。

实际上我们看图就看出来缺点了:

  1. 收到IO通知时,IO只在内核完成了,主线程还要自己调用API来move数据到用户进程。看起来是异步接收通知了,实际上收的通知是早期的一个通知。 当然我们暂且忽略这一点,毕竟从收到通知的角度讲也算是异步嘛!
  2. 虽然发起IO任务并不阻塞主线程,看起来主线程也能做其他事情了,但是主线程却接下来就立刻阻塞在了select调用上。 为了接收IO的通知,主线程最终还是没有空闲下来去做其他工作。

注意这里跟”同步非阻塞” 使用 while(true) 轮询还是有点区别的,select是系统调用,会使得主线程被系统挂起,而自己写while是让主线程忙等待(还会浪费主机的cpu资源)

不过话说回来,这种异步阻塞的方式,至少可以让主线程能在select阻塞之前,或者select回调的过程中,能发起多个IO任务了。从这个角度也体现出一点所谓异步的价值。 而且,这种方式虽然阻塞了主线程,但是有个办法解决啊: 那就是 “不在主线程用这种IO方式就可以啦”, 记得有人说过 “解决bug的最好方式就是不写代码” “减少bug的最好办法就是不提需求”

因此,在Node.js的底层实现中,libuv就是通过epoll实现的网络IO的处理。由于不是在JS引擎线程中进行网络IO,所以libuv完全可以在收到通知时自己处理所有事情,最终再转换一个事件通知放入主js线程事件循环的 任务队列,从而对JS主线程来说,使用网络IO就是个异步且非阻塞的过程。Node.js这个过程,也跟网上其他人举的例子应该是类似的:

异步非阻塞

唯有真正靠这种IO任务主动来通知的、且主线程的非阻塞的异步才能做到 “真正的解放主线程”!

如果非要写个JavaScript的例子,可以这样:

1
2
3
4
var a = ajax(function (data) {
// 本回调被执行的时刻是不确定的,等那个IO任务完成后才会执行的哦!
console.log(data)
})

而《Netty网络编程》讲到异步IO(如AIO)的时候,提到了2种获取异步任务通知的方式,一种是信号通知,一种是让内核回调主线程的一个函数。

我对这几个概念的理解

所以,我们大可就认为 “同步异步是主线程与另外一项任务是如何联系的”, “阻塞非阻塞是主线程发起另一个任务后,主线程的状态”,所以:

讨论异步的前提是让别人来做一项任务。 自己的任务自己做,就没有同步异步这一说。

讨论阻塞不阻塞的前提也是你让别人来做一项任务。自己的任务自己做,也没有阻塞不阻塞一说。

如果非要问: “自己的任务自己做”应该叫什么。 那我们大可叫他 “同步的阻塞的”, 因为任何时刻你都做不到并行(不过用协程可以做单线程并发编程),那么你执行任何一句代码指令就是边阻塞边往下执行的。

从计算机IO的角度,我们看出同步异步的含义大概是这样的:

  • 同步是主线程发起了一项任务,但要等待或者轮询去获取IO任务状态(无法被动等待通知)
  • 异步是主线程发起一项任务,不需要关心其状态,等待任务主动来通知就可以了。

所以,我对 异步非阻塞 通俗推论这样的:

真正的异步是要主线程去发起执行一个任务,但这个任务可以自己去做(与主线程是并行的)。在这个前提下,主线程如果没有被这个调用阻塞住,并且可以去做其他工作,然后等发起的这个任务结束后自己主动来通过信号或函数回调等方式来主动来通知主线程, 那么这个任务我们就可以称之为 “异步的任务”,主线程所调用的这个任务API我们就可以称它是 “非阻塞的一个API” 。

所有高阶函数都是异步的吗?

如果主线程执行一个函数,这个函数可以接收callback函数作为参数,但这个callback是立即执行的,比如下面例子:

1
2
3
4
5
6
7
// 假设ajax是个本线程内自己实现的一个函数
var ajax = function (cb) {cb()}

var a = ajax(function (data) {
console.log(1)
})
console.log(2)

这个例子永远是先输出1,后输出2, 也就是说ajax函数根本就是主线程自己执行了一个函数而已,底层并没有用到任何其他线程或操作系统的异步IO系统调用,那请问这种写起来很像回调函数的写法能叫异步操作吗?

按照上文中,我给同步异步下的定义,我认为这还是应该叫做 同步,而不能因为写的代码类似是异步回调的写法,就认为是个异步操作!而且由于他根本没有进行底层的异步操作,这个ajax函数是”阻塞”的(注意这里的阻塞是要加引号的,因为他并不是真的让主线程挂起的那种阻塞),ajax函数里执行时间越长则主线程”阻塞”事件越长,直到回调函数执行完成程序才能往下走。

所以,我们平常在进行讨论异步编程时,都应该有并行任务存在的,也就是大前提要保证 “是执行另外一个任务”, 连epoll这种阻塞式的异步也不例外。

异步的最佳实现方式—事件机制

上文讲了,既然异步的最大特性是 调用后就不管了, 等到合适的时机由 被调用者 来通知主线程,且一般这个调用是 非阻塞 的。那么,事件机制 就是异步情景下的最佳编程模式。

试想这个例子: 你用壶烧上开水,然后心里想好等水壶报警了我再回来拿水(事件绑定), 然后你离开去做别的事情,等到水开了水壶发出报警声(事件),然后你再去拿热水。如果把报警声比喻为事件,那么这里就是个典型的事件机制实现了订阅模式的例子。

在底层提供的异步IO机制里,其中一种通知方式是使用”信号”,这种方式其实就可以理解为一种事件机制。而在实际的现代化编程平台中,一般都对底层的异步IO操作进行了封装,例如libuv里面使用多线程+epoll实现的异步网络IO。 在这种封装之后的框架中可以在底层将”信号”或epoll回调转换封装为事件来发出。

在我的另外一篇博文浏览器的单线程机制和事件循环中,可以看到在Node.js和浏览器中,确实在底层将异步IO封装后作为事件分发给平台主线程事件循环的任务队列,从而实现了主线程基于异步事件的机制来获取异步结果。

同步异步和多线程的关系

线程的本质

线程不是一个计算机硬件的功能,而是操作系统提供的一种逻辑功能,怎么理解呢? 实际上,计算机上跑的软件就是操作系统这个软件。我们的exe程序,其实本质上是被操作系统拿来执行的, 所以操作系统就可以自己决定来执行哪段代码(exe)。 最终,操作系统采取的策略就是创造了 “线程” 这种逻辑单位, 这样操作系统可以轮流让这些代码来使用cpu资源(本质上就是操作系统轮流去执行不同的代码)。线程本质上是进程中一段并发运行的代码,所以线程需要操作系统投入CPU资源来运行和调度。

传统的使用多线程的场景,都是将本来同步的IO任务拆分到单独的线程去做,多线程的优点很明显,线程中的处理程序依然是顺序执行,符合普通人的思维习惯,所以编程简单。但是多线程的缺点也同样明显,线程的使用(滥用)会给系统带来上下文切换的额外负担。并且线程间的共享变量可能造成死锁的出现。

多线程的机制可以帮助我们实现异步

然后你可以发现,基于多线程,在主线程中就可以单独开一个线程去做某个任务。所以它是可以将任何操作都实现为异步非阻塞的(因为多了个人同时做工作嘛),包括某个本来是同步阻塞的IO操作。因为我开了一个单独的线程,就可以不阻塞调用方,而且并行执行。再加上可以完成任务后通过某些方式通知主线程,因此表现上就跟操作系统提供的异步IO是很像的。

所以,多线程是可以实现异步IO的,Apache就是对每个请求都开启一个线程来进行处理。每个线程内都是同步阻塞的。由于多线程存在上下文切换和资源消耗略大的问题,所以多线程适合用来做 异步IO 做不到的事情(比如计算), 而IO相关的尽量还是要用系统底层的异步IO。比如libuv里虽然用单独的线程来IO,但实际上IO线程里面用的是系统内核的异步IO–epoll,并没有开启多个线程来执行每一个IO任务。Node.js它不是纯靠添加线程来进行同步IO的,实际上它在网络IO的线程里就用了异步阻塞版本的epoll(注意是异步哦~)。正是由于是单独的线程进行epoll,所以尽管实际IO操作是阻塞的,但对Node.js的主执行线程来说所有的Node API都是异步非阻塞的!
http://blog.csdn.net/ppdyhappy/article/details/49001143

本文总结

理解异步IO,对于前端工程师是非常重要的。毕竟,前端两大平台: 浏览器和Node.js, 都是使用了异步非阻塞的IO模型。通过本文,我简单分析了Unix网络编程中的4种IO方式,并提出了自己对同步、异步概念的看法。

我认为判断同步和异步,简单可以从2点来看:

  1. 发起一个任务后,不管主线程阻塞不阻塞,要看发起的任务是不是与主线程并行处理。(这是讨论同步异步的前提!)
  2. 主线程是不是可以被动接收通知,这决定了是同步还是异步。

判断阻塞和非阻塞,简单从一点来判断即可: 调用某个API后,主线程是否被挂起,直到该API要执行的任务彻底完成。当然,讨论的前提也必须是发起一个并行的任务。

附录: 用吃饭喝茶的例子来理解这几个概念

举个简单的例子: 你在餐馆吃饭会友,调了一个服务员来给你倒茶喝(相当于做IO操作这个任务)。现在有2个角色存在,一个是你负责陪你朋友吃饭聊天,另外你叫了一个服务员角色来给你做倒茶的任务。此时你有几种方式来完成 倒茶->喝茶 这件事。

  1. 同步+阻塞。 你可以停下吃饭,也不跟你的老友聊天了,就眼睁睁的瞪着服务员给你倒茶。 你可以边倒边喝或者等他不倒了(IO完成)再喝。喝完再跟你好友聊天。
    这种方式,缺点是你无畏的浪费了时间,就在那瞪着服务员把茶水倒好。
    优点是操作简单容易理解,倒茶时就专心倒茶、聊天时就专心聊天。 你晚上回到家回想起这顿饭: 先倒了茶喝了茶,然后又聊了会天,恩很清晰。

  2. 同步+非阻塞。 你可以让服务员倒茶,发现没倒满,此时你就不看茶杯了而是跟你的好友聊天、吃饭; 然后聊几秒钟再停下来去看看倒茶的操作咋样了(再调用下IO的API看是否已完成),服务员说’嗨我这不正在给你倒么’,就表示还没倒完,如果他说’嗨早倒完了’就表示水倒满了。如果发现还没倒满你就再去聊会天,过一会再来看下倒完了没。如果倒完了就喝茶,喝完再聊天。
    这种方式,缺点是你要 主动不断 去检查倒茶的状态(检测IO结果状态),你不断去让服务员给你倒茶来问问有没有倒完,这依然耽误了你一部分聊天的时间,而且让你的聊天节奏都有点乱。 要是你还想倒一杯雪碧、可乐啥的,你还得主动去问好多遍呢。
    这种方式的优点是起码节省了你一点点时间

    同步+非阻塞 的方式已经有点良好了,能不能再往优秀进步一点呢? 那就是把同步换成异步咯。

  3. 异步+非阻塞。 你可以找这样一个服务员(支持异步调用的服务员),这种服务员经过了特殊培训,他倒完茶会自动在你菜单上面写个’已倒完’的几号,或者他直接扇你一巴掌把你按到茶杯里喝茶。 然后你请老友吃饭的时候,只需要想好今天倒完茶后是喝掉,还是漱口(做事件或信号的绑定)。 然后服务员就开始倒茶,你就安心聊天。

  • 当你聊天间隙闲着没事的时候(主线程空闲),你就瞅一眼桌上菜单有没有 ‘已倒完’ 的记号(Node.js的事件循环方式),当你发现了 ‘已倒完’,那你就按当初想的去做(触发事件handler)就行了(喝掉或漱口)

  • 或者你顾着聊天的时候,忽然被服务员扇了一巴掌,把你按到茶杯里把茶喝完了(系统信号中断或回调的方式)。

这种异步非阻塞的方式的优点就是倒茶这个事情根本不占用主线程的时间(IO整个过程完全独立,其IO状态也不需要主线程主动询问)。如果同时倒多种饮料也没关系,就监听各种事件就行了,监听到不同饮料倒完后做相应的处理就行。

如果用记记号的方式,则缺点是你要万一是聊天的很嗨,你就根本没时间去看桌上的纸条(主线程在做计算密集型的任务,无法空闲)。那么,你的茶就凉啦….喝晚了会胃疼。(这里特别像Node.js在处理一个http请求事件的时候如果你利用js自身这唯一的线程做大量耗时运算,那其他请求事件在事件队列里将一直得不到处理,你的WebServer就处于一种请求无响应的地步。 所以Node.js单线程性能好并发能力强的前提在于你不要用js主线程做耗时的操作。而应该去做那些IO操作,因为调用这种API相当于让服务员倒茶,它是非阻塞的且是异步等通知的,所以”叫完这个服务员之后”主线程就可以立马去处理下一个http请求)

Refer

epoll与异步的讨论:https://www.zhihu.com/question/21896633
IO多路复用到底是不是异步的? https://www.zhihu.com/question/59975081
IO多路复用,同步,异步,阻塞和非阻塞 区别. http://www.cnblogs.com/aspirant/p/6877350.html?utm_source=itdadao&utm_medium=referral