线程概念
线程是进程中的一条执行流
在linux系统中,pcb是调度程序运行的描述,通过这个描述(pcb)可以实现对程序的调度运行管理
一个进程可以有一个或多个pcb,线程是进程中的一条执行流,而linux下这个执行流就是通过pcb实现的,因此线程是一个pcb;并且这些pcb共享了进程中的大部分资源,因此相较于传统的pcb更加轻量化,所以称之为轻量级进程
- 创建线程会伴随在内核中创建一个pcb来实现程序的调度,作为进程中的一条执行流
- 进程是系统资源分配的基本单位(操作系统会为一个程序的运行分配所需的所有资源);线程是cpu调度的基本单位
线程之间的独有与共享:
- 独有:标识符、寄存器、栈、信号屏蔽字(阻塞信号集合)、errno(系统调用完毕后重置的错误编号)、优先级
- 共享:虚拟地址空间(代码段/数据段)、文件描述符表(IO信息)、信号处理方式、用户ID/组ID/工作路径
多线程/多进程进行多任务处理的优缺点:
共同的优点:针对IO密集型程序1和CPU密集型程序2,可以提高程序中的处理效率
多线程的优点:
- 线程间通信更加灵活方便(除了进程间通信方式之外还可以使用全局变量、函数传参的方式实现数据通信)
- 线程的创建与销毁成本更低(创建线程创建一个pcb,共用的数据只需要用一个指针指向同一处就可以了)
- 线程间调度成本更低(调度切换需要切换页表…)
多进程的优点:
- 多进程的健壮性、稳定性更高(有些系统调用和异常针对整个进程生效),适用于对主程序安全性要求更高的场景:shell/网络服务器等
线程控制
操作系统并没有提供线程的控制接口,进行线程控制的接口是程序员封装的一套库函数,这套封装的线程库函数提供线程相关的各种操作
创建线程
int pthread_create(pthread_t* tid,const pthread_attr_t* attr, void*(*start_routine)(void*),void* arg);
- tid:输出型参数,用于获取线程id,是一个无符号长整型数据
- attr:线程属性,用于在创建线程的同时设置线程属性,通常置NULL
- start_routine:函数指针,这就是一个线程的入口函数----线程运行的就是这个函数,函数运行完毕,则线程就会退出
- arg:通过线程入口函数,传递给线程的参数
- 返回值:成功返回0,失败返回非0值----错误编号
每个线程在进程的虚拟地址空间中都有一个自己的相对独立的线程地址空间(存储着上层线程的描述、线程的局部存储、栈)
tid是线程地址空间的首地址,pcb里的pid是轻量级进程的id(线程id),pcb里的tgid是线程组id(进程id)
线程终止
如何退出一个线程?
- 在线程入口函数中主动调用return,线程入口函数运行完毕,线程就会自动退出(main函数中return退出的是进程而不是线程)
- 在任意位置调用
void pthread_exit(void* retval);
主动退出线程(谁调用谁退出,retval是退出返回值) - 在其它线程中调用
int pthread_cancel(pthread_t tid);
终止一个线程,退出的线程是被动取消的
主线程退出并不会导致所有的线程退出,进程退出才会导致所有线程退出
线程等待
概念:等待线程的退出,获取退出线程的返回值,回收线程资源。默认情况下,一个线程是必须被等待的,若不等待则会造成资源泄露
线程有一个默认属性joinable
,这种属性的线程退出后不自动释放资源,需要被其它线程等待获取返回值回收资源
使用int pthread_join(pthread_t tid,void **retval);
等待指定线程退出,获取其返回值。这是一个阻塞函数,线程没有退出则一直等待
- tid:要等待退出的线程tid
- retval:输出型参数,用于返回线程的返回值
- 线程的返回值是一个void*是一个一级指针,若要通过一个函数的参数获取一级指针,就需要传入一个一级指针变量的地址进来
线程分离
将线程的joinable属性修改为detach。分离一个线程,一定是对线程的返回值不感兴趣不想获取,又不想一直等待线程退出,才会分离线程
- 一个线程属性若是joinable那么必须被等待
- 一个线程属性若是detach那么这个线程退出后则自动释放资源,不需要被等待
线程分离接口函数:
int pthread_detach(pthread_t tid); // 将指定的线程分离出去,属性修改为detach
线程安全
概念:多个执行流(线程)对临界资源争抢访问,而不会出现数据二义或逻辑混乱,就可称为线程安全
线程安全的实现:
- 同步:通过条件判断保证对临界资源访问的合理性,同步并不保证安全
- 互斥:通过同一时间对临界资源访问的唯一性实现临界资源访问的安全性,互斥并不保证合理
互斥锁
互斥的实现:互斥锁。互斥锁本身是一个临界资源,互斥锁自身计数器的操作是原子性的
互斥锁实现互斥的原理:互斥锁本质是一个只有0/1的计数器,对临界资源当前的访问状态进行标记(可访问/不可访问),所有执行流在访问临界资源都需要先判断当前的临界资源状态是否允许访问,若不允许访问则让执行流等待,若允许则让执行流访问,但在访问期间需要将访问状态修改为不可访问状态,这期间若其它执行流需要访问则不被允许
互斥锁的操作流程:
- 定义互斥锁变量:
pthread_mutex_t mutex;
- 初始化互斥锁:通过接口
pthread_mutex_init(pthread_mutex_t* mutex,pthread_mutex_t* attr);
或定义宏pthre_mutex_t mutex=PTHREAD_MUTEX_INITIALIZER;
来初始化 - 访问临界资源之前加锁:
pthread_mutex_lock(pthread_mutex_t* mutex)//阻塞加锁(若已经加锁则一直等待)
或pthread_mutex_trylock(pthread_mutex_t* mutex);//非阻塞枷锁(若已经加锁则报错返回)
- 临界资源访问完毕后解锁:
pthread_mutex_unlock(pthread_mutex_t* mutex);
- 销毁互斥锁:
pthread_mutex_destroy(pthread_mutex_t* mutex);
锁的使用注意事项:
- 使用锁尽量只保护临界资源的访问操作
- 在任意有可能退出线程的地方都要解锁
死锁:多个执行流(线程)对锁资源进行争抢访问,但因为访问推进顺序不当,造成互相等待最终导致程序无法继续推进,这就造成了死锁
死锁的产生:
- 互斥(一个进程加了锁,别的进程就不能再加锁)
- 不可剥夺条件(谁加的锁谁才能解锁)
- 请求与保持(加了A锁再请求加B锁,若不能对B锁加锁则不释放A锁)
- 环路等待(甲加了A锁去请求B锁,乙加了B锁去请求A锁)
死锁的预防:破坏死锁产生的必要条件(一般避免3和4两个条件的产生)
死锁的避免:死锁检测算法/银行家算法
银行家算法的思路:系统的安全状态/非安全状态
张表记录当前有哪些锁,一张表记录已经给谁分配了哪些锁,一张表记录谁当前需要哪些锁;按照三张表进行判断,若给一个执行流分配了指定的锁后是否会达成环路等待条件导致系统进入不安全状态,如果有可能造成不安全就不分配,若分配了之后不会造成环路等待就分配这个锁(破幻环路等待);若不能分配锁就进行资源回溯,把当前执行流中已经加的锁释放掉(破坏请求与保持);非阻塞加锁操作,若不能加锁则把现有的锁释放掉(破坏请求与保持)
条件变量
同步的实现:通过条件判断(条件变量)实现临界资源访问的合理性,让线程在能访问的时候访问,不能访问的时候阻塞(等待),直到条件满足再唤醒阻塞的线程
条件变量实现同步:提供了一个pcb的等待队列,以及让执行流(线程)等待和唤醒的接口。访问条件的判断需要程序员在程序中自己完成
条件变量实现流程:
- 定义条件变量:
pthread_cond_t cond;
- 初始化条件变量:使用函数
pthread_cond_init(pthread_cond_t* cond,pthread_condattr_t* attr);
或定义宏pthread_cond_t cond=PTHREAD_COND_INITIALIZER;
- 访问条件不满足时的阻塞接口:
pthread_cond_wait(pthread_cond_t* cond,pthread_mutex_t* mutex);//一直阻塞等唤醒
或pthread_cond_timedwait(pthread_cond_t* cond,pthread_mutex_t* mutex,struct timespec);//等待指定时间内未被唤醒则自动醒来
- 访问条件满足时唤醒线程的接口:
pthread_cond_signal(pthread_cond_t*);//唤醒至少一个线程(并不是唤醒单个)
或pthread_cond_broadcast(pthread_cond_t*);//唤醒所有等待的线程
- 销毁条件变量:
pthread_cond_destroy(pthread_cont_t*);
条件变量使用注意事项:
- 条件变量需要搭配互斥锁一起使用,
pthread_cond_wait
集合了解锁/休眠/被唤醒后加锁的三步操作 - 条件变量使用中对条件的判断应该使用while循环
- 多种不同角色线程应该使用多个条件变量,不能让所有线程等待在同一个条件变量上
生产者-消费者模型
应用场景:既有线程不断生产数据,又有线程不断处理数据的场景
生产者与消费者模型解决的问题(优点):解耦合、支持忙闲不均、支持并发
生产者与消费者模型的实现:将生产与处理放到不同的执行流中完成,中间增加一个数据缓冲区作为中间的数据缓冲场所;创建两种业务处理的线程,实现线程安全队列
封装一个BlockQueue阻塞队列,向外提供线程安全的入队/出队操作:
class BlockQueue{
public:
BlockQueue();
//编码风格:纯输入参数---const&/输出型参数---指针/输入输出型&
bool Push(const int &data); //生产者队列
bool Pop(int *data); //消费者队列
private:
std::queue<int> _queue;
int _capacity;//队列中节点的最大数量
pthread_mutex_t _mutex;
pthread_cond_t _productor_cond;//生产者队列
pthread_cond_t _customer_cond;//消费者队列
}
信号量
概念:信号量可以用于实现进程/线程间同步与互斥(主要用于实现同步)
本质:信号量是一个计数器+pcb等待队列
同步(访问合理)的实现:信号量通过自身的资源计数器对资源进行计数,判断线程是否符合访问资源的条件,若符合就可以访问,若不符合则调用提供的接口使线程阻塞;条件满足之后,可以唤醒pcb等待队列上的pcb
互斥(访问安全)的实现:信号量保证计数器的计数不大于1,就保证了资源只有一个,同一时间只有一个线程能够访问资源,实现互斥
信号量使用流程:
-
定义信号量:sem_t
-
初始化信号量:
int sem_init(sem_t *sem, int pshared, unsigned int value);
pshared
:0表示线程间共享,非零表示进程间共享
value
:信号量初始值,初始资源有多少计数就是多少
返回值
:成功返回0,失败返回-1 -
在访问临界资源之前,先访问信号量,判断是否符合访问条件:
int sem_wait(sem_t *sem);
:通过自身计数判断是否满足访问条件,不满足则一直阻塞线程
int sem_trywait(sem_t *sem);
:通过自身计数判断是否满足访问条件,不满足则立即报错返回
int sem_timedwait(sem_t *sem, const struct timespec *abs_timeout);
:不满足则等待指定时间,超时后报错返回-EITMEDOUT -
促使访问条件满足之后,计数+1,唤醒阻塞线程:
int sem_post(sem_t *sem);//通过信号量唤醒自己阻塞队列上的pcb
-
销毁信号量:
int sem_destroy(sem_t *sem);
读写锁
应用场景:读共享、写互斥
实现原理:通过两个计数器
自旋锁
应用场景:多个线程并发执行对资源访问
实现原理:自旋等待,一直在条件判断
线程基本应用
线程池
作用:避免线程大量创建/销毁的时间成本;避免资源耗尽的风险
应用场景:大量请求并发处理
线程安全的单例模式
单例模式概念:一份资源只能被申请加载一次/一个类只能实例化一个对象
单例模式实现:
- 饿汉方式:资源在程序初始化时就去加载,使用时可以直接使用,用起来比较流畅,但有可能会加载用不上的资源,导致程序初始化的时间比较慢。使用static将成员变量设置为静态变量,则所有对象共用同一份资源并且在程序初始化时就会申请资源,不涉及线程安全
- 懒汉方式:资源在使用时再去加载,初始化较快,第一次运行某个模块时比较慢,因为需要加载相应的资源。使用static保证所有对象使用同一份资源、使用volatile防止编译器过度优化、实现线程安全保证资源判断和资源申请过程安全、外部二次判断避免锁冲突提高效率