《Java并发编程实践》三(6):任务及其执行

前面第2~5章是本书的第二部分,介绍了线程安全的基本知识,以及常用的java线程安全组件。这一章开始,进入第二部分,从更高的层次,介绍如何通过结构化的方式,来设计、构建基于多线程的应用系统。

  • 第6章:任务及其执行
  • 第7章:任务的取消和关闭
  • 第8章:使用线程池
  • 第9张:GUI应用(本系列文章跳过这一章)

本章介绍Task的概念及其执行方式。

Task

大多数并发系统将工作抽象为Task,所谓task是指离散的工作单元。将系统功能划分为task,有很多的好处,包括但不限于以下几点:

  • 简化了系统的结构;
  • 多个任务并行运行,提高系统的并发性能;
  • 更简单的错误处理,因为细粒度的任务有更明确的边界;

服务系统应当具备良好的吞吐量和响应性,吞吐量指系统能够同时为尽量多的用户服务,响应性指系统能快速响应用户的请求。更进一步,服务系统在负载过高时,能够优雅地降级,而不是性能直线下降或宕机。将系统划分成边界合理的Task,再加上合理的任务调度机制,有助于我们达成这一目标。

任务的划分方式非常依赖具体的业务场景,对一个web服务系统来说,任务边界的选择是很自然的:每个HTTP请求的处理过程是一个任务,而且任务之间互相独立。

单线程任务调度

有多种调度任务的策略,最简单是单线程策略。下面是一个单线程的WebServer演示:

class SingleThreadWebServer {
	public static void main(String[] args) throws IOException {
		ServerSocket socket = new ServerSocket(80);
		while (true) {
			Socket connection = socket.accept();
			handleRequest(connection);
		}
	}
}

单线程的WebSever很简单,也能正确工作;每次只能处理一个请求,下一个请求只能等待上一个请求完成。由于http请求的处理涉及IO操作(及时没有数据库,也有http读写),因此即使在一个单核CPU的机器上,该系统也无法充分利用机器硬件性能。

因此,这种工作模式的系统,除非只为单个用户服务,它的吞吐量、响应性都很差。

为每个Task创建线程

另一种任务调度策略是为每个Task创建一个线程:

class ThreadPerTaskWebServer {
	public static void main(String[] args) throws IOException {
		ServerSocket socket = new ServerSocket(80);
		while (true) {
			final Socket connection = socket.accept();
			Runnable task = new Runnable() {
				public void run() {
					handleRequest(connection);
				}
			};
			new Thread(task).start();
		}
	}
}

这种策略为每个接受的请求创建一个独立的线程,该策略产生以下几个影响:

  • 任务的处理从主线程剥离,使得主线程可以更加快速的接受新的请求,改善了响应性;
  • 任务可以并行运行,充分利用CPU及其他计算资源,改善了吞吐量;
  • 任务处理代码必须考虑线程安全性,因为并发的任务可能访问公共数据状态。

但是无限制的线程创建,有以下的缺陷:

  • 线程创建的开销:线程的创建和销毁并不是免费的,需要消耗JVM和OS资源,同样带来任务处理的延迟;
  • 资源消耗:线程需要消耗一定的系统资源,尤其内存,如果CPU已经足够忙碌,创建更多的线程不会改善吞吐量,只会消耗更多的内存,且消耗更多的CPU周期来执行上下文切换。
  • 稳定性:JVM能创建的线程是有限的,该限制与平台、栈大小等因素有关,一旦达到上限,可能触发难以恢复的问题。

因此,在这种调度策略下,应用进程的线程数取决于用户访问频率;一旦在线用户量增大,或有恶意用户攻击系统,可导致系统瘫痪。

Executor

Executor是java并发框架提供的一个任务执行服务,能够代替我们管理线程、调度任务。

Executor接口定义如下:

public interface Executor {
	void execute(Runnable command);
}

该接口虽然简单,却形成了异步任务执行框架的基础,它定义了一种将任务提交和任务执行解耦的标准方式。Executor设计采用了生产者-消费者模式,提交任务的代码是生产者,Executor的内部执行线程是消费者。

基于Executor的WebServer

我们暂且不管Executor是如何实现的,先看看如何使用Executor来优化WebServer,解决ThreadPerTaskWebServer的缺陷:

class TaskExecutionWebServer {
	private static final int NTHREADS = 100;
	private static final Executor exec= Executors.newFixedThreadPool(NTHREADS);
	public static void main(String[] args) throws IOException {
		ServerSocket socket = new ServerSocket(80);
		while (true) {
			final Socket connection = socket.accept();
			Runnable task = new Runnable() {
				public void run() {
					handleRequest(connection);
				}
			};
			exec.execute(task);
		}
	}
}

这个方案下,我们只要很少的改动,使用不同的Executor实现,或进行不同的配置,就可以实现不同的任务执行策略。

Executor策略

Executor的策略解决以下几个问题:

  • 任务在什么线程执行;
  • 任务以何种顺序执行(FIFO,LIFO,priority order)?
  • 有多少个任务可以并发执行;
  • 有多少个任务可以排队等待执行;
  • 如果一个任务由于系统负载过高被拒绝,哪个任务是牺牲者,客户代码如何被通知该情况的发生;
  • 在任务开始之前或结束之后,会执行哪些额外行为。

Executor策略是一种资源管理工具,最佳策略取决于你的计算资源以及对服务质量的要求。Executor将任务的提交与执行分开,使得我们可以动态地依据资源情况来调整策略。如果你写下new Thread(runnable).start()这样的代码,那就失去了运行时优化的空间。

Executor的线程池

Executor背后有一个线程池,管理一组工作线程,线程池总是和一个任务队列协同工作。工作线程的行为很简单:从任务队列上获取下一个任务,执行它,再获取下一个,不断循环。

通过线程池来执行任务有两个好处:

  • 重用现有线程,而不是为任务创建新线程,减少任务处理延时,提高系统响应性;
  • 通过恰当地设置线程池尺寸,系统可以保持CPU忙碌,同时又免于过载风险;

JDK并发库有一个工厂类Executors,可以创建若干种线程池策略Executor,这些工厂方法如下:

  • newFixedThreadPool:固定线程数的线程池,随着任务提交,线程池不断创建新线程直至达到限制数,然后保持这个数量;
  • newCachedThreadPool:该线程池不会保留空闲线程,也不会限制线程数量;
  • newSingleThreadExecutor:单线程池,队列类型决定了任务执行顺序;
  • newScheduledThreadPool:固定线程数的线程池,支持任务调度(延迟执行、周期性执行)。

如果上面的功能不满足你的需求,可以直接创建ThreadPoolExecutor,并进行更细致的配置。关于线程池的工作和配置细节,第8章会详细介绍。

线程池策略,是整个Executor策略的一部分,严格上说,它只解决了“并发度”的问题。不过,CachedThreadPool实际上消除了“任务优先级”,”任务队列“相关问题。

Executor的生命周期

Executor异步地执行任务,因此在特定时刻,已提交任务所处的状态并不明显,有些已经执行完毕,有些正在执行,还有些在队列中等待。

关闭Executor的方法定义在ExecutorService接口里:

public interface ExecutorService extends Executor {
	void shutdown();
	List<Runnable> shutdownNow();
	boolean isShutdown();
	boolean isTerminated();
	boolean awaitTermination(long timeout, TimeUnit unit) throws InterruptedException;
}

ExecutorService有三种状态,运行中、关闭中、已关闭。新创建的ExecutorService状态是运行中;shutdown方法发起优雅关闭请求,将它置于"关闭中“状态,此时已经提交的任务继续执行(包括排队中的),但不再接受新的任务。shutdownNow方法强行关闭Executor,它尝试中断正在执行的任务,不再执行队列中的任务。

一旦所有的任务结束,ExecutorService进入”已关闭“状态。客户代码可执行awaitTermination等待关闭,也可通过isTerminated来查询。

注意,第五章的知识告诉我们,Executor并没有把握去中断一个任务,这取决于任务所实现的中断策略。

延时(定时)任务

java Timer可以延迟执行任务,但是Timer有两个明显缺陷:一是单线程执行,一旦有任务耗时较长,后面的任务就被推迟;二是一旦某个任务抛出未检查异常,Timer线程被终止。

ScheduledExecutorService是Executor的一个扩展接口,是Timer的完美替代者,在java 6以后,没有理由再使用Timer。

可返回结果的任务

使用Runnable来表示任务有一个缺陷,因为它不能返回执行结果;Callable是ExecutorService支持的另一种任务类型,弥补了该缺陷。

调用ExecutorService.submit()提交Callable类型的任务能返回一个Future类型的对象,是一个任务状态同步器(这个知识第5章也介绍过了)。

public interface Future<V> {
	boolean cancel(boolean mayInterruptIfRunning);
	boolean isCancelled();
	boolean isDone();
	V get();
	V get(long timeout, TimeUnit unit);
}

Future提供了相关方法来管理任务生命周期,以及get方法来查询执行结果,get方法的行为如下:

  • 如果任务尚未执行完成,get方法阻塞;
  • 如果任务正常结束,获得任务返回值;
  • 如果任务取消,抛出CancellationException
  • 如果任务执行异常,抛出ExecutionException,该异常包裹了任务代码抛出的异常;

Executor执行Callable包含了两次对象安全发布过程,一次是将任务对象转移给Executor线程,第二次是将执行结果返回给Future.get();Executor框架使用的是“序列化线程封闭安全策略”。为遵循该策略,对于已经提交Executor的任务对象,调用方不可再修改。

CompletionService:等待任务完成

如果你有大量任务需要提交给Executor,并且期望一旦有任何一个任务完成,就能检索到结果。 一种可行的技术方案如下:

  1. 依次提交任务并将所有的Future保存为一个列表;
  2. 不断了轮询这个列表,查找完成的任务;
  3. 直到所有任务都完成。

自己写代码实现这个方案是比较繁琐的,java并发库提供了现成的实现:CompletionService,你可以向CompletionService提交任务,使用take或poll方法来检索已完成任务的执行结果。

CompletionService只有一个实现ExecutorCompletionService

限时任务

有时候我们期望任务能够在一定时间内完成,Furture.get的timeout版本刚好满足这个需求,一旦等待超时,将会抛出TimeoutException。

此时需要注意的是,如果不希望该任务继续执行(消耗CPU和其他资源),需要cancel这个任务。

Future<Ad> f = exec.submit(new FetchAdTask());
try {
	ad = f.get(timeLeft, NANOSECONDS);
}  catch (TimeoutException e) {
	f.cancel(true);
}

cancel一个任务并不像上面代码展示的那么简单,下一章专门讨论这个问题。

总结

将应用程序功能拆分为一系列任务,有助于降低系统复杂度,提升并发性能。Executor框架是支持该设计的强大工具,它允许我们将任务的提交和任务的执行解耦,并且提供了丰富的并发策略可供选择;因此我们没有理由不使用Executor而去手动创建线程。

为了最大限度从“任务模型”中获利,我们必须划分合理的任务边界;在某些场景下,这个合理边界是很明显的;而某些场景下,需要我们仔细思考、测量以发掘更细粒度的可并行任务。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值