דירות תהליכונים וכניסה חוזרת¶
המספר של הthread נשאר זהה בכל שורת log, ובכל זאת לקוח רואה מצב שאמור להיות בלתי אפשרי. איך זה יכול לקרות? עד עכשיו חיפשנו race בין שני threadים. עכשיו נבדוק מצב שבו קריאה שנמצאת באמצע העבודה מפעילה קוד אחר, והקוד הזה נכנס בחזרה לאותו אובייקט.
השם של זה הוא reentrancy. הוא חשוב במיוחד בUI ובCOM, אבל הוא אינו שייך רק להם. כל callback הוא מקום שבו צריך לשאול מה מותר לקוד שמולנו לעשות.
מה apartment מבטיח¶
הבחירה בSTA משייכת אובייקטים למודל שבו קריאות שמגיעות דרך COM מנותבות לthread של הapartment. כדי שהן יתקדמו, אותו thread צריך לאפשר עיבוד הודעות. יכולים להיות כמה STAים בתהליך, כל אחד עם thread משלו. הבחירה בMTA מאפשרת לכמה threadים לפעול באותו apartment, ולכן האובייקטים צריכים להתמודד עם כניסה מקבילה.
הבחירה נעשית לכל thread באמצעות CoInitializeEx. היא אינה דגל גלובלי שהופך את כל התוכנית לthread safe. גם thread של UI שנוצר על ידי framework יכול כבר להיות מאותחל לפני שהקוד שלנו מגיע אליו. את החוזה בודקים דרך HRESULT, ולא על פי שם הפונקציה שיצרה את הthread.
ניסוי ראשון - מי אתחל את הthread¶
הקובץ הבא הוא תוכנית console מלאה בשם apartments.cpp. אין בו חלון ואין בו אובייקט שמקבל קריאה מרחוק, ולכן עוד אין צורך בmessage loop. אנחנו בודקים את האתחול בלבד.
#include <windows.h>
#include <objbase.h>
#include <cstdio>
#include <thread>
void report(const char* label, HRESULT hr) {
APTTYPE type{};
APTTYPEQUALIFIER qualifier{};
const HRESULT query = CoGetApartmentType(&type, &qualifier);
std::printf("%s tid=%lu hr=%08lx query=%08lx type=%d qualifier=%d\n",
label, GetCurrentThreadId(), static_cast<ULONG>(hr),
static_cast<ULONG>(query), static_cast<int>(type),
static_cast<int>(qualifier));
}
int main() {
const HRESULT first = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
report("first STA", first);
if (FAILED(first)) return 1;
const HRESULT again = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
report("second STA", again);
const HRESULT incompatible = CoInitializeEx(nullptr, COINIT_MULTITHREADED);
report("try MTA", incompatible);
try {
std::thread worker([] {
const HRESULT hr = CoInitializeEx(nullptr, COINIT_MULTITHREADED);
report("worker MTA", hr);
if (SUCCEEDED(hr)) CoUninitialize();
});
worker.join();
} catch (...) {
if (SUCCEEDED(incompatible)) CoUninitialize();
if (SUCCEEDED(again)) CoUninitialize();
CoUninitialize();
return 2;
}
if (SUCCEEDED(incompatible)) CoUninitialize();
if (SUCCEEDED(again)) CoUninitialize();
CoUninitialize();
}
בתהליך console חדש הקריאה הראשונה אמורה להצליח, השנייה להחזיר S_FALSE, והניסיון לשנות את המודל להחזיר RPC_E_CHANGED_MODE. הקוד משחרר כל אתחול שהצליח, כולל זה שהחזיר S_FALSE. כישלון בשינוי מודל אינו יוצר התחייבות נוספת לשחרור. נבדוק זאת מול חוזה האתחול.
ההמתנה לworker בטוחה בניסוי הזה משום שהworker אינו זקוק לקריאה לSTA הראשי. אם נוסיף לו שימוש בproxy לאובייקט שחי בSTA, אותו join יכול להיות חלק ממעגל המתנה. הבטיחות של המתנה תלויה בתלויות, ולא רק בפונקציה שבחרנו.
ניסוי שני - thread אחד ושני רגעים¶
נשתמש בדוגמה קטנה שאינה COM כדי לבודד את בעיית הכניסה החוזרת. שמרו כקובץ נפרד reentry.cpp. יש לנו שני תאים שסכומם צריך להיות 100 בכל רגע שנחשף ללקוח.
#include <cstdio>
#include <functional>
class Ledger {
int left_ = 100;
int right_ = 0;
public:
int total() const { return left_ + right_; }
void broken_transfer(int n, const std::function<void()>& notify) {
if (n < 0 || n > left_) return;
left_ -= n;
notify();
right_ += n;
}
void transfer(int n, const std::function<void()>& notify) {
if (n < 0 || n > left_) return;
left_ -= n;
right_ += n;
notify();
}
};
int main() {
Ledger broken;
broken.broken_transfer(10, [&] {
std::printf("during broken transfer: %d\n", broken.total());
});
Ledger fixed;
fixed.transfer(10, [&] {
std::printf("during fixed transfer: %d\n", fixed.total());
});
std::printf("after: %d %d\n", broken.total(), fixed.total());
}
הגרסה הראשונה חושפת 90 בזמן הcallback, למרות שבסוף שתי הגרסאות חוזרות ל100. בדיקת מצב רק בסיום הייתה מפספסת את הבעיה. אין כאן data race ואין כאן הרצה מקבילה. זאת נקודה חשובה: גם תוכנית שמופעלת על thread בודד צריכה להגדיר מתי מותר לאחרים לצפות במצב שלה.
בCOM קריאה יוצאת יכולה לאפשר עיבוד קריאות נכנסות בזמן ההמתנה. גם פתיחת dialog או לולאת הודעות פנימית יכולה להכניס קוד נוסף. לכן "אני בSTA" אינו הוכחה שלא ייכנסו שוב לאובייקט עד שהפונקציה תחזור.
למה lock לא תמיד מציל אותנו¶
נניח שאנחנו מחזיקים mutex, קוראים לשרת, והשרת קורא בחזרה למתודה שצריכה את אותו mutex. בmutex שאינו recursive אפשר להיתקע. בrecursive mutex אפשר להיכנס ולראות מצב חלקי. החלפת סוג הנעילה אינה מתקנת את הinvariant.
נתכנן גבול קריאה חיצונית כך שהמצב שלנו יהיה תקין לפניו. אם צריך לפרסם אירוע, נבנה snapshot מוגדר, נשחרר lock ואז נקרא למאזין. אם האירוע יכול לשנות או לסגור את האובייקט, נשמור reference חזק לאורך הקריאה ונבדוק מחדש את המצב לפני המשך עבודה. לא נשמור iterator לתוך container שהמאזין יכול לשנות.
אם ההודעה יכולה לזרוק exception בדוגמת C++, ההעברה בגרסה המתוקנת כבר בוצעה. צריך להגדיר אם כשל ההודעה הוא כשל נפרד או חלק מטרנזקציה. גלגול לאחור אחרי שהמאזין כבר ראה את המצב אינו פתרון כללי. בממשק COM נתרגם חריגות לHRESULT בתוך גבול המימוש, ונשמור את אותה הבחנה בין commit לבין notification.
בונים מעגל המתנה על הנייר¶
הthread של הSTA ממתין לסיום worker. הworker ממתין לתשובה מproxy. התשובה דורשת הרצה בSTA. קיבלנו מעגל, ולכן צריך לשבור לפחות תלות אחת. האפשרות הטובה בUI היא לרוב להתחיל עבודה ולהמשיך בלולאת ההודעות, ואז לטפל בהשלמה מאוחר יותר.
כאשר חייבים להמתין במודל סינכרוני, אפשר להשתמש במנגנון שמאפשר לCOM להתקדם, כגון CoWaitForMultipleHandles עם flags מתאימים. המחיר הוא כניסה חוזרת שצריך לתכנן. לולאת pumping אינה תיקון שאפשר להוסיף בעיניים עצומות באמצע שינוי state.
בשיעור הבא נריץ קריאה אמיתית בין apartments. לפני כן צריך לדעת להסביר שני דברים בנפרד: מי רשאי להריץ את המתודה, ובאיזה מצב האובייקט יכול להיות כאשר אותה מתודה נקראת. את מודל התהליכונים והapartments נקרא עם שתי השאלות האלה מול העיניים.