Foundationপ্রথম নীতি থেকে
LEVEL 4লেসন ২৩/২৯অ্যাডভান্সড১ ঘণ্টা ২০ মিনিট

Shared Memory এবং Message Queue — কপি বাদ দিয়ে সবচেয়ে দ্রুত IPC, আর সেই গতির লুকানো মূল্য

Shared Memory and Message Queues

Pipe-এ প্রতিটা বাইট দুইবার kernel অতিক্রম করে। Shared memory এই কপি সম্পূর্ণ বাদ দেয় -- দুইটা process-এর page table-এর একটা entry একই physical frame নির্দেশ করে, তাই read/write মানে শুধু normal load/store, কোনো syscall না। এই লেসনে memfd_create + mmap (আধুনিক), POSIX shm_open (পোর্টেবল), আর System V shmget (legacy) তিনটাই দেখব, একটা কাজ-করা shared ring buffer বানাব -- আর সততার সাথে স্বীকার করব যে multi-writer race condition-টা এখনো খোলা, কারণ সমাধানের যন্ত্রপাতি (mutex, condition variable) এখনো হাতে নেই। তারপর message queue দেখব -- kernel-মধ্যস্থতা রেখে দেওয়া একটা মধ্যপন্থা, যা synchronization আর message boundary দুটোই বিনামূল্যে দেয়।

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

  • shared memory কেন সবচেয়ে দ্রুত IPC মেকানিজম তা কপি-গণনা দিয়ে ডেরাইভ করতে পারবেন -- pipe-এর প্রতিটা বাইট কেন দুইবার কপি হয় (user→kernel, kernel→user) আর shared memory-তে সেটআপের পরে কেন কোনো কপিই লাগে না তা syscall-এর ব্যয়ের সাথে যুক্ত করে ব্যাখ্যা করতে পারবেন
  • `mmap()` ঠিক কীভাবে একই physical frame দুইটা আলাদা process-এর ভিন্ন page table-এ বসিয়ে দেয় তা বর্ণনা করতে পারবেন, আর `MAP_SHARED` বনাম `MAP_PRIVATE`-এর পার্থক্য প্রয়োগ করে বলতে পারবেন কোনটা কখন লাগবে
  • `memfd_create` + `mmap`-এর আধুনিক পদ্ধতি বনাম POSIX `shm_open` + `mmap` বনাম System V `shmget`/`shmat`-এর পুরনো পদ্ধতি -- তিনটার নামকরণ, discovery, আর জীবনচক্র পার্থক্য তুলনা করতে পারবেন, আর বলতে পারবেন কেন নতুন কোডে memfd/POSIX পছন্দ করা হয়
  • একটা কার্যকরী shared ring buffer (read/write pointer, wraparound সহ) নিজে হাতে লিখতে পারবেন, আর প্রমাণ করতে পারবেন কেন একাধিক লেখক write pointer একসাথে আপডেট করলে ডেটা হারিয়ে যায় -- আর কেন এই মুহূর্তে এই গ্যাপটা ইচ্ছাকৃতভাবে বন্ধ করা হচ্ছে না তা যুক্তি দিয়ে বলতে পারবেন
  • POSIX message queue (`mq_open`/`mq_send`/`mq_receive`) কীভাবে kernel-নিয়ন্ত্রিত synchronization আর message boundary একসাথে বিনামূল্যে দেয় তা ব্যাখ্যা করতে পারবেন -- pipe-এর undifferentiated byte stream আর raw shared memory-র DIY synchronization-এর সাথে তুলনা করে
  • Pipe/FIFO, shared memory, আর message queue -- এই তিনটা IPC মেকানিজমের মধ্যে throughput, synchronization, boundary preservation, আর ব্যবহারের ক্ষেত্র বিচার করে একটা প্রদত্ত পরিস্থিতির জন্য সঠিকটা বেছে নিতে পারবেন

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

আগে এটা বুঝি

গত লেসনের শেষে একটা হিসাব দেওয়া হয়েছিল — pipe দিয়ে ডেটা পাঠাতে প্রতিটা বাইট আসলে দুইবার কপি হয়: writer-এর buffer থেকে kernel-এর pipe buffer-এ (copy_from_user), তারপর kernel-এর pipe buffer থেকে reader-এর buffer-এ (copy_to_user)। প্রতিটা read()/write() কল নিজেই একটা syscall — user mode থেকে kernel mode-এ একটা mode switch, যার খরচ kernel-and-user-space লেসনে সরাসরি বেঞ্চমার্ক করে মাপা হয়েছিল: প্রায় ৪০০ ন্যানোসেকেন্ড প্রতি ক্রসিং, শুধু mode switch-এর জন্য, আসল কাজ বাদেই।

বড় ডেটার জন্য এই খরচ যোগ হতেই থাকে। কয়েক শ মেগাবাইট ৬৪ KB pipe দিয়ে পাঠাতে হাজার হাজার syscall লাগে, প্রতিটাতে দুইবার কপি। প্রশ্নটা স্বাভাবিক — কপি কি এড়ানো যায় না? দুইটা process কি সরাসরি একই মেমরি “দেখতে” পারে না, kernel-কে প্রতিবার মাঝে না বসিয়ে?

উত্তর হ্যাঁ, আর মেকানিজমটার নাম ঠিক ততটাই আক্ষরিক যতটা শোনাচ্ছে — shared memory। দুইটা সম্পূর্ণ ভিন্ন process, ভিন্ন virtual address space, কিন্তু তাদের page table-এর একটা entry একই physical frame-কে নির্দেশ করে (virtual memory লেসনের সেই MMU-ভিত্তিক ঠিকানা-অনুবাদ — এখানে দুইটা process-এর দুইটা আলাদা virtual ঠিকানা শেষমেশ একই physical গন্তব্যে গিয়ে মেলে)। একবার mapping হয়ে গেলে, read/write মানে শুধু একটা সাধারণ CPU load/store instruction — কোনো kernel, কোনো syscall, কোনো কপি নেই।

কিন্তু এই গতি বিনামূল্যে না। Pipe-এ kernel মধ্যস্থতা করত বলেই আপনি বিনামূল্যে দুইটা জিনিস পেতেন — blocking read/write (কেউ লিখলে reader নিজে থেকে জাগে, কাউকে polling করতে হয় না) আর serialized access (দুইজন writer কখনো একই মুহূর্তে একই বাইট লেখে না, কারণ kernel প্রতিটা write() কলকে নিজের ভেতরে সিরিয়ালাইজ করে দেয়)। Shared memory-তে mapping হয়ে যাওয়ার পরে kernel-এর আর কোনো ভূমিকাই থাকে না — তাই এই দুইটা গ্যারান্টিও উধাও। এই লেসনে shared memory বানাব, একটা কার্যকরী ring buffer লিখব, আর সততার সাথে দেখব ঠিক কোথায় এই গ্যারান্টির অভাবটা সমস্যা তৈরি করে — আর কেন সেই সমস্যাটা আজ সমাধান করব না।

তারপর দেখব message queue — একটা মধ্যপন্থা, যেটা kernel-মধ্যস্থতা রেখে দেয় (তাই synchronization বিনামূল্যে) কিন্তু pipe-এর মতো undifferentiated byte stream না, বরং discrete message হিসেবে ডেটা সংরক্ষণ করে, priority-সহ।

মূল ধারণা

Shared memory আসলে কী — একই physical frame, দুইটা ভিন্ন virtual ঠিকানা

Paging লেসনে দেখেছেন প্রতিটা process-এর নিজস্ব page table থাকে, আর mmap() কোনো ফাইলকে virtual address space-এ একটা VMA (virtual memory area) হিসেবে যোগ করে — সঙ্গে সঙ্গে কোনো physical frame বরাদ্দ হয় না, শুধু “এই ঠিকানা-পরিসর বৈধ” একটা রেকর্ড তৈরি হয়। প্রথম স্পর্শে (write বা read) একটা minor page fault হয়, তখনই kernel একটা physical frame বরাদ্দ করে page table entry-তে বসায় — page-faults-and-demand-paging লেসনের সেই একই মেকানিজম।

Shared memory এই মেকানিজমের উপরেই দাঁড়িয়ে, শুধু একটা বাড়তি নিয়ম যোগ করে। mmap() কল করার সময় MAP_SHARED flag দিলে kernel একটা প্রতিশ্রুতি দেয় — “এই VMA-র পেছনের backing object (একটা ফাইল বা memfd)-এর একটা নির্দিষ্ট offset সবসময় একটাই physical frame-এ ম্যাপ হবে, তা যত process-ই সেই offset ম্যাপ করুক না কেন।” ফলে দুইটা process যদি একই memfd/ফাইল MAP_SHARED দিয়ে ম্যাপ করে, তাদের ভিন্ন virtual ঠিকানা (আলাদা প্রসেসে সাধারণত আলাদা সংখ্যা) শেষ পর্যন্ত একই physical frame-এ গিয়ে মেলে।

flagলেখা visible অন্য mapper-দের কাছে?copy-on-write?ব্যবহার
MAP_PRIVATEনাহ্যাঁ — প্রথম লেখাতেই private কপি তৈরি হয়সাধারণ ফাইল-ম্যাপড বাইনারি লোড, heap allocator
MAP_SHAREDহ্যাঁ — সব mapper সাথে সাথে দেখেনাshared memory IPC-র ভিত্তি

তিনটা তৈরির উপায় — memfd_create, POSIX shm_open, System V shmget

memfd_create + mmap — আধুনিক পদ্ধতি

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

int fd = memfd_create("my_shared_region", 0);
ftruncate(fd, 65536);              /* সাইজ ঠিক করা -- নতুন memfd সবসময় ০ বাইট */
void *ptr = mmap(NULL, 65536, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);

memfd_create (Linux 3.17+, glibc 2.27+) একটা anonymous ফাইল বানায় যেটার filesystem-এ কোনো নাম নেই — FIFO-র ঠিক উল্টো: FIFO-র নাম আছে কিন্তু ডেটা in-memory, memfd-র নামও নেই আর ডেটাও in-memory (tmpfs-ব্যাকড)। শেয়ার করার একমাত্র উপায় হলো fd-টা কাউকে দেওয়া — fork()-এর মাধ্যমে (child স্বয়ংক্রিয়ভাবে fd আর mapping দুটোই পায়) অথবা অসম্পর্কিত process-এর ক্ষেত্রে UNIX domain socket-এর SCM_RIGHTS দিয়ে fd পাস করে (Level 7-এর socket লেসনে বিস্তারিত)।

POSIX shm_open + mmap

#include <sys/mman.h>
#include <fcntl.h>
#include <unistd.h>

int fd = shm_open("/my_shm", O_CREAT | O_RDWR, 0600);
ftruncate(fd, 65536);
void *ptr = mmap(NULL, 65536, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
/* কাজ শেষে: */
shm_unlink("/my_shm");

shm_open অনেকটা FIFO-র মতো — একটা নাম দেয় (/my_shm, POSIX অনুযায়ী /-দিয়ে শুরু, বাস্তবে Linux-এ /dev/shm/my_shm-এ tmpfs ফাইল হিসেবে দেখা যায়) যা দিয়ে সম্পূর্ণ অসম্পর্কিত process-ও এটা খুঁজে পেতে পারে — fork বা fd-passing ছাড়াই। এখানেই FIFO-র সাথে একটা মজার বৈসাদৃশ্য: FIFO-র filesystem entry-র সাইজ সবসময় শূন্য দেখায় (ডেটা কখনো disk-এ যায় না), কিন্তু /dev/shm/my_shm-এর সাইজ ftruncate-এর পরে আসল সাইজ দেখায় (ls -la /dev/shm/) — কারণ এটা প্রকৃতপক্ষে tmpfs-এর একটা ফাইল, RAM-ব্যাকড হলেও একটা সত্যিকারের inode আর data page আছে।

System V shmget + shmat — পুরনো, এখনো legacy কোডে

#include <sys/ipc.h>
#include <sys/shm.h>

key_t key = ftok("/some/existing/file", 42);   /* একটা integer key বানায় */
int shmid = shmget(key, 65536, IPC_CREAT | 0600);
void *ptr = shmat(shmid, NULL, 0);              /* attach -- mmap-এর পূর্বসূরি */
/* ... */
shmdt(ptr);                                      /* detach */
shmctl(shmid, IPC_RMID, NULL);                   /* মুছে ফেলা */

System V IPC (১৯৮৩, AT&T UNIX System V) POSIX shared memory-র চেয়ে পুরনো, আর তিনটা জায়গায় স্পষ্টভাবে ভিন্ন: (১) নামকরণ integer key-ভিত্তিক, filesystem path না — ftok() একটা ফাইল পাথ আর একটা project-id থেকে key বানায়, কিন্তু এই key generation-এ collision সম্ভব (দুইটা ভিন্ন ফাইল একই key দিতে পারে যদি inode number আর project-id মিলে যায়) — POSIX-এর সরাসরি path-ভিত্তিক নামকরণে এই ঝুঁকি নেই; (২) mmap() না, আলাদা shmat()/shmdt() সিস্টেম কল — mmap() System V-র চেয়ে পরে (BSD থেকে) এসেছে, তাই System V IPC তার নিজস্ব API নিয়েই থেকে গেছে; (৩) জীবনচক্র ভিন্ন — একটা System V shared segment explicit IPC_RMID না দেওয়া পর্যন্ত kernel-এ থেকেই যায়, এমনকি সব process মরে গেলেও (একটা ক্লাসিক leak-এর উৎস — ipcs -m দিয়ে “orphaned” segment দেখা এখনো সাধারণ একটা sysadmin অভিজ্ঞতা)।

memfd_createPOSIX shm_openSystem V shmget
filesystem-এ নামনা (anonymous)হ্যাঁ, /dev/shm/nameনা, integer key
mapping APImmap()mmap()shmat() (পুরনো, mmap() না)
অসম্পর্কিত process discoveryfd-passing (SCM_RIGHTS)নাম দিয়ে shm_openkey দিয়ে shmget
সাফাইfd বন্ধ + সব mapping unmap হলে automaticshm_unlink() explicitshmctl(IPC_RMID) explicit, নাহলে leak
যুগআধুনিক (Linux 3.17+)POSIX-স্ট্যান্ডার্ড, পোর্টেবল১৯৮৩, legacy
নতুন কোডে সুপারিশহ্যাঁ, fork-সম্পর্কিত হলেহ্যাঁ, অসম্পর্কিত process হলেনা, শুধু legacy compatibility

Ring buffer — shared memory-তে ডেটা organize করার সবচেয়ে সাধারণ উপায়

mmap() শুধু একটা raw মেমরি ঠিকানা দেয় — “কতটা লেখা হয়েছে”, “কোথা থেকে পড়া শুরু করব” এসব কিছুই ট্র্যাক করে না। Pipe-এ এই কাজটা kernel নিজেই করত (read()-এর রিটার্ন ভ্যালু, internal buffer accounting, EOF সংকেত) — shared memory-তে এসবের কিছুই বিনামূল্যে নেই। প্রোগ্রামারকে নিজে ট্র্যাক করতে হয়।

সবচেয়ে সাধারণ সমাধান — ring buffer (circular buffer): একটা fixed-size মেমরি অঞ্চল, দুইটা index (write_pos, read_pos) যেগুলো buffer-এর শেষে পৌঁছে আবার শুরুতে ফিরে যায় (wraparound)।

capacity = ৮ স্লটের একটা ring buffer, শুরুতে খালি:

index:      0   1   2   3   4   5   6   7
           [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ]
            ↑read_pos = 0        ↑write_pos = 5  (৫টা স্লট পূর্ণ, ০-৪)

producer আরও ৪টা স্লট লিখলে wraparound ঘটে (৫+৪=৯, কিন্তু capacity=৮):

           [I][J][ ][ ][ ][E][F][G][H]     ← স্লট ৮ আর ৯ আসলে ০ আর ১-এ পড়েছে
            ↑                    ↑write_pos = 9 → 9 % 8 = 1

producer শুধু write_pos-এ লেখে, consumer শুধু read_pos-এ লেখে — দুইজনেরই নিজস্ব একটা index, read_pos != write_pos থাকা পর্যন্ত buffer খালি না। এই প্রোটোকলটাই এই লেসনের build সেকশনের ভিত্তি।

Message queue — kernel এখনো মধ্যস্থতা করে, কিন্তু byte stream না

POSIX message queue

#include <mqueue.h>

mqd_t mq = mq_open("/my_queue", O_CREAT | O_RDWR, 0600, NULL);

mq_send(mq, "hello", 5, /* priority = */ 0);

char buf[8192];
unsigned prio;
ssize_t n = mq_receive(mq, buf, sizeof buf, &prio);

mq_close(mq);
mq_unlink("/my_queue");

mqd_t অনেকটা fd-র মতো আচরণ করে (Linux-এ select/poll/epoll দিয়ে পোল করাও যায়), কিন্তু এর পেছনে kernel একটা internal linked structure রাখে discrete message-এর — pipe-এর মতো undifferentiated বাইট-স্রোত না। mq_send/mq_receive সবসময় একটা সম্পূর্ণ message পাঠায় বা গ্রহণ করে, আংশিক না — boundary সংরক্ষিত, বিনামূল্যে, pipes-and-fifos লেসনের Question ৩-এ যে length-prefix framing নিজে বানাতে হয়েছিল সেটা এখানে kernel-ই করে দেয়।

Priority: প্রতিটা message-এর সাথে একটা numeric priority (Linux-এ 0 থেকে MQ_PRIO_MAX - 1, যেখানে MQ_PRIO_MAX = 32768) দেওয়া যায়। বেশি priority-র message আগে deliver হয়; সমান priority-র মধ্যে FIFO ক্রম বজায় থাকে। Blocking সেমান্টিক্স: queue খালি থাকলে mq_receive block করে (pipe-এর empty-buffer read()-এর মতো), queue পূর্ণ থাকলে mq_send block করে (ঠিক pipe-এর backpressure-এর মতো)। ডিফল্ট সীমা — /proc/sys/fs/mqueue/msg_max (queue-প্রতি সর্বোচ্চ message সংখ্যা, ঐতিহাসিকভাবে ডিফল্ট ১০) আর msgsize_max (প্রতি message-এর সর্বোচ্চ সাইজ, ঐতিহাসিকভাবে ডিফল্ট ৮১৯২ বাইট) — distro-ভেদে বদলাতে পারে, mq_open-এর সময় struct mq_attr দিয়ে override-ও করা যায় (root/capability সাপেক্ষে)।

System V message queue — সংক্ষেপে

#include <sys/msg.h>

struct msgbuf { long mtype; char mtext[100]; };

int msgid = msgget(key, IPC_CREAT | 0600);
struct msgbuf m = { .mtype = 1, .mtext = "hello" };
msgsnd(msgid, &m, strlen(m.mtext) + 1, 0);

struct msgbuf recv;
msgrcv(msgid, &recv, sizeof recv.mtext, /* msgtyp = */ 0, 0);

mtype ফিল্ডটাই এখানকার আকর্ষণীয় অংশ — এটা একটা coarse “topic”/selective-receive মেকানিজম। msgrcv-এর msgtyp argument যদি 0 হয়, সবচেয়ে পুরনো message (যেকোনো type) আসে — plain FIFO আচরণ। msgtyp > 0 হলে ঠিক সেই type-এর সবচেয়ে পুরনো message আসে। msgtyp \< 0 হলে |msgtyp|-এর চেয়ে কম-বা-সমান সবচেয়ে ছোট type-এর message আসে — একটা crude priority-এর মতো নির্বাচন। POSIX-এর সরাসরি numeric priority-র চেয়ে কম স্পষ্ট, কিন্তু একই উদ্দেশ্য পূরণ করে।

তিনটা IPC মেকানিজম পাশাপাশি

বৈশিষ্ট্যPipe/FIFOShared MemoryMessage Queue
প্রতি transfer-এ kernel জড়িত?হ্যাঁ — প্রতি read/write syscall + কপিনা — শুধু সেটআপেহ্যাঁ — প্রতি send/receive syscall + কপি
Synchronization বিনামূল্যে?হ্যাঁ (blocking read/write)না — সম্পূর্ণ DIYহ্যাঁ (kernel-managed enqueue/dequeue)
Message boundary সংরক্ষিত?না — undifferentiated byte streamনা — DIY framing দরকারহ্যাঁ — প্রতিটা send/receive একটা সম্পূর্ণ ইউনিট
Priority ordering?নানা (DIY)হ্যাঁ (POSIX: সংখ্যাসূচক priority; SysV: mtype-ভিত্তিক)
Throughput (illustrative, raw কপি-খরচ বাদে)মাঝারি — প্রতি transfer-এ ২ কপি + syscallসর্বোচ্চ — সেটআপের পরে কোনো কপিই নেইমাঝারি — pipe-এর কাছাকাছি, কিন্তু ছোট size limit
অসম্পর্কিত process discover করতে পারে?হ্যাঁ (FIFO নাম)হ্যাঁ (shm_open নাম / SysV key)হ্যাঁ (mq_open নাম / SysV key)
সাধারণ ব্যবহারশেল pipeline, সাধারণ stream IPCবড়, high-throughput ডেটা (video frame, DB buffer cache)discrete কাজ/টাস্ক, priority দরকার হলে

ভেতরে কী ঘটছে

mmap(MAP_SHARED) থেকে zero-copy read/write পর্যন্ত

memfd_create + mmap + fork() -- একই physical frame দুইটা page table-এ কীভাবে বসে
  1. parent: fd = memfd_create(); ftruncate(fd, size); ptr = mmap(..., MAP_SHARED, fd, 0)kernel tmpfs-এ একটা anonymous inode বানায়, ftruncate তার সাইজ ঠিক করে -- এখনো কোনো physical frame বরাদ্দ হয়নি (paging-and-page-tables লেসনের VMA-only নিয়ম)
  2. mmap() রিটার্ন করে ptr, কিন্তু page table entry এখনো নেইশুধু একটা VMA রেকর্ড -- 'এই virtual range বৈধ, এই fd-র সাথে যুক্ত, MAP_SHARED'
  3. fork()child-এর নিজস্ব page table তৈরি হয়, কিন্তু MAP_SHARED VMA কপি হয় ভাগাভাগির নিয়ম-সহ -- child-এর ptr একই virtual address-এ বসে, একই underlying fd/inode-এর দিকে নির্দেশ করে (MAP_PRIVATE হলে copy-on-write হতো, MAP_SHARED-এ হয় না)
  4. parent প্রথমবার ptr-এ লেখে -- page faultphysical frame এখনো নেই, তাই প্রথম স্পর্শেই একটা minor page fault ট্রিগার হয় (page-faults-and-demand-paging লেসনের ঠিক সেই মেকানিজম) -- kernel একটা physical frame বরাদ্দ করে parent-এর page table entry-তে বসায়
  5. child একই ঠিকানায় প্রথমবার পড়ে -- আরেকটা page fault, কিন্তু ভিন্ন ফলাফলchild-এর নিজের page table entry-ও খালি ছিল, কিন্তু kernel নতুন frame না বানিয়ে সেই একই physical frame-টাই child-এর entry-তে বসিয়ে দেয় -- কারণ backing object (memfd) এক, আর MAP_SHARED মানে 'এক inode-এর একটা offset সবসময় একটাই physical frame-এ ম্যাপ হবে, যত process-ই ম্যাপ করুক না কেন'
  6. এখন থেকে: parent আর child-এর page table আলাদা, কিন্তু একটা entry একই physical frame নির্দেশ করেMMU প্রতিটা process-এ আলাদাভাবে ঠিকানা অনুবাদ করে (isolation বজায় থাকে -- একজন অন্যের বাকি address space দেখে না), কিন্তু এই একটা page-এ দুইজনই একই RAM cell-এ পড়ে/লেখে
  7. পরবর্তী প্রতিটা read/write -- শুধু normal load/store instructionকোনো syscall না, কোনো mode switch না (kernel-and-user-space লেসনের সেই ~৪০০ ns খরচ এখানে শূন্য) -- CPU সরাসরি সেই physical frame-এ পড়ে/লেখে, MMU translation আসে TLB থেকে (tlb লেসন), যেটা প্রতি-process আলাদা কিন্তু উভয়েই একই frame number resolve করে

লক্ষণীয় দুইটা জিনিস। প্রথমত, “shared” হওয়ার জন্য কোনো বিশেষ hardware লাগে না — শুধু দুইটা ভিন্ন page table entry একই physical frame number-এ পয়েন্ট করছে, এটাই পুরো কৌশল। দ্বিতীয়ত, TLB (translation lookaside buffer) প্রতি-process আলাদা থাকে — parent আর child যদিও একই physical frame অ্যাক্সেস করছে, তাদের TLB-তে সেই ঠিকানার জন্য আলাদা entry বসে, আলাদা page walk-এর মাধ্যমে (কোনো TLB entry cross-process share হয় না, isolation-এর জন্য এটা জরুরি)।

Busy-wait — কেন এখন এটাই একমাত্র উপায়, আর কেন এটা খারাপ

Shared memory-তে reader “নতুন ডেটা এসেছে কি না” জানার কোনো kernel-প্রদত্ত উপায় নেই — pipe-এর মতো কোনো blocking read() নেই যেটা kernel নিজে থেকে জাগাবে। একমাত্র উপায় হলো বারবার চেক করা — একটা লুপে while (কিছু_নেই) { }। এটাকে বলে busy-wait বা spin

সমস্যাটা concrete: একটা CPU core সম্পূর্ণভাবে এই লুপে ব্যস্ত থাকে — schedule হয়ে থাকে, প্রতিটা instruction চালায়, কিন্তু কোনো দরকারি কাজ করে না, শুধু একটা মেমরি লোকেশন বারবার পড়ে। top-এ এই process-কে দেখাবে ১০০% CPU ব্যবহার করছে, এমনকি যখন কোনো ডেটাই আসছে না। তুলনা করুন pipe-এর blocking read()-এর সাথে (আগের লেসনের hood সেকশন) — সেখানে writer কিছু না লিখলে reader সম্পূর্ণ ঘুমিয়ে থাকত, zero CPU, kernel-এর wait queue-তে বসে। Shared memory-তে এই বিলাসিতা নেই, কারণ কোনো kernel মধ্যস্থতা নেই জাগানোর জন্য।

এই ঘাটতিটা এই লেসনের experiment আর build সেকশনে সরাসরি দেখা যাবে — আর এটাই একটা কারণ কেন পরের লেসনগুলো (signal, তারপর synchronization primitives) গুরুত্বপূর্ণ: তারা এই busy-wait-কে একটা প্রকৃত ঘুম-এ (blocking wait, zero CPU) রূপান্তর করার হাতিয়ার দেবে।

উদাহরণ

সংখ্যায় দেখা — কপি আর syscall-এর হিসাব

ধরা যাক দুইটা প্রসেস মিলিয়ে মোট ১০০ MB ডেটা পাঠাতে হবে, একটা ৬৪ KB চাংকে চাংকে (pipe-এর ডিফল্ট ক্ষমতা, আগের লেসন থেকে)।

Pipe (৬৪ KB বাফার)Shared memory (৬৪ KB ring buffer)
প্রথম সেটআপ syscallpipe() — ১টাmemfd_create + ftruncate + mmap — ৩টা
১০০ MB পাঠাতে syscall সংখ্যা~৩২০০+ (প্রতি ৬৪ KB চাংকে একটা write() আর একটা read())০ — সেটআপের পরে কোনো syscall লাগে না
প্রতি বাইটে kernel কপি২ বার (copy_from_user + copy_to_user)০ বার — একই physical frame, কেউ কপি করে না
মোট copy হওয়া ডেটা~২০০ MB (kernel বাফারে গিয়ে আবার বেরিয়ে)~৬৪ KB (শুধু প্রথমবার buffer-এর page-গুলো touch করার সময়, তারপর কিছুই কপি হয় না)
syscall mode-switch overhead (illustrative, ~৪০০ ns/switch ধরে)~৩২০০ × ৪০০ ns ≈ ১.৩ ms (শুধু ক্রসিং-এর খরচ, কপির সময় বাদে)সেটআপে হাতে গোনা কয়েকটা switch, তারপর ০

সংখ্যাগুলো illustrative, order-of-magnitude — প্রকৃত benchmark হার্ডওয়্যার, kernel সংস্করণ, আর buffer সাইজের উপর নির্ভর করবে। কিন্তু গুণগত সিদ্ধান্তটা দৃঢ়: pipe-এর খরচ ডেটার পরিমাণের সাথে সমানুপাতিকভাবে বাড়ে (প্রতিটা নতুন বাইটে নতুন syscall/কপি), shared memory-র সেটআপ-খরচ একবার দিতে হয়, তারপর যত বাইটই বয়ে যাক না কেন খরচ প্রায় স্থির — এটাই সেই কারণ যা এই লেসনের মূল দাবি প্রমাণ করে: shared memory সবচেয়ে দ্রুত IPC, কারণ এটা copy-খরচকে ডেটার আকার থেকে সম্পূর্ণ আলাদা করে দেয়।

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

EXPERIMENT

memfd_create + mmap -- সেটআপের পরে সত্যিই কোনো read()/write() syscall লাগে না

Linux· ১৫ মিনিট
/* shm_demo.c -- parent শেয়ার্ড মেমরিতে একটা বার্তা লেখে,
 * child সেটা busy-wait করে পড়ে
 */
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/mman.h>
#include <sys/wait.h>

int main(void) {
    int fd = memfd_create("shm_demo", 0);
    ftruncate(fd, 4096);

    char *shm = mmap(NULL, 4096, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);

    pid_t pid = fork();
    if (pid == 0) {
        /* child -- parent-এর লেখা অপেক্ষা করে পড়ে, কোনো syscall ছাড়াই */
        while (shm[0] == 0) { }          /* busy-wait -- ইচ্ছাকৃতভাবে সরল */
        printf("child পড়ল: %s\n", shm + 1);
        _exit(0);
    } else {
        strcpy(shm + 1, "hello via shared memory");
        shm[0] = 1;                       /* সিগনাল: ডেটা রেডি */
        wait(NULL);
    }
    return 0;
}

কম্পাইল আর সাধারণভাবে চালিয়ে দেখুন:

gcc -O0 shm_demo.c -o shm_demo
./shm_demo
child পড়ল: hello via shared memory

এবার strace দিয়ে চালিয়ে দেখুন ঠিক কোন syscall-গুলো হয়েছে:

strace -f -e trace=memfd_create,ftruncate,mmap,clone,read,write ./shm_demo
memfd_create("shm_demo", 0)            = 3
ftruncate(3, 4096)                     = 0
mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_SHARED, 3, 0) = 0x7f...
clone(...)                             = <child_pid>
[pid <child_pid>] ... (কোনো read()/write() নেই এখানে)
write(1, "child \340\246\252\340...\n", 34) = 34
+++ exited with 0 +++

memfd_create, ftruncate, mmap, clone (fork-এর underlying syscall) — এই চারটা সেটআপে হয়েছে। এরপর child তার বার্তা পড়ার জন্য busy-wait লুপে সময় কাটায় — এই সময়ে কোনো syscall হয় না, শুধু CPU instruction চলতে থাকে, মেমরি বারবার পড়া হয়। শেষে যে একটা write() দেখা যাচ্ছে সেটা printf()-এর stdout-এ (fd 1) ছাপানোর জন্য — parent থেকে child-এ শেয়ার্ড মেমরির মাধ্যমে ডেটা পৌঁছানোর জন্য নয়। পুরো “hello via shared memory” বার্তাটা parent থেকে child-এ পৌঁছেছে বিনা কোনো read()/write() syscall-এ — এটাই zero-copy IPC-র প্রত্যক্ষ প্রমাণ।

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

দুইটা প্রসেসের মধ্যে ডেটা আদান-প্রদান হচ্ছে, অথচ strace-এ কোনো read()/write() syscall দেখা যাচ্ছে না -- concept সেকশনের 'zero-copy, zero-syscall' দাবির সরাসরি প্রমাণ।

EXPERIMENT

Multi-writer race -- write_pos একসাথে দুইজন আপডেট করলে ডেটা হারিয়ে যায়

Linux· ২০ মিনিট
/* race_demo.c -- দুইজন producer একই write_pos আপডেট করছে,
 * কোনো lock/atomic ছাড়া -- ইচ্ছাকৃতভাবে ভুল কোড, race দেখানোর জন্য
 */
#define _GNU_SOURCE
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/mman.h>
#include <sys/wait.h>

#define CAPACITY 4096
#define N_PER_PRODUCER 200

typedef struct {
    volatile size_t write_pos;
    char slot_owner[CAPACITY];   /* প্রতিটা স্লটে কোন producer লিখেছে তার tag */
} shared_t;

static void produce(shared_t *s, char tag, int count) {
    for (int i = 0; i < count; i++) {
        size_t pos = s->write_pos;               /* ধাপ ১: read */
        usleep(200);                              /* ইচ্ছাকৃত বিলম্ব -- race window বড় করার জন্য */
        s->slot_owner[pos % CAPACITY] = tag;      /* ধাপ ২: payload লেখা */
        s->write_pos = pos + 1;                   /* ধাপ ৩: write back -- non-atomic RMW */
    }
}

int main(void) {
    int fd = memfd_create("race_demo", 0);
    ftruncate(fd, sizeof(shared_t));
    shared_t *s = mmap(NULL, sizeof(shared_t), PROT_READ | PROT_WRITE,
                        MAP_SHARED, fd, 0);
    s->write_pos = 0;

    pid_t p1 = fork();
    if (p1 == 0) { produce(s, 'A', N_PER_PRODUCER); _exit(0); }

    pid_t p2 = fork();
    if (p2 == 0) { produce(s, 'B', N_PER_PRODUCER); _exit(0); }

    wait(NULL);
    wait(NULL);

    int a = 0, b = 0;
    for (size_t i = 0; i < s->write_pos && i < CAPACITY; i++) {
        if (s->slot_owner[i] == 'A') a++;
        else if (s->slot_owner[i] == 'B') b++;
    }

    printf("প্রত্যাশিত মোট লেখা: %d\n", 2 * N_PER_PRODUCER);
    printf("চূড়ান্ত write_pos:   %zu\n", s->write_pos);
    printf("গোনা A: %d, B: %d, মোট: %d\n", a, b, a + b);
    return 0;
}
gcc -O0 race_demo.c -o race_demo
./race_demo
প্রত্যাশিত মোট লেখা: 400
চূড়ান্ত write_pos:   387
গোনা A: 201, B: 186, মোট: 387

প্রত্যাশিত মোট লেখা ৪০০ (প্রতিটা producer ২০০টা করে)। কিন্তু চূড়ান্ত write_pos প্রায় প্রতিবারই ৪০০-এর কম হবে (সংখ্যা প্রতিবার আলাদা — এটাই race-এর সংজ্ঞাগত ধর্ম, deterministic না)। কারণটা produce()-এর তিনটা ধাপে লুকানো: usleep(200) ইচ্ছাকৃতভাবে ধাপ ১ (read) আর ধাপ ৩ (write back)-এর মাঝের ফাঁকটা বড় করে দেয়। যখন দুইজন producer প্রায় একই মুহূর্তে ধাপ ১ চালায়, দুইজনই একই pos মান পড়ে ফেলে। দুইজনই সেই একই স্লটে লেখে (একজনের লেখা আরেকজনেরটা চাপা দিয়ে দেয়, নীরবে)। আর দুইজনই pos + 1 লেখে write_pos-এ — ফলে দুইটা প্রকৃত লেখা সত্ত্বেও write_pos মাত্র ১ বেড়েছে। এটাই ক্লাসিক lost update race।

s->write_pos++;-এর মতো একটা লাইনও আসলে একটা single hardware instruction না — এটা তিনটা আলাদা ধাপ (load, increment, store), আর তাদের মাঝখানে অন্য একটা process ঢুকে যেতে পারে। usleep(200) ছাড়া race-টা এখনও থেকে যায়, কিন্তু window এত ছোট (কয়েক ন্যানোসেকেন্ড) যে বাস্তবে খুব কম রানেই ধরা পড়বে — ইচ্ছাকৃতভাবে বিলম্ব বাড়িয়ে একটা অদৃশ্য race-কে দৃশ্যমান করে দেখানো হলো, ঠিক যেভাবে আগের অনেক experiment-এ timing-নির্ভর আচরণ প্রকট করতে delay ব্যবহার হয়েছে।

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

Concept সেকশনের সতর্কতা কাগুজে দাবি নয় -- একাধিক producer একই write_pos-এ read-modify-write করলে প্রকৃতপক্ষে লেখা হারিয়ে যায়, কোনো error বা crash ছাড়াই, চুপচাপ।

নিজে বানান

BUILD IT

Shared ring buffer -- memfd_create + mmap, একজন producer, একজন consumer

C · ●●●●○
  1. ring_buffer.c লিখুন -- struct-এ write_pos, read_pos, capacity, আর data[] অ্যারে রাখুন
  2. memfd_create + ftruncate + mmap(MAP_SHARED) দিয়ে region বানান, fork()-এর আগেই
  3. producer (parent) একটা লুপে ছোট ছোট বার্তা লিখুক, স্লট-ভিত্তিক wraparound যুক্তি সহ
  4. consumer (child) read_pos != write_pos যতক্ষণ সত্য না হয় ততক্ষণ busy-wait করুক, তারপর পড়ুক
  5. strace -f দিয়ে যাচাই করুন সেটআপের পরে আর কোনো read()/write() syscall হচ্ছে না
  6. ইচ্ছাকৃতভাবে একটা দ্বিতীয় producer child যোগ করে আগের experiment-এর race পুনরুৎপাদন করুন

Concept সেকশনের ring buffer প্রোটোকল আর আগের experiment-দুটোর কৌশল — এখন একসাথে জোড়া লাগিয়ে একটা কার্যকরী, একক-producer/একক-consumer ring buffer বানানো যাক। সরলতার জন্য একটা fixed-size “স্লট” ব্যবহার করা হয়েছে (byte-level framing নয়) — বাস্তব সিস্টেমে variable-length message-এর জন্য একটা length-prefix ফিল্ড লাগবে, কিন্তু মূল idea একই।

/* ring_buffer.c -- memfd_create + mmap দিয়ে একটা shared ring buffer
 * একজন producer (parent), একজন consumer (child)
 *
 * সতর্কতা: এই কোডে ইচ্ছাকৃতভাবে কোনো mutex/condition variable নেই --
 * সেটা synchronization-primitives লেসনের বিষয়। এখানে শুধু
 * single-producer/single-consumer ধরে নেওয়া হয়েছে, আর এমনকি
 * সেখানেও কোনো memory-ordering গ্যারান্টি (memory barrier) নেই --
 * সেটার আলোচনা এখনো বাকি।
 */
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/mman.h>
#include <sys/wait.h>

#define CAPACITY   4096
#define MSG_SIZE   32
#define N_SLOTS    (CAPACITY / MSG_SIZE)

typedef struct {
    volatile size_t write_pos;   /* শুধু producer লেখে */
    volatile size_t read_pos;    /* শুধু consumer লেখে */
    unsigned char   data[CAPACITY];
} ring_t;

static void ring_put(ring_t *r, const char *msg) {
    size_t slot = (r->write_pos / MSG_SIZE) % N_SLOTS;
    size_t off  = slot * MSG_SIZE;
    size_t len  = strlen(msg) + 1;                    /* NUL-সহ */
    memcpy(r->data + off, msg, len > MSG_SIZE ? MSG_SIZE : len);
    r->write_pos += MSG_SIZE;                          /* ধাপ ২: pointer আগানো */
}

static const char *ring_get(ring_t *r) {
    if (r->read_pos == r->write_pos) return NULL;      /* খালি */
    size_t slot = (r->read_pos / MSG_SIZE) % N_SLOTS;
    size_t off  = slot * MSG_SIZE;
    const char *msg = (const char *)(r->data + off);
    r->read_pos += MSG_SIZE;
    return msg;
}

int main(void) {
    int fd = memfd_create("ring_demo", 0);
    ftruncate(fd, sizeof(ring_t));

    ring_t *ring = mmap(NULL, sizeof(ring_t), PROT_READ | PROT_WRITE,
                         MAP_SHARED, fd, 0);
    ring->write_pos = 0;
    ring->read_pos  = 0;

    pid_t pid = fork();
    if (pid == 0) {
        /* ---- consumer (child) ---- */
        int received = 0;
        while (received < 20) {
            const char *msg = ring_get(ring);
            if (msg) {
                printf("consumer পেল: %s\n", msg);
                received++;
            }
            /* কোনো বার্তা না থাকলে busy-wait -- ইচ্ছাকৃতভাবে সরল */
        }
        _exit(0);
    } else {
        /* ---- producer (parent) ---- */
        char buf[MSG_SIZE];
        for (int i = 0; i < 20; i++) {
            snprintf(buf, sizeof buf, "message #%d", i);
            ring_put(ring, buf);
            usleep(1000);      /* consumer-কে ধরার সুযোগ দেওয়ার জন্য */
        }
        wait(NULL);
    }
    return 0;
}

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

gcc -O0 ring_buffer.c -o ring_buffer
./ring_buffer
consumer পেল: message #0
consumer পেল: message #1
...
consumer পেল: message #19

নিজে বাড়ান

১. strace দিয়ে যাচাই করুন। strace -f -e trace=read,write ./ring_buffer চালিয়ে দেখুন producer-consumer-এর মধ্যে ২০টা বার্তা আদান-প্রদানে সত্যিই কোনো read()/write() syscall লাগছে না (শুধু printf-এর stdout write বাদে)।

২. দ্বিতীয় producer যোগ করুন। আরেকটা fork() করে দুইজন producer একই ring_put() কল করুক — আগের experiment-এর race এখানেও পুনরুৎপাদন হবে, কারণ এখন দুইজন write_pos-এ read-modify-write করছে। consumer-এর প্রাপ্ত বার্তাগুলো গণনা করে দেখুন প্রত্যাশিত সংখ্যার চেয়ে কম কি না।

৩. busy-wait-এর CPU খরচ মাপুন। time ./ring_buffer চালিয়ে user সময়টা লক্ষ করুন — consumer-এর busy-wait লুপ কতটা CPU সময় নষ্ট করছে সেটা এখান থেকে আন্দাজ করা যায়, বিশেষত producer-এর usleep(1000) বাড়িয়ে consumer-কে বেশি সময় স্পিন করতে বাধ্য করলে।

৪. Byte-level framing চেষ্টা করুন। Fixed MSG_SIZE স্লটের বদলে variable-length বার্তার জন্য একটা 4-বাইট length-prefix যোগ করুন প্রতিটা বার্তার আগে — ঠিক pipes-and-fifos লেসনের Question ৩-এ যে framing কৌশলের কথা বলা হয়েছিল।

৫. /proc/<pid>/maps দিয়ে দেখুন। parent আর child উভয়ের /proc/<pid>/maps-এ shared region-এর entry খুঁজে বের করুন — দুইজনের virtual address হয়তো একই (fork-এর কারণে), কিন্তু /proc/<pid>/pagemap (paging-and-page-tables লেসনের experiment) দিয়ে প্রকৃত physical frame number বের করে যাচাই করুন সেটা দুইজনেরই একই।

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

যেখানে shared memory আর message queue সত্যিই ব্যবহৃত হয়

PostgreSQL-এর shared buffer cache। PostgreSQL প্রতিটা connection-এর জন্য আলাদা OS process চালায় (thread না) — তাই সবগুলো process-কে একই disk page cache দেখাতে হলে shared memory ছাড়া উপায় নেই। shared_buffers কনফিগারেশন প্যারামিটার সরাসরি এই shared memory অঞ্চলের আকার নির্ধারণ করে, যেখানে সব backend process একই disk block একই RAM-এ দেখে — ডুপ্লিকেট cache নয়।

Chromium-এর multi-process architecture। প্রতিটা ট্যাব একটা আলাদা renderer process-এ চলে (নিরাপত্তার জন্য sandboxed)। বড় ডেটা — যেমন একটা compositor-এর জন্য rendered bitmap — process-এর মধ্যে IPC message দিয়ে কপি করে পাঠানো অত্যন্ত ধীর হতো; তার বদলে Chromium shared memory (base::SharedMemory, Mojo shared buffer) ব্যবহার করে বড় বাইনারি ব্লব pass করতে, শুধু একটা ছোট handle/metadata প্রকৃত IPC message হিসেবে যায়।

X11 MIT-SHM extension আর Wayland। পুরনো X11 প্রোটোকলে প্রতিটা pixel network-protocol-এর মতো সিরিয়ালাইজ করে পাঠাতে হতো ক্লায়েন্ট থেকে সার্ভারে — বড় ছবির জন্য এটা অসহনীয় ধীর। MIT-SHM extension (আর আধুনিক Wayland-এর buffer sharing) shared memory ব্যবহার করে ক্লায়েন্ট-কম্পোজিটরের মধ্যে framebuffer সরাসরি শেয়ার করতে, pixel-বাই-pixel কপি এড়িয়ে।

PulseAudio / PipeWire-এর অডিও ring buffer। অডিও স্যাম্পল প্রতি সেকেন্ডে হাজার হাজার বার আসে — প্রতিটার জন্য syscall করলে latency আর CPU খরচ দুটোই অগ্রহণযোগ্য হয়ে উঠত। ক্লায়েন্ট অ্যাপ্লিকেশন আর অডিও সার্ভারের মধ্যে একটা shared-memory ring buffer (ঠিক এই লেসনের build সেকশনের ধারণা, কিন্তু production-grade synchronization সহ) ব্যবহৃত হয় — সার্ভার শুধু মাঝেমধ্যে একটা lightweight সংকেত পাঠায় “ডেটা রেডি”, প্রকৃত অডিও ডেটা কখনো kernel অতিক্রম করে না।

High-frequency trading আর low-latency সিস্টেম। মাইক্রোসেকেন্ড-স্তরের latency যেখানে গুরুত্বপূর্ণ (financial trading engine), সেখানে প্রতিটা syscall-এর ~৪০০ ns খরচও প্রায়ই অগ্রহণযোগ্য। Shared-memory ring buffer (এই লেসনের প্যাটার্নের একটা অত্যন্ত optimize করা সংস্করণ, প্রায়ই lock-free atomic operation সহ, যা আসবে পরের লেসনগুলোতে) প্রক্রিয়ার মধ্যে দ্রুততম যোগাযোগের প্রধান হাতিয়ার।

/dev/shm সাধারণ RAM-ব্যাকড scratch space হিসেবে। অনেক প্রোগ্রাম সত্যিকারের IPC না করেও শুধু দ্রুত temporary storage-এর জন্য /dev/shm ব্যবহার করে (browser sandbox, কিছু build tool-এর intermediate ফাইল) — ডিস্ক I/O সম্পূর্ণ এড়িয়ে, কারণ এটা tmpfs, disk না।

RTOS-এ priority message queue। QNX, VxWorks-এর মতো real-time operating system-এ task-থেকে-task যোগাযোগের প্রধান primitive-ই হলো priority-ভিত্তিক message queue — ঠিক এই লেসনের POSIX mq_send/mq_receive-এর ধারণা, কিন্তু deterministic latency গ্যারান্টি সহ। একটা high-priority সেন্সর-অ্যালার্ট message সবসময় একটা low-priority logging message-এর আগে পৌঁছাবে, এই গ্যারান্টিটাই RTOS ডিজাইনের কেন্দ্রে।

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

“Shared memory নিজে থেকেই synchronized -- kernel নিশ্চিত করে দুইজন একসাথে লিখলেও ডেটা ঠিক থাকবে।”

সম্পূর্ণ ভুল, আর এই লেসনের সবচেয়ে গুরুত্বপূর্ণ বার্তা। mmap(MAP_SHARED) করার পরে kernel-এর আর কোনো ভূমিকা নেই — read/write মানে শুধু normal CPU load/store, কোনো kernel কোড চলে না প্রতিটা অ্যাক্সেসে। Pipe-এ write() syscall-এর ভেতরে kernel নিজে সিরিয়ালাইজ করত (তাই PIPE_BUF-এর নিচে atomicity গ্যারান্টি ছিল, আগের লেসনে দেখা হয়েছে); shared memory-তে সেই syscall-ই নেই, তাই সেই গ্যারান্টিও নেই। Experiment ২ এই ভুল ধারণার সরাসরি খণ্ডন — কোনো error, কোনো crash ছাড়াই ডেটা চুপচাপ হারিয়ে যায়।

“mmap(MAP_SHARED) করার সাথে সাথেই সেই সাইজের physical memory বরাদ্দ হয়ে যায়।”

Hood সেকশনের LayerTrace এর সরাসরি বিপরীত প্রমাণ দেয়। mmap() শুধু একটা VMA রেকর্ড তৈরি করে — “এই ঠিকানা-পরিসর বৈধ, এই backing object-এর সাথে যুক্ত।” কোনো physical frame বরাদ্দ হয় না যতক্ষণ না প্রথমবার সেই ঠিকানা স্পর্শ করা হয় (read বা write) — তখনই একটা minor page fault physical frame বরাদ্দ করে। এই একই নিয়ম paging-and-page-tables আর page-faults-and-demand-paging লেসনে সাধারণ mmap/malloc-এর জন্যও সত্য ছিল — shared memory কোনো ব্যতিক্রম না, একই demand-paging নীতি এখানেও প্রযোজ্য।

“Message queue মানেই ডেটা কোনো persistent storage-এ থাকে, যেমন RabbitMQ বা Kafka-র মতো একটা 'queue' শুনলে মনে হয়।”

POSIX বা System V message queue সম্পূর্ণভাবে kernel মেমরিতে থাকে — reboot হলে হারিয়ে যায়, আর ডিফল্ট সীমা খুবই ছোট (POSIX-এ ঐতিহাসিকভাবে ~১০টা message, প্রতিটা সর্বোচ্চ ~৮ KB)। এটা RabbitMQ, Kafka, বা AWS SQS-এর মতো heavyweight message broker-এর সম্পূর্ণ আলাদা একটা category — ওগুলো নিজেরাই আলাদা userspace সার্ভিস, ডিস্কে persistent, নেটওয়ার্কের মাধ্যমে distributed, replication সহ। এই লেসনের POSIX/SysV message queue তুলনায় একটা অনেক হালকা, একই-মেশিনে-দুইটা-process-এর জন্য বানানো primitive — একটা production job queue বানাতে চাইলে সাধারণত এটা যথেষ্ট না, উপরের real-world উদাহরণের broker-গুলো লাগবে।

“Shared memory সবসময় দ্রুততর, তাই যেকোনো IPC পরিস্থিতিতে pipe/message queue-এর বদলে এটাই বেছে নেওয়া উচিত।”

Raw মেমরি-অ্যাক্সেসের গতি সত্যি, কিন্তু সেই গতির সাথে একটা লুকানো খরচ আসে — synchronization নিজে বানানোর দায়িত্ব। Experiment ১-এর busy-wait লুপ একটা পুরো CPU core নষ্ট করে যতক্ষণ না ডেটা আসে — pipe-এর blocking read()-এ এই খরচ শূন্য ছিল, kernel process-কে সম্পূর্ণ ঘুম পাড়িয়ে রাখত। আর Experiment ২ দেখিয়েছে সিঙ্ক্রোনাইজেশন ভুল করলে ডেটা নীরবে হারিয়ে যায় — একটা এমন bug যা কম লোডে কখনো দেখা যায় না, শুধু উঁচু concurrency-তে মাঝেমধ্যে প্রকাশ পায় (ঠিক আগের লেসনের PIPE_BUF misconception-এর মতো প্যাটার্ন)। যেখানে boundary preservation আর built-in synchronization গুরুত্বপূর্ণ, message queue বা pipe প্রায়ই সহজ আর নিরাপদ পছন্দ — shared memory তখনই যুক্তিসঙ্গত যখন throughput সত্যিই bottleneck, আর সঠিক synchronization বানানোর সময়/দক্ষতা আছে।

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

1

একটা প্রোগ্রাম memfd_create দিয়ে একটা shared region বানায়, mmap(MAP_SHARED) করে একটা global variable ptr-এ রাখে, তারপর fork() করে। Child কোনো নতুন mmap() কল না করেই শুধু সেই একই ptr variable ব্যবহার করে region-এ লেখে। এটা কি কাজ করবে? আর যদি mmap() করার সময় MAP_SHARED-এর বদলে MAP_PRIVATE দেওয়া হতো, তাহলে কী পার্থক্য হতো?

যুক্তি

হ্যাঁ, MAP_SHARED-এর ক্ষেত্রে এটা কাজ করবে — এবং এখানে বোঝাটা গুরুত্বপূর্ণ কেন

MAP_SHARED-এর ক্ষেত্রে যা ঘটে। fork() child-কে parent-এর সম্পূর্ণ virtual address space-এর একটা কপি দেয় — এর মধ্যে সেই VMA (virtual memory area) রেকর্ডটাও আছে যেটা mmap() তৈরি করেছিল। কিন্তু “কপি” মানে এখানে নতুন backing object তৈরি হওয়া না — VMA রেকর্ডে লেখা আছে “এই virtual range, এই fd/inode-এর সাথে যুক্ত, MAP_SHARED” — child-এর নতুন VMA-ও একই fd/inode-এর দিকে নির্দেশ করে। ফলে ptr variable-এর সংখ্যাগত মান (virtual address) parent আর child-এ একই থাকে (fork-এর সাধারণ আচরণ — সব virtual address অক্ষত থাকে), আর সেই ঠিকানা দুইজনের ক্ষেত্রেই একই backing object-এর দিকে যায় — তাই child সরাসরি ptr ব্যবহার করলেই লেখা/পড়া parent-এর সাথে শেয়ার হবে, কোনো নতুন mmap() না করেই।

MAP_PRIVATE হলে কী ভিন্ন হতো। MAP_PRIVATE-এ mapping copy-on-write। fork()-এর পরে parent আর child উভয়েই একই physical frame পড়তে পারে (শুরুতে), কিন্তু যেই মুহূর্তে যেকোনো একজন লেখে, kernel সেই page-এর একটা private কপি বানিয়ে দেয় শুধু লেখকের জন্য — অন্যজনের কপি অপরিবর্তিত থাকে। ফলে child যদি ptr-এ লেখে, সেই লেখা একটা সম্পূর্ণ নতুন physical frame-এ যাবে, parent তা কখনো দেখবে না — দুইজনের “একই দেখতে” ঠিকানা আসলে দুইটা ভিন্ন physical frame হয়ে যাবে প্রথম লেখার পরেই। এটাই IPC-র জন্য MAP_PRIVATE ব্যবহার না করার মূল কারণ — এটা isolation-এর জন্য বানানো (যেমন একটা shared library-র code segment একাধিক process লোড করলে), শেয়ারিং-এর জন্য না।

2

একটা producer process shared-memory ring buffer-এ লিখছে, আর consumer process সেটা পড়ছে। যদি consumer হঠাৎ ক্র্যাশ করে (segfault, বা kill -9), producer-এর কী হবে? এটা pipe-এর ক্ষেত্রে (SIGPIPE/EPIPE) যা ঘটত তার সাথে তুলনা করুন।

প্রয়োগ

এখানেই shared memory আর pipe-এর মধ্যে সবচেয়ে তীক্ষ্ণ পার্থক্যটা প্রকাশ পায়।

Pipe-এ যা হতো। আগের লেসনে দেখা হয়েছে — consumer (reader) মারা গেলে তার read-end-এর সব reference বন্ধ হয়ে যায় (process termination-এর স্বাভাবিক অংশ)। Producer-এর পরের write() কল kernel-এর কাছ থেকে SIGPIPE পায় (ডিফল্ট: process টার্মিনেট, অথবা handled থাকলে EPIPE errno) — একটা স্পষ্ট, তাৎক্ষণিক, kernel-প্রদত্ত সংকেত যে “অন্য প্রান্ত আর নেই।”

Shared memory-তে যা হয় — সম্পূর্ণ নীরবতা। Producer-এর ring_put() ফাংশনটা শুধু মেমরিতে লেখে — কোনো syscall নেই, তাই kernel-এর জানারও কোনো সুযোগ নেই যে consumer আর নেই। Producer নিরবচ্ছিন্নভাবে লিখতেই থাকবে, write_pos বাড়তেই থাকবে, buffer wraparound করতে থাকবে — এবং যেহেতু consumer আর read_pos আগাচ্ছে না, producer শীঘ্রই তার নিজের অপঠিত ডেটার উপর দিয়েই লিখতে শুরু করবে (overwrite), সব অপঠিত বার্তা হারিয়ে যাবে — কোনো error, কোনো signal, কোনো ইঙ্গিত ছাড়াই।

ব্যবহারিক ফল। Shared memory ব্যবহার করলে “অন্য প্রান্ত এখনো বেঁচে আছে কি না” জানার দায়িত্বও প্রোগ্রামারের — kernel এটা বিনামূল্যে দেয় না। বাস্তব সিস্টেমে এই সমস্যার সমাধান সাধারণত একটা আলাদা, হালকা “heartbeat” মেকানিজম — consumer পর্যায়ক্রমে একটা timestamp শেয়ার্ড মেমরিতে আপডেট করে, producer সেটা চেক করে বুঝতে পারে consumer সাড়া দিচ্ছে কি না। অথবা, পরের লেসনের বিষয় — signal ব্যবহার করে (যেমন consumer process মারা গেলে kernel producer-কে SIGCHLD পাঠাতে পারে যদি producer তার parent হয়) একটা আলাদা চ্যানেলে “অন্য প্রান্ত মারা গেছে” এই তথ্য পাওয়া। মূল শিক্ষাটা concept সেকশনের সেই বাক্যেরই প্রতিধ্বনি — kernel-মধ্যস্থতা বাদ দিলে শুধু গতি হারায় না, সাথে সাথে সব ধরনের বিনামূল্যে-পাওয়া error-reporting-ও হারিয়ে যায়।

3

আপনাকে দুইটা process-এর মধ্যে প্রতি সেকেন্ডে লক্ষাধিক ছোট বার্তা (প্রতিটা ~১০০ বাইট) পাঠানোর একটা সিস্টেম ডিজাইন করতে বলা হয়েছে, যেখানে throughput সবচেয়ে গুরুত্বপূর্ণ কিন্তু আপনি এটাও চান বার্তাগুলো কখনো একে অপরের সাথে মিশে না যায় (boundary সংরক্ষিত থাকুক) আর consumer যেন busy-wait করে CPU নষ্ট না করে। Pipe (length-prefix framing সহ), POSIX message queue, আর shared-memory ring buffer — তিনটা বিকল্প ওজন করে সুপারিশ করুন।

ডিজাইন

তিনটা বিকল্পের কোনোটাই একা এই তিনটা চাহিদা (throughput, boundary, no-busy-wait) একসাথে পুরোপুরি পূরণ করে না — এই প্রশ্নের আসল শিক্ষা হলো trade-off বোঝা।

পদ্ধতিThroughputBoundary সংরক্ষিত?Busy-wait এড়ায়?
Pipe + length-prefixমাঝারি — প্রতি বার্তায় অন্তত ১টা syscall জোড়া (write+read), লক্ষাধিক/সেকেন্ডে উল্লেখযোগ্য syscall overheadহ্যাঁ, DIY framing দিয়েহ্যাঁ — kernel blocking read() দেয়
POSIX message queueমাঝারি — pipe-এর কাছাকাছি, কিন্তু msgsize_max/msg_max-এর সিস্টেম-সীমা লক্ষাধিক ছোট বার্তার জন্য টিউন করতে হবেহ্যাঁ, বিনামূল্যেহ্যাঁ — kernel blocking mq_receive() দেয়
Shared-memory ring bufferসর্বোচ্চ — কোনো syscall প্রতি বার্তায় লাগে নাহ্যাঁ, যদি নিজে ঠিকভাবে framing করা হয় (এই লেসনের build-এর মতো)না — এই লেসনের অবস্থা অনুযায়ী শুধু busy-wait, এখনো সমাধান নেই

আমার সুপারিশ — একটা হাইব্রিড। খাঁটি shared-memory ring buffer সবচেয়ে বেশি throughput দেয়, কিন্তু busy-wait-এর CPU-খরচ (Experiment ১-এ দেখা হয়েছে) লক্ষাধিক বার্তা/সেকেন্ডের স্কেলেও অগ্রহণযোগ্য হতে পারে যদি বার্তার গতি অসম হয় (মাঝেমধ্যে ফাঁকা সময় থাকলে সেই সময়টায় CPU ১০০%-এ স্পিন করবে অকারণে)। বাস্তব high-throughput সিস্টেম (যেমন realworld সেকশনের অডিও সার্ভার আর HFT উদাহরণ) সাধারণত shared-memory ring buffer-কে প্রধান ডেটা-পথ হিসেবে রাখে (throughput-এর জন্য), আর একটা আলাদা, হালকা সংকেত-চ্যানেল (Linux-এ eventfd, বা futex-based wakeup — futex-and-lock-implementation লেসনের বিষয়) যোগ করে শুধু “নতুন ডেটা আছে” জানানোর জন্য, যাতে consumer সেই সংকেত না আসা পর্যন্ত সত্যিকারের ঘুমে থাকতে পারে, busy-wait না করে। এভাবে bulk ডেটা কখনো kernel অতিক্রম করে না (shared memory-র গতি অটুট), কিন্তু “জাগো” সংকেতটা kernel দিয়ে যায় (তাই CPU নষ্ট হয় না) — দুইটা মেকানিজমের সেরা অংশ একসাথে।

এই hybrid প্যাটার্নটাই এই লেসনের build সেকশনের busy-wait-এর সীমাবদ্ধতা আর signal/synchronization-primitives লেসনদ্বয়ের প্রতিশ্রুতির মধ্যে সেতুবন্ধন — আজ শুধু একটা অংশ (shared memory-র raw গতি) হাতে আছে, বাকি অংশ (সঠিক জাগানো, সঠিক lock) পরের দুইটা লেসনে আসবে।

4

Experiment ২-এর race_demo.c-এ usleep(200) ধাপ ১ (read) আর ধাপ ৩ (write back)-এর মাঝে বসানো হয়েছিল race window বড় করার জন্য। এই delay ছাড়া কোড চালালে কী হতো — race condition কি থাকবে না, নাকি শুধু কম দেখা যাবে? আর ব্যাখ্যা করুন কেন s->write_pos = pos + 1;-এর মতো একটা সাধারণ দেখতে C statement-ও “atomic” বলে ধরে নেওয়া বিপজ্জনক।

যুক্তি

Delay ছাড়া race থেকে যায়, শুধু window সংকুচিত হয়। Race condition-এর অস্তিত্ব নির্ভর করে দুইটা process-এর instruction-স্ট্রিম কি ওভারল্যাপ করতে পারে তার উপর, delay-র উপর না। usleep(200) শুধু race window-কে কৃত্রিমভাবে ~২০০ মাইক্রোসেকেন্ডে বড় করে দিয়েছে যাতে দুইটা প্রায়-swap-হয়ে-যাওয়া CPU core-এর process সেই সময়ের মধ্যে ওভারল্যাপ করার সুযোগ পায়। Delay বাদ দিলে ধাপ ১ আর ধাপ ৩-এর মধ্যে ফাঁকটা মাত্র কয়েক CPU cycle (কয়েক ন্যানোসেকেন্ড) — সেই ফাঁকে ঠিক আরেকটা process-এর ঠিক সেই মুহূর্তে ওই তিনটা instruction চালানো পরিসংখ্যানগতভাবে অনেক কম সম্ভাবনার, কিন্তু শূন্য সম্ভাবনার না, বিশেষত সত্যিকারের multi-core parallelism-এ (দুইজনই একই সময়ে ভিন্ন core-এ সত্যিই সমান্তরালে চলছে, cpu-scheduling লেসনের time-sliced single-core simulation না)। এটাই race condition-এর সবচেয়ে বিপজ্জনক বৈশিষ্ট্য — ছোট window-তে এটা “কাজ করে মনে হয়” (লোড কম থাকলে কখনো প্রকাশ পায় না), কিন্তু উঁচু concurrency, উঁচু লোড, বা ভিন্ন হার্ডওয়্যারে (বেশি core, ভিন্ন cache latency) হঠাৎ প্রকাশ পেতে পারে — একটা “works on my machine” বাগের ক্লাসিক উৎস।

কেন write_pos = pos + 1 একটা single atomic operation না। উৎস-কোডের একটা লাইন compiler-এর কাছে একাধিক machine instruction-এ ভেঙে যায় — সাধারণত অন্তত: (১) pos-এর মান একটা register-এ load করা (এখানে pos আগেই একটা variable-এ পড়া ছিল, কিন্তু ধরুন সরাসরি write_pos++ লেখা হতো, তাহলে প্রথমে write_pos-এর বর্তমান মান memory থেকে load), (২) সেই register-এ increment করা, (৩) নতুন মান আবার memory-তে store করা। এই তিনটা আলাদা ধাপের মাঝখানে CPU-র interrupt আসতে পারে (context switch, cpu-scheduling লেসনের বিষয়), অথবা — এই লেসনের ক্ষেত্রে প্রাসঙ্গিক — আরেকটা core-এ চলা আরেকটা process ঠিক একই মুহূর্তে একই memory location-এ load/store করতে পারে। “দেখতে এক লাইন” মানে “hardware-এ এক ধাপ” না — এই ভুল ধারণাটাই বেশিরভাগ race condition বাগের মূল উৎস, আর race-conditions-and-deadlock লেসনে এটাকেই আনুষ্ঠানিকভাবে “critical section problem” হিসেবে সংজ্ঞায়িত করা হবে।

5

Build সেকশনের ring_buffer.c-এ একটা ৪ KB (৪০৯৬ বাইট) region একবার memfd_create + mmap করে ঘণ্টার পর ঘণ্টা ধরে পুনর্ব্যবহার করা হয় (ring buffer-এর wraparound দিয়ে), যত গিগাবাইট ডেটাই এর মধ্য দিয়ে বয়ে যাক না কেন। যদি বদলে প্রতিটা আলাদা বার্তার জন্য একটা নতুন memfd_create + mmap করা হতো (বার্তা পাঠানোর পরে munmap করে ফেলে দেওয়া, পরের বার্তায় আবার নতুন করে), তাহলে ১০ লক্ষ বার্তা পাঠাতে মোট কতগুলো page fault আর syscall লাগত — আর কেন এই পার্থক্যটা shared memory-র পুরো সুবিধাকেই বিপন্ন করে দিতে পারে?

প্রয়োগ

পুনর্ব্যবহারের হিসাব — একবার, চিরকালের জন্য। ৪ KB region ঠিক একটা physical page-এর সমান (Linux-এ ডিফল্ট page size ৪ KB, tlb লেসনে দেখা হয়েছে)। প্রথমবার সেই page-এ কেউ লেখে (build সেকশনের LayerTrace অনুযায়ী), একটামাত্র minor page fault হয়, kernel একটা physical frame বরাদ্দ করে দেয়। এরপর ring buffer-এর wraparound যুক্তি অনুযায়ী প্রতিটা নতুন বার্তা এই একই page-এর ভেতরেই ভিন্ন offset-এ লেখা হয় — নতুন page স্পর্শ হয় না, তাই নতুন page fault হয় না। ফলে ১০ লক্ষ বার্তা হোক বা ১০ কোটি, page fault সংখ্যা থেকেই যায় ১-এর কাছাকাছি (ঠিক কতগুলো ৪ KB page আছে region-এ, এখানে মাত্র ১টা), আর syscall সংখ্যা সেটআপের ৩-৪টাতেই আটকে থাকে।

প্রতি-বার্তা নতুন mmap হলে — সম্পূর্ণ ভিন্ন হিসাব। প্রতিটা বার্তার জন্য যদি নতুন memfd_create + ftruncate + mmap + (লেখার সময়) ১টা page fault + munmap + close করা হতো, তাহলে প্রতি বার্তায় অন্তত ৫টা syscall + ১টা page fault লাগত। ১০ লক্ষ বার্তায় সেটা প্রায় ৫০ লক্ষ syscall + ১০ লক্ষ page fault — example সেকশনের ~৪০০ ns/syscall হিসাব ধরলে শুধু syscall overhead-এই প্রায় ২ সেকেন্ড (৫০,০০,০০০ × ৪০০ ns), তার উপর page fault-এর নিজস্ব খরচ (সাধারণত syscall-এর চেয়েও ভারী, কারণ এতে page table আপডেট, zero-fill, আর অনেক ক্ষেত্রে TLB shootdown জড়িত)।

কেন এটা পুরো সুবিধা বিপন্ন করে। এই সংখ্যাগুলো pipe-এর জন্য example সেকশনে হিসাব করা ~৩২০০ syscall/১০০ MB-এর চেয়েও খারাপ — অথচ shared memory-র পুরো দাবিই ছিল pipe-এর চেয়ে কম syscall। ভুল ব্যবহারে (প্রতি বার্তায় নতুন region) shared memory আসলে pipe-এর চেয়ে ধীরগতির হয়ে যেতে পারে, কারণ mmap/munmap নিজেই pipe-এর একটা read()/write() জোড়ার চেয়ে ভারী অপারেশন। এই প্রশ্নের মূল শিক্ষা — shared memory-র গতির সুবিধা আসে region পুনর্ব্যবহার থেকে, ring buffer প্যাটার্ন থেকে — সেটআপ-খরচ একবার দিয়ে বহুবার ব্যবহার করাই আসল কৌশল, প্রতিবার নতুন করে বানানো না। Build সেকশনের কোড ঠিক এই কারণেই একটাই region fork()-এর আগে বানিয়ে বারবার পুনর্ব্যবহার করে।

এরপর কী

পরের লেসন — Signal

এই লেসনে দুইটা জিনিস ইচ্ছাকৃতভাবে অসম্পূর্ণ রেখে দেওয়া হলো। প্রথমত, build সেকশনের ring buffer-এ consumer একটা busy-wait লুপে বসে থাকে (“কিছু নেই? আবার চেক করো, আবার চেক করো…”) — এটা একটা পুরো CPU core-কে ১০০%-এ ব্যস্ত রাখে শুধু অপেক্ষা করার জন্য, pipe-এর blocking read()-এর ঠিক বিপরীত (সেখানে kernel process-কে ঘুম পাড়িয়ে রাখত, zero CPU)। দ্বিতীয়ত, experiment সেকশনে দেখানো race condition — একাধিক producer একই write_pos আপডেট করলে ডেটা হারিয়ে যায় — এখনো সমাধান করা হয়নি।

দুইটা সমস্যারই মূল কারণ একটাই: আমাদের হাতে এখনো কোনো synchronization primitive নেই — এমন কোনো মেকানিজম নেই যা দিয়ে একটা process আরেকটাকে বলতে পারে “এখন অপেক্ষা করো” বা “এখন এগিয়ে যাও”, কোনো CPU নষ্ট না করে, আর কোনো ডেটা হারানোর ঝুঁকি ছাড়া।

পরের লেসনে আমরা signal নিয়ে কাজ করব — kernel একটা process-এর normal flow-কে asynchronously বাধা দিয়ে বলতে পারে “কিছু একটা ঘটেছে” (ঠিক যেভাবে SIGPIPE বলেছিল “তোমার reader আর নেই”, পিছনের লেসনে)। এটা সরাসরি shared-memory-র race সমাধান করে না — signal নিজেও একটা synchronization primitive না — কিন্তু busy-wait এড়ানোর একটা রাস্তা খুলে দেয়, আর crash-করা প্রক্রিয়া সনাক্ত করার একটা উপায় (Question ২-এ যেই ফাঁকটা চিহ্নিত হয়েছিল)।

আর তারপর, synchronization primitives লেসনে (mutex, condition variable) হাতে আসবে সেই আসল যন্ত্রপাতি যা দিয়ে আজকের ring buffer-এর race condition-টা সঠিকভাবে বন্ধ করা যাবে — একটা bounded blocking queue হিসেবে, ঠিক এই একই shared memory-র উপর ভিত্তি করে। ততক্ষণ পর্যন্ত, আজকের ring buffer একটা সৎ, কার্যকরী, কিন্তু ফাঁকা-থাকা-স্বীকৃত ডিজাইন হিসেবেই থাকবে।

আরও পড়ুন

  • shm_overview(7) — Linux man page · POSIX shared memory API-র সরকারি সারসংক্ষেপ -- shm_open/mmap/shm_unlink-এর সম্পূর্ণ জীবনচক্র
  • memfd_create(2) — Linux man page · anonymous shared memory তৈরির আধুনিক পদ্ধতি -- এই লেসনের ring buffer এই সিস্টেম কলের উপর ভিত্তি করে তৈরি
  • mmap(2) — Linux man page · MAP_SHARED বনাম MAP_PRIVATE-এর নির্ভুল সেমান্টিক্স, আর fork()-এর পরে mapping-এর আচরণ
  • mq_overview(7) — Linux man page · POSIX message queue-র সিস্টেম-ব্যাপী সীমা (msg_max, msgsize_max) আর priority সেমান্টিক্সের প্রামাণ্য উৎস
  • svipc(7) — Linux man page · System V IPC (shared memory, message queue, semaphore)-এর key/ftok-ভিত্তিক নামকরণ ব্যবস্থার বর্ণনা
  • The Linux Programming Interface, অধ্যায় ৪৫–৫৪: System V ও POSIX IPC — Michael Kerrisk · shared memory আর message queue-র দুই প্রজন্মের API-ই (System V ও POSIX) এখানে বিস্তারিত আলোচিত, প্রতিটা design decision-এর ঐতিহাসিক কারণসহ