Zoals je waarschijnlijk weet, heeft Ruby een aantal implementaties, zoals MRI, JRuby, Rubinius, Opal, RubyMotion etc., en elk van hen kan een ander patroon van code-uitvoering gebruiken. Dit artikel richt zich op de eerste drie en vergelijkt MRI
Zoals je waarschijnlijk weet, heeft Ruby een aantal implementaties, zoals MRI, JRuby, Rubinius, Opal, RubyMotion etc., en elk van hen kan een ander patroon van code uitvoering. Dit artikel richt zich op de eerste drie en vergelijkt MRI (momenteel de populairste implementatie) met JRuby en Rubinius door een paar voorbeeldscripts uit te voeren die geschiktheid van forking en threading in verschillende situaties moeten beoordelen, zoals het verwerken van CPU-intensieve algoritmen, het kopiëren van bestanden, enzovoort.
Vork
is een nieuw kindproces (een kopie van het ouderproces)
communiceert met anderen via inter-proces communicatie (IPC) kanalen zoals berichtwachtrijen, bestanden, sockets etc.
bestaat zelfs als ouderproces eindigt
is een POSIX-oproep - werkt voornamelijk op Unix-platforms
Draad
is "slechts" een uitvoeringscontext, werkend binnen een proces
deelt al het geheugen met anderen (standaard gebruikt het minder geheugen dan een fork)
communiceert met anderen door middel van gedeelde geheugenobjecten
sterft met een proces
introduceert typische multi-threading problemen zoals starvation, deadlocks etc.
Er zijn genoeg tools die forks en threads gebruiken en die dagelijks worden gebruikt, bijvoorbeeld Unicorn (forks) en Puma (threads) op het niveau van applicatieservers, Resque (forks) en Sidekiq (threads) op het niveau van achtergrondtaken, enzovoort.
De volgende tabel toont de ondersteuning voor forking en threading in de belangrijkste Ruby implementaties.
Ruby implementatie
Forking
Inrijgen
MRI
Ja
Ja (beperkt door GIL**)
JRuby
–
Ja
Rubinius
Ja
Ja
Twee andere magische woorden komen als een boemerang terug in dit onderwerp - parallellisme en concurrency - we moeten ze een beetje uitleggen. Allereerst kunnen deze termen niet door elkaar worden gebruikt. In een notendop - we kunnen spreken van parallellisme wanneer twee of meer taken op precies hetzelfde moment worden verwerkt. Concurrency vindt plaats wanneer twee of meer taken worden verwerkt in overlappende tijdsperioden (niet noodzakelijkerwijs op hetzelfde moment). Ja, het is een brede uitleg, maar goed genoeg om je te helpen het verschil op te merken en de rest van dit artikel te begrijpen.
De volgende tabel toont de ondersteuning voor parallellisme en concurrency.
Ruby implementatie
Parallellisme (via vorken)
Parallellisme (via threads)
Concurrentie
MRI
Ja
Geen
Ja
JRuby
–
Ja
Ja
Rubinius
Ja
Ja (sinds versie 2.X)
Ja
Dat is het einde van de theorie - laten we het in de praktijk zien!
Het hebben van apart geheugen hoeft niet te leiden tot het verbruiken van dezelfde hoeveelheid als het bovenliggende proces. Er zijn enkele geheugenoptimalisatietechnieken. Een daarvan is Copy on Write (CoW), waarmee het ouderproces toegewezen geheugen kan delen met het kind zonder het te kopiëren. Met CoW is er alleen extra geheugen nodig als het gedeelde geheugen door een kindproces wordt gewijzigd. In de Ruby context is niet elke implementatie CoW-vriendelijk, bijvoorbeeld MRI ondersteunt het volledig sinds versie 2.X. Voor deze versie verbruikte elke fork net zoveel geheugen als een ouder proces.
Een van de grootste voordelen/nadelen van MRI (schrap het ongepaste alternatief) is het gebruik van GIL (Global Interpreter Lock). In een notendop is dit mechanisme verantwoordelijk voor het synchroniseren van de uitvoering van threads, wat betekent dat er slechts één thread tegelijk kan worden uitgevoerd. Maar wacht... Betekent dit dat het helemaal geen zin heeft om threads te gebruiken in MRI? Het antwoord komt met het begrijpen van de GIL internals... of op zijn minst het bekijken van de codevoorbeelden in dit artikel.
Testgeval
Om te laten zien hoe forking en threading werkt in Ruby's implementaties, heb ik een eenvoudige klasse genaamd Test en een paar andere die ervan erven. Elke klasse heeft een andere taak om te verwerken. Standaard draait elke taak vier keer in een lus. Ook draait elke taak tegen drie typen van code-uitvoering: sequentieel, met forks en met threads. Bovendien, Benchmark.bmbm draait het blok code twee keer - de eerste keer om de runtime-omgeving up & running te krijgen, de tweede keer om te meten. Alle resultaten die in dit artikel worden gepresenteerd, zijn verkregen tijdens de tweede run. Natuurlijk, zelfs bmbm methode garandeert geen perfecte isolatie, maar de verschillen tussen meerdere runs van de code zijn onbeduidend.
benchmark vereisen
klasse Test
BEDRAG = 4
def run
Benchmark.bmbm do |b|
b.report("sequential") { sequential }
b.report("forking") { forking }
b.report("threading") { threading }
einde
einde
privé
def sequentieel
AMOUNT.times { uitvoeren }
einde
def forking
AMOUNT.times doen
fork doen
voer uit
einde
einde
Proces.waitall
redding Niet geïmplementeerd => e
# fork methode is niet beschikbaar in JRuby
zet e
einde
def threading
threads = []
AMOUNT.times do
threads << Thread.new do
voer uit
einde
einde
threads.map(&:join)
einde
def uitvoeren
raise "not implemented" (niet geïmplementeerd)
einde
einde
Belastingstest
Voert berekeningen in een lus uit om grote CPU-belasting te genereren.
Klasse LoadTest < Test
def uitvoeren
1000.keer { 1000.keer { 2**3**4 } }
einde
einde
Laten we het uitvoeren...
LoadTest.new.run
...en controleer de resultaten
MRI
JRuby
Rubinius
sequentieel
1.862928
2.089000
1.918873
forking
0.945018
–
1.178322
inrijgen
1.913982
1.107000
1.213315
Zoals je kunt zien, zijn de resultaten van sequentiële runs vergelijkbaar. Natuurlijk is er een klein verschil tussen de oplossingen, maar dat wordt veroorzaakt door de onderliggende implementatie van gekozen methoden in verschillende interpreters.
Forking levert in dit voorbeeld een aanzienlijke prestatiewinst op (code loopt bijna twee keer sneller).
Threading geeft vergelijkbare resultaten als forking, maar alleen voor JRuby en Rubinius. Het uitvoeren van het monster met threads op MRI kost iets meer tijd dan de sequentiële methode. Er zijn ten minste twee redenen. Ten eerste dwingt GIL sequentiële uitvoering van threads af, dus in een perfecte wereld zou de uitvoeringstijd hetzelfde moeten zijn als voor de sequentiële run, maar er treedt ook tijdverlies op voor GIL-bewerkingen (wisselen tussen threads enz.). Ten tweede is er ook wat overheadtijd nodig voor het aanmaken van threads.
Dit voorbeeld geeft ons geen antwoord op de vraag over de zin van gebruiksdraden in MRI. Laten we een ander voorbeeld bekijken.
Sluimertijd test
Voert een slaapmethode uit.
klasse SnoozeTest < Test
def uitvoeren
slaap 1
einde
einde
Hier zijn de resultaten
MRI
JRuby
Rubinius
sequentieel
4.004620
4.006000
4.003186
forking
1.022066
–
1.028381
inrijgen
1.001548
1.004000
1.003642
Zoals je kunt zien, geeft elke implementatie vergelijkbare resultaten, niet alleen in de sequentiële en forking runs, maar ook in de threading runs. Dus, waarom heeft MRI dezelfde prestatiewinst als JRuby en Rubinius? Het antwoord ligt in de implementatie van slaap.
MRI's slaap methode is geïmplementeerd met rb_thread_wait_for C-functie, die een andere functie genaamd inheemse_slaap. Laten we eens kijken naar de implementatie ervan (de code is vereenvoudigd, de originele implementatie kan worden gevonden hier):
statische void
native_sleep(rb_thread_t *th, struct timeval *timeout_tv)
{
...
GVL_UNLOCK_BEGIN();
{
// doe hier wat dingen
}
GVL_UNLOCK_END();
thread_debug("native_sleep donen");
}
De reden waarom deze functie belangrijk is, is dat het niet alleen de strikte Ruby context gebruikt, maar ook overschakelt naar de systeemcontext om daar enkele bewerkingen uit te voeren. In dit soort situaties heeft het Ruby proces niets te doen... Geweldig voorbeeld van tijdverspilling? Niet echt, want er is een GIL die zegt: "Niets te doen in deze thread? Laten we overschakelen naar een andere en na een tijdje hier terugkomen". Dit kan worden gedaan door de GIL te ontgrendelen en te vergrendelen met GVL_UNLOCK_BEGIN() en GVL_UNLOCK_END() functies.
De situatie wordt duidelijk, maar slaap methode is zelden nuttig. We hebben meer praktijkvoorbeelden nodig.
Bestand downloaden test
Voert een proces uit dat een bestand downloadt en opslaat.
vereisen "net/http"
klasse DownloadFileTest < Test
Uitvoeren
Net::HTTP.get("upload.wikimedia.org", "/wikipedia/commons/thumb/7/73/Ruby_logo.svg/2000px-Ruby_logo.svg.png")
einde
einde
De volgende resultaten hoeven niet becommentarieerd te worden. Ze lijken erg op die van het voorbeeld hierboven.
1.003642
JRuby
Rubinius
sequentieel
0.327980
0.334000
0.329353
forking
0.104766
–
0.121054
inrijgen
0.085789
0.094000
0.088490
Een ander goed voorbeeld zou het kopiëren van bestanden of een andere I/O bewerking kunnen zijn.
Conclusies
Rubinius ondersteunt zowel forking als threading volledig (sinds versie 2.X, toen GIL werd verwijderd). Je code kan concurrent zijn en parallel draaien.
JRuby doet het goed met threads, maar ondersteunt forking helemaal niet. Parallellisme en gelijktijdigheid kunnen worden bereikt met threads.
MRI ondersteunt forking, maar threading wordt beperkt door de aanwezigheid van GIL. Concurrency zou kunnen worden bereikt met threads, maar alleen wanneer draaiende code buiten de Ruby interpreter context gaat (bijv. IO operaties, kernelfuncties). Er is geen manier om parallellisme te bereiken.