كيف تختار وسيلة الاتّصال بين عمليّات Windows ── جدول قرار للأنابيب المسمّاة / TCP / gRPC / الذاكرة المشتركة / COM

· آخر تحديث: · · الاتّصال بين العمليّات, الأنابيب المسمّاة, Windows, .NET, C#, gRPC, الذاكرة المشتركة, COM, التصميم, جدول القرار, الاستشارة التقنيّة

«حين نقسم واجهة المستخدم عن الخدمة، كيف يكون الاتّصال بينهما؟» «أريد استخدام وظيفة DLL بنظام 64 بت من تطبيق بنظام 32 بت» «أريد إرسال البيانات من محرّك قياس يعمل في عمليّة منفصلة إلى الشاشة». تقسيم التطبيق إلى عدّة عمليّات حلّ واقعيّ متكرّر لأجل المتانة وفصل الصلاحيّات ومشكلات عدد البتّات، لكن بمجرّد التقسيم يظهر دائماً خيار «كيف يكون الاتّصال بينهما».

في هذه المدوّنة كتبنا سابقاً عن وسائل اتّصال منفردة، مثل مطبّات الذاكرة المشتركة، وتجزئة TCP إلى إطارات (framing)، والتعامل الآمن مع العمليّات الفرعيّة، وتكامل الملفّات والقفل (locking). لكن لم يكن هناك بعد نقاش عامّ حول «أيّ وسيلة ينبغي اختيارها أصلاً». في هذا المقال ننظّم مجالات تفوّق ومطبّات الخيارات الرئيسيّة للاتّصال بين عمليّات Windows (IPC) ── تكامل الملفّات، والأنابيب المسمّاة، وTCP المحليّ، وgRPC، والذاكرة المشتركة، وCOM ── بصيغة جدول القرار المعتاد في مدوّنتنا.

1. الخلاصة أوّلاً

  • بالنسبة إلى نمط الطلب والاستجابة داخل الجهاز نفسه (إرسال أمر واستلام النتيجة)، فإنّ الأنبوب المسمّى هو الخيار الأوّل. فهو معياريّ في نظام التشغيل، ولا يحتاج منفذاً (port)، ومدمَج مع التحكّم بالوصول في Windows، ويُكتَب بسهولة من .NET عبر System.IO.Pipes.1
  • إن كان تجاوز حدود الشبكة محتمَلاً مستقبلاً، فاختر من البداية TCP المحليّ أو gRPC. فالانتقال من الأنبوب إلى المقبس (socket) لاحقاً غالباً ما يكون فرقاً لا يُحلّ بـ«تعديل بسيط لاحقاً». لكن بما أنّ TCP الخام هو تدفّق بايتات، فإنّ تصميم التجزئة إلى إطارات ضروريّ.
  • حين تتزايد أنواع الخدمات أو الاستدعاءات وتصبح صيانة بروتوكولك الذاتيّ عبئاً، اختر gRPC. تحصل على تعريف المخطّط وتوليد الشيفرة عبر proto، والبثّ ثنائيّ الاتّجاه، ويمكن لـ ASP.NET Core (Kestrel) ابتداءً من .NET 8 استخدام الأنبوب المسمّى كطبقة نقل.2
  • اجعل الذاكرة المشتركة حصراً للبيانات كبيرة الحجم وعالية التكرار (إطارات الصور، الموجات). إنّها الأسرع، لكنّها تعني تصميم كلّ المزامنة بنفسك، لذا فالقاعدة الذهبيّة هي عدم وضع رسائل التحكّم عليها أيضاً.3
  • إن كان الأمر يحتمل الاقتران غير المحكم وغير المتزامن، ورغبتَ في الاحتفاظ بسجلّ تدقيق، فتكامل الملفّات لا يزال خياراً قويّاً. لكنّ النجاح فيه يتحدَّد بتصميم التحكّم الحصريّ (exclusive control).
  • لا تجعل COM (خارج العمليّة) الخيار الأوّل في تصميم جديد، لكنّه لا يزال أداة فعّالة في سياق الاستخدام من VBA أو لغات أخرى، وجسر 32bit/64bit.4
  • مهما كانت الوسيلة المختارة، لن تتخلّص من قضايا التصميم المشتركة: حدود الرسائل، ورقم الإصدار، والمهلة الزمنيّة، وإعادة الاتّصال (الفصل 6). استخدم من الوقت في هذا الجانب بقدر ما تستخدمه في اختيار الوسيلة.

2. طبيعة الخيارات

2.1 تكامل الملفّات ── اقتران غير محكم، غير متزامن، قابل للتدقيق

هو تكامل كلاسيكيّ من نوع «تضع A ملفّاً في مجلّد الإخراج، وتلتقطه B وتعالجه». لا حاجة لأن يعمل الطرفان في وقت واحد، ويبقى أثر المعالجة كملفّ، وعند حدوث عطل يمكن للإنسان النظر إلى الملفّ مباشرةً وإصلاحه وإعادة إدخاله. لا يزال قويّاً في التكامل ذي الطابع الدفعيّ (batch) الذي لا يتطلّب فوريّة.

يتركّز المطبّ في نقطة واحدة تقريباً، وهي التحكّم بالتزامن. «قراءة ملفّ لا يزال قيد الكتابة» و«تنازع معالجتين على الملفّ نفسه» حادثتان كلاسيكيّتان، ويلزم اتّباع أساليب معروفة مثل الكتابة باسم ملفّ مؤقّت ثمّ إعادة تسميته. التفاصيل موجودة في «أفضل ممارسات تكامل الملفّات والقفل»، فارجع إليها دائماً عند اختيار هذه الطريقة. لا تناسب هذه الطريقة الاتّصال التفاعليّ الذي يحتاج استجابة، ولا التبادل الذي يحدث عشرات المرّات في الثانية.

2.2 الأنبوب المسمّى ── الخيار الأساسيّ للاتّصال داخل الجهاز نفسه

الأنبوب المسمّى مسار اتّصال ثنائيّ الاتّجاه توفّره نواة Windows، وهو الخيار الأساسيّ لنمط عميل/خادم للاتّصال بين العمليّات داخل الجهاز نفسه. في .NET يُتعامَل معه عبر NamedPipeServerStream / NamedPipeClientStream، ويمكن لعدّة عملاء الاتّصال باسم أنبوب واحد.5 وخلافاً لـ TCP، لا يحتاج إدارة رقم منفذ، ولا يصطدم بجدار الحماية.

نذكر النقاط الأساسيّة التي ينبغي الإحاطة بها في العمل الفعليّ.

  • يمكن استخدام وضع الرسائل (Message Mode). بتحديد PipeTransmissionMode.Message، تصل كلّ عمليّة كتابة واحدة كرسالة واحدة مفصولة. هذه ميزة عمليّة كبيرة، إذ يتولّى نظام التشغيل نيابةً عنك التجزئة إلى إطارات (framing) التي تُعدّ ضروريّة في TCP، مثل بادئة الطول (length prefix) (يحتاج الطرف المستقبل إلى قراءة رسالة كاملة حتّى IsMessageComplete. راجع الفصل 5).
  • الصلاحيّات الافتراضيّة أوسع ممّا يُتوقَّع. يمنح واصف الأمان الافتراضيّ للأنبوب صلاحيّة التحكّم الكامل لِـ LocalSystem والمسؤولين والمُنشئ، بينما يسمح أيضاً بالقراءة لـ Everyone والحسابات المجهولة.6 بالنسبة إلى الاتّصال بين عمليّات المستخدم نفسه، يمكن فرض «الاتّصال فقط بطرف أنشأه المستخدم نفسه» بمجرّد إضافة PipeOptions.CurrentUserOnly، ونوصي بجعل هذا هو الأسلوب الافتراضيّ.7 أمّا بين حسابات مختلفة (كالتواصل مع خدمة)، فحدِّد ACL صراحةً عبر PipeSecurity.
  • اسم الأنبوب موجود في مساحة أسماء يراها الجميع. توضَع أسماء الأنابيب في مساحة أسماء واحدة تحت \\.\pipe\، وتراها أيضاً عمليّات مستخدمين آخرين على الجهاز. النقطة التي يجب الحذر منها هنا هي احتكار الاسم (squatting). إن أقامت عمليّة خبيثة خادماً بالاسم نفسه أوّلاً وانتظرت، فسيتّصل العميل بها. من الإجراءات الاحترازيّة الجديرة بالإضافة، في الأجهزة التي تشترك فيها مستخدمون بصلاحيّات مختلفة: أن يحدِّد الخادم PipeOptions.FirstPipeInstance ليفشل إذا كان هناك أنبوب بالاسم نفسه أصلاً، وأن يتحقّق العميل من حساب مالك الأنبوب بعد الاتّصال.8
  • يمكنه تجاوز حدود الصلاحيّات في الاتّصال. إنّه أيضاً مسار اتّصال معتاد بين «واجهة بصلاحيّات قياسيّة» و«وسيط (broker) بصلاحيّات مسؤول» مفصولَين عبر UAC. لكن في هذا الإعداد لا يمكن استخدام CurrentUserOnly (لأنّه يتطلّب حتّى مع المستخدم نفسه تطابق مستوى الرفع (elevation) أيضاً7). يلزم تصميم ACL صراحةً، وقد كتبنا التفاصيل الملموسة في «فصل صلاحيّات المسؤول (وسيط - Broker)».

نقطة الضعف هي أنّه غير عمليّ فعليّاً للاتّصال عبر أجهزة متعدّدة (ممكن نظريّاً لكن بقيود تشغيليّة كثيرة)، وصعب الاستخدام للتشغيل البيني مع أنظمة غير Windows. إن كان هذا المتطلّب واضحاً، فاختر TCP / gRPC.

2.3 TCP المحليّ ── تعميم يتجاوز اللغة ونظام التشغيل

الاتّصال عبر TCP إلى localhost هو أكثر وسيلة اتّصال بين العمليّات تعميماً، إذ يمكن استخدامها من أيّ لغة أو بيئة تشغيل أو نظام تشغيل تقريباً. في بِنى مختلطة مثل «محرّك تحليل على Linux وواجهة مستخدم على Windows»، يكون TCP (أو HTTP/gRPC فوقه) أوّل مرشّح.

هناك ثلاث نقاط للانتباه.

  • إنّه تدفّق بايتات (byte stream). لا يمتلك TCP خاصّيّة «تصل بالوحدة التي أُرسلت بها». وصول ثلاث رسائل أُرسلت بـ Send مجتمعة في Receive واحدة، ووصول رسالة واحدة مجزَّأة، كلاهما سلوك طبيعيّ. يلزم تصميم تجزئة إلى إطارات على مستوى التطبيق، مثل بادئة الطول، وأيّ شيفرة تتجاوز ذلك «تعمل بالصدفة» فقط. راجع التفاصيل في «المفهوم الخاطئ بأنّ الاستقبال يتطابق مع وحدة الإرسال في TCP».
  • ضيّق نطاق ظهور الاستماع (listening). حتّى وإن كنتَ تقصد الاتّصال داخل الجهاز نفسه، فإن استمعتَ على 0.0.0.0، ستتمكّن أجهزة أخرى على الشبكة من الاتّصال. المبدأ في IPC المحليّ هو الربط بـ 127.0.0.1 (loopback). ومع ذلك، لا يزال بإمكان مستخدم آخر على الجهاز نفسه الاتّصال، فإن احتجتَ إلى التحقّق من هويّة الطرف، أضف مصادقة على مستوى التطبيق. عدم وجود تحكّم وصول مدمَج في نظام التشغيل، كما في الأنبوب، فرق واضح.
  • إدارة المنفذ تلازمك. قد يتعارض منفذ ثابت مع برامج أخرى، وقد ترصده منتجات جدار الحماية بوصفه «استماعاً مريباً». ضع وسيلة تغيير رقم المنفذ في التصميم منذ البداية.

2.4 gRPC ── شراء المخطّط وتوليد الشيفرة

مقارنةً بمقبس خام + بروتوكول ذاتيّ، ما يوفّره gRPC ليس الاتّصال نفسه بقدر ما هو إطار عمل تطويريّ. عرِّف الخدمات والرسائل في ملفّ proto، وسيُولَّد كلّ شيء من تسلسل (serialization) وتجزئة إلى إطارات وشيفرة العميل/الخادم تلقائيّاً، ويمكن كتابة البثّ ثنائيّ الاتّجاه (كإشعار دفع من الخادم) وكأنّه ميزة لغويّة. عندما تدخل مرحلة «تعديل جملة switch الخاصّة ببروتوكولك الذاتيّ والتوثيق مع كلّ رسالة جديدة»، يصبح هذا الإطار فعّالاً.

ابتداءً من .NET 8، يدعم ASP.NET Core (Kestrel) مباشرةً مقابس المجال في Unix والأنابيب المسمّاة كطبقة نقل، وليس TCP فقط.8 على جانب الخادم يكفي استدعاء ListenNamedPipe، ويمكن أيضاً ضبط التحكّم بالوصول عبر PipeSecurity.2 المزيج «مسار الاتّصال يبقى أنبوباً بلا منفذ ومدمَجاً مع ACL، وطبقة البروتوكول هي gRPC» خيار قويّ عند فصل واجهة المستخدم عن الخدمة (الفصل 4).

من ناحية أخرى، يميل إلى أن يكون مبالَغاً فيه في الاتّصال بين أدوات صغيرة. إقامة خادم gRPC تعني حمل بنية استضافة ASP.NET Core، ما يزيد من الملفّات الموزَّعة والتبعيّات وتكلفة الإقلاع. بين أدوات لا تتجاوز أوامرها 2-3 أنواع، تكون الأنبوب المسمّى + JSON (الفصل 5) أقلّ تكلفة إجماليّة، احتفظ بهذا الخطّ الفاصل. لا حرج في الانتقال بعد أن يتحقّق واحد من: «تجاوز عدد الرسائل التي تريد كتابتها في proto عشرة»، أو «إشعارات البثّ ضرورة جوهريّة»، أو «الطرف الآخر خارج .NET».

2.5 الذاكرة المشتركة (ملفّ مُمَثَّل في الذاكرة) ── الأسرع، لكنّ كلّ المزامنة عليك

أسرع وسيلة اتّصال بين العمليّات داخل الجهاز نفسه هي الذاكرة المشتركة. في .NET يُنشَأ ذاكرة مشتركة مسمّاة عبر MemoryMappedFile.CreateNew، ويمكن لعدّة عمليّات قراءة وكتابة سلسلة البايتات نفسها مباشرةً.3 بما أنّ الأمر لا ينطوي على نسخ ولا تسلسل، فإنّ الأداء المتحقَّق لبيانات «كبيرة وسريعة» كإطارات الصور أو الموجات يجعلها الخيار شبه الوحيد المناسب.

لكنّ الذاكرة المشتركة ليست «أنبوباً سريعاً». ما تراه فقط هو سلسلة البايتات نفسها، ولا تُوفَّر أيّ مزامنة على الإطلاق. آليّة عدم قراءة بيانات لا تزال قيد الكتابة، واكتشاف حياة أو موت الطرف الآخر، والتعافي بعد إنهاء غير طبيعيّ لأحد الطرفين ── كلّها من مسؤوليّتك بالكامل. نظريّة التصميم بما فيها بِنية الحلقة الدائريّة (ring buffer) وإدارة إصدار المخطّط (layout) موجودة بالتفصيل في «مطبّات الذاكرة المشتركة وأفضل ممارساتها العمليّة».

استخدامها العمليّ واضح: وضع مستوى البيانات (data plane) فقط في الذاكرة المشتركة، وإرسال مستوى التحكّم (control plane) إلى قناة أخرى كالأنبوب المسمّى (البِنية 3 في الفصل 4). التحكّم يحدث بضع مرّات في الثانية فيكفيه الأنبوب، أمّا البيانات فتُقرَّب من عمليّة عديمة النسخ عبر الذاكرة المشتركة. فصل القناتين يتيح تحسين تصميم جانب البيانات (كالحلقة الدائريّة) بمعزل عن ملاءمات التحكّم. إن بدأتَ تحاول تمرير رسائل التحكّم مثل البدء والإيقاف وتغيير الإعدادات عبر الذاكرة المشتركة أيضاً، فتلك علامة على ضرورة إعادة النظر في التصميم.

2.6 COM (خارج العمليّة) ── ليس الخيار الأوّل الجديد، لكن لا يزال له مواضع فعّالة

خادم COM خارج العمليّة (خادم EXE) آليّة قديمة تملكها Windows «لاستدعاء كائن يعمل في عمليّة أخرى وكأنّه دالّة محليّة».4 لم يعد هو الخيار الأوّل في اتّصال جديد بين تطبيقات، لكنّه لا يزال عمليّاً في السياقات التالية.

  • جسر 32bit/64bit: DLL بنظام 64 بت (أو العكس) من تطبيق 32 بت لا يمكن تحميله في العمليّة نفسها، لكن COM خارج العمليّة يستطيع تجاوز الحدّ. تتولّى بنية COM المارشلينغ، فيكاد لا يتغيّر شيء في شيفرة الطرف المستدعي. كتبنا مثالاً فعليّاً في «مثال عمليّ على جسر COM من 32bit إلى 64bit».
  • الاستخدام من VBA أو لغات أخرى: عند رغبتك في استدعاء وظيفة .NET من بيئة تنفيذ قديمة كـ VBA في Excel، فإنّ الكشف عنها كـ COM لا يزال أبسط مسار.
  • التكامل مع أصول COM قائمة: إن كان الطرف الآخر لا يتحدّث إلّا COM، فتحدّث أنت أيضاً بـ COM كأقصر طريق (شرح COM نفسه في «ما هو COM · ActiveX · OCX»).

سبب التردّد في الاعتماد الجديد هو عناء التوزيع المصاحب لتسجيل السجلّ (Registry)، وتكلفة تعلّم تصميم الواجهات وعدّ المراجع، وصعوبة التحقيق عند حدوث مشكلات. قارِن قبل القرار بالبديل «إن كان الهدف جسر 32/64، اجعل جانب 64 بت مجرّد عمليّة مساعِدة وتحدَّث معها عبر الأنبوب المسمّى» (البِنية 2 في الفصل 4).

2.7 وسائل كلاسيكيّة ── لا تُختار في تصميم جديد

يمتلك Windows وسائل IPC أخرى مثل WM_COPYDATA (إرسال بيانات عبر رسالة نافذة)، والحافظة (clipboard)، وDDE، وفتحات البريد (mailslots)، وهي لا تزال مدرَجة في صفحة نظرة عامّة IPC الرسميّة.4 لكن بما أنّها تفترض وجود نافذة، أو أنّها في طريق الإلغاء (فتحات البريد البعيدة في طريقها إلى الإلغاء)، فلا يكاد يكون هناك سبب لاختيارها في تصميم جديد. يكفي معرفتها عند مصادفتها في صيانة تطبيق قائم.

3. جدول القرار

الزاوية الملفّات الأنبوب المسمّى TCP المحليّ gRPC الذاكرة المشتركة COM
نطاق الاتّصال يمكن تجاوز الأجهزة عبر مجلّد مشترَك الجهاز نفسه عمليّاً يمكن تجاوز الأجهزة يمكن تجاوز الأجهزة الجهاز نفسه حصراً الجهاز نفسه عمليّاً
نموذج الاتّصال تسليم ملفّ (غير متزامن) تدفّق + وضع رسائل تدفّق بايتات RPC + بثّ حالة مشترَكة استدعاء دالّة
إشعار من الخادم إلى العميل ✕ (استطلاع دوريّ) ◎ (بثّ ثنائيّ الاتّجاه) △ (بالاستعانة بحدث) △ (ممكن لكن معقّد)
تجاوز حدود الصلاحيّات (UAC، الخدمات) ○ (ACL المجلّد) ◎ (PipeSecurity) △ (المصادقة ذاتيّة) △ إلى ○ (◎ مع طبقة نقل الأنبوب) ○ (ACL ممكنة، التصميم صعب)
عدد بتّات مختلط / لغات مختلطة ◎ (توليد لكلّ لغة من proto) △ (تصميم ABI إلزاميّ) ○ (الجسر مجاله المفضَّل)
تكلفة التنفيذ منخفضة منخفضة إلى متوسّطة متوسّطة (تجزئة إلى إطارات ذاتيّة) متوسّطة (إدخال بنية) مرتفعة مرتفعة (للجديد)
سهولة التنقيح (debug) ◎ (المحتوى يبقى كملفّ) ○ (يمكن الالتقاط - capture) △ (HTTP/2 + ثنائيّ) △ (الأعراض صاخبة)
الإنتاجيّة / زمن الاستجابة منخفضة متوسّطة إلى مرتفعة متوسّطة متوسّطة متوسّطة

ملاحظة إضافيّة واحدة. الاستخدام الصحيح ليس اختيار «الوسيلة الأكثر ◎»، بل النظر فقط إلى الصفّ الذي يؤثّر في متطلّباتك والاختيار بأسلوب الاستبعاد التدريجيّ. مثلاً، بمجرّد أن يتقرّر «30 إطار صورة في الثانية»، يصبح مستوى البيانات الذاكرة المشتركة دون منازع.

4. أمثلة على البِنى الشائعة

عند تطبيق جدول القرار على متطلّبات محدَّدة، غالباً ما يستقرّ العمل الفعليّ على هذه البِنى الثلاث.

البِنية 1: فصل تطبيق واجهة المستخدم عن خدمة Windows. توضَع المعالجة المقيمة أو المعالجة التي تحتاج صلاحيّات خاصّة في الخدمة، وتبقى واجهة المستخدم عمليّة مستخدم عاديّة (راجع كيفيّة بناء جانب الخدمة في «مقال خدمات Windows» المنشور في اليوم نفسه). الأنبوب المسمّى هو الخيار الأساسيّ للاتّصال، والبدء برسائل JSON + وضع الرسائل (أو وضع البايتات + بادئة الطول) خيار متين، ومع نموّ أنواع الاستدعاءات هناك مسار للانتقال إلى طبقة نقل الأنبوب المسمّى في gRPC.2 بما أنّ الخدمة تعمل بحساب مختلف (كـ LocalService)، لا يمكن استخدام CurrentUserOnly، والنقطة المهمّة هي تحديد ACL صراحةً عبر PipeSecurity، مثل «السماح لمجموعة Users بالقراءة والكتابة، ورفض الوصول البعيد».

البِنية 2: جسر من تطبيق 32 بت إلى وظيفة 64 بت. عند الرغبة في استخدام DLL أو SDK جهاز لا يعمل إلّا بنظام 64 بت من تطبيق قائم بنظام 32 بت، يُفصَل جانب 64 بت إلى عمليّة منفصلة. التنفيذ على طريقتين: (أ) إطلاق عمليّة مساعِدة بنظام 64 بت والتخاطب عبر الأنبوب المسمّى، (ب) جعله خادم COM خارج العمليّة بنظام 64 بت. إن كان الطرف المستدعي VBA أو لغة قديمة، فالطريقة (ب) أبسط، وبين تطبيقين مكتوبين بـ C#، الطريقة (أ) أسهل توزيعاً وتحقيقاً. في حالة (أ)، استخدم نمط Job Object الذي كتبناه في «التعامل الآمن مع العمليّات الفرعيّة» بشكل مباشر لإطلاق العمليّة المساعِدة ومراقبتها وربط إنهائها بالعمليّة الأمّ.

البِنية 3: بيانات عالية التكرار من محرّك القياس إلى العرض. في بِنية تفصل محرّك القياس أو معالجة الصور في عمليّة منفصلة وتدفّق البيانات إلى واجهة المستخدم، البِنية الشائعة هي قناتان: التحكّم (البدء والإيقاف والإعدادات) عبر الأنبوب المسمّى، والبيانات (الإطارات والموجات) عبر الذاكرة المشتركة + حدث مسمّى. يكفي الأنبوب للتحكّم الذي يحدث بضع مرّات في الثانية، بينما تقارب البيانات نسخاً صفريّاً عبر الذاكرة المشتركة. فصل القناتين يتيح تحسين تصميم جانب البيانات (كالحلقة الدائريّة) بمعزل عن ملاءمات التحكّم. راجع أيضاً «عرض حالة الأجهزة الخارجيّة» بخصوص عرض حالة المحرّك أو الجهاز الخارجيّ.

5. مثال تنفيذ ── خادم وعميل الأنبوب المسمّى غير المتزامنَين

نعرض البِنية الدنيا في .NET 8 التي تُبنى عليها تنفيذات الأنبوب المسمّى. شكل يشمل وضع الرسائل، ودعم عدّة عملاء، ومعالجة الانقطاع. البروتوكول هو «JSON بترميز UTF-8، رسالة واحدة لكلّ اتّصال كتابة»، مع إلزام وجود حقل version دائماً. يفترض المثال أدناه اتّصالاً بين عمليّات المستخدم نفسه، ولذا يستخدم CurrentUserOnly. عند التواصل مع خدمة (حساب مختلف) كما في البِنية 1، أزِل CurrentUserOnly وحدِّد ACL صراحةً عبر PipeSecurity كما هو مذكور لاحقاً.

أوّلاً جانب الخادم. يُنشَأ تدفّق خادم جديد في كلّ مرّة يُستقبَل فيها اتّصال، وتُفصَل معالجة كلّ عميل إلى مهمّة (task) منفصلة.

using System.IO.Pipes;
using System.Text.Json;

public sealed class PipeServer(string pipeName)
{
    // الإعداد الافتراضيّ الويبيّ لِـ camelCase وعدم حساسيّة الأحرف. الإعداد
    // الافتراضيّ لِـ System.Text.Json حسّاس للأحرف، وبدون مشاركة هذا الإعداد
    // لن يُربَط "version" بـ Request.Version فيسقط طلب صحيح إلى unknown_type
    internal static readonly JsonSerializerOptions JsonOptions =
        new(JsonSerializerDefaults.Web);

    public async Task RunAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            var pipe = new NamedPipeServerStream(
                pipeName,
                PipeDirection.InOut,
                NamedPipeServerStream.MaxAllowedServerInstances,
                PipeTransmissionMode.Message,   // كتابة واحدة = رسالة واحدة
                PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);

            await pipe.WaitForConnectionAsync(ct);
            _ = Task.Run(() => HandleClientAsync(pipe, ct), ct);  // فصل لكلّ اتّصال
        }
    }

    // الحدّ الأقصى لرسالة واحدة. حتّى إن أرسل الطرف الآخر رسالة ضخمة،
    // يحمي هذا ذاكرة جانب الخدمة (الطرف الآخر في IPC مدخل خارجيّ أيضاً. الفصل 6)
    private const int MaxMessageBytes = 1024 * 1024;

    private static async Task HandleClientAsync(
        NamedPipeServerStream pipe, CancellationToken ct)
    {
        await using (pipe)
        {
            var buffer = new byte[64 * 1024];
            try
            {
                while (!ct.IsCancellationRequested)
                {
                    // حتّى في وضع الرسائل، لا يُضمَن قراءة الرسالة كاملةً
                    // في عمليّة Read واحدة. اقرأ حتّى IsMessageComplete
                    using var ms = new MemoryStream();
                    do
                    {
                        int n = await pipe.ReadAsync(buffer, ct);
                        if (n == 0) return;  // العميل قطع الاتّصال
                        ms.Write(buffer, 0, n);
                        if (ms.Length > MaxMessageBytes) return;  // تجاوز الحدّ. اقطع الاتّصال
                    } while (!pipe.IsMessageComplete);

                    byte[] response = Dispatch(ms.ToArray());
                    await pipe.WriteAsync(response, ct);
                }
            }
            catch (IOException)
            {
                // انقطع مسار الاتّصال مع هذا العميل فقط.
                // لا تُوقِف الخادم بأكمله، واصل معالجة الاتّصالات الأخرى
            }
        }
    }

    private static byte[] Dispatch(byte[] payload)
    {
        Request? req;
        try { req = JsonSerializer.Deserialize<Request>(payload, JsonOptions); }
        catch (JsonException) { req = null; }

        // افحص الشكل غير الصحيح والإصدار غير المعروف والنوع غير المعروف
        // وارفضها هنا
        object result = req switch
        {
            null => new { version = 1, error = "bad_request" },
            // إصدار غير مذكور (يُربَط بـ 0) أو إصدار غير معروف يُرفَض هنا
            { Version: not 1 } => new { version = 1, error = "version_unsupported" },
            { Type: "getStatus" } => new { version = 1, running = true },
            { Type: "startJob" } => new { version = 1, jobId = StartJob(req) },
            _ => new { version = 1, error = "unknown_type" },
        };
        return JsonSerializer.SerializeToUtf8Bytes(result, JsonOptions);
    }
}

public sealed record Request(int Version, string Type, JsonElement? Body);

جانب العميل يحصر «الاتّصال ← الطلب ← الاستجابة» داخل دالّة واحدة، ويضع المهلة الزمنيّة وإعادة المحاولة من البداية.

using System.IO.Pipes;
using System.Text.Json;

public sealed class PipeClient(string pipeName)
{
    // idempotent: يُعاد المحاولة فقط عندما يصرّح الطرف المستدعي بأنّ هذا
    // الطلب آمن حتّى إن وصل مرّتين. الافتراضيّ هو "لا إعادة محاولة"
    public async Task<TResponse?> RequestAsync<TResponse>(
        object request, CancellationToken ct, bool idempotent = false)
    {
        for (int attempt = 1; ; attempt++)
        {
            // ضع سقف زمن لعمليّة تبادل الطلب-الاستجابة بأكملها، لا للاتّصال
            // فقط. يمنع هذا الانتظار اللانهائيّ في حال قبل الخادم الاتّصال
            // فقط ثمّ تجمّد دون كتابة استجابة
            using var deadline =
                CancellationTokenSource.CreateLinkedTokenSource(ct);
            deadline.CancelAfter(TimeSpan.FromSeconds(10));
            try
            {
                using var pipe = new NamedPipeClientStream(
                    ".", pipeName, PipeDirection.InOut,
                    PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);

                // لا تنتظر إلى ما لا نهاية. إن لم يكن الخادم قد بدأ، يُرمى استثناء
                await pipe.ConnectAsync(timeout: 3000, deadline.Token);
                pipe.ReadMode = PipeTransmissionMode.Message;

                await pipe.WriteAsync(
                    JsonSerializer.SerializeToUtf8Bytes(
                        request, PipeServer.JsonOptions),
                    deadline.Token);

                using var ms = new MemoryStream();
                var buffer = new byte[64 * 1024];
                do
                {
                    int n = await pipe.ReadAsync(buffer, deadline.Token);
                    if (n == 0) throw new IOException("قطع الخادم الاتّصال.");
                    ms.Write(buffer, 0, n);
                } while (!pipe.IsMessageComplete);

                return JsonSerializer.Deserialize<TResponse>(
                    ms.ToArray(), PipeServer.JsonOptions);
            }
            catch (OperationCanceledException) when (ct.IsCancellationRequested)
            {
                throw;  // إلغاء من الطرف المستدعي. لا تُعِد المحاولة
            }
            catch (Exception ex) when (
                ex is IOException or TimeoutException or OperationCanceledException
                && idempotent && attempt < 3)
            {
                // أعِد المحاولة عند فشل مؤقّت كإعادة تشغيل الخادم.
                // في حالة "أُرسل الطلب لكن انقطع الاتّصال قبل قراءة الاستجابة"،
                // قد يكون الطلب قد نُفِّذ بالفعل، لذا الطلبات ذات الأثر الجانبيّ
                // كـ startJob لا تُعاد محاولتها افتراضيّاً، وبعد تصميمها بحيث
                // تُرفَض التكرارات عبر معرّف طلب، يُمرَّر idempotent: true (الفصل 6)
                await Task.Delay(500 * attempt, ct);
            }
        }
    }
}

نضيف توضيحاً موجزاً لثلاثة قرارات تصميميّة في هذه الشيفرة.

  • أدرِج version منذ الإصدار الأوّل. لا بدّ أن تأتي لحظة تُحدَّث فيها واجهة المستخدم والخدمة بشكل منفصل (يصبح أحدهما أحدث فقط). عندما يرفض الطرف المستجيب الإصدار غير المعروف صراحةً فقط، يتحوّل حادث «سلوك غريب دون تنبيه» إلى «خطأ واضح».
  • قرِّر هل يكون الاتّصال قابلاً للاستهلاك مرّة واحدة (disposable) أم دائماً. المثال أعلاه من النوع القابل للاستهلاك، يتّصل مع كلّ طلب، ولا يحتاج إدارة حالة انقطاع وإعادة اتّصال، لكنّه لا يناسب الاستدعاءات عالية التكرار. عند الحاجة إلى اتّصال دائم وإشعار دفع من الخادم، يصبح هذا التعقيد مبرّراً كافياً للانتقال إلى البثّ ثنائيّ الاتّجاه في gRPC.
  • عند التواصل مع خدمة، أزِل CurrentUserOnly وصمِّم PipeSecurity. يفترض المثال أعلاه اتّصالاً بين عمليّات المستخدم نفسه. إن كان الطرف الآخر خدمة بحساب مختلف، أنشئ تدفّق خادم مزوَّداً بـ ACL عبر NamedPipeServerStreamAcl.Create، وحدِّد صراحةً المجموعة المسموح لها بالاتّصال.6

6. تنبيهات مشتركة في تصميم البروتوكول ── العمل بعد اختيار الوسيلة

مهما كانت وسيلة IPC المختارة، هناك قضايا مشتركة في تصميم محتوى الاتّصال. وهذه، في الواقع، مصدر الحوادث أكثر من اختيار الوسيلة نفسه.

  • حدود الرسائل (التجزئة إلى إطارات): باستثناء الحالات التي يفصل فيها نظام التشغيل الرسائل تلقائيّاً كوضع رسائل الأنبوب، فإنّ «أين تبدأ الرسالة الواحدة وأين تنتهي» مسؤوليّة التطبيق. حدود السجلّات في الذاكرة المشتركة مشكلة مماثلة. اجعل طريقة بادئة الطول أساساً.
  • الإصدار (Versioning): أدرِج رقم إصدار الشكل في الرسالة، وليرفض الطرف المستقبل صراحةً الإصدار غير المعروف. لا ضمان لتحديث العمليّتين دائماً في وقت واحد حتّى إن كانتا تُوزَّعان بالمُثبِّت (installer) نفسه.
  • المهلة الزمنيّة: صمِّم مع افتراض أنّ «عدم استجابة الطرف الآخر» لا بدّ أن يحدث دائماً. حدِّد سقفاً زمنيّاً لكلّ من الاتّصال والطلب والاستجابة، ولا تترك شيفرة تنتظر إلى ما لا نهاية. الانتظار المتزامن في مؤشّر ترابط واجهة المستخدم مرفوض قطعيّاً.
  • إعادة الاتّصال والعمليّات المُتَحايدة (idempotency): إعادة المحاولة تعني خلق احتماليّة وصول الطلب نفسه مرّتين. صنِّف كلّ نوع طلب حسب «هل تنفيذه مرّتين آمن؟»، وأرفِق معرّف طلب لما هو غير آمن (كإرسال المهام) لرفض التكرار.
  • تحقّق من الطرف الآخر أيضاً باعتباره مُدخَلاً خارجيّاً: من السهل، في اتّصال داخل الجهاز نفسه، التساهل في التحقّق بحجّة «الطرف الآخر تطبيقي أنا»، لكن عند وجود حدّ صلاحيّات، فإنّ طرف IPC مدخل «غير موثوق» تماماً كمدخل من الشبكة. لا يجوز للوسيط (broker) أو الخدمة بصلاحيّات مسؤول تنفيذ المسارات أو الأوامر الواردة من عميل بصلاحيّات قياسيّة كما هي دون تحقّق. من قواعد IPC عبر حدود الصلاحيّات، بما في ذلك احتكار اسم الأنبوب (الفصل 2.2)، الشكّ في «مَن» و«ماذا» معاً.
  • اترك وسيلة مراقبة: تسجيل السجلّات (على الأقلّ نوع الرسالة والطرف والنتيجة) يقلّل بشكل كبير وقت التحقيق في مشكلات نوع «لا يتّصل» أو «لا يُستجاب».

7. الخلاصة

اختيار وسيلة الاتّصال بين العمليّات يكاد لا يكون مصدر حيرة إن فكّرت بالترتيب التالي. أوّلاً، هل يكفي الاقتصار على الجهاز نفسه؟ إن كفى ذلك، فالأنبوب المسمّى للطلب-الاستجابة، والذاكرة المشتركة للبيانات كبيرة الحجم وعالية التكرار فقط، وتكامل الملفّات للدفعات ذات الاقتران غير المحكم. إن كان تجاوز الأجهزة أو اختلاط اللغات واضحاً، فـ TCP المحليّ أو gRPC، وإن أصبحت إدارة بروتوكولك الذاتيّ عبئاً بحجم معيّن فـ gRPC. لا تجعل COM الخيار الأوّل الجديد، بل احتفظ به كأداة لجسر 32/64 والتكامل مع VBA ── جدول القرار في هذا المقال هو تجسيد جدوليّ لهذا التسلسل.

وبعد تحديد الوسيلة، أدرِج التصميم المشترَك في الفصل 6 (التجزئة إلى إطارات، والإصدار، والمهلة الزمنيّة، وإعادة الاتّصال، والتحقّق من الطرف الآخر) في الإصدار الأوّل. الشعور من العمل الفعليّ هو أنّ مشكلات IPC سببها «إغفال تصميم البروتوكول» أكثر بكثير من «خطأ في اختيار الوسيلة». عند إعادة النظر في تقسيم العمليّات أو وسيلة الاتّصال، نوصي بالبدء بجرد «أيّ عمليّة، وبصلاحيّات مَن، وماذا، وبأيّ تكرار تتبادل».

مقالات ذات صلة

مجالات الاستشارة ذات الصلة

تتعامل شركة Komura Soft LLC مع مراجعة تصميم تقسيم العمليّات ووسيلة الاتّصال، وتصميم التكامل مع الأصول القائمة بما في ذلك جسر 32bit/64bit، والتحقيق في أسباب مشكلات الاتّصال من نوع «لا يتّصل، لا يُستجاب».

روابط مرجعيّة

  1. Microsoft Learn, How to: Use Named Pipes for Network Interprocess Communication. حول مثال تنفيذ خادم/عميل داعم لعدّة عملاء عبر NamedPipeServerStream / NamedPipeClientStream. 

  2. Microsoft Learn, Inter-process communication with gRPC and Named pipes. حول تشغيل gRPC فوق الأنبوب المسمّى عبر ListenNamedPipe في Kestrel ابتداءً من .NET 8، والتحكّم بالوصول عبر PipeSecurity.  2 3

  3. Microsoft Learn, MemoryMappedFile Class. واجهة .NET لملفّات الذاكرة المُمَثَّلة. حول ملاءمة الذاكرة المشتركة غير المرتبطة بملفّ التي يُنشئها CreateNew لأغراض IPC.  2

  4. Microsoft Learn, Interprocess communications. حول قائمة آليّات IPC التي توفّرها Windows (الحافظة، وCOM، وWM_COPYDATA، وDDE، وتمثيل الملفّات، وفتحات البريد، والأنابيب، وRPC، ومقابس Windows) وإرشادات اختيار كلّ منها.  2 3

  5. Microsoft Learn, NamedPipeServerStream Class. حول تدفّق جانب الخادم للأنبوب المسمّى، والباني (constructor) الذي يحدِّد PipeTransmissionMode وPipeSecurity. 

  6. Microsoft Learn, Named Pipe Security and Access Rights. حول سماح واصف الأمان الافتراضيّ للأنبوب المسمّى بالقراءة لـ Everyone والحسابات المجهولة، وبِنية صلاحيّات الوصول.  2

  7. Microsoft Learn, PipeOptions Enum. حول أنّ CurrentUserOnly يسمح بالاتّصال فقط مع «طرف أنشأه المستخدم نفسه»، وأنّ Windows يتحقّق أيضاً من مستوى الرفع بالإضافة إلى حساب المستخدم.  2

  8. Microsoft Learn, Inter-process communication with gRPC. حول طبقات النقل (مقابس مجال Unix، الأنابيب المسمّاة) عند استخدام gRPC في IPC، واعتبارات الأمان مثل التحقّق من مالك الخادم ومضادّات انتحال الهوية (impersonation).  2

أحدث المقالات التي تشترك في نفس الوسوم. عمّق فهمك بمواضيع مرتبطة.

ترتبط هذه المقالة بشكل طبيعي بصفحات الخدمات التالية.

الأسئلة الشائعة

أسئلة شائعة حول موضوع هذه المقالة.

ما الذي ينبغي أن يكون الخيار الأوّل للاتّصال بين عمليّات Windows؟
بالنسبة إلى نمط الطلب والاستجابة (إرسال أمر واستلام النتيجة) داخل الجهاز نفسه، فإنّ الأنابيب المسمّاة هي الخيار الأوّل. فهي معياريّة في نظام التشغيل، ولا تحتاج إلى إدارة رقم منفذ، ولا تصطدم بجدار الحماية، وهي مدمَجة مع التحكّم بالوصول في Windows (PipeSecurity)، وتُكتَب بسهولة من .NET عبر System.IO.Pipes. إن كان من المحتمل تجاوز حدود الشبكة مستقبلاً، فمن البداية اختر TCP المحليّ أو gRPC، وللبيانات كبيرة الحجم وعالية التكرار (كإطارات الصور والموجات) اختر الذاكرة المشتركة فقط، ولتكامل غير محكم غير متزامن مع رغبة في الاحتفاظ بسجلّ تدقيق اختر تكامل الملفّات ── هذا هو الاختيار الصحيح بأسلوب الاستبعاد التدريجيّ.
متى ينبغي استخدام gRPC في الاتّصال بين العمليّات؟
عندما تزداد أنواع الخدمات أو الاستدعاءات وتصبح صيانة جملة switch الخاصّة ببروتوكولك الذاتيّ وتوثيقها عبئاً. عندئذٍ تحصل على تعريف المخطّط (schema) وتوليد الشيفرة عبر ملفّات proto، وعلى البثّ ثنائيّ الاتّجاه، كما يدعم ASP.NET Core (Kestrel) ابتداءً من .NET 8 الأنابيب المسمّاة مباشرةً كطبقة نقل. في المقابل، بين أدوات لا تتجاوز أوامرها 2-3 أنواع، يكون gRPC مبالغاً فيه بسبب حمل بنية استضافة ASP.NET Core، وتكون الأنابيب المسمّاة + JSON أقلّ تكلفة إجماليّة. لا حرج في الانتقال بعد أن يتجاوز عدد الرسائل التي تريد كتابتها في proto عشرة، أو تصبح إشعارات البثّ ضرورة جوهريّة، أو يكون الطرف الآخر خارج .NET.
كيف أستخدم DLL بنظام 64 بت من تطبيق بنظام 32 بت؟
بما أنّ عمليّة 32 بت لا يمكنها تحميل DLL بنظام 64 بت في العمليّة نفسها، يُفصَل جانب 64 بت إلى عمليّة منفصلة. التنفيذ على طريقتين: (أ) إطلاق عمليّة مساعِدة (helper) بنظام 64 بت والتواصل عبر أنبوب مسمّى، و(ب) جعله خادم COM خارج العمليّة (out-of-process) بنظام 64 بت. إن كان الطرف المستدعي VBA أو لغة قديمة، فالطريقة (ب) أبسط، لأنّ بنية COM تتولّى المارشلينغ (marshalling) نيابةً عنك فتحتاج إلى تعديل ضئيل في شيفرة الطرف المستدعي. أمّا بين تطبيقين مكتوبين بـ C#، فالطريقة (أ) أسهل توزيعاً وتحقيقاً. في حالة (أ) صمِّم إنهاء العمليّة المساعدة المرتبط بالعمليّة الأمّ عبر Job Object.
في أيّ الحالات ينبغي استخدام الذاكرة المشتركة؟
تُستخدَم حصراً للبيانات كبيرة الحجم وعالية التكرار كإطارات الصور أو بيانات الموجات. إنّها أسرع وسيلة اتّصال بين العمليّات داخل الجهاز نفسه لأنّها لا تنطوي على نسخ ولا تسلسل (serialization)، لكنّها لا توفّر أيّ مزامنة على الإطلاق، فيتوجّب عليك تصميم آليّة لعدم قراءة بيانات في منتصف الكتابة، واكتشاف حياة أو وفاة الطرف الآخر، والتعافي بعد الإنهاء غير الطبيعيّ، كلّ ذلك بنفسك. في العمل الفعليّ، البِنية الشائعة هي وضع مستوى البيانات (data plane) فقط في الذاكرة المشتركة، وإرسال مستوى التحكّم (control plane) مثل البدء والإيقاف وتغيير الإعدادات إلى قناة منفصلة كالأنبوب المسمّى. إذا بدأتَ تحاول تمرير رسائل التحكّم أيضاً عبر الذاكرة المشتركة، فتلك علامة على ضرورة إعادة النظر في التصميم.

الملف الشخصي للمؤلف

صفحة الملف الشخصي لمؤلف المقالة.

غو كومورا

مؤسّس شركة كومورا سوفت ذ.م.م.

يركّز على تطوير برامج ويندوز، والاستشارات التقنية، والتحقيق في الأخطاء، ويتميّز في المشاريع التي تبقى فيها الأصول القديمة ناشطة، وفي تشخيص الأعطال التي يصعب تحديد سببها.

روابط عامة

العودة إلى المدونة