Java并发学习之找出可利用的并行性
一.如何找出可利用的并行性
Executor框架帮助指定执行策略,但如果要使用Executor,必须将任务表述为一个Runnable。
在大多数服务器应用程序中都存在一个明显的任务边界:单个用户请求。
但有时候任务边界并非显而易见。 我们要解决的就是这种情况下如何找出可发掘的并发性。
例如即使在服务器应用程序中,在单个用户请求中还存在着可发掘的并发性,例如数据库服务器。
下面我们将学习几个例子来了解:
1.串行的页面渲染器
实现页面渲染器的最简单的方法是对HTML文档进行串行处理:
当遇到文本标签时,将其绘制到图像缓存中,当遇到图像引用时,先通过网络获取它,然后再将其绘制到图像缓存中,这种方法虽然简单,但是很耗时。
这时预处理的好处就展现了:我们可以先绘制文本元素,同时为图像处理预留出矩形的占位空间,在处理完了第一遍文本后,程序再开始下载图像,并将它们绘制到相应的占位空间中。
public class SingleThreadRenderer{
void renderPage(CharSequence source){
renderText(source);//渲染文本
List<ImageData> imageData = new ArrayList<ImageData>();
//扫描文本是否有图片并下载图片
for(ImageInfo imageInfo : scanForImageInfo(source))
imageData.add(imageInfo.downloadImage());
//渲染图片
for(ImageData data : imageData)
renderImage(data);
}
}
上面的代码保证了渲染过程一次执行完毕,但是,在图像下载的过程中大部分时间都是在等待I\O操作完成的,在这个期间CPU几乎不做任何事,因此这种串行执行方式没有利用好CPU,使得用户在看到最终页面之前要等待一段时间,不够好。
那么有没有方法解决这个问题呢?
我们可以通过将问题分解为多个独立的任务并发执行,能够获得更高的CPU利用率和响应速度。
2.在将上面的思路实际实现之前,我们需要详细学习携带结果的任务Callable与Future
1)我们知道Executor框架使用Runnable作为其基本的任务表示形式,但Runnable有一种很大的局限性:
虽然run能写入到日志文件或者将结果放入某个共享的数据结构,但它不能返回一个值或抛出一个受检查异常。
2)实际上许多任务都存在延迟的计算,例如:执行数据库查询,从网络上获取资源,计算某个复杂的功能。 而对于这些任务Callable比Runnable更好用,Callable认为主入口点(call)将返回一个值,并可能抛出一个异常。
3)在Executor中包含了一些辅助方法能将其它类型的任务封装为一个Callable。
4)Runnable和Callable描述的都是抽象的计算任务,这些任务通常是都有明确的起点,并且最终会结束。 在Executor中执行的任务有四个生命周期:创建,提交,开始 ,完成。
在Executor中已提交但还没开始的任务可以取消,但对于那些已经开始执行的任务,只有当它们能响应中断时才能取消。 取消一个已经完成的任务不会有任何的影响。
下面是Callable接口:
public interface Callable<V> {
V call() throws Exception();
}
Future表示一个任务的生命周期:它提供了相应的方法来判断是否已经完成或取消,以及获取任务的结果或者取消任务等。在Future规范中包含的含义是任务的生命周期只能前进,不能后退,就像ExecutorService的生命周期一样,当完成某个任务后,他就会永远停留在 完成状态 上。
其中get方法取决于任务的状态,如果任务以及完成,它会返回任务的计算结果或抛出一个异常,
如果任务没有完成,那么get将阻塞直到任务完成,如果任务抛出了异常,那么get将该异常封装为ExecutionException并重新抛出。
如果任务被取消,那么get将抛出CancellationException。
下面是Future接口:
public interface Future<V>{
boolean cancel(boolean mayInterruptIfRunning);//取消任务
boolean isCancelled();//判断任务是否取消
boolean isDone();//判断任务是否已经完成
//获取任务的返回值
V get() throws InterruptedException, ExecutionException, CancellationException;
//定时返回
V get(long timeout , TimeUnit unit) throws InterruptedException , ExecutionException,
CancellationException,TimeoutException;
}
3.创建Future的方式
我们可以通过很多方式创建一个Future:
1)ExecutorService中的所有submit方法都将返回一个Future,从而将一个Runnable或Callable提交给Executor,并得到一个Future来获得任务的执行结果或者取消任务。
2)我们还可以显式地为某个指定的Runnable或者Callable实例化一个FutureTask实例来得到一个Future。
3)从java6开始,ExecutorService实现可以改写AbstractExecutorService中的newTaskFor方法从而根据已经提交的Runnable或者Callable来控制Future的实例化过程。 newTaskFor默认实现是创建一个FutureTask.
4.下面我们使用Future来实现页面渲染器
针对上面的串行渲染器,为了使页面实现更高的并发性,首先将渲染过程分解为两个任务:
1)渲染所有的文本(CPU密集型任务)
2)下载所有的图像(I/O密集型任务)
通过使上面两个任务并行执行来提高资源利用和响应速度。
public class FutureRenderer{
private final ExecutorService executor = Executors.newFixedThreadPool(5);//创建线程池
void renderPage(CharSequence source){
final List<ImageInfo> imageInfos = scanForImageInfo(source);//存储图片信息的列表
Callable<List<ImageData>> task = new Callable<>(){
public List<ImageData> call(){
List<ImageData> result = new ArrayList<ImageData>();
for(ImageInfo imageInfo:imageInfos)
result.add(imageinfo.downloadImage());//下载图片存入图片列表中
return result;//返回图片列表
}
};
Future<List<ImageData>> future = executor.submit(task);//提交下载图片任务
renderText(source);//渲染文本
try{
List<ImageData> imageData = future.get();
for(ImageData data: imageData)
renderImage(data);//渲染图片
}catch(InterruptedException e){
Thread.currentThread().interrupt();//重新设置线程的中断状态
future.cancel(true);//由于不需要结果,所以取消任务
}catch(ExecutionException e){
throw launderThrowable(e.getCause());
}
}
}
上面的渲染程序相对于串行渲染程序极大的提升了响应速度。
但是上面的程序要等到所有的图片都渲染完成后用户才能看到页面,我们有没有办法做的更好呢?
用户不必等到所有图片下载完成,而是希望看到每当下载一副图像时立即显示?
5.在异构任务并行化中存在的局限
1)上面代码是我们尝试并行两个不同类型的任务:下载图像和渲染页面 所写的。
A.通过对异构任务进行并行化来获得重大的性能提升是很困难的。例如在现实生活中,两个人可以很好地分担洗碗工作:其中一个人负责清洗,另外一个人负责烘干。 但是,要将不同类型的工作分配给同一个人不容易。 特别是当人数增加的时候,我们还要确保他们是相互协作而不是相互阻碍,或者在重新分配工作的时候难度会增加。
如果没有在相似的任务之间找出细粒度的并行性,那么这种方法带来的好处将减少。
B.当在多个工人之间分配异构任务时,还有一个问题就是各个任务的大小会完全不同。这意味着多个工人的完工时间将会不同,如果将两个任务A和B分配给两个工人,但A执行的时间是B的10倍,那么整个过程也只是加速了1/11。
C.当在多个工人之间分解任务时,还需要一定的任务协调开销,为了使任务分解能提高性能,这种开销不能高于并行性实现的性能提升。
就拿上面的FutureRenderer代码来说:它使用了两个任务,其中一个负责渲染文本,另一个负责下载图像。 如果渲染的速度远远大于下载图像的速度,那么整个程序的性能提高的并不高而代码却变复杂了,这不是我们想要的结果。
当使用两个线程时,至多能将速度提高一倍。
所以:只有当大量相互独立且同构的任务可以并发进行处理时,才能体现出将程序的工作负载分配到多个任务中从而带来真正的性能提升。
6.下面我们学习如何实现每当下载完一副图像时就立即显示出来:
1)CompletionService:Executor与BlockingQueue
如果向Executor提交了一组计算任务,并且希望在计算完成后获得结果,一般我们可以保留每个任务关联的Future,然后反复调用get方法,同时将参数timeout指定为0,从而轮询来判断任务是否完成**(这个方法虽然可行,但却有些繁琐)**
幸运的是,还有更好的方法:完成服务(CompletionService)
2)CompletionService 将 Executor和BlockingQueue的功能融合在一起。我们可以将Callable任务提交给它来执行,然后使用类似于队列操作的take和poll等方法来获得已完成的结果,而这些结果会在完成时将被封装为Future。
3)ExecutorCompletionService:它实现了CompletionService,并将计算部分委托给一个Executor。
它的实现非常简单,在构造函数中创建一个BlockingQueue来保存计算完成的结果,当计算完成时,调用FutureTask中的done方法,当提交某个任务时,该任务将首先被包装成一个QueueingFuture(它是Future的一个子类),然后再改写子类的done方法,并将结果放入BlockingQueue中,如下面:
private class QueueingFuture<V> extends FutureTask<V> {
QueueingFuture(Callable<V> c){
super(c);
}
QueueingFuture(Runnable t, V r){
super(t,r);
}
protected void done(){
completionQueue.add(this);//添加结果到队列中
}
}
使用案例:使用CompletionService实现页面渲染
public class Renderer{
private final ExecutorService executor;//声明线程池
//实例化线程池
Renderer(ExecutorService executor){
this.executor = executor;
}
void renderPage(CharSequence source){
List<ImageInfo> info = scanForImageInfo(source);//扫描是否有图片
ComletionService<ImageData> comletionService =
new ExecutorCompletionService<ImageData>(executor);
//下载图片并把图片放在comletionService的队列里
for(final ImageInfo imageInfo: info)
completionService.submit(new Callable<ImageData>(){
public ImageData call(){
return imageInfo.downloadImage();//下载图片
}
});
renderText(source);//渲染文本
try{
//实现一张一张显示图片
for(int t=0,n=info.size();t<n;t++){
Future<ImageData> f = completionService.take();//返回被封装的future
ImageData imageData = f.get();//等待图片下载后获取
renderImage(imageData);//渲染图片
}
}catch(InterruptedException e){
Thread.currentThread().interrupt();
}catch(ExecutionException e){
throw launderThrowable(e.getCause());
}
}
}
上面代码实现了每下载一张图片就马上显示的功能。
多个ExecutorCompletionService可以共享一个Executor,因此可以创建一个对于特定计算私有,又能共享一个公共的Executor 的 ExecutorCompletionService。
CompletionSrvice的作用就相当于一组计算的句柄,这与Future作为单个计算的句柄非常类似,通过记录提交给CompletionService的任务数量,并计算出已经获得的已完成结果的数量,即使 使用一个共享的Executor,也能知道已经获得了所有任务结果的时间。