目录
3.5. 性能开销 (Performance Overhead)
3.6 优先级反转 (Priority Inversion)
1. 线程间互斥的相关背景和概念
1.1 为什么要存在互斥
要理解互斥,首先要理解为什么需要它。这源于并发编程中的一个根本问题:竞争条件(Race Condition)。
竞争条件:当两个及以上线程访问共享资源(如全局变量,内存区域,文件等),最终的结果取决于线程执行的精确时序时,就发生了竞争条件。
经典例子:银行取款
假设有一个共享的银行余额balance = 100,有两个线程要对其进行取款操作withdrawal(amount),其步骤如下
步骤1: 读取当前的余额
current_balance = balance
。步骤2: 计算新的余额
new_balance = current_balance - amount
。步骤3: 将新余额写回
balance = new_balance
。
取出ticket--部分的汇编代码(objdump 命令)objdump -d a.out > test.objdump152 40064b: 8b 05 e3 04 20 00 mov 0x2004e3(%rip),%eax # 600b34 <ticket>153 400651: 83 e8 01 sub $0x1,%eax154 400654: 89 05 da 04 20 00 mov %eax,0x2004da(%rip) # 600b34 <ticket>操作并不是原子操作,而是对应三条汇编指令:load :将共享变量 ticket 从内存加载到寄存器中update : 更新寄存器里面的值,执行 -1 操作store :将新值,从寄存器写回共享变量 ticket 的内存地址
如果没有互斥保护,可能会发生以下交错执行序列:
时间点 | 线程A (取50) | 线程B (取30) | 余额值 |
---|---|---|---|
t1 | current_balance = balance; (读到100) |
100 | |
t2 | current_balance = balance; (也读到100) |
100 | |
t3 | new_balance = 100 - 50; (得到50) |
100 | |
t4 | new_balance = 100 - 30; (得到70) |
100 | |
t5 | balance = 50; |
50 | |
t6 | balance = 70; |
70 |
最终结果:银行损失了120元(100 - 70 = 30,但实际应扣除50+30+70-30=120元),线程A的操作被线程B覆盖了。这就是一个严重的竞争条件 bug。
问题根源:线程A,B均可访问变量“balance
”,像balance = balance - amount
这样的操作,在高级语言中是一条语句,在底层CPU指令看来是多步操作(读取-计算-写入),但其并非原子操作,随时都可能被OS进行上下文替换,无法预测指令的交错顺序。
1.2 核心概念 - 互斥
互斥就是为了解决竞争条件而引入的概念,其核心思想为:
在多线程中,保证当一个线程访问临界资源(共享资源)时,其他线程无法临界资源进行操作,不能同时访问该资源。即对共享资源是互斥的,串行化的。
被互斥机制保护的,用于访问共享资源的代码段被称为临界区(critical section)。
一个正确的互斥方案应当满足一下四个条件
1.互斥(Mutual Exclusion):同一时间只允许一个线程/进程对临界资源的访问
2.进步(Progress):如果没有线程进入临界区内,且有多个线程希望进入,那么只能由其中一个进入,且选择不可无线推迟。
3.有限等待(Bounded Waiting):从一个线程提出请求到进入临界区访问临界资源,必须存在一个时间上限,不能让其他线程陷入永久等待(线程饥饿问题)。
4.让权等待(可选但高效):如果线程暂时无法访问临界资源,它因释放CPU资源(将PCB放入等待队列,进入阻塞状态),让其他线程运行,而不是忙等待(Busy Waiting)空耗CPU周期
1.3 信号量(Semaphore)
互斥锁也是信号量的一种特殊形式(称为二元信号量)
信号量工作原理:本质是一个计数器,用于表明临界资源中,资源数的多少。当执行wait()(或P)操作时,计数器-1,当计数器>= 0时,阻塞。执行signal(或V)操作时,计数器+1,唤醒一个等待进程。
信号量 VS 互斥锁
互斥锁(Mutex):计数器只有0和1,表示“空闲”和“持有”两种状态,且“谁加锁,谁解锁”。
信号量(Semaphore):计数器可以是任意非负整数,常用于表示线程资源的数量(比如连接池中有5个连接,则信号量初始值为5),在不同线程中都可执行P/V操作。
3. 其他机制
监视器(Monitor):一种高级同步构造,将互斥锁和条件变量(Condition Variable)封装在一起。Java中的
synchronized
关键字和Object.wait()/notify()
就是基于监视器。自旋锁(Spinlock):一种互斥锁的实现方式。当线程无法获取锁时,它不会进入阻塞状态,而是循环忙等待(Busy-wait),不断检查锁是否可用。这在等待时间极短且不想承受线程切换开销的场景(如内核开发)下很高效,但在用户空间编程中通常不推荐,因为它空耗CPU。
2. 互斥锁API
以上理解了为什么在并发编程时要存在互斥以及什么是互斥和信号量,接下来介绍互斥锁的调用接口。
2.1 核心API模式
几乎所有语言的互斥锁API都遵循以下模式:
创建锁:初始化一个锁对象。
加锁:在进入临界区(Critical Section) 之前调用。
如果锁未被持有,当前线程获得锁并继续执行。
如果锁已被其他线程持有,当前线程阻塞(Block),直到锁被释放。
解锁:在退出临界区之后调用。
释放锁,唤醒其他正在等待这个锁的线程(通常只有一个会被唤醒)。
2.2 初始化互斥锁
功能:动态初始化一个互斥锁。
int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);
参数 | 类型 | 描述 |
---|---|---|
mutex |
pthread_mutex_t * |
【出参】 指向要初始化的互斥锁对象的指针 |
attr |
const pthread_mutexattr_t * |
【入参】 指向互斥锁属性对象的指针。如果为NULL ,则使用默认属性 |
也可以使用宏进行静态初始化(无需调用pthread_mutex_init
和pthread_mutex_destroy
):
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
2.3 销毁互斥锁
功能:销毁一个互斥锁对象,释放其资源。
int pthread_mutex_destroy(pthread_mutex_t *mutex);
参数 | 类型 | 描述 |
---|---|---|
mutex |
pthread_mutex_t * |
【入参】 指向要销毁的互斥锁对象的指针 |
2.4 加锁操作
int pthread_mutex_lock(pthread_mutex_t *mutex);
功能:锁定互斥锁。如果锁已被其他线程持有,则调用线程将阻塞,直到锁变为可用。
参数 | 类型 | 描述 |
---|---|---|
mutex |
pthread_mutex_t * |
【入参】 指向要锁定的互斥锁对象的指针 |
int pthread_mutex_trylock(pthread_mutex_t *mutex);
功能:尝试锁定互斥锁。如果锁可用则获取锁,如果锁已被持有则立即返回错误,不会阻塞。
返回值 | 描述 |
---|---|
0 |
成功获取锁 |
EBUSY |
锁已被其他线程持有(这是预期的"失败") |
其他 | 其他错误 |
int pthread_mutex_timedlock(pthread_mutex_t *restrict mutex,
const struct timespec *restrict abstime);
功能:尝试在指定的时间内锁定互斥锁。
参数 | 类型 | 描述 |
---|---|---|
mutex |
pthread_mutex_t * |
要锁定的互斥锁 |
abstime |
const struct timespec * |
指向绝对超时时间的指针(不是相对时间) |
2.5 锁操作
功能:释放(解锁)互斥锁。
int pthread_mutex_unlock(pthread_mutex_t *mutex);
参数 | 类型 | 描述 |
---|---|---|
mutex |
pthread_mutex_t * |
【入参】 指向要解锁的互斥锁对象的指针 |
代码示例:
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
// 共享资源
int counter = 0;
// 互斥锁
pthread_mutex_t mutex;
void* increment(void* arg) {
for (int i = 0; i < 100000; ++i) {
// 加锁
int ret = pthread_mutex_lock(&mutex);
if (ret != 0) {
fprintf(stderr, "pthread_mutex_lock failed: %d\n", ret);
exit(EXIT_FAILURE);
}
// 临界区开始
counter++;
// 临界区结束
// 解锁
ret = pthread_mutex_unlock(&mutex);
if (ret != 0) {
fprintf(stderr, "pthread_mutex_unlock failed: %d\n", ret);
exit(EXIT_FAILURE);
}
}
return NULL;
}
int main() {
pthread_t t1, t2;
// 初始化互斥锁
if (pthread_mutex_init(&mutex, NULL) != 0) {
perror("Mutex initialization failed");
return EXIT_FAILURE;
}
// 创建线程
if (pthread_create(&t1, NULL, increment, NULL) != 0 ||
pthread_create(&t2, NULL, increment, NULL) != 0) {
perror("Thread creation failed");
return EXIT_FAILURE;
}
// 等待线程结束
pthread_join(t1, NULL);
pthread_join(t2, NULL);
printf("Final counter value: %d\n", counter); // 应该是 200000
// 销毁互斥锁
pthread_mutex_destroy(&mutex);
return EXIT_SUCCESS;
}
返回值均为
返回值 | 描述 |
---|---|
0 |
成功 |
≠0 |
失败,返回错误代码(如EAGAIN , ENOMEM , EPERM 等) |
3.加锁操作可能遇到的问题
3.1 理解锁
硬件层面的锁:就像一扇门的物理门锁和钥匙。它是一个实实在在的机械结构,决定了门能否被打开。
软件层面的锁:就像这扇门的使用规则或钥匙管理制度。规则上写着“只有拿着‘经理’钥匙的人才能进入”,但规则本身不能物理上阻止你开门,它依赖人们去遵守。
注:CPU中只有一套寄存器,但CPU内的数据可以有多份,进程/线程进行上下文切换时,保存当前执行流的上下文。
为了实现互斥锁操作,大多数体系结构都提供了swap或exshange(确保原子性,只有一条指令)指令,该指令的作用是把寄存器和内存单元的数据相交换,所以当执行xchgb %al, mutex时,不是拷贝,是交换
; 假设锁变量 lock_var 在内存中,初始值为 0 (空闲)
; 线程试图获取锁movb $1, %al ; 把“我想要锁”这个值(1)放入寄存器 AL
xchgb %al, (lock_var) ; 原子操作:交换 AL 和 lock_var 的值; 操作后,AL 中保存的是 lock_var 的*旧值*
testb %al, %al ; 检查 AL 中的旧值是什么
jnz try_again ; 如果旧值不是0(说明锁已经被别人拿走了),跳转回去重试
; 如果旧值是0(说明我成功地把0换成了1),那我就成功获得了锁,可以进入临界区了
3.2 死锁 (Deadlock)
这是最经典、最严重的问题。指两个或多个线程互相等待对方持有的资源,导致所有线程都无法继续执行。
产生死锁的四个必要条件(Coffman条件):
互斥条件:资源一次只能被一个线程持有。
持有并等待:线程持有至少一个资源,并等待获取其他线程持有的资源。
不可剥夺:资源只能由持有它的线程主动释放,不能被强制抢占。
循环等待:存在一个线程等待序列:T₁等待T₂持有的资源,T₂等待T₃,...,Tₙ等待T₁。
典型场景:
嵌套锁:以不同的顺序获取多个锁。
3.3 活锁 (Livelock)
线程没有阻塞,但在不断重试某个总是失败的操作,导致实际工作无法推进。就像两个人在走廊相遇,都向同一侧让路,结果又挡住了对方,持续循环。
典型场景:
多个线程在发生冲突时都主动释放锁并重试,但重试的时机和策略相同,导致下一次尝试又同时发生冲突,无限循环。
3.4 饥饿 (Starvation)
一个线程因为优先级太低或调度策略问题,长期无法获取到所需的锁,从而无法执行。虽然其他线程在正常推进,但该线程"饿死了"。
典型场景:
锁的竞争非常激烈。
线程调度策略不公平,某些线程总是能优先获得锁。
3.5. 性能开销 (Performance Overhead)
加锁不是免费的,它会带来显著的开销:
锁操作本身的开销:调用
pthread_mutex_lock/unlock
涉及用户态和内核态的切换(对于需要阻塞的锁),以及原子操作,这比普通的函数调用开销大得多。线程阻塞和唤醒的开销:当锁被持有时,其他线程会进入睡眠状态,之后又需要被唤醒,上下文切换的成本很高。
缓存失效:获得锁的线程在临界区内修改的数据,会导致其他CPU核心上对应缓存行的失效,其他线程在获得锁后需要重新从内存加载数据,速度很慢。
限制并行性:锁将并行操作强制串行化,临界区越大,持有锁的时间越长,程序的并发性能就越差。
3.6 优先级反转 (Priority Inversion)
这是一个在实时系统中尤其重要的问题。当一个低优先级线程持有一个高优先级线程需要的锁时,中优先级的线程(不需要该锁)可能会抢占低优先级线程的CPU时间,导致低优先级线程无法运行从而无法释放锁,进而导致高优先级线程无限期等待。
简单流程:
低优先级线程(L) 获得锁。
高优先级线程(H) 就绪,抢占L,但需要锁,因此被阻塞。
中优先级线程(M) 就绪(不需要锁),抢占L的CPU时间。
L 无法运行,无法释放锁。
H 被迫一直等待 M 执行完毕,仿佛H的优先级低于M。
3.7 错误使用导致的问题
忘记解锁:导致其他线程永久阻塞。必须使用
try-finally
或 RAII 模式来确保锁被释放。在锁保护范围外访问共享数据:锁形同虚设。
使用错误的锁:例如,用进程内的线程锁去保护进程间共享的内存。
破坏锁的封装性:直接操作锁内部结构。
3.8 解决策略和最佳实践
问题 | 解决策略和最佳实践 |
---|---|
死锁 | 1. 固定顺序:全局规定所有锁的获取顺序。 2. 尝试加锁:使用 pthread_mutex_trylock ,如果失败则释放已持有的所有锁,稍后重试。3. 锁超时:使用 pthread_mutex_timedlock 。4. 死锁检测算法:系统可以检测死锁并打破它(代价高,不常用)。 |
活锁 | 引入随机性:让每个线程在重试前随机等待一段时间,打破同步性。 |
饥饿 | 使用公平锁(FIFO队列),或者调整线程调度优先级。 |
性能开销 | 1. 减小临界区:只锁必须锁的代码。 2. 降低锁粒度:用多个细粒度锁保护不同的数据,代替一个粗粒度的大锁。 3. 无锁编程:对于简单操作,使用原子操作(如C++ std::atomic )。4. 读写锁:使用 pthread_rwlock_t ,允许多个读线程并发。 |
优先级反转 | 优先级继承:当高优先级线程等待低优先级线程持有的锁时,临时将低优先级线程的优先级提升到与高优先级线程相同,使其能尽快运行并释放锁。Linux的互斥锁属性可以设置此项。 |
错误使用 | 1. 代码审查。 2. 使用RAII(资源获取即初始化)模式,如C++ std::lock_guard 。3. 使用静态分析工具检测锁的使用问题。 |
注:加锁是一把双刃剑。它解决了数据竞争的问题,但引入了并发编程中一系列最复杂的挑战。一个优秀的开发者不仅要懂得如何使用锁,更要深刻理解使用锁带来的副作用,并知道如何通过设计(如减少共享、使用更高级的并发数据结构)和最佳实践来规避或减轻这些问题
4. 读写锁
什么是读写锁?
读写锁将访问共享资源的线程分为两类:
读线程:只读取数据,不修改数据。
写线程:会修改数据。
它的核心思想是:
允许多个读线程同时持有读锁(共享访问)。
只允许一个写线程持有写锁,且写锁是排他的(独占访问)。当写线程持有锁时,其他任何读线程或写线程都不能获取锁。
读锁和写锁是互斥的。如果写线程正在写,读线程不能读;如果读线程正在读,写线程不能写。
这极大地提高了并发性,因为在大多数情况下,读操作远多于写操作。
pthread_rwlock_t
API 详解
1. 初始化和销毁
初始化:
int pthread_rwlock_init(pthread_rwlock_t *rwlock, const pthread_rwlockattr_t *attr);
rwlock
: 【出参】指向要初始化的读写锁对象的指针。
attr
: 【入参】指向读写锁属性对象的指针。通常设为NULL
使用默认属性。返回值:成功返回
0
,失败返回错误码。
静态初始化:
pthread_rwlock_t rwlock = PTHREAD_RWLOCK_INITIALIZER;
销毁:
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
rwlock
: 【入参】指向要销毁的读写锁对象的指针。返回值:成功返回
0
,失败返回错误码。
2. 读锁操作(用于读线程)
阻塞方式获取读锁:
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
如果写锁未被持有,则立即获得读锁。
如果写锁已被持有,则调用线程阻塞,直到写锁被释放。
如果已有读锁,通常可以再次获得(可重入),但取决于属性。
非阻塞方式尝试获取读锁:
int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock);
尝试获取读锁,如果写锁正被持有,则立即返回
EBUSY
,而不是阻塞。
超时方式获取读锁:
int pthread_rwlock_timedrdlock(pthread_rwlock_t *rwlock, const struct timespec *abstime);
尝试在绝对时间
abstime
之前获取读锁,超时则返回ETIMEDOUT
。
3. 写锁操作(用于写线程)
阻塞方式获取写锁:
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);
如果没有任何线程(读或写)持有锁,则立即获得写锁。
如果锁正被其他线程(读或写)持有,则调用线程阻塞,直到锁变为可用。
非阻塞方式尝试获取写锁:
int pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock);
尝试获取写锁,如果锁正被持有,则立即返回
EBUSY
。
超时方式获取写锁:
int pthread_rwlock_timedwrlock(pthread_rwlock_t *rwlock, const struct timespec *abstime);
尝试在绝对时间
abstime
之前获取写锁。
4. 解锁操作
释放锁(读锁或写锁):
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);
释放由当前线程持有的读写锁。
无论是读锁还是写锁,都使用同一个函数解锁。
5.公平锁
什么是公平锁?
公平锁是一种线程同步机制,它保证线程获取锁的顺序遵循先来先服务(First-Come-First-Served, FCFS) 的原则。也就是说,当锁可用时,它会分配给等待时间最长的线程。
核心思想:维护一个等待队列,严格按照线程请求锁的顺序来授予锁。
公平锁 vs. 非公平锁
为了更好地理解公平锁,我们将其与常见的非公平锁(Non-fair Lock) 进行对比。
特性 | 公平锁 (Fair Lock) | 非公平锁 (Non-fair Lock) |
---|---|---|
获取顺序 | 严格FIFO(先来先服务) | 允许插队(竞争式) |
机制 | 维护一个等待队列,锁释放时唤醒队首线程。 | 新请求的线程可以直接与刚被唤醒的队首线程竞争锁。 |
性能 | 吞吐量通常较低。上下文切换开销大,且CPU可能空闲。 | 吞吐量通常更高。减少线程切换,CPU利用率高。 |
饥饿 | 有效防止饥饿。每个线程最终都能获得锁。 | 可能导致饥饿。活跃的线程可能一直"插队",使某些线程长期等待。 |
适用场景 | 对公平性要求高的实时系统、交易系统。 | 对性能吞吐量要求高的通用应用、Web服务器。 |
一个经典的比喻:银行排队
公平锁:就像一家严格排队的银行。新来的顾客必须走到队伍末尾排队。出纳员(锁)总是服务队伍最前面的顾客(线程)。保证绝对公平,但如果队伍很长,即使只有一个顾客办理业务,出纳员也可能需要等待新顾客走到队尾,效率不高。
非公平锁:就像一家允许插队的银行。当一位顾客办完业务离开,出纳员喊"下一位"时,队伍最前的人和新走进门的人可以同时冲向柜台。谁跑得快谁就得到服务。这提高了柜台(CPU)的利用率(几乎不会空闲),但可能让一直排队的老人生气(线程饥饿)。