QQ20260211-041049

前言

在 Linux 开发中,线程是实现并发的核心工具 —— 它比进程更轻量化,能高效利用多核 CPU,还能共享进程资源减少开销。但你是否真正理解:线程和进程到底是什么关系?线程为什么能共享进程的地址空间?POSIX 线程库(pthread)的底层实现逻辑是什么?

本文从底层原理到实战操作,层层拆解 Linux 线程的核心知识,既适合入门者夯实基础,也能帮开发者理清线程控制的关键细节,真正做到 “知其然且知其所以然”。

一、线程的本质:进程内的 “轻量级执行流”

1.1 什么是线程?

线程是操作系统调度 CPU 执行指令的基本调度单位,它不能独立存在,必须依附于进程。

线程是进程内部的一条独立控制序列,也可理解为 “进程内部的独立执行流”。

  • 一个运行起来的程序(进程),内部可以同时有多条代码执行路径,每一条执行路径就是一个线程。
  • 一切进程至少都有一个执行线程(主线程),线程在进程的虚拟地址空间内运行。

从概念角度感性的理解线程:

  • 进程:内核数据结构 + 代码和数据(执行流),是一个 “完整的运行实体”。
  • 线程:进程内部的一个执行分支(执行流),是 “进程里的一条独立干活的路径”。

从内核与资源的视角理解线程:

  • 进程:承担分配系统资源的基本实体(分配虚拟地址空间、内存、文件描述符等)。
  • 线程:CPU 调度的基本单位,在进程的地址空间内运行。

1.2 为什么需要线程

设计线程的核心目的是:

  • 解决单执行流的效率瓶颈,充分利用多核 CPU 资源。
  • 让程序能高效并发处理多任务(如同时处理网络请求、计算数据、刷新界面)。
  • 降低系统资源开销:同一进程内的线程切换,无需切换虚拟地址空间,开销远小于进程切换。

1.3 线程和进程的核心区别

角色 核心定位 核心特点
进程 资源分配的基本单位 操作系统为进程分配虚拟地址空间、文件描述符、用户 ID 等独立资源。
线程 调度执行的基本单位 线程依附于进程,共享进程的大部分资源,仅保留少量私有执行状态。

在认识线程之前我们所认识的进程可以看作是内部只有一个线程的进程。

进程是资源容器,线程是容器里的执行流。

进程强调独占,部分共享(比如通信的时候),而线程则强调共享,部分独占。

1.4 线程的核心定义

线程,就是进程内部的一条独立执行流,一个运行起来的程序(进程),内部可以同时有多条代码执行路径在跑,每一条执行路径,就是一个线程。

进程是程序的运行资源容器,线程是容器里独立执行任务的逻辑流;多线程就是同一个程序同时跑多段逻辑。

在一个程序里的一个执行路线就叫做线程(thread)。更准确的定义是:线程是 “一个进程内部的控制序列”,对于线程来说:

  • 一切进程至少都有一个执行线程
  • 线程在进程内部运行,本质是在进程地址空间内运行
  • 透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流(也就是执行路径),就形成了线程执行流。

一个进程中可以存在多个线程,这些线程共享进程的大部分资源,如下图所示:

QQ20260305-171547

线程是操作系统调度 CPU 执行指令的基本单位,它不能独立存在,必须依附于进程。

  • 一个线程就是一条 “连续的指令执行路线”:CPU 会按顺序执行线程中的指令,完成具体任务(比如处理网络请求、计算数据、刷新界面);
  • 同一进程内的所有线程,共享该进程的全部资源(虚拟地址空间、页表、文件描述符、代码段 / 数据段、全局变量等);
  • 每个线程仅独有少量 “私有执行状态”:程序计数器(记录当前执行到哪条指令)、CPU 寄存器值(临时存储的运算数据)、私有栈空间(存储局部变量、函数调用信息)—— 这些是线程执行时的 “临时状态”,不会和其他线程混淆。

什么是执行流?

执行流 = 一条 “连续执行的代码路径”

CPU 从某一行代码开始,一行一行、一步一步往下执行,形成的一整条执行轨迹,就叫一个执行流。

  • 一个程序只有一条执行流,程序只能先做完 A,再做 B,再做 C,同一时间只能干一件事。

    → 这就是单线程。

  • 一个程序里有多条执行流,程序可以同时做A、B、C,这些事件宏观上同时跑、互不阻塞。

    → 这就是多线程,每一个线程 = 一个独立的执行流。

执行流,就是 “CPU 在按顺序跑代码” 这件事本身。

线程,就是操作系统用来管理、调度 “执行流” 的实体。

1.5 Linux 线程的实现:用 “轻量级进程” 模拟线程

Linux 没有为线程设计专门的线程控制块(TCB),而是复用了描述进程的 task_struct(PCB)结构体,通过以下方式模拟线程:

  • 多个 task_struct 共享同一个 mm_struct(地址空间),即共享进程的虚拟地址空间、页表、代码段、数据段、文件描述符表等核心资源。
  • 每个线程(轻量级进程)只保留少量私有资源:寄存器上下文、私有栈、调度信息等。

因此在Linux中的线程本质上是轻量级进程(Light Weight Process, LWP)

  • CPU 调度时看到的 “线程”,其实是内核中的 task_struct(进程控制块 PCB)。
  • 与传统进程的 task_struct 相比,线程的 PCB 共享了进程的绝大部分资源(如虚拟地址空间、页表、文件描述符、代码段 / 数据段、全局变量等),仅保留少量私有信息,因此体现出 “轻量化”。

这种设计使得线程的创建、销毁和上下文切换的开销远小于传统进程,因此被称为 “轻量级”。

实现机制:

Linux 内核通过 clone() 系统调用创建线程,通过传入不同的标志位(如 CLONE_VMCLONE_FSCLONE_FILES 等)来指定新创建的 task_struct 需要共享哪些资源。当这些标志位被设置为共享模式时,就创建了一个轻量级进程,即线程。

现代 Linux 系统中,用户态的 POSIX 线程库(如 NPTL)就是基于这一内核机制实现的,采用 1:1 线程模型,即每个用户线程对应一个内核轻量级进程。

也就是说Linux 没有专门的 “线程” 内核对象,线程就是共享资源的轻量级进程

总结一下:

  • Linux 线程 = 轻量级进程(LWP)

    Linux 的线程,就是用轻量级进程模拟实现的,内核中没有独立的线程对象,所有执行流(无论进程还是线程)都用 task_struct 描述。

  • 资源划分的本质

    对线程的资源划分,本质是对地址空间虚拟地址范围的划分,虚拟地址是资源的代表。

  • 代码与执行流

    函数是虚拟地址(逻辑地址)空间的集合,未来执行 ELF 程序的不同函数,就对应不同的执行流(线程)。

  • 内核代码复用

    Linux 线程完全复用进程的内核代码,无需为线程重新设计复杂的内核管理逻辑,实现简洁高效。

与其他平台的差异:

Windows 等平台有独立的线程实现方案(如专门的 TCB),与 Linux 的 “轻量级进程” 模型不同。

因为inux 的设计哲学是:复用现有进程机制,用最小的代价实现线程。这一选择既保持了内核的简洁高效,又满足了多线程编程的需求。

1.6 Linux中线程的管理

线程在内核中依然需要被管理和调度,但 Linux 通过复用 task_struct,避免了重复造轮子。可以直接使用之前的调度结构和调度算法。

对 Linux 程序员而言,无需关心线程和进程在内核的差异,直接使用 POSIX 线程库(如 pthread)即可。

二、线程的深刻理解

2.1 资源的本质

执行流看到的资源,本质是合法的虚拟地址,虚拟地址是资源的代表。

虚拟地址空间由 mm_structvm_area_struct 管理,本质是对资源的统计和整体数据描述;页表是虚拟地址到物理地址的映射表,可以将其看作地图。

  • 资源划分:本质是地址空间划分,即划分页表项;
  • 资源共享:本质是地址空间共享,即共享页表条目。

线程进行资源划分:本质是划分地址空间,获得一定范围的合法虚拟地址,对应划分页表。

线程进行资源共享:本质是对地址空间的共享,对应对页表条目的共享。

2.2 线程的核心优势

  1. 创建与切换开销低

    • 创建新线程的代价比创建新进程小得多。
    • 线程切换开销远小于进程切换:
      • 线程切换时虚拟内存空间保持相同,进程切换时虚拟内存空间不同;内核切换时,线程切换仅需切换寄存器内容,无需切换地址空间。
      • 上下文切换会扰乱处理器缓存(缓存失效),但进程切换会导致 TLB(页表缓存)和硬件 Cache 全部刷新,造成内存访问低效;线程切换不会出现 TLB 刷新问题,缓存影响更小。
  2. 资源占用少

    线程占用的系统资源比进程少,同一进程内的线程共享大部分资源。

  3. 并发与并行能力强

    • 能充分利用多处理器的并行能力,提升计算效率。
    • 可在等待慢速 I/O 操作时执行其他计算任务,提升程序响应性。
    • 计算密集型应用:可将任务分解到多线程,利用多核并行执行。
    • I/O 密集型应用:可重叠 I/O 操作,同时等待不同 I/O 事件,提升吞吐量。

2.3 线程的缺点

  1. 性能损失

    计算密集型线程若数量超过处理器核心数,会因同步和调度开销增加导致性能损失。

  2. 健壮性降低

    多线程程序中,时间分配偏差或共享不当的变量易导致不良影响,线程间缺乏天然保护机制。

  3. 缺乏访问控制

    进程是访问控制的基本粒度,线程中调用某些 OS 函数会影响整个进程。

  4. 编程难度高

    编写与调试多线程程序比单线程程序困难得多,需关注同步、竞态条件等问题。

2.4 线程异常

单个线程出现除零、野指针等崩溃问题,会导致整个进程崩溃。

线程是进程的执行分支,线程异常会触发信号机制,终止进程,进程内所有线程随之退出。这是线程缺乏隔离性的核心体现,也是多线程编程必须规避的风险。

2.5 线程的用途

合理使用多线程,可提高 CPU 密集型程序的执行效率。

合理使用多线程,可提高 I/O 密集型程序的用户体验(如边写代码边下载开发工具)。

三、Linux线程控制实战:POSIX 线程库(pthread)

Linux 通过 POSIX 线程库(libpthread)提供线程操作接口,所有函数以pthread_开头,使用时需注意:

  • 头文件:#include <pthread.h>
  • 编译链接:需加-lpthread选项(如gcc thread.c -o thread -lpthread);
  • 错误处理:函数返回值为 0 表示成功,非 0 为错误码(不设置全局errno)。

为什么Linux中要使用 POSIX 线程库进行线程控制呢?

Linux 内核的设计哲学是 “用轻量级进程(LWP)模拟线程”,内核中没有独立的线程对象,所有执行流(进程 / 线程)都用 task_struct(PCB)描述,通过 clone() 系统调用创建。

直接使用 clone() 等系统调用创建线程,需要手动处理:

  • 共享 / 隔离资源(如地址空间、文件描述符表);
  • 分配线程栈、管理线程状态;
  • 处理信号、调度等底层细节。

pthread 库的价值:把这些复杂的内核操作封装成简洁的用户态 API(如 pthread_create()pthread_join()),让开发者不用关心内核的轻量级进程实现,只需面向 “线程” 这个抽象概念编程,大幅降低开发门槛。

Linux的线程实现是在用户层实现的,我们称之为用户级线程,pthread库称之为原生线程库。

C++11中提供了多线程(如std::thread),这是为了保证语言的跨平台和可移植性,其在Linux下本质是封装了pthread库,在windows下则是封装了windows控制线程的接口。

语言的跨平台或者可移植性一般是如何实现的呢?C++ 的做法是:

  1. 为每个平台单独实现一份适配代码:针对 Linux、Windows 等不同系统,分别封装其原生线程接口。
  2. 通过条件编译整合到库中:在编译时根据目标平台选择对应的实现,对外提供统一的标准接口(如std::thread)。

用户只需编写一套符合 C++ 标准的代码,就能在不同平台编译运行,无需关心底层平台差异。

3.1 线程创建:pthread_create

函数原型:

QQ20260306-152309

int pthread_create(pthread_t *thread, 
                   const pthread_attr_t *attr,
                   void *(*start_routine)(void*), 
                   void *arg);

参数说明:

  • thread:输出参数,存储新创建线程的 ID(pthread_t类型);
  • attr:线程属性(如栈大小、调度优先级),NULL表示使用默认属性;
  • start_routine:线程启动后执行的函数指针(返回值void*,参数void*);
  • arg:传给线程函数的参数(可传递任意类型数据,需手动转换)。

返回值:

  • 返回值:成功返回0;失败返回错误码

传统的⼀些函数的返回值是,成功返回0,失败返回-1,并且对全局变量errno设置对应的错误码。 pthread系列函数出错时不会设置全局变量errno(而⼤部分其他POSIX函数会这样做),而是将错误代码通过返回值返回。

pthread库同样也提供了线程内的errno变量,以⽀持其它使用errno的代码。对于pthread函数的错误,建议通过返回值判定,因为读取返回值要比读取线程内的errno变量的开销更小。

一般使用时参数attr我们直接传入nullptr,用默认属性即可。start_routine参数就是该线程的执行流,也就是其执行的函数,我们通过下面一个简单的例子来看一下:

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

void* routine(void* arg)
{
    char *msg = (char*)arg;
    int cnt = 3;
    while(cnt--)
    {
        std::cout << msg << std::endl; 
        sleep(1);
    }
    return nullptr;
}

int main()
{
    std::string msg = "hello thread!";
    pthread_t tid;
    // 创建线程,传入参数"hello thread"
    int n = pthread_create(&tid, nullptr, routine, (void*)msg.c_str());

    // 主线程继续执行
    int cnt = 6;
    while(cnt--)
    {
        std::cout << "main thread" << std::endl; 
        sleep(1);
    }
    return 0;
}

编译运行:g++ -o test main.cc -lpthread && ./thread,看一下运行结果:

QQ20260308-220650

可以发现我们进程中的两个线程是在并发执行。所以说线程是被CPU调度的基本单位

C/C++ 直接操作系统级线程,进程的生命周期和主线程强绑定:

主线程正常退出(如 main 函数 return)会触发整个进程终止,操作系统会立即回收进程的所有资源,包括未执行完的子线程 —— 子线程会被强制终止,哪怕只执行了一半。

// 获取线程ID
pthread_t pthread_self(void);

同时,pthread库中也提供了函数去获得当前线程的ID,它返回⼀个 pthread_t 类型的变量,指代的是调用 pthread_self 函数的线程的 “ID”。

我们将上面代码进行修改,在打印时加上这个函数看看结果:

QQ20260310-111502

可以看到两个线程的确有着不同的线程“ID”,那让我们再来看一看此时在OS中这两个线程的“ID”:

QQ20260310-111803

可以看到,在OS系统中的线程“ID”——LWP和我们的程序在运行时打印出来的线程“ID”不同,那么该如何去理解呢?

我们通过这个pthread_self()函数获得的“ID”是 pthread 库给每个线程定义的进程内唯⼀标识,是 pthread 库维持的。由于每个进程有自己独立的内存空间,故此“ID”的作用域是进程级而非系统级(内核不认识)。 其实 pthread 库也是通过内核提供的系统调⽤(例如clone())来创建线程的。

而内核会为每个线程创建系统全局唯⼀的“ID”来唯⼀标识这个线程,也就是LWP。

  • pthread_t pthread_self(void):返回当前线程的 ID(进程内唯一,本质是虚拟地址空间的地址);
  • 内核线程 ID(LWP):通过ps -aL查看,是内核调度的唯一标识(系统级);

区别:pthread_self()返回的是线程库维护的 ID(进程级),LWP 是内核维护的 ID(系统级)。

LWP 是什么呢?LWP 得到的是真正的线程ID。之前使⽤ pthread_self() 得到的这个数实际上是⼀个地址,在虚拟地址空间上的⼀个地址,通过这个地址,可以找到关于这个线程的基本信息,包括线程ID,线程栈,寄存器等属性。

ps -aL 得到的线程ID,有⼀个线程ID和进程ID相同,这个线程就是主线程,主线程的栈区在虚拟地址空间的栈区上,而其他线程的栈在是在共享区(堆栈之间),因为pthread系列函数都是pthread库提供给我们的。而pthread库是在共享区的。所以除了主线程之外的其他线程的栈都在共享区。

3.2 线程终止:3 种合法方式

线程终止需避免 “孤儿线程”(资源未释放),合法方式有 3 种:

  1. 线程函数return(主线程return等价于exit,终止整个进程);
  2. 调用pthread_exit(void *value_ptr):终止当前线程,value_ptr为返回值(需指向全局 / 堆内存,不可是栈内存);
  3. 其他线程调用pthread_cancel(pthread_t thread):取消指定线程(需线程处于可取消状态)。

第一种方式就不用过多解释,我们直接来看pthread_exit(void *value_ptr)函数:
QQ20260310-114104

参数:

  • value_ptr:当作返回值。

返回值:

  • 无返回值,跟进程⼀样,线程结束的时候⽆法返回到它的调用者(⾃⾝)

所以无论pthread_exit()或者return返回的指针所指向的内存单元必须是全局的或者是用malloc分配的,不能在线程函数的栈上分配,因为当其它线程得到这个返回指针时线程函数已经退出了。

下面来看一下简单的示例:

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

void* routine_exit(void* arg)
{
    std::cout << "thread exit" << std::endl;
    pthread_exit(nullptr);
    
    std::cout << "thread exist" << std::endl;
    return nullptr;
}

int main()
{
    pthread_t tid;
    int n = pthread_create(&tid, nullptr, routine_exit, nullptr);
    if(n != 0)
    {
        exit(1);
    }

    sleep(5);
    return 0;
}

编译运行后可以看到结果:
QQ20260310-124241

可以看到线程在调用pthread_exit()函数后就会退出,和主线程调用exit是相似的,之所以要单独设计pthread_exit()是因为如果我们在某一个线程中调用exit,那么整个进程就会退出。

很多人学习 pthread 时最容易混淆的点 ——主线程退出≠进程终止,子线程是否被终止,核心看主线程的退出方式,而非 “主线程退出” 这个行为本身。

主线程有两种完全不同的 “退出” 方式,对应两种截然不同的结果:

主线程退出方式 进程是否终止 子线程是否被终止 底层原因
main函数return/ 调用exit() 是(立即终止) 是(强制终止) main返回会触发exit()系统调用,操作系统直接回收整个进程的所有资源(包括所有线程的栈、CPU 资源等),进程一死,所有线程都不存在了
调用pthread_exit() 否(继续运行) pthread_exit()仅终止当前线程(主线程),不会触发进程退出;进程的生命周期由 “所有线程是否都退出” 决定,只要还有子线程在运行,进程就会存活

pthread_cancel()在实际工程开发中不算常用—— 甚至可以说,它是一个 “尽量避免使用” 的选项,核心原因是资源泄漏风险高、行为不可控、调试困难;所以认识即可。

3.3 线程等待:pthread_join

为什么需要线程等待呢?

  • 已经退出的线程,其空间没有被释放,仍然在进程的地址空间内。
  • 创建新的线程不会复用刚才退出线程的地址空间。

所以线程退出后,资源不会自动释放,需要通过pthread_join等待回收,否则会导致资源泄漏:

QQ20260310-125035

int pthread_join(pthread_t thread, void **value_ptr);

参数:

  • thread:要等待的线程 ID;
  • value_ptr:输出参数,接收线程的返回值(return/pthread_exit的参数,被取消则为PTHREAD_CANCELED);

返回值:

  • 成功返回0;失败返回错误码。

特性:

  • 调用线程会阻塞,直到目标线程终止。

调用该函数的线程将挂起等待,直到 id 为 thread 的线程终止。thread 线程以不同的方法终止,通过 pthread_join 得到的终止状态是不同的,总结如下:

  1. 如果 thread 线程通过 return 返回,value_ptr 所指向的单元里存放的是 thread 线程函数的返回值。
  2. 如果 thread 线程被别的线程调用 pthread_cancel 异常终止,value_ptr 所指向的单元里存放的是常数 PTHREAD_CANCELED
  3. 如果 thread 线程是自己调用 pthread_exit 终止的,value_ptr 所指向的单元存放的是传给 pthread_exit 的参数。
  4. 如果对 thread 线程的终止状态不感兴趣,可以传 NULLvalue_ptr 参数。

下面我们来看代码示例:

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

void *thread1( void *arg )
{
    printf("thread 1 returning ... \n");
    int *p = (int*)malloc(sizeof(int));
    *p = 1;
    return (void*)p;
} 

void *thread2( void *arg )
{
    printf("thread 2 exiting ...\n");
    int *p = (int*)malloc(sizeof(int));
    *p = 2;
    pthread_exit((void*)p);
} 

void *thread3( void *arg )
{
    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 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 cancel by other
    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);
    
    return 0;
}

看一下运行结果:
QQ20260310-183620

整体时间线如下图所示:
QQ20260310-183655

3.4 线程分离:pthread_detach

默认情况下,新创建的线程是 joinable 的,线程退出后,需要对其进行 pthread_join 操作,否则无法释放资源,从而造成系统泄漏。

但如果不关心线程的返回值,join 则是一种负担,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源。

因此我们可以通过pthread_detach函数设置线程为 “分离态”,线程退出后自动释放资源,无需pthread_join

QQ20260310-185958

int pthread_detach(pthread_t thread);

可以是线程组内其他线程对目标线程进行分离,也可以是线程自己分离:

pthread_detach(pthread_self());

joinable和分离是冲突的,⼀个线程不能既是joinable⼜是分离的。 下面来看代码示例:

#include <stdio.h>
#include <stdlib.h>
#include <string.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, (void*)"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");
        ret = 0;
    } 
    else 
    {
        printf("pthread wait failed\n");
        ret = 1;
    } 
    return ret;
}

pthread_join 还在阻塞等待(未成功) 时调用 pthread_detach,会发生两个关键行为:

  1. pthread_detach 执行成功(返回 0),目标线程被转为分离态(detached);
  2. 正在阻塞的 pthread_join 会立即被唤醒并失败,返回错误码 EINVAL(表示 “线程不支持 join 操作”);
  3. 目标线程会继续运行至终止,随后系统自动回收其资源(分离态特性),不会造成资源泄漏。

简单说:分离操作会 “打断” 等待过程,让 join 失效,但资源回收由系统自动完成。

注意:线程一旦设置为分离态,无法再通过pthread_join获取返回值。

3.5 编译小tips

编译多线程程序时,不同于绝大多数库需用-l<库名>的常规链接方式,-pthread是特例 —— 我们现在不再使用-lpthread,而是首选-pthread。这是因为-pthread并非单纯的库链接选项,而是编译器的多线程功能开关;即便新版 glibc 已将 libpthread 合并至 libc.so.6 中,-lpthread也仅能完成线程库的链接操作,无法在编译阶段定义_REENTRANT等关键宏,导致printfmalloc等函数仍调用非线程安全版本,极易引发多线程运行时乱码、内存损坏甚至崩溃;而-pthread会同时兼顾编译期(定义线程安全宏)和链接期(适配系统链接线程相关依赖),既保证函数的线程安全,也大幅提升跨系统、跨 glibc 版本的兼容性,因此是标准且安全的选择。

四、线程地址空间布局与底层实现

Linux中没有真正的线程,它是用轻量级进程来模拟实现的线程,所以OS不会直接提供线程的接口,因此在用户层面就和Linux存在一道鸿沟,于是就有了在用户层封装轻量级进程形成原生线程库,也就是pthread库,这是用户级别的库。

当我们的可执行程序加载到内存形成进程后,因为pthread库是动态库,所以会进行动态链接和动态地址重定向,将pthread库加载到内存中,并且映射到当前进程的地址空间中去,如下图所示:
bit-2026-03-11-15-41-34

进程在自己的代码区可以访问到pthread库内部的函数和数据,而且线程的概念是在库中维护的,所以在库内部就一会存在若干个被创建好的线程,所以当我们创建线程时库中会创建对应的结构体,我们叫做TCB,来描述这些线程并且进行管理,当然,这与内核中管理“轻量级进程”的PCB不同,分属用户态和内核态两层。

所以当调用 pthread_create() 时,pthread 库会在用户态创建对应的线程TCB,再通过系统调用(比如 clone())让内核创建一个轻量级进程(LWP),并将TCB与内核 LWP 的 PCB 绑定。

我们所说的线程IDpthread_t 的类型主要取决于实现。对于Linux⽬前实现的NPTL实现而言,pthread_t类型的线程ID,本质就是⼀个进程地址空间上的⼀个地址:

bit-2026-03-11-15-51-28

线程的返回值也是被保存到了如图中所示的struct pthread中,所以需要进行join获得对应线程的返回值。同时在join成功后会释放掉对应的管理块的内存,避免内存泄漏的问题。

每个线程都必须有自己独立的栈空间,非主线程的栈位于 “共享区”,由mmap分配,大小固定,与主线程栈隔离。所以主线程使用虚拟地址空间中的栈,而新创建的线程则使用对应的线程栈。

虽然 Linux 将线程和进程不加区分地统一到了 task_struct,但是对待其地址空间的 stack 还是有些区别的。

  • 对于 Linux 进程或者说主线程,简单理解就是 main 函数的栈空间,在 fork 的时候,实际上就是复制了父亲的 stack 空间地址,然后写时拷贝 (cow) 以及动态增长。如果扩充超出该上限则栈溢出会报段错误(发送段错误信号给该进程)。进程栈是唯一可以访问未映射页而不一定会发生段错误 —— 超出扩充上限才报。

  • 然而对于主线程生成的子线程而言,其 stack 将不再是向下生长的,而是事先固定下来的。线程栈一般是调用 glibc/uclibc 等的 pthread 库接口 pthread_create 创建的线程,在文件映射区(或称之为共享区)。其中使用 mmap 系统调用,这个可以从 glibc 的 nptl/allocatestack.c 中的 allocate_stack 函数中看到:

    mem = mmap (NULL, size, prot,
    MAP_PRIVATE | MAP_ANONYMOUS | MAP_STACK, -1, 0);
    

此调用中的 size 参数的获取很是复杂,你可以手工传入 stack 的大小,也可以使用默认的,一般而言就是默认的 8M 。这些都不重要,重要的是,这种 stack 不能动态增长,一旦用尽就没了,这是和生成进程的 fork 不同的地方。在 glibc 中通过 mmap 得到了 stack 之后,底层将调用 sys_clone 系统调用。

因此,对于子线程的 stack,它其实是在进程的地址空间中 map 出来的⼀块内存区域,原则上是线程私有的,但是同⼀个进程的所有线程生成的时候,子线程会继承生成者(通常是主线程)task_struct 中的部分字段(如内存映射表、文件描述符表等),这意味着线程间能通过这些共享的内核态数据,间接定位到其他线程的栈地址,因此多线程编程中必须避免直接操作其他线程的栈空间,否则极易引发内存错乱、程序崩溃。

pthread 库是 “用户态封装”,内核层面线程与进程统一用task_struct管理,线程的 “轻量级” 源于共享资源和切换开销低。

七、核心总结

核心要点 :

  1. 线程是进程内的轻量级执行流,共享进程资源,是调度的基本单位;
  2. 虚拟地址空间和分页机制是线程共享资源的底层支撑,线程栈位于共享区;
  3. pthread库核心接口:pthread_create(创建)、pthread_join(等待)、pthread_exit(终止)、pthread_detach(分离);
  4. 线程异常会导致进程崩溃,需注意线程安全和资源回收。

线程是 Linux 并发编程的 “基石”,更是连接底层内核机制与上层应用开发的关键纽带。掌握它的本质(进程内轻量级执行流)、底层支撑(虚拟地址空间与分页)、控制接口(pthread 库)与实现逻辑(TCB 与 clone 系统调用),不仅能帮你避开 “线程崩溃”“资源泄漏”“线程安全” 等常见坑,更能让你在设计并发程序时,从 “能用” 升级到 “高效、稳定、可扩展”。

无论是日常开发中的多任务协作,还是高性能场景下的多核利用,线程都是绕不开的核心工具。如果想进一步巩固所学,建议动手实现一个支持任务队列的简易线程池(复用线程减少创建开销),或深入研究线程同步机制(互斥锁、条件变量、信号量)—— 这些实践能让你更深刻地理解线程的调度与协作逻辑。

希望本文能帮你打通 Linux 线程的 “任督二脉”,在并发编程的道路上走得更稳、更远。后续若需深入线程池实现、多线程调试技巧或线程安全设计模式,随时可以进一步探讨!

尾声

本章讲解就到此结束了,若有纰漏或不足之处欢迎大家在评论区留言或者私信,同时也欢迎各位一起探讨学习。感谢您的观看!

更多内容可见主页

Logo

助力广东及东莞地区开发者,代码托管、在线学习与竞赛、技术交流与分享、资源共享、职业发展,成为松山湖开发者首选的工作与学习平台

更多推荐