《大前端进阶 Node.js》系列 异步非阻塞入门

4,272 阅读11分钟

前言

Coding 应当是一生的事业,而不仅仅是 30 岁的青春饭
本文已收录 GitHub https://github.com/ponkans/F2E,欢迎 Star,持续更新

每篇文章都希望你能收获到东西,这篇是讲 Node 异步非阻塞的原理,看完希望你有这些收获:

  • 阻塞、非阻塞本质与区别
  • 同步、异步本质与区别
  • Node 异步非阻塞本质

异步非阻塞

在提到 Node 的时候,异步非阻塞是一个经常被提及的话题,与之伴随的还有事件、回调、消息等等一系列词语。

看这些概念就像追一个渣女,你好像觉得自己很懂她,但有时候你又会觉得一无所知

本文将带大家层层剖析,自底向上的深入理解这些概念,让你看清这个渣女的真面目。

阻塞非阻塞

很多人会把非阻塞和异步混淆,这两个概念本身也确实有相似之处,但本质上肯定是不一样的,不然就不会被分成两个名词了。

我们先要思考的是阻塞是用来形容什么的?答案自然是进程。进程的五大状态:创建、就绪、运行、阻塞、终止。所以我们讲的阻塞非阻塞,一定是指进程。

明确了这一点之后,我们再来看这个概念,当一个进程在发起一个调用的时候,如果这个进程从运行态变成阻塞态,那就说明这是一次阻塞调用,反之就是非阻塞。

同步与异步

我们先搞清楚同步异步形容的是啥?我们常说的是某某方法是个异步方法,或者是某某调用是一种异步调用。可见同步异步形容的是某个调用的特性

何为同步?就是在发出一个功能调用时,在没有得到结果之前,该调用就不会返回。按照这个定义,其实绝大多数函数都是同步调用。

异步的概念和同步相对。当一个异步功能调用发出后,调用者不能立刻得到结果。当该异步功能完成后,通过状态、通知或回调来通知调用者。

这里有两个重要的点:

  • 第一是可以在得到结果前就直接返回,无需调用阻塞线程等待。
  • 第二就是能够在结束前主动的去通知主线程,并执行回调。

对于上述解释,可能有的小伙伴还是会有点迷茫,难以理清楚二者关系。别着急,往下看。

响水壶

关于上面的概念,网上有一个很经典的响水壶解释,怪怪在这里引申给大家,并谈谈自己的理解。


隔壁王大爷(不是隔壁老王,hhhhh~~)有个水壶,王大爷经常用它来烧开水。

王大爷把水壶放到火上烧,然后啥也不干在那等,直到水开了王大爷再去搞别的事情。(同步阻塞

王大爷觉得自己有点憨,不打算等了。把水壶放上去之后大爷就是去看电视,是不是来瞅一眼有没有开(同步非阻塞

王大爷去买了个响水壶,他把响水壶放在火上,然后也是等着水开,水开的时候水壶会发出声响(异步阻塞

王大爷又觉得自己有点憨,他把响水壶放在火上然后去看电视,这时他不用是不是来瞅一眼,因为水开的时候水壶会发出声音通知大爷。(异步非阻塞

上面四个栗子里,阻塞非阻塞说明的是大爷的状态,同步非同步说明的是水壶的调用姿势。水壶能在烧好的时候主动响起,就等同于我们异步的定义,能在结束时通知主线程并且回调。所以异步一般配合非阻塞,才能发挥其作用

阻塞 IO 与 非阻塞 IO

有了上面王大爷的启发,大家对一些基本的概念或许有了认知,那我们来进一步讨论下非阻塞 IO 与异步 IO。

阻塞 IO

阻塞 IO 如同其名字,主线程会在调用 IO 方法时进入阻塞态,直到 IO 结果返回,再继续运行,相当于需要整个操作全部结束了,调用才会返回。

首先要知道读一个磁盘的开销,读磁盘涉及到磁盘寻道,在对应扇区读取数据,然后把数据放在内存等一系列操作。所以阻塞 IO 必然是会被取代的,具体可以看下面这张图。

阻塞IO
阻塞IO
非阻塞 IO

非阻塞 IO 的特点与阻塞相对,在操作系统发起 IO 调用之后,可以先不带数据直接返回,这样主线程不会被阻塞,然后操作系统来处理读磁盘这一系列操作,而不需要主进程被阻塞,这就是我们所说的非阻塞 IO 了。


这里可以顺便提一下,现在大多数 IO 设备都支持DMA(Direct Memory Access,直接存储器访问),DMA 的意义在于可以解放 IO 时处理器的压力,CPU 只需要 DMA 控制器初始化,并向 I/O 接口发出操作命令,I/O 接口提出 DMA 请求,然后在存储器和外部设备之间直接进行数据传送,在传送过程中不需要中央处理器的参与,这段时间 CPU 可以去执行别的任务。


回到我们的非阻塞 IO,他的好处显而易见,进程不用等待函数返回,可以做做别的事情。

但也有一个明显的缺陷,我们想要读取的时候在函数返回时并没有就位。个人的理解是,如果你接下来的操作马上就强依赖 IO 的数据,那阻塞与否并无区别。

如果你接下来的操作并非强依赖,那可以先把非强依赖的程序执行了,再去看 IO 有没有好,这样 cpu 等待 IO 这段时间就可以被利用起来。非阻塞 IO 大致过程如下图。

PS:这里的阻塞和非阻塞和之前的观点一致,看的是发起调用的进程有没有阻塞。

非阻塞IO
非阻塞IO

轮询技术演进

上面讲到了一个关键的点,非阻塞 IO 的时候,我们想要读取的时候在函数返回时并没有就位。

就像那个烧水的大爷,在他没有响水壶的时候,他虽然一边烧水一边看电视,但他是不是也要去看一下水到底有没有开。

我这里也一样,我们无法预知数据什么时候好,所以我们也要去主动的探查 IO 数据是否就位,因为是我们主动的探查,那可以确定的就是,轮询技术并非异步,他并不是一个响水壶


这里需要帮大家梳理清楚一个细节,可能大家经常能听到很多 IO 相关的名词,比如 recv,select, epoll, kqueue 等等,但对他们可能没有很直观的认知。我们要读取一个文件,是分为两步的。

首先是去读取文件,然后是获取读取的结果。

读取文件需要调用的是 recv,recv 可以根据参数来决定是否阻塞,我们所讨论的非阻塞 IO,只是在读取这一步,而 select、epoll 都是第二步(获取结果)做的事情,他们是阻塞的方法。大家切莫把两个步奏混为一谈。

结下来我们来捋一下我们的轮询技术。

初代:read

read 是最原始的轮询方式,read 本身就是读取文件的方法,在 C++的调用里面,如果设置了 NONBLCOK 属性,那就会立即返回,但返回的值是-1。

简单写了个 C 的 read 供大家参考,加深下理解。先是阻塞 read。

阻塞IO
阻塞IO

那么之后我们需要这个 IO 结果的时候咋办呢?只能不断的调用 read 方法,直到他的返回不是-1 为止,然后从传入的一个 char[] 中拿文件数据,代码大致如下图。

非阻塞IO
非阻塞IO

流程大致如下图,虽然在发起 IO 的时候非阻塞了,但其弊端显而易见,他需要在获取的时候不断的去轮询,这里会很耗费 cpu,如果这是一个多核机器可能还好,如果是单核,那一个 cpu 被这些没有意义的轮询耗在这里,就很憨(怕不是个铁憨憨吧)。

非阻塞IO
非阻塞IO
进阶:select

之前我们讲了 read,read 的弊端是很明显的,需要不断轮询,除此之外我们只能监听一个文件,比如我需要读两个文件,那意味着我会调用两次 read 方法,这个时候我想获取结果的话就需要先轮询一个,等那个返回了,再轮询另一个。

你可能会说,我在一个 while(True)里面写两个不断调用 read 的代码不就行了。

那我们如果要读 10 个文件?100 个文件?你要写 100 个吗?答案肯定是 NO。针对不断的空轮询问题,和多文件监听问题,操作系统给出了更优的解决方案,select 调用。

我们在发起 read 操作之后,能拿到一个 fd(文件描述符),对文件描述符不理解的小伙伴可以去翻一下怪怪之前写的《大前端进阶 Node.js》系列 多进程模型底层实现,里面有详细描述。

如果我们发起 100 次 read 调用,那就会有 100 个 fd,select 可以批量监听文件描述符,我们在调用 select 方法的时候,当前进程进入阻塞状态(注意,之前讲的非阻塞 IO 是 read 调用,select 是阻塞调用)。

当监听的这一批文件描述符里,有属于某个文件描述符的 IO 操作结束的时候,操作系统会发起中断,中断程序做的事情很简单,唤醒阻塞的进程。

这个时候意味着某个文件描述符的数据已经就绪了,但问题是哪一个呢?母鸡。咋办呢?轮询。把所有监听的 fd 扫一遍,取出就绪的文件描述符,读取响应数据。

这样的话,之前两个问题就得到了解决,轮询消耗 cpu 问题通过阻塞进程,中断唤醒来解决,多文件监听问题通过 select 的多句柄监听特性来搞定。

大致流程如下图。

演进:epoll

select 解决了大多数的问题,但却带来了新的问题,如上面描述的一样,进程在被唤醒的时候一脸迷茫,是谁唤醒的我??他要一个一个看。

如果文件过多,这种遍历对性能的影响是很大的,所以 select 设计之初便规定监听的文件描述符是有上限的,一般是 1024 个。

为了解决 select 留下的坑,诞生了我们现在用的最广泛的 epoll。

epoll 最关键的优化点在于,引入了一个介于进程和 fd 之间的东西:eventpoll

在有 IO 结束的时候,中断程序不是直接唤醒进程,而是会先把 IO 就绪的文件描述符放在 eventpoll 里面,后续在进程被唤醒后,不需要轮询整个 fd 列表,只需要在 eventpoll 里面拿就绪的文件描述符即可。

以上就是目前主流轮询技术的演进过程了~