1.1 دید کلی از کانال‌ها

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

1.2 ایجاد و بستن کانال‌ها

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

ch := make(chan int)    // ایجاد یک کانال بدون بافر از نوع int
chBuffered := make(chan int, 10) // ایجاد یک کانال با بافر با ظرفیت 10 از نوع int

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

close(ch) // بستن کانال

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

ارسال و دریافت داده در یک کانال ساده است و از سمبل <- استفاده می‌کند. عمل ارسال در سمت چپ و عمل دریافت در سمت راست است.

ch <- 3 // ارسال داده به کانال
value := <- ch // دریافت داده از کانال

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

fmt.Println(<-ch) // این دستور تا زمانی که داده از ch ارسال نشود، مسدود خواهد شد

2 استفاده پیشرفته از کانال‌ها

2.1 ظرفیت و بافرکردن کانال‌ها

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

ch := make(chan int) // ایجاد یک کانال بدون بافر
go func() {
    ch <- 1 // این عمل اگر گوروتین دیگری برای دریافت وجود نداشته باشد، مسدود خواهد شد
}()

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

ch := make(chan int, 10) // ایجاد یک کانال بافر دار با ظرفیت 10
go func() {
    for i := 0; i < 10; i++ {
        ch <- i // این دستور فقط در صورتی مسدود نمی‌شود که کانال از قبل پر باشد
    }
    close(ch) // بستن کانال پس از اتمام ارسال
}()

انتخاب نوع کانال بستگی به ماهیت ارتباطات دارد: آیا نیاز به تضمین هماهنگی وجود دارد؟ آیا نیاز به بافرزنی است؟ و نیاز‌های عملکردی، و غیره.

2.2 استفاده از دستور select

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

ch1 := make(chan int)
ch2 := make(chan int)

go func() {
    for i := 0; i < 5; i++ {
        ch1 <- i
    }
}()

go func() {
    for i := 0; i < 5; i++ {
        ch2 <- i * 10
    }
}()

for i := 0; i < 5; i++ {
    select {
    case v1 := <-ch1:
        fmt.Println("دریافت از ch1:", v1)
    case v2 := <-ch2:
        fmt.Println("دریافت از ch2:", v2)
    }
}

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

2.3 حلقه محدوده برای کانال‌ها

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

ch := make(chan int)

go func() {
    for i := 0; i < 5; i++ {
        ch <- i
    }
    close(ch) // حتما به یاد داشته باشید که کانال را ببندید
}()

for n := range ch {
    fmt.Println("دریافت شد:", n)
}

وقتی کانال بسته شود و داده‌ای باقی نماند، حلقه به پایان می‌رسد. اگر کانال را فراموش کنید ببندید، range باعث یک نشتی گوروتین می‌شود و برنامه ممکن است برای برسی داده به طور نامحدود منتظر بماند.

3. رسیدگی به شرایط پیچیده در همزمانی

3.1 نقش Context

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

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

در این‌جا از context برای رسیدن به این نیاز استفاده می‌کنیم:

package main

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

func operation1(ctx context.Context) {
	time.Sleep(1 * time.Second)
	select {
	case <-ctx.Done():
		fmt.Println("عملیات 1 لغو شد")
		return
	default:
		fmt.Println("عملیات 1 تکمیل شد")
	}
}

func operation2(ctx context.Context) {
	time.Sleep(2 * time.Second)
	select {
	case <-ctx.Done():
		fmt.Println("عملیات 2 لغو شد")
		return
	default:
		fmt.Println("عملیات 2 تکمیل شد")
	}
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
	defer cancel()

go operation1(ctx)
go operation2(ctx)

	<-ctx.Done()
	fmt.Println("اصلی: context تمام شد")
}

در کد فوق، context.WithTimeout برای ایجاد یک Context که به طور خودکار پس از یک زمان مشخص لغو می‌شود استفاده شده است. توابع operation1 و operation2 یک بلوک select را که به ctx.Done() گوش می‌دهند دارند و این به آن‌ها امکان می‌دهد که در همان لحظه که Context یک سیگنال لغو ارسال می‌کند فوراً متوقف شوند.

3.2 رسیدگی به خطاها با استفاده از کانال‌ها

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

مثال کد زیر نشان می‌دهد که چگونه خطاها از یک گوروتین خارج شده و در گوروتین اصلی رسیدگی به آن‌ها انجام می‌شود:

package main

import (
	"errors"
	"fmt"
	"time"
)

func performTask(id int, errCh chan<- error) {
	// شبیه‌سازی یک وظیفه که ممکن است به صورت تصادفی موفق یا ناموفق باشد
	if id%2 == 0 {
		time.Sleep(2 * time.Second)
		errCh <- errors.New("وظیفه ناموفق بود")
	} else {
		fmt.Printf("وظیفه %d با موفقیت انجام شد\n", id)
		errCh <- nil
	}
}

func main() {
	tasks := 5
	errCh := make(chan error, tasks)

	for i := 0; i < tasks; i++ {
		go performTask(i, errCh)
	}

	for i := 0; i < tasks; i++ {
		err := <-errCh
		if err != nil {
			fmt.Printf("خطا دریافت شد: %s\n", err)
		}
	}
	fmt.Println("همه وظایف پردازش شدند")
}

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

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