目录
[一 线程概念](#一 线程概念)
[1 什么是线程](#1 什么是线程)
[2 Linux中线程的一种实现方法](#2 Linux中线程的一种实现方法)
[3 执行流资源划分的本质](#3 执行流资源划分的本质)
[4 线程的优点](#4 线程的优点)
[5 线程的缺点](#5 线程的缺点)
[6 线程异常](#6 线程异常)
[7 代码示例](#7 代码示例)
[二 分页式储存管理](#二 分页式储存管理)
[1 页表结构](#1 页表结构)
[2 虚拟地址和物理地址的映射](#2 虚拟地址和物理地址的映射)
[3 进程与线程关系](#3 进程与线程关系)
[4 Linux系统内,是否真的存在线程控制块结构(TCB)?](#4 Linux系统内,是否真的存在线程控制块结构(TCB)?)
一 线程概念
1 什么是线程
进程=内核数据结构+自己的代码和数据
在内核视角:进程是承担分配系统资源的基本单位
线程是进程内部的一个执行分支;线程的粒度比进程细,小,更轻一点
2 Linux中线程的一种实现方法
线程也需要被操作系统管理--->先描述,再组织
struct TCB
{
//.....
};
Linux没有设计内核级的TCB,而是在进程内创建线程时,在当前进程的内部创建PCB,把PCB指向和父进程一样的地址空间,把代码区分割出一部分;此时,这个PCB叫做线程
task_struct<=进程,把task_struct叫做轻量级进程
轻量级进程(线程)VS进程
我们以前讲的是特殊的进程,是单执行流进程,只有一个PCB;但是我们今天的进程是多执行流进程,,有一个或多个PCB
整个社会分配资源,是以什么为基本单位/载体的?
家庭!在一个家庭里,会有各种成员,每个人都各自做各自的事情,但是大家都在完成一个共同的目标:把日子过好,这也是社会给的任务。我们把家庭及内部成员合起来,叫做进程;每一个家庭成员,叫做线程 我们以前学过的进程--->一个家庭里只有一个人
线程不需要创建地址空间....,只需要把资源分配给PCB,和进程创建的方法不一样
3 执行流资源划分的本质
在 Linux 系统中,物理内存的最小管理单位是 页框(Page Frame,也叫页帧)
页框(物理单位):一块4KB 大小的连续物理内存块,是物理内存的基本分配单元 。
页(逻辑单位):一个 4KB 大小的数据块,是进程虚拟地址空间的基本映射单元
1GB = 1024MB = 1024 * 1024KB,因此 1GB 物理内存对应 1024*1024 / 4 = 262144 个 4KB 页框
物理内存中的每个页框都有独立的状态 :
空闲(未分配)、已占用(分配给进程 / 内核)、被锁定(不允许换出)、文件缓存、系统文件、内核缓冲区等
操作系统需要直到内存块的使用情况,操作系统需要对多个数据页框进程管理--->先描述,再组织
内核为每一个 4KB 页框,都创建了一个struct page 结构体实例,用来完整描述这个页框的所有属性
cpp
// 简化版 struct page 结构(内核真实定义更复杂)
struct page {
unsigned long flags; // 页框状态标志位(如是否空闲、是否被锁定、是否为脏页等)
atomic_t _count; // 引用计数(记录有多少个地方在使用这个页框)
struct address_space *mapping; // 页框对应的文件/地址空间(用于文件缓存)
void *virtual; // 页框对应的内核虚拟地址
// ... 其他成员(如反向映射、内存节点信息等)
};
结论1:操作系统管理物理内存,是以4KB为单位的
操作系统管理page是用全局数组;内核会创建一个全局数组 mem_map数组的每一个元素,都是一个 struct page ,对应物理内存中的一个页框
数组下标 = 页框号,通过下标可以直接定位到对应页框的描述符
只有知道物理地址,才能真正的访问它
下标能转化成物理地址吗?
下标左移12位:index<<12或者下标乘4KB:index*4KB
每一个内存块都是4KB对齐的,那么低几位是全0
物理地址转下标:物理地址右移12位
当得到物理内存中任意一个地址:(1)属于哪一个页框?(2)对应struct page 怎么找?本质是如何对应数组下标的?
(1)任意地址&&0XFFFF F000:低12位清0,高20位保留
(2)右移12位
结论2:数组下标,页框地址,任意地址之间可以相互转换
结论3:无论是进程,文件,还是其他模块,要向物理内存申请内存的过程,本质就是申请struct page结构体下标的过程
数组下标可以和物理地址映射
4 线程的优点
从执行流角度,进程和线程都是向CPU派发任务,进程做的事线程也能做
线程太多,会增加CPU调度和切换的成本
线程切换的成本,比进程切换的成本要低得多
在CPU内部存在cache,可以把cache理解为CPU内提供的一段缓冲区,具有临时缓存数据的能力,往往CPU读取某个数据块时,会把这一块附近所有内容全部加载到cache中,此时CPU访问后续代码时,就可以直接向cache中访问,不用再访问内存;这种特点,我们称为局部性原理:局部性原理指程序在指向的时候倾向于频繁的访问最近访问的数据和临近地址的数据
磁盘文件,提前加载到内存就是利用局部性原理
正是因为有了局部性原理,预加载才能称为有效的优化手段,内存才有意义
cache永远缓存的是当前进程的数据
线程的大部分资源都是共享的
线程切换不会导致cache中的代码和数据失效,但是进程会导致失效;所以线程切换比进程切换更轻量
线程占用的资源要比进程少
能充分利用多处理器的可并行数量
在等待慢速 I/O 操作结束的同时,程序可执行其他的计算任务
计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
I/O 密集型应用,为了提高性能,将 I/O 操作重叠。线程可以同时等待不同的 I/O 操作。
与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
最主要的区别是线程的切换虚拟内存空间依然是相同的,但是进程切换是不同的 。这两种上下文切换的处理都是通过操作系统内核来完成的。内核的这种切换过程伴随的最显著的性能损耗是将寄存器中的内容切换出。
另外一个隐藏的损耗是上下文的切换会扰乱处理器的缓存机制。简单的说,一旦去切换上下文,处理器中所有已经缓存的内存地址一瞬间都作废了。还有一个显著的区别是当你改变虚拟内存空间的时候,处理的页表缓冲 TLB(快表)会被全部刷新,这将导致内存的访问在一段时间内相当的低效。但是在线程的切换中,不会出现这个问题,当然还有硬件 cache
线程也有不共享的数据!
线程必须独立调度!必须有硬件上下文
线程必然有自己独立的栈
5 线程的缺点
- 性能损失
- 一个很少被外部事件阻塞的计算密集型线程往往无法与其它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。
- 健壮性降低
- 编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。
- 缺乏访问控制
- 进程是访问控制的基本粒度,在一个线程中调用某些 OS 函数会对整个进程造成影响。
- 编程难度提高
- 编写与调试一个多线程程序比单线程程序困难得多
6 线程异常
单个线程如果出现除零、野指针问题导致线程崩溃,进程也会随着崩溃。
线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出。
健壮性降低:一个线程异常(本质是收到操作系统发送的信号),全进程内部的线程遭殃,整个进程出问题
信号是以进程为载体发送给了所有线程的PCB的pending位图
7 代码示例
线程内部大部分资源都是共享的;多线程共享全局变量:因为线程共享整个虚拟地址空间,如果用同一个全局变量(就是同一个虚拟空间),每个线程通过页表访问到的是物理内存同一个位置
cpp
#include <iostream>
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
int g_val = 100;
void *thread_run(void *args)
{
const char *name = (const char *)args;
while (true)
{
printf("%s: g_val = %d, &g_val = %p\n", name, g_val, &g_val);
g_val++;
sleep(1);
}
}
int main()
{
pthread_t t1, t2;
pthread_create(&t1, nullptr, thread_run, (void *)"thread-1");
pthread_create(&t2, nullptr, thread_run, (void *)"thread-2");
while (true) sleep(1);
return 0;
}
多线程对代码区也是共享的
cpp
#include <iostream>
#include <stdio.h>
#include <string>
#include <unistd.h>
#include <pthread.h>
void hello(const std::string &name)
{
printf("hello from %s\n", name.c_str());
sleep(1);
}
void *thread_run(void *args)
{
const char *name = (const char *)args;
while (true)
{
hello(name);
}
}
int main()
{
pthread_t t1, t2;
pthread_create(&t1, nullptr, thread_run, (void *)"thread-1");
pthread_create(&t2, nullptr, thread_run, (void *)"thread-2");
while (true) sleep(1);
return 0;
}
线程1在执行hello的时候,线程2也能进入吗?
多个执行流进入同一个函数,叫做函数被重入,如果重入时没有出错,就叫做可重入函数,否则就叫做不可重入函数
printf函数不可重入
在线程malloc开辟一段空间,线程2能看到吗?
原则上时看不到的;因为线程2不知道堆区的起始地址,如果把堆改成全局变量,就能看到了;
结论:Linux中一个线程的内部,多线程几乎可以共享地址空间的所有内容。所以多线程更强调地址共享;因此两个线程想传递共享信息,只需定义一个全局容器
二 分页式储存管理
页表的真实面貌不是你想的样子
页表需要虚拟地址到物理地址做映射,如果每一个地址都要在页表中映射,意味着页表的一个条目数,需要10个字节慢慢虚拟地址到物理地址的过程,页表就需要2^32个,空间就太大了
1 页表结构
物理内存都是一个一个4KB的页框结构构成的数据块
在32位平台下,任意一个虚拟地址,核心点就是32个比特位,就能表示任意一个虚拟地址,虚拟地址会被系统识别为几个区域:前10个比特位为一个区域,中间10个为一个区域,最后12个为一个区域
创建进程、初始化地址空间时,系统会在物理内存中申请一个 4KB 的空间作为页目录 。
页目录以 4 字节为一个表项,因此总共有 4KB / 4B = 1024 个表项
页目录的索引由虚拟地址的前 10 位决定,10 位刚好可以表示 0~1023 的索引范围
页目录的每一个表项,都指向另一个 4KB 大小的内存块,这个块就是页表。每个页表同样以 4 字节为一个表项,因此也包含 1024 个表项;页表的索引由虚拟地址的中间 10 位决定,同样对应 0~1023 的索引范围;页表项中存储的是物理内存中对应页框的起始地址(必须指向合法页框);相当于,页目录的每一项,指向的是下一个页表的起始地址
2 虚拟地址和物理地址的映射
用虚拟地址的前十位,缩映页目录,确认你在哪一个页目录;中间10位,缩映下一个页表,页表里保留的是特定页框的起始地址(可以随便填写,但是必须指向页框),所以虚拟地址的前二十位就可以缩映到物理内存上的页框

页目录:一级,页表:二级。连起来,我们叫做32位平台下的2级页表(64位平台下是4级页表)
但是虚拟地址指向的是具体一个字节的地址,现在该怎么办?
用对应物理页框的起始地址+虚拟地址的低12位(页表偏移))= 具体字节的物理地址
页表映射,只会映射到对应的页框
32 位系统的二级页表,本质是通过两级索引 + 页内偏移的方式,将虚拟地址空间映射到物理内存
任何一个进程,必定会有页目录,但是页目录对应的1024个页表不一定全用到
细节:统一编址的本质是连续编址,连续编址的本质是把数据和地址放在一起,在一个页框里
页表从虚拟地址映射到物理地址,本质就是进行页框级别映射,没有达到字节级别;所以进程申请内存的单位,必须是4KB为单位
物理内存任何一个页框对应物理地址的低12位,全为0,只保护高20位(就是数组下标);用20个比特位就能映射到物理页框,剩下12位用来充当页表映射页框时所对应的权限标志位,所以查页表时,我们就能动态查看有没有权限
注意区分虚拟地址和物理内存中32位表示的含义不同:
| 对比维度 | 虚拟地址(VA) | 物理地址(PA) |
|---|---|---|
| 所属空间 | 进程独立的虚拟地址空间(每个进程 1 份) | 全局共享的物理内存空间(整个系统 1 份) |
| 总位数 | 32 位(固定) | 32 位(32 位系统下) |
| 结构拆分 | 10 位页目录索引 + 10 位页表索引 + 12 位页内偏移 | 20 位物理页框号 + 12 位页内偏移 |
| 作用 | 给进程用的「逻辑地址」,实现内存隔离、按需分配 | 给硬件用的「真实地址」,对应实际内存条的存储单元 |
| 映射关系 | 多对一:多个虚拟地址可映射到同一个物理页框(共享内存) | 一对一:一个物理页框对应唯一的物理地址 |
| 是否真实存在 | 逻辑上的,不对应实际硬件存储 | 真实存在的,对应内存条的物理单元 |
虚拟地址 → 页目录 → 页表 → 页框(物理内存)
整个过程就是:
查两次表,找到真正的物理内存位置
从虚拟地址到物理地址这个过程实际是由MMU这个硬件做的
我们有没有提高查询效率的办法?有! MMU引入了TLB:TLB是虚拟地址到物理地址的缓存
当访问完一个虚拟地址到物理地址的映射,不代表以后就不会访问这个,TLB就是把访问最常见,最频繁的映射条目缓存起来,提高查表效率
当 CPU 给 MMU 传新虚拟地址之后,MMU 先去问 TLB 那边有没有,如果有就直接拿到物理地址发到总线给内存,齐活。 但 TLB 容量比较小,难免发生 Cache Miss,这时候 MMU 还有保底的老武器------页表,在页表中找到之后 MMU 除了把地址发到总线传给内存,还把这条映射关系给到 TLB,让它记录一下刷新缓存

3 进程与线程关系
对于一个进程而言:进程拥有多少资源,取决于这个进程拥有多少个有效虚拟地址空间;有效的数量越多,虚拟地址映射到物理内存条目对于物理内存越多
把一个进程的资源进行划分,本质是:把这个进程相关的有效虚拟地址进行划分,划分页表所映射的页框
pthread_create:创建一个进程
返回值:成功返回0,失败返回失败原因
cpp
#include <pthread.h>
int pthread_create(pthread_t *restrict thread,
const pthread_attr_t *restrict attr,
void *(*start_routine)(void *),
void *restrict arg);
参数1:创建成功线程对应ID
参数2:线程属性,基本设置为NULL
参数3:函数指针,线程创建成功时,传入一个入口函数;线程启动后从此函数开始执行,返回值为线程退出码
参数4:函数的参数
当线程创建成功时,会分成主线程和新线程两个线程;主线程继续向下执行之前的代码,新线程执行传入的函数
代码示例:
cpp
#include <iostream>
#include <pthread.h>
#include <unistd.h>
void *hello(void *args) {
const char *name = (const char*)args;
while(true) {
std::cout << "我是新线程..., pid: " << getpid()
<< " name: " << name << std::endl;
sleep(1);
}
}
int main() {
pthread_t tid;
pthread_create(&tid, nullptr, hello, (void*)"new-thread");
while(true) {
std::cout << "我是主线程..., pid: " << getpid() << std::endl;
sleep(1);
}
return 0;
}
运行结果:
cpp
我是主线程..., pid: 226021
我是新线程..., pid: 226021
我们发现:Linux 中,同一个进程内的所有线程,共享同一个 PID!
但是单执行流不能进行两个死循环,所以一定是有多执行流的
多线程属于同一个进程--->一个进程内部可以存在两个执行流
一个选项: ps-aL 查看线程(轻量级进程)
cppwhb@iv-ye4ege8iyo5i3z3clix9:~/code/118linux/code/lesson39$ ps -aL PID LWP TTY TIME CMD 226021 226021 pts/1 00:00:00 testthread 226021 226022 pts/1 00:00:00 testthreadLWP : 轻量级进程 ID(线程 ID,Linux 内核调度的基本单位,每个线程唯一)
在一个进程内,有两个LWP(轻量级线程)
在CPU视角,和操作系统调度时,基本单位是线程(轻量级进程);进程是用来承担分配系统资源的基本实体
所以,PID只所谓进程级别的标识,而LWP作为看ID的单位,已经做了系统的划分
4 Linux系统内,是否真的存在线程控制块结构(TCB)?
**没有!**是用PCB模拟线程,也就是轻量级进程;所以提供创建轻量级进程的创建接口-->clone,没有创建线程的接口!
现在我们把线程和轻量级进程分开。但是轻量级进程的特性和线程是完全一样的,所以Linux会用轻量级进程代替线程(复用代码)
线程在操作系统层面上就不存在,Linux在应用层面,把轻量级进程的删除,创建......等操作封装一下,对上层显示就是线程
系统调用之上,会封装一层软件层,叫做pthread库(原生线程库),所以在用户层面上,就可以使用线程
所以我们把线程又叫做用户级线程,对应内核中的LWP;所以我们学习线程,都是属于pthread库往上的
C++中,也支持线程
例如:
cpp
#include <iostream>
#include <thread>
#include <unistd.h>
void hello()
{
while (true)
{
std::cout << "我是新线程..., pid: " << getpid() << std::endl;
sleep(1);
}
}
int main()
{
std::thread t(hello);
while (true)
{
std::cout << "我是主线程..., pid: " << getpid() << std::endl;
sleep(1);
}
t.join();
return 0;
}
C++的线程,和我们说的prhead库的关系--->C++的线程库是对pthread库的封装
在Windows中,是存在真线程的,存在TCB;但是这种线程的操作麻烦,可维护性差
