1.回顾进程
当我们要运行一个进程时,操作系统首先就要创建内核数据结构,进程PCB,地址空间,页表,文件描述符表等,然后将进程加载到物理内存中,在页表上建立虚拟地址与物理的映射关系,首先我们要理解地址空间是进程的资源窗口,因为进程执行正文代码是从地址空间上执行的,进程最初获取变量数据同样也是以地址空间为起点的堆区,栈区等进行获取的,同时进程获取执行共享库也是从地址空间上进行执行的,进程执行系统调用,同样的也是经过身份切换后,从用户态转化为内核态,从自己的地址空间上执行的,同样的变量,正文代码等都属于数据,是数据那么就是资源,所以我们说地址空间是进程的资源窗口。

进程在CPU上运行时,如果进程代码中有fork系统调用,所以就会创建子进程,子进程的绝大部分的数据都是来源于父进程,但是为了子进程的进程独立性,所以操作系统将父进程的内核数据结构的大部分内容也给子进程也拷贝一份,并且在页表上建立虚拟地址与物理的映射关系,所以子进程自此有了自己独立的内核数据结构,接下来子进程就可以被CPU调度运行了。
2.线程理解
在linux中,创建一个线程本质就是创建一个PCB(task_struct),那么创建多个线程,就是创建多个PCB(task_struct),那么由于地址空间是进程的资源窗口,那么进程执行代码是从正文代码执行的,进程的数据是从诸如堆区,栈区等获取的,那么我同样也可以让线程执行正文代码的一小部分区域,正如父进程和子进程的关系一样,既然子进程可以执行正文代码的一部分区域,那么对应的我们也可以让线程执行地址空间正文代码的一小部分区域(函数名就是地址,函数名也是代码,所以每一行代码也是有地址的,所以在代码层面天然的就可以进行划分为很多的区域),当线程需要获取例如全局变量之类的数据的时候,那通过访问进程地址空间来获取。
我们知道一个进程被调度运行本质上是进程的PCB(task_struct)被放在CPU上运行,那么线程同样有PCB,所以线程同样也可以被放在CPU上运行,那么对于CPU来讲其实无论是进程还是线程,本质都一样,站在CPU角度,都是一个一个的执行流!当一个进程只有一个执行流时,这个执行流就是一个进程,当一个进程有了多个执行流时,执行流就可以看作为线程。线程 <= 执行流 <= 进程
所以现在我们就可以重新定义一下进程和线程了,线程:线程是操作系统调度的基本单位。对于进程来讲,之前理解为进程 = 内核数据结构 + 代码和数据,而今天在多线程部分,我们要重新理解一下进程,站在内核角度:进程是承担分配系统资源的基本实体,线程是执行流,是资源,所以线程是进程内部的执行流资源,那么此时根据上面的理解一个进程,进程的PCB(执行流),地址空间,页表,物理内存上的代码和数据等都属于资源,所以下面红色框内的全部才是进程。

在很多操作系统的书上,我们通常可以看到这样一句话:linux没有真正"意义"上的线程,而是采用"进程"模拟线程。其实这样的描述是不准确的,linux有真正意义上的线程,struct task_struct就是真正意义上的线程,如果说没有单独为线程设计独立的内核数据结构,那么确实可以说linux没有真正"意义"上的线程,同样的而是采用"进程"模拟线程,这个"进程"同样不准确,更准确的来讲是采用的进程的内核数据结构来模拟的线程
所以linux的线程方案的可以进行两点总结:1. 在linux中,线程在进程内部运行,线程在进程的地址空间内运行,为什么?线程是执行流,任何执行流要执行,都要有资源(资源例如:代码,全局变量等,没有代码怎么运行,所以执行流的执行必须要有资源),地址空间是进程的资源窗口,所以线程是在进程的地址空间内运行的,2. 在linux中,为什么线程的执行(运行)粒度要比进程更细?因为线程执行(运行)的是进程代码的一小部分,而全部的代码都属于进程,进程执行的是全部的代码,线程执行的是一小部分代码,进程的执行是依赖一个至多个线程的,因为线程是操作系统调度的基本单位,所以线程执行的粒度比进程更细。
3.重谈进程地址空间
对于进程地址空间,大部分区域的空间都是共享的,但是在栈区中各个线程表示共享的,每隔线程都有自己私自的一部分空间。

页表的实际结构是分为页目录,二级页表,页目录必须存在,二级页表可以存在部分,CPU的CR3寄存器保存的实际上就是每个进程的页目录的起始地址。
那么我们来看一下上述转化过程,首先我们默认是以32为机器为例进行讲解,那么既然是32为机器,那么虚拟地址就是32位的,即一个虚拟地址对应32个比特位,但是这32个比特位并不是整体使用的,而是分隔使用的,即32个比特位,32 = 10 + 10 + 12,我们知道虚拟地址是从0x00000000到0xFFFFFFFF的,即全0到全F的,那么与之对应的,每一个比特位也都可以进行0到1的转换,所以32 = 10 + 10 + 12
其中的10,10,12都可以从全0到全1,那么10,10,12由于是比特位的个数,所以10个比特位代表着2^10 = 1024,那么范围就是从0到1024 - 1(1023),同样的第二个10也是代表的范围是从0到1024 - 1(1023),12个比特位代表着2^12即4096,所以12个比特位代表的范围是从0到2^12 - 1,大小正好是4096,所以一个页框的大小是4KB=4096,所以最后12个比特位恰好可以作为偏移量定位页框具体位置(物理地址0x00000000到0xFFFFFFFF,一个地址的大小为1字节共4GB,为了便于管理4GB的空间被划分为一个一个大小为4096字节(4KB)的页框)。
页目录里面存储的是二级页表的地址,二级页表里面存储的是真实的页框的起始地址。
前十位和中间十位对应的可以转化为下标,对应在页目录和二级页表的下标,所以CPU在得到一个虚拟地址要去访问物理地址是,先根据前十位去页目录里面找到对应的二级页表的地址,再根据虚拟地址的中间十位去二级页表中找到页框地址,最后根据最后12偏移量最后找到真实的物理地址。
那么CPU是找到了地址,怎么知道自己要访问几个字节,所以才有了c/c++里面的类型概念,CPU中会有寄存器来存储类型,根据类型来算出要访问多少个字节。
同样的二级页表不仅有存储页框地址的页表表项字段,同时还有与之对应的权限,以及是否建立映射的字段,这个是否建立映射的字段还与缺页中断有关,当要访问虚拟内存,但是通过层层定位定位到并没有对应的有建立映射,即页表项字段为空,那儿CPU就会通过CPU内部的CR2寄存器先记录引起中断异常的虚拟地址,中断会导致CPU进入内核态,这个时候回去判断是由于缺页中断引起的合法异常还是由于像空指针引起的非法异常,如果是缺页中断,去物理地址上加载数据,加载完成后回来如何得知重新建立映射,那么通过CR2寄存器即可得知引起缺页中断异常的虚拟地址,然后将物理地址填到虚拟地址对应页表的字段上即可。
首先我们知道页目录中有1024个页目录表项存储着1024个二级页表的地址,所以二级页表的个数是1024个,那么一个二级页表又有1024个页表表项,一个页表表项中存储的是一个物理地址,32位下,物理地址也是32位的,所以我们忽略二级页表的权限,以及是否建立映射的字段之后,那么一个二级页表就有1024个页表表项,一个页表表项中存储的是一个物理地址,一个物理地址的大小是4字节,所以一个二级页表的大小是4 * 1024字节 = 4096字节 = 4KB,一共有1024个二级页表,所以一共有4096KB = 4MB
所以操作系统创建一个进程的负荷是一个很重的工作。
4.线程周边概念
4.1 线程是轻量化的进程
1.创建和释放更加轻量化
线程是基于进程的基础上进行创建的,线程的创建本质上就是创建了一个PCB(task_struct),相较于进程的创建,进程的创建要创建内核数据结构,如:PCB(task_struct),进程地址空间,页表,文件描述符表,以及在物理内存上加载代码和数据,所以线程的创建更加轻量化。对于线程的释放本质上也就是释放一个PCB(task_struct),对于进程的释放,要将系统分配给它的资源全部释放,即释放内核数据结构+代码和数据,所以线程的释放更加轻量化。
2.线程的切换轻量化
线程之间的切换是在一个进程内的多个执行流,即多个线程之间进行切换,所以线程的切换无需切换地址空间,切换上下文以及重新加载页表,因为一个进程内部的多个线程共享地址空间,并且与之对应的CR3寄存器中也不用重新加载新的页表(页目录地址),而对于进程来讲,进程的切换必须切换地址空间,切换上下文以及在CR3寄存器中加载新的页目录地址。
3.cache缓存(主要原因)
CPU 内部集成了分级的 Cache 高速缓冲存储器,其设计依托程序运行的局部性原理:一个执行流访问物理内存中的某个数据时,大概率会在短期内再次访问该数据(时间局部性),或访问其相邻数据(空间局部性),典型场景如链表遍历、数组连续读写等。
CPU 从主存读取数据到 Cache 的最小单位是 Cache Line(缓存行,常见 64 字节) ------ 当访问某一内存地址的数据时,CPU 会将该地址所在的整个 Cache Line 数据一并载入 Cache。这些已缓存的有效数据称为 Cache 热数据,未缓存或无效的数据则称为 Cache 冷数据。Cache 的访问速度(纳秒级)远高于主存(微秒级),这是程序数据访问加速的关键硬件基础。
当进程切换发生时:
不同进程拥有完全隔离的虚拟地址空间和页表映射,原进程在 Cache 中缓存的指令、数据对新进程完全无效,会引发 Cache 污染后的冷启动。
新进程不仅需要将自身的上下文(寄存器值、程序计数器等)载入 CPU,还必须重新通过缺页异常等机制,从主存加载自身的指令和数据到 Cache,将 Cache 从冷数据状态切换为热数据状态。
寄存器上下文切换仅需纳秒级时间,而 Cache 冷加载涉及大量主存 IO 操作,是进程切换成本的主要来源。
而同一进程内的线程切换则完全不同:
同一进程的所有线程共享同一页表和虚拟地址空间,意味着它们访问的是同一批物理内存中的指令、全局变量、堆数据等核心资源。
线程切换时,CPU Cache 中已缓存的进程热数据可以被直接复用,无需重新从主存加载;仅需切换线程的私有上下文(如线程栈指针、线程局部存储等)。
即便线程私有栈数据存在少量未缓存的情况,其 Cache 缺失开销也远小于进程切换的全量冷加载开销。
4.2 线程调度时间片的理解
线程作为独立的执行流,具备被CPU调度的基本条件,所以线程内部必然存在时间片的概念------当线程在CPU上的运行时间耗尽时,就会触发线程切换,CPU执行下一个执行流。
我们知道,进程被创建时会默认生成一个执行流,即主线程 ;后续通过系统调用(如 pthread_create )创建的执行流则为新线程。需要明确的是:进程的总调度时间片是由操作系统的调度器分配的,这个时间片总量固定,不会因进程内线程数量的增加而额外追加。若为某进程额外分配时间,会占用其他进程的调度资源,破坏系统调度的公平性。
为了管控进程内所有线程的运行时长,进程层面会维护一个进程总时间片,而进程内的主线程和每个新线程,又各自拥有独立的线程私有时间片。这里的核心规则是:主线程私有时间片 + 所有新线程私有时间片 = 进程总时间片。
当进程内的某个线程(主线程或新线程)被调度到CPU上运行时,会同时发生两个时间片递减行为:
1. 当前运行线程的私有时间片持续减少;
2. 进程的总时间片同步减少。
由此会触发两种不同的切换场景:1.若线程私有时间片耗尽但进程总时间片未耗尽:触发进程内的线程切换,调度器会选择该进程内的另一个就绪线程投入运行;
2.若进程总时间片耗尽(无论线程私有时间片是否剩余):触发进程切换,当前进程的所有线程都会被调出CPU,操作系统会选择其他处于就绪队列的进程投入运行。
那么操作系统如何区分主线程和新线程,进而精准管控进程总时间片?关键在于线程控制块(TCB)------在Linux系统中,线程与进程统一由 task_struct 结构体描述,内核会在该结构体中设置专门的标识字段。通过这个标识,调度器可以快速识别出某个线程是否为进程的主线程,并通过主线程关联的 task_struct 获取整个进程的总时间片信息,从而实现对进程及内部所有线程的调度时长管控。
5.线程的优缺点
线程的优点
创建一个新线程的代价比创建一个新进程小得多
与进程之间的切换相比,线程之间的切换需要操作系统做的工作少得多
线程占用的资源要比进程少很多\n能充分利用多处理器的可并行数量
在等待慢速I/O操作结束的同时,程序可执行其它的计算任务\n计算密集型应用,为了能在多处理器上运行,将计算任务分解到多个线程中实现(但是通常来讲线程切换也需要耗费时间,不如仅仅使用一个进程执行计算任务,这样就不用进行线程之间的切换)
I/O密集型应用,为了提高性能,将I/O操作重叠,线程可以同时等待不同的I/O操作(例如:在手机上下载应用,可以下载应用的同时做其他的事情,这也是多线程的应用)
线程的缺点
**性能损失。**一个很少被外部事件阻塞的计算密集型线程往往无法与其它线程共享同一个处理器。即只能一个线程对应一个处理器。所以如果计算密集型线程的数量比可用的处理器的数量多,那么可能会有较大的性能损失,这个性能损失是指增加了额外的同步和调度开销,而可用的资源不变
**健壮性降低。**对于程序员来讲,编写一个多线程代码往往需要更为全面的考虑,在多线程程序中,因线程的时间的细微差距或者线程之间共享了不该共享的变量造成的损失可能会很大,即线程是缺乏保护的
**缺乏访问控制。**进程是访问控制(内核身份和用户身份的切换)的基本单位,在一个线程中调用系统调用可能会对整个进程产生影响
**编程难度提高。**编写与调试一个多线程程序的难度比单进程程序要困难得多
线程异常
1.单个线程如果出现除零,野指针等问题导致线程崩溃,那么紧接着也会导致进程崩溃
2.线程是进程的执行分支,换句话来说线程代表着进程。线程出现异常,那么进程也会出现异常,进而触发信号机制,进程会收到信号,换句话来说,进程下的所有线程都会收到信号,而线程对于信号的处理动作都是默认动作,所以就会终止所有线程,进而进程也就被终止了(假设进程内有5个线程,前4个线程的终止仅仅会将那4个线程的PCB(task_struct)进行释放,可是对于最后一个线程来讲,要将进程对应的所有的资源,即内核数据结构+代码和数据一并释放)
线程用途
合理使用多线程,可以提高CPU密集型程序的执行效率
合理的使用多线程,可以提高I/O密集型程序的用户体验(例如:我们的电脑上边下载开发工具边进行编写代码,这也是多线程运行的一种表现)
线程的私有部分与共享部分
线程是调度的基本单位。进程是系统资源分配的基本单位

多个线程之间共享进程的数据,但是线程也有自己的一部分数据:
-
线程ID
-
一组寄存器(CPU寄存器数据,即上下文,是动态的)
-
独立的部分栈空间(栈中的数据是运行时的临时变量,是动态的)
-
errno
-
信号屏蔽字
-
调度优先级
进程的内的多个线程共享同一个:
-
地址空间
-
全局变量
-
文件描述符表
-
每种信号的处理方式(默认动作SIG_DFL,忽略动作SIG_IGN,自定义动作),一个线程设置了,其他的线程的处理方式也算是一样的,但是每个线程有自己的信号屏蔽字,可以选择进行屏蔽。
-
进程的当前工作目录
-
用户ID和组ID