在Linux多线程编程中,线程互斥是一个至关重要的概念。想象一下,当多个线程同时访问同一个银行账户时,如果没有适当的保护机制,账户余额可能会出现严重错误。这就是我们需要互斥机制的根本原因。
共享资源:就像办公室里的公用打印机,任何员工(线程)都可以使用它。在多线程环境中,被多个执行流共同访问的资源(如全局变量、文件描述符等)就是共享资源。
临界资源:不是所有共享资源都需要保护,只有那些在多线程执行过程中可能导致数据不一致的资源才需要特别关注。比如,多个线程同时修改的全局计数器就是典型的临界资源。
临界区:这是访问临界资源的那段代码区域。就像打印机使用规则中"正在打印时其他人不得操作"的部分,临界区是我们需要特别保护的代码段。
互斥机制:确保任何时候只有一个线程能进入临界区。这就像给打印机加了把锁,只有拿到钥匙的人才能使用。
原子性操作:不可分割的操作,要么完全执行,要么完全不执行。比如在银行转账中,"扣款+存款"必须作为一个整体完成,不能只完成一半。
在多线程环境中,线程使用的数据主要分为两类:
| 数据类型 | 存储位置 | 访问特性 | 线程安全性 |
|---|---|---|---|
| 局部变量 | 线程栈空间 | 每个线程独立副本 | 天然安全 |
| 共享变量 | 全局数据区/堆区 | 所有线程可访问 | 需要保护 |
局部变量就像每个员工自己办公桌抽屉里的物品,其他员工无法直接接触。而共享变量则像是办公室公告板,所有人都能看到并能修改上面的内容。
让我们通过一个售票系统的例子来具体看看数据竞争的问题。假设有一个共享变量ticket表示剩余票数,四个线程同时售票:
c复制#include <pthread.h>
int ticket = 100;
void *sell_ticket(void *arg) {
char *id = (char *)arg;
while (1) {
if (ticket > 0) {
usleep(1000); // 模拟售票耗时
printf("%s sells ticket:%d\n", id, ticket);
ticket--;
} else {
break;
}
}
}
运行这个程序,你可能会看到这样的输出:
code复制thread 2 sells ticket:100
thread 3 sells ticket:100
thread 1 sells ticket:100
thread 4 sells ticket:100
thread 2 sells ticket:96
...
thread 1 sells ticket:-1
为什么会出现这种异常情况?让我们从三个层面来解析:
ticket--操作实际上对应三条汇编指令:
asm复制mov 0x2004e3(%rip),%eax # 加载ticket到寄存器
sub $0x1,%eax # 寄存器值减1
mov %eax,0x2004da(%rip) # 存回内存
当多个线程交错执行这三条指令时,就会出现问题。
考虑两个线程同时执行ticket--(假设初始ticket=100):
| 时间 | 线程1 | 线程2 | ticket值 |
|---|---|---|---|
| t1 | mov → eax=100 | - | 100 |
| t2 | sub → eax=99 | - | 100 |
| t3 | - | mov → eax=100 | 100 |
| t4 | - | sub → eax=99 | 100 |
| t5 | mov eax→mem | - | 99 |
| t6 | - | mov eax→mem | 99 |
两个线程各执行了一次减法,但最终ticket只减少了1!
问题代码段:
c复制if (ticket > 0) { // 检查
// ...
ticket--; // 执行
}
这两个操作不是原子的,中间可能被其他线程打断。特别是usleep(1000)人为放大了这个问题窗口。
互斥量就像一把钥匙,只有拿到钥匙的线程才能进入临界区。Linux提供了pthread_mutex_t来实现这一机制。
c复制pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
void *thread_func(void *arg) {
pthread_mutex_lock(&mutex);
// 临界区代码
pthread_mutex_unlock(&mutex);
}
c复制pthread_mutex_t mutex;
pthread_mutex_init(&mutex, NULL);
// 使用...
pthread_mutex_destroy(&mutex);
c复制pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
void *sell_ticket(void *arg) {
char *id = (char *)arg;
while (1) {
pthread_mutex_lock(&lock);
if (ticket > 0) {
usleep(1000);
printf("%s sells ticket:%d\n", id, ticket);
ticket--;
pthread_mutex_unlock(&lock);
} else {
pthread_mutex_unlock(&lock);
break;
}
}
}
现在输出将是正确的:
code复制thread 1 sells ticket:100
thread 1 sells ticket:99
...
thread 4 sells ticket:1
c复制if (pthread_mutex_trylock(&mutex) == 0) {
// 成功获取锁
pthread_mutex_unlock(&mutex);
} else {
// 锁已被占用
}
c复制struct timespec ts;
clock_gettime(CLOCK_REALTIME, &ts);
ts.tv_sec += 2; // 2秒超时
if (pthread_mutex_timedlock(&mutex, &ts) == 0) {
// 成功获取锁
pthread_mutex_unlock(&mutex);
} else {
// 超时或错误
}
现代CPU提供了特殊的原子指令来实现锁机制,最常见的是xchg(交换)指令:
asm复制; 自旋锁实现示例
acquire_lock:
mov eax, 1
xchg eax, [lock_var]
test eax, eax
jnz acquire_lock ; 如果锁已被占用,继续尝试
ret
release_lock:
mov [lock_var], 0
ret
当CPU执行原子指令时:
cpp复制class Mutex {
public:
Mutex() {
if (pthread_mutex_init(&mutex_, nullptr) != 0) {
throw std::runtime_error("mutex init failed");
}
}
void Lock() {
if (pthread_mutex_lock(&mutex_) != 0) {
throw std::runtime_error("lock failed");
}
}
void Unlock() {
if (pthread_mutex_unlock(&mutex_) != 0) {
throw std::runtime_error("unlock failed");
}
}
~Mutex() {
pthread_mutex_destroy(&mutex_);
}
private:
pthread_mutex_t mutex_;
};
cpp复制class LockGuard {
public:
explicit LockGuard(Mutex& mutex) : mutex_(mutex) {
mutex_.Lock();
}
~LockGuard() {
mutex_.Unlock();
}
// 禁止拷贝
LockGuard(const LockGuard&) = delete;
LockGuard& operator=(const LockGuard&) = delete;
private:
Mutex& mutex_;
};
cpp复制Mutex mutex;
int shared_data = 0;
void thread_func() {
LockGuard lock(mutex);
shared_data++;
// 离开作用域自动解锁
}
锁的粒度应该尽可能小,但也要保证必要的原子性。比如:
c复制// 不好:锁住整个复杂操作
pthread_mutex_lock(&lock);
do_complex_operation();
pthread_mutex_unlock(&lock);
// 更好:只锁住必要的部分
do_preparation();
pthread_mutex_lock(&lock);
update_shared_data();
pthread_mutex_unlock(&lock);
do_cleanup();
死锁的四个必要条件:
避免死锁的策略:
当锁竞争激烈时,可以考虑:
症状:程序卡死,线程无法继续执行
排查:
症状:线程卡在第二次加锁处
解决:
症状:多线程程序性能不如单线程
排查:
在实际项目中,我曾遇到一个典型的锁竞争问题:一个日志系统使用全局互斥锁保护所有写操作,当线程数增加到16个时,性能反而下降。通过将日志系统改为每个线程有独立缓冲区,只在刷新时短暂加锁,性能提升了3倍。