线程共享地址空间 -> 线程会共享大部分资源 -> 公共资源 -> 数据不一致问题 -> 解决方案:互斥和同步
一、互斥
现象:多个线程执行抢票时,最终把票数抢到了负数。
为什么会抢到负数?
ticket--操作不是原子的 -> C语言要翻译成汇编语言 -> 暂时认为一条汇编是原子的。
ticket--操作翻译成汇编分为三步:
0xFF00 载入ebx ticket 将内存中的ticket值载入到寄存器
0xFF02 减少ebx 1 做运算,寄存器中的值减一,姑且认为是这样
0xFF04 写回内存 ebx 将寄存器中的值写回内存
假设有一个线程A,进行这个ticket--操作,执行1次中已经把ebx的值减减了,正准备执行第三句汇编时,线程A被切换了,此时保存上下文数据,ebx值:99 下一条代码地址:0xFF04。线程B被切换进来了,执行ticket--操作,执行了99次,已经把内存中ticket的值减为1了。此时线程B被切换走了,线程A被切进来了,恢复上下文数据(寄存器的值),然后继续执行第三句汇编,把ebx值99写回到ticket中,ticket值由1->99,此时就出现了数据不一致问题。(抢到负数原理同上)
因此,多线程如果在不保护可变的共享资源情况下,会出现数据不一致问题,且问题导致的结果是随机的。
ticket--不是主要矛盾,判断是主要矛盾,原因:当ticket变为1时,此时有2个及以上线程根据ticket为1大于0的判断进入了代码块,然后减减,造成减到了负数。
多线程中,制造更多的并发,更多的切换。切走的时间点:1.时间片耗尽 2.阻塞IO 3.sleep等。(陷入内核) 选择新的线程,从内核态回到用户态,进行检查。
互斥锁
互斥锁的使用方法:
1.全局互斥锁
pthread_mutex_t mutex = PTHREAD_MUTEX_INITALIZER
只需定义一个全局互斥锁类型的变量,用宏赋值即可。正常使用上锁和解锁方法
全局互斥锁不需要被释放,程序运行结束,会自动释放。
2.非全局互斥锁,自己申请
pthread_mutex_t lock;
pthread_mutex_init(&lock, nullptr);
pthread_mutex_destroy(&lock);
需要进行初始化和手动销毁。
申请锁和释放锁:
pthread_mutex_lock(&lock);
pthread_mutex_unlock(&lock);
竞争申请锁,多线程都得看到同一个锁,锁本身就是共享资源!
因此,申请锁的过程必须是原子的!
申请锁成功:继续向后运行,访问临界区代码,访问临界资源
申请锁失败:阻塞挂起申请执行流
锁提供的能力的本质:执行临界区代码由并行变为串行
在我执行期间,不会被打扰,也是一种变相的原子性的表现。
进程间如何互斥?进程的共享内存,申请锁。
锁的封装
#pragma once #include <pthread.h> namespace quitesix { class Mutex { public: Mutex() { pthread_mutex_init(&_lock, nullptr); } ~Mutex() { pthread_mutex_destroy(&_lock); } void Lock() { pthread_mutex_lock(&_lock); } void Unlock() { pthread_mutex_unlock(&_lock); } private: pthread_mutex_t _lock; }; class MutexGuard { public: MutexGuard(Mutex *mutex) :_mutex(mutex) { _mutex->Lock(); } ~MutexGuard() { _mutex->Unlock(); } private: Mutex *_mutex; }; }
RAII是Resource Acquisition Is Initialization的缩写,他是一种管理资源的类的设计思想,本质是一种利用对象生命周期来管理获取到的动态资源,避免资源泄漏,这里的资源可以是内存、文件指针、网络连接、互斥锁等等。
锁的理解
对临界资源保护:本质就是用锁,来对临界区进行保护
加锁之后,在临界区内部,允许进程切换吗?切换了会怎么样?
允许进程切换。我当前线程,并没有释放锁,我是持有锁被切换的,即使我不在,其他线程也必须等我执行完临界区代码,释放锁后,其他线程才能进行锁的竞争,进入临界区。
切换了不怎么样,其他线程得等我跑完。
实例理解:
临界区是一个超级自习室,只允许一个人待在里面。需要竞争申请锁,拿到钥匙,一个人在自习室期间,没有人能进来。即使这个人去办别的事了,也是持有钥匙走的,别人还是进不去,直到钥匙放回去,别人才能重新竞争进去自习室。
锁的原理
1.硬件级实现:关闭时钟中断
2.软件级实现
lock:
movb $0, %al // 将寄存器%al的数据清0
xchqb %al, mutex // 交换寄存器%al和内存中mutex值
if(al寄存器的内容>0){ // 交换后如果al寄存器值大于0,说明申请锁成功
return 0;
}else
挂起等待; // 申请锁失败,挂起等待
goto lock;
lock中有效影响是否申请锁的只有交换那一句汇编,原子的。
谁交换后值大于0了就代表申请成功了。
核心:交换不会增加资源,只是资源的转移。
CPU寄存器只有一套,但CPU寄存器里的数据有多套 -> 把一个变量的内容交换到CPU寄存器内部,本质:把该变量的内容获取到执行流的硬件上下文 -> CPU寄存器数据是属于进程/线程私有的 -> swap,exchange内存中的变量,交换到CPU寄存器中,本质就是给当前线程/进程,获取锁,而且因为是交换,资源是转移的,并不会增加。
二、线程同步
理解:引入新的技术,必然引入新的问题,为了进一步解决问题,必须有新的技术引入。
线程互斥,本质没有错。但是不高效,不太公平。
实例:超级自习室,一个人自习完了,把钥匙放回到原处了。开始纠结了,再自习一会呢,省得下次等很久,然后又拿着钥匙回去开锁自习了。
其他人得不到钥匙,其他线程 饥饿问题。
不公平的点:上个申请锁的线程“离”锁最近,下次申请更容易。(其他线程在等待队列要被唤醒,还要被调度)
解决:
1.不能立即申请第二次
2.外边的人进行排队,出来的人排最后面,进行二次申请。
在保证自习室安全的情况下,让所有的执行流,访问临界资源,按照一定的顺序进行访问资源。(线程同步)
条件变量的理解
理解:有一个被蒙眼的人要执行放苹果的动作,其他蒙眼的人要去拿苹果。放苹果和拿苹果的操作要求是原子的(要加锁)。如果他们之间没有任何通知,那么放苹果的人要不断地执行放苹果这个动作(不知道盘子里苹果有没有被拿走),拿苹果的人也要不断执行拿苹果这个动作(不知道盘子里有没有放苹果)。
于是有人提出了个想法,每次放苹果的人放完苹果之后就敲一下铃铛,等待拿苹果的队列里就知道苹果准备好了,可以拿了,就去拿苹果,拿完之后也敲一下铃铛。这时放苹果的人知道了盘子是空,要放苹果,就形成闭环了。
当⼀个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了。 例如⼀个线程访问队列时,发现队列为空,它只能等待,只到其它线程将⼀个节点添加到队列中。这种情况就需要用到条件变量。
条件变量接口的使用
局部:
pthread_cond_t cond;
pthread_cond_destory(&cond); //第一个参数cond指针
pthread_cond_init(&cond, nullptr); //第二个参数为属性,不管全局或静态: pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
等待:
pthread_cond_timewait(&cond, &mutex, abstime); // 第三个参数为时间,按时间等
pthread_cond_wait(&cond, &mutex); // 第一个参数为cond指针,第二个参数为锁
唤醒:
pthread_cond_broadcast(&cond); // 唤醒指定条件变量下等待的所有线程
pthread_cond_signal(&cond); // 唤醒在该条件变量下等待的一个线程
demo,用条件变量让线程等待,实现同步
#include <iostream> #include <string> #include <vector> #include <cstdio> #include <pthread.h> #include <unistd.h> #define THREAD_NUM 5 int cnt = 1000; pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; pthread_cond_t cond = PTHREAD_COND_INITIALIZER; void *threadrun(void *arg) { std::string name = static_cast<char *>(arg); delete[] (char *)arg; while (true) { // 申请锁 pthread_mutex_lock(&mutex); // 直接等待,要唤醒才能访问 pthread_cond_wait(&cond, &mutex); std::cout << name << " 当前cnt的值为: " << cnt << std::endl; cnt++; // 释放锁 pthread_mutex_unlock(&mutex); } } int main() { std::vector<pthread_t> tids; for (int i = 0; i < THREAD_NUM; i++) { pthread_t tid; char *name = new char[64]; snprintf(name, 64, "thread-%d", i); pthread_create(&tid, nullptr, threadrun, (void *)name); } while (true) { // 每隔1秒唤醒一个线程 sleep(1); pthread_cond_signal(&cond); } for (int i = 0; i < THREAD_NUM; i++) { pthread_join(tids[i], nullptr); } return 0; }
通过条件sleep,以及直接等待,让所有线程都进入cond的等待队列中,每搁一秒唤醒一次,此时整体就有了顺序性,实现了同步。
- 等待是需要等,什么条件才会等呢?票数为0,等待之前,就要对资源的数量进行判定。
- 判定本身就是访问临界资源!因此判断一定是在临界区内部的.
- 判定结果,也一定在临界资源内部。所以,条件不满足要休眠,一定是在临界区内休眠的!
- 证明一件事情:条件变量,可以允许线程等待。
- 可以允许一个线程唤醒在cond等待的其他线程, 实现同步过程
生产者和消费者模型
理解:生产者为各种工厂,中间是超市,消费者是用户。
工厂生产商品交给超市;超市存放着工厂生产的商品,不是单一的商品,而是来自各个工厂,且品类不同的商品;用户需要从超市买商品。
工厂把商品给超市(工厂->超市)和 消费者买商品(超市->用户)这两个过程是互斥加锁的,串行执行的。因为有前提条件,超市货架不满工厂才能放,超市有东西用户才能买,且工厂放的时候用户不能买。
核心:工厂生产商品的时候不影响消费者使用商品,消费者使用商品不影响工厂生产商品。(生产和消费解耦合)
生产者消费者模型:
3种要素,生产者,消费者,一个交易场所(临界资源)
生产者之间:互斥关系
消费者之间:互斥关系
生产者和消费者之间: 互斥 和 同步
2种角色:生产者角色和消费者角色(线程承担)
1个交易场所:以特定结构构成的一块“内存”空间
记忆:321原则
为什么要有生产者消费者模型?
好处:
1.生产过程和消费过程解耦合
2.支持忙闲不均
3.提高效率
提高效率不是体现在入交易场所和出交易场所上,而是未来获取任务和处理具体任务,是并发的。真正耗时间的是生产(制造)和消费(使用)的过程,入交易场所和出交易场所占比很少,真正耗时间的(生产和消费)解耦,并发执行,就能提高效率。
编写基于blockqueue的生产消费模型
阻塞队列:
#pragma once #include <pthread.h> #include <iostream> #include <queue> const int max_cap = 5; template <typename T> class BlockQueue { private: bool IsFull() { return _q.size() >= _cap; } bool IsEmpty() { return _q.empty(); } public: BlockQueue(int cap = max_cap) : _cap(cap), _csleep_num(0), _psleep_num(0) { pthread_mutex_init(&_mutex, nullptr); pthread_cond_init(&_empty_cond, nullptr); pthread_cond_init(&_full_cond, nullptr); } ~BlockQueue() { pthread_mutex_destroy(&_mutex); pthread_cond_destroy(&_empty_cond); pthread_cond_destroy(&_full_cond); } void Push(const T &data) { pthread_mutex_lock(&_mutex); // 判满,如果满了就等待,生产者 while (IsFull()) { _psleep_num++; pthread_cond_wait(&_full_cond, &_mutex); _psleep_num--; } _q.push(data); // 插入后,现在队列里一定不为空,如果消费者在等待,唤醒 if (_csleep_num > 0) pthread_cond_signal(&_empty_cond); pthread_mutex_unlock(&_mutex); } T Pop() { pthread_mutex_lock(&_mutex); // 判空,如果空了就等待,消费者 while (IsEmpty()) { _csleep_num++; pthread_cond_wait(&_empty_cond, &_mutex); _csleep_num--; } T data = _q.front(); _q.pop(); // 删除后,现在队列里一定有空余,如果生产者在等待,唤醒 if (_psleep_num > 0) pthread_cond_signal(&_full_cond); pthread_mutex_unlock(&_mutex); return data; } private: int _cap; // 阻塞队列的容量 std::queue<T> _q; // 临界资源 pthread_mutex_t _mutex; // 锁 pthread_cond_t _empty_cond; // 空了,消费者等待的条件 pthread_cond_t _full_cond; // 满了,生产者等待的条件 int _csleep_num; // 消费者休眠的个数 int _psleep_num; // 生产者休眠的个数 };
1.关于pthread_cond_wait函数:
重点1:pthread_cond_wait调用成功,挂起当前线程之前,要先自动释放锁!!
重点2:当线程被唤醒的时候,默认就在临界区内唤醒!要从pthread_cond_wait
成功返回,需要当前线程,重新申请_mutex锁!!!
重点3:如果我被唤醒,但是申请锁失败了??我就会在锁上阻塞等待!!!
2.关于判空和判满那为什么要用循环判断而不是if:
问题1: pthread_cond_wait是函数吗?有没有可能失败?pthread_cond_wait立即返回了
问题2:pthread_cond_wait可能会因为,条件其实不满足,pthread_cond_wait 伪唤醒
举例:多线程情况,队列里只有一个数据,两个线程都被唤醒了(pthread_cond_boardcast),第一个线程申请到了锁,成功将数据拿到,此时队列没有数据了,释放锁。第二个线程紧接着申请到了锁,但此时队列中没有数据了,但是因为是if直接就出去了,也去拿数据,然后pop,就报错了。
在释放锁前唤醒和释放锁后唤醒都可以:
第一种:唤醒后,因为没有释放锁,消费者会在申请锁处阻塞,后面释放锁,正常往后执行,消费者被唤醒
第二种:唤醒后,锁已经被释放了,消费者可以申请到锁,消费者被唤醒
对于多消费者,多生产者的情况,阻塞队列的代码同样适用:
原因:多消费者,多生产者相比于单生产者单消费者无非就是要使消费者间互斥,生产者间互斥,锁可以保证这两个条件;单生产者单消费者:消费者和生产者间的同步(条件变量)和互斥(锁)已经保证了。