ידע בסיסי על STA/MTA ב-COM — מודל השרשור ואיך נמנעים מתקיעה

· עודכן בתאריך: · · COM, פיתוח Windows, STA, MTA, ת'רדים

‏STA/MTA ב-COM הם ידע בסיסי שקשה להימנע ממנו כשעובדים עם Windows או ניגשים ל-COM מתוך ‎.NET. השאלות הנפוצות ביותר בחיפושים הן: למה ת’רד ה-UI הוא STA, מה קורה כשחוצים Apartment, ולמה נוצרת תקיעה.

תוכן עניינים


כשמשתמשים ב-COM, אי אפשר להתעלם מהשאלה “באיזה ת’רד זה רץ”. במרכז הנושא נמצא מודל ה-Apartment (STA/MTA). ‏STA/MTA אינם מושג כללי של ת’רדים ב-Windows, אלא מודל שרשור שקובע את כללי הקריאה לאובייקטי COM.

במאמר הזה נסביר את הקשר בין STA,‏ MTA ו-COM באמצעות תרשימים, ונגיע גם להסבר “למה קורית תקיעה”.

מיקומו של Apartment Modelתרשים המראה ש-STA/MTA אינם מושג כללי של ת'רדים ב-Windows, אלא שתי הצורות של מודל ה-Apartment שקובע את כללי הקריאה לאובייקטי COM.כללי הקריאה לאובייקטי COMמודל ה-ApartmentSTAMTAלא מושג כללי של ת'רדים ב-Windows

איור 1: STA/MTA הן שתי הצורות של מודל ה-Apartment, שקובע את כללי הקריאה לאובייקטי COM.

מפת הידע של המאמר

STA (Single-Threaded Apartment) ו-MTA (Multi-Threaded Apartment) של COM הן שתי הצורות של מודל ה-Apartment, שקובע מאיזה ת’רד מותר לקרוא לרכיב. STA הוא Apartment אחד לכל ת’רד, וקריאה שחוצה Apartment עוברת מרשלינג דרך Proxy/Stub. STA שנקרא מת’רד אחר, אם אינו מריץ לולאת הודעות, לא יכול לקבל את העברת הקריאה ונתקע, וגם תבנית שבה מגיע callback במהלך קריאה סינכרונית נוטה ליצור מבנה של קיפאון. MTA משתף Apartment אחד בין כמה ת’רדים, אבל בתמורה דורש מהאובייקט תכנון בטוח לריבוי ת’רדים. המאפיין [STAThread] ב-‎.NET הוא רק עטיפה שמגדירה את האתחול הזה של ה-Apartment.

מפת הידע של STA/MTA ב-COMתרשים המראה איך מודל ה-Apartment של COM, STA ו-MTA, קשור ללולאת ההודעות, למרשלינג ול-Proxy/Stub, ואיך היעדר לולאת הודעות מוביל לתקיעה ולקיפאון ב-STA.משתמש במחייבמחייבמשתמש במחייבמשתמש במשתמש במשתמש במחייבמשתמש במוגדר באמצעותעלול לגרום למונעמונעעלול לגרום לעלול לגרום למשתמש במחייבמממש אתמממש אתSTA(Single-Threaded Apartment)MTA(Multi-Threaded Apartment)‏COM (Component Object Model)מודל ה-apartment של COM‏ (STA/MTA)CoInitializeExלולאת ההודעותProxy/Stubתכנון אובייקט COM בטוח לריבוי ת'רדיםWindows Formsהמאפיינים ‎[STAThread]‎/‎[MTAThread]‎ ב-‎.NETערך הרישום ThreadingModelAutomationIDispatchMIDLתקיעת STA‏ (עצירת העברת הקריאות)MsgWaitForMultipleObjectsקיפאון עקב callback במהלך קריאה סינכרונית‏.NET (מ-Core ואילך)מרשלינג (marshaling)

בתרשים, קו מלא מציין קשר שמתקיים תמיד וקו מקווקו מציין קשר מותנה (תנאי ההתקיימות מפורטים בהסבר של כל קשר בעמוד המפורט). רשימת כל הקשרים (סך הכול 20, עם אסמכתה ורמת ודאות) והגדרות המושגים המרכזיים מרוכזות בעמוד המפורט של מפת הידע (ביפנית). נתונים: JSON-LD / Turtle

1. קודם המסקנה (במשפט אחד)

  • לאובייקט COM נקבעים כללי הקריאה לפי “לאיזה Apartment הוא שייך”
  • קל יותר להבין את STA כ-Apartment אחד לכל ת’רד, ואת MTA כ-Apartment אחד המשותף לכמה ת’רדים
  • קריאה שחוצה Apartment COM מבצע לה מרשלינג דרך Proxy/Stub
הקשר בין STA, MTA וחציית Apartmentתרשים המראה ש-STA הוא Apartment אחד לכל ת'רד, ו-MTA הוא Apartment אחד המשותף לכמה ת'רדים, וקריאה שחוצה Apartment עוברת מרשלינג דרך Proxy/Stub על ידי COM.קריאה שחוצה Apartmentקריאה שחוצה ApartmentSTA(Apartment אחד לכל ת'רד)מרשלינג דרך Proxy / StubMTA(Apartment אחד לכמה ת'רדים)

איור 2: ה-Apartment שאליו האובייקט שייך קובע את כללי הקריאה, ומרשלינג נכנס לתמונה רק כשחוצים Apartment.

2. תבניות הקריאה של Apartment Model (תרשים)

לקריאה לאובייקט COM יש בגדול שלוש תבניות.

שלוש תבניות הקריאהתרשים המראה שלקריאה לאובייקט COM יש שלוש תבניות - קריאה בתוך אותו ת'רד STA, קריאה בתוך אותו MTA, וקריאה שחוצה Apartment.קריאה לאובייקט COMתבנית 1: בתוך אותו ת'רד STAתבנית 2: בתוך אותו MTAתבנית 3: חוצה Apartment

איור 3: יש שלוש תבניות קריאה, וההשתייכות לאחת מהן קובעת את התקורה ואת נקודות תשומת הלב.

2.1. תבנית 1: קריאה בתוך אותו ת’רד STA

בתוך אותו ת’רד STA אפשר לקרוא ישירות. אין תקורה.

קריאה ישירה בתוך ת'רד STAתרשים המראה שבתוך אותו ת'רד STA, הקוד הקורא קורא ישירות לאובייקט COM ללא תקורה.ת'רד STAקריאה ישירהקוד קוראאובייקט COM

איור 4: קריאה בתוך אותו ת’רד STA היא קריאה ישירה, ללא תקורה.

2.2. תבנית 2: קריאה בתוך אותו MTA

מכמה ת’רדים בתוך MTA אפשר לקרוא ישירות מכל ת’רד. אבל צד האובייקט חייב תכנון בטוח לריבוי ת’רדים.

קריאה ישירה בתוך MTA משותףתרשים המראה שבתוך MTA אחד, שני ת'רדי עבודה קוראים ישירות לאותו אובייקט COM, מה שמחייב תכנון בטוח לריבוי ת'רדים בצד האובייקט.MTA - Apartment אחדקריאה ישירהקריאה ישירהת'רד עבודה 1אובייקט COMת'רד עבודה 2

איור 5: בתוך אותו MTA אפשר לקרוא ישירות מכל ת’רד, אבל האובייקט חייב תכנון בטוח לריבוי ת’רדים.

2.3. תבנית 3: קריאה שחוצה Apartment

בין Apartment שונים, COM מעביר את הקריאה באמצעות Proxy/Stub. עבור ממשקים סטנדרטיים, זמן הריצה של COM מטפל בזה בשבילכם.

מכאן ואילך מופיעות בטבלה מילים ייחודיות ל-COM בלי הסבר. נסדר אותן קודם, שורה אחר שורה.

מונח משמעות
מרשלינג סידור מחדש של הקריאה והארגומנטים לצורה שאפשר “להעביר כמות שהיא” כדי לחצות גבול של Apartment או תהליך. בצד השני של הגבול מוחזרים לצורתם המקורית
Proxy / Stub זוג רכיבים שאחראים על המרשלינג. Proxy עומד בצד הקורא (מעמיד פנים שהוא האובייקט האמיתי ומקבל את הקריאה), Stub עומד בצד הנקרא (מעביר את הקריאה שהתקבלה לאובייקט האמיתי)
IDispatch ממשק COM שמאפשר לשאול שם שיטה כמחרוזת ולקרוא לה לפי מספר. בזכות המנגנון הזה אפשר להשתמש ב-COM משפות סקריפט או מ-VBA
Automation שם כולל לשימוש ב-COM שמניח IDispatch וטיפוסי נתונים מוגבלים שאפשר להשתמש בהם שם (BSTR,‏ VARIANT וכדומה). כשנשארים בטווח הזה, את המרשלינג מבצע oleaut32.dll שבצד מערכת ההפעלה
ספריית טיפוסים (Type Library) נתונים שכותבים בהם את צורת הממשק (שיטות, טיפוסי ארגומנטים) בצורה קריאה למכונה. מונחים כקובץ .tlb נפרד, או מוטמעים בתוך DLL / EXE
Type Library Marshaler פונקציונליות סטנדרטית ב-COM שקוראת ספריית טיפוסים ומבצעת מרשלינג במקום. בזכות זה לא צריך ליצור Proxy/Stub ייעודי
MIDL מהדר של Microsoft שיוצר, בין השאר, קוד Proxy/Stub מתוך הגדרת ממשק (‏.idl)

שימו לב: לא הכול נוצר אוטומטית עבור Proxy/Stub, אבל בפועל ברוב המקרים אין צורך ליצור אותם במפורש.

תבנית הכנת Proxy/Stub
מבוסס IDispatch (Automation) לא נדרש. oleaut32.dll מטפל בזה
ספריית טיפוסים רשומה לא נדרש. ה-Type Library Marshaler מטפל בזה
COM Interop ב-‎.NET בדרך כלל לא נדרש. פועל דרך ספריית טיפוסים
ממשק מותאם-אישית שיורש ישירות מ-IUnknown נדרשת יצירה ורישום של Proxy/Stub עם MIDL

כלומר, יצירת Proxy/Stub עם MIDL נדרשת רק כשבונים ממשק שיורש ישירות מ-IUnknown בלי להשתמש ב-IDispatch. ברכיבי COM רגילים שמשתמשים בהם מ-‎.NET או משפות סקריפט, נדרשת העבודה הזו לעיתים רחוקות.

נקודת ההכרעה אם להכין Proxy/Stub בעצמכםתרשים המראה שאם מרשלינג סטנדרטי כמו IDispatch או Type Library Marshaler מספיק, אין צורך ביצירת Proxy/Stub, ורק כשאלה לא מספיקים - עבור ממשק מותאם-אישית שיורש ישירות מ-IUnknown - נדרשת יצירה ורישום עם MIDL.כןלאמרשלינג סטנדרטי מספיק?אין צורך ביצירת Proxy/Stubיצירה ורישום עם MIDLIDispatch או ספריית טיפוסים מטפלים בזהברוב המקרים בפועל זה המצב

איור 6: יוצרים Proxy/Stub במפורש רק כשמרשלינג סטנדרטי לא מספיק - עבור ממשק מותאם-אישית שיורש ישירות מ-IUnknown.

מסלול הקריאה כשחוצים Apartmentתרשים המראה שקוד קורא בת'רד STA קורא ל-Proxy, שדרך RPC/IPC מעביר ל-Stub שבתוך זמן הריצה של COM, וה-Stub מעביר את הקריאה לאובייקט COM שבת'רד MTA.ת'רד MTAזמן הריצה של COM - אוטומטית'רד STAקריאההעברהאובייקט COMProxyRPC/IPCStubקוד קורא

איור 7: קריאה שחוצה Apartment עוברת דרך Proxy, RPC ו-Stub שזמן הריצה של COM מכין אוטומטית, ומגיעה ליעד.

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

2.4. אומדן התקורה של המרשלינג

להלן אומדן כללי (לא ערכים נמדדים בפועל, ומשתנה מאוד לפי המצב ומורכבות הפרמטרים).

תבנית קריאה זמן משוער תחושה יחסית
בתוך אותו Apartment (ישיר) 10-100 ננו-שנייה כמעט זהה לקריאת פונקציה רגילה
Apartment שונה (אותו תהליך) 1-10 מיקרו-שנייה פי 100-1000 מקריאה ישירה
תהליך שונה (Out-of-proc) 100-1000 מיקרו-שנייה פי 10,000-100,000 מקריאה ישירה

השוואה יחסית:

  • אותו Apartment: בסדר גודל של גישה אחת לזיכרון
  • Apartment שונה: בסדר גודל של קריאת מערכת אחת (system call)
  • תהליך שונה: בסדר גודל של תקשורת רשת ל-localhost

בתרחיש שבו קוראים 10,000 פעמים בלולאה, ההבדל הזה מורגש מאוד.

לגבי הטיפול במספרים האלה

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

מצד שני, המבנה שבו “בתוך אותו Apartment יש קריאה ישירה, וחציית גבול תמיד כרוכה במרשלינג” הוא מפרט שכתוב בתיעוד של Microsoft. בפרק על Single-Threaded Apartments כתוב במפורש שבתוך אותו Apartment אפשר להעביר מצביע לממשק בלי מרשלינג, שכאשר חוצים Apartment נעשה שימוש באותו מנגנון מרשלינג כמו בין תהליכים גם אם זה בתוך אותו תהליך, ושהקריאה מגיעה כהודעת חלון לחלון מוסתר (OleMainThreadWndClass). סדר הגודל משתנה בגלל המבנה הזה, והמספרים עצמם משתנים לפי הסביבה ומורכבות הארגומנטים.

אם רוצים להחליט לפי המקרה שלכם, הדרך הבטוחה היא למדוד ולהשוות בין קריאה לאותו ממשק מתוך אותו Apartment לבין קריאה מ-Apartment אחר.

הסיבה המבנית לשינוי בסדר הגודלתרשים המראה שקריאה בתוך אותו Apartment היא קריאה ישירה, בעוד קריאה שחוצה Apartment תמיד כרוכה במרשלינג גם בתוך אותו תהליך, וכשהיעד הוא STA הקריאה מגיעה כהודעת חלון לחלון מוסתר.קריאה בתוך אותו Apartmentקריאה ישירהקריאה שחוצה Apartmentתמיד כרוכה במרשלינגליעד STA מגיעה כהודעה לחלון מוסתר

איור 8: השינוי בסדר הגודל נובע מהמבנה שבו חציית גבול תמיד כרוכה במרשלינג.

// C#. חוזרים על אותה קריאה N פעמים ומחשבים את הזמן לקריאה אחת
static void Measure(string label, Action call, int iterations = 100_000)
{
    call(); // מוציאים מהמדידה את העיכוב הראשוני (JIT, יצירת proxy, כינון חיבור)

    var sw = System.Diagnostics.Stopwatch.StartNew();
    for (int i = 0; i < iterations; i++)
    {
        call();
    }
    sw.Stop();

    double perCallNs = sw.Elapsed.TotalMilliseconds * 1_000_000.0 / iterations;
    Console.WriteLine($"{label}: {perCallNs:F1} ns/call");
}

אם מודדים גם שיטה עם מעט ארגומנטים וגם שיטה שמעבירה מחרוזת או מערך, אפשר לראות ש”ההשפעה משתנה לפי כמות המרשלינג”.

3. STA (Single-Threaded Apartment)

‏STA הוא מודל של “ת’רד אחד = Apartment אחד”.

  • אובייקטי COM בתוך אותו Apartment פועלים בעיקרון רק באותו ת’רד
  • קריאה מת’רד אחר COM מעביר אותה דרך תור הודעות/RPC
  • נפוץ בת’רד ה-UI (WinForms/WPF) (גם ל-UI יש “זיקה לת’רד אחד + לולאת הודעות”, ולכן ההתאמה טובה)
מודל ההרצה של STAתרשים המראה שאובייקט COM ב-STA פועל בעיקרון רק בת'רד שיצר אותו, וקריאה מת'רד אחר מועברת על ידי COM דרך תור הודעות או RPC לאותו ת'רד.אובייקט COM ב-STAפועל רק בת'רד שיצר אותוקריאה מת'רד אחרמועברת דרך תור הודעות / RPC

איור 9: אובייקט STA פועל רק בת’רד הבעלים שלו, וקריאה מבחוץ מגיעה לאחר העברה.

3.1. למה ת’רד ה-UI משתמש ב-STA

מכיוון שהתכנון של ת’רד ה-UI ושל STA תואם.

  • פקדי UI אינם בטוחים לריבוי ת’רדים כפתורים ותיבות טקסט וכדומה ניתן לתפעל בבטחה רק מהת’רד שיצר אותם
  • גם STA הוא “זיקה לת’רד אחד” אובייקט COM פועל ישירות רק בת’רד שיצר אותו
  • ת’רד ה-UI תמיד מריץ לולאת הודעות זה הכרחי לטיפול באירועי חלון. זה תואם לתנאי המוקדם של STA (משאבת ההודעות)

לכן ת’רד ה-UI ב-WinForms/WPF הוא STA כברירת מחדל.

התאמת התכנון בין ת'רד UI ל-STAתרשים המראה שפקדי UI אפשר לתפעל בבטחה רק מהת'רד שיצר אותם, ל-STA יש אותה זיקה לת'רד אחד, ולולאת ההודעות שת'רד ה-UI תמיד מריץ תואמת לתנאי המוקדם של STA - משאבת ההודעות - ולכן ת'רד ה-UI הוא STA כברירת מחדל.זיקת ת'רד אחד של פקדי UIהתאמת תכנוןזיקת ת'רד אחד של STAלולאת ההודעות של ת'רד ה-UIהתנאי המוקדם של STA(משאבת הודעות)ת'רד ה-UI הוא STA כברירת מחדל

איור 10: מכיוון שהתכנון תואם בשתי נקודות - זיקה לת’רד אחד ולולאת הודעות - ת’רד ה-UI הוא STA.

נקודה מרכזית: ל-STA יש זיקה חזקה לת’רד, אבל בתמורה, כשיש הרבה קוראים קל שנוצר בו עומס.

4. MTA (Multi-Threaded Apartment)

‏MTA הוא מודל של “Apartment אחד לכמה ת’רדים”.

  • אובייקט COM נקרא בו-זמנית מכמה ת’רדים
  • חובה תכנון בטוח לריבוי ת’רדים בצד האובייקט
  • מתאים לעיבוד בצד שרת או עיבוד ברקע

נקודה מרכזית: ל-MTA יש מקביליות גבוהה, אבל האחריות על מימוש האובייקט כבדה יותר.

מודל ההרצה של MTAתרשים המראה ש-MTA משתף Apartment אחד בין כמה ת'רדים, ואובייקט COM נקרא בו-זמנית מכמה ת'רדים, מה שמחייב תכנון בטוח לריבוי ת'רדים בצד האובייקט.MTA(Apartment אחד לכמה ת'רדים)נקרא בו-זמנית מכמה ת'רדיםחובה תכנון בטוח לריבוי ת'רדים בצד האובייקטמתאים לעיבוד בצד שרת או ברקע

איור 11: ל-MTA יש קריאה מקבילית, אבל האחריות על הבטיחות עוברת למימוש האובייקט.

5. איפה נקבע STA/MTA

‏Apartment של COM נקבע על ידי אתחול לכל ת’רד.

  • ברגע שקוראים ל-CoInitialize / CoInitializeEx, נקבע ה-Apartment של אותו ת’רד
  • STA: ‏COINIT_APARTMENTTHREADED
  • MTA: ‏COINIT_MULTITHREADED
הרגע שבו נקבע ה-Apartmentתרשים המראה שברגע שהת'רד קורא ל-CoInitialize או ל-CoInitializeEx נקבע ה-Apartment שלו, ו-COINIT_APARTMENTTHREADED קובע STA בעוד COINIT_MULTITHREADED קובע MTA.COINIT_APARTMENTTHREADEDCOINIT_MULTITHREADEDת'רדקורא ל-CoInitialize / CoInitializeExנקבע STAנקבע MTA

איור 12: ה-Apartment נקבע לפי הציון שנעשה ברגע קריאת האתחול, לכל ת’רד בנפרד.

5.1. STA/MTA ב-‎.NET

גם ל-‎.NET יש את המאפיינים [STAThread] / [MTAThread] ואת ApartmentState, אבל אלה עטיפות להגדרת מודל ה-Apartment של COM.

  • [STAThread]מוסיפים ל-Main (נקודת הכניסה). מאותחל כ-STA כשמשתמשים ב-COM
  • [MTAThread] → גם הוא עבור Main. מאותחל כ-MTA
  • Thread.SetApartmentState(ApartmentState.STA)עבור ת’רדים נוספים שיוצרים. יש להגדיר לפני התחלת הת’רד

נקודות לתשומת לב:

  • גם אם יש [STAThread], האתחול לא מתבצע עד לקריאה בפועל ל-COM (אין השפעה באפליקציה שלא משתמשת ב-COM)
  • [STAThread] לא פועל על ת’רדים נוספים. יש להשתמש ב-Thread.SetApartmentState

כלומר, ה-STA/MTA של ‎.NET הם STA/MTA של COM עצמם, מנגנון שהוכן עבור COM Interop.

חשוב: אי אפשר לשנות את ה-Apartment לאחר מכן. האתחול הראשון קובע הכול.

היכן קובעים Apartment ב-‎.NETתרשים המראה של-Main מוסיפים מאפיין STAThread או MTAThread, ולת'רדים נוספים שיוצרים מגדירים לפני ההתחלה עם Thread.SetApartmentState, ובשני המקרים ה-Apartment נקבע באתחול הראשון ולא ניתן לשנות אותו לאחר מכן.שיטת Mainמאפיין [STAThread] / [MTAThread]ת'רד נוסף שנוצרלפני ההתחלה: Thread.SetApartmentStateהאתחול הראשון קובע את ה-Apartmentלא ניתן לשנות לאחר מכן

איור 13: בנקודת הכניסה זה נעשה במאפיין, ולת’רד נוסף עם SetApartmentState, ובשניהם ההגדרה נקבעת בפעם הראשונה בלבד.

6. דוגמה קונקרטית לתקיעה שקורית כשטועים ב-STA

מבנה כמו הבא גורם בפועל בקלות לתקיעה.

6.1. מצב נפוץ

  • יוצרים ת’רד STA ברקע ומייצרים בו אובייקט COM
  • הת’רד הזה אינו מריץ לולאת הודעות
  • קוראים לאובייקט ה-COM הזה מת’רד אחר (בלי קשר אם הוא STA או MTA)
מבנה שגורם בקלות לתקיעהתרשים המראה שת'רד STA שנוצר ברקע ייצר אובייקט COM אך אינו מריץ לולאת הודעות, ות'רד אחר קורא לאובייקט COM הזה - מבנה שגורם בקלות לתקיעה.קוראת'רד STA ברקעמייצר אובייקט COMאינו מריץ לולאת הודעותת'רד אחר(STA או MTA)

איור 14: השילוב של “STA שלא מריץ לולאה, שמחזיק אובייקט שקוראים לו מת’רד אחר” מסוכן.

6.2. מה קורה בפועל

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

  • אובייקט COM מטופל בת’רד ה-STA שיצר אותו בין אם הקורא הוא STA או MTA, קריאה מת’רד אחר תמיד מועברת לאותו ת’רד STA. ההעברה מגיעה כהודעת חלון לחלון מוסתר (מחלקת חלון OleMainThreadWndClass) ש-COM יוצר עבור אותו Apartment
  • כדי לקבל את ההעברה, ת’רד ה-STA חייב להריץ משאבת הודעות גם בתיעוד של Microsoft כתוב במפורש ש”כל STA חייב שתהיה לו לולאת הודעות, כדי לטפל בקריאות מתהליכים אחרים או מ-Apartment אחר באותו תהליך”

לכן ת’רד STA שלא מריץ הודעות לא יכול לקבל את הקריאה, הקורא ממשיך להמתין לתשובה, וכתוצאה מכך נוצרת תקיעה.

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

איור 15: ההעברה מגיעה כהודעה, ולכן כשהמשאבה עצורה ב-STA אין מי שיקבל אותה.

זה נכון גם ב-‎.NET. בתיעוד של Single-Threaded Apartments יש אזהרה שאם חוסמים ת’רד STA באמצעות Task.Wait(),‏ Task.Result,‏ Thread.Sleep(),‏ ManualResetEvent.WaitOne() וכדומה, callback של COM או קריאה שחוצה Apartment לא יכולים להסתיים, וזה גורם לקיפאון (deadlock). דוגמת הכשל בסעיף 6.3 הבא, שנעצרת ב-WaitOne(), היא בדיוק הצורה הזו.

מצד שני, ת’רד ה-UI מריץ לולאת הודעות מלכתחילה כדי לטפל באירועי חלון, ולכן הוא ממלא את דרישת STA בלי מימוש נוסף. זו הסיבה שת’רד ה-UI הוא בחירה טבעית להריץ בו אובייקטי COM מסוג STA.

6.3. פסאודו-קוד (תבנית כשל טיפוסית)

using System;
using System.Runtime.InteropServices;
using System.Threading;

internal static class StaHangDemo
{
    private const uint COINIT_APARTMENTTHREADED = 0x2;

    [DllImport("ole32.dll")]
    private static extern int CoInitializeEx(IntPtr pvReserved, uint dwCoInit);

    [DllImport("ole32.dll")]
    private static extern void CoUninitialize();

    public static void Run(string progId)
    {
        var ready = new AutoResetEvent(false);
        var done = new AutoResetEvent(false);

        object comObj = null;

        var staThread = new Thread(() =>
        {
            // אתחול כ-STA
            CoInitializeEx(IntPtr.Zero, COINIT_APARTMENTTHREADED);

            // בהנחה שמדובר במחלקת COM שנרשמה עם ThreadingModel=Apartment
            Type type = Type.GetTypeFromProgID(progId, throwOnError: true);
            comObj = Activator.CreateInstance(type);
            ready.Set();

            // ממתין בלי לולאת הודעות -> כאן נמצא הפגם הקטלני
            done.WaitOne();

            CoUninitialize();
        });

        staThread.SetApartmentState(ApartmentState.STA);
        staThread.Start();

        ready.WaitOne();

        try
        {
            // קריאה מת'רד אחר (בין אם STA או MTA) מועברת ל-STA
            // אבל צד ה-STA לא מטפל בהודעות, ולכן קל שכאן נוצרת תקיעה
            dynamic obj = comObj;
            obj.AnyMethod();
        }
        finally
        {
            // גם אם הקריאה חוזרת (המחלקה הייתה agile, הקריאה לא הועברה,
            // AnyMethod חזרה מיד וכו') - חשוב תמיד לשחרר את ת'רד ה-STA.
            // בלי זה, נשאר ת'רד קדמי שממתין ל-done, וגם כש"לא שוחזרה
            // התקיעה" התהליך לא מסתיים. אי אפשר להבחין בין שני המצבים
            // לפי הסימפטום
            done.Set();
            staThread.Join();
        }
    }
}

הקוד הזה מניח שמעבירים ProgID של מחלקת COM שנרשמה כ-ThreadingModel=Apartment (= STA). יש להחליף את AnyMethod בשם שיטה שקיימת בפועל באותה מחלקה. זה לא משוחזר עם כל מחלקת COM. שלושת התנאים הנדרשים לשחזור הם:

תנאי איך בודקים
ה-ThreadingModel של המחלקה הוא Apartment בודקים את הערך ThreadingModel תחת HKEY_CLASSES_ROOT\CLSID\{CLSID}\InprocServer32 ברג’יסטרי. אם זה Both או Free, הקריאה לא מועברת, ולא ישוחזר
ת’רד ה-STA לא מריץ הודעות זה בדיוק מה ש-done.WaitOne() בדוגמה למעלה עושה
הקריאה מתבצעת מת’רד אחר כל עוד קוראים מתוך אותו ת’רד STA, זו קריאה ישירה, ולא נוצרת תקיעה

הסיבה ש-obj.AnyMethod() עטוף ב-try / finally, עם done.Set() ו-staThread.Join() תמיד מתבצעים, היא כדי להבחין ב”מקרה שלא שוחזר”. ת’רד STA הוא כברירת מחדל ת’רד קדמי, ולכן התהליך לא מסתיים כל עוד done לא הוגדר. אם משמיטים את ה-finally, הסימפטום שנראה מבחוץ יהיה “התהליך לא מסתיים” גם כשהתקיעה שוחזרה (הקריאה לא חוזרת) וגם כשלא (הקריאה חזרה אבל אף אחד לא הגדיר את done). הכלי שנועד לבדוק את תנאי השחזור מסתיר בעצמו את הצלחת התנאי. עם הצורה למעלה, כשלא משוחזר, התהליך מסתיים כרגיל.

כדי לוודא שיש תקיעה, הדרך המהירה היא לעצור את התהליך בדיבאגר ולבדוק שה-Stack של ת’רד הקורא עצור בהמתנה של COM, ושת’רד ה-STA עצור ב-WaitOne.

הזרימה שיוצרת תקיעה בדוגמת STAתרשים רצף המראה שת'רד ה-STA נעצר ב-WaitOne בלי לולאת הודעות, ואילו ת'רד ראשי מנסה לקרוא לאובייקט COM, וההעברה לא מתקבלת, ושני הצדדים ממתינים זה לזה - תקיעה.זמן הריצה של COMת'רד STAת'רד ראשיזמן הריצה של COMת'רד STAת'רד ראשיאין לולאת הודעותתקוע כאןמעביר בהודעה, אבל...בגלל WaitOneלא יכול לטפל בהודעהגם הקורא ממשיך להמתיןשני הצדדים ממתינים -> תקיעההתחלת ת'רדCoInitializeEx (STA)יצירת אובייקט COMready.Set()ממתין עם done.WaitOne()CallComObject()מנסה להעביר את הקריאה

איור 16: ת’רד STA שנעצר ב-WaitOne ות’רד ראשי שממתין לתשובת ההעברה - שניהם לא יכולים להתקדם.

במרכז התרשים, שתי השורות “מעביר בהודעה, אבל…” הן המקום שבו שני התנאים המוקדמים שהוזכרו בסעיף 6.2 נשברים.

6.4. עקרונות למניעה

  • כשמקבלים קריאות מת’רד אחר, ת’רד ה-STA חייב להריץ לולאת הודעות
  • אם אפשר, ליצור ולהשתמש בת’רד ה-UI (לת’רד ה-UI כבר יש לולאת הודעות מלכתחילה)
  • אם STA לא נחוץ, להשתמש מלכתחילה ב-MTA

הערה: אם הכול נשאר בתוך אותו ת’רד, לא תמיד Application.Run() הכרחי. אבל מכיוון שב-UI וב-COM לרוב מעורבת קריאה מת’רד אחר, זה כמעט הכרחי בפועל.

שלושה כיווני מניעה של תקיעהתרשים המראה ששלושת כיווני המניעה של תקיעה הם - להריץ לולאת הודעות בת'רד ה-STA כשמקבלים קריאות מת'רד אחר, ליצור ולהשתמש באובייקט בת'רד ה-UI שיש לו כבר לולאה, ואם STA לא נחוץ - להשתמש מלכתחילה ב-MTA.איך נמנעים מתקיעת STAלהריץ לולאת הודעות בת'רד ה-STAליצור ולהשתמש בת'רד ה-UIאם STA לא נחוץ - MTA מלכתחילהלת'רד ה-UI כבר יש לולאה

איור 17: אפשר לסדר את דרכי המניעה בשלושה כיוונים - להריץ לולאה, לעבור למקום שיש בו לולאה, או לוותר על STA.

6.5. מה זה בעצם “להריץ לולאת הודעות”?

זה בדיוק מה שת’רד ה-UI ב-Win32 עושה.

while (GetMessage(out var msg, IntPtr.Zero, 0, 0))
{
    TranslateMessage(ref msg);
    DispatchMessage(ref msg);
}

ב-STA, קריאה מת’רד אחר “מועברת” לכאן. הלולאה הזו (משאבת ההודעות) היא זו שמקבלת את ההעברה ומעבירה אותה להרצה.

תפקיד משאבת ההודעותתרשים המראה שמשאבת ההודעות היא החזרה על קבלת הקריאה שהועברה מת'רד אחר באמצעות GetMessage והעברתה להרצה באמצעות DispatchMessage.קריאה שהועברה מת'רד אחרמתקבלת עם GetMessageמועברת להרצה עם DispatchMessage

איור 18: “להריץ לולאת הודעות” הוא בדיוק החזרה הזו של קבלת ההעברה והעברתה להרצה.

6.6. דוגמה לכיוון נכון (בגדול, ככה)

אם רוצים “להשתמש ב-COM ב-STA שברקע”, זה נראה כך.

var ready = new AutoResetEvent(false);
object comObj = null;

var staThread = new Thread(() =>
{
    CoInitializeEx(IntPtr.Zero, COINIT_APARTMENTTHREADED);

    comObj = new SomeStaComObject();
    ready.Set();

    // כל עוד ת'רד ה-STA חי, מריצים הודעות
    Application.Run();

    CoUninitialize();
});

staThread.SetApartmentState(ApartmentState.STA);
staThread.Start();

ready.WaitOne();
CallComObject(comObj);

(לשכוח לקרוא ל-CoInitializeEx / CoUninitialize זו תקלה רגילה. ההצהרה מסוג P/Invoke עבור CoInitializeEx נדרשת זהה לזו שבסעיף 6.3)

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

  • נדרשת הפניה ל-System.Windows.Forms. באפליקציית קונסולה, מוסיפים <UseWindowsForms>true</UseWindowsForms> לקובץ הפרויקט
  • הלולאה הזו לא מסתיימת מעצמה. כדי לעצור אותה, קוראים ל-Application.ExitThread() (או ל-Application.Exit() שמסיים את כל האפליקציה) מתוך אותו ת’רד. אם רוצים להגיע ל-CoUninitialize() בדוגמה למעלה, צריך מנגנון נפרד שמעביר “סיום” לת’רד ה-STA ומריץ בו ExitThread

אם לא רוצים תלות ב-WinForms, אפשר לכתוב בעצמכם לולאה של GetMessage / DispatchMessage כמו בסעיף 6.5, או להשתמש ב-MsgWaitForMultipleObjects, שמאפשר להמתין גם להודעות וגם לאובייקטי סנכרון. זו הבחירה הרגילה כשרוצים “לקבל הודעת סיום דרך אירוע, בלי לפספס קריאות COM”.

שלושה אמצעים להרצת לולאה ב-STA ברקעתרשים המראה ששלושת האמצעים להרצת לולאת הודעות ב-STA ברקע הם Application.Run שדורש הפניה ל-WinForms, כתיבה עצמית של לולאת GetMessage ו-DispatchMessage, או שימוש ב-MsgWaitForMultipleObjects שממתין גם להודעות וגם לאובייקטי סנכרון.הרצת לולאה ב-STA ברקעApplication.Runכתיבה עצמית של לולאת GetMessageMsgWaitForMultipleObjectsדורש הפניה ל-WinForms ואמצעי סיוםממתין גם להודעות וגם לאובייקטי סנכרון

איור 19: יש שלוש דרכים להריץ לולאה, ובוחרים לפי אופן הסיום ומשקל התלות.

6.7. עוד דוגמת תקיעה: callback במהלך קריאה סינכרונית

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

קיפאון בין קריאה סינכרונית ל-callbackתרשים רצף המראה שת'רד ה-UI ממתין לתשובה מקריאה סינכרונית ולא מטפל בהודעות, השרת שולח callback שהוא לא יכול לקבל, והשרת מצדו ממתין לסיום ה-callback - שני הצדדים ממתינים זה לזה.שרת COMת'רד UI (STA)שרת COMת'רד UI (STA)ממתין לתשובה של DoWork(לא מטפל בהודעות)בגלל שממתין,לא יכול לקבל את ה-callbackממתין לסיום ה-callbackשני הצדדים ממתינים זה לזה -> קיפאוןDoWork() (קריאה סינכרונית)ProgressCallback() (callback)

איור 20: ת’רד UI שממתין לתשובה של קריאה סינכרונית, ושרת שממתין לסיום callback - ממתינים זה לזה.

למה קל שנוצר קיפאון:

  1. ת’רד ה-UI קורא ל-DoWork() בקריאה סינכרונית (חוסמת)
  2. ת’רד ה-UI ממתין לתשובה (לא מטפל בהודעות)
  3. השרת שולח ProgressCallback() לת’רד ה-UI
  4. מכיוון שת’רד ה-UI ממתין, הוא לא יכול לקבל את ה-callback
  5. השרת ממתין לסיום ה-callback
  6. שני הצדדים ממתינים זה לזה -> לא מתקדם לעולם

אורך זמן העיבוד לא רלוונטי. התבנית עצמה שבה callback מגיע במהלך קריאה סינכרונית נוטה להיות בעייתית.

הערה: ל-COM יש גם מנגנוני הרצת הודעות וכניסה חוזרת (reentrancy) שמשתנים לפי המצב, וההתנהגות משתנה לפי הרכיב וצורת הקריאה. זה לא תמיד גורם לקיפאון, אבל עדיף להימנע מהתבנית הזו.

7. חלוקה גסה בין השימושים

מצב מה בוחרים סיבת ההחלטה מה נדרש גם
מעורב UI (WinForms / WPF) STA לפקדי UI יש זיקה לת’רד אחד, ולת’רד ה-UI כבר יש לולאת הודעות מלכתחילה (3.1) כלום במיוחד. STA כברירת מחדל
רוצים הרבה עיבוד מקבילי MTA כמה ת’רדים משתפים Apartment אחד וקוראים ישירות בלי העברה (2.2) תכנון בטוח לריבוי ת’רדים בצד אובייקט ה-COM. זו אחריות צד המימוש של האובייקט, לא חסימה בצד הקורא
רוצים להשתמש ב-COM מסוג STA ברקע STA + לולאת הודעות כל עוד קוראים מת’רד אחר, נדרש פתח לקבלת ההעברה (6.2) Application.Run() או לולאת GetMessage. יש לתכנן גם אמצעי סיום (כמו ExitThread) (6.6)
הדרישה של רכיב ה-COM שבו משתמשים כבר קבועה מתאימים לצד השני ה-Apartment הוא כלל הקריאה עצמו, ואי אפשר לשנות אותו לאחר מכן (פרק 5) בודקים את הערך ThreadingModel. אם זה Apartment, בונים בהנחת STA
קריאה בתדירות גבוהה מתקרבים לאותו Apartment של הקורא בכל חציית גבול נכנס מרשלינג (2.4) אם קשה - לתכנן צמצום של מספר הקריאות עצמו (העברה מרוכזת)

כשמתלבטים, קל יותר להחליט לפי סדר העדיפויות “דרישת הצד השני > קיום UI > מקביליות”. מכיוון שה-Apartment נקבע באתחול הראשון ולא ניתן לשנות אותו אחר כך, כדאי להחליט על זה עוד לפני תחילת המימוש.

הסדר שבודקים כשמתלבטיםתרשים המראה שכשמתלבטים בין STA ל-MTA, קל יותר להחליט אם בודקים לפי הסדר - דרישת רכיב ה-COM שבו משתמשים, קיום UI, ומקביליות.אחר כךאחר כךדרישת הצד השניקיום UIמקביליות

איור 21: כשמתלבטים בין השימושים, בודקים לפי הסדר - דרישת הצד השני, קיום UI, ומקביליות.

8. סיכום

‏STA/MTA הם מודל שרשור עבור COM: STA הוא ת’רד אחד = Apartment אחד, ו-MTA הוא Apartment אחד המשותף לכמה ת’רדים. קריאה שחוצה Apartment מועברת על ידי COM דרך Proxy/Stub (עבור ממשקים שאינם סטנדרטיים נדרשת יצירה ורישום, למשל עם MIDL), אבל זה כרוך בתקורת מרשלינג, ולכן במקומות שבהם צפויות קריאות בתדירות גבוהה כדאי להחליט בזהירות על תכנון ה-Apartment.

מבחינת תקיעה, הכול מצטמצם לנקודה אחת: “ת’רד STA שמקבל קריאות מת’רד אחר חייב להריץ משאבת הודעות”. קריאה לת’רד STA שלא מריץ הודעות נוטה לגרום לתקיעה, וגם תבנית שבה מגיע callback במהלך קריאה סינכרונית נוטה לגרום לקיפאון. לת’רד ה-UI יש מלכתחילה גם “זיקה לת’רד אחד” וגם “לולאת הודעות”, ולכן הוא ממלא את התנאי הזה בלי מימוש נוסף, וזו הסיבה שהוא מתאים כל כך ל-COM מסוג STA.

9. מקורות

  • Apartment Model https://learn.microsoft.com/en-us/windows/win32/com/com-apartments
  • CoInitializeEx https://learn.microsoft.com/en-us/windows/win32/api/objbase/nf-objbase-coinitializeex
  • Single-Threaded Apartments (הסיבה שלולאת הודעות הכרחית, החלון המוסתר, אזהרת קיפאון ב-‎.NET) https://learn.microsoft.com/en-us/windows/win32/com/single-threaded-apartments
  • Multithreaded Apartments https://learn.microsoft.com/en-us/windows/win32/com/multithreaded-apartments
  • InprocServer32 (הערך ThreadingModel) https://learn.microsoft.com/en-us/windows/win32/com/inprocserver32
  • שיטת Application.Run (הצורה של הרצת לולאת הודעות בלי טופס, ואיך עוצרים אותה) https://learn.microsoft.com/en-us/dotnet/api/system.windows.forms.application.run
  • MsgWaitForMultipleObjects (המתנה בו-זמנית להודעות ולאובייקטי סנכרון) https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-msgwaitformultipleobjects

הורדת קובץ ה-Word של המאמר הזה

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

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

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

שאלות נפוצות

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

האם כדאי לבחור STA או MTA?
בעיבוד שקשור ל-UI משתמשים ב-STA, ובעיבוד מקבילי בהיקף גדול משתמשים ב-MTA — זו החלוקה הבסיסית. ל-STA יש זיקה חזקה לת'רד אחד (Apartment אחד לת'רד), אך כאשר יש הרבה קוראים היא נוטה להיווצר בה עומס. MTA משתף Apartment אחד בין כמה ת'רדים, כך שיש בו מקביליות גבוהה, אך זה מחייב תכנון בטוח לריבוי ת'רדים בצד אובייקט ה-COM. אם המצב אינו אחד מהם, מעשי להתאים לדרישה של הספרייה הקיימת או שרת ה-COM שבו משתמשים.
למה ת'רד ה-UI הוא STA?
מכיוון שהתכנון של ת'רד ה-UI ושל STA תואם. פקדי UI כמו כפתורים ותיבות טקסט אינם בטוחים לריבוי ת'רדים, ואפשר לתפעל אותם בבטחה רק מהת'רד שיצר אותם. גם STA הוא מודל של זיקה לת'רד אחד. בנוסף, ת'רד ה-UI תמיד מריץ לולאת הודעות כדי לטפל באירועי חלון, וכך הוא ממלא ללא מימוש נוסף את התנאי המוקדם של STA — משאבת ההודעות. לכן ת'רד ה-UI ב-WinForms/WPF הוא STA כברירת מחדל.
למה קוראים לאובייקט COM ב-STA וזה תוקע?
קריאה לאובייקט COM ב-STA מטופלת בת'רד ה-STA שיצר אותו. קריאה מת'רד אחר מועברת על ידי COM דרך הודעה/RPC, אך אם ת'רד ה-STA אינו מריץ לולאת הודעות הוא לא יכול לקבל את ההעברה, והקורא ממשיך להמתין ונתקע. כדי להימנע מזה, יש להריץ לולאת הודעות בת'רד ה-STA שנקרא מת'רד אחר, או ליצור ולהשתמש בו בת'רד ה-UI, או אם STA לא נחוץ — להשתמש מלכתחילה ב-MTA.
לשם מה קיים המאפיין [STAThread] ב-‎.NET?
זוהי עטיפה שמגדירה את מודל ה-Apartment של COM. כשמוסיפים אותה ל-Main, הת'רד מאותחל כ-STA כאשר משתמשים ב-COM. עם זאת, האתחול לא מתבצע עד לקריאה בפועל ל-COM, ולכן אין לה השפעה באפליקציה שלא משתמשת ב-COM. היא גם לא פועלת על ת'רדים נוספים שנוצרים, ולכן משתמשים ב-Thread.SetApartmentState לפני התחלת הת'רד. חשוב לזכור גם ש-Apartment נקבע באתחול הראשון ולא ניתן לשנות אותו לאחר מכן.

פרופיל הכותב

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

Go Komura

מנהל KomuraSoft LLC

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

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

חזרה לבלוג