בפעם הקודמת (חלק 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.
sequenceDiagram
participant App as thread של האפליקציה
participant IOM as I/O Manager
participant DRV as driver (stack)
App->>IOM: ReadFile(handle סינכרוני)
IOM->>DRV: הוצאת IRP
DRV-->>IOM: STATUS_PENDING (ממתין לתגובת ההתקן)
Note over App,IOM: ה-thread נכנס להמתנה בתוך ה-kernel<br/>וישן בלי לבזבז CPU
DRV->>IOM: סיום (IoCompleteRequest)
IOM-->>App: מחזיר את התוצאה ומעיר את ה-thread<br/>ReadFile חוזר עם 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 |
מספר הבתים שהועברו בסיום (לשימוש המערכת) |
flowchart LR
subgraph H["handle (file object) = mode"]
M1["mode סינכרוני<br/>ה-kernel מנהל את המיקום הנוכחי<br/>ReadFile לא חוזר עד הסיום"]
M2["mode אסינכרוני (FILE_FLAG_OVERLAPPED)<br/>המיקום הנוכחי אינו מנוהל<br/>ההוצאה והסיום נפרדים"]
end
subgraph O["OVERLAPPED structure = פתק עבודה של פעולה אחת"]
F1["Offset: מהיכן קוראים"]
F2["hEvent: איך יודעים שהפעולה הסתיימה"]
F3["Internal/InternalHigh:<br/>state ותוצאה (המערכת כותבת)"]
end
C["נקבע פעם אחת, בזמן CreateFile"] --> H
R["נדרש אחד לכל הוצאת ReadFile/WriteFile"] --> O
איור 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 |
flowchart TB
A["ReadFile(handle אסינכרוני, עם OVERLAPPED)"]
Q{"מה ערך החזרה?"}
T["TRUE<br/>הסתיים במקום (סיום סינכרוני)<br/>כברירת מחדל גם מגיעה הודעת סיום"]
P["FALSE + ERROR_IO_PENDING<br/>התקבלה. הסיום יגיע בהמשך"]
E["FALSE + שגיאה אחרת<br/>ההוצאה עצמה נכשלה"]
W["ממתינים להודעת הסיום<br/>(ארבע השיטות בפרק 4)"]
A --> Q
Q --> T
Q --> P
Q --> E
P --> W
איור 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) |
flowchart TB
DONE["ה-I/O הסתיים ב-kernel<br/>(IoCompleteRequest, ואז APC קובע את התוצאה)"]
N1["(1) ה-file handle עובר למצב signaled<br/>קבלה: WaitForSingleObject(handle)"]
N2["(2) ה-hEvent של OVERLAPPED עובר למצב signaled<br/>קבלה: WaitForSingleObject + GetOverlappedResult"]
N3["(3) ה-completion routine נכנס ל-APC queue של ה-thread שהוציא<br/>קבלה: הוא רץ בזמן alertable wait כמו SleepEx"]
N4["(4) completion packet נכנס ל-I/O completion port<br/>קבלה: GetQueuedCompletionStatus (חלק 3)"]
DONE --> N1
DONE --> N2
DONE --> N3
DONE --> N4
איור 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
flowchart TB
A["הוצאת ReadFile/WriteFile על handle אסינכרוני"]
Q{"האם מתקיים תנאי של סיום סינכרוני?"}
C1["בקשה שאפשר למלא מיד<br/>(הנתונים כבר ב-cache וכו')"]
C2["קובץ דחוס ב-NTFS<br/>(קובץ דחוס לא נגיש באופן אסינכרוני)"]
C3["קובץ מוצפן ב-NTFS (EFS)"]
C4["כתיבה שמאריכה את אורך הקובץ"]
T["חוזר מיד עם TRUE<br/>= רץ עד הסוף בתוך הקריאה"]
P["חוזר עם ERROR_IO_PENDING<br/>= באמת בתהליך, באופן אסינכרוני"]
A --> Q
Q --> C1
Q --> C2
Q --> C3
Q --> C4
C1 --> T
C2 --> T
C3 --> T
C4 --> T
Q -->|"אף אחד מהם"| P
איור 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
sequenceDiagram
participant App as אפליקציה
participant IOM as I/O Manager
participant DRV as driver
App->>IOM: CancelIoEx(handle, OVERLAPPED)
Note over IOM: מבקש לבטל את ה-IRP המתאים<br/>שלא הסתיים (מסמן אותו)
IOM->>DRV: קריאה ל-cancel routine
Note over DRV: אם עוד אפשר לבטל — נקטע<br/>אם הוא כמעט הסתיים, הוא עלול להסתיים כרגיל
DRV->>IOM: IoCompleteRequest<br/>(STATUS_CANCELLED)
IOM-->>App: הודעת הסיום מגיעה<br/>GetOverlappedResult מדווח ERROR_OPERATION_ABORTED
Note over App: רק אחרי שרואים את ההודעה הזאת<br/>משחררים את OVERLAPPED ואת ה-buffer
איור 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
flowchart TB
A["await fs.ReadAsync(...)"]
Q{"האם ה-handle ב-mode אסינכרוני<br/>(FileOptions.Asynchronous)?"}
Y["I/O אסינכרוני אמיתי<br/>נשלחת המקבילה של OVERLAPPED<br/>והסיום מגיע ל-thread pool דרך IOCP (חלק 3)"]
N["אסינכרוני למראית עין<br/>thread מתוך ה-thread pool<br/>מבצע במקומכם קריאה סינכרונית וממתין"]
A --> Q
Q -->|כן| Y
Q -->|לא| N
איור 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/await — ConfigureAwait, הקשר ל-UI thread וכן הלאה — ראו טבלת החלטות מעשית ל-async/await ב-C# - Task.Run ו-ConfigureAwait ו-async וה-UI thread ב-WPF/WinForms על דף אחד. המאמר הזה מסביר איך מערכת ההפעלה מקדמת את הקריאות והכתיבות מתחת לכל זה.
8. סיכום: לעבור מהוצאה עד ניקוי כמסלול אחד רצוף
כשמעיינים ב-I/O אסינכרוני, עוקבים אחרי הקוד בסדר הזה.
- במקום שפותחים מאמתים את ה-mode הסינכרוני או האסינכרוני. בקובץ על הדיסק מציינים מיקום בכל פעולה אסינכרונית.
- במקום שמוציאים מאמתים שיש
OVERLAPPEDו-buffer ייעודיים לפעולה, ושכל שלושת הענפים מסעיף 3.3 מטופלים. - במקום שמקבלים את הסיום מאמתים שדרך ההמתנה מתאימה לשיטה — event, APC, IOCP — ושאותה תוצאה אינה מטופלת פעמיים.
- במקום שסוגרים מאמתים שאין שחרור של משהו רק על סמך 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.
מאמרים קשורים
- מעמקי ה-I/O של Windows (חלק 1) — כל קריאה וכתיבה הופכת ל-IRP: התמונה הכוללת של מערכת ה-I/O
- טבלת החלטות מעשית ל-async/await ב-C# - Task.Run ו-ConfigureAwait
- async וה-UI thread ב-WPF/WinForms על דף אחד
- מלכודות באפליקציית תקשורת סריאלית - מחיבור מחדש ועד תכנון הלוגים
- למה ב-Windows עדיף להמתין ל-event מאשר ל-Sleep(1)
- מדריך מעשי להתקרב ככל האפשר ל-soft real-time ב-Windows רגיל
- התפיסה השגויה שאפשר לקבל ב-TCP כל יחידה שנשלחה — תכנון קליטה שעובד על byte stream
תחומי ייעוץ קשורים
KomuraSoft LLC עוסקת בתכנון אפליקציות Windows עסקיות ואפליקציות תקשורת עם התקנים שמשתמשות ב-I/O אסינכרוני, ובחקירת הסיבות לתקלות כמו “קפיאה”, “קריסה אחרי ביטול” ו”התרוקנות ה-thread pool”.
- פיתוח אפליקציות Windows
- חקירת תקלות וניתוח שורש הבעיה
- פיתוח אפליקציות Windows ל-soft real-time
- יצירת קשר
מקורות
-
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
-
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
-
Microsoft Learn, OVERLAPPED structure. על כך ש-OVERLAPPED מחזיק את המידע ל-input/output אסינכרוני; על כך ש-Offset/OffsetHigh מחזיקים את מיקום הקובץ, hEvent את ה-event שמסומן בסיום, ו-Internal/InternalHigh את קוד המצב של הפעולה ואת מספר הבתים שהועברו; על כך שאסור לשנות את ה-structure בזמן שהפעולה רצה והוא צריך להישאר תקף; ועל נקודות הזהירות בשימוש ב-event. ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
Microsoft Learn, CancelIoEx function. על כך ש-CancelIoEx מסמן לביטול I/O שלא הסתיים ב-handle נתון, בלי קשר ל-thread שהוציא אותו; על כך שציון lpOverlapped מכוון לפעולה הזאת בלבד, ו-NULL לכל ה-I/O שלא הסתיים; על כך שפעולה שבוטלה מסתיימת עם ERROR_OPERATION_ABORTED; ועל כך שביטול של כל הפעולות אינו מובטח, ולכן יש להמתין עד שעיבוד הסיום נגמר. ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, CancelSynchronousIo function. על כך ש-CancelSynchronousIo מסמן לביטול פעולת I/O סינכרונית שרצה ב-thread שצוין, ועל כך שהפעולה שבוטלה חוזרת ככשל עם ERROR_OPERATION_ABORTED. ↩ ↩2
-
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
-
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
-
Microsoft Learn, GetOverlappedResult function. על כך ש-GetOverlappedResult מוציא את תוצאת הפעולה האסינכרונית — הצלחה או כשל ומספר הבתים שהועברו; על כך שהעברת TRUE ב-bWait ממתינה לסיום הפעולה; ועל כך שאם hEvent של OVERLAPPED הוא auto-reset event והמתנה אחרת צרכה את הסימון, קריאה עם bWait=TRUE עלולה שלא לזהות את הסיום ולהמשיך להמתין — ולכן יש להשתמש ב-manual-reset event. ↩ ↩2
-
Microsoft Learn, ReadFileEx function. על כך ש-ReadFileEx מקבל completion routine (FileIOCompletionRoutine) שנקרא בסיום הקריאה; על כך שה-completion routine רץ כשה-thread הקורא נמצא במצב alertable wait; ועל כך שנדרש handle שנפתח עם FILE_FLAG_OVERLAPPED. ↩ ↩2
-
Microsoft Learn, Alertable I/O. על כך שב-alertable I/O רשומה עבור ה-completion routine נכנסת ל-APC queue של ה-thread; על כך שה-APC רץ כשה-thread נכנס למצב alertable דרך SleepEx, WaitForSingleObjectEx, WaitForMultipleObjectsEx וכדומה; ועל כך ש-APC תמיד רץ בהקשר של ה-thread שהוציא אותו. ↩ ↩2 ↩3
-
Microsoft Learn, Asynchronous Procedure Calls. על כך ש-APC היא פונקציה שרצה באופן אסינכרוני בהקשר של thread מסוים; על כך שלכל thread יש APC queue משלו; ועל כך ש-user-mode APC רץ רק כשה-thread נמצא במצב alertable. ↩
-
Microsoft Learn, I/O Completion Ports. על כך ש-I/O completion port מספק מודל threading יעיל לטיפול בהרבה בקשות I/O אסינכרוניות במערכת עם כמה מעבדים; על כך ששיוך file handle לפורט מכניס completion packets ל-queue, ו-worker threads מוציאים אותם עם GetQueuedCompletionStatus; ועל כך שהפורט שולט במספר ה-threads שרצים במקביל. ↩
-
Microsoft Learn, CancelIo function. על כך ש-CancelIo יכול לבטל רק פעולות I/O שהוציא ה-thread הקורא עצמו, ועל כך שכדי לבטל גם פעולות שהוציאו threads אחרים משתמשים ב-CancelIoEx. ↩
-
Microsoft Learn, Canceling pending I/O operations. על המנגנון לביטול I/O שלא הסתיים, על כך שגם אחרי בקשת ביטול הפעולה יכולה כבר להיות בדרכה לסיום, על כך שיש לאמת את הסיום של הפעולה שבוטלה לפני שחרור המשאבים, ועל החלוקה שבה לסינכרוני משתמשים ב-CancelSynchronousIo ולאסינכרוני ב-CancelIo/CancelIoEx. ↩ ↩2
-
Microsoft Learn, Asynchronous file I/O (.NET). על הגישה של .NET ל-I/O אסינכרוני בקבצים, על כך שב-FileStream מציינים useAsync (FileOptions.Asynchronous) בבנאי כדי להפעיל I/O אסינכרוני ברמת מערכת ההפעלה, ועל ההבחנה בין שימוש בשיטות סינכרוניות לשימוש בשיטות אסינכרוניות. ↩ ↩2 ↩3
-
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
מאמרים קשורים
מאמרים עדכניים עם אותן תגיות, להעמקה בנושאים קרובים.
מעמקי ה-I/O ב-Windows (פרק 4) — Cache Manager: מתי WriteFile באמת מגיע לדיסק
פרק 4 בסדרה שמסבירה בתרשימים את Cache Manager של Windows. המאמר עובר על מטמון שממומש כמיפוי קובץ, read-ahead ו-lazy writer, הבחירה בין Fl...
למה arguments נשברים — כללי command-line arguments ב-Windows
Windows מעביר ל-CreateProcess מחרוזת אחת שהמקבל מפצל. מכסה את כללי CommandLineToArgvW, CRT ו-.NET, ArgumentList, ובניה ב-C++.
שיטות עבודה מומלצות ל-multithreading בפועל: מהדורת .NET — מה להחליט לפני שמוסיפים threads
כללי תכנון שמונעים מקוד multithreaded ב-.NET/C# לקרוס או להיתקע מדי פעם: להשתמש ב-Task במקום ליצור threads בעצמכם, לצמצם shared mutable s...
WMI/CIM מ-C# ומ-PowerShell — מדריך מעשי למידע חומרה, ניטור process ושאילתות remote
WMI/CIM הוא הדרך הסטנדרטית לשלוף serial number של מחשב, לנטר דיסק פנוי ולזהות process שהתחיל. המאמר מכסה CIM cmdlets כמו Get-CimInstance,...
מעמקי ה-I/O ב-Windows (פרק 6, אחרון) — filter drivers ו-minifilter: למה Procmon וסריקת אנטי-וירוס יכולים להתערב ב-I/O
הפרק האחרון בסדרה שמסבירה בתרשימים filter drivers ו-minifilter ב-Windows. המאמר עובר על Filter Manager ו-altitude, callbacks מסוג pre/pos...
נושאים קשורים
העמודים האלה ממקמים את הנושא בהקשר רחב יותר של שירותים והחלטות.
נושאים טכניים ב-Windows
שער לנושאי פיתוח Windows, חקירת תקלות וניצול נכסים קיימים.
שירותים הקשורים לנושא הזה
המאמר קשור ישירות לשירותים הבאים.
פיתוח יישומי Windows
יישומים עסקיים, חיבור התקנים וכלי תקשורת, מהגדרת הדרישות ועד הפיתוח.
שאלות נפוצות
שאלות נפוצות בפניות בנושא המאמר.
- מה משתנה כשמוסיפים 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 מפורשים.