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

Posted

技术标签:

【中文标题】使用 ThreadPoolExecutor 时看不到 CPU Bound 任务的上下文切换开销【英文标题】:Cannot see context switch overhead for CPU Bound tasks when using ThreadPoolExecutor 【发布时间】:2021-03-29 22:12:22 【问题描述】:

我正在尝试做一个简单的实验,当你有一堆 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 个逻辑 CPU 上运行此程序。

我调整了线程池的大小,并多次运行该程序(平均时间)。结果如下:

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%)。

我有什么遗漏的吗?

【问题讨论】:

【参考方案1】:

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

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

以一种非常简单的方式,当您使用 64 线程运行时,您将有 8 个线程每个内核,并且在第一次 4 迭代中运行 4 线程(1 per core) 并行,而其他 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线程并行运行,不管怎样,这就是为什么:

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

您的执行时间大致相同,与 4 线程并行完成 25 迭代所花费的执行时间大致相同。

因为这是一个 CPU 密集型算法,没有以下问题:

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

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

【讨论】:

【参考方案2】:

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

即使您有数百个线程,操作系统也不会在它们之间无限频繁地切换。通常有一个最小间隔(时间片)允许线程在没有抢占的情况下运行。由于有太多线程竞争物理内核,每个线程接收其 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 缓存污染的内存。它们也不访问其他共享资源,因此在这种情况下没有间接上下文切换开销。

【讨论】:

【参考方案3】:

Executors.newWorkStealingPool

如果您使用的是 Java 8,请使用 workStealingThreadPool,因为它可能会产生最佳效果:

ExecutorService es = Executors.newWorkStealingPool();

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

【讨论】:

感谢您的回答!据我所知,newWorkStealingPool 将创建一个 ForkJoinPool,它在分而治之的执行模型上效果最好,这不是我在这里要测试的(我想知道如何充分利用 ThreadPoolExecutor ) 你没有在这里回答实际问题或解决问题。【参考方案4】:

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

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

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

最后,像任何 Java 测试程序一样,您应该运行一分钟,以便类热交换和其他优化解决,然后运行很长时间收集数据。运行一个需要 18 秒的测试对 JVM 的使用比你的测试代码更多。如果你跑了(比如说)1800 秒,你可能会看到某种可测量的差异。而且,正如@dreamcrash 所提到的,使用System.nanoTime() 应该用于像这样的细粒度时序计算。

【讨论】:

以上是关于使用 ThreadPoolExecutor 时看不到 CPU Bound 任务的上下文切换开销的主要内容,如果未能解决你的问题,请参考以下文章

boss直聘立即沟通时看不到简历吗

从运行时看Block——披着函数外衣的结构体

年轻时看远,中年时看透,老年时看淡

程序员迷茫时看一下

买车时看的几个参数

go 读取数据库所有表数据显示到网页(便于测试时看)