چگونه Go با همزمانی بهتر از مدل‌های threading سنتی مدیریت می‌کند

چگونه Go با همزمانی بهتر از مدل‌های threading سنتی مدیریت می‌کند

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

برای دهه‌ها، زبان‌های برنامه‌نویسی سنتی مانند C++، جاوا و پایتون به مدل‌های نخی بومی سیستم عامل برای رسیدگی به وظایف همزمان متکی بودند. با این حال، هنگامی که گوگل در اواخر دهه 2000 Go (Golang) را طراحی کرد، مسیر کاملاً متفاوتی را در پیش گرفت. به جای افشای موضوعات خام سیستم عامل، Go Goroutines و M:N Scheduler را معرفی کرد.

در این مقاله، محدودیت‌های معماری مدل‌های threading سنتی را بررسی می‌کنیم و بررسی می‌کنیم که چرا طراحی همزمان Go بسیار کارآمدتر، مقیاس‌پذیرتر و توسعه‌دهنده‌تر است.


1. تنگناهای نخ سنتی (مدل 1:1)

اکثر سیستم‌های زمان اجرا سنتی از مدل نخ 1:1 استفاده می‌کنند. در این مدل، هر رشته ای که در کد فضای کاربر ایجاد می شود، مستقیماً به یک رشته هسته-فضای مدیریت شده توسط سیستم عامل (OS) نگاشت می شود.

در حالی که ساده است، این نقشه برداری 1:1 سه گلوگاه مهم را معرفی می کند:

A. سربار حافظه (اندازه های پشته بزرگ)

موضوع سیستم عامل یک منبع سنگین است. به‌طور پیش‌فرض، سیستم‌های عامل یک اندازه پشته ثابت و پیوسته را به هر رشته اختصاص می‌دهند (معمولاً 1 مگابایت تا 8 مگابایت).

  • اگر می‌خواهید 10000 اتصال همزمان انجام دهید، اختصاص دادن 10000 رشته سیستم عامل بین 10 تا 80 گیگابایت رم فقط برای حافظه پشته رشته نیاز دارد.
  • این امر مدیریت همزمانی بالا را تحت تعداد اتصالات بالا بسیار گران و تقریباً در سخت افزار سرور استاندارد غیرممکن می کند.

ب. هزینه های بالای تغییر متن

هنگامی که سیستم عامل اجرا را از یک نخ به نخ دیگر سوئیچ می‌کند، یک تغییر متن (context switch) انجام می‌دهد. از آنجا که نخ‌های سیستم عامل توسط هسته مدیریت می‌شوند، تغییر متن مستلزم عبور از مرز کاربر-هسته است. CPU باید:

  1. وضعیت ثبت‌های فعلی را ذخیره کند.
  2. خطوط حافظه پنهان CPU را تخلیه کرده و جداول صفحه (بافرهای ترجمه پیش‌فرض) را به‌روز کند.
  3. به حالت هسته بپرد، نخ بعدی را انتخاب کند و وضعیت ذخیره شده آن را بارگیری کند.
  4. بازگشت به حالت کاربر.

کل این رفت و برگشت حدود 1 تا 2 میکروثانیه طول می کشد، که نشان دهنده صدها یا هزاران چرخه CPU است که صرفاً صرف هزینه های اداری به جای اجرای منطق تجاری واقعی می شود.

C. محدودیت های زمان بندی سیستم عامل

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


2. راه حل Go: زمان‌بند M:N (Goroutines)

Go با معرفی Goroutines و پیاده‌سازی زمان‌بند زمان اجرا خود، محدودیت‌های نخ سیستم عامل را دور می‌زند. به‌جای نگاشت رشته‌ها 1:1، Go از یک مدل زمان‌بندی M:N استفاده می‌کند که در آن گوروتین‌های سبک وزن M بر روی رشته‌های N سیستم عامل فیزیکی مالتی پلکس می‌شوند.

نمودار مدل زمان‌بندی سنتی 1:1 Threading در مقابل Go M:N

این معماری توسط سه عنصر ساختاری اصلی اداره می شود که اغلب به عنوان مدل GMP از آن یاد می شود:

  • G (Goroutine): یک تک گوروتین را نشان می‌دهد. شامل پشته اجرای گوروتین، شمارنده برنامه و وضعیت است. یک G یک رشته سیستم عامل نیست؛ یک ساختار Go ساده است که هزینه آن تنها حدود 2 کیلوبایت حافظه برای مقداردهی اولیه است.
  • M (Machine): یک نخ فیزیکی سیستم عامل/هسته را نشان می‌دهد. توسط زمان‌بند سیستم عامل مدیریت می‌شود و مسئول اجرای دستورالعمل‌های کد ماشین گوروتین‌ها است.
  • P (Processor): یک پردازنده منطقی یا زمینه اجرا را نشان می‌دهد. تعداد نمونه‌های P به طور پیش‌فرض به تعداد هسته‌های CPU منطقی در ماشین میزبان (کنترل شده توسط GOMAXPROCS) است. یک ماشین M برای اجرای کد Go باید یک پردازنده منطقی P را به دست آورد.

چرا مدل GMP برتر است

از آنجایی که گوروتین‌ها به طور کامل در فضای کاربر توسط زمان اجرای Go مدیریت می‌شوند:

  1. پشته‌های پویا: یک گوروتین با یک پشته کوچک 2 کیلوبایتی شروع می‌شود. با تقاضای اجرا، پشته به طور پویا رشد می‌کند (تخصیص بخش‌های حافظه متوالی بزرگ‌تر در حافظه heap) و منقبض می‌شود. این به Go اجازه می‌دهد تا صدها هزار گوروتین را به طور همزمان روی یک لپ‌تاپ معمولی اجرا کند.
  2. تغییرات متن سریع: تغییر وضعیت بین گوروتین‌ها کاملاً در فضای کاربر و بدون فراخوانی سوئیچ‌های متن هسته رخ می‌دهد. فقط شمارنده برنامه و چند ثبات CPU ذخیره می‌شوند. این سوئیچ متن فضای کاربر تنها 10 تا 100 نانوثانیه طول می‌کشد—تقریباً 10 تا 100 برابر سریع‌تر از سوئیچ نخ سیستم عامل.

3. کار سرقت و غیر مسدود کردن I/O

زمان اجرا Go از دو مکانیسم زمان‌بندی پیشرفته استفاده می‌کند تا اطمینان حاصل شود که هسته‌های فیزیکی CPU هرگز کم استفاده نمی‌شوند: Work Stealing و Syscall Hand-off.

الف. الگوریتم سرقت کار

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

Local Queue P1: [ G1, G2, G3 ]  ---> Running on Thread M1
Local Queue P2: [ ]            ---> Running on Thread M2 (Idle)
                                     P2 steals G3 and G2 from P1!

ب. نظرسنجی شبکه و ورودی/خروجی غیرمسدود کننده

در عوض، Go Runtime بلوک را با Network Poller اختصاصی (که از APIهای مالتی پلکس مخصوص سیستم عامل مانند epoll در لینوکس، kqueue در macOS، یا IOCP در ویندوز استفاده می‌کند) ثبت می‌کند. گوروتین مسدود شده G از نخ M جدا شده و در نظرسنجی شبکه پارک می‌شود.

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


4. کانال‌ها در مقابل حافظه مشترک (مدل CSP)

مدل‌های نخ سنتی وظایف همزمان را با به اشتراک‌گذاری حافظه (مثلاً عبور اشاره‌گرها بین نخ‌ها) هماهنگ می‌کنند. برای جلوگیری از مسابقات داده، توسعه‌دهندگان باید به صورت دستی قفل‌ها، mutexها و متغیرهای شرطی را مدیریت کنند:

// Traditional Java Shared Memory Approach
synchronized(lock) {
    sharedResource.updateState();
}

این مدل به شدت در معرض خطا است و اغلب منجر به بن‌بست، شرایط مسابقه و گلوگاه‌های انسجام حافظه پنهان می‌شود.

Go مدل رسمی فرآیندهای متوالی ارتباطی (CSP) را پیاده‌سازی می‌کند که با ضرب‌المثل معروف Golang خلاصه می‌شود:

“از طریق اشتراک گذاری حافظه ارتباط برقرار نکنید، در عوض، حافظه را با برقراری ارتباط به اشتراک بگذارید.”

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


5. پیاده سازی عملی: برنامه ها و کانال ها در عمل

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

package main

import (
	"fmt"
	"time"
)

// Task represents a unit of work
type Task struct {
	ID       int
	Duration time.Duration
}

// Result represents the outcome of a processed task
type Result struct {
	TaskID int
	Value  string
}

// worker processes incoming tasks from the jobs channel and sends results
func worker(id int, jobs <-chan Task, results chan<- Result) {
	for job := range jobs {
		fmt.Printf("Worker %d: Started processing job %d\n", id, job.ID)
		time.Sleep(job.Duration) // Simulate CPU/IO delay
		
		results <- Result{
			TaskID: job.ID,
			Value:  fmt.Sprintf("Processed by worker %d in %v", id, job.Duration),
		}
	}
}

func main() {
	// Create tasks
	tasks := []Task{
		{ID: 101, Duration: 200 * time.Millisecond},
		{ID: 102, Duration: 400 * time.Millisecond},
		{ID: 103, Duration: 100 * time.Millisecond},
		{ID: 104, Duration: 300 * time.Millisecond},
	}

	// Buffered channels prevent sender blocking
	jobs := make(chan Task, len(tasks))
	results := make(chan Result, len(tasks))

	// Spawn 3 concurrent worker goroutines
	for w := 1; w <= 3; w++ {
		go worker(w, jobs, results)
	}

	// Feed tasks into the job channel
	for _, task := range tasks {
		jobs <- task
	}
	close(jobs) // Closing tells workers no more jobs are coming

	// Collect and aggregate results
	for i := 0; i < len(tasks); i++ {
		res := <-results
		fmt.Printf("[RESULT] Job %d: %s\n", res.TaskID, res.Value)
	}
	close(results)
}

6. مقایسه معماری: OS Threads در مقابل Goroutines

متریک / ویژگی موضوعات سیستم عامل سنتی (مدل 1:1) Go Goroutines (مدل M:N)
حافظه راه اندازی ثابت (معمولا 1MB - 8MB) پویا (از ~2 کیلوبایت شروع می شود)
زمان سوئیچ متن آهسته (1000 - 2000 نانوثانیه) سریع (10 - 100 نانوثانیه)
جایگزینی Kernel-space (سوئیچ بافت سنگین) فضای کاربر (زمان‌بندی زمان اجرا)
**هزینه ایجاد ** گران قیمت (شامل تماس های سیستم عامل) بسیار ارزان (تخصیص ساده)
ارتباطات حافظه مشترک (Mutexes، Semaphore) کانال ها (گذر پیام CSP)
ریسک بن بست بالا (ردیابی دستی دشوار است) کاهش با طراحی کانال و کامپایل تشخیص زمان اجرا

نتیجه گیری

Go با جدا کردن همزمانی از مدل threading خام سیستم عامل، مشکلات اساسی مقیاس‌بندی معماری‌های باطن مدرن را حل کرد.

گوروتین‌ها همزمانی عظیم را با حداقل سربار حافظه فعال می‌کنند، زمان‌بندی M:N استفاده از CPU را از طریق سرقت کار بدون هزینه‌های تعویض متن هسته بهینه می‌کند، و کانال‌ها یک الگوی همزمانی ایمن و گویا را ارائه می‌کنند.

چه در حال ساخت میکروسرویس ها یا سیستم های توزیع شده بزرگ باشید، معماری همزمانی داخلی Go تضمین می کند که برنامه شما پاسخگو، کارآمد از نظر منابع و نگهداری آسان است.


کاوش بینش‌های بیشتر در مورد توسعه نرم‌افزار و مهندسی بک‌اند در وبلاگ غزنیکس ←