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

Inter-process communication — pipe, shared memory, message queue আর socket

Inter-Process Communication — Shared Memory vs Message Passing, Pipes, FIFOs, mmap, Message Queues and Sockets

Taught in Bangla, this lesson covers inter-process communication: independent vs cooperating processes, shared memory vs message passing, message-passing design choices (direct/indirect, blocking/non-blocking, buffer capacity), pipes and the EOF rule, named pipes (FIFOs), shm_open and mmap, message queues, Unix domain sockets and signals, Windows and macOS mechanisms, higher layers like RPC, D-Bus and Binder, and a comparison table.

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

  • Independent আর cooperating প্রসেসের পার্থক্য, আর প্রসেস কেন একসাথে কাজ করে
  • Shared memory বনাম message passing — গতি, সমন্বয়, kernel-এর ভূমিকা আর একাধিক মেশিনে ছড়ানোর তুলনা
  • Message passing-এর তিন নকশা-সিদ্ধান্ত: direct/indirect, blocking/non-blocking, আর buffer-এর ধারণক্ষমতা
  • Pipe, FIFO, shared memory, message queue আর socket — প্রতিটি চালিয়ে দেখা, আর কোনটি কখন বেছে নেবেন
  • Windows আর macOS-এ একই কাজের ব্যবস্থা, আর RPC, D-Bus, Binder-এর মতো উঁচু স্তর

১ · প্রসেস আলাদা কেন, আর তবু কথা বলতে হয় কেন

পর্ব ৮-এ দেখেছেন, প্রতিটি প্রসেস নিজের একটি জগতে থাকে। তার কোড, ডেটা, heap আর stack — সবকিছু তার নিজের address space-এ। একটি প্রসেস যদি 0x5000 ঠিকানায় লেখে, আরেকটি প্রসেসের 0x5000 ঠিকানায় কিছুই বদলায় না। কারণ দুজনের ঠিকানা আলাদা page table দিয়ে আলাদা physical মেমরিতে যায় (পর্ব ৩২)। এই দেয়ালটি OS ইচ্ছে করে বানায়, আর এটিই কম্পিউটারকে স্থিতিশীল রাখে।

দেয়ালের লাভ স্পষ্ট: একটি প্রসেস ক্র্যাশ করলে বাকিরা টিকে থাকে, আর একটি দুষ্ট প্রোগ্রাম আপনার ব্যাংকিং অ্যাপের মেমরি থেকে পাসওয়ার্ড পড়তে পারে না।

একটি অফিসের কথা ভাবুন। প্রত্যেক কর্মকর্তার নিজের কামরা আর নিজের ফাইল ক্যাবিনেট আছে, চাবি শুধু তাঁর কাছে। একজন আরেকজনের ক্যাবিনেট খুলতে পারেন না — এটি প্রসেসের আলাদা address space. কিন্তু অফিস চালাতে হলে কথা বলতেই হয়। হিসাব বিভাগ বেতনের তালিকা পাঠাবে প্রশাসনে, প্রশাসন অনুমোদন পাঠাবে ফেরত। এর জন্য দুটি পথ থাকে: করিডরে একটি নোটিশ বোর্ড, যেখানে সবাই লিখতে আর পড়তে পারেন; অথবা পিয়নের হাতে চিঠি, যিনি এক কামরা থেকে আরেক কামরায় নিয়ে যান। এই দুটিই IPC-র দুই মডেল — shared memory আর message passing.

Independent আর cooperating প্রসেস

পাঠ্যবই প্রসেসকে দুই ভাগে ভাগ করে। যে প্রসেস অন্য কোনো প্রসেসের কাজে প্রভাব ফেলে না এবং অন্যের দ্বারা প্রভাবিতও হয় না, সে independent process. যেমন আপনার ক্যালকুলেটর আর গান-শোনার অ্যাপ — একসাথে চললেও কেউ কারও তথ্য নেয় না। আর যে প্রসেস অন্যের সাথে তথ্য ভাগ করে, অন্যের কাজে প্রভাব ফেলে বা প্রভাবিত হয়, সে cooperating process. Cooperating প্রসেসের জন্যই IPC লাগে।

প্রসেসকে একসাথে কাজ করানোর কারণগুলো পরীক্ষায় প্রায়ই চাওয়া হয়:

📋 তথ্য ভাগাভাগি

কয়েকটি প্রোগ্রামের একই তথ্য লাগে — যেমন একটি ডেটাবেস সার্ভারের অনেক worker প্রসেস একই buffer cache ব্যবহার করে।

information sharing

⚡ দ্রুত কাজ

বড় কাজকে টুকরো করে একাধিক CPU core-এ আলাদা প্রসেসে চালানো, শেষে ফল জোড়া দেওয়া।

computation speedup

🧩 মডিউলারিটি

বড় সিস্টেমকে ছোট, আলাদা অংশে ভাগ করা — shell-এর sort | uniq -c এর সবচেয়ে সহজ উদাহরণ।

modularity

🛡️ সুবিধা ও সুরক্ষা

ঝুঁকিপূর্ণ কাজ কম ক্ষমতার আলাদা প্রসেসে রাখা — যেমন Chrome-এ ওয়েবপেজ চালানোর renderer প্রসেস sandbox-এর ভেতরে থাকে, আর মূল browser প্রসেসের সাথে বার্তা দিয়ে কথা বলে।

convenience · privilege separation

খেয়াল করুন, একই প্রসেসের ভেতরের thread-গুলোর এই সমস্যা নেই — তারা একই address space ভাগ করে (পর্ব ১৪)। তবে thread-এর ভাগাভাগি “সব অথবা কিছুই না” — একটি thread মেমরি নষ্ট করলে পুরো প্রসেস পড়ে যায়। প্রসেস আর IPC দিয়ে আপনি ঠিক করেন কোন তথ্যটুকু ভাগ হবে।

২ · দুই মডেল — shared memory বনাম message passing

সব IPC ব্যবস্থাকেই শেষ পর্যন্ত দুই মডেলের একটিতে ফেলা যায়। পার্থক্যটি মূলত একটি প্রশ্নে: ডেটা পৌঁছাতে প্রতিবার kernel-কে লাগে কি না।

📌 Shared memory (নোটিশ বোর্ড)

Kernel একবার মেমরির একটি অংশ দুই প্রসেসের address space-এ বসিয়ে দেয়। এরপর দুজনেই সাধারণ load/store instruction দিয়ে সেখানে পড়ে-লেখে। Kernel আর মাঝখানে থাকে না।

শুরুতে একবার system call, তারপর শূন্য

✉️ Message passing (পিয়নের চিঠি)

প্রেরক send, প্রাপক receive ডাকে। প্রতিটি বার্তা প্রেরকের মেমরি থেকে kernel-এ, আবার kernel থেকে প্রাপকের মেমরিতে কপি হয়। Kernel ক্রম, অপেক্ষা আর অনুমতি সামলায়।

প্রতি বার্তায় system call
দিকShared memoryMessage passing
গতিসবচেয়ে দ্রুত; বড় ডেটায় কোনো কপি লাগে নাপ্রতিবার দুবার কপি আর মোড বদল; ছোট বার্তায় খরচ চোখে পড়ে
Kernel-এর ভূমিকাশুধু শুরুতে জায়গা তৈরি ও map করাপ্রতিটি send/receive-এ
সমন্বয় (synchronization)পুরো দায় প্রোগ্রামারের — lock বা semaphore ছাড়া race conditionঅনেকটা kernel নিজেই করে: খালি হলে প্রাপক অপেক্ষা করে
প্রোগ্রামিংডেটার গঠন দুজনকে একইভাবে বুঝতে হয়; pointer রাখা বিপজ্জনকসহজ — “পাঠানো আর নেওয়া”; বার্তার সীমানা পরিষ্কার
একাধিক মেশিনেসাধারণত না; একই কম্পিউটারের RAM লাগেহ্যাঁ — একই ধারণা নেটওয়ার্কেও চলে (socket, RPC)
ভুলের প্রভাবএকজন ভুল লিখলে অন্যজনের ডেটা নষ্টবার্তা আলাদা কপি; প্রাপক যাচাই করে নিতে পারে
উদাহরণshm_open + mmap, System V shmget, Windows file mappingpipe, FIFO, message queue, socket, Mach message, Binder

অনেক পাঠ্যবই বলে, shared memory দ্রুত কারণ “kernel লাগে না”। কথাটি অর্ধেক সত্য। ডেটা পড়া-লেখায় kernel লাগে না ঠিকই, কিন্তু “কখন পড়ব?” — এই সমন্বয়ের জন্য প্রায়ই semaphore বা futex লাগে, যা ব্যস্ত অবস্থায় আবার kernel-এ যায়। আর বহু-core মেশিনে একই মেমরি দুই core-এ পালাক্রমে লেখা হলে cache line এক core থেকে আরেক core-এ ছোটাছুটি করে (cache coherence, পর্ব ৪)। তাই ছোট বার্তায় ব্যবধান বইয়ের ছবির মতো বিশাল নয়; বড় ডেটায় shared memory স্পষ্টভাবে জেতে।

মূল ধারণা

Shared memory-তে আপনি পান গতি, বিনিময়ে নেন সমন্বয়ের দায়। Message passing-এ আপনি পান সরলতা, সুরক্ষা আর নেটওয়ার্কে ছড়ানোর সুযোগ, বিনিময়ে দেন কপি আর system call-এর খরচ। বাস্তবের বড় সিস্টেম প্রায়ই দুটো মেশায়: নিয়ন্ত্রণের ছোট বার্তা যায় message passing-এ, আর বড় ডেটা থাকে shared memory-তে।

৩ · Message passing-এর নকশা — কাকে, কখন, কতটুকু জমা

“বার্তা পাঠানো” কথাটি শুনতে সহজ। কিন্তু একটি message passing ব্যবস্থা বানাতে গেলে OS ডিজাইনারকে তিনটি প্রশ্নের উত্তর ঠিক করতে হয়। পরীক্ষা আর ইন্টারভিউতে এগুলো খুব চেনা।

প্রশ্ন ১ — নাম ধরে, নাকি ডাকবাক্সে? (direct বনাম indirect)

  • Direct communication: প্রেরক প্রাপকের নাম ধরে পাঠায় — send(P, msg), receive(Q, msg). প্রতি জোড়া প্রসেসের মধ্যে ঠিক একটি সংযোগ। প্রাপক যেকোনো জনের কাছ থেকে নিতে চাইলে receive(any, msg) — একে বলে asymmetric addressing. অসুবিধা: প্রাপকের পরিচয় বদলালে সব প্রেরকের কোড বদলাতে হয়।
  • Indirect communication: বার্তা যায় একটি mailbox বা port-এ — send(A, msg), receive(A, msg), যেখানে A একটি ডাকবাক্স। একই mailbox অনেক প্রসেস ভাগ করতে পারে, আর একজোড়া প্রসেস একাধিক mailbox দিয়ে যুক্ত থাকতে পারে। POSIX message queue, Mach port আর Windows ALPC port — সবই এই ধরনের।

Mailbox ভাগ করলে একটি প্রশ্ন ওঠে: P₁ পাঠাল, আর P₂ ও P₃ দুজনেই receive ডাকল — বার্তা পাবে কে? সমাধান তিনটি: এক mailbox-এ সর্বোচ্চ দুই প্রসেস; একসাথে মাত্র একজনকে receive করতে দেওয়া; অথবা সিস্টেম নিজে একজনকে বেছে নেবে। POSIX message queue-তে প্রতিটি বার্তা ঠিক একজন প্রাপক পায়।

প্রশ্ন ২ — অপেক্ষা করবে, নাকি ফিরে যাবে? (blocking বনাম non-blocking)

অপারেশনBlocking (synchronous)Non-blocking (asynchronous)
sendবার্তা গৃহীত না হওয়া পর্যন্ত (বা buffer-এ জায়গা না হওয়া পর্যন্ত) প্রেরক ঘুমায়বার্তা রেখেই ফেরে; জায়গা না থাকলে সঙ্গে সঙ্গে ত্রুটি (Linux-এ EAGAIN)
receiveবার্তা না আসা পর্যন্ত প্রাপক ঘুমায়বার্তা থাকলে নেয়, না থাকলে খালি হাতে ফেরে (EAGAIN)

যখন send আর receive দুটোই blocking, তখন প্রেরক আর প্রাপক একটি মুহূর্তে “মিলিত” হয় — একে বলে rendezvous. Linux-এ fd-কে non-blocking করতে O_NONBLOCK flag লাগে; সার্ভার সাধারণত এর সাথে epoll ব্যবহার করে, যাতে একটি thread অনেক সংযোগ সামলাতে পারে।

প্রশ্ন ৩ — পথে কতগুলো বার্তা জমতে পারে? (buffering)

  • Zero capacity: পথে কোনো বার্তা জমে না। প্রেরককে প্রাপক না নেওয়া পর্যন্ত অপেক্ষা করতেই হয় — স্বাভাবিকভাবেই rendezvous. Go ভাষার unbuffered channel ঠিক এমন।
  • Bounded capacity: সর্বোচ্চ nটি বার্তা জমতে পারে। জায়গা থাকলে প্রেরক না থেমে চলে যায়; ভর্তি হলে অপেক্ষা করে। Pipe, POSIX message queue — বাস্তবের প্রায় সব ব্যবস্থাই এই ধরনের।
  • Unbounded capacity: অসীম জায়গা, প্রেরক কখনো থামে না। এটি তাত্ত্বিক মডেল — বাস্তবে মেমরি একসময় শেষ হয়।

নিচের ডায়াগ্রামে ৩ ঘরের একটি bounded mailbox দেখুন। একজন প্রেরক (S) A থেকে F পর্যন্ত বার্তা পাঠাচ্ছে, একজন প্রাপক (R) নিচ্ছে। কখন কে ঘুমায়, কে জাগে, আর non-blocking চেষ্টায় কী ফেরে — খেয়াল করুন।

1R: receive(box)          ; empty2S: send(box, A)3R: gets A, works on it4S: send B; send C; send D5S: send(box, E)          ; full6R: receive -> B ; S wakes7S: try_send(box, F)      ; full8R: receive x3 -> C, D, E9R: try_receive(box)      ; empty
Mailbox আর দুই প্রসেস
mailbox
বার্তা
S (প্রেরক)
R (প্রাপক)
  1. খালি ডাকবাক্সে receive. R আগে পৌঁছে গেছে, mailbox-এ ০টি বার্তা। Blocking receive, তাই kernel R-কে ঘুম পাড়ায় (waiting); R কোনো CPU সময় নষ্ট করে না।
  2. প্রথম বার্তা এল। S পাঠাল A. বার্তাটি mailbox-এ ঢুকল (১/৩), S-এর send সঙ্গে সঙ্গে ফিরল কারণ জায়গা ছিল। সাথে সাথে kernel অপেক্ষমাণ R-কে জাগাল।
  3. R বার্তা নিল। R পেল A, mailbox আবার ০/৩. R এখন A নিয়ে কাজ করছে — এই সময়টায় সে receive ডাকছে না।
  4. প্রেরক দ্রুত, প্রাপক ব্যস্ত। S পরপর B, C, D পাঠাল। তিনটিই জায়গা পেল, তাই তিনটি send-ই না থেমে ফিরল। Mailbox এখন ৩/৩ — ভর্তি। এটিই bounded buffer-এর সুবিধা: অল্প সময়ের গতির পার্থক্য buffer শুষে নেয়।
  5. ভর্তি mailbox-এ send. S এবার E পাঠাতে চাইল, কিন্তু জায়গা নেই। Blocking send, তাই S ঘুমাল। এটিকে বলে back-pressure — ধীর প্রাপক নিজে থেকেই দ্রুত প্রেরককে থামিয়ে দেয়, মেমরি অসীমভাবে বাড়ে না।
  6. একটি ঘর খালি হলো। R ফিরে এসে receive করল, পেল সবচেয়ে পুরোনো B (প্রথমে আসা, প্রথমে যাওয়া)। একটি ঘর খালি হতেই kernel S-কে জাগাল, আর E ঢুকে গেল। Mailbox আবার ৩/৩: C, D, E.
  7. Non-blocking চেষ্টা। S এবার F পাঠাতে চাইল non-blocking ভাবে (POSIX-এ queue O_NONBLOCK দিয়ে খোলা)। ভর্তি, তাই S ঘুমাল না — সঙ্গে সঙ্গে ত্রুটি EAGAIN নিয়ে ফিরল। F পাঠানো হয়নি; পরে আবার চেষ্টা করা S-এর দায়িত্ব।
  8. R সব বার্তা নিল। R পরপর তিনবার receive করে C, D, E পেল — যে ক্রমে ঢুকেছিল। Mailbox ০/৩, S অন্য কাজে।
  9. খালি mailbox-এ non-blocking receive. R এবার non-blocking receive করল। কিছু নেই, তাই সঙ্গে সঙ্গে EAGAIN. Blocking হলে R ধাপ ১-এর মতো আবার ঘুমাত। Zero capacity হলে ধাপ ২-৪-এর কোনো send-ই R নেওয়ার আগে ফিরত না।

এই ছবিটি আসলে বিখ্যাত producer-consumer (বা bounded-buffer) সমস্যা। এখানে kernel আমাদের হয়ে অপেক্ষা আর জাগানোর কাজটি করে দিল। মডিউল ৪-এ (পর্ব ২৪-২৬) একই কাজ shared memory-র ওপর নিজে semaphore আর condition variable দিয়ে করবেন — তখন বুঝবেন kernel এখানে কতটা ঝামেলা লুকিয়ে রাখছে।

৪ · Pipe — সবচেয়ে পুরোনো, সবচেয়ে চেনা নালি

পর্ব ৩-এ shell-এ ls -l | grep txt লিখেছিলেন। ওই | চিহ্নের পেছনে আছে একটি anonymous pipe — kernel-এর ভেতরের একটি ছোট buffer, যার একদিকে লেখা হয় আর অন্যদিকে পড়া হয়। C-তে এটি পাওয়া যায় একটি system call দিয়ে:

int fd[2];
pipe(fd);      /* fd[0] = পড়ার মুখ (read end), fd[1] = লেখার মুখ (write end) */

Pipe-এর নিয়মগুলো ছোট, কিন্তু প্রতিটি গুরুত্বপূর্ণ:

  • একমুখী: ডেটা শুধু fd[1] থেকে fd[0]-এর দিকে যায়। দুই দিকে কথা বলতে চাইলে দুটি pipe লাগে।
  • বাইটের স্রোত: pipe বার্তার সীমানা মনে রাখে না। আপনি “cha” আর “puri” আলাদা দুটি write-এ পাঠালেও পাঠক একটি read-এ “chapuri” পেতে পারে। সীমানা লাগলে নিজে newline বা দৈর্ঘ্য দিয়ে চিহ্ন দিতে হয়।
  • শুধু আত্মীয়দের জন্য: pipe-এর নাম নেই; অন্য প্রসেস এটি পায় শুধু fork-এর সময় file descriptor উত্তরাধিকার সূত্রে পেয়ে।
  • সীমিত ধারণক্ষমতা: Linux-এ ডিফল্ট ৬৫,৫৩৬ বাইট (১৬টি page). খালি হলে read অপেক্ষা করে, ভর্তি হলে write অপেক্ষা করে।
  • ছোট write অবিভাজ্য: PIPE_BUF বাইট (Linux-এ ৪০৯৬) বা কম লেখা অন্য লেখকের লেখার সাথে মেশে না; বড় write-এ মিশতে পারে।
  • EOF আর SIGPIPE: সব লেখার মুখ বন্ধ হলে, buffer খালি হওয়ার পর read ফেরত দেয় 0 — অর্থাৎ ফাইলের শেষ। উল্টো দিকে, সব পড়ার মুখ বন্ধ থাকলে write করলে লেখক SIGPIPE signal পায় (ডিফল্টে প্রসেস শেষ), আর signal উপেক্ষা করা থাকলে write ব্যর্থ হয় EPIPE দিয়ে।

নিচের ডায়াগ্রামে বোঝার সুবিধার জন্য মাত্র ৮ বাইটের একটি খেলনা pipe নেওয়া হয়েছে (আসল Linux-এ ৬৫,৫৩৬)। ␤ চিহ্ন মানে newline, আর · মানে খালি ঘর।

1R: read(fd[0], buf, 16)2W: write(fd[1], "cha\n", 4)3R: read returns 44W: write(fd[1], "singara\n", 8)5W: write(fd[1], "puri\n", 5)6R: read(fd[0], buf, 6) -> 67W: (woken) write returns 58W: close(fd[1])9R: read returns 710R: read returns 0   ; EOF
Kernel-এর pipe buffer
buffer
ভরা
খোলা লেখার মুখ
W (লেখক)
R (পাঠক)
  1. খালি pipe-এ read. পাঠক R আগে এসে ১৬ বাইট চাইল। Buffer ০/৮, কিন্তু একটি লেখার মুখ এখনো খোলা — মানে ডেটা আসতে পারে। তাই read ফেরে না, kernel R-কে ঘুম পাড়ায়।
  2. প্রথম লেখা। লেখক W ৪ বাইট “cha␤” লিখল। জায়গা ছিল, তাই write সঙ্গে সঙ্গে 4 ফেরত দিল। Buffer ৪/৮. ডেটা আসায় kernel R-কে জাগাল।
  3. যা আছে তা-ই ফেরত। R চেয়েছিল ১৬ বাইট, পেল ৪ — buffer-এ যতটুকু ছিল। তাই কোড সবসময় ফেরত সংখ্যা দেখে লুপে পড়ে।
  4. Buffer ভর্তি। W এবার ৮ বাইট “singara␤” লিখল। ঠিক ৮টি ঘর খালি ছিল, write 8 ফেরত দিল। Buffer ৮/৮ — আর একটি বাইটেরও জায়গা নেই। R এখনো আগের ডেটা নিয়ে ব্যস্ত।
  5. ভর্তি pipe-এ write. W আরও ৫ বাইট “puri␤” লিখতে চাইল। জায়গা নেই, তাই blocking write — W ঘুমাল। ৫ বাইট PIPE_BUF-এর চেয়ে কম, তাই POSIX-এর নিয়মে পুরো ৫ বাইটের জায়গা না হওয়া পর্যন্ত কিছুই লেখা হয় না; টুকরো করে মেশানো হয় না।
  6. পাঠক জায়গা খালি করল। R ৬ বাইট পড়ল, পেল “singar”. Buffer-এ রইল “a␤” — ২/৮. এখন ৬টি ঘর খালি, ৫ বাইটের জন্য যথেষ্ট, তাই kernel W-কে জাগাল।
  7. আটকে থাকা write শেষ হলো। জেগে উঠে W-এর “puri␤” buffer-এ ঢুকল, write 5 ফেরত দিল। Buffer “a␤puri␤” — ৭/৮.
  8. লেখক লেখার মুখ বন্ধ করল। W-এর আর কিছু পাঠানোর নেই, তাই close(fd[1]). খোলা লেখার মুখ এখন ০. লক্ষ করুন, buffer-এ থাকা ৭ বাইট হারায়নি — বন্ধ করা মানে “আর আসবে না”, “যা আছে মুছে ফেলা” নয়।
  9. বাকিটা পড়া। R পেল বাকি ৭ বাইট “a␤puri␤”. Buffer খালি, আর R আবার read ডাকবে।
  10. EOF. এবার buffer খালি এবং খোলা লেখার মুখ ০. Kernel জানে আর কোনো ডেটা কখনো আসবে না, তাই read ঘুমায় না — সঙ্গে সঙ্গে 0 ফেরত দেয়। এই 0-ই EOF. শর্ত দুটির একটিও না মিললে R ধাপ ১-এর মতো ঘুমাত।

হাতে-কলমে — parent লেখে, child পড়ে

এবার আসল pipe. Parent তিনটি অর্ডার লিখবে, child পড়বে যতক্ষণ না EOF আসে। দুটি জিনিস খেয়াল করুন: fork-এর আগে fflush(stdout) (না করলে stdout-এর buffer-এ থাকা লেখা দুই প্রসেসেই কপি হয়ে দুবার ছাপা হতে পারে — পর্ব ১০), আর দুই প্রসেসেই অব্যবহৃত মুখ বন্ধ করা।

#include <stdio.h>
#include <string.h>
#include <sys/wait.h>
#include <unistd.h>

int main(void) {
    int fd[2];                          /* fd[0] = read end, fd[1] = write end */
    if (pipe(fd) == -1) { perror("pipe"); return 1; }
    printf("pipe(): read end = fd %d, write end = fd %d\n", fd[0], fd[1]);
    fflush(stdout);                     /* flush BEFORE fork */

    pid_t pid = fork();
    if (pid == 0) {                     /* child = reader */
        close(fd[1]);                   /* close the unused write end! */
        char buf[16];
        ssize_t n, total = 0;
        while ((n = read(fd[0], buf, sizeof buf)) > 0) {
            fwrite(buf, 1, (size_t)n, stdout);
            total += n;
        }
        printf("child : read() returned %zd -> EOF after %zd bytes\n", n, total);
        fflush(stdout);
        _exit(0);
    }
    close(fd[0]);                       /* parent = writer */
    const char *orders[] = {"order 1: cha\n", "order 2: singara\n", "order 3: puri\n"};
    for (int i = 0; i < 3; i++)
        if (write(fd[1], orders[i], strlen(orders[i])) == -1) perror("write");
    close(fd[1]);                       /* last write end closed -> reader sees EOF */
    waitpid(pid, NULL, 0);
    printf("parent: child has finished, pipe is gone\n");
    return 0;
}

একটি আসল রানের আউটপুট:

pipe(): read end = fd 3, write end = fd 4
order 1: cha
order 2: singara
order 3: puri
child : read() returned 0 -> EOF after 44 bytes
parent: child has finished, pipe is gone

প্রথম লাইন দেখায়, pipe পেয়েছে fd 3 আর 4 — কারণ 0, 1, 2 আগেই stdin, stdout, stderr-এর দখলে, আর kernel সবসময় সবচেয়ে ছোট খালি নম্বরটি দেয়। Child ১৬ বাইটের টুকরোয় পড়েছে; টুকরোর সংখ্যা প্রতি রানে বদলাতে পারে বলে সেটি ছাপা হয়নি, কিন্তু মোট বাইট নির্দিষ্ট: ১৩ + ১৭ + ১৪ = ৪৪. শেষ read ফিরিয়েছে 0 — EOF, কারণ parent নিজের লেখার মুখ বন্ধ করেছে আর child নিজেরটি শুরুতেই বন্ধ করেছিল। Parent-এর লাইন সবার শেষে, কারণ সে waitpid দিয়ে child শেষ হওয়ার অপেক্ষা করেছে। Child-এ _exit-এর আগে fflush দরকার ছিল, কারণ _exit stdio-র buffer খালি করে না।

সবচেয়ে চেনা বাগ — “read কখনো শেষ হয় না”

উপরের কোড থেকে child-এর close(fd[1]); লাইনটি মুছে দিলে কী হয়? প্রোগ্রাম তিনটি অর্ডার ছাপবে, তারপর চিরকাল আটকে থাকবে। কোনো error নেই, crash নেই — শুধু নীরবতা। কারণটি fork-এর একটি সরল নিয়মে: fork-এর পর প্রতিটি খোলা file descriptor দুই প্রসেসেই থাকে, আর kernel গোনে মোট কতগুলো লেখার মুখ খোলা।

1parent: pipe(fd)2parent: fork()3parent: write x3; close(fd[1])4child : read ... 44 bytes5child : read()   ; waits forever6FIX child: close(fd[1]) first
কার কাছে কোন মুখ খোলা
লেখার মুখ fd[1]
পড়ার মুখ fd[0]
buffer
child
parent
  1. একটি pipe, দুটি মুখ। Parent pipe() ডাকল। দুটি মুখই এখন শুধু parent-এর কাছে। Buffer খালি।
  2. fork দুটি মুখই কপি করে। Child parent-এর fd টেবিলের কপি পেল। এখন লেখার মুখ দুজনের কাছে, পড়ার মুখও দুজনের কাছে। Kernel-এর হিসাবে খোলা লেখার মুখ ২টি।
  3. Parent নিজের কাজ ঠিকঠাক করল। Parent তিনটি লাইন (৪৪ বাইট) লিখে নিজের লেখার মুখ বন্ধ করল। কিন্তু child নিজের কপিটি বন্ধ করেনি — তাই খোলা লেখার মুখ এখনো ১টি, আর সেটি child-এর নিজের হাতে।
  4. সব ডেটা ঠিকমতো পৌঁছাল। Child ৪৪ বাইটই পড়ে ছাপল। এ পর্যন্ত সব স্বাভাবিক দেখায় — এজন্যই বাগটি ধরা কঠিন।
  5. নীরব আটকে যাওয়া। Buffer খালি, কিন্তু একটি লেখার মুখ খোলা। Kernel ধরে নেয়, কেউ হয়তো আরও লিখবে, তাই read ঘুমায়। অথচ সেই “কেউ” child নিজেই, যে read-এ ঘুমিয়ে আছে! Parent আবার waitpid-এ child-এর জন্য অপেক্ষা করছে। দুজনেই চিরকাল অপেক্ষায় — একরকম deadlock (পর্ব ২৭)।
  6. সমাধান। fork-এর ঠিক পরে child close(fd[1]) করলে parent-এর close-এর পর খোলা লেখার মুখ ০টি। Buffer খালি হতেই read 0 ফেরত দেয়, child লুপ থেকে বেরিয়ে শেষ হয়, parent-এর waitpid ফেরে। নিয়ম: fork-এর পর প্রতিটি প্রসেস যে মুখ ব্যবহার করবে না, সেটি সঙ্গে সঙ্গে বন্ধ করুন।

অনেক child চালানো প্রোগ্রামে প্রতিটি child অন্যদের pipe-এর লেখার মুখ উত্তরাধিকার সূত্রে পেয়ে খোলা রাখলে একই বাগ হয়। আধুনিক কোডে তাই pipe2(fd, O_CLOEXEC) ব্যবহার করা হয় (Linux-এ পাওয়া যায়): exec হওয়ার সময় fd আপনা থেকেই বন্ধ হয়। Windows-এ একই ধরনের সমস্যা হয় handle inheritance থেকে — CreateProcess-এ bInheritHandles = TRUE দিলে উত্তরাধিকারযোগ্য সব handle child পায়।

৫ · Named pipe (FIFO) — নামওয়ালা নালি

Anonymous pipe-এর বড় সীমাবদ্ধতা: নাম নেই, তাই শুধু fork-সূত্রে আত্মীয় প্রসেসরাই ব্যবহার করতে পারে। দুটি সম্পূর্ণ আলাদা প্রোগ্রাম — ধরুন একটি log সংগ্রাহক আর একটি সার্ভার, যাদের আলাদা সময়ে আলাদা মানুষ চালু করেছেন — কীভাবে pipe ভাগ করবে? উত্তর: FIFO (first in, first out), যাকে named pipe-ও বলে।

FIFO হলো ফাইল সিস্টেমে একটি বিশেষ ধরনের নাম। তৈরি করতে shell-এ mkfifo name বা C-তে mkfifo(path, mode). যে প্রসেস নামটি জানে আর অনুমতি আছে, সে সাধারণ ফাইলের মতো open করতে পারে। কিন্তু ডেটা ডিস্কে লেখা হয় না — ভেতরে এটি অবিকল pipe, kernel-এর মেমরির buffer. FIFO-র একটি বিশেষ আচরণ: পড়ার জন্য open করলে লেখক না আসা পর্যন্ত, আর লেখার জন্য open করলে পাঠক না আসা পর্যন্ত open নিজেই অপেক্ষা করে।

নিচের স্ক্রিপ্টে একটি FIFO বানিয়ে পেছনে (background) একজন লেখক চালানো হয়েছে, তারপর আলাদাভাবে একজন পাঠক:

mkfifo kitchen.fifo                     # a named pipe, created in the cwd
ls -l kitchen.fifo | cut -c1-10         # first letter p = pipe
[ -p kitchen.fifo ] && echo "test -p: kitchen.fifo is a FIFO"

# writer runs in the background; open() blocks until a reader opens the FIFO
( for o in cha singara puri; do echo "order: $o"; done > kitchen.fifo ) &
writer=$!
sleep 0.3
echo "writer state while no reader: $(ps -o stat= -p "$writer")"

# reader: unrelated to the writer, finds it only by the file name
while read -r line; do echo "reader got -> $line"; done < kitchen.fifo
wait "$writer"
echo "writer exit status: $?"
echo "bytes stored in the FIFO on disk: $(stat -c %s kitchen.fifo)"
rm kitchen.fifo

আসল রানের আউটপুট:

prw-r--r--
test -p: kitchen.fifo is a FIFO
writer state while no reader: SN
reader got -> order: cha
reader got -> order: singara
reader got -> order: puri
writer exit status: 0
bytes stored in the FIFO on disk: 0

ls -l-এর প্রথম অক্ষর p — সাধারণ ফাইলে থাকে -, directory-তে d, Unix domain socket-এ s, আর FIFO-তে p. বাকি rw-r--r-- সাধারণ অনুমতি; এগুলো umask-এর ওপর নির্ভর করে, তাই আপনার মেশিনে আলাদা হতে পারে। পাঠক আসার আগে লেখকের অবস্থা SN: S মানে sleeping — লেখক FIFO-র open-এ ঘুমিয়ে আছে, কারণ এখনো কোনো পাঠক নেই (N শুধু বলে প্রসেসটি কম priority-তে চলছে, এই container-এর একটি বৈশিষ্ট্য)। পাঠক আসতেই তিনটি লাইন ক্রমমতো পৌঁছাল, আর লেখকের subshell শেষ হলে পাঠক EOF পেয়ে লুপ থেকে বেরোল। শেষ লাইনটি গুরুত্বপূর্ণ: FIFO-র আকার 0 বাইট — ডেটা কখনো ডিস্কে যায়নি, kernel-এর buffer দিয়েই গেছে।

FIFO কাজে লাগে একই মেশিনের সহজ কাজে, যেমন একটি ছোট daemon-কে কমান্ড পাঠানো। অনেক লেখক থাকলে PIPE_BUF-এর চেয়ে বড় লেখা মিশে যেতে পারে, আর উত্তর ফেরত পাঠাতে আলাদা FIFO লাগে।

Windows-এর named pipe আলাদা জিনিস। Windows-এর named pipe (CreateNamedPipe) অনেক বেশি ক্ষমতাশালী: দুই দিকে চলে, একটি নামে অনেক সংযোগ থাকতে পারে, বার্তার সীমানা রাখার “message mode” আছে, এমনকি অন্য Windows মেশিন থেকেও যুক্ত হওয়া যায়। Unix-এ এর কাছাকাছি জিনিস আসলে Unix domain socket, FIFO নয়।

৬ · Shared memory আর mmap — একই পাতা, দুই ঘরে

Message passing-এ প্রতিটি বাইট অন্তত দুবার কপি হয়। প্রায় ৮ MB-এর একটি Full HD ভিডিও ফ্রেম (১৯২০ × ১০৮০ পিক্সেল, প্রতিটি ৪ বাইট) প্রতি সেকেন্ডে ৩০ বার পাঠাতে হলে সেটি অনেক খরচ। Shared memory এর সমাধান: kernel একই physical মেমরির পাতা (page frame) দুই প্রসেসের page table-এ বসিয়ে দেয়। দুজনের কাছে এটি নিজের মেমরির মতোই — সাধারণ পয়েন্টার দিয়ে পড়া-লেখা।

Linux/POSIX-এ তিন উপায়

উপায়কীভাবেকারা ব্যবহার করতে পারে
Anonymous shared mappingfork-এর আগে mmap(…, MAP_SHARED | MAP_ANONYMOUS, -1, 0)শুধু parent আর তার পরের child-রা
POSIX shared memoryshm_open("/name") → ftruncate → mmap(MAP_SHARED)নাম জানে আর অনুমতি আছে এমন যেকোনো প্রসেস
Memory-mapped fileসাধারণ ফাইল open করে mmap(MAP_SHARED)ফাইলটি খুলতে পারে এমন যেকোনো প্রসেস; ডেটা ফাইলেও থেকে যায়
System V shared memory (পুরোনো)shmget → shmatএকটি সংখ্যা-চাবি (key) জানা প্রসেস

প্রথমটি সবচেয়ে সহজ, আর আমাদের পরীক্ষার জন্য যথেষ্ট। mmap দিয়ে দুটি পাতা নেওয়া হবে — একটি MAP_SHARED, একটি MAP_PRIVATE — আর একটি সাধারণ ভ্যারিয়েবল। Child তিনটিতেই লিখবে, parent অপেক্ষার পর পড়বে:

#include <stdio.h>
#include <sys/mman.h>
#include <sys/wait.h>
#include <unistd.h>

int main(void) {
    /* one page shared by parent and child, one page copied (private) */
    int *shared = mmap(NULL, sizeof(int), PROT_READ | PROT_WRITE,
                       MAP_SHARED | MAP_ANONYMOUS, -1, 0);
    int *priv   = mmap(NULL, sizeof(int), PROT_READ | PROT_WRITE,
                       MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    if (shared == MAP_FAILED || priv == MAP_FAILED) { perror("mmap"); return 1; }
    int plain = 0;                      /* ordinary variable */
    *shared = 0;
    *priv = 0;
    fflush(stdout);

    pid_t pid = fork();
    if (pid == 0) {                     /* child writes to all three */
        for (int i = 0; i < 1000; i++) (*shared)++;
        *priv = 1000;
        plain = 1000;
        printf("child : shared=%d private=%d plain=%d\n", *shared, *priv, plain);
        fflush(stdout);
        _exit(0);
    }
    waitpid(pid, NULL, 0);              /* wait = the simplest synchronisation */
    printf("parent: shared=%d private=%d plain=%d\n", *shared, *priv, plain);
    munmap(shared, sizeof(int));
    munmap(priv, sizeof(int));
    return 0;
}

আসল রানের আউটপুট:

child : shared=1000 private=1000 plain=1000
parent: shared=1000 private=0 plain=0

Child-এর চোখে তিনটিই 1000 — নিজের লেখা নিজে দেখছে। কিন্তু parent দেখছে শুধু shared=1000; private আর plain এখনো 0. কারণ fork-এর পর সাধারণ মেমরি (stack-এর plain) আর MAP_PRIVATE পাতা copy-on-write: child লেখামাত্র kernel তার জন্য আলাদা কপি বানায়, parent-এর পাতা অক্ষত থাকে। MAP_SHARED পাতা কপি হয় না — দুজনের page table একই physical frame দেখায়। আর waitpid নীরবে সমন্বয়ের কাজ করেছে: parent পড়েছে child শেষ হওয়ার পরে। (sizeof(int) চাইলেও kernel পুরো একটি page, সাধারণত ৪০৯৬ বাইট, দেয়।)

নিচের ডায়াগ্রামে ঠিক এই প্রোগ্রামের page table-এর ভেতরটা দেখুন। F2, F3, F7… হলো physical frame-এর কাল্পনিক নম্বর:

1mmap SHARED, mmap PRIVATE, plain=02fork()3child: (*shared)++ x10004child: *priv = 10005child: plain = 1000; _exit6parent: waitpid; read all three7process B: shm_open + mmap
Page table → physical frame
parent shared
parent private
parent plain
child shared
child private
child plain
F7-এর মান
parent পড়ল
process B
  1. fork-এর আগে তিনটি জায়গা। Parent-এর shared পাতা F7-এ, private পাতা F3-এ, আর plain ভ্যারিয়েবলটি stack-এর পাতা F2-এ। F7-এর মান 0. এখনো একটিই প্রসেস।
  2. fork — page table কপি, ডেটা নয়। Child parent-এর page table-এর কপি পেল। Shared পাতা দুজনেরই F7 — এটি নিয়মমাফিক ভাগ করা। Private পাতা আর stack-ও আপাতত একই F3, F2, কিন্তু kernel এগুলোকে “COW” (copy-on-write) চিহ্ন দিয়ে শুধু-পড়া করে রাখল (পর্ব ৩৫)।
  3. Shared পাতায় লেখা। Child ১০০০ বার (*shared)++ করল। F7 লেখার অনুমতিসহ ভাগ করা, তাই কোনো page fault নেই, কোনো কপি নেই — সরাসরি F7-এর মান 1000. Parent এই মুহূর্তে পড়লে সে-ও এটি দেখত।
  4. Private পাতায় লেখা — COW fault. Child *priv = 1000 লিখতে গেলে F3 শুধু-পড়া, তাই page fault. Kernel একটি নতুন frame F9-এ F3-এর কপি বানিয়ে child-এর page table-এ বসাল, তারপর লেখা F9-এ গেল। F3-এ parent-এর 0 অক্ষত।
  5. সাধারণ ভ্যারিয়েবলও একই নিয়মে। plain = 1000-এ stack-এর পাতা F2-তে একই COW fault, child পেল নিজের F11. এজন্যই fork-এর পর সাধারণ ভ্যারিয়েবল বদলালে parent কিছুই জানতে পারে না — global ভ্যারিয়েবল দিয়ে IPC হয় না।
  6. Parent অপেক্ষার পর পড়ল। Child শেষ, তার F9 আর F11 মুক্ত। F3 আর F2-এর মালিক এখন শুধু parent. Parent পড়ল shared = 1000 (F7), private = 0 (F3), plain = 0 (F2) — প্রোগ্রামের আউটপুটের সাথে হুবহু মিল।
  7. অসম্পর্কিত প্রসেসের সাথে ভাগ। fork-এর আত্মীয় না হলে নাম লাগে: shm_open("/board") + mmap. সেই নাম দিয়ে আরেক প্রসেস B-ও F7-এ পৌঁছায় — কিন্তু তার address space-এ হয়তো অন্য virtual ঠিকানায়। তাই shared memory-তে কখনো সাধারণ pointer রাখবেন না; রাখুন অঞ্চলের শুরু থেকে offset.

নাম দিয়ে shared memory — shm_open

অসম্পর্কিত দুই প্রোগ্রামের জন্য POSIX shared memory ব্যবহার করা হয়। নিচে দুটি আলাদা প্রোগ্রামের মূল অংশ (চালানোর জন্য নয়, নমুনা) — একটি “নোটিশ বোর্ড”-এ লেখে, আরেকটি পড়ে। Linux-এ নামটি /dev/shm/abcl_board হিসেবে দেখা যায়।

/* ---- writer ---- */
int fd = shm_open("/abcl_board", O_CREAT | O_RDWR, 0600);  /* owner only */
ftruncate(fd, 4096);                                        /* set its size */
char *board = mmap(NULL, 4096, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
close(fd);                              /* the mapping stays valid */
strcpy(board, "notice: meeting in room 302 at 3 pm");

/* ---- reader (another program, started later) ---- */
int fd = shm_open("/abcl_board", O_RDONLY, 0);
char *board = mmap(NULL, 4096, PROT_READ, MAP_SHARED, fd, 0);
close(fd);
printf("%s\n", board);
munmap(board, 4096);
shm_unlink("/abcl_board");              /* remove the name when done */

Shared memory নিজে কোনো সমন্বয় দেয় না। উপরের reader যদি writer-এর আগে চলে, সে খালি লেখা পড়বে; আর writer অর্ধেক লিখতেই পড়লে পাবে আধা-লেখা বাক্য। দুই প্রসেস একই counter একসাথে বাড়ালে কিছু বৃদ্ধি হারিয়ে যায়, কারণ (*shared)++ আসলে তিনটি ধাপ — পড়া, যোগ, লেখা। আমাদের প্রোগ্রামে এটি ঘটেনি কারণ শুধু child লিখেছে, আর parent waitpid করে অপেক্ষা করেছে। বাস্তবে লাগে process-shared mutex (PTHREAD_PROCESS_SHARED), POSIX semaphore (sem_open) বা atomic instruction — এগুলো মডিউল ৪-এর বিষয় (পর্ব ২২ থেকে ২৪)। আর অনুমতি দিন যতটুকু দরকার: 0600 বা নির্দিষ্ট group-এর জন্য 0660; 0666 দিলে মেশিনের যেকোনো user বোর্ডে লিখতে পারবে।

Memory-mapped file — ফাইলকে মেমরির মতো ব্যবহার

একটি সাধারণ ফাইলকে mmap(MAP_SHARED) করলে ফাইলের বিষয়বস্তু মেমরিতে একটি অ্যারের মতো দেখা যায়। পড়লে kernel দরকারমতো ডিস্ক থেকে page আনে (পর্ব ৩৫), লিখলে পরে ফাইলে ফেরত যায়। একই ফাইল দুই প্রসেস map করলে দুজন একই page cache-এর পাতা দেখে — অর্থাৎ এটিও shared memory, সাথে স্থায়ী সংরক্ষণ। LMDB-র মতো ডেটাবেস আর shared library লোড করা এভাবেই চলে। আসলে POSIX shm_open-ও Linux-এ /dev/shm-এর একটি মেমরি-ভিত্তিক ফাইল সিস্টেমে (tmpfs) একটি ফাইলকে map করা মাত্র।

৭ · Message queue, socket আর signal

POSIX message queue — বার্তার সীমানা আর priority

Pipe বাইটের স্রোত দেয়; কিন্তু অনেক সময় দরকার আলাদা আলাদা বার্তা — একটি অর্ডার মানে একটি পুরো বার্তা, অর্ধেক নয়। POSIX message queue ঠিক এটি দেয়: নাম দিয়ে খোলা একটি mailbox (indirect communication), যেখানে প্রতিটি mq_send একটি সম্পূর্ণ বার্তা, আর প্রতিটি বার্তার একটি priority. mq_receive সবসময় সবচেয়ে বেশি priority-র সবচেয়ে পুরোনো বার্তাটি দেয়। Queue-র আকার সীমিত (mq_maxmsg) — bounded capacity, আর ভর্তি হলে mq_send অপেক্ষা করে (non-blocking হলে EAGAIN).

#include <fcntl.h>
#include <mqueue.h>
#include <stdio.h>

int main(void) {
    struct mq_attr attr = { .mq_maxmsg = 4, .mq_msgsize = 32 };
    mqd_t q = mq_open("/abcl_orders", O_CREAT | O_RDWR, 0600, &attr);
    if (q == (mqd_t)-1) { perror("mq_open"); return 1; }

    mq_send(q, "tea for table 3", 16, 1);       /* priority 1 */
    mq_send(q, "URGENT: gas leak", 17, 9);      /* priority 9 */
    mq_send(q, "rice for table 5", 17, 1);

    char buf[32];
    unsigned prio;
    for (int i = 0; i < 3; i++) {
        ssize_t n = mq_receive(q, buf, sizeof buf, &prio);
        printf("got %zd bytes, prio %u: %s\n", n, prio, buf);
    }
    mq_close(q);
    mq_unlink("/abcl_orders");                  /* remove the name */
    return 0;
}

এই প্রোগ্রামটি আমরা একটি Linux container-এ (gcc, glibc) চালিয়ে দেখেছি; আউটপুট ছিল:

got 17 bytes, prio 9: URGENT: gas leak
got 16 bytes, prio 1: tea for table 3
got 17 bytes, prio 1: rice for table 5

“URGENT” পাঠানো হয়েছিল দ্বিতীয়, কিন্তু এসেছে প্রথমে — priority 9. বাকি দুটির priority সমান (1), তাই তারা পাঠানোর ক্রমেই এসেছে। প্রতিটি receive একটি পুরো বার্তা দিয়েছে (১৭, ১৬, ১৭ বাইট — শেষের '\0'-সহ), কখনো দুটি বার্তা জোড়া লাগেনি — pipe-এর সাথে এটিই বড় পার্থক্য। পুরোনো বই আর অনেক AI উত্তর বলে, এর জন্য -lrt দিয়ে link করতেই হবে; glibc 2.34 (২০২১) থেকে এই ফাংশনগুলো মূল libc-তেই আছে, তাই নতুন সিস্টেমে লাগে না, পুরোনো সিস্টেমে লাগতে পারে। macOS POSIX message queue সমর্থন করে না, তাই বহনযোগ্য কোডে এটি সাবধানে ব্যবহার করুন।

System V IPC — পুরোনো কিন্তু এখনো আছে

১৯৮০-র দশকের AT&T System V Unix থেকে আসা তিনটি ব্যবস্থা — message queue (msgget, msgsnd, msgrcv), semaphore set (semget, semop) আর shared memory (shmget, shmat) — একসাথে System V IPC নামে পরিচিত। এগুলো ফাইলের নামের বদলে একটি সংখ্যা-চাবি (key, প্রায়ই ftok দিয়ে বানানো) ব্যবহার করে, আর তৈরিকারী প্রসেস শেষ হয়ে গেলেও থেকে যায় — যতক্ষণ না কেউ মুছে দেয় বা কম্পিউটার reboot হয়। ipcs কমান্ড এদের তালিকা দেখায়, ipcrm মোছে। নতুন কোডে সাধারণত POSIX সংস্করণ (mq_open, shm_open, sem_open) পছন্দ করা হয়, তবে পুরোনো সফটওয়্যারে System V এখনো দেখা যায়।

Socket — একই মেশিনে, আর নেটওয়ার্কে

Socket হলো যোগাযোগের একটি প্রান্ত (endpoint). দুটি socket যুক্ত হলে দুই দিকেই ডেটা চলে। ১৯৮৩ সালের 4.2BSD Unix-এ আসা এই API আজ Linux, macOS আর Windows (Winsock) — সবখানে প্রায় একই রকম। দুই ধরনের socket এই পর্বে গুরুত্বপূর্ণ:

  • Unix domain socket (AF_UNIX): শুধু একই মেশিনের ভেতরে। ঠিকানা হলো ফাইল সিস্টেমের একটি পথ, যেমন /run/docker.sock বা PostgreSQL-এর /var/run/postgresql/.s.PGSQL.5432; তাই ফাইলের অনুমতি দিয়েই নিয়ন্ত্রণ করা যায় কে যুক্ত হতে পারবে। নেটওয়ার্কের protocol stack পার হয় না বলে সাধারণত localhost TCP-র চেয়ে দ্রুত; অপর প্রান্তের PID/UID জানা আর খোলা fd পাঠানোও যায় (প্র ০৪ দেখুন)।
  • TCP socket (AF_INET/AF_INET6): ঠিকানা হলো IP আর port, তাই অন্য মেশিনেও যাওয়া যায় — ইন্টারনেটের প্রায় সব সেবা এর ওপর চলে। সার্ভার socket → bind → listen → accept, ক্লায়েন্ট socket → connect, তারপর দুজনেই read/write (বা send/recv). বিস্তারিত C কোড আছে C প্রোগ্রামিং কোর্সের TCP socket পর্বে।

Parent আর child-এর জন্য সবচেয়ে সহজ পথ socketpair: এক কলেই দুটি যুক্ত Unix domain socket, pipe-এর মতো, কিন্তু দুই দিকে চলে। নিচে child একটি ছোট “echo” সার্ভার — যা পায় তা বড় হাতের অক্ষরে ফেরত দেয়:

#include <ctype.h>
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/wait.h>
#include <unistd.h>

int main(void) {
    int sv[2];                          /* two connected Unix-domain sockets */
    if (socketpair(AF_UNIX, SOCK_STREAM, 0, sv) == -1) { perror("socketpair"); return 1; }
    fflush(stdout);
    pid_t pid = fork();
    if (pid == 0) {                     /* child = echo server on sv[1] */
        close(sv[0]);
        char buf[64]; ssize_t n;
        while ((n = read(sv[1], buf, sizeof buf)) > 0) {
            for (ssize_t i = 0; i < n; i++) buf[i] = (char)toupper((unsigned char)buf[i]);
            if (write(sv[1], buf, (size_t)n) == -1) break;   /* reply, same socket */
        }
        _exit(0);                       /* read() == 0: client closed */
    }
    close(sv[1]);                       /* parent = client on sv[0] */
    const char *msgs[] = {"hello", "dhaka metro"};
    for (int i = 0; i < 2; i++) {
        char reply[64];
        if (write(sv[0], msgs[i], strlen(msgs[i])) == -1) { perror("write"); return 1; }
        ssize_t n = read(sv[0], reply, sizeof reply - 1);
        reply[n > 0 ? n : 0] = '\0';
        printf("client sent \"%s\" -> server replied \"%s\"\n", msgs[i], reply);
    }
    close(sv[0]);                       /* server's read() now returns 0 */
    waitpid(pid, NULL, 0);
    printf("one socket, both directions: done\n");
    return 0;
}

আসল রানের আউটপুট:

client sent "hello" -> server replied "HELLO"
client sent "dhaka metro" -> server replied "DHAKA METRO"
one socket, both directions: done

Parent একই sv[0]-এ লিখেছে আর পড়েছে; child একই sv[1]-এ পড়েছে আর লিখেছে — একটি সংযোগেই দুই দিক, যা একটি pipe দিয়ে সম্ভব ছিল না (দুটি লাগত)। Parent close(sv[0]) করতেই child-এর read 0 ফেরত দিয়েছে — pipe-এর মতোই EOF-এর নিয়ম। একটি সতর্কতা: SOCK_STREAM-ও pipe-এর মতো বাইটের স্রোত, বার্তার সীমানা রাখে না। এখানে প্রতিবার একটি পাঠিয়ে উত্তরের অপেক্ষা করা হয়েছে আর বার্তাগুলো ছোট, তাই একটি read-এ পুরো উত্তর এসেছে; বড় বা পরপর বার্তায় নিশ্চয়তা নেই। সীমানা দরকার হলে SOCK_SEQPACKET বা SOCK_DGRAM, অথবা নিজের protocol-এ দৈর্ঘ্য লিখে পাঠান। TCP socket হলেও ঠিক এই সতর্কতা খাটে।

Signal — সবচেয়ে ছোট বার্তা

Signal-ও একরকম IPC, তবে সবচেয়ে সংকীর্ণ: বার্তা মানে শুধু একটি সংখ্যা — SIGTERM, SIGUSR1, SIGCHLD ইত্যাদি। কোনো ডেটা যায় না, আর একই signal অপেক্ষমাণ থাকতে আবার এলে সাধারণ signal-এ দুটি একটিতে মিশে যেতে পারে। তবু “থেমে যান”, “config আবার পড়ুন”, “child শেষ হয়েছে” — এ ধরনের খবর জানাতে এটি যথেষ্ট — যেমন kill -HUP <pid> দিয়ে অনেক সার্ভারকে config আবার পড়তে বলা হয়। Signal কীভাবে পাঠানো, ধরা আর সামলানো হয়, তা পরের পর্বে (পর্ব ১৩)।

৮ · Windows, macOS আর উঁচু স্তর — তারপর সব এক টেবিলে

Windows-এ একই কাজ

কাজLinux / POSIXWindows (Win32)
Anonymous pipepipe()CreatePipe(); child-এ দিতে inheritable handle
নামওয়ালা pipemkfifo() — একমুখী, একই মেশিনCreateNamedPipe("\\.\pipe\…") — দুই দিক, বহু সংযোগ, নেটওয়ার্কেও চলে
Shared memoryshm_open + mmapCreateFileMapping + MapViewOfFile (kernel-এ “section” object)
SocketBSD socket, AF_UNIXWinsock; Windows 10-এর ২০১৮ সালের দিকের আপডেট থেকে AF_UNIX-ও
সমন্বয়ের objectPOSIX semaphore, futexনামওয়ালা mutex, semaphore, event
Kernel-এর ভেতরের দ্রুত বার্তা— (Binder মূলত Android-এ)ALPC (Advanced Local Procedure Call)

ALPC নিয়ে একটু সাবধানে বলা দরকার। এটি Windows NT kernel-এর একটি দ্রুত message passing ব্যবস্থা, যা Windows Vista-র সময় পুরোনো LPC-র জায়গা নেয়। একই মেশিনের ভেতরের RPC আর অনেক system service এর ওপর চলে। তবে Microsoft এটি অ্যাপের সরাসরি ব্যবহারের জন্য প্রকাশ্যে ডকুমেন্ট করেনি; অ্যাপ সাধারণত RPC বা COM-এর মাধ্যমে পরোক্ষে এটি ব্যবহার করে।

macOS আর Android

macOS-এর XNU kernel Mach microkernel-এর ঐতিহ্য বহন করে, তাই এর মূল IPC হলো Mach port-এ Mach message পাঠানো — একটি indirect, port-ভিত্তিক message passing. এর ওপর Apple-এর XPC চলে; পাশাপাশি BSD-অংশের pipe আর socket-ও আছে। Android Linux kernel-এর ওপর চলে, কিন্তু অ্যাপ আর system service-এর মধ্যে প্রায় সব কথা হয় Binder দিয়ে — একটি kernel driver, যা RPC-ধাঁচের কল, পাঠানো প্রসেসের পরিচয় আর অনুমতি-যাচাই একসাথে দেয়। Binder এখন mainline Linux kernel-এর অংশ।

উঁচু স্তর — RPC, gRPC আর D-Bus

সরাসরি pipe বা socket-এ বাইট পাঠানো নিচু স্তরের কাজ: বার্তার ফর্ম্যাট, সংস্করণ, ত্রুটি — সব নিজেকে ঠিক করতে হয়। তাই এর ওপর তৈরি হয়েছে উঁচু স্তর:

  • RPC (remote procedure call): অন্য প্রসেস বা অন্য মেশিনের একটি ফাংশন এমনভাবে ডাকা, যেন নিজের ফাংশন। একটি “stub” argument-গুলোকে বাইটে সাজায় (marshalling), message passing দিয়ে পাঠায়, উত্তর এলে আবার খুলে দেয়। Birrell আর Nelson-এর ১৯৮৪ সালের গবেষণাপত্র এর ক্লাসিক বর্ণনা; Sun-এর ONC RPC-র ওপর NFS চলে (পর্ব ৫৩)।
  • gRPC: Google-এর ২০১৫ সালে উন্মুক্ত করা RPC framework — HTTP/2-এর ওপর Protocol Buffers ফর্ম্যাটে বার্তা। Microservice-এর মধ্যে খুব জনপ্রিয়; একই মেশিনে Unix domain socket-এর ওপরেও চলতে পারে।
  • D-Bus: Linux ডেস্কটপ আর systemd-এর message bus. একটি কেন্দ্রীয় bus daemon (Unix domain socket-এর ওপর) প্রোগ্রামগুলোর মধ্যে বার্তা আর ঘটনা পৌঁছে দেয় — যেমন “USB লাগানো হয়েছে”।

আরও গভীরে: Linux kernel-এ pipe-এর buffer আসলে কয়েকটি page-এর একটি বৃত্তাকার অ্যারে (struct pipe_inode_info-এর pipe_buffer-গুলো), আর ধারণক্ষমতা fcntl(fd, F_SETPIPE_SZ, …) দিয়ে বাড়ানো যায় (সীমা /proc/sys/fs/pipe-max-size). splice আর vmsplice system call ডেটা কপি না করে page-এর মালিকানা pipe-এ সরিয়ে দিতে পারে — একে বলে zero-copy. Microkernel-এ IPC-ই সব কিছুর ভিত্তি, তাই এর গতি নিয়ে গবেষণা বিস্তর: Mach-এর ধীর IPC-র জবাবে Jochen Liedtke ১৯৯৩ সালে L4 kernel দিয়ে দেখান, নকশা ঠিক হলে IPC বহুগুণ দ্রুত করা যায়; আজকের seL4 সেই পরিবারেরই, যার সঠিকতা গাণিতিকভাবে প্রমাণিত। আর shared memory-তে অপেক্ষার জন্য Linux-এর futex এমনভাবে বানানো যে বিবাদ না থাকলে kernel-এ যেতেই হয় না (পর্ব ২৪)।

সব পদ্ধতি এক টেবিলে

পদ্ধতিদিকডেটার রূপগতিআত্মীয় প্রসেস লাগে?অন্য মেশিনে?
Anonymous pipeএকমুখীবাইটের স্রোতদ্রুত (দুবার কপি)হ্যাঁ — fork-সূত্রে fdনা
FIFO (named pipe)একমুখীবাইটের স্রোতpipe-এর সমাননা — ফাইলের নামনা
POSIX / System V message queueযে খুলবে সে-ই পাঠাতে ও নিতে পারেআলাদা বার্তা, priority বা type-সহমাঝারিনা — নাম বা keyনা
Shared memory (shm_open, shmget)দুই দিকযেকোনো গঠন, নিজে সাজাতে হয়সবচেয়ে দ্রুত (কপি নেই)না — নাম বা keyনা
mmap MAP_SHARED|ANONদুই দিকযেকোনো গঠনসবচেয়ে দ্রুতহ্যাঁ — fork-এর আগে mapনা
Memory-mapped fileদুই দিকফাইলের বাইটদ্রুত, সাথে ডিস্কে সংরক্ষণনা — ফাইলের পথনা (নেটওয়ার্ক ফাইল সিস্টেমে ভরসা নেই)
Unix domain socketদুই দিকস্রোত বা datagramদ্রুতনা — পথ (socketpair হলে হ্যাঁ)না
TCP socketদুই দিকবাইটের স্রোতধীরতর (protocol stack)না — IP আর portহ্যাঁ
Signalএকমুখীশুধু একটি সংখ্যাখুব ছোট বার্তানা — PID আর অনুমতিনা
RPC / gRPC / D-Bus / Binderঅনুরোধ-উত্তরগঠিত বার্তানিচের স্তরের ওপর নির্ভরনাgRPC হ্যাঁ; D-Bus, Binder না
ছোট, একমুখী, আত্মীয় → pipe · নামে খোঁজা, একমুখী → FIFO
আলাদা বার্তা আর priority → message queue · বড় ডেটা, সর্বোচ্চ গতি → shared memory + semaphore
দুই দিক, একই মেশিন → Unix socket · অন্য মেশিন → TCP / gRPC

৯ · AI-এর যুগে IPC

“দুই প্রসেসের মধ্যে shared memory দিয়ে ডেটা পাঠানোর C কোড দাও” — AI দ্রুত দেবে, আর আপনার ল্যাপটপে প্রথম কয়েকবার সেটি চলবেও। IPC-র বাগ সাধারণত প্রথম রানে ধরা পড়ে না: race condition আসে লোড বাড়লে, SIGPIPE আসে ক্লায়েন্ট হঠাৎ চলে গেলে। AI-এর সাধারণ ভুলগুলো:

AI-এর ভুলফলকীভাবে ধরবেন
fork-এর পর অব্যবহৃত pipe-মুখ বন্ধ না করাপাঠক EOF পায় না, প্রোগ্রাম চিরকাল আটকেপ্রতিটি fork-এর পর দুই শাখায় close আছে কি না গুনে দেখুন
Shared memory-তে lock বা semaphore ছাড়া counter বাড়ানো“আমার মেশিনে চলে”, সার্ভারে হিসাব কম আসেলক্ষ লক্ষবার বাড়িয়ে, একাধিক core-এ বারবার চালিয়ে যোগফল মেলান
ধরে নেওয়া এক read = এক বার্তা (pipe বা TCP-তে)বড় বা দ্রুত বার্তায় অর্ধেক বা জোড়া লাগা ডেটাদৈর্ঘ্য-চিহ্ন বা newline দিয়ে বার্তা ভাগ হচ্ছে কি না দেখুন
SIGPIPE সামলানো নেইক্লায়েন্ট চলে গেলে পুরো সার্ভার নীরবে মারা যায়পাঠক আগে বন্ধ করে পরীক্ষা করুন; EPIPE সামলানো আছে কি না দেখুন
shm_unlink/mq_unlink ভুলে যাওয়া/dev/shm-এ পুরোনো object জমে, পরের রানে পুরোনো ডেটারানের পর ls /dev/shm আর ipcs দেখুন
অনুমতি 0666 বা socket ফাইলে chmod 777মেশিনের যেকোনো user ডেটা পড়তে বা বদলাতে পারেন্যূনতম অনুমতি: 0600, দরকারে নির্দিষ্ট group
পুরোনো বা ভুল OS-এর তথ্য: “-lrt সবসময় লাগে”, Linux-এ CreatePipe, macOS-এ mq_opencompile বা link-এ ব্যর্থ, বা অন্য প্ল্যাটফর্মে চলে নাপ্রম্পটে OS, distro আর glibc সংস্করণ লিখুন; man 7 pipe, man 7 mq_overview মিলিয়ে নিন

AI-কে IPC কোড লিখতে দিলে প্রেক্ষাপট, শর্ত আর যাচাইয়ের পথ — তিনটিই দিন:

আমার পরিবেশ: Ubuntu 24.04, x86-64, gcc 13, glibc 2.39. একটি C প্রোগ্রাম লিখে দাও যেখানে parent একটি producer, আর দুটি child consumer; parent ১০০০টি সংখ্যা পাঠাবে, consumer-রা যোগফল বের করবে। শর্ত: ১. IPC হবে pipe দিয়ে; প্রতিটি fork-এর পর কোন প্রসেস কোন fd বন্ধ করছে, মন্তব্যে লেখো। ২. read আর write-এর ফেরত মান যাচাই করো; আংশিক write হলে বাকিটা আবার লেখো, read-এর বার্তা-সীমানা ঠিক রাখতে নির্দিষ্ট আকারের রেকর্ড ব্যবহার করো। ৩. SIGPIPE/EPIPE কীভাবে সামলাচ্ছ, ব্যাখ্যা করো। ৪. gcc -Wall -Wextra-এ কোনো warning যেন না আসে। শেষে বলো: একটি child-এ লেখার মুখ বন্ধ করতে ভুলে গেলে ঠিক কী ঘটবে, আর আমি কোন পরীক্ষা চালিয়ে নিশ্চিত হব যে যোগফল সঠিক এবং প্রোগ্রাম আটকে যায় না।

AI-এর লেখা IPC কোড চালানোর আগে — চেকলিস্ট

পরীক্ষা ও ইন্টারভিউতে এই বিষয়ে প্রায়ই যা জিজ্ঞেস করা হয়:

  • IPC-র দুই মডেল আর তাদের তুলনা? — shared memory (দ্রুত, kernel শুধু শুরুতে, সমন্বয় প্রোগ্রামারের) আর message passing (সহজ, প্রতিবার kernel, নেটওয়ার্কে ছড়ানো যায়)।
  • Buffering-এর তিন ধরন? — zero capacity (প্রেরক সবসময় অপেক্ষা করে, rendezvous), bounded (ভর্তি হলে অপেক্ষা), unbounded (কখনো অপেক্ষা নয়, তাত্ত্বিক)।
  • Pipe-এর read কখন 0 ফেরত দেয়? — buffer খালি এবং সব প্রসেসে সব লেখার মুখ বন্ধ হলে; তাই fork-এর পর অব্যবহৃত মুখ বন্ধ করতে হয়।
  • Anonymous pipe আর named pipe-এর পার্থক্য? — anonymous-এর নাম নেই, শুধু আত্মীয় প্রসেস; FIFO ফাইল সিস্টেমে নাম পায়, অসম্পর্কিত প্রসেসও খুলতে পারে, ডেটা ডিস্কে যায় না।
  • Shared memory থাকতে socket কেন? — socket দুই দিকের, সমন্বয় ভেতরেই, অন্য মেশিনেও যায়; shared memory দ্রুত কিন্তু একই মেশিনে আর আলাদা সমন্বয় লাগে।

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

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।

প্র ০১Shared memory সবচেয়ে দ্রুত হলে আধুনিক সিস্টেমগুলো — Chrome, Android, microkernel — কেন বেশিরভাগ ক্ষেত্রে message passing বেছে নেয়?

গতিই একমাত্র মাপকাঠি নয়; ঠিক থাকা আর নিরাপদ থাকা অনেক সময় বেশি দামি। Shared memory-তে দুই প্রসেসের মাঝের দেয়ালে একটি জানালা খোলা হয়। একজন ভুল করে বা ইচ্ছে করে ভুল ডেটা লিখলে অন্যজন সরাসরি তা পড়ে, আর পড়ার মুহূর্তেও ডেটা বদলে যেতে পারে। নিরাপত্তার ভাষায় এর একটি নাম আছে — “time of check to time of use” (TOCTOU) সমস্যা: আপনি একটি দৈর্ঘ্য যাচাই করলেন, তারপর ব্যবহার করার আগেই অপর প্রসেস সেটি বদলে দিল। Message passing-এ বার্তা kernel কপি করে দেয়, তাই প্রাপকের হাতে আসা কপিটি আর কেউ বদলাতে পারে না। Chrome-এর renderer প্রসেস ইন্টারনেটের অবিশ্বস্ত কোড চালায়, তাই Chrome-এর IPC (বর্তমানে Mojo) মূলত বার্তা-ভিত্তিক, আর শুধু বড় ডেটার জন্য আলাদা shared memory অঞ্চল দেওয়া হয়।

দ্বিতীয় কারণ সরলতা। Shared memory-র সাথে সবসময় lock, semaphore বা atomic লাগে, আর concurrency-র বাগ খুঁজে পাওয়া সবচেয়ে কঠিন বাগগুলোর একটি (মডিউল ৪ জুড়ে এটিই দেখবেন)। Message passing-এ “একজন পাঠায়, একজন নেয়” — ক্রম kernel নিশ্চিত করে। তৃতীয় কারণ ছড়ানো: আজ যে দুটি প্রসেস একই মেশিনে, কাল তারা হয়তো দুটি সার্ভারে — message passing-এর কোড সেখানে সহজে সরে।

মজার ব্যাপার, গবেষণাও এই দিকে গেছে। ২০০৯ সালে ETH Zurich আর Microsoft Research-এর Barrelfish প্রকল্প (“multikernel” নকশা) যুক্তি দেয়, বহু-core মেশিনের ভেতরেও cache coherence-এর খরচের কারণে core-গুলোকে একটি ছোট নেটওয়ার্কের মতো দেখে বার্তা পাঠানো ভালো হতে পারে। আর microkernel-এ (Mach, L4, QNX, seL4) driver আর file system আলাদা প্রসেস, তাই IPC-ই পুরো OS-এর মেরুদণ্ড — সেখানে IPC-কে দ্রুত করাই মূল গবেষণা, shared memory এড়ানো নয়। বাস্তব সমাধান তাই প্রায় সবসময় মিশ্র: নিয়ন্ত্রণ-বার্তা message passing-এ, বড় ডেটা shared memory-তে, আর shared অঞ্চলের মালিকানা কখন কার — সেটিও বার্তা দিয়ে জানানো।

মূল উপলব্ধি: Shared memory-র গতি আসে দেয়াল সরিয়ে; message passing-এর নিরাপত্তা আর সরলতা আসে দেয়াল রেখে। ভালো নকশা প্রথমে দেয়াল রাখে, আর শুধু যেখানে মাপা গতির দরকার সেখানে একটি জানালা খোলে।

প্র ০২Unix-এর pipe কোথা থেকে এল, আর “একমুখী বাইটের স্রোত”-এর মতো সরল নকশা কেন এত সফল হলো? এর সীমাবদ্ধতা কী?

Bell Labs-এর Doug McIlroy ১৯৬৪ সালেই একটি মেমোতে লিখেছিলেন, প্রোগ্রামগুলোকে বাগানের পানির পাইপের মতো জোড়া লাগানোর উপায় থাকা উচিত — এক প্রোগ্রামের আউটপুট সরাসরি আরেকটির ইনপুটে। ধারণাটি বাস্তব হয় ১৯৭৩ সালে, যখন Ken Thompson Unix-এ pipe() system call যোগ করেন (Unix-এর তৃতীয় সংস্করণের সময়), আর shell-এ এর জন্য চিহ্ন আসে, পরে যা | হয়ে স্থায়ী হয়। এরপর থেকে Unix-এর দর্শন দাঁড়ায়: প্রতিটি প্রোগ্রাম একটি কাজ ভালোভাবে করবে, লেখা (text) ইনপুট নেবে, লেখা আউটপুট দেবে, আর pipe দিয়ে জোড়া লাগবে।

সাফল্যের কারণ ঠিক সরলতাই। Pipe কোনো ফর্ম্যাট চাপিয়ে দেয় না — শুধু বাইট। তাই যে প্রোগ্রাম কখনো ভাবেনি অন্য কোনো প্রোগ্রামের সাথে জুড়বে, সে-ও জুড়তে পারে: grep-এর লেখক জানতেন না sort বা wc কীভাবে ব্যবহার হবে। দ্বিতীয়ত, pipe সাধারণ file descriptor, তাই প্রোগ্রামকে জানতে হয় না সে ফাইলে না pipe-এ লিখছে। তৃতীয়ত, bounded buffer আর blocking-এর কারণে প্রবাহ-নিয়ন্ত্রণ আপনা থেকেই হয়: yes | head -3-এ yes অসীমভাবে লিখতে চায়, কিন্তু buffer ভর্তি হলে থামে, আর head বন্ধ হলে SIGPIPE পেয়ে মারা যায় — কোনো প্রোগ্রামারকে এজন্য একটি লাইনও লিখতে হয়নি।

সীমাবদ্ধতাও এই সরলতা থেকেই। বাইটের স্রোতে বার্তার সীমানা নেই, ধরন নেই — প্রতিটি প্রোগ্রাম নিজের মতো লেখা ভাঙে (parse করে), আর ফাঁকা জায়গা বা নতুন লাইন-ওয়ালা ফাইলের নামে প্রায়ই ভুল হয়। একমুখী বলে প্রশ্ন-উত্তর চালাতে দুটি pipe লাগে, আর তাতে দুজনেই একে অপরের জন্য অপেক্ষা করে আটকে যাওয়ার ঝুঁকি থাকে। নাম নেই বলে আত্মীয় নয় এমন প্রসেস যুক্ত হতে পারে না — FIFO সেই ফাঁক পূরণ করে। Microsoft-এর PowerShell (২০০৬) ভিন্ন পথ নেয়: এর pipeline-এ বাইট নয়, .NET object যায়, তাই কলামের নাম আর ধরন হারায় না — কিন্তু প্রোগ্রামগুলোকে একই runtime-এ থাকতে হয়।

মূল উপলব্ধি: সবচেয়ে টেকসই interface প্রায়ই সবচেয়ে কম প্রতিশ্রুতি দেয়। Pipe শুধু বলে “বাইট ক্রমমতো পৌঁছাবে, খালি হলে অপেক্ষা, ভর্তি হলে অপেক্ষা” — আর এই অল্প কথাতেই পঞ্চাশ বছর ধরে অগণিত প্রোগ্রাম জোড়া লেগেছে।

প্র ০৩Pipe-এ EOF কেন “সব লেখার মুখ বন্ধ” হওয়ার ওপর নির্ভর করে, “লেখক বলল শেষ” — এমন কোনো বার্তার ওপর নয়? এই নকশার দাম কী?

প্রথমে বুঝুন kernel কী জানে আর কী জানে না। Kernel জানে কোন প্রসেসের fd টেবিলে কোন pipe-মুখ খোলা — এটি তার নিজের হিসাব, ভুল হওয়ার সুযোগ নেই। কিন্তু kernel জানে না লেখক প্রোগ্রামটির “মনে” আর কিছু লেখার আছে কি না। যদি EOF নির্ভর করত লেখকের পাঠানো কোনো বিশেষ “শেষ” বার্তার ওপর, তাহলে লেখক crash করলে, signal-এ মারা গেলে বা বাগের কারণে সেই বার্তা পাঠাতে ভুলে গেলে পাঠক চিরকাল অপেক্ষা করত। Reference count-ভিত্তিক নিয়মে প্রসেস যেভাবেই শেষ হোক, kernel তার সব fd বন্ধ করে দেয়, আর গোনা শূন্যে নামলেই পাঠক EOF পায়। অর্থাৎ নকশাটি ব্যর্থতার মুখেও সঠিক থাকে। একই যুক্তি উল্টো দিকে: সব পাঠক চলে গেলে লেখককে SIGPIPE দিয়ে জানানো হয়, যাতে সে অর্থহীনভাবে লিখে যেতে না থাকে।

এই নকশার দাম হলো “কে কে মুখটি ধরে আছে” — সেই হিসাব প্রোগ্রামারকেও রাখতে হয়। fork সব fd কপি করে, তাই প্রতিটি নতুন child প্রতিটি খোলা লেখার মুখের একটি করে বাড়তি রেফারেন্স পায়। একটি shell যখন a | b | c চালায়, তাকে প্রতিটি child-এ সঠিক মুখগুলো বন্ধ করতে হয়; একটি ভুলেই c কখনো শেষ হয় না। বড় সার্ভারে, যেখানে অনেক thread একসাথে fork আর exec করে, এক thread-এর তৈরি pipe অন্য thread-এর child-এ অনিচ্ছায় চলে যেতে পারে। এই কারণেই Linux 2.6.27-এ pipe2() আর O_CLOEXEC flag যোগ হয় — fd তৈরির মুহূর্তেই “exec হলে বন্ধ” চিহ্ন বসানো যায়, মাঝখানে অন্য thread-এর fork ঢুকে পড়ার সুযোগ থাকে না।

Windows-এ fork নেই, কিন্তু handle inheritance চালু করলে সব “inheritable” handle child পায়। Pipe-এর লেখার handle ভুল করে child-এ গেলে parent-এর ReadFile কখনো ERROR_BROKEN_PIPE পায় না — হুবহু একই বাগ। তাই সেখানে child-এ দেওয়া উচিত নয় এমন handle-কে SetHandleInformation দিয়ে অ-উত্তরাধিকারযোগ্য করা হয়।

মূল উপলব্ধি: EOF-কে kernel-এর নিজের হিসাবের (খোলা মুখের সংখ্যা) সাথে বাঁধা হয়েছে যাতে crash আর বাগের মধ্যেও তা সঠিক থাকে; বিনিময়ে প্রোগ্রামারের দায়িত্ব — প্রতিটি প্রসেস শুধু সেই মুখ খোলা রাখবে, যা সে সত্যিই ব্যবহার করে।

প্র ০৪একই মেশিনে থাকলে Docker, PostgreSQL বা systemd কেন localhost TCP-র বদলে Unix domain socket ব্যবহার করে?

প্রথম কারণ নিরাপত্তা আর অনুমতি। TCP-র 127.0.0.1:5432-এ যুক্ত হতে কোনো ফাইল-অনুমতি লাগে না — মেশিনের যেকোনো user বা প্রসেস চেষ্টা করতে পারে, আর কেউ ভুল করে 0.0.0.0-এ বসালে পুরো নেটওয়ার্ক থেকে খোলা হয়ে যায়। Unix domain socket-এর ঠিকানা একটি ফাইলের পথ, তাই সাধারণ ফাইল-অনুমতিই (পর্ব ৪৭) দারোয়ানের কাজ করে। Docker-এর /run/docker.sock সাধারণত root আর docker group-এর জন্য খোলা থাকে। এখানে একটি সতর্কতা: এই socket-এ লেখার অনুমতি কার্যত root-এর সমান ক্ষমতা দেয়, কারণ Docker যেকোনো container-কে পুরো host-এর ফাইল সিস্টেম দিতে পারে। তাই কাউকে docker group-এ যোগ করা বা socket-এ chmod 666 দেওয়া হালকা সিদ্ধান্ত নয়।

দ্বিতীয় কারণ পরিচয়। Linux-এ SO_PEERCRED দিয়ে সার্ভার জানতে পারে অপর প্রান্তে কোন PID, UID আর GID — kernel নিজে এটি জানায়, ক্লায়েন্ট মিথ্যা বলতে পারে না। PostgreSQL-এর “peer” authentication এটি ব্যবহার করে: Linux user-এর নাম ডেটাবেসের user-এর নামের সাথে মিললে পাসওয়ার্ড ছাড়াই ঢুকতে দেয়। systemd আর D-Bus-ও একইভাবে কে কী চাইছে তা যাচাই করে। TCP-তে এমন নির্ভরযোগ্য পরিচয় নেই।

তৃতীয় কারণ গতি আর ক্ষমতা। Unix socket IP header, checksum, routing বা TCP-র congestion নিয়ন্ত্রণের মধ্য দিয়ে যায় না — kernel সরাসরি এক socket-এর buffer থেকে আরেকটিতে ডেটা দেয়, তাই ছোট অনুরোধে সাধারণত localhost TCP-র চেয়ে কম সময় লাগে (কতটা, তা মেশিন আর কাজ অনুযায়ী মেপে দেখতে হয়)। আর একটি অনন্য ক্ষমতা: SCM_RIGHTS দিয়ে একটি খোলা file descriptor অন্য প্রসেসে পাঠানো যায়। systemd-এর socket activation এর কাছাকাছি একটি ধারণা ব্যবহার করে — systemd নিজে socket খুলে রাখে আর সার্ভিস চালু করার সময় সেই fd তাকে দেয়, যাতে সার্ভিস restart হলেও সংযোগ হারায় না।

Windows-এ দীর্ঘদিন এই কাজে named pipe ব্যবহার হয়েছে; AF_UNIX এসেছে Windows 10-এর ২০১৮ সালের দিকের আপডেটে।

মূল উপলব্ধি: একই মেশিনে Unix domain socket দেয় ফাইল-অনুমতির দারোয়ান, kernel-যাচাই করা পরিচয়, কম খরচ আর fd পাঠানোর ক্ষমতা; TCP দরকার শুধু তখন, যখন সত্যিই অন্য মেশিনে যেতে হবে।

প্র ০৫RPC অন্য প্রসেস বা অন্য মেশিনের ফাংশনকে নিজের ফাংশনের মতো দেখায়। এই “স্বচ্ছতা” কি পুরোপুরি সম্ভব, নাকি বিপজ্জনক ভ্রম?

RPC-র লক্ষ্য মহৎ: প্রোগ্রামার লিখবেন balance = get_balance(account), আর ভেতরে stub argument সাজিয়ে (marshalling) বার্তা পাঠাবে, অন্য প্রান্তে আসল ফাংশন চলবে, উত্তর ফিরবে। ১৯৮৪ সালে Xerox PARC-এর Andrew Birrell আর Bruce Nelson-এর বিখ্যাত গবেষণাপত্র দেখায়, এভাবে distributed প্রোগ্রাম লেখা অনেক সহজ হয়। Sun-এর ONC RPC, DCE RPC, CORBA, Java RMI, আর আজকের gRPC ও Binder — সবাই এর উত্তরসূরি।

কিন্তু ১৯৯৪ সালে Sun-এর Jim Waldo ও সহকর্মীদের “A Note on Distributed Computing” প্রবন্ধ সতর্ক করে: স্থানীয় আর দূরের কলকে পুরোপুরি একই রকম দেখানো ভুল, কারণ তাদের পার্থক্য মৌলিক। প্রথমত, দেরি (latency): একটি স্থানীয় ফাংশন কল ন্যানোসেকেন্ডে, একই মেশিনের অন্য প্রসেসে মাইক্রোসেকেন্ডে, আর অন্য শহরের সার্ভারে মিলিসেকেন্ডে — লুপের ভেতরে হাজারবার ডাকলে পার্থক্য হাজার গুণ। দ্বিতীয়ত, আংশিক ব্যর্থতা: স্থানীয় কল হয় ফেরে, নয়তো পুরো প্রোগ্রাম মরে। দূরের কলে উত্তর না এলে আপনি জানেন না — অনুরোধ পৌঁছায়নি, নাকি কাজ হয়েছে কিন্তু উত্তর হারিয়েছে? bKash-এর মতো টাকা পাঠানোর সেবায় এই প্রশ্নের দাম অনেক: আবার চেষ্টা করলে কি টাকা দুবার কাটবে? তাই বাস্তব RPC-তে লাগে timeout, retry আর idempotency (একই অনুরোধ দুবার এলেও ফল একবারের মতো)। তৃতীয়ত, দূরের প্রসেসে pointer পাঠানো যায় না, পুরো ডেটা কপি করতে হয়।

আধুনিক নকশা তাই স্বচ্ছতা কিছুটা ছেড়ে দিয়েছে। gRPC-তে প্রতিটি কলে deadline দেওয়া যায় আর ত্রুটির নির্দিষ্ট status code আছে; অনেক API async — ফল আসে পরে। D-Bus আর Binder একই মেশিনে থাকায় নেটওয়ার্কের সমস্যা কম, কিন্তু অপর প্রসেস crash করতে পারে, তাই Binder “death notification” দেয়। পরীক্ষায় RPC এলে stub, marshalling, binding (সার্ভার খুঁজে পাওয়া) আর “at most once” বনাম “at least once” semantics মনে রাখবেন — বিস্তারিত পর্ব ৫৩-এ।

মূল উপলব্ধি: RPC message passing-কে ফাংশন কলের পোশাক পরায় — লেখা সহজ হয়, কিন্তু দেরি, আংশিক ব্যর্থতা আর কপির বাস্তবতা পোশাকের নিচে থেকেই যায়; ভালো নকশা সেগুলো লুকায় না, স্পষ্ট করে।

অনুশীলন

হাতে হিসাব, ধাপে ধাপে ট্রেস, আউটপুট অনুমান আর কোড লেখা — আগে নিজে চেষ্টা করুন, তারপর উত্তর মেলান।

  1. হাতে হিসাব: Linux-এ একটি pipe-এর ধারণক্ষমতা ৬৫,৫৩৬ বাইট, PIPE_BUF ৪০৯৬ বাইট। পাঠক কিছুই পড়ছে না। লেখক blocking মোডে প্রতিবার ১০,২৪০ বাইট (১০ KiB) করে লেখে। (ক) কতগুলো write পুরোপুরি শেষ হবে? (খ) পরের write-এ কী ঘটবে? (গ) লেখক যদি প্রতিবার ২০০০ বাইট লিখত, কতগুলো write পুরো শেষ হতো?

    (ক) ৬৫,৫৩৬ ÷ ১০,২৪০ = ৬.৪, তাই ৬টি write পুরো শেষ হয়: ৬ × ১০,২৪০ = ৬১,৪৪০ বাইট। বাকি জায়গা ৬৫,৫৩৬ − ৬১,৪৪০ = ৪০৯৬ বাইট.

    (খ) সপ্তম write ১০,২৪০ বাইট চায়, জায়গা আছে ৪০৯৬. লেখাটি PIPE_BUF-এর চেয়ে বড়, তাই অবিভাজ্য হওয়ার নিশ্চয়তা নেই: Linux যতটুকু জায়গা আছে (৪০৯৬) লিখে ফেলে, তারপর বাকি ৬১৪৪ বাইটের জন্য লেখক ঘুমায়। Blocking মোডে call তখনই ফেরে, যখন পাঠক জায়গা খালি করে পুরো ১০,২৪০ বাইট লেখা শেষ হয়। (Non-blocking হলে আংশিক সংখ্যা, এখানে ৪০৯৬, ফেরত দিত।)

    (গ) ২০০০ ≤ ৪০৯৬, তাই প্রতিটি write অবিভাজ্য — হয় পুরো ২০০০ বাইট যাবে, নয়তো কিছুই না। ⌊৬৫,৫৩৬ ÷ ২০০০⌋ = ৩২টি write শেষ হয় (৬৪,০০০ বাইট); বাকি ১৫৩৬ বাইটে ২০০০ আঁটে না, তাই ৩৩তম write কিছু না লিখেই অপেক্ষা করে। (Linux-এ buffer আসলে ৪০৯৬ বাইটের ১৬টি page; প্রতিটি page-এ দুটি ২০০০ বাইটের write আঁটে, তাই সেখানেও ১৬ × ২ = ৩২ — একই উত্তর। তবু নিশ্চিত হতে নিজের মেশিনে মেপে দেখা ভালো অভ্যাস।)

  2. ধাপে ধাপে ট্রেস: একটি mailbox-এর ধারণক্ষমতা ২টি বার্তা; send আর receive দুটোই blocking. প্রেরক S আর প্রাপক R-এর কাজ এই ক্রমে শুরু হয়: S: send(a), S: send(b), S: send(c), R: receive, R: receive, R: receive, R: receive. প্রতিটি ধাপের পর mailbox-এ কী আছে, কে আটকে আছে, আর R কোন ক্রমে কী পায়? ধারণক্ষমতা ০ (zero capacity) হলে প্রথম কাজেই কী ঘটত?
    1. send(a) → [a] (১/২), S চলতে থাকে।
    2. send(b) → [a, b] (২/২), ভর্তি।
    3. send(c) → জায়গা নেই, S আটকে যায়; mailbox [a, b].
    4. R: receive → পায় a; একটি ঘর খালি হয়, S জেগে c রাখে → [b, c].
    5. R: receive → পায় b → [c].
    6. R: receive → পায় c → [] খালি।
    7. R: receive → খালি, R আটকে যায় — S আর কিছু না পাঠালে চিরকাল।

    R পায় a, b, c — পাঠানোর ক্রমেই (FIFO). Zero capacity হলে প্রথম send(a)-তেই S আটকে যেত, কারণ পথে কিছু জমা রাখার জায়গা নেই; R-এর প্রথম receive-এর মুহূর্তে দুজনের rendezvous হতো, তারপর S এগোত।

  3. আউটপুট অনুমান: নিচের প্রোগ্রাম চালালে কী ছাপবে, আর প্রোগ্রাম কি শেষ হবে? না হলে ঠিক কোথায় আটকাবে, আর এক লাইনের সংশোধন কী?
    int fd[2]; pipe(fd);
    if (fork() == 0) {                    /* child */
        char c; int n = 0;
        while (read(fd[0], &c, 1) > 0) n++;
        printf("child counted %d bytes\n", n);
        exit(0);
    }
    close(fd[0]);
    write(fd[1], "abcde", 5);
    close(fd[1]);
    wait(NULL);
    printf("parent done\n");

    কিছুই ছাপবে না, আর প্রোগ্রাম চিরকাল আটকে থাকবে। Child ৫ বাইট পড়ে n = ৫ করে, তারপর আবার read ডাকে। Parent নিজের লেখার মুখ বন্ধ করেছে, কিন্তু child fork-সূত্রে পাওয়া fd[1] কখনো বন্ধ করেনি — তাই একটি লেখার মুখ খোলা, আর read EOF না পেয়ে ঘুমায়। Child-এর printf-এ কখনো পৌঁছানো হয় না; parent wait-এ child-এর জন্য অপেক্ষা করে, তাই “parent done”-ও আসে না।

    সংশোধন: child-এর শাখার প্রথম লাইনে close(fd[1]); যোগ করুন। তখন আউটপুট হবে child counted 5 bytes, তারপর parent done — এই ক্রমেই, কারণ parent child শেষ হওয়ার পরে ছাপে। (ভালো অভ্যাস হিসেবে fork-এর আগে fflush(stdout)-ও রাখুন।)

  4. কোড লিখুন: C-তে pipe, fork, dup2 আর execlp দিয়ে shell-এর ls | wc -l কমান্ডটি তৈরি করুন। কোন প্রসেস কোন fd বন্ধ করবে, তা মন্তব্যে লিখুন।

    একটি সম্ভাব্য সমাধান (ত্রুটি-যাচাই সংক্ষেপে):

    int fd[2];
    if (pipe(fd) == -1) { perror("pipe"); exit(1); }
    
    if (fork() == 0) {                 /* child 1: ls, stdout -> pipe */
        dup2(fd[1], STDOUT_FILENO);
        close(fd[0]); close(fd[1]);    /* originals no longer needed */
        execlp("ls", "ls", (char *)NULL);
        perror("execlp ls"); _exit(127);
    }
    if (fork() == 0) {                 /* child 2: wc -l, stdin <- pipe */
        dup2(fd[0], STDIN_FILENO);
        close(fd[0]); close(fd[1]);    /* MUST close write end, or wc never sees EOF */
        execlp("wc", "wc", "-l", (char *)NULL);
        perror("execlp wc"); _exit(127);
    }
    close(fd[0]); close(fd[1]);        /* parent uses neither end */
    while (wait(NULL) > 0) ;           /* reap both children */

    মূল বিষয়: dup2 pipe-এর মুখকে fd 1 বা fd 0-তে কপি করে, তারপর আসল দুটি fd বন্ধ করতে হয়। child 2 বা parent — যেকোনো একটিতে লেখার মুখ খোলা থাকলে wc কখনো EOF পাবে না আর কমান্ড শেষ হবে না। পর্ব ৩-এর shell-এর ডায়াগ্রামে bash ঠিক এই কাজটিই করছিল।

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

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

ক্রমParent থেকে child-এ pipe দিয়ে ডেটা পাঠিয়ে ঠিকমতো শেষ করার ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. Parent pipe(fd) ডেকে দুটি মুখ পায়
  2. fflush(stdout), তারপর fork() — child দুটি মুখেরই কপি পায়
  3. Child close(fd[1]), parent close(fd[0]) — অব্যবহৃত মুখ বন্ধ
  4. Parent write(fd[1], …) দিয়ে ডেটা লেখে
  5. Parent close(fd[1]) — খোলা লেখার মুখ শূন্যে নামে
  6. Child-এর read বাকি ডেটা দিয়ে শেষে 0 ফেরত দেয় (EOF), আর parent waitpid করে

দুটি প্রসেসের মধ্যে প্রতি সেকেন্ডে কয়েকশো MB ডেটা আদান-প্রদান করতে হবে, দুটিই একই মেশিনে। শুরুর প্রস্তুতির পর কোন IPC সবচেয়ে কম খরচে ডেটা পৌঁছায়?

  • Anonymous pipe
  • Shared memory (shm_open + mmap)
  • Signal
  • Localhost TCP socket

একটি pipe থেকে পড়ার সময় read() কখন 0 ফেরত দেয়?

  • যখনই buffer খালি থাকে
  • যখন যেকোনো একটি প্রসেস তার লেখার মুখ বন্ধ করে
  • যখন buffer খালি এবং সব প্রসেসে সব লেখার মুখ বন্ধ
  • যখন pipe-এর buffer ভর্তি হয়ে যায়

একটি bounded mailbox-এর ধারণক্ষমতা ৪টি বার্তা, আর এটি এখন ভর্তি। একটি প্রসেস blocking send ডাকলে কী ঘটবে?

  • প্রেরক অপেক্ষা করবে, যতক্ষণ না প্রাপক একটি বার্তা নিয়ে জায়গা খালি করে
  • সবচেয়ে পুরোনো বার্তা মুছে নতুনটি রাখা হবে
  • send সঙ্গে সঙ্গে EAGAIN ত্রুটি দিয়ে ফিরবে
  • Kernel mailbox-এর ধারণক্ষমতা বাড়িয়ে ৫ করবে

দুটি প্রসেস দুটি আলাদা শহরের সার্ভারে চলছে। নিচের কোন IPC ব্যবস্থা দিয়ে সরাসরি তারা যোগাযোগ করতে পারবে?

  • Named pipe (FIFO)
  • Unix domain socket
  • POSIX shared memory
  • TCP socket

fork-এর আগে int *s = mmap(…, MAP_SHARED | MAP_ANONYMOUS, -1, 0); আর একটি সাধারণ ভ্যারিয়েবল int x = 0; নেওয়া হলো, *s = 0. Child *s = 5; x = 5; করে শেষ হলো। Parent waitpid-এর পর কী পড়বে?

  • *s = 5, x = 5
  • *s = 5, x = 0
  • *s = 0, x = 0
  • *s = 0, x = 5

Linux-এ ls -l-এর আউটপুটে একটি ফাইলের লাইন prw-r--r-- দিয়ে শুরু হলে ফাইলটি কী?

  • একটি সাধারণ ফাইল, যার মালিক পড়তে-লিখতে পারে
  • একটি Unix domain socket
  • একটি named pipe (FIFO)
  • একটি directory

শব্দকোষ

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

মনে রাখুন

  1. প্রসেস আলাদা address space-এ থাকে; cooperating প্রসেস তথ্য ভাগাভাগি, গতি, মডিউলারিটি আর সুরক্ষার জন্য IPC ব্যবহার করে।
  2. Shared memory = সবচেয়ে দ্রুত, kernel শুধু শুরুতে, সমন্বয় আপনার; message passing = প্রতিবার kernel, সহজ, নিরাপদ, নেটওয়ার্কে ছড়ায়।
  3. Message passing-এর নকশা: direct/indirect (mailbox), blocking/non-blocking, আর buffer zero / bounded / unbounded.
  4. Pipe একমুখী বাইটের স্রোত; EOF আসে সব লেখার মুখ বন্ধ হলে — fork-এর পর অব্যবহৃত মুখ বন্ধ করুন। FIFO-র নাম আছে, ডেটা ডিস্কে যায় না।
  5. নামে খোঁজা: shm_open, mq_open, Unix socket; অন্য মেশিন: TCP. Windows-এ named pipe, file mapping, ALPC; উঁচু স্তরে RPC, gRPC, D-Bus, Binder.

পরের পর্বে: এই পর্বের সবচেয়ে ছোট বার্তাটিকে কাছ থেকে দেখা — signal. Ctrl+C চাপলে ঠিক কী ঘটে, প্রসেস কীভাবে signal ধরে বা উপেক্ষা করে, আর signal handler-এ কেন খুব সাবধানে কাজ করতে হয়।

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

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