Come probabilmente saprete, Ruby ha diverse implementazioni, come MRI, JRuby, Rubinius, Opal, RubyMotion e così via, e ognuna di esse può utilizzare un diverso modello di esecuzione del codice. Questo articolo si concentrerà sulle prime tre e metterà a confronto MRI
Come probabilmente sapete, Ruby ha diverse implementazioni, come MRI, JRuby, Rubinius, Opal, RubyMotion e così via, e ognuna di esse può utilizzare uno schema diverso di codice esecuzione. Questo articolo si concentrerà sui primi tre e metterà a confronto la risonanza magnetica (attualmente l'implementazione più diffusa) con JRuby e Rubinius, eseguendo alcuni script di esempio che dovrebbero valutare l'idoneità del forking e del threading in varie situazioni, come l'elaborazione di algoritmi ad alta intensità di CPU, la copia di file ecc.
Forcella
è un nuovo processo figlio (una copia di quello padre)
comunica con altri tramite canali di comunicazione interprocesso (IPC) come code di messaggi, file, socket, ecc.
esiste anche quando il processo padre termina
è una chiamata POSIX, che funziona principalmente su piattaforme Unix.
Il filo
è "solo" un contesto di esecuzione, che lavora all'interno di un processo
condivide tutta la memoria con gli altri (per impostazione predefinita usa meno memoria di un fork)
comunica con gli altri tramite oggetti di memoria condivisa
muore con un processo
introduce i tipici problemi del multi-threading, come starvation, deadlock, ecc.
Esistono molti strumenti che utilizzano fork e thread e che vengono utilizzati quotidianamente, ad esempio Unicorn (fork) e Puma (thread) a livello di application server, Resque (fork) e Sidekiq (thread) a livello di lavori in background, ecc.
La tabella seguente presenta il supporto del forking e del threading nelle principali implementazioni di Ruby.
Implementazione di Ruby
Forcatura
Filettatura
RISONANZA MAGNETICA
Sì
Sì (limitato da GIL**)
JRuby
–
Sì
Rubinius
Sì
Sì
In questo argomento tornano come un boomerang altre due parole magiche: parallelismo e concorrenza. Innanzitutto, questi termini non possono essere usati in modo intercambiabile. In poche parole, possiamo parlare di parallelismo quando due o più task vengono elaborati esattamente nello stesso momento. La concomitanza ha luogo quando due o più task vengono elaborati in periodi di tempo sovrapposti (non necessariamente nello stesso momento). Certo, si tratta di una spiegazione ampia, ma sufficiente per aiutarvi a notare la differenza e a comprendere il resto di questo articolo.
La tabella seguente presenta il supporto per il parallelismo e la concorrenza.
Implementazione di Ruby
Parallelismo (tramite fork)
Parallelismo (tramite thread)
Concorrenza
RISONANZA MAGNETICA
Sì
No
Sì
JRuby
–
Sì
Sì
Rubinius
Sì
Sì (dalla versione 2.X)
Sì
Questa è la fine della teoria: vediamola in pratica!
Avere una memoria separata non significa necessariamente consumare la stessa quantità di memoria del processo padre. Esistono alcune tecniche di ottimizzazione della memoria. Una di queste è Copy on Write (CoW), che consente al processo genitore di condividere la memoria allocata con il processo figlio senza copiarla. Con CoW la memoria aggiuntiva è necessaria solo in caso di modifica della memoria condivisa da parte di un processo figlio. Nel contesto di Ruby, non tutte le implementazioni sono compatibili con il CoW, ad esempio MRI lo supporta pienamente dalla versione 2.X. Prima di questa versione, ogni fork consumava tanta memoria quanto un processo padre.
Uno dei maggiori vantaggi/svantaggi della risonanza magnetica (eliminare l'alternativa inappropriata) è l'uso del GIL (Global Interpreter Lock). In poche parole, questo meccanismo è responsabile della sincronizzazione dell'esecuzione dei thread, il che significa che solo un thread può essere eseguito alla volta. Ma aspettate... Significa che non ha senso usare i thread nella risonanza magnetica? La risposta arriva con la comprensione degli interni di GIL... o almeno dando un'occhiata agli esempi di codice in questo articolo.
Caso di test
Per presentare il funzionamento del forking e del threading nelle implementazioni di Ruby, ho creato una semplice classe chiamata Test e alcune altre che ereditano da essa. Ogni classe ha un compito diverso da elaborare. Per impostazione predefinita, ogni task viene eseguito quattro volte in un ciclo. Inoltre, ogni task viene eseguito contro tre tipi di esecuzione del codice: sequenziale, con fork e con thread. Inoltre, Benchmark.bmbm esegue il blocco di codice due volte: la prima volta per far funzionare l'ambiente di runtime, la seconda per effettuare le misurazioni. Tutti i risultati presentati in questo articolo sono stati ottenuti nella seconda esecuzione. Naturalmente, anche bmbm non garantisce un isolamento perfetto, ma le differenze tra più esecuzioni di codice sono insignificanti.
richiedere "benchmark"
classe Test
IMPORTO = 4
def Esegui
Benchmark.bmbm do |b|
b.report("sequenziale") { sequenziale }
b.report("forking") { forking }
b.report("threading") { threading }
fine
fine
privato
def sequenziale
IMPORTO.volte { eseguire }
end
def biforcazione
IMPORTO.volte do
biforcazione do
eseguire
fine
fine
Processo.waitall
rescue NotImplementedError => e
# il metodo fork non è disponibile in JRuby
mette e
fine
def threading
threads = []
IMPORTO.volte do
threads << Thread.new do
eseguire
fine
fine
threads.map(&:join)
fine
def eseguire
solleva "non implementato"
fine
fine
Prova di carico
Esegue i calcoli in un ciclo per generare un grande carico della CPU.
classe LoadTest < Test
def eseguire
1000.times { 1000.times { 2**3**4 } }
end
fine
Eseguiamolo...
LoadTest.new.run
...e controllare i risultati
RISONANZA MAGNETICA
JRuby
Rubinius
sequenziale
1.862928
2.089000
1.918873
biforcazione
0.945018
–
1.178322
filettatura
1.913982
1.107000
1.213315
Come si può vedere, i risultati delle esecuzioni sequenziali sono simili. Naturalmente c'è una piccola differenza tra le soluzioni, ma è causata dall'implementazione sottostante dei metodi scelti nei vari interpreti.
La biforcazione, in questo esempio, comporta un notevole guadagno di prestazioni (il codice viene eseguito quasi due volte più velocemente).
Il threading dà risultati simili a quelli del forking, ma solo per JRuby e Rubinius. L'esecuzione del campione con i thread su MRI richiede un po' più di tempo rispetto al metodo sequenziale. Le ragioni sono almeno due. In primo luogo, GIL forza l'esecuzione sequenziale dei thread, quindi in un mondo perfetto il tempo di esecuzione dovrebbe essere lo stesso dell'esecuzione sequenziale, ma si verifica anche una perdita di tempo per le operazioni di GIL (passaggio da un thread all'altro, ecc.). In secondo luogo, è necessario un certo tempo di overhead per la creazione dei thread.
Questo esempio non ci dà una risposta alla domanda sul senso dei fili d'uso nella risonanza magnetica. Vediamone un altro.
Test Snooze
Esegue un metodo di sospensione.
classe SnoozeTest < Test
def eseguire
dormire 1
fine
fine
Ecco i risultati
RISONANZA MAGNETICA
JRuby
Rubinius
sequenziale
4.004620
4.006000
4.003186
biforcazione
1.022066
–
1.028381
filettatura
1.001548
1.004000
1.003642
Come si può vedere, ogni implementazione fornisce risultati simili non solo nelle esecuzioni sequenziali e di biforcazione, ma anche in quelle di threading. Allora, perché MRI ha lo stesso incremento di prestazioni di JRuby e Rubinius? La risposta è nell'implementazione di dormire.
Risonanza magnetica dormire è implementato con il metodo rb_thread_wait_for C, che utilizza un'altra funzione chiamata nativo_sleep. Diamo una rapida occhiata alla sua implementazione (il codice è stato semplificato, l'implementazione originale si può trovare qui):
vuoto statico
native_sleep(rb_thread_t *th, struct timeval *timeout_tv)
{
...
GVL_UNLOCK_BEGIN();
{
// fa qualcosa qui
}
GVL_UNLOCK_END();
thread_debug("native_sleep donen");
}
Il motivo per cui questa funzione è importante è che, oltre a utilizzare il contesto stretto di Ruby, passa anche a quello di sistema per eseguire alcune operazioni. In situazioni come questa, il processo Ruby non ha nulla da fare... Un grande esempio di perdita di tempo? Non proprio, perché c'è un GIL che dice: "Non c'è niente da fare in questo thread? Passiamo a un altro e torniamo qui dopo un po'". Questo può essere fatto sbloccando e bloccando il GIL con GVL_UNLOCK_BEGIN() e GVL_UNLOCK_END() funzioni.
La situazione diventa chiara, ma dormire è raramente utile. Abbiamo bisogno di altri esempi di vita reale.
Test di download dei file
Esegue un processo che scarica e salva un file.
richiedere "net/http"
classe DownloadFileTest < Test
def eseguire
Net::HTTP.get("upload.wikimedia.org", "/wikipedia/commons/thumb/7/73/Ruby_logo.svg/2000px-Ruby_logo.svg.png")
fine
fine
Non è necessario commentare i risultati seguenti. Sono abbastanza simili a quelli dell'esempio precedente.
1.003642
JRuby
Rubinius
sequenziale
0.327980
0.334000
0.329353
biforcazione
0.104766
–
0.121054
filettatura
0.085789
0.094000
0.088490
Un altro buon esempio può essere il processo di copia dei file o qualsiasi altra operazione di I/O.
Conclusioni
Rubinius supporta pienamente sia la biforcazione che il threading (dalla versione 2.X, quando GIL è stato rimosso). Il codice può essere concorrente ed eseguito in parallelo.
JRuby fa un buon lavoro con i thread, ma non supporta affatto il fork. Il parallelismo e la concorrenza possono essere ottenuti con i thread.
RISONANZA MAGNETICA supporta il forking, ma il threading è limitato dalla presenza di GIL. La concorrenza potrebbe essere ottenuta con i thread, ma solo quando il codice in esecuzione esce dal contesto dell'interprete Ruby (ad esempio operazioni di IO, funzioni del kernel). Non c'è modo di ottenere il parallelismo.