使用 ThreadPoolExecutor 时看不到 CPU 绑定任务的上下文切换开销

Cannot see context switch overhead for CPU Bound tasks when using ThreadPoolExecutor

我正在尝试做一个简单的实验,当你有一堆 CPU 密集型任务时,我想找出线程池的正确大小。

我已经知道这个大小应该等于机器上的核心数,但我想凭经验证明这一点。这是代码:

public class Main {

    public static void main(String[] args) throws ExecutionException {
        List<Future> futures = new ArrayList<>();
        ExecutorService threadPool = Executors.newFixedThreadPool(4);

        long startTime = System.currentTimeMillis();

        for (int i = 0; i < 100; i++) {
            futures.add(threadPool.submit(new CpuBoundTask()));
        }

        for (int i = 0; i < futures.size(); i++) {
            futures.get(i).get();
        }

        long endTime = System.currentTimeMillis();
        System.out.println("Time = " + (endTime - startTime));
        threadPool.shutdown();
    }

    static class CpuBoundTask implements Runnable {
        @Override
        public void run() {
            int a = 0;
            for (int i = 0; i < 90000000; i++) {
                a = (int) (a + Math.tan(a));
            }
        }
    }
}

每个任务执行大约 700 毫秒(我认为这足以被 ThreadScheduler 至少抢占一次)。

我 运行 这台 MacbookPro 2017,3.1 GHz Intel Core i5,2 个激活超线程的物理内核,所以 4 个逻辑 CPUs。

我调整了线程池的大小,我 运行 这个程序多次(平均时间)。以下是结果:

1 thread = 57 seconds
2 threads = 29 seconds
4 threads = 18 seconds
8 threads = 18.1 seconds
16 threads = 18.2 seconds
32 threads = 17.8 seconds
64 threads = 18.2 seconds

由于上下文切换开销,一旦我添加了如此多的线程(超过 CPU 内核的数量),我预计执行时间会显着增加,但似乎这并没有这真的没有发生。

我使用 VisualVM 来监视程序,看起来所有线程都已创建并且它们处于 运行 状态,正如预期的那样。此外,CPU 似乎使用得当(接近 95%)。

有什么我遗漏的吗?

在这种情况下,您应该使用 System.nanoTime() instead of System.currentTimeMillis()

您的算法在 4 个线程处停止缩放,为简单起见,让我们假设所有线程执行相同数量的任务,因此 25 每个线程。每个线程大约花费 18 秒来计算 25 次迭代。

以一种非常简单的方式,当您 运行 使用 64 个线程时,每个 个核心将有 8 个线程 ,第一个 4 次迭代有 4 线程 运行ning(1 per 核心)并行,其他 60 线程在 idle 模式等待 CPU 资源来计算它们的迭代,所以你有这样的东西:

Iteration 0 : Thread 1 (running)
Iteration 1 : Thread 2 (running)
Iteration 2 : Thread 3 (running)
Iteration 3 : Thread 4 (running)
Iteration 4 : Thread 5 (waiting)
Iteration 5 : Thread 6 (waiting)
Iteration 6 : Thread 7 (waiting)
Iteration 7 : Thread 8 (waiting)
...
Iteration 63 : Thread 64 (waiting)

当那些 4 线程完成它们的迭代时,它们将分别进行另一次迭代。与此同时,假设线程 58 开始在接下来的四次迭代中工作( 4 个线程再次并行执行工作 ),而其他线程 阻塞 等待 CPU,依此类推。所以你 总是4 个并行线程 运行,不管怎样,这就是为什么 for:

8 threads = 18.1 seconds
16 threads = 18.2 seconds
32 threads = 17.8 seconds
64 threads = 18.2 seconds

你们的执行时间大致相同,大约与 相同的 执行时间 4 线程并行完成 25 迭代。

因为这是一个 CPU-bound 算法,没有以下问题:

  1. 同步;
  2. 加载不平衡(每次循环迭代花费的执行时间大致相同);
  3. 内存带宽饱和;
  4. 缓存失效;
  5. 虚假分享。

当您增加线程数 per core 时,它不会反映 那么多 的总体执行时间。

I was expecting the execution time to be significantly higher, once I add so many threads (more than the number of CPU cores), because of the context switch overhead, but it seems that this doesn't really happen.

由于多种原因,很难检测到这一点。首先,现代操作系统非常擅长针对此用例进行优化。上下文切换曾经是一把大锤,但在现代内存架构下,这样做的成本要低得多。

上下文切换的代价是内存缓存刷新。当一个线程被交换到 CPU 时,本地缓存内存可能不包含任何它进行计算所需的每线程信息。它必须去主内存读取所需的内存行,速度较慢。换出的速度也较慢,因为任何脏页都必须写入主内存。出于这个原因,我认为如果您的任务使用更多的缓存内存,您可能会看到更高的上下文切换惩罚。您当前的程序只存储几个整数。例如,假设您在程序开始时为每个线程分配了 ~10k,并将 运行dom 值放入其中。然后,当每个线程 运行 时,它们会尝试 运行dom 从相应的 10k 块中访问数据,这些块将移动到 CPU 缓存内存中。这可能是一个更好的实验。但这意味着您将必须对您的架构有很多了解并适当优化您的应用程序以完全检测上下文切换。

最后,像任何 Java 测试程序一样,您应该 运行 一分钟,以便 class 热插拔和其他优化解决,然后 运行 收集数据很长一段时间。 运行 花费 18 秒的测试更多地使用 JVM,而不是您的测试代码。如果您 运行 持续(比方说)1800 秒,您可能会看到某种可测量的差异。而且,正如@dreamcrash 提到的,使用 System.nanoTime() 应该用于像这样的细粒度计时计算。

首先,上下文切换开销随着线程数量增加而增加的假设并不总是正确的。您的示例程序执行固定数量的工作。您拥有的线程越多 - 每个线程所做的工作越少,它收到的 CPU 时间就越少。

即使您有数百个线程,OS 也不会无限频繁地在它们之间切换。通常有一个线程允许 运行 不被抢占的最小间隔(时间片)。由于有太多线程竞争物理内核,每个线程接收其 cpu 时间片的频率会降低(即饥饿),但上下文切换的数量不会与线程数量成比例增长。

我用 Linux perf:

测量了你程序中的上下文切换次数
perf stat -e context-switches java Main

结果如下:

 2 threads | 1,445 context-switches
 4 threads | 2,417 context-switches
 8 threads | 9,280 context-siwtches
16 threads | 9,257 context-switches
32 threads | 9,527 context-switches
64 threads | 9,986 context-switches

当线程数量超过物理 CPU 数量时,上下文切换预计会发生巨大飞跃,但之后数量不会增长那么多。

好的,我们看到大约 10K 次上下文切换。这么多吗?正如 the answers 所建议的,上下文切换的延迟可以估计为几微秒。让我们以 10 作为上限。因此,10K 个开关加起来大约需要 100 毫秒,或者每个 CPU 需要 25 毫秒。您的测试不太可能检测到这种开销。此外,所有线程都是纯粹 CPU 绑定的——它们甚至不会访问足够多的内存来遭受 CPU 缓存污染。它们也不访问其他共享资源,因此在这种情况下没有间接上下文切换开销。

Executors.newWorkStealingPool

如果您使用 Java 8,请使用 workStealingThreadPool,因为它可能会提供最佳结果:

ExecutorService es = Executors.newWorkStealingPool();

创建一个使用所有 available processors 作为其目标并行度级别的工作窃取线程池。 并行度级别对应于积极参与或可参与任务处理的最大线程数。实际线程数可能会动态增长和收缩。工作窃取池不保证提交任务的执行顺序。