Pipe এবং FIFO — দুইটা process-এর মধ্যে সবচেয়ে পুরনো IPC, যেটা এখনো সবচেয়ে বেশি ব্যবহৃত
Pipes and FIFOs
`ls | grep foo | wc -l` -- প্রতিদিন টাইপ করা এই একটা লাইনের পেছনে একটা সরল কিন্তু কঠোর কাঠামো আছে: kernel-মেমরিতে একটা ৬৪ KB ring buffer, দুইটা fd (read end, write end), আর একটা কড়া নিয়ম -- সব write-end বন্ধ না হওয়া পর্যন্ত reader কখনো EOF পায় না। এই লেসনে সেই buffer-এর প্রকৃত আচরণ, শেলের wiring-এর নিচের প্রক্রিয়া, SIGPIPE/EPIPE, PIPE_BUF-এর atomicity গ্যারান্টি, আর সবচেয়ে গুরুত্বপূর্ণ -- কেন একটা পূর্ণ pipe-এ block হয়ে যাওয়াটা আসলে একটা ফিচার -- দেখব।
আগে এটা বুঝি
গত লেসনের একদম শেষে একটা প্যাটার্ন উঠে এসেছিল যেটা প্রায় অগোচরে চলে গিয়েছিল — একটা process কোনো fd থেকে ডেটার জন্য অপেক্ষা করে ব্লক হয়ে বসে থাকে, kernel-এর একটা wait queue-তে যোগ হয়, আর যখনই ডেটা আসে তাকে জাগানো হয়। Keyboard-এর ক্ষেত্রে এই “ডেটা আসা”-টা এসেছিল hardware interrupt থেকে। কিন্তু এই একই প্যাটার্ন কাজ করে যখন কোনো hardware-ই জড়িত নেই — শুধু দুইটা process, একটা লিখছে, একটা পড়ছে।
আপনি প্রতিদিন এটা টাইপ করেন:
ls -l | grep '^d' | wc -lতিনটা আলাদা প্রোগ্রাম, তিনটা আলাদা process, অথচ ডেটা একটা থেকে আরেকটায় নির্বিঘ্নে প্রবাহিত হয় — কোনো temp file নেই, কোনো network নেই। এই তিনটাকে জোড়া লাগানো যন্ত্রটার নাম pipe, আর এটা ইউনিক্সের সবচেয়ে পুরনো IPC মেকানিজম (1973-এ Ken Thompson-এর হাতে জন্ম, Doug McIlroy-র ধারণা থেকে) — অথচ আজও, ৫০ বছর পরে, এটাই সবচেয়ে বেশি ব্যবহৃত।
কিন্তু এর নিচে কী আছে সেটা প্রায়ই ভুল বোঝা হয়। এটা কি একটা ফাইল? ডেটা কি ডিস্কে যায়? একবারে কত বাইট পাঠানো যায়? দুইজন লিখলে একসাথে কী হয়? আর সবচেয়ে গুরুত্বপূর্ণ প্রশ্ন — grep যখন বসে বসে অপেক্ষা করছে, তখন সে ঠিক কী অবস্থায় আছে, আর ls শেষ হয়ে গেলে সে কীভাবে জানে যে আর কিছু আসবে না? এই লেসনে এই প্রশ্নগুলোর প্রতিটার সঠিক, কংক্রিট উত্তর দেব।
মূল ধারণা
Pipe আসলে কী — একটা in-memory ring buffer, ফাইল নয়
pipe(2) সিস্টেম কল দুইটা file descriptor ফেরত দেয়:
int fds[2];
pipe(fds);
/* fds[0] -- read end
fds[1] -- write end */এই দুইটা fd-র মাঝে যা আছে তা ডিস্কে কোনো ফাইল নয় — এটা kernel মেমরিতে একটা ring buffer, struct pipe_buffer array হিসেবে বাস্তবায়িত, প্রতিটা entry একটা মেমরি page-এর দিকে নির্দেশ করে। Linux-এ ডিফল্ট ক্ষমতা ৬৪ KB (১৬টা page × ৪ KB, kernel 2.6.35 থেকে; তার আগে ছিল মাত্র ৪ KB — একটা single page)।
| বৈশিষ্ট্য | মান |
|---|---|
| ডিফল্ট ক্ষমতা | ৬৪ KB (Linux ≥ 2.6.35) |
| দিক | Half-duplex — ডেটা শুধু এক দিকে, fds[1] থেকে fds[0]-তে |
| সংরক্ষণ | সম্পূর্ণ kernel মেমরিতে, ডিস্কে নয় |
| দৃশ্যমানতা | filesystem-এ কোনো নাম নেই — শুধু সেই দুইটা fd-র মাধ্যমেই অ্যাক্সেসযোগ্য (তাই “anonymous”) |
Half-duplex সীমাবদ্ধতাটা লক্ষণীয় — একটা pipe দিয়ে দুই দিকেই ডেটা পাঠাতে চাইলে দুইটা pipe লাগবে, একটা প্রতিটা দিকে। এটা কোনো সীমাবদ্ধতা নয় বরং সরলতা — একটা single-buffer, single-direction primitive-এর উপর জটিল দ্বিমুখী প্রোটোকল বানানো প্রোগ্রামারের দায়িত্বে ছেড়ে দেওয়া হয়েছে।
Half-duplex থেকে pipeline — শেল কীভাবে | wiring করে
শেল যখন cmd1 | cmd2 দেখে, সে চারটা ধাপে কাজ করে — pipe() কল, দুইটা fork(), প্রতিটা child-এ dup2() দিয়ে stdin/stdout redirect, আর তারপর সব fd সব জায়গায় বন্ধ করা। build-a-shell প্রজেক্টে এই সিকোয়েন্সটা কোড-আকারে দেখেছেন — এখানে আমরা কেন এই বন্ধ করাটা বাধ্যতামূলক, তার কারণটা kernel-স্তরে ঠিক কী ঘটে সেটা দিয়ে ব্যাখ্যা করব।
প্রতিটা fd আসলে একটা open file description (OFD)-এর দিকে একটা reference। pipe() একটা OFD তৈরি করে pipe buffer-এর জন্য, আর fd সেই OFD-র দিকে নির্দেশ করে। fork() child-কে বাবার সব fd-র কপি দেয় — কিন্তু কপি মানে নতুন OFD নয়, বরং একই OFD-তে reference count বাড়ে। dup2()ও একই কাজ করে, নতুন fd number দিয়ে একই OFD-তে আরেকটা reference যোগ করে।
kernel-এর নিয়মটা নিরঙ্কুশ: যতক্ষণ write-end OFD-র reference count শূন্যে না নামে, ততক্ষণ read() কখনো EOF (0) ফেরত দেবে না — এমনকি write-end-এ এই মুহূর্তে কেউ সক্রিয়ভাবে না লিখলেও। কারণ kernel জানে না ভবিষ্যতে কেউ লিখবে কি না — যতক্ষণ একটা reference খোলা আছে, ততক্ষণ সম্ভাবনা আছে।
OFD (pipe write-end)
refcount = 3
│
┌─────────────────────┼─────────────────────┐
│ │ │
parent শেলের fd child1-এর dup2'd fd child1-এর মূল
(ভুলে বন্ধ করেনি!) stdout pipe fd (ভুলে বন্ধ করেনি!)
→ refcount কখনো 0 হবে না, যদি না সবগুলো close() হয়
→ reader-এর read() চিরকাল EAGAIN/block অবস্থায় থাকবে, কখনো EOF (0) নাbuild-a-shell প্রজেক্টে “তিন জায়গায় close করতে হয়” বলা হয়েছিল — এখন সেই নিয়মের কারণটা স্পষ্ট: প্রতিটা close() একটা reference কমায়, আর reader-এর EOF পাওয়ার শর্তটা হলো refcount = 0, একটা বিচ্ছিন্ন “ভালো অভ্যাস” নয়। parent শেল যদি pipe fd দুটো fork-এর পরে বন্ধ না করে, সে নিজেই একটা অতিরিক্ত reference ধরে রাখছে যেটা কখনো শেষ হবে না (শেল নিজে তো সারাজীবন চলতে থাকে) — child বন্ধ করলেও refcount শূন্যে নামবে না।
SIGPIPE এবং EPIPE — reader হারিয়ে গেলে writer কী পায়
$ yes | head -1
yyes অসীম লুপে y\n লিখতে থাকে। head -1 একটা লাইন পড়েই exit() করে, যার মানে তার সব fd (pipe-এর read-end সহ) বন্ধ হয়ে যায়। এখন yes পরের write() কল করলে কী হয়?
kernel দেখে pipe-এর read-end-এর কোনো reference বাকি নেই — অর্থাৎ এই ডেটা কখনোই কেউ পড়বে না। এই অবস্থায় ব্লক করে রাখাটা অর্থহীন হতো (চিরকাল অপেক্ষা), তাই kernel দুইটা কাজ করে:
- Writer process-কে SIGPIPE পাঠায় (ডিফল্ট action: process টার্মিনেট)
- যদি SIGPIPE ignore করা থাকে (বা block করা থাকে), তাহলে
write()সিস্টেম কল ব্যর্থ হয়েEPIPEerrno-সহ ফেরত আসে
yes প্রসেস: write(fd, "y\n", 2)
│
read-end-এর reference আছে?
│ │
হ্যাঁ না
│ │
বাফারে জায়গা SIGPIPE পাঠাও
থাকলে লেখো, │
নাহলে block SIGPIPE handled/ignored?
│ │
না হ্যাঁ
│ │
process মরে write() রিটার্ন -1,
যায় (ডিফল্ট) errno = EPIPEএটাই কারণ yes | head -1 চিরকাল CPU খেয়ে বসে থাকে না — yes মরে যায় ঠিক যখন আর কেউ তার আউটপুট চায় না। এই SIGPIPE-ভিত্তিক “স্বয়ংক্রিয় বন্ধ হওয়া” ইউনিক্স pipeline-এর দক্ষতার একটা গোপন কারণ।
ব্যবহারিক সিদ্ধান্ত — কখন SIGPIPE ignore করবেন। একটা library বা server process-এ (যেমন একটা network daemon যেটা একটা client-এর সাথে সংযুক্ত pipe/socket-এ লেখে) SIGPIPE-এর ডিফল্ট আচরণ (পুরো প্রসেস মেরে ফেলা) প্রায় সবসময় ভুল — একটা client disconnect হয়ে গেলে পুরো server মরে যাবে কেন? তাই এই ধরনের কোডে signal(SIGPIPE, SIG_IGN) করে write()-এর -1/EPIPE রিটার্ন মান নিজে চেক করাই সঠিক প্যাটার্ন — এভাবে failure স্থানীয়ভাবে হ্যান্ডল হয়, পুরো process crash করে না।
FIFO — pipe-কে filesystem-এ একটা নাম দেওয়া
Anonymous pipe-এর একটা সীমাবদ্ধতা আছে — শুধু সম্পর্কিত process-রাই এটা ব্যবহার করতে পারে (fork-এর মাধ্যমে fd শেয়ার করে)। দুইটা সম্পূর্ণ অসম্পর্কিত process-কে যদি pipe দিয়ে যোগ করতে হয়, দরকার একটা এমন কিছু যা filesystem-এ একটা নাম দিয়ে খুঁজে পাওয়া যায় — এটাই FIFO (named pipe)।
mkfifo /tmp/myfifo
ls -l /tmp/myfifoprw-r--r-- 1 user user 0 Aug 20 10:00 /tmp/myfifop মানে এটা একটা pipe special file — লক্ষ করুন আকার শূন্য, কারণ FIFO-র “ফাইল”-টা শুধু একটা নাম, প্রকৃত ডেটা এখনো সেই একই in-memory ring buffer-এ থাকে, ডিস্কে না। mkfifo একটা inode তৈরি করে filesystem-এ (S_IFIFO type), কিন্তু সেই inode-এর data block কখনো ব্যবহৃত হয় না।
| Anonymous pipe | FIFO (named pipe) | |
|---|---|---|
| তৈরি | pipe(2) | mkfifo(3) বা mknod |
| filesystem-এ নাম | না | হ্যাঁ |
| কারা ব্যবহার করতে পারে | শুধু সম্পর্কিত process (fork-এর মাধ্যমে fd শেয়ার) | যেকোনো process যে নামটা জানে ও অনুমতি আছে |
| ডেটা সংরক্ষণ | in-memory ring buffer | একই in-memory ring buffer (নাম শুধু filesystem-এ) |
open()-এর আচরণ | প্রযোজ্য নয় (fd সরাসরি pipe() থেকে) | blocking — নিচে বিস্তারিত |
Blocking-open সেমান্টিক্স — FIFO-র সবচেয়ে চমকপ্রদ অংশ। একটা FIFO-কে read-only মোডে open() করলে, সেই কল ব্লক করে থাকে যতক্ষণ না অন্তত একজন writer সেই একই FIFO write মোডে open করে — আর উল্টোটাও সত্য। এটা একটা rendezvous মেকানিজম: দুইজন প্রায় একই সময়ে না এলে কেউই এগোতে পারে না।
# টার্মিনাল ১ -- reader, এখানে ব্লক হয়ে থাকবে
cat /tmp/myfifo
# (কিছুই দেখাবে না, ব্লক করে আছে...)
# টার্মিনাল ২ -- writer open করার সাথে সাথেই টার্মিনাল ১ আনব্লক হবে
echo "hello via FIFO" > /tmp/myfifoএই ব্লকিং আচরণ O_NONBLOCK flag দিয়ে বদলানো যায় (তখন কোনো writer না থাকলে reader ENXIO পায়, বা reader প্রস্তুত না থাকলে writer ENXIO/immediate fail পায়) — কিন্তু ডিফল্ট আচরণটাই মনে রাখা জরুরি, কারণ এটাই একটা common “shell script হঠাৎ ঝুলে গেছে” bug-এর উৎস (একজন লিখতে ভুলে গেছে, reader অনন্তকাল অপেক্ষা করছে)।
PIPE_BUF — atomicity-র একটা কড়া গ্যারান্টি
একাধিক process যখন একই pipe-এ (বা FIFO-তে) একসাথে লেখে, তখন একটা স্বাভাবিক প্রশ্ন ওঠে — লেখাগুলো কি মিশে যেতে পারে? POSIX একটা নির্দিষ্ট, নির্ভরযোগ্য উত্তর দেয়:
এই সীমাটা কোনো implementation detail নয় — POSIX স্ট্যান্ডার্ডে (\<limits.h\>-এর PIPE_BUF) নির্দিষ্ট, প্রতিটা POSIX-compliant সিস্টেমে অন্তত ৫১২ বাইট গ্যারান্টিড, Linux-এ ৪০৯৬। ব্যবহারিক ফল — একাধিক প্রক্রিয়া থেকে log line লেখার সময়, প্রতিটা line যদি PIPE_BUF-এর নিচে থাকে, লাইনগুলো নিজেদের মধ্যে মিশে যাবে না, যদিও তাদের ক্রম নির্ধারিত নয় (কোন লাইন আগে যাবে সেটা schedule-নির্ভর, কিন্তু একটা লাইনের ভেতরে অন্য লাইনের বাইট ঢুকবে না)।
Backpressure — একটা পূর্ণ pipe-এ block হওয়াটা bug নয়, ফিচার
Pipe-এর buffer সসীম (ডিফল্ট ৬৪ KB)। Writer যদি reader-এর চেয়ে দ্রুত লিখতে থাকে, buffer একসময় পূর্ণ হয়ে যাবে। তখন কী হয়?
writer: write(fd, buf, n)
│
buffer-এ n বাইটের জায়গা আছে?
│ │
হ্যাঁ না
│ │
লেখো, ফেরত block করো (wait queue-তে) --
যাও reader কিছু পড়ে জায়গা খালি করা পর্যন্তWriter এখানে block করে — ঠিক গত লেসনের wait-queue প্যাটার্নের একটা বিশুদ্ধ সফটওয়্যার সংস্করণ, শুধু এখানে জাগানোর সংকেতটা hardware interrupt থেকে না এসে অন্য একটা process-এর read() কল থেকে আসে। এই ব্লকিংটাকে অনেকে ভুল করে ধীরগতির লক্ষণ ভাবেন — আসলে এটা backpressure: একটা স্বয়ংক্রিয় গতি-নিয়ন্ত্রণ ব্যবস্থা যা producer-কে consumer-এর গতির সাথে বাধ্য করে মেলাতে, কোনো explicit rate-limiting কোড ছাড়াই।
Backpressure না থাকলে কী হতো? Writer buffer-বিহীনভাবে লিখেই যেত, মেমরি অসীমভাবে বাড়তে থাকত (একটা unbounded queue), আর একসময় OOM (out-of-memory)। ৬৪ KB সীমাটা এই সমস্যাটাকেই কাঠামোগতভাবে ঠেকিয়ে দেয় — একটা সসীম buffer মানে producer কখনো consumer-এর চেয়ে ৬৪ KB-র বেশি এগিয়ে যেতে পারে না।
ভেতরে কী ঘটছে
একটা block হওয়া write()-এর পূর্ণ যাত্রা
Concept সেকশনে backpressure-এর ছবিটা দেখলাম উপর থেকে। এখন সেটা kernel-এর ভেতরে ঠিক কী ঘটে সেই স্তরে নামিয়ে দেখি — একটা writer যখন একটা প্রায়-পূর্ণ pipe-এ লিখতে চেষ্টা করে।
- writer: write(fd, buf, 8192)glibc wrapper syscall নম্বর সাজায়, ring 3 → ring 0 mode switch (kernel-and-user-space লেসনের সেই খরচ)
- pipe_write() kernel functionpipe-এর বর্তমান occupied বাইট গণনা করে -- ধরুন ৬৪ KB-র মধ্যে ৬২ KB পূর্ণ, মাত্র ২ KB জায়গা খালি, কিন্তু ৮ KB লিখতে চাইছেন
- আংশিক লেখা বা সম্পূর্ণ ব্লক -- policy-নির্ভরPIPE_BUF-এর নিচের লেখায় kernel সম্পূর্ণ লেখা একবারে করতে চায় (atomicity রক্ষা) -- জায়গা না থাকলে পুরো লেখাটাই ব্লক করে, আংশিক লেখে না
- current process-কে pipe-এর wait queue-তে যোগ করাTASK_INTERRUPTIBLE অবস্থায় সেট করা হয়, scheduler-কে বলা হয় এই process runnable না -- ঠিক আগের লেসনের interrupt-এর জন্য অপেক্ষারত process-এর মতোই মেকানিজম, কিন্তু ট্রিগারটা এখন hardware না, আরেকটা process
- schedule() -- CPU অন্য কাজে যায়writer এখন CPU সময় নিচ্ছে না; কোনো busy-wait নেই
- [অন্য প্রান্তে] reader: read(fd, buf, n)বাফার থেকে কিছু বাইট সরিয়ে নেয়, জায়গা খালি হয়
- pipe_read() → wake_up_interruptible()read সম্পন্ন হওয়ার পর kernel pipe-এর write-wait-queue-তে wake_up ডাকে -- writer আবার runnable হয়
- writer জেগে ওঠে, বাকি লেখা সম্পন্ন করে, syscall রিটার্ন করেuserspace-এ ফিরে যায় -- writer-এর দৃষ্টিতে write() শুধু 'একটু সময় নিল', ভেতরের এই পুরো নাটক অদৃশ্য
লক্ষণীয়: writer-এর কোনো CPU সময় নষ্ট হয়নি ব্লক থাকা অবস্থায় (polling নয়, সত্যিকারের sleep), আর reader-এর একটা সাধারণ read() কলই writer-কে জাগানোর ট্রিগার — কোনো signal বা explicit notification API লাগেনি, এটা pipe abstraction-এর ভেতরেই বেক করা।
EOF কীভাবে সংকেতিত হয় — refcount শূন্যের মুহূর্তে
Concept সেকশনে বলেছিলাম EOF নির্ভর করে write-end-এর refcount শূন্যে নামার উপর। প্রক্রিয়াটা:
close(write_fd) ডাকা হলো
│
এই OFD-র refcount--
│
refcount == 0?
│ │
না হ্যাঁ
│ │
কিছুই হয় pipe-এর read-wait-queue-তে
না wake_up() -- "অবস্থা বদলেছে, আবার চেক করো"
│
reader জাগে, pipe_read() আবার চেক করে:
buffer খালি + কোনো writer নেই → read() রিটার্ন 0 (EOF)read()-এর রিটার্ন মান ৩ ধরনের হতে পারে, আর পার্থক্যটা এই মেকানিজমেই লুকানো: > 0 (কিছু ডেটা পাওয়া গেছে), 0 (buffer খালি এবং কোনো writer বাকি নেই — সত্যিকারের EOF), অথবা ব্লক (buffer খালি কিন্তু অন্তত একজন writer এখনো আছে — হয়তো পরে লিখবে)।
উদাহরণ
PIPE_BUF-এর প্রভাব — সংখ্যায় দেখা
ধরা যাক ৪টা process একই pipe-এ একসাথে লগ-লাইন লিখছে, প্রতিটা প্রায় একই মুহূর্তে। দুইটা পরিস্থিতি তুলনা করি।
পরিস্থিতি ১ — প্রতিটা লেখা ৪০৯৫ বাইট (PIPE_BUF-এর নিচে):
প্রতিটা write(fd, msg, 4095) atomic। Reader যা পড়বে তা এই ৪টার একটা নির্দিষ্ট, কিন্তু অনির্দেশিত ক্রম-বিন্যাস — সম্ভাব্য ক্রম:
কোনো ক্ষেত্রেই একটা বার্তার মাঝখানে আরেকটা বার্তার বাইট ঢুকবে না — গ্যারান্টিড, POSIX দ্বারা বাধ্যতামূলক।
পরিস্থিতি ২ — প্রতিটা লেখা ৫০০০ বাইট (PIPE_BUF-এর উপরে):
Kernel প্রতিটা লেখাকে একাধিক অভ্যন্তরীণ chunk-এ ভাঙতে পারে (বাস্তবে প্রায়ই page-সীমানায়, ৪ KB-র কাছাকাছি)। চারটা প্রসেস একসাথে লিখলে, একটা প্রসেসের প্রথম ৪ KB pipe-এ যাওয়ার পর, scheduler অন্য একটা প্রসেসকে সুযোগ দিতে পারে তার অংশ লেখার — ফলাফল:
সম্ভাব্য দূষিত আউটপুট (৪টা প্রসেস A, B, C, D, প্রতিটা 5000 বাইট লিখছে):
[A-এর প্রথম ৪০৯৬ বাইট][B-এর প্রথম ৪০৯৬ বাইট][A-এর বাকি ৯০৪ বাইট]...
reader যদি লাইন-বাই-লাইন পার্স করতে চায়, এখানে A আর B-র ডেটা
একটা লাইনের ভেতরে মিশে গেছে -- কোনো valid parse নেই
| আকার | Atomicity | Multi-writer আউটপুট |
|---|---|---|
| ≤ 4096 বাইট | গ্যারান্টিড atomic | নিরাপদ — বার্তা কখনো মেশে না |
| > 4096 বাইট | গ্যারান্টি নেই | দূষণের ঝুঁকি — বার্তা interleave হতে পারে |
ব্যবহারিক ফল — যেকোনো multi-process logging design-এ যদি pipe/FIFO সরাসরি একাধিক writer থেকে ব্যবহার করতে হয়, প্রতিটা লগ-এন্ট্রি PIPE_BUF-এর নিচে রাখা একটা সহজ, নির্ভরযোগ্য নিয়ম — নাহলে প্রতিটা writer-এর নিজস্ব fd/file (বা একটা centralized logger process যেখানে শুধু একজনই আসল লেখক) লাগবে।
নিজে চালিয়ে দেখুন
Pipe hang করানো, তারপর ঠিক করা -- refcount নিয়ম হাতে-কলমে
প্রথমে normal, সঠিক pipeline:
{ echo "line1"; echo "line2"; } | catline1
line2
$সাথে সাথে ফেরত আসে — দুই দিকের প্রসেস শেষ হওয়ার সাথে সাথে সব fd বন্ধ, cat EOF পায়।
এখন bash-এর নিজস্ব fd manipulation দিয়ে ইচ্ছাকৃতভাবে একটা অতিরিক্ত write-end খোলা রেখে দেখুন hang:
exec 3>/tmp/leak_test.fifo 2>/dev/null # ব্যর্থ হবে, ফাইল নেই -- আগে FIFO বানাই
mkfifo /tmp/leak_test.fifo
# টার্মিনাল ১ -- reader শুরু করুন
cat /tmp/leak_test.fifo &
READER_PID=$!
# টার্মিনাল ১ -- এখন একটা fd খুলে রাখি write মোডে, কিন্তু কখনো বন্ধ করব না
exec 3>/tmp/leak_test.fifo # fd 3 এখন write-end ধরে আছে
echo "একটা লাইন" >&3 # লিখলাম
# এখন reader এই লাইন পাবে, কিন্তু EOF পাবে না -- কারণ fd 3 এখনো খোলা
jobs -l # cat এখনো চলছে, ব্লক হয়ে আছে[1]+ Running cat /tmp/leak_test.fifo &cat তার stdout-এ “একটা লাইন” দেখিয়েছে, কিন্তু প্রসেসটা এখনো চলছে — সে EOF-এর অপেক্ষায় ব্লক হয়ে আছে, কারণ shell-এর fd 3 এখনো write-end-এর একটা reference ধরে আছে। এটাই ঠিক build-a-shell প্রজেক্টের সেই বাগ, কিন্তু এবার আপনি ইচ্ছা করে ঘটিয়েছেন আর প্রমাণ করেছেন কারণটা।
এখন সমাধান — সেই অতিরিক্ত reference বন্ধ করুন:
exec 3>&- # fd 3 বন্ধ -- এখন refcount শূন্যে নামল
wait $READER_PID
echo "reader শেষ হলো, exit code: $?"reader শেষ হলো, exit code: 0exec 3>&- চালানোর সাথে সাথেই cat জেগে EOF পায় এবং exit করে — refcount শূন্যে নামা মাত্রই। rm /tmp/leak_test.fifo দিয়ে পরিষ্কার করুন।
write-end-এর একটা মাত্র অতিরিক্ত, ভুলে-খোলা reference reader-কে EOF পেতে চিরকালের জন্য বাধা দেয় -- concept সেকশনের refcount নিয়মের সরাসরি প্রমাণ।
PIPE_BUF atomicity -- 4095 বনাম 5000 বাইট, চোখের সামনে প্রমাণ
একটা ছোট C প্রোগ্রাম যা একটা নির্দিষ্ট আকারের বার্তা বারবার একটা shared pipe-এ লেখে:
/* writer.c -- একটা নির্দিষ্ট আকারের বার্তা N বার লেখে একটা pipe fd-তে
* ব্যবহার: ./writer <fd> <size> <tag_char> <count> */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
int main(int argc, char **argv) {
int fd = atoi(argv[1]);
int size = atoi(argv[2]);
char tag = argv[3][0];
int count = atoi(argv[4]);
char *buf = malloc(size);
memset(buf, tag, size - 1);
buf[size - 1] = '\n';
for (int i = 0; i < count; i++)
write(fd, buf, size);
return 0;
}Bash দিয়ে দুইজন writer একসাথে একই pipe-এ লেখান, ৪০৯৫ বাইট করে (PIPE_BUF-এর নিচে):
gcc -O2 writer.c -o writer
mkfifo /tmp/atomtest
cat /tmp/atomtest > /tmp/output.txt &
exec 3>/tmp/atomtest
./writer 3 4095 A 50 &
./writer 3 4095 B 50 &
wait
exec 3>&-
sleep 1
# প্রতিটা লাইন সম্পূর্ণভাবে A অথবা সম্পূর্ণভাবে B হওয়া উচিত, মেশা নয়
grep -c '^A*$' /tmp/output.txt # খাঁটি A-লাইন সংখ্যা
grep -c '^B*$' /tmp/output.txt # খাঁটি B-লাইন সংখ্যা
awk 'length($0) != 4094 { print "দূষিত লাইন:", $0 }' /tmp/output.txt | head50
50
(কোনো দূষিত লাইন নেই)এবার একই পরীক্ষা ৫০০০ বাইট দিয়ে (PIPE_BUF-এর উপরে):
cat /tmp/atomtest > /tmp/output2.txt &
exec 3>/tmp/atomtest
./writer 3 5000 A 50 &
./writer 3 5000 B 50 &
wait
exec 3>&-
sleep 1
awk 'length($0) != 4999 { print "দূষিত লাইন, দৈর্ঘ্য:", length($0) }' /tmp/output2.txt | headদূষিত লাইন, দৈর্ঘ্য: 8192
দূষিত লাইন, দৈর্ঘ্য: 1806
দূষিত লাইন, দৈর্ঘ্য: 6193৫০০০ বাইটে নিয়মিতভাবে ভুল-দৈর্ঘ্যের লাইন দেখা যায় — দুইটা writer-এর chunk একে অপরের মাঝে ঢুকে গেছে, ঠিক example সেকশনের পূর্বাভাসের মতো। ৪০৯৫ বাইটে এই সমস্যা কখনোই দেখা যাবে না, যতবারই চালান না কেন — এটা timing-নির্ভর random ঘটনা না, একটা গ্যারান্টি।
POSIX-এর atomicity গ্যারান্টি একটা কাগুজে দাবি নয় -- সীমার নিচে বার্তা কখনো মেশে না, উপরে নিয়মিতভাবে মিশে যায়।
নিজে বানান
তিন-স্তরের pipeline, সম্পূর্ণ fd hygiene সহ -- cmd1 | cmd2 | cmd3
- pipeline.c লিখুন যা তিনটা কমান্ডকে দুইটা pipe দিয়ে জোড়ে
- প্রতিটা child-এ ঠিক কোন fd বন্ধ হবে তার তালিকা আগে কাগজে লিখে ফেলুন, তারপর কোড লিখুন
- কম্পাইল করে `./pipeline` চালিয়ে `ls -l | grep rw | wc -l`-এর সমতুল্য ফলাফল যাচাই করুন
- ইচ্ছাকৃতভাবে একটা close() মন্তব্য করে hang প্রমাণ করুন, তারপর ফিরিয়ে আনুন
- `ls -l /proc/<pid>/fd` দিয়ে প্রতিটা child-এর খোলা fd সংখ্যা পরীক্ষা করুন -- ঠিক ৩টা (stdin, stdout, stderr) থাকা উচিত, অতিরিক্ত pipe fd নয়
build-a-shell প্রজেক্টে দুই-প্রসেসের একটা pipe দেখেছেন, তিনটা close-এর নিয়মসহ। এখানে সেই নিয়মটাকে N-স্তরে সাধারণীকরণ করছি — তিন কমান্ড, দুইটা pipe, তিনটা child। নিয়মটা স্কেল করে: প্রতিটা child তার নিজের ব্যবহার করা end বাদে বাকি সব pipe fd বন্ধ করবে, parent-ও সব pipe fd বন্ধ করবে fork করার পরে।
/* pipeline.c -- cmd1 | cmd2 | cmd3 কে হাতে wiring করা
* উদাহরণ চালানো: ./pipeline "ls -l" "grep rw" "wc -l"
*/
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <string.h>
#define NCMD 3
static char **split(char *s) {
/* সরল whitespace-tokeniser, শুধু ডেমো-র জন্য */
static char *argv[16];
int i = 0;
char *tok = strtok(s, " ");
while (tok && i < 15) { argv[i++] = tok; tok = strtok(NULL, " "); }
argv[i] = NULL;
return argv;
}
int main(int argc, char **argv) {
if (argc != NCMD + 1) {
fprintf(stderr, "ব্যবহার: %s \"cmd1\" \"cmd2\" \"cmd3\"\n", argv[0]);
return 2;
}
int npipes = NCMD - 1; /* ৩টা কমান্ড → ২টা pipe */
int pipefds[npipes][2];
for (int i = 0; i < npipes; i++) {
if (pipe(pipefds[i]) < 0) { perror("pipe"); return 1; }
}
pid_t pids[NCMD];
for (int i = 0; i < NCMD; i++) {
pids[i] = fork();
if (pids[i] < 0) { perror("fork"); return 1; }
if (pids[i] == 0) {
/* ---- child i: stdin/stdout সঠিক pipe-এ বসাও ---- */
if (i > 0) /* প্রথমটা ছাড়া সবাই আগের pipe থেকে পড়ে */
dup2(pipefds[i - 1][0], STDIN_FILENO);
if (i < NCMD - 1) /* শেষটা ছাড়া সবাই পরের pipe-এ লেখে */
dup2(pipefds[i][1], STDOUT_FILENO);
/* ---- সব pipe fd বন্ধ করো -- dup2 করা হয়ে গেছে, মূলগুলো এখন অপ্রয়োজনীয় ---- */
for (int j = 0; j < npipes; j++) {
close(pipefds[j][0]);
close(pipefds[j][1]);
}
char cmdbuf[256];
strncpy(cmdbuf, argv[i + 1], sizeof cmdbuf - 1);
execvp(split(cmdbuf)[0], split(cmdbuf));
perror("execvp");
_exit(127);
}
}
/* ---- parent: সব pipe fd বন্ধ করো -- parent কারো read/write-এ অংশ নেয় না ---- */
for (int j = 0; j < npipes; j++) {
close(pipefds[j][0]);
close(pipefds[j][1]);
}
int status, last_status = 0;
for (int i = 0; i < NCMD; i++) {
waitpid(pids[i], &status, 0);
if (i == NCMD - 1) last_status = status; /* pipeline-এর exit status = শেষ কমান্ডেরটা */
}
return WIFEXITED(last_status) ? WEXITSTATUS(last_status) : 1;
}চালানো:
gcc -O2 pipeline.c -o pipeline
./pipeline "ls -l /etc" "grep ^d" "wc -l"42তুলনা করুন সরাসরি শেলে:
ls -l /etc | grep ^d | wc -l42কেন parent-এও বন্ধ করা জরুরি — একটা কংক্রিট প্রদর্শন। নিচের লাইন দুটো মন্তব্য করে দিন (parent-এর close লুপ):
/* for (int j = 0; j < npipes; j++) {
close(pipefds[j][0]);
close(pipefds[j][1]);
} */আবার কম্পাইল করে চালান — এবার শেষ কমান্ড (wc -l) কখনো EOF পাবে না, কারণ parent process নিজেই সব pipe-এর write-end-এর একটা reference ধরে বসে আছে (আর parent কখনো নিজে থেকে বন্ধ হবে না যতক্ষণ children শেষ না হয়, আর children শেষ হচ্ছে না কারণ pipeline-এর মাঝের কমান্ডরা তাদের stdin-এ EOF-এর অপেক্ষায়)। ./pipeline ... ঝুলে থাকবে — Ctrl-C দিয়ে থামাতে হবে। এটাই refcount নিয়মের তৃতীয় প্রকাশ, এবার parent-এর নিজের ভুলে।
নিজে বাড়ান
১. N-কমান্ডে সাধারণীকরণ করুন। NCMD কে কমান্ড-লাইন argument সংখ্যা থেকে dynamic করুন, argv[]-এর পুরো proper tokenising (quote-handling সহ) যোগ করুন — একটা toy শেলের ভিত্তি।
২. Redirection যোগ করুন। প্রথম কমান্ডের stdin আর শেষ কমান্ডের stdout-কে ফাইলে redirect করার সুযোগ দিন (\<, >), যেমন সত্যিকারের শেলে cmd1 \< in.txt | cmd2 | cmd3 > out.txt লেখা যায়।
৩. exit status propagation চেক করুন। এই কোডে pipeline-এর exit status শুধু শেষ কমান্ডেরটা — bash-এর $PIPESTATUS array-র মতো প্রতিটা কমান্ডের status আলাদাভাবে সংরক্ষণ করুন।
৪. একটা মাঝের কমান্ড না থাকলে (execvp ব্যর্থ) কী হয় পরীক্ষা করুন। "nonexistent_cmd" কে মাঝে বসিয়ে দেখুন বাকি pipeline কীভাবে আচরণ করে — শেষ কমান্ড কি EOF পায়? কেন (বা কেন না)?
৫. strace -f দিয়ে পুরো pipeline চালিয়ে সব pipe/dup2/close/fork কল দেখুন, আর গুনে দেখুন মোট syscall সংখ্যা তত্ত্ব অনুযায়ী কত হওয়া উচিত ছিল (৩ প্রসেস × যা যা করে + ২ pipe + parent-এর close) তার সাথে মিলছে কি না।
বাস্তব সিস্টেমে
যেখানে pipe/FIFO সত্যিই ব্যবহৃত হয়
প্রতিটা শেল, প্রতিদিন। bash, zsh, fish — সবার pipeline বাস্তবায়ন উপরের build সেকশনের কোডের একটা পরিশীলিত সংস্করণ। strace -f bash -c 'ls | grep x | wc -l' চালালে ঠিক এই একই pipe()/fork()/dup2()/close() প্যাটার্ন দেখতে পাবেন প্রকৃত bash সোর্সে।
Systemd journal ও logging pipeline। অনেক service তাদের stdout/stderr একটা pipe-এর মাধ্যমে systemd-journald-এ পাঠায় — এখানেই PIPE_BUF-এর ৪০৯৬ বাইট সীমা বাস্তবিক গুরুত্ব পায়, কারণ multi-threaded application থেকে একই সাথে একাধিক log-line আসতে পারে, আর ছোট থাকলে সেগুলো মেশে না।
docker logs এবং container runtime। Container-এর stdout/stderr প্রায়ই একটা pipe-এর মাধ্যমে container runtime দ্বারা capture হয়, তারপর log driver-এ ফরওয়ার্ড হয় — একই backpressure নীতি প্রযোজ্য: একটা container যদি অত্যধিক দ্রুত log লেখে আর log driver ধীর হয়, container-এর নিজের write() ব্লক হতে শুরু করতে পারে, যা application-কেই ধীর করে দেয় (এটাই কেন অনেক production সেটআপে non-blocking বা buffered log driver ব্যবহার করা হয়)।
ffmpeg আর multimedia pipeline। ffmpeg -i input.mp4 -f rawvideo - | some_processor — বড় বড় raw ভিডিও ফ্রেম pipe দিয়ে স্ট্রিম করা হয়, প্রায়ই F_SETPIPE_SZ দিয়ে বাফার বড় করে (কম block হওয়া, বেশি throughput) — একটা বাস্তব উদাহরণ যেখানে ডিফল্ট ৬৪ KB যথেষ্ট নয় বলে explicit tuning করতে হয়।
named pipe ডেটাবেস/মনিটরিং টুলে। কিছু টুল (যেমন পুরনো mysqldump-ভিত্তিক ব্যাকআপ স্ক্রিপ্ট, বা কিছু IPC-ভিত্তিক monitoring agent) FIFO ব্যবহার করে দুইটা সম্পূর্ণ অসম্পর্কিত প্রসেসের (আলাদা সময়ে শুরু হওয়া) মধ্যে ডেটা পাস করতে, যেখানে fork-ভিত্তিক anonymous pipe সম্ভব না।
যে ভুলগুলো সবাই করে
“pipe দিয়ে ডেটা পাঠানো মানে ডেটা কোথাও ডিস্কে সাময়িকভাবে লেখা হয়।”
পুরোপুরি ভুল। Concept সেকশনে দেখা গেছে pipe সম্পূর্ণভাবে kernel মেমরিতে বাস্তবায়িত — একটা ring buffer, page-এর একটা array হিসেবে। এমনকি FIFO-ও, যার filesystem-এ একটা নাম আছে, ডেটা ডিস্কে রাখে না — নামটা শুধু একটা “ঠিকানা” যা দিয়ে দুইজন অসম্পর্কিত process একই in-memory buffer-এ পৌঁছাতে পারে (ls -l-এ FIFO-র আকার সবসময় শূন্য দেখায়, যা এই তথ্যের সরাসরি প্রমাণ)। এটাই কারণ pipe I/O ডিস্ক I/O-র চেয়ে বহুগুণ দ্রুত — কোনো disk seek, কোনো filesystem metadata আপডেট নেই।
“Reader EOF পায় ঠিক তখনই যখন writer 'শেষ' লেখাটা লিখে দেয়, অর্থাৎ writer-এর নিয়ন্ত্রণে EOF পাঠানো যায়।”
Writer সরাসরি “এখন EOF পাঠাও” বলতে পারে না — EOF হলো read-end থেকে দেখা একটা অবস্থা (buffer খালি এবং কোনো write-end reference বাকি নেই), লেখা কোনো বিশেষ বার্তা নয়। hood সেকশনের refcount আলোচনা অনুযায়ী, EOF আসে যখন সব write-end বন্ধ হয়ে যায় — এমনকি লেখক নিজে মনে করলেও “আমার লেখা শেষ”, যদি সে fd বন্ধ না করে (বা অন্য কোনো প্রসেস একই write-end-এর একটা অতিরিক্ত copy ধরে রাখে), reader কখনো EOF পাবে না। এই ভুল ধারণাটাই build-a-shell-এর সেই hang bug-এর মূল উৎস — নতুন প্রোগ্রামাররা ভাবেন “প্রোগ্রাম শেষ হলেই তো EOF যাবে”, কিন্তু kernel দেখে শুধু reference count, প্রোগ্রামের অভিপ্রায় না।
“একটা pipe-এ ৫০ বাইট লিখে ৫০ বাইট পড়লে সবসময় নিরাপদ -- আকার নিয়ে চিন্তা না করলেও চলে।”
Concept ও example সেকশনের PIPE_BUF আলোচনা এর সরাসরি খণ্ডন — atomicity গ্যারান্টি শুধু একজন writer একবারে একটা লেখা পরিস্থিতিতে অপ্রাসঙ্গিক (সেখানে তো এমনিতেই কোনো interleaving-এর প্রশ্ন নেই), কিন্তু একাধিক writer একই সময়ে একই pipe-এ লিখলে ৪০৯৬ বাইট সীমাটা বাস্তব এবং পরীক্ষাযোগ্য (experiment সেকশনের ফলাফল)। এটা উপেক্ষা করলে যে bug তৈরি হয় তা বিশেষভাবে বিপজ্জনক — লোড কম থাকলে কখনো দেখা যায় না (writes সচরাচর interleave না-ও হতে পারে টাইমিং-এর কারণে), শুধু উঁচু concurrency বা উঁচু লোডে মাঝেমধ্যে প্রকাশ পায়।
“একটা পূর্ণ pipe-এ writer block হয়ে যাওয়া মানে কিছু একটা ভুল হয়েছে, কোড অপ্টিমাইজ করা দরকার।”
Concept সেকশনের backpressure আলোচনা এই ধারণার বিপরীত প্রমাণ দেয়। একটা পূর্ণ pipe-এ block হওয়া ইচ্ছাকৃত, ডিজাইন-করা আচরণ — এটা producer-কে consumer-এর গতির সাথে স্বয়ংক্রিয়ভাবে মেলায়, বিকল্প (unbounded buffering) বাস্তবে অনেক বেশি বিপজ্জনক (মেমরি নিঃশেষ হয়ে OOM)। block হওয়া নিজেই সমস্যা নয় — সমস্যা হয় যখন এই ব্লকিং একটা single-threaded প্রোগ্রামের একমাত্র থ্রেডে ঘটে যেটার অন্যান্য কাজও করার কথা ছিল (তখন সমাধান হলো non-blocking I/O বা একাধিক thread/process ব্যবহার, block হওয়া বন্ধ করা নয়)।
বুঝেছেন কি না দেখুন
1একটা প্রোগ্রাম pipe(fds) কল করে, তারপর fork() করে, child-এ fds[0] (read end) বন্ধ করে আর parent-এ fds[1] (write end) বন্ধ করে না, যদিও parent শুধু পড়তে চায় লিখতে না। parent তারপর read(fds[0], ...) কল করে। কী ঘটবে, আর কেন?
যুক্তি
pipe(fds) কল করে, তারপর fork() করে, child-এ fds[0] (read end) বন্ধ করে আর parent-এ fds[1] (write end) বন্ধ করে না, যদিও parent শুধু পড়তে চায় লিখতে না। parent তারপর read(fds[0], ...) কল করে। কী ঘটবে, আর কেন?এখানে parent নিজেই নিজের জন্য একটা EOF-ফাঁদ তৈরি করেছে — নিজের write-end reference কখনো বন্ধ না করেই সেই একই pipe থেকে read করছে।
ধাপে ধাপে যা ঘটবে: child ঠিকমতো read()-এর জন্য নিজের কপি বন্ধ করে দিয়েছে, কিন্তু parent-এর নিজস্ব fds[1] (write-end) এখনো খোলা। যদি child শেষ পর্যন্ত fds[1]-এ কিছু লিখে আর নিজের কপি বন্ধ করে দেয়, তখনও write-end OFD-র refcount শূন্যে নামবে না — কারণ parent নিজেই একটা reference ধরে আছে।
ফলাফল: parent-এর read() ডেটা পড়তে থাকবে (যদি child কিছু লেখে), কিন্তু child শেষ হয়ে গেলেও parent-এর read() কখনো 0 (EOF) ফেরত দেবে না — কারণ kernel-এর দৃষ্টিতে এখনো একটা সম্ভাব্য লেখক (parent নিজেই!) বাকি আছে, ভবিষ্যতে সে লিখতে পারে এই ধারণায়। parent buffer খালি হয়ে গেলে ব্লক হয়ে থাকবে, চিরকালের জন্য (parent নিজেই লিখবে না, তাই কখনো জাগবে না)।
সাধারণ নিয়ম যা এখানে ভাঙা হয়েছে: একটা process যে দিকটা ব্যবহার করবে না, সেটা নিজের কপিতেও বন্ধ করতে হবে — এমনকি যদি সে read-only ব্যবহারকারী হয়, তবু নিজের write-end বন্ধ করা তার দায়িত্ব, শুধু অন্য প্রসেসের নয়। এটা concept সেকশনের refcount চিত্রের সরাসরি প্রয়োগ — প্রতিটা অব্যবহৃত fd প্রতিটা প্রসেসে বন্ধ করা, শুধু “যে দিকটা আমি ব্যবহার করব না সেটা অন্য কেউ বন্ধ করে দেবে” ধরে নেওয়া বিপজ্জনক।
এই একই bug class Level 7-এর socket প্রোগ্রামিং-এও ফিরে আসবে — একটা server socket-এর accept()-এর পরের কপি না বন্ধ করলে ঠিক এই একই ধরনের fd-leak আর resource-exhaustion সমস্যা হয়।
2আপনি একটা প্রোগ্রাম লিখছেন যেটা একটা child process spawn করে একটা pipe-এর মাধ্যমে তার সাথে কথা বলে, আর child মাঝেমধ্যে ক্র্যাশ করতে পারে (crash-prone third-party tool)। আপনার parent process কীভাবে নিশ্চিত করবে যে child ক্র্যাশ করলে সে নিজে ঝুলে না থেকে বা crash না করে ঠিকভাবে সেটা সনাক্ত করবে?
প্রয়োগ
এখানে দুইটা আলাদা ব্যর্থতা-মোড সামলাতে হবে, আর দুইটার সমাধান আলাদা।
যদি parent শুধু pipe থেকে read করছিল (child লিখছিল): child ক্র্যাশ করলে OS তার সব fd বন্ধ করে দেয় (process টার্মিনেশনের স্বাভাবিক অংশ), যার মানে pipe-এর write-end-এর refcount শূন্যে নামে (ধরে নিলে অন্য কোনো process সেই write-end-এর কপি ধরে নেই)। ফলে parent-এর read() স্বাভাবিকভাবেই 0 (EOF) ফেরত দেবে — parent ঝুলে থাকবে না, এটা সরাসরি সনাক্তযোগ্য: read() যদি অপ্রত্যাশিতভাবে অল্প ডেটার পরেই 0 রিটার্ন করে, সেটা crash-এর সংকেত হতে পারে (তবে normal শেষ হওয়া থেকে আলাদা করতে exit status-ও চেক করা উচিত waitpid-এর মাধ্যমে)।
যদি parent pipe-এ লিখছিল (child পড়ছিল, এখন child মৃত): এখানেই বিপদ — parent-এর পরের write() কল SIGPIPE ট্রিগার করবে, যার ডিফল্ট action parent-কেও মেরে ফেলা। এটাই সবচেয়ে common bug এই পরিস্থিতিতে — parent নিজে crash-prone না হয়েও child-এর crash-এর কারণে মরে যায়।
সঠিক প্যাটার্ন:
signal(SIGPIPE, SIG_IGN); /* SIGPIPE parent-কে মারবে না */
ssize_t n = write(fd, buf, len);
if (n < 0 && errno == EPIPE) {
/* child মারা গেছে -- এখানে recovery/restart logic */
fprintf(stderr, "child সংযোগ হারিয়েছে, পুনরায় চালু করছি...\n");
}এছাড়া child ক্র্যাশ করলেও পুরনো তথ্য জানার জন্য waitpid(child_pid, &status, WNOHANG) নিয়মিতভাবে (বা একটা SIGCHLD handler-এ) চেক করা উচিত — শুধু SIGPIPE/EPIPE-এর উপর নির্ভর করলে child কেন মরল (crash, normal exit, signal) সেই তথ্য হারিয়ে যায়। WIFSIGNALED(status) দিয়ে নিশ্চিত করা যায় crash আসলে একটা signal-এর কারণে হয়েছে কি না।
এই পুরো প্যাটার্ন — child crash detect করা, gracefully recover করা, SIGPIPE থেকে নিজেকে বাঁচানো — production-গ্রেড process-supervision টুল (supervisord, systemd-এর service watchdog) যা করে তার একটা সরলীকৃত রূপ, আর Level 12-এর container orchestration module-এ liveness probe হিসেবে একই ধারণা বড় স্কেলে ফিরে আসবে।
3আপনি একটা multi-threaded log-aggregator ডিজাইন করছেন যেখানে ১০০টা worker thread একই process-এর ভেতরে একটা shared pipe-এ log line লিখবে, আর একটা আলাদা thread সেই pipe থেকে পড়ে একটা ফাইলে flush করবে। প্রতিটা log line গড়ে ২০০ বাইট, কিন্তু কিছু stack-trace সহ লাইন ১০ KB পর্যন্ত হতে পারে। আপনার ডিজাইন কী হবে?
ডিজাইন
সরাসরি pipe ব্যবহার করলে দুইটা সমস্যা একসাথে আসবে, আর দুইটাই এই লেসনের ধারণা দিয়ে আগে থেকেই অনুমানযোগ্য।
সমস্যা ১ — PIPE_BUF ভঙ্গ হবে। ১০ KB-র stack-trace লাইন ৪০৯৬ বাইটের অনেক উপরে — একাধিক thread একসাথে লিখলে (এমনকি একই process-এর ভেতরে থ্রেড হলেও, pipe-এর নিয়ম প্রসেস-নির্বিশেষে প্রযোজ্য) এই বড় লেখাগুলো interleave হয়ে দূষিত হতে পারে, ঠিক experiment সেকশনের ৫০০০-বাইট পরীক্ষার মতো।
সমস্যা ২ — একই process-এর ভেতরে thread-এর জন্য pipe আদর্শ টুল না। Pipe মূলত process-এর মধ্যে যোগাযোগের জন্য ডিজাইন করা (kernel-crossing, copy semantics)। একই address space-এ থাকা thread-দের জন্য এটা অপ্রয়োজনীয় ওভারহেড — প্রতিটা লেখা এখনো একটা syscall (kernel-এ ঢোকা-বেরোনো), যেখানে shared memory + mutex দিয়ে একই কাজ কোনো syscall ছাড়াই করা যেত।
সুপারিশকৃত ডিজাইন, তিনটা বিকল্প ওজন সহ:
| পদ্ধতি | কীভাবে সমস্যা এড়ায় | Trade-off |
|---|---|---|
| প্রতিটা লাইনের আগে length-prefix | Reader প্রথমে ৪ বাইট length পড়ে, তারপর ঠিক ততটা পড়ে — interleaving সমস্যাই থাকে না কারণ reader নিজেই framing করছে, PIPE_BUF-এর উপর নির্ভর করছে না | Writer-দের এখনো একে অপরের সাথে race করতে হবে length+payload একসাথে লিখতে — এই জোড়াটাও PIPE_BUF-এর নিচে না হলে একই সমস্যা ফিরে আসে |
| Per-thread buffering + single writer thread | প্রতিটা worker thread একটা lock-protected shared queue-তে log line push করে (mutex + condition variable — lesson 26-এর বিষয়), একটাই dedicated thread সেই queue থেকে তুলে pipe/file-এ লেখে | সবচেয়ে নির্ভরযোগ্য — pipe-এর multi-writer সমস্যাটাই দূর হয়ে যায় কারণ এখন লেখক একজনই |
| সরাসরি pipe বাদ, shared-memory ring buffer | lesson 24-এ দেখা মেমরি-ভিত্তিক কৌশল, কোনো syscall ছাড়াই ডেটা পাস | সর্বোচ্চ throughput, কিন্তু নিজের synchronization ডিজাইন করতে হবে — এটাই lesson 24-এর deliberately-অসম্পূর্ণ TODO |
আমার সুপারিশ: “Per-thread buffering + single writer thread” — এটা pipe-এর multi-writer atomicity সমস্যাটাই কাঠামোগতভাবে দূর করে দেয় (single writer মানে interleaving-এর প্রশ্নই ওঠে না), আর বাস্তবায়ন জটিলতা তুলনামূলক কম। শুধু চরম-উঁচু throughput দরকার হলে (লাখ লাখ log line/সেকেন্ড) shared-memory পদ্ধতির দিকে যাওয়া যুক্তিসঙ্গত।
এই সিদ্ধান্ত-কাঠামো — “একটা primitive-এর সীমাবদ্ধতা এড়ানোর তিনটা স্তরের বিকল্প, প্রতিটার জটিলতা-বনাম-throughput trade-off” — lesson 24 আর lesson 26-এ ঠিক এই একই প্রশ্নে আবার ফিরব, তখন shared memory আর synchronization primitive দুটোই হাতে থাকবে।
4একটা FIFO-তে (/tmp/myfifo) একজন প্রসেস open() কল করেছে read-only মোডে, O_NONBLOCK ছাড়া, আর কোনো writer এখনো সেটা খোলেনি। প্রসেসটা কী অবস্থায় থাকবে? তারপর যদি একজন writer আসে কিন্তু কিছু না লিখেই সাথে সাথে বন্ধ করে দেয়, তখন reader-এর open() আর তারপরের read() কী আচরণ করবে?
যুক্তি
/tmp/myfifo) একজন প্রসেস open() কল করেছে read-only মোডে, O_NONBLOCK ছাড়া, আর কোনো writer এখনো সেটা খোলেনি। প্রসেসটা কী অবস্থায় থাকবে? তারপর যদি একজন writer আসে কিন্তু কিছু না লিখেই সাথে সাথে বন্ধ করে দেয়, তখন reader-এর open() আর তারপরের read() কী আচরণ করবে?প্রথম অংশ — reader-এর open() ব্লক করবে। FIFO-র blocking-open সেমান্টিক্স অনুযায়ী (concept সেকশন), read-only open() কোনো writer না থাকা পর্যন্ত ব্লক করে থাকে — এটা একটা rendezvous, দুই প্রান্তের কেউ একা এগোতে পারে না।
দ্বিতীয় অংশ — writer আসে, কিছু না লিখেই বন্ধ করে দেয়:
সময়রেখা:
t0: reader open() ব্লক অবস্থায়
t1: writer open() করে -- rendezvous সম্পন্ন, reader-এর open() এখন রিটার্ন করে
t2: writer কিছু না লিখেই close() করে
t3: reader তখন read() কল করেopen() t1-এই সফলভাবে রিটার্ন করবে (rendezvous শর্ত পূরণ হয়েছে, “কিছু লেখা হয়েছে” শর্ত না)। t3-এ reader-এর read() কী পাবে তা নির্ভর করে t3-এর মুহূর্তে write-end-এর refcount-এর উপর — যেহেতু writer t2-তেই বন্ধ করে দিয়েছে এবং আর কোনো writer নেই, refcount শূন্য। তাই read() সাথে সাথে 0 (EOF) রিটার্ন করবে, ব্লক করবে না — hood সেকশনের নিয়ম অনুযায়ী: buffer খালি + কোনো লিখিয়ে বাকি নেই = EOF।
একটা সূক্ষ্ম কিন্তু গুরুত্বপূর্ণ পয়েন্ট: যদি reader t1-এর পরে কিন্তু t2-এর আগে (writer এখনো খোলা আছে, এখনো কিছু লেখেনি) read() কল করে, তখন সে ব্লক করবে — কারণ তখন buffer খালি কিন্তু একজন writer এখনো সক্রিয়ভাবে সংযুক্ত (সে হয়তো পরে লিখবে)। শুধু writer-এর close()-এর পরেই EOF আসে। এটাই দেখায় কেন “খালি buffer” আর “EOF” দুইটা ভিন্ন অবস্থা — প্রথমটা সাময়িক (ব্লক), দ্বিতীয়টা স্থায়ী (রিটার্ন 0)।
এই পুরো rendezvous + state-machine প্যাটার্ন lesson 25-এর signal আলোচনায় self-pipe trick-এর ভিত্তি হয়ে ফিরে আসবে — সেখানে একটা pipe ব্যবহার করা হবে signal-কে epoll-এর সাথে সংযুক্ত করতে, ঠিক এই একই blocking/wake-up নিয়ম কাজে লাগিয়ে।
5dd if=/dev/zero bs=1M count=100 | pv | cat > /dev/null চালালে dd লেখে ১০০ MB-র বেশি ডেটা, একবারে ১ MB করে write() কল করে। pipe-এর ডিফল্ট ক্ষমতা ৬৪ KB। dd-এর প্রতিটা write(fd, buf, 1048576) কল ঠিক কীভাবে সম্পন্ন হয়, যেহেতু এক কলেই ৬৪ KB-র বেশি লিখতে বলা হচ্ছে?
প্রয়োগ
dd if=/dev/zero bs=1M count=100 | pv | cat > /dev/null চালালে dd লেখে ১০০ MB-র বেশি ডেটা, একবারে ১ MB করে write() কল করে। pipe-এর ডিফল্ট ক্ষমতা ৬৪ KB। dd-এর প্রতিটা write(fd, buf, 1048576) কল ঠিক কীভাবে সম্পন্ন হয়, যেহেতু এক কলেই ৬৪ KB-র বেশি লিখতে বলা হচ্ছে?এখানে একটা গুরুত্বপূর্ণ পার্থক্য মনে রাখা দরকার — PIPE_BUF (৪০৯৬ বাইট) atomicity-র সীমা, কিন্তু pipe capacity (৬৪ KB) সেই সীমা না। এই দুইটা আলাদা সংখ্যা, আলাদা উদ্দেশ্যে।
একটা write() কল যেটা pipe capacity-র চেয়ে বড় (এখানে ১ MB > ৬৪ KB), সেটা আংশিকভাবে সম্পন্ন হতে পারে এবং হবে — এটা PIPE_BUF-এর ৪০৯৬ বাইটের নিচের atomicity গ্যারান্টির লঙ্ঘন না, কারণ সেই গ্যারান্টি শুধু ৪০৯৬ বাইট বা তার কম লেখার জন্য প্রযোজ্য।
যা ঘটে ধাপে ধাপে:
dd: write(fd, buf, 1048576) -- ১ MB লিখতে চায়
│
kernel: pipe capacity ৬৪ KB, বর্তমানে খালি ধরি
│
৬৪ KB পর্যন্ত লিখে দেয় (buffer পূর্ণ)
│
write() কি রিটার্ন করে? -- না, এখনো না। POSIX অনুযায়ী write()
হয় সবটা লিখবে (ব্লক করে অপেক্ষা করে বাকিটার জন্য জায়গা খালি
হওয়ার), অথবা আংশিক লিখে আংশিক বাইট-সংখ্যা রিটার্ন করবে --
pipe-এর ক্ষেত্রে (non-blocking না হলে) kernel সাধারণত পুরো
request সম্পূর্ণ না করা পর্যন্ত ব্লকিং লুপে থাকে, ভেতরে ভেতরে
কয়েক দফায় buffer ভরে-খালি হতে দিয়ে
│
pv (মাঝের প্রসেস) ৬৪ KB পড়ে নেয়, buffer-এ জায়গা খালি হয়
│
dd-এর write() আবার এগিয়ে যায়, বাকি অংশ লেখে -- এই চক্র
চলতেই থাকে যতক্ষণ না পুরো ১ MB সম্পন্ন হয়
│
write() অবশেষে রিটার্ন করে 1048576 (সম্পূর্ণ বাইট সংখ্যা)অর্থাৎ userspace-এর দৃষ্টিতে write() একটাই কল, একটাই রিটার্ন মান (পুরো ১ MB) — কিন্তু kernel-এর ভেতরে এটা কার্যত অনেকগুলো ছোট চক্রে ভাঙা, প্রতিটা চক্রে pipe capacity-র সীমা পর্যন্ত লিখে reader-এর জন্য অপেক্ষা করে। এটাই ঠিক hood সেকশনের block-then-wake প্যাটার্ন, শুধু একটামাত্র syscall-এর ভেতরে বহুবার ঘটছে।
ব্যবহারিক ফল — একটা বড় write() pipe-এ পাঠানো নিরাপদ (কোনো ডেটা হারায় না, সবটাই শেষমেশ পৌঁছায়), কিন্তু এটা atomic নয় যদি আকার PIPE_BUF-এর বেশি হয় — একাধিক writer একই সময়ে বড় write() করলে তাদের chunk-গুলো interleave হতে পারে, ঠিক experiment সেকশনের ৫০০০-বাইট পরীক্ষার মতো, এখানে আরও বড় স্কেলে। dd-এর ক্ষেত্রে যেহেতু এটাই একমাত্র writer, interleaving-এর কোনো ঝুঁকি নেই — শুধু performance-এর প্রশ্ন (buffer ছোট হলে বেশি চক্র লাগে, F_SETPIPE_SZ দিয়ে বাফার বড় করলে চক্র কমে)।
এরপর কী
পরের লেসন — Shared Memory এবং Message Queue
Pipe চমৎকার, কিন্তু এর একটা মৌলিক খরচ আছে — প্রতিটা বাইট দুইবার কপি হয়: writer-এর buffer থেকে kernel-এর pipe buffer-এ, তারপর kernel-এর pipe buffer থেকে reader-এর buffer-এ। প্রতিটা কপি একটা copy_from_user/copy_to_user, আর প্রতিটা read/write একটা syscall — বড় ডেটার জন্য এই খরচ যোগ হতে থাকে।
পরের লেসনে আমরা এমন একটা কৌশল দেখব যেটা এই কপি সম্পূর্ণভাবে বাদ দেয় — shared memory, যেখানে দুইটা প্রসেস আক্ষরিক অর্থে একই physical page একসাথে দেখে, কোনো kernel-মধ্যস্থতা ছাড়াই। কিন্তু বিনামূল্যে না — এই গতির বিনিময়ে হারাবেন সেই সব জিনিস যা pipe বিনামূল্যে দিয়েছিল: kernel-প্রয়োগকৃত synchronization (কোনো ব্লকিং read/write নেই, শুধু raw মেমরি), আর message boundary (pipe-এ আপনি জানেন কতটা লিখেছেন, shared memory-তে সেটা নিজেকে ট্র্যাক করতে হয়)। আমরা memfd_create + mmap দিয়ে একটা shared ring buffer বানাব — আর ইচ্ছাকৃতভাবে এর synchronization অংশটা একটা TODO হিসেবে খোলা রেখে দেব, কারণ সেটা সঠিকভাবে বন্ধ করার যন্ত্রপাতি (mutex, condition variable) এখনো আমাদের হাতে নেই — সেটা আসবে lesson 26-এ।
আরও পড়ুন
- pipe(7) — Linux man page · PIPE_BUF, pipe capacity, F_SETPIPE_SZ, আর atomicity গ্যারান্টির প্রামাণ্য বর্ণনা
- fifo(7) — Linux man page · mkfifo-এর blocking-open সেমান্টিক্স -- reader/writer একে অপরের জন্য কীভাবে অপেক্ষা করে তার নির্ভুল বর্ণনা
- The Linux Programming Interface, Chapter 44: Pipes and FIFOs — Michael Kerrisk · pipe আর FIFO নিয়ে সবচেয়ে পূর্ণাঙ্গ আলোচনা -- half-duplex সীমাবদ্ধতা, atomicity, আর blocking সেমান্টিক্সের প্রতিটা কোণা এই অধ্যায়ে কভার করা
- write(3p) — POSIX specification, atomicity of writes · PIPE_BUF-এর নিচে write() atomic হওয়ার বাধ্যতামূলক গ্যারান্টি এখানে স্পষ্টভাবে লেখা আছে