その4 「メモリ管理問題」

時事ネタ

いや、「今、fj.comp.lang.cが熱い!」という話は ちょっと前から聞き及んではいたのですが、 流量が尋常じゃないらしいので、 忙しさもあって読むのを控えていたんですけど、 先日、大筋を拾い読みしてみました。 確かに熱いですねえ。 いったん収束するかなあ、と思ったら、なんかまた泥沼の気配が(^^;

fjを読んでない人のために説明すると(って、 私も普段はあんまり読んでないんですが)、 「malloc()で確保した領域は、必ずfree()で解放しなきゃいけないか?」という 話題でして、「exit()する時には、OSが解放してくれるんだから 別にfree()しなくてもいいじゃん」という主張と、 「いや、malloc()には常に対になるfree()があるべきだ」という主張があって、 議論を呼んでいたのでした。

んで、私がどう思うかなんですが、

私は面倒臭がりなので、free()しないで済むのなら、 free()したくない方です。

確かに、細かいことを言えば、 exit()した時にmalloc()した領域が解放されることは、 別にCの規格で保証されているわけではないので、 (fjの方ではいつの間にかぐちゃぐちゃになってますが、 規格書を読む限りではそう読めます。そうですよね? > 詳しい方)、 (ANSI-C準拠の)あらゆる処理系でリークなしに動かしたいなら、 exit()前でも解放しなければならないのは確かです。

# もちろん、exit()の後 malloc()した領域を free()してくれない処理系において、
# malloc()した領域をfree()しないプログラムを書いても、規格厳密合致プログラ
# ム(strictly conforming program)ではありますが、だから何だってんだろう?

でも、現実問題、プログラムってのは「移植されそうな」環境で動けばいいわけで、 当面移植の予定もないチープな環境のために今苦労しても、 この世界、進歩が早いですから、報われない可能性が高そうです。

# というわけで、私は int は最低 32bit はあるもんだと思っています(余談)。

「free()しない派」の主張としては、 どうせ解放する領域をわざわざfree()しても時間がかかるばっかりだし、 そんなことでバグを入れたら本末転倒、という、 もっともな意見(これは私も同意します)の他に、 「free()したからってたいていの実装ではOSにメモリが返されるわけじゃ ないじゃないか」という意見もありました。 これなんかは、私は、「だから何?」って思うんですが。

初心者にmalloc()とfree()を教える時に、 「free()さえすれば、綺麗さっぱり元通り」という 印象を与える、という意見だったかとも思うんですけど、 それがまずいというのなら、きっちり説明すれば済む話であって、 それが free()しない理由にはならない筈です。 少なくとも、そのプロセスでは、使える領域が増えるわけですし。

「free()する派」の主張には、「今はexit()しているかも知れないが、 そのプログラムを後にサブルーチンとして再利用しようとした時に困るじゃないか」 というのがあって、これはこれで至極もっともだと思います。 でも、そういう形で再利用することがまずないと思われる(たとえば99%の確率で) プログラムについて、1%の可能性のために余計な手間をかけるより、 今週末の納期を優先させる判断だって、ある筈だと思うのです。

それとは別に、私が「free()する派」の人達の主張を読んでて、何かヤだな、 と思うのは、

という主張が背後に(人によっては前面に)見えることです。

そんな「ていねいな」コーディングを「少しのミスもなく」出来るような人は、 プログラマなんてやくざな商売やってないでしょ(暴言)。 私なんか、結構長いことプログラマなんてやってるおかげで、 今となってはもう、例えばコンビニのレジ打ちみたいな 「まっとうな」仕事が出来る気がしません。 だって、コンビニのレジでは、お釣りを間違って渡しても、 警告メッセージひとつ出ないし、 チェックサムを取って「何かおかしい」ということに気付く頃には、 既にリカバリはおろか原因究明も不可能な状態になってそうですし。 そんなおっかない仕事、長年プログラマなんかやって堕落した私には、 勤まりそうにないです。

何たってプログラマは人間ですし、人間は、およそミスを犯せる所では 必ずミスを犯すものです。

ついでに言えば、今時の職業プログラマに、 プロジェクトに対する過度の忠誠心を期待するのも間違っています。

私がどうかはあえて触れないでおきますけど(^^; プロジェクトマネージャなら、プログラムの品質なんぞより、 納期の方がずっと優先する問題かも知れませんし、 コーディング中のプログラマにとっては、今書いているプログラムなんぞより、 今夜のデートの方がはるかに優先度の高い話なのかも知れません。

そういう状況下で、とにかく「ていねいな」コーディングをしろ、なんていう 精神論をふりかざしてもしょうがないでしょ。

他の職種はどうか知らないけど、およそプログラマに関する限り、 手を抜ける所では可能な限り手を抜くのが、 最大の美徳だと私は思っています。 何せ、「めんどくさい」コーディングってのは、書いた本人はおろか、 それを保守する側にまで、「めんどくささ」を強要するものだからです。

サブルーチンにして使い回す可能性があるから領域は一応全部解放しておきたい、 オーケイ、それはわかった。

それならそれで、いかに「手を抜いて」 それを実現するかを考えるべきなんじゃない? と、堕落したプログラマである私なんかは思ってしまいます。

傾向と対策

malloc()を使う局面

実際のアプリケーションプログラムにおいて、 malloc()で領域を確保する状況ですけど、 私の長くもない経験からすれば、以下のような状況に分類できると思います。

  1. 追加専用領域
    ファイルなり何なりからデータを読み込んでデータ構造を構築する時、 その領域に対して、データの追加はあっても部分的な削除は全くない (途中で不要になるとしたら全てまとめて不要になる)場合に、 その領域を「追加専用領域」と呼ぼうと思います。 プログラム起動時に設定ファイルを読み込む領域や、 コンパイラが作る構文木などはこれに当たります。
  2. 一時作業領域
    対話的なアプリケーションなら、GUIのボタンをぽちっと押すと、 しばらく何らかの処理が動いて、また入力待ちの状態に戻ります。 このひとつの処理(トランザクション)の間だけ一時的に必要になる領域を 「一時作業領域」と呼ぶことにします。
  3. 局所的作業領域
    例えば入力ファイルからトークンを切り出すようなプログラムの場合、 トークンのお尻までを一時バッファに取り込んで、 文字数が確定してからその領域をmalloc()する、という形になると思います。 その時、トークンの最大長に制限を加えたくないとすれば、 「一時バッファ」を固定長配列にするわけにはいきませんから、 ヒープに確保する必要があります。 これは、プログラムのその個所でしか使いませんから、 「局所的作業領域」と呼ぶことにします。
  4. 「データモデル」領域
    そのアプリケーションが取り扱うデータモデルそのものです。 ワープロなら「文書」が相当します。 当然、追加も削除もランダムにやってきます。

以上のケースについて、「いかにして手を抜くか」を考えてみようと思います。

追加専用領域

メモリ管理用のライブラリ(モジュール)の略称を"MEM"として(memoryの略ですな)、 インタフェースを考えていきます(モジュールの概念と命名規則については、 その3 「モジュールと命名とヘッダファイルと」を 参照のこと)。

「追加専用領域」については、まず、 それぞれひとかたまりとなるデータ構造に対して、 「ストレージ(貯蔵庫)」という概念を割り当てます。

で、アプリケーションは、まず「ストレージ」を作成し、 「ストレージ」からガンガン領域を確保して、 データ構造を構築していきます。

  #include <stdlib.h>

  typedef struct MEM_Storage_tag *MEM_Storage;

  /* ストレージを作成する */
  MEM_Storage MEM_create_storage();

  /* ストレージから領域を確保する */
  void *MEM_storage_malloc(MEM_Storage storage, size_t size);

「追加専用領域」は、追加はあっても、要素毎の削除はなく、 解放するときには全部まとめて、という領域ですので、 ストレージ単位での解放ができれば良いことになります。

  /* ストレージまるごと解放 */
  void MEM_dispose_storage(MEM_Storage storage);

これなら、細かい領域を「ちまちまと」解放していかなくても良いので、 ミスの入り込む余地はほとんどなくなると思います。 グラフのようなややこしい構造でも一発で解放できますし。

実装も簡単です。ある程度の大きさを持った領域をまとめて確保し、 その中から順次領域を割り当てていきます(当然、アライメントは取る)。 そして、領域が足りなくなったら新しいのを確保して、 連結リストで繋いでいけばいいわけです。

通常のmalloc()をちまちま呼ぶのに比べ、管理領域が少なくて済むので メモリの使用効率も良さそうです。

プログラムの起動時に設定ファイルを読み込むような場合は、 「読み込み終了」のタイミングがはっきりしているので、 最後の領域の空きエリアをrealloc()で縮めるインタフェースも付けといた方が 無駄がなくていいですね。

  /* ストレージをぎりぎりまで縮める */
  void MEM_shrink_storage(MEM_Storage storage);

一時作業領域

実の所、「一時作業領域」も、メモリにそれなりに余裕があれば、 「追加専用領域」と同じアプローチでいけると私は思っています。 ひとつのトランザクションが終わるまではひたすら領域を確保し続け、 「ひと仕事終わったら」、ストレージごと解放すればいいわけですね。

ただ、それではどうにもメモリがもったいない、という局面もあり得ます。

  for (ものすごい回数のループ) {
      p = malloc(...);
      ...
      free(p);
  }

端的には、こんな場合ですね。

こういう場合は、そこだけ普通のmalloc()/free()を使うか、 そこだけ新たにストレージを割り当てるとかすれば済むだろうとは思うんですが、 ただ、「個別にfree()することもできるし、トランザクションが終わったら 全部まとめて解放もできる」という仕掛けが考えられないこともない (私は普段やってませんけど)ので、ちょっと書いてみます。

インタフェースは以下のような形になりますね。

  #include <stdlib.h>

  typedef struct MEM_WorkArea_tag *MEM_WorkArea;

  /* ワークエリアの作成 */
  MEM_WorkArea MEM_create_work_area();

  /* ワークエリア内に領域を割り当てる */
  void *MEM_work_area_malloc(MEM_WorkArea work_area, size_t size);

  /* ワークエリア内の領域を個別にfree()する。
   * 第1引数は不要と言えば不要だけど。
   */
  void MEM_work_area_free(MEM_WorkArea work_area, void *p);

  /* ワークエリアごと解放 */
  void MEM_dispose_work_area(MEM_WorkArea work_area);

私が以前携わった某巨大システムでは、 malloc()を使って大きめの領域を割り当て、 独自のメモリ管理機構(buddyだったと思う)を使って その領域を切り売りしてました。 個別free()もできるのですが、ひとつのコマンドの実行が終了した時点で、 一時作業用として確保した領域は全て解放してくれました。 これはこれで、ひとつの方法であると思います。

独自のメモリ管理機構までインプリするのは面倒臭い、ということなら、 「作業用」に確保した領域を全部双方向連結リストに繋いでおく、 という方法が考えられます。

連結リストを構築するためのポインタの領域は、 MEM_work_area_malloc()の中でちょっとインチキして大きめに領域を確保し、 その先頭に「隠して」やって、アプリケーションにはその後ろのポインタを 返してやればいいでしょう(当然アライメントを取る必要があります)。

余談ですが、どうせ確保した領域毎に管理領域を「隠す」なら、 デバッグバージョンではそこに以下のような情報も入れてやると いいかも知れませんね。

  1. その領域のサイズ。
  2. 呼び出し情報。例えば、__FILE__とか、__LINE__とか、 サイズを指定している引数の文字列表現など。

局所的作業領域

例えば、

  char *get_token(FILE *fp);

みたいな関数を書くとして、 これはget_token()側でヒープに領域を確保するわけですが、 その領域を、あんまり頻繁にrealloc()でニョキニョキ伸ばすと 効率低下とフラグメンテーションの元になりますので、 「トークンのサイズが決まるまで一時的に格納するバッファ」を 用意すると思います。 トークンのサイズに何がなんでも制限をかけたくないのなら、 そのバッファもヒープに取るかも知れません。

特に、上記の「ストレージ」アプローチを採用した場合、 ストレージに取る領域はrealloc()できませんので、 「局所的作業領域」を使ってサイズが正確にわかってから 改めてストレージに領域を確保する必要がありますし。

そういう場合は、リエントラントにしなくていいのなら、 以下のようなコーディングになるのでしょう。

  /* st_という命名規則については、
   * その3 「モジュールと命名とヘッダファイルと」を参照のこと。
   */
  #define TOKEN_BUFFER_ALLOC_NUM  (256)
  static char *st_token_buffer = NULL;
  static int  st_token_buffer_alloc_size;

  char *
  get_token(FILE *fp)
  {
      /*
       * st_token_bufferを一時作業領域として、足りなければrealloc()で
       * 一定サイズごとに伸長し、トークン全体が切り出せたら、別途確保した
       * 領域にコピーして返す処理。
       */
  }

  void
  finalize_get_token(void)
  {
      free(st_token_buffer);
      st_token_buffer = NULL;
      st_token_buffer_alloc_size = 0;
  }

「exit()前でもfree()しなくちゃ」という主義の人は、 最後にfinalize_get_token()をコールすればいいわけです。

ま、ここは、「単体のプログラムのつもりだったものを、 サブルーチン化してループでガンガン呼ぶようにした」場合でも、 その中の最大のトークンに合わせて伸びるだけですから、 無理に解放しなくてもいいんじゃないかと思うんですけど。

3/9付記:
例えばここでget_tokenが汎用ライブラリの関数だったとすると、 汎用ゆえに戻り値として返す領域の生存期間は、get_token()側では 予測できませんので、MEM_storage_malloc()で確保する、みたいな 戦略は使えません。
そういう場合は... 相手が文字列なら、私なら、利用者側の関数で get_token()をラップして、そこで新たに領域を確保し、strcpy()で コピーした後、元の領域をfree()しちゃいます。
汎用ライブラリが返すのが、不完全型へのポインタだったり した場合には... ま、そういうのは、ちまちま開放するんでしょうね。 それはmalloc/freeの問題ではなく、その他一般のリソースの問題だと思います。

「データモデル」領域

一番困るのがここです。

オブジェクト(メモリの固まりと思ってください)の生存期間は長いわ、 解放・削除は不規則にやってくるわで、上記アプローチのどれも使えません。

この領域については、もし、以下の条件が成り立つのであれば、

  1. そこに確保されるデータの構造がきっちり定義されている。
  2. ひとしきりの処理(トランザクション)が終わった後の「定常状態」では、 そのデータモデルのルートオブジェクトから 有効な全てのオブジェクトを辿ることができて、 かつ、そのモデルの外からは、誰もオブジェクトを指していない。
  3. トランザクションの最中にメモリ不足をそれほど意識しなくて良い程度には メモリに余裕がある。
  4. トランザクション終了後、処理が一時停止することが許容できる。

こんなアプローチもありなんじゃないかなあ、と思って書いてみたのが これなんですけど、どんなもんでしょう?

実装について

具体的な実装についてですが、今まで、便宜的にmalloc()とかfree()とか 言ってきましたけど、実際のプログラムでは、 まさかアプリケーションプログラムが直接 malloc()は呼ばないと思うのです。

最低でも、以下のように皮を1枚かぶせますよね。

  void *
  MEM_malloc(size_t size)
  {
      void *p;

      p = malloc(size);
      if (p == NULL) {
          fprintf(stderr, "out of memory!\n");
      }

      return p;
  }

malloc()がNULLを返した時、標準エラー出力にメッセージを吐くようにしています。 実際、malloc()がNULLを返すことなんてそう滅多にはないのですから、 せめてこうでもしとかないと「いざmalloc()がNULLを返した時」に 原因が究明できない上に、再現させることも極めて困難な話になっちゃいます。 ついでに、私としては、このfprintf()の後に exit(1); というのを付け加えたくてしょうがないのですが、 それは、まあ、ひとまず置いておくとしまして。

ついでに、どこで領域不足が発生したのかも吐いておきたいと思うなら、 以下のような方法が有効だと思います。

void *MEM_malloc_func(char *filename, int line, size_t size);
...
#define	MEM_malloc(size)\
  (MEM_malloc_func(__FILE__, __LINE__, size))

free()の方ですけど、できることなら以下の機能を持たせたい所です。

  1. 引数として渡したポインタをNULLにする
    もうそのポインタからは参照できないのだからNULLにしておいた方が安全です。
  2. 解放した領域の内容を(わざと)ブチ壊す
    「既に解放された領域を、間違って指してる奴がいた」というのは メモリの解放にまつわる典型的な問題ですが、 そういうバグがあった時に、確実に症状を発現させるには、 解放と同時に領域の内容をブチ壊しておきたい所です。

ひとつめの問題については、

  void MEM_free(void **p);

という関数にして、

  MEM_free(&p);

と呼びなさい、という方法が考えられはしますけど、 void**って結局void*とマッチしてしまうので、 これを使うプログラマが、ごく普通に

  MEM_free(p);

と書いてしまってもコンパイラがはねてくれないことを考えたら、 やらない方が無難、という気がします。

ふたつめの問題についてですが、 free()の側では領域のサイズがわからないので、ブチ壊そうにもブチ壊せませんね。 でも、デバッグバージョンなら、「一時作業領域」の所で出てきたような 方法で領域サイズを「隠しておく」という方法は考えられます。 ついでに、後ろにも、何か特別なビットパターンを埋め込んでおくと、 デバッガで領域破壊を検出するのが楽になったりします。

# ってことは、WRITING SOLID CODEに書いてあると思ってたけど、探しても
# 見当たらない... CODE COMPLETEだったかな。手元にないので探せないけど。

ところで、このようにmalloc()とfree()に皮を被せると、 それぞれの呼び出し回数をグローバル変数でカウントすることができます。

「malloc()した領域は必ずfree()すべし」という方針でコーディングしてある場合、 アプリケーションを長時間走らせて、終了時に MEM_malloc()とMEM_free()の呼び出し回数が等しければ、 そのアプリケーションの範囲内では、free()し忘れによるメモリリークは まず起きてないだろう、と考えることができます。

このチェックができることが、「malloc()した領域は必ずfree()すべし」 というポリシーの最大の利点かも知れません。

戻り値チェック

実の所、malloc()を使う上で本当に頭が痛いのは、 free()をどうするかなんてことよりも むしろこっちなんじゃないかと私は思うんですが...

# このネタ、fjに投げたら、今よりももっとヒートアップしたりするのかな。

malloc()は、メモリがない時NULLを返します。 問題は、アプリケーションプログラムで、それをどう処理するかです。

一番簡単なのは、先にちょっと触れたように、 malloc()の「皮」の中でメッセージを吐いてexit()してしまうことです。

こういうことを言うと、「malloc()した領域は全部free()すべし」論者の人は、

「とんでもない! メモリ不足のステータスを上位ルーチンまできちんと持ち帰って、 適切な処理をしなきゃ」

とおっしゃるのだと思いますけど、 でも、それって、すごく面倒臭いですよねえ。

ファイルを食ってファイルを吐くようなプログラムでは、 再実行すればいいのだから、「メッセージを吐いてexit()」アプローチで 充分だろうと私は思います。

もちろん、これも、サブルーチンとして再利用される可能性を考えなければ ならないでしょうが、「戻り値チェック」の手間を考えたら 多くの場合割に合わないと思います。

もちろん、ワープロで文書を書いていて、突然ばたんと死んじゃって 文書がパーになったら私だって怒るわけで、 常に「戻り値チェック」が省略できるわけではないとは思いますが、 ただ、これもやっぱり、教条主義的に、

malloc()の戻り値は「ていねいに」チェックして、 「ていねいに」上位ルーチンにステータスを持ち帰るべきだ

とだけ教えればいいってもんじゃないですよね。

そうして、仮に、malloc()の戻り値をチェックして、 作りかけのデータ構造を修復しながら 上位ルーチンにちまちまとステータスを持って帰ることができたとして、 あと、どうするのでしょう?

少なくともこの辺までは考えないと、 これもやっぱり精神論と言われてしまうと思います。

もちろん、現実には、すごく巨大な領域を確保しようとして 失敗することが多いので、そういう場合にはダイアログを出したり、 ディスクにセーブするぐらいの余裕はあるかも知れませんけど。

あと、あらかじめ大きめの「非常用領域」をmalloc()しておいて、 malloc()がNULLを返した時点で解放してやれば、 malloc()についてはちょっとは状況が改善されますね。 OSにメモリを返せるわけではないので、スタックは困りますが。

それにしても、「どうやってテストするんだろう」という疑問は残りますね。 途中で突然malloc()が失敗したことにより、 データ構造が不整合を起こすかもしれません。

もちろん、そうならないようにコーディングするんでしょうが、 テストするには... 全てのmalloc()をgrepでひっかけて、一個ずつ、 デバッガで戻り値をNULLに置き換えて... やるだけでは駄目ですね。create_node()みたいな関数があったとき、 その中で呼んでいる malloc()の呼び出しについてテストしても駄目で、 create_node()自体がエラーを返す場合のテストをしなきゃならない。 全てのパスについて、ということだと、とてつもなく大変そうです。

避けるとは思うけど、malloc()が失敗した後で、またmalloc()を呼ぶような場合、 テストではきっと後で呼んだmalloc()は成功するんだけど、 実際にmalloc()が失敗するような状況ではどうなるかわからん、 という問題もあります。

そういえば、Cよりはるかにヒープを多用するJavaのプログラムでは、 この辺みなさんどうしてるんでしょ? OutOfMemoryErrorをcatchして、適切な処理をしているプログラムって、 どれぐらいあるのでしょうか? さらに、テストは?

OutOfMemoryErrorをcatchしてないとすれば、 malloc()の皮の中でexit()するプログラムと実質変わらないと 思うんだけどなあ。

おわりに

Cのプログラマにとって、メモリ管理は悩みが尽きないものです。

とか言うと、Javaな人は、「ほらほらJavaならGCがあるから」とか言うわけですが、 結局、言語に限らず、プログラマが、オブジェクトのライフサイクルを 把握しとらんでは話にならんわけで(Javaみたいなポインタ使いまくり言語だと、 immutableなオブジェクトは、もうあっちこっちからぐちゃぐちゃに参照された 状況にしたりしますが、それはちょっと話が別)、 それぞれ、ライフサイクルが限定された状況下では、 メモリの解放については、そこそこ手を抜く方法はあるんじゃないかなあ、 というのが、まず言いたかったことです。

戻り値チェックについては... うーん、なんか、触れてはいけない話だったような気もするなあ(^^;


ひとつ上のページに戻る | ひとつ前の話 | ひとつ後の話 | トップページに戻る