Som du säkert vet har Ruby några implementeringar, till exempel MRI, JRuby, Rubinius, Opal, RubyMotion etc., och var och en av dem kan använda ett annat mönster för kodkörning. Den här artikeln kommer att fokusera på de tre första av dem och jämföra MRI
Som du säkert vet har Ruby några implementeringar, till exempel MRI, JRuby, Rubinius, Opal, RubyMotion etc., och var och en av dem kan använda ett annat mönster för kod utförande. Den här artikeln fokuserar på de tre första av dem och jämför MRI (för närvarande den mest populära implementeringen) med JRuby och Rubinius genom att köra några exempelskript som ska bedöma lämpligheten av forking och threading i olika situationer, t.ex. bearbetning av CPU-intensiva algoritmer, kopiering av filer etc.Innan du börjar "lära dig genom att göra" måste du repetera några grundläggande termer.
Gaffel
är en ny underordnad process (en kopia av den överordnade)
kommunicerar med andra via IPC-kanaler (inter-process communication) som t.ex. meddelandeköer, filer, sockets etc.
existerar även när föräldraprocessen avslutas
är ett POSIX-anrop - fungerar främst på Unix-plattformar
Tråd
är "bara" en utförandekontext, som arbetar inom en process
delar allt minne med andra (som standard använder den mindre minne än en gaffel)
kommunicerar med andra genom delade minnesobjekt
dör med en process
introducerar typiska multi-threading-problem såsom starvation, deadlocks etc.
Det finns gott om verktyg som använder forkar och trådar och som används dagligen, t.ex. Unicorn (forkar) och Puma (trådar) på applikationsservernivå, Resque (forkar) och Sidekiq (trådar) på bakgrundsjobbnivå osv.
Följande tabell visar stödet för forking och threading i de viktigaste Ruby-implementeringarna.
Implementering av Ruby
Gaffling
Gängning
MRT
Ja
Ja (begränsat av GIL**)
JRuby
–
Ja
Rubinius
Ja
Ja
Ytterligare två magiska ord kommer tillbaka som en bumerang i det här ämnet - parallellism och samtidighet - vi måste förklara dem lite. Först och främst kan dessa termer inte användas omväxlande. I ett nötskal - vi kan tala om parallellism när två eller flera uppgifter bearbetas exakt samtidigt. Samtidighet uppstår när två eller flera uppgifter bearbetas under överlappande tidsperioder (inte nödvändigtvis samtidigt). Ja, det är en bred förklaring, men tillräckligt bra för att hjälpa dig att märka skillnaden och förstå resten av den här artikeln.
I följande tabell presenteras stödet för parallellism och samtidighet.
Implementering av Ruby
Parallellism (via gafflar)
Parallellism (via trådar)
Samtidighet
MRT
Ja
Nej
Ja
JRuby
–
Ja
Ja
Rubinius
Ja
Ja (sedan version 2.X)
Ja
Nu är det slut på teorin - nu ska vi se hur det fungerar i praktiken!
Att ha separat minne behöver inte innebära att man använder samma mängd minne som den överordnade processen. Det finns några tekniker för minnesoptimering. En av dem är Copy on Write (CoW), som gör det möjligt för en överordnad process att dela allokerat minne med en underordnad process utan att kopiera det. Med CoW behövs ytterligare minne endast om en barnprocess ändrar det delade minnet. I Ruby-sammanhang är inte alla implementeringar CoW-vänliga, t.ex. stöder MRI det fullt ut sedan version 2.X. Före denna version förbrukade varje fork lika mycket minne som en överordnad process.
En av de största fördelarna/nackdelarna med MRI (stryk det olämpliga alternativet) är användningen av GIL (Global Interpreter Lock). I ett nötskal är denna mekanism ansvarig för att synkronisera körning av trådar, vilket innebär att endast en tråd kan köras åt gången. Men vänta ... Betyder det att det inte finns någon mening med att använda trådar i MRI alls? Svaret kommer med förståelsen av GIL-interna ... eller åtminstone ta en titt på kodproverna i den här artikeln.
Testfall
För att visa hur forking och threading fungerar i Rubys implementeringar skapade jag en enkel klass som heter Test och några andra som ärver från den. Varje klass har olika uppgifter som ska bearbetas. Som standard körs varje uppgift fyra gånger i en loop. Dessutom körs varje uppgift mot tre typer av kodkörning: sekventiell, med förgreningar och med trådar. Och dessutom Riktmärke.bmbm kör kodblocket två gånger - första gången för att få igång körtidsmiljön och andra gången för att mäta. Alla resultat som presenteras i den här artikeln erhölls under den andra körningen. Naturligtvis kan även bmbm metoden garanterar inte perfekt isolering, men skillnaderna mellan flera kodkörningar är obetydliga.
kräver "benchmark"
klass Test
BELOPP = 4
def kör
Benchmark.bmbm do |b|
b.report("sekventiell") { sekventiell }
b.report("forking") { forking }
b.report("threading") { threading }
slut
slut
privat
def sekventiell
AMOUNT.times { utföra }
slut
def förgrening
AMOUNT.gånger do
gaffla do
utföra
slut
slut
Process.waitall
rescue NotImplementedError => e
# Fork-metoden är inte tillgänglig i JRuby
sätter e
slut
def trådning
trådar = []
AMOUNT.times do
trådar << Tråd.ny do
utföra
avsluta
slut
trådar.map(&:join)
slut
def utföra
raise "inte implementerad"
slut
slut
Belastningstest
Kör beräkningar i en loop för att generera stor CPU-belastning.
klass LoadTest < Test
def utföra
1000.gånger { 1000.gånger { 2**3**4 } }
slut
slut
Låt oss köra det...
LoadTest.new.run
...och kontrollera resultatet
MRT
JRuby
Rubinius
sekventiell
1.862928
2.089000
1.918873
gaffling
0.945018
–
1.178322
gängning
1.913982
1.107000
1.213315
Som du kan se är resultaten från sekventiella körningar likartade. Naturligtvis finns det en liten skillnad mellan lösningarna, men den orsakas av den underliggande implementeringen av valda metoder i olika tolkar.
I det här exemplet ger forking en betydande prestandavinst (koden körs nästan två gånger snabbare).
Threading ger liknande resultat som forking, men endast för JRuby och Rubinius. Att köra provet med trådar på MRT tar lite mer tid än den sekventiella metoden. Det finns åtminstone två anledningar. För det första tvingar GIL sekventiell exekvering av trådar, så i en perfekt värld borde exekveringstiden vara densamma som för den sekventiella körningen, men det uppstår också en tidsförlust för GIL-operationer (växling mellan trådar etc.). För det andra behövs det också en del overheadtid för att skapa trådar.
Det här exemplet ger oss inte svar på frågan om innebörden av användningstrådar i MRT. Låt oss se ett annat.
Snooze-test
Kör en sömnmetod.
klass SnoozeTest < Test
def utföra
sova 1
slut
slut
Här är resultaten
MRT
JRuby
Rubinius
sekventiell
4.004620
4.006000
4.003186
gaffling
1.022066
–
1.028381
gängning
1.001548
1.004000
1.003642
Som du kan se ger varje implementering liknande resultat, inte bara i de sekventiella och förgrenade körningarna, utan också i de trådade. Så varför har MRI samma prestandavinst som JRuby och Rubinius? Svaret ligger i implementeringen av Sova.
Magnetröntgen Sova metoden är implementerad med rb_tråd_vakta_för C-funktion, som använder en annan funktion som heter inbyggd_sömn. Låt oss ta en snabb titt på dess implementering (koden har förenklats, den ursprungliga implementeringen kan hittas här):
statiskt void
native_sleep(rb_thread_t *th, struct timeval *timeout_tv)
{
...
GVL_UNLOCK_BEGIN();
{
// gör några saker här
}
GVL_UNLOCK_END();
thread_debug("native_sleep donen");
}
Anledningen till att denna funktion är viktig är att förutom att använda strikt Ruby-kontext, växlar den också till systemkontexten för att utföra vissa operationer där. I situationer som denna har Ruby-processen inget att göra ... Bra exempel på slöseri med tid? Inte riktigt, för det finns en GIL som säger: "Inget att göra i den här tråden? Låt oss byta till en annan och komma tillbaka hit efter ett tag". Detta kan göras genom att låsa upp och låsa GIL med GVL_UNLOCK_BEGIN() och GVL_UNLOCK_END() funktioner.
Situationen blir tydlig, men Sova metoden är sällan användbar. Vi behöver fler exempel från verkligheten.
Test av nedladdning av filer
Startar en process som hämtar och sparar en fil.
kräver "net/http"
klass DownloadFileTest < Test
def utföra
Net::HTTP.get("upload.wikimedia.org","/wikipedia/commons/thumb/7/73/Ruby_logo.svg/2000px-Ruby_logo.svg.png")
slut
slut
Det finns inget behov av att kommentera följande resultat. De är ganska lika dem från exemplet ovan.
1.003642
JRuby
Rubinius
sekventiell
0.327980
0.334000
0.329353
gaffling
0.104766
–
0.121054
gängning
0.085789
0.094000
0.088490
Ett annat bra exempel kan vara filkopieringsprocessen eller någon annan I/O-operation.
Slutsatser
Rubinius har fullt stöd för både forking och threading (sedan version 2.X, då GIL togs bort). Din kod kan vara samtidig och köras parallellt.
JRuby gör ett bra jobb med trådar, men stöder inte forking alls. Parallellism och samtidighet kan uppnås med trådar.
MRT stöder forking, men trådning begränsas av förekomsten av GIL. Samtidighet kan uppnås med trådar, men endast när körkoden går utanför Ruby-tolkens kontext (t.ex. IO-operationer, kärnfunktioner). Det finns inget sätt att uppnå parallellism.