Linux线程

了解线程

核心:Linux 的线程本质是轻量级进程(LWP)

与一些操作系统(如 Windows)在内核中提供原生的、与进程截然不同的“线程”对象不同,Linux 内核本身并不直接区分“线程”和“进程”

内核视角:任务 (task_struct)

  • 内核调度的基本单位是一个称为“任务”的结构体 task_struct。

  • 每个 task_struct 代表一个独立的执行流,拥有自己独立的:

    • 进程 ID (PID): 内核标识该任务的唯一 ID。

    • 调度属性: 优先级、调度策略等。

    • 虚拟内存空间: 指向内存描述符 (mm_struct) 的指针。

    • 文件描述符表: 打开的文件、套接字等。

    • 信号处理信息:

    • 处理器状态: 寄存器、栈指针等。

    • 资源限制:

传统的“进程”在 Linux 内核中就是一个拥有独立虚拟内存空间 (mm_struct) 的 task_struct。

线程的本质:共享资源的 task_struct

  • 当我们创建一个“线程”时,内核实际上创建了一个新的 task_struct

  • 但是,这个新创建的 task_struct 不会创建新的虚拟内存空间 (mm_struct)

  • 相反,它共享其父任务(通常是创建它的那个“主线程”所在的进程)的 mm_struct 和其他一些资源,比如:

    • 虚拟地址空间: 代码段、数据段、堆、共享库、文件映射区域等。

    • 文件描述符表: 所有线程看到相同的打开文件。

    • 信号处理程序: 信号处理函数是进程范围内共享的(尽管信号可以发送给特定线程)。

    • 进程 ID (PID) 和父进程 ID (PPID): 所有线程共享同一个 PID 和 PPID。这就是为什么 ps 默认只显示进程(主线程)。

    • 用户 ID (UID) 和组 ID (GID):

    • 当前工作目录:

  • 每个线程拥有自己独立的部分:

    • 线程 ID (TID): 内核为每个 task_struct 分配的唯一 ID。这是真正的内核级线程标识符。

    • 线程组 ID (TGID): 这个值就是该线程组(即进程)的 PID。同一个进程的所有线程 TGID 相同。

    • 栈: 每个线程有自己的用户态栈和内核态栈。

    • 寄存器状态: 包括程序计数器 (PC)、栈指针 (SP) 等。

    • 调度属性: 可以单独设置线程的优先级和调度策略(需要权限)。

    • 信号掩码: 每个线程可以阻塞不同的信号。

    • 线程特定数据 (Thread-Local Storage - TLS): 线程私有的数据区域。

    • errno 变量: 线程安全的错误码。

    • 浮点环境:

  • 因此,从内核角度看,一个“多线程进程”就是一组共享同一个 mm_struct(以及文件描述符表等)的 task_struct 的集合。这些 task_struct 共享 TGID (PID),但拥有各自独立的 TID。

为什么称为“轻量级进程”(LWP)?

  • 创建开销小: 因为创建线程 (clone(CLONE_VM | CLONE_FS | CLONE_FILES | …)) 避免了创建全新的虚拟内存空间、文件描述符表等,只需要复制或共享父任务的大部分资源,主要开销是分配新的栈和初始化 task_struct。创建进程 (fork()) 需要复制或写时复制父进程的整个地址空间,开销大得多。

  • 切换开销小: 同一进程内的线程切换,因为共享地址空间,不需要切换页表(TLB 刷新代价高),缓存局部性也可能更好。进程切换需要切换地址空间。

  • 共享数据方便: 由于共享全局变量和堆内存,线程间通信和数据共享非常高效(但也带来了同步问题)。

理解POSIX

  • POSIX 是一系列由 IEEE 制定的标准,旨在定义操作系统(尤其是类 Unix 系统)应该提供的应用程序接口(API)、命令行接口和工具环境。

  • 它的核心目标是 可移植性。如果一个程序遵循 POSIX 标准编写,那么它应该能够在任何实现了该 POSIX 标准的操作系统(如 Linux, BSD, macOS, Solaris 等)上编译和运行,而无需进行重大修改。

与线程的关系

  • POSIX 线程标准(通常称为 Pthreads)是 POSIX 标准的一部分(具体在 IEEE Std 1003.1c)。

  • 它定义了一套 统一的、跨平台的 C 语言 API 用于创建、同步和管理线程。这套 API 是开发者编写多线程程序的主要接口。

  • 关键 API 示例

    • pthread_create():创建新线程。

    • pthread_join():等待线程终止并回收资源。

    • pthread_exit():终止当前线程。

    • pthread_mutex_init() / pthread_mutex_lock() / pthread_mutex_unlock():互斥锁操作。

    • pthread_cond_init() / pthread_cond_wait() / pthread_cond_signal():条件变量操作。

    • pthread_key_create() / pthread_setspecific() / pthread_getspecific():线程特定数据(TLS)管理(旧接口)。

  • 核心作用: 为开发者提供标准、可移植的多线程编程接口。开发者只需要学习 Pthreads API,就可以在多种支持 POSIX 的系统上编写多线程程序,无需深入了解底层操作系统(如 Linux)的具体实现机制(如 clone, futex)。

总结

  • POSIX (Pthreads): 定义标准接口。开发者用这些函数 (pthread_create, pthread_mutex_lock 等) 编写跨平台多线程程序。
  • 你用 POSIX 函数写多线程代码。

理解clone()

  • clone() 是 Linux 特有的一个底层系统调用。它是 Linux 内核创建新的执行流(无论是传统意义上的进程,还是线程)的基础机制。

  • 它非常灵活,通过传递一组标志位 (flags) 来控制新创建的“任务”(内核用 task_struct 表示)与其父任务共享哪些资源。

与线程的关系?

  • 线程的本质: 在 Linux 内核看来,线程就是一组共享了大量资源(特别是虚拟内存空间 CLONE_VM)的轻量级进程(LWP)。

  • 线程的创建: 当你在用户空间调用 pthread_create() (POSIX API) 时,底层的线程库(如 NPTL)最终会调用 clone() 系统调用来请求内核创建新线程。

  • 关键标志位 (flags) 用于线程

    • CLONE_VM: 共享虚拟内存空间(代码段、数据段、堆等)。这是线程区别于进程的核心标志。

    • CLONE_FS: 共享文件系统信息(根目录、当前工作目录等)。

    • CLONE_FILES: 共享打开的文件描述符表

    • CLONE_SIGHAND: 共享信号处理程序。

    • CLONE_SYSVSEM: 共享 System V 信号量撤销值。

    • CLONE_THREAD: 将新任务放在同一个线程组内(同一个 TGID/PID 下)。这是 NPTL 要求的关键标志。

    • CLONE_SETTLS: 设置新任务的 TLS (Thread Local Storage) 段。

    • CLONE_PARENT_SETTID / CLONE_CHILD_CLEARTID: 用于用户空间线程库高效管理线程 ID 和通知线程退出。

  • 对比进程创建 (fork()): fork() 在 Linux 上最终也是通过 clone() 实现的,但它传递的标志位不包含 CLONE_VM, CLONE_FS, CLONE_FILES 等。这导致子进程获得父进程资源的独立副本(通常通过写时复制)。

  • 核心作用: 提供 Linux 内核创建线程或进程的底层机制。它定义了新执行流与父执行流之间资源共享的粒度pthread_create() 是对 clone() 用于创建线程场景的高层封装。

简单来说

  • clone(): Linux 内核创建线程的底层机制。pthread_create() 最终调用 clone() 并设置特定的资源共享标志 (CLONE_VM, CLONE_FILES 等) 来创建内核线程(LWP)。
  • 创建线程时,POSIX 库用 clone() 告诉内核怎么建新线程(共享哪些资源)。

用户视角 vs 内核视角

  • 用户空间:程序员使用pthread_create, pthread_join等 POSIX 线程库 (libpthread.so) 的函数来操作线程。

  • 内核空间: Pthreads 库最终通过 Linux 的系统调用(主要是 clone())来请求内核创建和管理这些共享资源的 task_struct(LWP)。clone() 是一个非常灵活的系统调用,通过传递不同的标志位(如 CLONE_VM, CLONE_FS, CLONE_FILES, CLONE_SIGHAND)来控制新创建的 task_struct 与父 task_struct 共享哪些资源。创建线程时传递的标志位指示内核共享大部分资源创建进程时(fork() 最终也调用 clone())传递的标志位指示内核不共享地址空间等关键资源。

了解NPTL是现代 Linux 发行版默认使用的 Pthreads 实现。

优势:

  • 1:1 模型: 严格的一个用户线程对应一个内核调度实体(LWP / task_struct)。这使得调度高效且符合 POSIX 语义。

  • 高效同步: 利用内核提供的 futex (Fast Userspace muTEX) 机制实现高效的互斥锁、条件变量等同步原语。futex 结合了用户空间的快速路径(无竞争时)和内核空间的慢速路径(需要阻塞时),极大地减少了系统调用的开销。

  • 健壮的信号处理: 正确实现了 POSIX 信号语义。

  • 线程组管理: 内核支持线程组 (TGID),所有线程共享同一个 PID。

  • /proc 文件系统支持: /proc/[pid]/task 目录列出了进程中的所有线程(LWP),每个线程有自己的子目录(以其 TID 命名)。

  • 更好的性能和可伸缩性: 能够高效支持大量并发线程。

线程标识符

  • 进程 ID (PID)

    • 用户空间:getpid() 系统调用返回的是该进程(线程组)的 TGID。

    • 内核空间:task_struct 的 tgid 字段

    • 所有线程共享同一个 PID (TGID)

  • 线程 ID (TID)

    • 用户空间 (POSIX):pthread_t 类型。这是一个不透明的数据类型,由 Pthreads 库管理。使用 pthread_self() 获取当前线程的 pthread_t。pthread_t 不一定等于内核 TID,尽管 NPTL 中它通常是一个指向线程管理结构的指针,其值可能间接映射到 TID。

    • 内核空间:task_struct 的 pid 字段。这是内核调度器识别该独立执行流的唯一 ID。

    • 获取内核 TID:在用户空间可以使用 gettid() 系统调用直接获取当前线程的内核 TID (即 task_struct->pid)。ps -eLf 命令中的 LWP 列或 NLWP 列显示的就是内核 TID 和线程数。

  • 线程组 ID (TGID)

    • 内核空间:task_struct 的 tgid 字段,等于主线程的 PID

    • 用户空间:getpid() 返回 TGID。

线程同步*****

由于线程共享内存,访问共享数据(全局变量、堆内存)必须进行同步,以防止竞态条件(Race Conditions)导致数据不一致或程序崩溃。

子线程没有独立的地址空间,数据通常是共享的;如果同时访问数据会导致混乱,需要进行同步控制。

同步的核心思想是让某个线程在需要时获取资源(锁),其他线程在资源不可用时等待,直到资源可用再继续执行。
线程之间的同步通常通过两种锁来表达关系:

  • 互斥锁(mutex):确保同一时间只有一个线程能够访问共享资源。
  • 条件变量(condition variable):用于在某个条件成立时通知等待中的线程继续执行。

线程序列的执行需要保证执行的先后次序,常用的策略包括:

  • 先获取锁、再检查条件、在必要时等待(阻塞);
  • 条件满足时发出通知,唤醒等待的线程继续执行。
  • 线程之间的协作与耦合要尽量降低,避免过度依赖全局状态,减少死锁风险。
  • 资源管理要清晰,避免“资源占用-等待-再次占用”的循环,使并发程序保持高效。

理解futex

  • futex 是 Linux 提供的一个系统调用 (futex()) 和一种同步原语的构建思想

  • 它设计用于在用户空间和内核空间之间实现高效的同步(如互斥锁、读写锁、条件变量、信号量等)。

核心思想

  • 无竞争时走快速路径(用户空间): 在锁空闲(或条件满足)的情况下,同步操作(如加锁、解锁)完全在用户空间通过原子操作(如 cmpxchg)完成。这避免了昂贵的系统调用和内核态/用户态切换的开销。

  • 竞争时走慢速路径(内核): 当操作无法立即完成(如锁已被占用、条件未满足),需要阻塞线程时,才会通过 futex() 系统调用进入内核。内核负责将线程挂起在 futex 关联的地址(uaddr)上,并在必要时(如解锁操作唤醒、条件满足)将其唤醒。

  • futex 本身只关注对一个用户空间地址 (uaddr) 的等待 (FUTEX_WAIT) 和唤醒 (FUTEX_WAKE) 操作。更复杂的同步原语(如互斥锁)是利用这个基础能力在用户空间构建的。

与线程的关系?

  • 高效同步的基石: 现代 Linux POSIX 线程库(NPTL)使用 futex 作为其所有主要同步机制(互斥锁、条件变量、读写锁、屏障)的基础实现。

  • 性能关键: 多线程程序中同步操作非常频繁。futex 的设计极大地降低了无竞争或低竞争情况下同步操作的开销,这是实现高性能多线程程序的关键。

  • API 透明性: 开发者仍然使用标准的 pthread_mutex_lock() 等 Pthreads API。futex 是这些 API 在 Linux 上高性能实现的底层秘密武器。

  • 核心作用: 提供一种用户态优先的机制,使得在 Linux 上实现高性能的线程同步原语成为可能。它是 NPTL 高性能的关键因素之一。

简单来说

  • futex: Linux 实现高效同步的底层原语。pthread_mutex_lock/unlock(),
  • 你用的锁和条件变量,在 Linux 里靠 futex 才能那么快。

互斥锁

  • (pthread_mutex_t)

  • 最基本的同步原语。保证同一时刻只有一个线程能进入被保护的临界区 (Critical Section)。

  • 实现: NPTL 利用 futex 实现高效的互斥锁。无竞争时,加锁/解锁操作完全在用户空间完成(原子操作)。当发生竞争需要阻塞线程时,才会陷入内核。

例:
在这里插入图片描述

  • a++ 需要执行 3 个步骤:
    • 取 a 的值
    • 计算 a+1
    • a+1 赋值给 a
  • 某一时刻,若全局变量 a 的值为 5,线程 A 和线程 B 中都要执行 a++,若线程 A 在执行 a+1 赋值给 a 之前,这时线程 B 执行了 a++,线程 B 取到的 a 的值仍是 5,这时,线程 A 和 B 赋给 a 的值都是 6。值为 5 的变量 a 经过两次 a++ 结果却是 6,显然出现了错误。
  • 多线程竞争操作共享变量的这段代码也会出错,俗称临界区。多个进程同时操作临界区会产生错误,所以这段代码应该互斥,当一个线程执行临界区时,应该阻止其他线程进入临界区。
  • 为避免线程更进一步共享变量时出现问题,可以使用互斥量(mutex 是 mutual exclusion 的缩写)来确保同一时刻只有一个线程可以访问共享资源。
  • 互斥量的作用类似于一个“锁”,当一个线程请求共享资源时,它必须先获取互斥量的锁。如果互斥量当前没有被其他线程占用,那么这条线程就获得锁,并可以访问共享资源;如果互斷量已经被其他线程占用,那么该线程将被阻塞,直到互斥量的锁被释放。
  • 互斥量有两种状态:已锁定(locked)和未锁定(unlocked)。至多只有一个线程可以锁定该互斥量,试图再次锁定时将会阻塞,等待前一个线程释放锁。
    在这里插入图片描述

死锁现象

当多个线程为了保护多个共享资源而使用了互斥锁,如果多个互斥锁使用不当,就可能造成,多个线程一直等待对方的锁释放,这就是死锁现象
在这里插入图片描述
死锁产生的四个必要条件

  • 互斥条件:资源只能被一个进程占用
  • 持有并等待条件:线程1已经持有资源A,可以申请持有资源B,如果资源B已经被线程2持有,这时线程1持有资源并等待资源B
  • 不可剥夺条件:一个线程持有资源,只能自己释放后其他线程才能使用。其他线程不能强制收回资源
  • 环路等待条件:多个线程互相等待资源,形成一个环形等待链

避免死锁的常用方法如下

  • 锁的粒度控制:破坏请求与保持条件,尽量减少持有锁的时间,降低发生死锁的可能性
  • 资源有序分配:破坏环路等待条件,规定线程使用资源的顺序,按规定顺序给资源加锁
  • 重试机制:破坏不可剥夺条件,如果尝试获取资源失败,放弃已持有的资源后重试

条件变量

条件变量,通知状态的改变。 条件变量允许一个线程就某个共享变量的状态变化通知其他线程,并让其他线程等待(阻塞于)这一通知。

条件变量是结合互斥量使用。 条件变量就共享变量的状态改变发出通知,而互斁量则提供对该共享变量访问的互斥。

演示程序

  • 定义一个变量 a,线程 1 当 a 为 0 时对 a+1,线程 2 当 a 为 1 时对 a-1,循环 10 次, a 的结果应为 0 和 1 交替。

两个线程需要不断的轮询结果,造成 CPU 浪费。可以使用条件变量解决:线程 1 不满足运行条件时,先休眠等待,其他线程运行到满足条件时,通知并喚醒线程 2 继续执行。

  • (pthread_cond_t)

  • 用于线程间的等待/通知机制。允许线程在某个条件不满足时阻塞自己,并等待其他线程在条件可能变为真时唤醒它。

  • 必须与互斥锁配合使用等待操作 (pthread_cond_wait) 会原子地释放互斥锁并阻塞线程。当被唤醒时,它会重新获取互斥锁。

读写锁

读写锁, 中读锁和写锁两部分组成, 读取资源时用读锁, 修改资源时用写锁。 其特性为:写独占,读共享(读先锁)。 读写适合多写少写的场景。

读写锁的工作原理

  • 没有线程持有写锁时, 所有线程都可以一起持有读锁
  • 有线程持有写锁时, 所有的读锁和写锁都会阻塞

读写优先锁: 有线程持有读锁, 这时有一个读线程和一个写线程想要获取锁, 读线程会优先获取锁 就是读优先锁, 反过来就是写优先锁。

  • (pthread_rwlock_t)

  • 允许多个读线程同时访问共享资源,但只允许一个写线程独占访问。适用于读多写少的场景。

  • 实现: 同样基于 futex 或类似机制。

场景分析

  • 持有读锁时,申请读锁: 全部直接加锁成功,不需要等待
  • 持有写锁时,申请写锁: 申请写锁阻塞等待,写锁释放再申请加锁
  • 持有读锁时, 申请写锁: 写锁阻塞
  • 持有写锁时, 申请读锁: 读锁阻塞
  • 持有读锁时, 申请写锁和读锁: 申请的读锁和写锁都会阻塞,当持有的写锁释放时,读锁先加锁成功
  • 持有读锁时, 申请写锁和读锁: 申请的写锁阻塞,读锁加锁成功, 写锁阻塞到读锁全部解锁才能加锁,在此期间可能一直有读锁申请,会导致写锁一直无法申请成功,造成锁死

自旋锁

  • (pthread_spinlock_t)

  • 一种忙等待的锁。线程在尝试获取锁失败时,不会立即阻塞,而是在一个循环中不断检查锁的状态(“自旋”),直到锁可用。

  • 适用场景: 临界区非常短,且线程持有锁的时间极短,预期等待时间小于线程阻塞/唤醒的开销。在用户空间,自旋锁通常只适用于非抢占式内核或绑定到特定 CPU 的线程,否则在单核上或持有锁的线程被抢占可能导致其他线程长时间无意义自旋浪费 CPU。

  • 内核中广泛使用

屏障

  • (pthread_barrier_t)

  • 允许多个线程在一个公共点(屏障)上相互等待,直到所有参与的线程都到达屏障点,然后所有线程再继续执行。

信号量

信号量是操作系统提供的一种协调共享资源访问的方法。信号量是由内核维护的整形变量(sem),它的值表示可用资源的数量

  • (sem_t - POSIX 有名/无名信号量)

  • 一种更通用的同步机制,维护一个计数器。可以用来控制对多个共享资源的访问(计数信号量),也可以用作二值信号量(类似互斥锁)。

  • 内核信号量 (down(), up()) 与用户空间信号量 (sem_wait(), sem_post()) 不同。 用户空间 POSIX 信号量有进程共享选项。

对信号量的原子操作

  • P操作
    • 如果有可用资源(sem>0),占用一个资源(sem-1)
    • 如果没有可用资源(sem=0),进程或线程阻塞,直到有资源
  • V操作
    • 如果没有进程或线程在等待资源,释放一个资源(sem+1)
    • 如果有进程或线程在等待资源,唤醒一个进程或线程

P操作和V操作成对出现,进入临界区前进行P操作,离开临界区后进行V操作
在这里插入图片描述

生产者消费者模型

线程同步典型案例即为生产消费者模型,而借助条件变量来实现这一模型,是比较常见的一种方法。假定有两个线程,一个模拟生产者行为,一个模拟消费者行为。两个线程同时操作一个共享资源(一般称之为汇聚),生产者向其中添加产品,消费者从中消费掉产品。
在这里插入图片描述
相较于互斥量而言,条件变量可以减少竞争。如直接使用互斥量,除了生产者、消费者之间要竞争互斥量以外,消费者之间也需要竞争互斥量,但如果汇聚(链表)中没有数据,消费者之间竞争互斥量是无意义的。有了条件变量机制以后,只有生产者完成生产,才会引起消费者之间的竞争。提高了程序效率。

线程安全与可重入***

  • 线程安全: 指一个函数或代码片段可以在被多个线程同时调用时,仍然能正确地工作(产生预期的结果),无需调用者做额外的同步。通常意味着函数内部保护了所有共享数据的访问(使用锁等)。

  • 可重入: 指一个函数可以在其自身尚未返回的情况下被再次调用(例如被信号处理程序中断后再次调用,或递归调用),并且仍然能正确工作。可重入函数通常不依赖静态局部变量、全局变量或不可重入函数,只使用局部变量和传参。可重入函数一定是线程安全的,但线程安全函数不一定是可重入的(因为它可能使用了互斥锁,而锁在信号处理程序或递归调用中可能导致死锁)。

线程本地存储***

  • 关键字__thread (GCC/Clang 扩展),或 thread_local (C11/C++11 标准)。

  • 用途: 存储线程特定的状态,如 errno(每个线程有自己的 errno 副本)、数据库连接、用户会话信息、避免在函数中传递大量上下文参数等。

  • 实现: 编译器/链接器在 ELF 文件的 .tdata (初始化的 TLS) 和 .tbss (未初始化的 TLS) 段中分配 TLS 变量。运行时,当线程创建时,会为它分配一块私有的 TLS 内存区域。通过特殊的段寄存器 (如 FS 或 GS) 加上偏移量来访问 TLS 变量。

理解TLS变量

  • TLS 是一种存储类(Storage Class)。

  • 它允许你定义全局或静态变量,但每个线程都拥有该变量的独立副本

  • 一个线程对自身 TLS 变量的读写操作不会影响其他线程中同名的 TLS 变量。

  • 允许每个线程拥有一个变量的独立副本。一个线程修改其 TLS 变量不会影响其他线程的同名变量。

为什么需要?

  • 线程私有状态: 在多线程环境中,有些数据是线程特定的,而不是所有线程共享的全局状态。例如:

    • errno:每个线程需要有自己独立的错误码副本。

    • 用户身份或会话信息(在 Web 服务器中)。

    • 数据库连接句柄(每个线程管理自己的连接)。

    • 复杂的函数调用链中避免传递大量上下文参数。

    • 需要线程安全的伪随机数生成器种子。

  • 避免锁竞争: 如果使用全局变量加锁来模拟线程私有数据,会带来不必要的锁竞争开销。TLS 天然避免了这种竞争。

与线程的关系?

  • 线程创建与销毁: 当一个线程被创建时,系统会为它分配一块私有的 TLS 内存区域,用于存储其所有 TLS 变量的初始值或零初始化值。当线程终止时,这块内存(包括其中 TLS 变量的值)会被自动回收。

  • 访问机制: 编译器/链接器会为 TLS 变量分配在特殊的段(.tdata 用于初始化的 TLS,.tbss 用于未初始化的 TLS)。运行时,操作系统和线程库协作,使得每个线程能通过特定的段寄存器(如 FSGS 寄存器)加上该变量在 TLS 块中的偏移量来访问自己的 TLS 变量副本。这个过程对开发者是透明的,像访问普通变量一样使用即可。

  • 核心作用: 提供一种高效、安全、易用的机制来存储和管理线程私有的全局数据,是编写清晰、高效、线程安全代码的重要工具。

简单来说

  • TLS 变量: 提供线程私有存储的语言/编译器级机制。用于存储线程特有的状态(如 errno, 会话数据),避免使用全局变量+锁带来的开销和复杂性。
  • 你想让每个线程有自己的“全局”变量?用 TLS (__thread / thread_local) 就行。

在这里插入图片描述

线程与信号 (Signals)**

  • 信号目标

    • 信号可以发送给整个进程 (PID/TGID):内核会选择该进程中的一个未阻塞该信号的线程来递送信号(具体选择哪个线程未指定)。

    • 信号可以发送给特定线程 (TID):使用 pthread_kill()tgkill() 系统调用。

  • 信号处理程序

    • 进程范围内共享: 使用 sigaction() 设置的信号处理程序是该进程所有线程共享的。一个线程修改了处理程序,会影响所有线程。
  • 信号掩码

    • 线程独立: 每个线程可以独立地设置自己阻塞哪些信号 (pthread_sigmask)。这决定了哪些信号会被递送到该线程。
  • 备选信号栈

    • 线程独立: 每个线程可以使用 sigaltstack() 设置自己的备选信号栈。
  • 建议: 在多线程程序中,通常的做法是:

    • 在主线程启动所有工作线程之前,设置好所需的信号处理程序。

    • 让所有工作线程阻塞所有信号 (pthread_sigmask)。这样,只有主线程能接收信号。

    • 在主线程中专门用一个循环调用 sigwaitinfo() 或 sigtimedwait() 来同步地接收和处理信号。这样可以避免信号处理程序的异步性带来的复杂性。

线程取消 (Cancellation)***

  • 允许一个线程请求终止另一个线程 (pthread_cancel)。

  • 取消点: 被取消的线程并非立即终止,而是在下一次到达一个取消点时才会实际终止。取消点是 POSIX 定义的一些可能阻塞的系统调用或库函数(如 read, write, sleep, pthread_join, pthread_cond_wait, sem_wait 等)。也可以使用 pthread_testcancel() 主动创建取消点。

  • 取消类型

    • 延迟取消 (Deferred Cancellation - PTHREAD_CANCEL_DEFERRED): 默认类型。线程只在取消点检查取消请求。

    • 异步取消 (Asynchronous Cancellation - PTHREAD_CANCEL_ASYNCHRONOUS): 线程可以在任何时候被取消(甚至在执行非阻塞代码时)。极其危险,因为可能中断线程在持有锁、修改关键数据结构等中间状态。强烈不建议使用

  • 清理处理程序: 线程可以使用 pthread_cleanup_push()pthread_cleanup_pop() 注册清理函数。这些函数在线程被取消或显式调用 pthread_exit() 时会被调用(按照栈的顺序),用于释放资源(如关闭文件、释放锁、释放内存)。

线程终止与资源清理****

  • 线程终止方式

    • 从线程函数中 return。

    • 调用 pthread_exit()。

    • 被另一个线程取消 (pthread_cancel)。

    • 进程终止(例如主线程 return 或调用 exit())。

  • 分离状态 (Detached State):

    • 可连接 (Joinable - 默认): 线程终止后,其退出状态会被保留,直到另一个线程对其调用 pthread_join() 来回收资源并获取其返回值。必须调用 pthread_join() 来避免资源(栈、线程描述符)泄漏!

    • 分离的 (Detached): 线程终止时,系统会自动回收其所有资源。无法对其调用 pthread_join()。使用 pthread_detach() 可以将一个运行中的线程设为分离状态,或者在创建时使用 pthread_attr_setdetachstate() 设置属性。

  • 资源清理责任

    • 线程终止时,其栈空间会被释放

    • 堆内存 (malloc 分配) 不会被自动释放! 如果线程分配了堆内存,必须在线程退出前显式释放它,或者通过某种机制(如返回值传递给 pthread_join 的调用者)让其他线程负责释放。

    • 文件描述符不会自动关闭! 关闭文件描述符是进程级别的操作。如果线程打开了一个文件,通常应该由该线程自己关闭它,或者确保进程中的其他线程会负责关闭(需要谨慎设计共享)。进程退出时会关闭所有文件描述符。

    • 锁不会自动释放! 如果线程在持有锁时终止(特别是可连接线程未被 join,或者分离线程意外终止),会导致其他线程永久阻塞在该锁上(死锁)。务必确保锁的持有范围清晰,并在临界区外释放锁。 使用 pthread_cleanup_push 注册锁的释放函数是一种防御性手段。

性能考量与最佳实践

  • 创建/销毁开销: 虽然比进程轻量,但创建和销毁线程仍有开销(分配栈、初始化 TLS、内核操作)。避免在高频循环中创建/销毁线程。使用线程池是常见优化。

  • 上下文切换开销: 同一进程内线程切换开销小于进程切换,但过多的线程(尤其活跃线程数远超 CPU 核心数)会导致大量上下文切换开销,降低整体性能。

  • 同步开销: 锁竞争是性能杀手。设计时尽量减少临界区大小和持有时间。考虑使用无锁数据结构(但实现复杂)、读写锁、或减少共享数据。

  • 缓存友好性: 让线程尽可能长时间在同一个 CPU 核心上运行(CPU 亲和性 - pthread_setaffinity_np)可以提高缓存命中率。避免线程在核心间频繁迁移。

  • NUMA 架构: 在多插槽 (Multi-Socket) NUMA 系统中,访问本地内存节点的速度远快于访问远端内存节点。设计程序时要考虑数据局部性和线程绑定。

  • 合理线程数: 通常推荐活跃线程数不要显著超过 CPU 的物理核心数(或超线程后的逻辑核心数),除非线程大部分时间在阻塞(如 I/O 密集型)。I/O 密集型任务可以使用更多线程来重叠 I/O 等待。

  • 避免优先级反转: 使用优先级继承互斥锁 (PTHREAD_PRIO_INHERIT) 或优先级天花板协议 (PTHREAD_PRIO_PROTECT) 的互斥锁。

  • 使用工具: top -H, ps -eLf, pidstat -t, perf, strace, gdb 等工具对于监控、调试和分析多线程程序性能至关重要。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值