Linux 线程深度剖析:线程 ID 本质、地址空间布局与 pthread 源码全解


🔥草莓熊Lotso: 个人主页
❄️个人专栏: 《C++知识分享》 《Linux 入门到实践:零基础也能懂》
✨生活是默默的坚持,毅力是永久的享受!


🎬 博主简介:


文章目录

  • 前言:
  • [一. 你真的懂pthread_t吗?揭开线程 ID 的本质](#一. 你真的懂pthread_t吗?揭开线程 ID 的本质)
    • [1.1 两个线程 ID:用户态pthread_t vs 内核态 LWP](#1.1 两个线程 ID:用户态pthread_t vs 内核态 LWP)
    • [1.2 pthread_t的本质:TCB 控制块的指针](#1.2 pthread_t的本质:TCB 控制块的指针)
  • [二. 线程与进程地址空间的完整布局(可以先结合上面的图再来理解)](#二. 线程与进程地址空间的完整布局(可以先结合上面的图再来理解))
    • [2.1 先搞懂:动态库的加载映射](#2.1 先搞懂:动态库的加载映射)
    • [2.2 线程在地址空间中的完整布局](#2.2 线程在地址空间中的完整布局)
    • [2.3 主线程栈 vs 子线程栈的核心区别](#2.3 主线程栈 vs 子线程栈的核心区别)
  • [三. pthread_create源码级全解析:线程创建的完整流程](#三. pthread_create源码级全解析:线程创建的完整流程)
    • [3.1 步骤 1:函数入口与参数校验](#3.1 步骤 1:函数入口与参数校验)
    • [3.2 步骤 2:分配线程栈与 TCB 控制块](#3.2 步骤 2:分配线程栈与 TCB 控制块)
    • [3.3 步骤 3:TCB 控制块完整初始化](#3.3 步骤 3:TCB 控制块完整初始化)
    • [3.4 步骤 4:返回用户态线程 ID](#3.4 步骤 4:返回用户态线程 ID)
    • [3.5 步骤 5:调用 clone 系统调用,创建内核轻量级进程](#3.5 步骤 5:调用 clone 系统调用,创建内核轻量级进程)
    • [3.6 步骤 6:线程启动,执行用户函数](#3.6 步骤 6:线程启动,执行用户函数)
  • [四. 线程栈的底层实现与关键细节(补充了解)](#四. 线程栈的底层实现与关键细节(补充了解))
    • [4.1 线程栈的内存布局](#4.1 线程栈的内存布局)
    • [4.2 线程栈的大小设置](#4.2 线程栈的大小设置)
    • [4.3 线程栈的共享性风险](#4.3 线程栈的共享性风险)
    • [4.4 线程局部存储是什么?(图示代码和解析)](#4.4 线程局部存储是什么?(图示代码和解析))
  • [五. 从 0 到 1:基于 clone 系统调用手动实现线程](#五. 从 0 到 1:基于 clone 系统调用手动实现线程)
    • [5.1 clone 与 fork 的核心区别](#5.1 clone 与 fork 的核心区别)
    • [5.2 手动实现线程的完整代码](#5.2 手动实现线程的完整代码)
  • [六. C++ 线程封装实战:面向对象的线程类设计](#六. C++ 线程封装实战:面向对象的线程类设计)
    • [6.1 Makefile文件](#6.1 Makefile文件)
    • [6.2 线程类头文件 Thread.hpp](#6.2 线程类头文件 Thread.hpp)
    • [6.2 测试主程序 main.cc](#6.2 测试主程序 main.cc)
  • [七. 面试高频考点与全文总结](#七. 面试高频考点与全文总结)
    • [7.1 面试高频考点(必背)](#7.1 面试高频考点(必背))
    • [7.2 全文总结](#7.2 全文总结)
  • 结尾:

前言:

大家好,我是深耕 Linux 内核与系统编程的博主。在 Linux 后端开发中,多线程编程是高性能服务的基石,但绝大多数开发者对线程的理解,只停留在pthread_create /pthread_join的 API 调用层面。面试中一旦被问到:pthread_t到底是什么?和ps -aL看到的 LWP 有什么区别?线程在进程的虚拟地址空间中,到底是如何布局的?主线程栈和子线程栈有什么本质区别?pthread_create底层到底做了什么?很多同学就会一知半解。本文将完整参考 pthread 线程库源码、Linux 内核机制,结合 glibc-2.4 源码逐行解读,从用户态到内核态,完整拆解 Linux 线程的实现细节,带你彻底吃透 Linux 线程。


一. 你真的懂pthread_t吗?揭开线程 ID 的本质

我们日常使用pthread_create创建线程时,会得到一个pthread_t类型的线程 ID,通过pthread_self()也能获取当前线程的 ID。但 90% 的开发者都误解了这个 ID 的本质,先看一个最核心的结论:

pthread_t不是内核中的线程 LWP 号,其本质是进程虚拟地址空间中的一个内存地址,指向线程的用户态控制块 TCB!

1.1 两个线程 ID:用户态pthread_t vs 内核态 LWP

Linux 中,线程的实现采用**「用户态线程库 + 内核态轻量级进程 (LWP)」**的组合方案,因此存在两个完全不同的线程 ID,我们先通过代码直观感受:

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
#include <sys/syscall.h>

// 获取内核态LWP号
#define gettid() syscall(SYS_gettid)

void *thread_routine(void *arg)
{
    const char *thread_name = (const char *)arg;
    printf("[%s] 用户态pthread_t: 0x%lx\n", thread_name, pthread_self());
    printf("[%s] 内核态LWP号: %d\n", thread_name, gettid());
    
    pthread_detach(pthread_self());
    printf("%s exit...\n", thread_name);
    return NULL;
}

int main()
{
    pthread_t tid;
    pthread_create(&tid, NULL, thread_routine, "sub-thread");

    printf("[main-thread] 用户态pthread_t: 0x%lx\n", pthread_self());
    printf("[main-thread] 内核态LWP号: %d\n", gettid());
    printf("[main-thread] 新线程返回的pthread_t: 0x%lx\n", tid);

    sleep(1);
    return 0;
}

编译运行命令:gcc thread_id.c -o thread_id -lpthread

运行结果示例:

bash 复制代码
[main-thread] 用户态pthread_t: 0x7f8a1c920740
[main-thread] 内核态LWP号: 12345
[main-thread] 新线程返回的pthread_t: 0x7f8a1c000700
[sub-thread] 用户态pthread_t: 0x7f8a1c000700
[sub-thread] 内核态LWP号: 12346
sub-thread exit...

这里我们能清晰看到两个核心区别:

  • 内核态 LWP :通过gettid()获取(以前其实是需要自己去实现调系统调用的,这个后面我会在封装线程中用到),对应ps -aL命令看到的 LWP 列,是内核轻量级进程的唯一标识,是操作系统调度的最小单位,全局唯一。
  • 用户态pthread_t :通过pthread_self()获取,是 NPTL 线程库维护的进程内唯一标识,本质是一个虚拟地址,作用域仅限当前进程,内核不认识这个值。

1.2 pthread_t的本质:TCB 控制块的指针

为什么说pthread_t是一个地址?我们直接从 glibc 源码中找答案,源码路径:nptl/pthread_create.c中的核心函数__pthread_create_2_1(这是pthread_create的真正实现)。

源码中最关键的一行:

cpp 复制代码
// 重点5:把pd(就是线程控制块地址)作为ID,传递出去,所以上层拿到的就是一个虚拟地址
*newthread = (pthread_t)pd;

这里的pdstruct pthread *类型的指针,也就是线程控制块 TCB (Thread Control Block) 的起始地址。pthread库直接把 TCB 在进程虚拟地址空间中的地址,强制类型转换为pthread_t返回给用户,这就是pthread_t的本质。

后续我们调用pthread_join/pthread_detach/pthread_cancel等函数时,传入的pthread_t,本质就是告诉线程库:去操作这个地址对应的 TCB 控制块。



二. 线程与进程地址空间的完整布局(可以先结合上面的图再来理解)

理解了线程 ID 的本质,我们再看一个核心问题:进程的虚拟地址空间,是如何容纳多个线程的? 这是理解 Linux 线程实现的核心。

2.1 先搞懂:动态库的加载映射

我们使用的pthread系列函数,都来自libpthread.so动态库。而动态库在程序运行时,会被加载到进程虚拟地址空间的共享区(mmap 映射区),也就是栈区和堆区之间的内存区域,整个过程如下:

  • 程序启动时,内核加载可执行文件到代码段;
  • 动态链接器将依赖的libpthread.so加载到共享区,完成函数地址动态重定位;
  • 我们调用pthread_create时,CPU 直接跳转到共享区的库函数代码执行。

这也是为什么同一个进程的所有线程,都能调用pthread库函数 ------ 它们共享同一个地址空间,共享区的库代码对所有线程可见。

2.2 线程在地址空间中的完整布局

以 32 位 Linux 系统为例,进程虚拟地址空间范围是 0~4GB,其中内核空间占 1GB,用户空间占 3GB。多线程进程的地址空间完整布局如下(从上到下):

地址空间区域 线程相关内容 共享 / 私有
内核空间(1GB) 内核代码、数据、页表,所有线程共享同一份内核映射 全线程共享
用户栈区 主线程的栈空间,向下生长,支持动态扩容 主线程私有
共享区(mmap 映射区) 1. libpthread.so等动态库的代码和数据 2. 每个子线程的 TCB 控制块(struct pthread) 3. 每个子线程的独立栈空间 4. 线程局部存储 TLS 区域 库代码全共享,每个子线程私有自己的 TCB、栈、TLS
堆区 malloc/new分配的内存,所有线程均可访问 全线程共享
未初始化数据段(BSS) 全局变量、静态变量,所有线程共享 全线程共享
已初始化数据段 初始化的全局变量、静态常量,所有线程共享 全线程共享
代码段 程序的可执行机器指令,所有线程共享 全线程共享

这里有两个最核心的结论:

  • 进程是资源分配的基本单位(基本实体),线程是调度的基本单位:同一个进程的所有线程,共享绝大多数进程资源,只私有自己的 TCB、栈、寄存器上下文等最小调度资源。
  • 子线程的栈和 TCB,都在共享区通过 mmap 分配:这是和主线程最核心的区别,也是我们后面要重点讲解的内容。

2.3 主线程栈 vs 子线程栈的核心区别

很多同学会疑惑:都是线程栈,为什么主线程栈和子线程栈差异这么大?我们结合源码和内核机制,做一个完整的对比,这也是面试高频考点:

特性 主线程栈 子线程栈
内存位置 进程地址空间的栈区 共享区(mmap 映射区)
分配方式 进程创建时由内核自动分配 pthread库通过mmap系统调用手动分配
大小特性 支持动态向下增长,默认上限 8MB,超出后内核自动扩容 固定大小,默认 8MB,创建时确定,无法动态增长
栈溢出保护 内核自动处理,超出上限触发段错误 通过guardsize保护页实现,溢出访问保护页触发段错误
生命周期 随进程的生命周期存在,进程退出才释放 线程退出后,join/detach 时由pthread库释放,未处理会造成内存泄漏
地址生长 标准向下生长,内核维护栈边界 固定地址范围,无动态生长能力

这里重点讲两个底层细节:(第二个在之前的博客中讲过)

  • 子线程栈为什么不能动态增长? 子线程栈是mmap分配的连续固定内存块,前后都是其他线程的栈或动态库内存,没有连续的地址空间供其扩容,一旦栈使用超出分配的大小,就会直接访问到保护页,触发段错误。
  • 线程切换为什么比进程快得多? 进程切换时,内核会修改 CR3 寄存器,指向新进程的页目录,导致 CPU 的 TLB(快表)全部失效,内存访问效率骤降;而同一个进程的线程切换,CR3 寄存器值不变,页表完全共享,TLB 不会失效,只需要切换线程的寄存器上下文,开销只有进程切换的 1/10 不到。
  • 我们再往下继续深入看看源码,来全面的理解一下

三. pthread_create源码级全解析:线程创建的完整流程

理解了地址空间布局,我们再深入pthread库源码,逐行拆解pthread_create的执行全流程。源码基于 glibc-2.4,路径nptl/pthread_create.c,核心函数__pthread_create_2_1

整个线程创建流程,分为6 个核心步骤,我们逐一拆解:

3.1 步骤 1:函数入口与参数校验

cpp 复制代码
int __pthread_create_2_1(pthread_t *newthread, const pthread_attr_t *attr,
                          void *(*start_routine)(void*), void *arg)
{
    STACK_VARIABLES;
    // 重点1: 处理线程属性,用户传NULL则使用默认属性
    const struct pthread_attr *iattr = (struct pthread_attr *)attr;
    if (iattr == NULL)
        iattr = &default_attr;

    // 重点2: 定义线程TCB指针,后续会分配内存
    struct pthread *pd = NULL;
  • 入参newthread:用于返回创建后的pthread_t
  • attr:线程属性,包括栈大小、分离状态、调度优先级等,NULL 则使用默认属性;
  • start_routine:线程入口函数地址;
  • arg:传递给线程入口函数的参数。

3.2 步骤 2:分配线程栈与 TCB 控制块

这是整个函数最核心的步骤,通过ALLOCATE_STACK宏完成线程栈和 TCB 的内存分配,宏对应nptl/allocatestack.c中的allocate_stack函数。

cpp 复制代码
    // 重点3: 分配栈内存 + 线程TCB结构体
    int err = ALLOCATE_STACK(iattr, &pd);
    if (__builtin_expect(err != 0, 0))
        return err;

我们深入allocate_stack函数,看它到底做了什么:

  • 获取栈大小:用户在属性中设置了栈大小则使用用户值,否则使用系统默认值(通常 8MB);
  • 缓存尝试:先从线程库的栈缓存中获取空闲的栈内存,避免频繁 mmap/munmap,提升性能;
  • mmap 分配内存 :缓存未命中则调用mmap分配匿名私有内存(不了解的可以看看我之前写的那篇mmap的加餐博客,里面的讲解比较详细):
cpp 复制代码
mem = mmap(NULL, size, PROT_READ | PROT_WRITE,
           MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    • MAP_PRIVATE:私有映射,内存修改不会同步到任何文件;
    • MAP_ANONYMOUS:匿名映射,不关联任何磁盘文件,仅用于分配内存;
  • TCB 位置计算 :将struct pthread控制块放在分配的栈内存的末尾,保证栈向下生长时不会破坏 TCB:
cpp 复制代码
pd = (struct pthread *)((char *)mem + size - coloring) - 1;
  • 栈保护页设置 :在栈的底部分配guardsize(默认 4KB,1 页)的内存,通过mprotect设置为PROT_NONE(不可访问),一旦栈溢出访问到该页,直接触发段错误,防止破坏其他内存;
  • TCB 基础字段初始化 :初始化stackblock(栈起始地址)、stackblock_size(栈大小)、guardsize(保护页大小)等核心字段。

3.3 步骤 3:TCB 控制块完整初始化

分配好 TCB 和栈后,线程库会对 TCB 做完整的初始化,把用户传入的参数、线程属性全部写入 TCB,这是线程能正常运行的基础:

cpp 复制代码
#ifdef TLS_TCB_AT_TP
    // 初始化TLS线程局部存储
    pd->header.self = pd;
    pd->header.tcb = pd;
#endif

    // 重点4: 向TCB写入线程入口函数和参数,后续线程启动会从这里取
    pd->start_routine = start_routine;
    pd->arg = arg;

    // 拷贝线程属性、调度策略、调度参数
    pd->flags = ((iattr->flags & ~(ATTR_FLAG_SCHED_SET | ATTR_FLAG_POLICY_SET)) 
                | (self->flags & (ATTR_FLAG_SCHED_SET | ATTR_FLAG_POLICY_SET)));
    
    // 处理分离状态:如果是分离线程,joinid指向自己
    pd->joinid = iattr->flags & ATTR_FLAG_DETACHSTATE ? pd : NULL;

    // 拷贝栈溢出保护canary、调度优先级、信号处理等参数
    // ... 省略大量初始化代码

这里的struct pthread就是线程的用户态 TCB,里面包含了线程运行需要的所有信息,我们列出核心字段:

cpp 复制代码
struct pthread
{
    pid_t tid;                // 内核LWP号
    pid_t pid;                // 所属进程的PID
    void *(*start_routine)(void *); // 用户传入的线程入口函数
    void *arg;                // 入口函数的参数
    void *result;             // 线程退出的返回值,pthread_join从这里取
    struct pthread *joinid;   // 等待该线程的线程TCB,用于join机制
    void *stackblock;         // 线程栈的起始地址
    size_t stackblock_size;   // 线程栈的总大小
    size_t guardsize;         // 栈保护页大小
    int schedpolicy;          // 调度策略
    struct sched_param schedparam; // 调度优先级
    // ... 省略TLS、信号、锁、清理函数等上百个字段
};

3.4 步骤 4:返回用户态线程 ID

初始化完成后,线程库直接把 TCB 的地址作为pthread_t返回给用户,这就是我们前面讲的线程 ID 本质:

cpp 复制代码
    // 重点5: 把TCB的地址强制转换为pthread_t,返回给用户
    *newthread = (pthread_t)pd;

3.5 步骤 5:调用 clone 系统调用,创建内核轻量级进程

用户态的 TCB 和栈都准备好后,就需要让内核创建对应的轻量级进程,完成线程的最终创建。这里通过create_thread函数,最终调用clone系统调用:

cpp 复制代码
    // 检测线程是否为分离状态
    bool is_detached = IS_DETACHED(pd);
    
    // 重点6: 创建内核轻量级进程,启动线程执行
    err = create_thread(pd, iattr, STACK_VARIABLES_ARGS);
    if (err != 0)
    {
        // 创建失败,释放已分配的栈和TCB
        if (!is_detached)
            __deallocate_stack(pd);
        return err;
    }

    return 0;
}
// 版本绑定,pthread_create最终指向该函数
versioned_symbol(libpthread, __pthread_create_2_1, pthread_create, GLIBC_2_1);

我们看create_thread中最核心的clone_flags标志位,这是 Linux 线程和进程的核心区别:

cpp 复制代码
int clone_flags = (CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGNAL
                  | CLONE_SETTLS | CLONE_PARENT_SETTID
                  | CLONE_CHILD_CLEARTID | CLONE_SYSVSEM | 0);

每个标志位的含义,决定了线程和父进程共享哪些资源:

标志位 核心作用
CLONE_VM 共享虚拟地址空间,父子进程使用同一个页表,这是线程和进程最核心的区别
CLONE_FS 共享文件系统信息,包括工作目录、umask 等
CLONE_FILES 共享文件描述符表,一个线程打开的文件,其他线程可以直接使用
CLONE_SIGNAL 共享信号处理函数表,信号的处理方式对所有线程生效
CLONE_SETTLS 为新线程设置 TLS 线程局部存储区域
CLONE_PARENT_SETTID 内核将新线程的 LWP 号写入父进程指定的地址
CLONE_CHILD_CLEARTID 线程退出时,内核清空该地址,并触发 futex 唤醒等待的线程(join 机制的核心)

最终,create_thread会调用do_clone,再通过汇编封装的__clone函数陷入内核,执行 clone 系统调用。内核会创建新的task_struct,共享父进程的mm_struct(地址空间)、文件表、信号表等资源,然后将新线程的入口设置为线程库的封装函数,等待调度器调度执行。

3.6 步骤 6:线程启动,执行用户函数

内核的轻量级进程被调度后,不会直接执行用户传入的start_routine,而是先执行线程库的封装函数,做一些初始化工作,再调用用户函数,执行完成后自动调用pthread_exit退出。


四. 线程栈的底层实现与关键细节(补充了解)

线程栈是线程私有资源的核心,也是多线程编程中最容易出问题的地方,我们结合源码,讲透线程栈的关键细节。

4.1 线程栈的内存布局

每个子线程的栈,都是mmap分配的一块连续内存,整体布局如下(从低地址到高地址):

bash 复制代码
低地址  →  保护页(guardsize, PROT_NONE)  →  线程栈可用空间  →  TCB控制块  →  高地址
  • 保护页在栈的底部,防止栈向下溢出破坏其他内存;
  • 栈可用空间从高地址向低地址生长,符合 Linux 栈的生长规则;
  • TCB 放在栈内存的最高地址处,避免被栈生长破坏。

4.2 线程栈的大小设置

默认情况下,Linux 线程栈的大小是 8MB,我们可以通过两种方式修改:

  • 全局修改 :通过ulimit -s 10240修改系统默认栈大小为 10MB,仅对当前终端生效;
  • 代码中精准修改:通过线程属性设置,示例如下:
cpp 复制代码
#include <stdio.h>
#include <pthread.h>

void *routine(void *arg)
{
    printf("thread is running\n");
    return NULL;
}

int main()
{
    pthread_t tid;
    pthread_attr_t attr;
    // 初始化属性
    pthread_attr_init(&attr);
    // 设置栈大小为1MB
    pthread_attr_setstacksize(&attr, 1024 * 1024);
    
    // 用自定义属性创建线程
    pthread_create(&tid, &attr, routine, NULL);
    pthread_join(tid, NULL);
    
    // 销毁属性
    pthread_attr_destroy(&attr);
    return 0;
}

注意 :线程栈大小不能小于系统最小值PTHREAD_STACK_MIN(通常 16KB),否则会创建失败。

4.3 线程栈的共享性风险

虽然每个线程的栈是私有的,但这只是逻辑上的私有。因为所有线程共享同一个地址空间,只要一个线程拿到了另一个线程栈变量的地址,就可以随意修改该变量 ,这是多线程编程中非常隐蔽的风险点。

我们看一个反面示例:

cpp 复制代码
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>

int *shared_stack_var = NULL;

void *thread1_routine(void *arg)
{
    int a = 100; // 线程1的栈变量
    shared_stack_var = &a; // 把栈变量地址暴露给全局
    printf("thread1 set a = %d\n", a);
    sleep(2); // 等待线程2修改
    printf("thread1 a is changed to %d\n", a);
    return NULL;
}

void *thread2_routine(void *arg)
{
    sleep(1);
    *shared_stack_var = 200; // 直接修改线程1的栈变量
    printf("thread2 modify a to 200\n");
    return NULL;
}

int main()
{
    pthread_t t1, t2;
    pthread_create(&t1, NULL, thread1_routine, NULL);
    pthread_create(&t2, NULL, thread2_routine, NULL);
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    return 0;
}

运行结果:

cpp 复制代码
thread1 set a = 100
thread2 modify a to 200
thread1 a is changed to 200

可以看到,线程 2 成功修改了线程 1 的栈变量,这会导致极其隐蔽的 bug,开发中绝对不要把局部栈变量的地址传递给其他线程

4.4 线程局部存储是什么?(图示代码和解析)



五. 从 0 到 1:基于 clone 系统调用手动实现线程

理解了pthread_create的底层是 clone 系统调用,我们可以直接基于 clone 手动实现一个极简的线程,直观感受 Linux 线程的底层实现。

5.1 clone 与 fork 的核心区别

  • fork():创建子进程时,复制父进程的所有资源,采用写时拷贝,父子进程拥有独立的地址空间;
  • clone():可以通过参数精准控制父子进程共享哪些资源,传入CLONE_VM就可以共享地址空间,实现线程。

5.2 手动实现线程的完整代码

cpp 复制代码
#define _GNU_SOURCE
#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>

// 定义线程栈大小 1MB
#define STACK_SIZE (1024 * 1024)

// 子线程执行的函数
static int child_thread_func(void *arg)
{
    char *thread_name = (char *)arg;
    printf("[%s] 进程PID: %d, 线程LWP: %d\n", thread_name, getpid(), gettid());
    printf("[%s] 线程正在执行...\n", thread_name);
    
    for (int i = 0; i < 3; i++)
    {
        printf("[%s] 计数: %d\n", thread_name, i);
        sleep(1);
    }

    printf("[%s] 线程退出\n", thread_name);
    return 0;
}

int main()
{
    printf("[main] 主进程PID: %d, 主线程LWP: %d\n", getpid(), gettid());

    // 1. 为子线程分配栈空间
    char *stack = (char *)malloc(STACK_SIZE);
    if (stack == NULL)
    {
        perror("malloc stack failed");
        exit(EXIT_FAILURE);
    }

    // 2. 创建线程:共享地址空间,栈从高地址向低地址生长,所以传入栈的末尾
    pid_t tid = clone(child_thread_func, 
                      stack + STACK_SIZE, 
                      CLONE_VM | CLONE_FS | CLONE_FILES | SIGCHLD, 
                      "my-thread");

    if (tid == -1)
    {
        perror("clone failed");
        free(stack);
        exit(EXIT_FAILURE);
    }

    printf("[main] 成功创建线程,LWP号: %d\n", tid);

    // 3. 等待子线程结束
    if (waitpid(tid, NULL, 0) == -1)
    {
        perror("waitpid failed");
        free(stack);
        exit(EXIT_FAILURE);
    }

    printf("[main] 线程已退出,程序结束\n");
    free(stack);
    return 0;
}

编译运行命令:gcc clone_thread.c -o clone_thread,运行结果:

bash 复制代码
[main] 主进程PID: 12500, 主线程LWP: 12500
[main] 成功创建线程,LWP号: 12501
[my-thread] 进程PID: 12500, 线程LWP: 12501
[my-thread] 线程正在执行...
[my-thread] 计数: 0
[my-thread] 计数: 1
[my-thread] 计数: 2
[my-thread] 线程退出
[main] 线程已退出,程序结束

可以看到,我们通过 clone 创建的轻量级进程,和主线程拥有相同的 PID,不同的 LWP,共享同一个地址空间,这就是 Linux 线程的本质。


六. C++ 线程封装实战:面向对象的线程类设计

在 C++ 开发中,我们通常会对原生的 pthread 接口做面向对象的封装,屏蔽底层 C 接口的细节,让线程使用更便捷。我们参考文档中的封装 demo,实现一个功能完整的 C++ 线程类,支持任意可调用对象、参数传递、join/detach、线程命名等功能。

6.1 Makefile文件

bash 复制代码
CXX = g++
CXXFLAGS = -std=c++11 -Wall -g
TARGET = testThread
SRCS = Main.cc
OBJS = $(SRCS:.cc=.o)

$(TARGET): $(OBJS)
	$(CXX) $(CXXFLAGS) -o $@ $^

%.o: %.cc Thread.hpp
	$(CXX) $(CXXFLAGS) -c -o $@ $<

clean:
	rm -f $(OBJS) $(TARGET)

.PHONY: clean

6.2 线程类头文件 Thread.hpp

cpp 复制代码
#ifndef __THREAD_HPP
#define __THREAD_HPP

#include <iostream>
#include <string>
#include <functional>
#include <pthread.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/syscall.h>

using func_t = std::function<void()>;

enum  class TSTAYUS
{
    THREAD_NEW,
    THREAD_RUNNING,
    THREAD_STOPPED,
};
// 这个是有点bug的
static int gunm = 1;

class Thread
{
private:
    void get_pid()
    {
        _pid = getpid();
    }
    void get_lwid()
    {
        _lwid = syscall(SYS_gettid);
    }
    static void* routine(void* args)
    {
        Thread* ts = static_cast<Thread*>(args);
        ts->get_pid();
        ts->get_lwid();
        pthread_setname_np(pthread_self(), ts->Name().c_str());
        ts->_func();
        return nullptr;
    }
public:
    Thread(func_t f) : _func(f), _joinable(true), _status(TSTAYUS::THREAD_NEW)
    {
        _name = "thread-" + std::to_string(gunm++);
    }
    void start()
    {
        if(_status == TSTAYUS::THREAD_RUNNING)
        {
            std::cerr << "thread is already running" << std::endl;
            return;
        }
        int n = pthread_create(&_tid, nullptr, routine, this);
        if(n != 0)
        {
            std::cerr << "pthread_create failed" << std::endl;
        }
        _status = TSTAYUS::THREAD_RUNNING;
    }
    void stop()
    {
        if(_status == TSTAYUS::THREAD_RUNNING)
        {
            int n = pthread_cancel(_tid);
            if(n != 0)
            {
                std::cerr << "pthread_cancel failed" << std::endl;
            }
            _status = TSTAYUS::THREAD_STOPPED;
        }
        else 
        {
            std::cerr << "thread status is : THREAD_STOPPED or THREAD_NEW" << std::endl;
            return;
        }
    }
    void join()
    {
        if(_joinable)
        {
            int n = pthread_join(_tid, nullptr);
            if(n != 0)
            {
                std::cerr << "pthread_join failed" << std::endl;
            }
            printf("lwp: %d, name: %s, join success\n", _lwid, _name.c_str());
        }
        else {
            printf("lwp: %d, name: %s, join failed, because thread is detached\n", _lwid, _name.c_str());
        }
    }
    void detach()
    {
        if(_joinable && _status == TSTAYUS::THREAD_RUNNING)
        {
            _joinable = false;
            int n = pthread_detach(_tid);
            if(n != 0)
            {
                std::cerr << "pthread_detach failed" << std::endl;
            }
        }
    }
    std::string Name()
    {
        return _name;
    }
    ~Thread()
    {}
private:
    pthread_t _tid;
    pid_t _pid;
    pid_t _lwid;
    std::string _name;
    func_t _func;
    bool _joinable;
    TSTAYUS _status;
};

#endif

6.2 测试主程序 main.cc

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <vector>
#include "Thread.hpp"

void hello()
{
    char name[64];
    pthread_getname_np(pthread_self(), name, sizeof(name));
    int count = 0;
    while(count < 5) // 执行5次后退出
    {
        std::cout << "hello thread: " << name << std::endl;
        sleep(1);
        count++;
    }
}
int main()
{
    std::vector<Thread> threads;
    for(int i = 0; i < 10; i++)
    {
        threads.emplace_back(hello);
    }

    for(auto& t : threads)
    {
        t.start();
    }

    for(auto& t : threads)
    {
        t.join();
    }
    
    return 0;
}

编译运行命令:直接make就行了,运行后可以通过ps -aL看到子线程,完美实现了面向对象的线程封装。


七. 面试高频考点与全文总结

7.1 面试高频考点(必背)

  • pthread_t和 LWP 的区别是什么?
    • pthread_t是用户态线程库维护的线程 ID,本质是进程虚拟地址空间中 TCB 控制块的地址,进程内唯一;
    • LWP 是内核轻量级进程的 ID,是操作系统调度的最小单位,全局唯一。
  • 线程在进程地址空间中是如何布局的?
    • 主线程栈在地址空间的栈区,子线程的栈和 TCB 控制块都在共享区(mmap 区),通过 mmap 分配;
    • 所有线程共享代码段、数据段、堆区、文件描述符表、信号处理函数等进程资源,仅私有栈、TCB、寄存器上下文。
  • pthread_create底层做了哪些事情?
    • 校验线程属性,使用默认属性填充;
    • 通过 mmap 分配线程栈和 TCB 控制块内存;
    • 初始化 TCB,写入线程入口函数、参数、属性等信息;
    • 将 TCB 地址作为pthread_t返回给用户;
    • 调用 clone 系统调用,创建内核轻量级进程,设置共享资源标志位;
    • 内核创建 task_struct,共享进程地址空间,等待调度执行。
  • 主线程栈和子线程栈的核心区别?
    • 位置不同:主线程栈在栈区,子线程栈在共享区;
    • 分配方式不同:主线程栈由内核分配,子线程栈由 pthread 库 mmap 分配;
    • 生长特性不同:主线程栈支持动态增长,子线程栈固定大小,无法扩容。
  • 为什么线程切换比进程切换快?
    • 线程切换无需修改 CR3 寄存器,页表共享,CPU 的 TLB 不会失效,内存访问效率不受影响;
    • 进程切换需要切换 CR3 寄存器,TLB 全部刷新,内存访问效率骤降;
    • 线程切换仅需保存和恢复寄存器上下文,进程切换还需要切换地址空间、文件表等大量资源。

7.2 全文总结

Linux 下的线程实现,是用户态 NPTL 线程库 + 内核态轻量级进程的完美结合:

  • 用户态线程库负责 TCB 管理、栈内存分配、TLS 管理、线程生命周期控制;
  • 内核负责线程的调度,通过 clone 系统调用实现资源共享,让多个轻量级进程共享同一个进程的地址空间;
  • pthread_t的本质是 TCB 控制块的虚拟地址,这是理解整个线程实现的核心钥匙。

结尾:

html 复制代码
🍓 我是草莓熊 Lotso!若这篇技术干货帮你打通了学习中的卡点:
👀 【关注】跟我一起深耕技术领域,从基础到进阶,见证每一次成长
❤️ 【点赞】让优质内容被更多人看见,让知识传递更有力量
⭐ 【收藏】把核心知识点、实战技巧存好,需要时直接查、随时用
💬 【评论】分享你的经验或疑问(比如曾踩过的技术坑?),一起交流避坑
🗳️ 【投票】用你的选择助力社区内容方向,告诉大家哪个技术点最该重点拆解
技术之路难免有困惑,但同行的人会让前进更有方向~愿我们都能在自己专注的领域里,一步步靠近心中的技术目标!

结语:多线程编程的难点,从来都不是 API 的调用,而是对底层实现的理解。只有搞懂了线程在地址空间中的布局、底层的创建流程,才能写出高效、安全的多线程代码,从容应对面试和线上问题。如果本文对你有帮助,欢迎点赞、收藏、关注,后续会持续分享 Linux 系统编程、网络编程、后端开发的硬核内容!

✨把这些内容吃透超牛的!放松下吧✨ ʕ˘ᴥ˘ʔ づきらど

相关推荐
嵌入式吴彦祖2 小时前
RKNN demo运行
linux
沐雪轻挽萤2 小时前
2. C++17新特性-结构化绑定 (Structured Bindings)
java·开发语言·c++
殇者知忧2 小时前
Tmux快速上手
linux·tmux
AcrelGHP2 小时前
安科瑞AIM-T系列工业IT绝缘监测及故障定位解决方案为关键供电场所筑牢安全防线
大数据·运维·数据库
私人珍藏库2 小时前
【Android】Shizuku升级版-Stellar-提高软件权限
android·app·工具·软件·多功能
fīɡЙtīиɡ ℡2 小时前
【Mysql——MVCC】
数据库·mysql
XDHCOM2 小时前
ORA-31477: LogMiner会话清理失败,Oracle报错故障修复远程处理,快速解决,数据安全无忧
数据库·oracle
Robot_Nav2 小时前
ThetaStar全局规划算法纯C++控制器详解
开发语言·c++·lazy_theta_star
白毛大侠2 小时前
# MySQL InnoDB 隔离级别与 MVCC 完全解析
android·数据库·mysql