☰
Linux线程概念与控制
2026/10/22 7:27:08 网站建设 项目流程

概念

在一个程序里的执行路线就是线程,线程是“一个进程的内部序列”。

一切进程至少有一个线程

线程在进程的内部运行,本质是在进程地址空间运行

每一个线程都是一个执行流,都是共享内存地址,每一个执行流都对应一个函数,它们都各自独立但是又各自共享,在Pthread动态库中又会有各自的栈空间。

线程优缺点

前提

前提:线程是进程内的执行流,同一个进程下多个线程共享进程资源(地址空间、文件描述符、信号处理、全局变量等),线程自己只有独立:栈、寄存器、线程局部存储 TLS。

优点

  1. 上下文切换开销远小于进程进程切换要换页表(CR3)、刷新 TLB,切换成本高;同一进程内线程切换,不需要切换地址空间,不用刷 TLB,只保存 / 恢复寄存器、栈,速度更快。
  2. 通信简单高效同进程线程共享地址空间,直接读写全局变量、堆内存就能通信,不用管道、socket、共享内存 + 信号量这类跨进程通信 IPC,数据传递快。
  3. 资源占用小创建线程不用单独分配虚拟地址空间,内核开销低,创建销毁比进程快。
  4. 充分利用多核 CPU可以把任务拆分并行执行,发挥多核算力(IO 密集型、计算密集型都适合,注意 GIL 问题:Python 线程除外)。
  5. 程序响应性好主线程负责交互,子线程处理耗时 IO / 计算,避免阻塞主流程。

缺点

  1. 共享资源带来并发安全问题共享数据容易出现竞态条件,必须加锁(互斥锁、条件变量等);锁使用不当会引发死锁、活锁、锁饥饿。
  2. 一个线程崩溃,整个进程直接崩溃线程共享进程地址空间,一个线程非法访问内存触发段错误 SIGSEGV,整个进程所有线程一起挂掉;进程之间互相隔离,一个进程崩了不影响别的进程。
  3. 调试、排错难度更大多线程时序不确定,bug 大多是偶现(竞态),很难复现;栈、调用关系更复杂。
  4. 锁带来性能损耗大量锁竞争会造成线程频繁阻塞、上下文切换变多,反而性能下降;锁粒度不好控制。
  5. 信号处理复杂信号是发给进程的,信号到达后内核随机选一个线程去处理,多线程下信号处理容易混乱。
  6. 部分语言有限制(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 负责把虚拟地址翻译成物理地址,翻译的依据就是【页表】。

  1. 内存管理子系统负责找 / 回收物理页(struct page);
  2. 页表负责建立「虚拟页 ↔ 物理页帧」的映射关系;
  3. 缺页异常就是:查页表发现映射不存在 → 去物理内存分配器申请物理页,再填充页表。

硬件视角:MMU + 页表

开启分页后,CPU 访问地址时:

  1. 送出虚拟地址给 MMU;
  2. MMU 根据 CR3 寄存器(x86)拿到当前进程页表的物理基地址;
  3. 拆分虚拟地址为多级索引,逐级遍历页表:PGD → P4D → PUD → PMD → PTE;
  4. PTE 中取出物理页帧号,拼接页内偏移,得到最终物理地址;
  5. 同时检查权限(可读 / 可写 / 执行、用户态 / 内核态),非法则触发缺页 / 保护异常。

✅ 重点: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高 10bitPDE:页目录项索引(Page Directory Entry)
21~12中间 10bitPTE:页表项索引(Page Table Entry)
11~0低 12bit页内偏移,不变,直接拼到物理页号后面

找步骤(一步一步)

  1. CR3 = 页目录的物理起始地址
  2. 取出虚拟地址高 10 位:页目录索引
    • 页目录里每一项 PDE 占 4 字节
    • PDE 物理地址 =CR3 + 索引 × 4
  3. 读这个 PDE:
    • PDE 高 20bit =页表的物理基地址;低位是属性位(P、R/W、U/S 等)
  4. 取出虚拟地址中间 10 位:页表索引
    • PTE 物理地址 =PDE的页表基地址 + 索引 × 4
  5. 读 PTE:
    • PTE 高 20bit =物理页帧基地址(PFN)
  6. 拼接:
    • 最终物理地址 = PTE 的 20bit 页帧号 + 虚拟地址低 12bit 偏移

举个直观例子

虚拟地址:0x12345678

  • 32bit 拆分:
    • 高 10bit:页目录索引
    • 中间 10bit:页表索引
    • 低 12bit:0x678(偏移)
  1. CR3 拿到页目录物理基址,用高 10bit 找到 PDE → 得到页表物理基址
  2. 用中间 10bit 在页表中找到 PTE → 得到物理页帧的 20bit 基址
  3. 把 20bit 页帧号 左移 12 位,或直接拼接低 12bit 偏移0x678,得到物理地址

TLB

TLB 是 MMU 配套的高速缓存,缓存虚拟页到物理页的映射;MMU 收到虚拟地址优先查询 TLB,命中则直接完成地址转换;未命中则访问内存中的页表查询映射,并把新映射回填 TLB;修改页表映射时需要刷新 TLB,防止使用失效映射。

  1. First:TLB 命中(hit)✅TLB 里面找到了这个虚拟页对应的映射记录 → 直接读出物理页帧号,拼接页内偏移得到物理地址,不用去内存遍历页表,速度很快。
  2. 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

  1. PFN 左移 12 位:后面补 12 个 01010 0000 0000 0000 0011 0000 0000 0000
  2. 和偏移做按位或(|),把 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,或者权限非法,就触发缺页异常,陷入内核,由内核的缺页异常处理函数来修复映射。

一、什么时候会触发缺页异常

两大类:

  1. 有效缺页(正常,需要内核补映射)
    • 匿名缺页:进程堆 / 栈,已经分配了 VMA(虚拟地址区间),但还没分配物理页、没填 PTE(malloc 只拿虚拟地址,不拿物理内存)
    • 文件缺页:访问文件映射区域,内容还在磁盘,不在 page cache 内存里
    • 换入缺页:匿名页之前被 kswapd 换到 swap 分区,PTE 标记了 swap 位置,不在内存
  2. 无效缺页(非法访问,报错)
    • 虚拟地址没有对应的 VMA(乱访问野指针)
    • 权限不匹配:比如对只读 VMA 执行写操作、内核地址被用户态访问

二、32 位 x86 下,缺页进入内核后的基础流程do_page_fault

  1. CPU 触发 #PF,保存现场,进入内核异常入口;同时 CPU 会把出错的虚拟地址放进 CR2 寄存器(非常关键!CR2 = 引发异常的那个虚拟地址)
  2. 内核拿到 CR2 里的虚拟地址,在当前进程的 VMA 链表(红黑树)查找:这个虚拟地址有没有合法的vm_area_struct
    • ✅ 找不到 VMA → 非法访问,发送 SIGSEGV,进程崩溃
    • ✅ 找到 VMA,再判断访问类型(读 / 写 / 执行)和 VMA 权限是否匹配:权限不符,同样段错误
  3. 合法的缺页,区分类型处理:
    • 文件缺页(文件映射 /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 标记为只读;任意一方写的时候触发写缺页。内核分配新物理页,复制旧页内容,再把新页映射给写的进程。
  4. 处理完成、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 槽位
  • 标记这是文件页:记录文件偏移信息

五、容易混淆的点

  1. CR2:保存出错虚拟地址,是缺页处理的输入
  2. CR3:页目录物理基址,用来遍历页表
  3. 缺页异常只发生在MMU 查表阶段,页内偏移不参与缺页判断
  4. 缺页分:主缺页(需要磁盘 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!)

✅ 要点:

  1. pthread_t 是库层面的线程 ID,不是内核 TID;内核 TID 由 gettid 获取
  2. 默认属性:joinable(可被等待)
  3. 主线程退出(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);

作用:

  1. 阻塞调用线程,直到目标 thread 线程终止
  2. 获取线程退出返回值(retval 接收,如果不关心可以传 NULL)
  3. 回收该线程的资源(线程栈、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,拿不到返回值

两种设置分离的办法:

  1. 创建之后调用:pthread_detach(tid);
  2. 创建时通过 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; }

核心对比(面试必问)

  1. joinable(默认):线程退出后资源不释放,必须 pthread_join 回收;可以拿到返回值,join 会阻塞
  2. 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。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询