Go Routines و Concurrency: قدرت موازی‌سازی در Go

فهرست مطالب

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

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

در این پست جامع، ما به عمق مفهوم Go Routines و Channels خواهیم پرداخت. ابتدا تفاوت‌های کلیدی بین همزمانی و موازی‌سازی را روشن می‌کنیم، سپس به جزئیات Go Routines می‌پردازیم و نحوه عملکرد آن‌ها را بررسی می‌کنیم. در ادامه، Channels را به عنوان ستون فقرات ارتباطی در Go معرفی کرده و نحوه استفاده از آن‌ها را برای ایجاد جریان‌های داده‌ای امن و بدون نقص آموزش می‌دهیم. همچنین، الگوهای پیشرفته همزمانی، ابزارهای هماهنگ‌سازی و روش‌های کنترل خطاهای رایج مانند Race Condition، Deadlock و Goroutine Leak را مورد بحث قرار خواهیم داد. هدف نهایی این است که شما را قادر سازیم تا با اطمینان کامل، قدرت موازی‌سازی در Go را در پروژه‌های خود به کار گیرید و نرم‌افزارهایی بسازید که نه تنها سریع‌تر، بلکه پایدارتر و مقیاس‌پذیرتر باشند.

۱. درک مبانی همزمانی (Concurrency) و موازی‌سازی (Parallelism)

پیش از آنکه به Go Routines و Channels بپردازیم، ضروری است که تفاوت‌های بنیادین بین دو مفهوم کلیدی در علوم کامپیوتر را درک کنیم: همزمانی (Concurrency) و موازی‌سازی (Parallelism). این دو واژه اغلب به جای یکدیگر استفاده می‌شوند، اما معانی متفاوتی دارند و درک آن‌ها برای نوشتن کد Go کارآمد، حیاتی است.

همزمانی (Concurrency) چیست؟

همزمانی به توانایی یک سیستم برای مدیریت چندین کار در حال انجام (tasks in progress) اشاره دارد، اما لزوماً به این معنی نیست که همه آن‌ها در یک لحظه واحد اجرا می‌شوند. همزمانی در مورد “مدیریت همزمان چندین چیز” است. فکر کنید به یک سرآشپز که چندین سفارش مختلف را به صورت همزمان مدیریت می‌کند. او ممکن است شروع به آماده‌سازی غذای اول کند، سپس وقتی منتظر پخت آن است، شروع به آماده‌سازی غذای دوم کند، و سپس به غذای اول برگردد. در هر لحظه، او ممکن است فقط روی یک غذا کار کند، اما او چندین سفارش را به صورت همزمان “مدیریت” می‌کند و بین آن‌ها سوئیچ می‌کند تا به نظر برسد همه در حال پیشرفت هستند.

در برنامه‌نویسی، همزمانی معمولاً با استفاده از Threadها، Coroutineها یا Taskها پیاده‌سازی می‌شود. سیستم‌عامل یا زمان‌اجرای زبان (Runtime) با سوئیچ کردن سریع بین این واحدهای اجرایی (Context Switching)، توهم اجرای همزمان را ایجاد می‌کند، حتی اگر تنها یک هسته پردازشی در دسترس باشد.

موازی‌سازی (Parallelism) چیست؟

موازی‌سازی به معنای “انجام چندین چیز در یک لحظه واحد” است. برای موازی‌سازی واقعی، شما به چندین واحد پردازشی (مثلاً چندین هسته CPU یا چندین پردازنده) نیاز دارید. اگر سرآشپز ما چند دستیار داشته باشد که هر کدام روی یک سفارش جداگانه کار می‌کنند، در این صورت آن‌ها به صورت موازی کار می‌کنند.

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

تفاوت کلیدی

  • **همزمانی:** در مورد ساختار برنامه و نحوه مدیریت کارهاست. برنامه همزمان می‌تواند تنها با یک هسته CPU اجرا شود.
  • **موازی‌سازی:** در مورد اجرا و توانایی انجام واقعی چندین کار در یک لحظه است. برای موازی‌سازی واقعی، به چندین هسته CPU نیاز دارید.

می‌توان گفت که همزمانی یک ابزار برای رسیدن به موازی‌سازی است، اما لزوماً منجر به آن نمی‌شود. یک برنامه Go با Go Routines می‌تواند همزمان باشد (چون چندین Go Routine را مدیریت می‌کند) اما اگر فقط یک هسته CPU در دسترس باشد، ممکن است به صورت موازی اجرا نشود. با این حال، اگر چندین هسته CPU وجود داشته باشد، Go Runtime به طور خودکار Go Routines را روی هسته‌های مختلف توزیع می‌کند و به موازی‌سازی واقعی دست می‌یابد.

Go به طور ذاتی همزمان است. شما با استفاده از Go Routines به راحتی می‌توانید کدهای همزمان بنویسید. Go Runtime و زمان‌بند آن (Scheduler)، مسئولیت نگاشت این Go Routines به هسته‌های CPU موجود را بر عهده دارند تا در صورت امکان به موازی‌سازی دست یابند. این طراحی Go را برای ساخت سیستم‌های مدرن با کارایی بالا بسیار مناسب می‌کند.

۲. Go Routines: واحد بنیادین همزمانی در Go

Go Routines ستون فقرات مدل همزمانی در Go هستند. آن‌ها را می‌توان به عنوان “Threadهای سبک وزن” یا “Coroutine” در نظر گرفت که توسط زمان‌اجرای Go (Go Runtime) مدیریت می‌شوند. Go Routines بسیار سبک‌تر، سریع‌تر برای ایجاد و سوئیچ کردن هستند و سربار حافظه‌ای بسیار کمتری نسبت به Threadهای سنتی سیستم‌عامل دارند.

Go Routines چیستند و چگونه کار می‌کنند؟

هنگامی که یک برنامه Go را اجرا می‌کنید، تابع `main` در یک Go Routine اجرا می‌شود. شما می‌توانید با استفاده از کلمه کلیدی `go` پیش از یک فراخوانی تابع یا یک تابع بی‌نام (anonymous function)، Go Routineهای جدیدی را راه‌اندازی کنید.


package main

import (
    "fmt"
    "time"
)

func sayHello() {
    fmt.Println("Hello from a Go Routine!")
}

func main() {
    // Go Routine را راه‌اندازی می‌کنیم
    go sayHello() 

    // برای اینکه Go Routine فرصت اجرا پیدا کند، کمی صبر می‌کنیم
    // در غیر این صورت، تابع main ممکن است زودتر تمام شود و Go Routine اجرا نشود
    time.Sleep(100 * time.Millisecond) 
    fmt.Println("Hello from main Go Routine!")
}

در مثال بالا، `sayHello()` در یک Go Routine جداگانه اجرا می‌شود. اگر `time.Sleep` را حذف کنید، ممکن است “Hello from a Go Routine!” را نبینید، زیرا Go Routine اصلی (که تابع `main` را اجرا می‌کند) ممکن است قبل از اینکه Go Routine جدید فرصت اجرا پیدا کند، به پایان برسد. Go Runtime به طور خودکار Go Routines را زمان‌بندی و روی Threadهای سیستم‌عامل اجرا می‌کند. این زمان‌بندی به صورت تعاونی (cooperative) و پیشگیرانه (preemptive) انجام می‌شود، به این معنی که Go Runtime تلاش می‌کند تا Go Routines را به طور عادلانه روی Threadهای سیستم‌عامل توزیع کند و از مسدود شدن طولانی مدت یک Thread توسط یک Go Routine جلوگیری کند.

تفاوت با Threadهای سیستم‌عامل

تفاوت‌های کلیدی Go Routines با Threadهای سیستم‌عامل عبارتند از:

  • **سربار حافظه:** یک Go Routine در ابتدا با یک پشته (stack) بسیار کوچک (معمولاً ۲ کیلوبایت) شروع می‌شود که در صورت نیاز به صورت پویا رشد می‌کند و کوچک می‌شود. این در حالی است که Threadهای سیستم‌عامل پشته‌های بزرگتر و ثابتی دارند (معمولاً ۱ یا ۲ مگابایت). این امر به Go اجازه می‌دهد تا به راحتی هزاران (حتی صدها هزار) Go Routine را به طور همزمان اجرا کند.
  • **زمان‌بندی (Scheduling):** Go Routines توسط زمان‌اجرای Go و زمان‌بند داخلی آن مدیریت می‌شوند، نه توسط زمان‌بند سیستم‌عامل. این زمان‌بند، Go Routines را به Threadهای سیستم‌عامل (که توسط زمان‌اجرای Go مدیریت می‌شوند) نگاشت می‌کند. این مدل (M:N scheduling) به Go انعطاف‌پذیری و کارایی بالایی در مدیریت همزمانی می‌دهد.
  • **سوئیچ زمینه (Context Switching):** سوئیچ کردن بین Go Routines بسیار سریع‌تر از سوئیچ کردن بین Threadهای سیستم‌عامل است، زیرا توسط Go Runtime در فضای کاربری (userspace) انجام می‌شود و نیازی به فراخوانی‌های پرهزینه به هسته سیستم‌عامل ندارد.

مدل M:N Scheduler

مدل M:N Scheduler در Go به این معنی است که M تعداد Go Routines به N تعداد Thread سیستم‌عامل نگاشت می‌شوند. Go Runtime به طور هوشمندانه Go Routines را بین Threadهای موجود توزیع می‌کند. اگر یک Go Routine عملیات مسدودکننده‌ای (مانند I/O) انجام دهد، Go Runtime آن Go Routine را از Thread فعلی جدا کرده و به Go Routine دیگری فرصت اجرا روی همان Thread می‌دهد. این رویکرد تضمین می‌کند که حتی با وجود عملیات‌های مسدودکننده، هسته‌های CPU به طور کارآمد مورد استفاده قرار می‌گیرند و برنامه‌های Go می‌توانند به حداکثر توان عملیاتی (throughput) دست یابند.


package main

import (
    "fmt"
    "runtime" // برای استفاده از runtime.NumCPU
    "time"
)

func worker(id int) {
    fmt.Printf("Worker %d starting\n", id)
    time.Sleep(time.Second) // شبیه‌سازی کار
    fmt.Printf("Worker %d finished\n", id)
}

func main() {
    // تنظیم تعداد حداکثر هسته‌های منطقی CPU که Go می‌تواند استفاده کند.
    // به طور پیش‌فرض، Go از تمام هسته‌های در دسترس استفاده می‌کند.
    runtime.GOMAXPROCS(runtime.NumCPU()) 
    fmt.Printf("Number of logical CPUs: %d\n", runtime.NumCPU())

    for i := 1; i <= 5; i++ {
        go worker(i) // 5 Go Routine راه‌اندازی می‌کنیم
    }

    // برای اطمینان از اتمام کار تمام Go Routineها، کمی بیشتر صبر می‌کنیم.
    // در بخش‌های بعدی با sync.WaitGroup این مشکل را به درستی حل می‌کنیم.
    time.Sleep(2 * time.Second) 
    fmt.Println("Main finished")
}

با راه‌اندازی Go Routines، شما به Go Runtime می‌گویید که این توابع می‌توانند به صورت مستقل و همزمان اجرا شوند. این به Go Runtime آزادی عمل می‌دهد تا بهترین راه را برای توزیع کار بین هسته‌های CPU موجود پیدا کند. این سادگی و کارایی، Go Routines را به ابزاری قدرتمند برای ساخت سیستم‌های همزمان و توزیع شده تبدیل کرده است.

۳. Channels: راه ایمن برای ارتباط بین Go Routines

در برنامه‌نویسی همزمان، یکی از بزرگترین چالش‌ها، نحوه ارتباط و هماهنگی بین واحدهای اجرایی (مانند Go Routines) است. اگر چندین Go Routine سعی کنند به صورت همزمان به یک قطعه داده مشترک دسترسی پیدا کرده و آن را تغییر دهند، ممکن است پدیده Race Condition رخ دهد که منجر به نتایج غیرقابل پیش‌بینی و باگ‌های دشوار برای اشکال‌زدایی می‌شود.

Go این مشکل را با معرفی مفهوم Channels حل می‌کند. Channels یک لوله ارتباطی نوع‌دار هستند که Go Routines می‌توانند از طریق آن‌ها مقادیر را ارسال و دریافت کنند. فلسفه پشت Channels این جمله مشهور Go است: "Don't communicate by sharing memory; share memory by communicating." به عبارت دیگر، به جای اینکه Go Routines داده‌ها را مستقیماً از طریق حافظه مشترک به اشتراک بگذارند (که نیاز به قفل‌گذاری و مدیریت دقیق دارد)، آن‌ها داده‌ها را از طریق Channels به یکدیگر "ارسال" می‌کنند. این رویکرد، طراحی کدهای همزمان را به طرز چشمگیری ساده و امن می‌کند.

Channels چگونه کار می‌کنند؟

Channels ابزاری برای همگام‌سازی و ارتباط بین Go Routines هستند. شما می‌توانید یک Channel را با استفاده از تابع `make` ایجاد کنید و نوع داده‌ای را که قرار است از طریق آن منتقل شود، مشخص کنید.


// ساخت یک Channel از نوع int
myChannel := make(chan int) 

دو عمل اصلی برای Channels وجود دارد:

  • **ارسال (Send):** ارسال یک مقدار به Channel با استفاده از اپراتور `<-` انجام می‌شود: `channel <- value`
  • **دریافت (Receive):** دریافت یک مقدار از Channel نیز با همین اپراتور انجام می‌شود: `value := <-channel`

Channels بدون بافر (Unbuffered Channels)

به طور پیش‌فرض، Channels بدون بافر هستند. این بدان معناست که یک Channel بدون بافر، تا زمانی که یک Go Routine دیگر آماده دریافت مقدار باشد، عملیات ارسال (send) را مسدود می‌کند. به همین ترتیب، عملیات دریافت (receive) نیز تا زمانی که یک Go Routine دیگر آماده ارسال مقدار باشد، مسدود می‌شود. این مکانیسم "ارسال-دریافت" یک نقطه همگام‌سازی طبیعی ایجاد می‌کند.


package main

import "fmt"

func main() {
    messages := make(chan string) // یک Channel بدون بافر از نوع string

    go func() {
        fmt.Println("Sending message...")
        messages <- "Hello from Go Routine!" // ارسال به Channel. این خط مسدود می‌شود تا یک گیرنده وجود داشته باشد.
        fmt.Println("Message sent!")
    }()

    fmt.Println("Receiving message...")
    msg := <-messages // دریافت از Channel. این خط مسدود می‌شود تا یک فرستنده وجود داشته باشد.
    fmt.Println("Message received:", msg)

    // Output:
    // Receiving message...
    // Sending message...
    // Message received: Hello from Go Routine!
    // Message sent!
}

در این مثال، Go Routine اصلی (main) ابتدا برای دریافت پیام آماده می‌شود. Go Routine تازه راه‌اندازی شده سعی می‌کند پیامی را ارسال کند. چون Go Routine اصلی آماده دریافت است، عملیات ارسال و دریافت فوراً انجام شده و هر دو Go Routine از حالت مسدود خارج می‌شوند. اگر ترتیب `fmt.Println` را تغییر دهید یا `messages <- "..."` قبل از `msg := <-messages` در Go Routine اصلی قرار گیرد، ممکن است برنامه به Deadlock بخورد.

Channels با بافر (Buffered Channels)

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


package main

import "fmt"
import "time"

func main() {
    // یک Channel با بافر 2 از نوع int
    bufferedChannel := make(chan int, 2) 

    fmt.Println("Sending 1 to bufferedChannel...")
    bufferedChannel <- 1 // ارسال موفق، بافر پر نیست

    fmt.Println("Sending 2 to bufferedChannel...")
    bufferedChannel <- 2 // ارسال موفق، بافر هنوز پر نیست

    // اگر اینجا یک 3 را ارسال کنیم، مسدود می‌شود تا زمانی که یکی از مقادیر دریافت شود، چون بافر پر است.
    // bufferedChannel <- 3 // این خط مسدود می‌شود

    fmt.Println("Receiving from bufferedChannel...")
    fmt.Println(<-bufferedChannel) // دریافت 1

    fmt.Println("Sending 3 to bufferedChannel...")
    bufferedChannel <- 3 // اکنون ارسال 3 موفق است، چون بافر فضای خالی دارد.

    fmt.Println("Receiving from bufferedChannel...")
    fmt.Println(<-bufferedChannel) // دریافت 2

    fmt.Println("Receiving from bufferedChannel...")
    fmt.Println(<-bufferedChannel) // دریافت 3

    // اگر بخواهیم بیشتر از تعداد بافر و بعد از خالی شدن دریافت کنیم، مسدود می‌شود.
    // fmt.Println(<-bufferedChannel) // این خط مسدود می‌شود
}

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

بستن Channels (`close`) و بررسی وضعیت (`ok`)

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


package main

import "fmt"

func main() {
    jobs := make(chan int, 5)
    done := make(chan bool)

    go func() {
        for {
            j, more := <-jobs // j: مقدار دریافت شده، more: true اگر Channel باز باشد و مقداری وجود داشته باشد، false اگر بسته شده باشد و بافر خالی باشد.
            if more {
                fmt.Println("received job", j)
            } else {
                fmt.Println("received all jobs")
                done <- true // به Channel done اطلاع می‌دهد که کارها تمام شده
                return
            }
        }
    }()

    for j := 1; j <= 3; j++ {
        jobs <- j
        fmt.Println("sent job", j)
    }
    close(jobs) // Channel را می‌بندیم
    fmt.Println("sent all jobs")

    <-done // منتظر می‌مانیم تا Go Routine کار خود را تمام کند
}

بستن یک Channel بسیار مهم است، زیرا به گیرندگان اطلاع می‌دهد که هیچ داده‌ای دیگر نخواهد آمد. تلاش برای ارسال به یک Channel بسته شده منجر به Panic می‌شود. با این حال، دریافت از یک Channel بسته شده تا زمانی که مقادیر باقی‌مانده در بافر وجود دارند، ادامه می‌یابد. پس از خالی شدن بافر، دریافت از یک Channel بسته شده مقادیر صفر (zero-value) را برای نوع داده Channel برمی‌گرداند و `ok` به `false` تغییر می‌کند.

جهت Channel (Channel Direction)

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


package main

import "fmt"
import "time"

// ping فقط می‌تواند به Channel ارسال کند
func ping(pings chan<- string, message string) {
    pings <- message
}

// pong می‌تواند از یک Channel دریافت و به Channel دیگری ارسال کند
func pong(pings <-chan string, pongs chan<- string) {
    msg := <-pings
    pongs <- msg
}

func main() {
    pings := make(chan string, 1)
    pongs := make(chan string, 1)

    go ping(pings, "passed message")
    time.Sleep(100 * time.Millisecond) // کمی صبر می‌کنیم تا ping اجرا شود
    go pong(pings, pongs)
    time.Sleep(100 * time.Millisecond) // کمی صبر می‌کنیم تا pong اجرا شود

    fmt.Println(<-pongs) // دریافت پیام نهایی
}

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

۴. الگوهای پیشرفته Go Routines و Channels

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

الف) WaitGroup: هماهنگ‌سازی Go Routines

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

`WaitGroup` یک شمارنده داخلی دارد که می‌توان آن را اضافه یا کم کرد. شما آن را به Go Routines پاس می‌دهید و:

  • `Add(delta int)`: شمارنده را به اندازه `delta` افزایش می‌دهد.
  • `Done()`: شمارنده را به اندازه ۱ واحد کاهش می‌دهد (معمولاً در پایان هر Go Routine فراخوانی می‌شود).
  • `Wait()`: تا زمانی که شمارنده به صفر برسد، مسدود می‌شود.

package main

import (
    "fmt"
    "sync" // برای WaitGroup
    "time"
)

func worker(id int, wg *sync.WaitGroup) {
    defer wg.Done() // اطمینان از کاهش شمارنده پس از اتمام Go Routine

    fmt.Printf("Worker %d starting\n", id)
    time.Sleep(time.Second) // شبیه‌سازی کار
    fmt.Printf("Worker %d finished\n", id)
}

func main() {
    var wg sync.WaitGroup // یک WaitGroup ایجاد می‌کنیم

    for i := 1; i <= 5; i++ {
        wg.Add(1) // برای هر Go Routine که راه‌اندازی می‌کنیم، شمارنده را افزایش می‌دهیم
        go worker(i, &wg) // Go Routine را راه‌اندازی می‌کنیم و WaitGroup را پاس می‌دهیم
    }

    wg.Wait() // منتظر می‌مانیم تا شمارنده WaitGroup به صفر برسد (یعنی همه Go Routines تمام شوند)
    fmt.Println("All workers finished")
}

در این مثال، `main` به طور موثری منتظر می‌ماند تا همه "workers" Go Routine کار خود را تمام کنند. استفاده از `defer wg.Done()` در هر `worker` تضمین می‌کند که شمارنده حتی در صورت بروز خطا یا Panic نیز کاهش می‌یابد.

ب) Mutex: محافظت از داده‌های مشترک

با اینکه Go قویاً توصیه می‌کند که "با اشتراک‌گذاری حافظه ارتباط برقرار نکنید؛ بلکه با ارتباط برقرار کردن حافظه را به اشتراک بگذارید" (استفاده از Channels)، اما مواقعی وجود دارد که به ناچار باید به حافظه مشترک دسترسی داشته باشید، مثلاً برای به‌روزرسانی یک شمارنده جهانی یا دسترسی به ساختار داده‌ای پیچیده. در چنین مواردی، نیاز به مکانیزمی برای محافظت از داده‌ها در برابر Race Conditionها دارید. `sync.Mutex` (مخفف Mutual Exclusion) دقیقاً برای این منظور طراحی شده است.

`Mutex` یک قفل است که اطمینان می‌دهد تنها یک Go Routine در یک زمان می‌تواند به یک بخش خاص از کد (بخش بحرانی یا critical section) دسترسی داشته باشد. دو عمل اصلی دارد:

  • `Lock()`: Go Routine را مسدود می‌کند تا زمانی که بتواند قفل را به دست آورد.
  • `Unlock()`: قفل را آزاد می‌کند.

package main

import (
    "fmt"
    "sync"
    "time"
)

var (
    counter int
    mutex   sync.Mutex
)

func increment() {
    mutex.Lock()   // قفل را به دست می‌آوریم
    defer mutex.Unlock() // اطمینان از آزاد شدن قفل
    
    // این بخش از کد، "بخش بحرانی" است
    counter++
    fmt.Println("Counter:", counter)
}

func main() {
    var wg sync.WaitGroup

    for i := 0; i < 5; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            increment()
        }()
    }

    wg.Wait()
    fmt.Println("Final Counter:", counter)
}

در این مثال، بدون `mutex.Lock()` و `mutex.Unlock()`، خروجی `counter` ممکن است غیرقابل پیش‌بینی باشد (به دلیل Race Condition). `Mutex` تضمین می‌کند که `counter++` یک عملیات اتمی (atomic) خواهد بود و هیچ دو Go Routine همزمان آن را اجرا نمی‌کنند.

RWMutex (Read-Write Mutex)

برای سناریوهایی که خواندن از داده‌های مشترک بسیار بیشتر از نوشتن است، `sync.RWMutex` گزینه بهتری است. `RWMutex` امکان می‌دهد چندین Go Routine به طور همزمان به داده‌ها "بخوانند"، اما فقط یک Go Routine در هر زمان می‌تواند "بنویسد". اگر یک Go Routine قصد نوشتن داشته باشد، تمام عملیات خواندن و نوشتن دیگر مسدود می‌شوند تا زمانی که نوشتن به پایان برسد. این می‌تواند کارایی را در سیستم‌های خواندن-فشرده بهبود بخشد.

  • `RLock()`: قفل خواندن را به دست می‌آورد.
  • `RUnlock()`: قفل خواندن را آزاد می‌کند.
  • `Lock()`: قفل نوشتن را به دست می‌آورد (هم خواننده‌ها و هم نویسنده‌ها را مسدود می‌کند).
  • `Unlock()`: قفل نوشتن را آزاد می‌کند.

package main

import (
    "fmt"
    "sync"
    "time"
)

var (
    data   string
    rwMutex sync.RWMutex
)

func reader(id int) {
    rwMutex.RLock() // قفل خواندن را به دست می‌آوریم
    fmt.Printf("Reader %d reading: %s\n", id, data)
    time.Sleep(100 * time.Millisecond) // شبیه‌سازی زمان خواندن
    rwMutex.RUnlock() // قفل خواندن را آزاد می‌کنیم
}

func writer(id int, newData string) {
    rwMutex.Lock() // قفل نوشتن را به دست می‌آوریم (خواننده‌ها و نویسنده‌های دیگر مسدود می‌شوند)
    fmt.Printf("Writer %d writing: %s\n", id, newData)
    data = newData
    time.Sleep(200 * time.Millisecond) // شبیه‌سازی زمان نوشتن
    rwMutex.Unlock() // قفل نوشتن را آزاد می‌کنیم
}

func main() {
    var wg sync.WaitGroup
    data = "initial data"

    // خواننده‌ها را راه‌اندازی می‌کنیم
    for i := 1; i <= 3; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            reader(id)
        }(i)
    }

    // یک نویسنده را راه‌اندازی می‌کنیم
    wg.Add(1)
    go func() {
        defer wg.Done()
        writer(1, "updated data 1")
    }()

    // کمی صبر می‌کنیم تا نویسنده احتمالا قبل از خواننده بعدی اجرا شود
    time.Sleep(50 * time.Millisecond)

    // خواننده‌های بیشتر
    for i := 4; i <= 6; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            reader(id)
        }(i)
    }

    wg.Wait()
    fmt.Println("Final data:", data)
}

ج) Select: مدیریت چند کانال به صورت همزمان

زمانی که برنامه شما باید منتظر پیام‌ها از چندین Channel باشد، یا نیاز به پیاده‌سازی Time-outها برای عملیات Channel دارید، `select` یک ابزار قدرتمند است. دستور `select` شبیه به `switch` است، اما برای Channels استفاده می‌شود. یک `select` تا زمانی که یکی از عملیات‌های `case` آماده شود، مسدود می‌شود. اگر چندین `case` آماده باشند، `select` به طور تصادفی یکی را انتخاب می‌کند تا از گرسنگی (starvation) جلوگیری کند.


package main

import (
    "fmt"
    "time"
)

func main() {
    c1 := make(chan string)
    c2 := make(chan string)

    go func() {
        time.Sleep(1 * time.Second)
        c1 <- "one"
    }()

    go func() {
        time.Sleep(2 * time.Second)
        c2 <- "two"
    }()

    for i := 0; i < 2; i++ { // دو بار تکرار می‌کنیم تا هر دو پیام را دریافت کنیم
        select {
        case msg1 := <-c1:
            fmt.Println("received", msg1)
        case msg2 := <-c2:
            fmt.Println("received", msg2)
        case <-time.After(500 * time.Millisecond): // Time-out: اگر هیچ کدام از موارد بالا در 500 میلی‌ثانیه آماده نشدند.
            fmt.Println("timeout")
        }
    }

    // مثال دیگر: استفاده از default برای انتخاب غیرمسدودکننده
    messages := make(chan string)
    select {
    case msg := <-messages:
        fmt.Println("received message", msg)
    default: // اگر هیچ پیامی در Channel نبود، بلافاصله اجرا می‌شود.
        fmt.Println("no message received")
    }
}

در این مثال، `select` ابتدا `timeout` را چاپ می‌کند زیرا هیچ کدام از `c1` یا `c2` در ۵۰۰ میلی‌ثانیه اول آماده نیستند. سپس، پس از ۱ ثانیه، `one` را دریافت می‌کند. پس از ۲ ثانیه، `two` را دریافت می‌کند. بخش `default` در `select` باعث می‌شود که عملیات غیرمسدودکننده باشد؛ اگر هیچ `case` دیگری آماده نباشد، `default` بلافاصله اجرا می‌شود. این برای پیاده‌سازی پولینگ (polling) یا تلاش برای دریافت غیرمسدودکننده مفید است.

این ابزارها و الگوها به شما کمک می‌کنند تا کنترل بیشتری بر همزمانی در برنامه‌های Go خود داشته باشید و با اطمینان بیشتری کدهای قدرتمند و بدون خطا بنویسید.

۵. کنترل خطاهای همزمانی: Race Conditions، Deadlock و Goroutine Leak

همزمانی قدرت زیادی به همراه دارد، اما اگر به درستی مدیریت نشود، می‌تواند منجر به باگ‌های پیچیده و دشوار برای شناسایی شود. سه مورد از رایج‌ترین و مشکل‌سازترین خطاهای همزمانی عبارتند از Race Condition، Deadlock و Goroutine Leak. درک این مفاهیم و دانستن چگونگی پیشگیری از آن‌ها برای نوشتن کدهای همزمان پایدار در Go حیاتی است.

الف) Race Conditions (شرایط رقابتی)

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

مثال Race Condition:


package main

import (
    "fmt"
    "runtime"
    "sync"
    "time"
)

var sharedCounter int // متغیر مشترک

func incrementWithoutMutex(wg *sync.WaitGroup) {
    defer wg.Done()
    for i := 0; i < 1000; i++ {
        // این عملیات اتمی نیست و ممکن است دچار Race Condition شود
        sharedCounter++ 
    }
}

func main() {
    runtime.GOMAXPROCS(1) // برای واضح‌تر شدن Race Condition، روی یک هسته اجرا می‌کنیم
    
    var wg sync.WaitGroup
    for i := 0; i < 5; i++ {
        wg.Add(1)
        go incrementWithoutMutex(&wg)
    }
    wg.Wait()
    fmt.Println("Final Counter (without mutex):", sharedCounter) // معمولاً کمتر از 5000 است
}

در مثال بالا، انتظار داریم `sharedCounter` به ۵۰۰۰ برسد، اما به دلیل Race Condition، احتمالاً کمتر از این مقدار خواهد بود. دلیل آن این است که `sharedCounter++` در واقع شامل سه عملیات است: خواندن مقدار `sharedCounter`، افزایش آن، و نوشتن مقدار جدید. اگر دو Go Routine همزمان این عملیات را انجام دهند، ممکن است هر دو مقدار قدیمی را بخوانند، آن را افزایش دهند، و سپس هر دو مقدار مشابه را بنویسند، که منجر به از دست رفتن یک افزایش می‌شود.

تشخیص Race Condition:

Go یک ابزار داخلی قدرتمند برای تشخیص Race Conditionها دارد: Race Detector. می‌توانید با اجرای برنامه خود با فلگ `-race` آن را فعال کنید:


go run -race your_program.go

این ابزار به شما کمک می‌کند تا خطوط کد مشکوک به Race Condition را شناسایی کنید. اگر Race Detector چیزی پیدا کند، هشدار دقیق همراه با Trace Stack نشان می‌دهد.

پیشگیری از Race Condition:

  • **Channels:** بهترین و idiomatic ترین راه در Go. با ارتباط از طریق Channels، شما داده‌ها را مستقیماً به اشتراک نمی‌گذارید، بلکه آن‌ها را منتقل می‌کنید.
  • **Mutexes (یا RWMutex):** اگر باید از حافظه مشترک استفاده کنید، Mutexها تضمین می‌کنند که تنها یک Go Routine در یک زمان به بخش بحرانی دسترسی دارد.
  • **Atomic Operations:** برای عملیات‌های ساده مانند افزایش/کاهش یک عدد، بسته `sync/atomic` توابعی را ارائه می‌دهد که عملیات اتمی (Atomic Operations) را انجام می‌دهند، یعنی بدون نیاز به قفل‌گذاری، از ایمنی Thread اطمینان حاصل می‌کنند.

ب) Deadlock (بن‌بست)

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

مثال Deadlock:


package main

import "fmt"
import "time"

func main() {
    ch := make(chan int) // یک Channel بدون بافر

    // Go Routine A
    go func() {
        fmt.Println("Go Routine A waiting to send...")
        ch <- 1 // Go Routine A تلاش می‌کند به Channel ارسال کند.
        fmt.Println("Go Routine A sent 1")
    }()

    // Go Routine B
    // هیچ گیرنده‌ای در Go Routine B یا اصلی وجود ندارد.
    // اگر ch بدون بافر باشد، Go Routine A برای همیشه مسدود می‌شود.

    time.Sleep(2 * time.Second) // کمی صبر می‌کنیم تا Go Routine A فرصت اجرا پیدا کند
    fmt.Println("Main finished (but Go Routine A is deadlocked)")
    // در واقع، Go Runtime پس از یک زمان مشخص Deadlock را تشخیص داده و Panic می‌کند.
    // fatal error: all goroutines are asleep - deadlock!
}

یک سناریوی رایج دیگر Deadlock، قفل‌های دایره‌ای (circular locking) است که در آن Go Routine A قفل X را دارد و منتظر قفل Y است، در حالی که Go Routine B قفل Y را دارد و منتظر قفل X است.

پیشگیری از Deadlock:

  • **قوانین واضح برای Channels:** مطمئن شوید که هر ارسال کننده (sender) یک گیرنده (receiver) متناظر دارد و برعکس. در Channels بدون بافر، این امر حیاتی است.
  • **ترتیب قفل‌ها:** اگر از Mutexes استفاده می‌کنید، همیشه قفل‌ها را به یک ترتیب ثابت به دست آورید تا از Deadlockهای دایره‌ای جلوگیری کنید.
  • **استفاده از `select` با `default` یا `time.After`:** برای جلوگیری از مسدود شدن دائمی، می‌توانید از `select` با یک `default` (برای عملیات غیرمسدودکننده) یا یک `time.After` (برای Time-out) استفاده کنید.
  • **تجزیه و تحلیل دقیق:** قبل از نوشتن کد همزمان، جریان کنترل و داده‌ها را به دقت برنامه‌ریزی کنید.

ج) Goroutine Leak (نشت Goroutine)

یک Goroutine Leak زمانی رخ می‌دهد که یک Go Routine شروع به کار کند اما هرگز به پایان نرسد. این Go Routineها منابع سیستم (مانند حافظه و پردازنده) را مصرف می‌کنند و در طول زمان می‌توانند منجر به کاهش عملکرد یا حتی از کار افتادن برنامه شوند.

مثال Goroutine Leak:


package main

import "fmt"
import "time"

func leakyWorker() {
    // این Channel هرگز خوانده نمی‌شود
    ch := make(chan int) 
    
    // این Go Routine برای همیشه مسدود می‌شود چون کسی از ch دریافت نمی‌کند
    <-ch 
    fmt.Println("This line will never be reached")
}

func main() {
    for i := 0; i < 5; i++ {
        go leakyWorker() // 5 Go Routine راه‌اندازی می‌کنیم که هرگز تمام نمی‌شوند
    }

    fmt.Println("Main Go Routine finished. Leaky Goroutines are still running.")
    time.Sleep(5 * time.Second) // برنامه را برای مدتی زنده نگه می‌داریم تا Leak دیده شود
    fmt.Println("Program exiting.")
}

در این مثال، هر `leakyWorker` برای همیشه مسدود می‌شود زیرا هیچ کس از `ch` پیام نمی‌خواند. این Go Routineها تا پایان عمر برنامه در حافظه باقی می‌مانند.

پیشگیری از Goroutine Leak:

  • **مدیریت چرخه عمر Go Routineها:** اطمینان حاصل کنید که هر Go Routine در نهایت به پایان می‌رسد. این اغلب به معنای استفاده صحیح از Channels برای سیگنال‌دهی اتمام کار یا استفاده از `context.Context` برای لغو Go Routines است.
  • **بستن Channels (`close`):** اگر یک Channel دیگر استفاده نمی‌شود، آن را ببندید تا گیرندگان بتوانند تشخیص دهند که دیگر داده‌ای نخواهد آمد و از مسدود شدن ابدی جلوگیری شود.
  • **استفاده از `select` با Time-out/Default:** برای جلوگیری از مسدود شدن Go Routineها در عملیات Channel که ممکن است هرگز اتفاق نیفتند.
  • **`context.Context`:** برای ارسال سیگنال لغو به درخت Go Routineها، بسیار مفید است و در بخش بعدی به آن می‌پردازیم.
  • **پروفایلینگ:** استفاده از ابزارهای پروفایلینگ Go (مانند `pprof`) برای شناسایی Go Routineهای مسدود شده و مشکلات حافظه.

تسلط بر کنترل این خطاها برای ساخت سیستم‌های قوی و قابل اعتماد با Go Routines و Channels ضروری است. با درک عمیق این مفاهیم و استفاده از ابزارهای مناسب، می‌توانید از قدرت همزمانی به بهترین شکل بهره ببرید.

۶. Go Concurrency در عمل: مثال‌های کاربردی

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

الف) Fan-Out / Fan-In Pattern

الگوی Fan-Out / Fan-In برای توزیع کار بین چندین Go Routine (Fan-Out) و سپس جمع‌آوری نتایج از آن‌ها در یک Go Routine واحد (Fan-In) استفاده می‌شود. این الگو برای موازی‌سازی کارهایی که می‌توانند به قطعات کوچکتر تقسیم شوند، بسیار مفید است.


package main

import (
    "fmt"
    "math/rand"
    "sync"
    "time"
)

// simulateWork شبیه‌سازی یک کار با زمان تصادفی
func simulateWork(id int) int {
    delay := time.Duration(rand.Intn(500) + 100) * time.Millisecond // 100-600ms
    time.Sleep(delay)
    result := id * 2
    fmt.Printf("Worker %d finished, result: %d (took %v)\n", id, result, delay)
    return result
}

// worker Go Routine که کار را انجام می‌دهد و نتیجه را به Channel ارسال می‌کند
func worker(id int, input <-chan int, output chan<- int, wg *sync.WaitGroup) {
    defer wg.Done()
    for taskID := range input { // از input Channel دریافت می‌کند تا زمانی که بسته شود
        result := simulateWork(taskID)
        output <- result // نتیجه را به output Channel ارسال می‌کند
    }
}

func main() {
    const numTasks = 10
    const numWorkers = 3 // تعداد Go Routine های کارگر

    tasks := make(chan int, numTasks)   // Channel برای ارسال کارها
    results := make(chan int, numTasks) // Channel برای دریافت نتایج

    var wg sync.WaitGroup

    // Fan-Out: راه‌اندازی Go Routine های کارگر
    for i := 0; i < numWorkers; i++ {
        wg.Add(1)
        go worker(i+1, tasks, results, &wg)
    }

    // ارسال کارها به Channel tasks
    for i := 0; i < numTasks; i++ {
        tasks <- i + 1
    }
    close(tasks) // پس از ارسال همه کارها، Channel tasks را می‌بندیم

    // منتظر می‌مانیم تا همه کارگرها کارشان تمام شود و Channel results را ببندند (به طور غیرمستقیم)
    go func() {
        wg.Wait()
        close(results) // پس از اتمام همه کارگرها، Channel نتایج را می‌بندیم
    }()

    // Fan-In: جمع‌آوری نتایج
    totalResult := 0
    for res := range results { // از Channel نتایج دریافت می‌کند تا زمانی که بسته شود
        totalResult += res
    }

    fmt.Printf("Total result: %d\n", totalResult)
    // برای numTasks = 10، نتایج: 2,4,6,8,10,12,14,16,18,20
    // مجموع: 110
}

در این مثال، `main` کارها را به `tasks` Channel ارسال می‌کند (`Fan-Out`). چندین `worker` Go Routine از این Channel کارها را دریافت، پردازش و نتایج را به `results` Channel ارسال می‌کنند. در نهایت، `main` (یا یک Go Routine دیگر) از `results` Channel نتایج را جمع‌آوری می‌کند (`Fan-In`). `WaitGroup` تضمین می‌کند که `main` قبل از اینکه همه کارها پردازش شوند، خارج نمی‌شود.

ب) Worker Pools (استخر کارگران)

Worker Pool یک الگوی رایج برای محدود کردن تعداد Go Routineهایی است که به طور همزمان اجرا می‌شوند. این کار زمانی مفید است که شما تعداد زیادی وظیفه دارید، اما نمی‌خواهید با راه‌اندازی Go Routineهای بسیار زیاد، منابع سیستم را بیش از حد مصرف کنید. در این الگو، شما تعداد ثابتی از Go Routines "کارگر" را راه‌اندازی می‌کنید که منتظر دریافت وظایف از یک Channel هستند.


package main

import (
    "fmt"
    "time"
)

// worker Go Routine که وظایف را از jobs Channel دریافت و نتایج را به results Channel ارسال می‌کند
func workerPool(id int, jobs <-chan int, results chan<- int) {
    for j := range jobs { // تا زمانی که Channel jobs بسته شود، وظایف را دریافت می‌کند
        fmt.Printf("Worker %d processing job %d\n", id, j)
        time.Sleep(time.Millisecond * 500) // شبیه‌سازی کار
        results <- j * 2 // نتیجه را به Channel results ارسال می‌کند
    }
    fmt.Printf("Worker %d finished all jobs\n", id)
}

func main() {
    const numJobs = 9
    const numWorkers = 3 // تعداد Go Routine های کارگر

    jobs := make(chan int, numJobs)
    results := make(chan int, numJobs)

    // راه‌اندازی Go Routine های کارگر
    for w := 1; w <= numWorkers; w++ {
        go workerPool(w, jobs, results)
    }

    // ارسال وظایف به Channel jobs
    for j := 1; j <= numJobs; j++ {
        jobs <- j
    }
    close(jobs) // پس از ارسال همه وظایف، Channel jobs را می‌بندیم

    // جمع‌آوری و چاپ نتایج
    for a := 1; a <= numJobs; a++ {
        <-results // فقط نتایج را دریافت می‌کنیم، ترتیب مهم نیست
    }
    fmt.Println("All jobs done, all results received.")

    // برای اطمینان از اینکه همه Go Routines فرصت اتمام را پیدا کنند
    time.Sleep(time.Second) 
}

در این الگو، ما سه Go Routine `workerPool` را راه‌اندازی کرده‌ایم. آن‌ها به طور همزمان از Channel `jobs` وظایف را دریافت و پردازش می‌کنند. این تضمین می‌کند که تنها سه وظیفه در هر زمان در حال پردازش هستند، حتی اگر ما ۹ وظیفه برای انجام داشته باشیم. این رویکرد به شما کمک می‌کند تا منابع سیستم را به طور موثرتری مدیریت کنید.

ج) Context Package: لغو، Time-out و مقادیر درخواستی

بسته `context` در Go یک ابزار حیاتی برای مدیریت لغو، Time-outها و ارسال مقادیر درختی از Go Routineها است. این بسته به خصوص در سرویس‌های وب که یک درخواست ممکن است شامل چندین Go Routine داخلی باشد، بسیار مفید است. `Context` به شما اجازه می‌دهد تا یک درخت از Go Routineها را مدیریت کنید و سیگنال‌های لغو را از یک والد به فرزندان منتقل کنید.

انواع اصلی `Context`:

  • `context.Background()`: Context پایه برای Go Routines اصلی، هرگز لغو نمی‌شود.
  • `context.TODO()`: استفاده می‌شود زمانی که مطمئن نیستید از چه Contextی استفاده کنید یا هنوز Context مناسبی ندارید.
  • `context.WithCancel(parent Context)`: یک Context جدید و یک تابع `cancel` برمی‌گرداند. فراخوانی `cancel` باعث لغو این Context و تمام فرزندان آن می‌شود.
  • `context.WithTimeout(parent Context, timeout time.Duration)`: مشابه `WithCancel` اما پس از یک مدت زمان مشخص یا فراخوانی `cancel`، لغو می‌شود.
  • `context.WithDeadline(parent Context, d time.Time)`: مشابه `WithTimeout` اما در یک زمان مشخص در آینده لغو می‌شود.
  • `context.WithValue(parent Context, key, val interface{})`: برای ارسال مقادیر (مثلاً ID درخواست) در طول درخت Context.

مثال استفاده از Context برای لغو:


package main

import (
    "context" // بسته context
    "fmt"
    "time"
)

func longRunningTask(ctx context.Context, taskID int) {
    fmt.Printf("Task %d started\n", taskID)
    select {
    case <-time.After(2 * time.Second): // شبیه‌سازی کار طولانی
        fmt.Printf("Task %d completed\n", taskID)
    case <-ctx.Done(): // اگر Context لغو شود
        fmt.Printf("Task %d cancelled: %v\n", taskID, ctx.Err())
    }
}

func main() {
    // ایجاد یک Context با قابلیت لغو
    ctx, cancel := context.WithCancel(context.Background())

    // راه‌اندازی Go Routine ها با پاس دادن Context
    go longRunningTask(ctx, 1)
    go longRunningTask(ctx, 2)

    fmt.Println("Main: Tasks started, waiting 1 second...")
    time.Sleep(1 * time.Second)

    fmt.Println("Main: Cancelling tasks...")
    cancel() // Context را لغو می‌کنیم، که به Go Routine ها سیگنال می‌دهد

    // کمی صبر می‌کنیم تا Go Routine ها فرصت پردازش لغو را پیدا کنند
    time.Sleep(1 * time.Second) 
    fmt.Println("Main: Program finished.")
}

در این مثال، `longRunningTask` یک Context را به عنوان پارامتر می‌گیرد. این Go Routine با استفاده از `select` منتظر اتمام کار خود یا دریافت سیگنال لغو از طریق `ctx.Done()` می‌ماند. با فراخوانی `cancel()` در `main`، تمام Go Routineهایی که از این Context یا فرزندان آن استفاده می‌کنند، سیگنال لغو را دریافت می‌کنند و می‌توانند به طور منظم خاتمه یابند. این یک راه قدرتمند برای مدیریت چرخه عمر Go Routineها و جلوگیری از Goroutine Leak است.

این الگوهای عملی نشان می‌دهند که چگونه Go Routines و Channels (همراه با ابزارهایی مانند `WaitGroup` و `Context`) می‌توانند برای ساخت سیستم‌های همزمان قدرتمند، انعطاف‌پذیر و مقاوم در Go مورد استفاده قرار گیرند. با ترکیب این ابزارها، می‌توانید به راحتی الگوهای پیچیده‌تر را پیاده‌سازی کرده و از تمام پتانسیل Go در برنامه‌های خود بهره‌برداری کنید.

۷. بهترین شیوه‌ها و نکات پیشرفته

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

الف) "Don't communicate by sharing memory; share memory by communicating." (CSP Principle)

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


// رویکرد بد: اشتراک‌گذاری حافظه بدون محافظت (مثال Race Condition قبلی)
var counter int
// go func() { counter++ }()

// رویکرد خوب: اشتراک‌گذاری حافظه از طریق ارتباط (Channels)
func incrementer(ch chan bool, done chan bool) {
    for {
        select {
        case <-ch:
            // انجام کار ایمن در یک Go Routine واحد
            // یا ارسال به یک Go Routine مرکزی برای به روز رسانی یک شمارنده
        case <-done:
            return
        }
    }
}

ب) ترجیح Channels بر Mutexes (در صورت امکان)

در حالی که Mutexes ابزارهای ضروری برای محافظت از داده‌های مشترک هستند، استفاده بیش از حد یا نادرست از آن‌ها می‌تواند منجر به Deadlockها و باگ‌های Race Condition شود. Channels اغلب یک راه‌حل تمیزتر و ایمن‌تر را فراهم می‌کنند، زیرا مدل پیام‌محور آن‌ها به طور ذاتی از بسیاری از مشکلات همزمانی جلوگیری می‌کند. هرچند، برای محافظت از ساختارهای داده‌ای که به ندرت تغییر می‌کنند یا در سناریوهای خاص (مانند پیاده‌سازی ساختارهای داده همزمان)، Mutexes همچنان ابزارهای مناسبی هستند.

ج) همیشه چرخه عمر Go Routineها را مدیریت کنید (پرهیز از Goroutine Leak)

یکی از رایج‌ترین مشکلات در برنامه‌های همزمان Go، Go Routine Leak است. Go Routineهایی که هرگز به پایان نمی‌رسند، منابع سیستم را مصرف می‌کنند و می‌توانند منجر به مشکلات عملکردی شوند. همیشه مطمئن شوید که Go Routineهایی که راه‌اندازی می‌کنید، در نهایت به طور منظم خاتمه می‌یابند. استفاده از `context.Context` برای ارسال سیگنال لغو، بستن Channels پس از اتمام کار و استفاده از `select` با Time-out یا `default` از جمله راهکارهای موثر هستند.


// مثال: Go Routine که به درستی مدیریت می‌شود
func doWork(ctx context.Context, ch chan int) {
    for {
        select {
        case val := <-ch:
            fmt.Println("Processing:", val)
        case <-ctx.Done(): // از طریق Context خاتمه می‌یابد
            fmt.Println("Worker shutting down.")
            return
        }
    }
}

د) تست کردن کد همزمان

تست کردن کدهای همزمان به دلیل غیرقطعی بودن (non-determinism) آن‌ها می‌تواند چالش‌برانگیز باشد. Race Conditionها ممکن است تنها تحت بارهای خاص یا در زمان‌های خاص ظاهر شوند. برای بهبود قابلیت اطمینان تست‌های همزمان:

  • **Race Detector:** همیشه تست‌های خود را با `go test -race` اجرا کنید تا Race Conditionهای پنهان را شناسایی کنید.
  • **Sleepهای تصادفی:** اضافه کردن `time.Sleep`های کوتاه و تصادفی در بخش‌های بحرانی (در کدهای تست نه تولید) می‌تواند به افشای مشکلات زمان‌بندی کمک کند، اما نباید به آن به عنوان یک راه‌حل تکیه کرد.
  • **تکرار (Repetition):** تست‌ها را چندین بار اجرا کنید، به خصوص آن‌هایی که شامل همزمانی هستند.
  • **استفاده از `sync.WaitGroup`:** برای اطمینان از اینکه همه Go Routineها قبل از اتمام تست کار خود را به پایان رسانده‌اند.
  • **Channels و `context`:** از Channels و `context` برای هماهنگ‌سازی و سیگنال‌دهی در تست‌های خود استفاده کنید.

ه) پروفایلینگ و مانیتورینگ

برای شناسایی گلوگاه‌های عملکردی و Go Routine Leakها، از ابزارهای پروفایلینگ Go (بسته `pprof`) استفاده کنید. `pprof` به شما اجازه می‌دهد تا مصرف CPU، حافظه، Go Routines مسدود شده و گلوگاه‌های Mutex را تجسم کنید. مانیتورینگ مداوم برنامه‌های Go در محیط تولید نیز برای شناسایی مشکلات همزمانی در زمان واقعی حیاتی است.


// برای فعال کردن pprof HTTP server در برنامه
import (
    _ "net/http/pprof" // import side effect
    "net/http"
)
// در تابع main یا یک Go Routine جداگانه
go func() {
    log.Println(http.ListenAndServe("localhost:6060", nil))
}()
// سپس در مرورگر به http://localhost:6060/debug/pprof/ مراجعه کنید

و) مدیریت خطا در کدهای همزمان

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

  • **بازگرداندن خطا از طریق Channel:** یک Go Routine می‌تواند خطاها را به یک Channel ارسال کند که توسط Go Routine اصلی یا یک Go Routine مدیریت خطا خوانده شود.
  • **استفاده از `errgroup`:** بسته `golang.org/x/sync/errgroup` ابزاری عالی برای مدیریت مجموعه Go Routineهایی است که ممکن است خطا برگردانند. `errgroup.Group` و `errgroup.Context` اجازه می‌دهند تا Go Routineها را راه‌اندازی کرده و در صورت بروز خطا در هر یک از آن‌ها، به بقیه سیگنال لغو ارسال کنند و اولین خطا را جمع‌آوری کنند.

package main

import (
    "fmt"
    "golang.org/x/sync/errgroup" // نیاز به go get golang.org/x/sync
    "time"
    "errors"
)

func fetchUser(id int) (string, error) {
    time.Sleep(time.Millisecond * time.Duration(100 + id * 50))
    if id == 3 {
        return "", errors.New("failed to fetch user 3")
    }
    return fmt.Sprintf("User%d", id), nil
}

func main() {
    g, ctx := errgroup.WithContext(context.Background())
    users := make(chan string, 5)

    for i := 1; i <= 5; i++ {
        id := i // ایجاد یک کپی محلی برای استفاده در Go Routine
        g.Go(func() error {
            select {
            case <-ctx.Done():
                fmt.Printf("Worker %d cancelled\n", id)
                return ctx.Err()
            default:
                user, err := fetchUser(id)
                if err != nil {
                    return err // اگر خطایی رخ دهد، errgroup آن را دریافت می‌کند و بقیه را لغو می‌کند
                }
                users <- user
                return nil
            }
        })
    }

    go func() {
        // بستن Channel users پس از اتمام همه Go Routineها
        defer close(users)
        g.Wait() // منتظر اتمام همه Go Routineها می‌مانیم
    }()

    if err := g.Wait(); err != nil { // منتظر می‌مانیم تا اولین خطا رخ دهد یا همه با موفقیت تمام شوند
        fmt.Printf("Encountered error: %v\n", err)
    } else {
        fmt.Println("All users fetched successfully.")
    }

    // چاپ کاربران (تا جایی که دریافت شده‌اند قبل از خطا)
    for len(users) > 0 {
        fmt.Println(<-users)
    }
}

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

نتیجه‌گیری

همانطور که در طول این پست جامع مشاهده کردیم، Go Routines و Channels سنگ بنای مدل همزمانی در زبان برنامه‌نویسی Go را تشکیل می‌دهند. این دو مفهوم، همراه با فلسفه "Don't communicate by sharing memory; share memory by communicating"، ابزارهایی قدرتمند و در عین حال ساده را برای توسعه‌دهندگان فراهم می‌کنند تا بتوانند برنامه‌هایی با کارایی بالا، مقیاس‌پذیر و مقاوم در برابر خطا بسازند.

ما آموختیم که چگونه Go Routines به عنوان Threadهای سبک وزن، از مزایای زمان‌بند بهینه Go Runtime بهره می‌برند و امکان اجرای همزمان هزاران عملیات را با سربار کم فراهم می‌کنند. همچنین، با Channels به عنوان راهی ایمن و Go-idiomatic برای ارتباط و همگام‌سازی بین Go Routines آشنا شدیم، که به طور طبیعی از بسیاری از Race Conditionها جلوگیری می‌کنند.

با بررسی الگوهای پیشرفته‌ای مانند `WaitGroup` برای هماهنگ‌سازی، `Mutex` برای محافظت از داده‌های مشترک و `select` برای مدیریت چندین Channel، دیدیم که چگونه می‌توان کنترل بیشتری بر جریان همزمانی داشت. همچنین، به طور مفصل به سه خطای رایج همزمانی – Race Condition، Deadlock و Goroutine Leak – پرداختیم و راهکارهای عملی برای تشخیص، پیشگیری و مدیریت آن‌ها را ارائه دادیم. در نهایت، با مثال‌های کاربردی از الگوهای Fan-Out/Fan-In، Worker Pools و اهمیت بسته `context`، نشان دادیم که چگونه این مفاهیم در سناریوهای واقعی به کار گرفته می‌شوند و با نگاهی به بهترین شیوه‌ها و نکات پیشرفته، مسیر را برای نوشتن کدهای همزمان بهتر در Go روشن کردیم.

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

“تسلط به برنامه‌نویسی پایتون با هوش مصنوعی: آموزش کدنویسی هوشمند با ChatGPT”

قیمت اصلی 2.290.000 ریال بود.قیمت فعلی 1.590.000 ریال است.

"تسلط به برنامه‌نویسی پایتون با هوش مصنوعی: آموزش کدنویسی هوشمند با ChatGPT"

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

ویژگی‌های کلیدی:

بدون نیاز به تجربه قبلی برنامه‌نویسی

زیرنویس فارسی با ترجمه حرفه‌ای

۳۰ ٪ تخفیف ویژه برای دانشجویان و دانش آموزان