什么是可重入函数?
核心定义: 一个函数被称为可重入的(Reentrant),如果它可以被多个线程同时调用,并且每个调用都能使用自己的数据副本,而不会相互干扰或产生错误的结果。

换句话说,一个可重入函数在执行过程中,可以被“中断”,然后再次被调用(可能是同一个线程,也可能是不同线程),而不会破坏其内部状态和数据的一致性。
一个简单的比喻: 想象一个银行柜员,他一次只能服务一个客户。
- 非可重入函数:这个柜员有一个记事本,他正在为客户A办理业务,把A的信息写在记事本上,这时,客户B打断他,让他办B的业务,柜员就在记事本上擦掉A的信息,写上B的,等他办完B的业务,再回头找A的信息时,发现信息已经被覆盖了,结果就错了,这个柜员就是“非可重入”的。
- 可重入函数:这个银行给每个客户都发了一个独立的申请表,柜员为A服务时,使用A的表格;为B服务时,使用B的表格,他们互不干扰,这个柜员就是“可重入”的。
为什么可重入性很重要?
在并发编程(多线程、多进程、中断处理)中,如果一个函数被多个执行流共享调用,而它又是非可重入的,就会引发严重问题,最常见的就是竞态条件。
竞态条件:当两个或多个线程试图同时访问和修改共享数据时,最终的结果取决于线程执行的精确时序,这会导致不可预测的、错误的行为。
典型的场景:
- 多线程环境:两个线程同时调用一个非可重入函数。
- 中断处理程序:一个函数正在被主线程执行,此时被一个硬件中断打断,中断处理程序也调用了同一个函数。
在这些场景下,如果函数内部使用了静态变量或全局变量,或者依赖于可修改的静态/全局数据,就极有可能导致数据损坏。
如何判断一个函数是否可重入?(关键特征)
一个函数是否可重入,主要看它是否满足以下条件:
| 特征 | 可重入函数 | 非可重入函数 |
|---|---|---|
| 数据访问 | 只使用局部变量和函数参数,这些数据都存储在栈上,每个调用实例都有自己独立的副本。 | 使用了全局变量、静态变量、或者指向静态数据的指针,这些数据在所有调用实例间是共享的。 |
| 资源访问 | 如果需要访问外部资源(如文件、硬件),它会通过参数传递资源句柄(如文件指针),而不是直接使用全局句柄。 | 直接使用全局的资源句柄(如 stdout)或静态资源。 |
| 函数调用 | 只调用其他可重入函数。 | 调用了非可重入函数(如 rand(), strtok(), getenv() 等)。 |
| 内存修改 | 如果修改内存,是通过参数传递的指针指向的内存,而不是修改静态或全局内存。 | 修改了静态或全局内存区域。 |
经典的例子:可重入 vs. 非可重入
例子 1:非可重入函数
这是一个典型的非可重入函数,因为它使用了静态局部变量 temp。
#include <stdio.h>
// 非可重入函数
int non_reentrant_add(int a, int b) {
static int temp = 0; // 问题所在:静态变量,所有调用共享
temp = a + b;
return temp;
}
// 模拟多线程环境
void* thread_func(void* arg) {
int id = *(int*)arg;
int result = non_reentrant_add(id, id + 1);
printf("Thread %d: result = %d\n", id, result);
return NULL;
}
int main() {
pthread_t t1, t2;
int id1 = 1, id2 = 2;
pthread_create(&t1, NULL, thread_func, &id1);
pthread_create(&t2, NULL, thread_func, &id2);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
return 0;
}
潜在问题分析:
假设线程1执行到 temp = a + b (即 temp = 1 + 2),但指令还没执行完,CPU就被线程2抢占,线程2执行 temp = a + b (即 temp = 2 + 3),temp 的值被更新为 5,然后线程1恢复执行,它以为 temp 还是它计算后的值,但实际上 temp 已经被线程2覆盖了,最终结果可能是错误的,取决于具体的执行时序。
例子 2:可重入函数
下面是同一个功能的可重入版本。
#include <stdio.h>
// 可重入函数
int reentrant_add(int a, int b) {
int temp = a + b; // 局部变量,每个调用都有自己的副本
return temp;
}
// 模拟多线程环境
void* thread_func(void* arg) {
int id = *(int*)arg;
int result = reentrant_add(id, id + 1);
printf("Thread %d: result = %d\n", id, result);
return NULL;
}
int main() {
pthread_t t1, t2;
int id1 = 1, id2 = 2;
pthread_create(&t1, NULL, thread_func, &id1);
pthread_create(&t2, NULL, thread_func, &id2);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
return 0;
}
为什么它是可重入的?
函数 reentrant_add 内部唯一的变量 temp 是一个局部变量,存储在栈上,当线程1调用它时,会在线程1的栈空间里创建一个 temp;当线程2调用它时,会在线程2的栈空间里创建另一个独立的 temp,它们之间没有任何共享数据,因此一个线程的执行不会影响另一个线程,结果是安全、可预测的。
常见的非可重入 C 标准库函数
很多 C 标准库函数因为设计上使用了静态状态,所以是非可重入的,在多线程环境中,应该使用它们的线程安全版本(通常以 _r 或者提供明确的 reentrant 关键字)。
| 函数 | 非可重入原因 | 线程安全替代方案 |
|---|---|---|
strtok() |
内部维护一个静态的指针,记录分割位置。 | strtok_r() (POSIX) 或使用 strcspn()/strspn() 自己实现 |
rand() |
内部维护一个静态的种子来生成随机数。 | rand_r() (需要传入种子指针) 或使用现代的 random()/srandom() |
getenv() |
可能返回一个指向静态缓冲区的指针。 | 使用 secure_getenv() (glibc) 或确保在调用后立即复制数据 |
asctime() / ctime() |
返回一个指向静态缓冲区的指针,每次调用都会覆盖上一次的结果。 | asctime_r() / ctime_r() |
gmtime() / localtime() |
返回一个指向静态 struct tm 的指针。 |
gmtime_r() / localtime_r() |
qsort() |
标准库实现通常是可重入的,但有些旧实现可能不是。 | 通常认为它是可重入的,但如果不确定,可以自己实现一个或使用特定平台的版本。 |
可重入函数与线程安全函数的关系
这两个概念密切相关,但不完全相同。
- 可重入:关注的是函数自身的设计,一个函数不依赖任何共享状态,它就是可重入的。
- 线程安全:关注的是函数在多线程环境下的行为,一个函数是线程安全的,意味着当多个线程并发调用它时,程序的行为仍然是正确的。
关系:
-
可重入的函数一定是线程安全的。
因为它不依赖共享数据,自然不会出现数据竞争,所以是线程安全的。
-
线程安全的函数不一定是可重入的。
-
一个函数可以通过加锁(如使用互斥锁
mutex)来保证线程安全。int global_counter = 0; pthread_mutex_t counter_mutex = PTHREAD_MUTEX_INITIALIZER; // 线程安全,但非可重入 void thread_safe_increment() { pthread_mutex_lock(&counter_mutex); global_counter++; // 修改全局数据 pthread_mutex_unlock(&counter_mutex); } -
这个函数是线程安全的,因为锁保证了同一时间只有一个线程能修改
global_counter。 -
但它不是可重入的!如果一个线程在持有锁的时候(例如在
thread_safe_increment函数内部),又调用了自己(或者另一个也试图获取同一个锁的函数),就会发生死锁。
-
可重入性是比线程安全更严格的属性,在需要高可靠性、实时性或信号处理(中断)的场景下,可重入函数是首选,因为它避免了锁带来的开销和死锁的风险。
如何编写可重入函数?
遵循以下黄金法则:
- 只使用局部变量:函数内部所有数据都应定义在函数体内(或作为参数传入)。
- 避免使用全局/静态数据:不要在函数内部声明或修改全局变量或静态变量。
- 避免依赖非可重入函数:只调用你确定是可重入的函数。
- 通过参数传递资源:如果需要操作文件、网络连接等,将它们的句柄(如
FILE*)作为参数传入,而不是使用全局变量。
通过遵循这些原则,你编写的函数将天生具备可重入性,能够在复杂的并发环境中安全地工作。
