Domanda

Quando stavo imparando Java che proveniva da una storia di circa 20 anni di programmazione procedurale con base, Pascal, COBOL e C, all'epoca pensavo che la cosa più difficile a riguardo fosse avvolgermi la testa attorno al gergo e ai concetti di OOP. Ora con circa 8 anni di solido Java alle spalle, sono giunto alla conclusione che l'unica cosa più difficile della programmazione in Java e in linguaggi simili come C # sono gli aspetti multithread / simultanei.

La codifica di applicazioni multi-thread affidabili e scalabili è semplicemente dura! E con la tendenza dei processori a crescere "più ampia" piuttosto che più veloce, sta rapidamente diventando semplicemente critico.

L'area più difficile è, ovviamente, il controllo delle interazioni tra thread e bug risultanti: deadlock, condizioni di gara, dati non aggiornati e latenza.

Quindi la mia domanda per te è questa: quale approccio o metodologia usi tu per produrre codice concorrente sicuro mentre mitiga il potenziale di deadlock, latenza e altri problemi? Ho escogitato un approccio un po 'non convenzionale ma ha funzionato molto bene in diverse applicazioni di grandi dimensioni, che condividerò in una risposta dettagliata a questa domanda.

È stato utile?

Soluzione

Ci sono un certo numero di tecniche che stanno entrando nella coscienza pubblica proprio ora (come in: gli ultimi anni). Uno grande sarebbe attori. Questo è qualcosa che Erlang ha portato per la prima volta sulla griglia, ma che è stato portato avanti da linguaggi più recenti come Scala (attori della JVM). Sebbene sia vero che gli attori non risolvono tutti i problemi, rendono molto più facile ragionare sul codice e identificare i punti problematici. Semplificano inoltre la progettazione di algoritmi paralleli a causa del modo in cui ti costringono a utilizzare la continuazione passando sopra uno stato mutabile condiviso.

Fork / Join è qualcosa che dovresti guardare, specialmente se sei sulla JVM. Doug Lea ha scritto il seminario sull'argomento, ma molti ricercatori ne hanno discusso nel corso degli anni. A quanto ho capito, il framework di riferimento di Doug Lea è previsto per l'inclusione in Java 7.

A un livello leggermente meno invasivo, spesso gli unici passaggi necessari per semplificare un'applicazione multi-thread sono solo per ridurre la complessità del blocco. Il blocco a grana fine (nello stile Java 5) è ottimo per il throughput, ma molto molto difficile da ottenere. Un approccio alternativo al blocco che sta guadagnando una certa forza attraverso Clojure sarebbe la memoria software-transazionale (STM). Questo è essenzialmente l'opposto del bloccaggio convenzionale in quanto è ottimista piuttosto che pessimista. Si parte dal presupposto che non si verificheranno collisioni e quindi si consente al framework di risolvere i problemi se e quando si verificano. I database funzionano spesso in questo modo. È ottimo per il throughput su sistemi con bassi tassi di collisione, ma la grande vittoria sta nella componentizzazione logica dei tuoi algoritmi. Piuttosto che associare arbitrariamente un blocco (o una serie di blocchi) con alcuni dati, basta racchiudere il codice pericoloso in una transazione e lasciare che il framework capisca il resto. Puoi persino ottenere un bel po 'di tempo per la compilazione da decenti implementazioni STM come la monade STM di GHC o il mio sperimentale Scala STM.

Ci sono molte nuove opzioni per la creazione di applicazioni simultanee, quella che scegli dipende molto dalla tua esperienza, dalla tua lingua e dal tipo di problema che stai cercando di modellare. Come regola generale, penso che gli attori accoppiati con strutture di dati persistenti e immutabili siano una scommessa solida, ma come ho detto, STM è un po 'meno invasivo e talvolta può portare a miglioramenti più immediati.

Altri suggerimenti

Questo non vale solo per Java ma per la programmazione thread in generale. Mi ritrovo a evitare la maggior parte dei problemi di concorrenza e latenza semplicemente seguendo queste linee guida:

1 / Lascia che ogni thread abbia la propria vita (cioè decidi quando morire). Può essere richiesto dall'esterno (diciamo una variabile flag) ma è totalmente responsabile.

2 / Avere tutti i thread alloca e libera le loro risorse nello stesso ordine - questo garantisce che non si verifichi deadlock.

3 / Blocca le risorse nel minor tempo possibile.

4 / Passa la responsabilità per i dati con i dati stessi: una volta che comunichi a un thread che i dati devono essere elaborati, lasciali soli fino a quando non viene restituita la responsabilità.

  1. Evita la condivisione di dati tra thread, ove possibile (copia tutto).
  2. Non avere mai blocchi sulle chiamate di metodo a oggetti esterni, ove possibile.
  3. Conserva i blocchi per il minor tempo possibile.

Non esiste una One True Answer per la sicurezza dei thread in Java. Tuttavia, esiste almeno un libro davvero eccezionale: Java Concurrency in Practice . Mi riferisco ad esso regolarmente (specialmente la versione Safari online quando sono in viaggio).

Consiglio vivamente di leggere attentamente questo libro. Potresti scoprire che i costi e i benefici del tuo approccio non convenzionale sono esaminati in modo approfondito.

Di solito seguo un approccio in stile Erlang. Uso il modello di oggetti attivi. Funziona come segue.

Dividi la tua applicazione in unità a grana molto grossa. In una delle mie applicazioni attuali (400.000 LOC) ho appr. 8 di queste unità a grana grossa. Queste unità non condividono alcun dato. Ogni unità conserva i propri dati locali. Ogni unità viene eseguita sul proprio thread (= modello di oggetto attivo) e quindi è a thread singolo. Non hai bisogno di alcun blocco all'interno delle unità. Quando le unità devono inviare messaggi ad altre unità lo fanno pubblicando un messaggio in una coda delle altre unità. L'altra unità preleva il messaggio dalla coda e reagisce su quel messaggio. Ciò potrebbe attivare altri messaggi ad altre unità. Di conseguenza, gli unici blocchi in questo tipo di applicazione sono attorno alle code (una coda e blocco per unità). Questa architettura è priva di deadlock per definizione!

Questa architettura è estremamente scalabile ed è molto facile da implementare ed estendere non appena hai compreso il principio di base. Mi piace pensarlo come una SOA all'interno di un'applicazione.

Dividendo l'app in unità ricordati. Il numero ottimale di thread con esecuzione prolungata per core della CPU è 1.

Raccomando la programmazione basata sul flusso, ovvero la programmazione del flusso di dati. usa OOP e thread, lo sento come un naturale passo avanti, come OOP era procedurale. Devo dire che la programmazione del flusso di dati non può essere utilizzata per tutto, non è generica.

Wikipedia ha buoni articoli sull'argomento:

http://en.wikipedia.org/wiki/Dataflow_programming

http://en.wikipedia.org/wiki/Flow-based_programming

Inoltre, ha diversi vantaggi, come l'incredibile configurazione flessibile, la stratificazione; il programmatore (programmatore di componenti) non deve programmare la logica di business, è fatto in un'altra fase (mettendo insieme la rete di elaborazione).

Lo sapevi, make è un sistema di flusso di dati? Vedi make -j , specialmente se hai un processore multi-core.

Scrivere tutto il codice in un'applicazione multi-thread molto ... attentamente! Non conosco una risposta migliore di quella. (Ciò implica cose come jonnii menzionato).

Ho sentito la gente discutere (e concordare con loro) che il tradizionale modello di threading non funzionerà davvero andando verso il futuro, quindi dovremo sviluppare un diverso set di paradigmi / linguaggi per usarli davvero multi-core newfangled in modo efficace. Linguaggi come Haskell, i cui programmi sono facilmente parallelizzabili poiché qualsiasi funzione che ha effetti collaterali deve essere esplicitamente contrassegnata in questo modo, ed Erlang, che purtroppo non ne so molto.

Suggerisco il modello dell'attore.

Il modello attore è quello che stai usando ed è di gran lunga il più semplice (e modo efficiente) per roba multithreading. Fondamentalmente ogni thread ha una coda (sincronizzata) (può dipendere dal sistema operativo o meno) e altri thread generano messaggi e li inseriscono nella coda del thread che gestirà il messaggio.

Esempio di base:

thread1_proc() {

msg = get_queue1_msg(); // block until message is put to queue1
threat1_msg(msg);

}

thread2_proc() {
msg = create_msg_for_thread1();
send_to_queue1(msg);
}

È un tipico esempio di produttore consumatore .

È chiaramente un problema difficile. A parte l'ovvia necessità di attenzione, credo che il primo passo sia definire con precisione quali fili ti occorrono e perché.

Progetta i thread come faresti con le classi: assicurandoti di sapere cosa li rende coerenti: il loro contenuto e le loro interazioni con altri thread.

Ricordo di essere stato un po 'scioccato nello scoprire che la classe synchronizedList di Java non era completamente thread-safe, ma solo condizionale. Potrei comunque bruciarmi se non avvolgessi i miei accessi (iteratori, setter, ecc.) In un blocco sincronizzato. Ciò significa che avrei potuto assicurare al mio team e alla mia direzione che il mio codice era sicuro per i thread, ma avrei potuto sbagliarmi. Un altro modo in cui posso garantire la sicurezza del thread è che uno strumento analizzi il codice e lo faccia passare. STP, modello di attore, Erlang, ecc. Sono alcuni modi per ottenere quest'ultima forma di garanzia. Essere in grado di assicurare le proprietà di un programma in modo affidabile è / sarà un enorme passo avanti nella programmazione.

Sembra che il tuo CIO sia in qualche modo simile a FBP :-) Sarebbe fantastico se il codice JavaFBP potesse ottenere una verifica approfondita da qualcuno come te esperto nell'arte di scrivere codice thread-safe ... È su SVN in SourceForge .

Alcuni esperti ritengono che la risposta alla tua domanda sia quella di evitare del tutto le discussioni, perché è quasi impossibile evitare problemi imprevisti. Per citare The Problem with Threads :

Abbiamo sviluppato un processo che includeva un sistema di valutazione della maturità del codice (con quattro livelli, rosso, giallo, verde e blu), revisioni del progetto, codice recensioni, build notturni, test di regressione e metriche automatizzate di copertura del codice. La porzione del kernel che ha garantito una visione coerente della struttura del programma è stato scritto all'inizio del 2000, design rivisto in giallo e codice rivisto in verde. I revisori includevano esperti di concorrenza, non solo studenti laureati inesperti (Christopher Hylands (ora Brooks), Bart Kienhuis, John Reekie e [Ed Lee] erano tutti recensori). Abbiamo scritto test di regressione che hanno raggiunto un codice del 100 percento copertura... Il sistema stesso cominciò ad essere ampiamente utilizzato e ogni uso del sistema lo esercitava codice. Nessun problema è stato osservato fino a quando il codice non si è bloccato, il 26 aprile 2004, quattro anni dopo.

L'approccio più sicuro per progettare nuove applicazioni con multi threading è aderire alla regola:

Nessun disegno al di sotto del disegno.

Cosa significa?

Immagina di aver identificato i principali elementi costitutivi della tua applicazione. Lascia che sia la GUI, alcuni motori di calcolo. In genere, una volta che hai una dimensione del team abbastanza grande, alcune persone nel team chiederanno "librerie" a "quotare codice" tra quei blocchi principali. Sebbene all'inizio sia stato relativamente semplice definire le regole di threading e collaborazione per i principali elementi costitutivi, tutto questo sforzo è ora in pericolo in quanto le "librerie di riutilizzo del codice" sarà mal progettato, progettato quando necessario e disseminato di serrature e mutex che "si sentono bene". Quelle librerie ad hoc sono il design sotto il tuo design e il rischio maggiore per la tua architettura di threading.

Cosa fare al riguardo?

  • Dì loro che preferisci la duplicazione del codice piuttosto che il codice condiviso oltre i limiti del thread.
  • Se pensi che il progetto trarrà davvero beneficio da alcune biblioteche, stabilisci la regola che devono essere prive di stato e rientranti.
  • Il tuo design si sta evolvendo e parte di quel "codice comune" potrebbe essere "spostato in alto" nella progettazione per diventare un nuovo importante elemento costitutivo della tua applicazione.
  • Stai lontano dalla mania della biblioteca-cool-sul-web. Alcune librerie di terze parti possono davvero farti risparmiare un sacco di tempo. Ma c'è anche la tendenza che qualcuno abbia i suoi "preferiti", che sono quasi indispensabili. E con ogni libreria di terze parti che aggiungi, aumenta il rischio di incorrere in problemi di threading.

Non ultimo, considera di avere qualche interazione basata sui messaggi tra i tuoi blocchi principali; vedere il modello dell'attore spesso citato, per esempio.

Le preoccupazioni principali quando le ho viste erano (a) evitare deadlock e (b) scambiare dati tra thread. Una preoccupazione per il locatore (ma solo leggermente per il locatore) era evitare i colli di bottiglia. Avevo già riscontrato diversi problemi con un diverso blocco fuori sequenza che causava deadlock - è molto bello dire che "acquisisci sempre i blocchi nello stesso ordine", ma in un sistema medio-grande è praticamente impossibile spesso assicurarlo.

Avvertenza: quando ho trovato questa soluzione ho dovuto scegliere come target Java 1.1 (quindi il pacchetto di concorrenza non era ancora un luccichio negli occhi di Doug Lea) - gli strumenti a portata di mano erano completamente sincronizzati e aspettavano / avvisavano. Ho attinto all'esperienza scrivendo un complesso sistema di comunicazione multi-processo che utilizza il sistema di messaggistica QNX in tempo reale.

Basandomi sulla mia esperienza con QNX che aveva il problema del deadlock, ma evitavo la concorrenza dei dati copiando i messaggi dallo spazio di memoria di un processo ad altri, ho trovato un approccio basato sui messaggi per gli oggetti - che ho chiamato IOC, per inter coordinamento degli oggetti. All'inizio immaginavo di poter creare tutti i miei oggetti in questo modo, ma col senno di poi si scopre che sono necessari solo nei principali punti di controllo in una grande applicazione: gli "interstatali scambi", se non sarà appropriato per ogni singolo "incrocio" nel sistema stradale. Questo risulta essere un grande vantaggio perché sono abbastanza non-POJO.

Ho immaginato un sistema in cui gli oggetti non avrebbero invocato concettualmente metodi sincronizzati, ma invece avrebbero "inviato messaggi". I messaggi possono essere inviati / risposte, in cui il mittente attende che il messaggio venga elaborato e ritorni con la risposta, oppure asincrono in cui il messaggio viene rilasciato su una coda e accodato ed elaborato in una fase successiva. Si noti che questa è una distinzione concettuale: la messaggistica è stata implementata utilizzando chiamate di metodo sincronizzate.

Gli oggetti principali per il sistema di messaggistica sono un IsolatedObject, un IocBinding e un IocTarget.

IsolatedObject è così chiamato perché non ha metodi pubblici; è questo che viene esteso al fine di ricevere ed elaborare i messaggi. Usando la riflessione si impone inoltre che l'oggetto figlio non abbia metodi pubblici, né pacchetti o metodi protetti, tranne quelli ereditati da IsolatedObject, quasi tutti definitivi; all'inizio sembra molto strano perché quando esegui la sottoclasse di IsolatedObject, crei un oggetto con 1 metodo protetto:

Object processIocMessage(Object msgsdr, int msgidn, Object msgdta)

e tutti gli altri metodi sono metodi privati ??per gestire messaggi specifici.

IocTarget è un mezzo per astrarre la visibilità di un oggetto isolato ed è molto utile per fornire a un altro oggetto un auto-riferimento per inviarti segnali, senza esporre il riferimento effettivo dell'oggetto.

E IocBinding si limita semplicemente a associare un oggetto mittente a un destinatario del messaggio in modo che non vengano effettuati controlli di convalida per ogni messaggio inviato e venga creato utilizzando un IocTarget.

Tutte le interazioni con gli oggetti isolati avvengono tramite " invio " esso invia messaggi - il metodo processIocMessage del destinatario è sincronizzato, il che garantisce che venga gestito solo un messaggio alla volta.

Object iocMessage(int mid, Object dta)
void   iocSignal (int mid, Object dta)

Avendo creato una situazione in cui tutto il lavoro svolto dall'oggetto isolato viene incanalato attraverso un singolo metodo, ho successivamente disposto gli oggetti in una gerarchia dichiarata mediante una "classificazione". dichiarano quando costruiti - semplicemente una stringa che li identifica come uno di un qualsiasi numero di "tipi di destinatari del messaggio", che colloca l'oggetto all'interno di una gerarchia predeterminata. Quindi ho usato il codice di consegna del messaggio per assicurarmi che se il mittente fosse esso stesso un oggetto IsolatedObject che per i messaggi di invio / risposta sincroni fosse uno inferiore nella gerarchia. I messaggi (segnali) asincroni vengono inviati ai destinatari dei messaggi utilizzando thread separati in un pool di thread che l'intero processo invia segnali, quindi i segnali possono essere inviati da qualsiasi oggetto a qualsiasi ricevitore nel sistema. I segnali possono fornire tutti i dati dei messaggi desiderati, ma non è possibile rispondere.

Poiché i messaggi possono essere recapitati solo in una direzione verso l'alto (ei segnali sono sempre verso l'alto perché vengono consegnati da un thread separato eseguito esclusivamente a tale scopo) i deadlock vengono eliminati in base alla progettazione.

Poiché le interazioni tra i thread vengono realizzate scambiando messaggi utilizzando la sincronizzazione Java, le condizioni di competizione e i problemi di dati non aggiornati vengono ugualmente eliminati dalla progettazione.

Poiché un determinato ricevitore gestisce un solo messaggio alla volta e poiché non ha altri punti di ingresso, tutte le considerazioni sullo stato dell'oggetto vengono eliminate - in effetti, l'oggetto è completamente sincronizzato e la sincronizzazione non può essere accidentalmente esclusa da alcun metodo; nessun getter restituisce dati di thread memorizzati nella cache obsoleti e nessun setter modifica lo stato dell'oggetto mentre un altro metodo agisce su di esso.

Dato che solo le interazioni tra i componenti principali sono incanalate attraverso questo meccanismo, in pratica questo si è ridimensionato molto bene - quelle interazioni non si verificano quasi spesso nella pratica come ho teorizzato.

L'intero design diventa una raccolta ordinata di sottosistemi che interagiscono in modo strettamente controllato.

Nota che questo non è usato per le situazioni più semplici in cui i thread di lavoro che utilizzano pool di thread più convenzionali saranno sufficienti (anche se spesso inserirò nuovamente i risultati del lavoratore nel sistema principale inviando un messaggio IOC). Né viene utilizzato per situazioni in cui un thread si interrompe e fa qualcosa di completamente indipendente dal resto del sistema come un thread del server HTTP. Infine, non viene utilizzato per situazioni in cui esiste un coordinatore delle risorse che di per sé non interagisce con altri oggetti e in cui la sincronizzazione interna farà il lavoro senza rischio di deadlock.

EDIT: avrei dovuto dichiarare che i messaggi scambiati dovrebbero essere generalmente oggetti immutabili; se si utilizzano oggetti mutabili, l'atto di inviarlo dovrebbe essere considerato come una consegna e indurre il mittente a rinunciare a qualsiasi controllo e preferibilmente non conservare alcun riferimento ai dati. Personalmente, utilizzo una struttura di dati bloccabile che è bloccata dal codice IOC e quindi diventa immutabile all'invio (l'indicatore di blocco è volatile).

Autorizzato sotto: CC-BY-SA insieme a attribuzione
Non affiliato a StackOverflow
scroll top