Quanto lavoro devo inserire in un'istruzione lock?


27

Sono uno sviluppatore junior che lavora alla stesura di un aggiornamento per software che riceve dati da una soluzione di terze parti, li archivia in un database e quindi li condiziona per l'utilizzo da parte di un'altra soluzione di terze parti. Il nostro software funziona come un servizio Windows.

Guardando il codice di una versione precedente, vedo questo:

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

        }

La logica sembra chiara: attendere lo spazio nel pool di thread, assicurarsi che il servizio non sia stato arrestato, quindi incrementare il contatore dei thread e mettere in coda il lavoro. _runningWorkersviene decrementato all'interno SomeMethod()all'interno di una lockdichiarazione che le chiamate poi Monitor.Pulse(_workerLocker).

La mia domanda è: c'è qualche vantaggio nel raggruppare tutto il codice in un singolo lock, in questo modo:

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

Sembra che potrebbe causare un po 'più di attesa per altri thread, ma poi sembra che il blocco ripetuto in un singolo blocco logico richiederebbe un po' di tempo. Tuttavia, sono nuovo del multi-threading, quindi presumo che ci siano altre preoccupazioni di cui non sono a conoscenza.

Gli unici altri posti in cui _workerLockerviene bloccato sono dentro SomeMethod(), e solo allo scopo di decrementare _runningWorkers, e quindi fuori foreachdall'attesa che il numero di _runningWorkersvada a zero prima di accedere e tornare.

Grazie per qualsiasi aiuto.

MODIFICA 4/8/15

Grazie a @delnan per la raccomandazione di usare un semaforo. Il codice diventa:

        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()si chiama dentro SomeMethod().


1
Se l'intero blocco è bloccato, in che modo SomeMethod otterrà il blocco per decrementare _runningWorkers?
Russell all'ISC il

@RussellatISC: ThreadPool.QueueUserWorkItem chiama in SomeMethodmodo asincrono, la sezione "blocco" sopra verrà lasciata prima o almeno poco dopo l' SomeMethodinizio del nuovo thread con inizio.
Doc Brown,

Buon punto. Comprendo che lo scopo Monitor.Wait()è quello di rilasciare e riacquisire il blocco in modo che un'altra risorsa ( SomeMethod, in questo caso) possa utilizzarlo. Dall'altro lato, SomeMethodottiene il blocco, decrementa il contatore e quindi chiama Monitor.Pulse()che restituisce il blocco al metodo in questione. Ancora una volta, questa è la mia comprensione.
Joseph,

@Doc, l'ho perso, ma comunque ... sembra che SomeMethod dovrebbe iniziare prima che la foreach si blocchi sulla successiva iterazione o sarebbe ancora appesa al blocco tenuto "while (_runningWorkers> = MaxSimultaneousThreads)".
Russell all'ISC il

@RussellatISC: come già affermato da Joseph: Monitor.Waitrilascia il lucchetto. Consiglio di dare un'occhiata ai documenti.
Doc Brown,

Risposte:


33

Questa non è una questione di prestazioni. È prima di tutto una questione di correttezza. Se si dispone di due istruzioni di blocco, non è possibile garantire l'atomicità per le operazioni che si dividono tra loro o parzialmente al di fuori dell'istruzione di blocco. Su misura per la vecchia versione del tuo codice, questo significa:

Tra la fine del while (_runningWorkers >= MaxSimultaneousThreads)e il _runningWorkers++, può succedere di tutto , perché il codice si arrende e riacquista il blocco tra di loro. Ad esempio, il thread A potrebbe acquisire il blocco per la prima volta, attendere fino a quando non esce qualche altro thread, quindi uscire dal loop e dal lock. Viene quindi preemptato e il thread B entra nell'immagine, anche in attesa di spazio nel pool di thread. Poiché detto altro thread smettere, non v'è spazio in modo da non aspettare molto tempo a tutti. Sia il thread A che il thread B ora procedono in un certo ordine, ciascuno incrementando _runningWorkerse iniziando il proprio lavoro.

Ora, per quanto posso vedere, non ci sono gare di dati, ma logicamente è sbagliato, dato che ora ci sono più di MaxSimultaneousThreadslavoratori in esecuzione. Il controllo è (occasionalmente) inefficace perché il compito di prendere uno slot nel pool di thread non è atomico. Questo dovrebbe interessarti più che piccole ottimizzazioni sulla granularità dei blocchi! (Si noti che al contrario, il blocco troppo presto o troppo a lungo può facilmente portare a deadlock.)

Il secondo frammento risolve questo problema, per quanto posso vedere. Una modifica meno invasiva per risolvere il problema potrebbe essere quella di dare il ++_runningWorkersgiusto whileaspetto, all'interno della prima istruzione di blocco.

Ora, a parte la correttezza, che dire delle prestazioni? Questo è difficile da dire. Generalmente il blocco per un tempo più lungo ("grossolanamente") inibisce la concorrenza, ma come dici tu, questo deve essere bilanciato con il sovraccarico dall'ulteriore sincronizzazione del blocco a grana fine. Generalmente l'unica soluzione è il benchmarking ed essere consapevoli del fatto che ci sono più opzioni che "bloccare tutto ovunque" e "bloccare solo il minimo indispensabile". Sono disponibili numerosi schemi, primitive di concorrenza e strutture di dati thread-safe. Ad esempio, sembra che siano stati inventati i semafori dell'applicazione, quindi considera di utilizzare uno di questi invece di questo contatore bloccato manualmente.


11

IMHO, stai ponendo la domanda sbagliata: non dovresti preoccuparti tanto dei compromessi di efficienza, ma piuttosto della correttezza.

La prima variante assicura che _runningWorkerssi accede solo durante un blocco, ma manca il caso in cui _runningWorkerspotrebbe essere cambiato da un altro thread nello spazio tra il primo blocco e il secondo. Onestamente, il codice mi guarda se qualcuno ha messo ciechi blocchi intorno a tutti i punti di accesso _runningWorkerssenza pensare alle implicazioni e ai potenziali errori. Forse l'autore aveva delle paure superstiziose sull'esecuzione della breakdichiarazione all'interno del lockblocco, ma chi lo sa?

Quindi dovresti effettivamente usare la seconda variante, non perché è più o meno efficiente, ma perché (si spera) più corretta della prima.


D'altro canto, tenere un blocco mentre si intraprende un'attività che potrebbe richiedere l'acquisizione di un altro blocco può causare un deadlock che difficilmente può essere definito comportamento "corretto". Uno dovrebbe assicurarsi che tutto il codice che deve essere fatto come unità sia circondato da un blocco comune, ma si dovrebbe spostarsi all'esterno di quel blocco cose che non hanno bisogno di far parte di quell'unità, in particolare cose che potrebbero richiedere l'acquisizione di altri blocchi .
supercat

@supercat: qui non è il caso, leggi i commenti sotto la domanda originale.
Doc Brown,

9

Le altre risposte sono abbastanza buone e affrontano chiaramente i problemi di correttezza. Lasciami rispondere alla tua domanda più generale:

Quanto lavoro devo inserire in un'istruzione lock?

Cominciamo con i consigli standard a cui alludi e deludi alludi nel paragrafo finale della risposta accettata:

  • Fai il minor lavoro possibile mentre blocchi un oggetto particolare. I blocchi che vengono mantenuti per lungo tempo sono soggetti a contesa e la contesa è lenta. Si noti che ciò implica che la quantità totale di codice in un determinato blocco e la quantità totale di codice in tutte le istruzioni di blocco che si bloccano sullo stesso oggetto sono rilevanti.

  • Avere il minor numero possibile di blocchi, per ridurre la probabilità di deadlock (o livelock).

Il lettore intelligente noterà che questi sono opposti. Il primo punto suggerisce di rompere grandi blocchi in molti più piccoli, più fini, per evitare contese. Il secondo suggerisce di consolidare blocchi distinti nello stesso oggetto di blocco per evitare deadlock.

Cosa possiamo concludere dal fatto che la migliore consulenza standard è completamente contraddittoria? Riceviamo davvero buoni consigli:

  • Non andateci in primo luogo. Se condividi la memoria tra i thread, ti stai aprendo per un mondo di dolore.

Il mio consiglio è, se si desidera la concorrenza, utilizzare i processi come unità di concorrenza. Se non è possibile utilizzare i processi, utilizzare i domini dell'applicazione. Se non puoi utilizzare i domini dell'applicazione, fai in modo che i tuoi thread siano gestiti dalla Task Parallel Library e scrivi il tuo codice in termini di attività di alto livello (lavori) piuttosto che di thread di basso livello (lavoratori).

Se devi assolutamente utilizzare primitivi di concorrenza di basso livello come thread o semafori, utilizzali per creare un'astrazione di livello superiore che catturi ciò di cui hai veramente bisogno. Probabilmente scoprirai che l'astrazione di livello superiore è qualcosa come "eseguire un'attività in modo asincrono che può essere annullata dall'utente", e hey, il TPL lo supporta già, quindi non è necessario eseguire il roll-up. Scoprirai probabilmente che hai bisogno di qualcosa come l'inizializzazione pigra thread-safe; non usare il tuo, uso Lazy<T>, che è stato scritto da esperti. Usa raccolte thread-safe (immutabili o meno) scritte da esperti. Sposta il livello di astrazione il più in alto possibile.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.