一、概念
线程是进程中的一条执行流程,
但是因为linux下执行流是通过pcb实现的,因此linux下的线程实际上就是一个pcb,一个进程中可以有多个执行流,也就是有多个pcb,且这些pcb还共享很多程序运行所需的资源;
因此linux下的pcb就叫—轻量级进程。
(一般说:linux下无真正的线程,线程实际上是一个轻量级进程)
1.进程与线程的区别
- 进程是操作系统进行资源分配的基本单元;
- 线程是操作系统进行cpu调度的基本单元。
2.线程之间的独有与共享
共享:虚拟地址空间,信号处理方式,IO信息,工作路径…
独有:栈,寄存器,上下文数据,errno,信号屏蔽字,线程ID…
3.多进程与多线程在多任务处理中的优缺点
3.1多线程
- 线程间通信非常灵活(包含进程间通信方式在内,全局,传参);
- 线程的创建与销毁成本更低(线程资源大多共享);
- 线程间的切换调度成本稍低。
3.2多进程
独立性高,稳定性强
共同优点:多任务使用多执行流处理的优点。
(1)cpu密集型程序:
程序中几乎都是cpu数据运算;
多核cpu:更加充分利用cpu资源
执行流并不是越多越好,多了反而增加切换调度成本。
(2)IO密集型程序:
程序中几乎都是IO操作。
IO操作:等待IO就绪,数据拷贝
二、线程控制
在linux中没有直接提供线程的操作接口,
因此在上层封装实现了一套线程库。
在用户态创建了一个线程,
但是linux下程序调度只能通过pcb完成,
则在内核中也创建了一个新的pcb。
用户态线程:上层创建的线程。
轻量级进程:内核中的pcb。
1.创建
int pthread_create(
pthread_t *tid, 用于获取上层线程id
pthread_attr_t *attr, 用于设置线程属性,通常置NULL
void *(*thread_routine)(void* arg), 线程入口函数
void *arg 传递给线程入口函数的参数
)
返回值:成功返回0, 失败返回非0值。
2.终止
如果一个线程的入口函数运行完毕,则该线程就会退出。
(1)在线程入口函数中return。(main中return退出的是进程);
(2)void pthread_exit(void *retval);任意位置调用退出调用线程
函数没有返回值,但是参数retval这是线程的退出返回值。
(3)int pthread_cancel(pthread_t tid);在任意位置退出指定线程。
3.等待
等待一个指定的线程退出,获取这个线程的**退出返回值,**释放资源
默认情况下,如果一个线程退出,如果不等待也会造成资源泄露。
int pthread_join(pathread_t tid, void **retval);
tid:表示要等待哪个进程退出
retval:是一个void*空间的地址,用于接收线程返回值
void *(thr_entry)(void *arg)
默认情况下线程退出,为了保存自己的退出返回值,因此线程占用的资源在退出后也不会完全被释放,需要被其他线程等待。
线程等待不仅仅为了释放资源,避免资源泄露而等待;还有:
- 必须等到某个线程处理完成后得到结果才能继续往下处理;
- 等到某个或所有线程退出后再继续运行。
4.分离
int pthread_detach(pthread_t tid);
由来:
当不关心一个线程的返回值时,又不需要等待线程退出才能往下运行,
这时候等待会导致性能降低,
在该场景下,等待就不合适了,但是不等待又会资源泄露;
一个线程,既不关心返回值,也不想等待,
基于该需求就有了线程分离。
注意:
因为线程一旦设置了分离属性,则退出后自动释放资源,则等待将无意义,所以设置了分离属性的线程不能被等待。
线程分离:
将线程的分离属性设置为detach状态。
等待与分离:
- 等待:线程默认有个分离属性,默认值为
joinable
,
表示线程退出后不会自动释放资源,需要被等待,获取返回值之后释放资源。 - 分离:将线程的分离属性设置为
detach
,
线程退出之后将不再被等待,而是自动释放资源。
应用场景:
创建线程之后,根据是否有需要等待的需求而定;
本质上等待与分离,在实际使用中只会用其中一个!
三、线程安全
1.概念
多线程之间对同一个临界资源的访问操作是安全的
多线程同时修改同一个临界资源有可能会造成数据二义!
2.实现
如何实现线程操作是安全的?
- 同步:通过一些条件判断实现对资源获取的合理操作
同步的实现:条件变量,信号量 - 互斥:保证执行流在同一时间对临界资源的唯一访问
互斥的实现:互斥锁
2.1 同步的实现
概念:
通过一些条件判断,保证执行流对资源获取的合理。
实现:
(1)条件变量
(2)信号量
实现同步原理:
当线程访问资源的条件不合理时则调用阻塞接口阻塞线程,
将线程pcb加入到条件变量等待队列中,
等到其他线程促使条件满足之后,唤醒等待队列上的pcb。
2.1.1 条件变量
pcb等待队列+两个接口(阻塞线程、唤醒线程)
2.1.2 条件变量接口
1.定义条件变量:
pthread_cond_t cond;
2.初始化条件变量:
int pthread_cons_init(pthread_cond_t *cond,pthread_condattr_t *attr); //属性通常置空
3.使线程阻塞:
int pthread_cond_wait(pthread_cond_t *cond,pthread_mutex_t *mutex);
该接口中涉及三个操作:解锁,阻塞,被唤醒后加锁;
且解锁和陷入阻塞是原子操作,一步完成不会被打断;
int pthread_cond_timedwait() //限制时长的阻塞等待
如何阻塞一个线程?
将pcb的状态置位可中断休眠,置一个唤醒条件。
pthread_cond_wait 使线程陷入阻塞。
(修改线程状态,将线程pcb加入到cond的pcb队列中)
4.唤醒阻塞的线程
int pthread_cond_signal(pthread_cond_t *cond);
将cond的pcb队列中的线程至少唤醒一个;
int pthread_cond_broadcast(pthread_cond_t *cond);
将cond的pcb队列中的线程全部唤醒。
5.释放销毁
int pthread_cond_destroy(pthread_cond_t *cond);
注意:
信号只提供了使线程阻塞和唤醒线程接口,
至于什么时候阻塞,什么时候唤醒,条件变量本身并不关心,全部由用户自己控制
条件变量使用注意事项:
- 是否满足条件的判断语句应该使用循环操作。
- 多种角色线程使用多个条件变量,分开等待,分开唤醒,防止唤醒角色错误。
2.1.3 信号量(posix)
本质:
就是一个计数器,用于实现进程或线程之间的同步与互斥。
操作:
- P操作:计数-1,判断计数是否大于等于0,成立则返回否则阻塞。
- V操作:计数+1,唤醒一个阻塞的进程或线程。
同步的实现:
- 通过计数器对资源数量进行计数,获取资源之前进行P操作,产生资源之后进行V操作,
通过这种方式实现对资源的合理获取。
互斥的实现:
- 计数器初始值为1(资源只有一个),访问资源前进行P操作,访问完毕进行V操作,
实现类似于加锁和解锁的操作。
2.1.4 信号量接口
(1)定义: sem_t sem;
(2)初始化: int sem_init(sem_t *sem,int pshared,int value)
sem:定义的信号量;
pshared: 0-线程间 ; 1-进程间
value: 信号量初值—有多少资源初值就设置多少.
(3)P操作: int sem_wait(sem_t *sem); --阻塞
int sem_trywait(sem_t *sem);--非阻塞
int sem_timedwait(sem_t *sem,struct timespec*);
(4)V操作: int sem_post(sem_t *sem);
(5)销毁: int sme_destory(sem_t *sem);
2.1.5 使用信号量实现一个生产者与消费者模型
实现线程安全的数据队列:
class RingQueue{
std::vector<int> _arry;
int _capacity;
int _write_step;
int _read_step;
sem_t _sem_idle;//空闲空间计数
sem_t _sem_data; //数据资源计数
sem_t _sem_lock;//用于实现互斥
}
条件变量与信号量实现同步上的区别:
- 本质上的不同:信号量是个计数器,条件变量没有计数器;
因此条件变量的资源访问合理性需要用户自己进行,但是信号量可以通过自身计数完成。 - 条件变量需要搭配互斥锁一起使用,而信号量不需要。
2.2 互斥的实现–互斥锁
本质:
就是一个0/1的计数器,主要用于标记资源的访问状态;
0-不可访问, 1-可访问
操作:加锁,解锁
加锁:将状态置为不可访问状态
解锁:将状态置为可访问状态
一个执行流在访问资源之前进行加锁操作,
如果不能加锁则阻塞,在访问资源完毕后解锁。
- 互斥锁实现互斥,本质上自己也是个临界资源
(同一个资源所有线程在访问的时候必须加同一把锁)。
因此互斥锁必须先保证自己是安全的–
- 互斥锁的操作是一个原子操作!
2.2.1 接口
(1)定义互斥锁变量
pthread_mutex_t mutex;
(2)初始化互斥锁
int pthread_mutex_init(pthread_mutex_t *mutex,
pthread_mutexattr_t *attr);
mutex:定义的互斥锁变量;
attr:互斥锁属性,通常置NULL
返回值:成功返回0; 失败返回错误编号。
(3)加锁
int pthread_mutex_lock(pthread_mutex_t *mutex);-阻塞
int pthread_mutex_trylock(pthread_mutex_t *mutex)-非阻塞
(4)解锁
int pthread_mutex_unlock(pthread_mutex_t *mutex)
(5)释放销毁
int pthread_mutex_destroy(pthread_mutex_t *mutex)
注意点:
- 使用锁过程中,加锁后,在任意有可能退出的位置都要解锁;
- 锁只能保证安全操作,无法保证操作合理。
2.3 死锁
2.3.1 概念
死锁:程序流程无法继续推进,卡死。
产生:由于对锁资源的争抢不当所导致。
- 加解锁顺序不一致。
- 阻塞加锁。
2.3.2 四个必要条件
- 互斥条件:自己加的锁,别人不能继续加。
- 不可剥夺条件:自己加的锁别人不能解。
- 请求与保持条件:加A锁后请求B锁,B锁请求不到不释放A。
- 环路等待条件:加A请求B,对方加B请求A。
2.3.3 预防
- 加解锁顺序保持一致。
- 使用非阻塞加锁,加不了锁释放已有。
2.3.4 避免死锁—银行家算法
- 定义系统运行状态:安全,非安全。
- 定义表:所有资源表,已分配资源,资源请求表。
- 思想:查看资源请求表,哪个线程要请求哪个锁,根据前两张判断,
这个锁分配给线程是否有可能造成环路等待,有可能则不予分配。
四、线程的应用
4.1 生产者与消费者模型:一种特殊的设计模式
4.1.1 设计模式
针对典型应用场景设计的解决方案。
4.1.2 针对场景
有大量数据的产生以及处理的场景。
4.1.3 思想
将产生与处理两个模块分开,通过线程安全的缓冲区进行交互。
4.1.4 优点
- 解耦合(减少模块之间的依赖性,提高程序的独立性)
- 支持忙闲不均(数据缓存队列)
- 支持并发(缓存队列必须线程安全)
4.1.5 实现
生产者与消费者—两种角色的线程—入队和出队
封装代码通常都是一个功能一个模块
线程安全的任务队列:
class BlockQueue{
std::queue<int> _queue;
int _capacity;//容量
pthread_mutex_t_mutex;
pthread_cond_t_cond_pro;
pthread_cond_t_cond_con;
public:
BlockQueue(){}
bool Push(const int data);
return ture;
bool Pop(int *data);
return ture;
}
良好代码风格:
(1)输入参数:在函数内使用传入的数据--const
(2)输出参数:通过参数返回处理结果数据--使用指针
(3)输入输出参数:既要使用传入的数据,也要通过参数返回数据--使用引用
4.2 读写锁
4.2.1 应用场景–读者写者模型
4.2.2 读者写者模型:
读共享,写互斥的场景。
(用互斥锁,串行化效率低)
4.2.3 读写锁
- 加读锁:当前只要没有被加写锁
- 加写锁:既没有读,也没有写的时候才能加写锁
实现:用两个计数器—读者计数+写者计数
当加锁不成功时,则要阻塞进程/xainc;
读写锁的阻塞是通过自旋锁来实现的
4.2.4 自旋锁
一直占用cpu不释放,循环进行条件判断(即时性更强)
适用场景
适用于等待时间确定较短的场景
4.2.5 其他锁的种类
- 无锁编程, 悲观锁,乐观锁(CAS锁实现),可重入锁,不可重入锁
- 悲观锁:总是认为访问期间会有冲突,因此总是加锁保护。
- 乐观锁:总是认为访问期间大概率无冲突。
- 可重入锁:同一个线程可以重复加锁。
- 不可重入锁:同一个线程不可重复加锁,一个锁只能加一次。
4.3 线程池的简单实现
4.3.1 线程池
一个或多个线程+线程安全的任务队列,对任务进行处理。
4.3.2 主要针对
有大量任务需要处理的场景
使用多执行流可以提高处理效率,
若一个任务到来就创建一个线程进行处理,处理完后销毁有缺陷:
- 成本:一个任务处理的成本
总耗时=线程创建时间+任务处理时间+线程销毁时间
若任务处理时间较短,则大量时间被线程的创建与销毁消耗了。 - 风险:若线程无限制,则在峰值压力下会有资源耗尽系统崩溃风险。
4.3.3 思想
线程池其实是一堆创建好的线程+任务队列
有任务来了抛入线程池中,分配一个线程进行处理
4.3.4 优势
- 并发处理,效率更高。
- 节省了任务处理过程中线程的创建与销毁时间成本。
- 线程池中的线程与任务节点数量都有最大限制,避免资源耗尽风险。
4.3.5 实现
-
定义一个任务类:需要处理的数据,处理数据的方法
-
定义一个线程池类:多个提前创建好的线程+线程安全的任务队列
class ThreadPool{
private:
int _thr_max;//线程数量
int _qmax;//任务节点数量
BlockQueue _queue;
public:
ThreadPool(){创建线程 pthread_create(thr_entry())};
void *thr_entry(void *arg){出队任务,进行处理}
}
- 若线程入口函数固定,则线程的任务处理方法固定,
- 若任务处理也加入线程池中,则模块之间的耦合度较高;
因此线程池模块,就只是一个线程池,主管处理任务,
但是不管如何处理,要求外部在抛入数据的同时,顺便将处理方法也给定。
typedef void(*handler_t)(int data);
class ThreadTask{
private:
int _data;//要处理的数据
handler_t_handler;//数据的处理方法
public:
bool Run(){_handler(_data);}
}
4.4 线程安全的单例模式:
4.4.1 单例模式
一种典型的设计模式-针对典型场景设计的解决方案
4.4.2 针对场景
一个类只能实例化一个对象,提供一个访问接口
(一个资源在内存中只能有一份);
4.4.3 目的
- 节省内存空间。
- 防止数据二义混淆(多份数据在同一时刻的不同体现)。
4.4.4 实现
- 饿汉:资源提前全部加载初始化完毕,用的时候直接用。
(以空间换时间的思想) - 懒汉:资源用的时候再去加载初始化,不用就不需要。
(延迟加载的思想)
4.4.5 饿汉
template<class T>
class Singleton{
private:
static T data;//全局共享,程序运行前初始化加载
Singleton(){} //构造函数私有化
public:
T* Getlnstance(){
return &data;
}
};
- 构造函数私有化,无法在类外实例化对象。
- 成员变量(资源数据)静态化,资源单独一份共享,运行前初始化,
初始化过程不用考虑线程安全问题。
4.5.6 懒汉
template<class T>
class Singleton{
private:
volatile static T *data
static std::mutex _mutex;
public:
volatile static T* Getlnstance(){
if(data == NULL){
_mutex.lock();
if(data == NULL)
data = new T();
_mutex.unlock(); }
return data;
}
}
- 构造函数私有化
- 定义静态指针对象(为了在用的时候申请加载)
- 在访问接口中加锁保护资源初始化加载过程
- 在加锁之外进行二次探测,提高效率
- 防止编译器过度优化,使用volatile修饰指针成员变量