JavaScript单线程事件循环(Event Loop)那些事

韩忠康

1.概述

本篇主要介绍JavaScript的运行机制:单线程事件循环(Event Loop).

结论先: 在JavaScript中, 利用运行至完成和非阻塞IO 完成单线程下异步任务的处理. 就是先处理主模块(主线程)上的同步任务, 再处理异步任务. 异步任务使用事件循环机制完成调度.

涉及的内容有: 单线程, 事件循环, 同步执行, 异步执行, 定时器, nodeJS的事件循环

开始之前, 先看下面的代码, 给出结果:

// 当前时间
console.log('A: ' + new Date());

// 1秒(1000毫秒)后执行的定时器
// 异步执行的代码
setTimeout(function() {
    console.log('B: ' + new Date());
}, 1000);

// 循环3秒(3000毫秒)
var end = Date.now() + 3000;
while(Date.now() < end) {
}

// 当前时间
console.log('C: ' + new Date());

在浏览器中的结果为(chrome-50.0.2661.102):

A: Thu May 25 2017 13:48:26 GMT+0800 (中国标准时间)
C: Thu May 25 2017 13:48:29 GMT+0800 (中国标准时间) 
B: Thu May 25 2017 13:48:29 GMT+0800 (中国标准时间)

在NodeJS(v7.7.2 win-x64)中的结果为:

>node scripts\async.js
A: Thu May 25 2017 13:50:55 GMT+0800 (中国标准时间)
C: Thu May 25 2017 13:50:58 GMT+0800 (中国标准时间)
B: Thu May 25 2017 13:50:58 GMT+0800 (中国标准时间)

tip: 浏览器下和NodeJS结果一致.

分析上面的代码与结果, 注意的要点:

  • 虽然设置的定时器为1秒后执行, 但实际的执行时间在3秒以后, 看结果中B:的输出, 在A:的3秒后.

  • B:的输出在C:的输出之后. 可见, 虽然在while循环后, 时间已经到了定时器代码需要执行的时间, 但并没有立即执行, 而是等到了console.log('C: ')执行完, 再执行的定时器的代码.

本篇就是说明为什么会出现以上的现象. 下面请一步步的看.

2.单线程

单线程, 指的是JavaScript在一个时间仅处理一个任务. 就是JavaScript在执行时, 存在一个执行队列, 依次执行队列中的任务, 不能同时执行多个任务.
单线程的优势, 也是JavaScript选择单线程的原因是:
1, 降低处理复杂性, 简化开发. 例如不用考虑死锁, 竞争机制等.
2, 作为用于处理与用户互动的脚本语言, 可以更加容易地处理状态同步的问题(想想考虑用户操作的不确定性).
3, JavaScript核心维护人员自身的设计与理解.
4, 越简单越容易推广, 快速上手.

除了优势, 单线程有明显的劣势, 就是并发处理能力, 因为单线程处理下所有的任务就要排队处理. 但是如果排在前面的任务处理很耗时, 那就导致后面的任务一直处于等待状态. 如果前面的任务出处于满载运行状态还可以, 但是如果前面的任务处于IO等待状态呢? 就会导致CPU处理资源的浪费.
思考, 前面的是AJAX任务, 后边是其他任务. AJAX任务需要等待网络请求响应结束, 才能处理, 此时前面的AJAX任务就处于IO等待状态. 从而导致后面的任务也执行不了, 造成了单线程下的资源浪费. (CPU没有办法高速运转, 处于空闲状态).

在此情况下, 完全可以挂起前面的AJAX任务(挂起等待AJAX的响应结果), 先执行后面的任务. 等后面的任务处理完毕后, 再看前面的AJAX任务是否得到了IO结果, 如果有结果了, 在翻回来处理即可. 这种处理方式, 就是异步方式.

3.同步任务和异步任务

单线程的JavaScript为了更好利用CPU的性能, 将执行的任务设计为: 同步任务和异步任务, 两类.

  • 同步任务(synchronous task), 就是需要一个个顺序执行的任务, 不能跳过, 执行完前一个才能执行后一个. 我们称之为在主模块(主线程)执行的任务.

  • 异步任务(asynchronous task), 指的是被挂起执行的任务, 在系统内部处于等待IO处理结果状态, 一旦处理完毕, 记录下来, 等待后续处理. 需要事件循环处理的任务. 上面示例中的AJAX任务就是异步任务.

你应该会想, JavaScript不是单线程么, 怎么还能异步处理呢?
是这样的, JavaScript的单线程, 指的是在JavaScript语言(语法)层面是单线程的. 而内部的执行, 还是可以利用到处理器多线程和操作系统的任务调度的, 在后台处理我们的异步任务. 当操作在后台被处理完成后(例如ajax接收完毕了服务器的响应), 操作系统将结果告知给JavaScript, 并最终被JavaScript执行.

JavaScript是如何调度这些同步任务和异步任务的呢?
就涉及到了, 本文的重点: 任务队列 和 事件循环, 执行栈.

4.事件循环模型

如图(逻辑概述图)所示:
图片描述
执行如下:

  • step1, 同步任务直接放入到主模块(主线程)任务队列执行. 异步任务挂起后台执行, 等待IO事件完成或行为事件被触发.

  • step2, 系统后台执行异步任务, 如果某个异步任务事件发生(或者是行为事件被触发), 则将该任务push到任务队列中, 每个任务会对应一个回调函数进行处理. 这个步骤在后台一直执行, 因为就不断有事件被触发, IO不断完成, 任务被不断的加入到任务队列中.

  • step3, 执行任务队列中的任务. 任务的具体执行是在执行栈中完成的. 当运行栈中一个任务的基本运行单元(称之为Frame, 桢)全部执行完毕后, 去读取任务队列中的下一个任务, 继续执行. 是一个循环的过程. 处理一个任务队列中的任务, 称之为一个tick.

注意, step3, 是一个循环的过程, 这就是事件循环. 循环执行任务队列中已经发生的事件对应的任务.

再参考开始的代码, 我们可以知道:

// A:当前时间 
// 同步代码, 直接进入任务队列
console.log('A: ' + new Date());

// B:1秒(1000毫秒)后执行的定时器
// 异步代码, 等待到时事件发生, 才会进入任务队列
setTimeout(function() {
    console.log('B: ' + new Date());
}, 1000);

// 循环3秒(3000毫秒), 
// 同步代码, 直接进入任务队列
var end = Date.now() + 3000;
// 同步代码, 直接进入任务队列
while(Date.now() < end) {
}

// C:当前时间
// 同步代码, 直接进入任务队列
console.log('C: ' + new Date());

也就意味着, 此时, log(A), while, log(C) 三个任务, 已经进入到了任务队列中. 而setTimeout是异步任务(与AJAX一致)在等待事件发生(到时事件). 于此同时, JavaScript开始处理任务队列. 队列是先进先出, 需要依次处理. 所以, 即时当前已经到1s了, 事件发生, 也仅仅是将该任务push入任务队列而已(并没有立即执行回调函数). 当将setTimeout入队列时, log(C)已经在队列中了, 因此, setTimeout的log(B), 会在log(B)后执行. 这就是输出了: A, C, B的原因. 如下图(逻辑概述图)所示:
图片描述
由任务队列可知, 输出为: A, C, B顺序.

5.定时器函数

JavaScript提供了可以操作定时器的函数, setTimeout()和setInterval. 在NodeJS中还有setImmediate().

setTimeout(), 定时执行

setTimeout(callback, timer), 多久(毫秒计)后执行, 常规用法已经演示.
需要提醒大家的是, setTimetout()是延时触发, 而不是即时触发. 指的是, 在有机会处理计时器事件时, 优先处理最先到时的计时器程序. 而不是时间到立即处理. 因为是单线程, 需要先处理当前的任务, 例如主模块中的任务(同步任务).

实操中还有一个setTimeout(callback, 0)的用法, 表示立即加入到任务队列. 但是注意, 并不是在执行setTimeout的时候, 就加入队列了, 而是当全部的同步任务入队列后, 立即加入到任务队列, 也就意味着同步任务之后第一个执行. 但据说这个值内部执行时有一个最小值, 4ms.
上面的代码, 将时间改为0, 测试结果还是A, C, B. 不会因为先执行的setTimeout()而就将任务先执行.

// 异步代码, 等待到时事件发生, 才会进入任务队列
setTimeout(function() {
    console.log('B: ' + new Date());
}, 0);

未发生的定时器, 可以使用clearTimeout()方法清除.

setInterval(), 循环执行.

setInterval(callback, timer) 与setTimeout()相似, 不过是在callback执行完毕后, 再次设置了计时器. 不再赘述.

6.NodeJS中的事件循环

NodeJS的事件循环模型比浏览器更为复杂些.
如下图所示(引用自NodeJS官方文档), 事件循环, 按照下图的顺序调用事件.
图片描述
由于出现了不同的事件循环段, 例如 timer, check, 出现了额外的控制定时器方法.

setImmediate(), 立即执行

逻辑含义上讲, 与setTimeout(callback, 0)一致. 都是立即执行. 在NodeJS中setImmediate()存在的主要场景就是, 在异步IO调用中, 如果同时使用setImmediate()和settimeout(), 可以保证, setImmediate()先于所有的setTimeout()执行.
如下代码: (引用自NodeJS官方文档)

var fs = require('fs');
// 异步文件IO
fs.readFile(__filename, () => {

    setTimeout(function timeout () {
      console.log('timeout');
    },0);

    setImmediate(function immediate () {
      console.log('immediate');
    });
});

以上代码的执行结果, 一定是:

>node scripts\async-node.js
immediate
timeout
这是因为, 根据NodeJS的事件循环处理顺序, 处理完IO后, 需要处理check, 而setImmediate()就是check中的事件. 因此先处理.

但上面的代码如果没有在异步IO中调用, 在主模块(主线程)中调用, 则顺序不一定, 由操作系统调度决定!

process.nextTick(callback)

tick, 就是一个事件循环周期. 在prcess.nextTick()中设置的异步callback会在当前事件循环周期结束, 下一个事件循环周期开始前执行.
像是一个插入的tick. 生成了一个新的周期. 说白了, 是一个插队行为.
因此, 在时间上看, 一定先于settimeout(callback, 0)和setImmediate()执行. 通常用来处理在下一个事件周期(异步任务)前, 必须要处理好的任务. 常见的有, 处理错误, 回收资源, 和 重新执行存在错误的操作等.

测试一下执行时机:

setTimeout(function timeout () {
  console.log('timeout');
},0);

setImmediate(function immediate () {
  console.log('immediate');
});

process.nextTick(function immediate () {
  console.log('nickTick');
});

结果为:

>node scripts\async-node.js
nextTick
timeout
immediate

可见, nextTick先发生.

注意, 在NodeJS中, nexttick并不是一个特殊的定时器.
注意, 由于nextTick()会插队执行, 因此, NodeJS限制了nextTick()递归调用的深度. 防止IO处理饥饿.一直在处理nextTick(). 由于该原因, 递归时, NodeJS建议使用setImmediate()完成.

对比process.nextTick, setImmediate, setTimeout

process.nextTick, 永远先执行.
setImmediate和setTimeout, 那个先到时那个先执行. 如果同时, 则由系统调度负责.

7.总结

在JavaScript中, 利用运行至完成和非阻塞IO 完成单线程下异步任务的处理. 就是先处理主模块(主线程)上的同步任务, 再处理异步任务. 异步任务使用事件循环机制完成调度.

参考:

NodeJS文档, https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick/
JavaScript 运行机制详解:再谈Event Loop, http://www.ruanyifeng.com/blog/2014/10/event-loop.html
朴灵 深入浅出Node.js http://www.infoq.com/cn/master-nodejs

8.结语

以上就是本人对事件循环的理解. 一家之言, 欢迎讨论拍砖!
更多内容, 可以关注, 微信公众号, 小韩说理.
图片描述

阅读 2.8k

小韩说理
Web技术分享, PHP, 前端, Python, MySQL
10 声望
1 粉丝
0 条评论
10 声望
1 粉丝
文章目录
宣传栏