فارسی

راهنمای جامع ویژگی‌های همزمانی در Go، با بررسی Goroutineها و Channelها به همراه مثال‌های کاربردی برای ساخت برنامه‌های کارآمد و مقیاس‌پذیر.

همزمانی در Go: آزاد کردن قدرت Goroutineها و Channelها

زبان Go، که اغلب Golang نیز نامیده می‌شود، به دلیل سادگی، کارایی و پشتیبانی داخلی از همزمانی شهرت دارد. همزمانی به برنامه‌ها اجازه می‌دهد تا چندین وظیفه را به ظاهر به طور همزمان اجرا کنند و عملکرد و پاسخ‌دهی را بهبود بخشند. Go این کار را از طریق دو ویژگی کلیدی انجام می‌دهد: goroutineها و channelها. این پست وبلاگ به بررسی جامع این ویژگی‌ها می‌پردازد و مثال‌های عملی و بینش‌هایی را برای توسعه‌دهندگان در تمام سطوح ارائه می‌دهد.

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

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

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

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

Goroutineها: نخ‌های (Thread) سبک

یک goroutine یک تابع با اجرای مستقل و سبک است. آن را مانند یک نخ (thread) در نظر بگیرید، اما بسیار کارآمدتر. ایجاد یک goroutine فوق‌العاده ساده است: فقط کافی است قبل از فراخوانی تابع، کلمه کلیدی `go` را قرار دهید.

ایجاد Goroutineها

در اینجا یک مثال ساده آورده شده است:

package main

import (
	"fmt"
	"time"
)

func sayHello(name string) {
	for i := 0; i < 5; i++ {
		fmt.Printf("Hello, %s! (Iteration %d)\n", name, i)
		time.Sleep(100 * time.Millisecond)
	}
}

func main() {
	go sayHello("Alice")
	go sayHello("Bob")

	// کمی صبر می‌کنیم تا گوروتین‌ها فرصت اجرا داشته باشند
	time.Sleep(500 * time.Millisecond)
	fmt.Println("Main function exiting")
}

در این مثال، تابع `sayHello` به عنوان دو goroutine مجزا راه‌اندازی می‌شود، یکی برای "Alice" و دیگری برای "Bob". `time.Sleep` در تابع `main` برای اطمینان از این است که goroutineها زمان کافی برای اجرا قبل از خروج تابع اصلی را داشته باشند، مهم است. بدون آن، برنامه ممکن است قبل از تکمیل goroutineها خاتمه یابد.

مزایای Goroutineها

Channelها: ارتباط بین Goroutineها

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

ایجاد Channelها

Channelها با استفاده از تابع `make` ایجاد می‌شوند:

ch := make(chan int) // یک کانال ایجاد می‌کند که می‌تواند اعداد صحیح را منتقل کند

شما همچنین می‌توانید channelهای بافر شده (buffered) ایجاد کنید که می‌توانند تعداد مشخصی از مقادیر را بدون آماده بودن گیرنده، نگه دارند:

ch := make(chan int, 10) // یک کانال بافر شده با ظرفیت 10 ایجاد می‌کند

ارسال و دریافت داده

داده‌ها با استفاده از عملگر `<-` به یک channel ارسال می‌شوند:

ch <- 42 // مقدار 42 را به کانال ch ارسال می‌کند

داده‌ها از یک channel نیز با استفاده از عملگر `<-` دریافت می‌شوند:

value := <-ch // یک مقدار از کانال ch دریافت کرده و به متغیر value اختصاص می‌دهد

مثال: استفاده از Channelها برای هماهنگی Goroutineها

در اینجا مثالی است که نشان می‌دهد چگونه می‌توان از channelها برای هماهنگی goroutineها استفاده کرد:

package main

import (
	"fmt"
	"time"
)

func worker(id int, jobs <-chan int, results chan<- int) {
	for j := range jobs {
		fmt.Printf("Worker %d started job %d\n", id, j)
		time.Sleep(time.Second)
		fmt.Printf("Worker %d finished job %d\n", id, j)
		results <- j * 2
	}
}

func main() {
	jobs := make(chan int, 100)
	results := make(chan int, 100)

	// ۳ گوروتین کارگر (worker) را راه‌اندازی می‌کنیم
	for w := 1; w <= 3; w++ {
		go worker(w, jobs, results)
	}

	// ۵ کار (job) را به کانال jobs ارسال می‌کنیم
	for j := 1; j <= 5; j++ {
		jobs <- j
	}
	close(jobs)

	// نتایج را از کانال results جمع‌آوری می‌کنیم
	for a := 1; a <= 5; a++ {
		fmt.Println("Result:", <-results)
	}
}

در این مثال:

این مثال نشان می‌دهد که چگونه می‌توان از channelها برای توزیع کار بین چندین goroutine و جمع‌آوری نتایج استفاده کرد. بستن channel `jobs` برای اعلام به goroutineهای کارگر که دیگر کاری برای پردازش وجود ندارد، حیاتی است. بدون بستن channel، goroutineهای کارگر به طور نامحدود منتظر کارهای بیشتر مسدود می‌شدند.

دستور Select: مالتی‌پلکسینگ روی چندین Channel

دستور `select` به شما امکان می‌دهد تا به طور همزمان روی چندین عملیات channel منتظر بمانید. این دستور تا زمانی که یکی از caseها آماده اجرا شود، مسدود می‌شود. اگر چندین case آماده باشند، یکی به صورت تصادفی انتخاب می‌شود.

مثال: استفاده از Select برای مدیریت چندین Channel

package main

import (
	"fmt"
	"time"
)

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

	go func() {
		time.Sleep(2 * time.Second)
		c1 <- "Message from channel 1"
	}()

	go func() {
		time.Sleep(1 * time.Second)
		c2 <- "Message from channel 2"
	}()

	for i := 0; i < 2; i++ {
		select {
		case msg1 := <-c1:
			fmt.Println("Received:", msg1)
		case msg2 := <-c2:
			fmt.Println("Received:", msg2)
		case <-time.After(3 * time.Second):
			fmt.Println("Timeout")
			return
		}
	}
}

در این مثال:

دستور `select` ابزاری قدرتمند برای مدیریت چندین عملیات همزمان و جلوگیری از مسدود شدن نامحدود روی یک channel است. تابع `time.After` به ویژه برای پیاده‌سازی وقفه‌های زمانی و جلوگیری از بن‌بست (deadlock) مفید است.

الگوهای رایج همزمانی در Go

ویژگی‌های همزمانی Go به چندین الگوی رایج منجر می‌شوند. درک این الگوها می‌تواند به شما در نوشتن کدهای همزمان قوی‌تر و کارآمدتر کمک کند.

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

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

Fan-out, Fan-in (پخش و تجمیع)

این الگو شامل توزیع کار به چندین goroutine (fan-out) و سپس ترکیب نتایج در یک channel واحد (fan-in) است. این الگو اغلب برای پردازش موازی داده‌ها استفاده می‌شود.

Fan-Out: چندین goroutine برای پردازش همزمان داده‌ها ایجاد می‌شوند. هر goroutine بخشی از داده‌ها را برای پردازش دریافت می‌کند.

Fan-In: یک goroutine واحد نتایج را از تمام goroutineهای کارگر جمع‌آوری کرده و آنها را در یک نتیجه واحد ترکیب می‌کند. این کار اغلب شامل استفاده از یک channel برای دریافت نتایج از کارگران است.

سناریوهای مثال:

پایپ‌لاین‌ها (Pipelines)

یک پایپ‌لاین مجموعه‌ای از مراحل است که در آن هر مرحله داده‌ها را از مرحله قبل پردازش کرده و نتیجه را به مرحله بعد ارسال می‌کند. این برای ایجاد گردش‌های کاری پیچیده پردازش داده مفید است. هر مرحله معمولاً در goroutine خود اجرا می‌شود و از طریق channelها با مراحل دیگر ارتباط برقرار می‌کند.

موارد استفاده مثال:

مدیریت خطا در برنامه‌های همزمان Go

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

مثال: مدیریت خطا با Channelها

package main

import (
	"fmt"
	"time"
)

func worker(id int, jobs <-chan int, results chan<- int, errs chan<- error) {
	for j := range jobs {
		fmt.Printf("Worker %d started job %d\n", id, j)
		time.Sleep(time.Second)
		fmt.Printf("Worker %d finished job %d\n", id, j)
		if j%2 == 0 { // شبیه‌سازی خطا برای اعداد زوج
			errs <- fmt.Errorf("Worker %d: Job %d failed", id, j)
			results <- 0 // ارسال یک نتیجه جایگزین
		} else {
			results <- j * 2
		}
	}
}

func main() {
	jobs := make(chan int, 100)
	results := make(chan int, 100)
	errs := make(chan error, 100)

	// ۳ گوروتین کارگر را راه‌اندازی می‌کنیم
	for w := 1; w <= 3; w++ {
		go worker(w, jobs, results, errs)
	}

	// ۵ کار را به کانال jobs ارسال می‌کنیم
	for j := 1; j <= 5; j++ {
		jobs <- j
	}
	close(jobs)

	// نتایج و خطاها را جمع‌آوری می‌کنیم
	for a := 1; a <= 5; a++ {
		select {
		case res := <-results:
			fmt.Println("Result:", res)
		case err := <-errs:
			fmt.Println("Error:", err)
		}
	}
}

در این مثال، ما یک channel به نام `errs` اضافه کردیم تا پیام‌های خطا را از goroutineهای کارگر به تابع main منتقل کند. goroutine کارگر یک خطا را برای کارهای با شماره زوج شبیه‌سازی می‌کند و یک پیام خطا را روی channel `errs` ارسال می‌کند. سپس تابع main از یک دستور `select` برای دریافت نتیجه یا خطا از هر goroutine کارگر استفاده می‌کند.

اولیه های همگام‌سازی: Mutexها و WaitGroupها

در حالی که channelها راه ترجیحی برای ارتباط بین goroutineها هستند، گاهی اوقات شما به کنترل مستقیم‌تری بر منابع مشترک نیاز دارید. Go برای این منظور اولیه‌های همگام‌سازی مانند mutexها و waitgroupها را فراهم می‌کند.

Mutexها

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

package main

import (
	"fmt"
	"sync"
)

var ( // منبع مشترک
	counter int
	m sync.Mutex
)

func increment() {
	m.Lock() // قفل را به دست می‌آوریم
	counter++
	fmt.Println("Counter incremented to:", counter)
	m.Unlock() // قفل را آزاد می‌کنیم
}

func main() {
	var wg sync.WaitGroup

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

	wg.Wait() // منتظر می‌مانیم تا تمام گوروتین‌ها تمام شوند
	fmt.Println("Final counter value:", counter)
}

در این مثال، تابع `increment` از یک mutex برای محافظت از متغیر `counter` در برابر دسترسی همزمان استفاده می‌کند. متد `m.Lock()` قبل از افزایش شمارنده قفل را به دست می‌آورد و متد `m.Unlock()` پس از افزایش شمارنده قفل را آزاد می‌کند. این تضمین می‌کند که فقط یک goroutine می‌تواند در یک زمان شمارنده را افزایش دهد و از شرایط رقابتی داده جلوگیری می‌کند.

WaitGroupها

یک waitgroup برای منتظر ماندن برای اتمام مجموعه‌ای از goroutineها استفاده می‌شود. این سه متد را فراهم می‌کند:

در مثال قبلی، `sync.WaitGroup` تضمین می‌کند که تابع main قبل از چاپ مقدار نهایی شمارنده، منتظر اتمام تمام 100 goroutine بماند. `wg.Add(1)` شمارنده را برای هر goroutine راه‌اندازی شده افزایش می‌دهد. `defer wg.Done()` شمارنده را هنگام تکمیل یک goroutine کاهش می‌دهد و `wg.Wait()` تا زمانی که همه goroutineها به پایان برسند (شمارنده به صفر برسد) مسدود می‌شود.

Context: مدیریت Goroutineها و لغو عملیات

پکیج `context` راهی برای مدیریت goroutineها و انتشار سیگنال‌های لغو فراهم می‌کند. این به ویژه برای عملیات طولانی‌مدت یا عملیاتی که باید بر اساس رویدادهای خارجی لغو شوند، مفید است.

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

package main

import (
	"context"
	"fmt"
	"time"
)

func worker(ctx context.Context, id int) {
	for {
		select {
		case <-ctx.Done():
			fmt.Printf("Worker %d: Canceled\n", id)
			return
		default:
			fmt.Printf("Worker %d: Working...\n", id)
			time.Sleep(time.Second)
		}
	}
}

func main() {
	ctx, cancel := context.WithCancel(context.Background())

	// ۳ گوروتین کارگر را راه‌اندازی می‌کنیم
	for w := 1; w <= 3; w++ {
		go worker(ctx, w)
	}

	// کانتکست را پس از 5 ثانیه لغو می‌کنیم
	time.Sleep(5 * time.Second)
	fmt.Println("Canceling context...")
	cancel()

	// کمی منتظر می‌مانیم تا کارگران خارج شوند
	time.Sleep(2 * time.Second)
	fmt.Println("Main function exiting")
}

در این مثال:

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

کاربردهای دنیای واقعی همزمانی در Go

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

بهترین شیوه‌ها برای همزمانی در Go

در اینجا برخی از بهترین شیوه‌ها برای به خاطر سپردن هنگام نوشتن برنامه‌های همزمان Go آورده شده است:

نتیجه‌گیری

ویژگی‌های همزمانی Go، به ویژه goroutineها و channelها، راهی قدرتمند و کارآمد برای ساخت برنامه‌های همزمان و موازی فراهم می‌کنند. با درک این ویژگی‌ها و پیروی از بهترین شیوه‌ها، می‌توانید برنامه‌های قوی، مقیاس‌پذیر و با کارایی بالا بنویسید. توانایی استفاده مؤثر از این ابزارها یک مهارت حیاتی برای توسعه نرم‌افزار مدرن است، به ویژه در سیستم‌های توزیع‌شده و محیط‌های رایانش ابری. طراحی Go نوشتن کدهای همزمانی را که هم برای درک آسان و هم برای اجرای کارآمد هستند، ترویج می‌کند.