say_fall头像
【Linux系统编程】线程概念与控制:从虚拟地址空间到pthread库的深度剖析封面图

【Linux系统编程】线程概念与控制:从虚拟地址空间到pthread库的深度剖析

线程概念与控制:从虚拟地址空间到pthread库的深度剖析

在这里插入图片描述

🌈 say-fall:个人主页
🚀 专栏:《手把手教你学会C++》 | 《系统深入Linux操作系统》 | 《数据结构与算法》
💪 格言:做好你自己,才能吸引更多人,与他们共赢,这才是最好的成长方式。

📝 前言

在前面的文章中,我们已经系统学习了进程控制:fork 创建子进程、进程等待、进程程序替换……但你有没有想过一个问题:fork 一次创建一个子进程,代价实在太大了——要复制页表、写时拷贝、维护独立性,成本很高。

对比项创建新进程创建新线程
地址空间独立,需重建映射体系✅ 共享同一套
页表整套复制✅ 不用动
数据写时拷贝,按需分离天然共享,通信零成本

🔍 问题来了: 有没有办法让多个执行流共享同一份地址空间,既省下创建成本,又能并行干活?

答案就是——线程(Thread)!本文将从「线程是什么」一路讲到「虚拟地址空间如何被划分」,再落地到 pthread 库的创建、等待、终止、分离,最后亲手把线程封装成 C++ 类,彻底搞懂线程的概念与控制。


一、🌲 线程概念:什么是线程

1.1 站在两个视角看线程

先复习一句话:进程 = 内核数据结构 + 代码和数据。其中真正在 CPU 上"跑"的,是代码和数据这个执行流部分。

  • OS 视角:进程是承担分配系统资源的基本实体;
  • CPU 视角:它只认执行流——线程就是 CPU 调度的基本单位。

所以更准确的定义是:线程是进程内部的一个执行分支,是"一个进程内部的控制序列"。线程在进程内部运行,本质是在进程地址空间内运行。一切进程至少都包含一个执行线程。

💡 关键理解: 进程访问的大部分资源,都是通过地址空间访问的。既然如此,只要把进程的虚拟地址空间划分给不同的执行流,就形成了不同的线程执行流——这就是"将进程资源合理分配给每个执行流"的含义。

由此我们可以得到几个重要结论:

💡 四个核心结论:

  • 结论1:Linux 中的"线程"可以用进程来模拟——把资源分配给不同的 task_struct,用进程模拟线程;
  • 结论2:对资源的划分,实质就是对地址空间的划分;
  • 结论3:如何理解"代码段的划分"?函数本质上是虚拟地址的集合,让不同的线程去执行 ELF 中不同函数区间,代码就被"划分"了;
  • 结论4:之前所学的"单进程",其实是内部只有一个线程的进程。

1.2 其他操作系统怎么做的?Linux 为什么特立独行?

Windows 中存在真正的线程 TCB 结构,内核需要单独为线程设计一套数据结构和管理逻辑。而 Linux 的代码更加轻量:进程和线程都使用同一个 task_struct,线程只是"轻量级进程",或者说用轻量级进程模拟的。

为什么这样设计?

  • 线程控制块(TCB)和进程控制块(PCB)要管理的东西高度类似:状态、上下文、调度信息……
  • 与其再造一套结构,不如✅ 直接复用 PCB——代码更少、更易维护、不易出 bug。

在 CPU 眼中,Linux 里的 PCB 比传统进程更轻量化,这个"更轻量化的 PCB"就叫 LWP(Light Weight Process,轻量级进程)。

1.3 线程的优点

优点说明
创建代价小创建一个新线程比创建一个新进程的代价小得多
占用资源少线程占用的资源比进程少
切换成本低见下方重点分析
充分利用多核能充分利用多处理器的可并行数量
I/O 与计算重叠等待慢速 I/O 操作的同时,程序可执行其他计算任务
两类应用友好计算密集型:将计算分解到多个线程跑满多核;I/O 密集型:让 I/O 操作重叠,同时等待不同的 I/O

其中线程切换比进程切换"少做很多工作",这是面试高频点,值得展开:

💡 线程切换为什么快?

  • 线程切换时虚拟地址空间不变,所以不用切换页表、不用更换 CR3 寄存器;
  • 地址空间不变意味着硬件 cache、TLB(快表)里缓存的内容依然有效——而进程切换会把 TLB 全部刷新,导致切换后一段时间内内存访问相当低效,还会瞬间作废处理器中已缓存的内存地址;
  • 两者上下文切换都由内核完成,寄存器内容切换是共同开销,但上面两条是线程独有的优势。

1.4 线程的缺点

缺点说明
性能损失计算密集型线程数量超过可用处理器时,反而增加额外的同步和调度开销,而可用资源不变
健壮性降低线程之间缺乏保护,共享了不该共享的变量、时间分配的细微偏差都可能造成不良影响
缺乏访问控制进程是访问控制的基本粒度,一个线程中调用某些 OS 函数会影响整个进程
编程难度提高编写与调试多线程程序比单线程困难得多

1.5 线程异常:一损俱损

⚠️ 记住一句话:线程出异常 ⇒ 进程出异常 ⇒ 直接挂!

单个线程如果出现除零、野指针等问题导致崩溃,整个进程也会随之崩溃。因为线程只是进程的执行分支,线程出异常就类似进程出异常,会触发信号机制终止进程——进程一旦终止,该进程内的所有线程也就随即退出了。


二、🔍 深入虚拟地址空间:分页式存储管理

要真正理解线程,就必须搞清楚内核是如何进行资源划分的——而资源划分的载体,就是虚拟地址空间和页表。这一节是全文的地基。

2.1 为什么需要虚拟地址和页表?

设想没有虚拟内存和分页机制的情况:每个程序在物理内存上必须连续存放。程序退出后回收的空间大小不一,运行一段时间后物理内存就被切割成各种离散的、大小不同的内存碎片。

我们的诉求是:给用户看的地址空间连续,物理内存最好离散。于是:

  • 把物理内存按固定长度分割成一个个页框(page frame);
  • 把虚拟地址空间分成同样大小的页(page);
  • 用页表记录"页 → 页框"的映射关系。

💡 区分页与页框: 页框是一个存储区域;页是一个数据块,可以存放在任意页框或磁盘中。大多数 32 位体系结构支持 4KB 的页,Windows/Linux 的页框大小就是 4KB。

2.2 物理内存怎么管理?——一张大数组!

4GB 内存按 4KB 划分:4GB / 4KB = 1048576 个页框。操作系统要把它们管理起来,内核用 struct page 结构表示每个物理页,并以数组形式组织:

// 内核中:物理页以数组形式被组织,用下标直接访问!
struct page mem[1048576];

💡 一个颠覆认知的结论: 申请物理内存,本质上就是查这个数组——找到没有使用的 page,拿到它的下标(即物理页框地址),然后建立内核数据结构之间的对应关系。struct page 内部用了大量联合体 union 来节省内存,每个结构体约 40 字节,管理 1M 个页总共只消耗约 40MB,对 4GB 内存来说代价很小。

2.3 页表太长怎么办?——多级页表

如果用单级页表映射全部 4GB 虚拟空间:需要 1048576 个表项,每项 4 字节,页表本身就要 4MB——而且必须连续占用 1024 个页框,这和我们引入分页的初衷背道而驰。再加上局部性原理告诉我们:进程一段时间内往往只访问某几个页,没必要让所有映射常驻内存。

解决方案:把页表再"分页"——形成二级页表:

  • 一个页目录(4KB,1024 个表项)指向 1024 张页表;
  • 每张页表(4KB,1024 个表项)指向物理页;
  • 1024 × 1024 依然覆盖 4GB,但一个 10MB 的程序用 3 张页表就够了。

于是 32 位虚拟地址被切分为 10 + 10 + 12 三段:

虚拟地址(32位):
| 10位:页目录索引 | 10位:页表索引 | 12位:页内偏移 |
      查页目录          查页表          物理页内偏移

地址转换由 MMU(内存管理单元,一种硬件电路)完成,分两个阶段:

  1. 第一阶段:先查虚拟地址对应的页框——CR3 寄存器指向当前进程的页目录,按高 10 位查页目录、再按中间 10 位查页表,得到物理页框地址;
  2. 第二阶段:根据虚拟地址低 12 位作为页内偏移,访问物理页。

为什么偏偏是低 12 位做页内偏移?

  • 页大小 4KB = 4096 Byte,正好需要 12 位编址;
  • 一个物理页的地址一定是 4KB 对齐的(末 12 位全 0),所以页表项只需记录物理页地址的高 20 位;
  • 高 20 位相同、低 12 位连续的地址,局部必然属于同一个 4KB 页。

💡 效率补丁——TLB 快表: 多级页表是双刃剑:省空间但查询慢(N 级页表 = N 次检索 + 1 次读写)。MMU 引入了缓存机制 TLB(转译后备缓冲器):先问 TLB,命中直接拿物理地址;未命中(Cache Miss)再查页表,并顺手把这条映射记进 TLB。

2.4 缺页异常:越界了就一定会崩溃吗?

CPU 给 MMU 的虚拟地址,在 TLB 和页表中都找不到对应物理页,或者存在但无权限,就会触发缺页异常(Page Fault)——一个由硬件中断触发、可以由软件逻辑纠正的"错误"。进程从用户态切换到内核态,交给 Page Fault Handler 处理:

类型含义处理方式
Hard Page Fault物理内存中没有对应物理页从磁盘读取到内存,再建立映射
Soft Page Fault物理页存在(其他进程调入的),只是本进程还没建立映射只需建立映射,无需读磁盘
Invalid Page Fault非法访问,如野指针解引用报 segment fault,进程挂掉

🔍 越界了不一定崩溃! 越界了不一定会崩溃,因为越界发生时操作系统不一定知道(可能恰好指向了合法空间)。OS 靠两步检查区分"缺页"和"越界":

  • 第一步 · 页号合法性检查:检查触发事件的虚拟地址页号是否合法——页号合法但页面不在内存中 → 缺页中断;页号非法 → 越界访问;
  • 第二步 · 内存映射检查:检查虚拟地址是否在当前进程的内存映射范围内——在范围内但不在内存中 → 缺页中断;不在范围内 → 越界访问。

由此,之前积攒的几个疑问全都通透了:

  • 如何理解 new/malloc? 申请内存 → 查 page 数组 → 拿到空闲 page 的下标 → 得到物理页框地址,背后可能还要建立新的页表和映射关系(分配的往往只是虚拟地址空间,真正访问时才缺页建立映射);
  • 如何理解写时拷贝? 本质也是缺页中断:访问时发现页面无写权限/不在内存,内核在缺页处理中重新建立映射;
  • 进程的映射体系是什么? 一个进程 = 一张页目录 + N 张页表构建的映射体系。虚拟地址是索引,物理地址是目标,公式:虚拟地址低12位 + 页框地址 = 物理地址。

2.5 资源划分与资源共享的真相

现在可以回答开头的问题了。执行流看到的资源,本质是:在合法的情况下,有明确分配给自己的虚拟地址——虚拟地址就是资源的"代表"。地址空间由 mm_struct + vm_area_struct 描述,页表则是一张从虚拟到物理的地图。

💡 全文最重要的两句话:

  • 资源划分:本质是地址空间的划分——只要把虚拟地址空间划一块出来,资源就天然被划分好了;
  • 资源共享:本质是虚拟地址(页表条目)的共享——多个执行流指向同一批页表映射,自然看到同一份资源。

线程的一切,都建立在这两句话之上。


三、📊 进程 VS 线程:哪些共享,哪些私有

维度进程线程
定位资源分配的基本单位调度的基本单位
独立性进程间具有独立性共享地址空间,也就共享进程资源

线程共享进程数据,但也拥有一部分"私有"数据:

共享私有
同一地址空间(代码段、数据段共享)线程 ID
文件描述符表一组寄存器(上下文数据,可被调度)
每种信号的处理方式(SIG_IGN、SIG_DFL 或自定义)栈(独立栈结构 → 线程是动态的)
当前工作目录errno
用户 id 和组 id信号屏蔽字
—调度优先级

💡 再问一次: 如何看待之前学习的单进程?——它就是具有一个线程执行流的进程。


四、🛠️ Linux 线程控制

4.1 验证线程的轻量级包装:LWP

先眼见为实。写一个多线程程序,用 ps -aL 查看线程信息:

$ ps -aL | head -1 && ps -aL | grep mythread
   PID     LWP TTY          TIME CMD
2711838 2711838 pts/235  00:00:00 mythread
2711838 2711839 pts/235  00:00:00 mythread
  • -L 选项:打印线程信息;
  • 可以看到同一个 PID 下有两个 LWP——CPU 实际调度使用的就是 LWP;
  • 其中一个 LWP 与 PID 相同,它就是主线程。

由此引出三个常见疑问:

  1. 关于调度的名词问题: 进程组内的多线程会"平分"进程的调度权重,组间权重不同——调度是以 LWP 为单位进行的;
  2. 异常怎么办? 单线程异常会直接使整个进程异常(见 1.5);
  3. 线程编号显示有时会混乱? 线程信息属于共享内存层面的显示内容,处理方式因实现而异,了解即可。

4.2 为什么要引入 pthread 线程库?

这里存在一个认知错位:

  • 用户层:只认识"线程",所有操作系统教材讲的也都是线程;
  • Linux 内核:根本没有"线程"这个概念,只有轻量级进程,底层只提供 clone() / fork() 这类系统调用。

怎么办?——pthread 库把轻量级进程包装起来,提供统一的线程接口,LWP 用户不关心。

💡 重要结论: Linux 的线程方案是在用户层实现的,我们使用的其实是"用户态线程",pthread 被称为原生线程库(绝大多数函数以 pthread_ 开头,使用时引入 <pthread.h>,编译时加 -lpthread 选项)。更妙的是:如果我们自己在上层设计线程管理块排队,甚至可以基于内核在用户层实现自己的"线程调度"——很多更高级的语言就做了这个工作。

4.3 创建线程:pthread_create

#include <pthread.h>
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
                   void *(*start_routine)(void*), void *arg);
参数含义
thread输出型参数,返回线程 ID
attr线程属性,一般传 nullptr 用默认属性
start_routine函数指针,线程启动后要执行的函数入口
arg传给线程启动函数的参数

返回值:成功返回 0;失败返回错误码。注意 pthreads 函数出错时不会设置全局 errno,而是把错误码通过返回值返回(读取返回值比读取线程内的 errno 开销更小)。

第一个 demo——两个执行流同时打印:

#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <pthread.h>

void *rout(void *arg)
{
    for ( ; ; )
    {
        printf("I am thread 1\n");
        sleep(1);
    }
}

int main(void)
{
    pthread_t tid;
    int ret;
    // ========== 创建线程,检查返回值 ==========
    if ((ret = pthread_create(&tid, NULL, rout, NULL)) != 0)
    {
        fprintf(stderr, "pthread_create : %s\n", strerror(ret));
        exit(EXIT_FAILURE);
    }
    for ( ; ; )
    {
        printf("I am main thread\n");
        sleep(1);
    }
}

⚠️ 创建线程后要等待! 主线程要等待新线程,否则会造成类似僵尸进程的问题(线程退出了但资源没人回收)。

4.4 线程等待:pthread_join

为什么需要线程等待?

  • 已经退出的线程,其空间没有被释放,仍然在进程的地址空间内;
  • 创建新的线程时,不会复用刚才退出线程的地址空间。
#include <pthread.h>
int pthread_join(pthread_t thread, void **retval);
// thread: 线程ID
// retval: 输出型参数,指向线程的返回值(退出码)
// 成功返回0;失败返回错误码

这个函数就是 Linux 下类似 waitpid 的函数:调用后挂起等待,直到 id 为 thread 的线程结束。线程以不同方式终止,join 拿到的终止状态也不同:

线程终止方式pthread_join 拿到的结果
return 返回线程函数的返回值
pthread_cancel 被取消常量 PTHREAD_CANCELED(值就是 -1)
pthread_exit 自行退出传给 pthread_exit 的参数
不关心终止状态给 retval 传 NULL 即可

三种终止方式一次验证:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>

void *thread1(void *arg)   // 方式一:return
{
    printf("thread 1 returning ...\n");
    int *p = (int*)malloc(sizeof(int));
    *p = 1;
    return (void*)p;
}
void *thread2(void *arg)   // 方式二:pthread_exit
{
    printf("thread 2 exiting ...\n");
    int *p = (int*)malloc(sizeof(int));
    *p = 2;
    pthread_exit((void*)p);
}
void *thread3(void *arg)   // 方式三:被别人 cancel
{
    while (1)
    {
        printf("thread 3 is running ...\n");
        sleep(1);
    }
    return NULL;
}

int main(void)
{
    pthread_t tid;
    void *ret;

    // ========== thread 1: return ==========
    pthread_create(&tid, NULL, thread1, NULL);
    pthread_join(tid, &ret);
    printf("thread return, thread id %X, return code:%d\n", tid, *(int*)ret);
    free(ret);

    // ========== thread 2: pthread_exit ==========
    pthread_create(&tid, NULL, thread2, NULL);
    pthread_join(tid, &ret);
    printf("thread return, thread id %X, return code:%d\n", tid, *(int*)ret);
    free(ret);

    // ========== thread 3: pthread_cancel ==========
    pthread_create(&tid, NULL, thread3, NULL);
    sleep(3);
    pthread_cancel(tid);
    pthread_join(tid, &ret);
    if (ret == PTHREAD_CANCELED)
        printf("thread return, thread id %X, return code:PTHREAD_CANCELED\n", tid);
    else
        printf("thread return, thread id %X, return code:NULL\n", tid);
}

运行结果:

$ ./a.out
thread 1 returning ...
thread return, thread id 5AA79700, return code:1
thread 2 exiting ...
thread return, thread id 5AA79700, return code:2
thread 3 is running ...
thread 3 is running ...
thread 3 is running ...
thread return, thread id 5AA79700, return code:PTHREAD_CANCELED

4.5 线程终止的三种方式与一个禁忌

如果只需要终止某个线程而不终止整个进程,有三种方法:

方法说明
return从线程函数 return。⚠️ 对主线程不适用——从 main 函数 return 相当于调用 exit,代表主线程结束,一般也代表进程结束
pthread_exitvoid pthread_exit(void *value_ptr); 线程专用终止函数
pthread_cancelint pthread_cancel(pthread_t thread); 取消同一进程中的另一个线程(取消成功后对方 join 到的退出码是 PTHREAD_CANCELED,被取消之后也是要 join 的)

❌ 禁忌:线程不能调用 exit()! exit 会直接结束整个进程,进程终止,所有线程全部退出。

⚠️ 返回值的坑: pthread_exit 或 return 返回的指针,指向的内存必须是全局的或 malloc 出来的,不能放在线程函数的栈上——因为其它线程拿到这个返回指针时,线程函数的栈早已销毁。

💡 能力总结: 给线程传的参数和返回值,可以是任意类型(包括对象),于是可以利用对象来完成一系列任务。

4.6 线程分离:pthread_detach

默认情况下,新创建的线程是 joinable 的:线程退出后,必须对它执行 pthread_join,否则无法释放资源,造成系统泄漏。但如果主线程不关心新线程的返回值,join 就是一种负担——这时可以告诉系统:线程退出时自动释放资源。

#include <pthread.h>
int pthread_detach(pthread_t thread);
// 可以是线程组内其他线程对目标线程进行分离,也可以是线程自己分离自己:
pthread_detach(pthread_self());

💡 理解分离: 线程分离的语义是"主不等待新,新把自己分离"。但要注意——分离的线程依旧在进程地址空间中,进程的所有资源它依然可以访问和操作,只是主线程不再等待它了。joinable 和分离是冲突的:一个线程不能既是 joinable 又是分离的,join 对分离的线程不再生效。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>

void *thread_run(void *arg)
{
    pthread_detach(pthread_self()); // 新线程自己分离自己
    printf("%s\n", (char*)arg);
    return NULL;
}

int main(void)
{
    pthread_t tid;
    if (pthread_create(&tid, NULL, thread_run, "thread1 run...") != 0)
    {
        printf("create thread error\n");
        return 1;
    }
    int ret = 0;
    sleep(1); // 很重要:要让线程先分离,再等待
    if (pthread_join(tid, NULL) == 0)
        printf("pthread wait success\n");
    else
        printf("pthread wait failed\n");
    return ret;
}

4.7 线程 ID:tid 不是 LWP!

这里必须掰清楚两个"ID":

ID属于谁本质
LWP内核调度范畴(ps -aL 看到的)内核中轻量级进程的唯一标识
pthread_t(tid)NPTL 线程库范畴一个进程地址空间上的虚拟地址

pthread_create 第一个参数带回的"线程 ID"和 LWP 不是一回事。对 Linux 的 NPTL 实现而言,pthread_t 类型的线程 ID 本质就是一个地址——通过 pthread_self() 获得的这个数,实际上是虚拟地址空间上的一个地址,通过这个地址可以找到关于这个线程的基本信息(线程 ID、线程栈、寄存器等属性)。

#include <pthread.h>
pthread_t pthread_self(void); // 查看自己 tid 的函数

💡 为什么主线程特殊? ps -aL 中有一个线程 ID 与进程 ID 相同,它就是主线程。主线程的栈在地址空间的栈区上,而其他线程的栈在共享区(堆栈之间)——因为 pthread 系列函数由 pthread 库提供,而 pthread 库正是加载在共享区的。


五、🗺️ 线程 ID、地址空间布局与线程栈

5.1 库也要管理线程:tcb

线程的概念是在库中维护的——库内部一定有"被创建好的线程"需要管理,所以库也要管理线程。每个线程在库中对应一个 tcb(线程控制块),描述线程的结构体,其中包含线程应有的属性:

struct tcb
{
    // 线程应有的属性
    线程状态;
    线程 id;
    线程独立的栈结构;
    线程栈大小;
    // ...
    优先级、时间片、上下文; // 这三者交由 LWP → PCB,内核层面管理
};
  • tcb 中有一个 joinable 字段:若 joinable 为 0,则线程处于分离状态;
  • tcb 中还有 void* ret——线程函数的返回值就放在这里,所以需要 join 回收 ret 并回收内存,主线程才能拿到返回值;
  • 所谓 tid,就是对应该线程管理块(tcb)的起始地址(默认地址)。

另外,tcb 的部分数据是压在内核栈中的——库层 tcb 管属性,内核层 LWP 管调度,两边配合。

结合地址空间布局,一张图看懂线程的"家":

        虚拟内存(进程地址空间)          pthread 库(共享区中的动态库)
   ┌─────────────────────┐          ┌──────────────────────┐
   │      内核空间        │          │       动态库          │
   ├─────────────────────┤          │  struct pthread      │
   │      主线程栈 ↘      │          │  (线程控制块/线程id)   │ ← pthread_t tid1
   ├─────────────────────┤          ├──────────────────────┤
   │   mmap 区域(动态库)   │ ←─────── │  同上(线程2的tcb)    │ ← pthread_t tid2
   ├─────────────────────┤          │  ... + void* ret     │
   │      堆 ↑           │          └──────────────────────┘
   ├─────────────────────┤
   │  未初始化数据/已初始化数据 │
   ├─────────────────────┤
   │      代码段          │
   └─────────────────────┘

💡 __thread 线程局部存储: 用 __thread 修饰的全局变量,其数据会存放在线程局部存储中——每个线程各有一份,互不干扰。用途:需要全局变量,但不想让其他线程看到,从而不存在并发问题。限制:只能有内置类型和部分指针。

5.2 线程栈:主线程的栈是动态的,新线程的栈是固定的

线程是共享同一地址空间的,那"独立的上下文和独立的栈"如何实现?

  • 独立上下文:有独立的 PCB(内核)+ TCB(用户层,pthread 内部),寄存器上下文随 LWP 切换保存恢复;
  • 独立的栈:每个线程要有自己独立的栈,要么是进程自己的,要么是库中 mmap 出来的。

Linux 中进程和线程都使用 task_struct,只不过线程多了一层封装(多出了用户层数据)。但进程和线程的栈其实是同区域的,只是待遇不同:

主线程(进程)的栈新线程的栈
位置地址空间栈区库通过 mmap 在共享区申请
增长方式动态增长(fork 复制父栈空间 + 写时拷贝)固定的,一旦用尽就没了(默认约 8MB),超限栈溢出报段错误
私有性并非私有——其他线程可以访问,只是地址"协商"好了,其它线程不知道原则上线程私有,但同一进程的所有线程浅拷贝了生成者的 task_struct 众多字段,如果愿意,其它线程依然可以访问到

⚠️ 一定注意: “线程栈私有"只是原则上的约定,不是硬件级隔离——栈的地址对其他线程而言只是"不知道”,而不是"访问不到"。

5.3 clone:库创建线程的两步

pthread 库创建一个线程,实际做了两件事(对应底层 int clone(int (*fn)(void*), void *stack, int flag, void *arg, ...) 系统调用):

  1. 用户层:在库中创建线程的管理块(tcb),并通过 mmap 为新线程申请栈空间;
  2. 内核层:调用 clone 系统调用创建 LWP——告诉内核"执行什么方法(fn)、栈在哪(stack)"。

💡 源码佐证(了解即可): 在 pthread 库源码中,__pthread_create_2_1 先通过 ALLOCATE_STACK 宏申请空间(先查线程栈缓存,失败则 mmap 匿名内存),在空间中放置 struct pthread(也就是 tcb),把 start_routine 和 arg 填进去,然后把 tcb 的地址作为 ID 传给上层(*newthread = (pthread_t)pd;——上层拿到的就是一个虚拟地址!);随后 create_thread 设置 CLONE_VM | CLONE_FS | CLONE_FILES | ... 标志位调用 clone,让内核创建轻量级进程。线程执行完毕的返回值就存放在 tcb 的 result 变量中——pthread_join 本质就是读取该结构体,这也印证了"线程执行流可以退出,但 tcb 可以暂时保留"这句话。

5.4 给线程起名字:setname / getname

#include <pthread.h>
// 设置调用线程自己的名字(name 存放在线程局部存储内)
int pthread_setname_np(pthread_t thread, const char *name);
// 读取线程名字
int pthread_getname_np(pthread_t thread, char *name, size_t len);

💡 _np 表示 “non-portable”(非可移植),通常在 Linux 等类 Unix 系统中可用,用于调试和多线程程序的管理。线程名称最大长度为 16 个字符(包括结尾 \0),超长会被截断;通常只有线程自身可以设置自己的名字。


六、📦 线程封装:用 C++ 把 pthread 包起来

系统接口用起来终究繁琐,我们模仿 C++11 的思路,把线程封装成一个类——对外暴露 Start/Join,内部自动处理线程名、分离与等待策略:

// Thread.hpp
#pragma once
#include <iostream>
#include <string>
#include <functional>
#include <pthread.h>

namespace ThreadModule
{
    // 原子计数器,方便形成线程名称
    std::uint32_t cnt = 0;
    // 线程要执行的外部方法
    using threadfunc_t = std::function<void()>;

    // 线程状态
    enum class TSTATUS
    {
        THREAD_NEW,
        THREAD_RUNNING,
        THREAD_STOP
    };

    class Thread
    {
    private:
        // 静态成员函数:符合 pthread 线程入口的原型要求
        static void *run(void *obj)
        {
            Thread *self = static_cast<Thread *>(obj);
            pthread_setname_np(pthread_self(), self->_name.c_str()); // 设置线程名称
            self->_status = TSTATUS::THREAD_RUNNING;
            if (!self->_joined)
            {
                pthread_detach(pthread_self()); // 不想被join,自己分离自己
            }
            self->_func();  // 执行真正的任务
            return nullptr;
        }
        void SetName()
        {
            // 后期加锁保护
            _name = "Thread-" + std::to_string(cnt++);
        }
    public:
        Thread(threadfunc_t func) : _status(TSTATUS::THREAD_NEW),
                                    _joined(true), _func(func)
        {
            SetName();
        }
        void EnableDetach()
        {
            if (_status == TSTATUS::THREAD_NEW)
                _joined = false;
        }
        void EnableJoined()
        {
            if (_status == TSTATUS::THREAD_NEW)
                _joined = true;
        }
        bool Start()
        {
            if (_status == TSTATUS::THREAD_RUNNING)
                return true;
            int n = ::pthread_create(&_id, nullptr, run, this); // this指针传入
            if (n != 0)
                return false;
            return true;
        }
        bool Join()
        {
            if (_joined)
            {
                int n = pthread_join(_id, nullptr);
                if (n != 0)
                    return false;
                return true;
            }
            return false;
        }
        ~Thread() {}
    private:
        std::string _name;
        pthread_t _id;
        TSTATUS _status;
        bool _joined;
        threadfunc_t _func;
    };
}

使用方式:

// main.cc
#include <iostream>
#include <unistd.h>
#include "Thread.hpp"

void hello1()
{
    char buffer[64];
    pthread_getname_np(pthread_self(), buffer, sizeof(buffer) - 1);
    while (true)
    {
        std::cout << "hello world, " << buffer << std::endl;
        sleep(1);
    }
}

void hello2()
{
    char buffer[64];
    pthread_getname_np(pthread_self(), buffer, sizeof(buffer) - 1);
    while (true)
    {
        std::cout << "hello world, " << buffer << std::endl;
        sleep(1);
    }
}

int main()
{
    pthread_setname_np(pthread_self(), "main");
    ThreadModule::Thread t1(hello1);
    t1.Start();
    ThreadModule::Thread t2(std::bind(&hello2));
    t2.Start();
    t1.Join();
    t2.Join();
    return 0;
}

验证一下,ps -aL 里线程名一目了然:

$ ps -aL
   PID     LWP TTY          TIME CMD
195828  195828 pts/1    00:00:00 main
195828  195829 pts/1    00:00:00 Thread-0
195828  195830 pts/1    00:00:00 Thread-1

💡 设计要点: pthread_create 的入口函数必须是 void*(*)(void*) 原型,所以类内的 run 要设计成 static 成员函数,再通过传入的 this 指针拿回对象、调用真正的任务函数。如果要像 C++11 那样支持可变参数传递也可以设计,但太麻烦了——真到了那一步直接用 C++11 线程库就好,我们的目标是理解系统概念的对象化,后续还可以用 std::bind 来进行对象间调用。


七、🤔 核心思考题

  1. 为什么线程切换比进程切换快?
    线程切换虚拟地址空间不变——不用切换页表、不用更换 CR3 寄存器,硬件 cache 和 TLB 中缓存的内容也依然有效;而进程切换会刷新 TLB,导致切换后一段时间内内存访问低效。

  2. 越界访问一定会导致程序崩溃吗?
    不一定。越界发生时操作系统不一定知道(可能恰好指向合法空间)。OS 通过"页号合法性检查 + 内存映射检查"两步,才能区分这是缺页中断还是真正的越界访问。

  3. pthread_create 返回的 tid 和 ps -aL 看到的 LWP 有什么区别?
    LWP 是内核调度范畴的轻量级进程 ID;tid 是 NPTL 线程库范畴的概念,本质是进程地址空间上的一个地址——指向库中该线程管理块(tcb)的起始地址,库的后续操作都根据这个 ID 来操作线程。

  4. 线程不能调用哪些函数?主线程 return 为什么危险?
    线程不能调用 exit(),它会直接终止整个进程(所有线程全部退出);主线程从 main 函数 return 也相当于调用 exit。线程只应通过 return、pthread_exit 终止自己,或被其他线程 pthread_cancel 取消。


本节完

📝 作者:say-fall | 编辑:say-fall | 🌟 原创不易,如果对你有帮助,记得 👍 点赞 + ⭐ 收藏 哦!

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/say_fall/article/details/169454798

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--