文章对三种常见锁(自旋锁 互斥锁 读写锁)进行介绍以及实现, 并讨论各自的性能问题
自旋锁
自旋锁是计算机科学用于多线程同步的一种锁,线程反复检查锁变量是否可用。由于线程在这一过程中保持执行,因此是一种忙等待。一旦获取了自旋锁,线程会一直保持该锁,直至显式释放自旋锁。
自旋锁避免了进程上下文的调度开销,因此对于线程只会阻塞很短时间的场合是有效的。因此操作系统的实现在很多地方往往用自旋锁。
简单来说就是, 如果我们让一个自旋锁lock
那么在它unlock
前其他线程想获取这个自旋锁时就会一直等待, 线程一直处于running
状态避免了上下文切换的开销, 这种操作有利有弊, 好处就是前边提到的避免上下文切换(操作系统)的开销, 坏处就是, 如果一个线程持有锁的时间很长或者有许多个线程都想持有这个互斥锁, 那么简直就是性能灾难!!!
提到自旋锁很多人都会提到CAS(compare and swap), CAS操作基于CPU提供的原子操作指令实现。CAS功能等价于下边这部分代码
bool cas(int *addr, int old_v, int new_v) {
if(*addr != old_v)
return false;
*addr = new_v;
return true;
}
在C++中有这种功能的函数是atomic_compare_exchange_weak(strong)
, 不过这个函数的作用跟CAS并不完全一样, 所以自己实现自旋锁的话需要加一步, 以下是一个简单的spin_lock的实现:
class spin_lock {
std::atomic_bool locked{false};
public:
void lock() {
bool t{false};
while (!locked.compare_exchange_weak(t, true)) {
t = false;
}
}
void unlock() { locked = false; }
};
compare_exchange_weak比较恶心的点就是第一个参数(这里我使用了atomic对象的内部的函数)必须是左值, 所以还得额外定义一个bool
然后呢如果locked不是false, 它还会把t改为true...所以每次循环还得把t改回来...这样的效率简直太低了
为什么不直接判断locked的值再根据locked的值修改呢? 因为这种操作并不是原子的你可以想一下这句:
while (true) {
if (!locked) {
locked = true;
break;
}
}
在locked=false的条件下, 可能有不止一个线程同时判断出!locked
为true, 他们都进入了if为true的作用域, 并都将locked改为true之后离开lock()函数, 它们都认为自己持有了锁, 实际上并不是这样, 这种实现就导致锁不能很好的保护数据了
互斥锁
互斥锁跟自旋锁一样, 同样一次只能由一个线程持有, 不同的是互斥锁在获取锁失败后线程会被挂起, 而不是一直running
根据互斥锁的特性, 我们可以类比自旋锁, 当lock失败后线程挂起, 在C++中可以通过函数wait()
和notify_one()
来实现(需要C++20)
class mutex {
std::atomic_bool locked{false};
public:
void lock() {
bool t{false};
while (!locked.compare_exchange_weak(t, true)) {
locked.wait(true);
t = false;
}
}
void unlock() {
locked = false;
locked.notify_one();
}
};
我测试了一下20个线程情况下while共执行多少次, 大概就是19次, 200个线程大概是199次上下浮动很小, 也就是基本上每个线程(除了第一个获取锁的线程)的while都基本上只执行一次, 如果是自旋锁, while执行次数就是灾难, 在这里就需要消耗大量的CPU资源...
不过我也不知道为什么还会有浮动, 因为每次unlock都只唤醒一个线程才对, 求解答
读写锁
读写锁通过维护引用计数来将读锁与写锁分开, 读写锁适用于读多写少的场景, 它允许同一时间存在大量的读操作, 只存在一个写操作, 写操作与读操作互斥, 写写互斥
根据其特性, 我们可以这样实现读写锁:
class rwlock {
std::atomic_int total_{};
public:
void read_lock() {
while (true) {
int t{total_};
if (t < 0) {
continue;
}
if (total_.compare_exchange_weak(t, t + 1)) {
return;
}
}
}
void write_lock() {
int t{0};
while (!total_.compare_exchange_weak(t, -1)) {
t = 0;
}
}
void unlock() {
int t{-1};
if (!total_.compare_exchange_weak(t, 0)) {
total_--;
}
}
};
通过前边的铺垫应该可以看懂是怎么实现的了, 引用计数为-1时表示当前是写锁持有, 正数是读锁的引用计数, read_lock
中需要不断读取计数值并比较, 根据不同情况选择更新引用计数 or 继续执行循环, write_lock
就是跟互斥(自旋)锁一样, 等待引用计数归零
原子操作的弊端
通过以上对各个锁的实现可以看出, 它们都使用了原子操作, 原子变量, 我以前以为原子操作是无副作用的, 但是这个观点大错特错
当我们尝试对某一个变量进行原子地修改时修改它的CPU会给其他CPU发送信息, 告诉它们: "嗨, 你现在缓存的这个变量的值被我改了, 你再重新读一下", 就是这样, 在有大量线程的情况下, 对原子变量的写操作将导致性能大大下降
正是因为有这样的麻烦, 所以才发展了无锁编程, 后边我会介绍一些无锁技术
评论 (0)