锁的优缺
锁是用来做并发最简单的方式,当然其代价也是最高的。内核态的锁的时候需要操作系统进行一次上下文切换,加锁、释放锁会导致比较多的上下文切换和调度延时,等待锁的线程会被挂起直至锁释放。在上下文切换的时候,cpu之前缓存的指令和数据都将失效,对性能有很大的损失。操作系统对多线程的锁进行判断就像两姐妹在为一个玩具在争吵,然后操作系统就是能决定他们谁能拿到玩具的父母,这是很慢的。用户态的锁虽然避免了这些问题,但是其实它们只是在没有真实的竞争时才有效。
compare-and-swap (CAS)
是用于多线程以实现同步的原子
指令。它将存储位置的内容与给定值进行比较,并且只有它们相同时,才将该存储位置的内容修改为新的给定值。这是作为单个原子操作完成的。
原子性保证了根据最新信息计算新值。如果与此同时值已由另一个线程更新,则写入将失败。操作的结果必须表明它是否执行了替换。这可以通过一个简单的布尔值来完成响应(此变体通常称为compare-and-set),或者返回从内存位置读取的值(而不是写入该值的值)。
概述
以下是CAS伪代码的原子版本,其中*
表示通过指针进行的访问:
function cas(p : pointer to int, old : int, new : int) returns bool {
if *p ≠ old {
return false
}
*p ← new
return true
}
此操作用于实现 synchronization primitives(同步原语),像信号量 semaphores
和互斥 mutexes
,以及更复杂的无锁和等待释放算法。
Maurice Herlihy(1991)证明,CAS
可以实现比原子读取,写入或获取和添加更多的这些算法,并假设相当大的的内存量,它可以实现所有这些算法。从某种意义上讲 CAS
等效于load-link / store-conditional
,一个不变的原语的调用次数可以用来以无等待的方式实现另一个原语。
围绕CAS
构建的算法通常读取一些密钥存储位置并记住旧值。基于该旧值,他们计算出一些新值。然后,他们尝试使用CAS
交换新值,在比较中检查位置是否仍旧等于旧值。如果CAS
指示尝试失败,则必须从头开始重复:重新读取位置,重新计算新值并再次尝试CAS
。
研究人员发现,多处理器系统可以提高总体系统性能,而不是在CAS
操作失败后立即重试,在多处理器系统中,如果看到CAS
失败的线程使用指数补偿,则许多线程会不断更新某些特定的共享变量,换句话说,请等待重试CAS
之前需要一点时间。
示例应用程序:原子加法器
作为比较和交换的示例用例,以下是一种用于原子递增或递减整数的算法。这在使用计数器的各种应用程序中很有用。函数add
原子地执行操作
∗
p
←
∗
p
+
a
* p←* p + a
∗p←∗p+a(再次用*表示指针间接,如C所示),并返回存储在计数器中的最终值。与上面的cas
伪代码不同,不要求任何操作序列都是原子操作,除了cas
之外。
function add(p : pointer to int, a : int) returns int {
done ← false
while not done {
value ← *p // Even this operation doesn't need to be atomic.
done ← cas(p, value, value + a)
}
return value + a
}
在此算法中,如果*p
的值在它获取之后(or while!)
且在CAS执行存储之前
发生了变化,则CAS
将注意到并报告此事实,从而导致算法重试。
用C实现
许多C编译器支持通过C11 <stdatomic.h>函数 或该特定C编译器的某些非标准C扩展或通过使用用compare-and直接用汇编语言编写的函数来支持比较和交换指令。
以下C函数显示了compare-and-swap变体的基本行为,该变体返回指定存储位置的旧值;但是,此版本不提供真正的比较和交换操作将提供的原子性的关键保证:
int compare_and_swap(int* reg, int oldval, int newval)
{
ATOMIC();
int old_reg_val = *reg;
if (old_reg_val == oldval)
*reg = newval;
END_ATOMIC();
return old_reg_val;
}
old_reg_val
总是返回,但是可以在compare_and_swap
操作之后对其进行测试,以查看它是否匹配oldval
(可能有所不同),这意味着另一个进程已经成功地成功地compare_and_swap
从中更改了reg
值oldval
。
例如,可以实现election protoco(选举协议),以便每个进程都compare_and_swap
针对其自己的PID(= newval)
检查的结果。获胜过程找到compare_and_swap
返回的初始非PID
值(例如,零)。对于失败者,它将返回获胜的PID
。
bool compare_and_swap(int *accum, int *dest, int newval)
{
if (*accum == *dest) {
*dest = newval;
return true;
} else {
*accum = *dest;
return false;
}
}
volatile
volatile关键字用来阻止(伪)编译器认为的无法“被代码本身”改变的代码(变量/对象)进行优化。
如在C语言中,volatile关键字可以用来提醒编译器它后面所定义的变量随时有可能改变,因此编译后的程序每次需要存储或读取这个变量的时候,都会直接从变量地址(内存)中读取数据,而不是使用已经存在寄存器中的值。
如果没有volatile关键字,则编译器可能优化读取和存储,可能暂时使用寄存器中的值,如果这个变量由别的程序更新了的话,将出现不一致的现象。(思考多线程中对同一个变量的修改可能造成的死锁)
//全局变量
BOOL bStop = FALSE; // error
// modity :volatile BOOL bStop = FALSE;
//线程1
while( !bStop ) { ... }
bStop = FALSE;
return;
//线程2
bStop = TRUE;
while( bStop ); //等待上面的线程终止
如果 bStop 不使用 volatile 申明,那么这个循环将是一个死循环,因为 bStop 已经读取到了寄存器中,寄存器中 bStop 的值永远不会变成 FALSE,加上 volatile,程序在执行时,每次均从内存中读出 bStop 的值,就不会死循环了。
一个全局变量,会被多线程同时访问/修改,那么线程内部,就不能假设此变量的不变性,并且基于此假设,来做一些程序设计。当然,这样的假设,本身并没有什么问题,多线程编程,并发访问/修改的全局变量,通常都会建议加上Volatile关键词修饰,来防止C/C++编译器进行不必要的优化。
在C,以及C++中,volatile关键字的作用
- 允许访问内存映射设备
- 允许在setjmp和longjmp之间使用变量
- 允许在信号处理函数中使用sig_atomic_t变量
根据相关的标准(C,C++,POSIX,WIN32)和目前绝大多数实现,对volatile变量的操作并不是原子的,也不能用来为线程建立严格的happens-before关系。volatile关键字就像便携式线程构建一样基本没什么用处。
对用户定义的非基本数据类型使用volatile
基本类型的对象用volatile修饰后,仍旧支持所有的操作(加、乘、赋值等)。但是,用户定义的非基本类型(class、struct、union)的对象被volatile修饰后,具有不同行为:
- 只能调用volatile成员函数;即只能访问它的接口的子集。
- 只能通过const_cast运算符转为没有volatile修饰的普通对象。即由此可以获得对类型接口的完全访问。
- volatile性质会传递给它的数据成员。
volatile与多线程语义
- 临界区内部,通过互斥锁(mutex)保证只有一个线程可以访问,因此临界区内的变量不需要是volatile的;
- 而在临界区外部,被多个线程访问的变量应为volatile,这也符合了volatile的原意:防止编译器缓存(cache)了被多个线程并发用到的变量。
volatile对象
只能调用volatile成员函数
,这意味着应仅对多线程并发安全的成员函数加volatile
修饰,这种volatile成员函数
可自由用于多线程并发或者重入而不必使用临界区;
非volatile的成员函数
意味着单线程环境
,只应在临界区
内调用。在多线程编程中可以令该数据对象的所有成员函数均为普通的非volatile
修饰,从而保证了仅在进入临界区(即获得了互斥锁)后把该对象显式转为普通对象之后才能调用该数据对象的成员函数。这种用法避免了编程者的失误——在临界区以外访问共享对象的内容.