【LINUX操作系统】线程基础与分页式存储管理
1. 认识线程
【LINUX操作系统】信号保存/捕捉与操作系统运行原理-CSDN博客
在学习上一讲的内容中(可重入函数,volatile)时我们提到一个问题,内核空间的执行流和主程序的执行流在做着不同的任务,但是拥有相同的PID,这是否说明进程并不是操作系统内最小的执行任务的单位?
thread概念(线程)
在⼀个程序⾥的⼀个执⾏路线就叫做线程(thread)。更准确的定义是:线程是“⼀个进程内部的控制序列”⼀切进程⾄少都有⼀个执⾏线程, 可以认为我们之前学习的进程,就是“只有一个线程”的特殊情况的进程。现在,我们了解到一个进程中可以有很多线程。进程实际上承担分配系统资源的基本实体,而线程是进程中的一个执行分支,是操作系统调度的基本单位。此外,操作系统并不是直接调用进程,而是 调用线程
线程与进程的统一
在之前的认知中,只有一个绿色的task_struct是指向虚拟内存的,其实线程一样也需要能找到mm_struct等对应资源:
线程在进程内部运⾏,本质是在进程地址空间内运⾏
难道我们要在PCB结构中再设置一个tcb结构吗?这不会让整个操作系统结果更加混乱,
windos似乎就是这么做的,但是我们没有源码,不得而知。
在Linux中,实际上并没有所谓的线程,因为如果存在线程,那么操作系统必定要对线程进行管理,对应的线程也就需要属于自己的结构和调度方案(thread control block),但是因为线程本质是进程中的一个执行分支(即执行某一段代码,并且这段代码是通过特殊的方式执行的),其中的相关内容与进程非常类似,所以可以不需要额外单独创建一个结构来表示线程,只需要复用进程的PCB结构即可。(windows里是有具体的tcb类似的概念的,会复杂很多,这也是linux精妙的原因所在!)
这样一来,OS透过进程虚拟地址空间,可以看到进程的⼤部分资源,将进程资源合理分配给每个执⾏流,就形 成了线程执⾏流。所以,线程所能看到的就是一个进程所有的内容,包括进程的虚拟地址空间、打开的各种文件、各种数据等,这种线程在Linux下统一称为轻量级进程(LWP----->light weight process)
对于CPU来说,看到的PCB就要⽐传统的进程更加轻量化
为了描述方便,除非是为了描述具体区别,否则后面的内容会互换使用「线程」和「轻量级进程」这两个名词
有了以上理解,我们再更正一下之前所学习的”特殊进程“的调度:
更准确来说,是线程调度,只不过其中只有一个线程(即主线程),而从线程部分开始,一个进程内就可以有多个线程,所有线程共享一个进程的资源 ,进程是申请资源的载体。
![]()
2. 线程简单观察
1.
pthread_create
函数
- 功能:用于创建一个新线程。
- 函数原型:
#include <pthread.h> int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void *), void *arg);
- 参数解析:
thread
:指向pthread_t
类型的指针,用于存储新线程的标识符(ID)。attr
:线程属性(如栈大小、调度策略等),通常设为 nullptr 表示使用默认值。start_routine
:新线程要执行的函数(函数指针)。arg
:传递给线程函数的参数(void*
类型,需自行转换)。- 返回值:
成功时返回
0
,失败返回非零错误码(如EAGAIN
表示系统资源不足)
2.
pthread_t
类型- 定义:用于标识线程的唯一标识符(类似进程的PID)。
- 特点:
- 具体实现依赖于系统(可能是结构体或整数)。
- 通过
pthread_create
返回,后续用于操作线程(如pthread_join
)。- 示例声明:
pthread_t tid; // 声明一个线程标识符变量
ptheard_creat来自原生线程库,在编译的时候要在makefile或者指令中加入(声明库引用):
关于库引用的使用知识点:
初识linux(16) 动静态库(手搓动静态库!)_ar编译静态库-CSDN博客
最后一个选项其实是:-l pthread
code:
#include <iostream> #include <pthread.h> #include <unistd.h>using namespace std;void *run(void *arg) {while (true){cout << "new thread" << endl;sleep(1);} }int main() {pthread_t tid;pthread_create(&tid, nullptr, run, (void *)"thread-1");while (true){cout << "main thread" << endl;sleep(1);}return 0; }
3. 指令查看线程
验证一下:主线程和新线程的pid
果然一样。
输入脚本:
while :; do ps ajx |head -1 && ps ajx | grep mythread | grep -v grep;sleep 1; done
ps ajx竟然查不出线程
新的指令 ps -aL:
ps -aL
LWP和pid相等的是主线程,否则是新线程
pid用来区分执行流的唯一性就不够,所以真实调度的时候是按照lwp来区分的。
在以前的角度,lwp和pid一直相等,因为都是单执行流的进程。
现在,和PID相等的LWP是主线程,另外一个是新线程。
LINUX下的线程是用进程模拟实现的。
3. 分页式存储管理(难点!)
在有线程的概念之后,我们再谈虚拟内存机制,更进一步理解虚拟内存。
正如我们以前所知,进程使用的其实是一个虚拟内存:
我们希望操作系统提供给用户的空间必须是连续的,但是物理内存最好不要连续。此时虚拟内存和分⻚便出现了,如下图所⽰:
在Linux中,操作系统为了更好的将内存和硬盘进行交互,除了从磁盘上读取数据时按照4kb大小读取外,在内存中,写入/读取数据也是按照4kb进行的
OS对物理内存的管理:4kb的统一
把 物理内存 按照⼀个固定的⻓度的⻚框进⾏分割,有时叫做物理⻚。每个⻚框包含⼀个物理⻚ (page)。⼀个⻚的⼤⼩等于⻚框的⼤⼩。⼤多数 32位 体系结构⽀持 4KB 的⻚(学习中我们默认是4kb),⽽ 64 位 体系结 构⼀般会⽀持 8KB 的⻚。 区分 ⼀⻚ 和 ⼀个⻚框 是很重要的 :1. 页框(Page Frame)
- 定义:
- 页框是物理内存中用于存储页(Page)的固定大小的区域。
- 它是物理内存中分配给虚拟内存页的实际存储空间。
- 特点:
- 页框是物理内存中的具体位置,具有实际的物理地址。
- 页框的大小与页的大小一致(通常为 4KB、8KB 或 16KB)。
- 页框是操作系统进行内存管理的基本单位。
2. 物理页(Physical Page)
- 定义:
- 物理页是物理内存中实际存在的、连续的存储区域。
- 它是一个抽象概念,表示物理内存中的一段连续空间,用于存储数据。
- 特点:
- 物理页强调的是物理内存中的存储空间本身,而不是其分配状态。
- 物理页可以被分配给页框,也可以处于空闲状态。
另外,linux中从磁盘上读取数据时按照4kb大小读取,在内存中,写入/读取数据也是按照4kb进行的。这样就和物理内存的管理完美契合了。
结合之前页表的概念:
其思想是将虚拟内存下的 逻辑地址空间分为若⼲⻚ ,将物理内存空间分为若⼲⻚框,通过 ⻚表便能把连续的虚拟内存,映射到若⼲个不连续的物理内存⻚。
分页机制:
假设⼀个可⽤的物理内存有 4GB 的空间。按照⼀个⻚框的⼤⼩ 4KB 进⾏划分, 4GB 的空间就是 4GB/4KB = 1048576 个⻚框。有这么多的物理⻚,操作系统肯定是要将其管理起来的,操作系统 需要知道哪些⻚正在被使⽤,哪些⻚空闲等等。物理内存中管理每一个页的内核代码(不用全部看懂!):
struct page {unsigned long flags; /* atomic flags, some possiblyupdated asynchronously */atomic_t count; /* Usage count, see below. */struct list_head list; /* ->mapping has some page lists. */struct address_space *mapping; /* The inode (or ...) we belong to. */unsigned long index; /* Our offset within mapping. */struct list_head lru; /* Pageout list, eg. active_list;protected by zone->lru_lock !! */union {struct pte_chain *chain;/* Reverse pte mapping pointer.* protected by PG_chainlock */pte_addr_t direct;} pte;unsigned long private; /* mapping-private opaque data */#if defined(WANT_PAGE_VIRTUAL)void *virtual; /* Kernel virtual address (NULL ifnot kmapped, ie. highmem) */ #endif /* WANT_PAGE_VIRTUAL */ };
用flages(位图)用于描述page的状态(是否被使用)。count表示引用计数,计数归0表示次内容不再被使用,可以释放..............
系统中的每个物理⻚都要分配⼀个这样的结构体,让我们来算算对所有这些⻚都这么做,到底要消耗掉多少内存:算 struct page 占 40个字节 的内存,假定系统的物理⻚为 4KB ⼤⼩,系统有 4GB 物理内存。 那么系统中共有⻚⾯ 1048576 个(4Gb/4kb=1024*1024),所以描述这么多 ⻚ 的page结构体消耗的内存只不过 40MB(1024*1024*40byte) ,相对系统 4GB 内存⽽⾔,仅是很⼩的⼀部分罢了。因此,要管理系统中这么多物理⻚⾯,代价并不算太⼤。但为什么整个结构体中没有一个具体的地址来表现当前page到底指向的哪个页框呢?
数组下标的魅力
那么如何通过页表找到真实的页框之后,去获得管理页框的信息呢(去访问对应的struct page)?
操作系统可以考虑使用一个数组对所有的页框进行管理,其中的元素都是
struct page*
原因之一是因为数组可以使用下标进行访问,而在C语言的世界里,下标和对应的地址(即起始地址+偏移量)可以相互转换,有了这一点,一旦在页表中通过虚拟地址找到了对应的物理地址,就可以在对应的页框数组中通过下标找到指向指定物理地址对应页框的指针从而访问到对应的属性。
也就是说,当我们通过页表获得一个物理地址(指向存储数据的页框),我们用这个物理地址/4kb 即可获得 描述该物理地址的 结构体 对于所在 结构体数组 的偏移量。
页表 :
⻚表中的每⼀个表项,指向⼀个物理⻚的开始地址。在 32 位系统中,虚拟内存的最⼤空间是 4GB 。这是每⼀个用户程序都拥有的虚拟内存空间。既然需要让 4GB 的虚拟内存全部可⽤,那么⻚表中就需 要能够表⽰这所有的 4GB 空间,那么就⼀共需要 4GB/4KB = 1048576 个表项。![]()
这会导致两个问题:
在 32 位系统中,地址的⻓度是 4 个字节,那么⻚表中的每⼀个表项就是占⽤ 4 个字节。所以⻚表占据的总空间⼤⼩就是: 1048576*4 = 4MB 的⼤⼩。也就是说映射表⾃⼰本⾝,就要占⽤ 4MB / 4KB = 1024 个物理⻚。•回想⼀下,当初为什么使⽤⻚表,就是要将进程划分为⼀个个⻚可以不⽤连续的存放在物理内存 中,但是此时⻚表就需要1024个连续的物理页,似乎和当时的⽬标有点背道⽽驰了......•此外,根据局部性原理可知,很多时候进程在⼀段时间内只需要访问某⼏个⻚就可以正常运⾏ 了。因此也没有必要⼀次让所有的物理⻚都常驻内存。
真实的页表构造:分级页表
把这个单⼀⻚表拆分成 1024 个体积更⼩的映射表。如下图所⽰。这样⼀ 来,1024(每个表中的表项个数) * 1024(表的个数),仍然可以覆盖 4GB 的物理内存空间。尽管总数是一样的,但是这样就更方便只加载几个 二级页表 就能满足对应的需求。(不全部加载满!)
32位系统下,其中,一级页表称为页目录,一共有1024()项,每一项称为页目录表项,二级页表(或简称页表)每一个页表也有1024项,每一项称为页表项,页目录中每一项中存储的都是对应二级页表的起始地址,所以在所有的二级页表都加载出来的情况下总共1024 × 1024 × 4 = 4 M B
当前分配下,每一个二级页表的大小就是一个物理页(4kb)的大小。
通过虚拟地址定位物理地址时,就直接通过32位地址依次查询,具体步骤如下(难点):
1.前10位(作为下标)定位 页目录 中指定项,此时就可以找到指定的 二级页表
2.中间10位(作为下标)定位 二级页表 中的指定表项,此时就可以在该表项中找到虚拟地址对应的真实的页框起始地址。这个对应的物理页框地址是随机的!!
3.最后12位(作为偏移量)定位页框中具体的某一个字节的地址,此时就可以通过页框起始地址(第二步中找到的页框地址)+偏移量找到指定字节的地址。
一旦找到一个字节,如果想通过这个字节作为起始值找不同类型的值,就可以通过类型的字节占用获取到完整的值,这就是为什么编译型语言层面需要有变量类型的原因。
所以,我们可以只通过前20位就获得每个页框的地址,就可以用上面讲的的页框地址/4kb的方法来获得struct page*的数组下标,获得该页框的属性,明确这部分内容是否可以被访问。
注意,二级页号定位了一个具体的二级页表的表项,而该表项中的内容具体指向物理内存的哪个位置,是随机的!所以每一个进程都可以有一个相同的虚拟地址并且指向不同的物理地址空间。
CR3寄存器中保存的直接是页目录中的物理地址。
所以操作系统在加载用户程序的时候,不仅仅需要为程序内容来分配物理内存,还需要为⽤来保存程序的⻚⽬录和⻚表分配物理内存。
理解页表根本不需要虚拟地址:
直接用虚拟地址就能找到一个二级页表的表项内容。
如图,磁盘中的一个ELF文件加载到物理内存中,正如之前理解ELF文件时所讲:每一个ELF文件当中都是通过(起始地址+偏移量)来构建的,加载到物理内存中时,才会获得一个真正的物理地址:0x123(假设),entry point address是0x1060(可放大查看)。同时,内存中的虚拟地址也会分配0x1060这个位置作为当前程序地址空间的入口。
当程序要被运行的时候,CPU里的一个寄存器EIP获得0x1060的地址。
CR3里面放的是页目录的起始地址,MMU能自动进行这个查表的过程:
再谈二级页表:页表标志位
在进程地址空间部分提到过,页表中含有一些标记位:
如:是否命中当前物理地址(物理地址里是不是有可以被我们找到并使用的内容),这一块物理地址的权限是什么,是USER态还是KERNEL态等等信息,该怎么被记录下来呢?
物理内存是4GB,即2^32byte;一个页框4kb,即2^12
2^32/2^12=2^20,也就是说只需要在二级页表的表项中,使用20个比特位,就能表示清楚每一个表项。但是一、二级页表的表项存的不都是一个完整的4byte的地址吗!4byte地址代表着32个bit位的长整数,还有32-20=12个位置,就是拿来标识该物理内存的各种状态的标志位!
为什么要用硬件MMU去地址转换
在CPU发展的过程中,查表行为一直都是一个非常高频的行为,所以其效率也是值得关注的,查表的操作本可以交给软件完成,但是软件的速度毕竟没有硬件快(硬件是用各种逻辑电路实现的!),所以最后还是在CPU中集成了一个硬件MMU。
实际上,并不是每一次MMU都去查页表获取到物理地址,而是先看缓存中是否存在已经查到指定虚拟地址对应的物理地址,这个缓存也是通过硬件实现的,即TLB寄存器,所以整个过程就变为:查表时,先查看TLB是否存在需要的虚拟地址对应的物理地址,没有就去查表,并将这个映射存储到TLB
也就是说:
当 CPU 给 MMU 传新虚拟地址之后, MMU 先去问 TLB 那边有没有,如果有就直接拿到物理地址发到 总线给内存,在物理地址中找到指令再传回给CPU。但 TLB 容量⽐较⼩,难免发⽣ Cache Miss ,这时候 MMU 还有保底的⽼武器 :⻚表,在⻚表中找到之后 MMU 除了把地址发到总线传给内存,还把这条映射关系给到TLB,让它记录 ⼀下刷新缓存。
重新梳理整个地址转换的过程:
进程切换主要就换这三个寄存器:
在操作系统中,存在一个指针struct task_struct* current(图中的第一个寄存器),在前面Linux进程状态与进程优先级部分提到这个指针指向的就是当前正在被调度的进程,具体来说就是进程中对应的线程。当CPU调度时,就会有对应的寄存器存储当前current指针中的内容,所以CPU一直都知道当前正在被调度的进程是谁。
cpu根据程序计数器(PC指针,就是图中的EIP)存储下一条指令的虚拟地址,MMU查询页表将其转换为物理地址,这个物理地址通过内存总线发送到内存控制器(硬件),内存控制器将物理地址对应的指令数据通过总线返回给cpu中的IR寄存器(存储正在执行的代码)
MMU拿到的虚拟会先去查TLB寄存器
MMU获得物理地址之后,回去查cache:
cache是对于ELF文件内容的各个blocks的直接缓存,可以避免再去内存中找。同样的,先在cache中找,找不到再使用老武器:去内存中使用内存控制器将指令穿回给IR。比如你正在访问第四行代码,可能会把这四行附近的数据都导入(局部性原理)
cache是有概率的东西:
“当前正在访问的内容附近的代码有高概率会继续被访问”
所以才会讲附近的代码都缓存进来。
CPU也是局部性原理的一个体现(冯诺依曼)------->将磁盘中的内容先缓存进来,减少磁盘外设速度慢的问题。
缺页中断
设想,CPU 给 MMU 的虚拟地址,在 TLB 和⻚表都没有找到对应的物理⻚,该怎么办呢?其实这就是 缺⻚异常 Page Fault ,它是⼀个由硬件中断触发的可以由软件逻辑纠正的错误。
MMU报错,给进程发信号,报错,当前进程切换到内核态(另一个执行流),执行内核态的相关代码。根据信号的不同,决定是ignore信号或者直接杀掉进程。另外, 缺⻚中断交给内核的 Page Fault Handler 处理(需要进入内核态)。
缺⻚中断会交给 PageFaultHandler 处理,其根据缺⻚中断的不同类型会进⾏不同的处理:Hard Page Fault 也被称为 Major Page Fault ,翻译为硬缺⻚错误/主要缺⻚错误,这时物理内存中没有对应的物理⻚,需要CPU打开磁盘设备读取到物理内存中,再让MMU建⽴虚拟 地址和物理地址的映射。Soft Page Fault 也被称为 Minor Page Fault ,翻译为软缺⻚错误/次要缺⻚错误,这时物理内存中是存在对应物理⻚的,只不过可能是其他进程调⼊的,发出缺⻚异常的进程不知道⽽已,此时MMU只需要建⽴映射即可,⽆需从磁盘读取写⼊内存,⼀般出现在多进程共享内存区域。Invalid Page Fault 翻译为⽆效缺⻚错误,⽐如进程访问的内存地址越界访问,⼜⽐如对空指针解引⽤内核就会报 segment fault 错误中断进程直接挂掉。
例如:
char *msg = "hello bit"; *msg = 'H';
*msg = 'H';
尝试修改字符串字面量,而字符串字面量在C语言中通常是只读的,存储在程序的只读数据段。因此,修改只读内存会引发段错误(Segmentation Fault),这是一种软中断。MMU通过查表知道了没有权限,返回给CPU,从而由CPU触发软中断。并非MMU发生了错误从而触发硬中断,MMU没有出错,是在正常工作。
刚刚开始Malloc不会真的给你分配空间,真的在输入数据的时候,也会因为找不到这个内容而触发软中断。所以new和malloc其实是软中断带触发的一个程序。
如何理解写时拷贝?子进程在物理内存中找不到对应的实例,触发缺页中断,然后再从父进程那里拷贝一份
有了以上的了解,对于一个进程的资源是如何分配给一个一个线程的,是不是就清楚很多了?
开辟一个新的线程,将该换的寄存器内容换了,要执行的功能直接通过多级页表来获取指令就可以了。
3. 线程的优点
创建⼀个新线程的代价要⽐创建⼀个新进程⼩得多•与进程之间的切换相⽐,线程之间的切换需要操作系统做的⼯作要少很多◦最主要的区别是线程的切换虚拟内存空间依然是相同的 ,但是进程切换是不同的。这两种上下⽂切换的处理都是通过操作系统内核来完成的。内核的这种切换过程伴随的最显著的性能损耗是将寄存器中的内容切换出。◦另外⼀个隐藏的损耗是上下⽂的切换会扰乱处理器的缓存机制。简单的说,⼀旦去切换上下⽂, 处理器中所有已经缓存的内存地址⼀瞬间都作废了。还有⼀个显著的区别是当你改变虚拟内存空间的时候,处理的⻚表缓冲 TLB (快表)会被全部刷新,这将导致内存的访问在⼀段时间内相当的低效。 但是在线程的切换中,不会出现这个问题,当然还有硬件cache,同样存在这个问题。•线程占⽤的资源要⽐进程少很•能充分利⽤多处理器的可并⾏数量•在等待慢速I/O操作结束的同时,程序可执⾏其他的计算任务•计算密集型应⽤,为了能在多处理器系统上运⾏,将计算分解到多个线程中实现•I/O密集型应⽤,为了提⾼性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
计算密集型应用不是分的线程越多越好,分的多了,在等待轮换的线程就多,照样不能起到并行的作用,轮换时还会有对应的代价,还不如就让少一点的线程一直干活。
建议创建个数:核数*个数
线程优势的真正重点:进程之前的切换成本比线程间的切换工作多得多!
1.CR3是指向当前一级页表的,这个不用切
2.TLB里面的缓存内容,不用清空
4.线程的缺点
•性能损失(线程切换消耗资源)◦⼀个很少被外部事件阻塞的计算密集型线程往往⽆法与其它线程共享同⼀个处理器。如果计 算密集型线程的数量⽐可⽤的处理器多,那么可能会有较⼤的性能损失,这⾥的性能损失指 的是增加了额外的同步和调度开销,⽽可⽤的资源不变。•健壮性降低◦编写多线程需要更全⾯更深⼊的考虑,在⼀个多线程程序⾥,因时间分配上的细微偏差或者因共享了不该共享的变量⽽造成不良影响的可能性是很⼤的,换句话说线程之间是缺乏保护的。 线程之间通信非常容易,一个全局变量就可以直接用于通信,可以被所有的线程看到•缺乏访问控制◦进程是访问控制的基本粒度,在⼀个线程中调⽤某些OS函数会对整个进程造成影响(比如信号,一个线程干坏事被发siganl,整个进程(可能里面有3、4、5个线程,都一起被杀)
一个分支线程干了野指针,整个进程都会被干掉。操作系统发信号是以进程为单位来发送的。
5.线程的资源共享
线程共享进程数据,但也拥有⾃⼰的⼀部分数据:◦线程ID◦⼀组寄存器 (用于线程上下文切换,因为线程才是调度的最小单位)◦栈(每个线程的栈都是各自独立的。说白了,线程就是执行各个函数,每个函数都要开辟栈帧空间,如果不独立管理每个线程开的栈帧,整个栈就会乱套。具体会在以后学习源码的时候再提。 )◦errno◦信号屏蔽字( 大家收到一样的信号,但是对这个信号的处理方式可能不一样)◦调度优先级线程共享同⼀地址空间,因此Text Segment(代码区)、Data Segment(数据区)都是共享的,如果定义⼀个函数,在各线程中都可以调 ⽤,如果定义⼀个全局变量,在各线程中都可以访问到,除此之外,各线程还共享以下进程资源和环境:•⽂件描述符表( 线程A开了4号文件,那线程B再开就是5号文件)•每种信号的处理⽅式(SIG_ IGN、SIG_ DFL或者⾃定义的信号处理函数)•当前⼯作⽬录•用户d和组id
最后一张图,彻底入门线程,知道线程和进程的关系: