2012/11/26

コマンドプロンプトアプリケーションにおける終了時処理について

コマンドプロンプトアプリケーションにおける終了方法は、主に2つあります。

1つは、アプリケーションがプロセスを終え、自ら終了する場合。
もう一つは、ユーザが窓を閉じたり強制終了した場合です。

前者は普通、しっかり後処理されるプログラムであれば、正常に終了します。
問題なのは後者です。

プレイヤーが窓を閉じたり強制終了するというのは、割り込み処理であり、
本筋のプログラム実行中に、強制的に、
突然発生するものであるという事を念頭に置かなければなりません。


具体的な話として、
最近流行り(?)のオートセーブ機能付きのゲームを実現したいとします。
ここではゲーム終了時にゲームデータを保存します。

適当ですがこんな感じで書きました。(雰囲気です)
=============================================
int main(int argc, char* argv[])
{
  int ret = 0;

  // 初期化処理
  if(!GameInit()) {
    /* 何らかのエラー処理 */
    ret = -1;
  } else {
    // 実行処理(ゲームループ)
    if(!GameRunLoop()) {
      /* 実行時エラー */
      ret = -2;
    } else { 
      // データ保存処理
      GameSave();
    }
    // 終了時処理
    GameEnd();
  }

  return ret;
}
=============================================
ゲームループを抜けると、
実行中に異常検知さえなければ必ずデータ保存処理が実行されるため、
終了時にはオートセーブされるといった具合の作りです。
このソースだけ見ても特に大きな問題はないと思います。

問題は、ゲームループ中に窓を閉じられた場合です。
というよりこの作りからして、
初期化処理、保存処理、終了時処理は一瞬で終わるとした場合、
プレイヤーが窓を閉じるタイミングは、ほぼ100%ゲームループ中です。
そこで割り込みが発生するとどうなるでしょうか?

結論から言って上記の場合、以下のような悲惨な流れになります。
1.GameInit <= 実施
2.GameRunLoop <= 実施中に強制停止
3.GameSave <= 実施されず(頑張ったゲームデータが死亡)
4.GameEnd <= 実施されず(解放漏れ。最悪何かのハンドルを掴んだまま)

勿論意図するところとしては、1〜4本来全てを実施させたいのですが、
コンソールアプリケーションではこういう事が普通に起こります。

基本的にGUIでも同じことは言えますが、
GUIの場合、そもそもIDEを使用していることがほぼ当たり前で、
そういったIDEが終了処理を記述すべき場所を最初から提供していたりして、
注意不足でない限り、間違いは起こらないようになっています。

CUIはそういったものは用意されておらず、
つまり、最初から自分で意図して処理せねばなりません。


それほど詳細に調べ上げてた訳ではありませんが、
問題の内容をもう少し掘り下げてみます。

コマンドプロンプト(cmd.exe)に限った話をしますと、
まず、「閉じるボタン」ですが、
これは全てのスレッドを強制的に停止する悪魔のようなボタンです。
このボタンが押されると、割り込み要求を掛け、
その時点でプログラムはメインスレッドを停止しています。
割り込みスレッドをにて何らかの処理を挟み込まない限りなにも処理できません。

じゃあその割り込みスレッドに終了処理を挟み込めばいいと思うのが普通ですが、
しかし、簡単にはいきません。
それは、もうひとつの終了方法があるためです。

そのもうひとつの終了方法が「強制終了」です。
Ctrl + Alt + Delでタスクマネージャを起動してから良くやるアレですね。
強制終了を行うと、実は閉じるボタンとは違う動きをします。
そこが大きな問題になります。

強制終了ではメインスレッドに対して、終了要求をかけているだけです。
そこから一定の時間が経ってプログラムが自主的に終了しない場合、
ユーザに終了確認を取ります。ダイアログのアレです。
そこでユーザーが許可して、初めて全スレッドを停止します。

強制終了は閉じるボタンとは違い
タスクマネージャから強制終了をかけた瞬間に割り込み要求が発生します。
しかも、メインスレッドは割り込まれているだけで、停止はしていません。
そこが閉じるボタンとは全く違っていて、
つまり、割り込みスレッドが発生した後、
メインスレッドに割り込んだ位置へとプログラムカウンタを復帰し、
出来る限り処理を続行しようと努めます

その後問題なくプログラムが動き、ユーザーが強制終了を承認しない限り、
閉じるボタンのように本当に途中で強制終了することはありません。


正しい終了時処理前提として、システムの割り込みスレッドを捕捉し、
そこへユーザー処理を挟みこむ必要があるというのは確かですが、
以上のような「閉じるボタン」と「強制終了」の違いを理解していなければなりません


前置きが長くなりましたが、
この割り込みスレッドの中でユーザ定義の処理を挟み込むには、
以下のようにハンドラ関数をWindowsに事前通知しておく必要があります。
=============================================
#include <windows.h>

// 割り込みによるゲーム終了要求フラグ
static bool g_InterruptEndReq = false; 

int main(int argc, char* argv[])
{
  int ret = 0;

  // ハンドラ関数を登録
  SetConsoleCtrlHandler(HandlerRoutine, TRUE);

  // 初期化処理
  if(!GameInit()) {
    /* 何らかのエラー処理 */
    ret = -1;
  } else {
    // 実行処理(ゲームループ)
    // (内部ループ条件でg_InterruptEndReq == false判定)
    if(!GameRunLoop()) {
      /* 実行時エラー */
      ret = -2;
    } else { 
      // データ保存処理
      GameSave();
    }
    // 終了時処理
    GameEnd();
  }

  return ret;
}

// ハンドラ関数
BOOL WINAPI HandlerRoutine(DWORD dwCtrlType)
{
  BOOL ret = FASLE;

  // ウィンドウの終了イベント検出時
  // ※「閉じるボタン」でも、「強制終了」でも同じフラグ
  if(dwCtrlType == CTRL_CLOSE_EVENT) {
    // 割り込みによるゲーム終了フラグを立てる
    g_InterruptEndReq = true;
    ret = TRUE; // ハンドラ関数をの連続呼び出しを終了する
  }
  return ret;
}
=============================================
あくまで雰囲気ソースですが、
このようにすると、Windowsはコマンドプロンプトを閉じようとした場合、
ハンドラ関数へ特定のイベント処理をコールバックします。
そこで終了要求のフラグを設定し、
ゲームを終了しなければならないことを、ゲームループに通知します。

察しのよい方はお気づきかもしれませんが、
先ほどまでの説明の通り行くと、
このプログラムでは「閉じるボタン」を押された際には終了処理が発生しません。
そのままプログラムは落ちていしまいます。
なぜなら「閉じるボタン」からのはハンドラ関数終了時には、
メインスレッドが死んでおり、戻る場所がないからです。

しかし、こうしなければならない理由があります。

「強制終了」の場合、ハンドラ関数が呼び出された時点では、
メインスレッドの終了が確定していないため、
ハンドラ関数終了後、割り込みが発生したプログラムカウンタ位置に戻ります

つまりどういう事かというと、
ハンドラ関数で直接終了処理を行ってしまった場合
その後のメインスレッドの処理では当然復帰した位置から処理を継続しようとするため
終了処理にて破壊されたアドレスやら変数やらを使用することになってしまうのです。

ですから、コマンドプロンプトのゲームに関して言うなら、
ハンドラ関数で投げて良いのは終了要求のみだということになります。


また、上記ソースのコメントにもあるように、
ハンドラ関数では「閉じるボタン」か「強制終了」かを区別せず、
両方ともウィンドウを閉じるというイベントとして通知されてきます
そのために、ウィンドウの終了方法についての判定は出来ません。

ですので、ハンドラ関数で終了処理をしてしまうと、
「閉じるボタン」は良いかもしれませんが、
「強制終了」時は、ほぼ確実にメモリアクセス違反を起こします。


では「閉じるボタン」の処置はどうするか?

たどり着いた答えとしては、
閉じるボタンを無効にする意外に安全な道は無いということです。

あくまでここでつくろうとしているのはゲームですから、
幸い、ゲーム内メニューから終了することや、
ESCキーで終了するような動作はおおよそ一般的に考えて良いと思います。


そういうわけで、
=============================================
// 閉じるボタンを無効化
HMENU hmenu = GetSystemMenu(hConsole, FALSE);
RemoveMenu(hmenu, SC_CLOSE, MF_BYCOMMAND);
=============================================
として、メニューから閉じるボタンを破棄します。


(2016/01/02 コメントを頂き訂正)
なお、コマンドプロンプトウィンドウのハンドル(上記hConsole)を得る為
直接的なAPIは存在しませんので、以下のように取得します。
以下のAPIを呼び出します。
=============================================
HWND APIENTRY GetConsoleWindow(VOID)
=============================================


古い方法では、以下のような関数を定義・利用することで取得可能です。
(MS公式の資料に基づく取得方法なので、
 恐らくは上記のAPIも同様の動きかと思います。)
=============================================
HWND GetConsoleWindowHandle()
{
  HWND hwnd;
  char newTitle[1024]; // ハンドル取得用ウィンドウタイトル
  char oldTitle[1024]; // オリジナルウィンドウタイトル

  // 現在のウィンドウタイトルを取得
  GetConsoleTitle(oldTitle, sizeof oldTitle);

  // ユニークなタイトルに変更し、ウィンドウハンドルを特定
  sprintf(newTitle, "%d/%d\0", GetTickCount(), GetCurrentProcessId());
  SetConsoleTitle(newTitle);
  Sleep(40); // 誤作動防止に必要
  hwnd = FindWindow(NULL, newTitle);

  // タイトルを戻す
  SetConsoleTitle(oldTitle);

  return hwnd;
}
=============================================

これをアプリケーションの初期化時に組み込むことで、
閉じるボタンは無効となります。
つまりはハンドラ関数は強制終了時にのみ対応すれば良く、
閉じるボタンとの動作の違いに悩まされることは無くなります。


長くなりましたが、以上の仕組みによって、
コマンドプロンプトのゲームの終了時処理を※正しく動作させることが出来ます。


(※ただし、FPSを採用していない(キー入力待ちが発生する)ゲームでは、
 キー入力待ちが終わらない限り、この仕組みでは、
 必ずしもゲームを即時、正常終了させることは出来ません。
 これは当然のことで、キー入力待ちの処理を標準関数やAPI側で処理すると、
 自分では終了タイミングを制御できないからです。
 そこで、コンソールウィンドウをアクティブにして、
 そこへ無理矢理SendInputなどをしてみましたが動作しませんでした。
 これについては暇があればもう少し調査したいですが、
 FPS制御を導入すれば、必然的にハンドラ関数で出した終了要求が
 ゲームループで検知されて、終了処理が走るため、
 簡単に解決してしまう話なので特に対策不要と考えています。)

2 件のコメント:

  1. これでも動作しました

    呼び出し元プロセスに関連付けられているコンソールが使用するウィンドウハンドルを取得します。
    HWND WINAPI GetConsoleWindow(void);

    返信削除
    返信
    1. ありがうございます。
      WINAPI標準にこんなものがあったんですね!
      動作確認しました。

      有難うございます!

      削除