Quanto lavoro devo mettere dentro una dichiarazione di blocco?

27

Sono uno sviluppatore junior che lavora alla stesura di un aggiornamento per software che riceve dati da una soluzione di terze parti, lo memorizza in un database e quindi ne condiziona l'utilizzo da parte di un'altra soluzione di terze parti. Il nostro software funziona come un servizio di 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 accodare il lavoro. _runningWorkers è decrementato all'interno di SomeMethod() all'interno di un'istruzione lock che chiama quindi Monitor.Pulse(_workerLocker) .

La mia domanda è: C'è qualche vantaggio nel raggruppare tutto il codice all'interno di un singolo lock , come questo:

        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, potrebbe causare un po 'più di attesa per altri thread, ma poi sembra che il blocco ripetuto in un singolo blocco logico sia anche un po' dispendioso in termini di tempo. Tuttavia, sono nuovo nel multi-threading, quindi presumo che ci siano altre preoccupazioni di cui non sono a conoscenza.

Gli unici altri posti in cui _workerLocker viene bloccato è in SomeMethod() , e solo allo scopo di diminuire _runningWorkers e quindi all'esterno di foreach per attendere il numero di _runningWorkers per passare a zero prima di accedere e tornare.

Grazie per l'aiuto.

EDIT 4/8/15

Grazie a @delnan per la raccomandazione di utilizzare 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() è chiamato all'interno di SomeMethod() .

    
posta Joseph 07.04.2015 - 18:22
fonte

3 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 distribuite tra di loro o parzialmente all'esterno dell'istruzione di blocco. Realizzato su misura per la vecchia versione del tuo codice, significa:

Tra la fine del while (_runningWorkers >= MaxSimultaneousThreads) e il _runningWorkers++ , può accadere qualsiasi cosa , perché il codice arrende e riacquista il blocco in mezzo. Ad esempio, il thread A potrebbe acquisire il blocco per la prima volta, attendere fino all'uscita di qualche altro thread, quindi uscire dal ciclo e lock . Viene quindi preventivato e il thread B entra nell'immagine, anche in attesa di spazio nel pool di thread. Poiché detto altro thread si chiude, ci è room quindi non aspetta molto a lungo. Sia il thread A che il thread B ora procedono in un certo ordine, ciascuno incrementando _runningWorkers e iniziando il loro lavoro.

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

Il secondo snippet risolve questo problema, per quanto posso vedere. Una modifica meno invasiva per risolvere il problema potrebbe mettere il ++_runningWorkers subito dopo l'aspetto while , all'interno della prima istruzione di blocco.

Ora, a parte la correttezza, per quanto riguarda le 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 rispetto al sovraccarico dalla sincronizzazione aggiuntiva del blocco a grana fine. Generalmente l'unica soluzione è il benchmarking e la consapevolezza che ci sono più opzioni di "bloccare tutto ovunque" e "bloccare solo il minimo indispensabile". Sono disponibili numerosi modelli e primitive di concorrenza e strutture dati thread-safe. Ad esempio, questo sembra che siano stati inventati i semafori dell'applicazione stessa, quindi considera di usare uno di quelli al posto di questo contatore a mano bloccato a mano.

    
risposta data 07.04.2015 - 18:42
fonte
11

IMHO stai ponendo la domanda sbagliata - non dovresti preoccuparti tanto dei compromessi sull'efficienza, ma piuttosto della correttezza.

La prima variante si assicura che _runningWorkers sia accessibile solo durante un lock, ma manca il caso in cui _runningWorkers potrebbe essere cambiato da un altro thread nello spazio tra il primo blocco e il secondo. Onestamente, il codice mi sembra che qualcuno abbia bloccato ciecamente tutti i punti di accesso di _runningWorkers senza pensare alle implicazioni e ai potenziali errori. Forse l'autore ha avuto alcuni timori superstiziosi sull'esecuzione dell'istruzione break all'interno del blocco lock , ma chi lo sa?

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

    
risposta data 07.04.2015 - 18:41
fonte
7

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

How much work should I place inside a lock statement?

Iniziamo con il consiglio standard, a cui alludi e delnan allude nel paragrafo finale della risposta accettata:

  • Fai meno lavoro possibile mentre blocchi un oggetto particolare. Le serrature trattenute a lungo sono soggette a contesa e la contesa è lenta. Nota che ciò implica che la quantità di totale di codice in un blocco particolare e la quantità di totale di tutte le istruzioni di blocco che bloccano sullo stesso oggetto sono entrambi rilevanti.

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

Il lettore intelligente noterà che questi sono contrari. Il primo punto suggerisce di scomporre grosse serrature in molte serrature più piccole e più sottili per evitare la contesa. Il secondo suggerisce di consolidare blocchi separati nello stesso oggetto di blocco per evitare deadlock.

Che cosa possiamo concludere dal fatto che il miglior consiglio standard è completamente contraddittorio? In realtà otteniamo buoni consigli:

  • Non andare lì in primo luogo. Se stai condividendo la memoria tra i thread, ti stai aprendo per un mondo di dolore.

Il mio consiglio è, se vuoi la concorrenza, utilizzare i processi come unità di concorrenza. Se non è possibile utilizzare i processi, utilizzare i domini delle applicazioni. Se non è possibile utilizzare domini applicativi, i thread devono essere gestiti dalla Libreria parallela attività e devono scrivere il codice in termini di attività di alto livello (processi) anziché di thread di basso livello (worker).

Se devi assolutamente utilizzare primitive di concorrenza di basso livello come thread o semafori, allora usali per costruire un'astrazione di livello superiore che cattura ciò di cui hai veramente bisogno. Probabilmente scoprirai che l'astrazione di livello superiore è qualcosa come "Esegui un task in modo asincrono che può essere annullato dall'utente", e hey, il TPL lo supporta già, quindi non è necessario eseguire il rollover. Probabilmente troverai che ti serve qualcosa come l'inizializzazione pigra thread-safe; non eseguire il rollover, utilizzare Lazy<T> , che è stato scritto da esperti. Utilizza le raccolte a prova di thread (immutabili o meno) scritte da esperti. Sposta il livello di astrazione più in alto possibile.

    
risposta data 14.04.2015 - 19:29
fonte

Leggi altre domande sui tag