Skip to content

事件循环

进程与线程

进程是对正在运行中的程序的一个抽象。当启动一个程序时,操作系统就会给该程序创建一个内存空间(当程序被中止时,该内存空间就会被回收),该内存空间就是用来存放程序代码运行中的数据一个执行任务的主线程,这样的一个运行环境(内存空间)就被称为进程

每个应用至少要有一个进程,进程之间是完全隔离相互独立的,进程的通信需要借助管道IPC来传递。

一个进程至少包含一个线程,会在进程开启后自动创建,这个线程被称之为主线程。线程是进程的执行单元,是依附于进程的。进程将任务分为多个细小任务,分配给多个线程单独执行,可以提高运行效率。

浏览器事件循环

浏览器进程模型

现代浏览器是一个多进程多线程的应用程序,它在启动时会开启多个进程,拥有数个功能模块,会把不同的功能模块放在不同的进程里。

浏览器进程模型

类似与微服务架构,这意味着单个模块的崩溃不会牵连到其他模块。

其中有几个比较重要的进程:

📖 浏览器主进程

它是浏览器的主进程,无论打开多少标签页、浏览器窗口,都只会存在一个浏览器主进程,它主要负责浏览器界面展示、用户的交互、子进程管理

TIP

这里的界面和交互,指的是浏览器本身自带的界面部分(前进后退刷新按钮、导航栏、书签栏),以及浏览器的交互(滚动条、键盘等)

打开浏览器只有一个浏览器主进程,其他的进程是由主进程创建的,用于处理多个不同的任务,这些新开启的进程会交由子进程管理统一管理

📖 网络进程

负责加载网络资源,网络进程会开启多个线程处理不同的任务。

🌟 渲染进程

渲染进程启动后,会开启一个渲染主线程,主线程负责执行解析HTMLCSSJS脚本和其他资源。默认情况下,浏览器会为每个标签页开启一个新的渲染进程,保证不同的标签页之间不相互影响。

TIP

渲染进程也是前端开发要重点关注的进程

子进程管理

渲染主线程

渲染进程启动后,会开启一个渲染主线程,它是浏览器最繁忙的线程,需要处理的任务有很多:

  • 解析HTML
  • 解析CSS
  • 计算样式
  • 布局
  • 处理图层
  • 绘制页面
  • 解析JS代码并执行
  • 执行事件处理回调函数
  • 执行各种回调函数
  • ......

TIP

为什么浏览器不采用多线程的方式去处理这么多任务?

JavaScript的解析和执行是由渲染进程的主线程完成的,这个线程是渲染进程的核心线程,负责处理大多数关键任务。所有代码默认在这个线程上顺序执行,这意味着:

  • 同一时间只能做一件事,代码逐行顺序执行。
  • 没有多线程的复杂性,不需要处理多线程的锁、竞态问题等
  • 如果一个任务长时间占用主线程,整个程序会被阻塞

JavaScript在设计之初,主要用于处理网页的交互,比如表单验证、DOM操作等。如果使用多线程,可能会带来复杂的同步问题,比如多个线程同时操作DOM,容易导致竞态条件和不可预测的结果。所以JavaScript采用了单线程模型来避免这些问题。

虽然主线程是单线程,但单线程≠只能做一件事,通过事件循环和异步队列的机制,JavaScript可以实现非阻塞的异步操作,比如网络请求、定时器、文件读写(Nodejs)等。

这就 JavaScript单线程的 原因

面对这么多的任务,浏览器采用排队轮询的机制来进行任务调度。

  1. 浏览器会提供一个消息队列,用于存储等待执行的任务
  2. 渲染主线程会进入无限循环
  3. 自上而下执行JavaScript的同步代码,并将异步任务放入到消息队列
  4. 每次循环会检查消息队列是否有任务存在,如果有,就依次取出一个任务执行,执行完后进行下一次循环;如果没有,则进入休眠状态。
  5. 其他的线程可以往消息队列添加任务,新任务会添加在消息队列的队尾
  6. 不断循环这个过程,直到消息队列清空,进入休眠,一旦有任务又会被激活

异步任务

异步任务

JavaScript是一门单线程语言,如果采用同步的方式,极有可能会导致后面代码产生阻塞,消息队列中的任务造成堆积无法得到执行。

所以浏览器采用异步的方式来避免。当某些任务发生时,比如网络、计时器、事件这些异步任务,主线程会将任务交给其他线程处理,自身立即结束当前异步任务的执行,转而继续执行后续同步代码。

当其他线程完成的时候,会将事先包装好的回调函数包装成异步任务对象,加入到消息队列的队尾,等待主线程调度执行。

也就是说,大多数的异步任务其实是回调函数,Promise.then(task)setTimeout(task,0)fetch(task)等。

消息队列中的任务可以存在很多条,同一个任务必须在一个队列,不同类型的任务可以分类为不同的队列,这些队列之间是存在优先级的,W3C将这些消息队列分类为以下几种:

队列类型任务内容优先级
微任务队列Promise。then()MutationObserver的回调函数,queueMicrotask()的回调函数等。最高
交互队列用户交互事件的回调函数,例如clickpostMessage的回调等
延时队列计时器的回调函数,比如setTimeoutsetInterval
网络请求队列网络请求的回调函数,比如fetchXMLHttpRequest的回调
渲染任务队列页面渲染相关的任务,比如重绘、重排等

下面通过一个具体的代码案例,说明浏览器事件循环的过程:

js
// 写出下述程序的输出结果
const btn = document.getElementById('button');

function test() {
  console.log('function test!');
  Promise.resolve().then(() => {
    console.log('promise1');
  });
}

setTimeout(() => {
  console.log('set timer');
  Promise.resolve().then(test);
}, 0);

btn.onclick = () => {
  console.log('click button');
};

btn.click();

Promise.resolve().then(() => {
  console.log('promise2');
});

console.log('script start');

// 输出结果依次为:
// click button
// script start
// promise2
// set timer
// function test!
// promise1

加点Promise

Promise.then的回调函数在调用resolve() / reject()之后,状态由pending转变状态,就会被加入到微队列中排队。

看下面的代码分析输出:

js
Promise.resolve()
  .then(() => {
    console.log("promise1");
  })
  .then(() => {
    console.log("promise4");
  });

setTimeout(() => {
  console.log("setTimeout");
}, 0);

Promise.resolve()
  .then(() => {
    console.log("promise2");
  })
  .then(() => {
    console.log("promise3");
  });

console.log("start");

// start
// promise1
// promise2
// promise4
// promise3
// setTimeout

拆解上面的代码执行时机:

拆解过程

then()的回调函数被称为thenable,它的执行时机(注册到微队列)受到两个因素的影响

  • resolve()函数的调用,意味着该thenable已经完成, 需要从队列中取出来执行
  • then传递的回调,意味该thenable已经注册到微队列的队尾,等待执行

promise1的微任务执行完毕后,它的后面.then(() => {console.log("promise4")})并不会立即执行,而是会加入到队尾等待执行,promise2后面的任务也同理。

再来分析下面的代码:

js
Promise.resolve()
  .then(() => {
    console.log("promise1");
    return Promise.resolve("promise4");
  })
  .then((res) => {
    console.log(res);
  });

setTimeout(() => {
  console.log("setTimeout");
}, 0);

Promise.resolve()
  .then(() => {
    console.log("promise2");
  })
  .then(() => {
    console.log("promise3");
  })
  .then(() => {
    console.log("promise5");
  })
  .then(() => {
    console.log("promise6");
  });

console.log("start");

// start
// promise1
// promise2
// promise3
// promise5
// promise4
// promise6

这道题跟上一道题的区别就在于微任务内返回了个Promise,一个PromiseA接收另外一个PromiseB的结果,此时的微任务由thenable变成了包裹微任务的thenable

这是何解?用下面的代码解释就是:

js
Promise.resolve()
  .then(() => {
    console.log("1");
    return Promise.resolve("2");
  })

// ==> 产生了两个thenable
// Promise.resolve().then(thenable1:()=>Promise.resolve().then(thenable2))

Promise.resolve()
  .then(() => {
    console.log("1");
    return new Promise((resolve)=>{
      resolve("2")
    })
  })

async / await

这一对APIPromise的语法糖,考察的也是async awaitPromise之间的转换。

  • async 会返回一个 Promise

    js
    async function fn(){
      return 'fn'
    }
    
    // 等价于
    function fn(){
      return Promise.resolve('fn')
    }
  • await 为界线,之前的代码为同步 executor 代码,之后的代码为 thenable

    js
    async function fn(){
      console.log('1')
      const result = await fetch()
      console.log(result,'result')
    }
    
    // 等价于
    function fn(){
      console.log('1')
      return new Promise((resolve)=>{
        // console.log('1') 或者放在executor中执行
        fetch().then((result)=>{
          resolve(result)
        })
      })
    }
  • 多个 await 会被转换为链式的 then 调用

    js
    async function fn(){
      const a = await Promise.resolve('2')
      const b = await Promise.resolve('3')
      return a + b
    }
    
    // 等价于
    function fn(){
      return Promise.resolve('2')
       .then((a)=>{
          return promise.resolve('3')
           .then((b)=>{
              return a + b
            })
        })
    }

接下来看代码分析输出:

js
async function example() {
  console.log('1'); // 同步代码
  
  setTimeout(() => {
    console.log('2'); // 宏任务
  }, 0);
  
  const result = await Promise.resolve('3');
  console.log(result); // 微任务
  
  setTimeout(() => {
    console.log('4'); // 宏任务
  }, 0);
  
  await Promise.resolve('5')
    .then(res => {
      console.log(res); // 微任务
    });
    
  console.log('6'); // 微任务
}

console.log('start');
example();
console.log('end');

// 输出顺序:
// start
// 1
// end
// 3
// 5
// 6
// 2
// 4

Node事件循环

Nodejs 中的事件循环则是基于 libuv 实现的,事件循环也可以被称为是 Nodejs 的生命周期。

libuv 是一个用 C 语言实现的高性能解决单线程非阻塞异步 I/O 的开源库,本质上它是对常见操作系统底层异步 I/O 操作的封装。在 nodejs 底层,Node API 的实现其实就是调用的它。

img

  • 宏队列
    • timers (重要)
    • pending callback:调用上一次事件循环没在 poll 阶段立刻执行,而延迟的 I/O 回调函数
    • idle prepare:仅供 nodejs 内部使用
    • poll (重要)
    • check (重要)
    • close callbacks:执行所有注册 close 事件的回调函数
  • 微队列
    • nextTick
    • Promise

每一个 event loop 会按照下面的循环顺序:

plant
   ┌───────────────────────┐
┌─>│        timers         │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     I/O callbacks     │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     idle, prepare     │
│  └──────────┬────────────┘      ┌───────────────┐
│  ┌──────────┴────────────┐      │   incoming:   │
│  │         poll          │<─────┤  connections, │
│  └──────────┬────────────┘      │   data, etc.  │
│  ┌──────────┴────────────┐      └───────────────┘
│  │        check          │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
└──┤    close callbacks    │
   └───────────────────────┘

我们只需要注意其中几个比较重要的队列:

  • 📎 timers

计时器队列,负责处理setTimeoutsetInterval的回调函数

值得注意的是,不管在浏览器中还是 nodejs 中,所有的定时器回调函数都不能保证到达时间后立即执行。

一是因为从计算机硬件和底层操作系统来看,计时器的实现本身就是不精准的,

二是因为 poll 阶段对 timers 阶段的深刻影响。在没有满足 poll 阶段的结束条件前,就无法进入下一次事件循环的 timers 阶段,即使 timers 队列中已经有计时器到期的回调函数。

三是因为 timers 需要开启计时器线程去判断计时器是否到时间执行。

  • 📦 poll

轮询队列,该阶段会处理除 timerscheck 队列外的绝大多数 I/O 回调任务,如文件读取、监听用户请求等。

这个队列的运行方式

  • 如果 poll 存在回调,会依次执行回调,直到队列清空
  • 如果没有回调,会等到其他队列出现回调,会结束当前阶段,进入下一阶段的事件循环
  • 如果都没有回调,会持续等到,直到出现回调为止
js
const http = require('node:http')

setTimeout(function f1(){
  // 会在1s后执行times的回调,然后清空队列,进入poll阶段
  console.log('f1')
}, 1000)

const server = http.createServer((req, res) => {
  // 只要没有新请求进来之前,会一直在这个回调中等待,不会继续执行全局上下文的代码
  console.log('server start')
})

server.listen(9527)

关于等待其他队列出现回调,可以用下面的代码分析一下:

js
const start = Date.now();

setTimeout(function f1(){
  console.log(`setTimeout ${Date.now() - start}`)
}, 200)

const fs = require("node:fs");

fs.readFile("./data.json", "utf-8", (err,data)=>{
  console.log("readFile");
  while(Date.now() - start < 300){}
})

此时会发现打印的 setTimeout 时间远远不止200ms,这是因为在 poll 阶段中执行任务不是一下子就完成的,这需要一个过程,而这个等待的过程结束后,任务才会加入到 poll 队列中

poll 此时存在一个任务 readFile,这个任务会阻塞执行300ms,完成之后,会继续事件循环

发现计时器的任务到时间了,会加入到 timers 队列中,然后拿出来执行,此时的程序执行耗时时间:poll等待任务执行并加入到队列的时间 + 阻塞的300ms + timers任务所需的200ms

就会造成 timers 的实际执行时间与预期的执行时间有一定的差距,这是因为要等 poll 一直在轮询的动作。

  • 🏏 check

检查队列,负责处理 setImmediate 定义的回调函数。

setTimeout 的每次都会在计算机开辟计时器线程,这对于 times 的判断过程是很浪费时间的,但是 setImmediate 不同,它是直接加入到check 队列中,所以总的来说,setImmediate 的执行效率要远高于 setTimeout,于是也就出现了下面无法预测输出结果的情况

js
setTimeout(() => {
    console.log('setTimeout');
}, 0)

setImmediate(() => {
    console.log('setImmediate');
})

// 上述代码是无法预测先输出那个的
// 因为即使 setTimeout(xxx, 0),在计算机运算慢的情况下也不能立刻加入 timers 队列
  • 📌 nextTick

在每个阶段的队列执行之前,也就是在 timers、poll、check 的每个阶段,都会先去检查微队列,存在就先拿出来运行。

process.nextTick() 将回调函数加入 nextTick 队列,它的优先级是最高的。

  • 📌 Promise

最常见的微队列任务,通过 Promise.resolve、queueMicrotask 加入的任务

js
async function async1() {
  console.log("async1 start");
  await async2();
  console.log("async1 end");
}

async function async2() {
  console.log("async2");
}

console.log("script start");

setTimeout(function () {
  console.log("setTimeout0");
}, 0);

setTimeout(function () {
  console.log("setTimeout3");
}, 3);

setImmediate(() => console.log("setImmediate"));

process.nextTick(() => console.log("nextTick"));

async1();

new Promise(function (resolve) {
  console.log("promise1");
  resolve();
  console.log("promise2");
}).then(function () {
  console.log("promise3");
});

console.log("script end");

// 输出结果依次为:
// script start
// async1 start
// async2
// promise1
// promise2
// script end
// nextTick
// async1 end
// promise3

// 剩下的 setTimeout0、setTimeout3、setImmediate 顺序不定
// 唯一能确定的是 setTimeout0 在 setTimeout3 前输出
// 而 setImmediate 可能在 setTimeout0 前也可能在 setTimeout3 之后,也可能在两者中间

如有转载或 CV 的请标注本站原文地址