前言
如何正确有效的保护共享数据是编写并行程序必须面临的一个难题,通常的手段就是同步。同步可分为阻塞型同步(Blocking Synchronization)和非阻塞型同步( Non-blocking Synchronization)。
阻塞型同步是指当一个线程到达临界区时,因另外一个线程已经持有访问该共享数据的锁,从而不能获取锁资源而阻塞,直到另外一个线程释放锁。常见的同步原语有 mutex、semaphore 等。如果同步方案采用不当,就会造成死锁(deadlock),活锁(livelock)和优先级反转(priority inversion),以及效率低下等现象。而且阻塞型同步,当获取临界资源失败时,当前线程会被交出执行权,存在上下文切换的开销。
而非阻塞型同步,主要指类似SpinLock的一种方式,当前线程无法获取锁的时候,并不会被阻塞,而是原地等待,直到获取锁,在这期间并不会发生线程的上下文切换。为了进一步降低风险程度和提高程序运行效率,业界又提出了lock-free的同步方案,其本质特征就是一个线程的执行不会阻碍系统中其他并行执行实体的运行。
本文串行介绍了mutex, semaphone, spinlock, lock-free这几种机制的特性和使用场景,我们可以根据具体需求选择合适的方式。
1 互斥锁(mutex)
1.1 互斥锁介绍
互斥锁是通过保证多个线程对临界区的串行访问来达到同步的效果,针对多个线程都要修改或者访问共享内存区的场景,我们可以把针对这块共享内存访问和修改代码封装在一个代码段中,并使用互斥锁来进行保护,从而确保共享内存的一致性。
互斥锁其实包含了很多种类型:例如普通的互斥锁,递归锁,带有定时功能的互斥锁等等,而我们最常用到就是普通的互斥锁,普通互斥锁的接口如下所示:
int pthread_mutex_init(pthread_mutex_t * mutex,const pthread_mutexattr_t *attr);
int pthread_mutex_lock(pthread_mutex_t *mutex)
int pthread_mutex_unlock(pthread_mutex_t *mutex)
int pthread_mutex_trylock(pthread_mutex_t *mutex)
int pthread_mutex_destroy(pthread_mutex_t *mutex);
pthread_mutex_trylock()语义与pthread_mutex_lock()类似,不同的是在锁已经被占据时返回EBUSY而不是挂起等待,所以原则上Mutex也是可以用于非阻塞性的方式,只是比较少使用这种方式而已。 通常情况下我们使用的互斥锁是采用阻塞性同步方式,在无法获取到锁的情况下,当前线程或者进程就会被阻塞,会发生上下文切换,而使用pthread_mutex_trylock则不会。
这里需要注意的是,互斥锁在不同使用场景可以设置不同的属性,从而可以达到更好的效果,如下所示:
属性 | 定义 |
---|---|
PTHREAD_PROCESS_SHARED | 用于同步该进程和其他进程中的线程 |
PTHREAD_PROCESS_PRIVATE | 用于仅同步该进程中的线程 |
C++ 11中针对互斥锁在STD增加很多新的特性,例如std::lock_guard,std::unique_lock,使用的时候将会更加的方便,大家有兴趣可以了解学习。
1.2 应用介绍
通常情况下,互斥锁的使用方式如下所示:
/* thread 1 */
pthread_mutex_lock(&lock);
// critical area code;
pthread_mutex_unlock(&lock);
/* thread 2 */
pthread_mutex_lock(&lock);
// critical area code;
pthread_mutex_unlock(&lock);
2 信号量(semaphone)
2.1 概念
Semaphore是负责协调各个线程, 以保证它们能够正确、合理的使用公共资源,也是操作系统中用于控制进程同步互斥的量。
最简单的信号量是一个只有0与1两个值的变量,二值信号量,而具有多个正数值的信号量被称之为通用信号量。而针对信号量的P、V操作的定义,与我们大学操作系统课本上的P,V操作定义是一样的,假定我们有一个信号量变量sv,P操作和V操作定义如下:
- P(sv) 如果sv大于0,减小sv。如果sv为0,挂起这个进程的执行。
- V(sv) 如果有进程被挂起等待sv,使其恢复执行。如果没有进行被挂起等待sv,增加sv。
在linux上关于信号量的接口如下所示:
#include
int semctl(int sem_id, int sem_num, int command, ...);
int semget(key_t key, int num_sems, int sem_flags);
int semop(int sem_id, struct sembuf *sem_ops, size_t num_sem_ops);
大家可能觉得信号量的接口与我们的PV操作不一致,其中semget主要是用于创建信号量,而semop是对信号量的操作,可以通过对这个接口简单封装来实现更贴合我们语义的接口,如下所示:
static int semaphore_p(void)
{
struct sembuf sem_b;
sem_b.sem_num = 0;
sem_b.sem_op = -1;
sem_b.sem_flag = SEM_UNDO;
if(semop(sem_id, &sem_b, 1) == -1)
{
return 0;
}
return 1;
}
static int semaphore_v(void)
{
struct sembuf sem_b;
sem_b.sem_num = 0;
sem_b.sem_op = 1;
sem_b.sem_flag = SEM_UNDO;
if(semop(sem_id, &sem_b, 1) == -1)
{
return 0;
}
return 1;
}
2.2 semaphone VS mutex
大家和容易混要这两个概念,这里简单对比一下信号量与互斥量的三点差异:
- 互斥量主要用于线程的互斥,信号线主要用于线程的同步
这是互斥量和信号量的根本区别,也就是互斥和同步之间的区别
互斥:是指某一资源同时只允许一个访问者对其进行访问,具有唯一性和排它性。但互斥无法限制访问者对资源的访问顺序,即访问是无序的
同步:是指在互斥的基础上(大多数情况),通过其它机制实现访问者对资源的有序访问。在大多数情况下,同步已经实现了互斥,特别是所有写入资源的情况必定是互斥的。少数情况是指可以允许多个访问者同时访问资源 - 互斥量值只能为0/1,信号量值可以为非负整数
也就是说,一个互斥量只能用于一个资源的互斥访问,它不能实现多个资源的多线程互斥问题。信号量可以实现多个同类资源的多线程互斥和同步。当信号量为单值信号量是,也可以完成一个资源的互斥访问 - 互斥量的加锁和解锁必须由同一线程分别对应使用,信号量一般情况下由一个线程释放,另一个线程得到。
2.3 应用介绍
信号量的一个最典型的应用场景就是生产者消费者模型,伪代码如下所示:
void *productor(void *arg) //生产者线程
{
while(1)
{
semaphore_p(&p_sem);//生产信号量减1
write_buffer();// 生产消息
semaphore_v(&c_sem); //消费信号量加1
}
}
void *consumer(void *arg) //消费者线程
{
while(1)
{
semaphore_p(&c_sem); //消费者信号量减1
read_buffer();//接收消息
semaphore_v(&p_sem);//生产者信号量加1
}
}
其中p_sem设置为buffer可以容纳的消息个数,c_sem设置为0,从而可以实现当buffer被写满的时候,生产者线程被阻塞,而当Buffer为空的时候,消费者线程被阻塞。
3 spinlock
3.1 spinlock 概念
spinlock又称自旋锁,线程通过busy-wait-loop的方式来获取锁,任何时刻时刻只有一个线程能够获得锁,其他线程忙等待直到获得锁,下面简单介绍一下spinlock 的特点:
spin lock是一种死等的锁机制。当发生访问资源冲突的时候,可以有两个选择:一个是死等;一个是挂起当前进程,调度其他进程执行。spin lock是一种死等的机制,当前的执行thread会不断的重新尝试直到获取锁进入临界区。
只允许一个thread进入。semaphore可以允许多个thread进入,spin lock不行,一次只能有一个thread获取锁并进入临界区,其他的thread都是在门口不断的尝试。
执行时间短。由于spin lock死等这种特性,因此它使用在那些代码不是非常复杂的临界区(当然也不能太简单,否则使用原子操作或者其他适用简单场景的同步机制就OK了),如果临界区执行时间太长,那么不断在临界区门口“死等”的那些thread是多么的浪费CPU啊,这种情况最好使用Mutex。
可以在中断上下文执行。由于不睡眠,因此spin lock可以在中断上下文中适用。
由于spinlock的实时性比较好,也可以在中断相应程序中执行,所以spin_lock有很多种对等接口用来支持各种的需求,下面简单列举了常用到的spin_lock接口。
含义 | spinlock中接口函数 |
---|---|
动态初始化spin lock | spin_lock_init |
获取指定的spin lock | spin_lock |
获取指定的spin lock同时disable本CPU中断 | spin_lock_irq |
保存本CPU当前的irq状态,disable本CPU中断并获取指定的spin lock | spin_lock_irqsave |
获取指定的spin lock同时disable本CPU的bottom half | spin_lock_bh |
释放指定的spin lock | spin_unlock |
释放指定的spin lock同时enable本CPU中断 | spin_unlock_irq |
获取指定的spin lock同时enable本CPU的bottom half | spin_unlock_bh |
尝试去获取spin lock,如果失败,不会spin,而是返回非零值 | spin_trylock |
判断spin lock是否是locked,如果其他的thread已经获取了该lock,那么返回非零值,否则返回0 | spin_is_locked |
3.1 spinlock VS mutex
差异点:
spinlock不会使线程状态发生切换,mutex在获取不到锁的时候会选择sleep,会发生上下文切换。mutex获取锁分为两阶段,第一阶段在用户态采用锁总线的方式获取一次锁,如果成功立即返回;否则进入第二阶段,调用系统的futex锁去sleep,当锁可用后被唤醒,继续竞争锁。
spinlock 没有昂贵的系统调用,一直处于用户态,执行速度快,但是要一直占用cpu,而且在执行过程中还会锁bus总线,锁总线时其他处理器不能使用总线(目前是否在新的处理架构中有一些优化,例如软件锁,可以实现加锁的过程中不需要锁Bus总线);而mutex不会忙等,得不到锁会sleep, 在进入sleep时会陷入到内核态,需要昂贵的系统调用。
3.3 应用介绍
spin_lock的使用方式与Mutex是比较类似的,只是中间临界区的代码比较少,处理速度比较快。在通信产品代码中,在实时性的要求比较高的很多场景都可以用到,例如共享内存的分配,IPI中断信号量的处理等等。
4. lock-free
4.1 概念澄清
lock free (中文一般叫“无锁”,一般指的都是基于CAS指令的无锁技术) 是利用处理器的一些特殊的原子指令来避免传统并行设计中对锁(lock)的使用。锁在解决并行过程中资源访问问题的同时可能会引入诸多新的问题,比如死锁(dead lock),另外锁的申请/释放对性能也有不小的影响,当然最大的问题还在于使用锁的代码模块通常难以进行组合,lock free的目标就是要消除锁对编程带来的不利影响。
这里简单介绍一下Lock-Free 算法,通常由下面的compare_and_swap来实现,伪码如下面所示:
Bool CAS(T* addr, T expected, T newValue)
{
if( *addr == expected )
{
*addr = newValue;
return true;
}
else
return false;
}
上面代码仅是描述了语义,本身这个接口是需要硬件支持的,从而保证这个接口可以在SMP架构下能严格满足语义。在实际开发过程中,利用 CAS 进行同步时通常会采用下面的方式:
do{
备份旧数据;
基于旧数据构造新数据;
}while(!CAS( 内存地址,备份的旧数据,新数据 ))
就是指当两者进行比较时,如果相等证明共享数据没有被其他并行实体修改,则替换成新值成功,然后继续往下运行;如果不相等说明共享数据已经被修改,则放弃已经所做的操作,然后重新执行刚才的操作。可以看出使用这种方式,当同步冲突出现的机会很少时,这种方式能带来较大的性能提升。
这里简单列举了常用的无锁编程的一些接口,可以很容易根据接口的名字来了解接口的功能,如下所示:
type __sync_fetch_and_add (type *ptr, type value);
type __sync_fetch_and_sub (type *ptr, type value);
type __sync_fetch_and_or (type *ptr, type value);
type __sync_fetch_and_and (type *ptr, type value);
type __sync_fetch_and_xor (type *ptr, type value);
type __sync_fetch_and_nand (type *ptr, type value);
type __sync_add_and_fetch (type *ptr, type value);
type __sync_sub_and_fetch (type *ptr, type value);
type __sync_or_and_fetch (type *ptr, type value);
type __sync_and_and_fetch (type *ptr, type value);
type __sync_xor_and_fetch (type *ptr, type value);
type __sync_nand_and_fetch (type *ptr, type value);
bool __sync_bool_compare_and_swap (type *ptr, type oldval type newval, ...);
type __sync_val_compare_and_swap (type *ptr, type oldval type newval, ...);
type __sync_lock_test_and_set (type *ptr, type value, ...);
4.3 应用介绍
目前关于无锁队列的开发的资料已经比较多,网上应该还能找到无锁MAP,无锁链表,无锁栈的算法或者源码,大家有兴趣可以看看。在之前学习无锁编程的过程中,简单写过一个基于无锁的单向队列,从而支持多个线程的同时读写操作,部分源码如下:
bool UnlockQueue::push(void* unit)
{
U32 curIndex;
do
{
curIndex = writeIndex;
if((curIndex +1) % maxUnitNum == readIndex) return false;
} while (!__sync_bool_compare_and_swap(&writeIndex, curIndex, NEXT_INDEX(curIndex));
memcpy(getUnitByIndex(writeIndex), unit, unitSize);
do
{
}while(!__sync_bool_compare_and_swap(&writeDoneIndex, curIndex, NEXT_INDEX(curIndex));
return true;
}
bool UnlockQueue::pop(void* unit)
{
U32 curIndex;
do
{
curIndex = readIndex;
if(writeDoneIndex == curIndex) return false;
} while (!__sync_bool_compare_and_swap(&readIndex, curIndex, NEXT_INDEX(curIndex));
memcpy(unit, getUnitByIndex(curIndex), unitSize);
return true;
}
针对这些单向队列的操作,绝大部分情况下,都是一次插入队列成功,偶尔碰到几个线程同时插入的操作,也能保证每次都有一个线程插入成功,导致并发插入的线程数目很快下降,性能应该是比较高的。
4.3 硬件支持
针对无锁编程是需要硬件支持的,主要有下面两种方式:
- CPU提供Load-Link/Store-Conditional(LL/SC)这对指令,从而实现变量的CPU级别无锁同步,PowerPC、MIPS 和 ARM是采用这种实现方式。
- LL [addr],dst:从内存[addr]处读取值到dst。
- SC value,[addr]:对于当前线程,自从上次的LL动作后内存值没有改变,就更新成新值。
- 还有一种类借助compare_and_swap,一个X86下CAS的汇编支持如下:
__asm {
mov edx, dest
mov ecx, exchange_value
mov eax, compare_value
LOCK_IF_MP(mp)//判断是否是多核,是则添加LOCK指令维护顺序一致性
cmpxchg dword ptr [edx], ecx
}
结束语
本文简单介绍了Mutex, semaphone, spin_lock, lock-free等四种方式。首先Mutex 与 semaphone 使用场景不一样,Mutex主要解决并行实体之间的互斥的问题,而semaphone主要解决并行实体之间的同步问题。针对一些临界区代码比较少,处理开销比较小,而且实时性要求比较高的场景可以使用spin_lock来替代mutex实现互斥, 而如果需要同步的数据只有一个字段的情况下,可以使用lock-free的方式来替代spin_lock从而达到更高的性能。