קריאות חוזרות והשלמות מאוחרות¶
נרשום callback טבעי שמחכה ל־event. הקריאה לרישום תחזור לפני שה־callback מתבצע, ולכן תקופת החיות של ה־context אינה תחום ה־stack של פונקציית הרישום. גם אחרי שה־TaskCompletionSource קיבל תוצאה, ה־callback עשוי עדיין להיות בתוך המסגרת שלו. נצטרך פעולה שמבטיחה שכל הקריאות החוזרות הסתיימו לפני Free.
הניסוי משתמש ב־RegisterWaitForSingleObject של ווינדוס ומגדיר המתנה חד־פעמית. ה־callback הוא מתודה static עם UnmanagedCallersOnly, כך שאין delegate instance שצריך לשמור, אבל ה־context עדיין דורש root. הפרמטר timedOut הוא BOOLEAN בן byte אחד, וה־calling convention מתועד גם עבור x86. זוהי תוכנית מלאה עם AllowUnsafeBlocks.
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using Microsoft.Win32.SafeHandles;
using var signal = new EventWaitHandle(false, EventResetMode.ManualReset);
using var registration = new Registration(signal.SafeWaitHandle);
signal.Set();
Console.WriteLine(await registration.Completion);
sealed class State
{
public readonly TaskCompletionSource<bool> Source =
new(TaskCreationOptions.RunContinuationsAsynchronously);
}
sealed unsafe class Registration : IDisposable
{
private readonly SafeWaitHandle eventHandle;
private readonly State state = new();
private GCHandle root;
private nint waitHandle;
private bool added;
private bool disposed;
public Task<bool> Completion => state.Source.Task;
public Registration(SafeWaitHandle eventHandle)
{
this.eventHandle = eventHandle;
eventHandle.DangerousAddRef(ref added);
try
{
root = GCHandle.Alloc(state);
if (Native.RegisterWaitForSingleObject(out waitHandle,
eventHandle.DangerousGetHandle(), &Callback,
GCHandle.ToIntPtr(root), uint.MaxValue, 8) == 0)
throw new Win32Exception(Marshal.GetLastPInvokeError());
}
catch
{
if (root.IsAllocated) root.Free();
if (added) eventHandle.DangerousRelease();
throw;
}
}
[UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvStdcall) })]
private static void Callback(nint context, byte timedOut)
{
try
{
var state = (State)GCHandle.FromIntPtr(context).Target!;
state.Source.TrySetResult(timedOut == 0);
}
catch { Environment.FailFast("callback contract violated"); }
}
public void Dispose()
{
if (disposed) return;
if (Native.UnregisterWaitEx(waitHandle, new nint(-1)) == 0)
throw new Win32Exception(Marshal.GetLastPInvokeError());
disposed = true;
state.Source.TrySetCanceled();
root.Free();
if (added) eventHandle.DangerousRelease();
}
}
internal static unsafe partial class Native
{
[LibraryImport("kernel32.dll", SetLastError = true)]
internal static partial int RegisterWaitForSingleObject(out nint wait,
nint handle, delegate* unmanaged[Stdcall]<nint, byte, void> callback,
nint context, uint milliseconds, uint flags);
[LibraryImport("kernel32.dll", SetLastError = true)]
internal static partial int UnregisterWaitEx(nint wait, nint completionEvent);
}
ה־Dispose משתמש ב־INVALID_HANDLE_VALUE בתור completionEvent כדי להמתין לסיום callbacks לפי חוזה UnregisterWaitEx. הוא נקרא מן הקורא אחרי await, ולא מתוך ה־callback עצמו; המתנה לעצמך היתה עלולה ליצור deadlock. ההגנה על ה־event נשמרת באמצעות DangerousAddRef לאורך כל הרישום, ולא רק במהלך RegisterWaitForSingleObject.
ההשלמה של ה־Task וה־quiescence הם שני רגעים שונים. ה־RunContinuationsAsynchronously מונע continuation inline בתוך TrySetResult, אבל אינו מוכיח שה־callback כבר חזר. לכן איננו משחררים את ה־GCHandle מיד כשה־Task הושלם. רק פעולת הניתוק וההמתנה נותנת לנו את התנאי הנדרש לשחרור context.
החוזה של המעטפת כאן כולל Dispose חד־תהליכי, ללא קריאה חוזרת מתוך callback. במקרה שהניתוק נכשל, היא זורקת לפני שחרור ה־root וההשאלה. זה עלול להשאיר משאב מוחזק, אבל אינו משחרר context שעדיין עשוי להיות בשימוש. מוצר מלא יגדיר מדיניות הסגר ודיווח למצב הזה; אין להמשיך כאילו בוצע join מוצלח. ה־FailFast בתוך callback מיועד להפרת invariant של התשתית, בעוד שגיאות עבודה צפויות צריכות להיכתב לתוצאה בלי לזרוק מעבר לגבול הטבעי.
אפשר להשתמש במקום זאת ב־delegate שנשמר בשדה, אך צריך לשמור אותו חי עד סוף כל callback ולא רק עד סוף הרישום. המאפיין UnmanagedCallersOnly אינו הופך מתודה רגילה לזמינה גם בקריאה מנוהלת ישירה, והפרמטרים שלה חייבים להתאים למגבלות interop. התיעוד על רישום המתנה ועל ניתוק עם המתנה להשלמה הוא חלק מן הוכחת החיות שלנו, ולא רק רשימת signatures.
עוקבים אחרי שלושה רגעים שונים¶
נעקוב אחרי הריצה כמו שהיינו עוקבים אחרי buffer בIOCP. ברגע הראשון נרשמת ההמתנה, והמערכת שומרת context כדי שתוכל לקרוא לנו בעתיד. ברגע השני הcallback מוצא את State ומשלים את המשימה. ברגע השלישי הcallback חוזר אל הקוד הטבעי שהפעיל אותו. השחרור בטוח רק אחרי שמנגנון הרישום אישר שלא נשארה קריאה שמשתמשת בcontext.
הפער בין הרגע השני לשלישי יכול להיות קטן מאוד, אבל המשמעות שלו אינה קטנה. המשימה שהושלמה יכולה להעיר thread אחר לפני שהcallback המקורי יצא. לכן אפילו continuation שאינו רץ inline יכול להגיע לDispose בזמן שהcallback עדיין מסיים. ההמתנה בתוך UnregisterWaitEx היא החיבור בין שני צירי הזמן.
המספר שנוצר באמצעות GCHandle.ToIntPtr הוא token של handle מנוהל. הוא אינו כתובת הנתונים של State ואסור לתת לקוד C++ לבצע לו dereference בתור struct. הGC יכול להזיז את האובייקט, והcallback ימצא אותו דרך טבלת הhandles. משום כך השתמשנו בroot רגיל ולא בpinning של אובייקט; הקוד הטבעי שומר token אטום, ולא pointer לתוכן שאמור להישאר באותה כתובת.
הgraph של הבעלות מתחיל בRegistration שמחזיק את ההשאלה של eventHandle ואת הroot. הroot מחזיק את State, שמחזיק את TaskCompletionSource. כאשר ההמתנה מנותקת ומתרוקנת אפשר לשחרר את הroot ואת ההשאלה. המשימה שכבר הוחזרה ללקוח יכולה להישאר חיה בפני עצמה, אבל היא אינה סיבה להשאיר את רישום הWin32 פתוח.
נחשוב עכשיו על קריאה לDispose מתוך callback. הקריאה מבקשת להמתין לכל callbacks, וביניהם היא עצמה. הסדר הזה יוצר מעגל המתנה גם אם אין אף mutex בקוד שלנו. החוזה שאוסר את הקריאה הזאת אינו הערה צדדית; הוא תנאי שמאפשר למימוש הפשוט לסיים.
בתרגיל המשך אפשר להוסיף פעולת RequestStop שאינה ממתינה ופעולת Join שנקראת מהבעלים. הפיצול אינו משנה את זמן החיות של הroot. הוא רק מאפשר להפריד בין מי שמבקש עצירה לבין מי שמסוגל להמתין לה בלי לחסום את עצמו.