为啥说 malloc() 和 printf() 是不可重入的?

Posted

技术标签:

【中文标题】为啥说 malloc() 和 printf() 是不可重入的?【英文标题】:Why are malloc() and printf() said as non-reentrant?为什么说 malloc() 和 printf() 是不可重入的? 【发布时间】:2011-04-25 20:54:50 【问题描述】:

在 UNIX 系统中,我们知道 malloc() 是一个不可重入函数(系统调用)。这是为什么?

同样,printf() 也被称为不可重入;为什么?

我知道重入的定义,但我想知道为什么它适用于这些功能。 是什么阻止了它们保证可重入?

【问题讨论】:

@ripunjay-tripathi : printf,如果它正在打印到公共资源,例如标准输出。 malloc 因为它依赖锁。请记住,可重入和线程安全之间存在差异。其中 malloc 是线程安全的。看看这个帖子。 ***.com/questions/855763/malloc-thread-safe. 您在哪里找到“规范实现”?如果开发人员说函数是可重入的。 glibc 开发人员没有说 malloc 或 printf 是可重入的:所以它们不是。 @pmg,关于“规范实现”的词是我添加的。这就是我的意思。很明显,可重入性是实现的属性,而不是接口的属性。但是,例如,POSIX 没有将mallocprintf 列为可重入函数,这是有原因的。在这个问题中,OP 想知道原因是什么。 @pmg: 我想稍微有点问题的重点,POSIX 并没有说malloc 必须是可重入的,因此 glibc 开发人员可以这样做并且仍然声称是开发“一个 UNIX 系统”。 @Pavel:可重入性接口的属性,也是实现的属性。必需的语义是接口的一部分。可重入接口是所有实现都必须可重入的接口之一。在考虑 POSIX 作者的动机时,没有“规范”的实现。重要的是标准作者希望允许的任何合理实施策略是否无法(有效)实施可重入malloc。如果是这样,那么malloc 将被标记为不可重入,无论该实施策略是“规范”还是普通。 【参考方案1】:

这里至少有三个概念,所有这些概念都在口语中混为一谈,这可能就是您感到困惑的原因。

线程安全 临界区 重入

先取最简单的一个:mallocprintf 都是 thread-safe。自 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 在任何使用它的程序中都将成为critical section 一次只允许一个执行线程进入临界区。

至少在 POSIX 兼容的系统中,这是通过让 printf 以调用 flockfile(stdout) 开始并以调用 funlockfile(stdout) 结束来实现的,这基本上就像采用与 stdout 关联的全局互斥锁一样。

但是,程序中每个不同的FILE 都可以有自己的互斥体。这意味着一个线程可以调用fprintf(f1,...),同时第二个线程正在调用fprintf(f2,...)。这里没有竞争条件。 (您的 libc 是否实际上并行运行这两个调用是 QoI 问题。我实际上不知道 glibc 是做什么的。)

同样,malloc 不太可能成为任何现代系统中的关键部分,因为现代系统是smart enough to keep one pool of memory for each thread in the system,而不是让所有 N 个线程争夺一个池。 (sbrk 系统调用可能仍然是一个关键部分,但mallocsbrk 上花费的时间很少。或者mmap,或者现在很酷的孩子们正在使用的任何东西。)

好的,那么 re-entrancy 究竟是什么意思? 基本上,这意味着可以安全地递归调用该函数——当前调用在第二次调用运行时被“搁置”,并且那么第一次调用仍然能够“从中断的地方继续”。 (从技术上讲,这可能不是由于递归调用:第一次调用可能在线程 A 中,线程 B 在中间被线程 B 中断,从而进行第二次调用。但这种情况只是一个线程安全的特殊情况,所以我们可以在本段中忘记它。)

printfmalloc 都不能被单个线程递归调用,因为它们是叶函数(它们不会调用自己,也不会调用任何用户控制的代码可能会进行递归调用)。而且,正如我们在上面看到的,自 2001 年以来,它们对 *multi-*threaded 重入调用是线程安全的(通过使用锁)。

所以,谁告诉你printfmalloc 是不可重入的,那就错了;他们的意思可能是它们都有可能成为您程序中的关键部分——一次只能通过一个线程的瓶颈。


迂腐提示:glibc 确实提供了一个扩展,通过它printf 可以调用任意用户代码,包括重新调用自身。这在所有排列中都是完全安全的——至少就线程安全而言。 (显然它为绝对insane 格式字符串漏洞打开了大门。)有两个变体:register_printf_function(已记录在案且相当合理,但正式“弃用”)和register_printf_specifier(其几乎相同,除了一个额外的未记录参数和一个total lack of user-facing documentation)。我不会推荐它们中的任何一个,在这里提及它们只是作为一个有趣的旁白。

#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);

【讨论】:

这个答案几乎正确;但它缺少 POSIX 的一个关键概念,异步信号安全。由于异步信号,应用程序代码可以在调用printfmalloc 的过程中执行。这两个函数都不需要是异步信号安全的,因此从异步信号的处理程序调用它们是不安全的。这就是 POSIX 系统程序员printfmalloc 是“不可重入”的意思。 “那么,谁告诉你 printf 和 malloc 是不可重入的,那是错误的” 那么信号处理程序呢?使用线程,原始线程最终将恢复 CPU 并继续运行,但使用信号处理程序,在信号处理程序返回之前什么都不会发生... Jerry:当我给出这个答案时,我的印象是“可重入”和“异步信号安全”不是同义词,而 malloc/printf 是“可重入但不是异步信号” -安全的”。其实我还是有这种印象的;但@zwol 的评论表明,有很大一部分人认为它们同义词,因此任何不是异步信号安全的函数都可能 i> 根据定义,是“可重入的”。听起来杰里也在那个阵营里。我想道德是:使用行话时,应该了解自己的受众及其假设。 :) @Quuxplusone 我个人认为“可重入”是一个定义不明确的术语,应该避免使用它来支持“线程安全”、“递归调用安全” ”或“异步信号安全”,它们有更清晰的定义。所有异步信号安全的函数都是递归调用安全和线程安全的,所有递归调用安全的函数都是线程安全的,但反之则不然。递归调用安全只是回调到用户代码的库函数的一个相关类别(例如qsort),这也是我刚刚编造的一个术语,我认为标准不会使用它。 @zwol:从词源上看,“reentrant”确实应该是“recursive-call-safe”的同义词,这就是我一直在使用它的方式。顺便说一句,int f(void (*g)()) static int x = 0; ++x; g(); 是一个递归调用安全但不是线程安全的函数的示例,因为从两个不同的线程同时调用f([]) 将导致x 上的数据竞争但调用@ 987654364@ 是完全安全的。 (对不起 C++ lambdas。这里的空间有限。;))【参考方案2】:

mallocprintf 通常使用全局结构,并在内部使用基于锁的同步。这就是它们不能重入的原因。

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 也是不可重入的。

【讨论】:

最后,谢谢。我正要写或多或少相同的东西,但是当我开始输入我的答案时,您的答案就出现了。 +1。 "任何带锁的函数都不能重入"。我在一个系统上工作,您可以在其中标记互斥锁以禁用信号,无论是在等待互斥锁时,还是在持有互斥锁的整个过程中。显然它很容易被误用,你必须保证函数会返回,但它被用来从可重入函数(通常在内核中)访问全局变量。我假设没有证据证明其他内核具有等效机制,而且标准不希望在可以避免的情况下要求这种恶作剧。 @Steve:例如,在 Linux 内核中,自旋锁通常会在获得锁时禁用中断。 “正常”睡眠互斥锁 OTOH 在启用中断的情况下运行。 从 C11 开始,mallocprintf 都必须是线程安全的(POSIX 从 2001 年开始就要求这样做)。但是,从异步信号处理程序中使用它们仍然不安全。 重入锁会让malloc重入吗?【参考方案3】:

让我们了解re-entrant 的含义。可以在先前的调用完成之前调用可重入函数。如果

在信号处理程序(或更一般地,比 Unix 的某些中断处理程序)中调用函数以处理函数执行期间引发的信号 递归调用函数

malloc 不可重入,因为它管理着多个跟踪空闲内存块的全局数据结构。

printf 不可重入,因为它修改了一个全局变量,即 FILE* stout 的内容。

【讨论】:

【参考方案4】:

这是因为两者都适用于全局资源:堆内存结构和控制台。

编辑:堆只不过是一种链表结构。每个mallocfree 都会修改它,因此同时有多个线程对其进行写访问会破坏其一致性。

EDIT2:另一个细节:默认情况下,可以使用互斥锁使它们可重入。但这种方法成本高昂,并且无法保证它们将始终用于 MT 环境。

因此有两种解决方案:制作 2 个库函数,一个可重入,一个不可重入,或者将互斥锁部分留给用户。他们选择了第二个。

另外,也可能是因为这些函数的原始版本是不可重入的,所以为了兼容性而声明了。

【讨论】:

所以你声称malloc 不是线程安全的?有趣...(-1)而且您还声称包含互斥锁会使函数可重入...更有趣! (-2) @PavelShved - malloc 是否是线程安全的是 arguable point。至少提供参考以帮助解决问题。 From your own post你不是说malloc有可能是线程un安全的吗?【参考方案5】:

如果您尝试从两个单独的线程调用 malloc(除非您有一个线程安全的版本,C 标准不保证),就会发生不好的事情,因为两个线程只有一个堆。 printf 也一样——行为未定义。这就是使它们在现实中不可重入的原因。

【讨论】:

好的,但是哪里会失败呢?如果我能得到一些例子会很好。 这是 C 规范中未定义的行为,没有可能失败的示例,因为在编写 C 规范时这不是问题(根本没有线程)。它可能在某些实现上起作用,而在其他实现上则根本不起作用,而两种实现都可能遵循规范。 @RIPUNJAY:对 malloc 的调用可能会返回相同的指针,因为两个 malloc 调用都确定该块可用(第一次调用将在确定块为空闲和标记之间中断它已分配)。 @UnixShadow:抱歉有问题。如果根本没有线程的概念,为什么 RE-ENTERANCY 与它相关联? @deadmg:线程安全和RE-ENTERANCY虽然是相关的,但它们是不同的。【参考方案6】:

很可能是因为您无法开始写入输出,而对 printf 的另一个调用仍在打印它自己。内存分配和释放也是如此。

【讨论】:

这解释了什么是“可重入”,但不能解释为什么这些函数是不可重入的。 哦,太好了。我愚蠢地问这个关于printf的问题。谢谢。但是我们不能同时从两个不同的线程调用 malloc() 吗? “当另一个 printf 调用仍在打印它自己时,您无法开始写入输出。” 为什么? printf 的哪一点会导致这种情况?这并不明显。也许结果会是Hello, woSOMETHINGELSErld!,但你问的所有内容都会被打印出来? @RIPUNJAY TRIPATHI:这取决于实现,但如果您在编译中打开了线程支持,malloc 通常是线程安全的。 @Jeremy:线程安全和重入是一回事吗?我认为它们是两种不同的东西。

以上是关于为啥说 malloc() 和 printf() 是不可重入的?的主要内容,如果未能解决你的问题,请参考以下文章

为啥在 C 中需要使用 malloc 进行动态内存分配?

printf 没有正确显示我的字符串 - 为啥?

memset初始化指针指向

c语言中两句相同的printf为啥输出结果不同

为啥在malloc中不调用构造函数? [复制]

为啥在malloc中不调用构造函数? [复制]