Menu

Mutex ב-Golang: sync.Mutex, RWMutex, atomic ו-sync.Once

איך מגנים על state משותף בין goroutines עם sync.Mutex ו-sync.RWMutex, מתי sync/atomic מספיק, איך sync.Once מריץ אתחול בדיוק פעם אחת, ואילו טעויות נעילה גורמות ל-deadlocks.

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

הגנה על נתונים משותפים

sync.Mutex מאפשר ל-goroutine אחת בכל פעם להיכנס לקוד שבין Lock ל-Unlock. שימו את ה-mutex ליד הנתונים שהוא שומר עליהם, בדרך כלל באותו struct:

זה תמיד מדפיס hits: 10000. בלי המנעול, 100 goroutines שכותבות לאותו map היו בדרך כלל מקריסות את התוכנית עם fatal error: concurrent map writes. השגיאה הזאת מגיעה מבדיקה של ה-runtime שלא מובטח שתתפוס הכול, אי אפשר להתאושש ממנה, והיעדר קריסה לא מוכיח שהקוד נכון.

פרטים שחשובים:

  • ערך האפס של sync.Mutex לא נעול ומוכן לשימוש. אין צורך בבנאי.
  • מתודות משתמשות ב-pointer receiver (*Counter). value receiver היה נועל עותק של ה-mutex, וזה לא מגן על כלום.
  • defer c.mu.Unlock() מיד אחרי Lock אומר שכל מסלול return, וגם panic, משחררים את המנעול.
  • כל גישה עוברת דרך המנעול, כולל קריאות. קריאה בלי המנעול בזמן ש-goroutine אחרת כותבת היא עדיין data race.

לשמור על הקטע הקריטי קטן

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

func (s *Store) Save(key string) error {
	s.mu.Lock()
	data := s.items[key] // copy what you need
	s.mu.Unlock()

	return writeToDisk(key, data) // slow I/O, outside the lock
}

החזקת מנעול במהלך קריאות רשת, I/O לדיסק או שליחה ל-channel היא הסיבה הנפוצה ביותר לתוכנית מקבילית איטית, ושליחה ל-channel תחת מנעול היא סיבה נפוצה ל-deadlock.

RWMutex לנתונים עם הרבה קריאות

ל-sync.RWMutex יש שני מצבים. RLock/RUnlock לוקחים מנעול קריאה משותף שהרבה goroutines יכולות להחזיק בבת אחת. Lock/Unlock לוקחים את מנעול הכתיבה הבלעדי, שמחכה עד שכל הקוראים יוצאים.

RWMutex משתלם כשקריאות שולטות וכל קריאה עושה עבודה אמיתית תחת המנעול. בקטעים קריטיים זעירים כמו חיפוש בודד ב-map, Mutex רגיל לרוב מהיר באותה מידה, כי למנעול הקריאה יש ניהול משלו. הריצו benchmark לפני שאתם בוחרים.

אי אפשר לשדרג מנעול קריאה למנעול כתיבה. קריאה ל-Lock בזמן החזקת RLock באותה goroutine גורמת ל-deadlock. שחררו קודם את מנעול הקריאה, ואז קחו את מנעול הכתיבה ובדקו שוב את התנאי, כי ייתכן שכותב אחר שינה את הנתונים בינתיים.

sync/atomic לערכים בודדים

למונה או לדגל בודד, sync/atomic פשוט וזול יותר מ-mutex. העטיפות עם הטיפוסים (Go 1.19) הן אלה שכדאי להשתמש בהן:

atomics מגנים על ערך אחד בכל פעם. ברגע ששני ערכים חייבים להשתנות יחד (יתרה ומספר עסקאות, map והגודל שלו), השתמשו ב-mutex. שתי פעולות אטומיות נפרדות יכולות להשתלב עם goroutines אחרות ביניהן.

sync.Once

sync.Once מריץ פונקציה בדיוק פעם אחת, לא משנה כמה goroutines קוראות לה באותו זמן. כל מי שקורא ל-Do מחכה עד שהקריאה הראשונה הסתיימה. זו הדרך המקובלת לאתחל משהו בעצלות:

כל שורת "runs once" מופיעה בדיוק פעם אחת. אם הפונקציה שהועברה ל-Do נכנסת ל-panic, Once עדיין מחשיב אותה כגמורה ואף פעם לא מנסה שוב. sync.OnceValues עושה את אותו הדבר לפונקציות שמחזירות שני ערכים, בדרך כלל ערך ושגיאה.

Mutex, channel או sync.Map

מצבהשתמשו ב
struct או map שכמה goroutines מעדכנות במקוםsync.Mutex בתוך ה-struct
בעיקר קריאות, כתיבות מדי פעם, הקריאות עושות עבודה אמיתיתsync.RWMutex
מונה או דגל בודדsync/atomic
אתחול חד-פעמיsync.Once, sync.OnceValue
העברת נתונים מ-goroutine אחת לאחרתchannel
cache שהמפתחות שלו נכתבים פעם אחת ונקראים הרבה פעמים, או goroutines שנוגעות במפתחות נפרדיםsync.Map

sync.Map הוא לא תחליף כללי ל-map עם מנעול. אין לו פרמטרי טיפוס, כך שערכים חוזרים כ-any, והוא מהיר יותר רק בשני המקרים שבטבלה. התחילו עם mutex ו-map רגיל.

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

  • העתקת mutex. העברה של struct שמכיל sync.Mutex לפי ערך, או שימוש ב-value receiver, מעתיקה את המנעול. go vet מדווח passes lock by value או copies lock value.
  • נעילה פעמיים באותה goroutine. mutexes ב-Go הם לא reentrant. אם Inc קוראת ל-Get ושתיהן לוקחות את המנעול, Inc נחסמת לנצח. תנו למתודות הציבוריות לנעול, ולפונקציות העזר הפרטיות להניח שהמנעול מוחזק.
  • לשכוח לשחרר את המנעול ב-return מוקדם. השתמשו ב-defer, אלא אם יש לכם סיבה לא לעשות זאת.
  • נעילה בסדרים שונים. אם goroutine אחת לוקחת מנעול A ואז B בזמן שאחרת לוקחת B ואז A, שתיהן עלולות לחכות זו לזו לנצח. תמיד תפסו כמה מנעולים באותו סדר.
  • חשיפת הנתונים המוגנים. החזרת ה-map הפנימי ממתודה מאפשרת לקוד הקורא לקרוא ולכתוב אליו בלי המנעול. החזירו עותק (maps.Clone, Go 1.21) או ערך בודד.
  • הגנה רק על הכתיבות. קריאות לא נעולות במקביל לכתיבות נעולות הן עדיין races. הריצו את ה-בדיקות שלכם עם go test -race.

שאלות נפוצות

מה זה mutex ב-Go?

sync.Mutex הוא מנעול שמאפשר רק ל-goroutine אחת בכל פעם להריץ את הקוד שבין mu.Lock() ל-mu.Unlock(). משתמשים בו כדי להגן על נתונים שכמה goroutines קוראות וכותבות, כמו map או struct. ערך האפס שלו הוא mutex לא נעול, מוכן לשימוש.

מתי להשתמש ב-RWMutex במקום ב-Mutex?

כשיש הרבה יותר קריאות מכתיבות וכל קריאה מחזיקה את המנעול לפרק זמן משמעותי. RLock מכניס כל מספר של קוראים בבת אחת, בזמן ש-Lock מחכה לגישה בלעדית. בקטעים קריטיים קצרים, Mutex רגיל לרוב מהיר באותה מידה או יותר, אז מדדו לפני שאתם מחליפים.

האם map ב-Go בטוח לשימוש מקבילי?

לא. קריאות מקביליות הן בסדר, אבל כתיבה במקביל לכל קריאה או כתיבה אחרת היא data race, וה-runtime בדרך כלל מזהה אותה וקורס עם fatal error: concurrent map writes (או concurrent map read and map write). הגנו על ה-map עם sync.Mutex או sync.RWMutex, או השתמשו ב-sync.Map למקרי השימוש הספציפיים שלו.

האם sync.Mutex הוא reentrant ב-Go?

לא. אם goroutine שמחזיקה את המנעול קוראת שוב ל-Lock, היא נחסמת לנצח בהמתנה לעצמה. בנו את הקוד כך שמתודות מיוצאות לוקחות את המנעול וקוראות לפונקציות עזר לא מיוצאות שמניחות שהוא כבר מוחזק.

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

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

להתחיל