IDisposable と using は C# のリソース管理の根幹です。「using を付ければ Dispose が自動で呼ばれる」という知識は誰でも持っていますが、正しい Dispose パターンの実装・ファイナライザーとの関係・IAsyncDisposable・HttpClient の落とし穴・DI コンテナによる自動管理まで把握している人は意外と少ないです。
本記事ではリソース管理の仕組みを基礎から掘り下げ、実装時に必要な知識をすべて解説します。非同期リソース解放の詳細はasync/await完全ガイドの IAsyncDisposable セクションも参照してください。
なぜ Dispose が必要か — GC とアンマネージドリソースの違い
C# の GC(ガベージコレクター)はマネージドメモリ(C# のオブジェクト)を自動回収しますが、アンマネージドリソース(OS が管理するファイルハンドル・ネットワーク接続・データベース接続など)は自動解放しません。
| リソース種別 | GC で自動解放 | 解放手段 |
|---|---|---|
| マネージドメモリ(通常のオブジェクト) | ○(GC が自動) | 不要 |
ファイルハンドル(FileStream) |
✗ | Dispose()/using |
ネットワーク接続(HttpClient・Socket) |
✗ | Dispose()/using |
DB 接続(SqlConnection・DbContext) |
✗ | Dispose()/using |
アンマネージドメモリ(Marshal.AllocHGlobal) |
✗ | Marshal.FreeHGlobal + Dispose() |
| GDI オブジェクト(フォント・ブラシなど) | ✗ | Dispose()/using |
GC の実行タイミングは実行エンジンが決定し、プログラマーは制御できません。アンマネージドリソースを保持したまま GC を待つと、ファイルが長時間ロックされたり、接続プールが枯渇したりします。
using/Dispose() でスコープを抜けた直後に確実に解放することが基本です。using の仕組み — コンパイラが生成するコード
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() は常に呼ばれる
例外が発生しても
finally ブロックは必ず実行されます。つまり using は「例外が起きても Dispose する」ことを保証する構文です。手書きの try-finally と完全に同じ動作をしますが、using の方が見た目がスッキリします。using ステートメント vs using 宣言(C# 8+)
// 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; |
| スコープの明示性 | ブロックで明示 | 暗黙(スコープ終了まで) |
| 推奨場面 | スコープを狭く限定したいとき | メソッド全体で使うリソース |
// 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 パターンを実装します。重要なのはマネージドリソースとアンマネージドリソースを分けて管理することです。
マネージドリソースのみ持つクラス(最も一般的)
// アンマネージドリソースを直接持たないクラスのシンプルな実装
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 パターン)
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() は 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 はスレッドセーフなカウンティングと確実な解放を内部で実装しています。
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 の実装
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 の戻り値は ValueTaskDisposeAsync() の戻り値は Task ではなく ValueTask です。既に解放済みの場合(_disposed = true)は同期的に完了するため、ヒープアロケーションのない ValueTask が適しています。Dispose のべき等性と ObjectDisposedException
Dispose() は複数回呼ばれても安全でなければなりません。これをべき等性(idempotent)といいます。また、Dispose 後にメソッドを呼び出された場合は 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 を連鎖させます。
// レポート生成クラス: 複数のリソースを所有する
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 は危険
HttpClient は IDisposable を実装していますが、毎回 new して using で破棄するのは anti-pattern です。
// 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");
}
① ソケット枯渇: 毎回 Dispose するとポートが TIME_WAIT(最大240秒)でブロックされ、大量リクエスト時に
SocketException が発生します。② DNS 更新の無視: 長期間保持した
HttpClient は DNS の変更を検知しません。IHttpClientFactory(ASP.NET Core)はこの両方を解決します。DI コンテナと Dispose 管理
ASP.NET Core の DI コンテナは IDisposable を実装したサービスを自動的に 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) | 軽量で状態を持たないサービス |
よくある質問
Dispose() の中で例外を投げてはいけません。using ブロック内で例外が発生した後に Dispose() でも例外が発生すると、元の例外が失われます(finally の例外が元の例外を隠蔽)。Dispose 内部の処理は try-catch で包んで例外を抑制し、必要なら Debug.Fail や内部ログで記録するにとどめてください。GC.Collect() はマネージドオブジェクトのメモリを回収しますが、アンマネージドリソース(ファイルハンドル・接続)はファイナライザーが実行されなければ解放されません。また GC.Collect() はパフォーマンスに悪影響を与えます。常に using/Dispose() を使ってください。IDisposable を実装した struct(例: System.Threading.SemaphoreSlim は class ですが System.Buffers.MemoryHandle は struct)も using が使えます。ただし値型の using では boxing が発生することがあります。ref struct(例: Span<T>)は IDisposable を実装できませんが、Dispose() メソッドを持てば using が使えます(C# 8+)。using の展開は 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 しない |