第六章:任务执行
大多数并发应用程序是围绕执行任务(task)进行管理的。所谓任务就是抽象、离散的工作单元(unit of work)。
- 把一个应用程序的工作(work)分离到任务中,可以简化程序的管理;
- 这种分离还在不同事务间划分了自然的分界线,可以方便程序在出现错误时进行恢复;
- 同时这种分离还可以为并行工作提供一个自然的结构,有利于提高程序的并发性。
在线程中执行任务
理想情况下,任务是独立的活动:它的工作并不依赖于其他任务的状态、结果或者边界效应(side effect)。
独立有利于并发性,如果能得到相应的处理器资源,独立的任务还可以并行执行。
在正常的负载下,服务器应用程序应该兼具良好的吞吐量和快速的响应性。
进一步讲,应用程序应该在负荷过载时平缓地劣化,而不应该负载一高就简单地以失败告终。
为了达到这些目的,你要选择一个清晰的任务边界,并配合一个明确的任务执行策略。
大多数服务器应用程序都选择了下面这个自然的任务边界:单独的客户请求。
Web服务器,邮件服务器,文件服务器,EJB 容器和数据库服务器,这些服务器都接受远程客户通过网络连接发送的请求。
将独立的请求作为任务边界,可以让任务兼顾独立性和适当的大小。
例如,向邮件服务器提交一个消息后产生的结果,并不会被其他正在同时处理的消息所影响。
顺序地执行任务
应用程序内部的任务调度,存在多种可能的调度策略,这些策略可以在不同程度上发挥出潜在的并发性。
其中最简单的策略是在单一的线程中顺序地执行任务。
/**
* 顺序化的 Web Server
*/
class SingleThreadWebServer {
public static void main(String[] args) throws IOException {
ServerSocket socket = new ServerSocket(80);
while (true) {
Socket connection = socket.accept();
handleRequest(connection);
}
}
}
就这个示例而言,主线程不断地在“接受连接”与“处理相关请求”之间交替运行,并且直到主线程完成了当前的请求并再次调用accept
,此前新的请求都必须等待。
一个Web请求的处理包括执行运算与进行IO操作。
服务器必须处理Socket I/O,以读取请求和写回响应,网络拥堵或连通性问题会导致这个操作阻塞。
服务器还要处理文件I/O、发送数据库请求,这些同样会引起操作的阻塞。
这在生产环境中的执行效率会很糟糕。
在某些情况下,顺序化处理在简单性或者安全性上具有优势;大多数GUI框架使用单一的线程,并顺序地处理任务。
我们会在第9章再次讨论顺序化模型。
显示地为任务创建线程
为了提供更好的响应性,可以为每个服务请求创建一个新的线程。
/**
* Web Server为每个请求启动一个新的线程
*/
class ThreadPerTaskWebServer {
public static void main(String[] args) throws IOException {
ServerSocket socket = new ServerSocket(80);
while (true) {
final Socket connection = socket.accept();
Runnable task = () -> handleRequest(connection);
new Thread(task).start();
}
}
}
ThreadPerTaskWebServer
在结构上类似于单线程版本——主线程仍然不断地交替运行“接受外部连接”与“转发请求”。
不同点在于,主循环为每个连接都创建一个新线程以处理请求,而不是在主循环的内部处理它。由此得出下面3条主要结论:
- 执行任务的负载已经脱离了主线程,这让主循环能够更迅速地重新开始等待下一个连接。这使得程序可以在完成前面的请求之前接受新的请求,从而提高了响应性。
- 并行处理任务, 这使得多个请求可以同时得到服务。如果有多个处理器,或者出于I/O未完成、锁请求以及资源可用性等任何因素需要阻塞任务时,程序的吞吐量会得到提高。
- 任务处理代码必须是线程安全的,因为有多个任务会并发地调用它。
在中等强度的负载水平下,“每任务每线程( thread-per-task)”方法是对顺序化执行的良好改进。
只要请求的到达速度尚未超出服务器的请求处理能力,那么这种方法可以同时带来更快的响应性和更大的吞吐量。.
无限制创建线程的缺点
当用于生产环境中时,“ 每任务每线程(thread-per-task) ”方法存在一些实际的缺陷,尤其在需要创建大量的线程时会更加突出:
- 线程生命周期的开销。线程的创建与关闭不是“免费”的。实际的开销依据不同平台而不同,但是创建线程的确需要时间,带来处理请求的延迟,并且需要在JVM和操作系统之间进行相应的处理活动。如果请求是频繁的且轻量的,就像大多数服务器程序一样,那么为每个请求创建一个新线程的做法就会消耗大量的计算资源。
- 资源消耗。活动线程会消耗系统资源,尤其是内存。如果可运行的线程数多于可用的处理器数,线程将会空闲。大量空闲线程占用更多内存,给垃圾回收器带来压力,而且大量线程在竞争CPU资源时,还会产生其他的性能开销。如果你已经有了足够多的线程保持所有CPU忙碌,那么再创建更多的线程是有百害而无一利的。
- 稳定性。应该限制可创建线程的数目。限制的数目依不同平台而定,同时也受到JVM的启动参数、Thread 的构造函数中请求的栈大小等因素的影响,以及底层操作系统线程的限制。如果你打破了这些限制,最可能的结果是收到一个
OutOfMemoryError
。企图从这种错误中恢复是非常危险的,更简单的办法是构造你的程序时避免超出这些限制。
Executor框架
任务是逻辑上的工作单元,线程是使任务异步执行的机制。
我们已经分析了两种线程执行任务的策略——所有任务在单一的线程中顺序执行,以及每个任务在自己的线程中执行。
每一种策略都有严重的局限性:顺序执行会产生糟糕的响应性和吞吐量,“ 每任务每线程”会给资源管理带来麻烦。
线程池(ThreadPool)为线程管理提供了好处。
在Java类库中,任务执行的首要抽象不是Thread
,而是Executor
。
public interface Executor {
void execute(Runnable command);
}
它为任务提交和任务执行之间的解耦提供了标准的方法,为使用Runnable
描述任务提供了通用的方式。
Executor
基于生产者-消费者模式。
提交任务的执行者是生产者(产生待完成的工作单元),执行任务的线程是消费者(消耗掉这些工作单元)。
如果要在程序中实现一个生产者-消费者的设计,使用Executor
通常是最简单的方式。
/**
* 使用线程池的 WebServer
*/
class TaskExecutionWebServer {
private static final int NTHREADS = 100;
private static final Executor exec = Executore.newFixedThreadPool(NTHREADS);
public static void main(String[] args) throws IOException {
ServerSocket socket = new ServerSocket(80);
while (true) {
final Socket connection = socket.accept();
Runnable task = () -> handleRequest(connection);
exec.execute(task);
}
}
}
线程池
正如名称中所称的那样,线程池管理一个工作者线程的同构池(homogeneous pool)。
线程池是与工作队列(work queue)紧密绑定的。
所谓工作队列,其作用是持有所有等待执行的任务。
工作者线程的生活从此轻松起来:它从工作队列中获取下一个任务,执行它,然后回来继续等待另一个线程。
线程池的优点:
- 重用存在的线程,而不是创建新的线程,这可以在处理多请求时抵消线程创建、消亡产生的开销。
- 在请求到达时,工作者线程通常已经存在,用于创建线程的等待时间并不会延迟任务的执行,因此提高了响应性。
- 通过适当地调整线程池的大小,可以得到足够多的线程以保持处理器忙碌,同时可以还防止过多的线程相互竞争资源,导致应用程序耗尽内存或者失败。
类库提供了一个灵活的线程池实现和一些有用的预设配置。可以通过调用Executors
中的某个静态工厂方法来创建一个线程池:
- newFixedThreadPool
- newCachedThreadPool
- newSingleThreadExecutor
- newScheduledThreadPool
阿里巴巴Java开发手册中关于Executor
的说明如下:
线程池不允许使用
Executors
去创建,而是通过ThreadPoolExecutor
的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。
.
说明:Executors
返回的线程池对象的弊端如下:
1)FixedThreadPool
和SingleThreadPool
:允许的请求队列长度为Integer.MAX_VALUE
,可能会堆积大量的请求,从而导致OOM。
2)CachedThreadPool
:允许的创建线程数量为Integer.MAX_VALUE
,可能会创建大量的线程,从而导致OOM。
Executor的生命周期
JVM会在所有(非后台的,nondaemon)线程全部终止后才退出。因此,如果无法正确关闭Executor
,将会阻止JVM的结束。
为了解决这个执行服务的生命周期问题,Executorservice
接口扩展了Executor
,并且添加了一些用于生命周期管理的方法。
/**
* ExecutorService 中的生命周期方法
*/
public interface ExecutorService extends Executor {
void shutdown();
List<Runnable> shutdownNow();
boolean isShutdown();
boolean isTerminated();
boolean awaitTermination(long t imeout, TimeUnit unit) throws InterruptedException;
// ...其他用于任务提交的便利方法
}
Executorservice
暗示了生命周期有3种状态:运行(running)、关闭(shutting down)和终止(terminated)。
ExecutorService
最初创建后的初始状态是运行状态。
shutdown
方法会启动一个平缓的关闭过程:停止接受新的任务,同时等待已经提交的任务完成——包括尚未开始执行的任务。
shutdownNow
方法会启动一个强制的关闭过程:尝试取消所有运行中的任务和排在队列中尚未开始的任务。
在关闭后提交到ExecutorService
中的任务,会被拒绝执行处理器(rejected execution handler) 处理(参见第八章)。
/**
* 支持关闭操作的 Web Server
*/
class LifecycleWebServer {
private final ExecutorService exec = ...;
public void start() throws IOException {
ServerSocket socket = new ServerSocket(80);
while (!exec.isShutdown()) {
try {
final Socket conn = socket.accept();
exec.execute((Runnable) () -> handleRequest(conn));
} catch (RejectedExecutionException e) {
if (!exec.isShutdown()) {
log("task submission rejected", e);
}
}
}
}
void handleRequest(Socket connection){
Request req = readRequest(connection);
if (isShutdownRequest(req)) {
stop();
} else {
dispatchRequest(req);
}
}
private void stop() {
exec.shutdown();
}
}
寻找可强化的并行性
可携带结果的任务:Callable 和 Future
你可以将一个Runnable
或一个Callable
提交给executor
,然后得到一个Future
,用它来重新获得任务执行的结果,或者取消任务。
你也可以显式地为给定的Runnable
或Callable
实例化一个FutureTask
。( FutureTask
实现了Runnable
,所以既可以将它提交给Executor
来执行,又可以直接调用run
方法运行。)
如果任务已经完成,get 会立即返回或者抛出一个Exception
;如果任务没有完成,get 会阻塞直到它完成。
如果任务抛出了异常,get 会将该异常封装为ExecutionException
,然后重新抛出;
如果任务被取消,get 会抛出CancellationException
。
当抛出了ExecutionException
时,可以用getCause
重新获得被封装的原始异常。
大量相互独立且同类的任务进行并发处理,会将程序的任务量分配到不同的任务中,这样才能真正获得性能的提升。
CompletionService:当 Executor 遇见 BlockingQueue
CompletionService
整合了Executor
和BlockingQueue
的功能。
你可以将Callable
任务提交给它去执行,然后使用类似于队列中的take
和poll
方法,在结果完整可用时获得这个结果,像一个打包的Future
。
ExecutorCompletionservice
是实现CompletionService
接口的一个类,并将计算任务委托给一个Executor
。
ExecutorCompletionService
在构造函数中创建一个BlockingQueue
,用它去保存完成的结果。计算完成时会调用FutureTask
中done
方法。- 当提交了一个任务后,首先把这个任务包装为一个
QueueingFuture
,它是FutureTask
的一个子类,然后覆写done
方法,将结果置入BlockingQueue
中。take
和poll
方法委托给了BlockingQueue
,它会在结果不可用时阻塞。
使用CompletionService
,我们可以从两方面提高页面渲染器的性能:缩短总的运行时间以及提高响应性。
我们可以为每个图像的下载创建一个独立的任务, 并在线程池中执行它们,将顺序的下载过程转换为并行的:这能减少下载所有图像的总时间。
而且从Completionservice
中获取结果,只要任何一个图像下载完成,就立刻呈现。由此,我们可以给用户提供一个更加动态和有更高响应性的用户界面。
/**
* 使用 CompletionService渲染可用的页面元素
*/
public class Renderer {
private final ExecutorService executor;
Renderer(ExecutorService executor) { this.executor = executor; }
void renderPage(CharSequence source) {
final List<ImageInfo> info = scanForImageInfo(source);
CompletionService<ImageData> completionService = new ExecutorCompletionService<ImageData>(executor);
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 = comp1etionService.take();
ImageData imageData = f.get();
renderImage(imageData);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (ExecutionException e) {
throw launderThrowable(e.getCause());
}
}
}
}
为任务设置时限
有时候如果一个活动无法在某个确定时间内完成,那么它的结果就失效了,此时程序可以放弃该活动。
Future.get
的限时版本符合这个条件:它在结果准备好后立即返回,如果在时限内没有准备好,就会抛出TimeoutException
。
如果一个限时的get
抛出TimeoutException
,你可以通过Future
取消任务。
/**
* 在预定时间内获取广告信息
*/
Page renderPageWithAd() throws InterruptedException{
long endNanos = System.nanoTime() + TIME_BUDGET;
Future<Ad> f = exec.submit(new FetchAdTask());
// 等待广告是呈现页面
Page page = renderPageBody();
Ad ad;
try{
// 在预计时间内等待
long timeLeft = endNanos - System.nanoTime();
ad = f.get(timeLeft, NANOSECONDS);
}catch(ExecutionException e){
ad = DEFAULT_AD;
}catch(TimeoutException e){
ad = DEFAULT_AD;
f.cancel(true);
}
page.setAd(ad);
return page;
}
总结
围绕任务的执行来构造应用程序,可以简化开发,便于同步。
Executor
框架有助于你在任务的提交与任务的执行策略之间进行解耦,同时还支持很多不同类型的执行策略。
你发现自己为执行任务而创建线程时,可以考虑使用Executor
取代以前的方法。
把应用程序分解为不同的任务,为了使这一行为产生最大的效益,你必须指明一个清晰的任务边界。
在一些应用程序中,存在明显的工作良好的任务边界,然而还有一些程序,你需要作进一步的分析,以揭示更多可加强的并行性。