异步IO是什么?

什么是异步IO

从API上来说,是一组非阻塞的IO API,不过这是废话。

换个角度:程序不因为IO调用而被阻塞,就可以说程序是异步的。

要理解这个回答,首先要知道什么是“异步”。

编程或者架构模型有4种。

1. 阻塞

A调用B后,一直等着B返回结果。

这是最广泛使用,也是最简单的一种模型。普通的函数调用、传统的阻塞IO都是如此。

2. 轮询

A调用B后,A不断去B那里查询返回结果。

这在耗时任务中经常出现。比如一种资源的创建非常耗时,服务A通知服务B创建,B返回给A一个任务id或者资源id,A不断轮询B检查任务是否完成以及完成结果。这种也非常常见。在架构设计中,为了减少服务之间的循环依赖,常常不会让B再回去调用A。这样一来,在一个基于http的体系中,轮询是唯一解法。

3. 阻塞回调

A调用B后,A什么都不做,直到B通知A已完成

这种模式并不是经常出现,而且它实际上是异步回调的一个子集。在(资源非常少,无法承接多个任务 | 或者A通知B进行的是其他任务的前提)时,可能会选择这种模型。

4. 异步回调

A调用B后,该干啥干啥,B通知A已完成后,再继续处理该任务的后续任务。

这种模型是本文主要想说的。

异步回调

我们从逻辑上分析一下,A调用B时,需要告诉B哪些东西。

  1. B需要知道任务结束后通知谁,所以A需要告知B,完成后需要执行的过程f
  2. A要告诉B做什么事情,所以至少需要指定调用参数arg

所以,常见的异步回调框架,比如libuv(c)vertx(java)nodejs,函数签名大多是:功能(参数, 回调函数)

例如

1
2
3
4
db.find('select * from xx', [], resultHandler);
function resultHandler(err, rows) {
// ...
}

这里的f就是resultHandler,arg就是'select * from xx', []

但是,从语法上,没有人能保证这个调用真的跑到数据库里取回结果。比如在单元测试时,find函数可能会返回一堆预定义的数据。这样的过程显然不是“异步”的。

那么,如下find函数实现做法,能算“异步”吗?

  1. 把接收到的参数和函数打包到一个对象里
  2. 传到某个FIFO队列里
  3. 一组线程池消费这个队列,然后执行Thread.sleep 10s,然后用预定义的数据调用传进来的函数(resultHandler)

emmmmm……

这样肯定也不是。因为这种做法本质上和直接sleep 10s没有任何区别。此处,把sleep 10s换成其他阻塞IO(比如write/read)是一模一样的。

但是这种做法已经比较接近了。

真正的异步

定时器

我们先不管IO。
如果你想实现一个异步的“sleep”函数,你会怎么做?

我们能从硬件拿到的只有当前时间,那么除了真的Thread.sleep还有别的方法吗?答案是没有。但是,等待的方式可以完全不一样。

假设,我们想在12:00开始等待10分钟。我们实际上告诉等待线程的,其实是“我们想最早在12:10收到回调”。等待线程可以选择每隔几毫秒检查一次当前时间,然后在时机合适时触发回调。

可能有人想说,这tm不还是总共sleep了10分钟吗,有什么区别?

这种情况下没有任何区别,但是如果你有两个定时任务呢?

还是假设我们现在是12:00。一个任务需要sleep 10分钟,另一个任务需要sleep 15分钟。按照之前的做法,需要占用两个线程。而现在只需要占用一个线程。如果任务数量继续往上增加,比如10000个任务,老做法需要占用10000个线程,而新做法依然只需要占用1个线程。

当然我们可以做的好一点,利用中断。比如第一个任务sleep10分钟,那么线程就直接sleep 10分钟。第二个任务在12:01分进来,只要sleep 5分钟。那么就可以中断线程,然后sleep5分钟。触发第二个任务回调后,再sleep4分钟,触发第一个任务回调。不过两者本质上是差不多的。

IO

从“定时器”的例子里看到,只有一个线程放在那死循环,就可以完成成百上千个任务。原理是“仅当任务完成时,触发对应的任务回调”。

放到通用的IO,这个道理也是一样的(其实定时器也是IO的一种)。

IO的正常事件只有两个:1.可读,2.可写。异常事件通常是连接异常、连接断开、资源问题等。

按定时器的原理,异步IO原理可以扩展为:“仅当事件触发时,才进行回调”。

这些在应用层是无法感知的。比如说,写缓冲没满,那么fd是可写的;读缓冲有数据,那么fd是可读的。但是应用层感知不到网卡队列。所以这些事情只能让内核来做了。

Linux Epoll

以往的select和poll,本质是轮询fd,看是fd是否可读或者可写等。原理就是遍历指定的几个fd,检查它们的可读写状态,然后告诉应用层。select要做两次数据拷贝,poll做一次。
select和poll其实也是异步,只不过需要不断轮询,而且复杂度都是O(n),比较慢。

Epoll的出现解决了几乎所有select和poll的缺点。当事件触发时,会直接告诉epoll事件已触发,在查询事件时(epoll_wait),只需要拷贝对应的链表而无需轮询。

但是,epoll不接收回调函数,它只是通知你fd事件激活。当事件激活后,你需要自己去调用对应的回调。所以,你仍然需要自己写一个死循环不停调用epoll_wait。

有人可能不理解,为什么死循环不停调用就是异步了?这个线程不还是被“阻塞”了吗?

有这种问题说明你对“阻塞”理解完全错误。阻塞并不是说“下面的代码还没有被执行”。CPU一条条指令执行下来,如果你写了一个死循环,而且有一个cpu核心就是钻牛角尖似的不停执行,那这就不是“阻塞”。阻塞是说,cpu目前已经不执行你这段代码了,但是下面的代码还没有被执行。比如你调用了阻塞版本的write,然后对端没有读取,那么线程就阻塞在那里,没有cpu会去执行后面的代码。

Epoll也会“阻塞”?

epoll_wait允许传一个超时时间。如果超过了这个时间还没有事件发生,会返回给你一个空list。也就是说,epoll也是可以“阻塞”线程的。但是我们不认为程序是“阻塞”的。因为,当epoll阻塞线程时,这时并没有任何事件需要处理。就像下面这两段程序:

1
Thread.sleep(1000);
1
2
long start = System.currentTimeMillis();
while (System.currentTimeMillis() - start > 1000);

如果当作黑盒,两者效果是一模一样的。那么与其狂转cpu,还不如等着好了。我们之前的“定时器”示例也是如此,还不懂的话用那个例子思考一下,应该是能理解的。

线程池的选择

阻塞IO的情况下,有人会说什么“偏IO的用cpu * 2,偏CPU的用cpu + 1”。这些都是扯淡。举个例子,16核cpu,你开了32线程。结果这时32个客户端都不写不读数据,那服务就卡死了。这时你发现不行,加到128个线程,结果有时候128个客户端都在请求,cpu跑满,瞬间卡死。所以阻塞IO的情况下,你设置多少都没用。

看了上文,你应该能理解,非阻塞IO的情况下,如果有负载,那么程序的每个线程都是工作的,所以线程池线程数量等于CPU数即可。多了就会有不必要的上下文切换,少了就用不上cpu。