【多线程】忙等待/自旋(Busy Waiting/Spinning)
【多线程】忙等待/自旋(Busy Waiting/Spinning)
本文来自于我关于多线程系列文章。欢迎阅读、点评与交流
1.【多线程】互斥锁(Mutex)是什么?
2.【多线程】临界区(Critical Section)是什么?
3.【多线程】计算机领域中的各种锁
4.【多线程】信号量(Semaphore)是什么?
5.【多线程】信号量(Semaphore)常见的应用场景
6.【多线程】条件变量(Condition Variable)是什么?
7.【多线程】监视器(Monitor)是什么?
8.【多线程】什么是原子操作(Atomic Operation)?
9.【多线程】竞态条件(race condition)是什么?
10.【多线程】无锁数据结构(Lock-Free Data Structures)是什么?
11.【多线程】线程休眠(Thread Sleep)的底层实现
12.【多线程】多线程的底层实现
13.【多线程】读写锁(Read-Write Lock)是什么?
14.【多线程】死锁(deadlock)
15.【多线程】线程池(Thread Pool)
16.【多线程】忙等待/自旋(Busy Waiting/Spinning)
17.【多线程】屏障(Barrier)
18.【多线程硬件机制】总线锁(Bus Lock)是什么?
19.【多线程硬件机制】缓存锁(Cache Lock)是什么?
1. 什么是忙等待?
忙等待(Busy Waiting),也称为自旋(Spinning),是指一个线程在等待某个条件(如锁被释放、共享资源可用)时,不释放CPU,而是通过不断地执行循环并检查条件是否满足的一种策略。
通俗地说,就像你在等一个朋友,你不停地看表、不停地念叨“怎么还不来”,而不是先找个地方坐下来看看手机。在这个过程中,你(线程)一直处于“忙碌”的状态,消耗着精力(CPU资源)。
2. 忙等待的代码示例
下面是一个简单的C++示例,展示了忙等待的概念:
#include <iostream>
#include <thread>
#include <atomic>
#include <chrono>std::atomic<bool> ready(false); // 一个原子布尔变量,作为条件标志void worker() {// 模拟一些准备工作std::this_thread::sleep_for(std::chrono::seconds(2));std::cout << "Worker: Work done, notifying main thread.\n";ready = true; // 设置条件为真
}int main() {std::cout << "Main: Starting worker thread.\n";std::thread t(worker);// 忙等待:循环检查 ready 是否为 truestd::cout << "Main: Busy waiting...\n";while (!ready) { // 只要 ready 为 false,就空转// 循环体可以是空的,也可以有一些轻量级操作// 但线程不会进入阻塞状态}std::cout << "Main: Condition met, proceeding.\n";t.join();return 0;
}
在上面的代码中,主线程创建了一个工作线程,然后进入一个 while (!ready)
循环。这个循环会一直运行,直到工作线程将 ready
设置为 true
。在此期间,主线程持续占用一个CPU核心。
3. 忙等待的优缺点
优点:
- 低延迟响应:这是忙等待最大的优点。一旦条件满足,等待线程可以立即检测到并继续执行,因为它一直在运行,没有发生线程上下文切换的开销。对于等待时间极短(比如几个时钟周期或几条指令的时间)的场景,这比让操作系统重新调度它要快得多。
- 避免上下文切换:线程不会被操作系统挂起,因此省去了保存和恢复线程上下文(寄存器、栈等)的开销。
缺点:
- 浪费CPU资源:这是忙等待最致命的缺点。等待的线程会持续消耗整个CPU核心的算力来做无意义的循环检查。如果一个CPU核心上只有一个这样的线程,那么该核心的利用率将接近100%,但实际工作进度为零。
- 可能导致优先级反转:在一些有优先级调度机制的系统中,如果低优先级的线程持有一个锁,并在CPU上自旋,而高优先级的线程正在忙等待这个锁,那么低优先级线程可能永远得不到CPU时间来释放锁,从而导致系统死锁。
- 影响系统整体性能:如果系统中有多个忙等待的线程,它们会疯狂争抢CPU时间,导致其他真正需要工作的线程(如处理I/O、计算等)得不到足够的资源,拖慢整个系统。
4. 忙等待的适用场景
正因为有上述严重的缺点,忙等待的使用场景非常有限,必须同时满足以下条件:
- 等待时间极短:预期的等待时间最好小于完成两次线程上下文切换所需的时间。通常这意味着是纳秒或微秒级别。
- 多核处理器:等待线程运行在一个独立的CPU核心上,这样它不会阻塞持有锁的那个线程(持有锁的线程需要在另一个核心上运行来释放锁)。
典型应用:自旋锁
忙等待最常见的应用就是实现自旋锁。自旋锁假设锁被占用的时间非常短,因此它通过忙等待来获取锁。
#include <atomic>class SpinLock {
private:std::atomic_flag flag = ATOMIC_FLAG_INIT;public:void lock() {// 忙等待,直到成功将 flag 从 false 设置为 truewhile (flag.test_and_set(std::memory_order_acquire)) {// 在等待时,可以插入一些提示来优化性能// 如 yield() 或 pause 指令// __builtin_ia32_pause(); // x86 的 PAUSE 指令,减少功耗和缓解超线程竞争}}void unlock() {flag.clear(std::memory_order_release);}
};
代码简析:
std::atomic_flag
: 是 C++ 标准库中最简单的原子类型,保证是无锁的。它只有两个状态:set
(true)和clear
(false)。
ATOMIC_FLAG_INIT
: 用于初始化atomic_flag
为clear
状态(即 false),表示锁最初是未被持有的。
flag.test_and_set(std::memory_order_acquire)
:
- 操作: 这是一个原子操作。它同时完成两件事:
- 读取
flag
的当前值。- 设置
flag
的值为true
(set 状态)。内存序 std::memory_order_acquire:,确保在这个操作之后的所有读写操作都不会被重排到它之前执行。这保证了在成功获取锁之后,一定能看到之前持有锁的线程在临界区中所做的所有修改。
while
循环(忙等待):
- 如果
test_and_set
返回false
:说明在调用时锁是空闲的,并且当前线程已经成功地将其设置为了true
(即获取了锁)。循环条件不成立,线程跳出循环,进入临界区。- 如果
test_and_set
返回true
:说明在调用时锁已经被其他线程持有(flag
已经是true
)。循环条件成立,线程会一直在这里空转,反复调用test_and_set
,直到锁被释放。
flag.clear(std::memory_order_release)
:
操作: 原子地将
flag
设置回false
(clear 状态)。内存序
std::memory_order_release
: 这是与acquire
配对的另一个关键内存序。它建立了 “释放”语义,确保在这个操作之前的所有读写操作都不会被重排到它之后执行。这保证了当前线程在临界区中所做的所有修改,在释放锁之前都已经完成并对下一个获取锁的线程可见。acquire 和 release 共同作用,形成了一个“同步点”,确保了临界区数据的安全传递。
PAUSE
指令:
- 减少功耗: 告诉 CPU 这是一个自旋循环,CPU 可以降低功耗或进入一个更优化的等待状态,而不是全力执行无意义的循环。
- 避免内存顺序冲突: 在超线程技术中,它有助于减少处理器核心的资源争用,让另一个逻辑核心能更有效地工作。
- 提升性能: 在某些架构下,使用
PAUSE
可以显著提高自旋锁在竞争激烈时的性能。在 C++20 中,可以使用
std::this_thread::yield()
或在编译器内置指令中使用_mm_pause()
(MSVC)/__builtin_ia32_pause()
(GCC/Clang)。
现代操作系统和库(如C++11的 std::mutex
(互斥锁库))内部通常会采用自适应锁,即先自旋一小段时间,如果还拿不到锁,再转为阻塞状态,以在低延迟和资源消耗之间取得平衡。
5. 如何避免忙等待?—— 使用阻塞等待(Blocking Wait / Blocking)
在绝大多数情况下,当线程需要等待一个较长时间的条件时,应该使用阻塞等待(Blocking Wait / Blocking)。
阻塞等待是指线程在条件不满足时,主动释放CPU,进入睡眠状态(如 WAITING
或 BLOCKED
状态)。当条件满足时,由操作系统或其它线程将其唤醒。
实现阻塞等待的机制包括:
- 互斥锁与条件变量
- 信号量
- 操作系统提供的等待/通知API(如
futex
on Linux)
使用条件变量的C++示例:
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>// 全局同步变量
std::mutex mtx; // 互斥锁,用于保护共享数据 ready
std::condition_variable cv; // 条件变量,用于线程间通信
bool ready = false; // 条件标志:表示工作是否完成// 工作线程函数
void worker() {// 模拟耗时工作(比如文件读取、网络请求、复杂计算等)std::this_thread::sleep_for(std::chrono::seconds(2));{// 使用 lock_guard 自动管理锁的生命周期// 在作用域内持有锁,保护对 ready 的修改std::lock_guard<std::mutex> lock(mtx);ready = true; // 设置完成标志std::cout << "Worker: Work done, notifying all.\n";} // 作用域结束,lock_guard 析构,自动释放锁cv.notify_all(); // 通知等待的线程。注意:通知操作在锁外执行,这是优化做法,避免不必要的竞争
}int main() {std::cout << "Main: Starting worker thread.\n";std::thread t(worker); // 创建并启动工作线程std::cout << "Main: Going to sleep (blocking wait).\n";{// 使用 unique_lock 而不是 lock_guard,因为 wait() 需要临时释放锁的能力(独占)std::unique_lock<std::mutex> lock(mtx);// 条件等待:等待 ready 变为 true// wait() 的工作流程:// 1. 检查条件(ready == true?)// 2. 如果条件不满足,则:// - 原子地释放锁// - 将线程置于等待状态(阻塞,不消耗CPU)// - 当被 notify_all() 唤醒时,重新获取锁// - 再次检查条件// 3. 如果条件满足,则继续执行// 注意:使用lambda谓词可以防止"虚假唤醒"cv.wait(lock, [] { return ready; // 返回 true 时停止等待,false 时继续等待});} // unique_lock 析构,自动释放锁std::cout << "Main: Condition met, proceeding.\n";t.join(); // 等待工作线程结束return 0;
}
在这个例子中,主线程在调用 cv.wait()
时会被阻塞,不消耗CPU。直到工作线程调用 cv.notify_all()
时,操作系统才会唤醒主线程。
总结
特性 | 忙等待 | 阻塞等待 |
---|---|---|
CPU消耗 | 高,持续占用CPU | 低,等待时不占用CPU |
响应延迟 | 极低,条件满足立即响应 | 较高,需要上下文切换 |
适用场景 | 等待时间极短的多核系统(如自旋锁) | 等待时间不确定或较长 |
对系统影响 | 可能浪费大量资源,影响整体性能 | 更友好,允许CPU执行其他任务 |
最佳实践:除非你非常确定等待时间极短且是性能关键路径,否则应优先选择阻塞等待。现代并发编程中,应多使用高级同步原语(如 std::mutex
, std::condition_variable
, std::semaphore
),而避免手动编写忙等待循环。