第三章 异步I/O
异步与前端的不解之缘
前端编程算是 GUI编程 的一种, 因此驱动程序的很大部分都是异步事件,但糟糕的是:人类天生就不擅长异步思维
PHP:知道这一点我就放心了
PHP算是彻头彻尾的单线程同步语言在小规模站点中几乎没有缺点,
但是在复杂网络应用中会因为阻塞导致无法很好的并发。
事实上就算是提供了异步API,很多人也用不习惯
异步I/O还有两个好兄弟:
- 事件驱动
- 单线程
同样在这两个方向有出色设计理念的产品还有下面这位:
Nginx
Nginx具备面向客户端管理连结的强大能力,但是它依然受限于背后各种同步方式的编程语言。
为什么要异步I/O
用户体验
《高性能JavaScript》- Nicholas C.Zakas同步方式
1 | getData('from_db'); // 消费时间为M |
总耗时:M+N
异步方式
1 | getData('from_db',function(result){ |
总耗时:max(M, N)
不同的I/O类型对应的开销
I/O类型 | 消耗CPU时钟周期 |
---|---|
CPU一级缓存 | 3 |
CPU二级缓存 | 14 |
内存 | 250 |
硬盘 | 41000000 |
网络 | 240000000 |
资源分配
资源分配主要涉及到两部分计算机组件:
- I/O设备
- 计算设备
主流的资源分配的方法有以下两种:
- 单线程串行依次执行
- I/O设备 为主
- 易于表达
- 阻塞I/O导致硬件资源得不到更优使用
- 多线程并行完成
- 计算设备为主
- 能够提高多核CPU设备的CPU利用率
- 创建线程和执行期线程上下文切换开销大
- 经常面临锁、状态同步等问题
Node给出的方案
- 单线程还是多线程? - **单线程** - 同步还是异步? - **异步**如何弥补单进程缺点?
Node无法有效利用多核CPU设备资源, 但是提供了**子进程机制**(类似前端浏览器中的 *Web Workers*)异步I/O实现现状
同步、异步、阻塞、非阻塞、回调、事件…
这些概念的引入实际上都是为了达成一个目的:并行I/O。
但实际考虑到操作系统时,只涉及到两种I/O方式:
- 阻塞I/O
- 非阻塞I/O
阻塞和非阻塞
阻塞I/O
应用程序需要等待I/O完成才返回结果。
调用之后一定要等到系统内核完成所有操作之后才算做调用结束。
非阻塞I/O
应用程序在调用后立刻能获取到返回值。
需要通过文件描述符再次读取。
阻塞I/O | 非阻塞I/O |
---|---|
操作系统如何实现非阻塞I/O(文件描述符)
首先要知道操作系统是如何进行I/O操作的:
I/O设备在操作系统中,实际上都是被抽象成了文件,
I/O设备抽象成的文件有一个关键属性:文件描述符,
应用程序如果需要实现I/O调用:
- 首先打开文件描述符
- 根据文件描述符实现文件读写
因此文件描述符就是应用程序与系统内核之间的交互凭证,
也是非阻塞I/O方式获取数据的关键所在。
轮询
非阻塞I/O调用后能立即返回内容,
但返回的并不是实际数据,而只是当前调用的状态。
实际需要反复发起I/O调用来确认是否完成(复读机)。
目前有几类轮询技术:
机制 | 示意图 | 补充 | |
---|---|---|---|
read | 单纯的重复调用检查I/O状态方式 | ||
select | 相比read,select检查的不是I/O的状态而是文件描述符的状态 | 采用一个1024长的数组存储状态 | |
poll | 相比select的数组结构,采用了链表方式避免长度限制,过滤掉不必要的检查 | ||
epoll | 采用了轮询-休眠-唤醒的事件通知机制,Linux下效率最高的轮询机制 | ||
equeue | 类似epoll机制,仅在FreeBSD系统下存在 |
总结这几种方法之后,发现在应用程序发出请求之后,
CPU不是在遍历I/O状态,就是在遍历文件描述符状态,或者在休眠,
因此实际上仍旧是一种同步机制。
理想的非阻塞异步I/O
既然各种轮询的方式都不能很好的实现异步I/O,那么到底什么才是理想的非阻塞异步I/O机制呢?
大致如下:
- 应用程序发出I/O请求
- 直接开始处理下一个任务
- I/O处理完成后通过信号或回调将数据传递给应用程序
- 之所以异步I/O的实现如此困难的原因,就在于 通过信号和回调传递数据 这一步
- Linux内核I/O中的O_DIRECT方式读取数据时使用的AIO方式实际实现了这种机制
现实的异步I/O
在Windows和*nix平台下,Node采用了不同的异步I/O方案:
- *nix:libeio
- 采用线程池和阻塞I/O模拟异步I/O
- Windows:IOCP
- 同样是线程池原理,不同之处在于线程池由系统内核接手管理
Node中,用于处理平台差异的层即libuv:
- libuv作为抽象封装层,用于平台兼容性判断
- 保证上层Node与下层自定义线程池(libeio)和IOCP各自独立
I/O的两个误区:
- I/O不仅仅限于磁盘文件读写,实际范围包括几乎所有计算机资源,包括:
- 磁盘文件
- 硬件
- 套接字
- Node仅仅只是JavaScript执行在单线程中
- Node内部完成I/O任务实际需要依赖线程池机制
Node的异步I/O
Node异步I/O的三个主要机制:
- 事件循环
- 观察者
- 请求对象
事件循环
进程启动时Node会发起事件循环,
循环体单位又称Tick
每个Tick实际就是查看是否有事件待处理
观察者
对于每种事件都有相应的观察者,当事件被触发时,由对应观察者收集到事件循环中处理。
观察者将事件进行了分类,Node中的事件主要来源于:
- 网络请求
- 文件I/O
请求对象
从JavaScript发起异步调用,到内核执行完I/O操作的过渡过程中,
存在一种中间产物,即请求对象
fs.open()调用过程
- JavaScript调用Node核心模块
- 核心模块调用C++内建模块
- 内建模块通过libuv进行系统调用
回调函数的实现在第三步:
- 调用uv_fs_open()方法
- 其中创建请求对象FSReqWrap
- 回调函数放在FSReqWrap.oncomplete_sym中
req_wrap->object_->Set(oncomplete_sym, callback) - FSReqWrap包装完毕
- 将请求对象推入线程池中等待执行
调用QueueUserWorkItem() - 请求对象执行
- 回调通知
请求对象推入线程池方法
QueueUserWorkItem(&uv_fs_thread_proc, req, WT_EXECUTEDEFAULT)
- 参数1:**&uv_fs_thread_proc**
将要执行的方法的引用
根据传入req类型调用相应底层函数
如uv_fs_open()实际调用的是fs__open() - 参数2:req
参数1方法运行时所需要的参数 - 参数3:WT_EXECUTEDEFAULT
执行的标志
执行回调
回调通知的具体步骤如下:
- 事件处理完毕,存储结果
结果在req->result属性上 - 状态改变
调用PostQueuedCompletionStatus通知IOCP
提交状态,返还线程 - 等待检测
调用GetQueuedCompletionStatus检测完成状态请求 - 事件包装
被检测出完成的请求被加入观察者队列,包装成事件处理
req_wrap->object_->Set(oncomplete_sym, callback) - 事件回调
取出result->oncomplete_sym属性作为回调方法执行回调通知方法:
PostQueuedCompletionStatus((loop)->iocp, 0, 0, &((req)->overlapped))
方法作用是:
- 向IOCP提交执行状态
- 将线程归还线程池
检查线程池任务状态方法:
GetQueuedCompletionStatus()
用于检查线程池中是否有执行完的请求:
- 如果有,将请求对象加入I/O观察者队列,当做事件处理(onData)
非I/O的异步API
- setTimeout()
- setInterval()
- setImmediate()
- process.nextTick()
定时器
- setTimeout
- 单次定时执行任务
- setInterval
- 多次定时执行任务
定时器算是不需要I/O线程池参与的事件插入,
由定时器观察者观察。
定时器存在的问题在于并非精确,
严格来说它不是异步执行的,如果一次循环占用时间较多会导致超时
1 | setTimeout(function(){ |
process.nextTick()
1 | let process = require('node:process') |
同样是为了进行异步操作:
process.nextTick和定时器的区别:
- 定时器
- 需要动用红黑树,创造定时器对象和迭代等操作
- 时间复杂度:O(lg(n))
- process.nextTick
- 只会将回调函数放入事件循环队列中,
在下一轮Tick时取出执行 - 时间复杂度:O(1)
- 只会将回调函数放入事件循环队列中,
setImmediate
setImmediate与process.nextTick功能很相似,
不同点有下面这些:
- 执行优先级不同
- nextTick > setTimeout > setImmediate
setTimeout(() => {
console.log('setTimeout')
},0)
setImmediate(() => {
console.log('setImmediate')
})
process.nextTick(() => {
console.log('nextTick')
})
console.log('normal')
// normal
// nextTick
// setTimeout
// setImmediate
- 观察者不同
- setImmediate属于check观察者
- process.nextTick属于idle观察者
- idle观察者 > check观察者 > I/O观察者
- 回调函数存储结构不同
- process.nextTick存在数组里
- setImmediate存在链表里
- 执行方式不同
- process.nextTick将数组中的回调函数全部执行完
- setImmediate执行链表中的一个回调函数
事件驱动与高性能服务器
异步实现的两个基本要点:
- 主循环
- 事件触发
网络套接字的处理(Node构建Web服务器)
- 从网络套接字上侦听到请求
- 将请求形成网络I/O事件交给I/O观察者
- 触发对应I/O事件的回调函数
经典服务器模型
- 同步式
- 一次只能处理一个请求
- 易阻塞
- 每进程/每请求
- 为每个请求启动一个进程
- 扩展性差
- 每线程/每请求(Apache)
- 为每个请求启动一个线程
- 扩展性稍强
- 大并发情况下服务器缓慢
事件驱动服务器模型(Node,Nginx)
事件驱动线程少,操作系统上下文切换代价低,
但需要注意:
- 一旦事件循环中存在阻塞I/O,实际效果和同步式服务没有区别,性能会急剧下降