Home

关于异步的思考

21 Oct 2014 by LelouchHe

最近在做异步支持方面的工作,期间遇到了一些疑惑和问题,在这里总结整理下,希望下次不再掉到类似的坑里,或者要掉也要掉的漂亮

同步与异步 阻塞与非阻塞

这是两对非常容易混淆的概念.本来还想在wiki上看看定义,结果上面异步和非阻塞是一个意思

在我看来,这两对概念,针对的应该是两个不同的问题.前者着眼于调用方是否需要人工的跟踪操作的进度和完成,而后者强调了操作是如何完成的

同步异步

是否需要人工跟踪,是区分同步异步的关键.如果调用方需要人工的一步一步的看着操作完成,那么这样的操作就是同步的,反之,如果不关心操作完成与否,或者不需要显式的跟踪处理,那么这就是异步的

在这里,需要重点标出的是调用方.所有的操作,必定是有开始,有经过,有结束的,必然有某个/某些实体掌控整个流程的.同步异步说的就是调用方是否在内

比如linux下的aio系列,只要在”aiocb.aio_sigevent”下注册了对应的回调函数,然后使用”aio_read”,剩下的read操作就全全交给OS来完成了,调用方不需要关心操作的进度,而是在最后,OS通知

而同步则不然,如果使用的是”read”,不论是直接阻塞在调用上,还是需要不断轮询查询状态,调用方都需要密切的关注操作的进度,直到操作完成

可以看到,aio操作时,并不是无人关心操作进度,只是这个责任不在调用方,而在OS.对OS来说,read操作的流程自然是一样都不能少,只不过那是在内核态,同一般的用户态下的有较大区别

举个简单的例子.我们去饭店吃饭,更为具体一些则是我们要完成从点餐到上菜的操作.此时,二者的区别表现为:

这里强调了具体操作的定义.如果操作定义为吃饱饭,那么上面两种其实都成了同步了,因为最后饭还是要自己一口一口吃,吃饱没吃饱也是我们来处理的.此时的异步,就只能是”在iPad上点了几下,该干嘛干嘛,最后,觉得神清气爽,不知怎的就饱了”

从这里就可以看到,同步异步其实区分不大,”具体操作”定义的变化,甚至关注问题的角度的变化,都能带来这样的不同

比如裸IO复用(直接使用select/poll/epoll),这显然是同步的(显然,我们得关心ready的fd和IO操作),但如果把IO复用做到框架中,或者简单的做成单独的IO线程,read/write都是IO线程自己的,我们只提供fd/action/buf/callback等信息(和aiocb何其相像),从这个操作的角度看,这不就成了异步的么?只不过以前aio的主体是OS,现在换成了矿建或背后的IO线程了而已

优劣

同步操作一般更加直接,比如该read,就是read,除非是调用方自己确定了read成功,否则是不会进行下一步骤的,直观看来是比较符合人类处理事务的惯例的.而且,同步一般都是在本线程操作的,遇到IO等阻塞操作就直接sleep,不消耗额外的CPU,应该是OS比较喜欢的类型

异步操作需要小心的构建.除去某些压根不关心结果的操作,大部分都需要提供最后的回调处理,这样,本来完整的操作不免就被回调零碎化了,代码写起来比较复杂,最后回顾再看起来,就更加复杂了.而且,除去aio等依靠OS的异步,剩下的大部分都是用户态利用后台线程完成的,这样免不了线程之间的切换调度,同步和对象生命期等一些列性能和功能问题(后面会说),如果没有一个很好的框架和工具,难免搞的很伤心

阻塞非阻塞

上面说的时候,提及过这组概念.同步异步是讲调用方对操作的态度,而阻塞非阻塞则强调了操作本身的完成

阻塞是指操作对于调用方而言,是一个完整的过程,调用结束就意味着操作结束(成功或者失败).而非阻塞则相反,对于调用方而言,更类似一种查询,调用结束只意味着查询结束,至于操作如何,需要调用方来判断

举一个非IO的例子,mutex的加锁有2个操作,lock和trylock,此处的操作是获取mutex.前者就是阻塞的,因为lock返回,就意味着操作结束了,调用方获取到了mutex;而后者是非阻塞,意味着调用方需要看看trylock的返回,成功了,表示操作完成,否则,什么都没做

再比如read操作,此处操作是获取对应fd内容.BLOCK的操作是阻塞的,返回意味着读取成功了,或者遇到奇怪的问题(中断/信号之类的);NONBLOCK则分2段,当fd没有数据时,属于非阻塞操作,通过返回值,调用方得以判断是否继续,当fd就绪有数据时,则又变成了阻塞操作

所以,UNP上面把阻塞有关的IO操作分成了2部分,等待数据和复制数据(从OS到用户态),而阻塞非阻塞说的都是第一部分,复制数据这部分都是阻塞的

阻塞往往有hang住本线程的效果,一般来说,只要不能立即返回的,都属于阻塞.当然,此处的立即似乎模糊的,可以借鉴的是,如果操作必定失败,能够立即返回的,是非阻塞,反之,都是阻塞(不绝对)

再拿去吃饭做例子,操作是点菜到上菜:

优劣

阻塞的好处,类似于同步的好处,都是很直接的行为,易于理解,而且对OS非常友好

非阻塞操作比较复杂,需要额外的处理逻辑,一般情况下,都是套个while循环,伪装成阻塞操作

IO复用+非阻塞IO

最常见的非阻塞操作就是IO操作,它主要和IO复用一起,来构建高效的事件响应机制(libev之类的)

其实整体而言,还是阻塞的,不过不是阻塞在操作上,而是阻塞在IO复用上.而阻塞在IO复用上,仅仅意味着当前没有什么IO操作而已,这种阻塞,在这种情况下,是可以接受的(因为本来就没什么干的,还不如阻塞住给OS腾空间)

可以看出来,这套机制是用来提量的,单位时间的吞吐,以及顺带的IO请求的平均响应时间,而非IO请求的处理时间

现在的话,来吃饭的就不止我们了,而服务员则变成了:

众人问: 菜呢?

服务员道:

异步的思考

个人感觉,不论是aio还是IO复用的异步版本,底层IO处理是比较容易搞定的(当然,那几个api也是比较难搞的),最为复杂的,应该是回调的管理了

在回调上,谁负责调用比较明确(OS或者IO复用机制),但调用谁和拿什么调用就比较值得讨论了

调用谁

这个问题归结于回调的形式,通常来讲,有以下几个形式:

  1. 函数指针: 这个应该是最为普通的形式,也是最常用的形式.这个的优点在于简单,缺点则是太简单了.函数指针的调用参数是唯一的,可用到的信息也是唯一的,如果要有额外信息的话,往往需要附加到异步机制的某个void*上.虽然这种方式可能是其他所有方式的基础,但过于简单的表意,往往存在很大的限制
  2. 固定接口对象: 不提供函数了,我们提供一个保存有调用信息的对象,异步机制通过使用这个对象的固定接口,来完成回调操作.这需要异步机制的支持,但一旦配合一致,我们就能提供相当强大的回调功能.因为我们可以存储更多的参数,也可以支持更多种类的回调.这个相当于上一个的进化版(本质上来说,相当于提供多个函数指针+多个参数而已)
  3. std::function+std::bind: 这是C++提供的另一种方式,本质而言,和第2种一致,但提供了一个更加抽象通用的表达方式.异步机制只需要知道特定形式的回调格式,剩下的都可以通过function+bind的组合产生

对于第3种而言,更大的优势体现在,调用方不再受限于异步机制了,甚至都没必要知悉异步机制,只需要提供一个可以调用的function即可,参数格式,类型都随意,应该是极大的增加了灵活性

所以在可能的情况下,优先选择最灵活的第3种

拿什么调用

上面交代了回调函数和回调函数参数的从哪来,而这个问题则是回调函数的参数是什么

当然,对于简单的POD来说,传值即可,调用时自会复制一份.但对于非POD类型,或者需要在回调内部改变并反映到外面的参数,该怎么处理?

一个简单的想法是传指针,但谁来保证指针指向对象的生命期?所以一个合理的想法是,使用std::shared_ptr当作参数来保证对象的生命期

但在某些情况下,还是会有问题.比如一个rpc的框架,用户可以通过统一的接口,调用远程或本地的服务,该统一接口参数是正常类型参数(不可能包装为智能指针)

用户调用”a.async_method(int *pi)”,框架内部需要转调”b.async_method(int *pi)”, 由于涉及到回调,框架内部使用了智能指针,”shared_ptr spi(pi)",框架内部回调则是"inner_callback(spi)",用来调用用户自定义的回调"user_callback(pi)",并且pi在"user_callback"中free.

如何保证pi在调用前后存活呢?更重要的是,假如存在超时,如何进行处理呢?(又有异步又有超时,明显是多线程)

由于设置了”inner_callback”,可以想到,调用”b.async_method”之前,计数为2,调用后立即返回,计数减少,现在为1

当正常回调时,”inner_callback(spi)”中的spi计数为1,所以对象存活,可以取出传递给”user_callback”,并正常销毁

当存在超时时,”inner_callback(spi)”和上面一样,spi同样为1,可以调用”user_callback”,pi被销毁.但此时”b.async_method”怎么办?由于超时,这个方法可能还在运行,参数却已经失效了,而且我们也没有办法进行通知(无法假定b的实现方式,这属于框架使用方)

一个可能的解决办法是”user_callback”不free掉pi,等到超时返回后,”user_callback”再统一free.这个可以做到,因为异步调用必然有一个异步控制的对象(cntl)作为公共参数传递到异步和回调方法,可以用这个的状态来判断(框架内设置一下),比如:

if (cntl->is_ok) {
    delete pi;
} else if (cntl->is_timeout) {
    // nothing
} else if (cntl->is_timeout_ok) {
    delete pi;
}

但再假定一种情况,调用方没有兴趣知道超时的结果,比如调用方一旦超时,就自动退出了,等不到超时返回(对于服务而言,这是正常现象).此时,对于内存对象而言,可能可以收回(如果是进程级别的exit),但对于其他OS回收不了的资源,我们连处理的机会都没有,因为”user_callback”只调用了一次,而且还是”is_timeout”的那个不处理的操作

当然,调用方可能使用RAII保存资源,一旦退出,就释放,但这就回到刚才了,资源已经没有,但”b.async_method”还在运行,这必然是core的节奏

这个的处理方法还需要多想.暂时还没有什么好的结果