مزالق shared memory وأفضل الممارسات في العمل
· آخر تحديث: · 小村 豪 · Shared Memory, IPC, Concurrency, C++, C#, تطوير Windows
سجل التعديلات (3 تحديثات، آخر تحديث 3 Sep، 2026)
سجل بالتغييرات التي أُجريت على هذا المقال. وحيثما حُفظت نسخة سابقة، تبقى متاحة للقراءة عبر رابط دائم يحمل معرّف DOI.
- أُضيفت روابط الاستشارة الموجودة في الأصل الياباني (consultation_services). ولم يتغيّر نصّ المقالة نفسه. قراءة النسخة السابقة لهذا التحديث (DOI: 10.5281/zenodo.22240862)
- أُعيدَت الترجمة العربية كترجمة كاملة عن النص الياباني الأصلي، وأُضيفَت خريطة المعرفة.
- أعيدت الترجمة كترجمة كاملة عن النص الياباني الأصلي. كانت النسخة العربية السابقة مختصراً يسقط أبواباً وجداول ورسوم Mermaid وتعليقات الأشكال وFAQ. أُعيدت هذه العناصر وفق الأصل الياباني، والادّعاءات التقنية مطابقة للنسخة اليابانية.
- النشر الأول
الاستشهاد بهذا المقال(DOI: 10.5281/zenodo.21621414)
هذا المقال محفوظ على Zenodo. يرد أدناه معرّف DOI الذي يشير دائمًا إلى أحدث نسخة، ومعرّف DOI المثبَّت على النسخة التي تقرؤها.
小村 豪 (2026). مزالق shared memory وأفضل الممارسات في العمل. شركة كومورا سوفت ذ.م.م.. https://doi.org/10.5281/zenodo.21621414 https://comcomponent.com/ar/blog/2026/03/18/000-shared-memory-pitfalls-best-practices/
- DOI (أحدث نسخة)
- 10.5281/zenodo.21621414
- DOI (هذه النسخة)
- 10.5281/zenodo.22279836
إطارات صور، نتائج فحص، سجلات زمنية، بيانات لوحة، buffers ضخمة. عندما نريد تبادل بيانات كبيرة بزمن استجابة منخفض داخل الجهاز نفسه، يبدو shared memory جذاباً جداً.
غير أن ما هو خطر قليلاً هنا أن shared memory يقترب بوجه «IPC سريع». في الواقع، shared memory هو «IPC يتيح تقليل النسخ، وفي المقابل يدفع مسؤولية الاتساق إلى جهة التطبيق».
- سريع
- مرن
- لكن الـ protocol من صنعك أنت
- وحين تقع الأعطال تكون الأعراض صارخة
عادة تأتي هذه الأربع معاً.
flowchart TB
accTitle: الوجهان لـ shared memory
accDescr: مخطّط يبيّن أن shared memory يقترب بوجه IPC سريع، بينما هو في الواقع IPC يتيح تقليل النسخ ويدفع مسؤولية الاتساق إلى جهة التطبيق.
f1["وجه «IPC سريع»"] --> f2["الشكل الفعلي"]
f2 --> f3["يمكن تقليل النسخ"]
f2 --> f4["مسؤولية الاتساق على التطبيق"]
f4 -.-> f5["الـ protocol من صنعك، والأعراض صارخة عند العطل"]
الشكل 1: يقترب shared memory بوجه «IPC سريع»، ويدفع مسؤولية الاتساق إلى جهة التطبيق.
في هذه المقالة نرتّب، مع أخذ file mapping في Windows و shm_open / mmap في POSIX في الحسبان، نقاط الاحتكاك عند استخدام shared memory في العمل، والتصميم الذي يخفض معدل الأعطال.
سواء في C/C++ أو في MemoryMappedFile على C#، الجوهر تقريباً واحد.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 و shared memory وغيرها |
| coherent | أن عدة views تشير إلى الكيان نفسه تبدو بالمحتوى نفسه في اللحظة نفسها. ليس بمعنى «يستطيع القارئ دائماً قراءة سجل محدّث ومتسق» |
| ABI (Application Binary Interface) | اتفاق على مستوى ثنائي تلتزم به الملفات التنفيذية، لا الشيفرة المصدرية. يشمل حجم النوع و alignment و padding وترتيب الحقول في البنية |
| SPSC / MPSC / SPMC / MPMC | اختصارات لعدد المنتجين والمستهلكين. S = single، M = multi، P = producer، C = consumer. SPSC يعني كاتباً واحداً وقارئاً واحداً. نوسّعها في 4.2 |
| lock-free | بناء يتقدم بعمليات atomic فقط دون أخذ قفل. يشير إلى ضمان تقدّم «خيط واحد على الأقل يستطيع التقدم دائماً»، وهي صفة غير «السرعة» |
| sentinel | قيمة خاصة محجوزة لتعني «غير صالح» أو «نهاية». مثل الاتفاق أن UINT64_MAX في offset تعني غير صالح |
| NUMA (Non-Uniform Memory Access) | تكوين تختلف فيه مسافة الذاكرة كما يراها المعالج. لمس ذاكرة عقدة بعيدة يبطئ الكود نفسه بشكل ملحوظ |
1. الخلاصة أولاً (بجملة)
بصيغة خشنة جداً لكنها مفيدة في العمل، الأمر هكذا.
- shared memory آلية تعرض سلسلة البايتات نفسها على عدة عمليات، وليست المزامنة نفسها23
- السرعة تظهر مع البيانات الكبيرة عند تبادلها داخل الجهاز نفسه. أما الرسائل الصغيرة للتحكم فقط، فغالباً ما يكون pipe / socket / named pipe / queue أسهل بكثير
- في shared memory، أن يكون مرئياً و أن يُقرأ بأمان مسألتان منفصلتان
- لا تجعل
volatileأساس التصميم. الذرية، والترتيب، والانتظار تُعالج كلٌّ على حدة45 - إذا وضعت مؤشراً خاماً، أو
HANDLE، أو file descriptor، أوstd::string، أوstd::vector، أوstd::mutexكما هي، فستندم لاحقاً غالباً - البيانات التي توضع في shared memory أسلم أن تُمال نحو أعداد صحيحة بعرض ثابت + تخطيط صريح + ترويسة بإصدار
- مجرد وضع magic / version / size / state / generation / heartbeat في الترويسة الأمامية يغيّر كثيراً سهولة التحقيق في الأعطال
- صعوبات shared memory ليست السرعة، بل التهيئة، والعمر، والاستعادة، والأذونات، وABI
- في Windows الهيكل هو
CreateFileMapping/OpenFileMapping/MapViewOfFile، وفي POSIXshm_open/ftruncate/mmap63 - الأقل عرضة للأعطال أن تبدأ بـ SPSC (single-producer single-consumer) ring buffer أو double buffer
باختصار، shared memory سريع، لكن استخدامه بخشونة يصيب صاحبه بـ «وهم أن المزامنة تحدث وحدها». تجنّب هذا هو رهان البداية.
flowchart TB
accTitle: الفرق بين أن يكون مرئياً وأن يُقرأ بأمان
accDescr: مخطّط يبيّن أن shared memory يعرض سلسلة البايتات نفسها على عدة عمليات وليس المزامنة نفسها، وأن الرؤية والقراءة الآمنة مسألتان منفصلتان.
k1["آلية تعرض سلسلة البايتات نفسها"] --> k2["ليست المزامنة نفسها"]
k2 --> k3["أن يكون مرئياً"]
k2 --> k4["أن يُقرأ بأمان"]
k3 --> k5["يُصمَّمان كمسألتين منفصلتين"]
k4 --> k5
الشكل 2: في shared memory تصبح «الرؤية» و«القراءة الآمنة» مسألتين منفصلتين.
في المخطّط، يشير الخطّ المتّصل إلى علاقة قائمة دائماً، ويشير الخطّ المتقطّع إلى علاقة مشروطة (شروط قيامها مذكورة في شرح كلّ علاقة في الصفحة التفصيليّة). القائمة الكاملة للعلاقات (المجموع 24، مع الأدلّة ودرجة اليقين) وتعريفات المفاهيم الرئيسة مجمّعة في صفحة تفاصيل خريطة المعرفة (باليابانية). البيانات: JSON-LD / Turtle
2. ماذا يشارك shared memory، وماذا لا يشارك
shared memory، بصورة عامة، آلية تربط الصفحة الفيزيائية نفسها إلى فضاءات العناوين الافتراضية لعدة عمليات.
في Windows نستخدم file mapping object و view، وفي POSIX نجري mmap على shared memory object.273
المهم هنا نقطتان.
- ما يُشارك هو سلسلة بايتات المحتوى، وليس العنوان الافتراضي نفسه
- أن يكون coherent و أن يكون متزامناً مسألتان مختلفتان
flowchart TB
accTitle: الصفحة الفيزيائية نفسها تُرى عبر views مختلفة
accDescr: مخطّط يبيّن أن shared memory يربط الصفحة الفيزيائية نفسها إلى فضاءات العناوين الافتراضية لعدة عمليات، وأن المشارَك هو سلسلة البايتات لا العنوان الافتراضي نفسه.
pa["view العملية A"] --> pp["الصفحة الفيزيائية نفسها"]
pb["view العملية B"] --> pp
pp -.-> pn["المشارَك سلسلة البايتات، لا العنوان الافتراضي"]
الشكل 3: المشارَك سلسلة بايتات الصفحة الفيزيائية نفسها، لا العنوان الافتراضي نفسه.
في وثائق Windows أيضاً، الـ views المشتقة من file mapping object نفسه تكون coherent في اللحظة نفسها. لكن ذلك لا يعني أن القارئ يستطيع دائماً قراءة سجل محدّث ومتسق.8
مثلاً،
- يقصد الكاتب كتابة
length - ثم
payload - ثم
ready flag
بهذا الترتيب. لكن القارئ إن قرأ بلا أي مزامنة، فقد يرى length جديداً مع payload قديماً مركّبين.
shared memory لا يصلح هذا تلقائياً.
أي أن ما يشاركه shared memory هو البايتات. وما لا يشاركه هو المعنى، والترتيب، وإشعار الاكتمال، وسياسة الاستعادة. هذه كلها تحتاج تصميماً من جهتنا.
flowchart TB
accTitle: ما يشاركه shared memory وما لا يشاركه
accDescr: مخطّط يبيّن أن shared memory يشارك البايتات فقط، ولا يشارك المعنى والترتيب وإشعار الاكتمال وسياسة الاستعادة، فيلزم تصميمها من جهة التطبيق.
s1["shared memory"] --> s2["يشارك البايتات"]
s1 --> s3["ما لا يشاركه"]
s3 --> s4["المعنى والترتيب"]
s3 --> s5["إشعار الاكتمال وسياسة الاستعادة"]
s4 --> s6["نصمّمها نحن"]
s5 --> s6
الشكل 4: البايتات تُشارك، أما المعنى والترتيب وإشعار الاكتمال وسياسة الاستعادة فنصمّمها بأنفسنا.
3. المشاهد التي يناسبها shared memory / والتي لا يناسبه
| المشهد | يناسب / لا يناسب | السبب |
|---|---|---|
| تمرير إطارات أو buffers كبيرة داخل الجهاز نفسه | يناسب | يسهل تقليل عدد النسخ |
| قيم حسّاس عالية التكرار، صور، صوت، بيانات لوحة | يناسب | يسهل استهداف زمن استجابة منخفض ومعدل نقل عالٍ |
| تبادل أوامر وردود صغيرة فقط | لا يناسب كثيراً | تكلفة المزامنة للتحكم تصبح ثقيلة نسبياً |
| التبادل مع جهاز آخر | لا يناسب | shared memory يفترض أساساً المضيف نفسه |
| تعايش طويل للغات وإصدارات مختلفة | صعب | يلزم تصميم ABI وإصدارات |
| يلزم أيضاً الاستدامة | حسب الغرض | file-backed mapping خيار قوي، لكن يسهل اختلاط مسؤوليات الاستدامة و IPC |
في العمل، فصل التحكم عبر الرسائل، والجسم عبر shared memory قوي جداً. مثلاً،
- إشعار من عملية UI إلى عملية worker بـ «استخدم الإطار التالي» يتم عبر event / pipe / socket
- جسم الإطار الفعلي عبر shared memory
هذا التكوين. وهذا غالباً أكثر سلاماً.
flowchart TB
accTitle: التحكم عبر الرسائل، والجسم عبر shared memory
accDescr: مخطّط يبيّن تكويناً يفصل إشعار «استخدم الإطار التالي» من عملية UI إلى عملية worker عبر event أو pipe أو socket، ويمرّر جسم الإطار عبر shared memory.
ui["عملية UI"] -->|"إشعار (event / pipe / socket)"| wk["عملية worker"]
ui -.->|"كتابة جسم الإطار"| shm["shared memory"]
wk -.->|"قراءة جسم الإطار"| shm
الشكل 5: تكوين يرسل الإشعار عبر الرسائل ويضع جسم الإطار فقط في shared memory.
4. أربعة أمور يجب حسمها أولاً
عند تصميم shared memory، ما يجب حسمه أولاً هذه الأربعة.
flowchart TB
accTitle: أربعة أمور تُحسم أولاً
accDescr: مخطّط يبيّن أربعة بنود تُحسم أولاً عند تصميم shared memory: فصل الـ planes، ونموذج التزامن، والمالك والعمر، وABI والإصدار.
d0["تصميم shared memory"] --> d1["فصل الـ planes"]
d0 --> d2["نموذج التزامن"]
d0 --> d3["المالك والعمر"]
d0 --> d4["ABI والإصدار"]
الشكل 6: في بداية التصميم نحدد الفصل ونموذج التزامن والمالك والعمر وABI.
4.1 افصل control plane عن data plane
احسم أولاً ماذا يوضع في shared memory.
- data plane: صور، صوت، صفوف سجلات، بيانات بالجملة
- control plane: بدء، إيقاف، خطأ، إعادة اتصال، إعادة تهيئة، إشعار
فصل هذين وحدهما يبسّط تصميم جهة shared memory كثيراً.
4.2 ضيّق نموذج التزامن
- SPSC: منتج واحد / مستهلك واحد
- MPSC: عدة كتّاب / مستهلك واحد
- SPMC: كاتب واحد / عدة قرّاء
- MPMC: عدة كتّاب / عدة قرّاء
الصعوبة ترتفع تقريباً بهذا الترتيب. لا يُنصح بالذهاب إلى MPMC من البداية. تصبح مطالباً بالتعامل مع الإقصاء المتبادل بين الكتّاب وترتيب الذاكرة في الوقت نفسه، فتظهر لاحقاً أعطال يصعب تكرارها في الاختبار.
flowchart TB
accTitle: صعوبة نماذج التزامن
accDescr: مخطّط يبيّن أن الصعوبة ترتفع تقريباً من SPSC إلى MPSC ثم SPMC ثم MPMC، وأن الذهاب إلى MPMC من البداية يعني مواجهة الإقصاء المتبادل وترتيب الذاكرة معاً.
m1["SPSC (كتابة واحدة وقراءة واحدة)"] --> m2["MPSC (كتابات متعددة وقراءة واحدة)"]
m2 --> m3["SPMC (كتابة واحدة وقراءات متعددة)"]
m3 --> m4["MPMC (كتابات متعددة وقراءات متعددة)"]
m4 -.-> m5["مواجهة الإقصاء المتبادل وترتيب الذاكرة معاً"]
الشكل 7: صعوبة نموذج التزامن ترتفع تقريباً بهذا الترتيب من SPSC إلى MPMC.
4.3 حدّد المالك والعمر
- من ينشئ
- من يهيّئ
- من يحذف
- إذا سقط مشارك في الوسط، من يستعيد
إذا غامت هذه، يتغير السلوك مع ترتيب الإقلاع وإعادة التشغيل، ويصعب فصل السبب.
4.4 حدّد ABI والإصدار
- التخطيط
- حجم النوع
- alignment
- منطقة reserved
- version / feature flags
- وجود التوافق من عدمه
shared memory ليس حديث API بل حديث ABI (binary interface). إذا عولج هذا بخشونة، يقع عطل مزعج: توافق مصدري موجود بينما ينكسر وقت التشغيل فقط.
flowchart TB
accTitle: shared memory حديث ABI
accDescr: مخطّط يبيّن أن shared memory اتفاق ثنائي لا 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: مخطّط يبيّن أنه حتى لو أمكنت القراءة لأن الذاكرة نفسها مرئية، فضمان التوقيت والوحدة والترتيب الصحيح مسألة أخرى، وأن الوصول إلى shared memory يفترض الجمع مع وسيلة مزامنة.
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
- غير قابل للنقل
- يسهل التقاط حالة وسط الكتابة
تقريباً لا شيء جيد فيه.
علاوة على ذلك، WaitOnAddress في Windows موجّه إلى threads داخل العملية نفسها.
لا يُعتمد كآلية انتظار بين العمليات.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 جعل القارئ يقرأ حالة وسط الكتابة
مظهر العطل في shared memory عادي جداً.
- الترويسة وحدها جديدة
- الـ payload وحده قديم
- الطول وحده محدّث
- زوج حقلين مكسور
إذا رُسم، فطريقة وقوع العطل بسيطة. يدخل القارئ في الفجوة قبل أن ينتهي الكاتب من كتابة length و payload.
sequenceDiagram
participant W as عملية الكاتب
participant M as shared memory
participant R as عملية القارئ
W->>M: كتابة 1024 في length
Note over M: length جديد والـ payload لا يزال قديماً
R->>M: قراءة length
M-->>R: 1024
R->>M: قراءة payload بطول 1024 بايت
M-->>R: محتوى الجيل السابق
Note over R: «الترويسة وحدها جديدة» يمسك حالة وسط الطريق
W->>M: كتابة payload
W->>M: رفع ready flag
الشكل 11: يدخل القارئ في الفجوة قبل أن ينتهي الكاتب من payload، فيمسك حالة وسط الكتابة.
هذه الفجوة موجودة حتماً ما دامت كتابة length و payload «ليست عملية واحدة غير قابلة للتجزئة». تحديث scalar واحد بشكل atomic أبسط نسبياً، أما نشر سجل من عدة حقول فيحتاج إجراء commit.
نموذجياً أحد التالي.
- الحماية كلها بـ mutex
- double buffer ثم تبديل «رقم الـ buffer الساري الآن» في النهاية
- ring buffer مع state / sequence لكل slot
- إن كان كاتباً واحداً وعدة قرّاء، أخذ snapshot بـ sequence counter
حتى «رفع ready flag في النهاية» وحده لا يكفي كتصميم ما لم تُحدد بأي ترتيب ذاكرة يُكتب / يُقرأ ذلك الـ flag. في shared memory، لحظة النشر نفسها هي الـ protocol.
5.4 وضع مؤشرات أو كائنات معقّدة كما هي
هذا أيضاً نمط متكرر.
- مؤشر خام
HANDLE- file descriptor
std::stringstd::vectorstd::unordered_mapstd::mutexCRITICAL_SECTION
نمط يضع هذه في shared memory كما هي ويحاول استخدامها من عملية أخرى. في عملية القارئ، شبه المؤكد انتهاك وصول أو قيمة بلا معنى.
السبب بسيط: العنوان الافتراضي وموارد process-local لا معنى لها إلا في سياق تلك العملية. وحتى view في Windows، إذا رُبط الـ mapping نفسه في عملية أخرى، ليس مضموناً تطابق العنوان الافتراضي.711
لذا إن احتجت إشارة، الأساس حملها كـ offset من العنوان الأساسي.
typedef struct ShmRef {
uint64_t offset; // セグメント先頭からの相対位置
uint32_t length;
uint32_t kind;
} ShmRef;
عندها تستطيع كل عملية التحويل إلى عنوانها بـ base + offset.
flowchart TB
accTitle: الإشارة بـ offset لا بالمؤشر
accDescr: مخطّط يبيّن أن العنوان الافتراضي وموارد process-local لا معنى لها إلا في سياق تلك العملية، لذا تُحمل الإشارة كـ 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- فرق المترجم / اللغة
- alignment / padding
- little-endian / big-endian
داخل المضيف نفسه غالباً ما تتوافق endianness، لكن مجرد دخول دعم ARM64 أو mixed toolchain يكفي لإزاحة شائعة جداً.
لذا نوصي بقوة للبنى الموضوعة في shared memory بما يلي.
- أعداد صحيحة بعرض ثابت مثل
uint32_t/uint64_t - padding / reserved صريح
- في الترويسة
version,header_size,record_size,total_size - إن لزم
static_assert(sizeof(...)) - عدم وضع كائن غير trivial
flowchart TB
accTitle: الفروق التي تكسر ABI وطريقة المنع
accDescr: مخطّط يبيّن أن فروق حجم النوع و alignment و 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 مدعوم بملف الصفحة تبدأ بأصفار.8
في POSIX، shared memory object الجديد طوله في البداية 0، ويُعطى حجماً بـ ftruncate. البايتات المخصصة حديثاً تُهيّأ بأصفار. الإنشاء بـ O_CREAT | O_EXCL ذري.3
من دون معرفة هذا الفرق،
- الاستخدام فور open
- بلا علامة اكتمال التهيئة
- مشاركون يهيّئون في الوقت نفسه
- عدم النظر إلى version mismatch
ينكسر حسب ترتيب الإقلاع.
كحد أدنى، ضع هذه الحالات في الترويسة الأمامية.
INITIALIZINGREADYBROKEN
ثم المنشئ وحده يهيّئ، والمنضم ينتظر READY.
هذه الممارسة وحدها تهدّئ العالم كثيراً.
flowchart TB
accTitle: كيف نتجنّب سباق التهيئة
accDescr: مخطّط يبيّن تجنّب سباق التهيئة بوضع حالات INITIALIZING و READY و BROKEN في الترويسة، وتهيئة المنشئ وحده ورفع READY، وانتظار المنضم READY قبل الاستخدام.
c1["المنشئ ينشئ"] --> c2["المنشئ وحده يهيّئ"]
c2 --> c3["يجعل الحالة READY"]
j1["المنضم لا يستخدم فور open"] --> j2["ينتظر READY"]
c3 -.-> j2
j2 --> j3["يبدأ الاستخدام"]
الشكل 14: المنشئ وحده يهيّئ، والمنضم ينتظر READY في الترويسة ثم يستخدم.
5.7 عدم التفكير في استعادة الانهيار
ماذا لو سقط الكاتب أثناء تحديث البيانات المشتركة. إخراج هذا إلى الإنتاج وهو غير معرّف يجعل وجه العطل فجأة خطيراً.
mutex في Windows، إذا انتهى خيط المالك دون release، يصبح abandoned، ويستلم جانب الانتظار WAIT_ABANDONED. المعنى: المورد المشترك قد يكون في حالة غير محددة.12
ومع robust mutex في POSIX أيضاً، عند موت المالك يعود EOWNERDEAD، وبعد الإصلاح يُستدعى pthread_mutex_consistent().1314
المهم ألا «نواصل مؤقتاً» هنا. للاستعادة يلزم أحد التالي على الأقل.
- رقم generation
- sequence آخر commit مكتمل
- heartbeat
- dirty / clean flag
- commit على مرحلتين يشبه journal
- إجراء إعادة تهيئة كاملة عند التلف
flowchart TB
accTitle: عندما يسقط الكاتب أثناء التحديث
accDescr: مخطّط يبيّن أنه إذا انتهى المالك دون release يعود WAIT_ABANDONED في Windows و EOWNERDEAD في robust mutex على POSIX، فالمورد المشترك قد يكون غير محدد، فلا نواصل مؤقتاً بل نتجه إلى وسيلة الاستعادة.
w1["يسقط الكاتب أثناء التحديث"] --> 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 وتنافس سطر الـ cache
يُقال غالباً إن shared memory سريع. لكن إذا اكتظت عدّادات hot في سطر cache واحد، يتنقل السطر بين المعالجات فيبطئ بما لا يُتجاهل.
المثال النموذجي:
- المنتِج يحدّث
write_index - المستهلِك يحدّث
read_index - كلاهما على سطر cache نفسه
هذا النوع.
في هذه الحالة يكفي:
- فصل الحقول hot إلى سطر cache آخر
- فصل الحقول عالية التحديث عن منخفضة التحديث
- الوعي بأن كاتباً واحداً لسطر cache واحد
ليتغيّر الأمر كثيراً. كثيراً ما يُذكر المحاذاة على 64 بايت، لكن انظر إليها بروح أن 64 بايت قيمة شائعة في كثير من المعالجات وليست قانوناً مطلقاً.
flowchart TB
accTitle: المثال النموذجي لـ false sharing والعلاج
accDescr: مخطّط يبيّن أن write_index الذي يحدّثه المنتِج و read_index الذي يحدّثه المستهلِك إذا وقعا على سطر cache واحد يتنقل السطر بين المعالجات فيبطئ، لذا تُفصل الحقول hot إلى سطر آخر.
fp["المنتِج يحدّث write_index"] --> fl["سطر cache نفسه"]
fc["المستهلِك يحدّث read_index"] --> fl
fl --> fs["السطر يتنقل بين المعالجات فيبطئ"]
fs --> fx["فصل الحقول hot إلى سطر cache آخر"]
الشكل 16: عدّادات hot على سطر cache واحد تبطئ، فنفرّقها إلى أسطر أخرى.
5.9 الاستخفاف بالاسم والأذونات والأمان
named shared memory مريح، لكن خشونة الاسم والأذونات تسبب أعطالاً.
في Windows،
- توجد مساحتا أسماء
Global\وLocal\ - لإنشاء file mapping في
Global\من خارج session 0 إنشاءً جديداً يلزمSeCreateGlobalPrivilege - اسم الكائن يشترك في مساحة الأسماء مع event / semaphore / mutex / waitable timer / job
أي أن مشاهد من قبيل:
- تظن أن
"Global\\MyApp"يكفي للمشاركة بين خدمة وتطبيق سطح مكتب - لكن يفشل بسبب الأذونات
- بل إن mutex بالاسم نفسه أُنشئ أولاً فيصير
ERROR_INVALID_HANDLE
طين شديد الـ Windows-ية يظهر.
flowchart TB
accTitle: طين مساحة الأسماء Global
accDescr: مخطّط يبيّن أن استخدام اسم Global للمشاركة بين خدمة وتطبيق سطح مكتب قد يفشل بسبب غياب SeCreateGlobalPrivilege، أو يصطدم بـ mutex بالاسم نفسه موجود مسبقاً في مساحة الأسماء المشتركة.
n1["نريد المشاركة باسم Global"] --> n2["فشل بسبب الأذونات"]
n1 --> n3["اصطدام بـ mutex بالاسم نفسه"]
n2 -.-> n4["الإنشاء الجديد يحتاج SeCreateGlobalPrivilege"]
n3 -.-> n5["يصير ERROR_INVALID_HANDLE"]
الشكل 17: مساحة الأسماء Global سهلة الوقوع في نوعين من الطين: الأذونات واصطدام الأسماء.
وفي جهة POSIX أيضاً، الاستخفاف بـ mode أو umask في shm_open يجعله مرئياً أوسع مما يلزم، أو بالعكس يتعذر فتحه.3
shared memory ليس آمناً لمجرد أنه ذاكرة. من عملية تملك صلاحية القراءة يظهر المحتوى بصراحة. إن وضعت معلومات سرية، فكّر في سياق paging / swap / dump / الأذونات كما تفعل مع الذاكرة العادية.
5.10 خشونة تغيير الحجم والترقية
«نريد توسيعه قليلاً لاحقاً» طلب خطر نسبياً في shared memory.
- لكائن mapping في Windows حجم عند الإنشاء8
- وفي POSIX أيضاً إن لم تُراعَ مواءمة
ftruncateوmmap، لا يتوافق طول الـ map لدى المشاركين316
في العمل أسلم أن يكون الحجم ثابتاً داخل ذلك الجيل. إن لزم التوسيع،
- إنشاء segment بإصدار / اسم / generation جديد
- تبديل المشاركين
- إغلاق الـ segment القديم
يخفض معدل الأعطال أكثر.
flowchart TB
accTitle: توسيع الحجم بتبديل الجيل
accDescr: مخطّط يبيّن أن حجم shared memory يُثبَّت داخل الجيل، وإن لزم التوسيع يُنشأ segment لجيل جديد ويُبدَّل المشاركون ثم يُغلق القديم، فهذا يخفض معدل الأعطال أكثر من resize in place.
z0["نريد التوسيع لاحقاً"] --> z1["إنشاء segment لجيل جديد"]
z1 --> z2["تبديل المشاركين"]
z2 --> z3["إغلاق الـ segment القديم"]
z0 -.-> z4["resize in place خطر"]
الشكل 18: الحجم ثابت داخل الجيل، والتوسيع يتم بتبديل إلى segment جيل جديد.
5.11 حشر الإشعار أيضاً كله في shared memory
الشائع:
ready = 1في shared memory- الطرف الآخر
while (!ready) Sleep(1);
هذا يعمل في البداية. لكن لاحقاً يعود بالشكل التالي.
- إهدار CPU
- تذبذب زمن الاستجابة بسبب
Sleep(1) - صعوبة ملاحظة الفوات
- صعوبة كتابة مهلة أو إشعار إنهاء بشكل نظيف
الأفضل إمالة shared memory إلى سطح البيانات، وتوجيه الإشعار إلى primitive يمكن انتظاره.
- Windows: event / semaphore / mutex / named pipe وغيرها217
- POSIX: semaphore / mutex مشترك بين العمليات + condvar وغيرها1819
flowchart TB
accTitle: توجيه الإشعار إلى primitive يمكن انتظاره
accDescr: مخطّط يبيّن أن مراقبة علم في shared memory بـ Sleep تهدر CPU وتذبذب زمن الاستجابة وتفوّت أحداثاً، لذا يُمال shared memory إلى سطح البيانات ويُوجَّه الإشعار إلى primitive يمكن انتظاره مثل event أو semaphore.
t1["مراقبة ready flag بـ Sleep"] --> t2["إهدار CPU وتذبذب زمن الاستجابة"]
t1 -.-> t6["صعوبة ملاحظة الفوات"]
t2 --> t3["الإشعار إلى primitive يمكن انتظاره"]
t3 --> t4["في Windows event / semaphore وغيرها"]
t3 --> t5["في POSIX semaphore / condvar وغيرها"]
الشكل 19: لا نراقب علماً، بل نستلم الإشعار بـ primitive يمكن انتظاره.
5.12 الظن أن «بهذا نشارك مع جهاز آخر أيضاً»
تأتي لحظة تظن فيها أن ربط ملف مشترك عبر الشبكة بـ file-backed mapping يكفي لعمل يشبه shared memory مع جهاز آخر.
هذا خطر.
حتى شرح CreateFileMapping في Windows يقول إن coherence غير مضمون تجاه remote file.
إذا ربط جهازان الصفحة نفسها للكتابة، لا يرى كل منهما إلا كتابته، ولا يُدمَج عند تحديث القرص.8
shared memory أساساً آلية داخل المضيف نفسه. إذا عبرت الأجهزة، فاختيار socket / RPC / message broker بصراحة أبقى على الرشد.
flowchart TB
accTitle: لا نشارك مع جهاز آخر
accDescr: مخطّط يبيّن أن ربط ملف مشترك عبر الشبكة لا يضمن coherence لـ remote file، وأن shared memory آلية داخل المضيف نفسه، فإن عبرت الأجهزة نختار socket أو RPC أو message broker.
r1["نريد المشاركة بربط remote file"] --> r2["coherence غير مضمون"]
r2 --> r3["shared memory آلية داخل المضيف نفسه"]
r3 --> r4["إلى socket / RPC / message broker"]
الشكل 20: shared memory آلية داخل المضيف نفسه، فإن عبرت الأجهزة نختار عائلة الرسائل.
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يستبعدان فرق التخطيطstateيستبعد وسط التهيئةgenerationيكشف إعادة الإنشاءheartbeatيرى الحياةreservedيفتح مهرباً لتوسيع مستقبلي
.
ما يشق في shared memory هو «صعوبة رؤية ما يحدث». لذلك نحمل metadata للرصد من البداية.
flowchart TB
accTitle: دور كل حقل في الترويسة الثابتة
accDescr: مخطّط يبيّن توزيع الأدوار: magic يستبعد شيئاً آخر أو غير مهيّأ، و abi_version مع header_size يستبعدان فرق التخطيط، و state يستبعد وسط التهيئة، و generation يكشف إعادة الإنشاء، و heartbeat يرى الحياة.
h0["ترويسة ثابتة في المقدمة"] --> h1["magic يستبعد شيئاً آخر"]
h0 --> h2["version يستبعد الفرق"]
h0 --> h3["state يستبعد الوسط"]
h1 --> h4["تصير metadata للرصد"]
h2 --> h4
h3 --> h4
h0 -.-> h5["generation يكشف إعادة الإنشاء"]
h5 -.-> h6["heartbeat يرى الحياة"]
الشكل 21: كل حقل في الترويسة الثابتة يستبعد على حدة: شيئاً آخر، أو فرقاً، أو وسط تهيئة.
6.3 اجعل الإشارة بـ offset
الإشارة تُحمل كـ offset لا كمؤشر.
- تُحل بـ
base + offset - يُدخل فحص نطاق
offset + length - يُحدد sentinel للقيمة غير الصالحة
هذا وحده يقلل كثيراً أعطال عائلة عدم تطابق العنوان.
6.4 ضيّق نموذج التزامن
من النماذج الأربعة في 4.2، ما ينبغي اختياره أولاً أحدهما.
- SPSC ring buffer
- snapshot بكاتب واحد وعدة قرّاء
SPSC ring buffer بنية تكتب فيها المنتِج عند موضع write_seq في مصفوفة slots ثابتة الطول، ويقرأ المستهلِك من موضع read_seq. الكاتب قارئ واحد لكل منهما، فاتجاه التقدم أحادي.
flowchart LR
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["المستهلِك read_seq = 2 يتقدم بعد انتهاء القراءة"] --> s2
P["المنتِج write_seq = 4 يتقدم بعد انتهاء الكتابة"] --> s4
s7 -.->|"عند النهاية يعود إلى slot 0"| s0
الشكل 22: بنية SPSC ring buffer. المنتِج والمستهلِك يتقدمان بفهارس منفصلة في اتجاه واحد.
النقطة ألا تُكسر ترتيب «التقدم بالفهرس بعد انتهاء الكتابة» و «التقدم بالفهرس بعد انتهاء القراءة». وwrite_seq و read_seq كما في 5.8 يوضعان على سطري cache مختلفين.
إن لزم عدة كتّاب،
- الـ enqueue وحده lock-free / atomic
- تحديث البيانات الفعلية يُجمع إلى مستهلك واحد
مثل هذا، تقليل نقاط مسؤولية الاتساق ينجح غالباً.
6.5 صرّح ببروتوكول commit
تصميم لا تستطيع شرح «من أي لحظة يجوز القراءة» بجملة خطر.
مثلاً في double buffer:
- الكتابة إلى الـ buffer غير المنشور
- تثبيت المجموع الاختباري أو الطول
- تبديل فهرس الـ buffer الساري مع release
- القارئ يقرأ الفهرس الساري مع acquire
- بعد انتهاء القراءة يتحقق أن الفهرس لم يتغير
هكذا تُحدد طقوس النشر. إذا رُسم، يتضح أن لحظة التبديل موضع واحد فقط.
sequenceDiagram
participant W as الكاتب
participant BA as الـ buffer A
participant IX as active index
participant BB as الـ buffer B
participant R as القارئ
Note over IX: active index هو A
R->>IX: القراءة مع acquire
IX-->>R: A
R->>BA: قراءة الـ buffer A
W->>BB: الكتابة إلى B غير المنشور
W->>BB: تثبيت الطول والمجموع الاختباري
W->>IX: التبديل إلى B مع release
Note over IX: active index هو B
R->>IX: بعد القراءة إعادة التحقق من الفهرس
IX-->>R: تغيّر إلى B
Note over R: يُهمل المحتوى المقروء ويُعاد من B
الشكل 23: طقوس نشر double buffer. القارئ يعيد التحقق من active index بعد انتهاء القراءة.
إذا أُسقط إجراء «إعادة التحقق من الفهرس بعد انتهاء القراءة»، يعيد الكاتب استخدام الـ buffer نفسه للكتابة التالية بينما القارئ لا يزال يقرأ، فتصير الحالة الوسط نفسها التي في 5.3. وبما أن الوجهين اثنان فقط، قد يحدث تبديل آخر أثناء إعادة القراءة، فإن كان التحديث سريعاً تُزاد عدد الأوجه أو يُمال إلى أسلوب sequence counter المذكور في 5.3.
6.6 ثبّت الحجم لكل جيل
بدلاً من resize in place،
name = MyShm.v3abi_version = 3generation = 42
قطع الأجيال أسهل في الصيانة.
shared memory لا يفحص الأنواع عند الاستدعاء كما يفعل API. لذلك عدم كسر ABI حُسم مرة مهم.
6.7 أدخل قابلية الرصد
كحد أدنى، وجود ما يلي يساعد.
- وقت آخر تحديث
- آخر sequence ناجح
- عدد drop / overwrite
- عدد version mismatch
- عدد attach / detach
- last error code
- heartbeat
عندما ينكسر shared memory، تكون السجلات عادة رقيقة. وضع counters بنفسك يسهّل التعامل مع الأعطال كثيراً.
6.8 اصنع اختبارات المسار غير الطبيعي أولاً
المسار الطبيعي وحده لا يكفي. انظر على الأقل إلى التالي.
- إنهاء قسري أثناء تحديث الكاتب
- امتلاء الحلقة بسبب تأخير القارئ
- اتصال مع version mismatch
- اختلاط 32bit / 64bit
- open عبر الجلسات
- نقص الأذونات
- إعادة تشغيل بينما عملية سابقة لا تزال تحمل جيلاً قديماً
- أثر cache miss / NUMA عند نقل بيانات ضخمة متواصل
في shared memory قيمة اختبار طرق الكسر أكبر من المسار الطبيعي.
6.9 أصغر عيّنة ذهاب وإياب
نُنزل الممارسات حتى هنا إلى أصغر تكوين يعمل. تكوين واحد: كتلة بتخطيط ثابت على file mapping مدعوم بملف الصفحة في Windows، والإشعار بحدثين auto-reset.
الاتفاقات المشتركة هذه الأربعة.
- الكتلة أعداد صحيحة بعرض ثابت ومصفوفات ثابتة الطول فقط. لا مؤشر ولا
HANDLE - في المقدمة
magic/abi_version/block_size/state - تثبيت الطول بعد انتهاء كتابة الجسم ثم رفع event
- اسم الـ event مختلف عن file mapping (في Windows تشترك event / semaphore / mutex / waitable timer / job / file mapping في مساحة الأسماء)815
sequenceDiagram
accTitle: ذهاب وإياب العيّنة الصغرى
accDescr: مخطّط يبيّن ذهاباً وإياباً ينهي فيه المرسل التهيئة ثم يكتب الجسم ويثبّت الطول ويرفع event، وينتظر المستقبل الـ event ثم يتحقق من ABI والحالة ويفحص نطاق الطول ويقرأ، ويرد بالترتيب نفسه.
participant W as المرسل
participant M as الكتلة المشتركة
participant R as المستقبل
W->>M: التهيئة وجعل الحالة READY
W->>M: تثبيت الطول بعد انتهاء كتابة الجسم
W->>R: رفع event الطلب
R->>M: التحقق من magic / version / state
R->>M: فحص نطاق الطول ثم القراءة
R->>M: الرد أيضاً يثبّت الطول بعد كتابة الجسم
R->>W: رفع event الرد
الشكل 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. الصفحات الابتدائيّة تبدأ بأصفار.
* إن وُجد الاسم نفسه، 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;
}
المستقبل يستبدل main فقط بعد جعل التعريفات من SHM_NAME حتى DemoBlock مطابقة تماماً للمرسل. في العمل يُقطع هذا الجزء المشترك إلى ملف ترويسة.
/* 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 موضوع لمنع إعادة ترتيب تجعل الطول أو العلم يظهران قبل انتهاء كتابة الجسم.5
إن أردت إسقاط انحراف التخطيط وقت البناء، أضف /std:c11 إلى MSVC وثبّت sizeof(DemoBlock) بـ static_assert من <assert.h>.
التعامل مع الكتلة نفسها من جهة C# يكون كالتالي. النقطة التصريح عن الـ offsets كثوابت، مكتوبة حتى لا تنزاح بايتاً واحداً عن بنية جهة 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 بثوابت الإزاحة
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.
هذه العيّنة عمداً ذهاب وإياب واحد فقط. للنقل المتواصل انتقل إلى ring buffer في 6.4، ولتحمل الإنهاء غير الطبيعي للكاتب انتقل إلى generation و heartbeat في 5.7.
7. نقاط ننظر إليها في Windows و POSIX
| الزاوية | Windows | POSIX |
|---|---|---|
| الإنشاء / open | CreateFileMapping / OpenFileMapping / MapViewOfFile6 |
shm_open / ftruncate / mmap3 |
| مشاركة غير مرتبطة بالقرص | mapping مدعوم بملف الصفحة مع تحديد INVALID_HANDLE_VALUE68 |
POSIX shared memory object + mmap3 |
| القيمة الأولية | صفحات مدعومة بملف الصفحة تُهيّأ بأصفار8 | الكائن الجديد طوله 0. البايتات المخصصة حديثاً تُهيّأ بأصفار3 |
| المزامنة | mutex / semaphore / event / interlocked وغيرها25 | mutex / condvar / semaphore مشتركة بين العمليات2018 |
| ما لا يُستخدم عبر العمليات | CRITICAL_SECTION, WaitOnAddress2110 |
mutex / condvar التي بقيت PTHREAD_PROCESS_PRIVATE2019 |
| موت المالك | WAIT_ABANDONED12 |
robust mutex + EOWNERDEAD / pthread_mutex_consistent()1314 |
| حذف الاسم | يختفي بتحرير آخر handle / view28 | حذف الاسم بـ shm_unlink. إن بقيت مراجع يبقى الكيان حتى النهاية2223 |
| مساحة الأسماء / الأذونات | Global\ / Local\، ACL، SeCreateGlobalPrivilege1524 |
mode, umask, مساحة الأسماء، O_CREAT|O_EXCL3 |
MemoryMappedFile في C# أيضاً في الجوهر غلاف لـ 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. قائمة تحقق ننظر إليها أولاً
- هل shared memory لازم حقاً. هل هي بيانات كبيرة على المضيف نفسه
- هل فُصل control plane عن data plane
- هل يمكن تضييق نموذج التزامن إلى SPSC / كاتب واحد وعدة قرّاء
- هل في الترويسة الأمامية magic / version / size / state / generation / heartbeat
- هل لم تُوضع pointer /
HANDLE/ fd / كائن STL /std::mutex - هل يوجد بروتوكول commit يمنع القارئ من رؤية حالة وسط الكتابة
- هل المهيّئ محدد بشخص واحد
- هل يوجد إجراء استعادة عند الإنهاء غير الطبيعي
- هل الاسم والأذونات مصرَّح بهما
- هل
Global\لازم حقاً - هل لم يُفترض resize in place
- هل جُرّب قتل الكاتب / توقف القارئ / version mismatch / نقص الأذونات
9. الخلاصة
shared memory قوي جداً إذا أُحسن استخدامه. خصوصاً في
- الصور
- الصوت
- صفوف حسّاسات
- دفعات كبيرة
- snapshot عالية التكرار
أي بيانات كبيرة داخل الجهاز نفسه، يؤثّر حقاً.
لكن جوهر shared memory ليس «السرعة» بل نقل المسؤولية. بدلاً من تقليل النسخ والمراسلة عبر النواة، نتولى نحن:
- المزامنة
- الرؤية
- التهيئة
- ABI
- الاستعادة
- الأذونات
- قابلية الرصد
.
flowchart TB
accTitle: جوهر shared memory نقل المسؤولية
accDescr: مخطّط يبيّن نقل المسؤولية: تقليل النسخ والمراسلة عبر النواة مقابل تولّي التطبيق للمزامنة والرؤية والتهيئة وABI والاستعادة والأذونات وقابلية الرصد.
e1["تقليل النسخ والمراسلة"] --> e2["ما نتولاه بدلاً من ذلك"]
e2 --> e3["المزامنة والرؤية والتهيئة"]
e2 --> e4["ABI والاستعادة والأذونات"]
e2 --> e5["قابلية الرصد"]
الشكل 26: جوهر shared memory ليس «السرعة» بل نقل مسؤولية الاتساق إلينا.
لذا الأمان في الخيط الأول هكذا.
- SPSC ring buffer أو double buffer
- ترويسة ثابتة في المقدمة
- إشارة بـ offset
- إشعار عبر قناة أخرى
- مع version / generation / heartbeat
- مع اختبارات المسار غير الطبيعي
البدء بهذا الشكل يجعل shared memory أداة صريحة جداً. بالمقابل، معاملته فوراً كـ «ذاكرة مشتركة سريعة يوضع فيها أي شيء» يحوّل الأمر تدريجياً من تطبيق إلى علم آثار.
10. روابط مرجعية
- Windows: أساسيات file mapping و named shared memory682
- Windows: مساحة الأسماء / الأمان / المزامنة1524512
- POSIX:
shm_open,shm_unlink,mmap, مزامنة process-shared / robust322162013 - .NET: نظرة عامة على
MemoryMappedFile1
-
Microsoft Learn, Memory-mapped files / Microsoft Learn, MemoryMappedFile Class ↩ ↩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 ↩ ↩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, File Mapping Security and Access Rights ↩ ↩2
مقالات ذات صلة
أحدث المقالات التي تشترك في نفس الوسوم. عمّق فهمك بمواضيع مرتبطة.
لماذا تتعطّل الوسائط ── قواعد وسائط سطر أوامر Windows
في Windows لا توجد مصفوفة وسائط؛ يصل إلى CreateProcess سلسلة واحدة ويتولّى الطرف المستقبل تقسيمها. نشرح قواعد CommandLineToArgvW وCRT و.N...
ما الذي يبقى بعد موت الأب ── تربية العمليات الابنة في كائن Job
لماذا يبقى مساعد SDK ماسكاً الكاميرا أو منفذ COM بعد إنهاء الواجهة قسراً. كيف تجعل كائن Job شجرة العمليات وحدة واحدة وتصمم عمر العملية ال...
الأنابيب المسمّاة عمليّاً ── IPC القياسيّ في Windows من التصميم إلى الأمان
دليل عمليّ للأنابيب المسمّاة، وسيلة IPC القياسيّة في Windows. يغطّي وضع البايت مقابل وضع الرسالة، وخوادم متعدّدة العملاء، وأمان ACL وانتح...
قائمة تحقّق للتعامل الآمن مع العمليات الابن في تطبيقات Windows
للتعامل الآمن مع العمليات الابن في تطبيقات Windows، تصميم ملكية شجرة العملية وإجراء الإنهاء أهمّ من واجهة التشغيل. تنظّم المقالة Job Obje...
كيف نستدعي DLL من C# Native AOT من C/C++
إصدار مكتبة أصناف C# كـ native DLL عبر Native AOT، واستدعاء نقاط دخول UnmanagedCallersOnly من C/C++، مع مواضع الاستخدام وأنماط التنفيذ وا...
أين يتصل هذا الموضوع
ترتبط هذه المقالة بشكل طبيعي بصفحات الخدمات التالية.
تطوير تطبيقات ويندوز
تبادل البيانات الضخمة عبر الذاكرة المشتركة وfile mapping وMemoryMappedFile، وتصميم فصل العمليّات، موضوع يتّصل مباشرةً بتطوير تطبيقات Windows.
الاستشارات التقنية ومراجعة التصميم
ترتيب التصميم بما يخفّض معدّل الحوادث، مثل أسلوب المزامنة وتصميم ABI واستراتيجيّة الاستعادة وفصل control plane عن data plane، يتناسب جيّداً مع الاستشارة التقنيّة ومراجعة التصميم.
الأسئلة الشائعة
أسئلة شائعة حول موضوع هذه المقالة.
- إذا كتبت قيمة في shared memory، هل يقرأها عملية أخرى فوراً وبشكل صحيح؟
- أن تكون مرئية شيء، وأن تُقرأ بأمان شيء آخر. shared memory آلية تعرض سلسلة البايتات نفسها على عدة عمليات، وليست المزامنة نفسها. حتى لو قصد الكاتب كتابة length ثم payload ثم ready flag بهذا الترتيب، قد يرى القارئ بلا أي مزامنة length جديداً مع payload قديماً مركّبين. في Windows و POSIX على السواء، يُفترض أن يُجمع الوصول إلى shared memory مع وسيلة مزامنة مثل mutex أو semaphore أو event.
- هل يجوز وضع مؤشر أو std::string أو HANDLE في shared memory؟
- الأفضل ألا تضعها. العنوان الافتراضي وموارد process-local لا معنى لها إلا في سياق تلك العملية، وحتى لو رُبط الـ mapping نفسه في عملية أخرى فليس مضموناً أن يتطابق العنوان الافتراضي. الأمر نفسه ينطبق على std::vector و std::mutex و CRITICAL_SECTION. إن احتجت إشارة فاحملها كـ offset من العنوان الأساسي، وأملِ البيانات في shared memory نحو أعداد صحيحة بعرض ثابت + تخطيط صريح + ترويسة بإصدار.
- هل يغني استخدام volatile عن مزامنة shared memory؟
- لا. volatile ليس سحراً ينقذ تصميم shared memory، وatomicity وmutual exclusion مسألتان منفصلتان على الأقل. تصميم يراقب volatile bool بـ busy loop يهدر CPU، ويغيم ضمان ترتيب payload و ready flag، ويسهل التقاط حالة وسط الكتابة. كذلك WaitOnAddress في Windows موجّه إلى threads داخل العملية نفسها، فلا يُعتمد كآلية انتظار بين العمليات. الإشعار يُوجَّه إلى primitive يمكن انتظاره مثل event أو semaphore.
- ما الذي يجب حسمه أولاً عند تصميم shared memory؟
- أربعة أمور. فصل control plane عن data plane بحيث يكون التحكم (بدء، إيقاف، إشعار) عبر الرسائل والجسم عبر shared memory، وتضييق نموذج التزامن (في البداية SPSC ring buffer أو double buffer أقل عرضة للأعطال)، والمالك والعمر: من ينشئ ويهيّئ ويحذف ويستعيد، وتصميم ABI بما فيه التخطيط والإصدار. وضع magic و version و size و state و generation و heartbeat في الترويسة الأمامية وحده يغيّر كثيراً سهولة التحقيق في الأعطال.
الملف الشخصي للمؤلف
صفحة الملف الشخصي لمؤلف المقالة.
غو كومورا
مؤسّس شركة كومورا سوفت ذ.م.م.
يركّز على تطوير برامج ويندوز، والاستشارات التقنية، والتحقيق في الأخطاء، ويتميّز في المشاريع التي تبقى فيها الأصول القديمة ناشطة، وفي تشخيص الأعطال التي يصعب تحديد سببها.