【Linux线程】——线程同步线程互斥
目录
编辑
前言
线程互斥的相关背景概念
多线程的问题
线程互斥
锁机制
初始化互斥锁——pthread_mutex_init
销毁互斥锁——pthread_mutex_destroy
加锁——pthread_mutex_lock
解锁——pthread_mutex_unlock
补充:锁也是临界资源
优化线程互斥——同步
条件变量
初始化条件变量——pthread_cond_init
销毁条件变量——pthread_cond_destory
等待条件——pthread_cond_wait
唤醒单个线程——pthread_cond_signal
唤醒所有等待线程——pthread_cond_broadcast
同步实现
结语
前言
在计算机系统这个庞大而复杂的数字宇宙中,线程如同繁星般繁多且活跃。它们各自在自己的轨道上运行,承载着数据处理、任务执行的重任。然而,就像现实世界中的天体有时会相互靠近、碰撞,线程之间也会因为资源的共享和交互而产生一系列的“碰撞”问题。在这样的背景下,线程同步和互斥就像是这个宇宙中的引力法则和空间规则,保障着线程们的有序运行,避免混乱和冲突。让我们一同踏上探索线程同步与互斥的奇妙之旅,揭开它们神秘的面纱。
线程互斥的相关背景概念
在进行线程互斥的讲解前,先认识一些概念
1.临界资源:多线程执行流共享的资源
- 多线程是异步执行的,它们可能会并发地访问一个资源,对一个资源进行操作,我们将这个资源称为临界资源
2.临界区:每个线程内部,访问临界资源的代码,就叫临界区
- 这个临界区大小是根据访问临界资源的代码行数定的,代码中有多少是关于临界资源的,临界区就有多大
2.互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,对临界资源起保护作用
4.原子性:不会被任何调度操作打断的操作,该操作通常只有两态,要么完成,要么未完成
多线程的问题
线程是并发执行的,但实际上它们是以及其短暂的时间间隔,交替执行
在《线程概念&&线程接口》一文中,我们已经知道,线程之间一些资源是共享的,我们将这些资源称为共享资源
线程并发地访问这些共享资源
- 如果只读,不会出现任何问题
- 如果涉及到写操作,则会引发未知错误,脱离预设
简单验证一下:多线程对共享资源进行写操作
模拟一个买票程序,创建五个线程对一个全局变量ticket进行减减操作
struct ThreadData{
std::string _name;
ThreadData(std::string& name):_name(name){}
};
int ticket = 1000;
void* function(void* args){
ThreadData* dataptr = (ThreadData*)args;
while(ticket){
ticket--;
std::cout<<"线程:"<<dataptr->_name<<"销毁了一张票,"<<"表余额:"<<ticket<<std::endl;
}
return nullptr;
}
运行一下,结果:
可以发现,后面一直出现了表余额为0的情况
按理说ticket为零,不会出现打印语句
这就是多线程并发访问共享资源,出现不可预料的情况
为什么会出现这样的情况呢?
简单看一下ticke - -的汇编代码
一共三条汇编语句
- 第一条:将内存中的数据放入CPU的寄存器
- 第二条:将CPU寄存器中的数据 -1
- 第三条:将CPU寄存器的数据写入内存
前面说过,线程的并发是多个线程在短暂的时间间隔内交替执行
由于线程的切换我们是无法精准控制的,就可能会出现下面的情况
假设从头开始,线程 1 执行到第二条汇编语句被切换掉,此时寄存器中的值是1000
线程 2 开始第一条汇编语句,因为线程 1 没有执行完第二条语句就被切换,此时ticket仍是1000
当线程1 和 线程 2,都执行完第二条汇编语句,正常的逻辑是ticket = 998,但ticket = 999
那就相当于同一个变量,以相同的值,执行了两次减减操作,导致这个变量只减减一次
这只是不可预料情况中的一种,实际情况将更复杂,更难分析,让人无法判断
这就是多线程读写共享资源引发的问题,为了解决这一问题,就需要线程互斥
线程互斥
线程互斥就是对临界资源进行保护,让同一时间只有一个线程可以读写临界资源
如何做到这一点?
线程库提供了一个机制来实现线程互斥,锁机制,通过对临界区加锁与解锁,让一段时间只有一个线程能读写临界资源
锁机制
在多线程编程中,锁(Lock)是一种用于控制多个线程对共享资源访问的同步机制。
锁的主要目的是防止多个线程同时访问或修改共享资源,从而避免数据不一致和其他并发问题。
锁主要有两个操作,加锁 和 解锁,通过这两个操作来实现线程互斥
基本流程:
当第一个线程进入临界区,就对临界区加锁,并持有锁
其中其他线程访问临界区,临界区已经被上锁了,则无法进入,无法访问共享资源
当第一个线程访问完共享资源,退出临界区,就对临界区解锁,并释放锁
接下来,介绍一下,锁的相关接口
初始化互斥锁——pthread_mutex_init
pthred_mutex_init用于初始化一个互斥化锁对象
函数造型:
int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);
参数说明:
mutex
:指向要初始化的互斥锁对象的指针。attr
:指向互斥锁属性对象的指针,通常设为NULL
使用默认属性。
返回值说明:
- 成功返回0,失败返回错误码。
销毁互斥锁——pthread_mutex_destroy
pthread_mutex_destroy用于销毁一个互斥锁对象
函数造型:
int pthread_mutex_destroy(pthread_mutex_t *mutex);
参数说明:
mutex
:指向要销毁的互斥锁对象的指针。
返回值说明:
- 成功返回0,失败返回错误码。
加锁——pthread_mutex_lock
pthread_mutex_lock 对互斥锁进行加锁。如果锁已被其他线程持有,当前线程将被阻塞,直到锁可用。
函数造型:
int pthread_mutex_lock(pthread_mutex_t *mutex);
参数说明:
mutex
:指向要销毁的互斥锁对象的指针。
返回值说明:
- 成功返回0,失败返回错误码
解锁——pthread_mutex_unlock
pthread_mutex_unlock释放互斥锁,使其可供其他线程加锁。
函数造型:
int pthread_mutex_unlock(pthread_mutex_t *mutex);
参数说明:
mutex
:指向要销毁的互斥锁对象的指针。
返回值说明:
- 成功返回0,失败返回错误码。
补充:锁也是临界资源
多线程依靠互斥锁实现互斥
但多线程共同访问通一把互斥锁,互斥锁也是一个临界资源啊
但为什么访问互斥锁不会出问题呢?为什么互斥锁还能保护其他临界资源?
多线程访问临界资源出错
因为在底层执行汇编代码的时候容易出现问题,一个线程的切换会影响所有线程。
而我们的加锁操作,无论多少线程对其访问,执行汇编语句
只有两个结果
- 拿到锁
- 没有拿到锁
我们将这种只有两种结果的特性,成为原子性。
死锁
多进程并发访问临界资源,可以用锁保证安全
但不能盲目使用锁,不然可以会引发死锁
死锁
- 指各个线程均占有不会释放的资源,但因为互相申请被其他线程说占用的不会释放的资源而处于一种互相牵制,永久等待的、无法被唤醒执行的状态
举个生动的例子:面试
面试官这样对你说:你和我说说什么是死锁,我就录用你
你是这样回答:你录用我,我就告诉你什么是死锁
面试官要得到你的回答,只能录用你
但你不回答,他就不会录用你
这种互相牵制导致的无解问题,就是死锁
死锁的四个必要条件:
- 互斥条件:一个资源每次只能被一个执行流使用
- 请求和保持条件:一个执行流因为请求其他资源而阻塞的时候,对已经获得的资源保持不放
- 不剥夺条件:一个执行流已经获得的资源,在未使用完之前,不能被其他执行流剥夺
- 循环等待条件:若干执行流之间形成一种头尾相接的循环等待的关系
如何避免死锁
- 破坏死锁的四个条件
- 加锁顺序一致
- 避免锁未释放
- 资源一次性分配
优化线程互斥——同步
当临界资源发生变化,如临界资源暂时为空的时候
这时候,线程访问临界资源是无法进行预期的操作的,访问临界资源就变得无意义,就会退出
但每个线程仍然会不停申请锁,访问临界区,释放锁,一直重复这三个动作,无意义地消耗CPU的时空资源
为了避免这种情况发生
我们就需要让这些访问临界资源的线程进入等待队列,当临界资源条件满足的时候,再唤醒这些线程
这种优化,其实就是让线程与临界资源达到同步
同步
- 在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题
而要实现线程与临界资源的同步,就需要使用条件变量,来达到这一点
条件变量
条件变量(Condition Variable)是多线程编程中用于同步线程的一种机制。
它允许线程在某个条件满足之前等待,并在条件满足时被唤醒。
条件变量通常与互斥锁(Mutex)一起使用,以确保对共享资源的安全访问。
现在有点空,在认识一下条件变量的接口后,才能知道如何使用条件变量达到同步。
初始化条件变量——pthread_cond_init
pthread_cond_init初始化一个条件变量对象。
函数造型:
int pthread_cond_init(pthread_cond_t *cond, const pthread_condattr_t *attr);
参数说明:
cond
:指向要初始化的条件变量对象的指针。attr
:指向条件变量属性对象的指针,通常设为NULL
以使用默认属性。
返回值说明:
- 成功时返回
0
。 - 失败时返回错误码。
销毁条件变量——pthread_cond_destory
pthread_cond_destory销毁一个条件变量对象,释放相关资源。
函数造型:
int pthread_cond_destroy(pthread_cond_t *cond);
参数说明:
cond
:指向要销毁的条件变量对象的指针。
返回值说明:
- 成功时返回
0
。 - 失败时返回错误码。
等待条件——pthread_cond_wait
函数造型:
int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);
参数说明:
cond
:指向条件变量对象的指针。mutex
:指向与条件变量关联的互斥锁对象的指针。
功能说明:
- 使当前线程等待条件变量被信号唤醒,同时释放持有的互斥锁。当被唤醒时,重新获取互斥锁。
返回值说明:
- 成功时返回
0
。 - 失败时返回错误码
唤醒单个线程——pthread_cond_signal
pthread_cond_signal唤醒一个正在等待该条件变量的线程。
函数造型:
int pthread_cond_signal(pthread_cond_t *cond);
参数说明:
cond
:指向条件变量对象的指针。
返回值说明:
- 成功时返回
0
。 - 失败时返回错误码。
唤醒所有等待线程——pthread_cond_broadcast
pthread_cond_broadcast唤醒所有正在等待该条件变量的线程。
函数造型:
int pthread_cond_broadcast(pthread_cond_t *cond);
参数说明:
cond
:指向条件变量对象的指针。
返回值说明:
- 成功时返回
0
。 - 失败时返回错误码。
同步实现
使用条件变量实现同步基本流程
初始化条件变量
利用条件变量等待资源就绪
资源就绪后唤醒线程进行访问
使用完后销毁条件变量
第一步:初始化条件变量
pthread_cond_t cond;
int pthread_cond_init(&cond,nullptr);
第二步:使用条件变量等待资源就绪
pthread_cond_wait(&cond,&mutex)//cond是条件变量对象,mutex是互斥锁对象
第三步:唤醒对应条件变量上等待的线程
//唤醒所有线程
pthread_cond_broadcast(&cond);
//唤醒单一线程
pthread_cond_signal(&cond);
第四步:销毁条件变量
pthread_cond_destory(&cond);
以上就是用条件变量实现线程与临界资源同步的基本步骤,至于唤醒线程时,是唤醒一个还是全部,具体情况具体分析,我们将在下一篇博文中,直接来一手实践
结语
Linux线程的互斥与同步是一个充满挑战与魅力的技术领域。在实际的编程过程中,我们都可能会遇到各种各样的并发问题,而如何巧妙地运用互斥与同步机制去解决这些问题,无疑是每个程序员都需要深入思考和探索的方向。希望通过对Linux线程互斥与同步的学习和实践,你能够在自己的编程之路上不断创新,发现更多有趣的解决方案,与更多的开发者一同交流分享,共同进步。