文章目录
- 👉线程互斥👈
- 互斥相关概念
- 互斥量
- 全局互斥锁
- 局部互斥锁
- 互斥锁的进一步认识
- 互斥锁的实现原理
- 👉可重入和线程安全👈
- 概念
- 常见的线程不安全的情况
- 常见的线程安全的情况
- 常见不可重入的情况
- * 常见可重入的情况
- 可重入与线程安全的联系
- 👉死锁👈
- 概念
- 死锁的四个必要条件
- 避免死锁
- 避免死锁算法
- 👉线程同步👈
- 概念
- 条件变量
- 条件变量初始化和销毁
- 等待条件满足
- 唤醒等待
- 👉总结👈
👉线程互斥👈
互斥相关概念
- 临界资源:多线程执行流共享且每次只能一个执行流访问的资源叫做临界资源
- 临界区:每个线程内部访问临界资源的代码就是临界区
- 互斥:任何时刻,互斥保证有且只有一个执行流进入临界区访问临界资源,通常对临界资源起保护作用
- 原子性:原子性是指一个操作要么做完,要么就没做,没有中间状态
#include <iostream>
#include <unistd.h>
#include <cstdio>
#include <pthread.h>using namespace std;//多线程访问同一个全局变量,并对它进行数据计算,多线程之间会相互影响
//在并发访问的时候,因为调度时序问题,导致数据不一致的问题
int tickets = 10000;//getTickets函数会被重入,getTickets函数是重入函数
void* getTickets(void* args)
{(void)args;while(true){if(tickets > 0){usleep(1000);//等待1000微秒,usleep是一个以微妙为单位的等待函数;printf("%p: %d\n", pthread_self(), tickets--);}elsebreak;}return nullptr;
}int main()
{//多线程抢票pthread_t t1, t2, t3;pthread_create(&t1, nullptr, getTickets, nullptr);pthread_create(&t2, nullptr, getTickets, nullptr);pthread_create(&t3, nullptr, getTickets, nullptr);pthread_join(t1, nullptr);pthread_join(t2, nullptr);pthread_join(t3, nullptr);return 0;
}
上放的代码是模拟实现多线程抢票的场景,但是我们会发现票数 tickets 居然抢到了 -1,这是不允许存在的。那为什么会出现这种情况呢?其实是多线程访问同一个全局变量且对其进行计算时,如果不加以保护,线程之间是会相互影响的。
出现问题的原因: tickets > 0 判断的本质也是计算,计算就需要将内存中的数据读取到 CPU 的寄存器中,而将数据读取到 CPU 的寄存器中的本质就是将数据读取到当前执行流的上下文中。这样就有可能出现多个执行流进行判断是都没有小于 0,符合判断条件,然后多个执行流进行减减操作将 tickets 减到负数,那么就出现了 tickets 出现负数的情况了。除了这个原因,还会因为调度时序的问题,导致数据不一样的问题!
那如何解决这个问题呢?为了解决这个问题,我们需要进行加锁保护!
互斥量
要解决上面的问题,需要做到以下三点:
-
代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
-
如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
-
如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
要做到这三点,本质上就是需要一把锁,Linux 系统提供的这把锁叫互斥量。
销毁互斥量需要注意: -
使用PTHREAD_ MUTEX_ INITIALIZER初始化的互斥量不需要销毁
不要销毁一个已经加锁的互斥量 -
已经销毁的互斥量,要确保后面不会有线程再尝试加锁
调用 pthread_mutex_lock 时,可能会遇到以下情况:
- 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功。
- 发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么 pthread_mutex_lock 调用会陷入阻塞(执行流被挂起),等待互斥量解锁。
注:pthread_mutex_trylock 申请锁时,如果申请成功,则返回 0;如果申请失败,则立即返回错误码,并不会阻塞等待锁释放。
全局互斥锁
#include <iostream>
#include <unistd.h>
#include <cstdio>
#include <pthread.h>using namespace std;// pthread_mutex_t是原生线程库提供的数据类型
pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER; // 全局的互斥锁
int tickets = 10000; // 临界资源void* getTickets(void* args)
{(void)args;while(true){// 只有获得锁的线程才能执行进入临界区,访问临界资源pthread_mutex_lock(&mtx); // 加锁保护// 加锁和解锁之间的代码就是临界区if(tickets > 0){usleep(1000);printf("%p: %d\n", pthread_self(), tickets--);pthread_mutex_unlock(&mtx); // 解锁(释放锁)}else {pthread_mutex_unlock(&mtx); // 解锁(释放锁)break;}// 不能在这里解锁,因为tickets小于0,会跳出while循环// 无法执行到这里,从而导致锁没有释放,其余线程无法获// 得锁而阻塞等待// 抢完票还需要后续的动作}return nullptr;
}int main()
{// 多线程抢票pthread_t t1, t2, t3;pthread_create(&t1, nullptr, getTickets, nullptr);pthread_create(&t2, nullptr, getTickets, nullptr);pthread_create(&t3, nullptr, getTickets, nullptr);pthread_join(t1, nullptr);pthread_join(t2, nullptr);pthread_join(t3, nullptr);return 0;
}
如果出现票都被一个线程抢到的情况,可以将票数增多一点或者休眠时间随机一点,以让每个线程都能够抢到票。
加锁会导致临界区代码串行访问(互斥访问),从而代码执行的效率会降低。所以,进行加锁的时候,要保证加锁的粒度越小越好,不要将不访问临界区资源的代码也加锁。
局部互斥锁
#include <iostream>
#include <unistd.h>
#include <cstdio>
#include <cassert>
#include <pthread.h>
#include <string>
#include <ctime>using namespace std;#define THREAD_NUM 5 // 线程数量class ThreadData
{
public:ThreadData(const string& tname, pthread_mutex_t* pmtx): _tname(tname), _pmtx(pmtx){}string _tname;pthread_mutex_t* _pmtx;
};int tickets = 10000; // 临界资源void* getTickets(void* args)
{ThreadData* td = (ThreadData*)args;while(true){// 只有获得锁的线程才能执行进入临界区,访问临界资源int n = pthread_mutex_lock(td->_pmtx); // 加锁assert(n == 0); (void)n;// 加锁和解锁之间的代码就是临界区if(tickets > 0){usleep(rand() % 2000);printf("%s: %d\n", td->_tname.c_str(), tickets--);pthread_mutex_unlock(td->_pmtx); // 解锁}else {pthread_mutex_unlock(td->_pmtx);break;}// 抢到票后的其它逻辑usleep(rand() % 2000);}delete td; // 票数为0时,释放ThreadDatareturn nullptr;
}int main()
{int begin = time(nullptr);pthread_mutex_t mtx;// 第二个参数为锁的属性,设置为nullptr表示不关心pthread_mutex_init(&mtx, nullptr); // 初始化锁 pthread_t t[THREAD_NUM];for(int i = 0; i < THREAD_NUM; ++i){string name = "thread";name += to_string(i + 1);ThreadData* td = new ThreadData(name, &mtx);pthread_create(t + i, nullptr, getTickets, (void*)td);}for(int i = 0; i < THREAD_NUM; ++i){pthread_join(t[i], nullptr);}pthread_mutex_destroy(&mtx); // 销毁锁int end = time(nullptr);// 输出抢票所消耗的时间cout << "time: " << end - begin << endl;
}
time 返回的时间是秒级别的,如果想要获得微秒级别时间,可以使用 gettimeofday 函数。注:并不是线程越多,抢票就越快,因为线程调度也会有消耗。
互斥锁的进一步认识
- 加了锁之后,线程在临界区中也会被切换,但这样也不会有问题。因为线程是带着锁进行线程切换的,其余线程是无法申请到锁的,无法进入临界区访问临界资源。
- 错误的编码方式:线程不申请锁直接访问临界区资源,这样的话,就算别的线程持有锁,该线程也可以进入到临界区。
- 在没有持有锁的线程看来,对该线程最有意义的情况只用两种:1. 线程 1 没有持有锁(什么都没做),2. 线程 1 释放锁(做完),此时我可以申请锁。那么在线程 1 持有锁的期间,所做的所有操作在其他线程看来都是原子的!
- 加锁后,执行临界区的代码一定是串行执行的!
- 要访问临界资源,每一个线程都必须先申请锁,那么每一个线程都必须先看到同一把锁并访问它,所以锁本身也是一种共享资源。那么锁肯定也要保护起来,为了保护锁的安全,申请和释放锁的操作都必须是原子的!
如何保证申请和释放锁的操作是原子的,为了解答这个问题,我们需要知道锁是如何实现的!
互斥锁的实现原理
如果一个操作的汇编指令只有一条,那么我们就可以任务这个操作是原子的!为了实现互斥锁的操作,大多数体系结构都提供了 swap 或 exchange 指令,该指令的作用是以一条汇编指令的方式交换内存和 CPU 内寄存器中的数据。由于只有一条汇编指令,这样就保证了申请锁和释放锁原子性!
👉可重入和线程安全👈
概念
线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数;否则,是不可重入函数。
常见的线程不安全的情况
- 不保护共享变量的函数
- 函数状态随着被调用,状态发生变化的函数
- 返回指向静态变量指针的函数
- 调用线程不安全函数的函数
常见的线程安全的情况
- 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的
- 类或者接口对于线程来说都是原子操作
- 多个线程之间的切换不会导致该接口的执行结果存在二义性
常见不可重入的情况
- 调用了 malloc / free 函数,因为 malloc 函数是用全局链表来管理堆的
- 调用了标准 I / O 库函数,标准 I / O 库的很多实现都以不可重入的方式使用全局数据结构
- 可重入函数体内使用了静态的数据结构
* 常见可重入的情况
- 不使用全局变量或静态变量
- 不使用 malloc 或者 new 开辟出的空间
- 不调用不可重入函数
- 不返回静态或全局数据,所有数据都有函数的调用者提供
- 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
可重入与线程安全的联系
- 函数是可重入的,那就是线程安全的;线程安全的函数,不一定是可重入函数
- 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题(如:printf 函数是不可重入的,多线程向显示器上打印数据时,数据可能会黏在一起)
- 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的
👉死锁👈
概念
死锁是指在一组进程(线程)中的各个进程(线程)均占有不会释放的资源,但因互相申请被其他进程(线程)所占用不会释放的资源而处于的一种永久等待状态。
注:两把锁及以上就有可能会造成死锁问题,但是一把锁也可以引起死锁问题(特殊情况),如下图:
死锁的四个必要条件
- 互斥条件:一个资源每次只能被一个执行流使用
- 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
- 不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺
- 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系
避免死锁
- 破坏死锁的四个必要条件(破坏请求与保持条件:当使用 pthread_mutex_trylock 多次申请锁都失败是,就将自己的锁给释放掉。破坏不剥夺条件:依据一定的条件(优先级等)要求不符合条件的线程释放自己拥有的锁。)
- 加锁顺序一致
- 避免锁未释放的场景(锁用完了就立即释放锁,减少多个线程持有锁的场景)
- 资源一次性分配
避免死锁算法
- 死锁检测算法(了解)
- 银行家算法(了解)
👉线程同步👈
概念
互斥锁会存在两种不合理的情况:1. 一个线程频繁地申请到锁,别人无法申请到锁,从而造成别人饥饿的问题;2. 如果我们给多线程买票加上一种情况,当票数为 0时,并不会立即退出,而是等待票数增加。在等待票数增加的这个过程中,线程就会频繁地申请锁和释放锁,这样太浪费资源了,并没有创造任何的价值。而这两种情况并没有错,但是并不合理。
为了解决访问临界资源合理性的问题,就引入了同步的概念!竞态条件:因为时序问题,而导致程序异常,我们称之为竞态条件。线程同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步。为了完成线程同步,就需要借助条件变量。
当我们申请临界资源前,先要做临界资源是否存在的检测,检测的本质也是访问临界资源。那么对临界资源的检测也一定要在加锁和解锁之间的。常规方法检测临界资源是否就绪,就注定了我们必须频繁地申请锁和释放锁。
条件变量
为了解决频繁申请和释放锁的问题,需要做到以下两点:
- 不要让线程在频繁地检测资源是否就绪,而让线程在资源未就绪时进行等待。
- 当资源就绪的时候,通知等待该资源的线程,让这些线程来进行资源的申请和访问。
能做到以上两点的就是条件变量,条件变量可以通过允许线程阻塞和等待另一个线程发送信号来弥补互斥锁的不足,所以互斥锁和条件变量通常一起使用。
条件变量初始化和销毁
等待条件满足
唤醒等待
#include <iostream>
#include <pthread.h>
#include <string>
#include <unistd.h>
#include <string>#define THREAD_NUM 3
typedef void (*func_t)(const std::string &name,pthread_mutex_t *pmtx, pthread_cond_t *pcond);
volatile bool quit = false;// pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;
// pthread_cond_t cond = PTHREAD_COND_INITIALIZER;using namespace std;class ThreadData
{
public:ThreadData(const string& name, func_t func, pthread_mutex_t* pmtx, pthread_cond_t* pcon): _name(name), _func(func), _pmtx(pmtx), _pcon(pcon){}string _name;func_t _func;pthread_mutex_t* _pmtx;pthread_cond_t* _pcon;
};void func1(const string& name, pthread_mutex_t* pmtx, pthread_cond_t* pcon)
{while(!quit){// 检测临界资源是否就绪,需要在加锁和解锁之间检测// wait等待一定要在加锁和解锁之间进行pthread_mutex_lock(pmtx);// if(临界资源是否就绪 - 否) pthread_cond_wait(pcon, pmtx);// wait代码被执行,当前线程会被立即被阻塞,也就是线程的状态变成了S,放入到等待队列中// 线程被唤醒时,会有一定的唤醒顺序pthread_cond_wait(pcon, pmtx); // 现在不需要关心pmtx,后续会讲解cout << name << " running --- 执行下载任务" << endl;pthread_mutex_unlock(pmtx);}
}void func2(const string& name, pthread_mutex_t* pmtx, pthread_cond_t* pcon)
{while(!quit){// wait等待一定要在加锁和解锁之间进行pthread_mutex_lock(pmtx);// wait代码被执行,当前线程会被立即被阻塞pthread_cond_wait(pcon, pmtx);cout << name << " running --- 执行播放任务" << endl;pthread_mutex_unlock(pmtx);}
}void func3(const string& name, pthread_mutex_t* pmtx, pthread_cond_t* pcon)
{while(!quit){// wait等待一定要在加锁和解锁之间进行pthread_mutex_lock(pmtx);// wait代码被执行,当前线程会被立即被阻塞pthread_cond_wait(pcon, pmtx);cout << name << " running --- 执行刷新任务" << endl;pthread_mutex_unlock(pmtx);}
}void* Entry(void* args)
{ ThreadData* td = (ThreadData*)args; // td在每个线程的独立栈空间中保存td->_func(td->_name, td->_pmtx, td->_pcon); // 它是一个函数,调用完成后会返回delete td; // 释放ThreadDatareturn nullptr;
}int main()
{pthread_mutex_t mtx;pthread_cond_t cond;pthread_mutex_init(&mtx, nullptr);pthread_cond_init(&cond, nullptr);pthread_t tids[THREAD_NUM];func_t funcs[THREAD_NUM] = {func1, func2, func3};for(int i = 0; i < ThREAD_NUM; ++i){string name = "Thread ";name += to_string(i + 1);ThreadData* td = new ThreadData(name, funcs[i], &mtx, &cond);pthread_create(tids + i, nullptr, Entry, (void*)td);}sleep(5); // 休眠5秒,观察等待条件的现象int count = 10;while(count){cout << "Wake up thread " << count-- << endl;pthread_cond_signal(&cond); // 唤醒在该条件下等待的某个进程// pthread_cond_broadcast(&cond); // 唤醒在该条件下等待的所有线程sleep(1);}quit = true;pthread_cond_broadcast(&cond); // 最后还需要再唤醒一次// 等待线程for(int i = 0; i < ThREAD_NUM; ++i){pthread_join(tids[i], nullptr);cout << "thread: " << tids[i] << " quit!" << endl;}// 销毁锁和条件变量pthread_mutex_destroy(&mtx);pthread_cond_destroy(&cond);return 0;
}
👉总结👈
本篇博客主要讲解了线程互斥、可重入和线程安全、死锁以及线程同步等。那么以上就是本篇博客的全部内容了,如果大家觉得有收获的话,可以点个三连支持一下!谢谢大家!💖💝❣️