מבנה עבודה וסיום שיתופי¶
נניח שחלון נסגר בזמן שעובד ממתין לעבודה. קל לשלוח בקשת עצירה ולצאת מן הפונקציה. השאלה היא האם העובד באמת הפסיק להשתמש בנתונים שהפונקציה עומדת להרוס. בקשת עצירה אינה הוכחת סיום, בדיוק כפי שדגל בתור אינו join.
נשתמש ב־jthread כדי לקשור חיי תהליכון לאובייקט C++, וב־stop_token כדי לבקש סיום שיתופי. ההפרדה חשובה: ה־token מעביר בקשה, ואילו join ממתין לכך שפונקציית התהליכון חזרה. קוד שלא בודק את הבקשה ולא ממתין בצורה שניתנת לעצירה יכול להמשיך לעבוד.
עובד שמחכה בלי לולאת בדיקה¶
התוכנית הבאה מפעילה עובד יחיד עם תור וסיום מסוג drain. הבקשה לסיים מועברת באמצעות דגל סגירה תחת הנעילה; ה־stop_token משמש מסלול abort נפרד:
#include <condition_variable>
#include <deque>
#include <iostream>
#include <mutex>
#include <stop_token>
#include <thread>
int main() {
std::mutex mutex;
std::condition_variable_any changed;
std::deque<int> queue;
bool closed = false;
long long sum = 0;
std::jthread worker([&](std::stop_token stop) {
for (;;) {
int value = 0;
{
std::unique_lock lock(mutex);
const bool ready = changed.wait(lock, stop, [&] {
return closed || !queue.empty();
});
if (!ready || stop.stop_requested()) return;
if (queue.empty()) return;
value = queue.front();
queue.pop_front();
}
sum += value;
}
});
{
std::lock_guard lock(mutex);
for (int value = 1; value <= 10; ++value) queue.push_back(value);
closed = true;
}
changed.notify_all();
worker.join();
std::cout << sum << '\n';
}
הגרסה הזו דורשת תמיכה ב־C++20 במימוש הספרייה, ובפרט בגרסת ההמתנה המקבלת stop_token. ה־predicate והדגלים נשארים תחת mutex. העובד מוציא פריט ואז משחרר את הנעילה לפני ביצוע העבודה, כדי שלא יחזיק את התור בזמן פעולה שעלולה להיות ארוכה.
אם נחליף את הסגירה בבקשה worker.request_stop(), העובד רשאי לצאת בלי לרוקן את התור. במקרה הזה ה־main עדיין מחזיק את התור ומנקה את הפריטים אחרי join. התוצאה המספרית כבר אינה חייבת להיות חמישים וחמש, משום שהחוזה השתנה. מערכת צריכה לדווח אילו בקשות התקבלו, בוצעו ובוטלו; מספר יחיד של משימות שנשלחו אינו מספיק.
סדר הרס הוא חלק מפרוטוקול הסיום¶
ה־jthread נוצר אחרי המצב שעליו הוא עובד, ולכן סדר ההרס הרגיל משאיר את המצב בחיים בזמן סיום העובד. עם זאת, הסתמכות על destructor בלבד יכולה לשנות את משמעות הסיום: destructor של jthread מבקש עצירה ואז מצטרף. אם רצינו drain, עלינו לסגור קבלה ולהמתין במפורש לפני ההרס, כמו בדוגמה.
במחלקה, סדר הצהרת השדות קובע את סדר ההרס ההפוך. אם שדה התהליכון נהרס אחרי התור שעליו הוא עובד, ה־destructor של התור יכול לפגוש משתמש חי. בדיקת lifetime כזו חשובה יותר מהשאלה אם נעשה שימוש ב־shared_ptr. בעלות משותפת יכולה לדחות הרס, אך גם להסתיר מעגל שלא מסתיים.
מנעול שנשאר אצל הממתין¶
נבחן כשל נפוץ: פונקציית shutdown נועלת את mutex של התור ואז קוראת join. העובד זקוק לאותו mutex כדי לבדוק שהסגירה התבקשה ולצאת. ה־shutdown ממתין לעובד, והעובד ממתין לנעילה שמוחזקת על ידו. הוספת timeout לא מתקנת את המעגל; היא רק מאפשרת לזהות שהחוזה נשבר.
הפתרון הוא לבצע את שינוי המצב תחת הנעילה, לשחרר אותה, להעיר ממתינים ורק אז להצטרף. כאשר פעולה דורשת שתי נעילות, קובעים סדר אחיד או משתמשים במנגנון כגון scoped_lock שמתאים לרכישה משולבת. גם אז אסור לקרוא callback בלתי נשלט בזמן החזקת נעילה פנימית, משום שהוא יכול לחזור אל המערכת וליצור סדר נעילות חדש.
משימה אינה תהליכון¶
תהליכון הוא משאב ביצוע של מערכת ההפעלה, בעוד משימה היא יחידת עבודה שהמערכת שלנו מגדירה. יצירת thread לכל בקשה עלולה להגדיל מחסניות, context switching וזמן הקמה. מצד שני, העברת פעולות חוסמות למאגר קטן עלולה להשאיר את כולו ממתין. נצטרך למדוד את סוג העבודה לפני בחירת מספר העובדים.
מנגנון latch מתאים לנקודת מפגש חד־פעמית, למשל המתנה לכך שכל העובדים סיימו הקמה. מנגנון barrier מתאים לשלבים חוזרים שבהם המשתתפים מסיימים סבב ואז ממשיכים יחד. בשניהם צריך להגדיר מה קורה כאשר משתתף נכשל לפני ההגעה. מחסום שממתין למשתתף שכבר יצא הוא דרך נוספת לייצר תקיעה.
ב־WinAPI אפשר להמתין למספר handleים באמצעות WaitForMultipleObjects, אך יש מגבלה על מספרם ויש הבדל בין המתנה לאחד לבין המתנה לכולם. הבחירה מי התעורר אינה מספיקה כדי להסיק שכל המשאבים הסתיימו. בפרט, אירוע עצירה מסומן אינו אומר ש־thread handle כבר מסומן. את חיי המצב מסיימים רק אחרי הראיה הנכונה לסיום משתמשיו.
קריאה להעמקה¶
החוזים של תהליכון מצטרף בטיוטת תקן C++ ושל המתנה למספר אובייקטים מסייעים לנסח בדיוק מה כל פעולת סיום מבטיחה.