事件轮询(event loop)

js自身并没有任何内建存在的概念.js引擎除了在某个时刻被要求执行程序的代码块之外,没有做任何其他的事情。
js引擎运行在宿主环境中。JavaScript线程在每次调用JS引擎的时候,可以随着时间的推移执行程序中的多个代码块,这个就称为事件轮询
js引擎对时间没有天生的感觉,而是一个任意js代码片段的按需执行环境。是它周围的环境在不停地安排”事件(js代码的执行)

  • 单线程
    前面提到了javascript线程。下面简单理解一下js的单线程
    js语言的一大特点就是单线程,也就是说同一时间只能处理一件事情。这个是和JavaScript的作用有关系的。JavaScript的主要用途是用来和用户进行交互,操作DOM。例如某个操作是删除一个DOM节点,同时另一个操作是在这个DOM上添加内容,这个时候浏览器就不知道该执行哪个操作。所以这个决定了js只能是单线程的语言,同一个时间只能处理一件事情。
    单线程意味着,js代码在执行的任何时候,都只有一个主线程来处理所有的任务。

  • 浏览器下的js引擎的事件循环机制

    1. 执行栈和事件队列
      当js代码在编译阶段的时候会进行词法分析,会将不同的变量存放在内存中的不同环境中:堆(head)和栈(stack)来区分是对象类型还是基础数据类型(对象复合类型是存放在内存的堆中,而基础数据类型和对象复合类型的指针是存放在栈中)。
      1. 执行栈
        当一个js的函数被调用时,会在当前函数内创建一个执行上下文,该执行上下文中存放着这个方法的私有作用域,上层作用域的指向,方法的参数,这个作用域中定义的变量以及这个作用域的this对象。而当一系列方法被依次调用的时候,这些方法被排在一个单独的地方,这个地方就是执行栈。另外在js代码第一次执行的时候会向执行栈中加入一个全局的执行栈。
      2. 事件队列
        以上说的在执行栈中运行的函数是同步运行的,js的一大特点就是非阻塞的,而实现非阻塞就是依赖于事件队列。js引擎遇到一个异步事件之后并不会一直等待该异步事件的返回结果,而是将这个异步事件挂起,继续执行执行栈中的其他任务,当一个异步事件返回结果后,js会将这个事件加入与当前执行栈不同的另一个队列,该队列就是我们叫的事件队列。被放入事件队列的回调函数并不会立即执行,而是等待当前执行栈中的所有任务都执行完毕,主线程处于闲置状态时,主线程会去查找事件队列中是否有任务,如果有那么主线程会从中取出排在第一位的事件,并把这个事件对应的回调放入执行栈中,然后执行其中的同步代码…
    2. 事件循环
      以上事件队列中反复的执行过程会形成一个无限的循环,该循环就被称之为“事件轮询(event loop)见下图第二张图
  • 理解事件轮询

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    // 事件轮询类似一个队列的数组,先进先出
    const eventLoop = []
    let event
    // 永远执行
    while (true) {
    // 执行一个tick
    if (eventLoop.length > 0) {
    // 从队列中取出下一个事件
    event = eventLoop.shift()

    // 执行取出的事件
    try {
    event()
    } catch(e) {
    new Error(e)
    }
    }
    }

    如上所示,事件轮询就像一个一直在执行的while循环,不断的去查询eventLoop中是否还有事件存在没有被执行(每一次轮询迭代称为一个tick),如果队列中存在还没有被执行的事件,那么该事件就会被取出执行。这些事件就是回调函数。

    注意⚠️
    setTimeout函数并不会立刻将回调函数放入到事件轮询队列中,它设置了一个定时器,当达到定时器指定的时间的时候,setTimeout指定的回调函数才会被放入轮询队列,在未来的tick中被取出执行。如果该回调函数被放入队列中时队列还有很多等待被执行的回调函数,那么setTimeout回调函数便不会按照指定的时间执行,会晚于指定的事件执行。

  • javascript中的线程和任务
    由于js是单线程的,所以一个线程中,事件循环是唯一的。但是却可以有多个任务队列
    任务可以分为以下两种:

    1. 一种是同步任务:同步任务指的是,在主线程上排队执行的任务,只有前一个任务执行完毕,后一个任务才执行(先进先出)见下图。
    2. 一种是异步任务:异步任务指的是,不进入主线程,而是进入任务队列(task queue)的任务,只有任务队列通知主线程,某个异步任务可以进入主线程执行了,该异步任务才会从任务队列移到主线程中等待被执行。如下图。
  • 宏任务(macro task)和微任务(micro task)
    异步任务分为宏任务和微任何,常见的宏任务有:setInterval, setTimeout;常见的微任务有:new Promise(),new MutaionObserver()
    在一个事件循环中,异步事件的回调函数会被放入到任务队列中。但是,根据这个异步事件类型,这个事件实际上会被放入到对应的宏任务队列或者微任务队列中去。并且在当前执行栈为空的时候,主线程会去查看微任务队列中是否有事件存在,如果有事件存在,那么依次执行微任务队列中的事件回调函数,直到微任务队列为空,然后再去宏任务队列中取出最前面的一个事件,把对应的回调函数加入到当前的执行栈中;如果微任务队列中的事件已经被执行完毕或者不存在事件,那么就会去宏任务队列中取出最前面的事件并且把其放入到执行栈中执行。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    // 例如
    setTimeout(function(){
    console.log(1) // setTimeout为宏任务
    })
    new Promise(function(resolve, reject){
    console.log(2) // promise会立即执行
    resolve(3)
    }).then(function(value){
    console.log(value) // promise为微任务
    })
    // 输出为: 2->3->1
  • node中的事件循环
    Node中的事件循环的实现依赖的是libuv引擎,当chrom v8引擎将js的代码分析后调用对应的node api,而这些api最后则有libuv引擎驱动,执行对应的任务,并把不同的事件放在不同的队列中等待主线程执行,所以node中的事件循环存在于libuv引擎中。

    • node中的事件循环模型如下图所示
    • 事件循环阶段讲解
      外部输入数据->轮询阶段(poll)->检查阶段(check)->关闭事件回调阶段(close callback)->定时器检测阶段(timer)->I/O事件回调阶段(I/O callbacks)->闲置阶段(idle, prepare)->轮询阶段…依次循环
    • 各个阶段对应的功能如下所示
    1. timers: 这个阶段执行定时器队列中的回调(setTimeout, setInterval)。
    2. I/O callbacks: 该阶段执行除了close, 定时器(timers)和setImmediate之外的所有回调函数。
    3. idle, prepare: 该阶段只在内部使用。
    4. poll: 等待新的I/O事件,node在一些特殊情况下会阻塞在这里。
    5. check: setImmediate()的回调函数会在这个阶段执行。
    6. close callbacks: close事件的回调。
    • 循环阶段详解
      • poll
        循环首先进入的是pull阶段。poll阶段做的事情如下:先查看poll queue中是否有事件,有任务就按照队列的执行顺序(先进先出)依次执行回调。当queue为空时,会检查是否有setImmediate函数的回调,如果有的话就进入到check阶段执行setImmediate的回调函数,同时也会检查是否有到期的timer,如果有的话就将到期的timer的回调函数按照顺序放入到timer queue中,之后循环会进入timer阶段执行queue中的回调函数。如果两个都是空的,那么事件循环会在poll阶段停留,直到有一个I/O事件返回,循环会进入到I/O callback阶段立即执行这个事件的callback.
        注意⚠️停止poll queue中的回调无限执行的两种情况:
        1. 所有的回调执行完毕
        2. 执行树超过了node的限制
      • check
        check是用来专门来执行setImmediate()方法的回调,当poll阶段进入空闲状态,并且setImmediate queue中有callback时,事件循环进入到这个阶段.
      • close
        当一个socket连接或者一个handle被突然关闭时(例如调用了socket.destroy()方法),close事件会被发送到这个阶段执行回调。否则事件会用process.nextTick()方法发送出去。
      • timer
        timer阶段会以先进先出的队列方式来执行所有到期的timer加入到timer队列里的callback. 一个timer callback指得是一个通过setTimeout或者setInterval函数设置的回调函数。
      • I/O callback阶段
        如上文所言,这个阶段主要执行大部分I/O事件的回调,包括一些为操作系统执行的回调。例如一个TCP连接生错误时,系统需要执行回调来获得这个错误的报告。