如果你在阅读过程中有任何疑问或想要进一步探讨的内容欢迎在评论区畅所欲言我们一起学习、共同成长~ 如果你觉得这篇文章还不错不妨顺手点个赞、加入收藏并分享给更多的朋友噢~1. Linux 线程概念1.1 什么是线程1.1.1 线程核心定义【必考核心】线程是进程内部的一个执行流执行的是程序的一个片段代码一切进程至少有一个执行线程主线程。线程在进程内部运行本质是在进程地址空间内运行。站在内核角度进程是承担分配系统资源的基本实体线程是 CPU调度的基本单位。1.1.2 Linux 线程本质轻量级进程LWP【必考核心】Linux 内核没有为线程单独设计数据结构1个进程内创建线程时只复制创建新的 task_struct进程控制块task_struct与该进程共享地址空间mm_struct、页表、文件描述符表等资源。因此Linux 下的线程本质是轻量级进程LWP, Light Weight Process。CPU 调度时以task_struct为单位不区分也不需要区分当前调度的是进程还是线程。单线程进程中PID LWP多线程进程中多个 LWP 共享同一个 PID。⚠️易错点不要说 Linux 没有线程。准确表述是 Linux 内核没有独立的线程概念线程以轻量级进程的形式实现。Windows 有真正的线程内核对象其实现比 Linux 更复杂。1.1.3 vfork 函数【冷门了解】vfork 创建的子进程与父进程完全共享整个地址空间是 Linux共享空间执行流 的早期原型。现代面试极少直接考察 vfork理解其 共享地址空间 的语义即可。pid_t vfork(void); // 返回值同fork父进程返回子PID子进程返回0✅ 子进程修改全局变量父进程能看到因为共享地址空间。❌ 子进程中不能 return函数级操作必须用 _exit内核级操作不影响用户态的栈或 exec程序替换后父子各占独立物理页退出否则会破坏父进程栈。联动《Linux系统04进程控制》1.3 写时拷贝fork 创建子进程后每个进程有自己的独立页表父子进程起初共享同一块物理内存即父子进程页表的虚拟地址映射到同一物理地址一旦触发写时拷贝双方就各自拥有独立的物理内存。1.2 分页式存储管理虚拟地址与页表1.2.1 虚拟地址空间与页表的由来【必考核心】没有虚拟内存时程序必须占用连续物理内存导致物理内存碎片化严重。程序间缺乏隔离一个进程可直接访问其他进程内存。解决方案每个进程拥有独立的虚拟地址空间通过页表建立虚拟页→物理页框的映射。物理内存按固定大小通常 4KB划分为页框page frame。虚拟地址空间按同样大小划分为页page。页表记录每个虚拟页到物理页框的映射关系及权限。进程看到的是连续虚拟地址实际映射到离散物理页框。✅ 核心收益解决碎片化问题 进程间内存隔离 按需调页1.2.2 二级页表与地址转换【必考核心】单级页表缺陷单级页表中对于表项在页表的位置只有页表本身连续时才能通过 页号 × 4 KB 直接定位 —— 这与 用页表解决连续内存问题 的初衷相矛盾。多级页表如何解决以上矛盾以二级页表为例一级页目录1 张占 4KB1024 个表项 × 4 字节页目录内部必须连续用于定位二级页表。二级页表共 1024 张每张 4KB每张 1024 个表项 × 4 字节✅ 按需分配未用到的虚拟地址不分配二级页表节省内存如 10MB 程序仅需 3 张。✅ 离散存放每张二级页表内部连续二级页表间可分散存放、无需相邻。32 位二级页表地址拆分101012虚拟地址位数字段作用高 10 位页目录索引PDXPage Directory Index在页目录中找到二级页表起始地址中 10 位页表索引PTXPage Table Index在二级页表中找到物理页框起始地址低 12 位页内偏移在 4KB 物理页框内定位具体字节转换流程CR3 寄存器指向当前进程的页目录起始地址。虚拟地址高 10 位索引页目录 → 得到二级页表起始地址。中间 10 位索引二级页表 → 得到物理页框起始地址。物理页框起始地址 低 12 位偏移量 最终访问的物理地址。⚠️笔试高频计算题32 位系统、4KB 页、二级页表问页目录项数 / 页表项数 / 页表总大小。记住 101012 拆分即可推导。1.2.3 MMU 与 TLB【高频易错】MMU内存管理单元CPU 内置硬件负责虚拟地址→物理地址的转换及权限检查。TLBTranslation Lookaside Buffer转译后备缓冲器MMU 内部的高速缓存缓存最近使用的虚拟页→物理页框映射避免每次都查多级页表。地址转换实际流程CPU 发虚拟地址给 MMU。MMU 先查 TLB命中则直接得到物理地址快。TLB 未命中TLB Miss遍历多级页表查物理地址同时更新 TLB 缓存。⚠️关键面试点进程切换时 TLB 必须刷新因为地址空间变了这是进程切换开销远大于线程切换的核心原因之一。线程切换不刷新 TLB共享地址空间因此线程切换更快。TLB 容量很小通常几十到几百项Miss 代价高。1.2.4 缺页异常Page Fault【高频易错 / 进阶拔高】当 MMU 查页表发现虚拟地址对应页表项不存在未映射或权限不匹配时触发缺页异常硬件中断CPU 从用户态陷入内核态由 Page Fault Handler 处理。三种缺页类型类型别名场景处理方式Hard Page FaultMajor Fault物理页不在内存需从磁盘读入分配物理页 从磁盘加载 建映射Soft Page FaultMinor Fault物理页已在内存被其他进程调入仅缺映射直接建立映射无需磁盘 IOInvalid Page Fault—地址越界 / 野指针 / 权限非法发送 SIGSEGV 信号进程崩溃段错误重要认知缺页异常不一定是错误。malloc/new 分配内存时内核通常只分配虚拟地址空间不立即分配物理页第一次访问时触发缺页异常内核才真正分配物理页—— 这就是 按需调页demand paging。⚠️写时拷贝与缺页fork 后父子进程共享物理页只读当任一方尝试写入时触发缺页异常内核才复制该页并分别映射 —— 这是 fork 高效的根本原因。区分缺页 vs 越界页号合法但页不在内存 → 缺页异常正常机制。页号非法超出进程地址空间映射范围→ 越界访问 → Invalid Fault → 段错误。1.3 线程的优点【必考核心】⭐资源占用少创建 / 销毁开销小共享进程的地址空间、页表、文件描述符表等。⭐切换代价低不切换地址空间不刷新 TLB 和 Cache。⭐IO 重叠等待慢速 IO 时其他线程可继续计算。⭐通信高效共享内存变量无需进程通信机制但需同步。充分利用多核多个线程可并行运行在不同 CPU 核心上。计算密集型 vs IO 密集型计算密集型执行流以计算为主线程数 ≈ CPU 核心数过多线程增加调度开销。IO 密集型执行流以 IO 为主线程数可大于 CPU 核心数IO 阻塞时切换到其他线程。1.4 线程的缺点【高频易错】⭐健壮性降低线程间缺乏保护一个线程的野指针 / 除零会导致整个进程崩溃。⭐缺乏访问控制进程是访问控制基本粒度线程中调用某些 OS 函数系统调用如 exit、chdir影响整个进程。性能损失计算密集型线程数超过 CPU 核心数时额外的同步和调度开销反而降低性能。编程难度高需处理竞态条件、死锁、数据竞争调试困难。2. Linux 进程 VS 线程2.1 进程和线程的核心区别【必考核心】对比维度进程线程角色定位资源分配的基本单位CPU 调度的基本单位地址空间独立虚拟地址空间共享所属进程的地址空间创建开销大复制 / 建立地址空间等小仅复制创建 task_struct资源独立拥有内存、文件、信号等共享进程的地址空间、页表、文件描述符表等大部分资源切换开销大切换页表、刷新 TLB/Cache小仅切换寄存器和栈通信方式需 IPC管道 / 消息队列 / 共享内存 / 信号直接读写共享变量需同步机制崩溃影响范围一个进程崩溃不影响其他进程一个线程崩溃→整个进程崩溃2.2 线程私有数据 vs 共享资源【必考核心】线程私有每个线程独立一份线程 IDpthread 库维护的用户态 ID一组寄存器程序计数器、栈指针等保存上下文栈每个线程有独立栈主线程栈在进程栈区新线程栈在共享区errno每个线程独立的错误码变量信号屏蔽字每个线程可独立设置信号掩码调度优先级线程共享同进程内所有线程共享代码段Text Segment函数所有线程都可调用。数据段Data Segment全局变量、静态变量所有线程都可访问。堆区malloc/new 的内存所有线程可见。文件描述符表一个线程打开的文件其他线程可直接用 fd 访问。信号处理方式SIG_IGN / SIG_DFL / 自定义处理函数进程级共享。当前工作目录cwd用户 ID 和组 ID3. Linux 线程控制3.1 线程库概述【必考核心】头文件pthread.h编译链接添加 -pthread 选项链接 libpthread.so 线程库。线程库绝大多数库函数以 pthread_ 开头。错误处理规则成功返回 0失败返回正整数错误码不是 - 1也不设置 errno因此不能用 perror。线程库本质是对内核 clone 系统调用的封装在用户态管理线程属性。3.2 线程创建pthread_create【必考核心】int pthread_create(pthread_t* thread, const pthread_attr_t* attr, void* (*start_routine)(void*), void* arg);参数thread输出型参数返回创建成功的用户态线程 IDpthread_t 类型。attr线程属性传 NULL 使用默认属性默认 joinable、栈大小由系统决定。start_routine线程入口函数地址线程入口函数签名必须是 void* func(void* 形参)。arg传给线程函数的参数传多类型参数时封装为结构体。返回值成功 0失败返回错误码如 EAGAIN 资源不足、EINVAL 属性非法。LWP 与 pthread_create 创建线程的关系LWP 是内核态的调度实体是 CPU 真正调度执行的单位pthread_create 创建的线程是用户态的线程抽象LWP 与 pthread_create创建的线程是「一对一映射」关系每调用一次 pthread_create内核就同步创建一个 LWP 与之绑定。创建1个子线程 demo#include stdio.h #include pthread.h #include unistd.h #include stdlib.h // 线程入口函数签名必须是 void* func(void* 形参) void* Routine(void* arg) { char* msg (char*)arg; // pthread_self()获取用户态pthread库的线程IDpthread_t 类型 printf(%s, tid%lu, pid%d\n, msg, pthread_self(), getpid()); sleep(5); return NULL; } int main() { pthread_t tid; // 创建子线程 int ret pthread_create(tid, NULL, Routine, (void*)worker thread); if (ret ! 0) { // ⚠️必须检查返回值pthread失败不设errno返回正整数错误码 // fprintf(stderr, 格式串, 参数...) 打印到标准错误流 fprintf(stderr, pthread_create failed: %d\n, ret); exit(1); // 退出整个进程销毁所有线程 } printf(main thread, tid%lu, pid%d\n, pthread_self(), getpid()); // 主线程必须等待子线程否则子线程资源不回收内存泄漏 pthread_join(tid, NULL); printf(worker thread finished\n); return 0; }查看目标程序的所有轻量级进程 (LWPLinux 线程)ps -aL | head -1; ps -aL | grep 程序名-a列出终端上所有进程-L查看 LWP内核轻量级进程 ID是 Linux 内核真正用来调度线程的标识一行一个线程ps -aL | head -1; 先打印一行表头关于用户态 pthread_t 参数与内核 LWP的区别详见 3.6 。创建多个子线程 demo【场景题・字节 / 美团一面】写代码创建 5 个线程每个线程打印自己的编号主线程等待所有线程结束。#include stdio.h #include pthread.h #include stdlib.h // 线程入口函数 void* worker(void* arg) { // 取出当前线程的独立编号 int thread_num *(int*)arg; printf(第%d个线程线程ID%lu\n, thread_num, pthread_self()); return NULL; } int main() { pthread_t tids[5]; // 存储线程ID int args[5]; // 每个线程独立的参数空间 int i; for (i 0; i 5; i) { args[i] i; // 为每个线程单独保存编号避免共享循环变量地址 int ret pthread_create(tids[i], NULL, worker, args[i]); if (ret ! 0) { fprintf(stderr, 第%d个线程创建失败%d\n, i, ret); exit(1); } } for (i 0; i 5; i) { pthread_join(tids[i], NULL); } printf(所有子线程执行完毕主线程退出\n); return 0; }核心代码pthread_t tids[5]; int args[5]; // 每个线程独立参数不能传i for (int i 0; i 5; i) { args[i] i; pthread_create(tids[i], NULL, worker, args[i]); } for (int i 0; i 5; i) pthread_join(tids[i], NULL);循环创建线程时传参陷阱循环变量 i 在栈上只有唯一一份内存如果传 i变量地址所有线程共享同一个 i。pthread_create() 只是提交创建线程的请求调用后立刻返回线程实际上异步执行从而可能出现线程读到的 i 值相同或越界。必须为每个线程单独分配参数malloc 或用数组。// ❌ 错误所有线程共享同一个i的地址 for (int i 0; i 5; i) pthread_create(tid[i], NULL, Routine, i);3.3 线程终止【必考核心】只终止某个线程而不终止整个进程有三种方式方式退出码线程入口函数中 return区分主线程 return 退出整个进程return 的值要退出的线程主动调用 pthread_exit 终止自己区分exit() 退出整个进程传入的参数调用 pthread_cancel(线程 ID) 取消同一进程的指定线程PTHREAD_CANCELED3.3.1 return 退出线程入口函数中 return → 该线程退出。main 函数中 return→ 整个进程退出所有线程强制终止。#include stdio.h #include pthread.h #include stdlib.h // 【考点】子线程函数return仅终止当前线程不影响同进程其他线程 void* return_worker(void* arg) { printf(子线程执行完毕通过return退出\n); int* ret_val (int*)malloc(sizeof(int)); *ret_val 100; return (void*)ret_val; // 返回堆地址供主线程读取 } int main() { pthread_t tid; void* recv; // 接收线程返回值 pthread_create(tid, NULL, return_worker, NULL); // 【考点】pthread_join第二个参数接收线程的返回值void* pthread_join(tid, recv); printf(主线程收到子线程返回值 %d\n, *(int*)recv); free(recv); // 【规范】线程分配的堆内存主线程回收避免泄漏 return 0; }3.3.2 pthread_exitvoid pthread_exit(void* retval);终止当前线程retval 作为退出码可被 pthread_join 获取。无返回值线程结束无法返回调用者。⚠️retval指向的内存必须是全局的或 malloc 分配的不能是局部变量 —— 线程退出后栈已销毁。#include stdio.h #include pthread.h #include stdlib.h void* worker(void* arg) { printf(子线程执行中途调用pthread_exit退出\n); int* ret_val (int*)malloc(sizeof(int)); *ret_val 200; // 【考点】pthread_exit任意位置主动终止当前线程支持携带返回值 pthread_exit((void*)ret_val); // 【高频验证点】pthread_exit之后的代码永远不会执行 printf(这行代码绝对不会执行\n); return NULL; } int main() { pthread_t tid; void* recv; pthread_create(tid, NULL, worker, NULL); pthread_join(tid, recv); printf(主线程收到子线程返回值 %d\n, *(int*)recv); free(recv); /* 【面试易错补充】 若主线程调用pthread_exit仅退出主线程进程不会终止子线程继续运行 若主线程return/exit直接终止整个进程所有子线程强制销毁 */ return 0; }3.3.3 pthread_cancelint pthread_cancel(pthread_t thread);取消同一进程中的线程被取消线程的退出码为PTHREAD_CANCELED值为 (void*)-1调用是异步的pthread_cancel 只负责向目标线程发送一个取消请求调用后立刻返回不会等待目标线程终止⚠️ 线程可以设置取消状态启用 / 禁用和取消类型延迟 / 异步。默认是延迟取消——目标线程收到取消请求后不会立刻终止会先记住请求继续运行直到代码执行到取消点时才检查请求并执行退出。#include stdio.h #include pthread.h #include unistd.h void* cancel_worker(void* arg) { while (1) { printf(目标线程t运行中...\n); // 【考点】线程只有执行到取消点才会响应取消请求 sleep(1); } return NULL; } int main() { pthread_t tid; void* recv; pthread_create(tid, NULL, cancel_worker, NULL); sleep(2); // 先不发送取消请求让目标线程运行一会 // 【考点】参数传入目标线程ID实现跨线程取消 pthread_cancel(tid); pthread_join(tid, recv); // 【考点】被取消的线程退出值固定为宏 PTHREAD_CANCELED值为-1 if (recv PTHREAD_CANCELED) printf(主线程目标线程t已被取消退出值 PTHREAD_CANCELED\n); return 0; }3.4 线程等待pthread_join【必考核心】int pthread_join(pthread_t thread, void** retval);阻塞等待指定线程终止并回收其资源。thread要等待的线程 ID值传递不是指针。retval输出参数接收线程退出码。不关心线程退出状态则传 NULL。返回值成功 0失败返回错误码如 EINVAL 线程不可 join、ESRCH 线程不存在、EDEADLK 死锁。线程退出码的四种情况【高频易错】线程终止方式retval 指向的内容return 返回线程函数的返回值pthread_exit(retval)传给 pthread_exit 的参数pthread_cancel 取消PTHREAD_CANCELED即 - 1不关心退出状态传 NULL为什么必须 join新创建的线程默认是joinable状态。joinable 线程退出后资源不会自动释放类似僵尸进程造成内存泄漏。必须通过pthread_join回收或用pthread_detach分离。线程异常退出与pthread_join【高频易错】单个线程出现除零、野指针等问题 → 触发信号机制 →终止整个进程→ 进程内所有线程退出。因此pthread_join无法获取线程异常退出的信息—— ⚠️笔试常考pthread_join 为什么只能拿到退出码拿不到异常信号 答线程异常导致整个进程终止进程退出后主线程无法继续执行 join。不同进程等待中waitpid() 能拿到子进程的退出信号和 core dump 标志线程等待只能拿到正常退出码。3.5 分离线程pthread_detach【高频易错】int pthread_detach(pthread_t thread);将线程标记为detached状态线程退出时系统自动回收资源无需 join。可以是其他线程分离目标线程也可以是pthread_detach(pthread_self())分离自身。joinable 和 detached 互斥一个线程不能同时处于两种状态。分离后的线程不能再被 join。⚠️高频面试陷阱分离线程只是退出时自动回收资源不代表线程崩溃不影响进程—— 分离线程崩溃一样拖垮整个进程。分离线程的返回值无法获取因为没人 join。pthread_detach后线程 ID 可能被立即复用不能再用该线程 ID 做操作。主线程退出时分离线程也会被强制终止进程退出所有线程都退出。适用场景不关心线程返回值的后台任务如日志写入、心跳检测。不需要与线程同步的 fire and forget 场景。极简实操代码void* worker(void* arg) { pthread_detach(pthread_self()); // 分离自身 printf(detached thread running\n); sleep(2); printf(detached thread exit, auto recycle\n); return NULL; } // 主线程无需pthread_join3.6【高频易错】3.6.1 pthread_t 类型参数pthread_ 函数的 pthread_t thread 参数对应的线程 ID不是内核的 LWP而是线程库在用户态维护的线程标识本质是进程地址空间共享区中的一个虚拟地址指向该线程的 struct pthread 控制块。3.6.2进程地址空间中的线程布局4. 核心知识脉络线程本质LWP复用task_struct │ ├── 地址空间基础虚拟内存 → 页表 → 二级页表(101012) → MMU/TLB → 缺页异常 │ ├── 进程vs线程资源分配单位 vs 调度单位共享 vs 私有 │ └── 线程控制 ├── 创建 pthread_create-lpthread返回值判错 ├── 终止 return / pthread_exit / pthread_cancel(PTHREAD_CANCELED) ├── 等待 pthread_join阻塞回收资源只能拿退出码 ├── 分离 pthread_detach自动回收不可再join └── 线程IDpthread_t(共享区地址) ≠ LWP(内核调度ID) ≠ PID
