Jak pravděpodobně víte, Ruby má několik implementací, například MRI, JRuby, Rubinius, Opal, RubyMotion atd., a každá z nich může používat jiný vzor provádění kódu. Tento článek se zaměří na první tři z nich a porovná MRI
Jak pravděpodobně víte, Ruby má několik implementací, například MRI, JRuby, Rubinius, Opal, RubyMotion atd., a každá z nich může používat jiný vzor. kód provedení. V tomto článku se zaměříme na první tři z nich a porovnáme MRI (v současnosti nejpopulárnější implementaci) s JRuby a Rubiniusem na základě několika ukázkových skriptů, které mají posoudit vhodnost forkování a threadingu v různých situacích, jako je zpracování algoritmů náročných na výkon procesoru, kopírování souborů apod.Než se začnete "učit praxí", je třeba si zopakovat několik základních pojmů.
Vidlice
je nový podřízený proces (kopie rodičovského procesu).
komunikuje s ostatními prostřednictvím meziprocesových komunikačních kanálů (IPC), jako jsou fronty zpráv, soubory, zásuvky atd.
existuje i po ukončení nadřazeného procesu
je volání POSIX - funguje hlavně na platformách Unix
Vlákno
je "pouze" kontextem provádění, který pracuje v rámci procesu.
sdílí veškerou paměť s ostatními (ve výchozím nastavení využívá méně paměti než fork).
komunikuje s ostatními pomocí objektů sdílené paměti
umírá s procesem
přináší typické problémy s vícevláknovým zpracováním, jako je hladovění, deadlocky atd.
Existuje spousta nástrojů využívajících forky a vlákna, které se používají denně, např. Unicorn (forky) a Puma (vlákna) na úrovni aplikačních serverů, Resque (forky) a Sidekiq (vlákna) na úrovni úloh na pozadí atd.
Následující tabulka uvádí podporu forkování a threadingu v hlavních implementacích jazyka Ruby.
Implementace jazyka Ruby
Rozvětvení
Závitování
MRI
Ano
Ano (s omezením podle GIL**)
JRuby
–
Ano
Rubinius
Ano
Ano
V tomto tématu se jako bumerang vracejí další dvě magická slova - paralelismus a souběžnost - musíme si je trochu vysvětlit. Především nelze tyto pojmy používat zaměnitelně. Zjednodušeně řečeno - o paralelismu můžeme mluvit tehdy, když se dvě nebo více úloh zpracovávají přesně ve stejnou dobu. O souběhu hovoříme tehdy, když se dvě nebo více úloh zpracovává v časových úsecích, které se překrývají (ne nutně ve stejnou dobu). Ano, je to obecné vysvětlení, ale dostatečně dobré k tomu, abyste si všimli rozdílu a pochopili zbytek tohoto článku.
Následující tabulka uvádí podporu paralelismu a souběžnosti.
Implementace jazyka Ruby
Paralelismus (prostřednictvím vidlic)
Paralelismus (prostřednictvím vláken)
Současnost
MRI
Ano
Ne
Ano
JRuby
–
Ano
Ano
Rubinius
Ano
Ano (od verze 2.X)
Ano
To je konec teorie - podívejme se na to v praxi!
Samostatná paměť nemusí nutně spotřebovávat stejné množství paměti jako nadřazený proces. Existují některé techniky optimalizace paměti. Jednou z nich je Copy on Write (CoW), která umožňuje rodičovskému procesu sdílet přidělenou paměť s podřízeným procesem, aniž by ji kopíroval. Pomocí CoW je další paměť potřeba pouze v případě modifikace sdílené paměti dětským procesem. V kontextu Ruby není každá implementace CoW přátelská, např. MRI ji plně podporuje od verze 2.X. Před touto verzí každý fork spotřebovával tolik paměti jako rodičovský proces.
Jednou z největších výhod/nevýhod MRI (nehodící se alternativu škrtněte) je použití GIL (Global Interpreter Lock). Stručně řečeno, tento mechanismus je zodpovědný za synchronizaci provádění vláken, což znamená, že v jednom okamžiku může být prováděno pouze jedno vlákno. Ale počkat... Znamená to, že v MRI nemá smysl vlákna vůbec používat? Odpověď přichází s pochopením vnitřností GIL... nebo alespoň s nahlédnutím do ukázek kódu v tomto článku.
Testovací případ
Abych představil, jak funguje forkování a threading v implementacích jazyka Ruby, vytvořil jsem jednoduchou třídu s názvem Test a několik dalších, které z něj dědí. Každá třída má jiný úkol, který má zpracovat. Ve výchozím nastavení se každá úloha spustí čtyřikrát ve smyčce. Každá úloha také běží proti třem typům provádění kódu: sekvenčnímu, s vidlicemi a s vlákny. Kromě toho, Benchmark.bmbm spustí blok kódu dvakrát - poprvé za účelem zprovoznění běhového prostředí a podruhé za účelem měření. Všechny výsledky uvedené v tomto článku byly získány při druhém spuštění. Samozřejmě, že i bmbm metoda nezaručuje dokonalou izolaci, ale rozdíly mezi více spuštěnými kódy jsou zanedbatelné.
vyžadovat "benchmark"
třída Test
AMOUNT = 4
def run
Benchmark.bmbm do |b|
b.report("sequential") { sequential }
b.report("forking") { forking }
b.report("threading") { threading }
end
end
private
def sekvenční
AMOUNT.times { perform }
end
def forking
AMOUNT.times do
fork do
provést
end
end
Process.waitall
rescue NotImplementedError => e
# metoda fork není v JRuby k dispozici
puts e
end
def threading
threads = []
AMOUNT.times do
threads << Thread.new do
provést
end
end
threads.map(&:join)
end
def perform
raise "not implemented"
end
end
Zátěžový test
Provádí výpočty ve smyčce a generuje tak velké zatížení procesoru.
třída LoadTest < Test
def perform
1000.times { 1000.times { 2**3**4 } }
end
end
Spusťme ji...
LoadTest.new.run
...a zkontrolujte výsledky
MRI
JRuby
Rubinius
sekvenční
1.862928
2.089000
1.918873
rozvětvení
0.945018
–
1.178322
závitování
1.913982
1.107000
1.213315
Jak vidíte, výsledky sekvenčních běhů jsou podobné. Mezi řešeními je samozřejmě malý rozdíl, ale ten je způsoben základní implementací vybraných metod v různých interpretech.
V tomto příkladu má rozvětvení výrazný výkonnostní přínos (kód běží téměř dvakrát rychleji).
Vláknění poskytuje podobné výsledky jako rozvětvení, ale pouze pro JRuby a Rubinius. Spuštění ukázky s vlákny na MRI spotřebuje o něco více času než sekvenční metoda. Důvody jsou přinejmenším dva. Za prvé, GIL si vynucuje sekvenční provádění vláken, proto by v ideálním světě měla být doba provádění stejná jako u sekvenčního běhu, ale dochází také ke ztrátě času na operace GIL (přepínání mezi vlákny atd.). Za druhé, je také potřeba určitá režie času pro vytváření vláken.
Tento příklad neposkytuje nás odpověď na otázku o smyslu použití vláken v MRI. Podívejme se na další.
Test odložení
Spustí metodu spánku.
třída SnoozeTest < Test
def perform
sleep 1
end
end
Zde jsou výsledky
MRI
JRuby
Rubinius
sekvenční
4.004620
4.006000
4.003186
rozvětvení
1.022066
–
1.028381
závitování
1.001548
1.004000
1.003642
Jak vidíte, každá implementace poskytuje podobné výsledky nejen při sekvenčním a vidlicovém běhu, ale také při běhu ve vláknech. Proč má tedy MRI stejný nárůst výkonu jako JRuby a Rubinius? Odpověď je v implementaci spánek.
Magnetická rezonance spánek metoda je implementována pomocí rb_thread_wait_for C, která používá další funkci s názvem native_sleep. Podívejme se na jeho implementaci (kód byl zjednodušen, původní implementaci lze nalézt na adrese zde):
static void
native_sleep(rb_thread_t *th, struct timeval *timeout_tv)
{
...
GVL_UNLOCK_BEGIN();
{
// udělejte zde nějaké věci
}
GVL_UNLOCK_END();
thread_debug("native_sleep donen");
}
Důvodem, proč je tato funkce důležitá, je to, že kromě použití striktního kontextu Ruby také přepíná na systémový kontext, aby v něm mohla provádět některé operace. V takových situacích nemá proces Ruby co dělat... Skvělý příklad plýtvání časem? Ne tak docela, protože existuje GIL, který říká: "V tomto vlákně není co dělat? Přepneme se do jiného a po chvíli se sem vrátíme". To by se dalo provést odemknutím a uzamknutím GIL pomocí GVL_UNLOCK_BEGIN() a GVL_UNLOCK_END() funkce.
Situace je jasná, ale spánek je zřídkakdy užitečná. Potřebujeme více příkladů z reálného života.
Test stahování souborů
Spustí proces, který stáhne a uloží soubor.
vyžadovat "net/http"
třída DownloadFileTest < Test
def perform
Net::HTTP.get("upload.wikimedia.org", "/wikipedia/commons/thumb/7/73/Ruby_logo.svg/2000px-Ruby_logo.svg.png")
konec
konec
Následující výsledky není třeba komentovat. Jsou dosti podobné těm z výše uvedeného příkladu.
1.003642
JRuby
Rubinius
sekvenční
0.327980
0.334000
0.329353
rozvětvení
0.104766
–
0.121054
závitování
0.085789
0.094000
0.088490
Dalším dobrým příkladem může být proces kopírování souborů nebo jakákoli jiná I/O operace.
Závěry
Rubinius plně podporuje forking i threading (od verze 2.X, kdy byl odstraněn GIL). Váš kód může být souběžný a běžet paralelně.
JRuby dobře pracuje s vlákny, ale vůbec nepodporuje forkování. Paralelismu a souběžnosti lze dosáhnout pomocí vláken.
MRI podporuje rozvětvení, ale rozvětvení je omezeno přítomností GIL. Souběžnosti by bylo možné dosáhnout pomocí vláken, ale pouze v případě, že spuštěný kód jde mimo kontext interpretu Ruby (např. IO operace, funkce jádra). Neexistuje žádný způsob, jak dosáhnout paralelismu.