《Java并发编程实战》学习笔记(4)

第六章:任务执行

大多数并发应用程序是围绕执行任务(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)FixedThreadPoolSingleThreadPool:允许的请求队列长度为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,用它来重新获得任务执行的结果,或者取消任务。

你也可以显式地为给定的RunnableCallable实例化一个FutureTask。( FutureTask实现了Runnable,所以既可以将它提交给Executor来执行,又可以直接调用run方法运行。)

如果任务已经完成,get 会立即返回或者抛出一个Exception;如果任务没有完成,get 会阻塞直到它完成。

如果任务抛出了异常,get 会将该异常封装为ExecutionException,然后重新抛出;

如果任务被取消,get 会抛出CancellationException

当抛出了ExecutionException时,可以用getCause重新获得被封装的原始异常。

大量相互独立且同类的任务进行并发处理,会将程序的任务量分配到不同的任务中,这样才能真正获得性能的提升。

CompletionService:当 Executor 遇见 BlockingQueue

CompletionService整合了ExecutorBlockingQueue的功能。

你可以将Callable任务提交给它去执行,然后使用类似于队列中的takepoll方法,在结果完整可用时获得这个结果,像一个打包的Future

ExecutorCompletionservice是实现CompletionService接口的一个类,并将计算任务委托给一个Executor

  • ExecutorCompletionService在构造函数中创建一个BlockingQueue,用它去保存完成的结果。计算完成时会调用FutureTaskdone方法。
  • 当提交了一个任务后,首先把这个任务包装为一个QueueingFuture,它是FutureTask的一个子类,然后覆写done方法,将结果置入BlockingQueue中。takepoll方法委托给了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取代以前的方法。

把应用程序分解为不同的任务,为了使这一行为产生最大的效益,你必须指明一个清晰的任务边界。

在一些应用程序中,存在明显的工作良好的任务边界,然而还有一些程序,你需要作进一步的分析,以揭示更多可加强的并行性。

发布了107 篇原创文章 · 获赞 88 · 访问量 26万+

猜你喜欢

转载自blog.csdn.net/Code_shadow/article/details/104632326