Skip to content

3异步I/O

导论

  • 异步早就存在于操作系统的底层,在底层系统中, 异步通过信号量、 消息等方式有了广泛的应用
  • 在众多高级编程语言或运行平台中, 将异步作为主要编程方式和设计理念的, Node是首个

1、为什么要异步I/O

更快地影响资源,响应时间Max(M,N),前端体验更好 更合理的利用后端的资源

  • 1、用户体验(前端),
    • JavaScript在单线程上执行,与UI渲染共用一个线程
    • 前端通过异步可以消除掉UI阻塞的现象
    • 更快地响应资源,消除阻塞的影响,
  • 2、资源分配(后端)
    • 单线程同步编程模型会因阻塞I/O导致硬件资源得不到更优的使用。
    • 多线程编程模型也因为编程中的死锁、 状态同步等问题让开发人员头疼
    • Node在两者之间给出了它的方案:
      • 利用单线程, 远离多线程死锁、 状态同步等问题;
      • 利用异步I/O, 让单线程远离阻塞,同时使用子进程,以更好地使用CPU。

2、异步I/O实现现状

计算机内核层面如何I/O,阻塞和非阻塞,如何实现底层的异步高效查询

  • 实际效果而言,异步和非阻塞都达到了我们并行I/O的目的。
  • 但是从计算机内核I/O而言, 异步/同步和阻塞/非阻塞实际上是两回事
  • 阻塞I/O vs 非阻塞I/O
    • 阻塞I/O -> 等待
    • 非阻塞I/O -> 立即返回 -> 是状态 -> 轮询来获取完整数据
    • 轮询有很多种,最终采用epoll,但是依旧有问题,使用自定义线程池完成
    • 技术发展
      • linux
        • Linux下效率最高epoll技术(事件通知、回调,不是遍历查询,不浪费cpu)
        • 但是对于应用程序而言(应用程序在上层,不知道底层具体发生了什么,因为还是在等),它仍然只能算是一种同步
        • epoll休眠期间CPU几乎是闲置的,等于说其他的底层调用,依旧需要等待
        • 只需在I/O完成后通过信号或回调将数据传递给应用程序即可
        • AIO 支持异步,但是有缺陷
        • libeio(采用线程池与阻塞I/O模拟异步I/O) -> 最终:自定义线程池来完成异步I/O
      • windows
        • IOCP
        • 内部其实仍然是线程池原理, 不同之处在于这些线程池由系统内核接手管理
        • 与Node的异步模型十分接近
    • js这一层是单线程,但是底层是多线程
      • 相对而言,Node是单线程的, 这里的单线程是JavaScript执行在单线程中。
      • 在Node中, 无论是*nix还是Windows平台, 内部完成I/O任务的另有线程池。

3、Node的异步I/O

Node的执行模型:事件循环、观察者和请求对象

IOCP底层如何实现的思路

  • 事件循环
    • 类似while(true)的循环
    • -> Tick(执行一次循环体)
    • -> 查看是否有事件待处理
    • -> 如果有,就取出事件及其相关的回调函数,并执行回调
    • -> 进入下个循环,是否还有其他事件
    • -> 如果没有,退出进程
  • 观察者
    • 在每个Tick的过程中,是否有事件需要处理呢?
    • 每个事件循环中有一个或者多个观察者,
    • 而判断是否有事件要处理的过程就是向这些观察者询问是否有要处理的事件
    • 循环 - 做菜
    • 观察者 - 服务台小妹
    • 客人点单 - 回调
    • 收到下单 - 就是一个事件
    • 事件循环 - 典型的生产者/消费者模型
      • 异步I/O、 网络请求等则是事件的生产者, 源源不断为Node提供不同类型的事件,
      • 这些事件被传递到对应的观察者那里,
      • 事件循环则从观察者那里取出事件并处理
  • 请求对象
    • IOCP实现
    • 发出调用后, 到回调函数被执行, 中间发生了什么呢?请求对象
    • js代码 -> fs.open() -> c++核心代码 -> 核心模块 -> 内建模块 -> libuv调用 ->
    • uv_fs_open()调用过程中创建:FSReqWrap请求对象(js层的参数和当前方法都被封装在上边)
    • 回调函数 -> 放在oncomplete_sym属性上 ->
    • 调用QueueUserWorkItem(),将请求对象推入线程池等待执行
      • 第一个参数:uv_fs_thread_pro,第二个参数:所需参数(请求对象)
      • uv_fs_thread_proc()方法会根据传入参数的类型调用相应的底层函数。 以uv_fs_open()为例,实际上调用fs__open()方法。
    • JavaScript调用立即返回, 由JavaScript层面发起的异步调用的第一阶段就此结束
  • 执行回调
    • 线程池中的I/O操作调用完毕之后,
    • 会将获取的结果储存在req>result属性上
    • 调用PostQueuedCompletionStatus()通知IOCP, 告知当前对象操作已经完成
    • PostQueuedCompletionStatus作用是向IOCP提交执行状态, 并将线程归还线程池
    • 提交的状态, 可以通过GetQueuedCompletionStatus()提取
    • 还动用了事件循环的I/O观察者
      • 在每次Tick的执行中,
      • 它会调用IOCP相关的GetQueuedCompletionStatus()方法检查线程池中是否有执行完的请求
      • 如果存在,会将请求对象加入到I/O观察者的队列中,然后将其当做事件处理
      • I/O观察者回调函数的行为就是
      • 取出请求对象的result属性作为参数,
      • 取出oncomplete_sym属性作为方法,
      • 然后调用执行,以此达到调用JavaScript中传入的回调函数的目的。

4、非I/O的异步API

主讲setTimeout/setInterval/setImmediate/process.nextTick()区别

  • setTimeout/setInterval
    • 实现原理与异步I/O比较类似, 只是不需要I/O线程池的参与
    • 创建的定时器会被插入到定时器观察者内部的一个红黑树中
    • 每次Tick执行时, 会从该红黑树中迭代取出定时器对象, 检查是否超过定时时间
    • 如果超过,就形成一个事件,它的回调函数将立即执行
    • setInterval是重复性的检测和执行
    • 不足:
      • 非精确,一次循环占用时间过多,产生阻塞,那么就会超时
      • 定时器需要动用红黑树,创建定时器对象和迭代等操作,而setTimeout(fn, 0)的方式较为浪费性能
  • process.nextTick()
    • 优点:操作相对较为轻量
    • 每次调用process.nextTick()方法,只会将回调函数放入队列中
    • 定时器中采用红黑树的操作时间复杂度为O(lg(n)), nextTick()的时间复杂度为O(1)
  • setImmediate
    • process.nextTick()优先级要高于setImmediate()
    • 观察者顺序
      • 在于事件循环对观察者的检查是有先后顺序的,
      • process.nextTick()属于idle观察者,
      • setImmediate()属于check观察者
      • idle先于I/O,I/O先于check
    • 存储类型
      • process.nextTick()的回调函数保存在一个数组中,
      • setImmediate()的结果则是保存在链表中

5、事件驱动与高性能服务器

  • Node
    • 对于网络套接字的处理, Node也应用到了异步I/O,
    • 网络套接字上侦听到的请求都会形成事件交给I/O观察者。
  • Apache
    • 每线程/每请求的方式目前还被Apache所采用
  • Nginx
    • Nginx采用纯C写成,性能较高,但是它仅适合于做Web服务器,
    • 用于反向代理或负载均衡等服务,在处理具体业务方面较为欠缺
  • 其他平台
    • 没有成功异步I/O
    • 同步I/O库的存在,异步不是主流,及时实现也未必能流行起来
    • 异步I/O实现,其主旨是使I/O操作与CPU操作分离
    • Tim Caswell将Node的这套思想重新移植到了Lua平台, 该项目叫luavit。