Som du sikkert vet, har Ruby noen få implementasjoner, for eksempel MRI, JRuby, Rubinius, Opal, RubyMotion etc., og hver av dem kan bruke et annet mønster for kodeutførelse. Denne artikkelen vil fokusere på de tre første av dem og sammenligne MRI
Som du sikkert vet, har Ruby noen få implementasjoner, for eksempel MRI, JRuby, Rubinius, Opal, RubyMotion etc., og hver av dem kan bruke et annet mønster av kode utførelse. Denne artikkelen vil fokusere på de tre første av dem og sammenligne MRI (for tiden den mest populære implementasjonen) med JRuby og Rubinius ved å kjøre noen eksempler på skript som skal vurdere egnetheten til forking og threading i ulike situasjoner, for eksempel behandling av CPU-intensive algoritmer, kopiering av filer osv.Før du begynner å "lære ved å gjøre", må du repetere noen grunnleggende begreper.
Gaffel
er en ny underordnet prosess (en kopi av den overordnede)
kommuniserer med andre via IPC-kanaler (interprosess-kommunikasjon) som meldingskøer, filer, sockets osv.
eksisterer selv når overordnet prosess avsluttes
er et POSIX-kall - fungerer hovedsakelig på Unix-plattformer
Tråd
er "bare" en utførelseskontekst som fungerer innenfor en prosess
deler alt minnet med andre (som standard bruker den mindre minne enn en gaffel)
kommuniserer med andre ved hjelp av delte minneobjekter
dør med en prosess
introduserer typiske multitrådings-problemer som starvation, deadlocks osv.
Det finnes mange verktøy som bruker forks og tråder, og som brukes daglig, f.eks. Unicorn (forks) og Puma (tråder) på applikasjonsservernivå, Resque (forks) og Sidekiq (tråder) på bakgrunnsjobbnivå osv.
Tabellen nedenfor viser støtten for forking og threading i de viktigste Ruby-implementeringene.
Implementering av Ruby
Gaffel
Gjenging
MR
Ja
Ja (begrenset av GIL**)
JRuby
–
Ja
Rubinius
Ja
Ja
To andre magiske ord kommer tilbake som en bumerang i dette emnet - parallellisme og samtidighet - vi må forklare dem litt. Først og fremst kan ikke disse begrepene brukes om hverandre. Kort sagt - vi kan snakke om parallellitet når to eller flere oppgaver behandles nøyaktig samtidig. Samtidighet finner sted når to eller flere oppgaver behandles i overlappende tidsperioder (ikke nødvendigvis samtidig). Ja, det er en grov forklaring, men den er god nok til at du vil merke forskjellen og forstå resten av denne artikkelen.
Tabellen nedenfor viser støtten for parallellisme og samtidighet.
Implementering av Ruby
Parallellisme (via gafler)
Parallellisme (via tråder)
Samtidighet
MR
Ja
Nei
Ja
JRuby
–
Ja
Ja
Rubinius
Ja
Ja (siden versjon 2.X)
Ja
Nå er det slutt på teorien - la oss se det i praksis!
Å ha separat minne betyr ikke nødvendigvis at man bruker like mye minne som den overordnede prosessen. Det finnes noen teknikker for minneoptimalisering. En av dem er Copy on Write (CoW), som gjør det mulig for en overordnet prosess å dele allokert minne med en underordnet prosess uten å kopiere det. Med CoW er det bare behov for ekstra minne hvis en underordnet prosess endrer det delte minnet. I Ruby-sammenheng er ikke alle implementasjoner CoW-vennlige, f.eks. har MRI støttet det fullt ut siden versjon 2.X. Før denne versjonen brukte hver fork like mye minne som en overordnet prosess.
En av de største fordelene/ulempene med MRI (stryk det upassende alternativet) er bruken av GIL (Global Interpreter Lock). Kort fortalt er denne mekanismen ansvarlig for å synkronisere kjøringen av tråder, noe som betyr at bare én tråd kan kjøres om gangen. Men vent ... Betyr det at det ikke er noe poeng i å bruke tråder i MR i det hele tatt? Svaret får du ved å forstå GILs interne funksjoner ... eller i det minste ta en titt på kodeeksemplene i denne artikkelen.
Testtilfelle
For å vise hvordan forking og threading fungerer i Rubys implementasjoner, har jeg laget en enkel klasse som heter Test og noen få andre som arver fra den. Hver klasse har en egen oppgave som skal behandles. Som standard kjører hver oppgave fire ganger i en løkke. Hver oppgave kjører også mot tre typer kodeutførelse: sekvensiell, med gafler og med tråder. I tillegg til dette, Benchmark.bmbm kjører kodeblokken to ganger - første gang for å få kjøretidsmiljøet i gang, andre gang for å måle. Alle resultatene som presenteres i denne artikkelen, ble oppnådd i den andre kjøringen. Selvfølgelig er selv bmbm metoden garanterer ikke perfekt isolasjon, men forskjellene mellom flere kodekjøringer er ubetydelige.
krever "benchmark"
klasse Test
AMOUNT = 4
def run
Benchmark.bmbm do |b|
b.report("sekvensiell") { sekvensiell }
b.report("forking") { forking }
b.report("threading") { threading }
end
end
private
def sekvensiell
AMOUNT.times { utføre }
end
def forking
AMOUNT.times do
fork do
perform
end
end
Process.waitall
rescue NotImplementedError => e
# fork-metoden er ikke tilgjengelig i JRuby
puts e
end
def threading
threads = []
AMOUNT.times do
threads << Thread.new do
perform
end
end
threads.map(&:join)
end
def utføre
raise "ikke implementert"
end
end
Belastningstest
Kjører beregninger i en løkke for å generere stor CPU-belastning.
class LoadTest < Test
def utføre
1000.ganger { 1000.ganger { 2**3**4 } }
end
end
La oss kjøre det...
LoadTest.new.run
...og sjekk resultatene
MR
JRuby
Rubinius
sekvensiell
1.862928
2.089000
1.918873
gafling
0.945018
–
1.178322
gjenging
1.913982
1.107000
1.213315
Som du kan se, er resultatene fra sekvensielle kjøringer like. Det er selvfølgelig en liten forskjell mellom løsningene, men det skyldes den underliggende implementeringen av de valgte metodene i de ulike tolkene.
I dette eksempelet gir forking en betydelig ytelsesgevinst (koden kjører nesten to ganger raskere).
Threading gir lignende resultater som forking, men bare for JRuby og Rubinius. Å kjøre eksempelet med tråder på MR bruker litt mer tid enn den sekvensielle metoden. Det er minst to grunner til det. For det første tvinger GIL sekvensiell kjøring av tråder, og i en perfekt verden burde kjøretiden derfor være den samme som for den sekvensielle kjøringen, men det oppstår også et tidstap for GIL-operasjoner (bytte mellom tråder osv.). For det andre trengs det også noe overheadtid for å opprette tråder.
Dette eksemplet gir oss ikke noe svar på spørsmålet om betydningen av brukstråder i MR. La oss se på et annet.
Snooze-test
Kjører en hvilemetode.
class SnoozeTest < Test
def utføre
sleep 1
end
end
Her er resultatene
MR
JRuby
Rubinius
sekvensiell
4.004620
4.006000
4.003186
gafling
1.022066
–
1.028381
gjenging
1.001548
1.004000
1.003642
Som du kan se, gir hver implementering lignende resultater, ikke bare i sekvensielle og forking-kjøringer, men også i trådkjøringene. Så hvorfor har MRI samme ytelsesgevinst som JRuby og Rubinius? Svaret ligger i implementeringen av sove.
MR-undersøkelser sove metoden er implementert med rb_thread_wait_for C-funksjon, som bruker en annen funksjon kalt native_sleep. La oss ta en rask titt på implementasjonen (koden ble forenklet, den opprinnelige implementasjonen finnes her):
statisk ugyldig
native_sleep(rb_thread_t *th, struct timeval *timeout_tv)
{
...
GVL_UNLOCK_BEGIN();
{
// gjør noen ting her
}
GVL_UNLOCK_END();
thread_debug("native_sleep donen");
}
Grunnen til at denne funksjonen er viktig, er at den i tillegg til å bruke en streng Ruby-kontekst, også bytter til systemkonteksten for å utføre noen operasjoner der. I slike situasjoner har ikke Ruby-prosessen noe å gjøre... Et godt eksempel på bortkastet tid? Egentlig ikke, fordi det er en GIL som sier: "Ingenting å gjøre i denne tråden? La oss bytte til en annen og komme tilbake hit etter en stund". Dette kan gjøres ved å låse opp og låse GIL med GVL_UNLOCK_BEGIN() og GVL_UNLOCK_END() funksjoner.
Situasjonen blir klar, men sove metoden er sjelden nyttig. Vi trenger flere eksempler fra det virkelige liv.
Test av nedlasting av filer
Kjører en prosess som laster ned og lagrer en fil.
krever "net/http"
class DownloadFileTest < Test
def utføre
Net::HTTP.get("upload.wikimedia.org", "/wikipedia/commons/thumb/7/73/Ruby_logo.svg/2000px-Ruby_logo.svg.png")
slutt
end
Det er ikke nødvendig å kommentere de følgende resultatene. De er ganske like dem fra eksempelet ovenfor.
1.003642
JRuby
Rubinius
sekvensiell
0.327980
0.334000
0.329353
gafling
0.104766
–
0.121054
gjenging
0.085789
0.094000
0.088490
Et annet godt eksempel kan være filkopieringsprosessen eller andre I/O-operasjoner.
Konklusjoner
Rubinius støtter fullt ut både forking og threading (siden versjon 2.X, da GIL ble fjernet). Koden din kan være samtidig og kjøre parallelt.
JRuby gjør en god jobb med tråder, men støtter ikke forking i det hele tatt. Parallellisme og samtidighet kan oppnås med tråder.
MR støtter forking, men tråding er begrenset av tilstedeværelsen av GIL. Samtidighet kan oppnås med tråder, men bare når kjørende kode går utenfor Ruby-tolkerens kontekst (f.eks. IO-operasjoner, kjernefunksjoner). Det finnes ingen måte å oppnå parallellitet på.