线程安全
认识:多个线程同时操作临界资源而不会出现数据二义性,即在线程中是否对临界资源进行了非原子操作。
- 可重入与不可重入:
多个执行流中可以同时进入函数运行而不会出现问题。
可重入不一定线程安全,但不可重入一定不安全
。
如何实现?同步与互斥。
- 同步:临界资源的合理访问。
- 互斥:临界资源同一时间的唯一访问性。
互斥如何实现?通过互斥锁来实现。
互斥
互斥锁
案例:
黄牛抢票程序
模型,100张火车票,4个黄牛。
【 https://blog.csdn.net/qq_42351880/article/details/90049533 】
互斥锁本质上是一个0/1
计数器
0
表示不可以加锁,不能加锁则等待。1
表示可以加锁,加锁操作就是计数-1
。
操作完毕之后要解锁,解锁操作就是计数+1
,并且唤醒等待。
互斥锁操作步骤:
- 定义互斥锁变量
pthread_mutex_t
- 初始化互斥锁变量
pthread_mutex_init()
- 加锁
pthread_mutex_lock()
- 解锁
pthread_mutex_unlock()
- 销毁互斥锁
pthread_mutex_destroy()
初始化互斥锁
初始化要在线程创建之前,因为线程运行的时序我们无法得知,如果在创建之后初始化,有可能已经是使用过的了,这时初始化再已为时已晚。
- 使用
pthread_mutex_init()
接口 。
int pthread_mutex_init(pthread_mutex_t *mut,pthread_mutexattr_t *attr);
mut
: 互斥锁变量的地址。attr
:互斥锁的属性,通常置NULL
。
- 使用
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
直接赋值初始化(本质一个结构体)。
两种方法定义的互斥锁变量mutex
不一定非要全局变量,只要保证要互斥的线程都能访问到即可。
加锁
【注】:加锁要在访问临界资源之前。
pthread_mutex_lock()
接口:阻塞加锁(加不上锁就阻塞)
int pthread_mutex_lock(pthread_mutex_t *mutex);
pthread_mutex_trylock()
:尝试加锁 / 非阻塞加锁(加不上锁则直接报错返回)
int pthread_mutex_trylock(pthread_mutex_t *mutex);
pthread_mutex_timedlock()
:限时阻塞加锁
它的使用前提需要用户定义一个宏,只作知悉,有兴趣的读者请自行了解。
解锁
解锁在临界资源访问完毕,同时在线程任何有可能退出的地方都要进行解锁操作。
int pthread_mutex_unlock(pthread_mutex_t *mutex);
释放互斥锁
完全不使用之后再释放,要在join
操作之后,因为join
操作之前线程还没有退出,此时释放会出问题。
int pthread_mutex_destroy(pthread_mutex_t *mutex);
死锁
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所占用不会释放的资源而处于一种永久等待状态。
即因为对一些无法加锁的锁进行加锁,而没有导致程序卡死的现象。
死锁产生的必要条件
- 互斥条件 :(我操作时别人不能操作)
一个资源每次只能被一个执行流使用。 - 不可剥夺条件 :(我加的锁别人不能解锁(释放))
一个执行流已获得的资源,在末使用完之前,不能强行剥夺。 - 请求与保持条件 :(拿着手中的锁,请求其它的锁。其他的锁请求不到,手中的锁也不放)
一个执行流因请求资源而阻塞时,对已获得的资源保持不释放状态。 - 环路等待(循环等待)条件:
若干执行流之间形成一种头尾相接的循环等待资源的关系。
产生场景:加锁 / 解锁顺序不同,等等。
如何预防死锁? 破坏必要条件!
如何避免死锁?
死锁检测算法
,银行家算法
等。
死锁如何处理?
非阻塞加锁
、限时阻塞加锁
、定义锁序
等。
同步
互斥锁、条件变量、生产者与消费者模型、POSIX
标准信号量、读写锁(读写者模型)等
同步即临界资源访问的合理性,资源生产出来才能使用,没有资源则等待(死等)资源,生产资源后唤醒等待(唤醒与等待)。
条件变量
- 定义条件变量
pthread_cond_t
- 初始化条件变量
pthread_cond_init()
- 等待
pthread_cond_wait()
- 唤醒
pthread_cond_signal()
- 销毁条件变量
pthread_cond_destroy()
条件变量的本质也是一个结构体,其中存在等待队列等等数据结构。
初始化条件变量
int pthread_cond_init(pthread_cond_t *cond,pthread_condattr_t *attr);
cond
:条件变量。attr
:条件变量属性,通常置NULL
。
等待
- 在条件变量上等待
pthread_cond_wait(pthread_cond_t *cond,pthread_mutex_t *mutex);
cond
:条件变量。mutex
:互斥锁变量。
- 限时等待:(限时进行等待,超时后则返回)
int pthread_cond_timedwait(pthread_cond_t *cond,
pthread_mutex_t *mutex,
struct timespec *abstime
);
abstime
:限时等待时长。
(限时进行等待,超时后则返回)
唤醒
- 唤醒至少一个等待的人
int pthread_cond_signal(pthread_cond_t *cond);
- 广播唤醒:(唤醒所有等待的人)
int pthread_cond_broadcast(pthread_cond_t *cond);
销毁条件变量
int pthread_cond_destroy(pthread_cond_t *cond);
案例:
吃面模型
,前提是有人做面,吃面做面的单位都是一碗。
【 https://blog.csdn.net/qq_42351880/article/details/90050712 】
- 如果没有现成的面,就要等待厨师做出来,厨师做出来后唤醒顾客。(等待与唤醒)
- 厨师不会做太多的面,只会提前做一碗面,如果已经有面做好,但没人吃,就不会再做(即等待)。
- 顾客吃完面要求再来一碗,唤醒厨师的等待(即唤醒)。
- Q:条件变量为什么要搭配互斥锁使用?
- A1:因为条件变量只提供等待与唤醒的功能,具体什么时候等待需要用户来进行判断,这个条件的判断,通常涉及临界资源的操作(其他线程要通过修改条件,来促使条件满足),而这个临界资源的的操作应该受保护,因此搭配互斥锁一起使用。
- A2:条件变量本身不具有条件判断的功能,只提供等待与唤醒功能, 所以使用一个外部条件进行判断。判断是否满足条件,从而决定是否进入休眠。这个条件在多个线程中都要进行判断,对这个不满足的条件进行休眠等待,其他线程会促使条件满足,意味着其他线程会对这个条件进行修改,故这个条件也是一个临界资源,所以它应该受保护(加锁后操作)
因为竞态条件的原因,所以
pthread_cond_wait(&cond,&mutex)
操作实现了原子操作:
- 解锁
- 休眠
- 被唤醒后加锁
- 多个吃面人 与 厨师:
-
情况一:
因为促使条件满足后,pthread_cond_signal()
唤醒至少一个等待线程,导致因为条件的判断是一个if
语句而造成一碗面多次进食的情况(第一个吃面的人加锁吃完面后解锁,第二个被唤醒的吃面者等待在锁上,刚好拿到锁,继续后面逻辑(吃面)),因为条件的判断需要使用while
来循环判断。 -
情况二:
促使条件满足后(做面),pthread_cond_wait()
唤醒的是所有等待在条件变量上的线程,但有可能被唤醒的这个线程也是一个做面的线程,因为已经有面,条件不满足而陷入等待,导致死等。
【本质原因】:唤醒的时候,唤醒了错误的角色。(因为不同的角色等待在同一个条件变量上)。
因此线程有多少角色,就应该有多少个条件变量。分别等待,分别唤醒。
信号量
其本质是:计数器 + 等待队列 + 等待 + 唤醒
功能:实现线程/进程的同步与互斥
计数器就是判断的条件(当计数只有0/1
时,就可以实现互斥)。
等待队列 + 等待 + 唤醒实现同步的基本功能。
system V
信号量:信号量原语:P/V
操作
P
:(-1 +阻塞)V
:(+1 + 唤醒)
POSIX
信号量:
头文件<semahore.h>
定义:sem_t
信号量变量
初始化
int sem_init(sem_t *sem, int pshared, unsigned int value);
sem
:信号量变量pshared
:选项标志:决定信号量用于进程间还是线程间同步互斥
(选项:
0
:线程间
非0值
:进程间)value
: 信号量初始计数
计数-1
,等待:数据操作前资源计数判断
int sem_wait(sem_t *sem); // 若计数<=0;则 < 阻塞 >
int sem_trywait(sem_t *sem); // 若计数>0;则报错返回 < 非阻塞 >
int sem_timedwait(sem_t* sem, struct timespec* abs_timeout); // 若计数<=0;则限时阻塞,超时则报错返回
- 计数
>0
则计数-1
,直接返回,执行后面逻辑。 - 计数
<=0
则计数-1
,阻塞等待。
生产数据后则计数+1
,唤醒等待:
int sem_post(sem_t *sem); //计数+1 ,并唤醒等待
销毁
int sem_destroy(sem_t *sem);
信号量与条件变量的区别:
- 信号量拥有资源计数的功能。临界资源是否能够操作通过自身计数判断。
- 条件变量是搭配互斥锁使用。
- 信号量还可以实现互斥,计数仅为
0
/1
。
线程池
一堆固定数量的或者有最大数量限制的线程 +
任务队列 =
线程池
- 功能:并发处理任务请求
- 线程池避免大量频繁的线程创建销毁的时间成本。
- 线程池避免峰值压力带来的瞬间大量线程被创建资源耗尽,程序崩溃的危险。
线程池的种类与使用场景:之后补充~
线程池的设计:
(线程数量固定的线程池)
- int _max_thr;
- int _cur_thr;
- int _quit_flag; //退出标志
- int capacity; //队列最大节点数
- std::queue <
task
> _queue; //任务队列 - pthread_mutex_t _mutex;
- pthread_cond_t _cond_consumer;
- pthread_cond_t _cond_productor;
task
:
- typedef bool(*task_callback)(int data);
- int _data;
- task_callback _handle; //处理的就是data
- setTask(int data, task_callback handle)
线程池对外的接口:
ThreadInit()
//初始化PushTask(Task &)
//添加任务ThreadQuit()
//退出
线程池的实现:之后详解~
线程安全的单例模式
设计模式:常见场景的一般经典解决方案。单例模式是设计模式中的一种。
单例模式:一个对象只能被实例化一次 / 一个。(资源仅需一套)
单例模式分为:
模式 | 描述 | 优点 | 缺点 |
---|---|---|---|
饿汉单例模式 | 程序初始化时进行实例化 | 因为资源已经全部加载,运行速度快 | 初始化时候耗时较长 |
懒汉单例模式 | 程序资源使用时再进行加载,对象使用时再实例化 | 初始化加载快 | 线程安全问题、运行流畅度不够 |
读写锁:读者-写者问题
- 特点:写互斥、读共享。
- 本质:通过两个计数器实现:一个
writer
计数器,一个read
计数器。 - 实现:通过自旋锁实现。
自旋锁
:循环判断条件是否满足,优点是反应及时,缺点是CPU
消耗叫高。
- 适用于:阻塞时间很短的等待。
读的时候大家可以一起读,但是不能写。
若当前已经加写锁,则其他线程进行加写锁/读锁都会被阻塞。
若当前已经加读锁,则其他线程可以加读锁,但加写锁会被阻塞。
如果设置为读优先,适用于写者多,读者少的情景。拒绝后续读锁。
如果设置为写优先,适用于读者多,写者少的情景。拒绝后续写锁。
在本篇博客的结尾给出多线程阶段(上篇)博客:
【https://blog.csdn.net/qq_42351880/article/details/89430357 】
其中包括线程基础知识以及线程创建流程的诸多接口函数,有兴趣的看官大佬可以了解一下~