线程概念,控制
一、线程概念
线程概念:进程内部的一个执行流,轻量化
。
观点:进程是系统分配资源的基本单位,线程是CPU调度的基本单位
。
在理解线程之前,我们在谈一下虚拟地址空间。
我们都知道进程是通过页表将虚拟地址转化为物理地址的
,对于PCB,file我们已经了解了,所以,我们主要谈页表。
虚拟地址和物理地址之间映射时,是通过字节映射的吗?如果进程大小是4GB,那么一共就会有 4 * 1024 * 1024 * 1024个字节,如果是按照字节映射,那么页表的大小(虚拟地址和物理地址各占4字节,其它不考虑)就是 8 * 4 * 1024 * 1024 * 1024个字节,也就是32GB,一个进程的页表就这么大,这是不可能的。所以,肯定不是通过字节映射的
。
我们都知道磁盘和物理内存之间是通过4KB进行IO的,在逻辑上我们认为物理内存也是以4KB划分的,物理内存上4KB划分的空间,我们把它叫做页框或者页帧
。如何理解页框呢?
在物理内存中会有许多这样的4KB空间,有些空间被使用,有些未被使用…,那么OS要不要对这些空间进行管理呢?答案是要的,先描述在组织
。
在OS有一个 struct page就是用来描述物理内存4KB空间的,一个4KB空间对应一个 struct page
,那么对空间已经描述了,那么该怎么组织呢?只需要用一个 struct page pages[]数组来管理就可以了,对物理内存的管理就转变为了对数组的增删查改
。
既然如此,在OS内部还需要保存物理地址这样的概念吗?
不需要了,这个数组中每个内存块的大小是固定的(4KB),那么物理块的起始地址 = 数组下标 * 4KB,申请一个物理内存块,本质只要申请到 struct page,知道 struct page的下标,那么物理内存块的所有地址就都知道了
。
那么OS如何得知所有物理内存块的地址呢?OS只需要得到 page数组的起始地址即可
。
结论:文件,进程和物理内存之间的关系就转化为了 file,task_struct 和 page之间的关系了
。
在32位系统下,OS采用的是二级页表,从虚拟地址转换到物理地址,默认是没有直接转化到字节的。虚拟地址一共32个 bit,从左往右依次划分10个 bit,10个 bit,12个 bit,根据CR3寄存器里存储的页表起始地址,使用前10个 bit用来索引一级页表,一级页表中存在1024个页表项(存储的是下一级页表的起始地址),中间的10个 bit用来索引二级页表,二级页表存储的是物理页的起始地址,这样就可以找到物理页框的起始地址了,最后12个 bit用来做页内偏移
。
查页表只需要帮我们找到要访问的是哪一个页框就可以了。
真正的物理地址 = 页框起始地址 + 页内偏移
。
页表的大小 = 4 * 1024
,就是4KB的大小,每一个页表都是4KB大小,那么二级页表一共(1024 + 1)* 4KB
的大小,对比于32GB,那可真是小太多了。
细节1:CR3寄存器保存的是当前进程页表的基地址,物理地址
。
细节2:虚拟地址高20位相同,一定是连续存放在一个页框的,因为索引的时候访问的都是同一个页表的同一个位置
。
细节3:如果知道任意一个虚拟地址,如何得到所处的页框?
addr & 1111 1111 1111 1111 1111 0000 0000 0000
那如何得到 page结构体呢?page 存储在一个结构体数组里,只需要得到数组下标就可以了,数组下标 = 页框号 / 4KB
。
细节4:进程首次加载磁盘块的时候,OS做什么?
内存管理,申请内存就是申请 page,得到 page的数组下标,进而得到页框的物理地址,填充页表
。
细节5:如果访问的是 int呢?一个结构体呢?一个类变量呢?所有变量只有一个地址,开辟空间时最小字节的地址
。
页表转换的时候,只能拿到第一个字节的地址,所以语言中存在一个类型的概念。起始地址 + 偏移量的方式就可以访问了
。
细节6:如何理解写时拷贝?
OS内,申请和管理内存是以4KB为单位的,写时拷贝也是以4KB为单位的,申请一个新的页表,更改映射关系
。
细节7:我们用 new,malloc申请,怎么申请的时候1,4,n字节随意申请的呢?
new,malloc底层一定要调用系统调用(brk,mmap),只有OS才能访问硬件,调用系统调用是有成本的,所以C,C++自己在语言层,会有自己的内存管理机制,类似STL中的空间配置器
。
现在,再来理解什么是线程。
//thread线程标示符,类似于进程pid,输出型参数
//attr,线程的属性,通常设置为nullptr
//start_routine回调函数,函数指针类型
//arg作为回调函数的参数
//成功返回0,失败返回错误码
int pthread_create(pthread_t* thread, const pthread_attr_t* attr,
void*(*start_routine)(void*), void* arg); //创建线程,执行指定的回调函数


大家有没有发现问题呢?一个单进程代码,竟然同时让两个死循环跑起来了。
一个可执行程序,一个进程有一套页表,那么,进程页表的本质是什么?是进程看到资源的"窗口",通过虚拟地址与物理地址的映射,看到内存当中的代码和数据
。
一个进程,两个死循环同时跑起来了,是让不同的线程执行不同的函数,本质是让不同的线程,通过拥有不同区域的虚拟地址,拥有不同的资源,通过函数编译的方式,进行了进程内部的"资源划分"
。
Linux中多线程的实现:
一个进程中可以有一个执行流,那么可以有两个,多个执行流吗?当然是可以的
。那么这些执行流(线程)也是需要被OS管理,OS需要对这些线程分配新的页表,文件,调度算法等资源吗?答案是不需要的
,只需要给线程分配PCB就可以了,线程是进程内部的一个执行流,执行的是进程内部的一部分代码资源
,没有必要浪费这么多的资源为线程分配新的页表等。
Linux中,一个线程在进程内部运行,是如何运行的呢?线程在进程的虚拟地址空间中运行,线程和进程共享同一个虚拟地址,页表等资源(体现了线程是进程内部的一个执行流)
。
如何体现线程的轻量化呢?让不同的线程访问虚拟地址空间中的一部分资源
。
那么,要如何才能做到,让不同的线程看到自己的代码资源呢?以代码区为例:
让不同的线程未来执行不同的入口函数即可(函数编译的方式,进行进程内部资源的划分)
。
在Linux中,线程的实现是用进程模拟的,复用了进程代码和结构
。
那么,今天我们要如何理解进程和线程呢?
以前我们说 进程 = PCB + 自己的代码和数据
。可是今天进程里有许多的PCB,这要如何理解呢?
以前我们讲的进程是内部只有一个执行流的进程,也叫做单线程的进程
,而今天,我们需要对进程重新定义。
进程 = OS分配的所有 task_struct + 自己的代码和数据 + 页表、文件等资源
。
所以,我们说进程是承担分配系统资源的基本实体
。
在CPU的角度,是不区分线程和进程的,它只拿着 task_struct 进行资源的调度,所以,执行流我们把它叫做轻量级进程。
线程(task_struct)自然而然就是CPU调度的基本单位了
。
验证:
ps -aL //查看所有的轻量级进程
Linux中不存在线程概念,只存在轻量级进程的概念,所以,Linux系统给用户提供系统调用,只能提供轻量级进程的系统调用
。
所有创建进程或者线程的系统调用底层都对 clone 进行了封装
。
但是这个系统调用使用起来非常麻烦,所以创建线程时需要使用pthread库,这个库对clone这个函数做了封装。
CPU在获取物理地址时其实并不是直接通过MMU查找页表得到物理地址的,而是通过TLB(快表,其实就是缓存),如果TLB有虚拟地址到物理地址的映射就给CPU,否则就去查找页表,在页表中找到之后,把物理地址给CPU,同时把这条虚拟地址和物理地址的映射给TLB,进行缓存
。
线程的优点:
1.创建一个新线程比创建一个新进程的代价小得多(进程需要创建PCB,虚拟地址空间,文件资源,页表等,线程只需要创建PCB,共享进程的其它资源)
。
2.与进程之间的切换相比,线程之间的切换需要OS做的工作要少很多
。
.
CPU内有CR3寄存器,保存的是页表的基地址,进程间切换需要更新CR3寄存器的内容,线程间切换不需要,因为,同一个进程里所有线程拥有的是同一个页表。
.
TLB就是缓存虚拟地址和物理地址的映射关系,线程间切换TLB不需要更新(线程共享进程的虚拟地址空间),进程切换TLB需要更新。
.
CPU内有一个 cache 硬件,这个硬件就是用来缓存代码和数据的,在CPU访问内存中的代码和数据时,并不是不断的进行虚拟地址到物理地址之间的映射访问的,而是通过 cache 硬件访问的,cache 硬件会预先加载一部分代码和数据,线程切换时,cache 是不需要更新的,进程切换需要重新加载新的代码和数据。
可以看到,cache 大小还是挺大的(这与系统有关,也有MB的)。
3.线程占用的资源比进程少很多(线程拥有进程的完整资源,但线程不需要重复再分配共享资源,如虚拟地址空间,页表等,线程只需要维护少量资源,比如局部变量,函数调用,寄存器状态)
。
线程的缺点:
1.性能损失(过多的线程使用同一个处理器,增加了线程调度,而可用资源不变)
。
2.健壮性降低(多个线程共享了不该共享的变量,缺乏保护)
。
3.缺乏访问控制(调用某些OS函数对整个进程造成影响)
。
线程独有的数据:
线程ID
寄存器(线程上下文数据)
栈
进程间多线程共享:
同一地址空间(代码段,数据段...)
文件描述符表
每种信号的处理方式
当前工作目录
用户id,组id
写一段程序验证一下。
可以看到,线程之间是共享全局变量,函数和堆空间的
。当然了,这只是一部分,毕竟线程是共享进程的虚拟地址空间的
。
二、线程控制
主线程运行3秒后结束,新线程10秒后才终止,但是主线程一旦退出,所有的线程都退出了,表示进程终止了。
这是因为,进程创建时OS需要分配PCB等资源,那么当进程退出时,所有的资源也应该都要进行回收,所以,所有的线程都退出了
。
一般情况下,主线程应该最后退出,线程也需要等待,类似进程的 wait。要对新线程进行等待,否则,也会造成类似僵尸进程的问题。
//成功返回0,失败返回错误码
//thread表明等待哪一个线程
//retval获取新线程退出时的退出信息
//阻塞等待,main thread最后退出,自动解决新线程的内存泄漏问题(僵尸问题)
int pthread_join(pthread_t thread, void** retval);
可以看到,在多线程等待时,一旦只要有一个线程崩溃,所有的线程都崩溃了,而进程之间具有独立性,即便是父子进程,子进程崩溃也不会影响父进程。所以说多线程的缺点是健壮性低
。
线程终止:
.
return
.
exit
exit是用来终止进程的,变相导致所有的线程退出
。
.
pthread_exit
void pthread_exit(void* retval);//线程退出
可以看到,使用系统调用退出线程,只会让调用该函数的线程退出,不会影响到其它线程
。
.
pthread_cancel
//成功返回0,失败返回非0的错误码
//thread取消目标线程的线程标识符
//线程退出,退出信息设置为-1(PTHREAD_CANCELED,是一个宏值)
int pthread_cancel(pthread_t thread); //取消线程
通常用于主线程取消其它线程。
那么,线程自己可不可以取消自己呢?
先认识一个系统调用。
//返回的是调用线程的id
pthread_t pthread_self(void);
可以看到,主线程创建新线程的 tid 与新线程获取自己的线程标识符是一样的
。
可以看到,线程自己取消自己也是可以的
。但是,这里为什么会将这条语句打印两次呢?
这是因为,pthread_cancel函数发送取消请求,对应的线程收到取消请求之后会在合适的点终止自己,不是立即终止
。
最佳实践取消线程的方法:在主线程中使用 pthread_cancel,本来就是主线程取消其它线程的
。
线程的传参和返回值问题:
前面我们介绍了 pthread_join 函数
,它的第二个参数就是将线程退出时的退出信息带出来,现在,我们就要聊聊这个参数了,它是怎么通过这个参数把线程的退出信息带出来的,毕竟我们只是使用了两个系统调用而已。
还记得C语言
中的 fopen函数
吗,它的返回类型是 FILE*类型的文件指针。那么这个FILE是什么呢?它有在哪里?
这个前面我们是说过的,FILE是一个结构体,它在C标准库里,fopen函数返回文件指针的时候,就必然创建了一个FILE对象
,那么这个对象在哪里呢?它应该就在fopen函数内部申请的,然后通过 return 返回
。
那么,在多线程这里,线程的概念是谁提供的?pthread库提供的
。
那么,将来我们可以在一个进程中创建很多线程,在多个进程中呢?就会有更多的线程,所以,线程需不需要被管理呢?那些线程在被调度,那些线程退出了?答案是需要的
。
那么,就应该对线程进行先描述在组织
。像进程一样有一个结构体 struct tcb,那么,这个结构体在哪里呢?不要忘了,前面说了,线程的概念是pthread库提供的,所以,这个结构体应该在 pthread库里面。这个结构体中就会有线程的各种属性
。
将来线程退出时,return 将数据写入到结构体中,主线程在等待时,将等待线程的标示符 tid传入进去,就可以找到指定的线程了(结构体),然后通过第二个参数将结构体中的退出信息拷贝出来,不就拿到指定线程的退出信息了吗
。
分离线程
默认情况下,新创建的线程是 joinable 的,线程退出后,需要对其进行 pthread_join操作,否则,会造成类似僵尸进程的问题(资源泄露)。
如果不关心线程的返回值,我们可以告诉系统,当线程退出时,自动释放线程资源
。这个时候就不需要进行 pthread_join
了。
//成功返回0,失败返回错误码
//thread分离线程的线程标示符
int pthread_detach(pthread_t thread);
分离线程可以自己分离自己,也可以是其它线程分离目标线程。
线程分离之后再去等待线程,就会出错。
这个时候可能有人要问了,主线程把新线程分离之后,如果是主线程先退出呢。那进程都终止了,新线程不是也会终止吗?这个问题不用担心,因为真正的软件都是死循环的,新线程执行完自己的代码就会退出,主线程是最后退出的
。
接下来再聊一下,pthread_cancel函数取消目标线程之后得到的退出信息
。
三、线程ID及虚拟地址空间布局
我们说过,线程是由 pthread 库提供的,所以线程是依赖于 pthread 库的,将来 pthread 库也要被加载到内存里
。那么,一个进程中可以有许多线程,也可以加载许多进程啊,这些进程中都会包含许多子线程,那么这些进程也是需要将 pthread 库映射到自己的虚拟地址空间中的,调用mmap 系统调用实现的
。
前面我们说过,线程有几部分资源是独占的,线程id、一组寄存器(线程的硬件上下文数据)、栈。描述线程的结构体是由 pthread库维护的,线程栈并不是在虚拟地址空间中的栈区上的,而是在共享区上,由 pthread 库在共享区上申请的一块固定的内存空间,主线程的栈是在虚拟地址空间上的
。
那么,什么是线程局部存储?
前面我们说过,全局变量也是多线程之间共享的
,那么如果我们要使线程之间独自私有呢?
像这样的就是线程局部存储。
今天的内容分享就到这里了,觉得不错的给个一键三连吧。