我试着做一个简单的实验,当你有一堆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%)。
有什么我不知道的吗?
4条答案
按热度按时间yfjy0ee71#
executors.newworkstealingpool项目
如果您使用的是Java8,请使用
workStealingThreadPool
因为它可能会产生最好的结果:使用所有可用的处理器作为其目标并行级别来创建工作线程池。并行级别对应于主动参与或可用于参与任务处理的最大线程数。线程的实际数量可能会动态地增长和收缩。偷工池不能保证提交任务的执行顺序。
nuypyhwy2#
首先,上下文切换开销随着线程数的增加而增加的假设并不总是正确的。您的示例程序执行固定数量的工作。线程越多—每个线程所做的工作越少,接收的cpu时间也越少。
即使有数百个线程,操作系统也不会无限频繁地在它们之间切换。通常允许线程在没有抢占的情况下运行的最小间隔(时间片)。由于有太多线程争夺一个物理内核,每个线程接收cpu时间片的频率会降低(即饥饿),但上下文切换的数量不会与线程的数量成比例增长。
我测量了linux程序中上下文切换的数量
perf
:结果如下:
当线程的数量超过物理cpu的数量时,上下文切换的一个巨大飞跃就会发生,但是之后这个数量并没有增长那么多。
好的,我们可以看到大约10k的上下文切换。有那么多吗?答案表明,上下文切换的延迟可以估计为几微秒。我们以10为上限。因此,10k交换机加起来大约需要100ms,即每个cpu需要25ms。您的测试不太可能检测到这种开销。此外,所有线程都是纯cpu绑定的—它们甚至不能访问足够的内存,从而受到cpu缓存污染的影响。它们也不访问其他共享资源,因此在本例中没有间接的上下文切换开销。
vyswwuz23#
当我添加这么多线程(超过cpu内核的数量)时,我本来以为执行时间会大大增加,这是因为上下文切换的开销,但似乎这并没有真正发生。
由于很多原因,很难发现这一点。首先,现代操作系统非常擅长针对这个用例进行优化。上下文切换曾经是一个大难题,但在现代内存体系结构中,这样做的成本要低得多。
上下文切换的代价是内存缓存刷新。当一个线程被交换到一个cpu中时,本地缓存内存可能不包含它进行计算所需的任何每线程信息。它必须去主存读取所需的内存行,这是较慢的。交换出来的速度也比较慢,因为任何脏页都必须写入主内存。出于这个原因,我认为如果任务使用更多的缓存内存,您可能会看到更高的上下文切换代价。当前程序只存储几个整数。例如,假设您在程序开始时为每个线程分配~10k,并将随机值放入其中。然后,当每个线程运行时,它们会尝试随机访问相应的10k块中的数据,这些块将移动到cpu缓存内存中。那也许是个更好的实验。但也就是说,您必须对您的体系结构有很多了解,并适当地优化应用程序,以完全检测上下文切换。
最后,像任何java测试程序一样,您应该运行一分钟,以便类热交换和其他优化得到解决,然后运行一段长时间收集数据。运行一个耗时18秒的测试比测试代码更能锻炼jvm。如果你跑1800秒,你可能会看到一些可以测量的差别。正如@dreamcrash提到的,使用
System.nanoTime()
应该用于这样的细粒度计时计算。14ifxucb4#
在这种情况下,应该使用system.nanotime()而不是system.currenttimemillis()。
你的算法停止在
4
为了简单起见,让我们假设所有线程执行相同数量的任务,因此每个线程25个。每一根线18
计算25次迭代所需的时间。以一种非常简单的方式,当你和
64
线程,每个核心有8个线程,第一个4
迭代次数有4
并行运行的线程(每个核1个)和其他线程60
线程处于空闲模式,等待cpu资源计算其迭代次数,因此您有如下情况:当那些
4
线程完成了它们的迭代,它们将得到另一个迭代。同时,让我们说5
至8
当其他线程被阻塞等待cpu时,开始处理接下来的四个迭代(同样是4个线程并行执行工作),依此类推。所以你总是这样4
线程并行运行,这就是为什么:你有大约相同的执行时间,大约相同的执行时间
4
螺纹加工25
并行迭代。因为这是一个cpu受限的算法,没有以下问题:
同步化;
加载不平衡(即,每个循环迭代需要大约相同的执行时间);
内存带宽饱和;
缓存失效;
虚假分享。
当您增加每个线程的线程数时,它不会在总体执行时间上反映太多
core
.