開発時に起こしたスタックオーバーフローの思い出の話

スタックオーバーフローエラーをご存知だろうか。関数を呼び出す際にスタックメモリが足りなくなってプログラムが死んでしまうアレである。Wikipediaによると、このエラーで一番多い原因は「再帰呼び出しの階層が深すぎる(or 無限ループ)によるメモリの枯渇」で、その次に多いのは「関数内に巨大な配列を配置してしまうことによるメモリの枯渇」だそうだ。

関数呼び出しの階層が深すぎた結果スタックメモリが枯渇するエラーに関しては、再帰関数を組んだ経験のある人なら一度は踏んでしまう地雷だろう。私も競技プログラミングの練習問題などで再帰処理を組む際、終了条件をミスったりBruteForce(総当たり)なアルゴリズムを書いては何度もこの地雷を踏み抜いてきた。
しかし、私はスタックオーバーフローと聞くと、2番目の原因である「巨大な配列を配置したためにスタックメモリが枯渇するエラー」のことを思い出す。それはこの不具合によって「知識というものは本質を理解していないといざという時に役に立たない」ということを思い知らされたからだ。
今回はこの思い出について話そうと思う。

当時私は新卒入社2年目の会社で、ある携帯ゲーム機のゲームソフト開発に携わっていた。そのゲームソフトは続編物で、仲間にできるキャラは前作に比べると大幅に増える予定だった。しかし続編制作当初は最大何体増えるかは確定しておらず、開発中のプログラム上は前作の仲間の最大数で定数を定めていた。しかし開発終盤に差し掛かりリリースに向けてより本格的なデバッグが開始し、不確定だった仲間数の仕様もようやくFIXした。それから暫く後に、私が担当していた処理で原因不明のアプリ強制終了不具合がテスターから挙がってきた。
アプリ強制終了不具合とは、ゲームを作る上で致命的な不具合の一つだ。なぜならユーザーが数時間かけてプレイした進捗を一瞬で無に帰す可能性があるからだ。また、デバッグ作業においても確認作業がそこで滞り、開発フローそのものに支障をきたすため、私は急いでこの問題の原因を突き止める必要に迫られた。
実際にプログラムが落ちた関数の最終コミットは数ヶ月前で、その間は誰も変更した形跡はなく、「それではその前の処理が怪しいのでは?」と踏んで調べてみるものの不穏な箇所は見当たらない。実際に処理が止まる関数の該当行は、変数を宣言しているだけで当時の私には何が原因なのかさっぱりわからなかった。

■MemberStatus.cpp

// メンバーステータス計算
void CalcMemberStatus()
{
    MemberStatus status[MAX_MEMBER_NUM]; // ある日、ここで処理が死亡するようになった
    
    // 以下、計算処理
}

プロジェクトでは開発機とは別にWindows上でエミューレートできる環境を用意しており、Windows環境の方がブレークポイントを貼ったりなどの調査しやすいため、私は専らWindows環境で不具合調査をしていた。しかし、その不具合や厄介なことに開発機でのみ発生する環境依存な不具合だった。経験の浅い私はこの時点で原因が特定できず、お手上げ状態だった。

そこで当時エンジニアリーダーだった方に相談し、コールスタックや逆アセンブリレベルで確認してもらったところ、たちどころに問題の原因を突き止めてくれた。問題の直接の原因は、進行不能が発生し始める前日に他のエンジニアが最終仕様とずれがあった仲間の最大数定数の中身を、仕様に合わせて増やす形で書き換えていたことだった。結果としてCalcMemberStatus関数内のローカル変数statusのサイズも大きくなり、前作ではスタックメモリに載せられた処理が、今作ではスタックメモリが足りなくなってしまっていたのだ。

■Const.cpp

const int MAX_MEMBER_NUM = 300; // ← この数字が大幅に増えてた

■MemberStatus.cpp

// メンバーステータス計算
void CalcMemberStatus()
{
    MemberStatus status[MAX_MEMBER_NUM]; // 関数のサイズが大きくなりメモリに積めず死亡
    
    // 以下、計算処理
}

Windows環境では再現しなかったのも原因さえわかってしまえば簡単で、単純に開発機よりWindows環境のほうがスタックメモリが潤沢にあるというだけだった(逆に言うと開発機のスタックメモリはとても儚いメモリ量だったのだ…)
当時の開発ではSVNによるソースコード管理をしており、実は私が調べた際もこの定数の変更は履歴を見て把握はしていた。しかし当時の私は「スタックオーバーフローは呼び出しの階層が深くて起きる」くらいの認識しか持っていなかったため、この変更を直接問題と結びつけることができなかった。実際にエンジニアリーダーから調査結果を教えてもらっても、それを聞いた直後はあまりピンと来ていなかった記憶がある。
最終的にこのstatus配列変数をローカルで配列宣言するのではなく、mallocによる動的な割当を行うことで問題は解決された。

この不具合自体にはとても苦しめられたが、同時にプログラムというものが如何にメモリをやりくりして動いているかというのを体感して理解でき、自分にとっては気付きの多い不具合でもあった。
スタックオーバーフローそのものは知識としては知っており、実際に何度か遭遇した経験もあった。しかし「呼び出し階層が深すぎるから起きるエラー」くらいの認識しかなく、その結果生じる「スタックメモリの枯渇」という事象にまでは考えが至っていなかった。また、Windowsと開発機での再現性の違いも「環境によってスタックメモリに違いがある」ということも体験でき、個人的には興味深い経験だった。

プログラムをしている上で遭遇するエラーというものは、出会わないで済むならそれに越したことはないし、むしろ率先して遭遇したくないものだ。しかし、エラーを起こしたからこそ学べることも多い。
特に私にとってこの経験は「きちんと理屈を理解していないとエラーに遭遇した時に手も足も出ない」ということを教えられた点で、今なお思い出深い経験として心に強く残っている。

この記事が気に入ったらサポートをしてみませんか?