כשכמה תהליכונים (threads) קוראים וכותבים את אותם נתונים באותו זמן, עדכונים עלולים ללכת לאיבוד או להיראות חצי גמורים. ההוראה lock הופכת בלוק קוד לבלעדי: כל עוד תהליכון אחד נמצא בתוכו, כל תהליכון אחר שמגיע ל-lock על אותו אובייקט מחכה לתורו.
הבעיה: תנאי מרוץ
ארבע משימות מוסיפות כל אחת 100,000 למונה משותף. התוצאה אמורה להיות 400,000:
פלט לדוגמה:
Expected 400000, got 245609
הסכום בדרך כלל יוצא נמוך מדי, ובכל הרצה בהפרש אחר (במחשב עם ליבה אחת הוא עשוי לפעמים לצאת נכון, וזה מה שהופך את הבאגים האלה לקשים לתפיסה). count++ נראה כמו פעולה אחת, אבל הוא שלוש: קריאת count, הוספת אחד וכתיבה חזרה. שני תהליכונים יכולים שניהם לקרוא 500, שניהם לחשב 501 ושניהם לכתוב 501, והגדלה אחת נעלמת.
הפתרון: lock
עטפו את הקריאה, השינוי והכתיבה ב-lock על אובייקט משותף:
פלט:
Expected 400000, got 400000
עכשיו רק תהליכון אחד בכל רגע יכול להיות בתוך הבלוק, כך שכל הגדלה מסתיימת לפני שהבאה קוראת את הערך. הנעילה גם מבטיחה נראוּת: תהליכון שנכנס לנעילה רואה כל כתיבה שהמחזיק הקודם ביצע לפני שיצא.
ההגנה עובדת רק אם כל גישה ל-count עוברת דרך אותה נעילה. count++ אחד בלי נעילה במקום אחר בתוכנית מחזיר את תנאי המרוץ.
למה lock מתקמפל
lock הוא קיצור למחלקה Monitor, עם try/finally כדי שהנעילה תשתחרר גם כשהבלוק זורק חריגה:
lock (sync)
{
count++;
}
// is compiled roughly as:
bool taken = false;
try
{
Monitor.Enter(sync, ref taken);
count++;
}
finally
{
if (taken) Monitor.Exit(sync);
}
Monitor מציעה גם את TryEnter(obj, timeout), שמוותרת אחרי זמן קצוב במקום לחכות לנצח, ואת Wait/Pulse לאיתות בין תהליכונים. תהליכון שכבר מחזיק בנעילה יכול להיכנס אליה שוב (נעילות הן reentrant), כך שמתודה נעולה יכולה לקרוא למתודה נעולה אחרת על אותו אובייקט.
בחירת אובייקט הנעילה
האובייקט ב-lock (...) הוא רק אסימון שהתהליכונים מסכימים עליו. הכללים:
- השתמשו בשדה פרטי לקריאה בלבד מטיפוס
object.private readonly object sync = new object();. פרטי אומר ששום קוד חיצוני לא יכול לקחת את אותה נעילה;readonlyאומר שאי אפשר להחליף את האסימון בזמן שתהליכון מחזיק בו. - לעולם לא
lock (this). כל מי שמחזיק הפניה לאובייקט שלכם יכול לנעול עליו גם, ואז הקוד שלו חוסם את שלכם או נכנס איתו ל-deadlock. - לעולם אל תנעלו על מחרוזת או על
typeof(...). ליטרלים של מחרוזות עוברים interning (כל"orders"בתהליך הוא אותו אובייקט), ואובייקטים מסוגTypeמשותפים לכל האפליקציה, כך שקוד שאין לו קשר עלול להתחרות על אותה נעילה. - לעולם אל תנעלו על טיפוס ערך.
lock (count)עלintלא מתקמפל (שגיאה CS0185), ו-boxing ידני יוצר אובייקט חדש בכל פעם, כך שהוא לא היה נועל כלום. - השתמשו בנעילה אחת לכל קבוצת נתונים שחייבת להישאר עקבית: שדה נעילה static לנתונים סטטיים, ושדה מופע לנתונים של כל מופע.
הגנה על פעולות מרובות שלבים
lock נחוץ במיוחד כשבדיקה ופעולה חייבות לקרות יחד. חשבון בטוח לתהליכונים מדגים גם את תבנית ה"בדוק ואז פעל" וגם תמונת מצב עקבית של שני שדות:
פלט:
10 left after 33 withdrawals
בלי הנעילה, שני תהליכונים יכלו שניהם לראות יתרה של 40, שניהם לעבור את הבדיקה ושניהם למשוך 30, כך שהחשבון היה נשאר במינוס 20. גם המתודה Summary נועלת, כך שהיא אף פעם לא מדווחת על balance מאחרי משיכה יחד עם ספירת withdrawals מלפניה.
גם Dictionary<TKey, TValue> ו-List<T> אינם בטוחים לתהליכונים: כתיבות מקבילות יכולות לשבש את המערכים הפנימיים שלהם, ולא רק לאבד עדכונים. הגנו עליהם בנעילה או השתמשו ב-ConcurrentDictionary<TKey, TValue> ובשאר הטיפוסים ב-System.Collections.Concurrent.
Interlocked לערכים בודדים
כשהמצב המשותף הוא מספר שלם אחד או הפניה אחת והעדכון הוא צעד אחד, המחלקה Interlocked מבצעת אותו באופן אטומי בלי נעילה:
פלט:
Visits: 10000
Bytes: 5120000
Peak: 10000
Increment, Decrement, Add ו-Exchange ממופות לפעולות אטומיות של המעבד (פקודה אחת ב-x64). CompareExchange(ref location, newValue, expected) כותבת רק אם המיקום עדיין מכיל את expected, ומחזירה את מה שמצאה, וכך אפשר לבנות כל עדכון כלולאת ניסיון חוזר. כל דבר שנוגע בשני משתנים בבת אחת עדיין צריך lock.
Deadlock
Deadlock (קיפאון) קורה כששני תהליכונים מחזיקים כל אחד בנעילה שהשני צריך:
// Thread 1 // Thread 2
lock (accountA) lock (accountB)
{ {
lock (accountB) { /* ... */ } lock (accountA) { /* ... */ }
} }
אם תהליכון 1 לוקח את accountA בזמן שתהליכון 2 לוקח את accountB, כל אחד מהם מחכה אחר כך לנצח לשני. אין חריגה ואין זמן קצוב; התוכנית פשוט נתקעת. העברה בין שני חשבונות שנועלת קודם את "המקור" ואחר כך את "היעד" יוצרת בדיוק את זה כששתי העברות בכיוונים הפוכים רצות בו זמנית.
ההגנות המקובלות:
- נעלו בסדר גלובלי קבוע. בהעברה, נעלו קודם את החשבון עם המזהה הקטן יותר, לא משנה לאיזה כיוון הכסף זז.
- החזיקו נעילות לזמן קצר ובצעו עבודה איטית (קלט/פלט, לוגים, קריאות רשת) מחוץ להן.
- אל תקראו לקוד לא מוכר בזמן שאתם מחזיקים בנעילה: אירועים, callbacks ומתודות וירטואליות עשויים לקחת נעילות משלהם.
- השתמשו ב-
Monitor.TryEnterעם זמן קצוב במקום שבו היתקעות גרועה יותר מכישלון.
אין await בתוך lock: השתמשו ב-SemaphoreSlim
await אסור בתוך בלוק lock (שגיאת קומפילציה CS1996). אחרי await המתודה עשויה להמשיך בתהליכון אחר, ונעילת Monitor חייבת להשתחרר על ידי התהליכון שלקח אותה. בקוד async, SemaphoreSlim עם מונה 1 מתפקד כנעילה שמתאימה ל-async:
פלט:
5 saved, one at a time
תמיד צמדו את WaitAsync ל-Release בתוך finally, אחרת חריגה תשאיר את השער סגור לתמיד. SemaphoreSlim(3, 3) מכניס שלושה קוראים בבת אחת, וכך מגבילים את מספר הבקשות המקבילות ל-API עם מגבלת קצב.
System.Threading.Lock (.NET 9)
.NET 9 עם C# 13 מוסיפה טיפוס ייעודי, System.Threading.Lock. כשהאובייקט בהוראת lock הוא Lock, הקומפיילר משתמש ב-API המהיר יותר שלו, EnterScope, במקום ב-Monitor:
private readonly Lock sync = new Lock();
public void Add(decimal amount)
{
lock (sync) // uses Lock.EnterScope(), not Monitor
{
balance += amount;
}
}
הכללים לבחירת האובייקט נשארים אותם כללים. בגרסאות קודמות, private readonly object היא הבחירה הנכונה.
טעויות נפוצות
- נעילה של חלק מהגישות ולא של כולן. כל קריאה וכתיבה של הנתונים המשותפים חייבת לקחת את אותה נעילה.
lock (this),lock (typeof(X)),lock ("name"). קוד חיצוני יכול לקחת את אותה נעילה.- ביצוע קלט/פלט איטי בתוך נעילה. כל שאר התהליכונים מחכים; שמרו על הבלוק קצר.
- לקיחת שתי נעילות בסדרים שונים במקומות שונים. ה-deadlock הקלאסי.
- שימוש ב-
lockסביבawait. זה לא מתקמפל; השתמשו ב-SemaphoreSlim. - נעילה חדשה בכל קריאה.
lock (new object())לא מגן על כלום; האובייקט חייב להיות משותף.
שאלות נפוצות
מה עושה lock ב-C#?
lock (obj) { ... } מאפשר רק לתהליכון אחד בכל רגע להריץ את הבלוק עבור אובייקט נעילה נתון. תהליכון שני שמגיע ל-lock על אותו אובייקט מחכה עד שהראשון יוצא מהבלוק. הנעילה משתחררת גם אם הבלוק זורק חריגה, כי lock מתקמפל ל-Monitor.Enter ול-Monitor.Exit בתוך try/finally.
על איזה אובייקט כדאי לנעול ב-C#?
על שדה פרטי ייעודי: private readonly object _sync = new object();. הוא חייב להיות טיפוס הפניה, משותף לכל התהליכונים שנוגעים בנתונים המוגנים, ולא נגיש מחוץ למחלקה שלכם. לעולם אל תנעלו על this, על Type (typeof(MyClass)) או על מחרוזת, כי קוד אחר יכול לנעול על אותו אובייקט ולחסום אתכם או לגרום ל-deadlock.
מתי להשתמש ב-Interlocked במקום ב-lock?
כשהמצב המשותף הוא מספר אחד או הפניה אחת והפעולה היא צעד אחד: Interlocked.Increment(ref count), Interlocked.Add(ref total, x), Interlocked.Exchange או CompareExchange. אלה פעולות אטומיות של החומרה, והן מהירות יותר מנעילה. לכל דבר שנוגע בכמה שדות או באוסף, השתמשו ב-lock.
אפשר להשתמש ב-await בתוך lock ב-C#?
לא, הקומפיילר דוחה await בתוך בלוק lock (שגיאה CS1996), כי הקוד שאחרי ה-await עשוי להמשיך בתהליכון אחר מזה שמחזיק בנעילה. השתמשו במקום זאת ב-SemaphoreSlim עם מונה 1: await sem.WaitAsync(); try { ... } finally { sem.Release(); }.
איך נוצר deadlock עם lock?
תהליכון 1 מחזיק בנעילה A ומחכה לנעילה B, ובאותו זמן תהליכון 2 מחזיק בנעילה B ומחכה לנעילה A. אף אחד מהם לא יכול להתקדם, והתוכנית נתקעת בלי שום חריגה. מונעים את זה כשלוקחים תמיד כמה נעילות באותו סדר גלובלי, מחזיקים נעילות לזמן קצר ככל האפשר, ואף פעם לא קוראים לקוד לא מוכר בזמן שמחזיקים בנעילה.