1 مقدمه‌ای به Goroutines

1.1 مفاهیم پایه همزمانی و موازی‌سازی

همزمانی و موازی‌سازی دو مفهوم متداول در برنامه‌نویسی چندنخی هستند. آنها برای توصیف رویدادها یا اجرای برنامه استفاده می‌شوند که ممکن است به صورت همزمان رخ دهد.

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

زبان Go با همزمانی به عنوان یکی از اهداف اصلی خود طراحی شده است. این زبان با استفاده از Goroutines و Channels مدل‌های برنامه نویسی همزمان کارآمد را به دست می‌آورد. Runtime Go Goroutines را مدیریت می‌کند و می‌تواند این Goroutines را بر روی چندین نخ سیستم برنامه‌ریزی کند تا پردازش موازی را به دست آورد.

1.2 Goroutines در زبان Go

Goroutines مفهوم اصلی برای دستیابی به برنامه‌نویسی همزمان در زبان Go هستند. آنها نخ‌های سبکی هستند که توسط Runtime Go مدیریت می‌شوند. از دید کاربر، آنها شبیه موضوع‌ها هستند، اما منابع کمتری را مصرف می‌کنند و سریعتر شروع به کار می‌کنند.

ویژگی‌های Goroutines شامل:

  • سبکی: Goroutines حافظه پشته کمتری نسبت به نخ‌های سنتی را اشغال می‌کنند و اندازه پشته آن‌ها می‌تواند به صورت پویا گسترش یا کاهش یابد.
  • هزینه کم: هزینه ایجاد و از بین بردن Goroutines بسیار کمتر از هزینه نخ‌های سنتی است.
  • مکانیسم ارتباطی ساده: کانال‌ها یک مکانیسم ارتباطی ساده و موثر بین Goroutines ارائه می‌دهند.
  • طراحی غیر ‌بلاک‌کننده: Goroutines در برخی از عملیات‌ها دیگر Goroutines را از اجرا نمی‌آورند. به عنوان مثال، در حالی که یک Goroutine منتظر عملیات ورود/خروج است، Goroutines دیگر می‌توانند ادامه دهند.

2 ایجاد و مدیریت Goroutines

2.1 چگونگی ایجاد یک Goroutine

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

بیایید به یک مثال ساده نگاهی بیندازیم:

package main

import (
	"fmt"
	"time"
)

// تعریف یک تابع برای چاپ Hello
func sayHello() {
	fmt.Println("Hello")
}

func main() {
	// شروع یک Goroutine جدید با استفاده از کلمه کلیدی go
	go sayHello()

	// Goroutine اصلی برای یک بازه زمانی منتظر می‌ماند تا اجرای sayHello انجام شود
	time.Sleep(1 * time.Second)
	fmt.Println("تابع اصلی")
}

در کد فوق، تابع sayHello() به صورت ناهمزمان در یک Goroutine جدید اجرا خواهد شد. این بدان معنی است که تابع main() قبل از اینکه sayHello تمام شود، منتظر نخواهد ماند. بنابراین، ما از time.Sleep برای وقفه‌ی Goroutine اصلی استفاده کرده‌ایم تا اجازه چاپ متن در sayHello داده شود. این تنها برای اهداف نمایشی است. در توسعه واقعی، معمولاً از کانال‌ها یا روش‌های هماهنگ‌سازی دیگر برای هماهنگ سازی اجرای Goroutines مختلف استفاده می‌شود.

توجه: در برنامه‌های عملی، بهتر است از time.Sleep() برای انتظار اتمام یک Goroutine استفاده نشود، زیرا این یک مکانیزم هماهنگی قابل اعتماد نیست.

2.2 مکانیزم برنامه‌ریزی Goroutine

در Go، برنامه‌ریزی Goroutines توسط برنامه‌ریزی اجرای Go بر عهده دارد که منطق برنامه‌ریزی را بر روی پردازنده‌های منطقی موجود تخصیص می‌دهد. برنامه‌ریزی Go از فناوری برنامه‌ریزی M:N (چندین Goroutines نقشه‌برداری شده به چندین نخ سیستم عامل) برای به دست آوردن عملکرد بهتر بر روی پردازنده‌های چند هسته‌ای استفاده می‌کند.

GOMAXPROCS و پردازنده‌های منطقی

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

import "runtime"

func init() {
    runtime.GOMAXPROCS(2)
}

کد فوق حداکثر دو هسته را برای برنامه‌ریزی Goroutines تعیین می‌کند، حتی زمانی که برنامه روی یک ماشین با هسته‌های بیشتر اجرا می‌شود.

عملیات برنامه‌ریزی زمانبندی

برنامه‌ریز از سه موجود مهم استفاده می‌کند: M (ماشین)، P (پردازنده) و G (گوروتین). M ماشین یا رشته را نمایندگی کرده و انتزاعی از رشته‌های هسته سیستم‌عامل است. P نمایندگی می‌کند از منابع مورد نیاز برای اجرای یک گوروتین. هر P یک صف محلی از گوروتین دارد. G یک گوروتین را نمایندگی می‌کند و شامل پشته اجرا، مجموعه دستورات و سایر اطلاعات است.

اصول کار برنامه‌ریز Go به شرح زیر است:

  • M برای اجرای G باید یک P داشته باشد. اگر P وجود نداشته باشد، M به صفحه ذخیره رشته برمی‌گردد.
  • زمانی که G توسط دیگر G بلوکه نمی‌شود (برای مثال، در فراخوانی‌های سیستم)، تا جایی که ممکن است، بر روی همان M اجرا می‌شود و به بهره‌وری بیشتر کش‌های حافظه CPU کمک می‌کند.
  • زمانی که یک G مسدود می‌شود، M و P جدا خواهند شد و P برای یافتن یک M جدید یا بیدار کردن یک M جدید برای خدمت دادن به Gهای دیگر به‌کار می‌برد.
go func() {
    fmt.Println("سلام از گوروتین")
}()

کد فوق نشان می دهد چگونه یک گوروتین جدید راه‌اندازی می‌شود که به برنامه‌ریز نیز با اضافه شدن این G جدید به صف برای اجرا منجر می‌شود.

زمانبندی اجباری گوروتین‌ها

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

2.3 مدیریت چرخه زندگی گوروتین

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

راه‌اندازی ایمن گوروتین‌ها

قبل از شروع یک گوروتین، اطمینان حاصل کنید که از بار کاری و ویژگی‌های زمان اجرای آن آگاهی دارید. یک گوروتین باید شروع و پایان واضحی داشته باشد تا ایجاد "یتیم گوروتین" بدون شرایط پایانی جلوگیری شود.

func worker(done chan bool) {
    fmt.Println("در حال کار...")
    time.Sleep(time.Second) // شبیه‌سازی وظیفه گران
    fmt.Println("کار تمام شد.")
    done <- true
}

func main() {
    // در اینجا، از مکانیزم کانال در Go استفاده شده است. می‌توانید به راحتی کانال را به‌عنوان یک صف پیام پایه فرض کنید و از اپراتور "<-" برای خواندن و نوشتن داده‌های صف استفاده کنید.
    done := make(chan bool, 1)
    go worker(done)
    
    // منتظر پایان گوروتین باشید
    <-done
}

کد فوق یک روش را نشان می‌دهد تا منتظر شدن تا گوروتین با استفاده از کانال "done" را تمام کند.

توجه: این نمونه از مکانیزم کانال در Go استفاده می‌کند که در فصل‌های بعدی توضیح داده خواهد شد.

متوقف کردن گوروتین‌ها

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

  1. استفاده از کانال‌ها برای ارسال سیگنال‌های توقف: گوروتین‌ها می‌توانند کانال‌ها را برای بررسی سیگنال‌های توقف کنند.
stop := make(chan struct{})

go func() {
    for {
        select {
        case <-stop:
            fmt.Println("سیگنال توقف را دریافت کرد. در حال خاموش شدن...")
            return
        default:
            // اجرای عملیات عادی
        }
    }
}()

// ارسال سیگنال توقف
stop <- struct{}{}
  1. استفاده از بسته context برای مدیریت چرخه زندگی: استفاده از بسته context امکان کنترل انعطاف‌پذیر‌تری از گوروتین‌ها فراهم می‌کند و امکان‌پذیری‌های زمان بندی و لغو را فراهم می‌کند. در برنامه‌ها بزرگ یا خدمات میکروسرویس، context راه حل توصیه شده برای کنترل چرخه زندگی گوروتین‌ها است.