Menu

select ב-Golang: timeouts, default ו-channels לעצירה

איך select ממתינה לכמה פעולות channel בבת אחת: בחירה בין מקרים מוכנים, שליחה וקבלה בלי חסימה עם default, timeouts עם time.After, ועצירת לולאות עם quit channel או context.

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

המתנה לכמה channels

select נראית כמו switch, אבל כל מקרה הוא שליחה או קבלה ב-channel. היא נחסמת עד שמקרה אחד יכול להתבצע, ואז מריצה אותו.

עם ההשהיות האלה, ה-select הראשונה מקבלת את התוצאה המהירה והשנייה את האיטית. אף קבלה לא צריכה לחכות ל-channel השני. <-slow רגיל ואחריו <-fast היו מטפלים בהם בסדר קבוע, בלי קשר למי שהגיע קודם.

איך select מוערכת:

  1. כל ביטויי ה-channel והערכים לשליחה מוערכים פעם אחת, לפי סדר הקוד, כשה-select מתחילה.
  2. אם מקרה אחד או יותר מוכנים, אחד מהם נבחר באקראי.
  3. אם אף אחד לא מוכן ויש default, ה-default רץ.
  4. אחרת ה-goroutine נחסמת עד שמקרה כלשהו נעשה מוכן.

select {} ריקה נחסמת לנצח. לפעמים רואים אותה בסוף main בתוכניות שהעבודה האמיתית שלהן קורית ב-goroutines אחרות.

בחירה אקראית בין מקרים מוכנים

כשכמה מקרים מוכנים באותו זמן, select לא מעדיפה את הראשון ברשימה. התוכנית הזאת ממלאת שני channels עם buffer ואז מבצעת select אלף פעמים:

החלוקה בין countA ל-countB משתנה בכל הרצה ומתקרבת ל-500 לכל אחד. הבחירה האקראית מכוונת: היא מונעת מ-channel עמוס להרעיב את האחרים. אם צריך עדיפות, ראו את הדפוס בהמשך.

פעולות בלי חסימה עם default

עם מקרה default, select אף פעם לא נחסמת. זה הופך שליחה או קבלה לפעולת "ניסיון":

ויתור על עבודה כשה-buffer מלא הוא הדרך להוריד עומס או לשלוח מטריקות בלי לעכב אף פעם את הקורא.

אל תשימו default ב-select שבתוך לולאת for רק כדי "לבדוק" channels שוב ושוב. כששום דבר לא מוכן, הלולאה מסתובבת ב-100% CPU. היחסמו במקום זה, והוסיפו מקרה timeout אם צריך להתעורר מדי פעם.

Timeouts

time.After(d) מחזירה channel שמקבל ערך פעם אחת אחרי d. הריצו אותו במרוץ מול העבודה האמיתית:

הקריאה הראשונה מחזירה "data". השנייה מחזירה את שגיאת ה-timeout אחרי 50 ms, הרבה לפני שה-worker היה מסיים. ל-channel של התוצאה יש buffer בגודל 1 בכוונה. כשה-timeout מנצח, אף אחד לא מקבל אף פעם מ-result; עם channel בלי buffer, ה-goroutine של ה-worker הייתה נחסמת על השליחה לנצח ודולפת.

בלולאה, time.After יוצרת טיימר חדש בכל איטרציה, וזה בדיוק מה שצריך ל-timeout של חוסר פעילות לכל הודעה ("אין הודעה כבר שנייה"). ל-deadline כולל על פני הרבה פעולות, צרו טיימר אחד או context אחד לפני הלולאה. מאז Go 1.23, טיימרים שאין אליהם עוד הפניה מפונים על ידי ה-garbage collector גם אם עוד לא הופעלו, כך ש-time.After בלולאה כבר לא מחזיקה זיכרון עד שכל טיימר מופעל, כמו בגרסאות ישנות (זה דורש go 1.23 ומעלה ב-go.mod).

לולאות for-select ו-quit channels

goroutine שרצה עד שאומרים לה לעצור היא לולאת for סביב select עם מקרה אחד לעבודה ומקרה אחד לעצירה:

סגירת quit במקום שליחה אליו היא הניב המקובל: סגירה נראית לכל מקבל, עכשיו ובהמשך, כך ש-close אחד עוצר כל מספר של workers. ה-channel done מאפשר ל-main לחכות עד שה-worker באמת חזר.

בקוד אמיתי ה-quit channel הוא בדרך כלל context.Context: case <-ctx.Done():. הוא עובד באותה צורה (Done() מחזירה channel שנסגר בביטול) ונושא גם deadlines ואת הסיבה לעצירה. דף ה-context מכסה את זה.

break בתוך select

break במקרה של select יוצא מה-select, לא מה-for שעוטף אותה. זה מקור נפוץ ללולאות שלא נגמרות אף פעם. השתמשו ב-return, או שימו תווית על הלולאה:

עדיפות בין channels

מכיוון ש-select בוחרת באקראי, אי אפשר לדרג מקרים בתוך פקודה אחת. כדי ש-channel אחד ינצח בכל פעם שיש בו משהו, בדקו אותו קודם לבד:

for {
	select {
	case <-ctx.Done():
		return ctx.Err()
	default:
	}

	select {
	case <-ctx.Done():
		return ctx.Err()
	case job := <-jobs:
		handle(job)
	}
}

ה-select הראשונה חוזרת מיד אם הביטול כבר קרה. בלעדיה, זרם קבוע של jobs יכול להמשיך לנצח בבחירה האקראית עוד זמן מה אחרי ש-ctx בוטל.

channels שהם nil מנטרלים מקרה

שליחה או קבלה ב-channel שהוא nil אף פעם לא מוכנה, כך שמקרה על channel שהוא nil בפועל כבוי. הצבת nil בקלט שנסגר היא הדרך להפסיק לבצע עליו select ולהמשיך עם האחרים; דף ה-channels מראה לולאת מיזוג שבנויה כך. אותו טריק מדליק ומכבה timeout: השאירו var timeout <-chan time.Time כ-nil עד שצריך אותו, ואז הציבו בו time.After(d).

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

  • ציפייה לסדר הקוד. המקרה הראשון ברשימה לא מקבל עדיפות.
  • לולאה עסוקה עם default. for { select { ... default: } } בלי שום דבר לעשות שורפת ליבת CPU.
  • דליפה של המפסיד. כשה-timeout מנצח, ה-goroutine שהייתה אמורה לשלוח את התוצאה עדיין צריכה להיות מסוגלת לסיים. תנו ל-channel שלה buffer בגודל 1.
  • break שיוצא רק מה-select. השתמשו בתווית או ב-return.
  • time.After אחד לכל איטרציה של הלולאה שמשמש כ-deadline כולל. הוא מתחיל מחדש בכל איטרציה; צרו את ה-deadline פעם אחת, מחוץ ללולאה.

שאלות נפוצות

מה select עושה ב-Go?

select ממתינה עד שאחת מפעולות ה-channel שלה (שליחה או קבלה) יכולה להתבצע, ואז מריצה את המקרה הזה. אם כמה מוכנים באותו זמן, היא בוחרת אחד באקראי. בלי מקרה default היא נחסמת עד שמקרה כלשהו מוכן; עם default היא אף פעם לא נחסמת.

איך מוסיפים timeout לקבלה מ-channel ב-Go?

שימו את הקבלה וטיימר באותה select: select { case v := <-ch: use(v); case <-time.After(2 * time.Second): return errTimeout }. מה שקורה קודם מנצח. בתוך לולאה, או כשלקורא כבר יש deadline, השתמשו במקום זה ב-context.Context עם context.WithTimeout ובחרו ב-select על ctx.Done().

האם select ב-Go בוחרת מקרים לפי הסדר?

לא. כשיותר ממקרה אחד מוכן, Go בוחרת באקראי ובהסתברות שווה, כך ששום מקרה לא יכול להרעיב את האחרים. אם צריך עדיפות, בדקו קודם את ה-channel בעדיפות הגבוהה ב-select משלו עם default, ורק אחר כך עברו ל-select על כל ה-channels.

למה break לא יוצא מלולאת for-select שלי?

בתוך select, break יוצא רק מפקודת ה-select, לא מה-for שעוטף אותה. השתמשו ב-return, או שימו תווית על הלולאה (loop: for { select { case <-done: break loop } }).

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

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

להתחיל