什么是线程?
现在有一个机床工厂,加工零部件,工厂中的工人是真正干活的人。若工厂中只有一个工人,则同一时间只能完成一个零部件的加工。
如何实现同时加工多个零件?
- 第一种方式: 多建几个厂房,一个厂房中至少有一个工人
- 第二种方式:一个厂房中多招几个工人
线程的功能:多任务处理
- 多创建几个进程,一个进程就有一个pcb,能够串行化的完成一个任务
- 在一个进程中多创建几个pcb,因为pcb是调度程序运行的描述,因此有多少个pcb就会有多少个执行流程
在之前我们说进程就是一个pcb,是程序动态运行的描述,通过pcb可以实现操作系统对程序运行的调度管理。但是在多线程中,线程是进程中的一条执行流,这个执行流在Linux下是通过pcb实现的,因此实际上Linux中的线程就是一个pcb,然而pcb是进程,并且Linux中的pcb共用一个虚拟地址空间,相较于传统pcb更加轻量化,因此也被称为轻量级进程。本质上来说线程是不存在的,只有轻量级进程。
Linux中的进程其实是一个线程组,一个进程中可以有多个线程(多个pcb),线程是进程中的一条执行流。类似于进程就是工厂,线程就是工人(Linux中工人就是pcb)。而在Linux中pcb可以实现程序的调度,因此在实现线程的时候,使用了pcb来实现。创建线程会伴随在内核中创建一个pcb来实现程序的调度,作为进程中的一条执行流。进程就是多个线程的一个集合,并且这个进程中的所有pcb共用进程中的大部分资源(程序运行时,操作系统为程序运行所分配的所有资源),因此这些pcb在Linux中又被称为轻量级进程。
进程:是一个程序动态的运行,其实就是一个程序运行的描述(pcb)
线程:是进程中的一条执行流,执行一个程序中的某段代码
进程是操作系统资源分配的基本单位:程序运行起来后操作系统资源是分配给整个线程组的。
线程是CPU调度的基本单位:Linux下CPU通过调度pcb来实现程序的调度。
线程的独有与共享
独有
为了避免线程之间调用栈混乱,因此每个线程都有自己的栈区。每一个线程都有自己的一套 寄存器(能够控制自己的运行序列),信号屏蔽字,errno,线程标识符,调度优先级。
共享
虚拟地址空间(数据段,代码段),文件描述符表,信号处理方式,当前工作路径,用户id/组id。
如:为什么信号是先注销,再处理?信号屏蔽字
信号是针对整个进程通知事件进行处理的,但是一个信号只需要被处理一次就够了;然而一个进程有可能存在多个执行流,到底谁来处理这个事件(谁拿到时间片能处理就处理),有的线程不希望操作被信号打断,就可以独立屏蔽这个信号。
多线程、多进程进行多任务处理的优缺点
多线程:
- 线程间通信更加灵活方便(除了进程间通信方式之外还有全局变量以及函数传参,因为共用同一个虚拟地址空间,只要知道地址就能够访问同一块空间)
- 线程的创建与销毁成本更低(创建线程就是创建一个pcb,共用的数据只需要使用一个指针指向一处就可以了)
- 同一个进程中的线程间调度成本更低(进程之间调度需要切换页表)
- 线程间缺乏访问控制,一些系统调用以及错误是针对整个进程产生效果的(多进程稳定性更强)
- 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现。但是如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变
- I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作
多进程
- 多进程的健壮性、稳定性更高(异常以及一些系统调用exit直接针对整个进程生效)
线程控制
Linux操作系统并没有提供线程的控制系统调用接口,因此大佬们封装了一套线程控制接口库。
使用库函数实现创建的线程我们称为用户态线程,这个用户态线程在内核中使用一个轻量级进程实现调度。
Linux下的线程:用户态线程 + 轻量级进程
线程中 id 的讨论:
- tid:是一个线程id,线程的操作句柄,准确的来说这个 tid 是用户态线程的 id,即线程独有的空间的首地址。每个线程创建出来后,都会在虚拟地址空间的共享区上开辟一块空间,存储自己的栈、描述信息等。
- pid:是一个轻量级进程id,是内核中 task_struct 结构体中的 id
task_struct->pid:轻量级进程 id,即我们在上图中所看到的 LWP
task_struct->tpid:线程组 id,即主线程 id(就是我们在上图中所看到的PID,即进程 id)
线程创建
- thread:输出型参数,用户获取线程 tid,线程的操作句柄
- attr:线程属性,用于在创建线程的同时设置线程属性,通常置NULL
- start_routine:函数指针,线程的入口函数(线程运行的就是这个函数),函数运行完毕线程就会退出
- arg:传递给线程函数的参数
- 返回值:成功返回0 失败返回非0值
线程退出
线程函数运行完毕,线程就会自动退出(在线程函数中调用 return),但是在 main 函数中调用 return,退出的是进程(导致所有线程退出)而不是主线程。主线程退出并不会导致进程退出,只有所有的线程都退出了,进程才会退出。
-
第一种方式:退出线程自身,谁调用谁退出
retval:线程退出的返回值
如:pthread_exit(NULL);
即不关心线程退出的返回值 -
第二种方式:终止指定线程,让指定线程退出
thread:要指定退出的线程 tid
如:pthread_cancel(tid);
线程等待:等待指定线程退出,获取退出线程的返回值,释放退出线程的资源
一个线程创建出来,默认有一个属性 joinable;处于 joinable 属性的线程退出后,不会自动释放资源;需要被其它线程等待获取其返回值,才能释放资源。默认情况下,一个线程必须被等待,若不等待会造成资源泄露。
- pthread_join 是一个阻塞函数,线程没有退出则一直等待
- thread:要等待退出的线程 tid
- retval:输出型参数,用于获取等待的退出线程的返回值
等待的退出线程的返回值是一个 void*,是一个一级指针,若要通过一个函数的参数获取一级指针,就需要传入一个一级指针变量的地址
线程分离:将线程的 joinable 属性修改为 detach 属性
线程若处于 detach 属性,则线程退出后将自动回收资源;并且这个线程不需要被等待,等待是没有意义的,因为线程退出后的返回值占用的空间已经被回收了。
分离一个线程的前提:一定是对这个线程的返回值不感兴趣,根本就不想获取,也不想一直等待线程退出,这种情况才会分离线程。
thread:要分离的线程 tid,线程被属性修改为 detach,可以在任意线程中实现
获取调用线程的 tid
测试用例:https://github.com/achen228/Linux/tree/master/thread/thread_control
线程安全
线程安全:多个线程同时对临界资源进行访问操作而不会造成数据二义性
临界资源:多个执行流都能够直接访问的公共资源
线程之间通信极为方便灵活,这是线程的最大的一个优点,但是多个线程作为多个执行流对同一个临界资源进行访问,就有可能会出现数据的二义性。
如何实现线程安全:同步 + 互斥
- 同步:对临界资源访问的时序合理性
- 互斥:对临界资源同一时间访问的唯一性
线程间互斥的实现:互斥锁
原理:互斥锁本身是一个只有 0/1 的计数器,描述了一个临界资源当前的访问状态,所有执行流在访问临界资源时都需要先判断当前的临界资源状态是否被允许访问,如果不允许则让执行流等待,否则可以让执行流访问当前临界资源。但是在访问期间需要将当前临界资源的状态修改为不可访问状态,这期间如果其它执行流想要访问,则不被允许。
所有的执行流都需要通过同一个互斥锁实现互斥,意味着互斥锁本身就是一个临界资源,大家都会访问。但是如果互斥锁本身的操作并不安全如何保证别人的访问安全。所以互斥锁本身的操作必须是安全的,互斥锁自身计数的操作是原子操作,不可打断。
互斥锁实现原子操作的原理:
互斥锁具体的操作流程以及接口介绍
1.定义互斥锁变量
pthread_mutex_t mutex;
2.初始化互斥锁变量
如:pthread_mutex_init(&mutex, NULL);
3.在访问临界资源之前进行加锁操作(不能加锁则等待,能加锁则修改临界资源状态,置为不可访问)
若可以加锁则直接修改计数,函数返回;否则挂起等待(将线程状态设置为可中断休眠状态,被唤醒后设置为运行状态)。
如:pthread_mutex_lock( &mutex);
pthread_mutex_lock:阻塞加锁,如果当前不能加锁,则一直等待直到加锁成功
pthread_mutex_trylock:非阻塞加锁,如果当前不能加锁,则立即报错返回
4.在临界资源访问完毕之后进行解锁操作(将临界资源状态置为可访问,唤醒其它执行流)
如:pthread_mutex_unlock( &mutex);
5.销毁互斥锁
如:pthread_mutex_destroy( &mutex);
测试用例:https://github.com/achen228/Linux/tree/master/thread/mutex
死锁
多个线程对锁资源进行竞争访问,但是因为推进顺序不当,导致相互等待,使程序无法往下运行。
死锁实际上是一种程序流程无法继续推进,卡在某个位置的一种概念。
死锁的产生通常是在访问多个锁的时候需要注意的事项。
死锁产生的必要条件:有一条不满足就不会产生死锁
- 互斥条件:我加了锁,别人就不能再继续加锁
- 不可剥夺条件:我加的锁,别人不能解,只有我能解
- 请求与保持条件:我加了A锁,然后去请求B锁;如果不能对B锁加锁,则也不释放A锁
- 环路等待条件:我加了A锁,然后去请求B锁;另一个人加了B锁,然后去请求A锁
死锁的预防:破坏产生死锁的必要条件(主要避免后两个条件的产生)
死锁的避免:死锁检测算法、银行家算法
线程间同步的实现:条件变量(通过条件判断实现临界资源访问的时序合理性)
等待 + 唤醒:操作条件不满足则等待,别人促使条件满足后唤醒等待。
线程在对临界资源访问之前,先判断是否能够操作;若可以操作则线程直接操作;否则若不能操作,则条件变量提供等待功能,让pcb等待在队列上。直到其它线程促使操作条件满足,然后唤醒条件变量等待队列上的线程。
互斥锁具体的操作流程以及接口介绍(条件变量是搭配互斥锁一起使用的)
1.定义条件变量
pthread_cond_t cond;
2.初始化条件变量
- 如:
pthread_cond_init(&cond, NULL);
- 直接通过赋值初始化条件变量
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
3.若资源获取条件不满足时调用接口进行阻塞等待
- 如:
pthread_cond_wait(&cond, &mutex);
- pthread_cond_wait 实现了三步操作(其中解锁和休眠是一步完成,保证原子操作)
解锁
休眠
被唤醒后加锁 pthread_cond_timewait(pthread_cond_t* restrict cond, pthread_mutex_t* restrict mutex, const struct timespec* restrict abstime);
设置阻塞超时时间的等待接口,即在等待的指定时间内都没有被唤醒则自动醒来
4.其它线程在促使条件满足后唤醒等待
- 唤醒至少一个线程
pthread_cond_signal(&cond);
- 广播唤醒所有线程
pthread_cond_broadcast(&cond);
5.销毁条件变量
如:pthread_cond_destroy(&cond);
测试用例:https://github.com/achen228/Linux/tree/master/thread/cond
生产者消费者模型
大佬们针对典型场景设计的解决方案。
典型场景:任务处理中既有数据产出,又有数据处理的这种场景。
数据的生产与数据的处理,放在同一个线程中完成,因为执行流只有一个,那么肯定是生产一个处理一个,处理完一个后才能生产下一个。这样的话依赖关系太强,如果处理比较慢,也会拖得生产速度慢下来。因此将生产与处理放到不同的执行流中完成,中间增加一个数据缓冲区,作为中间的数据缓冲场所。
优点:解耦合,支持忙闲不均,支持并发
实现:一个场所(线程安全的缓冲区),两种角色(生产者与消费者),三种关系(实现线程安全)
测试用例:https://github.com/achen228/Linux/tree/master/thread/pro_con_model
信号量
可以用于实现进程或线程间同步与互斥(主要用于实现同步)
本质上就是一个计数器 + pcb等待队列
同步的实现
通过自身的计数器对资源进行计数,并且通过计数器的资源计数来判断进程或线程是否能够符合访问资源的条件
若符合就可以访问,若不符合则调用提供的接口使进程、线程阻塞,其它进程或线程促使条件满足后,可以唤醒 pcb 等待队列上的 pcb
互斥的实现
保证计数器的计数不大于1,就保证了资源只有一个,同一时间只有一个进程或线程能够访问资源,实现互斥
互斥锁具体的操作流程以及接口介绍
1.定义信号量
sem_t sem;
2.初始化信号量
- sem:定义的信号量变量
- 0:用于线程间安全 非0:用于进程间安全
- value:初始化信号量的初值,初始资源数量有多少计数就是多少
- 返回值:成功返回0 失败返回-1
3.在访问临界资源之前,先访问信号量,判断是否能够访问,如果可以访问计数 -1
- sem_wait:通过自身计数判断是否满足访问条件,不满足则一直阻塞进程或线程
- sem_trywait:通过自身计数判断是否满足访问条件,不满足则立即报错返回
- sem_timedwait:通过自身计数判断是否满足访问条件,不满足则等待指定时间,超时后报错返回
4.促使访问条件满足,计数 +1,唤醒阻塞进程或线程
通过信号量唤醒自己阻塞队列上的pcb
5.销毁信号量
测试用例:https://github.com/achen228/Linux/tree/master/thread/sem
线程池
线程池:装有线程的池子,有很多线程,但是数量不会超过池子的限制。(需要用到多执行流进行任务处理时,就从池子中取出一个线程去处理)
应用场景:有大量的数据处理请求,需要多执行流并发、并行处理。
若是一个数据请求的到来伴随一个线程的创建去处理,则会产生一些风险以及不必要的消耗:
- 线程若不限制数量的创建,在峰值的压力下,线程创建过多,资源耗尽,有程序崩溃的风险
- 处理一个任务的时间:创建线程时间 t1 + 任务处理时间 t2 + 线程销毁时间 t3 = T,若 t2/T 比例占据不够高,则表示大量的资源用于线程的创建与销毁成本上,因此线程池使用已经创建好的线程进行循环任务处理,就避免了大量线程的频繁创建与销毁的时间成本。
自主编写一个线程池:大量线程(每个线程中都是进行循环的任务处理) + 任务缓冲队列
线程的入口函数:都是在创建线程的时候就固定传入的,导致线程池中的线程进行任务处理的方式过于单一。因为i线程的入口函数都是一样的,处理流程也就都是一样的,只能处理单一方式的请求。灵活性太差。
若任务队列中的任务,不仅仅是单纯的数据,而是包含任务处理方法在内的数据,这时候,线程池中的线程只需要使用传入的方法,处理传入的数据即可,不需要关心是什么数据,如何处理。提高线程的灵活性。即要处理什么数据,如何处理的方法,组织成为一个任务节点,交给线程池,线程池中找出任意一个线程只需要使用方法处理数据即可。