👀樊梓慕:个人主页
🎥个人专栏:《C语言》《数据结构》《蓝桥杯试题》《LeetCode刷题笔记》《实训项目》《C++》《Linux》《算法》
🌝每一个不曾起舞的日子,都是对生命的辜负
目录
前言
本篇文章内容:线程互斥、互斥量的使用、线程同步、条件变量的使用、可重入函数与线程安全相关内容。
欢迎大家📂收藏📂以便未来做题时可以快速找到思路,巧妙的方法可以事半功倍。
=========================================================================
GITEE相关代码:🌟樊飞 (fanfei_c) - Gitee.com🌟
=========================================================================
1.Linux线程互斥
首先我们先来学习一组概念:
- 临界资源: 多线程执行流共享的资源叫做临界资源(全局变量)。
- 临界区: 每个线程内部,访问临界资源的代码,就叫做临界区(访问或修改临界资源的代码)。
- 互斥: 任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用。
- 原子性: 不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成。
原子性:如果操作只有一条汇编指令,那么该操作就是原子的。
为了更好的理解,这里模拟实现一个抢票系统,我们将记录票的剩余张数的变量定义为全局变量,主线程创建四个新线程,让这四个新线程进行抢票,当票被抢完后这四个线程自动退出。
#include <iostream>
#include <unistd.h>
#include <pthread.h>
int tickets = 1000;
void *route(void *arg)
{
const char *name = (char *)arg;
while (1)
{
if (tickets > 0)
{
usleep(10000);
std::cout << name << " get a ticket, remain: " << --tickets << std::endl;
}
else
{
break;
}
}
std::cout << name << "quit!" << std::endl;
pthread_exit((void *)0);
}
int main()
{
pthread_t t1, t2, t3, t4;
pthread_create(&t1, NULL, route, (void *)"thread 1");
pthread_create(&t2, NULL, route, (void *)"thread 2");
pthread_create(&t3, NULL, route, (void *)"thread 3");
pthread_create(&t4, NULL, route, (void *)"thread 4");
pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_join(t3, NULL);
pthread_join(t4, NULL);
return 0;
}
奇怪的是,最后剩余的票数为负值。
我们明明判断了当tickets>0时,才会对总票数--,可为什么这里是负值呢?
- if语句判断条件为真以后,代码可以并发的切换到其他线程。
- usleep用于模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段。
--tickets
操作本身就不是一个原子操作。
--tickets的操作并不是原子操作,因为它对应着三条汇编指令:
load
:将共享变量tickets从内存加载到寄存器中。update
:更新寄存器里面的值,执行-1操作。store
:将新值从寄存器写回共享变量tickets的内存地址。
要解决以上问题,需要做到三点:
- 代码必须有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
- 如果多个线程同时要求执行临界区的代码,并且此时临界区没有线程在执行,那么只能允许一个线程进入该临界区。
- 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
要做到以上三点,本质上就是需要一把锁,进入临界区之前加锁,离开临界区后解锁,Linux上提供的这把锁叫做互斥量mutex。
1.1互斥量的接口
有关于互斥量的操作非常简单,初始化互斥量,销毁互斥量,上锁,解锁等,并且我们还可以利用RAII的思想来管理动态分配的互斥量,具体如何使用都可以。
1.1.1初始化互斥量
对互斥量的初始化,我们有两种方式,一种是静态分配,另一种是动态分配。
静态分配就是当互斥量是全局或者static时使用:
pthread_mutex_t mutex=PTHREAD_MUTEX_INITIALIZER;//PTHREAD_MUTEX_INITIALIZER是一个宏
对于这种全局或static互斥量,不需要销毁。
动态分配就是当互斥量是局部时,利用pthread_mutex_init函数初始化,然后使用(动态分配的互斥量需要销毁):
int pthread_mutex_init(pthread_mutex_t *restrict mutex
, const pthread_mutexattr_t *restrict attr);
参数说明:
- mutex:需要初始化的互斥量。
- attr:初始化互斥量的属性,一般设置为nullptr即可。
返回值说明:
- 互斥量初始化成功返回0,失败返回错误码。
1.1.2销毁动态分配的互斥量
int pthread_mutex_destroy(pthread_mutex_t *mutex);
参数说明:
- mutex:需要销毁的互斥量。
返回值说明:
- 互斥量销毁成功返回0,失败返回错误码。
销毁互斥量需要注意:
- 使用PTHREAD_MUTEX_INITIALIZER初始化的互斥量不需要销毁。
- 不要销毁一个已经加锁的互斥量。
1.1.3互斥量加锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
参数说明:
- mutex:需要加锁的互斥量。
返回值说明:
- 互斥量加锁成功返回0,失败返回错误码。
调用pthread_mutex_lock时,可能会遇到以下情况:
- 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功。
- 发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_mutex_lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。
1.1.4互斥量解锁
int pthread_mutex_unlock(pthread_mutex_t *mutex);
参数说明:
- mutex:需要解锁的互斥量。
返回值说明:
- 互斥量解锁成功返回0,失败返回错误码。
1.2利用RAII思想封装一个管理互斥量的对象
#ifndef __LOCK_GUARD_HPP__
#define __LOCK_GUARD_HPP__
#include <iostream>
#include <pthread.h>
class LockGuard
{
public:
LockGuard(pthread_mutex_t *mutex):_mutex(mutex)
{
pthread_mutex_lock(_mutex); // 构造加锁
}
~LockGuard()
{
pthread_mutex_unlock(_mutex);
}
private:
pthread_mutex_t *_mutex;
};
#endif
有了这个类,你就可以将互斥量交给该类管理,当该类构造时,进行加锁,当该类析构时解锁。
1.3如何保证申请锁的过程是原子的?
上面大家已经意识到了--和++操作不是原子操作,可能会导致数据不一致问题。
其实为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用就是把寄存器和内存单元的数据相交换。
由于只有一条汇编指令,保证了原子性。
加锁和解锁的过程我们可以通过下面的伪代码来理解:
下面我们再以图示来理解:
图示中我们在内存中定义了一个mutex互斥量,当我们进行加锁时,cpu会原子地将两个数据进行交换, 交换后,内存中变为0,寄存器%al中变为1,并且这个1的值是唯一的,因为数据在内存中时,所有线程都能访问,属于共享的,但是如果转移到CPU内部寄存器中,就属于一个线程私有的了,因为CPU寄存器内部的数据是线程的硬件上下文。
所以这个1的值就是唯一的,当任何一个线程将这个1交换走后(交换是原子的),哪怕此时线程的时间片到了被切换走,这个1也被线程作为硬件上下文带走了,别的线程也拿不到,判断时也会认为该锁被占用了。
2.Linux线程同步
同步: 在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,这就叫做同步。
那么什么情况下需要同步呢?
如果个别的线程竞争能力非常强, 每次都能够申请到锁,但申请到锁之后什么也不做,所以在我们看来这个线程就一直在申请锁和释放锁,这就可能导致其他线程长时间竞争不到锁,引起饥饿问题。
那如何解决呢?
我们可以让线程每次释放锁后,不能立即申请锁,而是去排队,有顺序的申请锁。
2.1条件变量
条件变量就是实现线程同步的解决方案,是用来描述某种资源是否就绪的一种数据化描述。
换句话说,我们可以利用条件变量实现对其他线程的控制。
比如:
- 控制某个线程在某一条件变量下等待;
- 条件满足后,对该线程进行唤醒。
2.1.1初始化条件变量
对条件变量的初始化,我们有两种方式,一种是静态分配,另一种是动态分配。
静态分配就是当条件变量是全局或者static时使用:
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;//PTHREAD_COND_INITIALIZER是一个宏
对于这种全局或static互斥量,不需要销毁。
动态分配就是当条件变量是局部时,利用pthread_cond_init函数初始化,然后使用(动态分配的条件变量需要销毁):
int pthread_cond_init(pthread_cond_t *restrict cond
, const pthread_condattr_t *restrict attr);
参数说明:
- cond:需要初始化的条件变量。
- attr:初始化条件变量的属性,一般设置为nullptr即可。
返回值说明:
- 条件变量初始化成功返回0,失败返回错误码。
2.1.2销毁动态分配的条件变量
int pthread_cond_destroy(pthread_cond_t *cond);
参数说明:
- cond:需要销毁的条件变量。
返回值说明:
- 条件变量销毁成功返回0,失败返回错误码。
销毁互斥量需要注意:
- 使用PTHREAD_COND_INITIALIZER初始化的条件变量不需要销毁。
2.1.3等待条件变量满足
int pthread_cond_wait(pthread_cond_t *restrict cond
, pthread_mutex_t *restrict mutex);
参数说明:
- cond:让线程在该条件变量下等待。
- mutex:当前线程所处临界区对应的互斥锁(为什么要传互斥锁???)。
返回值说明:
- 函数调用成功返回0,失败返回错误码。
2.1.4唤醒等待的线程
int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *cond);
区别:
- pthread_cond_signal函数用于唤醒等待队列中首个线程。
- pthread_cond_broadcast函数用于唤醒等待队列中的全部线程。
参数说明:
- cond:唤醒在cond条件变量下等待的线程。
返回值说明:
- 函数调用成功返回0,失败返回错误码。
等待条件变量满足的函数参数为什么要传互斥锁?
- 首先你需要明确的是,使用条件变量一定会搭配使用互斥锁,因为线程同步的场景本身就是在互斥的前提下,即两个线程访问同一资源(临界资源),而现在需要保证的是资源使用的顺序性,所以才引入了条件变量。
- 而如果此时某个线程由于条件并不满足(这个条件不满足一定是临界资源不满足该线程运行的条件),被设置了等待条件变量满足,从而进入了阻塞状态,能使条件满足一定是该临界资源被修改了,从而满足了线程的运行需要,所以此时你就可以唤醒等待的线程。
- 也就是说我们想要让条件得到满足,就一定会修改临界资源,而如果你在等待条件变量满足的时候,仍然持有着该临界资源的锁,那么就会导致其他能使该临界资源满足线程运行需要的其他线程访问不了该临界资源,所以给等待条件变量满足的函数传入互斥锁的目的就是让这个锁临时被释放,让其他线程可以访问该临界资源。
总结:
- 等待的时候往往是在临界区内等待的,当该线程进入等待的时候,互斥锁会自动释放,而当该线程被唤醒时,又会自动获得对应的互斥锁。
- 条件变量需要配合互斥锁使用,其中条件变量是用来完成同步的,而互斥锁是用来完成互斥的。
- pthread_cond_wait函数有两个功能,一就是让线程在特定的条件变量下等待,二就是让线程释放对应的互斥锁。
2.2条件变量函数使用规范
等待条件变量的代码
pthread_mutex_lock(&mutex);
while (条件为假)
pthread_cond_wait(&cond, &mutex);
修改条件
pthread_mutex_unlock(&mutex);
唤醒等待线程的代码
pthread_mutex_lock(&mutex);
设置条件为真
pthread_cond_signal(&cond);
pthread_mutex_unlock(&mutex);
3.可重入VS线程安全
3.1概念
- 线程安全: 多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现线程安全问题。
- 重入: 同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则是不可重入函数。
注意: 线程安全讨论的是线程执行代码时是否安全,重入讨论的是函数被重入进入。
3.2常见的线程不安全的情况
- 不保护共享变量的函数。
- 函数状态随着被调用,状态发生变化的函数。
- 返回指向静态变量指针的函数。
- 调用线程不安全函数的函数。
3.3常见的线程安全的情况
- 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的。
- 类或者接口对于线程来说都是原子操作。
- 多个线程之间的切换不会导致该接口的执行结果存在二义性。
3.4常见的不可重入的情况
- 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的。
- 调用了标准I/O库函数,标准I/O可以的很多实现都是以不可重入的方式使用全局数据结构。
- 可重入函数体内使用了静态的数据结构。
3.5常见的可重入的情况
- 不使用全局变量或静态变量。
- 不使用malloc或者new开辟出的空间。
- 不调用不可重入函数。
- 不返回静态或全局数据,所有数据都由函数的调用者提供。
- 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据。
3.6可重入与线程安全联系
- 函数是可重入的,那就是线程安全的。
- 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题。
- 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
3.7可重入与线程安全区别
- 可重入函数是线程安全函数的一种。
- 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
- 如果对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数的锁还未释放则会产生死锁,因此是不可重入的。
可重入函数一定是线程安全的,函数不可重入是引发线程安全问题的一种常见情况。
4.常见锁概念
4.1死锁
死锁(Deadlock)是数据库系统、操作系统或并发编程中常见的一种现象,它指的是两个或两个以上的进程(或线程)在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法向前推进。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。
举例:假设有两个进程P1和P2,它们都需要两个资源R1和R2。如果P1获得了R1并请求R2,同时P2获得了R2并请求R1,那么这两个进程都会阻塞等待对方释放资源,从而导致死锁。
单执行流可能产生死锁吗?
单执行流也有可能产生死锁,如果某一执行流连续申请了两次锁,那么此时该执行流就会被挂起。
因为该执行流第一次申请锁的时候是申请成功的,但第二次申请锁时因为该锁已经被申请过了,于是申请失败导致被挂起直到该锁被释放时才会被唤醒,但是这个锁本来就在自己手上,自己现在处于被挂起的状态根本没有机会释放锁,所以该执行流将永远不会被唤醒,此时该执行流也就处于一种死锁的状态。
4.2死锁的四个必要条件
- 互斥条件: 一个资源每次只能被一个执行流使用。
- 请求与保持条件: 一个执行流因请求资源而阻塞时,对已获得的资源保持不放。
- 不剥夺条件: 一个执行流已获得的资源,在未使用完之前,不能强行剥夺。
- 循环等待条件: 若干执行流之间形成一种头尾相接的循环等待资源的关系。
注意: 这是死锁的四个必要条件,也就是说只有同时满足了这四个条件才可能产生死锁。
避免死锁
- 破坏死锁的四个必要条件。
- 按顺序申请资源。
- 避免锁未释放的场景。
- 资源一次性分配。
除此之外,还有一些避免死锁的算法,比如死锁检测算法和银行家算法。
=========================================================================
如果你对该系列文章有兴趣的话,欢迎持续关注博主动态,博主会持续输出优质内容
🍎博主很需要大家的支持,你的支持是我创作的不竭动力🍎
🌟~ 点赞收藏+关注 ~🌟
=========================================================================