מעמקי ה-I/O של Windows (חלק 3) — I/O completion ports (IOCP) ו-thread pool של .NET: המרתף שמתחת ל-async/await

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

איך כדאי לאסוף סיומים של 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.

מודל IOCP (I/O אסינכרוני)queue של סיומים(הודעות הסיום של כל החיבורים נאספות כאן)worker 1worker 2ה-workers מעטים, בערך כמספר ה-CPUsthread אחד לכל חיבור (I/O סינכרוני)thread 1 ממתין ל-read של חיבור 1thread 2 ממתין ל-read של חיבור 2thread 3 ממתין ל-read של חיבור 3...ה-threads גדלים עם מספר החיבוריםרובם סתם ישנים וממתינים ל-I/O

איור 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

worker threadsI/O completion porthandles שמשויכים לפורט (כל מספר)controlsממתין ב-GetQueuedCompletionStatusממתין ב-GetQueuedCompletionStatusqueue של completion packets (FIFO)packet = בתים שהועברו +CompletionKey + מצביע ל-OVERLAPPEDבקרת concurrencythreads runnable עד לגבולקובץsocketnamed pipe

איור 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

worker threadה-queue של הפורט (FIFO)kernel (סיום IRP)worker threadה-queue של הפורט (FIFO)kernel (סיום IRP)ממתין ב-GetQueuedCompletionStatusמסתכל על ה-packet ומריץ טיפול סיום(הרצת ה-continuation, הוצאת ה-I/O הבא וכן הלאה)אם נשארו packets ב-queueמקבל את הבא בלי להמתיןדוחף completion packet(מספר בתים / CompletionKey / OVERLAPPED)מעיר thread אחד שממתין ומוסר לו אותומסיים לעבד וקורא שוב ל-GetQueuedCompletionStatus

איור 3: completion packets נדחפים לפי FIFO, וה-worker חוזר בלולאה שבין הוצאת packet לבין עיבודו.

כשה-I/O האסינכרוני מסתיים, נדחף completion packet ל-queue של הפורט לפי סדר FIFO. ה-worker חוזר על הלולאה הבאה.17

  1. מקבל packet אחד עם GetQueuedCompletionStatus.
  2. מריץ את טיפול הסיום של הפעולה שהתקבלה.
  3. כשמסיים לעבד, קורא שוב ל-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 הבא.

threads שממתינים (stack LIFO)P1, P2 ו-P3 כולם הולכים קודםל-thread A אם הוא פנוירק כש-A עסוקמתעורר לעיתים נדירותthread A (רץ עד לפני רגע, חם)thread B (ישן כבר זמן מה)thread C (ישן כל הזמן)ה-queue מחזיק את P1, P2, P3 לפי FIFO

איור 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 מה קורה כשמישהו נכנס למצב המתנה.

נמוךבגבולpacket מגיע ל-queueהאם מספר ה-threads ה-runnableנמוך מערך ה-concurrencyמעיר thread שממתין ומאפשר לו לעבדלא מעיר איש ומשאיר אותו ב-queue(thread שרץ יבוא לקחת אותו)thread שרץ נכנסלמצב המתנה מסיבה אחרתמעיר מספיק threads שממתינים כדילכסות את הירידה במספר ה-runnable

איור 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 APICreateThreadpoolIo ו-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.

המקום שבו רץ ה-continuationI/O completion threadה-IOCP של ה-thread poolkernel(מהוצאת IRP עד הסיום)thread קורא(למשל UI thread)המקום שבו רץ ה-continuationI/O completion threadה-IOCP של ה-thread poolkernel(מהוצאת IRP עד הסיום)thread קורא(למשל UI thread)await רושם continuation על ה-Task שלא הושלםומוותר על ה-thread (ב-UI, חזרה לטיפול בהודעה הבאה)ההתקן עובדבזמן הזה אין thread שממתין בשום מקוםקובע את התוצאה (מספר בתים, סטטוס)משלים את ה-Task ומתזמן את ה-continuationהקוד שאחרי ה-await רץReadAsync מוציא קריאה אסינכרונית(עם המקבילה של OVERLAPPED)ERROR_IO_PENDING (חוזר מיד)דוחף completion packetמעיר thread אחד לפי LIFO ומוסר לו אותוזורק אותו להקשר שנלכד(ל-UI thread, או לרוץ על ה-thread pool אם אין כזה)

איור 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

כןלאכן (UI thread של WPF/WinForms, למשל)לא (אפליקציית console, ASP.NET Core וכן הלאה)ה-Task הושלם ורוצים להריץ את ה-continuationבנקודת ה-await, האם נלכדSynchronizationContext אוTaskScheduler שאינו ברירת מחדלהאם צורףConfigureAwait(false)זורקים חזרה למה שנלכדלמשל ריצה בלולאת ההודעות של ה-UI threadאו על ה-TaskScheduler הזהאין חובה לחזור למקום מסויםממשיך סינכרונית על ה-thread שהשליםאו רץ על thread מתוך ה-thread pool

איור 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 שמריץ אותו” — כל האפליקציה נגררת.

לבדוק את המספרים הפנויים יחד עם מקום החסימה בפועל

יש שתי נקודות כניסה לחקירה.

  1. להסתכל על הזמינות של worker threads ו-I/O completion threads עם ThreadPool.GetAvailableThreads.4
  2. לתפוס את המצב האמיתי של ה-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, והתנאים שבהם “נתונים שחשבתם שכתבתם נעלמים בהפסקת חשמל”.

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

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

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

מקורות

  1. 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

  2. Microsoft Learn, CreateIoCompletionPort function. על כך ש-CreateIoCompletionPort גם יוצר I/O completion port חדש וגם משייך handle לפורט קיים; על כך שאפשר לציין בזמן השיוך CompletionKey (ערך שמוגדר על ידי המשתמש) והוא נכלל ב-completion packet; ועל כך ש-NumberOfConcurrentThreads מגביל את מספר ה-threads שיכולים לעבד completion packets במקביל, וכשמציינים 0 משתמשים במספר ה-processors במערכת.  2 3 4 5 6

  3. Microsoft Learn, PostQueuedCompletionStatus function. על כך ש-PostQueuedCompletionStatus יכול לדחוף completion packet שמוגדר על ידי האפליקציה ל-queue של I/O completion port בלי להתחיל שום I/O אסינכרוני, ועל כך שזה מאפשר לפורט לשמש ערוץ תקשורת מ-threads אחרים בתהליך, בנוסף לקבלת סיומי I/O.  2 3

  4. 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

  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

  6. 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

  7. Microsoft Learn, GetQueuedCompletionStatus function. על כך ש-GetQueuedCompletionStatus מוציא completion packet אחד מ-queue של הפורט (ואם אין, ממתין); על כך שהתוצאה שהוצאה כוללת את מספר הבתים שהועברו, את ה-CompletionKey ואת מצביע ה-OVERLAPPED; ועל כך שערך החזרה FALSE יחד עם מצביע OVERLAPPED שאינו NULL פירושו “הוצא completion packet של פעולת I/O שנכשלה”, בעוד שמצביע OVERLAPPED שהוא NULL לבדו פירושו שה-packet עצמו לא הוצא (למשל בגלל timeout).  2 3 4

  8. Microsoft Learn, GetQueuedCompletionStatusEx function. על כך ש-GetQueuedCompletionStatusEx יכול להוציא כמה completion packets בבת אחת, ועל כך שמספר הרשומות שהוצאו מוחזר.  2

  9. Microsoft Learn, SetFileCompletionNotificationModes function. על כך ש-FILE_SKIP_COMPLETION_PORT_ON_SUCCESS מאפשר לבחור לא לדחוף packet ל-completion port כאשר I/O מצליח מיד והתוצאה כבר ידועה במקום. 

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

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

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

שאלות נפוצות

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

מהו 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, וזה שימושי כשמתחקרים צוואר בקבוק.

פרופיל הכותב

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

Go Komura

מנהל KomuraSoft LLC

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

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

חזרה לבלוג