1.2 简单的并发
这一节中,我们来看一个需要处理并发请求的示例。 对于一个需要真正并发的应用,对请求的处理也必须是同时的,用来合作完成任务的执行器也必须随时可用。 假设读者有在 JVM 中写并发代码的经验,且在那里遇到过难题。 所以这里希望你和我们一样,发现代码很难写,从而喜欢一个简单的模型,不用线程和锁来实现并发,而且作为额外的好处,所写代码也大大减少。 首先,我们从概念层次考虑并发,然后来看看解决卖票问题的两种办法(共享可变状态/消息传递(Akka))。
示例:卖票
在这个例子中,顾客从 TicketingAgents 买票去 Events(TODO)。 在本书后边会深入研究这个例子,这里我们用它来描述实现并发的两个方法:传统的方法,即开发者负责使用线程来分发负载,以及消息传递方法,即发送消息,和对消息队列进行简单处理。 模型间的另外一个区别:原来的模型使用共享可变状态,消息模型则不可变。
什么是不变性?简单地说(TODO),如果什么是不变的,这意味着它在构造时获得状态,之后不能改变。 二十年来的编程经验越来越强调不变性的重要(在 C++ 和 Java 社区)。 这是面向消息模型重要的一面,通过使用一些仅用不变的东西来交互的合作者,我们避开(TODO)了困难的主要来源:改变同一对象的多方。 这个例子显示,有时这意味着在初始实现时需要多一点工作,但是相对缺陷的减少和扩展的好处,这是很小的代价(最终是更干净的实现,令代码更干净,从而也更易读易维护)。 最后,这也令代码更易于验证,因为单元测试不需要关心改变,状态改变是各参与方的工作(TODO)。 图1.2显示卖票应用最基本的流程:当用户表示他希望买票时,发生了什么(TODO)。
Figure 1.2 买票
考虑卖票问题,看起来用并发很好,因为模型的需求一方可能是巨大的:如果一百万人同时买,会发生什么? 在基于线程的模型中,最简单常见的实现是,对每个请求创建一个线程,这会立即招致灾难性的失败。 即使线程池也有失败的风险,因为请求可能会超时(TODO),所以如果执行线程一直很忙,客户在接受服务前可能就失败了。 进一步,第一个模型中大多数程序员假定,其他线程意味着容量的线性提升,这通常是错的,因为这些线程要相互竞争他们一起操作的共享的资源。 使用消息,和不同享可变状态,组合起来会减轻这些问题。
具体看两个方法之前,我们先简单考虑考虑域模型(TODO)。 显然,我们需要一个 Venue,其中包含 Seats,并调度 Events。 对于每个 Seat,我们打印出一张供 TicketingAgent 卖给 Customer 的票。 我们可能预计到问题出自哪里:对票的每个请求要求我们找到一张票,看看客户是否需要,如果需要,把票的状态从 Available 改为 Sold,然后打印给客户。 所以如果我们扩展这个模型的办法是建立更多地 TicketingAgent,显然,令他们有序地访问可用的票池(在各种条件下)是最终结果能成功的关键因素。 (如果失败会发生什么?下一节中,我们会讨论困扰这些工作其他问题,如容错,讨论并发时会对错误处理策略提出很多新想法) 图1.3显示我们如何适应这些限制,通过建立多个 TicketingAgent 来应对大量客户任意时刻的服务要求。
Figure 1.3 Customers, TicketingAgents 以及打印室之间的关系
我们只有一个打印室,但是那不意味着如果它出现问题,我们就会遇到大灾难(下一节中会讨论)。 这里的重点是,不阻塞对票池的访问,我们成功地建立起任意个 TicketingAgents 处理来自 Customers 的请求。
OPTION 1: SHARING STATE APPROACH
如图所示,TicketingAgents 必须为了访问可买的票的列表相互竞争。 当另一个客户的票正在打印时,某些等待时间导致 Customer 等待,这毫无意义。 这明显是实现缺陷损害了领域模型的合理性。
有一种优化办法,图1.4展示了修改后的办法:
Figure 1.4 Buying sequence
你可以看到,我们不再需要竭尽去里去防止 Agents 相互阻塞对 Tickets 的访问,如果之后有人加入,重写售票流程使之并发,结果也会更清晰。
有三种线程缺陷能导致灾难性地运行失败:
1 线程饿死 - 当执行线程都在忙,或者都死锁的时候,没有线程服务新请求,这事实上关闭了服务(虽然从管理员角度看,它仍启动并运行着)。
2 竞争条件 - 当共享数据被多个线程改变的时候,同时一个指定的方法在执行,内部逻辑可能被颠覆,产生不可预料的结果。
3 死锁 - 如果两个线程开始一个需要锁住多个资源的操作,而且不按照同一顺序获取资源,则死锁可能发生:线程1锁住了资源 A,线程2锁住资源 B,则当2试图锁住 A,1试图锁住 B 时,就产生了死锁。
即使你能避免这些问题,确保基于锁的代码是最优的这一过程也是很痛苦的。 加锁经常用的太多或太少;太多的时候,没什么能同时进行。太少的时候,会发生很难排除的 bug。 取得平衡非常困难。
TicketingAgents 经常需要相互等待,这意味着客户等票的时间就更长了。 TicketingAgents 在打印室增加(TODO)票的时候也需要等待。 一些等待是不必要的。 为什么一个 TicketingAgent 的客户需要等待另一个 TicketingAgent 的客户? 这明显是实现缺陷损害了领域模型的合理性。(TODO 重复)
OPTION 2: 消息传递方法
我们令事情更简单的方法(通过 Akka)主要是避免了刚讨论的所有问题的根源:共享状态。 这里是我们需要做的三个改变:
1 Events 和 Tickets 只能当作不变的消息在 TicketingAgent 和打印室之间传递。
2 对代理的请求和打印室会异步地排队,线程不需要等待方法完成,也不用等收据。
3 代理和打印室,不是相互引用对方,而是包含可以发送给对方的地址。
这些消息会临时保存在信箱里,之后处理,每次一个,按照到来的顺序。 (别担心,工具箱如何实现会在之后解释)
当然,合作者的重新组织产生了新的需求:票卖出后,TicketingAgent 需要有办法取票。 这是另外一块:一旦我们通过传递不可变的消息完成了上述任务,就不必担心我们的应用是否可以应对猛烈地、快速的需求增长。 图 1.5 展示 TicketingAgent 流程
Figure 1.5 TicketingAgent 序列
TicketingAgent 如何取票呢?呃,打印室会发送 Event 消息给代理。 每个 Event 消息会包含一批票,以及 Event 的统计信息。 有很多办法可以发送票给所有的 TicketingAgents,我们选择的其中一种是,让它们相互转播消息,使得系统基于 peer,以及给定何时传输的简单规则,这是我们之后详细讨论的一种扩展办法。(TODO 原文有误?) TicketingAgents 知道相互的地址,相互发送消息,如图1.6所示。 (在 Actor 的世界里会经常见到“链”,从简单的传播工作状态,到责任链设计模式的分布式,基于 actor 的版本)
Figure 1.6 分布式售票
必要时,打印室会发送带有下一批票的 Event 消息给 TicketingAgents 链。 Event 消息可以指示票售完,或者打印完。 如果链中的 TicketingAgent 有余票,我们选择转发给其他代理,而不是仅仅返回到票池中。 另一个选项是让票可以过期;给每个 TicketingAgent 都发票,过了给定时间,他们拥有的票就失效了,不能再出售。
传递消息的方法有很多好处。 最重要的,没有锁要管理了。 任意多的 TicketingAgents 可以无锁地并行卖票,因为他们有自己的票。 当其发送“再来些票”消息的时候,TicketingAgent 不用等打印室的回应,打印室准备好了会发送回应。 也可以实现缓冲方案,一旦可售票的数量低于一定量,就发送取票请求(假设票在缓冲区变空之前会被取回。)
即使打印室崩溃了,所有的代理也可以继续卖票,直到卖完。 如果 TicketingAgent 发消息给崩溃了的打印室,TicketingAgent 不会等待,或者崩溃。 因为代理不直接知道打印室,给打印室地址的消息能被其他对象处理,如果消息系统打算把崩溃的打印室换掉,给新的打印室使用旧地址(后边有更详细的说明)。
这是 Akka 的典型实现。 消息传递例子中的 TicketingAgent 和打印室可以用 Akka 的 Actors 实现。 Actors 不共享状态,只能通过不变的消息进行通信,相互不直接交谈,而是通过 actor 引用,类似我们谈的地址。 这个方法满足了我们要改变的三件事。 这个为什么比共享可变状态的方法简单?
- 我们不需要管理锁,不需要考虑怎么保护共享状态。在 Actor 内是安全的。 
- 我们受到无序访问导致的死锁、以及竞态条件和线程饿死的影响更少。使用 Akka 杜绝了大部分问题,减轻了负担。 
- 对一个共享可变状态的方案进行性能优化很艰难,容易出错,而且想通过测试来验证几乎是不可能的。 
侧边栏 Actor 模型并不新鲜。 Actor 模型根本不新鲜,已经出现了很久,想法是 Carl Hewitt, Peter Bishop, and Richard Steiger 1973年提出的。
爱立信在1986年左右开发的 Erlang 语言和 OTP 库,支持 Actor 模型,已经用来搭建要求高可用的海量大规模系统。 Erlang 成功的例子有 AXD301 交换机,可靠性达到99.9999999%,即9个9的可靠性。 Akka 实现的 Actor 模型在很多细节上与 Erlang 的实现不同,但是受到它的明显影响,且使用很多相同的概念。
Akka 中完全没有像锁之类的同步原语?当然有,只是你不需要直接接触它。 每个东西最终都会运行在线程和同步原语之上。 Akka 使用 java.util.concurrent 库来协调消息传递,使用了很多精力来让锁的使用最少。 只要可能,都使用无锁和无等待的算法,如 compare-and-swap(CAS)技术,这不在本书讨论范围之内。 因为 actor 之间无共享,对象之间存在的共享锁根本不存在。 但是如我们稍后看到的,如果不小心共享了状态,仍然可能会遇到麻烦。
我们也看到,在消息传递方法中,我们得有重新分发票的方法。 我们需要建立不同的程序模型,这应该你预料的,没有免费的午餐。 但是优势是,我们把未知的,可能是大量的扩展相关的,阻止灾难的工作,换成改造为核心合作者的交互的少量修改,在这个例子中就是,简单的分发负载(即要卖出的票)。 本书后边,我们会看到把负载放在 domain(TODO)层在扩展时是有利的,因为我们已经提供了分发负载的方法来利用额外的资源。
还有其他因为 Akka 使用消息传递方法带来的好处,我们在下一节中讨论。 我们简单的接触已经:
- 即使在第一个简单的例子里,消息传递方法显然更容错更好,一个组件(无论多么关键)失败时避免了大灾难, 
- 例子中,共享可变状态总是在一个地方(如果都在内存中,就是在一个虚拟机中)。 如果你需要在这个限制之外扩展,你需要以某种方式(重新)分发数据。 因为数据传输方式使用地址,如果本地和远程地址是可以互换的,不需要改变任何代码就可以横向扩展。 
所以,我们付出了多一些的显式合作的代价,换来了明确地长期收益。