यह Elixir पर पर्सी ग्रुनवाल्ड की श्रृंखला का दूसरा भाग है। Elixir में यूनिकोड मैचिंग पर पहला लेख पढ़ना न भूलें।
Exercism के अभ्यास छोटे और कृत्रिम होते हैं, और अक्सर देखने में बहुत मामूली लगते हैं। यह सोचना आसान है कि अनुभवी डेवलपरों को इनसे सीखने को कुछ नहीं मिलेगा। लेकिन ऐसे कृत्रिम सवाल हल करने पर आप अपनी भाषा के ऐसे हिस्सों को सीखने और इस्तेमाल करने पर मजबूर हो जाते हैं, जिन्हें आपने शायद अब तक छुआ ही न हो। यह नई सीख आपको वास्तविक दुनिया की समस्याएँ ज़्यादा कुशलता से या ज़्यादा अभिव्यक्तिपूर्ण ढंग से हल करने की तरफ ले जा सकती है।
Exercism के Elixir ट्रैक पर समानांतर अक्षर आवृत्ति मध्यम कठिनाई वाला एक अभ्यास है, जो हैरान करने वाली कई दिलचस्प बातें सिखाता है। इस समस्या को सफलतापूर्वक हल करने के लिए ज़रूरी है कि आपका हल कई वर्कर प्रोसेस में पैरेलल रूप से चले। Elixir में सच्चा पैरेललिज़्म हासिल करना दूसरी भाषाओं के मुकाबले हैरान करने जितना आसान है, लेकिन अगर आपने Elixir में कभी कंकरेंट कोड नहीं लिखा है, तो यह थोड़ा डरावना लग सकता है। इस अभ्यास को हल करते समय आपको यह भी पता चलेगा कि Elixir में ऐसा कोड लिखना कितना आसान है जो कंकरेंट या पैरेलल रूप से चल सके। इन कौशलों को अपने कोड पर लगाने से आपके एप्लिकेशन के प्रदर्शन पर बड़ा असर पड़ सकता है।
इस अभ्यास में आपको एक फंक्शन Frequency.frequency/2 बनाना है, जो स्ट्रिंग के ऐरे में अक्षरों की आवृत्ति निकालता है। गणना कई वर्कर प्रोसेस में होनी चाहिए, जिनकी संख्या workers आर्गुमेंट तय करता है:
iex> Frequency.frequency(["Freude", "schöner", "Götterfunken"], workers)
%{
"c" => 1,
"d" => 1,
"e" => 5,
...
"ö" => 2
}
इस लेख में हम इस अभ्यास के एक चलते-फिरते सीक्वेंशियल हल को कंकरेंट बनाकर Elixir में कंकरेंसी को समझेंगे। लेकिन कोड में कूदने से पहले, आइए थोड़ी देर के लिए देखते हैं कि "कंकरेंसी" और "पैरेललिज़्म" का वास्तव में क्या मतलब है, और Elixir में दूसरी भाषाओं के मुकाबले इन दोनों को कैसे हासिल किया जाता है।
कंकरेंसी और पैरेललिज़्म
कंकरेंसी और पैरेललिज़्म आपस में जुड़े शब्द हैं, लेकिन इनका मतलब ठीक-ठीक एक ही नहीं है। एक कंकरेंट प्रोग्राम वह होता है जिसमें कई काम एक साथ "चल रहे" हो सकते हैं, पर किसी भी एक समय पर CPU पर सिर्फ एक ही काम चल रहा होता है (जैसे, एक काम चल रहा हो जबकि दूसरा IO का इंतज़ार कर रहा हो, जैसे डिस्क या नेटवर्क पर पढ़ना या लिखना)। दूसरी तरफ, एक पैरेलल प्रोग्राम कई CPU कोर पर कई काम एक ही समय पर चलाने में सक्षम होता है।
काम को कंकरेंट या पैरेलल रूप से चलाने पर गति काफी बढ़ सकती है, लेकिन कितनी बढ़ेगी, अगर बढ़ेगी भी, यह कई बातों पर निर्भर करता है। कुछ मामलों में कंकरेंसी या पैरेललिज़्म संभव ही नहीं होता; हो सकता है कि जो काम आप पूरा करना चाह रहे हैं वह कंकरेंट या पैरेलल रूप से चलने के लिए उपयुक्त ही न हो, या रनटाइम इन्हें सपोर्ट न करता हो। अगर आपके मामले में कंकरेंट या पैरेलल रूप से चलना वाकई संभव है, तो संभावित गति वृद्धि काफी हद तक इस पर निर्भर करती है कि काम IO-बाउंड है या CPU-बाउंड, और यह कि 1 से ज़्यादा CPU कोर उपलब्ध हैं या नहीं।
इतने सारे कारकों के बावजूद, कुछ "अंगूठे के नियम" हैं जिनसे यह तय किया जा सकता है कि कंकरेंसी या पैरेललिज़्म संभव है या नहीं, और कितनी गति वृद्धि की उम्मीद की जाए। पहला, एक ही CPU कोर पर कंकरेंट रूप से चलाना संभव है, लेकिन पैरेलल रूप से चलाना नहीं। दूसरा, पैरेललिज़्म और कंकरेंसी, IO-बाउंड कामों की गति काफी बढ़ाने चाहिए, और दोनों में यह वृद्धि लगभग समान होनी चाहिए। अंत में, CPU-बाउंड कामों का प्रदर्शन कंकरेंट रूप से चलाने पर वही (या और खराब) रहता है, और आम तौर पर गति तभी बढ़ती है जब इन्हें कई CPU कोर पर पैरेलल रूप से चलाया जाए।
इस अभ्यास में अक्षरों की आवृत्ति की गणना CPU-बाउंड काम का उदाहरण है, इसलिए ऊपर दिए अंगूठे के नियमों के मुताबिक गति तभी बढ़नी चाहिए जब गणना कई CPU कोर पर पैरेलल रूप से की जाए।
Elixir में कंकरेंसी और पैरेललिज़्म, दूसरी भाषाओं के मुकाबले
कई लोकप्रिय भाषाओं में आपको कंकरेंट कोड लिखने के टूल मिलते हैं, लेकिन पैरेललिज़्म हासिल करना आम तौर पर कहीं ज़्यादा जटिल होता है और इसमें कई समझौते करने पड़ते हैं।
उदाहरण के लिए, Node.js रनटाइम पर चलने वाले JavaScript में कंकरेंसी पहले दर्जे की चीज़ है, और IO से जुड़े फंक्शनों के डिफ़ॉल्ट रूप लगभग हमेशा "एसिंक्रोनस" (यानी कंकरेंट) होते हैं। जैसे, fs.ReadFile एक कंकरेंट फंक्शन है और किसी फाइल की सामग्री पढ़ने का मानक तरीका भी। यह बहुत अच्छा है, पर इसके नुकसान भी हैं: कॉलबैक हेल, या प्रॉमिस की ज़रूरत। साथ ही, चूँकि Node सभी कामों को बराबर CPU समय नहीं देता, एक ही CPU-भारी काम चलना रोक सकता है। Node में कामों को पैरेलल करना संभव है, पर आसान बिल्कुल नहीं। चूँकि Node सिंगल थ्रेडेड है, पैरेललिज़्म हासिल करने का एक ही तरीका है: cluster मॉड्यूल से खुद वर्कर प्रोसेस फोर्क करना, या अपने प्रोग्राम की कई प्रतियाँ चलाकर उनके बीच आपसी बातचीत खुद लागू करना।
Node के उलट, Python डिफ़ॉल्ट रूप से कंकरेंट नहीं है, पर कंकरेंट और पैरेलल कोड लिखने के लिए कई टूल देता है। लेकिन हर विकल्प में कुछ समझौते हैं, और एक चुनना हमेशा सीधा-सादा नहीं होता। आप threading मॉड्यूल इस्तेमाल कर सकते हैं और इस तथ्य से जूझ सकते हैं कि global interpreter lock (GIL) एक समय पर एक ही थ्रेड को चलने देता है, जिससे पैरेललिज़्म असंभव हो जाता है। दूसरा विकल्प Python का multiprocessing मॉड्यूल है, जो OS प्रोसेस बनाकर GIL की सीमा को पार कर जाता है (थ्रेड के बजाय)। multiprocessing इस्तेमाल करने से पैरेललिज़्म संभव हो जाता है, पर इसका समझौता यह है कि OS प्रोसेस बनने में धीमे होते हैं और थ्रेड से ज़्यादा मेमोरी लेते हैं।
Elixir में कंकरेंट और पैरेलल कोड लिखना कहीं ज़्यादा आसान है, क्योंकि Elixir रनटाइम के स्तर पर ही कंकरेंट है। Elixir की बड़े पैमाने पर स्केलेबिलिटी की प्रतिष्ठा इसी वजह से है कि यह BEAM वर्चुअल मशीन पर चलता है, जो सारा कोड बेहद हल्के "प्रोसेस" के अंदर चलाती है, और ये सभी प्रोसेस VM के अंदर कंकरेंट रूप से चलते हैं। Python के कंकरेंसी मॉड्यूलों द्वारा बनाए जाने वाले OS-स्तर के थ्रेड और प्रोसेस के मुकाबले BEAM प्रोसेस बनाने की लागत न के बराबर है और ये बेहद कम मेमोरी लेते हैं। साथ ही, Node के इवेंट लूप के उलट, BEAM VM में शेड्यूलर होते हैं जो सभी प्रोसेस को उपलब्ध CPU समय बाँटते हैं, जिससे यह पक्का होता है कि कोई एक CPU-भारी काम बाकी प्रोसेस को चलने से न रोक सके।
इस आर्किटेक्चर की वजह से, प्रोसेस को कंकरेंट रूप से चलाने से पैरेलल रूप से चलाने तक जाना सिर्फ और सिर्फ CPU कोर बढ़ाने की बात है। वास्तव में, कई सालों से BEAM मल्टी-कोर सिस्टम पर सिमेट्रिक मल्टीप्रोसेसिंग (SMP) क्षमताएँ अपने आप चालू कर देती है, जिससे VM के शेड्यूलर चल रहे प्रोसेस को सभी कोर का CPU समय दे पाते हैं। Elixir में कंकरेंसी तो पहले दर्जे की चीज़ है ही, साथ ही कंकरेंट और पैरेलल कोड में कोई फर्क भी नहीं है। आपको बस कंकरेंट कोड लिखना है और अगर 1 से ज़्यादा CPU कोर उपलब्ध हैं, तो VM उसे अपने आप और डिफ़ॉल्ट रूप से पैरेलल कर देती है।
Task मॉड्यूल से कंकरेंट Elixir कोड लिखना
जैसा ऊपर कहा गया, Elixir में कंकरेंसी कामों को कई BEAM प्रोसेस में बाँटकर हासिल की जाती है। आप Kernel.spawn_link/1 जैसे फंक्शनों से बहुत आसानी से प्रोसेस बना सकते हैं, लेकिन Task मॉड्यूल की दी हुई बेहद शक्तिशाली एब्स्ट्रैक्शन इस्तेमाल करना कहीं बेहतर है:
[Task] का सबसे आम उपयोग यह है कि किसी वैल्यू की गणना एसिंक्रोनस रूप से करके सीक्वेंशियल कोड को कंकरेंट कोड में बदला जाए।
Task मॉड्यूल आपको Elixir में अविश्वसनीय रूप से साफ़ कंकरेंट कोड लिखने देता है, न कॉलबैक हेल और न Promises की ज़रूरत।
इस अभ्यास के लिए Task.async_stream/3 बढ़िया विकल्प है:
async_stream(enumerable, function, options \\ [])
Task.async_stream/3 एक स्ट्रीम लौटाता है जो दिए गए function को enumerable के हर आइटम पर कंकरेंट रूप से चलाता है। डिफ़ॉल्ट रूप से बनने वाले प्रोसेस (वर्कर) की संख्या enumerable में मौजूद आइटमों की संख्या के बराबर होती है। इससे हमें workers आर्गुमेंट के ज़रिए पैरेललिज़्म का स्तर तय करने का सीधा तरीका मिल जाता है (बशर्ते हमारे पास काफी CPU कोर हों)। हमें बस इतना करना है कि अक्षरों के ऐरे को सही संख्या में हिस्सों में बाँटें और हर हिस्से को अलग वर्कर में प्रोसेस करने के लिए Task.async_stream/3 इस्तेमाल करें।
सीक्वेंशियल अक्षर-आवृत्ति फंक्शन को कंकरेंट बनाना
आइए मेरे हल से एक चलते-फिरते सीक्वेंशियल इम्प्लीमेंटेशन से शुरू करें:
def frequency(texts, _workers) do
texts
|> get_all_graphemes()
|> count_letters()
end
defp get_all_graphemes(texts) do
texts
|> Enum.join()
|> String.graphemes()
end
defp count_letters(graphemes) do
Enum.reduce(graphemes, %{}, fn grapheme, acc ->
if String.match?(grapheme, ~r/^\p{L}$/u) do
downcased_letter = String.downcase(grapheme)
Map.update(acc, downcased_letter, 1, fn count -> count + 1 end)
else
acc
end
end)
end
ऊपर के इम्प्लीमेंटेशन को हम इन तरीकों से कंकरेंट बना सकते हैं:
-
get_all_graphemes/1से लौटे ग्राफीम के ऐरे कोworkersकी संख्या के बराबर हिस्सों में बाँटें -
Task.async_stream/3इस्तेमाल करके हर हिस्से को एक वर्कर मेंcount_letters/1से प्रोसेस करें - हर वर्कर के नतीजों को एक ही नतीजे में मिलाएँ
ऊपर के चरणों का एक चित्र यहाँ है:

कंकरेंट लॉजिक चालू करने के लिए हमें सिर्फ 2 नए हेल्पर फंक्शन बनाने हैं: एक ग्राफीम को हिस्सों में बाँटने के लिए (split_into_chunks/2) और एक वर्करों से आने वाले नतीजों के stream को मिलाने के लिए (merge_results/1)। इन हेल्पर फंक्शनों को बनाने का एक तरीका यह है:
defp split_into_chunks(all_graphemes, num_chunks) do
all_graphemes_count = Enum.count(all_graphemes)
graphemes_per_chunk = :erlang.ceil(all_graphemes_count / num_chunks)
Enum.chunk_every(all_graphemes, graphemes_per_chunk)
end
defp merge_results_stream(results_stream) do
Enum.reduce(results_stream, %{}, fn {:ok, worker_result}, acc ->
Map.merge(acc, worker_result, fn _key, acc_val, worker_val ->
acc_val + worker_val
end)
end)
end
ये 2 फंक्शन बन जाने के बाद, frequency/2 फंक्शन को कंकरेंट रूप से चलाने के लिए बस नए हेल्पर फंक्शनों के कॉल जोड़ने हैं और count_letters/1 के सीधे कॉल की जगह Task.async_stream/3 लगाना है:
def frequency(texts, workers) do
texts
|> get_all_graphemes()
|> split_into_chunks(workers)
|> Task.async_stream(&count_letters/1)
|> merge_results_stream()
end
ऊपर का फंक्शन पूरी तरह चलने वाला कंकरेंट इम्प्लीमेंटेशन है और सारे टेस्ट पास करता है। कंकरेंट होने के बावजूद यह कोड बिल्कुल आम सीक्वेंशियल कोड की तरह पढ़ा जाता है, जो Task में दिए गए एब्स्ट्रैक्शन की शक्ति दिखाता है।
क्या कंकरेंट वाला रूप वाकई पैरेलल है?
जैसा मैंने इस लेख में पहले कहा, Elixir में कंकरेंट और पैरेलल कोड में कोई फर्क नहीं है। अगर हम workers को 1 से बड़ी संख्या पर सेट करें, और हमारे पास 1 से ज़्यादा CPU कोर उपलब्ध हों, तो BEAM VM बनाए गए प्रोसेस को अपने आप पैरेलल रूप से चला देती है।
डिफ़ॉल्ट रूप से BEAM हर उपलब्ध (लॉजिकल) CPU कोर के लिए एक शेड्यूलर शुरू करती है। BEAM ने कितने शेड्यूलर शुरू किए हैं, यह आप :erlang.system_info/1 से देख सकते हैं:
iex> :erlang.system_info(:schedulers_online)
8
यह उन VM प्रोसेस की अधिकतम संख्या बताता है जो एक ही समय पर चल सकते हैं। workers को शेड्यूलरों की संख्या से ज़्यादा पर सेट करने से पैरेललिज़्म नहीं बढ़ता और प्रदर्शन पर बुरा असर पड़ सकता है।
निष्कर्ष
Elixir के Task मॉड्यूल की मदद से कोड को सीक्वेंशियल से कंकरेंट में बदलना उम्मीद से कहीं ज़्यादा आसान निकलता है। कंकरेंट कोड में ग्राफीम के ऐरे को बाँटने और वर्करों के नतीजे जोड़ने की थोड़ी अतिरिक्त जटिलता जुड़ जाती है, पर अंत में कोड हैरान करने जितना साफ़ बनता है।
इस Exercism समस्या को हल करने से पहले मैंने Task के बारे में सुना था, पर कभी इस्तेमाल नहीं किया था। अपने हल में इसे लगाने के बाद, अब मैं इसे अपने Elixir टूलबॉक्स का एक अनिवार्य हिस्सा मानता हूँ।
इस नए टूल को आप कई तरह से इस्तेमाल करके अपने एप्लिकेशन के प्रदर्शन को बेहतर बनाने की कोशिश कर सकते हैं। ज़्यादातर वेब एप्लिकेशन IO-बाउंड होते हैं, इसलिए एक ही CPU कोर उपलब्ध होने पर भी उन्हें कंकरेंसी का फायदा मिलेगा। वेब एप्लिकेशन की गति बढ़ाने का एक काफी भरोसेमंद तरीका है HTTP रिक्वेस्ट कंकरेंट रूप से भेजना:
def call_apis_async() do
["https://api.example.com/users/123", ...]
|> Task.async_stream(&HTTPoison.get/1)
|> Enum.into([], fn {:ok, res} -> res end)
end
ऊपर का कोड Task.async_stream/3 लगाकर ऐरे के सारे URL कंकरेंट रूप से कॉल करता है, इसके उलट कि हर रिक्वेस्ट पूरी होने का इंतज़ार करके अगली शुरू की जाए। इससे हर रिक्वेस्ट की अवधि के हिसाब से गति काफी बढ़नी चाहिए।