為什麼malloc()和printf()表示不可重入?




unix operating-system (5)

如果你嘗試從兩個獨立的線程調用malloc(除非你有一個線程安全的版本,不能用C標准保證),會發生壞事,因為兩個線程只有一個堆。 對於printf也是如此 - 行為未定義。 這就是他們實際上不可重入的原因。

在UNIX系統中,我們知道malloc()是一個不可重入的函數(系統調用)。 這是為什麼?

同樣, printf()也被認為是不可重入的; 為什麼?

我知道re-entrancy的定義,但我想知道為什麼它適用於這些函數。 是什麼阻止他們保證可以重入?


這里至少有三個概念,所有這些概念都用口語混為一談,這可能就是你混淆的原因。

  • 線程安全
  • 關鍵部分
  • 重入

首先採用最簡單的方法: 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添加了同樣的東西(但是可選地,在不同的名稱下,因為Not Invented Here)作為strtok_s

好的,但是printf還沒有使用全球資源來構建它的輸出嗎? 事實上, 同時從兩個線程打印到stdout甚至意味著什麼呢? 這將我們帶到下一個主題。 顯然, printf將成為任何使用它的程序中的關鍵部分 一次只允許一個執行線程進入臨界區。

至少在POSIX兼容系統中,這是通過讓printf以調用flockfile(stdout)開始並以調用flockfile(stdout)結束來funlockfile(stdout) ,這基本上就像使用與stdout相關聯的全局互斥鎖一樣。

但是,程序中的每個不同的FILE都允許擁有自己的互斥鎖。 這意味著一個線程可以在第二個線程正在調用fprintf(f2,...)的同時調用fprintf(f2,...) 。 這裡沒有競爭條件。 (你的libc是否真的並行運行這兩個調用是一個QoI問題。我實際上並不知道glibc是做什麼的。)

類似地, malloc不太可能成為任何現代系統中的關鍵部分,因為現代系統足夠智能,可以為系統中的每個線程保留一個內存池 ,而不是讓所有N個線程在單個池中進行爭奪。 ( sbrk系統調用仍然可能是一個關鍵部分,但malloc花費很少的時間在sbrk 。或mmap ,或者這些天酷孩子們正在使用的。)

那麼re-entrancy實際意味著什麼呢? 基本上,這意味著可以安全地遞歸調用函數 - 當第二次調用運行時,當前調用被“保持”,然後第一次調用仍然能夠“從中斷處繼續”。 (從技術上講,這可能不是由於遞歸調用:第一次調用可能在線程A中,它在中間被線程B中斷,這使得第二次調用。但是這種情況只是線程安全的特例,所以我們可以在這一段中忘記它。)

printfmalloc都不可能由單個線程遞歸調用,因為它們是葉函數(它們不調用自身,也不調用可能進行遞歸調用的任何用戶控制的代碼)。 而且,正如我們上面所看到的,自2001年以來,它們一直是針對*多*線程可重入調用的線程安全(通過使用鎖)。

所以,無論誰告訴你printfmalloc是不可重入的都是錯的; 他們想說的可能是他們都有可能成為你程序中的關鍵部分 - 瓶頸只有一個線程可以一次通過。

迂腐:glibc確實提供了一個擴展,通過該擴展可以使printf調用任意用戶代碼,包括重新調用自身。 這在所有排列中都是完全安全的 - 至少就線程安全而言。 (顯然它為絕對瘋狂的格式字符串漏洞打開了大門。)有兩種變體: register_printf_function (記錄合理且理智,但正式“棄用”)和register_printf_specifier (除了一個額外的未記錄參數和a之外幾乎完全相同) 完全沒有面向用戶的文檔 )。 我不會推薦他們中的任何一個,在這裡提到它們僅僅是一個有趣的方面。

#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的調用仍在打印它自己。 內存分配和釋放也是如此。


這是因為它們都適用於全局資源:堆內存結構和控制台。

編輯:堆只是一種類型的鍊錶結構。 每個mallocfree修改,因此在寫入訪問權限的同時擁有多個線程會損害其一致性。

EDIT2:另一個細節:默認情況下,它們可以通過使用互斥鎖進行重入。 但是這種方法成本很高,並且沒有保證它們將始終用於MT環境。

因此有兩種解決方案:製作2個庫函數,一個是可重入的,一個是非,或者將互斥部分留給用戶。 他們選擇了第二個。

此外,它可能是因為這些函數的原始版本是不可重入的,因此為了兼容性而聲明了這些函數。


mallocprintf通常使用全局結構,並在內部使用基於鎖的同步。 這就是他們不可重入的原因。

malloc函數可以是線程安全的,也可以是線程不安全的。 兩者都不是可重入的:

  1. Malloc在全局堆上運行,並且可能同時發生兩次不同的malloc調用,返回相同的內存塊。 (第二個malloc調用應該在獲取塊的地址之前發生,但是塊沒有被標記為不可用)。 這違反了malloc的後置條件,因此這種實現不會重入。

  2. 為了防止這種影響, 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也是不可重入的。





reentrancy