常见锁介绍

在 C 语言中,常见的用于解决多线程访问数据的锁包括互斥锁、读写锁、条件变量、自旋锁、屏蔽等。

  1. 互斥锁(mutex):互斥锁是一种 最基本的锁机制 ,用于 保护共享资源,防止多个线程同时访问和修改同一个资源。当一个线程持有了互斥锁后,其他线程需要等待该线程释放锁之后才能访问共享资源。

  2. 读写锁(read-write lock):读写锁是一种 特殊的锁机制 ,它 允许多个线程同时读取共享资源,但是只允许一个线程写入共享资源。这种锁可以提高读取操作的并发度,同时保证写入操作的正确性和一致性。如果一个线程获取了写锁,其他线程就必须等待它释放锁后才能继续访问;如果一个线程获取了读锁,其他线程也可以获取读锁并访问资源。

  3. 条件变量(condition variable):条件变量是一种 用于线程之间通信的同步机制 ,它允许线程在某个条件成立时才能继续执行,通常与互斥锁一起使用。 当条件变量不满足时,线程释放互斥锁并等待条件变量被唤醒(通过另一个线程来唤醒条件变量);当条件变量满足时,通知线程重新获取互斥锁并继续执行

  4. 自旋锁(spinlock):自旋锁是一种 忙等待锁机制 ,当一个线程尝试获取锁时,如果锁已经被占用,它会一直循环等待直到锁被释放。 自旋锁适用于锁的持有时间很短的情况,因为长时间占用 CPU 会影响系统性能。

  5. 屏障(barrier):屏障是一种 用于多线程协同的同步机制 ,它允许多个线程在某个点上等待,直到所有线程都到达该点后再继续执行。 屏障通常用于一组线程需要在某个点进行同步操作 的情况,例如多线程排序算法。

综上,互斥锁、读写锁、自旋锁用于实现 互斥访问 ,条件变量、屏障用于实现 同步

互斥锁

互斥锁原理 :互斥锁属于 sleep-waiting 类型的锁。例如,在一个双核的机器上有两个线程(线程 A 和线程 B),它们分别运行在 Core0 和 Core1 上。假设线程 A 想要通过 pthread_mutex_lock 操作去得到一个临界区的锁,而此时这个锁正被线程 B 所持有,那么线程 A 就会 被阻塞 ,Core0 会在此时进行上下文切换(Context Switch)将线程 A 置于等待队列中,此时 Core0 就可以运行其它的任务而不必进行忙等待。

互斥锁实现:通常使用了操作系统提供的原子操作或者硬件提供的锁机制,保证锁的正确性和高效性。

互斥锁两种类型:递归锁和非递归锁。递归锁允许同一线程在不释放锁的情况下多次获取锁,而非递归锁不允许这种情况发生。

互斥锁使用场景:因互斥锁会引起线程的切换,效率较低;使用互斥锁会引起线程阻塞等待,不会一直占用着 CPU。因此,当锁的内容较多、切换不频繁时,建议使用互斥锁。

互斥锁使用笔记:互斥锁的使用非常简单,主要包括以下几个步骤:

  • 定义互斥锁变量,一般使用 pthread_mutex_t 类型;
  • 在需要保护的代码段前调用 pthread_mutex_lock 函数获取锁;
  • 在代码段执行完毕后调用 pthread_mutex_unlock 函数释放锁;
  • 释放锁之后其他线程就可以获取锁并访问共享资源。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <pthread.h>

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

// 初始化互斥锁
pthread_mutex_init(&mutex, NULL);

// 在需要保护的代码块前加锁
pthread_mutex_lock(&mutex);
// 访问共享资源
// ...
// 访问完毕后解锁
pthread_mutex_unlock(&mutex);

// 在使用完后销毁
pthread_mutex_destroy(&mutex);

读写锁

读写锁的实现:通常使用了计数器和互斥锁等机制,通过控制读写访问的次数和顺序来保证数据的正确性和一致性。

读写锁使用笔记:读写锁的使用也非常简单,主要包括以下几个步骤:

  • 定义读写锁变量,一般使用 pthread_rwlock_t 类型;
  • 在需要读取共享资源的代码段前调用 pthread_rwlock_rdlock 函数获取读锁;
  • 在需要写入共享资源的代码段前调用 pthread_rwlock_wrlock 函数获取写锁;
  • 在代码段执行完毕后调用 pthread_rwlock_unlock 函数释放锁;
  • 释放锁之后其他线程就可以获取锁并访问共享资源。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <pthread.h>

pthread_rwlock_t rwlock = PTHREAD_RWLOCK_INITIALIZER;

// 初始化读写锁
pthread_rwlock_init(&rwlock, NULL);

// 在需要读取共享资源的代码块前加读锁
pthread_rwlock_rdlock(&rwlock);
// 读取共享资源
// ...
// 读取完毕后释放读锁
pthread_rwlock_unlock(&rwlock);

// 在需要写入共享资源的代码块前加写锁
pthread_rwlock_wrlock(&rwlock);
// 写入共享资源
// ...
// 写入完毕后释放写锁
pthread_rwlock_unlock(&rwlock);

// 在使用完读写锁后销毁
pthread_rwlock_destroy(&rwlock);

读锁或写锁都是使用的 rwlock 变量。

自旋锁

自旋锁原理 :自旋锁属于 busy-waiting 类型的锁。例如,在一个双核的机器上有两个线程(线程 A 和线程 B),它们分别运行在 Core0 和 Core1 上。如果线程 A 使用 pthread_spin_lock 操作去请求锁,那么线程 A 就会一直在 Core0 上进行 忙等待并不停的进行锁请求,直到拿到这个锁为止 。自旋锁不会引起调用者睡眠,如果自旋锁已经被别的线程持有,调用者就 一直循环在那里 看是否该自旋锁的持有者已经释放了锁。

自旋锁使用场景 :因为自旋锁不会引起调用者睡眠,所以 自旋锁的效率远高于互斥锁。因此,如果锁的内容较少、阻塞的时间较短,使用自旋锁比较好。

自旋锁只有在内核可抢占式或 SMP 的情况下才真正需要。在单 CPU 且不可抢占式的内核下,自旋锁的操作为空操作。

自旋锁使用笔记

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <pthread.h>

// 定义自旋锁
pthread_spinlock_t spinlock = PTHREAD_PROCESS_PRIVATE;

// 初始化自旋锁
pthread_spin_init(&spinlock, PTHREAD_PROCESS_PRIVATE);

// 加锁,如果锁已被其他线程占用,则该函数会一直循环忙等待直到获取到锁
pthread_spin_lock(&spinlock);
// 访问共享资源
// ...
// 解锁,使其他线程可以获取锁并访问共享资源
pthread_spin_unlock(&spinlock);

// 销毁自旋锁
pthread_spin_destroy(&lock);

条件变量

条件变量(condition variable):条件变量是一种 用于线程之间通信的同步机制 ,它允许线程在某个条件成立时才能继续执行,通常与互斥锁一起使用。 当条件变量不满足时,线程释放互斥锁并等待条件变量被唤醒(通过另一个线程来唤醒条件变量);当条件变量满足时,通知线程重新获取互斥锁并继续执行

条件变量使用笔记

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#include <pthread.h>

// 定义互斥锁和条件变量
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

// 初始化互斥锁和条件变量
pthread_mutex_init(&mutex, NULL);
pthread_cond_init(&cond, NULL);

// 加锁
pthread_mutex_lock(&mutex);

while (条件不满足预期条件) {
// 释放锁,并等待条件变量通知——从而唤醒该线程继续执行
pthread_cond_wait(&cond, &mutex);
}

// 条件满足(由另一个线程控制),继续执行,访问共享资源
// ...
// 解锁
pthread_mutex_unlock(&mutex);

// 在使用完后销毁
pthread_cond_destroy(&cond);
pthread_mutex_destroy(&mutex);

//----------------------------------------------

// 在其他线程中通知条件变量
// ...
// 加锁
pthread_mutex_lock(&mutex);
// 访问共享资源
// ...
// 解锁后再发送通知
pthread_mutex_unlock(&mutex);

// 发送条件变量通知其它等待的线程
pthread_cond_signal(&cond);

生产消费者同步示例代码

一个生产者、消费者同步的多线程示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

#define BUFFER_SIZE (4)

int buffer[BUFFER_SIZE];
int count = 0; // 产品余量
pthread_mutex_t mutex;
pthread_cond_t cond;

void* producer(void* arg) {
int flag = 0, i = 0;
while (1) {
pthread_mutex_lock(&mutex);

// 预期条件是 buffer 不能满(当 buffer 满时为条件不满足预期)
while (BUFFER_SIZE == count) {
if (0 == flag) {
printf(" Buffer full...\n");
flag = 1;
}
pthread_cond_wait(&cond, &mutex);
}

flag = 0;
buffer[count++] = i++;
printf("Producer produced: %d\n", i);

pthread_mutex_unlock(&mutex);
pthread_cond_signal(&cond); // 释放锁,并通知条件变量
}

return NULL;
}

void* consumer(void* arg) {
int flag = 0, i = 0;
while (1) {
pthread_mutex_lock(&mutex);

// 预期条件是 buffer 不能空(当 buffer 空时为条件不满足预期)
while (0 == count) {
if (0 == flag) {
printf(" Buffer empty...\n");
flag = 1;
}
pthread_cond_wait(&cond, &mutex);
}

flag = 0;
int value = buffer[--count];
printf("Consumer consumed: %d\n", value);

pthread_mutex_unlock(&mutex);
pthread_cond_signal(&cond); // 释放锁,并通知条件变量
}

return NULL;
}

int main(int argc, char* argv[]) {
pthread_t producer_thread, consumer_thread;

pthread_mutex_init(&mutex, NULL);
pthread_cond_init(&cond, NULL);

pthread_create(&producer_thread, NULL, producer, NULL);
pthread_create(&consumer_thread, NULL, consumer, NULL);

pthread_join(producer_thread, (void**)(0));
pthread_join(consumer_thread, (void**)(0));

pthread_cond_destroy(&cond);
pthread_mutex_destroy(&mutex);

return 0;
}

在这个示例代码中,有一个容量为 4 的缓冲区,生产者线程负责往缓冲区中添加数据,消费者线程负责从缓冲区中取出数据:

  • 生产者线程通过加锁后检查缓冲区是否已满,如果已满则等待条件变量通知,否则将数据添加到缓冲区,并发送条件变量通知消费者线程。
  • 消费者线程通过加锁后检查缓冲区是否为空,如果为空则等待条件变量通知,否则从缓冲区中取出数据,并发送条件变量通知生产者线程。

生产者和消费者线程之间的同步是通过互斥锁和条件变量来实现的。互斥锁用于保护共享资源,条件变量用于线程间的通信和同步。

一种 可能的 运行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
...
Consumer consumed: 46584
Consumer consumed: 46583
Buffer empty...
Producer produced: 46588
Producer produced: 46589
Producer produced: 46590
Consumer consumed: 46589
Consumer consumed: 46588
Consumer consumed: 46587
Producer produced: 46591
Producer produced: 46592
Producer produced: 46593
Producer produced: 46594
Buffer full...
Consumer consumed: 46593
Consumer consumed: 46592
Consumer consumed: 46591
...

屏障

屏障(barrier):屏障是一种 用于多线程协同的同步机制 ,它允许多个线程在某个点上等待,直到所有线程都到达该点后再继续执行。 屏障通常用于一组线程需要在某个点进行同步操作 的情况,例如多线程排序算法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <pthread.h>

// 定义屏障
pthread_barrier_t barrier;
int thread_nums = 5;

// 初始化屏障
pthread_barrier_init(&barrier, NULL, thread_nums);

// 在每个线程中执行以下代码
pthread_barrier_wait(&barrier); // 等待屏障

// 所有线程都到达屏障后,继续执行以下代码
// ...

// 在使用完后销毁
pthread_barrier_destroy(&barrier);

屏蔽实现线程同步示例代码

下面是一个完整的示例代码,演示了如何使用 pthread 库中的屏障实现线程同步:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

// 定义屏障
pthread_barrier_t barrier;
int thread_nums = 5;

// 线程函数
void* thread_func(void* ptr) {
printf("Thread #%lu, param %d operation\n", pthread_self(), *(int*)ptr);

// 等待屏障
pthread_barrier_wait(&barrier);

// 所有线程都到达屏障后,继续执行以下代码
printf("Thread %lu, %d continues after the barrier\n", pthread_self(), *(int*)ptr);
free(ptr);

return NULL;
}

int main() {
pthread_t threads[thread_nums];

// 初始化屏障
pthread_barrier_init(&barrier, NULL, thread_nums);

// 创建线程
for (int i = 0; i < thread_nums; i++) {
int* ptr = (int*)malloc(sizeof(int));
*ptr = i;
pthread_create(&threads[i], NULL, thread_func, (void*)ptr);
}

// 等待线程结束
for (int i = 0; i < thread_nums; i++) {
pthread_join(threads[i], (void**)(0));
}

// 销毁屏障
pthread_barrier_destroy(&barrier);

return 0;
}

在线程函数中,线程首先执行一些操作,然后调用 pthread_barrier_wait 函数等待屏障。当所有线程都到达屏障后,屏障解除,所有线程继续执行后续的代码。下面是一种可能的执行结果。

1
2
3
4
5
6
7
8
9
10
Thread #140592591922752, param 1 operation
Thread #140592600315456, param 0 operation
Thread #140592466097728, param 4 operation
Thread #140592583530048, param 2 operation
Thread #140592575137344, param 3 operation
Thread 140592575137344, 3 continues after the barrier
Thread 140592466097728, 4 continues after the barrier
Thread 140592591922752, 1 continues after the barrier
Thread 140592600315456, 0 continues after the barrier
Thread 140592583530048, 2 continues after the barrier

原子操作

所谓原子操作,就是该操作绝不会在执行完毕前被任何其他任务或事件打断。也就是说,它是 最小的执行单位,不可能有比它更小的执行单位。因此,这里的原子实际是使用了物理学里的物质微粒的概念。

原子操作需要硬件的支持,因此是架构相关的,其 API 和原子类型的定义都定义在内核源码树的 include/asm/atomic.h 文件中,它们都使用汇编语言实现,因为 C 语言并不能实现这样的操作

原子操作主要用于实现资源计数,很多引用计数(Reference Count, refcnt)就是通过原子操作实现的。

总结分析

互斥锁是 sleep-waiting 类型的锁:与自旋锁相比它需要消耗大量的系统资源来建立锁,但是在线程被阻塞期间,它不消耗 CPU 资源。

  1. 当线程被阻塞等待时,线程的调度状态从就绪态到阻塞态,线程被加入等待线程队列;
  2. 当锁可用时,在获取锁之前,线程会被从等待队列取出,并将其调度状态切换会运行态。

互斥锁适用于那些可能会阻塞很长时间的场景:

  • 临界区有 IO 操作
  • 临界区代码复杂或者循环量大
  • 临界区竞争非常激烈
  • 单核处理器

自旋锁是 busy-waiting 类型的锁:它只需要消耗很少的资源来建立锁。但当线程被阻塞时,它会一直重复检查锁是否可用了。也就是说,自旋锁处于等待状态时它会一直消耗 CPU 时间。因此,自旋锁适用于那些仅需要阻塞很短时间的场景。