本文介绍一种使用自旋方式实现读写锁的方案。方案实现起来比较简单,但因为使用的是自旋锁机制,当获取不到锁时,线程就处于忙等待状态,CPU一直在自旋,并不会使线程进入阻塞状态。因此,它适用于加锁时间不长并且临界区不会阻塞的应用场景,比如没有调用互斥锁、IO操作、动态内存分配等。否则,可以选择使用带有阻塞功能的互斥锁,比如std::mutex、std::shared_mutex等。
基本原理是使用一个原子变量作为计数器,因为读写锁的语义允许多个读者可以同时持有读锁,该计数器可用来表示持有读锁的读者线程的数量,同时它也是一个同步变量,读-写和写-写线程之间要互斥竞争它,因此,它的取值有着不同的锁状态语义:如果该计数器的值大于0,说明有读者正持有读锁,它的数值就是所持有锁的读者数量;当计数器的值为-1时,说明有写者正在持有写锁;如果计数器的值为0,则说明既没有读者持有读锁,也没有写锁持有写锁,这也是它的初始化状态。
读写锁的类定义如下:
class rw_spin_lock {
std::atomic_int counter{0};
public:
rw_spin_lock() = default;
rw_spin_lock(const rw_spin_lock&) = delete;
rw_spin_lock &operator=(const rw_spin_lock&) = delete;
void lock_reader() noexcept;
bool try_lock_reader() noexcept;
void unlock_reader() noexcept;
void lock_writer() noexcept;
bool try_lock_writer() noexcept;
void unlock_writer() noexcept;
};
它包含了一个atomic_int类型的数据成员,它初始化为0,表示没有任何类型的锁被持有,还提供了6个申请/释放锁的成员函数,不支持拷贝和移动语义。
下面看一下各个成员函数的实现。
1、申请读锁
该成员函数用于申请读锁,直到申请成功后返回。
void rw_spin_lock::lock_reader() noexcept
{
while (true) {
// 1、等待写锁被释放
int c = counter.load(std::memory_order_relaxed);
if (c == -1) { // 写锁被持有
pause_cpu();
continue; // 自旋,等待写锁释放
}
// 2、设置读锁
while (c != -1) {// counter值为-1,说明写者抢先申请到了锁
if (counter.compare_exchange_strong(c, c+1, std::memory_order_acquire))
return;
}
}
}
在申请读锁时,分两步,第一步是检查是否有写者持有锁,也就是检查counter的值是否为-1,如果是-1,说明正在被写者持有,根据读写锁的语义,此时写者应该独占锁,读者无法获取锁,只能处于自旋等待中,等待写锁的释放。当counter不为-1时,说明写锁没有被持有或者已经被释放了,此时要么还没有读者申请读锁,即counter=0,要么是已经有别的读者申请到读锁了,即counter>0。
第二步是使用cas算法尝试让counter加1,记录有一个读者申请到了锁,如果cas运行失败,即counter所包含的值和期望值c不一样,说明在此期间锁的状态发生了变化,需要判断失败的原因:如果是因为有别的读者在此期间释放或者抢先申请到锁了,就重新尝试,因为compare_exchange_strong会更新c的值为counter的最新值,可以一直使用循环进行cas提交,直到成功;如果是因为是写者抢占获得了写锁,此时counter为-1,就回到第一步重新开始等待写锁释放,直到成功。
第一步确定没有写锁被占有,和第二步设置读锁,这两步不是原子操作,有可能在第二步之前,写者抢先在读者在更新counter前获得了写锁,所以不能简单地使用原子类型的函数fetch_add()加1,而是使用compare_exchange_strong()通过cas算法来加1,如果使用fetch_add(),有可能会让counter从-1变成0,把写锁释放了,发生错误。
2、尝试申请读锁
bool rw_spin_lock::try_lock_reader() noexcept
{
int c = counter.load(std::memory_order_relaxed);
if (c == -1) return false; // 写锁被持有
while (c != -1) {// 如果counter值为-1,说明写者抢先申请到了锁
if (counter.compare_exchange_strong(c, c+1, std::memory_order_acquire))
return true;
}
return false;
}
该成员函数的实现逻辑简单一些,如能够申请到锁,就直接返回true,否则返回false。在使用cas修改读者counter的时候,如果失败了,要检查原因:如果是别的读者线程申请或者释放读锁造成的,就继续重试,直到成功返回true;如果是因为被写者抢先申请到了锁,此时c=-1,就直接返回false,因为锁要被写者独占。
3、释放读锁
void rw_spin_lock::unlock_reader() noexcept
{
counter.fetch_sub(1, std::memory_order_release);
}
释放读锁非常简单,就是让counter减1,当最后一个持有锁的读者线程成功释放后,counter等于0,说明没有任何读者持有读锁了。
4、申请写锁
void rw_spin_lock::lock_writer() noexcept
{
while (true) {
// 1、等待所有锁被释放
while (counter.load(std::memory_order_relaxed) != 0) { // 可能别的线程已经成功获取了读锁或者写锁,等待释放锁
pause_cpu();
}
// 2、设置写锁
int c = 0; // 期望已经释放锁了
if (counter.compare_exchange_strong(c, -1, std::memory_order_acquire)) { // 检查是否被别的线程抢先获得锁了
break;
}
}
}
根据读写的语义要求,写锁要在既没有读者也没有写者持有锁时,才有可能申请成功,即只有当counter=0时,才有可能成功。因此,首先检查counter是否为0,当不为0时,说明肯定有写者(counter=-1)或者读者(counter>0)正在持有锁,就继续自旋等待,直到所有的读锁或者写锁被释放后(即当counter=0时)。然后再竞争写锁,使用cas算法让counter从0设置为-1,如果cas调用成功,则说明成功获取写锁。调用cas也有可能失败,说明counter已经不等于0了,有两种可能性:如果counter=-1,说明写锁已经被别的写者线程抢先申请了,则重新等待写锁释放;如果counter>0,说明被读者线程抢先申请了读锁了,则重新等待所有读锁释放。由此可见,读者线程在申请锁时可以抢占写者线程申请锁,读者的优先级比写者的优先级要高。
5、尝试申请写锁
首先检查counter是否为0,如果不为0,就直接返回false。当counter为0后,再使用cas算法尝试把counter从0更新为-1,如果能够成功更新,则说明成功申请到锁,cas返回true;如果没有成功更新,则说明在此期间有写者或者读者抢先申请到锁了,cas返回false。
bool rw_spin_lock::try_lock_writer() noexcept
{
if (counter.load(std::memory_order_acquire) != 0) {// 别的线程已经持有读锁或者写锁
return false;
} else {
int c = 0; // 期望所有读者和写者可能都释放锁了
return counter.compare_exchange_strong(c, -1, std::memory_order_acquire); // 如果没有别的线程抢先获得锁,返回成功
}
}
6、释放写锁
释放锁非常简单,让counter等于0即可。
void rw_spin_lock::unlock_writer() noexcept
{
counter.exchange(0, std::memory_order_release);
}
其它事项:
1、宏函数pause_cpu()
在自旋等待时,CPU高速运转,耗能极大,如果处理器提供了优化指令,可以调用它来降低CPU资源消耗(参见自旋锁的实现及优化),指令被封装成pause_cpu()宏函数。在x86体系中,使用的是汇编指令“pause”,如果不支持相关指令,可以让宏函数为空。
示例:
#ifdef X86
#define pause_cpu() asm("pause")
#else
#define pause_cpu()
#endif
2、申请锁要实现acquire语义,释放锁要实现release语义,目的是为了保证读写线程之间访问共享变量的可见性。因此在相关的函数中,指定了std::memory_order_acquire和std::memory_order_release内存序。
从获取写锁的实现可以看出,读者申请锁时优先级比写者要高一些。当读锁被持有时,写者在申请锁时只能等待,但是,当后面又有新的读者申请锁时,无视已经有写者正在申请锁,却可以继续成功的占有锁。而一直自旋等待的写者在所有读者释放锁之前是无法获得锁的,哪怕是写者在申请锁的时刻那些持有锁的读者早已把锁都释放了。因此,当大量的读者在持续不断地申请锁时,可能会造成写者饿死。下一篇文章介绍一种写者不会被饿死的读写自旋锁实现方案。