איך כדאי לאסוף סיומים של I/O כך שמספר קטן של threads יטפל במספר גדול של חיבורים? בזמן שאתם ממתינים על await, מי עושה את העבודה, ועל איזה thread חוזר הקוד שאחרי הסיום?
הפרק הזה עוקב אחרי המסלול מאיך I/O completion port (IOCP) אוסף הודעות סיום ועד איך .NET מקבל את ההודעות האלה ומריץ את המשך ה-await. ברגע שמפרידים בין “לקבל את הסיום” לבין “להחליט איפה רץ ההמשך”, ConfigureAwait(false) ו-thread pool starvation מתחברים לאותה תמונה.
בפעם הקודמת (חלק 2) ראינו איך מוציאים I/O אסינכרוני ובאילו ארבעה מסלולים מקבלים את הסיום. IOCP הוא המסלול שניקח כאן כמנגנון לקבלת הרבה סיומי I/O במקביל במספר קטן של threads.
זהו חלק 3 בסדרה “מעמקי ה-I/O של Windows”. המבנה הכולל נמצא בפתח חלק 1.
לקפוץ ישר למה שצריך
אם אתם קוראים לפי הסדר, פרק 2 מציג את המגבלות של תכנון שמקדיש thread לכל חיבור, פרקים 3 ו-4 מכסים את Win32, ופרק 5 ממפה את זה ל-.NET. אם אתם באמצע חקירה, המדריך הבא יוביל אתכם להסבר שצריך.
| מה שרוצים לדעת, או מה שנתקע | איפה לקרוא |
|---|---|
| למה אלפי חיבורים בו-זמנית לא דורשים את אותו מספר threads | פרק 2: מספר החיבורים ומספר ה-threads, פרק 3: ה-queue של הסיומים ובקרת ה-threads |
| איך מזהים איזה I/O על איזה handle הסתיים | סעיף 3.1: CompletionKey ו-OVERLAPPED |
| אחזור הסיום החזיר FALSE ואתם לא בטוחים אם בטוח לנקות | סעיף 3.2: להבדיל בין I/O שנכשל לאחזור שנכשל |
| היחס בין FIFO, LIFO וערך ה-concurrency | סעיפים 3.3 ו-3.4: איך נבחר thread שממתין, ומספר ה-threads ה-runnable |
| איך לטפל בהודעות shutdown, באחזור מרוכז ובסיום סינכרוני | פרק 4: APIs לפי מטרה |
| לעקוב אחרי await ReadAsync מהוצאה ועד חידוש | סעיף 5.1: הלוך-חזור אחד של await |
| ההיקף של “המתנה על I/O לא צורכת thread” | סעיף 5.2: ה-thread שממתין וה-thread שמעבד |
| ה-UI קופא, או שאי אפשר לגעת ב-UI, גם עם ConfigureAwait(false) | סעיף 5.3: להפריד את יעד ה-continuation מעבודת ה-CPU |
| כל העיבוד האסינכרוני מאט ככל שהעומס עולה | סעיף 5.4: המתנה סינכרונית ו-thread pool starvation |
1. קודם המסקנה
במה IOCP מטפל
- IOCP מאחד “queue של הודעות סיום” עם “בקרת מספר ה-threads”. completion packets נדחפים ל-queue לפי FIFO, ו-worker threads מוציאים אותם עם
GetQueuedCompletionStatus(פרק 3).1 - ה-threads מוערים לפי LIFO. ה-thread ה”חם” שעבד עד לפני רגע הוא זה שלוקח את ה-packet הבא, ולכן כל עוד ה-queue מלא אין כמעט context switches בכלל (סעיף 3.3).1
- ערך ה-concurrency מגביל את מספר ה-threads ה-runnable. נקודת ההתחלה המומלצת היא מספר ה-CPUs (0 פירושו מספר ה-processors). אם thread שרץ נחסם, thread שממתין מוער כדי למלא את החור (סעיף 3.4).12
נקודות מפתח בבחירת API
- אפשר להשתמש בפורט גם להודעות משלכם.
PostQueuedCompletionStatusמאפשר לדחוף packets שאינם קשורים לשום I/O, ולכן בקשות עבודה ל-workers והוראות shutdown יכולות לזרום דרך אותו queue (פרק 4).3 - למימוש שרת חדש עדיף להשתמש ב-Windows thread pool API (
CreateThreadpoolIo) ולא ב-IOCP גולמי. בפנים זה עדיין IOCP, והוא מסיר מכם את ניהול ה-threads (פרק 4).1
מה להבדיל ב-.NET וב-await
- ה-thread pool של .NET הוא מבנה דו-קומתי — worker threads ו-I/O completion threads — ו-handles שמשמשים ל-I/O אסינכרוני נקושרים ל-IOCP של ה-thread pool. לא קיים thread בכל משך ההמתנה של
awaitעל I/O; רק ה-continuation שאחרי הסיום עולה על thread (פרק 5).456 - לאן ה-continuation הולך נקבע על ידי “ההקשר שנלכד”. await על UI thread וההמשך חוזר ל-UI thread; בלי הקשר ללכוד ההמשך ממשיך על ה-thread pool (או על ה-thread שהשלים).
ConfigureAwait(false)היא הוראה להפסיק את הלכידה, ולא הבטחה למעבר ל-thread pool (סעיף 5.3).6
ב-diagram, solid line מציינת relation שתמיד מתקיים ו-dashed line מציינת relation מותנה (התנאים מופיעים בהסבר של כל relation ב-detail page). הרשימה המלאה של ה-relations (סה”כ 32, כולל evidence ו-certainty) וההגדרות של ה-concepts המרכזיים נמצאות ב-detail page של ה-knowledge map (ביפנית). Data: JSON-LD / Turtle
2. איפה התכנון “להכות עם threads” מתפרק
קודם נאמת את הבעיה ש-IOCP בא לפתור. שרת נאיבי אפשר לכתוב כ”thread אחד לכל חיבור”: לקרוא סינכרונית, לעבד, ולכתוב. זה תכנון קל להבנה.
הבעיה מגיעה כשמספר החיבורים גדל. השוו בין תכנון שמוסיף threads בקצב של מספר החיבורים שממתינים לבין תכנון שמעבד רק עבודה שהסתיימה, במספר קטן של threads, באיור 1.
flowchart TB
subgraph A["thread אחד לכל חיבור (I/O סינכרוני)"]
T1["thread 1 ממתין ל-read של חיבור 1"]
T2["thread 2 ממתין ל-read של חיבור 2"]
T3["thread 3 ממתין ל-read של חיבור 3"]
TN["...ה-threads גדלים עם מספר החיבורים<br/>רובם סתם ישנים וממתינים ל-I/O"]
end
subgraph B["מודל IOCP (I/O אסינכרוני)"]
Q["queue של סיומים<br/>(הודעות הסיום של כל החיבורים נאספות כאן)"]
W1["worker 1"]
W2["worker 2"]
WN["ה-workers מעטים, בערך כמספר ה-CPUs"]
Q --> W1
Q --> W2
Q --> WN
end
איור 1: משמאל, ה-threads גדלים ביחס למספר החיבורים. מימין, רק “מה שקרה” מעובד, במספר קטן של threads.
גם thread שרק ממתין עולה במשאבים
כל אחד מהם צורך stack (בברירת מחדל 1MB מוקצה) ואובייקט kernel, וככל שהמספר גדל, העומס על ה-scheduler ועל ה-context switching נערם. אלפי חיבורים שהם אלפי threads יקרים גם כשרובם סתם “ישנים ומחכים למשהו לקרוא”.
הפיכת threads נוספים ל-runnable לא מוסיפה CPUs
פיזית, רק threads כמספר ה-CPUs יכולים לרוץ בו-זמנית. הפיכת threads רבים יותר ל-runnable רק מגדילה את מחיר ההחלפה ביניהם.
בחלק 2 ראינו דרכים לקבל הודעות סיום של I/O דרך “events” ו-“APCs”. אבל גישת ה-event נתקלת במגבלה של 64 אובייקטים ב-WaitForMultipleObjects ומסבכת את תכנון ההמתנות, ו-APCs כרוכים ב-thread שהוציא את הפעולה. IOCP הוא התכנון שנבנה מלכתחילה סביב הצורה הרבה פעולות I/O במספר קטן של threads.1
3. התכנון של IOCP — איחוד ה-queue עם בקרת ה-threads
3.1. שני הפנים של CreateIoCompletionPort
CreateIoCompletionPort עושה שני דברים למרות שמו: יוצר פורט חדש, ומשייך handle לפורט קיים.2
flowchart LR
subgraph SRC["handles שמשויכים לפורט (כל מספר)"]
H1["קובץ"]
H2["socket"]
H3["named pipe"]
end
subgraph PORT["I/O completion port"]
Q["queue של completion packets (FIFO)<br/>packet = בתים שהועברו +<br/>CompletionKey + מצביע ל-OVERLAPPED"]
C["בקרת concurrency<br/>threads runnable עד לגבול"]
end
subgraph W["worker threads"]
G1["ממתין ב-GetQueuedCompletionStatus"]
G2["ממתין ב-GetQueuedCompletionStatus"]
end
H1 --> Q
H2 --> Q
H3 --> Q
Q --> G1
Q --> G2
C -.controls.-> W
איור 2: המבנה של IOCP. סיומים מהרבה handles נאספים ל-queue אחד, וגם מספר ה-threads שמוציאים מהם נמצא תחת בקרה.
ה-CompletionKey שמעבירים בזמן השיוך הוא ערך חופשי שמספר ל-worker “הסיום הזה הגיע מה-handle הזה”. הנהוג הוא לשים בו מצביע לאובייקט החיבור.2
ב-completion packet שלושה פריטי מידע משתלבים כדי לזהות את הפעולה.27
| המידע שמתקבל | מה הוא מזהה או מאשר |
|---|---|
| CompletionKey | מאיזה handle או חיבור הגיע הסיום |
| מצביע ל-OVERLAPPED | איזו פעולה בחיבור הזה הסתיימה |
| מספר הבתים שהועברו | כמה נתונים הועברו |
כך נאספת חזרה, ברגע הסיום, “תעודת הפעולה” מחלק 2.
היעד אינו מוגבל ל”קבצים”. sockets, named pipes, mailslots וכן הלאה — כל handle שיודע לדבר overlapped I/O יכול להיות משויך.1 התכנון של “הכול נראה כמו קובץ” שראינו בחלק 1 פועל גם כאן.
3.2. המסע של completion packet
sequenceDiagram
participant DRV as kernel (סיום IRP)
participant Q as ה-queue של הפורט (FIFO)
participant W as worker thread
Note over W: ממתין ב-GetQueuedCompletionStatus
DRV->>Q: דוחף completion packet<br/>(מספר בתים / CompletionKey / OVERLAPPED)
Q->>W: מעיר thread אחד שממתין ומוסר לו אותו
Note over W: מסתכל על ה-packet ומריץ טיפול סיום<br/>(הרצת ה-continuation, הוצאת ה-I/O הבא וכן הלאה)
W->>Q: מסיים לעבד וקורא שוב ל-GetQueuedCompletionStatus
Note over Q: אם נשארו packets ב-queue<br/>מקבל את הבא בלי להמתין
איור 3: completion packets נדחפים לפי FIFO, וה-worker חוזר בלולאה שבין הוצאת packet לבין עיבודו.
כשה-I/O האסינכרוני מסתיים, נדחף completion packet ל-queue של הפורט לפי סדר FIFO. ה-worker חוזר על הלולאה הבאה.17
- מקבל packet אחד עם
GetQueuedCompletionStatus. - מריץ את טיפול הסיום של הפעולה שהתקבלה.
- כשמסיים לעבד, קורא שוב ל-
GetQueuedCompletionStatus.
קיים גם GetQueuedCompletionStatusEx, שמוציא כמה packets בבת אחת ומפחית את מספר הקריאות ב-I/O בתדירות גבוהה.8
לא לצאת מהלולאה רק בגלל FALSE
גם כשהערך המוחזר מ-GetQueuedCompletionStatus הוא FALSE, ייתכן שאכן הוצאתם את completion packet של I/O שנכשל. החליטו לפי השילוב של ערך החזרה עם מצביע ה-OVERLAPPED.7
| ערך החזרה | OVERLAPPED | משמעות וטיפול |
|---|---|---|
FALSE |
שאינו NULL | הוצאתם את הסיום של I/O שנכשל. נדרשים טיפול בשגיאה וגם ניקוי של תעודת הפעולה וה-buffer |
FALSE |
NULL | לא הצלחתם להוציא packet. timeout, פורט שנסגר וכן הלאה |
גם פעולה שנכשלה צריכה ניקוי. אם מטפלים בזה רק עם if (!GetQueuedCompletionStatus(...)) break;, פעולות I/O שנכשלו נופלות בין הכיסאות ודולפות. ניהול חיי התעודה וה-buffer שראינו בחלק 2 אינו רק עניין של סיומים מוצלחים.
לראות את סדר הבדיקות בלולאת worker
שלד הקוד הבא מבצע קודם את שתי הבדיקות מהטבלה שלמעלה, ואחר כך מטפל ב-packet של shutdown ובסיומים רגילים.
/* שלד של לולאת worker של IOCP (C / Win32) */
for (;;) {
DWORD bytes = 0;
ULONG_PTR key = 0;
OVERLAPPED *ov = NULL;
BOOL ok = GetQueuedCompletionStatus(port, &bytes, &key, &ov, INFINITE);
if (!ok && ov == NULL) {
/* לא הוצא packet (הפורט נסגר, למשל). זה התנאי היחיד שמותר לצאת בגללו */
break;
}
if (!ok) {
/* ov != NULL → הוצא "completion packet של I/O שנכשל".
עדיין נדרש ניקוי (טיפול בשגיאה, שחרור התעודה וה-buffer), ולכן מטפלים ולא יוצאים */
DWORD err = GetLastError();
handle_failed_io(key, ov, err);
continue;
}
if (key == SHUTDOWN_KEY) {
/* packet shutdown שנדחף עם PostQueuedCompletionStatus (פרק 4) */
break;
}
handle_completed_io(key, ov, bytes); /* טיפול סיום רגיל. לשמור עליו קצר (סעיף 3.4) */
}
העיקר הוא לא לסגור את ok == FALSE בענף אחד, אלא לפצל אותו לשניים לפי האם ov הוא NULL. הבדיקות זהות גם כשעובדים עם timeout (משהו שאינו INFINITE): אזל הזמן מופיע כ-ok == FALSE עם ov == NULL.
שימו לב שכש-thread קורא ל-GetQueuedCompletionStatus בפעם הראשונה, אותו thread הופך להיות משויך לפורט הזה (thread אחד יכול להיות משויך לפורט אחד בלבד בכל רגע).1 התמונה של “צוות workers ייעודי שמצורף לפורט” היא תמונה מדויקת לשמור בראש.
3.3. threads מוערים לפי LIFO
כאן הסתכלו בנפרד על הסדר שבו packets נכנסים ל-queue ועל הסדר שבו מעירים threads שממתינים.1
| נושא | סדר | למה לשים לב |
|---|---|---|
| completion packets | FIFO | הסדר שבו הודעות הסיום נדחפות ל-queue |
| threads שממתינים | LIFO | ה-thread שנכנס להמתנה אחרון הוא שמוער ראשון |
כיוון שהסדר שונה בין packets ל-threads, ה-thread שעבד עד לפני רגע הוא גם זה שלוקח את ה-packet הבא.
flowchart TB
Q["ה-queue מחזיק את P1, P2, P3 לפי FIFO"]
subgraph TH["threads שממתינים (stack LIFO)"]
A["thread A (רץ עד לפני רגע, חם)"]
B["thread B (ישן כבר זמן מה)"]
C["thread C (ישן כל הזמן)"]
end
Q -->|"P1, P2 ו-P3 כולם הולכים קודם<br/>ל-thread A אם הוא פנוי"| A
B -.->|"רק כש-A עסוק"| Q
C -.->|"מתעורר לעיתים נדירות"| Q
איור 4: שחרור LIFO. ככל שהעומס גדול יותר, אותו thread ממשיך להסתובב, בעוד threads סרק יכולים להישאר ישנים.
לתכנון הזה שני יתרונות.
- לא מתרחשים context switches. כל עוד נשארו packets ב-queue, thread שמסיים לעבד וקורא ל-
GetQueuedCompletionStatusמקבל מיד את ה-packet הבא, בלי להמתין, וממשיך לרוץ. התיעוד קובע במפורש שבתרחיש של ערך concurrency 1, “לא מתרחש שינוי threads”.1 - ה-cache נשאר חם. כיוון שאותו thread ממשיך להסתובב, הסיכוי שה-stack שלו ומידע ה-scheduling שלו עוד יושבים ב-cache של ה-CPU גבוה. threads ישנים נשמרים בזול כעתודה מול שיאי עומס.
3.4. ערך ה-concurrency — לספור מה ש”runnable”
ה-NumberOfConcurrentThreads שמועבר בזמן יצירת הפורט הוא ערך ה-concurrency. מה שהוא סופר הוא ה-threads ה-runnable המשויכים לפורט הזה. כל עוד הגבול מושג, thread נוסף לא יכול לקבל packet.1
העברת 0 משתמשת במספר ה-processors במערכת. גם התיעוד מציין את מספר ה-CPUs כערך המקסימלי הטוב ביותר באופן כללי, ולכן זו נקודת ההתחלה.21
זה לא גבול על הסך הכולל, כולל threads שממתינים. ראו באיור 5 מה קורה כשמישהו נכנס למצב המתנה.
flowchart TB
P["packet מגיע ל-queue"]
Q{"האם מספר ה-threads ה-runnable<br/>נמוך מערך ה-concurrency"}
RUN["מעיר thread שממתין ומאפשר לו לעבד"]
HOLD["לא מעיר איש ומשאיר אותו ב-queue<br/>(thread שרץ יבוא לקחת אותו)"]
BLK["thread שרץ נכנס<br/>למצב המתנה מסיבה אחרת"]
COMP["מעיר מספיק threads שממתינים כדי<br/>לכסות את הירידה במספר ה-runnable"]
P --> Q
Q -->|נמוך| RUN
Q -->|בגבול| HOLD
BLK --> COMP
איור 5: בקרת concurrency. כיוון שהגבול הוא על “מספר ה-runnable”, המערכת משלימה אוטומטית אם מישהו נחסם.
worker שנכנס להמתנה מכוסה על ידי thread אחר שממתין
כש-worker שרץ נכנס לאיזושהי המתנה (lock, page fault, קריאת I/O סינכרונית שנעשתה בטעות), מספר ה-threads ה-runnable יורד, ולכן המערכת מעירה thread שממתין וממלאת את החור.1 זו הסיבה שהנהוג אינו ליצור בדיוק “כמספר ה-CPUs” workers, אלא להשאיר יותר threads ממתינים מערך ה-concurrency. אם העיבוד שלכם כולל חישוב ארוך, העלאת ערך ה-concurrency עצמו היא גם אפשרות, ועמדת התיעוד היא שבסופו של דבר מכווננים אותו דרך profiling.1
כיוון שאפשר לחרוג מהגבול לזמן קצר, שומרים על טיפול הסיום קצר
עם זאת, ההשלמה אינה קסם. אם thread חסום מתעורר אחר כך, מספר ה-threads ה-runnable עובר את הגבול באותו רגע (התיעוד עצמו מזכיר את החריגה הזאת).1 לשמור על טיפול הסיום קצר הוא כלל הברזל, והוא פועל באותה צורה בדיוק ב-.NET בסעיף 5.4.
4. ארגז הכלים — ה-APIs שתומכים בפורט
4.1. לשלוח בקשות עבודה והוראות shutdown דרך אותו queue
PostQueuedCompletionStatus — מאפשר לדחוף packet סיום משלכם ל-queue בלי להוציא שום I/O.3 חלוקת עבודה ל-workers, הוראות shutdown (דחיפת packet סיום אחד לכל worker — מה שמכונה בשם “poison pill”), הודעות מ-threads אחרים: היכולת לעבד סיומי I/O והודעות משלכם באותו queue ובאותה לולאה מפשטת את התכנון באופן משמעותי.
4.2. לקבל סיומי I/O בתדירות גבוהה בקבוצות
GetQueuedCompletionStatusEx — מוציא כמה completion packets בבת אחת. זה יעיל ב-I/O בתדירות גבוהה, שבו ה-overhead של קריאה אחת לכל packet מתחיל להציק.8
4.3. לדלג על ההודעה בסיום סינכרוני
SetFileCompletionNotificationModes — עבור המקרה מסעיף 5 של חלק 2, “הוצא אסינכרונית אבל הסתיים סינכרונית”, מאפשר לבחור מוד שבו לא נדחף packet לפורט (FILE_SKIP_COMPLETION_PORT_ON_SUCCESS). כיוון שתוצאת הסיום הסינכרוני ידועה כבר במקום, מעבר חוזר דרך ה-queue הוא בזבוז מוחלט — ומכאן האופטימיזציה.9
4.4. להאציל את יצירת ה-threads ואת הניהול שלהם
Windows thread pool API — CreateThreadpoolIo ו-StartThreadpoolIo משתמשים ב-IOCP בפנים, ובמקביל מסירים מכם את יצירת ה-threads ואת ניהולם. Microsoft ממליצה שאפליקציות שרת חדשות ישקלו אותן קודם, ולהשתמש ב-IOCP גולמי רק כשרוצים שליטה מפורשת בערך ה-concurrency או בניהול ה-threads.1 וה-thread pool של .NET הוא בדיוק ה-IOCP הזה בתוספת אוטומציה של ניהול ה-threads, כחלק מה-runtime של .NET.
4.5. שלושה כללי lifetime שלא משתנים בבחירת ה-API
- לא לחסום לזמן ארוך בתוך worker. ההשלמה בסעיף 3.4 רק מרככת את ההידרדרות.
- לזהות בנפרד לפי handle ולפי פעולה. CompletionKey הוא לפי handle, ו-
OVERLAPPEDהוא לפי פעולה. אסור לשחרר את “התעודה” מחלק 2 לפני הסיום. - לא לסגור handle כשנשארו פעולות I/O שלא הסתיימו. התנהגות ה-cleanup (סעיף 6 של חלק 1) ונוהג ה-cancellation (סעיף 6 של חלק 2) חלים כאן כמו שהם.
5. ה-thread pool של .NET — מבנה דו-קומתי מעל IOCP
מכאן ואילך נמפה את הודעות הסיום של Win32 לקוד .NET. הסדר שבו נסתכל עליהן הוא ה-thread שמקבל את הסיום, אחר כך הלוך-חזור אחד של await, אחר כך זמן ההמתנה, ואחר כך היכן רץ ה-continuation.
ה-thread pool של .NET מספק threads בשני תפקידים.4
| סוג | התפקיד שנבחן במאמר הזה |
|---|---|
| worker thread | מריץ Task.Run ו-continuations |
| I/O completion thread | מקבל את הסיום של I/O אסינכרוני |
גם ThreadPool.GetAvailableThreads(out workerThreads, out completionPortThreads) מחזיר את השניים האלה כשני מספרים נפרדים. בהמשך נעקוב אחרי “הצד שמקבל סיומים” ו”הצד שמריץ את העבודה” כדברים נפרדים.4
ב-Windows, ה-thread pool מחזיק I/O completion port משלו. ה-API ברמה הנמוכה לשיוך handle של מערכת ההפעלה לפורט הזה הוא ThreadPoolBoundHandle.BindHandle. I/O אסינכרוני על handle שקשור מטופל יחד עם NativeOverlapped, המקבילה בצד .NET ל-OVERLAPPED מחלק 2.5
ה-ThreadPool.BindHandle הוותיק נשאר לאותו תפקיד, אבל בקוד חדש משתמשים ב-ThreadPoolBoundHandle.BindHandle. כש-FileStream או Socket פותחים handle במוד אסינכרוני, קישור כזה נעשה בפנים.5
אם מפצלים להוצאה ולסיום, ההתאמה ל-Win32 נראית כך.
- ה”handle במוד אסינכרוני יחד עם OVERLAPPED” מחלק 2 הוא המנגנון להוצאה
- ה-IOCP במאמר הזה הוא המנגנון לקבלת הסיומים
- ה-I/O completion threads של ה-thread pool של .NET הם צוות ה-workers שמריץ את הלולאה של
GetQueuedCompletionStatus
עם ההתאמה הזאת, התמונה של Win32 הופכת לתמונה של .NET כמו שהיא.
5.1. הלוך-חזור אחד של await ReadAsync, בשלמותו
איור 7 של חלק 2 השאיר תיבה שכתוב עליה “I/O אסינכרוני אמיתי”; הפעם נלך אחרי תוכנה עד ל-continuation שאחרי הסיום. באיור 6 הסתכלו בנפרד על ה-thread שמוציא, על ה-thread שמקבל את הודעת הסיום, ועל המקום שבו רץ הקוד שאחרי ה-await.
sequenceDiagram
participant U as thread קורא<br/>(למשל UI thread)
participant K as kernel<br/>(מהוצאת IRP עד הסיום)
participant Q as ה-IOCP של ה-thread pool
participant IO as I/O completion thread
participant C as המקום שבו רץ ה-continuation
U->>K: ReadAsync מוציא קריאה אסינכרונית<br/>(עם המקבילה של OVERLAPPED)
K-->>U: ERROR_IO_PENDING (חוזר מיד)
Note over U: await רושם continuation על ה-Task שלא הושלם<br/>ומוותר על ה-thread (ב-UI, חזרה לטיפול בהודעה הבאה)
Note over K: ההתקן עובד<br/>בזמן הזה אין thread שממתין בשום מקום
K->>Q: דוחף completion packet
Q->>IO: מעיר thread אחד לפי LIFO ומוסר לו אותו
Note over IO: קובע את התוצאה (מספר בתים, סטטוס)<br/>משלים את ה-Task ומתזמן את ה-continuation
IO->>C: זורק אותו להקשר שנלכד<br/>(ל-UI thread, או לרוץ על ה-thread pool אם אין כזה)
Note over C: הקוד שאחרי ה-await רץ
איור 6: כל הלוך-חזור אחד של await. threads עובדים רק ב”הוצאה” וב”אחרי הסיום” — זמן ההמתנה משתמש באפס threads.
5.2. המשמעות המדויקת של “המתנה על I/O לא צורכת thread”
לא מציבים שם thread ייעודי רק בשביל ההמתנה
מה שהתרשים הזה אמור להנחיל הוא שבין ההוצאה לסיום, לא קיים thread — לא ב-user mode ולא ב-kernel — שכל תפקידו להמתין לסיום הזה. גם ההסבר של Microsoft על async (Async in depth) אומר את אותו דבר על Tasks שהם I/O-bound, ויורד עד ל-device drivers ול-interrupts.6
מנגד, יש ב-kernel רגעים שבהם driver מעביר חלק מהעבודה ל-system worker thread. זו עבודה שנועדה לקדם את הבקשה, וזה דבר אחר מ-thread שחוסם את עצמו וממשיך להמתין לסיום. “לא צורך thread” הוא אמירה על זמן ההמתנה.
אם ננסח זאת מחדש על גבי מה שנבנה מהחלק הראשון: IRP נשאר ב-device stack כמבנה נתונים ולא כ-thread (חלק 1), ההוצאה חוזרת מיד עם ERROR_IO_PENDING (חלק 2), והסיום מגיע כשרשרת אירועים — interrupt, ואחריו completion packet (המאמר הזה). התכנון מצליח לקיים את מצב ה”המתנה” בלי המשאב היקר ש-thread מהווה.
להבדיל את זה מעבודת CPU ומאסינכרוני למראית עין
זו הסיבה שאפליקציה שמשתמשת נכון ב-async/await יכולה לקיים “10,000 פעולות I/O בדרך בו-זמנית” עם תריסר threads. אבל כשמהפכים את זה, התכונה הזאת שייכת רק ל-Tasks שהם I/O-bound. עבודת CPU שעטופה ב-Task.Run תופסת מטבע הדברים worker thread אחד, וגם ה”אסינכרוני למראית עין” מסעיף 7 של חלק 2 מרדים thread מאחורי הקלעים.
5.3. היכן רץ ה-continuation
החץ האחרון באיור 6 הוא “אחרי שהסיום התקבל, היכן רץ ה-continuation”. חשבו על האם לחזור להקשר ועל לאן לשלוח עבודה שמשתמשת ב-CPU כשתי שאלות נפרדות.6
flowchart TB
A["ה-Task הושלם ורוצים להריץ את ה-continuation"]
Q1{"בנקודת ה-await, האם נלכד<br/>SynchronizationContext או<br/>TaskScheduler שאינו ברירת מחדל"}
Q2{"האם צורף<br/>ConfigureAwait(false)"}
UI["זורקים חזרה למה שנלכד<br/>למשל ריצה בלולאת ההודעות של ה-UI thread<br/>או על ה-TaskScheduler הזה"]
TP["אין חובה לחזור למקום מסוים<br/>ממשיך סינכרונית על ה-thread שהשלים<br/>או רץ על thread מתוך ה-thread pool"]
A --> Q2
Q2 -->|"כן"| TP
Q2 -->|"לא"| Q1
Q1 -->|"כן (UI thread של WPF/WinForms, למשל)"| UI
Q1 -->|"לא (אפליקציית console, ASP.NET Core וכן הלאה)"| TP
איור 7: לאן מגיע ה-continuation. הסיבה שאפשר לגעת ב-UI ישירות אחרי await היא שזורקים חזרה להקשר שנלכד.
ההקשר שנלכד, והמקרה שממשיך סינכרונית
- כשעושים
awaitעל UI thread של WPF או WinForms, ה-SynchronizationContextנלכד וההמשך חוזר ל-UI thread. זו הסיבה שנגיעה ב-control מיד אחריawaitאינה מפירה את כלל ה-thread. הצד המעשי של התכנון הזה נידון ב”async וה-UI thread ב-WPF/WinForms על דף אחד”. - לא רק
SynchronizationContextנלכד: אם עושיםawaitכשהקוד רץ עלTaskSchedulerשאינו ברירת מחדל, גם ה-scheduler הזה נלכד. במקום שאין אף אחד מהם (אפליקציית console, ASP.NET Core, קוד שכבר רץ על ה-thread pool), אין חובה לחזור למקום מסוים, ולכן ה-continuation רץ על ה-thread pool או ממשיך ישירות, סינכרונית, על ה-thread שהשלים את ה-Task. ConfigureAwait(false)היא הצהרה מפורשת של “אין צורך לחזור”, ולא הבטחה של “תמיד עוברים ל-thread pool”. אם עושים await על Task שכבר הושלם (וזה כולל את הסיומים הסינכרוניים שראינו בחלק 2), לא נוצרת המתנה והביצוע ממשיך על ה-thread הנוכחי כמו שהוא. לבחירה בין השניים בקוד ספרייה ראו “טבלת החלטות מעשית ל-async/await ב-C# - Task.Run ו-ConfigureAwait”.
דוגמה גרועה: להאמין ש-ConfigureAwait(false) העביר אתכם
נאמת את הנקודה האחרונה בקוד. הדוגמה הבאה נכתבה מתוך הנחה ש-ConfigureAwait(false) היא הוראה לעבור ל-thread pool.
// דוגמה גרועה: התפיסה השגויה ש"כיוון שצורף ConfigureAwait(false), כל מה שאחרי זה רץ על ה-thread pool"
private async void OnLoadClick(object sender, EventArgs e)
{
string csv = await File.ReadAllTextAsync(path).ConfigureAwait(false);
// ציפייה: כאן זה ה-thread pool, ולכן ה-UI לא יקפא
// בפועל: אם ה-Task כבר הושלם בנקודת ה-await, לא נוצרת המתנה
// והביצוע ממשיך על ה-UI thread → העבודה הכבדה הזאת מקפיאה את ה-UI
var rows = ParseHeavy(csv);
// וכשהוא כן מסתיים אסינכרונית, ההמשך אינו ה-UI thread
resultLabel.Text = $"{rows.Count} rows"; // → עשוי לזרוק חריגת thread
}
כל מה ש-ConfigureAwait(false) אומרת הוא שאין צורך לחזור להקשר שנלכד. היא לא מגדירה “איפה זה רץ”, ולכן הביצוע עשוי להמשיך על ה-UI thread, או להמשיך על I/O completion thread או על thread מתוך ה-thread pool. העובדה ששני התרחישים אפשריים היא הסיבה שהקוד הזה שבור.
דוגמה טובה: לקבוע בנפרד את החזרה ל-UI ואת היעד לעבודה כבדה
מפרידים בין קוד ה-UI לקוד הספרייה וכותבים כל כוונה במפורש. ל-await שמחזיר אתכם ל-UI ולמחולל הריצה Task.Run שדוחף עבודת CPU ל-thread pool יש תפקידים שונים.
// דוגמה טובה: לקבוע בנפרד "לחזור ל-UI או לא" ו"היכן רצה העבודה הכבדה"
private async void OnLoadClick(object sender, EventArgs e)
{
// בקוד UI נותנים ללכידה לקרות (ההמשך חוזר ל-UI thread)
string csv = await File.ReadAllTextAsync(path);
// אם רוצים לדחוף עבודה תלוית-CPU ל-thread pool, אומרים זאת במפורש עם Task.Run
var rows = await Task.Run(() => ParseHeavy(csv));
// כאן בהחלט ה-UI thread. בטוח לגעת ב-controls
resultLabel.Text = $"{rows.Count} rows";
}
// בצד הספרייה (קוד בלי UI) מצהירים ההפך: "אין צורך לחזור"
public async Task<string> ReadConfigAsync(string path)
{
string text = await File.ReadAllTextAsync(path).ConfigureAwait(false);
return text.Trim(); // אינו תלוי בהקשר של הקורא
}
ההחלטה מתפצלת לשלוש.
| מטרה | הבחירה בקוד הזה |
|---|---|
| לגעת ב-UI אחרי await | בקוד UI משאירים את ההקשר נלכד |
| לדחוף עבודת CPU כבדה ל-thread pool | אומרים זאת במפורש עם Task.Run |
| אין צורך לחזור להקשר של הקורא בתוך ספרייה | מפסיקים את הלכידה עם ConfigureAwait(false) |
ConfigureAwait(false) אינה קובעת על איזה thread משהו רץ. התייחסו אליה כאל כלי בצד הספרייה לעבוד בלי תלות בהקשר של הקורא, והפרידו אותה מהיכן שממוקמות עבודת ה-UI ועבודת ה-CPU.
5.4. מה באמת סותם את הצינור — thread pool starvation
המתנה סינכרונית חוסמת את ה-thread שיריץ את ה-continuation
אם ממתינים סינכרונית בתוך continuation או worker, ה-thread הזה נשאר תפוס. I/O סינכרוני, Task.Result/Wait() והמתנות lock ארוכות — כולם אותו דפוס.
ההשלמה של IOCP עצמו (סעיף 3.4) פועלת מיד כל עוד נשארו threads רזרביים שממתינים. אבל מעבר לנקודה שבה נגמרות העתודות מתחיל שטח שבו ה-thread pool מזריק threads חדשים רק לאט.
כשפורצת starvation ברגע שהעומס מגיע — “יש continuation להריץ אבל אין thread שמריץ אותו” — כל האפליקציה נגררת.
לבדוק את המספרים הפנויים יחד עם מקום החסימה בפועל
יש שתי נקודות כניסה לחקירה.
- להסתכל על הזמינות של worker threads ו-I/O completion threads עם
ThreadPool.GetAvailableThreads.4 - לתפוס את המצב האמיתי של ה-thread pool ושל החסימה עם event tracing.
הנוהל של ה-tracing מתואר באיתור “איטי” עם PerfView ו-dotnet-trace.
עקרון המניעה הוא להעביר את מסלול ה-async כמו שהוא עד הסוף ולא לערבב sync-over-async. גם אם אפשר לוותר על thread בזמן המתנה ל-I/O, המתנה סינכרונית בתוך ה-continuation חוסמת thread מחדש.
6. סיכום
- IOCP הוא מנגנון שמאחד את ה-queue של הסיומים (FIFO) עם בקרת מספר ה-threads. סיומים מהרבה handles נאספים לפורט אחד ומעובדים בלולאה של
GetQueuedCompletionStatus.17 - כיוון שthreads משוחררים לפי LIFO, ככל שהעומס גדול יותר אותו thread ממשיך להסתובב, וכך ממוזערים גם context switches וגם cache misses.1
- ערך ה-concurrency מגביל את מספר ה-threads ה-runnable, ונקודת ההתחלה היא מספר ה-CPUs (העברת 0). אם thread שרץ נחסם, threads שממתינים משלימים אותו, אבל לשמור על טיפול הסיום קצר נשאר כלל הברזל.12
PostQueuedCompletionStatusמאפשר שגם packets משלכם יזרמו באותו queue. במימוש חדש הבחירה הראשונה היא Windows thread pool API (IOCP בפנים).31- ה-thread pool של .NET הוא מבנה דו-קומתי, worker threads בתוספת I/O completion threads, ו-handles אסינכרוניים נקושרים ל-IOCP של ה-pool. לא קיים thread בהמתנה של
awaitעל I/O; רק ה-continuation שאחרי הסיום עולה על thread.456 - לאן ה-continuation הולך תלוי בהקשר שנלכד (חזרה ל-UI thread, או המשך על ה-thread pool).
ConfigureAwait(false)היא הוראה להפסיק את הלכידה הזאת.6 - מה שבאמת סותם את הצינור הוא כמעט תמיד thread pool starvation שנגרם מחדירה של המתנות סינכרוניות. העבירו את מסלול ה-async כמו שהוא עד הסוף.
הבא בתור הוא חלק 4, “מנהל ה-cache — מתי ה-WriteFile שלכם באמת מגיע לדיסק?”. חלק 2 כתב ש”אם זה כבר ב-cache, הסיום הוא סינכרוני”, והצל של ה-cache הבהב לנגד עינינו גם כמה פעמים במאמר הזה. בפעם הבאה נתמודד ישירות עם ה-cache עצמו: כתיבות נדחות, read-ahead, FILE_FLAG_NO_BUFFERING, והתנאים שבהם “נתונים שחשבתם שכתבתם נעלמים בהפסקת חשמל”.
מאמרים קשורים
- מעמקי ה-I/O של Windows (חלק 1) — כל קריאה וכתיבה הופכת ל-IRP: התמונה הכוללת של מערכת ה-I/O
- מעמקי ה-I/O של Windows (חלק 2) — I/O סינכרוני ואסינכרוני: מה OVERLAPPED באמת אומר
- טבלת החלטות מעשית ל-async/await ב-C# - Task.Run ו-ConfigureAwait
- async וה-UI thread ב-WPF/WinForms על דף אחד
- התפיסה השגויה שאפשר לקבל ב-TCP כל יחידה שנשלחה — תכנון קליטה שעובד על byte stream
- איתור “איטי” עם PerfView ו-dotnet-trace — מבוא מעשי לחקירת ביצועים ב-.NET
- מדריך מעשי להתקרב ככל האפשר ל-soft real-time ב-Windows רגיל
תחומי ייעוץ קשורים
KomuraSoft LLC עוסקת בתכנון אפליקציות ושרתים ל-Windows שמתמודדים עם מספר גדול של חיבורים בו-זמנית ועם I/O במקביל, ובחקירת הסיבות לבעיות ביצועים כמו “ה-thread pool נסתם” או “עשינו את זה אסינכרוני וזה לא הפך למהיר”.
- פיתוח אפליקציות Windows
- חקירת תקלות וניתוח שורש הבעיה
- פיתוח אפליקציות Windows ל-soft real-time
- יצירת קשר
מקורות
-
Microsoft Learn, I/O Completion Ports. על כך ש-I/O completion ports מספקים מודל threading יעיל לעיבוד מספר גדול של בקשות I/O אסינכרוניות במערכות מרובות מעבדים; על כך שכאשר I/O אסינכרוני מסתיים נדחפים completion packets ל-queue של הפורט לפי סדר FIFO; על כך שהיעד אינו מוגבל לקבצים בדיסק אלא כולל כל handle שתומך ב-overlapped I/O, כמו sockets, named pipes ו-mailslots; על כך ש-threads שממתינים על הפורט משוחררים לפי סדר LIFO, ושכאשר ה-queue אינו ריק בערך concurrency 1 לא מתרחש שינוי threads; על כך ש-thread הופך להיות משויך לפורט בפעם הראשונה שהוא קורא ל-GetQueuedCompletionStatus, ויכול להיות משויך לפורט אחד בלבד בכל רגע; על כך שערך ה-concurrency מגביל את מספר ה-threads ה-runnable, ושהערך המקסימלי הטוב ביותר באופן כללי הוא מספר ה-CPUs; על כך ש-thread שממתין יכול לעבד completion packet כאשר thread שרץ נכנס למצב המתנה מסיבה אחרת (ועל כך ש-thread חסום שמתעורר יכול לחרוג זמנית מהגבול); ועל כך שאפליקציות שרת חדשות צריכות לשקול קודם את Windows thread pool API (CreateThreadpoolIo ואחרים, שמשתמשים ב-IOCP בפנים). ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 ↩12 ↩13 ↩14 ↩15 ↩16 ↩17 ↩18 ↩19 ↩20
-
Microsoft Learn, CreateIoCompletionPort function. על כך ש-CreateIoCompletionPort גם יוצר I/O completion port חדש וגם משייך handle לפורט קיים; על כך שאפשר לציין בזמן השיוך CompletionKey (ערך שמוגדר על ידי המשתמש) והוא נכלל ב-completion packet; ועל כך ש-NumberOfConcurrentThreads מגביל את מספר ה-threads שיכולים לעבד completion packets במקביל, וכשמציינים 0 משתמשים במספר ה-processors במערכת. ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
Microsoft Learn, PostQueuedCompletionStatus function. על כך ש-PostQueuedCompletionStatus יכול לדחוף completion packet שמוגדר על ידי האפליקציה ל-queue של I/O completion port בלי להתחיל שום I/O אסינכרוני, ועל כך שזה מאפשר לפורט לשמש ערוץ תקשורת מ-threads אחרים בתהליך, בנוסף לקבלת סיומי I/O. ↩ ↩2 ↩3
-
Microsoft Learn, The managed thread pool. על כך שה-thread pool של .NET מספק worker threads ו-threads לסיום I/O אסינכרוני; על כך ש-ThreadPool.GetAvailableThreads יכול להוציא בנפרד את המספרים הפנויים של worker threads ושל I/O completion threads; ועל כך שאסור לחסום threads של ה-thread pool לפרקי זמן ארוכים. ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, ThreadPoolBoundHandle.BindHandle method. על כך ש-ThreadPoolBoundHandle.BindHandle מחזיר ThreadPoolBoundHandle שקושר handle של מערכת ההפעלה ל-system thread pool ול-I/O completion port שלו; על כך ש-I/O אסינכרוני ברמה נמוכה על handle שקשור מבוצע יחד עם NativeOverlapped; ועל כך שסיומו של I/O אסינכרוני מטופל לאחר מכן על ידי ה-thread pool. ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, Async in depth (.NET). על כך של-Task שמוגבל ב-I/O אין thread ייעודי שממתין לסיומו לאחר שהקריאה נמסרה למערכת ההפעלה (הרעיון של “There is no thread”); על כך שהסיום מאותת דרך device drivers ו-interrupts, וה-continuation שנרשם מורץ; על כך ש-await לוכד כברירת מחדל את ההקשר הנוכחי (כמו SynchronizationContext) ומריץ שם את ה-continuation, ואם אין הקשר שראוי ללכוד הוא רץ על ה-thread pool; ועל כך ש-ConfigureAwait(false) מבטל את הלכידה הזאת. ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
Microsoft Learn, GetQueuedCompletionStatus function. על כך ש-GetQueuedCompletionStatus מוציא completion packet אחד מ-queue של הפורט (ואם אין, ממתין); על כך שהתוצאה שהוצאה כוללת את מספר הבתים שהועברו, את ה-CompletionKey ואת מצביע ה-OVERLAPPED; ועל כך שערך החזרה FALSE יחד עם מצביע OVERLAPPED שאינו NULL פירושו “הוצא completion packet של פעולת I/O שנכשלה”, בעוד שמצביע OVERLAPPED שהוא NULL לבדו פירושו שה-packet עצמו לא הוצא (למשל בגלל timeout). ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, GetQueuedCompletionStatusEx function. על כך ש-GetQueuedCompletionStatusEx יכול להוציא כמה completion packets בבת אחת, ועל כך שמספר הרשומות שהוצאו מוחזר. ↩ ↩2
-
Microsoft Learn, SetFileCompletionNotificationModes function. על כך ש-FILE_SKIP_COMPLETION_PORT_ON_SUCCESS מאפשר לבחור לא לדחוף packet ל-completion port כאשר I/O מצליח מיד והתוצאה כבר ידועה במקום. ↩
מאמרים קשורים
מאמרים עדכניים עם אותן תגיות, להעמקה בנושאים קרובים.
מעמקי ה-I/O של Windows (חלק 2) — I/O סינכרוני ואסינכרוני: מה OVERLAPPED באמת אומר
חלק 2 בסדרה מסביר בדיאגרמות I/O סינכרוני ואסינכרוני (Overlapped I/O) ב-Windows: המשמעות של FILE_FLAG_OVERLAPPED, ארבע דרכי ההודעה על הסיו...
מעמקי ה-I/O ב-Windows (פרק 4) — Cache Manager: מתי WriteFile באמת מגיע לדיסק
פרק 4 בסדרה שמסבירה בתרשימים את Cache Manager של Windows. המאמר עובר על מטמון שממומש כמיפוי קובץ, read-ahead ו-lazy writer, הבחירה בין Fl...
מעמקי ה-I/O של Windows (חלק 1) — כל קריאה וכתיבה הופכת ל-IRP: התמונה הכוללת של מערכת ה-I/O
חלק 1 בסדרה על ה-I/O של Windows מהיסוד: מרחב השמות של ה-Object Manager, ה-driver ואובייקטי ה-device וה-file, מחזור החיים של IRP, ומה Clos...
למה arguments נשברים — כללי command-line arguments ב-Windows
Windows מעביר ל-CreateProcess מחרוזת אחת שהמקבל מפצל. מכסה את כללי CommandLineToArgvW, CRT ו-.NET, ArgumentList, ובניה ב-C++.
שיטות עבודה מומלצות ל-multithreading בפועל: מהדורת .NET — מה להחליט לפני שמוסיפים threads
כללי תכנון שמונעים מקוד multithreaded ב-.NET/C# לקרוס או להיתקע מדי פעם: להשתמש ב-Task במקום ליצור threads בעצמכם, לצמצם shared mutable s...
נושאים קשורים
העמודים האלה ממקמים את הנושא בהקשר רחב יותר של שירותים והחלטות.
נושאים טכניים ב-Windows
שער לנושאי פיתוח Windows, חקירת תקלות וניצול נכסים קיימים.
שירותים הקשורים לנושא הזה
המאמר קשור ישירות לשירותים הבאים.
פיתוח יישומי Windows
יישומים עסקיים, חיבור התקנים וכלי תקשורת, מהגדרת הדרישות ועד הפיתוח.
שאלות נפוצות
שאלות נפוצות בפניות בנושא המאמר.
- מהו I/O completion port (IOCP)?
- זהו מנגנון של ה-kernel של Windows שמרכז את הודעות הסיום של פעולות I/O אסינכרוניות רבות ל-queue אחד, ובמקביל גם שולט בכמה threads יכולים לרוץ במקביל כדי לעבד אותן. יוצרים פורט עם CreateIoCompletionPort ומשייכים אליו handles כמו קבצים או sockets, וכל פעם ש-I/O אסינכרוני מסתיים נדחף completion packet ל-FIFO queue של הפורט. worker threads מוציאים packets מה-queue עם GetQueuedCompletionStatus ומעבדים אותם. הנקודה המרכזית היא שזה לא סתם queue של הודעות: הוא גם מנגנון scheduling שמחזיק את מספר ה-threads ה-runnable בערך ה-concurrency או מתחתיו. הוא מאפשר לטפל במספר גדול של פעולות I/O במקביל ביעילות עם מספר קטן של threads, והוא הבסיס גם למימושים של שרתים ב-Windows וגם ל-thread pool של .NET.
- לאיזה ערך כדאי לכוון את ערך ה-concurrency (רמת ה-concurrency) של IOCP?
- התיעוד של Microsoft קובע שהערך המקסימלי הטוב ביותר באופן כללי הוא מספר ה-CPUs במחשב. העברת 0 ב-NumberOfConcurrentThreads של CreateIoCompletionPort משתמשת במספר ה-processors במערכת, ולכן 0 הוא נקודת ההתחלה אם אינכם בטוחים. הערך הזה מגביל את מספר ה-threads ה-runnable, ולא את מספר ה-threads שממתינים. אם thread שרץ נכנס למצב המתנה מכל סיבה שהיא, המערכת מעירה thread אחר שממתין כדי למלא את החור, ולכן אם העיבוד שלכם כולל חישוב ארוך או חסימה, אפשר לבחור גם ערך concurrency גדול יותר כדי להגדיל את מספר ה-packets שמעובדים בו-זמנית. בסופו של דבר מומלץ לכוונן אותו יחד עם profiling.
- למה אפשר לומר שהמתנה של async/await על I/O אינה צורכת thread?
- כי בין הוצאת ה-I/O לסיומו אין בשום מקום thread ייעודי שמטפל בפעולה הזאת. כפי שראינו בחלקים 1 ו-2, בקשה שהוצאה זורמת ב-device stack כ-IRP, והקריאה חוזרת מיד עם ERROR_IO_PENDING. כל מה ש-await עושה בשלב הזה הוא לרשום continuation על ה-Task שעוד לא הושלם ולוותר על ה-thread. בזמן שההתקן עושה את עבודתו כחומרה, אין thread בשום מקום — לא ב-user mode ולא ב-kernel — שסתם ממתין. כשהפעולה מסתיימת, נדחף completion packet ל-IOCP של ה-thread pool, ורק אז רץ לזמן קצר I/O completion thread כדי לתזמן את ה-continuation שנרשם. במילים אחרות, thread נדרש רק ברגע ההוצאה ובטיפול שאחרי הסיום; זמן ההמתנה עצמו מתקדם עם אפס threads.
- על איזה thread רץ המשך של await (ה-continuation)?
- כברירת מחדל נלכד ה-SynchronizationContext (או TaskScheduler) שהיה בתוקף בנקודת ה-await, וה-continuation מוחזר אליו. אם עשיתם await על UI thread של WPF או WinForms, ההמשך רץ על ה-UI thread, ולכן אפשר לגעת ב-controls ישירות אחרי ה-await. כשאין הקשר ללכוד — כמו באפליקציית console, ב-ASP.NET Core או בקוד שכבר רץ על ה-thread pool — ה-continuation רץ על thread מתוך ה-thread pool, או ממשיך ישירות על ה-thread שהשלים את ה-Task. הוספת ConfigureAwait(false) מפסיקה את הלכידה, אבל זו אינה הבטחה למעבר ל-thread pool: זו הוראה שה-continuation לא חייב לחזור למקום מסוים. אם עושים await על Task שכבר הושלם, לא נוצרת המתנה והביצוע ממשיך סינכרונית על ה-thread הנוכחי. בקוד ספרייה מומלץ ConfigureAwait(false) כדי להימנע מהלוך-חזור מיותר ל-UI thread וכדי לחסום מראש תלות בהקשר מסוים ואת מקורות ה-deadlock.
- מה קורה אם חוסמים לזמן ארוך בתוך worker thread של IOCP או ב-I/O completion thread של .NET?
- זה לא נשבר מיד, אבל הביצועים מתדרדרים כי יצאתם מההנחות של המנגנון. IOCP מעיר thread שממתין כדי להשלים את החסר בכל פעם ש-thread שרץ נכנס למצב המתנה, אבל כל השלמה כזאת מנפחת את רמת ה-concurrency ומגדילה את מספר ה-context switches. אם החסימה הופכת לשגרה, packets נערמים ב-queue וכל עיבוד הסיום מתעכב. כך גם ב-.NET: המתנה סינכרונית בתוך I/O completion thread או continuation — על I/O סינכרוני או על משהו כמו Task.Result — מזמינה thread pool starvation. הכלל הוא לשמור על טיפול הסיום וה-continuations קצרים ולהוציא עבודה כבדה למקום אחר. אפשר לצפות במספר ה-worker threads וה-I/O completion threads הפנויים עם ThreadPool.GetAvailableThreads, וזה שימושי כשמתחקרים צוואר בקבוק.