שיטות עבודה מומלצות לרב־תהליכוניות בפועל: מהדורת C++ — ביטול תאונות במבנה עם RAII ו-jthread

· · Windows, ריבוי תהליכונים, C++, Visual Studio, יישומים עסקיים, חקירת תקלות, תכנון

«תכנון שעבד בסדר ב-C# התחיל לקרוס מדי פעם אחרי שהעברנו ל-C++.» «השתמשנו ב-std::thread, וכשנזרקה חריגה כל היישום מת מיד דרך terminate.» «עצרנו דברים עם דגל volatile bool, אבל רק בגרסת הפצה, זה לא נעצר.» — רב־תהליכוניות ב-C++ נושאת סכנה שלשפות מנוהלות פשוט אין: מרוץ נתונים, כפי שהוא, הוא התנהגות לא מוגדרת (UB). זה לא רק שאפשר לקרוא ערך מקולקל; הנחות האופטימיזציה של המהדר קורסות, ומגיעים למצב שבו מילולית הכול יכול לקרות.

מאמר זה הוא מהדורת C++ של סדרת הרב־תהליכוניות המעשית שלנו. מיועד למפתחים שכותבים יישומים עסקיים, תוכנת בקרת ציוד ו-DLL ב-C++ מודרני (C++17/20), והוא מתרגם את העקרונות הכלליים של תכנון רב־תהליכוני — לא להוסיף תהליכונים ישירות, לצמצם מצב משתנה משותף, ליישם משמעת מנעולים, לתכנן איך עוצרים לפני כל דבר — לכלים של C++ ו-Windows, יחד עם מלכודות ייחודיות ל-C++, הכול מעוגן במקורות ראשוניים נכון לאוגוסט 2026. נכתב כדי לעמוד בפני עצמו. אותם עקרונות, שפותחו לשפות אחרות, מופיעים גם ב«מהדורת .NET», «מהדורת C» ו«מהדורת Java».

1. העיקר קודם

  • ב-C++, מרוץ נתונים אינו «אולי תקראו ערך מקולקל» — זו התנהגות לא מוגדרת. לא להשאיר אף גישה משתנה משותפת לא מסונכרנת בקוד הוא דרישה מוחלטת, יותר מאשר בשפות אחרות.1
  • אל תשתמשו ב-std::thread חשוף. אם המפרק של std::thread רץ בזמן שהתהליכון עדיין joinable, std::terminate הורג את התהליך מיד. std::jthread של C++20 עושה join אוטומטית במפרק ויש לו מנגנון בקשת עצירה (stop_token) מובנה.23
  • החזיקו מנעולים תמיד דרך RAII. הפסיקו לכתוב mtx.lock() ביד; השתמשו ב-lock_guard / scoped_lock. המפרק משחרר את המנעול באמינות גם אם נזרקת חריגה. כשרוכשים כמה מנעולים יחד, scoped_lock מטפל בזה באלגוריתם הימנעות מקיפאון.4
  • volatile אינו כלי סנכרון. השתמשו ב-std::atomic לדגלים ומונים משותפים, וב-std::mutex להגן על כמה משתנים יחד. std::atomic מספק גם אטומיות וגם סדר מבוסס memory_order.5
  • עשו המתנת מפגש בצורת הפרדיקט של wait של condition_variable. משתני תנאי חשופים להתעוררות ספונטנית (התעוררות בלי הודעה), ולכן קריאה ל-wait בלי פרדיקט היא כר לגידול באגים.6
  • jthread + stop_token (C++20) הוא הצורה הבסיסית לאיך עוצרים תהליכון. בסביבות שלפני כן בונים עצירה שיתופית ביד עם std::atomic<bool> ועוד משתנה תנאי. התייחסו לסיום כפוי של תהליכון כאל דבר שפשוט לא קיים בעולם C++.3
  • דעו שמפרק של future יכול לחסום לפני שמשתמשים ב-std::async. השליכו את ערך ההחזרה ותקבלו את אותו אפקט של ביצוע סדרתי.7
  • אובייקטי סנכרון של Win32 ראויים למקומם רק לתרחישי «עבודה עם ממשקי המתנה של Win32» ו«בין תהליכים». בכל מקום אחר, כתיבה מול הספרייה התקנית עדיפה לניידות ולתחזוקה.8

2. למה רב־תהליכוניות קשה — תנאי מרוץ, קיפאון והתנהגות לא מוגדרת

בתמצית, הבעיות שרב־תהליכוניות מביאה הן משני סוגים, בלי קשר לשפה.

תנאי מרוץ (race condition) הוא באג שבו התוצאה תלויה בסדר שבו כמה תהליכונים מגיעים לקטע קוד מסוים. הדוגמה הקלאסית היא מונה משותף: הביטוי הבודד ++count מתפרק ברמת קוד מכונה לשלושה שלבים — קריאה, חיבור, כתיבה חזרה. אם שני תהליכונים נכנסים לשלושת השלבים האלה באותו זמן, החיבור של תהליכון אחד נדרס ואובד בכתיבה של האחר. התוצאה משתנה מהרצה להרצה, ואיזו תוצאה תקבלו אי אפשר לחזות.

תהליכון Bהמשתנה המשותף countתהליכון Aתהליכון Bהמשתנה המשותף countתהליכון Acount = 10שני הגדלות קרו,ועדיין count = 11 — החיבור של תהליכון A אבדקריאה (10)קריאה (10)חיבור מקומי (11)חיבור מקומי (11)כתיבה חזרה (11)כתיבה חזרה (11)

איור 1: תנאי מרוץ קלאסי שבו הגדלה על מונה משותף אובדת. אם תהליכון אחר משתלב בשלושת שלבי ++count, הכתיבה החזרה שמתרחשת אחרונה דורסת את האחרת

קיפאון (deadlock) הוא מצב שבו שני תהליכונים כל אחד מחכה למנעול שהאחר מחזיק, כך שאף אחד לא יכול להתקדם. תהליכון A מחזיק מנעול 1 ומחכה למנעול 2; תהליכון B מחזיק מנעול 2 ומחכה למנעול 1 — זה לבדו מספיק כדי ששניהם ייעצרו לנצח.

מחכה לרכישת מנעול 2מחכה לרכישת מנעול 1תהליכון Aמחזיק מנעול 1תהליכון Bמחזיק מנעול 2

איור 2: ההמתנה המעגלית של קיפאון. ברגע שחצי ההמתנה יוצרים טבעת, כל תהליכון בטבעת הזו נעצר לנצח

מה שהופך את שניהם למביכים הוא שהם תלויי-עיתוי. שילוב שפוגע פעם בעשרות אלפי הרצות במכונת פיתוח יכול לקרות כל יום במכונת הלקוח, עם מספר ליבות אחר ועיתוי אחר. «לא משתחזר עם מנפה שגיאות מחובר» ו«נעלם כשהוספתי רישום» קורים שניהם כי עצם התצפית משנה את העיתוי — זה התנהגות קלאסית של באגי מרוץ. לכן בדיוק כל עיקרון במאמר זה מצביע לכיוון אחד: לצמצם את המקומות שצריכים סנכרון, לפני שדואגים לסנכרן אותם נכון.

2.1. ב-C++, מרוץ נתונים הוא ישירות התנהגות לא מוגדרת

מעבר לזה, ל-C++ יש שכבה נוספת שאין לשפות אחרות. לפי תקן C++, אם כמה תהליכונים ניגשים לאותו מיקום זיכרון בלי סנכרון ולפחות אחד כותב, זה מרוץ נתונים, וזו התנהגות לא מוגדרת. פרק המקביליות של C++ Core Guidelines (CP.2, «Avoid data races») קובע זאת ככלל המוחלט הראשון.1 התנהגות לא מוגדרת אינה הסיפור המתון «אולי תקראו את הערך הישן או החדש». המהדר מבצע אופטימיזציה מתוך הנחה שאין מרוץ נתונים, ולכן התנהגות שאי אפשר לחזות מקוד המקור — בדיקת תנאי שנעלמת מלולאה, כתיבות שמסודרות מחדש או מתמזגות — מתרחשת באופן לגיטימי. התאונה הקלאסית שבה «דגל עצירה volatile bool נכשל רק בגרסת הפצה» היא מקרה ספר בדיוק לזה.

2.2. RAII הוא היסוד

הנחת יסוד נוספת ייחודית ל-C++ היא חריגות וניהול משאבים. ל-C++ אין finally; במקומו יש RAII (שחרור אוטומטי דרך מפרקים), וכלי הרב־תהליכוניות מתוכננים מתוך הנחה שתשתמשו בו. «לנהל מנעולים דרך חיי אובייקט»; «להבטיח join של תהליכון גם דרך חיי אובייקט» — ללכת עם המוסכמה הזו הוא היסוד לכתיבת C++ רב־תהליכוני בבטחה.

3. איך מתחילים תהליכון — מלכודת thread, ו-jthread

3.1. המפרק של std::thread «תוכנן לגרום לתאונות»

ל-std::thread יש מלכודת ידועה. אם המפרק שלו רץ בזמן שהתהליכון עדיין joinable (לא נעשה join ולא detach), נקרא std::terminate והתהליך מת מיד.9

void process()
{
    std::thread worker([]{ HeavyWork(); });
    DoSomething();      // ← אם נזרקת כאן חריגה...
    worker.join();      // ← join לא מגיע לעולם; המפרק של worker קורא ל-terminate
}

להפוך זאת לבטוח לחריגות דרש להבטיח join ב-try/catch — מצב מעוות בשפת RAII שבה דווקא תהליכונים היו צריכים ניהול ידני. std::jthread של C++20 פותר זאת. כי המפרק שלו מוציא אוטומטית בקשת עצירה ואז עושה join, הקוד למעלה הופך לבטוח לחריגות רק במעבר ל-std::jthread.2 ב-MSVC, <stop_token> ו-jthread זמינים מ-Visual Studio 2019 16.9 ואילך.3

std::threadלא join ולא detachstd::threadכבר נעשה joinstd::jthread - C++20התהליכון הופעלמה קורהכשההיקף יוצא?std::terminateהתהליך מת מידjoin בבטחהrequest_stop + join אוטומטייםבטוח גם אם נזרקת חריגה

איור 3: חיי אובייקט תהליכון ואיך הם נגמרים. std::thread מוגדר למות מיד אם שוכחים join, לכן מ-C++20 ואילך עשו את jthread לברירת מחדל

ככלל, אל תשתמשו ב-detach(). תהליכון שאיבד כל אמצעי join הופך לגורם קלאסי לקריסות סגירה, מתחרה בהריסת משתנים סטטיים והערימה כשהתהליך יוצא.

3.2. כלים «מעל רמת התהליכון» — async, future ואלגוריתמים מקביליים

עקרון מהדורת .NET «אל תיצרו תהליכונים בעצמכם» ממופה ב-C++ לכלים הבאים.

  • std::async + std::future: למשימה אסינכרונית חד-פעמית ולקבלת התוצאה. יש, עם זאת, ייחוד חשוב: ה-future (או ה-shared_future האחרון) הקשור למשימה שהושקה דרך std::async חוסם עד השלמה אם המפרק שלו רץ בזמן שהמשימה עדיין לא הושלמה.7 לעבודה שהושקה בפועל עם std::launch::async, השלכת ה-future המוחזר שקולה לביצוע סינכרוני בנקודה הזו. גרוע מזה, אם לא מציינים מדיניות השקה, ההטמעה חופשית לבחור deferred (ביצוע עצלה) כברירת מחדל, ואז, אם אף אחד לא קורא ל-get() / wait(), העבודה כלל לא מבוצעת ונעלמת בשקט. אם רוצים להבטיח ביצוע מקבילי, ציינו std::launch::async במפורש, ותנו לבעלים לנהל את חיי ה-future.
  • PPL - Parallel Patterns Library - concurrency::parallel_for / parallel_for_each: החלת עבודה במקביל על כל איבר באוסף. עם זאת, אם העבודה באיטרציה בודדת קטנה מדי, תקורה של fork/join אוכלת את הרווח, לכן ככלל מקבילים בלולאה החיצונית.10
  • אלגוריתמים מקביליים של C++17 - std::execution::par: ב-MSVC האלגוריתמים העיקריים מקבילים (לא כולם).11 שימו לב שאם חריגה בורחת מעיבוד איבר תחת מדיניות ביצוע, נקרא std::terminate. הצבת גבול חריגה משלכם (try/catch) בתוך ה-callback הולכת באותו חשיבה כמו גבול התהליכון בסעיף 6.

הקו שנמתח במקום אחר — «המתנה לקלט/פלט אינה דבר שפותרים בהוספת תהליכונים» — עדיין חל בלי שינוי. לקוד Windows מקורי, I/O OVERLAPPED ו-IOCP הם הכלים שקולטים את העבודה הזו (למנגנונים, ראו «מעמקי הקלט/פלט של Windows, חלק 2»).

4. צמצום מצב משתנה משותף — פיצול, העברה לפי ערך, const ותורים

תחרות נוצרת רק כש«כמה תהליכונים» ו«נתונים משתנים משותפים» שניהם נוכחים. מספר התהליכונים נקבע בדרישות, לכן מה שהתכנון יכול לקצץ הוא השיתוף. האמצעים נופלים לשלוש משפחות — פיצול, הפיכה לבלתי-משתנה, ומסירת נתונים — והנה איך כותבים כל אחד ב-C++.

פצלו. בעבודה כמו צבירה מקבילית, במקום שכל תהליכון יכתוב לסכום משותף, תנו לכל תהליכון סכום-ביניים מקומי משלו ומזגו אותם פעם אחת, בסוף. הכתיבות לערך המשותף יורדות מ«כל איטרציה» ל«פעם לתהליכון», וחותכות גם את עלות הסנכרון וגם את חלון התחרות בסדרי גודל. צעד המיזוג היחיד הזה אפשר לעשות עם std::mutex או עם fetch_add על std::atomic — שניהם בסדר.

העבירו לפי ערך. אם מוסרים לתהליכון את הנתונים שהוא צריך בהעתקה (או העברה) בהפעלה, הנתונים האלה הופכים בלעדיים לתהליכון, ואין צורך בסנכרון. לכידת למבדות לפי הפניה ([&]) ואז נגיעה במשתנה שחיי הסתיימו היא תאונה נפוצה, לכן למבדות שמועברות לתהליכונים צריכות לכידות מפורשות, לפי העתקה או העברה ככלל. עם זאת, «הועתק, לכן בלעדי» מחזיק רק כשהערך הוא גרף ערכים עמוק שאינו מכיל כינויים כמו מצביעים או shared_ptr. העתקת מבנה שמכיל מצביע גולמי עדיין משאירה את מה שהוא מצביע עליו משותף.

שתפו כ-const. נתונים שנקראים בלבד בטוחים לקריאה מכל מספר תהליכונים יחד. ערכי תצורה, נתוני אב, קלטי חישוב וכדומה אפשר לשתף בלי סנכרון אם הופכים אותם לשיתוף const שלא נכתב מחדש אחרי בנייה (std::shared_ptr<const Config>, למשל). אזהרה אחת: מה ש-shared_ptr<const T> אוסר הוא רק שינוי דרך אותו handle מסוים. אם כינוי לא-const שורד במקום אחר, או חבר mutable נכתב מחדש, התחרות נשארת — לכן תכננו גם לזה, עד «אחרי שהבנייה נגמרה, שחררו את ההפניה הלא-const ואיש לא יכתוב אחר כך». עצם ההחלטה ש«כשצריך שינוי, בונים אובייקט חדש ומחליפים, במקום לשנות במקום» מסירה פיסת מצב משתנה שהייתם צריכים לשמור אחרת (לניהול חיי ההחלפה עצמה, ראו את האזהרה בסעיף 5.2).

מסרו דרך תור. נתבו את זרימת הנתונים בין תהליכונים דרך תור יצרן/צרכן במקום משתנה משותף. לתקן C++ אין טיפוס ערוץ, לכן כתיבת תור קטן עם std::mutex + std::condition_variable היא הדפוס המבוסס.

template <typename T>
class BlockingQueue {
public:
    explicit BlockingQueue(std::size_t capacity) : capacity_(capacity)
    {
        if (capacity == 0)                          // קיבולת 0 היא מלכודת שבה כל Push מחכה לנצח
            throw std::invalid_argument("capacity must be positive");
    }

    // מחכה עד שיש מקום (או בקשת עצירה) אם מלא. false פירושו בקשת עצירה.
    bool Push(T item, std::stop_token st)
    {
        {
            std::unique_lock lock(mtx_);
            if (!not_full_.wait(lock, st, [this]{ return queue_.size() < capacity_; }))
                return false;                       // התעורר מבקשת עצירה
            if (st.stop_requested())                // אם מקום ועצירה קורים יחד, להעדיף עצירה,
                return false;                       // ולסרב לדחיפות אחרי שהעצירה החלה
            queue_.push(std::move(item));
        }
        not_empty_.notify_one();   // להודיע מחוץ למנעול
        return true;
    }

    // מחכה לבקשת עצירה (stop_token) או להגעת פריט. nullopt כשנעצר.
    std::optional<T> Pop(std::stop_token st)
    {
        std::optional<T> item;
        {
            std::unique_lock lock(mtx_);
            if (!not_empty_.wait(lock, st, [this]{ return !queue_.empty(); }))
                return std::nullopt;                // התעורר מבקשת עצירה
            if (st.stop_requested())                // אם פריט ועצירה קורים יחד, להעדיף עצירה,
                return std::nullopt;                // ולא להתחיל עבודה חדשה אחרי שהעצירה החלה
            item = std::move(queue_.front());
            queue_.pop();
        }
        not_full_.notify_one();
        return item;
    }

private:
    const std::size_t capacity_;
    std::mutex mtx_;
    std::condition_variable_any not_empty_;   // condition_variable_any, כדי להשתמש ב-wait שמכיר stop_token
    std::condition_variable_any not_full_;
    std::queue<T> queue_;
};

יש כאן שתי נקודות תכנון. ראשונה, להגביל את הקיבולת ולגרום לצד היצרן לחכות כשמלא. תור בלי תקרה הופך לפצצת זמן במערכים שבהם הייצור עוקף את הצריכה: הוא «ממשיך לרוץ», אבל הזיכרון גדל. חסימת Push כשמלא פועלת כלחץ נגדי טבעי, ומפיצה עומס יתר במעלה הזרם מכנית. שנייה, כי משתני תנאי חשופים להתעוררות ספונטנית (התעוררות בלי הודעה), תמיד קוראים ל-wait עם פרדיקט. צורת הפרדיקט של wait מריצה בשבילכם בפנים את הלוגיקה «לולאה עד שהתנאי אמת».6

5. משמעת מנעולים — RAII ו-scoped_lock

גם אחרי צמצום מצב משתנה משותף, לעיתים קרובות אי אפשר להגיע לאפס. השתמשו בהדרה למה שנשאר משותף, אבל נעילה בלי משמעת רק מסתירה תחרות.

קודם, חשבו על יחידת הנעילה לא כ«קטע קוד» אלא כ«נתונים». הקצו mutex אחד לכל קבוצת נתונים משתנים שרוצים להגן עליה (עשו אותו חבר private, לא חשוף החוצה), וקחו את אותו mutex בכל מקום שנוגע בנתונים האלה — גרסה שבורה של טבלת ההתאמה הזו היא מה שרוב באגי המרוץ באמת. והדבר היחיד שמותר לעשות כשמחזיקים מנעול הוא לקרוא ולכתוב את הנתונים שהוא מגן. קלט/פלט קבצים, קריאות רשת ו-callback (קריאות לקוד חיצוני) בזמן שמחזיקים מנעול לא רק מאריכים את ההחזקה — הם פותחים נתיב שבו הנקרא מנסה לקחת מנעול אחר ונכנס לקיפאון. להכין מחוץ למנעול, ובתוך המנעול לא לעשות אלא להחליף היא הצורה הבסיסית.

5.1. כתיבת lock()/unlock() ביד אסורה

קוד שקורא ישירות ל-lock() / unlock() של std::mutex נגמר בכישלון לשחרר את המנעול בחריגות או בחזרה מוקדמת. תמיד השאירו רכישה ושחרור של מנעול לעטיפת RAII.

עטיפה שימוש
std::lock_guard מחזיק mutex בודד בדיוק למשך היקף — הצורה הבסיסית ביותר
std::scoped_lock (C++17) רוכש כמה mutex יחד. פותר את בעיית הסדר באלגוריתם הימנעות מקיפאון4
std::unique_lock כשרוצים לפתוח ולנעול שוב באמצע, או צריך להעביר ל-condition_variable::wait

כשיש שני מנעולים או יותר, החלפת סדר הרכישה לפי התהליכון היא דפוס הקיפאון הקלאסי (ההמתנה המעגלית באיור 2 נולדת בדיוק כך). התיקון הוא כלל ש«כל תהליכון רוכש מנעולים באותו סדר», אבל כשרוכשים אותם באותו רגע, ל-C++ יש תשובה טובה יותר: מסרו כמה mutex יחד ל-std::scoped_lock והספרייה מבטיחה סדר רכישה חופשי מקיפאון.4 במצבים כמו העברה בין שני אובייקטים שבהם רוצים «שניהם נעולים», לעולם אל תקחו אותם בנפרד — תמיד יחד.

void Transfer(Account& from, Account& to, int amount)
{
    if (&from == &to) return;                  // לא לעשות כלום לאותו חשבון (ראו הערה למטה)
    std::scoped_lock lock(from.mtx, to.mtx);   // שניהם יחד; הספרייה פותרת את הסדר
    from.balance -= amount;
    to.balance   += amount;
}

בדיקת הזהות בראש אינה קישוט. אם אותו Account מועבר גם כ-from וגם כ-to, מסיימים בהעברת אותו mutex לא-רקורסיבי ל-scoped_lock פעמיים, מה שגורם לתקיעה או להתנהגות לא מוגדרת. תמיד צרפו הדרת אותו-אובייקט לכל פונקציה ש«נועלת את שניהם».

לנתונים «נקראים הרבה, נכתבים לעיתים רחוקות» אפשר להשתמש ב-std::shared_mutex (C++17) כמנעול קריאה/כתיבה.12 recursive_mutex הוא טיפוס שתוכנן כך ש«רכישה חוזרת של אותו תהליכון לא תשבור», אבל תכנון שצריך רכישה רקורסיבית הוא לעיתים קרובות סימן שגבול האחריות של מנעול היטשטש — שקלו קודם לבחון מחדש את המבנה.

5.2. התפקיד הנכון של atomic

std::atomic מספק פעולות אטומיות על משתנה בודד, ועוד סדר מבוסס memory_order.5 הוא ראוי למקומו באותם מצבים כמו Interlocked במהדורת .NET: עדכון משתנה בודד, כמו מונה או דגל. הוא לא יכול לשמור כמה משתנים עקביים יחד, לכן לזה חוזרים ל-std::mutex.

החלפת מצביע גולמי (std::atomic<T*>) יש לה מלכודת משלה. אף שההחלפה עצמה אטומית, אף אחד לא מגן על חיי האובייקט הישן אחרי שהוחלף. אם קורא טוען את המצביע הישן רגע לפני שהכותב מחליף אותו ועושה delete, מקבלים גישה לזיכרון ששוחרר. אם רוצים תכנון «החלף ושתף אובייקט בלתי-משתנה» ב-C++, בחרו אמצעי שמגיע בצמד עם ניהול חיים — החלפת std::shared_ptr<const T> מוגן במנעול, או std::atomic<std::shared_ptr<T>> של C++20.

ולחזור: volatile אינו כלי סנכרון בין תהליכונים. תכנות בלי מנעולים שבו מציינים memory_order בעצמכם הוא שטח מומחים, שדורש גם סיבה לגיטימית להרפות מברירת המחדל (seq_cst) וגם דרך לאמת שעשיתם זאת נכון. ביישומים עסקיים, או משתמשים בברירת המחדל או כותבים ב-mutex מלכתחילה.

6. תכנון איך עוצרים — stop_token ועצירה שיתופית

השאלה הראשונה לשאול בסקירת תכנון רב־תהליכוני היא «איך זה נעצר?» ול-C++ אין אמצעי לעצור תהליכון בבטחה מבחוץ (כמה מסוכן TerminateThread של Win32 מפורט במהדורת C). לכן איך תהליכון נעצר צריך להיבנות בכלים של C++ סביב עצירה שיתופית — הצד שעוצר רק מוציא בקשה; התהליכון עצמו מחליט מתי ואיך לסיים, בנקודה שמשאירה דברים מסודרים; והשלמת ה-join היא מה שנחשב «נעצר».

ב-C++20, ל-std::jthread יש מנגנון עצירה מובנה. קריאה ל-request_stop() מרימה את בקשת העצירה על ה-std::stop_token שפונקציית התהליכון קיבלה, והלולאה סוקרת אותו. ה-wait של condition_variable_any יכול לקבל stop_token ישירות, לכן גם «תהליכון שמחכה שהעבודה תגיע» אפשר להעיר מיד בבקשת עצירה (BlockingQueue::Pop בסעיף 4 מקבל בדיוק את הצורה הזו).

class Worker {
public:
    void Start()
    {
        if (thread_.joinable())                       // לדחות Start כפול בזמן שכבר רץ.
            throw std::logic_error("already running"); // אם היינו מקצים במקום לדחות, תהליכון חדש
                                                       // היה מתחיל לרוץ, ובזמן שהוא מחכה
                                                       // שהישן ייעצר, שני עובדים
                                                       // היו רצים זה לצד זה
        thread_ = std::jthread([this](std::stop_token st) {
            try {
                while (!st.stop_requested()) {
                    if (auto item = queue_.Pop(st)) {   // מתעורר גם בבקשת עצירה
                        try {
                            Process(*item, st);          // להעביר st גם לעבודה שיכולה לחסום בפנים
                        } catch (...) {
                            ReportError(std::current_exception());  // לרשום כישלון בודד ולהמשיך
                        }
                    }
                }
            } catch (...) {
                // קו ההגנה האחרון בגבול התהליכון (תופס גם כשלונות
                // ב-Pop או בהעברה). אם חריגה בורחת מכאן, std::terminate
                // מפיל את כל התהליך, לכן ודאו ש-ReportError עצמו לעולם לא זורק
                ReportError(std::current_exception());
            }
        });
    }
    // אין צורך ב-Stop מפורש:
    // המפרק של Worker -> המפרק של jthread -> request_stop() + join()
private:
    BlockingQueue<WorkItem> queue_{100};   // קיבולת מוגבלת (סעיף 4)
    std::jthread thread_;
};
בקשת עצירההצד שעוצר- המפרק של jthread, או request_stopstop_tokenלולאת החישוב:סוקרת stop_requested()תהליכון ממתין:condition_variable_any::wait(lock, st, pred)מתעורר מידמנקה וחוזר בעצמוjoin משלים את המפגשרק עכשיו אפשר לקרוא לזה נעצר

איור 4: עצירה שיתופית ב-C++20. הצד שעוצר רק מוציא את הבקשה; התהליכון עצמו מחליט איך הוא נגמר; השלמת ה-join היא מה שנחשב נעצר

נקודה נוספת: אי אפשר להשמיט את ה-try/catch בתוך העובד. מה ש-jthread הופך לבטוח לחריגות הוא ה-join, ורק ה-join — אם חריגה בורחת מפונקציית התהליכון, std::terminate מפיל את התהליך, בדיוק כמו ב-std::thread. החליטו במפורש, בגבול התהליכון, איך לטפל בכישלון של יחידת עבודה אחת (לרשום ולהמשיך, או לדווח לבעלים בערוץ שגיאה).

מאותה סיבה, שימו לב שגם ל-Process מועבר ה-stop_token. אם עיבוד יחידת עבודה אחת חוסם בפנים (המתנה לרשת, חישוב ארוך וכן הלאה) והנקודה הזו לא יכולה לראות את בקשת העצירה, ה-join המשתמע של המפרק יחכה לנצח שאותו פריט אחד ייגמר. עצירה שיתופית מחזיקה רק אחרי שהאסימון הגיע לכל מקום שמחכה. אם העבודה כוללת קריאה חיצונית שאי אפשר להפסיק, צרפו פסק זמן ושמו תקרה לכמה זמן פריט אחד מותר לרוץ.

בסביבות שלפני C++17, בונים את אותה צורה ביד עם דגל עצירה std::atomic<bool> ועוד notify_all של condition_variable. הנקודה כאן היא לקפל את בדיקת דגל העצירה לפרדיקט של משתנה התנאי — אם רק מרימים את הדגל ושוכחים להודיע, תהליכון ממתין לעולם לא יתעורר.

7. חששות ייחודיים ל-Windows — הגבול עם ממשק Win32

7.1. בחירה בין הספרייה התקנית לאובייקטי סנכרון של Win32

התיעוד של Microsoft ממליץ על std::mutex / std::shared_mutex לקוד C++ ששם דגש על ניידות, וממקם את אובייקטי הסנכרון של Win32 כ«כשצריך ממשק המתנה של Win32» ו«סנכרון בין תהליכים».8

מצב בחירה
הדרה תוך-תהליכית רגילה std::mutex + RAII (ברירת מחדל)
קריאות רבות, כתיבות נדירות std::shared_mutex
המתנה לכמה אובייקטים יחד עם WaitForMultipleObjects אובייקטי ליבה של Win32 כמו אירועים ו-mutex
הדרה / הודעה בין תהליכים mutex, אירועים, סמפורים בעלי שם
נעילה תוך-תהליכית עם ממשק Win32 ישירות מנעולי SRW (CRITICAL_SECTION רק כשצריך רקורסיה)8

לתכנון הקונקרטי של הדרת גישה לזיכרון משותף בין תהליכים, ראו «המלכודות של הזיכרון המשותף ושיטות העבודה המומלצות בפועל».

7.2. אל תיגעו בתהליכונים בתוך DllMain

אילוץ רציני בכתיבת DLL הוא מנעול הטוען. DllMain נקרא בזמן שמנעול הטוען מוחזק, לכן פעולות בתוכו כמו סנכרון עם תהליכון אחר, המתנה לסיום תהליכון, או קריאה ל-LoadLibrary גורמות לקיפאון או להתנהגות בלתי צפויה. העבירו כל אתחול שמתחיל או עושה join לתהליכונים מחוץ ל-DllMain, לפונקציית אתחול מפורשת.13

7.3. תהליכון ה-UI ודירות COM

ליישומי שולחן עבודה של Windows יש אילוץ חזק שחל בלי קשר לשפה: רק התהליכון שיצר חלון או פקד — תהליכון ה-UI — רשאי לגעת בו. Windows מוסר הודעות חלון לתור ההודעות של התהליכון שיצר את החלון, לכן יצירה ותפעול של ה-UI חייבים להתרכז באותו תהליכון. כשרוצים לעדכן את המסך מתהליכון עובד, אל תיגעו ישירות — בקשו מתהליכון ה-UI ב-PostMessage (אסינכרוני), וטפלו בפרוצדורת החלון בצד תהליכון ה-UI. קריאה לצורה הסינכרונית, SendMessage, בזמן שתהליכון ה-UI מחכה שהעובד ההוא ייגמר גורמת לקיפאון שבו כל אחד מחכה לשני, לכן עשו את הצורה האסינכרונית לברירת מחדל להודעות מעובד. STA/MTA, כש-COM מעורב, מכוסה ב«ידע בסיסי על STA/MTA ב-COM — מודל השרשור ואיך נמנעים מתקיעה». שימו לב גם שבקוד C++/CLI שמקומפל עם /clr, כותרות תהליכון תקניות כמו <thread> ו-<mutex> חסומות.14

8. אימות וניפוי שגיאות — להתכונן מתוך הנחה שזה לא ישתחזר

אי אפשר להסתמך על בדיקות כדי למצוא באגי מרוץ, כי בדיקה רגילה סופרת הרצה ש«במקרה לא רצה» כהצלחה. חשבו את ההכנה בשלוש שכבות.

קו ההגנה הראשון הוא עקרונות התכנון שכוסו עד כאן, בדיוק כפי שהם. בסקירה, אשרו בטבלה: אילו נתונים משתנים משותפים, איזה mutex מגן על כל פיסה, האם סדר הרכישה של כמה מנעולים ייחודי (או שהם נלקחים יחד עם scoped_lock), ואיפה נתיב העצירה. תכנון שאי אפשר לכתוב את הטבלה הזו עבורו אינו גמור, כמה טוב שהוא רץ כרגע.

שנית, עשו מצבים חריגים ניתנים לצפייה במקום להסתיר אותם. צרפו פסק זמן עם try_lock_for של timed_mutex או wait_for של condition_variable לכל מנעול שלעולם לא אמור להיכשל ברכישה, ורשמו פסק זמן כחריגה — זה הופך תקיעה נצחית לכישלון שניתן לגלות. תמיד רשמו חריגות שנתפסו ב-try/catch של גבול התהליכון (סעיף 6). כשתקיעה או קריסה קורות בשטח, לכדו dump, בדקו את המחסנית של כל תהליכון, וחפשו אם המתנות המנעולים שלהם יוצרות מעגל. הקמת dump ורישום מכוסה ב«תכנון שמירת יומנים ו-dump בקריסת יישום Windows».

שלישית, נערו דברים תחת עומס. ריצה ארוכה עם מקביליות גדולה ממספר הליבות, ערבוב סדר עיבוד, והזרקת השהיות מלאכותיות הן טכניקות מבחן-לחץ מעשיות שמקלות לפגוע ב«זכייה» של מרוץ במכונת פיתוח. באגים שנעלמים בגרסת ניפוי לעיתים קרובות משתחזרים ברצון בגרסת הפצה ממוטבת תחת עומס כבד.

9. סיכום — רשימת הבדיקה של C++

ערמו בדיקות ייחודיות ל-C++ על העקרונות המשותפים לכל שפה: לא ליצור תהליכונים ישירות, למזער מצב משתנה משותף, התאמה אחד-לאחד בין מנעולים לנתונים, ועצירה שיתופית.

  1. האם std::thread משמש חשוף (יכול להיות jthread? האם join מובטח גם בנתיב החריגה?)
  2. האם detach() אינו בשימוש?
  3. האם לכידות הלמבדה מפורשות, והאם משתנה שנלכד בהפניה חי מעבר לתהליכון?
  4. אפשר לומר בביטחון שאין אף גישה משתנה משותפת לא מסונכרנת (= התנהגות לא מוגדרת) בשום מקום?
  5. אין lock() / unlock() שנכתב ביד, ומנעולים מרובים נלקחים יחד עם scoped_lock?
  6. כל condition_variable::wait משמש עם פרדיקט?
  7. volatile אינו משמש לדגל משותף (האם זה std::atomic במקומו)?
  8. נתיב העצירה מתוכנן סביב stop_token (או דגל אטומי ועוד הודעה), עם השלמת ה-join שמאשרת את המפגש?
  9. ה-future מ-std::async אינו מושלך?
  10. DllMain חופשי מהפעלה, סנכרון או join של תהליכונים?

C++ רב־תהליכוני הוא עבודה שנעשית בהליכה ממש ליד שפת הצוק של התנהגות לא מוגדרת, אבל הפכו זאת וזה אומר שהליכה כנה עם RAII ועם מוסכמות הספרייה התקנית שמה מרחק אמיתי ביניכם לבין השפה הזו. jthread, scoped_lock, wait בצורת פרדיקט, atomic — בחירת ברירות המחדל הנכונות בין הכלים האלה היא, ב-C++, עצם תרגול עקרונות התכנון.

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

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

KomuraSoft LLC מטפלת בסקירות תכנון רב־תהליכוני ליישומים ול-DLL ב-C++, בחקירת שורש (ניתוח dump) של באגי תנאי מרוץ כמו «קורס מדי פעם» או «מתנהג לא נכון רק בגרסת הפצה», ובייעוץ על העברת קוד תהליכונים ישן ל-C++ מודרני.

מקורות

  1. ISO C++, C++ Core Guidelines - CP: Concurrency and parallelism. על כך ש-CP.1 (הניחו שהקוד שלכם ירוץ כחלק מתוכנית רב־תהליכונית) ו-CP.2 (הימנעו ממרוצי נתונים) מוצגים ככללי הפתיחה של פרק המקביליות והמקביליות; על כך שאף ערובה אינה מחזיקה ברגע שיש מרוץ נתונים; ועל כך שכללי התכנון לקוד מקבילי — היקף החזקת המנעולים, השימוש ב-RAII וכן הלאה — מסודרים שם במערכת.  2

  2. cppreference.com, std::jthread. על כך ש-jthread של C++20 שונה מ-std::thread בכך שהמפרק שלו קורא אוטומטית ל-request_stop() ואז עושה join; על כך שאפשר לקבל std::stop_token כארגומנט המוביל של פונקציית התהליכון; ועל כך שזה מבטיח גם את ה-join וגם את בקשת העצירה גם כשנזרקת חריגה.  2

  3. Microsoft Learn, Microsoft C/C++ language conformance by Visual Studio version. על כך ש-P0660R10 (<stop_token> ו-jthread) ו-P1135R6 (ספריית הסנכרון של C++20) נתמכים מ-Visual Studio 2019 16.9; ועל מצב התמיכה לפי גרסה של תכונות ספריית C++ התקנית.  2 3

  4. Microsoft Learn, scoped_lock Class. על כך ש-scoped_lock של C++17 רוכש mutex אחד או יותר בבנייה ומשחרר אותם במפרק; על כך שכמה mutex, כשמועברים יחד, נרכשים באלגוריתם הימנעות מקיפאון שקול ל-std::lock; על כך שהוא משחרר באמינות גם אם נזרקת חריגה; ועל כך ש-lock_guard/unique_lock גם הם אפשרות כשמעורב mutex בודד.  2 3

  5. Microsoft Learn, <atomic>. על כך שפעולות אטומיות אינן ניתנות לחלוקה, כך שתהליכונים אחרים יכולים לצפות רק במצב לפני הפעולה או אחריה; על כך שעל בסיס ארגומנט memory_order נקבעות דרישות סדר לגבי נראות של פעולות אטומיות אחרות, ואופטימיזציות מהדר שיפרו אותן מדוכאות; על כך ש-atomic_flag תמיד חופשי ממנעול; ועל כך שהכותרת הזו חסומה תחת /clr:pure.  2

  6. Microsoft Learn, <condition_variable>. על כך שהמתנה על משתנה תנאי דורשת mutex, כשהמנעול משוחרר למשך ההמתנה; על כך שקיימות התעוררויות ספונטניות — התעוררות בלי הודעה — לכן הצד הממתין צריך לבדוק מחדש את התנאי במפורש בחזרה, וצורת הפרדיקט wait(lock, pred) מבצעת את הלולאה הזו בשבילכם; ועל כך ש-condition_variable_any ניתן לשילוב עם כל טיפוס mutex.  2

  7. Microsoft Learn, <future>. על כך שהמפרקים של future ו-shared_future ככלל אינם חוסמים, עם החריג היחיד שה-future (או ה-shared_future האחרון) הקשור למשימה שהושקה ב-std::async חוסם עד שהמצב המשותף הופך ready אם המפרק שלו רץ בזמן שהמשימה עדיין לא הושלמה — התנהגות שמצוינת במפורש בתקן.  2

  8. Microsoft Learn, About Synchronization. על הנחיות לבחירת פרימיטיבי סנכרון של Win32: std::mutex / std::shared_mutex ו-RAII מומלצים לקוד C++ ששם דגש על ניידות; אובייקטי סנכרון של Win32 משמשים כשצריך ממשק המתנה של Win32 או סנכרון בין תהליכים; ברירת המחדל לקוד תוך-תהליכי חדש היא מנעול SRW, עם CRITICAL_SECTION שמור לכשצריך רכישה רקורסיבית; ושימוש ב-Mutex לסנכרון תוך-תהליכי הוא «טעות נפוצה» כי תמיד כרוך במעבר ליבה.  2 3

  9. cppreference.com, std::thread::~thread. על כך שהמפרק של std::thread קורא ל-std::terminate אם הוא נקרא בזמן שהתהליכון עדיין joinable (לא נעשה join ולא detach) — כלומר, על כך שההחלטה על join או detach חייבת להיסגר לפני שהאובייקט נהרס, בלי יוצא מן הכלל. 

  10. Microsoft Learn, Best Practices in the Parallel Patterns Library. על כך שמקביליות אידיאלית צריכה להיות מבוטאת ברמה גבוהה ככל האפשר (הלולאה החיצונית); על כך שתקורה של תזמון fork/join יכולה לעלות על רווחי הביצוע המקבילי בלולאות מקביליות שבהן העבודה בכל איטרציה קטנה או לא מאוזנת; ועל כך שהנטייה הזו מתחזקת ככל שמספר המעבדים גדל. 

  11. Microsoft Learn, Microsoft C/C++ language conformance by Visual Studio version. על כך שספריית האלגוריתמים המקביליים של C++17 שלמה, בעוד «שלמה» אינה אומרת שכל אלגוריתם מקביל בכל מקרה; על מדיניות ההטמעה למקבל את האלגוריתמים החשובים ביותר ועדיין לספק חתימות מדיניות ביצוע לאלה שאינם. 

  12. Microsoft Learn, C++ standard library header files. על כך שכותרות התקן הקשורות לרב־תהליכוניות מסודרות כ-<atomic> (C++11), <mutex> (C++11), <shared_mutex> (C++14), <condition_variable> (C++11), <future> (C++11), <stop_token> / <semaphore> / <latch> / <barrier> (C++20), ו-<thread> (C++11). 

  13. Microsoft Learn, Dynamic-Link Library Best Practices. על כך ש-DllMain נקרא בזמן שמנעול הטוען מוחזק, ומטיל אילוצים רציניים על אילו ממשקים מותר לקרוא; על כך שסנכרון עם תהליכון אחר בתוך DllMain יכול להיכנס לקיפאון; על כך שקריאה ל-LoadLibrary או המתנה לסיום תהליכון הן פעולות אסורות טיפוסיות; על כך שאתחול אידיאלית צריך להידחות רחוק ככל האפשר ולהיות מועבר מחוץ ל-DllMain; ועל כך שיש להגדיר היררכיית מנעולים עם מנעול הטוען בראש. 

  14. Microsoft Learn, <thread>. על כך שכותרת <thread> מגדירה את מחלקת thread ופונקציות עזר כמו sleep_for; על כך שהכותרת הזו חסומה בקוד שמקומפל עם /clr; ועל כך שהמאקרו STDCPP_THREADS מאפשר לקבוע אם יש תמיכה בתהליכונים. 

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

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

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

שאלות נפוצות

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

איך לבחור בין std::mutex לבין CRITICAL_SECTION / מנעולי SRW של Win32?
לקוד C++ רגיל ששם דגש על ניידות, std::mutex / std::shared_mutex יחד עם עטיפות RAII (lock_guard / scoped_lock) הם הבחירה הראשונה. פונים לאובייקטי סנכרון של Win32 כשצריך לשלב אותם עם ממשק המתנה של Win32 כמו WaitForMultipleObjects, או כשצריך סנכרון בין תהליכים דרך אובייקט בעל שם. אם משתמשים בממשק Win32 ישירות בתוך תהליך, ברירת המחדל לקוד חדש היא מנעול SRW, ו-CRITICAL_SECTION רק כשאותו תהליכון צריך לרכוש אותו בצורה רקורסיבית. שימוש ב-Mutex של Win32 להדרה תוך-תהליכית הוא טעות קלאסית, כי תמיד כרוך במעבר ליבה ולכן איטי בהתאם.
מותר להשתמש ב-detach() של std::thread?
ככלל, להימנע. תהליכון שנותק מאבד כל אמצעי ל-join, ומאבדים שליטה אם הוא עדיין רץ כשהתהליך יוצא. זו תאונה קלאסית: תהליכון מנותק ממשיך לרוץ אחרי שהמשתנים הסטטיים או הערימה נהרסו, וגורם לקריסה בסגירה. היכולת לחכות לסיום תהליכון היא דרישת יסוד בתכנון תהליכונים, לכן משתמשים ב-jthread (שעושה join אוטומטית), או, אם משתמשים ב-thread, מבנים את הקוד כך שיעשה תמיד join לפני סוף ההיקף. detach מותר רק במצב הצר שבו התהליכון יכול לחלוק את גורל התהליך ואפשר להבטיח שהוא כלל לא נוגע במצב משותף.
אפשר להשתמש ב-volatile לסנכרון בין תהליכונים ב-C++?
לא. volatile של C++ הוא מגדיר לקריאות וכתיבות שלא רוצים שהמהדר יבטל באופטימיזציה — למשל קלט/פלט ממופה לזיכרון — והוא לא מבטיח נראות או סדר בין תהליכונים. אם כמה תהליכונים ניגשים לאותו משתנה בלי סנכרון, זה מרוץ נתונים, וזו התנהגות לא מוגדרת. משתמשים ב-std::atomic לדגלים ומונים משותפים בין תהליכונים, וב-std::mutex כשצריך להגן על כמה משתנים יחד. std::atomic מספק גם אטומיות של הפעולה וגם סדר מבוסס memory_order.
std::async נראה נוח, אבל יש מלכודות?
המלכודת הגדולה ביותר היא המפרק של future. ה-future (או ה-shared_future האחרון) הקשור למשימה שהושקה ב-std::async חוסם עד השלמה אם המפרק שלו רץ בזמן שהמשימה עדיין לא הושלמה. אם משליכים את ה-future המוחזר בלי להחזיק אותו, זה הופך שקול לביצוע סינכרוני במקום — תאונה שבה התכוונתם להיות אסינכרוניים וסיימתם סדרתיים. גם, אם לא מציינים מדיניות השקה, האם העבודה באמת רצה בתהליכון נפרד נשאר לשיקול ההטמעה. אם משתמשים, מנהלים במפורש את חיי ה-future, ומציינים std::launch::async בכל מקום שצריך להבטיח ביצוע מקבילי.

פרופיל הכותב

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

Go Komura

מנהל KomuraSoft LLC

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

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

חזרה לבלוג