工作生活

并发编程基础知识四 Actor CSP protoactor-g

2019-07-02  本文已影响0人  合肥黑

参考
码农翻身 当多线程并发遇到Actor
goroutine, channel 和 CSP
并发之痛 Thread,Goroutine,Actor

一、多线程并发的难题

张大胖在做一个银行相关的项目,写了一个Account的类,用来表示一个用户的银行账号,根据银行的常规业务,自然要提供两个方法,存款(deposit)和取款(withdraw)。

为了防止多线程并发时导致的数据不一致问题,张大胖给每个方法都加了synchronized, 那意思很清楚,想进入某个方法执行存款或取款操作,必须得先获得一把锁才行。


(注:为了简化,这里没有做边界条件检查。)

但是在做转账操作的时候,为了保证一致性,必须得把两个账户都加上锁,然后才可以操作,于是张大胖写下了这样的代码,他觉得很简单,立刻就提交给Bill ,让他Review。


image.png

富有经验的Bill立刻就发现了问题,马上对张大胖说:“这样会出现死锁!”

张大胖说:“这么简单的代码,怎么可能有死锁?”

“假设线程1 做的操作是账户A给账户B转账, 先锁住了A账户, 接下来试图申请B账户的锁;与此同时线程2 在从 账户B给账户A 转账, 先锁住了B账户的锁, 接下来试图申请A账户的锁。两个线程各自持有资源, 然后等待获取对方的资源, 都无法执行下去, 死锁就出现了!”

张大胖无言以对,不得不承认Bill是正确的。他问道:“那怎么解决这个问题?”

“非常简单,加锁的时候按次序来就可以了,例如所有的线程,无论是从A向B转账,还是从B向A转账,都先获得账号A的锁,成功后再获得账户B的锁,这样就没问题了。”

张大胖说:“那样代码会变得很古怪啊,还得给两个账户排个顺序,如果不知道背后的思想读起来很痛苦,怪不得人家说多线程编程很难啊。”

Bill说:“是啊, 其实线程这个东西,就是一段代码的执行而已, 是操作系统层面的概念,可是我们苦逼的程序员不得不来面对它,来背这个多线程并发的锅了。”

二、黑盒子

下班后,张大胖一直在思考这个问题:既然线程是操作系统层面的概念,能不能把线程的概念隐藏起来,然后所有的操作都不用加锁呢? 这样以来编程就会容易得多啊!

本质的问题是什么?

首先是共享的状态,例如Account中的balance ,多个线程都要读写, 其次就是多个线程乱序、并发执行。

能不能换个思路,把这个Account对象看成一个黑盒子,你想存款了,就发一个存款的消息过来,想取款就发一个取款的消息过来。

不管是有一个消息,还是有100个消息,我统统放到黑盒子的一个队例中,然后让Account对象一个个顺序处理不就可以了? 根本不用在方法上加锁!

这样做,其实就是把并发的操作变成了串行的操作而已!


image.png

不对,如果调用方把取款消息放下就走,不等待返回结果,那就不是同步操作,而是异步操作了!但是如果取款的时候发现余额不足,怎么通知调用方?嗯,调用方也必须是个黑盒子对象,也向它发送异步消息,这个消息也会在消息队列中存下来,调用方“黑盒子”也会一个个处理。


image.png

想到这一层,张大胖激动起来:取款和存款的操作就不用在加锁了,码农们只要考虑黑盒子对消息的处理即可:取出消息,处理消息,向别的黑盒子发送消息, 根本不用考虑线程这样底层的概念了。

三、Actor模型

第二天张大胖赶紧找到Bill, 向他炫耀自己的“新发明”。

Bill不动声色:“小伙子,不错啊,重新发明了轮子!”

“重新发明?”

“是啊,你这个所谓黑盒子,就是所谓Actor模型啊! 它最早由Carl Hewitt在1973定义,其消息传递的方式更加符合面向对象的原始意图, 这一点我想你也体会到了,要不你怎么把他们叫做黑盒子啊。”

“1973年? 我还没出生。唉,看来这些概念已经被老前辈们都发明完了啊。”

“Actor属于并发组件模型 ,可以把程序员从多线程并发或线程池等基础概念中解放出来。它有这么几个特点:”

image.png

张大胖说:“和我之前的图差不多,看来我确实是重新发明了轮子啊。”

四、用Actor实现转账

Bill 笑道:“这个Actor看起来很美,但是编程的时候你得刷新一下你的思维才行。 大胖,之前你的转账操作在多线程下不是会出现死锁吗? 你考虑下,如果用Actor的思路该怎么写?”

“首先,得有两个Actor, 这两个Actor 表示了两个账户,我把它们叫做旺财和小强。”

“然后呢,转账的逻辑怎么处理?”

张大胖想了一会:“既然转账是在两个Actor之间发生的,那可以引入一个协调者Actor,叫做转账管家吧。不过,由于消息都是异步的,转账管家向旺财这个Actor发起扣款请求以后,不知道什么时候才能真正执行扣款,也不能立刻知道是否成功,必须得等待啊,这就有点麻烦了。”

Bill说:“我给你画个流程图,你看看。”


image.png

张大胖感慨地说:“原来的多线程并发模型,需要同时锁住两个账户,然后才能进行转账。现在每个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,并且降低切换成本以及内存使用。但如何做到这一点呢?

陈力就列,不能者止

这句话是说,能干活的代码片段就放在线程里,如果干不了活(需要等待,被阻塞等),就摘下来。通俗的说就是不要占着茅坑不拉屎,如果拉不出来,需要酝酿下,先把茅坑让出来,因为茅坑是稀缺资源。

要做到这点一般有两种方案:

GreenThread

几个概念

3.Goroutine

Goroutine其实就是前面GreenThread系列解决方案的一种演进和实现。

image.png

这个图一般讲Goroutine调度器的地方都会引用,想要仔细了解的可以看看原博客。这里只说明几点:

4.Goroutine是银弹么?

Goroutine很大程度上降低了并发的开发成本,是不是我们所有需要并发的地方直接go func就搞定了呢?

Go通过Goroutine的调度解决了CPU利用率的问题。但遇到其他的瓶颈资源如何处理?比如带锁的共享资源,比如数据库连接等。互联网在线应用场景下,如果每个请求都扔到一个Goroutine里,当资源出现瓶颈的时候,会导致大量的Goroutine阻塞,最后用户请求超时。这时候就需要用Goroutine池来进行控流,同时问题又来了:池子里设置多少个Goroutine合适?

所以这个问题还是没有从根本上解决。

七、Actor模型

Actor对没接触过这个概念的人可能不太好理解,Actor的概念其实和OO里的对象类似,是一种抽象。面对对象编程对现实的抽象是对象=属性+行为(method),但当使用方调用对象行为(method)的时候,其实占用的是调用方的CPU时间片,是否并发也是由调用方决定的。这个抽象其实和现实世界是有差异的。现实世界更像Actor的抽象,互相都是通过异步消息通信的。比如你对一个美女say hi,美女是否回应,如何回应是由美女自己决定的,运行在美女自己的大脑里,并不会占用发送者的大脑。

所以Actor有以下特征:

Actor遵循以下规则:

Actor的目标:

Actor的实现:

八、Golang CSP VS Actor

二者的格言都是:

Don’t communicate by sharing memory, share memory by communicating

通过消息通信的机制来避免竞态条件,但具体的抽象和实现上有些差异。

从这样看来,CSP的模式比较适合Boss-Worker模式的任务分发机制,它的侵入性没那么强,可以在现有的系统中通过CSP解决某个具体的问题。它并不试图解决通信的超时容错问题,这个还是需要发起方进行处理。同时由于Channel是显式的,虽然可以通过netchan(原来Go提供的netchan机制由于过于复杂,被废弃,在讨论新的netchan)实现远程Channel,但很难做到对使用方透明。

而Actor则是一种全新的抽象,使用Actor要面临整个应用架构机制和思维方式的变更。它试图要解决的问题要更广一些,比如容错,比如分布式。但Actor的问题在于以当前的调度效率,哪怕是用Goroutine这样的机制,也很难达到直接方法调用的效率。当前要像OO的『一切皆对象』一样实现一个『一切皆Actor』的语言,效率上肯定有问题。所以折中的方式是在OO的基础上,将系统的某个层面的组件抽象为Actor。

部分选摘知乎 为什么 Go 语言如此不受待见?

评论区又见洗地,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只能做到自动挡

九、从写程序的角度来看,有什么不同

参考关于并发模型 Actor 和 CSP

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语言,在使用细节上差别有点大。

上一篇下一篇

猜你喜欢

热点阅读