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 או המשתמש המאומת. כל מה שפונקציה צריכה כדי לעשות את העבודה שלה שייך לפרמטרים שלה, שם הקומפיילר בודק אותו.