前言
今天我们讨论原子操作、锁操作。我们先来看一个案例:在并发编程时,我们使用十个线程对count进行++操作,每个线程加100000次,理论上这个结果是1000000。
#include <iostream>
#include <thread>
#include <vector>
int count = 0;
void increment() {
for (int i = 0; i < 100000; ++i) {
count++; // 这里没有使用任何同步机制
}
}
int main() {
std::vector<std::thread> threads;
// 创建10个线程,每个线程都执行 increment 函数
for (int i = 0; i < 10; ++i) {
threads.emplace_back(increment);
}
// 等待所有线程完成
for (auto& thread : threads) {
thread.join();
}
// 输出最终的 count 值
std::cout << "Final count: " << count << std::endl;
return 0;
}
但很可惜,经过几次实验,得出来的结果总是没有1000000:
那么究竟是什么原因呢????
从代码逻辑来看,每个线程执行的函数操作都是对count加100000次,那么理论上会得到count的结果为1000000。可实际上,多个线程或进程并行执行时,程序的输出依赖于竞争资源的线程或进程的执行顺序,而这个顺序是不可预测的。假设有两个线程 A 和 B,它们都试图增加一个全局变量 count
的值。如果没有同步机制,可能会出现以下情况:
- 线程 A 读取
count
的值为 10。 - 线程 B 也读取
count
的值为 10。 - 线程 A 将
count
增加到 11 并写回。 - 线程 B 也将
count
增加到 11 并写回。
在这种情况下,count
的最终值是 11,而不是预期的 12。这就是一个典型的数据竞争导致的竞态条件。不妨咱们再深究一点,一个线程对count进行++操作,它的本质其实是读取(Load)、修改(Modify)、写入(Store),这三步:
- mov eax, [count] ; 将 count 的值加载到 eax 寄存器
- inc eax ; 将 eax 寄存器的值增加 1
- mov [count], eax ; 将 eax 寄存器的值写回到 count
由于多个线程执行此操作,过程可能就不是每个线程一次性执行上面三步:
我们可以清楚地看到:线程B在线程A写回之前读取的count值,导致两个线程的操作没有边界性,由于都读的是10,都写回的是11,这样一来原本理论上的结果值12就合理地变为了11。那有何妙计可破此尴尬之境呢?咱们像事务操作一样,把这三步绑紧一点,绑成一步不就可以了吗?接下来我们就来介绍原子操作。
原子操作
automic本意是不可分割的粒子,正合我意。在底层,原子操作如 count++
的实现依赖于处理器提供的原子指令。这些指令确保了对变量的操作(如递增)在多线程环境中是不可中断的,从而保证了操作的原子性。对于 count++
这样的操作,处理器通常会提供专门的原子递增指令。
在c++11以后,有专门的原子操作库,我们需要包含<atomic>,将目标变量设置为原子变量,这样一来我们对这个原子变量的所有操作都会是原子操作。
#include <iostream>
#include <thread>
#include <vector>
#include<atomic>
std::atomic<int> count(0);//将count设置为原子变量,对count++就是原子操作
void increment() {
for (int i = 0; i < 100000; ++i) {
count++; // 这里没有使用任何同步机制
}
}
int main() {
std::vector<std::thread> threads;
// 创建10个线程,每个线程都执行 increment 函数
for (int i = 0; i < 10; ++i) {
threads.emplace_back(increment);
}
// 等待所有线程完成
for (auto& thread : threads) {
thread.join();
}
// 输出最终的 count 值
std::cout << "Final count: " << count << std::endl;
return 0;
}
我们将原来的int型变量count设置为原子变量,其他的地方都不变。得出结果稳稳地达到了理论值:1000000。
这么麻烦吗?有没有别的办法?当然有,我们可以选择不将这三步合体,而是给这三步派个保安,也就是锁操作。
锁操作
锁是多线程编程最靠谱的哥们儿,情绪稳定。一个线程对这个资源进行操作之前要获取这个锁,如果锁被其他线程占用,那么线程就不能访问这个资源,知道其他线程释放锁,才能获取锁,操作资源,合理且公平。常用的锁有互斥锁、自旋锁,此外还有读写锁、乐观锁、悲观锁、分布式锁等等。咱们就先来讨论互斥锁与自旋锁。
我们先来看互斥锁。互斥锁的核心概念是确保任何时候只有一个线程能够持有锁并访问受保护的资源。它是一种独占锁,如果互斥锁已经被其他线程持有,尝试获取锁的线程会让出CPU,内核会将线程置为阻塞状态,直到锁被释放。
很容易看出来,互斥锁的最大特性是阻塞,线程会被挂起。加锁失败时,会从用户态切换到内核态,内核会对线程状态进行切换:运行态->阻塞态,等到加锁成功,又会切换:阻塞态->就绪态。这样看来,当遇到线程频繁申请和释放锁或者执行任务时间短的情况时,线程上下文切换的占比就会比较大。我们就要考虑请另一位保镖了:自旋锁。
所谓自旋锁,就是在遇到获取锁失败的情况时,不会让出CPU,而是“自旋”,在忙等待循环中持续检查锁是否已被释放。在这段时间内,线程会占用CPU资源,一直自旋,利用 CPU 周期,直到锁可用,一旦锁被释放,等待的线程可以立即获取锁,并进入临界区执行代码。自旋锁是通过 CPU 提供的 CAS
函数(Compare And Swap),在用户态完成加锁和解锁操作,不会主动产生线程上下文切换,所以相比互斥锁来说,会快一些,开销也小一些。
#include <iostream>
#include <thread>
#include <vector>
#include <mutex>
int count=0;
std::mutex mutex_;//互斥锁
std::atomic_flag spin_lock = ATOMIC_FLAG_INIT; // 自旋锁
void increment() {
for (int i = 0; i < 100000; ++i) {
//互斥锁
// mutex_.lock();
// count++;
// mutex_.unlock();
//自旋锁
while (spin_lock.test_and_set(std::memory_order_acquire)) {
// 忙等待,直到获取锁
}
++count; // 递增操作
spin_lock.clear(std::memory_order_release); // 释放锁
}
}
int main() {
std::vector<std::thread> threads;
// 创建10个线程,每个线程都执行 increment 函数
for (int i = 0; i < 10; ++i) {
threads.emplace_back(increment);
}
// 等待所有线程完成
for (auto& thread : threads) {
thread.join();
}
// 输出最终的 count 值
std::cout << "Final count: " << count << std::endl;
return 0;
}
使用锁操作得出的结果依然正确:
那么我们什么时候使用互斥锁,什么时候使用自旋锁呢?应该从互斥锁和自旋锁的特性出发:
自旋锁(Spinlock)
- 忙等待:自旋锁在尝试获取锁时,如果锁已经被其他线程占用,它会进入忙等待状态(busy-wait)。这意味着它会不断检查锁是否可用,而不是将线程挂起。
- 低开销:自旋锁通常用于锁持有时间非常短的情况,因为它们避免了线程上下文切换和调度的开销。
- CPU资源:由于自旋锁会导致线程不断检查锁的状态,它可能会在等待锁时消耗大量的CPU资源。
- 实现简单:自旋锁的实现通常比互斥锁简单,因为它不需要涉及操作系统的调度器。
互斥锁(Mutex)
- 阻塞:当一个线程尝试获取一个已经被其他线程持有的互斥锁时,它会将线程置于阻塞状态(block)。线程会被操作系统挂起,直到锁被释放。
- 适合长任务:互斥锁适用于保护长时间操作的共享资源,因为它们允许操作系统在线程等待时进行其他工作。
- 资源利用率:互斥锁可以减少CPU资源的浪费,因为等待锁的线程不会消耗CPU时间。
- 可能的上下文切换:当锁被释放时,操作系统可能需要进行上下文切换,以唤醒等待锁的线程并允许它继续执行。
性能考虑
- 自旋锁:在锁持有时间非常短的情况下,自旋锁可能比互斥锁更高效,因为它们避免了线程的挂起和唤醒过程。然而,如果锁的持有时间较长,自旋锁会导致CPU资源的浪费。
- 互斥锁:在锁持有时间较长或不确定的情况下,互斥锁通常更合适,因为它们允许操作系统更有效地管理线程调度和资源利用。