1.相关概念补充
1.临界资源:多线程执行流访问的被保护的共享资源叫做临界资源
2.临界区:访问临界资源的代码叫临界区
3.互斥:任何时刻,保证只有一个执行流进入临界区访问临界资源(保证临界资源安全性)
4.同步:在临界资源安全前提下,让不同线程访问临界资源具有顺序性(解决效率低下导致的饥饿问题)
5.原子性:不会被任何调度机制打断的操作,只有完成和未完成两种状态
多线程并发冲突样例:
假设我们多线程并发执行一个抢票的任务,该票一共有1000张,共有n个线程,每个线程都可以无限次抢票,直到票数归零为止
预期结果:最后一张抢到的票是编号为1的
实际结果:最后一张票可能为0甚至是负数
讲解:
假设线程分为线程A,线程B,剩余线程
线程A执行:将tickets拷贝到cpu寄存器中,进行票数判断,发现票数为1000,满足要求,刚准备抢一张票,此时他的时间片用完,执行中断被切换,切换前将记录当前所处的执行进度(保留寄存器的硬件上下文),保存的也包括从内存中获取的tickets值
线程B执行:前面的流程和A一样,也是刚准备抢就被切换
其余线程:将票从1000抢到只剩0张,tickets变为0
线程A再次被调度,此时由于上次他已经通过tickets的判断进入抢票代码,所以即使此时tickets已经变为0,他也不会停止抢票代码的执行,所以A最终抢到的票号为0的票,并让tickets--,tickets变为-1
线程B再次被调度:和A一样,他也不会再进行票数合法性判断,直接抢到票号为-1的票
综上,由于线程执行不具备互斥属性,没有原子性,所以线程并发执行会陷入混乱,导致结果出错
隐藏问题:线程切换前保存的tickets在下次再次被调度的时候不会再次获取内存中的tickets,而是直接使用之前保存的,这会导致多线程并发时,tickets并不同步
eg:当前内存中的tickets已经变为0了,但是之前线程保存的tickets还是1000,然后--之后将999同步给了内存中的tickets
2.互斥锁
2.1理解互斥锁
疑问1:锁本身也是全局的,如何保证锁本身是安全的?
答:申请锁,释放锁等互斥锁操作本身就是原子的在实现原子性的方法上分两大类
1.硬件实现:取消时钟中断
由于线程切换是根据时间片是否消耗完来决定的,而时钟源会以相同的频率向cpu发送中断信号,所以只要时钟源还在运行,线程的执行就可能被打断
2.软件实现:使用一条汇编语句一个指令可能会由多个语句组成,而一个汇编语句是不会被打断的,所以只要保证加锁只使用一条语句实现即可
锁实现:
1.将0值交给cpu中保存mutex变量值的寄存器
2.交换寄存器和内存中的mutex变量值,此时内存中mutex为0,线程中mutex值为1,相当于将内存中的mutex值变为该线程私有,此时经过if语句判断,判断成功直接返回
疑问:这里加锁指令由两条语句,为什么还说加锁是原子的?
首先,第一条与第二条语句之间确实可能被打断,但是因为第一条赋值0的语句不是加锁的核心,只是一个初始化操作,就算被打断也不影响锁的获取,真正的加锁语句只有一条,就是交换语句
(1)加锁流程图解:
mutex是互斥量,其实就是一个整数(大于0)
现在有线程A与B
A执行语句1:内存中mutex变量值为1,寄存器中值为0
A执行语句2:mutex的值被交换,寄存器中值为1,内存中值为0,可以通过if语句判断,加锁成功,此时假设时间片到了,A被剥离
B执行:经过交换操作后,由于内存中的mutex已经被A交换为了0,所以即使B进行交换,此时寄存器中的值仍然为0,所以B无法通过if语句判断,加锁失败(锁已经被A带走了),于是B就挂起等待
疑问:如果A的语句二还没执行就被打断会怎么样?
这无非是A无法拿到锁,换了一个线程拿,但是不影响锁保证的原子性
综上,执行了加锁之后,没有锁的线程会被禁止访问临界区,这就是锁的原理
(2)解锁实现:
直接将内存中的mutex变量置为1即可
疑问2:访问统一公共资源同所有线程都要使用锁吗?是否可以让部分线程不使用锁?
不可以,所有的线程都要使用锁,否则使用锁的线程和不使用锁的线程之间仍然会出现问题
申请成功才会继续执行代码,否则线程就会阻塞
疑问3:进入临界区,线程会被切换吗?
会被切换,但是不会引发问题,因为线程是持有锁被切换的,其他的线程会被卡在临界区外无法进入执行,这也是加锁之后运行效率降低的原因
2.2使用互斥锁
1.定义互斥量
cpppthread_mutex_t mutex;2.初始化锁
静态分配:
cpppthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER动态分配:
cppint pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);参数1:要初始化的互斥量
参数2:设置为nullptr
3.加锁
cppint pthread_mutex_lock(pthread_mutex_t *mutex);参数为互斥量
注意:由于加锁后临界区代码运行效率会降低,为了尽量减少被降低的代码数量,我们加锁的代码尽量是最小集
4.解锁
cppint pthread_mutex_unlock(pthread_mutex_t *mutex);参数为互斥量
5.销毁锁
cppint pthread_mutex_destroy(pthread_mutex_t *mutex);
2.3封装互斥锁
mutex是锁本身,而lock是一种动作,包含加锁和解锁
cpp#include <pthread.h> #include <mutex> #include <iostream> class Mutex { public: Mutex() { pthread_mutex_init(&_lock, nullptr); } void Lock() { pthread_mutex_lock(&_lock); } void Unlock() { pthread_mutex_unlock(&_lock); } ~Mutex() { pthread_mutex_destroy(&_lock); } private: pthread_mutex_t _lock; };初始化和析构分别调用锁的初始化和销毁即可,加锁解锁也是直接调用接口
不过为了防止出现加锁之后没解锁的情况,我们在此基础上封装一个自动管理类lockguard
cppclass MutexGuard { public: MutexGuard(Mutex *mutexptr) : _mutexptr(mutexptr) { _mutexptr->Lock(); } ~MutexGuard() { _mutexptr->Unlock(); } private: Mutex *_mutexptr; };我们利用对象的构建和析构来自动完成加锁和解锁的工作,这样子就不会忘记解锁工作
代码演示:
cppvoid *route(void *arg) { char *id = (char *)arg; while (1) { { // 临界区起始 LockGuard lockguard(mutex); // 使⽤RAII⻛格的锁 if (ticket > 0) { usleep(1000); printf("%s sells ticket:%d\n", id, ticket); ticket--; } else { break; } } // 临界区结束 } return nullptr; }在该代码段中,使用自动化管理锁的lockguard来进行加锁解锁工作,他的加锁解锁时机跟随生命周期,由于它是局部变量,所以我们的花括号内开始处,他加锁,花括号结尾解锁
注意:添加花括号一是为了更清晰看到临界区的范围,而是为了和while中的非临界区隔离
至于锁的创建与销毁我们则是交给Mutex对象的构造和析构,原理一样
3.同步
3.1条件变量
条件变量是一个结构体
为了避免线程之间因为信息不同步导致的无效频繁访问临界区,我们添加条件变量,让主导线程设置条件变量,非主导线程申请锁后进行判断,如果不能正常完成资源获取,就去条件变量处等待,直到主导线程将条件变量处等待的线程唤醒
应用场景:主线程控制新线程运行
1.初始化环境变量
cpppthread_cond_t gcond = PTHREAD_COND_INITIALIZER;2.设置环境变量等待
参数1:线程等待的指定环境变量
参数2:锁
该接口设置在临界区
3.按顺序唤醒一个线程
参数:环境变量
4.直接唤醒所有线程竞争
参数:环境变量
封装条件变量:
1.mycond.hpp
cpp#pragma once #include <iostream> #include <pthread.h> #include"mymutex.hpp" class mycond { public: mycond() { pthread_cond_init(&_cond,nullptr); } void wait(Mutex &lock) { pthread_cond_wait(&_cond,lock.Get()); } void notifyone() { int n = pthread_cond_signal(&_cond); } void notifyall() { int n = pthread_cond_broadcast(&_cond); } ~mycond() { pthread_cond_destroy(&_cond); } private: pthread_cond_t _cond; };其中构造和析构分别使用条件变量的初始化和销毁,然后单线程唤醒使用signal,多线程唤醒使用broadcast,特别需要注意的是wait接口
wait接口除了使用phtread_mutex_wait的第一个参数外,还要给他传递第二个参数,就是锁,不过因为phtread_mutex_wait是线程库提供的,他需要的锁不是我们自己定义的锁类型,而是原生锁类型,所以我们还需要在mutex封装部分再写一个get接口,以此传递原生锁
补充:mymutex.hpp
cpppthread_mutex_t *Get() { return &_lock; }
3.2生产消费者模型(共享资源整体使用)
3.2.1模型认识
假设:有生产者,中间商,消费者
生产者生产商品,消费者购买商品,中间商具有如下作用
1.降低生产者和消费者的成本
(对生产者可以让生产者集中生产,降低设备运行成本)
(对消费者可以减少行走距离,降低路径成本)
2.通过中间商维护生产者和消费者的松耦合关系,解决生产消费之间的捆绑
中间商可以将生产者的产品缓存起来,从而生产者不再直接根据消费者的即时需求来进行生产
对应到计算机上:
多个线程(数据放置方)需要使用临界资源,多个线程(数据接收方)需要使用临界资源
其中:
1.生产者之间:互斥关系,不可以同时进入临界资源访问
2.消费者之间:互斥关系
3.生产者和消费者之间:互斥关系+同步关系
这里之所以还有同步,是为了防止某一方频繁访问导致另一方的需求被忽略
总结:
三种关系,两种角色,一个交易场所(通常是一个数据结构对象)
3.2.2模型实现
我们要实现的生产者消费者模型是基于阻塞队列的,当队列中没有数据,消费者读取会被阻塞,当队列中数据满了,生产者输入会被阻塞,所以其实管道就是一个进程间的生产者消费者模型
(1)单生产者与单消费者
cpp#include "blockqueue.hpp" #include<unistd.h> struct ThreadData { blockqueue<int> *bq; std::string name; }; void *consumer(void *args) { ThreadData *t = static_cast<ThreadData *>(args); while (true) { sleep(1); int data = 0; t->bq->Pop(&data); std::cout << "消费者消费了一个数据" << data << std::endl; } } void *productor(void *args) { ThreadData *t = static_cast<ThreadData *>(args); int data = 1; while (true) { t->bq->Enqueue(data); std::cout << "生产者生产了一个数据" << data++ << std::endl; } } int main() { blockqueue<int> *bq = new blockqueue<int>(); pthread_t c, p; ThreadData ctd{bq, "消费者"}; pthread_create(&c, nullptr, consumer, (void *)&ctd); ThreadData ptd{bq, "生产者"}; pthread_create(&p, nullptr, productor, (void *)&ptd); pthread_join(c, nullptr); pthread_join(p, nullptr); }使用流程:
1.创建模型主体:生产者和消费者线程,交易所(阻塞队列)
2.进行生产活动:使用blockqueue的生产者生产接口进行生产
3.进行消费活动:和进行生产活动组织方法同理
细节:
将需要传递给线程执行函数的参数设置为结构体,以此可以让两个线程看到同一个阻塞队列(交易所)
2.blockqueue.hpp
cpp#pragma once #include <iostream> #include <string> #include <pthread.h> #include <queue> const static u_int32_t gcap = 5; template <typename T> class blockqueue { public: bool Isfull() { return _bq.size() >= _cap; } bool Isempty() { return _bq.size() == 0 ? true : false; } blockqueue(u_int32_t cap = gcap) : _cap(cap) { pthread_mutex_init(&_lock, nullptr); pthread_cond_init(&_c_cond, nullptr); pthread_cond_init(&_p_cond, nullptr); } ~blockqueue() { pthread_mutex_destroy(&_lock); pthread_cond_destroy(&_c_cond); pthread_cond_destroy(&_p_cond); } void Pop(T *out) { pthread_mutex_lock(&_lock); while (Isempty()) { _c_wait_num++; pthread_cond_wait(&_c_cond, &_lock); _c_wait_num--; } *out = _bq.front(); _bq.pop(); if (_p_wait_num > 0) pthread_cond_signal(&_p_cond); // 唤醒生产者 pthread_mutex_unlock(&_lock); } void Enqueue(const T &in) { pthread_mutex_lock(&_lock); while (Isfull()) // 交易场所满了 { _p_wait_num++; pthread_cond_wait(&_p_cond, &_lock); _p_wait_num--; } // 满足生产条件 _bq.push(in); if (_c_wait_num > 0) pthread_cond_signal(&_c_cond); // 唤醒消费者 pthread_mutex_unlock(&_lock); } private: std::queue<T> _bq; // 阻塞队列 u_int32_t _cap; // 容量 pthread_mutex_t _lock; pthread_cond_t _c_cond; // 消费者条件变量 pthread_cond_t _p_cond; // 生产者条件变量 int _c_wait_num; // 消费者等待数 int _p_wait_num; // 生产者等待数 };根据需求创建相关接口:
1.构造和析构函数
cppblockqueue(u_int32_t cap = gcap) : _cap(cap) { pthread_mutex_init(&_lock, nullptr); pthread_cond_init(&_c_cond, nullptr); pthread_cond_init(&_p_cond, nullptr); } ~blockqueue() { pthread_mutex_destroy(&_lock); pthread_cond_destroy(&_c_cond); pthread_cond_destroy(&_p_cond); }需要保证线程运行的原子性,所以需要锁,需要保证线程的同步性,所以需要条件变量
于是我们在构造和析构函数进行两者的初始化与销毁
细节:条件变量需要有两个,一个是生产者条件变量(生产者等待处),一个是消费者条件变量(消费者等待处)
2.Enqueue接口+Isfull判断函数
cppbool Isfull() { return _bq.size() >= _cap; } void Enqueue(const T &in) { pthread_mutex_lock(&_lock); while (Isfull()) // 交易场所满了 { _p_wait_num++; pthread_cond_wait(&_p_cond, &_lock); _p_wait_num--; } // 满足生产条件 _bq.push(in); if (_c_wait_num > 0) pthread_cond_signal(&_c_cond); // 唤醒消费者 pthread_mutex_unlock(&_lock); }逻辑:
首先要将执行原子化,所以加上锁,然后进行生产条件判断,如果满了无法生产就让生产者进入生产者条件变量处等待,并释放锁
等到生产者被激活后,继续进行生产,将数据写入队列,然后判读是否激活消费者
疑问1:为什么可以在锁内进行阻塞等待?生产者持有锁等待会让消费者永远无法消费!
的确在锁内等待不释放锁会出现问题,但是我们的wait接口是可以做到一方面让线程在条件变量处等待,一方面可以释放当前锁
所以我们才需要在wait接口中使用第二个参数,第二个参数就是要释放的锁
注意:如果该线程再次被唤醒,他会去申请锁,申请成功后从上一次运行的结束位置继续运行
疑问2:为什么不直接在锁外部进行判断,判断成功才进入临界区?
因为进行判断需要访问临界资源,队列是临界资源,而访问临界资源必须在临界区内,所以一定要在锁内进行判断
疑问3:为什么是生产者来激活消费者?
因为消费者是在交易所没数据的时候才会在条件变量处等待,而只有当生产者补货了交易所才会有商品,所以生产者是可以第一时间知道消费者可以进行消费的,可以有他来进行唤醒。
**同理:**生产者也可以让消费者唤醒,因为消费者消费之后,交易所有空位,生产者才可以进行生产
3.Pop接口+Isempty判断函数
cppbool Isempty() { return _bq.size() == 0 ? true : false; } void Pop(T *out) { pthread_mutex_lock(&_lock); while (Isempty()) { _c_wait_num++; pthread_cond_wait(&_c_cond, &_lock); _c_wait_num--; } *out = _bq.front(); _bq.pop(); if (_p_wait_num > 0) pthread_cond_signal(&_p_cond); // 唤醒生产者 pthread_mutex_unlock(&_lock); }逻辑:
大体和生产者逻辑一致,只需要将条件判断改为交易所是否为空,为空才等待
不为空就将数据取出给out变量,然后删除队列中该数据,最后判断是否需要唤醒处于等待的生产者
疑问:为什么这里的判断需要改为循环判断?
为了防止伪唤醒和pthread_cond_wait调用失败的情况让代码错误的执行
一旦出现上述两种情况,就可能在不满足消费条件的情况下执行消费代码,所以我们让该判断变为循环判断
如果唤醒是异常的,就会陷入死循环无法继续执行,或者再次进入条件变量等待
如果唤醒是正常的,再次判断之后就会退出死循环,正确执行代码
(2)多生产者,多消费者模型
其实上面的代码也顺便实现了该模型,因为他只有一把锁,所以不仅生产者和消费者之间的互斥关系满足了,生产者之间和消费者之间的互斥关系也满足了
只需要将生产者和消费者的线程数增加就可以使用了
(3)模型传递数据改为类/函数
我们不仅可以直接传递数据,也可以让消费者端接受生产者端的任务,然后去执行指定的任务,只要将任务设置为一个类,然后定义好变量和接口即可
如果是传递函数,则传递完成后直接执行即可
疑问:为什么说生产者消费者模型是高效的?明明交换过程是串行的?
因为整个生产和消费的过程不仅仅包含两者交换的过程,还包括生产的预处理(数据来源接受),消费的后处理(交换到数据后的处理),他们可以同时并发进行,而不会阻塞住。
非该模型的流程线都是单线运行:生产者预处理->交换数据给消费者->消费者消费数据
预处理和消费者处理不可能同时进行
4.信号量
信号量主要应用于共享资源多份独立使用,是对资源的预定机制
疑问:信号量本身也是共享资源,他如何保证自身原子性?
因为申请信号量(p操作)和释放信号量(v操作)本身就是原子的
4.1信号量接口
信号量所有接口返回值:为0表示成功初始化,为-1表示失败
(1)信号量初始化
参数1:要初始化的信号量地址
参数2:固定为0
参数3:信号量的值
(2)信号量申请等待
参数为信号量地址
(3)信号量释放发布
参数为信号量地址
(4)信号量销毁
参数为信号量地址
4.2封装信号量
cpp#pragma once #include<iostream> #include<semaphore.h> class Sem { public: Sem(int num):_initnum(num) { sem_init(&_sem,0,_initnum); } ~Sem() { sem_destroy(&_sem); } void P() { sem_wait(&_sem); } void V() { sem_post(&_sem); } private: sem_t _sem; int _initnum; };信号量初始化的时候需要显示传参,将初始拥有的信号量输入进去
4.3生产者消费者模型(共享资源分块使用)
共享资源分块使用的交易场所可以是环形队列(使用数组模拟)
1.当队列为空:生产者先执行
2.当队列为满:消费者先执行
3.当队列非空非满:生产者与消费者可以并发执行,对环形队列进行存取操作
疑问1:如何用代码维护第一和第二点?
对于生产者而言,空位置是他需要的,对于消费者而言,有数据的位置是他需要的所以我们定义sem_p表示生产者信号量,sem_c表示消费者信号量
当生产者申请信号量时,实际上是使用环形队列的剩余空位,此时sem_p需要减少
当生产完成,队列中多了对应个数的数据,此时sem_c增加
同理:消费者申请信号量sem_c减少,消费完成后sem_p增加
(1)单消费者于单生产者
代码:
cpp#include "ringqueue.hpp" void *consumer(void *args) { RingQueue<int> *q = static_cast<RingQueue<int> *>(args); int data = 0; while (true) { sleep(1); q->Pop(&data); std::cout << "消费了一个数据" << data << std::endl; } } void *productor(void *args) { RingQueue<int> *q = static_cast<RingQueue<int> *>(args); int data = 1; while (true) { q->Endata(data); std::cout << "生产了一个数据" << data << std::endl; data++; } } int main() { RingQueue<int> *rq = new RingQueue<int>(); pthread_t c, p; pthread_create(&c, nullptr, consumer, (void *)rq); pthread_create(&p, nullptr, productor, (void *)rq); pthread_join(c, nullptr); pthread_join(p, nullptr); delete rq; return 0; }(1)创建交易所环形队列,以及生产者和消费者线程
(2)在生产者和消费者线程运行函数中,分别执行生产活动接口和消费活动接口,并将结果打印出来
2.ringqueue.hpp
cpp#pragma once #include<iostream> #include<pthread.h> #include<vector> #include<unistd.h> #include"Sem.hpp" static int defaultcap = 10; template<typename T> class RingQueue { public: RingQueue(int cap = defaultcap):_rq(cap),_cap(cap) ,_space_sem(cap),_data_sem(0) ,_p_step(0),_c_step(0) {} ~RingQueue(){} void Endata(const T &in) { _space_sem.P(); _rq[_p_step++] = in; _p_step %= _cap; _data_sem.V(); } void Pop(T *out) { _data_sem.P(); *out = _rq[_c_step++]; _c_step %= _cap; _space_sem.V(); } private: std::vector<T> _rq; int _cap; Sem _space_sem;//生产者信号量 Sem _data_sem;//消费者信号量 int _p_step;//生产者生产位置 int _c_step;//消费者消费位置 };对于Endata接口:
先申请_space_sem信号量,申请成功后就可以直接执行生产活动
将数据写入_p_step位置的环形队列,然后更新_p_step的值
最后释放信号量,将信号量给到_data_sem
疑问:为什么申请到信号量之后就可以直接进行生产活动?
因为申请到生产信号量说明队列一定不为满,此时只有两种情况,就是队列为空,或者队列非空非满,情况1自然只有生产活动可以进行,情况2则并发运行互不影响。
综上,申请到信号量就可以直接生产了
对于Pop接口:
和Endata接口同理,申请到消费信号量就可以直接消费了(将数据取走),然后再释放信号给生产者信号量
疑问1:我这里的代码都没有使用锁,为什么就可以保证非空非满的情况下生产者和消费者是互斥于同步的?
因为信号量间接的实现了,在非空非满的情况下,生产者和消费者只会访问临界资源的不同区域,他们的step是一定不同的
疑问2:对资源是否就绪的判断转移到哪了?
之前互斥锁中,我们需要在锁保护区域内判断资源是否就绪,但是使用了信号量之后,资源是否就绪就直接体现在信号量是否申请成功上了。
以消费者为例:
若申请到了信号量,说明队列一定非空,资源一定就绪
疑问3:锁和信号量的关系?
在使用整体资源时,使用锁,使用局部资源时,会用信号量。
核心区别:锁保护的资源只有一份,信号量保护的资源有多份
而信号量的值也可以设置为1,只保护一份资源,此时信号量就和锁完全一样了
总结:锁就是信号量的一种特殊情况
(2)多生产者多消费者模型
疑问:在多生产者和多消费者模型下,我们可以直接使用之前的代码吗?
不能,因为我们的信号量只能保证生产者和消费者之间的互斥与同步关系,但是生产者之间和消费者之间的互斥关系是无法维护的
可能会出现多个生产者都申请到了信号量,然后同时对某个位置的环形队列做生产操作
**解决方法:**我们可以给生产者和消费者都添加锁,让生产者之间保持互斥关系,消费者之间保持互斥关系
那么,是先申请信号量还是先申请锁?
先申请信号量,再申请锁,因为信号量申请放在锁内会导致申请信号量也变成串行
而先申请信号量,那么信号量的申请是所有线程可以并行申请的,然后再串行申请锁,此时就增加了效率
cppvoid Endata(const T &in) { _space_sem.P(); { _p_lock.Lock(); _rq[_p_step++] = in; _p_step %= _cap; _p_lock.Unlock(); } _data_sem.V(); } void Pop(T *out) { _data_sem.P(); { _c_lock.Lock(); *out = _rq[_c_step++]; _c_step %= _cap; _c_lock.Unlock(); } _space_sem.V(); }










