原子操作与无锁数据结构
lock-free
本文对lock-free进行介绍,主要介绍原子性操作,下一篇介绍内存访问控制
什么是lock-free
无锁数据结构
无锁数据结构的实现主要基于两个方面:原子性操作和内存访问控制方法。下面先记录原子性操作相关知识。
原子性操作
原子性操作可以简单地分为读写(read and write)、原子性交换操作(read-modify-write,RMW)两部分。原子操作可认为是一个不可分的操作;要么发生,要么没发生,我们看不到任何执行的中间过程,不存在部分结果(partial effects),就像事务。
在现代处理器中,简单的数据类型的对齐读写操作一般是原子的,而RMW更进一步的,允许进行更复杂的原子事务性操作。
下面以x86架构为例介绍其如何实现原子性操作,其通过三种机制保证原子性:(详情可参考1、2)
- 一些基本的内存读写操作已由硬件保证了其原子性。如单字节读写,奔腾6和最新的处理器能自动保证单处理器对同一个缓存行里进行16/32/64位的操作是原子的。
- 通过对总线加锁来保证。所谓总线锁就是使用处理器提供的一个LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占使用共享内存。
- 通过对缓存加锁来保证。在同一时刻我们只需保证对某个内存地址的操作是原子性即可,但总线锁定把CPU和内存之间通信锁住了,这使得锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁定的开销比较大,最近的处理器在某些场合下使用缓存锁定代替总线锁定来进行优化。
在不同平台RMW操作有不同实现,如:
- Win32
_InterlockedIncrement
_InterlockedCompareExchange
; - iOS
OSAtomicAdd32
; - C++11
std::atomic<>::fetch_add
std::atomic<>::compare_exchange_strong
.
在构建无锁数据结构时需要用到RMW操作,其包括:compare-and-swap (CAS)、fetch-and-add (FAA)、test-and-set (TAS) 等等。其中最基本的是CAS,其他操作可通过CAS实现。
CAS
CAS伪代码如下:
bool CAS( int * pAddr, int nExpected, int nNew )
atomically {
if ( *pAddr == nExpected ) {
*pAddr = nNew ;
return true ;
}
else
return false ;
}
但在实际中往往会想要知道,失败后,pAddr的当前值是多少,故可修改如下。
int CAS( int * pAddr, int nExpected, int nNew )
atomically {
if ( *pAddr == nExpected ) {
*pAddr = nNew ;
return nExpected ;
}
else
return *pAddr
}
c++11中CAS为std::atomic::compare_exchange_weak, std::atomic::compare_exchange_strong
其如同用std::memcmp
原子地比较 *this
的对象表示和 expected
的对象表示,而若它们逐位相等,则以 desired
替换前者(进行读修改写操作)。否则,将 *this
中的实际值加载进 expected
(进行加载操作)。如同用 std::memcpy
进行复制。详情可查看cppreference
ABA问题
ABA问题是所以基于CAS基本类型的无锁容器的一个巨大灾难.CAS算法实现的一个重要前提是需要取出内存中某时刻的数据,而在下一时刻比较并替换,那么就存在了一个时间差,这个时间差会内数据有可能变化。
考虑一个栈数据顺序为 ABC,线程1pop
,执行到取出A值,但还没CAS,此时要替换成的数据为A->next
即B线程2pop
了A、B,然后push
A ,顺序变为AC,然后线程1继续执行CAS,比较时栈顶仍为A比较成功,替换成B(已不在栈中),栈数据出错。
其解决方法有:标签指针(Tagged pointers)、险象指针(Hazard pointer)等。在其他文章介绍。
对于实现了load-linked、store-conditional (LL/SC) 这样的操作对的处理器,其不会发生ABA问题。
其伪代码如下: ``` word LL( word * pAddr ) { return *pAddr ; }
bool SC( word * pAddr, word New ) { if ( data in pAddr has not been changed since the LL call) { *pAddr = New ; return true ; } else return false ; }
> LL/SC对以括号运算符的形式运行,Load-linked(LL) 运算仅仅返回 pAddr 地址的当前变量值。如果 pAddr 中的数据在读取之后没有变化,那么 Store-conditional(SC) )操作会将LL读取 pAddr 地址的数据存储起来。这种变化之下,任何 pAddr 引用的缓存行修改都是明确无误的。为了实现 LL/SC 对,程序员不得不更改缓存结构。简而言之,每个缓存行必须含有额外的比特状态值(status bit)。一旦LL执行读运算,就会关联此比特值。任何的缓存行一旦有写入,此比特值就会被重置;在存储之前,SC操作会检查此比特值是否针对特定的缓存行。如果比特值为1,意味着缓存行没有任何改变,pAddr 地址中的值会变更为新值,SC操作成功。否则本操作就会失败,pAddr 地址中的值不会变更为新值。
> CAS通过LL/SC对得以实现,具体如下:
```C++
bool CAS( word * pAddr, word nExpected, word nNew ) {
if ( LL( pAddr ) == nExpected )
return SC( pAddr, nNew ) ;
return false ;
}
但LL/SC由于伪共享(False sharing)也存在问题,简单来说即是多个共享变量使用同一缓存行,只要有一个变量改变,即认为缓存行数据无效(SC失败),详情可参考4。
NOTE
但C++11的原子标准并不保证其在所以平台的实现都是lock-free的(std::atomic_flag 除外),可通过 std::atomic<>::is_lock_free
确认.
All atomic types except for std::atomic_flag may be implemented using mutexes or other locking operations, rather than using the lock-free atomic CPU instructions. Atomic types are also allowed to be sometimes lock-free, e.g. if only aligned memory accesses are naturally atomic on a given architecture, misaligned objects of the same type have to use locks.