概念
在一个程序里的执行路线就是线程,线程是“一个进程的内部序列”。
一切进程至少有一个线程
线程在进程的内部运行,本质是在进程地址空间运行
每一个线程都是一个执行流,都是共享内存地址,每一个执行流都对应一个函数,它们都各自独立但是又各自共享,在Pthread动态库中又会有各自的栈空间。
线程优缺点
前提
前提:线程是进程内的执行流,同一个进程下多个线程共享进程资源(地址空间、文件描述符、信号处理、全局变量等),线程自己只有独立:栈、寄存器、线程局部存储 TLS。
优点
- 上下文切换开销远小于进程进程切换要换页表(CR3)、刷新 TLB,切换成本高;同一进程内线程切换,不需要切换地址空间,不用刷 TLB,只保存 / 恢复寄存器、栈,速度更快。
- 通信简单高效同进程线程共享地址空间,直接读写全局变量、堆内存就能通信,不用管道、socket、共享内存 + 信号量这类跨进程通信 IPC,数据传递快。
- 资源占用小创建线程不用单独分配虚拟地址空间,内核开销低,创建销毁比进程快。
- 充分利用多核 CPU可以把任务拆分并行执行,发挥多核算力(IO 密集型、计算密集型都适合,注意 GIL 问题:Python 线程除外)。
- 程序响应性好主线程负责交互,子线程处理耗时 IO / 计算,避免阻塞主流程。
缺点
- 共享资源带来并发安全问题共享数据容易出现竞态条件,必须加锁(互斥锁、条件变量等);锁使用不当会引发死锁、活锁、锁饥饿。
- 一个线程崩溃,整个进程直接崩溃线程共享进程地址空间,一个线程非法访问内存触发段错误 SIGSEGV,整个进程所有线程一起挂掉;进程之间互相隔离,一个进程崩了不影响别的进程。
- 调试、排错难度更大多线程时序不确定,bug 大多是偶现(竞态),很难复现;栈、调用关系更复杂。
- 锁带来性能损耗大量锁竞争会造成线程频繁阻塞、上下文切换变多,反而性能下降;锁粒度不好控制。
- 信号处理复杂信号是发给进程的,信号到达后内核随机选一个线程去处理,多线程下信号处理容易混乱。
- 部分语言有限制(Python GIL)Python 同一时刻只有一个线程执行 CPU 计算,多线程无法利用多核做计算密集任务,只适合 IO 密集。
进程VS线程
• 进程间具有独⽴性
• 线程共享地址空间,也就共享进程资
• 进程是资源分配的基本单位
• 线程是调度的基本单位
• 线程共享进程数据,但也拥有⾃⼰的⼀部分"私有"数据:
◦ 线程ID ◦ ⼀组寄存器,线程的上下⽂数据 ◦ 栈 ◦ errno ◦ 信号屏蔽字 ◦ 调度优先级
同⼀地址空间,因此 Text Segment 、 Data Segment 都是共享的,如果定义⼀个函数,在各线程中 都可以调⽤,如果定义⼀个全局变量,在各线程中都可以访问到,除此之外,各线程还共享以下进程资源和 环境:
• ⽂件描述符表
• 每种信号的处理⽅式(SIG_IGN、SIG_DFL或者⾃定义的信号处理函数)
• 当前⼯作⽬录
• ⽤⼾id和组id
页表
物理内存
把物理内存划分为固定大小的页帧(page frame),用数据结构描述每一页,再通过伙伴系统、slab、页分配器等完成分配回收,同时区分可用内存、保留内存、内核占用、DMA 区域等。
基础单位:页帧(Page Frame),默认 4KB;也支持大页 2M/1G(HugePage)。
核心数据结构
struct page
一个物理页帧对应一个struct page实例,存放在内核数据段,不是在该物理页里面。 主要成员:
flags:页状态标志(PG_locked、PG_dirty、PG_reserved、PG_swapcache 等)_refcount:引用计数,判断页是否还在被使用mapping:指向该页所属的地址空间(inode / 匿名页)index:在地址空间内的偏移lru:链表节点,挂到 LRU 链表,供页面回收(kswapd)private:私有数据,slab、buffer_head 等使用
所有
struct page放在一个大数组里:mem_map[]。早期是一维数组;内存很大、NUMA 架构下按 node 组织。
flags :⽤来存放⻚的状态。这些状态包括⻚是不是脏的,是不是被锁定在内存中等。flag的 每⼀位单独表⽰⼀种状态,所以它⾄少可以同时表⽰出32种不同的状态。这些标志定义在 中。其中⼀些⽐特位⾮常重要,如PG_locked⽤于指定⻚是否锁定, PG_uptodate⽤于表⽰⻚的数据已经从块设备读取并且没有出现错误。
_mapcount :表⽰在⻚表中有多少项指向该⻚,也就是这⼀⻚被引⽤了多少次。当计数值变 为-1时,就说明当前内核并没有引⽤这⼀⻚,于是在新的分配中就可以使⽤它。
virtual :是⻚的虚拟地址。通常情况下,它就是⻚在虚拟内存中的地址。有些内存(即所谓 的⾼端内存)并不永久地映射到内核地址空间上。在这种情况下,这个域的值为NULL,需要的 时候,必须动态地映射这些⻚
页表结构
Linux 把物理内存切成物理页帧(page frame,由struct page描述),由伙伴系统、SLUB 管理物理页的分配和回收。
CPU、用户进程不会直接使用物理地址,进程看到的都是虚拟地址;MMU 负责把虚拟地址翻译成物理地址,翻译的依据就是【页表】。
- 内存管理子系统负责找 / 回收物理页(
struct page); - 页表负责建立「虚拟页 ↔ 物理页帧」的映射关系;
- 缺页异常就是:查页表发现映射不存在 → 去物理内存分配器申请物理页,再填充页表。
硬件视角:MMU + 页表
开启分页后,CPU 访问地址时:
- 送出虚拟地址给 MMU;
- MMU 根据 CR3 寄存器(x86)拿到当前进程页表的物理基地址;
- 拆分虚拟地址为多级索引,逐级遍历页表:PGD → P4D → PUD → PMD → PTE;
- PTE 中取出物理页帧号,拼接页内偏移,得到最终物理地址;
- 同时检查权限(可读 / 可写 / 执行、用户态 / 内核态),非法则触发缺页 / 保护异常。
✅ 重点:CR3 存的是页表根目录的物理地址,所以页表本身也是放在物理内存里的,页表占用的物理页同样会被
struct page跟踪、被伙伴系统分配释放。 切换进程 = 切换 CR3,也就是切换一套页表,实现进程地址空间隔离。
两级⻚表的地址转换
将逻辑地址( 0000000000,0000000001,11111111111 )转换为物理地址的过程
1. 在32位处理器中,采⽤4KB的⻚⼤⼩,则虚拟地址中低12位为⻚偏移,剩下⾼20位给⻚表,分成 两级,每个级别占10个bit(10+10)。
2. CR3 寄存器 读取⻚⽬录起始地址,再根据⼀级⻚号查⻚⽬录表,找到下⼀级⻚表在物理内存中 存放位置。
3. 根据⼆级⻚号查表,找到最终想要访问的内存块号。
4. 结合⻚内偏移量得到物理地址。
5. 注:⼀个物理⻚的地址⼀定是 ⻚地址的⾼20位即可。 4KB 对⻬的(最后的 12 位全部为 0 ),所以其实只需要记录物理
6. 以上其实就是MMU的⼯作流程。MMU(MemoryManageUnit)是⼀种硬件电路,其速度很快, 主要⼯作是进⾏内存管理,地址转换只是它承接的业务之⼀
| 段 | 位数 | 作用 | |||
| 31~22 | 高 10bit | PDE:页目录项索引(Page Directory Entry) | |||
| 21~12 | 中间 10bit | PTE:页表项索引(Page Table Entry) | |||
| 11~0 | 低 12bit | 页内偏移,不变,直接拼到物理页号后面 | |||
找步骤(一步一步)
- CR3 = 页目录的物理起始地址
- 取出虚拟地址高 10 位:页目录索引
- 页目录里每一项 PDE 占 4 字节
- PDE 物理地址 =
CR3 + 索引 × 4
- 读这个 PDE:
- PDE 高 20bit =页表的物理基地址;低位是属性位(P、R/W、U/S 等)
- 取出虚拟地址中间 10 位:页表索引
- PTE 物理地址 =
PDE的页表基地址 + 索引 × 4
- PTE 物理地址 =
- 读 PTE:
- PTE 高 20bit =物理页帧基地址(PFN)
- 拼接:
- 最终物理地址 = PTE 的 20bit 页帧号 + 虚拟地址低 12bit 偏移
举个直观例子
虚拟地址:0x12345678
- 32bit 拆分:
- 高 10bit:页目录索引
- 中间 10bit:页表索引
- 低 12bit:
0x678(偏移)
- CR3 拿到页目录物理基址,用高 10bit 找到 PDE → 得到页表物理基址
- 用中间 10bit 在页表中找到 PTE → 得到物理页帧的 20bit 基址
- 把 20bit 页帧号 左移 12 位,或直接拼接低 12bit 偏移
0x678,得到物理地址
TLB
TLB 是 MMU 配套的高速缓存,缓存虚拟页到物理页的映射;MMU 收到虚拟地址优先查询 TLB,命中则直接完成地址转换;未命中则访问内存中的页表查询映射,并把新映射回填 TLB;修改页表映射时需要刷新 TLB,防止使用失效映射。
- First:TLB 命中(hit)✅TLB 里面找到了这个虚拟页对应的映射记录 → 直接读出物理页帧号,拼接页内偏移得到物理地址,不用去内存遍历页表,速度很快。
- Second:TLB 不命中(miss)❌TLB 没有这条映射 → MMU 去内存中遍历页目录 + 页表(多级页表),查到 PTE,拿到物理页框 → 把这次新查到的【虚拟页→物理页】映射存入 TLB(图上虚线:刷新缓存,填充 TLB),方便下次访问同虚拟页时直接命中
注意:TLB 缓存的是映射结果,不存完整页表,不存页面数据
低 12 位【页内偏移】,虚拟地址和物理地址共用同一套,直接原样照搬,不需要转换、不需要查表!
前提
页大小 = 4KB = \(2^{12}\),所以一个页里面的地址用 12 位就足够表示(0 ~ 4095)。 不管是虚拟页,还是它映射过去的物理页,页内部的编号方式完全一样。
32 位二级分页回顾
虚拟地址:[10bit 页目录索引][10bit 页表索引][12bit 页内偏移]
- 高 20 位:用来查表,找到【物理页帧的起始地址】(物理页基址,是 4K 对齐的!末尾 12bit 一定是 0)
- 低 12 位:偏移 offset
PTE 里面存的是:物理页帧的高 20 位(PFN,页帧号)
物理页帧基地址 = PFN << 12 这个基地址,是这个物理页的第一个字节地址,它的二进制最后 12 位全是 0
拼接过程
最终物理地址 =(PFN 左移 12 位) | 虚拟地址的低 12 位偏移
举二进制直观例子: 假设: PFN(20bit):1010 0000 0000 0000 0011虚拟地址低 12 位偏移:0000 0110 0111
- PFN 左移 12 位:后面补 12 个 0
1010 0000 0000 0000 0011 0000 0000 0000 - 和偏移做按位或(|),把 12 位填进去:
1010 0000 0000 0000 0011 0000 0110 0111👉 这就是完整 32 位物理地址
回答你的问题:物理地址那边的 12 位是怎么来的?
物理地址的低 12 位,直接复制虚拟地址的低 12 位。
- 虚拟地址的 12 位:代表我想要访问【虚拟页里面的第多少个字节】
- 映射之后,等价于访问【物理页里面的第多少个字节】 同一个页内偏移,对应页内同一个位置。
数字例子
- 物理页帧基址:
0x2000000(二进制末尾 12bit=0,4K 对齐) - 虚拟地址偏移:
0x678 - 物理地址 =
0x2000000 + 0x678 = 0x2000678
✅ 本质:页是一块连续 4K 空间,页基址定位整块页,偏移定位页内字节。偏移只在页内部生效,MMU 只转换【页号(高 20 位)】,不碰偏移。
缺页异常
CPU 根据虚拟地址查页表,发现 PTE 的 Present 位 (P)=0,或者权限非法,就触发缺页异常,陷入内核,由内核的缺页异常处理函数来修复映射。
一、什么时候会触发缺页异常
两大类:
- 有效缺页(正常,需要内核补映射)
- 匿名缺页:进程堆 / 栈,已经分配了 VMA(虚拟地址区间),但还没分配物理页、没填 PTE(malloc 只拿虚拟地址,不拿物理内存)
- 文件缺页:访问文件映射区域,内容还在磁盘,不在 page cache 内存里
- 换入缺页:匿名页之前被 kswapd 换到 swap 分区,PTE 标记了 swap 位置,不在内存
- 无效缺页(非法访问,报错)
- 虚拟地址没有对应的 VMA(乱访问野指针)
- 权限不匹配:比如对只读 VMA 执行写操作、内核地址被用户态访问
二、32 位 x86 下,缺页进入内核后的基础流程do_page_fault
- CPU 触发 #PF,保存现场,进入内核异常入口;同时 CPU 会把出错的虚拟地址放进 CR2 寄存器(非常关键!CR2 = 引发异常的那个虚拟地址)
- 内核拿到 CR2 里的虚拟地址,在当前进程的 VMA 链表(红黑树)查找:这个虚拟地址有没有合法的
vm_area_struct- ✅ 找不到 VMA → 非法访问,发送 SIGSEGV,进程崩溃
- ✅ 找到 VMA,再判断访问类型(读 / 写 / 执行)和 VMA 权限是否匹配:权限不符,同样段错误
- 合法的缺页,区分类型处理:
- 文件缺页(文件映射 /mmap 普通文件)根据虚拟地址算出文件内 offset → 找到 inode 的 address_space → xarray(page cache)查找 page
- 没找到 page:分配物理页框,发起磁盘 IO,把磁盘数据读到这个物理页,page 加入 page cache,填充 PTE,标记 P=1
- 匿名缺页(堆、栈)调用内存分配器拿到物理页框,清零页(COW 例外),建立 PTE 映射
- Swap 缺页PTE 里面存放的是 swap 设备 + slot 位置,从 swap 分区把页面读回物理内存,重填 PTE
- 写时复制 COW 缺页(重点!fork)fork 之后父子共享同一张物理页,PTE 标记为只读;任意一方写的时候触发写缺页。内核分配新物理页,复制旧页内容,再把新页映射给写的进程。
- 文件缺页(文件映射 /mmap 普通文件)根据虚拟地址算出文件内 offset → 找到 inode 的 address_space → xarray(page cache)查找 page
- 处理完成、PTE 已经填好有效映射后: 从异常返回用户态,重新执行刚才触发异常的那条指令。这次再访问虚拟地址,MMU 查表 P 位为 1,直接翻译成功,不再报错。
三、和前面页表、偏移联动回顾(串起来)
虚拟地址拆分:10bit PDE +10bit PTE +12bit 偏移 MMU 遍历页目录、页表找到 PTE:
- P=1:直接拿 PFN,拼接 12 位偏移得到物理地址,正常访问
- P=0:触发缺页异常 → 内核分配 / 读取物理页,修改 PTE(填入 PFN,P 位置 1)→ 返回重试
四、PTE 里 P=0 的时候,PTE 不是全没用
P 位为 0 时,硬件不再解读 PFN;Linux 内核会复用 PTE 剩下的 bit 做标记:
- 标记这是 swap 页:记录 swap 分区号、swap 槽位
- 标记这是文件页:记录文件偏移信息
五、容易混淆的点
- CR2:保存出错虚拟地址,是缺页处理的输入
- CR3:页目录物理基址,用来遍历页表
- 缺页异常只发生在MMU 查表阶段,页内偏移不参与缺页判断
- 缺页分:主缺页(需要磁盘 IO,慢)、次缺页(page cache 里已经有页,只是没挂 PTE,不用读盘,快)
线程控制
创建线程
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void *), void *arg);参数:
thread:输出参数,拿到线程 ID(pthread_t)attr:线程属性,NULL 使用默认属性(joinable)start_routine:线程入口函数,返回void*,参数void*arg:传给线程函数的参数
返回值:成功返回 0;失败返回错误号(不是 - 1!)
✅ 要点:
- pthread_t 是库层面的线程 ID,不是内核 TID;内核 TID 由 gettid 获取
- 默认属性:joinable(可被等待)
- 主线程退出(main return),整个进程退出,所有线程直接被杀掉
#include<iostream> #include<unistd.h> #include<string> #include<cstdio> #include<thread> void *routine(void *args) { std::string name=static_cast<const char *>(args); while(true) { std::cout<<"我是新线程,我的名字是:"<<name<<std::endl; sleep(1); } return 0; } int main() { pthread_t tid; int n=pthread_create(&tid,nullptr,routine,(void*)"thread-1"); while(true) { std::cout<<"我是main线程..."<<std::endl; sleep(1); } return 0; }线程终止
方式 1:线程函数 return(推荐)
线程函数执行return (void*)retval;,线程正常退出,返回值可以被pthread_join拿到。
⚠️主线程 return,进程退出,所有线程销毁!
方式 2:pthread_exit
void pthread_exit(void *retval);- 在线程内部调用,主动终止自己
retval:退出返回值,给 join 接收- 主线程调用 pthread_exit,主线程自己退出,但其他子线程可以继续跑!(和 return 最大区别)
- 注意:不要在线程栈上存 retval,栈退出就释放了,要用全局 / 堆
#include <stdio.h> #include <pthread.h> #include <unistd.h> void *thread_func(void *arg) { while(1) { printf("子线程运行中...\n"); sleep(1); // sleep 是取消点 } return NULL; } int main(void) { pthread_t tid; pthread_create(&tid, NULL, thread_func, NULL); sleep(3); printf("主线程发起取消请求\n"); pthread_cancel(tid); pthread_join(tid, NULL); printf("子线程已经被取消\n"); return 0; }方式 3:被其他线程取消 pthread_cancel
int pthread_cancel(pthread_t thread);发给目标线程取消请求;线程不是立刻退出,要到取消点才退出(如 sleep、read 等阻塞函数)
❌ 禁止:在别的线程里调用 exit (),exit 直接结束整个进程
#include <stdio.h> #include <pthread.h> #include <unistd.h> void *thread_func(void *arg) { while(1) { printf("子线程运行中...\n"); sleep(1); // sleep 是取消点 } return NULL; } int main(void) { pthread_t tid; pthread_create(&tid, NULL, thread_func, NULL); sleep(3); printf("主线程发起取消请求\n"); pthread_cancel(tid); pthread_join(tid, NULL); printf("子线程已经被取消\n"); return 0; }线程等待
int pthread_join(pthread_t thread, void **retval);作用:
- 阻塞调用线程,直到目标 thread 线程终止
- 获取线程退出返回值(retval 接收,如果不关心可以传 NULL)
- 回收该线程的资源(线程栈、TCB),防止僵尸线程资源泄漏
✅ 限制:
- 只能等待joinable线程
- 一个线程只能被 join 一次;不能多个线程同时等同一个线程
- 不能等待已经分离(detach)的线程,会报错
场景:需要拿到子线程结果、需要确认子线程跑完再继续
#include <stdio.h> #include <pthread.h> #include <unistd.h> // 线程入口函数 void *thread_func(void *arg) { int num = *(int *)arg; printf("子线程开始执行,收到参数:%d\n", num); sleep(2); printf("子线程执行完毕\n"); // return 作为线程退出,返回值给 pthread_join 获取 return (void *)100; } int main(void) { pthread_t tid; int val = 666; void *ret; // 创建线程,默认 joinable int ret_create = pthread_create(&tid, NULL, thread_func, &val); if (ret_create != 0) { perror("pthread_create"); return -1; } printf("主线程等待子线程...\n"); // 阻塞等待,拿到子线程return的返回值 pthread_join(tid, &ret); printf("子线程返回值:%ld\n", (long)ret); printf("主线程结束\n"); return 0; }线程分离
int pthread_detach(pthread_t thread);作用: 把线程从joinable→detached(分离态)
- 分离后的线程:退出时自动释放自身资源,不需要、也不能 pthread_join
- 一旦 detach,无法再 join,拿不到返回值
两种设置分离的办法:
- 创建之后调用:
pthread_detach(tid); - 创建时通过 attr 属性直接设置分离态(pthread_attr_setdetachstate)
✅ 使用场景:不关心线程返回结果,不想阻塞等待,让线程跑完自动回收资源
#include <stdio.h> #include <pthread.h> #include <unistd.h> void *thread_func(void *arg) { printf("分离线程开始执行\n"); sleep(2); printf("分离线程结束,自动释放资源\n"); return NULL; } int main(void) { pthread_t tid; pthread_create(&tid, NULL, thread_func, NULL); // 设置为分离态 pthread_detach(tid); printf("已经detach,主线程不再等待子线程\n"); // 主线程不能return太快!否则进程直接退出,子线程被杀 sleep(3); printf("主线程结束\n"); return 0; }核心对比(面试必问)
- joinable(默认):线程退出后资源不释放,必须 pthread_join 回收;可以拿到返回值,join 会阻塞
- detached 分离态:线程结束自动释放资源;不能 join,拿不到返回值,不会阻塞
⚠️ 经典坑:创建线程后,既不 join,也不 detach → 线程结束变成僵尸线程,内存泄漏
一句话串流程(口述)
pthread_create 创建线程,默认可接合;线程可以 return、pthread_exit、被 cancel 终止;主线程用 pthread_join 阻塞等待它结束并回收资源、获取返回值;如果不需要等待、不需要返回值,可以 pthread_detach 把线程设为分离态,线程退出自动释放资源。
高频追问
问:主线程 return 和 pthread_exit 的区别? 答:main 函数 return 等价调用 exit,整个进程销毁,所有子线程全部终止;主线程调用 pthread_exit 只是主线程自己退出,进程保留,其他子线程继续运行。
问:pthread_join 和 pthread_detach 能不能一起用? 答:不行,一旦 detach,该线程不再支持 join。