Linux系统:多线程编程中的数据不一致问题与线程互斥理论
目录
一、相关背景概念
1.1 临界资源与临界区
1.2 原子性
二、数据不一致问题
三、Linux中的锁机制
3.1 POSIX库实现锁机制
pthread_mutex_init
pthread_mutex_destroy
pthread_mutex_lock
pthread_mutex_unlock
3.2 锁机制的底层原理
3.2.1 申请锁
3.2.2 释放锁
四、锁的封装
一、相关背景概念
1.1 临界资源与临界区
临界资源是指多个线程(或进程)在并发执行时可能共同访问的共享资源。临界区是指线程(或进程)中访问临界资源的那段代码片段。简单说,就是 “操作临界资源的代码”。
比如在这段代码中:
int ticket=10000;
void* routine(void* args)
{char* name=static_cast<char*>(args);//一个模拟抢票的代码片段:usleep(10000);while(1){if ( ticket > 0 ) {usleep(1000);printf("%s sells ticket:%d\n", name, ticket);ticket--;} else {break;}}return nullptr;
}
int main()
{std::vector<pthread_t> arr;int num=10;for(int i=0;i<num;i++){pthread_t tid;int ret=pthread_create(&tid,nullptr,routine,nullptr);arr.push_back(tid);}for(int i=0;i<10;i++){pthread_join(arr[i],nullptr);}return 0;
}
首先我们创建了一个全局变量也就是ticket,在main函数中我们创建10个线程对ticket进行修改,那么在这段代码中临界区与临界资源是这样划分的:
简单来讲多线程执行流共享的资源就叫做临界资源,每个线程内部,访问临界资源的代码,就叫做临界区
1.2 原子性
在多线程中,原子性指的是一个操作(或一组操作)在执行过程中不可被中断,要么完全执行完毕,要么完全不执行,中间不会被其他线程的操作插入或干扰。
线程是调度的基本单位,每个线程都有自己的时间片。当系统从内核态转换到用户态的时候会检查当前线程的时间片如果时间片耗尽就会开始保存上下文并将该线程从cpu上拿下来链入等待队列中再次等待调度执行。
原子性指的是,在该线程执行某一操作的时候不可被打断(比如正在执行时突然被系统调度导致操作并未执行完全)。因为编程语言中的每一个语句都是由大量的汇编代码组成的所以在现阶段我们可以认为一句汇编代码就是“原子性”的,是不可被打断的。在之后的学习中我们可以通过锁机制实现代码段的原子性。
原子性是多线程安全的核心保障之一,它确保操作的 “不可分割性”,避免因并发中断导致的数据不一致。实际开发中,需根据场景选择硬件指令、锁或原子类来实现原子性。
二、数据不一致问题
上一章我们讲了线程的概念与POSIX线程库实现线程控制以及多线程的创建方法。在多线程也就是多执行流的程序中一个不可避免的问题就是多线程并发访问共享资源时产生的数据不一致或者错误问题。下面我们来看一段代码:
int ticket=10000;
void* routine(void* args)
{char* name=static_cast<char*>(args);//一个模拟抢票的代码片段:usleep(10000);while(1){if ( ticket > 0 ) {usleep(1000);printf("%s sells ticket:%d\n", name, ticket);ticket--;} else {break;}}return nullptr;
}int main()
{std::vector<pthread_t> arr;int num=10;for(int i=0;i<num;i++){pthread_t tid;char* name=(char*)malloc(64);snprintf(name,64,"thread-%d",i);int ret=pthread_create(&tid,nullptr,routine,name);if(ret!=0){std::cout<<"pthread_create fail!"<<std::endl;exit(-1);}arr.push_back(tid);}for(int i=0;i<10;i++){pthread_join(arr[i],nullptr);}return 0;
}
运行结果:
在这段代码演示中,我们首先创建了10个线程来抢10000张票也就是对同一个全局变量进行修改。当一个线程抢到票后就将ticket变量-1并打印当前剩余的票数。通过最后的运行结果我们发现10个线程将ticket变量一直修改成了负数,在现实情况下这种行为是不合适的。这就是多线程并发下的数据不一致问题的典型例子。
为什么会导致这种问题呢?下面我们从两个方面来分析一下这个问题:
1. 线程调度导致多个线程同时进入临界区
观察代码我们发现 ticket>0 是进程进入临界区对临界资源进行操作的首要条件,但是线程调度等问题可能会使多个执行流进入临界区导致临界资源被“过度修改”。
举个例子,如果某一时刻ticket的值为1线程a进入临界区后还未来得及操作因为时间片耗尽导致被调度,此时线程b看到ticket还为1也进入临界区,并对ticket成功修改此时ticket为0,b线程结束后a线程被唤醒因为a已经进入了临界区中所以直接对ticket又进行修改将ticket变为负数。
1. --操作并不是原子操作
--操作的非原子性也会导致一个新的隐患:
当我们的编程语言交给计算机CPU识别并执行的时候首先会转换为汇编代码,这时因为汇编代码直接对应机器指令(CPU 可执行的二进制指令)操作直接面向 CPU 寄存器、内存地址、硬件端口等。这里的--操作通常对应以下汇编语句:
152 40064b: 8b 05 e3 04 20 00 mov 0x2004e3(%rip),%eax # 600b34 <ticket>
153 400651: 83 e8 01 sub $0x1,%eax
154 400654: 89 05 da 04 20 00 mov %eax,0x2004da(%rip) # 600b34 <ticket>
-
第 152 行:
mov 0x2004e3(%rip),%eax
- 功能:读取全局变量
ticket
的值到寄存器eax
中。 - 细节:
0x2004e3(%rip)
是RIP 相对寻址(RIP 是当前指令指针寄存器,存储下一条指令的地址)。通过计算当前地址(40064b
)+ 偏移量(0x2004e3
),得到ticket
的内存地址600b34
(注释已标注)。 - 效果:
eax = ticket
。
- 功能:读取全局变量
-
第 153 行:
sub $0x1,%eax
- 功能:对寄存器
eax
的值减 1。 - 效果:
eax = eax - 1
(即eax = ticket - 1
)。
- 功能:对寄存器
-
第 154 行:
mov %eax,0x2004da(%rip)
- 功能:将寄存器
eax
的新值写回全局变量ticket
的内存地址。 - 细节:同样使用 RIP 相对寻址,计算得到目标地址为
600b34
(与ticket
地址一致)。 - 效果:
ticket = eax
(即ticket
被更新为减 1 后的值)。
- 功能:将寄存器
总而言之,--操作的汇编代码主要表示了三个操作步骤:
- load:将共享变量ticket从内存加载到寄存器中
- update:更新寄存器里面的值,执行-1操作
- store:将新值,从寄存器写回共享变量ticket的内存地址
那么非原子性会对我们的程序带来哪些隐患呢?举个例子,如果某一时刻ticket=100
线程a共享变量ticket==100从内存加载到寄存器中后因为时间片耗尽突然被调度;
除a之外的线程成功执行了--操作将ticket修改成了1;
线程b成功执行了--操作将ticket修改成了1;
最后线程a被调度执行会将100-1将ticket重新修改为99并从寄存器写回内存,此时就会导致前面线程的操作功亏一篑,出现数据不一致问题。
总而言之,导致数据不一致问题的根源在于临界资源的并发访问与操作的非原子性。要解决这个问题我们就必须从这两方面着手,这就意味着我们必须做到:
- 保证操作的原子性
- 避免多线程并发访问临界资源,当线程进入临界区执行时,不允许其他线程进入该临界区。
要做到这三点,本质上就是需要⼀把锁。Linux上提供的这把锁叫互斥量。
三、Linux中的锁机制
在 Linux 中,互斥锁(Mutex,Mutual Exclusion)是解决并发场景下共享资源竞争的核心机制,其核心目标是确保同一时间只有一个线程(或进程)能访问临界资源,从而避免数据不一致或竞态条件。
3.1 POSIX库实现锁机制
pthread_mutex_init
动态初始化一个互斥锁对象,配置锁的属性(如类型、共享范围等)。
#include <pthread.h>
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
参数:
mutex
:输出参数,指向未初始化的pthread_mutex_t
对象的指针,初始化后该对象可用于加锁 / 解锁操作。attr
:输入参数,指向pthread_mutexattr_t
属性对象的指针(可通过pthread_mutexattr_init
初始化),用于指定锁的类型。若为NULL
,则使用默认属性(通常等价于PTHREAD_MUTEX_NORMAL
)。
返回值:
- 成功:返回
0
。 - 失败:返回非 0 错误码(如
ENOMEM
:内存不足;EPERM
:无权限初始化锁)。
代码实例:
#include <pthread.h>
#include <stdio.h>pthread_mutex_t mutex; // 定义互斥锁对象
pthread_mutexattr_t attr; // 定义属性对象int main() {// 1. 初始化属性对象int ret = pthread_mutexattr_init(&attr);if (ret != 0) {printf("属性初始化失败,错误码:%d\n", ret);return 1;}// 2. 设置锁类型为递归锁(允许同一线程多次加锁)ret = pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE);if (ret != 0) {printf("设置锁类型失败,错误码:%d\n", ret);return 1;}// 3. 用属性初始化互斥锁ret = pthread_mutex_init(&mutex, &attr);if (ret != 0) {printf("锁初始化失败,错误码:%d\n", ret);return 1;}// 4. 销毁属性对象(初始化后不再需要)pthread_mutexattr_destroy(&attr);return 0;
}
pthread_mutex_destroy
释放互斥锁占用的资源(如动态分配的内部数据结构),销毁后锁对象不可再使用。
int pthread_mutex_destroy(pthread_mutex_t *mutex);
参数
-
mutex
:输入参数,指向已初始化的pthread_mutex_t
对象的指针。
返回值
-
成功:返回
0
。 -
失败:返回非 0 错误码(如
EBUSY
:锁正被线程持有;EINVAL
:锁未初始化或已销毁)。
代码实例:
#include <pthread.h>
#include <stdio.h>int main() {pthread_mutex_t mutex;// 动态初始化锁(必须destroy)int ret = pthread_mutex_init(&mutex, NULL);if (ret != 0) {printf("锁初始化失败\n");return 1;}// 加锁(模拟临界区使用)pthread_mutex_lock(&mutex);// 错误演示:尝试销毁被持有的锁(应返回EBUSY)ret = pthread_mutex_destroy(&mutex);if (ret == EBUSY) {printf("销毁失败:锁正被持有\n");} else {printf("意外结果,错误码:%d\n", ret);}// 正确流程:先解锁,再销毁pthread_mutex_unlock(&mutex);ret = pthread_mutex_destroy(&mutex);if (ret == 0) {printf("锁销毁成功\n");} else {printf("销毁失败,错误码:%d\n", ret);}return 0;
}
这里需要注意的是,静态初始化的全局锁不需要显式调用pthread_mutex_destroy
销毁,原因是会导致未定义行为。
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; // 静态初始化锁
在多数libpthread
(如 glibc)中:对静态初始化的锁调用pthread_mutex_destroy
会被忽略(返回0
),因此代码会输出"锁销毁成功"
。原因是静态初始化的锁没有动态分配资源,destroy
操作无实际工作可做,直接返回成功。在严格的 POSIX 实现或特殊环境中:可能返回EINVAL
(错误码,含义 “锁未正确初始化或不支持销毁”),此时会输出"销毁失败:锁未初始化"
。
pthread_mutex_lock
获取互斥锁,若锁未被持有则立即返回;若已被持有,当前线程进入阻塞状态(睡眠),直到锁被释放并成功获取。
int pthread_mutex_lock(pthread_mutex_t *mutex);
参数
-
mutex
:输入参数,指向已初始化的pthread_mutex_t
对象的指针。
返回值
-
成功:返回
0
(已获取锁)。 -
失败:返回非 0 错误码(如
EDEADLK
:对PTHREAD_MUTEX_ERRORCHECK
类型的锁,同一线程重复加锁;EINVAL
:锁未初始化)。
#include <pthread.h>
#include <stdio.h>pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
int shared_data = 0; // 共享资源void *thread_func(void *arg) {// 加锁(若锁被占用则阻塞等待)int ret = pthread_mutex_lock(&mutex);if (ret != 0) {printf("加锁失败,错误码:%d\n", ret);return NULL;}// 临界区:操作共享资源shared_data++;printf("线程%d:shared_data = %d\n", *(int*)arg, shared_data);// 解锁pthread_mutex_unlock(&mutex);return NULL;
}int main() {pthread_t tid1, tid2;int id1 = 1, id2 = 2;pthread_create(&tid1, NULL, thread_func, &id1);pthread_create(&tid2, NULL, thread_func, &id2);pthread_join(tid1, NULL);pthread_join(tid2, NULL);return 0;
}
pthread_mutex_unlock
释放互斥锁,若有等待线程,唤醒其中一个(通常按 FIFO 顺序)。
int pthread_mutex_unlock(pthread_mutex_t *mutex);
参数
-
mutex
:输入参数,指向已初始化且被当前线程持有的pthread_mutex_t
对象的指针。
返回值
-
成功:返回
0
。 -
失败:返回非 0 错误码(如
EPERM
:当前线程未持有锁;EINVAL
:锁未初始化)。
#include <pthread.h>
#include <stdio.h>pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;void *thread_func(void *arg) {pthread_mutex_lock(&mutex);printf("线程%d:获取锁\n", *(int*)arg);// 模拟临界区操作sleep(2);// 解锁int ret = pthread_mutex_unlock(&mutex);if (ret != 0) {if (ret == EPERM) {printf("线程%d:解锁失败,未持有锁\n", *(int*)arg);}} else {printf("线程%d:释放锁\n", *(int*)arg);}return NULL;
}int main() {pthread_t tid;int id = 1;pthread_create(&tid, NULL, thread_func, &id);pthread_join(tid, NULL);// 错误示例:解锁未持有的锁int ret = pthread_mutex_unlock(&mutex);if (ret == EPERM) {printf("主线程:解锁失败,未持有锁\n");}return 0;
}
3.2 锁机制的底层原理
3.2.1 申请锁
在之前进程切换的章节我们讲过,CPU中的寄存器只有一套但是寄存器中的数据可以有多份,每个进程各自一份这些数据就是当前执行流的上下文。换句话说,将内存中的一个变量的内容加载到寄存器内部本质上就是将该变量的内容获取到当前执行流的硬件上下文中。
每个执行流的硬件上下文是各自私有的,彼此不可见的。
理解这一点后我们再来解释一下互斥锁的底层机制:
我们可以将锁理解为一个标志位1,当一个执行流申请锁时会首先将CPU中的%al寄存器清零,然后通过swap或exchange指令将标志位1与%al寄存器中的0进行交换,然后判断%al寄存器中的值是否为1,如果为1则表示申请锁成功了就会返回然后执行后续临界区中的代码;如果持有锁的执行流被调度,另一个执行进入CPU会重新申请锁此时它也会将CPU中的%al寄存器清零,然后通过swap或exchange指令将标志位与%al寄存器中的0进行交换,但是此时标志位被前者交换过了已经是0其只能0与0交换然后被判断为未持有锁然后被挂起,直到锁被释放。
因为swap或exchange指令是交换而不是拷贝这样就保证了内存中的锁永远只有一份,哪个执行流的上下文中%al寄存器的值为1就表示哪个执行流持有锁。
3.2.2 释放锁
当我们调用unlock释放锁时本质上就是将mutex对应的内存空间的标志位置为1然后唤醒之前被挂起的执行流go to lock重新竞争锁,对应的汇编伪代码可以如下表示:
四、锁的封装
#include <iostream>
#include <pthread.h>class Mutex
{
public:// 去掉不要的赋值与拷贝Mutex(const Mutex &) = delete;const Mutex &operator=(const Mutex &) = delete;Mutex(){pthread_mutex_init(&tid, NULL);}~Mutex(){pthread_mutex_destroy(&tid);}void lock(){pthread_mutex_lock(&tid);}void unlock(){pthread_mutex_unlock(&tid);}pthread_mutex_t *GetmMutexOriginal(){return &tid;}private:pthread_mutex_t tid;
};class LockGuard
{
public:LockGuard(Mutex &mutex) : _mutex(mutex){_mutex.lock();}~LockGuard(){_mutex.unlock();}private:Mutex &_mutex;
};