信号、原子操作与标准线程
本节目标
查询 C23 信号、原子操作、内存顺序与标准线程接口。
C 的信号与线程都可能打断“顺序执行”的直觉,但它们不是同一种机制。信号处理函数受异步执行边界约束;线程共享对象则需要原子操作或锁建立同步。本章先区分这两类问题,再按任务选择 ISO C23 接口,并把标准保证、条件能力与目标工具链实际支持分开。
信号模型、注册与触发
<signal.h> 定义标准信号宏、sig_atomic_t、signal 和 raise。signal(signal_number, disposition) 可以把处置设为 SIG_DFL、SIG_IGN 或兼容的处理函数;signal 返回先前的处置值,失败时返回 SIG_ERR。这个返回值只能说明注册调用的结果,不能扩展为某个平台对信号编号、排队、终端按键或多线程投递对象的保证。
N3096 明确规定,在多线程程序中使用 signal 的行为未定义。因此下面的注册与受控触发只说明单线程代表程序中的接口合同,不能作为多线程信号配置方案;目标平台若另有信号线程规则,也不能倒推成 ISO C 保证。
raise(signal_number) 让该信号在当前程序中发生,raise 成功时返回零,失败时返回非零。若已为这个信号安装处理函数,受控的 raise 会触发相应处置;处理函数正常返回后,raise 才返回。由外部环境产生的信号何时到达、同种信号是否排队,以及处理期间的处置变化,都不能仅凭一次本机运行推广为 ISO C 保证。
void (*previous)(int) = signal(SIGINT, handle_interrupt);
if (previous == SIG_ERR) {
/* 注册失败 */
}
if (raise(SIGINT) != 0) {
/* 触发失败 */
}
对于 SIGFPE、SIGILL 等与程序错误相关的信号,处理函数返回后的程序状态还受各自规则限制,不能把“处理后继续执行”写成通用恢复方案。需要异常终止时可对照异常终止与信号。
信号处理函数与可访问状态
可移植处理函数应保持最小:记录一个稍后由正常执行路径检查的标志,然后立即返回。对异步到达的信号,标准只为受限对象访问和少数接口给出保证;普通日志、分配、流 I/O 和锁操作不能假定为处理函数内安全的操作。即使某个平台额外提供“异步信号安全”函数集合,也不应倒推为 ISO C 的最低保证。
static volatile sig_atomic_t interrupt_requested = 0;
static void request_stop(int signal_number) {
if (signal_number == SIGINT) {
interrupt_requested = 1;
}
}
sig_atomic_t 是实现提供的整数类型。对于异步到达的信号,针对这类非原子静态或线程存储期对象的特殊许可,只允许处理函数给 volatile sig_atomic_t 赋值,处理函数不能先读取该对象的当前值;正常执行路径可以随后读取这个标志。这里的 volatile 让访问保持为可观察访问,但 volatile 不提供线程间原子性、顺序或同步。多个线程共享的计数、状态机或指针必须根据并发合同选用原子对象或锁,不能把这个标志模式直接复制过去。volatile 的一般对象语义见易变访问。
原子能力、对象与初始化
<stdatomic.h> 提供 _Atomic 类型说明符、原子类型别名、操作宏与内存顺序。目标实现若定义 __STDC_NO_ATOMICS__,表示不提供这组原子能力;即使宏未定义,也应以项目的真实编译选项包含头文件并编译最小探针,避免把另一套工具链的结果当成本目标的能力。
_Atomic(int) pending_count;
atomic_int published_count;
atomic_init(&pending_count, 0);
atomic_init(&published_count, 0);
类型、初始化与原子对象的生命周期要分别处理。对象必须具有合适的原子类型;在任何并发访问前完成初始化;对象仍可能被其他执行线程访问时,不能结束其存储期。atomic_init 用于为已经存在的原子对象设置初始值,它本身应处于独占初始化阶段,不能和其他访问并发。静态或自动存储期只决定对象存在多久,不自动建立线程间发布关系。
原子加载、存储与读改写
atomic_load 读取原子对象,atomic_store 写入原子对象,atomic_exchange 交换值;atomic_fetch_add、atomic_fetch_sub 以及按位读改写操作把“读旧值并写新值”作为一个原子操作完成。例如 atomic_fetch_add_explicit 还要求调用方给出合法内存顺序。保证只作用于被操作的原子对象,不会自动修复邻近普通对象上的数据竞争。
atomic_store_explicit(&published_count, 0, memory_order_relaxed);
int old_value = atomic_fetch_add_explicit(
&published_count,
1,
memory_order_relaxed
);
int current_value = atomic_load_explicit(
&published_count,
memory_order_relaxed
);
这里选择 memory_order_relaxed,因为这个示例只依赖计数器自身的原子性和修改顺序,不用它发布其他非原子状态。old_value 是加一之前的值;若需要加一后的值,调用方还要在可表示范围内计算或重新装载。
下面的代表程序使用 raise 在受控位置触发处理函数,再在主执行线程中固定执行 2000 次原子加一。代表程序只验证受控信号与原子计数,不验证线程调度、汇合或等待。
#include <signal.h>
#include <stdatomic.h>
#include <stdio.h>
#include <stdlib.h>
#if defined(__STDC_NO_ATOMICS__)
#error "C atomics unavailable"
#endif
enum { INCREMENT_COUNT = 2000 };
static volatile sig_atomic_t signal_seen = 0;
static atomic_int counter;
static void handle_interrupt(int signal_number) {
if (signal_number == SIGINT) {
signal_seen = 1;
}
}
int main(void) {
if (signal(SIGINT, handle_interrupt) == SIG_ERR || raise(SIGINT) != 0) {
fputs("signal-setup-error\n", stderr);
return EXIT_FAILURE;
}
if (signal_seen != 1) {
fputs("signal-state-error\n", stderr);
return EXIT_FAILURE;
}
atomic_init(&counter, 0);
for (int index = 0; index < INCREMENT_COUNT; ++index) {
atomic_fetch_add_explicit(&counter, 1, memory_order_relaxed);
}
int final_counter = atomic_load_explicit(&counter, memory_order_relaxed);
if (final_counter != INCREMENT_COUNT) {
fputs("counter-error\n", stderr);
return EXIT_FAILURE;
}
puts("signal=handled");
printf("atomic-counter=%d\n", final_counter);
return EXIT_SUCCESS;
}
输出:
signal=handled
atomic-counter=2000
比较交换、atomic_flag 与无锁查询
比较交换把“当前值是否等于预期值”与可能的写入组合成一个原子操作。以 atomic_compare_exchange_weak_explicit 为例:相等时把对象改为 desired 并按成功内存顺序执行;不相等时不写 desired,而是按失败内存顺序装载对象的实际值,expected 会被当前值更新。弱形式还允许在值相等时虚假失败,因此通常放在循环里;强形式适合不希望处理虚假失败的单次尝试。
int expected = atomic_load_explicit(&counter, memory_order_relaxed);
while (expected < 1000 && !atomic_compare_exchange_weak_explicit(
&counter,
&expected,
expected + 1,
memory_order_acq_rel,
memory_order_acquire
)) {
/* 失败后 expected 已更新,重新检查业务谓词与可表示范围 */
}
成功内存顺序描述成功读改写的排序,失败内存顺序只描述失败装载;失败顺序不能是 memory_order_release 或 memory_order_acq_rel,也不能比成功顺序要求更强。循环还必须处理业务条件和整数范围,不能因为操作原子化就忽略值域。
atomic_flag 是保证可用于无锁原子操作的专用标志类型,通过 ATOMIC_FLAG_INIT、atomic_flag_test_and_set 和 atomic_flag_clear 表达测试并置位与清除。它可以构成低层自旋协议,但不会自动提供公平性、阻塞等待或死锁诊断。atomic_is_lock_free 查询指针所指类型的原子操作是否无锁;同一次程序执行中,同一指针类型的结果必须一致。这个结果不等于 wait-free,也不保证更快,更不能推出所有原子类型都由单条硬件指令完成。
内存顺序与线程、信号栅栏
未显式给出顺序的原子接口使用 memory_order_seq_cst。显式接口还可按合同选择 memory_order_relaxed、memory_order_acquire、memory_order_release、memory_order_acq_rel 等。relaxed 仍保证对应原子操作不可拆分及其修改顺序,但不发布其他非原子状态;发布数据通常需要同一原子对象上的 release 操作与读取到相应值的 acquire 操作配对,再据此分析 happens-before。
payload = computed_value;
atomic_store_explicit(&ready, true, memory_order_release);
if (atomic_load_explicit(&ready, memory_order_acquire)) {
consume(payload);
}
这段模式只有在 payload 不存在其他无同步冲突访问、acquire 确实读取到 release 序列所发布的值,并且对象生命周期覆盖全部访问时才成立。不能把“用了原子变量”缩写成“所有共享数据都安全”。
atomic_thread_fence 参与执行线程之间的内存顺序关系,必须和相关原子操作及其读取关系一起分析;单独放置栅栏不会凭空消除数据竞争。atomic_signal_fence 用于同一执行线程与其信号处理函数之间的顺序约束,它不等价于硬件线程栅栏,也不把普通对象变成线程安全共享状态。两种栅栏解决的边界不同,不能按名称互换。
标准线程能力、创建与汇合
ISO C23 的 <threads.h> 是条件能力。实现可能定义 __STDC_NO_THREADS__;此外还必须用目标编译器、C 库与相同选项实际包含头文件并链接最小程序。若探针缺少头文件或接口,结论应限定为“该目标配置不提供标准线程能力”,不能改用 pthread、dispatch 或平台线程后继续声称验证了 ISO C 接口。
thrd_create 成功时返回 thrd_success,并让新线程调用 int (*)(void *) 形式的入口。线程入口返回的整数可由 thrd_join 写入结果对象;thrd_join 自身的返回码仍要单独检查。成功创建的线程必须汇合或分离:每个可汇合线程应恰好汇合一次,或由 thrd_detach 转为分离状态;创建失败的句柄、已汇合句柄和已分离句柄不能再次按成功创建路径处理。
非执行教学片段:
#include <stdlib.h>
#include <threads.h>
static int worker(void *argument) {
(void)argument;
return 0;
}
static int create_and_join(void) {
thrd_t thread;
if (thrd_create(&thread, worker, NULL) != thrd_success) {
return 0;
}
int thread_result = 1;
if (thrd_join(thread, &thread_result) != thrd_success) {
abort();
}
return thread_result == 0;
}
这段代码只说明创建、入口返回值和汇合合同,没有在本章代表程序中执行,也不声明固定输出。它不把自动存储期对象借给新线程;一旦 thrd_join 报告失败,线程是否终止没有得到确认,因此用 abort 走不可恢复路径,汇合失败后不能按线程已终止继续执行。若选择 thrd_detach,之后就不能再 thrd_join,还必须用其他已验证的生命周期协议保证分离线程不访问已经结束存储期的对象;不能在汇合失败后临时 detach 并假定这就修复了资源状态。
一次初始化与线程专用存储
C23 还在 <stdlib.h> 中声明 once_flag、ONCE_FLAG_INIT 和 call_once;这组一次初始化名称不以条件头 <threads.h> 的存在为前提。tss_*、thrd_*、mtx_* 和 cnd_* 仍属于条件线程能力。下面的组合片段使用了 tss_create,所以在当前缺少 <threads.h> 的工具链上只作非执行教学,不声称本地运行验证;这不削弱对标准线程合同的说明。
once_flag 以 ONCE_FLAG_INIT 初始化;多个线程用同一标志调用 call_once 时,初始化函数按接口合同执行一次,其他调用在它完成后继续。初始化函数没有返回值通道,因此需要把失败表达、资源所有权和后续可见状态纳入整体设计,不能在失败后假定标志会自动恢复成“未调用”。
tss_create 建立线程专用存储键,可选线程专用存储析构函数;每个线程通过 tss_set 和 tss_get 管理自己的值。线程入口返回或调用 thrd_exit 时,非空值的线程专用存储析构函数可能按不超过 TSS_DTOR_ITERATIONS 的轮次再次调用;析构若重新设置非空值,不能假定无限重试。tss_delete 删除键时不会替现存值调用析构,因此删除前必须另行安排资源回收。语言关键字 thread_local 提供线程存储期对象,与动态 TSS 键是两种不同机制。
非执行教学片段:
#include <stdlib.h>
#include <threads.h>
static once_flag library_once = ONCE_FLAG_INIT;
static tss_t error_key;
static void destroy_thread_error(void *pointer) {
free(pointer);
}
static void initialize_library(void) {
if (tss_create(&error_key, destroy_thread_error) != thrd_success) {
/* 通过独立的共享状态记录初始化失败 */
}
}
static void prepare_thread_state(void) {
call_once(&library_once, initialize_library);
}
这段代码只展示接口形状;真实设计还要保证初始化失败状态本身同步可见,并在所有线程结束使用后才删除键。程序通过从初始 main 返回、exit、quick_exit、_Exit 或异常终止结束时,不应假定每个线程都会先经过正常线程终止路径并运行 TSS 析构函数。
互斥量、锁定与释放
mtx_init 按类型创建互斥量;合法的 type 只有 mtx_plain、mtx_timed、mtx_plain | mtx_recursive 与 mtx_timed | mtx_recursive。mtx_recursive 不是可单独传入的类型,而是让普通或定时互斥量支持同一线程按对应次数递归锁定的标志。非递归互斥量不能由同一线程重复锁定,成功锁定后必须由持有它的线程释放,并保证每条错误路径保持锁状态一致。
非执行教学片段:
#include <threads.h>
static int update_shared_state(mtx_t *mutex, int *value) {
if (mtx_lock(mutex) != thrd_success) {
return 0;
}
*value += 1;
int unlock_result = mtx_unlock(mutex);
return unlock_result == thrd_success;
}
在进入工作线程前用 mtx_init 完成初始化;需要非阻塞尝试时使用 mtx_trylock,需要超时时只有相应类型的互斥量才可用 mtx_timedlock。mtx_timedlock 接收的是基于 TIME_UTC 的日历时间绝对值,不是任选时间基准的持续时间。所有使用结束且没有线程持有或等待后,才能调用 mtx_destroy。互斥量保护的是一组共享不变量,不只是某一行赋值;把相关字段放在不同锁下会破坏原本的整体合同。
条件变量、等待循环与超时
条件变量把“状态可能改变”的通知与受互斥量保护的谓词配合使用。cnd_wait 在等待时释放互斥量,并在返回前重新取得它;返回只表示应重新检查状态,不表示业务谓词一定为真。由于虚假唤醒、其他线程先消费状态或通知与调度交错,条件变量等待必须处于谓词循环中。
非执行教学片段:
#include <threads.h>
static int wait_until_ready(cnd_t *condition, mtx_t *mutex, int *ready) {
if (mtx_lock(mutex) != thrd_success) {
return 0;
}
while (!*ready) {
if (cnd_wait(condition, mutex) != thrd_success) {
(void)mtx_unlock(mutex);
return 0;
}
}
return mtx_unlock(mutex) == thrd_success;
}
在工作线程启动前用 cnd_init 初始化条件变量。改变谓词的线程应持有同一互斥量更新状态,再按协议调用 cnd_signal 或 cnd_broadcast。cnd_timedwait 接收的同样是基于 TIME_UTC 的日历时间绝对值;它可能因到达该时间点返回 thrd_timedout,但返回前同样重新取得互斥量,调用方仍要区分超时、其他错误与谓词是否已成立。仍有线程等待时不得销毁条件变量或调用 cnd_destroy;互斥量、谓词对象和条件变量的生命周期必须覆盖全部等待者。
数据竞争、死锁与生命周期陷阱
若两个并发执行对同一内存位置进行冲突访问、至少一个是修改,并且既非适当原子访问也没有建立 happens-before,程序存在数据竞争,行为未定义。给其中一个访问加 volatile 不会改变这一点;把计数器改成原子也不会自动保护旁边的普通结构体字段。
| 症状 | 常见根因 | 修正方向 |
|---|---|---|
| 偶发读取到不一致字段 | 只把“就绪”标志设为 relaxed,普通数据没有发布关系 | 使用合法的 release/acquire 协议或同一互斥量保护不变量 |
| 比较交换循环覆盖新值 | 忽略失败时 expected 被更新 | 每次失败后根据新值重新计算谓词和目标值 |
| 线程资源泄漏 | 成功创建后遗漏汇合或分离 | 记录已创建句柄,并让每个句柄恰好进入一种终态 |
| 两把锁永久互等 | 不同路径采用相反锁顺序 | 规定全局锁顺序,并让错误路径按逆序释放 |
| 条件通知后仍越过空队列 | 用 if 代替谓词循环 | 在持锁状态下用 while 重新检查共享谓词 |
| 销毁后偶发崩溃 | 分离线程、等待者或析构仍访问对象 | 先证明所有用户结束,再销毁锁、条件变量、键和共享存储 |
避免死锁不仅是“记得解锁”:还要审计重复锁定、锁顺序、持锁期间的回调、等待条件是否可能成立,以及线程取消式错误路径。资源生命周期应从创建成功点追踪到唯一清理点;只有实际成功初始化的资源才能销毁。
信号、原子与线程接口地图
| 头文件 | 主要接口组 | 适用任务与关键检查 |
|---|---|---|
<signal.h> | signal、raise、sig_atomic_t、SIG_DFL、SIG_IGN、SIG_ERR | 注册并触发标准信号;处理函数只做允许的最小状态修改,并检查注册与触发返回值 |
<stdatomic.h> | _Atomic、atomic_init、atomic_load、atomic_store、atomic_fetch_add、atomic_compare_exchange_weak、atomic_flag、atomic_is_lock_free、atomic_thread_fence、atomic_signal_fence | 声明原子对象,选择读写、读改写、比较交换和内存顺序;分别检查能力、对象生命周期与同步关系 |
<stdlib.h> | once_flag、ONCE_FLAG_INIT、call_once | C23 的一次初始化声明;不把这组名称的可用性误当成完整 <threads.h> 能力 |
<threads.h> | thrd_create、thrd_join、thrd_detach、call_once、tss_create、mtx_init、mtx_lock、mtx_unlock、cnd_wait、cnd_timedwait | 在目标实现提供条件能力时管理线程、一次初始化、线程专用状态、互斥与条件等待;逐项检查返回码和资源终态 |
反查时先确定边界:异步信号只留下最小标志,单个共享标量可考虑原子对象,复合不变量通常需要互斥量,等待状态变化则用“互斥量+谓词循环+条件变量”。涉及未定义行为、对象生存期与同步关系时,对照行为分类和动态资源清理路径。标准线程接口只有在目标配置通过能力探针后才能执行;未通过时保留条件能力说明,而不是伪造平台回退结果。