Som du sikkert ved, har Ruby et par implementeringer, såsom MRI, JRuby, Rubinius, Opal, RubyMotion osv., og hver af dem kan bruge et forskelligt mønster for udførelse af kode. Denne artikel vil fokusere på de tre første af dem og sammenligne MRI
Som du sikkert ved, har Ruby et par implementeringer, såsom MRI, JRuby, Rubinius, Opal, RubyMotion osv., og hver af dem kan bruge et forskelligt mønster af Kode udførelse. Denne artikel vil fokusere på de tre første af dem og sammenligne MRI (den mest populære implementering i øjeblikket) med JRuby og Rubinius ved at køre et par eksempler på scripts, som skal vurdere egnetheden af forking og threading i forskellige situationer, såsom behandling af CPU-intensive algoritmer, kopiering af filer osv.Før du begynder at "lære ved at gøre", er du nødt til at repetere et par grundlæggende termer.
Gaffel
er en ny underordnet proces (en kopi af den overordnede)
kommunikerer med andre via interproceskommunikationskanaler (IPC) som meddelelseskøer, filer, sockets osv.
eksisterer, selv når den overordnede proces slutter
er et POSIX-kald - fungerer primært på Unix-platforme
Tråd
er "kun" en udførelseskontekst, der arbejder inden for en proces
deler al hukommelse med andre (som standard bruger den mindre hukommelse end en gaffel)
kommunikerer med andre via delte hukommelsesobjekter
dør med en proces
introducerer typiske multi-threading-problemer som starvation, deadlocks osv.
Der er masser af værktøjer, som bruger forks og threads, og som bruges dagligt, f.eks. Unicorn (forks) og Puma (threads) på applikationsserverniveau, Resque (forks) og Sidekiq (threads) på baggrundsjobniveau osv.
Følgende tabel viser understøttelsen af forking og threading i de største Ruby-implementeringer.
Implementering af Ruby
Gaffel
Gevindskæring
MRI
Ja
Ja (begrænset af GIL**)
JRuby
–
Ja
Rubinius
Ja
Ja
Endnu to magiske ord vender tilbage som en boomerang i dette emne - parallelisme og samtidighed - vi er nødt til at forklare dem lidt. Først og fremmest kan disse udtryk ikke bruges i flæng. Kort sagt kan vi tale om parallelitet, når to eller flere opgaver behandles på nøjagtig samme tid. Samtidighed finder sted, når to eller flere opgaver behandles i overlappende tidsperioder (ikke nødvendigvis på samme tid). Ja, det er en bred forklaring, men god nok til at hjælpe dig med at lægge mærke til forskellen og forstå resten af denne artikel.
Følgende tabel viser understøttelsen af parallelisme og samtidighed.
Implementering af Ruby
Parallelisme (via gafler)
Parallelisme (via tråde)
Samtidighed
MRI
Ja
Nej
Ja
JRuby
–
Ja
Ja
Rubinius
Ja
Ja (siden version 2.X)
Ja
Nu er det slut med teorien - lad os se det i praksis!
At have separat hukommelse betyder ikke nødvendigvis, at man bruger den samme mængde som den overordnede proces. Der findes nogle teknikker til optimering af hukommelsen. En af dem er Copy on Write (CoW), som gør det muligt for den overordnede proces at dele tildelt hukommelse med den underordnede uden at kopiere den. Med CoW er der kun brug for ekstra hukommelse i tilfælde af, at en underordnet proces ændrer den delte hukommelse. I Ruby-sammenhæng er det ikke alle implementeringer, der er CoW-venlige, f.eks. understøtter MRI det fuldt ud siden version 2.X. Før denne version brugte hver fork lige så meget hukommelse som en overordnet proces.
En af de største fordele/ulemper ved MRI (stryg det upassende alternativ) er brugen af GIL (Global Interpreter Lock). Kort fortalt er denne mekanisme ansvarlig for at synkronisere udførelsen af tråde, hvilket betyder, at kun én tråd kan udføres ad gangen. Men vent ... Betyder det, at der slet ikke er nogen grund til at bruge tråde i MRI? Svaret kommer med forståelsen af GIL's interne funktioner ... eller i det mindste ved at kigge på kodeeksemplerne i denne artikel.
Testtilfælde
For at vise, hvordan forking og threading fungerer i Rubys implementeringer, har jeg lavet en simpel klasse, der hedder Test og et par andre, der arver fra den. Hver klasse har en forskellig opgave, der skal behandles. Som standard kører hver opgave fire gange i et loop. Desuden kører hver opgave mod tre typer af kodeudførelse: sekventiel, med forks og med threads. Og derudover, Benchmark.bmbm kører kodeblokken to gange - første gang for at få runtime-miljøet op at køre, anden gang for at måle. Alle de resultater, der præsenteres i denne artikel, blev opnået i den anden kørsel. Selvfølgelig er selv bmbm Metoden garanterer ikke perfekt isolation, men forskellene mellem flere kodekørsler er ubetydelige.
kræver "benchmark"
klasse Test
AMOUNT = 4
def run
Benchmark.bmbm do |b|
b.report("sequential") { sequential }
b.report("forking") { forking }
b.report("threading") { threading }
end
end
privat
def sekventiel
AMOUNT.times { udfør }
slut
def forking
AMOUNT.times do
gaffel do
udfør
end
end
Process.waitall
redder NotImplementedError => e
# fork-metoden er ikke tilgængelig i JRuby
sætter e
slut
def threading
tråde = []
AMOUNT.times do
tråde << Tråd.ny do
udfør
end
slut
threads.map(&:join)
slut
def udføre
raise "ikke implementeret"
end
slut
Belastningstest
Kører beregninger i en løkke for at generere stor CPU-belastning.
klasse LoadTest < Test
def udføre
1000.gange { 1000.gange { 2**3**4 } }
end
slut
Lad os køre det...
LoadTest.new.run
... og tjekke resultaterne
MRI
JRuby
Rubinius
sekventiel
1.862928
2.089000
1.918873
Gaffel
0.945018
–
1.178322
gevindskæring
1.913982
1.107000
1.213315
Som du kan se, er resultaterne fra sekventielle kørsler ens. Der er selvfølgelig en lille forskel mellem løsningerne, men det skyldes den underliggende implementering af de valgte metoder i de forskellige fortolkere.
I dette eksempel giver forking en betydelig præstationsgevinst (koden kører næsten to gange hurtigere).
Threading giver de samme resultater som forking, men kun for JRuby og Rubinius. At køre prøven med tråde på MRI bruger lidt mere tid end den sekventielle metode. Det er der mindst to grunde til. For det første tvinger GIL sekventiel udførelse af tråde, så i en perfekt verden burde udførelsestiden være den samme som for den sekventielle kørsel, men der sker også et tab af tid til GIL-operationer (skift mellem tråde osv.). For det andet er der også brug for noget overheadtid til at oprette tråde.
Dette eksempel giver os ikke svar på spørgsmålet om betydningen af brugstråde i MRI. Lad os se et andet.
Snooze-test
Kører en sleep-metode.
klasse SnoozeTest < Test
def udføre
sove 1
slut
slut
Her er resultaterne
MRI
JRuby
Rubinius
sekventiel
4.004620
4.006000
4.003186
Gaffel
1.022066
–
1.028381
gevindskæring
1.001548
1.004000
1.003642
Som du kan se, giver hver implementering lignende resultater, ikke kun i de sekventielle og forgrenede kørsler, men også i de trådede. Så hvorfor har MRI den samme præstationsgevinst som JRuby og Rubinius? Svaret ligger i implementeringen af sove.
MR-scanninger sove metoden er implementeret med rb_thread_wait_for C-funktion, som bruger en anden, der hedder indfødt_søvn. Lad os tage et hurtigt kig på implementeringen (koden blev forenklet, den oprindelige implementering kan findes her):
statisk void
native_sleep(rb_thread_t *th, struct timeval *timeout_tv)
{
...
GVL_UNLOCK_BEGIN();
{
// gør nogle ting her
}
GVL_UNLOCK_END();
thread_debug("native_sleep donen");
}
Grunden til, at denne funktion er vigtig, er, at den ud over at bruge en streng Ruby-kontekst også skifter til systemkonteksten for at udføre nogle operationer der. I situationer som denne har Ruby-processen intet at gøre ... Et godt eksempel på tidsspilde? Ikke rigtig, for der er en GIL, der siger: "Intet at gøre i denne tråd? Lad os skifte til en anden og komme tilbage hertil efter et stykke tid". Det kan gøres ved at låse op og låse GIL med GVL_UNLOCK_BEGIN() og GVL_UNLOCK_END() funktioner.
Situationen bliver klar, men sove metoden er sjældent brugbar. Vi har brug for flere eksempler fra det virkelige liv.
Test af download af filer
Kører en proces, som downloader og gemmer en fil.
kræver "net/http"
klassen DownloadFileTest < Test
def udfør
Net::HTTP.get("upload.wikimedia.org", "/wikipedia/commons/thumb/7/73/Ruby_logo.svg/2000px-Ruby_logo.svg.png")
slut
slut
Der er ingen grund til at kommentere de følgende resultater. De ligner meget dem fra eksemplet ovenfor.
1.003642
JRuby
Rubinius
sekventiel
0.327980
0.334000
0.329353
Gaffel
0.104766
–
0.121054
gevindskæring
0.085789
0.094000
0.088490
Et andet godt eksempel kunne være filkopieringsprocessen eller enhver anden I/O-operation.
Konklusioner
Rubinius understøtter fuldt ud både forking og threading (siden version 2.X, hvor GIL blev fjernet). Din kode kan være concurrent og køre parallelt.
JRuby gør et godt stykke arbejde med tråde, men understøtter slet ikke forking. Parallelisme og samtidighed kan opnås med tråde.
MRI understøtter forking, men threading er begrænset af tilstedeværelsen af GIL. Samtidighed kan opnås med tråde, men kun når den kørende kode går uden for Ruby-fortolkerens kontekst (f.eks. IO-operationer, kernefunktioner). Der er ingen måde at opnå parallelitet på.