1 线程互斥
1-1进程线程间的互斥相关背景概念
1-2 线程为什么要互斥
线程之间是相互共享地址空间的,所以线程会共享大部分资源---公共资源,这就会导致可能几个线程同一时间都会访问一个公共资源,这会导致数据不一致问题,所以要引入线程互斥与同步(这个我们之后再讨论)
1-3 重谈信号量
之前我们在进程间通信的时候谈过信号量,但是谈的很草率,现在就在线程部分好好重新讲解一下信号量
1-3-1 信号量是什么
可以理解为信号灯,本质是一个计数器,用来表示临界资源中,资源数量的多少
1-3-2 理解信号量
平常我们看电影需要购买电影票,购买电影票的时候需要选取位置,当位置选完后这个位置就标识有人不可以再被别人选走了。
电影院的某个放映厅就是一个临界资源(内部包含了很多子资源)。
电影院最怕两个问题:
1 票卖多了
2 票号重复
其中,买票:本质就是对于资源的预定,解决票卖多的问题。而信号量就像是这个座位的计数器,描述的是这个这个电影院还剩下多少作为,什么座位可以坐什么座位有人预定了,解决票号重复的问题。
所以信号量这就是个计数器,描述的是临界资源中,资源数量的多少。所有进程/线程,访问临界资源中的一小块,就必须申请信号量。进程访问资源前,先申请信号量,本质就是对于资源的预订机制(资源给你了等你随时访问没人和你抢)
细节1 :
信号量本身就是共享资源,所以申请和释放信号量也要是原子的。申请就是--计数器 ->P操作。释放信号量就是++技术器 -> V操作。通过对于信号量的PV操作来完成资源的预定机制。
细节2:
信号量只有0或者1两态的信号量称为二元信号量,否则就是多元信号量
当信号量这个计数器已经被用完时,则操作信号量的进程/线程会被阻塞挂起。
在操作系统中,信号量也是要被管理的,所以先描述再组织。上述理解其实我们就可以猜测到信号量的结构了
struct sem { // int count ---计时器 // 锁 --- 用来保证原子性操作 // waitqueue --- 阻塞挂起的等待队列 };1-4 互斥量mutex
大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量。 但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。 多个线程并发的操作共享变量,会带来⼀些问题。
例如下面售票的代码:
#include <pthread.h> #include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> int ticket = 100; void *route(void *arg) { char *id = (char *)arg; while (1) { if (ticket > 0) { usleep(1000); printf("%s sells ticket:%d\n", id, ticket); ticket--; } else { break; } } } 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_create(&t4, NULL, route, (void *)"thread 4"); pthread_join(t1, NULL); pthread_join(t2, NULL); pthread_join(t3, NULL); pthread_join(t4, NULL); } ⼀次执⾏结果: thread 4 sells ticket:100 ... thread 4 sells ticket:1 thread 2 sells ticket:0 thread 1 sells ticket:-1 thread 3 sells ticket:-2当其中一个线程正在执行这个三步分别的汇编代码时候,有可能会被其他线程打断
取出ticket--部分的汇编代码 objdump -d a.out > test.objdump 152 40064b: 8b 05 e3 04 20 00 mov 0x2004e3(%rip),%eax # 600b34 <ticket> 153 400651: 83 e8 01 sub $0x1,%eax 154 400654: 89 05 da 04 20 00 mov %eax,0x2004da(%rip) # 600b34 <ticket>1-5 互斥量的接口
1-5-1 初始化互斥量
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER//设置在全局域中方法2,动态分配:
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr); 参数: mutex:要初始化的互斥量 attr:输入对对应互斥量的属性设置,一般使用默认的传NULL就行 返回值: 成功返回0 不成功返回非01-5-2 销毁互斥量
int pthread_mutex_destroy(pthread_mutex_t *mutex); 参数: mutex:要销毁的互斥量 返回值: 成功返回0 不成功返回非01-5-3 互斥量的加锁和解锁
int pthread_mutex_lock(pthread_mutex_t *mutex); 参数: mutex:要加锁的互斥量 返回值: 成功返回0,失败返回错误号 int pthread_mutex_trylock(pthread_mutex_t *mutex);//加锁失败不会阻塞 参数: mutex:要加锁的互斥量 返回值: 成功返回0,失败返回错误号 int pthread_mutex_unlock(pthread_mutex_t *mutex); 参数: mutex:要解锁的互斥量 返回值: 成功返回0,失败返回错误号#include <pthread.h> #include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> int ticket = 100; pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; void *route(void *arg) { char *id = (char *)arg; while (1) { pthread_mutex_lock(&mutex); if (ticket > 0) { usleep(1000); printf("%s sells ticket:%d\n", id, ticket); ticket--; pthread_mutex_unlock(&mutex); } else { pthread_mutex_unlock(&mutex); 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_create(&t4, NULL, route, (void *)"thread 4"); pthread_join(t1, NULL); pthread_join(t2, NULL); pthread_join(t3, NULL); pthread_join(t4, NULL); }我们先对上述代码中进行区域划分
从上图中可以看出,临界区是对临界资源的一种保护,其本质就是用锁对临界区代码进行保护
如果有一个线程,不遵守你的规则呢?那是你在写bug,所有线程都必须遵守
加锁之后,在临界区内部,允许线程切换吗?切换了会怎么样?是允许切换的,因为我当前进程,并没有释放锁,我是锁的持有者的身份被切换的,即使我不在了,其他线程也得等我回来执行完代码,释放锁,其他线程才能展开锁的竞争,从而进入临界区。所以切换了页不会怎么样,所有线程都必须等我跑完
举例理解:
今天有一个超级自习室,每个人都想进入,但是这个自习室规定了一次只能进一个人。所以当第一个到的人就会将墙上的钥匙拿走,所以就算他离开自习室出去吃饭的时候,他只要带上钥匙,那么其他人也还是无法进入这个自习室。那么站在外边的人是如何看待你自习的过程的?要么不用,要么用完,对外边的人才有意义。所以这体现了原子性,你的自习,对于外边的人来说是原子的!!
1-6互斥量实现原理探究
1-6-1锁的原理
1 硬件级实现:关闭时钟中断,则线程不会发生切换(我们这里不详细描述)
2 软件级实现:为了实现互斥锁操作,大多数体系结构都提供了swap或者exchange指令(只有一条指令保证原子性),该指令的作用是把寄存器和内存单元的数据相交互。
即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时另⼀个处理器的交换指令只能等待总线周期。 现在我们把lock和unlock的伪代码改一下
锁的原理本质就是在内存中有一个非0的数。
如果今天有两个线程A和B竞争锁,当他们同时执行lock时。先将对应存放mutex的寄存器清零,然后再执行交换内存中锁的值的。因为指令是原子性,所以只会保证只有一个线程会拿到这个值,所以只会有一个线程进入临界区。而且因为这个数据会保存在执行流硬件上下文,所以可以保证切换其他进程拿不到“锁”,进入不了临界区。
1-6-2 理解锁的原理
进程/线程切换:CPU内的寄存器硬件只有一套(理解锁的前提),但CPU寄存器内的数据可以有多份,各自一份,存储在当前执行流的上下文
换句话说,如果把一个变量的内容,交换到CPU寄存器内部,本质是:把该变量的内容,获取到当前执行流的硬件上下文,当前CPU寄存器的硬件上下文(其实就是各个寄存器的内容),属于:进程/线程私有的!!
我们用swap,exchange将内存中的变量,交换到CPU的寄存器中:本质是当前线程/进程,在获取锁,因为是交换,不是拷贝!!所以只有一份,所以,谁申请到,谁持有锁!!
2 线程同步
2-1 初步理解线程同步
引入新的技术,必定引入新的问题,为了进一步解决问题,就必须有新的技术别引入。而线程的同步的引入就是为了解决线程互斥引入锁引发的问题。
在我们谈互斥的时候曾经谈过一个自习室的例子,你思考一下如果这个人一个人已经拿到锁了,现在准备出来,但是他不走,故意将钥匙放回,但是因为他刚将钥匙放回,所以他理论上比任何一个人都要距离这个钥匙都近,此时他决定再申请钥匙就又可以拿到了。如此反复的话。其他人就不可能得到钥匙。这就会导致线程饥饿问题。
但是这样纯互斥,有错吗?没错!但是会导致不太高效,所以引入了线程同步。、
那线程同步到底是什么?再次以上述例子为例。如果这个时候引入一个自习室管理员。他管理这个自习室,并且定下了两个规则。其一,不能立即二次申请,其二,外边的人,进行排队,退出的人,必须跑到队列的尾部,进行二次申请
这样再保证自习室安全的前提下,让所有执行流,访问临界资源,按照一定的顺序进行访问资源,这就是线程同步
2-2 同步概念与竞态条件
2-3 条件变量
2-3-1 为什么要条件变量
2-3-2 条件变量函数
初始化
静态分配:
pthread_cond_t gond =PTHREAD_COND_INITIALIZER动态分配:
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrict attr); 参数: cond:要初始化的条件变量 attr:NULL 返回值: 成功返回0,失败返回非0销毁
int pthread_cond_destroy(pthread_cond_t *cond) 参数: cond:要初始化的条件变量 返回值: 成功返回0,失败返回非0使用 PTHREAD_COND_INITIALIZER 初始化的互斥量不需要销毁
等待条件满足
int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex); 参数: cond:要在这个条件变量上等待 mutex:互斥量,后⾯详细解释 返回值: 成功返回0,失败返回非0等待是什么条件才会等?等待之前就要对资源数量进行判断。判定本身就是访问临界资源!所以判定一定是在临界区内部的。判断的结果,也一定是在临界资源内部。所以,条件不满足要休眠,一定是在临界区休眠的。所以当这个等待的线程被唤醒,也是要先竞争锁成功才会从pthread_cond_wait 成功返回。
证明一件事:条件变量,可以允许线程等待可以允许一个线程唤醒在cond等待的其他线程,实现同步过程
pthread_cond_wait调用成功,挂起当前线程之前,要先自动释放锁
唤醒等待
int pthread_cond_broadcast(pthread_cond_t *cond); 参数: cond:要在这个条件变量上等待的线程全部唤醒 返回值: 成功返回0,失败返回非0 int pthread_cond_signal(pthread_cond_t *cond); 参数: cond:要在这个条件变量上等待的一个线程唤醒 返回值: 成功返回0,失败返回非02-3-3 理解条件变量
举例理解:
今天有一个人往一个盘子放苹果,然后有多个人想要得到这个苹果。为了保证在放苹果同时又有人在拿苹果导致错乱,所以给这个盘子加上锁。但是,如果放苹果的人放的慢,拿苹果的人拿的很快,就会导致拿苹果的人一直访问锁,访问临界资源,这会导致效率极低。所以,放苹果的人就设置了一个铃铛,在没有放苹果的时候,让拿苹果的人去等待队列里面等,当有苹果的时候用铃铛通知他们,这样就可以提高效率,而而让拿苹果的人访问有一定的顺序性,所以实现了线程同步。而这个铃铛通知机制,和队列等待机制统称为条件变量
2-4 生产者消费者模型
321原则(便于记忆) --- 三种关系,两个角色,一个交易场所
2-4-1为何要使用生产者消费者模型
2-4-2 生产者消费者模型优点
三种要素:
生产者之间,竞争关系,互斥关系
消费者和消费者之间:互斥关系
生产者和消费者之间:互斥 和 同步
两个角色:
生产者角色和消费者角色(线程承担)
一个交易场所:
以特定结构构成的一块“内存”空间
三种要素,生产者,消费者,一个消费场所,这些都是临界资源。
2-5 基于BlockingQueue的生产者消费者模型
2-5-1 BlockingQueue
2-5-2 C++ queue模拟阻塞队列的生产消费模型
#ifndef __BLOCK_QUEUE_HPP__ #define __BLOCK_QUEUE_HPP__ #include <iostream> #include <string> #include <queue> #include <pthread.h> template <typename T> class BlockQueue { private: bool IsFull() { return _block_queue.size() == _cap; } bool IsEmpty() { return _block_queue.empty(); } public: BlockQueue(int cap) : _cap(cap) { _productor_wait_num = 0; _consumer_wait_num = 0; pthread_mutex_init(&_mutex, nullptr); pthread_cond_init(&_product_cond, nullptr); pthread_cond_init(&_consum_cond, nullptr); } void Enqueue(T &in) // ⽣产者⽤的接⼝ { pthread_mutex_lock(&_mutex); while(IsFull()) // 保证代码的健壮性 { // ⽣产线程去等待,是在临界区中休眠的!你现在还持有锁呢!!! // 1. pthread_cond_wait调⽤是: a. 让调⽤线程等待 b. ⾃动释放曾经持有的 //_mutex锁 c. 当条件满⾜,线程唤醒,pthread_cond_wait要求线性 // 必须重新竞争_mutex锁,竞争成功,⽅可返回!!! // 之前:安全 _productor_wait_num++; pthread_cond_wait(&_product_cond, &_mutex); // 只要等待,必定会有 //唤醒,唤醒的时候,就要继续从这个位置向下运⾏!! _productor_wait_num--; // 之后:安全 } // 进⾏⽣产 // _block_queue.push(std::move(in)); // std::cout << in << std::endl; _block_queue.push(in); // 通知消费者来消费 if(_consumer_wait_num > 0) pthread_cond_signal(&_consum_cond); // pthread_cond_broadcast pthread_mutex_unlock(&_mutex); } void Pop(T *out) // 消费者⽤的接⼝ --- 5个消费者 { pthread_mutex_lock(&_mutex); while(IsEmpty()) // 保证代码的健壮性 { // 消费线程去等待,是在临界区中休眠的!你现在还持有锁呢!!! // 1. pthread_cond_wait调⽤是: a. 让调⽤进程等待 b. ⾃动释放曾经持有的 //_mutex锁 _consumer_wait_num++; pthread_cond_wait(&_consum_cond, &_mutex); // 伪唤醒 _consumer_wait_num--; } // 进⾏消费 *out = _block_queue.front(); _block_queue.pop(); // 通知⽣产者来⽣产 if(_productor_wait_num > 0) pthread_cond_signal(&_product_cond); pthread_mutex_unlock(&_mutex); // pthread_cond_signal(&_product_cond); } ~BlockQueue() { pthread_mutex_destroy(&_mutex); pthread_cond_destroy(&_product_cond); pthread_cond_destroy(&_consum_cond); } private: std::queue<T> _block_queue; // 阻塞队列,是被整体使⽤的!!! int _cap; // 总上限 pthread_mutex_t _mutex; // 保护_block_queue的锁 pthread_cond_t _product_cond; // 专⻔给⽣产者提供的条件变量 pthread_cond_t _consum_cond; // 专⻔给消费者提供的条件变量 int _productor_wait_num; int _consumer_wait_num; };2-6 为什么pthread_cond_wait需要互斥量?
// 错误的设计 pthread_mutex_lock(&mutex); while (condition_is_false) { pthread_mutex_unlock(&mutex); //解锁之后,等待之前,条件可能已经满⾜,信号已经发出,但是该信号可能被错过 pthread_cond_wait(&cond, &mutex); pthread_mutex_lock(&mutex); } pthread_mutex_unlock(&mutex);2-7条件变量使用规范
pthread_mutex_lock(&mutex); 设置条件为真 pthread_cond_signal(cond); pthread_mutex_unlock(&mutex);给条件发送信号代码
pthread_mutex_lock(&mutex); while (条件为假) pthread_cond_wait(cond, mutex); 修改条件 pthread_mutex_unlock(&mutex);2-8 POSIX信号量
2-8-1 回顾信号量
POSIX信号量和上面谈的信号量是一样,本质都是一个计数器,都是对于资源的预订机制。指示在接口实现上用了POSIX的方案风格
多线程使用资源,有两种情况:
1 将目标资源整体使用 (mutex+2元信号量)
2 将目标资源按照不同的块,分批使用(信号量)
所有的线程都要看到sem,计数器,sem++,sem--。信号量本质也是临界资源
2-8-2POSIX信号量接口
初始化信号量:
#include <semaphore.h> int sem_init(sem_t *sem, int pshared, unsigned int value); 参数: sem:输出型参数,传入要初始化的信号量 pshared:0表⽰线程间共享,⾮零表⽰进程间共享 value:信号量初始值 返回值: 成功返回0,失败返回非0销毁信号量
int sem_destroy(sem_t *sem); 参数: sem:表示要销毁的信号量 返回值: 成功返回0,失败返回非0等待信号量
功能:等待信号量,会将信号量的值减1 int sem_wait(sem_t *sem); //P() 参数: sem:输入型参数,传入要操作的信号量 返回值: 成功返回0,失败返回非0发布信号量
功能:发布信号量,表⽰资源使⽤完毕,可以归还资源了。将信号量值加1。 int sem_post(sem_t *sem);//V() 参数: sem:输入型参数,传入要操作的信号量 返回值: 成功返回0,失败返回非02-8-3 基于环形队列的生产消费模型
环形队列采用数组模拟,用模运算来模拟环状特性
#pragma once #include <iostream> #include <semaphore.h> // 随⼿做⼀下封装 class Sem { public: Sem(int n) { sem_init(&_sem, 0, n); } void P() { sem_wait(&_sem); } void V() { sem_post(&_sem); } ~Sem() { sem_destroy(&_sem); } private: sem_t _sem; }; #pragma once #include <iostream> #include <string> #include <vector> #include <semaphore.h> #include <pthread.h> // 单⽣产,单消费 // 多⽣产,多消费 // "321": // 3: 三种关系 // a: ⽣产和消费互斥和同步 // b: ⽣产者之间: // c: 消费者之间: // 解决⽅案:加锁 // 1. 需要⼏把锁?2把 // 2. 如何加锁? template<typename T> class RingQueue { private: void Lock(pthread_mutex_t &mutex) { pthread_mutex_lock(&mutex); } void Unlock(pthread_mutex_t &mutex) { pthread_mutex_unlock(&mutex); } public: RingQueue(int cap) : _ring_queue(cap), _cap(cap), _room_sem(cap), _data_sem(0), _productor_step(0), _consumer_step(0) { pthread_mutex_init(&_productor_mutex, nullptr); pthread_mutex_init(&_consumer_mutex, nullptr); } void Enqueue(const T &in) { // ⽣产⾏为 _room_sem.P(); Lock(_productor_mutex); // ⼀定有空间!!! _ring_queue[_productor_step++] = in; // ⽣产 _productor_step %= _cap; Unlock(_productor_mutex); _data_sem.V(); } void Pop(T *out) { // 消费⾏为 _data_sem.P(); Lock(_consumer_mutex); *out = _ring_queue[_consumer_step++]; _consumer_step %= _cap; Unlock(_consumer_mutex); _room_sem.V(); } ~RingQueue() { pthread_mutex_destroy(&_productor_mutex); pthread_mutex_destroy(&_consumer_mutex); } private: // 1. 环形队列 std::vector<T> _ring_queue; int _cap; // 环形队列的容量上限 // 2. ⽣产和消费的下标 int _productor_step; int _consumer_step; // 3. 定义信号量 Sem _room_sem; // ⽣产者关⼼ Sem _data_sem; // 消费者关⼼ // 4. 定义锁,维护多⽣产多消费之间的互斥关系 pthread_mutex_t _productor_mutex; pthread_mutex_t _consumer_mutex; };实现这个生产消费者模型要遵守4个约定。
1 队列空的时候,生产者现运行
2 队列满的时候,消费者先运行
3 生产者不能把消费者套两个圈以上
4 消费者不能超过生产者
只要我们不访问同一个位置的时候,我们就可以同时访问。
什么时候,我们会在同一个位置上?为空或者为满的时候!!(如果不为空或者不为满同时运行)
为空:只能[互斥]生产者先[同步]运行
为满:只能[互斥]消费者先[同步]运行
结论:
1 环形队列,不为空 && 不为满,生产消费可以同时进行
2 环形队列,为空||为满,生产和消费,需要同步互斥
如何保证实现这些约定的?这就更信号量的机制有关,事项划分资源,当想要访问资源都会检查信号量这个计数器判断是否合法
注意访问同一块空间的时候操作都是原子的
2-8-4 再次理解信号量
进一步理解信号量:
信号量的另一个本质:信号量把对临界资源是否存在?是否就绪?等条件,以原子的形式,呈现在访问临界资源之前就判断了!!
如果资源可以拆分,你可以考虑sem。
如果对于资源是整体使用的,你就使用mutex
3线程安全和重入问题
3-1 概念
3-2 结论
4常见锁概念
4-1 死锁
造成的结果是