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_join或pthread_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 知识体系总览
下面这张图总结了本章的核心逻辑:
20.5 实战建议
最后,分享几个我在实际项目中积累的经验:
- 线程函数的参数一定要保证生命周期。不要传局部变量的地址,除非你能确保线程在函数返回前已经结束。
- 能用可重入函数就别用线程安全函数。可重入函数没有锁,性能更好,也不容易死锁。
- 标准库中很多函数都有可重入版本,比如
strtok_r、rand_r、localtime_r。我建议你优先使用这些带_r后缀的版本。 - 不要在线程函数中做太多事情。一个线程最好只负责一个明确的任务,这样调试起来也方便。
一句话总结:线程函数的签名是void* (*)(void*),这是pthread的约定;线程安全解决的是并发访问的问题,可重入解决的是中断重入的问题。搞懂这两点,多线程编程就成功了一半。