I/O模型、Libuv和Eventloop

3,011 阅读12分钟

一、I/O模型

①常见的IO模型:Linux(UNIX)操作系统中的网络IO模型为例

  1. Blocking I/O 同步阻塞IO
  2. Non-blocking I/O 同步非阻塞IO
  3. I/O Multiplexing IO多路复用
  4. Signal-blocking I/O 信号驱动IO
  5. Asynchronous I/O 异步IO

②基本概念的定义:

IO 指的是输入输出,通常指数据在内部存储器和外部存储器或其他周边设备之间的输入和输出。简而言之,从硬盘中读写数据或者从网络上收发数据,都属于IO行为。

  • IO:内存IO、网络IO和磁盘IO,通常我们说的IO指的是后两者。
  • 阻塞和非阻塞:在调用结果在返回之前,当前线程是否挂起,即发起IO请求是否会被阻塞。
  • 同步和异步:如果做阻塞I/O调用,应用程序等待调用的完成的过程就是一种同步状况。相反,I/O为非阻塞模式时,应用程序则是异步的。

③完成一次IO的过程: 以读一个文件为例,一个IO读过程是文件数据从磁盘→内核缓冲区→用户内存的过程。

同步与异步的区别主要在于数据从内核缓冲区→用户内存这个过程需不需要用户(应用)进程等待,即实际的IO读写是否阻塞请求进程。(网络IO可把磁盘换做网卡)


1、同步阻塞IO

阻塞 I/O是最简单的 I/O 模型,一般表现为进程或线程等待某个条件,如果条件不满足,则一直等下去。条件满足,则进行下一步操作。

应用进程通过系统调用 recvfrom 接收数据,但由于内核还未准备好数据报,应用进程就会阻塞住,直到内核准备好数据报,recvfrom 完成数据报复制工作,应用进程才能结束阻塞状态。


2、同步非阻塞IO

应用进程通过 recvfrom 调用不停的去和内核交互,直到内核准备好数据。如果没有准备好,内核会返回 error ,应用进程在得到 error 后,过一段时间再发送 recvfrom 请求。如果某一次轮询发现数据已经准备好了,那就把数据拷贝到用户空间中。在发送请求的时间间隔中,进程可以先做别的事情。


3、IO多路复用

IO多路复用是多了一个select函数,多个进程的IO可以注册到同一个select上,当用户进程调用该selectselect会监听所有注册好的IO,如果所有被监听的IO需要的数据都没有准备好时,select调用进程会阻塞。当任意一个IO所需的数据准备好之后,select调用就会返回,然后进程在通过recvfrom来进行数据拷贝。

这里的IO复用模型,并没有向内核注册信号处理函数,所以,他并不是非阻塞的。进程在发出select后,要等到select监听的所有IO操作中至少有一个需要的数据准备好,才会有返回,并且也需要再次发送请求去进行文件的拷贝。


4、信号驱动IO

应用进程预先向内核注册一个信号处理函数,然后用户进程返回,并且不阻塞,当内核数据准备就绪时会发送一个信号给进程,用户进程便在信号处理函数中开始把数据拷贝的用户空间中。


5、异步IO

应用进程发起aio_read操作之后,给内核传递描述符、缓冲区指针、缓冲区大小等,告诉内核当整个操作完成时,如何通知进程,然后就立刻去做其他事情了。当内核收到aio_read后,会立刻返回,然后内核开始等待数据准备,数据准备好以后,直接把数据拷贝到用户控件,然后再通知进程本次IO已经完成。


6、五种IO模型对比

阻塞IO模型、非阻塞IO模型、IO多路复用和信号驱动IO模型都是同步的IO模型,因为无论以上那种模型,真正的数据拷贝过程,都是同步进行的。


二、Libuv

libuv是一个高性能事件驱动库,屏蔽了各种操作系统的差异从而提供了统一的API。libuv严格使用异步、事件驱动的编程风格。其核心工作是提供事件循环及 基于I/O 或其他活动事件的回调机制。libuv库包含了诸如计时器、非阻塞网络支持、异步文件系统访问、线程创建、子进程等核心工具。

1、 句柄和请求

libuv给用户提供了两种方式与event loop一起协同工作,一个是句柄(handle)一个是请求(request)。

句柄(handle)代表了一个长期存在的对象,这些对象当处于活跃状态的时候能够执行特定的操作。例如:一个准备(prepare)句柄在活跃的时候可以在每个循环中调用它的回调一次。一个TCP服务器的句柄在每次有新的连接的时候都会调用它的连接回调函数。

请求(request)一般代表短时操作。这些操作能用作用于句柄之上。写请求用于在句柄上写数据;还有一些例外,比如说getaddrinfo请求不需要句柄而是直接在循环中执行。

2、 I/O循环

I/O循环或者叫做事件循环是整个libuv的核心部分。I/O循环建立了所有IO操作的执行环境,I/O循环会被绑定在一个线程之上。我们可以运行多个事件循环,只要每一个都运行在不同的线程之上。libuv事件循环不是线程安全的,所以所有包含事件循环的API及句柄都不是线程安全的。

事件循环遵循最普遍的单线程异步I/O方法:所有I/O或者网络操作在非阻塞的socket上执行,这个socket会使用基于平台的组好的poll机制:在linux上使用epoll,在OSX和其他BSD平台上使用kqueue,在sunOS上使用event ports,在windows上使用IOCP。作为循环迭代的一部分,循环会阻塞以等待socket上的I/O活动,这些活动已经被加到socket的触发实践中了,一旦这些条件满足,那么socket的状态就会发生变化,从而循环不再阻塞,而且句柄也可以读、写及执行其他期望的I/O操作。

更好的理解事件循环操作如何进行,下图展示了一个循环迭代的所有阶段。

文件 I/O 与网络 I/O 不同 ,并不存在 libuv 可以依靠的各特定平台下的文件 I/O 基础函数,所以目前的实现是在线程中执行阻塞的文件 I/O 操作来模拟异步。

注意:libuv利用线程池技术使得异步文件I/O操作称为可能,但是对于网络IO只能执行在一个单一线程中,即loop的线程中。


三、Event Loop

任务队列

异步任务分为task(宏任务,也可称为macroTask)和microtask(微任务)两类。 当满足执行条件时,task和microtask会被放入各自的队列中等待放入主线程执行,我们把这两个队列称为Task Queue(Macrotask Queue)和Microtask Queue。

MacroTask(宏任务)

script代码setTimeoutsetIntervalsetImmediate(浏览器IE10)MessageChannelI/OUI-Rendering

MicroTask(微任务)

Process.nextTick(Node独有)PromiseMutationObserverObject.observe(废弃)

1、 浏览器 E-L

JS调用栈采用的是先进后出原则,当函数执行的时候,会被添加到栈的顶部,当执行栈执行完成后,就会从栈顶移出,直到栈内被清空。

  • 执行栈在执行完同步任务后 ,查看执行栈是否为空,如果执行栈为空,就会去检查微任务microTask队列是否为空,如果为空的话,就执行Task(宏任务),否则执行微任务。
  • 每当单个宏任务执行完毕后 ,检查microTask队列是否为空,如果不为空,会按照 先入先出 原则全部执行microTask队列,设置microTask队列为null,然后再执行宏任务,如此反复。
console.log('script start');

setTimeout(function() {
  console.log('setTimeout');
}, 0);

Promise.resolve().then(function() {
  console.log('promise1');
}).then(function() {
  console.log('promise2');
});
console.log('script end');

// script start、script end、promise1、promise2、setTimeout

Another One

console.log('script start')

async function async1() {
  await async2()
  console.log('async1 end')
}
async function async2() {
  console.log('async2 end') 
}
async1()

setTimeout(function() {
  console.log('setTimeout')
}, 0)

new Promise(resolve => {
  console.log('Promise')
  resolve()
}).then(function() {
    console.log('promise1')
}).then(function() {
    console.log('promise2')
})

console.log('script end')
  • async/await 在底层转换成了 promisethen 回调函数。
  • 每次我们使用 await, 解释器都创建一个 promise 对象,然后把剩下的 async 函数中代码的操作放到 then 回调函数中。

关于Chrome73以下版本和73版本的区别

  • 在老版本版本以下,先执行promise1promise2,再执行async1。 script start、async2 end、Promise、script end、promise1、promise2、async1 end、setTimeout

  • 在73版中,先执行async1再执行promise1promise2。 script start、async2 end、Promise、script end、async1 end、promise1、promise2、setTimeout

主要原因是因为在谷歌73版本中更改了规范

2、 Node E-L

在Node中事件每一轮循环按照顺序分为6个阶段,来自libuv的实现:

   ┌───────────────────────┐
┌─>│        timers         │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     I/O callbacks     │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     idle, prepare     │
│  └──────────┬────────────┘      ┌───────────────┐
│  ┌──────────┴────────────┐      │   incoming:   │
│  │         poll          │<─────┤  connections, │
│  └──────────┬────────────┘      │   data, etc.  │
│  ┌──────────┴────────────┐      └───────────────┘
│  │        check          │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
└──┤    close callbacks    │
   └───────────────────────┘
timers:执行满足条件的setTimeout、setInterval回调。
I/O callbacks:是否有已完成的I/O操作的回调函数,来自上一轮的poll残留。
idle,prepare:可忽略
poll:等待还没完成的I/O事件,会因timers和超时时间等结束等待。
check:执行setImmediate的回调。
close callbacks:关闭所有的closing handles,一些onclose事件。

我们需要重点关心的是timerspollcheck这三个阶段。

1. timers 执行setTimeoutsetInterval中到期的callback,执行这两者回调需要设置一个毫秒数,理论上来说,应该是时间一到就立即执行callback回调,但是由于system的调度可能会延时,达不到预期时间。

2. poll 执行I/O回调 和 处理轮询队列中的事件。

① 如果 poll 队列不是空的,event loop 就会依次执行队列里的回调函数,直到队列被清空或者到达 poll 阶段的时间上限。

② 如果 poll 队列是空的,就会:

  1. 有 setImmediate 任务,event loop 就结束 poll 阶段去往 check 阶段。
  2. 没有 setImmediate 任务,event loop 就会等待新的回调函数进入 poll 队列,并立即执行它。

3. check 此阶段允许人员在poll阶段完成后立即执行回调。

setImmediate()实际上是一个特殊的计时器,它在事件循环的一个单独阶段运行。它是通过 libuv 里一个能将回调安排在 poll 阶段之后执行的 API 实现的。

在poll队列是空的 且有 setImmediate 任务的情况下,event loop 就结束 poll 阶段去往 check 阶段执行任务。

console.log('start')
setTimeout(() => {
  console.log('timer1')
  Promise.resolve().then(function() {
    console.log('promise1')
  })
}, 0)
setTimeout(() => {
  console.log('timer2')
  Promise.resolve().then(function() {
    console.log('promise2')
  })
}, 0)
Promise.resolve().then(function() {
  console.log('promise3')
})
console.log('end')

如果node版本为v11.x, 其结果与浏览器一致:

start
end
promise3
timer1
promise1
timer2
promise2

v10如果time2定时器已经在执行队列中结果为:

start
end
promise3
timer1
timer2
promise1
promise2

否则和第一个结果一致。

了解浏览器的eventloop可能就知道,浏览器的宏任务队列执行了一个,就会执行微任务。

简单的说,可以把浏览器的宏任务和node10timers比较,就是node10只有全部执行了timers阶段队列的全部任务才执行微任务队列,而浏览器只要执行了一个宏任务就会执行微任务队列。

node11保持和浏览器相同。


1. setImmediate && setTimeout

setImmediate和setTimeout是相似的,但根据它们被调用的时间以不同的方式表现。

setImmediate()设计用于在当前poll阶段完成后check阶段执行脚本 。 setTimeout()为最小(ms)后运行的脚本,在timers阶段执行。

setTimeout(() => {
  console.log('timeout');
}, 0);

setImmediate(() => {
  console.log('immediate');
});

// timeout,immediate
// immediate,timeout
const fs = require('fs');

fs.readFile('../file.txt', () => {
  setTimeout(() => {
    console.log('timeout');
  }, 0);
  setImmediate(() => {
    console.log('immediate');
  });
});
// immediate,timeout

2. Process.nextTick

process.nextTick()虽然它是异步API的一部分,但从技术上讲,它不是事件循环的一部分。

process.nextTick()方法将 callback 添加到next tick队列。 一旦当前事件轮询队列的任务全部完成,在next tick队列中的所有callbacks会被依次调用。

当每个阶段完成后,如果存在 nextTick 队列,就会清空队列中的所有回调函数,并且优先于其他 microtask 执行。

  Promise.resolve().then(() => console.log('Promise'));
  process.nextTick(() => console.log('nextTick'));
  // nextTick
  // Promise
setImmediate(() => {
  console.log('setImmediate1');
  setTimeout(() => {
    console.log('setTimeout1');
  }, 0);
});

setTimeout(() => {
  process.nextTick(() => console.log('nextTick'));
  console.log('setTimeout2');
  setImmediate(() => {
    console.log('setImmediate2');
  });
}, 0);

//结果1
// setImmediate1
// setTimeout2
// setTimeout1
// nextTick
// setImmediate2

// 结果2
// setTimeout2
// nextTick
// setImmediate1
// setImmediate2
// setTimeout1

JavaScript是单线程的,但Node本身其实是多线程的,除了用户代码无法并行执行外,所有的I/O请求是可以并行执行的。 事件循环是Node异步I/O实现的核心,Node通过事件驱动的方式处理请求,使得其无须为每个请求创建额外的线程,省掉了创建和销毁线程的开销。同时也因为线程数较少,不受线程上下文切换的影响,维持了Node的高性能。 Node异步IO、非阻塞的特性,使它非常适用于IO密集、高并发的应用场景。


参考文章: