ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

《从零入门Linux系统篇(五十二):线程篇·五——Linux线程底层实现:从TCB、LWP到clone与线程封装》

《从零入门Linux系统篇(五十二):线程篇·五——Linux线程底层实现:从TCB、LWP到clone与线程封装》 本文要做的是把Linux线程的底层实现机制彻底拆开看看它到底是怎么转起来的。我们会从进程地址空间映射讲起理清用户级TCB与内核级LWP各自扮演什么角色、又是怎么配合的接着深挖线程栈的存储原理和线程ID的本质最后落到源码层面分析clone与do_fork的实现再通过一次线程封装实战把整条链路完整跑通。目录一、线程的一体两面——用户级TCB与内核级LWP1.1 动态库映射与进程地址空间1.2 用户级线程如何被描述和组织1.2.1 线程控制块struct tcb1.2.2 用户级TCB与内核级LWP如何分工二、线程ID究竟是什么三、线程栈与LWP、TCB之间如何协同3.1 线程栈在哪里——分布与存储原理3.1.1 pthread_t tid到底是什么3.2 一个线程从创建到退出经历了什么3.3 LWP与TCB如何联动3.3.1 从clone系统调用看线程创建3.3.2 用“带饭”比喻理解用户级与内核级的职责3.4 线程机制中的几个延伸问题3.4.1 主线程与新线程谁先执行3.4.2 pthread_detach线程分离究竟做了什么3.4.3 从do_fork看内核统一的创建机制3.5 结合源码追踪线程实现四、从接口到实现——手动封装一个线程一、线程的一体两面——用户级TCB与内核级LWPLinux骨子里没有真正的内核级线程它是靠轻量级进程LWP来“模拟”的。所以操作系统的系统调用接口压根不直接给你线程这种玩意儿真正的做法是在用户层对轻量级进程再做一次封装这才有了我们熟悉的原生线程库pthread。1.1 动态库映射与进程地址空间原生线程库libpthread.so本质上就是一个动态库走的是ELF格式。当可执行程序启动、加载的时候系统会通过动态链接和动态地址重定向把pthread库从磁盘搬进物理内存然后映射到当前进程的共享区里。1.2 用户级线程如何被描述和组织Linux内核层面只有轻量级进程LWP“线程”这个概念和它背后的控制结构就得由用户层的pthread动态库全盘接手。库内部可能同时挂着好几个创建好的线程那自然得把它们统一管起来。有人可能会犯嘀咕线程怎么会被一个库管着理解这件事其实特别简单想想C标准库的fopen就通了文件管理调fopen的时候C标准库会在堆内存里给你造一个FILE结构体把文件描述符、缓冲区这些全塞进去你拿到的就是一个FILE*指针。线程管理调pthread_create的时候pthread线程库会在进程的共享区里给你造一个struct tcb也就是线程控制块。你拿到的pthread_t本质上就是这个TCB在地址空间里的首地址。一模一样的套路库替你造结构体你拿到一个指针剩下的管理活儿库自己扛。1.2.1 线程控制块struct tcb动态库要管线程靠的还是那套老规矩先描述再组织。于是它为每一条用户级线程都量身定做了一个数据结构这就是TCBThread Control Block线程控制块在源码里通常叫struct pthread。这个TCB里装的全是线程自己独享的东西线程ID用来在用户层唯一标识一条线程。它的类型是pthread_t但别被名字骗了它实质上就是该线程在动态库映射区里的首地址。线程状态与分离属性记录线程当前跑在什么状态以及它能不能被等待。比如int joinable就是判断它是否可join的标志。线程独立的栈结构所有线程都挤在进程地址空间的共享区里栈要是也共用迟早踩踏。所以每条用户线程都会在动态库的映射区里单独分一块内存作为自己的用户栈各用各的互不干扰。线程栈大小记录当前线程栈空间能长到多大也就是它的分配上限。1.2.2 用户级TCB与内核级LWP如何分工线程这套东西不是单靠哪一层就能撑起来的。它一半在用户层一半在内核层两边各管一摊合起来才算完整。先分清各自的地盘TCB只管用户层面的线程属性至于那些跟调度息息相关的优先级、时间片、上下文信息它一概不碰。这些硬核指标全由内核层的LWP所对应的PCB统一维护。用户级线程和内核级轻量级进程之间靠的是1:1的映射关系两边一一对应协同干活。站在用户层看开发者调一下pthread_create()库就在动态库映射区里划出一块空间实例化一个struct tcb线程的“用户身份”就此建立。站在内核层看库转头通过系统调用比如clone()向内核申请创建一个新的轻量级进程LWP。这个LWP拥有自己独立的task_struct同时又共享父进程的资源内核层面的“执行实体”也就位了。调度的时候真正上场的是谁内核调度器直接调度的是LWP不是TCB。而用户这边通过线程ID也就是TCB的起始地址来操控和管理对应线程的状态。一个管“跑”一个管“看”分工明确配合默契。二、线程ID究竟是什么pthread_create会产出一个线程ID塞进第一个参数指向的地址里。但这个ID和我们前面说的线程ID压根不是一回事。前面讲的那个线程ID属于进程调度的范畴。线程是轻量级进程是操作系统调度器眼里最小的调度单位所以得有个数值来唯一标识它内核靠这个来认人。而pthread_create第一个参数指向的是一个虚拟内存单元。这个内存单元的地址才是新线程的线程ID。它属于NPTL线程库的范畴。线程库后续的所有操作都靠这个ID来找到对应的线程。线程库还提供了pthread_self用来拿线程自己的IDpthread_t pthread_self(void);那pthread_t到底是个什么类型这取决于具体实现。在Linux目前的NPTL实现里pthread_t类型的线程ID本质上就是进程地址空间上的一个地址。三、线程栈与LWP、TCB之间如何协同3.1 线程栈在哪里——分布与存储原理前面说了线程有自己独立的栈结构。图什么就图函数调用、局部变量分配、上下文压栈的时候各干各的互不打架。那这个“独立的栈结构”到底独立在哪主线程的栈主线程用的还是进程地址空间里那个传统意义上的“栈区”位于高地址向下增长。老路子没变。新线程的栈通过pthread_create()创建出来的新线程它们的栈可就不走寻常路了。它们不在传统的栈区里混而是在动态库的映射区也就是mmap区域自己开一块地盘。对这很反直觉跟你以前学的东西不太一样。在pthread库的内部结构里每个线程的管理单元是连续分配的一整块内存空间。这一整块拆成三份struct pthreadTCB线程控制块保存线程的描述属性也就是线程的“户口本”。线程局部存储TLSThread Local Storage专门用来存那些被 __thread 修饰的线程私有全局或静态变量每个线程各存各的互不干扰。线程栈Thread Stack这条新线程独占的栈空间用来存放它的局部变量和函数调用栈帧。3.1.1 pthread_t tid到底是什么调用pthread_create(tid, ...)之后你拿到手里的那个pthread_t tid到底是什么答案很干脆它就是该线程对应的管理块TCB也就是struct pthread在当前进程虚拟地址空间里的起始地址。换句话说tid不是什么凭空编出来的编号而是一个实实在在的地址。这个地址指向那块连续内存的开头而那块内存里正住着这条线程的TCB、TLS和线程栈。库只要拿到这个地址就能顺藤摸瓜找到这条线程的全部家当接下来的管理、调度、回收全从这儿下手。所以别把tid当成一个抽象的数字。它是一个指针一个通往线程“户口本”的门牌号。3.2 一个线程从创建到退出经历了什么把线程从生到死捋一遍流程其实很清晰代码段调用pthread_create创建线程块。线程开始跑执行代码段里指定的那个线程执行函数。函数跑完return (void*)xxx这个返回值被写进struct pthread里的void* ret字段先存着。代码段执行join顺着 tid 指向的那个线程块摸过去通过输出型参数ret把struct pthread 里存着的返回值取出来。取完值释放线程块。那为什么线程一结束用ps -L就再也查不到它了因为线程结束时操作系统内核里的LWP已经被自动释放了。但库里面那个TCB还杵在原地没被清掉。内核那边的人已经走了用户层这边还留着一具空壳。ps -L查的是内核眼里的线程LWP都没了自然什么都看不到。到这里两个问题就全通了为什么资源会泄漏线程跑完了LWP没了但库里的TCB还没释放。不join这块内存就一直挂着。为什么获取返回值要用二级指针因为pthread_join要修改你传进去的那个void*指针本身让它指向线程返回的数据。想改指针就得传指针的地址也就是二级指针。3.3 LWP与TCB如何联动Linux下用户级线程和内核级LWPLight Weight Process轻量级进程是一对一的绑定关系。用户态的struct pthreadTCB与内核态的task_struct靠底层系统调用牵着手紧密联动一个在用户层管描述一个在内核层管调度配合得天衣无缝。3.3.1 从clone系统调用看线程创建当我们在用户态调用pthread_create时库内部会通过clone系统调用向内核申请创建一个轻量级进程int clone(int (*fn)(void *), void *stack, int flags, void *arg, ...);确定函数入口与栈地址pthread库先往用户空间的mmap区域里划出一整块连续内存里面装着struct pthread、TLS和线程栈。接着把执行函数的入口fn也就是routine和刚分配好的用户栈地址stack一起交给clone。CPU上下文绑定内核收到参数创建对应的task_struct也就是LWP。等到CPU调度这个LWP时会自动把入口地址装进PC寄存器同时把栈指针拨到用户态开辟的那块线程栈上。于是LWP就知道该去哪儿取数据、从哪儿开始执行代码了。3.3.2 用“带饭”比喻理解用户级与内核级的职责为什么叫它“用户级线程”答案就在线程控制块的内存归属上。struct pthread这块空间直接维护在用户区的动态库里。至于调度信息、时间片这些硬核指标全甩给内核里的LWP去管。用户级线程自己呢压根不需要操心调度逻辑。打个比方就全通了。你在宿舍打游戏让舍友帮忙带一份猪脚饭。你只需要在脑子用户态TCB里记下“时间、人物、带饭内容”然后就可以躺平了。真正出门跑腿、消耗CPU时间片去执行任务的是舍友也就是内核LWP。返回值怎么传回来LWP跑完routine函数把最终结果写进用户态struct pthread里的void *ret变量。写完内核LWP自动销毁退出。舍友把饭送到任务完成人也就撤了。3.4 线程机制中的几个延伸问题3.4.1 主线程与新线程谁先执行pthread_create一旦成功返回主线程和新线程谁先抢到CPU可没有定数。完全看内核调度器当时的心情以及CPU正忙成什么样。3.4.2 pthread_detach线程分离究竟做了什么用户级线程的结构体里藏着一个叫joinable的属性字段默认值是1。joinable 1默认处于可结合状态。线程退出时内核LWP自动释放但用户态的TCB块还留着一直等到主线程调用pthread_join把返回值读走它才彻底释放。不join那就等着用户态内存泄漏。joinable 0调用pthread_detach之后这个字段变成0。线程退出时不再等主线程来 join自己把用户态的线程控制块清理干净拍拍屁股走人。所有线程都住在同一个虚拟地址空间的共享区里但每个线程能拿到的只有自己那块线程控制块的虚拟地址。3.4.3 从do_fork看内核统一的创建机制在glibc里创建线程走的是clone这个系统调用新内核还有clone3。但再往深挖Linux内核底层不管是创建线程还是创建进程最后都会汇到同一个核心函数do_fork新版本叫_do_fork或 kernel_clone。万变不离其宗。用户态这几个API在底层对应的常见创建方式有三种fork()创建子进程默认不共享地址空间玩的是写时复制Copy-On-Write。父子俩一开始共享物理页谁写谁复制。vfork()也是创建子进程但父进程会被挂起父子共享地址空间直到子进程调用exec或exit才罢休。clone()创建线程或者定制化进程。通过传入不同的flags标志位比如CLONE_VM、CLONE_FS想共享什么就共享什么灵活得很。3.5 结合源码追踪线程实现先看一段glibc-2.4里pthread的源码路径在nptl/pthread_create.c。这段代码的写法第一眼可能会让人愣一下。平时写C/C几乎见不到这种样式。它叫KR风格是旧标准C语言的函数定义语法。截图里的代码正是Linux核心C库glibc中pthread_create的底层源码片段。我们平时写多线程看到的函数原型通常是这样的int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void *), void *arg);注意第二个参数attr。它在底层对应的就是截图里那个struct pthread_attr。这个参数是给程序员用来定制线程特性的比如你想手动把线程栈改小一点或者让线程一出生就处于分离状态detach都得靠它来设置。四、从接口到实现——手动封装一个线程先看封装类的实现#pragma once #include iostream #include pthread.h #include string #include cstdio #include cstring #include functional #include unistd.h namespace MyThread { uint32_t number 1; template typename T class Thread { using func_t std::functionvoid(T); private: void enablerunning() { _isrunning true; } void enabledetach() { _isdetach true; } static void* routine(void* args) { ThreadT* self (ThreadT*)args; self-_isrunning true; self-_func(self-_data); return nullptr; } public: Thread(T data, func_t func) : _tid(0), _isrunning(false), _isdetach(false), _ret(nullptr), _func(func), _data(data) { _name Thread- std::to_string(number); } ~Thread() {} bool Start() { if (_isrunning) return false; int n pthread_create(_tid, nullptr, routine, this); if (n 0) { std::cout 线程[ _name ]创建成功 std::endl; return true; } else { std::cout 线程[ _name ]创建失败 std::endl; return false; } return false; } bool Detach() { if (!_isdetach) { int n pthread_detach(_tid); if (n 0) { std::cout 线程[ _name ]分离成功 std::endl; _isdetach true; return true; } else { std::cout 线程[ _name ]分离失败 std::endl; return false; } } return false; } bool Stop() { if (_isrunning) { int n pthread_cancel(_tid); if (n 0) { std::cout 线程[ _name ]取消成功 std::endl; _isrunning false; return true; } else { std::cout 线程[ _name ]取消失败 std::endl; return false; } } return false; } bool Join() { if (_isdetach) { std::cout 线程[ _name ]为分离线程不可被主动回收 std::endl; return false; } if (_isrunning) { int n pthread_join(_tid, _ret); if (n 0) { std::cout 线程[ _name ]回收成功 std::endl; _isrunning false; return true; } else { std::cout 线程[ _name ]回收失败 std::endl; return false; } } return false; } private: pthread_t _tid; bool _isrunning; bool _isdetach; void* _ret; func_t _func; T _data; std::string _name; }; }再看调用它的主程序#include MyThread.hpp class ThreadData { private: std::string _name; pthread_t _tid; }; int main() { ThreadData tda; MyThread::ThreadThreadData thread_1(tda, [](ThreadData td) { std::cout 线程正在执行任务1 std::endl; sleep(1); }); MyThread::ThreadThreadData thread_2(tda, [](ThreadData td) { std::cout 线程正在执行任务2 std::endl; sleep(1); }); MyThread::ThreadThreadData thread_3(tda, [](ThreadData td) { std::cout 线程正在执行任务3 std::endl; sleep(1); }); MyThread::ThreadThreadData thread_4(tda, [](ThreadData td) { std::cout 线程正在执行任务4 std::endl; sleep(1); }); thread_1.Start(); thread_2.Start(); thread_3.Start(); thread_4.Start(); thread_1.Stop(); thread_2.Stop(); thread_3.Stop(); thread_4.Stop(); thread_1.Join(); thread_2.Join(); thread_3.Join(); thread_4.Join(); return 0; }几个值得聊的点。封装思路把pthread的裸接口包成类。 构造函数收数据和回调Start拉起线程Join等它收工Detach把它推开Stop直接取消。每个动作都对应一个pthread函数再加一层状态检查和日志打印。用起来比裸调pthread清爽多了。routine是静态函数。为什么因为pthread的入口函数签名是固定的void*(*)(void*)而类的普通成员函数自带一个隐式的 this 参数签名对不上。所以routine必须声明为static然后通过void* args把this指针传进来再在里面调真正的回调。_isrunning和_isdetach是两个状态闸门。Start里检查_isrunning防止重复创建Join里检查_isdetach分离过的线程不给joinStop里也检查_isrunning没跑起来的线程取消个啥。状态一卡逻辑就稳了。_isrunning的赋值时机有点微妙。它是在routine内部被置为true的也就是说线程真正开跑了状态才更新。这比在Start里直接置true更准确毕竟pthread_create成功不代表线程已经拿到CPU开始执行了。如果这篇文章对你有帮助别忘了点个赞、点个收藏、点个关注。你的每一次反馈都是我继续硬核输出的最大动力。
返回列表