המלכודות של הזיכרון המשותף ושיטות העבודה המומלצות בפועל

· עודכן בתאריך: · · ‏זיכרון משותף, IPC, מקביליות, ‏C++, ‏C#, פיתוח Windows

פריימים של תמונה, תוצאות בדיקה, יומני time-series, נתוני עומק שוק (board), חוצצים ענקיים. כשרוצים להעביר נתונים גדולים באותה מכונה בעיכוב נמוך, זיכרון משותף די מפתה.

אבל יש כאן משהו מסוכן: זיכרון משותף מתקרב בפרצוף של “IPC מהיר”. בפועל, זיכרון משותף הוא “IPC שמקטין העתקות, אבל בתמורה מחזיר את האחריות לעקביות אל צד האפליקציה”.

  • מהיר
  • גמיש
  • אבל ה-protocol הוא בעצמכם
  • וכשקורית תקלה, התסמינים ראוותניים

בערך ארבעת הנקודות האלה.

שני הפרצופים של הזיכרון המשותףתרשים המראה שזיכרון משותף מתקרב בפרצוף של IPC מהיר, אך בפועל הוא IPC שמקטין העתקות ומחזיר את האחריות לעקביות אל צד האפליקציה.פרצוף של IPC מהירהמצב בפועלמקטין העתקותהאחריות לעקביות אצל האפליקציהprotocol עצמאי · תקלה ראוותנית

איור 1: זיכרון משותף מתקרב בפרצוף של “IPC מהיר”, אבל מחזיר את האחריות לעקביות אל צד האפליקציה.

במאמר הזה, תוך התמקדות ב-file mapping של Windows וב-‏POSIX‏ shm_open / mmap, נסדר את נקודות התקיעה בשימוש בזיכרון משותף בעבודה בפועל, ואת התכנון שמוריד את שיעור התקלות. גם ב-C/C++ וגם ב-C# עם MemoryMappedFile, המהות כמעט זהה.1

קהל היעד וההנחות

המאמר נכתב עבור מפתחים שעומדים להחליט על תכנון להעברת נתונים גדולים בין תהליכים באותה מכונה. ההנחה העיקרית היא מי שנוגע ישירות ב-file mapping של Windows או ב-shm_open של POSIX ב-C /‏ C++, אבל גם מי שנכנס דרך MemoryMappedFile ב-C# נתקל באותן מלכודות. הפרקים על מלכודות והנחיות תכנון (פרקים 5 ו-6) אינם תלויי שפה.

דוגמה עובדת נמצאת בסעיף 6.9, וכוללת גם C‏ (Windows / MSVC) וגם C#. בצד POSIX מוצגת רק התאמת שמות ה-API בטבלה שבפרק 7.

מונחים לקבוע מראש

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

מונח משמעות
IPC (Inter-Process Communication) תקשורת בין תהליכים. מנגנון כללי להעברת נתונים או איתותים עם תהליך אחר. כולל pipe,‏ socket,‏ named pipe, זיכרון משותף ועוד
coherent כמה views שמצביעים על אותה ישות נראים באותו תוכן באותה נקודת זמן. אין הכוונה ל”קורא יכול תמיד לקרוא רשומה מעודכנת ועקבית”
ABI (Application Binary Interface) לא הסכם ברמת קוד המקור, אלא הבטחה ברמת הבינארי בין קבצי הרצה. כולל גודל טיפוסים, alignment,‏ padding וסדר השדות במבנה
SPSC / MPSC / SPMC / MPMC קיצור למספר ה-producer וה-consumer. S=single,‏ M=multi,‏ P=producer,‏ C=consumer. ב-SPSC יש writer אחד ו-reader אחד. מורחב בסעיף 4.2
lock-free תכנון שמתקדם רק בעזרת פעולות atomic, בלי לנעול. מתאר הבטחת התקדמות - “תמיד יש thread אחד שיכול להתקדם” - תכונה שונה מ”מהיר”
sentinel ערך מיוחד ששמור מראש כדי לציין “לא תקף” או “סוף”. למשל, עבור offset אפשר לקבוע ש-UINT64_MAX פירושו לא תקף
NUMA (Non-Uniform Memory Access) תצורה שבה המרחק של הזיכרון מנקודת המבט של ה-CPU אינו אחיד. גישה לזיכרון בצומת רחוק מאיטה בצורה ניכרת, גם לאותו קוד

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

בניסוח גס אבל שימושי בעבודה בפועל, הנה זה:

  • זיכרון משותף הוא מנגנון שמראה את אותה רצף בייטים למספר תהליכים, והוא אינו סנכרון כשלעצמו23
  • המהירות בולטת כאשר מעבירים נתונים גדולים באותה מכונה. אם מדובר רק בהודעות בקרה קטנות, לרוב נוח יותר להשתמש ב-pipe / socket / named pipe / queue
  • בזיכרון משותף, היכולת לראות והיכולת לקרוא בבטחה הן בעיות נפרדות
  • עדיף שלא לבסס את התכנון על volatile. אטומיות, סדר והמתנה צריך לתכנן בנפרד45
  • אם שמים מצביע גולמי, HANDLE, file descriptor,‏ std::string,‏ std::vector,‏ std::mutex כמות שהם, כמעט תמיד זה מסתיים בבכי מאוחר יותר
  • בטוח יותר לתכנן את הנתונים בזיכרון המשותף כמספרים שלמים ברוחב קבוע + פריסה מפורשת + כותרת עם גרסה
  • רק הצבת magic / version / size / state / generation / heartbeat בכותרת הראשית משנה מאוד את קלות חקירת התקלות
  • הקושי בזיכרון משותף הוא לא המהירות, אלא אתחול, אורך חיים, התאוששות, הרשאות ו-ABI
  • ב-Windows השלד הוא CreateFileMapping /‏ OpenFileMapping /‏ MapViewOfFile, וב-POSIX הוא shm_open /‏ ftruncate /‏ mmap63
  • הכי פחות נוטה לתקלות זה להתחיל מחוצץ טבעתי SPSC‏ (single-producer single-consumer) או מחיץ כפול

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

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

איור 2: בזיכרון משותף, “היכולת לראות” ו”היכולת לקרוא בבטחה” הן בעיות נפרדות.

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

זיכרון משותף הוא מנגנון שמשתמש ב-CreateFileMapping/MapViewOfFile של Windows או ב-shm_open/mmap של POSIX כדי להראות את אותו עמוד פיזי למספר תהליכים, אך הוא אינו סנכרון כשלעצמו, ולכן קריאה וכתיבה של מספר שדות ללא סנכרון עלולות לגרום לתקלה שבה קוראים את מצב הכתיבה של ה-writer באמצע. תקלה כזו נמנעת באמצעות פרוטוקול commit על בסיס חוצץ טבעתי SPSC או חיץ כפול; מצביעים גולמיים ומשאבים מקומיים לתהליך כמו HANDLE אינם ניתנים להנחה כמות שהם ולכן מוחלפים בהפניה לפי offset; ומומלץ להפריד בין control plane ל-data plane ולנתב את ההתראות לערוץ נפרד. בנוסף, קיבוע ה-ABI, מניעת תחרות באתחול באמצעות דפוס שבו רק היוצר מאתחל, תכנון התאוששות מקריסה בעזרת mutex נטוש ב-Windows או robust mutex ב-POSIX, סידור מרחב השמות וההרשאות, וקיבוע הגודל לפי דורות - הם עיקרי העבודה בפועל שמורידים את שיעור התקלות.

מפת הידע של המלכודות ושיטות העבודה המומלצות בזיכרון משותףתרשים המראה שזיכרון משותף חולק רק רצף בייטים ואינו סנכרון כשלעצמו; את המלכודות - קריאת מצב ביניים, תחרות באתחול, התאוששות מקריסה, אי-התאמת ABI ו-false sharing; ואת הקשר לאמצעים כמו הפרדת control plane ו-data plane, חוצץ טבעתי SPSC, חיץ כפול והפניה לפי offset.משתמש במשתמש במשתמש בעלול לגרום למונעמונעמענה מומלץ למענה מומלץ למענה מומלץ לאינו מתיישב עםמחייבעלול לגרום למונעמענה מומלץ למשתמש במשתמש בעלול לגרום למוגדר באמצעותמענה מומלץ לאינו מתיישב עםאינו מתיישב עםמענה מומלץ לשימוש לא מומלץ למשתמש בזיכרון משותףCreateFileMapping/MapViewOfFileshm_open/ftruncate/mmapמיפוי קובץ לזיכרון (memory-mapped file)קריאת מצב בינייםחוצץ טבעתי SPSCפרוטוקול commit בעזרת חיץ כפולהפרדה בין control plane ל-data planeהכותרת הקבועה של הזיכרון המשותףהפניה לפי היסט (offset)משאב מקומי לתהליךתכנון ה-ABI של הזיכרון המשותףתחרות באתחולדפוס שבו רק ה-creator מאתחלתכנון התאוששות מקריסהmutex נטוש (Windows)robust mutex(POSIX)‏false sharing ותחרות על שורת מטמוןמרחב השמות וההרשאות (Global/Local)קיבוע הגדלים לפי דורותשיתוף עם מכונות אחרותתכנון שמנתב התראות לערוץ נפרדתשאול בלולאת busy loop‏named pipe (צינור בעל שם)

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

2. מה זיכרון משותף חולק ומה הוא לא חולק

זיכרון משותף, בגסות, הוא מנגנון שממפה את אותו עמוד פיזי למרחב הכתובות הווירטואלי של כמה תהליכים. ב-Windows משתמשים ב-file mapping object וב-view, וב-POSIX ממפים (mmap) shared memory object.273

יש כאן שתי נקודות חשובות:

  1. מה שמשותף הוא רצף הבייטים, לא הכתובת הווירטואלית עצמה
  2. להיות coherent ולהיות מסונכרן הם דברים נפרדים
אותו עמוד פיזי נראה משני viewsתרשים המראה שזיכרון משותף ממפה את אותו עמוד פיזי למרחב הכתובות הווירטואלי של כמה תהליכים, ושהמשותף הוא רצף הבייטים ולא הכתובת הווירטואלית.view של תהליך Aאותו עמוד פיזיview של תהליך Bהמשותף הוא בייטים, לא כתובת וירטואלית

איור 3: המשותף הוא רצף הבייטים של אותו עמוד פיזי, לא הכתובת הווירטואלית עצמה.

גם בתיעוד של Windows כתוב ש-views שנוצרו מאותו file mapping object הם coherent באותה נקודת זמן. עם זאת, זה לא אומר שהקורא תמיד יכול לקרוא רשומה מעודכנת ועקבית.8

לדוגמה, גם אם ה-writer מתכוון לכתוב לפי הסדר

  • length
  • ואז payload
  • ואז ready flag

אם צד ה-reader קורא בלי שום סנכרון, הוא עלול לראות שילוב של length חדש ו-payload ישן. זיכרון משותף לא מתקן את זה אוטומטית.

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

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

איור 4: הבייט משותף, אבל את המשמעות, הסדר, ההתראה וההתאוששות מתכננים בעצמכם.

3. מתי זיכרון משותף מתאים ומתי לא

מצב מתאים / לא מתאים סיבה
העברת frame או buffer גדול באותה מכונה מתאים קל להפחית את מספר ההעתקות
ערכי חיישן בתדירות גבוהה, תמונה, שמע, נתוני board וכדומה מתאים קל לשאוף לעיכוב נמוך ותפוקה גבוהה
העברת פקודות או תגובות קטנות בלבד פחות מתאים עלות הסנכרון לבקרה יחסית כבדה
תקשורת עם מכונה אחרת לא מתאים זיכרון משותף מניח באופן בסיסי host אחד
הפעלה משותפת ארוכת טווח של שפות/גרסאות שונות קשה דרוש תכנון ABI וגרסאות
נדרשת גם persistence תלוי במטרה file-backed mapping אפשרות טובה, אבל קל לערבב את האחריות של persistence ו-IPC

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

  • ה-UI process מודיע ל-worker process “להשתמש בפריים הבא” דרך event / pipe / socket
  • גוף הפריים בפועל עובר בזיכרון משותף

זו תצורה יחסית שקטה.

בקרה בהודעות, נתונים בזיכרון משותףתרשים המראה שהתראת "להשתמש בפריים הבא" מתהליך ה-UI לתהליך ה-worker נשלחת דרך event, pipe או socket, בעוד גוף הפריים עובר בזיכרון משותף.התראה(event/pipe/socket)כותב את גוף הפרייםקורא את גוף הפרייםתהליך UIתהליך workerזיכרון משותף

איור 5: ההתראה עוברת במערכת הודעות, וגוף הפריים בלבד מונח בזיכרון המשותף.

4. ארבעה דברים שצריך להחליט קודם

כשמתכננים זיכרון משותף, ארבעת הדברים שצריך להחליט ראשית הם אלה.

ארבעה דברים להחליט קודםתרשים המראה את ארבעת הדברים שצריך להחליט תחילה בתכנון זיכרון משותף - הפרדת planes, מודל מקביליות, בעלות ואורך חיים, ABI וגרסה.תכנון הזיכרון המשותףהפרדת planesמודל מקביליותבעלות ואורך חייםABI וגרסה

איור 6: בתחילת התכנון קובעים הפרדה, מודל מקביליות, בעלות ואורך חיים, ו-ABI.

4.1 להפריד בין control plane ל-data plane

קודם קובעים מה שמים ב-shared memory.

  • data plane: תמונה, שמע, סדרת רשומות, נתוני bulk
  • control plane: התחלה, עצירה, שגיאה, חיבור מחדש, אתחול מחדש, התראה

רק ההפרדה בין השניים הופכת את התכנון של צד ה-shared memory לפשוט בהרבה.

4.2 לצמצם את מודל המקביליות

  • SPSC:‏ producer אחד / consumer אחד
  • MPSC: הרבה writer / consumer אחד
  • SPMC: writer אחד / הרבה reader
  • MPMC: הרבה writer / הרבה reader

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

רמת הקושי של מודלי מקביליותתרשים המראה שרמת הקושי עולה בערך לפי הסדר SPSC, MPSC, SPMC, MPMC, ושמעבר ישיר ל-MPMC דורש התמודדות בו-זמנית עם exclusion וסדר זיכרון.SPSC(כתיבה 1, קריאה 1)MPSC(כתיבה מרובה, קריאה 1)SPMC(כתיבה 1, קריאה מרובה)MPMC(כתיבה וקריאה מרובות)exclusion וסדר זיכרון בו-זמנית

איור 7: רמת הקושי של מודל המקביליות עולה בערך מ-SPSC ל-MPMC.

4.3 לקבוע בעלות ואורך חיים

  • מי יוצר
  • מי מאתחל
  • מי מוחק
  • מי משחזר כשמשתתף קורס באמצע

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

4.4 לקבוע ABI וגרסה

  • פריסה (layout)
  • גודל טיפוסים
  • ‏alignment
  • אזור reserved
  • version /‏ feature flags
  • קיום או אי-קיום תאימות

‏shared memory הוא לא עניין של API, אלא של ABI‏ (binary interface). אם מזלזלים כאן, מתקבלת תקלה לא נעימה - תאימות מקור קיימת, אבל דווקא בזמן ריצה זה נשבר.

shared memory הוא עניין של ABIתרשים המראה ש-shared memory הוא הבטחה ברמת ABI ולא API, ושזלזול בכך מוביל לתקלה שבה תאימות המקור קיימת אך זמן הריצה נשבר.shared memoryהבטחת ABI, לא APIזלזול כאןתאימות מקור, אך נשבר בזמן ריצה

איור 8: shared memory הוא הבטחת ABI, וזלזול בו שובר בזמן ריצה למרות תאימות המקור.

5. מלכודות נפוצות

5.1 לא מסנכרנים

זו המלכודת הכי נפוצה.

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

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

גם ב-Windows וגם ב-POSIX, הגישה לזיכרון משותף מניחה שילוב עם אמצעי סנכרון נפרד. גם בתיעוד של Windows כתוב שהגישה ל-view משותף צריכה להתואם באמצעות mutex /‏ semaphore /‏ event וכדומה.2 גם בתיעוד של POSIX כתוב שהגישה ל-shared memory דורשת סנכרון.9

המלכודת של "מה שנכתב יקרא"תרשים המראה שאפשר לקרוא כי מסתכלים על אותו זיכרון, אך העיתוי, היחידה והסדר הנכונים הם עניין נפרד, והגישה מניחה שילוב עם אמצעי סנכרון.'מה שנכתב יקרא'לפעמים אכן קוראיםעיתוי, יחידה וסדר - עניין נפרדמניחים שילוב עם סנכרוןmutex / semaphore / event וכו׳

איור 9: היכולת לקרוא, והיכולת לקרוא ביחידה ובסדר הנכונים, הן דברים שונים - וסנכרון הוא הנחת יסוד.

5.2 מנסים לפתור באמצעות volatile

volatile אינו קסם שמציל את תכנון הזיכרון המשותף. לפחות atomicity ו-mutual exclusion הן בעיות נפרדות.45

לדוגמה, תכנון שמציב volatile bool ready; ומפקח עליו ב-busy loop:

  • מבזבז CPU לריק
  • הבטחת הסדר בין payload ל-ready הופכת מעורפלת
  • אינו portable
  • נוטה לתפוס מצבי ביניים

ובקיצור, אין בזה הרבה טוב.

יתרה מזאת, WaitOnAddress ב-Windows מיועד ל-thread בתוך אותו תהליך. עדיף שלא להתייחס אליו כמנגנון המתנה בין תהליכים (cross-process).10

הבעיות בפיקוח באמצעות volatileתרשים המראה שפיקוח על volatile bool בעזרת busy loop מבזבז CPU, מעורפל בסדר, ונוטה לתפוס מצבי ביניים, ולכן לא כדאי לבסס עליו את התכנון.פיקוח על volatile bool ב-busy loopמבזבז CPUהבטחת סדר מעורפלתנוטה לתפוס מצבי בינייםלא לבסס עליו תכנוןגם WaitOnAddress מיועד ל-thread באותו תהליך

איור 10: busy loop על volatile נחות בשלושה היבטים - CPU, סדר, ומצבי ביניים.

5.3 גורמים לקריאת מצב ביניים

כשקורית תקלה בזיכרון משותף, המראה שלה די רגיל:

  • רק הכותרת חדשה
  • רק ה-payload ישן
  • רק האורך מעודכן
  • הזוגיות בין שני שדות שבורה

בתרשים, אופן קרות התקלה פשוט: ה-reader פשוט נכנס לרווח לפני שה-writer סיים לכתוב את length ואת payload.

קריאת מצב ביניים כשה-writer באמצע כתיבהתרשים המראה שכאשר writer כותב תחילה length חדש ורק אחר כך payload, reader שקורא ללא סנכרון עלול לקבל length חדש יחד עם payload מהדור הקודם.תהליך readerזיכרון משותףתהליך writerתהליך readerזיכרון משותףתהליך writerlength חדש, payload עדיין ישןתופס מצב ביניים - "רק הכותרת חדשה"כותב 1024 לתוך lengthקורא את length1024קורא 1024 בייטים מ-payloadתוכן מהדור הקודםכותב את payloadמדליק את ready flag

איור 11: reader נכנס לרווח לפני שה-writer סיים לכתוב את payload, ותופס מצב ביניים.

הרווח הזה קיים תמיד, כל עוד הכתיבה של length ושל payload אינה “פעולה אחת בלתי ניתנת לחלוקה”. אם מעדכנים סקלר בודד באופן atomic, הסיפור פשוט יחסית, אבל אם חושפים רשומה המורכבת ממספר שדות, נדרש נוהל commit.

בדרך כלל זו אחת מהאפשרויות האלה:

  • להגן על הכול בעזרת mutex
  • לעבור לחיץ כפול ולהחליף לבסוף את “מספר החוצץ הפעיל הנוכחי”
  • לעבור לחוצץ טבעתי ולהחזיק state /‏ sequence לכל slot
  • אם יש writer אחד ו-reader מרובים, לקחת snapshot בעזרת sequence counter

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

5.4 שמים מצביעים או אובייקטים מורכבים כמות שהם

גם זו תבנית שכיחה.

  • מצביע גולמי
  • ‏HANDLE
  • ‏file descriptor
  • ‏std::string
  • ‏std::vector
  • ‏std::unordered_map
  • ‏std::mutex
  • ‏CRITICAL_SECTION

תבנית שמניחה את אלה כמות שהם ב-shared memory ומנסה להשתמש בהם מתהליך אחר. בתהליך הקורא, כמעט תמיד מתקבלת הפרת גישה (access violation) או ערך חסר משמעות.

הסיבה פשוטה: כתובות וירטואליות ומשאבים מקומיים לתהליך (process-local) בעלי משמעות רק בהקשר של אותו process. גם ה-view של Windows: אם אותו mapping ממופה בתהליך אחר, אין ערובה שהכתובת הווירטואלית תהיה זהה.711

לכן, אם נדרשת הפניה, הבסיס הוא להחזיק אותה כהיסט (offset) מכתובת בסיס.

typedef struct ShmRef {
    uint64_t offset;   // מיקום יחסי מתחילת ה-segment
    uint32_t length;
    uint32_t kind;
} ShmRef;

כך, כל process יכול להמיר לכתובת שלו עצמו באמצעות base + offset.

הפניה לפי offset ולא לפי מצביעתרשים המראה שכתובות וירטואליות ומשאבים מקומיים לתהליך חסרי משמעות בתהליך אחר, ולכן ההפניה נשמרת כ-offset מכתובת בסיס ונפתרת בכל תהליך לפי base+offset.במקום זאתמצביע גולמי או HANDLEחסר משמעות בתהליך אחרשמירה כ-offsetכל תהליך פותר לפי base+offset

איור 12: הפניה נשמרת כ-offset מכתובת בסיס, לא כמצביע גולמי.

5.5 שבירת ABI

‏shared memory הוא הבטחה בינארית, לא הבטחה של קוד המקור. כלומר, כל ההבדלים הבאים משפיעים:

  • גודל int /‏ long
  • ייצוג bool
  • ה-underlying type של enum
  • גודל wchar_t
  • הבדל 32bit /‏ 64bit
  • #pragma pack
  • הבדלי compiler / שפה
  • ‏alignment /‏ padding
  • ‏little-endian /‏ big-endian

באותו host לרוב ה-endianness תואם, אבל ברגע שנכנסים תמיכת ARM64 או mixed toolchain, די בקלות נוצר חוסר התאמה.

לכן, ל-struct שמונח ב-shared memory מומלץ מאוד:

  • מספרים שלמים ברוחב קבוע כמו uint32_t /‏ uint64_t
  • ‏padding /‏ reserved מפורשים
  • version,‏ header_size,‏ record_size,‏ total_size בכותרת
  • במידת הצורך, static_assert(sizeof(...))
  • לא לשים אובייקטים non-trivial
מה שובר ABI וכיצד מונעיםתרשים המראה שהבדלי גודל טיפוסים, pack ו-padding והבדלי 32bit/64bit שוברים את ההבטחה הבינארית, ושמניעה מבוססת מספרים שלמים ברוחב קבוע, פריסה מפורשת וכותרת עם גרסה.הבדל בגודל טיפוסיםההבטחה הבינארית נשברתהבדל ב-pack או paddingהבדל 32bit / 64bitמספרים שלמים קבועים + פריסה מפורשתversion ו-size בכותרת

איור 13: הבדלי גודל ו-padding שוברים ABI, ולכן עוברים למספרים שלמים קבועים ופריסה מפורשת.

5.6 תחרות באתחול

‏shared memory נשבר בקלות בגלל ההנחה השגויה ש”מי שיצר בטח כבר אתחל”.

ב-Windows, כאשר CreateFileMapping פוגע בשם קיים, הוא מחזיר את האובייקט הקיים, וניתן לזהות זאת דרך GetLastError() שמחזיר ERROR_ALREADY_EXISTS. העמוד הראשוני של mapping‏ pagefile-backed מתחיל באפס.8 ב-POSIX, אובייקט shared memory חדש מתחיל באורך 0, ומקבל גודל דרך ftruncate. בייטים שהוקצו מחדש מאותחלים לאפס. יצירה עם O_CREAT | O_EXCL היא אטומית.3

אם לא מכירים את ההבדל הזה ו-

  • משתמשים מיד אחרי open
  • אין דגל שמציין השלמת אתחול
  • כמה משתתפים מאתחלים בו-זמנית
  • לא בודקים version mismatch

התוצאה נשברת בהתאם לסדר ההפעלה.

לכל הפחות, כדאי להציב בכותרת הראשית את ה-state הבא:

  • INITIALIZING
  • READY
  • BROKEN

ואז רק ה-creator מאתחל, וה-joiner ממתין ל-READY. רק המנהג הזה מרגיע את העולם באופן משמעותי.

איך נמנעים מתחרות באתחולתרשים המראה שכדי למנוע תחרות באתחול, ה-creator בלבד מאתחל ומדליק state של READY, וה-joiner ממתין ל-READY לפני שהוא מתחיל להשתמש.creator יוצררק creator מאתחלמגדיר state ל-READYjoiner לא משתמש מיד אחרי openממתין ל-READYמתחיל להשתמש

איור 14: רק ה-creator מאתחל, וה-joiner ממתין ל-READY בכותרת לפני שהוא מתחיל.

5.7 לא מתכננים התאוששות מקריסה

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

ה-mutex‏ ב-Windows, כאשר ה-thread הבעלים מסתיים בלי לשחרר, הופך ל-abandoned, וצד ה-wait מקבל WAIT_ABANDONED. המשמעות היא שהמשאב המשותף אולי במצב לא מוגדר.12 גם ב-robust mutex‏ של POSIX, כאשר ה-owner מת מוחזר EOWNERDEAD, וקיים נוהל שבו לאחר תיקון קוראים ל-pthread_mutex_consistent().1314

החשוב הוא לא “להמשיך בכל זאת” בשלב הזה. להתאוששות דרוש לפחות אחד מאלה:

  • מספר generation
  • ‏sequence אחרון שעבר commit
  • ‏heartbeat
  • דגל dirty / clean
  • commit דו-שלבי בסגנון journal
  • נוהל אתחול מלא מחדש במקרה של השחתה
כשה-writer קורס באמצע עדכוןתרשים המראה שכאשר הבעלים מסתיים בלי שחרור, Windows מחזיר WAIT_ABANDONED ו-robust mutex ב-POSIX מחזיר EOWNERDEAD, כך שהמשאב אולי במצב לא מוגדר ולא ממשיכים בלי בירור.writer קורס באמצע עדכוןWAIT_ABANDONED(Windows)EOWNERDEAD(POSIX robust)המשאב המשותף אולי לא מוגדרלא ממשיכים סתם כךgeneration / heartbeat / commit דו-שלבי / אתחול מחדש

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

5.8 ‏false sharing ותחרות על שורת מטמון

נהוג לומר ש-shared memory מהיר. אבל אם מונים “חמים” (hot counters) דחוסים באותו cache line, ה-line נודד הלוך ושוב בין ה-CPU-ים, וזה מאט בצורה שאי אפשר להתעלם ממנה.

הדוגמה האופיינית:

  • ה-producer מעדכן write_index
  • ה-consumer מעדכן read_index
  • שניהם על אותו cache line

במקרה כזה, רק

  • הפרדת שדות hot ל-cache line נפרד
  • הפרדה בין שדות בתדירות עדכון גבוהה לנמוכה
  • מודעות ל-1 writer, 1 cache line

משנה הרבה. מדברים הרבה על יישור ל-64 bytes, אבל כדאי להתייחס לזה כאל ערך נפוץ בהרבה CPU-ים, ולא כחוק מוחלט.

הדוגמה האופיינית ל-false sharingתרשים המראה שכאשר write_index של producer ו-read_index של consumer על אותו cache line, ה-line נודד בין CPU-ים ומאט, ולכן מפרידים שדות hot ל-cache line נפרד.producer מעדכן write_indexאותו cache lineconsumer מעדכן read_indexה-line נודד בין CPU-ים ומאטמפרידים שדה hot ל-cache line נפרד

איור 16: מונה חם על אותו cache line מאט את המערכת, ולכן מפרידים אותו ל-line נפרד.

5.9 מזלזלים בשם, בהרשאות ובאבטחה

‏named shared memory נוח, אבל זלזול בשם ובהרשאות גורם לתקלות.

ל-Windows יש מנהגים כאלה:

  • קיימים namespace-ים Global\ ו-Local\
  • כדי ליצור file mapping חדש תחת Global\ ממקור שאינו session 0, נדרשת SeCreateGlobalPrivilege
  • שם האובייקט חולק namespace עם event /‏ semaphore /‏ mutex /‏ waitable timer /‏ job1582

כלומר, נוצר סוג הבוץ האופייני מאוד ל-Windows כמו:

  • נראה כאילו שימוש ב-"Global\\MyApp" יאפשר שיתוף בין service ל-desktop app
  • אבל זה נכשל בגלל הרשאות
  • ולפעמים כבר קיים mutex בשם זהה, וזה נכשל עם ERROR_INVALID_HANDLE
הבוץ של מרחב השם Globalתרשים המראה שרצון לשתף בין service ל-desktop app בעזרת שם Global נכשל בגלל חוסר SeCreateGlobalPrivilege, או בגלל mutex בשם זהה שכבר קיים באותו namespace.רצון לשתף בשם Globalכישלון בהרשאותהתנגשות עם mutex בשם זההיצירה חדשה דורשת SeCreateGlobalPrivilegeמתקבל ERROR_INVALID_HANDLE

איור 17: מרחב השם Global חושף שני סוגי בוץ - הרשאות והתנגשות שמות.

גם בצד POSIX, זלזול ב-mode וב-umask של shm_open עלול לגרום לחשיפה רחבה מדי, או להיפך, לחוסר יכולת לפתוח.3

‏shared memory אינו בטוח רק בגלל שהוא זיכרון. מכל process שיש לו הרשאת קריאה, הוא נראה בצורה די ישירה. אם שמים בו מידע רגיש, צריך לחשוב עליו כמו על זיכרון רגיל - בהקשר של paging /‏ swap /‏ dump והרשאות.

5.10 שינוי גודל ושדרוג בפזיזות

הרצון “להרחיב מעט בהמשך” את הזיכרון המשותף הוא דרישה מסוכנת יחסית.

  • ל-mapping object של Windows יש גודל שנקבע בזמן היצירה8
  • גם ב-POSIX, אם לא חושבים על התאמה בין ftruncate ל-mmap, אורך ה-map בצד המשתתפים לא יתאים316

בעבודה בפועל, בטוח יותר לקבוע את הגודל כבלתי-משתנה בתוך אותו דור. אם דרושה הרחבה, עדיף:

  1. ליצור segment של version /‏ name /‏ generation חדשים
  2. להעביר את המשתתפים
  3. לסגור את ה-segment הישן

וכך שיעור התקלות יורד.

הרחבת גודל דרך מעבר בין דורותתרשים המראה שגודל הזיכרון המשותף קבוע בתוך דור, ושהרחבה נעשית ביצירת segment של דור חדש, העברת המשתתפים וסגירת ה-segment הישן - נוהל שמוריד את שיעור התקלות.רצון להרחיב בהמשךיוצרים segment של דור חדשמעבירים את המשתתפיםסוגרים את ה-segment הישןresize in place מסוכן

איור 18: הגודל קבוע בתוך דור, וההרחבה נעשית במעבר ל-segment של דור חדש.

5.11 דוחפים גם את ההתראה לתוך shared memory

תבנית שכיחה:

  • ready = 1 בזיכרון משותף
  • הצד השני: while (!ready) Sleep(1);

זה עובד בהתחלה. אבל בהמשך זה חוזר בצורה של:

  • בזבוז CPU
  • תזוזה בעיכוב בגלל Sleep(1)
  • קושי לשים לב לפספוסים (drops)
  • קושי לכתוב timeout או התראת סיום בצורה נקייה

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

  • Windows: event /‏ semaphore /‏ mutex /‏ named pipe וכדומה217
  • POSIX: semaphore /‏ process-shared mutex + condvar וכדומה1819
מעבירים התראה לפרימיטיב שממתינים לותרשים המראה שפיקוח על ready flag בעזרת Sleep מוביל לבזבוז CPU, תזוזה בעיכוב ופספוסים, ולכן מטים את הזיכרון המשותף לנתונים ואת ההתראה לפרימיטיב כמו event או semaphore.פיקוח על ready flag ב-Sleepבזבוז CPU · תזוזה בעיכובקושי לשים לב לפספוסיםמעבירים התראה לפרימיטיב הממתיןב-Windows: event / semaphoreב-POSIX: semaphore / condvar

איור 19: לא מפקחים על דגל, אלא מקבלים התראה דרך פרימיטיב שאפשר להמתין לו.

5.12 חושבים ש”אפשר לשתף גם עם מכונה אחרת”

יש רגעים שמתחשק לחשוב שאם ממפים קובץ משותף דרך רשת בעזרת file-backed mapping, אפשר להגיע לתחושה של shared memory גם עם מכונה אחרת.

זה מסוכן.

גם בתיעוד של CreateFileMapping ב-Windows כתוב שcoherence אינה מובטחת עבור remote file. כאשר שתי מכונות ממפות את אותו עמוד ב-writable, כל אחת רואה רק את הכתיבה שלה, ואין merge בעת עדכון הדיסק.8

זיכרון משותף הוא, באופן בסיסי, מנגנון בתוך אותו host. כשעוברים בין מכונות, עדיף פשוט לבחור socket /‏ RPC /‏ message broker, כדי לשמור על שפיות.

אין לשתף עם מכונה אחרתתרשים המראה שגם מיפוי קובץ משותף דרך הרשת אינו מבטיח coherence עבור remote file, ושזיכרון משותף הוא מנגנון בתוך אותו host, ולכן בין מכונות בוחרים socket, RPC או message broker.רצון לשתף remote file ממופהcoherence אינה מובטחתזיכרון משותף - מנגנון באותו hostעוברים ל-socket / RPC / message broker

איור 20: זיכרון משותף הוא מנגנון בתוך host אחד, ובין מכונות בוחרים במערכת הודעות.

6. שיטות עבודה מומלצות

6.1 הפרדה בין control plane ל-data plane

אם מורידים את ההפרדה שנקבעה בסעיף 4.1 עד לרמת ההקצאה בקוד, זה נראה כך (הצורה הכושלת מתוארת בסעיף 5.11):

  • shared memory: frame, sample, batch, snapshot
  • event / semaphore / pipe / socket: ready, consumed, stop, error, reconnect

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

6.2 להציב כותרת קבועה בראש

מומלץ בחום להציב לפחות כותרת כזו בראש.

typedef struct SharedHeader {
    uint32_t magic;
    uint16_t abi_version;
    uint16_t header_size;

    uint32_t state;          // 0=initializing, 1=ready, 2=broken
    uint32_t flags;

    uint64_t total_size;
    uint64_t generation;
    uint64_t heartbeat_ns;

    uint64_t payload_offset;
    uint64_t payload_size;

    uint64_t write_seq;
    uint64_t read_seq;

    uint8_t  reserved[64];
} SharedHeader;

הנקודות המרכזיות:

  • magic דוחה דבר אחר או מצב לא מאותחל
  • abi_version ו-header_size דוחים הבדל ב-layout
  • state דוחה מצב אתחול ביניים
  • generation מזהה יצירה מחדש
  • heartbeat בודק חיוּת
  • reserved פותח דרך מילוט להרחבה עתידית

הקושי ב-shared memory הוא ש”קשה לראות מה קורה”. בדיוק בגלל זה, נותנים מראש מטא-נתונים לצורכי תצפית.

תפקידי השדות בכותרת הקבועהתרשים המראה שהכותרת הראשית דוחה דבר אחר בעזרת magic, דוחה הבדל layout בעזרת version, דוחה אתחול ביניים בעזרת state, מזהה יצירה מחדש בעזרת generation ובודקת חיות בעזרת heartbeat.הכותרת הקבועה בראשmagic דוחה דבר אחרversion דוחה הבדלstate דוחה מצב בינייםהופכת למטא-נתוני תצפיתgeneration מזהה יצירה מחדשheartbeat בודק חיות

איור 21: כל שדה בכותרת הקבועה דוחה סוג אחר של בעיה - זהות, פערי גרסה, ואתחול ביניים.

6.3 מעבר להפניה לפי offset

את ההפניה מחזיקים כ-offset ולא כ-pointer.

  • פותרים באמצעות base + offset
  • מוסיפים בדיקת טווח ל-offset + length
  • קובעים sentinel לערך לא תקין

רק זה כבר מפחית משמעותית תקלות מסוג אי-התאמת כתובת (address mismatch).

6.4 צמצום מודל המקביליות

מבין ארבעת המודלים בסעיף 4.2, בהתחלה כדאי לבחור באחד מהשניים האלה:

  • ‏SPSC ring buffer
  • ‏snapshot של writer אחד / reader מרובים

חוצץ טבעתי SPSC הוא מבנה שבו, לתוך מערך של slots באורך קבוע, ה-producer כותב במיקום write_seq וה-consumer קורא ממיקום read_seq בלבד. מכיוון שיש writer אחד ו-reader אחד, ההתקדמות היא חד-כיוונית.

מבנה חוצץ טבעתי SPSCתרשים המראה חוצץ טבעתי בן שמונה סלוטים, שבו ה-consumer מתקדם במיקום read_seq רק אחרי שסיים לקרוא, וה-producer מתקדם במיקום write_seq רק אחרי שסיים לכתוב, וכשמגיעים לסוף חוזרים לסלוט 0.חוצץ טבעתי - 8 סלוטיםכשמגיעים לסוף חוזרים ל-slot 0slot 0 - קריאה הסתיימהslot 1 - קריאה הסתיימהslot 2 - טרם נקראslot 3 - טרם נקראslot 4 - בכתיבהslot 5 - פנויslot 6 - פנויslot 7 - פנויconsumer, read_seq=2, מתקדם רק אחרי סיום קריאהproducer, write_seq=4, מתקדם רק אחרי סיום כתיבה

איור 22: מבנה חוצץ טבעתי SPSC. ה-producer וה-consumer מתקדמים בנפרד ובכיוון אחד.

הנקודה החשובה היא לא לשבור את הסדר - “מקדמים index רק אחרי סיום כתיבה” ו-“מקדמים index רק אחרי סיום קריאה”. וכמו בסעיף 5.8, ‏write_seq ו-read_seq מונחים על cache line נפרד.

אם דרוש writer מרובה, בדרך כלל עדיף לצמצם את נקודות האחריות לעקביות, כמו:

  • רק ה-enqueue הוא lock-free /‏ atomic
  • עדכון הנתונים בפועל מרוכז ב-consumer יחיד

6.5 להגדיר commit protocol במפורש

תכנון שלא ניתן להסביר במילים “מאיזה רגע מותר לקרוא” הוא מסוכן.

למשל, עבור חיץ כפול:

  1. כותבים לחוצץ הלא-פומבי
  2. קובעים checksum ואורך
  3. מחליפים את active buffer index עם release
  4. ה-reader קורא את ה-active index עם acquire
  5. אחרי סיום הקריאה, בודקים אם ה-index השתנה

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

טקס הפרסום של חיץ כפולתרשים המראה איך reader קורא את active index באמצעות acquire, ואיך writer כותב לחוצץ הלא-פומבי ומחליף את ה-index באמצעות release, ואיך reader מאמת מחדש את ה-index אחרי סיום הקריאה ומזהה שינוי.readerחוצץ Bactive indexחוצץ Awriterreaderחוצץ Bactive indexחוצץ Awriteractive index הוא Aactive index הוא Bזורק את מה שנקרא וקורא שוב מ-Bקורא עם acquireAקורא את חוצץ Aכותב לחוצץ B הלא-פומביקובע אורך ו-checksumמחליף ל-B עם releaseאחרי סיום קריאה, מאמת מחדש את ה-indexהתברר שהשתנה ל-B

איור 23: טקס הפרסום של חיץ כפול. ה-reader מאמת מחדש את ה-index אחרי שסיים לקרוא.

אם מדלגים על הנוהל הזה - “אימות מחדש של ה-index אחרי סיום הקריאה” - ה-writer עלול לעשות שימוש חוזר באותו חוצץ לכתיבה הבאה בזמן שה-reader עדיין קורא, ומתקבל מצב ביניים זהה לזה שבסעיף 5.3. יש לשים לב שכאשר יש רק שני חוצצים, ההחלפה עלולה לקרות שוב באמצע הקריאה החוזרת; אם קצב העדכון מהיר, כדאי להגדיל את מספר החוצצים או לעבור לשיטת sequence counter שהוזכרה בסעיף 5.3.

6.6 קיבוע הגודל לפי דור

במקום resize in place, קל יותר לתחזק אם חותכים דור, למשל:

  • name = MyShm.v3
  • abi_version = 3
  • generation = 42

זיכרון משותף לא עושה בשבילנו “בדיקת טיפוסים בזמן קריאה” כמו API. לכן חשוב לא לשבור ABI שכבר נקבע.

6.7 להוסיף תצפיתיות

לפחות אלה עוזרים:

  • זמן העדכון האחרון
  • ‏sequence אחרון שהצליח
  • מספר drop / overwrite
  • מספר version mismatch
  • מספר attach / detach
  • ‏last error code
  • ‏heartbeat

כש-shared memory נשבר, בדרך כלל הלוגים דלים. הצבת counters משלכם מקלה מאוד על הטיפול בתקלות.

6.8 להכין קודם בדיקות מקרי קצה

בדיקת המסלול התקין בלבד לא מספיקה. כדאי לבדוק לפחות את אלה:

  • הריגה כפויה של ה-writer באמצע עדכון
  • גלישת ring בגלל עיכוב ה-reader
  • חיבור עם version mismatch
  • ערבוב 32bit /‏ 64bit
  • ‏open החוצה session-ים
  • חוסר הרשאות
  • הפעלה מחדש כשתהליך קודם עדיין מחזיק דור ישן
  • השפעת cache miss /‏ NUMA בהעברה רציפה של נתונים ענקיים

ל-shared memory, בדיקות של דרכי שבירה שוות יותר מבדיקות המסלול התקין.

6.9 דוגמה מינימלית של הלוך-חזור

הנוהג עד כה מונח כאן לתצורה מינימלית שעובדת. תצורה שבה מניחים בלוק אחד עם פריסה קבועה בתוך file mapping מסוג pagefile-backed של Windows, וההתראה עוברת דרך שני auto-reset event.

ארבע ההבטחות המשותפות הן:

  • הבלוק מכיל רק מספרים שלמים ברוחב קבוע ומערכים באורך קבוע. אין מצביע ואין HANDLE
  • בראש הבלוק מונחים magic /‏ abi_version /‏ block_size /‏ state
  • קובעים את האורך רק אחרי סיום כתיבת הגוף, ורק אז מדליקים את ה-event
  • שם ה-event שונה משם ה-file mapping (כי ב-Windows event /‏ semaphore /‏ mutex /‏ waitable timer /‏ job /‏ file mapping חולקים namespace)815
הלוך-חזור בדוגמה המינימליתתרשים המראה שהשולח כותב את הגוף רק אחרי סיום האתחול, קובע את האורך ומדליק event, והמקבל ממתין ל-event, מאמת ABI ו-state, קורא עם בדיקת טווח, ומחזיר תשובה באותו סדר.מקבלבלוק משותףשולחמקבלבלוק משותףשולחמאתחל וקובע state ל-READYקובע את האורך רק אחרי סיום כתיבת הגוףמדליק את ה-event של requestמאמת magic / version / stateקורא עם בדיקת טווח לאורךקובע גם את אורך התשובה רק אחרי כתיבת הגוףמדליק את ה-event של reply

איור 24: הלוך-חזור בדוגמה המינימלית. קובעים אורך רק אחרי כתיבת הגוף, ורק אז מודיעים.

קודם השולח.

/* shm_writer.c : השולח. מפעילים אותו קודם.
 *   cl /W4 /nologo shm_writer.c        (kernel32.lib מקושר כברירת מחדל) */
#include <windows.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>

#define SHM_NAME  L"Local\\KsShmDemo.v1.Block"
#define EVT_REQ   L"Local\\KsShmDemo.v1.Request"
#define EVT_REP   L"Local\\KsShmDemo.v1.Reply"
#define SHM_MAGIC 0x314D4853u   /* הערך של 'S','H','M','1' מסודר ב-little-endian */
#define SHM_ABI   1u
#define STATE_INITIALIZING 0u
#define STATE_READY        1u

#pragma pack(push, 8)
typedef struct DemoBlock {
    uint32_t magic;
    uint32_t abi_version;
    uint32_t block_size;
    uint32_t state;
    uint32_t request_len;
    uint32_t reply_len;
    char     request[256];
    char     reply[256];
} DemoBlock;          /* 24 + 256 + 256 = 536 בייטים */
#pragma pack(pop)

int main(void)
{
    HANDLE hMap = NULL, hReq = NULL, hRep = NULL;
    DemoBlock *blk = NULL;
    uint32_t len = 0;
    DWORD waited = 0;
    int rc = 1;

    /* 1. יוצרים mapping מסוג pagefile-backed. העמוד ההתחלתי מתחיל באפס.
     *    אם השם כבר קיים, CreateFileMappingW "מצליח ומחזיר את הקיים",
     *    ולכן בדיקת NULL בלבד אינה עוצרת שולח שני. אם ממשיכים ישירות ל-memset
     *    למטה, זה ימחק את הבלוק המשותף של הצד השני שפועל.
     *    GetLastError() נקבע גם בהצלחה, ולכן צריך לקרוא אותו מיד אחר כך. */
    hMap = CreateFileMappingW(INVALID_HANDLE_VALUE, NULL, PAGE_READWRITE,
                              0, (DWORD)sizeof(DemoBlock), SHM_NAME);
    if (hMap == NULL) {
        printf("CreateFileMapping failed: %lu\n", GetLastError());
        goto cleanup;
    }
    if (GetLastError() == ERROR_ALREADY_EXISTS) {
        printf("%ls כבר בשימוש. יכול להיות שולח אחד בלבד בו-זמנית\n", SHM_NAME);
        goto cleanup;
    }

    blk = (DemoBlock *)MapViewOfFile(hMap, FILE_MAP_ALL_ACCESS, 0, 0, sizeof(DemoBlock));
    if (blk == NULL) {
        printf("MapViewOfFile failed: %lu\n", GetLastError());
        goto cleanup;
    }

    /* 2. event לצורך התראה. שם שונה מה-mapping */
    hReq = CreateEventW(NULL, FALSE, FALSE, EVT_REQ);   /* auto-reset / לא מסומן */
    hRep = CreateEventW(NULL, FALSE, FALSE, EVT_REP);
    if (hReq == NULL || hRep == NULL) {
        printf("CreateEvent failed: %lu\n", GetLastError());
        goto cleanup;
    }

    /* 3. רק הצד שיצר מאתחל. את ה-state קובעים אחרון */
    memset(blk, 0, sizeof(*blk));
    blk->magic       = SHM_MAGIC;
    blk->abi_version = SHM_ABI;
    blk->block_size  = (uint32_t)sizeof(DemoBlock);
    blk->state       = STATE_INITIALIZING;
    MemoryBarrier();
    blk->state = STATE_READY;

    /* 4. לא לשבור את הסדר: גוף → מחסום → אורך → התראה */
    strcpy_s(blk->request, sizeof(blk->request), "ping from writer");
    MemoryBarrier();
    blk->request_len = (uint32_t)strlen(blk->request);
    if (!SetEvent(hReq)) {
        printf("SetEvent failed: %lu\n", GetLastError());
        goto cleanup;
    }

    /* 5. ממתינים לתשובה. לא ממתינים ללא הגבלה */
    waited = WaitForSingleObject(hRep, 5000);
    if (waited == WAIT_TIMEOUT) {
        printf("אין תגובה מה-reader\n");
        goto cleanup;
    }
    if (waited != WAIT_OBJECT_0) {
        printf("WaitForSingleObject failed: %lu\n", GetLastError());
        goto cleanup;
    }

    /* 6. בודקים טווח לאורך לפני הקריאה */
    len = blk->reply_len;
    if (len > sizeof(blk->reply)) {
        printf("reply_len מחוץ לטווח: %u\n", len);
        goto cleanup;
    }
    printf("reply: %.*s\n", (int)len, blk->reply);
    rc = 0;

cleanup:
    /* סגירת כל ה-view וה-handle מוחקת גם את השם. לא ליפול לפני ה-reader */
    if (hRep != NULL) CloseHandle(hRep);
    if (hReq != NULL) CloseHandle(hReq);
    if (blk  != NULL) UnmapViewOfFile(blk);
    if (hMap != NULL) CloseHandle(hMap);
    return rc;
}

המקבל, לאחר שהגדיר את ההגדרות מ-SHM_NAME ועד DemoBlock בדיוק כמו השולח, מחליף רק את main. בעבודה בפועל, מוציאים את החלק המשותף הזה לקובץ header.

/* shm_reader.c : המקבל. הקבועים והגדרת DemoBlock זהים ל-shm_writer.c.
 *   cl /W4 /nologo shm_reader.c */
int main(void)
{
    HANDLE hMap = NULL, hReq = NULL, hRep = NULL;
    DemoBlock *blk = NULL;
    uint32_t len = 0;
    int rc = 1;

    hMap = OpenFileMappingW(FILE_MAP_ALL_ACCESS, FALSE, SHM_NAME);
    if (hMap == NULL) {
        printf("OpenFileMapping failed: %lu / האם ה-writer פועל?\n", GetLastError());
        goto cleanup;
    }

    blk = (DemoBlock *)MapViewOfFile(hMap, FILE_MAP_ALL_ACCESS, 0, 0, sizeof(DemoBlock));
    if (blk == NULL) {
        printf("MapViewOfFile failed: %lu\n", GetLastError());
        goto cleanup;
    }

    hReq = CreateEventW(NULL, FALSE, FALSE, EVT_REQ);
    hRep = CreateEventW(NULL, FALSE, FALSE, EVT_REP);
    if (hReq == NULL || hRep == NULL) {
        printf("CreateEvent failed: %lu\n", GetLastError());
        goto cleanup;
    }

    /* 1. ממתינים תחילה להתראה. ה-writer מדליק רק אחרי סיום האתחול */
    if (WaitForSingleObject(hReq, 5000) != WAIT_OBJECT_0) {
        printf("ה-request לא הגיע\n");
        goto cleanup;
    }

    /* 2. מאמתים ABI ו-state לפני נגיעה */
    if (blk->magic != SHM_MAGIC || blk->abi_version != SHM_ABI ||
        blk->block_size != (uint32_t)sizeof(DemoBlock)) {
        printf("ABI לא תואם: magic=%08X abi=%u size=%u\n",
               blk->magic, blk->abi_version, blk->block_size);
        goto cleanup;
    }
    if (blk->state != STATE_READY) {
        printf("האתחול עדיין לא הסתיים: state=%u\n", blk->state);
        goto cleanup;
    }

    /* 3. בודקים טווח לאורך ורק אז קוראים */
    len = blk->request_len;
    if (len > sizeof(blk->request)) {
        printf("request_len מחוץ לטווח: %u\n", len);
        goto cleanup;
    }
    printf("request: %.*s\n", (int)len, blk->request);

    /* 4. גוף → מחסום → אורך → התראה, אותו סדר כמו ב-writer */
    strcpy_s(blk->reply, sizeof(blk->reply), "pong from reader");
    MemoryBarrier();
    blk->reply_len = (uint32_t)strlen(blk->reply);
    if (!SetEvent(hRep)) {
        printf("SetEvent failed: %lu\n", GetLastError());
        goto cleanup;
    }
    rc = 0;

cleanup:
    if (hRep != NULL) CloseHandle(hRep);
    if (hReq != NULL) CloseHandle(hReq);
    if (blk  != NULL) UnmapViewOfFile(blk);
    if (hMap != NULL) CloseHandle(hMap);
    return rc;
}

MemoryBarrier מונע reordering שבו האורך או הדגל נראים לפני שסיימו לכתוב את הגוף.5 אם רוצים לתפוס פערי layout כבר בזמן build, מוסיפים ל-MSVC את /std:c11 וקובעים את sizeof(DemoBlock) באמצעות static_assert מ-<assert.h>.

טיפול באותו בלוק מצד C# נראה כך. הנקודה המרכזית היא הצגת ה-offset במפורש כקבועים, כתוב כך שלא יהיה סטייה של אפילו בייט אחד מה-struct בצד C. ‏MemoryMappedFile ו-EventWaitHandle בעלי שם הם ייחודיים ל-Windows.1

// .NET 8 / Windows. השולח: dotnet run -- write, המקבל: dotnet run -- read
using System.IO.MemoryMappedFiles;
using System.Text;

const string MapName = "Local\\KsShmDemo.v1.Block";
const string ReqName = "Local\\KsShmDemo.v1.Request";
const string RepName = "Local\\KsShmDemo.v1.Reply";
const uint Magic = 0x314D4853;   // 'S','H','M','1'
const uint Abi = 1;
const int BlockSize = 536;
const int MaxBody = 256;

// מקבעים את אותה פריסה כמו DemoBlock בצד C, באמצעות קבועי offset
const int OffMagic = 0, OffAbi = 4, OffBlockSize = 8, OffState = 12;
const int OffRequestLen = 16, OffReplyLen = 20, OffRequest = 24, OffReply = 280;

bool isWriter = args.Length > 0 && args[0] == "write";

// השולח משתמש ב-CreateNew. עם CreateOrOpen, שולח שני היה פותח
// כמות שהוא את הבלוק הפעיל, והאתחול למטה היה שובר את התקשורת של הצד השני.
// CreateNew זורק IOException אם השם כבר קיים, וכך אפשר לשים לב לזה
// (אותו רעיון כמו בדיקת ERROR_ALREADY_EXISTS בצד C)
using var mmf = isWriter
    ? MemoryMappedFile.CreateNew(MapName, BlockSize)
    : MemoryMappedFile.OpenExisting(MapName);
using var view = mmf.CreateViewAccessor(0, BlockSize);
using var reqEvent = new EventWaitHandle(false, EventResetMode.AutoReset, ReqName);
using var repEvent = new EventWaitHandle(false, EventResetMode.AutoReset, RepName);

if (isWriter)
{
    view.Write(OffMagic, Magic);
    view.Write(OffAbi, Abi);
    view.Write(OffBlockSize, (uint)BlockSize);
    Thread.MemoryBarrier();
    view.Write(OffState, 1u);              // READY

    byte[] request = Encoding.UTF8.GetBytes("ping from C#");
    view.WriteArray(OffRequest, request, 0, request.Length);
    Thread.MemoryBarrier();
    view.Write(OffRequestLen, (uint)request.Length);
    reqEvent.Set();

    if (!repEvent.WaitOne(TimeSpan.FromSeconds(5)))
    {
        Console.WriteLine("אין תגובה מה-reader");
        return 1;
    }
    return PrintBody(OffReplyLen, OffReply, "reply");
}

if (!reqEvent.WaitOne(TimeSpan.FromSeconds(5)))
{
    Console.WriteLine("ה-request לא הגיע");
    return 1;
}
if (view.ReadUInt32(OffMagic) != Magic || view.ReadUInt32(OffAbi) != Abi
    || view.ReadUInt32(OffBlockSize) != BlockSize || view.ReadUInt32(OffState) != 1u)
{
    Console.WriteLine("ABI לא תואם, או שהאתחול עדיין לא הסתיים");
    return 1;
}
if (PrintBody(OffRequestLen, OffRequest, "request") != 0)
{
    return 1;
}

byte[] reply = Encoding.UTF8.GetBytes("pong from C#");
view.WriteArray(OffReply, reply, 0, reply.Length);
Thread.MemoryBarrier();
view.Write(OffReplyLen, (uint)reply.Length);
repEvent.Set();
return 0;

int PrintBody(int lenOffset, int bodyOffset, string label)
{
    uint length = view.ReadUInt32(lenOffset);
    if (length > MaxBody)
    {
        Console.WriteLine($"אורך {label} מחוץ לטווח: {length}");
        return 1;
    }
    byte[] body = new byte[length];
    view.ReadArray(bodyOffset, body, 0, body.Length);
    Console.WriteLine($"{label}: {Encoding.UTF8.GetString(body)}");
    return 0;
}

הגרסה ב-C והגרסה ב-C# חולקות אותו שם ואותה פריסה, ולכן גם אם אחת מהן היא השולח והשנייה המקבל, ההלוך-חזור עובד. זה מה שהכוונה בקיבוע ABI.

הדוגמה הזו מכוונת בכוונה להלוך-חזור אחד בלבד. אם רוצים העברה רציפה, המשיכו לחוצץ הטבעתי בסעיף 6.4; אם רוצים לעמוד בפני סיום חריג של ה-writer, המשיכו ל-generation ול-heartbeat בסעיף 5.7.

7. נקודות להשוואה בין Windows ל-POSIX

היבט Windows POSIX
יצירה / open CreateFileMapping /‏ OpenFileMapping /‏ MapViewOfFile6 shm_open /‏ ftruncate /‏ mmap3
שיתוף ללא קשר לדיסק ‏mapping מסוג pagefile-backed עם INVALID_HANDLE_VALUE68 ‏POSIX shared memory object + mmap3
ערך התחלתי עמודי pagefile-backed מאותחלים לאפס8 אובייקט חדש באורך 0. בייטים שהוקצו מחדש מאותחלים לאפס3
סנכרון mutex /‏ semaphore /‏ event /‏ interlocked וכדומה25 process-shared mutex /‏ condvar /‏ semaphore2018
אסור להשתמש בהם בין תהליכים CRITICAL_SECTION,‏ WaitOnAddress2110 mutex /‏ condvar שנשארו PTHREAD_PROCESS_PRIVATE2019
מות הבעלים WAIT_ABANDONED12 robust mutex + EOWNERDEAD /‏ pthread_mutex_consistent()1314
מחיקת שם נמחק עם שחרור ה-handle / view האחרון28 מחיקת שם עם shm_unlink. אם נשארה הפניה, הגוף נשאר עד הסוף2223
namespace / הרשאות Global\ /‏ Local\,‏ ACL,‏ SeCreateGlobalPrivilege1524 mode, umask, namespace,‏ O_CREAT|O_EXCL3

גם MemoryMappedFile של C# הוא, במהותו, עטיפה (wrapper) ל-file mapping של Windows. לכן היסודות לא משתנים:

  • פותחים באותו שם
  • משתמשים בנפרד ב-mutex /‏ event
  • קוראים מה-view עם פריסה מפורשת
  • לא שמים הפניה לאובייקט כמות שהיא1
אותם יסודות גם ב-MemoryMappedFileתרשים המראה ש-MemoryMappedFile של C# הוא במהותו עטיפה ל-file mapping של Windows, ולכן פותחים באותו שם, משתמשים בנפרד ב-mutex או event, קוראים בפריסה מפורשת, ולא שמים הפניה לאובייקט.MemoryMappedFile של C#עטיפה ל-file mappingפתיחה באותו שםסנכרון נפרד ב-mutex/eventקריאה בפריסה מפורשתלא שמים הפניה לאובייקט

איור 25: גם ב-MemoryMappedFile של C#, אותם יסודות של file mapping תקפים.

8. רשימת בדיקה ראשונית

  • האם באמת דרוש זיכרון משותף? האם מדובר בנתונים גדולים באותו host?
  • האם הופרד control plane מ-data plane?
  • האם ניתן לצמצם את מודל המקביליות ל-SPSC / writer אחד עם reader מרובים?
  • האם בכותרת הראשית יש magic / version / size / state / generation / heartbeat?
  • האם לא הונחו pointer / HANDLE / fd / STL object / std::mutex?
  • האם קיים commit protocol שמונע מה-reader לראות מצב ביניים?
  • האם מאתחל אחד יחיד מוגדר?
  • האם קיים נוהל התאוששות לסיום חריג?
  • האם השם וההרשאות מוגדרים במפורש?
  • האם Global\ באמת נחוץ?
  • האם לא מונח ההנחה של resize in place?
  • האם נבדקו writer kill / reader stall / version mismatch / חוסר הרשאות?

9. סיכום

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

  • תמונה
  • שמע
  • סדרת חיישנים
  • batch גדול
  • ‏snapshot בתדירות גבוהה

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

  • סנכרון
  • נראות (visibility)
  • אתחול
  • ‏ABI
  • התאוששות
  • הרשאות
  • תצפיתיות
מהות הזיכרון המשותף היא העברת אחריותתרשים המראה שבתמורה להפחתת העתקות ומעבר הודעות דרך הקרנל, האפליקציה מקבלת על עצמה סנכרון, נראות, אתחול, ABI, התאוששות, הרשאות ותצפיתיות.פחות העתקות והודעותמה שמקבלים על עצמם במקוםסנכרון · נראות · אתחולABI · התאוששות · הרשאותתצפיתיות

איור 26: מהות הזיכרון המשותף היא לא “מהירות” אלא העברת אחריות לעקביות אל האפליקציה.

לכן, הכי בטוח שהראשון שלכם ייראה כך:

  • ‏SPSC ring buffer או חיץ כפול
  • כותרת קבועה בראש
  • הפניה לפי offset
  • התראה בערוץ נפרד
  • קיום version /‏ generation /‏ heartbeat
  • קיום בדיקות מקרי קצה

אם מתחילים מהצורה הזו, shared memory הופך לכלי די צייתני. לעומת זאת, אם מתייחסים אליו מהתחלה כאל “זיכרון משותף מהיר שאפשר לשים בו הכול”, זה בהדרגה הופך לא לאפליקציה אלא לארכיאולוגיה.

10. מקורות

  • Windows: היסודות של file mapping ו-named shared memory682
  • Windows: namespace / security / synchronization1524512
  • POSIX: shm_open, shm_unlink, mmap, process-shared / robust synchronization322162013
  • .NET: סקירה כללית של MemoryMappedFile1
  1. Microsoft Learn, “メモリ マップト ファイル” / Microsoft Learn, “MemoryMappedFile クラス”  2 3 4

  2. Microsoft Learn, “Sharing Files and Memory”  2 3 4 5 6 7 8

  3. man7.org, “shm_open(3)”  2 3 4 5 6 7 8 9 10 11

  4. Microsoft Learn, “/volatile (volatile Keyword Interpretation)” / Microsoft Learn, “volatile (C++)”  2

  5. Microsoft Learn, “Interlocked Variable Access” / Microsoft Learn, “MemoryBarrier function”  2 3 4 5

  6. Microsoft Learn, “Creating Named Shared Memory” / Microsoft Learn, “名前付き共有メモリの作成”  2 3 4

  7. Microsoft Learn, “Scope of Allocated Memory”  2

  8. Microsoft Learn, “CreateFileMappingA function”  2 3 4 5 6 7 8 9 10

  9. man7.org, “POSIX Shared Memory” training slides 

  10. Microsoft Learn, “WaitOnAddress function”  2

  11. Microsoft Learn, “MapViewOfFileEx function” / Microsoft Learn, “MapViewOfFile function” 

  12. Microsoft Learn, “Mutex Objects”  2 3

  13. man7.org, “pthread_mutex_lock(3p)” / man7.org, “pthread_mutexattr_setrobust(3)”  2 3

  14. man7.org, “pthread_mutex_consistent(3)” / man7.org, “pthread_mutex_consistent(3p)”  2

  15. Microsoft Learn, “Kernel object namespaces”  2 3 4

  16. man7.org, “mmap(2)”  2

  17. Microsoft Learn, “Using Mutex Objects” 

  18. man7.org, “sem_init(3)” / man7.org, “sem_init(3p)”  2

  19. man7.org, “pthread_condattr_setpshared(3p)” / man7.org, “pthread_condattr_getpshared(3p)”  2

  20. man7.org, “pthread_mutexattr_getpshared(3)” / man7.org, “pthread_mutexattr_getpshared(3p)”  2 3

  21. Microsoft Learn, “Critical Section Objects” 

  22. Microsoft Learn, “ファイル マッピングのセキュリティとアクセス権” / Microsoft Learn, “File Mapping Security and Access Rights”  2

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

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

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

שאלות נפוצות

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

‏האם ערך שנכתב לזיכרון משותף ניתן לקריאה נכונה מיד מתהליכים אחרים?
היכולת לראות והיכולת לקרוא בבטחה הן שתי בעיות נפרדות. זיכרון משותף הוא מנגנון שמראה את אותה רצף בייטים למספר תהליכים, והוא אינו סנכרון כשלעצמו. גם אם ה-writer מתכוון לכתוב לפי הסדר length, payload ו-ready flag, אם צד ה-reader קורא ללא שום סנכרון, הוא עלול לראות שילוב של length חדש עם payload ישן. גם ב-Windows וגם ב-POSIX, הגישה לזיכרון משותף מניחה שילוב עם אמצעי סנכרון כמו mutex,‏ semaphore ו-event.
‏האם מותר לשים בזיכרון משותף מצביעים, std::string או HANDLE?
עדיף שלא. כתובות וירטואליות ומשאבים מקומיים לתהליך (process-local) בעלי משמעות רק בהקשר של אותו תהליך, וגם אם ממפים את אותו mapping בתהליך אחר, אין ערובה שהכתובת הווירטואלית תהיה זהה. הדבר נכון גם לגבי std::vector,‏ std::mutex ו-CRITICAL_SECTION. אם דרושה הפניה, עדיף להחזיק אותה כהיסט (offset) מכתובת בסיס, ולתכנן את הנתונים בזיכרון המשותף כמספרים שלמים ברוחב קבוע + פריסה מפורשת + כותרת עם גרסה.
‏האם שימוש ב-volatile מייתר את הצורך בסנכרון של זיכרון משותף?
לא. volatile אינו קסם שמציל את תכנון הזיכרון המשותף, ולפחות atomicity ו-mutual exclusion הן בעיות נפרדות. תכנון שמפקח על volatile bool בעזרת busy loop מבזבז CPU לריק, הבטחת הסדר בין payload ל-ready flag הופכת מעורפלת, ונוטה לתפוס מצבי ביניים. בנוסף, WaitOnAddress ב-Windows מיועד ל-thread בתוך אותו תהליך, ועדיף שלא להתייחס אליו כמנגנון המתנה בין תהליכים (cross-process). את ההתראה כדאי להעביר לפרימיטיב שאפשר להמתין לו, כמו event או semaphore.
‏מה צריך להחליט ראשית בתכנון זיכרון משותף?
ישנם ארבעה דברים. הפרדה בין control plane ל-data plane - הבקרה (התחלה, עצירה, התראה) דרך מערכת הודעות וגוף הנתונים בזיכרון המשותף; צמצום מודל המקביליות (בהתחלה חוצץ טבעתי SPSC או חיץ כפול פחות נוטים לתקלות); בעלות ואורך חיים - מי יוצר, מאתחל, מוחק ומשחזר; ותכנון ה-ABI כולל פריסה וגרסה. רק הצבת magic,‏ version,‏ size,‏ state,‏ generation ו-heartbeat בכותרת הראשית משנה מאוד את קלות חקירת התקלות.

פרופיל הכותב

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

Go Komura

מנהל KomuraSoft LLC

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

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

חזרה לבלוג