读书笔记 - Java 多线程编程实战指南
第八章 Active Object(主动对象)模式
模式介绍
- Active Object 模式是一种异步编程模式。它通过对方法的调用(Method Invocation)与方法的执行(Method Execution)进行解藕(Decoupling)来提高并发性。若以任务的概念来说,Active Object 模式的核心则是它允许任务的提交(相当于对异步方法的调用)和任务的执行(相当于异步方法的真正执行)分离。这有点类似于 System.gc() 这个方法:客户端代码调用完 gc() 后,一个进行垃圾回收的任务被提交,但此时 JVM 并不一定进行了垃圾回收,而可能是在 gc() 方法调用返回后的某段时间才开始执行任务——回收垃圾。我们知道,System.gc() 的调用方代码是运行在自己的线程上(通常是 main 线程派生的子线程),而 JVM 的垃圾回收这个动作则由专门的工作者线程(垃圾回收线程)来执行。换而言之,System.gc() 这个方法所代表的动作(其所定义的功能)的调用方和执行方是运行在不同的线程中的,从而提高了并发性。
-
模式架构
- 当 Active Object 模式对外暴露的异步方法被调用时,与该方法调用相关的上下文信息,包括被调用的异步方法名(或其代表的操作)、客户端代码所传递的参数等,会被封装成一个对象。该对象被称为方法请求(Method Request)。方法请求对象会被存入 Active Object 模式所维护的缓冲区(Activation Queue)中,并由专门的工作者线程负责根据其包含的上下文信息执行相应的操作。也就是说,方法请求对象是由客户端线程(Client Thread)通过调用 Active Object 模式对外暴露的异步方法生成的,而方法请求所代表的操作则由专门的工作者线程来执行,从而实现了方法的调用与执行的分离,产生了并发。
-
实战案例
某电信软件有一个彩信短号模块。其主要功能是实现手机用户给其他手机用户发送彩信时,接收方号码可以填写为对象的短号。例如,用户 13612345678 给同事 13787654321 发送彩信时,可以将接收方号码填写为对象的短号,如 776,而非真实的号码。
该模块处理接收到的下发彩信请求的一个关键操作是,查询数据库以获得接收方短号对应的真实号码(长号)。该操作可能因为数据库故障而失败,从而使整个请求无法继续被处理。而数据库故障是可恢复的故障,因此在短号转换为长号的过程中如果出现数据库异常,可以先将整个下发彩信请求消息缓存到磁盘中,等到数据库恢复后,再从磁盘中读取请求消息,进行重试。为方便起见,我们可以通过 Java 的对象序列化 API,将表示下发彩信的对象序列化到磁盘文件中从而实现请求缓存。
首先,请求消息缓存到磁盘中涉及文件 I/O 这种慢的操作,我们不希望它在请求处理的主线程(即 Web 服务器的工作者线程)中执行。因为这样会使该模块的响应延时增大,降低系统的响应性,并使得 Web 服务器的工作者线程因等待文件 I/O 而降低了系统的吞吐量。这时,异步处理旧派上用场了。Active Object 模式可以帮助我们实现请求缓存这个任务的提交和执行分离:任务的提交是在 Web 服务器的工作者线程中完成的,而任务的执行(包括序列化对象到磁盘文件中等操作)则是在 Active Object 工作者线程中执行的。这样,请求处理的主线程在侦测到短号转长号失败时即可触发对当前彩信下发请求进行缓存,接着继续其请求处理,如给客户端响应。而此时,当前请求消息可能正在被 Active Object 线程缓存到文件中。
其次,每个短号转长号失败的彩信下发请求消息会缓存为一个磁盘文件,但我们不希望这些缓存文件被存在同一个子目录下,而是希望多个缓存文件会被存储到多个子目录中。每个子目录最多可以存储指定个数(如 2000 个)的缓存文件。若当前子目录已存满,则新建一个子目录存放新的缓存文件,直到该子目录也存满,依此类推。当这些子目录的个数到达指定数量(如 100 个)时,最老的子目录(连同其下的缓存文件,如果有的话)会被删除,从而保证子目录的个数也是固定的。显然,在并发环境下,实现这种控制需要一些并发访问控制(如通过锁来控制),但是我们不希望这种控制暴露给处理请求的其他代码。而 Active Object 模式中的 Proxy 参与者可以帮助我们封装并发访问控制。
本案例 Demo 详见 multithreading 工程的 com.ActiveObject 包
-
Active Object 模式的评价与实现考量
- Active Object 模式通过将方法的调用与执行分离,实现了异步编程。有利于提高并发性,从而提高系统的吞吐率。
- Active Object 模式还有个好处是它可以将任务(MethodRequest)的提交(调用异步方法)和任务的执行策略(Execution Policy)分离。任务的执行策略被封装在 Scheduler 的实现类之内,因此它对外是“不可见”的,一旦需要变动也不会影响其他代码,从而降低了系统的耦合行。任务的执行策略可以反映以下一些问题:
- 采用什么顺序去执行任务,如 FIFO、LIFO,或者基于任务中包含的信息所定的优先级?
- 多少个任务可以并发执行?
- 多少个任务可以被排队等待执行?
- 如果有任务由于系统过载被拒绝,此时哪个任务该被选中作为牺牲品,应用程序该如何被通知到?
- 任务执行前、执行后需要执行哪些操作?
- Active Object 模式实现异步编程也有代价。该模式的参与者有 6 个之多,其实现过程也包含了不少中间的处理:MethodRequest 对象的生成、MethodRequest 对象的移动(进出缓冲区)、MethodRequest 对象的运行调度和线程上下文切换等。这些处理都有其空间和时间的代价。因此,Active Object 模式适合于分解一个比较耗时的任务(如涉及 I/O 操作的任务):将任务的发起和执行进行分离,以减少不必要的等待时间。
- 错误隔离
错误隔离指一个任务的处理失败不影响其他任务的处理。每个 MethodRequest 实例可以看做一个任务。那么,Scheduler 的实现类在执行 MethodRequest 时需要注意错误隔离。如果自己编写代码实现 Scheduler,用单个 Active Object 工作者线程逐一执行所有任务,则需要特别注意线程的 run 方法的异常处理,确保不会因为个别任务执行时遇到一些运行时异常而导致整个线程终止。
- 缓冲区监控
如果 ActivationQueue 是有界缓冲区,则对缓冲区的当前大小进行监控无论是对于运维还是测试来说都有其意义。从测试的角度来看,监控缓冲区有助于确定缓冲区容量的建议值(合理值)。
- 缓冲区饱和处理的策略
当任务的提交速率大于任务的执行速率时,缓冲区可能逐渐积压到满。这时新提交的任务会被拒绝。无论是自己编程代码还是利用 JDK 现有类来实现 Scheduler,对于缓冲区满时新任务提交失败,我们需要一个处理策略用于决定此时哪个任务会成为“牺牲品”。若使用 ThreadPoolExecutor 来实现 Scheduler 有个好处,是它已经提供了几种缓冲区饱和处理策略的实现代码,应用代码可以直接调用。
- ThreadPoolExecutor.AbortPolicy 直接抛出异常
- ThreadPoolExecutor.DiscardPolicy 丢弃当前被拒绝的任务
- ThreadPoolExecutor.DiscardOldestPolicy 将缓冲区中最老的任务丢弃,然后重新尝试接纳被拒绝的任务
- ThreadPoolExecutor.CallerRunsPolicy 在任务的提交方线程中运行被拒绝的任务
- Scheduler 空闲工作者线程清理
如果 Scheduler 采用多个工作者线程(如采用 ThreadPoolExecutor 这样的线程池)来执行任务,则可能需要清理空闲的线程以节约资源。
-
Java标准库实例
类 java.util.concurrent.ThreadPoolExecutor 可以看成是 Active Object 模式的一个通用实现。ThreadPoolExecutor 自身相当于 Active Object 模式的 Proxy 和 Scheduler 参与者实例。ThreadPoolExecutor 的 submit 方法相当于 Active Object 模式对外暴露的异步方法。该方法的唯一参数(java.util.concurrent.Callable 或者 java.lang.Runnable)可以看作是 MethodRequest 参与者实例。该方法的返回值(java.util.concurrent.Future)相当于 Future 参与者实例,而 ThreadPoolExecutor 的构造方法中需要传入的 BlockingQueue 实例相当于 ActivationQueue 参与者实例。