Parte IV — Potenza
Capitolo 14 — Fare più cose insieme
I computer di oggi hanno più processori, capaci di lavorare contemporaneamente. Un programma «normale» ne usa uno solo e fa una cosa alla volta; quando quella cosa è lunga — un calcolo pesante, lo scaricamento di un file — tutto il resto aspetta. Il parallelismo permette di avviare un lavoro e intanto continuare, o di dividere un compito su più processori. Leo lo offre con una sintassi tanto semplice che quasi non sembra parallelismo.
💡 L'idea — Più casse al supermercato
Il parallelismo è come aprire più casse al supermercato. Con una cassa sola la fila avanza lentamente e tutti aspettano; con quattro casse aperte la stessa fila si smaltisce in circa un quarto del tempo, perché quattro clienti vengono serviti insieme. Dividere il lavoro su più processori è esattamente questo: più «casse» che lavorano nello stesso momento invece di una coda unica.
in parallelo: avvia e continua
Il mattone fondamentale è il blocco in parallelo fai ... fine. Avvia un lavoro che gira per conto suo e restituisce un «segnaposto»; il programma prosegue senza aspettare. Quando ti serve il risultato, lo chiedi con aspetta(lavoro):
funzione lavoro_lungo(n)
variabile totale = 0
per ogni i in 1..n fai
totale += i
fine
restituisci totale
fine
variabile lavoro = in parallelo fai
restituisci lavoro_lungo(1000000)
fine
scrivi "Intanto faccio altro..."
scrivi "Risultato: {aspetta(lavoro)}"Intanto faccio altro...
Risultato: 500000500000Osserva l'ordine delle stampe: «Intanto faccio altro...» esce mentre il calcolo è in corso, non dopo. aspetta(lavoro) sospende il programma solo nel momento in cui gli serve davvero il risultato — e restituisce quello che il lavoro ha passato a restituisci. Se il lavoro aveva incontrato un errore, aspetta lo ripropone, così puoi gestirlo con prova/cattura come al solito.
La regola d'oro: lavori isolati
Qui c'è l'idea che rende il parallelismo di Leo sicuro, dove in altri linguaggi è un campo minato. Ogni lavoro riceve la propria copia delle variabili del programma, scattata nel momento in cui parte. Le può leggere, ma non può cambiare quelle di fuori. Se dentro un lavoro provi a scrivere contatore += 1 su un contatore del programma, Leo ti ferma prima ancora di eseguire, con un messaggio chiaro.
Perché è una buona notizia? Perché la causa numero uno dei bug nel parallelismo è proprio due lavori che pasticciano con lo stesso dato nello stesso momento, ottenendo risultati imprevedibili e diversi a ogni esecuzione. Isolando i lavori, Leo rende impossibile per costruzione quella categoria di errori. Due lavori non si rovinano mai i dati a vicenda.
Ma allora, come comunicano i lavori tra loro? Attraverso i canali.
I canali: messaggi tra lavori
Un canale è un tubo in cui un lavoro infila messaggi (invia) e un altro li ritira (ricevi), in ordine. Quando il mittente ha finito, chiude il canale. Il destinatario può scorrere i messaggi con per ogni, che termina da sé quando il canale è chiuso e vuoto:
variabile quadrati = canale()
variabile l = in parallelo fai
per ogni n in 1..3 fai
quadrati.invia(n * n)
fine
quadrati.chiudi()
fine
per ogni q in quadrati fai
scrivi(q)
fine1
4
9Il lavoro l calcola i quadrati e li manda nel canale uno a uno; il programma principale li riceve e li stampa man mano che arrivano. Ogni messaggio è una copia, coerentemente con la regola dell'isolamento. I canali sono il modo idiomatico per far collaborare lavori senza memoria condivisa.
Aspettare in tanti modi
Spesso avvii più lavori insieme e vuoi i loro risultati. Leo ha diverse forme di attesa per le situazioni comuni:
funzione quadrato(n)
restituisci n * n
fine
variabile l1 = in parallelo fai
restituisci quadrato(10)
fine
variabile l2 = in parallelo fai
restituisci quadrato(20)
fine
variabile risultati = aspetta_tutti([l1, l2])
scrivi(risultati)[100, 400]aspetta_tutti([...]) raccoglie i risultati di tutti i lavori in una lista (e, al primo errore, annulla gli altri). Ci sono anche aspetta([...]) per una lista di lavori, aspetta_il_primo([...]) per prendere solo il primo che finisce, e aspetta(lavoro, 5) per darsi un tempo massimo di cinque secondi. Un lavoro si può anche fermare con annulla(lavoro), e chiedere a che punto è con lavoro.finito().
Dividere il lavoro su più processori
Quando devi applicare lo stesso calcolo pesante a tanti elementi, c'è una scorciatoia che fa tutto da sé, distribuendo il lavoro sui processori disponibili: mappa_in_parallelo.
funzione pesante(n)
restituisci n * n
fine
variabile risultati = mappa_in_parallelo([1, 2, 3, 4], pesante)
scrivi(risultati)[1, 4, 9, 16]Si usa come mappa (Capitolo 9), ma dietro le quinte sfrutta più processori davvero. Per sapere quanti ne hai a disposizione c'è processori() (il numero dipende dalla macchina). Attenzione a non aspettarti miracoli con calcoli leggeri: avviare e coordinare i lavori ha un costo, e conviene solo quando ogni singolo calcolo è abbastanza pesante da ripagare quel costo. Per i dettagli e le misure c'è la scheda PARALLELISMO.md.
🦁 Curiosità — Nove donne non fanno un bambino in un mese
Non tutto si può accelerare mettendo più «lavoratori» sul problema. Lo ricorda una battuta celebre dell'ingegneria del software, legata alla legge di Brooks del libro The Mythical Man-Month (Fred Brooks, 1975): una gravidanza dura nove mesi, e non c'è modo di ridurli a uno assegnando nove donne al compito. Alcuni passi vanno fatti in sequenza, per forza, uno dopo l'altro — e nessun numero di processori li renderà simultanei.
Prova tu
- Avvia due lavori
in paralleloche calcolano due somme lunghe diverse, poi raccogline i risultati conaspetta_tuttie stampane il totale. - Scrivi un lavoro che invia su un canale i numeri primi fino a 50, e un programma principale che li riceve e li conta.
- Prova
mappa_in_parallelosu una lista di numeri con una funzione che fa un calcolo abbastanza pesante, e confronta — a occhio — il tempo con la versionemappanormale.