私が見たほとんどのアーキテクチャは、関数呼び出しの前にコンテキストを保存/復元するために呼び出しスタックに依存しています。プッシュおよびポップ操作がほとんどのプロセッサに組み込まれているのは非常に一般的なパラダイムです。スタックなしで動作するシステムはありますか?もしそうなら、それらはどのように機能し、何のために使用されますか?
私が見たほとんどのアーキテクチャは、関数呼び出しの前にコンテキストを保存/復元するために呼び出しスタックに依存しています。プッシュおよびポップ操作がほとんどのプロセッサに組み込まれているのは非常に一般的なパラダイムです。スタックなしで動作するシステムはありますか?もしそうなら、それらはどのように機能し、何のために使用されますか?
回答:
呼び出しスタックの(ある程度)人気のある代替手段は、継続です。
たとえば、Parrot VMは継続ベースです。データは完全にスタックレスです:データはレジスタに保持され(DalvikやLuaVMなど、Parrotはレジスタベース)、制御フローは継続で表されます(コールスタックを持つDalvikやLuaVMとは異なります)。
SmalltalkおよびLisp VMで一般的に使用されるもう1つの一般的なデータ構造は、スパゲッティスタックです。これは、スタックのネットワークのようなものです。
以下のよう@rwongが指摘し、継続渡しスタイルは、コールスタックに代わるものです。継続渡しスタイルで記述された(または変換された)プログラムは返されないため、スタックは必要ありません。
別の観点から質問に答える:スタックフレームをヒープに割り当てることにより、個別のスタックを持たずにコールスタックを持つことができます。一部のLispおよびScheme実装はこれを行います。
昔は、プロセッサにはスタック命令がなく、プログラミング言語は再帰をサポートしていませんでした。時間の経過とともに、再帰をサポートする言語が増え、スタックフレーム割り当て機能を備えたハードウェアスイートが続きます。このサポートは、プロセッサーが異なると、長年にわたって大きく異なります。一部のプロセッサは、スタックフレームおよび/またはスタックポインタレジスタを採用しています。いくつかの命令は、単一の命令でスタックフレームの割り当てを達成する命令を採用しました。
プロセッサが単一レベル、さらに複数レベルのキャッシュで進化するにつれて、スタックの重要な利点の1つはキャッシュの局所性です。スタックの最上部はほとんど常にキャッシュにあります。キャッシュヒット率の高い処理を実行できる場合はいつでも、最新のプロセッサを使用して正しい軌道に乗っています。スタックに適用されるキャッシュは、ローカル変数、パラメーターなどがほとんど常にキャッシュにあり、最高レベルのパフォーマンスを享受することを意味します。
つまり、スタックの使用はハードウェアとソフトウェアの両方で進化しました。他のモデルもあります(たとえば、データフローコンピューティングは長期間試行されました)が、スタックの局所性により、非常にうまく機能します。さらに、手続き型コードは、パフォーマンスのためにプロセッサが必要とするものです。1つの命令が次の命令を実行するよう指示します。命令の順序が線形ではない場合、ランダムアクセスをシーケンシャルアクセスほど高速にする方法が分からないため、プロセッサは少なくともまだ遅くなります。(ところで、キャッシュ、メインメモリ、ディスクなど、各メモリレベルで同様の問題があります...)
シーケンシャルアクセス命令の実証されたパフォーマンスとコールスタックの有益なキャッシュ動作との間には、少なくとも現時点では、優れたパフォーマンスモデルがあります。
(データ構造の可変性も同様に機能する可能性があります...)
これは、他のプログラミングモデルが機能しないことを意味するものではありません。特に、今日のハードウェアのシーケンシャル命令と呼び出しスタックモデルに変換できる場合はそうです。しかし、ハードウェアがどこにあるかをサポートするモデルには明確な利点があります。ただし、物事は常に同じであるとは限りません。そのため、異なるメモリおよびトランジスタテクノロジーにより多くの並列処理が可能になるため、将来的に変化が見られる可能性があります。それは、プログラミング言語とハードウェア機能との間のつまらないものです。
TL; DR
この回答の残りの部分は、考えや逸話のランダムなコレクションであり、そのため多少混乱しています。
(関数呼び出しメカニズムとして)説明したスタックは、命令型プログラミングに固有のものです。
命令型プログラミングの下には、マシンコードがあります。マシンコードは、命令の小さなシーケンスを実行することにより、コールスタックをエミュレートできます。
マシンコードの下には、ソフトウェアの実行を担当するハードウェアがあります。現代のマイクロプロセッサはここで説明するには複雑すぎますが、非常にシンプルな設計が存在し、遅いが同じマシンコードを実行できることが想像できます。このようなシンプルなデザインは、デジタルロジックの基本要素を利用します。
以下の議論には、命令型プログラムを構築する別の方法の例がたくさん含まれていました。
プログラムなどの構造は次のようになります。
void main(void)
{
do
{
// validate inputs for task 1
// execute task 1, inlined,
// must complete in a deterministically short amount of time
// and limited to a statically allocated amount of memory
// ...
// validate inputs for task 2
// execute task 2, inlined
// ...
// validate inputs for task N
// execute task N, inlined
}
while (true);
// if this line is reached, tell the programmers to prepare
// themselves to appear before an accident investigation board.
return 0;
}
このスタイルは、マイクロコントローラー、つまりソフトウェアをハードウェアの機能のコンパニオンと見なす人に適しています。
いいえ、必ずしもそうではありません。
Appelの古い論文のGarbage Collectionを読むと、Stack Allocationよりも速くなることがあります。継続渡しスタイルを使用し、スタックレス実装を示しています。
また、古いコンピューターアーキテクチャ(IBM / 360など)にはハードウェアスタックレジスタがなかったことにも注意してください。しかし、OSやコンパイラは、スタックポインタ用レジスタを予約大会(に関連する規則を呼び出し、彼らはソフトウェアの持つことができるように)コールスタックを。
原則として、プログラム全体のCコンパイラとオプティマイザは、呼び出しグラフが静的に認識され、再帰(または関数ポインタ)なしのケース(組み込みシステムにやや一般的)を検出できます。このようなシステムでは、各関数は戻りアドレスを固定された静的な場所に保持できます(それが1970年時代のコンピューターでのFortran77の動作でした)。
SUBROUTINE
してFUNCTION
。ただし、以前のバージョン(FORTRAN-IVおよび場合によってはWATFIV)については正しいです。
TR
とTRT
。
これまでにいくつかの良い答えがあります。スタックや「制御フロー」という概念をまったく使わずに言語を設計する方法の、実用的ではないが非常に教育的な例を挙げましょう。階乗を決定するプログラムは次のとおりです。
function f(i) => if i == 0 then 1 else i * f(i - 1)
let x = f(3)
このプログラムを文字列に入れ、テキスト置換によってプログラムを評価します。したがって、評価するときf(3)
、次のように検索を実行し、i for 3に置き換えます。
function f(i) => if i == 0 then 1 else i * f(i - 1)
let x = if 3 == 0 then 1 else 3 * f(3 - 1)
すばらしいです。次に、別のテキスト置換を実行します。「if」の条件がfalseであることがわかり、別の文字列置換を実行して、プログラムを生成します。
function f(i) => if i == 0 then 1 else i * f(i - 1)
let x = 3 * f(3 - 1)
ここで、定数を含むすべての部分式で別の文字列置換を実行します。
function f(i) => if i == 0 then 1 else i * f(i - 1)
let x = 3 * f(2)
そして、あなたはこれがどうなるかを見る。これ以上のことは言いません。私たちが着くまで、一連の文字列置換を続けてlet x = 6
、完了することができました。
私たちは伝統的にローカル変数と継続情報のためにスタックを使用します。覚えておいて、スタックはあなたがどこから来たのかを教えてくれず、その戻り値を手に入れて次にどこに行くのかを教えてくれます。
プログラミングの文字列置換モデルでは、スタックに「ローカル変数」はありません。関数が引数に適用されると、スタック上のルックアップテーブルに入れられるのではなく、仮パラメーターが値に置き換えられます。また、プログラムの評価は単純に文字列置換のルールを適用して、異なるが同等のプログラムを生成するため、「次へ進む」ことはありません。
もちろん、実際に文字列の置換を行うことはおそらく進むべき道ではありません。しかし、「等式推論」をサポートするプログラミング言語(Haskellなど)は、論理的にこの手法を使用しています。
システムをモジュールに分解する際に使用される基準に関する1972年のParnasの出版以来、ソフトウェアに隠された情報は良いことであると合理的に受け入れられてきました。これは、構造分解とモジュール式プログラミングに関する60年代の長い議論の結果です。
マルチスレッドシステムの異なるグループによって実装されるモジュール間のブラックボックス関係の必要な結果には、再入可能性を許可するメカニズムと、システムの動的なコールグラフを追跡する手段が必要です。制御された実行フローは、複数のモジュールに出入りする必要があります。
動的な動作を追跡するには語彙スコープが不十分であるとすぐに、違いを追跡するために実行時のブックキーピングが必要になります。
(定義上)スレッドに現在の命令ポインターが1つしかない場合、LIFOスタックは各呼び出しを追跡するのに適しています。
したがって、継続モデルはスタックのデータ構造を明示的に保持していませんが、どこかに保持する必要があるモジュールのネストされた呼び出しがまだあります!
宣言型言語でさえ、評価履歴を維持するか、逆にパフォーマンス上の理由で実行計画をフラット化し、他の方法で進捗を維持します。
rwongによって識別される無限ループ構造は、多くの一般的なプログラミング構造を許可しない静的スケジューリングを備えた高信頼性アプリケーションで一般的ですが、重要な情報を隠さないホワイトボックスと見なす必要があります。
複数の同時エンドレスループは、関数を呼び出さないため、リターンアドレスを保持する構造を必要としません。共有変数を使用して通信する場合、これらは簡単に旧式のFortranスタイルのリターンアドレス類似物に退化する可能性があります。
すべての古いメインフレーム(IBM System / 360)には、スタックという概念がまったくありませんでした。たとえば、260では、パラメーターはメモリ内の固定位置に構築され、サブルーチンが呼び出されると、R1
パラメーターブロックを指しR14
、リターンアドレスを含む状態で呼び出されました。呼び出されたルーチンは、別のサブルーチンを呼び出したい場合R14
、その呼び出しを行う前に既知の場所に保存する必要があります。
これは、スタックよりもはるかに信頼性が高いため、すべてはコンパイル時に確立された固定メモリの場所に保存でき、プロセスがスタックを使い果たすことがないことを100%保証できます。最近私たちがしなければならない「1MBを割り当てて指を交差させる」ことはありません。
キーワードを指定することにより、PL / Iで再帰的なサブルーチン呼び出しが許可されましたRECURSIVE
。つまり、サブルーチンで使用されるメモリは、静的に割り当てられるのではなく、動的に割り当てられます。しかし、再帰呼び出しは現在と同じくらいまれでした。
スタックレス操作により、大規模なマルチスレッド化がはるかに簡単になります。そのため、現代の言語をストークレスにしようとすることがよくあります。たとえば、スタックではなく動的に割り当てられたメモリを使用するようにC ++コンパイラをバックエンドで変更できなかった理由など、まったく理由はありません。