从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哪些东西。
所以,常见的异步回调框架,比如libuv(c)
,vertx(java)
,nodejs
,函数签名大多是:功能(参数, 回调函数)
。
例如
db.find('select * from xx', [], resultHandler);
function resultHandler(err, rows) {
// ...
}
这里的f就是resultHandler
,arg就是'select * from xx', []
。
但是,从语法上,没有人能保证这个调用真的跑到数据库里取回结果。比如在单元测试时,find函数可能会返回一堆预定义的数据。这样的过程显然不是“异步”的。
那么,如下find函数实现做法,能算“异步”吗?
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阻塞线程时,这时并没有任何事件需要处理。就像下面这两段程序:
Thread.sleep(1000);
以及
long start = System.currentTimeMillis();
while (System.currentTimeMillis() - start > 1000);
如果当作黑盒,两者效果是一模一样的。那么与其狂转cpu,还不如等着好了。我们之前的“定时器”示例也是如此,还不懂的话用那个例子思考一下,应该是能理解的。
— 本文结束 —