عمليات التعلم على لينكس


في هذه المقالة ، أود أن أتحدث عن مسار الحياة للعمليات في عائلة نظام التشغيل Linux. نظريًا وأمثلة ، سوف ألقي نظرة على كيفية ولادة العمليات وموتها ، القليل من الحديث عن آليات مكالمات وإشارات النظام.

هذه المقالة مخصصة أكثر للمبتدئين في برمجة النظام وأولئك الذين يريدون فقط معرفة المزيد عن كيفية عمل العمليات في Linux.

كل شيء مكتوب أدناه ينطبق على Debian Linux مع نواة 4.15.0.

المحتويات


  1. مقدمة
  2. سمات العملية
  3. دورة حياة العملية
    1. الولادة العملية
    2. حالة جاهزة
    3. الحالة "قيد التشغيل"
    4. التناسخ في برنامج آخر
    5. حالة معلقة
    6. حالة التوقف
    7. اكتمال العملية
    8. حالة "الزومبي"
    9. النسيان
  4. شكر وتقدير

مقدمة


يتفاعل برنامج النظام مع قلب النظام من خلال وظائف خاصة - مكالمات النظام. في حالات نادرة ، هناك واجهة برمجة تطبيقات بديلة ، على سبيل المثال ، procfs أو sysfs ، مصنوعة في شكل أنظمة ملفات افتراضية.

سمات العملية


يتم تقديم العملية في النواة ببساطة كهيكل مع العديد من المجالات (يمكن قراءة تعريف البنية هنا ).
ولكن نظرًا لأن المقالة مخصصة لبرمجة النظام ، وليس لتطوير kernel ، فإننا مجردة إلى حد ما ونركز ببساطة على مجالات العملية المهمة بالنسبة لنا:

  • معرف العملية (pid)
  • فتح واصفات الملفات (fd)
  • معالجات الإشارة
  • دليل العمل الحالي (cwd)
  • متغيرات البيئة (البيئة)
  • رمز الإرجاع

دورة حياة العملية




الولادة العملية


عملية واحدة فقط في النظام ولدت بطريقة خاصة - init - يتم إنشاؤها مباشرة بواسطة النواة. تظهر جميع العمليات الأخرى عن طريق تكرار العملية الحالية باستخدام استدعاء نظام fork(2) . بعد تنفيذ fork(2) ، نحصل على عمليتين متطابقتين تقريبًا ، باستثناء النقاط التالية:

  1. fork(2) تعيد PID للطفل إلى الوالد ، 0 تعاد إلى الطفل ؛
  2. يغير الطفل PPID (معرف العملية الأصل) إلى PID الخاص بالأصل.

بعد تنفيذ fork(2) ، تكون جميع موارد العملية الفرعية نسخة من موارد الوالد. يعد نسخ العملية باستخدام جميع الصفحات المخصصة للذاكرة أمرًا مكلفًا ، لذلك تستخدم نواة Linux تقنية Copy-On-Write.
يتم وضع علامة للقراءة فقط على كافة الصفحات الموجودة في ذاكرة الوالد ، وتصبح متاحة لكل من الوالد والطفل. بمجرد أن تقوم إحدى العمليات بتغيير البيانات على صفحة معينة ، لا تتغير هذه الصفحة ، ولكن يتم نسخ النسخة وتغييرها بالفعل. في هذه الحالة ، الأصلي "غير مرتبط" من هذه العملية. بمجرد أن يبقى الأصل للقراءة فقط "مرتبطًا" بعملية واحدة ، تتم إعادة تعيين الصفحة إلى حالة القراءة والكتابة.

مثال لبرنامج بسيط عديم الفائدة مع شوكة (2)

 #include <stdio.h> #include <unistd.h> #include <errno.h> #include <sys/wait.h> #include <sys/types.h> int main() { int pid = fork(); switch(pid) { case -1: perror("fork"); return -1; case 0: // Child printf("my pid = %i, returned pid = %i\n", getpid(), pid); break; default: // Parent printf("my pid = %i, returned pid = %i\n", getpid(), pid); break; } return 0; } 


 $ gcc test.c && ./a.out my pid = 15594, returned pid = 15595 my pid = 15595, returned pid = 0 


حالة جاهزة


فور التنفيذ ، تدخل fork(2) إلى حالة الاستعداد.
في الواقع ، تنتظر العملية وتنتظر المجدول في النواة للسماح بتشغيل العملية على المعالج.

الحالة "قيد التشغيل"


بمجرد أن بدأ المجدول تنفيذ العملية ، بدأت حالة "التشغيل". يمكن أن تقوم العملية بتشغيل الفترة الزمنية المقترحة (الكم) بأكملها ، أو يمكن أن تفسح المجال للعمليات الأخرى باستخدام تصدير نظام sched_yield .

التناسخ في برنامج آخر


تقوم بعض البرامج بتطبيق منطق تقوم العملية الرئيسية فيه بإنشاء طفل لحل مشكلة ما. يحل الطفل في هذه الحالة مشكلة معينة ، ولا يقوم الوالد بتفويض المهام إلا لأطفاله. على سبيل المثال ، يقوم خادم الويب الذي لديه اتصال وارد بإنشاء طفل ويمرر معالجة الاتصال إليه.
ومع ذلك ، إذا كنت بحاجة إلى تشغيل برنامج آخر ، يجب عليك اللجوء إلى استدعاء النظام execve(2) :

 int execve(const char *filename, char *const argv[], char *const envp[]); 

أو مكالمات مكتبة execl(3), execlp(3), execle(3), execv(3), execvp(3), execvpe(3) :

 int execl(const char *path, const char *arg, ... /* (char *) NULL */); int execlp(const char *file, const char *arg, ... /* (char *) NULL */); int execle(const char *path, const char *arg, ... /*, (char *) NULL, char * const envp[] */); int execv(const char *path, char *const argv[]); int execvp(const char *file, char *const argv[]); int execvpe(const char *file, char *const argv[], char *const envp[]); 

تنفذ جميع المكالمات المذكورة أعلاه البرنامج ، والمسار المشار إليه في الوسيطة الأولى. في حالة النجاح ، يتم نقل عنصر التحكم إلى البرنامج الذي تم تحميله ولا يتم إرجاعه إلى البرنامج الأصلي. في هذه الحالة ، يحتوي البرنامج الذي تم تحميله على جميع حقول بنية العملية ، باستثناء واصفات الملفات التي تم وضع علامة O_CLOEXEC ، سيتم إغلاقها.

كيف لا تخلط بين كل هذه التحديات وتختار الصواب؟ يكفي أن نفهم منطق التسمية:

  • تبدأ جميع المكالمات بـ exec
  • يحدد الحرف الخامس نوع تمرير الحجة:
    • l تعني القائمة ، يتم تمرير جميع المعلمات كـ arg1, arg2, ..., NULL
    • v تعني المتجه ، يتم تمرير جميع المعلمات في صفيف منتهي بقيمة خالية ؛
  • قد يتبع الحرف p ، الذي يشير إلى المسار . إذا كانت وسيطة file تبدأ بحرف غير "/" ، فسيتم البحث في file المحدد في الدلائل المدرجة في متغير بيئة PATH
  • قد يكون الأخير هو الحرف e ، مشيرًا إلى البيئة . في هذه الاستدعاءات ، تكون الوسيطة الأخيرة عبارة عن مصفوفة منتهية بقيمة خالية من السلاسل منتهية بقيمة خالية من key=value النموذج key=value - متغيرات البيئة التي سيتم تمريرها إلى البرنامج الجديد.

مثال استدعاء / bin / cat - مساعدة عبر execve

 #define _GNU_SOURCE #include <unistd.h> int main() { char* args[] = { "/bin/cat", "--help", NULL }; execve("/bin/cat", args, environ); // Unreachable return 1; } 


 $ gcc test.c && ./a.out Usage: /bin/cat [OPTION]... [FILE]... Concatenate FILE(s) to standard output. * * 


تتيح لك مجموعة المكالمات exec* تشغيل نصوص برمجية لها حقوق التنفيذ والبدء بتسلسل من shebangs (#!).

مثال على تشغيل برنامج نصي باستخدام PATH مخادع باستخدام execle

 #define _GNU_SOURCE #include <unistd.h> int main() { char* e[] = {"PATH=/habr:/rulez", NULL}; execle("/tmp/test.sh", "test.sh", NULL, e); // Unreachable return 1; } 


 $ cat test.sh #!/bin/bash echo $0 echo $PATH $ gcc test.c && ./a.out /tmp/test.sh /habr:/rulez 


يوجد اصطلاح يعني أن argv [0] يطابق الوسيطات الخالية للوظائف في عائلة exec *. ومع ذلك ، يمكن انتهاك هذا.

مثال عندما تصبح القط كلبًا باستخدام execlp

 #define _GNU_SOURCE #include <unistd.h> int main() { execlp("cat", "dog", "--help", NULL); // Unreachable return 1; } 


 $ gcc test.c && ./a.out Usage: dog [OPTION]... [FILE]... * * 


قد يلاحظ القارئ الغريب أن هناك عددًا في توقيع الوظيفة int main(int argc, char* argv[]) - عدد الحجج ، ولكن لا يتم تمرير أي شيء من هذا النوع إلى عائلة exec* من الوظائف. لماذا؟ لأنه عندما يبدأ البرنامج ، لا يتم نقل التحكم على الفور إلى الرئيسي. قبل ذلك ، يتم تنفيذ بعض الإجراءات المحددة بواسطة glibc ، بما في ذلك حساب argc.

حالة معلقة


قد تستغرق بعض مكالمات النظام وقتًا طويلاً ، مثل I / O. في مثل هذه الحالات ، تنتقل العملية إلى حالة "معلقة". بمجرد اكتمال استدعاء النظام ، ستضع النواة العملية في حالة "جاهزة".
في Linux ، هناك أيضًا حالة "انتظار" لا تستجيب فيها العملية لإشارات المقاطعة. في هذه الحالة ، تصبح العملية "غير قابلة للتدمير" ، وتقف جميع الإشارات الواردة في الطابور حتى تغادر العملية هذه الحالة.
النواة نفسها تختار الدولة التي ستنقل العملية إليها. غالبًا ما تكون العمليات التي تطلب إدخال / إخراج في حالة "انتظار (بدون مقاطعة)". هذا ملحوظ بشكل خاص عند استخدام قرص بعيد (NFS) مع إنترنت غير سريع للغاية.

حالة التوقف


يمكنك إيقاف العملية مؤقتًا في أي وقت عن طريق إرسالها إشارة SIGSTOP. ستصبح العملية في حالة "توقف" وستبقى هناك حتى تتلقى إشارة لمواصلة العمل (SIGCONT) أو تموت (SIGKILL). سيتم وضع الإشارات المتبقية في قائمة الانتظار.

اكتمال العملية


لا يمكن لأي برنامج إغلاق نفسه. يمكنهم فقط طلب النظام لذلك من خلال استدعاء النظام _exit أو إنهاء النظام بسبب خطأ. حتى عند إرجاع رقم من main() ، لا يزال يتم _exit بشكل ضمني.
على الرغم من أن وسيطة استدعاء النظام int ، إلا أن البايت المنخفض للرقم يؤخذ كرمز الإرجاع.

حالة "الزومبي"


مباشرة بعد اكتمال العملية (بغض النظر عما إذا كانت صحيحة أم لا) ، تكتب النواة معلومات حول كيفية انتهاء العملية وتضعها في حالة "غيبوبة". وبعبارة أخرى ، فإن الزومبي هو عملية مكتملة ، ولكن لا تزال ذاكرةه مخزنة في النواة.
علاوة على ذلك ، هذه هي الحالة الثانية التي يمكن فيها للعملية تجاهل إشارة SIGKILL بأمان ، لأنها لا يمكن أن تموت مرة أخرى.

النسيان


لا يزال رمز الإرجاع وسبب إتمام العملية مخزنين في النواة ويجب أخذهما من هناك. للقيام بذلك ، يمكنك استخدام مكالمات النظام المناسبة:

 pid_t wait(int *wstatus); /*  waitpid(-1, wstatus, 0) */ pid_t waitpid(pid_t pid, int *wstatus, int options); 

تتناسب جميع المعلومات حول إنهاء العملية مع نوع البيانات int. يتم استخدام وحدات الماكرو الموضحة في صفحة waitpid(2) " waitpid(2) للحصول على رمز الإرجاع وسبب إنهاء البرنامج.

مثال على الإكمال الصحيح واستلام رمز الإرجاع

 #include <stdio.h> #include <unistd.h> #include <errno.h> #include <sys/wait.h> #include <sys/types.h> int main() { int pid = fork(); switch(pid) { case -1: perror("fork"); return -1; case 0: // Child return 13; default: { // Parent int status; waitpid(pid, &status, 0); printf("exit normally? %s\n", (WIFEXITED(status) ? "true" : "false")); printf("child exitcode = %i\n", WEXITSTATUS(status)); break; } } return 0; } 


 $ gcc test.c && ./a.out exit normally? true child exitcode = 13 


مثال إتمام غير صحيح

تمرير argv [0] حيث ينتج عن NULL عطل.

 #include <stdio.h> #include <unistd.h> #include <errno.h> #include <sys/wait.h> #include <sys/types.h> int main() { int pid = fork(); switch(pid) { case -1: perror("fork"); return -1; case 0: // Child execl("/bin/cat", NULL); return 13; default: { // Parent int status; waitpid(pid, &status, 0); if(WIFEXITED(status)) { printf("Exit normally with code %i\n", WEXITSTATUS(status)); } if(WIFSIGNALED(status)) { printf("killed with signal %i\n", WTERMSIG(status)); } break; } } return 0; } 


 $ gcc test.c && ./a.out killed with signal 6 


هناك أوقات ينتهي فيها الوالد في وقت أبكر من الطفل. في مثل هذه الحالات ، سيصبح init هو والد الطفل وسيستخدم مكالمة wait(2) عندما يحين الوقت.

بعد أن يأخذ الوالدان معلومات حول وفاة الطفل ، تمحو النواة جميع المعلومات المتعلقة بالطفل حتى تتم عملية أخرى في مكانها قريبًا.

شكر وتقدير


بفضل ساشا "آل" للتحرير والمساعدة في التصميم ؛

شكرا لساشا "Reisse" للإجابات الواضحة على الأسئلة الصعبة.

لقد تحملوا الإلهام الذي هاجمني وابل من أسئلتي التي هاجمتني.

Source: https://habr.com/ru/post/ar423049/


All Articles