13. 常见锁概念
(一)了解死锁
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程占有的,且不释放的资源,而处于的一种永久等待状态
(二)死锁四个必要条件
- 互斥条件:一个资源每次只能被一个执行流使用
- 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
- 不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺
- 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系
(二)避免死锁
- 破坏死锁的四个必要条件
- 加锁顺序一致 (不是交错申请不同的锁资源)
- 避免锁未释放的场景
- 资源一次性分配
14. linux线程同步
(一)条件变量
当一个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了,为了使抢夺锁资源更公正(防止饥饿问题),就需要把所有等待锁资源的线程放入一个等待队列,直到线程被唤醒
。这种情况就需要用到条件变量
注意:
条件变量必须依赖锁
(二)同步概念
同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步
(三)条件变量相关函数
条件变量函数 初始化
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:互斥量
注意:
参数中有互斥量,是因为实现同步与互斥,需要先加锁,再判断(判断也是访问临界资源,和加锁保护线程安全对应)是否进入等待队列休眠,而这个函数里面有互斥量是为了把锁资源释放,接下来访问临界资源的顺序是按照队列里面的顺序进行的(前提是线程被唤醒)
大概代码思路
pthread_mutex_lock(&mutex); // 加锁
pthread_cond_wait(&cond); //放入等待队列
pthread_mutex_unlock(&mutex); //解锁
注意:
- 唤醒可以由其它线程去唤醒,如主线程
- 三个函数的顺序不要更换(解锁和等待都不是原子操作)
唤醒等待
int pthread_cond_broadcast(pthread_cond_t *cond);
唤醒队列中的所有线程
int pthread_cond_signal(pthread_cond_t *cond);
按顺序唤醒队列中的一个线程
注意:
- 唤醒之后的线程需要申请锁资源
- 唤醒的时候,可能出现唤醒错误的情况。比如在生产者消费者模型中(多生产者多消费者容易出现这种错误),唤醒了多个生成者,并且消费者此时也是属于唤醒状态,导致生产者和生产者之间,消费者和生产者之间竞争同一把锁,锁最后的分配,可能导致临界资源访问条件不满足(唤醒之后拿到锁资源的线程,有可能这个时候的访问条件不满足,但依然会执行后面的代码),出现错误
正确代码思路
pthread_mutex_lock(&mutex); // 加锁
while(... ...) //判断访问资源的条件是否成立
{
pthread_cond_wait(&cond); //放入等待队列
}
pthread_mutex_unlock(&mutex); //解锁
注意:
判断用while,是为了保证,线程被唤醒(出了等待队列),并且竞争到了锁资源后,再一次判断资源条件是否成立,才能决定后面的代码能否执行
15. 生产者消费者模型
(一)使用生产者消费者模型的原因
生产者消费者模式就是通过一个容器来解决生产者和消费者的强耦合问题。生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯
生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列;消费者不找生产者要数据,而是直接从阻塞队列里取
阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力,这个阻塞队列就是用来给生产者和消费者解耦的
(二)生产者消费者模型优点
- 解耦
- 支持并发
- 支持忙闲不均
(三)基于BlockingQueue的生产者消费者模型
BlockingQueue
在多线程编程中阻塞队列(Blocking Queue)是一种常用于实现生产者和消费者模型的数据结构。其与普通的队列区别在于,当队列为空时,从队列获取元素的操作将会被阻塞,直到队列中被放入了元素;当队列满时,往队列里存放元素的操作也会被阻塞,直到有元素被从队列中取出
(四)对生产者消费者模型的总结
1. 有三种关系
- 生成者和生成者之间的关系:互斥
- 生产者和消费者之间的关系:互斥,同步
- 消费者和消费者之间的关系:互斥
2. 有两者角色
生产者和消费者
3. 有一种交易场所
特定结构的内存空间
16. POSIX信号量
POSIX信号量和 SystemV信号量作用相同,都是用于同步操作,达到无冲突的访问共享资源目的。 但POSIX可以用于线程间同步
17. 基于环形队列的生产消费模型
(一)了解基于环形队列的生产消费模型
环形队列采用数组模拟,用模运算来模拟环状特性
判断队列是否为空或者为满,我们交给信号量去处理
注意:
- 信号量的本质是一个计数器,用来描述临界资源的数目(这里不把临界资源当成一个整体,而是很多份,多个线程并发访问临界资源中的不同份)
- 临界资源是否就绪的判断是在临界区之外的(和前一个生产消费模型区分开来),在申请信号量的时候,就已经间接在做判断了(这一步是预定操作,即预定成功,则整个临界资源必有一份资源是属于预定成功的线程的)
(二)基于环形队列的生产消费模型的原理
18. 线程池
(一)了解线程池
线程池:
一种线程使用模式
线程过多会带来调度开销,进而影响缓存局部性和整体性能。而线程池维护着多个线程(提前开辟好的),等待着监督管理者分配可并发执行的任务。这避免了在处理短时间任务时创建与销毁线程的代价。线程池不仅能够保证内核的充分利用,还能防止过分调度
池化计数:
线程会用到池化技术(以空间换时间),大致操作(C++为例):现在C++类内创建线程(创建的线程当作类内成员变量),再利用原生线程去实现生产者消费者模型
注意:
- 在类内创建时,若线程执行的函数也是类内函数,会出现错误(因为执行的函数传参只有一个void*,而类内函数会多一个this指针),所以该执行函数必须是静态的
- 若执行函数是静态的,同样会出现一个问题,即静态成员函数无法访问非静态成员变量(没有this指针),所以执行函数的参数void*必须是this指针,通过穿过来的this指针,访问非静态成员变量
(二)线程池的应用场景
- 需要大量的线程来完成任务,且完成任务的时间比较短。因为单个任务小,而任务数量巨大, 但对于长时间的任务,线程池的优点就不明显了
- 对性能要求苛刻的应用,比如要求服务器迅速响应客户请求
- 接受突发性的大量请求,但不至于使服务器因此产生大量线程的应用。突发性大量客户请求,在没有线程池情况下,将产生大量线程,虽然理论上大部分操作系统线程数目最大值不是问题,短时间内产生大量线程可能使内存到达极限
(三)线程池示例
- 创建固定数量线程池,循环从任务队列中获取任务对象
- 获取到任务对象后,执行任务对象中的任务接口
19. 线程安全的单例模式
(一)什么是单例模式
单例模式是一种 "经典的, 常用的, 常考的" 设计模式(这个类的对象,只允许创建一个)
(二)什么是设计模式
针对一些经典的常见的场景, 给定了一些对应的解决方案, 这个就是 设计模式
(三)单例模式的特点
- 某些类, 只应该具有一个对象(实例), 就称之为单例
- 在很多服务器开发场景中, 经常需要让服务器加载很多的数据 (上百G) 到内存中. 此时往往要用一个单例的类来管理这些数据
(四)饿汉实现方式和懒汉实现方式
举例类比(洗完的例子)
饿汉方式:
吃完饭, 立刻洗碗, 这种就是饿汉方式. 因为下一顿吃的时候可以立刻拿着碗就能吃饭
懒汉方式:
吃完饭, 先把碗放下, 然后下一顿饭用到这个碗了再洗碗, 就是懒汉方式
注意:
懒汉方式最核心的思想是 "延时加载". 从而能够优化服务器的启动速度(在准备进程启动时,需要把一些数据加载到内存,这一个过程的时间少)
饿汉方式 代码实现
template <typename T> class Singleton { static T data; public: static T* GetInstance() { return &data; } };
data 是静态类内成员,会在进程启动之前,早就创建好,而不是等到启动进程时,再去创建
懒汉模式 代码实现
template <typename T> class Singleton { static T* inst; public: static T* GetInstance() { if (inst == nullptr) { inst = new T(); } return inst; } }; template <typename T> T* Singleton<T>:: inst = nullptr;
inst指针是要等待使用的时候,所指向的内容才会在堆上开辟新空间
(五)懒汉方式实现单例模式(线程安全版本)
代码
static phread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; template <typename T> class Singleton { static T* inst; // 需要设置 volatile 关键字, 否则可能被编译器优化. public: static T* GetInstance() { if (inst == NULL) //这一次的判断可以提高效率,使得没有进入第二个判断的线程,直接执行非临界资源的代码 { lock.lock(); // 使用互斥锁, 防止多个线程进入第二个判断,保证多线程情况下也只调用一次 new if (inst == NULL) { inst = new T(); } lock.unlock(); } return inst; } }; T* Singleton<T>:: inst = nullptr;
注意:(代码不是完整的)
lock(),unlock()底层都是用原生线程库封装了
20. STL , 智能指针和线程安全
(一)常见问题
- STL中的容器是否是线程安全
不安全
原因:
STL 的设计初衷是将性能挖掘到极致, 而一旦涉及到加锁保证线程安全, 会对性能造成巨大的影响.
而且对于不同的容器, 加锁方式的不同, 性能可能也不同(例如hash表的锁表和锁桶).
因此 STL 默认不是线程安全. 如果需要在多线程环境下使用, 往往需要调用者自行保证线程安全.
- 智能指针是否是线程安全
- 对于 unique_ptr, 由于只是在当前代码块范围内生效, 因此不涉及线程安全问题.
- 对于 shared_ptr, 多个对象需要共用一个引用计数变量, 所以会存在线程安全问题. 但是标准库实现的时候考虑到了这个问题, 基于原子操作(CAS)的方式保证 shared_ptr 能够高效, 原子的操作引用计数.
(二)其他常见的各种锁
- 悲观锁:在每次取数据时,总是担心数据会被其他线程修改,所以会在取数据前先加锁(读锁,写锁,行锁等),当其他线程想要访问数据时,被阻塞挂起
- 乐观锁:每次取数据时候,总是乐观的认为数据不会被其他线程修改,因此不上锁。但是在更新数据前,会判断其他数据在更新前有没有对数据进行修改。主要采用两种方式:版本号机制和CAS操作
- CAS操作:当需要更新数据时,判断当前内存值和之前取得的值是否相等。如果相等则用新值更新。若不等则失败,失败则重试,一般是一个自旋的过程,即不断重试。
- 自旋锁(是否采用自旋锁,取决于临界资源访问的时间的长短,时间短,可以采用自旋锁,即线程不挂起,一直申请所资源)
21. 读者写者问题
读写锁
在编写多线程的时候,有一种情况是十分常见的:有些公共数据修改的机会比较少,相比较改写,它们读的机会反而高的多。
读写锁可以专门处理这种多读少写的情况
注意:写和写竞争,写和读竞争且同步,读和读共享资源,读锁优先级高
相关函数
初始化
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock,const pthread_rwlockattr_t
*restrict attr);
销毁
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
加锁和解锁
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock); //写与写竞争
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock); //读与写竞争
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);
代码(部分)
void * reader(void * arg) //读端 { char *id = (char *)arg; while (1) { pthread_rwlock_rdlock(&rwlock); if () //判断 { pthread_rwlock_unlock(&rwlock); break; } pthread_rwlock_unlock(&rwlock); } return nullptr; } void * writer(void * arg) //写端 { char *id = (char *)arg; while (1) { pthread_rwlock_wrlock(&rwlock); if () //判断条件 { pthread_rwlock_unlock(&rwlock); break; } } return nullptr; }
中间一些过程省略了
底层实现(伪代码)
int reader_count = 0; mutex_t rlock,wlock; 读端: lock(&rlock) reader_count++; if(reader_count == 1) //第一个竞争到所资源的读端 { lock(&wlock); //写段不能访问 } unlock(&rlock) lock(&rlock); reader_count--; if(reader_count == 0)//一开始一批读端的最后一个 { unlock(&wlock); //此时读端可以竞争 } unlock(&rlock); 写端: lock(&wlock); // ... 写入 unlock(&wlock);