پروسسهای لینوکس از درون: fork، exec، سیگنالها و زامبیها
وقتی یک دستور را اجرا میکنید واقعاً چه اتفاقی میافتد: fork و exec، کدهای خروج و wait، اینکه سیگنالها چطور برنامهها را متوقف میکنند (و چطور مؤدبانه خاموش شویم)، زامبیها از کجا میآیند و چرا PID 1 در کانتینر Ctrl+C شما را نادیده میگیرد.
در این صفحه
ls را تایپ میکنید و Enter را میزنید. فهرستی از فایلها ظاهر میشود و اعلانِ خط فرمان برمیگردد. پشت این ماجرا، کرنل یک پروسس ساخته، برنامهاش را عوض کرده، وضعیتِ خروجش را جمع کرده و بعد از آن تمیزکاری کرده؛ فقط با چند فراخوانیِ سیستمی که همهٔ برنامههای لینوکس به آنها تکیه دارند.
دانستنِ این فراخوانیها خیلی چیزها را توضیح میدهد که در غیر این صورت مرموز به نظر میرسند: چرا cd نمیتواند یک برنامهٔ بیرونی باشد، کد خروج ۱۳۷ چه معنایی دارد، چرا kill -9 باید آخرین چارهتان باشد، پروسسهای زامبی از کجا میآیند و چرا یک کانتینر گاهی دقیقاً ده ثانیه docker stop را نادیده میگیرد. مثالها از ماژول os پایتون استفاده میکنند که تقریباً یکبهیک به فراخوانیهای سیستمیِ زیرین نگاشت میشود، بهعلاوهٔ ابزارهای استانداردی مثل ps. اگر چند اسکریپت شل نوشته باشید بهرهٔ بیشتری میبرید؛ نوشتن اسکریپتهای Bash که خراب نمیشوند را ببینید.
فراخوانیهای fork و exec: هر برنامهای اینطور شروع میشود#
لینوکس پروسسهای جدید را در دو مرحلهٔ جدا میسازد:
fork()یک کپی از پروسسِ فراخواننده میسازد. والد و فرزند از همان خطِ کد ادامه میدهند، با همان محتوای حافظه، فایلهای باز و متغیرهای محیطی. تنها تفاوت، مقدارِ بازگشتیِfork()است: در فرزند0و در والد، PID فرزند.exec()برنامهای را که در پروسسِ فعلی اجرا میشود با یک برنامهٔ جدید جایگزین میکند. PID همان میماند؛ کد، حافظه و پشته دور ریخته میشوند و از فایل اجراییِ جدید از نو بارگذاری میشوند.
شل ls را اینطور اجرا میکند: خودش را fork میکند، بعد فرزند ls را exec میکند و والد منتظر میماند. همین کار در پایتون:
import os
print(f"parent: my PID is {os.getpid()}", flush=True)
pid = os.fork()
if pid == 0:
# Child: a copy of the parent. Replace it with the `ls` program.
print(f"child: my PID is {os.getpid()}, my parent is {os.getppid()}", flush=True)
os.execvp("ls", ["ls", "-d", "/tmp"])
print("never printed: exec only returns if it fails")
else:
# Parent: wait for the child and collect its exit status.
_, status = os.waitpid(pid, 0)
print(f"parent: child {pid} exited with code {os.waitstatus_to_exitcode(status)}")$ python3 forkexec.py
parent: my PID is 726920
child: my PID is 726922, my parent is 726920
/tmp
parent: child 726922 exited with code 0چرا flush=True مهم است
exec کلِ برنامه را جایگزین میکند، از جمله بافرِ خروجیِ پایتون را. اگر خروجی به یک پایپ یا فایل برود بافر میشود، و هر چیزی که هنوز flush نشده بیصدا از دست میرود. همین برای printf زبان C هم صدق میکند. قبل از fork یا exec، خروجی را flush کنید.
جدا بودنِ این دو فراخوانی همان چیزی است که شلهای یونیکس را اینقدر انعطافپذیر میکند. بین fork و exec، فرزند هنوز کدِ شل را اجرا میکند؛ پس میتواند اول محیطِ خودش را مرتب کند: توصیفگرهای فایل (file descriptor) را برای > file یا | جابهجا کند، پوشه را عوض کند و متغیرهای محیطی را تنظیم کند. بعد برنامه را exec میکند که همهٔ اینها را به ارث میبرد.
این همچنین توضیح میدهد چرا cd باید یک دستورِ داخلیِ شل (builtin) باشد. یک پروسسِ فرزند فقط میتواند پوشهٔ کاریِ خودش را عوض کند، و این تغییر با خارج شدنش از بین میرود. برای عوض کردنِ پوشهٔ خودِ شل، شل باید خودش این کار را بکند.
کدهای خروج و wait#
وقتی یک پروسس تمام میشود، یک وضعیتِ خروج (exit status) برای والدش باقی میگذارد: طبق قرارداد ۰ برای موفقیت و ۱ تا ۲۵۵ برای شکست. والد آن را با wait() یا waitpid() جمع میکند و شل آن را در $? میگذارد.
اگر پروسس با یک سیگنال کشته شده باشد، شل 128 + شمارهٔ سیگنال را گزارش میکند. اینها مدام در لاگها و خروجیِ CI دیده میشوند:
| کد خروج | معنی |
|---|---|
0 |
موفقیت |
1، 2، … |
برنامه خطایی گزارش کرده (معنایش در هر برنامه فرق میکند) |
126 / 127 |
دستور پیدا شد اما اجرایی نیست / دستور پیدا نشد |
130 |
با SIGINT (۲) کشته شد: کسی Ctrl+C را زد |
137 |
با SIGKILL (۹) کشته شد: اغلب OOM killer (قاتلِ کمبود حافظه)، یا کانتینری که از سقف حافظهاش فراتر رفته |
143 |
با SIGTERM (۱۵) کشته شد: یک درخواستِ عادیِ خاموش شدن، مثلاً docker stop یا یک دیپلوی |
سیگنالها: خبر دادن به یک پروسس#
سیگنال یک اعلانِ کوچک و ناهمگام است که کرنل یا پروسسِ دیگری (با kill) به یک پروسس میفرستد. با وجود اسمش، kill هر سیگنالی را میفرستد؛ SIGTERM فقط پیشفرض است. آنهایی که بیشتر میبینید:
| سیگنال | شماره | رفتار پیشفرض | منبع معمول |
|---|---|---|---|
| SIGHUP | 1 | پایان دادن | ترمینال بسته شده؛ خیلی از دیمنها با آن تنظیماتشان را دوباره بارگذاری میکنند |
| SIGINT | 2 | پایان دادن | Ctrl+C |
| SIGQUIT | 3 | پایان دادن + core dump | Ctrl+\ |
| SIGKILL | 9 | پایان دادن، قابل گرفتن نیست | kill -9، OOM killer |
| SIGTERM | 15 | پایان دادن | kill، systemctl stop، docker stop |
| SIGCHLD | 17 | نادیده گرفتن | یک پروسسِ فرزند خارج یا متوقف شده |
| SIGSTOP / SIGTSTP | 19 / 20 | مکث | kill -STOP / Ctrl+Z |
(شمارهها مربوط به لینوکس روی x86 و ARM هستند؛ برای دیدنِ فهرست کامل روی سیستم خودتان kill -l را اجرا کنید.)
یک پروسس میتواند بیشترِ سیگنالها را با تابعِ خودش مدیریت کند یا نادیده بگیرد. دو سیگنال هرگز قابل گرفتن یا نادیده گرفتن نیستند: SIGKILL و SIGSTOP. کرنل مستقیماً روی آنها عمل میکند.
خاموش شدنِ مؤدبانه#
به همین دلیل باید اول SIGTERM بفرستید و SIGKILL را فقط بهعنوان آخرین چاره به کار ببرید. SIGTERM اجازه میدهد برنامه درخواستی را که در حال پاسخ دادن به آن است تمام کند، دادهها را روی دیسک بنویسد، اتصالهای پایگاه داده را ببندد و فایلهای قفل را پاک کند. SIGKILL آن را وسطِ یک دستورالعمل متوقف میکند، بدون هیچ فرصتی برای تمیزکاری.
مدیریتِ SIGTERM چند خط بیشتر نیست:
import signal, time
running = True
def stop(signum, frame):
global running
print(f"got {signal.Signals(signum).name}, finishing the current job...", flush=True)
running = False
signal.signal(signal.SIGTERM, stop)
while running:
time.sleep(1) # pretend to work
print("clean exit", flush=True)$ python3 worker.py &
$ kill %1
got SIGTERM, finishing the current job...
clean exitتابعِ مدیریتکننده فقط یک پرچم را تنظیم میکند؛ حلقهٔ اصلی آن را بررسی میکند و در یک نقطهٔ امن خارج میشود. انجام دادنِ کارِ واقعی داخلِ یک signal handler پرخطر است، چون میتواند برنامهٔ شما را هر جایی قطع کند. اسکریپتهای Bash همین توانایی را با trap 'cleanup' TERM دارند.
مدیرهای سرویس هم از همین الگو پیروی میکنند: systemctl stop سیگنال SIGTERM میفرستد، صبر میکند (بهطور پیشفرض ۹۰ ثانیه) و بعد SIGKILL میفرستد. docker stop همین کار را با یک مهلتِ ۱۰ ثانیهای انجام میدهد.
زامبیها#
وقتی یک فرزند خارج میشود، کرنل هنوز نمیتواند آن را کاملاً دور بریزد: ممکن است والد هنوز وضعیتِ خروجش را بخواهد. پس پروسس به یک زامبی تبدیل میشود. دیگر حافظه یا فایلِ بازی ندارد، فقط یک ورودی در جدول پروسسها که PID و وضعیتِ خروجش را نگه میدارد و منتظر است والد wait() را صدا بزند. به جمعآوریِ این وضعیت درو کردن (reaping) میگویند و زامبی را حذف میکند.
والدی که هرگز wait() را صدا نمیزند، زامبیها را پشت سر جا میگذارد. بیایید یکی بسازیم:
import os, time
pid = os.fork()
if pid == 0:
os._exit(0) # the child exits immediately...
print(f"child PID: {pid}")
time.sleep(60) # ...but the parent never calls wait()$ python3 zombie.py &
child PID: 726905
$ ps -o pid,ppid,stat,cmd --ppid $!
PID PPID STAT CMD
726905 726903 Z [python3] <defunct>حرف Z در ستونِ STAT و عبارتِ <defunct> نشانهٔ زامبیاند. چند زامبی بیخطرند، اما هر کدام یک PID را اشغال میکنند، و یک سرورِ باگدار که زامبی نشت میدهد در نهایت میتواند سقفِ PIDها را پر کند؛ از آن لحظه هیچ چیزی روی آن ماشین نمیتواند پروسسِ جدیدی شروع کند.
نمیتوانید یک زامبی را بکُشید: از قبل مرده است، پس سیگنالها اثری ندارند. والد را درست کنید تا wait() را صدا بزند، یا والد را متوقف کنید. وقتی یک والد میمیرد، فرزندانش (زنده یا زامبی) به PID 1 سپرده میشوند (یا به نزدیکترین پروسسی که بهعنوان «subreaper» علامت خورده)، و آن پروسس خودکار آنها را درو میکند. روی یک سیستمِ معمولی PID 1 همان systemd است و در درو کردن خیلی خوب عمل میکند.
پروسسِ PID 1 خاص است، و کانتینرها آن را به مشکلِ شما تبدیل میکنند#
داخل یک کانتینر systemdی وجود ندارد. هر دستوری که ایمیج اجرا میکند، در PID namespace کانتینر PID 1 میشود، و PID 1 دو مسئولیتِ اضافه دارد:
- کرنل رفتارهای پیشفرضِ سیگنال را روی آن اعمال نمیکند. سیگنالی که PID 1 برایش handler نصب نکرده باشد بهسادگی نادیده گرفته میشود. برنامهٔ معمولیای که به رفتارِ پیشفرضِ «پایان دادن»ِ SIGTERM تکیه دارد،
docker stopرا نادیده میگیرد، ده ثانیهٔ مهلت را همانجا مینشیند و بعد با SIGKILL کشته میشود. - همهٔ یتیمها را به ارث میبرد؛ پس باید آنها را درو کند، وگرنه زامبیها انباشته میشوند.
اولی را بدون Docker هم میتوانید ببینید. unshare یک برنامه را بهعنوان PID 1 در یک PID namespace تازه اجرا میکند:
$ sudo unshare --fork --pid --mount-proc python3 -c 'import time; time.sleep(30)' &
$ pid=$(pgrep -n -f 'time.sleep') # the Python process, as seen from outside
$ sudo kill -TERM "$pid" # nothing happens: PID 1 has no SIGTERM handler
$ sudo kill -KILL "$pid" # this works: SIGKILL from the parent namespaceراهحلها سادهاند:
- یک پروسسِ init کوچک را بهعنوان PID 1 اجرا کنید:
docker run --initابزار tini را اضافه میکند که سیگنالها را به برنامهٔ شما میرساند و زامبیها را درو میکند. کاربرانِ Kubernetes میتوانندtini(یاdumb-init) را بهعنوان entrypoint به ایمیج اضافه کنند. - کاری کنید برنامهتان SIGTERM را صریحاً مدیریت کند، مثل
worker.pyدر بالا. - در Dockerfile از شکلِ exec استفاده کنید، یعنی
CMD ["python3", "app.py"]، نهCMD python3 app.py. شکلِ shell دستورِ/bin/sh -c "python3 app.py"را اجرا میکند؛ پس شل PID 1 میشود و SIGTERM را به برنامهٔ شما نمیرساند. - اسکریپتهای entrypoint را با
exec "$@"تمام کنید.execشل را با برنامهٔ شما جایگزین میکند (همان exec بالا)؛ پس برنامهتان PID 1 میشود و سیگنالها را مستقیماً دریافت میکند.
بررسیِ پروسسها#
هر پروسس یک پوشه زیرِ /proc/<PID>/ دارد و بیشترِ ابزارها فقط از آن میخوانند:
ps -eo pid,ppid,stat,etime,cmd --forest # every process, as a tree
pstree -p # a compact tree with PIDs
cat /proc/1234/status # state, parent, memory, threads, signal masks
tr '\0' ' ' < /proc/1234/cmdline # the full command line
ls -l /proc/1234/fd # open files, sockets and pipes
cat /proc/1234/environ | tr '\0' '\n' # its environment (needs permission)حرفِ اولِ ستونِ STAT میگوید یک پروسس مشغول چه کاری است:
| وضعیت | معنی |
|---|---|
R |
در حال اجرا، یا آمادهٔ اجرا |
S |
خواب، منتظرِ چیزی (بیشترِ پروسسها، بیشترِ وقتها) |
D |
خوابِ وقفهناپذیر، معمولاً منتظرِ دیسک یا ذخیرهسازِ شبکهای؛ حتی SIGKILL هم تا برگشتنش صبر میکند |
T |
متوقفشده (SIGSTOP، Ctrl+Z یا یک دیباگر) |
Z |
زامبی: خارج شده و منتظرِ درو شدن است |
پروسسی که در وضعیتِ D گیر کرده و کشته نمیشود، تقریباً همیشه نشاندهندهٔ یک مشکلِ ذخیرهسازی است (یک NFS mount هنگکرده، یک دیسکِ در حال خرابی)، نه مشکلی در خودِ برنامه.
جمعبندی#
- پروسسهای جدید با
fork()(کپی از پروسس فعلی) وexec()(جایگزین کردنِ برنامهاش) ساخته میشوند. فاصلهٔ بین این دو جایی است که شلها redirect و پایپها را آماده میکنند. - والد وضعیتِ خروج را با
wait()جمع میکند؛ کدهای خروجِ بالای ۱۲۸ یعنی «با سیگنالِ (کد − ۱۲۸) کشته شد»: 130 یعنی Ctrl+C، 137 یعنی SIGKILL/OOM و 143 یعنی SIGTERM. - SIGTERM بفرستید و به برنامهها فرصتِ خاموش شدن بدهید؛ SIGKILL قابل گرفتن نیست و همهٔ تمیزکاریها را رد میکند.
- زامبیها فرزندانِ خارجشدهای هستند که کسی درویشان نکرده؛ والد را درست کنید یا بکُشید، نه زامبی را.
- در کانتینرها، PID 1 سیگنالهایی را که مدیریت نمیکند نادیده میگیرد و باید یتیمها را درو کند: از
--init/tini،CMDبه شکلِ exec وexec "$@"در entrypointها استفاده کنید.