はじめに
C#はGC(ガベージコレクション)がメモリを自動解放してくれるため、リソース管理を意識しなくても動いてしまうことが多い言語です。しかし、DB接続・ファイルハンドル・ソケットのようなアンマネージドリソースはGCの対象外であり、Disposeを正しく呼ばないと解放が遅延し、接続プール枯渇やハンドル不足を引き起こします。ローカル開発では気づかず、本番の高負荷時にだけ顕在化するのがこの種の不具合の特徴です。本記事ではusing/IDisposableまわりで実務上よく発生する3つの落とし穴を扱います。
前提知識 / 用語整理
- マネージドリソース: GCが管理するメモリ上のオブジェクト。明示的な解放は不要
- アンマネージドリソース: DB接続、ファイルハンドル、ソケットなど、OSやネイティブ層が管理するリソース。GCでは解放タイミングを制御できない
-
IDisposable:Dispose()メソッドを通じてアンマネージドリソースを明示的に解放するためのインターフェース -
IAsyncDisposable:DisposeAsync()による非同期のリソース解放を提供するインターフェース(C# 8以降) -
ファイナライザ:
Disposeが呼ばれなかった場合の最終防衛ラインだが、実行タイミングはGCに依存し不定になります
落とし穴1: usingを付け忘れて接続がリークする
再現コード
// ❌ usingなしでSqlConnectionを使い回す
public List<string> GetNames()
{
var connection = new SqlConnection(_connectionString);
connection.Open();
var command = new SqlCommand("SELECT Name FROM Users", connection);
var reader = command.ExecuteReader();
var names = new List<string>();
while (reader.Read())
{
names.Add(reader.GetString(0));
}
return names; // connectionがCloseもDisposeもされない
}
なぜ起きるか
SqlConnectionは内部でコネクションプールから物理接続を借りています。Dispose(またはClose)を呼ばないと接続がプールに返却されず、プール上限(既定は100)に達するとInvalidOperationException: Timeout expiredが発生します。この現象はリクエスト数が少ないローカル環境では再現しにくく、本番で同時接続数が増えたときに初めて顕在化します。
正しい書き方
// ◎ using宣言(C# 8以降)で確実にDisposeさせる
public List<string> GetNames()
{
using var connection = new SqlConnection(_connectionString);
connection.Open();
using var command = new SqlCommand("SELECT Name FROM Users", connection);
using var reader = command.ExecuteReader();
var names = new List<string>();
while (reader.Read())
{
names.Add(reader.GetString(0));
}
return names;
}
using宣言はスコープ(このメソッドの終端)を抜けるタイミングで自動的にDisposeを呼び出します(ただし、例外発生時も含めて確実に実行されるのはtry/finallyと同等の保証があるためです)。ネストした複数のIDisposableを扱う場合、using宣言はブロックのネストを減らせるため可読性の面でも有利になります。
落とし穴2: IDisposableな内部リソースの伝播漏れ
再現コード
// ❌ HttpClientを内部で保持しているのにIDisposableを実装していない
public class ExternalApiClient
{
private readonly HttpClient _httpClient;
public ExternalApiClient()
{
_httpClient = new HttpClient();
}
public Task<string> GetDataAsync(string path) =>
_httpClient.GetStringAsync(path);
}
// 呼び出し側
var client = new ExternalApiClient();
var data = await client.GetDataAsync("/users");
// client自体をDisposeしても内部のHttpClientは解放されない
なぜ起きるか
ExternalApiClientがIDisposableを実装していないため、コンパイラも呼び出し側も「このクラスはリソースを持っている」と検知できません。内部で保持しているHttpClientのソケットハンドルが解放されないまま蓄積し、長時間稼働するプロセスではハンドルリークにつながります(HttpClient自体は使い回しが推奨されるため、このケースは"生成のたびにDisposeし忘れる"パターンで特に問題になります)。
正しい書き方
// ◎ 内部リソースを持つクラスはIDisposableを実装して伝播する
public class ExternalApiClient : IDisposable
{
private readonly HttpClient _httpClient;
private bool _disposed;
public ExternalApiClient()
{
_httpClient = new HttpClient();
}
public Task<string> GetDataAsync(string path) =>
_httpClient.GetStringAsync(path);
public void Dispose()
{
if (_disposed) return;
_httpClient.Dispose();
_disposed = true;
}
}
(ただし、HttpClientはソケット枯渇を避けるためIHttpClientFactory経由で使い回すのが推奨パターンです。このクラス自体がHttpClientを都度newする設計になっている場合は、まずIHttpClientFactoryへの置き換えを検討してください)
クラス設計の段階で「このフィールドはIDisposableか」を確認し、該当する場合は保持側のクラスもIDisposableを実装して呼び出し元に解放義務を伝播させる必要があります。
落とし穴3: 非同期リソースを同期的にDisposeしてしまう
再現コード
// ❌ IAsyncDisposableを実装した型を同期usingで扱う
public async Task<int> CountUsersAsync()
{
using var connection = new NpgsqlConnection(_connectionString);
await connection.OpenAsync();
using var command = new NpgsqlCommand("SELECT COUNT(*) FROM users", connection);
var result = await command.ExecuteScalarAsync();
return Convert.ToInt32(result);
}
なぜ起きるか
NpgsqlConnectionのような一部のDBプロバイダの接続クラスはIAsyncDisposableを実装しており、DisposeAsync()は接続クローズ時のネットワークI/Oを非同期で処理します。しかし同期のusingを使うとDispose()が呼ばれ、内部で非同期処理を同期待ち(sync-over-async)する実装になっている場合があります。この同期待ちはスレッドプールのスレッドをブロックし、高負荷時にスレッドプール枯渇によるレイテンシ悪化を招きます。
正しい書き方
// ◎ await usingでDisposeAsyncを使う
public async Task<int> CountUsersAsync()
{
await using var connection = new NpgsqlConnection(_connectionString);
await connection.OpenAsync();
await using var command = new NpgsqlCommand("SELECT COUNT(*) FROM users", connection);
var result = await command.ExecuteScalarAsync();
return Convert.ToInt32(result);
}
await usingは対象の型がIAsyncDisposableを実装している場合にDisposeAsync()を呼び出します(IDisposableしか実装していない型に対してもawait usingは使用でき、その場合は同期のDispose()にフォールバックします)。DBプロバイダやストリーム系クラスをasyncメソッド内で扱う場合は、まず対象の型がIAsyncDisposableを実装しているかをドキュメントで確認する習慣をつけてください。
ベストプラクティスまとめ
-
アンマネージドリソースを扱うローカル変数には必ず
using宣言を付ける -
クラスが
IDisposableなフィールドを保持する場合、そのクラス自身もIDisposableを実装して解放義務を伝播させる -
asyncメソッド内でIAsyncDisposableを実装した型を扱う場合はawait usingを使う -
HttpClientを都度newしている箇所がないか確認し、IHttpClientFactoryへの置き換えを検討する - コード分析ルール(CA2000: オブジェクトを破棄する前にスコープを失わないようにする、など)をCIに組み込み、Dispose漏れを機械的に検知する
おわりに
using/IDisposableのミスは単体テストでは検知しづらく、負荷やプロセスの稼働時間が伸びて初めて表面化するため厄介です。実装時に「このリソースはアンマネージドか」「非同期解放が必要か」を意識するだけで、多くのリークは未然に防げます。