C#の開発において、実行時にSystem.ObjectDisposedExceptionという例外に遭遇することは珍しくありません。

この例外は、すでにリソースが解放(破棄)されたオブジェクトに対して、プロパティの参照やメソッドの呼び出しを行った場合にスローされます。

マネージドメモリはガベージコレクション(GC)によって自動的に管理されますが、ファイルハンドルやネットワーク接続といったアンマネージドリソースは明示的な破棄が必要です。

本記事では、この例外が発生するメカニズムを深く掘り下げ、2026年現在のモダンなC#開発においてどのように対処すべきかを詳しく解説します。

System.ObjectDisposedExceptionとは何か

System.ObjectDisposedExceptionは、IDisposableインターフェースを実装したクラスにおいて、Disposeメソッドが呼ばれた後にそのオブジェクトを操作しようとした際に発生します。

オブジェクトが「破棄済み」という状態は、そのオブジェクトが保持していた重要なリソースがすでにOSやシステムに返却されていることを意味します。

例えば、閉じられたファイルに対して読み込みを行おうとしたり、切断されたデータベース接続に対してクエリを投げようとしたりする場合が該当します。

この例外のメッセージは通常、「Cannot access a disposed object.」と表示され、どのクラスで発生したかも付記されます。

開発者はこのメッセージを見ることで、オブジェクトのライフサイクル管理に不備があったことを即座に認識する必要があります。

IDisposableインターフェースの役割

C#におけるリソース管理の基本は、IDisposableインターフェースの適切な実装にあります。

Disposeメソッドを呼び出すことで、開発者はガベージコレクタが動作するのを待たずに、即座にメモリ以外の外部リソースを解放できます。

しかし、一度Disposeが呼ばれると、そのインスタンスは再利用不可能な状態になるのが原則です。

多くの標準ライブラリでは、内部的に「破棄フラグ」を保持しており、メソッド実行時にそのフラグをチェックして例外を投げる仕組みになっています。

ObjectDisposedExceptionが発生する主な原因

この例外が発生するシナリオは多岐にわたりますが、多くは「所有権の曖昧さ」や「非同期処理のタイミング」に起因します。

代表的な発生パターンを整理して、それぞれの原因を分析していきましょう。

1. usingブロックの早期終了

最も一般的な原因は、usingブロック内で開始した非同期処理が完了する前に、ブロックを抜けてしまうケースです。

usingブロックを抜けると、自動的にDisposeメソッドが呼び出されます。

その直後に、別のスレッドやタスクがそのオブジェクトにアクセスしようとすると、例外が発生します。

2. 共有オブジェクトの二重破棄や不適切な破棄

複数のクラスで一つのリソースを共有している場合、どこで破棄を行うかの責務が不明確だと問題が起こります。

あるメソッドが良かれと思ってDisposeを呼び出した結果、まだそのオブジェクトを必要としていた別の場所でエラーが発生します。

特にDI(依存性の注入)コンテナを利用している場合、コンテナが管理するインスタンスの寿命を誤解するとこの現象が起きやすくなります。

3. イベントハンドラの解除漏れ

オブジェクトが破棄された後も、イベントハンドラが登録されたまま残っていることがあります。

イベントが発火した際に、すでに破棄されたインスタンスのメソッドが呼び出され、例外がスローされるパターンです。

具体的なコード例:例外が発生するパターン

実際に例外が発生するコードを確認し、何が問題なのかを理解しましょう。

以下の例では、MemoryStreamusingブロックで使用していますが、非同期処理の待機を怠っています。

C#
using System;
using System.IO;
using System.Threading.Tasks;

public class DisposedExample
{
    public async Task RunExample()
    {
        Task task;
        using (var stream = new MemoryStream())
        {
            byte[] data = System.Text.Encoding.UTF8.GetBytes("Hello World");
            stream.Write(data, 0, data.Length);
            
            // 非同期タスクを開始するが、awaitせずにブロックを抜ける
            task = Task.Run(() => 
            {
                // ここで数秒待機すると想定
                Task.Delay(100).Wait();
                // すでにDisposeされたstreamにアクセス
                Console.WriteLine(stream.Length); 
            });
        }

        // ここでタスクの終了を待つ
        await task;
    }
}

上記のコードを実行すると、以下のような結果になります。

実行結果
Unhandled exception. System.ObjectDisposedException: Cannot access a disposed object.
Object name: 'MemoryStream'.
   at System.IO.MemoryStream.get_Length()
   ...

この例では、Task.Runの中身が実行される頃には、メインスレッド側のusingブロックが終了しています。

その結果、stream.Dispose()が実行された後にstream.Lengthを参照しようとしたため、例外がスローされました。

解決策1:usingステートメントの正しい利用

C# 8.0以降では、using宣言(using declaration)という簡潔な記述が可能になりました。

しかし、スコープの終わりで必ず破棄されるという性質は変わらないため、非同期処理との組み合わせには注意が必要です。

非同期処理ではawaitを徹底する

リソースを使用する非同期処理がある場合、必ずその処理が終わるまでusingブロックを維持しなければなりません。

awaitを適切に使用することで、タスクが完了するまでオブジェクトの破棄を遅らせることができます。

C#
public async Task FixedExample()
{
    using (var stream = new MemoryStream())
    {
        byte[] data = System.Text.Encoding.UTF8.GetBytes("Correct Implementation");
        await stream.WriteAsync(data, 0, data.Length);
        
        // しっかりとawaitすることで、書き込みが終わるまでDisposeされない
        await Task.Run(() => 
        {
            Console.WriteLine($"Current stream length: {stream.Length}");
        });
    }
}

解決策2:Disposeパターンの適切な実装

自作クラスでIDisposableを実装する場合、二重にDisposeが呼ばれても安全なように設計する必要があります。

また、破棄済みかどうかを判定するフラグを持つことで、予期せぬアクセスを早期に検知できます。

標準的なDisposeパターンの実装例

以下のコードは、C#における推奨されるDisposeパターンです。

C#
public class ResourceHandler : IDisposable
{
    private bool _disposed = false; // 破棄フラグ

    public void DoSomething()
    {
        // メソッドの冒頭で状態チェック
        ObjectDisposedException.ThrowIf(_disposed, this);

        Console.WriteLine("Executing logic...");
    }

    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }

    protected virtual void Dispose(bool disposing)
    {
        if (_disposed) return;

        if (disposing)
        {
            // マネージドリソースの解放
        }

        // アンマネージドリソースの解放

        _disposed = true;
    }
}

ObjectDisposedException.ThrowIfは、.NET 7以降で導入されたヘルパーメソッドです。

これを利用することで、簡潔かつ効率的に破棄チェックを記述することができます。

自分自身が破棄された後にメソッドが呼ばれた場合、即座に例外を投げることで、不整合な状態での動作を防ぎます。

解決策3:IAsyncDisposableの活用

ネットワーク通信やデータベース操作など、破棄処理自体に非同期処理が必要な場合は、IAsyncDisposableを使用します。

これにより、await usingステートメントを利用した、より安全なリソース管理が可能になります。

C#
public async Task AsyncResourceUsage()
{
    await using (var asyncRes = new MyAsyncResource())
    {
        await asyncRes.ProcessAsync();
    } // ここで非同期にDisposeAsyncが呼ばれる
}

従来のDisposeは同期的な呼び出ししかできなかったため、非同期コンテキストでのリソース解放に無理が生じていました。

IAsyncDisposableを実装することで、クローズ処理が完了するのを待機してから次のステップへ進むことが保証されます。

リソース管理の比較表

リソース管理の手法ごとの特徴を以下の表にまとめました。

手法適用シーンメリット注意点
usingステートメント単一メソッド内の局所的な利用確実に破棄されることが保証される非同期タスクの待ち忘れに弱い
DIコンテナによる管理サービスの依存関係注入ライフサイクルを自動制御できるScopeの設定ミスで早期破棄のリスク
手動Dispose複雑な寿命を持つ長寿命オブジェクト細かいタイミング制御が可能呼び出し忘れによるリークの危険性

デバッグと診断のテクニック

もしObjectDisposedExceptionが発生してしまった場合、どこでオブジェクトが破棄されたかを特定するのは難しいことがあります。

そのような場合は、スタックトレースを詳細に追跡することが不可欠です。

スタックトレースの読み方

例外メッセージに含まれるスタックトレースには、例外が「投げられた場所」が記録されています。

しかし、本当に知りたいのは「どこでDisposeが呼ばれたか」である場合が多いです。

デバッグ時には、Disposeメソッド内にブレークポイントを張り、意図しないタイミングで呼ばれていないか監視しましょう。

Visual Studioの診断ツール活用

Visual Studioの「メモリ使用量」ツールや、プロファイラを使用すると、オブジェクトがどのパスで破棄されたかを追跡できる場合があります。

「.NET オブジェクト割り当ての追跡」を有効にすると、生存期間の短いオブジェクトがどこで寿命を終えたかが可視化されます。

まとめ

System.ObjectDisposedExceptionは、C#開発におけるリソース管理の不備を知らせる重要なシグナルです。

この例外を防ぐためには、オブジェクトの所有権と寿命(ライフサイクル)を明確に設計することが最も重要です。

特にusingブロックと非同期処理(async/await)を組み合わせる際は、タスクの完了を待たずにスコープを抜けていないか、常に意識する必要があります。

また、自作クラスではIDisposableIAsyncDisposableを正しく実装し、破棄済みチェックを行うことで、堅牢なプログラムを構築できます。

モダンなC#の機能を駆使して、安全でメモリ効率の良いアプリケーション開発を目指しましょう。