Nodejs 异步IO与事件驱动
nodejs
的两大特性,即异步IO
和事件驱动
。通过对***《深入浅出nodejs》***和几篇博客的阅读以后,有了大致的了解,总结一下。
注意本文的内容基于node11以上。
同步与异步,阻塞与非阻塞
“阻塞”与"非阻塞"与"同步"与“异步"不能简单的从字面理解,提供一个从分布式系统角度的回答。
1.同步与异步
同步和异步关注的是消息通信机制 (synchronous communication/ asynchronous communication)
所谓同步,就是在发出一个调用时,在没有得到结果之前,该调用就不返回。但是一旦调用返回,就得到返回值了。
换句话说,就是由调用者主动等待这个调用的结果。
而异步则是相反,调用*在发出之后,这个调用就直接返回了,所以没有返回结果。换句话说,当一个异步过程调用发出后,调用者不会立刻得到结果。而是在调用*发出后,被调用者通过状态、通知来通知调用者,或通过回调函数处理这个调用。
典型的异步编程模型比如Node.js
举个通俗的例子:
你打电话问书店老板有没有《分布式系统》这本书,如果是同步通信机制,书店老板会说,你稍等,”我查一下",然后开始查啊查,等查好了(可能是5秒,也可能是一天)告诉你结果(返回结果)。
而异步通信机制,书店老板直接告诉你我查一下啊,查好了打电话给你,然后直接挂电话了(不返回结果)。然后查好了,他会主动打电话给你。在这里老板通过“回电”这种方式来回调。
\2. 阻塞与非阻塞
阻塞和非阻塞关注的是程序在等待调用结果(消息,返回值)时的状态.
阻塞调用是指调用结果返回之前,当前线程会被挂起。调用线程只有在得到结果之后才会返回。
非阻塞调用指在不能立刻得到结果之前,该调用不会阻塞当前线程。
还是上面的例子,
你打电话问书店老板有没有《分布式系统》这本书,你如果是阻塞式调用,你会一直把自己“挂起”,直到得到这本书有没有的结果,如果是非阻塞式调用,你不管老板有没有告诉你,你自己先一边去玩了, 当然你也要偶尔过几分钟check一下老板有没有返回结果。
在这里阻塞与非阻塞与是否同步异步无关。跟老板通过什么方式回答你结果无关。
几个例子
在开始之前,先来看几个简单例子,这也是我在使用nodejs
时候遇到的几个比较困惑的例子。
example 1
1 | var fs = require("fs"); |
question 1
为何
timeout1
和timeout2
的结果会在end
后面?
example 2
1 | var fs = require("fs"); |
question 2
为何
timeout1
和timeout2
没有输出到终端?while(true)
到底阻塞了什么?
example 3
1 | var fs = require("fs"); |
question 3
为什么
timeout1
中回调函数会阻塞timeout2
中的回调函数的执行?
example 4
1 | var fs = require("fs"); |
question 4
和上面的问题一样,为何
timeout1
的计算密集型工作将会阻塞timeout2
的回调函数的执行?
example 5
1 | var fs = require("fs"); |
question 5
为何读取文件的
IO
操作不会阻塞timeout2
的执行?
接下来我们就带着上面几个疑惑去理解nodejs
中的异步IO
和事件驱动
是如何工作的。
异步IO(asynchronous I/O)
首先来理解几个容易混淆的概念,阻塞IO(blocking I/O)
和非阻塞IO(non-blocking I/O)
,同步IO(synchronous I/O)和异步IO(synchronous I/O)
。
博主一直天真的以为非阻塞I/O
就是异步I/O
T_T,apue
一直没有读懂。
阻塞I/O 和 非阻塞I/O
简单来说,阻塞I/O就是当用户发一个读取文件描述符的操作的时候,进程就会被阻塞,直到要读取的数据全部准备好返回给用户,这时候进程才会解除block
的状态。
那非阻塞I/O呢,就与上面的情况相反,用户发起一个读取文件描述符操作的时,函数立即返回,不作任何等待,进程继续执行。但是程序如何知道要读取的数据已经准备好了呢?最简单的方法就是轮询。
除此之外,还有一种叫做IO多路复用
的模式,就是用一个阻塞函数同时监听多个文件描述符,当其中有一个文件描述符准备好了,就马上返回,在linux
下,select
,poll
,epoll
都提供了IO多路复用
的功能。
同步I/O 和 异步I/O
那么同步I/O
和异步I/O
又有什么区别么?是不是只要做到非阻塞IO
就可以实现异步I/O
呢?
其实不然。
同步I/O(synchronous I/O)
做I/O operation
的时候会将process阻塞,所以阻塞I/O
,非阻塞I/O
,IO多路复用I/O
都是同步I/O
。异步I/O(asynchronous I/O)
做I/O opertaion
的时候将不会造成任何的阻塞。
非阻塞I/O
都不阻塞了为什么不是异步I/O
呢?其实当非阻塞I/O
准备好数据以后还是要阻塞住进程去内核拿数据的。所以算不上异步I/O
。
这里借一张图(图来自这里)来说明他们之间的区别
事件驱动
事件驱动(event-driven)
是nodejs
中的第二大特性。何为事件驱动
呢?简单来说,就是通过监听事件的状态变化来做出相应的操作。比如读取一个文件,文件读取完毕,或者文件读取错误,那么就触发对应的状态,然后调用对应的回掉函数来进行处理。
线程驱动和事件驱动
那么线程驱动
编程和事件驱动
编程之间的区别是什么呢?
线程驱动
就是当收到一个请求的时候,将会为该请求开一个新的线程来处理请求。一般存在一个线程池,线程池中有空闲的线程,会从线程池中拿取线程来进行处理,如果线程池中没有空闲的线程,新来的请求将会进入队列排队,直到线程池中空闲线程。事件驱动
就是当进来一个新的请求的时,请求将会被压入队列中,然后通过一个循环来检测队列中的事件状态变化,如果检测到有状态变化的事件,那么就执行该事件对应的处理代码,一般都是回调函数。
对于事件驱动
编程来说,如果某个时间的回调函数是计算密集型
,或者是阻塞I/O
,那么这个回调函数将会阻塞后面所有事件回调函数的执行。这一点尤为重要。
nodejs的事件驱动和异步I/O
事件驱动模型
上面介绍了那么多的概念,现在我们来看看nodejs
中的事件驱动
和异步I/O
是如何实现的.
nodejs
是单线程(single thread)运行的,通过一个事件循环(event-loop)来循环取出消息队列(event-queue)中的消息进行处理,处理过程基本上就是去调用该消息对应的回调函数。消息队列就是当一个事件状态发生变化时,就将一个消息压入队列中。
nodejs
的时间驱动模型一般要注意下面几个点:
- 因为是单线程的,所以当顺序执行
js
文件中的代码的时候,事件循环是被暂停的。 - 当
js
文件执行完以后,事件循环开始运行,并从消息队列中取出消息,开始执行回调函数 - 因为是单线程的,所以当回调函数被执行的时候,事件循环是被暂停的
- 当涉及到I/O操作的时候,
nodejs
会开一个独立的线程来进行异步I/O
操作,操作结束以后将消息压入消息队列。
下面我们从一个简单的js
文件入手,来看看 nodejs
是如何执行的。
1 | var fs = require("fs"); |
- 同步执行
debug("begin")
- 异步调用
fs.readFile()
,此时会开一个新的线程去进行异步I/O
操作 - 异步调用
setTimeout()
,马上将超时信息压入到消息队列中 - 同步调用
debug("end")
- 开启事件循环,弹出消息队列中的信息(目前是超时信息)
- 然后执行信息对应的回调函数(事件循环又被暂停)
- 回调函数执行结束后,开始事件循环(目前消息队列中没有任何东西,文件还没读完)
异步I/O
读取文件完毕,将消息压入**消息队列(**消息中含有文件内容或者是出错信息)- 事件循环取得消息,执行回调
- 程序退出。
这里借一张图来说明nodejs
的事件驱动模型(图来自这里)
JS的事件循环(eventloop)是怎么运作的?
“先执行同步操作异步操作排在事件队列里”这样的理解其实也没有任何问题但如果深入的话会引出来很多其他概念,比如event table和event queue,我们来看运行过程:
- 首先判断JS是同步还是异步,同步就进入主线程运行,异步就进入event table。
- 异步任务在event table中注册事件,当满足触发条件后(触发条件可能是延时也可能是ajax回调),被推入event queue。
- 同步任务进入主线程后一直执行,直到主线程空闲时,才会去event queue中查看是否有可执行的异步任务,如果有就推入主线程中。
1 | setTimeout(() => { |
我们用上面的第二条来分析一下这段脚本,setTimeout是异步操作首先进入event table,注册的事件就是他的回调,触发条件就是2秒之后,当满足条件回调被推入event queue,当主线程空闲时会去event queue里查看是否有可执行的任务。
1 | console.log(1) // 同步任务进入主线程 |
1、3是同步任务马上会被执行,执行完成之后主线程空闲去event queue(事件队列)里查看是否有任务在等待执行,这就是为什么setTimeout的延迟时间是0毫秒却在最后执行的原因。
关于setTimeout有一点要注意延时的时间有时候并不是那么准确。
1 | setTimeout(() => { |
分析运行过程:
- console进入Event Table并注册,计时开始。
- 执行sleep函数,sleep方法虽然是同步任务但sleep方法进行了大量的逻辑运算,耗时超过了2秒。
- 2秒到了,计时事件timeout完成,console进入Event Queue,但是sleep还没执行完,主线程还被占用,只能等着。
- sleep终于执行完了,console终于从Event Queue进入了主线程执行,这个时候已经远远超过了2秒。
其实延迟2秒只是表示2秒后,setTimeout里的函数被会推入event queue,而event queue(事件队列)里的任务,只有在主线程空闲时才会执行。上述的流程走完,我们知道setTimeout这个函数,是经过指定时间后,把要执行的任务(本例中为console)加入到Event Queue中,又因为是单线程任务要一个一个执行,如果前面的任务需要的时间太久,那么只能等着,导致真正的延迟时间远远大于2秒。 我们还经常遇到setTimeout(fn,0)这样的代码,它的含义是,指定某个任务在主线程最早的空闲时间执行,意思就是不用再等多少秒了,只要主线程执行栈内的同步任务全部执行完成,栈为空就马上执行。但是即便主线程为空,0毫秒实际上也是达不到的。根据HTML的标准,最低是4毫秒。
关于setInterval: 以setInterval(fn,ms)为例,setInterval是循环执行的,setInterval会每隔指定的时间将注册的函数置入Event Queue,不是每过ms秒会执行一次fn,而是每过ms秒,会有fn进入Event Queue。需要注意的一点是,一旦setInterval的回调函数fn执行时间超过了延迟时间ms,那么就完全看不出来有时间间隔了。
上面的概念很基础也很容易理解但不幸的消息是上面讲的一切都不是绝对的正确,因为涉及到Promise、async/await、process.nextTick(node)所以要对任务有更精细的定义:
我们把宿主发起的任务称为宏观任务,把 JavaScript 引擎发起的任务称为微观任务。
宏任务(macro-task):包括整体代码script、setTimeout、setInterval、MessageChannel、postMessage、setImmediate。
微任务(micro-task):Promise、process.nextTick、MutationObsever。
在划分宏任务、微任务的时候并没有提到async/await因为async/await的本质就是Promise。
事件循环机制到底是怎么样的? 不同类型的任务会进入对应的Event Queue,比如setTimeout和setInterval会进入相同(宏任务)的Event Queue。而Promise和process.nextTick会进入相同(微任务)的Event Queue。
- 「宏任务」、「微任务」都是队列,一段代码执行时,会先执行宏任务中的同步代码。
- 进行第一轮事件循环的时候会把全部的js脚本当成一个宏任务来运行。
- 如果执行中遇到setTimeout之类宏任务,那么就把这个setTimeout内部的函数推入「宏任务的队列」中,下一轮宏任务执行时调用。
- 如果执行中遇到 promise.then() 之类的微任务,就会推入到「当前宏任务的微任务队列」(也就是说这些微任务仍属于当前宏任务)中,在本轮宏任务的同步代码都执行完成后,依次执行所有的微任务。
- 第一轮事件循环中当执行完全部的同步脚本以及微任务队列中的事件,这一轮事件循环就结束了,开始第二轮事件循环。
- 第二轮事件循环同理先执行宏任务中的同步脚本,遇到其他宏任务代码块继续追加到「宏任务的队列」中,遇到微任务,就会推入到「当前宏任务的微任务队列」中,在本轮宏任务的同步代码执行都完成后,依次执行当前所有的微任务。
- 开始第三轮,循环往复…
下面用代码来深入理解上面的机制:
1 | setTimeout(function() { |
- 这段代码作为宏任务,进入主线程。
- 先遇到setTimeout,那么将其回调函数注册后分发到宏任务Event Queue。
- 接下来遇到了Promise,new Promise立即执行,then函数分发到微任务Event Queue。
- 遇到console.log(),立即执行。
- 整体代码script作为第一个宏任务执行结束。查看当前有没有可执行的微任务,执行then的回调。 (第一轮事件循环结束了,我们开始第二轮循环。)
- 从宏任务Event Queue开始。我们发现了宏任务Event Queue中setTimeout对应的回调函数,立即执行。 执行结果:`1 - 2 - 3 - 4
1 | new Promise(function (resolve) { |
- 全部的代码作为第一个宏任务进入主线程执行。
- 首先输出1,是同步代码。then回调作为微任务进入到宏任务一的微任务队列。
- 下面最外层的三个setTimeout分别是宏任务二、宏任务三、宏任务四按序排入宏任务队列。
- 输出2,现在宏任务一的同步代码都执行完成了接下来执行宏任务一的微任务输出3。 第一轮事件循环完成了
- 现在执行宏任务二输出4,后面的setTimeout作为宏任务五排入宏任务队列。
- 执行宏任务三输出5,执行宏任务四输出6,宏任务四里面的setTimeout作为宏任务六。
- 执行宏任务五输出7,8。then回调作为宏任务五的微任务排入宏任务五的微任务队列。
- 输出同步代码9,宏任务五的同步代码执行完了,现在执行宏任务五的微任务。
- 输出10,后面的setTimeout作为宏任务七排入宏任务的队列。 宏任务五执行完成了。
- 执行宏任务六输出11,执行宏任务七输出12。
*-^-*,这个案例是有点恶心,目的是让大家明白各宏任务之间执行的顺序以及宏任务和微任务的执行关系。
我们把主线程(执行队列)中的内容从开始执行到结束叫做一次tick,一旦主线程内容执行完成,就回去宏任务队列头部拿一个加入主线程开始一次新的tick,如果在找个tick期间有其他方法注册了新的微任务或者宏任务本身注册了微任务,则回在本次tick结束之前拿出所有的微任务执行完成。
- 在当前tick中添加的微任务都不会留到下一个tick,而是在tick的尾部触发执行
- 一个事件循环中,在tick里的任务执行完毕以后,会有一个单独的步骤,叫 Perform a microtask checkpoint,即执行微任务检查点。这个操作是检查否有微任务,如果有,便将微任务队列也会当作执行队列来继续执行,完毕后将执行队列置空。
- 同一个tick产生的微任务总是会在宏任务之前被执行,因为本次tick结束之前注册的微任务会在微任务检查点执行,但是宏任务要等到下一次tick。
(注意,这里讨论的所有的宏任务和微任务已经是进入event loop中的任务,也就是异步条件达成后从event table进入event queue的状态)。
1 | new Promise(function (resolve) { |
如果进一步在timeout中加入延迟时间,不让他们的回调函数立即进入宏任务队列中,打印顺序又是什么呢?
先给出答案:1,2,3,6,5,4,7,8,9,10,11,12(或者1,2,3,6,5,4,11, 7,8,9,10,12)
其实完全按照刚才我们讲的逻辑,顺序应该是1,2,3,6,5,4,11, 7,8,9,10,12
为什么会出现两种不同的可能呢?
是因为Chrome中根据 mdn
setTimeout()
/setInterval()
的每调用一次定时器的最小间隔是4ms,所以被合入了一个宏任务一起执行。宏任务五和六都是嵌套的两个setTimeOut加起来2000ms,先后问题,也有可能被合并了。
这个例子和上面那个例子的不同之处就在于通过设置了不同的timeout时间,使得回调函数按照不同的顺序被推入宏任务队列。
问题答案
好,到目前为止,已经可以回答上面的问题了
question 1
为何
timeout1
和timeout2
的结果会在end后面?
answer 1
因为此时
timeout1
和timeout2
只是被异步函数推入到了队列中,事件循环还是暂停状态
question 2
为何
timeout1
和timeout2
没有输出到终端?while(true)
到底阻塞了什么?
answer 2
因为此处直接阻塞了事件循环,还没开始,就已经被阻塞了
question 3,4
- 为什么
timeout1
中回调函数会阻塞timeout2
中的回调函数的执行?- 为何
timeout1
的计算密集型工作将会阻塞timeout2
的回调函数的执行?
answer 3,4
因为该回调函数执行返回事件循环才会继续执行,回调函数将会阻塞事件循环的运行
question 5
为何读取文件的IO操作不会阻塞
timeout2
的执行?
answer 5
因为
IO
操作是异步的,会开启一个新的线程,不会阻塞到事件循环
参考链接:
https://www.zhihu.com/question/19732473