فصل ۲۵ − Process Termination

در این فصل در کنار سایر مباحثی که مطرح می‌کنیم در مورد exit handler ها نیز صحبت می‌کنیم. وقتی یک پراسس تابع کتابخانه‌ای exit(3) را صدا می‌زند exit handler های رجیستر شده به صورت خودکار clean up انجام می‌دهند.

یک پراسس به دو شیوه می‌تواند خاتمه یابد یا terminate شود:

  1. abnormal termination یا خاتمه یافتن غیر طبیعی پراسس. این نوع termination در نتیجه‌ی تحویل سیگنالی به پراسس است که رفتار پیش فرض سیگنال خاتمه دادن به پراسس با core dump یا بدون core dump است.
  2. normal termination یا خاتمه یافتن طبیعی پراسس. این نوع termination با فراخوانی سیستم کال _exit(2) اتفاق می‌افتد.
#include <unistd.h>

void _exit(int status);
                always success, never returns

آرگومان status فراهم شده برای سیستم کال _exit()، termination status نام دارد و پراسس پدر با فراخوانی سیستم کال wait می‌تواند این مقدار را بخواند.

اگرچه status از نوع integer است ولی فقط ۸ بیت پایین آن در معرض دید پدر قرار می‌گیرد. به عنوان قرارداد status صفر به معنای کامل شدن موفقیت‌آمیز پراسس و هر عددی غیر صفر به معنای این است که پراسس به صورت غیرموفقیت‌آمیز خاتمه یافته است. SUSv3 دو ثابت EXIT_SUCCESS و EXIT_FAILURE را برای این منظور مشخص کرده است.

مشاهده‌ی مقادیر دو ثابت EXIT_SUCCESS و EXIT_FAILURE

اگرچه status می‌تواند در بازه‌ی ۰ تا ۲۵۵ مقدار بگیرد ولی مقادیر بیشتر از ۱۲۸ ممکن است باعث سردرگمی در shell script ها شود. این به آن خاطر است که وقتی یک پراسس با سیگنال خاتمه می‌یاید shell متغیر $? را با 128 + signal number مقداردهی می‌کند. یعنی برای shell script ها عدد بالای ۱۲۸ به معنای خاتمه یافتن غیر طبیعی برنامه با سیگنال است.

این مثال را کامپایل کنید و بعد از اجرا مقدار $? را بررسی کنید.

مختصر و مفید توضیحات فوق این است که سیستم کال _exit() آرگومان status را که می‌گیرد، هر چه که باشد، فقط کم ارزش‌ترین بایت آن را به پراسس پدر می‌دهد. در سمت پدر این بایت کم ارزش در بایت دوم آرگومان status وارد شده قرار می‌گیرد و برای خواندن باید آن را ۸ بیت به سمت راست شیفت بدهیم. مثال

برنامه‌ها معمولا از سیستم کال _exit(2) به صورت مستقیم استفاده نمی‌کنند. به جای آن از تابع کتابخانه‌ای exit(3) بهره می‌برند که قبل از فراخوانی سیستم کال _exit چند کار دیگر را نیز انجام می‌دهد.

#include <stdlib.h>

void exit(int status);

تابع کتابخانه‌ای exit کارهای زیر را انجام می‌دهد:

exit handler های ثبت شده به وسیله‌ی توابع atexit(3) و on_exit(3) را به عکس ترتیب ثبت شدنشان اجرا می‌کند. بافرهای stdio همگی flush می‌شوند. سیستم کال _exit() با مقدار status فراهم شده برای تابع exit فراخوانی می‌شود. exit() جزو کتابخانه‌ی استاندارد زبان C است و لذا بر روی تمام پیاده‌سازی‌های زبان C وجود دارد.

در دسته‌بندی terminate شدن طبیعی پراسس بجز استفاده از سیستم کال _exit()، return کردن از تابع main نیز قرار دارد. حال این return کردن به دو صورت اتفاق می‌افتد: آشکار return n; و یا به صورت ضمنی و رسیدن کنترل به پایان تابع main.

return n = exit(n)

#Think

رسیدن کنترل به پایان تابع main و یا استفاده از return; بدون هیچ مقداری باز هم باعث می‌شود تا فراخواننده‌ی تابع main سیستم کال _exit را صدا بزند اما نتیجه‌ی این کار بسته به C استاندارد مورد استفاده و آپشن‌هایی که در فرایند کامپایل مورد استفاده قرار گرفته‌اند فرق می‌کند:

  • در چنین موقعیت‌هایی در C89 نتیجه تعریف نشده است. برنامه با یک مقدار status که به صورت arbitrary است خاتمه می‌یابد. عدد بازگشتی از یک مقدار تصادفی در استک و یا مقدار یک رجیستر برداشت می‌شود. از این نوع خاتمه دادن به برنامه باید اجتناب کرد.
  • استاندارد C99 می‌گوید که رسیدن کنترل به پایان تابع main باید معادل فراخوانی exit(0); باشد.در این استاندارد در صورت نوشتن return حتما باید مقدار بازگشتی در جلوی آن ذکر شود.

این برنامه‌ی ساده را با استاندارد‌های C89 و C99 کامپایل کنید و status code بازگشت داده شده به shell را بررسی کنید. یک نمونه کامپایل و اجرا و بررسی status code را در کادر زیر می‌بینید:

$ gcc -std=c89 return_1.c
$ ./a.out
$ echo $?
41

در استاندارد C89 کامنت به صورت // this line is comment درست نیست و خطا می‌دهد.

چه در صورت normal termination و چه در صورت abnormal termination کارهای زیر انجام می‌شود. #Think

در تصویر بالا، سومین نکته از پایین می‌گوید که اگر در جریان terminate شدن یک پراسس یک process group یتیم شود و در این گروه حداقل یک پراسس STOP شده وجود داشته باشد آنگاه به تمام پراسس‌های آن گروه فارغ از اینکه STOP شده‌اند یا نه اول یک سیگنال SIGCONT و در ادامه یک سیگنال SIGHUP فرستاده می‌شود. برای درک موضوع این مثال را بررسی کنید.

در مستندات قدیمی System V به exit handler ها program termination routines می‌گفتند.

exit handler یک تابع نوشته شده توسط برنامه‌نویس است که در هر زمانی از طول عمر پراسس می‌تواند رجیستر شود و در هنگام خاتمه‌ی طبیعی یا normal termination پراسس از طریق تابع کتابخانه‌ای exit به صورت اتوماتیک فراخوانده می‌شود. اگر مستقیما از سیستم کال _exit() برای خاتمه‌ی پراسس استفاده کنیم و یا پراسس با دریافت سیگنال terminate شود exit handler اجرا نخواهد شد.

تابع کتابخانه‌ای exit تابع async-signal-safe نیست و نباید در داخل signal handler ها استفاده شود. برای اطلاعات بیشتر به صفحه‌ی ۴۲۶ مراجعه کنید. #Think

این موضوع که exit handler ها در صورت خاتمه‌ی برنامه با دریافت سیگنال فراخوانی نمی‌شوند تا حدودی عملکرد آن‌ها را محدود می‌کند. بهترین روش برای حفظ توامان این دو ویژگی cacth کردن سیگنال‌هایی است که ممکن است به برنامه فرستاده شوند و سپس در signal handler آنها یک flag را ست کنیم که برنامه‌ی اصلی را وادارد که تابع exit را صدا بزند. البته حتی این راهکار هم برای SIGKILL کارا نیست چون این سیگنال catch نمی‌شود و disposition آن را نمی‌توان تغییر داد. این هم دلیل دیگری بر این است که نباید از SIGKILL برای خاتمه دادن به پراسس استفاده کنیم. به جای آن از سیگنال SIGTERM استفاده کنید که سیگنال پیش فرض دستور kill(1) است.

تابع کتابخانه‌ای atexit(3)، تابع داده شده به عنوان آرگومان را به لیست توابعی که باید در هنگام خاتمه‌ی پراسس فراخوانی شوند اضافه می‌کند. تابع داده شده نه آرگومانی می‌گیرد و نه مقداری برمی‌گرداند.

#include <stdlib.h>

int atexit(void (*func)(void));

    Returns 0 on success, or nonzero on error

می‌توان چندین exit handler رجیستر کرد و حتی می‌توان یک exit handler را چند بار ثبت کرد. وقتی برنامه تابع کتابخانه‌ای exit(3) را فرا می‌خواند این توابع به عکس ترتیب شدنشان اجرا می‌شوند. در داخل exit handler هر کاری می‌توان کرد از جمله ثبت exit handler جدید که در بالای لیست handler های باقیمانده برای اجرا قرار می‌گیرد.

تمام موارد فوق را در قالب این مثال نشان داده‌ایم.

اگر یکی از exit handler ها نتواند برگردد یا return نکند، خواه به این خاطر که _exit() را فراخوانی کرده باشد، خواه به خاطر اینکه پراسس به خاطر یک سیگنال خاتمه یافته باشد (مثلا exit handler با تابع کتابخانه‌ای raise(3) به پراسس خودش سیگنال زده باشد) در این صورت باقیمانده‌ی exit handler ها اجرا نخواهند شد. علاوه بر این اعمال باقی‌مانده‌ای که به صورت طبیعی توسط exit(3) انجام می‌شود (مثل flush کردن بافرهای stdio) انجام نخواهد شد. مثال

برنامه‌های portable باید از استفاده از تابع exit در داخل exit handler ها اجتناب کنند. لینوکس exit handler های باقیمانده را به صورت طبیعی اجرا می‌کند ولی این کار در بعضی دیگر از سیستم‌ها باعث می‌شود تا exit handler ها از ابتدا مجددا اجرا شوند که به یک حلقه‌ی پایان‌ناپذیر می‌انجامد. (البته stackoverflow باعث kill شدن برنامه می‌شود.)

مثال از مورد بالا. در FreeBSD هم عملکرد مشابه لینوکس بود و exit در داخل exit handler نادیده گرفته می‌شد.

راهی برای پیدا کردن اینکه تا کنون چند exit handler رجیستر کرده‌ایم وجود ندارد.

با تابع کتابخانه‌ای sysconf(3) و ثابت _SC_ATEXIT_MAX می توانیم حداکثر تعداد exit handler که سیستم برای رجیستر کردن پشتیبانی می‌کند را به دست بیاوریم. در واقع glibc برای پیاده‌سازی exit handler ها از linked list استفاده می‌کند و لذا مجازا تعداد بی‌نهایت exit handler می‌توان نصب کرد. در لینوکس این تعداد ۲،۱۴۷،۴۸۲،۶۴۷ است که بزرگترین عدد integer ۳۲ بیتی علامتدار است ولی در واقع شاید قبل از رسیدن به این عدد با کمبود حافظه روبرو شویم. مثال

پراسس فرزند بعد از fork تمام exit handler های ثبت شده برای پدرش را به ارث می‌برد ولی در صورت exec() کردن همه‌شان حذف می‌شوند چون کل فضای آدرس پراسس (تمام سگمنت‌های برنامه) پاک می‌شود و در اختیار برنامه‌ی جدید قرار می‌گیرد. مثال

بعد از نصب نمی‌توان exit handler را از نصب خارج کرد ولی می‌توان متغیری سراسری تعریف کرد و مثلا درست بودن مقدار آن به معنای فعال بودن exit handler و نادرست بودن مقدارش به معنای غیر فعال بودن آن باشد. بدیهی است چک کردن این فلگ باید در ابتدای تابع exit handler انجام شود. مثال

exit handler هایی که با تابع atexit(3) نصب می‌شوند دو محدودیت بزرگ دارند:

  1. از status ارسال شده به تابع exit() خبر ندارند.
  2. قادر به دریافت آرگومان ورودی نیستند.

glibc برای مرتفع کردن این دو محدودیت تابع غیر استاندارد on_exit(3) را به منظور نصب exit handler معرفی کرده است.

#define _BSD_SOURCE         /* or: #define _SVID_SOURCE */

#include <stdlib.h>

int on_exit(void (*func)(int, void *), void *arg);

        Returns 0 on success, or nonzero on error

آرگومان func تابع on_exit(3) اشاره‌گر به تابعی با قالب زیر است:

void
func(int status, void *arg)
{
    /* perform cleanup actions */
}

با توجه به این که آرگومان دوم تابع on_exit از نوع void * است، هر مقداری را می‌توانیم به exit handler ای که رجیستر می‌کنیم ارسال کنیم، فقط لازم است در تابع رجیستر شده type cast لازم را انجام دهیم. مثال

تابع on_exit هم مانند تابع atexit در صورت عدم موفقیت یک مقدار غیر صفر که لزوما -1 نیست برمی‌گرداند. همچنین چندین exit handler را می‌توان با فراخوانی‌های مکرر تابع on_exit ثبت کرد. توابع ثبت شده با on_exit و atexit در یک لیست نگهداری می‌شوند و به عکس ترتیب ثبت شدنشان اجرا می‌شوند. مثال

اگرچه on_exit بسیار منعطف‌تر از atexit است ولی هیچ استانداردی آن را تایید نمی‌کند و در پیاده‌سازی‌های اندکی از UNIX وجود دارد، لذا در برنامه‌هایی که قصد دارید قابل حمل باشند باید از کاربرد آن اجتناب کنید.

یک مثال دیگر از exit handler ها و حاوی یک نکته‌ی علامتگذاری شده در متن برنامه

بافرهای stdio در فضای کاربر حافظه‌ی پراسس یا به عبارت دیگر در process's user-space memory مدیریت می‌شوند و لذا هنگام fork کردن آنها هم توسط سیستم کال fork برای پراسس فرزند کپی می‌شوند.

وقتی خروجی استاندارد به ترمینال ارسال می‌شود به صورت پیش فرض line-buffered است یعنی محتوای بافر به محض قرار گرفتن یک کاراکتر خط جدید یا \n در خروجی استاندارد flush می‌شود ولی وقتی خروجی استاندارد به سمت یک فایل ارسال می‌شود به صورت پیش‌فرض block-buffered است. این مثال را وقتی که خروجی در ترمینال است و وقتی خروجی به سمت فایل هدایت می‌شود بررسی کنید و تفاوت را ببینید.

  • به عنوان راه حل برای چاپ نکردن چندباره‌ی اطلاعات در پدر و فرزندان می‌توان قبل از fork بافرهای stdio را با تابع کتابخانه‌ای fflush(3) خالی کرد. به عنوان راه حل جایگزین می‌توان بافر کردن stream های stdio را با setbuf(3) و setvbuf(3) غیر فعال کرد.
  • به عنوان راه حل دیگر فرزند به جای فراخوانی تابع کتابخانه‌ای exit، سیستم کال _exit را صدا بزند. در این صورت بافرهای stdio دیگر flush نمی‌شوند. اینجا یک قاعده‌ی عمومی مطرح می‌شود. در اپلیکیشنی که چندین پراسس فرزند fork می‌کند فقط یکی از پراسس‌ها باید تابع exit را فراخوانی کند و بقیه پراسس‌ها باید با فراخوانی سیستم کال _exit خاتمه یابند. این اقدام ما را مطمئن می‌سازد که تنها یک پراسس exit handler ها را صدا می‌زند و بافرهای stdio را خالی می‌کند.

kernel buffer در مقابل user buffer است. سیستم کال write(2) داده را مستقیما در بافر کرنل کپی می‌کند ولی توابع stdio داده‌ها را در بافرهای فضای user نگهداری می‌کنند و بعدا آن را در kernel buffer می‌نویسند. kernel buffer در هنگام fork، duplicate نمی‌شود.

در واقع هنگامی که توابع stdio را همراه با سیستم کال‌هایی که عملیات I/O انجام می‌دهند استفاده می‌کنیم باید دقت زیادی را مبذول کنیم. (مطالعه‌ی بیشتر فصل ۱۳، صفحه‌ی ۲۴۸ کتاب)

در این مثال خروجی را بررسی کنید. محل قرارگیری کاراکتر خط جدید یا \n را به دقت ملاحظه کنید. خروجی را به فایل هدایت کنید و تغییر در خروجی را ببینید.

تابع exit بافرهای stdio را flush می‌کند.

سیستم کال _exit(2) بافرهای stdio را flush نمی‌کند. مثال

خاتمه یافتن abnormal یک پراسس در نتیجه‌ی تحویل چند سیگنال مشخص است. بعضی از این سیگنال‌ها علاوه بر abnormal termination برنامه یک فایل core dump هم ایجاد می‌کنند.

۸ بیت پایین آرگومان status ارسالی به سیستم کال _exit و یا تابع کتابخانه‌ای exit، termination status پراسس را تعیین می‌کند.

normal termination یک پراسس در نتیجه‌ی فراخوانی سیستم کال _exit(2) و یا تابع کتابخانه‌ای exit(3) اتفاق می‌افتد. تابع exit بر روی سیستم کال _exit نوشته شده است.

کرنل چه در زمان خاتمه‌ی طبیعی و چه در زمان خاتمه‌ی غیر طبیعی پراسس کارهای مشترکی را انجام می‌دهد. خاتمه‌ی طبیعی برنامه با تابع کتابخانه‌ای exit باعث می‌شود تا علاوه بر آن کارها exit handler های نصب شده با توابع atexit و on_exit به ترتیب عکس ثبتشان فراخوانی شوند و بافرهای stdio نیز flush شوند.

در صفحه‌ی ۵۳۹ پرسیده شده است که اگر پراسس فرزند exit(-1); را صدا بزند بر مبنای ماکروی WEXITSTATUS چه exit status ای توسط پراسس پدر دیده می‌شود؟ جواب ۲۵۵ است. صحت جواب را با این برنامه بررسی کنید.

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

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

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

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

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