Anophel-آنوفل مقایسه Shared Memory و Message Passing در Go: کدام روش بهتر است؟

مقایسه Shared Memory و Message Passing در Go: کدام روش بهتر است؟

تاریخ انتشار:
Go
زمان مطالعه: 9 دقیقه

در دنیای برنامه‌نویسی هم‌زمان (Concurrent Programming)، یکی از چالش‌های اصلی، برقراری ارتباط بین thread ها یا گوروتین‌ها (Goroutines) است. زبان برنامه‌نویسی Go (Golang) به دلیل طراحی ساده، کارآمد و ایمن، به یکی از محبوب‌ترین زبان‌ها برای توسعه نرم‌افزارهای چند thread ی تبدیل شده است. در Go، دو روش اصلی برای ارتباط بین گوروتین‌ها وجود دارد: حافظه اشتراکی (Shared Memory) و انتقال پیام (Message Passing). این مقاله به بررسی این دو روش، مزایا و معایب آن‌ها، و توصیه‌های Go برای استفاده از کانال‌ها (Channels) به جای حافظه اشتراکی می‌پردازد. با استفاده از یک مطالعه موردی ساده و مثال‌های عملی، تفاوت‌های این دو روش را به‌صورت عمیق بررسی می‌کنیم و به مشکلات رایجی مانند Deadlock و نحوه رفع آن‌ها اشاره خواهیم کرد.

چرا ارتباط بین گوروتین‌ها در Go مهم است؟

برنامه‌نویسی هم‌زمان یکی از ویژگی‌های برجسته Go است که آن را از بسیاری از زبان‌های دیگر متمایز می‌کند. Go از گوروتین‌ها به‌عنوان واحدهای اجرایی سبک‌وزن استفاده می‌کند که برخلاف Thread های سنتی در سیستم‌عامل، توسط زGo Runtime گو مدیریت می‌شوند. این ویژگی باعث می‌شود که گوروتین‌ها بسیار کم‌هزینه باشند و بتوان صدها یا حتی هزاران گوروتین را در یک برنامه اجرا کرد بدون اینکه منابع سیستم به‌طور قابل‌توجهی مصرف شوند.

بااین‌حال، یکی از چالش‌های اصلی در برنامه‌نویسی هم‌زمان، ارتباط ایمن و کارآمد بین گوروتین‌ها است. بدون هماهنگی مناسب، مشکلاتی مانند وضعیت رقابتی (Race Condition)، بن بست (Deadlock)، یا نتایج نادرست ممکن است رخ دهد. Go دو روش اصلی برای این منظور ارائه می‌دهد:

حافظه اشتراکی (Shared Memory): استفاده از ابزارهای مشترک بین گوروتین‌ها با ابزارهای همگام‌سازی مانند قفل‌ها (Mutex) و عملیات اتمیک (Atomic Operations).  

انتقال پیام  (Message Passing) : استفاده از کانال ها (Channels) برای ارسال و دریافت داده ها بین گوروتین ها.

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

«با اشتراک حافظه ارتباط برقرار نکنید. با ارتباط، حافظه را به اشتراک بگذارید.»

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

مطالعه موردی: جمع یک لیست بزرگ از اعداد با گوروتین‌ها

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

جزئیات مسئله
ورودی: یک لیست با 10 میلیون عدد تصادفی (تولیدشده با استفاده از پکیج math/rand).
هدف: محاسبه مجموع این اعداد به‌صورت هم‌زمان با استفاده از چندین گوروتین.  

روش‌ها:

استفاده از حافظه اشتراکی با متغیر مشترک و عملیات اتمیک.
استفاده از کانال‌ها برای انتقال نتایج جمع‌های جزئی.  

معیارها: عملکرد، خوانایی کد، ایمنی در برابر خطاهای هم‌زمانی، و سهولت نگهداری.

روش اول: حافظه اشتراکی (Shared Memory) در Go

در روش حافظه اشتراکی، گوروتین‌ها به یک متغیر مشترک دسترسی دارند و برای جلوگیری از وضعیت رقابتی، از ابزارهای همگام‌سازی مانند قفل‌ها (Mutex)، متغیرهای شرطی (Cond)، یا عملیات اتمیک (Atomic Operations) استفاده می‌کنند. در Go، پکیج sync ابزارهای لازم برای این منظور را فراهم می‌کند. Go ابزارهای متنوعی در بسته sync ارائه می‌دهد، از جمله:

  • Mutex: برای قفل‌گذاری و جلوگیری از دسترسی همزمان.
    WaitGroup: برای هماهنگی و انتظار برای تکمیل گوروتین‌ها.
    Atomic Operations: برای انجام عملیات ایمن روی متغیرهای مشترک بدون نیاز به قفل.

پیاده‌سازی با حافظه اشتراکی

در کد زیر، از یک متغیر مشترک (v) برای ذخیره مجموع استفاده می‌کنیم و برای جلوگیری از وضعیت رقابتی، از عملیات اتمیک (atomic.AddInt64) استفاده می‌کنیم. همچنین از sync.WaitGroup برای هماهنگی بین گوروتین‌ها استفاده می‌شود تا منتظر تکمیل همه آن‌ها بمانیم.

1package main 2 3import ( 4 "fmt" 5 "math/rand" 6 "runtime" 7 "sync" 8 "sync/atomic" 9) 10 11func main() { 12 numbers := generateList(1e7) 13 fmt.Println("جمع غیرهمزمان: ", add(numbers)) 14 fmt.Println("جمع همزمان: ", addConcurrent(runtime.NumCPU(), numbers)) 15} 16 17func generateList(totalNumbers int) []int { 18 numbers := make([]int, totalNumbers) 19 for i := 0; i < totalNumbers; i++ { 20 numbers[i] = rand.Intn(totalNumbers) 21 } 22 return numbers 23} 24 25func add(numbers []int) int { 26 var v int 27 for _, n := range numbers { 28 v += n 29 } 30 return v 31} 32 33func addConcurrent(goroutines int, numbers []int) int { 34 var v int64 35 totalNumbers := len(numbers) 36 lastGoroutine := goroutines - 1 37 stride := totalNumbers / goroutines 38 39 var wg sync.WaitGroup 40 wg.Add(goroutines) 41 42 for g := 0; g < goroutines; g++ { 43 go func(g int) { 44 start := g * stride 45 end := start + stride 46 if g == lastGoroutine { 47 end = totalNumbers 48 } 49 50 var lv int 51 for _, n := range numbers[start:end] { 52 lv += n 53 } 54 atomic.AddInt64(&v, int64(lv)) 55 wg.Done() 56 }(g) 57 } 58 59 wg.Wait() 60 return int(v) 61}

در بالا :

تولید لیست اعداد: تابع generateList یک لیست با 10 میلیون عدد تصادفی تولید می‌کند.
تقسیم کار: لیست به بخش‌های مساوی تقسیم شده و هر گوروتین مسئول جمع کردن یک بخش است. تعداد گوروتین‌ها برابر با تعداد هسته‌های CPU (runtime.NumCPU) تنظیم می‌شود.
متغیر مشترک: متغیر v از نوع int64 بین گوروتین‌ها مشترک است و با استفاده از atomic.AddInt64 به‌روزرسانی می‌شود تا از وضعیت رقابتی جلوگیری شود.
همگام‌سازی: از sync.WaitGroup استفاده می‌شود تا گوروتین اصلی منتظر تکمیل همه گوروتین‌های دیگر بماند.

نکات :  

  • استفاده از Atomic به‌جای Mutex: در این مثال، از عملیات اتمیک استفاده کردیم، زیرا برای به‌روزرسانی یک متغیر ساده (مانند v) سریع‌تر و سبک‌تر از قفل‌ها هستند. بااین‌حال، برای عملیات پیچیده‌تر، ممکن است نیاز به sync.Mutex داشته باشید.
    تقسیم بهینه کار: برای جلوگیری از بار اضافی روی یک گوروتین، مطمئن شدیم که آخرین گوروتین فقط اعداد باقی‌مانده را پردازش کند.
    مدیریت تعداد گوروتین‌ها: استفاده از runtime.NumCPU برای تنظیم تعداد گوروتین‌ها، تعادل خوبی بین عملکرد و مصرف منابع ایجاد می‌کند. ایجاد گوروتین‌های بیش از حد ممکن است به کاهش عملکرد منجر شود.

مزایا و معایب حافظه اشتراکی  

مزایا:

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

معایب:

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

پایگاه داده NoSql چیست؟ بررسی کامل

امنیت در داکر آشنایی با 14 تا از بهترین روش ها

داکر چیست؟

روش دوم: انتقال پیام (Message Passing) با کانال‌ها در Go

Go به‌جای حافظه اشتراکی، استفاده از کانال‌ها را به‌عنوان روش اصلی ارتباط بین گوروتین‌ها توصیه می‌کند. کانال‌ها یک مکانیزم داخلی برای ارسال و دریافت داده‌ها به‌صورت ایمن و همگام‌سازی‌شده ارائه می‌دهند. این روش بر اساس مدل فرآیندهای ترتیبی ارتباطی (CSP) طراحی شده و به برنامه‌نویسان امکان می‌دهد بدون نگرانی درباره قفل‌ها، کدهای هم‌زمان ایمن بنویسند.

پیاده‌سازی با کانال‌ها

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

1package main 2 3import ( 4 "fmt" 5 "math/rand" 6 "runtime" 7) 8 9func main() { 10 var result int64 11 numbers := generateList(1e7) 12 13 goroutines := runtime.NumCPU() 14 lastGoroutine := goroutines - 1 15 totalNumbers := len(numbers) 16 stride := totalNumbers / goroutines 17 18 c := make(chan int) 19 20 for g := 0; g < goroutines; g++ { 21 start := g * stride 22 end := start + stride 23 if g == lastGoroutine { 24 end = totalNumbers 25 } 26 go add(numbers[start:end], c) 27 } 28 29 for j := 0; j < goroutines; j++ { 30 result += int64(<-c) 31 } 32 33 fmt.Println("جمع همزمان: ", result) 34} 35 36func generateList(totalNumbers int) []int { 37 numbers := make([]int, totalNumbers) 38 for i := 0; i < totalNumbers; i++ { 39 numbers[i] = rand.Intn(totalNumbers) 40 } 41 return numbers 42} 43 44func add(numbers []int, c chan int) { 45 var v int 46 for _, n := range numbers { 47 v += n 48 } 49 c <- v 50}

در بالا:

  • ایجاد کانال: یک کانال از نوع int با دستور make(chan int) ایجاد می‌شود. این کانال برای انتقال مقادیر جمع‌های جزئی استفاده می‌شود.
    ارسال داده: هر گوروتین مجموع بخش خود را محاسبه کرده و با استفاده از عملگر -> به کانال ارسال می‌کند (c <- v).
    دریافت داده: گوروتین اصلی با یک حلقه دستی، مقادیر ارسالی از کانال را دریافت کرده و جمع می‌کند.
    تقسیم کار: مشابه روش حافظه اشتراکی، لیست به بخش‌های مساوی تقسیم می‌شود تا هر گوروتین بخشی از کار را انجام دهد.

مشکل Deadlock و راه‌حل آن

اگر در کد بالا به‌جای حلقه دستی از range برای خواندن از کانال استفاده کنیم (مانند کد زیر)، با خطای Deadlock مواجه می‌شویم:

1for l := range c { 2 result += int64(l) 3}

دلیل Deadlock:

عملیات ارسال و دریافت در کانال‌ها به‌صورت مسدودکننده (Blocking) هستند. یعنی تا زمانی که داده‌ای از کانال خوانده نشود، گوروتین ارسال‌کننده منتظر می‌ماند.
حلقه range تا زمانی که کانال بسته نشود، به خواندن ادامه می‌دهد. در این کد، هیچ گوروتینی کانال را نبسته است، بنابراین گوروتین اصلی منتظر داده‌های بیشتر می‌ماند و قفل‌مردگی رخ می‌دهد.  

رفع Deadlock

حلقه دستی: همان‌طور که در کد اصلی استفاده کردیم، با یک حلقه که دقیقاً به تعداد گوروتین‌ها داده می‌خواند، مشکل حل می‌شود.
بستن کانال: می‌توانیم کانال را در گوروتین‌های فرعی پس از ارسال داده ببندیم، اما این کار نیاز به هماهنگی دقیق دارد تا از بسته شدن زودهنگام کانال جلوگیری شود.
استفاده از WaitGroup: برای اطمینان از اینکه همه گوروتین‌ها داده‌های خود را ارسال کرده‌اند، می‌توان از sync.WaitGroup در کنار کانال استفاده کرد.

کد بهبودیافته با مدیریت Deadlock
در کد زیر، از sync.WaitGroup و بستن کانال برای جلوگیری از Deadlock استفاده شده است:

1package main 2 3import ( 4 "fmt" 5 "math/rand" 6 "runtime" 7 "sync" 8) 9 10func main() { 11 var result int64 12 numbers := generateList(1e7) 13 14 goroutines := runtime.NumCPU() 15 lastGoroutine := goroutines - 1 16 totalNumbers := len(numbers) 17 stride := totalNumbers / goroutines 18 19 c := make(chan int) 20 var wg sync.WaitGroup 21 wg.Add(goroutines) 22 23 for g := 0; g < goroutines; g++ { 24 start := g * stride 25 end := start + stride 26 if g == lastGoroutine { 27 end = totalNumbers 28 } 29 go func(nums []int) { 30 add(nums, c) 31 wg.Done() 32 }(numbers[start:end]) 33 } 34 35 go func() { 36 wg.Wait() 37 close(c) 38 }() 39 40 for l := range c { 41 result += int64(l) 42 } 43 44 fmt.Println("جمع همزمان: ", result) 45} 46 47func generateList(totalNumbers int) []int { 48 numbers := make([]int, totalNumbers) 49 for i := 0; i < totalNumbers; i++ { 50 numbers[i] = rand.Intn(totalNumbers) 51 } 52 return numbers 53} 54 55func add(numbers []int, c chan int) { 56 var v int 57 for _, n := range numbers { 58 v += n 59 } 60 c <- v 61}

مقایسه حافظه اشتراکی و انتقال پیام

برای درک بهتر اینکه چرا Go انتقال پیام را ترجیح می‌دهد، بیایید این دو روش را از چند منظر مقایسه کنیم:

1. سطح انتزاع (Abstraction Level)

  • حافظه اشتراکی: این روش سطح انتزاع پایینی دارد. برنامه‌نویس باید مستقیماً با ابزارهای همگام‌سازی مانند `sync.Mutex، `sync.WaitGroup، یا sync/atomic کار کند. این کار نیاز به درک عمیق از هم‌زمانی و مدیریت دقیق منابع دارد.
    انتقال پیام: کانال‌ها یک لایه انتزاع بالاتر ارائه می‌دهند. Go پیچیدگی‌های همگام‌سازی را در داخل کانال‌ها مدیریت می‌کند، که کد را ساده‌تر و خواناتر می‌سازد.

چرا انتزاع بالاتر بهتر است؟

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

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

2. کاهش وابستگی (Decoupling)

  • حافظه اشتراکی: گوروتین‌ها به یک متغیر مشترک وابسته هستند، که باعث افزایش پیوستگی (Coupling) بین آن‌ها می‌شود. این وابستگی می‌تواند مقیاس‌پذیری و نگهداری کد را دشوار کند.
    انتقال پیام: کانال‌ها گوروتین‌ها را از یکدیگر جدا می‌کنند. هر گوروتین به‌عنوان یک تولیدکننده (Producer) یا مصرف‌کننده (Consumer) پیام عمل می‌کند، که مشابه مدل‌های صف پیام (Message Queue) در سیستم‌های توزیع‌شده است.

مثال عملی:

  • در مدل حافظه اشتراکی، اگر بخواهید یک گوروتین جدید اضافه کنید، باید قفل‌ها و دسترسی به متغیر مشترک را بازنگری کنید.
    در مدل کانال‌ها، کافی است یک گوروتین جدید را به کانال متصل کنید، بدون نیاز به تغییر ساختار موجود.

از کانال‌های بافرشده (Buffered Channels) برای کاهش مسدودسازی و بهبود عملکرد در سناریوهای با تولید و مصرف ناهمگام استفاده کنید. مثال:

1c := make(chan int, 100) // کانال با ظرفیت 100

3. جلوگیری از رقابت Thread ها (Thread Contention)

  • حافظه اشتراکی: دسترسی همزمان چند گوروتین به یک متغیر مشترک می‌تواند منجر به رقابت و خطاهای پیچیده شود. حتی با قفل‌ها، احتمال اشتباه در پیاده‌سازی وجود دارد.
    انتقال پیام: کانال‌ها به‌گونه‌ای طراحی شده‌اند که فقط یک گوروتین در هر لحظه به داده دسترسی دارد. این طراحی ذاتاً از رقابت Thread ها جلوگیری می‌کند.

هشدار: اگر یک اشاره‌گر (Pointer) به‌جای مقدار به کانال ارسال شود، مشکلات مشابه حافظه اشتراکی ممکن است رخ دهد. برای مثال:

1c := make(chan *int) 2v := 42 3c <- &v // ارسال اشاره‌گر

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

4. عملکرد (Performance)

  • حافظه اشتراکی: در سناریوهای ساده (مانند مثال ما)، عملیات اتمیک می‌توانند سریع‌تر از کانال‌ها باشند، زیرا کانال‌ها سربار مدیریت همگام‌سازی داخلی دارند.
    انتقال پیام: کانال‌ها به دلیل لایه انتزاع بالاتر، ممکن است در برخی موارد کمی کندتر باشند، اما این تفاوت معمولاً در برنامه‌های واقعی ناچیز است.

تکنیک بهینه‌سازی:

  • از کانال‌های بافرشده برای کاهش مسدودسازی استفاده کنید.
    تعداد گوروتین‌ها را با توجه به تعداد هسته‌های CPU و نوع کار تنظیم کنید تا از Overhead اضافی جلوگیری شود.
    برای عملیات بسیار ساده و پرتکرار، عملیات اتمیک را در نظر بگیرید، اما فقط در صورتی که ایمنی تضمین شود.

چرا Go انتقال پیام را ترجیح می‌دهد؟

طراحی مدل هم‌زمانی Go به شدت تحت تأثیر مدل فرآیندهای ترتیبی ارتباطی (CSP) است. در ادامه، دلایلی که Go استفاده از کانال‌ها را به حافظه اشتراکی ترجیح می‌دهد، بررسی می‌کنیم:

سادگی و خوانایی کد:
کانال‌ها کد را ساده‌تر و قابل‌فهم‌تر می‌کنند. برنامه‌نویس نیازی به مدیریت قفل‌ها یا شرایط رقابتی ندارد.
کدهای مبتنی بر کانال معمولاً کوتاه‌تر و واضح‌تر هستند.  

کاهش خطاهای هم‌زمانی:
کانال‌ها به‌صورت ذاتی ایمن هستند و احتمال بروز خطاهایی مانند وضعیت رقابتی یا قفل‌مردگی را کاهش می‌دهند.
ابزارهای داخلی Go (مانند go vet و race detector) به شناسایی مشکلات کانال‌ها کمک می‌کنند.  

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

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

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

نتیجه‌

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

برای برنامه‌نویسان Go، توصیه می‌کنیم:

با کانال‌ها شروع کنید و از آن‌ها به‌عنوان روش اصلی ارتباط استفاده کنید.
ابزارهای همگام‌سازی مانند sync.Mutex و sync.WaitGroup را برای سناریوهای خاص یاد بگیرید.
از ابزارهایی مانند go vet و race detector برای شناسایی خطاهای هم‌زمانی استفاده کنید.
پروژه‌های عملی با کانال‌ها و گوروتین‌ها پیاده‌سازی کنید تا درک عمیق‌تری از هم‌زمانی در Go به دست آورید.

حافظه اشتراکی در سناریوهای خاص که نیاز به کنترل دقیق یا عملکرد بالا دارند، می‌تواند مفید باشد، اما پیچیدگی و احتمال خطای آن بیشتر است. در مقابل، کانال‌ها به دلیل سطح انتزاع بالاتر، کاهش وابستگی، و جلوگیری از رقابت Thread ها، انتخاب بهتری برای اکثر برنامه‌های Go هستند. این فلسفه نه‌تنها در Go، بلکه در زبان‌های دیگری مانند Erlang و مدل‌های هم‌زمانی مانند Actor Model نیز دیده می‌شود.

#گولنگ #گو #go #golang #software