کانال‌ بافر شده و بافر نشده در Go — به زبان ساده

۷۸ بازدید
آخرین به‌روزرسانی: ۱۲ مهر ۱۴۰۲
زمان مطالعه: ۴ دقیقه
کانال‌ بافر شده و بافر نشده در Go — به زبان ساده

ساز و کار کانال در Go کاملاً قدرتمند است، اما درک مفاهیم درونی آن و تفاوت کانال‌ بافر شده و بافر نشده موجب می‌شود که بتوانیم بهره بیشتری از آن بگیریم. در واقع انتخاب یک کانال بافر شده یا بافر نشده، رفتار اپلیکیشن و همچنین عملکرد آن را به کلی تغییر می‌دهد.

کانال بافر نشده

کانال بافر نشده کانالی است که به محض ارسال پیامی به کانال، به یک گیرنده نیاز دارد. برای اعلان یک کانال بافر نشده باید یک ظرفیت اعلان کنید.

به مثال زیر توجه کنید:

1package main
2
3import (
4	"sync"
5	"time"
6)
7
8func main() {
9	c := make(chan string)
10
11	var wg sync.WaitGroup
12	wg.Add(2)
13
14	go func() {
15		defer wg.Done()
16		c <- `foo`
17	}()
18
19	go func() {
20		defer wg.Done()
21
22		time.Sleep(time.Second * 1)
23		println(`Message: `+ <-c)
24	}()
25
26	wg.Wait()
27}

Goroutine اول پس از ارسال پیام foo مسدود می‌شود، زیرا هیچ گیرنده‌ای هنوز آماده نیست. این رفتار در مستندات Go به صورت زیر توضیح داده شده است:

اگر ظرفیت صفر یا ناموجود باشد، کانال بافر نشده است و ارتباط تنها زمانی موفق خواهد بود که فرستنده و گیرنده هر دو آماده باشند.

مستندات effective Go (+) نیز در این مورد کاملاً واضح هستند:

اگر کانال بافر نشده باشد، فرستنده تا زمانی که گیرنده، مقدار را دریافت نکرده است، مسدود می‌شود.

بازنمایی درونی کانال، جزییات جالب‌تری در مورد این رفتار نمایش می‌دهد.

بازنمایی درونی کانال

سازه کانال hchan در chan.go از پکیج runtime موجود است. سازنده شامل خصوصیت‌های مرتبط با بافر کانال است، اما برای به تصویر کشیدن کانال بافر نشده این خصوصیت‌ها را نادیده گرفته‌ایم و در ادامه بررسی می‌کنیم. در تصویر زیر بازنمایی یک کانال بافر نشده را می‌بینید:

کانال‌ بافر شده

این کانال اشاره‌گرها یک لیست از گیرنده‌ها (recvq) و فرستنده‌ها (sendq) را نگه‌داری می‌کنند که به وسیله یک لیست پیوندی به نام waitq نمایش می‌یابند. sudog شامل اشاره‌گرهایی به عناصر قبلی و بعدی از اطلاعات مرتبط با goroutine است که فرستنده/گیرنده را مدیریت می‌کند. Go با بهره‌گیری از این اطلاعات می‌تواند به سادگی بداند که چه زمانی در صورت مفقود بودن یک فرستنده، یک کانال باید مسدود شود و یا برعکس.

گردش کار مثال قبلی به صورت زیر است:

  1. کانال با یک لیست خالی از گیرنده‌ها و فرستنده‌ها ایجاد می‌شود.
  2. Goroutine اول مقدار foo را به کانال در خط 16 ارسال می‌کند.
  3. کانال یک سازه sudog از Pool می‌گیرد که فرستنده را نمایش می‌دهد. این ساختار ارجاعی به goroutine و مقدار foo نگهداری می‌کند.
  4. فرستنده اینک در خصوصیت sendq صف‌بندی شده است.
  5. Goroutine وارد حالت انتظار می‌شود و دارای دلیل chan send است.
  6. Goroutine دوم یک پیام را از کانال در خط 23 می‌خواند.
  7. کانال لیست sendq را از صف خارج می‌کند تا فرستنده در انتظار را که به وسیله سازه نمایش یافته در گام 3 دیدیم، به دست آورد.
  8. کانال از تابع memmove استفاده کرده و مقدار ارسالی از سوی فرستنده را کپی می‌کند، در ادامه آن را در سازه sudog قرار می‌دهد و این متغیری است که کانال می‌خواند.
  9. Goroutine اول که در گام 5 پارک شده بود، اینک ازسر گرفته می‌شود و sudog را که در گام 3 گرفته شده بود رها می‌کند.

چنان که در این گردش کار می‌بینیم، goroutine باید به حالت انتظار سوئیچ کند تا این که گیرنده موجود شود. با این حال در موارد ضرورت، به لطف کانال‌های بافر شده، می‌توان از این رفتار مسدودکننده اجتناب کرد.

کانال بافر شده

مثال قبلی را کمی تغییر دادیم تا یک بافر اضافه کنیم:

1package main
2
3import (
4	"sync"
5	"time"
6)
7
8func main() {
9	c := make(chan string, 2)
10
11	var wg sync.WaitGroup
12	wg.Add(2)
13
14	go func() {
15		defer wg.Done()
16
17		c <- `foo`
18		c <- `bar`
19	}()
20
21	go func() {
22		defer wg.Done()
23
24		time.Sleep(time.Second * 1)
25		println(`Message: `+ <-c)
26		println(`Message: `+ <-c)
27	}()
28
29	wg.Wait()
30}

اکنون سازه hchan را با فیلدهای مرتبط با بافر بر اساس این مثال آنالیز می‌کنیم:

کانال‌ بافر شده

این بافر دارای پنج خصوصیت است:

  • Qcount تعداد کنونی عناصر را در بافر نگهداری می‌کند.
  • Dataqsiz تعداد بیشینه عناصر را در بافر نگهداری می‌کند.
  • Buf به قطعه‌ای از حافظه اشاره می‌کند که شامل فضایی برای تعداد بیشینه عناصر در بافر است.
  • Sendx موقعیت بافر را برای عنصر بعدی که باید در کانال دریافت شود نگه‌داری می‌کند.
  • Recvx موقعیتی در بافر برای عنصر بعدی که باید از سوی کانال بازگشت یابد نگهداری می‌کند.

بافر به لطف sendx و recvx مانند یک «صف مدور» (circular queue) کار می‌کند:

کانال‌ بافر شده

صف مدور امکان نگهداری یک ترتیب در بافر بدون نیاز به جابجا کردن عناصر در زمان خروج آن‌ها از بافر را فراهم می‌سازد.

زمانی که به محدودیت بافر برسیم، goroutine که تلاش می‌کند عنصری را در بافر وارد کند به لیست فرستنده جابجا خواهد شد و به وضعیت انتظار می‌رود که در گام قبلی دیدیم. سپس به محض این که برنامه بافر را بخواند، آن عنصر در موقعیت recvx در بافر بازگشت خواهد یافت و goroutine منتظر، از سر گرفته می‌شود و مقدار آن وارد بافر می‌شود. این اولویت‌بندی‌ها به کانال امکان می‌دهد که یک رفتار FIFO داشته باشد.

تأخیرهای ناشی از بافر کوچک

اندازه بافری که در زمان ایجاد کانال تعریف می‌کنیم می‌تواند تأثیر عمده‌ای روی عملکرد آن داشته باشد. ما از الگوی fan-out استفاده می‌کنیم که استفاده گسترده‌ای از کانال می‌کند تا تأثیر اندازه‌های بافر مختلف را بیازماید. به بنچمارک های زیر توجه کنید:

1package bench
2
3import (
4	"sync"
5	"sync/atomic"
6	"testing"
7)
8
9func BenchmarkWithNoBuffer(b *testing.B) {
10	benchmarkWithBuffer(b, 0)
11}
12
13func BenchmarkWithBufferSizeOf1(b *testing.B) {
14	benchmarkWithBuffer(b, 1)
15}
16
17func BenchmarkWithBufferSizeEqualsToNumberOfWorker(b *testing.B) {
18	benchmarkWithBuffer(b, 5)
19}
20
21func BenchmarkWithBufferSizeExceedsNumberOfWorker(b *testing.B) {
22	benchmarkWithBuffer(b, 25)
23}
24
25func benchmarkWithBuffer(b *testing.B, size int) {
26	for i := 0; i < b.N; i++ {
27		c := make(chan uint32, size)
28
29		var wg sync.WaitGroup
30		wg.Add(1)
31
32		go func() {
33			defer wg.Done()
34
35			for i := uint32(0); i < 1000; i++ {
36				c <- i%2
37			}
38			close(c)
39		}()
40
41		var total uint32
42		for w := 0; w < 5; w++ {
43			wg.Add(1)
44			go func() {
45				defer wg.Done()
46
47				for {
48					v, ok := <-c
49					if !ok {
50						break
51					}
52					atomic.AddUint32(&total, v)
53				}
54			}()
55		}
56
57		wg.Wait()
58	}
59}

در بنچمارک ما، یک تولیدکننده، یک میلیون عنصر صحیح را در کانال تزریق کرد، در حالی که ده تولیدکننده آن‌ها را خوانده و به متغیر خروجی منفردی به نام total اضافه می‌کردند. این بنچمارک را ده بار اجرا کرده و نتایج را به کمک benchstat تحلیل کردیم:

name                                    time/op
WithNoBuffer-8                          306µs ± 3%
WithBufferSizeOf1-8                     248µs ± 1%
WithBufferSizeEqualsToNumberOfWorker-8  183µs ± 4%
WithBufferSizeExceedsNumberOfWorker-8   134µs ± 2%

یک بافر با اندازه مناسب می‌تواند موجب سریع‌تر شدن اپلیکیشن شود. در ادامه به بررسی بنچمارک‌ها و تأیید میزان تأخیرها می‌پردازیم.

ردگیری تأخیر

با ردگیری بنچمارک‌ها به یک پروفایل مسدودسازی همگام‌سازی دست پیدا می‌کنیم که نشان می‌دهد goroutine-ها روی انواع مقدماتی همگام‌سازی کجا منتظر می‌شوند. goroutine-ها 9 میلی‌ثانیه در انتظار همگام‌سازی مسدود می‌شوند تا مقداری از کانال بافر نشده خوانده شود، در حالی که بافر با اندازه 50 تنها به مدت 1.9 میلی‌ثانیه مسدود می‌شود.

کانال‌ بافر شده

به لطف بافر، زمانی که تأخیر به ارسال یک مقدار مربوط است، میزان آن پنج برابر کاهش می‌یابد:

کانال‌ بافر شده

اینک می‌توانیم شک و شبه‌های قبلی را تأیید کنیم. اندازه بافر می‌تواند نقش مهمی در عملکرد اپلیکیشن‌های Go داشته باشد.

اگر این مطلب برای شما مفید بوده است، آموزش‌های زیر نیز به شما پیشنهاد می‌شوند:

==

بر اساس رای ۰ نفر
آیا این مطلب برای شما مفید بود؟
اگر بازخوردی درباره این مطلب دارید یا پرسشی دارید که بدون پاسخ مانده است، آن را از طریق بخش نظرات مطرح کنید.
منابع:
blanchon.vincent
نظر شما چیست؟

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *