ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从虚拟内存到 pthread:Linux 线程概念与控制全解析

从虚拟内存到 pthread:Linux 线程概念与控制全解析 个人主页道尔柯南❄专栏传送门《C语言》《C》《Linux操作系统》昙花一现却等待了整个白昼蝉鸣一夏却蛰伏了好几个四季。文章目录1. 线程到底是什么2. 理解线程的前置知识虚拟地址空间与分页2.1 没有虚拟内存会怎样2.2 页、页框、页表2.3 MMU、TLB 与缺页异常2.4 malloc/new、写时拷贝与缺页3. Linux 进程 vs 线程哪些共享哪些私有4. 线程的优点、缺点与适用场景4.1 优点4.2 缺点4.3 适用场景5. POSIX 线程控制pthread 系列5.1 创建线程5.2 线程终止5.3 线程等待5.4 线程分离5.5 取消与清理6. 线程 ID 与进程地址空间布局7. 简单 C 线程封装8. 从 glibc/NPTL 源码看 pthread_create9. 补充线程安全与同步9.1 竞态条件9.2 死锁9.3 可重入与线程局部存储9.4 信号与线程10. 最佳实践总结结语结尾1. 线程到底是什么在操作系统教材里线程常被定义为线程是进程内部的一条执行流是 CPU 调度的基本单位。但在 Linux 里这个定义要再往前走一步Linux 内核并没有为“线程”单独设计一种和进程完全不同的数据结构。无论是进程还是线程内核里都对应task_struct只是它们共享资源的范围不同。可以这样理解进程资源分配的基本单位拥有独立的虚拟地址空间、文件描述符表、信号处理方式等。线程调度的基本单位同一进程内的多个线程共享地址空间和大部分进程资源。LWPLight Weight Process轻量级进程。Linux 内核视角下线程更像是一个“共享了很多资源的进程”。所以CPU 眼中看到的线程 PCB比传统进程更“轻量”。而用户态使用的pthread线程库本质上是对内核clone系统调用的一层封装。一句话Linux 用“共享资源的 task”实现线程用 pthread 库给用户提供 POSIX 线程接口。2. 理解线程的前置知识虚拟地址空间与分页要真正理解线程不能只背“线程共享地址空间”。必须知道地址空间为什么能被共享以及内核如何管理内存。2.1 没有虚拟内存会怎样如果每个程序直接使用物理内存那么程序的代码段、数据段、栈段必须连续存放。程序运行、退出后物理内存会留下大量离散碎片。时间一长内存利用率会变差。虚拟内存和分页机制解决了这个问题给每个进程一个连续的虚拟地址空间物理内存按固定大小切成页框通过页表把虚拟页映射到任意可用的物理页框用户看到的是连续地址物理内存可以是离散的。2.2 页、页框、页表页虚拟内存中的数据块。页框物理内存中的存储区域。页表记录虚拟页到物理页框映射关系的数据结构。常见页大小是 4KB。32 位系统虚拟地址空间为 4GB因此需要4GB / 4KB 1048576 个页表项如果每个页表项 4 字节单级页表就要 4MB。这 4MB 还必须连续存放显然不太合理。于是引入多级页表把页表再分页用页目录管理页表典型 32 位两级页表页目录 1024 项每个页表 1024 项CR3 寄存器保存当前页目录物理地址MMU 负责地址转换。2.3 MMU、TLB 与缺页异常MMU 是硬件内存管理单元。CPU 发出虚拟地址后MMU 查页表得到物理地址。为了加速现代 CPU 还有 TLB即快表缓存最近使用的页表项。地址转换大致流程CPU 给出虚拟地址MMU 先查 TLBTLB 命中直接得到物理地址TLB 未命中查页表页表也没有触发缺页异常内核缺页处理程序决定是分配物理页、从磁盘读取还是判定为非法访问。缺页异常常见类型类型含义硬缺页 / 主缺页物理内存中没有对应页需要从磁盘读入软缺页 / 次缺页物理页已存在只需建立映射无效缺页地址非法通常触发 SIGSEGV补充一个容易混淆的点越界访问不一定立刻报错。如果地址仍落在合法 VMA 内或者页号合法只是没有建立映射可能先触发缺页只有地址非法时才会段错误。2.4 malloc/new、写时拷贝与缺页malloc/new通常只是申请虚拟地址空间并不立即分配物理页。第一次读写时才可能触发缺页由内核分配物理页。fork后的写时拷贝也是类似思想父子进程先共享物理页任一方写入时触发缺页内核复制一份新的物理页再修改页表映射。所以申请内存 ≠ 立即占用物理内存。理解这一点对理解线程栈、共享内存和性能都很重要。3. Linux 进程 vs 线程哪些共享哪些私有同一进程内的多个线程共享虚拟地址空间代码段、数据段、堆文件描述符表信号处理方式当前工作目录用户 ID、组 ID。每个线程私有线程 ID一组寄存器即线程上下文栈errno信号屏蔽字调度优先级线程局部存储 TLS。可以用表格总结资源进程内线程是否共享地址空间共享代码段、全局变量、堆共享文件描述符共享信号处理函数共享当前工作目录共享用户/组 ID共享栈私有寄存器上下文私有errno私有信号屏蔽字私有调度优先级私有因此进程是资源分配的基本单位线程是调度的基本单位线程共享地址空间所以通信方便但也意味着缺乏保护。4. 线程的优点、缺点与适用场景4.1 优点创建线程比创建进程开销小线程切换不需要切换地址空间TLB 和 cache 更友好线程间共享数据方便能利用多核并行适合 I/O 密集场景一个线程阻塞等待 I/O其他线程继续计算。4.2 缺点同步复杂容易竞态、死锁健壮性降低一个线程崩溃可能导致整个进程退出缺乏访问控制一个线程关闭 fd会影响同进程其他线程调试困难时序问题难以复现线程数量过多会增加调度和同步开销。4.3 适用场景CPU 密集型分解计算任务利用多核I/O 密集型重叠等待提高吞吐但线程不是越多越好通常结合线程池、异步 I/O、协程等方案。5. POSIX 线程控制pthread 系列Linux 下线程函数大多以pthread_开头需要包含pthread.h编译时链接-lpthread或-pthread。pthread 函数出错时通常不设置全局errno而是返回错误码。5.1 创建线程#includepthread.hintpthread_create(pthread_t*thread,constpthread_attr_t*attr,void*(*start_routine)(void*),void*arg);参数说明thread返回线程 IDattr线程属性NULL 表示默认start_routine线程入口函数arg传给入口函数的参数。示例#includepthread.h#includestdio.h#includestdlib.h#includestring.h#includeunistd.hvoid*worker(void*arg){printf(worker thread, pthread_self %lu\n,(unsignedlong)pthread_self());int*retmalloc(sizeof(int));*ret42;returnret;}intmain(void){pthread_ttid;interrpthread_create(tid,NULL,worker,NULL);if(err!0){fprintf(stderr,pthread_create: %s\n,strerror(err));return1;}void*retvalNULL;pthread_join(tid,retval);printf(worker return: %d\n,*(int*)retval);free(retval);return0;}5.2 线程终止线程可以有三种终止方式从线程函数return调用pthread_exit被其他线程pthread_cancel。注意主线程从main返回相当于调用exit会终止整个进程主线程调用pthread_exit其他线程可以继续运行pthread_exit的参数不要指向线程栈上的局部变量因为线程退出后栈可能失效。5.3 线程等待intpthread_join(pthread_tthread,void**value_ptr);pthread_join会阻塞调用线程直到目标线程终止。目标线程不同终止方式value_ptr得到的内容不同终止方式join 得到的内容return返回值pthread_exitpthread_exit 参数pthread_cancelPTHREAD_CANCELED如果不关心返回值可以传NULL。线程退出后如果既没有 join也没有 detach资源不会自动释放可能造成泄漏。5.4 线程分离intpthread_detach(pthread_tthread);默认创建的线程是 joinable 的。分离后线程退出时系统自动回收资源。也可以线程自己分离pthread_detach(pthread_self());joinable 和 detached 是冲突的一个线程不能同时两者都是。5.5 取消与清理pthread_cancel只是发送取消请求线程是否立即取消取决于取消状态和取消点。很多阻塞系统调用都是取消点。如果线程持有锁或申请了资源应使用清理处理程序pthread_cleanup_push(cleanup,arg);/* ... */pthread_cleanup_pop(1);这样可以避免取消时资源泄漏。6. 线程 ID 与进程地址空间布局这里有两个“线程 ID”类型来源作用域说明内核 tid / LWPgettid()、ps -aL的 LWP系统全局内核调度使用pthread_tpthread_self()进程内NPTL 中通常是struct pthread *地址ps -aL可以看到线程PID LWP TTY TIME CMD 2711838 2711838 pts/235 00:00:00 mythread 2711838 2711839 pts/235 00:00:00 mythread其中主线程的 LWP 和 PID 相同。其他线程的 LWP 不同但 PID 相同因为 PID 实际是线程组 ID。地址空间布局可以粗略理解为高地址 ------------------ | 内核空间 | ------------------ | 主线程栈 | 向下增长 ------------------ | mmap 区域 | 共享库、线程栈、TLS、TCB ------------------ | 堆 | 向上增长 ------------------ | BSS | | Data | | Text | ------------------ 低地址子线程的栈通常由mmap分配在共享区默认大小常见为 8MB并且有 guard page。它不能像主线程栈那样动态增长用尽就栈溢出。7. 简单 C 线程封装直接使用 pthread 比较底层C 中可以做一层封装。核心思路用std::functionvoid()保存任务pthread_create的入口函数是静态函数通过void*把当前对象传进去提供start、join、detach。简化示例#includepthread.h#includefunctional#includeutilityclassThread{public:explicitThread(std::functionvoid()func):func_(std::move(func)),tid_(0),joinable_(true){}boolStart(){returnpthread_create(tid_,nullptr,Entry,this)0;}voidJoin(){if(joinable_){pthread_join(tid_,nullptr);joinable_false;}}voidDetach(){if(joinable_){pthread_detach(tid_);joinable_false;}}~Thread(){if(joinable_){pthread_detach(tid_);}}private:staticvoid*Entry(void*arg){Thread*selfstatic_castThread*(arg);self-func_();returnnullptr;}pthread_t tid_;booljoinable_;std::functionvoid()func_;};补充C11 之后标准库提供了std::thread更推荐直接使用。C20 还有std::jthread支持自动 join 和停止令牌。线程名称也可以设置方便调试pthread_setname_np(pthread_self(),worker);Linux 下线程名通常最多 16 个字符。8. 从 glibc/NPTL 源码看 pthread_createLinux 原生线程库 NPTL 采用 1:1 模型一个用户态 pthread 对应一个内核调度实体 LWP。pthread_create的大致流程分配线程栈和 TCB初始化struct pthread设置入口函数和参数调用clone创建内核线程返回pthread_t本质是 TCB 地址。glibc 中会使用mmap分配线程栈memmmap(NULL,size,PROT_READ|PROT_WRITE,MAP_PRIVATE|MAP_ANONYMOUS|MAP_STACK,-1,0);然后调用clone常见 flags 包括clone flag作用CLONE_VM共享虚拟地址空间CLONE_FS共享文件系统信息CLONE_FILES共享文件描述符表CLONE_SIGHAND共享信号处理函数表CLONE_THREAD同一线程组CLONE_SETTLS设置 TLSCLONE_PARENT_SETTID写入线程 IDCLONE_CHILD_CLEARTID线程退出时清理并唤醒等待者CLONE_SYSVSEM共享 System V 信号量 undo内核中线程组 leader 的 PID 就是 tgid其他线程有各自 tid但 tgid 相同。getpid()返回 tgidgettid()返回内核 tid。所以pthread 库负责用户态线程管理内核负责调度。两者通过clone、futex 等机制协作。9. 补充线程安全与同步线程共享地址空间最大好处是通信方便最大风险是数据竞争。9.1 竞态条件多个线程并发访问共享变量且至少有一个写操作最终结果依赖调度顺序这就是竞态。例如intcount0;void*worker(void*arg){for(inti0;i100000;i){count;}returnNULL;}两个线程同时执行count很可能小于 200000。解决方式互斥锁pthread_mutex_t原子操作读写锁条件变量信号量。9.2 死锁死锁四个必要条件互斥占有并等待不可抢占循环等待。避免死锁的常见方法固定加锁顺序使用std::lock_guard/std::unique_lock尽量减少锁粒度避免在持锁时调用未知代码。9.3 可重入与线程局部存储可重入函数不依赖全局或静态可变状态多线程调用安全。errno是线程私有的。C/C 中也可以用 TLS__threadinttls_var;或 pthread 键pthread_key_tkey;pthread_key_create(key,NULL);pthread_setspecific(key,ptr);9.4 信号与线程信号处理方式在进程内共享但信号屏蔽字是线程私有的。信号可以发送给特定线程也可能由内核选择某个线程处理。一个线程收到SIGSEGV默认会终止整个进程。10. 最佳实践总结创建线程后明确是join还是detach避免资源泄漏。检查 pthread 函数返回值不要只看errno。线程入口函数返回值如果指向堆内存记得由 join 方释放。子线程栈默认固定大小注意栈溢出。共享数据必须同步优先使用 RAII 锁。不要在线程回调中访问已销毁对象封装时注意生命周期。线程名、日志、ps -aL、/proc/pid/task都是调试利器。高并发场景优先考虑线程池、异步 I/O、协程而不是无限创建线程。理解 Linux 线程的本质task_structclone 共享资源 NPTL 封装。进程与线程不是替代关系而是隔离性与通信效率之间的权衡。结语Linux 线程的核心可以压缩成一句话线程是共享进程地址空间和大部分资源的执行流Linux 内核用 task_struct 统一表示用 clone 实现资源共享用 NPTL 封装成 pthread 接口。理解虚拟地址空间、分页、页表、TLB 和缺页异常才能真正理解线程为什么“轻量”理解 pthread 的创建、终止、等待、分离才能写出正确的多线程程序理解 clone、TCB、TLS 和线程栈布局才能从“会用线程”走向“理解线程”。结尾uu们本文的内容到这里就全部结束了道尔在这里再次感谢您的阅读道尔柯南C/C Linux 底层探索者 | 一个正在努力学习的技术博主【关注】跟随我一起深耕技术领域见证每一次成长。❤️【点赞】让优质内容被更多人看见让知识传递更有力量。⭐【收藏】把核心知识点存好在需要时随时查、随时用。【评论】分享你的经验或疑问评论区一起交流避坑不要忘记给博主“一键四连”哦“今日目标达成”结语希望对学习Linux相关内容的uu有所帮助不要忘记给博主“一键四连”哦
返回列表