Il multi-threading senza blocco è per veri esperti di threading


86

Stavo leggendo una risposta che Jon Skeet ha dato a una domanda e in essa ha menzionato questo:

Per quanto mi riguarda, il multi-threading senza lock è per veri esperti di threading, di cui non sono uno.

Non è la prima volta che lo sento, ma trovo pochissime persone che parlano di come lo fai effettivamente se sei interessato a imparare come scrivere codice multi-threading senza blocchi.

Quindi la mia domanda è oltre ad imparare tutto ciò che puoi sul threading, ecc., Da dove inizi a provare a imparare a scrivere in modo specifico codice multi-threading senza blocchi e quali sono alcune buone risorse.

Saluti


Uso le piattaforme gcc, linux e X86 / X68. Senza lucchetto non è così difficile come sembrano tutti! I builtin atomici di gcc hanno barriere di memoria su Intel, ma non importa nella vita reale. Ciò che conta è che la memoria venga modificata atomicamente. Quando si progettano strutture di dati "libere da blocco" si scuote il fatto che non ha importanza quando un altro thread vede un cambiamento. Elenchi collegati singoli, elenchi da saltare, tabelle hash, elenchi gratuiti, ecc.Sono tutti abbastanza facili da fare senza blocco. Lock free non è per tutto. È solo un altro strumento adatto a determinate situazioni.
johnnycrash


Votare per chiudere come raccomandazione di risorse o non chiarire ciò che stai chiedendo.
Ciro Santilli 郝海东 冠状 病 六四 事件 法轮功

Risposte:


100

Le attuali implementazioni "senza blocco" seguono lo stesso schema per la maggior parte del tempo:

  • * leggi un po 'di stato e creane una copia **
  • * modifica copia **
  • eseguire un'operazione interbloccata
  • riprova se fallisce

(* opzionale: dipende dalla struttura dati / algoritmo)

L'ultimo pezzo è stranamente simile a uno spinlock. In effetti, è uno spinlock di base . :)
Sono d'accordo con @nobugz su questo: il costo delle operazioni interbloccate utilizzate nel multi-threading senza blocchi è dominato dalle attività di cache e coerenza della memoria che deve svolgere .

Ciò che si guadagna, tuttavia, con una struttura dati "priva di blocchi" è che i "lucchetti" hanno una grana molto fine . Ciò riduce la possibilità che due thread simultanei accedano allo stesso "blocco" (posizione di memoria).

Il trucco la maggior parte delle volte è che non hai blocchi dedicati - invece tratti, ad esempio, tutti gli elementi in un array o tutti i nodi in un elenco collegato come uno "spin-lock". Leggi, modifichi e provi ad aggiornare se non ci sono stati aggiornamenti dall'ultima lettura. Se c'era, riprova.
Questo rende il tuo "blocco" (oh, scusa, non bloccante :) a grana fine, senza introdurre requisiti aggiuntivi di memoria o risorse.
Rendendolo più a grana fine diminuisce la probabilità di attese. Renderlo il più dettagliato possibile senza introdurre requisiti di risorse aggiuntive sembra fantastico, non è vero?

La maggior parte del divertimento, tuttavia, può derivare dall'assicurare un corretto carico / ordine del negozio .
Contrariamente alle proprie intuizioni, le CPU sono libere di riordinare le letture / scritture della memoria - sono molto intelligenti, tra l'altro: sarà difficile osservarlo da un singolo thread. Tuttavia, si verificheranno problemi quando inizi a eseguire il multi-threading su più core. Le tue intuizioni verranno meno: solo perché un'istruzione è all'inizio del tuo codice, non significa che in realtà accadrà prima. Le CPU possono elaborare le istruzioni fuori ordine: e a loro piace farlo soprattutto alle istruzioni con accessi alla memoria, per nascondere la latenza della memoria principale e fare un uso migliore della loro cache.

Ora, è sicuro contro l'intuizione che una sequenza di codice non fluisce "dall'alto verso il basso", invece funziona come se non ci fosse alcuna sequenza - e può essere chiamata "parco giochi del diavolo". Credo che non sia possibile dare una risposta esatta su quali riordini di carico / negozio avranno luogo. Invece, si parla sempre in termini di mays and mights and cans e si prepara al peggio. "Oh, la CPU potrebbe riordinare questa lettura prima di quella scrittura, quindi è meglio mettere una barriera di memoria proprio qui, in questo punto."

Questioni sono complicate dal fatto che anche questi mays e mights possono differire tra architetture di CPU. Esso potrebbe essere il caso, per esempio, che qualcosa che è garantito per non accadere in un'architettura potrebbe accadere su un altro.


Per ottenere un multi-threading "senza blocco", è necessario comprendere i modelli di memoria.
Ottenere il modello di memoria e le garanzie corretti non è comunque banale, come dimostra questa storia, per cui Intel e AMD hanno apportato alcune correzioni alla documentazione MFENCEprovocando qualche scalpore tra gli sviluppatori di JVM . Come si è scoperto, la documentazione su cui gli sviluppatori hanno fatto affidamento sin dall'inizio non era così precisa in primo luogo.

I blocchi in .NET danno luogo a una barriera di memoria implicita, quindi sei sicuro di usarli (la maggior parte delle volte, cioè ... vedi ad esempio questa grandezza di Joe Duffy - Brad Abrams - Vance Morrison su inizializzazione pigra, blocchi, volatili e memoria barriere. :) (Assicurati di seguire i link in quella pagina.)

Come bonus aggiuntivo, verrai introdotto al modello di memoria .NET in una missione secondaria . :)

C'è anche un "oldie but goldie" di Vance Morrison: What Every Dev Must Know About Multithreaded Apps .

... e ovviamente, come ha detto @Eric , Joe Duffy è una lettura definitiva sull'argomento.

Un buon STM può avvicinarsi il più possibile al blocco a grana fine e probabilmente fornirà prestazioni vicine o alla pari con un'implementazione fatta a mano. Uno di questi è STM.NET dai progetti DevLabs di MS.

Se non sei un fanatico solo .NET, Doug Lea ha fatto un ottimo lavoro in JSR-166 .
Cliff Click ha un'interpretazione interessante delle tabelle hash che non si basa sullo striping dei blocchi, come fanno le tabelle hash simultanee Java e .NET, e sembrano scalare bene fino a 750 CPU.

Se non hai paura di avventurarti nel territorio di Linux, il seguente articolo fornisce ulteriori informazioni sugli interni delle attuali architetture di memoria e su come la condivisione della linea di cache può distruggere le prestazioni: Cosa dovrebbe sapere ogni programmatore sulla memoria .

@ Ben ha fatto molti commenti su MPI: Sono sinceramente d'accordo sul fatto che MPI possa brillare in alcune aree. Una soluzione basata su MPI può essere più facile da ragionare, più facile da implementare e meno soggetta a errori di un'implementazione di blocco a metà che cerca di essere intelligente. (È comunque - soggettivamente - vero anche per una soluzione basata su STM.) Scommetto anche che è anni luce più facile scrivere correttamente un'applicazione decente distribuita ad esempio in Erlang, come suggeriscono molti esempi di successo.

MPI, tuttavia, ha i suoi costi e i suoi problemi quando viene eseguito su un unico sistema multi-core . Ad esempio, a Erlang, ci sono problemi da risolvere intorno alla sincronizzazione della pianificazione dei processi e delle code di messaggi .
Inoltre, in fondo, i sistemi MPI di solito implementano una sorta di pianificazione N: M cooperativa per "processi leggeri". Questo ad esempio significa che c'è un inevitabile cambio di contesto tra processi leggeri. È vero che non si tratta di un "cambio di contesto classico" ma principalmente di un'operazione in spazio utente e può essere eseguita velocemente - tuttavia dubito sinceramente che possa essere portata sotto i 20-200 cicli necessari per un'operazione interbloccata . Il cambio di contesto in modalità utente è sicuramente più lentoanche nella libreria Intel McRT. N: M la programmazione con processi leggeri non è nuova. Gli LWP erano presenti in Solaris per molto tempo. Sono stati abbandonati. C'erano fibre nell'NT. Adesso sono perlopiù una reliquia. C'erano "attivazioni" in NetBSD. Sono stati abbandonati. Linux aveva il suo punto di vista sull'argomento del threading N: M. Sembra essere un po 'morto ormai.
Di tanto in tanto, ci sono nuovi contendenti: ad esempio McRT di Intel , o più recentemente User-Mode Scheduling insieme a ConCRT di Microsoft.
Al livello più basso, fanno ciò che fa uno scheduler MPI N: M. Erlang, o qualsiasi sistema MPI, potrebbe trarre grandi vantaggi dai sistemi SMP sfruttando il nuovo UMS .

Immagino che la domanda dell'OP non riguardi i meriti e gli argomenti soggettivi a favore / contro qualsiasi soluzione, ma se dovessi rispondere, immagino che dipenda dal compito: per costruire strutture dati di base di basso livello e ad alte prestazioni che girano su un un singolo sistema con molti core , le tecniche low-lock / "lock-free" o un STM produrranno i migliori risultati in termini di prestazioni e probabilmente batterebbero una soluzione MPI in qualsiasi momento in termini di prestazioni, anche se le rughe di cui sopra sono appianate ad esempio a Erlang.
Per costruire qualcosa di moderatamente più complesso che gira su un singolo sistema, forse sceglierei il classico blocco a grana grossa o, se le prestazioni sono di grande interesse, un STM.
Per costruire un sistema distribuito, un sistema MPI sarebbe probabilmente una scelta naturale.
Notare che ci sono implementazioni MPI anche per .NET (anche se sembrano non essere così attive).


1
Sebbene questa risposta contenga molte buone informazioni, l'idea principale che gli algoritmi senza blocco e le strutture dati siano essenzialmente solo una raccolta di spinlock a grana molto fine è sbagliata. Mentre di solito vedrai cicli di tentativi in ​​strutture prive di blocchi, il comportamento è molto diverso: i blocchi (inclusi gli spinlock) acquisiscono esclusivamente alcune risorse e altri thread non possono avanzare mentre sono tenuti. Il "nuovo tentativo" in questo senso è semplicemente aspettare che la risorsa esclusiva venga rilasciata.
BeeOnRope

1
Gli algoritmi senza lock, d'altra parte, non utilizzano CAS o altre istruzioni atomiche per acquisire una risorsa esclusiva, ma piuttosto per completare qualche operazione. Se falliscono, è dovuto a una corsa a grana fine temporale con un altro thread, e in quel caso l'altro thread ha fatto progressi (ha completato la sua operazione). Se un thread è sospetto a tempo indeterminato, tutti gli altri thread possono ancora avanzare. Questo è sia qualitativamente che per quanto riguarda le prestazioni molto diverso dalle serrature esclusive. Il numero di "tentativi" è solitamente molto basso per la maggior parte dei cicli CAS anche in condizioni di forte contesa ...
BeeOnRope

1
... ma questo ovviamente non implica un buon ridimensionamento: la contesa per una singola posizione di memoria sarà sempre piuttosto lenta sulle macchine SMP, solo a causa delle latenze inter-core tra socket, anche se il numero di errori CAS è Basso.
BeeOnRope

1
@ AndrasVass - Immagino che dipenda anche dal codice "buono" o "cattivo" senza blocco. Certamente chiunque può scrivere una struttura e chiamarla lock-free mentre in realtà utilizza solo uno spinlock in modalità utente e non soddisfa nemmeno la definizione. Vorrei inoltre incoraggiare tutti i lettori interessati a dare un'occhiata a questo documento di Herlihy e Shavit che esamina in modo formale le varie categorie di algoritmi basati su blocco e senza blocco. Anche qualsiasi cosa di Herlihy su questo argomento è consigliata.
BeeOnRope

1
@AndrasVass - Non sono d'accordo. La maggior parte delle strutture classiche prive di blocchi (elenchi, code, mappe simultanee, ecc.) Non avevano alcuna rotazione nemmeno per strutture mutabili condivise e le implementazioni pratiche esistenti delle stesse in, ad esempio, Java seguono lo stesso familiarità con ciò che è disponibile in C o C ++ compilato nativamente ed è più difficile lì a causa dell'assenza di garbage collection). Forse tu ed io abbiamo una definizione diversa di spinning: non considero il "CAS-retry" che trovi nelle cose senza blocco "spinning". IMO "spinning" implica un'attesa a caldo.
BeeOnRope

27

Il libro di Joe Duffy:

http://www.bluebytesoftware.com/books/winconc/winconc_book_resources.html

Scrive anche un blog su questi argomenti.

Il trucco per ottenere i programmi low-lock giusti è capire a un livello profondo esattamente quali sono le regole del modello di memoria sulla tua particolare combinazione di hardware, sistema operativo e ambiente di runtime.

Personalmente non sono neanche lontanamente abbastanza intelligente da eseguire una corretta programmazione low-lock oltre InterlockedIncrement, ma se lo sei, fantastico, fallo. Assicurati solo di lasciare molta documentazione nel codice in modo che le persone che non sono intelligenti come tu non rompano accidentalmente uno degli invarianti del modello di memoria e introducano un bug impossibile da trovare.


38
Quindi, se sia Eric Lippert che Jon Skeet pensano che la programmazione senza blocco sia solo per persone più intelligenti di loro, allora scapperò umilmente via urlando immediatamente dall'idea. ;-)
dodgy_coder

20

Al giorno d'oggi non esiste qualcosa come "threading senza blocco". Era un parco giochi interessante per il mondo accademico e simili, alla fine del secolo scorso, quando l'hardware del computer era lento e costoso. L'algoritmo di Dekker è sempre stato il mio preferito, l'hardware moderno l'ha messo a dura prova. Non funziona più.

Due sviluppi hanno posto fine a questo: la crescente disparità tra la velocità della RAM e la CPU. E la capacità dei produttori di chip di inserire più di un core della CPU su un chip.

Il problema della velocità della RAM richiedeva ai progettisti di chip di mettere un buffer sul chip della CPU. Il buffer memorizza codice e dati, rapidamente accessibili dal core della CPU. E può essere letto e scritto da / sulla RAM a una velocità molto più lenta. Questo buffer è chiamato cache della CPU, la maggior parte delle CPU ne ha almeno due. La cache di primo livello è piccola e veloce, la seconda è grande e più lenta. Finché la CPU può leggere dati e istruzioni dalla cache di primo livello, funzionerà velocemente. Un errore nella cache è molto costoso, mette la CPU in sospensione per un massimo di 10 cicli se i dati non sono nella 1a cache, fino a 200 cicli se non è nella 2a cache e deve essere letto da RAM.

Ogni core della CPU ha la propria cache, memorizzano la propria "vista" della RAM. Quando la CPU scrive i dati, la scrittura viene eseguita nella cache che viene quindi, lentamente, scaricata nella RAM. Inevitabile, ogni core avrà ora una visione diversa del contenuto della RAM. In altre parole, una CPU non sa cosa ha scritto un'altra CPU fino a quando il ciclo di scrittura della RAM non è completato e la CPU non aggiorna la propria vista.

Ciò è drammaticamente incompatibile con il threading. È sempre molto a cuore quello che lo stato di un altro thread è quando è necessario leggere i dati che è stato scritto da un altro thread. Per garantire ciò, è necessario programmare esplicitamente una cosiddetta barriera di memoria. È una primitiva della CPU di basso livello che garantisce che tutte le cache della CPU siano in uno stato coerente e abbiano una visualizzazione aggiornata della RAM. Tutte le scritture in sospeso devono essere scaricate nella RAM, quindi le cache devono essere aggiornate.

Questo è disponibile in .NET, il metodo Thread.MemoryBarrier () ne implementa uno. Dato che questo è il 90% del lavoro svolto dall'istruzione lock (e il 95% del tempo di esecuzione), semplicemente non sei avanti evitando gli strumenti che .NET ti offre e cercando di implementarne uno tuo.


2
@ Davy8: la composizione rende ancora difficile. Se ho due tabelle hash prive di blocchi e come utente accedo a entrambe, ciò non garantirà la coerenza dello stato nel suo insieme. Il più vicino che puoi arrivare oggi è STM dove puoi mettere i due accessi, ad esempio, in un unico atomicblocco. Tutto sommato, consumare strutture senza blocchi può essere altrettanto complicato in molti casi.
Andras Vass

4
Potrei sbagliarmi, ma penso che tu abbia spiegato male come funziona la coerenza della cache. La maggior parte dei processori multicore moderni ha cache coerenti, il che significa che l'hardware della cache si occupa di assicurarsi che tutti i processi abbiano la stessa visualizzazione del contenuto della RAM, bloccando le chiamate "di lettura" fino a quando tutte le chiamate di "scrittura" corrispondenti non sono state completate. La documentazione Thread.MemoryBarrier () ( msdn.microsoft.com/en-us/library/… ) non dice assolutamente nulla sul comportamento della cache: è semplicemente una direttiva che impedisce al processore di riordinare le letture e le scritture.
Brooks Moses

7
"Al giorno d'oggi non esiste una cosa come il" threading senza blocco "." Ditelo ai programmatori Erlang e Haskell.
Juliet

4
@HansPassant: "Non esiste" threading senza blocco "oggigiorno". F #, Erlang, Haskell, Cilk, OCaml, Task Parallel Library (TPL) di Microsoft e Threaded Building Blocks (TBB) di Intel incoraggiano tutti la programmazione multithread senza blocchi. Al giorno d'oggi uso raramente i blocchi nel codice di produzione.
JD

5
@HansPassant: "una cosiddetta barriera di memoria. È una primitiva CPU di basso livello che garantisce che tutte le cache della CPU siano in uno stato coerente e abbiano una visualizzazione aggiornata della RAM. Tutte le scritture in sospeso devono essere scaricate nella RAM, il le cache devono quindi essere aggiornate ". Una barriera di memoria in questo contesto impedisce che le istruzioni di memoria (carichi e archivi) vengano riordinate dal compilatore o dalla CPU. Niente a che vedere con la consistenza delle cache della CPU.
JD


0

Quando si tratta di multi-threading, devi sapere esattamente cosa stai facendo. Intendo esplorare tutti i possibili scenari / casi che potrebbero verificarsi quando si lavora in un ambiente multi-thread. Il multithreading senza blocco non è una libreria o una classe che incorporiamo, è una conoscenza / esperienza che guadagniamo durante il nostro viaggio sui thread.


Esistono numerose librerie che forniscono semantica di threading senza blocchi. STM è di particolare interesse, di cui ci sono parecchie implementazioni in giro.
Marcelo Cantos

Vedo entrambi i lati di questo. Ottenere prestazioni efficaci da una libreria priva di blocchi richiede una conoscenza approfondita dei modelli di memoria. Ma un programmatore che non ha quella conoscenza può comunque beneficiare dei vantaggi della correttezza.
Ben Voigt

0

Anche se il threading senza blocco può essere difficile in .NET, spesso è possibile apportare miglioramenti significativi quando si utilizza un blocco studiando esattamente cosa deve essere bloccato e riducendo al minimo la sezione bloccata ... questo è anche noto come minimizzazione della granularità del blocco .

Ad esempio, dì che devi rendere sicura una raccolta per i thread. Non limitarti a bloccare ciecamente un metodo che esegue l'iterazione sulla raccolta se esegue alcune attività che richiedono molta CPU su ogni elemento. Si potrebbe solo bisogno di mettere un lucchetto intorno creando una copia della collezione. L'iterazione sulla copia potrebbe quindi funzionare senza un blocco. Ovviamente questo dipende in larga misura dalle specifiche del codice, ma con questo approccio sono stato in grado di risolvere un problema di convoglio di blocchi .

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.