线程特定数据(thread-specific data),也称为线程私有数据(thread-private data),是线程单独的数据副本,存储在线程的私有存储空间,不与进程中其他线程共享。

线程键(pthread_key_t),每个线程用其与自身特定数据地址进行关联。

析构函数用于线程退出时调用。通常使用malloc为线程特定数据分配内存,析构函数通常用于释放已分配的内存。

线程特定数据

线程模型促进了进程中数据与属性的共享,那么为什么现在又要阻止这种共享呢?

原因有以下两点:

  1. 过犹不及,线程也想要有自己的私人空间。有时候需要维护基于每个线程的数据
  2. 不能有了新人就忘记旧人。线程私有数据提供了让基于进程的接口适配多线程环境的机制(例如errno)。

具体来说,可用于以下情况:

  1. 全局变量的值在不同线程中需要有不同的副本。由于线程共享内存空间,普通的全局变量无法满足这个需求。使用线程特定数据可以在每个线程中分别存储各自的副本。
  2. 在多线程环境下,需要保存一些与线程相关的状态信息。例如,可以使用线程特定数据来存储线程的登录状态、语言环境、配置信息等。
  3. 线程池和线程复用:在线程池或线程复用的场景中,线程的生命周期可能比较长,需要多次使用。使用线程特定数据可以在每次使用线程时,重新初始化线程私有数据,确保数据的正确性和可靠性。

线程键

在分配线程特定数据之前,需要先创建与该特定数据地址关联的。线程使用该键来对特定数据进行访问。

使用pthread_key_create()函数可以创建一个线程键:

#include <pthread.h>
int pthread_key_create(pthread_key_t* key, void (*destr_function)(void*));
// 返回值:成功返回0,失败返回错误编号

pthread_key_create()函数将创建的键保存在参数key中。

该键可以被进程中的所有线程使用,但每个线程可以将该键与不同的线程特定数据进行关联关联线程特定数据)。

pthread_key_create()函数还提供了一个参数destr_function,该参数指向一个回调函数(析构函数),回调函数以线程键绑定的特定数据地址为参数

该回调函数或析构函数被调用的条件需满足(见示例析构函数调用示例):

  1. 线程退出时,线程关联的线程特定数据地址不为空
  2. 线程调用pthread_exit或者线程执行返回,正常退出;线程取消时,最后的清理处理程序返回之后会被调用。
  3. 调用析构函数的次数不超过PTHREAD_DESTRUCTOR_ITERATIONS指定的值,这是因为析构函数中也有可能将非空线程特定数据与当前线程键关联,因此会不断循环。

如果线程调用了exit_exit_Exitabort,以及其他非正常的退出时,不会调用该函数(例如从主线程退出)。

线程键冲突问题

由于线程键可供多个线程同时使用,因此线程键一般为全局变量,那么就存在线程键在初始化时的竞争问题

为了避免多个线程环境下,初始化线程键的竞争问题,可以使用pthread_once()函数。

#include <pthread.h>
pthread_once_t once_control = PTHREAD_ONCE_INIT;
int pthread_once(pthread_once_t *once_control, void (*init_routine) (void));

如果多个进程都调用pthread_once()函数,那么该函数保证参数init_routine()指定回调函数在多线程环境下仅执行一次。具体应用方法见线程特定数据版getenv()

如果是在创建其他线程之前,初始化线程键(例如在main函数中创建其他线程前),那么可以不使用pthread_once()函数。

因此创建线程键避免冲突的示例代码如下:

#include <pthread.h>
static void destructor(void* arg);
pthread_key_t key;
pthread_once_t init_once = PTHREAD_ONCE_INIT;
// 线程初始化函数
static void thread_init(void)
{
    int err = 0;
    // 初始化线程键
    err = pthread_key_create(&key, destructor);
    // ...
}
// 线程函数
void* thread_func(void* arg)
{
    // thread_init函数在多线程下仅被调用一次,
    // 避免了线程键被初始化多次
    pthread_once(&init_once, thread_init);
}

特定数据

线程通常使用malloc为线程特定数据分配内存。析构函数通常用于释放已分配的内存。

线程键在关联特定数据之前,对应的线程特定数据为空。

线程键、特定数据以及析构函数之间的关系:

  1. 每个线程键可以关联一个析构函数。
  2. 每个析构函数可以关联多个线程键,即多个线程键可以共用一个析构函数。
  3. 每个线程键可以关联一个线程特定数据。
  4. 每个线程特定数据可以关联到多个线程键,即多个线程键可以公用一个线程特定数据。

函数pthread_setspecific()可以将线程键与线程特定数据关联起来,函数pthread_getspecific()可以通过线程键获取线程特定数据地址,函数pthread_key_delete()可以解除线程键与特定数据之间的关联关系。

#include <pthread.h>
int pthread_setspecific(pthread_key_t key, const void* pointer);
void* pthread_getspecific(pthread_key_t key);
int pthread_key_delete(pthread_key_t key);
// 返回值:成功返回0,失败返回错误编号

pthread_key_delete()函数会释放线程键,解除线程键与特定数据之间的关联关系,并不会释放线程特定数据,也不会调用析构函数。

示例

析构函数调用示例

在下面的示例中,线程1调用pthread_setspecific()函数绑定了特定数据,满足线程退出时析构函数的调用条件,进而调用了析构函数。

线程2没有绑定特定数据,意味着特定数据为空,退出时不会调用析构函数。

#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>

pthread_key_t key;  // 定义线程键

// 定义析构函数
void destructor(void* value)
{
    printf("Destructor called: %s\n", (char*)value);
    free(value);
}

// 线程函数
void* thread_func(void* arg)
{
    char* value = malloc(10);
    sprintf(value, "Thread %ld", (long)arg);

    if ((long)arg == 1)
    {
        // 仅线程1关联特定数据
        // 设置线程特定数据
        pthread_setspecific(key, value);

        // 读取线程特定数据
        printf("Thread specific data: %s\n", (char*)pthread_getspecific(key));
    }
    else
        free(value);

    return NULL;
}

int main()
{
    pthread_t thread1, thread2;

    // 创建线程键
    pthread_key_create(&key, destructor);

    // 创建线程1
    pthread_create(&thread1, NULL, thread_func, (void*)1);

    // 创建线程2
    pthread_create(&thread2, NULL, thread_func, (void*)2);

    // 等待线程1和线程2结束
    pthread_join(thread1, NULL);
    pthread_join(thread2, NULL);

    // 销毁线程键
    pthread_key_delete(key);

    return 0;
}
// 执行结果如下:
// Thread specific data: Thread 1
// Destructor called: Thread 1

线程特定数据版getenv

正常情况下,getenv()函数返回一个指向指定环境变量值的全局指针,也因此该函数不是线程安全的。

在这个示例中,我们不再使用全局指针来保存返回结果,而是使用线程特定数据,对每个线程都有一个内存空间来保存返回的环境变量值,因此该示例中的getenv()函数是线程安全的。

同时由于使用到线程键、并且不确定外部多线程的调用情况,我们需要使用pthread_once()函数来保证无论是否多个线程竞争调用,但线程键仅会被初始化一次。

示例代码如下:

#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

#define MAXSTRINGSZ 4096
pthread_key_t key;
pthread_once_t init_done = PTHREAD_ONCE_INIT;
pthread_mutex_t env_mutex = PTHREAD_MUTEX_INITIALIZER;

extern char** environ;

static void thread_init(void)
{
    // free函数作为析构函数,用于释放空间
    // 由于线程键是进程间的所有线程共同使用的,因此不需要在线程退出时进行释放,
    // 只需要进程退出时自动释放即可
    pthread_key_create(&key, free);
}

char* getenv(const char* name)
{
    // pthread_once函数保证多线程环境中,thread_init仅调用一次
    pthread_once(&init_done, thread_init);

    pthread_mutex_lock(&env_mutex);
    char* envbuf = (char*)pthread_getspecific(key);
    if (envbuf == NULL)
    {
        envbuf = (char*)malloc(sizeof(char) * 4096);
        if (envbuf == NULL)
        {
            pthread_mutex_unlock(&env_mutex);
            return NULL;
        }
        pthread_setspecific(key, envbuf);
    }

    for (int i = 0; environ[i] != NULL; i++)
    {
        if ((strncmp(name, environ[i], strlen(name)) == 0) && (environ[i][strlen(name)] == '='))
        {
            strncpy(envbuf, &environ[i][strlen(name) + 1], MAXSTRINGSZ - 1);
            pthread_mutex_unlock(&env_mutex);
            return envbuf;
        }
    }
    pthread_mutex_unlock(&env_mutex);
    return NULL;
}

// 线程函数
void* thread_func(void* arg)
{
    printf("Thread %ld", (long)arg);

    if ((long)arg == 1)
    {
        // 读取线程特定数据
        for (int i = 0; i < 100000; i++)
        {
            char* lang = getenv("LANG");
            printf("Thread %ld %s\n", (long)arg, lang);
        }
    }
    else
    {
        for (int i = 0; i < 100000; i++)
        {
            char* shell = getenv("SHELL");
            printf("Thread %ld %s\n", (long)arg, shell);
        }
    }
    return NULL;
}
int main()
{
    pthread_t thread1, thread2;

    // 创建线程1
    pthread_create(&thread1, NULL, thread_func, (void*)1);

    // 创建线程2
    pthread_create(&thread2, NULL, thread_func, (void*)2);

    // 等待线程1和线程2结束
    pthread_join(thread1, NULL);
    pthread_join(thread2, NULL);

    // 销毁线程键
    pthread_key_delete(key);

    return 0;
}

在上面的例子中分别使用两个线程调用了10w次getenv(),但是从其结果中被没有看到有线程不安全的状况发生。

虽然上面实现的getenv()函数是线程安全的,但是并不是异步信号安全的,因为其中使用malloc()函数、全局互斥锁env_mutex以及pthread_*函数等。

总结

线程特定数据与线程中直接malloc分配内存的区别:

  1. 使用malloc在每个线程中分配内存需要手动管理内存的分配和释放,容易出错和造成内存泄漏。而线程特定数据提供了自动的内存管理机制,可以确保在线程退出时释放相关资源,避免了内存泄漏的问题
  2. 线程安全的函数调用:某些函数在多线程环境下是线程安全的,但是需要传递一个上下文或状态给函数。使用线程特定数据可以将这个上下文或状态存储在线程特定数据中,而不需要显式地传递给函数。这样可以简化函数调用的过程,避免了参数传递的麻烦。