学习互斥锁前言
在上面这篇文章中,我们已经介绍过了关于线程的一些简单东西,比如线程的特性,线程之间的共享资源有哪些不共享资源有哪些,如果对这一部分简单知识不清楚的同学,我的建议是回看上面我的这篇文章再来熟悉一下。在前几篇文章中我们已经学习了线程中的一些基本的API,比如线程的创建,线程销毁,线程接合,线程设置属性之类的。我们后面将要学习的是线程之间竞争共享资源的控制,其实也就是线程间的同步互斥。
静态变量和全局变量是线程间共享的数据,它们可以用于存储线程需要共享的信息。不像进程那样,这里不需要linux操作系统额外为我们维护一些资源(共享内存,消息队列等),来实现进程间的通信。
同步机制(如互斥锁、条件变量和读写锁)用于保护这些共享资源,确保线程安全地访问和修改它们,实现同步互斥的效果。互斥锁,条件变量以及读写锁都有着linux系统为我们提供出来的现成的API,并不需要我们进行手动实现。
不知道你会不会有这样的疑问,就是既然线程之间拥有共享资源,能不能使用,比如静态变量,全局变量这类来实现线程间的同步互斥呢?答案是:不行
虽然共享变量可以作为状态标志,但直接通过它们来控制线程同步是不推荐的,因为非常容易出错。应该使用专门的同步机制来实现线程间的协调,因为使用这些简单变量进行标识,对变量的自增,在多线程环境下不是原子操作,多个线程同时修改共享变量可能导致数据不一致。些同步机制由操作系统提供,经过精心设计和测试,能够确保线程安全地访问共享资源。所以不要想了!!!开发者应该使用这些接口来实现线程同步,而不是尝试自己实现原子操作。
在Linux线程编程中,应该始终使用专门的同步机制(互斥锁,读写锁,条件变量)来管理共享资源的访问!!!
2025/3/4学习了学校老师讲的操作系统课程,有了新的体会,特地来此补充。主要是想要深层次解释一下为什么使用自己定义的变量不能确保原子性,以及为什么使用系统提供的信号量,互斥锁,读写锁可以来实现原子性,更可靠。补充的内容在文章的最后部分,感兴趣的朋友可以移步观看,相信看完后你对操作系统将会有更加清晰的认知!!!
要求掌握的程度
- 接下来我们将开始学习互斥锁,读写锁,条件变量。主要是学会对应的api接口就行了,也不用熟练掌握每个API函数的细节,知道个大概就行了。
- 需要明白的是每种控制方式的使用情景,使用流程,大致api有哪些就完全OK。实际中用的时候来查阅具体API的小细节就可以
互斥锁
互斥锁介绍
使得多线程间互斥运行的最简单办法,就是增加一个互斥锁。任何一条线程要开始运行临界区的代码,都必须先获取互斥锁。互斥锁的行为类似于一个二值信号量,因此当其中一条线程抢先获取了互斥锁之后,其余线程就无法再次获取了,效果相当于给相关的资源加了把锁,直到使用者主动解锁,其余线程方可有机会获取这把锁。
互斥锁本质上可以看作是一个特殊的变量,但它的特殊之处在于Linux系统提供的函数接口保证了对其操作的原子性,我们简单地使用全局变量进行标识的话,不能保证操作的原子性。
互斥锁的使用流程
- 初始化互斥锁: 使用
pthread_mutex_init()初始化
互斥锁。 - 锁定互斥锁: 在访问共享资源之前,使用
pthread_mutex_lock()
锁定互斥锁。 - 访问共享资源: 在互斥锁的保护下,安全地访问共享资源。
- 解锁互斥锁: 在完成共享资源访问后,使用
pthread_mutex_unlock()
解锁互斥锁。 - 销毁互斥锁: 在互斥锁不再使用时,使用
pthread_mutex_destroy()
销毁它。
pthread_mutex_init():初始化互斥锁
函数作用:初始化一个互斥锁。
函数原型:
int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);
参数:
- mutex:指向要初始化的互斥锁变量的指针。
- attr:互斥锁属性,通常设置为NULL表示使用默认属性。
返回值:
成功返回0,失败返回错误码。
注意:
- 为了让多个线程都能使用同一个互斥锁,互斥锁变量必须位于线程可以共享访问的内存区域,比如全局变量静态变量或者堆上,互斥锁变量的类型为pthread_mutex_t。
- 使用互斥锁的第一步必须是初始化互斥锁,不初始化可能会出现未定义情况。
- 初始化之后的互斥锁处于开放(未锁定)状态,任何线程都可以尝试去“拿起”它(即锁定)
- 通常来说pthread_mutex_init函数的第二个参数attr我们会设置为NULL,此时会创建一个默认属性的互斥锁,完全已经够用。
pthread_mutex_lock():获取互斥锁
函数作用:获取一个互斥锁。
函数原型:
int pthread_mutex_lock(pthread_mutex_t *mutex)
参数:
mutex:指向要获取的互斥锁变量的指针。
返回值:
成功返回0,失败返回错误码。
注意:
- 如果互斥锁当前未被获取,调用线程将立即获得锁。
- 如果互斥锁已被其他线程获取,调用线程将被阻塞,直到互斥锁可用。
pthread_mutex_trylock():尝试获取互斥锁
函数作用:尝试获取一个互斥锁,如果互斥锁已被获取,则立即返回错误。
函数原型:
int pthread_mutex_trylock(pthread_mutex_t *mutex);
参数:
mutex:指向尝试要获取的互斥锁变量的指针。
返回值:
- 成功获取返回0。
- 如果互斥锁已被获取,返回EBUSY。
- 其他错误返回相应的错误码。
注意:
pthread_mutex_trylock()是非阻塞的,它不会使调用线程进入等待状态。
这个函数在需要非阻塞的获取操作时很有用。
pthread_mutex_unlock():解锁互斥锁
函数作用:解锁一个互斥锁。
函数原型:
int pthread_mutex_unlock(pthread_mutex_t *mutex);
参数:
mutex:指向要解锁的互斥锁变量的指针。
返回值:
成功返回0,失败返回错误码。
注意:
解锁互斥锁后,其他等待该互斥锁的线程可以获得锁。
只有持有互斥锁的线程才能解锁它。
pthread_mutex_destroy():销毁互斥锁
函数作用:销毁一个互斥锁。
函数原型:
int pthread_mutex_destroy(pthread_mutex_t *mutex);
参数:
mutex:指向要销毁的互斥锁变量的指针。
返回值:
成功返回0,失败返回错误码。
注意:
- 在互斥锁不再使用时,应该销毁它以释放资源。
- 只有未被任何线程锁定的互斥锁才能被销毁。
互斥锁使用示例
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
#define NUM_THREADS 5
// 共享资源
int shared_resource = 0;
// 互斥锁
pthread_mutex_t mutex;
// 线程函数
void *thread_function(void *arg) {
int thread_num = *(int *)arg;
// 锁定互斥锁
pthread_mutex_lock(&mutex);
// 访问共享资源
printf("Thread %d: Accessing shared resource, current value: %d\n",
thread_num, shared_resource);
shared_resource++;
sleep(1); // 模拟耗时操作
printf("Thread %d: Updated shared resource to: %d\n",
thread_num, shared_resource);
// 解锁互斥锁
pthread_mutex_unlock(&mutex);
pthread_exit(NULL);
}
int main() {
pthread_t threads[NUM_THREADS];
int thread_num[NUM_THREADS];
// 初始化互斥锁
if (pthread_mutex_init(&mutex, NULL) != 0) {
perror("pthread_mutex_init");
exit(EXIT_FAILURE);
}
// 创建线程
for (int i = 0; i < NUM_THREADS; i++) {
thread_num[i] = i;
if (pthread_create(&threads[i], NULL, thread_function, &thread_num[i])
!= 0) {
perror("pthread_create");
exit(EXIT_FAILURE);
}
}
// 等待线程结束
for (int i = 0; i < NUM_THREADS; i++) {
pthread_join(threads[i], NULL);
}
// 销毁互斥锁
if (pthread_mutex_destroy(&mutex) != 0) {
perror("pthread_mutex_destroy");
exit(EXIT_FAILURE);
}
printf("Shared resource final value: %d\n", shared_resource);
return 0;
}
代码解释
定义共享资源和互斥锁:
shared_resource
:一个整数变量,作为共享资源。mutex
:一个pthread_mutex_t
类型的变量,用于互斥锁。
线程函数thread_function()
:
- 每个线程执行这个函数。
- 首先,使用
pthread_mutex_lock()
锁定互斥锁。 - 然后,访问和修改共享资源
shared_resource
。 - 使用
sleep()
模拟耗时操作,以便观察互斥锁的效果。 - 最后,使用
pthread_mutex_unlock()
解锁互斥锁。
main()
函数:
- 初始化互斥锁:使用
pthread_mutex_init()
初始化互斥锁。 - 创建线程:创建多个线程,并传递线程ID作为参数。
- 等待线程结束:使用
pthread_join()
等待所有线程结束。 - 销毁互斥锁:使用
pthread_mutex_destroy()
销毁互斥锁。 - 打印共享资源的最终值。
代码结果解释
观察上面代码的运行结果我们可以发现每个线程在两次输出是紧紧靠在一起输出的,不会出现就是一个线程输出读取数据之后,另一个线程也随之输出读取的数据。因为我们上面编写程序的逻辑是:线程只有拿到互斥锁后才能访问这个公共资源然后对其进行修改,没拿到互斥锁的线程会被阻塞在pthread_mutex_lock()函数,直到有进程解锁互斥锁。
关于用户态,内核态,原子指令,原子性的补充
首先我们先了解一下机器指令的分解知识以及程序用户态和内核态知识之后,我们再来详细解释原因。
1.机器指令的分解
首先我们来看这样一个简单的语句:flag++
你可能会认为这条语句就是一个很简单很简答,一步就可以执行完毕,但是其实你错了。
- 机器指令分解:
flag++
这样的简单语句在高级语言中看起来是一个操作,但它在底层通常会被分解成多个机器指令,例如:- 读取
flag
的值到寄存器。 - 寄存器中的值加 1。
- 将寄存器中的新值写回
flag
。
- 读取
- 在多线程环境下,如果多个线程同时执行这些指令,可能会发生以下情况:
- 线程 A 读取
flag
的值。 - 线程 B 也读取
flag
的值(此时,线程 A 和线程 B 读取到的值相同)。 - 线程 A 将寄存器中的值加 1,然后写回
flag
。 - 线程 B 将寄存器中的值加 1,然后写回
flag
。 - 结果是,
flag
只增加了 1,而不是 2,这就是数据不一致的问题。
- 线程 A 读取
- 指令交错:
- 由于线程的执行是交错的,这些机器指令的执行顺序可能以不可预测的方式发生。因此,我们对
flag++
的操作结果是不确定的,它不是原子的。 - 还有可能发生的就是一条线程A刚刚读取到了这个flag变量的值,还没来及修改这个值,时间片就到了,另一个线程B执行期间恰好也读取这个值,并且进行了修改。线程A再度执行的时候看到的flag值是没有更新之前的值,就会发生错误。
- 由于线程的执行是交错的,这些机器指令的执行顺序可能以不可预测的方式发生。因此,我们对
2.程序的用户态和内核态,以及CPU的原子指令
- 用户态:
- 用户态程序执行时,可能会因为多种原因被中断,包括:
- 时间片耗尽:操作系统为了实现多任务,会为每个程序分配时间片,时间片用完后,程序会被强制中断,切换到其他程序执行。
- 硬件中断:外部设备(如键盘、鼠标)发出的中断,需要操作系统处理。
- 软件中断(异常):程序自身产生的错误(如除零错误)或系统调用。
- 在用户态,程序的执行具有不确定性,无法保证原子性。
- 用户态程序执行时,可能会因为多种原因被中断,包括:
- 系统态:
- 操作系统在内核态(系统态)执行时,具有更高的权限,可以控制中断。
- 为了保证关键操作的原子性,操作系统可能会:
- 禁用中断:在执行某些关键操作(如修改内核数据结构、处理信号量)时,暂时禁用中断,防止其他中断干扰。
- 使用原子指令:利用 CPU 提供的原子指令(如 CAS 指令),确保操作的原子性。
- 但是,操作系统不能长时间禁用中断,否则会导致系统响应迟缓。因此,系统态虽然能更好地保证原子性,但也不是完全不会被中断。
- 系统态下,因为有更高的权限,并且能控制中断,所以相较于用户态,更能保证原子性。
CPU的原子指令
原子指令是计算机硬件提供的一种特殊指令,其特点是在执行过程中不会被中断。
常见的原子指令:
加载/存储指令: 原子加载指令可以原子地读取内存中的值,原子存储指令可以原子地写入内存。
读-修改-写指令: 例如,CAS(Compare-and-Swap)指令可以原子地比较并交换内存中的值。
原子算术指令: 例如,原子加法指令可以原子地增加内存中的值,原子减法指令可以原子地减少内存中的值。
POSIX信号量,读写锁,互斥锁满足原子性
对于我们的信号量的相关函数以及互斥锁读写锁相关函数在执行的时候,可能使用到CPU的原子指令或者以内核态进行执行,通过禁用中断来保证对于变量操作的原子性!至于具体如何实现的,我们不需要详细知道啦,了解了原因之后会用就行。