GC のタイミングでファイルを閉じるのではダメ、ということを目で見て理解する

ここでは、ファイルハンドルをちゃんと閉じないとだめですよ、というお話をします。

ファイルハンドルというのはプログラミングの入門コースでも出てくるので、 「そうそう、ファイルハンドルは閉じないとね」ということは分かっている(すくなくとも、聞いたことがある) 方は多いと思います。

C# などのマネージドコード内のリソース、例えばメモリリなどのリソースの解放はランタイムが自動的に行なってくれます。

しかし、ファイルハンドルはウッカリ閉じるのを忘れてると、しばらく開きっぱなしになってしまいます。

ハンドルとは?

Windows では File、Process、Mutex, Event などは、カーネルオブジェクトとして作られ OS の管理下に置かれています。

アプリケーションでファイルを使う場合、そのアプリケーションは CreateFile などの API を呼びます。 これによって Windows はファイルオブジェクトというカーネルオブジェクトを作成し、それを参照する情報をアプリケーションに返します。

このときに返される情報、すなわち、カーネルオブジェクトを参照するための情報をハンドル (handle) といいます。

ハンドルはハンドルテーブルというテーブル (表) のエントリーで、そのエントリーにカーネルオブジェクトへの参照(ポインタ)及びアクセス情報などが記録されています。

ハンドルテーブルのエントリーは、割り当てるのにもちろんメモリも使いますし、数に限りもあります。このため、一度作成したハンドルを閉じないで放っておくと、ある時点でそれ以上ハンドルが作成できなくなるなどして、 突然アプリケーションがクラッシュするとか、あるいは、動作が重くなったりして使えなくなります。

使うときに起動して、終わったらシャットダウンするようなアプリケーションでは特に問題にならないこともありますが、 サーバー系のソフトウェアのように基本的に一度起動したらずっと安定動作して欲しいソフトウェアでは不要なハンドルを閉じない等は大きな問題となります。

ハンドルを取得した後、そのハンドルにアクセスできないような状況になることをハンドルリークといいます。

スコープから抜けてもハンドルは閉じない

ここで、ひとつ実験をして見ましょう。

次のような画面のプログラムを作成します。

ここで、Open ボタンをクリックするたびに、C:\Temp フォルダ内の text0.txt、test1.txt、test2.txt、... という名前のファイルが開かれるようにします。

コードは次の通りです。

using System;
using System.IO;
using System.Windows.Forms;

namespace FileTest1 {
    public partial class Form1 : Form {

        int cnt = 0;

        public Form1() {
            InitializeComponent();
        }

        private void openButton_Click( object sender, EventArgs e ) {
            var filename = string.Format( "test{0}.txt", cnt++ );
            var path = Path.Combine( @"C:\temp", filename );
            var fs = File.Open( path, FileMode.Create );
        }

        private void gcButton_Click( object sender, EventArgs e ) {
            GC.Collect( 0, GCCollectionMode.Forced );
        }
    }
}

ここでは、File.Open で返された FileStream オブジェクト (ここではファイルハンドルを内部で保持している) をメソッド内のローカル変数 (fs) に代入して、そのままメソッドを抜けています。 この状況ではオブジェクト自体の参照カウントはゼロになりましたが、そのオブジェクトが保持している(開いている)ハンドルは開きっ放しになっています。

ためしに実際のファイルハンドルがどうなっているか、マイクロソフトのサイトからダウンロードできる Process Explorer というツールを使ってみてみましょう。

Open ボタンを5回連打した後、上記のプログラム (FileTest1.exe) を Process Explorer でみると確かに C:\Temp\test0.txt から C:\Temp\Test4.txt までのファイルハンドルが残っています。

結局、FileStream オブジェクトの参照 (つまり fs) 自体が使えなくなったからと言って、裏できれいにクリーンアップしてくれるわけではないのです。

GC ボタンをクリックすると、参照できなくなったゴミ FileStream オブジェクトがクリーンアップされるときに、ハンドルも閉じられました。

念のため言うと、ここで FileStream オブジェクトは自身が削除されるときに、つかんでいたファイルがあればそれを閉じるように実装されているので、ハンドルが閉じられました。ハンドルを閉じないようにも実装は出来てしまうところは注意してください。

要はマネージドのオブジェクトと、アンマネージドのリソース管理は別々に考えなければならないということです。

アンマネージドのリソース(この場合ファイルハンドル)を、スコープが抜けたタイミングでしっかりクリーンアップするということをしたい場合は、IDisposable インターフェイスを実装します。 そして、その変数を using ステートメントと共に使います。

前述の例を書き直すと次のようになります。

    ...
    using( var fs = File.Open( path, FileMode.Create ) ) {
        // FileStream を使う...
    }

これによって、using で定義されたスコープを抜けたときに IDisposable インターフェイスの Dispose メソッドが呼ばれ、そこでハンドルを閉じるように実装しておけばハンドルリークを避けることができます。

Process Explorer でみても確かにハンドルは残っていません。

FileStream クラスは Stream クラスから派生していますが、Stream クラスで IDisposable を実装すると定義されています。

ここまでお読みいただき、誠にありがとうございます。SNS 等でこの記事をシェアしていただけますと、大変励みになります。どうぞよろしくお願いします。

© 2024 C# 入門