いくつかのプログラミング言語で作業してきたので、必要に応じて自動的に拡張するのではなく、なぜスレッドスタックに事前定義された最大サイズがあるのかといつも疑問に思っていました。
対照的に、ほとんどのプログラミング言語で見られる非常に一般的な高レベルの構造(リスト、マップなど)は、新しい要素が追加されている間、必要に応じて大きくなるように設計されており、使用可能なメモリまたは計算制限(たとえば、32ビットアドレス指定)。
デフォルトのスタックオプションやコンパイラオプションによって最大スタックサイズが事前に制限されていないプログラミング言語やランタイム環境については知りません。これが、プロセスに利用可能なメモリの最小のパーセンテージだけがスタックに使用されている場合でも、あまりにも多くの再帰が、ユビキタスなスタックオーバーフローエラー/例外を非常に迅速にもたらす理由です。
ほとんどの(すべてではないにしても)ランタイム環境が、実行時にスタックが大きくなる可能性があるサイズに最大制限を設定しているのはなぜですか?
スタックがアドレス空間で連続している必要がないオペレーティングシステムを作成することは可能です。基本的に、次のことを確実にするために、呼び出し規約でいくつかの追加の混乱が必要です。
現在のスタックエクステントに、呼び出す関数の十分なスペースがない場合は、新しいスタックエクステントを作成し、呼び出しの一部としてスタックポインターを移動して、その先頭を指すようにします。
その呼び出しから戻ると、元のスタックエクステントに戻ります。ほとんどの場合、同じスレッドで将来使用するために、(1)で作成したものを保持します。原則として解放することもできますが、ループの境界を行き来するループが頻繁に発生し、すべての呼び出しでメモリ割り当てが必要になるという、かなり非効率的なケースです。
setjmp
とlongjmp
、またはローカルではない制御の移行のためのOSと同等のものが動作中であり、必要に応じて古いスタックエクステントに正しく移動できます。
「呼び出し規約」と言います。具体的には、呼び出し元ではなく関数プロローグで行うのがおそらく最善だと思いますが、私の記憶はかすんでいます。
かなりの数の言語がスレッドの固定スタックサイズを指定している理由は、ネイティブスタックを使用してできない これを行う。他の皆の答えが言うように、各スタックはアドレス空間で隣接している必要があり、移動できないという前提の下で、各スレッドが使用する特定のアドレス範囲を予約する必要があります。それは前もってサイズを選ぶことを意味します。アドレススペースが大きく、選択したサイズが本当に大きい場合でも、2つのスレッドが作成されたらすぐに選択する必要があります。
「あは」とあなたは言う。「非連続スタックを使用するこれらの想定されるOSは何ですか?それは私には役に立たない、あいまいな学術システムだと思います!」さて、 それは別の質問です 幸いにも既に質問され、回答されています。
これらのデータ構造には、通常、OSスタックにはないプロパティがあります。
リンクされたリストは、連続したアドレス空間を必要としません。そのため、成長したときにどこからでもメモリを追加できます。
C++のベクターのように、連続したストレージを必要とするコレクションでさえ、OSスタックよりも優れています。すべてのポインター/イテレーターが成長するたびに無効と宣言できるからです。一方、OSスタックは、ターゲットが属するフレームの関数が戻るまで、スタックへのポインタを有効に保つ必要があります。
プログラミング言語またはランタイムは、OSスタックの制限を回避するために非連続または移動可能な独自のスタックを実装することを選択できます。 Golangは、このようなカスタムスタックを使用して、非常に多くのコルーチンをサポートします。これは、元々非連続メモリとして実装され、現在はポインター追跡のおかげで可動スタックを介しています(hobbのコメントを参照)。スタックレスpython、Lua、Erlangもカスタムスタックを使用する可能性がありますが、確認しませんでした。
64ビットシステムでは、アドレススペースが十分にあり、物理メモリは実際に使用するときにのみ割り当てられるため、比較的大きなスタックを比較的低いコストで構成できます。
実際には、スタックを拡張することは困難(場合によっては不可能)です。理由を理解するには、仮想メモリをある程度理解する必要があります。
シングルスレッドアプリケーションと連続メモリのかつての時代では、3つがプロセスアドレス空間の3つのコンポーネントでした。コード、ヒープ、スタックです。これら3つがどのように配置されるかはOSに依存しますが、一般に、最初にコードがメモリの最下部から始まり、ヒープが次に来て上向きに成長し、スタックがメモリの最上部から始まり、下向きに成長しました。また、オペレーティングシステム用に予約されているメモリもありましたが、無視してかまいません。当時のプログラムでは、スタックオーバーフローがさらに劇的になりました。スタックはヒープにクラッシュし、最初に更新されたものに応じて、不良データを処理するか、サブルーチンからメモリの任意の部分に戻ります。
メモリ管理はこのモデルを多少変更しました。プログラムの観点からは、プロセスメモリマップの3つのコンポーネントがあり、それらは一般的に同じように編成されていましたが、現在、各コンポーネントは独立したセグメントとして管理され、MMUは、プログラムがセグメント外のメモリにアクセスしようとした場合にOSに信号を送信します。仮想メモリを取得すると、プログラムにアドレス空間全体へのアクセス権を与える必要はありませんまたは欲望 。したがって、セグメントには固定境界が割り当てられました。
それでは、プログラムに完全なアドレス空間へのアクセスを与えることがなぜ望ましいのではないのでしょうか。そのメモリは、スワップに対する「コミットチャージ」を構成するためです。いつでも、あるプログラムのメモリの一部またはすべてをスワップして、別のプログラムのメモリ用のスペースを確保する必要がある場合があります。すべてのプログラムが2GBのスワップを潜在的に消費する可能性がある場合は、すべてのプログラムに十分なスワップを提供するか、2つのプログラムが取得できる以上の容量を必要とする可能性を考慮する必要があります。
この時点で、十分な仮想アドレス空間を想定して、could必要に応じてこれらのセグメントを拡張すると、データセグメント(ヒープ)は実際に時間とともに増大します。小さなデータセグメントから始めて、メモリアロケータは、必要に応じてより多くのスペースを要求します。この時点では、単一のスタックでは、スタックセグメントを拡張することが物理的に可能でした。OSは、セグメントの外側に何かをプッシュする試みをトラップし、メモリを追加できます。しかし、これも特に望ましいことではありません。
マルチスレッドに入ります。この場合、各スレッドには独立したスタックセグメントがあり、サイズは固定されています。しかし、今やセグメントは仮想アドレス空間に次々と配置されているので、別のセグメントを移動せずに1つのセグメントを拡張する方法はありません-プログラムはスタックにあるメモリへのポインターを潜在的に持っているため、これを行うことはできません。あるいは、セグメント間にスペースを残すこともできますが、そのスペースはほとんどすべての場合で無駄になります。より良いアプローチは、アプリケーション開発者に負担をかけることでした。深いスタックが本当に必要な場合は、スレッドを作成するときにそれを指定できます。
今日では、64ビットの仮想アドレス空間を使用して、事実上無限のスレッドに対して事実上無限のスタックを作成できました。ただし、これは特に望ましくありません。ほとんどすべての場合、スタックが低すぎると、コードにバグがあることを示します。 1 GBのスタックを提供しても、そのバグの発見は延期されます。
最大サイズが固定されたスタックは、ユビキタスではありません。
また、スタックの深さはべき乗則の分布に従うため、正しく計算することも困難です。つまり、スタックサイズを小さくしても、スタックがさらに小さい場合でも、関数のかなりの部分が存在することになります(したがって、スペースを浪費します)。どれだけ大きくしても、スタックはさらに大きい関数が存在します(エラーのない関数にはスタックオーバーフローエラーを強制します)。言い換えると、どのサイズを選択しても、同時に小さすぎても大きすぎます。
スタックを小さく開始して動的に拡張できるようにすることで最初の問題を解決できますが、それでも2番目の問題が発生します。とにかくスタックを動的に拡張できるようにする場合、なぜそれに任意の制限を課すのでしょうか。
スタックが動的に拡張でき、最大サイズがないシステムもあります。たとえば、Erlang、Go、Smalltalk、Schemeなどです。そのようなものを実装する方法はたくさんあります:
強力な非ローカルの制御フロー構成体があるとすぐに、単一の連続したスタックのアイデアはとにかくウィンドウの外に出ます。たとえば、再開可能な例外と継続は、スタックを「フォーク」するので、実際にはネットワークになりますスタックの数(スパゲッティスタックで実装されているなど)。また、Smalltalkなどのファーストクラスの変更可能なスタックを備えたシステムでは、スパゲッティスタックなどが必要になります。
スタックが要求されると、OSは連続したブロックを提供する必要があります。それを行うことができる唯一の方法は、最大サイズが指定されている場合です。
たとえば、リクエスト中にメモリが次のようになっているとします(Xは使用済み、Oは未使用を表します)。
XOOOXOOXOOOOOX
スタックサイズ6を要求した場合、OSの応答は6を超えても利用できません。サイズ3のスタックのリクエストの場合、OSの応答は、3つの空のスロット(O)の領域の1つになります。
また、次の連続したスロットが占有されている場合、拡張を許可することの難しさを確認できます。
言及されている他のオブジェクト(リストなど)はスタックに移動せず、ヒープが隣接しない領域または断片化された領域に到達するため、成長するときにスペースを取得するだけで、隣接する必要はありません。別の方法で管理しました。
ほとんどのシステムはスタックサイズに適切な値を設定します。より大きなサイズが必要な場合は、スレッドの構築時にオーバーライドできます。
Linuxでは、これは純粋にリソースの制限であり、暴走したプロセスが有害な量のリソースを消費する前に強制終了します。私のdebianシステムでは、次のコード
#include <sys/resource.h>
#include <stdio.h>
int main() {
struct rlimit limits;
getrlimit(RLIMIT_STACK, &limits);
printf(" soft limit = 0x%016lx\n", limits.rlim_cur);
printf(" hard limit = 0x%016lx\n", limits.rlim_max);
printf("RLIM_INFINITY = 0x%016lx\n", RLIM_INFINITY);
}
出力を生成します
soft limit = 0x0000000000800000
hard limit = 0xffffffffffffffff
RLIM_INFINITY = 0xffffffffffffffff
ハード制限がRLIM_INFINITY
に設定されていることに注意してください。プロセスはそのソフト制限をany量に上げることが許可されています。ただし、プログラマがプログラムに本当に異常な量のスタックメモリが必要であると信じる理由がない限り、プロセスがスタックサイズ8メビバイトを超えるとプロセスは強制終了されます。
この制限により、暴走したプロセス(意図しない無限再帰)が大量のメモリを消費し始める前に長時間強制終了され、システムが強制的にスワップを開始します。これにより、クラッシュしたプロセスとサーバーがクラッシュする可能性があります。ただし、大きなスタックを正当に必要とするプログラムを制限するのではなく、ソフト制限を適切な値に設定するだけです。
技術的には、スタックは動的に増大します。ソフト制限が8メビバイトに設定されている場合でも、この量のメモリが実際にまだマップされているわけではありません。ほとんどのプログラムがそれぞれのソフト制限に近づくことはないため、これはかなり無駄になります。むしろ、カーネルはスタックの下のアクセスを検出し、必要に応じてメモリページにマップします。したがって、スタックサイズの唯一の実際の制限は、64ビットシステムで使用可能なメモリです(アドレススペースの断片化は、16ゼビバイトのアドレススペースサイズを使用した場合の理論的です)。
maximumスタックサイズは静的です( "definition of(maximum"。あらゆるものに対するあらゆる種類の最大値は、固定され、合意された制限値です。自発的に移動するターゲットとして動作する場合、最大ではありません。
仮想メモリオペレーティングシステムのスタックは、実際には動的に成長し、最大までです。
そういえば、静的である必要はありません。むしろ、プロセスごとまたはスレッドごとに構成することもできます。
質問が「なぜは最大スタックサイズがあるのですか」(人為的に課されたサイズ、通常は使用可能なメモリよりもはるかに少ない)ですか?
1つの理由は、ほとんどのアルゴリズムが大量のスタックスペースを必要としないことです。大きなスタックは、可能性のある暴走再帰を示しています。使用可能なすべてのメモリを割り当てる前に、暴走再帰を停止することをお勧めします。暴走再帰のように見える問題は、おそらく予期しないテストケースによって引き起こされた縮退したスタックの使用です。たとえば、バイナリのパーサー、中置演算子が右側のオペランドを再帰的に処理するとします。最初のオペランドの解析、スキャン演算子、式の残りの部分を解析します。これは、スタックの深さが式の長さに比例することを意味します:a op b op c op d ...
。このフォームの巨大なテストケースには、巨大なスタックが必要になります。適切なスタック制限に達したときにプログラムを中止すると、これをキャッチできます。
最大スタックサイズが固定されているもう1つの理由は、そのスタックの仮想スペースが特別な種類のマッピングを介して予約され、保証されるためです。保証は、スペースが別の割り当てに割り当てられず、スタックが制限に達する前に割り当てられることを意味します。このマッピングを要求するには、最大スタックサイズパラメータが必要です。
これと同様の理由で、スレッドには最大スタックサイズが必要です。それらのスタックは動的に作成され、何かと衝突した場合は移動できません。仮想スペースは事前に予約する必要があり、その割り当てにはサイズが必要です。