Foundationপ্রথম নীতি থেকে
LEVEL 4লেসন ২৪/২৯কঠিন১ ঘণ্টা ১০ মিনিট

Signal — process-কে asynchronous ভাবে বাধা দেওয়ার প্রোটোকল

Signals

Ctrl-C চাপলে, kill কমান্ড দিলে, বা একটা প্রোগ্রাম ভুল মেমরিতে হাত দিলে — kernel সেই process-কে একটা signal পাঠায়, যা ঠিক hardware interrupt-এর মতোই normal control flow মাঝপথে থামিয়ে একটা handler চালায়। এই লেসনে standard signal set, default action, নিজের handler লেখা, আর সবচেয়ে বিপজ্জনক অংশ — handler-এর ভেতরে ঠিক কী করা নিরাপদ (async-signal-safety) — দেখব, আর কেন malloc() নিজেই সেখানে বিপজ্জনক তা কংক্রিটভাবে দেখব।

এই লেসন শেষে আপনি পারবেন

  • signal-কে কেন 'software interrupt' বলা হয় তা hardware interrupt-এর সাথে ধাপে ধাপে তুলনা করে ব্যাখ্যা করতে পারবেন — কে কাকে থামায়, কে handler লেখে, কোন privilege স্তরে
  • স্ট্যান্ডার্ড signal set-এর সংখ্যা ও নাম (SIGINT, SIGTERM, SIGKILL, SIGSEGV, SIGCHLD, SIGSTOP/SIGCONT), তাদের ডিফল্ট action (Term/Ign/Core/Stop/Cont), আর ঠিক কেন SIGKILL ও SIGSTOP কখনো catch/block/ignore করা যায় না তা যুক্তিসহ ব্যাখ্যা করতে পারবেন
  • signal() আর sigaction()-এর পার্থক্য বলতে পারবেন, এবং নিজে একটা সঠিক handler লিখতে পারবেন যা শুধু একটা volatile sig_atomic_t flag সেট করে সাথে সাথে return করে
  • async-signal-safety সংজ্ঞায়িত করতে পারবেন, malloc()-এর ভেতরের arena lock দিয়ে কংক্রিটভাবে দেখাতে পারবেন কেন সেটা handler-এর ভেতরে বিপজ্জনক, আর POSIX-এর নিরাপদ function-এর তালিকা চিনতে পারবেন
  • sigprocmask() দিয়ে signal block/unblock করতে পারবেন, pending বনাম blocked সেটের পার্থক্য ব্যাখ্যা করতে পারবেন, আর এটা কীভাবে পরের লেসনের critical-section সমাধানগুলোর একটা primitive পূর্বসূরি তা যুক্তি দিয়ে বলতে পারবেন
  • self-pipe trick নিজে বাস্তবায়ন করে দেখাতে পারবেন — কীভাবে asynchronous signal delivery-কে একটা normal event loop-এর synchronous I/O event-এ রূপান্তর করা যায়

আগে যা বোঝা থাকা দরকার

আগে এটা বুঝি

kernel-and-user-space লেসনের experiment-এ একটা wall.c প্রোগ্রাম লিখেছিলাম — privileged instruction চালানোর চেষ্টা করে দেখেছিলাম CPU কীভাবে সেগুলো আটকায়। সেই প্রোগ্রামে sigaction(SIGSEGV, &sa, NULL) লাইনটা ছিল, SA_SIGINFO আর si_code ব্যবহার করেছিলাম — কিন্তু তখন পুরো mechanism-টা ব্যাখ্যা ছাড়াই ব্যবহার করে গেছি, শুধু বলেছিলাম “এটা একটা signal handler রেজিস্টার করছে।” এই লেসনে সেই ঋণ শোধ হবে।

একটা প্রশ্ন দিয়ে শুরু করি যেটা রোজ ঘটে অথচ কখনো ভাবিনি: টার্মিনালে একটা প্রোগ্রাম চলছে, হয়তো একটা অসীম loop-এর মাঝখানে, কোনো I/O করছে না, কোনো syscall-এর জন্য অপেক্ষা করছে না — শুধু CPU-তে গণনা করে যাচ্ছে। আপনি Ctrl-C চাপলেন। প্রোগ্রামটা সাথে সাথে থেমে গেল। কীভাবে? প্রোগ্রামটা তো কিছুই “শুনছিল” না — সে read()-এ ব্লক হয়ে বসে ছিল না, কোনো fd poll করছিল না। তবু বাইরের একটা keypress তার নিয়ন্ত্রণ কেড়ে নিল।

এতদিন যে IPC মেকানিজমগুলো দেখেছি — pipe, shared memory, message queue — সবগুলোর একটা common assumption ছিল: গ্রহীতা (receiver) সক্রিয়ভাবে কিছু একটা করছে — read() কল করছে, বা shared memory-তে একটা flag পোল করছে। কিন্তু Ctrl-C-এর প্রোগ্রামটা কিছুই করছিল না, কোনো IPC primitive-এর জন্য অপেক্ষায়ও ছিল না। তবু বার্তাটা পৌঁছাল। এটাই signal-কে বাকি সব IPC থেকে আলাদা করে — এটা receiver-এর সহযোগিতা ছাড়াই, তার normal control flow জোর করে থামিয়ে দিতে পারে।

interrupts-and-drivers লেসনের প্রথম লাইনটা মনে করুন: “device নিজে থেকে CPU-কে বলে ‘আমার হয়ে গেছে’ — CPU যা করছিল তা মাঝপথে থামিয়ে একটা নির্দিষ্ট কোড চালাতে বাধ্য হয়, তারপর যেখানে ছিল সেখানেই ফিরে যায়।” এখন ঠিক একই বাক্যটা আরেকটা স্তরে আবার লিখব — শুধু “device” আর “CPU”-এর জায়গায় বসবে “kernel” আর “process”: kernel নিজে থেকে process-কে বলে “কিছু ঘটেছে” — process যা করছিল তা মাঝপথে থামিয়ে একটা নির্দিষ্ট handler চালাতে বাধ্য হয়, তারপর (সাধারণত) যেখানে ছিল সেখানেই ফিরে যায়। এটাই একটা signal — একটা software interrupt, hardware interrupt-এর ঠিক এক স্তর উপরে।

এই লেসনে সেই সমান্তরালতাটা পুরোপুরি খুলে দেখব — standard signal set, কেন কিছু signal কখনো আটকানো যায় না, নিজের হাতে নিরাপদ handler লেখা, আর সবচেয়ে গুরুত্বপূর্ণ অংশ: handler-এর ভেতরে ঠিক কী করা নিরাপদ, আর কেন malloc()-এর মতো নিরীহ-দেখতে একটা ফাংশনও সেখানে বিপজ্জনক।

মূল ধারণা

Signal = process-স্তরের interrupt — সরাসরি তুলনা

সমান্তরালতাটা টেবিলে দেখলে পরিষ্কার হয়:

Hardware interrupt (interrupts-and-drivers লেসন)Signal
কে থামেCPU (এক বা একাধিক core)একটা নির্দিষ্ট process
কে থামায়Device বা timerkernel — hardware exception, অন্য process-এর kill(), বা kernel event-এর প্রতিক্রিয়ায়
রুটিং টেবিলIDT/vector table — kernel-এর, পুরো সিস্টেমে একটাইsigaction টেবিল — প্রতিটা process-এর নিজস্ব, task_struct-এ
Handler কোথায় চলেring 0, kernel code, IF flag ক্লিয়ারring 3, user code — process নিজে যা রেজিস্টার করেছে
Default dispositionনেই যদি vector খালি — crashপ্রতিটা signal-এর built-in kernel default (Term/Ign/Core/Stop/Cont)
মাস্ক করা যায়?হ্যাঁ — IF flag, APIC mask registerহ্যাঁ — sigprocmask(), blocked set
সম্পূর্ণ বন্ধ করা যায়?না শুধু NMI ছাড়ানা শুধু SIGKILL/SIGSTOP ছাড়া

এই সমান্তরালতা কাকতালীয় না। computer-architecture module-এর interrupts-exceptions-traps লেসনের রেফারেন্স তালিকায় একটা লাইন ছিল — “হার্ডওয়্যার exception কীভাবে POSIX signal-এ রূপান্তরিত হয়।” এখন সেটা সরাসরি দেখছি: একটা divide-by-zero, hardware-এ যেটা একটা #DE exception, kernel সেটা ধরে user process-কে SIGFPE পাঠায়। একটা invalid memory access — hardware-এ page fault, kernel-and-user-space লেসনের wall.c experiment-এ যা দেখেছিলাম — kernel সেটাকে SIGSEGV-তে রূপান্তরিত করে পাঠায়। hardware exception আর OS signal একই ঘটনার দুইটা ভিন্ন স্তরের নাম।

কিন্তু signal শুধু hardware exception থেকেই আসে না — এটা সফটওয়্যার থেকেও আসতে পারে:

উৎসউদাহরণ
Hardware exception → kernel রূপান্তরঅবৈধ মেমরি → SIGSEGV, divide-by-zero → SIGFPE, illegal instruction → SIGILL
Terminal driverCtrl-CSIGINT, Ctrl-ZSIGTSTP, Ctrl-\SIGQUIT — foreground process group-এ পাঠানো হয়
kill() syscall (অন্য process থেকে)kill(pid, SIGTERM), শেলের kill কমান্ড
Kernel eventchild terminate/stop/continue → SIGCHLD, pipe-এর পড়ার প্রান্ত বন্ধ থাকতে write → SIGPIPE (pipes-and-fifos লেসনে দেখা)
Timeralarm()/setitimer() মেয়াদ শেষ → SIGALRM
প্রোগ্রাম নিজেraise(sig), abort() (→ SIGABRT)

Standard signal set — সংখ্যা আর নাম মুখস্থ করার মতো নয়, বোঝার মতো

Linux x86-64-এ kill -l চালালে এই তালিকা বেরোয় — প্রতিটা সংখ্যা স্থির, POSIX-নির্ধারিত (real-time রেঞ্জ ছাড়া):

 1) SIGHUP     2) SIGINT     3) SIGQUIT    4) SIGILL     5) SIGTRAP
 6) SIGABRT    7) SIGBUS     8) SIGFPE     9) SIGKILL   10) SIGUSR1
11) SIGSEGV   12) SIGUSR2   13) SIGPIPE   14) SIGALRM   15) SIGTERM
16) SIGSTKFLT 17) SIGCHLD   18) SIGCONT   19) SIGSTOP   20) SIGTSTP
21) SIGTTIN   22) SIGTTOU   23) SIGURG    24) SIGXCPU   25) SIGXFSZ
26) SIGVTALRM 27) SIGPROF   28) SIGWINCH  29) SIGIO     30) SIGPWR
31) SIGSYS   34) SIGRTMIN  ...  ...  ...              64) SIGRTMAX

এই লেসনের কেন্দ্রে যে ছয়টা:

#নামডিফল্ট actionমানে
2SIGINTTermInteractive interrupt — Ctrl-C, “থামো, দয়া করে”
15SIGTERMTermTerminate — kill কমান্ডের ডিফল্ট, “please exit”
9SIGKILLTermKill — কখনো catch/block/ignore করা যায় না
11SIGSEGVCoreSegmentation violation — অবৈধ মেমরি অ্যাক্সেস
17SIGCHLDIgnChild status বদলেছে — terminate/stop/continue
19/18SIGSTOP/SIGCONTStop/ContJob control — থামাও/আবার চালাও, SIGSTOP-ও un-catchable

পাঁচ রকম ডিফল্ট action

প্রতিটা signal-এর একটা kernel-নির্ধারিত ডিফল্ট আচরণ আছে, যদি প্রোগ্রাম নিজে কিছু না করে (handler রেজিস্টার না করে, SIG_IGN সেট না করে):

Actionকী ঘটে
TermProcess সাথে সাথে শেষ — কোনো core file না
Ignকিছুই ঘটে না — signal-টা কার্যত অদৃশ্য
CoreProcess শেষ, আর একটা core dump ফাইল লেখা হয় (debugging-এর জন্য, core বা core.<pid>)
StopProcess থামে (T state), পরে SIGCONT না আসা পর্যন্ত চলবে না
Contথামা process আবার চলতে শুরু করে

কেন SIGKILL আর SIGSTOP কখনো catch করা যায় না

বাকি প্রতিটা signal-এর জন্য kernel delivery-র সময় sigaction টেবিল দেখে — process কি নিজের handler বসিয়েছে, নাকি SIG_IGN করেছে, নাকি ডিফল্ট চলবে। কিন্তু SIGKILL (9) আর SIGSTOP (19) — kernel-এর signal-delivery কোডে এই দুইটার জন্য আলাদা, hard-coded শর্টকাট আছে যা পুরো “sigaction টেবিল চেক করো” ধাপটাই বাদ দেয়। sigaction(SIGKILL, &sa, NULL) কল করলে সরাসরি EINVAL ফেরত আসে — kernel এমনকি চেষ্টাটাই প্রত্যাখ্যান করে।

এটা কোনো accident না, ইচ্ছাকৃত ডিজাইন। ভাবুন: একটা process buggy, তার সব signal handler নষ্ট বা deadlocked, বা সে ইচ্ছাকৃতভাবে সব signal block করে রেখেছে (sigprocmask() দিয়ে, নিচে বিস্তারিত)। যদি প্রতিটা signal handler-এর মধ্য দিয়ে যেতে হতো, তাহলে একটা truly-broken process-কে থামানোর কোনো গ্যারান্টিড উপায়ই থাকত নাSIGKILL সেই গ্যারান্টি — একটা escape hatch যেটা কখনো ব্যর্থ হয় না, কারণ এটা process-এর নিজের কোড চালানোর কোনো সুযোগই দেয় না। kernel সরাসরি scheduler/task-management স্তরে process-টাকে terminate-এর জন্য চিহ্নিত করে, runqueue থেকে সরায়, exit path চালায় — কোনো user-space instruction চলার সুযোগই নেই।

SIGSTOP একই কারণে un-catchable — job control (Ctrl-Z, debugger attach) নির্ভরযোগ্যভাবে কাজ করতে হলে “থামাও” নির্দেশটাও গ্যারান্টিড হতে হবে, নাহলে একটা buggy process নিজেকে থামানো থেকে আটকাতে পারত।

Custom handler — signal() বনাম sigaction()

সবচেয়ে পুরনো, সবচেয়ে সরল API:

void handler(int sig) {
    printf("পেলাম signal %d\n", sig);
}
signal(SIGINT, handler);

এটা দেখতে সহজ, কিন্তু ঐতিহাসিকভাবে সমস্যাযুক্ত। মূল Unix (System V) সংস্করণে signal()-এর semantics ছিল “unreliable” — একটা handler একবার চললেই তার disposition ডিফল্টে রিসেট হয়ে যেত (মানে handler-কে নিজেই আবার নিজেকে রেজিস্টার করতে হতো, আর সেই দুই কাজের মাঝে একটা race window থাকত যেখানে দ্বিতীয় signal ডিফল্ট action চালিয়ে দিত)। BSD একটা “reliable” সংস্করণ আনল, আর POSIX শেষমেশ sigaction()-কে প্রামাণ্য, portable API হিসেবে দাঁড় করাল।

struct sigaction {
    void     (*sa_handler)(int);                          /* সরল handler */
    void     (*sa_sigaction)(int, siginfo_t *, void *);    /* SA_SIGINFO হলে এটা */
    sigset_t   sa_mask;      /* handler চলাকালীন আর কোন signal block থাকবে */
    int        sa_flags;     /* SA_SIGINFO, SA_RESTART, ... */
};

আধুনিক কোডে সবসময় sigaction(), কারণ এটা স্পষ্ট, predictable semantics দেয়: disposition রিসেট হয় না (handler নিজেকে আবার বসাতে হয় না), আর ডিফল্টে sa_mask-এ যা দেওয়া হয় তা এবং handler যে signal-টার জন্য চলছে সেটাও handler চলাকালীন স্বয়ংক্রিয়ভাবে block থাকে — যাতে একই signal-এর দুইটা instance একে অপরের ভেতরে recursively ঢুকে না পড়ে।

struct sigaction sa = {0};
sa.sa_handler = handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
sigaction(SIGINT, &sa, NULL);

kill(), raise(), আর process group

kill(pid, SIGTERM);      /* pid-কে পাঠাও */
kill(0, SIGTERM);        /* নিজের process group-এর সবাইকে */
kill(-pgid, SIGTERM);    /* নির্দিষ্ট process group-এর সবাইকে -- ঋণাত্মক pid মানে group */
raise(SIGABRT);          /* নিজেকে -- kill(getpid(), sig)-এর সংক্ষিপ্ত রূপ */

শেলের Ctrl-C আসলে kill(-fg_pgid, SIGINT)-এর সমতুল্য — terminal driver পুরো foreground process group-কে পাঠায়, শুধু একটা process-কে না। এইজন্যই একটা pipeline (cmd1 | cmd2 | cmd3) চলাকালীন Ctrl-C চাপলে তিনটা process-ই একসাথে থামে।

Disposition fork()-এ বাঁচে, exec()-এ আংশিক হারায়

fork-exec-wait লেসনে fork() আর exec()-এর সম্পূর্ণ আচরণ দেখা হয়ে গেছে — এখন সেই একই দুইটা syscall signal-এর জন্য কী করে সেটা যোগ করা যাক, কারণ নিয়মটা প্রায়ই ভুল অনুমান করা হয়।

fork()-এ — child সম্পূর্ণ signal disposition টেবিলের একটা কপি পায়, ঠিক যেভাবে সে বাকি address space-এর copy-on-write কপি পায় (page-faults-and-demand-paging লেসন)। parent-এ যদি SIGTERM-এর জন্য একটা custom handler বসানো থাকে, child শুরুতে ঠিক সেই একই handler-ই পাবে। blocked/pending set-ও কপি হয় (pending অবশ্য child-এর জন্য খালি শুরু হয়, কারণ pending কোনো নির্দিষ্ট process-এর ইতিহাস, উত্তরাধিকার না)।

exec()-এ — এখানেই আসল সূক্ষ্মতা। exec() পুরো process image প্রতিস্থাপন করে (code, data, heap, stack — fork-exec-wait লেসনের সেই আলোচনা), তাই একটা পুরনো handler function-এর ঠিকানা আর অর্থবহ থাকে না — নতুন বাইনারিতে সেই ঠিকানায় হয়তো সম্পূর্ণ ভিন্ন কোড বসে আছে। তাই নিয়ম:

Disposition (exec-এর আগে)exec-এর পরে
ডিফল্ট actionঅপরিবর্তিত থাকে
SIG_IGN (ইচ্ছাকৃতভাবে ignore করা)অপরিবর্তিত থাকে — ignore-ই থাকে
Custom handler functionডিফল্টে রিসেট হয় — পুরনো handler ঠিকানা অর্থহীন

SIG_IGN কেন বেঁচে যায়, handler কেন যায় না — এটা ইচ্ছাকৃত: “আমি এই signal-এ পাত্তা দিই না” একটা নীতিগত সিদ্ধান্ত যা নতুন প্রোগ্রামেও অর্থবহ থাকতে পারে (যেমন শেল pipeline-এ SIGPIPE-কে ইচ্ছাকৃতভাবে ignore করে রাখা কিছু ক্ষেত্রে), কিন্তু একটা function pointer একটা সম্পূর্ণ ভিন্ন বাইনারিতে অর্থহীন — সেটা রাখলে exec()-করা প্রোগ্রাম random code address-এ jump করে ফেলতে পারত।

blocked set (sigprocmask()-এর মাধ্যমে যা সেট করা) exec()-এর পরেও বেঁচে থাকে — এটাই কারণ শেল-লেখকদের সাবধান থাকতে হয়: parent shell যদি ভুলবশত কোনো signal block করে রাখে আর সেটা exec()-এর আগে unblock না করে, child process (যেমন vim বা less) সেই signal-টা পুরোপুরি ব্লক অবস্থাতেই শুরু করবে, নিজে কিছু না জেনেই — একটা বাস্তব, প্রায়ই ভোগা bug class।

SIGCHLD — fork-exec-wait লেসনের সেই wait()-এর ঘটনার সম্পূর্ণ রূপ

fork-exec-wait লেসনে wait()/waitpid() দেখেছিলাম — parent সক্রিয়ভাবে জিজ্ঞেস করছিল “child কি শেষ হয়েছে?”, প্রয়োজনে ব্লক হয়ে বসে থেকে। কিন্তু একটা shell বা process supervisor সবসময় child-দের নিয়ে ব্যস্ত থাকতে চায় না — অন্য কাজ করতে চায়, আর child মরলে/থামলে/আবার চালু হলে খবর পেতে চায়। SIGCHLD ঠিক সেই খবর — child terminate, stop, বা continue করলে kernel parent-কে SIGCHLD পাঠায় (ডিফল্ট action Ign, তাই না ধরলে চুপচাপ কিছুই দৃশ্যমান হয় না, কিন্তু child তখনও reap করা দরকার, নাহলে zombie জমে — গত লেসনের সেই সমস্যা)।

idiomatic প্যাটার্ন handler-এ শুধু flag সেট না করে সরাসরি reap করারও প্রলোভন হতে পারে, কিন্তু সঠিক pattern-টা এরকম:

static volatile sig_atomic_t child_exited = 0;

static void sigchld_handler(int sig) {
    child_exited = 1;          /* handler-এ শুধু এটুকু -- waitpid() নিচে main loop-এ */
}

/* main loop-এ: */
if (child_exited) {
    child_exited = 0;
    int status;
    pid_t pid;
    while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
        printf("child %d reaped\n", pid);
    }
}

WNOHANG-সহ loop-টা বাধ্যতামূলক, একবার wait() না — কারণটা নিচের “signal গোনে না” অংশে আসছে। এখানেই মূল বিন্দু: SIGCHLD handler নিজে waitpid() কল করাও ঠিক আছে (এটা POSIX-এ async-signal-safe, নিচের তালিকায় আছে), কিন্তু flag+main-loop প্যাটার্নটাই বেশি সাধারণ কারণ handler-এ যত কম কাজ, তত ভালো — সেই যুক্তিটাই এই লেসনের বাকি অংশ জুড়ে আসছে।

SIGSEGV — page-faults-and-demand-paging লেসনের decision tree-র সরাসরি ফলাফল

page-faults-and-demand-paging লেসনে page fault handler-এর একটা decision tree দেখেছিলাম:

১. ঠিকানা কোনো VMA-র ভেতরে?  না → SIGSEGV "invalid address"
২. VMA permission fault-এর ধরনকে অনুমতি দেয়?  না, COW-ও প্রযোজ্য না → SIGSEGV "protection violation"

এই দুইটা পথই SIGSEGV-তে গিয়ে শেষ হয় — kernel page fault handler থেকেই সরাসরি এই signal তৈরি হয়। kernel-and-user-space লেসনের wall.c experiment-এ যে si_code=1 (SEGV_MAPERR) দেখেছিলাম, সেটা ঠিক এই প্রথম case — NULL pointer dereference, কারণ ঠিকানা 0 কোনো VMA-র ভেতরেই নেই।

Handler-এর সবচেয়ে বিপজ্জনক অংশ — reentrancy

এখন লেসনের কেন্দ্রীয় সমস্যায় আসি। একটা handler function যেকোনো সাধারণ ফাংশনের মতোই দেখতে — কিন্তু একটা মৌলিক পার্থক্য আছে যা প্রায়ই ভুলে যাওয়া হয়: এটা আপনার প্রোগ্রামের মূল control flow-এর মাঝখানে, আক্ষরিক অর্থে যেকোনো instruction-এর ফাঁকে ঢুকে পড়তে পারে — কোনো ফাংশন কল-এর মাঝখানে, একটা struct আপডেটের মাঝখানে, এমনকি একটা library ফাংশনের ভেতরের কোনো lock/unlock-এর মাঝখানে।

এটা কংক্রিটভাবে দেখা যাক malloc() দিয়ে। fork-exec-wait লেসনে একটা সংশ্লিষ্ট bug দেখেছিলাম — threaded প্রোগ্রামে fork() করলে child-এ একটা arena lock “locked অবস্থায় জমে” যেতে পারে, কারণ যে thread সেটা release করত সে child-এ অস্তিত্বেই নেই। এখানে ঠিক একই root cause, ভিন্ন trigger:

মূল প্রোগ্রাম (main flow):          signal handler (হঠাৎ ঢুকে পড়ল):
  malloc(64) ডাকল
    arena lock নিল
    free-list-এর pointer আপডেট
    করছে -- অর্ধেক সম্পন্ন, তালিকা
    এই মুহূর্তে inconsistent
                                       [signal আসল, এই মুহূর্তেই]
                                       handler চলা শুরু করল
                                       handler ভেতরে log করতে
                                       malloc(32) ডাকল
                                         arena lock নেওয়ার চেষ্টা --
                                         কিন্তু lock-টা তো "locked"
                                         (মূল প্রোগ্রামের সেই
                                         অসমাপ্ত call-এর হাতে)
                                       --- চিরতরে আটকে গেল ---

glibc-র ptmalloc arena lock একটা সাধারণ non-recursive lock — কে ধরে আছে তার কোনো real “owner” ট্র্যাকিং নেই, শুধু locked/unlocked। handler যদি সেই একই thread-এ চলে (আর signal handler সবসময় সেই thread-এই চলে যাকে বাধাগ্রস্ত করেছে), আর সেই thread-ই lock-টা “আটকে বসে” আছে (কারণ তার call-টা মাঝপথে থেমে আছে, শেষ হয়নি) — তাহলে handler-এর malloc() কল একটা lock-এর জন্য অপেক্ষা করছে যেটা কখনো release হবে না, কারণ যে call সেটা release করত সে কখনো আবার চলার সুযোগই পাবে না (handler নিজেই আটকে আছে)। একটা perfect self-deadlock, একই thread-এর নিজের সাথে।

printf()-এর ভেতরেও ঠিক একই সমস্যা — এটা নিজেও internally malloc() (buffer allocation) আর একটা stdio lock ব্যবহার করে। এই ধরনের bug console-এ প্রায় কখনো ধরা পড়ে না — এটা মাসে একবার production-এ ঘটে, ঠিক তখন যখন signal-টা ঠিক সেই ন্যানোসেকেন্ড উইন্ডোয় এসে পড়ে যখন malloc() মাঝপথে ছিল — সবচেয়ে খারাপ, সবচেয়ে debug-করা-কঠিন সময়ে।

নিরাপদ প্যাটার্ন — flag সেট করো, বাকিটা main loop-এ

সমাধান সরল একটা নিয়মে নেমে আসে: handler-এ শুধু একটা flag সেট করো আর সাথে সাথে return করো। বাকি সব কাজ main loop-এ, normal execution context-এ করো, যেখানে malloc()/printf() ব্যবহার সম্পূর্ণ নিরাপদ।

#include <signal.h>
#include <stdio.h>
#include <unistd.h>

static volatile sig_atomic_t got_signal = 0;

static void handler(int sig) {
    got_signal = 1;             /* শুধু এটুকু, আর কিছু না */
}

int main(void) {
    struct sigaction sa = {0};
    sa.sa_handler = handler;
    sigemptyset(&sa.sa_mask);
    sigaction(SIGINT, &sa, NULL);

    while (!got_signal) {
        /* মূল কাজ -- এখানে malloc/printf/lock সব স্বাভাবিকভাবে ব্যবহার করা যায় */
    }
    printf("সিগন্যাল পেয়ে পরিষ্কারভাবে বের হচ্ছি\n");   /* এখানে printf() নিরাপদ -- handler-এ না */
    return 0;
}

volatile sig_atomic_t টাইপটা এখানে ইচ্ছাকৃত, দুইটা কারণে। sig_atomic_t নিশ্চিত করে read/write নিজেই atomic — কোনো torn write নেই (একটা long long বা struct-এর মাঝপথে handler ঢুকে পড়লে আধা-লেখা অবস্থা দেখতে পারত)। volatile কম্পাইলারকে বলে এই ভ্যারিয়েবলটা register-এ cache করে রাখা যাবে না — নাহলে compiler অপটিমাইজেশনে ধরে নিতে পারে while (!got_signal) কখনো বদলাবে না (কারণ loop-এর ভেতরে কোথাও এটাকে সরাসরি বদলাতে দেখছে না) আর পুরো লুপ-চেকটাই বাদ দিয়ে দিতে পারে।

Async-signal-safety — একটা নামকরণ করা, POSIX-নির্ধারিত ধারণা

উপরের সমস্যাটার একটা প্রামাণ্য নাম আছে। একটা ফাংশন async-signal-safe যদি POSIX গ্যারান্টি দেয় যে সেটা নিরাপদে signal handler-এর ভেতর থেকে ডাকা যায় — হয় কারণ এটা reentrant (কোনো shared mutable state/lock নেই), অথবা কারণ এটা signal-এর সাপেক্ষে atomic।

নিরাপদ (এই ধরনের একটা ছোট সেট)অনিরাপদ (বাকি প্রায় সবকিছু)
_exit(), write(), read()malloc(), free(), realloc()
signal(), sigaction(), sigprocmask()printf()/fprintf()/sprintf() — পুরো stdio family
kill(), raise(), alarm(), pause()strtok() — internal static state
fork(), execve(), _exit()localtime()/gmtime() — static buffer রিটার্ন করে
waitpid()qsort() — নিজে বিভিন্ন internal state ব্যবহার করতে পারে
sigemptyset()/sigaddset()/sigismember()যেকোনো ফাংশন যা internally malloc() করে

sigprocmask() — signal block করা, একটা primitive critical-section রক্ষা

যদি main flow-এ এমন একটা অংশ থাকে যেখানে shared data (যেটা handler-ও ছুঁতে পারে) touch হচ্ছে, আর সেটা কোনোভাবে interrupted হলে সমস্যা — সরাসরি সমাধান হলো সেই সময়টুকুর জন্য signal-টা block করে রাখা:

sigset_t block_set, old_set;
sigemptyset(&block_set);
sigaddset(&block_set, SIGINT);

sigprocmask(SIG_BLOCK, &block_set, &old_set);   /* এখন থেকে SIGINT আটকানো */
/* critical section -- shared_data touch করা এখন নিরাপদ */
sigprocmask(SIG_SETMASK, &old_set, NULL);        /* আগের mask ফিরিয়ে আনো */

Block করা মানে signal হারিয়ে যায় না — এটা pending হয়ে থাকে (নিচের hood সেকশনে বিস্তারিত), আর unblock হওয়ার সাথে সাথে deliver হয়। ব্যতিক্রম শুধু SIGKILL/SIGSTOP — আগের সেকশনে দেখা সেই hard-coded শর্টকাটের কারণে এরা masking-কেও পুরোপুরি bypass করে।

মাল্টি-থ্রেডেড প্রোগ্রামে sigprocmask() না, pthread_sigmask() ব্যবহার করতে হয় — কারণ Linux-এ প্রতিটা thread-এর নিজস্ব blocked set আছে (যদিও pending set process-জুড়ে শেয়ার্ড), threads লেসনের সেই per-thread state-এর ধারণার সরাসরি সম্প্রসারণ।

EINTR — signal যখন একটা blocking syscall-কে মাঝপথে থামায়

একটা শেষ বাস্তব সমস্যা বাকি, যেটা build সেকশনের self-pipe কোডে ইতিমধ্যে একবার নীরবে ব্যবহার করা হয়েছে। ধরুন প্রোগ্রাম একটা “slow” syscall-এ ব্লক হয়ে বসে আছে — read() কোনো pipe থেকে ডেটার অপেক্ষায় (pipes-and-fifos লেসন), select()/accept() কোনো নতুন connection-এর অপেক্ষায়। ঠিক তখন একটা signal আসে, handler চলে, ফিরে আসে। সেই থেমে-থাকা syscall-টা এখন কী করবে — নিজে থেকে আবার অপেক্ষা চালিয়ে যাবে, নাকি থেমে যাবে?

উত্তরটা নির্ভর করে SA_RESTART flag-এর উপর, যা sigaction()-এর sa_flags-এ দেওয়া যায়:

SA_RESTARTSlow syscall-এর আচরণ
না দেওয়া (ডিফল্ট)syscall সাথে সাথে -1 রিটার্ন করে, errno = EINTR — প্রোগ্রামকে নিজে চেক করে আবার কল করতে হয়
দেওয়া থাকলেkernel নিজেই syscall-টা আবার শুরু করে, প্রোগ্রাম কিছুই টের পায় না

এটাই ঠিক সেই একই “unreliable বনাম reliable signal” ইতিহাসের আরেকটা মাত্রা — পুরনো System V-তে সব signal EINTR দিত (প্রোগ্রামারকে সবসময় manually retry-loop লিখতে হতো), BSD স্বয়ংক্রিয় restart চালু করল। POSIX উভয় বিকল্পই রাখল, SA_RESTART দিয়ে বেছে নেওয়ার সুযোগসহ — তবে সব syscall restart হয় না, select(), poll(), read() ছোট timeout-এর সাথে — এদের জন্য SA_RESTART থাকলেও প্রায়ই EINTR আসতে পারে, তাই সাবধানী কোড সবসময় EINTR চেক করে retry করে, SA_RESTART-এর উপর ভরসা না করে:

ssize_t n;
do {
    n = read(fd, buf, sizeof(buf));
} while (n == -1 && errno == EINTR);   /* signal-এ বাধাগ্রস্ত হলে আবার চেষ্টা */

build সেকশনের self-pipe কোডে if (ready < 0) continue; লাইনটা ঠিক এই কারণেই ছিল — select() signal-এ বাধাগ্রস্ত হয়ে EINTR-সহ ফিরলে, সেটাকে error না ধরে শুধু আবার loop-এর মাথায় ফিরে যাওয়া, কারণ ততক্ষণে আসল কাজটা (self-pipe-এ byte লেখা) ইতিমধ্যেই হয়ে গেছে হয়তো, পরের select() call-এই সেটা ধরা পড়বে।

ভেতরে কী ঘটছে

kernel-এর ভেতরে — pending আর blocked, দুইটা বিটমাস্ক

প্রতিটা process (আসলে Linux-এ প্রতিটা thread, কিছু আলাদা টুইস্টসহ) দুইটা sigset_t রাখে — প্রতিটা মূলত একটা ৬৪-বিট bitmask, প্রতি bit একটা signal number-এর জন্য:

  • blocked — কোন কোন signal আপাতত আটকানো (sigprocmask()/pthread_sigmask() দিয়ে সেট করা)
  • pending — কোন কোন signal generate হয়েছে কিন্তু এখনো deliver হয়নি
signal number:      1(HUP)  2(INT)  9(KILL)  11(SEGV)  15(TERM)  17(CHLD)
blocked bitmask:       0       0      --         0         1         0
pending bitmask:       0       0      --         0         1         0

উপরের উদাহরণে SIGTERM (15) blocked আর pending দুটোই 1 — মানে কেউ kill -TERM পাঠিয়েছে, কিন্তু process সেটা block করে রেখেছে, তাই এখনো deliver হয়নি, অপেক্ষা করছে। SIGKILL-এর ঘরে ড্যাশ — কারণ এই দুইটা bitmask তার ক্ষেত্রে অপ্রাসঙ্গিক, kernel সেটা কখনোই এই সাধারণ pipeline দিয়ে যেতে দেয় না।

গুরুত্বপূর্ণ বিন্দু: delivery instant না। kernel signal check করে শুধু নির্দিষ্ট কিছু মুহূর্তে — (১) process যখন kernel mode থেকে user mode-এ ফিরছে (একটা syscall শেষে, বা একটা interrupt/exception handling শেষে যা এই process-কে প্রভাবিত করেছে), আর (২) scheduler যখন এই process-কে চালানোর জন্য বেছে নেয়। এইজন্যই hardware-exception-generated signal (SIGSEGV, SIGFPE) প্রায় তাৎক্ষণিক মনে হয় — যে trap-টা signal তৈরি করল সেটাই একটা kernel-entry point। কিন্তু kill()-generated signal, যদি target process একটা দীর্ঘ CPU-bound loop-এ থাকে (কোনো syscall ছাড়া), সেই মুহূর্তে অপেক্ষা করে যতক্ষণ না scheduler-এর পরবর্তী timer-interrupt সেই process-কে kernel-এ ফিরিয়ে আনে — বাস্তবে কয়েক মিলিসেকেন্ডের বেশি না, কিন্তু সত্যিকারের, পরিমাপযোগ্য একটা latency, “instant magic preemption” না।

kill -TERM pid → handler execution পর্যন্ত পুরো পথ
  1. শেল -- kill(1) বাইনারিargument parse করে kill() syscall ডাকে -- kill(target_pid, SIGTERM)
  2. kernel -- kill() syscall handlertarget task_struct খুঁজে বের করে, pending bitmask-এ SIGTERM-এর bit সেট করে (যদি blocked না থাকে, delivery-র জন্য প্রস্তুত)
  3. IPI (যদি target অন্য CPU-তে চলমান)target process যদি এই মুহূর্তে অন্য core-এ সত্যিই চলছে, kernel সেই core-কে একটা inter-processor interrupt পাঠায় -- interrupts-and-drivers লেসনের LAPIC-এর সেই IPI ভূমিকা -- যাতে সে দ্রুত kernel-এ ফিরে এসে pending signal চেক করে
  4. do_signal() -- kernel-থেকে-user ফেরার মুহূর্তেpending & ~blocked গণনা করে -- SIGTERM পাওয়া গেল, blocked না, disposition দেখা হলো: custom handler আছে
  5. Signal frame তৈরি -- user stack-এবর্তমান register state, siginfo_t (SA_SIGINFO হলে), আর একটা return trampoline user stack-এ push করা হয়
  6. Handler চলে -- user mode, ring 3process-এর নিজের registered handler ফাংশন এখন চলছে, ঠিক যেন kernel-ই তাকে normally call করেছে
  7. ret → trampoline → rt_sigreturn syscallhandler শেষে normal return, কিন্তু return address আসলে trampoline-এ -- সেটা rt_sigreturn() ডাকে
  8. kernel: original context পুনরুদ্ধারsignal frame থেকে সব register ফিরিয়ে আনে, process ঠিক সেই instruction-এ ফিরে যায় যেখানে signal-টা তাকে থামিয়েছিল
একটা kill -TERM pid-এর পুরো যাত্রা -- interrupts-and-drivers লেসনের keyboard-trace-এর সমান্তরাল রূপ।

Signal frame আর sigreturn — একটা নিরাপত্তা ইতিহাসও বটে

উপরের ট্রেসের একটা ধাপ বিশেষভাবে লক্ষণীয়: handler চালানোর জন্য kernel শুধু একটা function pointer ডাকে না — সে process-এর নিজের user stack-এই একটা সম্পূর্ণ “signal frame” বানায়, যাতে থাকে বর্তমান সব register-এর মান, আর একটা return address যেটা আসলে handler-এর কোড না, বরং একটা ছোট trampoline (glibc-তে __restore_rt)। handler ফাংশন যখন সাধারণ ret-এ ফেরে, সে আসলে ফেরে সেই trampoline-এ, যেটা rt_sigreturn() নামের একটা বিশেষ syscall ডাকে — এই syscall-এর একমাত্র কাজ: stack-এ থাকা signal frame থেকে সব register পুনরুদ্ধার করে ঠিক সেই বিন্দুতে ফিরে যাওয়া যেখানে signal আসার আগে process ছিল।

এই মেকানিজমের একটা সাম্প্রতিক নিরাপত্তা-ইতিহাস আছে। যেহেতু পুরো signal frame — সব register-এর মান-সহ — process-এর নিজের, attacker-নিয়ন্ত্রণযোগ্য stack-এ বসে, একটা buffer overflow দিয়ে stack-এর কন্টেন্ট নিয়ন্ত্রণ করতে পারলে একজন আক্রমণকারী একটা সম্পূর্ণ ভুয়া signal frame বানিয়ে সরাসরি rt_sigreturn ডাকতে পারে — একটামাত্র “gadget” দিয়ে (যেটা প্রায় প্রতিটা বাইনারিতেই আছে, কারণ glibc নিজেই এটা রাখে) একসাথে সব register-এ ইচ্ছামতো মান বসিয়ে দেওয়া যায়, যা সাধারণ ROP (Return-Oriented Programming)-এর জন্য অনেক গ্যাজেট চেইন করার দরকার হতো। এই কৌশলের নাম Sigreturn-Oriented Programming (SROP) — Erik Bosman ও Herbert Bos ২০১৪-এ IEEE S&P কনফারেন্সে প্রকাশ করেন। এই কারণেই আধুনিক kernel rt_sigreturn-কে অতিরিক্তভাবে যাচাই করে (একটা “cookie” রেখে বৈধতা চেক করে) যাতে সহজে ভুয়া frame গ্রহণযোগ্য না হয়।

siginfo_t — handler কীভাবে জানে “কে, কেন”

শুধু একটা signal number যথেষ্ট না — একটা SIGCHLD কোন child থেকে এলো? একটা SIGSEGV-তে ঠিক কোন মেমরি ঠিকানা দোষী? kernel-and-user-space লেসনের wall.c experiment-এ SA_SIGINFO flag আর info->si_code ব্যবহার করেছিলাম ঠিক এই তথ্যের জন্য — এখন সেটার পুরো গঠন দেখা যাক। SA_SIGINFO দিয়ে রেজিস্টার করা handler sa_handler-এর বদলে sa_sigaction ব্যবহার করে, যা তিনটা argument পায়:

void handler(int sig, siginfo_t *info, void *ucontext);

siginfo_t-এর কিছু গুরুত্বপূর্ণ ফিল্ড, সব signal-এ প্রাসঙ্গিক না — কোনটা বৈধ তা si_signo/si_code-এর উপর নির্ভর করে:

ফিল্ডকোন signal-এ অর্থবহমানে
si_signo, si_codeসবক্ষেত্রেsignal number, আর কারণের subcategory (kernel-and-user-space লেসনে SEGV_MAPERR বনাম SI_KERNEL)
si_pid, si_uidkill()-generated signalকে পাঠিয়েছে
si_addrSIGSEGV, SIGBUSঠিক কোন মেমরি ঠিকানায় fault হয়েছে
si_statusSIGCHLDchild-এর exit status বা কোন signal তাকে থামিয়েছে
si_valuereal-time signal, sigqueue()-এর মাধ্যমে পাঠানোইচ্ছামতো একটা int/pointer payload

ucontext_t (তৃতীয় argument) আরও গভীরে যায় — signal আসার মুহূর্তে সব CPU register-এর হুবহু মান, যা sigreturn-এর জন্য kernel নিজেই ব্যবহার করে, কিন্তু handler চাইলে নিজেও পড়তে (বা এমনকি বদলাতে) পারে — উন্নত ব্যবহার যেমন একটা crash handler যেটা exactly কোন instruction-এ fault হয়েছিল তা রিপোর্ট করতে চায়।

Real-time signal — কেন standard signal “গুনতে” পারে না

pending একটা bit, কোনো counter না। তাই যদি একই standard signal (ধরুন SIGUSR1) পাঁচবার generate হয় যখন সেটা block করা ছিল, pending bitmask-এ শুধু bit ১০ = 1 থাকে — “৫ বার” মনে রাখার কোনো জায়গাই নেই। unblock হলে handler ঠিক একবার চলে, বাকি চারটা instance চিরতরে হারিয়ে যায় — কোনো error, কোনো warning ছাড়া।

এইজন্যই উপরে SIGCHLD handler-এ WNOHANG loop বাধ্যতামূলক — যদি ৩টা child প্রায় একই সময়ে exit করে আর তখন SIGCHLD কোনো কারণে block/coalesce হয়, handler হয়তো একবারই চলবে। একবার waitpid() ডেকে ধরে নিলে “একটা signal মানে একটা child,” ২টা zombie থেকেই যাবে। WNOHANG loop-এ “যতগুলো আছে সব reap করো” — এই ধরে নেওয়াটাই একমাত্র সঠিক।

Real-time signal (SIGRTMIN থেকে SIGRTMAX, Linux-এ 34-64) এই সীমাবদ্ধতা সমাধান করে — kernel প্রতিটার জন্য একটা প্রকৃত queue রাখে (একটা নির্দিষ্ট সীমা পর্যন্ত, RLIMIT_SIGPENDING), প্রতিটা instance আলাদাভাবে গণনা হয়, হারায় না, আর sigqueue() দিয়ে সাথে একটা ছোট data payload (sigval)-ও পাঠানো যায়। POSIX timer (timer_create + SIGEV_SIGNAL), POSIX message queue notification (mq_notify) — এরা বাস্তবে real-time signal ব্যবহার করে ঠিক এই “হারানো চলবে না” গ্যারান্টির জন্য।

উদাহরণ

সম্পূর্ণ উদাহরণ — নিরাপদ graceful shutdown, আর কী ঘটে ভুল করলে

একটা বাস্তবঘেঁষা প্রোগ্রাম — SIGINT/SIGTERM পেলে বর্তমান “batch” শেষ করে cleanly বন্ধ হয়:

#include <signal.h>
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

static volatile sig_atomic_t shutdown_requested = 0;

static void handle_shutdown(int sig) {
    shutdown_requested = 1;      /* signal-safe -- এটুকুই */
}

static void process_one_batch(int n) {
    printf("batch %d প্রসেস হচ্ছে...\n", n);
    sleep(1);                    /* কল্পনা করুন এখানে real কাজ হচ্ছে */
}

int main(void) {
    struct sigaction sa = {0};
    sa.sa_handler = handle_shutdown;
    sigemptyset(&sa.sa_mask);
    sigaction(SIGINT, &sa, NULL);
    sigaction(SIGTERM, &sa, NULL);

    int batch = 0;
    while (!shutdown_requested) {
        process_one_batch(batch++);
    }

    printf("shutdown signal পেয়েছি -- output flush করছি, cleanup করছি...\n");
    /* এখানে fclose(), free(), সব normal cleanup -- সম্পূর্ণ নিরাপদ, কারণ
       আমরা handler-এর ভেতরে না, normal main flow-এ আছি */
    printf("বন্ধ হলাম, cleanly।\n");
    return 0;
}

strace -tt দিয়ে চালালে সত্যিকারের timeline দেখা যায় — অন্য টার্মিনাল থেকে kill -TERM পাঠানোর সময়:

14:22:07.881204 nanosleep({tv_sec=1, tv_nsec=0}, ...) = 0
14:22:07.882931 write(1, "batch 3 \xe0\xa6\xaa\xe0\xa7\x8d..., 34) = 34
14:22:08.100442 --- SIGTERM {si_signo=SIGTERM, si_code=SI_USER, si_pid=41822} ---
14:22:08.100501 rt_sigreturn({mask=[]})  = 0
14:22:08.100550 write(1, "shutdown signal \xe0\xa6\xaa\xe0\xa7\x87...", 71) = 71

লক্ষ করুন — SIGTERM আসার (100442) আর rt_sigreturn ফেরার (100501) মাঝে মাত্র ৫৯ মাইক্রোসেকেন্ড। handler নিজে এত দ্রুত (শুধু একটা flag সেট) যে দুইটা write() syscall-এর মাঝেও এসে-গিয়ে ফিট হয়ে গেছে — এটাই “handler-কে ছোট রাখো”-র practical প্রমাণ।

এবার একই প্রোগ্রামের ভুল সংস্করণ — handler-এর ভেতরেই cleanup করার চেষ্টা:

static void handle_shutdown_BAD(int sig) {
    printf("shutdown signal পেয়েছি...\n");   /* stdio internal lock/malloc ব্যবহার করে */
    fflush(stdout);
    exit(0);                                  /* exit(), _exit() না -- আরও malloc/atexit হুক চালায় */
}

এই সংস্করণ বেশিরভাগ সময় “কাজ করবে” — টেস্টে ধরা পড়বে না। কিন্তু যদি ঠিক সেই মুহূর্তে main loop-এর কোনো আগের printf()/malloc() call ভেতরে-ভেতরে stdio buffer lock বা heap arena lock ধরে থাকে (উচ্চ লোডে বেশি সম্ভাবনা), signal handler-এর printf()/exit()-এর ভেতরের malloc() সেই একই lock-এর জন্য অপেক্ষা করবে — উপরের concept সেকশনের সেই self-deadlock ডায়াগ্রামটাই বাস্তবে ঘটবে, প্রোগ্রাম চিরতরে ঝুলে যাবে, Ctrl-C-ও তখন আর কাজ করবে না (কারণ handler নিজেই আটকে, নতুন SIGINT block-হীন হলেও পুরনো handler call-স্ট্যাকে জমে থাকবে)।

নিজে চালিয়ে দেখুন

EXPERIMENT

SIGTERM ধরা যায়, SIGKILL কখনোই না — নিজের চোখে দেখা

Linux / macOS· ১০ মিনিট
/* stubborn.c -- সবকিছু ধরার/উপেক্ষা করার চেষ্টা করে */
#include <signal.h>
#include <stdio.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>

static void ignore_everything(int sig) {
    printf("পেলাম signal %d (%s) -- পাত্তা দিলাম না\n", sig, strsignal(sig));
}

int main(void) {
    struct sigaction sa = {0};
    sa.sa_handler = ignore_everything;
    sigemptyset(&sa.sa_mask);

    sigaction(SIGINT,  &sa, NULL);
    sigaction(SIGTERM, &sa, NULL);
    sigaction(SIGHUP,  &sa, NULL);

    if (sigaction(SIGKILL, &sa, NULL) == -1)
        printf("SIGKILL রেজিস্টার ব্যর্থ: %s (errno=%d)\n", strerror(errno), errno);

    printf("PID: %d -- এখন kill -TERM, kill -INT, kill -KILL পাঠিয়ে দেখুন\n", getpid());
    while (1) pause();
}
gcc -O0 -o stubborn stubborn.c && ./stubborn &
PID: 41822 -- এখন kill -TERM, kill -INT, kill -KILL পাঠিয়ে দেখুন
SIGKILL রেজিস্টার ব্যর্থ: Invalid argument (errno=22)

আরেকটা টার্মিনাল থেকে:

kill -TERM 41822    # পেলাম signal 15 (Terminated) -- পাত্তা দিলাম না
kill -INT 41822     # পেলাম signal 2 (Interrupt) -- পাত্তা দিলাম না
kill -HUP 41822     # পেলাম signal 1 (Hangup) -- পাত্তা দিলাম না
echo "প্রোগ্রামটা এখনো বেঁচে -- ps দিয়ে যাচাই করুন"
ps -p 41822

kill -KILL 41822    # কোনো আউটপুট নেই -- process সাথে সাথে শেষ
ps -p 41822          # কিছুই দেখাবে না

SIGKILL-এর বেলায় stubborn প্রোগ্রামের কোনো output-ই আসে না — handler কখনো চলার সুযোগ পায়নি।

এটা কী প্রমাণ করে

handler রেজিস্টার করা signal-এর disposition বদলে দেয়, কিন্তু SIGKILL/SIGSTOP-এর জন্য kernel সেই সুযোগটাই দেয় না -- sigaction() নিজেই প্রত্যাখ্যান করে।

EXPERIMENT

sigprocmask() দিয়ে block করা signal-এর pending অবস্থা /proc-এ দেখা

Linux, root প্রয়োজন নেই· ১৫ মিনিট
/* blocker.c -- SIGUSR1 block করে বসে থাকে, পরে unblock করে */
#include <signal.h>
#include <stdio.h>
#include <unistd.h>

int main(void) {
    sigset_t block_set;
    sigemptyset(&block_set);
    sigaddset(&block_set, SIGUSR1);
    sigprocmask(SIG_BLOCK, &block_set, NULL);

    printf("PID %d -- SIGUSR1 block করা হলো, ৩ বার kill -USR1 পাঠান\n", getpid());
    sleep(15);   /* এই সময়ে /proc/PID/status চেক করুন */

    printf("এখন unblock করছি -- pending থাকলে এখনই deliver হবে\n");
    sigprocmask(SIG_UNBLOCK, &block_set, NULL);
    sleep(2);
    return 0;
}
gcc -O0 -o blocker blocker.c && ./blocker &

১৫ সেকেন্ডের জানালায় তিনবার SIGUSR1 (signal 10) পাঠান, তারপর /proc/<pid>/status-এর signal লাইনগুলো দেখুন:

kill -USR1 $!
kill -USR1 $!
kill -USR1 $!
grep -E '^Sig' /proc/$!/status
SigQ:   0/15759
SigPnd: 0000000000000200
SigBlk: 0000000000000200
SigIgn: 0000000000001000
SigCgt: 0000000000000000

0x200 = বাইনারিতে bit ৯ (০-ইনডেক্স, মানে signal number ১০ = SIGUSR1) সেট — ঠিক SigPnd আর SigBlk দুই জায়গাতেই। তিনবার পাঠানো সত্ত্বেও bit একটাই — hood সেকশনের “signal গোনে না” দাবির সরাসরি প্রমাণ। ১৫ সেকেন্ড পরে unblock হওয়ার সাথে সাথে output-এ দেখবেন handler (বা এখানে ডিফল্ট action, যেহেতু কোনো handler বসানো হয়নি) একবারই কাজ করে।

এটা কী প্রমাণ করে

pending আর blocked সত্যিই দুইটা আলাদা, পর্যবেক্ষণযোগ্য bitmask -- অনুমান না, /proc/pid/status-এ hex সংখ্যা হিসেবে সরাসরি দেখা যায়।

EXPERIMENT

SIGSTOP/SIGCONT -- process state-এ সরাসরি প্রভাব ps-এ দেখা

Linux / macOS· ৫ মিনিট
sleep 300 &
echo "PID: $!"
ps -o pid,stat,cmd -p $!
PID: 51204
  PID STAT CMD
51204 S    sleep 300

S মানে interruptible sleep — স্বাভাবিক। এখন থামান:

kill -STOP $!
ps -o pid,stat,cmd -p $!
  PID STAT CMD
51204 T    sleep 300

T — Stopped। কোনো handler চলেনি (এই প্রোগ্রামে একটাও রেজিস্টার করা নেই, আর SIGSTOP তো handler-ই চলতে দেয় না) — kernel সরাসরি scheduler-এ এই process-কে “চালানো যাবে না” চিহ্নিত করেছে। এই অবস্থায় top/htop-এ CPU ব্যবহার শূন্য দেখাবে, যদিও process মেমরিতে সম্পূর্ণ বহাল আছে।

kill -CONT $!
ps -o pid,stat,cmd -p $!
  PID STAT CMD
51204 S    sleep 300

আবার S — precisely যেখানে থেমেছিল সেখান থেকে চলা শুরু। এটাই Ctrl-Z/fg-এর ভিত্তি (realworld সেকশনে বিস্তারিত) — আর একটা debugger (gdb) যখন একটা process attach করে “pause” করে, ভেতরে এই একই SIGSTOP/PTRACE_* মেকানিজম ব্যবহার হয়।

এটা কী প্রমাণ করে

SIGSTOP একটা handler-নির্ভর ব্যাপার না -- এটা সরাসরি kernel-এর scheduler-স্তরে process-কে runnable তালিকা থেকে সরিয়ে দেয়, যা STAT কলামে সরাসরি দৃশ্যমান।

নিজে বানান

BUILD IT

Self-pipe trick -- asynchronous signal-কে synchronous I/O event-এ রূপান্তর

C · ●●●○○
  1. একটা pipe(2) তৈরি করুন -- read end আর write end
  2. signal handler-এ শুধু write end-এ একটা byte লিখুন (write() async-signal-safe)
  3. main loop-এ select()/poll() দিয়ে read end-কে অন্য সব fd-র সাথে একসাথে পোল করুন
  4. byte এলে সেটা drain করুন আর প্রকৃত shutdown/reload কাজ normal code-এ করুন
  5. তুলনার জন্য signalfd() দিয়ে একই জিনিস kernel-নেটিভভাবে করে দেখুন

সমস্যাটা মনে করুন: main loop-টা select()/poll() দিয়ে অনেকগুলো fd-তে (network socket, stdin, timer) একসাথে অপেক্ষা করছে। একটা signal এলে সেই select() call বাধাগ্রস্ত হয় (EINTR রিটার্ন করে), কিন্তু handler-এর ভেতরে real কাজ করা যাবে না (async-signal-safety সমস্যা)। Self-pipe trick (D. J. Bernstein-এর জনপ্রিয় করা কৌশল) সমাধান করে: signal-কে একটা সাধারণ, select()-যোগ্য fd event-এ রূপান্তর করে।

#include <signal.h>
#include <unistd.h>
#include <sys/select.h>
#include <stdio.h>
#include <string.h>

static int pipe_fds[2];   /* [0] = read end, [1] = write end */

static void signal_handler(int sig) {
    char byte = (char)sig;
    write(pipe_fds[1], &byte, 1);   /* async-signal-safe -- এটুকুই handler-এ */
}

int main(void) {
    pipe(pipe_fds);

    struct sigaction sa = {0};
    sa.sa_handler = signal_handler;
    sigemptyset(&sa.sa_mask);
    sigaction(SIGINT,  &sa, NULL);
    sigaction(SIGTERM, &sa, NULL);

    printf("event loop শুরু -- Ctrl-C চাপুন\n");
    for (;;) {
        fd_set readfds;
        FD_ZERO(&readfds);
        FD_SET(pipe_fds[0], &readfds);
        FD_SET(STDIN_FILENO, &readfds);
        int maxfd = pipe_fds[0] > STDIN_FILENO ? pipe_fds[0] : STDIN_FILENO;

        int ready = select(maxfd + 1, &readfds, NULL, NULL, NULL);
        if (ready < 0) continue;   /* EINTR -- আবার loop করো */

        if (FD_ISSET(pipe_fds[0], &readfds)) {
            char byte;
            read(pipe_fds[0], &byte, 1);   /* signal byte drain */
            printf("signal %d self-pipe দিয়ে এলো -- এখন normal code, printf/malloc সব নিরাপদ\n", (int)byte);
            printf("cleanup করছি, বের হচ্ছি\n");
            break;
        }
        if (FD_ISSET(STDIN_FILENO, &readfds)) {
            char line[256];
            if (fgets(line, sizeof(line), stdin))
                printf("পড়লাম: %s", line);
        }
    }
    return 0;
}

মূল ধারণা: handler থেকে normal I/O-র মধ্যে আর কোনো পার্থক্যই নেই main loop-এর কাছে — select()-এর চোখে signal pipe-টা stdin-এর মতোই একটা সাধারণ readable fd। এখানেই cleanup, log, malloc — সবকিছু সম্পূর্ণ স্বাধীনভাবে করা যায়, কারণ আমরা আর signal-handler context-এ নেই।

বাস্তব সিস্টেমে

১. Ctrl-C থেকে SIGKILL পর্যন্ত — শেলের job control

bash/zsh টার্মিনাল driver-এর মাধ্যমে foreground process group-কে signal পাঠায়: Ctrl-CSIGINT, Ctrl-ZSIGTSTP (process থামায়, শেল-এ ফিরিয়ে দেয়), fg/bg কমান্ড → SIGCONTjobs, fg %1, bg %1 — এই পুরো job-control সিস্টেমটাই signal-এর উপর দাঁড়িয়ে।

২. Docker/Kubernetes graceful shutdown — SIGTERM তারপর SIGKILL

docker stop ডিফল্টভাবে container-এর PID 1-কে SIGTERM পাঠায়, তারপর ১০ সেকেন্ড অপেক্ষা করে (কনফিগারযোগ্য --time), তাও process বেঁচে থাকলে SIGKILL পাঠায়। Kubernetes-এ একই প্যাটার্ন terminationGracePeriodSeconds (ডিফল্ট ৩০ সেকেন্ড) দিয়ে — pod বন্ধ করার সময় প্রথমে SIGTERM (application-কে in-flight request শেষ করা, connection বন্ধ করা, buffer flush করার সুযোগ), তারপর গ্রেস পিরিয়ড শেষে SIGKILL। এই লেসনের “SIGKILL মানে কোনো cleanup নেই” পয়েন্টটা এখানে সরাসরি প্রাসঙ্গিক — একটা container image যদি SIGTERM না ধরে, প্রতিটা docker stop/pod-eviction পুরো grace period অপেক্ষা করে তারপর জোর করে মারা হয়, নোংরা shutdown।

৩. nginx/PostgreSQL — SIGHUP দিয়ে reload

Unix daemon-দের একটা পুরনো কনভেনশন: SIGHUP (মূলত “hangup,” terminal disconnect বোঝাত) কে “config পুনরায় পড়ো” অর্থে পুনর্ব্যবহার করা। nginx -s reload আসলে master process-কে SIGHUP পাঠায় — সে নতুন config পড়ে, নতুন worker process spawn করে, পুরনোদের gracefully বন্ধ করে, কোনো downtime ছাড়া। PostgreSQL-ও একই কনভেনশন অনুসরণ করে pg_ctl reload-এ।

৪. Crash handler — SIGSEGV ধরে backtrace লেখা

JVM (hs_err_pid<N>.log), Python-এর faulthandler module, Redis, Envoy — এরা SIGSEGV/SIGABRT/SIGBUS-এর জন্য নিজস্ব handler বসায় শুধু একটা backtrace/stack-trace log-এ লিখে রাখার জন্য, তারপর disposition ডিফল্টে রিসেট করে signal আবার নিজেকে পাঠায় — যাতে kernel তার স্বাভাবিক কাজ (core dump/terminate) করে। handler নিজে চালিয়ে যাওয়ার চেষ্টা করে না, কারণ SIGSEGV-এর পরে process state (heap, stack) অবিশ্বস্ত হতে পারে — শুধু “কী হয়েছিল” রেকর্ড করে সরে যাওয়াই নিরাপদ।

৫. POSIX timer আর message queue notification — real-time signal-এর বাস্তব ব্যবহার

timer_create() কে SIGEV_SIGNAL দিয়ে একটা real-time signal বাঁধা যায় — প্রতিটা timer tick একটা আলাদা, নির্ভরযোগ্য (হারায় না) signal instance পাঠায়, যেখানে সাধারণ SIGALRM শুধু একটা timer-ই সাপোর্ট করে প্রতি process-এ। gত লেসনের POSIX message queue-র mq_notify()-ও একই মেকানিজম ব্যবহার করে — queue-তে নতুন message এলে একটা signal (বা thread) দিয়ে জানায়।

৬. Sigreturn-Oriented Programming — signal frame-কে অস্ত্র বানানো

hood সেকশনে দেখা signal frame মেকানিজমই একটা বাস্তব আক্রমণ-শ্রেণির ভিত্তি — SROP (২০১৪, Bosman ও Bos)। ASLR/stack-canary-র মতো সুরক্ষা থাকা সত্ত্বেও, একটা buffer overflow দিয়ে stack-এ একটা ভুয়া signal frame বসিয়ে rt_sigreturn ডাকলে আক্রমণকারী একটামাত্র gadget দিয়ে সব register নিয়ন্ত্রণ করতে পারে — যা সাধারণ ROP-এর চেয়ে অনেক সরল। security module-এ (Level 10) এই আক্রমণ-শ্রেণি ও তার প্রতিরোধ (kernel cookie validation) বিস্তারিত আসবে।

৭. signal()-এর ইতিহাস — unreliable থেকে POSIX-standardized পর্যন্ত

মূল Unix (System V)-এর signal() semantics ছিল “unreliable” — handler একবার চললে ডিফল্টে রিসেট হতো, প্রতিবার নিজেকে আবার রেজিস্টার করতে হতো (আর সেই ফাঁকে দ্বিতীয় দ্রুত signal ডিফল্ট action চালিয়ে দিতে পারত)। 4.2BSD “reliable signal” মডেল আনল — handler রিসেট হয় না, আর handler চলাকালীন সেই একই signal স্বয়ংক্রিয়ভাবে block থাকে। ১৯৮৮-এর POSIX.1 স্ট্যান্ডার্ড শেষমেশ BSD-ধাঁচের semantics-কে sigaction()-এ প্রামাণ্য করল — এখনো signal() টিকে আছে (আজকাল glibc-তে এটাও BSD semantics দেয়), কিন্তু বিভিন্ন platform-এ ঐতিহাসিকভাবে ভিন্ন আচরণের কারণে portable কোডে sigaction()-ই মান্য পছন্দ।

যে ভুলগুলো সবাই করে

“যথেষ্ট কৌশল করলে (উদাহরণ, sigaction() flag বা privileged process দিয়ে) SIGKILL-ও catch করা যায়”

kernel-এর signal-delivery কোডে SIGKILL/SIGSTOP-এর জন্য একটা hard-coded শর্টকাট আছে যা sigaction টেবিল চেক করার ধাপটাই বাদ দেয় — কোনো user-space flag, কোনো privilege level এটা বাইপাস করতে পারে না। sigaction(SIGKILL, ...) সরাসরি EINVAL রিটার্ন করে, চেষ্টাটাই প্রত্যাখ্যাত। এটা bug না, ইচ্ছাকৃত ডিজাইন — একটা guaranteed “কিছুই কাজ করেনি” escape hatch দরকার।

একটা ব্যতিক্রম মনে হয় আছে — একটা process D state-এ (uninterruptible sleep, সাধারণত ধীর/আটকে-থাকা kernel I/O, যেমন পুরনো NFS mount) থাকলে kill -9-ও কাজ করছে না মনে হতে পারে। কিন্তু এটা SIGKILL catch হওয়া না — এটা hood সেকশনের সেই “delivery শুধু নির্দিষ্ট মুহূর্তে চেক হয়” সত্যের ফল। process-টা kernel mode-এর ভেতরে আটকে আছে, এখনো সেই delivery-check পয়েন্টেই পৌঁছায়নি — SIGKILL pending হয়ে বসে থাকে, আর kernel call থেকে ফিরলেই সাথে সাথে কাজ করবে (অথবা truly stuck থাকলে কখনোই না, যেটার জন্য reboot লাগে)।

“signal handler ঠিক সেই মুহূর্তেই চলে যখন ঘটনাটা ঘটে -- main code তাৎক্ষণিকভাবে যেকোনো instruction-এর মাঝখানে থেমে যায়”

hardware-exception-generated signal-এর জন্য (SIGSEGV, SIGFPE) এটা প্রায় সত্যি — exception-এর trap-টাই একটা kernel-entry point। কিন্তু kill()-generated signal-এর জন্য delivery ঘটে শুধু নির্দিষ্ট মুহূর্তে — kernel-থেকে-user ফেরার সময়, বা scheduler-এর preemption পয়েন্টে। একটা CPU-bound loop (কোনো syscall ছাড়া) কয়েক মিলিসেকেন্ড দেরিতে signal পেতে পারে — ছোট কিন্তু বাস্তব একটা delay, “যেকোনো instruction-এর ফাঁকে জাদুকরী তাৎক্ষণিক থামা” না।

“handler-এ printf() ব্যবহার নিরাপদ, কারণ টেস্ট করেছি আর কাজ করেছে”

printf() নিজে internally একটা stdio buffer lock ব্যবহার করে, আর প্রায়ই buffer allocation-এর জন্য malloc()-ও ডাকে — দুটোই async-signal-unsafe। “কাজ করেছে” মানে race window-টা এখনো ধরা পড়েনি, নিরাপদ প্রমাণিত হয়নি। race window সরু বলেই ডেভ মেশিনে প্রায় কখনো ধরা পড়ে না, আর ঠিক production-এর উচ্চ-লোড মুহূর্তে (window চওড়া হলে) আঘাত করে।

“SIGCHLD পেলে মানে ঠিক একটা child মারা গেছে, তাই একবার wait() ডাকলেই যথেষ্ট”

pending একটা bit, counter না। একাধিক child প্রায় একইসাথে অবস্থা বদলালে, বা SIGCHLD সাময়িকভাবে block থাকা অবস্থায় একাধিকবার generate হলে, handler-এ শুধু একবারই “flag” ওঠে — কতবার ঘটেছে তার কোনো হিসাব থাকে না। সঠিক প্যাটার্ন সবসময় waitpid(-1, &status, WNOHANG)-কে একটা loop-এ চালানো, “যতগুলো এখন reap করা যায় সব করো, তারপর থামো” — একবার wait() না।

বুঝেছেন কি না দেখুন

1

SIGKILL আর SIGTERM-এর মধ্যে পার্থক্য ঠিক কী, আর কেন Docker/Kubernetes উভয়ই একটা container/pod বন্ধ করার সময় প্রথমে SIGTERM পাঠায়, সাথে সাথে SIGKILL না?

যুক্তি

SIGTERM (15) একটা সাধারণ signal — catch, block, বা ignore করা যায়। এর ডিফল্ট action Terminate, কিন্তু process চাইলে একটা handler বসিয়ে নিজের ইচ্ছামতো cleanup করে (open connection বন্ধ করা, buffer flush, temp file মোছা) তারপর নিজে থেকে বের হতে পারে। SIGKILL (9) কখনো catch/block/ignore করা যায় না — kernel সরাসরি, কোনো handler চালানোর সুযোগ না দিয়েই process terminate করে।

Docker/Kubernetes প্রথমে SIGTERM পাঠায় কারণ এটাই application-কে graceful shutdown-এর সুযোগ দেয় — in-flight HTTP request শেষ করা, database connection cleanly বন্ধ করা, unwritten data flush করা। যদি সরাসরি SIGKILL পাঠানো হতো, কোনো cleanup-এর সুযোগই থাকত না — অর্ধেক-লেখা ফাইল, বন্ধ-না-হওয়া connection, ক্লায়েন্টের কাছে হঠাৎ বিচ্ছিন্ন সংযোগ। SIGKILL শুধু গ্রেস পিরিয়ড (Docker-এ ডিফল্ট ১০ সেকেন্ড, Kubernetes-এ ৩০) শেষেও process বেঁচে থাকলে ব্যবহার হয় — একটা গ্যারান্টিড “শেষমেশ বন্ধ হবেই” নিশ্চয়তা, buggy বা hung application-এর জন্যও।

2

একটা process supervisor প্রায় একই মুহূর্তে তিনটা child spawn করেছিল, আর তিনটাই কাছাকাছি সময়ে exit করল। supervisor-এর SIGCHLD handler-এ শুধু একবার waitpid(-1, &status, 0) (WNOHANG ছাড়া, একবার) ডাকা হয়েছিল। ফলাফলে ps-এ ২টা zombie process দেখা গেল। কী ঘটেছে, আর কীভাবে ঠিক করবেন?

প্রয়োগ

kernel-এর pending set একটা bitmask, counter না — তিনটা child প্রায় একইসাথে exit করলেও SIGCHLD-এর bit একবারই সেট হয় (বা handler চলাকালীন নতুন SIGCHLD block/coalesce থাকে, কারণ sigaction()-এ ডিফল্টভাবে যে signal-এর জন্য handler চলছে সেটাই handler চলাকালীন block থাকে)। তাই handler ঠিক একবারই ট্রিগার হলো, কিন্তু ভেতরে শুধু একবার waitpid() ডাকায় শুধু একটা child reap হলো — বাকি দুইটা zombie অবস্থায় জমে রইল, কারণ তাদের জন্য আর কোনো signal আসবে না (তারা ইতিমধ্যেই exit করে ফেলেছে, নতুন state-change হবে না)।

সমাধান — non-blocking loop:

static void sigchld_handler(int sig) {
    int status;
    pid_t pid;
    while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
        /* প্রতিটা এখনই-reap-করা-যায় এমন child সবগুলো reap করো */
    }
}

WNOHANG মানে কোনো child না থাকলেও ব্লক করবে না, > 0 রিটার্ন থাকা পর্যন্ত loop চলে — একটা মাত্র SIGCHLD delivery-তেও একাধিক child reap হয়ে যায়, কারণ handler প্রশ্ন করছে “এই মুহূর্তে কতগুলো child reap করা যায়,” “কতগুলো signal এসেছে” না।

3

malloc() কেন async-signal-safe না — একটা কংক্রিট race scenario দিয়ে দেখান।

যুক্তি

glibc-র malloc implementation (ptmalloc) প্রতিটা heap allocation-এর সময় একটা internal arena lock নেয়, free-list-এর pointer-গুলো আপডেট করে, তারপর lock ছাড়ে। এই আপডেটের মাঝপথে heap-এর অবস্থা সাময়িকভাবে inconsistent — কিছু pointer আপডেট হয়ে গেছে, কিছু এখনো না।

ধরা যাক main flow একটা malloc(64) কল-এর মাঝখানে আছে — arena lock নিয়ে ফেলেছে, free-list আপডেট করছে, তখনই একটা signal আসে। handler চলে সেই একই thread-এ, main flow-এর ঠিক এই মাঝপথের অবস্থার উপর দিয়ে “ঢুকে পড়ে”। যদি handler নিজে malloc()/printf() ডাকে, সেটাও একই arena lock নেওয়ার চেষ্টা করবে — কিন্তু lock-টা তো ইতিমধ্যে “locked” (মূল, এখনো-অসমাপ্ত call-এর হাতে)। যেহেতু এই lock non-recursive এবং যে thread সেটা ধরে আছে সেই একই thread এখন handler-এও চলছে, handler-এর malloc() কল একটা lock-এর জন্য অপেক্ষা করে যেটা কখনো release হবে না — মূল call-টা কখনো আবার চলার সুযোগই পাবে না, কারণ handler নিজেই ঝুলে আছে। ফলাফল একটা perfect self-deadlock, একই thread নিজের সাথে আটকে।

এই একই root cause fork-exec-wait লেসনে থ্রেডেড প্রোগ্রামে fork()-এর পরও দেখা গিয়েছিল — child-এ arena lock “locked অবস্থায়” জমে থাকা, কারণ যে thread সেটা release করত সে child-এ অস্তিত্বেই নেই। দুইটা ভিন্ন trigger (fork বনাম signal), কিন্তু একই মূল সমস্যা: একটা non-reentrant, lock-ভিত্তিক ফাংশনকে এমন একটা প্রসঙ্গে চালানো যেখানে সেই lock ইতিমধ্যেই আধা-ধরা অবস্থায় আছে।

4

আপনি একটা long-running data-processing প্রোগ্রাম ডিজাইন করছেন যেটা বড় বড় ব্যাচে ডেটা প্রসেস করে। Ctrl-C চাপলে প্রোগ্রামটা সাথে সাথে না মরে, বরং বর্তমান ব্যাচ শেষ করে, output ফাইল flush করে, cleanly বন্ধ হোক — আপনি চান। কিন্তু প্রোগ্রামটা একটা event loop-এও চলে যেটা network socket আর একটা timer একসাথে পোল করে। signal handling-টা কীভাবে ডিজাইন করবেন?

ডিজাইন

দুইটা ভিন্ন কাঠামোর জন্য দুইটা সঠিক প্যাটার্ন আছে, আর প্রশ্নে দুটোই আছে — একটা সরল loop (ব্যাচ প্রসেসিং) আর একটা event loop (socket + timer polling)।

ব্যাচ প্রসেসিং অংশের জন্য — flag + main loop প্যাটার্ন। একটা volatile sig_atomic_t shutdown_requested global রাখুন। SIGINT/SIGTERM handler শুধু shutdown_requested = 1 সেট করে return করে। ব্যাচ-প্রসেসিং লুপ প্রতিটা ব্যাচের শেষে (মাঝখানে না, যাতে একটা ব্যাচ আধা-লেখা অবস্থায় না থামে) এই flag চেক করে — true হলে লুপ থেকে বেরিয়ে normal code-এ (handler-এর বাইরে) cleanup করে: ফাইল flush, close, log লেখা — এখানে malloc/printf সম্পূর্ণ নিরাপদ কারণ আমরা signal-handler context-এ নেই।

Event loop অংশের জন্য — self-pipe trick বা signalfd()। যেহেতু event loop ইতিমধ্যেই socket আর timer-এর জন্য select()/epoll() ব্যবহার করছে, সবচেয়ে পরিষ্কার সমাধান হলো signal-কেও একটা fd event বানিয়ে সেই একই loop-এ যোগ করা — signalfd() (Linux-নেটিভ, সহজতর) বা self-pipe trick (portable)। এতে আলাদা কোনো “signal-কে বিশেষভাবে হ্যান্ডল করো” শাখা লাগে না — signal শুধু আরেকটা readable fd, ঠিক socket/timer-এর মতোই, আর তার জন্য কাজ করা কোড সম্পূর্ণ স্বাভাবিক (non-signal-handler) context-এ চলে।

দুইটার মিল: উভয় ক্ষেত্রেই মূল নীতি এক — handler-এ (বা যেখান থেকে asynchronous notification আসে) সর্বনিম্ন কাজ, প্রকৃত কাজ সবসময় normal control flow-এ। দুইটার পার্থক্য শুধু “normal control flow-টা কীভাবে গঠিত” — সরল loop হলে flag-check, event-driven হলে fd-based event।

5

/proc/<pid>/status-এ SigBlk: 0000000000004002 দেখলেন। কোন কোন signal block করা আছে (শুধু হুবহু bit থেকে বের করুন, signal number অনুযায়ী)?

প্রয়োগ

হেক্স 4002-কে বাইনারিতে লিখলে: 0100 0000 0000 0010

bit numbering ১-থেকে-শুরু (signal number সরাসরি bit position, bit ১ = signal 1): set bit-গুলো হলো bit ২ আর bit ১৫ (ডান দিক থেকে গুনে, ১-ইনডেক্স)।

  • bit ২ = SIGINT
  • bit ১৫ = SIGTERM

যাচাই: 2^(2-1) + 2^(15-1) = 2 + 16384 = 16386, hex-এ 0x4002 — মিলে যায়। তাই এই process-এ SIGINT আর SIGTERM দুটোই এই মুহূর্তে block করা আছে — সম্ভবত একটা sigprocmask(SIG_BLOCK, ...) দিয়ে critical section রক্ষা করা হচ্ছে, অথবা self-pipe trick-এর জন্য এই দুইটাকেই signalfd()-এ রুট করে রাখা হয়েছে।

এরপর কী

পরের লেসন — Synchronization Primitives

এই পুরো লেসনে একটা নিয়ম বারবার এসেছে — signal handler-এ শুধু async-signal-safe জিনিস করা যায়, volatile sig_atomic_t ছাড়া কোনো global-এ হাত দেওয়া যায় না, কারণ handler আপনার main flow-এর মাঝখানে, যেকোনো instruction-এর ফাঁকে ঢুকে পড়তে পারে — তাই যেকোনো আধা-সম্পন্ন আপডেট সে দেখে ফেলতে পারে। আর sigprocmask() দিয়ে block করে এই সমস্যাটা সম্পূর্ণভাবে সমাধানও করা যায়।

কিন্তু signal ছিল একটা বিশেষ, সীমিত পরিস্থিতি — একটাই CPU, একটাই thread, শুধু নিয়ন্ত্রণ-প্রবাহ মাঝপথে সরে যাচ্ছে। পরের লেসনে ঠিক এই একই ধরনের সমস্যার অনেক বড়, অনেক সাধারণ রূপ দেখব — যেখানে ৮টা CPU core-এ ৮টা thread সত্যিকারের সমান্তরালে, একই সময়ে, একই memory-তে হাত দিচ্ছে। sigprocmask()-এর মতো single-thread trick সেখানে আর কাজ করে না — দরকার হয় সম্পূর্ণ নতুন যন্ত্রপাতি: mutex, semaphore, condition variable। threads লেসনের pthread_create() দিয়ে ৪টা thread একসাথে counter++ করলে ঠিক কী ভাঙে, আর সেই ভাঙা ঠেকানোর যন্ত্রগুলো ঠিক কী কী — পরের লেসনের বিষয়।

আরও পড়ুন

  • signal(7) — Linux manual page · স্ট্যান্ডার্ড signal set, ডিফল্ট action, আর async-signal-safe function-এর প্রামাণ্য তালিকা এখান থেকেই
  • sigaction(2) — Linux manual page · struct sigaction, SA_SIGINFO, sa_mask-এর সম্পূর্ণ semantics
  • Advanced Programming in the UNIX Environment, 3rd Edition — অধ্যায় ১০ — W. Richard Stevens, Stephen A. Rago · signal-এর ইতিহাস (unreliable বনাম reliable), আর reentrancy সমস্যার ধ্রুপদী আলোচনা
  • Framing Signals: A Return to Portable Shellcode — Erik Bosman, Herbert Bos · Sigreturn-Oriented Programming (SROP) — signal frame-এর গঠনকে কাজে লাগিয়ে exploitation, IEEE S&P 2014
  • signalfd(2) — Linux manual page · self-pipe trick-এর kernel-নেটিভ আধুনিক বিকল্প — এই লেসনের build সেকশনের শেষে সংক্ষেপে আসে