「Linux 系统编程」线程同步概念、互斥锁和读写锁、死锁的产生
1 线程同步
在多线程编程中,当多个线程并发访问共享数据(如全局变量、静态变量等)时,如果不对访问进行控制,就极有可能导致 数据竞争 和 数据不一致 的问题。线程同步 正是为了解决这类数据混乱问题而诞生,用于 确保多个线程在访问共享资源时的正确性和一致性。
2 互斥锁
2.1 互斥锁的概念
互斥锁是实现线程同步最基本的方法。在任何时刻,只允许一个线程持有互斥锁。线程在访问共享资源前必须先成功获取锁,访问完成后释放锁。如果另一个线程试图获取一个已经被锁定的互斥锁,它将会被阻塞,直到锁被释放。
这好比只有一个钥匙的卫生间,一个人(线程)进去后会把门锁上(加锁),其他人(其他线程)必须等在门口(阻塞),直到里面的人出来(解锁)并把钥匙交给下一个人。
2.2 Linux C 互斥锁函数
Linux 的 POSIX 线程库 pthread
提供了一系列函数来操作互斥锁,主要类型为 pthread_mutex_t
。
函数 | 功能描述 |
---|---|
int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr) |
初始化一个互斥锁。attr 为属性,通常设为 NULL 表示默认属性。 |
int pthread_mutex_lock(pthread_mutex_t *mutex) |
阻塞式 加锁。如果锁已被占用,则调用线程将阻塞,直到锁被释放。 |
int pthread_mutex_trylock(pthread_mutex_t *mutex) |
非阻塞式 加锁。如果锁已被占用,函数会立即返回错误码 EBUSY ,而不是阻塞。 |
int pthread_mutex_unlock(pthread_mutex_t *mutex) |
解锁。释放一个已持有的互斥锁。 |
int pthread_mutex_destroy(pthread_mutex_t *mutex) |
销毁一个互斥锁,释放其占用的资源。 |
2.3 Linux C 互斥锁示例
下面是一个多线程计数器示例,包含不使用锁和使用锁两种情况。
1 |
|
不加锁时,两个线程同时对 counter
进行递增,会发生数据覆盖,最终结果会小于预期。加锁后,保证了任何时刻只有一个线程执行 counter ++
,最终结果正确。
在 C++ 多线程中,一般采用 std::mutex
/ std::lock_guard
/ std::unique_lock
来实现线程同步。「C++ 多线程」std::mutex,lock(), unlock()、「C++ 多线程」std::lock_guard 基本用法、「C++ 多线程」std::unique_lock 基本用法
3 读写锁
3.1 读写锁的概念
互斥锁的粒度有时过于粗旷。考虑一种读多写少的场景(如缓存系统),多个线程同时读取数据并不会产生问题,但互斥锁只允许一个读线程进入,这无疑降低了读操作的效率。
读写锁解提供了更高的并行性。其规则如下:
- 读模式加锁: 只要没有线程持有写锁,多个线程可以同时持有读锁。
- 写模式加锁: 只要没有线程持有任何锁(读或写),只有一个线程可以持有写锁。
读写锁的特点可以概括为:读共享,写独占,写优先级高(默认情况下,防止写线程饥饿)。
读写锁优先级:
-
读并发与写阻塞
当有多个读线程获得共享锁时,这些读线程可以并发地对共享资源进行读取操作。此时如果有写线程尝试获取独占锁,这个写线程会被阻塞。
阻塞会一直持续,直到所有当前持有共享锁的读线程都释放了共享锁。
-
写独占与读阻塞
当一个写线程占有独占锁时,新的读线程尝试获取共享锁会被阻塞。写独占锁的优先级高于读共享锁。
这种设计是为了 防止写操作饥饿。如果读线程不断地获取共享锁,而写线程一直无法获取独占锁来执行写入操作,这会导致数据不能及时更新。
3.2 Linux C 读写锁函数
读写锁的类型是 pthread_rwlock_t
。
函数 | 功能描述 |
---|---|
int pthread_rwlock_init(pthread_rwlock_t *rwlock, const pthread_rwlockattr_t *attr) |
初始化一个读写锁。 |
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock) |
阻塞式获取读锁。 |
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock) |
阻塞式获取写锁。 |
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock) |
释放持有的读锁或写锁。 |
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock) |
销毁一个读写锁。 |
3.3 Linux C 读写锁示例
1 |
|
多个读线程可以同时读取数据,打印信息快速且连续。而当写线程工作时,它会独占锁,此时所有读线程和其他写线程都会被阻塞,直到写操作完成。显著提高了读多写少场景下的程序性能。
在 C++ 多线程中,通常会使用 std::shared_lock
配合 std::unique_lock
来实现读写锁(读共享、写独占)。「C++ 多线程」std::shared_mutex,std::shared_lock
4 死锁
4.1 死锁的概念
死锁是指两个或多个线程在执行过程中,因 争夺资源 而造成的一种相互等待(循环等待)的现象。若无外力干涉,这些线程都将无法继续推进。
死锁产生的条件:
- 互斥条件: 一个资源每次只能被一个线程使用。
- 请求与保持条件: 一个线程因请求资源而阻塞时,对已获得的资源保持不放。
- 不剥夺条件: 线程已获得的资源,在未使用完之前,不能被其他线程强行剥夺。
- 循环等待条件: 若干线程之间形成一种头尾相接的循环等待资源关系。
4.2 死锁产生的场景
- 重复加锁
一个线程已经持有一个锁,又再次尝试获取它。默认属性的互斥锁不支持重入,会导致该线程永久阻塞自己。
解决方法: 使用可重入锁(递归锁),通过设置互斥锁属性 PTHREAD_MUTEX_RECURSIVE
实现。
- 加锁顺序不一致
这是最常见的死锁原因。线程 T1 先锁 A 再锁 B,而线程 T2 先锁 B 再锁 A。在并发执行时,可能发生 T1 锁住 A 的同时 T2 锁住了 B,双方都在等待对方释放另一个锁,导致死锁。
1 |
|
解决方法: 定义统一的加锁顺序。所有需要同时获取锁 A 和 B 的线程,都必须按照先 A 后 B 的顺序申请。
在 C++ 多线程中,也可以通过 std::lock(mtx1, mtx2)
一次性分配所有互斥锁资源来避免死锁。「C++ 多线程」死锁及 C++ 多线程解决死锁方法