Concurență și paralelism Ruby: un tutorial practic

Publicat: 2022-03-11

Să începem prin a lămuri un punct de confuzie prea comun în rândul dezvoltatorilor Ruby; și anume: Concurența și paralelismul nu sunt același lucru (adică concurent != paralel).

În special, concurența Ruby este atunci când două sarcini pot începe, rula și finaliza în perioade de timp suprapuse . Totuși, nu înseamnă neapărat că ambele vor rula vreodată în același moment (de exemplu, mai multe fire de execuție pe o mașină cu un singur nucleu). În contrast, paralelismul este atunci când două sarcini rulează literalmente în același timp (de exemplu, mai multe fire de execuție pe un procesor multicore).

Punctul cheie aici este că firele și/sau procesele concurente nu vor rula neapărat în paralel.

Acest tutorial oferă un tratament practic (mai degrabă decât teoretic) al diferitelor tehnici și abordări care sunt disponibile pentru concurență și paralelism în Ruby.

Pentru mai multe exemple Ruby din lumea reală, consultați articolul nostru despre Ruby Interpreters și Runtimes.

Cazul nostru de testare

Pentru un caz de testare simplu, voi crea o clasă Mailer și voi adăuga o funcție Fibonacci (mai degrabă decât metoda sleep() ) pentru a face ca fiecare solicitare să consume mai mult CPU, după cum urmează:

 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

Putem apoi invoca această clasă Mailer după cum urmează pentru a trimite e-mail:

 Mailer.deliver do from "[email protected]" to "[email protected]" subject "Threading and Forking" body "Some content" end

(Notă: codul sursă pentru acest caz de testare este disponibil aici pe github.)

Pentru a stabili o linie de referință în scopuri de comparație, să începem prin a face un benchmark simplu, invocând mailer-ul de 100 de ori:

 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 }

Acest lucru a dat următoarele rezultate pe un procesor quad-core cu MRI Ruby 2.0.0p353:

 15.250000 0.020000 15.270000 ( 15.304447)

Procese multiple vs. Multithreading

Nu există un răspuns „unic pentru toate” atunci când vine vorba de a decide dacă să utilizați mai multe procese sau să faceți mai multe thread-uri aplicației dvs. Ruby. Tabelul de mai jos rezumă câțiva dintre factorii cheie de luat în considerare.

Procese Fire
Utilizează mai multă memorie Utilizează mai puțină memorie
Dacă părintele moare înainte ca copiii să iasă, copiii pot deveni procese zombie Toate firele mor când procesul moare (nicio șansă de zombi)
Mai scump pentru procesele bifurcate să schimbe contextul, deoarece sistemul de operare trebuie să salveze și să reîncarce totul Threadurile au o suprasarcină considerabil mai mică, deoarece împart spațiul de adrese și memoria
Proceselor bifurcate li se oferă un nou spațiu de memorie virtuală (izolarea procesului) Threadurile partajează aceeași memorie, așa că trebuie să controleze și să se ocupe de problemele de memorie concurente
Necesită comunicare între procese Poate „comunica” prin cozi și memorie partajată
Mai lent de a crea și de a distruge Mai rapid pentru a crea și a distruge
Mai ușor de codat și de depanat Poate fi mult mai complex de codat și de depanat

Exemple de soluții Ruby care utilizează mai multe procese:

  • Resque: O bibliotecă Ruby susținută de Redis pentru crearea de joburi de fundal, plasarea lor în mai multe cozi și procesarea lor mai târziu.
  • Unicorn: un server HTTP pentru aplicații Rack conceput pentru a servi doar clienți rapidi pe conexiuni cu latență redusă și lățime de bandă mare și pentru a profita de funcțiile din nucleele Unix/Unix.

Exemple de soluții Ruby care utilizează multithreading:

  • Sidekiq: un cadru complet de procesare în fundal pentru Ruby. Acesta își propune să fie simplu de integrat cu orice aplicație modernă Rails și performanță mult mai mare decât alte soluții existente.
  • Puma: Un server web Ruby creat pentru concurență.
  • Thin: Un server web Ruby foarte rapid și simplu.

Procese multiple

Înainte de a ne uita la opțiunile Ruby multithreading, să explorăm calea mai ușoară de a genera mai multe procese.

În Ruby, apelul de sistem fork() este folosit pentru a crea o „copie” a procesului curent. Acest nou proces este programat la nivel de sistem de operare, astfel încât să poată rula concomitent cu procesul original, la fel ca orice alt proces independent. ( Notă: fork() este un apel de sistem POSIX și, prin urmare, nu este disponibil dacă rulați Ruby pe o platformă Windows.)

OK, deci să rulăm cazul nostru de testare, dar de data aceasta folosind fork() pentru a folosi mai multe procese:

 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 așteaptă ca toate procesele copil să iasă și returnează o serie de stări de proces.)

Acest cod oferă acum următoarele rezultate (din nou, pe un procesor quad-core cu MRI Ruby 2.0.0p353):

 0.000000 0.030000 27.000000 ( 3.788106)

Nu prea ponosit! Am făcut mailer-ul de ~5 ori mai rapid prin modificarea doar a câteva linii de cod (adică, folosind fork() ).

Nu te entuziasma prea mult, totuși. Deși ar putea fi tentant să folosești forking, deoarece este o soluție ușoară pentru concurența Ruby, are un dezavantaj major care este cantitatea de memorie pe care o va consuma. Bifurcarea este oarecum costisitoare, mai ales dacă un Copy-on-Write (CoW) nu este utilizat de interpretul Ruby pe care îl utilizați. Dacă aplicația dvs. folosește 20 MB de memorie, de exemplu, bifurcarea acesteia de 100 de ori ar putea consuma până la 2 GB de memorie!

De asemenea, deși multithreading-ul are și propriile sale complexități, există o serie de complexități care trebuie luate în considerare atunci când se utilizează fork() , cum ar fi descriptori de fișiere partajate și semafoare (între procesele părinte și secundare bifurcate), necesitatea de a comunica prin conducte , și așa mai departe.

Ruby Multithreading

OK, deci acum să încercăm să facem același program mai rapid folosind tehnicile Ruby multithreading.

Firele multiple dintr-un singur proces au o suprasarcină considerabil mai mică decât un număr corespunzător de procese, deoarece împart spațiul de adrese și memoria.

Având în vedere acest lucru, haideți să revizuim cazul nostru de testare, dar de data aceasta folosind clasa Ruby’s Thread :

 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) }

Acest cod oferă acum următoarele rezultate (din nou, pe un procesor quad-core cu MRI Ruby 2.0.0p353):

 13.710000 0.040000 13.750000 ( 13.740204)

Păcat. Asta sigur nu este foarte impresionant! Deci ce se întâmplă? De ce produce acest lucru aproape aceleași rezultate ca și când am rulat codul sincron?

Răspunsul, care este dezastrul existenței multor programatori Ruby, este Global Interpreter Lock (GIL) . Datorită GIL, CRuby (implementarea RMN) nu acceptă cu adevărat threading.

Blocarea globală a interpretului este un mecanism utilizat în interpreții de limbaj computerizat pentru a sincroniza execuția firelor de execuție, astfel încât să poată fi executat doar un fir de execuție la un moment dat. Un interpret care folosește GIL va permite întotdeauna să execute exact un fir și un fir de execuție odată , chiar dacă rulează pe un procesor multi-core. Ruby RMN și CPython sunt două dintre cele mai comune exemple de interpreți populari care au un GIL.

Deci, revenind la problema noastră, cum putem exploata multithreading în Ruby pentru a îmbunătăți performanța în lumina GIL?

Ei bine, în RMN (CRuby), răspunsul nefericit este că practic ești blocat și există foarte puține lucruri pe care multithreading-ul poate face pentru tine.

Concurența Ruby fără paralelism poate fi, totuși, foarte utilă pentru sarcinile care necesită mult IO (de exemplu, sarcinile care trebuie să aștepte frecvent în rețea). Deci firele pot fi încă utile în RMN, pentru sarcini grele de IO. Există un motiv pentru care firele de execuție au fost, până la urmă, inventate și folosite chiar înainte ca serverele multi-core să fie comune.

Dar acestea fiind spuse, dacă aveți opțiunea de a utiliza o altă versiune decât CRuby, puteți utiliza o implementare Ruby alternativă, cum ar fi JRuby sau Rubinius, deoarece nu au un GIL și acceptă threading Ruby paralel real.

atașat cu JRuby

Pentru a demonstra ideea, iată rezultatele pe care le obținem atunci când rulăm exact aceeași versiune threaded a codului ca înainte, dar de data aceasta rulăm pe JRuby (în loc de CRuby):

 43.240000 0.140000 43.380000 ( 5.655000)

Acum vorbim!

Dar…

Firele nu sunt gratuite

Performanța îmbunătățită cu mai multe fire de execuție ar putea să creadă că putem continua să adăugăm mai multe fire de execuție – practic la infinit – pentru a continua să facem ca codul nostru să ruleze din ce în ce mai rapid. Ar fi într-adevăr frumos dacă ar fi adevărat, dar realitatea este că firele de execuție nu sunt gratuite și astfel, mai devreme sau mai târziu, vei rămâne fără resurse.

Să presupunem, de exemplu, că vrem să rulăm eșantionul nostru de e-mail nu de 100 de ori, ci de 10.000 de ori. Să vedem ce se întâmplă:

 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! Am primit o eroare cu OS X 10.8 după ce am generat aproximativ 2.000 de fire:

 can't create Thread: Resource temporarily unavailable (ThreadError)

După cum era de așteptat, mai devreme sau mai târziu începem să ne batem sau rămânem fără resurse. Deci, scalabilitatea acestei abordări este clar limitată.

Pooling de fire

Din fericire, există o cale mai bună; și anume, pooling de fire.

Un grup de fire este un grup de fire de execuție pre-instanțate, reutilizabile, care sunt disponibile pentru a efectua lucrări după cum este necesar. Pool-urile de fire sunt deosebit de utile atunci când există un număr mare de sarcini scurte de efectuat, mai degrabă decât un număr mic de sarcini mai lungi. Acest lucru previne nevoia de a suporta costul general al creării unui fir de execuție de un număr mare de ori.

Un parametru cheie de configurare pentru un pool de fire este de obicei numărul de fire din pool. Aceste fire de execuție pot fi fie instanțiate dintr-o dată (adică, atunci când grupul este creat) sau leneș (adică, după cum este necesar, până când numărul maxim de fire din pool a fost creat).

Când grupului i se înmânează o sarcină de efectuat, acesta atribuie sarcina unuia dintre firele de execuție inactive. Dacă niciun fir de execuție nu este inactiv (și numărul maxim de fire de execuție a fost deja creat), acesta așteaptă ca un fir de execuție să-și finalizeze activitatea și să devină inactiv și apoi atribuie sarcina acelui fir de execuție.

Pooling de fire

Deci, revenind la exemplul nostru, vom începe prin a folosi Queue (deoarece este un tip de date sigur pentru fire) și vom folosi o implementare simplă a pool-ului de fire:

necesită „./lib/mailer” necesită „benchmark” necesită „thread”

 POOL_SIZE = 10 jobs = Queue.new 10_0000.times{|i| 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)

În codul de mai sus, am început prin a crea o coadă de jobs pentru joburile care trebuie efectuate. Am folosit Queue în acest scop, deoarece este thread-safe (deci dacă mai multe fire de execuție îl accesează în același timp, se va menține consistența), ceea ce evită necesitatea unei implementări mai complicate care necesită utilizarea unui mutex.

Apoi am împins ID-urile expeditorilor în coada de joburi și am creat grupul nostru de 10 fire de lucru.

În cadrul fiecărui fir de lucru, vom scoate elemente din coada de locuri de muncă.

Astfel, ciclul de viață al unui fir de lucru este de a aștepta continuu ca sarcinile să fie introduse în coada de joburi și de a le executa.

Deci, vestea bună este că aceasta funcționează și se scalează fără probleme. Din păcate, totuși, acest lucru este destul de complicat chiar și pentru tutorialul nostru simplu.

Celuloid

Datorită ecosistemului Ruby Gem, o mare parte din complexitatea multithreading-ului este bine încapsulată într-un număr de Ruby Gems ușor de utilizat.

Un exemplu grozav este celuloidul, una dintre pietrele mele preferate de rubin. Cadrul celuloid este o modalitate simplă și curată de a implementa sisteme concurente bazate pe actori în Ruby. Celuloidul le permite oamenilor să construiască programe concurente din obiecte concurente la fel de ușor precum ei construiesc programe secvențiale din obiecte secvențiale.

În contextul discuției noastre din această postare, mă concentrez în mod special pe caracteristica Pools, dar fă-ți o favoare și verifică-o mai detaliat. Folosind Celluloid, veți putea construi programe Ruby cu mai multe fire fără a vă face griji cu privire la probleme urâte, cum ar fi blocajele, și veți găsi că este banal să utilizați alte funcții mai sofisticate, cum ar fi Futures și Promises.

Iată cât de simplă este o versiune cu mai multe fire a programului nostru de e-mail folosind celuloid:

 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

Curat, ușor, scalabil și robust. Ce poți cere mai mult?

Lucrări de fundal

Desigur, o altă alternativă potențial viabilă, în funcție de cerințele și constrângerile dumneavoastră operaționale, ar fi să angajați locuri de muncă de bază. Există o serie de Ruby Gems pentru a sprijini procesarea în fundal (adică, salvarea joburilor într-o coadă și procesarea lor mai târziu fără a bloca firul curent). Exemplele notabile includ Sidekiq, Resque, Delayed Job și Beanstalkd.

Pentru această postare, voi folosi Sidekiq și Redis (un cache și un magazin open source cheie-valoare).

Mai întâi, să instalăm Redis și să-l rulăm local:

 brew install redis redis-server /usr/local/etc/redis.conf

Cu instanța noastră locală Redis care rulează, să aruncăm o privire la o versiune a programului nostru de eșantion de mailer ( mail_worker.rb ) folosind 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

Putem declanșa Sidekiq cu fișierul mail_worker.rb :

 sidekiq -r ./mail_worker.rb

Și apoi de la IRB:

 ⇒ irb >> require_relative "mail_worker" => true >> 100.times{|i| MailWorker.perform_async(i)} 2014-12-20T02:42:30Z 46549 TID-ouh10w8gw INFO: Sidekiq client with redis options {} => 100

Grozav de simplu. Și se poate scala cu ușurință prin simpla schimbare a numărului de lucrători.

O altă opțiune este să folosesc Sucker Punch, una dintre bibliotecile mele preferate de procesare RoR asincronă. Implementarea folosind Sucker Punch va fi foarte asemănătoare. Va trebui doar să includem SuckerPunch::Job mai degrabă decât Sidekiq::Worker și MailWorker.new.async.perform() mai degrabă MailWorker.perform_async() .

Concluzie

Concurența ridicată nu este doar realizabilă în Ruby, ci este și mai simplă decât ați putea crede.

O abordare viabilă este pur și simplu de a întrerupe un proces care rulează pentru a-și multiplica puterea de procesare. O altă tehnică este de a profita de multithreading. Deși firele de execuție sunt mai ușoare decât procesele, necesitând mai puțină suprasarcină, puteți rămâne fără resurse dacă porniți prea multe fire de execuție concomitent. La un moment dat, este posibil să găsiți necesar să utilizați un pool de fire. Din fericire, multe dintre complexitățile multithreading-ului sunt simplificate prin valorificarea oricăreia dintre o serie de pietre prețioase disponibile, cum ar fi Celuloid și modelul său Actor.

O altă modalitate de a gestiona procesele consumatoare de timp este utilizarea procesării în fundal. Există multe biblioteci și servicii care vă permit să implementați joburi de fundal în aplicațiile dvs. Unele instrumente populare includ cadre de lucru susținute de baze de date și cozi de mesaje.

Forking, threading și procesarea în fundal sunt toate alternative viabile. Decizia cu privire la care să utilizați depinde de natura aplicației dvs., de mediul dumneavoastră operațional și de cerințe. Sperăm că acest tutorial a oferit o introducere utilă a opțiunilor disponibile.