לדלג לתוכן

מכונת המצבים של תכנות אסינכרוני

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

var operation = RunAsync();
Console.WriteLine("caller after invocation");
await operation;
Console.WriteLine("caller after completion");

static async Task RunAsync()
{
    Console.WriteLine("entered");
    await Task.CompletedTask;
    Console.WriteLine("after completed await");
    await Task.Delay(40);
    Console.WriteLine("after delay");
}

שתי ההודעות הראשונות מתוך RunAsync מופיעות לפני שהקורא מדפיס את ההודעה שאחרי ההפעלה. ה־await של Task.CompletedTask אינו מחייב השעיה. בזמן ההמתנה ל־Delay נשמר מצב הדרוש להמשך, והשלמת הטיימר מאפשרת להמשיך. אין הכרח שהמשך הפעולה ירוץ על אותו thread שהתחיל, ואין הבטחה שהוא יהיה תמיד thread אחר בתוכנית console.

הקומפיילר מפרק את המתודה למכונת מצבים עם מקום לשמירת locals שחיים מעבר ל־await, מקום לתוצאה או לחריגה, ומספר מצב שמסמן מאיפה להמשיך. לכן local שמחזיק buffer יכול להמשיך להיות root במשך ההמתנה. קיצור הטווח הלוגי של המשתנה או העברת חלק מן העבודה לפונקציה סינכרונית נפרדת יכולים לשנות את תקופת האחיזה; נמדוד לפני שנייחס כל allocation למילה async עצמה.

ה־SynchronizationContext מאפשר לסביבה להגדיר היכן להפעיל המשכים, למשל על thread ה־UI. ה־TaskScheduler מגדיר תזמון של Tasks ואינו זהה ל־context. ה־ExecutionContext מעביר מצב לוגי כמו AsyncLocal לאורך זרימת עבודה. השימוש ב־ConfigureAwait(false) מוותר על ניסיון לחזור ל־context או scheduler שנלכדו עבור אותה המתנה לפי הכללים המתאימים; הוא אינו מכבה אוטומטית זרימה של AsyncLocal ואינו מבטיח thread חדש.

var traceId = new AsyncLocal<string?> { Value = "job-42" };
await ShowAsync(traceId).ConfigureAwait(false);

static async Task ShowAsync(AsyncLocal<string?> traceId)
{
    await Task.Delay(10).ConfigureAwait(false);
    Console.WriteLine(traceId.Value);
}

הדוגמה עצמאית ותדפיס את מזהה העבודה שנזרם להמשך. נשתמש במידע כזה לקישור לוגים, אבל נזכור ש־AsyncLocal יכול גם להחזיק אובייקטים גדולים בחיים לאורך שרשרת async. הוא אינו תחליף להעברת בעלות מפורשת על buffer.

ב־UI deadlock הקורא מחכה סינכרונית לתוצאה, בעוד המשך הפעולה צריך לרוץ על אותו thread של הקורא. קטע הקוד הבא הוא handler פגום בתוך חלון WPF, לצורך קריאה וחקירה בלבד. הוא תלוי ב־LoadTextAsync של החלון, שמבצע await בלי ConfigureAwait(false).

private void OnClick(object sender, System.Windows.RoutedEventArgs args)
{
    string text = LoadTextAsync().Result;
    Title = text;
}

התיקון הוא handler מסוג async void שמותר בגבול אירוע, אשר מבצע await ומטפל בחריגה בתוך הגוף. שאר שכבות היישום יחזירו Task כדי שהקורא יוכל להמתין ולצפות בכשל. החלפת Result ב־GetAwaiter().GetResult אינה מתקנת את התלות המעגלית; היא רק משנה את צורת העברת החריגה. הוספת Task.Run מסביב לכל פעולה יכולה להסתיר את הבעיה ולהוסיף עומס, ולכן נבין קודם את ההקשר.

התיעוד על התכנות האסינכרוני וההסבר של צוות .NET על ConfigureAwait מגדירים את ההתנהגות. בסיום התחקיר נוכל לסמן מי מחכה למי ואיפה continuation אמור לרוץ, במקום להשתמש ב־async כאילו היה שם אחר ל־thread.