מעמקי ה-I/O של Windows (חלק 2) — I/O סינכרוני ואסינכרוני: מה OVERLAPPED באמת אומר

· עודכן בתאריך: · · Windows, Win32, I/O, I/O אסינכרוני, OVERLAPPED, Kernel, .NET, C#

בפעם הקודמת (חלק 1) ראינו שבקשת I/O ב-Windows הופכת לחבילה בשם IRP שזורמת דרך ה-device stack, ושב-kernel הוצאת הבקשה והשלמתה הן שני דברים נפרדים. הפעם נעסוק ב-I/O אסינכרוני (Overlapped I/O), הדרך שבה אפליקציה משתמשת בהפרדה הזאת.

הוספתם FILE_FLAG_OVERLAPPED — והקריאה עדיין גורמת לכם להמתין. השתמשתם ב-OVERLAPPED שוב ושוב — והנתונים נפגמו. שחררתם את ה-buffer מיד אחרי ביטול — והתוכנית קרסה. המפתח להבנת שלושתם הוא חלוקת תפקידים אחת: ה-mode שייך ל-handle, ה-state שייך לכל פעולה בנפרד, והניקוי נעשה רק אחרי שאימות הסיום התקבל.

המאמר עוקב אחרי הסדר: פותחים את הקובץ, מוציאים את ה-I/O, מקבלים את התוצאה, ומנקים. אחרי שהמנגנון של Win32 ברור, נבדוק איפה FileStream, ReadAsync ו-CancellationToken של .NET מתחברים אליו.

זהו חלק 2 של הסדרה “מעמקי ה-I/O של Windows”. המבנה הכולל נמצא בפתח חלק 1.

1. קודם המסקנה: שלוש הבחנות שקל להתבלבל בהן

אם מסתכלים על I/O אסינכרוני לפי שם ה-API בלבד, קשה להבין אותו. קודם מפרידים בין מה שמגדירים ובין הנקודה שבה קובעים שהפעולה הסתיימה.

מה שקל להתבלבל נקודת ההבחנה
ה-mode של ה-handle לעומת ה-state של הפעולה ה-mode הסינכרוני או האסינכרוני נקבע ברגע CreateFile. ה-OVERLAPPED מחזיק את ה-state של פעולה אחת שמוצאת ל-handle הזה
תוצאת ההוצאה לעומת קבלת הסיום ERROR_IO_PENDING אינו כשל אלא קבלה. TRUE פירושו סיום סינכרוני, אבל כברירת מחדל גם מגיעה הודעה נפרדת. אין לטפל בתוצאה בשני המקומות
בקשת ביטול לעומת הנקודה שבה מותר לנקות CancelIoEx היא בקשת ביטול. את ה-structure ואת ה-buffer משחררים רק אחרי שאימתתם שהפעולה הזאת הסתיימה

I/O סינכרוני לא חוזר לקורא לפני שהפעולה הסתיימה. I/O אסינכרוני מאפשר מסלול שכן חוזר לפני הסיום. אבל גם ב-mode אסינכרוני הפעולה יכולה להסתיים בתוך הקריאה, ולכן זו אינה הבטחה שלעולם לא תמתינו.12

במימוש חושבים בסדר הזה: קובעים את ה-mode → מכינים structure ו-buffer ייעודיים לפעולה → בוחנים את תוצאת ההוצאה → מקבלים את הסיום → מנקים. גם כשהתבקש ביטול, לא מדלגים על שלב קבלת הסיום.34

אם היעד כבר ברור, אפשר להתחיל מהמדריך הבא.

מה שרוצים לדעת, או מה שנתקע איפה לקרוא קודם
במה I/O סינכרוני ו-I/O אסינכרוני שונים פרק 2: איך עובדת ההמתנה, סעיף 3.1: ה-mode של ה-handle
הנתונים נפגמים עם OVERLAPPED, או קריסה אחרי היציאה מהפונקציה סעיף 3.2: state ו-lifetime לכל פעולה
ReadFile מחזיר FALSE, או טיפול כפול בסיום סינכרוני סעיף 3.3: שלושת הענפים של תוצאת ההוצאה
רוצים לבחור איך מקבלים את הסיום, או שה-callback לא מגיע פרק 4: השוואה בין דרכי ההודעה, סעיף 4.3: איך ממתינים ל-APC
עשינו את ה-I/O אסינכרוני והקריאה בכל זאת ממתינה פרק 5: התנאים לסיום סינכרוני וזמן התגובה
הביטול לא עובד, או קריסה אחרי ביטול פרק 6: לנקות רק אחרי אימות הסיום
משתמשים ב-ReadAsync ומספר ה-threads בכל זאת עולה פרק 7: שילוב של handle ו-API ב-.NET

ב-diagram, solid line מציינת relation שתמיד מתקיים ו-dashed line מציינת relation מותנה (התנאים מופיעים בהסבר של כל relation ב-detail page). הרשימה המלאה של ה-relations (סה”כ 36, כולל evidence ו-certainty) וההגדרות של ה-concepts המרכזיים נמצאות ב-detail page של ה-knowledge map (ביפנית). Data: JSON-LD / Turtle

2. I/O סינכרוני: thread שממתין לסיום ישן בלי לבזבז CPU

2.1. מי שממתין לסיום הוא I/O Manager

handle שנפתח בלי FILE_FLAG_OVERLAPPED נמצא ב-mode סינכרוני. ReadFile לא חוזר עד שה-I/O הסתיים.1

כשה-driver משהה (pends) את הבקשה מפני שהוא ממתין לתגובת החומרה, ה-I/O Manager ממתין לסיום ורק אז מחזיר שליטה לאפליקציה. ה-thread של האפליקציה נמצא כל הזמן הזה בהמתנה בתוך ה-kernel.

driver (stack)I/O Managerthread של האפליקציהdriver (stack)I/O Managerthread של האפליקציהה-thread נכנס להמתנה בתוך ה-kernelוישן בלי לבזבז CPUReadFile(handle סינכרוני)הוצאת IRPSTATUS_PENDING (ממתין לתגובת ההתקן)סיום (IoCompleteRequest)מחזיר את התוצאה ומעיר את ה-threadReadFile חוזר עם TRUE/FALSE

איור 1: I/O סינכרוני שבקשה שלו הושהתה. ReadFile חוזר רק אחרי שהסיום אומת

עם זאת, I/O סינכרוני לא תמיד מרדים את ה-thread. בקשה שאפשר למלא במקום, כמו cache hit, מחזירה תוצאה בלי המתנה כלל (המסלול של “סיום מיידי” באיור 5 של חלק 1). מה שמובטח הוא רק שהקריאה לא חוזרת לפני שהפעולה הסתיימה.

2.2. לא לבזבז CPU ושה-thread יכול לעשות דברים אחרים הם שני דברים שונים

thread שנמצא במצב המתנה יוצא מקבוצת ה-threads שה-scheduler מריץ, ולכן אינו צורך CPU. למה עדיף לתת ל-thread להמתין מאשר לעשות polling בעצמכם מוסבר גם ב-למה ב-Windows עדיף להמתין ל-event מאשר ל-Sleep(1).

מנגד, thread שממתין לא יכול לעשות שום דבר אחר. אם זה ה-UI thread — המסך קופא; ואם שרת מקצה thread לכל חיבור, כמה מאות חיבורים פירושם כמה מאות threads. החולשה של I/O סינכרוני אינה צריכת CPU, אלא שה-thread אינו זמין עד לסיום.

ב-mode סינכרוני ה-kernel גם מנהל את ה-file pointer (המיקום הנוכחי). לכן קריאות ReadFile עוקבות קוראות “מהמקום שבו הקודמת הפסיקה”. המיקום שייך ל-file object שמאחורי ה-handle, ולכן handles ששוכפלו עם DuplicateHandle חולקים את אותו מיקום (חלק 1, סעיף 3.3).

יש גם CancelSynchronousIo, שמבקש לבטל I/O סינכרוני שרץ ב-thread אחר. ההבחנה בינו לבין ה-APIs שמיועדים ל-I/O אסינכרוני מסוכמת בפרק 6.5

3. הכנה והוצאה של I/O אסינכרוני: להפריד בין mode, state ותוצאת החזרה

3.1. את ה-mode האסינכרוני קובעים כשפותחים את הקובץ

העברת FILE_FLAG_OVERLAPPED ל-CreateFile מכניסה את ה-file object שמאחורי ה-handle ל-mode אסינכרוני. ה-mode אינו משהו שמחליפים בכל קריאה. אפשר לפתוח את אותו קובץ פעמיים — handle אחד לשימוש סינכרוני ואחד לאסינכרוני — ואז גם יהיו שני file objects.1

ב-mode אסינכרוני המערכת לא מנהלת file pointer. כיוון שאפשר להוציא כמה פעולות במקביל, בקובץ על הדיסק מציינים את מיקום הקריאה והכתיבה בכל פעם דרך OVERLAPPED.Offset / OffsetHigh. בהתקנים שאין להם מיקום seek, כמו serial port ו-named pipe, המיקום הזה אינו בשימוש ומושאר 0. גם כשלא מציינים מיקום, עדיין נדרש OVERLAPPED ייעודי לפעולה.6

לעומת זאת, העברת OVERLAPPED ל-handle ב-mode סינכרוני לא הופכת אותו לאסינכרוני. הוא יקרא מהמיקום שב-Offset, אבל ההתנהגות של חסימה עד הסיום לא משתנה. מה שקובע אינו אם העברתם structure, אלא באיזה mode נפתח ה-handle.6

3.2. להתאים OVERLAPPED ו-buffer לפעולה אחת

OVERLAPPED הוא ה-structure שמזהה פעולה שנמצאת בדרך ומעביר את המיקום, ה-state והתוצאה שלה. אם חושבים עליו כ”פתק עבודה של פעולה אחת”, חלוקת התפקידים עם ה-handle מתבהרת.3

Member תפקיד
Offset / OffsetHigh המיקום בקובץ שממנו הפעולה הזאת קוראת או שאליו היא כותבת (נקבע בזמן ההוצאה; לא בשימוש בהתקנים בלי מיקום)
hEvent event שמסומן בסיום (אופציונלי; מומלץ manual-reset)
Internal ה-state של הפעולה. לפני הסיום מונחת בו המקבילה של STATUS_PENDING (לשימוש המערכת)
InternalHigh מספר הבתים שהועברו בסיום (לשימוש המערכת)
OVERLAPPED structure = פתק עבודה של פעולה אחתOffset: מהיכן קוראיםhEvent: איך יודעים שהפעולה הסתיימהInternal/InternalHigh:state ותוצאה (המערכת כותבת)handle (file object) = modemode סינכרוניה-kernel מנהל את המיקום הנוכחיReadFile לא חוזר עד הסיוםmode אסינכרוני (FILE_FLAG_OVERLAPPED)המיקום הנוכחי אינו מנוהלההוצאה והסיום נפרדיםנקבע פעם אחת, בזמן CreateFileנדרש אחד לכל הוצאת ReadFile/WriteFile

איור 2: ה-handle מחזיק את ה-mode, ו-OVERLAPPED מחזיק את המיקום וה-state של כל פעולה

שני דברים חייבים להישמר כאן: הכמות וה-lifetime. אם מוציאים שלוש פעולות I/O במקביל, מכינים שלושה OVERLAPPED. שיתוף של structure אחד בין כמה פעולות שלא הסתיימו מוביל לתוצאות בלתי צפויות ולפגיעה בנתונים.2

כמו כן, עד שהפעולה מסתיימת משאירים את ה-structure ואת ה-buffer תקפים: אין לשנות אותם, להשתמש בהם שוב או לשחרר אותם. ה-kernel עדיין משתמש בזיכרון הזה. אם מוציאים עם OVERLAPPED שהוא משתנה מקומי ויוצאים מהפונקציה כשהפעולה עוד בדרך, מעבירים ל-kernel אזור stack שחייו כבר נגמרו.32

כשמשתמשים ב-structure שוב אחרי שאימתתם את הסיום, מאתחלים אותו מחדש כדי ששום state מהפעולה הקודמת לא יישאר. אם בוחרים בשיטת ה-event, משתמשים ב-manual-reset event עבור hEvent. הקשר לאופן ההמתנה מוסבר בסעיף 4.2.3

3.3. לפצל את תוצאת החזרה של ReadFile לשלושה ענפים ולקבוע איפה מטפלים בכל אחד

את התוצאה של הוצאת ReadFile על handle אסינכרוני קובעים לפי שילוב של ערך החזרה ו-GetLastError(). החשוב הוא לא להתייחס לכל FALSE כאל כשל.6

ערך החזרה של ReadFile GetLastError() משמעות מה הקורא עושה
TRUE (לא נבדק) הסתיים במקום (סיום סינכרוני) כברירת מחדל גם מגיעה הודעת סיום נפרדת. משאירים את טיפול התוצאה לצד ההודעה
FALSE ERROR_IO_PENDING (997) התקבלה; בתהליך לא עושים כלום. ממתינים להודעת הסיום בלי לגעת ב-OVERLAPPED או ב-buffer
FALSE כל דבר אחר ההוצאה עצמה נכשלה הודעת סיום לא תגיע. מטפלים בשגיאה במקום ומנקים את ה-OVERLAPPED ואת ה-buffer
ReadFile(handle אסינכרוני, עם OVERLAPPED)מה ערך החזרה?TRUEהסתיים במקום (סיום סינכרוני)כברירת מחדל גם מגיעה הודעת סיוםFALSE + ERROR_IO_PENDINGהתקבלה. הסיום יגיע בהמשךFALSE + שגיאה אחרתההוצאה עצמה נכשלהממתינים להודעת הסיום(ארבע השיטות בפרק 4)

איור 3: שלושת הענפים — סיום סינכרוני, התקבלה ובתהליך, וכשל בהוצאה

הפונקציה הבאה עושה רק את ההבחנה הזאת וחוזרת לקורא. הכנת ה-handle, ה-structure, ה-buffer וה-event הייעודיים לפעולה, והקוד שמקבל את הסיום, מונחות כקיימות במקום אחר.

// C++ / Win32
// hFile : handle שנפתח עם FILE_FLAG_OVERLAPPED
// ov    : OVERLAPPED שהוקצה לפעולה הזאת בלבד (Offset ו-hEvent כבר מוגדרים)
// buf/len: buffer של הפעולה הזאת בלבד. אין לשחרר עד שתגיע הודעת הסיום
DWORD IssueRead(HANDLE hFile, OVERLAPPED* ov, BYTE* buf, DWORD len)
{
    // בהוצאה אסינכרונית מעבירים NULL ב-lpNumberOfBytesRead,
    // ואת מספר הבתים שהועברו מקבלים אחרי הסיום דרך GetOverlappedResult
    if (ReadFile(hFile, buf, len, nullptr, ov))
    {
        // (1) סיום סינכרוני. כברירת מחדל גם מגיעה הודעה, ולכן כאן לא מטפלים בתוצאה
        return ERROR_SUCCESS;
    }

    DWORD err = GetLastError();
    if (err == ERROR_IO_PENDING)
    {
        // (2) התקבלה. ממתינים להודעת הסיום בלי לגעת ב-ov וב-buf
        return ERROR_IO_PENDING;
    }

    // (3) ההוצאה עצמה נכשלה. הודעה לא תגיע, ולכן הקורא מנקה כאן
    return err;
}

ERROR_IO_PENDING הוא התוצאה “התקבלה, ועדיין לא הסתיימה”. אסור לנקות בעקבותיו כאל שגיאה רגילה. מנגד, סיום סינכרוני שמחזיר TRUE הוא גם מסלול תקין, ולכן תמיד מטפלים בו. למה פעולה מסתיימת באופן סינכרוני מוסבר בפרק 5.

מטפלים בתוצאה פעם אחת בלבד. כברירת מחדל, גם בפעולה שהסתיימה סינכרונית נכנס completion packet אם ה-handle משויך ל-IOCP, ואם עובדים בשיטת ה-event ה-event מסומן. אם מטפלים בתוצאה גם מיד אחרי TRUE וגם כשההודעה מגיעה, מטפלים באותה פעולה פעמיים ומסתכנים בשחרור כפול של ה-structure. הצורה הבטוחה היא לרכז את טיפול התוצאה, גם במסלול TRUE וגם במסלול ERROR_IO_PENDING, בצד ההודעה.1

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

יש גם אופטימיזציה שמדלגת על הודעת ה-IOCP בסיום סינכרוני, אבל היא תכנון שונה מברירת המחדל. היכן חל FILE_SKIP_COMPLETION_PORT_ON_SUCCESS מוסבר בנפרד בסעיף 5.3.7

4. בחירת דרך לקבל את הסיום: לפי מספר ה-I/O ולפי ה-thread שמטפל

מרגע שההוצאה והסיום נפרדים, צריך דרך לקבל את הסיום. קודם משווים את ארבע השיטות לפי שני צירים: כמה I/O מטפלים במקביל ואיזה thread מריץ את טיפול הסיום.1

שיטה ה-thread שמריץ את טיפול הסיום כמה I/O אפשר להוציא במקביל איפה היא מתאימה
(1) סימון ה-handle כל thread שהמתין אחת בפועל. עם כמה במקביל אי אפשר לדעת איזו מהן הסתיימה כמעט אין מקום (4.1)
(2) event עם GetOverlappedResult כל thread שהמתין event אחד לכל פעולה. המתנה מרוכזת עם WaitForMultipleObjects מוגבלת ל-64 עד כמה I/O במקביל. תקשורת עם התקנים (4.2)
(3) APC (ReadFileEx) ה-thread שהוציא, ורק כשהוא בתוך alertable wait אין הגבלה על המספר, אבל כל טיפול הסיום רץ בטור על אותו thread לוגיקת תקשורת שרוצים לסגור בתוך thread אחד (4.3)
(4) I/O completion port מאגר ה-threads שמשויכים לפורט הרבה I/O אפשר לקבל בכמה threads מעטים שרתים, thread pool (4.4)
ה-I/O הסתיים ב-kernel(IoCompleteRequest, ואז APC קובע את התוצאה)(1) ה-file handle עובר למצב signaledקבלה: WaitForSingleObject(handle)(2) ה-hEvent של OVERLAPPED עובר למצב signaledקבלה: WaitForSingleObject + GetOverlappedResult(3) ה-completion routine נכנס ל-APC queue של ה-thread שהוציאקבלה: הוא רץ בזמן alertable wait כמו SleepEx(4) completion packet נכנס ל-I/O completion portקבלה: GetQueuedCompletionStatus (חלק 3)

איור 4: ארבעת המסלולים של הודעת הסיום. איך מקבלים את הסיום תלוי באופן שבו הוצאתם את ה-I/O

4.1. סימון ה-handle: אי אפשר להבדיל בין הפעולות

אם מוציאים בלי לציין hEvent, ה-file handle עצמו עובר למצב signaled בסיום. אבל כשכמה פעולות נמצאות בדרך על אותו handle, אי אפשר לדעת איזו מהן הסתיימה.1

למעט המקרה המיוחד שבו לעולם אין יותר מפעולת I/O אסינכרונית אחת בדרך, בטוח יותר לא להשתמש בזה. זה נראה נוח, אבל הוא לא נותן דרך לנהל תוצאה לכל פעולה בנפרד.

4.2. event ו-GetOverlappedResult: הצורה הבסיסית לכמה I/O במקביל

קובעים manual-reset event ב-OVERLAPPED.hEvent של כל פעולה ומוציאים. אחרי המתנה עם WaitForSingleObject מוציאים הצלחה או כשל ואת מספר הבתים שהועברו עם GetOverlappedResult. כדי להמתין לכמה events יחד משתמשים ב-WaitForMultipleObjects, אבל הוא ממתין ל-64 לכל היותר.18

הגדרה של bWait ל-TRUE ב-GetOverlappedResult מאפשרת גם להמתין לסיום ורק אז לקחת את התוצאה. אם משתמשים כאן ב-auto-reset event, יכול להיות ש-GetOverlappedResult ימשיך להמתין אחרי שהמתנה אחרת צרכה את הסימון. שימוש ב-manual-reset event הוא הדרך להימנע מבעיית ההמתנה הזאת.83

לטיפול אמין בכמה I/O במקביל זו שיטה ברורה וקריאה. היא משמשת גם בלוגיקה של “לקרוא בזמן שכותבים” ב-serial port. דוגמה מעשית נמצאת ב-מלכודות באפליקציית תקשורת סריאלית.

4.3. APC: להשאיר את ה-thread שהוציא ב-alertable wait עד הסיום

ReadFileEx / WriteFileEx הם השיטה שבה מציינים completion routine (callback). כשהפעולה מסתיימת, ה-routine נכנס ל-APC queue של ה-thread שהוציא אותה. הוא רץ כשהאחרון נכנס ל-alertable wait דרך SleepEx, WaitForSingleObjectEx או קריאה דומה.91011

כיוון שטיפול הסיום רץ בטור על אותו thread, לוגיקה שנשארת בתוך thread אחד יכולה לוותר על locks. מנגד, אם ה-thread שהוציא לא נכנס ל-alertable wait, ה-completion routine לא רץ בכלל. שילוב עם ה-message loop של ה-UI דורש MsgWaitForMultipleObjectsEx, ולכן תכנון ההמתנה נעשה מסובך יותר. לשימוש כללי בוחרים לרוב ב-event או ב-IOCP.

שלושת הדברים שהכי קל לפספס ב-APC הם אם ההוצאה הצליחה, אם ההמתנה נכונה, ואם הפעולה שלכם הסתיימה. הקוד הבא הוא קטע שמנגיד בין המתנה גרועה להמתנה טובה, ולא דוגמה להרצת השניים בזה אחר זה. הכנת ה-handle וה-buffer, ו-OnReadCompleted שמעדכן את דגל הסיום של הפעולה, מונחות כקיימות במקום אחר.

// C++ / Win32. hFile הוא handle שנפתח עם FILE_FLAG_OVERLAPPED,
// ו-ov ו-buf נשארים בחיים עד לסיום (סעיף 3.2)

// דוגמה גרועה: ה-completion routine לא ייקרא לעולם
ReadFileEx(hFile, buf, len, ov, OnReadCompleted);
Sleep(1000);            // המתנה שאינה alertable. ה-APC לא נמסר

// דוגמה טובה: ממשיכים להמתין ב-alertable עד שה-I/O הזה נגמר
//
// ה-completion routine מעלה את הדגל הזה (למשל בתוך ה-structure שמארח את ov)
volatile bool completed = false;

// תמיד בודקים אם ההוצאה הצליחה. כשמוחזר 0, לא נכנס שום completion routine
if (!ReadFileEx(hFile, buf, len, ov, OnReadCompleted))
{
    const DWORD err = GetLastError();   // לוקחים מיד. APIs מאוחרים יותר דורסים אותו
    ReportError(err);                   // התקן שהוסר, handle לא תקין וכיוצא בזה
    return;                             // ★ אסור להיכנס ללולאת ההמתנה שלמטה
}

while (!completed)
{
    DWORD r = SleepEx(1000, TRUE);   // ה-TRUE בארגומנט השני הוא alertable
    if (r == WAIT_IO_COMPLETION)
    {
        // APC כלשהו רץ. אבל הוא לא בהכרח שייך ל-I/O שלנו,
        // ולכן מחליטים לפי completed, ואם הוא לא שלנו — ממתינים שוב
        continue;
    }
    // חזרנו בגלל timeout. ה-I/O עוד בדרך, ולכן
    // אם מוותרים — מבטלים עם CancelIoEx וממתינים שהסיום יימסר
    CancelIoEx(hFile, ov);
}

אם ההוצאה נכשלה, לא נכנסים להמתנה. כשמקבלים 0 מ-ReadFileEx בגלל התקן שהוסר, handle לא תקין או סיבה אחרת, לא נכנס שום completion routine. לוקחים את GetLastError() מיד, מטפלים בשגיאה ויוצאים. אם מפספסים את זה, completed לא עולה אף פעם וחוזרים שוב ושוב על SleepEx ו-CancelIoEx מול I/O שלא קיים.9

אל תתייחסו ל-timeout של ההמתנה כאל סוף ה-I/O. כש-SleepEx נגמר ב-timeout הוא יוצא מה-alertable wait, אבל ה-I/O שהוצא יכול עוד להיות בדרך. אל תצאו מהסקופ ותניחו ל-ov או ל-buf לפוג באותו רגע. או שממשיכים להמתין לסיום, או שאם מוותרים — מבקשים ביטול וממתינים עד שהסיום הזה נמסר. כלל ה-lifetime מסעיף 3.2 תקף גם אחרי timeout.

אל תסיקו מ-WAIT_IO_COMPLETION לבדו שה-I/O שלכם הסתיים. ערך החזרה הזה אומר ש-APC אחד או יותר רצו. אם לאותו thread מונחים I/O אחר או APC של QueueUserAPC, גם הם יחזירו אותו. קובעים לפי הדגל שה-completion routine שלכם מעדכן, ואם הוא עוד לא הועלה — ממתינים שוב.10

כשה-“APC לא מגיע”, בודקים גם את פונקציית ההמתנה וגם אם ההוצאה הצליחה. האם זה SleepEx(..., TRUE) ולא Sleep, ו-WaitForSingleObjectEx(..., TRUE) ולא WaitForSingleObject? ה-Ex בסוף וה-TRUE בארגומנט ה-alertable הם שתי נקודות הבדיקה.10

4.4. IOCP: לקבל הרבה I/O בכמה threads מעטים

עם I/O completion port (IOCP) משייכים את ה-handle לפורט. completion packets נכנסים ל-queue של הפורט, ו-worker threads מוציאים אותם עם GetQueuedCompletionStatus. זה המנגנון לעיבוד הרבה I/O במקביל במספר קטן של threads.12

זה גם המסלול שמונח מתחת ל-I/O האסינכרוני של .NET. איך מתשלב ה-queue של הודעות הסיום עם השליטה במספר ה-threads שרצים במקביל — לעומק בפעם הבאה, בחלק 3.

5. החריג של הסיום הסינכרוני: “אסינכרוני” אינו “לעולם לא תמתינו”

5.1. התנאים האופייניים שבהם הפעולה מסתיימת בתוך הקריאה

גם כשמוציאים נכון ב-mode אסינכרוני, ה-I/O יכול להסתיים בתוך הקריאה. סיום סינכרוני פירושו שה-I/O נגמר לפני שהפונקציה חזרה, וזו אינה הבטחה שהפונקציה חוזרת מהר. מפרידים בין המקרה שמסתיים מהר בגלל cache hit לבין המקרה שממתינים בתוך הקריאה.2

אף אחד מהםהוצאת ReadFile/WriteFile על handle אסינכרוניהאם מתקיים תנאי של סיום סינכרוני?בקשה שאפשר למלא מיד(הנתונים כבר ב-cache וכו')קובץ דחוס ב-NTFS(קובץ דחוס לא נגיש באופן אסינכרוני)קובץ מוצפן ב-NTFS (EFS)כתיבה שמאריכה את אורך הקובץחוזר מיד עם TRUE= רץ עד הסוף בתוך הקריאהחוזר עם ERROR_IO_PENDING= באמת בתהליך, באופן אסינכרוני

איור 5: התנאים העיקריים שבהם I/O שהוצא אסינכרונית מסתיים סינכרונית. יש להבדיל ביניהם לבין זמן החזרה של הקריאה

מסמך פתרון התקלות של Microsoft מונה את הסיבות הבאות.2

תנאי למה זה מעובד באופן סינכרוני, ומה זה אומר לקוד
בקשה שאפשר למלא מיד, או cache hit אם הנתונים בזיכרון, ה-driver יכול לסיים במקום. לסיים מהר זה בסדר, אבל קוד שמניח ש-ERROR_IO_PENDING תמיד חוזר — שבור
קריאה מ-cache שבה ה-page הנדרש חסר ה-cache של Windows ממומש ב-file mapping. אין מנגנון page fault אסינכרוני, ולכן הבקשה עשויה להיות מעובדת סינכרונית
קובץ דחוס ב-NTFS או מוצפן ב-EFS ה-driver של מערכת הקבצים ממיר את הגישה לסינכרונית
כתיבה שמאריכה את הקובץ כתיבה שמשנה את האורך נעשית סינכרונית

הנקודה החשובה היא שעיבוד סינכרוני יכול לקרות לא רק ב-cache hit אלא גם כשהנתונים אינם ב-cache. מנגנון ה-cache עצמו נידון בחלק 4 של הסדרה.

5.2. לתכנן בנפרד את ענפי תוצאת ההוצאה ואת זמן התגובה של ה-UI

קודם כל צריך לטפל בכל שלושת הענפים מסעיף 3.3. מתייחסים ל-TRUE גם כאל תוצאה תקינה, וכברירת מחדל מרכזים את טיפול התוצאה בצד ההודעה.

עם זאת, גם כתיבה נכונה של הענפים לא מבטיחה זמן תגובה. כיוון שאי אפשר לומר “ה-UI לא יקפא כי ה-I/O אסינכרוני”, נדרש תכנון שמוציא את עצם הוצאת ה-I/O מכל thread שאסור לו להיתקע, ומוסר אותה ל-thread ייעודי או ל-thread pool. הנוהג הקשור מוסבר ב-מדריך מעשי להתקרב ככל האפשר ל-soft real-time ב-Windows רגיל.

5.3. דילוג על ההודעה בסיום סינכרוני הוא אופטימיזציה של IOCP בלבד

ב-I/O בתדירות גבוהה אפשר לחסוך את ההודעה בסיום סינכרוני. הפעלה של FILE_SKIP_COMPLETION_PORT_ON_SUCCESS דרך SetFileCompletionNotificationModes גורמת לכך ש-I/O שהצליח מיד לא יכניס completion packet ל-IOCP. זו ההגדרה למקרה שבו עוברים לתכנון שמטפל בתוצאה במקום ולא בצד ההודעה.7

מה שמדולג הוא רק ה-packet ל-IOCP: הסימון של OVERLAPPED.hEvent אינו מדוכא. אל תחילו את אותה אופטימיזציה על שיטת ה-event. ערבוב בין מסלול ההודעה של ברירת המחדל לבין המסלול שאחרי האופטימיזציה מוביל לטיפול כפול מסעיף 3.3 או להמתנה להודעה שלא תגיע. השילוב עם IOCP נידון בחלק 3.

6. ביטול וסגירה: לבקש, לאמת את הסיום, לסגור

6.1. לבחור ב-API שמתאים למה שמבטלים

את ה-APIs של ביטול בוחרים לפי הפעולה ולפי ה-thread שהוציא אותה.4135

API מהו היעד ואיך מציינים אותו
CancelIoEx מבקש לבטל I/O שלא הסתיים ב-handle נתון, בלי קשר ל-thread שהוציא אותו. OVERLAPPED בארגומנט השני מכוון לפעולה הזאת, ו-NULL לכל הפעולות ב-handle
CancelIo מכוון רק לפעולות שהוציא ה-thread הקורא עצמו
CancelSynchronousIo מכוון ל-I/O סינכרוני שרץ ב-thread אחר שצוין

CancelIoEx נוסף ב-Vista. ב-I/O אסינכרוני אין היום סיבה לבחור במכוון ב-CancelIo הישן עם המגבלה על ה-thread שהוציא, ולכן ברירת המחדל היא CancelIoEx.

6.2. הצלחה של CancelIoEx אינה אומרת שה-I/O הסתיים

CancelIoEx הוא API שמבקש לבטל IRPs שלא הסתיימו, ולא API שממתין שהפעולה תסתיים. גם כשהקריאה מצליחה, זה רק השלב שבו הביטול התבקש. פעולה שכבר הייתה על סף סיום יכולה להסתיים כרגיל מפני שהביטול לא הספיק.14

driverI/O ManagerאפליקציהdriverI/O Managerאפליקציהמבקש לבטל את ה-IRP המתאיםשלא הסתיים (מסמן אותו)אם עוד אפשר לבטל — נקטעאם הוא כמעט הסתיים, הוא עלול להסתיים כרגילרק אחרי שרואים את ההודעה הזאתמשחררים את OVERLAPPED ואת ה-bufferCancelIoEx(handle, OVERLAPPED)קריאה ל-cancel routineIoCompleteRequest(STATUS_CANCELLED)הודעת הסיום מגיעהGetOverlappedResult מדווח ERROR_OPERATION_ABORTED

איור 6: גם פעולה שבוטלה מדווחת כסיום. הניקוי נעשה אחרי האימות הזה

פעולה שבאמת בוטלה חוזרת בהודעת הסיום כ-ERROR_OPERATION_ABORTED. בין אם הפעולה הסתיימה כרגיל ובין אם בוטלה, אין לשחרר את ה-structure ואת ה-buffer עד שההודעה התקבלה. שחרור מוקדם מפקיע מידי ה-kernel זיכרון שעוד בשימוש, ומוביל לפגיעה בזיכרון. כשמקבלים access violation אחרי ביטול, בודקים קודם את ה-lifetime הזה.414

6.3. לאסוף את הפעולות שהוצאו לפני סגירת ה-handle

רצף הסגירה הבסיסי הוא לבקש ביטול → לראות את הסיום → לסגור את ה-handle.

כפי שראינו בחלק 1, סגירת ה-handle האחרון מריצה עיבוד cleanup שמבטל IRPs שלא הסתיימו. אבל סגירה של ה-handle כשפעולות I/O שהוצאו עוד בדרך נוטה להרוס את ניהול הודעות הסיום ואת חיי ה-buffers. אין להפקיד את הניקוי בידי הסגירה; מסיימים קודם את הפעולות שלא הסתיימו.

אותו דבר תקף כשרוצים לזנוח עיבוד בגלל timeout. מערכת ההפעלה לא מחליטה בשבילכם מהו תנאי הנטישה של האפליקציה, ולכן מתכננים את הביטול שאחרי ה-timeout יחד עם הנוהל לקבלת הסיום. בשיטת APC ממשיכים ב-alertable wait עד שהסיום נמסר, כפי שמופיע בסעיף 4.3.

7. ההתאמה ל-.NET: להסתכל לא רק על ReadAsync אלא על המקום שבו פותחים את הקובץ

7.1. להתאים את ה-mode של ה-handle ל-API שקוראים לו

ה-useAsync של FileStream, או FileOptions.Asynchronous, מתאים ל-FILE_FLAG_OVERLAPPED של Win32. כמו בטבלת ההתאמה בחלק 1, גם ב-.NET מה שקובע הוא ה-mode שבו הקובץ נפתח.1516

כןלאawait fs.ReadAsync(...)האם ה-handle ב-mode אסינכרוני(FileOptions.Asynchronous)?I/O אסינכרוני אמיתינשלחת המקבילה של OVERLAPPEDוהסיום מגיע ל-thread pool דרך IOCP (חלק 3)אסינכרוני למראית עיןthread מתוך ה-thread poolמבצע במקומכם קריאה סינכרונית וממתין

איור 7: אותו ReadAsync בדיוק, אבל ה-mode של ה-handle משנה את המסלול בצד מערכת ההפעלה

שילוב של handle ו-API מה קורה בפנים
mode אסינכרוני + ReadAsync / WriteAsync השילוב שמשתמש ב-I/O האסינכרוני של מערכת ההפעלה
mode סינכרוני + ReadAsync / WriteAsync thread מתוך ה-thread pool מבצע במקומכם את הקריאה או הכתיבה הסינכרונית: אסינכרוני למראית עין
mode אסינכרוני + Read / Write סינכרוניים נוצר overhead של המתנה לסיום בפנים

גם ב”אסינכרוני למראית עין” ה-thread הקורא לא ממתין, אבל מאחוריו ממתין thread אחר. בכמות קטנה הנזק קטן, אבל בשרת או בעיבוד בתדירות גבוהה זה גורם להתרוקנות ה-thread pool ולירידה ב-scalability. העיקרון הוא להתאים את ה-mode ל-API.1615

7.2. להשוות שלוש דרכים ליצירת FileStream

ב-(A) וב-(B) שלמטה ReadAsync נקרא באותה צורה בדיוק. ההבדל היחיד הוא useAsync בזמן פתיחת הקובץ. (C) הוא הדוגמה של .NET 6 ואילך, שמציגה במפורש את ה-handle ואת המיקום.

using System;
using System.IO;
using System.Threading.Tasks;
using Microsoft.Win32.SafeHandles;

string path = @"C:\temp\data.bin";
byte[] buffer = new byte[4096];

// (A) אסינכרוני למראית עין. השמטה של useAsync או false פותחת את ה-handle ב-mode סינכרוני
using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read,
                               bufferSize: 4096, useAsync: false))
{
    // הקורא לא נחסם, אבל מאחוריו thread אחד מתוך ה-thread pool מבצע קריאה סינכרונית וממתין
    await fs.ReadAsync(buffer, 0, buffer.Length);
}

// (B) אסינכרוני אמיתי. useAsync: true ממופה ישירות ל-FILE_FLAG_OVERLAPPED
using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read,
                               bufferSize: 4096, useAsync: true))
{
    // הסיום מגיע ל-thread pool דרך IOCP (חלק 3)
    await fs.ReadAsync(buffer, 0, buffer.Length);
}

// (C) .NET 6 ואילך. הצורה הישירה שמציגה במפורש את ה-mode ואת ה-offset
using (SafeFileHandle handle = File.OpenHandle(path, FileMode.Open, FileAccess.Read,
                                               options: FileOptions.Asynchronous))
{
    int read = await RandomAccess.ReadAsync(handle, buffer, fileOffset: 0);
}

כשמעיינים בקוד קיים, מסתכלים לא רק על מקומות הקריאה ל-ReadAsync / WriteAsync אלא על המקומות שבהם נוצר FileStream. File.OpenRead ואוברלואדים קצרים כמו new FileStream(path, FileMode.Open) פותחים ב-mode סינכרוני. גם כשיוצרים FileStream מ-SafeFileHandle, מתאימים את הארגומנט isAsync ל-mode האמיתי של ה-handle.

7.3. ב-RandomAccess מציינים במפורש את ה-handle ואת ה-offset

ב-.NET 6 המימוש הפנימי של FileStream נכתב מחדש מאפס, ו-File.OpenHandle ו-RandomAccess נוספו. אלה APIs שבהם עובדים ישירות עם SafeFileHandle ומעבירים את מיקום הקריאה והכתיבה בכל קריאה.16

הצורה ב-(C), שמציינת במפורש את ה-mode ואת ה-fileOffset, מתאימה לחלוקת התפקידים שתוארה במאמר הזה: handle אסינכרוני ועוד OVERLAPPED.Offset לכל פעולה בנפרד.

7.4. גם עם CancellationToken הביטול נשאר בקשה

ב-handle ב-mode אסינכרוני, ביטול של I/O קבצים עם CancellationToken מגיע בפנים ל-CancelIoEx. כשה-ReadAsync שקיבל token מסתיים ב-OperationCanceledException, המנגנון מפרק 6 הוא זה שפועל מאחוריו. גם הנקודה שקטיעה מיידית אינה מובטחת זהה.

ל”אסינכרוני למראית עין” ב-mode סינכרוני אין פעולת overlapped לבטל, ולכן המסלול הזה לא זמין. ב-runtimes של .NET בשנים האחרונות יש מנגנון שמנסה לבטל קריאה שרצה סינכרונית דרך CancelSynchronousIo, אבל ההתנהגות תלויה בגרסת ה-runtime ובסוג הפעולה, וקטיעה מהימנה אינה מובטחת. אם מתכננים סביב ביטול, הדרך הנכונה היא להתאים את ה-mode של ה-handle ולהשתמש ב-I/O האסינכרוני של מערכת ההפעלה.

על השכבה המעשית שמעל async/awaitConfigureAwait, הקשר ל-UI thread וכן הלאה — ראו טבלת החלטות מעשית ל-async/await ב-C# - Task.Run ו-ConfigureAwait ו-async וה-UI thread ב-WPF/WinForms על דף אחד. המאמר הזה מסביר איך מערכת ההפעלה מקדמת את הקריאות והכתיבות מתחת לכל זה.

8. סיכום: לעבור מהוצאה עד ניקוי כמסלול אחד רצוף

כשמעיינים ב-I/O אסינכרוני, עוקבים אחרי הקוד בסדר הזה.

  1. במקום שפותחים מאמתים את ה-mode הסינכרוני או האסינכרוני. בקובץ על הדיסק מציינים מיקום בכל פעולה אסינכרונית.
  2. במקום שמוציאים מאמתים שיש OVERLAPPED ו-buffer ייעודיים לפעולה, ושכל שלושת הענפים מסעיף 3.3 מטופלים.
  3. במקום שמקבלים את הסיום מאמתים שדרך ההמתנה מתאימה לשיטה — event, APC, IOCP — ושאותה תוצאה אינה מטופלת פעמיים.
  4. במקום שסוגרים מאמתים שאין שחרור של משהו רק על סמך timeout או בקשת ביטול.

I/O סינכרוני ו-I/O אסינכרוני אינם צנרת נפרדת. ההבדל הוא אם חוזרים אחרי שהמתינו לסיום או נוקטים מסלול שחוזר לפניו. אבל כיוון שסיום סינכרוני קורה גם ב-mode אסינכרוני, מתכננים בנפרד את ענפי תוצאת ההוצאה ואת זמן התגובה.12

ה-mode שייך ל-handle, ה-state שייך לכל פעולה בנפרד, והניקוי נעשה רק אחרי שאימות הסיום התקבל. חלוקת התפקידים הזאת זהה בין אם עובדים ישירות עם OVERLAPPED של Win32 ובין אם משתמשים ב-FileOptions.Asynchronous של .NET. גם פעולה שבוטלה נשארת בניהול שלכם עד שמקבלים את הסיום שלה.3415

המשך בחלק 3: “I/O completion ports (IOCP) ו-thread pool של .NET — המרתף שמתחת ל-async/await”. הוא עוסק בשאלה למה ה-IOCP מסעיף 4.4 מאחד את ה-queue של הודעות הסיום עם השליטה במספר ה-threads שרצים, ועל איזה thread ממשיך ה-await.

מאמרים קשורים

תחומי ייעוץ קשורים

KomuraSoft LLC עוסקת בתכנון אפליקציות Windows עסקיות ואפליקציות תקשורת עם התקנים שמשתמשות ב-I/O אסינכרוני, ובחקירת הסיבות לתקלות כמו “קפיאה”, “קריסה אחרי ביטול” ו”התרוקנות ה-thread pool”.

מקורות

  1. Microsoft Learn, Synchronous and asynchronous I/O. על כך שב-I/O סינכרוני הפונקציה נחסמת עד לסיום ה-I/O, בעוד שב-I/O אסינכרוני הפונקציה שמוציאה את הבקשה חוזרת מיד וה-thread יכול להמשיך בעבודה אחרת; על כך שב-I/O אסינכרוני יש לפתוח את ה-handle עם FILE_FLAG_OVERLAPPED; על דרכי ההודעה על הסיום — סימון ה-file handle, סימון ה-event שנקבע ב-OVERLAPPED, completion routine (APC) שרץ ב-alertable wait, ו-I/O completion port; ועל כך שסימון ה-file handle אינו מאפשר להבדיל איזו פעולה הסתיימה כשכמה פעולות נמצאות בדרך במקביל.  2 3 4 5 6 7 8

  2. Microsoft Learn, Asynchronous disk I/O appears as synchronous on Windows. על הסיבות לכך ש-I/O שנכתב כאסינכרוני בכל זאת מסתיים סינכרונית: קובץ דחוס ב-NTFS (ה-driver של מערכת הקבצים לא ניגש לקבצים דחוסים באופן אסינכרוני, וכל הפעולות נעשות סינכרוניות), קובץ מוצפן ב-NTFS, כתיבה שמאריכה את אורך הקובץ, ובקשה שאפשר למלא מיד (למשל כשהנתונים כבר ב-cache שבזיכרון), שאז ה-driver מסיים את הפעולה במקום ומחזיר TRUE; על כך שה-cache של Windows ממומש ב-file mapping ואין מנגנון page fault אסינכרוני כשה-page חסר; ובנוסף, על כך שכדי להוציא שלוש פעולות I/O נדרשים שלושה OVERLAPPED, וששימוש חוזר מוביל לתוצאות בלתי צפויות או לפגיעה בנתונים, ועל כך שאסור לקרוא או לכתוב ל-buffer המתאים עד שהפעולה מסתיימת.  2 3 4 5 6

  3. Microsoft Learn, OVERLAPPED structure. על כך ש-OVERLAPPED מחזיק את המידע ל-input/output אסינכרוני; על כך ש-Offset/OffsetHigh מחזיקים את מיקום הקובץ, hEvent את ה-event שמסומן בסיום, ו-Internal/InternalHigh את קוד המצב של הפעולה ואת מספר הבתים שהועברו; על כך שאסור לשנות את ה-structure בזמן שהפעולה רצה והוא צריך להישאר תקף; ועל נקודות הזהירות בשימוש ב-event.  2 3 4 5 6

  4. Microsoft Learn, CancelIoEx function. על כך ש-CancelIoEx מסמן לביטול I/O שלא הסתיים ב-handle נתון, בלי קשר ל-thread שהוציא אותו; על כך שציון lpOverlapped מכוון לפעולה הזאת בלבד, ו-NULL לכל ה-I/O שלא הסתיים; על כך שפעולה שבוטלה מסתיימת עם ERROR_OPERATION_ABORTED; ועל כך שביטול של כל הפעולות אינו מובטח, ולכן יש להמתין עד שעיבוד הסיום נגמר.  2 3 4

  5. Microsoft Learn, CancelSynchronousIo function. על כך ש-CancelSynchronousIo מסמן לביטול פעולת I/O סינכרונית שרצה ב-thread שצוין, ועל כך שהפעולה שבוטלה חוזרת ככשל עם ERROR_OPERATION_ABORTED.  2

  6. Microsoft Learn, ReadFile function. על כך שב-handle שנפתח עם FILE_FLAG_OVERLAPPED חובה lpOverlapped, ושמיקום ההתחלה של הקריאה נקבע ב-Offset/OffsetHigh של OVERLAPPED; על כך שבעיבוד אסינכרוני מוחזרים FALSE ו-ERROR_IO_PENDING; על כך שהמערכת לא מנהלת file pointer ל-handle אסינכרוני; ועל כך שהעברת OVERLAPPED ל-handle שנפתח בלי FILE_FLAG_OVERLAPPED קוראת מה-offset שצוין, אבל ReadFile עדיין לא חוזר עד שהקריאה מסתיימת.  2 3

  7. Microsoft Learn, SetFileCompletionNotificationModes function. על כך ש-FILE_SKIP_COMPLETION_PORT_ON_SUCCESS מאפשר לא לדחוף completion packet ל-I/O completion port כשה-I/O מצליח מיד, ועל כך ש-FILE_SKIP_SET_EVENT_ON_HANDLE מאפשר לדלג על סימון ה-event של ה-file handle.  2

  8. Microsoft Learn, GetOverlappedResult function. על כך ש-GetOverlappedResult מוציא את תוצאת הפעולה האסינכרונית — הצלחה או כשל ומספר הבתים שהועברו; על כך שהעברת TRUE ב-bWait ממתינה לסיום הפעולה; ועל כך שאם hEvent של OVERLAPPED הוא auto-reset event והמתנה אחרת צרכה את הסימון, קריאה עם bWait=TRUE עלולה שלא לזהות את הסיום ולהמשיך להמתין — ולכן יש להשתמש ב-manual-reset event.  2

  9. Microsoft Learn, ReadFileEx function. על כך ש-ReadFileEx מקבל completion routine (FileIOCompletionRoutine) שנקרא בסיום הקריאה; על כך שה-completion routine רץ כשה-thread הקורא נמצא במצב alertable wait; ועל כך שנדרש handle שנפתח עם FILE_FLAG_OVERLAPPED.  2

  10. Microsoft Learn, Alertable I/O. על כך שב-alertable I/O רשומה עבור ה-completion routine נכנסת ל-APC queue של ה-thread; על כך שה-APC רץ כשה-thread נכנס למצב alertable דרך SleepEx, WaitForSingleObjectEx, WaitForMultipleObjectsEx וכדומה; ועל כך ש-APC תמיד רץ בהקשר של ה-thread שהוציא אותו.  2 3

  11. Microsoft Learn, Asynchronous Procedure Calls. על כך ש-APC היא פונקציה שרצה באופן אסינכרוני בהקשר של thread מסוים; על כך שלכל thread יש APC queue משלו; ועל כך ש-user-mode APC רץ רק כשה-thread נמצא במצב alertable. 

  12. Microsoft Learn, I/O Completion Ports. על כך ש-I/O completion port מספק מודל threading יעיל לטיפול בהרבה בקשות I/O אסינכרוניות במערכת עם כמה מעבדים; על כך ששיוך file handle לפורט מכניס completion packets ל-queue, ו-worker threads מוציאים אותם עם GetQueuedCompletionStatus; ועל כך שהפורט שולט במספר ה-threads שרצים במקביל. 

  13. Microsoft Learn, CancelIo function. על כך ש-CancelIo יכול לבטל רק פעולות I/O שהוציא ה-thread הקורא עצמו, ועל כך שכדי לבטל גם פעולות שהוציאו threads אחרים משתמשים ב-CancelIoEx. 

  14. Microsoft Learn, Canceling pending I/O operations. על המנגנון לביטול I/O שלא הסתיים, על כך שגם אחרי בקשת ביטול הפעולה יכולה כבר להיות בדרכה לסיום, על כך שיש לאמת את הסיום של הפעולה שבוטלה לפני שחרור המשאבים, ועל החלוקה שבה לסינכרוני משתמשים ב-CancelSynchronousIo ולאסינכרוני ב-CancelIo/CancelIoEx.  2

  15. Microsoft Learn, Asynchronous file I/O (.NET). על הגישה של .NET ל-I/O אסינכרוני בקבצים, על כך שב-FileStream מציינים useAsync (FileOptions.Asynchronous) בבנאי כדי להפעיל I/O אסינכרוני ברמת מערכת ההפעלה, ועל ההבחנה בין שימוש בשיטות סינכרוניות לשימוש בשיטות אסינכרוניות.  2 3

  16. Microsoft .NET Blog, File IO improvements in .NET 6. על כך שב-.NET 6 המימוש הפנימי של FileStream נכתב מחדש מאפס; על כך שהאסטרטגיה נקבעת לפי אם ה-handle נפתח ב-mode אסינכרוני; על כך ש-File.OpenHandle מחזיר SafeFileHandle ישירות ו-RandomAccess מאפשר קריאה וכתיבה עם offset מפורש (thread-safe); ועל כך שקריאות אסינכרוניות ל-handle שאינו ב-mode אסינכרוני מועברות ל-thread pool.  2 3

מאמרים עדכניים עם אותן תגיות, להעמקה בנושאים קרובים.

העמודים האלה ממקמים את הנושא בהקשר רחב יותר של שירותים והחלטות.

המאמר קשור ישירות לשירותים הבאים.

שאלות נפוצות

שאלות נפוצות בפניות בנושא המאמר.

מה משתנה כשמוסיפים FILE_FLAG_OVERLAPPED?
ה-file object שמאחורי ה-handle נפתח במצב "אסינכרוני". זו תכונה של ה-handle שנקבעת ברגע הקריאה ל-CreateFile, ואי אפשר להחליף בין סינכרוני לאסינכרוני בכל קריאה בנפרד. ב-handle במצב אסינכרוני חייבים תמיד להעביר OVERLAPPED ל-ReadFile/WriteFile. המערכת לא מנהלת עבור ה-handle הזה file pointer (מיקום נוכחי), ולכן בהתקן שיש לו מיקום — כמו קובץ על הדיסק — מציינים את מיקום הקריאה והכתיבה בכל פעם דרך Offset שב-OVERLAPPED (בהתקנים בלי מיקום, כמו serial port, לא משתמשים ב-Offset). פעולה שהוצאה יכולה להחזיר שליטה לפני שהסתיימה; במקרה כזה ReadFile מחזיר FALSE ו-GetLastError מדווח ERROR_IO_PENDING. את הסיום מקבלים דרך הודעה — event, APC או I/O completion port.
הוצאתי I/O אסינכרוני — למה הוא חוזר מיד כשהוא כבר הסתיים?
כי מצב אסינכרוני פירושו "לא חייבים להמתין לסיום", ולא "לעולם לא תמתינו". התיעוד של Microsoft מונה את הסיבות האופייניות לכך שפעולה שהוצאה אסינכרונית בכל זאת מסתיימת סינכרונית: בקשה שאפשר למלא מיד (למשל כשהנתונים כבר ב-cache), קובץ דחוס ב-NTFS, קובץ מוצפן ב-NTFS (EFS), וכתיבה שמאריכה את אורך הקובץ. במקרים האלה ReadFile/WriteFile מחזיר TRUE והתוצאה כבר סופית. קוד שמשתמש ב-I/O אסינכרוני חייב אפוא לטפל גם במקרה שחוזר ERROR_IO_PENDING וגם במקרה שמסתיים במקום, וזמן התגובה גם הוא אינו מובטח באופן מוחלט. שימו לב שכברירת מחדל הודעת הסיום (סימון של event, או packet שנכנס ל-I/O completion port) מגיעה בנפרד גם בפעולה שהסתיימה סינכרונית, ולכן הדרך הבטוחה היא לרכז את טיפול התוצאה בצד ההודעה בלבד.
אפשר להשתמש שוב באותו OVERLAPPED?
אסור לשתף OVERLAPPED אחד בין כמה פעולות במקביל. OVERLAPPED מייצג את המצב של פעולה אחת שנמצאת כרגע בדרך, והתיעוד של Microsoft קובע במפורש שאם מוציאים שלוש פעולות I/O צריך שלושה OVERLAPPED, וששימוש חוזר מוביל לתוצאות בלתי צפויות ולפגיעה בנתונים. עד שהפעולה מסתיימת, גם ה-structure וגם ה-buffer לקריאה ולכתיבה נשארים תקפים ואסור לגעת בתוכנם. אם משתמשים ב-structure שוב אחרי שהפעולה הסתיימה, מאתחלים אותו מחדש בכל פעם כדי ששאריות מהשימוש הקודם לא ישפיעו. עבור hEvent שמחזיק את ה-event, הבחירה הבטוחה היא manual-reset event.
איך מבטלים פעולת I/O שכבר רצה?
CancelIoEx מאפשר לבקש ביטול של I/O שלא הסתיים ב-handle נתון, בלי קשר ל-thread שהוציא אותו. העברת OVERLAPPED בארגומנט השני מכוונת לפעולה אחת מסוימת, ו-NULL לכל הפעולות ב-handle. CancelIo הישן יותר יכול לבטל רק פעולות שהוציא ה-thread הקורא עצמו. החשוב הוא שביטול הוא בקשה ולא הבטחה מיידית. פעולה שכבר הייתה על סף סיום יכולה להסתיים כרגיל, ופעולה שבוטלה מדווחת ככזו שהסתיימה עם ERROR_OPERATION_ABORTED. בשני המקרים אסור לשחרר את ה-OVERLAPPED או את ה-buffer עד שהתקבלה הודעת הסיום. ל-thread שנתקע ב-I/O סינכרוני יש API ייעודי בשם CancelSynchronousIo.
מה קורה אם לא מציינים FileOptions.Asynchronous (useAsync) ב-FileStream של .NET?
ה-handle נפתח במצב סינכרוני, ולכן קריאה ל-ReadAsync/WriteAsync לא נותנת I/O אסינכרוני אמיתי. במקום זה thread מתוך ה-thread pool מבצע במקומכם את הקריאה או הכתיבה הסינכרונית — אסינכרוני למראית עין. ה-thread הקורא לא נחסם, אבל thread אחר נשאר ממתין מאחורי הקלעים, וזה גורם להתרוקנות ה-thread pool ולירידה ב-scalability. לעומת זאת, פתיחה במצב אסינכרוני ואז קריאה ל-Read/Write הסינכרוניים מוסיפה overhead של המתנה לסיום בפנים. העיקרון הוא להתאים את מצב ה-handle ל-API שקוראים לו, ומ-.NET 6 אפשר לכתוב את זה בצורה ישירה עם File.OpenHandle ו-RandomAccess, כשגם המצב וגם ה-offset מפורשים.

פרופיל הכותב

עמוד היכרות עם כותב המאמר.

Go Komura

מנהל KomuraSoft LLC

מתמחה בפיתוח תוכנה עבור Windows, ייעוץ טכני וחקירת תקלות, בעיקר בפרויקטים עם מערכות קיימות ובאגים שקשה לשחזר.

קישורים ציבוריים

חזרה לבלוג