هذا هو الجزء الثاني من سلسلة Percy Grunwald عن Elixir. لا تفوّت المقال الأول عن مطابقة يونيكود في Elixir.
تمارين Exercism صغيرة ومُصطنعة، وغالبًا ما تبدو تافهة. من السهل أن تتخيل أن الممارسين ذوي الخبرة لن يجدوا فيها ما يتعلّمونه. لكن حل هذه المسائل المُصطنعة قد يدفعك إلى تعلّم أجزاء من لغتك وتطبيقها لم تستكشفها من قبل. هذا التعلّم الجديد قد يقودك إلى حل مشكلات العالم الحقيقي بكفاءة أكبر أو بتعبير أوضح.
تمرين تكرار الأحرف المتوازي هو تمرين متوسط الصعوبة على مسار Elixir في Exercism يكشف عن عدد مدهش من الدروس المثيرة للاهتمام. لحل هذه المشكلة بنجاح، يجب أن يُنفَّذ حلّك على التوازي ضمن عدة عمليات عاملة. تحقيق التوازي الحقيقي في 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 مقابل لغات أخرى.
التزامن والتوازي
التزامن والتوازي مصطلحان مرتبطان لكن لا يعنيان الشيء نفسه بالضبط. البرنامج المتزامن هو برنامج يمكن فيه أن تكون عدة مهام "قيد التنفيذ"، لكن في أي لحظة زمنية واحدة لا تُنفّذ سوى مهمة واحدة على المعالج (مثلًا، تنفيذ مهمة بينما تنتظر أخرى الإدخال/الإخراج مثل القراءة أو الكتابة من القرص أو الشبكة). في المقابل، البرنامج المتوازي قادر على تنفيذ عدة مهام في الوقت نفسه على عدة نوى معالجة.
يمكن أن يوفّر التنفيذ المتزامن والتنفيذ المتوازي على حد سواء زيادات كبيرة في السرعة، لكن مقدار التسريع الممكن، إن وُجد، يعتمد على عوامل كثيرة. هناك حالات لا يكون فيها التزامن أو التوازي ممكنًا أصلًا؛ فقد لا تكون المهمة التي تحاول إنجازها مناسبة للتنفيذ المتزامن أو المتوازي، أو قد لا تدعمهما بيئة التشغيل. إذا كانت حالتك تسمح فعلًا بالتنفيذ المتزامن أو المتوازي، فإن مقدار التسريع الممكن يعتمد إلى حد كبير على ما إذا كانت المهمة محدودة بالإدخال/الإخراج أم محدودة بالمعالج، وعلى ما إذا كان هناك أكثر من 1 نواة معالجة متاحة.
رغم كثرة العوامل المساهمة، هناك بعض "القواعد الإرشادية" لتحديد ما إذا كان التزامن أو التوازي ممكنًا ومقدار التسريع المتوقع. أولًا، التنفيذ المتزامن ممكن على نواة معالجة واحدة، أما التنفيذ المتوازي فلا. ثانيًا، ينبغي أن يوفّر كلٌّ من التوازي والتزامن تسريعًا كبيرًا للمهام المحدودة بالإدخال/الإخراج، وينبغي أن يكون التسريع متساويًا اسميًا في الحالتين. وأخيرًا، ينبغي أن يكون أداء المهام المحدودة بالمعالج هو نفسه (أو أبطأ) عند تنفيذها بشكل متزامن، وبشكل عام لا تكون زيادات السرعة ممكنة إلا عند تنفيذها على التوازي عبر عدة نوى معالجة.
حساب تكرار الأحرف في هذا التمرين مثال على مهمة محدودة بالمعالج، لذا وفقًا للقواعد الإرشادية أعلاه، لا ينبغي أن تكون زيادة السرعة ممكنة إلا بإجراء الحساب على التوازي على عدة نوى معالجة.
التزامن والتوازي في Elixir مقابل لغات أخرى
توفّر لك كثير من اللغات الشائعة أدوات لكتابة كود متزامن، لكن تحقيق التوازي عادةً أكثر تعقيدًا بكثير ومليء بالمقايضات.
على سبيل المثال، التزامن مواطن من الدرجة الأولى في JavaScript التي تعمل على بيئة تشغيل Node.js، والنسخ الافتراضية من الدوال المتعلقة بالإدخال/الإخراج تكون دائمًا تقريبًا "لا متزامنة" (أي أنها متزامنة). على سبيل المثال، fs.ReadFile دالة متزامنة وهي الطريقة القياسية لقراءة محتويات ملف. هذا رائع لكن له أيضًا مساوئه في صورة جحيم الاستدعاءات الراجعة، أو الحاجة إلى الوعود. كذلك، بما أن Node لا توزّع وقت المعالج بالتساوي بين المهام، فإنه لا يزال من الممكن تعطيل التنفيذ بمهمة واحدة كثيفة الاستخدام للمعالج. جعل التنفيذ متوازيًا في Node ممكن، لكنه بالتأكيد ليس سهلًا. وبما أن Node أحادية الخيط، فإن الطريقة الوحيدة لتحقيق التوازي هي تفرّع عمليات عاملة يدويًا باستخدام وحدة cluster أو تشغيل عدة نسخ من برنامجك وتنفيذ التواصل بينها يدويًا.
Python، بخلاف Node، ليست متزامنة افتراضيًا لكنها تمنحك أدوات متعددة لكتابة كود متزامن ومتوازٍ. لكن لكل خيار مقايضاته، واختيار أحدها ليس بالضرورة أمرًا مباشرًا. يمكنك استخدام وحدة threading والتعامل مع حقيقة أن global interpreter lock (GIL) يحدّ التنفيذ بخيط واحد في كل مرة، مما يجعل التوازي مستحيلًا. والخيار الآخر هو وحدة multiprocessing في Python، التي تتجاوز قيد GIL عبر إنشاء عمليات على مستوى نظام التشغيل (بدلًا من الخيوط). استخدام multiprocessing يجعل التوازي ممكنًا لكن مع مقايضة أن عمليات نظام التشغيل أبطأ في الإنشاء وتستهلك ذاكرة أكبر من الخيوط.
كتابة كود متزامن ومتوازٍ في Elixir أبسط بكثير، لأن Elixir متزامنة على مستوى بيئة التشغيل. تأتي سمعة Elixir في التوسّع الهائل من كونها تعمل على الآلة الافتراضية BEAM، التي تنفّذ كل الكود داخل "عمليات" خفيفة الوزن للغاية تعمل كلها بشكل متزامن داخل الآلة الافتراضية. عمليات BEAM تكلفة إنشائها ضئيلة وتستهلك كميات دقيقة من الذاكرة مقارنة بالخيوط والعمليات على مستوى نظام التشغيل التي تنشئها وحدات التزامن في Python. كذلك، على عكس حلقة الأحداث في Node، تمتلك آلة BEAM الافتراضية مُجدولات توزّع وقت المعالج المتاح على جميع العمليات، مما يضمن أن مهمة واحدة كثيفة الاستخدام للمعالج لا تستطيع منع العمليات الأخرى من التنفيذ.
بسبب هذه البنية، فإن الانتقال من تنفيذ العمليات بشكل متزامن إلى تنفيذها على التوازي ليس سوى مسألة إضافة المزيد من نوى المعالجة. في الواقع، منذ سنوات عديدة تُفعّل BEAM تلقائيًا قدرات المعالجة المتعددة المتماثلة (SMP) على الأنظمة متعددة الأنوية، مما يسمح لمُجدولات الآلة الافتراضية بتوزيع وقت المعالج من جميع الأنوية على العمليات قيد التشغيل. في Elixir، ليس التزامن مواطنًا من الدرجة الأولى فحسب، بل لا يوجد تمييز بين الكود المتزامن والكود المتوازي. كل ما عليك فعله هو كتابة كود متزامن، وستجعله الآلة الافتراضية تلقائيًا و_افتراضيًا_ متوازيًا إن كان هناك أكثر من 1 نواة معالجة متاحة.
كتابة كود Elixir متزامن باستخدام وحدة Task
كما ذُكر أعلاه، يتحقق التزامن في 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 (بافتراض أن لدينا نوى معالجة كافية). كل ما علينا فعله هو تقسيم مصفوفة الأحرف إلى العدد الصحيح من الأجزاء واستخدام 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 - عالج كل جزء باستخدام
count_letters/1في عملية عاملة عبر استخدامTask.async_stream/3 - ادمج النتائج من كل عملية عاملة في نتيجة واحدة
إليك مخططًا للخطوات أعلاه:

نحتاج فقط إلى تنفيذ 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
بعد تنفيذ هاتين الدالتين، كل ما يتبقى لجعل الدالة 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 نواة معالجة متاحة، فإن آلة BEAM الافتراضية تجعل تنفيذ العمليات المنشأة متوازيًا تلقائيًا.
افتراضيًا، تُشغّل BEAM مُجدولًا لكل نواة معالجة (منطقية) متاحة. يمكنك التحقق من عدد المُجدولات التي شغّلتها BEAM باستخدام :erlang.system_info/1:
iex> :erlang.system_info(:schedulers_online)
8
يمثّل هذا الحد الأقصى لعدد عمليات الآلة الافتراضية التي يمكن تنفيذها في الوقت نفسه. ضبط workers على عدد أكبر من عدد المُجدولات لا يزيد التوازي وقد يضرّ بالأداء.
الخاتمة
تبيّن أن تحويل الكود من تسلسلي إلى متزامن أسهل بكثير من المتوقع باستخدام وحدة Task في Elixir. يضيف الكود المتزامن بعض التعقيد الإضافي في تقسيم مصفوفة الوحدات الرسومية ودمج نتائج العمليات العاملة، لكن النتيجة النهائية نظيفة بشكل مفاجئ.
قبل حل هذه المسألة في Exercism كنت قد سمعت عن Task، لكنني لم أستخدمها قط. بعد تطبيقها في حلّي، أصبحت الآن أعتبرها جزءًا لا غنى عنه من صندوق أدواتي في Elixir.
يمكنك استخدام هذه الأداة الجديدة بطرق عديدة لمحاولة تحسين الأداء في تطبيقاتك. معظم تطبيقات الويب محدودة بالإدخال/الإخراج، وبالتالي ستستفيد من التزامن حتى لو كان هناك نواة معالجة واحدة فقط متاحة. من الطرق الموثوقة إلى حد كبير لتسريع تطبيق ويب هي إجراء طلبات 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 في القائمة بشكل متزامن، بدلًا من انتظار اكتمال كل طلب قبل بدء التالي، مما ينبغي أن يوفّر تسريعًا كبيرًا حسب مدة كل طلب.