JavaScript中数千个setTimeout与await如何实现并发运行?
问题场景
你实现了一套JavaScript客户端-服务端通信应用,客户端向监听localhost:5000的服务端累计发送1000个HTTP请求,单个请求内部通过await等待响应返回。
客户端实现代码
const SendRequest = async () => { const res = await fetch("http://localhost:5000") return res } for(let i=0;i<1000;i++) { SendRequest().then(res => console.log(res.status)) console.log("request sent") }
客户端运行现象:Node.js执行上述代码时,控制台会先打印1000条request sent日志,间隔一段时间后,才会快速连续打印1000条服务端返回的响应状态日志。
服务端实现代码
app.get("/", async (req, res) => { console.log("Connection established"); setTimeout(() => { console.log("Sending res") res.send("Hello from the server") }, 5000) // 等待5秒后返回响应 })
服务端运行现象:客户端发起请求后,服务端会先打印1000次Connection established日志;5秒后短短几毫秒内,服务端就会打印1000次Sending res日志,同时1000个响应一次性全部发送并被客户端接收。
核心疑问
已知JavaScript是单线程语言,Node.js会调用额外线程处理setTimeout、HTTP响应等待这类异步操作,测试设备为6核12线程配置:
- 即便占满系统全部12个线程,Node.js是如何同时等待数千个这类异步操作的?
- 服务端这1000个延迟均设置为5秒的
setTimeout是如何实现并发运行的?
首先纠正一个普遍的认知偏差:Node.js处理网络等待、定时器这类异步操作,根本不需要为每个操作分配独立线程。libuv提供的默认大小为4的工作线程池,仅用于处理文件读写、加解密、压缩解压这类需要阻塞执行的任务,网络IO、定时等待的逻辑完全不占用工作线程,和设备的CPU核心/线程数没有直接关系。
网络请求等待的实现逻辑
Node.js的网络IO能力底层依赖操作系统提供的IO多路复用机制(Linux下为epoll、macOS下为kqueue、Windows下为IOCP),整个等待流程完全不需要额外线程参与:
- 所有发起的网络请求对应的socket连接,都会被注册到操作系统内核的监听列表中
- Node.js事件循环每次轮询时,只会向内核发起一次系统调用,查询当前哪些连接已经完成数据接收、处于就绪状态
- 内核将所有就绪的连接列表返回给Node.js后,Node.js才会把对应请求的回调推入任务队列,等待主线程执行
整个等待响应的过程中,所有连接的状态都由操作系统内核统一维护,既不阻塞Node.js主线程,也不占用libuv工作线程。别说1000个等待中的连接,哪怕是数万、数十万个空闲等待的连接,消耗的CPU和内存资源都极低。
你观察到客户端先打印1000条request sent,是因为循环中调用SendRequest()时,fetch方法完成请求发送、把socket注册到内核监听列表后就会立刻返回,不会阻塞循环等待响应,因此循环会瞬间执行完成,打印完所有发送日志。等5秒后所有响应同时到达内核,事件循环一次性拿到所有就绪连接,就会连续执行回调打印所有响应状态。
setTimeout定时等待的实现逻辑
setTimeout的实现更轻量,完全在Node.js内部完成,和线程没有任何关系:
- Node.js内部维护了一个最小堆结构存储所有注册的定时器,堆顶永远是距离触发时间最近的定时器
- 事件循环每次进入定时器阶段时,只会对比当前时间和堆顶定时器的目标触发时间:
- 如果堆顶定时器还没到触发时间,就直接进入下一个事件循环阶段,不会空等
- 如果堆顶定时器已经到期,就依次取出堆中所有到期的定时器,把对应的回调推入任务队列等待主线程执行
服务端收到1000个请求时,相当于往这个定时器堆里插入了1000个目标触发时间均为“当前时间+5000ms”的定时器。在等待的5秒里,这1000个定时器只是堆中存储的1000条结构化数据,没有任何代码在运行、也没有任何线程被占用。等5秒时间到,事件循环检查定时器堆时发现这1000个定时器全部到期,就会在几毫秒内顺次执行完所有回调,因此你会观察到1000条Sending res日志几乎同时打印,所有响应也同步发回客户端。
本质上,所有“等待”类的异步逻辑都是事件订阅模式:操作发起时只需要注册一个“完成后通知我执行回调”的监听,等待过程中没有任何计算开销,自然不需要多线程支撑高并发。只有需要持续占用CPU执行运算的逻辑,才会消耗CPU线程资源。
内容的提问来源于stack exchange,提问作者Akshay Dagar

