web-dev-qa-db-ja.com

ロックステートメント内にどのくらいの作業を配置する必要がありますか?

私は、サードパーティのソリューションからデータを受け取り、それをデータベースに保存し、別のサードパーティのソリューションで使用できるようにデータを調整するソフトウェアの更新を作成する作業をしているジュニア開発者です。私たちのソフトウェアはWindowsサービスとして実行されます。

以前のバージョンのコードを見ると、次のことがわかります。

_        static Object _workerLocker = new object();
        static int _runningWorkers = 0;
        int MaxSimultaneousThreads = 5;

        foreach(int SomeObject in ListOfObjects)
        {
            lock (_workerLocker)
            {
                while (_runningWorkers >= MaxSimultaneousThreads)
                {
                    Monitor.Wait(_workerLocker);
                }
            }

            // check to see if the service has been stopped. If yes, then exit
            if (this.IsRunning() == false)
            {
                break;
            }

            lock (_workerLocker)
            {
                _runningWorkers++;
            }

            ThreadPool.QueueUserWorkItem(SomeMethod, SomeObject);

        }
_

ロジックは明確に思われます。スレッドプールの空き容量を待って、サービスが停止していないことを確認してから、スレッドカウンターをインクリメントして作業をキューに入れます。 __runningWorkers_はSomeMethod()内でlockステートメント内でデクリメントされ、次にMonitor.Pulse(_workerLocker)を呼び出します。

私の質問は:次のように、すべてのコードを単一のlock内にグループ化することには利点がありますか?

_        static Object _workerLocker = new object();
        static int _runningWorkers = 0;
        int MaxSimultaneousThreads = 5;

        foreach (int SomeObject in ListOfObjects)
        {
            // Is doing all the work inside a single lock better?
            lock (_workerLocker)
            {
                // wait for room in ThreadPool
                while (_runningWorkers >= MaxSimultaneousThreads) 
                {
                    Monitor.Wait(_workerLocker);
                }
                // check to see if the service has been stopped.
                if (this.IsRunning())
                {
                    ThreadPool.QueueUserWorkItem(SomeMethod, SomeObject);
                    _runningWorkers++;                  
                }
                else
                {
                    break;
                }
            }
        }
_

他のスレッドを待つのに少し時間がかかるように見えますが、単一の論理ブロックで繰り返しロックするのも時間がかかるようです。ただし、私はマルチスレッドに不慣れであるため、ここでは他にも気づいていない懸念があると思います。

__workerLocker_がロックされる他の唯一の場所はSomeMethod()内であり、__runningWorkers_をデクリメントする目的でのみ、次にforeachの外側で数値を待機しますの__runningWorkers_は、ログに記録して戻る前にゼロになります。

助けてくれてありがとう。

編集4/8/15

セマフォの使用を推奨してくれた@delnanに感謝します。コードは次のようになります。

_        static int MaxSimultaneousThreads = 5;
        static Semaphore WorkerSem = new Semaphore(MaxSimultaneousThreads, MaxSimultaneousThreads);

        foreach (int SomeObject in ListOfObjects)
        {
            // wait for an available thread
            WorkerSem.WaitOne();

            // check if the service has stopped
            if (this.IsRunning())
            {
                ThreadPool.QueueUserWorkItem(SomeMethod, SomeObject);
            }
            else
            {
                break;
            }
        }
_

WorkerSem.Release()SomeMethod()内で呼び出されます。

27
Joseph

これはパフォーマンスの問題ではありません。それは何よりもまず正しさの問題です。 2つのロックステートメントがある場合、それらのステートメント間または部分的にロックステートメントの外側にある操作の原子性を保証することはできません。コードの古いバージョンに合わせて調整すると、これは次のことを意味します。

while (_runningWorkers >= MaxSimultaneousThreads)の終わりと_runningWorkers++の終わりの間では、コードが引き渡されてその間のロックを再取得するため、何でも起こりますになります。たとえば、スレッドAが初めてロックを取得し、他のスレッドが終了するまで待機してから、ループとlockを抜け出す場合があります。次に、スレッドBがプリエンプトされ、スレッドBが画像に入り、スレッドプールのスペースを待ちます。他のスレッドが終了したため、isの空きがあるため、非常に長く待機しません。スレッドAとスレッドBの両方が順番に進み、それぞれが_runningWorkersをインクリメントして作業を開始します。

今、私が見る限りではデータの競合はありませんが、logicallyMaxSimultaneousThreadsを超えるワーカーが存在するため、これは誤りですランニング。スレッドプールのスロットを取得するタスクはアトミックではないため、このチェックは(場合によっては)効果がありません。これは、ロックの細分性に関する小さな最適化以上のものに関係するはずです! (逆に、ロックが早すぎたり長すぎたりすると、簡単にデッドロックが発生する可能性があります。)

2番目のスニペットは、私が見る限り、この問題を修正します。問題を修正するための侵襲性の少ない変更は、whileルックの直後の最初のロックステートメント内に++_runningWorkersを置くことです。

さて、正しさはさておき、パフォーマンスはどうですか?これはわかりにくいです。通常、より長い時間(「粗く」)ロックすると並行性が阻害されますが、細かいロックの追加の同期によるオーバーヘッドとバランスを取る必要があります。一般的に唯一の解決策はベンチマークであり、「どこでもすべてをロックする」および「最低限のみロックする」よりも多くのオプションがあることを認識しています。豊富なパターンと並行処理プリミティブ、およびスレッドセーフなデータ構造が利用可能です。たとえば、これはまさにアプリケーションセマフォが発明されたように思われるので、この手巻きハンドロックカウンターの代わりにそれらの1つを使用することを検討してください。

33
user7043

あなたが間違った質問をしている私見-あなたは効率のトレードオフについてそれほど気にすべきではなく、正確さについてもっと気にするべきです。

最初のバリアントは_runningWorkersがロック中にのみアクセスされることを確認しますが、最初のロックと2番目のロックの間のギャップにある別のスレッドによって_runningWorkersが変更される可能性があるケースを逃します。正直なところ、コードは私に、誰かが_runningWorkersのすべてのアクセスポイントを盲目的にロックした場合、影響と潜在的なエラーを考慮せずに見えます。おそらく、著者はbreakblock内でlockステートメントを実行することについて迷信的な懸念を抱いていましたが、誰が知っていますか?

したがって、2番目のバリアントを実際に使用する必要があります。多かれ少なかれ効率が良いからではなく、(うまくいけば)最初のものよりも正しいからです。

11
Doc Brown

他の答えは非常によく、正確性の懸念に明確に対処します。より一般的な質問をさせてください:

ロックステートメント内にどのくらいの作業を配置する必要がありますか?

受け入れられた回答の最後の段落であなたが暗示し、デルナンが暗示する標準的なアドバイスから始めましょう:

  • 特定のオブジェクトをロックしている間は、できる限り作業を行わないでください。長期間保持されているロックは競合の影響を受けやすく、競合は低速です。これは、totalparticularロックのコード量とtotalall lockステートメントのコード量)同じオブジェクトのロックは両方に関連します。

  • デッドロック(またはライブロック)の可能性を低くするために、ロックの数はできるだけ少なくしてください。

賢い読者なら、これらは反対であることに気付くでしょう。最初の点は、競合を回避するために、大きなロックを多数のより細かい粒度のロックに分割することを示唆しています。 2番目は、デッドロックを回避するために、個別のロックを同じロックオブジェクトに統合することを提案しています。

最高の標準的なアドバイスが完全に矛盾しているという事実から、私たちは何を結論付けることができますか?私たちは実際に良いアドバイスを得ます:

  • そもそもそこに行ってはいけません。スレッド間でメモリを共有している場合は、苦痛の世界に自分を開放していることになります。

私のアドバイスは、並行性が必要な場合は、プロセスを並行性の単位として使用することです。プロセスを使用できない場合は、アプリケーションドメインを使用します。アプリケーションドメインを使用できない場合は、タスクパラレルライブラリでスレッドを管理し、低レベルのスレッド(ワーカー)ではなく高レベルのタスク(ジョブ)の観点からコードを記述します。

スレッドやセマフォなどの低レベルの同時実行プリミティブを絶対的に積極的に使用する必要がある場合は、それらを使用して、より高いレベルの抽象化を構築する本当に必要なものをキャプチャします。より高いレベルの抽象化は「ユーザーがキャンセルできるタスクを非同期で実行する」ようなものであることに気付くでしょう。TPLはすでにそれをサポートしているので、自分でロールする必要はありません。スレッドセーフな遅延初期化のようなものが必要になることに気付くでしょう。自分で振るのではなく、Lazy<T>、専門家によって書かれました。専門家によって書かれたスレッドセーフなコレクション(不変またはその他)を使用します。抽象化レベルを可能な限り高くします。

9
Eric Lippert