Menu

Context ב-Golang: ביטול, timeouts וערכים

איך context.Context מעביר ביטול, deadlines וערכים ששייכים לבקשה לאורך תוכנית Go: Background, WithCancel, WithTimeout, WithValue, ctx.Done בתוך select, ו-context בשרתים ובלקוחות HTTP.

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

timeout בעשר שורות

התפקיד של context.Context הוא לומר לקוד מתי לעצור. כאן פעולה איטית מקבלת 50 ms, ומוותרת כשה-context אומר לה:

הקריאה הראשונה מסתיימת תוך 10 ms ומחזירה rows <nil>. השנייה הייתה צריכה 200 ms, אבל ה-context פג אחרי 50 ms (שנספרים מרגע היצירה שלו), ולכן היא מחזירה context deadline exceeded.

שום דבר לא נעצר בכוח. ל-Go אין דרך להרוג goroutine מבחוץ. context הוא אות, והקוד צריך לבדוק אותו: באמצעות select על ctx.Done(), בדיקת ctx.Err() בין שלבים, או העברת ctx לקריאות ספרייה (http.NewRequestWithContext, db.QueryContext, exec.CommandContext) שבודקות אותו בשבילכם.

הממשק Context

type Context interface {
	Deadline() (deadline time.Time, ok bool)
	Done() <-chan struct{}
	Err() error
	Value(key any) any
}
מתודהמחזירה
Done()channel שנסגר כשה-context מבוטל או כשנגמר הזמן שלו (nil עבור context שלעולם לא יכול להתבטל)
Err()nil כל עוד הוא פעיל, ואחר כך context.Canceled או context.DeadlineExceeded
Deadline()את ה-deadline ואת true, או ok == false אם אין כזה
Value(key)את הערך שנשמר תחת key ב-context הזה או באחד מאבותיו, או nil

contexts הם בלתי ניתנים לשינוי. אף פעם לא משנים context; גוזרים ממנו context בן עם אחת מפונקציות ה-With, והבן מוסיף אות ביטול, deadline או ערך.

מאיפה מגיע context

כל עץ של contexts מתחיל בשורש:

  • context.Background() עבור main, init, בדיקות וההגדרות ברמה העליונה של שרתים.
  • context.TODO() כשפונקציה צריכה לקבל context אבל למי שקורא לה עוד אין כזה. הוא מתנהג בדיוק כמו Background; השם הוא סימון לשכתוב עתידי.

בתוך handler של HTTP לא יוצרים שורש. משתמשים ב-r.Context(), שהשרת מבטל כשהלקוח מתנתק או כשה-handler חוזר.

WithCancel: עצירה לפי דרישה

context.WithCancel מחזירה context בן ופונקציית cancel. קריאה ל-cancel סוגרת את ה-channel Done של הבן ואת ערוצי ה-Done של כל מה שנגזר ממנו.

השליחה של ה-producer יושבת בתוך select לצד ctx.Done(). זה מה שמאפשר לו לעצור: out <- i חשוף היה נחסם לנצח ברגע שה-consumer הפסיק לקרוא, וה-goroutine הייתה דולפת. ה-for range nums האחרון מחכה עד שה-producer סוגר את ה-channel. בזמן שהוא מרוקן, ה-producer עוד עשוי להספיק לשלוח ערך או שניים, כי כששני המקרים של ה-select שלו מוכנים Go בוחרת אחד באקראי; הביטול מהיר, אבל לא מיידי.

בטוח לקרוא ל-cancel יותר מפעם אחת ומכל goroutine. רק הקריאה הראשונה עושה משהו.

WithTimeout ו-WithDeadline

WithTimeout(parent, d) שקולה ל-WithDeadline(parent, time.Now().Add(d)). השתמשו ב-timeout עבור "לכל היותר זמן כזה" וב-deadline כשיש לכם זמן מוחלט.

כשהזמן עובר, Done נסגר ו-Err מחזירה context.DeadlineExceeded. אם קוראים ל-cancel קודם, Err מחזירה context.Canceled. בדקו איזה מהם בעזרת errors.Is, כי ספריות בדרך כלל עוטפות את השגיאה:

תמיד קראו ל-cancel, גם עבור timeout שיפקע מעצמו. ה-context מחזיק טיימר ומקום אצל ההורה שלו עד שאחד מהם קורה, ו-defer cancel() משחרר את שניהם ברגע שהפונקציה חוזרת. go vet מדווח על פונקציית cancel שנזרקה: the cancel function returned by context.WithTimeout should be called, not discarded, to avoid a context leak.

ילדים לא יכולים לחיות יותר מההורים שלהם

contexts יוצרים עץ. ביטול הורה מבטל כל צאצא. לבן יכול להיות deadline קצר יותר מלהורה שלו, אף פעם לא ארוך יותר: ה-deadline המוקדם תמיד גובר.

זה מה שהופך contexts לשימושיים לאורך שכבות. handler של HTTP מקבל context שמת יחד עם הבקשה; קריאה למסד נתונים שלוש שכבות למטה גוזרת ממנו timeout של 2 שניות. אם הלקוח מנתק אחרי 100 ms, השאילתה מבוטלת באותו רגע, לא שתי שניות אחר כך.

תמיד בצעו select על ctx.Done() כשאתם נחסמים

כל goroutine שמחכה (לשליחה ל-channel, לקבלה, לטיימר) צריכה לחכות גם ל-ctx.Done() באותו זמן. עבור לולאות שעסוקות במעבד ואף פעם לא נחסמות, בדקו את ctx.Err() מדי פעם:

for i, item := range items {
	if i%1000 == 0 {
		if err := ctx.Err(); err != nil {
			return err
		}
	}
	process(item)
}

השתמשו ב-time.After בתוך select להמתנה פשוטה, אבל העדיפו טיימר שאפשר לעצור (או timeout של context) כשההמתנה עשויה להתבטל לעיתים קרובות.

סיבות ביטול (Go 1.20 ו-1.21)

ctx.Err() אומרת רק canceled או deadline exceeded. כדי לתעד למה, השתמשו בגרסאות ה-Cause:

WithCancelCause הגיעה ב-Go 1.20, ו-WithTimeoutCause ו-WithDeadlineCause ב-Go 1.21. Err ממשיכה להחזיר את הערכים הסטנדרטיים כך שבדיקות קיימות ממשיכות לעבוד; context.Cause נותנת את הפרט.

WithValue, במשורה

context.WithValue(parent, key, value) מצמידה ערך אחד. ctx.Value(key) מחפשת אותו לאורך שרשרת ההורים.

כללים לערכים:

  • השתמשו בטיפוס לא מיוצא למפתחות, אף פעם לא ב-string פשוט. שתי חבילות שמשתמשות שתיהן ב-"user" ידרסו זו את זו. (go vet לא תופס את זה; staticcheck כן.)
  • עטפו את הגישה בפונקציות עזר עם טיפוסים כמו WithRequestID ו-RequestID, כך שהקוראים אף פעם לא רואים any או את המפתח.
  • שמרו רק נתונים ששייכים לבקשה ועוברים דרך APIs: מזהי trace ומזהי בקשה, המשתמש המאומת, logger. אף פעם לא פרמטרים אופציונליים, חיבורים למסד נתונים או קונפיגורציה. אלה שייכים לארגומנטים של פונקציות או לשדות של struct, שם הקומפיילר יכול לבדוק אותם והקוראים יכולים לראות אותם.
  • החיפוש עובר על השרשרת הורה אחד בכל פעם, כך שכל ערך שמוסיפים מאריך בצעד את החיפוש של האחרים.

מוסכמות

  • ctx context.Context הוא הפרמטר הראשון של כל פונקציה שמבצעת I/O, נחסמת, או קוראת למשהו שעושה זאת: func Fetch(ctx context.Context, url string) error.
  • אל תשמרו context בתוך struct. העבירו אותו לכל קריאה למתודה. context שייך לפעולה אחת, ו-struct בדרך כלל חי יותר ממנה. (היוצא מן הכלל הוא טיפוס שמייצג פעולה יחידה, כמו http.Request.)
  • אף פעם אל תעבירו nil בתור context. השתמשו ב-context.TODO() אם אין לכם משהו טוב יותר.
  • החזירו את ctx.Err(), או עטפו אותה עם %w, כשאתם עוצרים בגלל ה-context, כדי שמי שקרא יוכל להבחין בין timeout לכישלון אמיתי.

Context בשרתים ובלקוחות HTTP

בצד השרת: r.Context() מבוטל כשהלקוח מתנתק, כשה-handler חוזר, או כש-stream של HTTP/2 מאופס. בצד הלקוח: http.NewRequestWithContext גורמת לבקשה לכבד timeout או ביטול. התוכנית הבאה מריצה את שני הצדדים דרך httptest:

הלקוח מוותר אחרי 50 ms וסוגר את החיבור. השרת מבחין בזה, ה-context של הבקשה שלו מבוטל, וה-handler עוצר במקום לבזבז עוד 450 מילישניות על דוח שאף אחד לא יקרא. ב-handler אמיתי מעבירים את r.Context() הלאה לכל קריאה למסד נתונים ול-HTTP, וכולן עוצרות יחד.

פונקציות עזר נוספות (Go 1.21)

  • context.WithoutCancel(ctx) מחזירה context עם אותם ערכים שלא מתבטל כש-ctx מתבטל. השתמשו בה לעבודה שחייבת להסתיים אחרי שהבקשה נגמרת, כמו כתיבת audit log.
  • context.AfterFunc(ctx, f) מריצה את f ב-goroutine משלה ברגע ש-ctx מסתיים, ומחזירה פונקציית stop לביטול הרישום.

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

  • לא לקרוא ל-cancel. תמיד כתבו defer cancel() מיד אחרי WithCancel, WithTimeout או WithDeadline.
  • הפעלת goroutine שמתעלמת מ-ctx. אם היא נחסמת בלי select על ctx.Done(), הביטול לא עושה כלום וה-goroutine דולפת.
  • יצירת context.Background() חדש עמוק בתוך שרשרת קריאות. זה מנתק את הקשר ל-deadline ולביטול של מי שקרא. העבירו את ה-ctx שקיבלתם.
  • השוואת שגיאות עם ==. השתמשו ב-errors.Is(err, context.DeadlineExceeded); רוב הספריות עוטפות אותה.
  • שימוש ב-WithValue עבור תלויות. חיבור למסד נתונים שמוסתר בתוך context הוא פרמטר שהקומפיילר כבר לא יכול לבדוק.
  • לצפות שהביטול יהיה מיידי. הקוד מבחין בו רק בבדיקה הבאה שלו. לולאה ארוכה בלי בדיקה ממשיכה לרוץ.

שאלות נפוצות

למה משמש context ב-Go?

context.Context אומר לפונקציה ולכל מה שהיא קוראת לו מתי לוותר: כי מי שקרא ביטל, כי עבר deadline, או כי הלקוח התנתק. הוא יכול גם לשאת ערכים ששייכים לבקשה, כמו מזהה בקשה. לפי המוסכמה הוא הפרמטר הראשון, ושמו ctx.

מה ההבדל בין context.Background ל-context.TODO?

שתיהן מחזירות context ריק שאף פעם לא מבוטל ואין לו deadline או ערכים. הן מתנהגות בדיוק אותו דבר. Background() הוא השורש עבור main, בדיקות והגדרות ברמה העליונה. TODO() מסמן מקום שבו צריך להעביר context אמיתי אבל לקוד שמסביב עוד אין כזה, וכך קל למצוא אותו אחר כך.

למה צריך לקרוא ל-cancel אחרי context.WithTimeout?

WithTimeout, WithDeadline ו-WithCancel רושמות את ה-context החדש אצל ההורה שלו ועשויות להפעיל טיימר. קריאה ל-cancel משחררת את המשאבים האלה ברגע שסיימתם, במקום כשה-timeout פוקע או כשההורה מבוטל. כתבו defer cancel() מיד אחרי היצירה; go vet מזהיר כשפונקציית cancel נזרקת.

מה המשמעות של "context deadline exceeded" ב-Go?

זה הטקסט של context.DeadlineExceeded, השגיאה ש-ctx.Err() מחזירה אחרי שה-deadline של context עבר. פונקציות שמכבדות את ה-context, כמו לקוחות HTTP ודרייברים של מסדי נתונים, מחזירות אותה (לעיתים קרובות עטופה) כשנגמר להן הזמן. בדקו אותה עם errors.Is(err, context.DeadlineExceeded).

האם כדאי להשתמש ב-context.WithValue כדי להעביר פרמטרים?

לא. השתמשו בו רק לנתונים ששייכים לבקשה, שחוצים גבולות של API ושהפונקציות שבדרך לא צריכות להכיר, כמו מזהה trace או המשתמש המאומת. כל מה שפונקציה צריכה כדי לעשות את העבודה שלה שייך לפרמטרים שלה, שם הקומפיילר בודק אותו.

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

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

להתחיל