20、函数与多线程:线程函数的签名,pthread_create的使用,线程安全与可重入函数

多线程编程,说白了就是让程序同时干几件事。但很多人在刚接触时,都栽在同一个坑里——线程函数的签名写错了。我当年第一次用pthread_create时,编译器报了一堆类型不匹配的警告,折腾了半天才发现是函数指针类型没搞对。

这一章,我们就来彻底搞懂线程函数的签名规则、pthread_create的正确用法,以及多线程环境下最头疼的两个概念:线程安全与可重入函数。

20.1 线程函数的签名——为什么必须是void* (*)(void*)

先看pthread_create的原型:

#include <pthread.h>

int pthread_create(pthread_t *thread,
                   const pthread_attr_t *attr,
                   void *(*start_routine)(void *),
                   void *arg);

第三个参数是关键:void *(*start_routine)(void *)。这是一个函数指针,指向一个参数为void*、返回值也为void*的函数。

为什么必须是这个签名?

嗯,这里有两个设计考量:

  • 通用性void*可以指向任何类型的数据。你想传一个整数、一个结构体、甚至一个数组,都可以通过void*传递进去。
  • 返回值传递:线程执行完后,可以通过pthread_join获取返回值。用void*同样可以返回任意类型的数据。

我个人习惯这样定义线程函数:

void* my_thread_func(void *arg) {
    // 强制类型转换,拿到真正的参数
    int *val = (int *)arg;
    printf("线程收到参数: %d\n", *val);
    return NULL;
}

注意:如果你传的是一个整数,不要直接取地址传局部变量。我在项目中遇到过这样的bug——局部变量在栈上,线程还没开始执行,变量就被销毁了。正确的做法是动态分配内存,或者传一个全局/静态变量的地址。

20.2 pthread_create的使用——从创建到回收

一个完整的线程生命周期包括:创建、执行、回收。我们来看一个标准示例:

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

void* worker(void *arg) {
    int id = *(int *)arg;
    printf("线程 %d 开始工作\n", id);
    return (void*)(long)(id * 2);
}

int main() {
    pthread_t t1, t2;
    int arg1 = 1, arg2 = 2;

    pthread_create(&t1, NULL, worker, &arg1);
    pthread_create(&t2, NULL, worker, &arg2);

    void *ret1, *ret2;
    pthread_join(t1, &ret1);
    pthread_join(t2, &ret2);

    printf("线程1返回值: %ld\n", (long)ret1);
    printf("线程2返回值: %ld\n", (long)ret2);
    return 0;
}

关键点:

  • pthread_create的第一个参数是输出参数,用于获取线程ID。
  • 第二个参数传NULL表示使用默认属性。
  • 第四个参数是传给线程函数的参数,必须保证在线程使用期间有效。
  • pthread_join会阻塞等待线程结束,并回收其资源。

我曾经踩过的坑:忘记调用pthread_joinpthread_detach,导致线程资源泄漏。每个线程都会占用一定的内核资源,如果主线程退出了而子线程还没结束,子线程会变成僵尸线程。所以,要么join它,要么detach它。

20.3 线程安全与可重入函数——两个容易混淆的概念

这两个概念经常被混为一谈,但它们的本质完全不同。我刚开始学的时候也搞混过,直到有一次在项目中排查一个诡异的崩溃问题,才真正理解了它们的区别。

20.3.1 线程安全

线程安全,指的是一个函数在多个线程同时调用时,不会产生数据竞争或未定义行为。说白了,就是多个线程一起用这个函数,不会出问题。

实现线程安全的常见手段:

  • 互斥锁:保护共享数据,同一时间只有一个线程能访问。
  • 原子操作:对简单变量的读写使用原子指令。
  • 线程局部存储:每个线程有自己的数据副本。

举个例子,一个非线程安全的计数器:

int counter = 0;

void increment() {
    counter++;  // 不是原子操作!可能被中断
}

改成线程安全版本:

pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
int counter = 0;

void increment() {
    pthread_mutex_lock(&lock);
    counter++;
    pthread_mutex_unlock(&lock);
}

20.3.2 可重入函数

可重入函数的要求更严格。它指的是一个函数在被中断后,再次被调用(比如在信号处理函数中),仍然能正确执行。

可重入函数必须满足:

  • 不使用任何静态或全局变量(除非是只读的)。
  • 不调用任何不可重入的函数。
  • 不使用锁(因为锁可能导致死锁)。

我的理解:线程安全是「多线程同时调用不出错」,可重入是「单线程内被中断后再次调用不出错」。可重入函数一定是线程安全的,但线程安全的函数不一定是可重入的。比如上面那个加锁的increment函数,如果它在持有锁的时候被信号中断,而信号处理函数又调用了它,那就死锁了。

20.4 知识体系总览

下面这张图总结了本章的核心逻辑:

多线程函数调用 线程函数签名 void* (*start_routine)(void*) void* arg 传递任意类型数据 void* 返回值通过join获取 pthread_create 创建 → 执行 → 回收 pthread_join 回收资源 pthread_detach 分离线程 线程安全 vs 可重入 线程安全:多线程同时调用不出错 可重入:中断后再次调用不出错 可重入函数一定是线程安全的 核心:理解函数签名是基础,区分线程安全与可重入是关键 实际开发中,优先使用可重入函数,避免锁带来的性能开销

20.5 实战建议

最后,分享几个我在实际项目中积累的经验:

  1. 线程函数的参数一定要保证生命周期。不要传局部变量的地址,除非你能确保线程在函数返回前已经结束。
  2. 能用可重入函数就别用线程安全函数。可重入函数没有锁,性能更好,也不容易死锁。
  3. 标准库中很多函数都有可重入版本,比如strtok_rrand_rlocaltime_r。我建议你优先使用这些带_r后缀的版本。
  4. 不要在线程函数中做太多事情。一个线程最好只负责一个明确的任务,这样调试起来也方便。

一句话总结:线程函数的签名是void* (*)(void*),这是pthread的约定;线程安全解决的是并发访问的问题,可重入解决的是中断重入的问题。搞懂这两点,多线程编程就成功了一半。

公众号:蓝海资料掘金营,微信deep3321