یکی از ویژگیهای محبوب زبان Go حمایت درجه اول آن از همزمانی (concurrency) است، یعنی توانایی یک برنامه برای انجام چند کار به طور همزمان. توانایی اجرای کد به صورت همزمان بخش بزرگتری از برنامهنویسی شده است زیرا کامپیوترها به جای اجرای یک جریان کد سریعتر، جریانهای مختلف کد را به طور همزمان اجرا میکنند. برای اجرای سریعتر برنامهها، برنامهنویس باید برنامهاش را طوری طراحی کند که به صورت همزمان اجرا شود، به طوری که هر بخش همزمان برنامه بتواند به طور مستقل از سایر بخشها اجرا شود. دو ویژگی در Go به نام goroutineها و channelها باعث میشوند همزمانی بسیار سادهتر انجام شود. Goroutineها مشکل راهاندازی و اجرای کد همزمان را حل میکنند و channelها مشکل برقراری ارتباط ایمن بین بخشهای مختلف کد همزمان در حال اجرا را برطرف میکنند.
در این آموزش، شما هر دو مفهوم goroutine و channel را بررسی خواهید کرد. ابتدا برنامهای میسازید که از goroutineها برای اجرای چند تابع به صورت همزمان استفاده میکند. سپس channelها را به آن برنامه اضافه میکنید تا بین goroutineهای اجرا شده ارتباط برقرار شود. در نهایت، goroutineهای بیشتری اضافه میکنید تا شبیهسازی کنید که برنامه با چند worker goroutine اجرا میشود.
پیشنیازها
برای دنبال کردن این آموزش به موارد زیر نیاز دارید:
- یک کامپیوتر مدرن که پردازنده یا CPU آن بتواند چندین جریان کد را همزمان اجرا کند.
در یک کامپیوتر امروزی، پردازنده (CPU) به گونهای طراحی شده که تا حد امکان جریانهای مختلف کد را همزمان اجرا کند. این پردازندهها چندین «هسته» دارند که هر کدام قادر است یک جریان کد را به طور همزمان اجرا کند. پس هرچه برنامه بتواند جریانهای بیشتری از کد را به طور همزمان استفاده کند، برنامه سریعتر اجرا خواهد شد. اما برای بهرهبرداری از این افزایش سرعت، برنامهها نیاز دارند به چند بخش تقسیم شوند. تقسیم برنامه به بخشهای مختلف یکی از چالشهای بزرگ برنامهنویسی است، اما Go برای سادهتر کردن این کار طراحی شده است.
یکی از راههای Go برای انجام این کار ویژگی به نام goroutine است. goroutine نوعی خاصی از توابع است که میتواند در حالی که goroutineهای دیگر نیز در حال اجرا هستند، اجرا شود. وقتی برنامهای چندین جریان کد را در یک زمان اجرا میکند، گفته میشود برنامه به صورت همزمان (concurrent) اجرا میشود. معمولا وقتی تابعی صدا زده میشود، تا پایان اجرای آن تابع برنامه منتظر میماند و کد بعدی اجرا نمیشود. این حالت «اجرای foreground» نامیده میشود چون اجازه نمیدهد برنامه کاری غیر از اجرای تابع قبل انجام دهد. اما با goroutine، فراخوانی تابع بلافاصله به اجرای کد بعدی میرود در حالی که goroutine به صورت پسزمینه (background) اجرا میشود. کدی که در پسزمینه اجرا میشود، مانع اجرای کدهای بعدی نمیشود.
قدرت goroutineها در این است که هر goroutine میتواند به طور همزمان روی هسته مجزای پردازنده اجرا شود. اگر کامپیوتر شما چهار هسته پردازنده دارد و برنامه شما چهار goroutine دارد، همه این چهار goroutine میتوانند به طور همزمان اجرا شوند. وقتی چند جریان کد همزمان روی هستههای مختلف اجرا میشوند، به آن اجرای موازی (parallelism) گفته میشود.
برای تصور تفاوت همزمانی و اجرای موازی، به نمودار زیر توجه کنید. وقتی پردازنده تابعی را اجرا میکند، همیشه آن تابع را از ابتدا تا انتها یکجا اجرا نمیکند. گاهی سیستم عامل بخشهای دیگر یا برنامهها را بین بخشهای تابع اجرا میکند، مثلاً زمان انتظار برای خواندن فایل. نمودار نشان میدهد چگونه برنامههای همزمان میتوانند روی یک هسته یا چندین هسته اجرا شوند و اینکه در حالت اجرای موازی بخشهای بیشتری از goroutineها میتوانند در بازه زمانی معین اجرا شوند.

ستون سمت چپ به نام «Concurrency» نشان میدهد چگونه برنامهای که برای همزمانی طراحی شده میتواند روی یک هسته CPU اجرا شود؛ به طوری که بخشی از goroutine1 اجرا شود، سپس دیگری، سپس goroutine2 و دوباره goroutine1 ادامه مییابد. به کاربر نشان داده میشود که برنامه همه توابع را به طور همزمان اجرا میکند، ولی در واقع توابع پشت سر هم و بخش بخش اجرا میشوند.
ستون سمت راست به نام «Parallelism» نشان میدهد آن برنامه چگونه میتواند به طور موازی روی پردازندهای که دو هسته CPU دارد اجرا شود. هسته اول goroutine1 و هسته دوم goroutine2 را به صورت همزمان اجرا میکنند. گاهی هر دو goroutine1 و goroutine2 در همان لحظه اجرا میشوند اما روی هستههای جداگانه.
این نمودار خاصیت مقیاسپذیری (scalability) Go را دیرکان میکند. برنامهای مقیاسپذیر است که بتواند روی هر کامپیوتر با تعداد کمی هسته یا سرور بزرگ با دهها هسته اجرا شود و از منابع اضافی بهره ببرد. نمودار نشان میدهد با استفاده از goroutineها، برنامه همزمان شما قادر است روی یک هسته اجرا شود، اما با اضافه شدن هستههای بیشتر، goroutineهای بیشتری به صورت موازی اجرا شده و سرعت برنامه افزایش مییابد.
برای شروع برنامه همزمان جدید، یک دایرکتوری به نام multifunc در هر مکان دلخواه ایجاد کنید. اگر دایرکتوری پروژه دارید، میتوانید از همان استفاده کنید، اما در این آموزش فرض میکنیم یک دایرکتوری به نام projects ساختهاید.
اگر از خط فرمان استفاده میکنید، ابتدا دایرکتوری projects را ساخته و وارد آن شوید:
// shell commands
mkdir projects
cd projects
از دایرکتوری projects، با دستور mkdir دایرکتوری برنامه multifunc را ساخته و وارد آن شوید:
// shell commands
mkdir multifunc
cd multifunc
در دایرکتوری multifunc، فایل main.go را با nano یا ویرایشگر دلخواه باز کنید:
nano main.go
کد زیر را در main.go قرار دهید یا تایپ کنید:
package main
import "fmt"
func generateNumbers(amount int) {
for i := 1; i <= amount; i++ {
fmt.Println("Generate:", i)
}
}
func printNumbers() {
for i := 1; i <= 3; i++ {
fmt.Println("Print:", i)
}
}
func main() {
printNumbers()
generateNumbers(3)
}
پس از ذخیره، برنامه را با دستور زیر اجرا کنید:
go run main.go
خروجی برنامه مشابه این خواهد بود که ابتدا printNumbers و سپس generateNumbers اجرا شدهاند:
Print: 1
Print: 2
Print: 3
Generate: 1
Generate: 2
Generate: 3
حال فرض کنید که هر دو تابع printNumbers و generateNumbers هر کدام ۳ ثانیه طول میکشند تا اجرا شوند. اگر برنامه به صورت همزمان (synchronously) اجرا شود، کل زمان اجرا ۶ ثانیه خواهد بود. اما این دو تابع مستقل از یکدیگرند و میتوانند همزمان اجرا شوند که در این صورت کل زمان اجرا در بهترین حالت ۳ ثانیه خواهد بود (البته عوامل مختلفی روی زمان تاثیر میگذارند مثل تعداد هستهها و سایر برنامههای در حال اجرا).
اجرای یک تابع همزمان به صورت goroutine مشابه اجرای همزمان معمولی است، فقط کافیست پیشوند go به فراخوانی تابع اضافه کنید.
برای اجرای دو تابع به صورت همزمان با goroutineها، باید برنامه منتظر بماند تا هر دو آنها کامل شوند. اگر main صبر نکند، ممکن است goroutineها پیش از کامل شدن برنامه تمام شوند یا فقط بخشی از آنها اجرا شود.
برای انتظار، از WaitGroup در پکیج sync استفاده میکنیم. WaitGroup شمارش میکند که چند کار باید تمام شوند. Add تعداد را زیاد میکند، Done یکی کم میکند و Wait منتظر میماند تا شمارش به صفر برسد.
کد زیر را در main.go قرار دهید:
package main
import (
"fmt"
"sync"
)
func generateNumbers(amount int, wg *sync.WaitGroup) {
defer wg.Done()
for i := 1; i <= amount; i++ {
fmt.Println("Generate:", i)
}
}
func printNumbers(wg *sync.WaitGroup) {
defer wg.Done()
for i := 1; i <= 3; i++ {
fmt.Println("Print:", i)
}
}
func main() {
var wg sync.WaitGroup
wg.Add(2)
go printNumbers(&wg)
go generateNumbers(3, &wg)
wg.Wait()
}
برنامه را مجددا اجرا کنید:
go run main.go
خروجی احتمالا به صورت ترکیب نامرتب از هر دو تابع خواهد بود، چون هر دو همزمان اجرا میشوند:
Print: 1
Generate: 1
Print: 2
Generate: 2
Generate: 3
Print: 3
اگر در main تابع wg.Wait() را حذف کنید و دوباره اجرا نمایید، احتمالاً کد شما به طور کامل اجرا نمیشود چون برنامه قبل از اجرای کامل goroutineها تمام میشود.
در این مرحله دو تابع را به صورت goroutine اجرا کردید و با WaitGroup برنامه را منتظر اتمام هر دو نگه داشتید.
متوجه خواهید شد generateNumbers و printNumbers هیچ مقداری برنمیگردانند. goroutineها نمیتوانند مقدار بازگردانند چون اگر تابعی که return دارد به صورت goroutine اجرا شود، مقدار برگشتی نادیده گرفته میشود. حالا سوال این است اگر نیاز باشد دادهای بین goroutineها رد و بدل شود، چکار باید کرد؟ پاسخ استفاده از channelهاست. اینها وسیلهای برای ارسال داده بین goroutineها هستند.
ارتباط ایمن بین بخشهای همزمان برنامه یکی دشواریهای بزرگ نوشتن برنامه چند-رشتهای است. به عنوان مثال data race یا رقابت دادهای وقتی رخ میدهد که دو بخش به صورت همزمان بخواهند به یک متغیر دسترسی داشته باشند و یکی در حال نوشتن و دیگری در حال خواندن آن باشد که میتواند باعث دادههای اشتباه شود.
Go ابزارهایی برای حل این مشکل دارد، از جمله channelها. channel مانند یک لوله بین goroutineهاست که دادهها از یک سر وارد و از سر دیگر خارج میشوند. ارسال و دریافت داده از کانالها با عملگر <- انجام میشود.
ساختن channel با تابع داخلی make() انجام میشود:
ch := make(chan int)
نوشتن داده روی channel به شکل زیر است:
ch <- 5
خواندن داده از channel:
value := <-ch
برای راحتی میتوانید به یاد داشته باشید که پیکان <- همیشه به سمت چپ است و نشاندهنده جهتی است که داده میرود؛ وقتی داده مینویسید، arrows به channel اشاره میکند؛ وقتی داده را میخوانید، از channel به متغیر اشاره میکند.
میتوانید کانال را در حلقه با استفاده از range بخوانید:
for num := range ch {
fmt.Println(num)
}
میتوان کانال را محدود به فقط خواندن یا فقط نوشتن کرد با اضافه کردن arrow روی نوع، یعنی <-chan int فقط خواندنی و chan<- int فقط نوشتنی است. این محدودیتها کمک میکنند صدا زدن اشتباهها کاهش یابد که باعث deadlock نشود.
وقتی دیگر نیازی به کانال نباشد باید آن را با close() ببندید تا حافظه آزاد شود و خواندن کانال با for…range پایان یابد. این کار از بروز memory leak جلوگیری میکند.
کد main.go را به گونهای تغییر دهید که از chan int برای ارتباط بین goroutineها استفاده کند. تابع generateNumbers اعداد را تولید میکند و در channel مینویسد و printNumbers اعداد را از channel میخواند و چاپ میکند. generateNumbers دیگر goroutine نیست و زمانی که تمام شد، کانال بسته میشود تا حلقه printNumbers پایان یابد و برنامه به درستی تمام شود.
package main
import (
"fmt"
"sync"
)
func generateNumbers(amount int, ch chan<- int) {
for i := 1; i <= amount; i++ {
ch <- i
}
// close the channel after sending all numbers
close(ch)
}
func printNumbers(id int, ch <-chan int, wg *sync.WaitGroup) {
defer wg.Done()
for num := range ch {
fmt.Printf("Printer %d: %d\n", id, num)
}
}
func main() {
var wg sync.WaitGroup
numberChan := make(chan int)
wg.Add(1)
go printNumbers(1, numberChan, &wg)
generateNumbers(3, numberChan)
wg.Wait()
}
با اجرای این برنامه خروجی به شکل زیر است:
Printer 1: 1
Printer 1: 2
Printer 1: 3
برنامه اعداد ۱ تا ۳ را در کانال میفرستد و یک goroutine دارد که اعداد را میخواند و نمایش میدهد. وقتی generateNumbers تمام شد، کانال بسته میشود و حلقه printNumbers خاتمه مییابد.
حالا برنامه را باز کرده و تغییر دهید که چند goroutine برای printNumbers ایجاد کنید، هر کدام یک شماره شناسه داشته باشند. همچنین تعداد اعداد تولید شده را به ۵ افزایش دهید. تغییرات زیر را اعمال کنید:
package main
import (
"fmt"
"sync"
"time"
)
func generateNumbers(amount int, ch chan<- int) {
for i := 1; i <= amount; i++ {
ch <- i
time.Sleep(100 * time.Millisecond) // Simulate work
}
close(ch)
}
func printNumbers(id int, ch <-chan int, wg *sync.WaitGroup) {
defer wg.Done()
for num := range ch {
fmt.Printf("Printer %d: %d\n", id, num)
}
}
func main() {
var wg sync.WaitGroup
numberChan := make(chan int)
numPrinters := 3
wg.Add(numPrinters)
for i := 1; i <= numPrinters; i++ {
go printNumbers(i, numberChan, &wg)
}
generateNumbers(5, numberChan)
wg.Wait()
}
بعد از اجرای مجدد برنامه متوجه میشوید که اعداد به صورت نامنظم بین goroutineهای مختلف چاپ میشوند، زیرا چند goroutine به طور همزمان از رشته مشترک خواندهاند:
Printer 1: 1
Printer 2: 2
Printer 3: 3
Printer 1: 4
Printer 3: 5
به خاطر داشته باشید افزایش زیاد تعداد goroutineها همیشه به افزایش کارایی منجر نمیشود. ممکن است به resource starvation منجر شود، زیرا زمان سوییچ بین آنها از زمان اجرای خود کد بیشتر شود. بهترین نقطه شروع تعداد goroutineها برابر تعداد هستههای CPU است.
ترکیب goroutineها و channelها باعث میشود برنامههای بسیار قدرتمندی بسازید که روی کامپیوترهای کوچک یا سرورهای بزرگ به خوبی مقیاسپذیر باشند. شما برنامههایی خواهید داشت که از فورک و ارتباط بدون پیچیدگیهای معمول برنامههای چند موضوعی بهره میبرد.
در این آموزش، با کلیدواژه go تابعها را به صورت concurrent اجرا کردید، سپس کانال ساخته و در یک goroutine اعداد تولید شد و در چند goroutine دیگر از کانال خوانده و چاپ شد.
برای یادگیری بیشتر درباره concurrency در Go مستندات Effective Go مناسب است. همچنین مطلب Concurrency is not parallelism مقاله خوبی درباره تفاوت این دو مفهوم است.
از همراهی شما با پارمین کلود متشکریم.
نظرات کاربران