参考
码农翻身 当多线程并发遇到Actor
goroutine, channel 和 CSP
并发之痛 Thread,Goroutine,Actor
一、多线程并发的难题
张大胖在做一个银行相关的项目,写了一个Account的类,用来表示一个用户的银行账号,根据银行的常规业务,自然要提供两个方法,存款(deposit)和取款(withdraw)。
为了防止多线程并发时导致的数据不一致问题,张大胖给每个方法都加了synchronized, 那意思很清楚,想进入某个方法执行存款或取款操作,必须得先获得一把锁才行。
但是在做转账操作的时候,为了保证一致性,必须得把两个账户都加上锁,然后才可以操作,于是张大胖写下了这样的代码,他觉得很简单,立刻就提交给Bill ,让他Review。
富有经验的Bill立刻就发现了问题,马上对张大胖说:“这样会出现死锁!”
张大胖说:“这么简单的代码,怎么可能有死锁?”
“假设线程1 做的操作是账户A给账户B转账, 先锁住了A账户, 接下来试图申请B账户的锁;与此同时线程2 在从 账户B给账户A 转账, 先锁住了B账户的锁, 接下来试图申请A账户的锁。两个线程各自持有资源, 然后等待获取对方的资源, 都无法执行下去, 死锁就出现了!”
张大胖无言以对,不得不承认Bill是正确的。他问道:“那怎么解决这个问题?”
“非常简单,加锁的时候按次序来就可以了,例如所有的线程,无论是从A向B转账,还是从B向A转账,都先获得账号A的锁,成功后再获得账户B的锁,这样就没问题了。”
张大胖说:“那样代码会变得很古怪啊,还得给两个账户排个顺序,如果不知道背后的思想读起来很痛苦,怪不得人家说多线程编程很难啊。”
Bill说:“是啊, 其实线程这个东西,就是一段代码的执行而已, 是操作系统层面的概念,可是我们苦逼的程序员不得不来面对它,来背这个多线程并发的锅了。”
二、黑盒子
下班后,张大胖一直在思考这个问题:既然线程是操作系统层面的概念,能不能把线程的概念隐藏起来,然后所有的操作都不用加锁呢? 这样以来编程就会容易得多啊!
本质的问题是什么?
首先是共享的状态,例如Account中的balance ,多个线程都要读写, 其次就是多个线程乱序、并发执行。
能不能换个思路,把这个Account对象看成一个黑盒子,你想存款了,就发一个存款的消息过来,想取款就发一个取款的消息过来。
不管是有一个消息,还是有100个消息,我统统放到黑盒子的一个队例中,然后让Account对象一个个顺序处理不就可以了? 根本不用在方法上加锁!
这样做,其实就是把并发的操作变成了串行的操作而已!
不对,如果调用方把取款消息放下就走,不等待返回结果,那就不是同步操作,而是异步操作了!但是如果取款的时候发现余额不足,怎么通知调用方?嗯,调用方也必须是个黑盒子对象,也向它发送异步消息,这个消息也会在消息队列中存下来,调用方“黑盒子”也会一个个处理。
想到这一层,张大胖激动起来:取款和存款的操作就不用在加锁了,码农们只要考虑黑盒子对消息的处理即可:取出消息,处理消息,向别的黑盒子发送消息, 根本不用考虑线程这样底层的概念了。
三、Actor模型
第二天张大胖赶紧找到Bill, 向他炫耀自己的“新发明”。
Bill不动声色:“小伙子,不错啊,重新发明了轮子!”
“重新发明?”
“是啊,你这个所谓黑盒子,就是所谓Actor模型啊! 它最早由Carl Hewitt在1973定义,其消息传递的方式更加符合面向对象的原始意图, 这一点我想你也体会到了,要不你怎么把他们叫做黑盒子啊。”
“1973年? 我还没出生。唉,看来这些概念已经被老前辈们都发明完了啊。”
“Actor属于并发组件模型 ,可以把程序员从多线程并发或线程池等基础概念中解放出来。它有这么几个特点:”
- Actor:就是你说的黑盒子,系统是由很多Actor组成。 Actor之间不共享状态,但是会接收别的Actor发送的异步消息,处理的过程中,会改变内部状态,也可能向别的Actor发送消息。
- Message:消息是不可变的, 它的发送都是异步的,Actor内部有个“MailBox”来缓存消息。
- MailBox:Actor内部缓存消息的邮箱, 其他Actor发送的消息都放到这里,然后被本Actor处理,类似有多个生成者和一个消费者的队例。
张大胖说:“和我之前的图差不多,看来我确实是重新发明了轮子啊。”
四、用Actor实现转账
Bill 笑道:“这个Actor看起来很美,但是编程的时候你得刷新一下你的思维才行。 大胖,之前你的转账操作在多线程下不是会出现死锁吗? 你考虑下,如果用Actor的思路该怎么写?”
“首先,得有两个Actor, 这两个Actor 表示了两个账户,我把它们叫做旺财和小强。”
“然后呢,转账的逻辑怎么处理?”
张大胖想了一会:“既然转账是在两个Actor之间发生的,那可以引入一个协调者Actor,叫做转账管家吧。不过,由于消息都是异步的,转账管家向旺财这个Actor发起扣款请求以后,不知道什么时候才能真正执行扣款,也不能立刻知道是否成功,必须得等待啊,这就有点麻烦了。”
Bill说:“我给你画个流程图,你看看。”
张大胖感慨地说:“原来的多线程并发模型,需要同时锁住两个账户,然后才能进行转账。现在每个Actor都独立,也把这个转账给搞定了。”
Bill说:“其实对于转账管家来说,对每个转账的消息,内部是隐含一个流程状态的,就是先向某个账户扣款,成功以后再向另一个账户增加,最后给调用者返回状态,这个次序是不能乱的。看到图中那个Transaction ID没有(Tx01),就是用来跟踪这个转账的事务。”
五、漏洞
“我发现了一个漏洞,你这个转账虽然看起来很美,没有加锁,但是和原来的是有区别的,原来多线程思路是会把旺财和小强的账户同时锁住,然后转账,在这个过程中,别人是不能操作这两个账号的! 而你的Actor方案中,当转账管家给旺财发消息扣款的时候,小强其实是自由的,如果这时候小强的账户被冻结,那你的转账管家还得回滚旺财的扣款,这多麻烦啊。”
Bill:“哈哈,你小子还挺机灵的嘛,看出了这个问题,Actor模型非常适用于多个组件独立工作,相互之间仅仅依靠消息传递的情况。如果想在多个组件之间维持一致的状态(比如咱们例子中的转账),那就不爽了。”
“那怎么解决这个问题?”
“那必须得用一些特殊手段了,有些实现Actor的框架,例如Akka,专门提供了像Coordinated /Transactor这样的机制来处理这个问题。有空的话给你仔细讲讲。”
“好吧,我回头看看这个Akka, 对了, Actor虽然对用户隐藏了线程, 但是总得有线程来处理消息吧。” 张大胖问道。
“那是肯定的,线程本质上就是一段代码的执行,每个Actor在处理消息的时候,肯定得和线程关联才行,只不过Actor系统把线程这个概念给隐藏了。”
“有哪些系统实现了Actor?” 张大胖接着问。
“其实最著名的就是Erlang了,Actor模型可以说是它的基础,除了我们上面所说的,还可以让Actor之间建立关联,例如让一个Actor去监控另外一些Actor工作,如果那些Actor崩溃了,就新建一个Actor继续工作。在Java 领域,刚才提到的Akka是比较知名的一个Actor框架。 ”
六、解决方案
1.线程池方案
Java1.5后,Doug Lea的Executor系列被包含在默认的JDK内,是典型的线程池方案。
线程池一定程度上控制了线程的数量,实现了线程复用,降低了线程的使用成本。但还是没有解决数量的问题,线程池初始化的时候还是要设置一个最小和最大线程数,以及任务队列的长度,自管理只是在设定范围内的动态调整。另外不同的任务可能有不同的并发需求,为了避免互相影响可能需要多个线程池,最后导致的结果就是Java的系统里充斥了大量的线程池。
2.新的思路
从前面的分析我们可以看出,如果线程是一直处于运行状态,我们只需设置和CPU核数相等的线程数即可,这样就可以最大化的利用CPU,并且降低切换成本以及内存使用。但如何做到这一点呢?
陈力就列,不能者止
这句话是说,能干活的代码片段就放在线程里,如果干不了活(需要等待,被阻塞等),就摘下来。通俗的说就是不要占着茅坑不拉屎,如果拉不出来,需要酝酿下,先把茅坑让出来,因为茅坑是稀缺资源。
要做到这点一般有两种方案:
异步回调方案
典型如NodeJS,遇到阻塞的情况,比如网络调用,则注册一个回调方法(其实还包括了一些上下文数据对象)给IO调度器(linux下是libev,调度器在另外的线程里),当前线程就被释放了,去干别的事情了。等数据准备好,调度器会将结果传递给回调方法然后执行,执行其实不在原来发起请求的线程里了,但对用户来说无感知。但这种方式的问题就是很容易遇到callback hell,因为所有的阻塞操作都必须异步,否则系统就卡死了。还有就是异步的方式有点违反人类思维习惯,人类还是习惯同步的方式。GreenThread/Coroutine/Fiber方案
这种方案其实和上面的方案本质上区别不大,关键在于回调上下文的保存以及执行机制。为了解决回调方法带来的难题,这种方案的思路是写代码的时候还是按顺序写,但遇到IO等阻塞调用时,将当前的代码片段暂停,保存上下文,让出当前线程。等IO事件回来,然后再找个线程让当前代码片段恢复上下文继续执行,写代码的时候感觉好像是同步的,仿佛在同一个线程完成的,但实际上系统可能切换了线程,但对程序无感。
GreenThread
- 用户空间 首先是在用户空间,避免内核态和用户态的切换导致的成本。
- 由语言或者框架层调度
- 更小的栈空间允许创建大量实例(百万级别)
几个概念
- Continuation 这个概念不熟悉FP编程的人可能不太熟悉,不过这里可以简单的顾名思义,可以理解为让我们的程序可以暂停,然后下次调用继续(contine)从上次暂停的地方开始的一种机制。相当于程序调用多了一种入口。
- Coroutine 是Continuation的一种实现,一般表现为语言层面的组件或者类库。主要提供yield,resume机制。
- Fiber 和Coroutine其实是一体两面的,主要是从系统层面描述,可以理解成Coroutine运行之后的东西就是Fiber。
3.Goroutine
Goroutine其实就是前面GreenThread系列解决方案的一种演进和实现。
- 首先,它内置了Coroutine机制。因为要用户态的调度,必须有可以让代码片段可以暂停/继续的机制。
- 其次,它内置了一个调度器,实现了Coroutine的多线程并行调度,同时通过对网络等库的封装,对用户屏蔽了调度细节。
- 最后,提供了Channel机制,用于Goroutine之间通信,实现CSP并发模型(Communicating Sequential Processes)。因为Go的Channel是通过语法关键词提供的,对用户屏蔽了许多细节。其实Go的Channel和Java中的SynchronousQueue是一样的机制,如果有buffer其实就是ArrayBlockQueue。
这个图一般讲Goroutine调度器的地方都会引用,想要仔细了解的可以看看原博客。这里只说明几点:
- M代表系统线程,P代表处理器(核),G代表Goroutine。Go实现了M:N的调度,也就是说线程和Goroutine之间是多对多的关系。这点在许多GreenThread/Coroutine的调度器并没有实现。比如Java1.1版本之前的线程其实是GreenThread(这个词就来源于Java),但由于没实现多对多的调度,也就是没有真正实现并行,发挥不了多核的优势,所以后来改成基于系统内核的Thread实现了。
- 某个系统线程如果被阻塞,排列在该线程上的Goroutine会被迁移。当然还有其他机制,比如M空闲了,如果全局队列没有任务,可能会从其他M偷任务执行,相当于一种rebalance机制。这里不再细说,有需要看专门的分析文章。
- 具体的实现策略和我们前面分析的机制类似。系统启动时,会启动一个独立的后台线程(不在Goroutine的调度线程池里),启动netpoll的轮询。当有Goroutine发起网络请求时,网络库会将fd(文件描述符)和pollDesc(用于描述netpoll的结构体,包含因为读/写这个fd而阻塞的Goroutine)关联起来,然后调用runtime.gopark方法,挂起当前的Goroutine。当后台的netpoll轮询获取到epoll(linux环境下)的event,会将event中的pollDesc取出来,找到关联的阻塞Goroutine,并进行恢复。
4.Goroutine是银弹么?
Goroutine很大程度上降低了并发的开发成本,是不是我们所有需要并发的地方直接go func就搞定了呢?
Go通过Goroutine的调度解决了CPU利用率的问题。但遇到其他的瓶颈资源如何处理?比如带锁的共享资源,比如数据库连接等。互联网在线应用场景下,如果每个请求都扔到一个Goroutine里,当资源出现瓶颈的时候,会导致大量的Goroutine阻塞,最后用户请求超时。这时候就需要用Goroutine池来进行控流,同时问题又来了:池子里设置多少个Goroutine合适?
所以这个问题还是没有从根本上解决。
七、Actor模型
Actor对没接触过这个概念的人可能不太好理解,Actor的概念其实和OO里的对象类似,是一种抽象。面对对象编程对现实的抽象是对象=属性+行为(method),但当使用方调用对象行为(method)的时候,其实占用的是调用方的CPU时间片,是否并发也是由调用方决定的。这个抽象其实和现实世界是有差异的。现实世界更像Actor的抽象,互相都是通过异步消息通信的。比如你对一个美女say hi,美女是否回应,如何回应是由美女自己决定的,运行在美女自己的大脑里,并不会占用发送者的大脑。
所以Actor有以下特征:
- Processing – actor可以做计算的,不需要占用调用方的CPU时间片,并发策略也是由自己决定。
- Storage – actor可以保存状态
- Communication – actor之间可以通过发送消息通讯
Actor遵循以下规则:
- 发送消息给其他的Actor
- 创建其他的Actor
- 接受并处理消息,修改自己的状态
Actor的目标:
- Actor可独立更新,实现热升级。因为Actor互相之间没有直接的耦合,是相对独立的实体,可能实现热升级。
- 无缝弥合本地和远程调用 因为Actor使用基于消息的通讯机制,无论是和本地的Actor,还是远程Actor交互,都是通过消息,这样就弥合了本地和远程的差异。
- 容错 Actor之间的通信是异步的,发送方只管发送,不关心超时以及错误,这些都由框架层和独立的错误处理机制接管。
- 易扩展,天然分布式 因为Actor的通信机制弥合了本地和远程调用,本地Actor处理不过来的时候,可以在远程节点上启动Actor然后转发消息过去。
Actor的实现:
- Erlang/OTP Actor模型的标杆,其他的实现基本上都一定程度参照了Erlang的模式。实现了热升级以及分布式。
- Akka(Scala,Java)基于线程和异步回调模式实现。由于Java中没有Fiber,所以是基于线程的。为了避免线程被阻塞,Akka中所有的阻塞操作都需要异步化。要么是Akka提供的异步框架,要么通过Future-callback机制,转换成回调模式。实现了分布式,但还不支持热升级。
- Quasar (Java) 为了解决Akka的阻塞回调问题,Quasar通过字节码增强的方式,在Java中实现了Coroutine/Fiber。同时通过ClassLoader的机制实现了热升级。缺点是系统启动的时候要通过javaagent机制进行字节码增强。
八、Golang CSP VS Actor
二者的格言都是:
Don’t communicate by sharing memory, share memory by communicating
通过消息通信的机制来避免竞态条件,但具体的抽象和实现上有些差异。
- CSP模型里消息和Channel是主体,处理器是匿名的。
也就是说发送方需要关心自己的消息类型以及应该写到哪个Channel,但不需要关心谁消费了它,以及有多少个消费者。Channel一般都是类型绑定的,一个Channel只写同一种类型的消息,所以CSP需要支持alt/select机制,同时监听多个Channel。Channel是同步的模式(Golang的Channel支持buffer,支持一定数量的异步),背后的逻辑是发送方非常关心消息是否被处理,CSP要保证每个消息都被正常处理了,没被处理就阻塞着。 - Actor模型里Actor是主体,Mailbox(类似于CSP的Channel)是透明的。
也就是说它假定发送方会关心消息发给谁消费了,但不关心消息类型以及通道。所以Mailbox是异步模式,发送者不能假定发送的消息一定被收到和处理。Actor模型必须支持强大的模式匹配机制,因为无论什么类型的消息都会通过同一个通道发送过来,需要通过模式匹配机制做分发。它背后的逻辑是现实世界本来就是异步的,不确定(non-deterministic)的,所以程序也要适应面对不确定的机制编程。自从有了并行之后,原来的确定编程思维模式已经受到了挑战,而Actor直接在模式中蕴含了这点。
从这样看来,CSP的模式比较适合Boss-Worker模式的任务分发机制,它的侵入性没那么强,可以在现有的系统中通过CSP解决某个具体的问题。它并不试图解决通信的超时容错问题,这个还是需要发起方进行处理。同时由于Channel是显式的,虽然可以通过netchan(原来Go提供的netchan机制由于过于复杂,被废弃,在讨论新的netchan)实现远程Channel,但很难做到对使用方透明。
而Actor则是一种全新的抽象,使用Actor要面临整个应用架构机制和思维方式的变更。它试图要解决的问题要更广一些,比如容错,比如分布式。但Actor的问题在于以当前的调度效率,哪怕是用Goroutine这样的机制,也很难达到直接方法调用的效率。当前要像OO的『一切皆对象』一样实现一个『一切皆Actor』的语言,效率上肯定有问题。所以折中的方式是在OO的基础上,将系统的某个层面的组件抽象为Actor。
评论区又见洗地,go的用户就不要拼命洗地说用go的虚拟线程能够作出actormodel了,这个明显就是设计上的失误,如果一开始就看准了有actormodel这回事的话,根本就不会设计成强制用户使用虚拟线程,直接暴露os线程是最简单也是最直观的做法,就是因为go走出错了路,挖了一个大坑,然后为了actormodel而强行实现,只能说明这一开始就是一个错误,最后不计成本地去模拟出别人的模型,那actormodel从本质上就有的性能优势还有什么意义?之所以用actormodel就是冲着性能去的,而go这样搞,只能作出比一般actormodel性能更差的实现,那还有什么意义?你还是回去无脑堆虚拟线程算了,何必呢?虚拟了一层又一层,其他语言的道路是通过保留线程api,然后增加虚拟线程api,但是go是直接一上来就干掉了原始线程,强行封装一层,然后再在这个基础之上,去填坑,这么做的人一定是疯了,这样做还不如不做,你看go用户一直鼓吹的性能优势在这个时候,从模型上就开始表现出不足来,看看其他语言,人家既有nativethread的包装,又有coroutine/fiber,我们要组合出eventlooop和actormodel来就很容易和直观,然后搭配async/await就可以用非常简单的方式写代码了,又回到最初的proactiveprogramming上去了,但是go就少了nativethread这一步,怎么模拟都会多出一层虚拟层,那这一层犹如隔靴扰痒,太不爽了
.
go是强行塞入n:m这一层,而其他语言是保留了1:1,然后再在1:1的基础之上增加了1:n,所以其他语言可以做到用async/await完全抹杀掉线程调度带来的消耗,同时在这个基础之上,也能做到无脑堆虚拟线程,用车来比喻,就是其他语言能做到手动挡和自动挡,但是go只能做到自动挡
九、从写程序的角度来看,有什么不同
1.假如,要做“关于取得RSS文章的单词数”这样的程序的话:
CSP:
步骤1:定义几个channel,保存不同处理的之间的数据:
1,新文章ch
2,文章内容ch
3,单词数ch
步骤2:然后写相应的处理程序:
1,新文章URL处理(把得取的新文章的URL,写入“新文章ch”)
2,新文章内容处理(把新文章内容读取下来,写入“文章内容ch”)
3,单词数统计(对文章内容进行单词个数统计,写入“单词数ch”)
4,文章单词数累加(读取“单词数ch”,把各个文章单词数进行累加)
步骤3:在主程序中,定义上面的几个channel,
再调用几个处理程序,并把channel当成参数传给处理程序
Actor:
步骤1:定义控制Actor,控制程序流程,功能如下:
1,当收到指令是“新文章URL处理”的话,调用“新文章URL处理”Actor,并把处理结果返回给调用者。
2,当收到指令是“新文章内容处理”的话,调用“新文章内容处理”Actor,并把处理结果返回给调用者。
3,当收到指令是“单词数统计”的话,调用“单词数统计”Actor,并把处理结果返回给调用者。
4,当收到指令是“文章单词数累加”的话,调用“文章单词数累加”Actor,并把处理结果返回给调用者。
(以上的每一个指令的执行,都可以做成并行的)
步骤2:定义指令相对应的处理程序。
步骤3:主程序,向“控制Actor”发指令,并把每次指令的结果当成参数,传给下一次的指令调用。
2.如果需求增加,在单词数统计时,去掉”of/to/and”这些单词的话,CSP和Actor要怎么做呢?
CSP:
主程序:
1,定义一个新的ch
2,并增加对内容过滤程序的调用。
3,传给“单词数统计”程序的ch,要修改成新定义的ch
子程序:
1,新加一个处理程序。
Actor:
主程序:
1,增加对内容过滤Actor的调用
控制Actor:
1,增加一个新的内容过滤指令
子程序:
1,定义一个新的内容过滤Actor
十、protoactor-go简介
Golang最好用的Actor框架 protoactor-go
go语言实践-protoactor使用小结
Actor模型是一种适用于高并发的编程模型。早在1973 年 Carl Hewitt 发表的论文中定义了Actor,但一直流行于Erlang 语言中。Erlang 被爱立信公司应用于建立高并发、可靠通信系统,取得了巨大成功,著名的rabbitMQ 就是Erlang的代表作。Java 语言的 Akka 库里面角色的API 跟Scala 框架里面角色相似,后者一些语法模仿Erlang语言。
Golang中 始终缺乏一个代表性的Actor 框架,Golang自身的协程处理被广大Golang推崇,但从生产实践来看,在大规模并发的情况下,协程并不是最佳的方案,使用Actor框架编写大规模并发服务通常被认为是更好的选择。
今天给大家推荐是由瑞士的团队Asynkron出品的Actor框架protoactor-go,从目前gitHub 上的Star数量观察, protoactor-go 是唯一个star 过千的Actor框架,由于背后有专业团队持续维护,因此稳定性有保证,同时protoactor-go有.Net/Golang/Java/Kotlin 的跨语言平台的实现,给开发者提供了更多系统集成的方案, 特别是在互联网公司,多语言共同开发环境下,优势明显。
Actor框架的提出基本上与CSP差不多处于同一个年代,相对于后者,过去10年Actor还是要稍微火一些,毕竟目前都强调快速开发,绝大部分的开发人员更关注业务实现,这符合Actor侧重于接收消息的对象(CSP更强调传输通道)一致。ProtoActor的设计与实现,绝大部分和AKKA很相似,只是在序列化、服务发现与注册、生命周期几个地方略有差异。框架使用上整体与AKKA相同,但受限于go语言,在使用细节上差别有点大。