为什么malloc()和printf()表示为不可重入?
在UNIX系统中,我们知道malloc()
是一个非重入函数(系统调用)。 这是为什么?
同样, printf()
也被认为是不可重入的; 为什么?
我知道重入的定义,但我想知道为什么它适用于这些function。 什么阻止他们保证可重入?
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
函数也在全局数据上运行。 任何输出stream通常都会使用一个连接到资源数据的全局缓冲区来发送(terminal缓冲区或文件缓冲区)。 打印过程通常是将数据复制到缓冲区并刷新缓冲区的顺序。 这个缓冲区应该像malloc
一样被锁保护。 因此, printf
也是不可重入的。
让我们明白我们的意思是重入 。 在之前的调用完成之前,可以调用重入函数。 这可能发生如果
- 在函数执行过程中产生一个信号,在一个信号处理函数中调用一个函数(或者比Unix更为普通的中断处理程序)
- 一个函数被recursion地调用
malloc不可重入,因为它正在pipe理跟踪空闲内存块的多个全局数据结构。
printf不可重入,因为它修改了一个全局variables,即FILE * stout的内容。
这里至less有三个概念,所有这些概念都是用口语混淆的,这可能就是为什么你感到困惑。
- 线程安全
- 关键部分
- 重入
首先采取最简单的方法: 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增加了相同的东西(但是可选地和在不同的名字下,因为Not Invented Here)作为strtok_s
。
好吧,但是printf
也不使用全局资源来构build它的输出呢? 事实上,从两个线程同时打印到stdout的意思是什么呢? 这使我们接下来的话题。 很明显, printf
将是任何使用它的程序中的关键部分 。 一次只允许一个执行线程在临界区内。
至less在符合POSIX标准的系统中,这是通过让printf
以flockfile(stdout)
调用开始,然后调用flockfile(stdout)
来funlockfile(stdout)
,基本上就像使用与stdout相关的全局互斥体一样。
但是,程序中的每个不同的FILE
都可以拥有自己的互斥锁。 这意味着一个线程可以调用fprintf(f1,...)
,同时第二个线程正在调用fprintf(f2,...)
。 这里没有比赛条件。 (不pipe你的libc实际上是否同时运行这两个调用是一个QoI问题,实际上我不知道glibc是做什么的。)
同样, malloc
不太可能成为任何现代系统中的关键部分,因为现代系统足够聪明,可以为系统中的每个线程保留一个内存池 ,而不是让所有N个线程在单个池中进行争夺。 ( sbrk
系统调用仍然可能是一个关键部分,但是malloc
花费很less的时间在sbrk
,或者mmap
,或者现在很酷的小孩所使用的。
好的, 重入是什么意思呢? 基本上,这意味着函数可以被安全地recursion地调用 – 当第二次调用运行时,当前的调用被“搁置”,然后第一次调用仍然能够“拾取离开的地方”。 (从技术上讲,这可能不是由于recursion调用引起的:第一个调用可能在线程A中,由线程B在中间被中断,从而进行第二次调用。但是这种情况只是线程安全的特例,所以我们可以在这段中忘记它。)
printf
和malloc
都不能被单线程recursion调用,因为它们是叶函数(它们不会自己调用,也不会调用任何用户可能进行recursion调用的用户控制的代码)。 而且,正如我们上面看到的那样,自2001年以来,它们已经针对*multithreading重入调用进行线程安全(通过使用锁)。
所以,谁告诉你printf
和malloc
是不可重入的是错误的; 他们的意思是说,他们都有可能成为你程序中的关键部分 – 一次只有一个线程可以通过的瓶颈。
迂腐的笔记:glibc确实提供了一个扩展,通过该扩展可以调用printf
来调用任意的用户代码,包括重新调用自己。 这是完全安全的所有排列 – 至less就线程安全而言。 (显然,它打开了绝对疯狂的格式string漏洞的大门。)有两个变种: register_printf_function
(这是logging和合理的理智,但正式“弃用”)和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
或free
修改它,所以有多个线程在同一时间写入访问将损坏其一致性。
编辑2:另一个细节:通过使用互斥,默认情况下它们可以重入。 但是这种方法代价高昂,并没有保证他们会一直用在MT环境中。
所以有两个解决scheme:创build2个库函数,一个是可重入的,另一个不是,或者将互斥部分留给用户。 他们select了第二个。
另外,这可能是因为这些函数的原始版本是不可重入的,所以为了兼容性而被声明。
如果你尝试从两个独立的线程中调用malloc(除非你有一个线程安全的版本,而不是C标准所保证的),坏事情会发生,因为两个线程只有一个堆。 相同的printf – 行为是未定义的。 这就是他们实际上不可重入的原因。