Concorrenza e parallelismo di Ruby: un tutorial pratico
Pubblicato: 2022-03-11Iniziamo col chiarire un punto di confusione fin troppo comune tra gli sviluppatori di Ruby; vale a dire: concorrenza e parallelismo non sono la stessa cosa (cioè, simultaneo! = parallelo).
In particolare, la concorrenza di Ruby è quando due attività possono essere avviate, eseguite e completate in periodi di tempo sovrapposti . Tuttavia, non significa necessariamente che saranno entrambi in esecuzione nello stesso istante (ad esempio, più thread su una macchina single-core). Al contrario, il parallelismo è quando due attività vengono letteralmente eseguite contemporaneamente (ad esempio, più thread su un processore multicore).
Il punto chiave qui è che thread e/o processi simultanei non verranno necessariamente eseguiti in parallelo.
Questo tutorial fornisce un trattamento pratico (piuttosto che teorico) delle varie tecniche e approcci disponibili per la concorrenza e il parallelismo in Ruby.
Per altri esempi di Ruby nel mondo reale, consulta il nostro articolo sugli interpreti e sui runtime di Ruby.
Il nostro caso di prova
Per un semplice test case, creerò una classe Mailer
e aggiungerò una funzione di Fibonacci (piuttosto che il metodo sleep()
) per rendere ogni richiesta più impegnativa per la CPU, come segue:
class Mailer def self.deliver(&block) mail = MailBuilder.new(&block).mail mail.send_mail end Mail = Struct.new(:from, :to, :subject, :body) do def send_mail fib(30) puts "Email from: #{from}" puts "Email to : #{to}" puts "Subject : #{subject}" puts "Body : #{body}" end def fib(n) n < 2 ? n : fib(n-1) + fib(n-2) end end class MailBuilder def initialize(&block) @mail = Mail.new instance_eval(&block) end attr_reader :mail %w(from to subject body).each do |m| define_method(m) do |val| @mail.send("#{m}=", val) end end end end
Possiamo quindi invocare questa classe Mailer
come segue per inviare la posta:
Mailer.deliver do from "[email protected]" to "[email protected]" subject "Threading and Forking" body "Some content" end
(Nota: il codice sorgente per questo test case è disponibile qui su github.)
Per stabilire una linea di base a scopo di confronto, iniziamo facendo un semplice benchmark, invocando il mailer 100 volte:
puts Benchmark.measure{ 100.times do |i| Mailer.deliver do from "eki_#{i}@eqbalq.com" to "jill_#{i}@example.com" subject "Threading and Forking (#{i})" body "Some content" end end }
Ciò ha prodotto i seguenti risultati su un processore quad-core con MRI Ruby 2.0.0p353:
15.250000 0.020000 15.270000 ( 15.304447)
Processi multipli e multithreading
Non esiste una risposta "taglia unica" quando si tratta di decidere se utilizzare più processi o multithread dell'applicazione Ruby. La tabella seguente riassume alcuni dei fattori chiave da considerare.
Processi | Fili |
---|---|
Utilizza più memoria | Utilizza meno memoria |
Se il genitore muore prima che i figli siano usciti, i figli possono diventare processi zombi | Tutti i thread muoiono quando il processo muore (nessuna possibilità di zombi) |
Più costoso per i processi biforcati per cambiare contesto poiché il sistema operativo deve salvare e ricaricare tutto | I thread hanno un sovraccarico notevolmente inferiore poiché condividono lo spazio degli indirizzi e la memoria |
Ai processi biforcati viene assegnato un nuovo spazio di memoria virtuale (isolamento del processo) | I thread condividono la stessa memoria, quindi è necessario controllare e gestire i problemi di memoria simultanei |
Richiede la comunicazione tra processi | Può "comunicare" tramite code e memoria condivisa |
Più lento da creare e distruggere | Più veloce da creare e distruggere |
Più facile da codificare ed eseguire il debug | Può essere significativamente più complesso da codificare ed eseguire il debug |
Esempi di soluzioni Ruby che utilizzano più processi:
- Resque: una libreria Ruby supportata da Redis per creare lavori in background, posizionarli su più code ed elaborarli in seguito.
- Unicorn: un server HTTP per applicazioni Rack progettato per servire solo client veloci su connessioni a bassa latenza e larghezza di banda elevata e sfruttare le funzionalità dei kernel Unix/simili a Unix.
Esempi di soluzioni Ruby che utilizzano il multithreading:
- Sidekiq: un framework di elaborazione in background completo per Ruby. Mira ad essere semplice da integrare con qualsiasi moderna applicazione Rails e prestazioni molto più elevate rispetto ad altre soluzioni esistenti.
- Puma: un server Web Ruby creato per la concorrenza.
- Thin: un server Web Ruby molto veloce e semplice.
Processi multipli
Prima di esaminare le opzioni di multithreading di Ruby, esploriamo il percorso più semplice per generare più processi.
In Ruby, la chiamata di sistema fork()
viene utilizzata per creare una "copia" del processo corrente. Questo nuovo processo è pianificato a livello di sistema operativo, quindi può essere eseguito contemporaneamente al processo originale, proprio come qualsiasi altro processo indipendente. ( Nota: fork()
è una chiamata di sistema POSIX e quindi non è disponibile se si esegue Ruby su una piattaforma Windows.)
OK, quindi eseguiamo il nostro test case, ma questa volta usando fork()
per impiegare più processi:
puts Benchmark.measure{ 100.times do |i| fork do Mailer.deliver do from "eki_#{i}@eqbalq.com" to "jill_#{i}@example.com" subject "Threading and Forking (#{i})" body "Some content" end end end Process.waitall }
( Process.waitall
attende l'uscita di tutti i processi figlio e restituisce una matrice di stati del processo.)
Questo codice ora produce i seguenti risultati (di nuovo, su un processore quad-core con MRI Ruby 2.0.0p353):
0.000000 0.030000 27.000000 ( 3.788106)
Non troppo malandato! Abbiamo reso il mailer ~5 volte più veloce semplicemente modificando un paio di righe di codice (ad esempio, usando fork()
).
Non essere eccessivamente eccitato però. Anche se potrebbe essere allettante usare il fork poiché è una soluzione facile per la concorrenza di Ruby, ha un grosso svantaggio che è la quantità di memoria che consumerà. Il fork è alquanto costoso, soprattutto se un Copy-on-Write (CoW) non viene utilizzato dall'interprete Ruby che stai utilizzando. Se la tua app utilizza 20 MB di memoria, ad esempio, biforcarla 100 volte potrebbe potenzialmente consumare fino a 2 GB di memoria!
Inoltre, sebbene anche il multithreading abbia le sue complessità, ci sono una serie di complessità che devono essere considerate quando si utilizza fork()
, come descrittori di file condivisi e semafori (tra processi biforcati padre e figlio), la necessità di comunicare tramite pipe , e così via.
Multithreading di Ruby
OK, quindi ora proviamo a rendere lo stesso programma più veloce usando invece le tecniche di multithreading di Ruby.
Più thread all'interno di un singolo processo hanno un sovraccarico notevolmente inferiore rispetto a un numero corrispondente di processi poiché condividono lo spazio degli indirizzi e la memoria.
Con questo in mente, rivisitiamo il nostro test case, ma questa volta usando la classe Thread
di Ruby:
threads = [] puts Benchmark.measure{ 100.times do |i| threads << Thread.new do Mailer.deliver do from "eki_#{i}@eqbalq.com" to "jill_#{i}@example.com" subject "Threading and Forking (#{i})" body "Some content" end end end threads.map(&:join) }
Questo codice ora produce i seguenti risultati (di nuovo, su un processore quad-core con MRI Ruby 2.0.0p353):
13.710000 0.040000 13.750000 ( 13.740204)
Peccato. Di certo non è molto impressionante! Allora cosa sta succedendo? Perché questo produce quasi gli stessi risultati che abbiamo ottenuto quando abbiamo eseguito il codice in modo sincrono?
La risposta, che è la rovina dell'esistenza di molti programmatori Ruby, è il Global Interpreter Lock (GIL) . Grazie al GIL, CRuby (l'implementazione MRI) non supporta realmente il threading.
Il Global Interpreter Lock è un meccanismo utilizzato negli interpreti del linguaggio informatico per sincronizzare l'esecuzione dei thread in modo che possa essere eseguito un solo thread alla volta. Un interprete che utilizza GIL consentirà sempre esattamente un thread e un thread solo per l'esecuzione alla volta , anche se eseguito su un processore multi-core. Ruby MRI e CPython sono due degli esempi più comuni di interpreti popolari che hanno un GIL.
Quindi, tornando al nostro problema, come possiamo sfruttare il multithreading in Ruby per migliorare le prestazioni alla luce del GIL?
Bene, nella risonanza magnetica (CRuby), la sfortunata risposta è che sei praticamente bloccato e c'è ben poco che il multithreading può fare per te.
La simultaneità di Ruby senza parallelismo può comunque essere molto utile per attività che richiedono IO-pesante (ad es. attività che devono attendere frequentemente sulla rete). Quindi i thread possono ancora essere utili nella risonanza magnetica, per attività pesanti di IO. C'è una ragione per cui i thread sono stati, dopo tutto, inventati e utilizzati anche prima che i server multi-core fossero comuni.

Ma detto questo, se hai la possibilità di usare una versione diversa da CRuby, puoi usare un'implementazione Ruby alternativa come JRuby o Rubinius, poiché non hanno un GIL e supportano il threading Ruby parallelo reale.
Per dimostrare il punto, ecco i risultati che otteniamo quando eseguiamo la stessa versione threaded del codice di prima, ma questa volta eseguiamola su JRuby (invece di CRuby):
43.240000 0.140000 43.380000 ( 5.655000)
Ora stiamo parlando!
Ma…
I thread non sono gratuiti
Le prestazioni migliorate con più thread potrebbero indurre a credere che possiamo semplicemente continuare ad aggiungere più thread, praticamente all'infinito, per continuare a far funzionare il nostro codice sempre più velocemente. Sarebbe davvero bello se fosse vero, ma la realtà è che i thread non sono liberi e quindi, prima o poi, finirai le risorse.
Diciamo, ad esempio, di voler eseguire il nostro mailer di esempio non 100 volte, ma 10.000 volte. Vediamo cosa succede:
threads = [] puts Benchmark.measure{ 10_000.times do |i| threads << Thread.new do Mailer.deliver do from "eki_#{i}@eqbalq.com" to "jill_#{i}@example.com" subject "Threading and Forking (#{i})" body "Some content" end end end threads.map(&:join) }
Boom! Ho ricevuto un errore con il mio OS X 10.8 dopo aver generato circa 2.000 thread:
can't create Thread: Resource temporarily unavailable (ThreadError)
Come previsto, prima o poi iniziamo a consumare o esauriamo completamente le risorse. Quindi la scalabilità di questo approccio è chiaramente limitata.
Raggruppamento di thread
Fortunatamente, c'è un modo migliore; vale a dire, thread pooling.
Un pool di thread è un gruppo di thread pre-istanziati e riutilizzabili disponibili per eseguire il lavoro in base alle esigenze. I pool di thread sono particolarmente utili quando è necessario eseguire un numero elevato di attività brevi anziché un numero ridotto di attività più lunghe. Ciò evita di dover sostenere il sovraccarico della creazione di un thread un numero elevato di volte.
Un parametro di configurazione chiave per un pool di thread è in genere il numero di thread nel pool. Questi thread possono essere istanziati tutti in una volta (cioè, quando viene creato il pool) o pigramente (cioè, secondo necessità fino a quando non è stato creato il numero massimo di thread nel pool).
Quando al pool viene assegnata un'attività da eseguire, assegna l'attività a uno dei thread attualmente inattivi. Se nessun thread è inattivo (e il numero massimo di thread è già stato creato), attende che un thread completi il suo lavoro e diventi inattivo, quindi assegna l'attività a quel thread.
Quindi, tornando al nostro esempio, inizieremo utilizzando Queue
(poiché è un tipo di dati thread-safe) e impiegheremo una semplice implementazione del pool di thread:
richiedono "./lib/mailer" richiedono "benchmark" richiedono 'thread'
POOL_SIZE = 10 jobs = Queue.new 10_0000.times{|i| jobs.push i} workers = (POOL_SIZE).times.map do Thread.new do begin while x = jobs.pop(true) Mailer.deliver do from "eki_#{x}@eqbalq.com" to "jill_#{x}@example.com" subject "Threading and Forking (#{x})" body "Some content" end end rescue ThreadError end end end workers.map(&:join)
Nel codice precedente, abbiamo iniziato creando una coda di jobs
per i lavori che devono essere eseguiti. Abbiamo usato Queue
per questo scopo poiché è thread-safe (quindi se più thread accedono ad esso contemporaneamente, manterrà la coerenza) che evita la necessità di un'implementazione più complicata che richiede l'uso di un mutex.
Abbiamo quindi inserito gli ID dei mailer nella coda dei lavori e creato il nostro pool di 10 thread di lavoro.
All'interno di ogni thread di lavoro, inseriamo elementi dalla coda dei lavori.
Pertanto, il ciclo di vita di un thread di lavoro consiste nell'attendere continuamente che le attività vengano inserite nella coda dei lavori ed eseguirle.
Quindi la buona notizia è che funziona e si adatta senza problemi. Sfortunatamente, però, questo è abbastanza complicato anche per il nostro semplice tutorial.
Celluloide
Grazie all'ecosistema Ruby Gem, gran parte della complessità del multithreading è perfettamente racchiusa in una serie di Ruby Gem facili da usare e pronte all'uso.
Un ottimo esempio è la celluloide, una delle mie gemme di rubino preferite. Il framework in celluloide è un modo semplice e pulito per implementare sistemi simultanei basati su attori in Ruby. La celluloide consente alle persone di creare programmi simultanei da oggetti simultanei con la stessa facilità con cui costruiscono programmi sequenziali da oggetti sequenziali.
Nel contesto della nostra discussione in questo post, mi sto concentrando specificamente sulla funzione Piscine, ma fatti un favore e dai un'occhiata più in dettaglio. Usando Celluloid sarai in grado di costruire programmi Ruby multithread senza preoccuparti di brutti problemi come deadlock e troverai banale usare altre funzionalità più sofisticate come Futures e Promises.
Ecco quanto semplice utilizza Celluloid una versione multithread del nostro programma di posta:
require "./lib/mailer" require "benchmark" require "celluloid" class MailWorker include Celluloid def send_email(id) Mailer.deliver do from "eki_#{id}@eqbalq.com" to "jill_#{id}@example.com" subject "Threading and Forking (#{id})" body "Some content" end end end mailer_pool = MailWorker.pool(size: 10) 10_000.times do |i| mailer_pool.async.send_email(i) end
Pulito, facile, scalabile e robusto. Cosa si può chiedere di più?
Lavori in background
Naturalmente, un'altra alternativa potenzialmente praticabile, a seconda dei requisiti e dei vincoli operativi, sarebbe quella di impiegare lavori in background. Esistono numerose Ruby Gems per supportare l'elaborazione in background (cioè salvare i lavori in una coda ed elaborarli in un secondo momento senza bloccare il thread corrente). Esempi degni di nota includono Sidekiq, Resque, Delayed Job e Beanstalkd.
Per questo post, userò Sidekiq e Redis (una cache e un negozio di valori chiave open source).
Innanzitutto, installiamo Redis ed eseguiamolo in locale:
brew install redis redis-server /usr/local/etc/redis.conf
Con la nostra istanza Redis locale in esecuzione, diamo un'occhiata a una versione del nostro programma di posta di esempio ( mail_worker.rb
) utilizzando Sidekiq:
require_relative "../lib/mailer" require "sidekiq" class MailWorker include Sidekiq::Worker def perform(id) Mailer.deliver do from "eki_#{id}@eqbalq.com" to "jill_#{id}@example.com" subject "Threading and Forking (#{id})" body "Some content" end end end
Possiamo attivare Sidekiq con il file mail_worker.rb
:
sidekiq -r ./mail_worker.rb
E poi da IRB:
⇒ irb >> require_relative "mail_worker" => true >> 100.times{|i| MailWorker.perform_async(i)} 2014-12-20T02:42:30Z 46549 TID-ouh10w8gw INFO: Sidekiq client with redis options {} => 100
Incredibilmente semplice. E può scalare facilmente semplicemente cambiando il numero di lavoratori.
Un'altra opzione è usare Sucker Punch, una delle mie librerie di elaborazione RoR asincrone preferite. L'implementazione con Sucker Punch sarà molto simile. Dovremo solo includere SuckerPunch::Job
anziché Sidekiq::Worker
e MailWorker.new.async.perform()
piuttosto MailWorker.perform_async()
.
Conclusione
Un'elevata concorrenza non è solo ottenibile in Ruby, ma è anche più semplice di quanto si possa pensare.
Un approccio praticabile è semplicemente quello di eseguire il fork di un processo in esecuzione per moltiplicarne la potenza di elaborazione. Un'altra tecnica è sfruttare il multithreading. Sebbene i thread siano più leggeri dei processi e richiedano meno sovraccarico, è comunque possibile esaurire le risorse se si avviano troppi thread contemporaneamente. Ad un certo punto, potrebbe essere necessario utilizzare un pool di thread. Fortunatamente, molte delle complessità del multithreading sono semplificate sfruttando una delle numerose gemme disponibili, come la Celluloide e il suo modello attore.
Un altro modo per gestire i processi che richiedono tempo è utilizzare l'elaborazione in background. Esistono molte librerie e servizi che ti consentono di implementare lavori in background nelle tue applicazioni. Alcuni strumenti popolari includono framework di lavoro supportati da database e code di messaggi.
Il fork, il threading e l'elaborazione in background sono tutte alternative praticabili. La decisione su quale utilizzare dipende dalla natura dell'applicazione, dall'ambiente operativo e dai requisiti. Si spera che questo tutorial abbia fornito un'utile introduzione alle opzioni disponibili.