JavaScript运行机制
一、JavaScript为什么是单线程?
JavaScript语言的一大特点就是单线程,这意味着JS代码在执行的时候,都只有一个主线程来处理所有的任务。这与它最初设计时的主要用途有关。
作为浏览器脚本语言,JavaScript的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。假如JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?
所以,为了避免复杂性,从一诞生,JavaScript就是单线程,这已经成了这门语言的核心特征,将来也不会改变。
为了利用多核CPU的计算能力,HTML5提出Web Worker标准,允许JavaScript创建多个线程,但是子线程完全受主线程控制,且不得操作DOM。所以,这个新标准并没有改变JavaScript单线程的本质。
二、JavaScript运行时概念
各个JavaScript引擎实现的运行时模型,主要有三部分组成:栈、堆和队列。下图来自MDN:
(一)堆
我们知道JavaScript内存空间分为栈内存和堆内存。占用空间较小的6中基本类型数据(Sting、Number、Boolean、null、undefined、Symbol)存储在栈内存中。其余的引用类型数据(Object、Array、Function)的实体存储在堆内存中,但它们的引用指针是存储在栈内存中的。这部分详细内容可参考这篇文章。本文要讲的栈是函数调用栈,与栈内存还不是完全一致。相同点:都是栈这种先进后出、后进先出特点的数据结构。
(二)栈
函数调用形成了一个由若干帧组成的栈,有时我们也会叫调用栈、函数调用栈、执行栈、执行上下文栈(Execution Context Stack,ECS)。
当一个函数被调用时会形成执行上下文环境,其中存放着这个函数的变量对象(Variable Object, VO),作用域链(Scope Chain),调用该函数的this对象,函数实参。执行上下文作为一个调用帧被压入函数调用栈。
function fun1(x) { const t = 9; return fun2(x * t ); } function fun2(y) { return y + 11; } function fun3() { return 100; } fun1(7) fun3()
示例中先调用函数fun1,将其压入执行栈内,fun1内又调用了函数fun2,再把fun2压入执行栈顶。fun2执行完后被弹出执行栈,然后执行fun1,fun1执行完之后也会被弹出栈。此时调用函数fun3,再把fun3压入执行栈,fun3执行完后弹出执行栈。
JavaScript的同步任务才会进入主线程的执行栈中。
(三)队列
除了同步任务之外,JavaScript的异步任务不进入主线程,而是进入任务队列(task queue)或称消息队列(message queue),比XHR网络请求、文件读写、键盘输入、鼠标点击。任务队列又分为 macro-task(宏任务)与 micro-task(微任务),在最新标准中,它们被分别称为 task 与 job 。异步任务执行完成后有了结果,就会在任务队列之中放置一个事件,该事件会告知主线程,异步任务执行状态已完成,可以将其回调函数放入主线程的执行栈中去执行了。
主线程和事件循环只有一个,但任务队列会有多个。微任务队列优先执行,宏任务队列后执行。任务队列是一个先进先出的数据结构,排在前面的事件,优先被主线程读取执行。
(四)Event Loop 事件循环
JavaScript异步执行的运行机制如下:
(1)所有同步任务都在主线程上执行,形成一个执行栈。
(2)主线程之外,还存在多个任务队列。只要异步任务运行状态完成,就在任务队列之中放置一个事件。
(3)一旦执行栈中的所有同步任务执行完毕,主线程就会读取任务队列,看看里面有哪些事件,这些事件对应的异步任务回调函数,会进入执行栈依次执行。
(4)主线程不断重复第三步,这个过程循环不断,所以整个机制又叫做 Event Loop 事件循环。
下图展示了事件循环机制
栈中的代码调用各种外部API,它们执行后往任务队列塞入各种事件,栈中的代码执行完后循环不断地去获取任务队列中的事件,将各种回调函数依次取回主线程执行栈去执行。
三、宏任务和微任务
宏任务方法是由JS宿主环境提供的,微任务方法是由JS引擎自身提供的。
宏任务有:setTimeout 早于 setImmediate(Node.js)、setInterval、postMessage、Web Worker中的MessageChannel、I/O、UI rendering
微任务有:process.nextTick(Node.js) 早于 Promise.then[.cache .finally]、MutaionObserver
微任务队列优先执行,宏任务队列后执行。
题目1
console.log("start"); setTimeout(() => { console.log(2); Promise.resolve().then(() => { console.log(3); }); }, 0); new Promise(function (resolve, reject) { console.log(4); setTimeout(function () { console.log(5); resolve(6); }, 0); }).then((res) => { console.log(7); setTimeout(() => { console.log(res); }, 0); }); // 依次输出:start、4、2、3、5、7、6
题目2
setImmediate(() => { console.log(1); }); setTimeout(() => { console.log(2); }, 0); new Promise(((resolve) => { console.log(3); resolve(); console.log(4); })).then(() => { console.log(5); }); process.nextTick(() => { console.log(7); }); console.log(6); console.log(8); // Node环境中输出:3 4 6 8 7 5 2 1
上面示例中,process.nextTick 也会放入 microtask quque,为什么优先级比 promise.then 高呢?
原因在于,Node 中的 _tickCallback 在每次执行完任务队列中的一个任务后被调用,调用 _tickCallback 实质上干了两件事:
1、nextTickQueue中所有任务执行掉;
2、然后执行 _runMicrotasks 函数,即执行微任务队列(promise.then 注册的回调位于此)。
所以很明显 process.nextTick 永远优先于 promise.then。
参考: