Začnimo z razčiščevanjem preveč pogostih točk zmede med Razvijalci Ruby ; in sicer: Sočasnost in vzporednost sta ne ista stvar (tj. sočasno! = vzporedno).
Zlasti Ruby sočasnost je, ko se lahko dve nalogi zaženeta, zaženeta in dokončata prekrivanje časovna obdobja. Ne pomeni pa nujno, da se bosta kdaj vsaka izvajala hkrati (npr. Več niti na enojedrnem računalniku). V nasprotju, vzporednost je, ko se dve nalogi dobesedno zaženeta ob istem času (npr. več niti v večjedrnem procesorju).
Ključna točka tukaj je, da bodo hkratne niti in / ali procesi Ni nujno teči vzporedno.
Ta vadnica nudi praktično (in ne teoretično) obravnavo različnih tehnik in pristopov, ki so na voljo za sočasnost in vzporednost v Rubyju.
Za več primerov Rubyja iz resničnega sveta si oglejte naš članek o Tolmači in časi izvajanja Ruby .
Za preprost testni primer bom ustvaril Mailer
razredu in dodajte funkcijo Fibonacci (namesto metode sleep()
), da bo vsaka zahteva bolj zahtevna CPU, kot sledi:
class Mailer def self.deliver(&block) mail = MailBuilder.new(&block).mail mail.send_mail end Mail = Struct.new(:from, :to, :subject, :body) do def send_mail fib(30) puts 'Email from: #{from}' puts 'Email to : #{to}' puts 'Subject : #{subject}' puts 'Body : #{body}' end def fib(n) n <2 ? n : fib(n-1) + fib(n-2) end end class MailBuilder def initialize(&block) @mail = Mail.new instance_eval(&block) end attr_reader :mail %w(from to subject body).each do |m| define_method(m) do |val| @mail.send('#{m}=', val) end end end end
Nato lahko uporabimo to Mailer
razred, kot sledi za pošiljanje pošte:
Mailer.deliver do from ' [email protected] ' to ' [email protected] ' subject 'Threading and Forking' body 'Some content' end
(Opomba: izvorna koda za ta testni primer je na voljo tukaj na githubu.)
Če želite določiti izhodišče za primerjavo, začnimo s preprostim primerjalnim preizkusom in 100-krat pokličemo pošiljatelja:
puts Benchmark.measure{ 100.times do |i| Mailer.deliver do from 'eki_#{i}@eqbalq.com' to 'jill_#{i}@example.com' subject 'Threading and Forking (#{i})' body 'Some content' end end }
To je dalo naslednje rezultate za štirijedrni procesor z MRI Ruby 2.0.0p353:
15.250000 0.020000 15.270000 ( 15.304447)
Glede odločitve, ali želite uporabiti več procesov ali večnitno aplikacijo Ruby, ni nobenega odgovora 'ena velikost za vse'. Spodnja tabela povzema nekatere ključne dejavnike, ki jih je treba upoštevati.
Procesi | Niti |
---|---|
Uporablja več pomnilnika | Uporablja manj pomnilnika |
Če starš umre, preden otroci zapustijo otroke, lahko otroci postanejo zombi procesi | Vse niti umrejo, ko postopek umre (ni možnosti za zombije) |
Dražje za forkirane procese za preklop konteksta, saj mora OS vse shraniti in znova naložiti | Niti imajo precej manj režijskih stroškov, saj si delijo naslovni prostor in pomnilnik |
Razcepljeni procesi dobijo nov prostor navideznega pomnilnika (izolacija procesa) | Niti imajo isti pomnilnik, zato je treba nadzirati in reševati sočasne težave s pomnilnikom |
Zahteva medprocesno komunikacijo | Lahko 'komunicira' prek čakalne vrste in skupni spomin |
Počasneje ustvarjati in uničiti | Hitreje ustvariti in uničiti |
Lažje kodiranje in odpravljanje napak | Za kodiranje in odpravljanje napak je lahko bistveno bolj zapleteno |
Primeri rešitev Ruby, ki uporabljajo več procesov:
Primeri rešitev Ruby, ki uporabljajo večnitnost:
Preden preučimo večnitne možnosti Ruby, raziščimo lažjo pot drstenja več procesov.
V Rubyju je fork()
sistemski klic se uporablja za ustvarjanje 'kopije' trenutnega procesa. Ta novi postopek je načrtovan na ravni operacijskega sistema, tako da se lahko izvaja hkrati s prvotnim postopkom, tako kot lahko vsak drug neodvisen postopek. ( Opomba: fork()
je sistemski klic POSIX in zato ni na voljo, če uporabljate Ruby na platformi Windows.)
V redu, zaženimo naš testni primer, vendar tokrat z uporabo fork()
za uporabo več procesov:
puts Benchmark.measure{ 100.times do |i| fork do Mailer.deliver do from 'eki_#{i}@eqbalq.com' to 'jill_#{i}@example.com' subject 'Threading and Forking (#{i})' body 'Some content' end end end Process.waitall }
(Process.waitall
čaka na vse podrejeni proces za izhod in vrne vrsto procesnih stanj.)
Ta koda zdaj daje naslednje rezultate (spet pri štirijedrnem procesorju z MRI Ruby 2.0.0p353):
0.000000 0.030000 27.000000 ( 3.788106)
Ne preveč umazano! Pošiljatelj smo naredili ~ 5-krat hitrejši s samo spremembo nekaj vrstic kode (tj. Z uporabo fork()
).
Ne bodite pa preveč navdušeni. Čeprav je morda skušnjava uporabiti forking, saj je enostavna rešitev za Rubyjevo sočasno, ima veliko pomanjkljivost, to je količino pomnilnika, ki jo bo porabil. Vilice so nekoliko drage, še posebej, če a Kopiranje na zapis (CoW) ne uporablja tolmač Ruby, ki ga uporabljate. Če vaša aplikacija na primer porabi 20 MB pomnilnika, lahko na primer 100-kratno viličenje porabi kar 2 GB pomnilnika!
Čeprav ima večnitnost tudi svoje zapletenosti, je pri uporabi fork()
treba upoštevati številne zapletenosti, kot so skupni deskriptorji datotek in semaforji (med nadrejenimi in podrejenimi forkiranimi procesi), potreba po komunikaciji po ceveh itd.
V redu, zato poskusimo isti program hitreje uporabiti z uporabo večnamenskih tehnik Ruby.
Več niti znotraj enega procesa imajo bistveno manj režijskih stroškov kot ustrezno število procesov, saj si delijo naslovni prostor in pomnilnik.
S tem v mislih ponovno poglejmo naš testni primer, a tokrat uporabimo Rubyev Thread
razred:
threads = [] puts Benchmark.measure{ 100.times do |i| threads << Thread.new do Mailer.deliver do from 'eki_#{i}@eqbalq.com' to 'jill_#{i}@example.com' subject 'Threading and Forking (#{i})' body 'Some content' end end end threads.map(&:join) }
Ta koda zdaj daje naslednje rezultate (spet pri štirijedrnem procesorju z MRI Ruby 2.0.0p353):
13.710000 0.040000 13.750000 ( 13.740204)
Udarec. To zagotovo ni zelo impresivno! Torej, kaj se dogaja? Zakaj to daje skoraj enake rezultate, kot smo jih dobili, ko smo kodo zagnali sinhrono?
Odgovor, ki je nagnjen k obstoju mnogih programerjev Ruby, je Global Interpreter Lock (GIL) . Zahvaljujoč GIL-u CRuby (izvedba MRI) v resnici ne podpira navojev.
The Global Interpreter Lock je mehanizem, ki se uporablja v tolmačih računalniškega jezika za sinhronizacijo izvajanja niti, tako da lahko hkrati izvaja samo eno nit. Tolmač, ki uporablja GIL, bo nenehno dovoli točno eno nit in eno nit samo za izvajanje naenkrat , tudi če deluje na večjedrnem procesorju. Ruby MRI in CPython sta najpogostejša primera priljubljenih tolmačev z GIL.
Torej, nazaj k naši težavi, kako lahko izkoristimo večnitnost v Rubyju za izboljšanje zmogljivosti glede na GIL?
No, v MRI (CRuby) je žalostni odgovor, da ste v bistvu zataknjeni in da vam večnitnost lahko naredi zelo malo.
Hkrati rubi brez paralelizma je lahko še vedno zelo uporaben za naloge, ki so težke z IO (npr. Za naloge, ki jih je treba pogosto čakati v omrežju). Torej niti lahko še vedno koristno pri magnetno resonančni tehniki za IO težka opravila. Obstaja razlog, da so bile niti navsezadnje izumljene in uporabljene še preden so bili večjedrni strežniki pogosti.
Toda če imate možnost, da uporabite različico, ki ni CRuby, lahko uporabite drugo izvedbo Rubyja, kot je JRuby ali Rubinius , ker nimajo GIL-a in podpirajo resnično vzporedno navajanje Ruby-jevih navojev.
Da bi dokazali to, tukaj so rezultati, ki jih dobimo, ko zaženemo popolnoma isto različico kode z navoji kot prej, vendar jo tokrat zaženimo na JRuby (namesto na CRuby):
43.240000 0.140000 43.380000 ( 5.655000)
Zdaj se pogovarjava!
Ampak ...
Izboljšana zmogljivost z več nitmi lahko privede do tega, da lahko nekdo verjame, da lahko še naprej dodajamo več niti - v bistvu neskončno - da bo naša koda delovala hitreje in hitreje. To bi bilo res lepo, če bi bilo res, toda resničnost je, da niti niso zastonj, zato vam bodo prej ali slej zmanjkalo sredstev.
Recimo, na primer, da želimo pošiljati vzorce ne 100-krat, ampak 10 000-krat. Poglejmo, kaj se bo zgodilo:
threads = [] puts Benchmark.measure{ 10_000.times do |i| threads << Thread.new do Mailer.deliver do from 'eki_#{i}@eqbalq.com' to 'jill_#{i}@example.com' subject 'Threading and Forking (#{i})' body 'Some content' end end end threads.map(&:join) }
Bum! Po pojavu približno 2.000 niti sem dobil napako z OS X 10.8:
can't create Thread: Resource temporarily unavailable (ThreadError)
Po pričakovanjih prej ali slej začnemo razbijati ali v celoti zmanjka virov. Torej je razširljivost tega pristopa očitno omejena.
Na srečo obstaja boljši način; in sicer združevanje niti.
Področje niti je skupina vnaprej izdelanih niti, ki jih je mogoče ponovno uporabiti in ki so na voljo za izvedbo del po potrebi. Skupine niti so še posebej koristne, kadar je treba opraviti veliko število kratkih nalog in ne majhnega števila daljših nalog. To preprečuje, da bi večkrat nastali režijski stroški ustvarjanja niti.
Ključni konfiguracijski parameter za področje niti je običajno število niti v področju. Te niti je mogoče ustvariti naenkrat naenkrat (tj. Ko je bazen ustvarjen) ali lenobno (tj. Po potrebi, dokler ni ustvarjeno največje število niti v področju).
Ko je združenju predana naloga, ki jo mora izvesti, jo dodeli eni od trenutno nedejavnih niti. Če nobena nit ne deluje (in je že ustvarjeno največje število niti), počaka, da nit dokonča svoje delo in postane nedejaven, nato pa tej nalogi dodeli nalogo.
Torej, ko se vrnemo k našemu primeru, bomo začeli z uporabo Queue
(saj je varno na nit podatkovni tip) in uporabite preprosto izvedbo področja niti:
zahtevajo './lib/mailer' zahtevajo 'primerjalno točko' zahtevajo 'nit'
POOL_SIZE = 10 jobs = Queue.new 10_0000.times jobs.push i workers = (POOL_SIZE).times.map do Thread.new do begin while x = jobs.pop(true) Mailer.deliver do from 'eki_#{x}@eqbalq.com' to 'jill_#{x}@example.com' subject 'Threading and Forking (#{x})' body 'Some content' end end rescue ThreadError end end end workers.map(&:join)
V zgornji kodi smo začeli z ustvarjanjem jobs
čakalna vrsta za opravila, ki jih je treba opraviti. Uporabili smo Queue
v ta namen, ker je varno pred nitmi (torej, če do njega hkrati dostopa več niti, bo ohranilo doslednost), kar preprečuje potrebo po bolj zapleteni izvedbi, ki zahteva uporabo mutex .
Nato smo ID-je pošiljateljev potisnili v čakalno vrsto in ustvarili svoj nabor 10 delovnih niti.
Znotraj vsake niti delavca izpišemo elemente iz čakalne vrste opravil.
Tako je življenjski cikel delovne niti nenehno čakati, da se opravila postavijo v čakalno vrsto opravil, in jih izvajati.
Dobra novica je torej, da to deluje in se brez težav širi. Na žalost pa je to precej zapleteno tudi za našo preprosto vadnico.
Zahvaljujoč Ruby Gem ekosistema, je večina zapletenosti večnitnosti lepo vključena v številne preproste Ruby Gems, ki so že na voljo.
Odličen primer je Celluloid, eden mojih najljubših rubinastih draguljev. Celluloid framework je preprost in čist način za izvajanje sočasnih sistemov, ki temeljijo na igralcih, v Rubyju. Celuloid omogoča ljudem, da gradijo sočasne programe iz sočasnih objektov enako enostavno kot zaporedne programe iz zaporednih objektov.
V okviru naše razprave v tej objavi se posebej osredotočam na funkcijo Pools, vendar si naredite uslugo in si jo podrobneje oglejte. Z uporabo Celluloida boste lahko sestavljali večnitne programe Ruby, ne da bi vas skrbele grde težave, kot so mrtve točke, in zdi se vam nepomembno uporabljati druge bolj dovršene funkcije, kot so Futures in Promises.
Evo, kako preprosta večnitna različica našega poštnega programa uporablja Celluloid:
require './lib/mailer' require 'benchmark' require 'celluloid' class MailWorker include Celluloid def send_email(id) Mailer.deliver do from 'eki_#{id}@eqbalq.com' to 'jill_#{id}@example.com' subject 'Threading and Forking (#{id})' body 'Some content' end end end mailer_pool = MailWorker.pool(size: 10) 10_000.times do |i| mailer_pool.async.send_email(i) end
Čist, enostaven, prilagodljiv in robusten. Kaj še lahko zahtevate?
Seveda bi lahko uporabili še eno potencialno izvedljivo alternativo, odvisno od vaših operativnih zahtev in omejitev delovna mesta v ozadju . Obstajajo številni Ruby Gems, ki podpirajo obdelavo v ozadju (tj. Shranjevanje opravil v čakalno vrsto in kasnejša obdelava, ne da bi blokirali trenutno nit). Pomembni primeri vključujejo Sidekiq , Blaginja , Zakasnjeno delo , in Beanstalkd .
Za to objavo bom uporabil Sidekiq in Redis (odprtokodni predpomnilnik in shramba ključ-vrednost).
Najprej namestimo Redis in ga zaženimo lokalno:
brew install redis redis-server /usr/local/etc/redis.conf
Z zagnanim lokalnim primerkom Redisa si oglejmo različico našega vzorčnega programa za pošiljanje (mail_worker.rb
) z uporabo Sidekiq:
require_relative '../lib/mailer' require 'sidekiq' class MailWorker include Sidekiq::Worker def perform(id) Mailer.deliver do from 'eki_#{id}@eqbalq.com' to 'jill_#{id}@example.com' subject 'Threading and Forking (#{id})' body 'Some content' end end end
Sidekiq lahko sprožimo s mail_worker.rb
mapa:
sidekiq -r ./mail_worker.rb
In potem od IRB :
⇒ irb >> require_relative 'mail_worker' => true >> 100.times 2014-12-20T02:42:30Z 46549 TID-ouh10w8gw INFO: Sidekiq client with redis options {} => 100
Izredno preprosto. In se lahko enostavno poveča s samo spreminjanjem števila delavcev.
Druga možnost je uporaba Prikriti udarec , ena mojih najljubših knjižnic za asinhrono obdelavo RoR. Izvedba z uporabo Sucker Punch bo zelo podobna. Vključiti bomo morali samo SuckerPunch::Job
namesto Sidekiq::Worker
in MailWorker.new.async.perform()
prej MailWorker.perform_async()
.
Visoke sočasnosti ni mogoče doseči samo v Ruby , vendar je tudi preprostejši, kot si morda mislite.
Eden od izvedljivih pristopov je preprosto oblikovanje tekočega procesa, da pomnožite njegovo procesorsko moč. Druga tehnika je izkoristiti večnitnost. Čeprav so niti lažje od procesov in zahtevajo manj režijskih stroškov, lahko vseeno zmanjka virov, če hkrati zaženete preveč niti. Na neki točki se vam bo morda zdelo potrebno uporabiti področje niti. Na srečo je veliko zapletenosti večnitnosti olajšano z izkoriščanjem katerega koli od razpoložljivih draguljev, kot sta Celluloid in njegov model Actor.
Drug način za obdelavo zamudnih procesov je uporaba obdelave v ozadju. Obstaja veliko knjižnic in storitev, ki vam omogočajo izvajanje opravil v ozadju v vaših aplikacijah. Nekatera priljubljena orodja vključujejo okvire opravil, ki jih podpira baza podatkov, in čakalne vrste sporočil.
Forking, rezanje navojev in obdelava ozadja so vse izvedljive alternative. Odločitev, katero uporabiti, je odvisna od narave vaše aplikacije, vašega operativnega okolja in zahtev. Upajmo, da je ta vadnica uporaben uvod v razpoložljive možnosti.