it-swarm.com.de

Wie viel Arbeit sollte ich in eine Lock-Anweisung stecken?

Ich bin ein Junior-Entwickler, der daran arbeitet, ein Update für Software zu schreiben, die Daten von einer Drittanbieterlösung empfängt, in einer Datenbank speichert und die Daten dann für die Verwendung durch eine andere Drittanbieterlösung konditioniert. Unsere Software läuft als Windows-Dienst.

Wenn ich mir den Code einer früheren Version ansehe, sehe ich Folgendes:

        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);

        }

Die Logik scheint klar zu sein: Warten Sie auf Platz im Thread-Pool, stellen Sie sicher, dass der Dienst nicht gestoppt wurde, erhöhen Sie dann den Thread-Zähler und stellen Sie die Arbeit in die Warteschlange. _runningWorkers Wird innerhalb von SomeMethod() innerhalb einer lock - Anweisung dekrementiert, die dann Monitor.Pulse(_workerLocker) aufruft.

Meine Frage ist : Gibt es einen Vorteil, wenn Sie den gesamten Code in einem einzigen lock wie folgt gruppieren:

        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;
                }
            }
        }

Es scheint, als würde es ein bisschen länger auf andere Threads warten, aber dann scheint es auch etwas zeitaufwändig zu sein, wiederholt in einem einzelnen logischen Block zu sperren. Ich bin jedoch neu im Multithreading, daher gehe ich davon aus, dass es hier andere Bedenken gibt, die mir nicht bekannt sind.

Die einzigen anderen Stellen, an denen _workerLocker Gesperrt wird, befinden sich in SomeMethod() und nur zum Dekrementieren von _runningWorkers Und dann außerhalb von foreach, um darauf zu warten Die Anzahl von _runningWorkers, die vor dem Protokollieren und Zurückgeben auf Null gesetzt werden soll.

Vielen Dank für jede Hilfe.

EDIT 4/8/15

Vielen Dank an @delnan für die Empfehlung, ein Semaphor zu verwenden. Der Code wird:

        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() wird innerhalb von SomeMethod() aufgerufen.

27
Joseph

Dies ist keine Frage der Leistung. Es ist in erster Linie eine Frage der Richtigkeit. Wenn Sie über zwei Lock-Anweisungen verfügen, können Sie keine Atomizität für Operationen garantieren, die zwischen ihnen oder teilweise außerhalb der Lock-Anweisung verteilt sind. Auf die alte Version Ihres Codes zugeschnitten bedeutet dies:

Zwischen dem Ende der while (_runningWorkers >= MaxSimultaneousThreads) und der _runningWorkers++, alles kann passieren, weil der Code die dazwischen liegende Sperre abgibt und wiedererlangt. Beispielsweise kann Thread A die Sperre zum ersten Mal erhalten, warten, bis ein anderer Thread beendet wird, und dann aus der Schleife und dem lock ausbrechen. Es wird dann vorbelegt, und Thread B betritt das Bild und wartet ebenfalls auf Platz im Thread-Pool. Da der andere Thread beendet wurde, gibt es Platz, so dass es nicht lange dauert. Sowohl Thread A als auch Thread B werden nun in einer bestimmten Reihenfolge fortgesetzt, wobei jeweils _runningWorkers und beginnen ihre Arbeit.

Soweit ich sehen kann, gibt es keine Datenrennen, aber logisch ist es falsch, da es jetzt mehr als MaxSimultaneousThreads Arbeiter gibt Laufen. Die Überprüfung ist (gelegentlich) ineffektiv, da die Aufgabe, einen Slot im Thread-Pool zu belegen, nicht atomar ist. Dies sollte Sie mehr als nur kleine Optimierungen in Bezug auf die Granularität von Sperren betreffen! (Beachten Sie, dass ein zu frühes oder zu langes Sperren leicht zu Deadlocks führen kann.)

Das zweite Snippet behebt dieses Problem, soweit ich sehen kann. Eine weniger invasive Änderung zur Behebung des Problems könnte darin bestehen, ++_runningWorkers direkt nach dem while Look in der ersten lock-Anweisung.

Abgesehen von der Korrektheit, was ist mit der Leistung? Das ist schwer zu sagen. Im Allgemeinen verhindert das Sperren über einen längeren Zeitraum ("grob") die Parallelität, aber wie Sie sagen, muss dies gegen den Overhead durch die zusätzliche Synchronisation des feinkörnigen Sperren abgewogen werden. Im Allgemeinen ist die einzige Lösung das Benchmarking und das Bewusstsein, dass es mehr Optionen gibt als "Alles überall sperren" und "Nur das Nötigste sperren". Es gibt eine Fülle von Mustern und Parallelitätsprimitiven sowie threadsicheren Datenstrukturen. Dies scheint zum Beispiel genau die Anwendungssemaphoren zu sein, für die erfunden wurde. Verwenden Sie daher eine dieser Semaphoren anstelle dieses handgerollten handverriegelten Zählers.

33
user7043

IMHO stellen Sie die falsche Frage - Sie sollten sich nicht so sehr um Effizienzkompromisse kümmern, sondern mehr um Korrektheit.

Die erste Variante stellt sicher, dass auf _runningWorkers Nur während einer Sperre zugegriffen wird, es fehlt jedoch der Fall, in dem _runningWorkers Durch einen anderen Thread in der Lücke zwischen der ersten und der zweiten Sperre geändert werden könnte. Ehrlich gesagt sieht der Code für mich so aus, als hätte jemand alle Zugriffspunkte von _runningWorkers Blind gesperrt, ohne über die Auswirkungen und die möglichen Fehler nachzudenken. Vielleicht hatte der Autor einige abergläubische Befürchtungen, die Anweisung break im Block lock auszuführen, aber wer weiß?

Sie sollten also tatsächlich die zweite Variante verwenden, nicht weil sie mehr oder weniger effizient ist, sondern weil sie (hoffentlich) korrekter ist als die erste.

11
Doc Brown

Die anderen Antworten sind recht gut und gehen eindeutig auf die Korrektheitsbedenken ein. Lassen Sie mich Ihre allgemeinere Frage beantworten:

Wie viel Arbeit sollte ich in eine Lock-Anweisung stecken?

Beginnen wir mit den Standardempfehlungen, auf die Sie im letzten Absatz der akzeptierten Antwort anspielen und auf die Delnan anspielt:

  • Arbeiten Sie so wenig wie möglich, während Sie ein bestimmtes Objekt sperren. Sperren, die für eine lange Zeit gehalten werden, sind Gegenstand von Konflikten, und der Wettbewerb ist langsam. Beachten Sie, dass dies impliziert, dass die gesamt Menge an Code in einer bestimmten Sperre und die gesamt Menge an Code in alle lock-Anweisungen, die Sperre für dasselbe Objekt sind beide relevant.

  • Haben Sie so wenig Sperren wie möglich, um die Wahrscheinlichkeit von Deadlocks (oder Livelocks) zu verringern.

Der kluge Leser wird feststellen, dass dies Gegensätze sind. Der erste Punkt schlägt vor, große Schlösser in viele kleinere, feinkörnigere Schlösser aufzuteilen, um Konflikte zu vermeiden. Die zweite schlägt vor, unterschiedliche Sperren in demselben Sperrobjekt zu konsolidieren, um Deadlocks zu vermeiden.

Was können wir daraus schließen, dass die beste Standardberatung durchaus widersprüchlich ist? Wir bekommen eigentlich gute Ratschläge:

  • Gehen Sie nicht in erster Linie dorthin. Wenn Sie das Gedächtnis zwischen Threads teilen, öffnen Sie sich für eine Welt voller Schmerzen.

Mein Rat ist, wenn Sie Parallelität wünschen, Prozesse als Parallelitätseinheit zu verwenden. Wenn Sie keine Prozesse verwenden können, verwenden Sie Anwendungsdomänen. Wenn Sie keine Anwendungsdomänen verwenden können, lassen Sie Ihre Threads von der Task Parallel Library verwalten und schreiben Sie Ihren Code in Form von Aufgaben auf hoher Ebene (Jobs) anstelle von Threads auf niedriger Ebene (Worker).

Wenn Sie absolut positiv Parallelitätsprimitive auf niedriger Ebene wie Threads oder Semaphoren verwenden müssen, dann verwenden Sie sie, um eine Abstraktion auf höherer Ebene zu erstellen, die erfasst, was Sie wirklich benötigen. Sie werden wahrscheinlich feststellen, dass die Abstraktion auf höherer Ebene so etwas wie "eine Aufgabe asynchron ausführen, die vom Benutzer abgebrochen werden kann" ist, und hey, die TPL unterstützt dies bereits, sodass Sie keine eigene rollen müssen. Sie werden wahrscheinlich feststellen, dass Sie so etwas wie eine thread-sichere verzögerte Initialisierung benötigen. Rollen Sie nicht Ihre eigenen, verwenden Sie Lazy<T>, das von Experten geschrieben wurde. Verwenden Sie threadsichere Sammlungen (unveränderlich oder anderweitig), die von Experten verfasst wurden. Bewegen Sie die Abstraktionsebene so hoch wie möglich.

9
Eric Lippert