সরাসরি পর্বে যান
পর্ব ৫১ · ৬০-এর মধ্যে · মডিউল ৭

signal আর low-level I/O — file descriptor, poll আর mmap

Signals & Low-Level I/O — File Descriptors, read/write, poll and mmap

Taught in Bangla, this lesson goes below printf: file descriptors and the open, read, write and close system calls, partial reads and EINTR, dup2 and buffering, signals with sigaction and async-signal safety, graceful shutdown, SIGCHLD and SIGPIPE, poll versus epoll, and mmap for files and shared memory.

এই পর্বে যা শিখবেন

  • File descriptor কী, open/read/write/close কীভাবে কাজ করে, আর partial read/write ও errno কীভাবে সামলাবেন
  • dup2 দিয়ে stdout ঘুরিয়ে দেওয়া, আর printf ও write-এর লেখা কেন উল্টো ক্রমে আসতে পারে
  • Signal কী, sigaction কেন signal()-এর চেয়ে ভালো, handler-এ কী করা নিরাপদ, আর গুছিয়ে বন্ধ হওয়ার নকশা
  • poll দিয়ে একসাথে অনেক pipe বা socket দেখা, আর select, poll, epoll-এর পার্থক্য
  • mmap দিয়ে ফাইল পড়া ও প্রসেসের মধ্যে মেমরি ভাগ করা, আর AI-এর লেখা low-level কোড যাচাইয়ের চেকলিস্ট

১ · printf-এর নিচে কী আছে — file descriptor আর “সবকিছুই ফাইল”

পর্ব ২৪-এ আপনি fopen, fprintf, fclose দিয়ে ফাইলে লিখেছেন। পর্ব ৫০-এ দেখেছেন, printf শেষ পর্যন্ত write নামের একটি system call করে। এই পর্বে আমরা সেই তলায় নামব — যেখানে C library নেই, আছে শুধু আপনার প্রোগ্রাম আর kernel. একে বলে low-level I/O.

ফাইল, কিবোর্ড, স্ক্রিন, pipe, নেটওয়ার্ক সংযোগ — সম্পূর্ণ আলাদা জিনিস, তবু UNIX-এ সবগুলো একই চারটি ফাংশন দিয়ে ব্যবহার করা যায়: open, read, write, close. কীভাবে?

কমলাপুর রেলস্টেশনের লাগেজ রুমের কথা ভাবুন। আপনি ব্যাগ জমা দিয়ে পেলেন একটি টোকেন — “৩ নম্বর”। ব্যাগটি কোন তাকে আছে, জানার দরকার নেই; পরে শুধু টোকেন দেখালেই কাজ হয়। ব্যাগ হোক বা চালের বস্তা — টোকেনের নিয়ম একটাই। Kernel ঠিক এভাবেই কাজ করে। আপনি একটি ফাইল খুললেন, kernel আপনাকে দিল একটি ছোট সংখ্যা। এরপর যা করবেন, সেই সংখ্যা দেখিয়েই করবেন।

এই সংখ্যাটিই file descriptor (সংক্ষেপে fd). প্রতিটি প্রসেসের জন্য kernel একটি file descriptor table রাখে। তালিকার প্রতিটি ঘরের নম্বর একটি fd, আর ঘরটি নির্দেশ করে kernel-এর ভেতরের কোনো খোলা ফাইল, pipe বা socket-এর দিকে। প্রোগ্রাম চালু হওয়ার সময়েই তিনটি ঘর ভরা থাকে:

fdনামসাধারণত কোথায় যুক্তstdio-তে
0stdinকিবোর্ড (terminal)scanf, fgets(…, stdin)
1stdoutস্ক্রিন (terminal)printf, puts
2stderrস্ক্রিন (terminal)fprintf(stderr, …), perror
3, 4, …আপনি যা খুলবেনফাইল, pipe, socketfileno(f) দিয়ে FILE*-এর ভেতরের fd পাওয়া যায়

তাহলে FILE* কী? এটি C library-র বানানো একটি struct, যার ভেতরে আছে একটি fd আর একটি buffer. fprintf লেখা জমায় buffer-এ, আর buffer ভরলে একবারে write(fd, …) ডাকে। অর্থাৎ stdio হলো system call-এর ওপর বসানো একটি সুবিধার স্তর:

কাজstdio (পর্ব ২৪)POSIX system call (এই পর্ব)
খোলাFILE *f = fopen("a.txt", "w");int fd = open("a.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
পড়াfread, fgets, fscanfread(fd, buf, n) — শুধু byte, কোনো format নেই
লেখাfwrite, fprintfwrite(fd, buf, n)
অবস্থান বদলানোfseek, ftelllseek(fd, offset, whence)
বন্ধfclose(f) — flush করে, তারপর closeclose(fd)
ভুল হলেNULL বা EOF, সাথে errno-1, সাথে errno
Bufferআছে — দ্রুত, কিন্তু লেখা দেরিতে নড়েনেই — প্রতিটি ডাক একটি সত্যিকারের system call

১৯৭৪ সালে Ken Thompson আর Dennis Ritchie “The UNIX Time-Sharing System” প্রবন্ধে UNIX-এর নকশা বর্ণনা করেন। সেখানে একটি সাহসী সিদ্ধান্ত ছিল: প্রিন্টার, terminal, ডিস্কের মতো যন্ত্রগুলোও /dev ফোল্ডারে “বিশেষ ফাইল” হয়ে থাকবে, আর সাধারণ ফাইলের মতোই পড়া-লেখা যাবে। পরে pipe আর socket-ও একই পথে এলো। এই নীতির নাম হয়ে যায় “everything is a file”. আজকের Linux-এ /proc ফোল্ডারে চলমান প্রসেসের তথ্যও ফাইল হিসেবে পড়া যায় — cat /proc/self/status.

এই পর্বের কোড POSIX মান অনুযায়ী — Linux, macOS ও অন্যান্য Unix-ধাঁচের সিস্টেমে চলে; পাতার রানারও Linux-এ চলে। Windows-এর কথা অংশ ৮-এর শেষে।

২ · open, read, write, close — byte সরানোর চারটি system call

open — টোকেন নেওয়া

open দুটি বা তিনটি জিনিস নেয়: ফাইলের নাম, কী করতে চান তা জানানোর flag, আর নতুন ফাইল তৈরি হলে তার অনুমতি। সফল হলে ফেরত দেয় একটি fd — সবসময় সবচেয়ে ছোট খালি নম্বরটি। তাই 0, 1, 2 ভরা থাকলে প্রথম open সাধারণত 3 দেয়। ব্যর্থ হলে ফেরত দেয় -1, আর কারণটি রাখে errno-তে।

Flagঅর্থstdio-র কাছাকাছি mode
O_RDONLYশুধু পড়া"r"
O_WRONLY | O_CREAT | O_TRUNCশুধু লেখা; না থাকলে তৈরি; থাকলে খালি করে শুরু"w"
O_WRONLY | O_CREAT | O_APPENDপ্রতিটি লেখা ফাইলের শেষে যোগ হবে"a"
O_CREAT | O_EXCLফাইল আগে থেকে থাকলে ব্যর্থ হও — নিরাপদে নতুন ফাইল বানাতে"wx" (C11)
O_NONBLOCKঅপেক্ষা কোরো না — অংশ ৭—

তৃতীয় argument 0644 হলো নতুন ফাইলের অনুমতি, octal-এ লেখা (শুরুর 0 খেয়াল করুন)। তিনটি অঙ্ক তিন দলের জন্য — মালিক, গ্রুপ, অন্য সবাই। ৬ মানে পড়া + লেখা, ৪ মানে শুধু পড়া। তবে আসল অনুমতি থেকে প্রসেসের umask কিছু বিট কেটে দেয়; সাধারণ umask 022 হলে 0666 চাইলেও ফাইল হয় 0644. O_CREAT দিয়ে এই argument বাদ দেওয়া একটি পরিচিত বাগ — তখন অনুমতি হয় register বা stack-এ পড়ে থাকা আবর্জনা মান।

ভুল হলে — errno, perror আর strerror

পর্ব ৩০-এ দেখেছেন, ব্যর্থ ফাংশন কারণের কোড রেখে যায় errno-তে। System call-এর জগতে এই কোডগুলোই আপনার প্রধান তথ্য। perror সেটিকে মানুষের ভাষায় stderr-এ ছাপে, আর strerror একটি string ফেরত দেয়। রানারে যাচাই করা কয়েকটি আসল মেসেজ:

open("missing.txt", O_RDONLY)  → -1, errno = ENOENT
open missing.txt: No such file or directory      ← perror("open missing.txt")
open("/etc/shadow", O_RDONLY)  → -1, errno = EACCES: Permission denied
errnostrerror যা বলেকখন দেখবেন
ENOENTNo such file or directoryফাইল বা ফোল্ডার নেই
EACCESPermission deniedঅনুমতি নেই
EMFILEToo many open filesপ্রসেসের fd-সীমা শেষ — সাধারণত fd leak
EINTRInterrupted system callঅপেক্ষার মাঝে signal এসেছে
EAGAINResource temporarily unavailableNon-blocking fd-এ এখন ডেটা নেই
EPIPEBroken pipeপড়ার দিক বন্ধ এমন pipe বা socket-এ লেখা

errno একমাত্র ব্যর্থতার পরেই অর্থবহ। সফল ফাংশন errno-কে 0 করে দেয় না, আর মাঝখানে অন্য কোনো ফাংশন (এমনকি printf) ডাকলে errno বদলে যেতে পারে। তাই নিয়ম: ফেরত মান -1 দেখুন, সাথে সাথে errno পড়ুন বা একটি ভ্যারিয়েবলে রেখে দিন।

read আর write — “কম পেলে ভুল নয়”

read(fd, buf, 100)-এর তিন রকম ফল হতে পারে, আর তিনটিই আলাদাভাবে সামলাতে হয়:

> 0 — কিছু byte এসেছে

১০০ চেয়েছিলেন, এলো হয়তো ৪০. এটি ভুল নয়। ফাইলের শেষের কাছে, pipe-এ যতটুকু জমা ছিল, বা নেটওয়ার্কে যতটুকু পৌঁছেছে — ততটুকুই। একে বলে short count.

বাকিটার জন্য আবার ডাকুন

0 — শেষ

ফাইলের শেষ, অথবা pipe-এর সব লেখার মুখ বন্ধ। এটিই EOF. Low-level জগতে EOF কোনো বিশেষ অক্ষর নয় — শুধু “0 byte”.

লুপ থামান

-1 — ব্যর্থ

errno দেখুন। EINTR হলে signal এসেছিল — আবার চেষ্টা করুন। EAGAIN হলে non-blocking fd-এ ডেটা নেই। বাকিগুলো সত্যিকারের ভুল।

কারণ বুঝে সিদ্ধান্ত

write-ও একই রকম। ডিস্ক ভরে গেলে, pipe-এর buffer ভরা থাকলে বা signal এলে সে চাওয়ার চেয়ে কম byte লিখে ফেরত আসতে পারে। সাধারণ ফাইলে এটি কালেভদ্রে ঘটে, কিন্তু pipe আর socket-এ খুবই স্বাভাবিক — তাই পেশাদার কোডে একটি “পুরোটা লেখো” ফাংশন থাকে। নিচের প্রোগ্রামটি একটি ছোট log ফাইল লেখে, তারপর ৮ byte-এর buffer দিয়ে পড়ে স্ক্রিনে (fd 1) কপি করে — অর্থাৎ একটি ক্ষুদ্র log copier:

#include <stdio.h>
#include <errno.h>
#include <fcntl.h>
#include <unistd.h>
static int write_all(int fd, const char *p, size_t n) {
    while (n > 0) {                          /* write() may take only a part */
        ssize_t w = write(fd, p, n);
        if (w < 0) { if (errno == EINTR) continue; return -1; }
        p += w; n -= (size_t)w;
    }
    return 0;
}
int main(void) {
    int fd = open("app.log", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (fd < 0) { perror("open"); return 1; }
    write_all(fd, "boot ok\nlogin rahim\ndisk 91%\n", 29); close(fd);
    if ((fd = open("app.log", O_RDONLY)) < 0) { perror("open"); return 1; }
    printf("log opened as fd %d\n", fd); fflush(stdout);   /* empty stdio's buffer first */
    char buf[8]; ssize_t n; int calls = 0;
    while ((n = read(fd, buf, sizeof buf)) > 0) { write_all(1, buf, (size_t)n); calls++; }
    close(fd); printf("read() calls: %d\n", calls);
}

আউটপুট: প্রথমে log opened as fd 3 (0, 1, 2 ভরা ছিল, আগের fd 3 বন্ধ হয়ে খালি হয়েছিল, তাই আবার 3), তারপর log-এর তিনটি লাইন, শেষে read() calls: 4. ফাইলে ২৯ byte, buffer ৮ byte — তাই read দিল ৮, ৮, ৮, ৫, তারপর 0 (EOF). write_all প্রতিবার যতটুকু লেখা হলো ততটুকু pointer এগিয়ে বাকিটার জন্য আবার ডাকে, আর EINTR হলে চুপচাপ আবার চেষ্টা করে। এবার পরীক্ষা: fflush(stdout); মুছে আবার চালান। রানারে দেখবেন log-এর লাইনগুলো আগে এসেছে, আর log opened as fd 3 চলে গেছে নিচে! কেন — পরের অংশে।

close, fd leak আর lseek

প্রতিটি fd kernel-এর একটি সম্পদ আটকে রাখে, আর প্রসেস একসাথে কতগুলো খুলে রাখতে পারবে তার সীমা আছে — অনেক Linux সিস্টেমে ডিফল্ট ১০২৪ (ulimit -n দিয়ে দেখুন)। সার্ভার প্রতিটি অনুরোধে একটি ফাইল খুলে বন্ধ করতে ভুলে গেলে, এক সময় সব open ব্যর্থ হবে EMFILE নিয়ে — এর নাম fd leak. Linux-এ কোনো প্রসেসের খোলা fd গুনতে ls /proc/<pid>/fd বা lsof -p <pid> দেখুন।

আরেকটি সূক্ষ্ম জায়গা: exec-এর পরেও fd খোলা থাকে (পর্ব ৫০-এ এভাবেই pipe জোড়া হয়েছিল), তাই অপ্রয়োজনীয় fd — যেমন একটি database সংযোগ — নতুন প্রোগ্রামের হাতে চলে যেতে পারে। এজন্য O_CLOEXEC (close-on-exec) দিয়ে খোলা ভালো অভ্যাস।

lseek হলো fseek-এর low-level রূপ। lseek(fd, 0, SEEK_END) অবস্থানকে ফাইলের শেষে নিয়ে নতুন অবস্থান ফেরত দেয় — অর্থাৎ ফাইলের আকার। Pipe বা socket-এ lseek চলে না (ESPIPE), কারণ স্রোতকে পেছনে টানা যায় না।

৩ · dup2 আর buffering — shell-এর > আর উল্টো ক্রমের রহস্য

পর্ব ৫০-এ ls | grep txt-এর ভেতরে dup2 দেখেছেন। একই কৌশলে shell ./app > out.txt করে, যাকে বলে redirection. এবার fd তালিকার ভেতরে কী হয়, একটি প্রসেসের দিক থেকে ধাপে ধাপে দেখুন:

1/* shell's child, before exec */2int fd = open("out.txt", O_WRONLY|O_CREAT|O_TRUNC, 0644);3dup2(fd, 1);       /* fd 1 now = out.txt */4close(fd);         /* 3 no longer needed */5execlp("./app", "./app", (char *)NULL);6/* inside app: printf -> write(1, ...) */
File descriptor তালিকা
fd 0
fd 1
fd 2
fd 3
out.txt-এর ভেতরে
  1. শুরু। Shell fork করেছে; child-এর হাতে shell-এর তালিকার কপি — 0 কিবোর্ড, 1 আর 2 স্ক্রিন। এখনো exec হয়নি, তাই এই কোড shell-এর নিজের।
  2. open — নতুন টোকেন। out.txt তৈরি হলো (বা খালি হলো), আর সবচেয়ে ছোট খালি নম্বর 3 পাওয়া গেল।
  3. dup2(3, 1) — আসল কৌশল। fd 1 আগে যা ছিল (স্ক্রিন) সেটি বন্ধ হলো, আর 1 এখন 3-এর মতোই out.txt-এর দিকে নির্দেশ করছে। দুটি নম্বর, একটিই খোলা ফাইল — অবস্থানও (offset) দুজনের ভাগাভাগি।
  4. close(3). ফাইল বন্ধ হয়নি — শুধু একটি টোকেন ফেরত দেওয়া হলো। Kernel ফাইলটি খোলা রাখে, কারণ fd 1 এখনো সেটি ধরে আছে। এই close না করলে প্রোগ্রাম অকারণে একটি বাড়তি fd নিয়ে চলত।
  5. exec. নতুন প্রোগ্রাম app এলো, কিন্তু fd তালিকা একই রইল। app জানেই না তার stdout একটি ফাইল।
  6. app লিখল। printf("Total: 42\n") শেষ পর্যন্ত write(1, …) করল, আর লেখা গেল ফাইলে। স্ক্রিন ফাঁকা। 2> লিখলে একই কাজ হয় fd 2-এর জন্য, আর 2>&1 মানে dup2(1, 2).

dup(fd) হলো এর সরল ভাই — একই খোলা ফাইলের একটি নতুন নম্বর দেয়। প্রোগ্রামের ভেতরে কিছুক্ষণের জন্য stdout ঘুরিয়ে আবার ফেরাতে চাইলে saved = dup(1) করে রাখুন, কাজ শেষে dup2(saved, 1).

printf আর write — কেন উল্টো ক্রমে আসে

এবার অংশ ২-এর রহস্য। printf সাথে সাথে write ডাকে না; stdio-র buffer-এ জমায় (পর্ব ১০ ও ২৪)। কখন পাঠাবে, তা নির্ভর করে stdout কোথায় যুক্ত তার ওপর:

stdout যুক্ত আছেBuffering মোডলেখা কখন OS-এ যায়
Terminalline-bufferedপ্রতিটি \n-এ
ফাইল বা pipe (রানারও এমন)fully bufferedbuffer ভরলে (কয়েক KB), fflush-এ, বা প্রোগ্রাম শেষে
stderr (যেকোনো জায়গায়)unbufferedসাথে সাথে

এখন log copier-এর ঘটনাটি পরিষ্কার। রানারে stdout একটি pipe, তাই printf("log opened…") buffer-এ বসে রইল। অথচ write(1, …) buffer এড়িয়ে সরাসরি kernel-এ গেল। ফলে পরে লেখা জিনিস আগে পৌঁছাল, আর printf-এর দুটি লাইন একসাথে বেরোল প্রোগ্রামের শেষে। Terminal-এ চালালে line-buffering-এর কারণে ক্রম ঠিক দেখাত। নিয়ম: একই fd-তে stdio আর raw write মেশালে, সীমানায় fflush দিন — অথবা যেকোনো একটিই ব্যবহার করুন।

৪ · Signal — অপারেটিং সিস্টেমের হঠাৎ টোকা

এবার উল্টো দিক: কখনো kernel নিজেই প্রোগ্রামকে থামিয়ে কিছু জানাতে চায় — ব্যবহারকারী Ctrl+C চেপেছেন, একটি child শেষ হয়েছে, প্রোগ্রাম নিষিদ্ধ মেমরিতে হাত দিয়েছে, বা বেঁধে দেওয়া সময় শেষ। এই বার্তার নাম signal.

ক্লাস চলছে, শিক্ষক বোর্ডে লিখছেন। হঠাৎ দপ্তরি একটি চিরকুট দিলেন; শিক্ষক বাক্যটি মাঝপথে থামিয়ে চিরকুট পড়লেন, তারপর আবার বোর্ডে ফিরলেন। চিরকুটে শুধু একটি সংকেত: “ছুটির ঘণ্টা”। Signal-ও তাই: কোনো ডেটা নয়, শুধু একটি নম্বর। আর আসে যেকোনো মুহূর্তে — আপনার কোডের যেকোনো দুই নির্দেশের মাঝখানে।

প্রতিটি signal-এর একটি নাম আর নম্বর আছে। Handler না বসালে প্রতিটি signal-এ প্রসেস একটি default action নেয় — বন্ধ হওয়া, বন্ধ হয়ে core dump রেখে যাওয়া, উপেক্ষা করা, বা থেমে থাকা। সবচেয়ে কাজের signal-গুলো (নম্বরগুলো Linux-এর; অন্য সিস্টেমে কয়েকটি আলাদা):

Signalনম্বরকখন আসেDefaultধরা যায়?
SIGINT2Terminal-এ Ctrl+Cবন্ধহ্যাঁ
SIGTERM15kill 1234, systemd, Docker — “ভদ্রভাবে বন্ধ হও”বন্ধহ্যাঁ
SIGKILL9kill -9 1234, মেমরি ফুরালে Linux-এর OOM killerবন্ধনা, কখনো না
SIGSEGV11নিষিদ্ধ মেমরিতে হাত — Segmentation faultবন্ধ + coreহ্যাঁ, কিন্তু প্রায় কখনো উচিত নয়
SIGCHLD17কোনো child শেষ হলো বা থামলউপেক্ষাহ্যাঁ
SIGPIPE13পড়ার দিক বন্ধ এমন pipe/socket-এ লেখাবন্ধ (নিঃশব্দে!)হ্যাঁ
SIGALRM14alarm()-এর সময় শেষবন্ধহ্যাঁ
SIGHUP, SIGUSR11, 10Terminal বন্ধ হলো; বা নিজের দেওয়া অর্থ (যেমন “config আবার পড়ো”)বন্ধহ্যাঁ

রানারে strsignal() দিয়ে এগুলোর বর্ণনা ছাপলে আসে Interrupt, Terminated, Alarm clock, Segmentation fault — সেই চেনা মেসেজগুলো, যা shell মৃত প্রোগ্রামের পরে দেখায়। Shell-এ কোনো প্রোগ্রাম signal-এ মারা গেলে তার exit status হয় ১২৮ + signal নম্বর; তাই Ctrl+C-তে মরা প্রোগ্রামের পর echo $? দেখায় 130.

মূল ধারণা

SIGKILL (আর থামানোর SIGSTOP) কোনো প্রোগ্রাম ধরতে, উপেক্ষা করতে বা আটকাতে পারে না — রানারে sigaction(SIGKILL, …) চেষ্টা করলে ফেরত আসে -1 আর Invalid argument. এটি ইচ্ছাকৃত নকশা: কোনো প্রোগ্রাম যত খারাপই হোক, administrator-এর হাতে তাকে বন্ধ করার শেষ অস্ত্র থাকতেই হবে। তাই kill -9 সবসময় কাজ করে, কিন্তু প্রোগ্রাম কিছুই গুছানোর সুযোগ পায় না — buffer হারায়, অস্থায়ী ফাইল পড়ে থাকে। প্রথমে SIGTERM, শেষ উপায় SIGKILL.

Ctrl+C চাপার পর আসলে কী কী ঘটে? ধরুন একটি প্রোগ্রাম কিবোর্ড থেকে লাইন পড়ছে, আর SIGINT-এর জন্য একটি handler বসানো আছে (কীভাবে বসায়, পরের অংশে)। ধাপে ধাপে দেখুন:

1static volatile sig_atomic_t stop = 0;2void on_int(int s) { stop = 1; }3int main(void) {  /* sigaction(SIGINT): no SA_RESTART */4    while (!stop) {5        n = read(0, line, sizeof line);6        if (n < 0 && errno == EINTR) continue;7        handle(line, n);8    }9    save_and_close(); return 0;10}
অবস্থা
প্রসেস কোথায়
অপেক্ষমাণ signal
stop
read()-এর ফল
  1. অপেক্ষা। প্রোগ্রাম লাইন ৫-এ read ডেকেছে। কিবোর্ডে কিছু নেই, তাই kernel তাকে ঘুম পাড়িয়ে রেখেছে — CPU একটুও খরচ হচ্ছে না।
  2. Ctrl+C — terminal driver-এর কাজ। আপনি Ctrl+C চাপলেন। কিবোর্ড থেকে আসা এই বিশেষ অক্ষর প্রথমে দেখে kernel-এর terminal (tty) driver. সে অক্ষরটি প্রোগ্রামকে না দিয়ে, স্ক্রিনে ^C দেখিয়ে, সামনের process group-এর সবাইকে SIGINT পাঠায়। Kernel signal-টিকে প্রসেসের নামে “অপেক্ষমাণ” হিসেবে লিখে রাখে।
  3. প্রসেসকে থামানো — handler চলে। Kernel ঘুমন্ত read থেকে প্রসেসকে জাগাল। কিন্তু সরাসরি লাইন ৫-এ ফেরত না দিয়ে, আগে আপনার handler on_int চালাল — যেন কেউ জোর করে একটি ফাংশন ডেকে দিল। Handler শুধু stop = 1 করল, আর কিছু না।
  4. System call-এর ফল: EINTR. Handler ফেরার পর বাধা পাওয়া read ফেরত দিল -1, errno = EINTR. SA_RESTART দেওয়া থাকলে kernel read-কে নিজেই আবার শুরু করত, আর প্রোগ্রাম আবার ঘুমিয়ে পড়ত — flag দেখার সুযোগই পেত না। এখানে তাই ইচ্ছা করেই SA_RESTART নেই।
  5. মূল লুপ flag দেখে। continue লুপের শর্তে ফেরত নিল; !stop এখন মিথ্যা, তাই লুপ শেষ। খেয়াল করুন: আসল সিদ্ধান্ত নিল মূল প্রোগ্রাম, নিজের সুবিধামতো সময়ে — handler নয়।
  6. Cleanup. এখন প্রোগ্রাম স্বাভাবিক অবস্থায় আছে, তাই যা খুশি করতে পারে: printf, ফাইলে সেভ, fclose, অস্থায়ী ফাইল মোছা। এগুলো handler-এর ভেতরে করা নিরাপদ ছিল না।
  7. শেষ। প্রোগ্রাম নিজে return 0 করল, তাই exit status 0 — signal-এ মারা গেলে হতো ১৩০. কিছু প্রোগ্রাম cleanup-এর পর ইচ্ছা করে ১৩০ দিয়ে বের হয়, যাতে shell script বুঝতে পারে ব্যবহারকারী থামিয়েছেন।

৫ · sigaction আর async-signal-safety — handler-এ কী করা যায়, কী যায় না

পর্ব ২৮-এ function pointer শেখার সময় signal(SIGINT, on_ctrl_c) দেখেছিলেন — সবচেয়ে সরল উপায়। কিন্তু পেশাদার কোডে signal() প্রায় ব্যবহারই হয় না। কারণটি ইতিহাসে।

আদি UNIX-এ signal ছিল “অবিশ্বস্ত” — এখন এর নাম unreliable signals. Handler একবার চলার সাথে সাথেই kernel সেটিকে ডিফল্টে ফিরিয়ে দিত, তাই handler-এর প্রথম কাজ ছিল নিজেকে আবার বসানো। কিন্তু আবার বসানোর আগেই দ্বিতীয় Ctrl+C এলে? ডিফল্ট কাজ — প্রোগ্রাম মারা যেত। Signal সাময়িকভাবে আটকে রাখারও উপায় ছিল না। BSD আর System V দুই দিকেই আলাদা আলাদা সমাধান এলো, ফলে একই signal() দুই সিস্টেমে দুই রকম আচরণ করত। শেষে ১৯৮৮ সালের POSIX.1 মান একটি নতুন, স্পষ্ট ফাংশন ঠিক করে দেয়: sigaction.

সেই বিভ্রান্তি আজও আছে। Linux-এর glibc সাধারণত signal()-কে BSD-ধাঁচে চালায় (handler থেকে যায়, system call আবার শুরু হয়)। কিন্তু gcc -std=c11-এর মতো কড়া মোডে compile করলে সেই একই signal() পুরোনো System V আচরণে চলে — handler একবারেই শেষ! sigaction-এ এই অনিশ্চয়তা নেই; আপনি নিজে সব বলে দেন:

ঘরকী বলেসাধারণ মান
sa_handlerকোন ফাংশন চলবে (বা SIG_IGN উপেক্ষা, SIG_DFL ডিফল্ট)on_sig
sa_maskhandler চলার সময় আর কোন signal আটকে থাকবেsigemptyset(&sa.sa_mask)
sa_flagsবাড়তি আচরণ0, বা SA_RESTART

SA_RESTART দিলে signal-এ বাধা পাওয়া read/write-এর মতো system call kernel নিজেই আবার শুরু করে — EINTR নিয়ে মাথা ঘামাতে হয় না। কিন্তু আগের stepper-এ দেখলেন, তাতে মূল লুপ flag দেখার সুযোগ হারায়। আর Linux-এ poll, select, epoll_wait, nanosleep-এর মতো কিছু ডাক SA_RESTART থাকলেও কখনো আবার শুরু হয় না, সবসময় EINTR দেয়। তাই EINTR সামলানোর কোড রাখতেই হয়।

Handler-এর ভেতরে — সবচেয়ে কড়া নিয়ম

Signal আসে যেকোনো মুহূর্তে। ধরুন মূল প্রোগ্রাম printf-এর ভেতরে, stdout-এর buffer-এ অর্ধেক লেখা কপি করেছে, buffer-এর lock ধরে আছে। ঠিক তখন signal এলো, আর handler-এও printf. Handler সেই একই buffer ছুঁতে গেল — হয় lock-এর জন্য চিরকাল অপেক্ষা (deadlock), নয়তো অর্ধেক-বদলানো buffer আরও এলোমেলো। malloc-এরও একই সমস্যা: heap-এর তালিকা অর্ধেক বদলানো অবস্থায় handler আবার malloc ডাকলে heap নষ্ট হয়ে যায় (পর্ব ৪৭-এ দেখেছেন allocator-এর ভেতরে কী জটিল তালিকা থাকে)।

তাই POSIX একটি তালিকা দিয়েছে: যেসব ফাংশন handler-এর ভেতর থেকে ডাকা নিরাপদ, তারা async-signal-safe. তালিকায় আছে write, read, _exit, close, kill, waitpid, sigaction ইত্যাদি। তালিকায় নেই printf, fprintf, malloc, free, exit (সে stdio flush করে), strerror-এর মতো বহু পরিচিত ফাংশন।

Handler-এ শুধু একটি flag সেট করুন —
আসল কাজ করবে মূল লুপ, নিজের সময়ে।

Flag-এর টাইপ হয় volatile sig_atomic_t. volatile (পর্ব ৪৫) compiler-কে বলে, “এই মান লুকিয়ে বদলাতে পারে, প্রতিবার মেমরি থেকে পড়ো” — নইলে optimizer while (!stop)-কে চিরকালের লুপ বানিয়ে ফেলতে পারে। আর sig_atomic_t নিশ্চিত করে যে মানটি এক ধাক্কায় লেখা হয়, অর্ধেক লেখা অবস্থায় কেউ দেখবে না। নিচের প্রোগ্রামে দুটি ঘটনাই দেখুন — Ctrl+C (raise দিয়ে নিজেকেই SIGINT পাঠিয়ে), আর একটি watchdog timer, যা আটকে থাকা read-কে ১ সেকেন্ড পর ভেঙে দেয়:

#include <stdio.h>
#include <string.h>
#include <errno.h>
#include <signal.h>
#include <unistd.h>
static volatile sig_atomic_t got = 0;
static void on_sig(int sig) { got = sig; }            /* handler only sets a flag */
int main(void) {
    struct sigaction sa = {0};
    sa.sa_handler = on_sig; sigemptyset(&sa.sa_mask);  /* sa_flags = 0: no SA_RESTART */
    sigaction(SIGINT, &sa, NULL); sigaction(SIGALRM, &sa, NULL);
    for (int job = 1; got == 0; job++) {
        printf("job %d done\n", job);
        if (job == 3) raise(SIGINT);                   /* as if Ctrl+C was pressed */
    }
    printf("got signal %d: saving work, closing files\n", (int)got);
    int p[2]; char c; if (pipe(p) == -1) return 1;     /* nobody writes to p[1] */
    alarm(1);                                          /* watchdog: SIGALRM in 1 s */
    ssize_t n = read(p[0], &c, 1);                     /* blocks... until the alarm */
    printf("read returned %zd (%s), signal %d\n", n, strerror(errno), (int)got);
    return 0;
}

আউটপুট: job 1 done, job 2 done, job 3 done, তারপর got signal 2: saving work, closing files, আর ১ সেকেন্ড পর read returned -1 (Interrupted system call), signal 14. প্রথম ভাগে raise(SIGINT)-এর পর handler শুধু got = 2 করে, আর লুপ পরের বার শর্ত দেখে থামে। দ্বিতীয় ভাগে pipe-এ কেউ লিখবে না, তাই read চিরকাল আটকে থাকার কথা। কিন্তু alarm(1) ১ সেকেন্ড পর SIGALRM পাঠাল, আর SA_RESTART না থাকায় read EINTR নিয়ে ফিরল। এটাই watchdog-এর সরল রূপ। এবার sa.sa_flags = SA_RESTART; যোগ করে চালিয়ে দেখুন কী হয় (অনুশীলন ৪)।

Signal আটকে রাখা — sigprocmask

কখনো কোডের একটি অংশ এমন, যেখানে signal-এ বাধা পাওয়া চলবে না — যেমন দুটি সম্পর্কিত হিসাব একসাথে বদলানো। তখন sigprocmask(SIG_BLOCK, &set, &old) দিয়ে signal আটকে রাখুন, কাজ শেষে sigprocmask(SIG_SETMASK, &old, NULL). আটকানো signal হারায় না; প্রসেসের signal mask-এ থাকা অবস্থায় অপেক্ষা করে, ছাড়লেই পৌঁছায়। তবে একই signal অপেক্ষার সময় দশবার এলেও সাধারণত একবারই পৌঁছায় — সাধারণ signal গোনা যায় না, শুধু “অন্তত একবার এসেছে” বোঝা যায়। (Multithreaded প্রোগ্রামে একই কাজ করে pthread_sigmask — পর্ব ৫২.)

৬ · বাস্তবের signal-নকশা — গুছিয়ে বন্ধ হওয়া, SIGCHLD আর SIGPIPE

Graceful shutdown — SIGTERM থেকে পরিষ্কার বিদায়

একটি বাস্তব দৃশ্য: আপনার bKash-এর মতো একটি লেনদেন সার্ভার Docker container-এ চলছে। নতুন version deploy করতে docker stop দেওয়া হলো। Docker প্রথমে প্রোগ্রামকে SIGTERM পাঠায়, তারপর ডিফল্টে ১০ সেকেন্ড অপেক্ষা করে; এর মধ্যে বন্ধ না হলে SIGKILL. Kubernetes-এও একই নকশা, ডিফল্ট অপেক্ষা ৩০ সেকেন্ড। প্রোগ্রাম SIGTERM না ধরলে মাঝপথের লেনদেন অর্ধেক হয়ে থাকবে। ধরলে সে graceful shutdown করতে পারে:

sigaction(SIGTERM, on_term)       ← handler: stop = 1, আর কিছু না
sigaction(SIGINT,  on_term)
while (!stop):
    নতুন অনুরোধ নাও, কাজ করো    ← poll/read EINTR দিলে লুপের শুরুতে ফেরো
নতুন অনুরোধ নেওয়া বন্ধ করো         ← listening socket close
চলমান লেনদেনগুলো শেষ করো           ← সময়সীমার মধ্যে
buffer flush, ফাইল ও সংযোগ বন্ধ, অস্থায়ী ফাইল মোছো
exit(0)

বড় সার্ভারগুলো signal-কে নিয়ন্ত্রণের ভাষা হিসেবেও ব্যবহার করে — যেমন nginx SIGHUP পেলে বন্ধ না হয়ে configuration আবার পড়ে।

SIGCHLD — zombie পরিষ্কারের ঘণ্টা

পর্ব ৫০-এ নিয়ম শিখেছেন: “যে fork করে, সে-ই wait করে।” কিন্তু একটি সার্ভার তো waitpid-এ বসে থাকতে পারে না — তাকে নতুন সংযোগ নিতে হবে। সমাধান: child শেষ হলেই kernel parent-কে SIGCHLD পাঠায়। Handler (বা handler-এর flag দেখে মূল লুপ) তখন যত child শেষ হয়েছে সবাইকে তুলে নেয়:

while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
    /* একটি child reaped — দরকার হলে তার status লিখে রাখুন */
}

এখানে দুটি সূক্ষ্মতা। লুপ কেন? কারণ তিনটি child প্রায় একসাথে মরলে SIGCHLD হয়তো একবারই পৌঁছাবে — signal গোনা যায় না, তাই একবারে সবাইকে তুলতে হয়। WNOHANG কেন? যাতে আর কোনো মৃত child না থাকলে waitpid অপেক্ষা না করে সাথে সাথে 0 ফেরত দেয়। waitpid async-signal-safe, তাই handler-এর ভেতরেও এটি চলে। আর child-এর ফলাফল একদমই না লাগলে SIGCHLD-কে SIG_IGN করলে Linux-এ child শেষ হয়েই মুছে যায়, zombie হয় না।

SIGPIPE — নিঃশব্দ মৃত্যু

পর্ব ৫০-এ দেখেছেন, pipe-এর পড়ার দিক বন্ধ হলে লেখার আর কোনো অর্থ নেই। Kernel তখন লেখককে SIGPIPE পাঠায়, আর ডিফল্টে প্রোগ্রাম কোনো মেসেজ ছাড়াই মারা যায়। yes | head -1-এ এটিই কাম্য, কিন্তু সার্ভারে একজন client হঠাৎ সংযোগ কাটলেই পুরো সার্ভার মারা যাওয়া — বিপর্যয়। রানারে দুটি অবস্থাই যাচাই করা:

/* পড়ার দিক বন্ধ pipe-এ write — default action */
child killed by signal: 1, sig=13
/* signal(SIGPIPE, SIG_IGN) দেওয়ার পর একই write */
write = -1, errno = Broken pipe, EPIPE? 1

নিয়ম: সার্ভার বা নেটওয়ার্ক প্রোগ্রামে শুরুতেই SIGPIPE উপেক্ষা করুন (SIG_IGN), আর প্রতিটি write-এর পর EPIPE দেখে শুধু সেই সংযোগটি বন্ধ করুন। Socket-এ বিকল্প হলো পর্ব ৫৩-এর send(…, MSG_NOSIGNAL).

Signal-কে সাধারণ ঘটনা বানানো — self-pipe আর signalfd

Handler-এর সীমাবদ্ধতা এড়ানোর সবচেয়ে পরিষ্কার উপায় হলো signal-কে একটি file descriptor-এর ঘটনায় রূপ দেওয়া। Self-pipe trick-এ প্রোগ্রাম নিজের জন্য একটি pipe বানায়; handler শুধু তাতে write(pfd[1], "x", 1) করে (write async-signal-safe). মূল লুপ সেই pipe-এর পড়ার মুখটিকে অন্য socket-এর সাথে poll-এ দেখে — signal এলে সেটি “পড়ার মতো ডেটা এসেছে” হয়ে ধরা পড়ে। Linux এর জন্য সরাসরি একটি system call দেয়: signalfd, যা কিছু signal আটকে রেখে সেগুলোর জন্য একটি fd ফেরত দেয়; handler-ই লাগে না।

৭ · poll — এক thread, অনেক দরজা

একটি চ্যাট প্রোগ্রামের কথা ভাবুন। তাকে একসাথে দুটি জায়গা দেখতে হবে: কিবোর্ড (আপনি কিছু লিখলেন কি না) আর নেটওয়ার্ক (বন্ধু কিছু পাঠাল কি না)। read(0, …) ডাকলে কিবোর্ডে কিছু না আসা পর্যন্ত প্রোগ্রাম ঘুমিয়ে থাকবে — এর মধ্যে বন্ধুর বার্তা এলেও দেখবে না। একটি read একটিই দরজায় দাঁড়াতে পারে।

একটি ব্যাংকের তিনটি কাউন্টার, আর একজন ম্যানেজার। তিনি এক কাউন্টারে দাঁড়িয়ে থাকতে পারেন (blocking read), বা সারাক্ষণ তিন কাউন্টারে দৌড়াতে পারেন (busy loop). ভালো উপায়: প্রতিটি কাউন্টারে একটি কলিং বেল; যে কাউন্টারের বেল বাজে, শুধু সেখানে যান। poll হলো সেই বেলের ব্যবস্থা।

O_NONBLOCK — অপেক্ষা না করা

কোনো fd-তে O_NONBLOCK দিলে (open-এ, বা পরে fcntl দিয়ে) সেটি হয় non-blocking: ডেটা না থাকলে read ঘুমায় না, সাথে সাথে -1 আর EAGAIN দেয় (রানারে খালি pipe-এ: Resource temporarily unavailable). শুধু এটুকু দিয়ে লুপে বারবার চেষ্টা করলে CPU ১০০% পুড়বে। এর আসল কাজ poll-এর সাথে জুটি বেঁধে — poll বলে কখন চেষ্টা করতে হবে, non-blocking নিশ্চিত করে চেষ্টাটি আটকাবে না।

poll — কোন দরজায় কেউ এলো

poll-কে আপনি একটি তালিকা দেন: কোন কোন fd, প্রতিটিতে কিসের অপেক্ষা (POLLIN = পড়ার মতো কিছু এলো, POLLOUT = লেখার জায়গা আছে), আর সর্বোচ্চ কত মিলিসেকেন্ড অপেক্ষা। Kernel প্রসেসকে ঘুম পাড়ায়, আর যেকোনো একটি fd তৈরি হলেই জাগায়। ফেরত মান হলো কয়টি fd তৈরি; 0 মানে timeout, -1 মানে ভুল (প্রায়ই EINTR). কোনটি তৈরি, তা kernel লিখে দেয় প্রতিটি ঘরের revents-এ। নিচে দুটি pipe, যার শুধু দ্বিতীয়টিতে ডেটা আছে:

#include <stdio.h>
#include <poll.h>
#include <unistd.h>
int main(void) {
    int a[2], b[2];
    if (pipe(a) == -1 || pipe(b) == -1) return 1;
    if (write(b[1], "bKash: 500 tk", 13) != 13) return 1;   /* only pipe b has data */
    struct pollfd fds[2] = { { a[0], POLLIN, 0 }, { b[0], POLLIN, 0 } };
    int ready = poll(fds, 2, 1000);                  /* wait at most 1000 ms */
    printf("ready = %d\n", ready);
    for (int i = 0; i < 2; i++) {
        if (fds[i].revents & POLLIN) {
            char buf[32];
            ssize_t n = read(fds[i].fd, buf, sizeof buf - 1);
            if (n > 0) { buf[n] = '\0'; printf("pipe %d: %s\n", i, buf); }
        } else printf("pipe %d: nothing yet\n", i);
    }
    return 0;
}
প্রত্যাশিত আউটপুট (Linux-এ, এই পাতার রানারের মতো পরিবেশে যাচাই করা):
ready = 1
pipe 0: nothing yet
pipe 1: bKash: 500 tk

খেয়াল করুন, poll সাথে সাথে ফিরেছে — ১০০০ ms পুরো অপেক্ষা করেনি, কারণ একটি fd আগেই তৈরি ছিল। আর b[1] বন্ধ করে আবার poll করলে revents-এ আসে POLLHUP — অন্য প্রান্ত চলে গেছে। একটি বাস্তব event loop-এ এই কাজ চলে চিরকালের লুপে। ধাপে ধাপে দেখুন:

1struct pollfd fds[2] = {{a, POLLIN}, {b, POLLIN}};2for (;;) {3    int r = poll(fds, 2, 5000);4    if (r == 0) { puts("timeout"); continue; }5    for (i = 0; i < 2; i++) {6        if (fds[i].revents & POLLIN)  read_and_print(i);7        else if (fds[i].revents & POLLHUP) close_and_skip(i);8    }9}
poll-এর হিসাব
r
pipe A revents
pipe B revents
CPU খরচ
  1. দুই দরজাই চুপ। poll kernel-কে বলল, “A বা B-তে কিছু এলে, নয়তো ৫ সেকেন্ড পরে জাগিয়ো।” প্রসেস ঘুমিয়ে আছে; CPU একটুও খরচ হচ্ছে না — busy loop-এর সাথে এটাই মূল পার্থক্য।
  2. B-তে ডেটা এলো। অন্য প্রসেস pipe B-তে লিখল। Kernel জাগিয়ে দিল: r = 1, A-এর revents 0, B-এর POLLIN. কোনটি তৈরি, তা জানতে প্রোগ্রামকে তালিকাটি ঘুরে দেখতে হবে।
  3. শুধু তৈরি দরজায় read. লুপ A-তে কিছু পেল না, এগিয়ে গেল; B-তে POLLIN দেখে read করল — আর জানা কথা, এই read আটকাবে না। তারপর আবার poll-এ ফেরত।
  4. Timeout. ৫ সেকেন্ড কেউ কিছু লিখল না। poll 0 ফেরত দিল। এই সুযোগে প্রোগ্রাম নিয়মিত কাজ সারতে পারে — যেমন অনেকক্ষণ চুপ থাকা সংযোগ বন্ধ করা।
  5. দুজন একসাথে। A আর B দুটিতেই ডেটা: r = 2. একটি poll-এর ডাকেই দুটির খবর; লুপ দুটিই পড়ল। একটি thread-এই দুই দরজা সামলানো হলো।
  6. A-এর লেখক চলে গেল। A-এর লেখার মুখ বন্ধ হলো, তাই revents-এ POLLHUP (কখনো বাকি ডেটার জন্য POLLIN-ও থাকে; তখন read 0 দিলে বোঝা যায় EOF). প্রোগ্রাম A বন্ধ করে তালিকা থেকে সরাল — এটা না করলে poll বারবার POLLHUP দিয়ে সাথে সাথে ফিরত, আর লুপ CPU পুড়িয়ে ঘুরত।

select, poll, epoll — একই প্রশ্নের তিন প্রজন্ম

অনেক fd একসাথে দেখার এই কৌশলের নাম I/O multiplexing. এর তিনটি প্রধান রূপ আছে:

selectpollepoll (Linux) / kqueue (BSD, macOS)
উৎসBSD UNIX, ১৯৮০-এর দশকSystem V UNIXkqueue: FreeBSD, ২০০০; epoll: Linux 2.5.44 (২০০২), 2.6-এ স্থায়ী
তালিকা কীভাবে দেনfd-এর bit-set (fd_set)struct pollfd-এর arrayএকবার kernel-এ নিবন্ধন, পরে শুধু পরিবর্তন
সীমাসাধারণত fd নম্বর ১০২৪-এর নিচে (FD_SETSIZE)কোনো নির্দিষ্ট সীমা নেইকোনো নির্দিষ্ট সীমা নেই
প্রতি ডাকের খরচসব fd-এর অনুপাতেসব fd-এর অনুপাতেশুধু তৈরি fd-এর অনুপাতে
কোথায় চলেপ্রায় সব জায়গায়, Windows-এও (শুধু socket)সব POSIX সিস্টেমেনির্দিষ্ট OS-এ
কখন বেছে নেবেনপুরোনো কোড, খুব কম fdকয়েক ডজন fd, portable কোডহাজারো সংযোগের সার্ভার

পর্ব ৫৩-এ দেখবেন, হাজারো client সামলানো server এই কারণেই epoll-ভিত্তিক event loop-এ চলে। একটি সূক্ষ্ম পার্থক্য: poll হলো level-triggered — fd তৈরি থাকলে প্রতিবার জানায়; epoll-এর edge-triggered মোডে একবারই জানায়, তাই সেখানে EAGAIN না আসা পর্যন্ত সব পড়ে ফেলতে হয়।

৮ · mmap — ফাইলকে মেমরির মতো পড়া

read দিয়ে ফাইল পড়লে byte দুবার নড়ে: ডিস্ক থেকে kernel-এর মেমরিতে, তারপর kernel থেকে আপনার buffer-এ। mmap একটি ভিন্ন প্রস্তাব দেয়: “ফাইলটিকেই আমার ঠিকানা-জগতে বসিয়ে দাও।” তারপর একটি সাধারণ char *p দিয়ে p[0], p[5000] পড়লেই ফাইলের সেই byte — কোনো read নেই, কোনো buffer নেই।

এটি সম্ভব হয় virtual memory-র জন্য (পর্ব ৫০ ও ৪৭)। প্রসেসের মেমরি পাতায় ভাগ করা — সাধারণ x86-64 Linux-এ প্রতিটি পাতা ৪০৯৬ byte (রানারে sysconf(_SC_PAGESIZE) দেখায় 4096). mmap প্রথমে কিছুই পড়ে না, শুধু হিসাব লিখে রাখে: “এই ঠিকানা থেকে ওই ফাইল।” পড়া হয় আলসেমি করে, পাতা ধরে ধরে:

p = mmap(NULL, 12000, PROT_READ, MAP_PRIVATE, fd, 0)শুধু হিসাব লেখা হলো
ঠিকানা-জগৎ     পাতা ০ [0–4095]      পাতা ১ [4096–8191]    পাতা ২ [8192–11999]
অবস্থা         ফাঁকা (ফাইলের দিকে নির্দেশ)   ফাঁকা                ফাঁকা
RAM-এ এসেছে   ০টি পাতা         ← mmap সাথে সাথে ফেরত, কোনো disk-পড়া নেই
c = p[10]প্রথম ছোঁয়া → page fault
ঠিকানা-জগৎ     পাতা ০               পাতা ১                পাতা ২
অবস্থা         ফাইলের byte 0–4095   ফাঁকা                ফাঁকা
ঘটনা          CPU থামল → kernel পাতা ০ আনল (page cache থেকে, না থাকলে disk থেকে)
              → প্রোগ্রাম আবার চলল, c পেল
c = p[9000]আরেকটি পাতা
ঠিকানা-জগৎ     পাতা ০               পাতা ১                পাতা ২
অবস্থা         ফাইলের byte 0–4095   ফাঁকা                ফাইলের byte 8192–…
RAM-এ এসেছে   ২টি পাতা          ← পাতা ১ কখনো না ছুঁলে কখনো পড়াই হবে না
অন্য একটি প্রসেস একই ফাইল mmap করলএকই RAM, দুই প্রসেস
প্রসেস A  ──► পাতা ০ ─┐
                       ├──► page cache-এর একই পাতা (RAM-এ একটিই কপি)
প্রসেস B  ──► পাতা ০ ─┘
shared library (libc.so) সব প্রোগ্রামে ঠিক এভাবেই ভাগ হয়
munmap(p, 12000)ঠিকানা মুছে গেল
ঠিকানা-জগৎ     (কিছু নেই)
p[10] পড়লে    Segmentation fault   ← ঝুলন্ত pointer, পর্ব ২০-এর use-after-free-এর মতো
page cache     পাতাগুলো হয়তো আরও কিছুক্ষণ RAM-এ থাকবে — পরের পাঠকের জন্য
  1. mmap — শুধু প্রতিশ্রুতি। ১২০০০ byte-এর ফাইলের জন্য তিনটি পাতার ঠিকানা সংরক্ষিত হলো, কিন্তু একটি byte-ও পড়া হয়নি। তাই বিশাল ফাইলেও mmap প্রায় সাথে সাথে ফেরে।
  2. প্রথম ছোঁয়া — page fault. p[10] পড়তেই CPU দেখল পাতা ০ এখনো ফাঁকা। সে থেমে kernel-কে ডাকল — এটাই page fault, কোনো ভুল নয়। Kernel পাতাটি এনে বসাল, প্রোগ্রাম কিছু টের না পেয়ে আবার চলল।
  3. যা ছোঁবেন, শুধু তা-ই আসে। p[9000] পাতা ২-এ, তাই শুধু সেটি এলো। একটি বড় ফাইলের মাঝখান থেকে অল্প কিছু পড়ার কাজে mmap এজন্যই চমৎকার।
  4. ভাগ করা পাতা। একই ফাইল আরেকটি প্রসেস map করলে দুজনের ঠিকানা RAM-এর একই পাতায় পৌঁছায়। আপনার কম্পিউটারের শত শত প্রোগ্রাম যে একই libc ব্যবহার করে, RAM-এ তার কোড একবারই থাকে — loader সেটি mmap করে।
  5. munmap — ফেরত দেওয়া। munmap-এর পর ওই ঠিকানা আর বৈধ নয়; ছুঁলে SIGSEGV. ফাইলের পাতাগুলো kernel-এর page cache-এ কিছুক্ষণ থাকতে পারে, তাই আবার খুললে দ্রুত পাওয়া যায়।

এবার একটি বাস্তব কাজ: একটি বিক্রির ফাইলে কয়টি লাইন, mmap দিয়ে গোনা। fstat দিয়ে ফাইলের আকার জেনে পুরো ফাইল map করা হয়, তারপর সাধারণ লুপ:

#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/mman.h>
#include <sys/stat.h>
int main(void) {
    int fd = open("sales.txt", O_RDONLY);              /* "rice,2\noil,1\nsugar,3\n" */
    if (fd < 0) { perror("open"); return 1; }
    struct stat st;
    if (fstat(fd, &st) == -1) { perror("fstat"); return 1; }
    char *p = mmap(NULL, (size_t)st.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
    if (p == MAP_FAILED) { perror("mmap"); return 1; }  /* NOT NULL! */
    close(fd);                                           /* the mapping stays valid */
    long lines = 0;
    for (off_t i = 0; i < st.st_size; i++) if (p[i] == '\n') lines++;
    printf("%ld bytes, %ld lines\n", (long)st.st_size, lines);
    munmap(p, (size_t)st.st_size);
    return 0;
}
প্রত্যাশিত আউটপুট (ফাইলে তিনটি লাইন থাকলে; Linux-এ যাচাই করা):
21 bytes, 3 lines
খালি ফাইলে (আকার 0) mmap ব্যর্থ হয়:
mmap: Invalid argument

তিনটি জিনিস খেয়াল করুন। এক, ব্যর্থ হলে mmap NULL নয়, MAP_FAILED ফেরত দেয় — AI প্রায়ই if (!p) লিখে ভুল করে। দুই, map করার পর fd বন্ধ করলেও ম্যাপিং টিকে থাকে। তিন, দৈর্ঘ্য 0 হলে mmap EINVAL দেয়, তাই খালি ফাইল আলাদা করে সামলাতে হয়।

Anonymous mapping — parent আর child-এর ভাগ করা খাতা

পর্ব ৫০-এ দেখেছেন, fork-এর পর parent আর child-এর মেমরি আলাদা — একজন লিখলে অন্যজন দেখে না। কিন্তু fork-এর আগে mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0) দিয়ে মেমরি নিলে, সেই অংশটি fork-এর পরেও দুজনের মধ্যে ভাগ করা থাকে। এটি anonymous mapping — কোনো ফাইল ছাড়া, শূন্যে ভরা shared memory. রানারে যাচাই: child *cnt = 99 লিখে বের হলো, আর parent waitpid-এর পর পড়ল parent sees 99. মনে রাখুন, দুজন একসাথে লিখলে পর্ব ৫২-এর race condition — শৃঙ্খলা নিজেকেই রাখতে হবে।

বিষয়যা জানা দরকার
MAP_PRIVATEনিজের কপি; লিখলে copy-on-write, ফাইল বদলায় না
MAP_SHAREDলিখলে ফাইলে (বা ভাগীদারদের কাছে) পৌঁছায়; ডিস্কে নিশ্চিত করতে msync(MS_SYNC) — fsync-এর mmap-রূপ
পাতার মাপoffset অবশ্যই পাতার মাপের গুণিতক (৪০৯৬); দৈর্ঘ্য যা-ই দিন, kernel পুরো পাতা ধরে হিসাব করে
ফাইলের শেষের পরেশেষ পাতার বাকি অংশ শূন্য; তার পরের পাতা ছুঁলে SIGBUS. অন্য কেউ ফাইল ছোট করে দিলেও তাই

কখন mmap, কখন read?

mmap জেতে

বড় ফাইলের এলোমেলো অংশ বারবার পড়া (database index, বড় ছবি, অভিধান); অনেক প্রসেস একই ফাইল পড়া; ফাইলের ভেতরে struct-এর মতো pointer দিয়ে চলাফেরা; shared library লোড।

random access, ভাগাভাগি

read জেতে

শুরু থেকে শেষ পর্যন্ত একবার পড়া (log কপি, stream); ছোট ফাইল; pipe, socket, terminal — যেগুলো mmap করাই যায় না; ভুল স্পষ্টভাবে সামলাতে চাইলে।

ধারাবাহিক পড়া, stream

mmap জাদুর গুলি নয় — প্রতিটি page fault-এর খরচ আছে। নিয়মটি পর্ব ৫৫-এর মতোই: অনুমান নয়, মেপে দেখুন।

Windows-এ কী হয়? — portability

এই পর্বের সবকিছু POSIX. Windows-এর নিজস্ব নকশা আছে, যা ধারণায় কাছাকাছি কিন্তু নামে ও খুঁটিনাটিতে আলাদা:

POSIXWindows (Win32 API)মন্তব্য
int file descriptorHANDLEছোট ক্রমিক সংখ্যা নয়; ফাইল, প্রসেস, event — সবই HANDLE
open / read / write / closeCreateFile / ReadFile / WriteFile / CloseHandleMSVC-র _open, _read fd-এর অনুকরণ দেয়
signal, sigactionC-এর signal() সীমিতভাবে; Ctrl+C-এর জন্য SetConsoleCtrlHandlerSIGCHLD, SIGPIPE, SIGALRM নেই; sigaction-ই নেই
poll / epollWSAPoll (শুধু socket), WaitForMultipleObjects, IOCPIOCP “কাজ শেষ হলে জানাও” ধাঁচের — readiness নয়, completion
mmapCreateFileMapping + MapViewOfFileধারণা একই
forkনেই; CreateProcessপর্ব ৫০ দেখুন

তাই portable প্রোগ্রাম হয় শুধু standard C-র stdio ব্যবহার করে, নয়তো libuv-এর মতো library, যা নিচে নিজেই epoll, kqueue বা IOCP বেছে নেয় (Node.js-এর ভেতরে এটিই আছে)।

পরীক্ষা ও ইন্টারভিউতে — বিশ্ববিদ্যালয়ের অপারেটিং সিস্টেম ও সিস্টেম প্রোগ্রামিং পরীক্ষা, সরকারি-ব্যাংকের প্রোগ্রামার পদের লিখিত পরীক্ষা আর কোডিং ইন্টারভিউতে এই ধরনের প্রশ্ন আসতে পারে:

  • File descriptor কী? stdin, stdout, stderr-এর নম্বর কত? প্রসেসের খোলা ফাইল-তালিকার একটি সূচক (ছোট পূর্ণসংখ্যা); 0, 1, 2.
  • fopen আর open-এর পার্থক্য? fopen standard C, buffer-যুক্ত FILE* দেয়; open POSIX system call, buffer ছাড়া int fd দেয়।
  • কোন signal ধরা বা উপেক্ষা করা যায় না? SIGKILL আর SIGSTOP.
  • Signal handler-এ printf কেন নয়? এটি async-signal-safe নয় — মূল প্রোগ্রামের অর্ধেক কাজ করা stdio buffer বা lock-এ ঢুকে deadlock বা এলোমেলো আউটপুট ঘটাতে পারে; flag সেট করুন বা write ব্যবহার করুন।
  • select, poll আর epoll-এর পার্থক্য? select-এ fd-সীমা (সাধারণত ১০২৪) আর প্রতিবার পুরো তালিকা; poll-এ সীমা নেই কিন্তু খরচ সব fd-এর অনুপাতে; epoll-এ একবার নিবন্ধন, খরচ শুধু তৈরি fd-এর অনুপাতে।

৯ · AI-এর যুগে low-level কোড — যা একবার চালিয়ে ধরা পড়ে না

AI-এর লেখা signal বা I/O কোড আপনার মেশিনে একশোবার ঠিকঠাক চলতে পারে, কারণ এসব বাগ নির্ভর করে সময়ের ওপর — ধরা পড়ে সার্ভারে, ভিড়ের সময়।

AI-এর ভুলফলকীভাবে ধরবেন
Signal handler-এ printf, malloc, exit, ফাইল সেভকালেভদ্রে deadlock বা নষ্ট আউটপুট — পুনরায় ঘটানো প্রায় অসম্ভবHandler-এ flag ছাড়া কিছু আছে কি না দেখুন; থাকলে async-signal-safe তালিকা মেলান
Flag সাধারণ int, volatile sig_atomic_t নয়-O2-তে লুপ কখনো থামে নাFlag-এর ঘোষণা দেখুন; optimization চালু করে পরীক্ষা করুন
signal() ব্যবহার, sigaction নয়সিস্টেম ও compile flag অনুযায়ী আলাদা আচরণsigaction + sigemptyset + স্পষ্ট sa_flags চান
write(fd, buf, n)-এর ফেরত মান উপেক্ষাSocket/pipe-এ ডেটার শেষ অংশ নিঃশব্দে হারায়প্রতিটি read/write-এর ফেরত মান লুপে সামলানো হয়েছে কি না
EINTR সামলানো নেইSignal এলেই “অকারণ” ব্যর্থতাপ্রতিটি blocking ডাকের পাশে EINTR-এর শাখা খুঁজুন
ভুলের পথে close বাদfd leak — কয়েক ঘণ্টা পর Too many open filesপ্রতিটি return-এর আগে খোলা fd গুনুন; ls /proc/<pid>/fd
SIGPIPE নিয়ে কিছুই নেইএকজন client সংযোগ কাটলেই সার্ভার নিঃশব্দে শেষSIG_IGN বা MSG_NOSIGNAL, আর EPIPE-এর শাখা

AI-কে low-level কোড লিখতে দিলে নিয়মগুলো আগেই বলে দিন, আর তাকে দিয়েই নিজের কোডের ঝুঁকি খুঁজিয়ে নিন:

Linux-এর জন্য C-তে একটি প্রোগ্রাম লেখো, যা stdin থেকে লাইন পড়ে একটি log ফাইলে লিখবে, আর SIGINT বা SIGTERM পেলে গুছিয়ে বন্ধ হবে। কড়া নিয়ম: ১. handler বসাবে sigaction দিয়ে (signal() নয়), sa_mask খালি, sa_flags স্পষ্ট করে লিখবে এবং কেন SA_RESTART দিলে বা দিলে না তা বলবে। ২. handler-এর ভেতরে শুধু একটি volatile sig_atomic_t flag সেট করবে — printf, malloc, exit কিছুই নয়। ৩. প্রতিটি read/write-এর ফেরত মান সামলাবে: partial write-এর জন্য লুপ, EINTR হলে আবার চেষ্টা। ৪. প্রতিটি ভুলের পথে খোলা fd বন্ধ হবে; শুরুতেই SIGPIPE উপেক্ষা করবে। শেষে একটি তালিকা দাও: কোডের কোন লাইনে signal এলে কী হবে, আর কোন জায়গা এখনো ঝুঁকিপূর্ণ।

AI-এর লেখা low-level কোড হাতে পেলে — যাচাইয়ের চেকলিস্ট

অন্য ভাষায় একই ধারণা: Java NIO-র FileChannel.map হলো mmap, আর Selector Linux-এ ভেতরে epoll ব্যবহার করে — দেখুন Java-র ফাইল I/O ও NIO পর্ব. Python-এর signal মডিউলের handler আসলে C-স্তরের একটি flag দেখে পরে মূল thread-এ চলে — ঠিক এই পর্বের নকশা (Python-এর ফাইল I/O পর্ব).

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন। এগুলোই আপনাকে কোড-লেখক থেকে সিস্টেম-চিন্তক বানাবে।

প্র ০১UNIX-এর “everything is a file” নীতি এত শক্তিশালী কেন — আর কোথায় এসে এটি ভেঙে পড়ে?

১৯৭০-এর দশকে প্রতিটি যন্ত্রের জন্য আলাদা নিয়ম ছিল সাধারণ ব্যাপার — টেপ ড্রাইভের এক ভাষা, প্রিন্টারের আরেক। Thompson আর Ritchie-র সিদ্ধান্ত ছিল: সবকিছু একটি নামে খোলা যাবে, আর খুললে পাওয়া যাবে একটি file descriptor, যাতে read আর write চলে।

এর শক্তি কোথায়? একটি সাধারণ interface থাকলে প্রোগ্রামগুলো একে অপরের সাথে না জেনেই জোড়া লাগে। grep জানে না সে ফাইল পড়ছে, pipe পড়ছে, নাকি নেটওয়ার্ক — সে শুধু fd 0 পড়ে। এজন্যই shell-এর >, | আর dup2 এত সরলভাবে কাজ করে, এজন্যই cat /proc/cpuinfo দিয়ে CPU-র তথ্য পড়া যায়। পর্ব ০৩-এর LLVM-এর শিক্ষাও একই — মাঝখানে একটি সাধারণ interface বসালে M × N সমস্যা M + N হয়ে যায়।

কোথায় ভাঙে?

  • Socket: socket fd-তে read/write চলে, কিন্তু তৈরি করতে লাগে socket, bind, listen, accept — নামে খোলা যায় না। Berkeley socket API (১৯৮৩) ফাইলের মডেলে পুরোপুরি বসেনি।
  • ioctl: যন্ত্রের যেসব কাজ “byte পড়া-লেখা” নয় (terminal-এর আকার, ডিস্কের partition), সেগুলোর জন্য একটি সাধারণ “বাকি সব” ফাংশন — interface-এর পেছনের দরজা।
  • Signal, প্রসেস, timer: এগুলো শুরুতে fd ছিল না; তাই Linux পরে signalfd, timerfd, pidfd যোগ করে — অর্থাৎ নীতির দিকে ফেরার চেষ্টা।

মূল উপলব্ধি: একটি সরল, সাধারণ interface সময়ের সাথে বহু গুণ ফল দেয়, কিন্তু কোনো abstraction সব কিছু ধরতে পারে না।

প্র ০২আদি UNIX-এর signal কেন “অবিশ্বস্ত” ছিল, আর POSIX-এর sigaction কী কী সমস্যা সমাধান করল?

আদি UNIX-এর (যেমন ১৯৭৯ সালের Version 7) signal ব্যবস্থা ছিল খুব সরল, আর সেই সরলতাতেই তিনটি গর্ত:

  • Handler একবারের জন্য: signal পৌঁছানোর সাথে সাথে kernel handler-কে ডিফল্টে ফিরিয়ে দিত। Handler-এর প্রথম লাইনে আবার signal(SIGINT, handler) লিখতে হতো। এই দুই মুহূর্তের মাঝে দ্বিতীয় Ctrl+C এলে ডিফল্ট কাজ — প্রোগ্রাম মারা যেত। দুর্লভ, কিন্তু চাপের মধ্যে ঘটত।
  • আটকে রাখার উপায় নেই: “এই অংশে signal এসো না” বলা যেত না; শুধু উপেক্ষা করা যেত, আর উপেক্ষিত signal হারিয়ে যেত।
  • অপেক্ষার race: “flag সেট না হলে pause() করো” — flag যাচাই আর pause-এর মাঝখানে signal এলে প্রোগ্রাম চিরকাল ঘুমিয়ে থাকত, কারণ যে signal-এর অপেক্ষা সেটি আগেই চলে গেছে।

১৯৮০-এর দশকে BSD আর AT&T-র System V দুজনেই নিজের মতো “নির্ভরযোগ্য signal” আনল — কিন্তু আলাদা নামে, আলাদা আচরণে। ফলে একই কোড দুই সিস্টেমে দুই রকম চলত। ১৯৮৮ সালে IEEE-র POSIX.1 মান মিটমাট করল:

  • sigaction: handler স্থায়ী থাকে (চাইলে SA_RESETHAND দিয়ে পুরোনো আচরণ), sa_mask দিয়ে handler চলার সময় কোন signal আটকে থাকবে বলা যায়, আর flag দিয়ে আচরণ স্পষ্ট।
  • sigprocmask: signal আটকে রাখা যায়; আটকানো signal হারায় না, অপেক্ষা করে।
  • sigsuspend: “mask বদলাও আর ঘুমাও” — এক অবিভাজ্য কাজে, তাই race-এর ফাঁক বন্ধ।

পুরোনো signal() কিন্তু মোছা হয়নি — standard C-তে আজও আছে, কারণ বহু পুরোনো কোড এর ওপর নির্ভর করে। দাম হলো আজকের বিভ্রান্তি: glibc-তে compile flag অনুযায়ী এর আচরণ বদলায়, যা এই পর্বেই দেখলেন।

মূল উপলব্ধি: concurrency-র বাগ প্রায়ই “দুই ধাপের মাঝখানের ফাঁক”-এ থাকে। ভালো API সেই ফাঁক বন্ধ করে একাধিক ধাপকে একটি অবিভাজ্য কাজে জুড়ে দেয় — sigsuspend থেকে শুরু করে mutex আর atomic (পর্ব ৫২) পর্যন্ত একই চিন্তা।

প্র ০৩select থেকে poll, তারপর epoll আর kqueue — একই প্রশ্নের নতুন নতুন API কেন বানাতে হলো?

প্রশ্নটি সবসময় একই: “এই fd-গুলোর মধ্যে কোনগুলো তৈরি?” পার্থক্য হলো, প্রশ্নটি কতবার আর কতটুকু তথ্য দিয়ে করতে হয়।

select (BSD, ১৯৮০-এর দশক): প্রতিবার একটি bit-তালিকা দিতে হয়, kernel সব bit দেখে, ফেরত দেয় বদলানো তালিকা — যা আবার নতুন করে বানাতে হয়। fd নম্বরের সীমা (সাধারণত ১০২৪) bit-তালিকার নকশাতেই বাঁধা। কয়েকটি terminal দেখার যুগে এটা যথেষ্ট ছিল।

poll (System V): array দেয়, তাই সীমা নেই, আর ফল আলাদা revents-এ আসে বলে তালিকা নষ্ট হয় না। কিন্তু মূল সমস্যা থেকে যায় — প্রতিটি ডাকে পুরো তালিকা kernel-এ কপি হয়, kernel প্রতিটি fd পরীক্ষা করে, ফেরার পর প্রোগ্রামও সবটা ঘুরে দেখে। দশ হাজার সংযোগের দশটি সক্রিয় হলেও কাজ দশ হাজারের।

১৯৯৯ সালে Dan Kegel যখন C10K প্রশ্ন তোলেন (পর্ব ৫৩-এ বিস্তারিত), তখন এই খরচই ছিল দেয়াল। উত্তর এলো একটি নকশা-পরিবর্তনে: stateless থেকে stateful API. FreeBSD ২০০০ সালে kqueue আনে, Linux ২০০২ সালে 2.5.44-এ epoll (2.6-এ স্থায়ী)। দুটিতেই আগ্রহের তালিকা একবার kernel-এ রেখে দেওয়া হয়; kernel নিজেই তৈরি fd-গুলোর একটি আলাদা সারি রাখে; প্রতিটি ডাকে শুধু সেই সারি আসে। খরচ হয় সক্রিয় সংযোগের অনুপাতে, মোট সংযোগের নয়।

দাম কী? নতুন API OS-নির্দিষ্ট — epoll শুধু Linux, kqueue শুধু BSD/macOS, আর Windows-এর IOCP পুরো আলাদা দর্শনে চলে। এই খণ্ডিত জগৎ জুড়তে libevent, libuv-এর মতো library জন্মায়।

মূল উপলব্ধি: একটি API-র নকশা বলে দেয় সে কত বড় পর্যন্ত টিকবে। “প্রতিবার সব কিছু পাঠাও” সরল, কিন্তু বড় হলে ভেঙে পড়ে; “একবার জানিয়ে রাখো, শুধু পরিবর্তন শোনো” জটিল, কিন্তু বড় হয়।

প্র ০৪Linux-এর সবচেয়ে নতুন I/O ব্যবস্থা io_uring আসলে কী বদলাল — আর একে নিয়ে সতর্কতা কেন?

epoll বলে “কোন fd তৈরি”, তারপরও প্রতিটি fd-র জন্য আলাদা read/write system call করতে হয়। প্রতিটি system call-এ user space থেকে kernel space-এ যাওয়া-আসার খরচ আছে, আর Spectre-Meltdown (২০১৮)-এর পর নিরাপত্তার কারণে সেই খরচ আরও বেড়েছে। তার ওপর সাধারণ ফাইলের জন্য এরা কাজই করে না — epoll-এ ডিস্কের ফাইল যোগই করা যায় না, আর poll/select তাকে সবসময় “তৈরি” বলে, অথচ পড়তে গিয়ে প্রোগ্রাম আটকে যেতে পারে।

io_uring এসেছে Linux 5.1-এ, ২০১৯ সালে; মূল নকশাকার Jens Axboe. নকশাটি এমন:

  • প্রোগ্রাম আর kernel দুটি ring buffer ভাগ করে নেয় — mmap দিয়ে, ঠিক এই পর্বের মতো। একটিতে প্রোগ্রাম অনুরোধ জমা দেয় (submission queue: “এই fd থেকে এত byte পড়ো”), অন্যটিতে kernel ফল রাখে (completion queue).
  • একসাথে অনেক অনুরোধ জমা দিয়ে একটি system call-এ পাঠানো যায়; কিছু মোডে system call ছাড়াই kernel নিজে ring দেখে নেয়।
  • এটি completion-ভিত্তিক — Windows-এর IOCP-র মতো “কাজ শেষ হলে জানাও”। ফাইল, socket, এমনকি open, accept-এর মতো কাজও এভাবে চলে।

সতর্কতা কেন? io_uring kernel-এর একটি বড় ও নতুন অংশ, আর kernel-এর ভেতরে প্রোগ্রামের দেওয়া অনুরোধ অন্যভাবে, অনেক পথে চলে। কয়েক বছরে এতে বেশ কিছু গুরুতর নিরাপত্তা-ত্রুটি পাওয়া গেছে। Google ২০২৩ সালে জানায়, এসব ঝুঁকির কারণে তারা ChromeOS ও নিজেদের production সার্ভারে io_uring বন্ধ রাখছে এবং Android-এ সাধারণ অ্যাপের জন্য সীমিত করছে। তাই এটি শক্তিশালী, কিন্তু “সবার জন্য ডিফল্ট” এখনো নয়।

মূল উপলব্ধি: প্রতিটি নতুন গতির কৌশল একটি নতুন আক্রমণের পথও খোলে। প্রকৌশলে “সবচেয়ে নতুন” মানেই “সবচেয়ে উপযুক্ত” নয় — read/write আর poll আজও বহু সার্ভারের ভিত, কারণ সেগুলো বোঝা, যাচাই করা আর portable.

প্র ০৫“async-signal-safe” ফাংশনের একটি আলাদা তালিকা কেন দরকার হলো? printf তো thread-safe — তাহলে signal-safe নয় কেন?

দুটি শব্দ শুনতে কাছাকাছি, কিন্তু সমস্যা ভিন্ন। Thread-safe মানে দুটি আলাদা thread একসাথে ডাকলেও ঠিক থাকে — সাধারণত একটি lock দিয়ে: একজন ধরলে অন্যজন অপেক্ষা করে। কিন্তু signal handler আলাদা thread নয়; সে একই thread-কে মাঝপথে থামিয়ে তার ওপরেই চলে।

এখন দৃশ্যটি ভাবুন: মূল প্রোগ্রাম printf-এর ভেতরে stdout-এর lock ধরেছে। Signal এলো, handler-ও printf ডাকল, lock চাইল। Lock কার হাতে? একই thread-এর — যে এখন handler শেষ না হওয়া পর্যন্ত নড়বে না। Handler অপেক্ষা করছে মূল প্রোগ্রামের জন্য, মূল প্রোগ্রাম অপেক্ষা করছে handler-এর জন্য — চিরকালের deadlock. Lock না থাকলে আরও খারাপ: অর্ধেক-বদলানো buffer বা heap-এর তালিকায় handler ঢুকে সব এলোমেলো করে দেয়। malloc ঠিক এই কারণেই নিষিদ্ধ।

এর মূলে আছে reentrancy — একটি ফাংশন নিজের কাজ শেষের আগেই আবার ডাকা হলে ঠিক থাকে কি না। যে ফাংশন লুকানো global অবস্থা (buffer, static ভ্যারিয়েবল, lock) ছোঁয়, সে সাধারণত reentrant নয়। write নিরাপদ, কারণ সে সরাসরি একটি system call — user space-এ কোনো ভাগ করা অবস্থা নেই। errno-ও এখানে ফাঁদ: handler-এর ভেতরের কোনো ডাক errno বদলালে, মূল প্রোগ্রাম ফিরে এসে ভুল errno দেখবে; তাই handler শুরুতে errno রেখে শেষে ফিরিয়ে দেয়।

POSIX তাই একটি স্পষ্ট তালিকা দিয়েছে। আর আধুনিক উত্তর হলো handler-কেই প্রায় বিদায় দেওয়া: self-pipe বা signalfd দিয়ে signal-কে একটি সাধারণ ঘটনায় বদলে মূল লুপে আনা। পর্ব ৫৪-এ embedded C-র interrupt handler (ISR)-এও একই নিয়ম পাবেন — ISR-এ ছোট কাজ, flag সেট, আসল কাজ মূল লুপে।

মূল উপলব্ধি: “হঠাৎ যেকোনো মুহূর্তে চলে আসা কোড” — signal handler, interrupt, callback — সবসময় ধরে নিন বাকি প্রোগ্রাম অর্ধেক কাজ করা অবস্থায় আছে। এমন কোডে যত কম কাজ, তত কম বিপদ।

অনুশীলন

বেশিরভাগ কাজে কোড লিখতে হবে না — চালিয়ে দেখা, অনুমান করা আর ভাবাই আসল কাজ।

  1. ক্রমের রহস্য: উপরের log_copy.c-এ fflush(stdout); মুছে রানারে চালান। আউটপুটের ক্রম লিখুন। তারপর অনুমান করুন — একই প্রোগ্রাম নিজের terminal-এ সরাসরি চালালে, আর ./a.out | cat দিয়ে চালালে কী দেখবেন?

    রানারে আসে: boot ok, login rahim, disk 91%, তারপর log opened as fd 3, শেষে read() calls: 4. রানারে stdout একটি pipe, তাই stdio fully buffered — দুটি printf-এর লেখা buffer-এ বসে থাকে আর প্রোগ্রাম শেষে একসাথে বেরোয়, অথচ write(1, …) সাথে সাথে পৌঁছায়।

    নিজের terminal-এ সরাসরি চালালে stdout line-buffered, তাই \n-এই লাইনটি চলে যায় — ক্রম ঠিক দেখাবে। | cat দিলে stdout আবার pipe, তাই রানারের মতো উল্টো ক্রম। একই প্রোগ্রাম, শুধু stdout কোথায় যুক্ত তার ওপর আচরণ বদলাল — এজন্যই এই বাগ “আমার মেশিনে তো ঠিক আছে” হয়ে লুকিয়ে থাকে।

  2. strace দিয়ে উঁকি: Linux বা WSL-এ চালান: strace -e trace=openat,read,write,close cat /etc/hostname. আউটপুটে খুঁজুন — কোন fd নম্বর ফেরত এলো, read কতবার ডাকা হলো, শেষ read কত ফেরত দিল, আর open-এর বদলে openat কেন?

    শুরুতে কয়েকটি openat দেখবেন যা cat নিজে করেনি — loader shared library (যেমন libc.so.6) খুলছে, আর সেগুলো mmap করছে। তারপর openat(AT_FDCWD, "/etc/hostname", O_RDONLY) = 3 — সবচেয়ে ছোট খালি নম্বর ৩. এরপর একটি read(3, …) ফাইলের লেখা ফেরত দেয়, write(1, …) সেটি স্ক্রিনে পাঠায়, আর পরের read(3, …) = 0 — EOF. শেষে close(3).

    আধুনিক glibc-এর open() ভেতরে openat system call ব্যবহার করে (AT_FDCWD = বর্তমান ফোল্ডার থেকে) — library আর system call-এর নাম সবসময় এক নয়। (খুঁটিনাটি system ভেদে একটু আলাদা হতে পারে।)

  3. বাগ শিকার: একটি AI এই handler দিল: void on_int(int s) { printf("Saving...\n"); save_all(); exit(0); }, বসাল signal(SIGINT, on_int); দিয়ে, আর মূল লুপে write(sock, buf, len); লিখল ফেরত মান না দেখে। অন্তত চারটি সমস্যা খুঁজে প্রতিটির সমাধান বলুন।
    • Handler-এ printf: async-signal-safe নয় — মূল প্রোগ্রাম printf-এর মাঝে থাকলে deadlock বা নষ্ট আউটপুট। সমাধান: volatile sig_atomic_t flag.
    • Handler-এ save_all() আর exit(0): ফাইল সেভ মানে fopen/malloc/fprintf — সব নিষিদ্ধ; exit stdio flush করে, সেটিও নিরাপদ নয়। সমাধান: মূল লুপ flag দেখে সেভ করে স্বাভাবিকভাবে বের হবে। হ্যান্ডলার থেকে বেরোতেই হলে _exit.
    • signal(): আচরণ সিস্টেম ও compile flag অনুযায়ী আলাদা। সমাধান: sigaction, sigemptyset, স্পষ্ট sa_flags.
    • write-এর ফেরত মান উপেক্ষা: socket-এ partial write স্বাভাবিক; EINTR আর EPIPE-ও আসতে পারে। সমাধান: write_all-ধাঁচের লুপ, EINTR-এ আবার চেষ্টা, EPIPE-এ সংযোগ বন্ধ।
    • বোনাস — SIGPIPE নেই: client চলে গেলে প্রোগ্রাম নিঃশব্দে মরবে। সমাধান: SIG_IGN বা MSG_NOSIGNAL.
  4. SA_RESTART-এর পরীক্ষা (কোড বদলানো): graceful.c-এ sa.sa_handler = on_sig;-এর পরে sa.sa_flags = SA_RESTART; যোগ করুন। চালানোর আগে অনুমান করুন কী হবে, তারপর চালিয়ে মিলিয়ে নিন। এরপর ভাবুন: SA_RESTART রেখেও watchdog কীভাবে কাজ করানো যায়?

    প্রথম ভাগ একই থাকবে (job 1–3, got signal 2), কারণ সেখানে কোনো blocking system call ছিল না। কিন্তু দ্বিতীয় ভাগে SIGALRM আসার পর handler চলবে, তারপর kernel read-কে নিজেই আবার শুরু করবে — আর pipe-এ কেউ লিখবে না, তাই প্রোগ্রাম চিরকাল আটকে থাকবে। রানার কোনো error দেখাবে না, শুধু সময়সীমা পেরিয়ে থেমে যাবে। এটাই SA_RESTART-এর দুই দিক: EINTR-এর ঝামেলা কমায়, কিন্তু signal দিয়ে অপেক্ষা ভাঙার ক্ষমতাও কেড়ে নেয়।

    SA_RESTART রেখেও timeout চাইলে, অপেক্ষাটাই timeout-সহ করুন: poll(&pfd, 1, 1000) দিয়ে আগে দেখুন pipe তৈরি কি না, 0 এলে timeout. Linux-এ poll কখনো restart হয় না, আর timeout তার নিজেরই অংশ — তাই alarm-ই লাগে না। এটাই আধুনিক নকশার পছন্দ: signal দিয়ে নিয়ন্ত্রণপ্রবাহ ভাঙার বদলে সময়সীমা অপেক্ষার ডাকেই বলে দেওয়া।

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমSIGTERM পেয়ে একটি সার্ভারের গুছিয়ে বন্ধ হওয়ার ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. শুরুতে sigaction দিয়ে SIGTERM-এর handler বসানো
  2. মূল লুপ poll/read দিয়ে স্বাভাবিক কাজ করছে
  3. SIGTERM এলো — handler শুধু stop = 1 করল
  4. অপেক্ষার ডাক EINTR দিল, মূল লুপ flag দেখে বেরিয়ে এলো
  5. Buffer flush, ফাইল ও সংযোগ বন্ধ, অস্থায়ী ফাইল মুছে exit(0)

n = read(fd, buf, 100); ফেরত দিল 40. এর অর্থ কী?

  • একটি ভুল হয়েছে, errno দেখতে হবে
  • ৪০ byte পাওয়া গেছে; আরও লাগলে আবার read ডাকতে হবে
  • ফাইল শেষ হয়ে গেছে, আর কিছু পড়ার নেই
  • buf-এর বাকি ৬০ byte-এ আবর্জনা পড়া হয়েছে

কোন signal কোনো প্রোগ্রাম ধরতে, উপেক্ষা করতে বা আটকাতে পারে না?

  • SIGINT
  • SIGTERM
  • SIGKILL
  • SIGPIPE

Signal handler-এর ভেতরে printf ডাকা কেন বিপজ্জনক?

  • printf async-signal-safe নয় — মূল প্রোগ্রাম printf-এর মাঝে থাকলে deadlock বা নষ্ট buffer হতে পারে
  • printf খুব ধীর, তাই signal হারিয়ে যায়
  • Handler-এর ভেতরে stdout বন্ধ থাকে
  • Compiler এটি compile করতে দেয় না

একটি সার্ভার এমন socket-এ লিখল, যার client আগেই সংযোগ কেটে দিয়েছে। কিছু না করলে Linux-এ ডিফল্টে কী হয়?

  • write -1 ফেরত দেয়, errno = EPIPE
  • ডেটা জমা থাকে, client ফিরলে পৌঁছায়
  • স্ক্রিনে একটি সতর্কবার্তা আসে, প্রোগ্রাম চলতে থাকে
  • প্রসেস SIGPIPE পেয়ে নিঃশব্দে মারা যায়

poll(fds, 3, 2000) ফেরত দিল 0. এর অর্থ কী?

  • poll ব্যর্থ হয়েছে, errno দেখতে হবে
  • ২ সেকেন্ডে কোনো fd তৈরি হয়নি — timeout
  • প্রথম fd (সূচক 0) পড়ার জন্য তৈরি
  • তিনটি fd-ই বন্ধ হয়ে গেছে

fd = open("out.txt", …) 3 দিল। তারপর dup2(fd, 1); close(fd); printf("hi\n"); — “hi” কোথায় যাবে?

  • স্ক্রিনে, কারণ printf সবসময় স্ক্রিনে লেখে
  • কোথাও না, কারণ ফাইলটি close করা হয়েছে
  • out.txt ফাইলে
  • stderr-এ

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. File descriptor = kernel-এর টোকেন। 0, 1, 2 আগে থেকেই খোলা; open দেয় সবচেয়ে ছোট খালি নম্বর। প্রতিটি open-এর জোড়া close, নইলে fd leak.
  2. read/write: >0 = এতটুকু, 0 = EOF, -1 = errno দেখুন। Partial count আর EINTR-এর জন্য লুপ।
  3. dup2(fd, 1) দিয়েই shell-এর >. একই fd-তে stdio আর write মেশালে সীমানায় fflush.
  4. Signal যেকোনো মুহূর্তে আসে। sigaction দিয়ে বসান, handler-এ শুধু volatile sig_atomic_t flag; SIGKILL ধরা যায় না; সার্ভারে SIGPIPE উপেক্ষা করে EPIPE দেখুন; SIGCHLD-এ waitpid(-1, …, WNOHANG) লুপ।
  5. poll এক thread-এ অনেক fd দেখে (হাজারো হলে epoll); mmap ফাইলকে পাতা ধরে মেমরিতে আনে — ব্যর্থতা MAP_FAILED, NULL নয়।

পরের পর্বে: এতক্ষণ একটি প্রসেসের ভেতরে একটিমাত্র ধারায় কাজ হচ্ছিল, আর একসাথে অনেক কিছু সামলাতে আমরা poll-এর ওপর ভর করেছি। পরের পর্বে আরেকটি পথ — একই প্রসেসের ভেতরে কয়েকটি thread, যারা মেমরি ভাগ করে একসাথে চলে; সাথে race condition, mutex আর deadlock.

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান