[Linux系统编程]多线程—互斥
多线程补充
- 1. 线程互斥
- 1.1 进程线程间的互斥相关背景概念
- 1.2 互斥量mutex
- 1.3 互斥量的接口
- 1.3.1 互斥量的初始化
- 1.3.2 销毁互斥量
- 1.3.3 互斥量实现原理探究
- 1.4 可重入与线程安全
- 1.4.1 线程安全
- 1.4.2 重入
1. 线程互斥
1.1 进程线程间的互斥相关背景概念
✅ 临界资源(Critical Resource)
多线程程序中,多个线程可能共享一些资源(比如一个变量、文件、内存块等)。这些共享资源就叫“临界资源”。
✅ 临界区(Critical Section)
临界区就是访问共享资源的那段代码。
在多线程程序里,如果多个线程都访问同一个共享变量,而这个访问操作又是读写、修改这些可能引发问题的,就属于“临界区”。
✅ 互斥(Mutual Exclusion)
为了解决这个问题,程序必须保证:
同一时刻最多只能有一个线程执行临界区中的代码。
其中详细说说临界区:
🧠 为什么要单独提“临界区”?
因为多个线程同时执行临界区的代码,就可能发生数据错乱、结果不一致、程序崩溃等问题。
所以:
我们不希望多个线程同时进入临界区
要用 互斥锁(mutex) 来保护临界区
举个例子讲清楚
🔴 没加锁的临界区(错误版):
if (ticket > 0) {
printf("Sell ticket %d\n", ticket);
ticket--;
}
这段代码访问了共享变量 ticket,所以它是临界区。
问题来了:
假设两个线程都看到 ticket == 1,都通过了 if 判断,然后都执行 ticket–,那最终就会卖出两张票(变成 ticket = -1),明显是错的。
✅ 加锁的临界区(正确版):
pthread_mutex_lock(&mutex); // 加锁:别人不能进来了
if (ticket > 0) {
printf("Sell ticket %d\n", ticket);
ticket--;
}
pthread_mutex_unlock(&mutex); // 解锁:别人可以进了
现在,我们把访问 ticket 的代码包进互斥锁中,这样任何时刻,只有一个线程能访问这段代码,就不会出问题了。
为什么要实现互斥?
int ticket = 100; // ✅ 共享资源:所有线程共享的票数变量
void *route(void *arg) {
char *id = (char*)arg;
while (1) {
if (ticket > 0) { // 🟡 这是判断临界资源状态
usleep(1000); // 💤 模拟业务处理,容易引发线程切换(并发问题)usleep功能把进程挂起一段时间
printf("%s sells ticket:%d\n", id, ticket);
ticket--; // 🔥 临界区(非原子操作)
} else {
break;
}
}
}
💥 多个线程如果“同时”执行 ticket–,就可能出现 数据竞争:
例子:
thread 1 和 thread 2 都看到 ticket == 1
都通过了 if (ticket > 0)
然后都执行了 ticket–
最终可能 ticket == -1 ❌,明明只有 1 张票,卖了两张
usleep(1000) 增加了出错几率(模拟现实)
它模拟的是一个“耗时操作”(比如打印票据、联系数据库)
这段代码一执行,线程就可能“被操作系统切换出去”
如果这时另一个线程也进来了,就会两个线程一起进入临界区
❗️总结这个问题:为什么不安全?
这段代码没有使用互斥锁保护临界区,所以:
多线程访问共享变量 ticket,会产生 竞态条件(竞态条件指两个或多个线程试图对共享资源进行操作时,由于线程执行的先后顺序不确定,导致程序的运行结果不可预测或错误的情况。 )非原子操作 ticket-- 在高并发下就会引发错误
usleep() 增加了被中断的概率,让问题更容易复现
结果就是:多卖、错卖、甚至负数票都可能出现
✅ 正确的做法:加互斥锁
1.2 互斥量mutex
🧱 什么是互斥量(mutex)?
互斥量是一种线程同步机制,用于控制对共享资源的访问。确保每次只有一个线程进入临界区。
💡 使用步骤
初始化 mutex
在进入临界区前 pthread_mutex_lock(&mutex);
在退出临界区后 pthread_mutex_unlock(&mutex);
最后销毁 pthread_mutex_destroy(&mutex);
可以用 pthread_mutex_t 来保护 ticket-- 部分,像这样:
// 多线程卖票 + 互斥锁控制
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
✅ 声明并初始化一个互斥锁(mutex)。
这把“锁”会保护我们共享的资源:ticket。
你也可以使用 pthread_mutex_init() 动态初始化,不过这里用宏更方便。
线程执行的函数route()如下。传进来的arg是线程的名字
(比如 "thread 1"、"thread 2")用于打印区分。
void *route(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); // 🔓 解锁,退出临界区
💥 这块代码是关键的临界区代码
多个线程不能同时执行,否则 ticket-- 会乱!
} else {
pthread_mutex_unlock(&lock); // 🔓 别忘了解锁
break;
}
}
}
pthread_mutex_lock
它是 Linux 多线程编程中用于“加锁”的函数,来自 POSIX 线程库(pthread)。
int pthread_mutex_lock(pthread_mutex_t *mutex);
mutex:指向互斥锁的指针(在你的例子中是 &lock)。
返回值:
成功返回 0
失败返回错误码(比如死锁、锁已损坏等)
✅ 它做了什么事情?
当一个线程执行到 pthread_mutex_lock(&lock) 时:
如果这把锁是“空闲的”(没有其他线程持有):
当前线程立即获得这把锁,进入临界区。
如果这把锁已经被其他线程持有:
当前线程会阻塞等待,直到锁被释放。
🧠 它就像“上锁进入一个只能一个人进去的小房间”,别人只能在门外等着。
其中ticket–操作:
– 操作并不是原子操作,而是对应三条汇编指令:
load :将共享变量ticket从内存加载到寄存器中
update : 更新寄存器里面的值,执行-1操作
store :将新值,从寄存器写回共享变量ticket的内存地址
1.3 互斥量的接口
1.3.1 互斥量的初始化
互斥量(mutex)是用于多线程编程中控制对共享资源访问的工具,避免多个线程同时访问同一资源,导致数据不一致的情况。
方法1:静态分配
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
这是最简单的一种方式,在声明互斥量时直接进行初始化。PTHREAD_MUTEX_INITIALIZER 是一个常量,它初始化一个互斥量为默认状态,适合简单的场景。
方法2:动态分配
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
这种方法允许你在运行时初始化互斥量,可以为互斥量设置特定的属性。常见的属性包括锁的类型(如普通锁、递归锁等)。如果不需要特殊属性,可以将 attr 设置为 NULL,这样就会使用默认的属性。
mutex:指向互斥量的指针。
attr:指向互斥量属性的指针。如果不需要特别的属性,通常传入 NULL。
生活中的例子: 你可以把互斥量初始化比作准备好一个门。方法1是直接从厂家购买一个已经准备好的门,而方法2则是根据需要自己选择门的大小、样式等属性。
1.3.2 销毁互斥量
销毁互斥量是为了释放资源,但销毁时有一些注意事项。
int pthread_mutex_destroy(pthread_mutex_t *mutex);
重要注意事项:
如果使用 PTHREAD_MUTEX_INITIALIZER 初始化的互斥量,不需要调用 pthread_mutex_destroy(),因为它是静态初始化的,生命周期由操作系统管理。
不要销毁已经加锁的互斥量,否则会导致未定义行为。
确保不会在互斥量被销毁后继续加锁。
生活中的例子: 销毁互斥量就像是把门从房间里移走。你不能在别人还在门里时把门拆掉,否则会影响他们的使用。
互斥量加锁和解锁
加锁:pthread_mutex_lock
int pthread_mutex_lock(pthread_mutex_t *mutex);
这个函数用来给互斥量上锁,确保当前线程进入临界区时,其他线程无法进入。
如果当前互斥量没有被锁住,线程会成功加锁,进入临界区。
如果互斥量已经被其他线程加锁,调用这个函数的线程将会被阻塞,直到锁被释放。
解锁:pthread_mutex_unlock
int pthread_mutex_unlock(pthread_mutex_t *mutex);
当线程不再需要访问共享资源时,应该解锁互斥量,以便其他线程可以获得访问权。
生活中的例子: 假设你和朋友在玩一个游戏,需要共享一个遥控器。你们设定了一个规则:每次只有一个人能控制遥控器。
加锁:当你拿到遥控器时,你会锁住遥控器,其他朋友不能同时拿。其他朋友必须等你玩完才行。
解锁:当你完成你的操作后,释放遥控器,让其他朋友可以使用。
调用 pthread_mutex_lock 时的情况:
当一个线程调用 pthread_mutex_lock 时,可能会遇到以下两种情况:
互斥量为空闲:当前线程获得锁,进入临界区,继续执行。
互斥量已被其他线程锁住:当前线程会被阻塞,直到其他线程释放锁。
1.3.3 互斥量实现原理探究
在多线程程序中,如果多个线程访问共享资源时没有合适的同步机制,就容易导致数据一致性问题。例如,在没有锁的情况下,多个线程同时执行 ticket-- 这样的操作,就会导致票数不准确,因为每个线程对 ticket 的修改操作可能会发生在相同的时刻,从而互相覆盖,造成丢失更新。为了避免这种问题,我们需要使用互斥锁。
xchg(交换)指令是大多数体系结构提供的一种原子操作,它可以将寄存器的值与内存中的值交换,并且这个操作是不可中断的,也就是说它在多处理器平台上是线程安全的,能够确保交换操作的原子性。
lock操作:
lock :
movb $0, mutex ; 将0(表示锁未被占用)存入 mutex
xchgb %al, mutex ; 将寄存器 %al 的值与 mutex 的值交换
if (al寄存器的内容 > 0) {
return 0; ; 如果原本 mutex 的值大于0,说明锁已经被占用,返回
} else {
挂起等待; ; 否则,说明 mutex 的值为 0,锁已成功获取,继续执行
goto lock; ; 如果锁被其他线程占用,则继续等待
}
通过 xchg(或 exchange)指令,锁的操作变得原子化,不会被中断,保证了多线程环境中的同步性。mutex 的状态只有在交换操作完成后才能被更改,这样即使有多个处理器或线程在同一时刻访问 mutex,它们也会被有效地同步,确保一次只有一个线程可以进入临界区。
这种方法通过硬件指令提供了高效的锁机制,并且由于操作的原子性,避免了线程间的竞态条件。
1.4 可重入与线程安全
1.4.1 线程安全
线程安全是指多个线程并发访问同一段代码时,不会出现数据竞态或不一致的情况,最终结果是正确的。在多线程环境中,如果没有适当的同步机制(如互斥锁、条件变量等),多个线程同时访问共享资源时,可能会导致数据损坏或不一致。
举个例子:
假设有多个线程同时对一个全局变量 counter 进行加1操作:
int counter = 0;
void increment() {
counter++; // 非线程安全
}
如果不做任何保护,多个线程同时执行 counter++,可能会导致 counter 的值丢失,出现错误的结果。这就是 线程不安全 的一个例子。
为了使其 线程安全,我们可以使用互斥锁来保证每次只有一个线程可以访问 counter:
int counter = 0;
pthread_mutex_t mutex;
void increment() {
pthread_mutex_lock(&mutex);
counter++; // 现在是线程安全的
pthread_mutex_unlock(&mutex);
}
这样就确保了每次只有一个线程能修改 counter,防止了数据竞争问题。
1.4.2 重入
重入性 是指同一个函数在不同的执行流中被再次调用,且不会因为前一个调用尚未结束,导致执行结果不一致或出现其他问题。如果一个函数在重入的情况下能正确工作,说明该函数是可重入的。如果不能正确工作,那么它就是不可重入的。
举个例子:
假设我们有一个递归函数:
void foo(int n) {
if (n == 0)
return;
foo(n-1);
}
这个函数是 可重入的,因为它每次调用自己都会在不同的栈帧中执行,互不干扰。而 不可重入的 函数可能依赖于共享资源或全局变量,在多个执行流进入时,会因为数据被破坏而导致问题。
线程安全与重入性之间的区别与联系
线程安全与重入性:
线程安全的函数 是指该函数在多线程环境下能够正确工作,多个线程并发访问时,不会出现数据竞争问题。
可重入的函数 是指同一个函数在多个执行流(例如,递归或多线程环境)中被多次调用时,每次调用都能正确工作,不会因为其他调用的存在而出错。
联系:
可重入函数一定是线程安全的。如果一个函数可以在多个执行流中被调用,而不出现数据损坏或状态冲突,那它肯定是线程安全的。因为多线程中的每个线程相当于一个独立的执行流,如果函数是可重入的,那么它不会被其他线程的执行影响,因此也不会引发线程安全问题。
线程安全的函数不一定是可重入的。即使一个函数在多线程环境中使用锁来保证线程安全,但如果它不是设计为可重入的,那么可能会出现死锁问题。例如,如果一个线程在执行该函数时持有锁并进入了该函数的某个部分,而同一线程又因为某种原因在函数内部的另一部分重入该函数,那么就可能引发死锁。这种情况下,这个函数虽然是线程安全的,但却是不可重入的。
不可重入的情况:
调用 malloc/free 等函数: malloc 和 free 是用全局链表来管理内存的,它们的实现通常依赖于全局变量,因此如果多个线程在并发调用 malloc 或 free 时,会导致数据结构的损坏,导致内存泄漏或重复释放内存等问题。
标准 I/O 库函数: 标准 I/O 库(如 printf、scanf)的很多实现依赖于全局静态变量,因此它们在多线程环境下是不可重入的。如果多个线程同时调用这些函数,可能会干扰彼此,导致输出错乱。
可重入的情况:
没有全局变量或静态变量的函数
: 如果一个函数内部只使用局部变量,并且这些变量的数据在每次调用时都是独立的,那么这个函数是可重入的。例如:
void add(int a, int b) {
int result = a + b;
printf("%d\n", result);
}
这个函数是可重入的,因为它的执行不依赖任何全局或静态变量,每个线程或递归调用都会独立地完成自己的任务。
不调用不可重入的函数: 如果函数内部没有调用 malloc、free 或其他不可重入的函数,且没有共享全局数据,那么它就是可重入的。例如:
int multiply(int a, int b) {
return a * b;
}