تستعرض مقالة حالات استخدام القنوات حالات استخدام كثيرة تُستخدَم فيها القنوات لإجراء مزامنات تزامن بين الـgoroutines. في الواقع، القنوات ليست أسلوب المزامنة الوحيد الموفَّر في Go، فهناك بعض أساليب المزامنة الأخرى التي تدعمها Go. وفي بعض الظروف المحدَّدة، يكون استخدام أساليب المزامنة بخلاف القناة أكثر كفاءة ووضوحاً من استخدام القنوات. وفيما يلي سنقدّم أساليب المزامنة الموفَّرة في الحزمة القياسية sync.
تقدّم الحزمة القياسية sync عدة أنواع يمكن استخدامها لإجراء مزامنات في بعض الظروف المتخصّصة وضمان بعض ترتيبات الذاكرة المتخصّصة. وفي الظروف المتخصّصة، تكون هذه الأساليب أكثر كفاءة وأكثر نظافة في المظهر من طرق القنوات.
(يُرجى ملاحظة أنه لتجنّب سلوكيات غير طبيعية، من الأفضل ألا تنسخ أبداً قيم الأنواع الموجودة في الحزمة القياسية sync.)
النوع sync.WaitGroup
تحتفظ كل قيمة sync.WaitGroup داخلياً بعدّاد. والقيمة الابتدائية للعدّاد صفر.
للنوع *WaitGroup ثلاث طرق: Add(delta int) وDone() وWait().
لقيمة WaitGroup قابلة للعنونة wg،
- يمكننا استدعاء الطريقة
wg.Add(delta)لتغيير قيمة العدّاد التي تحتفظ بهاwg. - استدعاء الطريقة
wg.Done()مكافئ تماماً لاستدعاء الطريقةwg.Add(-1). - إذا عدّل استدعاء
wg.Add(delta)(أوwg.Done()) العدّاد الذي تحتفظ بهwgإلى قيمة سالبة، فسيحدث panic. - عندما تستدعي goroutine الدالة
wg.Wait()، إذا كان العدّاد الذي تحتفظ بهwgصفراً بالفعل، فيمكن النظر إلى الاستدعاءwg.Wait()كعملية لا فعل لها. - وإلا (أي إذا كان العدّاد موجباً)، فستدخل الـgoroutine حالة الحجب. وستدخل حالة التشغيل مجدداً (أي يُرجع الاستدعاء
wg.Wait()) عندما تعدّل goroutine أخرى العدّاد إلى الصفر، عموماً باستدعاءwg.Done().
يُرجى ملاحظة أن wg.Add(delta) وwg.Done() وwg.Wait() صيغ مختصرة لـ (&wg).Add(delta) و(&wg).Done() و(&wg).Wait() على الترتيب.
عموماً، تُستخدَم قيمة WaitGroup في السيناريو الذي تنتظر فيه goroutine واحدة حتى تُنهي عدة goroutines أخرى مهامها الخاصة. مثال:
package main
import (
"log"
"math/rand"
"sync"
)
func main() {
const N = 5
var values [N]int32
var wg sync.WaitGroup
wg.Add(N)
for i := 0; i < N; i++ {
go func(n int) {
values[n] = 50 + rand.Int31n(50)
log.Println("Done:", n)
wg.Done() // <=> wg.Add(-1)
}(i)
}
wg.Wait()
// نضمن الآن تهيئة جميع
// العناصر.
log.Println("values:", values)
}
في المثال أعلاه، تنتظر الـgoroutine الرئيسية حتى تكون جميع الـgoroutines الـN الأخرى قد ملأت قيمة عنصرها المقابل في مصفوفة values. وفيما يلي إحدى نتائج الإخراج المحتملة:
Done: 4
Done: 1
Done: 3
Done: 0
Done: 2
values: [71 89 50 62 60]
يمكننا تقسيم استدعاء الطريقة Add الوحيد في المثال أعلاه إلى استدعاءات متعددة.
...
var wg sync.WaitGroup
for i := 0; i < N; i++ {
wg.Add(1) // سيُستدعى N مرة
go func(n int) {
values[n] = 50 + rand.Int31n(50)
wg.Done()
}(i)
}
...
يمكن استدعاء الطريقة Wait في عدة goroutines. وعندما يصبح العدّاد صفراً، ستُخطَر جميعها، بطريقة بثّية.
func main() {
const N = 5
var values [N]int32
var wgA, wgB sync.WaitGroup
wgA.Add(N)
wgB.Add(1)
for i := 0; i < N; i++ {
go func(n int) {
wgB.Wait() // انتظار إشعار
log.Printf("values[%v]=%v \n", n, values[n])
wgA.Done()
}(i)
}
// نضمن انتهاء الحلقة قبل أن يعود
// أي من استدعاءات wg.Wait أعلاه.
for i := 0; i < N; i++ {
values[i] = 50 + rand.Int31n(50)
}
// إجراء إشعار بثّي.
wgB.Done()
wgA.Wait()
}
يمكن إعادة استخدام قيمة WaitGroup بعد عودة استدعاء واحد لطريقتها Wait. لكن يُرجى ملاحظة أن كل استدعاء للطريقة Add بزيادة موجبة يحدث عندما يكون العدّاد صفراً يجب أن يحدث قبل بدء أي استدعاء لـ Wait، وإلا فقد تحدث تسابقات بيانات.
النوع sync.Once
تحتوي قيمة *sync.Once على طريقة Do(f func())، تأخذ وسيطاً وحيداً نوعه func().
لقيمة Once قابلة للعنونة o، يمكن تنفيذ استدعاء الطريقة o.Do()، وهو صيغة مختصرة لـ (&o).Do()، بشكل متزامن عدة مرات في عدة goroutines. ويجب أن تكون معطيات استدعاءات o.Do() هذه (لكن لا يُشترط ذلك) قيمة الدالة نفسها.
من بين استدعاءات الطريقة o.Do هذه، ستُستدعى دالة معطى واحدة فقط بالضبط. ونضمن خروج دالة المعطى المُستدعاة قبل عودة أي استدعاء للطريقة o.Do. وبعبارة أخرى، نضمن تنفيذ الشيفرة في دالة المعطى المُستدعاة قبل عودة أي استدعاء للطريقة o.Do.
عموماً، تُستخدَم قيمة Once لضمان تنفيذ قطعة شيفرة مرة واحدة بالضبط في البرمجة المتزامنة.
مثال:
package main
import (
"log"
"sync"
)
func main() {
log.SetFlags(0)
x := 0
doSomething := func() {
x++
log.Println("Hello")
}
var wg sync.WaitGroup
var once sync.Once
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
once.Do(doSomething)
log.Println("world!")
}()
}
wg.Wait()
log.Println("x =", x) // x = 1
}
في المثال أعلاه، ستُطبَع Hello مرة واحدة، لكن world! ستُطبَع خمس مرات. ونضمن طباعة Hello قبل جميع مرات طباعة world! الخمس.
النوعان sync.Mutex وsync.RWMutex
يُنفِّذ كلا النوعين *sync.Mutex و*sync.RWMutex الواجهة sync.Locker. لذا لكلٍّ منهما طريقتان، Lock وUnlock، لمنع عدة مستخدمي بيانات من استخدام قطعة بيانات بشكل متزامن.
إلى جانب الطريقتين Lock وUnlock، يحتوي النوع *RWMutex على طريقتين أخريين، RLock وRUnlock، لتجنّب استخدام بعض مستخدمي البيانات (سواء كُتّاب أو قُرّاء) وكاتب بيانات واحد لقطعة بيانات في الوقت نفسه، مع السماح لبعض قُرّاء البيانات بالوصول إلى قطعة البيانات في الوقت نفسه.
(لاحظ أن المصطلحين قارئ بيانات وكاتب بيانات هنا لا ينبغي تفسيرهما حرفياً؛ فما هما إلا لأغراض الشرح. فقد يعدّل قارئ البيانات بيانات، وقد يكتفي كاتب البيانات بالقراءة.)
غالباً ما تُسمّى قيمة Mutex قفل استبعاد متبادل. وقيمة Mutex الصفرية قفل غير مقفَل. ولا يمكن قفل قيمة Mutex إلا عندما تكون في حالة غير مقفَلة. وبعبارة أخرى، بمجرد نجاح قفل قيمة Mutex قابلة للعنونة m (أي عودة استدعاء الطريقة m.Lock())، فإن أي محاولة جديدة من goroutine لقفل قيمة Mutex ستجعل الـgoroutine تدخل حالة الحجب، حتى تُفتَح قيمة Mutex (عبر استدعاء m.Unlock() لاحق).
يُرجى ملاحظة أن m.Lock() وm.Unlock() صيغتان مختصرتان لـ (&m).Lock() و(&m).Unlock() على الترتيب.
مثال على استخدام sync.Mutex:
package main
import (
"fmt"
"runtime"
"sync"
)
type Counter struct {
m sync.Mutex
n uint64
}
func (c *Counter) Value() uint64 {
c.m.Lock()
defer c.m.Unlock()
return c.n
}
func (c *Counter) Increase(delta uint64) {
c.m.Lock()
c.n += delta
c.m.Unlock()
}
func main() {
var c Counter
for i := 0; i < 100; i++ {
go func() {
for k := 0; k < 100; k++ {
c.Increase(1)
}
}()
}
// الحلقة لأغراض العرض فقط.
for c.Value() < 10000 {
runtime.Gosched()
}
fmt.Println(c.Value()) // 10000
}
في المثال أعلاه، تستخدم قيمة Counter حقلاً من نوع Mutex لضمان عدم استخدام حقل n في قيمة Counter من قبل عدة goroutines في الوقت نفسه.
غالباً ما تُسمّى قيمة RWMutex قفل استبعاد متبادل للقارئ+الكاتب. وله قفلان: قفل الكتابة وقفل القراءة. وقفلا قيمة RWMutex الصفرية كلاهما غير مقفَلين. ولقيمة RWMutex قابلة للعنونة rwm، يمكن لكُتّاب البيانات قفل قفل الكتابة في rwm عبر استدعاءات الطريقة rwm.Lock()، ويمكن لقُرّاء البيانات قفل قفل القراءة في rwm عبر استدعاءات الطريقة rwm.RLock(). ويُستخدَم استدعاءا الطريقتين rwm.Unlock() وrwm.RUnlock() لفتح قفلي الكتابة والقراءة في rwm. ويحتفظ قفل القراءة في rwm بعدّاد أقفال، يزداد بمقدار واحد عند نجاح استدعاء rwm.Lock() وينقص بمقدار واحد عند نجاح استدعاء rwm.Unlock(). ويعني عدّاد الأقفال الصفري أن قفل القراءة في حالة غير مقفَلة، ويعني العدّاد غير الصفري (الذي يجب أن يكون أكبر من واحد) أن قفل القراءة مقفَل.
يُرجى ملاحظة أن rwm.Lock() وrwm.Unlock() وrwm.RLock() وrwm.RUnlock() صيغ مختصرة لـ (&m).Lock() و(&m).Unlock() و(&m).RLock() و(&m).RUnlock() على الترتيب.
لقيمة RWMutex قابلة للعنونة rwm، توجد القواعد التالية.
- لا يجوز قفل قفل الكتابة في
rwmإلا إذا لم يكن أي من قفل القراءة وقفل الكتابة فيrwmفي حالة مقفَلة. وبعبارة أخرى، لا يجوز أن يحمل قفل الكتابة فيrwmأكثر من كاتب واحد في أي لحظة معيّنة، ولا يجوز حمل قفل القراءة وقفل الكتابة فيrwmفي الوقت نفسه. - عندما يكون قفل الكتابة في
rwmفي حالة مقفَلة، ستُحجَب أي محاولات أحدث لقفل قفل الكتابة أو قفل القراءة فيrwmحتى يُفتَح قفل الكتابة الأولي. - عندما يكون قفل القراءة في
rwmفي حالة مقفَلة، ستُحجَب أي محاولات أحدث لقفل قفل الكتابة حتى يُفتَح قفل القراءة. ومع ذلك، ستنجح المحاولات الأحدث لقفل قفل القراءة ما دامت تُنفَّذ قبل أي محاولات محجوبة لقفل قفل الكتابة (انظر القاعدة التالية للتفاصيل). وبعبارة أخرى، قد يحمل قفل القراءة أكثر من قارئ واحد في الوقت نفسه. وسيعود قفل القراءة إلى حالة غير مقفَلة عندما يعود عدّاد أقفاله إلى الصفر. - افترض أن قفل القراءة للقيمة
rwmفي حالة مقفَلة الآن؛ لتجنّب قفل القراءة اللانهائي، ستُحجَب أي محاولات أحدث لقفل قفل القراءة بعد محاولة محجوبة لقفل قفل الكتابة. - افترض أن قفل الكتابة للقيمة
rwmفي حالة مقفَلة الآن؛ وبالنسبة إلى مصرِّف Go القياسي الرسمي، لتجنّب قفل الكتابة اللانهائي، ستنجح حتماً محاولات قفل قفل القراءة قبل إطلاق قفل الكتابة بمجرد فتح قفل الكتابة، حتى لو جُعلت بعض هذه المحاولات بعد بعض المحاولات التي ما زالت محجوبة لقفل قفل الكتابة.
تهدف القاعدتان الأخيرتان إلى ضمان حصول كلٍّ من القُرّاء والكُتّاب على فرص لحيازة الأقفال.
يُرجى ملاحظة أن الأقفال ليست مرتبطة بالـgoroutines. فقد يُقفَل قفل في goroutine ويُفتَح في goroutine أخرى لاحقاً. وبعبارة أخرى، لا يعرف القفل أي goroutine نجحت في قفله أو فتحه.
يمكن تغيير نوع الحقل m في النوع Counter في المثال الأخير إلى sync.RWMutex، كما تُظهر الشيفرة التالية، للحصول على أداء أفضل عندما تُستدعى الطريقة Value بتكرار كبير لكن الطريقة Increase لا تُستدعى إلا قليلاً.
...
type Counter struct {
//m sync.Mutex
m sync.RWMutex
n uint64
}
func (c *Counter) Value() uint64 {
//c.m.Lock()
//defer c.m.Unlock()
c.m.RLock()
defer c.m.RUnlock()
return c.n
}
...
من سيناريوهات الاستخدام الأخرى لقيم sync.RWMutex تقسيم مهمة كتابة إلى عدة مهام صغيرة. يُرجى قراءة القسم التالي للحصول على مثال.
بفضل القاعدتين الأخيرتين المذكورتين أعلاه، من المرجّح جداً أن يُخرج البرنامج التالي abdc.
package main
import (
"fmt"
"time"
"sync"
)
func main() {
var m sync.RWMutex
go func() {
m.RLock()
fmt.Print("a")
time.Sleep(time.Second)
m.RUnlock()
}()
go func() {
time.Sleep(time.Second * 1 / 4)
m.Lock()
fmt.Print("b")
time.Sleep(time.Second)
m.Unlock()
}()
go func() {
time.Sleep(time.Second * 2 / 4)
m.Lock()
fmt.Print("c")
m.Unlock()
}()
go func () {
time.Sleep(time.Second * 3 / 4)
m.RLock()
fmt.Print("d")
m.RUnlock()
}()
time.Sleep(time.Second * 3)
fmt.Println()
}
يُرجى ملاحظة أن المثال أعلاه لأغراض الشرح فقط. فهو يستخدم استدعاءات time.Sleep لإجراء مزامنات تزامن، وهذه ممارسة سيئة في شيفرة الإنتاج.
يمكن أيضاً استخدام قيم sync.Mutex وsync.RWMutex لإجراء إشعارات، رغم وجود طرق أخرى كثيرة أفضل للقيام بالمهمة نفسها. وفيما يلي مثال يُجري إشعاراً باستخدام قيمة sync.Mutex.
package main
import (
"fmt"
"sync"
"time"
)
func main() {
var m sync.Mutex
m.Lock()
go func() {
time.Sleep(time.Second)
fmt.Println("Hi")
m.Unlock() // إجراء إشعار
}()
m.Lock() // انتظار الإشعار
fmt.Println("Bye")
}
في المثال أعلاه، نضمن طباعة النص Hi قبل النص Bye. ولمعرفة ضمانات ترتيب الذاكرة التي تقدّمها قيم sync.Mutex وsync.RWMutex، يُرجى قراءة ضمانات ترتيب الذاكرة في Go.
النوع sync.Cond
يقدّم النوع sync.Cond طريقة فعّالة لإجراء إشعارات بين الـgoroutines.
تحتوي كل قيمة sync.Cond على حقل sync.Locker اسمه L. وغالباً ما تكون قيمة الحقل من النوع *sync.Mutex أو *sync.RWMutex.
للنوع *sync.Cond ثلاث طرق: Wait() وSignal() وBroadcast().
تحتفظ كل قيمة sync.Cond أيضاً بطابور انتظار للـgoroutines بنظام FIFO (الوارد أولاً يُخدم أولاً). ولقيمة sync.Cond قابلة للعنونة c،
- يجب استدعاء
c.Wait()عندما يكونc.Lمقفَلاً، وإلا فسيُسبِّب استدعاءc.Wait()حالة panic. وسيقوم استدعاءc.Wait()بما يلي: 1. أولاً يدفع goroutine المستدعي الحالية إلى طابور انتظار الـgoroutines الذي تحتفظ بهc،
- ثم يستدعي
c.L.Unlock()لفتح/إطلاق القفلc.L. - ثم يجعل goroutine المستدعي الحالية تدخل حالة الحجب. (1)
(ستُفكّ حجب goroutine المستدعي بواسطة goroutine أخرى عبر استدعاء
c.Signal()أوc.Broadcast()لاحقاً.) وبمجرد فكّ حجب goroutine المستدعي ودخولها حالة التشغيل مجدداً، سيُستدعىc.L.Lock()(في استدعاءc.Wait()المستأنف) لمحاولة قفل القفلc.Lوحمله مجدداً، وسينتهي استدعاءc.Wait()بعد عودة استدعاءc.L.Lock().
- سيؤدي استدعاء
c.Signal()إلى فكّ حجب أول goroutine في طابور انتظار الـgoroutines الذي تحتفظ بهc(وإزالتها منه)، إذا لم يكن الطابور فارغاً. - سيؤدي استدعاء
c.Broadcast()إلى فكّ حجب جميع الـgoroutines في طابور انتظار الـgoroutines الذي تحتفظ بهc(وإزالتها منه)، إذا لم يكن الطابور فارغاً.
يُرجى ملاحظة أن c.Wait() وc.Signal() وc.Broadcast() صيغ مختصرة لـ (&c).Wait() و(&c).Signal() و(&c).Broadcast() على الترتيب.
غالباً ما تُستخدَم c.Signal() وc.Broadcast() للإشعار بتغيّر حالة شرط. وعموماً، ينبغي استدعاء c.Wait() في حلقة تتحقق مما إذا كان شرط قد تحقق أم لا.
في حالة استخدام نمطية لقيمة sync.Cond، عموماً، تنتظر goroutine واحدة تغيّرات شرط معيّن، وتغيّر بعض الـgoroutines الأخرى الشرط وترسل إشعارات. وفيما يلي مثال:
package main
import (
"fmt"
"math/rand"
"sync"
"time"
)
func main() {
rand.Seed(time.Now().UnixNano()) // مطلوبة قبل Go 1.20
const N = 10
var values [N]string
cond := sync.NewCond(&sync.Mutex{})
for i := 0; i < N; i++ {
d := time.Second * time.Duration(rand.Intn(10)) / 10
go func(i int) {
time.Sleep(d) // محاكاة عبء عمل
// يجب إجراء التغييرات عندما
// يكون cond.L مقفَلاً.
cond.L.Lock()
values[i] = string('a' + i)
// أرسل إشعاراً عندما يكون قفل cond.L مقفَلاً.
cond.Broadcast()
cond.L.Unlock()
// يمكن أيضاً وضع "cond.Broadcast()"
// هنا، عندما يكون قفل cond.L غير مقفَل.
//cond.Broadcast()
}(i)
}
// يجب استدعاء هذه الدالة عندما
// يكون cond.L مقفَلاً.
checkCondition := func() bool {
fmt.Println(values)
for i := 0; i < N; i++ {
if values[i] == "" {
return false
}
}
return true
}
cond.L.Lock()
defer cond.L.Unlock()
for !checkCondition() {
// يجب استدعاؤها عندما يكون cond.L مقفَلاً.
cond.Wait()
}
}
إحدى نتائج الإخراج المحتملة:
[ ]
[ f ]
[ c f ]
[ c f h ]
[ b c f h ]
[a b c f h j]
[a b c f g h i j]
[a b c e f g h i j]
[a b c d e f g h i j]
بما أنه لا توجد إلا goroutine واحدة (الـgoroutine الرئيسية) تنتظر فكّ الحجب في هذا المثال، فيمكن استبدال استدعاء cond.Broadcast() بـ cond.Signal(). وكما تشير التعليقات، لا يُشترط استدعاء cond.Broadcast() وcond.Signal() عندما يكون cond.L مقفَلاً.
لتجنّب تسابقات البيانات، ينبغي عدم تعديل أي جزء من الأجزاء العشرة للشرط المعرَّف من المستخدم إلا عندما يكون cond.L مقفَلاً. وينبغي أيضاً استدعاء الدالة checkCondition والطريقة cond.Wait عندما يكون cond.L مقفَلاً.
في الواقع، وبالنسبة إلى المثال المحدّد أعلاه، يمكن أيضاً أن يكون الحقل cond.L قيمة *sync.RWMutex، ويمكن تعديل كل جزء من الأجزاء العشرة للشرط المعرَّف من المستخدم عندما يكون قفل القراءة في cond.L محمولاً، تماماً كما تُظهر الشيفرة التالية:
...
cond := sync.NewCond(&sync.RWMutex{})
cond.L.Lock()
for i := 0; i < N; i++ {
d := time.Second * time.Duration(rand.Intn(10)) / 10
go func(i int) {
time.Sleep(d)
cond.L.(*sync.RWMutex).RLock()
values[i] = string('a' + i)
cond.L.(*sync.RWMutex).RUnlock()
cond.Signal()
}(i)
}
...
في المثال أعلاه، تُستخدَم قيمة sync.RWMutex على نحو غير معتاد. فقفل قراءتها تحمله بعض الـgoroutines التي تعدّل عناصر المصفوفة، وقفل كتابتها تستخدمه الـgoroutine الرئيسية لقراءة عناصر المصفوفة.
قد يكون الشرط المعرَّف من المستخدم الذي تراقبه قيمة Cond عدمياً (void). وفي مثل هذه الحالات، تُستخدَم قيمة Cond للإشعارات فحسب. على سبيل المثال، سيطبع البرنامج التالي abc أو bac.
package main
import (
"fmt"
"sync"
)
func main() {
wg := sync.WaitGroup{}
wg.Add(1)
cond := sync.NewCond(&sync.Mutex{})
cond.L.Lock()
go func() {
cond.L.Lock()
go func() {
cond.L.Lock()
cond.Broadcast()
cond.L.Unlock()
}()
cond.Wait()
fmt.Print("a")
cond.L.Unlock()
wg.Done()
}()
cond.Wait()
fmt.Print("b")
cond.L.Unlock()
wg.Wait()
fmt.Println("c")
}
إذا لزم الأمر، يمكن لعدة قيم sync.Cond أن تتشارك قيمة sync.Locker نفسها. لكن مثل هذه الحالات نادرة في الممارسة.