Nodejs 异步I/O

        在了解异步之前,我们需要先来说一下同步,同步的概念是我们最为常见的,也是使用最多的,因为其编程思想符合程序员编程的顺序。但是同步式编程有一个很明显的缺陷,那就是同步阻塞,多线程模式也会有死锁,状态同步等的问题,因此,异步编程便随之而出,解决了同步阻塞的问题,并且使用单线程避免了死锁的问题。切记,单线程指的是js代码的执行是单线程,而Node本身是多线程。让我们进一步了解 异步编程的好处吧!

一. 为什么要异步 I/O

1. 运行时间

  • 同步写法
// 同步写法
getData('A'); // 消耗 M ms
getData('B'); // 消耗 N ms

// 总消耗: M + N
  • 异步写法 
getData('A', data =>{
    // 消耗 M ms
});
getData('B', data =>{
    // 消耗 N ms
});

// 总消耗: Max(M, N)

        从执行时间上我们就直接感受到了异步的强大,同步执行时间是各个资源消耗的总和,而异步编程执行时间是一个资源消耗的最长时间。

2. 资源分配

假设业务场景有一组互不相关的任务需要完成,现行的主流方法有两种:

  • 单线程穿行依次执行:单线程顺序执行方式比较符合编程人员按顺序思考的思维方式。但是性能十分低下,很容易造成代码阻塞。
  • 多线程并行完成:如果创建多线程的开销小于并行执行,那么多线程一定是首选,多线程的代价在于创建线程和执行期上下文切换开销较大。另外在复杂业务中,多线程编程经常面临锁、状态同步等问题,但是多线程在多核CPU上能够有效提升CPU的利用率。

而异步执行期望 I/O 调用不在阻塞后续运算,执行流程如下:

如上是我们期待的运行流程,但是具体应该怎么实现呢?我们继续往下看。

二. 异步 I/O 实现现状

1. 异步I/O与非阻塞I/O

操作系统对于I/O只有两种方式:阻塞与非阻塞。

阻塞I/O特性:调用之后一直等待系统内核完成所有操作,在此期间一直等待数据返回,大大浪费了CPU的性能资源。如果所示:

非阻塞I/O:调用后立即返回,CPU 的时间片可以用来处理其他事物,此时性能提升很明显的,

但是其也有缺点,虽然立即返回,但是该返回并没有拿到处理过的数据,只是拿到了一个状态,因此还得考虑,应用层如何知道数据处理完了,因此就需要接触一个新的只是,叫轮询

 轮询

        轮询也就是说应用层要不断去确认数据是否处理完成,可以拿回来使用,因此,频繁判断也造成成了CPU资源的浪费,我们来看一下轮询的发展:

  • read:它是最初始、性能最低的一种,通过重复调用I/O的状态来完成完整数据的读取,在得到最终数据之前,cpu 一直处于等待上
  • select:它是在 read 的基础上进行了改进的方案,通过文件描述符上的事件状态来进行判断。
  • poll:该方案较 select 有所改进,采用的方式避免数组长度的限制,其次他能避免不需要的检查。
  • epoll:该方案是Linux下效率最高的I/O事件通知机制,在进入轮询时候如果没有检查到 I/O 事件,将会进行休眠,直到事件发生将它唤醒。它是真正的利用了事件通知、执行回调的方式,而不是进行遍历查询,所以不会浪费CPU,执行效率高,工作原理如果所示:

  • kqueue:该方案实现与 epoll 类似,不过仅在FreeBSD系统下存在。 

         轮询技术满足了获取完整数据的需求,但是仍然算是一种同步,因为应用程序仍需要等待I/O完全返回,依旧花时间来等待。因此这种方式还是不够好。

2. 理想的非阻塞异步I/O

        理想的实现是应用层调用异步方法后,直接进行其他操作,等数据完整拿到后再去执行回调,理想的事件执行流程图如下:

3. 现实的异步I/O

         现实往往要比理想骨干一些,通过让部分线程进行阻塞I/O或者非阻塞I/O加轮询技术来完成数据获取,让一个线程进行计算处理,通过线程之间的通信将I/O得到的数据进行传递,这就实现了异步I/O。

  • *uix下

        采用了 libeio 配合 libev 实现的I/O 部分,实现了异步IO,在Node v0.9.3 中,自行实现了线程池来完成异步I/O。

  • window 下的 IOCP

        它在某种程度上提供了理想的异步I/O:调用异步方法,等到I/O完成后的通知,执行回调,无需进行轮询。其内部是由线程池原理,不同之处在与这些线程池由系统内核接受管理。

  • 二者兼容

        node 提供了 libuv 作为抽象封装层,使得所有平台兼容新的判断都由这一层来完成,并保证深层的Node 与下层自定义的线程池及IOCP 之间的独立。Node 在编译期间会判断平台的条件,选择性编译 unix 目录或是 win 目录下的源文件到目标程序中去,架构如图所示:

 三. Node 的异步I/O

        node 实现异步I/O有使事件循环、观察者模式和请求对象等。

1. 事件循环

        原理:在进程启动时,Node 便会创建一个类似于while(true)的循环,每执行一次循环体过程我们称之为一个Tick。每个 Tick 的过程就是查看是否有事件待处理,如果有就取出事件及其相关的回调函数。如果存在关联的回调函数,就执行它们。然后进入下一个循环,如果不再有事件处理,就退出流程。

2. 观察者 

        观察者的作用就是用来判断是否有事件要处理。浏览器采用了类似的机制,事件可能来自用户的点击或者加载某些文件时产生,而这些事件都有各自对应的观察者。在 Node 中,事件主要来源于网络请求、文件I/O等,这些事件对应的观察者有文件I/O观察者、网络I/O观察者等。观察者将事件进行了分类。

        事件循环是一个典型的生产者/消费者模型。异步I/O 、网络请求等则是事件生产者,源源不断为Node 提供不同类型的事件,这些事件被传递到对应的观察者那里,事件循环则从观察者那里取出事件并处理。

3. 请求对象

        我们的普通函数由开发者自行调用,而回调函数是由系统直接调用,那么从我们发出调用后,到回调函数执行,中间发生了什么呢?从 javascript 发起调用到内核执行完 I/O 操作的过渡过程中,存在一种中间产物,就是请求对象

        首先 JS 在调用回调之后,会调用C++ 内建模块,由 C++ 完成后续操作,创建一个 FSReqWrap 请求对象。从 JS 中传入的参数和当前方法都被封装在这个请求对象中,其中,回调函数被设置在这个对象的 oncomplete_sym 属性上:

req_wrap -> object_ -> Set(oncomplete_sym, callback);

对象包装完毕后,在 windows 下,则调用QueueUserWorkItem() 方法将这个 FSReqWrap 对象推入线程池中等待执行,该方法代码如下所示:

QueueUserWorkItem(&uv_fs_thread_proc, req, WT_EXECUTEDEFAULT);

该方法接受三个参数,第一个是将要执行的方法的引用,第二个是第一个方法的参数,第三个参数是执行的标志。

至此 JS 调用立即返回,由 JS 层面发起的异步调用第一阶段就此结束,JS 可以执行后续操作,后续操作交给了其他线程,这样就达到了异步的目的。

        请求对象是异步I/O 过程中重要的中间产物,所有的状态都保存在这个对象中,包括送入线程池等待执行以及I/O操作完毕后的回调处理。 

4. 执行回调

        线程中的I/O操作调用完毕之后,会将获取的结果储存在 req-> result 属性上,然后调用 PostQueuedCompletionStatus() 通知 IOCP,告知当前对象操作已经完成:

PostQueuedCompletionStatus((loop)->iocp, 0, 0, &((req)->overlapped));

 PostQueuedCompletionStatus()方法的作用就是向IOCP 提交执行状态,并将线程归还线程池。通过该方法提交的状态,可以通过 GetQueuedCompletionStatus() 提取。

        在这个过程中我们还动用了事件循环的I/O 观察者。在每次Tick的执行中,他会调用IOCP相关的GetQueuedCompletionStatus() 方法检查线程池中是否有执行完的请求,如果存在,会将请求对象加入到I/O观察者的队列中,然后将其当作事件处理。

        IO 观察者回调函数的行为就是取出请求对象的 result 属性作为参数,取出 oncomplete_sym 属性作为方法,然后调用执行,以此达到调用JS中传入回调函数的目的。

        至此,整个异步IO 调用流程全部结束。

小结:事件循环、观察者、请求对象、IO 线程池共同构成了Node异步IO模型的基本要素。在Node中,除了 JS 是单线程的,Node自身是多线程,只是IO线程使用的CPU较少。另一个需要知道的是,除了用户代码无法并行执行,所有的IO都是可以并行执行的。

四. 非 I/O 的异步API

        除了异步I/O外,还有一些与I/O 无关的异步API,他们分别是setTimeout()、setInterval()、setImmediate() 和 process.nextTick()。

1. 定时器

        与异步I/O 不同的是它没有线程池的参与,调用定时器后,将回调插入到定时器观察者内部的一个红黑树中。每次 Tick 执行,会从该红黑树中迭代取出的定时器对象,检查是否超过定时时间,如果超过,就形成一个事件,它的回调函数立即执行。

        明白这个后,我们就明白了为什么定时器执行为什么每次都有偏差。尽管事件循环十分快,但是如果某次定时器还有1ms 超过定时,因此不会执行,而下一个循环占用的时间较多,那么下次轮到这个事件时,已经超时很久了,所以就会与我们预期设置的时间有偏差。

2. process.nextTick()

有时候我们也许需要立即执行一个异步任务,会这样调用 

setTimeout(()=>{}, 0);

 由于事件循环自身特点,定时器的精确度不够。除此之外还用动用红黑树,创建定时器对象和迭代等操作,十分浪费性能。而process.nextTick() 方法相对轻量,将回调放入一个队列中,在下一次Tick 时取出来执行。采用定时器红黑树时间复杂度为O(lgn),nextTick 的时间复杂度为O(1),相比之下,nextTick 更高效。

3. setImmediate()

        该方法同 process.nextTick() 相同,都是异步执行,但是 setImmediate () 是将回调保存在链表中,而process.nextTick 是保存在数组中,并且process.nextTick 每次循环会将数组中的回调函数全部执行完,而 setImmediate 只执行一个,且 process.nextTick 的执行优先级更高。


setImmediate(()=>{
    console.log('setImmediate 执行1');
    process.nextTick(()=>{
        console.log('process.nextTick 强行插入')
    })
    
})
process.nextTick(()=>{
    console.log('process.nextTick 执行1')
   
})
setImmediate(()=>{
    console.log('setImmediate 执行2');

    
})

process.nextTick(()=>{
    console.log('process.nextTick 执行2')
})

console.log('正常执行');

执行结果:

 五. 事件驱动与高性能服务器

        Node 异步同时用在了网络套接字的处理,将请求形成事件交给I/O观察者。事件循环会不停的处理这些网络I/O事件。因此不会形成请求堵塞,成就了高性能服务器。因此在大型项目中,我们经常用Node 作为中间层,去处理这些大量的请求,会大幅提升一个程序的服务质量。

六. 总结

        事件循环是实现异步的核心,它与浏览器中的执行模式保持一致,Node 正是依靠构建了一套完整的高性能异步I/O框架,打破了javascript 在服务器端止步不前的局面。

  • 3
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

程序员-石头山

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值