目录
-
- 一、线程概念
- 二、分页式存储管理
-
- 2.1、重谈地址空间
- 2.2、页和页框
-
- [2.2.1 虚拟地址空间管理------页](#2.2.1 虚拟地址空间管理——页)
- [2.2.2 物理内存管理------页框](#2.2.2 物理内存管理——页框)
- [2.3、深入理解"页表" & "页目录"](#2.3、深入理解“页表” & “页目录”)
- 2.4、两级页表的地址转换
- 三、深刻理解线程
-
- 3.1、线程优缺点
- [3.2、线程 VS 进程](#3.2、线程 VS 进程)
- 四、线程控制
-
- 4.1、POSIX线程库
- 4.2、系统接口介绍
-
- [4.2.1 创建线程](#4.2.1 创建线程)
- [4.2.2 线程终止](#4.2.2 线程终止)
- [4.2.3 线程等待](#4.2.3 线程等待)
- [4.2.4 线程分离](#4.2.4 线程分离)
- 4.3、理解线程创建
- 结语

引入
到这里进程想必大家都再熟悉不过了,那么什么是线程呢?线程和进程有什么关联?有什么区别?
带着这些问题我们正式开始对线程的学习!!!
一、线程概念
1. 教材角度:
进程 = 内核数据结构 + 代码和数据 ;
线程:是进程内部的一个执行分支 。
2. 内核和资源角度:
进程:是承担分配系统资源的实体 ;
线程:CPU调度的基本单位 。
上面的概念有点抽象,我们通过一个例子来理解:同进程线程
对于一个家庭而言,整个家庭就是进程,与其他家庭相互独立。家庭是分配社会资源的实体,其主线任务就是让家庭更加美好,而一个家庭中又有许多的成员,这些成员就类似一个个的执行分支(线程),他们可能同时会扫地,做饭,洗衣服等,这些行动可能并发执行,大大提高了效率。与此同时,家庭中厕所,冰箱,洗衣机等等,所有成员共享,而每个成员的房间,零花钱等等又独自拥有。
在Linux中,每个线程都有自己独立的task_struct,同进程线程共享虚拟地址空间mm_struct。这也是其设计的精妙之处,说白了就是Linux中其实并不真正区分进程与线程,线程 = 共享资源的轻量级进程。

总结几个结论:
1️⃣为什么是轻量级?
创建进程:需要拷贝完整的地址空间等资源,开销大,慢;
创建线程(轻量级进程):只需要创建一个新的task_struct,与父进程共享虚拟地址空间等资源,不需要拷贝,创建快,占用资源少,实现了轻量化。
2️⃣线程如何运行?
进程访问大部分资源,都是通过地址空间来访问的,而所有线程共享地址空间,此时地址空间就是一个共享窗口。
我们需要线程去执行一段代码时,就将这部分资源划分给线程,而代码其实就是虚拟地址的集合(编译时对代码进行平坦模式编址),只需要将函数入口地址交给线程,通过入口地址被线程定位,线程凭 程序计数器(PC) 在共享的代码段中"游走"执行,栈则记录了它的调用路径和局部状态。
3️⃣资源划分:
虚拟地址就代表资源,因为虚拟地址就是访问资源的入口,资源划分本质就是对虚拟地址空间的范围进行划分 。
4️⃣为什么这样设计?
Linux不区分进程与线程,而是用轻量级进程模拟,调度线程就可以完美地复用进程调度的那一套,不再需要额外实现线程调度算法,Linux 内核中只有统一的 task_struct 结构。
5️⃣怎么理解前面的进程呢?
不就是进程内部只有一个线程嘛,单线程进程,只有一个执行流去执行所有的代码,今天变成了多线程进程,可以让多个执行流去执行我们的代码。
二、分页式存储管理
我们希望操作系统提供给用戶的空间必须是连续的,但是物理内存最好不要连续。,所以有了分页式存储管理。
2.1、重谈地址空间
让进程对物理内存的访问从无序变为有序,用户使用虚拟地址,虚拟地址连续,逻辑上访问的是连续有序的物理内存,即用户空间是连续的。
2.2、页和页框
2.2.1 虚拟地址空间管理------页
进程(用户)角度来看,地址空间被划分为栈,共享区,堆区等,但在操作系统管理角度来看,地址空间就是一个一个的页,即一定范围虚拟地址的集合,32位下一个页的大小一般为4KB。栈不就是其中连续的n个页嘛!
虚拟地址又是用户数据的访问入口,那页就成为了一个数据块。
OS通过struct mm_struct中的struct vm_area_struct来管理连续的页。
2.2.2 物理内存管理------页框
我们的代码,数据大小不一,如果对物理内存不加以管理,一旦这些资源被释放,就会导致非常多的内存碎片。因此对物理内存进行分页管理:

把物理内存按照固定大小的页框进行分割,一个页框与一个页的大小相同,页框是物理内存的一个存储区域。
页框:用来存储数据的区域;
页:是一个数据块,可以存储到页框或磁盘。
总结一下,其思想是将虚拟内存下的逻辑地址空间分为若干页,将物理内存空间分为若干页框,通过页表便能把连续的虚拟内存,映射到若干个不连续的物理内存页。这样就解决了使用连续的物理内存造成的碎片问题。
1️⃣我们加载程序到内存
- 编译链接:程序被平坦编址,生成连续的虚拟地址,这些地址自然划分成连续的页(虚拟地址的集合);
- 运行(按需调页):CPU 在虚拟地址上执行,遇到未映射的页触发缺页中断。内核分配页框,从磁盘读取对应代码/数据到页框,建立页(虚拟地址集合)与页框(物理存储)的映射,然后恢复执行;
- 后续访问:同一页内的地址直接通过页表翻译,无需再次缺页。
2️⃣OS管理页框
依旧先描述,再组织!!!
描述页框的内核数据结构为struct page,其中有几个关键的参数:
flags:存放页框的状态;_mapcount:表示在页表中有多少个项(指针)指向该页框,引用计数,当计数值变为-1时,就说明当前内核并没有引用这一页,于是在新的分配中就可以使用它。virtual:页框的虚拟地址。
我们可以算算管理这些页框需要多少个struct page对象,占多少内存:
一个struct page算40个字节,而一个页框4KB,系统有4GB物理内存,就有4GB/4KB =1048576个,所占内存就是1048576*40,大概40MB,这个代价不算大。
如何组织?
全局数组: struct page 类型的mem_map数组:
数组下标就是页框号(PFN),页框大小位4KB,那么页框地址 = PFN*4096。
如何理解申请物理内存?
查mem_map数组,找到未被使用的页框,然后标记flags为已使用。
2.3、深入理解"页表" & "页目录"
这是我们第一次真正学习页表,前面我们只知道页表是完成虚拟地址转换物理地址,但实际要更复杂一点。
32位系统虚拟地址空间最大4GB,可寻址的物理内存最大4GB,实际我们要将数据加载到物理内存,即页框中,就要通过页表找到页框,通过计算我们知道有1048576(2^20)个页框,那么就需要这么多页表项 指向页框。一个指针4字节,1048576*4也就是4MB,32位下一张页表通常是4KB ,与页的大小适配,那么就需要4MB/4KB = 1024张页表。
与此同时,我们还需要一张表来索引页表,即页目录。那么页目录一定有1024个页目录项,每个页目录项对应一张页表。
这其实就是一个二级页表结构 。

现在看来,页表并不是通过将虚拟地址与物理地址都存储下来来完成转换的,而是一种更加巧妙的方式。
2.4、两级页表的地址转换
当我们拿到一个虚拟地址,如何找到对应的物理地址呢?
假设有个虚拟地址(32位):0000 1000 0000 0100 1000 0000 0000 0100
c
二级页表索引:
┌──────────┬──────────┬──────────┐
│ 页目录索引 │ 页表索引 │ 页内偏移 │
│ 10位 │ 10位 │ 12位 │
│ (32) │ (72) │ (4) │
└──────────┴──────────┴──────────┘
OS会将高10位作为页目录索引,中间10位作为页表索引,低12位作为页内偏移。具体什么意思看下图:

🔄两级页表的地址转换完整过程:
通过CR3寄存器找到页目录物理地址,将页目录加载到内存,根据虚拟地址高10位在页目录中索引到页表地址,然后通过中间10位在页表中索引到页框号,最后页框地址加上低12位就得到了真实的物理地址,然后进行单字节访问。
以上过程由硬件------内存管理单元MMU完成,速度很快。
到这里其实还有个问题,MMU要先进行两次页表查询确定物理地址,在确认了权限等问题后,MMU再将这个物理地址发送到总线,内存收到之后开始读取对应地址的数据并返回。
那么当页表变为N级时,就变成了N次检索+1次读写。可见,页表级数越多查询的步骤越多,对于CPU来说等待时间越长,效率越低 。
有没有什么办法提高效率?
添加一个中间层来解决。
MMU引入了新武器,江湖人称快表的TLB(其实,就是缓存,Translation Lookaside Buffer,学名转译后备缓冲器)。当 CPU 给 MMU 传新虚拟地址之后, MMU 先去问 TLB 那边有没有,如果有就直接拿到物理地址发到总线给内存,齐活。
但 TLB 容量比较小,难免发生 Cache Miss ,这时候 MMU 还有保底的老武器页表,在页表中找到之后 MMU 除了把地址发到总线传给内存,还把这条映射关系给到TLB,让它记录一下刷新缓存。

三、深刻理解线程
线程进行资源划分: 本质是划分地址空间,获得一定范围的合法的虚拟地址,再本质,就是在划分页表!!!
线程进行资源共享: 本质就是共享地址空间,再本质,就是对页表项的共享。
3.1、线程优缺点
优点:
- 线程即轻量级进程,OS在进行线程切换时工作量小,不需要像进程那样将页目录,页表,地址空间等资源切换;
- 同一个进程下的线程在切换时,不会扰乱硬件缓存:TLB页表缓存映射,CPU 的Cache缓存,页表本身也在缓存中。而进程切换相当于缓存需要重新开始,效率就会降低;
- 线程占用资源少;
- 线程可以在多处理器上并行,效率非常高;
- 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现;
- I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
缺点:
- 线程共享地址空间中的数据,如果不加以保护,就会出现数据不一致的问题。
- 单个线程出现除零,野指针访问,可能导致整个进程崩溃。
3.2、线程 VS 进程
进程是相互独立的;
线程共享地址空间,即共享资源。
进程是分配系统资源的实体;
线程是CPU调度的基本单位。
💥💥💥什么数据是线程独占的:
- 线程id;
- 寄存器上下文数据(线程是独立调度的);
- 线程有自己的栈(函数调用时创建自己独立的栈帧)。
进程与线程关系图如下:

四、线程控制
4.1、POSIX线程库
• 与线程有关的函数构成了一个完整的系列,绝大多数函数的名字都是以pthread_ 打头的
• 要使用这些函数库,要通过引入头文 <pthread.h>
• 链接这些线程函数库时要使用编译器命令的-lpthread选项
4.2、系统接口介绍
接下来我们从代码层面介绍然后创建和管理一个线程:
4.2.1 创建线程

返回值:返回值:成功返回0;失败返回错误码。
cpp
#include <iostream>
#include <pthread.h>
#include <unistd.h>
void *routine(void *args)
{
std::string name = static_cast<const char *>(args);
while (true)
{
sleep(1);
std::cout << "我是新线程: " << name << std::endl;
}
return nullptr;
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, routine, (void *)"pthread-1");
std::cout << "线程id = " << tid << std::endl;
while (true)
{
sleep(1);
std::cout << "我是主线程" << std::endl;
}
return 0;
}

打印出来的
tid是pthread_t类型,pthread 库层面的抽象。
这个"ID"是 pthread 库给每个线程定义的进程内唯一标识 ,是 pthread 库维持的。由于每个进程有自己独立的内存空间,故此"ID"的作用域是进程级而非系统级(内核不认识)。通过pthread库中的pthread_self方法可以获得:
使用ps命令查看线程信息:
bash
ps -aL | head -1 && ps -aL | grep test
-L 选项:打印线程信息

LWP又是什么呢?LWP(Light Weight Process),即轻量级进程,他才是真正的线程ID,内核用它来标识线程 。可以看到主线程main的LWP与进程pid相同。
通过系统接口gettid可以获得LWP:

4.2.2 线程终止
如果需要只终止某个线程而不终止整个进程,可以有三种方法:
- 从线程函数routine中return;
- 线程调用
pthread_exit终止自身; - 主线程调用
pthread_cancel终止新线程。
c
功能:线程终止
原型:void pthread_exit(void *value_ptr);
参数:value_ptr:value_ptr不要指向一个局部变量。
返回值:无返回值,跟进程一样,线程结束的时候无法返回到它的调用者(自身)
需要注意:pthread_exit或者return返回的指针所指向的内存单元必须是全局的或者是用malloc分配的,不能在线程函数的栈上分配,因为当其它线程得到这个返回指针时线程函数已经退出了。
c
功能:取消一个执行中的线程
原型:int pthread_cancel(pthread_t thread);
参数:thread:线程ID
返回值:成功返回0;失败返回错误码
4.2.3 线程等待
Why?
• 线程退出后,其空间没有被释放,仍然在进程的地址空间内。类似僵尸进程(内存泄漏)。
• 创建新的线程不会复用刚才退出线程的地址空间。
c
功能:等待线程结束
原型int pthread_join(pthread_t thread, void **value_ptr);
参数:thread:线程ID
value_ptr:它指向一个指针,后者指向线程的返回值
返回值:成功返回0;失败返回错误码
调用该函数的线程将挂起等待,直到id为thread的线程终止。thread线程以不同的方法终止,通过pthread_j_join得到的终止状态是不同的,总结如下:
- 如果thread线程通过return返回,value_ ptr所指向的单元里存放的是thread线程函数的返回值。
- 如果thread线程被别的线程调用
pthread_ cancel异常终掉,value_ ptr所指向的单元里存放的是常数PTHREAD_ CANCELED。 - 如果thread线程是自己调用
pthread_exit终止的,value_ptr所指向的单元存放的是传给pthread_exit的参数。 - 如果对thread线程的终止状态不感兴趣,可以传NULL给value_ ptr参数。
c
void *routine(void *arg)
{
printf("thread-1 returning ... \n");
int *p = (int *)malloc(sizeof(int));
*p = 1;
return (void *)p;
}
int main()
{
pthread_t tid;
void *ret;
// 创建线程
pthread_create(&tid, NULL, routine, NULL);
// 等待回收线程
pthread_join(tid, &ret);
printf("thread return, thread id %lx, return code:%d\n", tid, *(int *)ret);
free(ret);
return 0;
}

4.2.4 线程分离
通常我们的线程退出后,想要调用pthread_join来等待回收线程资源,但是如果我们将新线程与主线程分离后,自动释放资源,就不需要再join新线程了。
如果不关心线程退出后的返回值,join是一种负担,就可以将新线程分离。
有两种方法可以分离新线程:
- 主线程分离新线程:
c
int pthread_detach(pthread);
- 新线程主动分离:
c
pthread_detach(pthread_self());
4.3、理解线程创建
线程在创建时有tid和LWP,其中tid是pthread库形成的,是用户层面对形成的标识,用来pthread_join,phread_detch,pthread_cancel...。
LWP,内核对线程的唯一标识,拿着LWP来调度线程(轻量级进程)。
当我们调用pthread_create:
第一步: glibc 在pthread库中为该线程创建一个struct pthread结构体对象,该对象的内容包括:线程id,线程状态,线程局部存储,线程栈,线程栈大小...。
然后,通过 mmap 分配线程栈。
第二步: 主动调用系统调用clone,由int 0x80/syscall发起软中断,陷入内核,执行中断处理方法(copy_process):
- 分配全新的 task_struct(内核 TCB);
- 根据 clone 的参数(CLONE_VM、CLONE_THREAD 等),设置资源共享:共享地址空间、文件描述符表、信号处理函数等;
- 分配 PID 号(即 LWP 号),作为线程唯一标识(内核);
- 创建独立的寄存器上下文(PC、SP、通用寄存器)等。
第三步:
- 由OS将线程放入调度队列,等待 CPU 调度。
- 被调度后,从
glibc的start_thread入口开始执行,完成线程环境初始化,最终调用用户传入的 routine 方法。
pthread_t 到底是什么类型呢?取决于实现。对于Linux目前实现的NPTL实现而言,pthread_t类型的线程ID,本质就是一个进程地址空间上的一个地址。
结语
线程(上)的介绍到此结束,相信大家对线程已经有了深刻的认识,下一期我会接着介绍线程(下) ,我会持续更新,希望你能够多多关注,如果本文有帮助到你,还请三连加关注,你的支持,就是我创作的最大动力!

