并发中的流程控制

异步vs同步

异步(编程)和同步(编程)是对立的。同步是执行某个操作时,必须等待该操作完成,再执行下面的流程。异步则是执行某个操作时,不等待该操作完成,直接执行下面的流程,而在适当的时候,“被”通知到“该操作完成了”。这里的“操作”一般指非纯CPU运算,比如收发网络数据,若一直等待整个操作过程,期间CPU会有空闲。

【程序0】若有流程A、操作X、流程B需要依次执行。
同步的代码:

流程A;
操作X;
流程B;
异步的代码:
流程A;
启动操作X,注册回调流程B; //当操作X完成后,执行流程B
等待操作X完成;

【程序1】令依次执行流程A、操作X、流程B为过程1,依次执行流程C、操作Y、流程D为过程2,过程1和过程2相互独立。
同步的代码:

流程A;
操作X;
流程B;
流程C;
操作Y;
流程D;
异步的代码:
流程A;
启动操作X,注册回调流程B;
流程C;
启动操作Y,注册回调流程D;
等待操作X、操作Y完成; //这里等待不同的操作,一般基于IO多路复用实现
假设操作X和操作Y均需要n的时间(响应时间,非CPU时间),其他流程时间忽略不计,则同步模式需要2n的时间,而异步只需要n的时间。下面在同步模式中引入线程,将过程1和过程2分别用两个线程来执行。
同步(线程化)的代码:
function 过程1() {
  流程A;
  操作X;
  流程B;
}
function 过程2() {
  流程C;
  操作Y;
  流程D;
}
启动线程0执行过程1;
启动线程1执行过程2;
由于线程间是交替并行运行的(多核CPU可能是物理上并行的),因此同步(线程化)模式只需要n的物理时间。

【程序2】令依次执行流程A、操作X、流程B为过程1,依次执行流程C、操作Y、流程D为过程2,过程1和过程2相互独立,但过程1和过程2均结束后需要执行流程E。
同步的代码:

流程A;
操作X;
流程B;
流程C;
操作Y;
流程D;
流程E;
同步(线程化)的代码:
function 过程1() {
  流程A;
  操作X;
  流程B;
}
function 过程2() {
  流程C;
  操作Y;
  流程D;
}
启动线程0执行过程1;
启动线程1执行过程2;
等待线程0和线程1完成; //这个过程是“线程同步”
流程E;
异步的代码:
流程A;
启动操作X,注册回调流程B;
流程C;
启动操作Y,注册回调流程D;
等待操作X、操作Y完成; //这里等待不同的操作,一般基于IO多路复用实现
流程E;


其之殇

同步模式之殇

同步模式的代码是最容易编写和阅读的。从上面的实例可以看出,其耗时是全部等待操作的总和。根本原因在于同步模式无法表达出并发这一概念。就网络服务器而言,很容易出现上万用户同时需要响应的情况,采用这种模式第1个用户在n的时间里获得了响应,第10000个用户则在10000n的时间后才能获得响应。所以在很多情况下,这一殇是致命的

同步(线程化)模式之殇

同步(线程化)模式的代码的易编写和易阅读性居中。由于各个线程之间相互独立,该模式无法方便的描述出可并行单元间的依赖关系,如“操作X和操作Y可并行,两者均完成后才可执行流程E”,以及“操作X和操作Y可并行,两者任一完成后即可执行流程E”,要完成这些逻辑,需要线程间通讯,这个过程又必须引入线程同步机制(临界区、互斥量、事件、信号量等)。此外,由于线程的切换是由操作系统决定的,很多过程无法保证原子性,又需要通过锁机制来保证其原子性。这一殇只是增加程序的复杂度,另一殇就不这么简单了,同样是上万用户同时需要响应的情况,采用这种模式则需要10000个线程,光内存就会使用很大的量了,在忽略网络带宽和内存大小的情况下,若某一次响应需要n的时间(响应时间,非CPU时间),假设在期间的CPU需要m的时间(一次响应期间的CPU时间),一般来说m远小于n,所以计算响应时间的时候可以忽略m,若这里存在10000*m<=n,则对于10000的并发度来说是可以完成的。等等,这里有10000个线程,在单核CPU的情况下,它们需要交替切换运行,极端情况下就算切换一次就可以完成全部逻辑,则也至少需要10000次切换,令一次响应期间的线程切换时间为k(CPU时间),这里的k和m想当,甚至可能是k>m,因此需要能够处理10000个并发请求,则需要保证(m+k)*10000<=n,就CPU而言,真正业务逻辑耗时仅占了m/(m+k)。当然不同场景下m和k的比值不同,对于业务逻辑很短的业务(例如代理服务器),k可能比m更大,如此CPU的大部分计算都用来打酱油了(切换线程)。

异步模式之殇

异步模式的代码是最难以编写和阅读的,其性能却是最为卓越的。回看【程序2】-异步的代码,若【程序2】的整个过程有10000个可独立并行的,上面的代码则不可用了,因为“等待操作X和操作Y完成”是一个阻塞的操作,在异步模式中,整个进程(线程)只能有一处完成这个过程,因此存在10000个独立的【程序2】,则需要将全部等待过程汇集到一起。

标志位[10000]; //这里需要10000份标志位
function 过程0(){
  流程B;
  标志位 |= 0x1;
  if (标志位 == 0x3)
    流程E;
}
function 过程1(){
  流程D;
  标志位 |= 0x2;
  if (标志位 == 0x3)
    流程E;
}
function 单元(){
  标志位 = 0x0; //这里实际为操作对应的那个标志位(没有再详细化了)
  流程A;
  启动操作X,注册回调过程0;
  流程C;
  启动操作Y,注册回调过程1;
}
for(i=0;i<10000;i++) //循环10000次
  单元();
等待全部单元中的操作X或操作Y完成; //这里等待不同的操作,一般基于IO多路复用实现
从上面的代码可以看出,异步模式的代码似乎是不那么容易重用的,其原因在于等待全部阻塞操作(核心的IO复用)全局只能有一个,若有两个独立的异步程序(逻辑A+逻辑B),现需要将逻辑A和逻辑B合并,首先就得合并两个异步程序中的等待全部阻塞操作,若两者使用不同风格的异步代码(不同框架)则这个过程会更加困难。现在来审视这个过程,一次响应的逻辑是这样的:执行流程A,执行流程C,然后等待操作X和Y都完成,然后再执行流程B,执行流程D,最后执行流程E(这里只是众多合理逻辑的其中一种)。如果这些过程都在一个函数内,那么很容易看明白整个响应的逻辑吧,但是很不幸,异步模式中,等待操作X和Y完成是全局性的(核心的IO复用),你只能告诉它操作X完成后执行什么,然后一个函数内的逻辑被分解到两个函数上去了,哦,不对,我需要等待两个操作完成,因此一个函数内的逻辑被分解到三个函数里去了(若将执行流程E抽象为函数,那么这里应该是四个函数)。完成这些流程依赖的数据怎么办?首先得将数据放到堆区(放到栈区的话,函数退出栈都消亡了),然后得将数据的指针或索引通过回调函数的void指针参数传递给下一部分逻辑的函数(对于更高级的语言,提供的闭包机制可以省略这个步骤)。我不知道操作X和Y是谁先完成啊,怎么知道什么时候执行流程E呢?上面的标志位就是干这个的,实际上这就是异步模式中的状态机。异步模式中的操作乱序、操作在分支中、操作在循环中都需要状态机来表述当前的状态。状态的数量还随着上面的情况成指数级增长。现在试着用异步模式来完成下面的逻辑:“这是一个查询服务,对于每个发起请求的用户,首先需要检验其用户权限,若权限合法则查询其的目标数据的个数,获得个数后,再分别查询获得数据,最后返回响应成功,全部过程每个环节失败或超时(每个环节拥有独立的超时时间),则返回响应失败”。“Oh, my god”,为何异步模式的代码这么难以编写和维护?其根本原因在于异步模式中的回调函数将逻辑和数据分解得支离破碎。PS:回调函数风格就是坑爹(对人类不自然)的CPS(Continuation-passing style)  

并发流程控制

这是笔者自己设计的一个新的模式。
这里引入一些新概念来描述并发的流程控制(用以解决上面提到的问题)。

任务
并发流程中最小的控制单元,一段逻辑上可能阻塞的过程视为任务
例如:休息100ms毫秒可以视为一个任务,从某套接字接受一个数据包可以视为一个任务。

start 原语
异步地执行一个任务,该过程不阻塞当前任务的执行,目标任务启动后立即继续
若当前任务A需要执行新的任务B,任务B和任务A逻辑上是可以并行的,且任务B执行完成后和任务A无任何关联,则可使用异步执行start

await 原语
同步地执行一个任务,该过程会阻塞当前任务的执行,等待目标任务执行完成后继续
若当前任务A需要执行新的任务B,任务A必须等待任务B完成后,才能继续执行,则可使用等待任务执行await

【程序1】令依次执行流程A、操作X、流程B为过程1,依次执行流程C、操作Y、流程D为过程2,过程1和过程2相互独立。
并发流程控制的代码:

task 过程1 {
  流程A;
  await 操作X;
  流程B;
}
task 过程2 {
  流程C;
  await 操作Y;
  流程D;
}

start 过程1;
start 过程2;

有了这两个基本原语,可以发现能够很容易表达出相互无关联的可并行单元,这个过程和启动新线程将任务用新线程运行很相似。但是如何表达相互存在关联的可并行单元呢?下面再引入另外的两个原语。

all_of 原语
将多个任务组合为一个新的任务,全部任务执行完成后新的任务视为执行完成。
若当前任务A需要执行新的任务B和C,任务A必须等待任务B和C都完成后才能继续执行,并且B和C之间可以并行,则可使用await+all_of组合出await_all_of并行等待全部

any_of 原语
将多个任务组合为一个新的任务,任一任务执行完成后新的任务视为执行完成,同时会取消掉其他未执行完成的任务。
若当前任务A需要执行新的任务B和C,任务A必须等待任务B和C中的任意一个完成后才能继续执行,并且B和C之间可以并行,则可使用await+any_of组合出await_any_of并行等待任意

【程序2】令依次执行流程A、操作X、流程B为过程1,依次执行流程C、操作Y、流程D为过程2,过程1和过程2相互独立,但过程1和过程2均结束后需要执行流程E。
并发流程控制的代码:

task 过程1 {
  流程A;
  await 操作X;
  流程B;
}
task 过程2 {
  流程C;
  await 操作Y;
  流程D;
}

await all_of(过程1, 过程2);
流程E;
现在再使用它们来表达 【程序2】的整个过程有10000个可独立并行的
task 过程1 {
  流程A;
  await 操作X;
  流程B;
}
task 过程2 {
  流程C;
  await 操作Y;
  流程D;
}

task 程序2 {
  await all_of(过程1, 过程2);
  流程E;
}

for(i=0;i<10000;i++)
  start 程序2;
现在再使用它们来表达【异步编程之殇】中令人大呼“Oh, my god”的逻辑: “这是一个查询服务,对于每个发起请求的用户,首先需要检验其用户权限,若权限合法则查询其的目标数据的个数,获得个数后,再分别查询获得数据,最后返回响应成功,全部过程每个环节失败或超时(每个环节拥有独立的超时时间),则返回响应失败”
task 服务{
  await any_of(检验权限, sleep a ms); //等待 检验权限 和 休息a毫秒 中的任意一个
  if (who_completed() == 0 && 权限合法) { //前面的条件为判断不是超时
    await any_of(查询个数, sleep b ms);
    if (who_completed() == 0 && 个数 > 0) {
      await any_of( all_of(查询数据0, 查询数据1, ...), sleep c ms);
       if (who_completed() == 0) {
         failed = 0; //失败的个数
         for (i=0; i<个数; i++)
           if (查询数据i失败) failed++;
         if (failed == 0)
           返回响应成功;
         else
           返回响应失败;
       }
    } else
      返回响应失败;
  } else
    返回响应失败;
}

while (true) {
  await 用户发起请求;
  start 服务;
}
从上面四个示例可以看出这四个原语在并发流程中拥有多么强大的表达能力。其中示例二到示例三的过程也展现了在该模式下的代码复用能力。

为何如此强大的表达能力?因为该模式下有方便的描述相互存在关联的可并行单元的机制,更重要的是它的数据和逻辑是完整的(不会被分解),服务的逻辑过程是由语言的固有语法(分支和循环)表达出来的,不需要单独使用状态机表示它的状态,状态信息固有地存在于分支和循环中。

真能实现这个模式么?当然文章讲了这么多,笔者也不是空谈,下面即为笔者自己实现的一个C++的版本:cocoflow

其核心是基于协程的(关于协程,详细的内容可以参考百科),相比于线程:

  1. 众多协程实际上只是在一个线程中
  2. 协程的切换是用户态的(运行栈是自己维护的),而线程是内核态(Linux下的ucontext实现上存在rt_sigprocmask系统调用,用于屏蔽信号
  3. 协程的切换是程序手动的,而线程是操作系统自动的
拥有这些特性,决定了,其一,协程切换的成本更低,其二,手动切换可以在一定程度上保证原子性(比如一次操作STL数据结构的过程就是原子的,不会被打断,也就不需要锁机制)。cocoflow中的阻塞全部是hold住当前运行态,切换给其他协程(当然你不能调用真正的阻塞函数)。协程之间的相互关系替代了异步中的状态机,而各个事件(事件task,例如休息10毫秒)则通过内部维护的事件队列(基于 libuv )来完成。其内部事件处理本身为高效的异步机制,只是异步的回调方式被转换成等价的协程切换,提供了更易编写的用户接口。
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值