Menu

WaitGroup ב-Golang: Add, Done, Wait ו-worker pool

איך sync.WaitGroup ממתין שקבוצה של goroutines תסתיים: הכללים של Add, Done ו-Wait, למה חייבים להעביר אותו לפי מצביע, איסוף תוצאות ושגיאות, ו-worker pool שבנוי עליו.

בדף הזה יש עורכים שאפשר להריץ - לערוך, להריץ ולראות את הפלט מיד.

הדפוס הבסיסי

sync.WaitGroup סופר goroutines שרצות. Add מעלה את הספירה, Done מורידה אותה, ו-Wait נחסם עד שהיא אפס.

שלוש ההורדות רצות במקביל, כך שהתוכנית לוקחת בערך 10 ms במקום 30. התוצאות מודפסות לפי סדר הקלט כי כל goroutine כותבת רק לאינדקס שלה ב-sizes, ו-main קוראת אותן רק אחרי Wait.

ערך האפס של WaitGroup מוכן לשימוש. אין בנאי.

שלושת הכללים

קראו ל-Add לפני go, לא בתוך ה-goroutine. אם ה-goroutine קוראת ל-Add בעצמה, main יכולה להגיע ל-Wait לפני שאף goroutine התחילה, לראות ספירה של אפס ולחזור בזמן שהעבודה עוד לא התחילה. כשהספירה ידועה מראש, wg.Add(len(files)) פעם אחת לפני הלולאה שקול לזה.

קראו ל-Done עם defer כשורה הראשונה של ה-goroutine. goroutine שחוזרת מוקדם בגלל שגיאה, או גורמת ל-panic, עדיין מקטינה את המונה. Done חסר משאיר את Wait חסום לנצח. אם זו ה-goroutine היחידה שנשארה, סביבת הריצה מדווחת fatal error: all goroutines are asleep עם sync.WaitGroup.Wait ב-trace.

לעולם אל תעתיקו WaitGroup אחרי השימוש הראשון. העבירו *sync.WaitGroup לפונקציות, או לכדו את המשתנה ב-closure כמו למעלה.

העברת WaitGroup לפונקציה

כשגוף ה-goroutine הוא פונקציה עם שם, העבירו מצביע:

עם wg sync.WaitGroup כפרמטר לפי ערך, כל worker היה קורא ל-Done על העותק שלו ו-main הייתה נחסמת ב-Wait לנצח. go vet תופס את זה לפני שמריצים משהו:

./main.go:8:24: worker passes lock by value: sync.WaitGroup contains sync.noCopy

תכנון נקי יותר משאיר את המקביליות מחוץ ל-worker לגמרי: תנו לה להיות פונקציה רגילה ובצעו את הניהול של Add/Done ב-closure של הקורא. אז קל לבדוק את worker ולקרוא לה באופן סינכרוני.

מונה שלילי

Done היא Add(-1). אם הספירה יורדת מתחת לאפס, התוכנית גורמת ל-panic:

הפלט הוא recovered: sync: negative WaitGroup counter. הסיבה הרגילה היא goroutine עם defer wg.Done() שקוראת גם ל-wg.Done() במפורש באחד המסלולים.

איסוף שגיאות

WaitGroup רק סופר. לשגיאות, תנו לכל goroutine מקום משלה ובדקו אותן אחרי Wait:

errors.Join (Go 1.20) מדלגת על ערכי nil ומחזירה nil אם כולם nil, כך שהיא משלבת "שגיאה אחת לכל goroutine" בלי שום ניהול נוסף.

אם רוצים לעצור את שאר העבודה ברגע ש-goroutine אחת נכשלת, השתמשו במקום זה ב-golang.org/x/sync/errgroup. הוא WaitGroup ועוד השגיאה הראשונה ועוד context שמבוטל בכישלון, ו-g.SetLimit(n) מגביל את המקביליות. הוא נמצא מחוץ לספרייה הסטנדרטית, ולכן לא יכול לרוץ בעורך של הדף הזה:

g, ctx := errgroup.WithContext(ctx)
for _, h := range hosts {
	g.Go(func() error { return checkCtx(ctx, h) })
}
if err := g.Wait(); err != nil {
	return err // the first error; ctx was cancelled for the others
}

Worker pool

מספר קבוע של goroutines שקוראות משימות מ-channel שומר על מקביליות מוגבלת, לא משנה כמה משימות יש. ה-WaitGroup אומר מתי כל ה-workers סיימו, וזה הרגע שבו אפשר לסגור את ה-channel של התוצאות.

הסדר של שלושת החלקים חשוב:

  • main חייבת לקבל תוצאות בזמן שה-workers רצים. אם main הייתה קוראת ל-wg.Wait() ישירות לפני הקריאה, ה-workers היו נחסמים בשליחה ל-results, אף פעם לא היו מגיעים ל-Done, והכול היה נתקע ב-deadlock. לכן Wait רץ ב-goroutine משלו.
  • close(results) קורה רק אחרי Wait, כך שאף worker לא יכול לשלוח ל-channel סגור.
  • מזין המשימות רץ גם הוא ב-goroutine, כך שההזנה והאיסוף חופפים.

איזה worker טיפל באיזו משימה משתנה מהרצה להרצה, ולכן התוכנית ממיינת לפי משימה לפני ההדפסה. כל מה שהיא מדפיסה דטרמיניסטי.

WaitGroup, channel או errgroup

צורךבמה להשתמש
להמתין ל-N goroutines, תוצאות במקומות לפי אינדקסsync.WaitGroup
להמתין ל-goroutine אחתchannel בשם done או ה-channel של התוצאה עצמו
תוצאות שזורמות ככל שהן מסתיימותchannel, שנסגר אחרי wg.Wait()
לעצור הכול בשגיאה הראשונהerrgroup.WithContext
לעצור הכול ב-timeout או בביטול של הקוראcontext.Context ועוד WaitGroup או errgroup

Go 1.25 מוסיפה את wg.Go(func() { ... }), שעושה בשבילכם את ה-Add(1) ואת ה-Done שב-defer. קוד ל-Go 1.24 ולפני כן, כולל העורך של הדף הזה, משתמש בצורה המפורשת שמוצגת למעלה.

טעויות נפוצות

  • wg.Add(1) בתוך ה-goroutine. Wait יכול לחזור לפני שהיא רצה.
  • שכחה של Done בחזרה מוקדמת. תמיד defer wg.Done().
  • העברת ה-WaitGroup לפי ערך. השתמשו במצביע; go vet מסמן את ההעתקה.
  • המתנה באותה goroutine שצריכה לרוקן channel. העבירו את wg.Wait() ואת ה-close ל-goroutine נפרדת.
  • שימוש חוזר ב-WaitGroup לפני שה-Wait הקודם חזר. התחילו מחזור חדש של קריאות Add רק אחרי ש-Wait הסתיים.

שאלות נפוצות

איך sync.WaitGroup עובד ב-Go?

WaitGroup הוא מונה. wg.Add(n) מגדיל אותו, wg.Done() מקטין אותו באחד, ו-wg.Wait() נחסם עד שהוא מגיע לאפס. קראו ל-Add לפני שמפעילים כל goroutine, ל-defer wg.Done() בתוכה, ול-Wait במקום שבו צריך שהכול יסתיים.

האם להעביר WaitGroup לפי ערך או לפי מצביע?

לפי מצביע (*sync.WaitGroup), או לתת ל-goroutines ללכוד אותו ב-closure. לעותק יש מונה משלו, כך ש-Done על העותק אף פעם לא מגיע למקור ו-Wait נחסם לנצח. go vet מדווח על הטעות כ-"passes lock by value".

מה גורם ל-"sync: negative WaitGroup counter"?

יותר קריאות ל-Done מקריאות ל-Add. בדרך כלל goroutine קוראת ל-Done פעמיים (פעם עם defer ופעם במפורש), או ש-Add(1) מדולג באחד המסלולים. התוכנית גורמת ל-panic, כי המונה כבר לא יכול לומר שום דבר נכון.

איך מקבלים שגיאות מ-goroutines שהופעלו עם WaitGroup?

WaitGroup לא נושא תוצאות או שגיאות. תנו לכל goroutine מקום משלה ב-slice של שגיאות ושלבו אותן אחרי Wait (למשל עם errors.Join), או השתמשו ב-golang.org/x/sync/errgroup, שה-Wait שלו מחזיר את השגיאה הראשונה ויכול לבטל את האחרות דרך context.

איור של שפות התכנות ב-Coddy

ללמוד תכנות עם Coddy

להתחיל