线程创建、终止

线程创建、终止

1. 进程内存分布回顾

  • 进程的内存布局与 C 语言程序的内存分布高度一致,主要包括以下区域(从低地址到高地址):

    • 代码区(Text Segment):存放程序的机器指令。
    • 初始化数据区(Initialized Data Segment):存放已显式初始化的全局变量和静态变量(如 static int a = 5;)。
    • 未初始化数据区(BSS Segment, Block Started by Symbol):存放未显式初始化的全局变量和静态变量(如 static int a;),程序加载时由内核自动清零。
      • 初始化数据区与 BSS 段统称为 静态存储区
    • 堆区(Heap):通过 malloccalloc 等函数动态分配的内存区域,向上增长。
    • 栈区(Stack):存放函数调用时的局部变量(自动变量)、函数参数、返回地址等,向下增长。
    • 内核空间(Kernel Space):位于最高地址区域,在学习 C 语言时通常被忽略,但它是操作系统内核代码和数据的驻留地。
  • 关键概念

    • 用户态代码只能访问用户空间(从代码区到栈区)。
    • 当程序执行系统调用(如 read, write)时,会触发硬件中断,CPU 切换到内核态,此时才能访问内核空间。

2. 线程(Thread)的基本概念

  • 线程是进程内的一个 执行流(Execution Stream)
  • 一个单线程进程可以看作是只有一个执行流的进程。
  • 多线程进程则包含多个并发的执行流。

2.1 多线程进程的内存共享模型

  • 在一个多线程进程中,所有线程 共享 以下内存区域:

    • 堆区(Heap)
    • 静态存储区(Static Storage Area),包括:
      • 初始化数据段
      • 未初始化数据段(BSS)
    • 代码区(Text Segment):所有线程执行的代码都来自同一个可执行文件。
  • 每个线程拥有自己 私有 的内存区域:

    • 栈区(Stack):每个线程在创建时都会分配独立的栈空间,用于存放其自身的局部变量和函数调用信息。
  • 核心结论:多线程进程本质上是 共享同一个地址空间 的多个执行流。

2.2 并发(Concurrency)与并行(Parallelism)

  • 并发(Concurrency)

    • 指多个执行流在宏观上看起来是同时运行的。
    • 在单核 CPU 上,通过操作系统快速切换(时间片轮转)实现,给用户“同时”的错觉。
  • 并行(Parallelism)

    • 指多个执行流在物理上真正同时运行。
    • 需要多核 CPU 或多处理器支持,每个核心可以独立执行一个线程。
  • I/O 阻塞:当一个线程因等待 I/O 操作(如读取文件、网络请求)而阻塞时,其他线程可以继续执行,提高了程序的整体效率。

3. POSIX 标准与 Pthreads

  • POSIX(Portable Operating System Interface) 是一套由 IEEE 制定的标准,旨在提高应用程序在不同 Unix-like 系统(如 Linux, macOS)之间的可移植性。
  • Pthreads(POSIX Threads) 是 POSIX 标准中定义的线程 API,也常被称为 pthread

3.1 线程共享的进程属性

根据 POSIX 标准,同一进程内的所有线程共享以下进程级资源:

  • 进程 ID (PID)
  • 父进程 ID (PPID)
  • 进程组 ID
  • 会话 ID
  • 打开的文件描述符表
  • 信号处理设置(Signal Disposition)
  • 文件模式创建掩码(umask)
  • 环境变量(Environment Variables)

3.2 线程私有的资源

每个线程拥有自己独立的资源,其中最重要的是:

  • 线程 ID (TID)
  • errno 变量:这是一个关键点。errno 是一个全局变量,用于记录最近一次系统调用或库函数的错误码。如果 errno 是进程共享的,那么当多个线程同时发生错误时,它们会互相覆盖 errno 的值,导致无法准确判断哪个线程发生了何种错误。因此,POSIX 要求 errno线程私有 的,确保每个线程都能独立获取自己的错误状态。

3.3 Pthreads 函数的返回值约定

  • 成功:返回 0
  • 失败不返回 -1,而是直接返回一个 错误码(Error Number)
  • 重要区别:与传统的系统调用(如 open, fork)不同,Pthreads 函数 不会设置全局的 errno 变量。错误信息直接通过返回值传递。

4. Linux 中线程的实现方式

Linux 系统对 POSIX 线程标准的实现经历了两个主要阶段:

4.1 LinuxThreads(已废弃)

  • 这是一个早期的实现,存在诸多不符合 POSIX 标准的问题:
    • 线程实际上是通过轻量级进程(LWP)实现的。
    • getpid() 在不同线程中可能返回不同的值(即线程ID),而不是统一的进程ID。
    • 线程不共享 uidgid 等进程属性。
  • 由于这些问题,LinuxThreads 已被现代 Linux 发行版弃用。

4.2 NPTL(Native POSIX Thread Library)

  • NPTL 是当前 Linux 系统(自 glibc 2.3.2 和 Linux kernel 2.6 起)采用的标准实现。
  • 优点
    • 更好地符合 POSIX 标准。
    • 性能更优。
    • 解决了 LinuxThreads 的大部分问题,例如所有线程现在共享同一个 PID。
  • 验证方法:可以通过命令 getconf GNU_LIBPTHREAD_VERSION 查看当前系统使用的线程库版本。输出通常为 NPTL x.x.x

5. 线程的底层系统调用:clone

  • 线程的创建最终依赖于 Linux 内核提供的 clone 系统调用。
  • clonefork 更加灵活,它允许子任务(可以是进程或线程)与父任务共享特定的资源(如内存空间、文件描述符表等)。
  • pthread_create 等高级 Pthreads API 在内部封装了对 clone 的调用,并处理了符合 POSIX 标准所需的额外逻辑。

6. 线程的创建与管理

6.1 pthread_create 函数

  • 功能:创建一个新的线程。

  • 函数原型

    int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
                       void *(*start_routine) (void *), void *arg);
    
  • 参数详解

    1. thread:指向 pthread_t 类型的指针,用于存储新创建线程的 ID。
    2. attr:指向线程属性对象的指针。若为 NULL,则使用默认属性。
    3. start_routine:新线程开始执行的函数地址。该函数必须接受一个 void* 参数并返回一个 void*
    4. arg:传递给 start_routine 函数的参数,类型为 void*,可用来传递任意数据。
  • 返回值

    • 成功:返回 0
    • 失败:返回一个非零的错误码(而非 -1)。

6.2 线程的终止方式

线程可以通过以下四种方式终止:

  1. 从启动例程中返回:线程函数执行 return 语句。
  2. 调用 pthread_exit:线程主动调用此函数退出,并可传递一个返回值。
  3. 被其他线程取消:通过 pthread_cancel 向目标线程发送取消请求。
  4. 整个进程终止:如果主线程(或其他任何线程)调用了 exit 或从 main 函数返回,整个进程及其所有线程都会被终止。

6.3 线程的连接(Joining)与分离(Detaching)

  • 可连接线程(Joinable Thread)

    • 默认状态下,线程是可连接的。
    • 其他线程(通常是主线程)必须调用 pthread_join 来等待该线程结束,并回收其资源和获取其返回值。
    • 如果不进行 join,该线程终止后会变成“僵尸线程”,占用系统资源。
  • 分离线程(Detached Thread)

    • 分离线程在终止时会自动释放其资源,无需其他线程调用 pthread_join
    • 可以通过两种方式将线程设为分离状态:
      1. 在创建后调用 pthread_detach(thread_id)
      2. 在创建前,通过 pthread_attr_setdetachstate 设置线程属性为 PTHREAD_CREATE_DETACHED

6.4 线程间的参数传递

  • 由于 pthread_createarg 参数和线程函数的返回值都是 void* 类型,可以通过指针传递任意复杂的数据结构。
  • 常见技巧
    • 传递栈上变量的地址需谨慎:如果主线程在子线程有机会使用该地址前就修改或销毁了该变量,会导致未定义行为。安全的做法是传递堆上分配的内存或使用数组/结构体来保存每个线程的独立数据。
    • 直接传递整数值:在 64 位系统上,可以将一个整数强制转换为 void* 进行传递(反之亦然),这是一种常见的“投机取巧”方法,但不够规范。

7. 多线程编程实践与性能

  • 性能优势:在多核 CPU 上,多线程可以实现真正的并行计算,显著提升 CPU 密集型任务的执行效率。
  • 潜在问题:多线程共享内存也引入了新的挑战,最主要的是 竞态条件(Race Condition)
    • 示例:多个线程同时对一个全局变量 global++ 进行操作。由于 global++ 不是原子操作(包含读取、加一、写回三个步骤),最终结果可能小于预期,因为线程间的操作可能会相互覆盖。
  • 解决方案:为了解决竞态条件等问题,需要使用同步机制,如互斥锁(Mutex)、信号量(Semaphore)等,这些内容将在后续课程中讲解。
信号处理 2026-03-15

评论区