记自旋触发内核看门狗导致系统重启的思考
问题描述
业务中有用到一个驱动,但是加载这个驱动并使用他的一些功能时,会导致设备宕机重启。查看内核日志和转储宕机日志,发现时触发了内核软狗。
1 | cat /var/log/messages |
查看当前系统的看门狗超时时间,发现是5S,这个值一般默认是10S,直接改大这个超时时间到30S后成功解决问题:
1 | # 查看看门狗时间 |
至于这个参数,其实该参数控制的是 看门狗超时阈值,即在系统没有发送内核心跳信号(或者系统处于“无响应”状态)超过此时间时,会触发看门狗机制。这个参数的值通常是以秒为单位,表示系统多久没有响应后,触发看门狗行为。
再次使用驱动,执行相关命令,并监视CPU占用,发现CPU有个核心在一段时间内占用达到了100%,分析确定是驱动存在相关问题。
问题分析
驱动上加了一些内核日志,最终定位到是有段代码存在问题:
1 | // ....... |
这段代码逻辑很清楚,就是有两个超时时间,在spin_timeout
内,不释放CPU,自旋的获取状态,超过时间后就进入阻塞式获取状态,最终还不行就超时报错。
逻辑确实没问题,但是坏就坏在自旋的超时时间设置的太大了!自旋的适合并不会释放CPU,而这里有没有阻塞的IO操作,于是导致CPU被占用到100,进而触发了内核软狗,导致系统重启!
那么解决其实就很好解决了,直接把自旋的时间缩小就行了。至于缩小到什么程度,还是要看你的业务实现,太短了自旋没啥意义,太大了导致你CPU被冗余占用。
问题发散
这个问题让我想起前两个月写的关于CPU加压的那篇文章,现在反过来同样有几个思考。
忙等待与自旋
忙等待(Busy Waiting)
忙等待是指程序在等待某个条件满足时,通过不断检查条件的状态来决定是否继续执行。这种等待方式没有让出 CPU 的控制权,而是一直占用 CPU,导致资源的浪费。
特征
- 程序在循环中反复检查条件。
- 不会主动放弃 CPU 资源。
- 会导致 CPU 使用率升高,即使没有实际的任务在执行。
示例
1 | while (!condition) { |
在上述代码中,程序会持续循环检查 condition
,直到条件满足。期间 CPU 一直被占用。
自旋(Spin Waiting 或 Spinning)
自旋是忙等待的一种具体实现方式,通常在 多线程编程中用于尝试获取锁或其他共享资源。当一个线程尝试获取锁时,如果锁已经被其他线程占用,自旋线程会在循环中反复检查锁是否可用。
特征
- 类似于忙等待,但有更明确的目的(例如,获取锁)。
- 自旋通常用于 短时间的等待,避免线程上下文切换的开销。
- 常常在多核 CPU 系统中使用,因为其他核可能很快释放资源。
示例
1 | while (lock_is_taken) { |
在这个例子中,线程会在 lock_is_taken
变为 false
时退出循环,获取锁。
忙等待与自旋的主要区别
对比点 | 忙等待 | 自旋 |
---|---|---|
目的 | 一般用于等待某个条件,无具体场景约束 | 用于等待锁或资源,主要用于多线程场景 |
适用场景 | 无明确目的的循环等待 | 资源预期很快释放的短时间等待 |
CPU 占用 | 持续占用 CPU,浪费资源 | 设计更优化,比如使用 _mm_pause 等减轻 CPU 压力 |
是否主动让出 CPU | 通常不会 | 通常不会,但有些实现可能会结合让步策略 |
多核支持 | 适用性较低,可能浪费资源 | 常在多核 CPU 上运行,多个线程分布在不同核心上 |
适用场景
忙等待
- 一般不推荐使用,除非在一些非常特殊的场景下,比如某些嵌入式系统中没有多线程调度的支持。
自旋
- 多核场景:当资源预计很快释放时,用自旋可以避免线程上下文切换的开销。
- 短等待场景:如果锁的持有时间非常短,自旋更高效。
- 长等待场景:如果等待时间较长,自旋可能会浪费 CPU 资源,此时应考虑使用 阻塞。
优化措施
忙等待中尽量避免无意义的循环。例如,在等待时加入休眠或暂停(如
sleep
、sched_yield
、_mm_pause
)。示例:
1
2
3while (!condition) {
sched_yield(); // 暂时让出 CPU
}自旋中可以使用专用指令(如
_mm_pause
)降低资源占用:1
2
3while (!condition) {
_mm_pause(); // 减少总线和缓存占用
}
总结
- 忙等待:更通用但浪费资源,适用性较低。
- 自旋:忙等待的优化版本,主要在短时间等待锁或资源的多核场景中使用。
- 如果等待时间预计较长,应考虑通过 阻塞 机制(如
mutex
或condition variable
)来让出 CPU,减少资源浪费。
CPU密集型和IO密集型运算
I/O密集型和CPU密集型是描述程序或任务执行时主要受限于哪类资源的术语。它们分别对应系统中两种主要的瓶颈:I/O操作(如磁盘、网络)和CPU运算(如数学计算、数据处理)。
1. I/O密集型
I/O密集型程序是指程序的大部分时间花在等待输入/输出设备完成操作上,而不是执行计算任务。
特点
- 主要瓶颈:I/O设备的速度,例如磁盘读写、网络数据传输。
- CPU使用率:通常较低,因为 CPU 大部分时间在等待 I/O 设备完成任务。
- 常见场景:
- 数据库操作
- 文件读写
- 网络请求(例如 Web 服务)
- 日志处理
优化策略
- 异步 I/O:通过异步或非阻塞 I/O 减少等待时间,例如使用多线程或事件驱动模型。
- 并发处理:增加线程或进程数,提高对多个 I/O 操作的处理能力。
- 缓存优化:通过缓存减少对慢速 I/O 设备的访问。
- 负载均衡:对于网络 I/O,合理分配任务以避免单点过载。
示例
一个从网络上读取文件并保存到磁盘的程序:
1 | import requests |
2. CPU密集型
CPU密集型程序是指程序的大部分时间花在执行计算任务上,而不是等待其他设备完成任务。
特点
- 主要瓶颈:CPU 运算能力,例如复杂算法、大量数学计算、图像处理等。
- CPU使用率:通常很高,因为 CPU 一直在执行计算任务。
- 常见场景:
- 加密解密
- 视频/音频处理
- 数据分析与机器学习模型训练
- 物理模拟(例如计算流体动力学)
优化策略
- 多线程/多进程:在多核 CPU 上并行执行任务。
- 算法优化:优化算法复杂度,减少不必要的计算。
- 硬件加速:利用 GPU 或专用硬件(如 TPU、FPGA)加速计算。
- 代码优化:使用高效的编程语言或库(如 C++、NumPy)。
示例
计算斐波那契数列的程序(递归版本):
1 | def fibonacci(n): |
3. 区别总结
属性 | I/O密集型 | CPU密集型 |
---|---|---|
主要瓶颈 | I/O设备(磁盘、网络等)的速度 | CPU 的计算能力 |
CPU使用率 | 通常较低,大部分时间等待 I/O 操作完成 | 通常较高,CPU 持续工作 |
线程并发 | 高并发有助于掩盖 I/O 等待 | 高并发可能导致线程争夺 CPU(需限制线程数) |
优化方向 | 异步 I/O、并发、缓存等 | 算法优化、多线程、硬件加速 |
常见场景 | 文件操作、数据库访问、网络请求 | 加密解密、数据分析、图像处理 |
4. 综合场景
许多应用程序是 I/O密集型 和 CPU密集型 的混合体。例如:
- 一个大型 Web 应用可能在处理用户请求时需要进行数据库查询(I/O密集型),同时对返回的数据进行分析和格式化(CPU密集型)。
- 视频编辑软件需要从磁盘加载视频数据(I/O密集型),同时对视频帧进行编码或滤波处理(CPU密集型)。
对于这样的混合场景,需要同时优化 I/O 和 CPU 的使用效率,合理分配资源。
总结
- I/O密集型:主要受限于输入/输出设备的性能(网络、磁盘等)。
- CPU密集型:主要受限于处理器的计算能力。
- 优化方法:针对不同的瓶颈采取相应的策略,避免资源浪费或系统瓶颈影响性能。
_mm_pause/cpu_relax作用
_mm_pause
和 cpu_relax
是用于优化自旋锁或者忙等待的指令或函数。它们的主要目的是降低忙等待过程中对系统的资源占用,同时提高性能和多核 CPU 的协作效率。
1. _mm_pause
概述
_mm_pause
是 Intel 提供的一个内联函数,它本质上是对 PAUSE
汇编指令的封装。PAUSE
是一种提示指令,告诉 CPU 当前的循环是忙等待,让 CPU 优化其内部操作。
作用
- 降低功耗:
- 在忙等待期间,CPU执行无意义的循环会浪费功耗,
_mm_pause
指令可以减少这种浪费。
- 在忙等待期间,CPU执行无意义的循环会浪费功耗,
- 减少总线争用:
- 在多核 CPU 系统中,忙等待可能导致缓存一致性协议占用大量总线带宽,
PAUSE
可以减少这种影响。
- 在多核 CPU 系统中,忙等待可能导致缓存一致性协议占用大量总线带宽,
- 避免退化:
- 超标量 CPU 执行紧密循环时可能会发生管道退化(Pipeline Stall),
PAUSE
指令可以提示 CPU 延迟循环,从而提高管道效率。
- 超标量 CPU 执行紧密循环时可能会发生管道退化(Pipeline Stall),
示例
1 |
|
适用场景
- 多线程的自旋锁。
- 短时间的忙等待。
- 多核系统下避免不必要的资源争用。
2. cpu_relax
概述
cpu_relax
是 Linux 内核中的一个宏,它在不同的架构下提供了统一的自旋等待优化方式:
- 在 x86/x86_64 平台,它通常等价于
_mm_pause
。 - 在 ARM 平台,它可能对应
yield
或者其他低功耗的等待指令。 - 在未提供特定实现的平台,它可能被定义为空操作(
nop
)。
作用
cpu_relax
的功能与 _mm_pause
类似,其核心目的也是优化忙等待,具体包括:
- 减少功耗和资源争用:
- 提示 CPU 当前是一个低优先级循环,不需要激进执行。
- 平台无关性:
- 提供统一的接口,屏蔽不同平台间的指令差异。
示例
Linux 内核中的自旋锁实现:
1 | while (lock_is_held()) { |
3. _mm_pause
和 cpu_relax
的区别
属性 | _mm_pause | cpu_relax |
---|---|---|
适用范围 | 主要在 x86/x86_64 平台使用 | 跨平台支持,多种 CPU 架构 |
实现方式 | 调用 Intel 的 PAUSE 指令 |
由架构具体实现,可能是 PAUSE 或其他指令 |
库/环境 | 依赖 Intel SSE2 指令集 | Linux 内核通用宏 |
场景 | 用户态和内核态都可以使用 | 主要在内核态使用 |
4. _mm_pause
的性能优势
减少缓存争用
当多个线程同时在等待一个共享资源(例如锁)时,频繁检查变量会导致缓存行冲突。_mm_pause
会降低对变量的反复访问频率,从而缓解这种冲突。
避免管道阻塞
CPU 的流水线机制在执行紧密循环时可能因分支预测错误或其他因素导致停顿。使用 PAUSE
指令会让流水线重新填充,提高执行效率。
优化功耗
CPU 在执行 PAUSE
指令时会切换到一种低功耗模式,降低电量消耗,同时释放资源给其他线程。
5. 示例对比
普通忙等待
1 | while (condition_not_met()) { |
- 高 CPU 占用。
- 浪费电量和资源。
- 导致缓存争用。
加入 _mm_pause
或 cpu_relax
的优化版本
1 | while (condition_not_met()) { |
- 降低对 CPU 资源的占用。
- 缓解缓存争用和总线压力。
- 提高系统多线程的协作效率。
6. 注意事项
- 适用场景:仅适用于短时间的等待。例如资源即将释放的情况下。如果等待时间较长,应使用阻塞机制(如信号量、条件变量)。
- 硬件依赖:
_mm_pause
依赖于支持 SSE2 指令集的 CPU,cpu_relax
则提供更广泛的兼容性。 - 滥用风险:如果滥用忙等待,即使使用
_mm_pause
或cpu_relax
,仍可能导致资源浪费。
7. 总结
_mm_pause
和cpu_relax
是忙等待优化的工具,特别适合多线程环境下的自旋锁或短期等待。- 它们通过提示 CPU 延迟循环,减少功耗、缓存争用和流水线退化,显著提高系统性能。
- 在实际开发中,合理选择等待机制(自旋 vs 阻塞)和优化工具对于系统稳定性和性能至关重要。
Java中的自旋锁
先说结论,Java没有直接提供能用的自旋的锁,但是有几点。
能够通过原子类提供的CAS自己显示简单的自旋锁
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15import java.util.concurrent.atomic.AtomicBoolean;
public class SpinLock {
private final AtomicBoolean lock = new AtomicBoolean(false);
public void lock() {
while (!lock.compareAndSet(false, true)) {
Thread.yield(); // 避免完全占用 CPU
}
}
public void unlock() {
lock.set(false);
}
}实现可重入自旋锁
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
28import java.util.concurrent.atomic.AtomicReference;
public class ReentrantSpinLock {
private final AtomicReference<Thread> owner = new AtomicReference<>();
private int count = 0;
public void lock() {
Thread current = Thread.currentThread();
if (owner.get() == current) {
count++;
return;
}
while (!owner.compareAndSet(null, current)) {
Thread.yield();
}
}
public void unlock() {
Thread current = Thread.currentThread();
if (owner.get() == current) {
if (count > 0) {
count--;
} else {
owner.set(null);
}
}
}
}可重入锁
ReentrantLock
存在自旋现象查看底层的
AQS
源码: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
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108/**
* Main acquire method, invoked by all exported acquire methods.
*
* @param node null unless a reacquiring Condition
* @param arg the acquire argument
* @param shared true if shared mode else exclusive
* @param interruptible if abort and return negative on interrupt
* @param timed if true use timed waits
* @param time if timed, the System.nanoTime value to timeout
* @return positive if acquired, 0 if timed out, negative if interrupted
*/
final int acquire(Node node, int arg, boolean shared,
boolean interruptible, boolean timed, long time) {
Thread current = Thread.currentThread();
byte spins = 0, postSpins = 0; // retries upon unpark of first thread
boolean interrupted = false, first = false;
Node pred = null; // predecessor of node when enqueued
/*
* Repeatedly:
* Check if node now first
* if so, ensure head stable, else ensure valid predecessor
* if node is first or not yet enqueued, try acquiring
* else if queue is not initialized, do so by attaching new header node
* resort to spinwait on OOME trying to create node
* else if node not yet created, create it
* resort to spinwait on OOME trying to create node
* else if not yet enqueued, try once to enqueue
* else if woken from park, retry (up to postSpins times)
* else if WAITING status not set, set and retry
* else park and clear WAITING status, and check cancellation
*/
for (;;) {
if (!first && (pred = (node == null) ? null : node.prev) != null &&
!(first = (head == pred))) {
if (pred.status < 0) {
cleanQueue(); // predecessor cancelled
continue;
} else if (pred.prev == null) {
Thread.onSpinWait(); // ensure serialization
continue;
}
}
if (first || pred == null) {
boolean acquired;
try {
if (shared)
acquired = (tryAcquireShared(arg) >= 0);
else
acquired = tryAcquire(arg);
} catch (Throwable ex) {
cancelAcquire(node, interrupted, false);
throw ex;
}
if (acquired) {
if (first) {
node.prev = null;
head = node;
pred.next = null;
node.waiter = null;
if (shared)
signalNextIfShared(node);
if (interrupted)
current.interrupt();
}
return 1;
}
}
Node t;
if ((t = tail) == null) { // initialize queue
if (tryInitializeHead() == null)
return acquireOnOOME(shared, arg);
} else if (node == null) { // allocate; retry before enqueue
try {
node = (shared) ? new SharedNode() : new ExclusiveNode();
} catch (OutOfMemoryError oome) {
return acquireOnOOME(shared, arg);
}
} else if (pred == null) { // try to enqueue
node.waiter = current;
node.setPrevRelaxed(t); // avoid unnecessary fence
if (!casTail(t, node))
node.setPrevRelaxed(null); // back out
else
t.next = node;
} else if (first && spins != 0) {
--spins; // reduce unfairness on rewaits
Thread.onSpinWait();
} else if (node.status == 0) {
node.status = WAITING; // enable signal and recheck
} else {
long nanos;
spins = postSpins = (byte)((postSpins << 1) | 1);
if (!timed)
LockSupport.park(this);
else if ((nanos = time - System.nanoTime()) > 0L)
LockSupport.parkNanos(this, nanos);
else
break;
node.clearStatus();
if ((interrupted |= Thread.interrupted()) && interruptible)
break;
}
}
return cancelAcquire(node, interrupted, interruptible);
}可见在
AQS
中,在某些一些情况下,会优先进入自旋的状态而不是直接阻塞,同时在一轮的自旋后,还对自旋进行了时间增强(见postSpins
逻辑)。至于AQS
是如何具体进行自旋和阻塞切换的,以及如何具体唤醒线程取获取锁,讲起来又是一堆,下次有空再开一篇来说。