এলিক্সার নিয়ে পার্সি গ্রুনভাল্ডের সিরিজের এটি দ্বিতীয় পর্ব। এলিক্সারে ইউনিকোড ম্যাচিং নিয়ে প্রথম নিবন্ধটিও পড়ে ফেলুন।
Exercism-এর অনুশীলনীগুলো ছোট, কৃত্রিম, এবং অনেক সময় আপাতদৃষ্টিতে তুচ্ছ মনে হয়। এগুলো থেকে অভিজ্ঞ প্র্যাকটিশনারদের শেখার কিছু নেই, এটা ভাবা সহজ। তবে এই কৃত্রিম সমস্যাগুলো সমাধান করতে গিয়ে আপনি হয়তো আপনার ভাষার এমন কিছু অংশ শিখতে ও কাজে লাগাতে পারেন, যা আগে ঘাঁটানো হয়নি। এই নতুন শেখা আপনাকে বাস্তব জগতের সমস্যাগুলো আরও দক্ষভাবে বা আরও স্পষ্টভাবে সমাধান করতে সাহায্য করতে পারে।
প্যারালাল লেটার ফ্রিকোয়েন্সি হলো Exercism-এর এলিক্সার ট্র্যাক-এর একটি মধ্যম কঠিনতার অনুশীলনী, যেটি থেকে আশ্চর্যজনকভাবে অনেক 흥দ্বীপপূর্ণ শিক্ষা পাওয়া যায়। এই সমস্যাটি সফলভাবে সমাধান করতে হলে আপনার সমাধানকে একাধিক ওয়ার্কার প্রসেসে প্যারালালভাবে চলতে হবে। অন্য ভাষার তুলনায় এলিক্সারে সত্যিকারের প্যারালালিজম অর্জন আশ্চর্যজনকভাবে সহজ, তবে এলিক্সারে যদি আগে কখনো কনকারেন্ট কোড লেখা না থাকে, তাহলে বিষয়টা একটু ভয়ের মনে হতে পারে। এই অনুশীলনীটি সমাধান করতে গিয়ে আপনি যে বিষয়গুলো আবিষ্কার করবেন তার একটি হলো, কনকারেন্ট বা প্যারালালভাবে চলতে পারে এমন কোড লেখা এলিক্সার কত সহজ করে দেয়। আপনার কোডে এই দক্ষতাগুলো কাজে লাগালে আপনার অ্যাপ্লিকেশনের পারফরম্যান্সে বড় প্রভাব পড়তে পারে।
এই অনুশীলনীতে আপনাকে একটি ফাংশন, Frequency.frequency/2 বাস্তবায়ন করতে হবে, যা স্ট্রিংয়ের একটি অ্যারেতে অক্ষরের কম্পাঙ্ক নির্ণয় করে। হিসাবটি বেশ কয়েকটি ওয়ার্কার প্রসেসে চালানো উচিত, যার সংখ্যা workers আর্গুমেন্ট দিয়ে ঠিক করা হয়:
iex> Frequency.frequency(["Freude", "schöner", "Götterfunken"], workers)
%{
"c" => 1,
"d" => 1,
"e" => 5,
...
"ö" => 2
}
এই লেখায় আমরা এলিক্সারের কনকারেন্সি নিয়ে খুঁজে দেখব, এই অনুশীলনীর একটি সচল সিকোয়েন্সিয়াল সমাধানকে কনকারেন্ট বানিয়ে। তবে কোডে ঝাঁপ দেওয়ার আগে, চলুন একটু সময় নিয়ে দেখি "কনকারেন্সি" আর "প্যারালালিজম" আসলে কী বোঝায়, এবং এলিক্সারে বনাম অন্য ভাষায় এই দুটো কীভাবে অর্জন করা যায়।
কনকারেন্সি ও প্যারালালিজম
কনকারেন্সি ও প্যারালালিজম হলো সম্পর্কিত দুটি পরিভাষা, তবে এদের অর্থ একেবারে এক নয়। একটি কনকারেন্ট প্রোগ্রাম হলো এমন একটি প্রোগ্রাম, যেখানে একাধিক টাস্ক "চলমান" থাকতে পারে, কিন্তু যেকোনো একটি নির্দিষ্ট মুহূর্তে CPU-তে কেবল একটি টাস্কই চলছে (যেমন, একটি টাস্ক চলছে আর অন্যটি ডিস্ক বা নেটওয়ার্কে লেখা-পড়ার মতো IO-র জন্য অপেক্ষা করছে)। অন্যদিকে, একটি প্যারালাল প্রোগ্রাম একাধিক CPU কোরের উপর একই সময়ে একাধিক টাস্ক চালাতে সক্ষম।
কনকারেন্ট আর প্যারালাল, দুটো নির্বাহই যথেষ্ট গতি বৃদ্ধি দিতে পারে, তবে কতটা গতি বাড়বে, যদি আদৌ বাড়ে, তা অনেক বিষয়ের উপর নির্ভর করে। এমনও সময় আছে যখন কনকারেন্সি বা প্যারালালিজম একেবারেই সম্ভব নয়; আপনি যে কাজটি শেষ করতে চাইছেন তা কনকারেন্ট বা প্যারালাল নির্বাহের উপযোগী নয়, অথবা রানটাইম সেগুলো সমর্থন করে না। আপনার ক্ষেত্রে যদি সত্যিই কনকারেন্ট বা প্যারালাল নির্বাহ সম্ভব হয়, তাহলে কতটা গতি বাড়ানো যাবে তা মূলত নির্ভর করে কাজটি IO-নির্ভর না CPU-নির্ভর, এবং একাধিক CPU কোর পাওয়া যাচ্ছে কি না তার উপর।
এতসব বিষয় জড়িত থাকলেও, কনকারেন্সি বা প্যারালালিজম সম্ভব কি না এবং কতটা গতি বৃদ্ধি আশা করা যায় তা নির্ধারণের জন্য কয়েকটি "অভিজ্ঞতালব্ধ নিয়ম" আছে। প্রথমত, একটি মাত্র CPU কোরেও কনকারেন্ট নির্বাহ সম্ভব, কিন্তু প্যারালাল নির্বাহ নয়। দ্বিতীয়ত, IO-নির্ভর কাজে প্যারালালিজম আর কনকারেন্সি দুটোই যথেষ্ট গতি বাড়ানো উচিত, এবং দুটোর ক্ষেত্রেই সেই গতি বৃদ্ধি নামমাত্র একই হওয়া উচিত। সবশেষে, CPU-নির্ভর কাজ কনকারেন্টভাবে চালালে পারফরম্যান্স একই (বা আরও খারাপ) থাকে, আর সাধারণত একাধিক CPU কোরে প্যারালালভাবে চালালেই কেবল গতি বাড়ে।
এই অনুশীলনীর অক্ষর-কম্পাঙ্ক গণনাটি CPU-নির্ভর কাজের একটি উদাহরণ, তাই উপরের নিয়ম অনুযায়ী একাধিক CPU কোরে প্যারালালভাবে গণনা করলেই কেবল গতি বাড়া উচিত।
এলিক্সার বনাম অন্য ভাষায় কনকারেন্সি ও প্যারালালিজম
অনেক জনপ্রিয় ভাষাই কনকারেন্ট কোড লেখার সরঞ্জাম দেয়, কিন্তু প্যারালালিজম অর্জন সাধারণত অনেক বেশি জটিল এবং নানা আপসে ভরা।
যেমন, Node.js রানটাইমে চলা JavaScript-এ কনকারেন্সি প্রথম শ্রেণির নাগরিক, আর IO-সম্পর্কিত ফাংশনগুলোর ডিফল্ট সংস্করণ প্রায় সবসময়ই "অ্যাসিনক্রোনাস" (অর্থাৎ কনকারেন্ট)। যেমন, fs.ReadFile একটি কনকারেন্ট ফাংশন এবং ফাইলের ভেতরের বিষয়বস্তু পড়ার আদর্শ উপায়। এটা দুর্দান্ত, তবে এর খারাপ দিকও আছে, যেমন কলব্যাক হেল কিংবা Promises-এর প্রয়োজন। এছাড়া, Node যেহেতু সব টাস্কে সমানভাবে CPU সময় ভাগ করে না, তাই একটি মাত্র CPU-নিবিড় টাস্ক দিয়েও নির্বাহ আটকে দেওয়া সম্ভব। Node-এ নির্বাহ প্যারালাল করা সম্ভব, তবে নিশ্চিতভাবেই সহজ নয়। যেহেতু Node একক থ্রেডে চলে, প্যারালালিজম অর্জনের একমাত্র উপায় হলো cluster মডিউল দিয়ে নিজে হাতে ওয়ার্কার প্রসেস ফর্ক করা অথবা আপনার প্রোগ্রামের একাধিক ইনস্ট্যান্স চালিয়ে নিজে হাতে তাদের মধ্যে যোগাযোগ তৈরি করা।
Node-এর বিপরীতে Python ডিফল্টভাবে কনকারেন্ট নয়, তবে কনকারেন্ট ও প্যারালাল কোড লেখার জন্য একাধিক সরঞ্জাম দেয়। তবে প্রতিটি বিকল্পেই আপস আছে, আর একটা বেছে নেওয়া সবসময় সহজ নয়। আপনি threading মডিউল ব্যবহার করতে পারেন, তবে মেনে নিতে হবে যে global interpreter lock (GIL) একসাথে কেবল একটি থ্রেডে নির্বাহ সীমাবদ্ধ রাখে, ফলে প্যারালালিজম অসম্ভব হয়ে পড়ে। আরেকটি বিকল্প হলো Python-এর multiprocessing মডিউল, যা থ্রেডের বদলে OS প্রসেস তৈরি করে GIL-এর সীমাবদ্ধতা এড়িয়ে যায়। multiprocessing ব্যবহার করলে প্যারালালিজম সম্ভব হয়, তবে এর আপস হলো থ্রেডের তুলনায় OS প্রসেস তৈরি হতে বেশি সময় নেয় এবং বেশি মেমরি খায়।
এলিক্সারে কনকারেন্ট ও প্যারালাল কোড লেখা অনেক সহজ, কারণ এলিক্সার রানটাইম পর্যায়েই কনকারেন্ট। বিশাল স্কেলের ক্ষমতার জন্য এলিক্সারের সুনাম এসেছে এই কারণে যে এটি BEAM ভার্চুয়াল মেশিনে চলে, যা সব কোড অত্যন্ত হালকা "প্রসেসে" চালায়, আর এই প্রসেসগুলো VM-এর ভেতরে একসাথে কনকারেন্টভাবে চলে। Python-এর কনকারেন্সি মডিউলগুলো যে OS-পর্যায়ের থ্রেড ও প্রসেস তৈরি করে, তাদের তুলনায় BEAM প্রসেস তৈরি করতে প্রায় কোনো খরচই হয় না এবং তা নগণ্য মেমরি ব্যবহার করে। এছাড়া, Node-এর ইভেন্ট লুপের বিপরীতে BEAM VM-এ শিডিউলার থাকে, যা সব প্রসেসের মধ্যে পাওয়া CPU সময় ভাগ করে দেয়, ফলে একটি মাত্র CPU-নিবিড় টাস্ক অন্য প্রসেসের নির্বাহ আটকে দিতে পারে না।
এই স্থাপত্যের কারণে, প্রসেসগুলোকে কনকারেন্টভাবে চালানো থেকে প্যারালালভাবে চালানোতে যেতে কেবল আরও CPU কোর যোগ করলেই হয়। আসলে বহু বছর ধরেই BEAM মাল্টি-কোর সিস্টেমে স্বয়ংক্রিয়ভাবে সিমেট্রিক মাল্টিপ্রসেসিং (SMP) ক্ষমতা চালু করে, যা VM-এর শিডিউলারদের সব কোরের CPU সময় চলমান প্রসেসগুলোকে দিতে দেয়। এলিক্সারে কনকারেন্সি শুধু প্রথম শ্রেণির নাগরিকই নয়, বরং কনকারেন্ট আর প্যারালাল কোডের মধ্যে কোনো পার্থক্যও নেই। আপনাকে কেবল কনকারেন্ট কোড লিখতে হবে, আর একাধিক CPU কোর থাকলে VM স্বয়ংক্রিয়ভাবে এবং ডিফল্টভাবেই সেটা প্যারালাল করে দেবে।
Task মডিউল দিয়ে কনকারেন্ট এলিক্সার কোড লেখা
উপরে বলা হয়েছে, এলিক্সারে কনকারেন্সি অর্জিত হয় কাজগুলোকে একাধিক BEAM প্রসেসে ভাগ করে দিয়ে। আপনি Kernel.spawn_link/1-এর মতো ফাংশন দিয়ে খুব সহজেই প্রসেস তৈরি করতে পারেন, তবে Task মডিউল-এর দেওয়া দুর্দান্ত শক্তিশালী অ্যাবস্ট্রাকশন ব্যবহার করাই অনেক ভালো:
[Task]-এর সবচেয়ে সাধারণ ব্যবহার হলো, একটি মান অ্যাসিনক্রোনাসভাবে হিসাব করে সিকোয়েন্সিয়াল কোডকে কনকারেন্ট কোডে রূপান্তর করা।
Task মডিউল দিয়ে এলিক্সারে আপনি অবিশ্বাস্য রকম পরিষ্কার কনকারেন্ট কোড লিখতে পারেন, কোনো কলব্যাক হেল নেই, Promises-এর দরকারও নেই।
এই অনুশীলনীর জন্য Task.async_stream/3 দারুণ একটি বিকল্প:
async_stream(enumerable, function, options \\ [])
Task.async_stream/3 একটি স্ট্রিম রিটার্ন করে, যা enumerable-এর প্রতিটি এলিমেন্টের উপর দেওয়া function-টি কনকারেন্টভাবে চালায়। ডিফল্টভাবে তৈরি হওয়া প্রসেসের (ওয়ার্কার) সংখ্যা 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দিয়ে প্রক্রিয়া করা - প্রতিটি ওয়ার্কারের ফলাফল একটি একক ফলাফলে মিলিয়ে দেওয়া
উপরের ধাপগুলোর একটি চিত্র এখানে দেওয়া হলো:

কনকারেন্ট লজিক চালু করতে আমাদের কেবল ২টি নতুন হেল্পার ফাংশন বাস্তবায়ন করতে হবে: একটি গ্রাফিমগুলোকে চাঙ্কে ভাগ করতে (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
এই ২টি ফাংশন বাস্তবায়ন হয়ে গেলে 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-এ দেওয়া অ্যাবস্ট্রাকশনগুলোর শক্তি দেখায়।
কনকারেন্ট সংস্করণটি কি আসলে প্যারালাল?
এই লেখায় আগেই বলেছি, এলিক্সারে কনকারেন্ট আর প্যারালাল কোডের মধ্যে কোনো পার্থক্য নেই। workers-কে ১-এর চেয়ে বড় কোনো সংখ্যায় ঠিক করলে এবং একাধিক CPU কোর থাকলে, BEAM VM নিজে থেকেই তৈরি হওয়া প্রসেসগুলোর নির্বাহ প্যারালাল করে দেয়।
ডিফল্টভাবে BEAM প্রতিটি (লজিকাল) CPU কোরের জন্য একটি শিডিউলার চালু করে। BEAM কতগুলো শিডিউলার চালু করেছে তা আপনি :erlang.system_info/1 দিয়ে দেখতে পারেন:
iex> :erlang.system_info(:schedulers_online)
8
এটি বোঝায় একই সময়ে সর্বোচ্চ কতগুলো VM প্রসেস চলতে পারে। শিডিউলারের সংখ্যার চেয়ে বড় কোনো সংখ্যায় workers ঠিক করলে প্যারালালিজম বাড়ে না, বরং পারফরম্যান্স খারাপ হতে পারে।
উপসংহার
এলিক্সারের Task মডিউল ব্যবহার করে কোডকে সিকোয়েন্সিয়াল থেকে কনকারেন্টে রূপান্তর করা প্রত্যাশার চেয়ে অনেক সহজ বলে প্রমাণিত হয়। কনকারেন্ট কোড গ্রাফিমের অ্যারে ভাগ করা আর ওয়ার্কারের ফলাফল একত্র করার সময় কিছু বাড়তি জটিলতা যোগ করে, তবে শেষ ফলাফলটি আশ্চর্যজনকভাবে পরিষ্কার।
Exercism-এর এই সমস্যাটি সমাধান করার আগে আমি Task-এর কথা শুনেছিলাম, তবে কখনো ব্যবহার করিনি। আমার সমাধানে এটি কাজে লাগানোর পর, এখন আমি এটিকে আমার এলিক্সার টুলবক্সের এক অপরিহার্য অংশ বলেই মনে করি।
আপনার অ্যাপ্লিকেশনের পারফরম্যান্স উন্নত করার চেষ্টায় এই নতুন সরঞ্জামটি নানা ভাবে ব্যবহার করতে পারেন। বেশিরভাগ ওয়েব অ্যাপ্লিকেশন 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 কনকারেন্টভাবে কল করে, প্রতিটি রিকোয়েস্ট শেষ হওয়ার পর পরেরটি শুরু করার অপেক্ষা না করে, যা প্রতিটি রিকোয়েস্টের সময়কাল অনুযায়ী যথেষ্ট গতি বৃদ্ধি দেবে।