Menu

Goroutines ב-Go: איך הן עובדות, עם דוגמאות

איך מריצים פונקציות במקביל עם מילת המפתח go, מחכים שיסתיימו, מקבלים מהן תוצאות ונמנעים מ-data races, מדליפות ומקריסות שקל כל כך ליצור עם goroutines.

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

הפעלת goroutine

כתבו go לפני קריאה לפונקציה, והקריאה הזאת תרוץ במקביל. הפקודה חוזרת מיד; הקוד הקורא לא מחכה.

ארבע goroutines מריצות את shout באותו זמן. הסדר שבו הן מסתיימות לא מוגדר, אבל הפלט תמיד בסדר המקורי, כי כל goroutine כותבת לאינדקס משלה ו-main קוראת את ה-slice רק אחרי ש-wg.Wait() חוזרת.

הדוגמה הזאת כבר מכילה את שלושת הדברים שכמעט כל תוכנית עם goroutines צריכה: דרך להתחיל עבודה (go), דרך לחכות לה (sync.WaitGroup), ודרך לקבל תוצאות בלי ששתי goroutines ייגעו באותו זיכרון (איבר אחד ב-slice לכל אחת).

main לא מחכה

כש-main חוזרת, התוכנית יוצאת. goroutines שעדיין רצות נעצרות בדיוק איפה שהן נמצאות. שום דבר לא מחכה להן.

בדרך כלל זה מדפיס רק את from main. לפעמים ה-goroutine מתוזמנת בזמן ורואים את שתי השורות. ה"בדרך כלל" הזה הוא הבעיה: קוד שעובד אצלכם במחשב ונכשל בשרת עמוס.

time.Sleep בסוף main גורם לדמו להדפיס את שתי השורות, וזה התיקון הלא נכון. הוא מנחש כמה זמן העבודה לוקחת. חכו לעבודה עצמה, עם WaitGroup (בעמוד על WaitGroup יש הסבר מפורט) או עם channel.

קבלת תוצאות בחזרה

פקודת go זורקת את ערכי ההחזרה של הפונקציה. x := go f() לא מתקמפל. יש שתי דרכים מקובלות להחזיר נתונים.

תא אחד לכל goroutine, כמו בדוגמה הראשונה. הקצו slice מראש, תנו לכל goroutine את האינדקס שלה, וקראו אחרי Wait. הסדר נשמר ואין צורך בנעילה, כי אין שתי goroutines שכותבות לאותו איבר.

channel. כל goroutine שולחת את התוצאה שלה, והצד המקבל אוסף אותן. התוצאות מגיעות לפי סדר הסיום, לא לפי סדר ההתחלה.

קבלה של בדיוק len(nums) ערכים משמשת גם כהמתנה: main לא יכולה לעבור את הלולאה עד שכל goroutine שלחה. סדר ההגעה משתנה בין הרצות, ולכן התוכנית ממיינת לפני שהיא מדפיסה משהו שתלוי בסדר. העמוד על channels מסביר על buffered channels, סגירה, ו-range על channel.

משתני לולאה ו-closures (השינוי ב-Go 1.22)

מאז Go 1.22, כל איטרציה של לולאת for מקבלת עותק חדש של משתני הלולאה. closure שמופעלת ב-goroutine לוכדת את הערך של אותה איטרציה, ולכן הקוד הזה נכון:

for i, w := range words {
	go func() {
		results[i] = shout(w) // Go 1.22+: i and w belong to this iteration
	}()
}

לפני Go 1.22, כל האיטרציות חלקו i אחד ו-w אחד, ובדרך כלל כל goroutine ראתה את הערך האחרון. קוד ישן עוקף את זה על ידי העברת הערכים כארגומנטים, go func(i int, w string) { ... }(i, w), או על ידי הצללה (shadowing), i := i. שתי הדרכים לא מזיקות ב-Go 1.22 ואילך, ועדיין תראו אותן בקוד קיים. ההתנהגות החדשה חלה כשב-go.mod של המודול כתוב go 1.22 ומעלה.

goroutines הן זולות

goroutine מתחילה עם stack קטן (כמה קילובייטים) שה-runtime מגדיל ומקטין לפי הצורך. המתזמן של Go מריץ goroutines על מאגר של threads של מערכת ההפעלה, כשלכל היותר GOMAXPROCS מהם מריצים קוד Go בו זמנית, וכברירת מחדל GOMAXPROCS שווה למספר המעבדים. חסימה על channel, על mutex, על sleep או על I/O ברשת משהה את ה-goroutine ומשחררת את ה-thread לטובת goroutine אחרת.

לכן אין בעיה להפעיל goroutine לכל משימה, גם במספרים גדולים:

מאה אלף goroutines מסתיימות בשבריר שנייה. הסכום הוא תמיד 4999950000, כי atomic.Int64 הופך כל חיבור לפעולה שאי אפשר לחלק. עם זאת, זול לא אומר חינם: כל goroutine שעדיין חסומה שומרת בחיים את ה-stack שלה ואת כל מה שהיא מפנה אליו.

thread של מערכת ההפעלהGoroutine
נוצר על ידיהקרנלה-runtime של Go
stack התחלתיקבוע, לרוב 1 MB או יותרכמה KB, גדל לפי הצורך
מעבר ביניהםcontext switch של הקרנלהמתזמן של Go, ב-user space
זהותיש לו thread IDאין ID שאפשר לקרוא, בכוונה
כמות אופייניתמאותאלפים עד מיליונים

Data races

כששתי goroutines ניגשות לאותו משתנה באותו זמן, ולפחות אחת מהן כותבת, זה data race. התוצאה לא צפויה, ולא רק "קצת לא מדויקת": עדכונים הולכים לאיבוד, ו-race על ערך מסוג string, slice, map או interface יכול להקריס את התוכנית או להשחית זיכרון.

במחשב מרובה ליבות זה מדפיס ברוב ההרצות מספר אחר שקטן מ-10000, כי שתי goroutines קוראות את אותו ערך ישן ושתיהן כותבות בחזרה את הערך הזה ועוד אחד. בליבה אחת זה יכול להדפיס 10000, וזה גרוע יותר: הבאג עובר את הבדיקה שלכם ומופיע ב-production.

Go מגיעה עם race detector. הריצו את התוכנית או את הבדיקות עם -race:

go run -race main.go
go test -race ./...
==================
WARNING: DATA RACE
Read at 0x00c000090038 by goroutine 8:
  main.main.func1()
      /tmp/race/main.go:16 +0x94

Previous write at 0x00c000090038 by goroutine 6:
  main.main.func1()
      /tmp/race/main.go:16 +0xa4
...
Found 2 data race(s)
exit status 66

הוא מצביע על השורה המדויקת (counter++) ועל שתי ה-goroutines. הוא מדווח רק על races שבאמת קורים במהלך ההרצה, אז הריצו אותו על בדיקות שמפעילות את המסלולים המקביליים. הוא מאט את התוכנית פי כמה, ולכן הוא מיועד לבדיקות ול-staging, לא ל-production.

התיקונים, מהפשוט ביותר לכללי ביותר:

  • אל תשתפו. תנו לכל goroutine נתונים משלה ואחדו בסוף (התבנית של תא לכל goroutine).
  • השתמשו ב-sync/atomic למונה או לדגל בודד: var n atomic.Int64; n.Add(1).
  • השתמשו ב-sync.Mutex סביב כל דבר גדול יותר, כמו map או struct עם כמה שדות. העמוד על mutex מסביר גם על RWMutex ועל sync.Once.
  • שלחו את הנתונים דרך channel, כך שרק goroutine אחת היא הבעלים שלהם בכל רגע.

panic ב-goroutine מפיל את התוכנית

אם goroutine נכנסת ל-panic ושום דבר לא מבצע recover בתוך אותה goroutine, כל התוכנית קורסת, כולל main וכל שאר ה-goroutines. recover ב-main לא עוזר, כי recover תופס רק panics ב-goroutine שלו.

recover כזה הגיוני בקצה של שרת שרץ לאורך זמן, שבו בקשה גרועה אחת לא אמורה להפיל את כל השאר. בקוד רגיל, panic בדרך כלל מעיד על באג, וקריסה רועשת היא התוצאה הנכונה.

דליפות goroutines

goroutine שנחסמת לנצח לא יוצאת אף פעם ולא משחררת את הזיכרון שלה. הסיבה הקלאסית היא שליחה שאף אחד לא יקבל לעולם:

func firstResult(urls []string) string {
	ch := make(chan string) // unbuffered
	for _, u := range urls {
		go func() { ch <- fetch(u) }()
	}
	return <-ch // takes the first result; the other senders block forever
}

כל קריאה מדליפה len(urls) - 1 goroutines. בשרת שמטפל בבקשה הזאת אלפי פעמים, הזיכרון מטפס עד שהתהליך מת. שני תיקונים: הגדילו את ה-channel מספיק כדי שכל שולח יוכל לסיים (make(chan string, len(urls))), או תנו ל-goroutines דרך לוותר, בדרך כלל context.Context יחד עם select על ctx.Done(). אפשר לעקוב אחרי דליפות עם runtime.NumGoroutine() בבדיקות.

הגבלת מספר ה-goroutines שרצות בו זמנית

"goroutine אחת לכל פריט" זה בסדר ל-10,000 חישובים זולים. זה לא בסדר ל-10,000 בקשות HTTP לאותו שרת או ל-10,000 קבצים פתוחים. הגבילו את המקביליות עם buffered channel שמשמש כ-semaphore:

ה-buffered channel מחזיק לכל היותר 3 אסימונים, כך שלכל היותר 3 goroutines נמצאות אחרי השורה sem <- בכל רגע. השיא לא יכול לעבור את 3, ועם שתים עשרה משימות שכל אחת מהן ישנה הוא מגיע ל-3 בפועל. מאגר קבוע של worker goroutines שקוראות מ-channel של משימות הוא הצורה הנפוצה השנייה; העמוד על WaitGroup בונה אחד כזה.

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

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

  • לשכוח לחכות. main חוזרת והעבודה פשוט לא קורה, בלי שום הודעה. כל פקודת go צריכה דרך מתאימה לדעת שהיא הסתיימה.
  • לקרוא ל-wg.Add בתוך ה-goroutine. Wait עלולה לרוץ לפני Add, לראות מונה אפס ולחזור מוקדם. קראו ל-Add לפני פקודת go.
  • לשתף משתנה בלי סנכרון. maps הן המקרה הנפוץ: כתיבות מקביליות ל-map בדרך כלל מזוהות על ידי ה-runtime ומקריסות את התוכנית עם fatal error: concurrent map writes, ש-recover לא יכול לתפוס.
  • להניח סדר מסוים. goroutines רצות בכל סדר שהמתזמן בוחר. אם הפלט חייב להיות מסודר, אספו ומיינו, או כתבו לתאים לפי אינדקס.
  • להשתמש ב-time.Sleep לסנכרון. זה הופך בדיקות לאיטיות ועדיין לא יציבות. חכו לאירוע, לא לניחוש.
  • להפעיל goroutine בלי דרך לעצור אותה. כל דבר שרץ בלולאה או מחכה ל-I/O צריך לקבל context.Context, כדי שהקוד הקורא יוכל לבטל אותו.

שאלות נפוצות

מה זה goroutine ב-Go?

goroutine היא קריאה לפונקציה שרצה במקביל לשאר התוכנית. מפעילים אחת על ידי כתיבת go לפני קריאה: go work(). את ה-goroutines מנהל ה-runtime של Go ולא מערכת ההפעלה, וה-runtime מפזר רבות מהן על מספר קטן של threads של מערכת ההפעלה, כך שהפעלה של אלפי goroutines היא דבר רגיל.

איך מחכים ש-goroutines יסתיימו ב-Go?

השתמשו ב-sync.WaitGroup: קראו ל-wg.Add(1) לפני כל פקודת go, ל-defer wg.Done() בתחילת ה-goroutine, ול-wg.Wait() במקום שבו כל ה-goroutines צריכות להיות גמורות. אם ה-goroutines מייצרות ערכים, קבלה של ערך אחד לכל goroutine מתוך channel משמשת גם היא כהמתנה.

מה ההבדל בין goroutine ל-thread?

ל-thread של מערכת ההפעלה יש stack בגודל קבוע (לרוב 1 MB או יותר), והקרנל מתזמן אותו. goroutine מתחילה עם stack של כמה קילובייטים שגדל לפי הצורך, והמתזמן של Go עובר בין goroutines ב-user space. ה-runtime מריץ goroutines על עד GOMAXPROCS threads בו זמנית (כברירת מחדל, מספר המעבדים).

איך מקבלים ערך החזרה מ-goroutine?

פקודת go זורקת את ערכי ההחזרה של הפונקציה. שלחו את התוצאה ב-channel (results <- compute(x)) או כתבו אותה לתא משלכם ב-slice שהוקצה מראש (out[i] = compute(x)) וקראו אותה אחרי wg.Wait().

למה תוכנית ה-Go שלי יוצאת לפני שה-goroutine מדפיסה משהו?

כש-main חוזרת, התוכנית מסתיימת וכל שאר ה-goroutines נעצרות בלי להריץ את הקוד שנשאר להן. שום דבר לא מחכה ל-goroutines אוטומטית. חסמו את main עד שהעבודה נגמרת, עם WaitGroup או עם קבלה מ-channel. הוספת time.Sleep רק מסתירה את הבעיה.

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

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

להתחיל