LoginSignup
39
41

More than 5 years have passed since last update.

C# そのスレッド本当に必要ですか?

Posted at

プログラミングはバグを埋め込む作業です(゚д゚)

バグの密度というは諸説ありますがステップ数10~100に1つのバグが発生してしまうようです。
ステップ数はコーディングスタイルやアルゴリズムの選択によって大きく変わるので、あまりアテにはなりませんけど、私の経験では20~30行のメソッドを一つ実装して、それが一発ですべてのテストに合格する確率は50%いくかどうかという感覚です。
ぽんこつですみません(´・ω・`)

いわばプログラミングは、バグを埋め込みながら除去する作業の繰り返しで、いかにして効率よくバグを発見して退治するのかが大切だと思います。

マルチスレッドのバグは不発弾

ところがマルチスレッドは、実装するのがすっごくす~っごく難しくて、埋め込まれるバグは不発弾のごとく地中深くに潜み、何度上を通っても滅多に爆発しないくせに、プログラムの規模が大きくなったり長い間運用されるといつか爆発したりします。

安全に実装するために抑えておかなければいけない知識は多いのに、とりあえず「動く」ように実装するだけなら、ちょっとした入門記事を読めば何とかなってしまうので、安易にマルチスレッドでやればいいじゃんっていう選択をしがちな人を結構見かけます。

ワーカースレッドから Invoke せずに UI にアクセスするとゴルァしてくれるので、.NET はとっても親切ですが自作のクラス間で同じ失敗をすれば、たちまち不発弾となってしまいます。
スレッドセーフに設計しなかったクラスをシングルトンパターンで使用するのも、かなり危険ですよね。
そもそも人間の思考方法ってシングルスレッド的で、マルチスレッドな思考は難しいからバグを埋め込みやすいと思うんですよね。

漏れなく排他処理するのは大変だよね

変数の操作をするときにはスレッド間で不整合が起こらないように lock ステートメントReaderWriterLock クラス、Mutex クラスなどで排他処理をしたり、簡易的な方法で問題ないケースなら volatile キーワードや Interlocked クラスで済ませたり対応方法は色々あります。

なかでも一番よく使うのは、たぶん lock ステートメントですよね。
lock ステートメントに渡すオブジェクトは、作成したワーカースレッドがそのクラスのインスタンス内で完結するのなら private readonly なオブジェクトを使えばいいですし、それ以上の範囲に及ぶのであれば static readonly など、より広い範囲のオブジェクトを使う必要があります。
使用するオブジェクトのスコープを間違えると全くロックの意味がなくなったりしますが、そのミスが直ちにエラーとなって表面化したなら、あなたはラッキーです( ´∀`)

以下は私が C# を覚えたてのころに書いた排他処理です。

public override void Close()
{
    lock (_syncLock)
    {
        CloseStream();
    }
}

当時ビルドしたアセンブリを .NET Reflector で逆コンパイルすると次のようになります。

public override void Close()
{
    object obj2;
    Monitor.Enter(obj2 = this._syncLock);
Label_000F:
    try
    {
        this.CloseStream();
        goto Label_0022;
    }
    finally
    {
    Label_001A:
        Monitor.Exit(obj2);
    }
Label_0022:
    return;
}

lock ステートメントは Monitor.Enter ~ Monitor.Exit のシンタックスシュガーなんですね。
上記の処理には .NET 4.0 未満でビルドすると問題があって、Monitor.Enter が実行された直後にスレッドが Abort されると try ~ finally に到達していないので Monitor.Exit が呼ばれないためデッドロックを起こします。

これは .NET 4.0 以降でビルドすると以下のように修正されています。
(今でも4.0未満でビルドすると上記の問題は発生します)

public override unsafe void Close()
{
    bool flag;
    object obj2;
    bool flag2;
    flag = 0;
Label_0003:
    try
    {
        Monitor.Enter(obj2 = this._syncLock, &flag);
        this.CloseStream();
        goto Label_002E;
    }
    finally
    {
    Label_001E:
        if ((flag == 0) != null)
        {
            goto Label_002D;
        }
        Monitor.Exit(obj2);
    Label_002D:;
    }
Label_002E:
    return;
}

Monitor.Enter が try の中に入ったのでより安全になりました。
それにしても、使われてない flag2 ってなんのためにあるんでしょうね?(´・ω・`)

個人的にはオブジェクトをスレッドセーフにするために、リフレクションで指定のクラスのラッパーを生成してすべての public メンバーを lock ステートメントで保護するコードを動的に生成するクラスを作ったりして対策してます。
Visual Studio の機能でちょちょいと出来そうな気がするんですけど、まだまだ使いこなせてないです・・・ orz

Win32API のハンドルも注意が必要です

ニコニコ動画と自作ツールでログインセッションを共有するために、IE のクッキーを列挙する FindFirstUrlCacheEntry という Win32API を使ってます。
ファイルを列挙する FindFirstFile の IE キャッシュ版と考えてもらえればおっけーです。

[DllImport(@"wininet",SetLastError=true,CharSet=CharSet.Auto,EntryPoint="FindFirstUrlCacheEntryA",CallingConvention=CallingConvention.StdCall)]
static extern IntPtr FindFirstUrlCacheEntry([MarshalAs(UnmanagedType.LPTStr)] string lpszUrlSearchPattern, IntPtr lpFirstCacheEntryInfo, ref int lpdwFirstCacheEntryInfoBufferSize);

戻り値の IntPtr はキャッシュを列挙するために使用するハンドルで、呼び出したプログラマが責任を持ってクローズしなければいけません。
もし、このメソッドをワーカースレッドで呼び出したのなら、先ほどの Monitor.Enter ~ Monitor.Exit のように例外の発生や Abort によってクローズし損なうことがないように注意しなければいけません。
C++ ではスマートポインタなるものがありましたが、C# の場合は最悪クローズしそこなってもガーベジコレクタによって回収出来るように SafeHandle クラスが用意されています。
そもそも IntPtr を使うのは、よっぽどの理由が無い限り避けるべきだと思います。
これはマルチスレッドだけの問題じゃないですね( ̄▽ ̄;)

public sealed class SafeFindFirstUrlCacheEntryHandle : SafeHandle
{
    [DllImport("wininet.dll", SetLastError = true)]
    private static extern bool FindCloseUrlCache(IntPtr hEnumHandle);

    private SafeFindFirstUrlCacheEntryHandle()
        : base(IntPtr.Zero, true)
    {
    }

    private SafeFindFirstUrlCacheEntryHandle(IntPtr handle, bool ownsHandle)
        : base(handle, ownsHandle)
    {
        SetHandle(handle);
    }

    public static SafeFindFirstUrlCacheEntryHandle InvalidHandle
    {
        get { return new SafeFindFirstUrlCacheEntryHandle(IntPtr.Zero, true); }
    }

    public override bool IsInvalid
    {
        get { return IsClosed || handle == IntPtr.Zero; }
    }

    protected override bool ReleaseHandle()
    {
        return FindCloseUrlCache(handle);
    }
}

static extern SafeFindFirstUrlCacheEntryHandle FindFirstUrlCacheEntry([MarshalAs(UnmanagedType.LPTStr)] string lpszUrlSearchPattern, SafeAllocHGlobalHandle lpFirstCacheEntryInfo, ref int lpdwFirstCacheEntryInfoBufferSize);

このように SafeHandle を継承したクラスを作成して、ReleaseHandle メソッドで開放する処理を実装しておけば、最悪でも、いつかガーベジコレクタによって Dispose が呼ばれてクローズされます。
IDisposable を継承しているので using ステートメントを使うとお手軽かもしれませんね。
なお2番目の引数は Marshal.AllocHGlobal メソッドで確保したバッファを渡さなければいけないのですが、これも IntPtr のまま使うと危険なので SafeHandleZeroOrMinusOneIsInvalid クラスから継承したクラスを使用しています。

public abstract class SafeUnmanagedMemoryHandle : SafeHandleZeroOrMinusOneIsInvalid
{
    // Marshal クラスの Read / Write / Copy 系メソッドのラッパー実装
}

[SecurityPermission(SecurityAction.Demand, UnmanagedCode = false)]
public sealed class SafeAllocHGlobalHandle : SafeUnmanagedMemoryHandle
{
    private SafeAllocHGlobalHandle()
        : base()
    {
    }

    private SafeAllocHGlobalHandle(IntPtr handle, bool ownsHandle)
        : base(handle, ownsHandle)
    {
        SetHandle(handle);
    }

    public static SafeAllocHGlobalHandle InvalidHandle
    {
        get { return new SafeAllocHGlobalHandle(IntPtr.Zero, true); }
    }

    public static SafeAllocHGlobalHandle Alloc(int size)
    {
        SafeAllocHGlobalHandle myself = new SafeAllocHGlobalHandle();
        RuntimeHelpers.PrepareConstrainedRegions();
        try { }
        finally
        {
            myself.handle = Marshal.AllocHGlobal(size);
        }

        if (myself.handle == IntPtr.Zero)
        {
            throw new OutOfMemoryException();
        }

        return myself;
    }

    public static SafeAllocHGlobalHandle Alloc(string text)
    {
        SafeAllocHGlobalHandle myself = new SafeAllocHGlobalHandle();
        RuntimeHelpers.PrepareConstrainedRegions();
        try { }
        finally
        {
            myself.handle = Marshal.StringToHGlobalUni(text);
        }

        if (myself.handle == IntPtr.Zero)
        {
            throw new OutOfMemoryException();
        }

        return myself;
    }

    protected override bool ReleaseHandle()
    {
        Marshal.FreeHGlobal(handle);
        return true;
    }
}

Alloc メソッドで AllocHGlobal メソッドを呼び出してハンドルを受け取っている処理がありますが、例外が発生しても必ず代入されることを期待しています。
RuntimeHelpers.PrepareConstrainedRegions メソッドを実行することで、それに続く finally の処理中に例外が割り込まないようになるようですが、ドキュメントが難しすぎて私の手には負えません(;´д`)
SecureString クラスのソースを読むと、このパターンが頻出していますよ。
lock ステートメントのほうは使われてないけど大丈夫なのかなぁ・・・謎だわ

詳しく知りたい方は以下を参考にしてください。

こんな難しいもの使いこなせないぉ(´;ω;`)

これまで注意点の極一部を紹介しましたが、正直マルチスレッドは私の手にあまる怪物です。
メリットとデメリットをよく考えて使わないと本当に痛い目を見ます。
C# では簡単に動くコードを書けますが、きちんと使いこなすのは、とてつもなく難しいです><
業務アプリケーションで2~3秒 UI が固まって画面が白くなるとしても、別に機能的にはなんの問題もないですし、マルチスレッドにしないで済むならやらない方がいいと思っています。
マルチスレッドを使うときは、それこそ goto を使うときと同じくらいの後ろめたさを感じつつも、使わざるを得ない理由を頭の中にいくつも並べ立てて自分に言い訳しながら書くくらいの心構えで丁度いいと思います。

ネットワーク処理などで UI が固まるのを避けたいならば、非同期メソッドを持ったライブラリ(内部でスレッドの処理が完結しているクラス)を利用してメインスレッドでコールバックを待つようなシンプルな実装が無難です。
長時間の複雑な処理が必要であれば、思い切って別プロセスにして終了を待つほうが安全に作れますよ。
まあ趣味のプログラムでは結構安易に使っちゃうんですけどね(ノ▽≦*)

39
41
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
39
41