文章目录
声明:
本博客是本人在学习《实战 Java 高并发程序设计》后整理的笔记,旨在方便复习和回顾,并非用作商业用途。
本博客已标明出处,如有侵权请告知,马上删除。
5.5 Future 模式
Future 模式是多线程开发中非常常见的一种设计模式,它的核心思想是异步调用。当我们需要调用一个函数方法时,如果这个函数执行很慢,那么我们就要进行等待。但有时候,我们可能并不急着要结果。因此,我们可以让被调者立即返回,让它在后台慢慢处理这个请求。对于调用者来说,则可以先处理一些其他任务,在真正需要数据的场合再去尝试获得需要的数据。
Future 模式有点类似在网上买东西。如果我们在网上下单买了一个手机,当我们支付完成后,手机并没有办法立即送到家里,但是在电脑上会立即产生一个订单。这个订单就是将来发货或者领取手机的重要凭证,这个凭证也就是 Future 模式中会给出的一个契约。在支付活动结束后,大家不会傻傻地等着手机到来,而是可以各忙各的。而这张订单就成为了商家配货、发货的驱动力。当然,这一切你并不用关心。你要做的,只是在快递上门时,开一下门,拿一下货而已。
对于 Future 模式来说,虽然它无法立即给出你需要的数据。但是,它会返回给你一个契约,将来,你可以凭借着这个契约去重新获取你需要的信息。
如图 5.6 所示,显示了通过传统的同步方法,调用一段比较耗时的程序。客户端发出 call 请求,这个请求需要相当长一段时间才能返回。客户端一直等待,直到数据返回,随后,再进行其他任务的处理。
使用 Future 模式替换原来的实现方式,可以改进其调用过程,如图 5.7 所示。
下面的模型展示了一个广义 Future 模式的实现,从 Data_Future 对象可以看到,虽然 call 本身仍然需要很长一段时间处理程序。但是,服务程序不等数据处理完成便立即返回客户端一个伪造的数据(相当于商品的订单,而不是商品本身),实现了 Future 模式的客户端在拿到这个返回结果后,并不急于对其进行处理,而去调用了其他业务逻辑,充分利用了等待时间,这就是 Future 模式的核心所在。在完成了其他业务逻辑的处理后,最后再使用返回比较慢的 Future 数据。这样,在整个调用过程中,就不存在无谓的等待,充分利用了所有的时间片段,从而提高系统的响应速度。
5.5.1 Future 模式的主要角色
为了让大家能够更清晰地认识 Future 模式的基本结构。在这里,我给出一个非常简单的 Future 模式的实现,它的主要参与者如表 5.2 所示。
它的核心结构如图 5.8 所示。
5.5.2 Future 模式的简单实现
在这个实现中,有一个核心接口 Data,这就是客户端希望获取的数据。在 Future 模式中,这个 Data 接口有两个重要的实现,分别是:
- RealData,也就是真实数据,这就是我们最终需要获得的,有价值的信息。
- FutureData,它就是用来提取 RealData 的一个 “订单”,因此 FutureData 是可以立即返回得到的。
下面是 Data 接口:
FutureData 实现了一个快速返回的 RealData 包装。它只是一个包装,或者说是一个 RealData 的虚拟实现。因此,它可以很快被构造并返回。当使用 FutrueData 的 getResult() 方法时,如果实际的数据没有准备好,那么程序就会阻塞,等待 RealData 准备好并注入到 FutureData 中,才最终返回数据。
注意: FutureData 是 Future 模式的关键。它实际上是真实数据 RealData 的代理,封装了获取 RealData 的等待过程。
RealData 是最终需要使用的数据模型。它的构造很慢。在这里,使用 sleep() 函数模拟这个过程,简单地模拟一个字符串的构造。
接下来就是我们的客户端程序,Client 主要实现了获取 FutureData,并开启构造 RealData 的线程。并在接受请求后,很快的返回 FutureData。注意,它不会等待数据真的构造完毕再返回,而是立即返回 FutureData,即使这个时候 FutureData 内并没有真实数据。
最后,就是我们的主函数 Main,它主要负责调用 Client 发起请求,并消费返回的数据。
5.5.3 JDK 中的 Future 模式
Future 模式是如此常用,因此 JDK 内部已经为我们准备好了一套完整的实现。显然,这个实现要比我们前面提出的方案复杂得多。在这里,我们将简单向大家介绍一下它的使用方式。
首先,让我们看一下 Future 模式的基本结构,如图 5.9 所示。其中 Future 接口就类似于前文描述的订单或者说是契约。通过它,你可以得到真实的数据。RunnableFuture 继承了 Future 和 Runnable 两个接口,其中 run() 方法用于构造真实的数据。它有一个具体的实现 FutureTask 类。FutureTask 有一个内部类 Sync,一些实质性的工作,会委托 Sync 类实现。而 Sync 类最终会调用 Callable 接口,完成实际数据的组装工作。
Callable 接口只有一个方法 call() ,它会返回需要构造的实际数据。这个 Callable 接口也是这个 Future 框架和应用程序之间的重要接口。如果我们要实现自己的业务系统,通常需要实现自己的 Callable 对象。此外, FutureTask 类也与应用密切相关,通常,我们会使用 Callable 实例构造一个 FutureTask 实例,并将它提交给线程池。
下面我们将展示这个内置的 Future 模式的使用:
上述代码实现了 Callable 接口,它的 call() 方法会构造我们需要的真实数据并返回。当然这个过程可能是缓慢的,这里使用 Thread.sleep() 模拟它:
上述代码就是使用 Future 模式的典型。第 4 行,构造了 FutureTask 对象实例,表示这个任务是有返回值的。构造 FutureTask 时,使用 Callable 接口,告诉 FutureTask 我们需要的数据应该如何产生。接着再第 8 行,将 FutureTask 提交给线程池。显然,作为一个简单的任务提交,这里必然是立即返回的,因此程序不会阻塞。接下来,我们不用关心数据是如何产生的。可以去做一些额外的事情,然后在需要的时候可以通过 Future.get()(第 18 行)得到实际的数据。
除了基本的功能外,JDK 还为 Future 接口提供了一些简单的控制功能:
5.6 并行流水线
并发算法虽然可以充分发挥多核 CPU 的性能。但不幸的是,并非所有的计算都可以改造成并发的形式。那什么样的算法是无法使用并发进行计算的呢?简单来说,执行过程中有数据相关性的运算都是无法完美并行化的。
假如现在有两个数,B 和 C。如果我们要计算(B+C)* B/2,那么这个运行过程就是无法并行的。原因是,如果 B+C 没有执行完成,则永远算不出(B+C)* B,这就是数据相关性。如果线程执行时,所需的数据存在这种依赖关系,那么,就没有办法将它们完美的并行化。如图 5.10 所示,诠释了这个道理。
那遇到这种情况时,有没有什么补救措施呢?答案是肯定的,那就是借鉴日常生产中的流水线思想。
比如,现在要生产一批小玩偶。小玩偶的制作分为四个步骤,第一要组装身体,第二要在身体上安装四肢和头部,第三,给组装完成的玩偶穿上一件漂亮的衣服,第四,就可以包装出货了。为了加快制作玩具的进度,我们不可能叫四个人同时加工一个玩具,因为这四个步骤有着严重的依赖关系。如果没有身体,就没有地方安装四肢,如果没有组装完成,就不能穿衣服,如果没有穿上衣服,就不能包装发货。因此,找四个人来做一个玩偶是毫无意义的。
但是,如果你现在要制作的不是 1 只玩偶,而是 1 万只玩偶,那情况就不同了。你可以找四个人,第一个人只负责组装身体,完成后交给第二个人;第二个人只负责安装头部和四肢,交付第三人;第三人只负责穿衣服,并交付第四人;第四人只负责包装发货。这样所有人都可以一起工作,共同完成任务,而整个时间周期也能缩短到原来的 1/4 左右,这就是流水线的思想。一旦流水线满载,每次只需要一步(假设一个玩偶需要四步)就可以产生一个玩偶,如图 5.11 所示。
类似的思想可以借鉴到程序开发中。即使(B+C)* B/2 无法并行,但是如果你需要计算一大堆 B 和 C 的值,你依然可以将它流水化。首先将计算过程拆分为三个步骤:
- P1:A=B+C
- P2:D=AXB
- P3:D=D/2
上述步骤中 P1、P2 和 P3 均在单独的线程中计算,并且每个线程只负责自己的工作。此时,P3 的计算结果就是最终需要的答案。
P1 接收 B 和 C 的值,并求和,将结果输入给 P2。P2 求乘积后输入给 P3。P3 将 D 除以 2 得到最终值。一旦这条流水线建立,只需要一个计算步骤就可以得到(B+C)* B/2 的结果。
为了实现这个功能,我们需要定义一个在线程间携带结果进行信息交换的载体:
P1 计算的是加法:
上述代码中,P1 取得封装了两个操作数的 Msg,并进行求和,将结果传递给乘法线程 P2(第 9 行)。当没有数据需要处理时,P1 进行等待。
P2 计算乘法:
和 P1 非常类似,P2 计算相乘结果后,将中间结果传递给除法线程 P3。
P3 计算除法:
P3 将结果除以 2 后输出最终的结果。
最后是提交任务的主线程,这里,我们提交 100 万个请求,让线程组进行计算:
上述代码第 13 行,将数据提交给 P1 加法线程,开启流水线的计算。在多核或者分布式场景中,这种设计思路可以有效地将有依赖关系的操作分配在不同的线程中进行计算,尽可能利用多核优势。