فصل ۲۴ − Process Creation

سوالات:

  1. فرض کنید در مثال fork_sig_sync.c پراسس فرزند نیز احتیاج دارد که منتظر بماند تا پروسس پدر چند عمل را کامل انجام دهد. برای این منظور چه تغییراتی لازم است در برنامه اعمال شود؟

عناوین اصلی این فصل و چند فصل آینده سیستم کال‌های fork و wait و execve و تابع کتابخانه‌ای exit هستند. تمام این سیستم کال‌ها و توابع کتابخانه‌ای انواع مختلفی دارند که آن‌ها را هم بررسی می‌کنیم.

  1. fork: سیستم کال fork به پراسس اجازه می‌دهد که یک پراسس جدید ایجاد کند. پراسس جدید فرزند پراسس اجراکننده‌ی سیستم کال fork خواهد بود. پراسس فرزند تقریبا یک کپی دقیق از پراسس پدر خواهد بود: یک کپی از stack و data و heap و سگمنت تکست.
  2. exit(status): تابع کتابخانه‌ای exit پراسس را terminate می‌کند و تمام resource های در اختیار پراسس اعم از حافظه و open file descriptor ها و امثال این‌ها را آزاد می‌کند. کرنل این resource های آزاد شده را بعدا در صورت نیاز در اختیار پراسس‌های جدید قرار می‌دهد. status یک عدد integer است که وضعیت خاتمه‌ی برنامه را مشخص می‌کند. با سیستم کال wait پراسس پدر می‌تواند این عدد وضعیت را بازیابی کند. تابع کتابخانه‌ای exit(3) روی سیستم کال _exit(2) نوشته شده است. فعلا فقط به خاطر داشته باشید که بعد از fork عموما فقط یکی از بین پدر و فرزند با تابع کتابخانه‌ای exit خاتمه می‌یابد، دیگری باید با سیستم کال _exit خاتمه یابد یا terminate شود.
  3. wait(&status): سیستم کال wait دو منظور را برآورده می‌کند. اول اینکه اگر پراسس پدر فرزند یا فرزندان erminate نشده‌ای داشته باشد، یعنی هنوز exit را فراخوانی نکرده باشند، wait اجرای پراسس فراخوان را متوقف می‌کند تا یکی از فرزندان پراسس terminate شود. و دوم اینکه termination status پراسس فرزند در آرگومان status برگردانده می‌شود.
  4. execve(pathname, argv, envp): سیستم کال exceve یک برنامه‌ی جدید را در فضای آدرس پراسس لود می‌کند و program text را دور می‌ریزد، سگمنت‌های استک و data و heap به صورت تر و تمیز برای برنامه‌ی جدید ساخته می‌شوند. به این کار معمولا exec کردن یک برنامه جدید می‌گویند. بعد خواهیم دید که چندین تابع کتابخانه‌ای روی سیستم کال execve نوشته شده‌اند. به این توابع کتابخانه‌ای معمولا exec family می‌گویند ولی به یاد داشته باشید که هیچ سیستم کال یا تابع کتابخانه‌ای به نام exec وجود ندارد.

بعضی سیستم عامل‌ها عمل fork و exec را در قالب یک عمل ترکیب می‌کنند که به spawn کردن معروف است. spawn یک پراسس ایجاد می‌کند و سپس برنامه‌ی مشخص شده را اجرا می‌کند. روشی که یونیکس انتخاب کرده است یعنی جداسازی این دو عمل از هم بسیار شیک‌تر و ساده‌تر است. fork هیچ آرگومانی ندارد و بسیار ساده است و از طرفی این جداسازی انعطاف بیشتری به برنامه‌نویس می‌دهد که در بین این دو عمل کارهای دیگری نیز به دلخواه انجام دهد. علاوه بر این بعضی جاها می‌توان fork کرد و exec نکرد.

SUSv3 یک تابع optional به نام posix_spawn(3) تعریف می‌کند که دو عمل fork و exec را با هم ترکیب می‌کند.

The shell (sh(1)) continuously executes a loop that reads a command, performs various processing on it, and then forks a child process to exec the command.

استفاده از سیستم کال wait چندان ضروری نیست. پراسس پدر می‌تواند به سادگی فرزندش را فراموش کند و به کار خودش ادامه دهد. سیستم کال wait معمولا در هندلر سیگنال SIGCHLD استفاده می‌شود. وقتی پراسس فرزند terminate می‌شود کرنل به پراسس پدر سیگنال SIGCHLD می‌زند. (در STOP و RESUME شدن پراسس فرزند هم به پراسس پدر همین سیگنال را می‌زند که دو مورد اخیر را باید از کرنل درخواست کنیم که نزند. رجوع کنید به اینجا) سیگنال SIGCHLD به صورت پیش فرض نادیده گرفته می‌شود.

خواندن status code پراسس فرزند در هندلر سیگنال SIGCHLD

سیستم کال pause(2) زمانی برمی‌گردد که سیگنال داده شده catch شود و signal handler مربوطه return کند. در این مثال همه‌ی سیگنال‌ها غیر از سیگنال SIGCHLD و SIGTERM که هندلری برای آن‌ها نصب نمی‌شود را catch می‌کنیم. رفتار معمول پراسس در مقابل سیگنال SIGCHLD نادیده گرفتن و در مواجهه با SIGTERM خاتمه دادن به پراسس است. مثال را با همین سیگنال‌ها تست کنید.

در بسیاری از application ها ایجاد چندین پراسس روش مفیدی برای تقسیم task هاست. مثلا یک پراسس server می‌تواند گوش به زنگ رسیدن یک request شود و سپس برای انجام آن request یک پراسس ایجاد کند و خودش مجددا منتظر رسیدن request دیگر شود. این شیوه علاوه بر این که طراحی application را ساده‌تر می‌کند اجازه‌ی همزمانی یا concurrency بیشتری می‌دهد یعنی task ها و درخواست‌های بیشتری به صورت همزمان اجرا می‌شوند.

سیستم کال fork(2) یک پراسس که تقریبا به طور دقیق یک کپی از پراسس فراخوان fork است ایجاد می‌کند. بعد از برگشت موفقیت آمیز fork ما دو پراسس داریم یعنی fork در دو پراسس با دو مقدار متفاوت برمی‌گردد: در پراسس پدر با PID فرزند و در پراسس فرزند با PID صفر. در هر دو پراسس اجرا از جایی که fork برگشته است ادامه می‌یابد.

#include <unistd.h>

pid_t fork(void);

    In parent: returns process ID of child on success, or -1 on error;
                       in successfully created child: always returns 0

بعد از fork پراسس‌ها سگمنت‌های data و stack و heap مخصوص به خود و جداگانه دارند ولی هر دو یک program text را اجرا می‌کنند.

دو احتمال موفق نشدن سیستم کال fork و -1 برگرداندن:

  1. رسیدن به سقف محدودیت حداکثر پراسس یک real user ID، به عبارتی رسیدن به RLIMIT_NPROC.
  2. رسیدن به سقف حداکثر پراسس‌های قابل ایجاد در کل سیستم.

مهم است که بدانید بعد از fork به هیچ عنوان معلوم نیست که الگوریتم زمان‌بندی CPU در کرنل اول پدر را schedule می‌کند یا فرزند را. عدم توجه به این نکته به خطاهایی که به نام race condition معروف هستند منجر می‌شود. بعدا در مورد این خطاها بیشتر صحبت می‌کنیم.

مثال از سیستم کال fork و بررسی فضای متفاوت stack segment و data segment پراسس پدر و فرزند

متغیرهای local یا automatic در داخل استک ذخیره می‌شوند. متغیرهای سراسری و static در داخل data segment ذخیره می‌شوند.

پراسس فرزند تمام file descriptor های پراسس پدر را به ارث می‌برد. به خاطر دارید که یک درایه از جدول file descriptor در پراسس حاوی اطلاعات محدود و کمی است ولی فیلد اشاره‌گری به درایه‌ی متناظر از جدول open file description در سطح سیستم دارد که اطلاعات جامع در مورد file descriptor در این جدول نگهداری می‌شود. سیستم کال fork جدول file descriptor سطح پراسس را کپی می‌کند ولی کاری به جدول open file description سطح سیستم ندارد. لذا بعد از fork درایه‌های جدول file descriptor در هر دو پراسس به درایه‌های یکسانی از جدول open file description در سیستم اشاره می‌کنند. (توضیحات بیشتر صفحه ۹۴)

آفست و status flag های فایل در جدول open file description در سطح سیستم نگهداری می‌شوند به همین خاطر این اطلاعات بین پدر و فرزند مشترک هستند.

مثال از اطلاعات مشترک فایل باز شده بین دو پراسس مثال از تغییر آفست و فلگ در فرزند و مشاهده تغییرات در پدر

سیستم کال wait(2) می‌تواند به عنوان یک ابزار همزمانی یا synchronization مورد استفاده قرار گیرد. در این مثال نمونه‌ای از آن را می‌بینید. پراسس پدر منتظر می‌ماند تا کار پراسس فرزند تمام شود. این همان کاری است که sh(1) انجام می‌دهد. (صفحه ۵۱۹)

اگر مشترک بودن file descriptor ها بین پراسس و فرزند چیزی نیست که می‌خواهید بعد از fork به عنوان اولین کار file descriptor هایی که پراسس مربوطه به آن نیاز ندارد را ببندید. فلگ O_CLOEXEC نیز در صورت exec کردن برنامه جدید در فضای آدرس پراسس مفید است.

مثال از close کردن یک file descriptor در پروسس فرزند

در پیاده‌سازی‌های اولیه‌ی UNIX فضای آدرس پدر دقیقا به عنوان فضای آدرس فرزند کپی می‌شد ولی این کار زمان‌بر و اتلاف وقت است چون در بیشتر موارد بلافاصله بعد از fork اقدام به exec می‌شود و کل سگمنت‌ها دوباره با مقادیر جدید initialize می‌شود. امروزه اغلب سیستم‌های مدرن یونیکس از جمله لینوکس برای رهایی از چنین کپی بی‌ثمری از دو تکنیک استفاده می‌کنند:

  • کرنل سگمنت text هر پراسس را به عنوان readonly علامت‌گذاری می‌کند. به این صورت می‌توان سگمنت text را بدون ترس از تغییر متحوای آن بین چند پراسس share کرد.
  • برای صفحات قرار گرفته در سگمنت‌های data و heap و stack پراسس پدر، کرنل از تکنیکی به نام copy-on-write استفاده می‌کند. (پیاده‌سازی این تکنیک در Bach, 1986 و Bovet & Cesati, 2005 توضیح داده شده است.) خلاصه‌ی کار این است که کرنل جدول صفحه‌ای دقیقا مشابه جدول صفحه‌ی پراسس پدر برای فرزند می‌سازد که درایه‌های آن به همان صفحات حافظه‌ی فیزیکی که مورد استفاده‌ی پراسس پدر است ارجاع می‌شوند. کرنل این صفحات حافظه‌‌ی فیزیکی را readonly علامت می‌زند و آن‌ها را در معرض استفاده‌ی مشترک پدر و فرزند قرار می‌دهد. کرنل هر تلاشی برای تغییر این صفحات را با تله‌ی نرم‌افزاری trap شکار می‌کند، خواه این تلاش از جانب پدر باشد و خواه از جانب فرزند. سپس یک کپی از این صفحه‌ی trap شده می‌سازد و جدول صفحه‌ی خودش را بر مبنای آدرس صفحه‌ی فیزیکی جدید اصلاح می‌کند. از این به بعد پراسسی که قصد تغییر این صفحه را داشت با صفحه‌ی اختصاصی خودش کار می‌کند و تغییرات در صفحات نظیر پراسس‌ها برای همدیگر قابل مشاهده نیست.

اگرچه پراسس فرزند می‌تواند از طریق exit status که یک عدد ۸ بیتی است اطلاعاتی در اختیار پراسس پدر بگذارد باز هم روش‌های دیگری مانند استفاده از pipe و یا به کارگیری فایل‌ها یا سایر روش‌های IPC وجود دارند که می‌توانند اطلاعات حجیم‌تری را بین هم رد و بدل کنند.

صفحه‌ی ۵۲۱ و مبحث Controlling a process's memory footprint آیا مطلبی داشت که من متوجه نشدم؟

سیستم کال vfork(2) در سیستم‌های BSD پدید آمد و به اکثر پیاده‌سازی‌های یونیکس از جمله لینوکس راه یافت. علت پیدایش این سیستم کال پیاده‌سازی اولیه سیستم کال fork بر مبنای کپی کامل سگمنت‌های data و heap و stack بود که همان طور که دیدیم کاری زمان‌بر و بی‌فایده بود بخصوص اگر بعد از fork به سرعت exec می‌کردیم. امروزه سیستم کال fork بر مبنای copy-on-write پیاده‌سازی می‌شود و دلایل وجود vfork تا حد زیادی از بین رفته است. به این دلیل و به دلیل سمانتیک تا حدودی عجیب و غریب آن تا جای ممکن نباید از آن استفاده کرد.

vfork هم همانند fork برای ایجاد پراسس جدید استفاده می‌شود با وجود این vfork به صورت ویژه (expressly) برای کار در برنامه‌هایی طراحی شده است که پراسس فرزند به سرعت exec می‌کند.

#include <unistd.h>

pid_t vfork(void);

    In parent: returns process ID of child on success, or -1 on error;
                      in successfully created child: always returns 0

دو ویژگی سیستم کال vfork که آن را از fork متمایز و کاراتر می‌کند:

  • هیچ کپی از صفحات حافظه‌ی مجازی یا جداول صفحه برای پراسس فرزند انجام نمی‌شود. پراسس فرزند از حافظه‌ی پراسس پدر استفاده می‌کند تا اینکه یا یک exec موفق انجام دهد یا با _exit() خاتمه یابد.
  • اجرای پراسس پدر تا زمانی که فرزند یا exec کند یا _exit() متوقف یا suspend می‌شود.

از آنجایی که پراسس فرزند از حافظه‌ی پراسس پدر استفاده می‌کند هر تغییری در این فضای حافظه بعد از اینکه پدر اجرا را از سر گرفت یا اصطلاحا resume شد برای پدر قابل مشاهده است. علاوه بر این اگر فرزند بین vfork و exec یا _exit، return کند این کار روی پدر اثر دارد. علاوه بر این فراخوانی هر تابع دیگری قبل از exec کردن یا _exit() کردن اثر تعریف نشده‌ای روی اجرای برنامه دارد.

از آنجایی که file descriptor table هر پروسس در فضای کرنل مدیریت می‌شود file descriptor ها برای پراسس فرزند کپی می‌شوند و فرزند آزادانه می‌تواند روی آن‌ها عملیات انجام دهد. مثال

اینکه بعد از سیستم کال vfork قطعا فرزند CPU را برای اجرا به دست خواهد گرفت تضمین شده است، در حالی که در مورد سیستم کال fork چنین تضمینی وجود نداشت.

مثال از سیستم کال vfork

اگر مساله‌ی سرعت به شدت حیاتی است از vfork استفاده کنید در غیر این صورت به یاد داشته باشید که با پیاده‌سازی سیستم کال fork در سیستم‌های یونیکس مدرن بر اساس copy-on-write سرعت سیستم کال fork به سرعت vfork نزدیک شده است. با این پیاده‌سازی جدید از شر رفتارها و قوانین عجیب و غریب vfork خلاص می‌شویم. در فصل ۲۸ مقایسه‌ای بین سرعت fork و vfork انجام خواهیم داد.

سیستم کال vfork در SUSv3 علامت منسوخ شده خورد و در SUSv4 حذف شد.

پراسسی که با vfork ایجاد شده است نباید با تابع کتابخانه‌ای exit(3) خاتمه یابد چون این کار باعث می‌شود تا بافرهای stdio پراسس پدر ابتدا flush و سپس بسته شوند. عموما سیستم کال vfork باید فورا با فراخوانی exec همراه شود.

در بعضی سیستم‌ها vfork به سادگی توسط سیستم کال fork پیاده‌سازی شده است و معنا و مفهوم خاص آن رعایت نشده است یعنی تضمینی نیست که ابتدا پراسس فرزند schedule شود و پراسس پدر تا اتمام یا exec کردن فرزند متوقف شود و حافظه نیز بین پدر و فرزند مشترک نیست. تکیه بر این اصول vfork باعث غیر قابل حمل شدن برنامه به سیستم‌های دیگر می‌شود.

در مورد سیستم کال vfork بیشتر باید مطالعه کنم. #Think

race condition یا شرایط رقابتی، رقابت بر سر منابع از جمله CPU است. debug کردن خطاهایی که از race condition نشات می‌گیرند سخت است چون مثلا در مورد CPU این خطاها بستگی به الگوریتم‌های زمان‌بندی دارد که توسط کرنل اعمال می‌شود و ما اطلاعی از آن نداریم.

تست race condition و اینکه پدر زودتر CPU را می‌گیرد و یا فرزند. خروجی را با این فایل awk تحلیل کنید. در سیستم من در هر ۱۰ هزار تست بیش از ۹۹۹۰ بار پراسس پدر زودتر از فرزند اجرا می‌شود و آن ۱۰ بار هم به این خاطر است که time slice پروسس پدر قبل از اینکه بتواند پیغامش را چاپ کند تمام می‌شود. ولی به هیچ وجه و هرگز نباید روی این مساله تکیه کنیم و کد بنویسیم چون روی سیستم‌های مختلف سیاست‌های مختلفی وجود دارد و حتی از این کرنل لینوکس به آن کرنل لینوکس نیز تفاوت سیاست زمان‌بندی وجود دارد.

در مورد مزایای اول پدر و مزایای اول فرزند:

پس آموختیم که به هیچ عنوان نمی‌شود بر روی اینکه بعد از fork اول پدر اجرا می‌شود یا فرزند حساب کنیم. اگر تضمین این نکته که کدام یک باید اول اجرا شوند برای عملکرد برنامه مهم است باید از تکنیک‌های دیگر همزمانی استفاده کنیم. چندین تکنیک برای این کار وجود دارد: سمافورها، file locks، ارسال پیام بین پراسس‌ها با pipe، و سیگنال‌ها.

در مورد همزمانی با استفاده از سیگنال‌ها، پراسسی که باید منتظر تکمیل پراسس دیگر بشود می‌تواند منتظر سیگنالی شود که پراسس دیگر در صورت تکمیل کار آن سیگنال را به او ساطع خواهد کرد.

مثال در مورد پیاده‌سازی همزمانی با استفاده از سیگنال. در اینجا قبل از fork سیگنال SIGUSR1، که نقش سیگنال همزمانی را بازی می‌کند را بلاک کرده‌ایم چون ممکن است فرزند زودتر schedule شود و سیگنال را ساطع کند و سیگنال در پراسس پدر وقتی که منتظرش نیست دریافت شود و از دست برود.

بعد از n عدد fork پشت سر هم چند پراسس خواهیم داشت؟ جواب 2n-1 مثال (n را از خط فرمان وارد کنید.)

در صفحه‌ی ۵۳۰ سوال ۲۴.۳ پرسیده شده است فرض کنید می‌توانیم کد منبع یک برنامه را اصلاح کنیم. چگونه ‌می‌توانیم در لحظه‌ی بخصوصی از اجرای پراسس همزمان با اینکه اجرای پراسس متوقف نشود از آن core dump بگیریم؟ جواب این است که باید fork کنیم و پراسس فرزند بلافاصله به خودش سیگنال SIGABRT بزند. از تابع abort(3) هم می‌توانیم استفاده کنیم.

مطالعه‌ی بیشتر

نوشته شده در: 1405-03-09 (1 هفته 1 روز 21 ساعت پیش)

من محسن هستم؛ برنامه‌نویس تفننی!

برای ارتباط با من یا در همین سایت کامنت بگذارید و یا به dokaj.ir(at)gmail.com ایمیل بزنید.

در مورد این مطلب یادداشتی بنویسید.