目录
一.背景知识
二.线程的概念
- 进程中执行的基本单位线程,一个进程可以包含多个线程。这些线程共享进程的资源,但每个线程有自己的执行路径和栈。
- 在单进程中代码是串行执行的,在多线程的进程中代码可以并发执行的。
线程需要创建、需要调度、销毁、与进程产生关联,所以操作系统需要对线程进行管理,先描述再组织。
struct tcb
{
//线程的id;
//线程的状态;
//线程的优先级
//上下文内容(用于记录CPU执行到代码的什么位置,方便下一次轮转到时知道从什么位置开始执行)
}
Windows操作系统、Linux操作系统对线程的设计是不同的,Linux操作系统对线程的管理复用的是进程的管理,来模拟对线程管理。而Windows操作系统单独设计了一个对线程的管理,调度算法也是单独设计的。进程在设计的时候就是用来调度和执行的,而进程也是用来调度和执行的,所以struct tcb{}内部所需要的属性和struct pcb{}内部所需要的属性一样,tcb的调度算法完全也可以复用pcb的调度算法,Linux操作系统对线程的管理复用的是进程的管理。
【创建线程的系统调用】
#include <pthread.h>
int pthread_create(pthread_t* thread,
const pthread_attr_t* attr,
void *(*start_routine)(void *), void* arg);
- 线程创建成功,主线程继续向下执行代码,新线程执行对应的函数;
- thread输出型参数,线程如果创建成功,会将线程id返回给thread;
- start_routinue函数指针,线创建成功后新线程所要执行的代码;
- 当新线程执行start_routine函数时arg作为实参传递给start_routine函数。
- ps -aL:查看线程信息
- 执行创建新线程操作是在一个进程中的执行的,最开始就存在一个线程,该线程就是主线程,当前进程的id就是主线程的id。
【线程的优点】
- 线程的创建成本要低于进程的创建成本,创建进程需要创建task_struct、虚拟地址空间、页表,而线程的创建只需要创建task_struct;
- 在运行期间CPU调度线程的成本低,进程需要CPU重新切换页表,这个成本也不是最主要的影响,切换页表只需要更改cr3寄存器中的值。
- 删除一个线程成本低,删除一个进程需要删除页表、释放task_struct、释放内存空间,还需要释放代码和数据,但删除线程只需要释放task_struct。
- 进程与进程之间数据和代码是独立的,这个进程出现了Bug另一个进程不会报错。一个进程中的线程之间,当一个线程出现了问题,那该进程就出现了问题,所有的线程都不能执行。
- 线程占用的资源要比进程少很多;
- 能充分利用多处理器的可并行数量;(进程也有)
- 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务(进程也有)
【线程的缺点】
- 一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变;
- 单个线程出异常,如:出现除零,野指针问题,就类似进程出异常,操作系统会给该进程发送信号,终止进程,进程终止,该进程内的所有线程也就随即退出。
【线程的用途】
计算密集型应用:这类应用通常需要大量的数学运算、逻辑处理或数据计算,性能主要受限于CPU计算能力,而I/O操作相对较少。例如:大数据分析、图像处理、如密码学算法、数据加密/解密操作等。优化方法:为了能在多处理器系统上运行,将计算分解到多个线程中实现。
I/O密集型应用:I/O密集型应用是大量读取或写入数据的应用,例如:文件处理、数据库查询、网络请求等。I/O密集型应用CPU利用率较低,因为大部分时间在等待I/O操作完成,CPU的利用率通常不高。优化方法:使用异步I/O操作,可以避免阻塞主线程,提高并发处理能力;为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
并不是任何情况下为了提高应用的性能创建线程越多就越好,在单核的CPU执行任务时每次只能执行一个线程,创建多个线程就需要调度轮转,在调度轮转的过程中需要重新加载,这样多线程反而没有单线程的效率高。对于计算密集型应用,线程的创建的个数最多与CPU的核数相同比较好,这样才能能提高密集型程序的执行效率。对于I/O密集型应用可以创建多个线程。
进程的多个线程共享同一地址空间,因此代码段是共享的,如果定义一个函数,在各线程中都可以调用;数据段也共享,如果定义一个全局变量,在各线程中都可以访问到。除此之外各线程还共享以下进程资源和环境:文件描述符表,一个线程打开了文件,该文件是被其他线程共享的,都能够读取和写入数据;当前工作目录(pwd);用户id和组id。但也拥有自己的一部分数据:栈、上下文数据、线程ID、errno、信号屏蔽字、调度优先级。
三.线程控制
#include <pthread.h>
int pthread_create(pthread_t* thread,
const pthread_attr_t* attr,
void *(*start_routine)(void *), void* arg);
- 新线程创建成功返回0,创建失败返回错误码
- 主线程等待新线程,retval输出性参数,用于接收新线程所执行函数的返回值。
在主线程不等待新线程的情况下,如果主线程先行退出,那所有的线程都会跟着退;如果新线程先行退出,操作系统会将此进程的资源维护起来,让主线程拿新线程的返回值,此时没有等待新线程,会出现类似于进程中的僵尸进程状态,造成资源泄漏。
【主线程等待新线程】
【全面看待给线程传参】
- 可以给线程传递任意类型的变量地址,包含对象地址;
- static_cast<ThreadData*>(args)是安全类别的强转,会对目标类型和转换数据做安全性的检查,能否强转
- 给线程传递对象地址,代表可以给线程传递多个数据,并且可以调用类中的成员函数
【全面看待线程函数返回值】
- 某种类型转换成void类型,void类型只能转换回原来的某种类型
全面看待线程函数的返回值一:只考虑正确的返回值,不用考虑异常情况线程函数的返回值,因为线程异常整个进程都崩溃了,也就没有机会拿到线程函数的返回值。
- 全面看待线程函数的返回值二:线程函数的返回值是任意类型,可以是自定义类型
【创建多线程问题】
- for循环中每次创建给name字符数组在栈上创建的空间地址是相同的,线程通过name的地址找name空间中的数据时可能主线程重新创建name,重新赋值。
- 在for循环中每次给name在堆上申请的空间地址不同
- 主线程等待多线程
【取消线程】
- 取消线程,前提条件是要取消的线程存在。
- 取消线程后线程会返回(void*)-1。
【新线程分离】
- 新线程分离后新线程不能被等待(也就不能接受新线程的返回值)。如果主线程先行退出已分离的线程也会终止,只要主线程不先退新线程会自动释放。
- 不仅主线程分离新线程,而且线程也可以自己分离自己
【C++11的多线程】
四.线程ID
- 打印内核中轻量级进程id和通过C++语言打印的线程id不一样。
五.多线程的互斥和同步
- 全局变量g_val多线程共享的,如何让全局变量g_val让每个线程各自私有一份,__thread int val=10;(两个’_')这里的__thread只能修饰内置类型,只能在Linux下有效。
- 主线程一直都是10,而新线程在++。
多线程在并发条件下访问同一块资源可能会导致数据不一致问题。
- 多个线程并发去抢票,造成多个线程抢到同一张票,当票没有之后还有线程去抢。
【原因分析】
5.1多线程的互斥
互斥:任何时刻只允许一个线程进行资源访问
5.1.1理解锁
pthread_mutex_t //pthread库中提供的互斥锁的类型
锁的两种初始化方式
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict
attr);
- 对栈上定义的和动态开辟的锁进行初始化
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
- 对全局的或者静态的锁进行初始化
加锁和解锁
//对指定的锁进行加锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
//对指定的锁进行解锁
int pthread_mutex_unlock(pthread_mutex_t *mutex);
销毁锁
int pthread_mutex_destroy(pthread_mutex_t *mutex);
-
没有必要对全局的和静态的锁进行销毁,全局的静态的锁会随着程序的结束自动销毁
对资源的访问是通过代码来访问的,所以对资源的保护本质上是对访问资源代码的保护。被保护的资源称为临界资源,访问临界资源的代码称为临界区,没有访问临界资源的代码称为非临界区,所以要在临界区的开始进行加锁,临界区的结束进行解锁。
- 如上代码位置解锁,当ticket为0时走else直接break那就无法再进行解锁
【对抢票代码修改】
- 在抢票的过程中,给其他线程都进行加锁,但给某个线程不加锁,这个在技术上是能够实现的,再写一个抢票的函数不加锁让指定的线程去执行,但是如果写了这样的程序就有了Bug。
所有的线程要能够加锁要保证所有的线程能够看到锁,所以锁必须是共享资源,加锁的过程必须是原子性(原子性:只有两种状态,要么没有执行,要么执行完成,不会出现中间状态,一条指令就能够完成的具有原子性,两条以上指令才能执行成功就不具有原子性,因为执行完第一条指令可能会被切走,此时是执行了但还没有完成,处于一种等待的状态),也就是一个线程正在加锁的过程中,其他线程不能进行加锁。
- 一旦某个线程申请锁成功,会继续执形后面的代码,后面的代码有可能是很长,所以执行后面的代码期间线程是可以被切换的,被切换了其他线程就会申请锁,其他多个线程申请锁都会失败,都会进入到阻塞的状态,直到解锁某个线程才会被唤醒。
如何理解申请锁成功,线程继续向后执行,申请锁失败被阻塞?
线程通过调用pthread_mutex_lock()函数,thread_mutex_lock()内部检查锁是否就绪,如果锁就绪了该函数就返回继续执行后面的代码,如果锁没有就绪会将线程的状态设为S状态,然后放入到等待队列中,这时该线程也就不会继续执行,pthread_mutex_lock()也就无法返回,从而也就无法执行后面的代码,如果线程调用了pthread_mutex_unlock(),会将所有阻塞的线程唤醒,被唤醒的线程与其他线程再次竞争所锁。
5.1.2锁的实现原理
5.2多线程的同步
5.2.1条件变量
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
- 定义静态的或者全局的条件变量
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrict attr);
- 初始化动态开辟的或者局部的条件变量;
- cond:要初始化的条件变量;
- attr:设为NULL。
int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex);
- 指定到具体的条件变量下去等待,cond:指定的条件变量
int pthread_cond_broadcast(pthread_cond_t *cond); //唤醒所有线程
int pthread_cond_signal(pthread_cond_t *cond);//唤醒一个线程
- 唤醒指定条件变量下的线程
int pthread_cond_destroy(pthread_cond_t *cond);
- 释放条件变量
5.2.2生产消费模型
【阻塞队列】
阻塞队列是一种常见的实现生产消费模型的数据结构。
BlockQueue.hpp
test.cc
生产消费模型的优点:
- 解耦
- 高效:多线程访问临界资源时是互斥的,一个生产线程正在向共享资源写任务时生产线程并发构建任务,消费线程并发执行任务;多个消费线程并发执行多个任务,多个生产线程并发生产任务;生产线程生产任务时消费线程执行任务;
在实际项目中根据具体情况选择生产线程的个数、消费线程的个数,如果产生任务、向共享资源写任务复杂耗时,那就生产线程个数多一些。如果执行任务、从共享资源读任务复杂耗时,那就消费线程个数多一些。
为什么让线程在条件变量下等待的代码是在临界区中(在加锁和解锁代码之间)呢?
让线程到条件变量下去等待需要条件判断,条件判断是临界资源的状态,所以条件判断是在临界区中的,从而调用pthread_cond_wait();接口让线程到条件变量下去等待在加锁和解锁代码之间的。
5.3环形队列
5.3.1信号量
信号量是用来保护临界资源的,信号量本质是一个计数器。信号量不能是全局变量,因为对父子进程而言不能同时做修改,对没有关系的进程而言不能同时访问,所以信号量是一个共享资源,既然是一个共享资源,需要被保护,所以申请信号量和释放信号量的操作必须是原子的,申请信号量P操作,释放信号量V操作。
申请信号量本质是对共享资源的预定机制
【线程库中对信号量的操作】
//初始信号量
#include <semaphore.h>
int sem_init(sem_t *sem, int pshared, unsigned int value);
//对信号量的申请
int sem_wait(sem_t *sem);
- 申请信号量成功返回0,代码继续向下执行;
- 信号量资源不足,会被阻塞
//归还信号量
int sem_post(sem_t *sem);
//释放信号量
int sem_destroy(sem_t *sem);
5.3.2环形队列
环形队列数据结构
- 队里为空时只能让生产线程先放任务,放完任务之后消费线程
- 队列为满时只能让消费线程先读取任务,读取任务之后
(为空为满时只能让生产线程、消费线程一个执行这是互斥;先执行完生产线程/消费线程再执行消费线程/生产线程这是同步) - 队列不为空&&不为满时生产线程、消费线程同时进行
环形队列实现生产消费模型的实现思路:
- 在环形队列中任何时候生产线程与生产线程是互斥的,通过一把锁来维护;
- 任何时候消费线程与消费线程互斥,通过一把锁来维护;
- 为空为满时生产线程与消费线程为空为满时是互斥和同步,其他时候是同时进行,通过信号量来维护。
该程序中生产线程和生产线程互斥、消费线程和消费线程互斥,如果将该程序中信号量初始值设为1(信号量初始值设为1称为二元信号量),任何时候生产线程和消费线程互斥、同步,这样环形队列就变成了阻塞队列。