目录
前言:
一、什么是线程
(一)基本概念
(二)线程理解
(三)线程与进程的关系
(四)简单实用线程
(五)重谈虚拟地址空间
1. 页表的大小
2. 内存与磁盘的交互
3. 局部性原理
4. 页表映射原理
5. 扩展:动态内存管理
(六)小结
二、线程优缺点
(一)优点
(二)缺点
(三)线程用途
前言:
将一份代码成功编译后,可以得到一个可执行程序,程序运行后,操作系统会生成对应数据结构(比如 PCB)对其进行管理及分配资源,并且相关代码和数据被 加载 到内存中,准备工作做完之后,我们就可以得到一个运行中的程序,简称为 进程。对于操作系统来说,光有 进程 的概念是无法满足高效运行的需求的,因此需要一种执行粒度更细、调度成本更低的执行流,而这就是线程。
Windows11中的线程:
一、什么是线程
(一)基本概念
教材观点:
- 线程就是一个执行分支、执行粒度比进程更细、调度成本更低
- 线程就是进程内部的一个执行流
内核观点:
- 进程是承担系统资源分配的基本实体,而线程是
CPU
调度的基本单位
线程是对以往进程概念的补充完善,正确理解线程概念是一件十分重要的事。
(二)线程理解
注意:以下理解是站在 Linux 系统的角度对于线程的理解,不同的操作系统具体实现方式略有差异。
理解 线程 之前需要先简单回顾一下 进程
- 程序运行后,操作系统为其创建对应的
PCB
数据结构,然后生成虚拟地址空间、分配内存资源,相关的代码和数据会被load
到内存中,并通过页表建立映射关系。
进程之间是相互独立的
即使是 父子进程,他们也有各自的 虚拟地址空间、映射关系、代码和数据(可能共享部分数据,出现修改行为时引发 写时拷贝机制)
如果我们想要创建 其他进程 执行任务,那么 虚拟地址空间、映射关系、代码和数据 这几样东西是必不可少的,想象一下:如果只有进程的概念,并且同时存在几百个进程,那么操作系统调度就会变得十分臃肿。
- 操作系统在调度进程时,需要频繁保存上下文数据、创建的虚拟地址空间及建立映射关系
为了避免这种繁琐的操作,引入了 线程 的概念,所谓线程就是:额外创建一个 task_struct 结构,该 task_struct 同样指向当前的虚拟地址空间,并且不需要建立映射关系及加载代码和数据,如此一来,我们就会发现我们新创建的task_struct 好像也是一个"进程",更是一个独立的执行流,其实这就是创建一个线程。操作系统只需要针对一个 task_struct 结构即可完成调度,成本非常低。
为什么线程其执行粒度比进程更细?
现在一个进程有多个task_struct ,都指向同一个地址空间。把地址空间中例如代码区的代码划分成多个部分,每一个 task_struct 在未来执行时,都执行同一个地址空间的不同部分的代码。这样一个进程中就存在了多个执行流(线程)。因此线程是一个执行分支,这样每个线程就能更精确的执行完成任务。
为什么调度线程的成本比进程更低?
在 CPU 内部包括:运算器、控制器、寄存器、MMU、硬件级缓存(cache),其中 硬件级缓存 cache 又称为 高速缓存,遵循计算机设计的基本原则:局部性原理,会预先加载 部分用户可能访问的数据,并在接下来的执行中有很大的概率命中这些数据,从而提高整机的效率。如果需要切换进程,会导致 高速缓存 中的数据无法使用(进程具有独立性),需重新开始 预加载,这是非常浪费时间的(对于 CPU 来说);但切换线程就不一样了,由于进程没变,所以缓存内容也不变,切换线程时,所需要的数据的也不会发生改变,这就意味值 高数缓存 中的数据可以继续使用,不需重新加载,并且可以接着 预加载 下一波数据。
注:高速缓存中预加载的是公共数据,并非线程的私有数据
进程(process)的 task_struct 称为 PCB,线程(thread)的 task_struct 则称为 TCB
从今天开始,无论是 进程 还是 线程,都可以称为 执行流,线程 从属于 进程:当进程中只有一个线程时,我们可以粗粒度的称当前进程为一个单独的执行流;当进程中有多个线程时,则称当前进程为多执行流,其中每一个执行流就是一个个的线程。
执行流的调度由操作系统负责,CPU 只负责根据 task_struct 结构进行计算
- 若下一个待调度的执行流为一个单独的进程,操作系统仍需创建 PCB 及 虚拟地址空间、建立映射关系、加载代码和数据。
- 但如果下一个待调度的执行流为一个线程,操作系统只需要创建一个 TCB,并将其指向已有的虚拟地址空间即可。
(三)线程与进程的关系
进程是承担系统资源分配的实体,比如 程序运行必备的:虚拟地址空间、页表映射关系、相关数据和代码 这些都是存储在 进程 中的,也就是我们历史学习中 进程 的基本概念。
线程是 CPU 运行的基本单位,程序运行时,CPU 只认识 task_struct 结构,并不关心你是线程还是进程,不过,线程包含于进程中,一个 进程可以只有一个线程,也可以有很多线程,当只有一个 线程时,通常将其称为 进程,但对于CPU 来说,这个进程本质上仍然是线程;因为 CPU 只认 task_struct 结构,并且 PCB 与 TCB 都属于 task_struct ,所以才说线程是CPU 运行的基本单位。
总结:进程是由操作系统将程序运行所需地址空间、映射关系、代码和数据打包后的资源包,而 线程/轻量级线程/执行流 则是利用资源完成任务的基本单位。
我们之前学习的进程概念是不完整的,引入线程之后,可以对进程有一个更加全面的认识。
通常将程序启动,比如 main
函数中的这个线程称为 主线程,其他线程则称为 次线程:
实际上 进程 = PCB
+ TCB
+ 虚拟地址空间 + 映射关系 + 代码和数据,这才是一个完整的概念
以后谈及进程时,就要想到 一批执行流+可支配的资源
在 Linux 中,认为 PCB 与 TCB 的共同点太多了,于是直接复用了 PCB 的设计思想和调度策略,在进行 线程管理 时,完全可以复用 进程管理 的解决方案(代码和结构),这可以大大减少系统调度时的开销,做到 小而美,因此 Linux 中实际是没有真正的 线程 概念的,有的只是复用 PCB 设计思想的 TCB。在这种设计思想下,线程 注定不会过于庞大,因此 Linux 中的 线程 又可以称为 轻量级进程(
LWP
),轻量级进程 足够简单,且 易于维护、效率更高、安全性更强,可以使得 Linux 系统不间断的运行程序,不会轻易 崩溃。
与 一切皆文件一样,这种设计思想注定 Linux 会成为一款 卓越 的操作系统
别的系统采用的是其他方案,比如 Windows 使用的是真线程方案,为 TCB 额外设计了一逻辑,这就导致操作系统在同时面临 PCB 和 TCB 时需要进行识别后切换成不同的处理手段,存在不同的逻辑容易增加系统运行不稳定的风险,这就导致 Windows 无法做到长时间运行,需要通过重启来重置风险
此时我的电脑中同时存在几百个进程和几千个真线程,可想而知操作系统的负担有多大:
(四)简单实用线程
接下来简单使用一下 pthread
线程原生库中的线程相关函数(只是简单使用,不涉及其他操作)
#include <iostream>
#include <unistd.h>
#include <pthread.h>
using namespace std;
void *threadHandler1(void *args)
{
while (true)
{
cout << "我是次线程1,我正在运行..." << endl;
sleep(1);
}
}
void *threadHandler2(void *args)
{
while (true)
{
cout << "我是次线程2,我正在运行..." << endl;
sleep(1);
}
}
void *threadHandler3(void *args)
{
while (true)
{
cout << "我是次线程3,我正在运行..." << endl;
sleep(1);
}
}
int main()
{
pthread_t t1, t2, t3; // 创建三个线程
pthread_create(&t1, NULL, threadHandler1, NULL);
pthread_create(&t2, NULL, threadHandler2, NULL);
pthread_create(&t3, NULL, threadHandler3, NULL);
// 主线程运行
while (true)
{
cout << "我是主线程" << endl;
sleep(1);
}
return 0;
}
编译程序时,需要带上 -lpthread
指明使用 线程原生库:
g++ -o $@ $^ -std=c++11 -lpthread
结果:主线程+三个次线程同时在运行
至于为什么打印结果会有点不符合预期,这就涉及到 加锁 相关问题了,后面再解决
使用指令查看当前系统中正在运行的 线程 信息:
ps -aL | head -1 && ps -aL | grep mythread | grep -v grep
可以看到此时有 四个线程
- 细节1:四个线程的
PID
都是24786
- 细节2:四个线程的
LWP
各不相同 - 细节3:第一个线程的
PID
和LWP
是一样的
其中,第一个线程就是 主线程,也就是我们之前一直很熟悉的 进程,因为它的 PID
和 LWP
是一样的,所以只需要关心 PID
也行。
操作系统如何判断调度时,是切换为 线程 还是切换为 进程 ?
- 将待切换的执行流
PID
与当前执行流的PID
进行比对,如果相同,说明接下来要切换的是 线程,否则切换的就是 进程。 - 操作系统只需要找到
LWP
与PID
相同的线程,即可轻松锁定主线程。
线程是进程的一部分,给其中任何一个线程发送信号,都会影响到其他线程,进而影响到整个进程。
(五)重谈虚拟地址空间
注:当前部分是拓展,与线程没有很大的关系,但是一个比较重要的知识点
1. 页表的大小
页表 是用来将 虚拟地址 和 物理地址 之间建立映射关系的,页表难道真的只是简单一 一存储映射吗?除此之外,页表 中还存在 其他属性 字段
在 32
位系统中,存在 2^32 个地址(一个内存单元大小是 1byte
),虚拟地址空间中的每一个地址依次为 [0, 2^32 − 1] 即 0x00000000 - 0xFFFFFFFF,也就是我们常说的 4 GB 虚拟内存空间。
假设极端情况:每个地址都在页表中建立了映射关系,其中页表的每一列大小都是 4
字节,那么页表的大小就是 2^32 * 4 * 3 * 1byte
= 48GB
,这就意味着悲观情况下页表已经干掉 48GB
的内存了,但现在电脑普遍都只有 16GB
内存,更何况是几十年前的电脑。
所以说页表绝对不是采用这种单纯 地址->地址 的映射方案。
2. 内存与磁盘的交互
操作系统从 磁盘 中读取数据时,一次读取大量数据 比 多次读取少量数据 要快的多,因为 磁盘 是外设,每一次读取都必然伴随着寻址等机械运动(机械硬盘),无论是对于 内存 还是 CPU
,这都是非常慢的,为了尽可能提高效率,操作系统选择一次 IO
大量数据的方式读取数据。
通常 IO
的数据以 块 为基本单位,在文件系统中,一个 块 的大小为 4KB
(一个块由8个扇区组成,单个扇区大小为 512Byte
),所以OS在和磁盘这样的设备进行IO交互的时候,就不能按照字节为单位的而是要按照块为单位。即使我们一次只想获取一个字节,操作系统最低也会 IO
一个 数据块(4KB)。
4KB 这个大小很关键
- 文件系统/编译器:文件存储时,需要以 4KB 为单位进行存储
- 操作系统/内存:读取文件或进行内存管理时,也是以 4KB 为单位的
为了让内存与与磁盘更高效地进行IO,操作系统对内存也进行了按管理划分,OS将内存划分成一个个页框,其中每个页框可以存储的数据的大小为4KB,这4KB被称为页 (Page)的数据。
为了将内存中的 页 Page 进行管理,需要 先描述,在组织,构建 struct page 结构体,用于描述 页 Page 的各种属性,比如是否为脏数据、是否已经被占用了,因为存在很多 页 Page,所以需要将这些 struct page 结构进行管理,使用的就是 数组(天然有下标) struct page mem[N],其中 N 表示当前内存中的 页 Page 数量。
struct page
{
int status; // 基础字段:状态
// 注意:这个结构不能设计的太复杂了,因为稍微大一点内存就爆了,所以里面的属性非常少
};
struct page mem[N]; // 管理 page 结构体的数组
假设我们的内存为 4GB,那么等分为 4KB 的 页 Page,可以得到约 100w 个 页 Page,其中 struct page 结构体不会设计的很大,大小是 字节 级别的,也就是说 struct page mem[100w] 占用的总大小不过 4~5MB,对于偌大的内存来说可以忽略不计。
内存管理的本质:
- 申请:无非就是寻找 mem 数组中一块未被使用的足量空间,将对应的 页 Page 属性设置为已被申请,并返回起始地址(足量空间页框的起始地址)。
- 使用:将磁盘中的指定的 4KB 大小数据块存储至内存中对应的 页 Page 中。
- 释放:将 页 Page 属性设置为可用状态。
3. 局部性原理
重新视 4KB,为什么内存与磁盘交互的基本单位是 块(4KB)?
这里就要提一下 局部性原理 了:
局部性原理的特征
- 现代计算机预加载的理论基础
- 允许我们提前加载正在访问数据的 相邻或者附加的数据(数据预加载)
局部性原理 的核心在于 预加载,如果没有 局部性原理,那么我们可能今天都用不上电脑,因为如果没有这个原则,那么内存在于磁盘交互时,只能做到用户需要什么,就申请什么,这会直接拉低 CPU
的速度,而速度极快的 磁盘 又非常贵。
而 局部性原理 有效避免了这个问题:用户访问数据时,操作系统不仅会加载用想要访问的数据,同时还会加载当前数据的临近数据,如此一来就可以做到用户访问下一份数据时,不必再次 IO
,尽量减少 IO
的次数。
- 合理性:用户访问的数据大多都是具有一定连续性的,比如用户访问
668
号数据,那么他下一次想访问的数据大概是669
及以后,因此可以提前加载669及以后的数据。
配合上 4KB 的块大小,可以使得每次 IO
足量的数据,并且有可能会多出,起到 预加载 的效果
所以现在就可以回答为什么是 4KB :
IO
的基本单位,内核系统/文件系统 都对其提供了支持- 利于通过 局部性原理 预测数据的命中情况,尽可能提高效率
总结:IO
的基本单位是 4KB ,内存实际上被划分成了很多个 4KB 的小块,并存在相应的数据结构对其进行管理。
4. 页表映射原理
显然,页表 绝对不可能动辄几十个 GB
,实际在根据 虚拟地址 进行寻址时,页表 也有自己的设计逻辑。
虚拟地址(32 位操作系统) 大小也就是 32 比特位,大概也就是 4Byte,通常将一个 虚拟地址 分割为三份:10、10、12
- 10:虚拟地址中的前 10 个比特位,用于寻址 页表2
- 10:虚拟地址中间的 10 个比特位,用于寻找 页框起始地址
- 12:虚拟地址中的后 12 个比特位,用于定位 具体地址(偏移量)
所以,实际上在通过 页表 进行寻址时,需要用到 两个页表(为了方便演示,仅包含一组 kv
关系):
注:“页表2” 中的 20
表示内存中的下标,即 页框地址
通常将 “页表1” 称为 页目录,“页表2” 称为 页表项
- 页目录:使用
10
个比特位定位 页表项 - 页表项:使用
10
个比特位定位 页框地址 - 偏移量:使用
12
个比特位,在 页Page
中进行任意地址的寻址
所以即使是每个 物理地址 都被寻址的的极端情况下,页表 总大小不过为:(2^10 + 2^10) * (2^10 + 2^20),大约也就需要 4Mb 大小,即可映射至每一个 物理内存,但实际上 物理内存 并不会被时刻占满,大多数情况下都是使用一部分,因此实际页表大小不过 几十字节。
像这种 页框起始地址+偏移量 的方式称为 基地址+偏移量,是一种运用十分广泛的思想,比如所谓的 类型(int、double、char…)都是通过 类型的起始地址+类型的大小 来标识该变量大小的,也就是说我们只需要 获得变量的起始地址,即可自由进行偏移操作(如果偏移过度了,就是越界),这也就解释了为什么取地址只会取到 起始地址。
总结:得益于 划分+偏移 的思想,使得页表的大小可以变得很小。
5. 扩展:动态内存管理
实际上,我们在进行 动态内存管理(malloc/new
) 申请堆空间时,操作系统 并没有立即在物理内存中申请空间(因为你申请了可能不会立马使用),而是 先在 虚拟地址 中进行申请(成本很低),当我们实际使用该空间时,操作系统 再去 填充相应的页表信息+申请具体的物理内存。
像这种操作系统赌博式的行为我们已经不是第一次见了,比如之前的 写时拷贝,就是在赌你不会修改,这样做的好处就是可以 最大化提高效率,对于内存来说,这种使用时再申请的行为会引发 缺页中断。
具体原理:
当用户 动态申请内存 时,操作系统只会在虚拟地址中申请,具体表现为 返回一块未被使用的空间起始地址,用户实际使用这块空间时,遵循 查页表、寻址物理内存 的原则,实际进行 查页表 操作时,发现 页表项 没有记录此地址的映射关系,于是就会引发 缺页中断,发出对应的 中断信号,陷入内核态,通过 中断控制器 识别 中断信号 后做出相应的动作,比如这里的动作是:填充页表信息、申请物理内存 ;把 物理内存 准备好后,用户就可以进行正常使用了,整个过程非常快,对于用户来说几乎无感知。
同理,在进行 磁盘文件读取 时,也存在 缺页中断 行为,毕竟你打开文件了,并不是立即进行读写操作的。
诸如这种 硬件级的中断行为 我们已经在 以往 信号产生 章节 中学过了,即:从键盘按下的那一刻,发出硬件中断信号,中断控制器识别为 键盘 发出的信号后,去 中断向量表 中查找执行方法,也就是 键盘 的读取方法。
所以操作系统根本不需要关系 硬件 是什么样子,只需要关心对方是否发出了 信号(请求),并作出相应的 动作(执行方法) 即可,很好的实现了 解耦。
对于 内存 的具体情况,诸如:是否命中、是否被占用、对应的 RWX
权限 需要额外的空间对其进行描述,而 页表 中的 其他属性 列就包含了这些信息:
对 内存 进行操作时,势必要进行 虚拟地址到物理地址 之间的转换,而 MMU
机制 + 页表信息 可以判断 当前操作 是否合法,如果不合法会报错。
注:UK
权限用于区分当前是用户级页表,还是内核级页表
比如这段代码:
char *ps = "good morning";
*ps = 'A'; // 此时程序会报错(需要赋值为字符,否则无法编译)
结合 页表、信号 等知识,解释整个报错逻辑:
- "good morning" 属于字符常量,存储在字符常量区中,其中的权限为 R。
- char *ps 属于一个指针变量,指向字符常量的起始地址
- 当我们进行 *ps = "A" 操作时,首先会将字符常量的地址转换为物理地址,在转换过程中,MMU 机制发现该内存权限仅为 R,但 *ps 操作需要 W 权限,于是 MMU 引发异常 -> 操作系统识别到异常,将该异常转换为 信号 -> 并把 信号 发给出现问题的 进程 -> 信号暂时被保存 -> 在 内核态转为用户态 的过程中,进行 信号处理 -> 最终结果是终止进程,也就是报错。
程序运行后,就会报错:
(六)小结
所以目前 地址空间 的所有组成部分我们都已经打通了,再次回顾这种设计时,会发现 用户压根不知道、也不需要知道虚拟地址空间之后发生的事,只需要正常使用就好了,当引发异常操作时,操作系统能在 查页表 阶段就进行拦截,而不是等到真正影响到 物理内存 时才报错。
所谓的 虚拟地址空间 就是在进行设计时添加的一层 软件层,它解决了 多进程时的物理内存访问问题、也解决了物理内存的保护问题,同时还为用户提供了一个简单的虚拟地址空间,做到了 虚拟与物理 的 完美解耦:
这种设计思想就是计算机界著名的 所有问题都可以通过添加一层 软件层 解决,这种思想早在几十年前就已经得到了运用。
这种分层结构不仅适用于 操作系统,还适用于 网络,比如大名鼎鼎的 OSI
七层网络模型。
二、线程优缺点
Linux 中没有 真线程,有的只是复刻进程代码和管理逻辑的 轻量级线程(LWP)
线程 有以下概念:
- 在一个程序中的一个执行路线就叫做 线程(Thread),或者说 线程 是一个进程内部的控制程序
- 每一个进程都至少包含一个 主线程
- 线程 在进程内部执行,本质上仍然是在进程地址空间内运行
- 在 Linux 系统中,CPU 看到的 线程TCB 比传统的 进程PCB 更加轻量化
- 透过进程地址空间,可以看到进程的大部分资源,将资源合理分配给每个执行流,就形成了 线程执行流
(一)优点
线程 最大的优点就是 轻巧、灵活,更容易进行调度
- 创建一个线程的代价比创建一个进程的代价要小得多
- 调度线程比调度进程要容易得多
- 线程占用的系统资源远小于进程
- 可以充分利用多处理器的并行数量(进程也可以)
- 在等待慢速 IO 操作时,程序可以执行其他任务(比如看剧软件中的 “边下边看” 功能)
- 对于计算密集型应用,可以将计算分解到多个线程中实现(比如 压缩/解压 时涉及大量计算)
- 对于 IO密集型应用,为了提高性能,将 IO操作重叠,线程可以同时等待资源,进行 高效IO(比如 文件/网络 的大量 IO 需要,可以通过 多路转接 技术,提高效率)
线程 的合理使用可以提高效率,但 线程不是越多越好,而是 合适 最好,让每一个线程都能参与到计算中。
(二)缺点
线程 也是有缺点的:
1、性能损失:当线程数量过多时,频繁的线程调度所造成的消耗会导致 计算密集型应用 无法专心计算,从而造成性能损失。
2、 健壮性降低:在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。
在下面这个程序中,次线程4 出现异常后,会导致整个进程运行异常,进而终止进程
#include <iostream>
#include <unistd.h>
#include <pthread.h>
using namespace std;
void *threadHandler1(void *args)
{
while (true)
{
cout << "我是次线程1,我正在运行..." << endl;
sleep(1);
}
}
void *threadHandler4(void *args)
{
while (true)
{
sleep(5); // 等其他线程先跑一会
cout << "我是次线程4,我正在运行..." << endl;
char *ps = "good morning";
*ps = 'A';
}
}
int main()
{
pthread_t t1, t4; // 创建两个线程
pthread_create(&t1, NULL, threadHandler1, NULL);
pthread_create(&t4, NULL, threadHandler4, NULL);
// 主线程运行
while (true)
{
cout << "我是主线程" << endl;
sleep(1);
}
return 0;
}
结果一轮到次线程4运行,因为触发异常,从而整个进程就直接终止了:
为什么 单个线程 引发的错误需要让 整个进程 来承担?
- 站在技术角度:完全可以让其自行承担,但这不合理
- 系统角度:线程是进程的执行分支,线程出问题了,进程也不应该继续运行(比如一颗老鼠屎坏了一锅汤)
- 信号角度:线程出现异常后,MMU 识别到异常 -> 操作系统将异常转换为信号 -> 发送信号给指定
3、缺乏访问控制,进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响
#include <iostream>
#include <unistd.h>
#include <pthread.h>
using namespace std;
int g_val = 0;
void *threadHandler1(void *args)
{
while (true)
{
printf("我是次线程1,我正在运行... &g_val: %p g_val: %d\n", &g_val, g_val);
sleep(1);
}
}
void *threadHandler2(void *args)
{
while (true)
{
printf("我是次线程2,我正在运行... &g_val: %p g_val: %d\n", &g_val, g_val);
g_val++; // 次线程2 每次都需改这个全局变量
sleep(1);
}
}
int main()
{
pthread_t t1, t2; // 创建两个线程
pthread_create(&t1, NULL, threadHandler1, NULL);
pthread_create(&t2, NULL, threadHandler2, NULL);
// 主线程运行
while (true)
{
printf("我是主线程,我正在运行... &g_val: %p g_val: %d\n", &g_val, g_val);
sleep(1);
}
return 0;
}
结果:无论是主线程还是次线程,当其中的一个线程出现修改行为时,其他线程也会同步更改
多个线程访问同时访问一个资源,不加以保护的话,势必会造成影响,当然这都是后话了(加锁相关内容)。
4、编程难度提高,编写与调试一个多线程程序需要考虑许多问题,诸如 加锁、同步、互斥 的等,面对多个执行流时,调试也是非常困难的。
(三)线程用途
合理的使用 多线程,可以提高 CPU
计算密集型程序的效率
合理的使用 多线程,可以提高 IO
密集型程序中用户的体验(具体表现为用户可以一边下载,一边做其他事情)
三、