Virtual Filesystem Switch — কেন একটাই read() ext4, NFS, tmpfs আর FUSE-এ সমানভাবে কাজ করে
The VFS Layer: One Interface, Many Filesystems
একটা read() syscall যেভাবে ext4-এ কাজ করে, ঠিক সেভাবেই NFS, tmpfs বা /proc-এ কাজ করে -- অ্যাপ্লিকেশন কোড কখনো জানেই না নিচে কোন filesystem চলছে। এই জাদুটার নাম Virtual Filesystem Switch (VFS): superblock, inode, dentry, আর file -- এই চারটা object আর তাদের function-pointer struct দিয়ে C ভাষায় বাস্তবায়িত একটা polymorphic interface। এই লেসনে dentry cache-এর গতি, mount tree-র গঠন, pseudo-filesystem-এর কৌশল, আর FUSE-এর userspace round-trip খরচ -- সব একসাথে দেখব।
আগে এটা বুঝি
গত লেসনে আমরা ext4-এর ভেতরে ঢুকেছিলাম — inode, extent tree, journal। কিন্তু একটা প্রশ্ন সেখানে এড়িয়ে গিয়েছিলাম: আপনি যখন read(fd, buf, 4096) কল করেন, kernel কীভাবে জানে যে এটা ext4-এর জন্য bound একটা fd, নাকি একটা NFS mount-এর, নাকি /proc/cpuinfo-র, নাকি একটা USB drive-এর FAT32-এর?
উত্তরটা প্রথমে অদ্ভুত লাগতে পারে: kernel জানেই না, আর জানার দরকারও নেই। sys_read()-এর ভেতরের কোড কখনো if (fs == ext4) ... else if (fs == nfs) ... লেখে না। বরং সে একটা function pointer কল করে যেটা প্রতিটা filesystem নিজে নিজে পূরণ করে রেখেছে। ext4-এর pointer disk block পড়ে; NFS-এর pointer নেটওয়ার্কে প্যাকেট পাঠায়; tmpfs-এর pointer শুধু RAM থেকে copy করে; procfs-এর pointer একটা struct থেকে সংখ্যা format করে string বানায় — কোনো disk-ই নেই। চারটাই একই syscall, একই read(), ব্যবহারকারীর কাছে অভিন্ন আচরণ।
এই একটা কৌশলের নাম Virtual Filesystem Switch (VFS) — Linux-এর সবচেয়ে সফল abstraction-গুলোর একটা, আর এই লেসনের বিষয়। এটা বুঝলে একসাথে অনেকগুলো জিনিস পরিষ্কার হয়ে যায়: mount কেন এত সরল একটা কমান্ড অথচ এত শক্তিশালী; /proc/cpuinfo পড়লে “ফাইল” থেকে live তথ্য কীভাবে বেরোয়; আর কেন একটা নতুন filesystem লিখতে পুরো kernel বদলাতে হয় না — শুধু একটা struct পূরণ করে register করতে হয়।
মূল ধারণা
চারটা কেন্দ্রীয় object
VFS পুরোটাই চারটা C struct-এর উপর দাঁড়িয়ে। প্রতিটা একটা ভিন্ন প্রশ্নের উত্তর দেয়:
| Object | প্রশ্ন | বাস্তব উদাহরণ |
|---|---|---|
struct super_block | কোন filesystem instance, কী তার সামগ্রিক অবস্থা? | “root partition-এ mount করা ext4, UUID এইটা” |
struct inode | একটা নির্দিষ্ট ফাইল/ডিরেক্টরি — মেটাডেটা কী? | “inode 41231: owner root, size 4096, permission 644” |
struct dentry | নামের সাথে inode-এর সংযোগ | ""/etc/passwd” নামটা inode 41231-কে নির্দেশ করে” |
struct file | একটা প্রক্রিয়ার খোলা handle | “PID 1234-এর fd 3, বর্তমান offset 512” |
এই চারটার সম্পর্কটা মনে রাখার সহজ উপায় — inode ফাইল সম্পর্কে, dentry নাম সম্পর্কে, file আপনার সেশন সম্পর্কে, আর superblock গোটা filesystem সম্পর্কে। একই inode-কে একাধিক dentry নির্দেশ করতে পারে (hard link — গত লেসনের বিষয়), আর একই inode-এ একাধিক প্রক্রিয়ার একাধিক struct file খোলা থাকতে পারে, প্রতিটার নিজস্ব offset।
struct super_block
(একটা mounted filesystem)
│
┌─────────────┴─────────────┐
│ │
struct dentry struct dentry
name: "etc" name: "passwd"
inode: 2 (dir) inode: 41231
│ │
└──────────┬──────────────────┘
│ (parent-child, গাছ গঠন করে)
▼
struct inode #41231
mode, uid, gid, size,
i_op (inode_operations)
│
┌─────────────────┴─────────────────┐
▼ ▼
struct file (PID 1234, fd 3) struct file (PID 5678, fd 7)
f_pos = 512 f_pos = 0
f_op (file_operations) f_op (file_operations)Operations struct — C-তে interface/vtable
প্রতিটা object-এর সাথে একটা “operations” struct জোড়া থাকে — নিছক function pointer-এর একটা তালিকা। এটাই সেই জায়গা যেখানে “polymorphism” শব্দটা আক্ষরিক অর্থে প্রযোজ্য, যদিও C-তে কোনো class বা virtual keyword নেই।
/* সরলীকৃত -- linux/fs.h */
struct file_operations {
ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
int (*open) (struct inode *, struct file *);
int (*release)(struct inode *, struct file *);
loff_t (*llseek)(struct file *, loff_t, int);
long (*unlocked_ioctl)(struct file *, unsigned int, unsigned long);
/* ... আরও প্রায় ৩০টা ফিল্ড ... */
};ext4 এই struct-টা এভাবে পূরণ করে:
/* fs/ext4/file.c (সরলীকৃত) */
const struct file_operations ext4_file_operations = {
.llseek = ext4_llseek,
.read_iter = ext4_file_read_iter,
.write_iter = ext4_file_write_iter,
.open = ext4_file_open,
.release = ext4_release_file,
/* ... */
};আর tmpfs-এর জন্য সম্পূর্ণ ভিন্ন ফাংশনগুলো একই struct-এর একই স্লটে বসে:
/* mm/shmem.c (সরলীকৃত) */
const struct file_operations shmem_file_operations = {
.llseek = shmem_file_llseek,
.read_iter = shmem_file_read_iter, /* সরাসরি page cache থেকে -- disk I/O নেই */
.write_iter = generic_file_write_iter,
/* ... */
};sys_read()-এর ভেতরের কোড শুধু এটুকু করে:
/* সরলীকৃত -- fs/read_write.c */
ssize_t vfs_read(struct file *file, char __user *buf, size_t count, loff_t *pos) {
if (!file->f_op->read_iter)
return -EINVAL;
return file->f_op->read_iter(...); /* ★ indirect call -- এখানেই polymorphism */
}এই একটা ->read_iter(...) কলটাই পুরো VFS-এর হৃদয়। file->f_op pointer-টা open() করার সময় সেট হয়ে গেছে — ext4 file হলে ext4-এর ফাংশন, tmpfs হলে tmpfs-এর, NFS হলে network round-trip করা ফাংশন। কলার-এর কোড (VFS, syscall layer) কখনো জানে না কোনটা চলছে, আর জানারও দরকার নেই।
| ভাষা | সমতুল্য কৌশল |
|---|---|
| C (VFS) | struct এর ভেতর function pointer, ম্যানুয়ালি পূরণ |
| C++ | Virtual function → compiler স্বয়ংক্রিয়ভাবে vtable বানায় |
| Java/Go | Interface → runtime dispatch table |
| Rust | trait object (dyn Trait) → vtable |
মূল পার্থক্য শুধু এটুকু: C++/Java-তে compiler vtable-টা লুকিয়ে বানায়, C-তে kernel developer নিজের হাতে struct পূরণ করেন। প্রক্রিয়াটা একই — একটা indirect function call দিয়ে “কোন বাস্তবায়ন চলবে” সিদ্ধান্তটা compile-time থেকে run-time-এ ঠেলে দেওয়া। Level 5-এর compiler module-এ দেখব কীভাবে একটা C++ compiler এই একই struct স্বয়ংক্রিয়ভাবে তৈরি করে।
Dentry cache — কেন এটা ছাড়া path lookup অসহনীয় ধীর হতো
একটা path resolve করা মানে কী, সেটা প্রথমে দেখা যাক। /etc/nginx/sites-enabled/default পড়তে গেলে kernel-কে প্রতিটা component ধাপে ধাপে resolve করতে হয়:
১. root inode (২) থেকে "etc" খোঁজো → directory block পড়ো, "etc" এন্ট্রি বের করো
২. "etc"-এর inode থেকে "nginx" খোঁজো → আরেকটা directory block পড়ো
৩. "nginx"-এর inode থেকে "sites-enabled" → আরেকটা directory block পড়ো
৪. "sites-enabled"-এর inode থেকে "default" → আরেকটা directory block পড়োচারটা component, চারটা directory read — আর প্রতিটা read মানে সম্ভাব্য একটা disk I/O (৫-১০ ms, যদি cache-এ না থাকে)। একটা গভীর path (Kubernetes বা npm-এর মতো সিস্টেমে ১০-১৫ level গভীর path সাধারণ) হলে এই costটা আক্ষরিক অর্থে unusable হয়ে যেত — প্রতিটা open() কল কয়েক দশ মিলিসেকেন্ড লাগত।
এর সমাধান dentry cache (dcache) — প্রতিটা resolve করা (parent dentry, name) → dentry জোড়া একটা in-memory hash table-এ রেখে দেওয়া। দ্বিতীয়বার একই path resolve করলে প্রতিটা ধাপ hash lookup — কোনো disk I/O নেই।
| Cold (dcache miss) | Warm (dcache hit) | |
|---|---|---|
| প্রতি component খরচ | ~৫-১০ ms (disk seek) বা ~০.১-০.৫ ms (SSD) | ~৫০-১০০ ns (hash lookup) |
| ৪-component path মোট | ২০-৪০ ms (HDD) | ~০.৪ μs |
| অনুপাত | — | প্রায় ১০০,০০০× দ্রুত |
এই সংখ্যাটাই এই লেসনের experiment সেকশনে সরাসরি মাপা হবে drop_caches দিয়ে।
Mount tree — আলাদা filesystem একটা গাছে জোড়া লাগা
mount-এর ধারণাটা VFS-এর দ্বিতীয় বড় কৌশল। একটা mount আসলে করে এটুকুই: একটা নির্দিষ্ট dentry-কে (mount point) একটা ভিন্ন superblock-এর root dentry-র সাথে জুড়ে দেয়।
/ (ext4 root superblock)
├── etc/ (একই ext4)
├── home/ (একই ext4)
├── proc/ (◄── এখানে mount point, procfs superblock জোড়া)
│ ├── cpuinfo
│ └── 1234/
├── mnt/
│ └── usb/ (◄── mount point, vfat superblock জোড়া)
└── tmp/ (◄── mount point, tmpfs superblock জোড়া)/proc-এ cd করলে VFS দেখে এটা একটা mount point — সেখান থেকে lookup procfs-এর superblock/dentry-তে “লাফ দেয়”। ব্যবহারকারীর কাছে এটা একটাই নিরবচ্ছিন্ন গাছ, কিন্তু প্রতিটা শাখা সম্পূর্ণ ভিন্ন backend হতে পারে। struct vfsmount এই জোড়াটা ধরে রাখে — কোন dentry কোন superblock-এর সাথে mount হয়েছে।
Bind mount (mount --bind /a /b) এই একই কৌশলের একটা প্রকরণ — নতুন কোনো superblock নয়, বরং একই filesystem-এর একটা dentry-কে গাছের অন্য একটা জায়গায় দ্বিতীয়বার দেখানো। Docker container-এর ভেতরে host-এর একটা ডিরেক্টরি “mount” হিসেবে দেখানো এভাবেই কাজ করে।
Mount namespace (unshare --mount, বা প্রতিটা container-এর নিজস্ব namespace) এক ধাপ আরও এগিয়ে — পুরো mount tree-র একটা কপি, process-ভেদে ভিন্ন হতে পারে। একটা container-এর ভেতরের প্রক্রিয়া / দেখে একরকম, host-এর প্রক্রিয়া দেখে আরেকরকম — অথচ underlying kernel একটাই, VFS object-গুলো একই কাঠামোর। এটাই container isolation-এর একটা মূল স্তম্ভ (namespace-এর বাকি অংশ — PID, network, user — Level 9-এর distributed systems module-এ)।
Pseudo-filesystem — ফাইল যা আসলে ফাইল নয়
VFS-এর সবচেয়ে চমৎকার প্রয়োগ হলো এমন filesystem যাদের পেছনে কোনো disk block-ই নেই।
| Filesystem | কী প্রকাশ করে | Read করলে কী হয় |
|---|---|---|
procfs (/proc) | প্রতিটা প্রক্রিয়ার state, kernel পরিসংখ্যান | kernel struct থেকে সংখ্যা format করে string বানায়, on-the-fly |
sysfs (/sys) | Device, driver, kernel object-এর গাছ (kobject) | সংশ্লিষ্ট kobject-এর attribute পড়ে/লেখে |
| tmpfs | সাধারণ ফাইল, কিন্তু RAM-এ (persist করে না) | সরাসরি page cache, কখনো disk-এ যায় না |
| devtmpfs | /dev-এর device node, kernel driver detect করলেই স্বয়ংক্রিয় তৈরি | ফাইল অপারেশন driver-এর কাছে ফরওয়ার্ড হয় |
| cgroupfs | Control group-এর hierarchy আর resource limit | pseudo-ফাইলে লিখলে সরাসরি cgroup-এর সীমা বদলায় |
এই প্রতিটার read_iter/write_iter implementation disk-এ কিছু access করে না — সরাসরি kernel-এর ভেতরের data structure পড়ে বা লেখে। cat /proc/1234/status আসলে PID 1234-এর task_struct থেকে সরাসরি ডেটা টেনে আনছে; disk-এ এমন কোনো ফাইল কখনোই ছিল না। এই কারণেই /proc/cpuinfo-র size প্রায়ই 0 দেখায় (ls -la-তে) — এর কোনো নির্দিষ্ট আকার নেই, প্রতিবার read করার সময় তৈরি হয়।
এটাই সেই কৌশল যা Unix দর্শনের “everything is a file” প্রতিশ্রুতিটা বাস্তবে টিকিয়ে রাখে — শুধু নিয়মিত ফাইলের জন্য নয়, বরং process, device, kernel configuration, এমনকি network socket-এর জন্যও একই read/write/ioctl interface। VFS-ই সেই স্তর যা এটা সম্ভব করে।
FUSE — userspace-এ filesystem, আর তার আসল খরচ
VFS-এর operations struct শুধু kernel কোডে পূরণ করা যায় তা নয় — FUSE (Filesystem in Userspace) একটা কৌশল যেখানে kernel-এর ভেতরের একটা generic fuse_file_operations implementation সব call একটা /dev/fuse character device-এর মধ্য দিয়ে userspace প্রক্রিয়ায় ফরওয়ার্ড করে দেয়।
ব্যবহারকারীর read(fd, buf, 4096)
│
▼
VFS: file->f_op->read_iter() → fuse_file_read_iter() [kernel-এ]
│
▼
/dev/fuse-তে request লেখা, userspace প্রক্রিয়াকে জাগানো
│
▼ (context switch, userspace-এ ফিরে যাওয়া)
আপনার FUSE ডেমন: read() handler চলে, ফলাফল তৈরি করে
│
▼
/dev/fuse-তে উত্তর লেখা
│
▼ (আরেকটা context switch, kernel-এ ফিরে যাওয়া)
fuse_file_read_iter() উত্তর কপি করে ব্যবহারকারীকে দেয়সাধারণ ext4-এ একটা read() মানে এক syscall, এক kernel-space function call। FUSE-এ একই read() মানে দুইটা পূর্ণ context switch (kernel → userspace ডেমন → kernel), প্রতিটা VFS operation-এর জন্য। এটাই FUSE-এর মৌলিক খরচ — সুবিধা (যেকোনো ভাষায়, memory-safe ভাষায়, সহজে filesystem লেখা) বনাম latency-র trade-off। একটা benchmark-নির্ভর সংখ্যা: sshfs (FUSE-ভিত্তিক) সাধারণত native ext4-এর তুলনায় ছোট ফাইলে ৩-১০× ধীর, কারণ প্রতিটা operation-এ এই round-trip যোগ হয়।
ভেতরে কী ঘটছে
একটা open() এর পূর্ণ যাত্রা — VFS-এর ভেতর দিয়ে
open("/etc/nginx/nginx.conf", O_RDONLY) কল করলে কী ঘটে, ধাপে ধাপে:
- sys_open() → do_filp_open()syscall entry, path string userspace থেকে kernel-এ কপি
- path_lookupat() -- component ধরে ধরে হাঁটা"etc" → dcache lookup (hit হলে disk I/O নেই); "nginx" → dcache lookup; "nginx.conf" → dcache lookup
- dcache miss হলে: ->lookup() কলparent inode-এর inode_operations->lookup() -- filesystem-নির্দিষ্ট (ext4_lookup, nfs_lookup...) -- disk/network থেকে আনে, dcache-এ ঢোকায়
- চূড়ান্ত dentry পাওয়া গেল -- inode বের করাdentry->d_inode -- মেটাডেটা (permission, size, i_op, i_fop) ইতিমধ্যে RAM-এ
- permission checkinode->i_mode, uid/gid, ACL -- বর্তমান প্রক্রিয়ার credential-এর সাথে মেলানো
- struct file বরাদ্দ, f_op সেটinode->i_fop থেকে কপি -- এখন থেকে এই fd-তে read/write এই operations ব্যবহার করবে
- ->open() কল (থাকলে)filesystem-নির্দিষ্ট init -- ext4 কিছুই করে না প্রায়, FUSE এখানে userspace-কে জানায়
- fd table-এ বসানো, fd নম্বর ফেরতcurrent->files->fd_array[fd] = file
লক্ষ করুন — dentry cache hit হলে পুরো path resolution-এ কোনো filesystem-নির্দিষ্ট কোডই চলে না, শুধু hash lookup। filesystem-নির্দিষ্ট কোড (->lookup()) শুধু তখনই ডাকা হয় যখন cache miss। এটাই VFS ডিজাইনের কেন্দ্রীয় optimization — common case (warm cache) generic আর দ্রুত, rare case (cold cache) filesystem-এ delegate করা।
RCU-walk বনাম ref-walk — লক ছাড়া path lookup
আধুনিক Linux-এ path lookup আরও একধাপ সূক্ষ্ম। প্রতিটা dentry-তে reference count বাড়ানো-কমানো (dget/dput) মানে একটা atomic operation প্রতি component-এ — বহু thread একসাথে path resolve করলে এই atomic operation-গুলোই cache-line contention তৈরি করত।
সমাধান: RCU-walk মোড। Fast path-এ kernel কোনো reference count-ই বাড়ায় না, শুধু RCU (Read-Copy-Update, Level 11-এ বিস্তারিত) read-lock-এর নিচে dentry-গুলো পড়ে। কোনো race সন্দেহ হলে (একটা rename বা unlink মাঝপথে ঘটলে) পুরো lookup বাতিল করে ধীর, নিরাপদ ref-walk মোডে fallback করে — প্রকৃত reference counting নিয়ে। বেশিরভাগ lookup RCU-walk-এই সম্পূর্ণ হয়ে যায়, কোনো lock বা atomic op ছাড়াই।
উদাহরণ
একটা concrete সংখ্যার হিসাব — dcache-এর প্রকৃত প্রভাব
ধরুন একটা build system প্রতি সেকেন্ডে ৫০,০০০টা stat() কল করে (একটা বড় monorepo-তে incremental build চেক করার সময় এটা অস্বাভাবিক নয়), প্রতিটা path গড়ে ৬ component গভীর।
Cold dcache (প্রথমবার, বা drop_caches-এর পরে), SSD ধরে (~০.১৫ ms প্রতি uncached lookup):
Warm dcache (~৭০ ns প্রতি component, hash lookup):
| অবস্থা | মোট সময় | অনুপাত |
|---|---|---|
| Cold dcache | ৪৫ সেকেন্ড | — |
| Warm dcache | ২১ মিলিসেকেন্ড | ~২১৪০× দ্রুত |
এই বিশাল পার্থক্যটাই ব্যাখ্যা করে কেন প্রথমবার একটা বড় প্রজেক্ট checkout করে build করলে এত ধীর লাগে (cold cache, প্রতিটা path নতুন), অথচ দ্বিতীয়বার প্রায় তাৎক্ষণিক (warm cache) — এমনকি কোনো ফাইল না বদলালেও, কারণ build tool-কে তবুও প্রতিটা dependency-র path resolve করতে হয় “বদলেছে কি না” চেক করার জন্য।
নিজে চালিয়ে দেখুন
একটা মেশিনে filesystem-এর বৈচিত্র্য দেখুন
কোন filesystem type kernel সমর্থন করে (built-in বা module হিসেবে লোড করা):
cat /proc/filesystemsnodev sysfs
nodev tmpfs
nodev proc
nodev cgroup
nodev cgroup2
nodev devpts
ext3
ext2
ext4
nodev overlay
vfat
nodev fuseblk
nodev fusenodev মানে এই filesystem-এর পেছনে কোনো block device দরকার নেই — এরাই মূলত pseudo-filesystem (sysfs, tmpfs, proc, cgroup) বা network/userspace filesystem (fuse)। যাদের nodev নেই (ext4, vfat) তাদের একটা প্রকৃত disk partition বা image দরকার।
এখন এই মুহূর্তে কী কী mount হয়ে আছে:
mount | column -t | grep -E "^/|tmpfs|proc|sysfs" | head -15/dev/nvme0n1p2 on / type ext4 (rw,relatime,errors=remount-ro)
proc on /proc type proc (rw,nosuid,nodev,noexec,relatime)
sysfs on /sys type sysfs (rw,nosuid,nodev,noexec,relatime)
tmpfs on /run type tmpfs (rw,nosuid,nodev,size=1635684k)
tmpfs on /dev/shm type tmpfs (rw,nosuid,nodev)
devpts on /dev/pts type devpts (rw,gid=5,mode=620)
cgroup2 on /sys/fs/cgroup type cgroup2 (rw,nosuid,nodev,noexec,relatime)লক্ষ করুন / একমাত্র সারি যেটা প্রকৃত ডিভাইস (/dev/nvme0n1p2) থেকে এসেছে — বাকি সবগুলোর “উৎস” একটা নাম মাত্র (proc, sysfs, tmpfs), কোনো ডিভাইস নয়, কারণ এদের পেছনে কোনো disk block নেই।
এখন pseudo-filesystem-এর “লেখায় সরাসরি kernel বদলায়” আচরণটা দেখুন (root প্রয়োজন, সাবধানে):
cat /proc/sys/vm/swappiness
echo 10 | sudo tee /proc/sys/vm/swappiness
cat /proc/sys/vm/swappiness60
10
10এই তিনটা লাইনে কোনো config file পার্স হয়নি, কোনো daemon রিস্টার্ট হয়নি — write() syscall-টা সরাসরি kernel-এর একটা global variable বদলেছে, আর পরের read() তাৎক্ষণিক নতুন মান দেখাচ্ছে। এটাই procfs-এর write_iter implementation-এর কাজ — নিচে কোনো “ফাইল” নেই, শুধু একটা kernel integer।
VFS একটা একক interface-এর নিচে সম্পূর্ণ ভিন্ন প্রকৃতির filesystem একত্র করে -- কিছু disk-ব্যাকড, কিছু RAM-ব্যাকড, কিছু সম্পূর্ণ synthetic।
dentry cache-এর প্রভাব মাপা -- cold বনাম warm lookup
একটা গভীর ডিরেক্টরি গাছ বানান:
mkdir -p /tmp/deep/a/b/c/d/e/f/g/h/i/j
touch /tmp/deep/a/b/c/d/e/f/g/h/i/j/target.txt
syncCache খালি করুন (এটা dentry, inode, আর page cache তিনটাই ফেলে দেয়):
echo 3 | sudo tee /proc/sys/vm/drop_cachesCold lookup মাপুন:
strace -T -e trace=stat stat /tmp/deep/a/b/c/d/e/f/g/h/i/j/target.txt 2>&1 | tail -3stat("/tmp/deep/a/b/c/d/e/f/g/h/i/j/target.txt", {...}) = 0 <0.000412><0.000412> মানে ৪১২ μs — cold cache-এ ১০ component resolve করতে। এবার আবার চালান, কিন্তু এবার cache খালি না করে:
strace -T -e trace=stat stat /tmp/deep/a/b/c/d/e/f/g/h/i/j/target.txt 2>&1 | tail -3stat("/tmp/deep/a/b/c/d/e/f/g/h/i/j/target.txt", {...}) = 0 <0.000009><0.000009> — ৯ μs, warm cache-এ। প্রায় ৪৬× দ্রুত এই একবারের পরীক্ষাতেই — আর এটা একটা SSD-তে, যেখানে প্রতিটা uncached lookup ইতিমধ্যে দ্রুত (RAM cache আর NAND-এর ফারাক)। একটা ঘূর্ণায়মান HDD-তে বা নেটওয়ার্ক filesystem-এ (NFS) এই অনুপাত আরও অনেক বড় হবে, কারণ প্রতিটা miss তখন একটা প্রকৃত seek বা network round-trip।
আরও নির্ভরযোগ্য সংখ্যার জন্য perf stat দিয়ে বহুবার গড় নিন:
echo 3 | sudo tee /proc/sys/vm/drop_caches
perf stat -r 5 -e task-clock stat /tmp/deep/a/b/c/d/e/f/g/h/i/j/target.txtdentry/inode cache খালি করলে গভীর path lookup পরিমাপযোগ্যভাবে ধীর হয়ে যায় -- concept সেকশনের ১০০,০০০× দাবিটা এখানে সরাসরি যাচাই হচ্ছে (অনুপাত ছোট মেশিনে কম হবে, কিন্তু দিক একই)।
নিজে বানান
একটা মিনিমাল FUSE filesystem -- একটা virtual ফাইল যার content generate হয় read-এর সময়
- fusepy ইনস্টল করুন এবং libfuse আছে কি না যাচাই করুন
- clockfs.py লিখুন -- একটা virtual ফাইল "now.txt" যা প্রতিবার read করলে বর্তমান সময় দেখায়
- একটা mount point বানিয়ে চালান, foreground মোডে (-f) যাতে debug আউটপুট দেখা যায়
- দুইবার cat করে দেখুন সময় বদলাচ্ছে -- অর্থাৎ প্রতিটা read() সত্যিই handler চালাচ্ছে, ফাইল থেকে পড়ছে না
- strace দিয়ে দেখুন কতগুলো round-trip হচ্ছে প্রতি read-এ
ইনস্টলেশন
# Debian/Ubuntu
sudo apt install -y python3-pip fuse3 libfuse3-dev
pip3 install fusepy
# macOS -- FUSE এখানে সরাসরি সমর্থিত নয় (Apple সরিয়ে দিয়েছে)
# বিকল্প: macFUSE ইনস্টল করুন (https://osxfuse.github.io) -- একটা kernel
# extension, তারপর একই fusepy কোড কাজ করবে macFUSE-এর মাধ্যমে
# যদি কোনোভাবেই FUSE পাওয়া না যায় (কন্টেইনার, restricted VM,
# CI environment): নিচের কোডটা readable, চালানো ছাড়াই মেকানিজম
# বোঝা যায় -- প্রতিটা operation-এর docstring-এ কী ঘটত তা লেখা আছে।clockfs.py
#!/usr/bin/env python3
"""
clockfs.py -- একটা virtual filesystem, একটাই ফাইল।
"/now.txt" পড়লে বর্তমান সময় জেনারেট হয়, ডিস্কে কিছু লেখা নেই।
চালান: python3 clockfs.py /tmp/clockmnt
বন্ধ: fusermount -u /tmp/clockmnt (অথবা umount, macOS-এ)
"""
import os
import stat
import time
import errno
import sys
from fuse import FUSE, FuseOSError, Operations
FILENAME = "now.txt"
class ClockFS(Operations):
"""VFS-এর file_operations/inode_operations-এর userspace সমতুল্য।
প্রতিটা method এখানে ঠিক সেই ভূমিকা পালন করে যা kernel-এ
struct file_operations/inode_operations-এর একটা ফিল্ড করত।"""
# ---- getattr == inode_operations->getattr ----
def getattr(self, path, fh=None):
now = time.time()
base = dict(
st_atime=now, st_mtime=now, st_ctime=now,
st_uid=os.getuid(), st_gid=os.getgid(),
)
if path == "/":
return dict(base, st_mode=(stat.S_IFDIR | 0o755), st_nlink=2)
if path == f"/{FILENAME}":
content = self._generate().encode()
return dict(base, st_mode=(stat.S_IFREG | 0o444),
st_nlink=1, st_size=len(content))
raise FuseOSError(errno.ENOENT)
# ---- readdir == file_operations->iterate_shared ----
def readdir(self, path, fh):
return [".", "..", FILENAME]
# ---- open == file_operations->open ----
def open(self, path, flags):
if path != f"/{FILENAME}":
raise FuseOSError(errno.ENOENT)
# read-only ফাইল -- write flag থাকলে প্রত্যাখ্যান
if flags & (os.O_WRONLY | os.O_RDWR):
raise FuseOSError(errno.EACCES)
return 0 # কোনো real fd নেই, dummy handle
# ---- read == file_operations->read_iter -- এখানেই আসল কাজ ----
def read(self, path, size, offset, fh):
if path != f"/{FILENAME}":
raise FuseOSError(errno.ENOENT)
content = self._generate().encode()
return content[offset:offset + size]
def _generate(self) -> str:
"""এই ফাংশনটাই "ফাইলের বিষয়বস্তু" -- ডিস্কে কোথাও এই স্ট্রিং
সংরক্ষিত নেই, প্রতিবার read-এর সময় নতুন করে বানানো হয়।"""
return f"বর্তমান সময়: {time.strftime('%Y-%m-%d %H:%M:%S')}\n"
if __name__ == "__main__":
if len(sys.argv) != 2:
print(f"ব্যবহার: {sys.argv[0]} <mount-point>")
sys.exit(1)
mountpoint = sys.argv[1]
os.makedirs(mountpoint, exist_ok=True)
FUSE(ClockFS(), mountpoint, foreground=True, nothreads=True)চালানো
python3 clockfs.py /tmp/clockmnt &
sleep 1
cat /tmp/clockmnt/now.txt
sleep 3
cat /tmp/clockmnt/now.txtবর্তমান সময়: 2026-08-20 14:02:11
বর্তমান সময়: 2026-08-20 14:02:14তিন সেকেন্ড ব্যবধানে দুইটা cat, দুইটা ভিন্ন সময়। কোনো ফাইল ডিস্কে লেখা হয়নি — read() handler প্রতিবার চলেছে। বন্ধ করতে:
fusermount -u /tmp/clockmnt # Linux
# umount /tmp/clockmnt # macOS (macFUSE)strace -f দিয়ে cat-এর দিক থেকে দেখলে এটা একটা সাধারণ read() syscall-এর মতোই দেখাবে — cat জানেই না নিচে FUSE চলছে, VFS তার কাছ থেকে এই পুরো round-trip আড়াল করে রেখেছে। এটাই এই লেসনের কেন্দ্রীয় দাবির সরাসরি প্রমাণ: একই read(), সম্পূর্ণ ভিন্ন backend।
নিজে বাড়ান
১. একটা লেখাযোগ্য কাউন্টার ফাইল যোগ করুন। /counter নামে একটা ফাইল যোগ করুন যেখানে write() করলে একটা in-memory কাউন্টার বাড়ে (বা নির্দিষ্ট মান সেট হয়), আর read() করলে বর্তমান মান দেখায়। write method বাস্তবায়ন করতে হবে, আর getattr-এ st_mode-এ write permission যোগ করতে হবে।
২. round-trip সংখ্যা গুনুন। strace -c -f cat /tmp/clockmnt/now.txt চালিয়ে দেখুন কতগুলো syscall হয় — শুধু read নয়, getattr/open-ও। তারপর একই কমান্ড একটা সাধারণ ext4 ফাইলে চালিয়ে তুলনা করুন। পার্থক্যটা FUSE-এর userspace round-trip-এর সরাসরি প্রমাণ।
৩. একটা ডিরেক্টরি-ভিত্তিক ফাইল বানান। /proc/<pid>/status-এর মতো — /pid/<any-number> path pattern-এ একটা virtual ফাইল বানান যা psutil দিয়ে সেই PID-এর প্রকৃত তথ্য দেখায় (থাকলে), না থাকলে ENOENT। এটা procfs-এর dynamic path resolution-এর একটা মিনি-সংস্করণ।
৪. latency মাপুন। একটা ৪ KB regular ext4 ফাইল বনাম একই আকারের একটা static-content FUSE ফাইল থেকে ১০,০০০ বার পড়ে গড় latency তুলনা করুন (hyperfine বা নিজের লুপ দিয়ে)। FUSE-এর প্রতি-অপারেশন overhead-টা মাইক্রোসেকেন্ডে quantify করুন।
বাস্তব সিস্টেমে
যেখানে VFS-এর এই ডিজাইন বাস্তবে দেখা যায়
Kubernetes ও CSI drivers — Container Storage Interface driver-গুলো প্রায়ই FUSE-ভিত্তিক filesystem হিসেবে বাস্তবায়িত হয় (যেমন s3fs, gcsfuse — S3/GCS bucket-কে একটা local ডিরেক্টরির মতো দেখানো)। প্রতিটা read()/write() আসলে একটা network API call-এ পরিণত হয়, আর এই লেসনে দেখা round-trip খরচটাই সেখানে ব্যাখ্যা করে কেন gcsfuse-এর উপর ভারী I/O workload চালানো recommended নয়।
Docker/overlay2 — Docker-এর image layer-গুলো overlayfs-এ বাস্তবায়িত, যেটা নিজেই একটা VFS-স্তরের কৌশল: একাধিক directory (layer) একটার উপর আরেকটা “স্তুপ” করে একটা একক merged view দেখানো, প্রতিটা VFS operation নিচের সঠিক layer-এ route করা। docker inspect-এ UpperDir/LowerDir/MergedDir — এই তিনটাই overlayfs-এর superblock-স্তরের ধারণা।
gVisor — Google-এর container sandbox gVisor একটা userspace kernel বাস্তবায়ন করে, যেখানে filesystem access-এর জন্য নিজস্ব VFS-সদৃশ layer আছে (gofer protocol দিয়ে host filesystem-এ pass-through) — সরাসরি এই লেসনের ধারণাগুলোর একটা পুনর্বাস্তবায়ন, Go ভাষায়, নিরাপত্তা isolation-এর জন্য।
systemd আর cgroupfs — আধুনিক resource management (CPU limit, memory limit, container-এর জন্য) মূলত /sys/fs/cgroup-এ ফাইল লেখা আর পড়া দিয়ে হয় — echo 100000 > cpu.max। এই “ফাইল”-গুলোর প্রতিটা লেখা সরাসরি kernel scheduler-এর একটা structure বদলায়, ঠিক এই লেসনের swappiness উদাহরণের মতো।
FUSE-এর প্রোডাকশন সীমা — Dropbox, sshfs, এবং rclone-এর মতো টুল সবই FUSE ব্যবহার করে, কিন্তু প্রতিটা নথিতে একটা সতর্কতা থাকে: heavy random I/O বা অনেক ছোট ফাইলের workload-এ native filesystem-এর তুলনায় উল্লেখযোগ্য ধীরতা প্রত্যাশিত — ঠিক এই লেসনের round-trip হিসাবের কারণেই।
যে ভুলগুলো সবাই করে
“VFS মানে একটা 'universal file format' -- সব filesystem আসলে একই ভাবে ডেটা সংরক্ষণ করে, VFS শুধু সেটা দেখায়।”
এটা উল্টো। VFS কোনো storage format নির্ধারণ করে না — প্রতিটা filesystem সম্পূর্ণ স্বাধীনভাবে সিদ্ধান্ত নেয় ডেটা কীভাবে সংরক্ষিত হবে (ext4-এর extent tree, tmpfs-এর কিছুই না — সরাসরি RAM, NFS-এর নেটওয়ার্ক প্রোটোকল)। VFS শুধু একটা আচরণগত চুক্তি — “তোমার filesystem-এ read_iter নামের একটা ফাংশন থাকবে, যা এই signature মানবে, আর এই semantics মেনে চলবে (byte count ফেরত দেবে বা negative errno)”।
এটা অনেকটা একটা shipping standard-এর মতো — কনটেইনারের ভেতরে কী আছে তা নিয়ে standard কিছু বলে না, শুধু কনটেইনারের বাইরের আকার আর হ্যান্ডেল কোথায় থাকবে তা নির্ধারণ করে যাতে যেকোনো ক্রেন সেটা তুলতে পারে। VFS ঠিক তেমনি filesystem-এর ভেতরের গঠন নিয়ে উদাসীন, শুধু বাইরের interface-টা standardize করে।
“dentry cache আর inode cache একই জিনিস, বা dentry cache শুধু ডিরেক্টরির জন্য।”
দুইটাই আলাদা cache, আলাদা উদ্দেশ্যে, আর dentry শুধু ডিরেক্টরির জন্য নয়। inode cache ফাইলের মেটাডেটা মনে রাখে (permission, size, timestamps, block pointer) — এটা প্রতিটা ফাইল/ডিরেক্টরির জন্য প্রযোজ্য। dentry cache নাম-থেকে-inode ম্যাপিং মনে রাখে — এবং প্রতিটা path component-এর জন্য একটা dentry থাকে, তা সেটা ডিরেক্টরি হোক বা সাধারণ ফাইল (/etc/passwd-এর জন্য passwd-এরও একটা dentry আছে, শুধু etc-এর নয়)।
এই দুটো একসাথে কাজ করে কিন্তু স্বাধীনভাবে evict হতে পারে — একটা inode cache-এ থাকতে পারে অথচ তার dentry evict হয়ে গেছে (hard link-এর ক্ষেত্রে সাধারণ, যেখানে একই inode-এর একাধিক নাম), অথবা উল্টোটা। slabtop-এ dentry আর ext4_inode_cache (বা filesystem-নির্দিষ্ট নাম) আলাদা সারি হিসেবে দেখা যায় ঠিক এই কারণেই।
“procfs/sysfs-এর ফাইলগুলো আসলে বুট করার সময় জেনারেট হয় আর ডিস্কের মতোই RAM-এ 'সংরক্ষিত' থাকে, tmpfs-এর মতো।”
এটা tmpfs-এর সাথে গুলিয়ে ফেলা একটা সাধারণ ভুল ধারণা। tmpfs-এ ফাইল সত্যিই RAM-এ সংরক্ষিত থাকে — আপনি একটা ফাইল লিখলে সেই বাইটগুলো page cache-এ বসে থাকে যতক্ষণ না মুছে ফেলেন বা reboot হয়। কিন্তু procfs/sysfs-এ কোনো সংরক্ষণ নেই — read() কল হওয়ার মুহূর্তে ফাংশন চলে, বর্তমান kernel state থেকে string বানায়, আর সেটাই ফেরত দেয়। কল শেষ হলে সেই string-এর আর কোনো অস্তিত্ব থাকে না কোথাও।
এটা যাচাই করার সহজ উপায়: /proc/uptime দুইবার দ্রুত পড়ুন — দুইবারই ভিন্ন সংখ্যা পাবেন, কারণ প্রতিবার ফাংশন নতুন করে চলে। একটা tmpfs ফাইল দুইবার পড়লে (মাঝে কেউ না লিখলে) সবসময় একই বাইট পাবেন, কারণ সেখানে সত্যিই কিছু “সংরক্ষিত” আছে।
“FUSE ধীর কারণ Python/userspace ভাষা ধীর -- C-তে FUSE লিখলে kernel filesystem-এর মতোই দ্রুত হবে।”
ভাষার গতি এখানে দ্বিতীয় সারির কারণ। মূল খরচ হলো প্রতিটা VFS operation-এ বাধ্যতামূলক দুইটা context switch (kernel → userspace ডেমন → kernel), যা ভাষা নির্বিশেষে ঘটে। C-তে লেখা একটা FUSE filesystem-ও এই context switch খরচ এড়াতে পারে না — শুধু handler-এর ভেতরের কাজটা দ্রুত হবে, কিন্তু round-trip-টা থেকেই যায়।
একটা kernel-native filesystem (ext4)-এর read_iter সরাসরি kernel address space-এ চলে, কোনো context switch ছাড়াই — সেটাই আসল পার্থক্য, ভাষা নয়। C-তে লেখা FUSE filesystem হয়তো handler-এর ভেতরের CPU কাজ Python-এর চেয়ে দ্রুত করবে, কিন্তু round-trip overhead (যা ছোট, ঘন ঘন operation-এ প্রায়ই প্রধান খরচ) প্রায় একই থাকে। এই কারণেই high-performance FUSE filesystem (যেমন কিছু ডেটাবেসের FUSE layer) ব্যাচিং আর caching দিয়ে round-trip সংখ্যা কমানোর চেষ্টা করে — ভাষা বদলে নয়, io_uring লেসনের batching নীতির মতোই।
বুঝেছেন কি না দেখুন
1আপনি একটা নতুন filesystem type লিখছেন। VFS-এর সাথে integrate করতে আপনাকে ঠিক কোন তিনটা operations struct পূরণ করতে হবে (কমপক্ষে), আর প্রতিটার দায়িত্ব কী? আর কেন এই বিভাজনটা তিন ভাগে করা হয়েছে, একটা বড় struct-এ নয়?
যুক্তি
তিনটা মূল struct:
| Struct | দায়িত্ব | উদাহরণ ফিল্ড |
|---|---|---|
super_operations | পুরো filesystem instance-এর জীবনচক্র | alloc_inode, write_inode, statfs, sync_fs |
inode_operations | একটা inode-এর উপর namespace অপারেশন | lookup, create, mkdir, rename, symlink |
file_operations | একটা খোলা file handle-এর উপর data অপারেশন | read_iter, write_iter, open, llseek, mmap |
তিন ভাগে বিভাজনের কারণ concept সেকশনের চারটা object-এর সাথেই মেলে — প্রতিটা struct তার সংশ্লিষ্ট object-এর “প্রশ্ন”-এর সাথে বাঁধা। এটা আলাদা রাখার সুবিধা হলো granularity: একটা filesystem হয়তো inode_operations-এ symlink বাস্তবায়ন করতে চায় না (কিছু filesystem symlink সমর্থন করে না), কিন্তু file_operations-এর read_iter অবশ্যই লাগবে। যদি একটা মনোলিথিক struct হতো, প্রতিটা filesystem-কে সব ধরনের অপারেশনের (data + namespace + lifecycle) জন্য একসাথে সিদ্ধান্ত নিতে হতো, আর একটা read-only pseudo-filesystem-কেও mkdir/rename-এর ডামি entry রাখতে হতো অর্থহীনভাবে।
এই বিভাজনটা object-oriented design-এর interface segregation principle-এর একটা প্রাক-OOP উদাহরণ — একটা বড় interface-এর বদলে একাধিক ছোট, নির্দিষ্ট-উদ্দেশ্যের interface। Level 5-এর programming languages module-এ এই নীতিটা প্রথাগত OOP ভাষায় কীভাবে প্রকাশ পায় তা দেখব।
2একটা প্রোগ্রাম /mnt/nfs-share/data.txt খুলতে চাইছে, যেখানে /mnt/nfs-share একটা NFS mount, আর NFS সার্ভারের সাথে নেটওয়ার্ক latency ৫০ ms। open() কল করলে VFS-এর কোন কোন ধাপে এই ৫০ ms latency আঘাত করবে, আর কোন ধাপে করবে না? dentry cache warm থাকলে দ্বিতীয়বার এই ফাইল খুলতে কতটা দ্রুত হবে?
প্রয়োগ
/mnt/nfs-share/data.txt খুলতে চাইছে, যেখানে /mnt/nfs-share একটা NFS mount, আর NFS সার্ভারের সাথে নেটওয়ার্ক latency ৫০ ms। open() কল করলে VFS-এর কোন কোন ধাপে এই ৫০ ms latency আঘাত করবে, আর কোন ধাপে করবে না? dentry cache warm থাকলে দ্বিতীয়বার এই ফাইল খুলতে কতটা দ্রুত হবে?প্রথমবার (cold), path resolution প্রতিটা component-এ NFS-এর inode_operations->lookup() কল করবে যদি dcache-এ না থাকে — আর প্রতিটা lookup() একটা network round-trip (NFS প্রোটোকলে LOOKUP RPC):
| ধাপ | dcache-এ আছে? | নেটওয়ার্ক খরচ |
|---|---|---|
| “mnt” resolve | সাধারণত হ্যাঁ (স্থানীয় root-এর কাছাকাছি, প্রায়ই আগে থেকে access করা) | ০ |
| “nfs-share” resolve (mount point) | হ্যাঁ, কিন্তু এরপর NFS superblock-এ “লাফ” | ০ (mount point resolution local) |
| “data.txt” resolve (NFS-এর ভেতরে) | না (প্রথমবার) → NFS-এর lookup() কল | ~৫০ ms |
Permission check, struct file তৈরি | local | ০ |
মোট cold খরচ: ~৫০ ms, প্রায় পুরোটাই একটা single network round-trip-এ, কারণ শুধু NFS-এর ভেতরের component-টাই miss হয়েছে (বাকিগুলো local mount tree-তে, আগে থেকেই warm)।
দ্বিতীয়বার (dcache warm): data.txt-এর dentry এখন cache-এ আছে, NFS attribute cache-ও (যদি actimeo timeout পার না হয়ে থাকে) সম্ভবত এখনো বৈধ। পুরো lookup হয়ে যায় local hash lookup, নেটওয়ার্কে কিছুই যায় না:
এখানে একটা গুরুত্বপূর্ণ বাস্তব সতর্কতা: NFS-এর dentry/attribute cache-এর একটা timeout থাকে (ডিফল্ট কয়েক সেকেন্ড, actimeo মাউন্ট অপশনে কনফিগারযোগ্য), কারণ একটা দূরবর্তী ফাইল অন্য client বদলে দিতে পারে যেকোনো মুহূর্তে — local ext4-এর dcache-এর মতো “চিরকাল বৈধ” ধরে নেওয়া নিরাপদ নয়। এই cache-consistency সমস্যাটাই distributed filesystem ডিজাইনের একটা কেন্দ্রীয় চ্যালেঞ্জ, Level 9-এর distributed systems module-এ যেখানে CAP theorem-এর প্রেক্ষাপটে বিস্তারিত আলোচনা হবে।
3ls -la /proc/cpuinfo চালালে file size প্রায়ই 0 দেখায়, অথচ cat /proc/cpuinfo করলে কয়েক হাজার বাইট আউটপুট আসে। এটা কি একটা bug? ব্যাখ্যা করুন VFS-এর কোন নকশা-সিদ্ধান্তের ফলে এটা ঘটে।
যুক্তি
ls -la /proc/cpuinfo চালালে file size প্রায়ই 0 দেখায়, অথচ cat /proc/cpuinfo করলে কয়েক হাজার বাইট আউটপুট আসে। এটা কি একটা bug? ব্যাখ্যা করুন VFS-এর কোন নকশা-সিদ্ধান্তের ফলে এটা ঘটে।এটা bug নয়, বরং pseudo-filesystem-এর প্রকৃতির একটা সৎ প্রতিফলন। ls -la একটা stat()/lstat() syscall কল করে, যেটা VFS-এর getattr (বা super_operations/inode_operations-এর সংশ্লিষ্ট ফাংশন) ব্যবহার করে — এবং সেই ফাংশন st_size ফিল্ডে একটা সংখ্যা বসাতে বাধ্য, ফাইলের বিষয়বস্তু তৈরি না করেই।
কিন্তু /proc/cpuinfo-র “বিষয়বস্তু” প্রতিবার read() কল হলে তবেই তৈরি হয় (CPU-র সংখ্যা, model name, flags — সব read_iter-এর ভেতরে string হিসেবে বানানো হয়)। stat()-এর সময় সেই string বানিয়ে তার length মাপা একটা অপ্রয়োজনীয় খরচ হতো — পুরো read-এর কাজটাই আগেভাগে করে ফেলতে হতো, শুধু একটা সংখ্যার জন্য। তাই procfs-এর বেশিরভাগ ফাইলের getattr implementation সহজভাবে st_size = 0 বসিয়ে দেয় (বা কিছু ক্ষেত্রে একটা রক্ষণশীল আনুমানিক মান — PAGE_SIZE)।
এটার সরাসরি প্রভাব: যেকোনো টুল যেটা stat()-এর st_size দেখে আগেভাগে buffer বরাদ্দ করে (যেমন কিছু নিম্নমানের read-loop যা malloc(st_size) করে) procfs-এ ভুল আচরণ করবে। সঠিক প্যাটার্ন হলো read() করতে থাকা যতক্ষণ না 0 বাইট ফেরত আসে (EOF), size জানার উপর নির্ভর না করে — যা আসলে যেকোনো সাধারণ stream-এর জন্যও সঠিক অভ্যাস (pipe, socket-এও st_size অর্থবহ নয়)।
VFS-এর নকশা-নীতিটা এখানে স্পষ্ট: getattr আর read সম্পূর্ণ স্বাধীন operations, একটা অন্যটার সাথে সামঞ্জস্যপূর্ণ হতে বাধ্য নয়। এই স্বাধীনতাটাই procfs-কে সস্তায় metadata দিতে দেয় বিষয়বস্তু তৈরি না করেই — কিন্তু এর মূল্য হলো st_size-এর উপর নির্ভরতা ভাঙা।
4আপনি একটা নতুন feature ডিজাইন করছেন: একটা ডিরেক্টরি যেখানে প্রতিটা ফাইল একটা চলমান ML inference job-এর output represent করে, আর ফাইলের বিষয়বস্তু ধীরে ধীরে (কয়েক সেকেন্ডে) generate হয় একটা background process-এ। এটা বাস্তবায়নের জন্য FUSE বনাম একটা kernel module (custom VFS filesystem) — কোনটা বেছে নেবেন, আর কেন? অন্তত তিনটা trade-off বিবেচনা করুন।
ডিজাইন
এই সিদ্ধান্তের কাঠামোটা io_uring লেসনের production-decision প্রশ্নের সাথে মিল — কোনো একক সঠিক উত্তর নেই, কিন্তু trade-off-গুলো নির্দিষ্ট।
| বিবেচনা | FUSE | Kernel module |
|---|---|---|
| Development speed | Python/Rust/Go-তে লেখা, সাধারণ userspace debug টুল (gdb, print) কাজ করে | C, kernel-স্তরের debugging (kgdb, ftrace), অনেক ধীর iteration cycle |
| Crash isolation | FUSE ডেমন crash করলে filesystem হ্যাং করে বা ENOTCONN দেয়, kernel অক্ষত থাকে | একটা বাগ পুরো kernel panic করতে পারে — পুরো সিস্টেম down |
| Performance | প্রতি operation দুইটা context switch (এই লেসনের কেন্দ্রীয় হিসাব) | সরাসরি kernel-এ, কোনো round-trip নেই |
| ML inference-এর জন্য প্রাসঙ্গিকতা | Inference-এর latency (সেকেন্ড) FUSE-এর overhead (মাইক্রোসেকেন্ড)-এর তুলনায় বিশাল — overhead কার্যত অদৃশ্য | কোনো বাস্তব সুবিধা পাওয়া যাবে না, শুধু ঝুঁকি বাড়বে |
| Deployment | একটা userspace binary, distribute করা সহজ, kernel version-নির্ভরতা কম | kernel-নির্দিষ্ট (version, config), signing প্রয়োজন secure boot-এ, distribution জটিল |
সিদ্ধান্ত: FUSE, স্পষ্টভাবে। কারণটা কর্মভারের প্রকৃতিতেই — ML inference output কয়েক সেকেন্ড ধরে তৈরি হয়, যেখানে FUSE-এর round-trip overhead মাইক্রোসেকেন্ড স্কেলে। Amdahl-এর নিয়ম অনুযায়ী (io_uring লেসনের misconception সেকশন) — যে খরচ মোট সময়ের নগণ্য অংশ, সেটা অপ্টিমাইজ করার কোনো মূল্য নেই। উল্টোদিকে kernel module-এর ঝুঁকি (crash isolation-এর অভাব, deployment জটিলতা, ধীর development) কোনো প্রকৃত সুবিধা ছাড়াই বহন করতে হবে।
Kernel module তখনই যুক্তিসঙ্গত হতো যদি workload latency-critical হতো (প্রতি-অপারেশন মাইক্রোসেকেন্ড গুরুত্বপূর্ণ, যেমন একটা high-frequency trading system-এর log filesystem) — এই ক্ষেত্রে তা নয়। বাস্তব উদাহরণ হিসেবে, এই ধরনের “virtual output as files” প্যাটার্ন বহু ML প্ল্যাটফর্মে FUSE দিয়েই বাস্তবায়িত (যেমন কিছু experiment-tracking টুল যা S3-backed artifact একটা local ডিরেক্টরির মতো mount করে দেখায়) — শিল্পের বাস্তব অভ্যাসও এই যুক্তির সাথে মেলে।
5আপনার সিস্টেমে একটা প্রক্রিয়া একটা ফাইল খুলে রেখেছে (fd এখনো খোলা), কিন্তু অন্য একটা প্রক্রিয়া সেই একই ফাইলকে unlink() করে ফেলেছে। প্রথম প্রক্রিয়াটা এখনো সেই fd দিয়ে read()/write() করতে পারছে, যদিও ls করলে ফাইলটা আর দেখা যায় না। VFS-এর কোন object-গুলো এই আচরণটা সম্ভব করে, আর কেন?
প্রয়োগ
fd এখনো খোলা), কিন্তু অন্য একটা প্রক্রিয়া সেই একই ফাইলকে unlink() করে ফেলেছে। প্রথম প্রক্রিয়াটা এখনো সেই fd দিয়ে read()/write() করতে পারছে, যদিও ls করলে ফাইলটা আর দেখা যায় না। VFS-এর কোন object-গুলো এই আচরণটা সম্ভব করে, আর কেন?এই classic Unix আচরণটা সরাসরি ব্যাখ্যা করা যায় concept সেকশনের object-গুলোর জীবনকাল আলাদা হওয়ার কারণে।
unlink() আসলে কী করে: এটা inode মুছে দেয় না — এটা শুধু একটা dentry সরিয়ে দেয় (নাম-থেকে-inode ম্যাপিং বাতিল করে) এবং inode-এর link count এক কমায়। inode নিজে টিকে থাকে যতক্ষণ তার reference count শূন্য না হয়।
| Object | unlink()-এর পরে অবস্থা |
|---|---|
dentry (নাম “file.txt”) | মুছে ফেলা হয় dcache থেকে — ls আর দেখাবে না |
inode | link count = 0 হয়ে গেছে, কিন্তু এখনো মেমরিতে বেঁচে আছে |
struct file (প্রথম প্রক্রিয়ার খোলা fd) | অপরিবর্তিত — এখনো সরাসরি সেই inode-কে point করছে |
struct file কখনোই নামের (dentry) মাধ্যমে ফাইলে পৌঁছায় না প্রতিটা operation-এ — এটা একবার open()-এর সময় inode resolve করে ফেলে, আর তারপর সরাসরি সেই struct inode-এর pointer ধরে রাখে। তাই dentry মুছে গেলেও struct file-এর কাছে path আর দরকার নেই, শুধু inode pointer লাগে — আর সেটা এখনো বৈধ।
Inode কখন সত্যিই মুছবে: VFS reference counting করে — যতক্ষণ কোনো struct file (অর্থাৎ কোনো খোলা fd) সেই inode-কে ধরে আছে, filesystem-নির্দিষ্ট evict_inode/delete_inode কল হয় না, disk block মুক্ত হয় না। শেষ প্রক্রিয়াটা close() করার মুহূর্তে (link count ইতিমধ্যে 0), inode reference count-ও 0-এ নামে, আর তখন প্রকৃত deletion ঘটে — disk block মুক্ত হয়, inode structure মেমরি থেকে সরে।
এই আচরণের একটা প্রায়োগিক ব্যবহার — লগ ফাইলের জন্য: একটা প্রোগ্রাম যদি একটা বড় temp/log ফাইল খুলে রাখে আর unlink() করে দেয় সাথে সাথে, ফাইলটা “নামহীন” হয়ে যায় কিন্তু প্রোগ্রাম চলতে থাকা পর্যন্ত ব্যবহারযোগ্য থাকে — প্রোগ্রাম crash করলেও kernel automatically disk space মুক্ত করে দেয় (fd বন্ধ হওয়ার সাথে সাথে), কোনো cleanup script ছাড়াই। lsof | grep deleted চালিয়ে একটা লাইভ সিস্টেমে এই ধরনের “deleted but open” ফাইল দেখা যায় — প্রায়ই একটা রোটেট-করা log ফাইল যার পুরনো fd এখনো কোনো প্রক্রিয়া ধরে আছে, যতক্ষণ না সেই প্রক্রিয়া restart হয়ে নতুন fd খোলে।
এই reference-counted lifetime-এর ধারণাটা io_uring লেসনের “অসমকালীন resource lifetime” আলোচনার সাথে সরাসরি সম্পর্কিত (সেখানে io_uring-এর submitted কাজের buffer lifetime), আর garbage collection-এর reference counting কৌশলের (Level 5-এর programming languages module) একটা kernel-স্তরের সমতুল্য।
এরপর কী
পরের লেসন — I/O মডেল: blocking থেকে epoll পর্যন্ত
VFS আমাদের দেখিয়েছে একটা read() কীভাবে সম্পূর্ণ ভিন্ন filesystem-এ একই আচরণ করে — কিন্তু একটা প্রশ্ন এখনো অনালোচিত রয়ে গেছে: যখন সেই ডেটা তাৎক্ষণিক প্রস্তুত না থাকে (network socket-এ কোনো প্যাকেট এখনো আসেনি, বা disk I/O চলমান), তখন read() কল করা প্রক্রিয়াটার কী হয়? সে কি অপেক্ষা করে (block করে)? নাকি সাথে সাথে ফিরে আসে খালি হাতে?
পরের লেসনে আমরা এই প্রশ্নের চারপাশে গড়ে ওঠা পাঁচটা মৌলিক I/O model দেখব — blocking, non-blocking, multiplexing (select/poll/epoll), signal-driven, আর asynchronous — আর ইতিহাসের একটা কুখ্যাত সমস্যা: C10K — একটা সার্ভারে একসাথে ১০,০০০ connection সামলানোর চ্যালেঞ্জ, যেটা thread-per-connection মডেলকে ভেঙে দিয়েছিল আর epoll-এর জন্ম দিয়েছিল। এই লেসনের VFS জ্ঞান সেখানে সরাসরি কাজে লাগবে — কেন epoll regular file-এ কাজ করে না (আগেই দেখেছি io_uring লেসনে, কিন্তু এবার কারণটা VFS-এর দৃষ্টিকোণ থেকে সম্পূর্ণ পরিষ্কার হবে), আর কেন সেই সীমাবদ্ধতাই শেষ পর্যন্ত io_uring-এর জন্ম দিয়েছে।
আরও পড়ুন
- Linux VFS documentation -- Overview of the Linux Virtual File System — Linux kernel documentation · superblock_operations, inode_operations, file_operations প্রতিটার প্রতিটা ফিল্ডের প্রামাণ্য বর্ণনা এখানেই
- The Linux Programming Interface, Chapter 14: File Systems — Michael Kerrisk · VFS object-গুলোর userspace থেকে দৃশ্যমান আচরণ -- mount, stat, path resolution -- বিস্তারিত ব্যাখ্যাসহ
- libfuse -- the reference FUSE userspace library — libfuse project · এই লেসনের build সেকশনের Python কোড fusepy-র মাধ্যমে এই লাইব্রেরির উপর নির্ভর করে; high-level API-র উদাহরণ example/ ডিরেক্টরিতে
- Documentation/filesystems/path-lookup.rst — Linux kernel documentation · RCU-walk আর ref-walk -- dentry cache-এর lock-free fast path -- এই লেসনের hood সেকশনের ভিত্তি