线程安全
多个执行流对临界资源争抢访问,但是不会出现数据二义性。通俗来讲,就是多个线程并发同一段代码时,不会出现不同的结果。
线程安全如何实现?
互斥
通过同一时间对临界资源访问的唯一性实现临界资源访问的安全性
互斥的实现
互斥锁是一个只有0和1的计数器,描述了一个临界资源当前的访问状态,所有执行流在访问临界资源时都需要先判断当前的临界资源状态是否允许访问,如果不允许则让执行流等待,否则可以让执行流访问临界资源,但是在访问期间需要将状态修改为不可访问状态。这期间如果有其它执行流想要访问,则不被允许。
互斥锁的操作流程以及接口
1.互斥锁变量
pthread_mutex_t mutex;
2.互斥锁初始化
pthread_mutex_init(pthread_mutex_t* mutex, pthread_mutexattr_t* attr);
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
3.在访问临界资源之前进行加锁操作(不能加锁则等待,可以加锁则修改资源状态,然后调用返回,访问临界资源 )
pthread_mutex_lock(pthread_mutex_t* mutex);
阻塞加锁:如果当前不能加锁,则一直等待直到加锁成功调用返回
pthread_mutex_trylock(pthread_mutex_t* mutex);
非阻塞加锁:如果当前不能加锁,则立刻报错返回::EBUSY
挂起等待:将线程状态置为可中断休眠状态,表示当前休眠;
被唤醒:将线程状态置为运行状态,表示当前运行。
4.在临界资源访问完毕之后进行解锁操作(将资源状态置为可访问,将其它执行流唤醒)
pthread_mutex_unlock(pthread_mutex_t* mutex);
5.销毁互斥锁
pthread_mutex_destroy(pthread_mutex_t* mutex);
出现计数错误
所有的执行流都需要同一个互斥锁实现互斥,意味着互斥锁本身就是一个临界资源,都会去访问。
如果互斥锁本身的操作都不安全如何保证别人安全。
互斥锁本身操作首先必须是安全的,互斥锁自身计数的操作是原子操作。
死锁
多个执行流对锁资源进行争抢访问,但是因为访问顺序不当,造成相互等待最终导致程序流程无法继续推进,这时候就造成了死锁。实际上是一种程序流程无法继续推进,卡在某个位置的一种概念。
死锁产生的必要条件
死锁的产生通常是在访问多个锁的时候。
必须具备的条件,有一条不满足就不会产生死锁。
1.互斥条件:自己加了锁,别人就不能再继续加锁
2.不可剥夺条件:自己加的锁,别人不能解,只有自己能解锁
3.请求与保持条件:加了A锁,然后去请求B锁;如果不能对B锁加锁,则也不释放A锁
4.环路等待条件:加了A锁,然后去请求B锁;另一个人加了B锁,然后去请求A锁
死锁的预防
破坏死锁产生的必要条件(主要避免3和4两个条件的产生)
死锁的避免
死锁检测算法/银行家算法
银行家算法思想: 系统的安全状态与非安全状态
一张表记录当前有哪些锁,一张表记录已经给谁分配了哪些锁,一张表记录谁当前需要哪些锁。
按照三张表进行判断,判断若给一个执行流分配了指定的锁,是否会达成环路等待条件导致系统的运行进入不安全状态,如果是就不能分配。
反之,若分配了之后不会造成环路等待,系统是安全的,则分配这个锁:破坏环路等待条件
后续若不能分配锁,可以资源回溯,把当前执行流已经加的锁释放掉:破坏请求与保持
非阻塞加锁操作,若不能加锁,则把手上的其它锁也释放掉:破坏请求与保持
锁的弊端
加锁对临界资源进行保护,实际上对程序的性能是一个极大的挑战
在高性能程序中通常会存在一种无锁编程(LOCK-FREE):CAS锁/一对一的阻塞队列/atomic原子操作
同步
通过条件判断实现临界资源访问的合理性——条件变量
同步的实现
当前是否满足获取资源的条件,若不满足,则让执行流等待,等到满足条件能够获取的时候在唤醒执行流。
条件变量实现同步只提供了两个功能接口:让执行流等待的接口和唤醒执行流的接口。
因此条件的判断是需要进程自身进行操作,自身判断是否满足条件,不满足的时候调用条件变量接口是线程等待
使用接口
1.定义条件变量
pthread_cond_t cond;
2.初始化条件变量
pthread_cond_init(pthread_cond_t* cond, pthread_condattr* attr)
pthread_cond_t cond = PTRHEAD_COND_INITIALIZER;
3.若资源获取条件不满足时调用接口进行阻塞等待
使线程挂起休眠的接口:
pthread_cond_wait(pthread_cond_t* cond, pthread_mutex_t* mutex);
(条件变量是搭配互斥锁一起使用的)
设置阻塞超时时间的等待接口
pthread_cond_timedwait(pthread_cond_t* cond, pthread_mutex_t* mutex, struct timespec* abstime);
4.唤醒线程的接口:
唤醒至少一个等待的线程(并不是唤醒单个)
pthread_cond_signal(pthread_cond_t* cond );
唤醒所有等待的线程
pthread_cond_broadcast(pthread_cond_t* cond);
5.销毁条件变量
pthread_cond_destroy(pthread_cond_t* cond );
注意事项:
1.条件变量使用中对条件的判断应该使用while循环
2.多种角色线程应该使用多个条件变量
生产者与消费者模型应用场景
有线程不断生产数据,有线程不断处理数据
数据的生产与数据的处理,放在同一个线程中完成,因为执行流只有一个,那么肯定是生产一个处理一个,处理完一个后才能生产一个,这样依赖关系太强,如果处理比较慢,也会拖累生产速度慢下来
因此将生产与处理放到不同的执行流中完成,中间增加一个数据缓冲区,作为中间数据缓冲场所
解决的问题:
1.解耦合
2.支持忙闲不均
3.支持并发
实现的关键在于线程安全的队列
封装一个线程安全的BlockQueue–阻塞队列–向外提供安全的入队/出队操作
信号量
用于实现进程/线程间同步与互斥(主要用于实现同步)
本质是一个计数器+PCB等待队列
信号量同步实现
通过自身的计数器对资源进行计数,并且通过计数器的资源计数,判断进程/线程是否能够符合访问资源的条件,若符合就可以访问,若不符合则调用提供的接口是进程/线程阻塞;其它进程/线程促使条件满足后,可以唤醒PCB等待队列上的PCB。
信号量互斥实现
保证计数器的计数不大于1,就保证了资源只有一个,同一时间只有一个进程/线程能够访问资源,实现互斥
代码操作以及使用接口
1.定义信号量
sem_t sem;
2.初始化信号量
int sem_init(sem_t* sem, int pshared, unsigned int value);
3.在访问临界资源之前,先访问信号量,判断是否能够访问:计数-1
int sem_wait(sem_t* sem);
通过自身计数判断是否满足访问条件,不满足直接一直阻塞线程/进程
int sem_trywait(sem_t* sem);
通过自身计数判断是否满足访问条件,不满足则立刻报错返回
int sem_timedwait(sem_t* sem, const struct timespec* abs_timeout);
不满足则等待指定时间,超时后报错返回ETIMEDOUT
4.促使访问条件满足+1,唤醒阻塞线程/进程
int sem_post(sem_t* sem);
通过信号量唤醒自己阻塞队列上的PCB
5.销毁信号量
int sem_destroy(sem_t* sem);
线程池
线程的池子,有很多线程,但是数量不会超过池子的限制。需要用到多执行流进行任务处理的时候,就从池子中取出一个线程去处理
若是一个数据请求伴随一个线程的创建去处理,则会产生一些风险以及一些不必要的消耗
1.线程若不限制数量的创建,在峰值压力下,线程创建过多,资源耗尽,程序由崩溃的风险。
2.处理一个任务的时间:
创建线程的时间T1+任务处理时间T2+线程销毁时间T3=T, 若T2/T比例占据不够高,则表示大量资源用于线程的创建与销毁成本上,因此线程池使用已经创建好的线程进行循环任务处理,就避免了大量线程的频繁创建与销毁的时间成本。
编写线程池
线程安全的单例模式
是非常典型常用的一种设计模式,一份资源只能被申请/一个类实例化的对象共用同一份资源
单例模式的实现
饿汉方式
资源的程序初始化的时候就去加载,使用的时候就能直接使用
使用的时候很流畅,有可能加载用不上的资源,并且会导致程序初始化的时间比较慢
懒汉方式
资源在使用时发现没有加载,则申请加载,程序初始化比较快,
第一次运行这个模块时比较慢,因为这时候会加载相应资源
实现注意的细节
1.使用static保证所有对象使用同一份资源
2.volatile,防止编译器过度优化
3.实现线程安全,保证资源判断以及申请过程是安全的
4.外部二次判断,以及避免资源已经加载成功每次获取都要加锁解锁,以及所带来的锁冲突。