1.线程互斥
1.1 不加锁时,多线程访问全局变量出现的问题
观察代码:
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <pthread.h> int ticket = 1000; void *route(void *arg) { char *id = (char *)arg; while (1) { if (ticket > 0) // 1. 判断 { usleep(1000); ticket--; printf("%s出票成功,剩余票数:%d\n", id, ticket); } else { break; } } return nullptr; } int main(void) { 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_join(t1, NULL); pthread_join(t2, NULL); pthread_join(t3, NULL); }
上述代码的运行结果如图所示 :当自定义方法内的 if 条件不满足时,可以看到线程仍然执行了 ticket-- 的代码,并将其减到了负数,这显然是不对的。
1.2 什么是原子性?
注:在分析上述流程前,需要知道几点
①:在线程切换时,cpu会将当前线程寄存器中的数据临时备份一份,此时其他线程就可以直接覆盖当前线程的所有数据,当新线程执行完毕时,再将老线程的中的数据拷贝回寄存器中,重新开始执行。
②:逻辑运算和算数运算在cpu内部是会加载到不同寄存器当中,分别执行运算的。
③:原子性 ,只有执行完 和不执行两种状态,不存在第三种状态(例如当前代码执行一半,因为线程切换不执行了)
代码中将全局变量 ticket-- 的操作可以大致分为三步,如下图所示:假设开始时运行线程A,当执行完第②步时,此时 ticket 经过cpu的运算处理后变为 99,并且pc指针指向 0xFF04。正当CPU想要执行第③步时,即将寄存器中新的 ticket 值拷贝回内存时,假设此时当前线程的时间片运行完毕,发生了线程切换,那么新的线程B会从第①步重新开始,此时内存中的ticket值仍为100,假设线程B一直不断运行,将全局变量的ticket减到了1,此时又发生了线程切换执行线程A,执行线程A前,cpu会将先前存储的有关线程A的上下文先拷贝到cpu的寄存器当中(例如寄存器中的变量,pc指针),此时CPU会跟着上一次线程A结束的位置往后执行,即将ticket = 99 拷贝如内存中,这样一来,线程B所有努力都功亏一篑
注1 :上述过程证明了全局变量ticket,在减减时不具备原子性。
注2:简单理解的话,一条汇编指令一定是有原子性的,而多条汇编指令没有。
问:为什么ticket会被减到负数?答:我们再来分析这三条汇编代码
假设此时ticket为1,此时线程A从内存中取到ticket = 1,并进行逻辑运算发现满足if条件,于是开始执行,但是假设此时发生进程切换,线程B也同样会从 内存中吧取到 ticket = 1,执行相应语句,同时在发生线程切换转到运行线程C也是同样,那么此时一共就有三个线程
(A、B、C)在执行满足if条件时相应的代码。当A线程中的ticket--后,会拷贝回内存中,当执行线程B时,因为逻辑运算和算数运算是分开进行的,cpu在执行线程B的操作时,会重新将内存中的ticket拷贝到cpu的寄存器中执行算数运算,而此时内存中的ticket = 0,减减后再将-1拷贝回内存,同理线程C会将ticket的值变为-2,这就是为什么能够看到ticket变成负数的原因。
1.3 互斥锁/互斥量
为了解决上述问题,我们就需要引入锁的概念:
初始化互斥锁/互斥量:
法一:静态分配
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
法二:
int pthread_mutex_init ( pthread_mutex_t * restrict mutex, const pthread_mutexattr_t * restrict attr);
参数:
mutex : 要初始化的互斥量
attr : NULL
互斥量加锁和解锁:
int pthread_mutex_lock ( pthread_mutex_t *mutex);
int pthread_mutex_unlock ( pthread_mutex_t *mutex);
返回值 : 成功返回 0 , 失败返回错误号
摧毁互斥量:
int pthread_mutex_destroy ( pthread_mutex_t *mutex) ;
注1:使用 PTHREAD_MUTEX_INITIALIZER 初始化的互斥量不需要销毁
注2 : 不要销毁⼀个已经加锁的互斥量
注3 : 已经销毁的互斥量,要确保后⾯不会有线程再尝试加锁
引入互斥量改进上述代码:
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <pthread.h> int ticket = 1000; pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; void *route(void *arg) { char *id = (char *)arg; while (1) { pthread_mutex_lock(&lock); if (ticket > 0) // 1. 判断 { usleep(1000); ticket--; printf("%s出票成功,剩余票数:%d\n", id, ticket); pthread_mutex_unlock(&lock); } else { pthread_mutex_unlock(&lock); break; } } return nullptr; } int main(void) { 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_join(t1, NULL); pthread_join(t2, NULL); pthread_join(t3, NULL); }
运行结果如下图所示:
问题1:锁是用来保护临界资源的,而锁本身也是一种临界资源,那么由谁来保护锁?答:锁的申请过程是原子性的,锁申请成功就向后运行临界区代码和资源,失败则阻塞挂起申请执行流
问题2:加锁之后,在临界区内部,允许进程切换吗?切换会怎么样?
答:允许,因为当前线程是加锁的,并没有释放锁,当前线程在持有锁的情况下被切换,那么其他线程也无法被执行,会重新进行进程切换,直至拥有锁的进程被执行完并解锁时。这也就是为什么加锁之后,运行时间会相应增加。
1.4 互斥锁/互斥量的原理
实现锁的方式:
①:硬件级实现 → 关闭时钟中断
注:这显然是不可取的!
②:软件级实现 → 下方伪代码
1.4.1 分析软件级实现锁的原理
在分析原理前,先举一个不恰当的例子:
假设学校里有一件超级自习室只供一个人使用,门口有一把钥匙,谁拥有这把钥匙,谁就有使用这个自习室的权利。假设此时某人带着钥匙进来,那么其他成员就都用不了。
而当这名成员如果带着钥匙吃饭去了,走之前把自习室锁上了,那么其他成员同样也用不了这件自习室。
现在再来分析软件级实现锁的原理:初始时,锁在内存中设置为1。
假设现在有一个线程A,开始时它指向语句①,将寄存器中的值初始化为0。
开始执行语句②,语句②的意思是:将寄存器中mutex的值与寄存器中al中的值进行互换!
结果如下图所示:
如果此时进行线程切换,假设为线程B,切换前会将线程A中al的值拷贝一份,而线程B的值会直接覆盖当前寄存器中的值,那么当线程B来到第②步时,此时内存中mutex的值为0,交换后仍为0,那么线程B就会挂起等待。
在此后,无论在第③步,还是第④步,当发生进程切换时,其他线程只能在内存中拿到 mutex = 0,最终会执行挂起等到的操作。就好比:钥匙只有一把,mutex = 1 也只有一个,谁(哪个线程)拿到了,其他人就没有可以使用的权利。
注:前面我们说了,申请锁是具有原子性的,上述xchgb &al mutex 只有一行汇编指令,因此具有原子性。
2. 线程同步
上述的代码存在一个问题:上述代码中一共有四个线程,当线程1在执行时,突然另一个线程2访问某个变量,而在线程1改变状态之前,线程2什么都做不了。
于是就会看到明明有四个线程存在,却只有线程1在执行。
注 :这种状态下所引起的就是线程饥饿的问题。为了避免线程饥饿的问题,于是引入了同步的概念
同步:
在保证数据安全的前提下,让线程能够按照 某种特定的顺序访问临界资源,从而有效避免
饥饿问题,叫做同步
注:通过引入条件变量来使得线程能够按照顺序访问临界资源
竞态条件:
因为时序问题,而导致程序异常,我们称之为竞态条件。在线程场景下,这种问题也
不难理解
2.1 条件变量函数
初始化:
int pthread_cond_init ( pthread_cond_t * restrict cond, const pthread_condattr_t
* restrict attr);
参数:
cond:要初始化的条件变量
attr: NULL
销毁:
int pthread_cond_destroy ( pthread_cond_t *cond)
注:当条件变量设置为全局时,不需要销毁,若为局部的,则需要销毁,这点和互斥锁是相同的
条件等待:
int pthread_cond_wait ( pthread_cond_t * restrict cond, pthread_mutex_t * restrict
mutex);
参数:
cond:要在这个条件变量上等待
mutex:互斥量,后⾯详细解释
注1:条件等待的功能之一是将当前线程暂停,同时释放互斥锁
问:为什么要释放互斥锁?
答:程序设计的过程中,如果遇到条件等待说明此时线程已经不满足条件了,应当执行其他线程,通过上面对线程互斥的学习中,我们知道,多线程通信间只会存在一把锁,如果要执行其他线程,就需要将当前线程的锁释放,这样其他下线程才会运行。
注2:通过cond来区分是否是同一个条件
注3:如果被唤醒时,申请锁失败了,就会在锁上阻塞等待。
唤醒等待线程:
int pthread_cond_broadcast ( pthread_cond_t *cond);
唤醒所有满足条件的线程
int pthread_cond_signal ( pthread_cond_t *cond);
唤醒所有满足条件的线程中的一个
注:在唤醒前,即将唤醒的线程通过 pthread_cond_wai 重新取回互斥锁,继续执行 pthread_cond_wai 后面的代码。
总结:针对条件变量我们需要做以下几点认识:①.不同条件变量名 意味着 条件不同,条件变量名尽可能的赋予实际意义(方便维护代码)
②.抛开条件变量初始化和销毁,其实只有等待和唤醒两种操作,很简单。但是简单的操作成就了多线程间的正常通信!
③.互斥锁成为了多线程间通信的关键,谁拿到了这把锁,谁就可以对临界资源进行访问
④.条件变量一定是在互斥锁之间的,因为条件判断本身也是对临界资源中的数据进行判断,判断的结果也是在临界资源内部的,当条件不满足线程进行休眠时,也是在临界资源内的。
3. 生产消费者模型
生产消费者模型满足"321原则":
**3 → 指三种关系:**消费者之间的互斥关系、生产者之间的互斥关系、生产者和消费者之间的互斥和同步关系
2 → 指两个角色:消费者角色和生产者角色,两者都由线程承担
1 → 一个交易场所:以特定结构构成的 "内存" 空间
生产消费者模型的优点:①. 生产过程和消费过程解耦
注:当一个线程拿到锁,去访问临界资源时,其他线程可以执行非临界区属于自己的代码
②. 支持忙闲不均
注:平日里,生产者可以加点加班生产货物,消费者也不去消费,所有的商品全部屯在超市里。逢年过节,工厂休息,消费者就涌进超时进行购物。
③.提高效率?
4. POSIX信号量
信号量:本质是一个计数器,对特定资源进行预定机制
多线程使用资源分两种:①.将目标资源整体使用[mutex + 2元信号量]
②.将目标资源按照不同的"块",分批使用[信号量]
注:阻塞队列就是对目标资源的整体使用
4.1 信号量相关的函数
#include <semaphore.h>
初始化信号量:
int sem_init ( sem_t *sem, int pshared, unsigned int value);
参数:
pshared: 0 表示线程间共享,非零表示进程间共享
value:信号量初始值
销毁信号量:
int sem_destroy ( sem_t *sem);
等待信号量:
int sem_wait ( sem_t *sem);
功能:
等待信号量,会将信号量的值减 1,P 操作
注:
若当前信号量的值大于0,则将信号量的值减1,并允许线程继续向后执行
若当前信号量的值为0,则当前线程将会被阻塞,直到其他线程执行 sem_post 增加信号量的值
发布信号量:
int sem_post ( sem_t *sem);
功能:
发布信号量,表示资源使⽤完毕,可以归还资源了。将信号量值加 1 , V操作
注:
当调用此函数时,信号量的值增加 1,并且如果有线程因为信号量为 0 而被阻塞,它们可能会被唤醒
4.2 基于环形队列的生产消费模型
4.2.1 什么是环形队列?
如图所示:当队列为空时或者队列为满时,头和尾都指向同一位置处的队列称为环形队列。
此处可以通过数组模拟环形队列,当数组达到最后一个元素时,对数组大小取余就能够回到原来位置。
4.2.2 通过环形队列实现单对单的生产消费者模型
先论述单对单的生产消费者模型:实现一个生产者向数组存入数据,消费者从数组拿出数据的代码
以上述 数组/环形队列 为参照,做以下几点约定:
①. 当数组元素为空时,生产者先运行
②. 当数组元素满时,消费者先运行
③. 生产者不能超消费者一圈
④. 消费者不能超过生产者
注:有了上述约定后,消费者和生产者只可能在 环形队列(数组) 为满 or 为空 时才会处于相同位置(下标)
注 :这里的临界资源其实就是这个环形队列,如果代码设计合理,可以看到当生产者访问一部分临界资源时,消费者同时也在访问另一部分临界资源。今天只是简单插入一个int值,如果我将这个数组设置为vector,里面的元素设置为函数指针呢?是不是就可以分别执行不同任务了?
4.2.3 代码
**头文件Sem.hpp:**对信号量相关函数进行封装
#pragma once #include <iostream> #include <semaphore.h> int default_num = 5; namespace SemModule { class Sem { public: Sem(int value = default_num) { sem_init(&_sem,0,value); } void P() { sem_wait(&_sem); } void V() { sem_post(&_sem); } Sem() { sem_destroy(&_sem); } private: sem_t _sem; }; }
头文件RingQueue.hpp的封装:
#pragma once #include "Sem.hpp" #include "_Mutex.hpp" #include <iostream> #include <unistd.h> #include <vector> using namespace SemModule; using namespace MutexModule; template <class T> class RingQueue { public: RingQueue(int cap = default_num) :_cap(cap), _v(cap), //对于自定义类而言,通过参数化列表构造会自动调用其构造函数,这里就把vector的大小初始化好了 _blank_sem(cap),//生产者初始信号量大小为5 _P_step(0),//生产者数组下标 _data_sem(0),//消费者初始信号量大小为0 _C_step(0)//消费者数组下标 { } void Equeue(const T& in) { //生产者申请信号量,本质是让它做--,因为生产者本身信号量初始值就为5 _blank_sem.P(); //生产,入数据 _v[_P_step] = in; //更新数组下标 _P_step++; _P_step %= _cap;//使之成环 //消费者信号量++,这里就能唤醒等待的消费者线程,让其执行后续代码 _data_sem.V(); } void Pop(T* out) { //消费者申请信号量,因为消费者的初始信号量大小为0,所以会阻塞等待,直到生产者生产一个数据,并释放 _data_sem.P(); //保存数据,保存数据后当前下标就没有用了。 *out = _v[_C_step]; //更新下表 _C_step++; _C_step %= _cap; //生产者信号量++ _blank_sem.V();//当消费者拿完一个数据时,当前位置就空下来了,所以生产的信号量要++ } ~RingQueue(){} private: std::vector<T> _v; int _cap; //生产者 Sem _blank_sem; //生产者信号量 int _P_step; //生产者对应下标,为了不让消费者超过生产者 //消费者 Sem _data_sem; //消费者信号量 int _C_step; };
main.cc主函数:
#include "Task.hpp" #include "RingQueue.hpp" void* productor(void* args) { RingQueue<int> * q = static_cast<RingQueue<int>*>(args); //int i = 0, j = 0; int data = 0; while(true) { q->Equeue(data);//传入一个数据 data++; } return nullptr; } void* consumer(void* args) { RingQueue<int > * q = static_cast<RingQueue<int>*>(args); while(true) { sleep(1); int t = 0; q->Pop(&t);//这里通过传入指针来修改原来的变量 std::cout << "消费者消费了一个数据:" << t << std::endl;//取数据 } return nullptr; } int main() { RingQueue<int >* bq = new RingQueue<int>();//pthread_create 规定第四个参数为指针,所以new一个自定义类出来,通过传参强制类型转换进行自定义类的访问 pthread_t td1,td2; //创建线程1 pthread_create(&td1,nullptr,productor,bq); //创建线程2 pthread_create(&td1,nullptr,consumer,bq); //线程等待 pthread_join(td1,nullptr); pthread_join(td2,nullptr); return 0; }
4.2.4 通过环形队列实现多对多的生产消费者模型
对上述代码中 头文件RingQueue.hpp中的部分代码 进行修改,如下图**:**
void Equeue(const T& in)
{
//生产者申请信号量,本质是让它做--,因为生产者本身信号量初始值就为5
_blank_sem.P();
_plock.Lock();//加锁,这部分同样是对互斥锁进行了封装在调用
//生产,入数据
_v[_P_step] = in;
//更新数组下标
_P_step++;
_P_step %= _cap;//使之成环
_plock.UnLock();
//消费者信号量++,这里就能唤醒等待的消费者线程,让其执行后续代码
_data_sem.V();
}
void Pop(T* out)
{
//消费者申请信号量,因为消费者的初始信号量大小为0,所以会阻塞等待,直到生产者生产一个数据,并释放
_data_sem.P();
_clock.Lock();
//保存数据,保存数据后当前下标就没有用了。
*out = _v[_C_step];
//更新下表
_C_step++;
_C_step %= _cap;
//生产者信号量++
_clock.UnLock();
_blank_sem.V();//当消费者拿完一个数据时,当前位置就空下来了,所以生产的信号量要++
}
增加了消费者间以及生产者间的互斥关系。
问:先申请锁?还是先申请信号量?
答:都可以,但是先申请信号量的效率比先申请锁的效率高!如何理解?假设现在有三个生产者,他们先对资源进行预定,本质就是信号量 -= 3,当他们申请好了之后,三个线程再去争这把锁,谁抢到了,谁就先执行,其他线程阻塞等待。当前线程执行完毕时,释放锁,先前没抢到锁的线程就再依次执行。
注:当某个线程成功申请到锁时,其他线程可以进行申请信号量的操作;当前线程执行完毕时,可以先把锁释放了交给其他线程去执行,自己再去执行其他信号量操作
举一个简单的例子:去看电影,是先排队然后买票?还是先买票再排队?肯定是后者。买票好比申请信号量,排队就好比申请锁。