מלכודות של shared memory ו-best practices מעשיים
· עודכן בתאריך: · Go Komura · 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 כותבים בעצמכם
- וכשיש תקלה, הסימפטומים בולטים מאוד
בערך זה הסט של ארבע הנקודות.
flowchart TB
accTitle: שני הצדדים של shared memory
accDescr: תרשים שמראה ש-shared memory נראה כמו IPC מהיר, אבל בפועל זה IPC שמקטין copies ומחזיר את האחריות ל-consistency אל האפליקציה.
f1["נראה כמו IPC מהיר"] --> f2["מה זה בפועל"]
f2 --> f3["מקטין copies"]
f2 --> f4["האחריות ל-consistency אצל האפליקציה"]
f4 -.-> f5["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 מהיר, אבל שימוש רשלני גורם לחשוב שהוא כבר מסונכרן מעצמו. להימנע מזה זו ההתמודדות הראשונה.
flowchart TB
accTitle: ההבדל בין לראות ובין לקרוא בבטחה
accDescr: תרשים שמראה ש-shared memory מראה את אותו רצף bytes לכמה processes ואינו סנכרון בפני עצמו, ושהיכולת לראות ולקרוא בבטחה הן בעיות נפרדות.
k1["מראה את אותו רצף bytes"] --> k2["אינו סנכרון בפני עצמו"]
k2 --> k3["היכולת לראות"]
k2 --> k4["היכולת לקרוא בבטחה"]
k3 --> k5["מתכננים כבעיות נפרדות"]
k4 --> k5
איור 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
יש כאן שתי נקודות חשובות:
- מה שמשותף הוא רצף ה-bytes, לא הכתובת הווירטואלית עצמה
- להיות coherent ולהיות מסונכרן הם דברים נפרדים
flowchart TB
accTitle: אותו physical page נראה משני views
accDescr: תרשים שמראה ש-shared memory ממפה את אותו physical page למרחב הכתובות הווירטואלי של כמה processes, ושהמשותף הוא רצף ה-bytes ולא הכתובת הווירטואלית.
pa["view של process A"] --> pp["אותו physical page"]
pb["view של process B"] --> pp
pp -.-> pn["המשותף הוא 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. את כל אלה צריך לתכנן בעצמכם.
flowchart TB
accTitle: מה משותף ומה לא משותף
accDescr: תרשים שמראה ש-shared memory משתף רק bytes, ושמשמעות, סדר, notification על סיום ומדיניות recovery אינם משותפים ודורשים תכנון בצד האפליקציה.
s1["shared memory"] --> s2["מה שמשותף: bytes"]
s1 --> s3["מה שלא משותף"]
s3 --> s4["משמעות וסדר"]
s3 --> s5["notification על סיום ו-recovery"]
s4 --> s6["מתכננים בעצמכם"]
s5 --> s6
איור 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
זו תצורה יחסית שקטה.
flowchart TB
accTitle: בקרה בהודעות, נתונים ב-shared memory
accDescr: תרשים שמראה שהתראת "להשתמש ב-frame הבא" מ-UI process ל-worker process נשלחת דרך event, pipe או socket, בעוד גוף ה-frame עובר ב-shared memory.
ui["UI process"] -->|"התראה (event / pipe / socket)"| wk["worker process"]
ui -.->|"כותב את גוף ה-frame"| shm["shared memory"]
wk -.->|"קורא את גוף ה-frame"| shm
איור 5: ה-notification עובר במערכת הודעות, וגוף ה-frame בלבד מונח ב-shared memory.
4. ארבעה דברים שכדאי להחליט בהתחלה
כשמתכננים shared memory, ארבעת הדברים שכדאי להחליט ראשית הם אלה.
flowchart TB
accTitle: ארבעה דברים להחליט קודם
accDescr: תרשים שמראה את ארבעת הדברים שכדאי להחליט תחילה בתכנון shared memory: הפרדת planes, מודל מקביליות, בעלות ו-lifetime, ABI וגרסה.
d0["תכנון shared memory"] --> d1["הפרדת planes"]
d0 --> d2["מודל מקביליות"]
d0 --> d3["בעלות ו-lifetime"]
d0 --> d4["ABI וגרסה"]
איור 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 וגם עם סדר הזיכרון, ותקלות שקשה לשחזר בבדיקות צצות בהמשך.
flowchart TB
accTitle: רמת הקושי של מודלי מקביליות
accDescr: תרשים שמראה שרמת הקושי עולה בערך לפי הסדר SPSC, MPSC, SPMC, MPMC, ושמעבר ישיר ל-MPMC דורש התמודדות בו-זמנית עם exclusion וסדר זיכרון.
m1["SPSC (כתיבה 1, קריאה 1)"] --> m2["MPSC (כתיבה מרובה, קריאה 1)"]
m2 --> m3["SPMC (כתיבה 1, קריאה מרובה)"]
m3 --> m4["MPMC (כתיבה וקריאה מרובות)"]
m4 -.-> m5["exclusion וסדר זיכרון בו-זמנית"]
איור 7: רמת הקושי של מודל המקביליות עולה בערך מ-SPSC ל-MPMC.
4.3 לקבוע בעלות ו-lifetime
- מי יוצר
- מי מאתחל
- מי מוחק
- מי משחזר כשמשתתף קורס באמצע
אם זה נשאר מעורפל, ההתנהגות משתנה בכל סדר הפעלה או הפעלה מחדש, וקשה לבודד את הסיבה.
4.4 לקבוע ABI וגרסה
- layout
- גודל טיפוסים
- alignment
- אזור reserved
- version / feature flags
- קיום או אי-קיום תאימות
shared memory הוא לא עניין של API, אלא של ABI (binary interface). אם מזלזלים כאן, מתקבלת תקלה לא נעימה: תאימות מקור קיימת, אבל דווקא בזמן ריצה זה נשבר.
flowchart TB
accTitle: shared memory הוא עניין של ABI
accDescr: תרשים שמראה ש-shared memory הוא הבטחה ברמת ABI ולא API, ושזלזול בכך מוביל לתקלה שבה תאימות המקור קיימת אך זמן הריצה נשבר.
a1["shared memory"] --> a2["הבטחת ABI, לא API"]
a2 --> a3["זלזול כאן"]
a3 --> a4["תאימות מקור, אך נשבר בזמן ריצה"]
איור 8: shared memory הוא הבטחת ABI, וזלזול בו שובר בזמן ריצה למרות תאימות המקור.
5. מלכודות נפוצות
5.1 לא מסנכרנים
זו המלכודת הכי נפוצה.
“אנחנו מסתכלים על אותו זיכרון, אז מה שנכתב אמור להיקרא”
לפעמים אכן אפשר לקרוא. אבל זה לא אומר שקוראים בעיתוי הנכון, ביחידה הנכונה ובסדר הנכון.
גם ב-Windows וגם ב-POSIX, הגישה ל-shared memory מניחה שילוב עם אמצעי סנכרון נפרד. גם בתיעוד של Windows כתוב שהגישה ל-view משותף צריכה להתואם באמצעות mutex / semaphore / event וכדומה.2 גם בתיעוד של POSIX כתוב שהגישה ל-shared memory דורשת סנכרון.9
flowchart TB
accTitle: המלכודת של "מה שנכתב ייקרא"
accDescr: תרשים שמראה שאפשר לקרוא כי מסתכלים על אותו זיכרון, אך העיתוי, היחידה והסדר הנכונים הם עניין נפרד, והגישה מניחה שילוב עם אמצעי סנכרון.
g1["'מה שנכתב ייקרא'"] --> g2["לפעמים אכן קוראים"]
g2 --> g3["עיתוי, יחידה וסדר - עניין נפרד"]
g3 --> g4["מניחים שילוב עם סנכרון"]
g4 -.-> g5["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
flowchart TB
accTitle: הבעיות במעקב באמצעות volatile
accDescr: תרשים שמראה שמעקב אחרי volatile bool בעזרת busy loop מבזבז CPU, מעורפל בסדר, ונוטה לקלוט מצבי ביניים, ולכן לא כדאי לבסס עליו את התכנון.
v1["מעקב אחרי volatile bool ב-busy loop"] --> v2["מבזבז CPU"]
v1 --> v3["הבטחת סדר מעורפלת"]
v1 --> v4["נוטה לקלוט מצבי ביניים"]
v2 --> v5["לא לבסס עליו תכנון"]
v3 --> v5
v4 --> v5
v5 -.-> v6["גם WaitOnAddress מיועד ל-thread באותו process"]
איור 10: busy loop על volatile נחות בשלושה היבטים — CPU, סדר, ומצבי ביניים.
5.3 גורמים לקריאת מצב ביניים
כשקורית תקלה ב-shared memory, המראה שלה די רגיל:
- רק ה-header חדש
- רק ה-payload ישן
- רק האורך מעודכן
- הזוגיות בין שני שדות שבורה
בתרשים, אופן קרות התקלה פשוט: ה-reader פשוט נכנס לרווח לפני שה-writer סיים לכתוב את length ואת payload.
sequenceDiagram
accTitle: קריאת מצב ביניים כשה-writer באמצע כתיבה
accDescr: תרשים שמראה שכאשר writer כותב תחילה length חדש ורק אחר כך payload, reader שקורא ללא סנכרון עלול לקבל length חדש יחד עם payload מהדור הקודם.
participant W as writer process
participant M as shared memory
participant R as reader process
W->>M: כותב 1024 לתוך length
Note over M: length חדש, payload עדיין ישן
R->>M: קורא את length
M-->>R: 1024
R->>M: קורא 1024 bytes מ-payload
M-->>R: תוכן מהדור הקודם
Note over R: תופס מצב ביניים - "רק ה-header חדש"
W->>M: כותב את payload
W->>M: מדליק את 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.
flowchart TB
accTitle: הפניה לפי offset ולא לפי pointer
accDescr: תרשים שמראה שכתובות וירטואליות ומשאבים process-local חסרי משמעות ב-process אחר, ולכן ההפניה נשמרת כ-offset מכתובת בסיס ונפתרת בכל process לפי base+offset.
p1["raw pointer או HANDLE"] --> p2["חסר משמעות ב-process אחר"]
p2 -.->|"במקום זאת"| p3["שמירה כ-offset"]
p3 --> p4["כל 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
flowchart TB
accTitle: מה שובר ABI וכיצד מונעים
accDescr: תרשים שמראה שהבדלי גודל טיפוסים, pack ו-padding והבדלי 32bit/64bit שוברים את ההבטחה הבינארית, ושמניעה מבוססת integers ברוחב קבוע, layout מפורש ו-header עם גרסה.
b1["הבדל בגודל טיפוסים"] --> b4["ההבטחה הבינארית נשברת"]
b2["הבדל ב-pack או padding"] --> b4
b3["הבדל 32bit / 64bit"] --> b4
b4 --> b5["integers קבועים + layout מפורש"]
b5 --> b6["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 הבא:
INITIALIZINGREADYBROKEN
ואז רק ה-creator מאתחל, וה-joiner ממתין ל-READY.
רק הנוהג הזה מרגיע משמעותית את המערכת.
flowchart TB
accTitle: איך נמנעים מ-initialization race
accDescr: תרשים שמראה שכדי למנוע initialization race, ה-creator בלבד מאתחל ומדליק state של READY, וה-joiner ממתין ל-READY לפני שהוא מתחיל להשתמש.
c1["creator יוצר"] --> c2["רק creator מאתחל"]
c2 --> c3["מגדיר state ל-READY"]
j1["joiner לא משתמש מיד אחרי open"] --> j2["ממתין ל-READY"]
c3 -.-> j2
j2 --> j3["מתחיל להשתמש"]
איור 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
- נוהל אתחול מלא מחדש במקרה של השחתה
flowchart TB
accTitle: כשה-writer קורס באמצע עדכון
accDescr: תרשים שמראה שכאשר הבעלים מסתיים בלי שחרור, Windows מחזיר WAIT_ABANDONED ו-robust mutex ב-POSIX מחזיר EOWNERDEAD, כך שהמשאב אולי במצב לא מוגדר ולא ממשיכים בלי בירור.
w1["writer קורס באמצע עדכון"] --> w2["WAIT_ABANDONED (Windows)"]
w1 --> w3["EOWNERDEAD (POSIX robust)"]
w2 --> w4["המשאב המשותף אולי לא מוגדר"]
w3 --> w4
w4 --> w5["לא ממשיכים סתם כך"]
w5 -.-> w6["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-ים, ולא כחוק מוחלט.
flowchart TB
accTitle: הדוגמה האופיינית ל-false sharing
accDescr: תרשים שמראה שכאשר write_index של producer ו-read_index של consumer על אותו cache line, ה-line נודד בין CPU-ים ומאט, ולכן מפרידים שדות hot ל-cache line נפרד.
fp["producer מעדכן write_index"] --> fl["אותו cache line"]
fc["consumer מעדכן read_index"] --> fl
fl --> fs["ה-line נודד בין CPU-ים ומאט"]
fs --> fx["מפרידים שדה 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
flowchart TB
accTitle: הבלגן של namespace בשם Global
accDescr: תרשים שמראה שרצון לשתף בין service ל-desktop app בעזרת שם Global נכשל בגלל חוסר SeCreateGlobalPrivilege, או בגלל mutex בשם זהה שכבר קיים באותו namespace.
n1["רצון לשתף בשם Global"] --> n2["כישלון בהרשאות"]
n1 --> n3["התנגשות עם mutex בשם זהה"]
n2 -.-> n4["יצירה חדשה דורשת SeCreateGlobalPrivilege"]
n3 -.-> n5["מתקבל 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
בשטח, בטוח יותר לקבוע את הגודל כבלתי-משתנה בתוך אותו דור. אם דרושה הרחבה, עדיף:
- ליצור segment של version / name / generation חדשים
- להעביר את המשתתפים
- לסגור את ה-segment הישן
וכך שיעור התקלות יורד.
flowchart TB
accTitle: הרחבת גודל דרך מעבר בין דורות
accDescr: תרשים שמראה שגודל ה-shared memory קבוע בתוך דור, ושהרחבה נעשית ביצירת segment של דור חדש, העברת המשתתפים וסגירת ה-segment הישן — נוהל שמוריד את שיעור התקלות.
z0["רצון להרחיב בהמשך"] --> z1["יוצרים segment של דור חדש"]
z1 --> z2["מעבירים את המשתתפים"]
z2 --> z3["סוגרים את ה-segment הישן"]
z0 -.-> z4["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
flowchart TB
accTitle: מעבירים notification ל-primitive שממתינים לו
accDescr: תרשים שמראה שמעקב אחרי ready flag בעזרת Sleep מוביל לבזבוז CPU, תזוזה ב-latency ו-drops, ולכן מטים את ה-shared memory לנתונים ואת ה-notification ל-primitive כמו event או semaphore.
t1["מעקב אחרי ready flag ב-Sleep"] --> t2["בזבוז CPU · תזוזה ב-latency"]
t1 -.-> t6["קושי לשים לב ל-drops"]
t2 --> t3["מעבירים notification ל-primitive הממתין"]
t3 --> t4["ב-Windows: event / semaphore"]
t3 --> t5["ב-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, כדי לשמור על תכנון ברור.
flowchart TB
accTitle: אין לשתף עם מכונה אחרת
accDescr: תרשים שמראה שגם מיפוי קובץ משותף דרך הרשת אינו מבטיח coherence עבור remote file, וש-shared memory הוא מנגנון בתוך אותו host, ולכן בין מכונות בוחרים socket, RPC או message broker.
r1["רצון לשתף remote file ממופה"] --> r2["coherence אינה מובטחת"]
r2 --> r3["shared memory - מנגנון באותו host"]
r3 --> r4["עוברים ל-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דוחים הבדל ב-layoutstateדוחה מצב אתחול בינייםgenerationמזהה יצירה מחדשheartbeatבודק חיוּתreservedפותח דרך מילוט להרחבה עתידית
הקושי ב-shared memory הוא ש”קשה לראות מה קורה”. בדיוק בגלל זה, נותנים מראש metadata לצורכי תצפית.
flowchart TB
accTitle: תפקידי השדות ב-header הקבוע
accDescr: תרשים שמראה שה-header הראשי דוחה דבר אחר בעזרת magic, דוחה הבדל layout בעזרת version, דוחה אתחול ביניים בעזרת state, מזהה יצירה מחדש בעזרת generation ובודק חיות בעזרת heartbeat.
h0["ה-header הקבוע בראש"] --> h1["magic דוחה דבר אחר"]
h0 --> h2["version דוחה הבדל"]
h0 --> h3["state דוחה מצב ביניים"]
h1 --> h4["הופכת ל-metadata של תצפית"]
h2 --> h4
h3 --> h4
h0 -.-> h5["generation מזהה יצירה מחדש"]
h5 -.-> h6["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 אחד, ההתקדמות היא חד-כיוונית.
flowchart LR
accTitle: מבנה SPSC ring buffer
accDescr: תרשים שמראה ring buffer בן שמונה slots, שבו ה-consumer מתקדם במיקום read_seq רק אחרי שסיים לקרוא, וה-producer מתקדם במיקום write_seq רק אחרי שסיים לכתוב, וכשמגיעים לסוף חוזרים ל-slot 0.
subgraph ring["ring buffer - 8 slots"]
direction LR
s0["slot 0 - קריאה הסתיימה"]
s1["slot 1 - קריאה הסתיימה"]
s2["slot 2 - טרם נקרא"]
s3["slot 3 - טרם נקרא"]
s4["slot 4 - בכתיבה"]
s5["slot 5 - פנוי"]
s6["slot 6 - פנוי"]
s7["slot 7 - פנוי"]
end
C["consumer, read_seq=2, מתקדם רק אחרי סיום קריאה"] --> s2
P["producer, write_seq=4, מתקדם רק אחרי סיום כתיבה"] --> s4
s7 -.->|"כשמגיעים לסוף חוזרים ל-slot 0"| s0
איור 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:
- כותבים ל-buffer הלא-פומבי
- קובעים checksum ואורך
- מחליפים את active buffer index עם
release - ה-reader קורא את ה-active index עם
acquire - אחרי סיום הקריאה, בודקים אם ה-index השתנה
כך קובעים את נוהל ה-publish. כשמציירים את זה בתרשים, ברור שרגע ההחלפה הוא נקודה אחת בלבד.
sequenceDiagram
accTitle: נוהל ה-publish של double buffer
accDescr: תרשים שמראה איך reader קורא את active index באמצעות acquire, ואיך writer כותב ל-buffer הלא-פומבי ומחליף את ה-index באמצעות release, ואיך reader מאמת מחדש את ה-index אחרי סיום הקריאה ומזהה שינוי.
participant W as writer
participant BA as buffer A
participant IX as active index
participant BB as buffer B
participant R as reader
Note over IX: active index הוא A
R->>IX: קורא עם acquire
IX-->>R: A
R->>BA: קורא את buffer A
W->>BB: כותב ל-buffer B הלא-פומבי
W->>BB: קובע אורך ו-checksum
W->>IX: מחליף ל-B עם release
Note over IX: active index הוא B
R->>IX: אחרי סיום קריאה, מאמת מחדש את ה-index
IX-->>R: התברר שהשתנה ל-B
Note over R: זורק את מה שנקרא וקורא שוב מ-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.v3abi_version = 3generation = 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
sequenceDiagram
accTitle: הלוך-חזור בדוגמה המינימלית
accDescr: תרשים שמראה שהשולח כותב את הגוף רק אחרי סיום האתחול, קובע את האורך ומדליק event, והמקבל ממתין ל-event, מאמת ABI ו-state, קורא עם בדיקת טווח, ומחזיר תשובה באותו סדר.
participant W as שולח
participant M as בלוק משותף
participant R as מקבל
W->>M: מאתחל וקובע state ל-READY
W->>M: קובע את האורך רק אחרי סיום כתיבת הגוף
W->>R: מדליק את ה-event של request
R->>M: מאמת magic / version / state
R->>M: קורא עם בדיקת טווח לאורך
R->>M: קובע גם את אורך התשובה רק אחרי כתיבת הגוף
R->>W: מדליק את ה-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
flowchart TB
accTitle: אותם יסודות גם ב-MemoryMappedFile
accDescr: תרשים שמראה ש-MemoryMappedFile של C# הוא במהותו wrapper ל-file mapping של Windows, ולכן פותחים באותו שם, משתמשים בנפרד ב-mutex או event, קוראים ב-layout מפורש, ולא שמים הפניה לאובייקט.
cs["MemoryMappedFile של C#"] --> fm["wrapper ל-file mapping"]
fm --> q1["פתיחה באותו שם"]
fm --> q2["סנכרון נפרד ב-mutex/event"]
fm --> q3["קריאה ב-layout מפורש"]
q3 -.-> q4["לא שמים הפניה לאובייקט"]
איור 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
flowchart TB
accTitle: מהות shared memory היא העברת אחריות
accDescr: תרשים שמראה שבתמורה להפחתת copies ומעבר הודעות דרך הקרנל, האפליקציה מקבלת על עצמה סנכרון, visibility, אתחול, ABI, recovery, הרשאות ו-observability.
e1["פחות copies והודעות"] --> e2["מה שמקבלים על עצמם במקום"]
e2 --> e3["סנכרון · visibility · אתחול"]
e2 --> e4["ABI · recovery · הרשאות"]
e2 --> e5["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
-
Microsoft Learn, “メモリ マップト ファイル” / Microsoft Learn, “MemoryMappedFile クラス” ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, “/volatile (volatile Keyword Interpretation)” / Microsoft Learn, “volatile (C++)” ↩ ↩2
-
Microsoft Learn, “Interlocked Variable Access” / Microsoft Learn, “MemoryBarrier function” ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, “Creating Named Shared Memory” / Microsoft Learn, “名前付き共有メモリの作成” ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, “Scope of Allocated Memory” ↩ ↩2
-
Microsoft Learn, “CreateFileMappingA function” ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10
-
man7.org, “POSIX Shared Memory” training slides ↩
-
Microsoft Learn, “WaitOnAddress function” ↩ ↩2
-
Microsoft Learn, “MapViewOfFileEx function” / Microsoft Learn, “MapViewOfFile function” ↩
-
Microsoft Learn, “Mutex Objects” ↩ ↩2 ↩3
-
man7.org, “pthread_mutex_lock(3p)” / man7.org, “pthread_mutexattr_setrobust(3)” ↩ ↩2 ↩3
-
man7.org, “pthread_mutex_consistent(3)” / man7.org, “pthread_mutex_consistent(3p)” ↩ ↩2
-
Microsoft Learn, “Kernel object namespaces” ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, “Using Mutex Objects” ↩
-
man7.org, “sem_init(3)” / man7.org, “sem_init(3p)” ↩ ↩2
-
Microsoft Learn, “Critical Section Objects” ↩
-
man7.org, “shm_unlink(3p)” ↩ ↩2
-
man7.org, “shm_open(3)” (shm_unlink semantics) ↩
-
Microsoft Learn, “ファイル マッピングのセキュリティとアクセス権” / Microsoft Learn, “File Mapping Security and Access Rights” ↩ ↩2
מאמרים קשורים
מאמרים עדכניים עם אותן תגיות, להעמקה בנושאים קרובים.
Named Pipes בפועל — ה-IPC הסטנדרטי של Windows, מתכנון עד אבטחה
מדריך מעשי ל-Named Pipes, ה-IPC הסטנדרטי ב-Windows. המאמר מסדר לפי מקורות ראשוניים את הבחירה בין byte mode ל-message mode, תכנון server ל...
למה arguments נשברים — כללי command-line arguments ב-Windows
Windows מעביר ל-CreateProcess מחרוזת אחת שהמקבל מפצל. מכסה את כללי CommandLineToArgvW, CRT ו-.NET, ArgumentList, ובניה ב-C++.
מה נשאר אחרי שה-parent מת — מחזיקים child processes ב-Job Object
למה SDK helpers שורדים UI שנהרג ומחזיקים את המצלמה או את ה-COM port? מתכננים משך חיים של child process עם Job Objects, KillOnJobClose ו-c...
רשימת בדיקה לניהול בטוח של child processes באפליקציית Windows
בניהול בטוח של child processes באפליקציית Windows, בעלות על עץ התהליכים ותהליך הסיום חשובים יותר מבחירת API להפעלה. המאמר עובר על Job Obj...
איך קוראים ל-DLL של C# Native AOT מ-C/C++
איך מפרסמים ספריית מחלקות C# כ-DLL native עם Native AOT, וקוראים לנקודות כניסה מסוג UnmanagedCallersOnly מ-C/C++ — לפי מקום השימוש, דפוסי...
נושאים קשורים
העמודים האלה ממקמים את הנושא בהקשר רחב יותר של שירותים והחלטות.
נושאים טכניים ב-Windows
שער לנושאי פיתוח Windows, חקירת תקלות וניצול נכסים קיימים.
שירותים הקשורים לנושא הזה
המאמר קשור ישירות לשירותים הבאים.
פיתוח יישומי Windows
shared memory, file mapping ו-MemoryMappedFile להעברת data בנפח גדול ולתכנון הפרדת processes הם נושא שקשור ישירות ל-Windows Custom Software Development.
ייעוץ טכני וסקירת תכנון
סידור תכנון שמוריד את שיעור התקלות — שיטת סנכרון, תכנון ABI, אסטרטגיית recovery, והפרדה בין control plane ל-data plane — מתאים היטב לייעוץ טכני ול-design review.
שאלות נפוצות
שאלות נפוצות בפניות בנושא המאמר.
- ערך שנכתב ל-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 הראשי משנה מאוד את קלות חקירת התקלות.