مقاله تخصصی باورژن

هم‌روندی (Concurrency)، پردازش موازی (Parallelism) و برنامه‌نویسی ناهمگام (Async)

تفاوت Concurrency، Parallelism و Async را با مثال‌های کاربردی، کد Ruby و بررسی نقش Thread، CPU و I/O در عملکرد و مقیاس‌پذیری نرم‌افزار یاد بگیرید.

مقایسه تفاوت‌های Concurrency، Parallelism و Async در برنامه‌نویسی و نحوه مدیریت وظایف هم‌زمان در سیستم‌های نرم‌افزاری
این مقاله را با تمرکز بر مفاهیم و مثال‌های فنی بخوانید. زمان مطالعه تقریبی: 12 دقیقه

هر برنامه‌نویسی این سه اصطلاح را شنیده است. بیشتر افراد آن‌ها را در یک جمله به کار برده‌اند و بسیاری نیز از آن‌ها به‌جای یکدیگر استفاده می‌کنند. اما هم‌روندی (Concurrency)، پردازش موازی (Parallelism) و برنامه‌نویسی ناهمگام (Asynchronous Programming) سه مفهوم کاملا متمایز هستند. اشتباه گرفتن آن‌ها می‌تواند به باگ‌های واقعی، تصمیم‌های نادرست در معماری نرم‌افزار و حتی شکست در مصاحبه‌های فنی منجر شود.

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

چرا این مفاهیم با یکدیگر اشتباه گرفته می‌شوند؟

ریشه این سردرگمی در این است که هر سه مفهوم به یک پرسش ظاهرا مشترک پاسخ می‌دهند:

«چطور می‌توانم برنامه‌ام را طوری طراحی کنم که بیش از یک کار را مدیریت کند؟»

اما پاسخ‌هایی که به این سوال می‌دهند، اساسا با یکدیگر متفاوت هستند.

برای درک بهتر، یک آشپز را تصور کنید که به‌تنهایی در حال آماده کردن یک شام سه‌مرحله‌ای است.

او می‌تواند ابتدا پختن پاستا را شروع کند و در مدتی که آب به جوش می‌آید، سبزیجات را خرد کند. این کار نمونه‌ای از هم‌روندی (Concurrency) است.

حالا فرض کنید رستوران یک آشپز دیگر استخدام می‌کند تا هم‌زمان سالاد را آماده کند. این نمونه‌ای از پردازش موازی (Parallelism) است.

و در نهایت، آشپز تایمر فر را تنظیم می‌کند و به‌جای اینکه کنار فر بایستد و منتظر بماند، برای سرویس‌دهی به میز دیگری می‌رود. این دقیقا جوهره برنامه‌نویسی ناهمگام (Async) است.

آشپزخانه در هر سه حالت یکسان است، اما استراتژی‌ها کاملا متفاوت‌اند.

هم‌روندی (Concurrency): مدیریت چند کار به‌صورت نوبتی، نه انجام هم‌زمان آن‌ها

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

در یک هسته پردازنده (CPU Core)، پردازنده با سرعت بسیار زیادی بین وظایف مختلف جابه‌جا می‌شود، به‌طوری‌که اجرای آن‌ها هم‌زمان به نظر می‌رسد. اما در واقعیت، در هر چرخه کلاک تنها یک دستورالعمل اجرا می‌شود.

به این فرایند تقسیم زمان (Time-slicing) یا تعویض زمینه (Context Switching) گفته می‌شود.

سیستم‌عامل بازه‌های زمانی کوچکی را به هر وظیفه اختصاص می‌دهد، اجرای آن را متوقف می‌کند، وضعیت فعلی‌اش را ذخیره می‌کند و به سراغ وظیفه بعدی می‌رود.

از بیرون، به نظر می‌رسد چند کار به‌صورت موازی در حال اجرا هستند، اما در لایه داخلی، این فرایند چیزی جز جابه‌جایی بسیار سریع بین وظایف نیست.

Concurrency

دقت کنید که در هیچ لحظه‌ای هر دو وظیفه با هم اجرا نمی‌شوند، بلکه به‌صورت نوبتی پیش می‌روند.

زمان واقعی سپری‌شده (Wall-clock Time) کاهش پیدا نمی‌کند، اما سیستم پاسخ‌گوتر به نظر می‌رسد، زیرا هیچ‌کدام از وظایف مجبور نیستند منتظر بمانند تا وظیفه دیگر به‌طور کامل تمام شود.

هم‌روندی برای وظایفی که محدود به عملیات ورودی و خروجی (I/O-bound) هستند، گزینه‌ای ایده‌آل است، مانند:

  • خواندن فایل‌ها
  • اجرای Query روی پایگاه داده
  • انتظار برای دریافت پاسخ از شبکه

در تمام این موارد، هنگام انتظار برای تکمیل عملیات، پردازنده عملا بیکار است. هم‌روندی به شما اجازه می‌دهد از این زمان بیکاری به‌شکل مفیدتری استفاده کنید.

پردازش موازی (Parallelism): انجام واقعی چند کار به‌صورت هم‌زمان

پردازش موازی زمانی اتفاق می‌افتد که چند وظیفه دقیقا در یک لحظه اجرا شوند و هرکدام از یک هسته پردازنده جداگانه استفاده کنند.

در اینجا خبری از اجرای نوبتی نیست. دو هسته پردازنده به معنای اجرای دو دستورالعمل در هر چرخه کلاک است.

Parallelism

این همان افزایش سرعت واقعی است که بسیاری از افراد هنگام گفتن جمله «فقط برنامه را مولتی ترد (Multi-threaded) می‌کنم» در ذهن دارند.

اما یک شرط اساسی وجود دارد: باید بیش از یک هسته فیزیکی پردازنده داشته باشید و وظایف نیز نباید به یکدیگر وابسته باشند، در غیر این صورت، همچنان مجبور خواهند بود منتظر یکدیگر بمانند.

پردازش موازی برای وظایفی که محدود به توان پردازشی CPU هستند (CPU-bound)، بسیار مناسب است، از جمله:

  • پردازش تصویر
  • کدگذاری ویدئو (Video Encoding)
  • ضرب ماتریس‌ها
  • اجرای مدل‌های یادگیری ماشین (Machine Learning Inference)

این مسائل را می‌توان به بخش‌های مستقلی تقسیم کرد و هر بخش را به‌صورت هم‌زمان پردازش کرد. در نتیجه، می‌توان به افزایش سرعت خطی یا نزدیک به خطی دست یافت.

هزینه پردازش موازی

پردازش موازی مشکلاتی را در زمینه وضعیت مشترک (Shared State) ایجاد می‌کند.

وقتی دو هسته پردازنده به‌صورت هم‌زمان در یک موقعیت حافظه مشترک می‌نویسند، نتیجه می‌تواند غیرقابل پیش‌بینی باشد. به این وضعیت شرط رقابتی (Race Condition) گفته می‌شود.

مدیریت چنین شرایطی به ابزارهای همگام‌سازی (Synchronization Primitives) نیاز دارد، ابزارهایی مانند:

  • Mutex
  • Semaphore
  • Atomic Operations

این ابزارها پیچیدگی برنامه را افزایش می‌دهند و حتی ممکن است خودشان به گلوگاه عملکردی تبدیل شوند. به این پدیده رقابت بر سر قفل (Lock Contention) گفته می‌شود.

هزینه پردازش موازی

به همین دلیل، نوشتن کد موازیِ صحیح از نوشتن کد هم‌روند دشوارتر است. همچنین بسیاری از باگ‌های موجود در سیستم‌های مولتی ترد (Multi-threaded) ظریف هستند و به‌صورت قطعی و قابل تکرار رخ نمی‌دهند.

ورودی و خروجی ناهمگام (Async I/O): انتظار بدون مسدود کردن اجرا

برنامه‌نویسی ناهمگام یک مدل برنامه‌نویسی است، نه یک ویژگی سخت‌افزاری.

این مفهوم به پرسش متفاوتی پاسخ می‌دهد:

چطور می‌توان با استفاده از یک Thread، تعداد زیادی وظیفه را به‌شکل کارآمد مدیریت کرد، بدون اینکه Thread در زمان انتظار بیکار بماند؟

ایده اصلی در اینجا حلقه رویداد (Event Loop) است.

به‌جای اینکه یک Thread هنگام انتظار برای دریافت پاسخ (مثلا نتیجه یک Query از پایگاه داده) مسدود شود، سیستم Async یک Callback یا Continuation ثبت می‌کند، Thread را آزاد می‌کند و زمانی که پاسخ دریافت شد، اجرای وظیفه را از همان نقطه‌ای که متوقف شده بود ادامه می‌دهد.

ورودی و خروجی ناهمگام

هر دو Query تقریبا به‌صورت هم‌زمان آغاز شده‌اند، با اینکه فقط یک Thread در اختیار داریم.

زمان کل انتظار تقریبا برابر است با:

max(t_user, t_orders)

به‌جای:

t_user + t_orders

این همان بهبود اصلی در بهره‌وری است.

در بیشتر زبان‌های برنامه‌نویسی، کدهای Async از ساختارهای نحوی ویژه‌ای استفاده می‌کنند، مانند:

  • async/await در جاوااسکریپت، پایتون و Rust
  • Fibers در Ruby
  • Goroutines در Go

محیط اجرایی (Runtime) کدی را که ظاهرا به‌صورت خطی نوشته شده است، به یک ماشین حالت (State Machine) تبدیل می‌کند که می‌تواند در نقاط await متوقف شود و بعدا اجرای خود را از سر بگیرد.

در ادامه، نمونه‌ای از این روش را در Ruby با استفاده از Fiber می‌بینیم که رفتار ناهمگام را شبیه‌سازی می‌کند:

require 'fiber'

fetch_user = Fiber.new do
  puts "Fetching user..."
  sleep(1) # شبیه‌سازی انتظار برای پایگاه داده
  Fiber.yield "User: Alice"
end

fetch_orders = Fiber.new do
  puts "Fetching orders..."
  sleep(1) # شبیه‌سازی انتظار برای پایگاه داده
  Fiber.yield "Orders: [#1, #2, #3]"
end

# هر دو Fiber به‌صورت مشارکتی اجرا می‌شوند؛
# هیچ‌کدام دیگری را مسدود نمی‌کند.
user   = fetch_user.resume
orders = fetch_orders.resume

puts user
puts orders

در اپلیکیشن‌های عملیاتی Ruby on Rails، کتابخانه Async یا وب‌سرور Falcon امکان استفاده از Async I/O واقعی را با استفاده از همین مدل مبتنی بر Fiber فراهم می‌کنند.

این قابلیت به یک فرایند Rails اجازه می‌دهد تعداد زیادی درخواست هم‌زمان را مدیریت کند، بدون اینکه نیاز باشد هزاران Thread ایجاد کند.

این سه مفهوم چه ارتباطی با یکدیگر دارند؟

این سه مفهوم لزوما مستقل از یکدیگر نیستند و یکدیگر را نفی نمی‌کنند. در واقع، سیستم‌های واقعی معمولا ترکیبی از هر سه را به کار می‌گیرند.

این سه مفهوم چه ارتباطی با یکدیگر دارند؟

هم‌روندی به ساختار برنامه مربوط است، یعنی اینکه چطور برنامه را طراحی می‌کنید تا چند وظیفه را مدیریت کند.

در مقابل، پردازش موازی به نحوه اجرا مربوط می‌شود، یعنی اینکه آیا این وظایف واقعا در یک لحظه و به‌صورت فیزیکی اجرا می‌شوند یا خیر.

Async نیز یک تکنیک مشخص برای دستیابی به هم‌روندی است که حتی به چند Thread مجزا هم نیاز ندارد.

راب پایک (Rob Pike)، یکی از خالقان زبان Go، این تفاوت را به‌خوبی بیان کرده است:

هم‌روندی درباره مدیریت تعداد زیادی کار به‌صورت هم‌زمان است، اما پردازش موازی درباره انجام تعداد زیادی کار به‌صورت هم‌زمان است.
بُعد مقایسه هم‌روندی (Concurrency) پردازش موازی (Parallelism) ناهمگام (Async)
سخت‌افزار موردنیاز یک یا چند هسته پردازنده نیازمند چند هسته پردازنده یک هسته پردازنده کافی است
مناسب برای وظایف وابسته به I/O وظایف وابسته به CPU وظایف I/O با تعداد اتصال بالا
مدل Thread چند Thread با اجرای نوبتی (Time-sliced) چند Thread با اجرای واقعا هم‌زمان اغلب یک Thread
ریسک وضعیت مشترک (Shared State) متوسط زیاد کم (معمولا سینگل ترد)
پیچیدگی متوسط زیاد متوسط (با خطر Callback Hell)
افزایش توان عملیاتی (Throughput Gain) متوسط زیاد برای پردازش‌های CPU زیاد برای پردازش‌های I/O
نمونه در دنیای واقعی Rails با Puma (مولتی ترد) Workerهای تبدیل ویدئو (Video Transcoding) Node.js، Ruby Async، Python asyncio

چارچوب تصمیم‌گیری (Decision Framework)

وقتی با یک مشکل عملکردی (Performance) یا مقیاس‌پذیری (Scalability) مواجه می‌شوید، این چهار سوال را به‌ترتیب از خودتان بپرسید:

۱. گلوگاه سیستم، پردازنده است یا عملیات ورودی و خروجی؟

ابتدا پروفایلینگ (Profiling) انجام دهید.

بیشتر اپلیکیشن‌های وب به عملیات I/O وابسته هستند. پایگاه داده، کش و APIهای خارجی معمولا ۸۰ تا ۹۵ درصد زمان پاسخ‌گویی را به خود اختصاص می‌دهند.

اضافه کردن پردازش موازی به مسئله‌ای که محدود به I/O است، اغلب هیچ تغییری ایجاد نمی‌کند.

۲. چند وظیفه به‌صورت هم‌زمان در حال اجرا هستند؟

استفاده از چند ده Thread معمولا مشکلی ایجاد نمی‌کند، اما هزاران Thread هزینه زیادی دارند.

هر Thread در Ruby یا Java تقریبا ۱ تا ۸ مگابایت حافظه برای Stack مصرف می‌کند.

اگر انتظار دارید هزاران اتصال هم‌زمان را مدیریت کنید، استفاده از Async از نظر مصرف حافظه بسیار بهینه‌تر است.

۳. آیا وظایف از وضعیت مشترک (Shared State) استفاده می‌کنند؟

اگر پاسخ مثبت باشد، تمام گزینه‌ها پیچیده‌تر می‌شوند.

استفاده از Async با یک Event Loop، به‌صورت طبیعی از بسیاری از این مشکلات جلوگیری می‌کند.

در پردازش موازی، باید از قفل‌گذاری دقیق (Locking) یا ساختارهای داده تغییرناپذیر (Immutable Data Structures) استفاده کنید.

۴. محیط اجرایی شما از کدام روش بهتر پشتیبانی می‌کند؟

در Ruby، مکانیزمی به نام GVL (Global VM Lock) یا قفل سراسری ماشین مجازی وجود دارد که مانع اجرای موازی واقعی Threadهای Ruby می‌شود.

البته Ractorها که از Ruby 3.0 معرفی شدند، امکان اجرای موازی واقعی را با استفاده از وضعیت ایزوله فراهم می‌کنند.

Node.js به‌صورت پیش‌فرض بر اساس یک Thread و Async I/O طراحی شده است.

Go نیز از همان ابتدا با هدف پشتیبانی از Goroutineهای هم‌روند و ابزارهای سبک و مشابه Thread طراحی شده است.

نقاط قوت و محدودیت‌های Ruby

Ruby نمونه مناسبی برای بررسی این مفاهیم است، زیرا روند تکامل این زبان، در واقع بازتابی از درک عمیق‌تر صنعت نرم‌افزار نسبت به این مفاهیم است.

نسخه کلاسیک Ruby یعنی MRI از مکانیزمی به نام GVL (Global VM Lock) استفاده می‌کند که با نام GIL نیز شناخته می‌شود.

این مکانیزم باعث می‌شود در هر لحظه فقط یک Thread مربوط به Ruby اجرا شود، حتی اگر سیستم چندین هسته پردازنده داشته باشد.

این محدودیت در بیشتر موارد از بروز Race Condition جلوگیری می‌کند، اما در عین حال به این معناست که Threadهای Ruby امکان هم‌روندی را فراهم می‌کنند، نه پردازش موازی واقعی.

برای اپلیکیشن‌های Rails که بیشتر به عملیات I/O وابسته هستند، این موضوع مشکلی ایجاد نمی‌کند، زیرا هنگام اجرای عملیات ورودی و خروجی، GVL آزاد می‌شود. بنابراین Threadها می‌توانند در زمان انتظار برای پاسخ پایگاه داده، به‌صورت هم‌روند اجرا شوند.

در Ruby 3.x، قابلیتی به نام Ractor برای پشتیبانی از پردازش موازی واقعی با استفاده از مدل Actor و ایزوله‌سازی معرفی شد.

هر Ractor فضای Heap مستقل خودش را دارد و از طریق ارسال پیام (Message Passing) با سایر Ractorها ارتباط برقرار می‌کند.

این روش وضعیت مشترک (Shared State) را به‌طور کامل حذف می‌کند، البته به قیمت اعمال محدودیت‌های سخت‌گیرانه‌تر بر اشیایی که می‌توانند از مرز بین Ractorها عبور کنند.

نمونه‌ای از استفاده از Ractor در Ruby 3.x برای اجرای موازی واقعی:

ractor1 = Ractor.new { (1..10_000).reduce(:+) }
ractor2 = Ractor.new { (10_001..20_000).reduce(:+) }

result = ractor1.take + ractor2.take
puts result  # => 200_010_000

# هر دو Ractor روی Threadهای جداگانه سیستم‌عامل اجرا می‌شوند
# و پردازش را واقعاً به‌صورت موازی انجام می‌دهند.

در همین حال، کتابخانه Async امکان استفاده از هم‌روندی مشارکتی (Cooperative Concurrency) مبتنی بر Event Loop را در Ruby فراهم می‌کند.

این کتابخانه به شما اجازه می‌دهد کدهای ناهمگام بنویسید که ظاهری مشابه کدهای همگام (Synchronous) دارند، الگویی که برای توسعه‌دهندگان Ruby و به‌خصوص برنامه‌نویسان آشنا با Rails بسیار طبیعی و قابل درک است.

قانون آمدال (Amdahl’s Law)

قبل از اینکه برای موازی‌سازی تمام بخش‌های برنامه عجله کنید، باید یک واقعیت مهم را در نظر بگیرید: قانون آمدال.

اگر تنها بخشی به‌اندازه p از برنامه شما قابلیت موازی‌سازی داشته باشد، حداکثر افزایش سرعت تئوری S با استفاده از N پردازنده به شکل زیر محاسبه می‌شود:

معادله

اگر ۵۰ درصد کد شما ذاتا ترتیبی (Serial) باشد و امکان اجرای موازی نداشته باشد، حداکثر افزایش سرعتی که می‌توانید به آن دست پیدا کنید، حتی با تعداد نامحدودی هسته پردازنده، فقط ۲ برابر خواهد بود.

نه ۱۰۰ برابر و نه حتی ۱۰ برابر!

به همین دلیل است که پروفایلینگ قبل از بهینه‌سازی اهمیت زیادی دارد.

اگر برنامه‌ای ۹۰ درصد زمان اجرای خود را در یک گلوگاه ترتیبی سپری کند، صرف‌نظر از اینکه چند هسته پردازنده در اختیارش قرار دهید، از پردازش موازی سود قابل‌توجهی نخواهد برد.

باورهای اشتباه رایج

«چندنخی کردن (Multi-threading) همیشه باعث افزایش سرعت می‌شود.»

فقط زمانی که وظایف به پردازنده وابسته باشند، واقعا مستقل از یکدیگر باشند و هسته‌های پردازشی آزاد در اختیار داشته باشید.

برای کدهای وابسته به I/O که روی یک سرور Async با پیکربندی مناسب اجرا می‌شوند، چندنخی کردن ممکن است صرفا سربار (Overhead) ایجاد کند، بدون اینکه مزیتی داشته باشد.

«Async یعنی Parallelism.»

خیر.

یک سرور مبتنی بر Node.js یا Ruby Async از یک Thread استفاده می‌کند.

دو درخواست می‌توانند به‌صورت هم‌روند و درهم‌تنیده (Interleaved) مدیریت شوند، اما هرگز در یک لحظه به‌صورت موازی اجرا نمی‌شوند.

برای وظایف سنگین محاسباتی که به CPU وابسته هستند، Async هیچ مزیتی ایجاد نمی‌کند.

«Concurrency خطرناک است.»

این موضوع به نحوه پیاده‌سازی آن بستگی دارد.

استفاده از Async با یک Event Loop، روشی نسبتا ایمن است.

بخش عمده خطرات زمانی ایجاد می‌شوند که چند Thread به‌صورت هم‌زمان به وضعیت مشترک دسترسی داشته باشند.

مدل‌های Actor، مانند Ractor در Ruby یا فرایندهای Erlang، با حذف وضعیت مشترک، ساخت سیستم‌های هم‌روند را بسیار ایمن‌تر می‌کنند.

«وجود GVL باعث می‌شود Threadهای Ruby بی‌فایده باشند.»

برای وظایفی که به عملیات I/O وابسته هستند، یعنی بخش بزرگی از اپلیکیشن‌های Rails، Threadها بسیار مفید هستند.

هنگام انتظار برای عملیات ورودی و خروجی، GVL آزاد می‌شود. بنابراین Threadها واقعا می‌توانند در زمان اجرای Queryهای پایگاه داده و درخواست‌های HTTP به‌صورت هم‌روند پیش بروند.

این محدودیت فقط زمانی اهمیت پیدا می‌کند که با محاسبات سنگین وابسته به CPU سروکار داشته باشید.

جمع‌بندی

این سه مفهوم یک مدل ذهنی لایه‌ای برای درک نحوه اجرای وظایف در نرم‌افزار ایجاد می‌کنند.

Async یک تکنیک برنامه‌نویسی است که با استفاده از یک Thread، بیشترین بهره‌وری را از زمان انتظار برای عملیات I/O به دست می‌آورد.

Concurrency یک رویکرد گسترده‌تر در طراحی نرم‌افزار است، به این معنا که چند وظیفه بتوانند به‌صورت هم‌روند پیشرفت کنند، چه از طریق Async و چه با استفاده از Threadهایی که زمان پردازنده را به‌صورت نوبتی دریافت می‌کنند.

Parallelism نیز قابلیتی در سطح سخت‌افزار است که به شما اجازه می‌دهد مسائل وابسته به CPU را به بخش‌های مستقل تقسیم کنید و آن‌ها را واقعا به‌صورت هم‌زمان حل کنید.

بیشتر سیستم‌های واقعی از هر سه مفهوم استفاده می‌کنند.

برای مثال، یک وب‌سرور می‌تواند با استفاده از Async I/O، تعداد ۱۰ هزار اتصال هم‌زمان را مدیریت کند.

همچنین برای عملیات مسدودکننده‌ای که امکان پیاده‌سازی ناهمگام ندارند، یک Thread Pool ایجاد کند.

در نهایت، وظایف سنگین محاسباتی مانند تغییر اندازه تصاویر یا تولید فایل‌های PDF را به یک Background Worker Pool بسپارد تا پردازش میان تمام هسته‌های پردازنده موجود توزیع شود.

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

دیدگاه‌ها


ثبت دیدگاه