如何安全使用 C11 的线程特定存储(TSS)?
正常使用 C11 线程时,线程调度不会让线程在任意两条指令之间退出并执行 TSS 析构。引入异步取消等额外终止机制后,才需要专门处理你担心的执行窗口。不过,原代码确实没有检查内存分配和 TSS 操作是否成功,需要修复。
线程会不会在这些步骤之间突然退出?
C11 的 <threads.h> 没有提供强制取消其他线程的标准接口。工作线程通常通过从线程入口函数返回,或主动调用 thrd_exit 退出。线程因调度暂停时只是暂时停止执行,恢复后会从暂停处继续。
按照包含 DR 416 修正的线程退出语义,线程入口函数返回等效于调用 thrd_exit,退出时会处理具有非空值和析构函数的 TSS。原始 C11 对这部分规定存在缺漏,相关修正已列入 C17。参见 WG14 DR 416。
因此,在没有异步终止、信号处理程序异常跳出等额外机制的前提下,示例不会执行完 malloc 就自行跳到退出析构,也不会在 realloc 返回后无缘无故退出。
如果整个进程被终止,就不能依靠 TSS 析构函数完成清理。程序终止和单个工作线程正常退出需要分别考虑。
原代码中需要修复的问题
检查分配和注册是否成功
tss_create 和 tss_set 都可能失败,必须检查返回值是否为 thrd_success。首次分配成功但 tss_set 失败时,程序仍需释放那块内存。相关返回值见 C11 草案 N1570 的 7.26.6 节。
realloc 需要单独处理:对于非零目标大小,失败时返回空指针,原来的内存仍然有效。直接写成 buffer.data = realloc(...) 会覆盖用于访问原内存的指针;如果随后又成功将空指针写入 TSS,就会丢失这块分配的清理记录。应先用临时变量接收结果。参见 N1570 的 7.22.3.5 节。
即使 realloc 成功,后续的 tss_set 仍可能失败。如果旧分配已经失效,TSS 就可能继续保存失效指针。把两次调用写在同一个表达式里也不能消除这个问题。
通常只需要创建一个共享的 key
tss_t 是用于访问线程特定值的 key。同一个 key 可以供多个线程使用,每个线程通过它读写自己的值。
通常可以用一个全局 key 配合 call_once 初始化,无需让每个线程都创建独立的 key。线程退出时的值析构和 key 的删除是两件事,退出析构不会自动删除 key。
示例:让 TSS 保存地址稳定的管理对象
可以把 data 和 capacity 放进一个单独分配的管理对象,让 TSS 始终保存这个对象的地址。缓冲区扩容时只更新对象内部的 data,无需反复调用 tss_set。
下面的 C11 示例可供集成到程序中。它要求实现支持 <threads.h>,且执行期间不会发生异步取消,也不会从分配或更新流程中异常跳出:
#include <stddef.h>
#include <stdlib.h>
#include <threads.h>
typedef struct {
char *data;
size_t capacity;
} Buffer;
static tss_t buffer_key;
static once_flag buffer_once = ONCE_FLAG_INIT;
static int buffer_key_status = thrd_error;
static void buffer_destroy(void *ptr)
{
Buffer *buffer = ptr;
if (buffer != NULL) {
free(buffer->data);
free(buffer);
}
}
static void buffer_key_init(void)
{
buffer_key_status = tss_create(&buffer_key, buffer_destroy);
}
static Buffer *buffer_get(void)
{
call_once(&buffer_once, buffer_key_init);
if (buffer_key_status != thrd_success)
return NULL;
Buffer *buffer = tss_get(buffer_key);
if (buffer != NULL)
return buffer;
buffer = malloc(sizeof *buffer);
if (buffer == NULL)
return NULL;
*buffer = (Buffer){ .data = NULL, .capacity = 0 };
if (tss_set(buffer_key, buffer) != thrd_success) {
buffer_destroy(buffer);
return NULL;
}
return buffer;
}
/* buffer must be a valid object owned by the calling thread. */
static int buffer_reserve(Buffer *buffer, size_t capacity)
{
if (capacity <= buffer->capacity)
return 0;
char *next = realloc(buffer->data, capacity);
if (next == NULL)
return -1;
buffer->data = next;
buffer->capacity = capacity;
return 0;
}
调用方应检查 buffer_get() 的返回值,确认非空后再调用 buffer_reserve(buffer, 10),并检查结果。这里的容量以字节计,char 缓冲区无需额外乘以 sizeof(char)。
这样,TSS 只需注册一次。扩容失败时,旧缓冲区和容量保持有效;扩容成功后也无需再次调用 tss_set,因此不会遇到第二次注册失败的问题。这个设计仍依赖前述退出方式约束,不能保证线程在任意指令处被异步取消时的安全。
如果确实使用了 POSIX 线程取消
POSIX 的 PTHREAD_CANCEL_ASYNCHRONOUS 允许线程在任意位置响应取消,此时你的担忧成立,甚至可能在 malloc 或 realloc 内部就破坏分配器状态。增加临时变量、互斥锁或退出析构函数,都不能单独解决这个问题。Linux man-pages 对异步取消的说明明确列出了这类限制。
优先使用协作式停止,让线程检查停止标志后自行返回。如果必须使用 POSIX 取消,应采用延迟取消,并将取消点安排在资源状态一致的位置。对于必须保护的操作,可以在开始分配前用 pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, &oldstate) 禁用取消,完成注册、指针更新和失败回滚后再恢复原状态。还需检查调用结果,并覆盖所有错误分支。
C11 没有规定 tss_t 与 POSIX 取消之间的互操作保证。如果依赖取消时执行清理,应使用 pthread_key_create 等 POSIX 接口,或者确认所用实现明确支持这种组合。
使用时还要注意什么?
- 纯 C11 不支持
nullptr,应使用NULL;问题中的原始代码需要根据实际语言标准调整。 - 扩容成功后,先前缓存的
data地址以及指向缓冲区内部的指针可能失效。 - 不要在其他线程仍使用 key 时删除它。
tss_delete不会调用值的析构函数;应先让使用该 key 的工作线程完成退出和清理,再安排删除。WG14 DR 416
备注:内容仅供参考。
