לדלג לתוכן

תהליכונים והקשר ביצוע

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

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

נותנים לכל עובד נתונים משלו

נשתמש ב־_beginthreadex של ה־CRT כדי ליצור threads שמריצים קוד C++. הקוד הבא מפעיל שני עובדים, ממתין לשניהם ורק אז קורא תוצאות.

#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <process.h>
#include <array>
#include <cstdint>
#include <cstdio>

thread_local unsigned local_calls = 0;

struct Work {
    std::uint64_t seed;
    std::uint64_t result = 0;
    unsigned tls_value = 0;
};

unsigned __stdcall worker(void* raw) {
    auto& work = *static_cast<Work*>(raw);
    ++local_calls;
    std::uint64_t value = work.seed;
    for (unsigned i = 0; i < 2000000; ++i) {
        value ^= value << 13;
        value ^= value >> 7;
        value ^= value << 17;
    }
    work.result = value;
    work.tls_value = local_calls;
    return 0;
}

int main() {
    std::array<Work, 2> work{{{17}, {29}}};
    std::array<HANDLE, 2> threads{};
    std::size_t started = 0;
    for (; started < threads.size(); ++started) {
        const auto value = _beginthreadex(nullptr, 0, worker,
                                          &work[started], 0, nullptr);
        if (value == 0) break;
        threads[started] = reinterpret_cast<HANDLE>(value);
    }
    bool waits_ok = true;
    for (std::size_t i = 0; i < started; ++i) {
        if (WaitForSingleObject(threads[i], INFINITE) != WAIT_OBJECT_0)
            waits_ok = false;
        CloseHandle(threads[i]);
    }
    if (!waits_ok || started != threads.size()) return 1;
    for (const auto& item : work)
        std::printf("result=%llu tls=%u\n",
            static_cast<unsigned long long>(item.result), item.tls_value);
}
cl /nologo /std:c++20 /EHsc /W4 /O2 /Zi threads.cpp /Fethreads.exe

כל עובד כותב לאיבר Work נפרד. ההורה אינו קורא את התוצאות עד השלמת ההמתנה על ה־thread. זהו גבול סנכרון של פלטפורמת Windows; בפרק הבא נבנה גם הוכחות באמצעות כללי C++ ו־join של הספרייה התקנית.

אם יצירת העובד השני נכשלת, ההורה עדיין ממתין לעובד הראשון. חזרה מ־main בלי להמתין הייתה משמידה את מערך work בזמן שהעובד משתמש בו. מסלול כשל חלקי הוא בדיוק המקום שבו בעלות על context הופכת לחשובה.

אחסון TLS הוא מצב לפי thread

לכל thread יש עותק של local_calls, ולכן כל עובד שמפעיל את הפונקציה פעם אחת רואה אחד. המונה אינו משותף בין העובדים. אילו היינו מסירים thread_local והופכים אותו למשתנה משותף רגיל, שתי הכתיבות היו יוצרות data race בלי סנכרון.

ה־TLS אינו הופך את כל האובייקטים שאליהם הוא מצביע לפרטיים. מצביע שנשמר ב־TLS עדיין יכול להפנות לאותו buffer משותף. צריך להבחין בין מיקום המשתנה לבין בעלות על היעד שלו.

קיימים מנגנוני TLS של Windows ומימושי שפה שמשתמשים בתשתיות runtime. בנייה והשמדה של אובייקטי thread_local יכולות להיות מורכבות יותר ממספר שלם. סיום thread בכוח עלול לדלג על ניקוי שהקוד ציפה לו.

מחסנית היא משאב עם גבולות

לכל thread יש stack עם reservation ו־commit לפי התצורה. ה־stack אינו כל הזיכרון שה־thread יכול להשתמש בו; הקצאות heap משותפות למרחב התהליך ויכולות לחיות מעבר לקריאה מסוימת.

פרמטר stack size של API יצירה דורש קריאת חוזה. בחלק מהדגלים הוא מתאר commitment ובאחרים reservation. ערך קטן מדי יכול לגרום לכשל עמוק בתוך ספרייה, וערך ענק לכל אחד מאלפי threads יכול לבזבז מרחב כתובות או commit.

בפרק מנהל הזיכרון נעקוב אחר guard pages וגדילת stack. כאן חשוב כבר לדעת שיצירת thread אינה פעולה חינמית ושמספר threads אינו צריך להיות שווה למספר בקשות.

המצב הפנימי אינו מבנה שאנחנו מנהלים

במרחב המשתמש אפשר לחקור TEB, שמכיל מידע הקשור ל־thread. בקרנל קיימים מבנים כמו ETHREAD ו־KTHREAD שמחברים זהות, תזמון ומצב ביצוע. הם אינם headers יציבים של אפליקציה.

בדיבאגר משתמשים בסמלים של build מתאים כדי להבין מה קרה. אין לקבע offset של TLS או מצב scheduling מתוך צילום של מחשב אחד. ממשק API נתמך הוא הגבול שעליו בונים מוצר; המבנים הפנימיים הם כלי להסבר ולתחקיר.

מוכן לרוץ אינו רץ

ה־thread יכול להיות running על מעבד לוגי, ready אך ממתין להזדמנות, או waiting לתנאי. זו מפה מפושטת של מצבים משמעותיים, ולא רשימה מלאה של כל מצב פנימי.

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

מסיימים דרך חוזה ה־runtime

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

ב־C++ אפשר לבחור std::thread או std::jthread וליהנות מבעלות וממשק שפה מתאים. ההיכרות עם API ה־CRT עדיין חשובה כדי להבין handles, wait ואת החיבור למערכת, במיוחד בקוד קיים.

לקריאה נוספת: יצירת threads באמצעות ה־CRT.