目录
一、线程概念
1.什么是线程
2.线程的轻量化
3.LWP字段
4.局部性原理
5.线程的优缺点
6.进程VS线程
二、线程的控制
1.线程创建
2.获取线程id
3.线程退出与等待
4.创建轻量级进程
三、线程的管理
1.pthread库管理线程
2.线程局部存储
四、C++线程库
1.构造函数
2.成员函数
一、线程概念
1.什么是线程
线程是比进程更加轻量化的,在进程内部分出来的执行的一种执行流。线程才是CPU调度的基本单位,进程是承担系统资源的基本实体。
2.线程的轻量化
为什么说线程轻量化呢?因为创建线程的时候只需要创建一个PCB参与资源的分配即可,这里的资源指的是进程地址空间,也就是说线程也是在进程的地址空间中运行的。所以不需要创建进程地址空间、页表并建立映射,以及文件描述符、信号处理方式、当工作目录等等数据信息都是共享的,所以说线程是一个非常轻量化的执行流。当然每种系统对于进程的创建是不一样的,上述是Linux方案,而windows方案中设计了一种单独的TCB体系。
那么很多内容都是共享的,所以不像父子进程那样修改一个变量会发生写时拷贝,对于多线程环境下来说,同时修改一个进程不会发生写时拷贝,而是修改的同一个物理内存位置的变量,因为他们是在共同完成一个任务。
3.LWP字段
其实Linux中是不存在真正的线程的,而是在进程的数据结构体系上模拟出来了线程,每个线程被创建之后会参与资源的分配(进程地址空间),每个线程执行一部分进程的代码,共同完成一个进程任务。CPU在调度的时候是按一个一个PCB调度的,进程还是线程都有自己的PCB,所以CPU不需要区分是进程还是线程,他们的PCB都是地位对等的。
创建多线程的本质:划分页表地址-->划分进程地址空间。
同一个进程中多个线程执行流的pid是相同的,因为是同一个进程。可以使用ps -aL查看多线程,会出现新的名词LWP(light weight process)用来区别不同的线程,主线程的PID和LWP是一样的CPU在真实调度的时候其实看的是LWP。
4.局部性原理
线程不仅仅是创建上更加简单,更加轻量化,在调度方面也比较简单。线程在切换的时候,只需要修改少量的CPU中的寄存器即可,页表,地址空间等上下文是不需要切换的。而进程切换时是全部都需要替换。
局部性原理就是例如代码在执行第500行的时候,大概率下一次执行会执行501行,所以对于一个大型程序可以一边运行一边加载。CPU中有一个大的cache缓存,也是同理,不会一行一行的去内存读取代码和数据,而是一次读取一部分到cache缓存中,并且把常用的数据也放进去,这样CPU很大程度上只会在cache中取数据,提高了效率。局部性原理可以说为预加载机制提供了理论依据。
所以说线程在切换的时候,很大几率上不会使cache中的大部分数据失效,只会去替换一小部分数据以及寄存器。所以说线程的调度切换上也很简单。
对于调度方面,每个进程都有一个时间片,时间片也是资源,那么划分到线程上,是多个线程平均瓜分一个时间片的,保证了在一个时间片内可以使得多个线程都能被调度。
5.线程的优缺点
优点
- 轻量化:在创建、调度、释放等方面体现。
- 可以多处理器并行处理任务:对于多核处理器只运行一个或几个进程的时候,没有达到所有的处理器都在工作,所以无法发挥出多核处理器的性能,一个进程变成了多个线程,也就是多个执行流的话,可以使所有核心都参与工作发挥性能。
- 对于计算密集型和I/O密集型应用会提高效率:可以减少等待时间,让一个进程去执行计算和I/O操作,主线程和其他进程继续向下执行,等到需要计算数据和I/O数据的时候,该线程已经获取到了。
缺点
- 性能损失:如果说线程数量比可用的处理器多的话,会增加额外的同步与调度开销(上下文切换,调度器负载增加等问题),因为多个线程竞争有限的处理器资源的时候,可能会导致缓存失效率增加,以及多个线程的管理也是问题,会占用过多的系统资源。
- 缺乏访问控制:多线程可以同时修改一个变量、函数等各种资源,所以需要配合加锁来控制。
- 健壮性降低:任何一个线程出错崩掉的话,真个进程可能会终止。
- 变成难度比较高
6.进程VS线程
下面是从资源分配、调度与执行、并发性与独立性以及销毁等方面阐述的。
进程是资源分配的基本单位,每个进程有自己独立的地址空间、包括代码段、数据段以及堆栈区域等。这意味着进程之间的数据是相互隔离的,一个进程无法直接访问另一个进程的数据,无法直接通信,拥有较高的独立性。还拥有自己独立的文件描述符表、进程PID等资源。但是多进程的调度上就会费力一些,需要全部替换。销毁的时候成本也比较高,需要将整个进程体系的数据结构全部释放。
线程是进程中的执行单元,他共享所属进程的资源。多个线程中可以相互访问相同的代码段、数据段以及打开的文件等各种资源。独立性不是很好,但是对于调度方面需要替换的上下文数据很少,所以调度很简单。在并发性上,多个线程高效的协同工作,共享数据可以提高执行的效率。在销毁方面也比较简单,只需要销毁创建PCB等少量数据结构就可以。
二、线程的控制
1.线程创建
头文件 <pthread.h>
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start_routine)(void*), void* arg);
pthread_t是一个unsigned long类型,thread是一个输出型参数用来接收线程的id;attr是用来设置线程的属性,一般设置为nullptr即可;start_routine是一个函数指针,传递一个函数地址,用来告诉线程需要执行的哪个部分的代码,arg则是传递给start_routine的参数,没有的话,设置为nullptr。
在使用该函数的时候,编译的时候可能会报错,因为Linux中没有实际的线程,只有轻量级进程,所以Linux内核只会提高轻量级进程创建的系统调用接口,没有线程的接口,所以上述的接口属于第三方库实现的。
pthread属于第三方库,属于用户与系统交互的一个中间软件层,该线程库为用户层提供线程的各种接口,当用户进程创建操作的时候,在线程库内部会将创建的线程,通过调用创建轻量级进程的系统调用接口,将线程转化为操作系统认识的轻量级进程。对于第三方库的调用,即使说将头文件以及库文件放入了指定的环境位置,也需要在编译的时候指定写出使用哪一个库才可以,所以不写的话编译的时候可能会报错。
C++也有多线程库,但是内部也是对pthread库的一个封装。
2.获取线程id
pthread_t pthread_self(void);
绝大多数情况用于线程函数内部自己获取自己的线程id 。
3.线程退出与等待
线程的退出分为三种,一种是跑完代码后自己退出线程函数或者说特定的判断条件后直接return返回,另一种则是使用线程退出函数,pthread_exit和exit不一样,exit会使整个进程退出,而这个接口只会让当前线程退出。retval是退出的时候,返回的参数。最后一种是使用了线程取消函数pthread_cancel,会发送SIGCANCEL信号异常终止该线程。
void pthread_exit(void* retval);
int pthread_cancel(pthread_t thread);
对于退出的线程和子进程一样,也需要进行线程等待,用于获取线程的执行情况,返回值以及对线程资源进行回收操作,防止出现内存泄露。
int pthread_join(pthread_t thread, void** retval);
thread是传递的需要等待的线程id,retval则是输出型参数,用于接收线程正常退出的返回值,如果是异常退出的话,整个进程都会终止,所以接收一个线程的返回值已经没有意义了。该等待方式是阻塞式等待。
阻塞等待和接收返回值的操作,对于一些计算、I/O场景需要确保线程执行完毕后,获取到线程计算的值或者I/O读取的值之后,才可以进行的场景很有用。因为可能下面的代码需要用到线程执行的结果,但是线程计算完,主线程就往下运行的话,对于使用到线程计算数据的操作可能是未定义的,所以阻塞式等待可以确保上述场景不会出错。
线程模式默认式joinable的也就是可以被等待的,但是我们可以手动设置为分离状态,也就是不需要进行等待该线程,适用于不需要获取线程返回值的场景,分离之后,主线程就不会因为等待一些对于自己执行没有用处的线程而阻塞住了。设置为分离状态也就和进程忽略SIGCHLD信号一样,运行结束后会直接交给操作系统领养,不会造成内存泄露问题。
int pthread_detach(pthread_t pthread);
4.创建轻量级进程
int clone(int (*fn)(void*), void* stack, int flags, void* arg, ...);
第一个参数是轻量级进程执行的方法,stack是用户传入栈空间地址,flags表示创建子进程还是创建轻量级进程,所以说fork的底层也是使用的clone接口。
在进程替换的时候,最好是在线程中先创建一个子进程,之后再进行替换,因为程序替换是进程级别的,一个线程执行程序替换,替换的也是整个进程。
三、线程的管理
1.pthread库管理线程
虽然说操作系统中没有线程的概念,但是用户层面是把轻量级进程作为线程来处理的,所以就需要有人帮我们进行两者之间的转化,也就是pthread原生第三方库,该线程库会做上述的操作,同时他也会创建很多个线程,所以需要将多个线程使用先描述再组织的操作进行管理多个线程,类似于windows的tcb结构体。同时线程库内部也一定有调用创建轻量级进程的接口、lwp字段的映射等内容。
对于栈空间来说如何做到每个线程都独立的呢?对于主线程使用的是进程地址空间中的栈区域,而对于其他线程来说使用的是由线程库提供的栈空间。C语言的文件FILE结构体中都有一个对应的文件缓冲区,是由C库自己维护的,所以说库是有维护一块地址空间的能力的。线程库提供栈区的方式是在进程地址空间中的堆区malloc一块空间作为其他线程的栈区,让其他线程的栈区指针指向该malloc的区域。
因为pthread是一个动态的库,所以说要程序运行的时候,要现在加载到内存,然后映射到进程地址空间的共享区内部。pthread库内部会把一个个线程用结构体管理起来,内部由线程栈指针指向自己堆区内部的栈空间还有线程局部存储、LWP等字段。对于我们创建的线程获取到的线程id,其实就是该线程属性结构体在线程库中的地址。
2.线程局部存储
对于全局变量在前面加一个__thread编译选项的话,在创建多线程时,在每个线程的局部存储里都会开辟一个空间存储该变量的值(只能修饰内置变量),对于局部存储的变量是每一个线程都有一份,属于线程的私有变量,修改不会影响其他线程。
四、C++线程库
C++是将线程封装成了一个std::thread类,创建一个线程就相当于创建了一个类对象,对线程的所有操作都转化为了对于类对象的操作。
1.构造函数
- 默认构造函数时std::thread():表示创建一个空的线程对象,不关联任何的实际线程。
- 关联函数的构造函数:std::thread(Fn&& fn, Args&&... args),这个是最常用的构造函数,用于创建一个线程并关联一个可调用对象(函数、函数对象、lambda表达式等),Fn是可调用对象的类型,Args表示可调用对象的参数类型。对于C++11的线程库,传递的线程函数对于返回值以及参数是没有要求的。
// 关联普通函数 void print_hello() { std::cout << "Hello, world!" << std::endl; } std::thread t1(print_hello); // 关联函数对象 class Printer { public: void operator()() const { std::cout << "Function object printing." << std::endl; } }; std::thread t2(Printer()); // 关联lambda表达式 std::thread t3([]() { std::cout << "Lambda expression printing." << std::endl; }); // 线程的参数传递 void print_number(int num1, int num2) { std::cout << "The number1 is: " << num1 << "number2 is: " << num2 << std::endl; } std::thread t4(print_number, 10, 20);
2.成员函数
- join()函数:用于线程等待
- detach()函数:用于线程分离
- joinable()函数:用于检查对象是否可以被join或detach,如果说线程对象关联了一个实际运行的线程,而并非空的线程对象的话,会返回true,否则返回false。