更新于 

第三章 异步I/O

异步与前端的不解之缘

前端编程算是 GUI编程 的一种, 因此驱动程序的很大部分都是异步事件,但糟糕的是:

人类天生就不擅长异步思维

PHP:知道这一点我就放心了
PHP算是彻头彻尾的单线程同步语言
在小规模站点中几乎没有缺点,
但是在复杂网络应用中会因为阻塞导致无法很好的并发。

事实上就算是提供了异步API,很多人也用不习惯

异步I/O还有两个好兄弟:

  • 事件驱动
  • 单线程

同样在这两个方向有出色设计理念的产品还有下面这位:

Nginx
Nginx具备面向客户端管理连结的强大能力,
但是它依然受限于背后各种同步方式的编程语言。

为什么要异步I/O

用户体验

《高性能JavaScript》- Nicholas C.Zakas
前端获取两个同样的资源:
同步方式
1
2
getData('from_db'); // 消费时间为M
getData('from_remote_api'); //消费时间为B

总耗时:M+N

异步方式
1
2
3
4
5
6
getData('from_db',function(result){
// 消费时间为M
})
getData('from_remote_api',function(result){
// 消费时间为N
})

总耗时:max(M, N)


不同的I/O类型对应的开销
I/O类型 消耗CPU时钟周期
CPU一级缓存 3
CPU二级缓存 14
内存 250
硬盘 41000000
网络 240000000
分布式应用会使M和N值呈线性增长, 这也是分布式应用会放大同步和异步差异的原因。

资源分配

资源分配主要涉及到两部分计算机组件:

  • 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
123 123

操作系统如何实现非阻塞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下效率最高的轮询机制 123
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()调用过程

  1. JavaScript调用Node核心模块
  2. 核心模块调用C++内建模块
  3. 内建模块通过libuv进行系统调用

回调函数的实现在第三步:

  1. 调用uv_fs_open()方法
  2. 其中创建请求对象FSReqWrap
  3. 回调函数放在FSReqWrap.oncomplete_sym中
    req_wrap->object_->Set(oncomplete_sym, callback)
  4. FSReqWrap包装完毕
  5. 将请求对象推入线程池中等待执行
    调用QueueUserWorkItem()
  6. 请求对象执行
  7. 回调通知

    请求对象推入线程池方法
    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
    执行的标志

执行回调

回调通知的具体步骤如下:

  1. 事件处理完毕,存储结果
    结果在req->result属性上
  2. 状态改变
    调用PostQueuedCompletionStatus通知IOCP
    提交状态,返还线程
  3. 等待检测
    调用GetQueuedCompletionStatus检测完成状态请求
  4. 事件包装
    被检测出完成的请求被加入观察者队列,包装成事件处理
    req_wrap->object_->Set(oncomplete_sym, callback)
  5. 事件回调
    取出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
2
3
4
5
setTimeout(function(){
// 任务1:花费9ms
// 任务2: 花费5ms
},10)
// 超时: 9+5-10=4ms

process.nextTick()

1
2
let process = require('node:process')
process.nextTick(callback)

同样是为了进行异步操作:
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服务器)

  1. 从网络套接字上侦听到请求
  2. 将请求形成网络I/O事件交给I/O观察者
  3. 触发对应I/O事件的回调函数

经典服务器模型

  • 同步式
    • 一次只能处理一个请求
    • 易阻塞
  • 每进程/每请求
    • 为每个请求启动一个进程
    • 扩展性差
  • 每线程/每请求(Apache)
    • 为每个请求启动一个线程
    • 扩展性稍强
    • 大并发情况下服务器缓慢

事件驱动服务器模型(Node,Nginx)

事件驱动线程少,操作系统上下文切换代价低,
但需要注意:

  • 一旦事件循环中存在阻塞I/O,实际效果和同步式服务没有区别,性能会急剧下降