文章目录
一、线程概念
什么是线程
进程:一个运行起来的执行流,一个加载到内存中的程序 (教材)进程 = 内核数据结构 + 自己的代码和数据 线程:进程内部的一个执行流,轻量化
观点:进程是系统分配资源的基本单位 (内核角度,给进程下的定义), 线程是 CPU 调度的基本单位
第四次谈地址空间
物理内存结构------page
1、页表的每一个虚拟到物理的映射条目叫做页表项。
2、访问内存的基本单位是字节,但是页表并不是以字节为单位映射的,因为地址空间一共有4GB空间 = 4 * 1024 * 1024 * 1024字节,假设一个页表项占8个字节(32位下虚拟地址4字节,物理地址4字节),按字节映射的话每一个字节的虚拟地址都需要一个8字节的页表项来存储映射关系,那么页表一共就会占 8 * 4 * 1024 * 1024 * 1024字节 = 32GB空间,远超4GB的物理内存空间大小,很明显不合理。
3、我们在讲文件系统的时候讲过,磁盘存储是以4kb为单位,实际上物理内存也从逻辑层面上被划分成了一个个4kb大小的小内存,内存与磁盘之间的IO就叫做文件系统IO,且IO的基本单位大小是4kb。
4、物理内存中的每一个4kb空间被叫做页框或者页帧。
5、当每出现一个概念时,OS都需要对其管理,页框也是同理,在内核中有一个struct page结构体用来描述页框,结构体当作有一个位图标志位字段用于描述页框的状态,还会有一个整型变量表示当前内存页的引用计数等等,struct page结构体比较小,因为4GB内存会存在4GB / 4KB = 1048576个struct page结构体,所以单个struct page不能占据太多空间。
6、将页框进行描述后还需要对其进行管理,OS是用一个结构体数组: struct page pages[1048576]对其进行管理的,那么未来OS对物理内存的管理就转化成为了对pages数组的增删查改。
7、有了上面的知识,我们就认识到了一个颠覆认知的知识:所有物理地址,都可以通过数组下标转化而来:数组下标0对应物理内存第一个4kb内存块,数组下标1对应物理内存第二个4kb内存块...这样一来物理地址这个概念就被弱化了,所以在内核中我们经常会看到虚拟内存地址,但很少看到物理内存地址,因为物理地址是拿着数组起始地址和page元素下标进行计算、转化得来的。
8、进程申请物理内存本质就是在pages数组中申请一个page结构体,然后OS拿到数组的下标,并结合pages数组起始地址就能找到该物理内存块的地址了,转化关系是:页框起始地址 = pages数组下标 * 4KB。
虚拟/物理内存转化
32位系统下PC指针(程序计数器)的具体实现是EIP寄存器,32位系统下页表不是一个整体,而是采用二级页表结构,下面是利用页表进行虚拟到物理内存转化的示意图(一个页表对应一个4KB页框空间):

当我们拿着虚拟地址要找物理地址时,首先要查页表,找到要访问的物理地址在哪一个页框,然后加上页内偏移量就能找到具体物理地址了,下面是详细过程(整个查询过程由硬件MMU完成,比用软件效率更高):
首先用虚拟地址的高10位查页目录表,2^10正好对应页目录表中1024个页表起始地址。利用页目录表索引到具体哪个页表后,然后用虚拟地址的次高10位查该页表,查找页表后就得到了待找页框的起始物理地址。最后用虚拟地址的低12位正好就能索引整个页框4kb空间的所有字节,这里我们称为页内偏移,由此就能访问页内的任意一个字节了。
这样映射的话一个进程的页表所占空间大小:页目录表(4kb)+ 1024个页表(1024 * 4kb)约等于4MB,并且一个进程并不会把整个物理内存映射完,实际大小只会小于4MB,远小于按字节映射的32GB大小。
下面是一些细节补充:1、CR3保存当前进程页表的基地址,该地址是物理地址。
2、虚拟地址32个比特位被划分为10/10/12三部分编译器不参与。
3、虚拟地址高20位相同的地址,映射后一定存放在同一个页框中。
4、除了能把虚拟地址转化为物理地址,也能把物理地址转化为虚拟地址。
5、但进程首次加载磁盘块时,OS会做以下事情:先内存管理申请内存(在pages数组中申请page结构体)-> 拿到数组下标 -> 计算出页框的起始物理地址 -> 把页框的起始物理地址填充到页表中 -> ELF拿MMU进行虚实转换
6、除了访问单字节变量外,我们还可能访问int 数组 结构体等等变量,这些变量大小不止一个字节,但是所有变量都只有一个地址------开辟空间的最小字节的地址,所以页表在进行转化的时候,虽然只能拿到一个字节的地址,实际是参考变量具体类型,利用起始地址+偏移量的方式进行转换。
7、有了上面的认识,我们就能认识到OS申请和管理物理内存都是以4KB为单位的,所以写时拷贝和缺页中断并不是只拷贝访问的一个变量、缺页中断也不是只换入一个变量,它们俩都是以4KB为单位进行操作的。
8、既然OS申请和管理物理内存都是以4KB为单位的,为什么我们可以用new、malloc申请1、4、n字节空间呢?这是因为c/c++在语言层面有自己的内存管理机制,语言已经为你提前向系统申请好了一些空间,并用链表组织管理起来,你需要多少就给你多少,类似于STL的空间配置器。
9、单级⻚表对连续内存要求⾼,于是引⼊了多级⻚表,但是多级⻚表也是⼀把双刃剑,在减少连续存储要求且减少存储空间的同时降低了查询效率。
有没有提升效率的办法呢?计算机科学中的所有问题,都可以通过添加⼀个中间层来解决。 MMU 引⼊了新武器,江湖⼈称快表的 TLB (其实,就是缓存)当 CPU 给 MMU 传新虚拟地址之后, MMU 先去问 TLB 那边有没有,如果有就直接拿到物理地址发到总线给内存,⻬活。但 TLB 容量⽐较⼩,难免发⽣ Cache Miss ,这时候 MMU 还有保底的⽼武器⻚表,在⻚表中找到之后 MMU 除了把地址发到总线传给内存,还把这条映射关系给到TLB,让它记录⼀下刷新缓存。

linux中多线程的实现
我们先介绍linux中多线程的实现,后面再推而广之,介绍线程在OS学科中的实现方式。
我们已经知道了线程是进程内部的一个执行流,对于linux程序员来说,新出现了一个概念最方便的实现方式就是复用已有的代码,类似管道的实现,所以linux中线程是用进程模拟的,复用了进程的代码和内核结构。
我们知道一个进程要有task_struct、mm_struct、页表和物理内存中进程的代码和数据,当进程创建一个线程后本质就是创建了一个task_struct,指向当前进程的mm_struct,也就是进程和线程共用一个mm_struct、页表,不同的线程各自拥有该进程的代码和数据资源,各自执行当前进程代码的一部分。
内核管理线程也要遵循先描述再组织,操作系统学科中描述线程的标准结构体是 TCB(Thread Control Block),也被相应的内核容器化数据结构管理起来。(这里我们会隐约感知到linux中是没有TCB的,但是windows是真正实现了TCB的,学到后面我相信大家就会通透了)

所以线程本质就会在进程虚拟空间中运行,未来要让"线程"各自访问进程虚拟空间中不同的资源只用让"线程"执行不同的入口函数即可(如示例中主线程执行main函数,新线程执行thread_routine),编译器会为我们自动完成资源划分:分配虚拟地址、页表映射。(因为编译器会编址不同的函数,函数本质就是许多汇编指令,每条汇编指令都有地址,函数地址也叫做入口地址就是第一条指令的地址)
下面我们回头再理解一下以前我们学习的进程,以前我们说进程是真正意义上的进程的一种特殊情况,进程内部是可以有一个或者多个执行流(线程)的,而以前我们学习的进程是只有一个执行流的,所以我们会说一个进程一个task_struct,实际上一个进程可以有多个task_struct。
现在我们切换视角,站在CPU的角度看待进程和线程,在CPU角度是不区分进程和线程的,在CPU眼中只有task_struct(执行流),所以在linux中把进程和线程统一称为轻量级进程,也就是在linux中没有线程的概念!!
我们看下面的示意图,黑框中的整体是linux中的进程。(当然还包括内存中进程的代码和数据)

现在我们就能理解在操作系统学科中对进程和线程下的定义了: 进程是承担分配系统资源的基本实体。
线程是CPU调度的基本单位,线程在进程内部运行。
线程操作
有了上面的认识,我们上手写份代码感受一下线程:
先看一个创建线程的库函数,我们先照葫芦画瓢试用一下,在线程控制章节再细讲。

cpp
#include <iostream>
#include <string>
#include <unistd.h>
#include <pthread.h>
void *thread_routine(void *arg)
{
std::string name = (const char *)arg;
while(true)
{
std::cout << "我是新线程..., 名字是:" << name << std::endl;
sleep(1);
}
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, thread_routine, (void *)"thread-1");
while(true)
{
std::cout << "我是主线程..." << std::endl;
sleep(1);
}
return 0;
}
编译一下:

下面我们运行与一下程序:

在linux中,用LWP(Light Weight Process)标识不同的轻量级进程,ps -aL 是查看进程LWP的指令,我们可以看到,一个进程中的所有线程的pid的相同的,进程中不同的线程有不同的LWP,其中主线程和进程pid相同,所以实际上OS调度只看LWP,但是原来不是说CPU是看pid的吗?其实这两者并不冲突,因为以前我们的进程只有一个执行流,所以LWP本身就等于pid,当一个进程有多个执行流时,进程pid和主执行流的LWP相等。
linux中没有创建线程的系统调用,只有创建轻量级进程的系统调用:clone,这个系统调用很复杂,我们只用了解它的第一个参数即可:

我们之前介绍的pthread_create底层就会调用clone。
但是这时会出现一个问题,因为linux只有创建轻量级进程的系统调用,有一些没学过linux的用户是不知道轻量级进程的概念的,他们只知道进程和线程,所以linux对上封装了一套pthread库,也叫做原生线程库(它和linux内核是会一直绑定在一起的),包含一整套线程接口供用户使用。
缺页异常
设想,CPU 给 MMU 的虚拟地址,在 TLB 和⻚表都没有找到对应的物理⻚,该怎么办呢?
其实这就是缺⻚异常 Page Fault ,它是⼀个由硬件中断触发的可以由软件逻辑纠正的错误。 假如⽬标内存⻚在物理内存中没有对应的物理⻚或者存在但⽆对应权限,CPU 就⽆法获取数据,这种情况下CPU就会报告⼀个缺⻚错误。
由于 CPU 没有数据就⽆法进⾏计算,CPU罢⼯了⽤⼾进程也就出现了缺⻚中断,进程会从⽤⼾态切换到内核态,并将缺⻚中断交给内核的 Page Fault Handler 处理。

缺⻚中断会交给 PageFaultHandler 处理,其根据缺⻚中断的不同类型会进⾏不同的处理:
- Hard Page Fault 也被称为 Major Page Fault ,翻译为硬缺⻚错误/主要缺⻚错误,这时物理内存中没有对应的物理⻚,需要CPU打开磁盘设备读取到物理内存中,再让MMU建⽴虚拟地址和物理地址的映射。
- Soft Page Fault 也被称为 Minor Page Fault ,翻译为软缺⻚错误/次要缺⻚错误,这时物理内存中是存在对应物理⻚的,只不过可能是其他进程调⼊的,发出缺⻚异常的进程不知道⽽已,此时MMU只需要建⽴映射即可,⽆需从磁盘读取写⼊内存,⼀般出现在多进程共享内存区域。
- Invalid Page Fault 翻译为⽆效缺⻚错误,⽐如进程访问的内存地址越界访问,⼜⽐如对空指针解引⽤内核就会报 segment fault 错误中断进程直接挂掉。
线程的优点
1、创建⼀个新线程的代价要⽐创建⼀个新进程⼩得多
2、与进程之间的切换相⽐,线程之间的切换需要操作系统做的⼯作要少很多
- 最主要的区别是是否切换CPU中的CR3寄存器(指向当前进程的页表),也就是线程切换后虚拟内存空间和页表不会别切换,但是进程切换时会将虚拟内存空间和页表都切换。这两种上下⽂切换的处理都是通过操作系统内核来完成的。内核的这种切换过程伴随的最显著的性能损耗是将寄存器中的内容切换出。
(CPU如何判断是否需要切换CR3寄存器呢?因为进程中所有线程共享同一个时间片,当进程中创建线程会将剩余时间片与所有线程平分,当时间片还未耗尽时CPU就是进行线程切换,不用切换CR3寄存器,当时间片耗尽后CPU就是进行进程切换,也就需要切换CR3寄存器)- 另外⼀个隐藏的损耗是上下⽂的切换会扰乱处理器的缓存机制。TLB缓存虚拟到物理的映射关系,cache缓存物理内存数据(CPU与物理内存之间),简单的说,⼀旦去切换上下⽂,处理器中所有已经缓存的内存地址⼀瞬间都作废了。还有⼀个显著的区别是当你改变虚拟内存空间的时候,处理的⻚表缓冲 TLB (快表)会被全部刷新,这将导致内存的访问在⼀段时间内相当的低效。但是在线程的切换中,不会出现这个问题,当然还有硬件cache。
- (一个进程内不同线程之间的切换不需要更换TLB缓存(块表),因为所有线程用于用一个虚拟地址空间和页表,虚拟到物理的映射关系都是同一套,但切换进程后原来的TLB就失效了,所以需要更换TLB。)
3、线程占⽤的资源要⽐进程少很多4、能充分利⽤多处理器的可并⾏数量(进程线程共有)
5、在等待慢速I/O操作结束的同时,程序可执⾏其他的计算任务(进程线程共有)
6、计算密集型应⽤,如我们平时写的算法题,为了能在多处理器系统上运⾏,将计算分解到多个线程中实现,注意计算密集型应⽤创建的线程不宜过多,一般推荐创建的线程个数 = CPU个数 * 核数,避免同一核中线程切换的花销。 7、I/O密集型应⽤,特点是程序频繁与外设交互,为了提⾼性能,将I/O操作重叠,线程可以同时等待不同的I/O操作。I/O密集型应⽤可以适当多创建一些线程,因为I/O操作大部分时间都在等,不如多创建一些线程一起等。
这里小编补充说明一下linux中进程task_struct、CPU、物理内存、虚拟内存、cache缓存和TLB缓存之间的关系,把知识串联一下,下面用一个实际执行流程串联所有环节:
- 调度器选中进程 A → 从进程 A 的 task_struct 加载 CPU 上下文(寄存器、PC 等)→ 将 task_struct->mm->pgd 的物理地址加载到 CR3 寄存器;
- CPU 读取指令(访问虚拟地址 VA1)→ 先查 TLB:
- 若 TLB 命中:直接得到物理地址 PA1;
- 若 TLB 未命中:遍历 CR3 指向的页表,找到 VA1→PA1,将映射写入 TLB;
- CPU 用 PA1 访问 Cache:
- 若 Cache 命中:直接读取数据到 CPU 寄存器,执行指令;
- 若 Cache 未命中:从物理内存读取 PA1 对应的数据到 Cache,再读取到 CPU 寄存器执行;
- 指令执行完成后,调度器判断是否切换进程:
- 若切换到进程 B:保存进程 A 的上下文到其 task_struct → 加载进程 B 的上下文 → 切换 CR3 到进程 B 的页表基址 → TLB 失效 → 后续访问进程 B 的 VA 重新填充 TLB/Cache;
- 若切换到进程 A 的线程:仅切换线程上下文(寄存器/栈),CR3/TLB/Cache 均不失效,开销极低。
线程的缺点
1、性能损失
该点其实不属于线程的缺点范畴,而是不合理使用多线程会造成性能损失,⼀个很少被外部事件阻塞的计算密集型线程往往⽆法与其它线程共享同⼀个处理器。如果计算密集型线程的数量⽐可⽤的处理器多,那么可能会有较⼤的性能损失,这⾥的性能损失指的是增加了额外的同步和调度开销,⽽可⽤的资源不变。
2、健壮性降低
编写多线程需要更全⾯更深⼊的考虑,在⼀个多线程程序⾥,因时间分配上的细微偏差或者因共享了不该共享的变量⽽造成不良影响的可能性是很⼤的,换句话说线程之间是缺乏保护的。
(例如一个进程中的一个线程除零或野指针了,该进程内的所有线程都会收到退出信号)
3、缺乏访问控制
进程之间具有独立性,这就是具有访问控制的良好实现。而线程之间的数据(也就是虚拟地址空间)基本都是共享的,虽然我们的说法是线程各自享有自己的虚拟地址资源,但是线程之间是可以互相访问资源的,所以说线程缺乏访问控制。进程是访问控制的基本粒度,在⼀个线程中调⽤某些OS函数会对整个进程造成影响。
4、编程难度提⾼
编写与调试⼀个多线程程序⽐单线程程序困难得多。
线程异常
单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃。
线程是进程的执⾏分⽀,线程出异常,就类似进程出异常,进⽽触发信号机制,终⽌进程,进程终⽌,该进程内的所有线程也就随即退出。
线程用途
合理的使⽤多线程,能提⾼CPU密集型程序的执⾏效率
合理的使⽤多线程,能提⾼IO密集型程序的⽤⼾体验(如⽣活中我们⼀边写代码⼀边下载开发⼯具,就是多线程运⾏的⼀种表现)
二、Linux进程VS线程
线程独占资源
1、线程ID(LWP)
2、CPU中⼀组寄存器,线程的上下文数据(因为线程是要被调度的)
3、栈(线程除了被调度,线程运行时也会产生各种临时数据,线程自己也要有函数调用、栈帧结构)
4、errno
5、信号屏蔽字
6、调度优先级
面试时前三个必须答出,这样面试官就会知道你是知道线程不是静态而是动态的。
线程共享资源
进程中的线程共享同⼀地址空间,因此Text Segment、Data
Segment、堆区、命令行参数等等都是共享的,如果定义⼀个函数,在各线程中都可以调⽤,如果定义⼀个全局变量,在各线程中都可以访问到,除此之外,各线程还共享以下进程资源和环境:
1、⽂件描述符表
2、每种信号的处理⽅式(SIG_ IGN、SIG_ DFL或者⾃定义的信号处理函数)
3、当前⼯作⽬录
4、⽤⼾id和组id
进程和线程的关系如下图:

以上就是小编分享的全部内容了,如果觉得不错还请留下免费的赞和收藏
如果有建议欢迎通过评论区或私信留言,感谢您的大力支持。
一键三连好运连连哦~~
