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

· עודכן בתאריך: · · Windows, multithreading, C++, Visual Studio, יישומים עסקיים, חקירת תקלות, תכנון

היסטוריית עדכונים (גרסה ראשונה, פורסמה בתאריך 2 Aug 2026)
פרסום ראשון
לצטט את המאמר הזה(DOI: 10.5281/zenodo.22175858)

מאמר זה מאוחסן בארכיון Zenodo. להלן גם ה-DOI שתמיד מפנה לגרסה האחרונה וגם ה-DOI המקובע לגרסה שאתם קוראים.

Go Komura (2026). שיטות עבודה מומלצות ל-multithreading בפועל: מהדורת C++ — לבטל תאונות במבנה עם RAII ו-jthread. KomuraSoft LLC. https://doi.org/10.5281/zenodo.22175858 https://comcomponent.com/he/blog/multithreading-best-practices-cpp/

DOI (הגרסה האחרונה)
10.5281/zenodo.22175858
DOI (הגרסה הזו)
10.5281/zenodo.22175859

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

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

1. העיקר קודם

  • ב-C++, data race אינו «אולי תקראו ערך מקולקל» — זו undefined behavior. לא להשאיר אף גישה shared mutable לא מסונכרנת בקוד הוא דרישה מוחלטת, יותר מאשר בשפות אחרות.1
  • אל תשתמשו ב-std::thread חשוף. אם ה-destructor של std::thread רץ בזמן שה-thread עדיין joinable, std::terminate הורג את ה-process מיד. std::jthread של C++20 עושה join אוטומטית ב-destructor ויש לו מנגנון בקשת עצירה (stop_token) מובנה.23
  • החזיקו locks תמיד דרך RAII. הפסיקו לכתוב mtx.lock() ביד; השתמשו ב-lock_guard / scoped_lock. ה-destructor משחרר את ה-lock באמינות גם אם נזרקת exception. כשרוכשים כמה locks יחד, scoped_lock מטפל בזה באלגוריתם הימנעות מ-deadlock.4
  • volatile אינו כלי סנכרון. השתמשו ב-std::atomic לדגלים ומונים משותפים, וב-std::mutex להגן על כמה משתנים יחד. std::atomic מספק גם atomicity וגם סדר מבוסס memory_order.5
  • עשו wait בצורת ה-predicate של wait של condition_variable. condition variables חשופים ל-spurious wakeup (התעוררות בלי notification), ולכן קריאה ל-wait בלי predicate היא כר לגידול באגים.6
  • jthread + stop_token (C++20) הוא הצורה הבסיסית לאיך עוצרים thread. בסביבות שלפני כן בונים עצירה שיתופית ביד עם std::atomic<bool> ועוד condition variable. התייחסו ל-forced termination של thread כאל דבר שפשוט לא קיים בעולם C++.3
  • דעו שה-destructor של future יכול לחסום לפני שמשתמשים ב-std::async. זורקים את ערך ההחזרה ותקבלו את אותו אפקט של ביצוע serial.7
  • אובייקטי סנכרון של Win32 ראויים למקומם רק לתרחישי «עבודה עם wait API של Win32» ו«בין processes». בכל מקום אחר, כתיבה מול הספרייה התקנית עדיפה ל-portability ולתחזוקה.8

2. למה multithreading קשה — race condition, deadlock ו-undefined behavior

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

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

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

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

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

ממתין לשחרור lock 2ממתין לשחרור lock 1thread Aמחזיק lock 1thread Bמחזיק lock 2

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

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

2.1. ב-C++, data race הוא ישירות undefined behavior

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

2.2. RAII הוא היסוד

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

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

3.1. ה-destructor של std::thread «תוכנן לגרום לתאונות»

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

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

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

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

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

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

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

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

  • std::async + std::future: למשימה asynchronous חד-פעמית ולקבלת התוצאה. יש, עם זאת, ייחוד חשוב: ה-future (או ה-shared_future האחרון) שקשור למשימה שהושקה דרך std::async חוסם עד השלמה אם ה-destructor שלו רץ בזמן שהמשימה עדיין לא הושלמה.7 לעבודה שהושקה בפועל עם std::launch::async, זריקת ה-future המוחזר שקולה לביצוע סינכרוני בנקודה הזו. גרוע מזה, אם לא מציינים launch policy, ה-implementation חופשי לבחור deferred (lazy execution) כברירת מחדל, ואז, אם אף אחד לא קורא ל-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 שימו לב שאם exception בורחת מעיבוד איבר תחת execution policy, נקרא std::terminate. הצבת גבול exception משלכם (try/catch) בתוך ה-callback הולכת באותו חשיבה כמו גבול ה-thread בסעיף 6.

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

4. צמצום shared mutable state — פיצול, העברה לפי ערך, const ותורים

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

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

העבירו לפי ערך. אם מוסרים ל-thread את הנתונים שהוא צריך בהעתקה (או move) בהפעלה, הנתונים האלה הופכים בלעדיים ל-thread, ואין צורך בסנכרון. לכידת lambdas לפי הפניה ([&]) ואז נגיעה במשתנה שחיי הסתיימו היא תאונה נפוצה, לכן lambdas שמועברות ל-threads צריכות לכידות מפורשות, לפי copy או move ככלל. עם זאת, «הועתק, לכן בלעדי» מחזיק רק כשהערך הוא גרף ערכים עמוק שאינו מכיל aliases כמו pointers או shared_ptr. העתקת מבנה שמכיל raw pointer עדיין משאירה את מה שהוא מצביע עליו משותף.

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

מסרו דרך תור. נתבו את זרימת הנתונים בין threads דרך תור producer/consumer במקום משתנה משותף. לתקן C++ אין טיפוס channel, לכן כתיבת תור קטן עם 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();   // להודיע מחוץ ל-lock
        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_;
};

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

5. משמעת locks —‏ RAII ו-scoped_lock

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

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

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

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

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

כשיש שני locks או יותר, החלפת סדר הרכישה לפי ה-thread היא דפוס ה-deadlock הקלאסי (ההמתנה המעגלית באיור 2 נולדת בדיוק כך). התיקון הוא כלל ש«כל thread רוכש locks באותו סדר», אבל כשרוכשים אותם באותו רגע, ל-C++ יש תשובה טובה יותר: מסרו כמה mutex יחד ל-std::scoped_lock והספרייה מבטיחה סדר רכישה חופשי מ-deadlock.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 לא-recursive ל-scoped_lock פעמיים, מה שגורם ל-hang או ל-undefined behavior. תמיד צרפו הדרת אותו-אובייקט לכל פונקציה ש«נועלת את שניהם».

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

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

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

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

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

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

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

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

class Worker {
public:
    void Start()
    {
        if (thread_.joinable())                       // לדחות Start כפול בזמן שכבר רץ.
            throw std::logic_error("already running"); // אם היינו מקצים במקום לדחות, thread חדש
                                                       // היה מתחיל לרוץ, ובזמן שהוא מחכה
                                                       // שהישן ייעצר, שני workers
                                                       // היו רצים זה לצד זה
        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 (...) {
                // קו ההגנה האחרון בגבול ה-thread (תופס גם כשלונות
                // ב-Pop או ב-move). אם exception בורחת מכאן, std::terminate
                // מפיל את כל ה-process, לכן ודאו ש-ReportError עצמו לעולם לא זורק
                ReportError(std::current_exception());
            }
        });
    }
    // אין צורך ב-Stop מפורש:
    // ה-destructor של Worker -> ה-destructor של jthread -> request_stop() + join()
private:
    BlockingQueue<WorkItem> queue_{100};   // קיבולת מוגבלת (סעיף 4)
    std::jthread thread_;
};
בקשת עצירההצד שעוצר- ה-destructor של jthread, או request_stopstop_tokenלולאת החישוב:סוקרת stop_requested()thread ממתין:condition_variable_any::wait(lock, st, pred)מתעורר מידמנקה וחוזר בעצמוjoin משלים את המפגשרק עכשיו אפשר לקרוא לזה נעצר

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

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

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

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

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

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

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

מצב בחירה
הדרה רגילה בתוך process std::mutex + RAII (ברירת מחדל)
קריאות רבות, כתיבות נדירות std::shared_mutex
המתנה לכמה אובייקטים יחד עם WaitForMultipleObjects kernel objects של Win32 כמו events ו-mutex
הדרה / הודעה בין processes named mutex, events, semaphores
נעילה בתוך process עם Win32 API ישירות SRW locks (CRITICAL_SECTION רק כשצריך recursion)8

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

7.2. אל תיגעו ב-threads בתוך DllMain

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

7.3. UI thread ו-COM apartments

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

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

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

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

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

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

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

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

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

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

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

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

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

מקורות

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

  2. cppreference.com, std::jthread. על כך ש-jthread של C++20 שונה מ-std::thread בכך שה-destructor שלו קורא אוטומטית ל-request_stop() ואז עושה join; על כך שאפשר לקבל std::stop_token כארגומנט המוביל של פונקציית ה-thread; ועל כך שזה מבטיח גם את ה-join וגם את בקשת העצירה גם כשנזרקת exception. ↩ ↩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 אחד או יותר בבנייה ומשחרר אותם ב-destructor; על כך שכמה mutex, כשמועברים יחד, נרכשים באלגוריתם הימנעות מ-deadlock שקול ל-std::lock; על כך שהוא משחרר באמינות גם אם נזרקת exception; ועל כך ש-lock_guard/unique_lock גם הם אפשרות כשמעורב mutex בודד. ↩ ↩2 ↩3

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

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

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

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

  9. cppreference.com, std::thread::~thread. על כך שה-destructor של std::thread קורא ל-std::terminate אם הוא נקרא בזמן שה-thread עדיין 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 שלמה, בעוד «שלמה» אינה אומרת שכל אלגוריתם מקביל בכל מקרה; על מדיניות ה-implementation למקבל את האלגוריתמים החשובים ביותר ועדיין לספק חתימות execution policy לאלה שאינם. ↩

  12. Microsoft Learn, C++ standard library header files. על כך שכותרות התקן הקשורות ל-multithreading מסודרות כ-<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 נקרא בזמן ש-loader lock מוחזק, ומטיל אילוצים רציניים על אילו APIs מותר לקרוא; על כך שסנכרון עם thread אחר בתוך DllMain יכול להיכנס ל-deadlock; על כך שקריאה ל-LoadLibrary או המתנה לסיום thread הן פעולות אסורות טיפוסיות; על כך שאתחול אידיאלית צריך להידחות רחוק ככל האפשר ולהיות מועבר מחוץ ל-DllMain; ועל כך שיש להגדיר היררכיית locks עם loader lock בראש. ↩

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

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

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

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

שאלות נפוצות

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

איך בוחרים בין std::mutex לבין CRITICAL_SECTION / SRW lock של Win32?
בקוד C++ רגיל ששם דגש על portability, std::mutex / std::shared_mutex יחד עם עטיפות RAII (lock_guard / scoped_lock) הם המועמד הראשון. פונים לאובייקטי סנכרון של Win32 כשצריך לשלב אותם עם wait API של Win32 כמו WaitForMultipleObjects, או כשצריך סנכרון בין processes דרך named object. אם משתמשים ב-Win32 API ישירות בתוך process, ברירת המחדל לקוד חדש היא SRW lock, ו-CRITICAL_SECTION רק כשאותו thread צריך לרכוש אותו בצורה recursive. שימוש ב-Mutex של Win32 להדרה בתוך process הוא טעות קלאסית, כי תמיד כרוך ב-kernel transition ולכן איטי בהתאם.
מותר להשתמש ב-detach() של std::thread?
ככלל, להימנע. thread שעבר detach מאבד כל אמצעי ל-join, ומאבדים שליטה אם הוא עדיין רץ כשה-process יוצא. זו תאונה קלאסית: detached thread ממשיך לרוץ אחרי שמשתנים סטטיים או ה-heap נהרסו, וגורם לקריסה בסגירה. היכולת לחכות לסיום thread היא דרישת יסוד בתכנון threads, לכן משתמשים ב-jthread (שעושה join אוטומטית), או, אם משתמשים ב-thread, בונים את הקוד כך שיעשה תמיד join לפני סוף ה-scope. detach מותר רק במצב הצר שבו ה-thread יכול לחלוק את גורל ה-process ואפשר להבטיח שהוא כלל לא נוגע ב-shared state.
אפשר להשתמש ב-volatile לסנכרון בין threads ב-C++?
לא. volatile של C++ הוא qualifier לקריאות וכתיבות שלא רוצים שה-compiler יבטל באופטימיזציה — למשל memory-mapped I/O — והוא לא מבטיח visibility או סדר בין threads. אם כמה threads ניגשים לאותו משתנה בלי סנכרון, זה data race, וזו undefined behavior. משתמשים ב-std::atomic לדגלים ומונים משותפים בין threads, וב-std::mutex כשצריך להגן על כמה משתנים יחד. std::atomic מספק גם atomicity של הפעולה וגם סדר מבוסס memory_order.
std::async נראה נוח, אבל יש מלכודות?
המלכודת הגדולה ביותר היא ה-destructor של future. ה-future (או ה-shared_future האחרון) שקשור למשימה שהושקה ב-std::async חוסם עד השלמה אם ה-destructor שלו רץ בזמן שהמשימה עדיין לא הושלמה. אם זורקים את ה-future המוחזר בלי להחזיק אותו, זה הופך שקול לביצוע סינכרוני במקום — תאונה שבה התכוונתם להיות asynchronous וסיימתם serial. גם, אם לא מציינים launch policy, האם העבודה באמת רצה ב-thread נפרד נשאר לשיקול ה-implementation. אם משתמשים, מנהלים במפורש את חיי ה-future, ומציינים std::launch::async בכל מקום שצריך להבטיח ביצוע מקבילי.

פרופיל הכותב

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

Go Komura

מנהל KomuraSoft LLC

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

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

חזרה לבלוג