单线程处理一系列任务时,如果某个任务比较耗时,则会增加整体的时间花费。而
该任务的部分后续任务,可能不依赖该任务的执行结果,因此完全可以通过异步执行耗时任务来减少等待。通过使用Promise(承诺)模式来进行优化。

Promise模式简介

Promise模式是一种异步编程模式,它可以减少等待,增加系统的并发性。
主要是思想是,开始一个任务的执行,但不等待该任务完成才执行其他操作,而是得到一个用于获取该任务执行结果的凭据对象。等到我们需要该任务的执行结果时,再调用凭据对象的相关方法来获取。

框架组成:

  • Client:客户端调用某个异步方法,返回一个凭据对象(Promise,承诺)。凭借该对象,客户端可以在需要的时候获取异步方法相应真正任务的执行结果。
  • Promisor:负责对外暴露可以返回Promise对象的异步方法(compute),并启动异步任务的执行。
  • Promise:包装异步任务处理结果的凭据对象,主要负责检测异步任务是否处理完毕(isDone),返回(getResult)和存储(setResult)异步任务处理结果。
  • Result:负责表示异步任务处理结果。具体类型由客户端决定。
  • TaskExecutor:负责执行异步任务的计算,并将其计算结果设置到相应的Promise实例。

序列图:

  • 客户端代码获取异步任务处理结果的过程:
    1. client调用Promisor的异步方法compute
    2. compute创建Promise实例作为返回值并返回给client
    3. client调用Promise实例的getResult方法来获取异步任务处理结果,如果此时异步任务尚未完成,则getResult方法会阻塞直到异步任务处理完成。

  • 异步任务执行以及处理结果设置的过程:
    1. Promisor.compute()创建TaskExecutor实例
    2. TaskExecutor实例的run方法执行(使用其他线程或者线程池来调用run方法)
    3. run方法执行结束后创建Result实例
    4. run方法将Result 设置到相应Promise实例中。

实战案例

  • 功能:某系统的一个功能模块需要将一批本地文件上传到指定的目标FTP服务器上,这些文件是根据页面中的输入条件查询数据库的相应记录生成的。在文件上传到目标服务器之前,需要对FTP客户端实例进行初始化。
  • 需求:因为FTP客户端实例初始化这个操作比较耗时间,我们希望它尽可能地在本地文件上传之前准备就绪。使FTP客户端实例初始化和本地文件上传这两个任务能够并发执行,减少不必要的等待。同时不希望增加太多代码编写的复杂性。
  • 解决方案:引入异步编程,使用Promise模式。先开始FTP客户端实例的初始化,并得到一个获取FTP客户端实例的凭据对象。在不必等待FTP客户端实例初始化完毕的情况下,生成本地文件后,通过凭据对象获取FTP客户端实例,通过该实例将文件上传到目标服务器。
  • 功能模块方法入口:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public void uploadFilesToFTP() {
// ftp连接信息
String ftpInfo = "server + userName + password";
//先开始初始化FTP客户端实例,获取一个凭据对象
Future<FTPClientUtil> ftpClientUtilPromise = FTPClientUtil.newInstance(ftpInfo);
//查询数据库生成本地文件
Set<File> files = generateFilesFromDB();
// 通过凭据对象获取初始化完毕的FTP客户端实例,如果未执行完毕,则该线程阻塞
FTPClientUtil ftpClientUtil = ftpClientUtilPromise.get();
// 使用初始化完毕的FTP客户端实例上传文件
ftpClientUtil.upload(files);
}
  • FTP客户端工具类:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
//模式角色:Promisor,Result
public class FTPClientUtil {
private FTPClientUtil() {}
//模式角色:Promisor.compute
public static Future<FTPClientUtil> newInstance(final String ftpInfo) {
Callable<FTPClientUtil> callable = new Callable<FTPClientUtil>() {
@Override
public FTPClientUtil call() throws Exception {
FTPClientUtil self = new FTPClientUtil();
//始化ftp客户端
initFtp(ftpInfo);
return self;
}
};
//模式角色:Promise;返回给client的凭据对象
final FutureTask<FTPClientUtil> task = new FutureTask<FTPClientUtil>(callable);
//模式角色:Promise.TaskExecutor
new Thread(task).start();
return task;
}
//文件上传ftp
public void upload(Set<File> files) {}
}
* Callable和Runnable类似,区别在于有返回值。Callable<FTPClientUtil> 表示一个返回FTPClientUtil对象的异步计算
* Future 是java提供的用作Promise的抽象接口类,包含了Promise 需要的get(), isDone() 等方法
* FutureTask包装器实现了Runnable和Future,将Callable转换成了这两者。

FTPClientUtil类封装了FTP客户端,其构造方法是private修饰的,因此其他类无法通过new来生成相应的实例,而是通过其静态方法newInstance来获得实例。不过newInstance方法的返回值并不是一个FTPClientUtil实例,而是一个可以获取FTPClientUtil实例的凭据对象。

上述的实现可以发现,使用Promise模式的异步编程并没有本质上增加编程的复杂性,客户端代码的编写方式与同步编程并没有太大差别,唯一区别就是获取ftp客户端实例时多了一步get方法调用。

评价

优点:
  • 增加系统并发性,减少不必要的等待
  • 保持同步编程的简单性,异步编程的细节都封装在Promisor中,对client不可见
其他处理:
  1. 异步方法异常处理:
    异步任务运行在自己的线程中,如果产生异常,无法再compute方法中抛出,如果想让client捕获到异常,可以让TaskExecutor在执行中捕获到异常后,记录到Promise实例中的一个特定变量上,Promise实例的get()方法先对该字段进行检查,如果有值则抛出异常供client捕获。(FutureTask采用该方法实现)

  2. 轮询减少线程阻塞:
    client 调用get()方法时,如果异步任务未完成则会导致阻塞。首先我们应该通过尽早调用compute方法并尽可能晚地调用get()方法来避免这种等待的出现。但如果我们想避免该阻塞的出现,我们可以通过调用isDone方法来判断异步任务是否完成。

  3. 线程池优化线程创建开销
    因为异步任务是通过新建一个线程去执行的,如果系统中存在多个线程调用Promisor的异步方法,会导致启动的线程数量过多从而对性能产生影响,这个时候考虑使用线程池负责执行TaskExecutor的run方法来实现异步任务的执行,以此来减少开启线程的开销。

参考

Java多线程编程模式实战指南之Promise模式