为什么线程池中的临时线程这么奇怪?

301 阅读5分钟

为什么线程池中的临时线程这么奇怪

禁止转载。

IO密集型任务通常使用 Executors#newCachedThreadPool 方法进行创建,核心线程数为0,临时线程用于处理IO任务。瓶颈在于短时创建大量线程占用过多系统资源,可以通过限流、配置临时线程数解决。

本文仅讨论核心线程数不为0的情况。

笔者最初学习线程池有这样一个疑问:

为什么阻塞队列满后,临时线程不是一次性创建,而是一个一个地创建?

线程池任务处理的基本流程

线程池状态

源码中ctl 表示线程池状态,复用int字段,代码已高度优化。

private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
private static final int COUNT_BITS = Integer.SIZE - 3;
private static final int COUNT_MASK = (1 << COUNT_BITS) - 1;

// runState is stored in the high-order bits
private static final int RUNNING    = -1 << COUNT_BITS;
private static final int SHUTDOWN   =  0 << COUNT_BITS;
private static final int STOP       =  1 << COUNT_BITS;
private static final int TIDYING    =  2 << COUNT_BITS;
private static final int TERMINATED =  3 << COUNT_BITS;

// Packing and unpacking ctl
private static int runStateOf(int c)     { return c & ~COUNT_MASK; }
private static int workerCountOf(int c)  { return c & COUNT_MASK; }
private static int ctlOf(int rs, int wc) { return rs | wc; }

可以使用值类型(value-based)表示为直观理解的形式:

public enum TpState {
    RUNNING,
    SHUTDOWN,
    STOP,
    TIDYING,
    TERMINATED,
}
// 使用Ctl同样可以保证线程安全
record Ctl(TpState state, int workerCount) {}

执行流程

public void execute(Runnable command) {
    if (command == null)
        throw new NullPointerException();
    /*
     * Proceed in 3 steps:
     *
     * 1. If fewer than corePoolSize threads are running, try to
     * start a new thread with the given command as its first
     * task.  The call to addWorker atomically checks runState and
     * workerCount, and so prevents false alarms that would add
     * threads when it shouldn't, by returning false.
     *
     * 2. If a task can be successfully queued, then we still need
     * to double-check whether we should have added a thread
     * (because existing ones died since last checking) or that
     * the pool shut down since entry into this method. So we
     * recheck state and if necessary roll back the enqueuing if
     * stopped, or start a new thread if there are none.
     *
     * 3. If we cannot queue task, then we try to add a new
     * thread.  If it fails, we know we are shut down or saturated
     * and so reject the task.
     */
    int c = ctl.get();
    if (workerCountOf(c) < corePoolSize) {
        if (addWorker(command, true))
            return;
        c = ctl.get();
    }
    if (isRunning(c) && workQueue.offer(command)) {
        int recheck = ctl.get();
      	// 对读取旧状态进行检查,检查状态并回退
        if (! isRunning(recheck) && remove(command))
            reject(command);
        else if (workerCountOf(recheck) == 0)
            addWorker(null, false);
    }
    else if (!addWorker(command, false))
        reject(command);
}

ctl 使用CAS优化,并未处于同步块中。使用CAS优化的代码需要注意:

  1. 适用于竞争不激烈的场景。
  2. 所有读操作,在读取操作完成后就不一定是当前值,需要视为过去的有效值

任务处理流程(其实代码已经很清楚了,仅做简单文字说明):

  1. 工作线程数小于核心线程数时,添加核心线程处理,成功,则返回。
  2. 尝试添加到任务队列中,成功,则返回。
  3. 尝试添加临时线程处理,成功,则返回。
  4. 无法处理,执行拒绝回调。

可以简单地将线程池看作生产者—消费者模式中的消费者,内部维护了消费队列。

从流程上可以看出,临时线程的优先级小于任务队列。仅在任务队列满时触发新增,任务队列为空后,经过超时时间后,仍然无法竞争到任务时关闭。

其创建条件比较苛刻,当出现短期大量任务时,不一定完全触发所有临时线程的创建,更一般的情况是:当创建了一些临时线程后,工作线程消费速度快于任务的生成速度,此时工作线程数大于核心线程数,这种状态会持续到临时线程超时销毁。这种现象是我们不想看到了,因为一般核心线程数与CPU核数接近,临时线程的加入反而会增加不同线程上下文切换的开销。

总之,不要依赖核心线程处理问题,对于临时线程的配置应该与CPU使用率的监控相互参照。

还有一种可能,如果你希望可以同时处理更多的任务,同时可以接受牺牲掉一部分处理速度,那么适当调大核心线程数才是正解。

不恰当的比喻

核心线程数是正式员工,最大线程数是外包,找外包的条件是阻塞队列满了以后线程不够用,等最大空闲时间到了,还没活干,就开掉多余的外包。

更恰当的比喻

为什么临时线程不是外包?

外包确实可能不需要时会“销毁”,但是外包对于公司整体来说是有利的。这个比喻忽略了临时线程可能带来不好影响的问题。

核心线程数是工时,临时线程数是加班工时,员工数相当于CPU核心数

原本的任务是可以按照计划完成的,但是可能突然产生很多需求,此时公司要求加班,员工处理任务的效率降低了(上下文切换+降低响应时间),但是保证了任务可以完成。

配置的超时时间相当于加班形成了惯性,没事也要在公司待着。

拒绝策略相当于即使加班也无法按时交付。

结论

  1. 临时线程是一个仅次于拒绝策略的兜底策略。
  2. 大多数时候线程池的配置的重点应该是核心线程和任务队列。
  3. 使用到临时线程(甚至拒绝策略)说明线程池执行遇到瓶颈,可能需要重新评估任务的重要性,选择分配更多资源或者执行兜底策略。