【Linux】线程概念与控制

🎬 个人主页道尔柯南
专栏传送门 :《C语言》《C++》《Linux操作系统

昙花一现,却等待了整个白昼;蝉鸣一夏,却蛰伏了好几个四季。

文章目录

  • [从虚拟内存到 pthread:Linux 线程概念与控制全解析](#从虚拟内存到 pthread:Linux 线程概念与控制全解析)
    • [1. 线程到底是什么?](#1. 线程到底是什么?)
    • [2. 理解线程的前置知识:虚拟地址空间与分页](#2. 理解线程的前置知识:虚拟地址空间与分页)
      • [2.1 没有虚拟内存会怎样?](#2.1 没有虚拟内存会怎样?)
      • [2.2 页、页框、页表](#2.2 页、页框、页表)
      • [2.3 MMU、TLB 与缺页异常](#2.3 MMU、TLB 与缺页异常)
      • [2.4 malloc/new、写时拷贝与缺页](#2.4 malloc/new、写时拷贝与缺页)
    • [3. Linux 进程 vs 线程:哪些共享,哪些私有?](#3. Linux 进程 vs 线程:哪些共享,哪些私有?)
    • [4. 线程的优点、缺点与适用场景](#4. 线程的优点、缺点与适用场景)
      • [4.1 优点](#4.1 优点)
      • [4.2 缺点](#4.2 缺点)
      • [4.3 适用场景](#4.3 适用场景)
    • [5. POSIX 线程控制:pthread 系列](#5. POSIX 线程控制:pthread 系列)
      • [5.1 创建线程](#5.1 创建线程)
      • [5.2 线程终止](#5.2 线程终止)
      • [5.3 线程等待](#5.3 线程等待)
      • [5.4 线程分离](#5.4 线程分离)
      • [5.5 取消与清理](#5.5 取消与清理)
    • [6. 线程 ID 与进程地址空间布局](#6. 线程 ID 与进程地址空间布局)
    • [7. 简单 C++ 线程封装](#7. 简单 C++ 线程封装)
    • [8. 从 glibc/NPTL 源码看 pthread_create](#8. 从 glibc/NPTL 源码看 pthread_create)
    • [9. 补充:线程安全与同步](#9. 补充:线程安全与同步)
      • [9.1 竞态条件](#9.1 竞态条件)
      • [9.2 死锁](#9.2 死锁)
      • [9.3 可重入与线程局部存储](#9.3 可重入与线程局部存储)
      • [9.4 信号与线程](#9.4 信号与线程)
    • [10. 最佳实践总结](#10. 最佳实践总结)
    • 结语
  • 结尾

从虚拟内存到 pthread:Linux 线程概念与控制全解析

本文不是对课件的逐页复述,而是把"Linux 线程概念与控制"串成一条完整的技术路线:先理解虚拟地址空间和分页,再理解线程为什么轻量,然后落到 pthread 的创建、终止、等待、分离,最后补充 NPTL、clone、线程安全和工程实践。


1. 线程到底是什么?

在操作系统教材里,线程常被定义为:

线程是进程内部的一条执行流,是 CPU 调度的基本单位。

但在 Linux 里,这个定义要再往前走一步:Linux 内核并没有为"线程"单独设计一种和进程完全不同的数据结构 。无论是进程还是线程,内核里都对应 task_struct,只是它们共享资源的范围不同。

可以这样理解:

  • 进程:资源分配的基本单位,拥有独立的虚拟地址空间、文件描述符表、信号处理方式等。
  • 线程:调度的基本单位,同一进程内的多个线程共享地址空间和大部分进程资源。
  • LWP:Light Weight Process,轻量级进程。Linux 内核视角下,线程更像是一个"共享了很多资源的进程"。

所以,CPU 眼中看到的线程 PCB,比传统进程更"轻量"。而用户态使用的 pthread 线程库,本质上是对内核 clone 系统调用的一层封装。

一句话:

Linux 用"共享资源的 task"实现线程,用 pthread 库给用户提供 POSIX 线程接口。


2. 理解线程的前置知识:虚拟地址空间与分页

要真正理解线程,不能只背"线程共享地址空间"。必须知道地址空间为什么能被共享,以及内核如何管理内存。

2.1 没有虚拟内存会怎样?

如果每个程序直接使用物理内存,那么程序的代码段、数据段、栈段必须连续存放。程序运行、退出后,物理内存会留下大量离散碎片。时间一长,内存利用率会变差。

虚拟内存和分页机制解决了这个问题:

  • 给每个进程一个连续的虚拟地址空间;
  • 物理内存按固定大小切成页框;
  • 通过页表把虚拟页映射到任意可用的物理页框;
  • 用户看到的是连续地址,物理内存可以是离散的。

2.2 页、页框、页表

  • :虚拟内存中的数据块。
  • 页框:物理内存中的存储区域。
  • 页表:记录虚拟页到物理页框映射关系的数据结构。

常见页大小是 4KB。32 位系统虚拟地址空间为 4GB,因此需要:

text 复制代码
4GB / 4KB = 1048576 个页表项

如果每个页表项 4 字节,单级页表就要 4MB。这 4MB 还必须连续存放,显然不太合理。

于是引入多级页表:

  • 把页表再分页;
  • 用页目录管理页表;
  • 典型 32 位两级页表:页目录 1024 项,每个页表 1024 项;
  • CR3 寄存器保存当前页目录物理地址;
  • MMU 负责地址转换。

2.3 MMU、TLB 与缺页异常

MMU 是硬件内存管理单元。CPU 发出虚拟地址后,MMU 查页表得到物理地址。为了加速,现代 CPU 还有 TLB,即快表,缓存最近使用的页表项。

地址转换大致流程:

  1. CPU 给出虚拟地址;
  2. MMU 先查 TLB;
  3. TLB 命中,直接得到物理地址;
  4. TLB 未命中,查页表;
  5. 页表也没有,触发缺页异常;
  6. 内核缺页处理程序决定是分配物理页、从磁盘读取,还是判定为非法访问。

缺页异常常见类型:

类型 含义
硬缺页 / 主缺页 物理内存中没有对应页,需要从磁盘读入
软缺页 / 次缺页 物理页已存在,只需建立映射
无效缺页 地址非法,通常触发 SIGSEGV

补充一个容易混淆的点:越界访问不一定立刻报错。如果地址仍落在合法 VMA 内,或者页号合法只是没有建立映射,可能先触发缺页;只有地址非法时才会段错误。

2.4 malloc/new、写时拷贝与缺页

malloc / new 通常只是申请虚拟地址空间,并不立即分配物理页。第一次读写时才可能触发缺页,由内核分配物理页。

fork 后的写时拷贝也是类似思想:

  • 父子进程先共享物理页;
  • 任一方写入时触发缺页;
  • 内核复制一份新的物理页;
  • 再修改页表映射。

所以,申请内存 ≠ 立即占用物理内存。理解这一点,对理解线程栈、共享内存和性能都很重要。


3. Linux 进程 vs 线程:哪些共享,哪些私有?

同一进程内的多个线程共享:

  • 虚拟地址空间;
  • 代码段、数据段、堆;
  • 文件描述符表;
  • 信号处理方式;
  • 当前工作目录;
  • 用户 ID、组 ID。

每个线程私有:

  • 线程 ID;
  • 一组寄存器,即线程上下文;
  • 栈;
  • errno
  • 信号屏蔽字;
  • 调度优先级;
  • 线程局部存储 TLS。

可以用表格总结:

资源 进程内线程是否共享
地址空间 共享
代码段、全局变量、堆 共享
文件描述符 共享
信号处理函数 共享
当前工作目录 共享
用户/组 ID 共享
私有
寄存器上下文 私有
errno 私有
信号屏蔽字 私有
调度优先级 私有

因此:

  • 进程是资源分配的基本单位
  • 线程是调度的基本单位
  • 线程共享地址空间,所以通信方便,但也意味着缺乏保护。

4. 线程的优点、缺点与适用场景

4.1 优点

  • 创建线程比创建进程开销小;
  • 线程切换不需要切换地址空间,TLB 和 cache 更友好;
  • 线程间共享数据方便;
  • 能利用多核并行;
  • 适合 I/O 密集场景,一个线程阻塞等待 I/O,其他线程继续计算。

4.2 缺点

  • 同步复杂,容易竞态、死锁;
  • 健壮性降低,一个线程崩溃可能导致整个进程退出;
  • 缺乏访问控制,一个线程关闭 fd,会影响同进程其他线程;
  • 调试困难,时序问题难以复现;
  • 线程数量过多会增加调度和同步开销。

4.3 适用场景

  • CPU 密集型:分解计算任务,利用多核;
  • I/O 密集型:重叠等待,提高吞吐;
  • 但线程不是越多越好,通常结合线程池、异步 I/O、协程等方案。

5. POSIX 线程控制:pthread 系列

Linux 下线程函数大多以 pthread_ 开头,需要包含 <pthread.h>,编译时链接 -lpthread-pthread

pthread 函数出错时通常不设置全局 errno,而是返回错误码。

5.1 创建线程

c 复制代码
#include <pthread.h>

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

参数说明:

  • thread:返回线程 ID;
  • attr:线程属性,NULL 表示默认;
  • start_routine:线程入口函数;
  • arg:传给入口函数的参数。

示例:

c 复制代码
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

void *worker(void *arg) {
    printf("worker thread, pthread_self = %lu\n",
           (unsigned long)pthread_self());
    int *ret = malloc(sizeof(int));
    *ret = 42;
    return ret;
}

int main(void) {
    pthread_t tid;
    int err = pthread_create(&tid, NULL, worker, NULL);
    if (err != 0) {
        fprintf(stderr, "pthread_create: %s\n", strerror(err));
        return 1;
    }

    void *retval = NULL;
    pthread_join(tid, &retval);
    printf("worker return: %d\n", *(int *)retval);
    free(retval);

    return 0;
}

5.2 线程终止

线程可以有三种终止方式:

  1. 从线程函数 return
  2. 调用 pthread_exit
  3. 被其他线程 pthread_cancel

注意:

  • 主线程从 main 返回相当于调用 exit,会终止整个进程;
  • 主线程调用 pthread_exit,其他线程可以继续运行;
  • pthread_exit 的参数不要指向线程栈上的局部变量,因为线程退出后栈可能失效。

5.3 线程等待

c 复制代码
int pthread_join(pthread_t thread, void **value_ptr);

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

目标线程不同终止方式,value_ptr 得到的内容不同:

终止方式 join 得到的内容
return 返回值
pthread_exit pthread_exit 参数
pthread_cancel PTHREAD_CANCELED

如果不关心返回值,可以传 NULL

线程退出后,如果既没有 join,也没有 detach,资源不会自动释放,可能造成泄漏。

5.4 线程分离

c 复制代码
int pthread_detach(pthread_t thread);

默认创建的线程是 joinable 的。分离后,线程退出时系统自动回收资源。

也可以线程自己分离:

c 复制代码
pthread_detach(pthread_self());

joinable 和 detached 是冲突的,一个线程不能同时两者都是。

5.5 取消与清理

pthread_cancel 只是发送取消请求,线程是否立即取消取决于取消状态和取消点。很多阻塞系统调用都是取消点。

如果线程持有锁或申请了资源,应使用清理处理程序:

c 复制代码
pthread_cleanup_push(cleanup, arg);
/* ... */
pthread_cleanup_pop(1);

这样可以避免取消时资源泄漏。


6. 线程 ID 与进程地址空间布局

这里有两个"线程 ID":

类型 来源 作用域 说明
内核 tid / LWP gettid()ps -aL 的 LWP 系统全局 内核调度使用
pthread_t pthread_self() 进程内 NPTL 中通常是 struct pthread * 地址

ps -aL 可以看到线程:

text 复制代码
PID    LWP    TTY      TIME     CMD
2711838 2711838 pts/235 00:00:00 mythread
2711838 2711839 pts/235 00:00:00 mythread

其中主线程的 LWP 和 PID 相同。其他线程的 LWP 不同,但 PID 相同,因为 PID 实际是线程组 ID。

地址空间布局可以粗略理解为:

text 复制代码
高地址
+------------------+
| 内核空间          |
+------------------+
| 主线程栈          | 向下增长
+------------------+
| mmap 区域         | 共享库、线程栈、TLS、TCB
+------------------+
| 堆                | 向上增长
+------------------+
| BSS               |
| Data              |
| Text              |
+------------------+
低地址

子线程的栈通常由 mmap 分配在共享区,默认大小常见为 8MB,并且有 guard page。它不能像主线程栈那样动态增长,用尽就栈溢出。


7. 简单 C++ 线程封装

直接使用 pthread 比较底层,C++ 中可以做一层封装。核心思路:

  • std::function<void()> 保存任务;
  • pthread_create 的入口函数是静态函数;
  • 通过 void* 把当前对象传进去;
  • 提供 startjoindetach

简化示例:

cpp 复制代码
#include <pthread.h>
#include <functional>
#include <utility>

class Thread {
public:
    explicit Thread(std::function<void()> func)
        : func_(std::move(func)), tid_(0), joinable_(true) {}

    bool Start() {
        return pthread_create(&tid_, nullptr, Entry, this) == 0;
    }

    void Join() {
        if (joinable_) {
            pthread_join(tid_, nullptr);
            joinable_ = false;
        }
    }

    void Detach() {
        if (joinable_) {
            pthread_detach(tid_);
            joinable_ = false;
        }
    }

    ~Thread() {
        if (joinable_) {
            pthread_detach(tid_);
        }
    }

private:
    static void *Entry(void *arg) {
        Thread *self = static_cast<Thread *>(arg);
        self->func_();
        return nullptr;
    }

    pthread_t tid_;
    bool joinable_;
    std::function<void()> func_;
};

补充:C++11 之后标准库提供了 std::thread,更推荐直接使用。C++20 还有 std::jthread,支持自动 join 和停止令牌。

线程名称也可以设置,方便调试:

cpp 复制代码
pthread_setname_np(pthread_self(), "worker");

Linux 下线程名通常最多 16 个字符。


8. 从 glibc/NPTL 源码看 pthread_create

Linux 原生线程库 NPTL 采用 1:1 模型:一个用户态 pthread 对应一个内核调度实体 LWP。

pthread_create 的大致流程:

  1. 分配线程栈和 TCB;
  2. 初始化 struct pthread
  3. 设置入口函数和参数;
  4. 调用 clone 创建内核线程;
  5. 返回 pthread_t,本质是 TCB 地址。

glibc 中会使用 mmap 分配线程栈:

c 复制代码
mem = mmap(NULL, size, PROT_READ | PROT_WRITE,
           MAP_PRIVATE | MAP_ANONYMOUS | MAP_STACK, -1, 0);

然后调用 clone,常见 flags 包括:

clone flag 作用
CLONE_VM 共享虚拟地址空间
CLONE_FS 共享文件系统信息
CLONE_FILES 共享文件描述符表
CLONE_SIGHAND 共享信号处理函数表
CLONE_THREAD 同一线程组
CLONE_SETTLS 设置 TLS
CLONE_PARENT_SETTID 写入线程 ID
CLONE_CHILD_CLEARTID 线程退出时清理并唤醒等待者
CLONE_SYSVSEM 共享 System V 信号量 undo

内核中,线程组 leader 的 PID 就是 tgid,其他线程有各自 tid,但 tgid 相同。getpid() 返回 tgid,gettid() 返回内核 tid。

所以,pthread 库负责用户态线程管理,内核负责调度。两者通过 clone、futex 等机制协作。


9. 补充:线程安全与同步

线程共享地址空间,最大好处是通信方便,最大风险是数据竞争。

9.1 竞态条件

多个线程并发访问共享变量,且至少有一个写操作,最终结果依赖调度顺序,这就是竞态。

例如:

c 复制代码
int count = 0;

void *worker(void *arg) {
    for (int i = 0; i < 100000; i++) {
        count++;
    }
    return NULL;
}

两个线程同时执行,count 很可能小于 200000。

解决方式:

  • 互斥锁 pthread_mutex_t
  • 原子操作;
  • 读写锁;
  • 条件变量;
  • 信号量。

9.2 死锁

死锁四个必要条件:

  1. 互斥;
  2. 占有并等待;
  3. 不可抢占;
  4. 循环等待。

避免死锁的常见方法:

  • 固定加锁顺序;
  • 使用 std::lock_guard / std::unique_lock
  • 尽量减少锁粒度;
  • 避免在持锁时调用未知代码。

9.3 可重入与线程局部存储

可重入函数不依赖全局或静态可变状态,多线程调用安全。

errno 是线程私有的。C/C++ 中也可以用 TLS:

c 复制代码
__thread int tls_var;

或 pthread 键:

c 复制代码
pthread_key_t key;
pthread_key_create(&key, NULL);
pthread_setspecific(key, ptr);

9.4 信号与线程

信号处理方式在进程内共享,但信号屏蔽字是线程私有的。信号可以发送给特定线程,也可能由内核选择某个线程处理。一个线程收到 SIGSEGV,默认会终止整个进程。


10. 最佳实践总结

  1. 创建线程后,明确是 join 还是 detach,避免资源泄漏。
  2. 检查 pthread 函数返回值,不要只看 errno
  3. 线程入口函数返回值如果指向堆内存,记得由 join 方释放。
  4. 子线程栈默认固定大小,注意栈溢出。
  5. 共享数据必须同步,优先使用 RAII 锁。
  6. 不要在线程回调中访问已销毁对象,封装时注意生命周期。
  7. 线程名、日志、ps -aL/proc/<pid>/task 都是调试利器。
  8. 高并发场景优先考虑线程池、异步 I/O、协程,而不是无限创建线程。
  9. 理解 Linux 线程的本质:task_struct + clone + 共享资源 + NPTL 封装。
  10. 进程与线程不是替代关系,而是隔离性与通信效率之间的权衡。

结语

Linux 线程的核心可以压缩成一句话:

线程是共享进程地址空间和大部分资源的执行流;Linux 内核用 task_struct 统一表示,用 clone 实现资源共享,用 NPTL 封装成 pthread 接口。

理解虚拟地址空间、分页、页表、TLB 和缺页异常,才能真正理解线程为什么"轻量";理解 pthread 的创建、终止、等待、分离,才能写出正确的多线程程序;理解 clone、TCB、TLS 和线程栈布局,才能从"会用线程"走向"理解线程"。


结尾

uu们,本文的内容到这里就全部结束了,道尔在这里再次感谢您的阅读!

|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| ### 道尔柯南 C/C++ & Linux 底层探索者 | 一个正在努力学习的技术博主 *** ** * ** *** 👀 【关注】 跟随我一起深耕技术领域,见证每一次成长。 ❤️ 【点赞】 让优质内容被更多人看见,让知识传递更有力量。 ⭐ 【收藏】 把核心知识点存好,在需要时随时查、随时用。 💬 【评论】 分享你的经验或疑问,评论区一起交流避坑! 不要忘记给博主"一键四连"哦! "今日目标达成!" |

结语:希望对学习Linux相关内容的uu有所帮助,不要忘记给博主"一键四连"哦!

相关推荐
wuminyu1 小时前
Markword在紧凑对象头上的实现原理剖析
java·linux·c语言·jvm·c++
酸菜。2 小时前
DW_apb_wdt总结
linux
虚无的纽扣2 小时前
【Linux】一篇文章带你吃透进程控制:fork、wait、exit到底在做什么
linux·ubuntu
raindayinrain2 小时前
Linux 网络编程--套接字选项
linux·网络·套接字选项
金士顿2 小时前
ASP.NET Core BackgroundService 深度解析:设备后台任务到底应该怎么设计
linux·嵌入式·asp.net core·arm64
j7~2 小时前
【Linux网络加餐课】(篇八)网络版计算器(终篇):守护进程、标准 IO 重定向与部署打包
linux·运维·网络编程·tcp·守护进程·io重定向·部署打包
Escalating_xu2 小时前
【Linux 多线程】自旋锁:从 atomic_flag 原子操作到 pthread_spin_* 与高竞争优化
linux·系统架构
kkkkkkkkkk_Z3 小时前
学嵌入式和Linux应用编程|学习日记:深入理解TCP协议与网络编程实战
linux·笔记·学习
kaoa0003 小时前
Linux入门攻坚——90、Hadoop-2-MapReduce计算框架及Hadoop生态系统概览
linux·hadoop·mapreduce