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

File Descriptor — একটা ছোট সংখ্যার পেছনে তিন স্তরের টেবিল

File Descriptors: The Three-Level Indirection

`open()` একটা ছোট অ-ঋণাত্মক সংখ্যা ফেরত দেয় — কিন্তু সেই সংখ্যার পেছনে কার্নেলে তিনটা আলাদা টেবিল আছে, আর প্রায় প্রতিটা fd-সংক্রান্ত বিভ্রান্তির উৎস এই তিন স্তরকে এক করে ফেলা। এই লেসনে সেই তিন স্তর সঠিকভাবে আঁকা হবে, তারপর তার সরাসরি ফলাফল দেখা হবে: dup আর দুইবার open কেন ভিন্ন আচরণ করে, O_APPEND কেন race ঠেকায়, fd কীভাবে exec-এর ওপারে leak হয়, আর EMFILE কখন আসে।

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

  • fd থেকে ডিস্ক পর্যন্ত তিন স্তরের indirection (per-process fd table → system-wide open file table → inode table) নির্ভুলভাবে আঁকতে পারবেন, আর বলতে পারবেন offset ও status flag ঠিক কোন স্তরে থাকে
  • একই ফাইলে দুইবার `open()` করা আর একটা fd `dup()` করার মধ্যে পার্থক্য ব্যাখ্যা করতে পারবেন — কোনটায় file offset শেয়ার হয়, কোনটায় হয় না, আর কেন
  • `O_APPEND` কেন একটা atomic offset-অপারেশন আর `lseek()`+`write()` কেন race-প্রবণ, তা একটা concrete interleaving দিয়ে দেখাতে পারবেন এবং কত বাইট হারায় হিসাব করতে পারবেন
  • `O_CLOEXEC` ছাড়া fd কীভাবে `exec()`-এর ওপারে leak হয়, সেটার নিরাপত্তা-ঝুঁকি বর্ণনা করতে পারবেন, আর `close_range()`/`FD_CLOEXEC` দিয়ে সমাধান করতে পারবেন
  • `ulimit -n`, `/proc/sys/fs/nr_open` আর `/proc/sys/fs/file-max` — তিনটা ভিন্ন সীমার পার্থক্য বুঝে EMFILE বনাম ENFILE failure আলাদা করে চিনতে ও ঠিক করতে পারবেন
  • `/proc/PID/fd/` আর `/proc/PID/fdinfo/N` পড়ে একটা চলমান process-এর প্রতিটা fd-র প্রকৃত offset, flag আর inode লাইভ পরিদর্শন করতে পারবেন

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

আগে এটা বুঝি

একটা ছোট প্রোগ্রাম, যেটার আউটপুট প্রায় সবাই প্রথমবার ভুল অনুমান করে:

int a = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
int b = open("log.txt", O_WRONLY);          /* একই ফাইল, আবার */
write(a, "AAAAA", 5);
write(b, "BBBBB", 5);

log.txt-এ শেষে কী থাকবে? বেশিরভাগ মানুষের প্রথম উত্তর AAAAABBBBB (১০ বাইট)। প্রকৃত উত্তর BBBBB — মোট ৫ বাইট, AAAAA সম্পূর্ণ চাপা পড়ে গেছে।

এবার একটা সামান্য ভিন্ন সংস্করণ:

int a = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
int b = dup(a);                             /* open না, dup */
write(a, "AAAAA", 5);
write(b, "BBBBB", 5);

এখানে ফলাফল সত্যিই AAAAABBBBB — ১০ বাইট

দুইটা প্রোগ্রামে a আর b দুটোই “একই ফাইলের দিকে ইশারা করা fd”, দুটোতেই একই ক্রমে একই দুইটা write। তবু ফলাফল সম্পূর্ণ ভিন্ন। কারণ open() আর dup() কার্নেলের ভিন্ন স্তরে কাজ করে — আর সেই স্তরগুলো না জানা থাকলে এই আচরণ জাদু মনে হয়।

এই লেসনের কেন্দ্রীয় কাজ একটাই: fd-র পেছনের তিনটা টেবিল সঠিকভাবে আঁকা। এই ডায়াগ্রামটা একবার ঠিকভাবে মাথায় বসে গেলে fd নিয়ে বাকি প্রায় প্রতিটা প্রশ্নের উত্তর — offset কে শেয়ার করে, fork() করলে কী হয়, close() আসলে কী করে, O_APPEND কেন দরকার, fd কেন leak হয় — নিজে থেকেই বেরিয়ে আসে, মুখস্থ না করেই।

মূল ধারণা

fd একটা pointer না — একটা index

প্রথম ভুল ধারণাটা নাম থেকেই আসে। “File descriptor” নামটা শুনে মনে হয় এটা ফাইলের একটা “বর্ণনা” বহন করছে। করে না। open() যে সংখ্যাটা ফেরত দেয়, সেটা শুধু একটা array-র index — process-এর নিজস্ব একটা pointer array-তে।

int fd = open("/etc/hosts", O_RDONLY);
printf("%d\n", fd);      /* সাধারণত 3 */

3 মানে “আমার fd table-এর ৩ নম্বর ঘর”। ঐ সংখ্যাটা process-সাপেক্ষ — আপনার process-এর fd 3 আর অন্য process-এর fd 3 সম্পূর্ণ ভিন্ন দুইটা ফাইল হতে পারে, প্রায় সবসময় হয়ও।

POSIX একটা কড়া নিয়ম দেয় যা এখান থেকেই আসে: open() সবসময় সবচেয়ে ছোট অব্যবহৃত fd নম্বরটা বরাদ্দ করে। এটা “একটা optimization” না, এটা স্পেসিফিকেশনের অংশ — আর বহু কৌশল (আর বহু বাগ) এই গ্যারান্টির উপর দাঁড়িয়ে আছে।

তিন স্তরের টেবিল — এই লেসনের কেন্দ্রীয় ছবি

fd থেকে ডিস্ক পর্যন্ত পথে তিনটা আলাদা ডেটা স্ট্রাকচার আছে, তিনটার scope তিন রকম:

স্তরকার্নেল structScopeকী রাখে
fd tablefiles_structfdtablestruct file **fdপ্রতি process-এ একটাশুধু pointer-এর একটা array, আর একটা close-on-exec bitmap
open file table (open file description)struct filesystem-wide, প্রতি open() কলে একটাfile offset (f_pos), status flags (f_flags), access mode, reference count
inode tablestruct inodesystem-wide, প্রতি ফাইলে একটাফাইলের size, permission, owner, timestamp, link count, ডেটা ব্লকের ঠিকানা

এই তিন স্তরের সবচেয়ে গুরুত্বপূর্ণ তথ্য একটা বাক্যে: offset fd-তে থাকে না, inode-এও থাকে না — মাঝের স্তরে থাকে। ইন্ট্রোর দুইটা প্রোগ্রামের পুরো রহস্য এই একটা বাক্যেই সমাধান।

 fd table (per-process)        open file table (system-wide)      inode table (system-wide)
 ─────────────────────         ──────────────────────────────     ─────────────────────────
 struct files_struct           struct file                        struct inode
   → fdtable → fd[]            (একটা প্রতি open() কলে)              (একটা প্রতি ফাইলে)


  fd 0 ──────────────────────►  file #1
       stdin                    f_pos   = 0
                                f_flags = O_RDONLY              ┌─► inode 3721
                                f_count = 1  ──────────────────►│   /dev/pts/0
                                                                │   (character device)
  fd 1 ──┐                                                      │   i_nlink = 1
         │  dup2(1, 2) --       file #2                         │
         ├──────────────────►   f_pos   = 0                     │
  fd 2 ──┘  দুইটা fd, একটাই      f_flags = O_WRONLY              │
            description         f_count = 2  ──────────────────►┘

                                        └── reference count ২, কারণ
                                            fd 1 আর fd 2 দুজনেই ধরে আছে

  fd 3 ──────────────────────►  file #3                          ┌─► inode 918273
       open("data.txt",O_RDWR)  f_pos   = 100                    │   data.txt
                                f_flags = O_RDWR  ───────────────┤   i_size  = 4096
                                f_count = 1                      │   i_mode  = 0100644
                                                                 │   i_uid, i_gid
  fd 4 ──────────────────────►  file #4                          │   i_nlink = 1
       আবার open("data.txt")    f_pos   = 0                      │   i_blocks = 8
       -- একই ফাইল!             f_flags = O_RDWR  ───────────────┘   extent tree / block map
                                f_count = 1

                                        └── আলাদা description, তাই
                                            offset সম্পূর্ণ স্বাধীন
fd থেকে ডিস্ক পর্যন্ত তিন স্তর। fd 1 আর fd 2 একই open file description শেয়ার করছে (dup2-এর ফল, তাই একই offset); fd 3 আর fd 4 একই ফাইলের দুইটা আলাদা open, তাই একই inode কিন্তু সম্পূর্ণ আলাদা offset।

এই ছবির তিনটা পাঠ, যেগুলো বাকি সব কিছুর ভিত্তি:

  1. open() মধ্যম স্তরে একটা নতুন entry বানায়। একই ফাইলে দুইবার open() করলে দুইটা struct file তৈরি হয়, দুইটার নিজস্ব f_pos — তাই offset স্বাধীন। ইন্ট্রোর প্রথম প্রোগ্রামে দুইটা write দুইটাই offset 0 থেকে শুরু করেছিল, তাই দ্বিতীয়টা প্রথমটাকে ওভাররাইট করেছে।
  2. dup() মধ্যম স্তরে কিছুই বানায় না। এটা শুধু fd table-এ আরেকটা ঘরে একই pointer কপি করে, আর f_count এক বাড়ায়। তাই offset শেয়ার হয় — ইন্ট্রোর দ্বিতীয় প্রোগ্রামে প্রথম write offset ৫-এ নিয়ে গেছে, দ্বিতীয় write সেখান থেকেই চলেছে।
  3. inode স্তরে offset-এর কোনো অস্তিত্ব নেই। inode ফাইলটা “কী” তা জানে — কত বড়, কোথায় ব্লকগুলো, কার মালিকানা — কিন্তু “কে কোথায় পড়ছে” তা জানে না, জানার দরকারও নেই।

fork() — একটা তৃতীয় ক্ষেত্র, যেটা dup-এর মতো আচরণ করে

তিন-স্তরের ছবিটা fork()-এর আচরণও ব্যাখ্যা করে দেয়। fork() fd table কপি করে (child পায় নিজের files_struct), কিন্তু সেই table-এর pointer-গুলো একই struct file-এর দিকেই ইশারা করে, f_count বেড়ে যায়।

ফলাফল: parent আর child file offset শেয়ার করে — ঠিক dup()-এর মতোই। এটাই কারণ যে shell-এ (cmd1; cmd2) > out.txt চালালে দুইটা কমান্ডের আউটপুট পরপর বসে, একে অপরকে ওভাররাইট করে না।

অপারেশননতুন fd number?নতুন struct file?offset শেয়ার?
open() একই ফাইলে দ্বিতীয়বারহ্যাঁহ্যাঁনা
dup(fd) / dup2(fd, n)হ্যাঁনাহ্যাঁ
fork()না (একই নম্বর, ভিন্ন table-এ)নাহ্যাঁ
execve() (CLOEXEC ছাড়া)নানাহ্যাঁ (একই description টিকে থাকে)

fd 0, 1, 2 — এগুলো শুধুই একটা কনভেনশন

কার্নেলের কাছে fd 0, 1, 2 বিন্দুমাত্র বিশেষ না। কার্নেল কোথাও লিখে রাখেনি “১ নম্বরটা output”। যা ঘটে তা হলো: shell (বা init) নতুন process চালু করার আগে fd 0/1/2-তে যথাক্রমে terminal-এর read-end, write-end বসিয়ে দেয় — dup2() দিয়ে। তারপর C library-র printf স্রেফ কনভেনশন হিসেবে fd 1-এ লেখে।

Shell-এর প্রতিটা redirection আসলে ছদ্মবেশে dup2():

Shell লেখাকার্নেলে যা ঘটে
cmd > out.txtfd = open("out.txt", O_WRONLY|O_CREAT|O_TRUNC, 0666); dup2(fd, 1); close(fd);
cmd >> out.txtএকই, কিন্তু O_APPEND সহ (O_TRUNC-এর বদলে)
cmd 2>&1dup2(1, 2) — fd 2 এখন fd 1-এর একই description দেখায়
cmd \< in.txtfd = open("in.txt", O_RDONLY); dup2(fd, 0); close(fd);

2>&1 কেন offset শেয়ার করে সেটা এখন স্পষ্ট: dup2 মধ্যম স্তরে নতুন কিছু বানায় না। এই কারণেই cmd > out.txt 2>&1 কাজ করে (stdout আর stderr একই description-এ, offset একসাথে এগোয়, লেখা মিশে যায় না), কিন্তু cmd > out.txt 2> out.txt কাজ করে না — সেখানে দুইটা আলাদা open(), দুইটা স্বাধীন offset, একে অপরকে ওভাররাইট করবে। ঠিক ইন্ট্রোর প্রথম প্রোগ্রামটাই।

O_APPEND — offset আর write একসাথে, atomically

দুইটা process একই log ফাইলে লিখছে। “শেষে যোগ করা”-র সহজ কোড:

lseek(fd, 0, SEEK_END);      /* offset ফাইলের শেষে নাও */
write(fd, buf, 100);         /* সেখানে লেখো */

এই দুই লাইনের মাঝখানে কার্নেল অন্য process-কে চালাতে পারে। একটা concrete interleaving, ফাইল শুরুতে ১০০০ বাইট:

সময়Process PProcess Qফাইলের অবস্থা
t1lseek → offset = 1000১০০০ বাইট
t2lseek → offset = 1000১০০০ বাইট
t3write 100 বাইট @1000১১০০ বাইট, P-র ডেটা 1000–1099
t4write 100 বাইট @1000১১০০ বাইট, Q-র ডেটা P-রটার উপর

শেষ ফলাফল: ২০০ বাইট লেখা হয়েছে, ফাইল বেড়েছে মাত্র ১০০ বাইট, P-র ১০০ বাইট নীরবে হারিয়ে গেছে। কোনো error না, কোনো warning না — শুধু একটা log line অদৃশ্য।

O_APPEND ঠিক এটাই ঠেকায়। এই flag নিয়ে খোলা fd-তে write() করলে কার্নেল offset-নির্ধারণ আর ডেটা-লেখা এক অবিভাজ্য অপারেশনে করে — inode_lock ধরে রেখে f_pos-কে বর্তমান i_size-এ বসায়, তারপর লেখে, তারপর lock ছাড়ে। মাঝখানে কেউ ঢুকতে পারে না। ফলাফল: ১২০০ বাইট, দুইটা লেখাই অক্ষত।

O_CLOEXEC — fd-র exec-এর ওপারে বেঁচে থাকা

fork()-এ fd বেঁচে থাকে (আমরা দেখেছি)। কম পরিচিত ব্যাপারটা হলো — execve()-তেও ডিফল্টে fd বেঁচে থাকে। নতুন প্রোগ্রামের image সম্পূর্ণ বদলে যায় (code, data, heap, stack — সব নতুন), কিন্তু files_struct অক্ষত থেকে যায়। এটা ইচ্ছাকৃত: shell-এর redirection তো ঠিক এভাবেই কাজ করে (fork → dup2 → exec, আর dup2-র ফলাফল exec-এর ওপারে টিকে থাকে বলেই নতুন প্রোগ্রামের stdout সঠিক জায়গায় যায়)।

কিন্তু এই ডিফল্টটাই একটা leak-এর উৎস। ধরুন আপনার server প্রোগ্রামের কাছে খোলা আছে: database-এর socket, TLS private key ফাইল, একটা privileged config। এখন প্রোগ্রামটা একটা helper চালাতে fork() + execve("/usr/bin/convert", ...) করল। সেই helper — যেটা হয়তো একটা তৃতীয় পক্ষের বাইনারি — আপনার সব fd উত্তরাধিকার সূত্রে পেয়ে গেল, এবং সেগুলো দিয়ে পড়তে-লিখতে পারে।

সমাধান দুইভাবে:

/* সঠিক পদ্ধতি -- open-এর সময়েই, atomically */
int fd = open("/etc/secret.key", O_RDONLY | O_CLOEXEC);

/* পুরনো পদ্ধতি -- দুই ধাপে, আর তাই race-প্রবণ */
int fd = open("/etc/secret.key", O_RDONLY);
fcntl(fd, F_SETFD, FD_CLOEXEC);      /* ← এই দুই লাইনের মাঝে অন্য thread
                                          fork+exec করলে fd leak হয়ে গেছে */

O_CLOEXEC (Linux 2.6.23-এ যোগ হয়) ঠিক এই race-টা বন্ধ করতেই এসেছিল — multithreaded প্রোগ্রামে open() আর fcntl()-এর মাঝের জানালাটা বাস্তবে exploit-যোগ্য। একই কারণে Linux পরে SOCK_CLOEXEC, pipe2(..., O_CLOEXEC), accept4(), dup3() — সবগুলোর CLOEXEC-সহ সংস্করণ যোগ করেছে।

fd-র সীমা — তিনটা ভিন্ন সংখ্যা, দুইটা ভিন্ন errno

“Too many open files” একটা সাধারণ production failure, আর প্রায় সবসময় ভুল জায়গায় খোঁজা হয়, কারণ আসলে তিনটা আলাদা সীমা আছে:

সীমাকোথায় দেখবেনScopeভাঙলে errno
RLIMIT_NOFILE softulimit -nপ্রতি processEMFILE (24)
RLIMIT_NOFILE hardulimit -Hnপ্রতি process (soft-এর সিলিং)— (soft বাড়াতে গেলে EPERM)
fs.nr_open/proc/sys/fs/nr_openপ্রতি process, কার্নেল-স্তরের চূড়ান্ত সিলিং (ডিফল্ট 1048576)EPERM (hard limit এর বেশি সেট করতে গেলে)
fs.file-max/proc/sys/fs/file-maxপুরো সিস্টেম (মোট struct file)ENFILE (23)

দুইটা errno আলাদা করে চেনা গুরুত্বপূর্ণ, কারণ সমাধান সম্পূর্ণ ভিন্ন:

  • EMFILE — “এই process-টা তার নিজের কোটা শেষ করেছে।” প্রায় সবসময় এটাই দেখবেন। সমাধান: ulimit -n বাড়ান (বা systemd unit-এ LimitNOFILE=), অথবা — বেশিরভাগ ক্ষেত্রেই সঠিক উত্তর — আপনার fd leak খুঁজে বের করুন
  • ENFILE — “পুরো মেশিন তার struct file কোটা শেষ করেছে।” আধুনিক কার্নেলে বিরল, কারণ file-max স্বয়ংক্রিয়ভাবে RAM-এর অনুপাতে সেট হয় (Linux 4.x থেকে ~১০% মেমরি, সাধারণত লক্ষ-কোটির ঘরে)।

ভেতরে কী ঘটছে

write(1, "hi", 2) — সাত স্তর নিচে

একটা একক write() কল কার্নেলের ভেতরে ঠিক কোথায় কোন টেবিল ছোঁয়, সেটা ধাপে ধাপে:

write(1, "hi", 2) -- fd থেকে ডিস্ক পর্যন্ত
  1. userspace: write(1, buf, 2)glibc-র পাতলা wrapper -- rax = 1 (__NR_write), rdi = 1 (fd), rsi = buf, rdx = 2, তারপর syscall instruction
  2. kernel entry: ksys_write() [fs/read_write.c]syscall table থেকে ডিসপ্যাচ; এখান থেকে সবকিছু kernel mode-এ, current->files হাতের কাছে
  3. স্তর ১ -- fdget_pos(1): fd table lookupcurrent->files->fdt->fd[1] পড়ে struct file * পাওয়া গেল, f_count বাড়ল (RCU-protected), আর f_pos_lock নেওয়া হলো -- এখানেই fd number থেকে object-এ রূপান্তর, একটা array index মাত্র
  4. স্তর ২ -- struct file: offset ও flagsfile->f_pos = 4096 (কতদূর লেখা হয়েছে), file->f_flags-এ O_APPEND আছে কি না দেখা হয়; O_APPEND থাকলে f_pos-কে উপেক্ষা করে i_size ব্যবহার হবে
  5. vfs_write() → file->f_op->write_iter()এই f_op পয়েন্টারটা inode থেকে এসেছে -- ext4 হলে ext4_file_write_iter, tmpfs হলে অন্য কিছু, tty হলে সম্পূর্ণ ভিন্ন। এটাই পরের-পরের লেসনের VFS abstraction
  6. স্তর ৩ -- struct inode: i_size, block mapinode_lock নিয়ে i_size আপডেট, নতুন ব্লক দরকার হলে allocator ডাকা, i_mtime/i_ctime সেট
  7. page cache → block layer → ডিস্কডেটা page cache-এ dirty হিসেবে বসে; write() এখানেই ফিরে আসে -- ডিস্কে যাওয়া অনেক পরে, writeback thread-এর হাতে (পরের-পরের লেসনের fsync আলোচনা)

লক্ষ করার মতো ব্যাপার — তিনটা স্তরই একটা একক write()-এ ছোঁয়া হয়, আর প্রতিটা স্তর ভিন্ন প্রশ্নের উত্তর দেয়: fd table বলে “কোন object”, struct file বলে “কোথায় লিখব আর কোন নিয়মে”, inode বলে “ফাইলটা আসলে কী আর ডেটা কোথায়”।

কার্নেলের প্রকৃত struct-গুলো

তিনটা টেবিলের C সংজ্ঞা (সরলীকৃত, কিন্তু ফিল্ডের নাম আসল):

/* স্তর ১ -- include/linux/fdtable.h */
struct fdtable {
    unsigned int max_fds;
    struct file __rcu **fd;       /* ← আসল array: fd[3] মানে fd নম্বর ৩ */
    unsigned long *close_on_exec; /* ← bitmap: কোন fd exec-এ বন্ধ হবে */
    unsigned long *open_fds;      /* ← bitmap: কোন ঘর ব্যবহৃত */
};
struct files_struct {
    atomic_t count;               /* কয়টা thread এই table শেয়ার করে */
    struct fdtable __rcu *fdt;
    spinlock_t file_lock;
    unsigned int next_fd;         /* ← "সবচেয়ে ছোট মুক্ত fd" খোঁজার hint */
    struct file __rcu *fd_array[NR_OPEN_DEFAULT];  /* ছোট process-এর জন্য
                                                      ইনলাইন array, ৬৪টা ঘর */
};

/* স্তর ২ -- include/linux/fs.h */
struct file {
    struct path       f_path;     /* dentry + mount -- পথ কোথা থেকে এসেছে */
    struct inode     *f_inode;    /* ← স্তর ৩-এর দিকে সরাসরি পয়েন্টার */
    const struct file_operations *f_op;
    atomic_long_t     f_count;    /* ← reference count: কয়টা fd + kernel ref */
    unsigned int      f_flags;    /* ← O_APPEND, O_NONBLOCK, ... */
    fmode_t           f_mode;     /* FMODE_READ / FMODE_WRITE */
    struct mutex      f_pos_lock; /* ← f_pos-এর সুরক্ষা (3.14-এ যোগ হয়) */
    loff_t            f_pos;      /* ★ file offset -- এই লেসনের নায়ক */
};

/* স্তর ৩ -- include/linux/fs.h */
struct inode {
    umode_t        i_mode;        /* type + permission */
    kuid_t         i_uid;
    unsigned long  i_ino;         /* ← inode number, ls -i যা দেখায় */
    loff_t         i_size;
    unsigned int   i_nlink;       /* ← hard link count */
    const struct inode_operations *i_op;
    const struct file_operations  *i_fop;  /* ← open() এখান থেকে f_op নেয় */
};

দুইটা ফিল্ড বিশেষভাবে খেয়াল করুন:

next_fd — “সবচেয়ে ছোট মুক্ত fd” নিয়মটা naive হলে O(n) স্ক্যান হতো। কার্নেল একটা hint রাখে (next_fd) আর open_fds bitmap-এ find_next_zero_bit() চালায়, তাই সাধারণ ক্ষেত্রে এটা প্রায় ধ্রুবক সময়।

f_count — এটাই close()-এর প্রকৃত semantics। close(fd) fd table-এর ঘরটা NULL করে আর f_count এক কমায়। শূন্যে না নামা পর্যন্ত open file description বেঁচেই থাকে। তাই dup করা বা fork করা অবস্থায় একটা fd close করলে ফাইল “বন্ধ” হয় না — অন্য reference টিকে থাকে।

/proc/PID/fd/ আর /proc/PID/fdinfo/N — টেবিল দুইটা লাইভ পড়া

Linux এই দুইটা টেবিল সরাসরি প্রকাশ করে, যা এই লেসনের সব দাবি নিজের চোখে যাচাই করার সুযোগ দেয়:

  • /proc/PID/fd/ — স্তর ১-এর সরাসরি প্রতিচ্ছবি। প্রতিটা ঘর একটা symlink, target হলো ফাইলের পথ (বা socket:[12345], pipe:[67890], anon_inode:[eventfd] — যাদের কোনো পথ নেই)।
  • /proc/PID/fdinfo/N — স্তর ২-এর সরাসরি প্রতিচ্ছবি: pos, flags, mnt_id, ino

একটা সাধারণ fdinfo আউটপুট:

pos:    4096
flags:  0100002
mnt_id: 29
ino:    918273

flags-টা octal, আর প্রতিটা বিট একটা O_* constant:

Octal মানFlagমন্তব্য
0O_RDONLYকোনো বিট না — তাই O_RDONLY কখনো “টেস্ট” করা যায় না bitmask দিয়ে
01O_WRONLY
02O_RDWR
02000O_APPEND
04000O_NONBLOCK
0100000O_LARGEFILE৬৪-বিট Linux-এ কার্নেল সবসময় সেট করে, তাই প্রায় প্রতিটা fdinfo-তে দেখবেন
02000000O_CLOEXEC

উপরের 0100002 তাই = O_LARGEFILE | O_RDWR — একটা সাধারণ read-write open, CLOEXEC ছাড়া।

“সবকিছুই ফাইল” — আর সৎ ব্যতিক্রমগুলো

Unix-এর সবচেয়ে বিখ্যাত ডিজাইন-নীতি: একটা fd-তে read()/write()/close() কাজ করে, সেটা যা-ই হোক না কেন। বাস্তবে Linux-এ একটা fd হতে পারে:

fd-র পেছনে কীকীভাবে তৈরি/proc/PID/fd/ কী দেখায়
সাধারণ ফাইলopen()/home/u/data.txt
Directoryopen(..., O_DIRECTORY)/etc
Pipepipe()pipe:[67890]
TCP/Unix socketsocket()socket:[12345]
Terminalopen("/dev/pts/0")/dev/pts/0
Timertimerfd_create()anon_inode:[timerfd]
Signalsignalfd()anon_inode:[signalfd]
Event countereventfd()anon_inode:[eventfd]
Process handlepidfd_open()anon_inode:[pidfd]
epoll instanceepoll_create1()anon_inode:[eventpoll]

এই তালিকার শেষ পাঁচটা লক্ষ করুন — timer, signal, process — এগুলো ঐতিহ্যগতভাবে ফাইলের সাথে সম্পর্কহীন ধারণা, কিন্তু Linux সেগুলোকেও fd বানিয়েছে। কারণ একটাই: fd হলেই epoll দিয়ে একসাথে অপেক্ষা করা যায় — যেটা লেসন ২০-এর মূল বিষয়।

কিন্তু নীতিটা ফাঁস হয়, আর সৎ থাকা জরুরি:

  1. Socket open() দিয়ে খোলা যায় না। কোনো pathname নেই — socket(AF_INET, SOCK_STREAM, 0) লাগে, তারপর bind, listen, connect, accept — চারটা syscall যাদের ফাইল-জগতে কোনো প্রতিরূপ নেই। Datagram socket-এ read/write যথেষ্টই না, কারণ প্রতিটা প্যাকেটের সাথে একটা ঠিকানা লাগে — তাই sendto/recvfrom। Plan 9 এই ফাঁকটা বন্ধ করার চেষ্টা করেছিল (সব কিছুকে সত্যিই filesystem namespace-এ এনে), Linux করেনি।
  2. ioctl()-এর অস্তিত্বই স্বীকারোক্তি। ioctl(fd, request, arg) মানে মূলত “এই device-এর এমন কিছু করা দরকার যা read/write দিয়ে প্রকাশ করা যায় না”। Terminal-এর baud rate, ব্লক-ডিভাইসের size, নেটওয়ার্ক-ইন্টারফেসের flag — সব ioctl। এটা abstraction-এর escape hatch, আর Linux-এ কয়েক হাজার ভিন্ন ioctl কমান্ড আছে, প্রতিটা device-নির্দিষ্ট।
  3. সব fd seekable না। Pipe, socket, terminal-এ lseek() ESPIPE দেয়। তাই “প্রতিটা fd-র একটা offset আছে” কথাটাও পুরোপুরি সত্য না — offset-এর ঘর আছে, কিন্তু অনেক ক্ষেত্রে তার কোনো অর্থ নেই।

উদাহরণ

একটা পূর্ণ trace — চার ধাপে টেবিল কীভাবে বদলায়

/tmp/x.txt শুরুতে নেই। নিচের চারটা ধাপ চালানোর সময় তিনটা টেবিলের প্রকৃত অবস্থা:

int a = open("/tmp/x.txt", O_RDWR | O_CREAT | O_TRUNC, 0644);  /* ধাপ ১ */
int b = open("/tmp/x.txt", O_RDWR);                            /* ধাপ ২ */
int c = dup(a);                                                /* ধাপ ৩ */
write(a, "HELLO", 5);                                          /* ধাপ ৪ */
write(b, "xy",    2);                                          /* ধাপ ৫ */
write(c, "!!",    2);                                          /* ধাপ ৬ */

ধরে নিই process-টার fd 0/1/2 আগে থেকেই দখল করা, তাই নতুন fd শুরু হবে 3 থেকে।

ধাপের পরfd tableopen file tableinode 918273 (i_size)ফাইলের বিষয়বস্তু
১ (a=open)3 → #1#1: pos=0, RDWR, count=10(খালি)
২ (b=open)3 → #1, 4 → #2#1: pos=0, count=1
#2: pos=0, count=1
0(খালি)
৩ (c=dup(a))3 → #1, 4 → #2, 5 → #1#1: pos=0, count=2
#2: pos=0, count=1
0(খালি)
৪ (write(a,"HELLO"))অপরিবর্তিত#1: pos=5
#2: pos=0
5HELLO
৫ (write(b,"xy"))অপরিবর্তিত#1: pos=5
#2: pos=2
5xyLLOচাপা পড়ল!
৬ (write(c,"!!"))অপরিবর্তিত#1: pos=7
#2: pos=2
7xyLLO!!

তিনটা পর্যবেক্ষণ, তিনটাই ছবিটার সরাসরি ফল:

  • ধাপ ৩-এ কোনো নতুন struct file তৈরি হয়নি — শুধু count ১ থেকে ২ হয়েছে। dup() তিন স্তরের মাঝেরটায় কিছুই যোগ করে না।
  • ধাপ ৫-এ b লিখেছে offset 0 থেকে, কারণ #2-র pos কখনো এগোয়নি — a-র লেখা #1-এর pos বাড়িয়েছে, #2-র না। এই দুইটা description একে অপরের অস্তিত্বই জানে না।
  • ধাপ ৬-এ c লিখেছে offset 5 থেকে, ঠিক a যেখানে থেমেছিল — কারণ a আর c একই #1 ব্যবহার করছে। এটাই ইন্ট্রোর দ্বিতীয় প্রোগ্রামের ব্যাখ্যা।

এখন একটা চতুর্থ ধাপ যোগ করুন:

close(a);              /* ধাপ ৭ */
write(c, "??", 2);     /* ধাপ ৮ -- এটা কি কাজ করবে? */

করবে। close(a) fd table-এর ৩ নম্বর ঘর NULL করে দেয় আর #1-এর count ২ থেকে ১-এ নামায় — শূন্য না, তাই description বেঁচে আছে, pos এখনো ৭। write(c, "??", 2) সফলভাবে offset ৭-এ লিখবে, ফাইল হবে xyLLO!!??, ৯ বাইট।

i_sizefinal=max(সব description-এর সর্বোচ্চ পৌঁছানো offset)=max(9, 2)=9\text{i\_size}_{\text{final}} = \max(\text{সব description-এর সর্বোচ্চ পৌঁছানো offset}) = \max(9,\ 2) = 9

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

EXPERIMENT

একই ফাইল দুইবার open বনাম একবার dup -- কোনটা offset শেয়ার করে, /proc দিয়ে প্রমাণ

Linux (bash, procfs)· ১৫ মিনিট

Bash-এর exec N< file সরাসরি shell-এর নিজের process-এ একটা fd খোলে, তাই কোনো C কোড ছাড়াই তিন স্তর পরিদর্শন করা যায়।

# ধাপ ১ -- একটা পরিচিত আকারের ফাইল বানাই
printf 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' > /tmp/fdlab.txt
wc -c /tmp/fdlab.txt
36 /tmp/fdlab.txt
# ধাপ ২ -- fd 7-এ খুলি, তারপর fd 8 = dup(7), fd 9 = আলাদা একটা open
exec 7< /tmp/fdlab.txt      # প্রথম open
exec 8<&7                   # dup -- একই description
exec 9< /tmp/fdlab.txt      # দ্বিতীয়, স্বাধীন open

# ধাপ ৩ -- fd table (স্তর ১) দেখি
ls -l /proc/$$/fd/7 /proc/$$/fd/8 /proc/$$/fd/9
lr-x------ 1 u u 64 Aug 20 14:07 /proc/2914/fd/7 -> /tmp/fdlab.txt
lr-x------ 1 u u 64 Aug 20 14:07 /proc/2914/fd/8 -> /tmp/fdlab.txt
lr-x------ 1 u u 64 Aug 20 14:07 /proc/2914/fd/9 -> /tmp/fdlab.txt

তিনটাই একই ফাইলে ইশারা করছে — স্তর ১ থেকে ৭, ৮, ৯-এর মধ্যে কোনো পার্থক্যই বোঝা যাচ্ছে না। পার্থক্যটা স্তর ২-এ:

# ধাপ ৪ -- fd 7 দিয়ে ঠিক ১০ বাইট পড়ি (dd bs=1 count=10 নিশ্চিতভাবে ১০ বাইটই পড়ে)
dd bs=1 count=10 <&7 2>/dev/null; echo

# ধাপ ৫ -- এখন তিনটার offset (স্তর ২) দেখি
for n in 7 8 9; do echo "--- fd $n ---"; cat /proc/$$/fdinfo/$n; done
ABCDEFGHIJ
--- fd 7 ---
pos:	10
flags:	0100000
mnt_id:	29
ino:	4218903
--- fd 8 ---
pos:	10
flags:	0100000
mnt_id:	29
ino:	4218903
--- fd 9 ---
pos:	0
flags:	0100000
mnt_id:	29
ino:	4218903

এটাই পুরো লেসনের এক-স্ক্রিন প্রমাণ। তিনটা fd-র ino: অভিন্ন (একই inode, স্তর ৩), কিন্তু:

  • fd 7 আর fd 8-এর pos: 10একটাই description, একজনের পড়া অন্যজনের offset এগিয়ে দিয়েছে
  • fd 9-এর pos: 0স্বাধীন description, fd 7-এর পড়া তার কিছুই বদলায়নি
# ধাপ ৬ -- নিশ্চিত করি: fd 8 দিয়ে পড়লে ধারাবাহিকভাবে পরের বাইটগুলো আসে,
# আর fd 9 দিয়ে পড়লে আবার শুরু থেকে
echo -n "fd 8 পড়ছে: "; dd bs=1 count=5 <&8 2>/dev/null; echo
echo -n "fd 9 পড়ছে: "; dd bs=1 count=5 <&9 2>/dev/null; echo

exec 7<&-; exec 8<&-; exec 9<&-      # তিনটাই বন্ধ
fd 8 পড়ছে: KLMNO
fd 9 পড়ছে: ABCDE

flags: 0100000 মানে O_LARGEFILE | O_RDONLY — ৬৪-বিট Linux-এ O_LARGEFILE সবসময় থাকে, তাই এটাই একটা সাধারণ read-only open।

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

File offset fd-র বৈশিষ্ট্য না, open file description-এর বৈশিষ্ট্য। dup-করা fd একই description ভাগ করে বলে একটার offset এগোলে অন্যটারও এগোয়; দুইবার open করা fd দুইটা স্বাধীন description পায় বলে তাদের offset সম্পূর্ণ আলাদা থাকে -- /proc/PID/fdinfo-তে pos ফিল্ডেই পার্থক্যটা সরাসরি দেখা যায়।

EXPERIMENT

fd limit ভেঙে EMFILE ধরুন, আর একটা fd exec-এর ওপারে leak হতে দেখুন

Linux (bash, python3)· ১৫ মিনিট

অংশ ক — তিনটা সীমা দেখুন, তারপর ভাঙুন

ulimit -n                       # soft limit -- এটাই কার্যকর সীমা
ulimit -Hn                      # hard limit -- soft এর সিলিং
cat /proc/sys/fs/nr_open        # কার্নেলের চূড়ান্ত per-process সিলিং
cat /proc/sys/fs/file-max       # পুরো সিস্টেমের struct file কোটা
1024
524288
1048576
9223372036854775807

(file-max-এর ঐ বিশাল সংখ্যাটা আধুনিক কার্নেলে স্বাভাবিক — Linux 5.4 থেকে এটা কার্যত সীমাহীন করে দেওয়া হয়েছে, কারণ প্রকৃত সীমা এখন memory cgroup।)

# soft limit ইচ্ছা করে ছোট করে নিই, যাতে দ্রুত ভাঙে
bash -c 'ulimit -n 64; python3 - <<"PY"
import os, resource, errno
soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
print("soft =", soft, " hard =", hard)
fds = []
try:
    while True:
        fds.append(os.open("/etc/hostname", os.O_RDONLY))
except OSError as e:
    print("থেমে গেল %d-টা অতিরিক্ত fd খোলার পর" % len(fds))
    print("errno =", e.errno, "(", errno.errorcode[e.errno], ") --", e.strerror)
    print("সর্বোচ্চ fd নম্বর =", max(fds))
PY'
soft = 64  hard = 524288
থেমে গেল 58-টা অতিরিক্ত fd খোলার পর
errno = 24 ( EMFILE ) -- Too many open files
সর্বোচ্চ fd নম্বর = 63

সংখ্যাগুলো মিলিয়ে দেখুন: সীমা ৬৪, সর্বোচ্চ fd নম্বর ৬৩ (কারণ গণনা শূন্য থেকে) — অর্থাৎ ০ থেকে ৬৩, মোট ৬৪টা ঘরই ভরাট। আমরা নতুন খুলতে পেরেছি ৫৮টা, কারণ বাকি ৬টা আগে থেকেই দখলে ছিল (stdin/stdout/stderr, Python-এর নিজস্ব কিছু)। সীমাটা “কতগুলো নতুন খুলতে পারবেন” না, “মোট কতগুলো খোলা থাকতে পারবে” — production-এ fd leak ডিবাগ করার সময় এই পার্থক্যটা গুরুত্বপূর্ণ।

অংশ খ — fd exec-এর ওপারে বেঁচে থাকে

# একটা fd খুলি (bash-এর exec N< ডিফল্টে CLOEXEC সেট করে না)
exec 7< /etc/hostname

# এখন একটা সম্পূর্ণ নতুন প্রোগ্রাম exec করি এবং তার fd দেখি।
# bash এখানে fork+exec করবে -- fd 7 কি টিকে থাকবে?
bash -c 'ls -l /proc/self/fd/ | grep -v " 255 \|total"'
lrwx------ 1 u u 64 Aug 20 14:12 0 -> /dev/pts/0
lrwx------ 1 u u 64 Aug 20 14:12 1 -> /dev/pts/0
lrwx------ 1 u u 64 Aug 20 14:12 2 -> /dev/pts/0
lr-x------ 1 u u 64 Aug 20 14:12 7 -> /etc/hostname

fd 7 সেখানে আছে। একটা সম্পূর্ণ ভিন্ন প্রোগ্রাম (ls), যার সাথে মূল shell-এর কোনো সম্পর্ক নেই, /etc/hostname পড়তে পারে — শুধু কারণ fd-টা উত্তরাধিকার সূত্রে এসেছে। কল্পনা করুন /etc/hostname-এর জায়গায় একটা TLS private key।

# এবার CLOEXEC সেট করে একই পরীক্ষা
# (bash-এ সরাসরি O_CLOEXEC-এর syntax নেই, তাই fcntl দিয়ে python3 ব্যবহার করি)
python3 -c '
import os, fcntl, subprocess
fd = os.open("/etc/hostname", os.O_RDONLY)          # CLOEXEC ছাড়া
fd2 = os.open("/etc/hostname", os.O_RDONLY | os.O_CLOEXEC)
os.set_inheritable(fd, True)                         # Python ডিফল্টে CLOEXEC দেয়
print("খোলা fd:", fd, "(inheritable) ও", fd2, "(CLOEXEC)")
subprocess.run(["bash", "-c", "ls /proc/self/fd/"], close_fds=False)
'
খোলা fd: 3 (inheritable) ও 4 (CLOEXEC)
0  1  2  3

fd 3 উত্তরাধিকার পেয়েছে, fd 4 পায়নিO_CLOEXEC কার্নেলকে বলে দিয়েছিল execve()-এর সময় ঐ ঘরটা বন্ধ করে দিতে।

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

RLIMIT_NOFILE একটা প্রকৃত, কঠিন প্রাচীর -- ভাঙলে open() ব্যর্থ হয় errno 24 (EMFILE) দিয়ে, আর সেই সীমায় ইতিমধ্যে খোলা fd 0/1/2 সহ সব গোনা হয়। আর O_CLOEXEC ছাড়া খোলা fd সত্যিই exec-এর ওপারে টিকে যায়, নতুন প্রোগ্রামের কাছে পুরো access সহ -- যা একটা বাস্তব নিরাপত্তা-ঝুঁকি।

নিজে বানান

BUILD IT

তিন স্তর নিজের প্রোগ্রামে দেখুন -- দুইবার open, একবার dup, fdinfo-তে pos মেলান

C (POSIX) · ●●●○○
  1. একই ফাইলে দুইবার open() করুন আর একটা fd dup() করুন -- তিনটা fd, কিন্তু মাত্র দুইটা open file description
  2. /proc/self/fdinfo/<n> পড়ে প্রতিটা fd-র প্রকৃত pos আর flags তুলে আনুন (macOS-এ fallback হিসেবে lseek ব্যবহার করুন)
  3. fstat() দিয়ে (st_dev, st_ino) জোড়া বের করুন -- এটাই স্তর ৩-এর পরিচয়, তিনটার একই হওয়া উচিত
  4. প্রতিটা fd দিয়ে আলাদা আলাদা write() করুন, আর প্রতিবার তিনটারই pos ছাপান -- কোনগুলো একসাথে নড়ে দেখুন
  5. শেষে একটা fd close() করে দেখুন অন্যটা এখনো কাজ করে কি না, আর ফাইলের চূড়ান্ত বিষয়বস্তু ছাপান
/* fdtables.c -- তিন স্তরের টেবিল নিজের চোখে।
 *
 *   কম্পাইল:  cc -Wall -Wextra -O2 -o fdtables fdtables.c
 *   চালান:    ./fdtables
 *
 * যা প্রমাণ করে: dup() করা fd আর দুইবার open() করা fd -- দুটোই একই
 * inode দেখায়, কিন্তু শুধু dup-করাটা offset শেয়ার করে।
 */
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <sys/stat.h>

#define PATH "/tmp/fdlab_build.txt"

/* /proc/self/fdinfo/<fd> থেকে pos আর flags পড়ি -- এটা কার্নেলের
   struct file-এর f_pos আর f_flags-এর সরাসরি প্রতিফলন (স্তর ২)।
   /proc না থাকলে (macOS/BSD) lseek দিয়ে offset বের করি -- flags
   তখন fcntl(F_GETFL) থেকে। */
static void read_fdinfo(int fd, long long *pos, unsigned *flags)
{
    char path[64], line[160];
    FILE *f;

    *pos = -1;
    *flags = 0;

    snprintf(path, sizeof path, "/proc/self/fdinfo/%d", fd);
    f = fopen(path, "r");
    if (!f) {                                   /* procfs নেই -- fallback */
        *pos = (long long)lseek(fd, 0, SEEK_CUR);
        int fl = fcntl(fd, F_GETFL);
        if (fl >= 0) *flags = (unsigned)fl;
        return;
    }
    while (fgets(line, sizeof line, f)) {
        if (strncmp(line, "pos:", 4) == 0)
            *pos = strtoll(line + 4, NULL, 10);
        else if (strncmp(line, "flags:", 6) == 0)
            *flags = (unsigned)strtoul(line + 6, NULL, 8);   /* octal! */
    }
    fclose(f);
}

/* একটা fd-র তিন স্তরের অবস্থা এক লাইনে */
static void show(const char *label, int fd)
{
    long long pos;
    unsigned flags;
    struct stat st;

    read_fdinfo(fd, &pos, &flags);
    if (fstat(fd, &st) != 0) { perror("fstat"); return; }

    printf("  %-16s fd=%-2d | pos=%-4lld | flags=0%-9o | dev=%llu ino=%llu\n",
           label, fd, pos, flags,
           (unsigned long long)st.st_dev,
           (unsigned long long)st.st_ino);
}

static void banner(const char *msg)
{
    printf("\n== %s\n", msg);
}

int main(void)
{
    unlink(PATH);                                    /* পরিষ্কার শুরু */

    /* স্তর ২-তে দুইটা আলাদা entry: প্রতিটা open() একটা করে বানায় */
    int a = open(PATH, O_RDWR | O_CREAT | O_TRUNC | O_CLOEXEC, 0644);
    int b = open(PATH, O_RDWR | O_CLOEXEC);          /* একই ফাইল, আবার */
    /* স্তর ১-এ শুধু একটা নতুন ঘর: dup নতুন description বানায় না */
    int c = dup(a);

    if (a < 0 || b < 0 || c < 0) { perror("open/dup"); return 1; }

    banner("শুরুতে -- তিনটাই offset 0, তিনটাই একই (dev, ino)");
    show("a = open()",  a);
    show("b = open()",  b);
    show("c = dup(a)",  c);

    banner("write(a, \"HELLO\", 5) -- a আর c একসাথে নড়বে, b নড়বে না");
    if (write(a, "HELLO", 5) != 5) { perror("write a"); return 1; }
    show("a", a); show("b", b); show("c", c);

    banner("write(b, \"xy\", 2) -- b নিজের offset 0 থেকে লিখছে, চাপা পড়বে");
    if (write(b, "xy", 2) != 2) { perror("write b"); return 1; }
    show("a", a); show("b", b); show("c", c);

    banner("write(c, \"!!\", 2) -- c লিখছে a যেখানে থেমেছিল ঠিক সেখান থেকে");
    if (write(c, "!!", 2) != 2) { perror("write c"); return 1; }
    show("a", a); show("b", b); show("c", c);

    banner("close(a) -- description মরে না, কারণ c এখনো ধরে আছে (f_count 2→1)");
    close(a);
    if (write(c, "??", 2) != 2) { perror("write c after close(a)"); return 1; }
    show("b", b); show("c", c);

    /* ফাইলের চূড়ান্ত বিষয়বস্তু */
    char buf[64] = {0};
    int r = open(PATH, O_RDONLY | O_CLOEXEC);
    ssize_t n = read(r, buf, sizeof buf - 1);
    struct stat st;
    fstat(r, &st);
    printf("\n== চূড়ান্ত ফাইল: \"%s\"  (%zd বাইট পড়া, i_size = %lld)\n",
           buf, n, (long long)st.st_size);
    printf("   মোট লেখা হয়েছিল 5+2+2+2 = 11 বাইট, কিন্তু ফাইল %lld বাইট --\n"
           "   কারণ b-র লেখা a-র লেখার উপরেই পড়েছে।\n",
           (long long)st.st_size);

    close(b); close(c); close(r);
    return 0;
}

প্রত্যাশিত আউটপুট (Linux, ext4-এ /tmp):

== শুরুতে -- তিনটাই offset 0, তিনটাই একই (dev, ino)
  a = open()       fd=3  | pos=0    | flags=02100002  | dev=2049 ino=4218917
  b = open()       fd=4  | pos=0    | flags=02100002  | dev=2049 ino=4218917
  c = dup(a)       fd=5  | pos=0    | flags=0100002   | dev=2049 ino=4218917

== write(a, "HELLO", 5) -- a আর c একসাথে নড়বে, b নড়বে না
  a                fd=3  | pos=5    | flags=02100002  | dev=2049 ino=4218917
  b                fd=4  | pos=0    | flags=02100002  | dev=2049 ino=4218917
  c                fd=5  | pos=5    | flags=0100002   | dev=2049 ino=4218917

== write(b, "xy", 2) -- b নিজের offset 0 থেকে লিখছে, চাপা পড়বে
  a                fd=3  | pos=5    | flags=02100002  | dev=2049 ino=4218917
  b                fd=4  | pos=2    | flags=02100002  | dev=2049 ino=4218917
  c                fd=5  | pos=5    | flags=0100002   | dev=2049 ino=4218917

== write(c, "!!", 2) -- c লিখছে a যেখানে থেমেছিল ঠিক সেখান থেকে
  a                fd=3  | pos=7    | flags=02100002  | dev=2049 ino=4218917
  b                fd=4  | pos=2    | flags=02100002  | dev=2049 ino=4218917
  c                fd=5  | pos=7    | flags=0100002   | dev=2049 ino=4218917

== close(a) -- description মরে না, কারণ c এখনো ধরে আছে (f_count 2→1)
  b                fd=4  | pos=2    | flags=02100002  | dev=2049 ino=4218917
  c                fd=5  | pos=9    | flags=0100002   | dev=2049 ino=4218917

== চূড়ান্ত ফাইল: "xyLLO!!??"  (9 বাইট পড়া, i_size = 9)
   মোট লেখা হয়েছিল 5+2+2+2 = 11 বাইট, কিন্তু ফাইল 9 বাইট --
   কারণ b-র লেখা a-র লেখার উপরেই পড়েছে।

আউটপুটের তিনটা সূক্ষ্মতা যাচাই করুন:

  • dev আর ino তিনটা fd-তেই অভিন্ন — স্তর ৩ একটাই। এই (device, inode) জোড়াই ফাইলের প্রকৃত পরিচয়, পথ না; পরের লেসনে এটাই hard link সনাক্ত করার ভিত্তি হবে।
  • a আর c-র pos সবসময় একসাথে নড়ে, b-রটা স্বাধীন — স্তর ২-এ দুইটা entry।
  • c-র flags-এ 02000000 (O_CLOEXEC) নেই, a-তে আছে — কারণ dup() স্পষ্টভাবে close-on-exec flag কপি করে না (man 2 dup: “the close-on-exec flag for the duplicate descriptor is off”). একই description, কিন্তু স্তর ১-এর flag আলাদা — CLOEXEC যে সত্যিই fd-র বৈশিষ্ট্য, description-এর না, তার সরাসরি প্রমাণ। CLOEXEC সহ dup চাইলে dup3(a, newfd, O_CLOEXEC) ব্যবহার করুন।

নিজে বাড়ান

  1. O_APPEND যোগ করুন। b-কে O_RDWR | O_APPEND দিয়ে খুলুন আর একই ক্রম চালান। এখন চূড়ান্ত ফাইল কত বাইট, আর বিষয়বস্তু কী? ব্যাখ্যা করুন কেন b-র pos এখন প্রতিটা write-এর পরে ফাইলের শেষে চলে যায়।
  2. fork() যোগ করুন। write(a, ...)-এর আগে fork() করুন, parent আর child দুজনেই a-তে ৫ বাইট লিখুক। দুজনের লেখা কি একে অপরকে চাপা দেবে? আগে ভবিষ্যদ্বাণী করুন, তারপর চালিয়ে দেখুন — আর /proc/PID/fdinfo দুইটা PID-এই পড়ে ব্যাখ্যা করুন।
  3. Race-টা বাস্তবে ঘটান। দুইটা process ২০০০ বার করে lseek(fd,0,SEEK_END) + write(fd, "0123456789\n", 11) চালাক। প্রত্যাশিত আকার ৪০০০×১১ = ৪৪০০০ বাইট। প্রকৃত আকার মাপুন (wc -c), হারানো বাইট গুনুন। তারপর O_APPEND দিয়ে একই পরীক্ষা — এবার হুবহু ৪৪০০০ পাওয়া উচিত।
  4. fd leak ধরার টুল বানান। একটা count_fds() ফাংশন লিখুন যেটা /proc/self/fd directory-র entry গুনে ফেরত দেয়। আপনার প্রোগ্রামের একটা লুপে প্রতিবার open() করুন কিন্তু close() করবেন না, আর প্রতি ১০০ iteration-এ গণনা ছাপান — EMFILE আসার আগেই leak-এর ঢাল দেখতে পাবেন।
  5. dup2 দিয়ে redirection লিখুন। fork() করে child-এ dup2(logfd, 1) করে execlp("date", "date", NULL) চালান। প্রমাণ করুন date-এর আউটপুট ফাইলে গেছে, আর ব্যাখ্যা করুন execlp-এর পরেও redirection টিকে থাকল কেন (ইঙ্গিত: files_struct execve()-তে বদলায় না)।

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

যেখানে এই তিন স্তর প্রতিদিন কামড় দেয়

nginx আর worker_rlimit_nofile — কেন দুইটা আলাদা সেটিং লাগে। nginx-এর worker_connections 10240; লিখে দিলেই কাজ হয় না; সাথে worker_rlimit_nofile 20480; লাগে। কারণ প্রতিটা proxied connection দুইটা fd খায় — একটা client-এর socket, একটা upstream-এর socket — সাথে log ফাইল, cache ফাইল, /dev/urandom। ভুল হিসাব করলে error log-এ আসে accept4() failed (24: Too many open files) — সেই EMFILE, ঠিক আমাদের experiment-এর মতো, আর প্রতিটা ব্যর্থ accept মানে একটা প্রত্যাখ্যাত ব্যবহারকারী।

Redis-এর maxclients নিজে থেকে কমে যাওয়া। Redis চালু হওয়ার সময় getrlimit(RLIMIT_NOFILE) পড়ে, আর সীমা কম হলে নিজের maxclients কমিয়ে দেয় এবং লগে লেখে: You requested maxclients of 10000 requiring at least 10032 max file descriptors. Server can't set maximum open files to 10032 because of OS error. Current maximum open files is 4096. maxclients has been reduced to 4064. — অতিরিক্ত ৩২টা Redis-এর নিজের অভ্যন্তরীণ fd-র জন্য সংরক্ষিত, ঠিক যেমন আমাদের experiment-এ Python-এর ৬টা ঘর আগে থেকে দখলে ছিল।

Elasticsearch-এর bootstrap check — সীমা কম থাকলে চালুই হয় না। Elasticsearch production mode-এ চালু হওয়ার আগে যাচাই করে RLIMIT_NOFILE >= 65536; না হলে সরাসরি ব্যর্থ হয়ে থামে (max file descriptors [4096] for elasticsearch process is too low, increase to at least [65535])। কারণ Lucene প্রতিটা shard-এর প্রতিটা segment-এর জন্য fd ধরে রাখে — একটা index-এ শত শত।

CVE-2019-5736 — runc container escape, একটা fd দিয়ে। সবচেয়ে বিখ্যাত fd-ভিত্তিক দুর্বলতা। Container-এর ভেতরের একটা প্রক্রিয়া /proc/self/exe (যেটা host-এর runc বাইনারি) খুলে ফেলত, তারপর runc exec চলাকালীন সেই খোলা fd-তে লিখে host-এর runc বাইনারিকেই ওভাররাইট করে দিত — পরের বার যে কেউ runc চালালেই আক্রমণকারীর কোড root হিসেবে চলত, container থেকে সম্পূর্ণ পালানো। সমাধানে runc এখন নিজের একটা memfd কপি বানিয়ে সেটা থেকে চলে। এই আক্রমণের পুরো ভিত্তি fd একটা টিকে থাকা reference, শুধু একটা নাম না — inode-টা যতক্ষণ কেউ fd দিয়ে ধরে আছে, ততক্ষণ সে বেঁচে আছে এবং লেখা যায়।

CVE-2016-9962 — runc/Docker-এ fd inheritance দিয়ে escape। আরেকটা: docker exec চালানোর সময় host-এর init process-এর কিছু fd container-এর process-এ leak হতো (setns-এর পরে ঠিকমতো বন্ধ না করায়), আর সেগুলো দিয়ে host filesystem-এ পৌঁছানো যেত। ঠিক এই লেসনের “CLOEXEC ছাড়া fd exec-এর ওপারে টিকে থাকে” সমস্যাটাই, container-এর প্রেক্ষাপটে। Level 12-এর cloud/virtualization module-এ namespace আর container isolation-এর আলোচনায় এই শ্রেণির leak ফিরে আসবে।

systemd-এর LimitNOFILE=infinity আর ধীর process-চালু। কিছু distribution একসময় সব service-এ LimitNOFILE=infinity (মানে nr_open, ১০৪৮৫৭৬) দিয়েছিল “নিরাপদ” ভেবে। ফল উল্টো হলো — বহু প্রোগ্রাম (বিশেষত পুরনো Java, Python-এর subprocess, shell script) exec-এর আগে 0 থেকে RLIMIT_NOFILE পর্যন্ত লুপ চালিয়ে সব fd বন্ধ করত, আর সেই লুপ এক মিলিয়ন ব্যর্থ syscall-এ পরিণত হলো। কিছু ক্ষেত্রে প্রতিটা subprocess চালু হতে কয়েকশো মিলিসেকেন্ড বেশি লাগত। এটাই close_range() (Linux 5.9) যোগ করার প্রত্যক্ষ প্রেরণা ছিল, আর systemd এখন soft limit ১০২৪-এ রেখে hard limit বড় রাখে (DefaultLimitNOFILE=1024:524288) — যাতে যে প্রোগ্রাম চায় সে নিজে বাড়িয়ে নেয়।

lsof আর ss — দুইটাই মূলত /proc/PID/fd পড়ে। Production-এ “কোন process fd খেয়ে ফেলছে” খোঁজার আদর্শ কমান্ড: lsof -p PID | wc -l, বা আরো দ্রুত ls /proc/PID/fd | wc -l। আর “কোন process সবচেয়ে বেশি খাচ্ছে” — for p in /proc/[0-9]*; do echo "$(ls $p/fd 2>/dev/null | wc -l) $p"; done | sort -rn | head। এই এক লাইনে আপনি ঠিক স্তর ১-এর আকার মাপছেন, প্রতিটা process-এর জন্য।

Go আর Java runtime-এ সব fd ডিফল্টে CLOEXEC। Go-র os.Open সবসময় O_CLOEXEC দেয়, Java 7+ এবং Python 3.4+ (PEP 446) একই কাজ করে — কারণ এই leak-টা এত সাধারণ আর এত বিপজ্জনক ছিল যে ভাষার runtime-গুলো ডিফল্ট উল্টে দিয়েছে। C-ই এখন প্রধান ব্যতিক্রম, ঐতিহাসিক সামঞ্জস্যের কারণে — তাই C লিখলে O_CLOEXEC আপনাকে নিজে মনে রাখতে হবে।

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

“প্রতিটা file descriptor-এর নিজস্ব আলাদা file offset আছে।”

এটাই এই লেসনের কেন্দ্রীয় ভুল ধারণা, আর এটা তিন স্তরকে দুই স্তরে চেপে ফেলা থেকে আসে।

Offset (f_pos) থাকে open file description-এ (স্তর ২, কার্নেলের struct file), file descriptor-এ (স্তর ১) না। একটা fd শুধু একটা description-এর দিকে একটা pointer — আর একাধিক fd একই description দেখাতে পারে

তিনটা ক্ষেত্রে fd-রা offset শেয়ার করে: dup()/dup2()/dup3() করলে, fork()-এর পরে parent-child-এর মধ্যে, আর Unix domain socket-এর SCM_RIGHTS দিয়ে fd পাঠালে। শুধু আলাদা open() কলই আলাদা description বানায়, তাই শুধু সেখানেই offset স্বাধীন।

এই লেসনের প্রথম experiment-এ fd 7 আর fd 8-এ pos: 10 আর fd 9-এ pos: 0 — একই ফাইল, একই inode, তবু ভিন্ন offset — এটাই সরাসরি প্রমাণ।

“fork() করলে child তার নিজের fd-র কপি পায়, তাই parent আর child একে অপরের offset-এ প্রভাব ফেলে না।”

অর্ধেক সত্য, আর সেই অর্ধেকটাই বিপজ্জনক। fork() fd table (স্তর ১) কপি করে — child পায় নিজস্ব files_struct, নিজস্ব close-on-exec bitmap, তাই child-এ close(3) করলে parent-এর fd 3 অক্ষত থাকে। এইটুকু ঠিক।

কিন্তু কপি করা table-এর প্রতিটা ঘর একই struct file-এর দিকেই ইশারা করে (স্তর ২ কপি হয় না, শুধু f_count বাড়ে)। তাই offset শেয়ার হয় — child ১০০ বাইট লিখলে parent-এর পরের write ঠিক তার পরে বসবে।

এটা bug না, feature — shell-এর (echo a; echo b) > f আর প্রতিটা multi-process logging system এই আচরণের উপরেই দাঁড়িয়ে। কিন্তু না জানলে দুই দিকেই ভুল হয়: কেউ ভাবে “শেয়ার হয় না” বলে অকারণে locking যোগ করে, আবার কেউ ভাবে “fork করলেই নিরাপদ” বলে O_APPEND বাদ দেয় — যেটা fork-এর ক্ষেত্রে ঠিক আছে কিন্তু আলাদা open()-করা দুইটা process-এর ক্ষেত্রে সর্বনাশ।

“close(fd) মানে ফাইলটা বন্ধ হয়ে গেল আর ডেটা ডিস্কে নিরাপদে লেখা হলো।”

দুইটা আলাদা ভুল একসাথে।

(১) close() ফাইল “বন্ধ” করে না, একটা reference কমায়। এটা স্তর ১-এর ঘরটা NULL করে আর স্তর ২-এর f_count এক কমায়। f_count শূন্যে না নামা পর্যন্ত description বেঁচে থাকে — dup করা fd, forked child, বা SCM_RIGHTS-এ পাঠানো fd থাকলে ফাইল খোলাই থাকে। এই লেসনের build প্রোগ্রামে close(a)-র পরেও c দিয়ে লেখা গেছে, সেটাই প্রমাণ। একই কারণে একটা মুছে ফেলা (unlink) ফাইল ডিস্কের জায়গা ছাড়ে না যতক্ষণ কেউ fd ধরে আছে — production-এ “df বলছে ডিস্ক ভরা, du বলছে খালি” রহস্যের সবচেয়ে সাধারণ কারণ (lsof +L1 দিয়ে ধরা যায়)।

(২) close() কোনো durability গ্যারান্টি দেয় না। ডেটা page cache-এ dirty অবস্থায় থাকতে পারে; close() তা flush করে না, অপেক্ষাও করে না। Durability-র জন্য fsync() লাগে — আর close()-এর return value চেক না করলে NFS-এর মতো ফাইলসিস্টেমে write error সম্পূর্ণ নীরবে হারিয়ে যেতে পারে। লেসন ১৮-এ (ext4-and-journaling) এই durability contract-টা পুরোপুরি খোলা হবে, সাথে “fsync করেও কেন ডেটা হারাতে পারেন” সেই গল্পটাও।

“Unix-এ সবকিছুই ফাইল, তাই socket-ও open() দিয়ে খোলা যায় আর read/write দিয়েই সব কাজ চলে।”

“Everything is a file” একটা শক্তিশালী রূপক, আক্ষরিক সত্য না — আর সীমাগুলো জানা না থাকলে API খুঁজতে গিয়ে হতাশ হবেন।

Socket-এর কোনো pathname নেই, তাই open() অচল — socket(2) লাগে, তারপর bind/listen/accept/connect, যাদের ফাইল-জগতে কোনো প্রতিরূপ নেই। UDP-তে প্রতিটা datagram-এর সাথে একটা peer address লাগে, যা read/write-এর signature-এ বসানোর জায়গাই নেই — তাই recvfrom/sendto। Socket option (SO_REUSEADDR, TCP_NODELAY) সেট করতে setsockopt — আরেকটা সমান্তরাল API।

ioctl()-এর অস্তিত্বই এই ফাঁসের সবচেয়ে সৎ স্বীকৃতি: “এই device-এর এমন কিছু দরকার যা read/write-এ প্রকাশ করা যায় না” — terminal-এর window size, ব্লক-ডিভাইসের সেক্টর-সংখ্যা, GPU-র কমান্ড সাবমিশন, সবই ioctl, আর Linux-এ কয়েক হাজার আলাদা ioctl কমান্ড আছে।

যেটা সত্যিই সর্বজনীন তা হলো fd নিজেই — একটা অভিন্ন handle, যেটা close() করা যায়, epoll-এ নিবন্ধন করা যায়, fork-এ উত্তরাধিকার দেওয়া যায়। এই একীভূত handle-টাই আসল অর্জন, “read/write সব কিছুতে কাজ করে” দাবিটা না। Plan 9 আক্ষরিক সংস্করণটা বাস্তবায়ন করে দেখিয়েছিল (নেটওয়ার্ক স্ট্যাক পর্যন্ত filesystem-এ), কিন্তু Linux সেই পথে যায়নি।

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

1

একটা process নিচের কোড চালাল। শেষে /tmp/t ফাইলের আকার কত বাইট, আর বিষয়বস্তু কী? প্রতিটা ধাপে কোন স্তরে কী বদলাল বলুন।

int f = open("/tmp/t", O_RDWR|O_CREAT|O_TRUNC, 0644);
int g = dup2(f, 20);
int h = open("/tmp/t", O_RDWR);
write(f, "AAAA", 4);
write(g, "BB",   2);
write(h, "CCC",  3);
যুক্তি

ফাইল ৬ বাইট, বিষয়বস্তু CCCBBB… না — সাবধানে ধাপে ধাপে করি।

f আর g একই open file description (dup2 নতুন description বানায় না), h আলাদা।

ধাপকোন descriptionwrite শুরু offsetলিখলপরে সেই description-এর posফাইলের বিষয়বস্তু
শুরু#1: 0, #2: 0(খালি, size 0)
write(f,"AAAA",4)#1 (f ও g-র)0AAAA @0–3#1: 4AAAA (4)
write(g,"BB",2)#1একই!4BB @4–5#1: 6AAAABB (6)
write(h,"CCC",3)#2 (স্বাধীন)0CCC @0–2#2: 3CCCABB (6)

চূড়ান্ত উত্তর: ৬ বাইট, বিষয়বস্তু CCCABB

CCC প্রথম তিন বাইট (AAA) চাপা দিয়েছে, চতুর্থ A টিকে গেছে, তারপর BB

স্তর অনুযায়ী কী বদলাল:

স্তরঘটনা
স্তর ১ (fd table)তিনটা ঘর ভরল — 3 → #1, 20 → #1, 4 → #2dup2(f, 20) স্পষ্টভাবে ২০ নম্বর ঘরটাই চেয়েছে (“সবচেয়ে ছোট মুক্ত” নিয়ম এখানে খাটে না — dup2-ই একমাত্র উপায় নির্দিষ্ট নম্বর দাবি করার, আর shell-এর redirection ঠিক এজন্যই dup2 ব্যবহার করে)
স্তর ২ (description)দুইটা entry: #1 (f_count = 2), #2 (f_count = 1)। মোট তিনটা write কিন্তু pos বদলেছে দুইটা জায়গায়
স্তর ৩ (inode)একটাই inode; i_size 0 → 4 → 6 → 6 (শেষ write পুরোপুরি বিদ্যমান range-এর ভেতরে, তাই size বাড়েনি)

Level 11-এর সাথে যোগ: এই “মোট ৯ বাইট লেখা, ফাইল ৬ বাইট” ফাঁকটা performance analysis-এও গুরুত্বপূর্ণ — write amplification মাপার সময় application-স্তরের বাইট-গণনা আর প্রকৃত ফাইল-বৃদ্ধি আলাদা করে দেখতে হয়, নইলে I/O accounting ভুল হয়।

2

দুইটা process একটা log ফাইলে (শুরুতে খালি) প্রত্যেকে ১০০০ বার করে lseek(fd,0,SEEK_END) + write(fd, line, 50) চালাল — মোট ২০০০ লাইন, ২০০০×৫০ = ১,০০,০০০ বাইট লেখার কথা। শেষে wc -c দেখাল ৭৩,৪০০ বাইট।

(ক) কতটা ডেটা হারিয়েছে এবং কতগুলো লাইন? (খ) হারানোর প্রক্রিয়াটা ধাপে ধাপে বলুন। (গ) O_APPEND ব্যবহার করলে ফলাফল কী হতো, আর কেন?

প্রয়োগ

(ক) হিসাব

পরিমাপমান
লেখা হয়েছে2000 × 50 = 100,000 বাইট
ফাইলে আছে73,400 বাইট
হারিয়েছে100,000 − 73,400 = 26,600 বাইট
হারানো লাইন26,600 ÷ 50 = 532 লাইন
টিকে থাকা লাইন73,400 ÷ 50 = 1,468 লাইন

অর্থাৎ ২৬.৬% log line নীরবে অদৃশ্য — কোনো error, কোনো failed write, কোনো লক্ষণ ছাড়াই। প্রতিটা write() সফলভাবে ৫০ ফেরত দিয়েছে

(খ) প্রক্রিয়া — একটা হারানো লাইনের জীবনকাহিনি

ফাইল ধরুন 36,700 বাইট, দুইটা process P আর Q:

সময়PQফাইলের অবস্থা
t1lseek(SEEK_END)3670036,700
t2(scheduler P-কে সরাল)lseek(SEEK_END)3670036,700
t3write 50 বাইট @3670036,750 (Q-র লাইন)
t4write 50 বাইট @3670036,750 (Q-র লাইন মুছে P-রটা)

দুইটা সফল write, ফাইল বাড়ল মাত্র ৫০ বাইট — একটা লাইন হারাল। lseek আর write দুইটা আলাদা syscall, আর তাদের মাঝখানে scheduler যেকোনো সময় ঢুকতে পারে (time-slice শেষ, preemption, বা multi-core-এ সত্যিকার সমান্তরালতা)। ৫৩২টা এমন উইন্ডো ঘটেছে ২০০০ চেষ্টার মধ্যে — প্রায় ২৭%, যা দুইটা CPU-bound process একই ফাইলে লিখলে সম্পূর্ণ স্বাভাবিক।

গুরুত্বপূর্ণ: file descriptor দুইটা process-এ আলাদা open() থেকে এসেছে, তাই তারা offset শেয়ার করে না। যদি একটা process fork() করে দুইটা হতো (offset শেয়ার করত), তাহলেও lseek+write অনিরাপদ থাকত — কারণ f_pos শেয়ার করা মানে শুধু একটা মান, দুইটা syscall-এর মাঝে তা বদলে যেতে পারে।

(গ) O_APPEND দিয়ে: হুবহু ১,০০,০০০ বাইট, শূন্য ক্ষতি।

কারণটা স্তর ২-এ: O_APPEND flag থাকলে write() f_pos উপেক্ষাই করে। কার্নেল inode_lock ধরে, f_pos = i_size বসায়, লেখে, i_size আপডেট করে, তারপর lock ছাড়ে — সবটা এক অবিভাজ্য critical section-এ। “শেষ কোথায় দেখা” আর “সেখানে লেখা”-র মাঝে অন্য কেউ ঢুকতেই পারে না, তাই উপরের t2–t4 interleaving-টা অসম্ভব হয়ে যায়।

তিনটা শর্ত মনে রাখুন: এটা local filesystem-এ প্রযোজ্য (NFS-এ না, man 2 open-এর সতর্কবাণী অনুযায়ী), প্রতিটা লাইন একটা write() কলে যেতে হবে (দুইটা কলে ভাগ করলে মাঝে অন্যের লাইন ঢুকতে পারে), আর pipe-এর ক্ষেত্রে সীমা PIPE_BUF = ৪০৯৬ বাইট।

Level 9-এর সাথে যোগ: এটা distributed systems-এর “read-modify-write race” সমস্যার একদম ছোট, একক-মেশিন সংস্করণ — সেখানেও সমাধানের কাঠামো অভিন্ন: হয় অপারেশনটাকে atomic করো (compare-and-swap, O_APPEND), নয়তো বাইরে থেকে lock দাও। Level 11-এ দেখবেন O_APPEND-এর inode_lock উচ্চ-সমান্তরালতায় নিজেই একটা contention bottleneck হয়ে দাঁড়াতে পারে — তখন per-thread ফাইলে লিখে পরে merge করাই দ্রুততর।

3

/proc/1234/fdinfo/9 পড়ে পেলেন:

pos:	0
flags:	02102002
mnt_id:	31
ino:	1049601

(ক) কোন কোন O_* flag সেট আছে? (খ) pos: 0 অথচ flag-এর তালিকা দেখে বলা যায় লেখা কোথায় যাবে — কোথায়? (গ) এই fd-টা fork()+exec()-এর পরেও child-এ থাকবে কি?

যুক্তি

(ক) Octal মানটা ভেঙে ফেলি। 02102002 octal — প্রতিটা সেট বিট আলাদা করি:

Octal বিটমানFlag
020000001048576O_CLOEXEC
010000032768O_LARGEFILE
020001024O_APPEND
022O_RDWR

যাচাই: 02000000 + 0100000 + 02000 + 02 = 02102002 — মিলে গেছে (octal-এ কলামভিত্তিক যোগ: 2+0+0+0 → 2, তারপর 1, তারপর 0, 2, 0, 0, 2)।

তাই: O_RDWR | O_APPEND | O_LARGEFILE | O_CLOEXEC — একটা read-write, append-mode, exec-এ বন্ধ হয়ে যাওয়া fd। প্রায় নিশ্চিতভাবে একটা log ফাইল, ভালোভাবে লেখা কোনো প্রোগ্রামের।

(খ) pos: 0 হলেও লেখা যাবে ফাইলের শেষে। কারণ O_APPEND সেট — এই flag থাকলে write() f_pos-কে সম্পূর্ণ উপেক্ষা করে, প্রতিবার i_size থেকে শুরু করে। pos: 0 এখানে শুধু বলছে এই description দিয়ে এখনো কেউ কিছু পড়েনি (read() কিন্তু f_pos থেকেই পড়ে — O_APPEND শুধু write-কে প্রভাবিত করে, read-কে না, এটা একটা সূক্ষ্ম কিন্তু গুরুত্বপূর্ণ অসামঞ্জস্য)। প্রথম write()-এর পরে pos লাফ দিয়ে i_size-এ চলে যাবে।

(গ) fork()-এ থাকবে, exec()-এ থাকবে না। fork() fd table কপি করে, CLOEXEC bitmap-সহ — তাই fd 9 child-এ ঠিকই থাকবে, একই description শেয়ার করে। কিন্তু child যখন execve() করবে, কার্নেল close_on_exec bitmap দেখে fd 9-কে স্বয়ংক্রিয়ভাবে বন্ধ করে দেবে। নতুন প্রোগ্রাম log ফাইলটা দেখতেই পাবে না — যা এখানে ঠিক কাম্য, নিরাপত্তার জন্য।

মনে রাখুন O_CLOEXEC আসলে স্তর ২-এর f_flags-এ নেই, স্তর ১-এর bitmap-এ — /proc উপস্থাপনার সময় দুইটা জুড়ে দেখায়।

Level 10-এর সাথে যোগ: এই CLOEXEC বিটটা privilege separation-এর একটা মৌলিক টুল। Level 10-এ দেখবেন sandbox ডিজাইনের নিয়ম হলো “capability সবসময় স্পষ্টভাবে হস্তান্তর করো, উত্তরাধিকারে দিয়ো না” — CLOEXEC ডিফল্ট-অন করা সেই নীতিরই fd-স্তরের প্রয়োগ, আর seccomp + fd passing (SCM_RIGHTS) দিয়ে গড়া আধুনিক sandbox-এর ভিত্তি।

4

আপনার HTTP service প্রতিদিন দুপুরে ক্র্যাশ করছে, log-এ: accept: Too many open filesulimit -n দেখাচ্ছে 1024। একজন সহকর্মী বলছেন “ulimit -n 65536 করে দাও, শেষ”। এটা কেন অসম্পূর্ণ পরামর্শ, আর আপনি কীভাবে প্রকৃত কারণ নির্ণয় করবেন? ধাপে ধাপে একটা diagnosis পরিকল্পনা দিন।

প্রয়োগ

কেন পরামর্শটা অসম্পূর্ণ: Too many open files = EMFILE, যার দুইটা সম্পূর্ণ ভিন্ন কারণ থাকতে পারে —

কারণলক্ষণসঠিক সমাধান
প্রকৃত লোড সীমা ছাড়িয়েছেfd সংখ্যা লোডের সাথে ওঠানামা করে, চূড়ায় সীমা ছোঁয়সীমা বাড়ানো ঠিক উত্তর
fd leakfd সংখ্যা একদিকে বাড়তেই থাকে, লোড কমলেও কমে নাসীমা বাড়ানো শুধু ক্র্যাশটা পিছিয়ে দেয় — ১০২৪-এ যদি ৬ ঘণ্টা লাগত, ৬৫৫৩৬-এ ১৬ দিন লাগবে, তারপর একই ক্র্যাশ, কিন্তু ততক্ষণে ৬৫,০০০ leaked object-এর মেমরি-খরচ সহ

“প্রতিদিন দুপুরে” — একটা periodic pattern — দুইটাই হতে পারে (দুপুরের peak traffic, নাকি প্রতি রাতে restart হয়ে আবার ২৪ ঘণ্টায় leak জমে?)। তাই আগে মাপতে হবে।

Diagnosis পরিকল্পনা

ধাপ ১ — fd সংখ্যার সময়-রেখা নিন (leak বনাম spike আলাদা করার একমাত্র উপায়):

PID=$(pgrep -f myservice)
while true; do
  printf '%s %s\n' "$(date +%H:%M:%S)" "$(ls /proc/$PID/fd | wc -l)"
  sleep 60
done | tee fdcount.log

একঘেয়ে ঊর্ধ্বমুখী রেখা = leak। লোডের সাথে ওঠানামা = ক্ষমতার সমস্যা।

ধাপ ২ — fd-গুলো কী তা ভাগ করুন:

ls -l /proc/$PID/fd | awk '{print $NF}' | sed 's/\[[0-9]*\]//' \
  | sort | uniq -c | sort -rn | head
   8912 socket:
     31 /var/log/myservice/app.log
     12 anon_inode:
      4 /dev/urandom

৮৯১২টা socket = network-স্তরে leak। ৮৯১২টা একই log ফাইল = কেউ প্রতি request-এ log ফাইল খুলছে আর বন্ধ করছে না।

ধাপ ৩ — socket হলে অবস্থা দেখুন:

ss -s
ss -tanp state close-wait | wc -l

প্রচুর CLOSE_WAIT একটা নির্দিষ্ট রোগের স্বাক্ষর: peer connection বন্ধ করেছে, কিন্তু আপনার প্রোগ্রাম close() ডাকেনি। এটা প্রায় সবসময় error path-এ একটা ভুলে যাওয়া close() — যেমন timeout বা exception হলে socket বন্ধ না করে বেরিয়ে যাওয়া।

ধাপ ৪ — কোড-স্তরে ধরুন: lsof -p $PID দিয়ে সবচেয়ে পুরনো fd-গুলো দেখুন (নম্বর যত ছোট তত পুরনো নয় — নম্বর পুনর্ব্যবহৃত হয় — তাই /proc/$PID/fdinfo আর application log মিলিয়ে দেখুন)। আরো সরাসরি: strace -f -e trace=openat,socket,accept4,close -p $PID -c চালিয়ে গণনা মেলান — যদি open+socket+accept4 এর যোগফল close-এর চেয়ে ধারাবাহিকভাবে বেশি হয়, leak নিশ্চিত।

ধাপ ৫ — তারপর সীমাও ঠিক করুন। Leak সারানোর পরেও একটা web service-এর ১০২৪ কম। systemd unit-এ LimitNOFILE=65536, আর ক্ষমতা-পরিকল্পনা: প্রয়োজনীয় fd ≈ (সর্বোচ্চ concurrent connection × প্রতি connection fd) + log/config/cache fd + নিরাপত্তা-মার্জিন। Proxy হলে “প্রতি connection fd” (client + upstream), nginx-এর মতোই।

Level 7 ও Level 12-এর সাথে যোগ: CLOSE_WAIT-এর সঠিক ব্যাখ্যা TCP state machine থেকে আসে — Level 7-এর networking module-এ FIN/ACK আর four-way close দেখলে বুঝবেন কেন peer বন্ধ করলে আপনার দিকটা নিজে থেকে মুক্ত হয় না। আর Level 12-এর cloud module-এ দেখবেন container-এ ulimit inherit হয় ভিন্নভাবে (Docker-এর --ulimit, Kubernetes-এ pod-স্তরে সরাসরি সেট করা যায় না) — তাই host-এ ulimit -n ঠিক করেও container-এর ভেতরে সমস্যা থেকে যেতে পারে।

5

আপনি একটা C library ডিজাইন করছেন যা একটা database connection pool রাখে (প্রতিটা একটা socket fd), আর যে অ্যাপ্লিকেশন এটা ব্যবহার করবে সেটা মাঝে মাঝে fork()+execve() করে বাইরের helper প্রোগ্রাম চালায়। আপনার library যেন কোনো fd leak না করে — তিনটা ডিজাইন-সিদ্ধান্ত প্রস্তাব করুন, প্রতিটার trade-off সহ।

ডিজাইন

সিদ্ধান্ত ১ — প্রতিটা fd SOCK_CLOEXEC দিয়ে তৈরি করুন, ব্যতিক্রমহীনভাবে।

int s = socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0);
/* accept-এও: accept4(lfd, NULL, NULL, SOCK_CLOEXEC) -- accept() না */

socket() + পরে fcntl(F_SETFD) নয় — সেই দুই লাইনের মাঝে অন্য thread fork+exec করলে fd leak হয়েই গেছে, আর multithreaded অ্যাপে এই জানালা বাস্তবে খোলে। একই কারণে pipe2(), dup3(), open(O_CLOEXEC), accept4() — সব CLOEXEC-সহ সংস্করণ ব্যবহার করুন।

Trade-off: পুরনো platform-এ portability। SOCK_CLOEXEC/accept4() Linux 2.6.27+ (2008), macOS-এ SOCK_CLOEXEC নেই — সেখানে fcntl fallback লাগবে, আর সেই fallback-এ race-টা ফিরে আসে (macOS-এ কোনো atomic বিকল্প নেই)। তাই একটা #ifdef-ঘেরা compatibility layer লিখতে হবে, যা কোড জটিল করে।

সিদ্ধান্ত ২ — pthread_atfork() হ্যান্ডলার দিয়ে fork-এর পরে pool-এর অবস্থা স্পষ্টভাবে ঠিক করুন।

CLOEXEC শুধু exec-এ কাজ করে। কিন্তু অ্যাপ যদি fork() করে exec না করে (একটা background worker fork করল), তখন child-এ pool-এর সব socket fd বেঁচে থাকে — আর এখন দুইটা process একই TCP connection-এ লিখছে, যা প্রোটোকল-স্তরে সম্পূর্ণ বিশৃঙ্খলা (দুই দিকের request-response আন্তঃমিশ্রিত)। এটা CLOEXEC দিয়ে ধরা পড়ে না, আর ডিবাগ করা ভয়াবহ কঠিন।

static void after_fork_in_child(void) {
    for (int i = 0; i \< pool->n; i++) close(pool->conn[i].fd);
    pool->n = 0;              /* child নতুন connection বানাবে, উত্তরাধিকার না */
}
pthread_atfork(NULL, NULL, after_fork_in_child);

Trade-off: pthread_atfork handler-এ কী করা নিরাপদ তার কড়া সীমা আছে — child-এ শুধু async-signal-safe ফাংশন ডাকা বৈধ (fork করা multithreaded process-এ অন্য thread-এর ধরে রাখা mutex চিরতরে লক থাকতে পারে)। close() নিরাপদ, কিন্তু malloc/free/logging নিরাপদ না। তাই handler-টা খুব সংযত রাখতে হবে, আর pool-এর data structure এমনভাবে ডিজাইন করতে হবে যাতে শুধু fd-গুলো ছোঁয়া যায়, allocation ছাড়াই।

সিদ্ধান্ত ৩ — library-র নিজস্ব একটা “spawn helper” API দিন, অ্যাপকে কাঁচা fork+exec করতে না দিয়ে।

posix_spawn() (বা fork+close_range+exec-এর একটা wrapper) ব্যবহার করে একটা mylib_spawn(cmd, argv, fds_to_keep[]) ফাংশন দিন, যেটা child-এ শুধু স্পষ্টভাবে চাওয়া fd-গুলো রাখে আর বাকি সব close_range(3, ~0U, 0) (Linux 5.9+) দিয়ে বন্ধ করে দেয়।

Trade-off: এটা “defence in depth” — সবচেয়ে শক্ত গ্যারান্টি, কারণ এটা আপনার library-র বাইরের অন্য library-র leak করা fd-ও ধরে ফেলে (আপনার নিয়ন্ত্রণের বাইরের কোড, যেটা CLOEXEC ব্যবহার নাও করতে পারে)। কিন্তু দাম দুইটা: (ক) এটা অ্যাপের উপর একটা API চাপিয়ে দেয় — অ্যাপ যদি সরাসরি fork করে, সুরক্ষাটা কাজ করে না, তাই ডকুমেন্টেশন আর শৃঙ্খলার উপর নির্ভরশীল; (খ) close_range নেই এমন কার্নেলে fallback হলো RLIMIT_NOFILE পর্যন্ত লুপ — যা সীমা বড় হলে (৫২৪২৮৮) প্রতিটা spawn-এ মাপা যায় এমন বিলম্ব যোগ করে।

সুপারিশকৃত সমন্বয়: ১ + ২ বাধ্যতামূলক (আপনার নিজের fd-র জন্য, আর এগুলো অ্যাপের কোনো সহযোগিতা ছাড়াই কাজ করে), ৩ ঐচ্ছিক সুবিধা হিসেবে — যে অ্যাপ চায় সে ব্যবহার করবে, না চাইলেও ১ ও ২ তাকে রক্ষা করবে।

Level 12-এর সাথে যোগ: container-এ এই সমস্যাগুলো আরো তীব্র — container runtime নিজেই fork/exec/setns-এর একটা শৃঙ্খল চালায়, আর সেখানে একটা leaked fd মানে namespace-এর বেড়া টপকানো (CVE-2016-9962 ঠিক এটাই ছিল)। Level 12-এ দেখবেন কেন container isolation-এর সঠিক মানসিক মডেল হলো “namespace একটা বেড়া, কিন্তু একটা fd সেই বেড়ার নিচ দিয়ে যাওয়া একটা সুড়ঙ্গ” — আর Level 10-এ capability-based security মডেলে দেখবেন fd-কে আসলে একটা capability হিসেবে ভাবাই সবচেয়ে সঠিক: অধিকারটা নামের মধ্যে না, handle-এর মধ্যে।

এরপর কী

পরের লেসন — Filesystem ও Inode

এই লেসনে তিনটা টেবিলের দুইটা পুরোপুরি খোলা হয়েছে: fd table (স্তর ১) আর open file table (স্তর ২)। তৃতীয়টা — inode table — বারবার এসেছে কিন্তু ভেতরটা দেখানো হয়নি। আমরা জেনেছি inode-এ i_size, i_nlink, i_mode আছে আর “ডেটা ব্লকের ঠিকানা” আছে, কিন্তু সেই ঠিকানাগুলো ঠিক কীভাবে সাজানো, আর একটা inode-এ কী নেই — সেটাই পরের লেসনের বিষয়।

আর সেই “কী নেই”-টাই সবচেয়ে চমকপ্রদ: inode-এ ফাইলের নাম নেই। নামটা থাকে directory-তে, আর directory নিজেই একটা ফাইল যার ভেতরে শুধু name→inode জোড়ার একটা তালিকা। এই একটা ডিজাইন-সিদ্ধান্ত থেকেই একগুচ্ছ Unix আচরণ সরাসরি বেরিয়ে আসে — কেন hard link সম্ভব, কেন rm-এর আসল নাম unlink, কেন একটা ফাইল মুছে ফেলার পরেও আপনার প্রোগ্রাম সেটা পড়তে পারে (এই লেসনের f_count আলোচনার ঠিক ধারাবাহিকতা), আর কেন directory-কে hard link করা যায় না।

সাথে দেখব ডিস্কের প্রকৃত বিন্যাস — superblock, inode bitmap, block bitmap, data block — আর সেই বিখ্যাত direct/single/double/triple indirect ব্লক-অ্যাড্রেসিং স্কিম, যার সর্বোচ্চ ফাইল-আকার আমরা ৪KB ব্লক আর ৪-বাইট পয়েন্টার ধরে হাতে হিসাব করব। তারপর দেখব আধুনিক ext4 কেন সেই স্কিম ছেড়ে extent ব্যবহার করে, আর truncate -s 1G দিয়ে বানানো একটা ফাইল কীভাবে ls -l-এ ১GB আর du-তে ০ বাইট দেখাতে পারে।

আরও পড়ুন

  • open(2) — Linux manual page — Michael Kerrisk, man-pages project · O_APPEND, O_CLOEXEC-এর প্রামাণ্য সংজ্ঞা, আর NFS-এ O_APPEND-এর race নিয়ে সেই বিখ্যাত সতর্কবাণী
  • dup(2), dup2(2), dup3(2) — Linux manual page · dup-করা fd কী শেয়ার করে আর কী করে না, তার প্রামাণ্য তালিকা
  • The Linux Programming Interface, Chapter 5 — File I/O: Further Details — Michael Kerrisk · Figure 5-2 — এই লেসনের তিন-টেবিল ডায়াগ্রামের canonical উৎস; প্রতিটা fd-বিভ্রান্তির উত্তর এখানে আছে
  • Linux kernel source — include/linux/fdtable.h ও include/linux/fs.h · struct files_struct, struct fdtable, struct file — এই লেসনের তিন টেবিলের প্রকৃত C সংজ্ঞা