用于和浏览器交互,JavaScript 诞生时起就是单线程非阻塞的脚本语言。
单线程意味着,JavaScript 在执行代码的任何时候,都只有一个主线程来处理所有的任务。非阻塞则是当代码需要进行一项异步任务时,主线程会挂起这个任务,然后在异步任务返回结果时再根据一定规则去执行相应回调。
单线程是必要的,缘于其最初的宿主环境——浏览器中,要进行各种 DOM 操作。如果多线程,可能会导致 DOM 操作困难和结果不一致。JavaScript 选择只用一个主线程来执行代码,保证了程序执行的一致性。
但是,单线程在保证了执行顺序的同时限制了 JavaScript 的效率,因此开发出了 Web Worker 技术。不过,Web Worker 的使用有很多限制,如:新线程受主线程完全控制,不能独立执行,即这些“线程”实际上是主线程的子线程;子线程没有 I/O 操作权限,只能为主线程分担一些诸如计算等任务。所以,严格讲这些线程并没有完整的功能,故无法改变 JavaScript 语言单线程的本质。
那 JavaScript 引擎是怎么实现“非阻塞”呢?事件循环!
JavaScript 代码执行时会将不同的变量存在内存中不同位置:
在调用方法时,JavaScript 引擎会生成一个对应的执行环境(context,执行上下文),其中包含:
当一系列的方法被调用的时候,因为 JavaScript 是单线程的,同一时刻只能执行一个方法,所以这些方法被排队在一个单独的地方——调用栈。
当一段代码第一次执行,JavaScript 引擎会解析代码,并将其中的同步代码按照执行顺序加入执行栈,然后从头开始执行。如果当前执行的是个方法,那 JavaScript 引擎会像执行栈添加这个方法的执行上下文,然后进入该执行上下文继续执行其中的代码。当该执行上下文中的代码执行完毕返回结果后,JavaScript 会退出这个执行环境并将该执行环境销毁,再回到上一个方法的执行环境,知道栈中所有代码执行结束。
调用栈中的执行环境可以不断添加,知道发生栈溢出,即超过所能利用的最大内存。
以上都是同步代码,当异步代码执行时,会使用非阻塞特点的实现机制——事件队列。
JavaScript 引擎遇到异步事件后并不会一直等待其返回结果,而是将这个事件挂起,继续执行执行栈中的其他任务。当一个异步事件返回结果后,JavaScript 会将这个事件加入与当前执行栈不同的一个队列——事件队列。被放入事件队列后不会立即执行器回调,而是等待当前执行栈中所有任务都执行完毕,主线程属于闲置状态时,主线程回去查找事件队列中是否有任务。如果有,就会取出排在第一位的事件,并将对应的回调放入执行栈,然后执行同步代码,如此反复,形成一个无限的循环——事件循环(Event Loop)。
SetInterval()
SetTimeout()
new Promise()
new MutationObserver()
在事件循环中,异步事件返回结果会被放到一个任务队列中,根据异步事件的类型,事件会被放到对应的宏任务队列或微任务队列中。在当前执行栈为空的时候,主线程会查看微任务队列是否有事件存在,如果不存在,再去宏任务队列取出一个事件把对应回调加入到当前执行栈;如果存在,这会一次执行队列中事件对应的回调,直到微任务队列为空,然后去宏任务队列...如此反复。
当当前执行栈执行完毕会立刻去处理所有微任务队列中的事件,然后再去宏观任务队列中处理一个事件。
setTimeout(function() {
console.log(1);
})
new Promise(function(resolve, reject) {
console.log(2);
resolve(3);
}).then(function(value) {
console.log(value);
});
// 2
// 3
// 1
在 Node.js 中,事件循环表现出的状态与浏览器中大致相同,不过 Node.js 有一套自己的模型。
Node.js 中事件循环依靠 libuv 引擎实现。Node.js 选择 Chrome V8 作为 JavaScript 解释器,V8 引擎将 JavaScript 代码分析后去调用对应的 Node.js API,而这些 API 最后由 libuv 引擎驱动,执行对应的任务,并把不同的事件放在不同的队列中等待主线程的执行。实际上 Node.js 中的事件循环存在于 libuv 引擎中。
libuv 引擎中的事件循环模型:
从模型中可以大致看出,Node.js 中的事件循环顺序:
外部输入数据 -> 轮询阶段(poll) -> 检查阶段(check) -> 关闭事件回调阶段(close callback) -> 定时器检测阶段(timer) -> I/O 事件回调阶段 (I/O callbacks) -> 闲置阶段(idle,prepare) -> 轮询阶段...。
setTimeout()
、setInterval()
close
事件,定时器和 setImmediate()
的回调 setImmediate()
的回调,若有就进入 check 阶段执行执行回调setImmediate()
回调会在这里执行 setImmediate queue
不为空时,会进入 check 阶段socket.on('close', function() { ... })
这种 close
事件的回调 close
事件会被发送到这个阶段执行回调,否则事件会用 process.nextTick()
方法发送出去在 Node.js 中有三个常用来推迟任务执行的方法:process.nextTick()
、setImmediate()
、setTimeout()
(setInterval()
与之相同)。
process.nextTick()
process.nextTick()
会导致 Node.js 进入死循环,直至内存泄露。setTimeout()
和 setImmediate()
setTimeout()
定义一个回调,希望回调在指定时间间隔后第一时间去执行。注意“第一时间”受到操作系统和当前执行任务的诸多影响,回调并不会在预期时间执行。setImmediate()
从命名理解是立即执行,但实际上是在一个固定的阶段才会执行,即 poll 阶段之后。setTimeout()
不设置时间间隔时和setImmediate()
表现极其相似