لینوکسپیشرفته

پروسس‌های لینوکس از درون: fork، exec، سیگنال‌ها و زامبی‌ها

وقتی یک دستور را اجرا می‌کنید واقعاً چه اتفاقی می‌افتد: fork و exec، کدهای خروج و wait، اینکه سیگنال‌ها چطور برنامه‌ها را متوقف می‌کنند (و چطور مؤدبانه خاموش شویم)، زامبی‌ها از کجا می‌آیند و چرا PID 1 در کانتینر Ctrl+C شما را نادیده می‌گیرد.

یک درخت پروسس: شل یک فرزند fork می‌کند، فرزند یک برنامهٔ جدید را exec می‌کند، خارج می‌شود و والدش آن را جمع‌آوری می‌کند
در این صفحه

ls را تایپ می‌کنید و Enter را می‌زنید. فهرستی از فایل‌ها ظاهر می‌شود و اعلانِ خط فرمان برمی‌گردد. پشت این ماجرا، کرنل یک پروسس ساخته، برنامه‌اش را عوض کرده، وضعیتِ خروجش را جمع کرده و بعد از آن تمیزکاری کرده؛ فقط با چند فراخوانیِ سیستمی که همهٔ برنامه‌های لینوکس به آن‌ها تکیه دارند.

دانستنِ این فراخوانی‌ها خیلی چیزها را توضیح می‌دهد که در غیر این صورت مرموز به نظر می‌رسند: چرا cd نمی‌تواند یک برنامهٔ بیرونی باشد، کد خروج ۱۳۷ چه معنایی دارد، چرا kill -9 باید آخرین چاره‌تان باشد، پروسس‌های زامبی از کجا می‌آیند و چرا یک کانتینر گاهی دقیقاً ده ثانیه docker stop را نادیده می‌گیرد. مثال‌ها از ماژول os پایتون استفاده می‌کنند که تقریباً یک‌به‌یک به فراخوانی‌های سیستمیِ زیرین نگاشت می‌شود، به‌علاوهٔ ابزارهای استانداردی مثل ps. اگر چند اسکریپت شل نوشته باشید بهرهٔ بیشتری می‌برید؛ نوشتن اسکریپت‌های Bash که خراب نمی‌شوند را ببینید.

فراخوانی‌های fork و exec: هر برنامه‌ای این‌طور شروع می‌شود#

لینوکس پروسس‌های جدید را در دو مرحلهٔ جدا می‌سازد:

  • fork() یک کپی از پروسسِ فراخواننده می‌سازد. والد و فرزند از همان خطِ کد ادامه می‌دهند، با همان محتوای حافظه، فایل‌های باز و متغیرهای محیطی. تنها تفاوت، مقدارِ بازگشتیِ fork() است: در فرزند 0 و در والد، PID فرزند.
  • exec() برنامه‌ای را که در پروسسِ فعلی اجرا می‌شود با یک برنامهٔ جدید جایگزین می‌کند. PID همان می‌ماند؛ کد، حافظه و پشته دور ریخته می‌شوند و از فایل اجراییِ جدید از نو بارگذاری می‌شوند.

شل ls را این‌طور اجرا می‌کند: خودش را fork می‌کند، بعد فرزند ls را exec می‌کند و والد منتظر می‌ماند. همین کار در پایتون:

forkexec.pyPython
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)}")
Text
$ 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) باشد. یک پروسسِ فرزند فقط می‌تواند پوشهٔ کاریِ خودش را عوض کند، و این تغییر با خارج شدنش از بین می‌رود. برای عوض کردنِ پوشهٔ خودِ شل، شل باید خودش این کار را بکند.

⁧والد⁩⁧فرزند⁩ bashPID 4100 fork() ⁧⁦wait()⁩: منتظر⁩ bashPID 4101 exec("ls") lsPID 4101 exit(0) ⁧زامبی⁩Z <defunct> ⁧درو شد⁩⁧وضعیت ۰⁩ bash$? = 0 ⁧⁦exec⁩ همان PID را نگه می‌دارد و برنامه را عوض می‌کند؛ زامبی تا وقتی والد ⁦wait()⁩ را صدا بزند باقی می‌ماند⁩
زندگیِ یک پروسس: شل (PID 4100) یک فرزند (PID 4101) را fork می‌کند، فرزند ls را exec می‌کند، ls با کد ۰ خارج می‌شود، فرزند تا وقتی شل wait را صدا بزند زامبی می‌ماند و بعد ناپدید می‌شود

کدهای خروج و 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 چند خط بیشتر نیست:

worker.pyPython
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)
Text
$ 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() را صدا نمی‌زند، زامبی‌ها را پشت سر جا می‌گذارد. بیایید یکی بسازیم:

zombie.pyPython
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()
Text
$ 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 دو مسئولیتِ اضافه دارد:

  1. کرنل رفتارهای پیش‌فرضِ سیگنال را روی آن اعمال نمی‌کند. سیگنالی که PID 1 برایش handler نصب نکرده باشد به‌سادگی نادیده گرفته می‌شود. برنامهٔ معمولی‌ای که به رفتارِ پیش‌فرضِ «پایان دادن»ِ SIGTERM تکیه دارد، docker stop را نادیده می‌گیرد، ده ثانیهٔ مهلت را همان‌جا می‌نشیند و بعد با SIGKILL کشته می‌شود.
  2. همهٔ یتیم‌ها را به ارث می‌برد؛ پس باید آن‌ها را درو کند، وگرنه زامبی‌ها انباشته می‌شوند.

اولی را بدون Docker هم می‌توانید ببینید. unshare یک برنامه را به‌عنوان PID 1 در یک PID namespace تازه اجرا می‌کند:

Bash
$ 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>/ دارد و بیشترِ ابزارها فقط از آن می‌خوانند:

Bash
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ها استفاده کنید.