传统的游戏服务器要么是单线程要么是多线程,过去几十年里CPU一直遵循摩尔定律发展,带来的结果是单核频率越来越高。而近几年摩尔定义在CPU上已然失效,为什么呢?
大于在2003年左右,计算机的核心特性经历了一个重要的变化,处理器的速度达到了一个顶点。在接下来近15年里,时钟速度是呈线性增长的,而不会像以前那样以指数级的速度增长。
由于CPU的工艺制程和发热稳定性之间难以取舍,取而代之的策略是增加CPU核心的数量。多核处理器应运而生,计算处理变成了团队协作,效率的提升通过多个核心的通信来实现,而不是传统的时钟速度的提升。这也是线程发挥作用的地方。
目前家用PC四核已经非常常见,服务器更是达到32核64线程。为了高效的利用多核CPU,应该在代码层面就考虑并发性。经过十几年痛苦的开发经历,事实告诉我们线程并不是获取并发性的好方法,而往往会带来难以查找的问题。
例如:以稀缺资源的计数为例,如商品的库存数量或活动的可售门票,可能存在多个请求同时获取一个或多个商品或门票。考虑常用实现方式,每个请求对应一个线程,很可能会有多个并发运行的线程都去调整计数器。模型必须确保在同一时间只能有一个线程去递减计数器的值。这样做的原因是因为递减操作存在两个步骤:首先检查当前计数器,确保计数器的值大于或等于要减少的值。其次递减计数器。
为什么要将两步操作作为一个整体操作来完成呢?
因为每个请求代表购买一个或多个,假设有两个线程并发地调整计数器,若计数器目前为10, 线程1要想计数器递减2,线程2想要计数器递减9,线程1和线程2都会检查当前计数器的值,而计数器的值均大于要递减的数量。所以线程1和线程2都会继续运行并递减计数器的值,最后的结果是多少呢?10-2-9=-1,问题来了。这样的结果直接操作库存被过度分配,违反了业务规则。
为了防止过度分配,原生的方式是将检查和递减两步操作放到一个原子操作中,将两步操作锁定到一个操作中,就能够消除过度分配的可能性。
例如,两个线程同时尝试购买最后一件商品时,如果没有锁就可能出现多个线程同时断定计数器的值大于或等于购买数量,然后错误地递减计数器,从而导致出现负数。
然而,问题的根源在于一个请求对应一个线程。
另外,在高度竞争的阶段,很有可能出现很长的线程队列,他们都在等待递减计数器。但使用队列的方式的问题在于可能造成众多阻塞线程,也就是每个线程都在等待轮到它们去执行一个序列化的操作。
所以,应用设计者一不小心,内在的复杂性就有可能将多核多线程的应用变成单线程的应用,或者导致工作线程之间存在高度竞争。
Actor模型优雅的解决了这个难题,为真正多线程的应用提供了一个基础支持。
为什么会出现Actor这种并发编程的模型呢?
关于这一点需要先说说并发性中的一致性和隔离性,一致性是让数据保持一致,例如银行转账的场景中,转账完成时双方账户必须是一方减少一方增加。而隔离性而可以理解为牺牲一部分一致性需求,从而获得性能的提升。例如,在完全一致性的情况下,任务是串行的,此时也就不存在隔离性了。
那为什么会有Actor模型呢?
因为传统并发模式中,共享内存是倾向于强一致性弱隔离性的,例如悲观锁同步的方式就是使用强一致性的方式控制并发,而Actor模型天然是强隔离性且弱一致性的,所以Actor模型在并发中有良好的性能,而且易于控制和管理。
Actor模型的设计是消息驱动和非阻塞的,吞吐量自然也被考虑在内。
Actor模型适用于对一致性需求不是很高且对性能需求较高的场景
综上所述,计算机CPU的计算速度(频率)的提高是有限的,剩下能做的是放入多个计算核心以提升性能。为了利用多核心的性能,需要并发执行。但多线程的方式往往会引入很多问题,同时直接增加了调试难度。
为什么Actor模型是一种处理并发问题的解决方案呢?
处理并发问题一贯的思路是如何保证共享数据的一致性和正确性。
一般而言,有两种策略用来在并发线程中进行通信:共享数据、消息传递
使用共享数据的并发编程面临的最大问题是数据条件竞争data race
,处理各种锁的问题是让人十分头疼的。和共享数据方式相比,消息传递机制最大的优势在于不会产生数据竞争状态。而实现消息传递有两种常见类型:基于channel
的消息传递、基于Actor
的消息传递。
为什么要保持共享数据的正确性呢?
无非是因为程序是多线程的,多个线程对同一个数据操作时若不加入同步条件,势必造成数据污染。
那么为什么不能使用单线程去处理请求呢?
大部分人认为单线程处理相比多线程而言,系统的性能将大打折扣。Actor模型的出现解决了这些问题。
- 进程间通信
把通信的线程可以想象成两个无法直接说话而必须通过邮件交流的人,双方要交流就要发送邮件。发送方邮件一旦发出就不能修改任何内容,而且是没有办法收回修改后再发的,这也就是消息一旦发出就不可改变。对于接收方而言,想什么时候看邮件就什么时候看,而且不需要监听,这就叫异步。接收方看了发送方的邮件可以回复也可以撒都不做。只是回复邮件一旦发出也同样是不能收回修改的,也就是不可变性两端都是一样的。同样,发送方针对回复邮件,也是想什么时候看就什么时候看。两端同样都是异步的。这种通信模型就是Actor想要的模型,可以发现这种通信方式其实依赖一套邮件系统或叫做消息管理系统。进程内部要有一套这样的系统,给每个线程一个独立的收发消息的管道,并且都是异步的。
- 并发性
并发导致最大的问题是对共享数据的操作,面对并发问题时多采用锁去保证共享数据的一致性,但同样也会带来一系列的副作用,比如要去考虑锁的粒度(对方法、程序块等)、锁的形式(读锁、写锁等)等问题。
传统的并发编程的方式大多使用锁机制,相信大多数都是悲观锁,这几乎可以断定会出现两个非常明显的问题:随着项目体量增大,业务愈加复杂,不可避免地会大量的使用锁,然而锁的机制其实是很低效的。即使大量依赖锁解决了项目中资源竞争的情况,但由于没有一个规范的编程模式,最后系统的稳定性肯定会出问题,最根本的原因是没有把系统的任务调度抽象出来,由于任务调度和业务逻辑耦合在一起,很难做一个很高层的抽象以保证任务调度有序性。
Actor模型为并发而生,是为解决高并发的一种编程思路。使用并发编程时需要特别关注锁与内存原子性等一系列的线程问题,Actor模型内部的状态由自身维护,也就是说Actor内部数据只能由它自己通过消息传递来进行状态修改,所以使用Actor模型可以很好地避免这些问题。
Actor为什么一定程度上可以解决这些问题呢?
因为Actor模型下提供了一种可靠的任务调度系统,也就是在原生的线程或协程的级别上做了更高层次的封装,这会给编程模式带来巨大的好处:由于抽象了任务调度系统所以系统的线程调度可控,易于统一处理,稳定性和可维护性更高。另外开发者只需要关心每个Actor的逻辑即可从而避免了锁的滥用。
Actor就没有缺点吗?
当然不是,比如当所有逻辑都跑在Actor中的时候,很难掌握Actor的粒度,稍有不慎就可能造成系统中Actor个数爆炸的情况。另外,当必须共享数据或状态时很难避免使用锁,由于Actor可能会堵塞自己但Actor不应该堵塞它运行的线程,此时也许可选择使用Redis做数据共享。
Actor模型
Actor模型是1973年提出的一个分布式并发编程模式,在Erlang语言中得到广泛支持和应用。
在Actor模型中,Actor
参与者是一个并发原语,简单来说,一个参与者就是一个工人,与进程或线程一样能够工作或处理任务。
可以将Actor想象成面向对象编程语言中的对象实例,不同的是Actor的状态不能直接读取和修改,方法也不能直接调用。Actor只能通过消息传递的方式与外界通信。每个参与者存在一个代表本身的地址,但只能向该地址发送消息。
在计算机科学领域,Actor是一个并行计算的数学模型,最初是为了由大量独立的微处理器组成的高并行计算机所开发的。
Actor模型的理念非常简单:万物皆Actor
Actor模型将Actor
当作通用的并行计算原语:一个参与者Actor
对接收到的消息做出响应,本地策略可以创建出更多的参与者或发送更多的消息,同时准备接收下一条消息。
简单来说,Actor模型是一个概念模型,用于处理并发计算。它定义了一系列系统组件应该如何动作和交互的通用规则,最著名的使用这套规则的编程语言是Erlang。
Erlang引入了”随它崩溃“的哲学理念,这部分关键代码被监控着,监控者supervisor
唯一的职责是知道代码崩溃后干什么,让这种理念成为可能的正是Actor模型。
在Erlang中,每段代码都运行在进程中,进程是Erlang中对Actor的称呼,意味着它的状态不会影响其他进程。系统中会有一个supervisor
,实际上它只是另一个进程。被监控的进程挂掉了,supervisor
会被通知并对此进行处理,因此也就能创建一个具有自愈功能的系统。如果一个Actor到达异常状态并且崩溃,无论如何,supervisor
都可以做出反应并尝试把它变成一致状态,最常见的方式就是根据初始状态重启Actor。
简单来说,Actor通过消息传递的方式与外界通信,而且消息传递是异步的。每个Actor都有一个邮箱,邮箱接收并缓存其他Actor发过来的消息,通过邮箱队列mail queue
来处理消息。Actor一次只能同步处理一个消息,处理消息过程中,除了可以接收消息外不能做任何其他操作。
每个Actor是完全独立的,可以同时执行他们的操作。每个Actor是一个计算实体,映射接收到的消息并执行以下动作:发送有限个消息给其他Actor、创建有限个新的Actor、为下一个接收的消息指定行为。这三个动作没有固定的顺序,可以并发地执行,Actor会根据接收到的消息进行不同的处理。
在Actor系统中包含一个未处理的任务集,每个任务都由三个属性标识:
-
tag
用以区分系统中的其他任务 -
target
通信到达的地址 -
communication
包含在target
目标地址上的Actor,处理任务时可获取的信息。
为简单起见,可见一个任务视为一个消息,在Actor之间传递包含以上三个属性的值的消息。
Actor模型有两种任务调度方式:基于线程的调度、基于事件的调度
- 基于线程的调度
为每个Actor分配一个线程,在接收一个消息时,如果当前Actor的邮箱为空则会阻塞当前线程。基于线程的调度实现较为简单,但线程数量受到操作的限制,现在的Actor模型一般不采用这种方式。 - 基于事件的调度
事件可以理解为任务或消息的到来,而此时才会为Actor的任务分配线程并执行。
因此,可以把系统中所有事物都抽象成为一个Actor:
- Actor的输入是接收到的消息
- Actor接收到消息后处理消息中定义的任务
- Actor处理完成任务后可以发送消息给其它Actor
在一个系统中可以将一个大规模的任务分解为一些小任务,这些小任务可以由多个Actor并发处理,从而减少任务的完成时间。
Actor模型的另一个好处是可以消除共享状态,因为Actor每次只能处理一条消息,所以Actor内部可以安全的处理状态,而不用考虑锁机制。
Actor
包含发送者和接收者,设计简单的消息驱动对象用来实现异步性。
例如:将计数器场景中基于线程的实现替换为Actor
,当然Actor
也要在线程中运行,但Actor
只在有事情可做(没有消息要处理)的时候才会使用线程。
在计数器场景中,请求者代表CutomerActor
,计数器数量由TicketsActor
来维护并持有当前计数器的状态。CustomerActor
和TicketsActor
在空闲idle
或没有事情做的时候都不会持有线程。
在初始购买操作时CustomerActor
需要发送一个消息给TicketsActor
,消息中包含了要购买的数量。当TicketsActor
接收到消息时会校验购买数量是否超过库存数量,若合法则递减数量。此时TicketsActor
会发送一条消息给CutomerActor
表明订单被成功接受。若购买数量超过库存数量TicketsActor
也会发送给CustomerActor
一条消息,表明订单被拒绝。
可划分两个阶段的行为检查和递减操作,也可以通过同步操作序列来完成。但是基于Actor
的实现不仅在每个Actor
中提供了自然的操作同步,还能避免大量的线程积压,防止线程等待轮到它们执行同步代码区域。明显会降低系统资源的占用。
Actor
模型本身确保处理是按照同步的方式执行的。TicketsActor
会处理其收件箱中的每条消息,注意这里没有复杂的线程或锁,只是一个多线程的处理过程,但Actor
系统会管理线程的使用和分配。
Actor是由状态(state)、行为(behavior)、邮箱(mailbox)三者组成的。
- 状态(state):状态是指actor对象的变量信息,状态由actor自身管理,避免并发环境下的锁和内存原子性等问题。
- 行为(behavior):行为指定的是actor中计算逻辑,通过actor接收到的消息来改变actor的状态。
- 邮箱(mailbox):邮箱是actor之间的通信桥梁,邮箱内部通过FIFO消息队列来存储发送发消息,而接收方则从邮箱中获取消息。
Actor模型描述了一组为避免并发编程的公理:
- 所有的Actor状态是本地的,外部是无法访问的。
- Actor必须通过消息传递进行通信
- 一个Actor可以响应消息、退出新Actor、改变内部状态、将消息发送到一个或多个Actor。
- Actor可能会堵塞自己但Actor不应该堵塞自己运行的线程
Actor参与者
Actor的概念来自于Erlang,在AKKA中可以认为一个Actor就是一个容器,用来存储状态、行为、邮箱Mailbox、子Actor、Supervisor策略。Actor之间并不直接通信,而是通过邮件Mail来互通有无。Actor模型的本质就是消息传递,作为一种计算实体,Actor与原子类似。参与者是一个运算实体,回应接收到的消息,同时并行的发送有限数量的消息给其他参与者、创建有限数量的新参与者、指定接收到下一个消息时的行为。
Actor模型推崇的哲学是”一切皆是参与者“,与面向对象编程的”一切皆是对象“类似,但面向对象编程通常是顺序执行的,而Actor模型则是并行执行的。一个Actor指的是一个最基本的计算单元,能够接受一个消息并基于它执行计算。这个理念也很类似面向对象语言中:一个对象接收一个消息(方法调用),然后根据接收的消息做事儿(调用了哪个方法)。Actors一大重大特征在于actors之间相互隔离,它们并不相互共享内存。这点区别于上述的对象,也就是说,一个actor能维持一个私有的状态,并且这个状态不可能被另一个actor所改变。
在Actor模型中主角是actor,类似一种worker。Actor彼此之间直接发送消息,不需要经过什么中介,消息是异步发送和处理的。在Actor模型中一切都是Actor,所有逻辑或模块都可以看成是Actor,通过不同Actor之间的消息传递实现模块之间的通信和交互。
Mailbox邮箱
光有一个actor是不够的,多个actors才能组成系统。在Actor模型中每个actor都有自己的地址,所以他们才能相互发送消息。需要指明的一点是,尽管多个actors同时运行,但是一个actor只能顺序地处理消息。也就是说其它actor发送多条消息给一个actor时,这个actor只能一次处理一条。如果需要并行的处理多条消息时,需要将消息发送给多个actor。
消息是异步的传送到actor的,所以当actor正在处理消息时,新来的消息应该存储到别的地方,也就是mailbox消息存储的地方。
每个actor都有且仅有一个mailbox,mailbox相当于一个小型的队列,一旦sender发送消息,就将该消息入队到mailbox中。入队的顺序按照消息发送的时间顺序。
异步的发送消息是用actor模型编程的重要特性之一,消息并不是直接发送到一个actor,而是发送到一个mailbox中的。这样的设计解耦了actor之间的关系,每个actor都以自己的步调运行,且发送消息时不会被堵塞。虽然所有actor可以同时运行,但它们都按照mailbox接收消息的顺序来依次处理消息,且仅仅在当前消息处理完毕后才会处理下一个消息,因此我们只需要关心发送消息时的并发问题即可。
当一个actor接收到消息后,它能做如下三件事中的任意一件:
- 创建有限数量的新actors
- 发送有限数量的消息给其他参与者
- 指定下一条消息到来时的行为
之前说每个actor能维持一个私有状态,”指定下一条消息到来时的行为“意味着可以定义下一条消息来到时的状态,简单来说,就是actors如何修改状态。
以上操作不含有顺序执行的假设,因此可以并行进行。发送者与已经发送的消息解耦,是Actor模型的根本优势。这允许进行异步通信,同时满足消息传递的控制结构。消息接收者是通过地址区分的,也就是邮件地址。因此参与者只能和它拥有地址的参与者通信,他可以通过接收到的消息获取地址,或者获取它创建的参与者的地址。Actor模型的特征是,actor内部或之间进行并行计算,actor可以动态创建,actor地址包含在消息中,交互只有通过直接的异步消息通信,不限制消息到达的顺序。
最佳实践
素数计算
需求:使用多线程找出1000000以内素数个数
传统方式通过锁/同步的方式实现并发,每次同步获取当前值并让一个线程去判断值是否为素数,若是的话则通过同步方式对计数器加一。
使用Actor模型方式会将此过程拆分成多个模块,即拆分成多个Actor。每个Actor负责不同部分,并通过消息传递让多个Actor协同工作。
银行转账
存在的问题:当用户A Actor扣款期间,用户B Actor是不受限的,此时对用户B Actor进行操作是合法的,针对这种情况,单纯的Actor模型就显得比较乏力,需要加入其他机制来保证一致性。