1. دور آليات التزامن
في البرمجة المتزامنة، عندما تشارك متعددة من الـ goroutines الموارد، من الضروري ضمان أن يكون بإمكان هذه الموارد إما وصولاً إلى goroutine واحدة في كل مرة، وذلك لمنع حالات السباق. هذا يتطلب استخدام آليات التزامن. تستطيع آليات التزامن تنظيم ترتيب الوصول للـ goroutines المختلفة إلى الموارد المشتركة، مما يضمن تناسق البيانات وتزامن الحالة في بيئة متزامنة.
يوفر لغة Go مجموعة غنية من آليات التزامن، تشمل ولكن لا تقتصر على:
- القفل (Mutexes) (sync.Mutex) وقفل القراءة-الكتابة (Read-Write Mutexes) (sync.RWMutex)
- القنوات (Channels)
- مجموعات الانتظار (WaitGroups)
- الدوال الذرية (Atomic functions) (pckage atomic)
- المتغيرات الشرطية (Condition variables) (sync.Cond)
2. المبادئ التزامنية
2.1 القفل (sync.Mutex)
2.1.1 مفهوم ودور القفل
القفل هو آلية تزامن تضمن التشغيل الآمن للموارد المشتركة من خلال السماح للـ goroutine واحدة فقط بامتلاك القفل للوصول إلى المورد المشترك في أي وقت معين. يتحقق القفل من خلال استخدام الدوال Lock
وUnlock
. عند استدعاء الدالة Lock
، سيتم حظر حتى يتم الإفراج عن القفل، وفي هذه اللحظة، ستنتظر الـ goroutines الأخرى المحاولة لاقتناء القفل. باستدعاء Unlock
، يتم إطلاق القفل، مما يسمح للـ goroutines الأخرى بامتلاكه.
var mu sync.Mutex
func criticalSection() {
// امتلاك القفل للوصول حصرياً إلى المورد
mu.Lock()
// الوصول إلى المورد المشترك هنا
// ...
// إطلاق القفل للسماح للـ goroutines الأخرى باقتناءه
mu.Unlock()
}
2.1.2 الاستخدام العملي للقفل
لنفترض أنه علينا الحفاظ على عداد عالمي، وهناك العديد من الـ goroutines التي بحاجة إلى زيادة قيمته. باستخدام قفل يمكن ضمان دقة العداد.
var (
mu sync.Mutex
counter int
)
func increment() {
mu.Lock() // قفل قبل تعديل العداد
counter++ // زيادة قيمة العداد بأمان
mu.Unlock() // فتح القفل بعد العملية، مما يسمح للـ goroutines الأخرى بالوصول إلى العداد
}
func main() {
for i := 0; i < 10; i++ {
go increment() // بدء العديد من الـ goroutines لزيادة قيمة العداد
}
// الانتظار لبعض الوقت (عملياً يجب استخدام WaitGroup أو طرق أخرى للانتظار حتى تكتمل كل الـ goroutines)
time.Sleep(1 * time.Second)
fmt.Println(counter) // إخراج قيمة العداد
}
2.2 قفل القراءة-الكتابة (sync.RWMutex)
2.2.1 مفهوم القفل القراءة-الكتابة
القفل القراءة-الكتابة هو نوع خاص من القفل يسمح للعديد من الـ goroutines بقراءة الموارد المشتركة بشكل متزامن، بينما تكون عمليات الكتابة حصرية. بالمقارنة مع القفل العادي، يمكن لقفل القراءة-الكتابة تحسين الأداء في سيناريوهات قراءة متعددة. يحتوي على أربعة دوال: RLock
، RUnlock
لقفل وفتح العمليات القراءة، وLock
، Unlock
لقفل وفتح العمليات الكتابة.
2.2.2 حالات الاستخدام العملي لقفل القراءة-الكتابة
في تطبيق قاعدة بيانات، قد تكون العمليات القراءة أكثر تواترًا بكثير من العمليات الكتابة. باستخدام قفل القراءة-الكتابة يمكن تحسين أداء النظام لأنه يسمح للعديد من الـ goroutines بالقراءة بشكل متزامن.
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) // بدء الـ goroutine لإجراء عملية كتابة
fmt.Println(readData()) // الـ goroutine الرئيسية تقوم بعملية قراءة
// استخدام WaitGroup أو طرق تزامن أخرى لضمان اكتمال كل الـ goroutines
}
في المثال أعلاه، يمكن للقراء المتعددين تشغيل الدالة readData
بشكل متزامن، ولكن كاتب يشغل الدالة writeData
سوف يمنع القراء والكُتَّاب الآخرين الجُدُد. هذه الآلية توفر مزايا الأداء للسيناريوهات التي تحتوي على قراءات أكثر من الكتابات.
2.3 المتغيرات الشرطية (sync.Cond
)
2.3.1 مفهوم المتغيرات الشرطية
في لغة Go، يتم استخدام المتغيرات الشرطية كنوع من آلية التزامن للانتظار أو الإبلاغ عن تغييرات في بعض الشروط. تُستخدم المتغيرات الشرطية دائمًا مع قفل (sync.Mutex
)، والذي يُستخدم لحماية تناسق الشرط ذاته.
يأتي مفهوم المتغيرات الشرطية من مجال نظام التشغيل، مما يسمح لمجموعة من الـ goroutines بالانتظار حتى تتحقق شرط معين. على وجه التحديد، يمكن لـ goroutine ما أن تُوقف تنفيذها أثناء الانتظار حتى يتحقق الشرط، ويمكن لـ goroutine أخرى أن تُبلغ goroutines أخرى بأن تستأنف تنفيذها بعد تغيير الشرط باستخدام المتغير الشرطي.
في مكتبة Go القياسية، يتم توفير المتغيرات الشرطية من خلال نوع sync.Cond
، وتشمل الطرق الرئيسية لها:
-
Wait
: استدعاء هذه الطريقة سيقوم بإطلاق القفل المُحتجز وسيحجب حتى تقوم goroutine أخرى بالاستدعاءSignal
أوBroadcast
على نفس المتغير الشرطي لإيقاظه، وبعد ذلك ستحاول الحصول على القفل مرة أخرى. -
Signal
: يوقظ goroutine واحدة تنتظر هذا المتغير الشرطي. إذا لم تكن هناك goroutine تنتظر، فإن استدعاء هذه الطريقة لن يكون له تأثير. -
Broadcast
: يوقظ كل الgoroutines التي تنتظر هذا المتغير الشرطي.
لا ينبغي نسخ المتغيرات الشرطية، لذا يتم استخدامها عادة كحقل مؤشر من نوع struct معين.
2.3.2 الحالات العملية للمتغيرات الشرطية
فيما يلي مثال على استخدام المتغيرات الشرطية يوضح نموذجًا بسيطًا لطرح المُنتج والمستهلك:
package main
import (
"fmt"
"sync"
"time"
)
// SafeQueue هي قائمة آمنة محمية بواسطة قفل
type SafeQueue struct {
mu sync.Mutex
cond *sync.Cond
queue []interface{}
}
// Enqueue يضيف عنصرًا إلى نهاية القائمة ويُبلغ الgoroutines الانتظار
func (sq *SafeQueue) Enqueue(item interface{}) {
sq.mu.Lock()
defer sq.mu.Unlock()
sq.queue = append(sq.queue, item)
sq.cond.Signal() // يُبلغ الgoroutines الانتظار بأن القائمة ليست فارغة
}
// 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,
}
// Goroutine المُنتج
go func() {
for i := 0; i < 5; i++ {
time.Sleep(1 * time.Second) // محاكاة وقت الإنتاج
sq.Enqueue(fmt.Sprintf("item%d", i)) // إنتاج عنصر
fmt.Println("الإنتاج:", i)
}
}()
// Goroutine المستهلك
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
هو آلية تزامن تُستخدم لانتظار مجموعة من goroutines لاستكمال عملها. عندما تبدأ goroutine، يمكنك زيادة العداد عن طريق استدعاء الطريقة Add
، ويمكن لكل goroutine استدعاء الطريقة Done
(والتي تقوم فعلياً بتنفيذ Add(-1)
) عندما تنتهي. يمكن للgoroutine الرئيسية أن تُحجب عن طريق استدعاء الطريقة Wait
حتى يصل العداد إلى 0، مما يُشير إلى أن كل الgoroutines قد أكملت مهامها.
عند استخدام WaitGroup
، يجب ملاحظة النقاط التالية:
- الطرق
Add
،Done
، وWait
ليست آمنة للموضوع وينبغي عدم استدعائها بشكل متزامن في عدة goroutines. - يجب استدعاء الطريقة
Add
قبل بدء goroutine المنشأة حديثًا.
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) // زيادة العداد قبل بدء الـ goroutine
go worker(i, &wg)
}
wg.Wait() // انتظر حتى ينتهي جميع الـ worker goroutines
fmt.Println("كل العاملين انتهوا")
}
في هذا المثال، تقوم الدالة worker
بمحاكاة تنفيذ مهمة. في الدالة الرئيسية، نبدأ خمس worker
goroutines. قبل بدء كل goroutine، نستدعي wg.Add(1)
لإخطار WaitGroup
بأن مهمة جديدة قيد التنفيذ. عندما تنتهي كل دالة عامل، تستدعي defer wg.Done()
لإخطار WaitGroup
بأن المهمة انتهت. بعد بدء جميع الـ goroutines، تقف الدالة الرئيسية عند wg.Wait()
حتى يبلغ جميع العاملين الانتهاء.
2.5 العمليات الذرية (sync/atomic
)
2.5.1 مفهوم العمليات الذرية
العمليات الذرية تشير إلى العمليات في البرمجة المتزامنة التي لا يمكن انقسامها، وهذا يعني أنها لا تتأثر بعمليات أخرى أثناء التنفيذ. بالنسبة للعديد من الـ goroutines، استخدام العمليات الذرية يمكن أن يضمن تناسق البيانات وتزامن الحالة بدون الحاجة إلى قفل، حيث تضمن العمليات الذرية ذاتياً انفرادية التنفيذ.
في لغة 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)
}
في هذا المثال، نخلق 100 goroutines لمحاكاة وصول ومغادرة الزوار. باستخدام الدالة atomic.AddInt32()
، نضمن أن زيادة ونقصان العداد يكونان ذاتيين في العمليات حتى في الحالات المتزامنة بشكل كبير، مما يضمن دقة visitorCount
.
2.6 آلية المزامنة القنوات
2.6.1 خصائص المزامنة للقنوات
القنوات هي وسيلة للـ goroutines للتواصل في لغة Go على مستوى اللغة. توفر القناة القدرة على إرسال واستقبال البيانات. عندما تحاول goroutine قراءة بيانات من قناة والقناة ليست بها بيانات، فإنها ستتوقف حتى تتوفر البيانات. بالمثل، إذا كانت القناة ممتلئة (بالنسبة للقناة غير المخزنة عندها بالفعل بيانات)، فإن goroutine التي تحاول إرسال البيانات ستتوقف أيضاً حتى تتوفر مساحة للكتابة. هذه الميزة تجعل القنوات مفيدة جداً للمزامنة بين goroutines.
2.6.2 حالات الاستخدام للتزامن مع القنوات
لنفترض أن لدينا مهمة يجب إكمالها بواسطة العديد من goroutines، كل واحدة منها تتعامل مع مهمة فرعية، وبعد ذلك نحتاج إلى تجميع نتائج جميع المهام الفرعية. يمكننا استخدام قناة للانتظار حتى تنتهي جميع goroutines.
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 goroutines لأداء المهام وجمع النتائج من خلال قناة resultChan
. تنتظر الgoroutine الرئيسي الانتهاء من جميع الأعمال في goroutine منفصل ومن ثم يُغلق قناة النتائج. بعد ذلك، يعبر الgoroutine الرئيسي قناة resultChan
، مجمعًا وطباعة نتائج جميع goroutines.
2.7 التنفيذ مرة واحدة (sync.Once
)
sync.Once
هو مبدأ تزامن يضمن أن العملية تُنفَّذ مرة واحدة فقط خلال تنفيذ البرنامج. استخدام شائع لـ sync.Once
هو في تهيئة كائن من نوع واحد أو في السيناريوهات التي تتطلب التهيئة المؤجلة. بغض النظر عن عدد goroutines التي تستدعي هذه العملية، ستُنفَّذ مرة واحدة فقط، ومن هنا يأتي اسم وظيفة 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
سيحدث مرة واحدة فقط. ستقوم الاستدعاءات التالية بإرجاع مثيل الـ singleton الذي تم إنشاؤه في المرة الأولى مباشرة، مضمنة فرادة المثيل.
2.8 ErrGroup
ErrGroup
هو مكتبة في لغة Go تُستخدم لمزامنة عدة goroutines وجمع أخطائها. إنه جزء من حزمة "golang.org/x/sync/errgroup"، ويوفر طريقة موجزة لمعالجة سيناريوهات الأخطاء في العمليات المتزامنة.
2.8.1 مفهوم ErrGroup
الفكرة الأساسية لـ ErrGroup
هو ربط مجموعة من المهام ذات الصلة (التي تُنفَّذ عادة بشكل متزامن) معًا، وإذا فشلت إحدى المهام فسيتم إلغاء تنفيذ المجموعة بأكملها. في الوقت نفسه، إذا كان أي من هذه العمليات المتزامنة يُرجِع خطأً، سيُلتَقَطُ ErrGroup
هذا الخطأ ويُعيده.
لاستخدام ErrGroup
، استورد الحزمة أولاً:
import "golang.org/x/sync/errgroup"
ثم، أنشئ نسخة من ErrGroup
:
var g errgroup.Group
بعد ذلك، يُمكنك تمرير المهام إلى ErrGroup
في شكل إغلاقات والبدء في Goroutine جديد بالاتصال بالدالة 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":
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
مع حزمة context لتوفير سياق قابل للإلغاء.
بهذه الطريقة، تصبح "ErrGroup" آلية مزامنة ومعالجة أخطاء عملية برمجة Go المتزامنة عمليةً وعمليةً في الواقع.