1.什么是线程
首先线程是一个执行流,它的执行力度比进程更细,是进程内部的一个执行分支,而进程是承担分配系统资源的基本实体,线程是OS调度的基本单位,
线程一定要用自己的独立上下文和栈结构!!!
线程允许一个程序内部同时做多件事而不互相卡住 ,它让程序能在执行耗时操作(如网络请求、文件读写、计算)时依然保持界面响应,避免整个软件卡死;通过把任务拆分成多个轻量级线程,可以充分利用多核 CPU 提升运行效率,同时线程间共享同一进程的内存空间,切换成本远低于进程,适合高并发、高响应速度的场景;无论是桌面软件、服务器、移动端 App 还是游戏,线程都是实现并发处理、后台任务、异步操作、提升性能的基础机制,没有线程,现代软件几乎无法做到流畅、高效、同时处理多个任务。
而Linux支持线程呢必然也要对线程有对应的管理操作,所以线程也必须要有struct_thread_block->tcb;呢我们这个tcb结构体中也一定要有id值吗,区分唯一性吗,要有优先级吗、要有状态吗,也要有自己的代码数据吗,还要有页表进行映射吗,更重要的是线程是进程内部的执行分支,呢么就要在一个进程的内部维护一个子链表,把这些线程维护起来吗,这样也太复杂了吧,所以Linux的设计者也想到了,所以它直接让task_struct就是我们的线程,之后的task_struct中的属性都是对第一个task_struct的拷贝,并且每个task_struct是共享地址空间的,但是对于代码是将代码区的代码分成若干个,每一个线程执行一个;所以这样的每一个执行分支就叫做线程;所以Linux下的线程是对进程模拟实现的;
得出这个结论,我们就知道了对线程的数据结构,等待队列也就是再组织我们都不需要重新设计了,对线程的调度也不用再重新设计了,对线程的切换也不用重新设计了,所以我们现在直接复用了历史代码,这在软件工程上就是增加代码可维护性的一种思路;

也就是说我们今天创建的线程其实就只创建了一个PCB结构体,哈哈哈,然后跟进程共享地址空间,再给线程分一部分代码,这样线程不就实现了比之前进程更细的一个粒度了吗?
如何理解线程是在进程内部执行这句话呢?也就是线程再进程的地址空间上运行,

2.更新对进程的理解
进程!=PCB,有一个PCB的时候PCB表示的进程,有多个PCB的时候PCB就是线程!

3.站在CPU角度理解线程
在cpu角度看来进程可能是只有一个PCB的,也可能是有多个PCB的,所以今天在我们看来线程/Linux执行流,统一称为:轻量级进程(LWP);
Linux中没有真正意义上的线程,Linux的线程概念是用LWP进行模拟实现的。所以在Linux中创建一个线程其实是Linux为我们创建了一个轻量级进程(LWP).
4.使用线程接口
4.1创建线程的接口

我们验证一下线程本质上是不是进程,只用创建一个新的线程,看两个线程是不是都能执行各自的循环体(循环体只会在一个进程中跑)
cpp
#include <iostream>
#include <unistd.h>
#include <pthread.h>
//新线程
void * run(void * argc)
{
while(true)
{
std::cout<<"new thread"<<std::endl;
sleep(3);
}
}
int main()
{
//1.创建一个线程
pthread_t tid;
pthread_create(&tid,nullptr,run,(void*)"thread-1");//第三个参数可以理解为回调函数
//"thread-1"是我们给线程起的名字
//主线程会继续向下执行,而pthread-1线程会去执行run函数,也叫新线程
//主线程
while(true)
{
std::cout<<"main thread " <<std::endl;
sleep(3);
}
return 0;
}

值得注意的是我们使用的pthred_create其实是我们的glibc封装的库,需要我们在编译的时候加上-lphread选项,但是现在可以编译成功了,主要是下面的原因;


我们让两个线程都打印对应的pid之后,发现两个线程的pid相同,这更加证明了线程是在进程内部执行的这个结论

通过使用轻量级监控对我们的轻量级进程进行监视,我们发现他们的pid相同,但是LWP不相同,
而第一行的轻量级进程的PID和LWP相同,但是第二行的PID和LWP不相同,这就说明第一行的轻量级进程是我们的主线程,而第二行的是我们的新线程;所以LWP是区别执行流的依据;所以我们的PCB中既有LWP还有PID;
我们可以把一个进程想象成一个大家庭,我们的爷爷奶奶,爸爸妈妈,还有我们都是线程,我们的共同目标就是为了让家庭变得更好,所以线程的共同目标是为了实现当前进程的目标;
5.从进程地址空间理解线程
5.1虚拟地址和页表的由来
①物理内存管理
磁盘和物理内存之间是以4KB为单位进行IO的,这样的一个4KB的内存叫做一个数据块,如果一个数据的大小是15KB,呢么它就会占4个数据块,这样的内存管理的模式简单,并且就算造成内存碎片,也只是块内碎片,有效的解决了内存碎片的问题,所以我们的OS进行内存管理并不是直接给你一大块内存,而是将内存分成一个个小的4KB的数据块,我们把这样的数据块叫做页框。

OS如何管理这样的一个个的物理内存呢?当然了,方法还是"先描述,后组织",所以为了描述内存块,定义了对应的struct page,而每一个page都在数组中,管理的时候使用数组,这样每一个page都有了下标,每一个物理内存不都有地址吗?地址在哪里呢?下标和物理地址会快速相互转换;假设⼀个可⽤的物理内存有 4GB 的空间。按照⼀个页框的大小 4KB 进行划分, 4GB 的空间就是4GB/4KB = 1048576 个页框。有这么多的物理页,操作系统肯定是要将其管理起来的,操作系统需要知道哪些页正在被使用,哪些页空闲等等。内核用 struct page 结构表示系统中的每个物理页,出于节省内存的考虑, struct page 中使用了大量的联合体union。

物理地址=下标*4KB;
下标=物理内存/4KB

所以其实只要找到了page就找到了对应物理地址的位置,但是page不是4KB,page是一个结构体,要注意的是 struct page 与物理页相关,⽽并⾮与虚拟页相关。⽽系统中的每个物理页都要分配⼀个这样的结构体,让我们来算算对所有这些页都这么做,到底要消耗掉多少内存。
算 struct page 占40个字节的内存吧,假定系统的物理页为 4KB 大小,系统有 4GB 物理内存。
那么系统中共有页⾯ 1048576 个(1兆个),所以描述这么多页⾯的page结构体消耗的内存只不过40MB ,相对系统 4GB 内存而言,仅是很⼩的⼀部分罢了。因此,要管理系统中这么多物理页面,这个代价并不算太大。要知道的是,页的大小对于内存利用和系统开销来说非常重要,页太大,页内必然会剩余较大不能利用的空间(页内碎片)。页太小,虽然可以减小页内碎片的大小,但是页太多,会使得页表太长而占用内存,同时系统频繁地进行页转化,加重系统开销。因此,页的大小应该适中,通常为 512B -8KB ,windows/Linux系统的页框大小为4KB。

②页表
页表中的每⼀个表项,指向⼀个物理页的开始地址。在 32 位系统中,虚拟内存的最大空间是 4GB ,这是每⼀个用户程序都拥有的虚拟内存空间。既然需要让 4GB 的虚拟内存全部可用,那么页表中就需要能够表示这所有的 4GB 空间,那么就⼀共需要 4GB/4KB = 1048576 个表项。如下图所表示

虚拟内存看上去被虚线"分割"成⼀个个单元,其实并不是真的分割,虚拟内存仍然是连续的。这个
虚线的单元仅仅表示它与页表中每⼀个表项的映射关系,并最终映射到相同大小的⼀个物理内存页
上。页表中的物理地址,与物理内存之间,是随机的映射关系,哪里可用就指向哪里(物理页)。虽然最终使用的物理内存是离散的,但是与虚拟内存对应的线性地址是连续的。处理器在访问数据、获取指令时,使用的都是线性地址,只要它是连续的就可以了,最终都能够通过页表找到实际的物理地址。假设,在 32 位系统中,地址的长度是 4 个字节,那么页表中的每⼀个表项就是占⽤ 4 个字节。所以页表占据的总空间大小就是: 1048576*4 = 4MB 的大小。也就是说映射表自己本身,就要占用4MB / 4KB = 1024 个物理页。这会存在哪些问题呢?
回想⼀下,当初为什么使用页表,就是要将进程划分为⼀个个页可以不用连续的存放在物理内存
中,但是此时页表就需要1024个连续的页框,似乎和当时的目标有点背道而驰了...... 此外,根据局部性原理可知,很多时候进程在⼀段时间内只需要访问某几个页就可以正常运行了。因此也没有必要⼀次让所有的物理页都常驻内存。解决需要大容量页表的最好方法是:把页表看成普通的文件,对它进行离散分配,即对页表再分页,由此形成多级页表的思想。为了解决这个问题,可以把这个单⼀页表拆分成 1024 个体积更小的映射表。如下图所示。这样⼀来,1024(每个表中的表项个数) * 1024(表的个数),仍然可以覆盖 4GB 的物理内存空间。
这⾥的每⼀个表,就是真正的页表,所以⼀共有 1024 个页表。⼀个页表自身占用 4KB ,那么
1024 个页表⼀共就占用了 4MB 的物理内存空间,和之前没差别啊?
从总数上看是这样,但是⼀个应⽤程序是不可能完全使用全部的 4GB 空间的,也许只要几十个页表就可以了。例如:⼀个用户程序的代码段、数据段、栈段,⼀共就需要 10 MB 的空间,那么使用 3 个页表就足够了;计算过程:每⼀个页表项指向⼀个 4KB 的物理页,那么⼀个页表中 1024 个页表项,⼀共能覆盖 4MB 的物理内存;那么 10MB 的程序,向上对齐取整之后(4MB 的倍数,就是 12 MB),就需要 3 个页表就可以了。
③ 页目录
目前为止,每⼀个页框都被⼀个页表中的⼀个表项来指向了,那么这 1024 个页表也需要被管理起
来。管理页表的表称之为页目录表,形成⼆级页表。如下图所示:

所有页表的物理地址被页目录表项指向;页目录的物理地址被 CR3 寄存器 指向,这个寄存器中,保存了当前正在执行任务的页目录地址。所以操作系统在加载用户程序的时候,不仅仅需要为程序内容来分配物理内存,还需要为用来保存程序的页目录和页表分配物理内存;
④两级页表的目录转换
下面以⼀个逻辑地址为例。将逻辑地址( 0000000000,0000000001,11111111111 )转换为物
理地址的过程:
a. 在32位处理器中,采用4KB的页大小,则虚拟地址中低12位为页偏移,剩下高20位给页表,分成两级,每个级别占10个bit(10+10)。
b. CR3 寄存器 读取页目录起始地址,再根据⼀级页号查页目录表,找到下⼀级页表在物理内存中
存放位置。
c. 根据⼆级页号查表,找到最终想要访问的内存块号。
d. 结合页内偏移量得到物理地址。

⑤ TLB
注:一个物理页的地址一定是 4KB 对齐的(最后的 12 位全部为 0),所以其实只需要记录物理页地址的高 20 位 即可。以上其实就是 MMU 的工作流程。MMU (Memory Manage Unit) 是一种硬件电路,其速度很快,主要工作是进行内存管理,虚拟地址到物理地址的转换是它的核心功能之一。
到这里其实还有个问题:MMU 需要先进行两次页表查询 才能确定最终物理地址,在完成权限检查等校验后,MMU 再将这个物理地址发送到总线,内存收到之后开始读取对应地址的数据并返回。当页表变为 N 级时,地址转换就需要 N 次内存访问查询页表 + 1 次内存访问读写数据。可见,页表级数越多,查询的内存访问次数越多,CPU 等待时间越长,效率越低。
让我们现在总结一下:单级页表对连续内存要求高,于是引入了多级页表。但是多级页表是一把双刃剑:它在降低连续内存需求、减少页表本身存储空间的同时,增加了地址转换的内存访问次数,降低了查询效率。
有没有提升效率的办法呢?计算机科学中的所有问题,都可以通过添加一个中间层来解决。MMU 引入了新硬件 ------快表 TLB(Translation Lookaside Buffer,转译后备缓冲器) ,本质是专门缓存虚拟页号→物理页号映射的高速硬件缓存。
当 CPU 给 MMU 发送虚拟地址之后,MMU 先查询 TLB:如果命中(TLB Hit),就直接拿到物理页号,拼接页内偏移得到物理地址并发送到总线访问内存,流程极快。但 TLB 容量比较小,难免发生TLB 未命中(TLB Miss) 。这时候 MMU 会按照多级页表流程完成地址转换,除了把物理地址发到总线访问内存,还会将这条虚拟页→物理页的映射关系写入 TLB 进行缓存,方便后续快速访问。

⑥缺页中断
设想,CPU 给 MMU 的虚拟地址,在 TLB 和页表都没有找到对应的物理页,该怎么办呢,其实这就是缺页异常 Page Fault,它是一个由硬件触发、由软件逻辑处理的异常。
假如目标内存页在物理内存中不存在,或者存在但访问权限不合法,CPU 就无法正常完成地址转换并获取数据,这种情况下 CPU 会触发缺页异常。当前进程会从用户态切换到内核态,将异常交给内核的缺页异常处理程序 Page Fault Handler 进行处理,根据缺页类型不同,处理方式也不一样。
A. Hard Page Fault 也叫 Major Page Fault,即硬缺页错误或主缺页错误,此时物理内存中没有对应的物理页,需要操作系统从磁盘读取数据加载到物理内存,再由 MMU 建立虚拟地址和物理地址的映射关系。
B. Soft Page Fault 也叫 Minor Page Fault,即软缺页错误或次缺页错误,此时物理内存中已经存在对应的物理页,可能是其他进程已经加载,只是当前进程没有建立映射,这种情况只需要更新页表建立映射即可,不需要从磁盘读取数据,常见于多进程共享内存等场景。
C . Invalid Page Fault 即无效缺页错误,比如进程访问越界地址、对空指针解引用等非法操作,内核会判定为非法访问,抛出段错误 Segmentation Fault 并直接终止进程。

⑦思考
A.new和malloc的本质是什么?
我们可以结合之前学习的虚拟内存和缺页异常,来理解 new 和 malloc,这两个函数本质上并不是直接向物理内存申请空间,而是向操作系统申请虚拟地址空间,在调用时只会分配虚拟内存,并不会立即分配物理内存,也不会真正读写物理内存,只有当进程真正访问这段地址时,才会触发缺页中断,再由操作系统分配物理页。
B.如何理解写时拷贝
写时复制是一种优化机制,当父进程创建子进程时,父子进程会共享所有物理内存页,并且将这些页都标记为只读,此时并不会复制物理内存,只有当其中一方尝试写入数据时,才会触发异常,操作系统才会真正复制一份物理页,让写入的进程使用,以此减少内存拷贝和资源消耗。
C.申请内存的本质是什么?
申请内存的本质,就是在进程的虚拟地址空间中划分一段合法的区域,建立虚拟地址的管理信息,而不是立刻占用物理内存。申请虚拟地址空间+填充修改页表
D.如何区分
区分缺页和越界也很简单,操作系统会先检查虚拟地址是否在当前进程合法的虚拟地址空间范围内,地址合法但物理页不存在,就是缺页异常,地址不在合法范围内,就是越界访问。同时越界访问不一定会立刻报错,如果越界的地址刚好落在进程其他合法的虚拟内存区域内,程序可能正常运行,不会触发异常,只有越界到了完全非法的地址空间,才会触发段错误导致进程崩溃。线程资源的划分也基于虚拟地址空间,同一个进程内的所有线程共享同一个虚拟地址空间,只需要对虚拟地址空间进行划分,进程的资源就可以完成天然的分配与管理。
⑧CPU如何调度进程
CPU的某个寄存器中存储了当前进程的PCB的起始地址,CR3寄存器存储了当前进程存储数据的虚拟地址;EIP(又叫PC指针)是我们当前进程的入口虚拟地址,所以进程切换其实就是切换这三个寄存器中的值,这样进程的地址空间、页表、进程都就换了,所以CPU中的寄存器属于进程上下文,物理内存和CPU之间是有地址总线的;
MMU已经被集成在了CPU中,它的工作是虚拟地址到物理地址的转换,因为它知道页表和虚拟地址;之所以用硬件做是因为比较快;

CPU 寄存器中保存着当前进程的 CR3(页表基地址)和 EIP(入口虚拟地址),MMU 硬件单元基于 CR3 指向的页表,将 EIP 提供的虚拟地址转换为物理地址,再通过系统总线访问内存读取对应指令 / 数据,将其存入 IR(指令寄存器)暂存;同时 CPU 会通过 current 指针关联当前进程的 task_struct 与 mm_struct,以此管理进程的虚拟地址空间与内存映射,而物理地址的生成、指令 / 数据的暂存与传递,都依赖寄存器完成状态与数据的临时承载,最终实现进程指令的有序执行与内存隔离。
6.线程的优点
创建一个新线程的代价远小于创建新进程,线程间切换时操作系统需要完成的工作也更少,二者最核心的区别在于
①线程切换时虚拟内存空间保持不变,而进程切换需要切换虚拟地址空间,这两种上下文切换均由操作系统内核完成,过程中会产生寄存器内容保存与恢复的性能开销,同时上下文切换会破坏处理器的缓存机制;
②进程切换还会导致页表缓冲 TLB 被全部清空,大幅降低内存访问效率,而线程切换不会出现这类问题;
③此外线程占用的系统资源比进程更少,还能充分利用多处理器的并行能力,在程序等待慢速 I/O 操作时可以执行其他计算任务;
④计算密集型应用可借助多线程将任务拆分适配多处理器运行提升效率,I/O 密集型应用也能通过多线程让 I/O 操作重叠执行,同时等待不同的 I/O 操作以优化整体性能。
6.1线程为什么比进程块
首先,进程切换会销毁TLB中缓存的虚拟地址和物理地址的高频映射关系,而线程中的这样的关系可能是共享的,所以不需要销毁,并且进程切换一定会切换CR3中页表的地址等信息;
其次,在CPU中有一个cache的硬件缓存了当前进程的代码和数据;Cache 是 CPU 核心内部或靠近 CPU 的高速静态存储器(SRAM),比寄存器慢,但比内存(DRAM)快得多。它分为 L1、L2、L3 等层级。缓存的是什么:最近被频繁访问的内存数据和指令。具体内容:数据缓存(Data Cache):缓存程序读取的实际数据(如数组内容、变量值)。指令缓存(Instruction Cache):缓存即将执行的机器指令。页表缓存(TLB):这是你之前关注的特殊缓存,专门缓存虚拟地址→物理地址的映射关系(页表项)。核心作用:缓解 "CPU 速度太快,内存太慢" 的性能瓶颈,避免 CPU 每次都要去内存读数据。所以将进程切换的时候这个cache中的缓存的代码和数据会直接失效,而进程是共享代码和数据的所以还是会访问cache中的代码;像cache会缓存下面的代码,比如我正在访问前五行,但是cache缓存了后面的五行代码,这是有一定的概率的,是局部性原理;
7.线程的缺点
①多线程会带来性能损失,计算密集型线程过多会增加线程同步与系统调度开销,反而降低运行效率。
②多线程会降低程序健壮性,线程间缺乏隔离保护,微小的执行时序差异或变量共享不当都容易引发程序问题。
⑤多线程会大幅提高编程与调试难度,且线程本身不是访问控制的基本单位,单个线程调用系统函数可能影响整个进程。
8.线程异常
单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃;线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出;
9.线程切换成本为什么低
线程切换成本远低于进程,核心原因就是进程拥有独立的地址空间(页表、内存映射、虚拟内存空间) ,而同一进程内的所有线程共享同一份地址空间 ;进程切换时,CPU 必须更换页表、刷新 TLB(CPU上的寄存器,缓存虚拟地址到物理地址转换的信息)、重建整个内存映射环境,这些硬件级操作非常耗时,而线程切换只需要切换程序计数器、寄存器、栈指针这些少量上下文,不需要修改页表和地址空间,也不用刷新 TLB,所以速度极快、开销极小,本质区别就在于**是否需要切换独立的地址空间;**MMU 是负责地址翻译的整个硬件单元,TLB 是它里面的高速缓存。