常见锁介绍
在 C 语言中,常见的用于解决多线程访问数据的锁包括互斥锁、读写锁、条件变量、自旋锁、屏蔽等。
-
互斥锁(mutex):互斥锁是一种 最基本的锁机制 ,用于 保护共享资源,防止多个线程同时访问和修改同一个资源。当一个线程持有了互斥锁后,其他线程需要等待该线程释放锁之后才能访问共享资源。
-
读写锁(read-write lock):读写锁是一种 特殊的锁机制 ,它 允许多个线程同时读取共享资源,但是只允许一个线程写入共享资源。这种锁可以提高读取操作的并发度,同时保证写入操作的正确性和一致性。如果一个线程获取了写锁,其他线程就必须等待它释放锁后才能继续访问;如果一个线程获取了读锁,其他线程也可以获取读锁并访问资源。
-
条件变量(condition variable):条件变量是一种 用于线程之间通信的同步机制 ,它允许线程在某个条件成立时才能继续执行,通常与互斥锁一起使用。 当条件变量不满足时,线程释放互斥锁并等待条件变量被唤醒(通过另一个线程来唤醒条件变量);当条件变量满足时,通知线程重新获取互斥锁并继续执行。
-
自旋锁(spinlock):自旋锁是一种 忙等待锁机制 ,当一个线程尝试获取锁时,如果锁已经被占用,它会一直循环等待直到锁被释放。 自旋锁适用于锁的持有时间很短的情况,因为长时间占用 CPU 会影响系统性能。
-
屏障(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);
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);
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 资源。
- 当线程被阻塞等待时,线程的调度状态从就绪态到阻塞态,线程被加入等待线程队列;
- 当锁可用时,在获取锁之前,线程会被从等待队列取出,并将其调度状态切换会运行态。
互斥锁适用于那些可能会阻塞很长时间的场景:
- 临界区有 IO 操作
- 临界区代码复杂或者循环量大
- 临界区竞争非常激烈
- 单核处理器
自旋锁是 busy-waiting 类型的锁:它只需要消耗很少的资源来建立锁。但当线程被阻塞时,它会一直重复检查锁是否可用了。也就是说,自旋锁处于等待状态时它会一直消耗 CPU 时间。因此,自旋锁适用于那些仅需要阻塞很短时间的场景。