הפעלת 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 רק מסתירה את הבעיה.