欢迎光临
我们一直在努力

《Linux系统编程》Linux 系统多线程(三):线程 ID 本质、进程地址空间布局及线程封装

🔥小叶-duck:个人主页

 ❄️个人专栏:《Data-Structure-Learning》《C++入门到进阶&自我学习过程记录》 《Linux操作系统从入门到实践》《Qt从入门到实践》 《算法题讲解指南》–优选算法 《算法题讲解指南》–递归、搜索与回溯算法 《算法题讲解指南》–动态规划算法

未择之路,不须回头 已择之路,纵是荆棘遍野,亦作花海遨游


目录

前言

一、线程 ID 的本质:线程 ID 是什么?

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

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

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

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

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

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

三、pthread_create源码解析:线程创建的完整流程

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

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

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

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

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

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

四、线程栈的底层实现与关键细节

  4.1 线程栈的内存布局

  4.2 线程栈的大小设置

  4.3 线程栈的共享性风险

  4.4 线程局部存储是什么?

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

  5.1 功能最简单的封装demo

    5.1.1 线程类头文件 Thread.hpp

    5.1.2 测试主程序 main.cc

    5.1.3 获取线程名字的方式(补充)

  5.2 功能增加:支持任意可调用对象、参数传递(模板)

    5.2.1 线程类头文件 Thread.hpp

    5.2.2 测试主程序 main.cc

  5.3 多线程的创建

    5.3.1 测试主程序 main.cc

六、面试高频考点与总结

结束语


前言

      在 Linux 后端开发领域,多线程编程是构建高性能服务的核心基础,但绝大多数开发者对线程的认知,仅仅停留在 pthread_create、pthread_join 等表层 API 调用阶段。

      一旦面试深挖底层原理:用户态的 pthread_t 本质是什么?它与系统命令 ps -aL 展示的内核 LWP 线程号有何关联与区别?线程在进程虚拟地址空间内的真实布局是怎样的?主线程栈与子线程栈存在哪些核心差异?pthread_create 底层究竟完成了哪些用户态+内核态的工作?很多同学被问到这些问题都会一知半解。答不到关键点。

      本文将完整参考 pthread 线程库源码、Linux 内核机制,结合 glibc-2.4 源码,逐层拆解 Linux 线程的完整实现逻辑,带你彻底吃透 Linux 线程。

一、线程 ID 的本质:线程 ID 是什么?

      我们日常使用 pthread_create 创建线程时,会得到一个 pthread_t 类型的线程 ID,通过pthread_self() 也能获取当前线程的 ID。但很多人刚接触和学习的时候都误解了这个 ID 的本质,先看一个最核心的结论:

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

      那 线程的用户态控制块 TCB 这个东西又是在哪呢?后面我们就会进行讲解。

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

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

#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号: %ld\\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, (void *)"sub-thread");

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

sleep(1);
return 0;
}

      通过打印的结果我们就能清晰看到两个核心区别:

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

      如果内核都不认识这个虚拟地址,那这个 pthread_t 的作用是什么呢?后面通过查看源码再结合图例我们就能解释清楚了。

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

      我们凭什么就能肯定 pthread_t 就是地址,虽然上面打印的结果来看是这么回事,但好像还是不能说明这个结论。       我们直接从 glibc 源码中找答案,源码路径:nptl/pthread_create.c 中的核心函数__pthread_create_2_1(这是pthread_create的真正实现)。后面我们会对这其中部分源码进行讲解,但这里先提出其中一行关键的代码:

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

      这里的 pd 是 struct 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。

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

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 函数。

// 重点3: ALLOCATE_STACK会在先申请struct pthread对象: 分配栈内存 + 线程TCB结构体
int err = ALLOCATE_STACK(iattr, &pd);
if (__builtin_expect(err != 0, 0))
return err;

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

  • 获取栈大小:用户在属性中设置了栈大小则使用用户值,否则使用系统默认值(通常 8MB);
  • 缓存尝试:先从线程库的栈缓存中获取空闲的栈内存,避免频繁 mmap / munmap,提升性能;
  • mmap 分配内存:缓存未命中则调用 mmap 分配匿名私有内存

mem = mmap(NULL, size, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);

  • MAP_PRIVATE:私有映射,内存修改不会同步到任何文件;
  • MAP_ANONYMOUS:匿名映射,不关联任何磁盘文件,仅用于分配内存;
    • TCB 位置计算:将 struct pthread 控制块放在分配的栈内存的末尾(高地址),保证栈向下生长时不会破坏 TCB

    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,这是线程能正常运行的基础:

    #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,里面包含了线程运行需要的所有信息,我们列出核心字段:

    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 本质:

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

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

          上面的步骤全是面向用户态的,还没有接触到内核。       所以用户态的 TCB 和栈都准备好后,就需要让内核创建对应的轻量级进程,完成线程的最终创建。这里通过 create_thread 函数,最终调用 clone 系统调用:

    // 检测线程是否为分离状态
    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 线程和进程的核心区别:

    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(地址空间)、文件表、信号表等资源,然后将新线程的入口设置为线程库的封装函数,等待调度器调度执行。

    等价于直接调用clone:

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

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

    四、线程栈的底层实现与关键细节

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

      4.1 线程栈的内存布局

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

    低地址 → 保护页(guardsize, PROT_NONE) → 线程栈可用空间 → TCB控制块 → 高地址

    • 保护页在栈的底部,防止栈向下溢出破坏其他内存;
    • 栈可用空间从高地址向低地址生长,符合 Linux 栈的生长规则;
    • TCB 放在栈内存的最高地址处,避免被栈生长破坏。

      4.2 线程栈的大小设置

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

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

    #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 线程栈的共享性风险

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

    #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;
    }

          可以看到,虽然 a 变量的确就是线程1的私有资源(线程1函数内部创建的局部变量),但是线程 2 还是成功修改了线程 1 的栈变量,这就会导致极其隐蔽的 bug,开发中绝对不要把局部栈变量的地址传递给其他线程。

      4.4 线程局部存储是什么?

    #include <iostream>
    #include <pthread.h>
    #include <unistd.h>
    #include <cstdio>

    int count = 1; //全局变量,所有线程都能看见访问

    void *routine1(void *args)
    {
    (void)args;
    while (true)
    {
    printf("thread-1, count = %d [我来修改count], &count = %p\\n", count, &count);
    count++;
    sleep(1);
    }
    }

    void *routine2(void *args)
    {
    (void)args;
    while (true)
    {
    printf("thread-2, count = %d, &count = %p\\n", count, &count);
    sleep(1);
    }
    }

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

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

          共享这个全局变量,你修改我也可以看到,变量地址也是一样的,肯定是同一个。

          我们只需要改一处地方:

          告诉编译器,局部存储的形式。

          这种变量就是线程局部存储。

          这个互不影响了,变量也不一样了,只是在编码上用了同一个名字。

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

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

      5.1 功能最简单的封装demo

        5.1.1 线程类头文件 Thread.hpp

    #ifndef __THREAD_HPP
    #define __THREAD_HPP

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

    enum TSTAYUS
    {
    THREAD_NEW, // 创建但没有运行状态
    THREAD_RUNNING, // 运行状态
    THREAD_STOPPED, // 退出状态
    };

    static int gnum = 1;
    using func_t = std::function<void()>;

    class Thread
    {
    private:
    // void *routine(void *args) //错误写法
    // 原因是函数在类内部为成员函数,第一个参数是隐含的void* this指针,会导致参数个数不匹配问题
    // 解决办法:前面加上static,静态成员函数属于整个类不存在this指针,参数个数也就匹配上了
    static void *routine(void *args)
    {
    // _func(); //回调处理(错误写法)
    // 因为上面讲了静态成员函数属于整个类不存在this指针,所以无法直接调用类中其他的成员函数和成员变量
    // 那想使用成员变量就需要this指针但又不能去掉static,怎么解决呢?pthread_create第四个参数起作用了
    // Start()函数存在this指针,我们就可以把this指针通过传入第四个参数带到这个函数中!
    // 这样我们就既拿到了this指针又不会导致函数参数个数不匹配的问题
    Thread *self = static_cast<Thread *>(args);
    self->get_pid();
    self->get_lwpid();

    //获取线程名字:pthread_setname_np
    //int pthread_setname_np(pthread_t thread, const char *name);
    pthread_setname_np(pthread_self(), self->_name.c_str());

    self->_func(); // 回调处理
    return nullptr;
    }

    void get_pid()
    {
    _pid = getpid();
    }

    void get_lwpid()
    {
    _lwpid = syscall(SYS_gettid); // syscall陷入内核获取LWP轻量级进程ID
    }

    public:
    Thread(func_t func)
    : _tid(0), _joinable(true), _status(TSTAYUS::THREAD_NEW), _func(func)
    {
    _name = "thread-" + std::to_string(gnum++);
    }

    bool Start()
    {
    if (_status == TSTAYUS::THREAD_RUNNING)
    {
    std::cerr << "thread is already running" << std::endl;
    return false;
    }
    //重点
    int n = pthread_create(&_tid, nullptr, routine, this);
    if (n != 0)
    {
    std::cerr << "pthread_create failed" << std::endl;
    return false;
    }
    else
    {
    std::cout << _name << " create success" << std::endl;
    _status = TSTAYUS::THREAD_RUNNING;
    return true;
    }
    }

    void Detach()
    {
    if (_joinable)
    {
    int n = pthread_detach(_tid);
    if (n != 0)
    {
    std::cerr << "pthread_detach failed" << std::endl;
    }
    else
    {
    std::cout << _name << " detach success" << std::endl;
    _joinable = false;
    }
    }
    else // 已经处于分离状态,不可再次分离
    {
    std::cerr << "detach failed, because thread is detached" << std::endl;
    }
    }

    void Stop()
    {
    if (_status == TSTAYUS::THREAD_RUNNING)
    {
    int n = pthread_cancel(_tid);
    if (n != 0)
    {
    std::cerr << "pthread_cancel failed" << std::endl;
    }
    else
    {
    std::cout << _name << " stop success" << std::endl;
    _status = TSTAYUS::THREAD_STOPPED;
    }
    }
    }

    void Join()
    {
    if (_joinable)
    {
    int n = pthread_join(_tid, nullptr);
    if (n != 0)
    {
    std::cerr << "pthread_join failed" << std::endl;
    }
    else
    {
    printf("lwp: %d, name: %s, thread join success\\n", _lwpid, _name.c_str());
    }
    }
    else // 分离的线程不能join
    {
    std::cerr << "join failed, because thread is detached" << std::endl;
    }
    }

    ~Thread() {}

    private:
    pthread_t _tid;
    pid_t _pid;
    pid_t _lwpid;
    std::string _name;
    bool _joinable; // 线程可否join(是否分离)
    TSTAYUS _status; // 线程状态
    func_t _func; // 回调变量
    };

    #endif

        5.1.2 测试主程序 main.cc

    #include "Thread.hpp"
    #include <vector>

    void Count()
    {
    int cnt = 10;
    while(cnt–)
    {
    //routine函数调用pthread_setname_np,再将名字获取出来:pthread_getname_np
    //int pthread_getname_np(pthread_t thread, char name[.size], size_t size);
    char name[128];
    pthread_getname_np(pthread_self(), name, sizeof(name));
    std::cout << "我是一个新线程: " << name << ", cnt: " << cnt << std::endl;
    sleep(1);
    }
    }

    int main()
    {
    Thread t(Count);
    t.Start();
    //Count函数由成员变量_func接收,调用pthread_create线程进入routine函数
    //routine函数中再进行_func()回调处理,调用Count()函数

    sleep(5);
    t.Stop();
    sleep(5);
    t.Join();
    return 0;
    }

    线程进行分离还调用 join 的情况:

    #include "Thread.hpp"
    #include <vector>

    void Count()
    {
    int cnt = 10;
    while(cnt–)
    {
    //routine函数调用pthread_setname_np,再将名字获取出来:pthread_getname_np
    //int pthread_getname_np(pthread_t thread, char name[.size], size_t size);
    char name[128];
    pthread_getname_np(pthread_self(), name, sizeof(name));
    std::cout << "我是一个新线程: " << name << ", cnt: " << cnt << std::endl;
    sleep(1);
    }
    }

    int main()
    {
    Thread t(Count);
    t.Start();
    //Count函数由成员变量_func接收,调用pthread_create线程进入routine函数
    //routine函数中再进行_func()回调处理,调用Count()函数

    t.Detach();
    sleep(5);
    t.Stop();
    sleep(5);
    t.Join();
    return 0;
    }

        5.1.3 获取线程名字的方式(补充)

          通过上面的代码我们会发现是使用 pthread_setname_np 和 pthread_getname_np 来管理线程名称。

          这两个函数属于 POSIX 线程库的非标准扩展(这就是为什么名字后面带 _np,即 non-portable)。

    #define _GNU_SOURCE /* 必须定义这个宏才能使用 */
    #include <pthread.h>

    // 设置线程名称
    int pthread_setname_np(pthread_t thread, const char *name);

    // 获取线程名称
    int pthread_getname_np(pthread_t thread, char *name, size_t len);

    关键约束

    • 长度限制:线程名称(包括终止符 \\0)不能超过 16 个字符。如果传入的字符串太长,函数会返回错误(通常是 ERANGE)。

    • 线程引用:可以通过 pthread_self() 给当前线程改名,也可以通过保存好的

          观察这两个函数的第一个参数都是传入 pthread_t thread,通过对上面线程 ID 的学习。其实这里我们就大概能清楚这两个函数会进行怎样的处理了:

      5.2 功能增加:支持任意可调用对象、参数传递(模板)

        5.2.1 线程类头文件 Thread.hpp

          相比于前面代码只需要对部分函数加以修改即可支持任意可调用对象、参数传递:

        5.2.2 测试主程序 main.cc

    #include "Thread.hpp"

    void Count(int cnt)
    {
    int n = cnt;
    while(n–)
    {
    std::cout << "我是一个新线程, cnt = " << n << std::endl;
    sleep(1);
    }
    }

    int main()
    {
    int cnt = 10;
    Thread<int> t(Count, cnt);
    t.Start();
    //Count函数由成员变量_func接收,调用pthread_create线程进入routine函数
    //routine函数中再进行_func()回调处理,调用Count()函数
    sleep(5);
    t.Stop();
    sleep(5);
    t.Join();
    return 0;
    }

      5.3 多线程的创建

        5.3.1 测试主程序 main.cc

    #include "Thread.hpp"
    #include <vector>

    // 创建多线程
    void Count()
    {
    int cnt = 3;
    while (cnt–)
    {
    // routine函数调用pthread_setname_np,再将名字获取出来:pthread_getname_np
    // int pthread_getname_np(pthread_t thread, char name[.size], size_t size);
    char name[128];
    pthread_getname_np(pthread_self(), name, sizeof(name));
    std::cout << "我是一个新线程: " << name << ", cnt: " << cnt << std::endl;
    sleep(1);
    }
    }

    int main()
    {
    std::vector<Thread> threads;
    for (int i = 0; i < 5; i++)
    {
    // threads.push_back(Thread(Count));
    threads.emplace_back(Count);
    //直接把参数转发到容器内存,原地构造对象
    }
    sleep(1);

    for (auto& thread: threads)
    {
    thread.Start();
    }
    sleep(1);

    for (auto& thread: threads)
    {
    thread.Join();
    }
    return 0;
    }

    六、面试高频考点与总结

    • 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 全部刷新,内存访问效率骤降;
      • 线程切换仅需保存和恢复寄存器上下文,进程切换还需要切换地址空间、文件表等大量资源。

    结束语

          多线程编程的难点,从来都不是 API 的调用,而是对底层实现的理解。只有搞懂了线程在地址空间中的布局、底层的创建流程,才能写出高效、安全的多线程代码,从容应对面试问题。

    赞(0)
    未经允许不得转载:171主机测评 » 《Linux系统编程》Linux 系统多线程(三):线程 ID 本质、进程地址空间布局及线程封装
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址