מלכודות של shared memory ו-best practices מעשיים

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

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

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

Go Komura (2026). מלכודות של shared memory ו-best practices מעשיים. KomuraSoft LLC. https://doi.org/10.5281/zenodo.22173620 https://comcomponent.com/he/blog/shared-memory-pitfalls-best-practices/

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

פריימי תמונה, תוצאות בדיקה, לוגים של time-series, נתוני board, buffers ענקיים. כשרוצים להעביר data גדול באותו מחשב ב-latency נמוך, shared memory נראה מאוד מפתה.

הבעיה הקטנה כאן היא ש-shared memory מגיע אליכם בתור “IPC מהיר”. בפועל, shared memory הוא “IPC שמקטין copies, ובתמורה מחזיר את האחריות ל-consistency אל האפליקציה”.

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

בערך זה הסט של ארבע הנקודות.

שני הצדדים של shared memoryתרשים שמראה ש-shared memory נראה כמו IPC מהיר, אבל בפועל זה IPC שמקטין copies ומחזיר את האחריות ל-consistency אל האפליקציה.נראה כמו IPC מהירמה זה בפועלמקטין copiesהאחריות ל-consistency אצל האפליקציהprotocol עצמאי · כשל עם סימפטומים בולטים

איור 1: shared memory נראה כמו “IPC מהיר”, אבל מחזיר את האחריות ל-consistency אל האפליקציה.

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

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

המאמר נכתב עבור מפתחים שעומדים להחליט על תכנון להעברת data גדול בין processes באותו מחשב. ההנחה העיקרית היא מי שנוגע ישירות ב-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) תקשורת בין processes. מנגנון כללי להעברת נתונים או איתותים עם process אחר. כולל pipe, socket, named pipe, shared memory ועוד
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. קודם המסקנה, במשפט אחד

בניסוח גס אבל שימושי בשטח, הנה זה:

  • shared memory הוא מנגנון שמראה את אותו רצף bytes לכמה processes, והוא אינו סנכרון בפני עצמו23
  • המהירות בולטת כאשר מעבירים data גדול באותו מחשב. אם מדובר רק בהודעות בקרה קטנות, לרוב נוח יותר להשתמש ב-pipe / socket / named pipe / queue
  • ב-shared memory, היכולת לראות והיכולת לקרוא בבטחה הן בעיות נפרדות
  • עדיף שלא לבסס את התכנון על volatile. atomicity, סדר והמתנה צריך לתכנן בנפרד45
  • אם שמים raw pointer, HANDLE, file descriptor, std::string, std::vector, std::mutex כמות שהם, כמעט תמיד זה נשבר אחר כך
  • בטוח יותר לתכנן את הנתונים ב-shared memory כintegers ברוחב קבוע + layout מפורש + header עם גרסה
  • רק הצבת magic / version / size / state / generation / heartbeat ב-header הראשי משנה מאוד את קלות חקירת התקלות
  • הקושי ב-shared memory הוא לא המהירות, אלא אתחול, lifetime, recovery, הרשאות ו-ABI
  • ב-Windows השלד הוא CreateFileMapping / OpenFileMapping / MapViewOfFile, וב-POSIX הוא shm_open / ftruncate / mmap63
  • הכי פחות נוטה לתקלות זה להתחיל מSPSC ring buffer (single-producer single-consumer) או מdouble buffer

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

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

איור 2: ב-shared memory, “היכולת לראות” ו”היכולת לקרוא בבטחה” הן בעיות נפרדות.

ב-diagram, solid line מציינת relation שתמיד מתקיים ו-dashed line מציינת relation מותנה (התנאים מופיעים בהסבר של כל relation ב-detail page). הרשימה המלאה של ה-relations (סה”כ 24, כולל evidence ו-certainty) וההגדרות של ה-concepts המרכזיים נמצאות ב-detail page של ה-knowledge map (ביפנית). Data: JSON-LD / Turtle

2. מה shared memory משתף, ומה לא

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

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

  1. מה שמשותף הוא רצף ה-bytes, לא הכתובת הווירטואלית עצמה
  2. להיות coherent ולהיות מסונכרן הם דברים נפרדים
אותו physical page נראה משני viewsתרשים שמראה ש-shared memory ממפה את אותו physical page למרחב הכתובות הווירטואלי של כמה processes, ושהמשותף הוא רצף ה-bytes ולא הכתובת הווירטואלית.view של process Aאותו physical pageview של process Bהמשותף הוא bytes, לא כתובת וירטואלית

איור 3: המשותף הוא רצף ה-bytes של אותו physical page, לא הכתובת הווירטואלית עצמה.

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

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

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

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

במילים אחרות, מה ש-shared memory משתף הוא bytes. מה שהוא לא משתף הוא משמעות, סדר, notification על סיום, ומדיניות recovery. את כל אלה צריך לתכנן בעצמכם.

מה משותף ומה לא משותףתרשים שמראה ש-shared memory משתף רק bytes, ושמשמעות, סדר, notification על סיום ומדיניות recovery אינם משותפים ודורשים תכנון בצד האפליקציה.shared memoryמה שמשותף: bytesמה שלא משותףמשמעות וסדרnotification על סיום ו-recoveryמתכננים בעצמכם

איור 4: ה-bytes משותפים, אבל את המשמעות, הסדר, ה-notification וה-recovery מתכננים בעצמכם.

3. מתי shared memory מתאים ומתי לא

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

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

  • ה-UI process מודיע ל-worker process “להשתמש ב-frame הבא” דרך event / pipe / socket
  • גוף ה-frame בפועל עובר ב-shared memory

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

בקרה בהודעות, נתונים ב-shared memoryתרשים שמראה שהתראת "להשתמש ב-frame הבא" מ-UI process ל-worker process נשלחת דרך event, pipe או socket, בעוד גוף ה-frame עובר ב-shared memory.התראה (event / pipe / socket)כותב את גוף ה-frameקורא את גוף ה-frameUI processworker processshared memory

איור 5: ה-notification עובר במערכת הודעות, וגוף ה-frame בלבד מונח ב-shared memory.

4. ארבעה דברים שכדאי להחליט בהתחלה

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

ארבעה דברים להחליט קודםתרשים שמראה את ארבעת הדברים שכדאי להחליט תחילה בתכנון shared memory: הפרדת planes, מודל מקביליות, בעלות ו-lifetime, ABI וגרסה.תכנון shared memoryהפרדת planesמודל מקביליותבעלות ו-lifetimeABI וגרסה

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

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

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

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

רק ההפרדה בין השניים הופכת את התכנון בצד ה-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 לקבוע בעלות ו-lifetime

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

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

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

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

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

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

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

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

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

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

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

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

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

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

כשקורית תקלה ב-shared memory, המראה שלה די רגיל:

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

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

קריאת מצב ביניים כשה-writer באמצע כתיבהתרשים שמראה שכאשר writer כותב תחילה length חדש ורק אחר כך payload, reader שקורא ללא סנכרון עלול לקבל length חדש יחד עם payload מהדור הקודם.reader processshared memorywriter processreader processshared memorywriter processlength חדש, payload עדיין ישןתופס מצב ביניים - "רק ה-header חדש"כותב 1024 לתוך lengthקורא את length1024קורא 1024 bytes מ-payloadתוכן מהדור הקודםכותב את payloadמדליק את ready flag

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

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

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

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

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

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

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

  • raw pointer
  • HANDLE
  • file descriptor
  • std::string
  • std::vector
  • std::unordered_map
  • std::mutex
  • CRITICAL_SECTION

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

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

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

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

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

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

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

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 מומלץ מאוד:

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

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

5.6 initialization race

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

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

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

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

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

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

  • INITIALIZING
  • READY
  • BROKEN

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

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

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

5.7 לא מתכננים crash recovery

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

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

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

  • מספר 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: אם הבעלים קרס, חושדים במצב לא מוגדר ופונים לנוהל recovery לפני המשך.

5.8 false sharing ותחרות על cache line

נהוג לומר ש-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
הבלגן של namespace בשם Globalתרשים שמראה שרצון לשתף בין service ל-desktop app בעזרת שם Global נכשל בגלל חוסר SeCreateGlobalPrivilege, או בגלל mutex בשם זהה שכבר קיים באותו namespace.רצון לשתף בשם Globalכישלון בהרשאותהתנגשות עם mutex בשם זההיצירה חדשה דורשת SeCreateGlobalPrivilegeמתקבל ERROR_INVALID_HANDLE

איור 17: namespace בשם Global חושף שני סוגי בלגן — הרשאות והתנגשות שמות.

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

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

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

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

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

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

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

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

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

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

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

תבנית שכיחה:

  • ready = 1 ב-shared memory
  • הצד השני: while (!ready) Sleep(1);

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

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

עדיף להטות את ה-shared memory לצד הנתונים, ואת ה-notification להעביר לprimitive שאפשר להמתין עליו.

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

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

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

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

זה מסוכן.

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

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

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

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

6. Best practices

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 להציב header קבוע בראש

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

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 הוא ש”קשה לראות מה קורה”. בדיוק בגלל זה, נותנים מראש metadata לצורכי תצפית.

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

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

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

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

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

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

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

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

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

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

מבנה SPSC ring bufferתרשים שמראה ring buffer בן שמונה slots, שבו ה-consumer מתקדם במיקום read_seq רק אחרי שסיים לקרוא, וה-producer מתקדם במיקום write_seq רק אחרי שסיים לכתוב, וכשמגיעים לסוף חוזרים ל-slot 0.ring buffer - 8 slotsכשמגיעים לסוף חוזרים ל-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 ring buffer. ה-producer וה-consumer מתקדמים בנפרד ובכיוון אחד.

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

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

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

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

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

למשל, עבור double buffer:

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

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

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

איור 23: נוהל ה-publish של double buffer. ה-reader מאמת מחדש את ה-index אחרי שסיים לקרוא.

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

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

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

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

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

6.7 להוסיף observability

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

  • זמן העדכון האחרון
  • 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-ים
  • חוסר הרשאות
  • הפעלה מחדש כש-process קודם עדיין מחזיק דור ישן
  • השפעת cache miss / NUMA בהעברה רציפה של data ענק

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

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

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

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

  • הבלוק מכיל רק integers ברוחב קבוע ומערכים באורך קבוע. אין pointer ואין 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 bytes */
#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 כבר בשימוש. יכול להיות writer אחד בלבד באותו זמן\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 לצורך notification. שם שונה מה-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. לא לשבור את הסדר: גוף → מחסום → אורך → notification */
    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. ממתינים תחילה ל-notification. ה-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. גוף → מחסום → אורך → notification, אותו סדר כמו ב-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 במפורש כקבועים, כתוב כך שלא תהיה סטייה של אפילו byte אחד מה-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;

// מקבעים את אותו layout כמו 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# חולקות אותו שם ואותו layout, ולכן גם אם אחת מהן היא השולח והשנייה המקבל, ההלוך-חזור עובד. זה מה שהכוונה בקיבוע ABI.

הדוגמה הזו מכוונת בכוונה להלוך-חזור אחד בלבד. אם רוצים העברה רציפה, המשיכו ל-ring buffer בסעיף 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. bytes שהוקצו מחדש מאותחלים לאפס3
סנכרון mutex / semaphore / event / interlocked וכדומה25 process-shared mutex / condvar / semaphore2018
אסור להשתמש בהם בין processes 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 עם layout מפורש
  • לא שמים הפניה לאובייקט כמות שהיא1
אותם יסודות גם ב-MemoryMappedFileתרשים שמראה ש-MemoryMappedFile של C# הוא במהותו wrapper ל-file mapping של Windows, ולכן פותחים באותו שם, משתמשים בנפרד ב-mutex או event, קוראים ב-layout מפורש, ולא שמים הפניה לאובייקט.MemoryMappedFile של C#wrapper ל-file mappingפתיחה באותו שםסנכרון נפרד ב-mutex/eventקריאה ב-layout מפורשלא שמים הפניה לאובייקט

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

8. Checklist ראשוני

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

9. סיכום

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

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

עם זאת, מהות ה-shared memory היא לא “מהירות” אלא העברת אחריות. במקום להעתיק או להעביר הודעות דרך הקרנל, מקבלים על עצמנו:

  • סנכרון
  • visibility
  • אתחול
  • ABI
  • recovery
  • הרשאות
  • observability
מהות shared memory היא העברת אחריותתרשים שמראה שבתמורה להפחתת copies ומעבר הודעות דרך הקרנל, האפליקציה מקבלת על עצמה סנכרון, visibility, אתחול, ABI, recovery, הרשאות ו-observability.פחות copies והודעותמה שמקבלים על עצמם במקוםסנכרון · visibility · אתחולABI · recovery · הרשאותobservability

איור 26: מהות shared memory היא לא “מהירות” אלא העברת אחריות ל-consistency אל האפליקציה.

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

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

אם מתחילים מהצורה הזו, shared memory הופך לכלי די צפוי. לעומת זאת, אם מתייחסים אליו מההתחלה כאל “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

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

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

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

שאלות נפוצות

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

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

פרופיל הכותב

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

Go Komura

מנהל KomuraSoft LLC

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

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

חזרה לבלוג