malloc()
UNIXシステムでは、非再入可能関数(システムコール)であることがわかっています。何故ですか?
同様に、printf()
リエントラントではないとも言われています。なぜ?
再入可能性の定義は知っていますが、なぜこれらの関数に適用されるのか知りたいと思いました。彼らが再入可能であることが保証されるのを妨げるものは何ですか?
malloc()
UNIXシステムでは、非再入可能関数(システムコール)であることがわかっています。何故ですか?
同様に、printf()
リエントラントではないとも言われています。なぜ?
再入可能性の定義は知っていますが、なぜこれらの関数に適用されるのか知りたいと思いました。彼らが再入可能であることが保証されるのを妨げるものは何ですか?
malloc
通常はグローバル構造をprintf
使用し、ロックベースの同期を内部的に採用します。そのため、再入可能ではありません。
関数は、malloc
スレッドセーフまたはスレッドセーフでない可能性があります。どちらも再入可能ではありません:
Malloc はグローバル ヒープで動作し、2 つの異なる呼び出しがmalloc
同時に発生して、同じメモリ ブロックが返される可能性があります。(2 番目の 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
は、単に別のスレッドから呼び出された場合には発生しません。実際、再入可能性の概念はスレッド セーフを超えており、呼び出しの 1 つが決して終了しない場合でも関数が適切に動作する必要があります。これが基本的に、ロックを使用する関数が再入可能ではない理由です。
このprintf
関数は、グローバル データにも作用しました。出力ストリームは通常、データが送信されるリソースに接続されたグローバル バッファを使用します (端末用またはファイル用のバッファ)。印刷プロセスは通常、データをバッファにコピーし、その後バッファをフラッシュする一連の処理です。このバッファは、同じようにロックで保護する必要がmalloc
あります。したがって、printf
再入不可でもあります。
re-entrant の意味を理解しましょう。再入可能関数は、前の呼び出しが完了する前に呼び出すことができます。これは次の場合に発生する可能性があります
malloc は、空きメモリ ブロックを追跡する複数のグローバル データ構造を管理しているため、再入可能ではありません。
printf は、グローバル変数、つまり FILE* stout の内容を変更するため、再入可能ではありません。
ここには少なくとも 3 つの概念があり、それらはすべて口語で混同されているため、混乱した可能性があります。
最初に最も簡単なものを取り上げます。とは両方ともスレッドセーフ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
2 つの異なるスレッドから同時に呼び出した場合、結果は未定義の動作になります —strtok
内部的に静的バッファーを使用してその状態を追跡するためです。glibc はstrtok_r
この問題を修正するために追加し、C11 はstrtok_s
.
わかりましたがprintf
、出力を構築するためにグローバル リソースも使用しませんか? 実際、2 つのスレッドから同時にstdout に出力するとはどういう意味でしょうか? それが次のトピックにつながります。明らかに、それを使用するプログラムの重要なセクションになります。一度にクリティカル セクション内に存在できる実行スレッドは 1 つだけです。printf
少なくとも POSIX 準拠のシステムでは、これはへの呼び出しでprintf
開始し、 への呼び出しflockfile(stdout)
で終了することによって実現されます。これfunlockfile(stdout)
は、基本的に stdout に関連付けられたグローバル ミューテックスを取得するようなものです。
ただし、FILE
プログラム内の個別の各オブジェクトは、独自のミューテックスを持つことができます。これは、1 つのスレッドが を呼び出しfprintf(f1,...)
ているときに、2 番目のスレッドが を呼び出すことができることを意味しfprintf(f2,...)
ます。ここには競合状態はありません。(libc が実際にこれら 2 つの呼び出しを並行して実行するかどうかは、QoIの問題です。glibc が実際に何をするかはわかりません。)
同様に、malloc
が最新のシステムでクリティカル セクションになる可能性は低いです。なぜなら、最新のシステムは、システム内のスレッドごとに 1 つのメモリ プールを保持するほどスマートであり、 N 個のスレッドすべてが 1 つのプールをめぐって争うのではありません。(sbrk
システム コールはおそらく依然として重要なセクションですが、 . や、または最近のクールな子供たちが使用しているものmalloc
にはほとんど時間を費やしません。)sbrk
mmap
では、再入可能性とは実際には何を意味するのでしょうか。基本的に、これは関数を安全に再帰的に呼び出すことができることを意味します — 2 番目の呼び出しが実行されている間、現在の呼び出しは「保留」され、最初の呼び出しは引き続き「中断したところから再開」できます。(技術的には、これは再帰呼び出しによるものではない可能性があります。最初の呼び出しはスレッド A で行われ、スレッド B によって途中で中断され、2 番目の呼び出しが行われる可能性があります。しかし、そのシナリオは、スレッドセーフの特殊なケースにすぎません。そのため、この段落では忘れることができます。)
これらはリーフ関数であるため、単一のスレッドによって再帰的に呼び出されることはありませprintf
ん(それらは自分自身を呼び出したり、再帰呼び出しを行う可能性のあるユーザー制御コードを呼び出したりしません)。そして、上で見たように、それらは 2001 年以来 (ロックを使用することで) *マルチ*スレッド化された再入可能呼び出しに対してスレッドセーフになっています。malloc
printf
だから、あなたにそれを言って、malloc
再入可能でない人は誰でも間違っていました。彼らが言おうとしていたのは、おそらく、どちらもプログラムのクリティカル セクション(一度に 1 つのスレッドしか通過できないボトルネック) になる可能性があるということです。
辛辣な注意: glibc は、printf
それ自体の再呼び出しを含め、任意のユーザー コードを呼び出すことができる拡張機能を提供します。これは、少なくともスレッドセーフに関する限り、すべての順列で完全に安全です。(明らかに、これは絶対に非常識なフォーマット文字列の脆弱性への扉を開きます。) 2 つのバリアントがあります: register_printf_function
(これは文書化されており、かなり正気ですが、公式には「推奨されていません」) とregister_printf_specifier
(これは、1 つの余分な文書化されていないパラメーターと完全な欠落を除いてほとんど同じです)。ユーザー向けドキュメントの)。私はどちらもお勧めしません。
#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
を変更するため、複数のスレッドが同時に書き込みアクセスを行うと、その一貫性が損なわれます。
EDIT2: 別の詳細: デフォルトでは、ミューテックスを使用して再入可能にすることができます。しかし、このアプローチはコストがかかり、MT 環境で常に使用されるという保証はありません。
したがって、2 つの解決策があります。1 つは再入可能で、もう 1 つは再入可能でない 2 つのライブラリ関数を作成するか、mutex 部分をユーザーに任せます。彼らは二番目を選びました。
また、これらの関数の元のバージョンは再入可能ではないため、互換性のためにそう宣言されている可能性があります。
2 つの別々のスレッドから malloc を呼び出そうとすると (C 標準で保証されていないスレッドセーフ バージョンを使用している場合を除きます)、2 つのスレッドに対して 1 つのヒープしかないため、悪いことが起こります。printf についても同様です。動作は未定義です。それが、実際には再入不可にする理由です。