לדלג לתוכן

המתנה וקריאות APC

נניח שה־thread שלנו מחכה לאירוע, ובאותו זמן נרשמה עבורו פעולה שצריך לבצע. האם ההמתנה מאפשרת לפעולה לרוץ? התשובה תלויה בסוג ההמתנה ובמנגנון ההודעה. המתנה רגילה והמתנה alertable אינן אותו חוזה.

ה־dispatcher מנהל תנאי המתנה של אובייקטים שונים. אובייקטי event, semaphore, mutex ותהליך אינם מסומנים מאותה סיבה, ואפילו חזרת המתנה מוצלחת יכולה לשנות state באופן שונה. כדי לכתוב shutdown תקין צריך לפרש כל תוצאה ולא להסתפק בבדיקה שהפונקציה חזרה.

בקשה לבצע callback אינה ביצוע שלו

נרשום user APC ל־thread הנוכחי. הניסוי נשאר בתוך התהליך שלנו ואינו משתמש בהזרקת קוד לתהליך אחר.

#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <cstdio>

struct State {
    unsigned calls = 0;
};

void CALLBACK notify(ULONG_PTR parameter) {
    auto* state = reinterpret_cast<State*>(parameter);
    ++state->calls;
}

int main() {
    State state;
    if (QueueUserAPC(notify, GetCurrentThread(),
                     reinterpret_cast<ULONG_PTR>(&state)) == 0) {
        std::printf("queue error=%lu\n", GetLastError());
        return 1;
    }

    SleepEx(0, FALSE);
    std::printf("before alertable: %u\n", state.calls);
    const DWORD result = SleepEx(1000, TRUE);
    std::printf("after alertable: %u result=%lu\n", state.calls, result);
    return state.calls == 1 && result == WAIT_IO_COMPLETION ? 0 : 2;
}
cl /nologo /std:c++20 /EHsc /W4 apc.cpp /Feapc.exe

ה־callback הרגיל לא מופעל רק מפני שקראנו ל־SleepEx עם alertable שקר. הקריאה השנייה מאפשרת ל־thread לטפל ב־APC. המונה אינו atomic משום שכל הגישות בדוגמה נעשות באותו thread, אך עדיין צריך לחשוב על reentrancy.

המצביע ל־state נשאר חי עד אחרי הפעלת ה־callback. במערכת שבה APC נרשם ל־thread אחר, אי אפשר לשחרר context רק מפני ש־QueueUserAPC הצליחה. ייתכן שה־thread לא ייכנס שוב להמתנה alertable, או שיסתיים לפני שההודעה תימסר.

חזרה בגלל APC אינה הצלחת ההמתנה המקורית

ב־WaitForSingleObjectEx, תוצאה של WAIT_IO_COMPLETION אומרת שההמתנה הופסקה לצורך APC מתאים. היא אינה אומרת שה־event שעליו חיכינו מסומן. צריך להחליט אם להמשיך להמתין ולחשב מחדש את הזמן שנותר.

אם בכל חזרה כזו מתחילים timeout מלא מחדש, זרם של APCים יכול להאריך את ההמתנה בלי גבול. הפתרון הוא deadline מוחלט בשעון מתאים, והמרתו לזמן שנותר בכל ניסיון.

הקטע הבא הוא אלגוריתם לשילוב בממשק המתנה בעל timeout מוגבל; הוא משתמש באירוע חי שהקורא מחזיק לאורך כל הפעולה.

DWORD wait_with_deadline(HANDLE event, DWORD timeout_ms) {
    const ULONGLONG deadline = GetTickCount64() + timeout_ms;
    for (;;) {
        const ULONGLONG now = GetTickCount64();
        const DWORD remaining = now >= deadline ? 0 :
            static_cast<DWORD>(deadline - now);
        const DWORD result = WaitForSingleObjectEx(event, remaining, TRUE);
        if (result != WAIT_IO_COMPLETION) return result;
        if (GetTickCount64() >= deadline)
            return WaitForSingleObjectEx(event, 0, FALSE);
    }
}

הפונקציה מוגבלת ל־timeout סופי שמיוצג ב־DWORD ואינה מפרשת INFINITE כמילישניות רגילות. בממשק ציבורי צריך לדחות את הערך הזה או לטפל בו בענף נפרד. הגלישה של מונה uptime רחב אינה מעשית בניסוי, אך חוזה כללי צריך להגדיר אריתמטיקת deadline במפורש.

כניסה חוזרת שונה ממקביליות

ה־APC יכול לרוץ באותו thread בזמן שהקוד הרגיל נמצא בתוך המתנה alertable. אם החזקנו lock או השארנו מבנה במצב ביניים לפני ההמתנה, ה־callback יכול לפגוש אותו במצב שלא ציפינו לו.

אין כאן בהכרח שני מעבדים שכותבים בו זמנית. עדיין יכולה להיות תקלה בגלל כניסה חוזרת לאותו רכיב. פעולת callback שמפעילה בעצמו המתנה alertable עלול לאפשר APC נוסף וליצור קינון עמוק.

לכן callback קצר צריך לפעול תחת חוזה ברור: אילו מצבים מותר לו לקרוא, אילו פעולות הוא מבצע ואילו נעילות אסור להחזיק בעת הכניסה להמתנה. הוספת mutex בלי לבדוק reentrancy יכולה להחליף פגם לוגי ב־deadlock.

לכל אובייקט משמעות איתות משלו

ה־manual-reset event נשאר מסומן עד איפוס. ה־auto-reset event משחרר ממתין מתאים ומאפס את מצבו לפי החוזה. ה־semaphore מייצג כמות זמינה, והמתנה מוצלחת צורכת יחידה.

ב־mutex, תוצאה של WAIT_ABANDONED אומרת שהבעלות הקודמת אבדה עקב סיום thread. הממתין שקיבל אותו צריך להתייחס למצב המוגן כחשוד ולבצע התאוששות או כשל מבוקר. זו אינה הצלחה רגילה שמאפשרת להמשיך כאילו transaction הושלמה.

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

סוגי APC אינם כולם callbacks של יישום

המערכת משתמשת גם ב־kernel APC ובמנגנונים פנימיים בעלי כללים אחרים. אין להעתיק את הכלל של alertable wait לכל סוג APC שמופיע בתחקיר קרנל. בשלב הזה אנחנו מתמקדים ב־user APC הרגיל של QueueUserAPC.

גם thread pool אינו יעד טבעי להודעה שמחכה ל־thread מסוים: חיי העובדים מנוהלים בידי המערכת, והם אינם מתחייבים להיכנס למצב ההמתנה שלנו. עבור עבודה כזו נשתמש באובייקטי work, wait או timer של המאגר.

לקריאה נוספת: המנגנון של asynchronous procedure calls.