并发编程-1-上下文切换

1. 并发和串行执行速度对比

我们可以简单对比在计数递增的场景下,并发和串行的执行速度:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
public static void main(String[] args) throws InterruptedException {
long count = 10000;
long averageTestCount = 10;
for (long i=0;i<6;i++) {
System.out.println("test:"+count);
long time1 = 0;
long time2 = 0;
for (int j=0;j<averageTestCount;j++) {
time1 += testConcurrency(count);
time2 += testSerial(count);
}
time1 /= averageTestCount;
time2 /= averageTestCount;
String format = String.format("并发:%s | 串行:%s | 差值:%s", time1, time2, time1-time2);
System.out.println(format);

count *= 10;
System.gc();
Thread.sleep(1000);
}

}

public static long testConcurrency(long count) throws InterruptedException {
Thread thread = new Thread(() -> {
long a = 0;
for (long i = 0; i < count; i++) {
a += 5;
}
});
long start = System.nanoTime();
// a
thread.start();
// b
long b = 0;
for (long j=0;j<count;j++) {
b += 5;
}
thread.join();
long end = System.nanoTime();
return end - start;
}

public static long testSerial(long count) {
long start = System.nanoTime();
// a
long a = 0;
for (long i=0;i<count;i++) {
a += 5;
}
// b
long b = 0;
for (long j=0;j<count;j++) {
b += 5;
}

long end = System.nanoTime();
return end - start;
}

我们可以分别测试数量为 \(10^4、10^5、10^6、10^7、10^8、10^9\) 时,并发和串行的耗时:

循环次数 并发行执行耗时(ns) 串行执行耗时(ns) 差值(ns)
\(10^4\) 352430 90920 慢261510
\(10^5\) 400760 104020 慢296740
\(10^6\) 632220 518520 慢113700
\(10^7\) 4099540 5438800 快1339260
\(10^8\) 29268920 46877000 快17608080
\(10^9\) 293388350 476937870 快183549520

从表格中我们可以发现当数量级达到百万级时,并发的提升效果才逐渐体现出来。
为什么明明启动了多个线程,执行的速度却更慢呢?
这是因为并发方法执行时存在线程创建、线程同步和上下文切换等等额外的开销。


2. 什么是上下文切换?

让程序执行得更快是并发的核心目标。然而,启动更多的线程未必能提升程序运行的速度,因为我们很难做到程序得到最大限度的并发执行
一方面是线程创建、线程同步等操作影响了并发执行的效率,另一方面则是线程上下文切换的开销。

目前的大多数CPU都是支持并发执行的,这通常是通过CPU给每一个线程分配时间片这样的机制来实现的。时间片就是CPU分配给各个线程可以执行的时间。由于时间片的时间非常短,一般是几十毫秒,所以CPU会不停的的切换执行的线程,这样从宏观维度上让我们感觉多个线程在同时执行。

在发生执行的线程切换时,我们需要保存当前线程已执行的位置和一些中间状态等信息,以便于后续该线程再次执行时可以加载到当前的执行状态。这些线程切换时保存的信息就是上下文,线程从保存信息再到加载恢复的过程就是一次上下文切换


3. 减少上下文切换

3.1. 无锁并发

多线程竞争锁时,会引起额外的上下文切换。因此我们可以通过在并发编程时尽量避免锁竞争,来减少上下文切换的开销。
这里有两种避免锁竞争的方式:
- 避免线程冲突,如提前划分好各线程处理的资源,各线程独立处理各自部分的数据。 - CAS算法,Java中JUC包内大量采用了本算法来提供线程安全的解决方案。

3.2. 减少线程使用数量

当任务数量小于线程数量时,会有部分线程处于等待状态,等待状态的线程同样会获取时间片,因此会产生空耗的上下文切换。
因此尽量减少线程的数量可以减少上下文切换次数。

3.3. 协程

协程通过在单个线程内实现多任务的调度,可以避免大量的上下文切换。

4. 上下文切换优化实战

第一步,获取dump线程信息
通过 jstack 命令获取 pid 为 31177 的进程的线程信息

1
sudo -u admin jstack 31177 > dumpTest

第二步,查看线程状态统计
通过 grep、awk、sort、uniq 等命令统计线程状态

1
grep java.lang.Thread.State dumpTest | awk '{print $2$3$4$5}' | sort | uniq -c

结果如下:

1
2
3
4
5
6
39 RUNNABLE
21 TIMED_WAITING(onobjectmonitor)
6 TIMED_WAITING(parking)
51 TIMED_WAITING(sleeping)
305 WAITING(onobjectmonitor)
3 WAITING(parking)

这里发现有 300 多个线程处于 WAITING(onobjectmonitor) 状态。

第三步,查看线程的具体执行信息
到 dump 文件中查看处于 WAITING(onobjectmonitor) 状态的线程的具体执行信息。
若发现这些线程大多属于某个工作线程,且状态为 await,则较大可能该工作线程内存在大量闲置线程。

第四步,调整线程使用
如该工作线程中使用了线程池,那么适当调整该线程池的配置,如核心线程数、最大线程数、线程空闲时间等。

第五步,重复以上操作调优
重启应用,重复前四步操作。



并发编程-1-上下文切换
https://cqu-linmu.github.io/linmu-blog/2024/09/01/并发编程-1-上下文切换/
作者
linmu
发布于
2024年9月1日
更新于
2024年9月2日
许可协议