✨个人主页: 北 海
所属专栏: Linux学习之旅
操作环境: CentOS 7.6 阿里云远程服务器
将一份代码成功编译后,可以得到一个可执行程序,程序运行后,相关代码和数据被 load
到内存中,并且操作系统会生成对应数据结构(比如 PCB
)对其进行管理及分配资源,准备工作做完之后,我们就可以得到一个运行中的程序,简称为 进程
,对于操作系统来说,光有 进程
的概念是无法满足高效运行的需求的,因此需要一种执行粒度更细、调度成本更低的执行流,而这就是 线程
Windows
中的线程
可能很多人第一次听说 线程 这个词是在 处理器 中,比如今年 英特尔第 13
代酷睿 系列芯片,就在其宣传页中提到了 线程 这个词
硬件上的 线程 概念我们这里不讨论,接下来看看操作系统层面的 线程概念
教材观点
内核观点
CPU
运行的基本单位线程是对以往进程概念的补充完善,正确理解线程概念是一件十分重要的事
注意:以下理解是站在 Linux 系统的角度,不同的系统具体实现方式略有差异
理解 线程 之前需要先简单回顾一下 进程
load
到内存中,然后操作系统为其创建对应的 PCB
数据结构、生成虚拟地址空间、分配对应的资源,并通过页表建立映射关系详见 《Linux进程学习【进程地址】》
进程之间是相互独立
即使是 父子进程,他们也有各自的 虚拟地址空间、映射关系、代码和数据(可能共享部分数据,出现修改行为时引发 写时拷贝机制)
如果我们想要创建 其他进程 执行任务,那么 虚拟地址空间、映射关系、代码和数据 这几样东西是必不可少的,想象一下:如果只有进程的概念,并且同时存在几百个进程,那么操作系统调度就会变得十分臃肿
为了避免这种繁琐的操作,引入了 线程 的概念,所谓 线程 就是:额外创建一个 task_struct
结构,并且该 task_struct
同样指向当前的虚拟地址空间,并且不需要建立映射关系及加载代码和数据,如此一来,操作系统只需要 创建一个 task_struct
结构即可完成调度,成本非常低
为什么切换进程比切换线程开销大得多?
在 CPU
内部包括:运算器、控制器、寄存器、MMU
、硬件级缓存(cache
),其中 硬件级缓存 cache
又称为 高速缓存,遵循计算机设计的基本原则:局部性原理,会预先加载 部分用户可能访问的数据 以提高效率,如果切换进程,会导致 高速缓存 中的数据无法使用(进程具有独立性),重新开始 预加载,这是非常浪费时间的(对于 CPU
来说);但切换线程就不一样了,因此线程从属于进程,切换线程时,所需要的数据的不会发生改变,这就意味值 高数缓存 中的数据可以继续使用,并且可以接着 预加载 下一波数据
不同 CPU
的 高速缓存 大小不同,足够大的高速缓存 + 先进的工艺 就可以得到一块性能优越的 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_strcut
,所以才说 线程是 CPU
运行的基本单位
总结:进程是由操作系统将程序运行所需地址空间、映射关系、代码和数据打包后的资源包,而 线程/轻量级线程/执行流 则是利用资源完成任务的基本单位
线程包含于进程中,进程本身也是一个线程
我们之前学习的进程概念是不完整的,引入线程之后,可以对进程有一个更加全面的认识
通常将程序启动,比如 main
函数中的这个线程称为 主线程,其他线程则称为 次线程
实际上 进程 = PCB
+ TCB
+ 虚拟地址空间 + 映射关系 + 代码和数据,这才是一个完整的概念
以后谈及进程时,就要想到 一批执行流+可支配的资源
进程与线程的概念并不冲突,而是相互成就
在 Linux
中,认为 PCB
与 TCB
的共同点太多了,于是直接复用了 PCB
的设计思想和调度策略,在进行 线程管理 时,完全可以复用 进程管理 的解决方案(代码和结构),这可以大大减少系统调度时的开销,做到 小而美,因此 Linux
中实际是没有真正的 线程 概念的,有的只是复用 PCB
设计思想的 TCB
在这种设计思想下,线程 注定不会过于庞大,因此 Linux
中的 线程 又可以称为 轻量级进程(LWP
),轻量级进程 足够简单,且 易于维护、效率更高、安全性更强,可以使得 Linux
系统不间断的运行程序,不会轻易 崩溃
与 一切皆文件一样,这种设计思想注定 Linux
会成为一款 卓越 的操作系统
别的系统采用的是其他方案,比如
Windows
使用的是真线程方案,为TCB
额外设计了一逻辑,这就导致操作系统在同时面临PCB
和TCB
时需要进行识别后切换成不同的处理手段,存在不同的逻辑容易增加系统运行不稳定的风险,这就导致Windows
无法做到长时间运行,需要通过重启来重置风险
此时我的电脑中同时存在几百个进程和几千个真线程,可想而知操作系统的负担有多大
如何验证 Linux
中的线程解决方案? 简单使用一下就好了
接下来简单使用一下 pthread
线程原生库中的线程相关函数(只是简单使用,不涉及其他操作)
#include
#include
#include
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
都是 13039
LWP
各不相同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
10
:虚拟地址中的前 10
个比特位,用于寻址 页表210
:虚拟地址中间的 10
个比特位,用于寻找 页框起始地址12
:虚拟地址中的后 12
个比特位,用于定位 具体地址(偏移量)所以,实际上在通过 页表 进行寻址时,需要用到 两个页表(为了方便演示,仅包含一组 kv
关系):
通常将 “页表1” 称为 页目录,“页表2” 称为 页表项
10
个比特位定位 页表项10
个比特位定位 页框地址12
个比特位,在 页 Page
中进行任意地址的寻址所以即使是每个 物理地址 都被寻址的的极端情况下,页表 总大小不过为:(2^10 + 2^10) * (2^10 + 2^20)
,大约也就需要 4Mb
大小,即可映射至每一个 物理内存,但实际上 物理内存 并不会被时刻占满,大多数情况下都是使用一部分,因此实际 页表 大小不过 几十字节
像这种 页框起始地址+偏移量 的方式称为 基地址+偏移量,是一种运用十分广泛的思想,比如所谓的 类型(int
、double
、char
…)都是通过 类型的起始地址+类型的大小 来标识该变量大小的,也就是说我们只需要 获得变量的起始地址,即可自由进行偏移操作(如果偏移过度了,就是越界),这也就解释了为什么取地址只会取到 起始地址
总结:得益于 划分+偏移 的思想,使得页表的大小可以变得很小
扩展:动态内存管理
实际上,我们在进行 动态内存管理(malloc/new
) 申请堆空间时,操作系统 并没有立即在物理内存中申请空间(因为你申请了可能不会立马使用),而是 先在 虚拟地址 中进行申请(成本很低),当我们实际使用该空间时,操作系统 再去 填充相应的页表信息+申请具体的物理内存
像这种操作系统赌博式的行为我们已经不是第一次见了,比如之前的 写时拷贝,就是在赌你不会修改,这样做的好处就是可以 最大化提高效率,对于内存来说,这种使用时再申请的行为会引发 缺页中断
当用户 动态申请内存 时,操作系统只会在 虚拟地址 中申请,具体表现为 返回一块未被使用的空间起始地址,用户实际使用这块空间时,遵循 查页表、寻址物理内存 的原则,实际进行 查页表 操作时,发现 页表项 没有记录此地址的映射关系,于是就会引发 缺页中断,发出对应的 中断信号,陷入内核态,通过 中断控制器 识别 中断信号 后做出相应的动作,比如这里的动作是:填充页表信息、申请物理内存 ;把 物理内存 准备好后,用户就可以进行正常使用了,整个过程非常快,对于用户来说几乎无感知
同理,在进行 磁盘文件读取 时,也存在 缺页中断 行为,毕竟你打开文件了,并不是立即进行读写操作的
诸如这种 硬件级的中断行为 我们已经在 信号产生 中学过了,即:从键盘按下的那一刻,发出硬件中断信号,中断控制器识别为 键盘 发出的信号后,去 中断向量表 中查找执行方法,也就是 键盘 的读取方法
所以操作系统根本不需要关系 硬件 是什么样子,只需要关心对方是否发出了 信号(请求),并作出相应的 动作(执行方法) 即可,很好的实现了 解耦
对于 内存 的具体情况,诸如:是否命中、是否被占用、对应的 RWX
权限 需要额外的空间对其进行描述,而 页表 中的 其他属性 列就包含了这些信息
对 内存 进行操作时,势必要进行 虚拟地址到物理地址 之间的转换,而 MMU
机制 + 页表信息 可以判断 当前操作 是否合法,如果不合法会报错
注:UK
权限用于区分当前是用户级页表,还是内核级页表
比如这段代码:
char *ps = "Change World!";
*ps = 'N'; // 此时程序会报错(需要赋值为字符,否则无法编译)
结合 页表、信号 等知识,解释整个报错逻辑:
"Change World!"
属于字符常量,存储在字符常量区中,其中的权限为 R
char *ps
属于一个指针变量,指向字符常量的起始地址*ps = "No"
操作时,首先会将字符常量的地址转换为物理地址,在转换过程中,MMU
机制发现该内存权限仅为 R
,但 *ps
操作需要 W
权限,于是 MMU
引发异常 -> 操作系统识别到异常,将该异常转换为 信号 -> 并把 信号 发给出现问题的 进程 -> 信号暂时被保存 -> 在 内核态转为用户态 的过程中,进行 信号处理 -> 最终结果是终止进程,也就是报错程序运行后,就会报错
所以目前 地址空间 的所有组成部分我们都已经打通了,再次回顾这种设计时,会发现 用户压根不知道、也不需要知道虚拟地址空间之后发生的事,只需要正常使用就好了,当引发异常操作时,操作系统能在 查页表 阶段就进行拦截,而不是等到真正影响到 物理内存 时才报错
所谓的 虚拟地址空间 就是在进行设计时添加的一层 软件层,它解决了 多进程时的物理内存访问问题、也解决了物理内存的保护问题,同时还为用户提供了一个简单的虚拟地址空间,做到了 虚拟与物理 的 完美解耦
这种设计思想就是计算机界著名的 所有问题都可以通过添加一层 软件层 解决,这种思想早在几十年前就已经得到了运用
这种分层结构不仅适用于 操作系统,还适用于 网络,比如大名鼎鼎的 OSI
七层网络模型
Linux
中没有 真线程,有的只是复刻 进程 代码和管理逻辑的 轻量级线程(LWP
)
线程 有以下概念:
Thread
),或者说 线程 是一个进程内部的控制程序Linux
系统中,CPU
看到的 线程 TCB
比传统的 进程 PCB
更加轻量化线程 最大的优点就是 轻巧、灵活,更容易进行调度
IO
操作时,程序可以执行其他任务(比如看剧软件中的 “边下边看” 功能)IO
密集型应用,为了提高性能,将 IO
操作重叠,线程可以同时等待资源,进行 高效IO
(比如 文件/网络 的大量 IO
需要,可以通过 多路转接 技术,提高效率)线程 的合理使用可以提高效率,但 线程 不是越多越好,而是 合适 最好,让每一个线程都能参与到计算中
线程 也是有缺点的:
1、性能损失,当 线程 数量过多时,频繁的 线程 调度所造成的消耗会导致 计算密集型应用 无法专心计算,从而造成性能损失
2、 健壮性降低,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的
在下面这个程序中,次线程2 出现异常后,会导致整个进程运行异常,进而终止进程
#include
#include
#include
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
#include
#include
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多线程【初识线程】的全部内容了,在本文中,我们主要学习了 线程 的基本概念,深入理解了地址空间,比如 如何页表进行地址的转换,最后复盘了 线程 的基本概念,学习了其优缺点及使用场景,多线程 是一个十分重要的章节,需要用心学习
相关文章推荐 Linux进程信号 ===== :>
【信号产生】、【信号保存】、【信号处理】Linux进程间通信 ===== :>
【消息队列、信号量】、【共享内存】、【命名管道】、【匿名管道】
Linux基础IO ===== :>
【软硬链接与动静态库】、【深入理解文件系统】、【模拟实现C语言文件流】、【重定向及缓冲区理解】、【文件理解与操作】
Linux进程控制 ===== :>
【简易版bash】、【进程程序替换】、【创建、终止、等待】
Linux进程学习 ===== :>
【进程地址】、【环境变量】、【进程状态】、【基本认知】
Linux基础 ===== :>
【gdb】、【git】、【gcc/g++】、【vim】、Linux 权限理解和学习、听说Linux基础指令很多?这里都帮你总结好了