مدیریت گروه های Goroutine در زبان Go — راهنمای مقدماتی

۲۹۶ بازدید
آخرین به‌روزرسانی: ۲۹ شهریور ۱۴۰۲
زمان مطالعه: ۷ دقیقه
مدیریت گروه های Goroutine در زبان Go — راهنمای مقدماتی

در این مقاله به صورت اجمالی توضیحاتی در مورد انواع و الگوهای مورد استفاده برای مدیریت گروه های Goroutine همزمان که به صورت روزمره در زبان برنامه‌نویسی Go استفاده می‌شوند، ارائه کرده‌ایم. در ابتدا به توضیح گروه پیش‌فرض wait می‌پردازیم و سپس گروه error و موارد پیشرفته‌تر را توضیح می‌دهیم.

997696

آغاز کردن یک Goroutine

آغاز کردن یک Goroutine در زبان Go بسیار ساده است. کافی است به ابتدای فراخوانی یک تابع کلیدواژه go اضافه کنید.

اگر به تازگی با این زبان آشنا شده‌اید، باید انتظار داشته باشید که قطعه کد زیر، اعدادی از 0 تا 9 را به ترتیب تصادفی پرینت کند. ما نیز در ابتدا قطعاً چنین انتظاری داشتیم.

1package main
2
3import "fmt"
4
5func main() {
6	for i := 0; i < 10; i++ {
7		go fmt.Printf("This is job: %v\n", i)
8	}
9}

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

اگر کد فوق را چند بار اجرا کنید ممکن است ببینید که چیزی در خروجی لاگ شده است. دلیل این امر آن است که یک یا چند مورد از goroutine-ها موفق شده‌اند تا پیش از خاتمه اجرای تابع main اجرا شوند. با این حال نکته مهم این است که بدانیم هیچ تضمینی وجود ندارد که هر goroutine که آغاز می‌شود اجرا شود و از این رو باید در این مورد مراقب باشیم.

کتابخانه استاندارد

کتابخانه استاندارد زبان Go بسیار گسترده است و از این رو غالباً می‌توان گزینه مفیدی در آن یافت که یکی از مشکلات موجود را حل می‌کند. در این مورد نیز چنین وضعیتی وجود دارد، چون پکیج sync یک نوع مفید به نام WaitGroup دارد. این گروه ساده‌ترین روش برای توضیح کارکرد انواع با استفاده از مثال است، بنابراین در ادامه به نسخه به‌روزشده برنامه فوق که از گروه wait استفاده می‌کند و مطابق انتظار عمل می‌کند نگاه کنید:

1package main
2
3import (
4	"fmt"
5	"sync"
6)
7
8func main() {
9	var wg sync.WaitGroup
10	for i := 0; i < 10; i++ {
11		wg.Add(1)
12		go func(jobID int) {
13			defer wg.Done()
14			fmt.Printf("This is job: %v\n", jobID)
15		}(i)
16	}
17	wg.Wait()
18}

گروه Wait اساساً فقط یک شمارنده threadsafe است که سه متد زیر را عرضه می‌کند:

  • Add مقدار شمارنده را برحسب مقدار توصیف شده افزایش (یا کاهش) می‌دهد.
  • Done مقدار شمارنده را به یک کاهش می‌دهد.
  • Wait تا زمانی که شمارنده صفر شود مسدود می‌سازد.

برای اطمینان یافتن از این goroutine-ها پیش از خاتمه تابع main اجرا می‌شوند، مقدار 1 را پیش از آغاز هر goroutine به شمارنده اضافه می‌کنیم. همچنین Done را در یک گزاره متأخر در تابعی که در goroutine اجرا می‌شود، فرامی‌خوانیم تا مطمئن شویم شمارنده هر بار که تابع خاتمه می‌یابد کاهش خواهد یافت. در نهایت Wait را پس از آغاز همه goroutine ها فرامی‌خوانیم تا منتظر تمام شدن آن‌ها بماند.

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

خطاها

مثال‌هایی که تا به این جا برسی کردیم همگی بسیار ساده بودند، اما این که فکر کنیم در این موارد هیچ خطایی رخ نمی‌دهد، بسیار آرمان‌گرایانه است. چنین تصوری در دنیای واقعی چندان خوب نیست و از این رو باید اطمینان پیدا کنید که خطاها را در کد پروداکشن به درستی مدیریت می‌کنید. خوشبختانه یک پکیج به نام errgroup (+) وجود دارد که یک پوشش پیرامون گروه wait می‌سازد و این خطاها را مدیریت می‌کند.

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

1func Job(jobID int) error {
2	if rand.Intn(12) == jobID {
3		return fmt.Errorf("job %v failed", jobID)
4	}
5
6	fmt.Printf("Job %v done.\n", jobID)
7	return nil
8}

این تابع با استفاده از پکیج داخلی rand صرفاً یک عدد صحیح تصادفی بین 0 و 11 تولید می‌کند. اگر عدد تولیدشده برابر با id مربوط به job باشد در این صورت آن job ناموفق خواهد بود و خطایی بازگشت می‌یابد. در غیر این صورت ادامه کار از سر گرفته می‌شود. اینک مثالی را ملاحظه کنید که از گروه error و این تابع job استفاده می‌کند.

1func main() {
2	var eg errgroup.Group
3
4	for i := 0; i < 10; i++ {
5		jobID := i
6		eg.Go(func() error {
7			return Job(jobID)
8		})
9	}
10
11	if err := eg.Wait(); err != nil {
12		fmt.Println("Encountered error:", err)
13	}
14	fmt.Println("Successfully finished.")
15}

این حالت شباهت زیادی به شیوه استفاده از گروه wait دارد که قبلاً دیدیم و شاید حتی ساده‌تر باشد. ابتدا یک گروه ساختیم و سپس به جای آغاز goroutine-ها از سوی خودمان و فراخوانی توابع add و done به ترتیب پیش از آغاز و در زمان پایان، صرفاً تابع Go را روی گروه error فراخوانی می‌کنیم. بدین ترتیب تابع ارائه‌شده در goroutine اجرا می‌شود و سپس بی‌درنگ بازگشت می‌یابد و از این رو می‌توانیم حلقه را اجرا کنیم. توجه داشته باشید که در این حالت یک کپی از متغیر i به نام jobId ساخته می‌شود که در تکرار جاری حلقه تعریف شده است و بدین ترتیب مشکل i را که در استفاده مجدد بین تکرارها ایجاد می‌شد حل کرده‌ایم.

همانند قبل پس از آغاز کردن goroutine-ها صبر می‌کنیم تا پایان یابند. البته در این مورد تابع wait یک خطا بازگشت می‌دهد. این نخستین خطایی است که با آن موجه می‌شویم، از این رو اگر بیش از یک job شکست بخورد، همه موارد را به جز خطای اول از دست می‌دهیم. این حالت معمولاً اشکالی ندارد، چون در اغلب موارد نمی‌خواهیم چیزی شکست بخورد.

Context

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

پکیج errgroup یک روش جایگزین برای ایجاد گروه error با استفاده از Context مطرح کرده که از طریق فراخوانی تابع WithContext است. این تابع یک Context به عنوان آرگومان می‌گیرد و یک گروه error همراه با Context بازگشت می‌دهد که فرزند Context ارائه شده است. این بدان معنی است که هر زمان Context والد لغو شود، Context فرزند نیز لغو خواهد شد.

Context بازگشتی از سوی تابع WithContext نیز هر زمان که هر کدام از تابع‌های اجرا شده در گروه error با خطا خاتمه یابند، لغو خواهد شد. بدین ترتیب می‌توانیم اجرای job-های دیگر را در گروه به محض مواجهه با نخستین خطا متوقف کنیم. توجه کنید که Context گروه خطا نیز زمان فراخوانی نخست به wait بازگشت می‌یافت لغو می‌شود و از این رو Context نمی‌تواند مجدداً مورد استفاده قرار گیرد. اینک job خود را طوری تغییر می‌دهیم که با Context کار کند:

1func JobWithCtx(ctx context.Context, jobID int) error {
2	select {
3	case <-ctx.Done():
4		fmt.Printf("context cancelled job %v terminting\n", jobID)
5		return nil
6	case <-time.After(time.Second * time.Duration(rand.Intn(3))):
7	}
8	if rand.Intn(12) == jobID {
9		fmt.Printf("Job %v failed.\n", jobID)
10		return fmt.Errorf("job %v failed", jobID)
11	}
12
13	fmt.Printf("Job %v done.\n", jobID)
14	return nil
15}

در کد فوق context را به عنوان آرگومان اول اضافه کرده‌ایم. توجه داشته باشید که در زبان Go این رسم وجود دارد که هر زمان تابعی از context استفاده می‌کند، آن را به عنوان آرگومان اول ارسال کنیم. در ابتدای تابعی که نوشتیم، یک گزاره select وجود دارد که از کانال Done مربوط به context و کانال بازگشتی از فراخوانی به time.After می‌خواند. زمان جاری روی کانال زمان پس از مدت معین شده از ایجاد ارسال می‌شود. این کار اساساً یک sleep تصادفی ایجاد می‌کند که می‌تواند به وسیله لغو کردن context دچار وقفه شود و بدین ترتیب تابع خاتمه می‌یابد. بقیه تابع همانند قبل است.

اکنون تنها چیزی که نیاز داریم در تابع main انجام دهیم، این است که از context برای ایجاد گروه error با استفاده از متد WithContext استفاده کنیم و context بازگشتی را به همه job-هایی که آغاز شده‌اند بفرستیم. بنابراین کد به صورت زیر درمی‌آید.

1func main() {
2	eg, ctx := errgroup.WithContext(context.Background())
3
4	for i := 0; i < 10; i++ {
5		jobID := i
6		eg.Go(func() error {
7			return JobWithCtx(ctx, jobID)
8		})
9	}
10
11	if err := eg.Wait(); err != nil {
12		fmt.Println("Encountered error:", err)
13	}
14	fmt.Println("Successfully finished.")
15}

یک کار دیگر که می‌توانیم انجام دهیم، این است که تلاش کنیم زمانی که برنامه یک سیگنال وقفه دریافت می‌کند، خروج تمیزی داشته باشیم. به این منظور بهتر است از context ایجاد شده به وسیله تابع زیر به جای context بازگشتی از ()context.Background که هرگز لغو نمی‌شود بهره بگیریم. این context جدید به محض این که برنامه سیگنال وقفه را دریافت کند، لغو می‌شود و این امر موجب می‌شود که همه فرزندانش شامل گروه error نیز لغو شوند و از این رو یک خاتمه تمیز تضمین می‌شود.

1func NewCtx() context.Context {
2	ctx, cancel := context.WithCancel(context.Background())
3	go func() {
4		sCh := make(chan os.Signal, 1)
5		signal.Notify(sCh, syscall.SIGINT, syscall.SIGTERM)
6		<-sCh
7		cancel()
8	}()
9	return ctx
10}

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

گروه error نیازهای شما را پاسخگو نیست، چه باید کرد؟

گروه Error عالی است، اما برخی اوقات ما به رفتار اندکی متفاوت نیاز داریم. برای نمونه ممکن است علاقه‌مند باشیم به جای بازگشت دادن خطای نخست، یک slice شامل همه خطاهایی که رخ داده بازگشت دهیم. گروه error از چنین رفتاری پشتیبانی نمی‌کند، بنابراین شاید بهتر باشد کد منبع آن را مورد بررسی قرار دهید و چنین کارکردی را خودتان به آن اضافه کنید. شاید این بهترین کار باشد زیرا کد آن (+) کاملاً ساده است و تنها 66 خط کد دارد. تعویض sync.Once با یک mutex و یک خطای منفرد با دسته‌ای از خطاها، موجب می‌شود رفتاری که می‌خواهیم را به دست آوریم.

اگر به دنبال جایگزین‌هایی هستید که به صورت آماده در اختیار شما قرار گیرند، می‌توانید پکیج داخلی github.com/uw-labs/sync را بررسی کنید. این پکیج شامل دو پکیج فرعی است که یکی rungroup با نوعی است که دقیقاً شبیه به گروه error عمل می‌کند به جز این که استفاده از context را الزام کرده و context زیرین را به محض خاتمه تابع‌های آغاز شده گروه، لغو می‌کند. این وضعیت برای pipeline-های اجرایی که همه کامپوننت‌ها به ترتیب اجرا می‌شود مناسب است.

پکیج فرعی دیگر gogroup نام دارد و مانند rungroup عمل می‌کند به جز این که یک فراخوانی به Wait به محض خاتمه تابع آغاز شده اول در گروه بازگشت می‌یابد. این وضعیت برای پیاده‌سازی یک سرور gRPC با استریمینگ دوطرفه مناسب است، زیرا به تابع handler امکان می‌دهد که وقتی سرور با خطایی مواجه شد، در صورتی که goroutine همچنان منتظر پیامی از سوی کلاینت باشد، بازگشت یابد.

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

==

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

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