ryo’s blog

日々学んだことをまとめています.

第8章 例外的な制御フロー

8章について
この章では、制御フローについて学んでいきます。
例外的な制御フローについて学ぶことで、アプリケーションがどのようにオペレーティング・システムと相互作用するかを理解しやすくなります。
また try, catch ,throw などの構文が理解しやすくなるそうです。
以下まとめです。


例外的な制御フローって?

制御転送 : プロセッサに電源を入れてから切るまでの、プログラム・カウンタの遷移のこと。
制御フロー : 連続する制御転送のこと。

制御フローに対して突発的な変更を行うことで、さまざまな状況に反応する。
この突発的な変更のことを例外的な制御フローとよぶ。

例外

例外ハンドラ

プロセッサはイベントの発生を検出すると、例外テーブルとよばれるジャンプ・テーブルを介して、例外ハンドラをよぶ。
例外ハンドラが処理を終了すると、種類に応じて以下のどれかが起きる。

  1. 制御を現在の命令に戻す。
  2. 例外が起きなければ次に実行されていたはずの命令に戻す。
  3. ブログラムを中断させる。


例外のそれぞれには、例外番号が割り当てられている。
システムの起動時に、オペレーティング・システムは例外テーブルを確保し初期化する。
例外番号はその例外テーブルのインデクスとして使われる。

例外の4つのクラス

  1. 割り込み : ハードウェア割り込みに対する例外ハンドラは、割り込みハンドラとよばれる。
  2. トラップ : 命令実行の結果として発生する意図的な例外。read, fork, exitなどのシステムコールのこと。
  3. フォールト : エラーを訂正できる場合は、命令に制御を戻すが訂正できない場合は、abort ルーチンに制御を移す。
  4. アボート : アボートは回復不能なエラーによって起きる。プログラムを強制終了する。


Linux/x86-64 システムにおける例外

x86-64 では 最大で 256 個までのタイプの例外がある。

  • 除算エラー : ゼロ除算か、除算結果がオーバーフローした場合に発生する。

  • 一般保護フォールト : プログラムが仮想メモリ上の未定義領域を参照した場合や、読み出し専用のセグメントに書き込もうとした場合に起きる。

  • ページ・フォールト : ディスク上の適切な仮想メモリのページを物理メモリのベージにマップし、その後にファールディング命令を再開する。

  • マシン・チェック : フォールティング命令の実行中に検出された致命的なハードウェア・エラーの結果として発生する。


プロセス

例外はプロセスという機能を実現するための基本的な仕組みである。
システム内の各プログラムは、いずれかのプロセスのコンテクストで実行される。

プログラムを実行するたびに、シェルは新しいプロセスを作りその新しいプロセスのコンテクストで実行可能ファイルを実行する。


3つのプロセスを実行する場合

制御フローは 1つしかないがプロセス 1つ 1つに分割され、3つの論理フローとなる。
プロセスはプロセッサを順番に使うので、他のプロセスの実行中の間一時停止している。


平行と並列

倫理フローは、例外ハンドラ、プロセス、シグナル・ハンドラ、スレッドなど様々な形で現れる。

マルチタスク : 他のプロセスと交代しながら実行される場合。
タイム・スライス : 一つのプロセスがフローの一部を連続して実行する期間のこと。

平行フロー : 2 つのフローが時間的にオーバーラップして実行されるとき。
並列フロー : 2つのフローが、異なるプロセッサ・コアあるいはコンピュータ上で実行されている場合。


コンテクスト・スイッチ

例外的制御フローを利用した仕組みのことで、マルチタスクを実装するのに使う。
カーネルはプロセスごとにコンテクストを管理している。

コンテクスト : 一時的に停止されたプロセスを再開するためにカーネルが必要とする状態のこと。

スケジューリング : プロセスが実行されているいずれかの時点で、カーネルは現在実行中のプロセスを一時停止し、以前停止したプロセスを再開すること。


システム・コールにおけるエラー・ハンドリング

エラー処理は省略されがちだが、必ず行うべき。
しかしそのまま書くとコードが膨らんでしまうので、ラッパー関数を作成してエラー処理を行う。

// これを毎回書くとコードが膨む
if ((pid = fork()) < 0) {
    fprintf(stderr, "Error: fork failed\n");
    exit(0);
}

pid = xfork(); // 失敗した場合にラッパー関数がエラー処理をする


プロセスの生成と終了

プロセスは 3つの状態のどれかとなる。

  1. 実行中状態 : 実行中、あるいは実行待ちで、やがてスケジュールされる。
  2. 停止状態 : 実行が停止しており、スケジュールされることがない状態。
  3. 終了状態 : プロセスが永久に停止した状態。


プロセス生成

fork 関数によって子プロセスを生成できる。
子プロセスは親プロセスが持つユーザ・レベルの仮想アドレス空間のコピーを持つ。
コード・セグメント、データ・セグメント、ヒープ、共有ライブラリ、ユーザ・スタックがコピーされる。


子プロセスの回収

終了してるが、回収されていないプロセスをゾンビという。
親プロセスが終了すると、孤児となった子プロセスを init プロセスが引き取るようカーネルが処理を行う。
長く動き続けるプログラムの場合、システムのメモリ資源を消費したままなので、wait関数でゾンビを回収する。


プログラムとプロセスの違い

プログラム : コードとデータの集まり。
プロセス : 実行中のプログラムのプログラムの実体として具体的に存在するもの。

プロセス・グループ

ls | sort というコマンドを実行した場合、この 2つのプロセスは同じプロセス・グループに属している。

Ctrl+C を入力すると、プロセス・グループに属するすべてのプロセスに対して、SIGINT を送信する。

# 一度のSIGINTで終了する
cat | cat | cat


シグナル

シグナル : 小さなメッセージのようなもので、何らかのイベントがシステム内で発生したことをプロセスに通知するもの。

それぞれのシグナルは何らかのシステム・イベントに対応している。
またシグナル・ハンドラを用いて、シグナルをキャッチできる。

ペンディング・シグナル

送信されたもののまだ受信されていないシグナルのこと。
あるシグナル番号に対して一つしか持たないので、それ以降送られてきた場合は捨てられる。

ペンディング・シグナルの集合を pending というビット・ベクタに保持している。
ブロックされているシグナルの集合は blocked というビット・ベクタに保持している。


シグナル・ハンドラの書き方

  1. ハンドラはフラグをセットするだけにするなどシンプルにする。

  2. 非同期シグナル・セーフ関数だけをよぶ。

  3. errno が書き換わらないようにする。

     void sig_handler(int sig)
     {
         int tmp = errno;
         sleep(1); // errnoが書き換わる
         errno = tmp; // もとに戻す
     }
    
  4. データ構造へのアクセスを保護するためシグナルをブロックする。

     // keyの書き換え中に割り込みが発生するとvalueが変えられない
     struct t_dict {
         char *key;
         char *value;
     };
    
  5. グローバル変数はvolatileで宣言して、変数の値をキャッシュさせないようにする。

  6. sig_atomic_tで宣言して、 読み書きが1 命令で実装されているためアトミックであることを保証する。


非局所的ジャンプ

C では非局所的ジャンプというユーザ・レベルの例外的制御フローが提供されている。
ある関数から他の関数に直接飛び込むことができる、setjmpとlongjmpがある。
try catch throw の仕組みに似ている。

try : 例外を監視するブロックを宣言する。
catch : try ブロックで例外が発生した場合、ブロックを実行する。setjmp 関数に似ている。
throw : 例外を発生させる。longjmp 関数に似ている。


感想

以前簡易的なbashの再実装をしたことがあるので、概念的には知っていることがたくさんありました。
ですがその時に知らなかったような仕組みや、より安全なコードの書き方など学ぶことができたので良かったです。
C言語でもジャンプを利用して try catch のような仕組みができるようなので、試してみようと思います。