浅析 event-loop 事件轮询


原文出自:https://www.pandashen.com


浏览器中的事件轮询

JavaScript 是一门单线程语言,之所以说是单线程,是因为在浏览器中,如果是多线程,并且两个线程同时操作了同一个 Dom 元素,那最后的结果会出现问题。所以,JavaScript 是单线程的,但是如果完全由上至下的一行一行执行代码,假如一个代码块执行了很长的时间,后面必须要等待当前执行完毕,这样的效率是非常低的,所以有了异步的概念,确切的说,JavaScript 的主线程是单线程的,但是也有其他的线程去帮我们实现异步操作,比如定时器线程、事件线程、Ajax 线程。

在浏览器中执行 JavaScript 有两个区域,一个是我们平时所说的同步代码执行,是在栈中执行,原则是先进后出,而在执行异步代码的时候分为两个队列,macro-task(宏任务)和 micro-task(微任务),遵循先进先出的原则。

// 作用域链
function one() {
    console.log(1);
    function two() {
        console.log(2);
        function three() {
            console.log(3);
        }
        three();
    }
    two();
}
one();

// 1
// 2
// 3

上面的代码都是同步的代码,在执行的时候先将全局作用域放入栈中,执行全局作用域中的代码,解析了函数 one,当执行函数调用 one() 的时候将 one 的作用域放入栈中,执行 one 中的代码,打印了 1,解析了 two,执行 two(),将 two 放入栈中,执行 two,打印了 2,解析了 three,执行了 three(),将 three 放入栈中,执行 three,打印了 3

在函数执行完释放的过程中,因为全局作用域中有 one 正在执行,one 中有 two 正在执行,two 中有 three 正在执行,所以释放内存时必须由内层向外层释放,three 执行后释放,此时 three 不再占用 two 的执行环境,将 two 释放,two 不再占用 one 的执行环境,将 one 释放,one 不再占用全局作用域的执行环境,最后释放全局作用域,这就是在栈中执行同步代码时的先进后出原则,更像是一个杯子,先放进去的在最下面,需要最后取出。

而异步队列更像时一个管道,有两个口,从入口进,从出口出,所以是先进先出,在宏任务队列中代表的有 setTimeoutsetIntervalsetImmediateMessageChannel,微任务的代表为 Promise 的 then 方法、MutationObserve(已废弃)。

案例 1

let messageChannel = new MessageChannel();
let prot2 = messageChannel.port2;

messageChannel.port1.postMessage("I love you");
console.log(1);

prot2.onmessage = function(e) {
    console.log(e.data);
};
console.log(2);

// 1
// 2
// I love you

从上面案例中可以看出,MessageChannel 是宏任务,晚于同步代码执行。

案例 2

setTimeout(() => console.log(1), 2000);
setTimeout(() => console.log(2), 1000);
console.log(3);

// 3
// 2
// 1

上面代码可以看出其实 setTimeout 并不是在同步代码执行的时候就放入了异步队列,而是等待时间到达时才会放入异步队列,所以才会有了上面的结果。

案例 3

setImmediate(function() {
    console.log("setImmediate");
});

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

console.log(1);

// 1
// setTimeout
// setImmediate

同为宏任务,setImmediatesetTimeout 延迟时间为 0 时是晚于 setTimeout 被放入异步队列的,这里需要注意的是 setImmediate 在浏览器端,到目前为止只有 IE 实现了。

上面的案例都是关于宏任务,下面我们举一个有微任务的案例来看一看微任务和宏任务的执行机制,在浏览器端微任务的代表其实就是 Promise 的 then 方法。

案例 4

setTimeout(() => {
    console.log("setTimeout1");
    Promise.resolve().then(data => {
        console.log("Promise1");
    });
}, 0);

Promise.resolve().then(data => {
    console.log("Promise2");
    setTimeout(() => {
        console.log("setTimeout2");
    }, 0);
});

// Promise2
// setTimeout1
// Promise1
// setTimeout2

从上面的执行结果其实可以看出,同步代码在栈中执行完毕后会先去执行微任务队列,将微任务队列执行完毕后,会去执行宏任务队列,宏任务队列执行一个宏任务以后,会去看看有没有产生新的微任务,如果有则清空微任务队列后再执行下一个宏任务,依次轮询,直到清空整个异步队列。


Node 中的事件轮询

在 Node 中的事件轮询机制与浏览器相似又不同,相似的是,同样先在栈中执行同步代码,同样是先进后出,不同的是 Node 有自己的多个处理不同问题的阶段和对应的队列,也有自己内部实现的微任务 process.nextTick,Node 的整个事件轮询机制是 Libuv 库实现的。

Node 中事件轮询的流程如下图:



从图中可以看出,在 Node 中有多个队列,分别执行不同的操作,而每次在队列切换的时候都去执行一次微任务队列,反复的轮询。

案例 1

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

setImmediate(function() {
    console.log("setInmediate");
});

默认情况下 setTimeoutsetImmediate 是不知道哪一个先执行的,顺序不固定,Node 执行的时候有准备的时间,setTimeout 延迟时间设置为 0 其实是大概 4ms,假设 Node 准备时间在 4ms 之内,开始执行轮询,定时器没到时间,所以轮询到下一队列,此时要等再次循环到 timer 队列后执行定时器,所以会先执行 check 队列的 setImmediate

如果 Node 执行的准备时间大于了 4ms,因为执行同步代码后,定时器的回调已经被放入 timer 队列,所以会先执行 timer 队列。

案例 2

setTimeout(() => {
    console.log("setTimeout1");
    Promise.resolve().then(() => {
        console.log("Promise1");
    });
}, 0);

setTimeout(() => {
    console.log("setTimeout2");
}, 0);
console.log(1);

// 1
// setTimeout1
// setTimeout2
// Promise1

Node 事件轮询中,轮询到每一个队列时,都会将当前队列任务清空后,在切换下一队列之前清空一次微任务队列,这是与浏览器端不一样的。

浏览器端会在宏任务队列当中执行一个任务后插入执行微任务队列,清空微任务队列后,再回到宏任务队列执行下一个宏任务。

上面案例在 Node 事件轮询中,会将 timer 队列清空后,在轮询下一个队列之前执行微任务队列。

案例 3

setTimeout(() => {
    console.log("setTimeout1");
}, 0);

setTimeout(() => {
    console.log("setTimeout2");
}, 0);

Promise.resolve().then(() => {
    console.log("Promise1");
});
console.log(1);

// 1
// Promise1
// setTimeout1
// setTimeout2

上面代码的执行过程是,先执行栈,栈执行时打印 1Promise.resolve() 产生微任务,栈执行完毕,从栈切换到 timer 队列之前,执行微任务队列,再去执行 timer 队列。

案例 4

setImmediate(() => {
    console.log("setImmediate1");
    setTimeout(() => {
        console.log("setTimeout1");
    }, 0);
});

setTimeout(() => {
    console.log("setTimeout2");
    setImmediate(() => {
        console.log("setImmediate2");
    });
}, 0);

//结果1
// setImmediate1
// setTimeout2
// setTimeout1
// setImmediate2

// 结果2
// setTimeout2
// setImmediate1
// setImmediate2
// setTimeout1

setImmediatesetTimeout 执行顺序不固定,假设 check 队列先执行,会执行 setImmediate 打印 setImmediate1,将遇到的定时器放入 timer 队列,轮询到 timer 队列,因为在栈中执行同步代码已经在 timer 队列放入了一个定时器,所以按先后顺序执行两个 setTimeout,执行第一个定时器打印 setTimeout2,将遇到的 setImmediate 放入 check 队列,执行第二个定时器打印 setTimeout1,再次轮询到 check 队列执行新加入的 setImmediate,打印 setImmediate2,产生结果 1

假设 timer 队列先执行,会执行 setTimeout 打印 setTimeout2,将遇到的 setImmediate 放入 check 队列,轮询到 check 队列,因为在栈中执行同步代码已经在 check 队列放入了一个 setImmediate,所以按先后顺序执行两个 setImmediate,执行第一个 setImmediate 打印 setImmediate1,将遇到的 setTimeout 放入 timer 队列,执行第二个 setImmediate 打印 setImmediate2,再次轮询到 timer 队列执行新加入的 setTimeout,打印 setTimeout1,产生结果 2

案例 5

setImmediate(() => {
    console.log("setImmediate1");
    setTimeout(() => {
        console.log("setTimeout1");
    }, 0);
});

setTimeout(() => {
    process.nextTick(() => console.log("nextTick"));
    console.log("setTimeout2");
    setImmediate(() => {
        console.log("setImmediate2");
    });
}, 0);

//结果1
// setImmediate1
// setTimeout2
// setTimeout1
// nextTick
// setImmediate2

// 结果2
// setTimeout2
// nextTick
// setImmediate1
// setImmediate2
// setTimeout1

这与上面一个案例类似,不同的是在 setTimeout 执行的时候产生了一个微任务 nextTick,我们只要知道,在 Node 事件轮询中,在切换队列时要先去执行微任务队列,无论是 check 队列先执行,还是 timer 队列先执行,都会很容易分析出上面的两个结果。

案例 6

const fs = require("fs");

fs.readFile("./.gitignore", "utf8", function() {
    setTimeout(() => {
        console.log("timeout");
    }, 0);
    setImmediate(function() {
        console.log("setImmediate");
    });
});

// setImmediate
// timeout

上面案例的 setTimeoutsetImmediate 的执行顺序是固定的,前面都是不固定的,这是为什么?

因为前面的不固定是在栈中执行同步代码时就遇到了 setTimeoutsetImmediate,因为无法判断 Node 的准备时间,不确定准备结束定时器是否到时并加入 timer 队列。

而上面代码明显可以看出 Node 准备结束后会直接执行 poll 队列进行文件的读取,在回调中将 setTimeoutsetImmediate 分别加入 timer 队列和 check 队列,Node 队列的轮询是有顺序的,在 poll 队列后应该先切换到 check 队列,然后再重新轮询到 timer 队列,所以得到上面的结果。

案例 7

Promise.resolve().then(() => console.log("Promise"));
process.nextTick(() => console.log("nextTick"));

// nextTick
// Promise

在 Node 中有两个微任务,Promisethen 方法和 process.nextTick,从上面案例的结果我们可以看出,在微任务队列中 process.nextTick 是优先执行的。

上面内容就是浏览器与 Node 在事件轮询的规则,相信在读完以后应该已经彻底弄清了浏览器的事件轮询机制和 Node 的事件轮询机制,并深刻的体会到了他们之间的相同和不同。


©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,214评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,307评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,543评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,221评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,224评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,007评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,313评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,956评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,441评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,925评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,018评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,685评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,234评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,240评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,464评论 1 261
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,467评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,762评论 2 345

推荐阅读更多精彩内容