שיטות עבודה מומלצות לרב־תהליכוניות בפועל: מהדורת C++ — ביטול תאונות במבנה עם RAII ו-jthread
· Go Komura · 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 מתפרק ברמת קוד מכונה לשלושה שלבים — קריאה, חיבור, כתיבה חזרה. אם שני תהליכונים נכנסים לשלושת השלבים האלה באותו זמן, החיבור של תהליכון אחד נדרס ואובד בכתיבה של האחר. התוצאה משתנה מהרצה להרצה, ואיזו תוצאה תקבלו אי אפשר לחזות.
sequenceDiagram
participant A as תהליכון A
participant M as המשתנה המשותף count
participant B as תהליכון B
Note over M: count = 10
A->>M: קריאה (10)
B->>M: קריאה (10)
A->>A: חיבור מקומי (11)
B->>B: חיבור מקומי (11)
A->>M: כתיבה חזרה (11)
B->>M: כתיבה חזרה (11)
Note over M: שני הגדלות קרו,<br/>ועדיין count = 11 — החיבור של תהליכון A אבד
איור 1: תנאי מרוץ קלאסי שבו הגדלה על מונה משותף אובדת. אם תהליכון אחר משתלב בשלושת שלבי ++count, הכתיבה החזרה שמתרחשת אחרונה דורסת את האחרת
קיפאון (deadlock) הוא מצב שבו שני תהליכונים כל אחד מחכה למנעול שהאחר מחזיק, כך שאף אחד לא יכול להתקדם. תהליכון A מחזיק מנעול 1 ומחכה למנעול 2; תהליכון B מחזיק מנעול 2 ומחכה למנעול 1 — זה לבדו מספיק כדי ששניהם ייעצרו לנצח.
flowchart LR
A["תהליכון A<br/>מחזיק מנעול 1"] -->|"מחכה לרכישת מנעול 2"| B["תהליכון B<br/>מחזיק מנעול 2"]
B -->|"מחכה לרכישת מנעול 1"| A
איור 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
flowchart TB
T["התהליכון הופעל"] --> Q{"מה קורה<br/>כשההיקף יוצא?"}
Q -->|"std::thread<br/>לא join ולא detach"| X["std::terminate<br/>התהליך מת מיד"]
Q -->|"std::thread<br/>כבר נעשה join"| OK1["join בבטחה"]
Q -->|"std::jthread - C++20"| OK2["request_stop + join אוטומטיים<br/>בטוח גם אם נזרקת חריגה"]
איור 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_;
};
flowchart TB
OWNER["הצד שעוצר<br/>- המפרק של jthread, או request_stop"] -->|"בקשת עצירה"| ST["stop_token"]
ST --> P["לולאת החישוב:<br/>סוקרת stop_requested()"]
ST --> W["תהליכון ממתין:<br/>condition_variable_any::wait(lock, st, pred)<br/>מתעורר מיד"]
P --> E["מנקה וחוזר בעצמו"]
W --> E
E --> J["join משלים את המפגש<br/>רק עכשיו אפשר לקרוא לזה נעצר"]
איור 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++ על העקרונות המשותפים לכל שפה: לא ליצור תהליכונים ישירות, למזער מצב משתנה משותף, התאמה אחד-לאחד בין מנעולים לנתונים, ועצירה שיתופית.
- האם
std::threadמשמש חשוף (יכול להיותjthread? האם join מובטח גם בנתיב החריגה?) - האם
detach()אינו בשימוש? - האם לכידות הלמבדה מפורשות, והאם משתנה שנלכד בהפניה חי מעבר לתהליכון?
- אפשר לומר בביטחון שאין אף גישה משתנה משותפת לא מסונכרנת (= התנהגות לא מוגדרת) בשום מקום?
- אין
lock()/unlock()שנכתב ביד, ומנעולים מרובים נלקחים יחד עםscoped_lock? - כל
condition_variable::waitמשמש עם פרדיקט? volatileאינו משמש לדגל משותף (האם זהstd::atomicבמקומו)?- נתיב העצירה מתוכנן סביב
stop_token(או דגל אטומי ועוד הודעה), עם השלמת ה-join שמאשרת את המפגש? - ה-
futureמ-std::asyncאינו מושלך? DllMainחופשי מהפעלה, סנכרון או join של תהליכונים?
C++ רב־תהליכוני הוא עבודה שנעשית בהליכה ממש ליד שפת הצוק של התנהגות לא מוגדרת, אבל הפכו זאת וזה אומר שהליכה כנה עם RAII ועם מוסכמות הספרייה התקנית שמה מרחק אמיתי ביניכם לבין השפה הזו. jthread, scoped_lock, wait בצורת פרדיקט, atomic — בחירת ברירות המחדל הנכונות בין הכלים האלה היא, ב-C++, עצם תרגול עקרונות התכנון.
מאמרים קשורים
- שיטות עבודה מומלצות לרב־תהליכוניות בפועל: מהדורת .NET — מה להחליט לפני שמוסיפים תהליכונים
- שיטות עבודה מומלצות לרב־תהליכוניות בפועל: מהדורת C — כתיבה בטוחה בדרך ממשק Win32
- שיטות עבודה מומלצות לרב־תהליכוניות בפועל: מהדורת Java — מוסכמות עידן התהליכונים הווירטואליים
- קריאה מ-C# ל-DLL נייטיב: עטיפת C++/CLI מול P/Invoke
- המלכודות של הזיכרון המשותף ושיטות העבודה המומלצות בפועל
- ידע בסיסי על STA/MTA ב-COM — מודל השרשור ואיך נמנעים מתקיעה
- מעמקי הקלט/פלט של Windows (חלק 2) — קלט/פלט סינכרוני ואסינכרוני: מה OVERLAPPED באמת אומר
תחומי ייעוץ קשורים
KomuraSoft LLC מטפלת בסקירות תכנון רב־תהליכוני ליישומים ול-DLL ב-C++, בחקירת שורש (ניתוח dump) של באגי תנאי מרוץ כמו «קורס מדי פעם» או «מתנהג לא נכון רק בגרסת הפצה», ובייעוץ על העברת קוד תהליכונים ישן ל-C++ מודרני.
מקורות
-
ISO C++, C++ Core Guidelines - CP: Concurrency and parallelism. על כך ש-CP.1 (הניחו שהקוד שלכם ירוץ כחלק מתוכנית רב־תהליכונית) ו-CP.2 (הימנעו ממרוצי נתונים) מוצגים ככללי הפתיחה של פרק המקביליות והמקביליות; על כך שאף ערובה אינה מחזיקה ברגע שיש מרוץ נתונים; ועל כך שכללי התכנון לקוד מקבילי — היקף החזקת המנעולים, השימוש ב-RAII וכן הלאה — מסודרים שם במערכת. ↩ ↩2
-
cppreference.com, std::jthread. על כך ש-jthread של C++20 שונה מ-std::thread בכך שהמפרק שלו קורא אוטומטית ל-request_stop() ואז עושה join; על כך שאפשר לקבל std::stop_token כארגומנט המוביל של פונקציית התהליכון; ועל כך שזה מבטיח גם את ה-join וגם את בקשת העצירה גם כשנזרקת חריגה. ↩ ↩2
-
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
-
Microsoft Learn, scoped_lock Class. על כך ש-scoped_lock של C++17 רוכש mutex אחד או יותר בבנייה ומשחרר אותם במפרק; על כך שכמה mutex, כשמועברים יחד, נרכשים באלגוריתם הימנעות מקיפאון שקול ל-std::lock; על כך שהוא משחרר באמינות גם אם נזרקת חריגה; ועל כך ש-lock_guard/unique_lock גם הם אפשרות כשמעורב mutex בודד. ↩ ↩2 ↩3
-
Microsoft Learn, <atomic>. על כך שפעולות אטומיות אינן ניתנות לחלוקה, כך שתהליכונים אחרים יכולים לצפות רק במצב לפני הפעולה או אחריה; על כך שעל בסיס ארגומנט memory_order נקבעות דרישות סדר לגבי נראות של פעולות אטומיות אחרות, ואופטימיזציות מהדר שיפרו אותן מדוכאות; על כך ש-atomic_flag תמיד חופשי ממנעול; ועל כך שהכותרת הזו חסומה תחת /clr:pure. ↩ ↩2
-
Microsoft Learn, <condition_variable>. על כך שהמתנה על משתנה תנאי דורשת mutex, כשהמנעול משוחרר למשך ההמתנה; על כך שקיימות התעוררויות ספונטניות — התעוררות בלי הודעה — לכן הצד הממתין צריך לבדוק מחדש את התנאי במפורש בחזרה, וצורת הפרדיקט wait(lock, pred) מבצעת את הלולאה הזו בשבילכם; ועל כך ש-condition_variable_any ניתן לשילוב עם כל טיפוס mutex. ↩ ↩2
-
Microsoft Learn, <future>. על כך שהמפרקים של future ו-shared_future ככלל אינם חוסמים, עם החריג היחיד שה-future (או ה-shared_future האחרון) הקשור למשימה שהושקה ב-std::async חוסם עד שהמצב המשותף הופך ready אם המפרק שלו רץ בזמן שהמשימה עדיין לא הושלמה — התנהגות שמצוינת במפורש בתקן. ↩ ↩2
-
Microsoft Learn, About Synchronization. על הנחיות לבחירת פרימיטיבי סנכרון של Win32: std::mutex / std::shared_mutex ו-RAII מומלצים לקוד C++ ששם דגש על ניידות; אובייקטי סנכרון של Win32 משמשים כשצריך ממשק המתנה של Win32 או סנכרון בין תהליכים; ברירת המחדל לקוד תוך-תהליכי חדש היא מנעול SRW, עם CRITICAL_SECTION שמור לכשצריך רכישה רקורסיבית; ושימוש ב-Mutex לסנכרון תוך-תהליכי הוא «טעות נפוצה» כי תמיד כרוך במעבר ליבה. ↩ ↩2 ↩3
-
cppreference.com, std::thread::~thread. על כך שהמפרק של std::thread קורא ל-std::terminate אם הוא נקרא בזמן שהתהליכון עדיין joinable (לא נעשה join ולא detach) — כלומר, על כך שההחלטה על join או detach חייבת להיסגר לפני שהאובייקט נהרס, בלי יוצא מן הכלל. ↩
-
Microsoft Learn, Best Practices in the Parallel Patterns Library. על כך שמקביליות אידיאלית צריכה להיות מבוטאת ברמה גבוהה ככל האפשר (הלולאה החיצונית); על כך שתקורה של תזמון fork/join יכולה לעלות על רווחי הביצוע המקבילי בלולאות מקביליות שבהן העבודה בכל איטרציה קטנה או לא מאוזנת; ועל כך שהנטייה הזו מתחזקת ככל שמספר המעבדים גדל. ↩
-
Microsoft Learn, Microsoft C/C++ language conformance by Visual Studio version. על כך שספריית האלגוריתמים המקביליים של C++17 שלמה, בעוד «שלמה» אינה אומרת שכל אלגוריתם מקביל בכל מקרה; על מדיניות ההטמעה למקבל את האלגוריתמים החשובים ביותר ועדיין לספק חתימות מדיניות ביצוע לאלה שאינם. ↩
-
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). ↩
-
Microsoft Learn, Dynamic-Link Library Best Practices. על כך ש-DllMain נקרא בזמן שמנעול הטוען מוחזק, ומטיל אילוצים רציניים על אילו ממשקים מותר לקרוא; על כך שסנכרון עם תהליכון אחר בתוך DllMain יכול להיכנס לקיפאון; על כך שקריאה ל-LoadLibrary או המתנה לסיום תהליכון הן פעולות אסורות טיפוסיות; על כך שאתחול אידיאלית צריך להידחות רחוק ככל האפשר ולהיות מועבר מחוץ ל-DllMain; ועל כך שיש להגדיר היררכיית מנעולים עם מנעול הטוען בראש. ↩
-
Microsoft Learn, <thread>. על כך שכותרת <thread> מגדירה את מחלקת thread ופונקציות עזר כמו sleep_for; על כך שהכותרת הזו חסומה בקוד שמקומפל עם /clr; ועל כך שהמאקרו STDCPP_THREADS מאפשר לקבוע אם יש תמיכה בתהליכונים. ↩
מאמרים קשורים
מאמרים עדכניים עם אותן תגיות, להעמקה בנושאים קרובים.
פרקטיקות מומלצות לריבוי תהליכונים: מהדורת C — כתיבה בטוחה בדרך של Win32 API
הגישה המבוססת לריבוי תהליכונים ב-C עם Win32 היא יצירת תהליכונים דרך _beginthreadex, מנעולי SRW ומשתני תנאי, פונקציות Interlocked, ועיצוב ...
DllMain ונעילת הטוען — הסיבה האמיתית שאומרים לכם "לא לעשות כלום באתחול DLL"
למה אסור לקרוא ל-LoadLibrary או להסתנכרן עם תהליכונים אחרים מתוך DllMain. המאמר מסביר ממקורות ראשוניים איך נעילת הטוען מסדרת כל הודעת DLL...
התעוררויות ספונטניות — למה משתני תנאי מתעוררים "בלי שהודיעו להם" ואיך להמתין נכון ב-Windows
המתנה על משתנה תנאי יכולה לחזור גם כשלא הגיעה הודעה (התעוררות ספונטנית). המאמר מסביר ממימוש Windows למה המפרט מתיר זאת, ומציג את צורת ההמ...
שיטות עבודה מומלצות למולטי-ת'רדינג בפועל: מהדורת Java — מוסכמות עידן התהליכונים הווירטואליים
ב-Java הנוהג המבוסס במולטי-ת'רדינג הוא לא ליצור תהליכונים ישירות אלא לבנות על ExecutorService ועל תהליכונים וירטואליים. המאמר מציג את העק...
Win32 Thread Pool API — מקביליות בלי ליצור תהליכונים, דרך CreateThreadpoolWork
מפזרים קריאות CreateThread בכל הקוד הנייטיב? המאמר מסביר את ה-API של מאגר התהליכונים של Win32 שעוצב מחדש ב-Vista — ארבעת האובייקטים work,...
נושאים קשורים
העמודים האלה ממקמים את הנושא בהקשר רחב יותר של שירותים והחלטות.
נושאים טכניים ב-Windows
שער לנושאי פיתוח Windows, חקירת תקלות וניצול נכסים קיימים.
שירותים הקשורים לנושא הזה
המאמר קשור ישירות לשירותים הבאים.
פיתוח יישומי Windows
יישומים עסקיים, חיבור התקנים וכלי תקשורת, מהגדרת הדרישות ועד הפיתוח.
שאלות נפוצות
שאלות נפוצות בפניות בנושא המאמר.
- איך לבחור בין 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 בכל מקום שצריך להבטיח ביצוע מקבילי.