一直都没有特别明白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 操作:网络请求(
fetch
、XMLHttpRequest
)、文件读写(fs.readFile
)的回调; - 浏览器特定:UI 渲染、
postMessage
setImmediate
(Node.js 特有);- 事件回调:
click
、load
等 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
阶段。
- 主要是一些操作系统层面的异步 I/O 回调,如 TCP 连接错误的回调(如
3. idle, prepare(闲置、准备阶段)
- 核心作用:Node.js 内部使用,开发者无需关注。
- 细节:
idle
阶段:用于执行 Node.js 内部的闲置任务(如垃圾回收相关的预备工作)。prepare
阶段:为下一个poll
阶段做准备(如设置 poll 阶段的超时时间)。
4. poll(轮询阶段)
- 核心作用:处理I/O 操作的回调(最常用的阶段,大部分异步任务在此执行)。
- 任务来源:
- 网络请求(如
http
、net
模块)的回调(如服务器接收请求、客户端收到响应)。 - 文件操作(如
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开发者在大多数情况下无法自定义
从实际执行顺序来看,浏览器宏任务的优先级大致如下(从高到低):
- 用户交互事件回调(如
click
、scroll
、resize
等) - 首页加载的fetch请求
- UI 渲染任务(DOM 操作后的渲染)
setTimeout
/setInterval
回调requestIdleCallback
回调(最低优先级,仅在浏览器空闲时执行)
简单来说浏览器会“优先响应用户交互和关键渲染”的基本原则去设置宏任务执行的优先级
总结
回到文章最开始的疑问,“Javascript怎么通过异步和回调函数(callback function)来实现高并发IO操作的”,下面是一个简单的流程
在调用异步 API(如 fetch
)时,JavaScript 主线程会:
- 将 I/O 任务(如网络请求)委托给宿主环境(浏览器 / Node.js)的后台线程(如浏览器的网络线程);
- 向后台线程注册一个 回调函数(用于处理 I/O 完成后的结果);
- 立即返回,继续执行后续同步代码(不阻塞主线程)。
- 后台线程会使用更底层的系统级API处理IO操作,完成后将回调函数加入宏任务队列
- 宏任务队列按顺序执行直到处理到该 I/O 任务对应的回调函数
JS引擎的主线程不断从一个任务队列中拿出宏任务/微任务执行,同时宿主环境的线程不断将回调函数加入任务队列,有没有一种很熟悉的感觉,对这其实就是经典并发同步模式——生产者-消费者设计模式,事件循环只是在此基础上进行了调度的优化
Comments NOTHING