Javascript异步原理的探究

ChainPray 发布于 8 天前 41 次阅读 2688 字 预计阅读时间: 12 分钟


一直都没有特别明白Javascript怎么通过异步和回调函数(callback function)来实现高并发IO操作的,最近有空就好好研究了一下

javascript是一个单线程语言,这就意味着所有任务本质上都是线性运行的,也就是一个任务完成后在进行下一个任务。

学过python的读者应该知道python有个threading模块可以启动多线程,虽然受限于GIL锁实际上只能在一个CPU核心上跑,但是依赖于CPU底层的时间切片机制(每个线程都运行一段时间就切换),能够实现伪并行的效果

而Javascript没法依赖这个机制,所以就必须设计一种架构来解决网络请求/文件操作这类会阻塞主线程的IO操作——也就是js的事件循环

事件循环

简单来说js的事件循环就是主线程不断从任务队列中取出一个任务(注意任务队列可能不止一个,也不严格遵守FIFO先进先出规则,后面会详细解释),类似下面的一个while循环

while (queue.waitForMessage()) {
  queue.processNextMessage();
}

而任务队列的添加既可以是主线程,比如Promise.resolve().then添加的微队列;也可以是委托的其他线程,比如setTimeout的定时器线程到时间后将回调函数放入宏任务队列

这里就引出了事件循环第一个重要概念——宏任务 / 微任务队列

两种任务队列

时间线

在ES6推出Promise这个新特性之前,js的事件循环只有宏任务;而在ES6 引入 Promise、ES7 引入 async/await之后事件循环多了微任务队列的概念

宏任务

定义:由宿主环境(浏览器 / Node.js)发起的异步任务,优先级低于微任务,会在所有微任务执行完毕后才执行。

常见的宏任务

  • 全局脚本执行(整个 <script> 代码块,是第一个宏任务);
  • 定时器:setTimeout()setInterval()
  • I/O 操作:网络请求(fetchXMLHttpRequest)、文件读写(fs.readFile)的回调;
  • 浏览器特定:UI 渲染、postMessage
  • setImmediate(Node.js 特有);
  • 事件回调:clickload 等 DOM 事件回调。

注意在宏任务的定义中说宏任务是由“宿主环境发起的异步任务“,具体来说是js主线程将异步任务委托给宿主环境,宿主环境在达到某个条件后将回调函数加入宏任务队列

拿定时器任务来说,大致的运行流程如下

  • 主线程执行setTimeout后会将任务委托给宿主环境的定时器线程
  • 然后主线程继续执行同步代码,同时宿主环境的定时器线程也在运行,这两个线程是并行的
  • 直到定时器线程判断到达指定时间,然后将回调函数加入宏任务队列来通知主线程执行回调

I/O 操作也和定时器类似主线程委托宿主环境的线程/线程池来执行对应的I/O 操作,不同的是这里宿主环境会调用更底层系统级的API来处理I/O 操作,不过js开发者无需在意这些具体实现

初始宏任务

注意:html页面script标签包裹的js内容以及nodejs的入口js文件会被作为第一个宏任务执行

微任务

定义:由 JavaScript 引擎发起的异步任务,优先级高于宏任务,会在当前宏任务执行完毕后、下一个宏任务开始前执行

常见类型

  • Promise.then()Promise.catch()Promise.finally()
  • async/await 中 await 后面的代码(本质是 Promise.then 的语法糖);
  • Node.js 中的 process.nextTick()(优先级高于其他微任务)。

注意Promise 的执行器函数(new Promise((resolve) => { ... }) 中的回调同步执行的,仅 then/catch 是微任务

process.nextTick()的优先级比较特殊,可以无视在Nodejs的6个阶段执行顺序(后文会介绍),拥有最高的宏任务优先级

任务队列小结

  • 宏任务可能需要委托宿主环境的线程/线程池来异步执行阻塞操作(IO,定时器),而微任务单纯是由js发起的由js引擎本身执行的同步代码
  • 每执行完一个宏任务后会立即清空所有微任务

宏任务执行顺序

在刚引出任务队列的时候笔者提到了“任务队列可能不止一个,也不严格遵守FIFO先进先出规则”,这里其实分别对应两种宿主环境的两个特征——Nodejs的6个阶段和浏览器的宏任务优先级

Nodejs的6个阶段

Nodejs的事件循环按以下固定顺序循环执行,每个阶段完成后才会进入下一个阶段:
timers → pending callbacks → idle, prepare → poll → check → close callbacks

每个阶段的核心工作是:处理本阶段的任务队列 → 执行完所有任务(或达到系统限制)后,进入下一阶段

1. timers(定时器阶段)

  • 核心作用:执行 setTimeout() 和 setInterval() 中延迟时间已到期的回调函数。
  • 任务来源
    • setTimeout(fn, delay):当延迟时间 delay 到期后,fn 会被加入本阶段队列。
    • setInterval(fn, interval):每间隔 interval 时间,fn 会被加入本阶段队列(若前一次回调未执行则跳过,避免堆积)。
  • 关键细节
    • 延迟时间是 “最小等待时间” 而非 “精确时间”:若主线程被其他任务阻塞,回调会延迟执行。
    • 队列执行限制:本阶段会执行所有 “到期” 的回调,直到队列为空或执行的回调数量达到系统上限(避免长时间阻塞)。

2. pending callbacks(待处理回调阶段)

  • 核心作用:执行延迟到下一轮循环的 I/O 回调(系统级回调,开发者较少直接接触)。
  • 任务来源
    • 主要是一些操作系统层面的异步 I/O 回调,如 TCP 连接错误的回调(如 ECONNREFUSED 错误)。
    • 这些回调本应在上一轮循环的 poll 阶段执行,但因某些原因(如系统限制)被延迟到本轮的 pending callbacks 阶段。

3. idle, prepare(闲置、准备阶段)

  • 核心作用:Node.js 内部使用,开发者无需关注
  • 细节
    • idle 阶段:用于执行 Node.js 内部的闲置任务(如垃圾回收相关的预备工作)。
    • prepare 阶段:为下一个 poll 阶段做准备(如设置 poll 阶段的超时时间)。

4. poll(轮询阶段)

  • 核心作用:处理I/O 操作的回调(最常用的阶段,大部分异步任务在此执行)。
  • 任务来源
    • 网络请求(如 httpnet 模块)的回调(如服务器接收请求、客户端收到响应)。
    • 文件操作(如 fs.readFile)的回调(文件读写完成后触发)。
    • 其他 I/O 相关事件(如流操作 stream 的 data 事件)。
  • 执行逻辑
    • 若 poll 队列不为空:依次执行队列中的回调,直到队列为空或达到系统限制(防止过度占用 CPU)。
    • 若 poll 队列为空:
      • 检查是否有 setImmediate() 注册的回调:若有,直接进入 check 阶段。
      • 若没有 setImmediate() 回调:阻塞等待新的 I/O 事件加入队列(或等待 timers 阶段的定时器到期)。

5. check(检查阶段)

  • 核心作用:执行 setImmediate() 注册的回调函数。
  • 任务来源setImmediate(fn) 专门用于在 poll 阶段结束后立即执行回调(优先级高于下一轮 timers 阶段的任务)。
  • 与 setTimeout(fn, 0) 的区别
    • setImmediate 回调在 poll 阶段为空时立即执行(属于 check 阶段)。
    • setTimeout(fn, 0) 回调需等待 timers 阶段(可能被 poll 阶段阻塞)。
    • 若 poll 阶段有任务,setImmediate 会比 setTimeout(fn, 0) 先执行。

6. close callbacks(关闭回调阶段)

  • 核心作用:执行资源关闭相关的回调
  • 任务来源
    • 如 socket.on('close', fn):TCP 连接关闭时触发。
    • process.on('exit', fn):进程退出前的清理回调(严格来说属于退出钩子,但逻辑类似)。
  • 特点:本阶段的回调优先级较低,仅在其他阶段完成后执行。

浏览器的宏任务优先级

浏览器没有Nodejs的阶段宏任务,只有一个全局宏任务队列,但是这个队列的任务不是FIFO机制,而是有隐含的任务优先级,这个优先级由浏览器决定,js开发者在大多数情况下无法自定义

从实际执行顺序来看,浏览器宏任务的优先级大致如下(从高到低):

  1. 用户交互事件回调(如 clickscrollresize 等)
  2. 首页加载的fetch请求
  3. UI 渲染任务(DOM 操作后的渲染)
  4. setTimeout/setInterval 回调
  5. requestIdleCallback 回调(最低优先级,仅在浏览器空闲时执行)

简单来说浏览器会“优先响应用户交互和关键渲染”的基本原则去设置宏任务执行的优先级

总结

回到文章最开始的疑问,“Javascript怎么通过异步和回调函数(callback function)来实现高并发IO操作的”,下面是一个简单的流程

在调用异步 API(如 fetch)时,JavaScript 主线程会:

  • 将 I/O 任务(如网络请求)委托给宿主环境(浏览器 / Node.js)的后台线程(如浏览器的网络线程);
  • 后台线程注册一个 回调函数(用于处理 I/O 完成后的结果);
  • 立即返回,继续执行后续同步代码(不阻塞主线程)。
  • 后台线程会使用更底层的系统级API处理IO操作,完成后将回调函数加入宏任务队列
  • 宏任务队列按顺序执行直到处理到该 I/O 任务对应的回调函数

JS引擎的主线程不断从一个任务队列中拿出宏任务/微任务执行,同时宿主环境的线程不断将回调函数加入任务队列,有没有一种很熟悉的感觉,对这其实就是经典并发同步模式——生产者-消费者设计模式,事件循环只是在此基础上进行了调度的优化

深圳大学腾讯创新俱乐部的一名TICer,目前致力于成为全栈工程师
最后更新于 2025-09-06