Programmazione di thread Java nel mondo reale, parte 1

Tutti i programmi Java diversi dalle semplici applicazioni basate su console sono multithread, che ti piaccia o no. Il problema è che l'Abstract Windowing Toolkit (AWT) elabora gli eventi del sistema operativo (OS) sul proprio thread, quindi i metodi del listener vengono effettivamente eseguiti sul thread AWT. Questi stessi metodi listener in genere accedono agli oggetti a cui si accede anche dal thread principale. Potrebbe essere allettante, a questo punto, seppellire la testa nella sabbia e fingere di non doverti preoccupare dei problemi di threading, ma di solito non puoi farla franca. E, sfortunatamente, praticamente nessuno dei libri su Java affronta i problemi di threading in modo sufficientemente approfondito. (Per un elenco di libri utili sull'argomento, vedere Risorse.)

Questo articolo è il primo di una serie che presenterà soluzioni reali ai problemi di programmazione Java in un ambiente multithread. È orientato ai programmatori Java che comprendono le cose a livello di linguaggio (la synchronizedparola chiave e le varie strutture della Threadclasse), ma vogliono imparare come usare efficacemente queste caratteristiche del linguaggio.

Dipendenza dalla piattaforma

Sfortunatamente, la promessa di indipendenza dalla piattaforma di Java cade a viso aperto nell'arena dei thread. Sebbene sia possibile scrivere un programma Java multithread indipendente dalla piattaforma, devi farlo con gli occhi aperti. Non è veramente colpa di Java; è quasi impossibile scrivere un sistema di threading veramente indipendente dalla piattaforma. (Il framework ACE [Adaptive Communication Environment] di Doug Schmidt è un tentativo valido, anche se complesso. Vedere Risorse per un collegamento al suo programma.) Quindi, prima di poter parlare di problemi di programmazione Java hard-core nelle puntate successive, devo discutere le difficoltà introdotte dalle piattaforme su cui potrebbe essere eseguita la Java virtual machine (JVM).

Energia atomica

Il primo concetto a livello di sistema operativo che è importante comprendere è l' atomicità. Un'operazione atomica non può essere interrotta da un altro thread. Java definisce almeno alcune operazioni atomiche. In particolare, l'assegnazione a variabili di qualsiasi tipo eccetto longo doubleè atomica. Non devi preoccuparti che un thread anticipi un metodo nel mezzo del compito. In pratica, questo significa che non devi mai sincronizzare un metodo che non fa altro che restituire il valore di (o assegnare un valore a) una variabile di istanza booleano int. Allo stesso modo, un metodo che ha eseguito molti calcoli utilizzando solo variabili e argomenti locali e che ha assegnato i risultati di tale calcolo a una variabile di istanza come ultima operazione eseguita, non dovrebbe essere sincronizzato. Per esempio:

class some_class {int some_field; void f (some_class arg) // deliberatamente non sincronizzato {// Qui fa molte cose che usano variabili locali // e argomenti del metodo, ma non accede // a nessun campo della classe (o chiama metodi // che accedono a qualsiasi campi della classe). // ... some_field = new_value; // fallo per ultimo. }}

D'altra parte, quando si esegue x=++yo x+=y, si potrebbe essere anticipati dopo l'incremento ma prima dell'assegnazione. Per ottenere l'atomicità in questa situazione, dovrai utilizzare la parola chiave synchronized.

Tutto ciò è importante perché il sovraccarico della sincronizzazione può essere non banale e può variare da sistema operativo a sistema operativo. Il seguente programma illustra il problema. Ogni ciclo chiama ripetutamente un metodo che esegue le stesse operazioni, ma uno dei metodi ( locking()) è sincronizzato e l'altro ( not_locking()) no. Utilizzando la VM JDK "performance-pack" in esecuzione su Windows NT 4, il programma segnala una differenza di tempo di esecuzione di 1,2 secondi tra i due cicli, ovvero circa 1,2 microsecondi per chiamata. Questa differenza potrebbe non sembrare molto, ma rappresenta un aumento del 7,25% del tempo di chiamata. Ovviamente, l'aumento percentuale diminuisce man mano che il metodo funziona di più, ma un numero significativo di metodi, almeno nei miei programmi, sono solo poche righe di codice.

import java.util. *; class synch { sincronizzato int lock (int a, int b) {return a + b;} int not_locking (int a, int b) {return a + b;} private static final int ITERAZIONI = 1000000; static public void main (String [] args) {synch tester = new synch (); doppio inizio = nuova data (). getTime (); for (long i = ITERATIONS; --i> = 0;) tester.locking (0,0); double end = new Date (). getTime (); double locking_time = end - start; inizio = nuova data (). getTime (); for (long i = ITERATIONS; --i> = 0;) tester.not_locking (0,0);fine = nuova data (). getTime (); double not_locking_time = end - start; double time_in_synchronization = locking_time - not_locking_time; System.out.println ("Tempo perso per la sincronizzazione (millis.):" + Time_in_synchronization); System.out.println ("Overhead di blocco per chiamata:" + (time_in_synchronization / ITERATIONS)); System.out.println (not_locking_time / locking_time * 100.0 + "% aumento"); }}

Sebbene la VM HotSpot dovrebbe risolvere il problema dell'overhead di sincronizzazione, HotSpot non è un freebee: devi acquistarlo. A meno che non concedi in licenza e spedisci HotSpot con la tua app, non si può dire quale sarà la VM sulla piattaforma di destinazione e, naturalmente, vuoi che la velocità di esecuzione del tuo programma dipenda il meno possibile dalla VM che lo sta eseguendo. Anche se i problemi di deadlock (di cui parlerò nella prossima puntata di questa serie) non esistevano, l'idea che dovresti "sincronizzare tutto" è semplicemente sbagliata.

Concorrenza contro parallelismo

Il prossimo problema relativo al sistema operativo (e il problema principale quando si tratta di scrivere Java indipendente dalla piattaforma) ha a che fare con le nozioni di concorrenza e parallelismo. I sistemi multithreading simultanei danno l'aspetto di più attività in esecuzione contemporaneamente, ma queste attività sono in realtà suddivise in blocchi che condividono il processore con blocchi di altre attività. La figura seguente illustra i problemi. Nei sistemi paralleli, due attività vengono effettivamente eseguite contemporaneamente. Il parallelismo richiede un sistema con più CPU.

A meno che tu non stia trascorrendo molto tempo bloccato, in attesa del completamento delle operazioni di I / O, un programma che utilizza più thread simultanei verrà spesso eseguito più lentamente di un programma a thread singolo equivalente, sebbene spesso sarà organizzato meglio del singolo equivalente -versione filettata. Un programma che utilizza più thread in esecuzione in parallelo su più processori verrà eseguito molto più velocemente.

Sebbene Java consenta l'implementazione del threading interamente nella VM, almeno in teoria, questo approccio precluderebbe qualsiasi parallelismo nell'applicazione. Se non venissero utilizzati thread a livello di sistema operativo, il sistema operativo considererebbe l'istanza VM come un'applicazione a thread singolo, che molto probabilmente sarebbe pianificata su un singolo processore. Il risultato netto sarebbe che non ci sarebbero mai due thread Java in esecuzione nella stessa istanza VM in esecuzione in parallelo, anche se avessi più CPU e la tua VM fosse l'unico processo attivo. Due istanze della VM che eseguono applicazioni separate potrebbero essere eseguite in parallelo, ovviamente, ma voglio fare di meglio. Per ottenere il parallelismo, la VM devemappare i thread Java ai thread del sistema operativo; quindi, non puoi permetterti di ignorare le differenze tra i vari modelli di threading se l'indipendenza dalla piattaforma è importante.

Rendi chiare le tue priorità

Dimostrerò i modi in cui i problemi appena discussi possono influire sui tuoi programmi confrontando due sistemi operativi: Solaris e Windows NT.

Java, almeno in teoria, fornisce dieci livelli di priorità per i thread. (Se due o più thread sono entrambi in attesa di essere eseguiti, verrà eseguito quello con il livello di priorità più alto.) In Solaris, che supporta 231 livelli di priorità, questo non è un problema (sebbene le priorità di Solaris possano essere difficili da usare - di più su questo in un momento). NT, d'altra parte, ha sette livelli di priorità disponibili e questi devono essere mappati nei dieci di Java. Questa mappatura non è definita, quindi si presentano molte possibilità. (Ad esempio, i livelli di priorità Java 1 e 2 potrebbero essere associati al livello di priorità NT 1 e i livelli di priorità Java 8, 9 e 10 potrebbero mappare tutti al livello NT 7.)

La scarsità di livelli di priorità di NT è un problema se si desidera utilizzare la priorità per controllare la pianificazione. Le cose sono rese ancora più complicate dal fatto che i livelli di priorità non sono fissi. NT fornisce un meccanismo chiamato aumento della priorità, che è possibile disattivare con una chiamata di sistema C, ma non da Java. Quando il boosting della priorità è abilitato, NT aumenta la priorità di un thread di una quantità indeterminata per un periodo di tempo indeterminato ogni volta che esegue determinate chiamate di sistema relative all'I / O. In pratica, ciò significa che il livello di priorità di un thread potrebbe essere più alto di quanto si pensi perché quel thread ha eseguito un'operazione di I / O in un momento difficile.

Il punto del potenziamento della priorità è impedire che i thread che eseguono l'elaborazione in background influiscano sulla reattività apparente delle attività pesanti dell'interfaccia utente. Altri sistemi operativi hanno algoritmi più sofisticati che in genere riducono la priorità dei processi in background. Lo svantaggio di questo schema, in particolare se implementato a livello per thread anziché per processo, è che è molto difficile usare la priorità per determinare quando verrà eseguito un thread particolare.

La situazione peggiora.

In Solaris, come in tutti i sistemi Unix, i processi hanno la priorità così come i thread. I thread dei processi ad alta priorità non possono essere interrotti dai thread dei processi a bassa priorità. Inoltre, il livello di priorità di un dato processo può essere limitato da un amministratore di sistema in modo che un processo utente non interrompa i processi critici del sistema operativo. NT non supporta niente di tutto questo. Un processo NT è solo uno spazio degli indirizzi. Non ha priorità di per sé e non è programmato. Il sistema pianifica i thread; quindi, se un dato thread è in esecuzione in un processo che non è in memoria, il processo viene scambiato. Le priorità dei thread NT rientrano in varie "classi di priorità", che sono distribuite su un continuum di priorità effettive. Il sistema si presenta così:

Le colonne sono livelli di priorità effettivi, solo 22 dei quali devono essere condivisi da tutte le applicazioni. (Le altre sono usate da NT stesso.) Le righe sono classi di priorità. I thread in esecuzione in un processo ancorato alla classe di priorità inattiva vengono eseguiti ai livelli da 1 a 6 e 15, a seconda del livello di priorità logica assegnato. I thread di un processo ancorato come normale classe di priorità verranno eseguiti ai livelli 1, da 6 a 10 o 15 se il processo non ha il focus di input. Se ha il focus di input, i thread vengono eseguiti ai livelli da 1, 7 a 11 o 15. Ciò significa che un thread ad alta priorità di un processo di classe di priorità inattivo può anticipare un thread a bassa priorità di un processo di classe di priorità normale, ma solo se il processo è in esecuzione in background. Notare che un processo in esecuzione in "alto"la classe di priorità ha solo sei livelli di priorità disponibili. Le altre classi ne hanno sette.

NT non fornisce alcun modo per limitare la classe di priorità di un processo. Qualsiasi thread su qualsiasi processo sulla macchina può assumere il controllo della scatola in qualsiasi momento aumentando la propria classe di priorità; non c'è difesa contro questo.

Il termine tecnico che uso per descrivere la priorità di NT è un pasticcio empio. In pratica, la priorità è praticamente inutile sotto NT.

Allora cosa deve fare un programmatore? Tra il numero limitato di livelli di priorità di NT e l'incremento incontrollabile della priorità, non esiste un modo assolutamente sicuro per un programma Java di utilizzare i livelli di priorità per la pianificazione. Un compromesso praticabile è quello di limitare se stessi per Thread.MAX_PRIORITY, Thread.MIN_PRIORITYe Thread.NORM_PRIORITYquando si chiama setPriority(). Questa restrizione evita almeno il problema da 10 livelli mappati a 7 livelli. Suppongo che potresti utilizzare la os.nameproprietà di sistema per rilevare NT e quindi chiamare un metodo nativo per disattivare l'aumento della priorità, ma ciò non funzionerà se la tua app è in esecuzione su Internet Explorer a meno che non utilizzi anche il plug-in VM di Sun. (La VM di Microsoft utilizza un'implementazione del metodo nativo non standard.) In ogni caso, odio usare metodi nativi.Di solito evito il problema il più possibile inserendo la maggior parte dei thread inNORM_PRIORITYe utilizzando meccanismi di pianificazione diversi dalla priorità. (Discuterò alcuni di questi nelle puntate future di questa serie.)

Cooperare!

In genere sono disponibili due modelli di threading supportati dai sistemi operativi: cooperativo e preventivo.

Il modello di multithreading cooperativo

In un sistema cooperativo , un thread mantiene il controllo del proprio processore fino a quando non decide di rinunciarvi (cosa che potrebbe non accadere mai). I vari thread devono cooperare tra loro o tutti tranne uno saranno "affamati" (ovvero, non verrà mai data la possibilità di eseguire). La pianificazione nella maggior parte dei sistemi cooperativi viene eseguita rigorosamente in base al livello di priorità. Quando il thread corrente rinuncia al controllo, il thread in attesa con la priorità più alta ottiene il controllo. (Un'eccezione a questa regola è Windows 3.x, che utilizza un modello cooperativo ma non ha molto di uno scheduler. La finestra che ha il focus ottiene il controllo.)