Współbieżność w Ruby
Trójmiejska Grupa Użytkowników Ruby
Gdańsk, 15 Maja 2012
Paweł Gościcki
pawelgoscicki.com
@pawelgoscicki
github.com/pjg
Exvo.com
Wspołbieżność?
Przetwarzanie współbieżne (ang. concurrent computing) – przetwarzanie oparte na współistnieniu wielu wątków lub procesów, operujących na współdzielonych danych. Wątki uruchomione na tym samym procesorze są przełączane w krótkich przedziałach czasu, co sprawia wrażenie, że wykonują się równolegle.
-- http://pl.wikipedia.org/wiki/Przetwarzanie_współbieżne
Czemu jest to ważne? (I)
Czemu jest to ważne? (II)
Free Lunch is over
-- Herb Sutter, http://www.gotw.ca/publications/concurrency-ddj.htm
W 2003 roku zatrzymał się wzrost GHz, ale liczba tranzystorów ciągle rośnie z roku na rok.
Powód? Wprowadzenie wielordzeniowych procesorów.
Nowy miernik
Wydajność w przeliczeniu na 1W (TDP)
(tutaj prawo Moore'a nadal znajduje zastosowanie)
Co to znaczy dla nas?
Dla nas, twórców aplikacji webowych.
Może znaczyć "nic"
Serwery aplikacji są właściwie bez stanu (stan przechowywany jest w sesji u klienta, każdy request może być obsługiwany przez inny proces/maszynę).
Możemy się skalować horyzontalnie dodając nowe serwery aplikacji.
Tylko pojawiają się problemy...
- Jeden proces serwera aplikacji
~150MB
- Chcemy obsługiwać 100 requestów jednocześnie?
15GB
RAM trzeba...
- RAM jest tani... (póki nie trzeba go podwajać co jakiś czas)
Na początek trochę teorii... (I)
PROCES
- Definiuje się jako egzemplarz wykonywanego programu
- Każdy działający program uruchamia (co najmniej) jeden proces
- Każdy proces może tworzyć nowe procesy (kopie siebie) funkcją
fork()
- niczego nie współdzieląc
- Czyli nową pamięć trzeba zarezerwować dla nowego procesu (problem wspomnianych
15GB
ramu)
Na początek trochę teorii... (II)
Passenger/Unicorn
- Dynamiczne skalowanie - gdy jest zapotrzebowanie (więcej requestów), to jest uruchamianych więcej procesów serwera aplikacji, gdy mniej to procesy są zabijane
- Używają systemowego (Linux/Unix) polecenia
fork()
, starając się wykorzystywać copy on write tzn. pamięć nowego procesu dziecka jest kopiowana tylko w momencie, gdy zostaje zmieniona
- Oczywiście w modelach/kontrolerach ładujemy sporo obiektów, więc pamięć będzie się zwiększać, ale część pozostanie współdzielona
- Słabe wsparcie w Ruby (1.8.7 REE i 2.0+ MRI) (garbage collector)
- Bardzo popularne rozwiązanie pozwalające trochę efektywniej używać dostępne rdzenie/procesory
I na tym właściwie mógłbym zakończyć swoją prezentację
Na początek trochę teorii... (III)
WĄTEK (thread)
- Wątek działa w kontekście procesu
- Nowy wątek dzieli wszystkie zasoby zarezerwowane dla danego procesu (m.in. współdzieli otwarte pliki, gniazda, pamięć z wątkiem, który go wywołał, czyli może zmieniać te same zmienne)
- W Ruby narzut (overhead) jednego wątku to tylko
~2KB
- Przełączanie się między wątkami jest szybsze niż przełączanie się między procesami
- Używając wątków kontrolowanych przez sytem operacyjny (o czym za chwilę) możemy wykonywać kod współbieżnie na wielu rdzeniach/procesorach
- "Zarządca" może w każdej chwili przerwać jeden wątek i przełączyć się na kolejny
Na początek trochę teorii... (IV)
WADY WĄTKÓW
- Komunikacja przez współdzieloną pamięć - trzeba używać blokad, muteksów (algorytmów wzajemnego wykluczania)
- Łatwo spowodować korupcję danych
- Możliwość wystąpienia zakleszczeń (deadlocks) - pierwszy wątek czeka na zakończenie drugiego, a drugi na pierwszego (sytuacja spotykana w bazach danych)
- Większe skomplikowanie kodu
- Niedeterministyczne zachowanie kodu (trudno jest być 100% pewnym jak dokładnie zachowają się wątki wykonywane współbieżnie) - bardzo trudno jest pisać testy wykrywające wyścigi (chociaż jest to możliwe...)
A jak to się robi w Ruby?
Zielone wątki (ang. green threads)
- Istnieją w Ruby z linii 1.8
- obsługiwane przez VM Ruby'iego (a nie przez system operacyjny)
- takie samo zachowanie (ujednolicone) na różnych platformach (systemach operacyjnych) (bez optymalizacji)
- szybsze uruchamianie, mniejszy narzut pamięci (niż natywne wątki, o których za chwilę)
- nie wykonują się współbieżne (limit 1 procesora)
- operacje I/O blokują inne wątki (czyli trzeba czekać na odczyt danych z bazy danych)
- 14 lat temu Matz prawidłowo stwierdził, że to wystarczy (wtedy nie było wieloprocesorowych systemów)
Natywne wątki (ang. native threads)
- Ruby linii 1.9
- obsługiwane bezpośrednio przez system operacyjny (Ruby ma mniej pracy)
- mogą się wykonywać na wielu procesorach jednocześnie
- operacje I/O nie blokują innych wątków (większość obecnych gemów Railsowych nie blokuje Ruby'iego)
- tylko
~2KB
narzutu na każdy wątek
Fibers/continuations
- Ruby linii 1.9
- "lekkie wątki"
- do zadań programisty należy przełączanie się między wątkami (czyli decyzje o tym kiedy przerwać wątek i uruchomić kolejny) (a nie do zarządcy)
- wiemy kiedy takie wątki startują i się zatrzymują, więc nie mamy problemów związanych z korupcją danych
- podobnie jak z zielonymi wątkami operacje I/O blokują inne wątki
GIL - Global Interpreter Lock
- aby zapobiegać korupcji danych, w danym momencie czasu tylko 1 wątek może "rozmawiać" z VM Ruby'iego
- czyli nie możemy rodzielić pracy na wiele procesorów...
- (inne języki interpretowane, jak np. Python też mają odpowiednik GILa)
- nie można doprowadzić do korupcji danych (życie programistów staje się łatwiejsze)
- nie ma wyścigów (race conditions)
- część Ruby MRI jest napisana w C i nie jest "thread safe" (np. Hash)
- niektóre implementacje Ruby nie mają GILa (JRuby/Rubinius/MacRuby)
To może usuńmy GIL?
- część rozszerzeń pisana w C przestanie działać (wiele rzeczy... i core i stdlib i gemy)
- ogólnie - zbyt dużo roboty / zbyt duża zmiana
- pisanie rozszerzeń w C staje się dużo trudniejsze (blokady zapisu), a tak napisane rozszerzenia będą wolniejsze
- Ruby nie jest taki wolny - ograniczenie I/O a nie CPU (zazwyczaj czekamy na zakończenie operacji I/O a nie obliczeń) (podobnie node.js, EventedMachine, Reactor pattern)
Analogia do kaloszy
Jeżeli nie pada, to czemu mamy je codziennie nosić?
The main concern is safeness. We cannot remove the GIL because then it would allow the C Ruby code to crash when you use threads. I don't want Ruby to be like that.
-- Matz @ RubyConf '2011-10, New Orleans
To jak radzą sobie JRuby/Rubinius?
- JRuby/Rubinius przepisały sporą część Ruby'iego (głównie rzeczy napisane w C), tak aby osiągnąć "thread safety"
- Niektóre gemy nie działają, o części w ogóle nie wiadomo czy działają czy nie
- Co innego jest działanie pod JRuby/Rubiniusem, a co innego bycie "thread safe"
- Z drugiej strony np. w JRuby jak coś nie działa z Ruby'iego, to można wziąć jakąś bibliotekę Javy
- No to używajmy JRuby! (ew. Rubiniusa)
Ale...
Współbieżność jest trudna.
Wydaje się, że już ją umiesz, że wszystko się kuma, ale napotykasz na wyścig (ang. race condition) -- sytuacja, że kod wykonuje się wcześniej niż zakładaliśmy -- np. przed zakończeniem innego kodu), który teoretycznie nie jest możliwy, ale jednak ma miejsce i wszystko wywraca się do góry nogami.
Przykład
@array, threads = [], []
4.times do
threads << Thread.new { (1..100_000).each { |n| @array << n } }
end
threads.each { |t| t.join }
puts @array.size
- MRI:
400000
- JRuby:
335467, 342397, 341080
- MRI:
294278, 285755, 280704
A co z Rails?
I z MRI?
Mamy coś "za darmo" (dokładniej: współbieżność przy wykonywaniu I/O)
Gemfile:
config/database.yml
development:
adapter: mysql2
pool: 20
...
routes.rb
class SiteController < ApplicationController
def index
threads = []
10.times do |n|
threads << Thread.new do
ActiveRecord::Base.connection_pool.with_connection do |conn|
res = conn.execute("SELECT SLEEP(1)")
end
end
end
threads.each { |t| t.join }
end
end
☺ rails s
=> Booting WEBrick
=> Rails 3.2.3 application starting in development on http://0.0.0.0:3000
=> Call with -d to detach
☺ ab -c 1 -n 1 http://localhost:3000/
...
Requests per second: 0.99 [#/sec] (mean)
Time per request: 1013.341 [ms] (mean)
Time per request: 1013.341 [ms] (mean, across all concurrent requests)
Started GET "/" for 127.0.0.1 at 2012-05-13 20:05:19 +0200
Processing by SiteController#index as */*
(1007.0ms) SELECT SLEEP(1)
(1003.1ms) SELECT SLEEP(1)
(1005.1ms) SELECT SLEEP(1)
(1001.4ms) SELECT SLEEP(1)
(1000.2ms) SELECT SLEEP(1)
(1000.2ms) SELECT SLEEP(1)
(1000.2ms) SELECT SLEEP(1)
(1000.2ms) SELECT SLEEP(1)
(1000.2ms) SELECT SLEEP(1)
(1000.2ms) SELECT SLEEP(1)
Rendered site/index.html.erb within layouts/application (1.3ms)
Completed 200 OK in 1066ms (Views: 39.8ms | ActiveRecord: 0.0ms)
config.threadsafe!
Rails 2.2+
config/database.yml
development:
adapter: mysql2
pool: 20
...
config/environments/development.rb
class SiteController < ApplicationController
def index
ActiveRecord::Base.connection_pool.with_connection do |conn|
res = conn.execute("SELECT SLEEP(1)")
end
end
end
☺ r s
=> Booting WEBrick
=> Rails 3.2.3 application starting in development on http://0.0.0.0:3000
=> Call with -d to detach
=> Ctrl-C to shutdown server
[2012-05-13 21:25:46] INFO WEBrick 1.3.1
[2012-05-13 21:25:46] INFO ruby 1.9.3 (2012-04-20) [x86_64-linux]
[2012-05-13 21:25:46] INFO WEBrick::HTTPServer#start: pid=28954 port=3000
☺ ab -c 10 -n 10 http://localhost:3000/
Benchmarking localhost (be patient).....done
...
Requests per second: 9.37 [#/sec] (mean)
Time per request: 1067.630 [ms] (mean)
Time per request: 106.763 [ms] (mean, across all concurrent requests)
Transfer rate: 13.02 [Kbytes/sec] received
Connection Times (ms)
min mean[+/-sd] median max
Connect: 0 0 0.0 0 0
Processing: 1015 1049 20.2 1057 1067
Waiting: 1015 1048 20.1 1057 1067
Total: 1015 1049 20.2 1058 1067
Percentage of the requests served within a certain time (ms)
50% 1058
66% 1065
75% 1065
80% 1067
90% 1067
95% 1067
98% 1067
99% 1067
100% 1067 (longest request)
Wnioski
- Programowanie współbieżne jest trudne... ale chyba nie do uniknięcia (wszyscy będziemy musieli się kiedyś przestawić - nawet w telefonach mamy już 4 rdzenie)
- Istnieją rozwiązania próbujące ukryć przed programistą część problemów programowania współbieżnego, ale nie wszystkie da się ukryć...
- Warto wiedzieć dlaczego to jest takie skomplikowane
Dziękuję za uwagę
Pytania?
Materiały uzupełniające