המלכודות של הזיכרון המשותף ושיטות העבודה המומלצות בפועל
· עודכן בתאריך: · Go Komura · זיכרון משותף, IPC, מקביליות, C++, C#, פיתוח Windows
פריימים של תמונה, תוצאות בדיקה, יומני time-series, נתוני עומק שוק (board), חוצצים ענקיים. כשרוצים להעביר נתונים גדולים באותה מכונה בעיכוב נמוך, זיכרון משותף די מפתה.
אבל יש כאן משהו מסוכן: זיכרון משותף מתקרב בפרצוף של “IPC מהיר”. בפועל, זיכרון משותף הוא “IPC שמקטין העתקות, אבל בתמורה מחזיר את האחריות לעקביות אל צד האפליקציה”.
- מהיר
- גמיש
- אבל ה-protocol הוא בעצמכם
- וכשקורית תקלה, התסמינים ראוותניים
בערך ארבעת הנקודות האלה.
flowchart TB
accTitle: שני הפרצופים של הזיכרון המשותף
accDescr: תרשים המראה שזיכרון משותף מתקרב בפרצוף של IPC מהיר, אך בפועל הוא IPC שמקטין העתקות ומחזיר את האחריות לעקביות אל צד האפליקציה.
f1["פרצוף של IPC מהיר"] --> f2["המצב בפועל"]
f2 --> f3["מקטין העתקות"]
f2 --> f4["האחריות לעקביות אצל האפליקציה"]
f4 -.-> f5["protocol עצמאי · תקלה ראוותנית"]
איור 1: זיכרון משותף מתקרב בפרצוף של “IPC מהיר”, אבל מחזיר את האחריות לעקביות אל צד האפליקציה.
במאמר הזה, תוך התמקדות ב-file mapping של Windows וב-POSIX shm_open / mmap, נסדר את נקודות התקיעה בשימוש בזיכרון משותף בעבודה בפועל, ואת התכנון שמוריד את שיעור התקלות. גם ב-C/C++ וגם ב-C# עם MemoryMappedFile, המהות כמעט זהה.1
קהל היעד וההנחות
המאמר נכתב עבור מפתחים שעומדים להחליט על תכנון להעברת נתונים גדולים בין תהליכים באותה מכונה. ההנחה העיקרית היא מי שנוגע ישירות ב-file mapping של Windows או ב-shm_open של POSIX ב-C / C++, אבל גם מי שנכנס דרך MemoryMappedFile ב-C# נתקל באותן מלכודות. הפרקים על מלכודות והנחיות תכנון (פרקים 5 ו-6) אינם תלויי שפה.
דוגמה עובדת נמצאת בסעיף 6.9, וכוללת גם C (Windows / MSVC) וגם C#. בצד POSIX מוצגת רק התאמת שמות ה-API בטבלה שבפרק 7.
מונחים לקבוע מראש
בגוף המאמר מופיעים כמה מונחים באנגלית כפי שהם. כדי לא להיתקע בהופעה הראשונה שלהם, הנה סיכום מראש.
| מונח | משמעות |
|---|---|
| IPC (Inter-Process Communication) | תקשורת בין תהליכים. מנגנון כללי להעברת נתונים או איתותים עם תהליך אחר. כולל pipe, socket, named pipe, זיכרון משותף ועוד |
| coherent | כמה views שמצביעים על אותה ישות נראים באותו תוכן באותה נקודת זמן. אין הכוונה ל”קורא יכול תמיד לקרוא רשומה מעודכנת ועקבית” |
| ABI (Application Binary Interface) | לא הסכם ברמת קוד המקור, אלא הבטחה ברמת הבינארי בין קבצי הרצה. כולל גודל טיפוסים, alignment, padding וסדר השדות במבנה |
| SPSC / MPSC / SPMC / MPMC | קיצור למספר ה-producer וה-consumer. S=single, M=multi, P=producer, C=consumer. ב-SPSC יש writer אחד ו-reader אחד. מורחב בסעיף 4.2 |
| lock-free | תכנון שמתקדם רק בעזרת פעולות atomic, בלי לנעול. מתאר הבטחת התקדמות - “תמיד יש thread אחד שיכול להתקדם” - תכונה שונה מ”מהיר” |
| sentinel | ערך מיוחד ששמור מראש כדי לציין “לא תקף” או “סוף”. למשל, עבור offset אפשר לקבוע ש-UINT64_MAX פירושו לא תקף |
| NUMA (Non-Uniform Memory Access) | תצורה שבה המרחק של הזיכרון מנקודת המבט של ה-CPU אינו אחיד. גישה לזיכרון בצומת רחוק מאיטה בצורה ניכרת, גם לאותו קוד |
1. המסקנה קודם (במשפט אחד)
בניסוח גס אבל שימושי בעבודה בפועל, הנה זה:
- זיכרון משותף הוא מנגנון שמראה את אותה רצף בייטים למספר תהליכים, והוא אינו סנכרון כשלעצמו23
- המהירות בולטת כאשר מעבירים נתונים גדולים באותה מכונה. אם מדובר רק בהודעות בקרה קטנות, לרוב נוח יותר להשתמש ב-pipe / socket / named pipe / queue
- בזיכרון משותף, היכולת לראות והיכולת לקרוא בבטחה הן בעיות נפרדות
- עדיף שלא לבסס את התכנון על
volatile. אטומיות, סדר והמתנה צריך לתכנן בנפרד45 - אם שמים מצביע גולמי, HANDLE, file descriptor, std::string, std::vector, std::mutex כמות שהם, כמעט תמיד זה מסתיים בבכי מאוחר יותר
- בטוח יותר לתכנן את הנתונים בזיכרון המשותף כמספרים שלמים ברוחב קבוע + פריסה מפורשת + כותרת עם גרסה
- רק הצבת magic / version / size / state / generation / heartbeat בכותרת הראשית משנה מאוד את קלות חקירת התקלות
- הקושי בזיכרון משותף הוא לא המהירות, אלא אתחול, אורך חיים, התאוששות, הרשאות ו-ABI
- ב-Windows השלד הוא
CreateFileMapping/OpenFileMapping/MapViewOfFile, וב-POSIX הואshm_open/ftruncate/mmap63 - הכי פחות נוטה לתקלות זה להתחיל מחוצץ טבעתי SPSC (single-producer single-consumer) או מחיץ כפול
בקיצור, זיכרון משותף מהיר, אבל שימוש רשלני בו גורם ל”מחלת התחושה שהוא מסונכרן מעצמו”. להימנע מזה זו ההתמודדות הראשונה.
flowchart TB
accTitle: ההבדל בין לראות ובין לקרוא בבטחה
accDescr: תרשים המראה שזיכרון משותף מראה את אותה רצף בייטים למספר תהליכים ואינו סנכרון כשלעצמו, ושהיכולת לראות ולקרוא בבטחה הן בעיות נפרדות.
k1["מראה את אותה רצף בייטים"] --> k2["אינו סנכרון כשלעצמו"]
k2 --> k3["היכולת לראות"]
k2 --> k4["היכולת לקרוא בבטחה"]
k3 --> k5["מתכננים כבעיות נפרדות"]
k4 --> k5
איור 2: בזיכרון משותף, “היכולת לראות” ו”היכולת לקרוא בבטחה” הן בעיות נפרדות.
מפת הידע של המאמר
זיכרון משותף הוא מנגנון שמשתמש ב-CreateFileMapping/MapViewOfFile של Windows או ב-shm_open/mmap של POSIX כדי להראות את אותו עמוד פיזי למספר תהליכים, אך הוא אינו סנכרון כשלעצמו, ולכן קריאה וכתיבה של מספר שדות ללא סנכרון עלולות לגרום לתקלה שבה קוראים את מצב הכתיבה של ה-writer באמצע. תקלה כזו נמנעת באמצעות פרוטוקול commit על בסיס חוצץ טבעתי SPSC או חיץ כפול; מצביעים גולמיים ומשאבים מקומיים לתהליך כמו HANDLE אינם ניתנים להנחה כמות שהם ולכן מוחלפים בהפניה לפי offset; ומומלץ להפריד בין control plane ל-data plane ולנתב את ההתראות לערוץ נפרד. בנוסף, קיבוע ה-ABI, מניעת תחרות באתחול באמצעות דפוס שבו רק היוצר מאתחל, תכנון התאוששות מקריסה בעזרת mutex נטוש ב-Windows או robust mutex ב-POSIX, סידור מרחב השמות וההרשאות, וקיבוע הגודל לפי דורות - הם עיקרי העבודה בפועל שמורידים את שיעור התקלות.
flowchart LR
accTitle: מפת הידע של המלכודות ושיטות העבודה המומלצות בזיכרון משותף
accDescr: תרשים המראה שזיכרון משותף חולק רק רצף בייטים ואינו סנכרון כשלעצמו; את המלכודות - קריאת מצב ביניים, תחרות באתחול, התאוששות מקריסה, אי-התאמת ABI ו-false sharing; ואת הקשר לאמצעים כמו הפרדת control plane ו-data plane, חוצץ טבעתי SPSC, חיץ כפול והפניה לפי offset.
shared_memory["זיכרון משותף"]
windows_createfilemapping["CreateFileMapping/MapViewOfFile"]
posix_shm_open["shm_open/ftruncate/mmap"]
memory_mapped_file["מיפוי קובץ לזיכרון (memory-mapped file)"]
torn_read["קריאת מצב ביניים"]
spsc_ring_buffer["חוצץ טבעתי SPSC"]
double_buffering_commit["פרוטוקול commit בעזרת חיץ כפול"]
control_plane_data_plane_separation["הפרדה בין control plane ל-data plane"]
shared_memory_header["הכותרת הקבועה של הזיכרון המשותף"]
offset_reference["הפניה לפי היסט (offset)"]
process_local_resource["משאב מקומי לתהליך"]
shared_memory_abi["תכנון ה-ABI של הזיכרון המשותף"]
initialization_race["תחרות באתחול"]
creator_joiner_initialization["דפוס שבו רק ה-creator מאתחל"]
crash_recovery_design["תכנון התאוששות מקריסה"]
abandoned_mutex["mutex נטוש (Windows)"]
robust_mutex["robust mutex(POSIX)"]
false_sharing["false sharing ותחרות על שורת מטמון"]
shared_memory_namespace_permission["מרחב השמות וההרשאות (Global/Local)"]
fixed_generation_sizing["קיבוע הגדלים לפי דורות"]
remote_machine_sharing["שיתוף עם מכונות אחרות"]
notification_channel_separation["תכנון שמנתב התראות לערוץ נפרד"]
busy_loop_polling["תשאול בלולאת busy loop"]
named_pipe["named pipe (צינור בעל שם)"]
shared_memory -.->|"משתמש ב"| windows_createfilemapping
shared_memory -.->|"משתמש ב"| posix_shm_open
windows_createfilemapping -->|"משתמש ב"| memory_mapped_file
shared_memory -.->|"עלול לגרום ל"| torn_read
spsc_ring_buffer -.->|"מונע"| torn_read
double_buffering_commit -.->|"מונע"| torn_read
control_plane_data_plane_separation -->|"מענה מומלץ ל"| shared_memory
shared_memory_header -->|"מענה מומלץ ל"| shared_memory
offset_reference -->|"מענה מומלץ ל"| shared_memory
process_local_resource -->|"אינו מתיישב עם"| shared_memory
shared_memory -->|"מחייב"| shared_memory_abi
shared_memory -.->|"עלול לגרום ל"| initialization_race
creator_joiner_initialization -->|"מונע"| initialization_race
crash_recovery_design -->|"מענה מומלץ ל"| shared_memory
crash_recovery_design -.->|"משתמש ב"| abandoned_mutex
crash_recovery_design -.->|"משתמש ב"| robust_mutex
shared_memory -.->|"עלול לגרום ל"| false_sharing
shared_memory -.->|"מוגדר באמצעות"| shared_memory_namespace_permission
fixed_generation_sizing -->|"מענה מומלץ ל"| shared_memory
shared_memory -->|"אינו מתיישב עם"| remote_machine_sharing
windows_createfilemapping -->|"אינו מתיישב עם"| remote_machine_sharing
notification_channel_separation -->|"מענה מומלץ ל"| shared_memory
busy_loop_polling -->|"שימוש לא מומלץ ל"| shared_memory
notification_channel_separation -.->|"משתמש ב"| named_pipe
בתרשים, קו מלא מציין קשר שמתקיים תמיד וקו מקווקו מציין קשר מותנה (תנאי ההתקיימות מפורטים בהסבר של כל קשר בעמוד המפורט). רשימת כל הקשרים (סך הכול 24, עם אסמכתה ורמת ודאות) והגדרות המושגים המרכזיים מרוכזות בעמוד המפורט של מפת הידע (ביפנית). נתונים: JSON-LD / Turtle
2. מה זיכרון משותף חולק ומה הוא לא חולק
זיכרון משותף, בגסות, הוא מנגנון שממפה את אותו עמוד פיזי למרחב הכתובות הווירטואלי של כמה תהליכים. ב-Windows משתמשים ב-file mapping object וב-view, וב-POSIX ממפים (mmap) shared memory object.273
יש כאן שתי נקודות חשובות:
- מה שמשותף הוא רצף הבייטים, לא הכתובת הווירטואלית עצמה
- להיות coherent ולהיות מסונכרן הם דברים נפרדים
flowchart TB
accTitle: אותו עמוד פיזי נראה משני views
accDescr: תרשים המראה שזיכרון משותף ממפה את אותו עמוד פיזי למרחב הכתובות הווירטואלי של כמה תהליכים, ושהמשותף הוא רצף הבייטים ולא הכתובת הווירטואלית.
pa["view של תהליך A"] --> pp["אותו עמוד פיזי"]
pb["view של תהליך B"] --> pp
pp -.-> pn["המשותף הוא בייטים, לא כתובת וירטואלית"]
איור 3: המשותף הוא רצף הבייטים של אותו עמוד פיזי, לא הכתובת הווירטואלית עצמה.
גם בתיעוד של Windows כתוב ש-views שנוצרו מאותו file mapping object הם coherent באותה נקודת זמן. עם זאת, זה לא אומר שהקורא תמיד יכול לקרוא רשומה מעודכנת ועקבית.8
לדוגמה, גם אם ה-writer מתכוון לכתוב לפי הסדר
length- ואז
payload - ואז
ready flag
אם צד ה-reader קורא בלי שום סנכרון, הוא עלול לראות שילוב של length חדש ו-payload ישן.
זיכרון משותף לא מתקן את זה אוטומטית.
במילים אחרות, מה שהזיכרון המשותף חולק הוא בייטים. מה שהוא לא חולק הוא משמעות, סדר, התראת סיום, ומדיניות התאוששות. את כל אלה צריך לתכנן בעצמכם.
flowchart TB
accTitle: מה משותף ומה לא משותף
accDescr: תרשים המראה שזיכרון משותף חולק רק בייטים, ושמשמעות, סדר, התראת סיום ומדיניות התאוששות אינם משותפים ודורשים תכנון בצד האפליקציה.
s1["זיכרון משותף"] --> s2["מה שמשותף: בייטים"]
s1 --> s3["מה שלא משותף"]
s3 --> s4["משמעות וסדר"]
s3 --> s5["התראת סיום והתאוששות"]
s4 --> s6["מתכננים בעצמכם"]
s5 --> s6
איור 4: הבייט משותף, אבל את המשמעות, הסדר, ההתראה וההתאוששות מתכננים בעצמכם.
3. מתי זיכרון משותף מתאים ומתי לא
| מצב | מתאים / לא מתאים | סיבה |
|---|---|---|
| העברת frame או buffer גדול באותה מכונה | מתאים | קל להפחית את מספר ההעתקות |
| ערכי חיישן בתדירות גבוהה, תמונה, שמע, נתוני board וכדומה | מתאים | קל לשאוף לעיכוב נמוך ותפוקה גבוהה |
| העברת פקודות או תגובות קטנות בלבד | פחות מתאים | עלות הסנכרון לבקרה יחסית כבדה |
| תקשורת עם מכונה אחרת | לא מתאים | זיכרון משותף מניח באופן בסיסי host אחד |
| הפעלה משותפת ארוכת טווח של שפות/גרסאות שונות | קשה | דרוש תכנון ABI וגרסאות |
| נדרשת גם persistence | תלוי במטרה | file-backed mapping אפשרות טובה, אבל קל לערבב את האחריות של persistence ו-IPC |
בעבודה בפועל, ההפרדה בקרה דרך מערכת הודעות, גוף הנתונים בזיכרון משותף חזקה למדי. למשל:
- ה-UI process מודיע ל-worker process “להשתמש בפריים הבא” דרך event / pipe / socket
- גוף הפריים בפועל עובר בזיכרון משותף
זו תצורה יחסית שקטה.
flowchart TB
accTitle: בקרה בהודעות, נתונים בזיכרון משותף
accDescr: תרשים המראה שהתראת "להשתמש בפריים הבא" מתהליך ה-UI לתהליך ה-worker נשלחת דרך event, pipe או socket, בעוד גוף הפריים עובר בזיכרון משותף.
ui["תהליך UI"] -->|"התראה(event/pipe/socket)"| wk["תהליך worker"]
ui -.->|"כותב את גוף הפריים"| shm["זיכרון משותף"]
wk -.->|"קורא את גוף הפריים"| shm
איור 5: ההתראה עוברת במערכת הודעות, וגוף הפריים בלבד מונח בזיכרון המשותף.
4. ארבעה דברים שצריך להחליט קודם
כשמתכננים זיכרון משותף, ארבעת הדברים שצריך להחליט ראשית הם אלה.
flowchart TB
accTitle: ארבעה דברים להחליט קודם
accDescr: תרשים המראה את ארבעת הדברים שצריך להחליט תחילה בתכנון זיכרון משותף - הפרדת planes, מודל מקביליות, בעלות ואורך חיים, ABI וגרסה.
d0["תכנון הזיכרון המשותף"] --> d1["הפרדת planes"]
d0 --> d2["מודל מקביליות"]
d0 --> d3["בעלות ואורך חיים"]
d0 --> d4["ABI וגרסה"]
איור 6: בתחילת התכנון קובעים הפרדה, מודל מקביליות, בעלות ואורך חיים, ו-ABI.
4.1 להפריד בין control plane ל-data plane
קודם קובעים מה שמים ב-shared memory.
- data plane: תמונה, שמע, סדרת רשומות, נתוני bulk
- control plane: התחלה, עצירה, שגיאה, חיבור מחדש, אתחול מחדש, התראה
רק ההפרדה בין השניים הופכת את התכנון של צד ה-shared memory לפשוט בהרבה.
4.2 לצמצם את מודל המקביליות
- SPSC: producer אחד / consumer אחד
- MPSC: הרבה writer / consumer אחד
- SPMC: writer אחד / הרבה reader
- MPMC: הרבה writer / הרבה reader
רמת הקושי עולה בערך לפי הסדר הזה. לא מומלץ לצאת ישר ל-MPMC. אז צריך להתמודד בו-זמנית גם עם exclusion בין ה-writers וגם עם סדר הזיכרון, ותקלות שקשה לשחזר בבדיקות צצות בהמשך.
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 לקבוע בעלות ואורך חיים
- מי יוצר
- מי מאתחל
- מי מוחק
- מי משחזר כשמשתתף קורס באמצע
אם זה נשאר מעורפל, ההתנהגות משתנה בכל סדר הפעלה או הפעלה מחדש, וקשה לבודד את הסיבה.
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, הגישה לזיכרון משותף מניחה שילוב עם אמצעי סנכרון נפרד. גם בתיעוד של 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 אינו קסם שמציל את תכנון הזיכרון המשותף.
לפחות atomicity ו-mutual exclusion הן בעיות נפרדות.45
לדוגמה, תכנון שמציב volatile bool ready; ומפקח עליו ב-busy loop:
- מבזבז CPU לריק
- הבטחת הסדר בין payload ל-ready הופכת מעורפלת
- אינו portable
- נוטה לתפוס מצבי ביניים
ובקיצור, אין בזה הרבה טוב.
יתרה מזאת, WaitOnAddress ב-Windows מיועד ל-thread בתוך אותו תהליך.
עדיף שלא להתייחס אליו כמנגנון המתנה בין תהליכים (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 באותו תהליך"]
איור 10: busy loop על volatile נחות בשלושה היבטים - CPU, סדר, ומצבי ביניים.
5.3 גורמים לקריאת מצב ביניים
כשקורית תקלה בזיכרון משותף, המראה שלה די רגיל:
- רק הכותרת חדשה
- רק ה-payload ישן
- רק האורך מעודכן
- הזוגיות בין שני שדות שבורה
בתרשים, אופן קרות התקלה פשוט: ה-reader פשוט נכנס לרווח לפני שה-writer סיים לכתוב את length ואת payload.
sequenceDiagram
accTitle: קריאת מצב ביניים כשה-writer באמצע כתיבה
accDescr: תרשים המראה שכאשר writer כותב תחילה length חדש ורק אחר כך payload, reader שקורא ללא סנכרון עלול לקבל length חדש יחד עם payload מהדור הקודם.
participant W as תהליך writer
participant M as זיכרון משותף
participant R as תהליך reader
W->>M: כותב 1024 לתוך length
Note over M: length חדש, payload עדיין ישן
R->>M: קורא את length
M-->>R: 1024
R->>M: קורא 1024 בייטים מ-payload
M-->>R: תוכן מהדור הקודם
Note over R: תופס מצב ביניים - "רק הכותרת חדשה"
W->>M: כותב את payload
W->>M: מדליק את ready flag
איור 11: reader נכנס לרווח לפני שה-writer סיים לכתוב את payload, ותופס מצב ביניים.
הרווח הזה קיים תמיד, כל עוד הכתיבה של length ושל payload אינה “פעולה אחת בלתי ניתנת לחלוקה”. אם מעדכנים סקלר בודד באופן atomic, הסיפור פשוט יחסית, אבל אם חושפים רשומה המורכבת ממספר שדות, נדרש נוהל commit.
בדרך כלל זו אחת מהאפשרויות האלה:
- להגן על הכול בעזרת mutex
- לעבור לחיץ כפול ולהחליף לבסוף את “מספר החוצץ הפעיל הנוכחי”
- לעבור לחוצץ טבעתי ולהחזיק state / sequence לכל slot
- אם יש writer אחד ו-reader מרובים, לקחת snapshot בעזרת sequence counter
גם “מדליקים את ready flag בסוף” בלבד אינו מספיק - צריך גם לקבוע באיזה סדר זיכרון כותבים וקוראים את הדגל הזה, אחרת התכנון עדיין רך מדי. בזיכרון משותף, עיתוי החשיפה עצמו הוא הפרוטוקול.
5.4 שמים מצביעים או אובייקטים מורכבים כמות שהם
גם זו תבנית שכיחה.
- מצביע גולמי
- HANDLE
- file descriptor
- std::string
- std::vector
- std::unordered_map
- std::mutex
- CRITICAL_SECTION
תבנית שמניחה את אלה כמות שהם ב-shared memory ומנסה להשתמש בהם מתהליך אחר. בתהליך הקורא, כמעט תמיד מתקבלת הפרת גישה (access violation) או ערך חסר משמעות.
הסיבה פשוטה: כתובות וירטואליות ומשאבים מקומיים לתהליך (process-local) בעלי משמעות רק בהקשר של אותו process. גם ה-view של Windows: אם אותו mapping ממופה בתהליך אחר, אין ערובה שהכתובת הווירטואלית תהיה זהה.711
לכן, אם נדרשת הפניה, הבסיס הוא להחזיק אותה כהיסט (offset) מכתובת בסיס.
typedef struct ShmRef {
uint64_t offset; // מיקום יחסי מתחילת ה-segment
uint32_t length;
uint32_t kind;
} ShmRef;
כך, כל process יכול להמיר לכתובת שלו עצמו באמצעות base + offset.
flowchart TB
accTitle: הפניה לפי offset ולא לפי מצביע
accDescr: תרשים המראה שכתובות וירטואליות ומשאבים מקומיים לתהליך חסרי משמעות בתהליך אחר, ולכן ההפניה נשמרת כ-offset מכתובת בסיס ונפתרת בכל תהליך לפי base+offset.
p1["מצביע גולמי או HANDLE"] --> p2["חסר משמעות בתהליך אחר"]
p2 -.->|"במקום זאת"| p3["שמירה כ-offset"]
p3 --> p4["כל תהליך פותר לפי base+offset"]
איור 12: הפניה נשמרת כ-offset מכתובת בסיס, לא כמצביע גולמי.
5.5 שבירת ABI
shared memory הוא הבטחה בינארית, לא הבטחה של קוד המקור. כלומר, כל ההבדלים הבאים משפיעים:
- גודל
int/long - ייצוג
bool - ה-underlying type של
enum - גודל
wchar_t - הבדל 32bit / 64bit
-
#pragma pack - הבדלי compiler / שפה
- alignment / padding
- little-endian / big-endian
באותו host לרוב ה-endianness תואם, אבל ברגע שנכנסים תמיכת ARM64 או mixed toolchain, די בקלות נוצר חוסר התאמה.
לכן, ל-struct שמונח ב-shared memory מומלץ מאוד:
- מספרים שלמים ברוחב קבוע כמו
uint32_t/uint64_t - padding / reserved מפורשים
-
version,header_size,record_size,total_sizeבכותרת - במידת הצורך,
static_assert(sizeof(...)) - לא לשים אובייקטים non-trivial
flowchart TB
accTitle: מה שובר ABI וכיצד מונעים
accDescr: תרשים המראה שהבדלי גודל טיפוסים, pack ו-padding והבדלי 32bit/64bit שוברים את ההבטחה הבינארית, ושמניעה מבוססת מספרים שלמים ברוחב קבוע, פריסה מפורשת וכותרת עם גרסה.
b1["הבדל בגודל טיפוסים"] --> b4["ההבטחה הבינארית נשברת"]
b2["הבדל ב-pack או padding"] --> b4
b3["הבדל 32bit / 64bit"] --> b4
b4 --> b5["מספרים שלמים קבועים + פריסה מפורשת"]
b5 --> b6["version ו-size בכותרת"]
איור 13: הבדלי גודל ו-padding שוברים ABI, ולכן עוברים למספרים שלמים קבועים ופריסה מפורשת.
5.6 תחרות באתחול
shared memory נשבר בקלות בגלל ההנחה השגויה ש”מי שיצר בטח כבר אתחל”.
ב-Windows, כאשר CreateFileMapping פוגע בשם קיים, הוא מחזיר את האובייקט הקיים, וניתן לזהות זאת דרך GetLastError() שמחזיר ERROR_ALREADY_EXISTS.
העמוד הראשוני של mapping pagefile-backed מתחיל באפס.8
ב-POSIX, אובייקט shared memory חדש מתחיל באורך 0, ומקבל גודל דרך ftruncate. בייטים שהוקצו מחדש מאותחלים לאפס. יצירה עם O_CREAT | O_EXCL היא אטומית.3
אם לא מכירים את ההבדל הזה ו-
- משתמשים מיד אחרי
open - אין דגל שמציין השלמת אתחול
- כמה משתתפים מאתחלים בו-זמנית
- לא בודקים version mismatch
התוצאה נשברת בהתאם לסדר ההפעלה.
לכל הפחות, כדאי להציב בכותרת הראשית את ה-state הבא:
-
INITIALIZING -
READY -
BROKEN
ואז רק ה-creator מאתחל, וה-joiner ממתין ל-READY.
רק המנהג הזה מרגיע את העולם באופן משמעותי.
flowchart TB
accTitle: איך נמנעים מתחרות באתחול
accDescr: תרשים המראה שכדי למנוע תחרות באתחול, ה-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 בכותרת לפני שהוא מתחיל.
5.7 לא מתכננים התאוששות מקריסה
מה קורה אם ה-writer קורס באמצע עדכון הנתונים המשותפים? אם משאירים את זה לא מוגדר ויוצאים לפרודקשן, המראה בזמן תקלה הופך פתאום חמור.
ה-mutex ב-Windows, כאשר ה-thread הבעלים מסתיים בלי לשחרר, הופך ל-abandoned, וצד ה-wait מקבל WAIT_ABANDONED. המשמעות היא שהמשאב המשותף אולי במצב לא מוגדר.12
גם ב-robust mutex של POSIX, כאשר ה-owner מת מוחזר EOWNERDEAD, וקיים נוהל שבו לאחר תיקון קוראים ל-pthread_mutex_consistent().1314
החשוב הוא לא “להמשיך בכל זאת” בשלב הזה. להתאוששות דרוש לפחות אחד מאלה:
- מספר generation
- sequence אחרון שעבר commit
- heartbeat
- דגל dirty / clean
- commit דו-שלבי בסגנון journal
- נוהל אתחול מלא מחדש במקרה של השחתה
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: אם הבעלים קרס, חושדים במצב לא מוגדר ופונים לנוהל התאוששות לפני המשך.
5.8 false sharing ותחרות על שורת מטמון
נהוג לומר ש-shared memory מהיר. אבל אם מונים “חמים” (hot counters) דחוסים באותו cache line, ה-line נודד הלוך ושוב בין ה-CPU-ים, וזה מאט בצורה שאי אפשר להתעלם ממנה.
הדוגמה האופיינית:
- ה-producer מעדכן
write_index - ה-consumer מעדכן
read_index - שניהם על אותו cache line
במקרה כזה, רק
- הפרדת שדות hot ל-cache line נפרד
- הפרדה בין שדות בתדירות עדכון גבוהה לנמוכה
- מודעות ל-1 writer, 1 cache line
משנה הרבה. מדברים הרבה על יישור ל-64 bytes, אבל כדאי להתייחס לזה כאל ערך נפוץ בהרבה CPU-ים, ולא כחוק מוחלט.
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: הבוץ של מרחב השם Global
accDescr: תרשים המראה שרצון לשתף בין service ל-desktop app בעזרת שם Global נכשל בגלל חוסר SeCreateGlobalPrivilege, או בגלל mutex בשם זהה שכבר קיים באותו namespace.
n1["רצון לשתף בשם Global"] --> n2["כישלון בהרשאות"]
n1 --> n3["התנגשות עם mutex בשם זהה"]
n2 -.-> n4["יצירה חדשה דורשת SeCreateGlobalPrivilege"]
n3 -.-> n5["מתקבל ERROR_INVALID_HANDLE"]
איור 17: מרחב השם Global חושף שני סוגי בוץ - הרשאות והתנגשות שמות.
גם בצד POSIX, זלזול ב-mode וב-umask של shm_open עלול לגרום לחשיפה רחבה מדי, או להיפך, לחוסר יכולת לפתוח.3
shared memory אינו בטוח רק בגלל שהוא זיכרון. מכל process שיש לו הרשאת קריאה, הוא נראה בצורה די ישירה. אם שמים בו מידע רגיש, צריך לחשוב עליו כמו על זיכרון רגיל - בהקשר של paging / swap / dump והרשאות.
5.10 שינוי גודל ושדרוג בפזיזות
הרצון “להרחיב מעט בהמשך” את הזיכרון המשותף הוא דרישה מסוכנת יחסית.
- ל-mapping object של Windows יש גודל שנקבע בזמן היצירה8
- גם ב-POSIX, אם לא חושבים על התאמה בין
ftruncateל-mmap, אורך ה-map בצד המשתתפים לא יתאים316
בעבודה בפועל, בטוח יותר לקבוע את הגודל כבלתי-משתנה בתוך אותו דור. אם דרושה הרחבה, עדיף:
- ליצור segment של version / name / generation חדשים
- להעביר את המשתתפים
- לסגור את ה-segment הישן
וכך שיעור התקלות יורד.
flowchart TB
accTitle: הרחבת גודל דרך מעבר בין דורות
accDescr: תרשים המראה שגודל הזיכרון המשותף קבוע בתוך דור, ושהרחבה נעשית ביצירת segment של דור חדש, העברת המשתתפים וסגירת ה-segment הישן - נוהל שמוריד את שיעור התקלות.
z0["רצון להרחיב בהמשך"] --> z1["יוצרים segment של דור חדש"]
z1 --> z2["מעבירים את המשתתפים"]
z2 --> z3["סוגרים את ה-segment הישן"]
z0 -.-> z4["resize in place מסוכן"]
איור 18: הגודל קבוע בתוך דור, וההרחבה נעשית במעבר ל-segment של דור חדש.
5.11 דוחפים גם את ההתראה לתוך shared memory
תבנית שכיחה:
-
ready = 1בזיכרון משותף - הצד השני:
while (!ready) Sleep(1);
זה עובד בהתחלה. אבל בהמשך זה חוזר בצורה של:
- בזבוז CPU
- תזוזה בעיכוב בגלל
Sleep(1) - קושי לשים לב לפספוסים (drops)
- קושי לכתוב timeout או התראת סיום בצורה נקייה
עדיף להטות את הזיכרון המשותף לצד הנתונים, ואת ההתראה להעביר לפרימיטיב שאפשר להמתין לו.
- Windows: event / semaphore / mutex / named pipe וכדומה217
- POSIX: semaphore / process-shared mutex + condvar וכדומה1819
flowchart TB
accTitle: מעבירים התראה לפרימיטיב שממתינים לו
accDescr: תרשים המראה שפיקוח על ready flag בעזרת Sleep מוביל לבזבוז CPU, תזוזה בעיכוב ופספוסים, ולכן מטים את הזיכרון המשותף לנתונים ואת ההתראה לפרימיטיב כמו event או semaphore.
t1["פיקוח על ready flag ב-Sleep"] --> t2["בזבוז CPU · תזוזה בעיכוב"]
t1 -.-> t6["קושי לשים לב לפספוסים"]
t2 --> t3["מעבירים התראה לפרימיטיב הממתין"]
t3 --> t4["ב-Windows: event / semaphore"]
t3 --> t5["ב-POSIX: semaphore / condvar"]
איור 19: לא מפקחים על דגל, אלא מקבלים התראה דרך פרימיטיב שאפשר להמתין לו.
5.12 חושבים ש”אפשר לשתף גם עם מכונה אחרת”
יש רגעים שמתחשק לחשוב שאם ממפים קובץ משותף דרך רשת בעזרת file-backed mapping, אפשר להגיע לתחושה של shared memory גם עם מכונה אחרת.
זה מסוכן.
גם בתיעוד של CreateFileMapping ב-Windows כתוב שcoherence אינה מובטחת עבור remote file.
כאשר שתי מכונות ממפות את אותו עמוד ב-writable, כל אחת רואה רק את הכתיבה שלה, ואין merge בעת עדכון הדיסק.8
זיכרון משותף הוא, באופן בסיסי, מנגנון בתוך אותו host. כשעוברים בין מכונות, עדיף פשוט לבחור socket / RPC / message broker, כדי לשמור על שפיות.
flowchart TB
accTitle: אין לשתף עם מכונה אחרת
accDescr: תרשים המראה שגם מיפוי קובץ משותף דרך הרשת אינו מבטיח coherence עבור remote file, ושזיכרון משותף הוא מנגנון בתוך אותו host, ולכן בין מכונות בוחרים socket, RPC או message broker.
r1["רצון לשתף remote file ממופה"] --> r2["coherence אינה מובטחת"]
r2 --> r3["זיכרון משותף - מנגנון באותו host"]
r3 --> r4["עוברים ל-socket / RPC / message broker"]
איור 20: זיכרון משותף הוא מנגנון בתוך host אחד, ובין מכונות בוחרים במערכת הודעות.
6. שיטות עבודה מומלצות
6.1 הפרדה בין control plane ל-data plane
אם מורידים את ההפרדה שנקבעה בסעיף 4.1 עד לרמת ההקצאה בקוד, זה נראה כך (הצורה הכושלת מתוארת בסעיף 5.11):
- shared memory: frame, sample, batch, snapshot
- event / semaphore / pipe / socket: ready, consumed, stop, error, reconnect
ההפרדה הזו משפרת קודם כול את הבהירות של התכנון, ורק אחר כך את הביצועים.
6.2 להציב כותרת קבועה בראש
מומלץ בחום להציב לפחות כותרת כזו בראש.
typedef struct SharedHeader {
uint32_t magic;
uint16_t abi_version;
uint16_t header_size;
uint32_t state; // 0=initializing, 1=ready, 2=broken
uint32_t flags;
uint64_t total_size;
uint64_t generation;
uint64_t heartbeat_ns;
uint64_t payload_offset;
uint64_t payload_size;
uint64_t write_seq;
uint64_t read_seq;
uint8_t reserved[64];
} SharedHeader;
הנקודות המרכזיות:
-
magicדוחה דבר אחר או מצב לא מאותחל -
abi_versionו-header_sizeדוחים הבדל ב-layout -
stateדוחה מצב אתחול ביניים -
generationמזהה יצירה מחדש -
heartbeatבודק חיוּת -
reservedפותח דרך מילוט להרחבה עתידית
הקושי ב-shared memory הוא ש”קשה לראות מה קורה”. בדיוק בגלל זה, נותנים מראש מטא-נתונים לצורכי תצפית.
flowchart TB
accTitle: תפקידי השדות בכותרת הקבועה
accDescr: תרשים המראה שהכותרת הראשית דוחה דבר אחר בעזרת magic, דוחה הבדל layout בעזרת version, דוחה אתחול ביניים בעזרת state, מזהה יצירה מחדש בעזרת generation ובודקת חיות בעזרת heartbeat.
h0["הכותרת הקבועה בראש"] --> h1["magic דוחה דבר אחר"]
h0 --> h2["version דוחה הבדל"]
h0 --> h3["state דוחה מצב ביניים"]
h1 --> h4["הופכת למטא-נתוני תצפית"]
h2 --> h4
h3 --> h4
h0 -.-> h5["generation מזהה יצירה מחדש"]
h5 -.-> h6["heartbeat בודק חיות"]
איור 21: כל שדה בכותרת הקבועה דוחה סוג אחר של בעיה - זהות, פערי גרסה, ואתחול ביניים.
6.3 מעבר להפניה לפי offset
את ההפניה מחזיקים כ-offset ולא כ-pointer.
- פותרים באמצעות
base + offset - מוסיפים בדיקת טווח ל-
offset + length - קובעים
sentinelלערך לא תקין
רק זה כבר מפחית משמעותית תקלות מסוג אי-התאמת כתובת (address mismatch).
6.4 צמצום מודל המקביליות
מבין ארבעת המודלים בסעיף 4.2, בהתחלה כדאי לבחור באחד מהשניים האלה:
- SPSC ring buffer
- snapshot של writer אחד / reader מרובים
חוצץ טבעתי SPSC הוא מבנה שבו, לתוך מערך של slots באורך קבוע, ה-producer כותב במיקום write_seq וה-consumer קורא ממיקום read_seq בלבד. מכיוון שיש writer אחד ו-reader אחד, ההתקדמות היא חד-כיוונית.
flowchart LR
accTitle: מבנה חוצץ טבעתי SPSC
accDescr: תרשים המראה חוצץ טבעתי בן שמונה סלוטים, שבו ה-consumer מתקדם במיקום read_seq רק אחרי שסיים לקרוא, וה-producer מתקדם במיקום write_seq רק אחרי שסיים לכתוב, וכשמגיעים לסוף חוזרים לסלוט 0.
subgraph ring["חוצץ טבעתי - 8 סלוטים"]
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. ה-producer וה-consumer מתקדמים בנפרד ובכיוון אחד.
הנקודה החשובה היא לא לשבור את הסדר - “מקדמים index רק אחרי סיום כתיבה” ו-“מקדמים index רק אחרי סיום קריאה”. וכמו בסעיף 5.8, write_seq ו-read_seq מונחים על cache line נפרד.
אם דרוש writer מרובה, בדרך כלל עדיף לצמצם את נקודות האחריות לעקביות, כמו:
- רק ה-enqueue הוא lock-free / atomic
- עדכון הנתונים בפועל מרוכז ב-consumer יחיד
6.5 להגדיר commit protocol במפורש
תכנון שלא ניתן להסביר במילים “מאיזה רגע מותר לקרוא” הוא מסוכן.
למשל, עבור חיץ כפול:
- כותבים לחוצץ הלא-פומבי
- קובעים checksum ואורך
- מחליפים את active buffer index עם
release - ה-reader קורא את ה-active index עם
acquire - אחרי סיום הקריאה, בודקים אם ה-index השתנה
כך קובעים את טקס הפרסום. כשמציירים את זה בתרשים, ברור שרגע ההחלפה הוא נקודה אחת בלבד.
sequenceDiagram
accTitle: טקס הפרסום של חיץ כפול
accDescr: תרשים המראה איך reader קורא את active index באמצעות acquire, ואיך writer כותב לחוצץ הלא-פומבי ומחליף את ה-index באמצעות release, ואיך reader מאמת מחדש את ה-index אחרי סיום הקריאה ומזהה שינוי.
participant W as writer
participant BA as חוצץ A
participant IX as active index
participant BB as חוצץ B
participant R as reader
Note over IX: active index הוא A
R->>IX: קורא עם acquire
IX-->>R: A
R->>BA: קורא את חוצץ A
W->>BB: כותב לחוצץ 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: טקס הפרסום של חיץ כפול. ה-reader מאמת מחדש את ה-index אחרי שסיים לקרוא.
אם מדלגים על הנוהל הזה - “אימות מחדש של ה-index אחרי סיום הקריאה” - ה-writer עלול לעשות שימוש חוזר באותו חוצץ לכתיבה הבאה בזמן שה-reader עדיין קורא, ומתקבל מצב ביניים זהה לזה שבסעיף 5.3. יש לשים לב שכאשר יש רק שני חוצצים, ההחלפה עלולה לקרות שוב באמצע הקריאה החוזרת; אם קצב העדכון מהיר, כדאי להגדיל את מספר החוצצים או לעבור לשיטת sequence counter שהוזכרה בסעיף 5.3.
6.6 קיבוע הגודל לפי דור
במקום resize in place, קל יותר לתחזק אם חותכים דור, למשל:
name = MyShm.v3abi_version = 3generation = 42
זיכרון משותף לא עושה בשבילנו “בדיקת טיפוסים בזמן קריאה” כמו API. לכן חשוב לא לשבור ABI שכבר נקבע.
6.7 להוסיף תצפיתיות
לפחות אלה עוזרים:
- זמן העדכון האחרון
- sequence אחרון שהצליח
- מספר drop / overwrite
- מספר version mismatch
- מספר attach / detach
- last error code
- heartbeat
כש-shared memory נשבר, בדרך כלל הלוגים דלים. הצבת counters משלכם מקלה מאוד על הטיפול בתקלות.
6.8 להכין קודם בדיקות מקרי קצה
בדיקת המסלול התקין בלבד לא מספיקה. כדאי לבדוק לפחות את אלה:
- הריגה כפויה של ה-writer באמצע עדכון
- גלישת ring בגלל עיכוב ה-reader
- חיבור עם version mismatch
- ערבוב 32bit / 64bit
- open החוצה session-ים
- חוסר הרשאות
- הפעלה מחדש כשתהליך קודם עדיין מחזיק דור ישן
- השפעת cache miss / NUMA בהעברה רציפה של נתונים ענקיים
ל-shared memory, בדיקות של דרכי שבירה שוות יותר מבדיקות המסלול התקין.
6.9 דוגמה מינימלית של הלוך-חזור
הנוהג עד כה מונח כאן לתצורה מינימלית שעובדת. תצורה שבה מניחים בלוק אחד עם פריסה קבועה בתוך file mapping מסוג pagefile-backed של Windows, וההתראה עוברת דרך שני auto-reset event.
ארבע ההבטחות המשותפות הן:
- הבלוק מכיל רק מספרים שלמים ברוחב קבוע ומערכים באורך קבוע. אין מצביע ואין HANDLE
- בראש הבלוק מונחים
magic/abi_version/block_size/state - קובעים את האורך רק אחרי סיום כתיבת הגוף, ורק אז מדליקים את ה-event
- שם ה-event שונה משם ה-file mapping (כי ב-Windows event / semaphore / mutex / waitable timer / job / file mapping חולקים namespace)815
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 בייטים */
#pragma pack(pop)
int main(void)
{
HANDLE hMap = NULL, hReq = NULL, hRep = NULL;
DemoBlock *blk = NULL;
uint32_t len = 0;
DWORD waited = 0;
int rc = 1;
/* 1. יוצרים mapping מסוג pagefile-backed. העמוד ההתחלתי מתחיל באפס.
* אם השם כבר קיים, CreateFileMappingW "מצליח ומחזיר את הקיים",
* ולכן בדיקת NULL בלבד אינה עוצרת שולח שני. אם ממשיכים ישירות ל-memset
* למטה, זה ימחק את הבלוק המשותף של הצד השני שפועל.
* GetLastError() נקבע גם בהצלחה, ולכן צריך לקרוא אותו מיד אחר כך. */
hMap = CreateFileMappingW(INVALID_HANDLE_VALUE, NULL, PAGE_READWRITE,
0, (DWORD)sizeof(DemoBlock), SHM_NAME);
if (hMap == NULL) {
printf("CreateFileMapping failed: %lu\n", GetLastError());
goto cleanup;
}
if (GetLastError() == ERROR_ALREADY_EXISTS) {
printf("%ls כבר בשימוש. יכול להיות שולח אחד בלבד בו-זמנית\n", SHM_NAME);
goto cleanup;
}
blk = (DemoBlock *)MapViewOfFile(hMap, FILE_MAP_ALL_ACCESS, 0, 0, sizeof(DemoBlock));
if (blk == NULL) {
printf("MapViewOfFile failed: %lu\n", GetLastError());
goto cleanup;
}
/* 2. event לצורך התראה. שם שונה מה-mapping */
hReq = CreateEventW(NULL, FALSE, FALSE, EVT_REQ); /* auto-reset / לא מסומן */
hRep = CreateEventW(NULL, FALSE, FALSE, EVT_REP);
if (hReq == NULL || hRep == NULL) {
printf("CreateEvent failed: %lu\n", GetLastError());
goto cleanup;
}
/* 3. רק הצד שיצר מאתחל. את ה-state קובעים אחרון */
memset(blk, 0, sizeof(*blk));
blk->magic = SHM_MAGIC;
blk->abi_version = SHM_ABI;
blk->block_size = (uint32_t)sizeof(DemoBlock);
blk->state = STATE_INITIALIZING;
MemoryBarrier();
blk->state = STATE_READY;
/* 4. לא לשבור את הסדר: גוף → מחסום → אורך → התראה */
strcpy_s(blk->request, sizeof(blk->request), "ping from writer");
MemoryBarrier();
blk->request_len = (uint32_t)strlen(blk->request);
if (!SetEvent(hReq)) {
printf("SetEvent failed: %lu\n", GetLastError());
goto cleanup;
}
/* 5. ממתינים לתשובה. לא ממתינים ללא הגבלה */
waited = WaitForSingleObject(hRep, 5000);
if (waited == WAIT_TIMEOUT) {
printf("אין תגובה מה-reader\n");
goto cleanup;
}
if (waited != WAIT_OBJECT_0) {
printf("WaitForSingleObject failed: %lu\n", GetLastError());
goto cleanup;
}
/* 6. בודקים טווח לאורך לפני הקריאה */
len = blk->reply_len;
if (len > sizeof(blk->reply)) {
printf("reply_len מחוץ לטווח: %u\n", len);
goto cleanup;
}
printf("reply: %.*s\n", (int)len, blk->reply);
rc = 0;
cleanup:
/* סגירת כל ה-view וה-handle מוחקת גם את השם. לא ליפול לפני ה-reader */
if (hRep != NULL) CloseHandle(hRep);
if (hReq != NULL) CloseHandle(hReq);
if (blk != NULL) UnmapViewOfFile(blk);
if (hMap != NULL) CloseHandle(hMap);
return rc;
}
המקבל, לאחר שהגדיר את ההגדרות מ-SHM_NAME ועד DemoBlock בדיוק כמו השולח, מחליף רק את main. בעבודה בפועל, מוציאים את החלק המשותף הזה לקובץ header.
/* shm_reader.c : המקבל. הקבועים והגדרת DemoBlock זהים ל-shm_writer.c.
* cl /W4 /nologo shm_reader.c */
int main(void)
{
HANDLE hMap = NULL, hReq = NULL, hRep = NULL;
DemoBlock *blk = NULL;
uint32_t len = 0;
int rc = 1;
hMap = OpenFileMappingW(FILE_MAP_ALL_ACCESS, FALSE, SHM_NAME);
if (hMap == NULL) {
printf("OpenFileMapping failed: %lu / האם ה-writer פועל?\n", GetLastError());
goto cleanup;
}
blk = (DemoBlock *)MapViewOfFile(hMap, FILE_MAP_ALL_ACCESS, 0, 0, sizeof(DemoBlock));
if (blk == NULL) {
printf("MapViewOfFile failed: %lu\n", GetLastError());
goto cleanup;
}
hReq = CreateEventW(NULL, FALSE, FALSE, EVT_REQ);
hRep = CreateEventW(NULL, FALSE, FALSE, EVT_REP);
if (hReq == NULL || hRep == NULL) {
printf("CreateEvent failed: %lu\n", GetLastError());
goto cleanup;
}
/* 1. ממתינים תחילה להתראה. ה-writer מדליק רק אחרי סיום האתחול */
if (WaitForSingleObject(hReq, 5000) != WAIT_OBJECT_0) {
printf("ה-request לא הגיע\n");
goto cleanup;
}
/* 2. מאמתים ABI ו-state לפני נגיעה */
if (blk->magic != SHM_MAGIC || blk->abi_version != SHM_ABI ||
blk->block_size != (uint32_t)sizeof(DemoBlock)) {
printf("ABI לא תואם: magic=%08X abi=%u size=%u\n",
blk->magic, blk->abi_version, blk->block_size);
goto cleanup;
}
if (blk->state != STATE_READY) {
printf("האתחול עדיין לא הסתיים: state=%u\n", blk->state);
goto cleanup;
}
/* 3. בודקים טווח לאורך ורק אז קוראים */
len = blk->request_len;
if (len > sizeof(blk->request)) {
printf("request_len מחוץ לטווח: %u\n", len);
goto cleanup;
}
printf("request: %.*s\n", (int)len, blk->request);
/* 4. גוף → מחסום → אורך → התראה, אותו סדר כמו ב-writer */
strcpy_s(blk->reply, sizeof(blk->reply), "pong from reader");
MemoryBarrier();
blk->reply_len = (uint32_t)strlen(blk->reply);
if (!SetEvent(hRep)) {
printf("SetEvent failed: %lu\n", GetLastError());
goto cleanup;
}
rc = 0;
cleanup:
if (hRep != NULL) CloseHandle(hRep);
if (hReq != NULL) CloseHandle(hReq);
if (blk != NULL) UnmapViewOfFile(blk);
if (hMap != NULL) CloseHandle(hMap);
return rc;
}
MemoryBarrier מונע reordering שבו האורך או הדגל נראים לפני שסיימו לכתוב את הגוף.5
אם רוצים לתפוס פערי layout כבר בזמן build, מוסיפים ל-MSVC את /std:c11 וקובעים את sizeof(DemoBlock) באמצעות static_assert מ-<assert.h>.
טיפול באותו בלוק מצד C# נראה כך. הנקודה המרכזית היא הצגת ה-offset במפורש כקבועים, כתוב כך שלא יהיה סטייה של אפילו בייט אחד מה-struct בצד C. MemoryMappedFile ו-EventWaitHandle בעלי שם הם ייחודיים ל-Windows.1
// .NET 8 / Windows. השולח: dotnet run -- write, המקבל: dotnet run -- read
using System.IO.MemoryMappedFiles;
using System.Text;
const string MapName = "Local\\KsShmDemo.v1.Block";
const string ReqName = "Local\\KsShmDemo.v1.Request";
const string RepName = "Local\\KsShmDemo.v1.Reply";
const uint Magic = 0x314D4853; // 'S','H','M','1'
const uint Abi = 1;
const int BlockSize = 536;
const int MaxBody = 256;
// מקבעים את אותה פריסה כמו DemoBlock בצד C, באמצעות קבועי offset
const int OffMagic = 0, OffAbi = 4, OffBlockSize = 8, OffState = 12;
const int OffRequestLen = 16, OffReplyLen = 20, OffRequest = 24, OffReply = 280;
bool isWriter = args.Length > 0 && args[0] == "write";
// השולח משתמש ב-CreateNew. עם CreateOrOpen, שולח שני היה פותח
// כמות שהוא את הבלוק הפעיל, והאתחול למטה היה שובר את התקשורת של הצד השני.
// CreateNew זורק IOException אם השם כבר קיים, וכך אפשר לשים לב לזה
// (אותו רעיון כמו בדיקת ERROR_ALREADY_EXISTS בצד C)
using var mmf = isWriter
? MemoryMappedFile.CreateNew(MapName, BlockSize)
: MemoryMappedFile.OpenExisting(MapName);
using var view = mmf.CreateViewAccessor(0, BlockSize);
using var reqEvent = new EventWaitHandle(false, EventResetMode.AutoReset, ReqName);
using var repEvent = new EventWaitHandle(false, EventResetMode.AutoReset, RepName);
if (isWriter)
{
view.Write(OffMagic, Magic);
view.Write(OffAbi, Abi);
view.Write(OffBlockSize, (uint)BlockSize);
Thread.MemoryBarrier();
view.Write(OffState, 1u); // READY
byte[] request = Encoding.UTF8.GetBytes("ping from C#");
view.WriteArray(OffRequest, request, 0, request.Length);
Thread.MemoryBarrier();
view.Write(OffRequestLen, (uint)request.Length);
reqEvent.Set();
if (!repEvent.WaitOne(TimeSpan.FromSeconds(5)))
{
Console.WriteLine("אין תגובה מה-reader");
return 1;
}
return PrintBody(OffReplyLen, OffReply, "reply");
}
if (!reqEvent.WaitOne(TimeSpan.FromSeconds(5)))
{
Console.WriteLine("ה-request לא הגיע");
return 1;
}
if (view.ReadUInt32(OffMagic) != Magic || view.ReadUInt32(OffAbi) != Abi
|| view.ReadUInt32(OffBlockSize) != BlockSize || view.ReadUInt32(OffState) != 1u)
{
Console.WriteLine("ABI לא תואם, או שהאתחול עדיין לא הסתיים");
return 1;
}
if (PrintBody(OffRequestLen, OffRequest, "request") != 0)
{
return 1;
}
byte[] reply = Encoding.UTF8.GetBytes("pong from C#");
view.WriteArray(OffReply, reply, 0, reply.Length);
Thread.MemoryBarrier();
view.Write(OffReplyLen, (uint)reply.Length);
repEvent.Set();
return 0;
int PrintBody(int lenOffset, int bodyOffset, string label)
{
uint length = view.ReadUInt32(lenOffset);
if (length > MaxBody)
{
Console.WriteLine($"אורך {label} מחוץ לטווח: {length}");
return 1;
}
byte[] body = new byte[length];
view.ReadArray(bodyOffset, body, 0, body.Length);
Console.WriteLine($"{label}: {Encoding.UTF8.GetString(body)}");
return 0;
}
הגרסה ב-C והגרסה ב-C# חולקות אותו שם ואותה פריסה, ולכן גם אם אחת מהן היא השולח והשנייה המקבל, ההלוך-חזור עובד. זה מה שהכוונה בקיבוע ABI.
הדוגמה הזו מכוונת בכוונה להלוך-חזור אחד בלבד. אם רוצים העברה רציפה, המשיכו לחוצץ הטבעתי בסעיף 6.4; אם רוצים לעמוד בפני סיום חריג של ה-writer, המשיכו ל-generation ול-heartbeat בסעיף 5.7.
7. נקודות להשוואה בין Windows ל-POSIX
| היבט | Windows | POSIX |
|---|---|---|
| יצירה / open | CreateFileMapping / OpenFileMapping / MapViewOfFile6 |
shm_open / ftruncate / mmap3 |
| שיתוף ללא קשר לדיסק | mapping מסוג pagefile-backed עם INVALID_HANDLE_VALUE68 |
POSIX shared memory object + mmap3 |
| ערך התחלתי | עמודי pagefile-backed מאותחלים לאפס8 | אובייקט חדש באורך 0. בייטים שהוקצו מחדש מאותחלים לאפס3 |
| סנכרון | mutex / semaphore / event / interlocked וכדומה25 | process-shared mutex / condvar / semaphore2018 |
| אסור להשתמש בהם בין תהליכים | CRITICAL_SECTION, WaitOnAddress2110 |
mutex / condvar שנשארו PTHREAD_PROCESS_PRIVATE2019 |
| מות הבעלים | WAIT_ABANDONED12 |
robust mutex + EOWNERDEAD / pthread_mutex_consistent()1314 |
| מחיקת שם | נמחק עם שחרור ה-handle / view האחרון28 | מחיקת שם עם shm_unlink. אם נשארה הפניה, הגוף נשאר עד הסוף2223 |
| namespace / הרשאות | Global\ / Local\, ACL, SeCreateGlobalPrivilege1524 |
mode, umask, namespace, O_CREAT|O_EXCL3 |
גם MemoryMappedFile של C# הוא, במהותו, עטיפה (wrapper) ל-file mapping של Windows.
לכן היסודות לא משתנים:
- פותחים באותו שם
- משתמשים בנפרד ב-mutex / event
- קוראים מה-view עם פריסה מפורשת
- לא שמים הפניה לאובייקט כמות שהיא1
flowchart TB
accTitle: אותם יסודות גם ב-MemoryMappedFile
accDescr: תרשים המראה ש-MemoryMappedFile של C# הוא במהותו עטיפה ל-file mapping של Windows, ולכן פותחים באותו שם, משתמשים בנפרד ב-mutex או event, קוראים בפריסה מפורשת, ולא שמים הפניה לאובייקט.
cs["MemoryMappedFile של C#"] --> fm["עטיפה ל-file mapping"]
fm --> q1["פתיחה באותו שם"]
fm --> q2["סנכרון נפרד ב-mutex/event"]
fm --> q3["קריאה בפריסה מפורשת"]
q3 -.-> q4["לא שמים הפניה לאובייקט"]
איור 25: גם ב-MemoryMappedFile של C#, אותם יסודות של file mapping תקפים.
8. רשימת בדיקה ראשונית
- האם באמת דרוש זיכרון משותף? האם מדובר בנתונים גדולים באותו host?
- האם הופרד control plane מ-data plane?
- האם ניתן לצמצם את מודל המקביליות ל-SPSC / writer אחד עם reader מרובים?
- האם בכותרת הראשית יש magic / version / size / state / generation / heartbeat?
- האם לא הונחו pointer / HANDLE / fd / STL object / std::mutex?
- האם קיים commit protocol שמונע מה-reader לראות מצב ביניים?
- האם מאתחל אחד יחיד מוגדר?
- האם קיים נוהל התאוששות לסיום חריג?
- האם השם וההרשאות מוגדרים במפורש?
- האם
Global\באמת נחוץ? - האם לא מונח ההנחה של resize in place?
- האם נבדקו writer kill / reader stall / version mismatch / חוסר הרשאות?
9. סיכום
שימוש נכון בזיכרון משותף חזק מאוד. בפרט הוא באמת יעיל עבור נתונים גדולים באותה מכונה, כמו:
- תמונה
- שמע
- סדרת חיישנים
- batch גדול
- snapshot בתדירות גבוהה
עם זאת, מהות הזיכרון המשותף היא לא “מהירות” אלא העברת אחריות. במקום להעתיק או להעביר הודעות דרך הקרנל, מקבלים על עצמנו:
- סנכרון
- נראות (visibility)
- אתחול
- ABI
- התאוששות
- הרשאות
- תצפיתיות
flowchart TB
accTitle: מהות הזיכרון המשותף היא העברת אחריות
accDescr: תרשים המראה שבתמורה להפחתת העתקות ומעבר הודעות דרך הקרנל, האפליקציה מקבלת על עצמה סנכרון, נראות, אתחול, ABI, התאוששות, הרשאות ותצפיתיות.
e1["פחות העתקות והודעות"] --> e2["מה שמקבלים על עצמם במקום"]
e2 --> e3["סנכרון · נראות · אתחול"]
e2 --> e4["ABI · התאוששות · הרשאות"]
e2 --> e5["תצפיתיות"]
איור 26: מהות הזיכרון המשותף היא לא “מהירות” אלא העברת אחריות לעקביות אל האפליקציה.
לכן, הכי בטוח שהראשון שלכם ייראה כך:
- SPSC ring buffer או חיץ כפול
- כותרת קבועה בראש
- הפניה לפי offset
- התראה בערוץ נפרד
- קיום version / generation / heartbeat
- קיום בדיקות מקרי קצה
אם מתחילים מהצורה הזו, shared memory הופך לכלי די צייתני. לעומת זאת, אם מתייחסים אליו מהתחלה כאל “זיכרון משותף מהיר שאפשר לשים בו הכול”, זה בהדרגה הופך לא לאפליקציה אלא לארכיאולוגיה.
10. מקורות
- Windows: היסודות של file mapping ו-named shared memory682
- Windows: namespace / security / synchronization1524512
- POSIX:
shm_open,shm_unlink,mmap, process-shared / robust synchronization322162013 - .NET: סקירה כללית של
MemoryMappedFile1
-
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
מאמרים קשורים
מאמרים עדכניים עם אותן תגיות, להעמקה בנושאים קרובים.
המלכודות של יישום תקשורת טורית - עד לתכנון חיבור מחדש ויומן
המאמר מסדר, מנקודת מבט מעשית, את המלכודות שכדאי להימנע מהן ביישום תקשורת טורית לחיבור ציוד ובקרת מכשירי מדידה - מבניית מסגרות, timeout, ...
איך בוחרים בין WinForms, WPF ו-WinUI - טבלת החלטה מהשטח
המאמר מסדר את הבחירה בין WinForms, WPF ו-WinUI מנקודות המבט של פיתוח חדש, נכסים קיימים, הפצה, ביטוי ה-UI ומבנה הצוות.
השוואה הוגנת של מהירות ריצה - C#, C++, Java, Go
המאמר מסדר איך להשוות בהוגנת את מהירות הריצה של C#, C++, Java ו-Go - תכנון המדידה, warm-up, קיבוע הסביבה, אופן קריאת הסטטיסטיקה, ופריטי...
צריבת תמונה וטקסט על MP4 עם Media Foundation
המאמר מסדר את הגישה לצריבת תמונה וטקסט על כל מסגרת בסרטון MP4 ויצירת MP4 חדש עם Media Foundation - חלוקת התפקידים בין Source Reader, ציור...
רשימת בדיקה לטיפול בטוח בתהליכי ילד ביישום Windows
כדי לטפל בבטחה בתהליכי ילד ביישום Windows, תכנון הבעלות על עץ התהליכים ונוהל הסיום חשוב יותר מבחירת ה-API להפעלה. המאמר מסדר את Job Objec...
נושאים קשורים
העמודים האלה ממקמים את הנושא בהקשר רחב יותר של שירותים והחלטות.
נושאים טכניים ב-Windows
שער לנושאי פיתוח Windows, חקירת תקלות וניצול נכסים קיימים.
שירותים הקשורים לנושא הזה
המאמר קשור ישירות לשירותים הבאים.
פיתוח יישומי Windows
זיכרון משותף, file mapping ו-MemoryMappedFile לצורך העברת נתונים בנפח גדול ותכנון הפרדת תהליכים הם נושא הקשור ישירות לפיתוח יישומי Windows.
ייעוץ טכני וסקירת תכנון
סידור תכנון שמוריד את שיעור התקלות - שיטות סנכרון, תכנון ABI, אסטרטגיית התאוששות, הפרדה בין control plane ל-data plane - מתאים היטב לייעוץ טכני וסקירת תכנון.
שאלות נפוצות
שאלות נפוצות בפניות בנושא המאמר.
- האם ערך שנכתב לזיכרון משותף ניתן לקריאה נכונה מיד מתהליכים אחרים?
- היכולת לראות והיכולת לקרוא בבטחה הן שתי בעיות נפרדות. זיכרון משותף הוא מנגנון שמראה את אותה רצף בייטים למספר תהליכים, והוא אינו סנכרון כשלעצמו. גם אם ה-writer מתכוון לכתוב לפי הסדר length, payload ו-ready flag, אם צד ה-reader קורא ללא שום סנכרון, הוא עלול לראות שילוב של length חדש עם payload ישן. גם ב-Windows וגם ב-POSIX, הגישה לזיכרון משותף מניחה שילוב עם אמצעי סנכרון כמו mutex, semaphore ו-event.
- האם מותר לשים בזיכרון משותף מצביעים, std::string או HANDLE?
- עדיף שלא. כתובות וירטואליות ומשאבים מקומיים לתהליך (process-local) בעלי משמעות רק בהקשר של אותו תהליך, וגם אם ממפים את אותו mapping בתהליך אחר, אין ערובה שהכתובת הווירטואלית תהיה זהה. הדבר נכון גם לגבי std::vector, std::mutex ו-CRITICAL_SECTION. אם דרושה הפניה, עדיף להחזיק אותה כהיסט (offset) מכתובת בסיס, ולתכנן את הנתונים בזיכרון המשותף כמספרים שלמים ברוחב קבוע + פריסה מפורשת + כותרת עם גרסה.
- האם שימוש ב-volatile מייתר את הצורך בסנכרון של זיכרון משותף?
- לא. volatile אינו קסם שמציל את תכנון הזיכרון המשותף, ולפחות atomicity ו-mutual exclusion הן בעיות נפרדות. תכנון שמפקח על volatile bool בעזרת busy loop מבזבז CPU לריק, הבטחת הסדר בין payload ל-ready flag הופכת מעורפלת, ונוטה לתפוס מצבי ביניים. בנוסף, WaitOnAddress ב-Windows מיועד ל-thread בתוך אותו תהליך, ועדיף שלא להתייחס אליו כמנגנון המתנה בין תהליכים (cross-process). את ההתראה כדאי להעביר לפרימיטיב שאפשר להמתין לו, כמו event או semaphore.
- מה צריך להחליט ראשית בתכנון זיכרון משותף?
- ישנם ארבעה דברים. הפרדה בין control plane ל-data plane - הבקרה (התחלה, עצירה, התראה) דרך מערכת הודעות וגוף הנתונים בזיכרון המשותף; צמצום מודל המקביליות (בהתחלה חוצץ טבעתי SPSC או חיץ כפול פחות נוטים לתקלות); בעלות ואורך חיים - מי יוצר, מאתחל, מוחק ומשחזר; ותכנון ה-ABI כולל פריסה וגרסה. רק הצבת magic, version, size, state, generation ו-heartbeat בכותרת הראשית משנה מאוד את קלות חקירת התקלות.