הדרך ממנוע הריצה אל מערכת ההפעלה¶
נעקוב אחרי ReadAsync של קובץ בווינדוס ונשאל מי מחזיק את ה־buffer בזמן שהקוד שלנו אינו רץ. התשובה עוברת דרך FileStream, ה־runtime, handle שנפתח לאסינכרוניות ומנגנון ההשלמות של המערכת. הכיתוב Async בשם המתודה אינו מספיק כדי לדעת אם worker נחסם או אם בקשת I/O הוגשה למערכת בצורה חופפת.
הניסוי הבא פותח קובץ במצב המתאים ומחזיק את המערך המושאל עד לסיום כל קריאה. הוא מעבד לכל היותר buffer אחד בכל רגע, כך שקל לעקוב אחרי הבעלות לפני הוספת מקביליות.
using System.Buffers;
using System.Diagnostics;
if (args.Length != 1) throw new ArgumentException("file path required");
using var stop = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await using var stream = new FileStream(args[0], new FileStreamOptions
{
Mode = FileMode.Open,
Access = FileAccess.Read,
Share = FileShare.Read,
Options = FileOptions.Asynchronous | FileOptions.SequentialScan,
BufferSize = 1
});
byte[] buffer = ArrayPool<byte>.Shared.Rent(64 * 1024);
try
{
var watch = Stopwatch.StartNew();
ulong sum = 0;
long bytes = 0;
while (true)
{
int count = await stream.ReadAsync(buffer.AsMemory(0, 64 * 1024), stop.Token);
if (count == 0) break;
bytes += count;
for (int i = 0; i < count; i++) sum += buffer[i];
}
Console.WriteLine($"bytes={bytes}, sum={sum}, elapsed={watch.Elapsed}");
}
finally { ArrayPool<byte>.Shared.Return(buffer); }
ה־BufferSize של FileStream מבטל כאן buffering פנימי של השכבה המנוהלת, אך אינו מבטל cache של מערכת ההפעלה. קריאה חוזרת של אותו קובץ יכולה לכן למדוד cache חם, ולא את התקן האחסון. ה־SequentialScan הוא רמז על תבנית גישה ואינו חוזה שכל הבקשות יושלמו בסדר או בזמן מסוים. בבדיקת ביצועים נציין את מצב ה־cache ואת סוג ההתקן במקום להשוות מספרים חסרי הקשר.
במסלול אסינכרוני מתאים בווינדוס, ה־runtime מגשר בין בקשת overlapped I/O לבין ייצוג ההשלמה המנוהל. המשאב שמחזיק את ה־OVERLAPPED, את המידע הנדרש ואת חיי ה־buffer חייב להישאר תקף עד ההשלמה. ה־IOCP מאפשר למערכת לדווח על השלמות בלי להקדיש thread חסום לכל בקשה. לאחר ההשלמה עדיין נדרשת עבודה כדי לעדכן state ולהפעיל continuation; עלות זו יכולה להתחרות עם עבודה אחרת ב־runtime.
התוצאה יכולה להיות זמינה סינכרונית, למשל כאשר נתונים כבר נמצאים במצב שמאפשר זאת. לכן לא נבנה מדידה שמניחה שכל ReadAsync משעה את המתודה או שכל פעולה יוצרת Task חדש. גרסאות runtime משתמשות במנגנונים reusable במקומות מסוימים, והחוזה של ValueTask נועד לאפשר גם את זה. נחקור את גרסת המקור שמתאימה ל־runtime הנעול, ולא נעתיק פרט יישום מגרסה היסטורית כאילו היה ABI.
ניצור השוואה נוספת של קריאה חוסמת עטופה ב־Task.Run מול קריאה אסינכרונית אמיתית של מספר קבצים. נגדיל בהדרגה את מספר הפעולות, עם מגבלת מקביליות ברורה, ונמדוד queue length של ה־pool, מספר workers, throughput ו־latency. עומס קטן על cache חם יכול להסתיר את עלות החסימה; מספר גדול מדי יכול למדוד רוויה של הדיסק במקום את מנגנון ההמתנה.
בתקלה של starvation נראה פעולות שמחכות זמן רב ל־worker, לעיתים בזמן ש־workers אחרים חסומים על Result או Wait. הגדלת MinThreads יכולה לשנות את הסימפטום, אבל אינה מתקנת בהכרח את הזרימה שיצרה חסימה. נבחן trace עם stacks, אירועי runtime ואירועי מערכת ונקשר אותם למזהה פעולה משלנו.
המאמר של צוות .NET על המימוש מחדש של FileStream מסביר את ההיסטוריה של המנגנון, והתיעוד על אבחון ThreadPool starvation נותן תהליך בדיקה. בניסוי הנוכחי נשמור את ה־buffer עד שה־ReadAsync הסתיים, גם כשהתוצאה היא ביטול או כשל; יציאה מוקדמת מהמתנה חיצונית אינה מספיקה.