异步I/O

作者: jluemmmm | 来源:发表于2020-09-19 18:14 被阅读0次

    为什么要用异步I/O?

    单线程同步编程会引阻塞I/O导致硬件资源得不到更优使用,多线程编程会出现死锁、状态同步等问题,node的解决方案:利用单线程,避免多线程死锁、状态同步问题,利用异步I/O,让单线程远离阻塞,更好使用CPU。为了弥补单线程无法利用多核CPU的缺点,node提供了类似前端浏览器中webworker的子进程,子进程可以通过工作进程高效利用CPU和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造成CPU等待I/O,浪费等待时间,CPU的处理能力不能得到充分利用,为了提高性能,内核提供了非阻塞I/O,非阻塞I/O与阻塞I/O的差别是调用后会立即返回。

    非阻塞I/O

    非阻塞I/O不带数据直接返回,要获取数据,通过文件描述符再次读取。非阻塞I/O,完整的I/O没有完成,应用程序通过轮询重复调用I/O操作确认是否完成。现有的轮询技术有

    • read :通过重复调用I/O的状态完成完整数据的读取,在得到最终数据前,CPU一直耗时在等待上。
    • selectread基础上的改进,通过对文件描述符上的事件状态进行判断。采用一个1024长度的数组存储状态,最多可以同时检查1024个文件描述符。
    • poll: 采用链表的方式避免数组长度的限制,避免不需要的文件检查,当文件描述符较多时,性能较为低下。
    • epolllinux下效率最高的事件通知机制,进入轮询时如果没有检查到I/O事件,将会进行休眠,直到事件发生。利用了事件通知、执行回调的方式,不是遍历查询,不会浪费CPU,执行效率高。
    epoll方式实现轮询

    异步I/O

    node的单线程,是指javascript执行在单线程中,node中,无论是*nix还是windows平台,内部完成I/O任务另有线程池。部分线程进行阻塞I/O或非阻塞I/O加轮询技术完成数据获取,一个线程进行计算处理,通过线程之间的通信将I/O得到的数据进行传递。

    windows系统采用IOCP实现异步I/O,调用异步方法,等待I/O完成之后的通知,执行回调,用户无需考虑轮询,内部实现仍是线程池原理。*nix使用自定义线程池实现异步I/O。

    node提供libuv做为抽象层封装,兼容windows平台和*nix平台的差异,保证上层的Node与下层的自定义线程池及IOCP之间独立,Node在编译期间判断平台条件,选择性编译unix目录或是win目录下的源文件到目标程序中。

    基于libuv的架构示意图

    windows系统下的异步I/O调用

    1. windows系统下的异步I/O调用,以fs.open()方法为例,js调用node核心模块,核心模块调用C++内建模块,内建模块通过libuv实现系统调用。

    2. 系统调用实质是调用各个平台下的uv_fs_open方法,在uv_fs_open调用过程中,创建FSReqWrap请求对象(请求对象是异步I/O过程中重要的中间产物,所有状态都保存在这个对象上,包括送入线程池等待执行以及I/O操作完毕后的回调处理),从js层传入的参数和当前方法都被封装在请求对象中,回调函数被设置在对象的oncomplete_sym属性上,对象包装完毕后,在windows系统下,调用 QueueUserWorkItem 方法将FSReqWrap对象推入线程池中等待执行

    QueueUserWorkItem(&uv_fs_thread_proc, req, WT_EXECUTEDEFAULT)
    

    [ QueueUserWorkItem方法接受三个参数,第一个参数是将要执行的方法的引用,这里引用的是uv_fs_thread_proc,第二个参数是uv_fs_thread_proc运行时所需的参数,第三个参数是执行的标志,当线程池中有可用线程时,调用uv_fs_thread_proc方法,uv_fs_thread_proc方法根据传入参数的类型调用相应的底层函数,以uv_fs_open为例,实际上调用fs__open方法 ]

    1. 至此,js调用立即返回,由js层面发起的异步调用第一阶段结束,js线程可以继续执行当前任务的后续操作,当前的I/O操作在线程池中等待执行。
    fs.open调用示意图
    1. 线程池中的I/O操作调用完毕后,会将获取的结果储存在req->result属性上,然后调用PostQueuedCompletionStstus(该事件提交的状态可以通过GetQueuedCompletionStstus获取)通知IOCP,告知当前对象操作已经完成,将线程归还线程池。

    2. 在这个过程中,使用了事件循环的I/O观察者,每个tick的执行过程中,会调用IOCP相关GetQueuedCompletionStstus 检查线程池中是否有执行完的请求,如果存在,会将请求对象加入到I/O观察者的队列中,将其当作事件处理。

    3. I/O观察者回调函数的行为就是取出请求对象的result属性作为参数,取出oncomplete_sym属性作为方法,然后调用执行,达到调用js中传入回调函数的目的。

    整个异步I/O的流程

    windows下主要通过IOCP向系统内核发送I/O调用和从内核中获取已完成的I/O,配合事件循环,完成异步I/O,linux通过epoll实现,线程池在windows下由内核IOCP直接提供,*nix下由libuv自行实现。在node中,除了js是单线程的,node自身是多线程的,除了用户代码无法并行执行外,所有的I/O可以并行起来。

    非I/O的异步API

    • 定时器
      setTimeoutsetInterval 与浏览器中的API是一致的,分别用于单次和多次执行定时任务,实现原理与异步I/O比较相似,但是不需要I/O线程池的参与。调用setTimeout或setInterval创建的定时器被插入到定时器观察者内部的一个红黑树中,每个tick执行时,会从该红黑树中迭代取出定时器对象,检查是否超过指定时间,回调函数立即执行。
    • process.nextTick
      立即执行一个异步任务,调用setTimeout(fn, 0)实现效果,采用定时器需要使用红黑树,创建定时器对象和迭代等操作,较为浪费性能,而process.nextTick方法的操作较为轻量。每次调用process.nextTick方法,会将回调函数放入队列中,在下一轮Tick时取出执行,定时器中采用红黑树时间复杂度为O(lg(n)),nextTick的时间复杂度为O(1)
    process.nextTick = function(callback){
      if(process._exiting) return
      if(tickDepth >= process.maxTickDepth) maxTickWarn()
      var tock = { callback: callback }
      if(process.domain) tock.domain = process.domain
      newxtTickQueue.push(tock)
      if(nextTickQueue.length) process.needTickCallback()
    }
    
    • 执行优先级
      process.nextTick 中的回调函数执行的优先级高于setImmediate,事件循环对于观察者的检查是有先后顺序的,process.nextTick属于idle观察者,setImmediate 属于check观察者,在每一轮循环检查中,idle观察者先于I/O观察者,I/O观察者先于check观察者。
    process.nextTick(function () {
      console.log('nextTick1执行');
    });
    setImmediate(function () {
      console.log('setImmediate执行');
    });
    process.nextTick(function () {
      console.log('nextTick2执行');
    });
    console.log('正常执行');
    
    正常执行
    nextTick1执行
    nextTick2执行
    setImmediate执行
    

    具体实现上,process.nextTick 的回调函数保存在一个数组中,setImmediate 结果保存在链表中,process.nextTick在每轮循环会将数组中的回调函数全部执行,setImmediate 在每轮循环中执行链表中的一个回调函数。

    process.nextTick(function () {
      console.log('nextTick􁃽􀗿执行1');
    });
    process.nextTick(function () {
      console.log('nextTick􁃽􀗿执行2');
    });
    setImmediate(function () {
      console.log('setImmediate􁃽􀗿执行1');
      process.nextTick(function () {
        console.log('􀴽势􀖭入');
      });
    });
    setImmediate(function () {
      console.log('setImmediate􁃽􀗿执行2');
    });
    console.log('正常执行');
    
    
    
    v12.18.3执行结果
    
    正常执行
    nextTick延迟执行1
    nextTick延迟执行2
    setImmediate延迟执行1
    强势插入
    setImmediate延迟执行2
    
    
    v8.9.4执行结果
    
    nextTick延迟执行1
    nextTick延迟执行2
    setImmediate延迟执行1
    setImmediate延迟执行2
    强势插入
    
    

    第一个setImmediate回调函数执行后,并没有执行第二个,而是进入下一轮循环,再次按process.nextTick优先,setTimmediate后的顺序执行,保证每轮循环能够较快执行结束,防止CPU过多阻塞后续I/O调用情况

    事件驱动与高性能服务器

    文件操作和网络套接字的处理都用到了异步I/O,网络套接字上侦听不到的请求,形成事件交给I/O观察者,事件循环会持续处理网络I/O事件,如果js有传入回调函数,事件最终传递到业务逻辑层进行处理。利用node构建web服务器是在此基础上实现的。

    node没有采用经典的服务器模型,包括 同步式、每进程/每请求、每线程/每请求,node通过事件驱动的方式处理请求,无需为每一个请求创建额外的对应线程,可以省掉创建线程和销毁线程的开销,同时操作系统在调度任务时因为线程较少,上下文切换的代价很低。在大量连接的情况下,不受线程上下文切换开销的影响。nginx采用事件驱动机制,由C语言实现,性能较高,适合做web服务器,用于反向代理或负载均衡等服务。

    相关文章

      网友评论

          本文标题:异步I/O

          本文链接:https://www.haomeiwen.com/subject/vexjyktx.html