Linux 互斥锁详解
一、线程间互斥相关背景概念
共享资源与临界资源:多线程执行流被保护的共享资源叫做临界资源。每个线程内部,访问临界资源的代码,叫做临界区。
在进程虚拟地址空间中,内容可以被所有线程看到,称为共享资源。当多个线程同时并发地访问共享资源(如向显示器文件写入内容)时,可能会发生数据不一致问题。这种因多线程并发访问所产生数据不一致等问题的共享资源叫做临界资源。
互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用。
生活中常见的例子是银行 ATM 机独立小房间,每次只能进去一个人,进去后锁门,出来前其他人需等待。这与互斥思想相似,只允许一个执行流进入临界区。
原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成。
二、互斥量 mutex
2.1 为什么会出现问题?
以下是一个模拟售票系统的代码示例,四个线程抢票,未使用互斥保护共享资源 ticket:
int ticket = 1000;
void *route(void *arg) {
char *id = (char *)arg;
while (1) {
if (ticket > 0) {
usleep(1000);
printf("%s sells ticket:%d\n", id, ticket--);
} else {
break;
}
}
return (void*)0;
}
int main(void) {
pthread_t t1, t2, t3, t4;
pthread_create(&t1, NULL, route, (void *)"thread 1");
pthread_create(&t2, NULL, route, (void *)"thread 2");
pthread_create(&t3, NULL, route, (void *)"thread 3");
pthread_create(&t4, NULL, route, (void *)"thread 4");
pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_join(t3, NULL);
pthread_join(t4, NULL);
}
执行结果可能出现票数变为负数的情况。原因分析如下:
2.1.1 判断条件
if (ticket > 0) 和 ticket-- 不是原子操作。CPU 执行指令分为取数据、执行指令、返回结果三步。当数据从内存拷贝到寄存器后,属于线程私有上下文。若线程 A 读取了 ticket 值后发生切换,线程 B 也读取并修改了 ticket,线程 A 恢复后基于旧值继续修改,会导致数据竞争。
2.1.2 ticket-- 操作
ticket-- 在汇编层面通常分解为多条指令(如 mov, sub, mov)。如果在这些指令之间发生线程切换,同样会导致数据覆盖或丢失,例如线程 B 将 ticket 减至 0 结束,线程 A 恢复后将 1000 减 1 写回,导致 ticket 变回 999。
2.2 如何解决问题
2.2.1 解决方式
本质是对代码进行保护,不让线程并发式访问共享资源。使用互斥量 mutex 进行三步操作:初始化、加锁解锁、销毁。
1. 初始化互斥量
- 静态分配:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; - 动态分配:
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
2. 销毁互斥量
注意:使用 PTHREAD_MUTEX_INITIALIZER 初始化的不需要销毁;不要销毁已加锁的互斥量;销毁后确保无线程再尝试加锁。
int pthread_mutex_destroy(pthread_mutex_t *mutex);
3. 加锁和解锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
修改后的售票系统代码示例(静态分配):
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
void *route(void *arg) {
char *id = (char *)arg;
while (1) {
pthread_mutex_lock(&mutex);
if (ticket > 0) {
usleep(1000);
printf("%s sells ticket:%d\n", id, ticket--);
} else {
break;
}
pthread_mutex_unlock(&mutex);
}
return (void *)0;
}
动态分配示例:
struct Mutex {
const char *name;
pthread_mutex_t *mutex;
};
void *route(void *arg) {
Mutex *m = (Mutex *)arg;
while (1) {
pthread_mutex_lock(m->mutex);
if (ticket > 0) {
usleep(1000);
printf("%s sells ticket:%d\n", m->name, ticket--);
} else {
break;
}
pthread_mutex_unlock(m->mutex);
}
return (void *)0;
}
int main(void) {
pthread_mutex_t mutex;
pthread_mutex_init(&mutex, NULL);
// ... 创建线程 ...
pthread_mutex_destroy(&mutex);
}
2.2.2 所产生的疑问
- mutex 自身安全? 互斥量底层实现具有原子性。
- 所有线程都要申请锁吗? 是的,所有访问临界资源的代码路径都必须以原子方式(持有锁)执行。
- 进入临界区会被切换吗? 会,但不会引发并发问题,因为锁资源未被释放。
三、互斥实现原理探究
互斥量的实现可从硬件和软件两方面理解。
硬件方案:关闭中断。防止 CPU 接收时钟中断导致线程切换,但这会影响系统整体性能,现代操作系统较少直接使用。
软件方案:利用原子指令。
大多数体系结构提供 swap 或 exchange 指令,作用是把寄存器和内存单元的数据相交换。这是实现互斥的核心。
伪代码逻辑:
- 清空寄存器
%al。 - 通过
exchange指令将%al与内存中的mutex交换。 - 判断
%al的值:若大于 0(原值为 1),说明获取锁成功;否则挂起等待。
Unlock 过程:
- 将 1 写入
mutex中,回收锁资源。 - 唤醒等待的线程。
软件的实现方案就是通过一条原子汇编指令和 swap/exchange 交换来实现互斥。
四、由线程互斥的缺点引出线程同步
互斥可能导致饥饿问题(Starvation)。例如,某个线程竞争力强,反复获得锁,其他线程长期无法执行。
为解决此问题,引入线程同步。规则包括:
- 互斥进入。
- 归还钥匙后不能立即申请。
- 外部人员排队,重新申请者需排到队列尾部。
这保证了临界资源访问的顺序性,避免单一线程独占资源。


