在 UNIX 系统中我们知道malloc()
是一个不可重入函数(系统调用)。这是为什么?
同样,printf()
也可以说是不可重入的;为什么?
我知道重入的定义,但我想知道为什么它适用于这些功能。是什么阻止了他们保证可重入?
在 UNIX 系统中我们知道malloc()
是一个不可重入函数(系统调用)。这是为什么?
同样,printf()
也可以说是不可重入的;为什么?
我知道重入的定义,但我想知道为什么它适用于这些功能。是什么阻止了他们保证可重入?
malloc
并且printf
通常使用全局结构,并在内部使用基于锁的同步。这就是为什么它们不能重入。
该malloc
函数可以是线程安全的或线程不安全的。两者都不可重入:
Malloc 在全局堆上运行,可能malloc
同时发生两次不同的调用,返回相同的内存块。(第二次 malloc 调用应该在获取块的地址之前发生,但块没有被标记为不可用)。这违反了 的后置条件malloc
,所以这个实现不会是可重入的。
为了防止这种影响,线程安全的实现malloc
将使用基于锁的同步。但是,如果从信号处理程序中调用 malloc,则可能会发生以下情况:
malloc(); //initial call
lock(memory_lock); //acquire lock inside malloc implementation
signal_handler(); //interrupt and process signal
malloc(); //call malloc() inside signal handler
lock(memory_lock); //try to acquire lock in malloc implementation
// DEADLOCK! We wait for release of memory_lock, but
// it won't be released because the original malloc call is interrupted
malloc
当只是从不同的线程调用时,这种情况不会发生。事实上,重入概念超越了线程安全,并且还要求函数正常工作,即使它的调用之一永远不会终止。这基本上就是为什么任何带锁的函数都不能重入的原因。
该printf
函数还对全局数据进行操作。任何输出流通常都使用一个附加到资源数据的全局缓冲区(用于终端或文件的缓冲区)。打印过程通常是将数据复制到缓冲区并随后刷新缓冲区的序列。这个缓冲区应该以同样的方式受到锁的保护malloc
。因此,printf
也是不可重入的。
让我们理解我们所说的re-entrant是什么意思。可以在先前的调用完成之前调用可重入函数。如果发生这种情况
malloc 不是可重入的,因为它管理着几个跟踪空闲内存块的全局数据结构。
printf 不是可重入的,因为它修改了一个全局变量,即 FILE* stout 的内容。
这里至少有三个概念,所有这些都在口语中混为一谈,这可能就是您感到困惑的原因。
首先采取最简单的方法:两者malloc
和printf
都是线程安全的。自 2011 年以来,它们在标准 C 中被保证是线程安全的,自 2001 年以来在 POSIX 中被保证是线程安全的,并且在很久以前就在实践中。这意味着保证以下程序不会崩溃或表现出不良行为:
#include <pthread.h>
#include <stdio.h>
void *printme(void *msg) {
while (1)
printf("%s\r", (char*)msg);
}
int main() {
pthread_t thr;
pthread_create(&thr, NULL, printme, "hello");
pthread_create(&thr, NULL, printme, "goodbye");
pthread_join(thr, NULL);
}
一个不是线程安全的函数的例子是strtok
. 如果strtok
同时从两个不同的线程调用,结果是未定义的行为——因为strtok
内部使用静态缓冲区来跟踪其状态。glibc 添加strtok_r
来解决这个问题,C11 添加了相同的东西(但可选地使用不同的名称,因为不是在这里发明的)作为strtok_s
.
好的,但也不printf
使用全局资源来构建其输出吗?事实上,同时从两个线程打印到标准输出意味着什么?这将我们带到下一个主题。显然printf
它将成为任何使用它的程序中的关键部分。一次只允许一个执行线程进入临界区。
至少在 POSIX 兼容的系统中,这是通过printf
调用 to 开始并以调用flockfile(stdout)
结束来实现的funlockfile(stdout)
,这基本上就像使用与 stdout 关联的全局互斥锁一样。
但是,FILE
程序中的每个不同的都可以有自己的互斥锁。这意味着一个线程可以同时调用fprintf(f1,...)
另一个线程正在调用fprintf(f2,...)
. 这里没有竞争条件。(您的 libc 是否实际上并行运行这两个调用是QoI问题。我实际上不知道 glibc 做了什么。)
同样,malloc
它不太可能成为任何现代系统中的关键部分,因为现代系统足够智能,可以为系统中的每个线程保留一个内存池,而不是让所有 N 个线程争夺一个内存池。(sbrk
系统调用可能仍然是一个关键部分,但malloc
很少花费时间在sbrk
. 或mmap
,或者这些天酷孩子正在使用的任何东西。)
好的,那么重入实际上是什么意思?基本上,这意味着可以安全地递归调用该函数——当前调用被“搁置”,而第二个调用运行,然后第一个调用仍然能够“从它停止的地方继续”。(从技术上讲,这可能不是由于递归调用:第一次调用可能在线程 A 中,线程 B 在中间被线程 B 中断,从而进行第二次调用。但这种情况只是线程安全的一个特例,所以我们可以在本段中忘记它。)
单个线程也printf
不能递归调用它们,因为它们是叶函数(它们不会调用自己,也不会调用任何可能进行递归调用的用户控制代码malloc
)。而且,正如我们在上面看到的,自 2001 年以来,它们对 *multi-*threaded 重入调用是线程安全的(通过使用锁)。
因此,无论是谁告诉您printf
并且malloc
不可重入,都是错误的;他们的意思可能是它们都有可能成为程序中的关键部分——一次只能通过一个线程的瓶颈。
学究式注释:glibc 确实提供了一个扩展,通过它printf
可以调用任意用户代码,包括重新调用自身。这在所有排列中都是完全安全的——至少就线程安全而言。(显然,它为绝对疯狂的格式字符串漏洞打开了大门。)有两种变体:(register_printf_function
已记录且合理,但正式“弃用”)和register_printf_specifier
(除了一个额外的未记录参数和完全缺乏外,几乎相同面向用户的文档)。我不会推荐它们中的任何一个,在这里提到它们只是作为一个有趣的旁白。
#include <stdio.h>
#include <printf.h> // glibc extension
int widget(FILE *fp, const struct printf_info *info, const void *const *args) {
static int count = 5;
int w = *((const int *) args[0]);
printf("boo!"); // direct recursive call
return fprintf(fp, --count ? "<%W>" : "<%d>", w); // indirect recursive call
}
int widget_arginfo(const struct printf_info *info, size_t n, int *argtypes) {
argtypes[0] = PA_INT;
return 1;
}
int main() {
register_printf_function('W', widget, widget_arginfo);
printf("|%W|\n", 42);
}
很可能是因为您无法开始写入输出,而对 printf 的另一个调用仍在打印它自己。内存分配和释放也是如此。
这是因为两者都适用于全局资源:堆内存结构和控制台。
编辑:堆只不过是一种链表结构。每个malloc
or都会free
修改它,因此同时拥有多个线程并对其进行写访问会损害其一致性。
EDIT2:另一个细节:默认情况下,它们可以通过使用互斥锁成为可重入的。但这种方法成本高昂,并且无法保证它们将始终用于 MT 环境。
因此有两种解决方案:制作 2 个库函数,一个可重入,一个不可重入,或者将互斥锁部分留给用户。他们选择了第二个。
此外,可能是因为这些函数的原始版本是不可重入的,所以为了兼容性而声明了。
如果你尝试从两个单独的线程调用 malloc(除非你有一个线程安全的版本,C 标准不保证),就会发生不好的事情,因为两个线程只有一个堆。printf 也一样——行为未定义。这就是使它们在现实中不可重入的原因。