1. نقش مکانیسمهای همگامسازی
در برنامهنویسی همروند، زمانی که چند گوروتین منابع را به اشتراک میگذارند، لازم است اطمینان حاصل شود که منابع تنها توسط یک گوروتین در هر زمان دسترسی داشته باشند تا از وقوع شرایط تداخلی جلوگیری شود. برای این کار نیاز به استفاده از مکانیسمهای همگامسازی است. مکانیسمهای همگامسازی میتوانند ترتیب دسترسی گوروتینهای مختلف به منابع اشتراکی را هماهنگ کنند، به منظور تضمین سازگاری داده و همگامسازی وضعیت در یک محیط همروند.
زبان Go یک مجموعه غنی از مکانیسمهای همگامسازی فراهم میکند که شامل اما محدود به موردهای زیر است:
- قفلها (sync.Mutex) و قفلهای خواندنی-نوشتنی (sync.RWMutex)
- کانالها
- گروههای انتظار (WaitGroups)
- توابع اتمی (بستهی atomic)
- متغیرهای شرایطی (sync.Cond)
2. ابزارهای همگامسازی
2.1 Mutex (sync.Mutex)
2.1.1 مفهوم و نقش قفلها
قفل یک مکانیسم همگامسازی است که با اجازه دادن تنها به یک گوروتین برای نگهداری قفل و دسترسی به منبع اشتراکی در هر زمان، اطمینان از عملکرد ایمن منابع اشتراکی را فراهم میکند. قفل از طریق متدهای Lock
و Unlock
همگامسازی را دستیابی میکند. فراخوانی متد Lock
تا زمانی که قفل آزاد نشده است، مسدود خواهد بود و در این نقطه، سایر گوروتینها که سعی در تصرف قفل کردند، منتظر میمانند. فراخوانی Unlock
قفل را آزاد میکند و اجازه میدهد که گوروتینهای منتظر آن را تصرف کنند.
var mu sync.Mutex
func criticalSection() {
// قفل را برای دسترسی انحصاری به منبع گرفتهایم
mu.Lock()
// اینجا به منبع اشتراکی دسترسی داریم
// ...
// قفل را آزاد کرده و اجازه میدهیم که گوروتینهای دیگر آن را تصرف کنند
mu.Unlock()
}
2.1.2 استفادههای عملی از قفل
فرض کنید نیاز داریم تا یک شمارنده سراسری را حفظ کنیم و چندین گوروتین نیاز دارند تا مقدار آن را افزایش دهند. استفاده از یک قفل میتواند برای اطمینان از دقت شمارنده استفاده شود.
var (
mu sync.Mutex
counter int
)
func increment() {
mu.Lock() // قفل را قبل از اصلاح شمارنده قفل میکنیم
counter++ // مقدار شمارنده را به صورت ایمن افزایش میدهیم
mu.Unlock() // پس از عملیات، قفل را آزاد میکنیم تا گوروتینهای دیگر به شمارنده دسترسی یابند
}
func main() {
for i := 0; i < 10; i++ {
go increment() // چندین گوروتین را برای افزایش مقدار شمارنده شروع میکنیم
}
// برای مدتی منتظر بمانیم (در عمل، باید از WaitGroup یا روشهای دیگر برای انتظار برای تمامی گوروتینها استفاده کنید)
time.Sleep(1 * time.Second)
fmt.Println(counter) // مقدار شمارنده را خروجی دهید
}
2.2 قفل خواندنی-نوشتنی (sync.RWMutex)
2.2.1 مفهوم قفل خواندنی-نوشتنی
RWMutex نوعی خاص از قفل است که به چندین گوروتین اجازه میدهد تا به صورت همزمان منابع اشتراکی را بخوانند، در حالی که عملیات نوشتن انحصاری است. نسبت به قفلها، قفلهای خواندنی-نوشتنی میتوانند عملکرد را در صورتهای چندخواننده روی منابع بهبود بخشند. این دارای چهار متد RLock
، RUnlock
برای قفل کردن و بازکردن عملیاتهای خواندن و Lock
، Unlock
برای قفل کردن و بازکردن عملیاتهای نوشتن است.
2.2.2 موارد کاربردی عملی از RWMutex
در یک برنامه پایگاهداده، عملیاتهای خواندن ممکن است از عملیاتهای نوشتن بسیار بیشتر باشند. استفاده از یک قفل خواندنی-نوشتنی میتواند باعث بهبود عملکرد سیستم شود زیرا به چندین گوروتین اجازه میدهد تا به صورت همزمان بخوانند.
var (
rwMu sync.RWMutex
data int
)
func readData() int {
rwMu.RLock() // قفل خواندن را گرفته و اجازه میدهد که عملیاتهای خواندن دیگر همزمان ادامه یابند
defer rwMu.RUnlock() // اطمینان حاصل میکند که قفل با استفاده از defer آزاد میشود
return data // به صورت ایمن داده را میخوانیم
}
func writeData(newValue int) {
rwMu.Lock() // قفل نوشتن را گرفته و از عملیاتهای دیگر خواندن یا نوشتن در این زمان جلوگیری میکنیم
data = newValue // مقدار جدید را به صورت ایمن نوشته میکنیم
rwMu.Unlock() // پس از اتمام نوشتن، قفل را آزاد میکنیم
}
func main() {
go writeData(42) // یک گوروتین را برای انجام عملیات نوشتن شروع میکنیم
fmt.Println(readData()) // گوروتین اصلی عملیات خواندن را انجام میدهد
// از WaitGroup یا روشهای همگامسازی دیگر برای اطمینان حاصل شود که تمامی گوروتینها انجام شدهاند
}
در مثال فوق، چندین خواننده میتوانند به صورت همزمان تابع readData
را اجرا کنند، اما یک نویسنده اجرای writeData
را مسدود خواهد کرد و از خوانندگان جدید و نویسندگان دیگر جلوگیری میکند. این مکانیسم مزایای عملکردی را برای صورتهایی با بیشتر کارهای خواندن نسبت به نوشتن ارائه میدهد.
2.3 متغیرهای شرایطی (sync.Cond
)
2.3.1 مفهوم متغیرهای شرطی
در مکانیزم همگامسازی زبان Go، متغیرهای شرطی برای انتظار یا اطلاعرسانی تغییرات شرایط به عنوان یک ابزار همگامسازی استفاده میشوند. متغیرهای شرطی همیشه همراه با قفل (sync.Mutex
) استفاده میشوند که برای حفظ سازگاری شرط خود استفاده میشود.
مفهوم متغیرهای شرطی از دامنه سیستمعاملها میآید و به گروهی از گوروتینها امکان انتظار برای برآورده شدن یک شرط خاص را میدهد. به طور خاصتر، یک گوروتین ممکن است در حالت انتظار برای برآورده شدن یک شرط و تعلیق اجرا باشد، و یک گوروتین دیگر ممکن است با استفاده از متغیر شرطی شرایط دیگر را تغییر داده و سپس به سایر گوروتینها اطلاع داده و آنها را بیدار کند تا اجرایشان را ادامه دهند.
در کتابخانه استاندارد Go، متغیرهای شرطی از طریق نوع sync.Cond
ارائه میشوند و اصلیترین متدهای آن عبارتند از:
-
Wait
: فراخوانی این متد باعث از دست دادن قفل نگهداری شده و مسدود شدن تا زمانی که یک گوروتین دیگر بر روی همان متغیر شرطیSignal
یاBroadcast
را فراخواند تا آن را بیدار کند. سپس دوباره سعی میکند تا قفل را بدست آورد. -
Signal
: یک گوروتین معلق را بیدار میکند که بر روی این متغیر شرطی منتظر است. اگر هیچ گوروتینی منتظر نباشد، فراخوانی این متد تأثیری نخواهد داشت. -
Broadcast
: تمام گوروتینهایی که بر روی این متغیر شرطی منتظر هستند را بیدار میکند.
متغیرهای شرطی نباید کپی شوند، بنابراین به طور کلی به عنوان یک فیلد اشارهگری از یک ساختار خاص استفاده میشوند.
2.3.2 موارد عملیاتی متغیرهای شرطی
در اینجا یک مثال از استفاده متغیرهای شرطی را برای نمایش یک مدل تولیدکننده-مصرفکننده نشان میدهد:
package main
import (
"fmt"
"sync"
"time"
)
// SafeQueue یک صف ایمن محافظت شده توسط یک mutex است
type SafeQueue struct {
mu sync.Mutex
cond *sync.Cond
queue []interface{}
}
// Enqueue یک عنصر را به انتهای صف اضافه میکند و گوروتینهای منتظر را اطلاعرسانی میکند
func (sq *SafeQueue) Enqueue(item interface{}) {
sq.mu.Lock()
defer sq.mu.Unlock()
sq.queue = append(sq.queue, item)
sq.cond.Signal() // اطلاعرسانی گوروتینهای منتظر که صف خالی نیست
}
// Dequeue یک عنصر را از سر صف حذف میکند، اگر صف خالی باشد منتظر میماند
func (sq *SafeQueue) Dequeue() interface{} {
sq.mu.Lock()
defer sq.mu.Unlock()
// انتظار وقتی که صف خالی باشد
for len(sq.queue) == 0 {
sq.cond.Wait() // منتظر تغییر شرط میماند
}
item := sq.queue[0]
sq.queue = sq.queue[1:]
return item
}
func main() {
queue := make([]interface{}, 0)
sq := SafeQueue{
mu: sync.Mutex{},
cond: sync.NewCond(&sync.Mutex{}),
queue: queue,
}
// گوروتین تولیدکننده
go func() {
for i := 0; i < 5; i++ {
time.Sleep(1 * time.Second) // شبیهسازی زمان تولید
sq.Enqueue(fmt.Sprintf("item%d", i)) // تولید یک عنصر
fmt.Println("تولید:", i)
}
}()
// گوروتین مصرفکننده
go func() {
for i := 0; i < 5; i++ {
item := sq.Dequeue() // مصرف یک عنصر، در صورتی که صف خالی باشد منتظر میماند
fmt.Printf("مصرف: %v\n", item)
}
}()
// انتظار برای مدت کافی از زمان برای اطمینان از اتمام تولید و مصرف
time.Sleep(10 * time.Second)
}
در این مثال، یک ساختار SafeQueue
با یک صف داخلی و یک متغیر شرطی تعریف شده است. هنگامی که مصرفکننده متد Dequeue
را فراخوانی کرده و صف خالی است، با استفاده از متد Wait
در حالت انتظار قرار میگیرد. هنگامی که تولیدکننده متد Enqueue
را فراخوانی کرده و یک عنصر جدید را در صف قرار میدهد، از متد Signal
برای بیدار کردن مصرفکننده منتظر استفاده میکند.
2.4 WaitGroup
2.4.1 مفهوم و استفاده از WaitGroup
sync.WaitGroup
یک مکانیزم همگامسازی است که برای انتظار برای تکمیل یک گروه از گوروتینها استفاده میشود. هنگامی که یک گوروتین را شروع میکنید، میتوانید با فراخوانی متد Add
شمارنده را افزایش دهید و هر گوروتین میتواند زمانی که کارش را تمام کرده است، متد Done
را فراخوانی کند (که در واقع Add(-1)
را انجام میدهد). گوروتین اصلی میتواند با فراخوانی متد Wait
تا زمانی که شمارنده به 0 برسد (که نشاندهنده تمام شدن وظایف تمامی گوروتینهاست) مسدود شود.
هنگام استفاده از WaitGroup
، نکات زیر باید مورد توجه قرار گیرد:
- متدهای
Add
،Done
وWait
ایمن نخ نیستند و نباید به صورت همزمان در چند گوروتین فراخوانی شوند. - متد
Add
باید قبل از شروع گوروتین تازه ایجاد شود فراخوانی شود.
2.4.2 موارد کاربرد عملی از WaitGroup
در ادامه، مثالی از استفاده از WaitGroup
آورده شده است:
package main
import (
"fmt"
"sync"
"time"
)
func worker(id int, wg *sync.WaitGroup) {
defer wg.Done() // پس از اتمام، WaitGroup را مطلع میکند
fmt.Printf("شروع کارگر %d\n", id)
time.Sleep(time.Second) // عملیات مصرفکننده زمان را شبیهسازی میکند
fmt.Printf("کارگر %d انجام شد\n", id)
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 5; i++ {
wg.Add(1) // قبل از شروع گوروتین، شمارنده را افزایش میدهد
go worker(i, &wg)
}
wg.Wait() // منتظر میماند تا همه گوروتینهای کارگر اتمام کار شوند
fmt.Println("همه کارگرها انجام شدند")
}
در این مثال، تابع worker
اجرای یک وظیفه را شبیهسازی میکند. در تابع اصلی، ما پنج worker
گوروتین را شروع میکنیم. قبل از شروع هر گوروتین، ما wg.Add(1)
را صدا میزنیم تا به WaitGroup
اطلاع دهیم که یک وظیفه جدید در حال اجراست. هر زمان که تابع کارگر کامل میشود، defer wg.Done()
را فراخوانی میکند تا به WaitGroup
اطلاع دهد که وظیفه انجام شده است. پس از شروع همه گوروتینها، تابع اصلی با wg.Wait()
در محلی مسدود میشود تا همه کارگرها پایان کار را گزارش دهند.
2.5 عملیات اتمی (sync/atomic
)
2.5.1 مفهوم عملیات اتمی
عملیات اتمی به عملیات در برنامهنویسی همروند اشاره دارد که غیرقابل تقسیم هستند، به این معنی که در حین اجرا توسط عملیات دیگر مختل نمیشوند. برای چندین گوروتین، استفاده از عملیات اتمی میتواند همگرایی داده و هماهنگی وضعیت را بدون نیاز به قفلگذاری اطمینانبخشد، زیرا خود عملیات اتمی اتمیت اجرا را تضمین میکند.
در زبان Go، بسته sync/atomic
عملیات حافظهاتمی پایینسطح را فراهم میکند. برای انواع دادههای پایهای مانند int32
، int64
، uint32
، uint64
، uintptr
و pointer
، میتوان از متدهای بسته sync/atomic
برای عملیات همروند ایمن استفاده کرد. اهمیت عملیات اتمی در این است که عمدهبازیگر ساخت قبایل همروندی دیگر (مانند قفلها و متغیرهای شرطی) است و اغلب بهتر از مکانیسمهای قفلگذاری کارا هم هستند.
2.5.2 موارد کاربرد عملیات اتمی
فرض کنید که به یک سناریو نیاز داریم که تعداد همزمان بازدیدکنندگان وبسایت را پیگیری کنیم. با استفاده از یک متغیر شمارنده ساده به طور شهودی، تعداد بازدیدکنندگان را زمانی که یک بازدیدکننده میآید افزایش میدهیم و زمانی که یک بازدیدکننده میرود کاهش میدهیم. با این حال، در یک محیط همزمان، این رویکرد به مسابقه داده منجر میشود. بنابراین، ما میتوانیم از بسته sync/atomic
برای بهطور ایمن انجام دادن عملیات برروی شمارنده استفاده کنیم.
package main
import (
"fmt"
"sync"
"sync/atomic"
"time"
)
var visitorCount int32
func incrementVisitorCount() {
atomic.AddInt32(&visitorCount, 1)
}
func decrementVisitorCount() {
atomic.AddInt32(&visitorCount, -1)
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
incrementVisitorCount()
time.Sleep(time.Second) // زمان بازدید از بازدیدکننده
decrementVisitorCount()
wg.Done()
}()
}
wg.Wait()
fmt.Printf("شمارش بازدیدکنندههای فعلی: %d\n", visitorCount)
}
در این مثال، ما ۱۰۰ گوروتین ایجاد میکنیم تا ورود و خروج بازدیدکنندگان را شبیهسازی کنند. با استفاده از تابع atomic.AddInt32()
، ما اطمینان حاصل میکنیم که افزایش و کاهش شمارنده حتی در شرایط بسیار همروند نیز اتمی هستند و از این رو دقت visitorCount
تضمین میشود.
2.6 مکانیزم هماهنگسازی کانال
2.6.1 ویژگیهای هماهنگسازی کانالها
کانالها راهی برای ارتباط بین گوروتینها در زبان Go در سطح زبان فراهم میکنند. یک کانال امکان ارسال و دریافت دادهها را فراهم میکند. زمانی که یک گوروتین سعی میکند دادهای را از یک کانال بخواند و کانال دارای داده نباشد، تا وقتی داده در دسترس باشد مسدود میشود. به همین ترتیب، اگر کانال پر باشد (برای یک کانال غیرپیشبارگذاری، این به معنای داشتن داده است)، گوروتینی که سعی میکند داده ارسال کند هم مسدود میشود تا فضای نوشتن وجود داشته باشد. این ویژگی کانالها را برای هماهنگسازی بین گوروتینها بسیار مفید میکند.
2.6.2 استفاده از موارد همگام سازی با کانالها
فرض کنید که یک وظیفه وجود دارد که باید توسط چند گوروتین انجام شود، هرکدام از آنها یک زیروظیفه را انجام میدهند، سپس نیاز است که نتایج تمام زیروظایفهها را جمعآوری کنیم. میتوانیم از یک کانال برای انتظار تمام گوروتینها برای پایان کار استفاده کنیم.
package main
import (
"fmt"
"sync"
)
func worker(id int, wg *sync.WaitGroup, resultChan chan<- int) {
defer wg.Done()
// انجام چند عملیات...
fmt.Printf("شروع کارگر %d\n", id)
// فرض کنید نتیجه زیروظیفه شناسه کارگر است
resultChan <- id
fmt.Printf("کارگر %d انجام شد\n", id)
}
func main() {
var wg sync.WaitGroup
numWorkers := 5
resultChan := make(chan int, numWorkers)
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go worker(i, &wg, resultChan)
}
go func() {
wg.Wait()
close(resultChan)
}()
// جمعآوری تمام نتایج
for result := range resultChan {
fmt.Printf("نتیجه دریافت شد: %d\n", result)
}
}
در این مثال، ما 5 گوروتین برای انجام وظایف راهاندازی میکنیم و نتایج را از طریق کانال resultChan
جمعآوری میکنیم. گوروتین اصلی منتظر این است که کل کار را در یک گوروتین جداگانه انجام دهد و سپس کانال نتیجه را ببندد. سپس، گوروتین اصلی تمامی عناصر کانال resultChan
را بررسی کرده و نتایج همه گوروتینها را چاپ میکند.
2.7 اجرای یکباره (sync.Once
)
sync.Once
یک ابزار همگامسازی است که اطمینان مییابد تا زمانی که برنامه اجرا میشود، عملیات فقط یکبار انجام میشود. استفادهی معمول از sync.Once
در مقداردهی اولیه یک شیء یکتا یا در سناریوهای نیازمندیهای اولیه به تأخیر است. بدون توجه به اینکه چند گوروتین این عملیات را فراخوانی کنند، تنها یکبار اجرا میشود، به همین دلیل تابع Do
نام دارد.
sync.Once
به طور کامل مسائل همزمانی و کارآیی اجرا را تعادل بخشیده و نگرانیهای مربوط به مشکلات عملکرد ناشی از مقداردهی تکراری را از بین میبرد.
یک مثال ساده برای نشان دادن استفاده از sync.Once
:
package main
import (
"fmt"
"sync"
)
var once sync.Once
var instance *Singleton
type Singleton struct{}
func Instance() *Singleton {
once.Do(func() {
fmt.Println("اکنون اقدام به ایجاد نمونهی تکی میکنیم.")
instance = &Singleton{}
})
return instance
}
func main() {
for i := 0; i < 10; i++ {
go Instance()
}
fmt.Scanln() // منتظر بمانید تا خروجی را مشاهده کنید
}
در این مثال، حتی اگر تابع Instance
به صورت همروند چندین بار فراخوانی شود، ایجاد نمونهی Singleton
فقط یکبار اتفاق میافتد. فراخوانیهای بعدی به صورت مستقیم نمونهی تکی ایجاد شده در اولین بار را برمیگردانند وحتماً یکتایی نمونه را تضمین میکند.
2.8 گروه خطا (ErrGroup
)
ErrGroup
یک کتابخانه در زبان Go است که برای همگامسازی چندین گوروتین و جمعآوری خطاهای آنها استفاده میشود. این بخشی از بسته "golang.org/x/sync/errgroup" است و روشی مختصر برای رفع موارد خطا در عملیات همروند فراهم میکند.
2.8.1 مفهوم ErrGroup
ایده اصلی ErrGroup
این است که یک گروه از وظایف مرتبط را (معمولاً به صورت همروند اجرا میشوند) به یکدیگر متصل کنیم و اگر یکی از وظایف شکست بخورد، اجرای کل گروه لغو میشود. همزمان، اگر هرکدام از این عملیات همروند خطا برگرداند، ErrGroup
این خطا را ضبط و برمیگرداند.
برای استفاده از ErrGroup
، ابتدا باید بسته را وارد کنید:
import "golang.org/x/sync/errgroup"
سپس، یک نمونه از ErrGroup
ایجاد کنید:
var g errgroup.Group
بعد، میتوانید وظایف را به صورت اسناد محضی به ErrGroup
منتقل کنید و با فراخوانی متد Go
یک گوروتین جدید را راهاندازی کنید:
g.Go(func() error {
// انجام یک وظیفه خاص
// اگر همه چیز خوب پیش برود
return nil
// اگر خطایی رخ دهد
// return fmt.Errorf("خطا رخ داده است")
})
سرانجام، متد Wait
را فراخوانی کنید که منتظر ماندن و انجام تمام وظایف را بلوکه میکند. اگر هرکدام از این وظایف خطا برگرداند، Wait
این خطا را برمیگرداند:
if err := g.Wait(); err != nil {
// با خطا برخورد کنید
log.Fatalf("خطای اجرای وظیفه: %v", err)
}
2.8.2 مورد عملی ErrGroup
یک سناریو را در نظر بگیرید که ما نیاز داریم به صورت همزمان داده ها را از سه منبع داده مختلف دریافت کنیم، و اگر یکی از منابع داده با مشکل مواجه شود، می خواهیم به طور فوری سایر عملیات دریافت داده را لغو کنیم. این وظیفه را می توان با استفاده از ErrGroup
به آسانی انجام داد:
package main
import (
"fmt"
"golang.org/x/sync/errgroup"
)
func fetchDataFromSource1() error {
// شبیه سازی دریافت داده از منبع 1
return nil // یا برگرداندن خطا برای شبیه سازی یک خطا
}
func fetchDataFromSource2() error {
// شبیه سازی دریافت داده از منبع 2
return nil // یا برگرداندن خطا برای شبیه سازی یک خطا
}
func fetchDataFromSource3() error {
// شبیه سازی دریافت داده از منبع 3
return nil // یا برگرداندن خطا برای شبیه سازی یک خطا
}
func main() {
var g errgroup.Group
g.Go(fetchDataFromSource1)
g.Go(fetchDataFromSource2)
g.Go(fetchDataFromSource3)
// منتظر شدن برای تکمیل شدن همه گوروتین ها و جمع آوری خطاهای آنها
if err := g.Wait(); err != nil {
fmt.Printf("هنگام دریافت داده خطا رخ داد: %v\n", err)
return
}
fmt.Println("تمام داده ها با موفقیت دریافت شدند!")
}
در این مثال، توابع fetchDataFromSource1
، fetchDataFromSource2
و fetchDataFromSource3
شبیه سازی دریافت داده از منابع داده مختلف هستند. آنها به متد g.Go
ارسال میشوند و در گوروتینهای جداگانه اجرا میشوند. اگر هر یک از توابع یک خطا برگردانند، g.Wait
به طور فوری آن خطا را برمیگرداند و امکان برخورداری از اندازهگیری مناسب هنگام رخداد آن خطا را فراهم میکند. اگر همه توابع با موفقیت اجرا شوند، g.Wait
مقدار nil
را برمیگرداند که نمایانگر این است که تمام وظایف با موفقیت انجام شده اند.
یک ویژگی مهم دیگر از ErrGroup
این است که اگر هر یک از گوروتینها در حالت خشونتیلا بیافتد، تلاش میکند تا این خشونت را دیدهباندازد و آن را به عنوان یک خطا برگرداند. این کمک میکند تا از شکستن غیرفعالیت های دیگر به طور زیبایی پایان یابد. البته، اگر بخواهید وظایف به سیگنالهای لغوی بیرونی پاسخ دهند، میتوانید تابع WithContext
از errgroup
را با بسته متن منسجم کنید تا یک متن قابل لغو ارائه دهد.
به این ترتیب، ErrGroup
به یک مکانیسم هماهنگ سازی و پردازش خطای عملیاتی در عملیات همزمان برنامه نویسی Go تبدیل میشود.