メモリリークのバグが入り込み易い2つのパターン

2022年5月20日バグの巣

メモリリークは動的メモリの解放漏れで起きる

メモリリークは動的メモリを使うアプリケーションソフトで、獲得した動的メモリの解放を忘れてしまい、OSが提供できる動的メモリの残量が減っていく事で発生します。メモリリークの起きる仕組みは別の記事で紹介しますので、この記事では動的メモリの解放漏れがどんな場面で起き易いのか、2つのパターンを例に紹介します。

アプリケーションソフトで動的メモリを使う時の処理の流れは、以下のような3段階です。

 ①必要な動的メモリをOSから獲得する(OSから動的メモリを借りる)

 ②獲得した動的メモリを使ってアプリケーションに必要な処理を実行する

 ③処理の実行が終わったら獲得していた動的メモリを解放する(OSに動的メモリを返す)

この③の動的メモリの解放を忘れるとメモリリークが起こります。ではどんな時に動的メモリの解放忘れが起きるのでしょうか? 組み込み系ソフトで動的メモリの解放を忘れ易い2つのパターンがあります、この記事ではその2つのパターンを紹介しますので、皆さんも動的メモリを使う時には注意してください。

動的メモリの解放を忘れてメモリリークを起こし易い2つのパターン

動的メモリの解放忘れによるメモリリークをデバッグしていくと、大抵は以下の2つのパターンのどちらかで起きています。この2つのパターンでの動的メモリの解放忘れを防止できると、メモリリークのバグはかなり減るでしょう。

パターン1:動的メモリの獲得と解放を1対にしたソースコードの中で、エラー処理で解放を忘れる

パターン2:動的メモリの獲得と解放を別々の処理に実装していて、解放を含む処理の実行を忘れる

パターン1は、1つの処理(関数など)の中で先頭で動的メモリを獲得し、最後で動的メモリを解放するというように、獲得を解放を一対にしておく事で、動的メモリの解放漏れが起きにくいように工夫をしたソースコードで、エラー処理の中で図らずも起こってしまう動的メモリの解放漏れです。

パターン2は、通信系の処理に多いのですが、送受信する電文を動的メモリに記憶しておき、複数の処理を受け渡しながら順次送受信の処理を進めていくような構造のソフトで起きてしまう動的メモリの解放漏れです。

どちらのパターンでも、最初にソースコードを開発した時には、動的メモリを使っている事を十分に注意してソースコードの作成やデバッグをしているので、解放漏れのバグが入りこむ事はあまりありません。しかし、機能追加やバグ修正などで後からソースコードを修正する時に、修正目的に注意が行きすぎて、動的メモリの解放の漏れを起こしてしまう場面がよくあります。

パターン1:エラー処理を追加・修正した時に起こるメモリ解放漏れ

動的メモリを使う時には、メモリの解放漏れが起きにくいように、獲得と解放を1対にして見やすくコーディングするという方法が良く使われます。疑似コードで書くと以下のような処理ですね。

 Sample_Crypt( input-list, output-list) {

  ① 必要なサイズの動的メモリを獲得してワーク領域に割り当て    /* 初期化処理、動的メモリの獲得はここのみ */

  ② ワーク領域を使って input-llist の内容を暗号化し output-list に出力    /* 処理の本体 */

  ③ ワーク領域に使っていた動的メモリを解放    /* 終了処理、動的メモリの解放はここのみ */

  }

Sample_Crypt() 処理の中で動的メモリの獲得を先頭の初期化処理で実施し動的メモリの解放を最後の終了処理で実施する事で、獲得と解放の呼び出し箇所をそれぞれ1か所に限定して、メモリの解放漏れを防いでいます。

このような構造のソースコードで、動的メモリの解放漏れのバグが入り込み易いのは、②の暗号化処理の中で、エラー処理を追加した場合です。

②の暗号化処理の中身を、少しだけブレークダウンしてみましょう。

   if ( iput-list の内容にエラーが無い)then {

           ・暗号化処理を実行し、output_list に暗号化の結果を出力;

    ・Return-status に SUCCESS をセット; 

               } else {

              ・Return-status に INPUT-ERROR をセット;

               }

最初の開発段階でこのようなソースコードを作ります、このソースコードにはまだ動的メモリの解放漏れバグありません。しかしテスト&デバッグを進める中で、暗号化処理で稀に特殊なエラーが起きる場合が見つかったので、以下のようなエラー処理コードを追加したと想定します。

   if ( iput-list の内容にエラーが無い)then {

           ・暗号化処理を実行し、output_list に暗号化の結果を出力;

   ・if (特殊なエラーが発生)then {   /* 暗号化処理の特殊エラー対策のためにコードを追加 */

                   ・色々と後処理をした上で、Return-status に CRIPT-ERROR をセット;  

                    ・return;    <– ★ここでメモリ解放エラーのバグが混入

                  } else { 

     ・Return-status に SUCCESS をセット; 

                  }

               } else {

                ・Return-status に INPUT-ERROR をセット;

               }

暗号化処理で稀に特殊なエラーの対策をする事に注意をしてソースコードを修正していたので 、元のソースコードでの動的メモリの解放が終了処理の中に書いてある事を忘れてしまい、エラー処理の中から直接 return 命令で処理を抜けてしまいました。この return 命令が実行されると、動的メモリの解放処理が実行されないまま  Sample_Crypt() 処理が終了してしまい、動的メモリのリークが起きます。

この動的メモリのリークは、入力された内容に何か問題があり暗号化処理で特殊なエラーが起こるような状態でないと発生しません。そのために、異常系のエージングでメモリリークを確認するようなリークテストを根気よく実施しないとなかなか見つけられず、発見が難しいメモリリークのバグとなってしまいます。

獲得と解放を別々の処理に分けている時に起こるメモリ解放漏れ

動的メモリは処理の間でデータを受け渡すのに便利に使えるので、一連の処理の最初に動的メモリを獲得してそこに処理対象のデータを記録しておき、そのデータをいくつかの処理で受け渡しをしながら、一連の処理を実行するようなソフトの構造を採用する事も良くあります。 通信系の送受信の処理では、送受信の対象となる電文を動的メモリに記録して、電文の種類に従って処理を切り替えたり様々な処理に受け渡していくようなソースコードを書く場面が良くあります。疑似コードで書くと以下のような処理ですね。

Receive_Message() {

 ① 電文を記録する buffer として動的メモリを獲得し、受信した電文をそこに書き込む

 ② 電文のヘッダ情報を解析して必要な電文の分析処理を選択して実行する

  switch  {

            (電文タイプ == TYPE1) : Result = Data_Analsys_1 (buffer);  /* 動的メモリの解放は Data_Analsys_1(); で実施*/

            (電文タイプ == TYPE2)  : Result = Data_Analsys_2 (buffer); /* 動的メモリの解放は Data_Analsys_2(); で実施*/

           defaulr { 動的メモリ解放;  Result = TYPE_ERROR); }

         }

 ③ 受信した電文を分析した結果の Result を持って呼び出し元にリーターン

}

パターン1の時とは異なり、上記のような構造のソフトでは、動的メモリの獲得は最初の Receive_Message() の処理の先頭で実行され、そこで獲得した動的メモリの解放は Data_Analsys_1 () や Data_Analsys_2() の処理の最後で実行されます。

動的メモリの獲得と解放のがソースコード上で離れた別々の処理の中に書かれるので、コードレビューなどで動的メモリの解放忘れの確認がし難くいというデメリットはあります。しかし、Data_Analsys_1 () や Data_Analsys_2() の処理の内容が複雑になる場合だと、受信した電文を受け渡しならが幾つかの処理を進める構造を取る事で、ソフト全体の構造を見通しやすくする事ができ、その事による潜在バグのリスクが減らせるメリットが大きい場合には、このようなソフトの構造を取る事もあります。

この例のようなソフト構造の場合、Data_Analsys_1 () や Data_Analsys_2() でちゃんと動的メモリの解放が実行されていればメモリリークは起きません。ソースコードの初期開発の段階では、コーダも動的メモリの獲得・解放に注意してコーディング・デバッグしているので、メモリのリークが起きる事は少ないです。

メモリリークが起きるのは、例えばバグ修正のために Data_Analsys_2 () の処理を修正・追加する場合などです。Data_Analsys_12()  の処理の中で、特定の条件の元での処理の流れを変えたり何等かのエラー対策の処理を追加したりしたりする場合、コーダの意識は今回の修正の目的に集中しているので、変更や追加した処理の最後に入れるべき動的メモリの解放をうっかり忘れたりします。

Data_Analsys_2 () の追加したソースコードの中で動的メモリの解放を忘れていても、長時間の動作によるメモリ枯渇が起きない限りは、修正した箇所は問題なく機能します。そのために、バグ修正後の動作確認テストでメモリ解放漏れに気付けない事もよくあります。その結果、特定の条件が揃った時にメモリのリークが起きるバグが潜む事になります。

動的メモリを使っている時はエラー処理の追加に注意

パターン1もパターン2も、初期開発の段階では動的メモリの解放忘れのソースコードが作られる事はまずありません。 機能追加やバグ修正のために、エラー処理回りでソースコードを触る時に、本来必要な動的メモリの解放処理を飛ばしてしまったり、実行を忘れてしまったりする事で、動的メモリのリークのバグが入り込みます。

動的メモリを使っているソースコードを修正する時には、よく注意してメモリリークのバグが入り込まないようにしましょう。

バグの巣に戻る