线程池是Executor框架的经典实现。
本篇应该结合线程池原理解析来看。
线程池的隐性耦合
Executor将任务的提交和执行解耦,实际上就像大多数复杂的解耦过程一样,这种论断有些言过其实
任务与执行策略之间,实际上存在一些隐形耦合,需要我们了解,才能在使用的过程避开。
任务依赖
任务之间最好独立存在,这有利于一致性和并发性。但现实是,这种依赖在少数情况下仍然存在,例如在任务中可能需要提交新的任务,这时候就可能引起长时间延迟、死锁等。任务影响了任务的执行策略。
例如单线程池如果执行某个任务,这个任务又提交了另一个任务并阻塞等待,就会引起线程饥饿问题。同理,即使是更大的线程池,当所有执行的任务的线程都阻塞等待其他在队列中的任务时,同样会出现问题。
在提交一个有依赖性的任务时,一定要清除地知道可能会引起线程饥饿,并想办法排除这种风险——例如更改线程池配置
线程封闭
线程封闭可以使得任务不需要对资源做安全性保护仍能正执行,但这样一来,任务和任务执行策略就存在隐形的耦合——任务执行策略必须按某种顺序去执行任务。典型的线程封闭常见,单线程线程池,这时候改动线程池配置就可能会影响任务的正确性。
时间敏感性
任务可能具有时间敏感性,例如它对于响应时间是敏感的,那么在只有少量线程的线程池中一旦你执行某个时间过长的任务就会影响任务的响应。
又比如,某些任务执行时间过长。幸运的是,大多数阻塞方法都提供了无限时和限时的版本,如果超时,那么可以取消任务,避免影响线程池的性能。
ThreadLocal
ThreadLocal属于线程,但是在线程池的场景中,线程会被复用。这就要求,ThreadLocal必须和任务生命周期相同,才能够使用。
线程池大小
线程池的线程数量设置,主要是需要考虑到任务用到的种种资源,其目的就是尽量让CPU满转运行。通用的就比如CPU的核心数量,又比如内存容量,IO连接数,数据库连接池等,它们决定了线程数量的上限。
在通常情况下,我们考虑CPU核心数量。
- 对于计算密集型任务,设置[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8QfUTkpG-1641907884325)(https://cdn.nlark.com/yuque/latex/63c389615a54e129a4a5457050ec3222.svg#card=math&code=N%7Bthread%7D%3DN%7Bcpu%7D%20%2B%201&id=QPIrG)]
- 对于阻塞密集的任务,一般情况下[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7xcWsaFI-1641907884326)(https://cdn.nlark.com/yuque/latex/9feba0b49a735b4382d5045cb43b149c.svg#card=math&code=N%7Bthread%7D%3DN%7Bcpu%7D%2AU_%7Bcpu%7D%2A%5Cleft%28%201%20%2B%20%5Cfrac%7BW%7D%7BC%7D%5Cright%29&id=ozbVv)],其中U是目标CPU利用率,W为等待时间,C为计算时间。这个公式的核心在于,线程数量与(等待时间/计算时间比值)的倍数有关。
配置线程池
线程数量
线程池中线程数量主要有两个参数控制核心线程数量(Core Pool Size)和最大线程数量(Max Pool Size)。核心线程数量控制线程池的固定线程数量,最大线程数量 - 核心线程数量 = 普通线程数量,普通线程数量控制线程池弹性大小。——在任务队列满的时候,说明核心线程不够用,任务太多,需要开启普通线程。具体参考线程池原理。
任务队列
无限制地增加线程数量会让线程池变得不稳定,所以线程池通常是用阻塞队列在存放任务,控制线程数量。
以下队列类型之间有交集,例如ArrayBlockingQueue可能同时是有界队列,先进先出队列;PriorityQueue同时是有界队列,优先级队列。理解这些类型分别的作用和限制非常有必要。
无界队列
- 注意:如果任务持续到达,那么任务队列可能无限制地增加,工作线程处于忙碌状态
- 场景:newFixedThreadPool和newSingleThreadPool默认使用无界队列
有界队列
- 使用:ArrayBlockingQueue,LinkedBlockingQueue,PriorityBlockingQueue
- 注意:有界队列比无界队列更为稳妥,解决了资源耗尽的问题。但它必须指定饱和策略——即任务饱和之后如何处理,下文详细展开。
需要了解,当任务不是独立的时候(存在任务依赖),设置有界队列可能会引起线程饥饿。
同步移交队列
- 使用:SynchronousQueue,该队列的特地是没有容量,放一个,取一个
- 注意:只有当线程池是无界的或者可以拒绝任务时,使用它才有意义——有充足的线程执行任务,任务不必进入任务队列阻塞。
- 场景:newCachedThreadPool就使用了同步移交队列来提高性能
先进先出队列
- 使用:LinkedBlockingQueue和ArrayBlockingQueue
- 注意:执行顺序和任务到达顺序相同,并发场景似乎没啥意义,对于单线程线程池有意义
优先级队列
- 使用:PriorityBlockingQueue
- 注意:指定任务优先级
饱和策略
线程池中设置有RejectedExecutionHandler用于指定饱和策略。
public interface RejectedExecutionHandler {
void rejectedExecution(Runnable r, ThreadPoolExecutor executor);
}
几种内置的策略源码非常简单:
/*
调用者执行策略:
交给调用者执行,当任务饱和的时候,任务会交给execute调用者所在的线程执行,由于执行是同步的
需要执行时间,所以调用者无法再增加新的任务,从而间接控制任务提交速率。
*/
public static class CallerRunsPolicy implements RejectedExecutionHandler {
public CallerRunsPolicy() { }
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
if (!e.isShutdown()) {
// 直接调用r.run()就是调用者本身的线程执行,否则是交给线程池中的线程去执行
r.run();
}
}
}
/*
终止策略:
终止任务,抛出异常。
*/
public static class AbortPolicy implements RejectedExecutionHandler {
public AbortPolicy() { }
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
throw new RejectedExecutionException("Task " + r.toString() +
" rejected from " +
e.toString());
}
}
/*
丢弃策略:
任务饱和时,什么都不做,直接丢弃任务。
*/
public static class DiscardPolicy implements RejectedExecutionHandler {
public DiscardPolicy() { }
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
}
}
/*
抛弃最久策略:
任务饱和时,丢弃最早前提交的任务,然后提交当前任务
*/
public static class DiscardOldestPolicy implements RejectedExecutionHandler {
public DiscardOldestPolicy() { }
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
if (!e.isShutdown()) {
// 先进先出,最早的任务出队
e.getQueue().poll();
e.execute(r);
}
}
}
上述只有第一种策略能间接控制任务到达速率,有时候我们想手动控制任务速率,使用信号量可以简单达到目的。
@ThreadSafe
public class BoundedExecutor {
private final Executor exec;
private final Semaphore semaphore;
public BoundedExecutor(Executor exec, int bound) {
this.exec = exec;
this.semaphore = new Semaphore(bound);
}
public void submitTask(final Runnable command)
throws InterruptedException {
// 提交任务时申请资源,到达指定数量会阻塞
semaphore.acquire();
try {
exec.execute(new Runnable() {
public void run() {
try {
command.run();
} finally {
// 任务执行完释放信号量
semaphore.release();
}
}
});
} catch (RejectedExecutionException e) {
// 任务拒绝时,释放信号量
semaphore.release();
}
}
}
线程工厂
使用线程工厂可以自由定制我们需要的线程。
// 自定义线程工厂
public class MyThreadFactory implements ThreadFactory {
private final String poolName;
public MyThreadFactory(String poolName) {
this.poolName = poolName;
}
public Thread newThread(Runnable runnable) {
return new MyAppThread(runnable, poolName);
}
}
// 自定义线程,带生命周期、记录异常退出的线程
public class MyAppThread extends Thread {
public static final String DEFAULT_NAME = "MyAppThread";
// 对象状态,这些状态虽然发布了,但是各个状态都是独立的,通过原子性确保安全发布
private static volatile boolean debugLifecycle = false;
private static final AtomicInteger created = new AtomicInteger();
private static final AtomicInteger alive = new AtomicInteger();
private static final Logger log = Logger.getAnonymousLogger();
public MyAppThread(Runnable r) {
this(r, DEFAULT_NAME);
}
public MyAppThread(Runnable runnable, String name) {
super(runnable, name + "-" + created.incrementAndGet());
// 线程出现未捕获异常退出时,设定handler,记录日志
setUncaughtExceptionHandler(
new Thread.UncaughtExceptionHandler() {
public void uncaughtException(Thread t,
Throwable e) {
log.log(Level.SEVERE,
"UNCAUGHT in thread " + t.getName(), e);
}
});
}
public void run() {
// 技巧(COW的一种应用):
// 为局部复制了一份debugLifecycle字段,作用是由于方法未加锁,这样的复制保证在方法内的一致性
boolean debug = debugLifecycle;
if (debug)
log.log(Level.FINE, "Created " + getName());
try {
alive.incrementAndGet();
super.run();
} finally {
alive.decrementAndGet();
if (debug)
log.log(Level.FINE, "Exiting " + getName());
}
}
public static int getThreadsCreated() {return created.get();}
public static int getThreadsAlive() {return alive.get();}
public static boolean getDebug() {return debugLifecycle;}
public static void setDebug(boolean b) {debugLifecycle = b;}
}
防止创建后被篡改
线程池创建之后可以通过以下方式修改配置:
ExecutorService exec = Executors.newCachedThreadPool();
if (exec instanceof ThreadPoolExecutor)
((ThreadPoolExecutor) exec).setCorePoolSize(10);
else
throw new AssertionError("Oops, bad assumption");
但有的时候,我们并不期望这种事发生——后来的开发者可能因为错误操作,导致线程出问题,我们想让线程池创建之后就无法修改。
Executors中的unconfigurableExecutorService方法就提供了这种功能。它非常简单:
public static ExecutorService unconfigurableExecutorService(ExecutorService executor) {
if (executor == null)
throw new NullPointerException();
return new DelegatedExecutorService(executor);
}
// 基于组合的拓展,实现了ExecutorService接口,并委托大部分操作给e
// 不再基础于ExecutorThreadPool,因而无法修改配置
private static class DelegatedExecutorService implements ExecutorService {
private final ExecutorService e;
DelegatedExecutorService(ExecutorService executor) {
e = executor;
}
public void execute(Runnable command) {
try {
e.execute(command);
} finally {
reachabilityFence(this);
}
}
public void shutdown() {
e.shutdown();
}
...
}
线程池生命周期
线程池提供了beforeExecute, afterExecute, terminated三个protected方法,可以拓展线程池各生命周期的操作。
对于线程池来说,这个平均时间其实不太好方便地计算,生命周期方法其实可以做一些这种统计拓展。
// 案例,通过拓展线程池生命周期方法,计算平均任务执行时间
public class TimingThreadPool extends ThreadPoolExecutor {
// ThreadLocal出现的另一个原因:Executor的广泛应用。ThreadLocal实际上解耦了Thread和它的局部变量
// 这里ThreadLocal的生命周期和任务的生命周期一致,可以安全使用
private final ThreadLocal<Long> startTime = new ThreadLocal<Long>();
private final Logger log = Logger.getLogger("TimingThreadPool");
private final AtomicLong numTasks = new AtomicLong();
private final AtomicLong totalTime = new AtomicLong();
// 任务执行前
protected void beforeExecute(Thread t, Runnable r) {
// 调用是为了保存父类的实现,子类作为拓展
super.beforeExecute(t, r);
log.fine(String.format("Thread %s: start %s", t, r));
// 记录任务开始时间
startTime.set(System.nanoTime());
}
// 任务执行后
protected void afterExecute(Runnable r, Throwable t) {
try {
// 计算任务执行时间
long endTime = System.nanoTime();
long taskTime = endTime - startTime.get();
// 计算任务数量
numTasks.incrementAndGet();
// 计算任务总时间
totalTime.addAndGet(taskTime);
log.fine(String.format("Thread %s: end %s, time=%dns",
t, r, taskTime));
} finally {
super.afterExecute(r, t);
}
}
// 结束时
protected void terminated() {
try {
// 平均任务执行时间
log.info(String.format("Terminated: avg time=%dns",
totalTime.get() / numTasks.get()));
} finally {
super.terminated();
}
}
}
使用线程池并发化程序
迭代并发化
只有当迭代中各个操作独立,且每个迭代的计算工作量大于管理一个任务的开销时,这个并行化才是有意义的。
void processSequentially(List<Element> elements) {
for (Element e : elements)
process(e);
}
}
void processInParallel(Executor exec, List<Element> elements) {
for (final Element e : elements)
// 不需要等待上一个迭代执行
exec.execute(new Runnable() {
public void run() { process(e); }
});
}
深搜并发化
递归中的深度搜索,其实这种形式不好做并行化。
只有当每次递归的操作独立,每次递归的计算工作量大于管理一个任务的开销时,这个并行化才是有意义的。
public<T> void sequentialRecursive(List<Node<T>> nodes,
Collection<T> results) {
for (Node<T> n : nodes) {
results.add(n.compute());
sequentialRecursive(n.getChildren(), results);
}
}
public<T> void parallelRecursive(final Executor exec,
List<Node<T>> nodes,
final Collection<T> results) {
for (final Node<T> n : nodes) {
// 节点并行计算
exec.execute(new Runnable() {
public void run() {
results.add(n.compute());
}
});
parallelRecursive(exec, n.getChildren(), results);
}
}
广搜并发化
递归中的广度搜索,其实是天然和线程池契合的,因为线程池中本身的任务就是一个队列。
来看一个解决Puzzle的并行化案例,它原来是基于深度搜索的。
public class SequentialPuzzleSolver<P, M> {
private final Puzzle<P, M> puzzle;
private final Set<P> seen = new HashSet<P>();
public SequentialPuzzleSolver(Puzzle<P, M> puzzle) {
this.puzzle = puzzle;
}
public List<M> solve() {
// 初始化标志
P pos = puzzle.initialPosition();
// dfs
return search(new Node<P, M>(pos, null, null));
}
private List<M> search(Node<P, M> node) {
if (!seen.contains(node.pos)) {
seen.add(node.pos);
if (puzzle.isGoal(node.pos))
return node.asMoveList();
for (M move : puzzle.legalMoves(node.pos)) {
P pos = puzzle.move(node.pos, move);
Node<P, M> child = new Node<P, M>(pos, move, node);
List<M> result = search(child);
if (result != null)
return result;
}
}
return null;
}
static class Node<P, M> {
/* Listing 8.14 */ }
}
并行化,并改造成广度搜索,它将每次递归的搜索封装为一个Task。
public class ConcurrentPuzzleSolver<P, M> {
private final Puzzle<P, M> puzzle;
private final ExecutorService exec;
// vis委托给ConcurrentMap
private final ConcurrentMap<P, Boolean> seen;
// 保存结果,也可以直接使用阻塞队列
final ValueLatch<Node<P, M>> solution = new ValueLatch<Node<P, M>>();...
public List<M> solve() throws InterruptedException {
try {
P p = puzzle.initialPosition();
// 开始并发搜索
exec.execute(newTask(p, null, null));
// 阻塞,直到找到解
Node<P, M> solnNode = solution.getValue();
return (solnNode == null) ? null : solnNode.asMoveList();
} finally {
exec.shutdown();
}
}
protected Runnable newTask(P p, M m, Node<P, M> n) {
return new SolverTask(p, m, n);
}
class SolverTask extends Node<P, M> implements Runnable {
public void run() {
if (solution.isSet()
|| seen.putIfAbsent(pos, true) != null)
return; // already solved or seen this position
if (puzzle.isGoal(pos))
solution.setValue(this);
else
// 每个新的节点新建为一个搜索任务,插入线程池队列中
for (M m : puzzle.legalMoves(pos))
exec.execute(
newTask(puzzle.move(pos, m), m, this));
}
}
}
上述程序有一个问题——如果并发搜索不到解,那么就会永远阻塞。所以可以进一步拓展,当执行任务为0时,直接设置null为结果,避免无限阻塞。
public class PuzzleSolver<P, M> extends ConcurrentPuzzleSolver<P, M> {
// 统计执行的任务数量,原子性保证安全发布
private final AtomicInteger taskCount = new AtomicInteger(0);
protected Runnable newTask(P p, M m, Node<P, M> n) {
return new CountingSolverTask(p, m, n);
}
class CountingSolverTask extends SolverTask {
CountingSolverTask(P pos, M move, Node<P, M> prev) {
super(pos, move, prev);
taskCount.incrementAndGet();
}
public void run() {
try {
super.run();
} finally {
// 任务执行完判断,如果归0,这设置空值,此处不会有竞态问题
if (taskCount.decrementAndGet() == 0)
solution.setValue(null);
}
}
}
}
MR框架
从上述并发改造来看,这种程序改造并不容易,所以我联想起另一种适合于并发(并行)执行的编程框架,MapReduce。它从一开始就是考虑并发执行的场景,虽然编程不是特别的直观,但简单许多。有兴趣请查阅其他资料了解。