io_uring — যখন syscall নিজেই bottleneck, তখন ring buffer দিয়ে kernel-এর সাথে কথা বলা
io_uring: Modern Asynchronous I/O on Linux
epoll আপনাকে বলে 'fd এখন প্রস্তুত' — কিন্তু আসল I/O করতে তবুও একটা syscall লাগে, আর regular file-এ সেটা কখনোই কাজ করেনি কারণ regular file সবসময় 'প্রস্তুত'। io_uring এই সমস্যাটাকে উল্টো দিক থেকে ধরে: userspace আর kernel দুইটা lock-free ring buffer শেয়ার করে, আপনি সেখানে কাজের অনুরোধ (SQE) লিখে রাখেন আর ফলাফল (CQE) তুলে নেন — steady state-এ syscall সংখ্যা শূন্যে নামানো সম্ভব। এই লেসনে ring-এর প্রকৃত মেকানিক্স, liburing দিয়ে একটা batch-read প্রোগ্রাম, আর io_uring-এর কুখ্যাত নিরাপত্তা-ইতিহাস সৎভাবে দেখব।
আগে এটা বুঝি
গত লেসনে আমরা I/O model-গুলোর একটা সিঁড়ি বেয়ে উঠেছিলাম — blocking, non-blocking, select, poll, তারপর epoll। শেষ ধাপে দাঁড়িয়ে মনে হয়েছিল সমস্যাটা সমাধান হয়ে গেছে: epoll O(1)-এ ১০ লাখ socket পাহারা দিতে পারে, C10K সমস্যা ইতিহাস।
কিন্তু একটা হিসাব করে দেখুন। ধরুন আপনার server-এ একটা epoll_wait ফিরল ৫০০টা ready socket নিয়ে। এখন কী করবেন? প্রতিটার জন্য একটা read() করবেন। তারপর হয়তো প্রতিটার জন্য একটা write()। অর্থাৎ:
১টা epoll_wait → ৫০০টা read → ৫০০টা write = ১০০১টা syscallepoll আপনাকে শুধু বলেছে “এই fd-গুলোতে ডেটা আছে” — সেই ডেটা আনার কাজটা সে করেনি। এটাকে বলে readiness interface: kernel প্রস্তুতির খবর দেয়, কাজটা আপনি করেন, আর প্রতিটা কাজ একটা করে syscall। Spectre/Meltdown মিটিগেশনের পর একটা আধুনিক x86-64 machine-এ একটা তুচ্ছ syscall-এরও খরচ ৩০০–৬০০ ন্যানোসেকেন্ড হতে পারে (KPTI-র page-table সুইচ, retpoline, IBRS — Level 10-এর security module-এ এই মিটিগেশনগুলো বিস্তারিত আসবে)। ১০০০ syscall × ৫০০ ns = ০.৫ মিলিসেকেন্ড, শুধু kernel-এ ঢোকা-বেরোনোর জন্য, কোনো প্রকৃত কাজ ছাড়াই।
আর একটা দ্বিতীয়, আরও অস্বস্তিকর সমস্যা আছে: epoll regular file-এ কাজই করে না। একটা disk file কখনো “প্রস্তুত না” হয় না — epoll_ctl একটা regular file-এর fd যোগ করতে গেলে EPERM দেয়। কারণটা দার্শনিক নয়, সংজ্ঞাগত: readiness-এর ধারণাটাই socket/pipe-এর জন্য তৈরি, যেখানে “ডেটা এসেছে কি না” একটা অর্থবহ প্রশ্ন। ফাইলের ক্ষেত্রে ডেটা সবসময় “আছে” — শুধু হয়তো disk থেকে আনতে ৫ মিলিসেকেন্ড লাগবে, আর সেই ৫ মিলিসেকেন্ড আপনার thread block হয়ে থাকবে।
এই লেসনের বিষয় হলো সেই interface যেটা এই দুইটা সমস্যার একটাই উত্তর দেয় — completion-ভিত্তিক মডেল, যেখানে আপনি kernel-কে বলেন “এই কাজটা করে দাও”, আর kernel পরে বলে “হয়ে গেছে, এই নাও ফলাফল”। নাম: io_uring, Jens Axboe-র ডিজাইন, Linux 5.1-এ (মে ২০১৯) প্রথম merge হয়। আর এর কেন্দ্রীয় কৌশলটা এত সরল যে প্রথমে বিশ্বাস হয় না: userspace আর kernel একই memory শেয়ার করে দুইটা ring buffer দিয়ে, আর steady state-এ একটাও syscall লাগে না।
মূল ধারণা
Readiness বনাম Completion — দুইটা মৌলিকভাবে ভিন্ন প্রশ্ন
পুরো লেসনের ভিত্তি এই একটা পার্থক্যে। দুইটা interface দুইটা ভিন্ন প্রশ্নের উত্তর দেয়:
Readiness model (select, poll, epoll) | Completion model (io_uring, Windows IOCP) | |
|---|---|---|
| Kernel-কে প্রশ্ন | “কোন fd-তে এখন কাজ করা যাবে?” | “এই কাজগুলো করে দাও” |
| Kernel-এর উত্তর | “এই ৫০০টা fd প্রস্তুত” | “এই ৫০০টা কাজ শেষ, ফলাফল এই” |
| প্রকৃত I/O কে করে | আপনি, আলাদা syscall-এ | Kernel, নিজে থেকে |
| Buffer কখন লাগে | read() কল করার মুহূর্তে | কাজ জমা দেওয়ার মুহূর্তে |
| N কাজের syscall খরচ | 1 + N (বা 2N) | 1, বা ০ (SQPOLL-এ) |
| Regular file-এ কাজ করে? | না (সবসময় “ready”) | হ্যাঁ |
শেষ সারিটাই সবচেয়ে বেশি underrated। Completion model-এ “ready” ধারণাটার দরকারই নেই — আপনি বলছেন “এই ফাইলের ১০২৪ বাইট পড়ো”, kernel সেটা যেভাবে পারে (page cache থেকে সাথে সাথে, বা disk থেকে ৫ ms পরে) করে ফলাফল জানায়। Socket, pipe, regular file, timerfd, accept, connect, openat, statx, fsync — সবই একই অভিন্ন ইন্টারফেসে চলে যায়।
io_uring-এর আগে যে চেষ্টাটা ব্যর্থ হয়েছিল — Linux AIO
Linux-এ completion-ভিত্তিক async I/O-র চেষ্টা io_uring-ই প্রথম না। ২০০৩ থেকে Linux-এ ছিল AIO (io_setup, io_submit, io_getevents, io_destroy — POSIX AIO নয়, এটা Linux-নির্দিষ্ট)। কাগজে-কলমে এটা ঠিক যা দরকার ছিল: batch submission, batch completion। বাস্তবে প্রায় কেউ ব্যবহার করেনি। কারণ তিনটা, আর তিনটাই কাঠামোগত:
| সমস্যা | বিস্তারিত |
|---|---|
শুধু O_DIRECT | Buffered (স্বাভাবিক) file I/O-তে AIO নীরবে synchronous হয়ে যেত — অর্থাৎ io_submit নিজেই block করত, ঠিক যা এড়াতে চাইছিলেন। শুধু O_DIRECT (page cache বাইপাস) খুললে সত্যিকারের async পেতেন, কিন্তু তখন আপনাকে নিজে caching, alignment, readahead সব করতে হতো |
| তবুও block করতে পারত | io_submit metadata পড়তে গিয়ে block করতে পারত (block mapping না থাকলে), বা request queue পূর্ণ থাকলে। অর্থাৎ “async” API-টার কোনো নিশ্চিত non-blocking গ্যারান্টিই ছিল না |
| সীমিত operation | মূলত read/write। accept, connect, openat, fsync — কিছুই না |
| প্রতি operation-এ copy | প্রতিটা io_submit-এ iocb structure userspace থেকে kernel-এ কপি হতো, প্রতিটা completion আবার উল্টোদিকে |
io_uring-এর কেন্দ্রীয় ধারণা — দুইটা shared ring
io_uring-এর পুরো interface মাত্র তিনটা syscall:
| Syscall | কাজ | কত বার কল হয় |
|---|---|---|
io_uring_setup(entries, params) | ring তৈরি করে, একটা fd ফেরত দেয় | প্রোগ্রামের শুরুতে একবার |
io_uring_enter(fd, to_submit, min_complete, flags, ...) | জমা দেওয়া কাজ kernel-কে জানানো এবং/অথবা completion-এর জন্য অপেক্ষা | প্রতি batch-এ একবার, বা কখনোই না |
io_uring_register(fd, opcode, arg, nr) | buffer/file আগেভাগে register করা | সেটআপে কয়েকবার |
আসল জাদুটা io_uring_setup-এর পরে। সেই fd-কে তিনবার mmap করলে userspace আর kernel একই ভৌত memory দেখতে পায়:
| mmap offset | কী পাওয়া যায় |
|---|---|
IORING_OFF_SQ_RING | Submission queue-র head, tail, mask, আর index array |
IORING_OFF_CQ_RING | Completion queue-র head, tail, mask, আর CQE array |
IORING_OFF_SQES | প্রকৃত SQE array (৬৪ বাইট প্রতিটা) |
USERSPACE KERNEL
┌─────────────────────┐ ┌─────────────────────┐
│ 1. SQE লেখো │ │ 3. SQE পড়ো │
│ (opcode, fd, │ │ কাজ শুরু করো │
│ buf, len) │ │ │
│ 2. SQ tail++ │ │ 4. SQ head++ │
└──────────┬──────────┘ └──────────▲──────────┘
│ │
▼ │
╔══════════════════════════════════════════════════════════════════╗
║ SUBMISSION QUEUE (shared mmap, lock-free SPSC ring) ║
║ ┌────┬────┬────┬────┬────┬────┬────┬────┐ ║
║ │SQE0│SQE1│SQE2│SQE3│ │ │ │ │ ৬৪ বাইট প্রতিটা ║
║ └────┴────┴────┴────┴────┴────┴────┴────┘ ║
║ head=1 (kernel) tail=4 (user) ║
╚══════════════════════════════════════════════════════════════════╝
╔══════════════════════════════════════════════════════════════════╗
║ COMPLETION QUEUE (shared mmap, lock-free SPSC ring) ║
║ ┌────┬────┬────┬────┬────┬────┬────┬────┐ ║
║ │CQE0│CQE1│ │ │ │ │ │ │ ১৬ বাইট প্রতিটা ║
║ └────┴────┴────┴────┴────┴────┴────┴────┘ ║
║ head=0 (user) tail=2 (kernel) ║
╚══════════════════════════════════════════════════════════════════╝
▲ │
│ ▼
┌──────────┴──────────┐ ┌─────────────────────┐
│ 6. CQE পড়ো │ │ 5. CQE লেখো │
│ (res, user_data)│ │ CQ tail++ │
│ 7. CQ head++ │ │ │
└─────────────────────┘ └─────────────────────┘দুইটা ring-ই single-producer single-consumer (SPSC) — SQ-তে userspace একমাত্র producer, kernel একমাত্র consumer; CQ-তে ঠিক উল্টো। এই সীমাবদ্ধতাটাই ring-দুটোকে সম্পূর্ণ lock-free রাখতে দেয়: কোনো mutex নেই, কোনো atomic compare-and-swap নেই, শুধু একটা acquire-load আর একটা release-store প্রতি দিকে (Level 11-এর memory ordering লেসনে এই দুইটা ঠিক কী গ্যারান্টি দেয় তা derive করা হবে)।
SQE — একটা কাজের অনুরোধ, ৬৪ বাইটে
একটা Submission Queue Entry দেখতে এরকম (\<linux/io_uring.h\>-এর struct io_uring_sqe, সরলীকৃত):
struct io_uring_sqe {
__u8 opcode; /* IORING_OP_READV, IORING_OP_ACCEPT, ... */
__u8 flags; /* IOSQE_IO_LINK, IOSQE_FIXED_FILE, ... */
__u16 ioprio;
__s32 fd; /* কোন fd-তে কাজ */
__u64 off; /* file offset */
__u64 addr; /* buffer-এর ঠিকানা */
__u32 len; /* কত বাইট */
/* ... union of op-specific flags ... */
__u64 user_data; /* আপনার নিজের ট্যাগ -- CQE-তে হুবহু ফেরত আসবে */
/* ... padding to 64 bytes ... */
};user_data ফিল্ডটাই পুরো programming model-টাকে ব্যবহারযোগ্য করে। Kernel এটা কখনো পড়ে না, ব্যাখ্যা করে না — শুধু completion-এর সময় হুবহু ফেরত দেয়। তাই এখানে একটা pointer রাখা যায় (আপনার connection struct-এর দিকে), বা একটা request-id, বা একটা tagged integer। CQE হাতে পেলে আপনি সাথে সাথে জানেন এটা কোন কাজের ফলাফল — কোনো লুকআপ টেবিল লাগে না।
CQE — একটা ফলাফল, ১৬ বাইটে
struct io_uring_cqe {
__u64 user_data; /* SQE-তে যা দিয়েছিলেন, হুবহু */
__s32 res; /* read()-এর return value-র মতোই: বাইট সংখ্যা, বা -errno */
__u32 flags;
};res ফিল্ডটার semantics ঠিক সেই syscall-টার মতোই যেটা আপনি চেয়েছিলেন — একটা read-এর CQE-তে res = 4096 মানে ৪০৯৬ বাইট পড়া হয়েছে, আর res = -2 মানে -ENOENT। লক্ষ করুন errno ব্যবহার হয় না — errno একটা thread-local variable, কিন্তু CQE যেকোনো thread-এ reap করা যেতে পারে, তাই error code সরাসরি ঋণাত্মক সংখ্যা হিসেবে res-এ আসে। এটা একটা ছোট কিন্তু গুরুত্বপূর্ণ ডিজাইন-সিদ্ধান্ত।
Batching — যেখানে আসল লাভটা
এখন পুরো ছবিটা একসাথে দেখুন। ১০০টা ফাইল পড়তে হবে:
পুরনো পদ্ধতি: io_uring:
for (i = 0; i \< 100; i++) for (i = 0; i \< 100; i++)
read(fd[i], buf[i], n); sqe = get_sqe(ring);
prep_read(sqe, fd[i], buf[i], n);
→ ১০০টা syscall io_uring_enter(ring_fd, 100, 100, ...);
→ ১০০টা kernel entry/exit → ১টা syscall
→ ১টা kernel entry/exitলুপের ভেতরের কাজটা এখন শুধু memory-তে লেখা — একটা ৬৪-বাইট struct পূরণ করা, যা কয়েক ন্যানোসেকেন্ড। ১০০টা কাজের জন্য মোট kernel-transition খরচ ১০০ থেকে ১-এ নেমে এল। এটাই io_uring-এর প্রথম-স্তরের লাভ, আর সবচেয়ে সহজে মাপা যায় (নিচের experiment-এ ঠিক এটাই মাপব strace -c দিয়ে)।
SQPOLL — সত্যিকারের শূন্য syscall
দ্বিতীয়-স্তরের লাভটা আরও চরম। io_uring_setup-এ IORING_SETUP_SQPOLL flag দিলে kernel একটা ডেডিকেটেড kernel thread চালু করে যেটা নিজে থেকেই SQ ring-এর tail পোল করতে থাকে। ফলাফল:
আপনার কোড: SQE লেখো → SQ tail-এ release-store করো → শেষ
↑
কোনো io_uring_enter নেই। কোনো syscall নেই।
kernel thread: tail বদলেছে? → নতুন SQE পড়ো → কাজ শুরু করোSteady state-এ (যতক্ষণ কাজ আসতে থাকে) আপনার প্রোগ্রাম একটাও syscall করে না — না submission-এ, না completion reap-এ (CQ ring তো এমনিতেই shared memory, শুধু পড়লেই হলো)। এটা আক্ষরিক অর্থে শূন্য kernel transition।
খরচটাও স্পষ্ট, আর সেটা লুকানোর কিছু নেই: একটা পুরো CPU core পুড়ছে সেই polling thread-এ। তাই SQPOLL শুধু তখনই যুক্তিযুক্ত যখন I/O rate যথেষ্ট উঁচু যে একটা core-এর মূল্য syscall-সাশ্রয়ের চেয়ে কম — ডেটাবেস, storage engine, উঁচু-throughput proxy। একটা সাধারণ web server-এ এটা নিছক অপচয়। sq_thread_idle প্যারামিটার দিয়ে বলা যায় কত মিলিসেকেন্ড নিষ্ক্রিয় থাকার পর thread ঘুমিয়ে পড়বে (তখন আবার জাগাতে একটা io_uring_enter লাগবে, IORING_SQ_NEED_WAKEUP flag দেখে)।
তিনটা উন্নত সুবিধা
Linked operations (IOSQE_IO_LINK) — একটা SQE-তে এই flag দিলে পরের SQE-টা এটার সফল সমাপ্তির পরে শুরু হবে। এতে একটা পুরো নির্ভরশীল চেইন এক batch-এ জমা দেওয়া যায়:
SQE[0]: openat("/etc/passwd") IOSQE_IO_LINK
SQE[1]: read(fd_from_prev) IOSQE_IO_LINK
SQE[2]: close(fd_from_prev)
└─ তিনটা কাজ, একটা submission, কোনো round-trip নেই মাঝখানেচেইনের কোনো ধাপ ব্যর্থ হলে বাকিগুলো -ECANCELED নিয়ে বাতিল হয়ে যায়। IOSQE_IO_HARDLINK একটা কড়া সংস্করণ (short read-কেও ব্যর্থতা ধরে না)।
Registered (fixed) buffers — সাধারণত প্রতিটা I/O-তে kernel-কে আপনার userspace buffer-এর page গুলো pin করতে হয় (get_user_pages), কাজ শেষে unpin করতে হয়। io_uring_register(IORING_REGISTER_BUFFERS) দিয়ে আগেই একবার pin করে রাখলে সেই খরচটা প্রতিবার আর লাগে না। উঁচু IOPS-এ এটা মাপার মতো পার্থক্য করে।
Registered files (IORING_REGISTER_FILES) — প্রতিটা operation-এ kernel-কে fd থেকে struct file * বের করতে হয় (fdtable lookup + reference count বাড়ানো-কমানো, যা multi-threaded প্রোগ্রামে atomic operation)। ফাইলগুলো register করে রাখলে SQE-তে সত্যিকারের fd-র বদলে একটা index দেওয়া যায় (IOSQE_FIXED_FILE), আর সেই lookup-টা সম্পূর্ণ বাদ যায়।
ভেতরে কী ঘটছে
একটা read-এর পূর্ণ যাত্রা, দুই পথে
সবচেয়ে স্পষ্টভাবে বোঝার উপায় হলো একই কাজ — একটা ৪ KB read — দুই interface-এ ধাপে ধাপে অনুসরণ করা।
- read(fd, buf, 4096)glibc wrapper, আর্গুমেন্ট রেজিস্টারে সাজায় (rdi, rsi, rdx), rax-এ syscall number 0
- syscall instructionCPU ring 3 → ring 0, MSR_LSTAR থেকে entry point, KPTI থাকলে CR3 লিখে page table সুইচ -- এখানেই ~২০০-৪০০ ns
- entry_SYSCALL_64 → sys_readsyscall table lookup, argument validation, fdget() দিয়ে fd → struct file *
- vfs_read → f_op->read_iterVFS স্তর, তারপর filesystem-নির্দিষ্ট read (ext4/xfs)
- page cache hit?হ্যাঁ হলে copy_to_user() করে ফেরত; না হলে thread এখানে ঘুমিয়ে পড়ে, disk I/O সম্পূর্ণ হওয়া পর্যন্ত
- sysret → userspaceআবার page table সুইচ, register পুনরুদ্ধার -- আরও ~২০০ ns
- io_uring_get_sqe(ring)shared mmap-এ পরবর্তী খালি SQE-র pointer -- নিছক pointer arithmetic, ~২ ns
- io_uring_prep_read(sqe, ...)৬৪ বাইটের struct পূরণ: opcode=IORING_OP_READ, fd, addr, len, user_data
- smp_store_release(sq->tail, n)একটা release-store -- এটাই পুরো 'submission'। userspace-এর কাজ এখানেই শেষ
- [kernel SQ poll thread] tail পড়েঅন্য একটা core-এ চলমান kernel thread acquire-load-এ নতুন tail দেখে -- আপনার thread কিছুই করেনি
- io_issue_sqe() → vfs read pathpage cache hit হলে সাথে সাথে সম্পূর্ণ; miss হলে io-wq worker thread-এ হস্তান্তর, যাতে poll thread block না করে
- CQE লেখা + smp_store_release(cq->tail)kernel shared memory-তে ১৬ বাইট লেখে; আপনার প্রোগ্রাম পরের বার CQ পড়লেই দেখতে পাবে -- syscall শূন্য
দুইটা trace পাশাপাশি রাখলে io_uring-এর দাবি আর কোনো বিমূর্ত কথা থাকে না: পথ ১-এ কমপক্ষে দুইটা privilege-level transition প্রতি কাজে, পথ ২-এ শূন্য।
Ring protocol-টা আসলে কী — head, tail, আর mask
Ring buffer-টার প্রকৃত নিয়মটা এতই সরল যে হাতে লিখে ফেলা যায়। প্রতিটা ring-এ চারটা জিনিস shared memory-তে থাকে:
| ফিল্ড | কে লেখে | কে পড়ে |
|---|---|---|
head | consumer | producer (কতটা জায়গা খালি জানতে) |
tail | producer | consumer (নতুন কিছু আছে কি না জানতে) |
ring_mask | kernel (setup-এ, স্থির) | দুইজনেই |
array / entries | producer | consumer |
Index দুইটা কখনো wrap করা হয় না — তারা অবিরাম বাড়তেই থাকে (৩২-বিট unsigned, overflow-এ স্বাভাবিকভাবেই wrap করে, আর সেটা ঠিক আছে)। প্রকৃত slot বের করা হয় mask দিয়ে:
unsigned index = tail & ring_mask; /* ring_mask = entries - 1, তাই entries দুইয়ের ঘাত */head আর tail আলাদা রাখার সৌন্দর্যটা হলো — খালি আর পূর্ণ অবস্থা আলাদা করা যায় (head == tail মানে খালি, tail - head == entries মানে পূর্ণ), অথচ কোনো আলাদা counter বা lock লাগে না। Level 6-এর data structure module-এ এই একই SPSC ring গঠনটা আবার আসবে, আর Level 11-এ দেখব কেন head/tail আলাদা cache line-এ রাখা জরুরি (false sharing এড়াতে — io_uring-এর kernel কোডেও এই padding আছে)।
Producer-এর দিকের সম্পূর্ণ প্রোটোকল, ছদ্মকোডে:
/* userspace, submission */
unsigned tail = sq->tail; /* আমরাই লিখি, plain load যথেষ্ট */
unsigned head = smp_load_acquire(sq->head); /* kernel লেখে -- acquire দরকার */
if (tail - head == sq_entries) return -EBUSY; /* ring পূর্ণ */
sqes[tail & mask] = (struct io_uring_sqe){ ... }; /* কাজটা লেখো */
sq->array[tail & mask] = tail & mask; /* indirection array */
smp_store_release(&sq->tail, tail + 1); /* এই একটা লাইনই 'প্রকাশ' করে */শেষ লাইনের smp_store_release-টা অপরিহার্য এবং সূক্ষ্ম। এটা গ্যারান্টি দেয় যে SQE-র সব বাইট লেখা শেষ হওয়ার আগে tail-এর নতুন মান অন্য CPU-তে দৃশ্যমান হবে না। এটা না থাকলে compiler বা CPU লেখাগুলো পুনর্বিন্যাস করতে পারত, আর kernel একটা অর্ধ-লেখা SQE পড়ে ফেলত। x86-64-তে release-store এমনিতেই একটা সাধারণ mov (TSO memory model), কিন্তু ARM64-তে এটা সত্যিকারের stlr instruction — তাই এটা “শুধু compiler barrier” ভেবে বাদ দিলে ARM সার্ভারে বাস্তব, পুনরুৎপাদনযোগ্য corruption পাবেন। Level 11-এর memory model লেসন এই দুই architecture-এর পার্থক্য পূর্ণভাবে ব্যাখ্যা করবে।
SQ ring-এ ওই অতিরিক্ত indirection array কেন
একটা প্রশ্ন প্রায় সবার মনে আসে: SQ ring-এ সরাসরি SQE না রেখে একটা array (index-এর array) রাখা হয়েছে কেন — একটা বাড়তি স্তর কেন?
কারণটা ব্যবহারিক: এতে আপনি SQE গুলো আগেভাগে প্রস্তুত করে রাখতে পারেন এবং যেকোনো ক্রমে জমা দিতে পারেন। যদি আপনার অ্যাপ্লিকেশনের একটা internal request pool থাকে যেখানে প্রতিটা request-এর SQE ইতিমধ্যে পূরণ করা, তবে জমা দেওয়ার সময় শুধু সঠিক index-টা array-তে লিখলেই হলো — ৬৪ বাইট আবার কপি করতে হলো না। বেশিরভাগ সাধারণ প্রোগ্রাম (এবং liburing-এর ডিফল্ট পথ) এই সুবিধাটা ব্যবহারই করে না, শুধু array[i] = i লিখে রাখে — কিন্তু interface-টা সম্ভাবনাটা খোলা রেখেছে।
io-wq — যখন কাজটা সত্যিই block করে
একটা প্রশ্ন এখনো বাকি: page cache miss হলে, অর্থাৎ disk থেকে পড়তেই হবে — তখন কে অপেক্ষা করে?
io_uring প্রথমে non-blocking চেষ্টা করে (IOCB_NOWAIT)। সফল হলে (page cache hit — বাস্তবে বেশিরভাগ ক্ষেত্রেই তাই) CQE সাথে সাথে পূরণ হয়ে যায়, কোনো thread কোথাও ঘুমায় না। ব্যর্থ হলে (-EAGAIN) কাজটা io-wq-তে হস্তান্তরিত হয় — একটা kernel-পরিচালিত worker thread pool, যেখানে একটা worker thread স্বাভাবিকভাবে block হয়ে বসে থাকে। আপনি ps -eLf-এ এদের iou-wrk-\<pid\> নামে দেখতে পাবেন।
উদাহরণ
১০,০০০ ছোট ফাইল পড়া — সংখ্যায় হিসাব
একটা বাস্তব কাজ ধরি: একটা ডিরেক্টরিতে ১০,০০০টা ছোট ফাইল (প্রতিটা ৪ KB), সবগুলো পড়ে মোট বাইট গুনতে হবে। ফাইলগুলো page cache-এ গরম (দ্বিতীয়বার চালাচ্ছেন), তাই disk-এর দেরি হিসাবে নেই — এটা বিশুদ্ধভাবে overhead-এর পরীক্ষা।
ধরা যাক এই মেশিনে (KPTI সক্রিয়, একটা সাধারণ ক্লাউড VM):
| উপাদান | সময় |
|---|---|
| একটা syscall-এর entry+exit overhead | ~৪৫০ ns |
page cache থেকে ৪ KB copy_to_user | ~৩৫০ ns |
| একটা SQE পূরণ (memory write) | ~৫ ns |
| একটা CQE পড়া (memory read) | ~৫ ns |
পদ্ধতি ১ — ১০,০০০টা সাধারণ read():
এর মধ্যে ৪.৫ ms বিশুদ্ধ syscall overhead — মোট সময়ের ৫৬%, কোনো প্রকৃত কাজ নয়।
পদ্ধতি ২ — io_uring, ২৫৬ করে batch, io_uring_enter দিয়ে:
Batch সংখ্যা = 10,000 ÷ 256 ≈ ৪০টা io_uring_enter কল।
পদ্ধতি ৩ — io_uring + SQPOLL (syscall শূন্য):
| পদ্ধতি | মোট সময় | Syscall সংখ্যা | Overhead-এর অংশ |
|---|---|---|---|
read() লুপ | ৮.০ ms | ১০,০০০ | ৫৬% |
| io_uring (batch ২৫৬) | ৩.৬ ms | ৪০ | ৩% |
| io_uring + SQPOLL | ৩.৬ ms | ০ | ~০% |
দুইটা জিনিস এই টেবিল থেকে সৎভাবে পড়া দরকার।
প্রথমত, লাভটা বাস্তব — ২.২× দ্রুত, এবং সেটা কোনো অ্যালগরিদম বদলে নয়, শুধু kernel-এর সাথে কথা বলার পদ্ধতি বদলে।
দ্বিতীয়ত, পদ্ধতি ২ আর ৩-এর মধ্যে পার্থক্য প্রায় শূন্য (৩.৬১৮ বনাম ৩.৬০০ ms — ০.৫%)। কারণ ২৫৬ করে batch করার পরেই syscall overhead ইতিমধ্যে নগণ্য হয়ে গেছে। SQPOLL-এর জন্য একটা পুরো CPU core খরচ করে ০.৫% পাওয়া একটা খারাপ লেনদেন — এই কাজের জন্য। SQPOLL সার্থক হয় যখন batch ছোট রাখতে বাধ্য হন (latency-সংবেদনশীল কাজ, যেখানে ২৫৬টা request জমা হওয়ার অপেক্ষা করা যায় না), তখন batch = ১-২ হয়ে যায় আর syscall আবার প্রধান খরচ।
নিজে চালিয়ে দেখুন
আপনার kernel-এ io_uring আছে কি না, আর কোন operation সমর্থিত
প্রথমে kernel সংস্করণ:
uname -r6.8.0-45-genericio_uring 5.1-এ এসেছে, কিন্তু প্রথম দিকের সংস্করণগুলোতে অনেক operation ছিল না এবং নিরাপত্তা-বাগও বেশি ছিল। 5.10 বা তার বেশি না হলে নতুন কোডে ব্যবহার করা উচিত নয়।
এখন কোনো নীতি (sysctl বা seccomp) এটা বন্ধ করে রেখেছে কি না:
cat /proc/sys/kernel/io_uring_disabled 2>/dev/null || echo "(এই sysctl নেই -- kernel 6.6-এর আগে)"0মানগুলোর অর্থ — 0: সবার জন্য খোলা; 1: শুধু CAP_SYS_ADMIN বা io_uring_group-এর সদস্যরা; 2: সম্পূর্ণ বন্ধ, io_uring_setup সবসময় -EPERM দেবে। এই sysctl-টা Linux 6.6-এ যোগ হয়েছে ঠিক নিরাপত্তার কারণেই — যা এই লেসনের realworld সেকশনের বিষয়।
এখন সত্যিকারের feature probe — অনুমান নয়, সরাসরি জিজ্ঞাসা:
sudo apt install -y liburing-dev # Debian/Ubuntu
# Fedora/RHEL: sudo dnf install liburing-devel
# Arch: sudo pacman -S liburing/* probe.c -- io_uring আছে কি না, আর কোন opcode সমর্থিত */
#include <stdio.h>
#include <string.h>
#include <liburing.h>
int main(void) {
struct io_uring ring;
int rc = io_uring_queue_init(8, &ring, 0);
if (rc < 0) {
fprintf(stderr, "io_uring অনুপলব্ধ: %s\n", strerror(-rc));
fprintf(stderr, "→ fallback পথ (read/epoll) ব্যবহার করুন\n");
return 1;
}
struct io_uring_probe *p = io_uring_get_probe_ring(&ring);
if (!p) { fprintf(stderr, "probe অসমর্থিত (kernel খুব পুরনো)\n"); return 1; }
printf("io_uring উপলব্ধ। শেষ সমর্থিত opcode: %d\n", p->last_op);
struct { int op; const char *name; } ops[] = {
{ IORING_OP_READ, "READ" }, { IORING_OP_WRITE, "WRITE" },
{ IORING_OP_ACCEPT, "ACCEPT" }, { IORING_OP_OPENAT, "OPENAT" },
{ IORING_OP_SEND, "SEND" }, { IORING_OP_RECV, "RECV" },
};
for (unsigned i = 0; i < sizeof(ops)/sizeof(ops[0]); i++)
printf(" %-8s : %s\n", ops[i].name,
io_uring_opcode_supported(p, ops[i].op) ? "হ্যাঁ" : "না");
io_uring_free_probe(p);
io_uring_queue_exit(&ring);
return 0;
}gcc -O2 probe.c -o probe -luring && ./probeio_uring উপলব্ধ। শেষ সমর্থিত opcode: 56
READ : হ্যাঁ
WRITE : হ্যাঁ
ACCEPT : হ্যাঁ
OPENAT : হ্যাঁ
SEND : হ্যাঁ
RECV : হ্যাঁmacOS বা WSL1-এ io_uring_queue_init সরাসরি ব্যর্থ হবে (ENOSYS বা লাইব্রেরিই পাবেন না) — এটা প্রত্যাশিত, io_uring একান্তই Linux-এর। macOS-এ সমতুল্য ধারণা kqueue (কিন্তু সেটা readiness-ভিত্তিক, completion নয়); Windows-এর IOCP অনেক আগে থেকেই completion-ভিত্তিক, আর io_uring-এর ডিজাইনে তার প্রভাব স্বীকৃত।
io_uring একটা kernel-সংস্করণ-নির্ভর সুবিধা, এবং অনেক প্রোডাকশন পরিবেশে (কন্টেইনার, hardened kernel) এটা ইচ্ছাকৃতভাবে বন্ধ করা থাকে -- তাই যেকোনো io_uring কোডে runtime probe আর fallback পথ বাধ্যতামূলক।
strace -c দিয়ে syscall গুনে দেখুন -- ৫০০০ read বনাম ২০ io_uring_enter
প্রথমে পরীক্ষার ডেটা — ৫০০০টা ছোট ফাইল:
mkdir -p /tmp/manyfiles && cd /tmp/manyfiles
for i in $(seq 1 5000); do head -c 4096 /dev/urandom > f$i.bin; done
ls | wc -l5000সংস্করণ ক — সাধারণ read লুপ:
/* naive.c */
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>
int main(int argc, char **argv) {
long total = 0;
char buf[4096];
for (int i = 1; i < argc; i++) {
int fd = open(argv[i], O_RDONLY);
if (fd < 0) continue;
ssize_t n = read(fd, buf, sizeof buf);
if (n > 0) total += n;
close(fd);
}
printf("মোট %ld বাইট\n", total);
return 0;
}gcc -O2 naive.c -o naive
./naive /tmp/manyfiles/*.bin # page cache গরম করার জন্য একবার
strace -c -f ./naive /tmp/manyfiles/*.binমোট 20480000 বাইট
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
41.02 0.013891 2 5000 read
33.77 0.011435 2 5001 openat
24.11 0.008165 1 5001 close
...
------ ----------- ----------- --------- --------- ----------------
100.00 0.033857 15012 6 totalসংস্করণ খ — io_uring, ২৫৬ করে batch (build সেকশনের batch_read.c, নিচে দেওয়া):
gcc -O2 batch_read.c -o batch_read -luring
strace -c -f ./batch_read /tmp/manyfiles/*.binমোট 20480000 বাইট, 5000 completion
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
52.19 0.006702 1 5001 openat
31.44 0.004037 100 40 io_uring_enter
14.90 0.001913 0 5001 close
...
------ ----------- ----------- --------- --------- ----------------
100.00 0.012838 10063 2 totalপড়ার মতো সংখ্যাগুলো:
| naive | io_uring | পরিবর্তন | |
|---|---|---|---|
| I/O-র জন্য syscall | ৫০০০ read | ৪০ io_uring_enter | ১২৫× কম |
| মোট syscall | ১৫,০১২ | ১০,০৬৩ | ৩৩% কম |
| মোট system time | ৩৩.৯ ms | ১২.৮ ms | ২.৬× কম |
Throughput-ও মাপুন, শুধু syscall নয়:
hyperfine --warmup 3 './naive /tmp/manyfiles/*.bin' './batch_read /tmp/manyfiles/*.bin'Benchmark 1: ./naive /tmp/manyfiles/*.bin
Time (mean ± σ): 41.7 ms ± 1.9 ms
Benchmark 2: ./batch_read /tmp/manyfiles/*.bin
Time (mean ± σ): 24.3 ms ± 1.4 ms
Summary
'./batch_read ...' ran 1.72 ± 0.12 times faster than './naive ...'hyperfine না থাকলে perf stat -r 10 ./naive ... দিয়েও একই তুলনা করা যায় — সেখানে context-switches আর page-faults counter-ও দেখতে পাবেন, যা এই লেসনের হিসাবের সাথে মিলিয়ে দেখার মতো।
io_uring-এর মূল দাবি -- N-টা I/O operation আর N-টা syscall নয় -- সরাসরি পর্যবেক্ষণযোগ্য। strace -c-র সারণিতে read-এর সংখ্যা হাজারে থেকে io_uring_enter-এর সংখ্যা দশে নেমে আসে, আর মোট system time কমে যায়।
নিজে বানান
liburing দিয়ে batch read -- আর নিচে ঠিক একই জিনিস raw syscall-এ
- liburing-dev ইনস্টল করুন এবং probe প্রোগ্রাম দিয়ে উপলব্ধতা নিশ্চিত করুন
- batch_read.c লিখে কম্পাইল করুন -luring দিয়ে, /tmp/manyfiles-এ চালান
- strace -c দিয়ে io_uring_enter-এর সংখ্যা গুনুন এবং naive সংস্করণের সাথে তুলনা করুন
- BATCH ধ্রুবকটা 1, 16, 256, 1024 করে চালিয়ে syscall সংখ্যা আর মোট সময়ের সম্পর্ক লক্ষ করুন
- raw-syscall সংস্করণটা পড়ে মিলিয়ে দেখুন liburing-এর প্রতিটা helper আসলে কোন লাইনগুলোর শর্টহ্যান্ড
অংশ ১ — liburing সংস্করণ
/* batch_read.c -- অনেকগুলো ফাইল io_uring দিয়ে batch করে পড়া
* বিল্ড: gcc -O2 batch_read.c -o batch_read -luring
* চালান: ./batch_read /tmp/manyfiles/*.bin
*/
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>
#include <liburing.h>
#define BATCH 256
#define BUFSZ 4096
struct job { /* প্রতিটা কাজের প্রেক্ষাপট -- pointer-টাই user_data */
int fd;
char *buf;
};
int main(int argc, char **argv) {
if (argc < 2) { fprintf(stderr, "ব্যবহার: %s file...\n", argv[0]); return 2; }
struct io_uring ring;
int rc = io_uring_queue_init(BATCH, &ring, 0);
if (rc < 0) {
fprintf(stderr, "io_uring অনুপলব্ধ (%s) -- সাধারণ read fallback\n",
strerror(-rc));
long t = 0; char b[BUFSZ];
for (int i = 1; i < argc; i++) {
int fd = open(argv[i], O_RDONLY);
if (fd < 0) continue;
ssize_t n = read(fd, b, BUFSZ);
if (n > 0) t += n;
close(fd);
}
printf("মোট %ld বাইট (fallback পথ)\n", t);
return 0;
}
long total = 0, done = 0;
int i = 1;
while (i < argc) {
int queued = 0;
/* ---- ধাপ ১: SQ ring ভরাও, প্রতিটা SQE শুধু memory-তে লেখা ---- */
while (i < argc && queued < BATCH) {
int fd = open(argv[i], O_RDONLY);
i++;
if (fd < 0) continue;
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
if (!sqe) { close(fd); break; } /* ring পূর্ণ */
struct job *j = malloc(sizeof *j);
j->fd = fd;
j->buf = malloc(BUFSZ);
io_uring_prep_read(sqe, fd, j->buf, BUFSZ, 0);
io_uring_sqe_set_data(sqe, j); /* user_data = আমাদের pointer */
queued++;
}
if (queued == 0) break;
/* ---- ধাপ ২: একটাই syscall -- queued-টা কাজ জমা দাও ---- */
rc = io_uring_submit(&ring);
if (rc < 0) { fprintf(stderr, "submit: %s\n", strerror(-rc)); break; }
/* ---- ধাপ ৩: completion তুলে নাও ---- */
for (int k = 0; k < queued; k++) {
struct io_uring_cqe *cqe;
rc = io_uring_wait_cqe(&ring, &cqe);
if (rc < 0) { fprintf(stderr, "wait_cqe: %s\n", strerror(-rc)); break; }
struct job *j = io_uring_cqe_get_data(cqe);
if (cqe->res < 0)
fprintf(stderr, "read ব্যর্থ: %s\n", strerror(-cqe->res));
else
total += cqe->res; /* res = কত বাইট পড়া হলো */
done++;
close(j->fd);
free(j->buf);
free(j);
io_uring_cqe_seen(&ring, cqe); /* CQ head++ -- slot ছেড়ে দাও */
}
}
printf("মোট %ld বাইট, %ld completion\n", total, done);
io_uring_queue_exit(&ring);
return 0;
}চালানো:
gcc -O2 batch_read.c -o batch_read -luring
./batch_read /tmp/manyfiles/*.binমোট 20480000 বাইট, 5000 completionঅংশ ২ — ঠিক একই জিনিস, কোনো লাইব্রেরি ছাড়া
liburing যা করে তা কোনো জাদু নয় — শুধু mmap আর index arithmetic। নিচের সংস্করণটা পড়লে ring-এর প্রকৃত মেকানিক্স চোখের সামনে খুলে যায়। এটাই সেই কোড যেটা io_uring_get_sqe আর io_uring_submit আসলে চালায়।
/* raw_uring.c -- liburing ছাড়া, শুধু syscall + mmap
* বিল্ড: gcc -O2 raw_uring.c -o raw_uring
*/
#define _GNU_SOURCE
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>
#include <sys/mman.h>
#include <sys/syscall.h>
#include <linux/io_uring.h>
#define QD 64
/* liburing নেই, তাই syscall wrapper নিজে লিখতে হবে */
static int sys_io_uring_setup(unsigned e, struct io_uring_params *p) {
return (int)syscall(__NR_io_uring_setup, e, p);
}
static int sys_io_uring_enter(int fd, unsigned to_submit,
unsigned min_complete, unsigned flags) {
return (int)syscall(__NR_io_uring_enter, fd, to_submit,
min_complete, flags, NULL, 0);
}
struct ring {
int fd;
/* SQ */
unsigned *sq_head, *sq_tail, *sq_mask, *sq_array;
struct io_uring_sqe *sqes;
/* CQ */
unsigned *cq_head, *cq_tail, *cq_mask;
struct io_uring_cqe *cqes;
};
static int ring_init(struct ring *r, unsigned entries) {
struct io_uring_params p;
memset(&p, 0, sizeof p);
r->fd = sys_io_uring_setup(entries, &p);
if (r->fd < 0) return -errno;
/* --- SQ ring mmap --- */
size_t sq_sz = p.sq_off.array + p.sq_entries * sizeof(unsigned);
void *sq = mmap(NULL, sq_sz, PROT_READ | PROT_WRITE,
MAP_SHARED | MAP_POPULATE, r->fd, IORING_OFF_SQ_RING);
if (sq == MAP_FAILED) return -errno;
r->sq_head = (unsigned *)((char *)sq + p.sq_off.head);
r->sq_tail = (unsigned *)((char *)sq + p.sq_off.tail);
r->sq_mask = (unsigned *)((char *)sq + p.sq_off.ring_mask);
r->sq_array = (unsigned *)((char *)sq + p.sq_off.array);
/* --- SQE array আলাদা mmap (আলাদা offset!) --- */
r->sqes = mmap(NULL, p.sq_entries * sizeof(struct io_uring_sqe),
PROT_READ | PROT_WRITE, MAP_SHARED | MAP_POPULATE,
r->fd, IORING_OFF_SQES);
if (r->sqes == MAP_FAILED) return -errno;
/* --- CQ ring mmap --- */
size_t cq_sz = p.cq_off.cqes + p.cq_entries * sizeof(struct io_uring_cqe);
void *cq = mmap(NULL, cq_sz, PROT_READ | PROT_WRITE,
MAP_SHARED | MAP_POPULATE, r->fd, IORING_OFF_CQ_RING);
if (cq == MAP_FAILED) return -errno;
r->cq_head = (unsigned *)((char *)cq + p.cq_off.head);
r->cq_tail = (unsigned *)((char *)cq + p.cq_off.tail);
r->cq_mask = (unsigned *)((char *)cq + p.cq_off.ring_mask);
r->cqes = (struct io_uring_cqe *)((char *)cq + p.cq_off.cqes);
return 0;
}
int main(int argc, char **argv) {
if (argc < 2) { fprintf(stderr, "ব্যবহার: %s file...\n", argv[0]); return 2; }
struct ring r;
int rc = ring_init(&r, QD);
if (rc < 0) { fprintf(stderr, "setup: %s\n", strerror(-rc)); return 1; }
static char bufs[QD][4096];
int fds[QD];
unsigned n = 0;
for (int i = 1; i < argc && n < QD; i++) {
int fd = open(argv[i], O_RDONLY);
if (fd < 0) continue;
fds[n] = fd;
/* ---- SQE হাতে লিখুন ---- */
unsigned tail = *r.sq_tail; /* আমরাই producer */
unsigned index = tail & *r.sq_mask;
struct io_uring_sqe *sqe = &r.sqes[index];
memset(sqe, 0, sizeof *sqe);
sqe->opcode = IORING_OP_READ;
sqe->fd = fd;
sqe->addr = (unsigned long)bufs[n];
sqe->len = 4096;
sqe->off = 0;
sqe->user_data = n; /* আমাদের ট্যাগ */
r.sq_array[index] = index; /* indirection */
__atomic_store_n(r.sq_tail, tail + 1, __ATOMIC_RELEASE); /* ★ প্রকাশ */
n++;
}
/* ---- একটাই syscall: n-টা জমা দাও, n-টার জন্য অপেক্ষা করো ---- */
rc = sys_io_uring_enter(r.fd, n, n, IORING_ENTER_GETEVENTS);
if (rc < 0) { perror("io_uring_enter"); return 1; }
/* ---- CQ থেকে ফলাফল তোলো ---- */
long total = 0;
unsigned head = *r.cq_head;
unsigned tail = __atomic_load_n(r.cq_tail, __ATOMIC_ACQUIRE); /* ★ গ্রহণ */
while (head != tail) {
struct io_uring_cqe *cqe = &r.cqes[head & *r.cq_mask];
if (cqe->res > 0) total += cqe->res;
else fprintf(stderr, "job %llu: %s\n",
(unsigned long long)cqe->user_data, strerror(-cqe->res));
head++;
}
__atomic_store_n(r.cq_head, head, __ATOMIC_RELEASE); /* slot ফেরত দাও */
printf("%u কাজ, মোট %ld বাইট\n", n, total);
for (unsigned i = 0; i < n; i++) close(fds[i]);
close(r.fd);
return 0;
}দুইটা ★ চিহ্নিত লাইনই পুরো protocol-এর হৃদয় — একটা release-store (submission প্রকাশ) আর একটা acquire-load (completion গ্রহণ)। বাকি সব শুধু index arithmetic। liburing-এর কয়েক হাজার লাইন কোড মূলত এই কয়েকটা লাইনকে নিরাপদ, portable আর সুবিধাজনক করে তোলার জন্য (প্রতিটা opcode-এর prep_* helper, SQPOLL-এর wakeup যুক্তি, buffer ring ব্যবস্থাপনা)।
নিজে বাড়ান
১. open+read+close-কে একটা linked চেইনে নিন। IORING_OP_OPENAT, IORING_OP_READ, IORING_OP_CLOSE তিনটা SQE-তে IOSQE_IO_LINK flag দিন, আর IORING_OP_READ-এ fd-র বদলে IOSQE_FIXED_FILE বা IORING_OP_OPENAT-এর ফলাফল ব্যবহার করুন। তারপর strace -c চালিয়ে দেখুন মোট syscall ১৫,০০০ থেকে কতে নামল। ইঙ্গিত: চেইনের মাঝে ব্যর্থতা হলে বাকিগুলো -ECANCELED পাবে — সেই ক্ষেত্রেও ঠিকভাবে হিসাব রাখুন।
২. BATCH কে ভেরিয়েবল করে একটা গ্রাফ বানান। BATCH কে argv[1] থেকে নিন, ১ থেকে ২০৪৮ পর্যন্ত দুইয়ের ঘাতে চালিয়ে প্রতিবার wall time আর io_uring_enter সংখ্যা রেকর্ড করুন। আপনি একটা স্পষ্ট hyperbola দেখবেন যা ~৬৪-১২৮-এর পর সমতল হয়ে যায় — সেটাই আপনার মেশিনে batching-এর প্রকৃত সীমা।
৩. SQPOLL চালু করুন এবং core-খরচ মাপুন। io_uring_queue_init_params দিয়ে IORING_SETUP_SQPOLL আর sq_thread_idle = 1000 দিন (CAP_SYS_NICE লাগতে পারে)। তারপর top -H-এ iou-sqp-\<pid\> thread-টার CPU% দেখুন — নিষ্ক্রিয় অবস্থাতেও ১০০% দেখলে বুঝবেন কেন এটা ডিফল্ট নয়।
৪. Registered buffer যোগ করে পার্থক্য মাপুন। io_uring_register_buffers দিয়ে সব buffer আগে register করুন, তারপর io_uring_prep_read_fixed ব্যবহার করুন। ছোট ফাইলে পার্থক্য সামান্য হবে; ৬৪ KB-র বড় read-এ ৫-১৫% পর্যন্ত দেখা যেতে পারে — কারণ তখন get_user_pages-এর খরচ আপেক্ষিকভাবে বড়।
৫. একটা সম্পূর্ণ echo server লিখুন io_uring-এ। IORING_OP_ACCEPT → IORING_OP_RECV → IORING_OP_SEND চক্র, প্রতিটা connection-এর state একটা struct-এ, user_data-তে সেই struct-এর pointer + একটা opcode tag (নিচের ২ বিটে)। এটা লেসন ২৫-এর graceful shutdown build-এর সাথে জোড়া লাগবে — তখন signalfd-এর বদলে IORING_OP_POLL_ADD দিয়ে signal সামলাতে পারবেন।
বাস্তব সিস্টেমে
যেখানে io_uring সত্যিই চলছে
PostgreSQL 18 (২০২৫) — PostgreSQL-এ asynchronous I/O subsystem যোগ হয়েছে, যেখানে io_method কনফিগারেশনের একটা বিকল্প io_uring। দশকের পর দশক ধরে PostgreSQL শুধু blocking pread/pwrite আর posix_fadvise(WILLNEED) দিয়ে readahead ইঙ্গিত দিয়ে চলছিল — Linux AIO কখনো যথেষ্ট ভালো ছিল না বলেই। io_uring প্রথম interface যেটা তাদের প্রকৃত async পথে যেতে দিয়েছে, তাও buffered I/O ছাড়ার প্রয়োজন ছাড়াই।
ScyllaDB / Seastar — Seastar ফ্রেমওয়ার্ক (ScyllaDB-র ভিত্তি) shard-per-core, share-nothing স্থাপত্যে চলে, আর সেখানে প্রতিটা syscall একটা core-এর run-to-completion লুপে বিঘ্ন। io_uring তাদের ডিজাইন-দর্শনের সাথে এত ভালো মেলে যে তারা এটাকে প্রথম দিকের গ্রহীতাদের একজন — এবং তাদের ব্লগগুলো io_uring-এর প্রাথমিক bug-রিপোর্টের একটা উল্লেখযোগ্য উৎস।
Meta / Facebook — Jens Axboe Meta-তে কাজ করেন, আর io_uring-এর উন্নয়ন মূলত সেখানেই অর্থায়িত। তাদের storage আর caching স্তরে এটা প্রোডাকশনে চলে।
Rust-এর tokio-uring, tokio-rs/io-uring; Go-তে জটিলতা — Rust ইকোসিস্টেমে io_uring-ভিত্তিক runtime সক্রিয়ভাবে বিকশিত হচ্ছে। Go-তে অবস্থাটা আকর্ষণীয়ভাবে ভিন্ন: Go-র runtime নিজেই একটা netpoller (epoll-ভিত্তিক) চালায় আর goroutine scheduling-এর সাথে গভীরভাবে জড়ানো, তাই io_uring যোগ করা মানে runtime-এর একটা কেন্দ্রীয় অংশ পুনর্লিখন — বহু আলোচনার পরেও এটা এখনো standard library-তে আসেনি। এটা একটা ভালো উদাহরণ যে “দ্রুততর interface” স্বয়ংক্রিয়ভাবে গৃহীত হয় না; বিদ্যমান স্থাপত্যের সাথে খাপ খাওয়ানোর খরচও একটা বাস্তব হিসাব।
নিরাপত্তার ইতিহাস — এখানে সততা জরুরি
io_uring-এর পারফরম্যান্সের গল্পটা যত উজ্জ্বল, নিরাপত্তার গল্পটা তত অস্বস্তিকর। এটা লুকানোর কিছু নেই, আর যেকোনো প্রোডাকশন সিদ্ধান্তে এটা ওজন করতেই হবে।
Google-এর kCTF পর্যবেক্ষণ (জুন ২০২৩)। Google-এর kernel bug bounty প্রোগ্রামে জমা পড়া ৪২টা Linux kernel exploit বিশ্লেষণ করে তারা জানায় প্রায় ৬০% io_uring-কে লক্ষ্য করেছিল। ফলাফল: Google ChromeOS-এ io_uring বন্ধ করে দেয়, Android-এ seccomp দিয়ে অ্যাপের জন্য ব্লক করে, আর GKE AutoPilot-এ নিষ্ক্রিয় করে। এটা কোনো তাত্ত্বিক সতর্কতা নয় — একটা বড় ভেন্ডরের বাস্তব, প্রকাশিত সিদ্ধান্ত।
কয়েকটা কংক্রিট CVE:
| CVE | প্রকৃতি | সংক্ষেপে |
|---|---|---|
| CVE-2021-3491 | Heap overflow | IORING_OP_PROVIDE_BUFFERS MAX_RW_COUNT সীমা বাইপাস করত |
| CVE-2021-41073 | Type confusion | loop_rw_iter — privilege escalation-এ ব্যবহারযোগ্য |
| CVE-2022-29582 | Use-after-free | Timeout handling-এর race condition |
| CVE-2022-1786 | Use-after-free | পাবলিক exploit লেখা হয়েছিল, kCTF-এ ব্যবহৃত |
| CVE-2023-2598 | Out-of-bounds | Registered (fixed) buffer-এর folio হিসাবে ভুল — ভৌত memory-তে OOB access |
কেন এটা কঠিন — কাঠামোগত ব্যাখ্যা। এগুলো “অসাবধান প্রোগ্রামিং” নয়; io_uring-এর ডিজাইনই কয়েকটা কারণে নিরাপদ করা কঠিন:
- বিশাল নতুন attack surface। ৫০-এর বেশি opcode, প্রতিটা kernel-এর একটা আলাদা subsystem-এ পৌঁছায় (VFS, নেটওয়ার্ক স্ট্যাক, timer, filesystem)। প্রতিটাকে এখন একটা নতুন প্রেক্ষাপটে নিরাপদ হতে হবে — এমন প্রেক্ষাপট যার জন্য মূল কোডটা লেখা হয়নি।
- অসমকালীন জীবনকাল (lifetime)। একটা সাধারণ syscall-এ resource-এর জীবনকাল syscall-এর সীমার মধ্যেই আবদ্ধ। io_uring-এ একটা কাজ জমা দেওয়ার পর আপনার প্রোগ্রাম
close()করতে পারে,munmap()করতে পারে,fork()করতে পারে,exit()করতে পারে — kernel-কে প্রতিটা সংমিশ্রণে reference counting সঠিক রাখতে হবে। use-after-free বাগের সবচেয়ে উর্বর ক্ষেত্র ঠিক এখানেই, আর তালিকার তিনটা CVE-ই এই শ্রেণির। - কাজ অন্য প্রেক্ষাপটে চলে। io-wq worker thread একটা ভিন্ন credential/namespace প্রেক্ষাপটে চলতে পারে, যা permission check-এর অনুমানগুলোকে সূক্ষ্মভাবে ভাঙে (কিছু CVE ঠিক এই বিভ্রান্তি থেকেই এসেছে)।
- Seccomp-কে এটা অন্ধ করে দেয়। একটা seccomp ফিল্টার
openatব্লক করলেও, io_uring-এরIORING_OP_OPENATসেই ফিল্টার এড়িয়ে যেতে পারত (কারণ কাজটা kernel thread-এ হচ্ছে, আপনার thread-এর seccomp প্রয়োগ হচ্ছে না)। এটা পরে সংশোধিত হয়েছে, কিন্তু ধারণাগত সমস্যাটা — একটা syscall যেটা অন্য সব syscall-এর কাজ করতে পারে, তাকে syscall-স্তরের ফিল্টার দিয়ে নিয়ন্ত্রণ করা কঠিন — টিকে আছে। Level 10-এর security module-এ sandboxing নিয়ে আলোচনায় এই উদাহরণটা কেন্দ্রীয়।
kernel-এর প্রতিক্রিয়া। Linux 6.6-এ kernel.io_uring_disabled sysctl যোগ হয় (0/1/2) আর kernel.io_uring_group দিয়ে নির্দিষ্ট গ্রুপে সীমাবদ্ধ করার উপায় আসে। কোডে দুর্বলতাগুলো একে একে ঠিক হচ্ছে, fuzzing (syzkaller) io_uring-এ ব্যাপকভাবে চালানো হচ্ছে, আর পরিপক্বতার হার বাড়ছে।
যে ভুলগুলো সবাই করে
“io_uring সবসময় epoll-এর চেয়ে দ্রুত, তাই নতুন কোডে সবসময় io_uring ব্যবহার করা উচিত।”
লেসনের example সেকশনের হিসাবটা মনে করুন — io_uring-এর লাভ আসে batching থেকে। যদি আপনার প্রোগ্রাম একবারে একটা করে I/O করে (একটা সাধারণ request-response সার্ভার, একটা CLI টুল), তবে batch size = ১, আর তখন io_uring-এর প্রতি operation-এ syscall সংখ্যা epoll-এর সমান বা তার চেয়ে বেশিই হতে পারে (submission + completion wait)।
বাস্তবে অনেক benchmark দেখিয়েছে যে কম কানেকশন সংখ্যায় বা কম I/O rate-এ epoll io_uring-এর সমান বা দ্রুত — কারণ epoll-এর কোড পথ ছোট, cache-friendly, আর দুই দশক ধরে অপ্টিমাইজড। io_uring-এর সেটআপ খরচ (দুইটা ring mmap, kernel-এ per-ring state) একটা স্বল্পস্থায়ী প্রক্রিয়ার জন্য যুক্তিহীন হতে পারে।
সঠিক নিয়মটা হলো: মাপুন। strace -c-এ যদি syscall-এর সময় আপনার প্রোফাইলের অর্থবহ অংশ না হয়, io_uring কিছুই দেবে না।
“io_uring মানে কোনো thread কখনো block করে না -- সবকিছু সত্যিকারের non-blocking।”
Hood সেকশনের io-wq আলোচনাটা ঠিক এটার প্রতিষেধক। io_uring প্রথমে non-blocking চেষ্টা করে (IOCB_NOWAIT); সেটা সফল হলে (page cache hit) সত্যিই কেউ block করে না। কিন্তু cache miss হলে কাজটা io-wq worker thread-এ যায়, আর সেই kernel thread স্বাভাবিকভাবেই block হয়ে বসে থাকে disk I/O শেষ না হওয়া পর্যন্ত।
ps -eLf | grep iou- চালালে আপনি এই worker thread-গুলো নিজের চোখে দেখতে পাবেন। অর্থাৎ io_uring আসলে আপনার thread-কে blocking থেকে মুক্ত করে, blocking-কে দুনিয়া থেকে মুছে দেয় না — কাজটা kernel-পরিচালিত একটা thread pool-এ সরে যায়।
পার্থক্যটা তবুও মূল্যবান (fast path-এ কোনো hand-off নেই, pool ব্যবস্থাপনা kernel করে), কিন্তু “কোনো blocking নেই” বলাটা প্রযুক্তিগতভাবে ভুল এবং capacity planning-এ ভুল সিদ্ধান্তে নিয়ে যায় — কারণ io-wq thread সংখ্যারও একটা সীমা আছে (IORING_REGISTER_IOWQ_MAX_WORKERS দিয়ে সেট করা যায়), আর সেই সীমায় পৌঁছালে throughput আটকে যাবে।
“SQPOLL চালু করলেই সবসময় সবচেয়ে ভালো পারফরম্যান্স পাবেন, কারণ syscall শূন্য।”
Example সেকশনের টেবিলটা এই দাবির সরাসরি পরিমাণগত খণ্ডন — ২৫৬ করে batch করলে io_uring (৩.৬১৮ ms) আর io_uring+SQPOLL (৩.৬০০ ms)-এর পার্থক্য ০.৫%, অথচ SQPOLL-এর দাম একটা পুরো CPU core যেটা ব্যস্ত-লুপে ঘুরছে।
আটটা core-এর একটা মেশিনে এটা আপনার ১২.৫% CPU ক্ষমতা কেড়ে নিল ০.৫% latency উন্নতির জন্য। যদি সেই core-টা অন্য কাজে লাগত (আরও worker thread, আরও request handle করা), মোট throughput আসলে কমে যেত।
SQPOLL সার্থক শুধু তখনই যখন batching সম্ভব নয় — অর্থাৎ latency-সংবেদনশীল কাজ যেখানে প্রতিটা request সাথে সাথে জমা দিতে হয়, ফলে batch size ১-২-এ আটকে থাকে আর syscall আবার প্রধান খরচ হয়ে দাঁড়ায়। এছাড়া sq_thread_idle ঠিকমতো সেট না করলে idle অবস্থাতেও core পুড়তে থাকবে, যা ল্যাপটপে ব্যাটারি আর ক্লাউডে বিল দুটোতেই সরাসরি প্রভাব ফেলে (Level 12-এর cloud module-এ CPU-খরচের এই হিসাব বিস্তারিত আসবে)।
“io_uring-এর নিরাপত্তা-সমস্যাগুলো শুধু পুরনো kernel-এর ব্যাপার -- এখন সব ঠিক হয়ে গেছে।”
সংশোধন হচ্ছে, এটা সত্যি — কিন্তু “সব ঠিক হয়ে গেছে” বলাটা সেই কাঠামোগত কারণগুলো উপেক্ষা করা যা realworld সেকশনে তালিকাভুক্ত: ৫০-এর বেশি opcode যা kernel-এর প্রায় প্রতিটা subsystem-এ পৌঁছায়, অসমকালীন resource lifetime, আর কাজের ভিন্ন প্রেক্ষাপটে সম্পাদন। এই তিনটাই ডিজাইনের অন্তর্নিহিত বৈশিষ্ট্য, বাগ নয় — এবং তিনটাই নতুন বাগের জন্য উর্বর ভূমি হিসেবে টিকে আছে।
সবচেয়ে শক্ত প্রমাণ: Linux 6.6-এ (২০২৩, অর্থাৎ io_uring merge হওয়ার চার বছর পর) kernel.io_uring_disabled sysctl যোগ করা হয়েছে। Kernel community যদি সমস্যাটাকে সমাধান-হয়ে-গেছে মনে করত, তবে একটা global kill switch যোগ করার দরকার হতো না। একইভাবে Google-এর ChromeOS/Android/GKE-তে বন্ধ রাখার সিদ্ধান্ত এখনো বহাল।
সঠিক অবস্থান: io_uring বিশ্বস্ত workload-এ ব্যবহারযোগ্য এবং ক্রমশ পরিপক্ব হচ্ছে, কিন্তু একটা multi-tenant sandbox-এ untrusted কোডকে io_uring দেওয়াটা এখনো একটা সচেতন, ন্যায্যতা-প্রয়োজনীয় ঝুঁকি — ২০১৯-এর মতোই।
বুঝেছেন কি না দেখুন
1আপনার সার্ভার epoll ব্যবহার করে, আর একটা সাধারণ epoll_wait ২০০টা ready socket ফেরত দেয়, প্রতিটার জন্য একটা read আর একটা write করতে হয়। syscall overhead ৫০০ ns ধরে, প্রতি epoll_wait চক্রে কত সময় শুধু kernel transition-এ যায়? io_uring-এ (batch ২০০, একটা io_uring_enter) সেটা কত হতো, আর সাশ্রয়ের শতাংশ কত?
যুক্তি
epoll ব্যবহার করে, আর একটা সাধারণ epoll_wait ২০০টা ready socket ফেরত দেয়, প্রতিটার জন্য একটা read আর একটা write করতে হয়। syscall overhead ৫০০ ns ধরে, প্রতি epoll_wait চক্রে কত সময় শুধু kernel transition-এ যায়? io_uring-এ (batch ২০০, একটা io_uring_enter) সেটা কত হতো, আর সাশ্রয়ের শতাংশ কত?epoll পথ:
| ধাপ | syscall সংখ্যা |
|---|---|
epoll_wait | ১ |
read × ২০০ | ২০০ |
write × ২০০ | ২০০ |
| মোট | ৪০১ |
io_uring পথ: ২০০টা read আর ২০০টা write SQE হিসেবে জমা (৪০০টা SQE, সবগুলো memory write), তারপর একটা io_uring_enter:
কিন্তু এখানে একটা গুরুত্বপূর্ণ সতর্কতা যোগ করা দরকার — এটা শুধু kernel transition-এর হিসাব, মোট সময়ের নয়। প্রকৃত কাজ (TCP স্ট্যাকে ডেটা কপি, checksum, protocol processing) দুই পথেই একই খরচ। যদি প্রতিটা read+write-এ প্রকৃত কাজ ৩ μs হয়, মোট চিত্রটা দাঁড়ায়:
| transition | প্রকৃত কাজ | মোট | |
|---|---|---|---|
| epoll | ২০০.৫ μs | ৬০০ μs | ৮০০.৫ μs |
| io_uring | ০.৫ μs | ৬০০ μs | ৬০০.৫ μs |
বাস্তব উন্নতি তখন ২৫% (৯৯.৭৫% নয়) — এটাই Amdahl-এর নিয়ম, যেটা Level 11-এর performance module-এর কেন্দ্রীয় সূত্র: একটা উপাদানকে শূন্যে নামিয়ে আনলেও মোট উন্নতি সেই উপাদানের অংশের চেয়ে বেশি হতে পারে না। io_uring-এর benchmark পড়ার সময় সবসময় জিজ্ঞাসা করুন — এটা transition মাপছে, না মোট সময়?
2নিচের কোডে একটা সূক্ষ্ম কিন্তু মারাত্মক বাগ আছে। খুঁজে বের করুন এবং ব্যাখ্যা করুন ঠিক কী ভুল হবে।
for (int i = 0; i < 100; i++) {
char buf[4096]; /* stack-এ */
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fds[i], buf, 4096, 0);
io_uring_sqe_set_data(sqe, (void *)(long)i);
}
io_uring_submit(&ring);
/* ... পরে CQE reap ... */
প্রয়োগ
for (int i = 0; i < 100; i++) {
char buf[4096]; /* stack-এ */
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fds[i], buf, 4096, 0);
io_uring_sqe_set_data(sqe, (void *)(long)i);
}
io_uring_submit(&ring);
/* ... পরে CQE reap ... */বাগ: buf লুপের প্রতিটা iteration-এ scope ছেড়ে যায়, কিন্তু kernel সেই ঠিকানায় পরে লিখবে।
এটা completion model-এর সবচেয়ে গুরুত্বপূর্ণ নিয়মটার লঙ্ঘন, যা concept সেকশনের টেবিলে ছিল: buffer লাগে কাজ জমা দেওয়ার সময়, আর সেটাকে completion পর্যন্ত বেঁচে থাকতে হবে।
কী ঘটে ধাপে ধাপে:
| সময় | ঘটনা |
|---|---|
| t0 | buf stack-এ বরাদ্দ, ঠিকানা ধরুন 0x7ffd...e00 |
| t1 | SQE-তে সেই ঠিকানা লেখা হলো |
| t2 | লুপের iteration শেষ — buf-এর scope শেষ, কিন্তু stack pointer একই জায়গায় ফিরে আসে |
| t3 | পরের iteration একই ঠিকানা পুনর্ব্যবহার করে — ১০০টা SQE-ই একই ৪ KB-তে লিখতে বলছে |
| t4 | io_uring_submit — kernel কাজ শুরু করে |
| t5 | submit-এর পরের ফাংশন কলগুলো সেই একই stack অঞ্চল ব্যবহার করে |
| t6 | Kernel অসমকালীনভাবে সেখানে ডেটা লেখে — আপনার সক্রিয় stack frame overwrite হয়ে যায় |
ফলাফল দুইটা, দুইটাই খারাপ:
- ডেটা ধ্বংস — ১০০টা read একই buffer-এ লিখছে, তাই ৯৯টা ফলাফল হারিয়ে যায়।
- Stack corruption — সবচেয়ে বিপজ্জনক অংশ।
submit-এর পরে আপনার প্রোগ্রাম যেসব local variable, saved register, আর return address stack-এ রেখেছে, kernel সেগুলোর উপর দিয়ে লিখতে পারে। লক্ষণগুলো ভয়াবহভাবে অসংগত হবে — কখনো crash, কখনো ভুল উত্তর, কখনো কিছুই না (timing-নির্ভর)। এটা কার্যত একটা self-inflicted stack buffer overflow, যা Level 10-এর security module-এর সেই আক্রমণ-শ্রেণির সাথে যান্ত্রিকভাবে অভিন্ন।
সঠিক সংস্করণ — প্রতিটা কাজের জন্য আলাদা, দীর্ঘজীবী buffer, আর user_data-তে সেটার pointer:
struct job { int fd; char *buf; };
for (int i = 0; i < 100; i++) {
struct job *j = malloc(sizeof *j);
j->fd = fds[i];
j->buf = malloc(4096); /* heap -- completion পর্যন্ত বাঁচবে */
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, j->fd, j->buf, 4096, 0);
io_uring_sqe_set_data(sqe, j);
}
io_uring_submit(&ring);
/* CQE reap করার সময় j->buf ব্যবহার করে, তারপর free */সাধারণ নিয়ম: io_uring-এ যে memory জমা দিয়েছেন, সেটা CQE হাতে না পাওয়া পর্যন্ত আপনার নয় — free করবেন না, scope ছাড়তে দেবেন না, পুনর্ব্যবহার করবেন না। Rust-এর tokio-uring এই সমস্যাটা সমাধান করতে buffer-এর ownership runtime-কে দিয়ে দেয় এবং completion-এ ফেরত দেয় — একই কারণে Rust-এর সাধারণ AsyncRead trait io_uring-এ সরাসরি ম্যাপ করা যায়নি। এটা Level 5-এর type system আলোচনার একটা চমৎকার বাস্তব উদাহরণ: ownership নিয়ম দিয়ে একটা পুরো বাগ-শ্রেণি কম্পাইল-টাইমে নির্মূল করা।
3smp_store_release(&sq->tail, tail + 1)-এর বদলে যদি সাধারণ sq->tail = tail + 1; লেখা হয়, x86-64-তে সম্ভবত কাজ করবে কিন্তু ARM64-তে ভাঙবে। কেন? আর ঠিক কী ধরনের ব্যর্থতা দেখা যাবে?
যুক্তি
smp_store_release(&sq->tail, tail + 1)-এর বদলে যদি সাধারণ sq->tail = tail + 1; লেখা হয়, x86-64-তে সম্ভবত কাজ করবে কিন্তু ARM64-তে ভাঙবে। কেন? আর ঠিক কী ধরনের ব্যর্থতা দেখা যাবে?সমস্যাটা দুইটা স্তরে — compiler আর CPU — আর দুই architecture দুইভাবে প্রভাবিত।
যা ঘটা উচিত: SQE-র ৬৪ বাইট সম্পূর্ণ লেখা হবে, তারপর tail বাড়বে। Kernel নতুন tail দেখেই ধরে নেয় SQE প্রস্তুত।
Compiler স্তর (দুই architecture-এই সমস্যা): compiler জানে না অন্য একটা CPU এই memory পড়ছে। তার দৃষ্টিতে sqe->opcode = ... আর sq->tail = ... স্বাধীন লেখা, তাই সে অবাধে পুনর্বিন্যাস করতে পারে — বিশেষ করে -O2-তে। smp_store_release একটা compiler barrier হিসেবেও কাজ করে।
CPU স্তর — এখানেই architecture-এর পার্থক্য:
| Architecture | Memory model | সাধারণ store-এ কী হয় |
|---|---|---|
| x86-64 | TSO (Total Store Order) | Store-গুলো প্রোগ্রাম-ক্রমেই দৃশ্যমান হয়; store-store পুনর্বিন্যাস হয় না |
| ARM64 | Weakly ordered | Store-গুলো যেকোনো ক্রমে অন্য core-এ দৃশ্যমান হতে পারে |
তাই x86-64-তে release-store কম্পাইল হয় একটা সাধারণ mov-এ (হার্ডওয়্যার এমনিতেই গ্যারান্টিটা দেয়), আর ARM64-তে হয় একটা প্রকৃত stlr (store-release) instruction-এ, যা হার্ডওয়্যারকে স্পষ্টভাবে বলে “এর আগের সব store এর আগে দৃশ্যমান হতে হবে”।
ARM64-তে ব্যর্থতাটা দেখতে কেমন: kernel-এর SQ poll thread নতুন tail দেখে SQE পড়তে যায়, কিন্তু SQE-র কিছু ফিল্ড এখনো পুরনো (আগের কাজের অবশিষ্টাংশ) বা শূন্য। বাস্তব লক্ষণ:
- এলোমেলোভাবে
-EINVALবা-EBADFCQE (opcode বা fd এখনো লেখা হয়নি) - ভুল ফাইল থেকে পড়া (fd ফিল্ড পুরনো)
len = 0বা বিশালlen(আংশিক লেখা ফিল্ড)- সবচেয়ে ভয়াবহ: পুরনো
addr— অর্থাৎ kernel একটা ভুল ঠিকানায় লিখছে, যা এখন হয়তো অন্য কিছুর দখলে
আর এই বাগের চরিত্রটাই সবচেয়ে কষ্টকর: এটা নন-ডিটার্মিনিস্টিক, লোডের নিচে বেশি দেখা যায়, ডিবাগারের নিচে অদৃশ্য হয়ে যায় (timing বদলে যায় বলে), আর x86-64 ডেভেলপমেন্ট মেশিনে কখনোই পুনরুৎপাদন হয় না — শুধু ARM প্রোডাকশন সার্ভারে (Graviton, Ampere)।
এই memory ordering-এর পূর্ণ formalism — acquire, release, sequentially consistent, relaxed, আর প্রতিটার ঠিক কী গ্যারান্টি — Level 11-এর concurrency ও memory model লেসনে derive করা হবে। আপাতত নিয়মটা মনে রাখুন: shared memory-তে দুই CPU-র মধ্যে “আমি লেখা শেষ করেছি” জানানোর একমাত্র সঠিক উপায় একটা release-store, আর “সে লেখা শেষ করেছে” জানার একমাত্র সঠিক উপায় একটা acquire-load।
4আপনি একটা multi-tenant PaaS চালান যেখানে গ্রাহকদের কন্টেইনার একই kernel শেয়ার করে। একজন গ্রাহক তার ডেটাবেস workload দ্রুত করতে io_uring চালু করার অনুরোধ করেছেন। আপনার সিদ্ধান্ত কী হবে এবং কেন? অন্তত তিনটা বিকল্প বিবেচনা করুন, প্রতিটার trade-off সহ।
ডিজাইন
এই প্রশ্নের সঠিক উত্তর কোনো একক “হ্যাঁ” বা “না” নয় — বরং threat model স্পষ্ট করা এবং তার ভিত্তিতে বিকল্প ওজন করা।
Threat model প্রথমে: multi-tenant PaaS-এ সব গ্রাহকের কোড untrusted, এবং তারা একই kernel শেয়ার করছে। একটা kernel privilege escalation মানে container escape — অর্থাৎ এক গ্রাহক অন্য সবার ডেটা পড়তে পারে। এটাই সেই ঠিক পরিস্থিতি যার জন্য Google ChromeOS, Android আর GKE AutoPilot-এ io_uring বন্ধ করেছে (realworld সেকশন)।
| বিকল্প | কীভাবে | সুবিধা | Trade-off |
|---|---|---|---|
| ১. সম্পূর্ণ প্রত্যাখ্যান | kernel.io_uring_disabled=2, বা seccomp-এ তিনটা syscall ব্লক | সবচেয়ে নিরাপদ; একটা সম্পূর্ণ attack surface বাদ | গ্রাহক অসন্তুষ্ট; প্রতিযোগী হয়তো দিচ্ছে; কিছু আধুনিক সফটওয়্যার (PostgreSQL 18) সুবিধাটা হারায় |
| ২. শক্ত বিচ্ছিন্নতাসহ অনুমোদন | সেই গ্রাহককে ডেডিকেটেড node-এ সরান, অথবা Firecracker/Kata-র মতো microVM-এ চালান যেখানে kernel আলাদা | গ্রাহক পূর্ণ পারফরম্যান্স পান; একটা exploit শুধু তার নিজের VM-এ সীমাবদ্ধ | খরচ বেশি (ডেডিকেটেড হার্ডওয়্যার/VM); অপারেশনাল জটিলতা; density কমে |
| ৩. সীমিত অনুমোদন | kernel.io_uring_disabled=1 + kernel.io_uring_group, শুধু অনুমোদিত গ্রাহকের জন্য | সূক্ষ্ম নিয়ন্ত্রণ; ঝুঁকি সীমাবদ্ধ পরিসরে | সেই গ্রাহকের কোড এখনো untrusted — সে exploit চালাতে পারলে ফলাফল বিকল্প ১-এর সব সুরক্ষা বাতিল; নিরাপত্তার দৃষ্টিতে এটা প্রায় “হ্যাঁ” বলারই সমান |
| ৪. শর্তসাপেক্ষ, সময়সীমাসহ | সর্বশেষ stable kernel-এ (নিরাপত্তা-প্যাচসহ) অনুমোদন, ত্রৈমাসিক পুনর্বিবেচনা, io_uring CVE-র জন্য নিবিড় monitoring | ব্যবসায়িক চাহিদা মেটে; ঝুঁকি স্বীকৃত ও পরিচালিত | monitoring-এর অপারেশনাল বোঝা; নতুন 0-day-তে এখনো উন্মুক্ত |
আমার সুপারিশ, যুক্তিসহ: প্রথমে বিকল্প ১ ডিফল্ট হিসেবে, আর সেই গ্রাহকের জন্য বিকল্প ২ একটা প্রিমিয়াম অফার হিসেবে — কারণ এটাই একমাত্র বিকল্প যেখানে “গ্রাহক পারফরম্যান্স পাচ্ছেন” আর “অন্য গ্রাহকরা নিরাপদ” দুটোই একসাথে সত্য, কোনো একটার সাথে আপস না করে। microVM-এর অতিরিক্ত খরচটা গ্রাহকের কাছ থেকে নেওয়া ন্যায্য, কারণ খরচটা তার নির্দিষ্ট চাহিদা থেকেই এসেছে।
প্রথমে যা করবেন — মাপা: সিদ্ধান্তের আগে গ্রাহকের workload-এ io_uring আসলে কতটা সাহায্য করবে তা মাপুন। যদি strace -c-এ তার প্রোফাইলে syscall-এর সময় ৫%-এর কম হয়, তবে পুরো আলোচনাটাই অপ্রয়োজনীয় — এই লেসনের প্রথম misconception-এর সরাসরি প্রয়োগ, আর একটা নিরাপত্তা-বিতর্ক সম্পূর্ণ এড়ানোর সবচেয়ে সস্তা উপায়।
এই ধরনের isolation বনাম density-র trade-off Level 12-এর virtualization ও cloud module-এর কেন্দ্রীয় বিষয় (কন্টেইনার বনাম microVM বনাম পূর্ণ VM), আর threat modeling-এর কাঠামোগত পদ্ধতি Level 10-এর security module-এ আসবে।
5আপনার io_uring প্রোগ্রামে ৬৪ entry-র ring, আর আপনি লুপে ১০০টা SQE জমা দেওয়ার চেষ্টা করছেন io_uring_submit না ডেকে। ৬৫তম কলে io_uring_get_sqe কী ফেরত দেবে, আর সঠিক ব্যবস্থাপনা কী?
প্রয়োগ
io_uring_submit না ডেকে। ৬৫তম কলে io_uring_get_sqe কী ফেরত দেবে, আর সঠিক ব্যবস্থাপনা কী?io_uring_get_sqe NULL ফেরত দেবে — SQ ring পূর্ণ।
কেন — ring-এর গণিত: hood সেকশনের protocol অনুযায়ী ring পূর্ণ যখন:
Head শুধু তখনই বাড়ে যখন kernel SQE-গুলো গ্রহণ করে — আর kernel সেগুলো দেখেই না যতক্ষণ না আপনি io_uring_submit (অর্থাৎ io_uring_enter) ডাকছেন, বা SQPOLL মোডে poll thread tail-এর পরিবর্তন লক্ষ করছে। submit না ডাকলে head স্থির ০-তে থাকে, tail ৬৪-এ পৌঁছেই থেমে যায়।
ভুল ব্যবস্থাপনা (এবং কেন প্রতিটা খারাপ):
| ভুল | পরিণতি |
|---|---|
sqe-র NULL চেক না করা | সাথে সাথে NULL pointer dereference — segfault |
| ব্যস্ত-লুপে আবার চেষ্টা | চিরস্থায়ী লুপ, কারণ submit না করলে head কখনো বাড়বে না |
| Ring size বাড়িয়ে দেওয়া (৪০৯৬) | সমস্যাটা স্থগিত হলো মাত্র; আর বড় ring মানে বেশি pinned kernel memory |
সঠিক প্যাটার্ন — NULL দেখলে submit করে জায়গা তৈরি করুন:
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
if (!sqe) {
int n = io_uring_submit(&ring); /* জমা দাও -- kernel head বাড়াবে */
if (n < 0) { /* ত্রুটি সামলান */ }
sqe = io_uring_get_sqe(&ring); /* এখন পাওয়া যাবে */
if (!sqe) { /* CQ পূর্ণ -- কিছু completion reap করুন */ }
}দ্বিতীয়, কম-পরিচিত ফাঁদ — CQ overflow। CQ ring সাধারণত SQ-র দ্বিগুণ (৬৪ SQ ⇒ ১২৮ CQ)। যদি আপনি জমা দিতেই থাকেন কিন্তু CQE reap না করেন, CQ ভরে যাবে। পুরনো kernel-এ (5.5-এর আগে) completion নীরবে হারিয়ে যেত। আধুনিক kernel-এ সেগুলো একটা overflow তালিকায় জমা হয় আর cq_ring.flags-এ IORING_SQ_CQ_OVERFLOW সেট হয় — কিন্তু তখন প্রতিটা io_uring_enter-এ অতিরিক্ত কাজ হয়, পারফরম্যান্স পড়ে যায়। অর্থাৎ submit আর reap-এর মধ্যে ভারসাম্য রাখা প্রোগ্রামারের দায়িত্ব, ring নিজে সেটা প্রয়োগ করে না।
এই “producer-এর গতি consumer-এর চেয়ে বেশি হলে কী করব” প্রশ্নটাই backpressure-এর মূল প্রশ্ন — লেসন ২৩-এ pipe-এর ৬৪ KB buffer পূর্ণ হলে writer কেন block করে সেই আলোচনায় আমরা ঠিক এই একই সমস্যার একটা ভিন্ন সমাধান দেখব, আর Level 7-এর networking module-এ TCP-র flow control window হিসেবে তৃতীয় একটা রূপ।
এরপর কী
পরের লেসন — Interrupts এবং Device Drivers
এই পুরো লেসনে একটা প্রশ্ন আমরা ইচ্ছাকৃতভাবে এড়িয়ে গেছি। io_uring-এ একটা read জমা দিলেন, disk থেকে ডেটা আনতে হবে, আর কয়েক মিলিসেকেন্ড পরে সেই ডেটা এসে পৌঁছাল — kernel কীভাবে জানল যে ডেটা এসেছে? কেউ কি লুপে বসে disk controller-কে জিজ্ঞাসা করছিল?
উত্তর: না। Disk controller নিজে থেকে CPU-কে একটা interrupt পাঠিয়েছে — একটা হার্ডওয়্যার সংকেত যা CPU-কে তার চলমান কাজ মাঝপথে থামিয়ে একটা নির্দিষ্ট handler চালাতে বাধ্য করে। সেই handler CQE-টা ring-এ লিখেছে, তারপর CPU যা করছিল তাতে ফিরে গেছে — আর আপনার প্রোগ্রাম পরের বার CQ পড়তে গিয়ে ফলাফলটা পেয়েছে।
পরের লেসনে আমরা এই পুরো পথটা খুলে দেখব: device থেকে interrupt controller (APIC/GIC), সেখান থেকে IDT lookup, handler-এ প্রবেশ, আর সেই কুখ্যাত top half / bottom half বিভাজন — কেন interrupt handler-এর প্রথম অংশটাকে অবিশ্বাস্য রকম দ্রুত হতে হয় এবং কেন সেখানে ঘুমানো সম্পূর্ণ নিষিদ্ধ। দেখব NAPI — নেটওয়ার্ক ড্রাইভারের সেই মার্জিত কৌশল যা উঁচু লোডে interrupt বন্ধ করে polling-এ সুইচ করে (এই লেসনের SQPOLL-এর সাথে একটা চমৎকার সমান্তরাল)। আর শেষে নিজের হাতে একটা kernel module লিখব — একটা সত্যিকারের character device driver, insmod করে dmesg-এ তার বার্তা দেখব। (হ্যাঁ, একটা VM-এ। হ্যাঁ, সেটা বাধ্যতামূলক।)
আরও পড়ুন
- Efficient IO with io_uring — Jens Axboe · io_uring-এর মূল স্রষ্টার নিজের লেখা ডিজাইন-ডকুমেন্ট — ring layout, memory ordering আর SQPOLL-এর প্রামাণ্য বর্ণনা এখান থেকেই
- io_uring_setup(2), io_uring_enter(2), io_uring_register(2) — Linux man pages · মাত্র তিনটা syscall-এই পুরো interface — এই তিনটা man page পড়লে liburing-এর ভেতরে কী হচ্ছে সম্পূর্ণ পরিষ্কার হয়
- Learnings from kCTF VRP's 42 Linux kernel exploits submissions — Google Security Blog · যে পোস্টে Google জানায় জমা পড়া exploit-এর ~৬০% io_uring লক্ষ্য করেছিল, আর তারা ChromeOS/Android/GKE-তে এটা বন্ধ করে দিয়েছে
- liburing — the io_uring userspace library — Jens Axboe · এই লেসনের build সেকশনের কোড এই লাইব্রেরির উপর নির্ভরশীল; man/ ডিরেক্টরিতে প্রতিটা helper-এর man page আছে