سجل التعديلات (6 تحديثات، آخر تحديث 3 Sep، 2026)
سجل بالتغييرات التي أُجريت على هذا المقال. وحيثما حُفظت نسخة سابقة، تبقى متاحة للقراءة عبر رابط دائم يحمل معرّف DOI.
- أُضيفت روابط الاستشارة الموجودة في الأصل الياباني (consultation_services). ولم يتغيّر نصّ المقالة نفسه. قراءة النسخة السابقة لهذا التحديث (DOI: 10.5281/zenodo.22243019)
- أُصلِحت بقايا يابانية في تعليقات إعادة المسح ومبرّر JSON وترخيص Job. قراءة النسخة السابقة لهذا التحديث (DOI: 10.5281/zenodo.22240872)
- أُعيدَت الترجمة العربية كترجمة كاملة عن النص الياباني الأصلي، وأُضيفَت خريطة المعرفة.
- تُرجمت تعليقات التحذير من اختطاف البحث عن الملف التنفيذي عبر CreateProcess وlpApplicationName وPATH إلى العربية.
- عُرِّبت رسائل فشل CreateJobObject و SetInformationJobObject.
- أعيدت الترجمة كترجمة كاملة عن النص الياباني الأصلي. كانت النسخة العربية السابقة مختصراً يسقط أبواباً وجداول ورسوم Mermaid وتعليقات الأشكال وFAQ. أُعيدت هذه العناصر وفق الأصل الياباني، والادّعاءات التقنية مطابقة للنسخة اليابانية.
- النشر الأول
الاستشهاد بهذا المقال(DOI: 10.5281/zenodo.21621435)
هذا المقال محفوظ على Zenodo. يرد أدناه معرّف DOI الذي يشير دائمًا إلى أحدث نسخة، ومعرّف DOI المثبَّت على النسخة التي تقرؤها.
小村 豪 (2026). قائمة تحقّق للتعامل الآمن مع العمليات الابن في تطبيقات Windows. شركة كومورا سوفت ذ.م.م.. https://doi.org/10.5281/zenodo.21621435 https://comcomponent.com/ar/blog/2026/03/20/001-windows-app-safe-child-process-handling-job-object-exit-propagation-stdio-watchdog/
- DOI (أحدث نسخة)
- 10.5281/zenodo.21621435
- DOI (هذه النسخة)
- 10.5281/zenodo.22279866
نزّل قائمة تحقّق Excel بورقتين يابانية وإنكليزية
أدوات التحويل، والمحدّثات، وعمّال التحليل، وCLI خارجي، وPowerShell، وffmpeg، وأدوات مساعدة داخلية. تطبيقات Windows تعتمد على العمليات الابن بسهولة أكبر ممّا يُظنّ.
لكن الحادثة ليست «هل أمكن التشغيل».
- تسقط العملية الأم وتبقى الابن وحدها
- تبقى العمليات الحفيدة وحدها
- ينسدّ
stdout/stderrفلا يعودWaitForExit - يموت الـ watchdog مع الهدف المراقَب
- تظنّ أنّ
Kill(entireProcessTree: true)أنهى الأمر، بينما انتهت المراقبة فقط أوّلاً
سرّ التعامل الآمن مع العمليات الابن في Windows ليس اختيار واجهة التشغيل، بل تقرير مالك شجرة العملية وتصميم إجراء الإنهاء والإدخال/الإخراج.
تنظّم هذه المقالة Job Object وانتشار الإنهاء والإدخال/الإخراج القياسي والـ watchdog تصميماً واحداً.
flowchart TB
accTitle: الحادثة خارج التشغيل
accDescr: مخطّط يبيّن أنّ حوادث العملية الابن ليست هل أمكن التشغيل بل أشكالاً مثل بقاء الابن بعد سقوط العملية الأم أو انسداد stdout، وأنّ السرّ ليس اختيار واجهة التشغيل بل تقرير مالك شجرة العملية وتصميم إجراء الإنهاء والإدخال/الإخراج.
a1["اختيار واجهة التشغيل"] -.-> a2["هذه ليست جسم الحادثة"]
a3["تقرير مالك شجرة العملية"] --> a6["تعامل آمن مع العملية الابن"]
a4["تصميم إجراء الإنهاء"] --> a6
a5["تصميم الإدخال/الإخراج"] --> a6
الشكل 1: أمان العملية الابن يُحسَم بتصميم الملكية والإنهاء والإدخال/الإخراج لا بواجهة التشغيل.
مصطلحات تستخدمها المقالة
نرتّب أوّلاً كلمات تظهر بالإنكليزية كما هي، سطراً لكلّ منها.
| المصطلح | المعنى في سطر واحد |
|---|---|
| process tree | شجرة العملية. العائلة التي تشمل الابن الذي شغّلته العملية الأم، والحفيد الذي شغّله ذلك الابن |
| graceful shutdown | إنهاء تعاوني. أسلوب تطلب فيه «أنهِ من فضلك» فينهي الطرف الآخر بعد تنظيفه بنفسه. ضدّ الإنهاء القسري |
| I/O completion port | آلية إشعار اكتمال إدخال/إخراج غير متزامن في Windows. إن رُبطت بـ Job Object تستقبل إشعارات تشغيل العملية وإنهائها |
| message pump | حلقة الرسائل. آلية يواصل فيها خيط يملك نافذة استخراج رسائل نظام التشغيل ومعالجتها. إن توقّفت تجمّدت الشاشة |
| heartbeat | إشارة يصدرها العملية الابن دوريّاً لتأكيد البقاء. تُستخدَم لكشف حالة «حيّ لكنّه لا يتقدّم» |
| restart budget | ميزانية إعادة التشغيل. حدّ أعلى لعدد إعادة التشغيل ضمن زمن معيّن. تُحمَل لإيقاف crash loop |
| drain | الصرف. قراءة مخرجات تراكمت في الأنبوب حتى النهاية حتى لا ينسدّ طرف الكاتب |
الصورة الكلّية
أوّلاً علاقات الشخصيّات في رسم واحد.
flowchart TB
W["watchdog<br/>يُوضَع خارج الـ Job"]
subgraph JOB["Job Object مع JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE"]
P["التطبيق الأم / جسم العامل<br/>المالك النهائي لـ job handle"]
C["الابن helper.exe"]
G1["الحفيد converter.exe"]
G2["الحفيد ffmpeg.exe"]
P --> C
C --> G1
C --> G2
end
W -.->|"يكشف الإنهاء بـ exit handle"| P
W -.->|"يكشف التعليق بـ heartbeat"| P
W -.->|"يعيد البناء ضمن restart budget"| JOB
الشكل 2: الصورة الكلّية. حدود الـ Job حدود شجرة العملية، والـ watchdog وحده في الخارج.
موضع النظر اثنان.
- حدود الـ Job حدود شجرة العملية. تُجمَع بالانتماء إلى الـ Job لا بحياة العملية الأم، فلا يحدث تسرّب استعادة حتى إن زاد الأحفاد
- الـ watchdog وحده خارج الـ Job. إن أدخلته في الداخل يُنظَّف مع الهدف المراقَب
1. الخلاصة أوّلاً
أوّلاً نرتّب الأكثر أثراً عمليّاً فقط.
- إن أردت ربط حياة العملية الأم بعمر شجرة العمليات الابن، فنقطة الارتكاز Job Object
- طلب الإنهاء لوحدة التحكّم واستعادة شجرة العملية أمران مختلفان
- الأوّل مجموعة عملية و
GenerateConsoleCtrlEvent - الثاني Job Object
- الأوّل مجموعة عملية و
- إن أردت الإدخال في الـ Job من لحظة التشغيل، فتصميم يستخدم
STARTUPINFOEXوPROC_THREAD_ATTRIBUTE_JOB_LISTأصدق - الأساس صرف الإخراج القياسي / الخطأ القياسي بالتوازي
- إن استخدمت
stdin، فصمّم حتى الإغلاق بعد الكتابة لإبلاغ EOF - وضع الـ watchdog خارج Job الهدف المراقَب أأمن
Kill(entireProcessTree: true)في.NETمريح بوصفه واجهة إيقاف صريح، لكنّه ليس بديلاً لتصميم يشمل الاستعادة التلقائية عند انهيار العملية الأم وgraceful shutdown
في المخطّط، يشير الخطّ المتّصل إلى علاقة قائمة دائماً، ويشير الخطّ المتقطّع إلى علاقة مشروطة (شروط قيامها مذكورة في شرح كلّ علاقة في الصفحة التفصيليّة). القائمة الكاملة للعلاقات (المجموع 17، مع الأدلّة ودرجة اليقين) وتعريفات المفاهيم الرئيسة مجمّعة في صفحة تفاصيل خريطة المعرفة (باليابانية). البيانات: JSON-LD / Turtle
2. ما الخطر؟
تنفيذ تشغيل العملية الابن يُكتَب أوّلاً في نحو 10 أسطر عادة. لكن الحادثة خارج تلك الأسطر العشرة.
- بعد سقوط العملية الأم تبقى الابن أو الحفيد
- يشغّل المساعد مساعداً آخر، فتكتفي بانتظار الابن المباشر
- ينسدّ أحد طرفي
stdout/stderrفينتظر الطرفان بعضهما - تنتظر على خيط الواجهة فتجمد الشاشة وCOM
- يصير الـ watchdog جماعة مصير مع الهدف المراقَب فيسقط معه عند الشذوذ
المهمّ هنا أنّ «إدارة العملية الابن» ليست حديث واجهة واحدة.
على الأقلّ هذه الأربع إن فُصلت يسهل النظر.
- من يملك شجرة العملية
- كيف تطلب الإنهاء التعاوني
- كيف تُمرَّر الإدخال/الإخراج القياسي
- كيف تراقب الإنهاء غير الطبيعي والتعليق
flowchart TB
accTitle: أربعة أسئلة تُفصَل
accDescr: مخطّط يبيّن أنّ إدارة العملية الابن ليست حديث واجهة واحدة، وأنّ فصل أربعة: من يملك شجرة العملية، وكيف تطلب الإنهاء التعاوني، وكيف تُمرَّر الإدخال/الإخراج القياسي، وكيف تراقب الإنهاء غير الطبيعي والتعليق، يسهّل النظر.
b1["1. مالك شجرة العملية"] --> b2["2. طريقة طلب الإنهاء التعاوني"]
b2 --> b3["3. تمرير الإدخال/الإخراج القياسي"]
b3 --> b4["4. مراقبة الإنهاء غير الطبيعي والتعليق"]
b1 -.-> b5["ليست حديث واجهة واحدة"]
الشكل 3: إدارة العملية الابن تُصمَّم بتفكيك هذه الأسئلة الأربعة.
3. لا تخلط أدوار الآليات
process handle / process group / Job Object تبدو متشابهة وأدوارها مختلفة.
| الآلية | الدور الرئيسي | المشهد المناسب | ما لا يكفي وحده |
|---|---|---|---|
| process handle | انتظار إنهاء عملية واحدة، أخذ exit code | انتظار اكتمال أداة لمرّة | استعادة العمليات الحفيدة |
| process group | نشر Ctrl+Break إلى وحدة التحكّم | إنهاء تعاوني لابن وحدة التحكّم | التنظيف عند انهيار العملية الأم، العملية الابن ذات الواجهة |
| Job Object | جمع شجرة العملية، التقييد، الإنهاء جملة | شجرة عامل، محدّث، سلسلة مساعدين | «احفظ ثمّ أغلق» الخاصّ بالتطبيق |
مجموعة العملية آلية تقرّر إلى أين يُرسَل إشارة وحدة التحكّم، وليست آلية لتنظيف الشجرة كلّها إن ماتت العملية الأم. أما Job Object فآلية جهة Windows تدير مجموعة عمليات وحدة واحدة.
3.1 جدول مقابل حسب اللغة
تختلط في هذه المقالة حديث Win32 و.NET. نرتّب المقابل أوّلاً لتلتقط عمود لغتك فقط.
| المراد | Win32 / C++ | .NET / C# |
|---|---|---|
| تشغيل عملية | CreateProcessW |
Process.Start |
| إنشاء Job وإضافة قيد | CreateJobObjectW + SetInformationJobObject |
استدعاء الواجهة نفسها بـ P/Invoke. لا غلاف Job Object في المكتبة القياسية |
| الإدخال في الـ Job من لحظة التشغيل | STARTUPINFOEX + PROC_THREAD_ATTRIBUTE_JOB_LIST |
نفسه. لا يمكن التحديد من ProcessStartInfo |
| الإدخال في الـ Job لاحقاً | AssignProcessToJobObject |
P/Invoke للواجهة نفسها وتمرير Process.Handle |
| انتظار الإنهاء | WaitForSingleObject |
Process.WaitForExit، وغير المتزامن WaitForExitAsync (من .NET 5 فصاعداً) |
| أخذ exit code | GetExitCodeProcess |
Process.ExitCode |
| قراءة stdout / stderr | إنشاء أنبوب مجهول والقراءة في خيط آخر | RedirectStandardOutput وBeginOutputReadLine |
| طلب إغلاق ابن واجهة | إرسال WM_CLOSE |
Process.CloseMainWindow |
| إرسال Ctrl+Break لابن وحدة التحكّم | CREATE_NEW_PROCESS_GROUP + GenerateConsoleCtrlEvent |
لا واجهة مقابلة، لذا P/Invoke |
| إنهاء قسري للشجرة كلّها | TerminateJobObject، أو إغلاق آخر job handle |
Process.Kill(entireProcessTree: true) (من .NET Core 3.0 فصاعداً)، أو P/Invoke نفسه أعلاه |
| انتظار إنهاء أبناء كثيرين | RegisterWaitForSingleObject / SetThreadpoolWait |
حدث Process.Exited، أو WaitForExitAsync |
ما يظهر هنا أنّ ما حول Job Object يستدعي واجهة Win32 كما هي حتى في .NET. ما يوفّره .NET يصل إلى عمليات وحدة عملية واحدة فقط.
flowchart TB
accTitle: نطاق دفاع .NET وحدود الـ Job
accDescr: مخطّط يبيّن أنّ ما توفّره المكتبة القياسية في .NET يصل إلى عمليات وحدة عملية واحدة مثل التشغيل وانتظار الإنهاء، وأنّ عمليات ما حول Job Object بلا غلاف فتُستدعى واجهة Win32 كما هي بـ P/Invoke.
c1["عمليات وحدة عملية واحدة"] --> c2["Process القياسي في .NET يكفي"]
c3["عمليات ما حول Job Object"] --> c4["لا غلاف"]
c4 --> c5["استدعاء واجهة Win32 بـ P/Invoke"]
الشكل 4: حتى في .NET، جزء جمع شجرة العملية يستدعي واجهة Win32 مباشرة.
4. اجعل Job Object نقطة الارتكاز
أقوى نقطة في Job Object أنّها تستطيع جمع process tree بـ «إلى أيّ Job ينتمي» لا «ابن من». العملية التي دخلت الـ Job، الابن الذي تصنعه بـ CreateProcess يدخل ذلك الـ Job افتراضيّاً.
وإضافة، بإضافة JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE تنتهي كلّ العمليات المرتبطة بالـ Job عند إغلاق آخر job handle.
flowchart TB
accTitle: الجمع بـ Job يُزيل تسرّب الاستعادة
accDescr: مخطّط يبيّن أنّ Job Object يجمع شجرة العملية بالانتماء إلى الـ Job لا بابن من، وأنّ الابن الذي تصنعه عملية دخلت الـ Job يدخل الـ Job نفسه افتراضيّاً، وأنّ إضافة KILL_ON_JOB_CLOSE تنهي كلّ العمليات عند إغلاق آخر job handle.
d1["التتبّع بـ «ابن من»"] -.-> d2["يتسرّب إن زاد الأحفاد"]
d3["الجمع بـ «إلى أيّ Job ينتمي»"] --> d4["الابن الذي يصنعه الابن إلى الـ Job نفسه"]
d4 --> d5["KILL_ON_JOB_CLOSE"]
d5 --> d6["إغلاق آخر handle ينهي الكلّ"]
الشكل 5: الجمع بانتماء الـ Job لا بعلاقة الأم والابن يتيح الاستعادة حتى الأحفاد.
4.1 أربع نقاط تُمسَك أوّلاً
1. إن أردت تنظيف الشجرة كلّها عند إنهاء العملية الأم فـ KILL_ON_JOB_CLOSE
هذا أساس معاملة المساعد / العامل في تطبيقات Windows. تصميم يستدعي TerminateJobObject صراحة جائز أيضاً، لكن إن أردت تقريب التنظيف حتى الإنهاء غير الطبيعي للعملية الأم من عمر العملية الأم فـ KILL_ON_JOB_CLOSE أوضح.
2. لا تُضِف BREAKAWAY بخفّة
JOB_OBJECT_LIMIT_BREAKAWAY_OK وJOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK تبدوان مريحتين، لكنّهما قد تكونان سبباً في خروج جزء من الشجرة التي نويت تنظيفها. ما دام لا قصد، عدم إضافة breakaway يخفض معدّل الحوادث.
3. إن أردت الإدخال في الـ Job من لحظة التشغيل فـ PROC_THREAD_ATTRIBUTE_JOB_LIST
الربط لاحقاً بـ AssignProcessToJobObject ممكن أيضاً.
لكن في مشهد تريد افتراض الانتماء إلى الـ Job فور التشغيل، تحديد الـ Job عند الإنشاء بـ STARTUPINFOEX وPROC_THREAD_ATTRIBUTE_JOB_LIST أقوم.
4. لا تُبقِ مالك job handle غامضاً
KILL_ON_JOB_CLOSE يؤثّر عند إغلاق آخر handle.
أي بالمقابل، إن نسخت job handle إلى عملية أخرى أو ورّثته دون قصد، لا يُنظَّف كما يُفترَض حتى إن ماتت العملية الأم. من المالك النهائي لـ job handle يجب تقريره أوّلاً.
flowchart TB
accTitle: لا تُبقِ مالك job handle غامضاً
accDescr: مخطّط يبيّن أنّ KILL_ON_JOB_CLOSE يؤثّر عند إغلاق آخر handle، لذا إن نسخت job handle إلى عملية أخرى أو ورّثته دون قصد لا يُنظَّف كما يُفترَض حتى إن ماتت العملية الأم، ويجب تقرير المالك النهائي أوّلاً.
e1["نسخ job handle أو توريثه"] --> e2["لا يُغلَق آخر handle"]
e2 --> e3["لا يُنظَّف حتى إن ماتت العملية الأم"]
e3 -.->|"لذلك"| e4["قرّر المالك النهائي أوّلاً"]
الشكل 6: KILL_ON_JOB_CLOSE لا يؤثّر إلّا بعد إغلاق «آخر handle».
4.2 Job Object يصلح أيضاً للرصد، لكن الإشعار ليس كلّياً
لدى Job Object آلية ربط منفذ اكتمال إدخال/إخراج واستقبال إشعار. لكن أأمن ألّا تُعدّ إشعارات منفذ الاكتمال إشعاراً مضمون الضمان في كلّ الحالات.
لذا منفذ الاكتمال مريح لـ
- المراقبة
- التجميع
- السجلّ
- المقاييس
لكن الأفضل ألّا تُبنى correctness عليه وحده.
flowchart TB
accTitle: موضع استخدام إشعار منفذ الاكتمال
accDescr: مخطّط يبيّن أنّ لدى Job Object آلية ربط منفذ اكتمال إدخال/إخراج واستقبال إشعار، لكن لا تُعدّ إشعاراً مضمون الضمان في كلّ الحالات، فتُستخدَم للمراقبة والتجميع والسجلّ والمقاييس ولا تُبنى correctness عليها وحدها.
f1["إشعار منفذ اكتمال الـ Job"] --> f2["مريح للمراقبة والتجميع والسجلّ"]
f1 -.-> f3["لا تُعدّ إشعاراً مضمون الضمان"]
f3 -.-> f4["لا تُبنى correctness عليها وحدها"]
الشكل 7: استخدم الإشعار للرصد، لا حجّة للصحة.
4.3 نظرة في شيفرة دنيا
أقصر من الكلام، نضع الشكل الأدنى باللغتين.
جهة C++ ثلاث حركات: إنشاء Job → إضافة KILL_ON_JOB_CLOSE → تحديد الـ Job عند التشغيل.
// Windows 10 以降 / C++17。helper.exe を Job に入れて起動し、親の終了で木ごと片づける
#include <windows.h>
#include <memory>
#include <string>
int wmain()
{
// 0. ثبّت الملف الذي تشغّله بمسار مطلق.
// إن جعلت lpApplicationName يساوي nullptr وتركت البحث من أوّل سطر الأوامر,
// يدخل الدليل الحالي للعملية الأم وPATH في أهداف البحث.
// إن غاب helper.exe عن مجلدك ووُضع ملف تنفيذي بنفس الاسم في موضع قابل للكتابة,
// يعمل ذلك الملف بصلاحيات العملية الأم
wchar_t modulePath[MAX_PATH]{};
DWORD moduleLen = GetModuleFileNameW(nullptr, modulePath, MAX_PATH);
if (moduleLen == 0 || moduleLen >= MAX_PATH) // MAX_PATH で切り詰められた場合も失敗扱い
{
return 1;
}
std::wstring application(modulePath, moduleLen);
application.resize(application.find_last_of(L'\\') + 1); // 自分の実行ファイルがあるフォルダー
application += L"helper.exe";
// 1. Job を作り、最後の handle が閉じたら中身を全部終了させる
HANDLE job = CreateJobObjectW(nullptr, nullptr);
if (job == nullptr)
{
return 1;
}
JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits{};
limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
if (!SetInformationJobObject(job, JobObjectExtendedLimitInformation, &limits, sizeof(limits)))
{
CloseHandle(job);
return 1;
}
// 2. 起動時点から Job 所属にするための属性リストを作る
SIZE_T attributeSize = 0;
InitializeProcThreadAttributeList(nullptr, 1, 0, &attributeSize); // 必要サイズを取るための空振り
auto storage = std::make_unique<BYTE[]>(attributeSize);
auto attributes = reinterpret_cast<LPPROC_THREAD_ATTRIBUTE_LIST>(storage.get());
if (!InitializeProcThreadAttributeList(attributes, 1, 0, &attributeSize))
{
CloseHandle(job);
return 1;
}
// job の値は DeleteProcThreadAttributeList を呼ぶまで生かしておく必要がある
if (!UpdateProcThreadAttribute(attributes, 0, PROC_THREAD_ATTRIBUTE_JOB_LIST,
&job, sizeof(job), nullptr, nullptr))
{
DeleteProcThreadAttributeList(attributes);
CloseHandle(job);
return 1;
}
// 3. 起動する
STARTUPINFOEXW startup{};
startup.StartupInfo.cb = sizeof(startup);
startup.lpAttributeList = attributes;
PROCESS_INFORMATION info{};
// CreateProcessW は書き換え可能なバッファを要求する。
// argv[0] にも同じパスを置く。空白を含むので必ず引用符で囲む
std::wstring commandLine = L"\"" + application + L"\" --input data.bin";
BOOL created = CreateProcessW(
application.c_str(), commandLine.data(), nullptr, nullptr,
FALSE, // 継承させる handle は絞る
EXTENDED_STARTUPINFO_PRESENT,
nullptr, nullptr,
&startup.StartupInfo, &info);
DeleteProcThreadAttributeList(attributes);
if (!created)
{
CloseHandle(job);
return 1;
}
WaitForSingleObject(info.hProcess, INFINITE);
DWORD exitCode = 0;
GetExitCodeProcess(info.hProcess, &exitCode);
CloseHandle(info.hThread);
CloseHandle(info.hProcess);
CloseHandle(job); // 最後の job handle。ここで残っている子孫はまとめて終了する
return static_cast<int>(exitCode);
}
هذه الشيفرة تطأ قيدين مكتوبين في وثائق UpdateProcThreadAttribute. كلاهما سهل التفويت.
PROC_THREAD_ATTRIBUTE_JOB_LISTيعمل على Windows 10 / Windows Server 2016 فصاعداً. إن استهدفت أقدم، يلزم النزول إلىAssignProcessToJobObject- القيمة الممرَّرة إلى
UpdateProcThreadAttributeيجب أن تبقى حيّة حتى استدعاءDeleteProcThreadAttributeList. كتابة تمرّر متغيّراً محلّياً وتخرج من النطاق فوراً تنكسر
الملفّ الذي تشغّله أشر إليه حتماً بمسار مطلق
تجميع المسار من GetModuleFileNameW في «0.» في المقدّمة ليس ذوق كتابة بل لتثبيت أيّ ملفّ تنفيذي يعمل.
إن مرّرت nullptr إلى lpApplicationName، تصير الكلمة الأولى في سطر الأوامر اسم الوحدة. إن لم يتضمّن مساراً، يبحث Windows بهذا الترتيب.
- الدليل الذي حُمِّل منه التطبيق
- الدليل الحالي للعملية الأم
- دليل النظام 32bit
- دليل النظام 16bit
- دليل Windows
- الأدلة المتتابعة في متغيّر البيئة
PATH
المشكلة 2 و6. حين لا يوجد helper.exe في 1 ── نقص وضع، بناء بتركيب آخر، بقايا إلغاء تثبيت ── يتقدّم البحث إلى 2. إن كان الدليل الحالي موضعاً قابلاً للكتابة (تشغيل من مجلد تنزيلات المستخدم كما هو، أو جعل مجلد مشترك دليل عمل)، يعمل helper.exe الموضوع هناك بصلاحيات العملية الأم نفسها. إن أمكن تعديل PATH فـ 6 الأمر نفسه.
وثائق Microsoft أيضاً تفرد لهذا «ملاحظة أمنية» قسماً مستقلّاً، وتصرّح: «لتجنّب هذه المشكلة، لا تمرّر NULL إلى lpApplicationName». المثال الشهير أنّ عدم إحاطة مسار يحوي فراغاً بعلامات اقتباس قد يشغّل C:\Program.exe في القسم نفسه. لذلك نحيط جهة سطر الأوامر أيضاً بـ "...".
ProcessStartInfo في C# كذلك. عند UseShellExecute = false، يجمع .NET FileName والوسائط في سطر أوامر واحد ويمرّر null إلى lpApplicationName، لذا تمرير اسم الملفّ فقط يُجري البحث أعلاه كما هو. مرّر مساراً مطلقاً مجمَّعاً من AppContext.BaseDirectory.
حتى في بيئة يبدو فيها «خطأ الوضع هذا لا يحدث»، تكلفة الكتابة تكاد تكون صفراً. في شيفرة تشغّل عملية ابن، لا يوجد أساساً سبب لكتابة الملفّ التنفيذي اسماً نسبيّاً.
flowchart TB
accTitle: حادثة يجرّها بحث التشغيل بالاسم النسبي
accDescr: مخطّط يبيّن أنّ تمرير NULL إلى lpApplicationName والتشغيل باسم الملفّ فقط يُدخل الدليل الحالي للعملية الأم وPATH في أهداف البحث، فإن غاب helper.exe الأصلي يعمل ملفّ تنفيذي بنفس الاسم موضوع في موضع قابل للكتابة بصلاحيات العملية الأم، لذا أشر حتماً بمسار مطلق.
g1["التشغيل باسم الملفّ فقط"] --> g2["يدخل الدليل الحالي وPATH في البحث"]
g2 --> g3["حين لا يوجد helper.exe في الموضع الأصلي"]
g3 --> g4["يعمل EXE بنفس الاسم الموضوع بصلاحيات العملية الأم"]
g4 -.->|"للمنع"| g5["أشر بمسار مطلق وأحِط بعلامات اقتباس"]
الشكل 8: الملفّ الذي تشغّله ثبّته بمسار مطلق دون إيكال البحث.
لا غلاف Job Object في .NET، لذا يصير P/Invoke. تعريف البنى يبدو طويلاً، لكن ما يُستدعى فعلاً دالّتان فقط.
// .NET 8 / C# 12。Job を作って KILL_ON_JOB_CLOSE を付け、起動済みプロセスを入れる
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using Microsoft.Win32.SafeHandles;
internal static class KillOnCloseJob
{
private const int JobObjectExtendedLimitInformation = 9;
private const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000;
[StructLayout(LayoutKind.Sequential)]
private struct JOBOBJECT_BASIC_LIMIT_INFORMATION
{
public long PerProcessUserTimeLimit;
public long PerJobUserTimeLimit;
public uint LimitFlags;
public nuint MinimumWorkingSetSize;
public nuint MaximumWorkingSetSize;
public uint ActiveProcessLimit;
public nuint Affinity;
public uint PriorityClass;
public uint SchedulingClass;
}
[StructLayout(LayoutKind.Sequential)]
private struct IO_COUNTERS
{
public ulong ReadOperationCount;
public ulong WriteOperationCount;
public ulong OtherOperationCount;
public ulong ReadTransferCount;
public ulong WriteTransferCount;
public ulong OtherTransferCount;
}
[StructLayout(LayoutKind.Sequential)]
private struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION
{
public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation;
public IO_COUNTERS IoInfo;
public nuint ProcessMemoryLimit;
public nuint JobMemoryLimit;
public nuint PeakProcessMemoryUsed;
public nuint PeakJobMemoryUsed;
}
[DllImport("kernel32.dll", SetLastError = true)]
private static extern SafeJobHandle CreateJobObjectW(IntPtr attributes, IntPtr name);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SetInformationJobObject(
SafeJobHandle job, int infoClass, ref JOBOBJECT_EXTENDED_LIMIT_INFORMATION info, uint infoSize);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool AssignProcessToJobObject(SafeJobHandle job, IntPtr process);
/// <summary>Job を作る。返した handle はアプリの寿命の間ずっと開いたままにする。</summary>
public static SafeJobHandle Create()
{
var job = CreateJobObjectW(IntPtr.Zero, IntPtr.Zero);
if (job.IsInvalid)
{
throw new InvalidOperationException($"فشل CreateJobObject. code={Marshal.GetLastWin32Error()}");
}
var info = default(JOBOBJECT_EXTENDED_LIMIT_INFORMATION);
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
var size = (uint)Marshal.SizeOf<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>();
if (!SetInformationJobObject(job, JobObjectExtendedLimitInformation, ref info, size))
{
// 作れたが設定できなかった、という中途半端な Job をそのまま捨てない。
// 呼び出し側が初期化エラーを捕まえてリトライする作りだと、
// 試行のたびにカーネルハンドルが1個ずつ漏れる(上の C++ 版は
// この経路で CloseHandle している)
var error = Marshal.GetLastWin32Error();
job.Dispose();
throw new InvalidOperationException($"فشل SetInformationJobObject. code={error}");
}
return job;
}
public static void Add(SafeJobHandle job, Process process)
{
if (!AssignProcessToJobObject(job, process.Handle))
{
throw new InvalidOperationException($"فشل AssignProcessToJobObject. code={Marshal.GetLastWin32Error()}");
}
}
}
// 生の IntPtr で持つと、初期化に失敗した経路で誰も閉じられない。
// SafeHandle にしておけば、失敗経路は Dispose を1回呼ぶだけで済む
internal sealed class SafeJobHandle : SafeHandleZeroOrMinusOneIsInvalid
{
// P/Invoke の戻り値としてマーシャラーが生成するので、引数なしで作れる必要がある
private SafeJobHandle() : base(ownsHandle: true) { }
protected override bool ReleaseHandle() => CloseHandle(handle);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CloseHandle(IntPtr handle);
}
جهة الاستدعاء كالتالي. استدعاء Create فقط ونسيان Add يصير الحالة الأصعب ملاحظة: الـ Job موجود والابن غير داخل.
// job handle はフィールドなどに持ち、アプリが終わるまで閉じない。
// KILL_ON_JOB_CLOSE 付きなので、閉じた瞬間に Job の中の子が全部終わる。
// ここで using を付けてはいけない(スコープを抜けた時点で子が死ぬ)
SafeJobHandle job = KillOnCloseJob.Create();
try
{
// 起動するファイルは絶対パスで渡す。ファイル名だけを渡すと、
// CreateProcess の探索対象にカレントディレクトリと PATH が入る
string helperPath = Path.Combine(AppContext.BaseDirectory, "helper.exe");
var startInfo = new ProcessStartInfo(helperPath, "--input data.bin")
{
UseShellExecute = false,
CreateNoWindow = true,
};
using var child = Process.Start(startInfo)
?? throw new InvalidOperationException("تعذر تشغيل helper.exe.");
try
{
KillOnCloseJob.Add(job, child); // إن نسيت هذا يبقى الـ Job فارغاً
}
catch (Exception assignFailed)
{
// يفشل Add مثلاً إذا كانت قيود Job غير متوافقة على الأب.
// عندها helper.exe يعمل أصلاً. Dispose في using يتخلّص من غلاف Process
// فقط، ولا تنتهي عملية نظام التشغيل. الـ Job فارغ لذلك
// job.Dispose() لا ينظّفه. أوقف العملية هنا وانتظر حتى تنتهي
try
{
if (!child.HasExited)
{
child.Kill(entireProcessTree: true);
}
// Kill يطلب الإنهاء ويعود فوراً. إن رميت دون انتظار،
// قد يعمل helper ثانٍ أُعيدت تهيئته بالتوازي
child.WaitForExit();
}
catch (Exception killFailed)
{
// تعذّر الإيقاف أثقل من فشل Add. إن ابتُلع الاستثناء
// تتابع وفي النظام ابن ليس في الـ Job ولم يتوقف
throw new AggregateException(
"فشل التعيين إلى الـ Job وتعذّر إيقاف helper.exe أيضاً.",
assignFailed, killFailed);
}
throw;
}
}
catch
{
// إن فشل التشغيل أو الإدخال في الـ Job، لا تعد تستخدم هذا الـ Job.
// الخروج دون إغلاق يترك مقبض نواة في كل إعادة تهيئة.
// في هذه اللحظة الـ Job فارغ (أو أُوقف الابن في الـ catch أعلاه)،
// فلا شيء يتوقف بشكل مؤذٍ عند الإغلاق
job.Dispose();
throw;
}
لكن نسخة .NET هذه فيها فجوة من التشغيل حتى الإدخال في الـ Job. إن صنع الابن حفيداً في تلك الأثناء، يولد الحفيد خارج الـ Job. سبب استخدام نسخة C++ لـ PROC_THREAD_ATTRIBUTE_JOB_LIST هو سدّ هذه الفجوة تحديداً. إن تعاملت مع مساعد يصنع أحفاداً، يستحقّ في .NET أيضاً التوغّل حتى P/Invoke يستخدم STARTUPINFOEX.
flowchart TB
accTitle: الفجوة من التشغيل حتى Assign
accDescr: مخطّط يبيّن أنّه إن صنع الابن حفيداً بين التشغيل والإدخال بـ AssignProcessToJobObject يولد الحفيد خارج الـ Job، لذا إن تعاملت مع مساعد يصنع أحفاداً يستحقّ سدّ الفجوة بتحديد الـ Job عند الإنشاء بـ PROC_THREAD_ATTRIBUTE_JOB_LIST.
h1["التشغيل ثمّ الإدخال في الـ Job لاحقاً"] --> h2["فجوة بين التشغيل وAssign"]
h2 --> h3["الحفيد المولود في الأثناء خارج الـ Job"]
h3 -.->|"لسدّ الفجوة"| h4["التشغيل مع تحديد الـ Job عند الإنشاء"]
الشكل 9: أسلوب الإدخال في الـ Job لاحقاً فيه فجوة يتسلّل منها الحفيد.
5. صمّم انتشار الإنهاء ببروتوكول وtimeout
إنهاء العملية الابن ليس حديثاً ينتهي بواجهة kill واحدة. الأقل عرضة للحوادث شكل يطأ هذه المراحل الثلاث.
- اطلب إنهاء تعاونياً
- انتظر بـ timeout قصير
- أخيراً أنهِ الـ Job قسراً كلّه
بهذا الترتيب تُحفَظ مسار الإنهاء السليم، وتُستعاد عند التعليق.
flowchart TB
accTitle: إجراء الإنهاء بثلاث مراحل
accDescr: مخطّط يبيّن أنّ إنهاء العملية الابن ليس حديثاً ينتهي بواجهة kill واحدة، وأنّ وطء ثلاث مراحل: طلب إنهاء تعاوني، انتظار بـ timeout قصير، ثمّ إنهاء قسري للـ Job كلّه، يحفظ مسار الإنهاء السليم ويستعيد عند التعليق.
i1["1. اطلب إنهاء تعاونياً"] --> i2["2. انتظر بـ timeout قصير"]
i2 --> i3["3. أخيراً أنهِ الـ Job قسراً كلّه"]
i2 -.-> i4["احفظ المسار السليم واستعد عند التعليق"]
الشكل 10: صمّم الإنهاء بثلاث مراحل: طلب، انتظار، قسر.
5.1 ابن واجهة رسومية
إن كانت العملية الابن تملك واجهة، يصير CloseMainWindow في .NET إرسال رسالة إغلاق.
لكن هذا طلب إنهاء لا إنهاء قسري. لذا أصدق تدفّق:
CloseMainWindow- انتظر زمناً معيّناً
- إن لم ينفع فـ kill للـ Job كلّه
5.2 ابن وحدة التحكّم
ابن وحدة التحكّم لا يستخدم رسالة إغلاق الواجهة. هنا تُستخدَم مجموعة العملية وإشارة وحدة التحكّم.
التدفّق: التشغيل بـ CREATE_NEW_PROCESS_GROUP، وإرسال CTRL_BREAK_EVENT بـ GenerateConsoleCtrlEvent.
المهمّ هنا:
CTRL_C_EVENTلا يناسب التقييد بمجموعة معيّنة- من يستطيع استقبال الإشارة العمليات التي تشارك وحدة التحكّم فقط
- استخدام
CREATE_NEW_PROCESS_GROUPيغيّر أيضاً معنىCTRL+C
5.3 عامل / ابن بلا رأس
العامل أو الابن بلا رأس كثيراً ما ليس واجهة ولا وحدة تحكّم. في هذه الحال أأمن حمل بروتوكول إنهاء مخصَّص للعملية الابن.
- أرسل
quitإلىstdin - أرسل أمر إيقاف عبر named pipe / socket / RPC
- بلّغ طلب التوقّف بكائن حدث
بأسلوب Windows يتولّى Job Object تنظيف الشجرة، وبأسلوب التطبيق يتولّى الأنبوب أو stdin الإنهاء التعاوني؛ هذا الفصل أقل عرضة للحوادث.
flowchart TB
accTitle: اقسم الإنهاء التعاوني حسب نوع الابن
accDescr: مخطّط يبيّن تقسيم وسيلة طلب الإنهاء التعاوني حسب نوع الابن: رسالة إغلاق مثل CloseMainWindow لابن الواجهة، وCREATE_NEW_PROCESS_GROUP مع CTRL_BREAK_EVENT لابن وحدة التحكّم، وبروتوكول إنهاء عبر stdin أو أنبوب للعامل.
j0["طلب إنهاء تعاوني"] --> j1["ابن واجهة: رسالة إغلاق"]
j0 --> j2["ابن وحدة التحكّم: CTRL_BREAK_EVENT"]
j0 --> j3["عامل: بروتوكول إنهاء عبر stdin أو أنبوب"]
j3 -.-> j4["تنظيف الشجرة يتولّاه Job Object"]
الشكل 11: اختر وسيلة الإنهاء التعاوني حسب نوع الابن، وأوكل الاستعادة إلى الـ Job.
6. لا تسدّ الإدخال/الإخراج القياسي
6.1 stdout / stderr صرف متوازٍ
الأساس الأوّل هذا.
اصرف stdout وstderr بالتوازي. قراءة أحدهما كاملاً ثمّ الآخر تنسدّ بسهولة.
أنبوب Windows ليس مخزناً لا نهائياً. إن أخرج الابن stderr بكثرة ولم تقرأ العملية الأم إلّا stdout، يتوقّف الابن عند write والأم عند انتظار الإنهاء؛ شكل شائع.
بالرسم هذا الشكل.
sequenceDiagram
participant P as العملية الأم
participant SO as أنبوب stdout
participant SE as أنبوب stderr
participant C as العملية الابن
P->>SO: تواصل قراءة stdout فقط
C->>SO: تكتب قليلاً
SO-->>P: قُرئ
C->>SE: تكتب تحذيرات كثيرة
Note over SE: يمتلئ مخزن الأنبوب
C->>SE: تحاول الكتابة أكثر
Note over C: لا يعود write. تتوقّف الابن هنا
P->>SO: تحاول قراءة التتمّة
Note over P: الابن متوقّفة فلا يأتي شيء
Note over P,C: الأم تنتظر القراءة والابن تنتظر الكتابة. لا يعود WaitForExit أيضاً
الشكل 12: قراءة stdout فقط تملأ أنبوب stderr فينتظر الطرفان بعضهما.
موضع التوقّف الأنبوب لا العملية الأم ولا الابن، فلا يظهر السبب في سجلّ أيّ منهما. سطر ناقص واحد «لم نقرأ stderr» يصير تعليقاً كما هو.
إن استقبلت stdout وstderr بمعالجين منفصلين وواصلت القراءة مستقلّة، لا تقوم هذه الحلقة. في .NET الشكل التالي.
// .NET 8 / C# 12。stdout と stderr を並列に drain し、出力の読み切りまで待つ
using System;
using System.ComponentModel; // Win32Exception
using System.Diagnostics;
using System.IO;
using System.Text;
// 起動するファイルは絶対パスで渡す(理由は Job Object の節を参照)
string helperPath = Path.Combine(AppContext.BaseDirectory, "helper.exe");
var startInfo = new ProcessStartInfo(helperPath, "--input data.bin")
{
UseShellExecute = false, // リダイレクトを使うなら必須
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
using var process = new Process { StartInfo = startInfo };
var stdout = new StringBuilder();
var stderr = new StringBuilder();
// 片方を読み切ってからもう片方、にしない。両方をイベントで受ける
process.OutputDataReceived += (_, e) =>
{
if (e.Data is not null)
{
stdout.AppendLine(e.Data);
}
};
process.ErrorDataReceived += (_, e) =>
{
if (e.Data is not null)
{
stderr.AppendLine(e.Data);
}
};
process.Start();
process.BeginOutputReadLine(); // 登録しただけでは読み始めない。必ず両方呼ぶ
process.BeginErrorReadLine();
if (!process.WaitForExit(30_000))
{
// ここは「待つのをやめる」判断であって、cleanup の代わりではない
try
{
process.Kill(entireProcessTree: true);
}
catch (Exception ex) when (ex is Win32Exception or InvalidOperationException)
{
// 30 秒の待ちが切れた「直後」に子が自分で終わる、という取り合いがある。
// .NET では終了処理中の Kill が Win32Exception("The process is
// terminating.")、.NET Framework では終了済みの Kill が
// InvalidOperationException になる。
// 既に終わっているならこれは失敗ではないので、飲み込んで下の
// TimeoutException へ進む。まだ生きているなら本当に止められなかった
// ということなので、そのまま投げ直す
if (!process.HasExited)
{
throw;
}
}
// AggregateException(子孫の一部を止められなかった)は握らない。
// それは「木が片付いていない」ことそのものなので、外へ出す
// Kill は終了を要求して即座に返る。ここで待たずに throw すると、
// using の Dispose が走った時点でまだ子が生きていることがあり、
// 「タイムアウト例外が出た=木は片付いた」が成り立たない
process.WaitForExit();
throw new TimeoutException("لم ينتهِ helper.exe خلال 30 ثانية.");
}
// timeout 付きの WaitForExit が true を返しても、非同期の出力処理はまだ終わっていないことがある。
// 引数なしの WaitForExit をもう一度呼び、出力の読み切りまで待つ。
process.WaitForExit();
Console.WriteLine($"exit code : {process.ExitCode}");
Console.WriteLine($"stdout : {stdout.Length} 文字");
Console.WriteLine($"stderr : {stderr.Length} 文字");
عند حدود الـ timeout يوجد دائماً سباق. بين عودة WaitForExit(30_000) بـ false واستدعاء Kill بفاصل ضئيل، قد تنهي الابن بنفسها. عندها لا ينجح Kill ── في .NET Win32Exception أثناء الإنهاء («The process is terminating.»)، وفي .NET Framework InvalidOperationException لعملية منتهية. إن مرّرت هذا كما هو، يطير فشل التنظيف بدل TimeoutException الذي كان يجب رميه. تتلقّى جهة الاستدعاء «خطأ غير مفهوم» لا «انتهت المهلة»، وتُتخطّى أيضاً قراءة المخرجات حتى النهاية بـ WaitForExit() الأخيرة. كما أعلاه، ابلع بعد التحقّق بـ HasExited أنّه «انتهى فعلاً». إن كان ما زال حيّاً فلم يُوقَف، فأعد الرمي كما هو. ولا تبلع AggregateException التي يرميها Kill(entireProcessTree: true) (تعذّر إيقاف بعض الأحفاد). ذلك هو «الشجرة لم تُنظَّف» نفسه، الحالة التي يحاول هذا القسم منعها.
flowchart TB
accTitle: سباق حدود الـ timeout
accDescr: مخطّط يبيّن أنّ الابن قد تنهي بنفسها في الفاصل الضئيل بين عودة WaitForExit بـ false واستدعاء Kill، فإن بقي كما هو يغطّي فشل التنظيف TimeoutException الأصلي، لذا ابلع بعد التحقّق بـ HasExited أنّه انتهى فعلاً، وأعد الرمي إن كان ما زال حيّاً.
k1["تنهي الابن بنفسها فور انتهاء الانتظار"] --> k2["يفشل Kill"]
k2 --> k3{"تحقّق بـ HasExited"}
k3 -->|"انتهت"| k4["ليس فشلاً فابلعه"]
k3 -->|"ما زالت حيّة"| k5["لم تُوقَف فأعد الرمي"]
k4 --> k6["ارمِ TimeoutException الأصلي"]
الشكل 13: في سباق الحدود ميّز بـ HasExited هل فشل Kill فشل حقيقي.
WaitForExit() الأخيرة ليست نسياناً بل إلزام. وثائق WaitForExit(int) تكتب أنّ عند إعادة توجيه الإخراج القياسي إلى معالج حدث غير متزامن، قد لا تكون معالجة الإخراج قد اكتملت عند عودة هذا التحميل الزائد، وترشد إلى استدعاء WaitForExit() بلا وسيط بعد تلقّي true. إن أسقطت هذا، ينكسر بشكل صعب التكرار: نقص ذيل الإخراج فقط.
6.2 إن استخدمت stdin فصمّم حتى EOF
إمكان الكتابة إلى stdin وإمكان إنهاء الابن ليسا الشيء نفسه.
- تكتب الإدخال ثمّ لا تغلق
- العملية الأم تظنّ «لقد سلّمت»
- الابن تظنّ «ما زال تتمّة ستأتي» وتواصل الانتظار
حالة كهذه تقع. إن استخدمت stdin، يلزم تصميم يشمل الإغلاق بعد الكتابة لإبلاغ EOF.
flowchart TB
accTitle: صمّم stdin حتى EOF
accDescr: مخطّط يبيّن أنّ الكتابة إلى stdin دون إغلاق تجعل العملية الأم تظنّ أنّها سلّمت والابن تنتظر تتمّة، لذا صمّم حتى الإغلاق بعد الكتابة لإبلاغ EOF.
m1["تكتب الإدخال ثمّ لا تغلق"] --> m2["العملية الأم تظنّ «لقد سلّمت»"]
m1 --> m3["الابن تنتظر «ما زال تتمّة»"]
m2 --> m4["أغلق بعد الكتابة وأبلغ EOF"]
m3 --> m4
الشكل 14: تصميم stdin ليس إمكان الكتابة بل حتى وصول EOF.
6.3 أغلق نهاية الأنبوب غير المستخدمة حتماً
إن لم تُغلَق النهاية غير المستخدمة في جهة العملية الأم أو الابن، لا يصل EOF فينهار شرط الإنهاء. هذا بسيط، لكنّه حادثة شائعة جدّاً في التشغيل.
6.4 لا تُبقِ معاملة UseShellExecute=false وتوريث المقبض غامضة
إن استخدمت إعادة توجيه الإدخال/الإخراج القياسي، في .NET الافتراض UseShellExecute=false.
وفي Win32 أيضاً أأمن تضييق ما تورّثه قدر الإمكان. توريث الكلّ مع بقاء bInheritHandles=TRUE يصير سبب تسرّب مقابض غير متوقَّع.
7. ضع الـ watchdog «في الخارج»
الأهمّ عند إدخال watchdog ألّا تُدخله في الـ Job نفسه مع الهدف المراقَب. تريد إعادة تشغيل العامل إن سقط، فإن مات دور إعادة التشغيل معه أيضاً فلا معنى.
flowchart TB
accTitle: ضع الـ watchdog خارج الهدف المراقَب
accDescr: مخطّط يبيّن أنّ إدخال الـ watchdog في الـ Job نفسه مع الهدف يجعل دور إعادة التشغيل يموت معه عند تنظيف سقوط العامل، لذا يُوضَع الـ watchdog خارج Job الهدف المراقَب.
n1["إدخال الـ watchdog في الـ Job نفسه"] --> n2["عند التنظيف يموت دور إعادة التشغيل أيضاً"]
n2 -.->|"لذلك"| n3["ضع الـ watchdog خارج الـ Job"]
n3 --> n4["يمكن إعادة التشغيل حتى إن سقط العامل"]
الشكل 15: ألّا تجعل دور إعادة التشغيل جماعة مصير هو الشرط الأوّل لوضع الـ watchdog.
7.1 راقب الإنهاء على أساس wait handle
تصير العملية signaled عند الإنهاء.
لذا مراقبة الإنهاء أصلاً لا تحتاج حلقة polling ترى HasExited كلّ 100ms.
في Win32 الهجوم الصحيح:
WaitForSingleObjectWaitForMultipleObjectsRegisterWaitForSingleObjectSetThreadpoolWait
إن تعاملت مع أبناء متعدّدين، أساس wait handle أطبيعي من timer polling.
7.2 لا تنتظر لا نهائياً على خيط الواجهة
WaitForSingleObject(INFINITE) مريح، لكن استخدامه في خيط يملك نافذة يسهّل إيقاف message pump.
في خيط الواجهة وخيط شقّة COM وخيط يملك message pump، أأمن التفكير أوّلاً في موضع الانتظار.
flowchart TB
accTitle: راقب الإنهاء على أساس wait handle
accDescr: مخطّط يبيّن أنّ العملية تصير signaled عند الإنهاء، لذا راقب الإنهاء على أساس wait handle لا polling دوري لـ HasExited، وتجنّب الانتظار اللانهائي على خيط الواجهة لأنّه يوقف message pump.
p1["polling دوري لـ HasExited"] -.-> p2["أصلاً غير لازم"]
p3["انتظر مقبضاً يصير signaled عند الإنهاء"] --> p4["مراقبة على أساس wait handle"]
p4 -.-> p5["انتظار لا نهائي على خيط الواجهة يجمد الشاشة"]
الشكل 16: كشف الإنهاء أوكله إلى wait handle لا إلى polling.
7.3 watchdog التعليق يحتاج heartbeat
watchdog الإنهاء يكفي فيه process handle. لكن watchdog التعليق مختلف.
- متجمّد بـ CPU 100%
- في deadlock
- حلقة الأحداث حيّة لكن لا تقدّم
- متوقّف في انتظار إدخال
هذه الحالات لا تُحكَم بـ «هل العملية حيّة» وحدها. لذا إن أردت رؤية التعليق أيضاً، يلزم تأكيد بقاء في طبقة التطبيق مثل:
- heartbeat
- تسلسل التقدّم
- طابع زمني لآخر عمل ناجح
- مسبار الصحّة
flowchart TB
accTitle: كشف التعليق يحتاج heartbeat
accDescr: مخطّط يبيّن أنّ حالات مثل التجمّد بـ CPU 100% أو deadlock أو غياب التقدّم لا تُحكَم بكون العملية حيّة وحدها، لذا إن أردت رؤية التعليق يلزم تأكيد بقاء في طبقة التطبيق مثل heartbeat أو التقدّم.
q1["العملية حيّة"] --> q2["لكنّها قد لا تتقدّم"]
q2 --> q3["مراقبة الإنهاء وحدها لا تحكم"]
q3 -.->|"لذلك"| q4["تأكيد في طبقة التطبيق مثل heartbeat أو التقدّم"]
الشكل 17: «هل هي حيّة» و«هل تتقدّم» مراقبتان مختلفتان.
7.4 ضع دور إعادة التشغيل خارج الهدف المراقَب
الشائع عمليّاً هذان النمطان.
- التطبيق الأم يشغّل مساعداً مؤقّتاً فقط
- العملية الأم تحمل الـ Job، وتستعيد شجرة المساعد عند إنهاء العملية الأم
- تريد إسكان عامل طويلاً وإعادة تشغيله إن سقط
- عملية watchdog خارجية / خدمة تنشئ Job لكلّ جيل عامل
في الأخير أثبت التصميم فصل شجرة العامل عن سلطة إعادة التشغيل.
7.5 سياسة إعادة التشغيل تُحمَل ميزانية
إدخال watchdog يبدأ بعده crash loop.
- إعادة تشغيل فورية
- سقوط فوري مرّة أخرى
- سجلّ كثير فقط يخرج
لتجنّب هذا، الأفضل حمل restart budget:
- backoff
- حدّ أعلى لعدد إعادة التشغيل ضمن زمن معيّن
- عند الفشل المتتابع توقّف وأبلغ
flowchart TB
accTitle: أوقف crash loop بـ restart budget
accDescr: مخطّط يبيّن حمل restart budget: backoff، وحدّ أعلى لعدد إعادة التشغيل ضمن زمن معيّن، وعند الفشل المتتابع توقّف وأبلغ، لتجنّب crash loop يعيد التشغيل فوراً ويسقط فوراً.
r1["إعادة تشغيل فورية→سقوط فوري مرّة أخرى"] --> r2["crash loop وفيضان سجلّ"]
r2 -.->|"للمنع"| r3["أدخل backoff"]
r3 --> r4["حدّ أعلى للعدد ضمن زمن معيّن"]
r4 --> r5["عند الفشل المتتابع توقّف وأبلغ"]
الشكل 18: أدِر إعادة التشغيل بميزانية، وإن نفدت توقّف وأعلم إنساناً.
8. تركيب موصى به حسب النمط النموذجي
| المشهد | التركيب الموصى به |
|---|---|
| تطبيق سطح مكتب يشغّل مساعد CLI لمرّة | تشغيل واحد = Job واحد. أضِف KILL_ON_JOB_CLOSE، واصرف stdout / stderr بالتوازي. عند الإلغاء: إنهاء تعاوني → timeout → Job kill |
| المساعد يشغّل عمليات حفيدة أيضاً | افترض Job Object ولا تسمح بـ breakaway. إن أردت التثبيت من التشغيل فـ PROC_THREAD_ATTRIBUTE_JOB_LIST |
| خدمة / watchdog تراقب شجرة عامل طويلاً | الـ watchdog عملية / خدمة خارجية. أنشئ Job لكلّ جيل عامل، وراقب بـ exit handle + heartbeat |
| تريد إيقاف أداة وحدة تحكّم بعناية | شغّل بـ CREATE_NEW_PROCESS_GROUP، وأنهِ تعاونياً بـ CTRL_BREAK_EVENT. ثمّ Job kill بعد timeout |
| تريد إغلاق مساعد واجهة | ما يعادل CloseMainWindow / WM_CLOSE → timeout → Job kill |
| تريد مراقبة عمليات ابن كثيرة | استخدم RegisterWaitForSingleObject / SetThreadpoolWait بدل زيادة خيوط حاجبة |
الأهمّ هنا فصل آلية graceful shutdown وآلية التنظيف.
flowchart TB
accTitle: آلية الطلب وآلية التنظيف
accDescr: مخطّط يبيّن أنّ الأهمّ في كلّ نمط نموذجي حمل آلية graceful shutdown مثل رسالة الإغلاق أو بروتوكول الإنهاء، وآلية التنظيف بـ Job Object، منفصلتين.
s1["آلية graceful shutdown"] --> s3["احمل كليهما منفصلتين"]
s2["آلية التنظيف (Job)"] --> s3
s3 -.-> s4["في السليم الأولى، وفي الشذوذ الثانية"]
الشكل 19: في كلّ نمط هيّئ مسار الطلب ومسار الاستعادة منفصلين.
9. ما لا يجوز فعله
نعيد ترتيب تنبيهات الفصول بشكل يُستخدَم في المراجعة كما هو. نرتّب «ماذا يحدث» و«أين مكتوب»، فتعود من السطر الذي علق إلى المتن.
| ما لا يجوز فعله | ماذا يحدث | المتن |
|---|---|---|
الظنّ أنّ Kill(entireProcessTree: true) وحده حلّ graceful shutdown واستعادة انهيار العملية الأم |
لا يؤثّر إلّا عند الإيقاف الصريح. يسقط مسار الاستعادة عند سقوط العملية الأم ومسار جعل الابن ينظّف | الفصل 5 |
توريث الكلّ مع بقاء bInheritHandles=TRUE |
تمرّ مقابض غير مقصودة إلى الابن، فتصير سبب تسرّب مقابض وعدم وصول EOF | 6.4 |
قراءة stdout كاملاً ثمّ قراءة stderr |
يمتلئ الأنبوب الآخر، فتتوقّف العملية الأم في انتظار القراءة والابن في انتظار الكتابة | 6.1 |
| عدم إغلاق نهاية الأنبوب غير المستخدمة | لا يصل EOF، فلا يقوم شرط إنهاء جهة القراءة | 6.3 |
WaitForSingleObject(INFINITE) على خيط الواجهة |
يتوقّف message pump فتجمد الشاشة وCOM | 7.2 |
| إدخال الـ watchdog في الـ Job نفسه مع الهدف المراقَب | عند تنظيف الهدف المراقَب يختفي دور إعادة التشغيل أيضاً | الفصل 7 |
| استخدام 259 كـ exit code عادي | GetExitCodeProcess يعيد STILL_ACTIVE أي 259 أثناء التشغيل. إن أنهت الابن بـ 259 بشكل سليم، يُحكَم خطأ أنّها تعمل رغم انتهائها |
7.1 |
| جعل إشعار منفذ اكتمال الـ Job الحقيقة الوحيدة | الإشعار للمراقبة والتجميع؛ بناؤه correctness وحده يُسقِط حالات | 4.2 |
10. الخلاصة
عند التعامل الآمن مع العمليات الابن في تطبيقات Windows، الأكثر أثراً هذا الترتيب.
من يملك process tree كيف تبلّغ طلب الإنهاء كيف تُمرَّر الإدخال/الإخراج القياسي حتى النهاية أين تضع الـ watchdog
قرّر هذه الأربع أوّلاً.
بعد ذلك، بجمع فضفاض كالتالي.
- نقطة ارتكاز تنظيف الشجرة Job Object
- اقسم graceful shutdown حسب واجهة / وحدة تحكّم / عامل
- صمّم stdio حتى الصرف المتوازي وEOF
- ضع الـ watchdog خارج الهدف المراقَب، وانظر بـ wait handle وheartbeat لا polling
CreateProcess وProcess.Start نفسهما مدخل فقط.
ما يؤثّر فعلاً في معدّل الحوادث موضع مسؤولية الإنهاء وتمرير الإدخال/الإخراج حتى النهاية.
flowchart TB
accTitle: أربع تُقرَّر أوّلاً
accDescr: مخطّط يبيّن أنّ تقرير أربع أوّلاً: من يملك process tree، وكيف تبلّغ طلب الإنهاء، وكيف تُمرَّر الإدخال/الإخراج القياسي حتى النهاية، وأين تضع الـ watchdog، هو الأكثر أثراً في معدّل حوادث العملية الابن.
t1["مالك الشجرة"] --> t5["قرّرها أوّلاً"]
t2["طريقة تبليغ الإنهاء"] --> t5
t3["معاملة stdio"] --> t5
t4["موضع المراقبة"] --> t5
t5 --> t6["واجهة التشغيل مدخل فقط"]
الشكل 20: ما يؤثّر في معدّل الحوادث تقرير هذه الأربع قبل التشغيل.
11. روابط مرجعية
- Microsoft Learn, Job Objects
- Microsoft Learn, JOBOBJECT_BASIC_LIMIT_INFORMATION
- Microsoft Learn, UpdateProcThreadAttribute
- Microsoft Learn, InitializeProcThreadAttributeList
- Microsoft Learn, Inheritance (Processes and Threads)
- Microsoft Learn, CreateProcessW
- Microsoft Learn, Creating a Child Process with Redirected Input and Output
- Microsoft Learn, Pipe Handle Inheritance
- Microsoft Learn, Process.Kill
- Microsoft Learn, Process.CloseMainWindow
- Microsoft Learn, GenerateConsoleCtrlEvent
- Microsoft Learn, WaitForSingleObject
- Microsoft Learn, RegisterWaitForSingleObject
- Microsoft Learn, GetExitCodeProcess
- Microsoft Learn, JOBOBJECT_ASSOCIATE_COMPLETION_PORT
مقالات ذات صلة
أحدث المقالات التي تشترك في نفس الوسوم. عمّق فهمك بمواضيع مرتبطة.
لماذا تتعطّل الوسائط ── قواعد وسائط سطر أوامر Windows
في Windows لا توجد مصفوفة وسائط؛ يصل إلى CreateProcess سلسلة واحدة ويتولّى الطرف المستقبل تقسيمها. نشرح قواعد CommandLineToArgvW وCRT و.N...
Time Travel Debugging ── تسجيل الأخطاء التي لا تتكرّر في التطبيقات طويلة التشغيل وإرجاعها
خطأ يظهر مرّة في الشهر لا يترك في تفريغ الانهيار سوى نتيجته. سجّل التنفيذ وأرجعه بـ WinDbg Time Travel Debugging (TTD): TTD.exe والمخزن ا...
ما الذي يبقى بعد موت الأب ── تربية العمليات الابنة في كائن Job
لماذا يبقى مساعد SDK ماسكاً الكاميرا أو منفذ COM بعد إنهاء الواجهة قسراً. كيف تجعل كائن Job شجرة العمليات وحدة واحدة وتصمم عمر العملية ال...
الأنابيب المسمّاة عمليّاً ── IPC القياسيّ في Windows من التصميم إلى الأمان
دليل عمليّ للأنابيب المسمّاة، وسيلة IPC القياسيّة في Windows. يغطّي وضع البايت مقابل وضع الرسالة، وخوادم متعدّدة العملاء، وأمان ACL وانتح...
الإيقاظات الزائفة ── لماذا يستيقظ متغيّر الشرط «دون إشعار» وكيف تنتظر بشكل صحيح على Windows
يمكن لانتظار متغيّر شرط أن يستيقظ بلا إشعار (إيقاظ زائف). لماذا يسمح Windows بذلك، والانتظار الصحيح بـ while ومحمول في Win32 وC++ وC#.
أين يتصل هذا الموضوع
ترتبط هذه المقالة بشكل طبيعي بصفحات الخدمات التالية.
تطوير تطبيقات ويندوز
في تطبيقات Windows التي تتعامل مع أدوات CLI خارجيّة وأدوات التحويل وworker وupdater، تتحدّد الاستقراريّة بإدارة شجرة العمليّات وتصميم الإنهاء أكثر ممّا تتحدّد بطريقة بدء التشغيل.
التحقيق في الأخطاء وتحليل السبب الجذري
الأعطال التشغيليّة التي يصعب إعادة إنتاجها، مثل بقاء العمليّة الابنة وحدها بعد سقوط العمليّة الأب، أو انسداد stdout، أو سقوط watchdog نفسه، يسهل تحسينها عبر مراجعة تصميم إدارة العمليّات.
الأسئلة الشائعة
أسئلة شائعة حول موضوع هذه المقالة.
- لماذا تبقى العملية الابن بعد سقوط العملية الأم؟
- لأنّ process handle وprocess group وحدهما لا يملكان آلية لاستعادة شجرة العملية عند انهيار العملية الأم. إن أردت ربط حياة العملية الأم بعمر شجرة العمليات الابن، فنقطة الارتكاز Job Object. بإضافة JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE تنتهي كلّ العمليات المنتمية إلى الـ Job عند إغلاق آخر job handle، فتقترب عملية التنظيف حتى الإنهاء غير الطبيعي للعملية الأم من عمر العملية الأم.
- لماذا لا يعود WaitForExit؟
- الاحتمال الأقوى أنّ أنبوب الإخراج القياسي أو الخطأ القياسي مسدود. أنبوب Windows ليس مخزناً لا نهائياً؛ إن كتبت العملية الابن كثيراً إلى stderr بينما تقرأ العملية الأم stdout فقط، تتوقّف الابن عند write والأم عند انتظار الإنهاء. الأساس صرف stdout وstderr بالتوازي؛ تنفيذ يقرأ أحدهما كاملاً ثمّ الآخر ينسدّ بسهولة. كذلك إن لم تُغلَق نهاية الأنبوب غير المستخدمة لا يصل EOF فينهار شرط الإنهاء.
- ألا يكفي Kill(entireProcessTree: true) في .NET وحده؟
- لا يكفي. هو مريح بوصفه واجهة إيقاف صريح، لكنّه ليس بديلاً لتصميم يشمل الاستعادة التلقائية عند انهيار العملية الأم وgraceful shutdown. الأقل عرضة للحوادث ثلاث مراحل: طلب إنهاء تعاوني، انتظار بـ timeout قصير، ثمّ إنهاء قسري للـ Job كلّه. وسيلة الإنهاء التعاوني تُقسَم حسب نوع الابن: CloseMainWindow لابن واجهة رسومية، وCREATE_NEW_PROCESS_GROUP مع CTRL_BREAK_EVENT لابن وحدة التحكّم، وبروتوكول إنهاء عبر stdin أو أنبوب للعامل.
- أين يجب وضع عملية الـ watchdog؟
- الأهمّ ألّا تُدخَل في الـ Job نفسه مع الهدف المراقَب. تريد إعادة تشغيل العامل إن سقط، فإن مات دور إعادة التشغيل معه أيضاً فلا معنى. عند إسكان عامل طويلاً، أثبت تركيب أن يكون watchdog خارجي أو خدمة ينشئ Job لكلّ جيل عامل. راقب الإنهاء على أساس wait handle لا polling، وإن لزم كشف التعليق فادمج تأكيد بقاء في طبقة التطبيق مثل heartbeat.
الملف الشخصي للمؤلف
صفحة الملف الشخصي لمؤلف المقالة.
غو كومورا
مؤسّس شركة كومورا سوفت ذ.م.م.
يركّز على تطوير برامج ويندوز، والاستشارات التقنية، والتحقيق في الأخطاء، ويتميّز في المشاريع التي تبقى فيها الأصول القديمة ناشطة، وفي تشخيص الأعطال التي يصعب تحديد سببها.