【C#】IDisposable・using完全ガイド|Disposeパターン・ファイナライザー・SafeHandle・IAsyncDisposable・実装原則まで

IDisposableusing は C# のリソース管理の根幹です。「using を付ければ Dispose が自動で呼ばれる」という知識は誰でも持っていますが、正しい Dispose パターンの実装ファイナライザーとの関係IAsyncDisposableHttpClient の落とし穴DI コンテナによる自動管理まで把握している人は意外と少ないです。

本記事ではリソース管理の仕組みを基礎から掘り下げ、実装時に必要な知識をすべて解説します。非同期リソース解放の詳細はasync/await完全ガイドの IAsyncDisposable セクションも参照してください。

スポンサーリンク

なぜ Dispose が必要か — GC とアンマネージドリソースの違い

C# の GC(ガベージコレクター)はマネージドメモリ(C# のオブジェクト)を自動回収しますが、アンマネージドリソース(OS が管理するファイルハンドル・ネットワーク接続・データベース接続など)は自動解放しません。

リソース種別 GC で自動解放 解放手段
マネージドメモリ(通常のオブジェクト) ○(GC が自動) 不要
ファイルハンドル(FileStream Dispose()/using
ネットワーク接続(HttpClientSocket Dispose()/using
DB 接続(SqlConnectionDbContext Dispose()/using
アンマネージドメモリ(Marshal.AllocHGlobal Marshal.FreeHGlobal + Dispose()
GDI オブジェクト(フォント・ブラシなど) Dispose()/using
GC はいつ動くか分からない
GC の実行タイミングは実行エンジンが決定し、プログラマーは制御できません。アンマネージドリソースを保持したまま GC を待つと、ファイルが長時間ロックされたり、接続プールが枯渇したりします。using/Dispose() でスコープを抜けた直後に確実に解放することが基本です。

using の仕組み — コンパイラが生成するコード

using ステートメントはコンパイラが try-finally に展開します。両者は完全に等価です。

using とコンパイラ展開された try-finally
// 書いたコード(using ステートメント)
using (var reader = new StreamReader("data.txt"))
{
    Console.WriteLine(reader.ReadToEnd());
}

// コンパイラが生成するコード(意味的に完全に等価)
StreamReader reader2 = new StreamReader("data.txt");
try
{
    Console.WriteLine(reader2.ReadToEnd());
}
finally
{
    // reader2 が null でないことを確認してから Dispose
    if (reader2 != null)
        ((IDisposable)reader2).Dispose();
}
// → 例外が発生しても finally が必ず実行されるため、
//   Dispose() は常に呼ばれる
using の本質は try-finally
例外が発生しても finally ブロックは必ず実行されます。つまり using は「例外が起きても Dispose する」ことを保証する構文です。手書きの try-finally と完全に同じ動作をしますが、using の方が見た目がスッキリします。

using ステートメント vs using 宣言(C# 8+)

using ステートメントと using 宣言の違い
// using ステートメント(C# 1.0〜): ブロックスコープで解放
using (var conn1 = new SqliteConnection("..."))
{
    conn1.Open();
    // ← ここで conn1 が使える
}
// ← ここで conn1.Dispose() が呼ばれる(ブロック終了時)

// using 宣言(C# 8.0+): 宣言スコープ(メソッド終了)で解放
using var conn2 = new SqliteConnection("...");
conn2.Open();
// ← conn2 は同じスコープ内で使える
// ← メソッド終了時(または囲んでいるブロック終了時)に Dispose() が呼ばれる

// using 宣言の解放順序: 宣言の逆順
static void MultipleUsing()
{
    using var a = new ResourceA();  // 1番目に宣言
    using var b = new ResourceB();  // 2番目に宣言
    using var c = new ResourceC();  // 3番目に宣言

    // Dispose の呼び出し順: C → B → A(逆順)
}   // ← メソッド終了時に C, B, A の順で Dispose
観点 using ステートメント using 宣言(C# 8+)
解放タイミング ブロック } 終了時 スコープ(メソッド・ブロック)終了時
ネストの深さ 深くなりやすい フラットに書ける
複数リソース using(A) using(B) { } または using(A, B) using var a; using var b;
スコープの明示性 ブロックで明示 暗黙(スコープ終了まで)
推奨場面 スコープを狭く限定したいとき メソッド全体で使うリソース
複数リソースをまとめて using する方法
// C# 8 以前: 複数の using をネスト(または using(A, B) 構文)
using (var a = new ResourceA())
using (var b = new ResourceB())   // ネストだが 1 段に見える
{
    // a と b を使う
}   // b.Dispose() → a.Dispose() の順

// C# 8+: using 宣言を並べる(フラット)
using var x = new ResourceA();
using var y = new ResourceB();
// y.Dispose() → x.Dispose() の順(逆順)

// C# 8 以前の同一行 using(同じ型)
using (SqliteConnection c1 = new("..."), c2 = new("..."))
{
    // c1, c2 を使う
}

正しい IDisposable の実装パターン

Microsoft が推奨する標準 Dispose パターンを実装します。重要なのはマネージドリソースとアンマネージドリソースを分けて管理することです。

マネージドリソースのみ持つクラス(最も一般的)

マネージドリソースのみの Dispose パターン
// アンマネージドリソースを直接持たないクラスのシンプルな実装
public class ManagedOnlyResource : IDisposable
{
    private StreamReader? _reader;
    private bool _disposed;

    public ManagedOnlyResource(string filePath)
    {
        _reader = new StreamReader(filePath);
    }

    public string? ReadLine() => _reader?.ReadLine();

    public void Dispose()
    {
        if (_disposed) return;        // べき等性: 2回目以降は何もしない

        _reader?.Dispose();           // 子リソースを解放
        _reader = null;

        _disposed = true;

        // ファイナライザーは持たないので GC.SuppressFinalize は不要
        // ただし記述しても問題ない
    }
}

アンマネージドリソースも持つクラス(標準 Dispose パターン)

標準 Dispose パターン(マネージド + アンマネージド)
using System.Runtime.InteropServices;

public class UnmanagedResource : IDisposable
{
    // アンマネージドリソース(OS から直接確保したメモリなど)
    private IntPtr _unmanagedBuffer;

    // マネージドリソース
    private FileStream? _fileStream;

    private bool _disposed;

    public UnmanagedResource(string filePath)
    {
        _unmanagedBuffer = Marshal.AllocHGlobal(1024);   // アンマネージドメモリ確保
        _fileStream      = new FileStream(filePath, FileMode.OpenOrCreate);
    }

    // ① public Dispose(): 外部から呼ばれる
    public void Dispose()
    {
        Dispose(disposing: true);
        GC.SuppressFinalize(this);   // ファイナライザーをキャンセル(二重解放防止)
    }

    // ② protected virtual Dispose(bool): 実際の解放処理
    protected virtual void Dispose(bool disposing)
    {
        if (_disposed) return;

        if (disposing)
        {
            // マネージドリソースの解放(Dispose(true) のときのみ)
            // ファイナライザーから呼ばれる Dispose(false) では触らない
            // (ファイナライザー実行時は他のマネージドオブジェクトが既に GC 済みかもしれないため)
            _fileStream?.Dispose();
            _fileStream = null;
        }

        // アンマネージドリソースは Dispose(true)/Dispose(false) 両方で解放
        if (_unmanagedBuffer != IntPtr.Zero)
        {
            Marshal.FreeHGlobal(_unmanagedBuffer);
            _unmanagedBuffer = IntPtr.Zero;
        }

        _disposed = true;
    }

    // ③ ファイナライザー: Dispose が呼ばれなかったときの保険
    ~UnmanagedResource()
    {
        Dispose(disposing: false);   // アンマネージドのみ解放
    }
}

// 派生クラスでのオーバーライド
public class ExtendedResource : UnmanagedResource
{
    private SqliteConnection? _conn;
    private bool _disposed;

    public ExtendedResource(string filePath, string connStr) : base(filePath)
    {
        _conn = new SqliteConnection(connStr);
    }

    protected override void Dispose(bool disposing)
    {
        if (_disposed) return;
        if (disposing)
        {
            _conn?.Dispose();
            _conn = null;
        }
        _disposed = true;
        base.Dispose(disposing);   // 基底クラスの Dispose を必ず呼ぶ
    }
}
Dispose パターンの3原則
Dispose()Dispose(true) + GC.SuppressFinalize(this) を呼ぶ
Dispose(bool disposing) で disposing=true(明示的解放)と false(ファイナライザーから)を区別
③ ファイナライザーは Dispose(false) を呼ぶ(アンマネージドのみ解放)

ファイナライザー(~ClassName)の役割とコスト

ファイナライザーは Dispose が呼ばれなかったときの「最後の砦」です。GC がオブジェクトを回収するときに自動的に呼ばれます。

ファイナライザーの動作確認
public class FinalizerDemo : IDisposable
{
    private bool _disposed;

    public void Dispose()
    {
        if (_disposed) return;
        Console.WriteLine("[Dispose] 明示的に解放");
        _disposed = true;
        GC.SuppressFinalize(this);   // ファイナライザーをキャンセル
    }

    ~FinalizerDemo()
    {
        // Dispose が呼ばれなかった場合のみここに来る
        Console.WriteLine("[Finalizer] GC が解放(Dispose を忘れた!)");
        Dispose(false);   // アンマネージドリソースのみ解放
    }

    protected virtual void Dispose(bool disposing) { /* ... */ }
}

// using で正しく解放した場合
using (var d = new FinalizerDemo())
{
    // 使用
}
// → "[Dispose] 明示的に解放" のみ出力
// → ファイナライザーは GC.SuppressFinalize でキャンセル済みなので呼ばれない

// Dispose を忘れた場合
{
    var d2 = new FinalizerDemo();
    // using なし、Dispose 呼び忘れ
}
// → GC が動いたときに "[Finalizer] GC が解放(Dispose を忘れた!)" が出力される
//   → タイミングは不定(すぐとは限らない)
観点 Dispose()(明示的) ファイナライザー(GC)
実行タイミング 即座(using ブロック終了時) GC の判断(不定・遅延あり)
マネージドリソース 解放できる 解放してはいけない(既に GC 済みかもしれない)
アンマネージドリソース 解放できる 解放できる(唯一の目的)
パフォーマンス 即座に解放・低コスト GC の追加スキャンコストあり
GC.SuppressFinalize 呼んでファイナライザーをキャンセルする
ファイナライザーのコスト
ファイナライザーを持つオブジェクトは GC の Generation 1→2 に昇格されやすく、メモリ解放が遅くなります。Dispose() を正しく呼んで GC.SuppressFinalize すれば、ファイナライザーはキャンセルされてコストがかかりません。アンマネージドリソースを直接持たないクラスにはファイナライザーを書かないことが推奨されます。

SafeHandle — 現代的なアンマネージドリソース管理

アンマネージドリソースをファイナライザーで管理する代わりに、SafeHandle(または派生クラス)を使う方が安全で信頼性が高いです。SafeHandle はスレッドセーフなカウンティングと確実な解放を内部で実装しています。

SafeHandle を使ったアンマネージドリソースのラップ
using Microsoft.Win32.SafeHandles;
using System.Runtime.InteropServices;

// カスタム SafeHandle: アンマネージドリソースをラップ
public class NativeBufferHandle : SafeHandleZeroOrMinusOneIsInvalid
{
    // SafeHandleZeroOrMinusOneIsInvalid: handle が 0 または -1 のとき invalid(解放不要)
    public NativeBufferHandle() : base(ownsHandle: true) { }

    // ハンドルの解放処理(GC やファイナライザーから安全に呼ばれる)
    protected override bool ReleaseHandle()
    {
        Marshal.FreeHGlobal(handle);
        return true;
    }
}

// SafeHandle を使うクラス: ファイナライザー不要!
public class SafeBufferResource : IDisposable
{
    private NativeBufferHandle _handle;
    private bool _disposed;

    public SafeBufferResource(int size)
    {
        _handle = new NativeBufferHandle();
        _handle.SetHandle(Marshal.AllocHGlobal(size));
    }

    public void Dispose()
    {
        if (_disposed) return;
        _handle.Dispose();   // SafeHandle が解放を保証
        _disposed = true;
        // GC.SuppressFinalize 不要(ファイナライザーがないため)
    }
}

// .NET が提供する SafeHandle 派生クラス
// - SafeFileHandle: ファイルハンドル
// - SafeWaitHandle: Wait オブジェクト
// - SafeMemoryMappedFileHandle: メモリマップトファイル
// → 多くの場面でカスタム SafeHandle を書く必要はなく、既存クラスを使える

IAsyncDisposable と await using(C# 8+)

非同期 I/O を使うリソース(データベース接続・ネットワークストリームなど)は、解放処理も非同期にしたい場合があります。IAsyncDisposable インターフェースと await using がその手段です。

IAsyncDisposable の実装と await using
// IAsyncDisposable の実装
public class AsyncDbConnection : IAsyncDisposable, IDisposable
{
    private bool _disposed;

    public async Task OpenAsync()
    {
        await Task.Delay(50);   // 非同期接続
        Console.WriteLine("DB接続完了");
    }

    // 非同期 Dispose: ネットワーク接続の切断など時間がかかる処理
    public async ValueTask DisposeAsync()
    {
        if (_disposed) return;
        Console.WriteLine("非同期クリーンアップ開始...");
        await Task.Delay(100);   // 非同期で接続を閉じる
        Console.WriteLine("DB接続解放完了");
        _disposed = true;
    }

    // 同期 Dispose も実装しておく(using ステートメントとの互換性)
    public void Dispose()
    {
        if (_disposed) return;
        // 同期的なフォールバック(可能であれば非同期版を使う)
        DisposeAsync().AsTask().GetAwaiter().GetResult();
    }
}

// 使用例: await using で非同期に解放
static async Task UseDbAsync()
{
    await using var db = new AsyncDbConnection();
    await db.OpenAsync();
    // ← スコープ終了時に DisposeAsync() が自動で await される
}

// 同期環境で using(Dispose())を使う場合
static void UseDbSync()
{
    using var db = new AsyncDbConnection();
    // ← Dispose() が呼ばれる(非同期版のブロッキング)
}
IAsyncDisposable の戻り値は ValueTask
DisposeAsync() の戻り値は Task ではなく ValueTask です。既に解放済みの場合(_disposed = true)は同期的に完了するため、ヒープアロケーションのない ValueTask が適しています。

Dispose のべき等性と ObjectDisposedException

Dispose()複数回呼ばれても安全でなければなりません。これをべき等性(idempotent)といいます。また、Dispose 後にメソッドを呼び出された場合は ObjectDisposedException をスローします。

べき等性と ObjectDisposedException
public class SafeResource : IDisposable
{
    private StreamReader? _reader;
    private bool _disposed;

    public SafeResource(string path)
    {
        _reader = new StreamReader(path);
    }

    public string? ReadLine()
    {
        // Dispose 後の使用を検出して例外をスロー
        ObjectDisposedException.ThrowIf(_disposed, this);
        // ↑ .NET 7+。それ以前: if (_disposed) throw new ObjectDisposedException(nameof(SafeResource));
        return _reader!.ReadLine();
    }

    public void Dispose()
    {
        if (_disposed) return;   // べき等性: 2回目以降は何もしない

        _reader?.Dispose();
        _reader = null;
        _disposed = true;
    }
}

// Dispose 後に使用するとどうなるか
var resource = new SafeResource("data.txt");
resource.Dispose();
resource.Dispose();  // ← べき等: 例外なし(2回呼んでも安全)

try
{
    resource.ReadLine();  // ← ObjectDisposedException
}
catch (ObjectDisposedException ex)
{
    Console.WriteLine($"解放済みオブジェクトを使用: {ex.ObjectName}");
}

依存オブジェクトの Dispose 連鎖

あるクラスが IDisposable なオブジェクトを所有している場合、そのクラスも IDisposable を実装して子オブジェクトの Dispose を連鎖させます。

Dispose の連鎖パターン
// レポート生成クラス: 複数のリソースを所有する
public class ReportGenerator : IDisposable
{
    private readonly StreamReader  _dataReader;
    private readonly StreamWriter  _reportWriter;
    private readonly HttpClient    _httpClient;
    private bool _disposed;

    public ReportGenerator(string inputPath, string outputPath)
    {
        _dataReader   = new StreamReader(inputPath);
        _reportWriter = new StreamWriter(outputPath);
        _httpClient   = new HttpClient();
    }

    public async Task GenerateAsync()
    {
        // レポート生成処理...
    }

    public void Dispose()
    {
        if (_disposed) return;

        // 所有するすべての IDisposable を解放
        _dataReader.Dispose();
        _reportWriter.Dispose();
        _httpClient.Dispose();

        _disposed = true;
    }
}

// 使用側: ReportGenerator を using で使えばすべて解放される
// ReportGenerator は IDisposable のみ実装なので using(await using は不可)
using var generator = new ReportGenerator("data.csv", "report.txt");
await generator.GenerateAsync();
// ← Dispose() が連鎖して _dataReader, _reportWriter, _httpClient すべて解放

// 注意: 外部から受け取ったリソースは自分では解放しない
public class DataProcessor : IDisposable
{
    private readonly StreamReader _reader;  // 外部から受け取った
    private readonly StreamWriter _writer;  // 自分で作成した
    private bool _ownsReader;              // 自分が所有するか?

    public DataProcessor(StreamReader reader, string outputPath, bool ownsReader = false)
    {
        _reader     = reader;
        _writer     = new StreamWriter(outputPath);  // 自分で作成
        _ownsReader = ownsReader;
    }

    public void Dispose()
    {
        if (_ownsReader) _reader.Dispose();  // 所有している場合のみ解放
        _writer.Dispose();                   // 自分で作成したので必ず解放
    }
}

HttpClient の落とし穴 — 毎回 new は危険

HttpClientIDisposable を実装していますが、毎回 new して using で破棄するのは anti-pattern です。

HttpClient の正しい使い方
// NG: 毎回 new + Dispose
// ソケットが TIME_WAIT 状態で残り、接続枯渇(SocketException)が起きる
for (int i = 0; i < 1000; i++)
{
    using var client = new HttpClient();            // NG!
    var response = await client.GetStringAsync("https://api.example.com/data");
}

// OK1: static / シングルトンとして再利用(最もシンプル)
private static readonly HttpClient _client = new HttpClient
{
    Timeout = TimeSpan.FromSeconds(30)
};

// OK2: IHttpClientFactory を使う(ASP.NET Core 推奨)
// Startup.cs / Program.cs
builder.Services.AddHttpClient<MyService>(client =>
{
    client.BaseAddress = new Uri("https://api.example.com/");
    client.Timeout = TimeSpan.FromSeconds(30);
});

public class MyService
{
    private readonly HttpClient _client;

    // コンストラクタインジェクション(IHttpClientFactory が接続プールを管理)
    public MyService(HttpClient client) => _client = client;

    public async Task<string> GetDataAsync()
        => await _client.GetStringAsync("/data");
}
HttpClient の2つの問題
ソケット枯渇: 毎回 Dispose するとポートが TIME_WAIT(最大240秒)でブロックされ、大量リクエスト時に SocketException が発生します。
DNS 更新の無視: 長期間保持した HttpClient は DNS の変更を検知しません。IHttpClientFactory(ASP.NET Core)はこの両方を解決します。

DI コンテナと Dispose 管理

ASP.NET Core の DI コンテナは IDisposable を実装したサービスを自動的に Dispose 管理します。ライフタイムによって管理の挙動が異なります。

DI コンテナによる Dispose 管理
// DI コンテナへの登録
builder.Services.AddScoped<MyDbContext>();    // リクエスト終了時に Dispose
builder.Services.AddSingleton<CacheService>(); // アプリ終了時に Dispose
builder.Services.AddTransient<TempResource>(); // 取得ごとに新インスタンス(コンテナが Dispose)

// DI コンテナが管理する場合、自分で Dispose してはいけない
public class UserRepository
{
    private readonly MyDbContext _db;

    // DI でインジェクション(ライフタイムはコンテナが管理)
    public UserRepository(MyDbContext db)
    {
        _db = db;
        // _db.Dispose() を自分で呼ばない!コンテナが管理する
    }
}

// Scope を手動で作成する場合は自分で管理
using var scope = app.Services.CreateScope();
var service = scope.ServiceProvider.GetRequiredService<MyDbContext>();
// scope が Dispose されると service も Dispose される
ライフタイム Dispose タイミング 典型的な用途
AddScoped HTTP リクエスト終了時 DbContext・リクエスト単位のリソース
AddSingleton アプリ終了時 キャッシュ・設定・HttpClient
AddTransient DI スコープ終了時(コンテナが Dispose) 軽量で状態を持たないサービス

よくある質問

QDispose() の中で例外を投げていいですか?
A原則として Dispose() の中で例外を投げてはいけません。using ブロック内で例外が発生した後に Dispose() でも例外が発生すると、元の例外が失われます(finally の例外が元の例外を隠蔽)。Dispose 内部の処理は try-catch で包んで例外を抑制し、必要なら Debug.Fail や内部ログで記録するにとどめてください。
QGC.Collect() を呼べば Dispose と同じ効果がありますか?
Aありません。GC.Collect() はマネージドオブジェクトのメモリを回収しますが、アンマネージドリソース(ファイルハンドル・接続)はファイナライザーが実行されなければ解放されません。また GC.Collect() はパフォーマンスに悪影響を与えます。常に using/Dispose() を使ってください。
Qusing は値型(struct)にも使えますか?
Aはい。IDisposable を実装した struct(例: System.Threading.SemaphoreSlim は class ですが System.Buffers.MemoryHandle は struct)も using が使えます。ただし値型の using では boxing が発生することがあります。ref struct(例: Span<T>)は IDisposable を実装できませんが、Dispose() メソッドを持てば using が使えます(C# 8+)。
QDispose が呼ばれた後に using ブロック内で例外が発生したらどうなりますか?
Ausing の展開は try-finally なので、ブロック内で例外が発生しても finally(Dispose)は必ず実行されます。例外はそのまま呼び出し元に伝播します。Dispose() が例外を投げなければ元の例外がそのまま呼び出し元に届きます。

まとめ

トピック ポイント
using の本質 try-finally の糖衣構文。例外が起きても Dispose を保証
using 宣言(C# 8+) スコープ終了まで有効。複数リソースをフラットに書ける
Dispose パターン Dispose(bool disposing) でマネージド/アンマネージドを分離。基底クラスの Dispose を必ず呼ぶ
ファイナライザー 保険(Dispose 忘れ対策)。GC コスト増。GC.SuppressFinalize でキャンセル
SafeHandle アンマネージドリソースの安全なラップ。ファイナライザー不要
IAsyncDisposable 非同期 Dispose に DisposeAsync() を実装。await using で使う
べき等性 _disposed フラグで複数回 Dispose 呼び出しを安全に
ObjectDisposedException Dispose 後の使用で必ずスローする
HttpClient 毎回 new は NG。static/シングルトンか IHttpClientFactory を使う
DI コンテナ Scoped → リクエスト終了時。Singleton → アプリ終了時。自分で Dispose しない