לדלג לתוכן

מודל עבודה מנוהל

משימה שמחזירה Task אינה בהכרח thread, ו־thread חסום אינו בהכרח מעבד עסוק. נתחיל מהבחירה מי מבצע את העבודה ומי רק מחכה לתוצאה. עבודה חישובית צריכה זמן CPU, בעוד בקשת קלט־פלט יכולה להמתין למערכת בלי להשאיר thread מנוהל חסום לכל בקשה. השפה מספקת ייצוגים נוחים, אבל אינה מבטלת את מודל התזמון של ווינדוס שלמדנו קודם.

ה־Thread הוא תהליכון עם stack ומצב תזמון. ה־ThreadPool ממחזר workers ומנהל את מספרם לפי מדיניות ה־runtime. ה־Task מייצג השלמה, כשל או ביטול של פעולה, ויכול להתממש מעל worker, מעל callback של I/O או מעל פעולה שכבר הסתיימה. הקריאה Task.Run מתזמנת delegate ל־pool; היא אינה הופכת קריאה חוסמת ל־I/O שאינו תופס worker.

נבנה מונה עם invariant שמקשר בין שני שדות. שימוש בשתי פעולות אטומיות נפרדות אינו מספיק כדי להבטיח snapshot עקבי של שניהם, ולכן נבחר lock אחד עבור הפעולה הלוגית.

var ledger = new Ledger();
Task[] workers = Enumerable.Range(0, 4).Select(_ => Task.Run(() =>
{
    for (int i = 0; i < 10_000; i++) ledger.Add(3);
})).ToArray();
await Task.WhenAll(workers);
var result = ledger.Snapshot();
Console.WriteLine($"{result.Count}, {result.Bytes}");

sealed class Ledger
{
    private readonly object gate = new();
    private long count;
    private long bytes;
    public void Add(int length)
    {
        if (length < 0) throw new ArgumentOutOfRangeException(nameof(length));
        lock (gate)
        {
            long nextCount = checked(count + 1);
            long nextBytes = checked(bytes + length);
            count = nextCount;
            bytes = nextBytes;
        }
    }
    public (long Count, long Bytes) Snapshot()
    {
        lock (gate) return (count, bytes);
    }
}

הפלט חייב להיות 40000 ו־120000. חישוב שני הערכים החדשים לפני כתיבה שומר על המצב הקודם אם checked זורק. אותו lock גם מספק גבול נראות בין writer ל־reader. אם Snapshot יקרא ללא נעילה, הוא מפר את חוזה הסנכרון גם אם כל long נקרא באופן אטומי במכונה המסוימת. אטומיות של שדה אינה אטומיות של טרנזקציה.

ה־lock על object משתמש ב־Monitor ומאפשר כניסה חוזרת מאותו thread. התכונה הזאת אינה מבטיחה שמצב ביניים בטוח ל־callback; קוד חיצוני שמופעל מתוך הנעילה יכול להיכנס שוב ולראות מצב שלא התכוונו לפרסם. נעדיף לעדכן תחת נעילה, להעתיק את הנתונים הדרושים ואז לקרוא לקוד חיצוני מחוץ לנעילה. אין להשתמש ב־lock על this או על מחרוזת ציבורית שהצרכן יכול לנעול בעצמו.

כאשר ה־invariant כולל רק מספר עצמאי, Interlocked יכול לספק read-modify-write אטומי בלי לנהל Monitor. למשל מונה מספר הפעולות שהסתיימו מתאים ל־Interlocked.Increment. לעומת זאת פעולה מסוג "קרא, בדוק מגבלה, הגדל" דורשת CompareExchange בלולאה או נעילה. בדיקת התנאי לפני Increment אינה אטומית עם ההגדלה. ה־Volatile מספק semantics של גישה לפרסום ולצריכה במקרים המתאימים, אבל אינו מאחד רצף פעולות ליחידה אחת.

ה־Monitor.Wait משחרר את הנעילה וממתין, ואז חוזר לאחר רכישה מחודשת שלה. תמיד נבדוק predicate בלולאה, משום שהתעוררות אינה בעלות על המשאב שהמתנו לו. ה־Pulse מודיע לממתין ולא שומר token לעתיד. תור bounded שמבוסס על Monitor צריך גם מצב closed ו־wake-up לכל מי שצריך להפסיק לחכות; בפרק הזה נשתמש בהמשך ב־Channels כדי לקבל חוזה מוכן שנוכל לבדוק.

בתחקיר נרשום מספר workers, queue length, שימוש CPU וזמני המתנה. תור שגדל יחד עם workers חסומים מצביע לכיוון אחר מ־CPU רווי בחישוב. התיעוד על ה־ThreadPool ועל פעולות Interlocked יעזור לבחור את המנגנון. ההחלטה תתחיל ב־invariant ובסוג העבודה, ורק אחר כך בשם ה־API.