目录
一.线程的概念
(1)地址空间是进程的资源窗口
(2)轻量级进程
二.线程的理解
1.Linux中线程的实现方案
2. 线程VS进程
3.线程比进程更加轻量化
4.线程的优点
5.线程的缺点
6.线程共享的资源
7.线程私有的资源
三.地址空间虚拟到物理的转化
1.页框
2.重新理解文件缓冲区
3.页表
四.线程控制
1.pthread库
2.线程传参
3.线程终止
4.线程等待
5.线程分离
6.线程取消
五.pthread库
1.pthread库中的线程
2.系统调用clone
3.pthread库管理线程
4.语言层面的线程库
5.线程的局部存储
总结
一.线程的概念
操作系统教材对于线程的定义如下:
- 线程是比进程更加轻量化的一种执行流
- 线程是在进程内部执行的一种执行流
(1)地址空间是进程的资源窗口
进程运行时,代码,数据,堆区,栈区,动态库,操作系统内的系统调用代码和数据,都是通过地址空间+页表的形式来访问的,所以地址空间是进程的资源窗口
(2)轻量级进程
创建一个进程操作系统要做很多工作:创建进程PCB,地址空间,页表。将可执行程序从磁盘加载到物理内存,如果其中依赖了动态库还要加载动态库,在页表中构建虚拟地址到物理地址的映射关系,打开标准输入,标准输出,标准错误流,初始化文件描述符表,初始化信号相关pending,block,handler表,还要将进程的状态设置成R状态,链入CPU的运行队列。
创建一个进程的时间和空间成本挺高的。那么创建这样一种“进程”,只创建一个PCB,该PCB和之前存在的PCB指向同一个地址空间。利用技术手段,将代码分成若干份,数据该共享的共享,该分离的分离,本质就是将地址空间分配给若干个“进程”。很显然,这种“进程”比传统的进程要轻便很多。当CPU调度这种轻量级进程时,只会执行进程的一部分代码,访问进程的一部分数据,完成进程任务的一部分。这种轻量级进程就叫做线程。
- 线程只是参与资源分配,并不重新申请资源,所以线程创建更加简单,更加轻量化
- 线程在进程的地址空间中运行,所以线程在进程内运行
二.线程的理解
1.Linux中线程的实现方案
Linux没有为线程专门设计一种内核数据结构来描述它,因为线程和进程高度类似。所以Linux仍然以进程PCB来描述线程,并且复用进程管理的代码来管理线程。而Windows为进程和线程分别设计了内核数据结构,并且使用两种不同的管理方案。
一个进程内部可能有一个执行流,也可能有多个。Linux当中并不存在真正意义上的线程,它是用进程的PCB来模拟线程。所以严格来说,PCB是一个执行流的描述符,每个PCB代表一个执行流,将这种执行流称为轻量级进程。
当CPU拿到一个PCB时,不管他是一个进程当中唯一的执行流,还是进程众多执行流中的一个,不作区分,统一认为他是一个轻量级进程,有地址空间和页表。
2. 线程VS进程
进程是承担系统资源的基本实体,线程是CPU调度的基本单位。
进程就像一个家庭,以家庭为单位分配社会资源。线程就像家庭成员,各自执行自己的任务,最终完成进程的任务。家庭由家庭成员组成,只不过有的家庭只有一个人;进程由线程组成,只不过有的进程只有一个线程。
#include <iostream>
#include <thread>
#include <unistd.h>
using namespace std;void* ThreadRoutine(void* arg)
{const char* threadname = (const char*) arg;while (true){cout << "I am a new thread: " << threadname << "pid: " << getpid() << endl;sleep(1);}
}
int main()
{//已经有进程了pthread_t tid;pthread_create(&tid, nullptr, ThreadRoutine, (void*)"thread 1");while (true){cout << "I am main thread, pid: " << getpid() << endl;sleep(1);}return 0;
}
ps -aL : 查看轻量级进程(L即light)
LWP(light weight process)即轻量级进程编号,即线程编号,主线程编号等于进程编号。CPU调度时看的是LWP,判断一个线程是不是主线程,只需看它的LWP是否和PID相等。
3.线程比进程更加轻量化
- 线程创建的成本更低(前文讲过)
- 线程切换的成本更低。一方面,硬件上下文切换的较少,即要切换的寄存器更少。但更主要的是,CPU内集成了高速缓存cache,根据局部性原理,CPU会把接下来有较高概率访问,或者高频访问的代码和数据,缓存到cache里,保存在cache里的数据叫做热数据。线程间切换不需要切换cache,因为线程(一个进程内的)的资源窗口是一样的,共享绝大部分数据。虽然可能会因为切换线程导致热数据失效,但这并不影响,重新缓存就好了。但是进程间切换,这个cache肯定失效了,必须重新缓存。
4.线程的优点
- 创建线程,调度线程,释放进程,操作系统做的工作都比进程少
- 多个线程可以并行被多个CPU调度
- 计算密集型(加密解密,打包压缩),将计算任务分解到多个线程,在多CPU上运行
- I/O密集型(下载,上传),将I/O任务分解到多个线程,线程可以同时等待不同的I/O操作,等待时间重叠,减少总的等待时间。
注:3,4是对2点的补充说明
5.线程的缺点
- 线程间的独立性差,缺乏访问控制。由于线程共享同一个地址空间,理论上来说,地址空间中的所有数据都是共享的,即使是一个线程定义的局部变量(传递地址)。数据一旦共享,就会带来数据不同步问题。
- 健壮性降低。任何一个线程出问题,例如收到信号,整个进程都会崩溃。
- 编写代码和调试代码的难度提高。
6.线程共享的资源
文件描述符表,多个PCB指向同一个files_struct
信号处理方法,因为handler表是共享的,没有直接存储在PCB中
当前工作目录cwd,虽然cwd存储在PCB中,但是使用系统调用更改cwd,进程内多个PCB的cwd会同步更改
页表,地址空间及其中的代码和数据
7.线程私有的资源
线程id
寄存器里的数据(硬件上下文)
独立的栈结构
errno
调度优先级
pending表和block表
注:2,3点最重要
三.地址空间虚拟到物理的转化
1.页框
物理内存被划分为若干个4KB,这一个个4KB空间叫做页框(page),4KB叫做页大小(page size)。。文件系统IO的基本大小也是4KB,磁盘可看做由若干个4KB组成,这一个个4KB空间叫做文件块。
也即,操作系统将数据从磁盘上,以4KB为单位加载到内存,放到内存中的一个个页框中。
操作系统对内存的管理,转化为对所有页框的管理。先描述:内核中有描述页框的数据结构struct page,包含page的属性,以及状态(是否已经使用,是否正在向外设刷新,是否被修改过等)。再组织:结构体数组struct page pages[]将所有页框组织起来。
2.重新理解文件缓冲区
文件缓冲区并不是一块物理内存,而是一种数据结构,指向特定的页框的起始地址或者编号。文件缓冲区本质是一种数据结构指向另一种数据结构。
3.页表
页表的功能是记录虚拟地址到物理地址的映射关系,但页表并不是对每个字节(地址)都映射的,否则整个内存连一个页表都装不下。
以32位地址为例,32位被划分为三部分,10+10+12。进程页表也不止一张,页表被分为两级。
第一级叫做页目录,页目录最多有2^10=1024项。页目录就是一个数组。拿32位地址的前10位去页目录中去索引,这10位地址也就是数组的下标。所以映射关系中的虚拟地址那一项根本不用存,即采用直接定址法。
第二级叫做页表项,页目录中存储的是页表的物理地址。和页目录类似,32位地址的中间10位,就是页表项数组的下标。页表项中存储的是页框的起始地址。
所以通过页表可以找到物理内存中特定的页框,但我们要定位的是某个字节。实际上,物理地址=页框的起始地址+虚拟地址的后12位。也就是说,虚拟地址的最后的12位,是页内偏移量。
小结:页表内存储的是页框的起始地址,不是字节的地址。
用虚拟地址前10位去页表目录索引,找到特定页表项;再用虚拟地址的中间10位去页目录索引,找到页框的起始地址。目标物理地址=页框起始地址+页内偏移量
细节:
一级页表常驻,二级页表按需创建CPU内有寄存器保存进程的页目录地址
将进程的资源分配给线程,本质就是划分页表 ,划分页表也就是划分地址空间!!!
想要访问一块内存,要么通过变量,要么通过地址。变量有作用域限制,但地址没有,并且变量本质是地址+地址偏移量(由变量类型决定)。所以在进程和线程的视角,虚拟地址本身就是资源,谁拥有的虚拟地址越多,范围越大,谁就能访问更多资源。
四.线程控制
1.pthread库
Linux操作系统内核没有线程,只有轻量级进程的概念,所以Linux只会提供轻量级进程创建的系统调用,不会直接提供线程创建的接口。在内核和用户之间,有一层中间的软件层,对用户提供线程控制接口,封装内核中轻量级进程的接口。这个软件层就是pthread原生线程库,<pthread.h>就是库的头文件。
内核中的轻量级进程模块是线程的实现层,线程库是接口层。将内核与线程库分离,这种做法将满足用户需求的接口和实现方案解耦,内核更新和线程库更新,二者互不影响。
pthread库下文会详谈。
2.线程传参
arg的类型是void*,意味着可以传递任意类型的指针。通常在主线程定义一个对象,传参时对其取地址
3.线程终止
- 在线程执行的函数中return
- exit()会终止整个进程
- pthread_exit()终止调用该函数的线程
- pthread_cancel()终止指定的线程(下文详谈)
4.线程等待
- 线程退出,没有等待会导致类似进程的僵尸问题(PCB一直不释放)
- 主线程如何等待新线程退出,获取退出信息?-> pthread_join
retval是一个输出型参数,由用户传入,为了接收线程的返回值void*,所以得用二级指针。通常在主线程定义一个void* 的retval,传入&retval。
进程等待vs线程等待:
- 进程退出信息有退出码和退出信号,但是线程退出只有退出码(void*),没有退出信号。因为线程一旦收到退出信号,整个进程都会终止,主线程连获取退出信息的机会都没有。
- 进程可以选择阻塞式等待和WNOHANG,但是线程只能阻塞式等待
5.线程分离
一个线程被创建时,默认是可连接状态(joinable),这意味着当线程终止后,其资源不会被立即回收,直到其他线程调用pthread_join()来获取该线程的退出信息,并释放相关资源。
如果主线程不关心新线程任务执行的情况,不需要获取线程退出信息,并且不想阻塞等待线程退出,那么就可以将线程设置为分离状态,当线程退出时,资源就会被系统自动释放。
thread是要设置成分离状态的线程的tid,该函数既可以新线程自己调,也可以主线程(也可以是其它线程)调,因为它是设置线程的一种状态。
处于分离状态的线程不可被pthread_join,会pthread_join一定会失败,返回22。
//在主线程中调用detach:
pthread_create(&tid, nullptr, threadRoutine, nullptr);
pthread_detach(tid);//在新线程中调用detach:
pthread_detach(pthread_self());
6.线程取消
线程不仅可以主动return和pthread_exit(),还可以被主进程强制终止
thread是要被终止的进程的tid
- 如果线程正常return或者pthread_exit,pthread_join得到的retval就是线程函数的返回值
- 如果线程被异常cancel,pthread_join得到的retval是PTHREAD_CANCELED宏,也即-1
- 线程被分离了依然可以detach,只不过不能join
五.pthread库
1.pthread库中的线程
以上使用的线程控制的接口,都不是系统调用,而是原生线程库pthread提供的接口。
再次重申,Linux内核中并没有线程,只有轻量级进程,描述轻量级进程是task_struct(PCB),系统只会提供操作轻量级进程的接口。
我们使用的线程都是用户级线程,所谓“用户”,是因为这些线程都维护在pthread库中,pthread库在地址空间中的共享区,在用户区而非内核区!!!
thread库如何管理线程?先描述,再组织!
pthread库中有描述线程的数据结构,也就是操作系统学科中的TCB。TCB中的属性,一部分来自用户,另一部分来自内核中的轻量级进程。内核中的PCB和库中的TCB是一一对应的关系。
2.系统调用clone
前文说过,一个线程至少要有两种私有的资源:上下文数据和栈
上下文数据被操作系统维护在轻量级进程的PCB中,当线程被调度时,就会将其加载到寄存器。当从CPU上剥离下来时,又会把数据备份到PCB中,这是理所当然的。
但如何理解线程有独立的栈?
地址空间中只有一个栈区,这个栈给谁用呢?
这是一个系统调用,功能就是创建一个轻量级进程,fork的底层就是它,pthread库中的pthread_create就封装了这个接口。
fn是被创建出来的轻量级进程执行的方法;flags是标志位,选择创建一个“真进程”还是一个轻量级进程,即要不要申请地址空间这些资源;stack就是你这个轻量级进程要使用的栈的起始地址。
pthread库在使用这个接口之前,会先malloc一段堆空间,将地址作为参数传给clone,把这块堆空间充当线程的栈。
所以,新线程的栈在库中维护,真正的空间在堆区,由用户提供(因为动态库在用户空间)。地址空间中的栈区由主线程使用,由操作系统提供。类似地,C语言提供了语言层面的缓冲区,这个缓冲区是stdio库维护的。
3.pthread库管理线程
pthread库管理进程的前提是,pthread库映射到进程的地址空间中,进程能访问到库中的代码。线程库是共享的,所以pthread库要管理用户启动的所有线程。
pthread库以数组的形式组织线程,每个单元包含线程TCB,线程局部存储和线程栈。线程tid就是pthread库中描述线程的控制块的地址,使用tid就能直接在地址空间中找到线程
TCB中包含了线程的各种属性,其中一定封装了对应轻量级进程的LWP,未来线程退出的返回值也保存在其中。所以主线程只需访问地址空间中的共享区就能获取返回值,而不用像父进程回收子进程资源那样,必须借助操作系统。
线程栈具体来说是一个指针,指向堆区的某块空间。
线程局部存储与线程独立的资源有关,下文会讲。
4.语言层面的线程库
C++11新增了thread库,实际上这些语言层面的线程库都是封装的原生线程库pthread,编译时必须引入pthread库,否则会出现undefined reference错误。语言层面的线程库是很有意义的,它根据不同平台的提供的接口,对线程进行封装,这样就使得在同一份代码在不同的系统下都可以运行,这提高了代码的可移植性。
5.线程的局部存储
全局变量本身就是被进程内的所有线程共享的,但是在全局变量前,加上__thread这个编译选项,就可以给每个线程私有一份该变量。当pthread库被链接时,编译器就会在线程控制块中开辟出一块空间,将数据拷贝进来。开辟的空间就是局部存储空间。未来线程访问全局变量,实际上访问的是局部存储空间的数据。
注:__thread只能存储内置类型数据,不能存储STL容器。
总结
- 结合Linux上的轻量级进程,理解:线程是比进程更加轻量化的一种执行流,线程是在进程内部执行的一种执行流这两句话
- 对比进程和线程:进程是资源分配的实体,线程是调度的基本单位
- 线程比进程更加轻量化:从创建成本和调度成本两个方面来谈
- 线程共享和独立的资源(重点理解栈)
- 理解页表转化虚拟地址的过程
- pthread库中接口的使用
- pthread库的理解