
将一份代码成功编译后,可以得到一个可执行程序,程序运行后,相关代码和数据被 load 到内存中,并且操作系统会生成对应数据结构(比如 PCB)对其进行管理及分配资源,准备工作做完之后,我们就可以得到一个运行中的程序,简称为 进程,对于操作系统来说,光有 进程 的概念是无法满足高效运行的需求的,因此需要一种执行粒度更细、调度成本更低的执行流,而这就是 线程
Windows 中的线程

教材观点
内核观点
线程是对以往进程概念的补充完善,正确理解线程概念是一件十分重要的事
注意:以下理解是站在 Linux 系统的角度,不同的系统具体实现方式略有差异
理解线程之前需要先简单回顾一下 进程
程序运行后,相关的代码和数据会被load到内存中,然后操作系统为其创建对应的 PCB 数据结构、生成虚拟地址空间、分配对应的资源,并通过页表建立映射关系

进程之间是相互独立
即使是 父子进程,他们也有各自的 虚拟地址空间、映射关系、代码和数据(可能共享部分数据,出现修改行为时引发 写时拷贝机制)
如果我们想要创建 其他进程 执行任务,那么虚拟地址空间、映射关系、代码和数据这几样东西是必不可少的,想象一下:如果只有进程的概念,并且同时存在几百个进程,那么操作系统调度就会变得十分臃肿
线程 的概念,
线程 就是:额外创建一个 task_struct 结构,该task_struct同样指向当前的虚拟地址空间,并且不需要建立映射关系及加载代码和数据,如此一来,操作系统只需要 针对一个 task_struct 结构即可完成调度,成本非常低
为什么切换进程比切换线程开销大得多?
运算器、控制器、寄存器、MMU、硬件级缓存(cache),其中 硬件级缓存 cache 又称为 高速缓存,遵循计算机设计的基本原则:局部性原理,会预先加载 部分用户可能访问的数据 以提高效率 高速缓存 中的数据无法使用(进程具有独立性),重新开始 预加载,这是非常浪费时间的(对于 CPU 来说切换线程就不一样了,因此线程从属于进程,切换线程时,所需要的数据的不会发生改变,这就意味值 高数缓存 中的数据可以继续使用,并且可以接着 预加载 下一波数据不同 CPU 的 高速缓存 大小不同,足够大的高速缓存 + 先进的工艺 就可以得到一块性能优越的 CPU
注:高速缓存中预加载的是公共数据,并非线程的私有数据

进程(process)的 task_struct 称为 PCB,线程(thread)的 task_struct 则称为 TCB
从今天开始,无论是进程还是 线程,都可以称为 执行流,线程 从属于 进程:
执行流的调度由操作系统负责,CPU 只负责根据 task_struct 结构进行计算
PCB 及 虚拟地址空间、建立映射关系、加载代码和数据TCB,并将其指向已有的虚拟地址空间即可现在面临着一个很关键的问题:进程和线程究竟是什么关系?

进程是承担系统资源分配的实体,比如 程序运行必备的:虚拟地址空间、页表映射关系、相关数据和代码 这些都是存储在 进程 中的,也就是我们历史学习中 进程 的基本概念
线程是 CPU 运行的基本单位,程序运行时,CPU 只认task_struct 结构,并不关心你是 线程 还是 进程。
线程 包含于 进程 中,一个进程可以只有一个 线程,也可以有很多 线程,当只有一个 线程 时,通常将其称为 进程进程 本质上仍然是 线程;因为 CPU 只认 task_struct 结构,并且 PCB 与 TCB 都属于 task_strcut,所以才说 线程是 CPU 运行的基本单位总结:进程是由操作系统将程序运行所需地址空间、映射关系、代码和数据打包后的资源包,而线程/轻量级线程/执行流则是利用资源完成任务的基本单位
线程包含于进程中,进程本身也是一个线程
我们之前学习的进程概念是不完整的,引入线程之后,可以对进程有一个更加全面的认识
通常将程序启动,比如 main 函数中的这个线程称为 主线程,其他线程则称为 次线程

实际上 进程 =PCB+ TCB + 虚拟地址空间 + 映射关系 + 代码和数据,这才是一个完整的概念
以后谈及进程时,就要想到 一批执行流+可支配的资源

进程与线程的概念并不冲突,而是相互成就
在 Linux 中,认为 PCB 与 TCB 的共同点太多了,于是直接复用了 PCB 的设计思想和调度策略,在进行 线程管理 时,完全可以复用进程管理的解决方案(代码和结构),这可以大大减少系统调度时的开销,做到 小而美,因此 Linux 中实际是没有真正的 线程 概念的,有的只是复用 PCB 设计思想的 TCB
在这种设计思想下,线程 注定不会过于庞大,因此 Linux 中的 线程 又可以称为 轻量级进程(LWP),轻量级进程 足够简单,且 易于维护、效率更高、安全性更强,可以使得 Linux 系统不间断的运行程序,不会轻易 崩溃
与 一切皆文件一样,这种设计思想注定 Linux 会成为一款 卓越 的操作系统
别的系统采用的是其他方案,比如 Windows 使用的是真线程方案,为 TCB 额外设计了一逻辑,这就导致操作系统在同时面临 PCB 和 TCB 时需要进行识别后切换成不同的处理手段,存在不同的逻辑容易增加系统运行不稳定的风险,这就导致 Windows 无法做到长时间运行,需要通过重启来重置风险
如何验证 Linux 中的线程解决方案? 简单使用一下就好了
接下来简单使用一下 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 指明使用 线程原生库
结果:主线程+三个次线程同时在运行
至于为什么打印结果会有点不符合预期,这就涉及到 加锁 相关问题了,后面再解决

使用指令查看当前系统中正在运行的 线程 信息
ps -aL | head -1 && ps -aL | grep myThread | grep -v grep
可以看到此时有 四个线程
PID 都是 3730189LWP 各不相同PID 和LWP是一样的其中,第一个线程就是 主线程,也就是我们之前一直很熟悉的 进程,因为它的PID和 LWP 是一样的,所以只需要关心 PID 也行
操作系统如何判断调度时,是切换为 线程 还是切换为 进程 ?
PID 与当前执行流的 PID 进行比对,如果相同,说明接下来要切换的是 线程,否则切换的就是 进程LWP 与PID相同的线程,即可轻松锁定主线程线程是进程的一部分,给其中任何一个线程发送信号,都会影响到其他线程,进而影响到整个进程
注:当前部分是拓展,与线程没有很大的关系,但是一个比较重要的知识点
页表 是用来将 虚拟地址 和 物理地址 之间建立映射关系的,除此之外,页表 中还存在 其他属性 字段

众所周知,在 32 位系统中,存在 2^32 个地址(一个内存单元大小是 1byte),意味着虚拟地址空间 的大小为 4GB
假设极端情况:每个地址都在页表中建立了映射关系,其中页表的每一列大小都是4字节,那么页表的大小就是 2^32 * 4 * 3 * 1byte = 48GB,这就意味着悲观情况下页表已经干掉 48GB 的内存了,但现在电脑普遍都只有 16GB 内存,更何况是几十年前的电脑
所以说页表绝对不是采用这种单纯 地址->地址 的映射方案
操作系统从 磁盘 中读取数据时,一次读取大量数据 比 多次读取少量数据 要快的多,因为 磁盘 是外设,每一次读取都必然伴随着寻址等机械运动(机械硬盘),无论是对于 内存 还是 CPU ,这都是非常慢的,为了尽可能提高效率,操作系统选择一次 IO 大量数据的方式读取数据
通常 IO 的数据以块为基本单位,在文件系统中,一个 块 的大小为 4KB(一个块由8个扇区组成,单个扇区大小为 512Byte),即使我们一次只想获取一个字节,操作系统最低也会IO一个 数据块(4KB)
4KB 这个大小很关键
4KB 为单位进行存储4KB 为单位的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 属性设置为可用状态关于 mem 数组的查找算法(内存分配算法):LRU、伙伴系统等
重新审视 4KB,为什么内存与磁盘交互的基本单位是 块(4KB)?
这里就要提一下局部性原理了

局部性原理的特征
提前加载正在访问数据的 相邻或者附加的数据(数据预加载)局部性原理 的核心在于 预加载,如果没有 局部性原理,那么我们可能今天都用不上电脑,因为如果没有这个原则,那么内存在于磁盘交互时,只能做到用户需要什么,就申请什么,这会直接拉低 CPU 的速度,而速度极快的 磁盘 又非常贵
局部性原理 有效避免了这个问题:用户访问数据时,操作系统不仅会加载用想要访问的数据,同时还会加载当前数据的临近数据,如此一来就可以做到用户访问下一份数据时,不必再次 IO,尽量减少 IO 的次数
合理性:用户访问的数据大多都是具有一定连续性的,比如用户访问 668 号数据,那么他下一次想访问的数据大概是 669 及以后,因此可以提前加载
4KB 的块大小,可以使得每次 IO 足量的数据,并且有可能会多出,起到 预加载 的效果
所以现在就可以回答为什么是 4KB:
IO 的基本单位,内核系统/文件系统 都对其提供了支持局部性原理预测数据的命中情况,尽可能提高效率总结:IO 的基本单位是 4KB,内存实际上被划分成了很多个 4KB 的小块,并存在相应的数据结构对其进行管理
显然,页表 绝对不可能动辄几十个 GB,实际在根据虚拟地址进行寻址时,页表 也有自己的设计逻辑
虚拟地址(32 位操作系统) 大小也就是 32 比特位,大概也就是 4Byte,通常将一个 虚拟地址 分割为三份:10、10、12
页表2页框起始地址具体地址(偏移量)
所以,实际上在通过 页表 进行寻址时,需要用到 两个页表(为了方便演示,仅包含一组 kv 关系):

注:“页表2” 中的 20 表示内存中的下标,即 页框地址
通常将 “页表1” 称为 页目录,“页表2” 称为 页表项
页表项 页框地址页 Page 中进行任意地址的寻址所以即使是每个 物理地址 都被寻址的的极端情况下,页表 总大小不过为:(2^10 + 2^10) * (2^10 + 2^20),大约也就需要 4Mb 大小,即可映射至每一个 物理内存,但实际上 物理内存 并不会被时刻占满,大多数情况下都是使用一部分,因此实际页表大小不过 几十字节
像这种 页框起始地址+偏移量 的方式称为 基地址+偏移量,是一种运用十分广泛的思想,比如所谓的 类型(int、double、char…)都是通过 类型的起始地址+类型的大小 来标识该变量大小的,也就是说我们只需要 获得变量的起始地址,即可自由进行偏移操作(如果偏移过度了,就是越界),这也就解释了为什么取地址只会取到 起始地址
总结:得益于划分+偏移的思想,使得页表的大小可以变得很小
扩展:动态内存管理
实际上,我们在进行 动态内存管理(malloc/new) 申请堆空间时,操作系统 并没有立即在物理内存中申请空间(因为你申请了可能不会立马使用),而是 先在 虚拟地址 中进行申请(成本很低),当我们实际使用该空间时,操作系统 再去 填充相应的页表信息+申请具体的物理内存
像这种操作系统赌博式的行为我们已经不是第一次见了,比如之前的 写时拷贝,就是在赌你不会修改,这样做的好处就是可以 最大化提高效率,对于内存来说,这种使用时再申请的行为会引发 缺页中断

虚拟地址 中申请,具体表现为 返回一块未被使用的空间起始地址,用户实际使用这块空间时,遵循 查页表、寻址物理内存 的原则查页表 操作时,发现 页表项 没有记录此地址的映射关系,于是就会引发 缺页中断,发出对应的 中断信号,陷入内核态,通过 中断控制器 识别 中断信号 后做出相应的动作,比如这里的动作是:填充页表信息、申请物理内存 ; 物理内存 准备好后,用户就可以进行正常使用了,整个过程非常快,对于用户来说几乎无感知

同理,在进行 磁盘文件读取 时,也存在 缺页中断 行为,毕竟你打开文件了,并不是立即进行读写操作的
诸如这种
硬件级的中断行为我们已经在信号产生中学过了,即:从键盘按下的那一刻,发出硬件中断信号,中断控制器识别为键盘发出的信号后,去中断向量表中查找执行方法,也就是键盘的读取方法
所以操作系统根本不需要关系 硬件 是什么样子,只需要关心对方是否发出了 信号(请求),并作出相应的 动作(执行方法) 即可,很好的实现了 解耦
对于 内存 的具体情况,诸如:是否命中、是否被占用、对应的 RWX 权限 需要额外的空间对其进行描述,而 页表 中的 其他属性 列就包含了这些信息

对 内存 进行操作时,势必要进行虚拟地址到物理地址之间的转换,而 MMU 机制 + 页表信息 可以判断 当前操作 是否合法,如果不合法会报错
注:UK 权限用于区分当前是用户级页表,还是内核级页表
比如这段代码:
char *ps = "Change World!";
*ps = 'N'; // 此时程序会报错(需要赋值为字符,否则无法编译)结合 页表、信号 等知识,解释整个报错逻辑:
字符常量,存储在字符常量区中,其中的权限为 R
物理地址,在转换过程中,MMU 机制发现该内存权限仅为 R,但 *ps 操作需要 W 权限,于是 MMU 引发异常 -> 操作系统识别到异常,将该异常转换为 信号 -> 并把 信号 发给出现问题的 进程 -> 信号暂时被保存 -> 在 内核态转为用户态 的过程中,进行 信号处理 -> 最终结果是终止进程,也就是报错
所以目前地址空间的所有组成部分我们都已经打通了,再次回顾这种设计时,会发现 用户压根不知道、也不需要知道虚拟地址空间之后发生的事,只需要正常使用就好了,当引发异常操作时,操作系统能在 查页表 阶段就进行拦截,而不是等到真正影响到 物理内存 时才报错

所谓的 虚拟地址空间 就是在进行设计时添加的一层 软件层,它解决了 多进程时的物理内存访问问题、也解决了物理内存的保护问题,同时还为用户提供了一个简单的虚拟地址空间,做到了 虚拟与物理 的 完美解耦

这种设计思想就是计算机界著名的 所有问题都可以通过添加一层 软件层 解决,这种思想早在几十年前就已经得到了运用
这种分层结构不仅适用于 操作系统,还适用于 网络,比如大名鼎鼎的 OSI 七层网络模型
Linux 中没有 真线程,有的只是复刻进程代码和管理逻辑的 轻量级线程(LWP)
线程 有以下概念:
线程(Thread),或者说 线程 是一个进程内部的控制程序
主线程
线程在进程内部执行,本质上仍然是在进程地址空间内运行
线程 TCB 比传统的 进程 PCB 更加轻量化
线程执行流
线程 最大的优点就是 轻巧、灵活,更容易进行调度
高效IO(比如 文件/网络 的大量 IO 需要,可以通过 多路转接 技术,提高效率)线程 的合理使用可以提高效率,但 线程 不是越多越好,而是 合适 最好,让每一个线程都能参与到计算中线程 也是有缺点的:
线程 数量过多时,频繁的 线程 调度所造成的消耗会导致 计算密集型应用 无法专心计算,从而造成性能损失
在下面这个程序中,次线程2 出现异常后,会导致整个进程运行异常,进而终止进程
#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)
{
sleep(5); // 等其他线程先跑一会
cout << "我是次线程2,我正在运行..." << endl;
char *ps = "Change World!";
*ps = 'N';
}
}
int main()
{
pthread_t t1, t2; // 创建两个线程
pthread_create(&t1, NULL, threadHandler1, NULL);
pthread_create(&t2, NULL, threadHandler2, NULL);
// 主线程运行
while (true)
{
cout << "我是主线程" << endl;
sleep(1);
}
return 0;
}结果一轮到 次线程2 运行,因为触发异常,从而整个进程就直接终止了
为什么 单个线程引发的错误需要让整个进程 来承担?
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 密集型程序中用户的体验(具体表现为用户可以一边下载,一边做其他事情)
线程,是计算机程序中不声不响的舞者,独立又紧密相连;它既是操作系统内在的血脉,也是我们技术创新的源泉。在这段初识Linux多线程的旅程中,我们跨越了基础的门槛,瞥见了它蕴藏的无限潜力。在未来的探索中,我们将继续在这片浩瀚的知识海洋中航行,挖掘更多关于并发与并行的奥秘。线程的世界,远比我们想象的更加精彩,等待着我们一步一步去揭开它的面纱。
本篇关于线程初识的介绍就暂告段落啦,希望能对大家的学习产生帮助,欢迎各位佬前来支持斧正!!!