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

Inode ও Filesystem গঠন — যেখানে ফাইলের নাম-ই থাকে না

Filesystem Inodes: On-Disk Structure

fd-র তৃতীয় স্তর ছিল inode, কিন্তু ভেতরটা তখন খোলা হয়নি। এই লেসনে ডিস্কের প্রকৃত বিন্যাস দেখা হবে -- superblock থেকে data block পর্যন্ত -- আর সবচেয়ে গুরুত্বপূর্ণ আবিষ্কারটা করা হবে: inode-এ ফাইলের নাম থাকে না, নামটা থাকে directory নামের একটা সাধারণ ফাইলে। এই একটা সিদ্ধান্ত থেকেই hard link, rm-এর প্রকৃত অর্থ (unlink), আর ব্লক-অ্যাড্রেসিং-এর পুরো ডিজাইন বেরিয়ে আসে।

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

  • একটা ext-পরিবার filesystem-এর চারটা মৌলিক অংশ -- superblock, inode bitmap, block bitmap, inode table, data block -- ডিস্কে কীভাবে সাজানো থাকে এবং প্রতিটার কাজ কী, তা নির্ভুলভাবে বর্ণনা করতে পারবেন
  • inode-এ ঠিক কী থাকে আর কী নেই তা স্পষ্টভাবে বলতে পারবেন -- বিশেষত কেন ফাইলের নাম inode-এ থাকে না, directory কীভাবে একটা সাধারণ ফাইল যার ভেতরে name→inode entry থাকে, আর এই একটা তথ্য থেকে hard link, rm/unlink, আর deleted-but-open ফাইলের আচরণ কীভাবে সরাসরি বেরিয়ে আসে তা ব্যাখ্যা করতে পারবেন
  • hard link আর symbolic link-এর মধ্যে পার্থক্য -- link count কীভাবে বদলায়, কোনটা inode ভাগ করে, কেন symlink আলাদা inode আর ডেটা ব্লক দখল করে, আর কেন directory hard-link করা যায় না -- উদাহরণসহ ব্যাখ্যা করতে পারবেন
  • direct, single/double/triple indirect ব্লক-পয়েন্টার স্কিম হাতে এঁকে ৪KB ব্লক আর ৪-বাইট পয়েন্টার ধরে সর্বোচ্চ ফাইল-আকার নিজে হিসাব করতে পারবেন, এবং কেন আধুনিক ext4 সেই স্কিম ছেড়ে extent-based addressing-এ গেছে তা ব্যাখ্যা করতে পারবেন
  • sparse file আর hole কী, কীভাবে সেগুলো তৈরি হয়, আর `ls -l` (apparent size) বনাম `du` (প্রকৃত ডিস্ক ব্যবহার)-এর পার্থক্য দিয়ে সেগুলো শনাক্ত করতে পারবেন
  • `stat`, `ls -i`, `debugfs`, আর নিজে লেখা readdir+stat প্রোগ্রাম দিয়ে একটা directory tree-তে hard-link গ্রুপ (device, inode) জোড়া মিলিয়ে সনাক্ত করতে পারবেন

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

আগে এটা বুঝি

আগের লেসনে একটা লাইন লেখা হয়েছিল যা এখন ফেরত আনা দরকার: fd-র তৃতীয় স্তর inode-এ “size, permission, owner, timestamp, link count, ডেটা ব্লকের ঠিকানা” থাকে। এই তালিকাটা আরেকবার পড়ুন। একটা জিনিস অনুপস্থিত — ফাইলের নাম

এটা টাইপো না। inode সত্যিই জানে না তার ফাইলের নাম কী। প্রমাণ চাইলে, ext4-এ একটা সাধারণ পরীক্ষা:

echo "hello" > /tmp/a.txt
ln /tmp/a.txt /tmp/b.txt        # hard link
stat -c '%n -> inode %i, links %h' /tmp/a.txt /tmp/b.txt
/tmp/a.txt -> inode 4218903, links 2
/tmp/b.txt -> inode 4218903, links 2

দুইটা সম্পূর্ণ ভিন্ন নাম, দুইটা ভিন্ন directory entry — কিন্তু একই inode নম্বর, আর inode নিজে বলছে তার ২টা link আছে। /tmp/a.txt মুছে ফেলুন — /tmp/b.txt দিয়ে ফাইলের ডেটা এখনো পড়া যাবে, বিন্দুমাত্র সমস্যা ছাড়া। “নাম” আর “ফাইল” যে একই জিনিস না, এটাই Unix filesystem-এর সবচেয়ে কম বোঝা কিন্তু সবচেয়ে বেশি ফলাফলবাহী ডিজাইন-সিদ্ধান্ত।

এই লেসনের কাজ তিনটা: ডিস্কে filesystem-টা আসলে কেমন দেখতে (superblock থেকে data block পর্যন্ত), directory যে নিজেই একটা ফাইল সেই ধারণা থেকে hard link/unlink-এর পুরো সেমান্টিক্স বের করা, আর inode কীভাবে জানে তার ডেটা ডিস্কের কোথায় আছে — direct block থেকে শুরু করে indirect block হয়ে আধুনিক extent পর্যন্ত।

মূল ধারণা

একটা ext4 filesystem ডিস্কে যেমন দেখতে

একটা ব্লক ডিভাইস (পার্টিশন) ফরম্যাট করলে mkfs.ext4 তার উপর একটা নির্দিষ্ট বিন্যাস বসিয়ে দেয়। পুরো ডিভাইসটাকে ভাগ করা হয় কয়েকটা block group-এ (প্রতিটা সাধারণত ১২৮MB, ৪KB ব্লকে), আর প্রতিটা group-এর নিজস্ব ছোট সংস্করণ থাকে নিচের কাঠামোর:

অংশকাজআকার (typical)
superblockপুরো filesystem-এর metadata: মোট block/inode সংখ্যা, block size, মাউন্ট গণনা, filesystem state, feature flags১ ব্লক (প্রথম group-এ প্রধান কপি, বাকিগুলোতে backup)
group descriptor tableপ্রতিটা block group কোথায় শুরু, কয়টা ফ্রি ব্লক/inode বাকিকয়েক ব্লক
block bitmapএই group-এর কোন data block ব্যবহৃত, কোনটা ফ্রি — ১ বিট প্রতি ব্লক১ ব্লক (৪KB × ৮ = ৩২,৭৬৮ ব্লক কভার করে)
inode bitmapকোন inode নম্বর ব্যবহৃত, কোনটা ফ্রি — ১ বিট প্রতি inode১ ব্লক
inode tableপ্রতিটা inode-এর প্রকৃত struct (আকার, permission, ব্লক-ঠিকানা…), সারিবদ্ধ arrayকয়েক হাজার ব্লক
data blocksপ্রকৃত ফাইল-বিষয়বস্তু আর directory entryবাকি সব জায়গা
 Block Group 0
 ┌──────────┬──────────┬────────┬────────┬─────────────┬──────────────────────┐
 │superblock│  group   │ block  │ inode  │ inode table │     data blocks      │
 │ (backup) │descriptor│ bitmap │ bitmap │ (inode #1..N)│  (ফাইল + directory   │
 │          │  table   │        │        │              │   বিষয়বস্তু)          │
 └──────────┴──────────┴────────┴────────┴─────────────┴──────────────────────┘
   ১ ব্লক      কয়েক ব্লক   ১ ব্লক    ১ ব্লক    কয়েক হাজার ব্লক      বাকি সব

 প্রতিটা inode bitmap-এর বিট আর inode table-এর একটা entry এক-এক সম্পর্কে যুক্ত:
   bit #7 = 1  ──────────►  inode table-এর inode #7 ব্যবহৃত
   bit #8 = 0  ──────────►  inode table-এর inode #8 ফ্রি -- পরের mkfs/create এখানে যাবে
একটা block group-এর ভেতরের বিন্যাস। superblock আর group descriptor প্রতিটা group-এ (বা redundancy-র জন্য নির্বাচিত group-এ) কপি থাকে -- একটা copy নষ্ট হলেও e2fsck অন্য কপি থেকে উদ্ধার করতে পারে।

কেন bitmap? ফ্রি ব্লক আর ফ্রি inode খোঁজা একটা ঘন ঘন হওয়া অপারেশন (প্রতিটা open(O_CREAT), প্রতিটা write যা ফাইল বড় করে)। একটা bitmap-এ find_first_zero_bit() চালানো O(block_size) — ৪KB bitmap মানে ৩২,৭৬৮ সম্ভাব্য এন্ট্রি এক CPU cache line-স্কেলে স্ক্যান করা যায়। linked list বা অন্য কোনো কাঠামোর চেয়ে এটা বহুগুণ দ্রুত আর সহজে ডিস্কে সরাসরি bit হিসেবে রাখা যায়।

inode-এ কী থাকে — আর কী নেই

struct ext4_inode (সরলীকৃত, ext4-specific ফিল্ড বাদ দিয়ে POSIX-স্তরের মূল অংশ):

struct ext4_inode {
    __u16 i_mode;        /* ফাইলের ধরন (regular/dir/symlink/...) + permission bit */
    __u16 i_uid, i_gid;  /* মালিক */
    __u32 i_size_lo;     /* ফাইলের আকার (নিচের ৩২ বিট, i_size_high দিয়ে ৬৪-বিট পর্যন্ত) */
    __u32 i_atime, i_mtime, i_ctime;   /* access, modify, inode-change time */
    __u16 i_links_count;  /* ★ hard link count -- এই লেসনের কেন্দ্রীয় ফিল্ড */
    __u32 i_blocks_lo;    /* কয়টা 512-বাইট sector দখলে (sparse file বোঝার চাবি) */
    __u32 i_block[15];    /* ★ ডেটা ব্লকের ঠিকানা -- অথবা extent tree, নিচে বিস্তারিত */
    __u32 i_flags;        /* EXTENTS_FL, IMMUTABLE_FL, ... */
};

এই struct-এ নেই: filename, path, parent directory-র কোনো রেফারেন্স। inode শুধু জানে “আমি কী” (mode, size, timestamp, ownership) আর “আমার ডেটা কোথায়” (block pointer)। “আমার নাম কী” আর “আমি কোথায় আছি” — এই দুইটা প্রশ্নের উত্তর inode-এর কাছে নেই, কারণ এই প্রশ্নগুলোর উত্তর একাধিক হতে পারে। একটা inode-এর একাধিক নাম থাকতে পারে (hard link), একাধিক directory-তে থাকতে পারে — inode নিজে একটাই থাকা সত্ত্বেও।

Directory একটা ফাইল — আর এই একটা বাক্য থেকে বাকি সব বেরোয়

ls -la-তে একটা directory-র নিজস্ব open()+read() করা যায় (readdir() সিস্টেম কল আসলে এটাই করে, একটা কার্নেল-বোঝা ফরম্যাটে)। একটা সরলীকৃত ext4 directory entry-র বিন্যাস:

struct ext4_dir_entry_2 {
    __u32 inode;          /* কোন inode-এর দিকে নির্দেশ করছে */
    __u16 rec_len;         /* এই entry-র মোট দৈর্ঘ্য (byte) */
    __u8  name_len;        /* নামের দৈর্ঘ্য */
    __u8  file_type;       /* regular/dir/symlink/... (dtype hint, inode-এও আছে) */
    char  name[name_len];  /* নাম নিজে, null-terminated না */
};

একটা directory-র data block, ধারণাগতভাবে:

inode 4218903, name_len=5, name="a.txt"
inode 4218903, name_len=5, name="b.txt"     ← আমাদের hard link, একই inode!
inode 4218904, name_len=3, name="log"
inode 4218905, name_len=1, name="."         ← নিজের দিকে
inode 4218200, name_len=2, name=".."        ← parent directory-র দিকে

এখন তিনটা “রহস্য” নিজে থেকেই সমাধান হয়ে যায়:

১. Hard link কেন সম্ভব? কারণ link() syscall মূলত একটা directory-র data block-এ নতুন একটা entry যোগ করে যেটা বিদ্যমান কোনো inode নম্বরের দিকে নির্দেশ করে, আর সেই inode-এর i_links_count এক বাড়ায়। কোনো নতুন inode বানানো হয় না, কোনো ডেটা কপি হয় না — শুধু “এই নামটাও ঐ inode-এর দিকে নির্দেশ করে” এই তথ্যটা লেখা হয়।

ln /tmp/a.txt /tmp/b.txt
# কার্নেলে: inode 4218903-এর জন্য /tmp directory-তে "b.txt" নামে
# একটা নতুন entry যোগ হলো, inode-এর i_links_count 1 → 2

২. rm কেন আসলে unlink? rm কমান্ডটা যে syscall ডাকে তার নাম unlink(), “delete” না — নামটা ইচ্ছাকৃত এবং নির্ভুল। এটা করে ঠিক দুইটা কাজ: directory-র data block থেকে ঐ নামের entry সরিয়ে ফেলা, আর inode-এর i_links_count এক কমানো। inode আর তার data block তখনই মুক্ত হয় যখন i_links_count শূন্যে নামে

rm /tmp/a.txt   →  unlink("/tmp/a.txt")
                    directory থেকে "a.txt" entry মুছল
                    inode 4218903: i_links_count 2 → 1
                    (এখনো "b.txt" আছে, তাই inode বেঁচে আছে, ডেটা অক্ষত)

৩. একটা deleted ফাইল কেন খোলা রাখা প্রোগ্রামে পড়া যায়? আগের লেসনের f_count মেকানিজম আর i_links_count একসাথে কাজ করে। প্রকৃত নিয়ম: inode আর তার ব্লক মুক্ত হয় শুধু তখনই যখন i_links_count == 0 এবং কোনো খোলা struct file তাকে ধরে নেই (reference count-ও 0)। একটা প্রোগ্রাম ফাইল খুলে রেখে অন্য কেউ সেটা unlink করলে, নাম চলে যায় (directory-তে আর দেখা যায় না) কিন্তু inode বেঁচে থাকে যতক্ষণ ঐ fd খোলা — যা /tmp-এ লগ ফাইলের একটা প্রচলিত প্যাটার্ন (unlink করে সাথে সাথেই লেখা চালিয়ে যাওয়া, process বন্ধ হলে ডেটা নিজে থেকেই মুছে যায়)।

Hard linkSymbolic link
তৈরিlink(target, newname)symlink(target, newname)
নতুন inode?না — বিদ্যমান inode-এর i_links_count বাড়েহ্যাঁ — নিজের একটা নতুন inode, নিজের data block
data block-এ কী থাকে(এটাই তো লক্ষ্য inode, আলাদা data block নেই)টার্গেটের path স্ট্রিং, যেমন ../a.txt
একই filesystem-এ থাকা লাগে?হ্যাঁ, বাধ্যতামূলক (inode নম্বর filesystem-সাপেক্ষ)না, যেকোনো path, এমনকি অন্য filesystem বা অস্তিত্বহীন টার্গেট
টার্গেট মুছে গেলেlink অক্ষত (কারণ সেই inode-ই তো টার্গেট)broken/dangling — path থেকে গেছে, কিন্তু resolve ব্যর্থ
directory-তে করা যায়?না (নিচে ব্যাখ্যা)হ্যাঁ
resolve কে করেকার্নেল, সরাসরি — একটাই lookupকার্নেল, path আবার পুরোপুরি resolve করে (chain হতে পারে)

কেন directory-কে hard-link করা যায় না? দুইটা কারণ, দুটোই fundamental:

  1. সাইকেল তৈরির ঝুঁকি। যদি /a/b-কে hard link করে /a/b/c বানানো যেত, তাহলে filesystem tree একটা গ্রাফ হয়ে যেত, tree না — find, du, backup টুল, সবকিছু infinite loop-এ পড়ে যেত (.. নিজেই একটা “hard link”-এর মতো, কিন্তু কার্নেল সেটা বিশেষভাবে হ্যান্ডল করে, সাধারণ link()-কে নয়)।
  2. Link counting-এর জটিলতা। একটা ফাইলের hard link count সহজ: কয়টা directory entry ওকে নির্দেশ করছে। একটা directory hard-link করা গেলে, “parent” ধারণাটাই ভেঙে পড়ত — কোন ..-টা “আসল” parent?

তাই POSIX-compliant filesystem-এ link() explicitly directory-তে EPERM দেয় (root-ও পারে না, আধুনিক Linux-এ; পুরনো BSD-তে superuser exception ছিল, কিন্তু আধুনিক কার্নেল সেটাও বন্ধ করেছে নিরাপত্তার কারণে)। Directory move করার একমাত্র বৈধ উপায় rename(), যেটা একটা atomic “একটা directory entry মুছে আরেকটা যোগ” — inode-এর কোনো ডেটা না ছুঁয়ে।

ব্লক অ্যাড্রেসিং — inode কীভাবে জানে ডেটা কোথায়

struct ext4_inode-এর i_block[15] অ্যারেটাই ফাইলের ডেটা খুঁজে পাওয়ার চাবি। ext2/ext3-এর ক্লাসিক স্কিমে (ext4-এও legacy fallback হিসেবে আছে) এই ১৫টা ঘর চারভাগে ভাগ করা:

ঘরকী রাখে
i_block[0..11] (১২টা)direct block — সরাসরি data block-এর ঠিকানা
i_block[12]single indirect — একটা block-এর ঠিকানা, যেই block ভর্তি আরো data block-এর ঠিকানায়
i_block[13]double indirect — একটা block, যার ভেতরের প্রতিটা entry আরেকটা single-indirect block-এর ঠিকানা
i_block[14]triple indirect — আরেক স্তর গভীর
 inode.i_block[]
 ┌────┐
 │ 0  │──► data block (সরাসরি ফাইলের বাইট)
 │ 1  │──► data block
 │... │
 │ 11 │──► data block          ← ১২টা direct: প্রথম ১২ ব্লক তাৎক্ষণিক অ্যাক্সেস
 ├────┤
 │ 12 │──► [ptr,ptr,ptr,...] ──► data block   ← single indirect:
 │    │     (একটা ব্লক ভর্তি ঠিকানা)  data block     এক লাফ, তারপর ডেটা
 │    │                        └──► data block
 ├────┤
 │ 13 │──► [ptr,ptr,...] ──► [ptr,ptr,...] ──► data block  ← double indirect:
 │    │    (ঠিকানার ব্লক)      (ঠিকানার ব্লক)                দুই লাফ, তারপর ডেটা
 ├────┤
 │ 14 │──► [ptr,...] ──► [ptr,...] ──► [ptr,...] ──► data block  ← triple:
 │    │                                                            তিন লাফ
 └────┘
Direct থেকে triple indirect পর্যন্ত -- প্রতিটা স্তর একটা 'ঠিকানার ব্লক', যার ভেতরে হয় সরাসরি ডেটা, নয়তো আরেকটা ঠিকানার ব্লকের ঠিকানা।

একটা মাঝারি ফাইলে বড় offset-এ পড়তে গেলে কয়টা extra disk I/O লাগে, সেটাই এই স্কিমের প্রকৃত খরচ — direct block-এ 0, single indirect-এ 1 (ঠিকানার ব্লক পড়তে হবে), double indirect-এ 2, triple-এ 3। এই কারণেই বড় ফাইলে random access ছোট ফাইলের চেয়ে বাস্তবে ধীর হতে পারত — যদিও আধুনিক ext4-এ page cache আর extent tree এই খরচ প্রায় সবসময় লুকিয়ে ফেলে (নিচে দেখুন)।

Extent — আধুনিক প্রতিস্থাপন

ext4 ডিফল্টে indirect block স্কিম ব্যবহার করে না — করে extent। একটা extent হলো “শুরু থেকে N-টা পরপর ব্লক”, একটামাত্র struct-এ:

struct ext4_extent {
    __u32 ee_block;    /* এই extent ফাইলের কোন লজিক্যাল ব্লক থেকে শুরু */
    __u16 ee_len;       /* কয়টা পরপর ব্লক (সর্বোচ্চ ৩২,৭৬৮ = ১২৮MB, ৪KB ব্লকে) */
    __u32 ee_start_lo;  /* ডিস্কে ফিজিক্যাল ব্লক নম্বর, শুরু */
};

একটা ১০০MB পুরোপুরি contiguous ফাইলকে পুরনো স্কিমে বর্ণনা করতে লাগত হাজার হাজার individual block pointer (২৫,৬০০ ব্লক × ৪ বাইট = ~১০০KB শুধু metadata-য়)। Extent দিয়ে একই ফাইল বর্ণনা করা যায় একটা struct দিয়ে (১২ বাইট) — কারণ ডিস্কে ব্লকগুলো আসলেই পরপর, তাই “শুরু + দৈর্ঘ্য” যথেষ্ট।

i_block[15] অ্যারেটাই পুনর্ব্যবহার হয়েছে একটা ছোট extent tree-এর root হিসেবে — প্রথম ৪টা extent সরাসরি inode-এ ধরে (ছোট ফাইলে ডিস্ক-I/O ছাড়াই পুরো ম্যাপিং জানা যায়), বেশি fragmented হলে একটা B-tree-এর মতো কাঠামোতে ছড়িয়ে যায়। ext4-এর i_flags-এ EXT4_EXTENTS_FL বিট দেখেই কার্নেল বোঝে কোন ব্যাখ্যায় i_block[] পড়তে হবে — legacy indirect naki extent tree।

Extent-এর দুইটা প্রত্যক্ষ ফায়দা: কম metadata (বড় contiguous ফাইলে অনেক কম disk I/O ম্যাপিং পড়তে), আর built-in sparse file / hole সাপোর্ট — কোনো extent যদি একটা রেঞ্জ কভার না করে, সেই রেঞ্জ স্বয়ংক্রিয়ভাবে “hole” (নিচে বিস্তারিত)। XFS আর btrfs-ও extent-based, কারণ modern workload-এ (বড়, কম-fragmented ফাইল) এটা প্রায় সবসময় জেতে; indirect block স্কিম এখন মূলত ইতিহাস আর ছোট/highly-fragmented ফাইলের edge case।

Sparse file আর hole

truncate -s 1G empty.img চালালে একটা ১GB “সাইজের” ফাইল তৈরি হয় তাৎক্ষণিকভাবে — কোনো ডিস্ক I/O ছাড়া, কারণ কোনো ডেটা ব্লক আসলে বরাদ্দ হয়নি। inode-এর i_size হয়ে যায় 1GB, কিন্তু extent tree খালি (বা শুধু একটা “hole” চিহ্নিত রেঞ্জ)। এই ফাইলের কোনো অংশ পড়লে কার্নেল কোনো ডিস্ক ব্লক না ছুঁয়েই zero বাইট ফেরত দেয় — filesystem জানে hole-এর অর্থ “সব শূন্য”, তাই সেটা ডিস্কে রাখারই দরকার নেই।

Sparse file-এর দুইটা প্র্যাক্টিক্যাল ব্যবহার: ডাটাবেস আর VM ইমেজ ফাইল (যেমন qcow2, বা raw disk image) প্রায়ই বিশাল “size” নিয়ে তৈরি হয় কিন্তু প্রকৃতপক্ষে যতটুকু লেখা হয়েছে ততটুকুই ডিস্কে থাকে; আর dd if=/dev/zero of=f bs=1 seek=1G count=1 দিয়ে “1GB ফাইল, ১ বাইট ডেটা” বানানো — একটা ক্লাসিক sparse-file trick, ডিস্ক পরীক্ষার জন্য।

i_blocks_lo ফিল্ডটাই (512-বাইট sector গণনা) প্রকৃত ডিস্ক ব্যবহারের সোর্স অফ ট্রুথ — i_size না। du এই ফিল্ড পড়ে, ls -l পড়ে i_size। দুইটা ভিন্ন সংখ্যা দেখানোর কারণ এটাই।

ভেতরে কী ঘটছে

ln /tmp/a.txt /tmp/b.txt — কার্নেলের ভেতরে

link("/tmp/a.txt", "/tmp/b.txt") -- একটা নতুন নাম, একই inode
  1. userspace: link(2) syscallglibc wrapper -- rax = __NR_link (বা linkat), rdi/rsi = দুইটা path
  2. kernel: path resolution -- দুইটা path-ই resolve/tmp/a.txt resolve করে টার্গেট inode 4218903 পাওয়া যায় (dentry cache/VFS lookup); /tmp/b.txt-এর পথ resolve করে শুধু parent directory (/tmp) আর নতুন নামটা বের করা হয়, নিজে resolve হয় না -- এখনো অস্তিত্বই নেই
  3. চেক -- একই filesystem?টার্গেট inode-এর st_dev আর নতুন path-এর directory-র st_dev মিলতে হবে; না মিললে EXDEV -- hard link filesystem পার হতে পারে না, কারণ inode নম্বর শুধু নিজের filesystem-এর মধ্যেই অর্থবহ
  4. চেক -- টার্গেট directory না?i_mode-এ S_IFDIR থাকলে EPERM -- directory hard-link করার চেষ্টা এখানেই থামে
  5. vfs_link() → ext4_link()নতুন parent directory-র data block-এ একটা entry যোগ (inode নম্বর 4218903, নাম "b.txt") -- যদি বর্তমান block-এ জায়গা না থাকে, নতুন data block বরাদ্দ
  6. inode 4218903: i_links_count++1 → 2। এই একটা লাইন বাড়ানোর পরেই দুইটা নাম সমান বৈধ, কোনোটা "আসল" না
  7. i_ctime আপডেটinode metadata বদলেছে (link count), তাই change-time নতুন -- কিন্তু ডেটা বা i_mtime স্পর্শ হয়নি

লক্ষ করুন নতুন কোনো data block বরাদ্দ হয়নি ফাইলের জন্য, শুধু directory-র data block বড় হয়েছে (নতুন entry রাখতে)। এটাই কেন ln তাৎক্ষণিক, ফাইলের আকার যাই হোক — একটা ১০GB ফাইল hard-link করাও এক মিলিসেকেন্ডেরও কম।

rm /tmp/a.txt ডাকে unlink("/tmp/a.txt"):

  1. /tmp directory-র data block থেকে “a.txt” entry সরানো (rec_len ঠিক করে পাশের entry-তে merge, বা tombstone মার্ক)
  2. inode 4218903: i_links_count-- (2 → 1)
  3. যদি i_links_count == 0 এবং কোনো process-এর খোলা fd না থাকে (open file description reference 0) — তখনই, আর শুধু তখনই: inode bitmap-এ বিটটা ক্লিয়ার, data block bitmap-এ সংশ্লিষ্ট ব্লকগুলো ক্লিয়ার, জায়গাটা সত্যিকারভাবে “ফ্রি” ঘোষিত

শেষ শর্তটাই আগের লেসনের f_count আর এই লেসনের i_links_count — দুইটা সম্পূর্ণ ভিন্ন counter — একসাথে মিলে ফাইলের প্রকৃত জীবনকাল ঠিক করে। দুইটার একটাও নন-জিরো থাকলে ডেটা বেঁচে থাকে।

উদাহরণ

সর্বোচ্চ ফাইল-আকার — হাতে হিসাব, ৪KB ব্লক ধরে

ধরুন block size = 4096 বাইট, আর প্রতিটা block pointer 4 বাইট (৩২-বিট ব্লক নম্বর — পুরনো ext2/ext3-এর প্রকৃত মান)। তাহলে একটা indirect block-এ কয়টা pointer ধরে?

pointers per block=40964=1024\text{pointers per block} = \frac{4096}{4} = 1024

এখন প্রতিটা স্তর হিসাব করি:

স্তরকভার করে (ব্লকে)কভার করে (বাইটে)
Direct (১২টা)১২ ব্লক12×4096=49,15212 \times 4096 = 49{,}152 বাইট (৪৮KB)
Single indirect১০২৪ ব্লক1024×4096=4,194,3041024 \times 4096 = 4{,}194{,}304 বাইট (৪MB)
Double indirect1024×1024=1,048,5761024 \times 1024 = 1{,}048{,}576 ব্লক10242×4096=4,294,967,2961024^2 \times 4096 = 4{,}294{,}967{,}296 বাইট (৪GB)
Triple indirect10243=1,073,741,8241024^3 = 1{,}073{,}741{,}824 ব্লক10243×40964.4×10121024^3 \times 4096 \approx 4.4 \times 10^{12} বাইট (৪TB)

সর্বমোট, সব স্তর যোগ করে:

max size=12×4096+1024×4096+10242×4096+10243×4096\text{max size} = 12 \times 4096 + 1024 \times 4096 + 1024^2 \times 4096 + 1024^3 \times 4096

=4096×(12+1024+1,048,576+1,073,741,824)= 4096 \times (12 + 1024 + 1{,}048{,}576 + 1{,}073{,}741{,}824)

=4096×1,074,790,4364.4×1012 বাইট4TB= 4096 \times 1{,}074{,}790{,}436 \approx 4.4 \times 10^{12} \text{ বাইট} \approx 4\text{TB}

Triple indirect একাই বাকি সবগুলোকে numerically চাপা দিয়ে দেয় — direct ব্লক (৪৮KB) পুরো যোগফলের তুলনায় নগণ্য। এই কারণেই পুরনো ext2/ext3-এর ডকুমেন্টেশনে “সর্বোচ্চ ফাইল সাইজ ~২TB” লেখা থাকত (আসল সীমাটা এই হিসাবের চেয়েও ছোট ছিল, কারণ ব্লক নম্বর ৩২-বিট signed/unsigned আর i_size-এর নিজস্ব সীমা মিলে আরো টাইট হয়ে যেত)।

Extent দিয়ে একই ফাইল — মেটাডেটার তুলনা

ধরুন একটা ১০০MB পুরোপুরি contiguous ফাইল, ৪KB ব্লকে = ২৫,৬০০ ব্লক।

স্কিমদরকারি metadatadisk I/O (metadata পড়তে, ধরুন cold cache)
Indirect block (২৫,৬০০ পয়েন্টার, ৪ বাইট করে)25,600×4=102,40025{,}600 \times 4 = 102{,}400 বাইট (~১০০KB), ~২৫টা indirect blockdirect ১২ ব্লকের বাইরে গেলে একটা extra I/O (single indirect), বেশি দূরে গেলে আরো
Extent (১টা extent, ধরুন max length যথেষ্ট বড়, নাহলে কয়েকটা)১২ বাইট (একটা extent struct) বা কয়েকটা যদি ৩২,৭৬৮-ব্লক সীমা ছাড়ায়০ — পুরো ম্যাপিং inode-এর ভেতরেই (৪টা পর্যন্ত extent সরাসরি inode-এ)

~১০০KB বনাম ~১২ বাইট — এটাই extent-এর ব্যবহারিক ফায়দার সংখ্যাগত প্রমাণ, শুধু তত্ত্ব না।

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

EXPERIMENT

stat, ls -i, আর hard link দিয়ে inode আর link count নিজে দেখুন

Linux/macOS (bash)· ১৫ মিনিট
# ধাপ ১ -- একটা ফাইল, তার inode নম্বর দেখি
mkdir -p /tmp/inodelab && cd /tmp/inodelab
echo "original content" > a.txt
stat -c 'name=%n inode=%i links=%h size=%s' a.txt   # GNU stat
ls -i a.txt
name=a.txt inode=4218903 links=1 size=18
4218903 a.txt
# ধাপ ২ -- hard link বানাই, link count আর inode নম্বর মেলাই
ln a.txt b.txt
stat -c 'name=%n inode=%i links=%h' a.txt b.txt
name=a.txt inode=4218903 links=2
name=b.txt inode=4218903 links=2

দুইটার inode= অভিন্ন, দুইটাই links=2 দেখাচ্ছে — কারণ link count inode-এর সম্পত্তি, filename-এর না; দুইটা নামই একই সংখ্যা দেখবে।

# ধাপ ৩ -- আরেকটা hard link, তারপর একটা মুছে দেখি
ln a.txt c.txt
stat -c 'links=%h' a.txt      # এখন ৩
rm b.txt
stat -c 'links=%h' a.txt c.txt
links=3
links=2
links=2
# ধাপ ৪ -- এখন symlink বানাই, তুলনা করি
ln -s a.txt s.txt
stat -c 'name=%n inode=%i links=%h type=%F' a.txt s.txt
ls -li a.txt c.txt s.txt
name=a.txt inode=4218903 links=2 type=regular file
name=s.txt inode=4219102 links=1 type=symbolic link
4218903 -rw-r--r-- 2 u u 18 Aug 20 14:20 a.txt
4218903 -rw-r--r-- 2 u u 18 Aug 20 14:20 c.txt
4219102 lrwxrwxrwx 1 u u  5 Aug 20 14:20 s.txt -> a.txt

s.txt-এর inode সম্পূর্ণ আলাদা (4219102), নিজের link count ১, আর size ৫ — সেটা a.txt-এর content-এর আকার না, “a.txt” স্ট্রিংটার দৈর্ঘ্য (৫ অক্ষর)। এটাই প্রমাণ করে symlink নিজেই একটা ফাইল, যার ডেটা হলো একটা path।

# ধাপ ৫ -- a.txt মুছে দিলে symlink ভেঙে যায়, hard link ভাঙে না
rm a.txt
cat c.txt          # কাজ করে -- inode এখনো বেঁচে (links এখনো ১)
cat s.txt          # ব্যর্থ -- path resolve করতে গিয়ে "a.txt" খুঁজে পায় না
original content
cat: s.txt: No such file or directory

সংক্ষেপে যা দেখা গেল: hard link একটা inode-কে একাধিক নামে বাঁচিয়ে রাখে (মূল নাম মুছলেও), symlink শুধু একটা path মনে রাখে যা প্রতিবার নতুন করে resolve হয় — টার্গেট সরে গেলে বা মুছে গেলে ভেঙে যায়।

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

একাধিক filename একই inode-এর দিকে নির্দেশ করতে পারে (hard link), inode-এর নিজস্ব link count থাকে যা link()/unlink()-এ বদলায়, আর symlink সম্পূর্ণ ভিন্ন -- নিজের একটা আলাদা inode আছে।

EXPERIMENT

scratch loopback ext4 বানিয়ে debugfs দিয়ে raw inode দেখুন, আর sparse file পরীক্ষা

Linux (root/sudo দরকার loopback-এর জন্য)· ২৫ মিনিট
# ধাপ ১ -- একটা ৬৪MB ফাঁকা ফাইল বানাই (এটাই আমাদের "ডিস্ক")
dd if=/dev/zero of=/tmp/scratch.img bs=1M count=64
64+0 records in
64+0 records out
67108864 bytes (67 MB, 64 MiB) copied, 0.08 s, 838 MB/s
# ধাপ ২ -- সেই ফাইলের ভেতরে একটা ext4 filesystem বানাই
mkfs.ext4 -q /tmp/scratch.img
# ধাপ ৩ -- একটা loopback ডিভাইসে mount করি
sudo mkdir -p /mnt/scratch
sudo mount -o loop /tmp/scratch.img /mnt/scratch
df -h /mnt/scratch
Filesystem      Size  Used Avail Use% Mounted on
/dev/loop0       61M   24K   57M   1% /mnt/scratch
# ধাপ ৪ -- এই নতুন filesystem-এ কিছু ফাইল বানাই যাচাইয়ের জন্য
sudo bash -c '
echo "hello inode" > /mnt/scratch/f.txt
ln /mnt/scratch/f.txt /mnt/scratch/g.txt
mkdir /mnt/scratch/d
'
ls -i /mnt/scratch/f.txt
12 /mnt/scratch/f.txt
# ধাপ ৫ -- এবার mounted filesystem un-mount করে debugfs দিয়ে raw inode পড়ি
# (debugfs image ফাইলের উপর সরাসরিও চলে, mount করা লাগে না -- কিন্তু
#  mounted অবস্থায় write-mode debugfs চালানো বিপজ্জনক, তাই আগে unmount)
sudo umount /mnt/scratch
sudo debugfs -R "stat <12>" /tmp/scratch.img
Inode: 12   Type: regular    Mode:  0644   Flags: 0x80000
Generation: 2847193651    Version: 0x00000000:00000001
User:     0   Group:     0   Project:     0   Size: 13
File ACL: 0
Links: 2
Inode checksum 0x8f2a1c3e
 ctime: 0x... -- ...
 atime: 0x... -- ...
 mtime: 0x... -- ...
EXTENTS:
(0):1543

Links: 2 — hard link-এর প্রমাণ raw inode-এ সরাসরি, stat-এর দরকারই নেই। EXTENTS: (0):1543 — এই ফাইলের লজিক্যাল ব্লক ০ ম্যাপ হয়েছে ডিস্কের ফিজিক্যাল ব্লক ১৫৪৩-এ, ঠিক এই লেসনের extent আলোচনার সেই struct সরাসরি প্রদর্শিত। Directory-র raw বিষয়বস্তুও দেখা যায়:

sudo debugfs -R "ls -l <2>" /tmp/scratch.img
 2  40755 (2)      0      0    4096 20-Aug-2026 14:25 .
 2  40755 (2)      0      0    4096 20-Aug-2026 14:25 ..
11  40700 (2)      0      0   16384 20-Aug-2026 14:25 lost+found
12  100644 (1)      0      0      13 20-Aug-2026 14:25 f.txt
12  100644 (1)      0      0      13 20-Aug-2026 14:25 g.txt
14  40755 (2)      0      0    4096 20-Aug-2026 14:25 d

লক্ষ করুন f.txt আর g.txt দুইটা লাইনেই প্রথম কলামে 12 — root directory-র data block-এ সত্যিই দুইটা আলাদা entry আছে যারা একই inode নম্বরে নির্দেশ করছে, ঠিক এই লেসনের “concept” সেকশনের ছবিটাই raw বাইটে।

# ধাপ ৬ -- sparse file: apparent size বনাম প্রকৃত ডিস্ক ব্যবহার
truncate -s 1G /tmp/sparse.img
ls -lh /tmp/sparse.img
du -h /tmp/sparse.img
du -h --apparent-size /tmp/sparse.img
-rw-r--r-- 1 u u 1.0G Aug 20 14:30 /tmp/sparse.img
4.0K	/tmp/sparse.img
1.0G	/tmp/sparse.img

ls -l বলছে ১GB (এটা i_size, শুধু একটা সংখ্যা)। du (ডিফল্ট) বলছে ৪KB — filesystem-এর নিজস্ব metadata-র জন্য সামান্য জায়গা ছাড়া প্রকৃত কোনো data block বরাদ্দই হয়নি। --apparent-size দিয়ে du-কে জোর করে i_size দেখানো যায়, যা ls -l-এর সাথে মিলে যায়। মাঝখানে একটা বাইট লিখে আবার মাপুন:

dd if=/dev/urandom of=/tmp/sparse.img bs=1 seek=500M count=1 conv=notrunc 2>/dev/null
du -h /tmp/sparse.img
4.0K	/tmp/sparse.img

এখনো ৪KB-র কাছাকাছি (একটা মাত্র ব্লক বরাদ্দ হয়েছে সেই এক বাইটের জন্য, ব্লক সাইজ ৪KB বলে) — কিন্তু ls -l এখনো ১GB দেখাবে, কারণ i_size অপরিবর্তিত। ৯৯.৯৯% ফাইলটাই এখনো “hole” — কোনো ডেটা ব্লক নেই, পড়লে zero আসবে।

# পরিষ্কার
sudo rm -rf /mnt/scratch
rm /tmp/scratch.img /tmp/sparse.img
এটা কী প্রমাণ করে

একটা filesystem-এর raw metadata (inode struct, extent, directory entry) debugfs দিয়ে সরাসরি পড়া যায় -- আর truncate দিয়ে বানানো sparse file-এ ls -l (apparent size) আর du (প্রকৃত ডিস্ক ব্যবহার) সম্পূর্ণ ভিন্ন সংখ্যা দেখায়।

নিজে বানান

BUILD IT

readdir + stat দিয়ে hard-link গ্রুপ খুঁজে বের করুন

C (POSIX) · ●●●●○
  1. একটা directory readdir() দিয়ে ট্র্যাভার্স করুন (recursive, subdirectory-সহ)
  2. প্রতিটা entry-তে lstat() করুন (symlink-কে follow না করে) -- (st_dev, st_ino) জোড়া আর st_nlink সংগ্রহ করুন
  3. (st_dev, st_ino) কে key ধরে একটা hash map-এ path-গুলো গ্রুপ করুন
  4. যেসব গ্রুপে একাধিক path জমেছে, সেগুলোই hard-link গ্রুপ -- ছাপান, আর নিশ্চিত করুন গ্রুপের size == st_nlink
  5. directory-কে বিশেষভাবে হ্যান্ডল করুন -- S_IFDIR হলে recurse করুন, hard-link গ্রুপিং-এ ধরবেন না (directory hard-link করা যায় না বলে)
/* linkfind.c -- একটা directory tree-তে হার্ড-লিঙ্ক গ্রুপ খুঁজে বের করে,
 * (device, inode) জোড়া মিলিয়ে -- ঠিক যেভাবে কার্নেল নিজে চেনে দুইটা
 * নাম একই ফাইল কি না।
 *
 *   কম্পাইল: cc -Wall -Wextra -O2 -o linkfind linkfind.c
 *   চালান:   ./linkfind /tmp/inodelab
 */
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <dirent.h>
#include <sys/stat.h>
#include <unistd.h>

#define MAX_ENTRIES 4096
#define MAX_PATH    1024

typedef struct {
    dev_t   dev;
    ino_t   ino;
    nlink_t nlink;
    char    path[MAX_PATH];
} Entry;

static Entry g_entries[MAX_ENTRIES];
static int   g_count = 0;

/* directory tree recursively হাঁটি, প্রতিটা non-directory entry রেকর্ড করি */
static void walk(const char *dir)
{
    DIR *d = opendir(dir);
    if (!d) { fprintf(stderr, "opendir %s: skip\n", dir); return; }

    struct dirent *de;
    while ((de = readdir(d)) != NULL) {
        if (strcmp(de->d_name, ".") == 0 || strcmp(de->d_name, "..") == 0)
            continue;

        char path[MAX_PATH];
        snprintf(path, sizeof path, "%s/%s", dir, de->d_name);

        struct stat st;
        /* lstat, stat না -- symlink নিজেকেই রিপোর্ট করবে, টার্গেটকে না */
        if (lstat(path, &st) != 0) { perror("lstat"); continue; }

        if (S_ISDIR(st.st_mode)) {
            walk(path);          /* directory recurse, কিন্তু গ্রুপিং-এ ধরি না */
            continue;
        }

        if (g_count >= MAX_ENTRIES) { fprintf(stderr, "too many entries\n"); return; }
        g_entries[g_count].dev   = st.st_dev;
        g_entries[g_count].ino   = st.st_ino;
        g_entries[g_count].nlink = st.st_nlink;
        strncpy(g_entries[g_count].path, path, MAX_PATH - 1);
        g_entries[g_count].path[MAX_PATH - 1] = '\0';
        g_count++;
    }
    closedir(d);
}

/* (dev, ino) জোড়া মিলিয়ে O(n^2) গ্রুপিং -- ছোট tree-র জন্য যথেষ্ট
   পরিষ্কার; বড় tree-তে হ্যাশ টেবিল লাগবে (নিচে extension ৩ দেখুন) */
static void report_groups(void)
{
    int *done = calloc(g_count, sizeof(int));
    int group_no = 0;

    for (int i = 0; i < g_count; i++) {
        if (done[i]) continue;

        /* এই entry-র সাথে মিলে এমন সব entry খুঁজি */
        int members[MAX_ENTRIES], nmembers = 0;
        members[nmembers++] = i;
        for (int j = i + 1; j < g_count; j++) {
            if (done[j]) continue;
            if (g_entries[j].dev == g_entries[i].dev &&
                g_entries[j].ino == g_entries[i].ino) {
                members[nmembers++] = j;
            }
        }

        if (nmembers > 1) {
            group_no++;
            printf("গ্রুপ %d -- inode %llu (dev %llu), %d নাম, st_nlink=%d:\n",
                   group_no,
                   (unsigned long long)g_entries[i].ino,
                   (unsigned long long)g_entries[i].dev,
                   nmembers,
                   (int)g_entries[i].nlink);
            for (int k = 0; k < nmembers; k++) {
                printf("    %s\n", g_entries[members[k]].path);
                done[members[k]] = 1;
            }
            /* যাচাই: আমরা যতগুলো নাম খুঁজে পেয়েছি সেটা st_nlink-এর সাথে
               মেলা উচিত -- না মিললে মানে tree-র বাইরে আরো hard link আছে */
            if (nmembers != (int)g_entries[i].nlink) {
                printf("    (সতর্কতা: st_nlink=%d কিন্তু এই tree-তে শুধু %d-টা "
                       "নাম পাওয়া গেছে -- বাকি link tree-র বাইরে কোথাও আছে)\n",
                       (int)g_entries[i].nlink, nmembers);
            }
        } else {
            done[i] = 1;
        }
    }
    free(done);

    if (group_no == 0)
        printf("কোনো hard-link গ্রুপ পাওয়া যায়নি -- সব ফাইলের st_nlink == 1\n");
}

int main(int argc, char **argv)
{
    const char *root = (argc > 1) ? argv[1] : ".";
    walk(root);
    printf("মোট %d-টা non-directory entry স্ক্যান করা হলো\n\n", g_count);
    report_groups();
    return 0;
}

প্রত্যাশিত আউটপুট (আগের experiment-এর /tmp/inodelab-এ চালালে, যেখানে a.txt, c.txt hard link আর s.txt symlink):

মোট 3-টা non-directory entry স্ক্যান করা হলো

গ্রুপ 1 -- inode 4218903 (dev 66306), 2 নাম, st_nlink=2:
    /tmp/inodelab/a.txt
    /tmp/inodelab/c.txt

s.txt আলাদা গ্রুপে পড়েনি (st_nlink=1, নিজের inode) — lstat() symlink-কে follow না করে নিজেই রিপোর্ট করেছে বলে, ঠিক এই লেসনের hard-link বনাম symlink পার্থক্য প্রোগ্রামে বাস্তবায়িত।

নিজে বাড়ান

  1. হ্যাশ টেবিল দিয়ে O(n²) থেকে O(n) করুন। (dev, ino) জোড়াকে হ্যাশ করে একটা open-addressing বা chaining hash map বানান, বড় directory tree-তে (হাজার হাজার ফাইল) দ্রুত চালান।
  2. --cross-device সতর্কতা যোগ করুন। যদি একই st_ino কিন্তু ভিন্ন st_dev দেখেন (দুইটা ভিন্ন filesystem-এ কাকতালীয়ভাবে একই inode নম্বর), সেটা ভুলভাবে একই গ্রুপে ফেলবেন না প্রমাণ করুন কেন — আর কোড দেখান কীভাবে st_dev চেকটাই এই ভুল ঠেকায়।
  3. duplicate-content ডিটেক্টরের সাথে তুলনা করুন। একটা mode যোগ করুন যা content hash (SHA-256) দিয়ে “ডুপ্লিকেট” ফাইল খুঁজবে, তারপর দেখান hard-link গ্রুপ (একই inode) বনাম content-duplicate গ্রুপ (ভিন্ন inode, একই বাইট) সম্পূর্ণ ভিন্ন ধারণা — একটা cp করা ফাইল content-duplicate কিন্তু hard-link না।
  4. unlink simulate করুন। একটা ফাংশন লিখুন যা নিজের ট্র্যাক করা group থেকে একটা path “মুছে” দেয় (in-memory) আর দেখায় বাকি group-এর সদস্যরা এখনো valid, ঠিক যেমন প্রকৃত unlink()-এ inode বেঁচে থাকে যতক্ষণ কোনো link বাকি।
  5. du -x-এর মতো mount-boundary সম্মান করুন। walk()-এ একটা চেক যোগ করুন যা subdirectory-র st_dev root-এর st_dev-এর থেকে ভিন্ন হলে recurse না করে (bind mount বা অন্য filesystem mount হওয়া subdirectory এড়িয়ে যাওয়া)।

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

যেখানে inode আর directory-র এই ডিজাইন প্রতিদিন কাজে লাগে

rsync --link-dest এবং Time Machine-এর incremental backup। প্রতিদিনের backup-এ শুধু বদলে যাওয়া ফাইল কপি করা হয়, বাকি সব হার্ড-লিঙ্ক করা হয় আগের backup snapshot-এর দিকে। ফলাফল: প্রতিটা backup snapshot দেখতে একটা সম্পূর্ণ, স্বাধীন ফাইল-tree, কিন্তু ডিস্কে অপরিবর্তিত ফাইলের ডেটা একবারই আছে — ৩০ দিনের backup যদি প্রতিদিন ১% ফাইল বদলায়, তাহলে ডিস্ক ব্যবহার ৩০× না, বরং প্রায় ১× + ৩০×১% জমা। macOS-এর Time Machine ঠিক এই কৌশলের উপর দাঁড়িয়ে (HFS+/APFS-এ)।

git-এর object store আর hardlink-based clone (cp -al, বা পুরনো git clone --shared)। Git-এর .git/objects/ ডিরেক্টরির প্রতিটা object immutable — একবার লেখা হলে আর বদলায় না। তাই একই মেশিনে একাধিক clone hard-link করে একই object share করা নিরাপদ (একটা repo-তে content বদলাবে না, কারণ git নতুন content মানেই নতুন hash মানেই নতুন object)। docker build-এর layer caching-এও একই নীতি অনুসরিত হয় বিভিন্ন storage driver-এ।

df বলছে ডিস্ক ভরা, du বলছে খালি” — deleted কিন্তু open ফাইল। Production-এ একটা অতি সাধারণ incident: একটা log ফাইল rotate করা হয়েছে (unlink করা হয়েছে পুরনো নাম), কিন্তু চলমান process এখনো পুরনো fd-তে লিখছে। du -sh /var/log ছোট দেখাবে (নাম-ই নেই খুঁজতে), df -h দেখাবে ডিস্ক প্রায় ভরা (inode বেঁচে আছে, ডেটা ব্লক দখলে)। সমাধান: lsof +L1 (link count থাকা সত্ত্বেও deleted ফাইল খুঁজে বের করে) অথবা lsof | grep deleted, তারপর প্রোগ্রামটাকে সিগন্যাল দিয়ে log পুনরায় খুলতে বলা (logrotate-এর copytruncate বা SIGHUP-based reopen এই সমস্যা এড়াতেই ডিজাইন করা)।

VM ইমেজ আর ডাটাবেসের sparse file। QEMU/KVM-এর raw disk image, PostgreSQL-এর কিছু ফাইল, আর git gc-এর pack ফাইল — সবই sparse file ব্যবহারের সুবিধা নেয়। একটা ৫০GB “size”-এর VM disk image আসলে ৫GB ডেটা ধরে থাকতে পারে; cloud provider-রা (AWS EBS snapshot, ইত্যাদি) billing-এ প্রকৃত allocated block গণনা করে, i_size না — ঠিক এই লেসনের du বনাম ls -l পার্থক্যের production সংস্করণ।

ext4 কেন extent-এ গেল — বাস্তব সংখ্যা। ext4 (২০০৮, kernel 2.6.28) ext3-এর indirect-block ডিজাইন থেকে সরে আসার প্রধান কারণ ছিল বড় ফাইলের performance। একটা ১০০GB ফাইলে (আধুনিক ডাটাবেস বা VM image-এ সাধারণ) indirect-block স্কিমে মেটাডেটার আকার কয়েক মেগাবাইট হয়ে যেত, যা fsck আর mount সময়কে ধীর করত। Extent দিয়ে সেই একই ফাইলের মেটাডেটা কয়েক কিলোবাইটে নেমে আসে — এই লেসনের “example” সেকশনের ১০০KB বনাম ১২ বাইট হিসাবটার সরাসরি বাস্তব প্রতিফলন, শুধু ১০০০× বড় স্কেলে।

XFS আর btrfs-এর B-tree-based directory। ext4-এর ছোট directory linear array (উপরে দেখানো ফরম্যাট) ব্যবহার করে, কিন্তু বড় directory-তে (হাজার হাজার entry) সেটা ধীর — তাই ext4 বড় directory-তে internally একটা hashed B-tree (HTree, i_flags-এ EXT4_INDEX_FL) ব্যবহার করে O(n) lookup-কে O(log n)-এ নামায়। XFS আর btrfs শুরু থেকেই সব directory-তে B-tree, তাই লক্ষ লক্ষ ফাইলের একটা directory-তেও (যেমন mail server-এর maildir) ls বা open() দ্রুত থাকে।

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

“একটা ফাইলের 'আসল নাম' আর 'কপি নাম' বলে কিছু আছে -- hard link একটা shortcut বা alias মাত্র।”

এটা ঠিক উল্টো। inode-এর দৃষ্টিতে সব hard link সম্পূর্ণ সমান — কোনটা “আসল” আর কোনটা “কপি” সেই তথ্য কোথাও সংরক্ষিত নেই, কারণ সংরক্ষণ করার কোনো জায়গাই নেই (inode ফাইলের নাম জানে না, শুধু directory entry জানে, আর প্রতিটা entry সমান)।

এই লেসনের প্রথম experiment-এ a.txt, b.txt, c.txt — তিনটাই stat-এ ঠিক একই তথ্য দেখায় (একই inode, একই size, একই timestamp)। কোনটা প্রথমে তৈরি হয়েছিল সেটা filesystem-level-এ কোথাও রেকর্ড নেই (যদিও i_ctime প্রথম creation-এর সময় বহন করে — কিন্তু সেটা inode-এর, কোনো নির্দিষ্ট নামের না)। rm a.txt করলে বাকি দুইটা নাম দিয়ে ফাইলটা ঠিক আগের মতোই অ্যাক্সেসযোগ্য — কোনো “মূল ফাইল হারিয়ে গেছে” ধারণা প্রযোজ্যই না।

Symbolic link-এ এই “মূল বনাম alias” ধারণাটা বৈধ (টার্গেট আছে, symlink তাকে নির্দেশ করছে) — কিন্তু hard link-এ না। দুইটাকে গুলিয়ে ফেলাই এই ভুল ধারণার উৎস।

“rm করলে ডেটা তাৎক্ষণিকভাবে ডিস্ক থেকে মুছে যায়।”

দুই ধাপে ভুল। প্রথমত, rm = unlink(), যেটা শুধু একটা directory entry সরায় আর i_links_count কমায়। যদি অন্য hard link বা খোলা fd থেকে যায়, ডেটা অক্ষত থাকে — এই লেসনের unlink() LayerTrace ঠিক এই ক্রমটাই দেখিয়েছে।

দ্বিতীয়ত, এমনকি শেষ link আর শেষ fd-ও চলে গেলে, ext4 (আর বেশিরভাগ traditional filesystem) শুধু inode bitmap আর block bitmap-এ bit ক্লিয়ার করে — প্রকৃত বাইট ডিস্কে overwrite করে না। এই কারণেই photorec, testdisk, বা raw debugfs-এর মতো টুল দিয়ে “মুছে ফেলা” ফাইল প্রায়ই পুনরুদ্ধার করা যায়, যতক্ষণ না নতুন কোনো ফাইল ঠিক সেই ব্লকগুলোতে লেখা হয়ে যায়। নিরাপদ মুছে ফেলার জন্য shred বা srm-এর মতো টুল দরকার, যেগুলো unlink করার আগে জায়গাটা explicitly overwrite করে — আর SSD-তে wear-leveling-এর কারণে এমনকি সেটাও গ্যারান্টিড না (Level 11-এর storage/performance module-এ TRIM আর SSD-র ভেতরের ম্যাপিং নিয়ে বিস্তারিত আসবে)।

“inode নম্বর গোটা সিস্টেমে ইউনিক -- দুইটা ফাইলের inode নম্বর মিলে গেলে তারা একই ফাইল।”

inode নম্বর শুধু একটা filesystem-এর ভেতরে ইউনিক, পুরো সিস্টেমে না। দুইটা ভিন্ন filesystem (দুইটা ভিন্ন পার্টিশন, বা একটা mounted USB drive) স্বাধীনভাবে নিজেদের inode নম্বর বরাদ্দ করে — তাই /home-এ inode 12 আর /mnt/usb-এ inode 12 সম্পূর্ণ ভিন্ন, সম্পর্কহীন দুইটা ফাইল হতে পারে, নিছক কাকতালীয় সংখ্যা-মিল।

ফাইলের প্রকৃত, সিস্টেম-ব্যাপী পরিচয় সবসময় জোড়া: (st_dev, st_ino)। এই লেসনের build প্রোগ্রামে ঠিক এই কারণেই শুধু st_ino না, st_dev-ও মেলানো হয়েছে — নাহলে দুইটা ভিন্ন filesystem-এর কাকতালীয়ভাবে-মিলে-যাওয়া inode নম্বরকে ভুলভাবে “hard link” বলে রিপোর্ট করা হতো। এই একই কারণে hard link filesystem পার হতে পারে না (EXDEV) — link() syscall-এর ভেতরে st_dev মিলছে কি না চেক করাটাই প্রথম শর্ত (এই লেসনের LayerTrace-এ দেখানো হয়েছে)।

“একটা directory-র 'সাইজ' (ls -l-এ দেখা) মানে তার ভেতরে কতগুলো ফাইল আছে।”

ls -l dir যে সংখ্যাটা দেখায় সেটা directory নিজের data block-এর আকার (কতগুলো data block তার entry-র তালিকা ধরে রাখতে লেগেছে), ভেতরের ফাইলের সংখ্যা বা তাদের মোট আকার না। একটা খালি directory-তেও সাধারণত ৪০৯৬ বাইট দেখা যায় (এক ব্লক, . আর .. entry-সহ ন্যূনতম বরাদ্দ) — “0 ফাইল, তবু ৪KB সাইজ” এই বিভ্রান্তির উৎস।

একটা directory-তে অনেক ফাইল যোগ করলে (আর পরে বেশিরভাগ মুছে ফেললে) directory-র নিজস্ব data block সাধারণত ছোট হয় না — bitmap-এর মতো, ext4 সাধারণত directory-র allocated block shrink করে না (fragmentation এড়াতে), তাই একবার বড় হওয়া directory ছোট থেকে যায় এমনকি এখন প্রায় খালি থাকলেও। ভেতরের ফাইলের প্রকৃত সংখ্যা বা মোট আকার জানতে find dir | wc -l বা du -sh dir লাগে — ls -l dir | head -1 (total) বা directory-র নিজের stat-এর size-এ না।

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

1

একটা ফাইল /data/big.log-এর i_links_count = 1। একটা process এটা open() করে fd রেখেছে, তারপর অন্য একটা process rm /data/big.log চালাল। প্রশ্ন: (ক) rm-এর পরপরই ls /data/ কী দেখাবে? (খ) যে process-টা fd খোলা রেখেছিল সে কি এখনো লিখতে/পড়তে পারবে? (গ) inode আর data block কখন প্রকৃতপক্ষে ফ্রি হবে?

যুক্তি

(ক) ls /data/-এ big.log আর থাকবে না। rm = unlink(), যা directory-র data block থেকে “big.log” নামের entry সরিয়ে দেয় সাথে সাথেই — এটা synchronous, তাই unlink() ফেরত আসার মুহূর্তেই কোনো readdir()/ls আর এই নামটা দেখবে না।

(খ) হ্যাঁ, সম্পূর্ণ স্বাভাবিকভাবে পড়তে/লিখতে পারবে। কারণটা এই লেসনের দুইটা counter মনে করুন — inode-এর i_links_count আর আগের লেসনের struct file-এর f_countunlink() শুধু i_links_count-কে 1 → 0 করেছে। প্রোগ্রামটার fd-র সাথে যুক্ত struct file এখনো inode-কে reference করছে (f_count \>= 1), তাই কার্নেল inode আর তার data block মুক্ত করে না।

(গ) inode আর ব্লক ফ্রি হবে ঠিক তখন যখন প্রোগ্রামটা close(fd) ডাকবে (বা প্রোগ্রামটা ক্র্যাশ করে/শেষ হয়ে যায়, যাতে কার্নেল তার সব fd বন্ধ করে দেয়)। শর্ত: i_links_count == 0 এবং কোনো খোলা reference নেই — দুইটাই মিলতে হবে। close()-এর মুহূর্তে যদি এটাই শেষ reference হয়, কার্নেল তখন block bitmap আর inode bitmap-এ bit ক্লিয়ার করে জায়গাটা সত্যিকারভাবে মুক্ত ঘোষণা করে।

একটা প্র্যাক্টিক্যাল পরিণতি: যতক্ষণ প্রোগ্রামটা fd খোলা রাখে, df ডিস্ক ব্যবহার কমতে দেখাবে না, যদিও ls/du (path দিয়ে) ফাইলটা আর দেখতেই পাবে না — realworld সেকশনের “df ভরা, du খালি” ঘটনার ঠিক এই প্রক্রিয়া। এই আচরণটা এতটাই দরকারি যে অনেক প্রোগ্রাম ইচ্ছাকৃতভাবে ব্যবহার করে: একটা temp ফাইল খুলেই সাথে সাথে unlink() করা — ফাইলটা তখন কোনো namespace-এ দৃশ্যমান না, শুধু সেই একটা process-এর fd দিয়ে অ্যাক্সেসযোগ্য, আর process শেষ হলে স্বয়ংক্রিয়ভাবে পরিষ্কার (O_TMPFILE ফ্ল্যাগ, Linux 3.11+, ঠিক এই প্যাটার্নটাকে একধাপে করে দেয়)।

Level 9-এর সাথে যোগ: distributed filesystem-এ (NFS) এই একই সেমান্টিক্স রাখা কঠিন — client যদি একটা ফাইল খোলা রাখা অবস্থায় অন্য client সেটা unlink করে, server-কে “silly rename” (.nfsXXXXXXXX নামে গোপনে rename) করতে হয় local unlink সেমান্টিক্স নকল করতে, কারণ NFS protocol-এ “deleted কিন্তু খোলা” ধারণাটা native না।

2

একটা filesystem-এ block size 1024 বাইট (১KB), block pointer 4 বাইট। inode-এ ১০টা direct block, একটা single indirect, একটা double indirect (triple নেই, সরল রাখতে)। হিসাব করুন: (ক) single indirect block-এ কয়টা pointer ধরে? (খ) direct + single indirect মিলিয়ে সর্বোচ্চ কত বাইট কভার হয়? (গ) double indirect যোগ করলে সর্বমোট সর্বোচ্চ ফাইল সাইজ কত?

প্রয়োগ

(ক) Pointers per block:

1024 বাইট4 বাইট/pointer=256 pointer\frac{1024 \text{ বাইট}}{4 \text{ বাইট/pointer}} = 256 \text{ pointer}

(খ) Direct + single indirect:

স্তরব্লক সংখ্যাবাইট
Direct (১০টা)১০10×1024=10,24010 \times 1024 = 10{,}240
Single indirect২৫৬256×1024=262,144256 \times 1024 = 262{,}144
যোগফল২৬৬272,384272{,}384 বাইট (≈ ২৬৬KB)

(গ) Double indirect যোগ করে:

Double indirect একটা ব্লক যার প্রতিটা entry একটা single-indirect block-এর ঠিকানা, আর প্রতিটা single-indirect আবার ২৫৬টা data block ধরে:

double indirect ব্লক সংখ্যা=256×256=65,536\text{double indirect ব্লক সংখ্যা} = 256 \times 256 = 65{,}536 double indirect বাইট=65,536×1024=67,108,864 বাইট64MB\text{double indirect বাইট} = 65{,}536 \times 1024 = 67{,}108{,}864 \text{ বাইট} \approx 64\text{MB}

সর্বমোট:

10,240+262,144+67,108,864=67,381,248 বাইট64.26MB10{,}240 + 262{,}144 + 67{,}108{,}864 = 67{,}381{,}248 \text{ বাইট} \approx 64.26\text{MB}

লক্ষ করুন এই লেসনের “example” সেকশনের ৪KB ব্লক/১০২৪ pointer-per-block হিসাবের সাথে তুলনা করলে — এখানে ব্লক ছোট (১KB) আর pointer-per-block কম (২৫৬, কারণ ১০২৪÷৪) হওয়ায় প্রতিটা স্তরের multiplier ছোট, ফলে triple indirect ছাড়াই (শুধু double পর্যন্ত) সর্বোচ্চ সাইজ মাত্র ~৬৪MB-তে থেমে যায় — যেখানে ৪KB ব্লকে শুধু double indirect-ই ৪GB কভার করত। এই প্যাটার্নটাই মূল শিক্ষা: block size দ্বিগুণ করলে pointers-per-block দ্বিগুণ হয়, আর প্রতিটা indirection স্তরে সেই গুণিতক নিজের সাথে গুণ হয় — তাই ছোট block size দিয়ে design করা একটা filesystem বড় ফাইলের জন্য অনেক বেশি indirection-স্তর “খরচ” করে, একই কারণে পুরনো filesystem ছোট block size-এ ছোট max-file-size সীমায় আটকা পড়ত।

Level 6-এর সাথে যোগ: এই “প্রতি স্তরে গুণিতক” প্যাটার্নটা ঠিক B-tree-এর branching factor বনাম উচ্চতার সম্পর্কের মতোই — Level 6-এর algorithms module-এ B-tree analysis করার সময় এই একই গণিত ফিরে আসবে, আর দেখবেন কেন ডাটাবেস index-এ branching factor বড় রাখা (disk-block-সাইজ align করে) height কমিয়ে I/O কমায়।

3

truncate -s 1G sparse.img চালানোর পর du -h sparse.img দেখায় 4.0K। এখন dd if=/dev/zero of=sparse.img bs=1M count=1 seek=0 conv=notrunc চালানো হলো (প্রথম ১MB শূন্য দিয়ে ওভাররাইট)। এরপর du -h sparse.img কী দেখাবে, আর কেন? তারপর ls -l sparse.img কী দেখাবে?

যুক্তি

du -h এখন দেখাবে 1.0M (বা কাছাকাছি, filesystem-এর নিজস্ব ছোট metadata overhead-সহ কিছুটা বেশি) — 4.0K না, যদিও লেখা ডেটা নিজে সব শূন্য বাইট।

এখানে একটা সূক্ষ্ম কিন্তু গুরুত্বপূর্ণ পয়েন্ট: truncate -s 1G কোনো ব্লক বরাদ্দ করেনি বলে hole (zero পড়া হয় ব্লক ছাড়াই)। কিন্তু dd ... conv=notrunc একটা সাধারণ write() syscall, যেটা কার্নেলের কাছে “এই রেঞ্জে এই বাইটগুলো লেখো” — কার্নেল ডেটা পরীক্ষা করে “এগুলো তো সব শূন্য, তাই hole-ই রেখে দিই” এই optimization সাধারণত করে না (ext4 ডিফল্টে করে না; কিছু filesystem/ফাইল-সিস্টেম-নির্দিষ্ট hole-punching থাকতে পারে কিন্তু সেটা explicit fallocate(FALLOC_FL_PUNCH_HOLE) ছাড়া ঘটে না)। ফলে প্রথম ১MB-এর জন্য প্রকৃত data block বরাদ্দ হয়ে যায়, বিষয়বস্তু জিরো হলেও।

ls -l sparse.img এখনো 1.0G-ই দেখাবে, একবিন্দুও না বদলে — কারণ i_size নির্ধারিত হয় ফাইলের সর্বোচ্চ পৌঁছানো offset দিয়ে (আগের লেসনের একই নিয়ম), আর truncate -s 1G সেটা আগেই 1GB বসিয়ে দিয়েছে। ১MB লেখা সেই সীমার অনেক ভেতরে, তাই i_size অপরিবর্তিত।

সংক্ষেপে: i_size (ls -l) বলে “ফাইল কতদূর পর্যন্ত বিস্তৃত বলে দাবি করে”, i_blocks/du বলে “প্রকৃতপক্ষে কতটা ডিস্ক দখল করে আছে” — আর এই দুইটা সংখ্যা independent, একটা আরেকটা থেকে অনুমান করা যায় না।

একটা সত্যিকার hole বানাতে চাইলে dd-এর বদলে fallocate --punch-hole --offset 0 --length 1M sparse.img লাগবে, অথবা প্রথম থেকেই সেই রেঞ্জে কখনো না লেখা — কারণ hole তৈরি হয় “কখনো লেখা হয়নি” থেকে, “শূন্য লেখা হয়েছে” থেকে না।

Level 12-এর সাথে যোগ: cloud storage billing (AWS EBS, GCP Persistent Disk) ঠিক i_blocks-ভিত্তিক allocation গণনা করে, i_size না — তাই একটা “provisioned 1TB” volume-এ মাত্র ১০GB প্রকৃত ডেটা থাকলে thin-provisioning-এ বিল হয় ~১০GB-র, পুরো ১TB-র না। Level 12-এ এই thin-provisioning মডেল বিস্তারিত আসবে।

4

একটা backup script প্রতিদিন রাতে cp -al /data/today /backup/2026-08-20 চালায় (hard-link করে পুরো tree কপি করে, cp -al মানে “archive, link”)। তিন দিন পর কেউ /backup/2026-08-18/report.pdf-এ একটা ভুল পেয়ে সেটাকে edit করে ফেলল (in-place, vim দিয়ে সেভ করল)। প্রশ্ন: এই edit-টা কি /data/today/report.pdf-কেও বদলে দেবে? আর /backup/2026-08-19/report.pdf, /backup/2026-08-20/report.pdf-এর কী হবে? ব্যাখ্যা করুন কেন, আর একটা সমাধান প্রস্তাব করুন যাতে backup সত্যিই immutable থাকে।

প্রয়োগ

হ্যাঁ, edit-টা সবগুলো hard-linked কপিকেই বদলে দেবে/data/today/report.pdf সহ, আর প্রতিটা backup তারিখেও যদি সেই তারিখে ফাইলটা অপরিবর্তিত থেকে থাকে (cp -al-এ hard link হয়েছিল)।

কারণ: cp -al করে প্রতিটা ফাইলের একটা নতুন directory entry বানায় যা একই inode-কে নির্দেশ করে (এই লেসনের প্রথম section-এর link() ঠিক এটাই)। report.pdf-এর একটাই inode, একটাই data block সেট — শুধু directory entry-র সংখ্যা বেড়েছে (i_links_count অনেকগুলো তারিখ জুড়ে বাড়তে থাকবে)।

vim-এর edit কীভাবে সমস্যা করে তা নির্ভর করে vim কীভাবে সেভ করে:

vim-এর সেভ পদ্ধতিফলাফল
In-place (:set nobackup noswapfile, সরাসরি একই fd-তে ওভাররাইট)বিপর্যয়কর — সরাসরি সেই inode-এর data block বদলে যায়, তাই সব hard-linked কপিতেই (সব তারিখে) পরিবর্তন দেখা যায়
ডিফল্ট vim আচরণ (নতুন temp ফাইল লেখা, তারপর rename() দিয়ে আসল নামের জায়গায় বসানো)নিরাপদrename() পুরনো report.pdf নামটাকে নতুন inode-এর দিকে redirect করে দেয়; পুরনো inode (যেটা বাকি backup তারিখগুলো এখনো ধরে আছে) স্পর্শই হয় না

ডিফল্ট vim আচরণে সমস্যা হতো না, কিন্তু এটা backup design হিসেবে বিপজ্জনকভাবে ভঙ্গুর — কোনো টুল যদি in-place write করে (অনেক প্রোগ্রাম করে, বিশেষত database বা লগ ফাইলে append), পুরো backup history নীরবে করাপ্ট হয়ে যায়, আর কেউ বুঝতেও পারবে না যতক্ষণ না পুরনো backup restore করে দেখছে।

সমাধান — backup-কে সত্যিই read-only করে দিন filesystem-স্তরে:

# প্রতিটা backup snapshot তৈরির পরে
chmod -R a-w /backup/2026-08-18
# আরো কড়া: immutable attribute (ext4-এ), root-ও override করতে chattr লাগবে
sudo chattr -R +i /backup/2026-08-18

chattr +i (immutable flag, i_flags-এ EXT4_IMMUTABLE_FL) সেট করলে সেই inode-এ কোনো write, rename, এমনকি delete-ও কার্নেল-স্তরে ব্লক হয়ে যায় — vim-এর in-place write ব্যর্থ হবে EPERM দিয়ে, চুপচাপ করাপ্ট হবে না। এটা chmod-এর চেয়ে শক্তিশালী কারণ chmod ownership/permission দিয়ে আটকায় (root বাইপাস করতে পারে), chattr +i filesystem-level সুরক্ষা।

Level 8-এর সাথে যোগ: এই পুরো সমস্যাটা — “shared underlying storage, কিন্তু logically independent copy” — copy-on-write-এর মূল ধারণা, যেটা পরের লেসনের শেষে btrfs/ZFS আলোচনায় আসবে, আর Level 8-এর database module-এ MVCC (multi-version concurrency control)-এও একই প্যাটার্ন ফিরে আসে: একটা row-এর একাধিক “version” শেয়ার্ড স্টোরেজে থাকে যতক্ষণ না কেউ সত্যিই একটা version বদলায়, তখন নতুন version আলাদা হয়ে যায় — hard-link backup আর MVCC দুটোই “শেয়ার করো যতক্ষণ বদলাচ্ছ না, বদলালে আলাদা করো” নীতির প্রয়োগ, যদিও hard link-এ সেই বিচ্ছেদটা automatic না (এখানেই এই প্রশ্নের বিপত্তি)।

5

আপনি একটা নতুন filesystem ডিজাইন করছেন যেখানে বেশিরভাগ ফাইল খুব ছোট (গড়ে ২KB, যেমন একটা mail server-এর প্রতিটা email আলাদা ফাইল, লক্ষ লক্ষ ফাইল) — কিন্তু মাঝে মাঝে খুব বড় ফাইলও থাকে (attachment, কয়েক GB)। block addressing স্কিম হিসেবে আপনি (ক) ক্লাসিক direct+indirect, (খ) pure extent-based, নাকি (গ) hybrid বেছে নেবেন? প্রতিটার trade-off বিশ্লেষণ করুন এই নির্দিষ্ট workload-এর জন্য।

ডিজাইন

এই workload-এর দুইটা বিপরীতমুখী চাপ: লক্ষ লক্ষ ছোট ফাইল মানে প্রতি-inode metadata overhead সবচেয়ে গুরুত্বপূর্ণ (ছোট ফাইলে addressing metadata-র আকার inode-এর নিজের আকারের একটা বড় অংশ হয়ে যেতে পারে), আর মাঝে মাঝে বড় ফাইল মানে সেই বড় ফাইলগুলোর জন্য compact large-range addressing দরকার।

বিকল্প (ক) — খাঁটি direct+indirect: ছোট ফাইলের জন্য (২KB, ৪KB ব্লকে ১ ব্লকই যথেষ্ট) এটা চমৎকার — একটা মাত্র direct pointer, কোনো indirect ব্লক ছোঁয়ারই দরকার নেই, ন্যূনতম metadata। কিন্তু বড় (কয়েক GB) attachment ফাইলে ভয়াবহ — এই লেসনের example-এর হিসাব অনুযায়ী একটা contiguous ১GB ফাইলেও শত শত indirect block পয়েন্টার লাগবে, প্রতিটা pointer আলাদা যদি allocator perfectly contiguous জায়গা নাও দেয় (fragmentation সাধারণ)। এই workload-এর জন্য বড়-ফাইল-দিকটায় দুর্বল।

বিকল্প (খ) — pure extent-based: বড় ফাইলে চমৎকার (এই লেসনের ~১০০KB বনাম ~১২ বাইট তুলনা)। কিন্তু ছোট ফাইলে অতিরিক্ত জটিলতা — একটা extent struct (ee_block, ee_len, ee_start) একটা single direct pointer-এর চেয়ে ভারী (১২ বাইট বনাম ৪ বাইট), যখন ফাইলটা এমনিতেই এক ব্লকের — extent-এর “রেঞ্জ বর্ণনা করার” শক্তিটাই এখানে অপচয়, কারণ length সবসময় ১। লক্ষ লক্ষ এমন ফাইলে এই ছোট overhead গুণিতক হয়ে যোগ হয়।

বিকল্প (গ) — hybrid, সুপারিশকৃত: এটাই ext4 প্রকৃতপক্ষে করে, আর এই workload-এর জন্য সবচেয়ে যুক্তিসঙ্গত। inode-এর ভেতরে ছোট সংখ্যক (ext4-এ ৪টা) extent সরাসরি রাখা — একটা ২KB ফাইলে একটামাত্র extent (ee_block=0, ee_len=1) লাগে, যেটা inode-এর ভেতরেই বসে যায়, কোনো অতিরিক্ত ব্লক ছোঁয়া লাগে না (extent সামান্য বড় হলেও, এখনো “১ ব্লক-ই যথেষ্ট” শ্রেণির কর্মক্ষমতা)। বড় ফাইলে যখন extent সংখ্যা ৪ ছাড়িয়ে যায়, তখনই একটা B-tree-এ ছড়িয়ে পড়ে (extent tree) — কিন্তু এই খরচ শুধু সেই সংখ্যালঘু বড় ফাইলেই লাগে, লক্ষ লক্ষ ছোট ফাইলের কেউ এই খরচ বহন করে না।

অতিরিক্ত ডিজাইন বিবেচনা এই নির্দিষ্ট workload-এর জন্য:

  • inline data — কিছু আধুনিক filesystem (ext4-এর inline_data ফিচার, btrfs) খুবই ছোট ফাইল (কয়েকশ বাইট, একটা ছোট ইমেইল header তো এই রেঞ্জে পড়তেই পারে) সরাসরি inode-এর ভেতরে রেখে দেয়, কোনো আলাদা data block ছাড়াই — একটা data block বরাদ্দ (৪KB, ফাইল ২০০ বাইট হলেও পুরো ব্লক দখল) সম্পূর্ণ এড়িয়ে যায়। এই workload-এ (mail) ছোট email-এর একটা অংশ সহজেই এই সীমার নিচে পড়বে।
  • inode density — mkfs-এর সময় inode-প্রতি-বাইট অনুপাত টিউন করা জরুরি (mkfs.ext4 -i); লক্ষ লক্ষ ছোট ফাইলের workload-এ ডিফল্ট অনুপাতে inode ফুরিয়ে যেতে পারে যদিও ডিস্ক স্পেস বাকি থাকে (ENOSPC “No space left on device” আসলেও কারণ inode শেষ, ব্লক না) — df -i দিয়ে এটা আলাদা করে দেখা যায় সাধারণ df-এর থেকে।
  • directory scaling — লক্ষ লক্ষ ছোট ফাইল একই directory-তে রাখলে linear directory ফরম্যাট ধ্বসে পড়ে; এখানে hashed B-tree directory (HTree/H-tree) বাধ্যতামূলক, ঐচ্ছিক না।

Level 6-এর সাথে যোগ: এই hybrid-এর নকশা নীতিটাই — “সাধারণ কেসে ছোট/দ্রুত, বিরল কেসে scalable, দুটোর মধ্যে একটা threshold-ভিত্তিক switch” — ঠিক small-vector-optimization বা adaptive data structure-এর (যেমন Redis-এর ziplist বনাম full hash table, ছোট collection-এ compact encoding, বড় হলে সুইচ) একই সাধারণ প্যাটার্ন, যা Level 6-এর algorithms module-এ amortized/adaptive data structure আলোচনায় আরো গভীরভাবে আসবে।

এরপর কী

পরের লেসন — ext4 আর Journaling

এই লেসনে filesystem-এর ডেটা কীভাবে সাজানো থাকে সেটা দেখা হলো — কিন্তু একটা প্রশ্ন এড়িয়ে যাওয়া হয়েছে ইচ্ছা করে: যদি মাঝপথে power চলে যায়, তাহলে কী হয়? একটা ফাইলে ডেটা append করতে গেলে আসলে তিনটা আলাদা জায়গা বদলাতে হয় — inode-এর i_size, block bitmap, আর data block নিজে। এই তিনটা আলাদা write, আর একটা crash এই তিনটার মাঝখানের যেকোনো বিন্দুতে ঘটতে পারে।

পরের লেসনে দেখব ঠিক কী থাকে যায় প্রতিটা সম্ভাব্য crash point-এ, কেন পুরনো fsck-ভিত্তিক সমাধান পুরো ডিস্ক স্ক্যান করতে ঘণ্টার পর ঘণ্টা লাগাত, আর কীভাবে journaling — লেখার আগে একটা লগে “আমি কী করতে যাচ্ছি” লিখে রাখা — এই সমস্যাটা মিনিটের মধ্যে সমাধান করে দেয়। সাথে আসবে write() বনাম fsync()-এর প্রকৃত durability contract, ২০০৯ সালের বিখ্যাত ext4 “zero-length files after crash” ঘটনা, আর কেন শুধু ফাইল না, directory-ও fsync করা লাগে — এই লেসনেই শেখা “directory একটা ফাইল” ধারণাটার সরাসরি পরিণতি।

আরও পড়ুন

  • ext4 Disk Layout — ext4 wiki (kernel.org) · superblock, group descriptor, inode table, extent tree -- ext4-এর প্রকৃত on-disk ফরম্যাটের প্রামাণ্য উৎস
  • stat(2) -- Linux manual page — Michael Kerrisk, man-pages project · struct stat-এর সব ফিল্ড, st_dev/st_ino জোড়া কীভাবে ফাইলের প্রকৃত পরিচয় গঠন করে তার সংজ্ঞা
  • The Design and Implementation of the FreeBSD Operating System, Chapter 8 -- Local Filesystems — Marshall Kirk McKusick et al. · inode, directory, block addressing-এর ক্লাসিক ব্যাখ্যা -- ext2/ext3-এর ডিজাইন সরাসরি এই বংশধারা থেকে এসেছে
  • Linux kernel source -- fs/ext4/ext4.h ও Documentation/filesystems/ext4/ · struct ext4_inode, extent tree-র প্রকৃত C সংজ্ঞা এবং ডকুমেন্টেশন