如何理解互斥锁、条件锁、读写锁以及自旋锁?

如何理解互斥锁、条件锁、读写锁以及自旋锁?他们之间的区别和应用场景究竟是什么?
关注者
2,342
被浏览
612,213

62 个回答

自旋锁(spinlock)很好理解。对自旋锁加锁的操作,你可以认为是类似这样的:

while (抢锁(lock) == 没抢到) {
}

只要没有锁上,就不断重试。显然,如果别的线程长期持有该锁,那么你这个线程就一直在 while while while 地检查是否能够加锁,浪费 CPU 做无用功。

仔细想想,其实没有必要一直去尝试加锁,因为只要锁的持有状态没有改变,加锁操作就肯定是失败的。所以,抢锁失败后只要锁的持有状态一直没有改变,那就让出 CPU 给别的线程先执行好了。这就是互斥器(mutex)也就是题目里的互斥锁(不过个人觉得既然英语里本来就不带 lock,就不要称作锁了吧)。对互斥器加锁的操作你可以认为是类似这样的:

while (抢锁(lock) == 没抢到) {
    本线程先去睡了请在这把锁的状态发生改变时再唤醒(lock);
}

操作系统负责线程调度,为了实现「锁的状态发生改变时再唤醒」就需要把锁也交给操作系统管理。所以互斥器的加锁操作通常都需要涉及到上下文切换,操作花销也就会比自旋锁要大。

以上两者的作用是加锁互斥,保证能够排它地访问被锁保护的资源。

不过并不是所有场景下我们都希望能够独占某个资源,很快你可能就会不得不写出这样的代码:

// 这是「生产者消费者问题」中的消费者的部分逻辑
// 等待队列非空,再从队列中取走元素进行处理

加锁(lock);  // lock 保护对 queue 的操作
while (queue.isEmpty()) {  // 队列为空时等待
    解锁(lock);
    // 这里让出锁,让生产者有机会往 queue 里安放数据
    加锁(lock);
}
data = queue.pop();  // 至此肯定非空,所以能对资源进行操作
解锁(lock);
消费(data);  // 在临界区外做其它处理

你看那个 while,这不就是自己又搞了一个自旋锁么?区别在于这次你不是在 while 一个抽象资源是否可用,而是在 while 某个被锁保护的具体的条件是否达成。

有了前面自旋锁、互斥器的经验就不难想到:「只要条件没有发生改变,while 里就没有必要再去加锁、判断、条件不成立、解锁,完全可以让出 CPU 给别的线程」。不过由于「条件是否达成」属于业务逻辑,操作系统没法管理,需要让能够作出这一改变的代码来手动「通知」,比如上面的例子里就需要在生产者往 queue 里 push 后「通知」!queue.isEmpty() 成立。

也就是说,我们希望把上面例子中的 while 循环变成这样:

while (queue.isEmpty()) {
    解锁后等待通知唤醒再加锁(用来收发通知的东西, lock);
}

生产者只需在往 queue 中 push 数据后这样,就可以完成协作:

触发通知(用来收发通知的东西);
// 一般有两种方式:
//   通知所有在等待的(notifyAll / broadcast)
//   通知一个在等待的(notifyOne / signal)

这就是条件变量(condition variable),也就是问题里的条件锁。它解决的问题不是「互斥」,而是「等待」。

至于读写锁(readers-writer lock),看英文可以顾名思义,在执行加锁操作时需要额外表明读写意图,复数读者之间并不互斥,而写者则要求与任何人互斥。读写锁不需要特殊支持就可以直接用之前提到的几个东西实现,比如可以直接用两个 spinlock 或者两个 mutex 实现:

void 以读者身份加锁(rwlock) {
    加锁(rwlock.保护当前读者数量的锁);
    rwlock.当前读者数量 += 1;
    if (rwlock.当前读者数量 == 1) {
        加锁(rwlock.保护写操作的锁);
    }
    解锁(rwlock.保护当前读者数量的锁);
}

void 以读者身份解锁(rwlock) {
    加锁(rwlock.保护当前读者数量的锁);
    rwlock.当前读者数量 -= 1;
    if (rwlock.当前读者数量 == 0) {
        解锁(rwlock.保护写操作的锁);
    }
    解锁(rwlock.保护当前读者数量的锁);
}

void 以写者身份加锁(rwlock) {
    加锁(rwlock.保护写操作的锁);
}

void 以写者身份解锁(rwlock) {
    解锁(rwlock.保护写操作的锁);
}

如果整个场景中只有一个读者、一个写者,那么其实可以等价于直接使用互斥器。不过由于读写锁需要额外记录读者数量,花销要大一点。

你可以认为读写锁是针对某种特定情景的「优化」。但个人还是建议忘掉读写锁,直接用互斥器。

这个问题不错,锁是一个常见的同步概念,我们都听说过加锁(lock)或者解锁(unlock),当然学术一点地说法是获取(acquire)和释放(release)。

恰好pthread包含这几种锁的API,而C++11只包含其中的部分。接下来我将通过pthread的API来展开回答。

mutex(互斥量)

mutex(mutual exclusive)即互斥量(互斥体)。也便是常说的互斥锁。
尽管名称不含lock,但是称之为锁,也是没有太大问题的。mutex无疑是最常见的多线程同步方式。其思想简单粗暴,多线程共享一个互斥量,然后线程之间去竞争。得到锁的线程可以进入临界区执行代码。

// 声明一个互斥量    
pthread_mutex_t mtx;
// 初始化 
pthread_mutex_init(&mtx, NULL);
// 加锁  
pthread_mutex_lock(&mtx);
// 解锁 
pthread_mutex_unlock(&mtx);
// 销毁
pthread_mutex_destroy(&mtx); 


mutex是睡眠等待(sleep waiting)类型的锁,当线程抢互斥锁失败的时候,线程会陷入休眠。优点就是节省CPU资源,缺点就是休眠唤醒会消耗一点时间。另外自从Linux 2.6版以后,mutex完全用futex的API实现了,内部系统调用的开销大大减小。

值得一提的是,pthread的锁一般都有一个trylock的函数,比如对于互斥量:

ret = pthread_mutex_trylock(&mtx);
if (0 == ret) { // 加锁成功
    ... 
    pthread_mutex_unlock(&mtx);
} else if(EBUSY == ret){ // 锁正在被使用;
    ... 
}

pthread_mutex_trylock用于以非阻塞的模式来请求互斥量。就好比各种IO函数都有一个noblock的模式一样,对于加锁这件事也有类似的非阻塞模式。

当线程尝试加锁时,如果锁已经被其他线程锁定,该线程就会阻塞住,直到能成功acquire。但有时候我们不希望这样。pthread_mutex_trylock在被其他线程锁定时,会返回特殊错误码。加锁成返回0,仅当成功但时候,我们才能解锁在后面进行解锁操作!

C++11开始引入了多线程库<thread>,其中也包含了互斥锁的API:std::mutex

此外,依据同一线程是否能多次加锁,把互斥量又分为如下两类:

  • 是:称为『递归互斥量』recursive mutex ,也称『可重入锁』reentrant lock
  • 否:即『非递归互斥量』non-recursive mute),也称『不可重入锁』non-reentrant mutex

若同一线程对非递归的互斥量多次加锁,可能会造成死锁。递归互斥量则无此风险。C++11中有递归互斥量的API:std::recursive_mutex。对于pthread则可以通过给mutex添加PTHREAD_MUTEX_RECURSIVE 属性的方式来使用递归互斥量:

// 声明一个互斥量
pthread_mutex_t mtx;
// 声明一个互斥量的属性变量
pthread_mutexattr_t mtx_attr;

// 初始化互斥量的属性变量
pthread_mutexattr_init(&mtx_attr);
// 设置递归互斥量的属性
pthread_mutexattr_settype(&mtx_attr, PTHREAD_MUTEX_RECURSIVE);

// 把属性赋值给互斥量
pthread_mutext_init(&mtx, &mutext_attr);

然而对于递归互斥量或者说可重入锁的使用则需要克制。Stevens大神生前在《APUE》中说『使用好它是十分tricky的,仅当没有其他解决方案时才使用』[1]

可重入锁这个概念和称呼的走俏多半是Java语言的功劳。

condition variable(条件变量)

题主所谓的条件锁,我猜指的应该是条件变量。请注意条件变量不是锁,它是一种线程间的通讯机制,并且几乎总是和互斥量一起使用的。所以互斥量和条件变量二者一般是成套出现的。比如C++11中也有条件变量的API: std::condition_variable

对于pthread:

// 声明一个互斥量     
pthread_mutex_t mtx;
// 声明一个条件变量
pthread_cond_t cond;
...

// 初始化 
pthread_mutex_init(&mtx, NULL);
pthread_cond_init(&cond, NULL);

// 加锁  
pthread_mutex_lock(&mtx);
// 加锁成功,等待条件变量触发
pthread_cond_wait(&cond, &mtx);

...
// 加锁  
pthread_mutex_lock(&mtx);
pthread_cond_signal(&cond);
...

// 解锁 
pthread_mutex_unlock(&mtx);
// 销毁
pthread_mutex_destroy(&mtx);

pthread_cond_wait函数会把条件变量和互斥量都传入。并且多线程调用的时候条件变量和互斥量一定要一一对应,不能一个条件变量在不同线程中wait的时候传入不同的互斥量。否则是未定义结果。

关于是先解锁互斥量还是先进行条件变量的通知,是另外一个比较大的议题。有种论断说:先解锁互斥量再通知条件变量可以减少多余的上下文切换,进而提高效率。这种说法是基于一种实现假设:先通知条件变量,再解锁。可能让其他等待条件变量的线程被唤醒了,但是此时互斥量还没解锁,从而再次陷入休眠。然而对于另外一些实现,比如Linux系统,则通过等待变形(wait morphing[2]解决了这一问题。所以先通知再解锁也没用问题。


另外在使用条件变量的过程中有个稍微违反直觉的写法:那就是使用while而不是if来做判断状态是否满足。这样做的原因有二:

  1. 避免惊群;
  2. 避免某些情况下线程被虚假唤醒(即没有pthread_cond_signal就解除了阻塞)。

比如半同步/半reactor的网络模型中,在工作线程消费fd队列的时候:

while (1) {
    if (pthread_mutex_lock(&mtx) != 0) { // 加锁
        ... // 异常逻辑
    }
    while (queue.empty()) {
        if (pthread_cond_wait(&cond, &mtx) != 0) {
            ... // 异常逻辑
        }
    }
    auto data = queue.pop();
    if (pthread_mutex_unlock(&mtx) != 0) { // 解锁
        ... // 异常逻辑
    }
    process(data); // 处理流程,业务逻辑
}

以上伪码摘自我的文章:

read-write lock(读写锁)

顾名思义『读写锁』就是对于临界区区分读和写。在读多写少的场景下,不加区分的使用互斥量显然是有点浪费的。此时便该上演读写锁的拿手好戏。

读写锁有一个别称叫『共享-独占锁』。不过单看『共享-独占锁』或者『读写锁』这两个名称,其实并未区分对于读和写,到底谁共享,谁独占。可能会让人误以为读写锁是一种更为泛化的称呼,其实不是。读写锁的含义是准确的:是一种 读共享,写独占的锁。

读写锁的特性:

  • 当读写锁被加了写锁时,其他线程对该锁加读锁或者写锁都会阻塞(不是失败)。
  • 当读写锁被加了读锁时,其他线程对该锁加写锁会阻塞,加读锁会成功。

因而适用于多读少写的场景。

// 声明一个读写锁
pthread_rwlock_t rwlock;
...
// 在读之前加读锁
pthread_rwlock_rdlock(&rwlock);

... 共享资源的读操作

// 读完释放锁
pthread_rwlock_unlock(&rwlock);

// 在写之前加写锁
pthread_rwlock_wrlock(&rwlock); 

... 共享资源的写操作

// 写完释放锁
pthread_rwlock_unlock(&rwlock);

// 销毁读写锁
pthread_rwlock_destroy(&rwlock);

其实加读锁和加写锁这两个说法可能会造成误导,让人误以为是有两把锁,其实读写锁是一个锁。所谓加读锁和加写锁,准确的说法可能是『给读写锁加读模式的锁定和加写模式的锁定』。

读写锁和互斥量一样也有trylock函数,也是以非阻塞地形式来请求锁,不会导致阻塞。

 pthread_rwlock_tryrdlock(&rwlock)
 pthread_rwlock_trywrlock(&rwlock)


C++11中有互斥量、条件变量但是并没有引入读写锁。而在C++17中出现了一种新锁:std::shared_mutex。用它可以模拟实现出读写锁。demo代码可以直接参考cppreference:


另外多读少写的场景有些特殊场景,可以用特殊的数据结构减少锁使用:

  • 多读单写的线性数据。用数组实现环形队列,避免vector等动态扩张的数据结构,写在结尾,由于单写因而可以不加锁;读在开头,由于多读(避免重复消费)所以需要加一下锁(互斥量就行)。
  • 多读单写的KV。可以使用双缓冲(double buffer)的数据结构来实现。double buffer同名的概念比较多,这里指的是foreground 和 backgroud 两个buffer进行切换的『0 - 1切换』技术。比如实现动态加载(热加载)配置文件的时候。可能会在切换间隙加一个短暂的互斥量,但是基本可以认为是lock free的。

我一张口,你就会发现:无非也就是空间换时间的老套路了。

spinlock(自旋锁)

自旋之名颇为玄妙,第一次听闻常让人略觉高大。但和无数个好似『故意把简单概念复杂化』的计算机术语一样,自旋锁的本质简单的难以置信。

要了解自旋锁,首先了解自旋。什么是自旋(spin)呢?更为通俗的一个词是『忙等待』(busy waiting)。最最通俗的一个理解,其实就是死循环……

单看使用方法和使用互斥量的代码是差不多的。只不过自旋锁不会引起线程休眠。当共享资源的状态不满足的时候,自旋锁会不停地循环检测状态。因为不会陷入休眠,而是忙等待的方式也就不需要条件变量。

这是优点也是缺点。不休眠就不会引起上下文切换,但是会比较浪费CPU。

// 声明一个自旋锁变量
pthread_spinlock_t spinlock;

// 初始化   
pthread_spin_init(&spinlock, 0);

// 加锁  
pthread_spin_lock(&spinlock);

// 解锁 
pthread_spin_unlock(&spinlock);

// 销毁  
pthread_spin_destroy(&spinlock);

pthread_spin_init函数的第二个参数名为pshared(int类型)。表示的是是否能进程间共享自旋锁。这被称之为Thread Process-Shared Synchronization。互斥量的通过属性也可以把互斥量设置成进程间共享的。pshared有两个枚举值:

  • PTHREAD_PROCESS_PRIVATE:仅同进程下读线程可以使用该自旋锁
  • PTHREAD_PROCESS_SHARED:不同进程下的线程可以使用该自旋锁

在Linux上的glibc中这两个枚举值分别是0和1(Mac上不是)。所以通常也会看到直接传0的代码。你可能觉得不使用宏,直接用数字硬编码不是一个好习惯。的确,妥妥的Magic Number,但还有一个有趣的事实你需要了解:并不是所有实现都支持自旋锁设置两种pshared。比如[3]

int pthread_spin_init (pthread_spinlock_t *lock, int pshared) {
    /* Relaxed MO is fine because this is an initializing store.  */
    atomic_store_relaxed (lock, 0);
    return0;
}

所以直接传0可能也无伤大雅。

自旋锁 VS 互斥量+条件变量 孰优孰劣?肯定要看具体的使用场景,(我好像在说片汤话)。当你不知道在你的使用场景下这两种锁该用哪个的时候,那就是用互斥量吧!或者通过压测的判断,不过大多数时候我们好像并不需要这么一个pthread的自旋锁,知友们可以提供一些自旋锁的使用参考。


内容太多,难免有误,望大家指教。


课后思考

你还知道哪些锁类型?

或者哪些线程同步机制(不一定叫锁)?

参考

  1. ^1 https://en.wikipedia.org/wiki/Reentrant_mutex#Practical_use
  2. ^2 https://books.google.com.hk/books?id=Ps2SH727eCIC&pg=PA647&lpg=PA647&dq=linux+programming+interface+wait+morphing&source=bl&ots=kMKcz2zPC7&sig=ACfU3U1ZSbxBegrQhuVkfNAMTRkY-YavvA&hl=en&sa=X&redir_esc=y&hl=zh-CN&sourceid=cndr#v=onepage&q=linux%20programming%20interface%20wait%20morphing&f=false
  3. ^3 https://github.com/lattera/glibc/blob/895ef79e04a953cac1493863bcae29ad85657ee1/nptl/pthread_spin_init.c