Uploaded avatar of PercyGrunwald

هم‌روندی و موازی‌سازی در Elixir

@PercyGrunwald
بیش از 7 سال پیش

این دومین بخش از مجموعهٔ 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 در مقابل زبان‌های دیگر به دست آورد.

همروندی و موازی‌سازی

همروندی و موازی‌سازی اصطلاحاتی مرتبط‌اند، اما دقیقاً معنای یکسانی ندارند. برنامهٔ همروند برنامه‌ای است که در آن چندین کار می‌توانند «در حال انجام» باشند، اما در هر لحظهٔ واحد تنها یک کار روی پردازنده اجرا می‌شود (مثلاً اجرای یک کار در حالی که کار دیگری منتظر ورودی/خروجی است، مانند خواندن یا نوشتن روی دیسک یا شبکه). از سوی دیگر، برنامهٔ موازی می‌تواند چندین کار را به‌طور همزمان روی چندین هستهٔ پردازنده اجرا کند.

هم اجرای همروند و هم اجرای موازی می‌توانند افزایش سرعت چشمگیری به همراه داشته باشند، اما میزان افزایش سرعت ممکن، اگر اصلاً ممکن باشد، به عوامل بسیاری بستگی دارد. مواردی وجود دارد که در آن‌ها همروندی یا موازی‌سازی حتی ممکن نیست؛ ممکن است کاری که می‌کوشید به انجام برسانید نه به اجرای همروند تن بدهد و نه به اجرای موازی، یا اینکه محیط اجرا از آن‌ها پشتیبانی نکند. اگر مورد شما اجرای همروند یا موازی را ممکن کند، میزان افزایش سرعت ممکن عمدتاً به این بستگی دارد که کار وابسته به ورودی/خروجی باشد یا وابسته به پردازنده، و اینکه بیش از ۱ هستهٔ پردازنده در دسترس باشد.

با وجود شمار عوامل مؤثر، چند «قاعدهٔ سرانگشتی» برای تعیین اینکه همروندی یا موازی‌سازی ممکن است و چقدر افزایش سرعت می‌توان انتظار داشت وجود دارد. نخست، اجرای همروند روی یک هستهٔ پردازندهٔ واحد ممکن است، اما اجرای موازی نه. دوم، موازی‌سازی و همروندی باید افزایش سرعت چشمگیری برای کارهای وابسته به ورودی/خروجی به همراه داشته باشند و این افزایش سرعت باید برای هر دو تقریباً یکسان باشد. سرانجام، کارهای وابسته به پردازنده هنگام اجرای همروند باید کارایی یکسان (یا کندتری) داشته باشند و به‌طور کلی افزایش سرعت تنها هنگام اجرای موازی روی چندین هستهٔ پردازنده ممکن است.

محاسبهٔ فراوانی حروف در این تمرین نمونه‌ای از یک کار وابسته به پردازنده است، پس بر اساس قواعد سرانگشتی بالا، افزایش سرعت تنها با انجام موازی محاسبه روی چندین هستهٔ پردازنده ممکن خواهد بود.

همروندی و موازی‌سازی در Elixir در مقابل زبان‌های دیگر

بسیاری از زبان‌های محبوب ابزارهایی برای نوشتن کد همروند در اختیارتان می‌گذارند، اما دستیابی به موازی‌سازی معمولاً بسیار پیچیده‌تر و سرشار از بده‌بستان است.

برای مثال، همروندی در JavaScript که روی محیط اجرای Node.js اجرا می‌شود یک ویژگی درجه‌یک است و نسخه‌های پیش‌فرض توابع مرتبط با ورودی/خروجی تقریباً همیشه «ناهمگام» (یعنی همروند) هستند. برای مثال، fs.ReadFile تابعی همروند و روش استاندارد خواندن محتوای یک فایل است. این عالی است، اما نقطه‌ضعف‌هایی هم به شکل جهنم callback یا نیاز به Promises دارد. همچنین، چون Node زمان پردازنده را به‌طور یکسان میان کارها توزیع نمی‌کند، همچنان ممکن است یک کار سنگین از نظر پردازنده اجرا را مسدود کند. موازی‌سازی اجرا در Node ممکن است، اما قطعاً آسان نیست. با توجه به اینکه Node تک‌رشته‌ای است، تنها راه دستیابی به موازی‌سازی این است که فرایندهای کارگر را به‌صورت دستی با ماژول cluster فورک کنید یا چندین نمونه از برنامه‌تان را اجرا کنید و ارتباط میان آن‌ها را به‌صورت دستی پیاده‌سازی کنید.

Python، برخلاف Node، به‌طور پیش‌فرض همروند نیست، اما ابزارهای متعددی برای نوشتن کد همروند و موازی در اختیارتان می‌گذارد. با این حال، هر گزینه بده‌بستان‌های خودش را دارد و انتخاب یکی از آن‌ها لزوماً سرراست نیست. می‌توانید از ماژول threading استفاده کنید و با این واقعیت کنار بیایید که global interpreter lock (GIL) اجرا را در هر لحظه به یک رشتهٔ واحد محدود می‌کند و موازی‌سازی را ناممکن می‌سازد. گزینهٔ دیگر ماژول multiprocessing در Python است که با ایجاد فرایندهای سیستم‌عامل (به‌جای رشته‌ها) از محدودیت GIL دور می‌زند. استفاده از multiprocessing موازی‌سازی را ممکن می‌کند، اما این بده‌بستان را دارد که ایجاد فرایندهای سیستم‌عامل کندتر است و حافظهٔ بیشتری از رشته‌ها مصرف می‌کند.

نوشتن کد همروند و موازی در Elixir بسیار ساده‌تر است، چون Elixir در سطح محیط اجرا همروند است. شهرت Elixir در مقیاس‌پذیری عظیم از این واقعیت می‌آید که روی ماشین مجازی BEAM اجرا می‌شود، ماشینی که همهٔ کد را درون «فرایندهای» بسیار سبکی اجرا می‌کند که همگی به‌صورت همروند درون VM اجرا می‌شوند. هزینهٔ ایجاد فرایندهای BEAM ناچیز است و در مقایسه با رشته‌ها و فرایندهای سطح سیستم‌عامل که ماژول‌های همروندی Python ایجاد می‌کنند، حافظهٔ بسیار اندکی مصرف می‌کنند. همچنین، برخلاف حلقهٔ رویداد Node، ماشین مجازی BEAM زمان‌بندهایی دارد که زمان در دسترس پردازنده را به همهٔ فرایندها تخصیص می‌دهند و این تضمین می‌کند که یک کار سنگین از نظر پردازنده نتواند از اجرای فرایندهای دیگر جلوگیری کند.

به خاطر این معماری، گذر از اجرای همروند فرایندها به اجرای موازی تنها به افزودن هسته‌های بیشتر پردازنده بستگی دارد. در واقع، چندین سال است که BEAM به‌طور خودکار قابلیت‌های پردازش چندگانهٔ متقارن (SMP) را روی سیستم‌های چند هسته‌ای فعال می‌کند و این به زمان‌بندهای VM اجازه می‌دهد زمان پردازنده را از همهٔ هسته‌ها به فرایندهای در حال اجرا اختصاص دهند. در Elixir، نه تنها همروندی یک ویژگی درجه‌یک است، بلکه هیچ تمایزی میان کد همروند و موازی وجود ندارد. تنها کاری که باید بکنید این است که کد همروند بنویسید و VM آن را به‌طور خودکار و به‌طور پیش‌فرض موازی می‌کند، به شرط اینکه بیش از ۱ هستهٔ پردازنده در دسترس باشد.

نوشتن کد همروند Elixir با ماژول Task

همان‌طور که بالاتر اشاره شد، در Elixir همروندی با توزیع عملیات میان چندین فرایند BEAM به دست می‌آید. می‌توانید فرایندها را به‌سادگی با توابعی مانند Kernel.spawn_link/1 ایجاد کنید، اما بسیار بهتر است از انتزاع‌های به‌شدت قدرتمندی که ماژول Task فراهم می‌کند استفاده کنید:

رایج‌ترین کاربرد [Task] تبدیل کد ترتیبی به کد همروند با محاسبهٔ یک مقدار به‌صورت ناهمگام است.

ماژول Task به شما اجازه می‌دهد در Elixir کد همروندی به‌طور باورنکردنی تمیز بنویسید؛ نه جهنم callback و نه نیازی به 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 پردازش کنید ۳. نتایج هر کارگر را در یک نتیجهٔ واحد ادغام کنید

این هم نموداری از مراحل بالا است:

همروند کردن محاسبهٔ فراوانی حروف

برای فعال کردن منطق همروند، تنها باید ۲ تابع کمکی جدید پیاده‌سازی کنیم: یکی برای تقسیم گرافیم‌ها به تکه‌ها (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 را روی عددی بزرگ‌تر از ۱ تنظیم کنیم و بیش از ۱ هستهٔ پردازنده در دسترس داشته باشیم، ماشین مجازی BEAM به‌طور خودکار اجرای فرایندهای ایجادشده را موازی می‌کند.

به‌طور پیش‌فرض، BEAM برای هر هستهٔ پردازندهٔ منطقی در دسترس، یک زمان‌بند راه‌اندازی می‌کند. می‌توانید تعداد زمان‌بندهایی که BEAM راه‌اندازی کرده را با :erlang.system_info/1 بررسی کنید:

iex> :erlang.system_info(:schedulers_online)
8

این نشان‌دهندهٔ بیشترین تعداد فرایندهای VM است که می‌توانند همزمان اجرا شوند. تنظیم 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های موجود در فهرست به کار می‌برد، در مقابل انتظار برای کامل شدن هر درخواست پیش از آغاز درخواست بعدی، که بسته به مدت هر درخواست باید افزایش سرعت چشمگیری ایجاد کند.

3 آوریل 2019 · برایتان مفید بود؟