Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
ccforward opened this issue Nov 3, 2016 · 24 comments
Open

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

ccforward opened this issue Nov 3, 2016 · 24 comments
Assignees

Comments

@ccforward
Copy link
Owner

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

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

@jrainlau
Copy link

jrainlau commented Nov 4, 2016

great!

@zhanglun
Copy link

zhanglun commented Nov 4, 2016

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

@ccforward
Copy link
Owner Author

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

@shouhe
Copy link

shouhe commented Nov 4, 2016

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

@liwenlong
Copy link

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

@Thinking80s
Copy link

学习了事件循环

@ihaichao
Copy link

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

@bloody-ux
Copy link

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

@Aaaaaaaty
Copy link

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

@ccforward
Copy link
Owner Author

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

@Aaaaaaaty
Copy link

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

@LeeYunhang
Copy link

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

@Aaaaaaaty
Copy link

Aaaaaaaty commented Aug 8, 2017

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

@char8x
Copy link

char8x commented Jan 1, 2018

附录视频作为参考 Philip Roberts: What the heck is the event loop anyway? | JSConf EU 2014

@ajhsu
Copy link

ajhsu commented Apr 15, 2018

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

@Jokcy
Copy link

Jokcy commented Apr 19, 2018

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

@lawpachi
Copy link

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

@LeeYunhang
Copy link

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

@solome
Copy link

solome commented Dec 25, 2018

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

@DFLovingWM
Copy link

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

@justahole
Copy link

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

@solome
Copy link

solome commented Mar 22, 2019

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

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

@justahole
Copy link

justahole commented Mar 22, 2019 via email

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests