Linux系统篇42——线程(七) pthread库管理线程的工作流,从创建到回收


📚 本文收录于「流浪」的系列专栏

🐧 Linux系统 ⚙️ C++
📊 数据结构与算法 🐍 Python
🔗 LangChain & LangGraph 🗄️ MySQL 数据库
🌿 Git 工具 🌐 计算机网络
🤖 AI 💯 大厂面试、八股
📚 学习筑基专栏

🏠 博客主页:流浪 | 📝 原创首发于 CSDN


前言: 线程(六)讲完退出和隔离,线程控制的面儿全了。但还有个更底层的问题没拆开------pthread 库到底是怎么把一个线程从无到有管起来的。本篇拆这条工作流:库怎么描述线程,内核怎么造执行流,两者怎么联动,join 怎么收割。


一、Linux 没有真正的线程,线程库是用户层的封装

1.1 内核眼里只有轻量级进程

篇36 讲过一个底层事实:Linux 没有真正的线程,只是用轻量级进程(LWP)来模拟的。内核里只有 task_struct 这一种执行流描述符,线程和进程在内核眼里是同一种东西,区别只在共享了哪些资源。

由此推出一个关键结论:OS 不提供线程接口 。既然内核不认识「线程」这个概念,自然不会有 create_thread() 这样的系统调用给你用。

1.2 用户层自己封装,造出原生线程库

内核不提供,应用又需要,这个缺口只能由用户层来补------在用户层封装轻量级进程,形成原生线程库。

Linux 下这就是 NPTL(Native POSIX Threads Library,原生 POSIX 线程库) ,从 glibc 2.3.2 起成为标准实现。按照 man 手册 pthreads(7) 的说法,它是 1:1 实现------你每创建一个用户线程,库就在内核里造一个对应的调度实体,一个对一个。
1:1 意味着每个线程在内核眼里都是一个独立可调度的任务,多核机器上可以真正并行着跑,ps -eLf 里能看到每个线程自己的 LWP 号。更早的实现 LinuxThreads 还要在进程里塞一个 manager 线程替库打杂,NPTL 把它干掉了,线程就是普通任务,没有中间商。

我们前面所有篇章用的 pthread_create、pthread_join,全是这个库提供的函数。

1.3 这个库是动态库,映射进进程地址空间

pthread 库本身是个动态库。动态库和你的可执行程序是同一种 ELF 格式,只是文件类型字段标的是共享对象(ET_DYN),而不是可执行(ET_EXEC)------这是 ELF 规范里 e_type 字段的两种取值,本质是一家人。

程序启动时会发生什么?可执行程序被加载,形成进程,经过动态链接和动态地址重定向,动态库也被加载到内存,映射到当前进程的地址空间。

这一步是后面一切的前提:库被映射进了进程地址空间,进程自己的代码才可以访问 pthread 库内部的相关数据。管理线程用的数据结构、栈、属性,全都放在这块映射出来的区域里,跟你的全局变量、堆栈处在同一个地址空间中,互相看得见。


二、库要管理线程,先描述再组织

线程的概念是在库中维护的。库内部一定会存在多个被创建好的线程,那库要不要管理它们?要 。管理的套路还是那句老话------先描述,再组织。

2.1 先描述,管理块里记什么

描述线程,就是给每个线程建一个结构体。线程控制块(TCB)是这类结构的通用叫法,glibc 源码里的实名是 struct pthread ,定义在 nptl/descr.h 中。本篇后面统一用管理块称呼它。

核心字段长这样(简化自 glibc nptl/descr.h):

c 复制代码
struct pthread
{
    pid_t tid;              // 内核侧的轻量级进程 ID
    void *result;           // 线程的返回值
    void *(*start_routine)(void *);  // 线程要执行的函数
    void *arg;              // 传给函数的参数
    // 线程状态、独立栈结构、栈大小、取消状态...
};

线程从创建到组织起来要记的参数,就落在这几个字段上:内核 ID、返回值、执行方法和参数、栈信息、状态标志。前两篇埋的伏笔在这里收口------篇40 拆过 pthread_t 的本质是地址,那个地址指向的就是这个管理块;篇39 讲过 create 的第三参数是返回值通道,函数指针和参数先存进管理块,就是为执行那天做准备。

2.2 调度属性不归库管,归内核

有一批属性在描述时被刻意排除了:优先级、时间片、上下文 。这三样不进管理块,它们走另一条路------LWP → PCB,在内核中维护。

道理很简单,这三样都是调度概念,而调度是内核的活。库在用户态,管不着 CPU 给谁时间片。于是线程的相关概念被劈成了两半:

对比项 用户层(pthread 库) 内核(LWP → PCB)
管理块 struct pthread task_struct
记什么 线程状态、线程 id、独立栈、栈大小、返回值、函数指针 优先级、时间片、上下文
谁在用 pthread 系列库函数 内核调度器

线程的相关概念,一部分在用户层,一部分在内核维护。 这张表就是本篇的中心结论,后面三章全是围绕它展开的工作流。

2.3 再组织,管理块挂在链表上

光描述不够,多个管理块得串起来。glibc 的做法是把每个 struct pthread 用 list 字段挂进库维护的全局链表(源码注释写明挂在 dl_stack_used 或 dl_stack_user 链上)。库想遍历所有线程、找一个线程、回收一个线程,顺着链表走就行。

内核侧同样是组织好的------所有 task_struct 挂在内核自己的调度数据结构里,等着调度器挑。用户态一条链,内核态一条链,两边的组织各管各的,靠管理块里的 tid 字段把同一个线程在两边对上号。

先描述、再组织,进程管理那套方法论在线程库身上原样重演了一遍。


三、pthread_create 背后,创建线程的完整工作流

铺垫到位,看主戏。用户线程和 LWP 是如何关联的?先看一个前提:每个线程都有自己独立的栈空间------主线程用地址空间里的栈区,新线程用库 mmap 出来的线程栈,这点篇38 已经拆过,不重讲。

带着这个前提,看一个线程是怎么被创建的。代码区里就一行:

c 复制代码
pthread_create(&id, ..., routine, arg);

这一行背后是两个步骤。

1. 库里先建管理块,起始地址写进 id

调用 create 时,pthread 库会先在库中创建一个 struct pthread 管理块,把 routine、arg、栈信息、状态这些字段填好,再将管理块的起始地址写入 id 中。

这就是篇40 那个结论的诞生现场------pthread_t 为什么是个地址?因为 create 压根没给你什么编号,它直接把库内部数据结构的地址交了出来。之后你调 pthread_join、pthread_detach,库拿着这个地址一步到位找到管理块,中间不需要任何映射表。

2. clone 系统调用,在内核里建 LWP

create 还要在内核中创建轻量级进程。这一步走的是系统调用 clone------既然线程在内核眼里就是 LWP,那创建线程就是让内核建一个 task_struct。

man 手册里 clone 的 wrapper 原型长这样:

c 复制代码
int clone(int (*fn)(void *), void *child_stack,
          int flags, void *arg, ...);

注意前两个参数:fn,执行方法;child_stack,栈在哪里。内核要造执行流,必须知道两件事------这个执行流从哪个函数开始跑、用什么栈跑。栈由库提前 mmap 好,把栈顶地址递过去;fn 传的是库自己的启动函数,用户的方法存在管理块里,由它中转。
flags 则告诉内核共享什么:CLONE_VM 共享地址空间、CLONE_FILES 共享文件描述符表、CLONE_THREAD 归入同一线程组......共享得越多,新 task 越像线程,这正是篇36 讲过的「用进程模拟线程」在系统调用层面的落地。


四、内核和用户层就这样联动起来

clone 返回,LWP 造好了,接下来轮到 CPU。

4.1 新线程怎么跑起来

CPU 在进行调度时会自动执行对应的方法 ------调度器选中这个新 LWP,执行流从库的启动函数进入,启动函数从管理块里取出 start_routine 和 arg,去执行自己定义的方法,再把线程私有的栈结构地址交给它。

「把栈地址交给它」落到硬件层面,就是把栈寄存器指向那块 mmap 出来的空间。此后函数运行形成的临时数据,会自动压进线程栈------局部变量、函数调用帧,全落在线程自己的私有地盘上,谁也踩不着谁。

4.2 用户线程和 LWP 的关联闭环

到这里,整条链路闭环了,内核和用户层在一定程度联动起来:

  • 用户层的库出数据结构(管理块)和栈;
  • 内核出执行流(LWP/task_struct)和调度;
  • 管理块的 tid 字段记着内核给的 LWP ID,库借它跟内核对话;pthread_t 指向管理块,LWP 在内核调度------一个用户线程就这样和 LWP 一对一关联。

你写的 routine 函数跑在库准备的栈上、被内核当成 LWP 调度、被库的数据结构记录------三方合资,才凑出一个「线程」。


五、线程的回收,join 从管理块里拿返回值

创建讲完讲回收。站在 struct_pthread 和线程返回值的角度,回收过程值得单独拆一章。

5.1 返回值写进管理块

线程结束后,返回值被写进 struct_pthread 的 result 字段。篇39 讲过 return 的值通过 void* 通道传出来、join 用二级指针接住------那个值最终落在哪?就落在管理块里。

5.2 线程被销毁,管理块没销毁

关键一句:线程被销毁,但是 struct_pthread 没有被销毁,通过 struct_pthread 进行 join。

「线程被销毁」销的是内核侧的执行流------LWP 不复存在,调度队列里没有它了。「没被销毁」的是用户侧的数据------管理块还挂在库的链表上,result 字段原封不动地存着退出信息。它处没有,想拿线程的返回值,只能通过这个管理块。

两层销毁时机不同,恰好对应 2.2 那张表的左右两半:内核的跟着调度实体走,说没就没;用户层的跟着数据走,等人来收。

5.3 join 的收割动作

站在管理块和返回值的角度,join 的回收过程三步走:先等 ------通过 futex 阻塞在目标线程的结束事件上;再取 ------目标线程结束后,从它的管理块里把 result 读出来,交给 join 的第二个参数;后释放------返回值拿走了,管理块连同 mmap 出来的线程栈才真正被库回收。

回收下来的栈也不会马上还给系统。篇38 讲过 glibc 有栈缓存------回收的栈先进缓存池,等下一个 pthread_create 来了直接复用,省去又一次 mmap。所以「释放」释放的是库的占用,不是内存的消失。

线程(六)讲过 detach------分离的线程没人 join,库就在它结束时自己走完取值和释放这套动作,管理块直接回收。所以分离线程的返回值你拿不到,不是库小气,是收割流程压根没给你留入口。


六、线程的局部存储,__thread 让全局变量每线程一份

工作流讲完,补一个库挂在线程身上的私有能力。篇38 拆 errno 时点过一句------错误码能线程私有,靠的就是线程局部存储(TLS,Thread-Local Storage)。

6.1 变量前加 __thread,一个变量各线程一份

用法就一个动作,全局或静态变量前面加 __thread:

cpp 复制代码
__thread int g_val = 10;   // 加 __thread,全局变量按线程私有一份
  • 变量前的 __thread 起到引导编译器的作用。没有它,g_val 全进程一份,定义在已初始化数据段共享;加上它,编译器不再按全局共享的方式安置这个变量,而是给每个线程准备一份私有副本,放进各自线程的局部存储空间------大家的变量名相同,底层虚拟地址不同。

demo

cpp 复制代码
int a=10;
std::string Addr(int &num)
{
    char a[64];
    snprintf(a,sizeof(a),"%p",&num);
    return a;
}

void *routine1(void *mes)
{
    while(true)
    {
        std::cout<<"我是线程1,a的值为:"<<a++<<" "<<"a的地址:"<<Addr(a)<<std::endl;
        sleep(1);
    }
    return nullptr;
}

void *routine2(void *mes)
{
    while(true)
    {
        std::cout<<"我是线程2,a的值为:"<<a  <<" "<<"a的地址:"<<Addr(a)<<std::endl;
        sleep(1);
    }
    return nullptr;
}

int main()
{
    pthread_t tid1,tid2;
    pthread_create(&tid1,nullptr,routine1,nullptr);
    pthread_create(&tid2,nullptr,routine2,nullptr);

    pthread_join(tid1,nullptr);
    pthread_join(tid2,nullptr);
    return 0;
}

加上__thread之后:

  • 容易联想到父子进程的写时拷贝,像,但机制不一样:写时拷贝先共享一份、写到才复制;局部存储是线程的存储空间里本来就备好独立一份,没有「触发拷贝」这个动作。共性只有一个------各有一份,互不干扰。

6.2 它解决什么问题

有一个全局变量,但又不想让这个全局变量被其他线程看到。 全局的写法省事,共享的副作用全不想要------局部存储就是为这种情况准备的,访问零成本,副作用隔离在各自线程里。

errno 是现成的招牌:出错码放全局,一个线程刚查完就被另一个覆盖,错误码全乱套。篇38 那句「每线程一份」到这里才算把用法补全。

6.3 两个边界,外加名字存哪的甄别

1. 类型受限,只能存内置类型和部分指针

局部存储装不下复杂对象。 GCC 手册要求 C++ 的初始化必须是常量表达式------带自定义构造、析构的类对象放不进来。 日常用法就剩整型、指针这类,「内置类型和部分指针」说的就是这条边界。

2. setname_np 设置的名字,每线程一份但不放局部存储

pthread_setname_np / pthread_getname_np 给线程起名字,名字限 16 字节(含结尾 '\0'),超了报 ERANGE。名字每线程一份,各写各的,不存在互相覆盖的并发问题。

但 man 手册注记写明 setname_np 内部写的是 /proc/self/task/tid/comm------名字落在内核 task_struct 的 comm 字段,不在用户层的局部存储里。「无并发问题」取的是每线程一份的私有语义,位置上面试别说错。

七、全篇总结

  • Linux 没有真正的线程,内核用 LWP 模拟,OS 不提供线程接口,用户层封装出原生线程库 NPTL,1:1 映射。
  • 库是动态库,ELF 格式,映射进进程地址空间,进程代码才能访问库内部数据。
  • 先描述再组织------描述是 struct pthread 管理块(记 tid、result、start_routine、栈信息),组织是挂在库的全局链表上;优先级、时间片、上下文归内核,走 LWP → PCB。
  • 创建两步走------库里建管理块、起始地址写进 id;clone 在内核建 LWP,前两个参数是执行方法和栈位置。
  • 执行靠联动------CPU 调度 LWP,从库的启动函数进入,转去执行用户方法,临时数据自动入线程栈。
  • 回收看两层------线程销毁的是 LWP,管理块留着,join 从 result 取值后才释放管理块和栈。
  • 线程局部存储------全局变量加 __thread 每线程一份,变量名相同地址不同;只能放内置类型和部分指针,errno 是招牌应用。

八、源码框架--线程创建到销毁全链路骨架

cpp 复制代码
/*
 * LWP-Life 框架 ------ 线程从创建到销毁的全链路骨架(glibc nptl)
 * 闭环:申请空间 -> 克隆 -> 运行 -> 退出 -> 等待/回收 -> 归还缓存 -> 再被申请
 */

/* 一、线程描述符 TCB:struct pthread 关键成员 */
struct pthread
{
    pid_t tid;   // 线程 ID,兼作"该描述符(栈)是否在被使用"的标志
                 // >=0 在用;==0 内核已把它清零(线程真走了);<0 已被自行 detach/join
    pid_t pid;   // 进程 ID,内核里叫线程组 ID(tgid)

    bool user_stack; // 栈是否由用户提供(否则由库 mmap 而来,才可进缓存复用)

    void *result;    // 线程函数的返回值 void* 放在这里
                     // pthread_join 拿退出信息,读的就是它
                     // 也解释了"执行流可以退出,TCB 可以暂时保留"

    struct sched_param schedparam;    // 调度参数
    int schedpolicy;                  // 调度策略

    void *(*start_routine) (void *);  // 用户指定的线程函数
    void *arg;                        // 用户指定的参数

    void *stackblock;       // 线程整块空间的起始地址
    size_t stackblock_size; // 整块空间的大小
};

/* 二、pthread_create:用户层入口,创建链路起点 */
int
__pthread_create_2_1 (pthread_t *newthread, const pthread_attr_t *attr,
                      void *(*start_routine) (void *), void *arg)
{
    // 线程属性:没传就退回默认属性
    const struct pthread_attr *iattr = (struct pthread_attr *) attr;
    if (iattr == NULL)
        iattr = &default_attr;

    struct pthread *pd = NULL;   // 待填充的线程描述符

    // 申请一大块线程私有空间,struct pthread 落在空间开头(见第三阶段)
    int err = ALLOCATE_STACK (iattr, &pd);

    // 把要执行的函数和参数登记进 TCB(稍后由新线程自己取出来跑)
    pd->start_routine = start_routine;
    pd->arg = arg;

    // 把 TCB 地址作为线程 ID 交给上层 ------ pthread_t 本质是一个虚拟地址
    *newthread = (pthread_t) pd;

    bool is_detached = IS_DETACHED (pd);           // 是否分离:决定退出时谁释放资源

    err = create_thread (pd, iattr, STACK_VARIABLES_ARGS);   // 真正创建(第四阶段)
    if (err != 0)
        __deallocate_stack (pd);                   // 创建失败:空间直接还回去
    return err;
}

/* 三、allocate_stack:安家 ------ 申请空间并确定 TCB 位置 */
static int
allocate_stack (const struct pthread_attr *attr, struct pthread **pdp,
                ALLOCATE_STACK_PARMS)
{
    // 栈大小:用户设置了就用用户的,否则用默认值
    size = attr->stacksize ?: __default_stacksize;

    // 先尝试从栈缓存申请:缓存里挂的是已退出线程的旧栈
    // 判定能否复用的条件是 FREE_P(curr) == (curr->tid <= 0)
    // ------ 内核只有在线程真正退出后才会把 tid 清零,这就是闭环的连接点
    pd = get_cached_stack (&size, &mem);
    if (pd == NULL)
        // 缓存没有合适的 -> mmap 申请私有匿名内存,作用类似 malloc
        ;

    // 在申请到的空间中确定 TCB 地址:放在这块空间的末尾
    pd = (struct pthread *) ((char *) mem + size - coloring) - 1;

    // 记录整块空间的地址和大小,回收时按它归还/复用
    pd->stackblock = mem;
    pd->stackblock_size = size;

    // 线程即轻量级进程,记录所属进程的 pid
    pd->pid = THREAD_GETMEM (THREAD_SELF, pid);
    return 0;
}

/* 四、create_thread:向内核下创建订单,成功后放行新线程 */
static int
create_thread (struct pthread *pd, const struct pthread_attr *attr,
               STACK_VARIABLES_DECL)
{
    // 最后一个参数控制新线程是否先挂起,等父线程登记完信息再放行
    int res = do_clone (pd, attr, clone_flags, start_thread,
                        STACK_VARIABLES_ARGS, stopped);

    if (res == 0)
    {
        // 登记 TD_CREATE 调试事件,挂进事件链表并通知调试器
        pd->eventbuf.eventnum = TD_CREATE;
        pd->eventbuf.eventdata = pd;
        // ... CAS 挂链表 + __nptl_create_event() ...

        lll_unlock (pd->lock);      // 解锁放行:新线程这才开始真正跑
    }
    return res;
}

/* 4.1 do_clone 内部:打包参数,真正陷入内核 */
// ARCH_CLONE 把入口函数 fct、参数 pd、TLS、tid 地址打包交给内核,返回 -1 即失败
if (ARCH_CLONE (fct, STACK_VARIABLES_ARGS, clone_flags,
                pd, &pd->tid, TLS_VALUE, &pd->tid) == -1)
    /* 失败:释放栈,返回错误码 */;

movl $SYS_ify(clone), %eax   /* 系统调用号进 eax */
syscall                      /* 陷入内核(x86_32 是 int 80),要求内核创建轻量级进程 */
/* 内核从此担起一个约定:clone 带了 CLONE_CHILD_CLEARTID,
   线程死时会把 *(&pd->tid) 清零并 futex 唤醒等待者 ------ 后面 join 全靠它 */

/* 五、start_thread:新线程的起点,也是退出的起点 */
static int
start_thread (void *arg)
{
    struct pthread *pd = arg;

    // 若被要求挂起启动:抢到 pd->lock 才算拿到 TCB 所有权,随即释放
    // 这一步和上面 create_thread 的解锁正好对上,保证父子交接完成
    if (pd->stopped_start)
    {
        lll_lock (pd->lock, LLL_PRIVATE);
        lll_unlock (pd->lock, LLL_PRIVATE);
    }

    // setjmp 埋点:pthread_exit / 取消时的清理回滚就落在 unwind_buf 上
    int not_first_call = setjmp (unwind_buf.cancel_jmp_buf);

    if (__glibc_likely (! not_first_call))
    {
        THREAD_SETMEM (pd, cleanup_jmp_buf, &unwind_buf);

        void *ret = pd->start_routine (pd->arg);   // 跑用户的线程函数
        THREAD_SETMEM (pd, result, ret);           // 返回值落进 TCB
    }

    call_function_static_weak (__call_tls_dtors);  // 析构 thread_local 变量
    __nptl_deallocate_tsd ();                      // 清理线程局部数据
    __libc_thread_freeres ();                      // 清理 libc 的线程状态

    atomic_fetch_or_relaxed (&pd->cancelhandling, EXITING_BITMASK);  // 标记正在退出

    // 已经没人能 join(分离线程)?自己把资源清干净
    if (IS_DETACHED (pd))
        __free_tcb (pd);

    // 不能 _exit(会杀整个进程):只退出当前执行流
    // 内核按 CLONE_CHILD_CLEARTID 的约定把 pd->tid 清零,并 futex 唤醒 join 者
    // tid 归零后,这块栈才可能被 get_cached_stack 挑走复用
    while (1)
        INTERNAL_SYSCALL_CALL (exit, 0);
}

/* 六、pthread_join:等待线程死亡并取回结果(非分离线程由这里收尾) */
int
__pthread_join (pthread_t threadid, void **thread_return)
{
    struct pthread *pd = (struct pthread *) threadid;   // pthread_t 就是 TCB 地址

    if (INVALID_NOT_TERMINATED_TD_P (pd)) return ESRCH; // 句柄已失效
    if (IS_DETACHED (pd))                return EINVAL; // 分离线程不可 join
    if (pd == THREAD_SELF)               return EDEADLK;// 自己 join 自己:死锁

    atomic_exchange (&pd->joinid, self);   // 抢占 join 权,只能有一个等待者

    // 在 pd->tid 上 futex 阻塞:非 0 就睡,内核清零时自动唤醒
    lll_wait_tid (pd->tid);

    pd->tid = -1;                          // 标记:线程已终止且已被 join
    if (thread_return != NULL)
        *thread_return = pd->result;       // 取回返回值
    __free_tcb (pd);                       // 未分离线程的资源,由 join 者释放
    return 0;
}

/* 七、__free_tcb -> __deallocate_stack:资源归还,闭环回到第三阶段 */
void
__free_tcb (struct pthread *pd)
{
    // 首次置上 TERMINATED 位的那个线程负责回收(分离线程是自己,join 情形是调用者)
    if (__builtin_expect (atomic_bit_test_set (&pd->cancelhandling,
                                               TERMINATED_BIT) == 0, 1))
        __deallocate_stack (pd);
}

void
__deallocate_stack (struct pthread *pd)
{
    lll_lock (stack_cache_lock, LLL_PRIVATE);
    stack_list_del (&pd->list);            // 先移出在用链表

    if (__glibc_likely (! pd->user_stack))
        queue_stack (pd);                  // 库申请的栈:进缓存,留给下一个线程复用
    else
        _dl_deallocate_tls (TLS_TPADJ (pd), false); // 用户自己的栈不能动,只释放 TLS
    lll_unlock (stack_cache_lock, LLL_PRIVATE);
}

static inline void
queue_stack (struct pthread *stack)
{
    // 无条件挂进缓存:这块内存的 tid 还没被内核清零前不会被选中复用
    stack_list_add (&stack->list, &stack_cache);
    stack_cache_actsize += stack->stackblock_size;
    if (__glibc_unlikely (stack_cache_actsize > stack_cache_maxsize))
        __free_stacks (stack_cache_maxsize);  // 缓存超限:挑旧的真正 munmap 归还
}

九、文末面试题

9.1 推导题

1. 为什么内核不提供线程接口,线程还能被使用?

答(推导):内核虽然不认识「线程」,但提供了 clone 这个可控共享的系统调用。用户层库把 clone 封装起来,配上自己的数据结构(管理块、线程栈),在用户态拼出了线程的完整语义。OS 不提供的是「线程」这个名字和接口,不提供不了的是共享执行流的能力。

2. clone 的 fn 参数和用户传给 pthread_create 的 start_routine 是同一个函数吗?

答(推导):不是。clone 的 fn 传的是库内部的启动函数,用户的方法 start_routine 和参数 arg 先存进管理块。新 LWP 被调度时先执行库的启动函数,再由它从管理块取出 start_routine 转调。多做这一层中转,库才能在执行前后插手------填 tid、收返回值、标记状态,全靠这个中转站。

3. 线程结束后,内核的 task_struct 和库里的管理块谁先消失?

答(推导):task_struct 先消失。线程退出即执行流终结,内核侧的 LWP 不复存在;管理块是库的数据结构,要等 join(或 detach 的自动回收)取走 result 后才释放。所以「线程死了但返回值还在」,拿返回值靠的是管理块。

4. 为什么 create 直接把管理块地址当线程 ID 返回给用户?

答(推导):后续所有库函数(join、detach、cancel)都需要找到目标线程的管理块。直接把地址当 ID,库函数拿到参数就能访问数据,省去一次 ID 到内存块的映射查找;地址在进程地址空间内天然唯一,也不会和别的线程混淆。代价是它只在进程内有意义,内核不认这个值。

5. 主线程有没有管理块和线程栈?

答(推导):主线程同样被库管理,pthread_self 在 main 里一样能拿到 ID。区别在栈------主线程用的是进程地址空间里的栈区,随进程创建就有;新线程的栈才是库 mmap 出来的。所以主线程和子线程在库眼里都是线程,差异只在栈的来源。

6. 库把线程概念劈成用户层一半、内核一半,为什么不干脆全放一边?

答(推导):全放内核,就得给内核加线程接口,改动内核且违背 Linux「一切皆进程」的简化设计;全放用户层,调度属性(优先级、时间片、上下文)没人管------调度是内核的职权,用户态碰不到 CPU 分配。两半的划分不是设计者的偏好,是职权边界:数据归看得见它的库,调度归管得了 CPU 的内核。

9.2 真题

1. 在 Linux 中有一个线程被创建出来,会发生什么?

【真题·转述自掘金面经复盘帖《面试复盘:谈谈 Linux 在创建线程时做了什么工作》】

答(推导 · 已对照面经,转述):按本篇工作流作答------库先在用户态建管理块、mmap 线程栈,把 pthread_t(管理块地址)交给调用者;再走 clone 系统调用,内核创建 task_struct(LWP),按 flags 共享地址空间和文件表,得到自己的 TID;新执行流进调度队列,被调度后从库的启动函数进入,转去执行用户方法,临时数据压线程栈。面经里面试官还期待你能点出 CLONE_VM 这类 flags 的作用,本篇第三章正好补齐。

2. pthread_create 是系统调用吗?它和 clone 是什么关系?

【真题·转述自 CSDN 技术博客《Linux 线程机制》面试高频总结,iies.in 面试题汇编同题互证】

答(推导 · 已对照面经,转述):不是。pthread_create 是用户态库函数,clone 才是系统调用。前者封装后者:建管理块、备栈,然后带上一串 CLONE_ 标志调 clone 造出 LWP。内核从头到尾不知道「线程」的存在,它只是照要求建了一个共享资源的 task_struct。


💬 结语: 到这里,线程系列从概念、控制、退出一路拆到了库和内核的交界处。一个 pthread_create 背后站着两个世界,看懂这条工作流,前面几篇的所有结论(pthread_t 是地址、返回值通道、栈私有)就全部串成了一张网。哪一步最出乎你的意料,评论区聊聊。如果这篇对你有帮助,点个赞再走,关注流浪,Linux 系统篇持续更新。


相关推荐
Dragon~Snow1 小时前
Linux server CentOS stream 10系统构建
linux·运维·centos
霞姐聊IT1 小时前
服务器RAS软件开发经验分享——从Linux、BMC与BIOS三个层面谈工程实践(二)
linux·服务器
高山有多高2 小时前
【Linux笔记】Linux磁盘文件系统
linux
几何心凉2 小时前
嵌入式Linux系统开发21天速成:从基础开发到综合项目实战
linux·运维·服务器
Tairitsu_H2 小时前
[Linux系统] 一切皆文件 | 缓冲区机制 | FILE 结构
linux·运维·服务器·文件·缓冲区
blueSatchel2 小时前
UVC驱动源码研究
linux·嵌入式
高山有多高2 小时前
【Linux笔记】Linux基本指令
linux·运维·笔记
码上有光3 小时前
Linux:进程间通信——匿名管道通信、命名管道通信
android·java·linux·linux通信·匿名通信·命名通信
w01_02_033 小时前
cpu , 控制 ,计算。
linux