PAYATHON 2026

如何安全使用 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

备注:内容仅供参考。