Wetts's blog

Stay Hungry, Stay Foolish.

0%

Java多线程编程实战指南-第12章-Master-Slave(主仆)模式

读书笔记 - Java 多线程编程实战指南


第十二章 Master-Slave(主仆)模式

模式介绍

  • Master-Slave 模式是一个基于分而治之(Divide and conquer)思想的设计模式。其核心思想是将一个任务(原始任务)分解为若干个语义等同(Semantically-identical)的子任务,并由专门的工作者线程来并行执行这些子任务。原始任务的处理结果是通过整合各个子任务的处理结果而形成的。而这些与分而治之相关的处理细节对于原始任务的提交方来说又是不可见的,因此,Master-Slave 模式既提高计算效率,又实现了信息隐藏。

-

模式架构

-

实战案例

某基于 Web Service 的电信系统需要一个系统流量统计工具。该工具的统计依据是该系统运行过程中产生的接口日志文件。接口日志文件记录了该系统接收到的请求、该系统返回给客户端的相应以及该系统调用外部系统时涉及的请求和响应的相关数据。
另外,流量统计工具要支持统计指定时间端内的系统流量。因此,我们把该工具分为两个模块:Linux Shell 脚本模块和 Java 模块。Linux Shell 脚本模块根据指定的时间段查找相应的接口日志文件,并将所有符合要求的接口日志文件的文件名通过标准输出传递给 Java 模块。Java 模块根据其标准输入(System.in)中指定的接口日志文件名读取相应的接口日志文件进行流量统计。
实现上述统计工具需要考虑一下几个问题:

  • 首先,该系统的接口日志文件被统一存储在日志文件服务器上。有时我们可能需要在日志服务器上直接统计系统流量。因此,我们系统统计工具占用的系统资源(CPU 时间和内存)尽可能地低,以免其运行干扰了日志服务器。
  • 其次,统计工具的统计依据-接口日志文件可能包含大量的纪录。以该系统单节点的流量为 100 TPS(Transaction per second,即 1s 内系统能够处理的请求数量),平均某个请求的处理会产生 4 条接口日志记录为例,那么 1h 会产生上千万条接口日志记录(100 * 3600 * 4 = 1440000)。因此,统计工具的统计速率要尽可能快,以免在急需统计结果的时候等待过久。
  • 当然,上述两个需求时矛盾的。我们只能在统计速率和资源消耗之间寻求一个平衡点。这里,我们可以使用 Master-Slave 模式:某个时间段内涉及的接口日志记录总数可能达上千万,因此原始任务的规模比较大。考虑到每个接口日志文件包含的纪录个数最多为 N 条(如 N 为10000),我们可以以文件为单位进行任务分解,将若干个日志文件合为一个子任务,并利用专门的工作者线程去执行这些子任务。再汇总各个子任务的统计结果并可得到我们所需要的最终结果。

本案例 Demo 详见 multithreading 工程的 com.Master_Slave 包

-

Master-Slave 模式的评价与现实考量

  • Master-Slave 模式的应用场景包括一下 3 个:
  • 并行计算(Parallel Computation)。 该场景使用 Master-Slave 模式是为了提升计算性能。本章案例就属于该运用场景。在此情景下,原始任务的处理结果是通过组合每个 Slave 实例的处理结果形成的。
  • 容错处理(Fault Tolerance)。 该场景使用 Master-Slave 模式是为了提高计算的可靠性。在此情形下,原始任务的处理结果是任意一个 Slave 实例的成功处理结果(那些处理失败的 Slave 实例无法为 Master 返回结果)。
  • 计算精度(Computational Accuracy)。 该场景使用 Master-Slave 模式是为了提高计算的精确程度。在此情形下,原始任务的处理结果是所有 Slave 实例中处理结果不精确性最低的一个结果。
  • Master-Slave 模式需要考虑一下几个问题:
  • 子任务的处理结果的收集

Master 参与者需要收集各个子任务的处理结果,才能生成原始任务的处理结果。收集子任务的处理结果通常有两种方法。一个是使用存储仓库(Repository)。所谓的存储仓库是一个 Master 参与者和 Slave 参与者都能够访问的数据结构。Slave 参与者实例将其子任务的处理结果存入存储仓库。Master 参与者可以等待各个 Slave 参与者处理完毕后从存储仓库中获取各个子任务的处理结果。另外一种方法是使用 Promise 模式。这里,我们可以使 Slave 参与者的 subService 方法的返回值为 Promise 模式中的 Promise 参与者实例。通常可以使 subService 方法的返回值为一个 java.util.concurrent.Future 实例。这样,Master 参与者可以通过调用各个 Slave 参与者实例的 subService 方法的返回值的 get() 方法来获取子任务的处理结果。

  • Slave 参与者实例的负载均衡与工作窃取

在并行计算的场景中,为了使各个 Slave 参与者实例能够充分发挥其作用又不至于使其中某个负载过重,在子任务派发的时候我们需要注意各个 Slave 参与者实例所得到的子任务的规模和数量是否均衡。
如果原始任务的规模实现可知,那么 Master 参与者可以根据原始任务的规模和 Slave 参与者实例的个数算出的平均数进行子任务的派发。这时,每个 Slave 参与者实例被分配到的子任务的总规模是相等的。在并行计算场景中,一个 Slave 参与者实例通常就是一个线程。为了避免增加 JVM 线程调度的负担,Slave 线程的数量一般要根据 JVM 所在主机的 CPU 个数来定。通常,Slave 线程数量只比 CPU 个数大一点。
如果原始任务的规模事先不可知,则 Master 参与者在派发子任务的时候可能要采用某种负载均衡算法来使得各个 Slave 线程所分配到的子任务的规模和数量是均衡的。本章案例使用了简单的轮询(Round-Robin)算法来保持各个 Slave 线程的子任务负载均衡。
我们可以使用 Producer-Consumer 模式中介绍的工作窃取算法来动态调整各个 Slave 实例的计算负载。

  • 可靠性与异常处理

Master 参与者的实现类需要注意处理其与 Slave 参与者的通信异常以及 Slave 参与者对子任务处理的异常。
Master 参与者与 Slave 参与的通信异常指 Master 参与者实例调用 Slave 参与者实例的 subService 方法时出现的异常。
由于 Slave 参与者是运行在工作中线程中的,而 Master 参与者实例是运行在客户端线程中的,后者要获取前者抛出的异常有点困难。如果 Master 参与者利用上文所述的基于 Promise 模式的方法来获取子任务的处理结果,那么获取 Slave 参与者实例的处理异常就变得非常简单:Master 参与者实例调用 subService 返回值的 get() 方法获取子任务处理结果的时候,如果 get() 方法抛出异常则说明相应的子任务处理失败。
为了提高计算的可靠性,Master 参与者在侦测到上述异常时可以考虑由其自身重新执行处理失败的子任务,即让处理失败的子任务运行在客户端线程中,而不是在 Slave 参与者的工作者线程中。这类似于 ThreadPoolExecutor 提供的 java.util.ThreadPoolExecutor.CallerRunsPolicy 这个任务提交失败处理策略。

  • Slave 线程的停止

在并行计算场景中,每个 Slave 参与者实例就是一个线程。通常,这些线程会使用阻塞队列(BlockingQueue)来接受子任务,而线程则从阻塞队列中取出子任务(即调用 BlockingQueue 的 take() 方法)进行执行。而 BlockingQueue 的 take() 在队列为空时会使当前线程一直处于等待状态,因此当 Slave 线程处理完分配给其的所有子任务时,Slave 线程仍然未停止。此时,我们可以使用 Two-phase Termination 模式来实现 Slave 线程在处理完分配给其的子任务后停止。

-

Java 标准库实例

Java 标准库中没有使用 Master-Slave 模式