C 进阶(11) - 线程控制

在 Linux C 多线程编程中,所谓的"线程控制",本质上就是对线程从创建、运行、等待、分离到终止这一完整生命周期的管理。

线程限制

在 Linux C 多线程编程中,线程并不是可以无限制创建的。系统从全局、用户、进程 等多个层面设置了严格的"天花板"。如果你在开发高并发服务,或者遇到 pthread_create 返回 EAGAIN(资源暂时不可用)的错误,通常就是触碰到了这些限制。

以下是 Linux 系统中限制线程数量的几个核心因素:

1. 系统全局线程总数限制 (threads-max)

这是整个操作系统允许创建的最大线程数(包括所有进程的所有线程)。

  • 查看方式cat /proc/sys/kernel/threads-max
  • 默认计算 :内核通常会根据系统物理内存大小自动计算(一般是 总内存页数 / 4)。
  • 修改方式
    • 临时生效:sudo sysctl -w kernel.threads-max=200000
    • 永久生效:在 /etc/sysctl.conf 中添加 kernel.threads-max = 200000,然后执行 sudo sysctl -p

2. 用户级进程/线程数限制 (nproc)

Linux 内核将线程视为"轻量级进程(LWP)",因此这个限制实际上是限制了单个用户能创建的进程和线程总数。这是日常开发中最容易触发的限制。

  • 查看方式 :在终端输入 ulimit -u(普通用户默认通常是 1024 或 4096)。
  • 修改方式 :编辑 /etc/security/limits.conf 文件,添加如 your_username hard nproc 65535 的配置。

3. 虚拟内存与线程栈大小限制 (stack size)

这是限制单个进程线程数的最常见瓶颈

  • 原理 :每个线程创建时都需要分配独立的栈空间(Stack)。Linux 默认每个线程的栈大小通常是 8MB (可以通过 ulimit -s 查看,单位是 KB)。
  • 计算公式最大线程数 ≈ 进程可用虚拟内存 / 线程栈大小
  • 举个例子 :如果你的进程可用虚拟内存是 4GB,默认栈大小是 8MB,那么理论上最多只能创建 4096MB / 8MB = 512 个线程。一旦超过,pthread_create 就会因为内存不足而失败。
  • 优化手段 :在创建线程时,通过 pthread_attr_setstacksize 显式指定一个较小的栈空间(比如 256KB 或 512KB),这样在同样的内存下可以创建数倍于默认的线程。

4. 系统最大 PID 数限制 (pid_max)

因为每个线程在内核中都需要占用一个唯一的 PID(更准确地说是 TID),所以系统的 PID 上限也会间接限制线程总数。

  • 查看方式cat /proc/sys/kernel/pid_max(默认通常是 32768,现代系统可能会设为 4194304)。

5. 其他潜在限制

  • 内存映射区域数量 (vm.max_map_count):每个线程的栈都需要独立的内存映射区域(VMA)。如果线程极多,可能会耗尽这个配额。
  • 容器环境限制 (cgroups) :如果你的程序跑在 Docker 或 Kubernetes 等容器里,还会受到 cgroups 的 pids.max 参数限制,即使宿主机资源充足,容器内也可能无法创建新线程。

📊 核心限制参数速查表

表格

限制维度 核心参数 / 命令 说明与影响
系统全局 /proc/sys/kernel/threads-max 整个系统允许的最大线程总数
用户级别 ulimit -u (nproc) 单个用户能创建的进程/线程总数
内存与栈 ulimit -s (stack size) 默认8MB栈极易耗尽虚拟内存,限制单机线程数
PID 资源 /proc/sys/kernel/pid_max 系统最大 PID 数,间接限制线程 ID 的分配

💡 避坑建议:

在编写高并发 C 程序时,如果发现线程创建失败,不要只盯着物理内存看。建议优先检查 ulimit -u 是否太小,以及是否因为默认的 8MB 线程栈导致虚拟内存过早耗尽。对于需要创建成千上万个线程的场景,调小线程栈调大 nproc 是最立竿见影的优化手段。

线程属性

在 Linux 多线程编程中,创建线程时如果传 NULL,系统就会使用默认属性。但在高性能或特殊场景下,我们往往需要对线程进行"精细化"的控制,这就需要用到 线程属性(pthread_attr_t

通过配置线程属性,你可以定制线程的栈大小、调度策略、分离状态等。以下是线程属性在实际开发中最核心的几个应用:

🔧 线程属性的基本使用流程

线程属性不能直接赋值,必须通过专门的 API 进行初始化和设置。标准的使用步骤如下:

  1. 定义属性变量pthread_attr_t attr;
  2. 初始化属性pthread_attr_init(&attr);(将其设为默认值)
  3. 设置具体属性:如栈大小、分离状态等。
  4. 创建线程 :将 attr 传给 pthread_create
  5. 销毁属性pthread_attr_destroy(&attr);(销毁属性对象本身,不影响已创建的线程)。

📏 设置线程栈大小(stacksize

这是线程属性在高并发场景中最实用的优化手段。

  • 默认情况 :Linux 下线程的默认栈大小通常是 8MB(可以通过 ulimit -s 查看)。
  • 优化意义 :如果你需要创建成百上千个线程,默认的 8MB 会迅速耗尽进程的虚拟内存。通过 pthread_attr_setstacksize 将栈大小调小(例如 256KB 或 512KB),可以在同样的内存下支撑更多的并发线程。

🚀 设置分离状态(detachstate

在创建线程时,直接将其指定为分离状态,可以省去后续调用 pthread_detach 的步骤。

  • 默认状态PTHREAD_CREATE_JOINABLE(可结合,需要其他线程 join 来回收资源)。
  • 分离状态PTHREAD_CREATE_DETACHED(线程结束后由内核自动回收资源,无法被 join)。

⚙️ 设置调度策略与优先级(schedpolicy & schedparam

这块在实时系统、音视频处理等对延迟要求极高的场景中非常有用。

  • 调度策略
    • SCHED_OTHER:默认的分时调度策略(CFS),适用于普通线程。
    • SCHED_FIFO / SCHED_RR:实时调度策略(先进先出 / 时间片轮转)。注意 :设置实时调度策略通常需要 root 权限 ,否则会返回权限错误(EPERM)。
  • ⚠️ 极易踩坑点(继承属性)
    如果你显式设置了调度策略和优先级,必须 同时调用 pthread_attr_setinheritsched(&attr, PTHREAD_EXPLICIT_SCHED)
    因为默认情况下,线程会继承 创建者(父线程)的调度属性(PTHREAD_INHERIT_SCHED)。如果不关掉继承,你辛辛苦苦设置的实时策略和优先级会被直接忽略!

📊 核心线程属性 API 速查

属性控制 核心 API 作用与说明
初始化/销毁 pthread_attr_init / destroy 使用属性前的必经之路,用完需销毁
栈大小 pthread_attr_setstacksize 调小栈空间,高并发场景节省内存的核心手段
分离状态 pthread_attr_setdetachstate 设为 DETACHED,线程结束自动回收资源
调度策略 pthread_attr_setschedpolicy 设置 SCHED_FIFO 等实时策略(需 root)
继承属性 pthread_attr_setinheritsched 必须设为 EXPLICIT,否则自定义调度策略不生效

💡 综合代码示例

下面是一个结合了分离状态自定义栈大小 以及调度策略的综合示例,可以直观地看到这些属性是如何串联起来的:

复制代码
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <sched.h>

void* thread_func(void* arg) {
    printf("子线程正在运行...\n");
    // 模拟业务逻辑
    sleep(1);
    return NULL;
}

int main() {
    pthread_t tid;
    pthread_attr_t attr;

    // 1. 初始化线程属性
    pthread_attr_init(&attr);

    // 2. 设置为分离状态(线程结束后自动回收)
    pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);

    // 3. 设置线程栈大小为 512KB(默认通常是 8MB,高并发时可大幅节省内存)
    pthread_attr_setstacksize(&attr, 512 * 1024);

    // 4. 设置调度策略为 SCHED_RR(时间片轮转实时调度)
    // 注意:运行此程序可能需要 sudo 权限
    pthread_attr_setinheritsched(&attr, PTHREAD_EXPLICIT_SCHED); // 必须显式指定,否则不生效!
    pthread_attr_setschedpolicy(&attr, SCHED_RR);
    
    struct sched_param param;
    param.sched_priority = sched_get_priority_max(SCHED_RR); // 获取并设置最大优先级
    pthread_attr_setschedparam(&attr, &param);

    // 5. 使用属性创建线程
    int ret = pthread_create(&tid, &attr, thread_func, NULL);
    if (ret != 0) {
        perror("pthread_create failed");
        exit(EXIT_FAILURE);
    }

    // 6. 销毁属性对象(此时线程已经创建,销毁attr不影响线程运行)
    pthread_attr_destroy(&attr);

    printf("主线程继续执行其他任务...\n");
    sleep(2); // 等待子线程执行完毕
    printf("程序结束。\n");
    return 0;
}

掌握这些线程属性,在编写 Linux 多线程程序时,从"能用"进阶到"好用"和"高性能"。

同步属性

在 Linux 多线程编程中,除了线程本身的属性(如栈大小、调度策略),同步原语(如互斥锁、读写锁等)也有自己的属性

通过配置这些属性,我们可以改变锁的行为,比如让锁支持同一个线程多次加锁(递归锁),或者让锁在出现错误操作时主动报错(检错锁),从而极大地提升程序的健壮性和灵活性。

🔒 互斥锁的属性(Mutex Attributes)

互斥锁是最常用的同步工具,它的属性控制着锁在加锁、解锁时的具体行为。

基本使用流程:

  1. 定义属性变量:pthread_mutexattr_t attr;
  2. 初始化属性:pthread_mutexattr_init(&attr);
  3. 设置具体属性(如类型、协议等)。
  4. 创建互斥锁:pthread_mutex_init(&mutex, &attr);
  5. 销毁属性:pthread_mutexattr_destroy(&attr);
核心属性:互斥锁类型(Type)

这是互斥锁属性中最重要、最常用的一个。它决定了当同一个线程尝试对已经持有的锁再次加锁时,系统会做出什么反应。

锁类型宏定义 核心特性与行为 适用场景
PTHREAD_MUTEX_NORMAL (普通锁) 默认类型。不检测错误,如果同一个线程重复加锁,会直接导致死锁 绝大多数常规互斥场景,性能最好。
PTHREAD_MUTEX_RECURSIVE (递归锁) 允许同一个线程对同一把锁成功加锁多次。内部会有一个计数器,加锁几次就必须解锁几次才能真正释放。 递归函数、或者多层嵌套函数都需要加同一把锁的场景。
PTHREAD_MUTEX_ERRORCHECK (检错锁) 会进行完整的错误检查。如果同一个线程重复加锁,会返回 EDEADLK 错误,而不是死锁。 程序开发和调试阶段,用来排查死锁隐患。
PTHREAD_MUTEX_ADAPTIVE_NP (自适应锁) Linux特有。竞争失败时先在用户态自旋等待一小段时间,自旋失败再进入内核态休眠。 临界区执行时间极短、且竞争不那么激烈的场景。

💡 代码演示:设置递归锁

复制代码
pthread_mutex_t mutex;
pthread_mutexattr_t attr;

// 1. 初始化属性对象
pthread_mutexattr_init(&attr);
// 2. 设置锁的类型为递归锁
pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE);
// 3. 使用带有属性的 attr 来初始化互斥锁
pthread_mutex_init(&mutex, &attr);
// 4. 销毁属性对象(锁已经创建好了,属性对象可以销毁了)
pthread_mutexattr_destroy(&attr);

// 此时,同一个线程可以多次 lock,不会死锁
pthread_mutex_lock(&mutex);
pthread_mutex_lock(&mutex); // 成功,内部计数+1
pthread_mutex_unlock(&mutex);
pthread_mutex_unlock(&mutex); // 计数归零,锁真正释放
进阶属性:优先级继承协议(Protocol)

在实时系统中,如果低优先级线程持有了锁,而高优先级线程在等待这把锁,会发生优先级反转 问题(高优先级线程被低优先级线程阻塞)。

通过设置 PTHREAD_PRIO_INHERIT 协议,内核会临时将低优先级线程的优先级提升至高优先级线程的级别,让它赶紧执行完并释放锁。

复制代码
pthread_mutexattr_setprotocol(&attr, PTHREAD_PRIO_INHERIT);

📖 读写锁的属性(Read-Write Lock Attributes)

读写锁(pthread_rwlock_t)也有属性对象 pthread_rwlockattr_t,但它的可配置项相对较少。

  • 基本流程 :与互斥锁类似,使用 pthread_rwlockattr_init 初始化,pthread_rwlockattr_setkind_np(Linux特有)或标准属性设置,最后传给 pthread_rwlock_init
  • 主要用途 :通常用来设置读写锁的进程共享属性PTHREAD_PROCESS_SHARED),让这把锁可以在多个进程之间共享(配合共享内存使用)。在单进程的多线程环境下,一般直接传 NULL 使用默认属性即可。

📊 同步属性核心 API 速查

同步工具 属性对象 核心设置 API 作用
互斥锁 pthread_mutexattr_t pthread_mutexattr_settype 设置普通锁、递归锁、检错锁等
互斥锁 pthread_mutexattr_t pthread_mutexattr_setprotocol 设置优先级继承,解决优先级反转
读写锁 pthread_rwlockattr_t pthread_rwlockattr_setpshared 设置锁是否能在多进程间共享

总结建议:

在日常开发中,互斥锁的类型属性 是最需要关注的。如果你发现程序在某个锁上莫名卡死(死锁),可以先尝试在调试阶段把锁改为 PTHREAD_MUTEX_ERRORCHECK(检错锁),这样系统在发现重复加锁时会直接报错,能帮你快速定位 Bug 的位置。

重入

在多线程编程中,"重入"是一个非常重要且容易与"线程安全"混淆的概念。

简单来说,重入 描述的是同一个函数 被多个执行流(线程或信号处理程序)打断后,在还没执行完时又被再次进入 的情况。而可重入函数,指的就是即使发生这种情况,也能保证运行结果完全正确的函数。

🤔 什么是重入?

重入通常发生在以下两种场景:

  1. 多线程重入:线程 A 正在执行某个函数,时间片到了被切走,线程 B 紧接着也来执行这个函数。
  2. 信号导致重入 (Linux 系统编程中更底层的重入):一个线程正在运行某个函数,突然收到一个信号(如 SIGINT),操作系统暂停当前逻辑,转而去执行信号处理函数。如果信号处理函数里恰好也调用了这个普通函数,就发生了信号重入。

✅ 什么样的函数才是"可重入"的?

一个函数要成为"可重入函数",必须严格遵守以下规则(核心思想是:所有状态必须完全本地化):

  • 只使用局部变量:所有数据都来自函数参数或定义在栈上的局部变量。
  • 不使用全局/静态变量:绝对不能依赖或修改全局变量、静态变量(static)。
  • 不调用不可重入的函数 :例如标准的 malloc/free(内部依赖全局堆管理锁)、printf(内部有全局缓冲区)等。
  • 不返回静态数据的指针:比如不能返回指向内部静态缓冲区的指针。

典型的可重入函数示例

复制代码
// 纯函数,无状态,无锁,绝对可重入
int max(int a, int b) {
    return a > b ? a : b;
}

⚠️ 重入与线程安全的关系(极易踩坑)

这是面试和实际开发中最容易搞混的地方。可重入一定是线程安全的,但线程安全不一定是可重入的!

我们可以通过以下四种类型来直观理解它们的区别:

类型 说明 可重入性 线程安全性 代码特征
双重安全 最完美的函数 纯函数,只用局部变量(如上面的 max
仅线程安全 最容易踩坑 加了锁的全局变量操作 。多线程访问安全,但同一个线程重入会因为重复拿锁导致死锁
仅可重入 较少见 使用了线程局部存储(TLS),单线程重入没事,但多线程结果不一致。
双重不安全 绝对要避免 无保护地修改全局变量。

为什么"仅线程安全"的函数不可重入?

比如你写了一个带互斥锁的全局计数器:

复制代码
pthread_mutex_t m;
int counter = 0;
int inc() {
    pthread_mutex_lock(&m); // 第一次加锁成功
    int r = ++counter;
    // 如果此时发生信号重入,再次调用 inc()
    pthread_mutex_lock(&m); // 第二次加锁!普通锁会直接死锁
    pthread_mutex_unlock(&m);
    return r;
}

这就是为什么它线程安全(多个人来抢锁没问题),但不可重入(自己抢自己的锁会死锁)。

💡 实际开发中的重入风险与规避

在 Linux C 开发中,最隐蔽的重入风险往往来自 glibc 库函数与信号处理的混合使用

典型死锁场景:

线程 A 调用 malloc 分配内存,此时 glibc 内部会自动加上堆管理的互斥锁。在 malloc 执行期间,突然触发一个信号(比如段错误 SIGSEGV),信号处理函数里你又调用了 printf 或再次 malloc 来打印日志。由于 glibc 默认的互斥锁是不可重入的普通锁,这会导致线程自己把自己锁死。

规避建议:

  1. 信号处理函数极简 化:在信号处理函数中,只设置一个 volatile 标志位,具体的业务逻辑(如打印日志、分配内存)交给主循环去处理。
  2. 使用异步信号安全的函数 :在信号处理中,只能调用 POSIX 标准规定的安全函数(如 write_exit),严禁使用 mallocprintf 等。
  3. 需要重入时使用递归锁 :如果你的业务逻辑确实需要在同一线程中反复进入加锁区域(比如递归调用),可以将互斥锁的属性设置为 PTHREAD_MUTEX_RECURSIVE(递归锁),这样同一个线程多次加锁就不会死锁了。

线程私有数据

在多线程编程中,我们通常使用互斥锁来保护共享的全局变量。但在某些特定场景下,你根本不希望数据被共享 ,而是希望每个线程都拥有一份该变量的独立副本,互不干扰。

这种机制就叫做线程私有数据 ,专业术语叫线程局部存储(Thread-Local Storage, TLS)

🤔 为什么要用线程私有数据?

最经典的例子就是 Linux C 标准库中的全局变量 errno

当多线程程序同时调用系统函数失败时,如果 errno 是普通的全局变量,线程 A 刚把 errno 设为 EACCES,线程 B 紧接着把它改成了 ENOENT,线程 A 再去读 errno 时就会读到错误的信息。

通过 TLS,每个线程都会拥有自己独立的 errno 副本,线程 A 的修改绝对不会影响到线程 B,从而完美避开了复杂的加锁操作。

在 Linux 下,实现线程私有数据主要有两种方式:

⚡ 方式一:使用 __thread 关键字(最推荐,简单高效)

这是 GCC 编译器提供的一个扩展关键字(C11 标准中对应的关键字是 _Thread_local),也是日常开发中最常用、性能最高的方式。

你只需要在声明全局变量或静态变量时,加上 __thread 前缀即可。

核心特点:

  • 生命周期:和程序一样长,但每个线程都有自己独立的一份。
  • 适用类型 :只能用于基础数据类型(如 int, char, 指针等),不能用于需要复杂构造函数和析构函数的 C++ 类对象。
  • 性能:访问速度极快,接近普通全局变量。

代码演示:

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

// 加上 __thread 后,每个线程都会拥有自己独立的 count 副本
__thread int count = 0; 

void* thread_func(void* arg) {
    int id = *(int*)arg;
    // 每个线程修改自己的 count,互不影响
    for (int i = 0; i < 3; i++) {
        count++;
        printf("线程 %d: count = %d (地址: %p)\n", id, count, (void*)&count);
        sleep(1);
    }
    return NULL;
}

int main() {
    pthread_t t1, t2;
    int id1 = 1, id2 = 2;
    
    pthread_create(&t1, NULL, thread_func, &id1);
    pthread_create(&t2, NULL, thread_func, &id2);
    
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    return 0;
}

运行结果你会发现,两个线程打印出的 count 都是从 1 增加到 3,且它们的内存地址(&count)是完全不同的。

🛠️ 方式二:使用 Pthreads API(pthread_key_t

__thread 出现之前,或者当你需要在线程退出时自动释放动态分配的内存(如 malloc 出来的结构体)时,就需要用到这套 POSIX 标准 API。

它的工作原理是:创建一个所有线程共享的"钥匙"(Key),每个线程用这把钥匙去存取自己专属的"柜子"(Value)。

核心 API 速查:

  • pthread_key_create(&key, destructor):创建一把全局共享的钥匙。destructor 是一个析构函数,当线程退出时,系统会自动调用它来释放该线程专属的数据(防止内存泄漏)。
  • pthread_setspecific(key, value):当前线程往自己的"柜子"里存数据。
  • pthread_getspecific(key):当前线程从自己的"柜子"里取数据。
  • pthread_key_delete(key):销毁这把钥匙。

代码演示:

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

pthread_key_t tls_key; // 定义全局共享的钥匙

// 析构函数:线程退出时自动被调用,用于释放动态分配的内存
void destructor(void* data) {
    printf("线程 %lu 退出,正在清理私有数据: %p\n", pthread_self(), data);
    free(data);
}

void* thread_func(void* arg) {
    // 1. 动态分配一块内存作为该线程的私有数据
    int* private_data = malloc(sizeof(int));
    *private_data = (int)(long)arg; // 存入线程ID
    
    // 2. 将私有数据绑定到全局钥匙上
    pthread_setspecific(tls_key, private_data);
    
    // 3. 获取并使用私有数据
    int* my_data = (int*)pthread_getspecific(tls_key);
    printf("线程 %lu 读取到自己的私有数据: %d\n", pthread_self(), *my_data);
    
    return NULL;
}

int main() {
    pthread_t t1, t2;
    
    // 创建钥匙,并注册析构函数
    pthread_key_create(&tls_key, destructor);
    
    pthread_create(&t1, NULL, thread_func, (void*)100);
    pthread_create(&t2, NULL, thread_func, (void*)200);
    
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    
    // 销毁钥匙
    pthread_key_delete(tls_key);
    return 0;
}

📊 两种方式对比总结

实现方式 核心特点 优缺点 适用场景
__thread 编译器扩展,语法简单 高效,静态分配;但不支持复杂析构 绝大多数基础类型(int, double, 指针)的线程私有化
pthread_key_t POSIX 标准 API 灵活,支持动态内存自动回收;但 API 稍繁琐 需要在线程退出时自动释放 malloc 内存的复杂场景

总结建议:

在日常 Linux C 开发中,优先使用 __thread ,它写起来最省事,性能也最好。只有当你需要为每个线程动态分配一大块内存(比如每个线程都有自己的大缓冲区),并且希望线程结束时能自动 free 掉这块内存时,才去使用 pthread_key_t

线程和信号

在 Linux 多线程编程中,信号(Signal)绝对是一个容易踩坑的"重灾区"。因为传统的信号机制是基于单进程模型设计的,当它遇到多线程环境时,如果处理不当,极易导致程序莫名其妙地崩溃或死锁。

为了理清多线程与信号的爱恨情仇,我们可以从以下几个核心维度来拆解:

🎯 信号的分类:异步信号 vs 同步信号

要正确处理信号,首先要明白信号是怎么来的,这决定了它的投递目标:

  • 异步信号(来自外部) :例如 Ctrl+C 产生的 SIGINTkill pid 产生的 SIGTERM、定时器产生的 SIGALRM 等。这类信号与当前线程执行到哪行代码无关,是面向整个进程的。
  • 同步信号(来自内部硬件异常) :例如非法内存访问产生的 SIGSEGV(段错误)、除以零产生的 SIGFPE。这类信号是由当前正在执行的指令触发的,因此必须精准地发给当前出错的线程,不能发给别人。

📡 信号该由哪个线程来处理?

当你向一个进程(kill pid)发送异步信号时,内核到底会把它交给哪个线程?POSIX 标准的规定非常"任性":

  • 随机投递 :内核会将异步信号投递给任意一个没有阻塞(屏蔽)该信号的线程。你无法预知是哪个线程会收到,这导致如果直接在信号处理函数里做业务逻辑,会极大地破坏程序的确定性。
  • 信号处理函数的共享性 :通过 signal()sigaction() 注册的信号处理函数是整个进程共享的。任何一个线程修改了某个信号的处理方式,所有线程都会受到影响。
  • 信号屏蔽字(Signal Mask)的独立性 :每个线程都有自己独立的信号屏蔽字(通过 pthread_sigmask 设置)。线程可以通过屏蔽某些信号,来主动拒绝接收它们。

💣 多线程信号处理的常见风险

  1. 业务逻辑被随机打断:如果工作线程(Worker Thread)正在修改一个复杂的数据结构,突然被一个异步信号打断去执行信号处理函数,很容易导致数据不一致或死锁。
  2. SIGPIPE 导致进程意外退出 :在网络编程中,如果向一个已经断开的 socket 写入数据,内核默认会发送 SIGPIPE 信号,其默认动作是直接终止整个进程。这在多线程服务器中是致命的。
  3. 库函数非异步信号安全 :在信号处理函数中,你只能调用极少数的"异步信号安全"函数(如 write)。绝对不能调用 mallocprintfpthread_mutex_lock 等常规库函数,否则极易引发死锁或内存破坏。

🛡️ 工程最佳实践:统一信号管理模型

为了避免异步信号随机打断工作线程,现代 Linux 多线程服务器通常采用**"同步等待 + 专用线程"**的模型,将异步的信号转化为同步的事件来处理。

标准操作步骤如下:

  1. 主线程提前屏蔽信号 :在创建任何子线程之前,主线程先调用 pthread_sigmask 屏蔽掉需要统一管理的异步信号(如 SIGINT, SIGTERM, SIGPIPE 等)。
  2. 子线程自动继承:之后创建的所有工作线程(Worker Thread)都会自动继承主线程的信号屏蔽字。这样,工作线程就永远不会被这些异步信号随机打断,可以安心处理业务。
  3. 创建专用信号线程 :单独创建一个线程(Signal Thread),它不处理任何业务,专门负责调用 sigwait()signalfd同步等待这些被屏蔽的信号。
  4. 优雅退出或处理:当专用线程捕获到信号后,再通过修改全局标志位、管道(pipe)或条件变量等方式,通知主线程或其他工作线程进行优雅的资源清理和退出。

代码骨架演示:

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

// 全局退出标志
volatile sig_atomic_t g_stop = 0;

// 专用信号处理线程
void* signal_thread_func(void* arg) {
    sigset_t *set = (sigset_t*)arg;
    int sig;
    while (1) {
        // 同步等待信号的到来(这里不会被打断,是阻塞的)
        sigwait(set, &sig);
        if (sig == SIGINT || sig == SIGTERM) {
            printf("\n专用线程捕获到退出信号 (SIG=%d),通知程序退出...\n", sig);
            g_stop = 1;
            break;
        }
    }
    return NULL;
}

// 普通工作线程
void* worker_thread_func(void* arg) {
    int id = *(int*)arg;
    while (!g_stop) {
        printf("工作线程 %d 正在处理业务...\n", id);
        sleep(1);
    }
    printf("工作线程 %d 收到退出通知,正在清理资源并退出。\n", id);
    return NULL;
}

int main() {
    pthread_t sig_tid, work_tid;
    int work_id = 1;
    sigset_t set;

    // 1. 初始化信号集,并添加需要屏蔽的信号
    sigemptyset(&set);
    sigaddset(&set, SIGINT);
    sigaddset(&set, SIGTERM);

    // 2. 【关键】主线程提前屏蔽这些信号,后续子线程会自动继承
    pthread_sigmask(SIG_BLOCK, &set, NULL);

    // 3. 创建专用信号线程,让它去等待这些被屏蔽的信号
    pthread_create(&sig_tid, NULL, signal_thread_func, &set);
    
    // 4. 创建工作线程,它们永远不会被 SIGINT/SIGTERM 随机打断
    pthread_create(&work_tid, NULL, worker_thread_func, &work_id);

    // 5. 等待线程结束
    pthread_join(work_tid, NULL);
    pthread_join(sig_tid, NULL);

    printf("程序已优雅退出。\n");
    return 0;
}

总结:

在多线程编程中,千万不要让工作线程去异步处理信号 。通过 pthread_sigmask 屏蔽 + 专用线程 sigwait 同步等待的模式,可以完美规避多线程信号带来的随机性和不安全性,这也是编写高可靠 Linux 服务端程序的标配做法。

线程和fork

在 Linux 多线程编程中,fork() 绝对是一个极其凶险的"雷区"。很多看似正常的程序,一旦在多线程环境下调用 fork(),就会莫名其妙地出现死锁或崩溃。

这背后的核心原因,可以总结为一句话:fork() 在多线程环境下的行为极其"片面"------它只复制当前调用 fork() 的那个线程,而父进程中其他的线程在子进程中会瞬间"蒸发"。

💣 为什么多线程中调用 fork 极其危险?

当父进程有多个线程并发运行时,调用 fork() 会产生一系列严重的"后遗症":

  1. 其他线程凭空消失

    子进程中只有调用 fork() 的那个线程的副本,父进程中其他的线程(比如负责后台计算的线程、负责信号处理的线程)在子进程中统统不存在。如果程序逻辑依赖这些线程,子进程的运行状态将完全不可控。

  2. 极易引发死锁(最致命的隐患)

    这是最容易踩的坑。假设父进程中,线程 A 正在持有一个互斥锁(比如你之前写的 g_split_port_mutex),而恰好是线程 B 调用了 fork()

  • 子进程会完整复制父进程的内存空间,包括这把锁的当前状态(已锁定)
  • 但是,持有这把锁的线程 A 并没有被复制到子进程中。
  • 结果就是:子进程拿到了一把**"永远没人能解开"的死锁**。当子进程后续尝试去获取这把锁时,程序就会直接永久卡死。
  1. 只能调用"异步信号安全"的函数
    由于子进程的状态极度不完整,POSIX 标准严格规定:在 fork() 之后,子进程只能调用极少数的异步信号安全(async-signal-safe) 函数(如 _exit, write 等)。绝对不能调用 malloc/free(内部有堆锁)、printf(内部有缓冲区锁)或任何 pthread_xxx 系列函数,否则极易引发崩溃或死锁。

🛡️ 如何安全地在多线程中使用 fork?

针对不同的业务需求,有以下三种标准的避坑方案:

方案一:"fork + 立即 exec"黄金法则(最推荐)

如果你调用 fork() 的目的只是为了启动一个全新的外部程序,那么请严格遵守在子进程中 fork() 之后,立刻、马上调用 exec() 系列函数
exec() 会用全新的程序替换掉当前子进程的内存空间,所有之前复制过来的残缺线程、被锁住的互斥锁都会被彻底清空重置。这是最安全、最没有副作用的做法。

方案二:使用 pthread_atfork 钩子(治标不治本)

如果你必须在子进程中继续执行原来的代码,POSIX 提供了一个补救机制 pthread_atfork()。它可以注册三个钩子函数:

  • prepare:在 fork() 创建子进程之前调用(通常在这里把父进程的所有锁都锁上)。
  • parent:在 fork() 返回父进程之前调用(在这里把父进程的锁解开)。
  • child:在 fork() 返回子进程之前调用(在这里把子进程的锁解开,保证子进程拿到的是干净的未锁定状态)。

但它的缺陷非常明显 :你只能清理你自己代码里知道的锁。如果程序链接了第三方库(比如 C 运行库、SSL 库等),它们内部隐藏的锁你根本无法通过 pthread_atfork 去触及,死锁隐患依然存在。

方案三:架构规避(最稳妥)

在设计高可靠性的多线程服务器时,最稳妥的工程实践是:将多线程和 fork() 在架构上完全隔离开

  • 如果程序需要创建子进程,请在创建任何工作线程之前 (即主线程还是单线程的时候)就完成 fork()
  • 或者采用专门的"单线程进程管理器"来负责 fork/exec 外部程序,而让多线程的工作进程专注于业务逻辑,绝不触碰 fork()

总结建议:

除非有极其特殊的理由,否则绝对不要在多线程的业务逻辑中直接调用 fork()。如果必须创建新进程,请优先采用"fork 后立即 exec"的模式,或者在程序初始化的单线程阶段就完成进程的创建。

线程和IO

在多线程编程中,preadpwrite 绝对是处理文件 I/O 的"神兵利器"。它们完美解决了多线程并发读写同一个文件时,最让人头疼的文件指针(偏移量)冲突问题。

🤔 为什么多线程需要 pread 和 pwrite?

在传统的文件读写中,我们通常使用 read()write()。这两个函数有一个隐式的动作:每次读写后,都会自动移动文件描述符(fd)内部的当前文件指针

这在单线程下完全没问题,但在多线程环境下就会引发巨大的灾难。举个经典的例子:

  1. 线程 A 调用 lseek(fd, 100, SEEK_SET),想把指针移到第 100 字节处准备读取。
  2. 就在 A 刚执行完 lseek,还没来得及执行 read 的瞬间,操作系统把时间片切给了线程 B
  3. 线程 B 也操作同一个 fd,它调用了 read(fd, ...)。因为此时指针还在开头,B 读走了文件头的数据,同时文件指针被 B 移动到了其他位置
  4. 时间片回到线程 A ,A 继续执行 read(fd, ...)。此时它读到的根本不是第 100 字节的数据,而是 B 移动指针后的位置,导致数据完全错乱。

这就是典型的竞态条件(Race Condition) 。即使你把 lseekread 包在同一个互斥锁里,虽然能解决冲突,但会强制让所有线程串行执行,严重拖垮并发性能。

🛠️ pread 和 pwrite 的核心作用

preadpwrite 的出现就是为了解决上述痛点。它们的函数原型如下:

复制代码
#include <unistd.h>
// 从指定偏移量 offset 处读取 count 个字节
ssize_t pread(int fd, void *buf, size_t count, off_t offset);
// 向指定偏移量 offset 处写入 count 个字节
ssize_t pwrite(int fd, const void *buf, size_t count, off_t offset);

它们相比普通的 read/write,拥有两大核心特性:

  1. 原子性(Atomicity)pread(fd, buf, count, offset) 等价于将 lseekread 两个步骤合并成了一个不可分割的原子操作。在执行过程中,绝对不会被其他线程的 I/O 操作打断。
  2. 不改变文件指针 :无论读取或写入多少数据,它们绝对不会修改文件描述符 fd 内部维护的当前文件偏移量。

📊 传统方式与 pread/pwrite 对比

表格

核心特性 传统方式 (lseek + read/write) 进阶方式 (pread / pwrite)
操作原子性 非原子(定位和读写是分开的两步) 原子操作(定位和读写合并为一步)
文件指针变化 会改变 fd 的当前文件偏移量 绝不改变 fd 的当前文件偏移量
多线程安全性 需加锁保护,否则引发竞态条件 天然线程安全,无需额外加锁
并发性能 加锁后导致线程串行,性能受限 多个线程可并行读写文件不同位置,性能极高

💡 实战中的核心价值

在实际的高性能开发(比如数据库引擎、高并发文件服务器)中,preadpwrite 的价值主要体现在:

  • 多线程并行随机读写 :多个线程可以同时操作同一个文件的不同区域。比如线程 A 读文件头,线程 B 读文件尾,它们互不干扰,不需要加互斥锁,极大地提升了 I/O 吞吐量。
  • 简化代码逻辑 :你不再需要小心翼翼地维护文件指针,也不需要为文件读写专门设计一把全局大锁。直接指定 offset 就能精准读写,代码更加健壮。

避坑提示
preadpwrite 只能用于支持"寻址"(seek)操作的文件描述符(比如普通的磁盘文件)。它们不能用于管道(Pipe)、FIFO 或者网络 Socket,因为这些设备本身就没有"文件偏移量"的概念。

相关推荐
_OP_CHEN3 个月前
【Linux系统编程】(四十)线程控制终极指南:从资源共享到实战操控,带你吃透线程全生命周期
linux·运维·操作系统·线程·进程·c/c++·线程控制
Trouvaille ~4 个月前
【Linux】Linux线程概念与控制(四):glibc源码剖析与实现原理
linux·运维·服务器·c++·操作系统·glibc·线程控制
NiKo_W7 个月前
Linux 线程控制
linux·数据结构·内核·线程·进程·线程控制
Ronin3059 个月前
【Linux系统】线程控制
linux·线程·线程控制
自信不孤单1 年前
Linux多线程
linux·服务器·c++·进程·多线程·线程控制·线程库
lisanndesu1 年前
线程-7-信号量
linux·线程控制·信号量
w_outlier2 年前
线程(二)【线程控制】
linux·c++·多线程·线程控制
说淑人2 年前
Redis & 线程控制 & 问题
redis·线程控制
小堃学编程2 年前
Linux系统编程——线程控制
linux·开发语言·c/c++·线程控制·线程库·原生线程库·地址空间