cc icon indicating copy to clipboard operation
cc copied to clipboard

44.理解事件循环一(浅析)

Open ccforward opened this issue 7 years ago • 24 comments

Node.js 事件循环一: 浅析

理解事件循环系列第一步 浅析和总览

多数的网站不需要大量计算,程序花费的时间主要集中在磁盘 I/O 和网络 I/O 上面

SSD读取很快,但和CPU处理指令的速度比起来也不在一个数量级上,而且网络上一个数据包来回的时间更慢:

一个数据包来回的延迟平均320ms(我网速慢,ping国内网站会更快),这段时间内一个普通 cpu 执行几千万个周期应该没问题

因此异步IO就要发挥作用了,比如用多线程,如果用 Java 去读一个文件,这是一个阻塞的操作,在等待数据返回的过程中什么也干不了,因此就开一个新的线程来处理文件读取,读取操作结束后再去通知主线程。

这样虽然行得通,但是代码写起来比较麻烦。像 Node.js V8 这种无法开一个线程的怎么办?

先看下面函数执行过程

栈 Stack

当我们调用一个函数,它的地址、参数、局部变量都会压入到一个 stack 中

function fire() {
    const result = sumSqrt(3, 4)
    console.log(result);
}
function sumSqrt(x, y) {
    const s1 = square(x)
    const s2 = square(y)
    const sum = s1 + s2;
    return Math.sqrt(sum)
}
function square(x) {
    return x * x;
}

fire()

下面的图都是用 keynote 做的 keynote地址

函数 fire 首先被调用

fire 调用 sumSqrt 函数 参数为3和4

之后调用 square 参数为 x, x==3

square 执行结束返回时,从 stack 中弹出,并将返回值赋值给 s1
s1加入到 sumSqrt 的 stack frame 中

以同样的方式调用下一个 square 函数

在下一行的表达式中计算出 s1+s2 并赋值给 sum

之后调用 Math.sqrt 参数为sum

现在就剩下 sumSqrt 函数返回计算结果了

返回值赋值给 result

在 console 中打印出 result

最终 fire 没有任何返回值 从stack中弹出 stack也清空了

当函数执行完毕后本地变量会从 stack 中弹出,这只有在使用 numbers string boolean 这种基本数据类型时才会发生。而对象、数组的值是存在于 heap(堆) 中的,stack 只存放了他们对应的指针。

当函数之行结束从 stack 中弹出来时,只有对象的指针被弹出,而真正的值依然存在 heap 中,然后由垃圾回收器自动的清理回收。

事件循环

通过一个例子来了解函数的执行顺序


'use strict'

const express = require('express')
const superagent = require('superagent')
const app = express()

app.get('/', getArticle)

function getArticle(req, res) {
    fetchArticle(req, res)
    print()
}

const aids = [4564824, 4506868, 4767667, 4856099, 7456996];

function fetchArticle(req, res) {
    const aid = aids[Math.floor(Math.random() * aids.length)]
    superagent.get(`http://news-at.zhihu.com/api/4/news/${aid}`)
        .end((err, res) => {
            if(err) {
                console.log('error ......');
                return res.status(500).send('an error ......')
            }
            const article = res.body
            res.send(article)
            console.log('Got an article')
        })

    console.log('Now is fetching an article')
}

function print(){
    console.log('Print something')
}


app.listen('5000')

请求 http://localhost:5000/ 后打印出

Now is fetching an article

Print something

Got an article

虽然 V8 是单线程的,但底层的 C++ API 却不是。这意味着当我们执行一些非阻塞的操作,Node会调用一些代码,与引擎里的js代码同时执行。一旦这个隐藏的线程收到了等待的返回值或者抛出一个异常,之前提供的回调函数就会执行。

上面的说的Node调用的一些代码其实就是 libuv,一个开源的跨平台的异步 I/O 。最初就是为 Node.js 开发的,现在很多项目都在用

任务队列

javascript 是单线程事件驱动的语言,那我们可以给时间添加监听器,当事件触发时,监听器就能执行回调函数。

当我们去调用 setTimeout http.get fs.readFile, Node.js 会把这些定时器、http、IO操作发送给另一个线程以保证V8继续执行我们的代码。

然而我们只有一个主线程和一个 call-stack ,这样当一个读取文件的操作还在执行时,有一个网络请求request过来,那这时他的回调就需要等stack变空才能执行。

回调函数正在等待轮到自己执行所排的队就被称为任务队列(或者事件队列、消息队列)。每当主线程完成前一个任务,回调函数就会在一个无限循环圈里被调用,因此这个圈被称为事件循环。

我们前面那个获取文章的例子的执行顺序就会如下:

  1. express 给 request 事件注册了一个 handler,并且当请求到达路径 '/' 时来触发handler
  2. 调过各个函数并且在端口 5000 上启动监听
  3. stack 为空,等待 request 事件触发
  4. 根据传入的请求,事件触发,express 调用之前提供的函数 getArticle
  5. getArticle 压入(push) stack
  6. fetchArticle 被调用 同时压入 stack
  7. Math.floorMath.random 被调用压入 stack 然后再 弹出(pop), 从 aids 里面取出的一个值被赋值给变量 aid
  8. superagent.get 被执行,参数为 'http://news-at.zhihu.com/api/4/news/${aid}' ,并且回调函数注册给了 end 事件
  9. http://news-at.zhihu.com/api/4/news/${aid} 的HTTP请求被发送到后台线程,然后函数继续往下执行
  10. 'Now is fetching an article' 打印在 console 中。 函数 fetchArticle 返回
  11. print 函数被调用, 'Print something' 打印在 console 中
  12. 函数 getArticle 返回,并从 stack 中弹出, stack 为空
  13. 等待 http://news-at.zhihu.com/api/4/news/${aid} 发送相应信息
  14. 响应信息到达,end 事件被触发
  15. 注册给 end 事件的匿名回调函数被执行,这个匿名函数和他闭包中的所有变量压入 stack,这意味着这个匿名函数可以访问并修改 express, superagent, app, aids, req, res, aid 的值以及之前所有已经定义的函数
  16. 函数 res.send() 伴随着 200 或 500 的状态码被执行,但同时又被放入到后台线程中,因此 响应流 不会阻塞我们函数的执行。匿名函数也被 pop 出 stack。

Microtasks Macrotasks

任务队列不止一个,还有 microtasks 和 macrotasks

microtasks:

  • process.nextTick
  • promise
  • Object.observe

macrotasks:

  • setTimeout
  • setInterval
  • setImmediate
  • I/O

这两个的详细区别下一篇再写,先看一段代码

console.log('start')

const interval = setInterval(() => {  
  console.log('setInterval')
}, 0)

setTimeout(() => {  
  console.log('setTimeout 1')
  Promise.resolve()
      .then(() => {
        console.log('promise 3')
      })
      .then(() => {
        console.log('promise 4')
      })
      .then(() => {
        setTimeout(() => {
          console.log('setTimeout 2')
          Promise.resolve()
              .then(() => {
                console.log('promise 5')
              })
              .then(() => {
                console.log('promise 6')
              })
              .then(() => {
                clearInterval(interval)
              })
        }, 0)
      })
}, 0)

Promise.resolve()
    .then(() => {  
        console.log('promise 1')
    })
    .then(() => {
        console.log('promise 2')
    })

理解了node的事件循环还是比较容易得出答案的:

start
promise 1
promise 2
setInterval
setTimeout 1
promise 3
promise 4
setInterval
setTimeout 2
promise 5
promise 6

根据 WHATVG 的说明,在一个事件循环的周期(cycle)中一个 (macro)task 应该从 macrotask 队列开始执行。当这个 macrotask 结束后,所有的 microtasks 将在同一个 cycle 中执行。在 microtasks 执行时还可以加入更多的 microtask,然后一个一个的执行,直到 microtask 队列清空。

规范理解起来有点晦涩,来看下上面的例子

Cycle 1

1) setInterval 被列为 task

2) setTimeout 1 被列为 task

3) Promise.resolve 1 中两个 then 被列为 microtask

4) stack 清空 microtasks 执行

任务队列: setInterval setTimeout 1

Cycle 2

5) microtasks 队列清空 setInteval 的回调可以执行。另一个 setInterval 被列为 task , 位于 setTimeout 1 后面

任务队列: setTimeout 1 setInterval

Cycle 3

6) microtask 队列清空,setTimeout 1 的回调可以执行,promise 3promise 4 被列为 microtasks

7) promise 3promise 4 执行。 setTimeout 2 被列为 task

任务队列 setInterval setTimeout 2

Cycle 4

8) microtask 队列清空 setInteval 的回调可以执行。然后另一个 setInterval 被列为 task ,位于 setTimeout 2 后面

任务队列: setTimeout 2 setInterval

9) setTimeout 2 的回调执行, promise 5promise 6 被列为 microtasks

现在 promise 5promise 6 的回调应该执行,并且 clear 掉 interval。 但有的时候不知道为什么 setInterval 还会在执行一遍,变成下面结果

...
setTimeout 2
setInterval
promise 5
promise 6

但是把上面的代码放入 chrome console 中执行却没有问题。这一点还要再根据不同的 node版本 查一下。

Last

这篇只是对 事件循环 的浅析和总览,后面再继续深入的研究。

ccforward avatar Nov 03 '16 17:11 ccforward

great!

jrainlau avatar Nov 04 '16 00:11 jrainlau

好奇,文中的图片 左边的编辑器师怎么做的

zhanglun avatar Nov 04 '16 01:11 zhanglun

@zhanglun 左边其实就是 sublime 截的图

ccforward avatar Nov 04 '16 02:11 ccforward

好文 正好时间循环机制不是很了解

shouhe avatar Nov 04 '16 02:11 shouhe

写的很详细,赞。 希望以后加点,复杂的一些流程流程中转的话,node是如何处理的~

liwenlong avatar Nov 04 '16 05:11 liwenlong

学习了事件循环

Thinking80s avatar Mar 20 '17 05:03 Thinking80s

为什么在 Cycle2 中 setInterval 和 setTimeout 同样是 macrotask,但是先执行 setInterval 呢?

ihaichao avatar May 14 '17 02:05 ihaichao

“microtasks 队列清空” 这个过程应该在每个cycle的结尾,更加严格讲是每个task执行完毕后从task队列中移除。

bloody-ux avatar Jun 06 '17 12:06 bloody-ux

这个sublime主题很好看,可以搜到么

Aaaaaaaty avatar Jul 21 '17 02:07 Aaaaaaaty

@Aaaaaaaty 主题是我自己改的颜色

ccforward avatar Jul 21 '17 02:07 ccforward

@ccforward 啊这样 谢谢啦 文章好nice 持续关注中~

Aaaaaaaty avatar Jul 21 '17 02:07 Aaaaaaaty

我有一个问题, Promise.then 里的 callback 是 microtask 吗? 这个不应该是 macrotask 吗?

LeeYunhang avatar Jul 22 '17 16:07 LeeYunhang

@mrcodehang 是microtask,可以参考这篇文章,promise都应该是microtask,在一个次循环后串行打印结果,只是有的环境将promise callback解释为macrotask而不是microtask导致打印顺序出了问题。至于为什么环境之间对它的解释不一样,这似乎涉及到ECMA和HTML之间的一些标准界定了=。=毕竟厂商们都是自己按照标准来撸一套环境

Aaaaaaaty avatar Aug 08 '17 08:08 Aaaaaaaty

@ccforward 謝謝樓主分享如此詳盡的比較與分析。 我了解到 macrotask === task !== microtask,但有個問題想請教一下樓主: 目前我除了可以在這份文件 找到 macro-task 這個名詞以外,似乎在其他正式文件 (如 WHATWG) 裡頭,還是以 task 稱呼之,所以想請教下, macrotask 這個名詞的起源為何呢?

ajhsu avatar Apr 15 '18 05:04 ajhsu

这段代码在chrome console中执行,第二个setInterval输出了两次,是什么情况?

Jokcy avatar Apr 19 '18 06:04 Jokcy

请教下作者 async/await 属不属于microtasks

lawpachi avatar Apr 19 '18 09:04 lawpachi

@lawpachi async/await 相当于是 Promise。应该是属于 microtasks

LeeYunhang avatar Apr 20 '18 03:04 LeeYunhang

手动夸一夸作者,整理的很赞~

solome avatar Dec 25 '18 12:12 solome

这个例子很方便理解!有个疑问:第9步是否属于Cycle 5?

DFLovingWM avatar Feb 12 '19 02:02 DFLovingWM

我想请教,分为两个任务队列的原因。

justahole avatar Mar 18 '19 07:03 justahole

@justahole 并不能理解成只有两个任务队列,而是两种类型的任务队列。

  • microtask 类型:会在一次事件循环中清空掉该队列中所有的任务。
  • macrotask 类型:在一次事件循环中只会执行该队列中最前面的那个任务,剩下的任务在后续的事件循环中进行。

solome avatar Mar 22 '19 10:03 solome

恩,我知道,但是这样设计的目的是啥

掬一捧 [email protected] 于 2019年3月22日周五 下午6:19写道:

@justahole https://github.com/justahole 并不能理解成只有两个任务队列,而是两种类型的任务队列。

  • microtask 类型:会在一次事件循环中清空掉该队列中所有的任务。
  • macrotask 类型:在一次事件循环中只会执行该队列中最前面的那个任务,剩下的任务在后续的事件循环中进行。

— You are receiving this because you were mentioned. Reply to this email directly, view it on GitHub https://github.com/ccforward/cc/issues/47#issuecomment-475566036, or mute the thread https://github.com/notifications/unsubscribe-auth/AaBDq_F-DvqD0Hz5f5RhOpKu_UHpid43ks5vZK5FgaJpZM4Kosox .

justahole avatar Mar 22 '19 10:03 justahole