[js] event loop
并发模型(Concurrency model) 、运行时(runtime)
栈(stack)
函数调用形成了一个栈帧(stack of frames)。
function foo(b) {
var a = 10;
return a + b + 11;
}
function bar(x) {
var y = 3;
return foo(x * y);
}
console.log(bar(7));
当调用bar时,创建了第一个帧(frame),帧中包含了bar的参数和局部变量。当bar调用foo时,第二个帧就被创建,并被压到第一个帧之上,帧中包含了foo的参数和局部变量。当foo返回时,最上层的帧就被弹出栈(剩下bar函数的调用帧 )。当bar返回的时候,栈就空了。
堆(heap)
对象被分配在一个堆中,用以表示一个大部分非结构化的内存区域。
队列(queue)
一个 JavaScript 运行时包含了一个待处理的消息队列。每一个消息都与一个函数相关联。当栈拥有足够内存时,从队列中取出一个消息进行处理。这个处理过程包含了调用与这个消息相关联的函数(以及因而创建了一个初始堆栈帧stack frame)。当栈再次为空的时候,也就意味着消息处理结束。
- 所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。
- 主线程之外,还存在一个"任务队列"(task queue)。只要异步任务有了运行结果,就在"任务队列"之中放置一个事件。
- 一旦"执行栈"中的所有同步任务执行完毕,系统就会读取"任务队列",看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。
- 主线程不断重复上面的第三步。
"任务队列"是一个事件的队列(也可以理解成消息的队列),IO设备完成一项任务,就在"任务队列"中添加一个事件,表示相关的异步任务可以进入"执行栈"了。主线程读取"任务队列",就是读取里面有哪些事件。
"任务队列"中的事件,除了IO设备的事件以外,还包括一些用户产生的事件(比如鼠标点击、页面滚动等等)。只要指定过回调函数,这些事件发生时就会进入"任务队列",等待主线程读取。
所谓"回调函数"(callback),就是那些会被主线程挂起来的代码。异步任务必须指定回调函数,当主线程开始执行异步任务,就是执行对应的回调函数。
"任务队列"是一个先进先出的数据结构,排在前面的事件,优先被主线程读取。主线程的读取过程基本上是自动的,只要执行栈一清空,"任务队列"上第一位的事件就自动进入主线程。而"定时器"功能,主线程首先要检查一下执行时间,某些事件只有到了规定的时间,才能返回主线程。
事件循环(event loop)
主线程从"任务队列"中读取事件,这个过程是循环不断的,所以整个的这种运行机制又称为Event Loop(事件循环)。
while (queue.waitForMessage()) {
queue.processNextMessage();
}
如果当前没有消息queue.waitForMessage
会同步地(synchronously )等待消息到达。
执行至完成(Run-to-completion)
每一个消息完整的执行后,其它消息才会被执行。每当一个函数运行时,它就不能被抢占,并且在其他代码运行之前完全运行(且可以修改此函数操作的数据)。
javascript是单线程(single thread)。
这与C语言不同,例如,如果函数在线程中运行,则可以在任何位置终止然后在另一个线程中运行其他代码。
这个模型的一个缺点在于当一个消息需要太长时间才能完成,Web应用无法处理用户的交互,例如点击或滚动。浏览器用“程序需要过长时间运行”的对话框来缓解这个问题。
一个很好的做法是使消息处理缩短,如果可能,将一个消息裁剪成几个消息。
添加消息(Adding messages)
在浏览器里,当一个事件出现且有一个事件监听器被绑定时,消息会被随时添加。如果没有事件监听器,事件会丢失。所以点击一个附带点击事件处理函数的元素会添加一个消息。其它事件亦然。
如果将setTimeout()的第二个参数设为0,就表示当前代码执行完(执行栈清空)以后,立即执行(0毫秒间隔)指定的回调函数,即指定某个任务在主线程最早可得的空闲时间执行。
setTimeout()只是将事件插入了"任务队列",必须等到当前代码(执行栈)执行完,主线程才会去执行它指定的回调函数。要是当前代码耗时很长,有可能要等很久,所以并没有办法保证,回调函数一定会在setTimeout()指定的时间执行。
因此第二个参数仅仅表示最少的时间,而非确切的时间。
零延迟(Zero delays)
零延迟并不是意味着回调会立即执行。在零延迟调用 setTimeout 时,其并不是过了给定的时间间隔后就马上执行回调函数。其等待的时间基于队列里正在等待的消息数量。在下面的例子中,"this is just a message" 将会在回调 (callback) 获得处理之前输出到控制台,这是因为延迟是要求运行时 (runtime) 处理请求所需的最小时间,但不是有所保证的时间。
(function () {
console.log(1);
setTimeout(function cb() {
console.log(2);
});
console.log(3);
setTimeout(function cb1() {
console.log(4);
}, 0);
console.log(5);
})();
// 1
// 3
// 5
// 2
// 4
多个运行时互相通信
一个 web worker 或者一个跨域的iframe都有自己的栈,堆和消息队列。两个不同的运行时只能通过 postMessage方法进行通信。如果后者侦听到message事件,则此方法会向其他运行时添加消息。
永不阻塞(Never blocking)
主线程的绿色部分,还是表示运行时间,而橙色部分表示空闲时间。
每当遇到I/O的时候,主线程就让Event Loop线程去通知相应的I/O程序,然后接着往后运行,所以不存在红色的等待时间。
等到I/O程序完成操作,Event Loop线程再把结果返回主线程。
主线程就调用事先设定的回调函数,完成整个任务。
nodejs里的event loop
┌───────────────────────┐
┌─>│ timers │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ I/O callbacks │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ idle, prepare │
│ └──────────┬────────────┘ ┌───────────────┐
│ ┌──────────┴────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └──────────┬────────────┘ │ data, etc. │
│ ┌──────────┴────────────┐ └───────────────┘
│ │ check │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
└──┤ close callbacks │
└───────────────────────┘
- timers: 这个阶段执行setTimeout()和setInterval()设定的回调。
- I/O callbacks: 执行几乎所有的回调,除了close回调,timer的回调,和setImmediate()的回调。
- idle, prepare: 仅内部使用。
- poll: 获取新的I/O事件;node会在适当条件下阻塞在这里。
- check: 执行setImmediate()设定的回调。
- close callbacks: 执行比如socket.on('close', ...)的回调。
process.nextTick方法可以在当前"执行栈"的尾部,下一次Event Loop(主线程读取"任务队列")之前触发回调函数。即它指定的任务总是发生在所有异步任务之前。
process.nextTick(function A() {
console.log(1);
process.nextTick(function B() {
console.log(2);
});
});
setTimeout(function timeout() {
console.log('timeout');
}, 0)
// 1
// 2
// timeout
setImmediate方法则是在当前"任务队列"添加事件,也就是说,它指定的任务总是在下一次Event Loop时执行。
setImmediate()被设计在 poll 阶段结束后立即执行回调;
setTimeout()被设计在指定下限时间到达后执行回调。
setImmediate(function A() {
console.log(1);
setImmediate(function B() {
console.log(2);
});
});
setTimeout(function timeout() {
console.log('timeout');
}, 0);
//有可能是
//timeout
//1
//2
//也可能是
//1
//timeout
//2
当多次调用 setImmediate() 时,callback 函数会按照它们被创建的顺序依次执行。 每次事件循环迭代都会处理整个回调队列。 如果一个立即定时器是被一个正在执行的回调排入队列的,则该定时器直到下一次事件循环迭代才会被触发。
如果两者都在主模块(main module)调用,那么执行先后取决于进程性能,即随机。
如果两者都不在主模块调用(即在一个 IO circle 中调用),那么setImmediate的回调永远先执行。
task
这里的task是指event loop里的task ,也就是queue里的task。
一般task被称为macrotask(宏任务),
一般包含:
- setTimeout
- setInterval
- setImmediate
- requestAnimationFrame
- postMessage
- I/O
- UI rendering
除了macrotask还有microtask(微任务),每一个event loop都有一个microtask队列。microtask也被称作job。
一般包含:
- process.nextTick
- Promise.then
- Object.observe
- MutationObserver
process.nextTick有的人觉得有异议,node的event loop不能算作microtasks。只从执行顺序来看是microtasks的效果。
Promise.then在不同的浏览器表现可能会有不同,但是普遍的共识是Promise.then属于microtasks队列。
Promise.then是异步执行的,而创建Promise实例是同步的(在stack里)执行的。
setTimeout(function () {
console.log(1)
}, 0);
new Promise(function executor(resolve) {
console.log(2);
for (var i = 0; i < 10000; i++) {
i == 9999 && resolve();
}
console.log(3);
}).then(function () {
console.log(4);
});
setTimeout(function setTimeout1() {
console.log(5)
Promise.resolve().then(function promise2() {
console.log(6);
})
}, 0)
setTimeout(function () {
console.log(7)
}, 0);
console.log(8);
//2 3 8 4 1 5 6 7
- 处理 HTML 标记并构建 DOM 树。
- 处理 CSS 标记并构建 CSSOM 树, 将 DOM 与 CSSOM 合并成一个渲染树。
- 根据渲染树来布局,以计算每个节点的几何信息。
- 将各个节点绘制到屏幕上。
- 在一轮event loop中多次修改同一dom,只有最后一次会进行绘制。
- 渲染更新(Update the rendering)会在event loop中的tasks和microtasks完成后进行,但并不是每轮event loop都会更新渲染,这取决于是否修改了dom和浏览器觉得是否有必要在此时立即将新状态呈现给用户。如果在一帧(60hz)的时间内(时间并不确定,因为浏览器每秒的帧数总在波动,16.7ms只是估算并不准确)修改了多处dom,浏览器可能将变动积攒起来,只进行一次绘制,这是合理的。
- 如果希望在每轮event loop都即时呈现变动,可以使用requestAnimationFrame。