Go: قنوات الاتصال
القنوات هي العناصر الأساسية لنموذج التزامن في لغة Go — «لا تتواصل عن طريق مشاركة الذاكرة؛ بل شارك الذاكرة عن طريق التواصل». تجعل القنوات نقل البيانات بين goroutines آمنًا وأنيقًا تمامًا مثل استخدام الأنابيب.
إذا كانت «الغوروتينات» هي «الأشخاص» في عالم التزامن بلغة Go، فإن القنوات هي «خطوط الهاتف» التي تربط بينهم. في هذا الدرس، ستتقن جميع الاستخدامات الأساسية للقنوات وستتعرف على الأخطاء الشائعة.
1. ستتعلم
- إنشاء قناة (
make(chan T)بدون مخزن مؤقت /make(chan T, n)بمخزن مؤقت) - إرسال
ch <- vواستقبالv := <-ch - سلوك التزامن للقنوات غير المخزنة مؤقتًا
- السلوك غير المتزامن للقنوات المخزنة مؤقتًا
closeيغلق القناة وrangeيتوقف عن التكرار- قيود الاتجاه:
chan<-مخصص للكتابة فقط؛<-chanمخصص للقراءة فقط - تحليل السيناريوهات الشائعة لحالات التعطل
- مقدمة في تعدد الإرسال الانتقائي
- دراسة حالة شاملة حول سلسلة التوريد بين المنتج والمستهلك
2. قصة حقيقية لمهندس برمجيات الخلفية
(1) المشكلة: كود الذاكرة المشتركة + الميوتكس قبيح وعرضة لحدوث حالات تعطل
أليس هي مهندسة «الخلفية» في فريق المدفوعات، وتحتاج إلى تمرير بيانات «المعاملات» بين عدة «غوروتينات»:
«استخدمت
sync.Mutexلحماية شريحة[]Transactionمشتركة، حيث تقوم 5 غوروتينات بالكتابة إليها و3 غوروتينات بالقراءة منها. ومن بين 100 سطر من الكود، هناك 30 سطرًا عبارة عن أوامر Lock/Unlock. وعندما رأى المراجع ذلك، قال على الفور: «لا يمكنني مراجعة هذا الكود — لن أجرؤ على لمس سطر واحد منه.»»
يبدو كودها كما يلي:
// Shared memory approach: lock on every read/write, hard to maintain
type TransactionPool struct {
mu sync.Mutex
items []Transaction
}
func (p *TransactionPool) Add(t Transaction) {
p.mu.Lock()
defer p.mu.Unlock()
p.items = append(p.items, t)
}
func (p *TransactionPool) Get() Transaction {
p.mu.Lock()
defer p.mu.Unlock()
if len(p.items) == 0 {
return Transaction{}
}
item := p.items[0]
p.items = p.items[1:]
return item
}
(2) الحل بلغة Go: تمرير البيانات عبر قناة
// channel_approach.go
package main
import "fmt"
type Transaction struct {
ID string
Amount float64
}
func main() {
// Create an unbuffered channel
ch := make(chan Transaction)
// Producer goroutine
go func() {
tx := Transaction{ID: "TXN-001", Amount: 99.99}
ch <- tx // Send (blocks until receiver is ready)
fmt.Println("Producer: send complete")
}()
// Consumer
tx := <-ch // Receive (blocks until sender is ready)
fmt.Printf("Consumer: received %s ($%.2f)\n", tx.ID, tx.Amount)
}
الناتج:
Consumer: received TXN-001 ($99.99)
Producer: send complete
(3) الأداء: القناة مقابل الذاكرة المشتركة
| البعد | الذاكرة المشتركة + موتكس | القناة |
|---|---|---|
| حجم الكود | 30 سطراً من كود القوالب | سطر واحد ch <- v |
| أمان الخيوط | القفل/إلغاء القفل يدويًّا | ضمانات اللغة الأصلية |
| الترابط | عالي (مترابط عبر متغيرات مشتركة) | منخفض (يعتمد فقط على واجهة chan) |
| قابلية الاختبار | يتطلب استخدام موتكس وهمي | الاختبار مباشرةً باستخدام قناة |
| خطر حدوث تعطل | مرتفع (مشاكل في ترتيب القفل) | يمكن اكتشاف بعض حالات التعطل في مرحلة التحويل البرمجي |
3. إنشاء القنوات والعمليات الأساسية
(1) إنشاء قناة
package main
import "fmt"
func main() {
// Unbuffered channel (synchronous)
ch1 := make(chan int)
// Buffered channel (asynchronous, capacity 3)
ch2 := make(chan string, 3)
// nil channel (cannot be used directly)
var ch3 chan float64
fmt.Printf("ch1: %T, unbuffered\n", ch1)
fmt.Printf("ch2: %T, buffer=%d\n", ch2, cap(ch2))
fmt.Printf("ch3: %T, nil=%v\n", ch3, ch3 == nil)
}
(2) الإرسال والاستلام
ch := make(chan int)
// Send: ch <- القيمة
ch <- 42 // Unbuffered: blocks until someone receives
// Receive: القيمة := <-ch
القيمة := <-ch // Blocks until someone sends
// Discard received القيمة
<-ch // Receive only, ignore القيمة (used for synchronization signals)
▶ مثال: سلوك التزامن لقناة غير مزودة بمخزن مؤقت
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan string)
go func() {
fmt.Println("goroutine: ready to send...")
ch <- "hello" // Blocks until main receives
fmt.Println("goroutine: send complete")
}()
time.Sleep(1 * time.Second) // Simulate delay
fmt.Println("main: ready to receive...")
msg := <-ch
fmt.Printf("main: received %q\n", msg)
}
الناتج:
goroutine: ready to send...
main: ready to receive...
goroutine: send complete
main: received "hello"
4. القنوات غير المخزنة مقابل القنوات المخزنة
(1) السلوك غير المتزامن للقنوات المخزنة مؤقتًا
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan int, 3) // Capacity 3
go func() {
for i := 1; i <= 5; i++ {
ch <- i
fmt.Printf("Sent %d (len=%d)\n", i, len(ch))
}
close(ch)
}()
time.Sleep(500 * time.Millisecond)
for v := range ch {
fmt.Printf("Received %d\n", v)
time.Sleep(200 * time.Millisecond)
}
}
الناتج:
Sent 1 (len=0)
Sent 2 (len=1)
Sent 3 (len=2)
Sent 4 (len=3)
Sent 5 (len=4)
Received 1
Received 2
Received 3
Received 4
Received 5
▶ مثال: إيقاف التشغيل عند امتلاء المخزن المؤقت
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan int, 2) // Capacity 2
ch <- 1 // ✅ Does not block (has space)
ch <- 2 // ✅ Does not block (has space)
// ch <- 3 // ❌ Blocks! Buffer is full, waiting for receiver
go func() {
time.Sleep(100 * time.Millisecond)
fmt.Println("goroutine: starting to receive")
<-ch // Frees up one slot
}()
fmt.Println("main: sending 3")
ch <- 3 // Now does not block (goroutine has received one)
fmt.Println("main: send complete")
close(ch)
for v := range ch {
fmt.Printf("v=%d\n", v)
}
}
(3) مقارنة بين الأنظمة الخالية من المخزن المؤقت والأنظمة المزودة بمخزن مؤقت
| مقال خاص | بدون تخزين مؤقت make(chan T) |
مع تخزين مؤقت make(chan T, n) |
|---|---|---|
| عملية الإرسال | التوقف حتى يصبح جهاز الاستقبال جاهزًا | عدم التوقف إذا كان هناك مساحة متوفرة في المخزن المؤقت |
| سلوك الاستلام | الحظر حتى يصبح المرسل جاهزًا | عدم الحظر عندما تكون البيانات موجودة في المخزن المؤقت |
| متزامن/غير متزامن | متزامن (المصافحة) | غير متزامن (قائمة الانتظار) |
| السعة | 0 | n |
| السيناريوهات النموذجية | إشارات التزامن، والتنسيق بين الغوروتينات | قوائم انتظار المهام، وخطوط الإنتاج |
5. التكرار حسب القيمة القريبة والنطاق
(1) إغلاق: إغلاق القناة
package main
import "fmt"
func main() {
ch := make(chan int, 3)
ch <- 1
ch <- 2
close(ch) // Cannot send after closing
// Can still receive remaining data after closing
v1 := <-ch // 1
v2 := <-ch // 2
v3 := <-ch // 0 (zero value, ok=false)
fmt.Println(v1, v2, v3)
}
▶ مثال: تقوم وظيفة «comma-ok» بالتحقق مما إذا كانت القناة مغلقة أم لا
package main
import "fmt"
func main() {
ch := make(chan int, 2)
ch <- 10
ch <- 20
close(ch)
// comma-ok: ok=false means channel is closed and has no data
v, ok := <-ch
fmt.Printf("v=%d, ok=%v\n", v, ok) // v=10, ok=true
v, ok = <-ch
fmt.Printf("v=%d, ok=%v\n", v, ok) // v=20, ok=true
v, ok = <-ch
fmt.Printf("v=%d, ok=%v\n", v, ok) // v=0, ok=false (closed and empty)
}
(3) إجراء تكرار عبر القناة باستخدام حلقة for-range
package main
import "fmt"
func main() {
ch := make(chan string, 3)
ch <- "Alice"
ch <- "Bob"
ch <- "Charlie"
close(ch)
// for range automatically exits when the channel is closed
for name := range ch {
fmt.Printf("Hello, %s!\n", name)
}
// Equivalent to:
// for {
// name, ok := <-ch
// if !ok { break }
// fmt.Println(name)
// }
}
for range يتولى ذلك تلقائيًا. ويقع على عاتق المرسل دائمًا مسؤولية استدعاء close.
6. قيود الاتجاه
(1) تحدد معلمة الدالة اتجاه القناة
package main
import "fmt"
// Write-only channel (can only send)
func producer(out chan<- int) {
for i := 1; i <= 3; i++ {
out <- i // ✅ Can only send
}
close(out)
}
// Read-only channel (can only receive)
func consumer(in <-chan int) {
for v := range in {
fmt.Printf("Received: %d\n", v) // ✅ Can only receive
}
}
func main() {
ch := make(chan int, 3)
go producer(ch) // Automatically converts to chan<- int
consumer(ch) // Automatically converts to <-chan int
}
▶ مثال: القيود الاتجاهية في الممارسة العملية (نمط خط الأنابيب)
package main
import (
"fmt"
"strings"
)
// Stage 1: Write-only
func stage1(names []string, out chan<- string) {
for _, name := range names {
out <- strings.ToUpper(name)
}
close(out)
}
// Stage 2: Read-write (read from in, process, write to out)
func stage2(in <-chan string, out chan<- string) {
for name := range in {
out <- "Hello, " + name + "!"
}
close(out)
}
// Stage 3: Read-only
func stage3(in <-chan string) {
for msg := range in {
fmt.Println(msg)
}
}
func main() {
ch1 := make(chan string, 3)
ch2 := make(chan string, 3)
names := []string{"Alice", "Bob", "Charlie"}
go stage1(names, ch1)
go stage2(ch1, ch2)
stage3(ch2)
}
الناتج:
Hello, ALICE!
Hello, BOB!
Hello, CHARLIE!
(3) الاستخدام مع قيود اتجاهية
| الإعلان | الأذونات | الغرض |
|---|---|---|
ch chan T |
القراءة/الكتابة | إعلان المتغيرات |
ch chan<- T |
للكتابة فقط | معلمات دالة producer |
ch <-chan T |
للقراءة فقط | معلمات دالة المستهلك |
7. مقدمة في تقنية «Select Multiplexing»
(1) أساسيات SELECT
package main
import (
"fmt"
"time"
)
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
go func() {
time.Sleep(100 * time.Millisecond)
ch1 <- "from ch1"
}()
go func() {
time.Sleep(200 * time.Millisecond)
ch2 <- "from ch2"
}()
select {
case msg := <-ch1:
fmt.Println(msg)
case msg := <-ch2:
fmt.Println(msg)
case <-time.After(300 * time.Millisecond):
fmt.Println("Timeout")
}
}
select مشابه لـ switch، لكنه يُستخدم للقنوات — حيث يتم تنفيذ أول قناة تصبح جاهزة ضمن case. وإذا أصبحت عدة قنوات جاهزة في نفس الوقت، يتم اختيار واحدة منها عشوائيًا. يُعد select الأداة المثلى للبرمجة المتزامنة في لغة Go؛ وسنتناولها بالتفصيل في الدرس 15.
▶ مثال: تطبيق التحكم في مهلة الانتظار في جمل SELECT
package main
import (
"fmt"
"time"
)
func longOperation(result chan<- string) {
time.Sleep(3 * time.Second)
result <- "Done"
}
func main() {
result := make(chan string)
go longOperation(result)
الاختيار {
case res := <-result:
fmt.Println(res)
case <-time.After(1 * time.Second):
fmt.Println("Operation timed out!")
}
}
الناتج:
Operation timed out!
8. سيناريوهات التعطل الشائعة
// Deadlock 1: Unbuffered channel with send but no receive
func deadlock1() {
ch := make(chan int)
ch <- 42 // fatal error: all goroutines are asleep - deadlock!
}
// Deadlock 2: main goroutine waiting on itself
func deadlock2() {
ch := make(chan int)
<-ch // fatal error: deadlock
}
// Deadlock 3: Multiple goroutines waiting on each other
func deadlock3() {
ch1 := make(chan int)
ch2 := make(chan int)
go func() {
<-ch1 // Wait for ch1
ch2 <- 1
}()
<-ch2 // Wait for ch2
ch1 <- 1 // Never reaches this line
}
(1) مرجع سريع: 4 أنواع من سيناريوهات التعطل
| السيناريو | السبب | الحل |
|---|---|---|
| إرسال فقط، بدون استقبال | حجب القناة غير المخزنة مؤقتًا | التأكد من وجود مستهلكين |
| الاستقبال فقط، بدون إرسال | القناة فارغة ولا يوجد مرسل | تأكد من وجود منتج |
| الجوروتينات التي تنتظر بعضها البعض | A تنتظر قناة B، وB تنتظر قناة A | إعادة تصميم ترتيب التبعية |
| العمليات على القنوات الفارغة | الحظر الدائم على قناة فارغة أو من قناة فارغة | تهيئة قناة |
9. مثال كامل: مسار «المنتج-المستهلك»
// pipeline.go
package main
import (
"fmt"
"math/rand"
"sync"
"time"
)
// Work unit
type Job struct {
ID int
Payload string
}
type Result struct {
Job Job
Output string
Err error
Duration time.Duration
}
// Producer: generates jobs (write-only channel)
func producer(jobs chan<- Job, count int) {
for i := 1; i <= count; i++ {
jobs <- Job{
ID: i,
Payload: fmt.Sprintf("task-%d", i),
}
time.Sleep(time.Duration(rand.Intn(50)) * time.Millisecond)
}
close(jobs)
}
// Consumer: processes jobs (read jobs, write results)
func consumer(id int, jobs <-chan Job, results chan<- Result, wg *sync.WaitGroup) {
defer wg.Done()
for job := range jobs {
start := time.Now()
// Simulate processing
time.Sleep(time.Duration(50+rand.Intn(100)) * time.Millisecond)
output := fmt.Sprintf("consumer-%d processed %s", id, job.Payload)
results <- Result{
Job: job,
Output: output,
Duration: time.Since(start),
}
}
}
// Collector: collects and prints results (read-only channel)
func collector(results <-chan Result, done chan<- struct{}) {
var total time.Duration
count := 0
for r := range results {
count++
total += r.Duration
status := "OK"
if r.Err != nil {
status = "ERR"
}
fmt.Printf("[%s] Job#%d: %s (%v)\n",
status, r.Job.ID, r.Output, r.Duration)
}
if count > 0 {
fmt.Printf("\nTotal %d tasks, avg %.2f ms\n",
count, float64(total.Milliseconds())/float64(count))
}
close(done)
}
func main() {
const (
jobCount = 20
workerCount = 3
)
jobs := make(chan Job, 10)
results := make(chan Result, 10)
done := make(chan struct{})
// 1 producer
go producer(jobs, jobCount)
// N consumers
var wg sync.WaitGroup
for w := 1; w <= workerCount; w++ {
wg.Add(1)
go consumer(w, jobs, results, &wg)
}
// Wait for all consumers to finish, then close results
go func() {
wg.Wait()
close(results)
}()
// Collector (main goroutine)
collector(results, done)
<-done
}
النتيجة المتوقعة:
[OK] Job#1: consumer-2 processed task-1 (85ms)
[OK] Job#2: consumer-1 processed task-2 (92ms)
[OK] Job#3: consumer-3 processed task-3 (78ms)
...
[OK] Job#20: consumer-1 processed task-20 (112ms)
Total 20 tasks, avg 87.45 ms
sequenceDiagram
participant P as Producer
participant C as Channel
participant W as Consumer
Note over P: Sender is responsible for close
P->>C: ch <- job (send)
activate C
Note over C: Unbuffered: sync wait<br/>Buffered: enqueue
C->>W: job := <-ch (receive)
deactivate C
Note over W: Process task
W-->>P: Continue loop
Note over P: close(ch)
Note over C: Remaining data readable
Note over W: for range auto-exit
❓ أسئلة شائعة
v, ok := <-ch، تشير قيمة ok التي تساوي false إلى أن القناة مغلقة ولم يتبق فيها أي بيانات.chan<- و<-chan؟hchan في كود مصدر لغة Go runtime/chan.go.select القنوات؟select إلى قنوات متعددة في آن واحد؛ ويقوم بتنفيذ case لأي قناة تصبح جاهزة أولاً. وإذا أصبحت عدة قنوات جاهزة في نفس الوقت، فإنه يختار واحدة منها عشوائيًا. وإذا لم تكن أي من القنوات جاهزة وتم تحديد حالة default، يتم تنفيذ حالة default؛ وإلا، يتم تعليق العملية والانتظار. select هو المفتاح للتحكم في مهلة الانتظار والعمليات غير المعطلة.select مع جملة default لتنفيذ العمليات غير المعطلة؛ استخدم close لإخطار المستقبل بأن القناة قد أُغلقت.sem := make(chan struct{}, 10)، وإرسال sem <- struct{}{} قبل العملية، واستلام <-sem عند الانتهاء. وتقوم القناة بحجب المرور عندما يمتلئ المخزن المؤقت — مما يوفر تحديدًا طبيعيًا للمعدل.📖 ملخص
- إنشاء قناة:
make(chan T)(بدون مخزن مؤقت)،make(chan T, n)(مع مخزن مؤقت) - قد يؤدي إرسال
ch <- vإلى انقطاع الاتصال؛ وقد يؤدي استقبالv := <-chإلى انقطاع الاتصال - بدون مخزن مؤقت = مصافحة متزامنة؛ مع مخزن مؤقت = قائمة انتظار غير متزامنة
- يتم استدعاء
close(ch)من قِبل المرسل؛ ويقوم المستقبل بالتكرار عليها باستخدامfor range v, ok := <-chيتحقق مما إذا كانت القناة مغلقة أم لا- قيود الاتجاه:
chan<-مخصص للكتابة فقط، و<-chanمخصص للقراءة فقط (يتم التحقق من ذلك في مرحلة الترجمة) - استخدم تقنية التعدد
selectللاستماع إلى قنوات متعددة - حالات التعطل الشائعة: عدم وجود طرف مقابل، التبعيات الدائرية، القناة الفارغة
📝 تمارين
-
المسألة الأساسية (الصعوبة ⭐): اكتب برنامجًا يبدأ «جوروتين» لإرسال الأرقام العشرة من 0 إلى 9 إلى قناة غير مخزنة مؤقتًا، بينما تقوم «الجوروتين» الرئيسية باستلامها وطباعتها. لاحظ التناوب في ترتيب الإرسال والاستلام.
-
مشكلة متقدمة (درجة الصعوبة ⭐⭐): قم بتنفيذ نمط التوزيع: يقوم منتج بإرسال 100 مهمة إلى قناة؛ قم بتشغيل 5 goroutines مستهلكة لقراءة المهام ومعالجتها من نفس القناة، على أن يقوم كل مستهلك بطباعة معرّفه الخاص ورقم المهمة. يجب عليك استخدام قناة مخزنة مؤقتًا، و
close، وfor range. -
التحدي (الصعوبة ⭐⭐⭐): قم بتنفيذ مسار من ثلاث مراحل: المرحلة 1 تولد أعدادًا صحيحة عشوائية (
[]int)؛ المرحلة 2 تستبعد الأعداد الزوجية؛ المرحلة 3 تحسب مجموع المربعات. كل مرحلة عبارة عن goroutine مستقلة متصلة عبر القنوات. المتطلبات: (1) استخدم قيود الاتجاه بين المراحل؛ (2) ادعم التعديل الديناميكي لعدد المراحل؛ (3) قم بتنفيذ خروج أنيق باستخدامselect.