3.5
I/O Model: Blocking, Non-Blocking, epoll ও io_uring
একটা thread কীভাবে দশ হাজার socket নজরে রাখে: select আর poll থেকে epoll ও io_uring পর্যন্ত।
45 মিনিট পড়া + Animation
টিপস: ডটেড-আন্ডারলাইন করা যেকোনো শব্দে ট্যাপ বা Hover করলে তার অর্থ দেখবে।
এই Lesson শেষে তুমি পারবে
- যেকোনো Read-কে তার দুই ধাপে ভাগ করতে (Data-র অপেক্ষা, তারপর Copy), আর কোন ধাপে কে অপেক্ষা করে সেই হিসাবে পাঁচটা I/O Model বোঝাতে।
- প্রতি Connection-এ একটা Thread দিলে আসলে কত খরচ, তা হিসাব করতে, আর C10K সমস্যার সমাধানে Server কেন এই পথ ছেড়েছে তা বলতে।
- Non-blocking Socket ঠিকভাবে ব্যবহার করতে:
EAGAIN, ছোট Read, আধা Write, EOF আর Backpressure। - আসল খরচ ধরে
select,poll,epollআরkqueueমেলাতে, আর চুপচাপ Connection বাড়লেওepoll_waitকেন ধীর হয় না তা বোঝাতে। - একটা কাজ করা
epollServer পড়তে ও লিখতে, আর আটকে যাওয়া বা Busy Loop না বানিয়ে Level-Triggered বা Edge-Triggered বেছে নিতে। EPOLLEXCLUSIVEবাSO_REUSEPORTদিয়ে Worker-দের মধ্যে Port ভাগ করতে, আর কোনটা কোথায় ব্যর্থ হয় তা জানতে।Too many open files,EMFILE-এর ঘূর্ণি আর Descriptor Leak ধরতে।- Regular File কেন সবসময় “Ready”, আর Disk Read যাতে Event Loop থামাতে না পারে সেজন্য Server কী করে, তা বোঝাতে।
- Readiness আর Completion আলাদা করতে,
io_uringকীভাবে কাজ করে বলতে, আর কখন ব্যবহার করবে বা করবে না তা ঠিক করতে।
#এই Lesson কোথায় বসে
Lesson ১.২-এ Server-এর Concurrency Model-গুলো (Process, Thread, Event Loop) দেখেছি, C10K-এর গল্পও শুনেছি। Lesson ১.১৩-এ দেখেছি Nginx-এর Worker আর Apache-র MPM। Lesson ২.৬-এ হিসাব করেছি একটা Socket-এ কত Memory লাগে। সবখানেই একটা কথা বলে আমরা এগিয়ে গেছি: “কোন Socket Ready, Kernel সেটা Loop-কে জানায়।”
এই Lesson-এ ওই একটা বাক্যের ভেতরটা খুলব। এর ভিত হলো Lesson ৩.১ (Thread, System Call, File Descriptor), Lesson ৩.২ (Switching-এর দাম) আর Lesson ৩.৩ (Pool)। এই System Call-গুলোর ওপরে যে Loop বসে, তার Phase আর Callback নিয়ে Lesson ৩.৬। আমরা এখানে থাকব তোমার Program আর Kernel-এর সীমানায়।
#I/O কী, আর Model নিয়ে এত কথা কেন
I/O মানে Input আর Output: Program যখনই CPU আর Memory-র বাইরের কিছুর সাথে Data আদান-প্রদান করে, সেটাই I/O।
| উদাহরণ | তোমার Thread আসলে কীসের অপেক্ষায় | সাধারণ অপেক্ষা |
|---|---|---|
| Socket থেকে পড়া | আরেকটা Machine কখন কিছু পাঠাবে | Microsecond থেকে অনন্তকাল |
| আরেকটা Service-এ Network Request | Network আর ওই Service | Data Center-এর ভেতরে ১ Millisecond-এর মতো, পৃথিবীর ওপারে ১০০ ms বা বেশি |
| Database Read | একটা Network Round Trip, সাথে Database-এর নিজের Disk আর Lock | ১ থেকে কয়েকশো Millisecond |
| File পড়া | Disk, যদি না Data আগে থেকেই Memory-তে থাকে | Microsecond (Cache-এ) থেকে Millisecond |
| Disk-এ লেখা | সাধারণত শুধু Memory-তে Copy; জোর করলে তবেই Disk | Microsecond, fsync দিলে Millisecond |
আজকের একটা Core সেকেন্ডে শত শত কোটি Instruction চালায়, তাই ১ Millisecond অপেক্ষা মানে লাখ লাখ Instruction-এর সময় চলে গেল। একটা সাধারণ Web Request-এর জীবনের প্রায় পুরোটাই কাটে অপেক্ষায়। তাই বড় আকারে Performance তোমার Code কত দ্রুত চলে তার চেয়েও বেশি নির্ভর করে ওই অপেক্ষার সময় তোমার Thread কী করছে তার ওপর। এটাই ঠিক করে I/O Model।
প্রতিটা Read-এর দুটো ধাপ, আর এই Lesson-এর সবকিছু এখান থেকেই আসে:
১. অপেক্ষা: Data Kernel-এ পৌঁছানো পর্যন্ত (Packet এল, Disk উত্তর দিল)। ২. Copy: Kernel-এর Buffer থেকে তোমার Program-এর Memory-তে।
#প্রতি Connection-এ Thread কেন একসময় আর চলে না
সবচেয়ে সহজ Server প্রতিটা Connection-কে একটা Thread দেয়, যে Block হয়ে থাকে (Lesson ১.২-এ পুরোটা দেখেছ)। ইতিহাসটাও ওখানেই আছে: ১৯৯৯ সালে Dan Kegel-এর C10K Page প্রশ্ন তোলে, একসাথে দশ হাজার Client কীভাবে সামলাব? এখন যেটা ভালো করে দেখা দরকার, Thread কেন ফুরিয়ে যায়, কারণ চালু উত্তরটা (“প্রতিটায় ৮ MB লাগে”) ভুল।
- Address Space মানে Memory না। glibc প্রতি Thread-এর জন্য ৮ MB Stack সংরক্ষণ করে (
ulimit -s), JVM প্রায় ১ MB, কিন্তু আসল Memory লাগে শুধু যে Page-গুলো Thread সত্যিই ছোঁয় (Lesson ৩.৪)। ৬৪-bit Machine-এ ৫০,০০০ × ৮ MB = ৪০০ GB সংরক্ষণে কোনো ক্ষতি নেই। - তবু আসল Memory কম না। প্রতি Thread-এ কিছুটা ছোঁয়া Stack, সাথে একটা Kernel Stack (x86-64-এ ১৬ KB) আর হিসাবপত্র। মোটামুটি ৮০ KB ধরলে ৫০,০০০ Thread কিছু না করেই ধরে রাখে প্রায় ৪ GB।
- সীমা আগেই চলে আসে। প্রতিটা Stack মানে একটা Memory Mapping আর একটা Guard Page, আর
vm.max_map_countপ্রায়ই ৬৫,৫৩০-এর মতো, তাই থামতে হয় প্রায় ৩২,০০০ Thread-এ।kernel.threads-max,ulimit -uআর Container-এরpids.maxআরও আগেও থামাতে পারে। - Burst-এ Scheduling ভোগায়। ঘুমন্ত Thread-এ Scheduler-এর প্রায় কোনো খরচ নেই। ক্ষতি হয় যখন হাজার হাজার Thread একসাথে জেগে অল্প কটা Core নিয়ে কাড়াকাড়ি করে: প্রতিটা Switch-এ Microsecond তো যায়ই, সাথে ঠান্ডা Cache আর TLB Miss (Lesson ৩.২)।
- নির্দিষ্ট আকারের Pool মানে শক্ত ছাদ। ২০০ Thread-এর Pool-এ ২০০টা ধীর Client পুরো Service থামিয়ে দেয়, অথচ CPU তখন অলস।
একই ৫০,০০০ Connection ধরতে একটা Event Loop-এর লাগে হাতে গোনা কয়েকটা Thread, আর প্রতি Connection-এ হয়তো ১০ KB State। Kernel-এর Socket Buffer-এর খরচ দুই Design-এই সমান, তাই পার্থক্যটা Thread-এ, Socket-এ না। WhatsApp ২০১২ সালে জানিয়েছিল, একটা FreeBSD Server-এ তারা প্রায় ২০ লাখ Connection ধরেছে।
প্রতি Connection-এ একটা Thread: সংখ্যা বাড়লে দাম কত
একই Server, ২০০ আর ৫০,০০০ Connection-এ, তারপর ধীর Client নিয়ে, তারপর একটা Event Loop দিয়ে। Map-এর নিচের Table-টা খেয়াল করো।
পরিস্থিতি
এই আকারে Blocking Thread ভালো Design: সহজ Code, খরচও অল্প।
২০০ Thread-এ খরচ
| জিনিস | পরিমাণ |
|---|---|
| Stack-এর আসল Memory | প্রায় ১৬ MB |
| অপেক্ষায় ঘুমন্ত Thread | বেশিরভাগ |
দুইশো Client যুক্ত হলো, প্রত্যেকে একটা করে Thread পেল। Code ওপর থেকে নিচে পড়া যায়: Request পড়ো, Database জিজ্ঞেস করো, Response লেখো।
#Blocking I/O
Blocking I/O-তে Thread একটা কাজ শুরু করলে কাজটা শেষ হওয়ার মতো অবস্থা না হওয়া পর্যন্ত থেমে থাকে। সব Socket-এর Default এটাই।
data = sock.recv(1024) # waits here until at least one byte exists
process(data)
Timeline-এ দেখলে Thread-এর জীবনের বেশিরভাগই অপেক্ষা:
Thread 1:
|------------- I/O waiting -------------| processing |
Data না এলে recv() অপেক্ষা করে। Thread তখন আর কিছু করতে পারে না, তাই একই সময়ে দ্বিতীয় Client সামলাতে আরেকটা Execution Unit লাগে: একটা Thread বা একটা Process।
সহজ ছবিটায় তিনটা কথা ঢাকা পড়ে যায়:
- Blocked Thread কোনো CPU খায় না। Kernel তাকে Run Queue থেকে সরিয়ে Socket-এর Wait Queue-তে রাখে। তার হাতে থাকে Memory আর Pool-এর একটা জায়গা, Core না।
recv(1024)মানে “সর্বোচ্চ ১০২৪ Byte”। কিছু থাকলেই ফিরে আসে। TCP-তে Message-এর কোনো সীমারেখা নেই, তাই পুরো Message না আসা পর্যন্ত জমাতে আর পড়তে হয়। আর ০ ফেরত এলে বুঝবে ওপাশ বন্ধ করেছে, এটাই EOF, Error না।- Write-ও Block হয়।
send()Data Copy করে Socket-এর Send Buffer-এ। ধীর পাঠকের কারণে সেই Buffer ভরে গেলে Blockingsend()ঘুমিয়ে পড়ে। তাইSO_RCVTIMEOআরSO_SNDTIMEOবা তার সমতুল্য কিছু দাও, যাতে থেমে যাওয়া ওপাশ কোনো Thread-কে চিরকাল আটকে রাখতে না পারে।
Disk Write-এর ব্যাপারটা একটু শুধরে নেওয়া দরকার: File-এ সাধারণ write() Page Cache-এ Copy করেই ফিরে আসে, Disk-এর অপেক্ষা করে না। অপেক্ষা আসে fsync, O_SYNC-এ, অথবা অনেক বেশি Dirty Data জমে গেলে (Lesson ৩.৪)।
| Blocking I/O | |
|---|---|
| সুবিধা | Code সহজ আর ধারাবাহিক, Logic সহজে বোঝা যায়, Stack Trace-এর মানে থাকে |
| অসুবিধা | অনেক Thread লাগে, Thread-এর Resource খরচ, Context Switching বাড়ে, Blocked Thread মানে হারানো Capacity |
| কোথায় ভালো | সাধারণ Application, কম Concurrency-র Server, Background Job-এর মতো Worker-ভিত্তিক কাজ |
Blocking I/O: ঘুমন্ত Thread আসলে কী করে
একটা recv()-এর পেছন পেছন Kernel-এ চলো: অপেক্ষা, জেগে ওঠা আর Copy, তারপর ছোট Read আর ভর্তি Buffer, যেখানে নতুনরা আটকে যায়।
পরিস্থিতি
কিছু না এলে সাধারণ Blocking Read এভাবে চলে।
System Call-এ Thread User Mode থেকে Kernel Mode-এ যায় (Lesson ৩.১)। Kernel এই Socket-এর Receive Buffer দেখে, খালি।
#Non-blocking I/O
O_NONBLOCK Flag দিলে Kernel আর Thread-কে Data-র জন্য বসিয়ে রাখে না। কাজ এগোনোর উপায় না থাকলে Call সাথে সাথে ফিরে আসে।
result = sock.recv(1024) # with sock.setblocking(False)
Thread:
| check I/O | not ready | do other work | check I/O | ...
Data তৈরি না থাকলে System Call ফেরত দেয় -1, আর errno-তে বসায় EAGAIN বা EWOULDBLOCK (Linux-এ দুটোর মান একই, তবে অন্য OS-এও চালাতে চাইলে দুটোই দেখো)। কোনোটাই ব্যর্থতা না, মানে “এখন কিছু নেই, পরে এসো”। Python দেয় BlockingIOError, Go আর Node.js তোমার থেকে এটা লুকিয়ে রাখে। Application তখন অন্য কাজ করে, পরে আবার Readiness দেখে।
Blocking Code যা বিনা খরচে পেত, Non-blocking Code-কে সেগুলো নিজে সামলাতে হয়:
- আধা Write। ১ MB-র
send()হয়তো ২৫৬ KB নিয়ে সেই সংখ্যাটা ফেরত দেয়। বাকিটা রেখে দিতে হবে, Socket-এ জায়গা হলে পাঠাতে হবে। - State। Thread যখন চলে যায়, Connection হয়তো Request-এর মাঝপথে। সেই অবস্থান Stack-এ থাকে না, রাখতে হয় একটা আলাদা Object-এ।
- Backpressure। জমে থাকা Data তোমার Memory-তেই থাকে, তাই প্রতি Connection-এ একটা সীমা রাখো, অথবা যে Client তোমার Response পড়ছে না তার কাছ থেকে পড়া থামাও।
| Non-blocking I/O | |
|---|---|
| সুবিধা | অল্প Thread-এ অনেক Connection, Blocking কম, High-concurrency Server-এর উপযোগী |
| অসুবিধা | Programming জটিল, State সামলানো কঠিন, Error Handling ঝামেলার, আর ভুলভাবে Poll করলে CPU Busy Loop |
একা Non-blocking I/O তেমন কাজের না, কারণ কখন আবার চেষ্টা করবে তা তুমি জানো না। সাথে সাথে চেষ্টা করলে একটা Core পুড়ে যায় (Busy Loop), আর মাঝে Sleep দিলে Latency বাড়ে। এটা কাজের হয় তখনই, যখন পাশে এমন কেউ থাকে যে বলে দেয় কখন চেষ্টা করতে হবে।
Non-blocking I/O: EAGAIN, আর এর ভুল ব্যবহার
O_NONBLOCK দিলে Kernel তোমার Thread-কে Data-র জন্য আর বসিয়ে রাখে না। পরিষ্কার একটা Read, চিরচেনা Busy Loop আর আধা Write ধাপে ধাপে দেখো।
পরিস্থিতি
খালি Socket না ঘুমিয়ে সাথে সাথে উত্তর দেয়।
Socket-এ O_NONBLOCK দেওয়া আছে। Buffer খালি, কিন্তু এবার Kernel Thread-কে ঘুম পাড়াবে না।
#পুরো মানচিত্র: পাঁচটা I/O Model
W. Richard Stevens-এর UNIX Network Programming বইয়ে পাঁচটা Model-এর নাম আছে, Interview-তে এখনো এই তালিকাই চলে। পার্থক্য হলো প্রতিটা ধাপে তোমার Thread কী করে:
| Model | ধাপ ১: Data-র অপেক্ষা | ধাপ ২: Copy | Linux-এর উদাহরণ |
|---|---|---|---|
| Blocking | Thread Blocked | Thread Blocked | Default read, recv |
| Non-blocking | Thread বারবার জিজ্ঞেস করে (EAGAIN) | Thread Blocked | O_NONBLOCK |
| I/O Multiplexing | Thread Blocked, তবে এক Call-এ অনেক Socket | Thread Blocked | select, poll, epoll |
| Signal-driven | Thread স্বাধীন, Signal এসে জানায় | Thread Blocked | O_ASYNC, SIGIO (কম ব্যবহার হয়) |
| Asynchronous | Thread স্বাধীন | Thread স্বাধীন | io_uring; POSIX AIO |
দুটো কথা পরিষ্কার করে নিই:
epollআলাদা কোনো Model না। এটা Multiplexing-এর একটা বাস্তবায়ন। যে তালিকায় “Blocking, Non-blocking, Multiplexing, Async, epoll” একসাথে লেখা থাকে, সেখানে দুই স্তরের জিনিস মিশে গেছে।- POSIX-এর সংজ্ঞায় শুধু শেষেরটাই Asynchronous। প্রথম চারটা Copy-র সময় Block হয়, তাই কড়া অর্থে সবই Synchronous I/O। ওই Copy সাধারণত দ্রুত, তাই মুখের কথায় Node.js Server-কে সবাই “Async” বলে, যদিও নিচে
epollচলছে। তোমার Language-এর Non-blocking API আর Kernel-এর Asynchronous I/O-র পার্থক্য নিয়ে আরও কথা আছে Lesson ৩.৬-এ।
POSIX AIO নিয়ে একটা কথা: aio_read পরিবার আছে ঠিকই, কিন্তু glibc এটা চালায় User Space-এর একটা Thread Pool দিয়ে। Linux-এ পুরনো একটা নিজস্ব Interface-ও আছে (io_submit, ব্যবহার হয় libaio দিয়ে), যা আসলে কাজে লাগে শুধু O_DIRECT File-এ, আর Metadata-তে তবু Block হতে পারে। Socket-এর জগতে এদের কোনোটাই জায়গা পায়নি। Linux-এ সত্যিকারের Asynchronous I/O মানে io_uring।
এক Timeline-এ পাঁচটা I/O Model
প্রতিটা Read-এর দুটো ধাপ: Kernel Data-র জন্য অপেক্ষা করে, তারপর তোমার Buffer-এ Copy করে। দুই ধাপে তোমার Thread কী করছে, মিলিয়ে দেখো।
পরিস্থিতি
দুই ধাপেই Thread Blocked।
Thread recv() করে ঘুমাচ্ছে। আর কাউকে সেবা দিতে পারে না, এজন্যই এই Model-এ প্রতি Connection-এ একটা Thread লাগে।
#I/O Multiplexing: select, poll, epoll, kqueue
I/O Multiplexing মানে একটা Thread বা Process অনেক Connection-এর Readiness-এ নজর রাখে। ধরো তোমার Server-এ ১০,০০০ Socket Connection: প্রতিটায় আলাদা Thread না দিয়ে একটাই ব্যবস্থা সবগুলো দেখে।
| ব্যবস্থা | Platform | ধরন | প্রতি Wait-এ খরচ | সীমা |
|---|---|---|---|---|
select() | সব জায়গায় | Readiness | সবচেয়ে বড় Descriptor নম্বর ধরে | FD_SETSIZE, সাধারণত ১০২৪ |
poll() | Unix-ধাঁচের | Readiness | নজরে থাকা সংখ্যা ধরে | নিজের কোনো সীমা নেই |
epoll | Linux | Readiness | Ready সংখ্যা ধরে | fs.epoll.max_user_watches |
kqueue | FreeBSD, macOS | Readiness | Ready সংখ্যা ধরে | Kernel Memory |
| IOCP | Windows | Completion | শেষ হওয়া কাজের সংখ্যা ধরে | Kernel Memory |
#select()
সবচেয়ে পুরনো, ১৯৮০-র দশকের 4.2BSD থেকে। তুমি Descriptor নম্বরের Bitmap দাও, সে বলে কোনগুলো Ready। সীমাবদ্ধতা অনেক:
FD_SETSIZE, সাধারণত ১০২৪, Descriptor-এর নম্বরকেই সীমিত করে, শুধু কতগুলো দেখছ তা না। ১৫০০ নম্বর Descriptor একা হলেও চলবে না।FD_SETদিয়ে লিখলে Bitmap উপচে পড়ে, আর এটা Production-এর সত্যিকারের Bug:ulimit -nবাড়ালে, ১,০২৪ Connection পেরোলে, আর ভুলে যাওয়া একটা Libraryselect()ডাকলে Memory নষ্ট হতে থাকে বা Program Abort করে। (Windows আলাদা: সেখানেselectসংখ্যা সীমিত করে, Default ৬৪।)- প্রতি Call-এ পুরো Set Copy আর Scan হয়, প্রথমে Kernel, পরে তুমি, সক্রিয় Socket যত অল্পই হোক।
- তোমার Bitmap-এর ওপরই লিখে দেয়, তাই প্রতি Call-এর আগে আবার বানাতে হয়।
#poll()
poll()-ও একইভাবে Readiness দেখে, তবে নেয় struct pollfd-এর Array, প্রতিটা ৮ Byte। Set-এর কোনো নির্দিষ্ট আকার নেই, আর Input-Output আলাদা Field, তাই আবার বানাতে হয় না। কিন্তু Array Copy করে পুরোটা ঘোরে ঠিকই: ৫০,০০০ Socket মানে প্রতি Call-এ ৪০০ KB Copy আর ৫০,০০০ যাচাই, হয়তো তিনটা সক্রিয় Socket খুঁজতে।
#epoll
epoll হলো Linux-এর Event Notification ব্যবস্থা, ২০০২-এর দিকে 2.5/2.6 Series-এ এসেছে। এটা Scan-টাই তুলে দেয়, কাজটাকে দুই ভাগ করে: একবার Register, তারপর বারবার Wait। তালিকা রাখে Kernel, তাই প্রতি Wait-এ কিছু Copy হয় না, আর Ready List তৈরি হয় Event ঘটার মুহূর্তেই। এর দুটো Mode, Level-Triggered আর Edge-Triggered, নিচে দেখব।
#kqueue আর IOCP
kqueue (২০০০ সালে FreeBSD 4.1, macOS-এও আছে) একই কাজ করে, অনেকের মতে আরও পরিষ্কার Design-এ। একটাই Call, kevent(), বদলও Register করে, অপেক্ষাও করে, তাই System Call কম লাগে। আর এটা শুধু Socket না, File-এর বদল, Signal, Timer, Process-এর শেষ হওয়াও দেখে।
Windows-এর IOCP প্রায়ই এই তালিকায় থাকে, কিন্তু এটা Readiness-এর ব্যবস্থা না। তুমি একটা Buffer দিয়ে কাজ শুরু করো (WSARecv), আর শেষ হওয়া ফল পরে তুলে নাও (GetQueuedCompletionStatus)। এটা Completion-ভিত্তিক, সত্যিকারের একটা পার্থক্য, নিচে আবার আসব।
select, poll আর epoll: কে কী Scan করে
প্রতিটা বিন্দু অনেকগুলো Socket। তিনটায় Data এসেছে। ওই তিনটা খুঁজতে কোন Call কত কাজ করে, মিলিয়ে দেখো।
পরিস্থিতি
১০২৪ Bit-এর নির্দিষ্ট Bitmap, প্রতি Call-এ Copy আর Scan।
পুরো Bitmap, প্রতি Call-এ
১০২৩ (FD_SETSIZE)
তুমি Descriptor নম্বরের তিনটা Bitmap (Read, Write, Error) বানিয়ে দাও। এতে ১০২৪ Bit ধরে, তাই এখানে সর্বোচ্চ ১০২৪ Socket, আর প্রতিটা বিন্দু প্রায় ১১টা।
নিজেকে যাচাই করোপ্রশ্ন
একটা Service ৪০,০০০ প্রায়-চুপচাপ WebSocket Connection ধরে রাখে আর poll() ব্যবহার করে। Traffic কম থাকলেও System Time-এ অনেক CPU যায়। epoll-এ গেলে কেন লাভ হবে?
#epoll বিস্তারিত
ধরো একটা Web Server-এ ৫০,০০০ Client Connection। প্রতি Connection-এ Thread দিলে ওপরে যে Memory আর Scheduling-এর খরচ দেখলাম, সেটা আসবে। epoll দিয়ে একটা Event Loop-ই সবগুলো দেখতে পারে।
#তিনটা Call, দুটো List
| Call | কী করে |
|---|---|
epoll_create1(flags) | Kernel-এ একটা epoll Instance বানায়, তার Descriptor ফেরত দেয় |
epoll_ctl(epfd, op, fd, &event) | Descriptor যোগ, বদল বা সরানো: EPOLL_CTL_ADD, EPOLL_CTL_MOD, EPOLL_CTL_DEL |
epoll_wait(epfd, events, max, timeout) | কিছু Ready হওয়া পর্যন্ত অপেক্ষা করে, Ready Entry-গুলো ফেরত দেয় |
Kernel-এর ভেতরে Instance-টার দুটো জিনিস থাকে:
- Interest List, যা কিছু তুমি দেখছ তার একটা Red-Black Tree। বদলাতে O(log n), আর ৬৪-bit-এ প্রতি Entry-তে প্রায় ১৬০ Byte Kernel Memory (
epoll-এর Man Page-এর হিসাব)। - Ready List, এই মুহূর্তে যা Ready তার একটা Linked List।
কেউ Scan না করলে Socket Ready List-এ ওঠে কীভাবে? Socket Register করার সময় Kernel তার Wait Queue-তে একটা ছোট Callback জুড়ে দেয়। Packet এসে TCP-র Code ওই Queue জাগালে Callback চলে, আর Socket-টাকে Ready List-এ তুলে দেয়। কাজটা হয় একবারই, Event-এর মুহূর্তে, আর epoll_wait-কে শুধু List-টা দেখতে হয়। এজন্যই চুপচাপ Socket-এ কোনো খরচ নেই।
Skeleton-টা এই:
int epfd = epoll_create1(0);
struct epoll_event event;
event.events = EPOLLIN;
event.data.fd = server_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, server_fd, &event);
while (1) {
struct epoll_event events[100];
int n = epoll_wait(epfd, events, 100, -1);
for (int i = 0; i < n; i++) {
// handle the ready socket
}
}
এটা শুধু ধারণা বোঝানোর কাঠামো। Production Server-কে আরও করতে হয়: Socket Non-blocking করা, Accept Loop, আধা Read-Write সামলানো, Error Event, Connection-এর পুরো জীবনচক্র, আর Resource পরিষ্কার করা।
epoll API, এক এক Call করে
তিনটা System Call, Kernel-এ দুটো List। একটা Server বসাও, একঝাঁক Client Accept করো, আর Client চলে গেলে কী হয় দেখো।
পরিস্থিতি
create, ctl, wait: পুরো API এটুকুই।
Kernel-এ একটা epoll Instance তৈরি হয়, ফেরত আসে তার Descriptor। ভেতরে দুটো List-ই আসল: যা কিছু তুমি নজরে রাখছ, আর এখন যা Ready।
#একটা পূর্ণ Server
Skeleton যা বাদ দিয়েছে, এই Echo Server সেগুলো করে। Linux-এ Build করো gcc -O2 -Wall -o echo echo.c দিয়ে, চালাও ./echo, আর যুক্ত হও nc 127.0.0.1 8080 দিয়ে:
// echo.c: an edge-triggered epoll echo server.
#define _GNU_SOURCE
#include <errno.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <unistd.h>
#define MAX_EVENTS 128
static void die(const char *what) { perror(what); exit(1); }
static void watch(int epfd, int fd, unsigned events) {
struct epoll_event ev = { .events = events, .data.fd = fd };
if (epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev) < 0) die("epoll_ctl");
}
int main(void) {
int lfd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0);
if (lfd < 0) die("socket");
int on = 1;
setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof on);
struct sockaddr_in addr = {
.sin_family = AF_INET, .sin_port = htons(8080), .sin_addr.s_addr = htonl(INADDR_ANY)
};
if (bind(lfd, (struct sockaddr *)&addr, sizeof addr) < 0) die("bind");
if (listen(lfd, 4096) < 0) die("listen");
int epfd = epoll_create1(EPOLL_CLOEXEC);
if (epfd < 0) die("epoll_create1");
watch(epfd, lfd, EPOLLIN); // level-triggered listener
struct epoll_event events[MAX_EVENTS];
char buf[4096];
for (;;) {
int n = epoll_wait(epfd, events, MAX_EVENTS, -1); // sleeps until something is ready
if (n < 0) {
if (errno == EINTR) continue; // a signal woke us: not an error
die("epoll_wait");
}
for (int i = 0; i < n; i++) {
int fd = events[i].data.fd;
if (fd == lfd) { // new connections: accept them all
for (;;) {
int c = accept4(lfd, NULL, NULL, SOCK_NONBLOCK | SOCK_CLOEXEC);
if (c < 0) {
if (errno != EAGAIN && errno != EWOULDBLOCK) perror("accept4");
break;
}
watch(epfd, c, EPOLLIN | EPOLLET); // clients are edge-triggered
}
continue;
}
for (;;) { // edge-triggered: drain until EAGAIN
ssize_t r = read(fd, buf, sizeof buf);
if (r > 0) {
// Simplified: a real server keeps what write() did not take and waits for EPOLLOUT.
if (write(fd, buf, r) < 0) { close(fd); break; }
} else if (r == 0) { // the peer closed: end of file
close(fd); // also removes fd from the epoll set
break;
} else if (errno == EAGAIN || errno == EWOULDBLOCK) {
break; // drained: wait for the next edge
} else if (errno != EINTR) {
close(fd); // a real error such as ECONNRESET
break;
}
}
}
}
}
কয়েকটা জিনিস খেয়াল করো: EINTR স্বাভাবিক, আবার চেষ্টা করলেই হয়। EPOLLHUP আর EPOLLERR চাইতে হয় না, এগুলো সবসময় জানানো হয় (তখন read-ই বলে দেয় কী হয়েছে)। ইচ্ছে করেই কিছু জিনিস সহজ রাখা হয়েছে: জমে থাকা Write-এর Buffer নেই, প্রতি Connection-এর Object নেই, Timeout নেই, আর EMFILE-এর কোনো উত্তর নেই, সেটায় একটু পরে আসছি। বাস্তবে তুমি এটা খুব কমই লিখবে। libuv, libevent, Tokio, Netty আর Go-র Runtime এটাকে মুড়ে রাখে। তবু বোঝা দরকার, কারণ তাদের Bug আর সীমা এর Bug আর সীমাই।
#Level-Triggered আর Edge-Triggered
epoll নিয়ে সবচেয়ে বেশি জিজ্ঞেস করা প্রশ্ন এটাই, আর আসল প্রশ্নটা হলো কখন তোমাকে জানানো হবে।
Level-Triggered (LT) হলো Default। Descriptor যতক্ষণ Readable বা Writable, প্রতিটা epoll_wait সেটা জানায়। ধরো Socket Buffer-এ ১০০০ Byte আছে, তুমি পড়লে ১০০: বাকি ৯০০, তাই পরের Wait-এও Socket Readable বলে আসবে।
Edge-Triggered (ET), EPOLLET দিয়ে চালু হয়, জানায় অবস্থার বদল: নতুন Data এসেছে। এরপর আবার বদল না হওয়া পর্যন্ত চুপ। তাই Socket-কে Non-blocking রাখতে হয়, আর সাধারণত EAGAIN বা EWOULDBLOCK না পাওয়া পর্যন্ত Read বা Write চালিয়ে যেতে হয়:
New data arrives
|
v
Edge event triggered
|
v
Application reads data
|
v
Continue until EAGAIN
| বিষয় | Level-Triggered | Edge-Triggered |
|---|---|---|
| Event | Ready থাকা পর্যন্ত জানায় | অবস্থা বদলালে জানায় |
| সামলানো | তুলনায় সহজ | সাবধানে করতে হয় |
| Non-blocking Socket | উপকারী (করাই উচিত) | অপরিহার্য |
| Data পড়া | ধাপে ধাপে করা যায় | সাধারণত EAGAIN পর্যন্ত পড়তে হয় |
| ঝুঁকি | বাড়তি Notification আর System Call | Event হারানো: Connection আটকে যাওয়া |
| কারা ব্যবহার করে | Redis, libuv | Nginx, Netty-র Native Transport |
একটা চালু বর্ণনা শুধরে নিই: Edge হতে Buffer-কে খালি থেকে ভরা হতে হয় না। নতুন Data এলেই Event আসে, পুরনো Data পড়া না হলেও। এজন্যই Bug-টা লুকিয়ে থাকে: কথাবার্তা চলতে থাকা Connection-এ পরের Message এসে তোমার ফেলে রাখা Byte-গুলোকে বাঁচিয়ে দেয়। কামড়ায় তখন, যখন Client পুরো Request পাঠিয়ে চুপ করে উত্তরের অপেক্ষায় আছে। Server ঘুমায়, Client বসে থাকে, CPU অলস, কোথাও কোনো Error নেই।
তাহলে কোনটা নেবে? সাধারণ Server-এ কোনোটাই স্বভাবে বেশি দ্রুত না, Benchmark-ও একেক রকম বলে। Level-Triggered সহজ, মারাত্মক ভুল করা কঠিন, তাই এটা দিয়েই শুরু করো। Edge-Triggered কিছু epoll_wait আর epoll_ctl Call বাঁচাতে পারে, কারণ EPOLLOUT বারবার চালু-বন্ধ করতে হয় না, আর অনেক Thread-এর সাথে ভালো মানায়। এটা নাও শুধু মেপে দেখা কারণ থাকলে, আর দলটা সাবধানী হলে।
Level-Triggered আর Edge-Triggered: ১০০০ Byte-এর পরীক্ষা
Socket Buffer-এ ১০০০ Byte, Code পড়ল ১০০। কোন Mode আবার জানায় দেখো, তারপর কোন Mode কোন Bug ডেকে আনে।
পরিস্থিতি
Level-Triggered হলো Default, ছোট Read-কে সে ক্ষমা করে।
Data এল, Socket উঠল Ready List-এ।
দুই Mode-এরই আরও একটা করে চিরচেনা Bug আছে:
- ET-তে Starvation। “
EAGAINপর্যন্ত পড়ো” দ্রুত Sender-এর ওপর চিরকাল চলতে পারে, বাকি সব Connection না খেয়ে থাকে। প্রতি পালায় একটা Socket-এর কাজের সীমা দাও, আর তাকে নিজের কাজের List-এ রাখো, কারণ নতুন Event না-ও আসতে পারে। - LT-তে
EPOLLOUT-এর ঘূর্ণি। Socket প্রায় সবসময় Writable।EPOLLOUTচালু রেখে দিলেepoll_waitসাথে সাথে ফিরে আসে, চিরকাল। Data জমে থাকলে তবেই চাও।
দুটো Flag জেনে রাখো: EPOLLONESHOT একটা Event-এর পরে Descriptor বন্ধ করে দেয়, আবার চালু না করা পর্যন্ত, যাতে অনেক Thread-এর Loop-এ একটা Socket একসাথে একটাই Thread ধরে। EPOLLRDHUP জানায় ওপাশ পাঠানোর দিকটা বন্ধ করেছে।
#Level-Triggered-এও Socket Non-blocking কেন
মনে হতে পারে, epoll “Readable” বলার পরে Blocking read তো নিরাপদ। না, নিরাপদ না। Linux-এর select Man Page সাবধান করে: একটা Socket Ready বলে জানানো হলেও পরে Read Block হতে পারে, যেমন আসা Packet-এর Checksum ভুল বেরিয়ে সেটা ফেলে দেওয়া হলে। Socket ভাগ করে নেওয়া আরেকটা Process বা Thread-ও আগে Data নিয়ে নিতে পারে। Blocking Socket-এ এমন একটা ঘটনাই পুরো Loop থামিয়ে দেয়। তাই Multiplexing-এর সাথে Socket সবসময় Non-blocking রাখো।
#অনেক Worker, এক Port
একটা Loop একটা Core ব্যবহার করে, তাই ৩২ Core-এর Machine-এ চলে ৩২টা Worker (Lesson ১.১৩-এ Nginx-কে এটা করতে দেখেছ)। সবাই Port 443 সেবা দেয়, আর তখনই প্রশ্ন: নতুন Connection নেবে কে?
Thundering Herd হলো একটা Event-এ অনেকে জেগে ওঠে, অথচ কাজে লাগাতে পারে একজনই। Blocking accept() এর Wake-one সমাধান পেয়েছে বহু আগে, কিন্তু অনেক Worker নিজের নিজের সাধারণ epoll Instance দিয়ে একই Shared Socket দেখলে একটা Connection-এ সবাই জাগতে পারে। একজন বাদে সবাই EAGAIN পেয়ে আবার ঘুমায়। অনেক Core আর ঘন ঘন নতুন Connection-এ এই অপচয় System Time-এ সত্যিকারের CPU আর Lock-এর কাড়াকাড়ি হয়ে দেখা দেয়। চালু উত্তর তিনটা:
| পদ্ধতি | কীভাবে | ভালো দিক | খারাপ দিক |
|---|---|---|---|
| Accept Mutex | Worker-রা User Space-এ পালা করে Socket দেখে | সব জায়গায় চলে | Latency বাড়ে। Nginx-এর accept_mutex এখন Default-এ বন্ধ |
EPOLLEXCLUSIVE (Linux 4.5) | একটাই Shared Socket; সবাই না, অন্তত একজন জাগে | আটকে পড়া Worker কাউকে আটকায় না; খালি Worker Connection নেয় | অসমান: Kernel একই জনকে বেশি জাগায়, তাই কেউ কেউ অনেক বেশি Connection পায় |
SO_REUSEPORT (Linux 3.9) | প্রত্যেক Worker-এর একই Port-এ নিজের Socket; Kernel 4-tuple Hash করে একটা বাছে | Herd নেই, Shared Lock নেই, ভাগ প্রায় সমান | আটকে পড়া Worker-ও তার ভাগ পায়, সেই Connection-গুলো দাঁড়িয়ে থাকে, Tail Latency খারাপ হয়। Socket বন্ধ করলে Queue-তে থাকা গুলো Reset হয় |
Nginx-এ এটা চালু হয় listen 443 reuseport; দিয়ে। কোনোটাই সব জায়গায় জেতে না: ছোট, একই রকম কাজে সমান Load-এর জন্য SO_REUSEPORT নাও, আর একটা Request Worker-কে আটকে দিতে পারলে সাবধান থাকো। UDP-তেও এটা খাটে, যেখানে একটা Shared Socket এমনিতে বড় Bottleneck।
অনেক Worker, এক Port: Thundering Herd
একটা Event Loop একটা Core ব্যবহার করে, তাই Server প্রতি Core-এ একটা চালায়। Port 443 তারা কীভাবে ভাগ করবে? সাধারণ Shared Socket, EPOLLEXCLUSIVE আর SO_REUSEPORT মিলিয়ে দেখো।
পরিস্থিতি
সব Worker সাধারণ epoll দিয়ে একই Listening Socket দেখে।
Master Socket খোলে আর Worker-দের Fork করে, তারা সেটা উত্তরাধিকারে পায়, আর প্রত্যেকে নিজের epoll Instance-এ যোগ করে। তিনজনই epoll_wait-এ ঘুমন্ত।
#File Descriptor আর তার সীমা
প্রতিটা Socket, File, Pipe আর epoll Instance একটা File Descriptor (Lesson ৩.১), আর সীমা আছে কয়েক স্তরে:
| সীমা | পরিধি | কোথায় বদলাবে | Error |
|---|---|---|---|
Soft RLIMIT_NOFILE | Process, প্রায়ই ১০২৪ | ulimit -Sn, systemd-তে LimitNOFILE=, Docker-এ --ulimit nofile= | EMFILE |
Hard RLIMIT_NOFILE | Process, Soft Limit-এর ছাদ | ulimit -Hn, একই Setting | EMFILE |
fs.nr_open | কোনো Process সর্বোচ্চ যত দিতে পারে, প্রায়ই ১০,৪৮,৫৭৬ | sysctl | সীমা বসানোই ব্যর্থ |
fs.file-max | পুরো Machine | sysctl | ENFILE |
fs.epoll.max_user_watches | প্রতি User-এ Register করা epoll Entry | sysctl | epoll_ctl-এ ENOSPC |
প্রতি Client Connection-এ অন্তত একটা Descriptor ধরো, Proxy হলে দুটো (Client আর Upstream), সাথে Log, Listener ইত্যাদি। ১,০০,০০০ Client-এর Proxy-তে লাগে ২,০০,০০০-এর বেশি। Shell-এ টাইপ করা বা Login Session-এর জন্য limits.conf-এ লেখা সীমা systemd Service মানে না, আর “বাড়ালাম, তবু বদলাল না”-র সবচেয়ে সাধারণ কারণ এটাই। চলন্ত Process-এ যাচাই করো cat /proc/<pid>/limits দিয়ে।
দুটো ব্যর্থতা তুমি সত্যিই দেখবে:
EMFILE-এর ঘূর্ণি।acceptব্যর্থ, কিন্তু Connection Queue-তেই থাকে, তাই Level-Triggered Listener Readable-ই থাকে। Loop ১০০% CPU-তে ঘোরে, Error ছাপতে থাকে। Library-গুলো এর জন্য একটা Spare Descriptor রাখে, বা কিছুক্ষণ Listener দেখা বন্ধ রাখে।- Descriptor Leak। কোনো Error-এর পথে
close()ভুলে গেছে। সীমায় পৌঁছানো পর্যন্ত SocketCLOSE_WAIT-এ পড়ে থাকে, আর সীমা বাড়ালে Outage শুধু পিছিয়ে যায়।CLOSE_WAITবাড়তে থাকা সবসময় তোমার Application-এর Bug, Network-এর না।
File Descriptor ফুরিয়ে গেলে
প্রতিটা Socket একটা Descriptor। Event Loop-এর ভেতর থেকে সীমাটা কেমন দেখায়, কেন ১০০% CPU-তে ঘুরতে পারে, আর Descriptor Leak কীভাবে একই জায়গায় নিয়ে যায়, দেখো।
পরিস্থিতি
Default Soft Limit অবাক করার মতো ছোট।
Log File, epoll-এর Descriptor আর Listening Socket-ও গোনা হয়, তাই ১,০২৪ Connection-এর একটু আগেই সীমা চলে আসে।
#Regular File সবসময় “Ready”
Readiness Model বানানো হয়েছিল এমন জিনিসের জন্য, যার Data বাইরে থেকে আসে, যেমন Socket আর Pipe। Regular File আলাদা:
selectআরpollRegular File-কে সবসময় Readable আর Writable বলে।- Regular File-এ
epoll_ctlনা করে দেয়EPERMদিয়ে। - এতে
O_NONBLOCK-এর কোনো প্রভাব নেই, তাইEAGAINকখনো আসে না।
কেন? “Ready” মানে Read-কে বাইরের কোনো ঘটনার অপেক্ষা করতে হবে না, আর File-এর Byte তো সবসময়ই পাওয়া যায়। Disk-এর অপেক্ষাকে Kernel এই হিসাবে ধরে না, সে এটাকে সীমাহীন অপেক্ষা না, একটা ছোট বিলম্ব ভাবে। তাই Page Cache-এ না থাকা Page-এর read() সোজা তোমার Thread-কে ঘুম পাড়ায় (D State-এ দেখায়, “Uninterruptible”), আর তার সাথে তোমার Event Loop-ও থেমে যায়। Laptop-এ সব Cache-এ থাকে, তাই কখনো দেখবে না। Production-এ Data যখন Memory-র চেয়ে অনেক বড়, বা Network Disk ব্যস্ত, তখন দেখা দেয় হঠাৎ হঠাৎ Latency Spike হয়ে, একসাথে সব Connection-এ।
উত্তরগুলো:
| পদ্ধতি | কীভাবে | কথা |
|---|---|---|
| Thread Pool | Read-টা একটা সাহায্যকারী Thread-কে দাও, শেষ হলে খবর পাও | চিরচেনা: Node.js-এর File কাজে libuv-এর Pool, Nginx-এ aio threads। সহজ, সব জায়গায় চলে |
io_uring | Read জমা দাও, ফল তুলে নাও | সত্যিকারের Asynchronous File I/O, Buffered Read-এও |
preadv2 আর RWF_NOWAIT | Block না হলে তবেই পড়ে; নইলে EAGAIN, তখন Pool-এ পাঠাও | Data Cache-এ থাকলে Thread-এ যাওয়া বাঁচে |
sendfile (File থেকে Socket-এ পাঠাতে) | Kernel সরাসরি Page Cache থেকে Socket-এ দেয় | Copy বাঁচে, তবে ঠান্ডা Page-এ Block হতে পারে |
Regular File: সবসময় “Ready”, তবু ধীর
Readiness Socket-এর জন্য কাজ করে, যার Data বাইরে থেকে আসে। Readiness-এর হিসাবে File-এর অপেক্ষা করার কিছু নেই, তাই Disk তবু তোমার Loop থামিয়ে দিতে পারে।
পরিস্থিতি
Regular File-এ O_NONBLOCK আর poll কোনো কাজ করে না।
Loop একটা File-কে Socket-এর মতো নজরে রাখতে চায়।
#Readiness আর Completion, আর io_uring
#দুটো আলাদা প্রশ্ন
| Readiness (Reactor) | Completion (Proactor) | |
|---|---|---|
| তুমি বলো | “পড়া গেলে বলো” | “এই Buffer-এ পড়ে রাখো, হলে জানিও” |
| I/O করে কে | তুমি, আরেকটা System Call-এ | Kernel |
| Buffer লাগে | শুধু Socket Ready হলে | কাজ শুরুর মুহূর্ত থেকে |
| File-এ চলে | না | হ্যাঁ |
| উদাহরণ | select, poll, epoll, kqueue | IOCP, io_uring (নামে POSIX AIO) |
epoll-এর দাম হলো, এটা শুধু জানায়, আর কিছু করে না। হাজারটা Ready Socket মানে একটা epoll_wait, হাজারটা read, আর হয়তো হাজারটা write, প্রতিটায় Kernel-এর সীমানা পেরোনো। Spectre আর Meltdown-এর Mitigation-এর পর থেকে এই পেরোনো বেশ দামি হয়েছে। ভারী Traffic-এ Server-এর CPU-র বড় একটা অংশ এখানেই যেতে পারে।
#io_uring কীভাবে কাজ করে
Jens Axboe-র বানানো io_uring ২০১৯ সালে Linux 5.1-এ এসেছে। এর ভিত দুটো Ring Buffer, যা থাকে তোমার Program আর Kernel-এর Shared Memory-তে:
- Submission Queue (SQ), যেখানে তুমি Request লেখো। প্রতিটা Entry (SQE) বলে কী করতে হবে: Operation, Descriptor, Buffer, আর তোমার পছন্দের একটা
user_data। - Completion Queue (CQ), যেখানে Kernel ফল লেখে। প্রতিটা Entry (CQE)-তে থাকে তোমার
user_dataআর একটা Result Code।
Your program Kernel
writes SQE ---> [ SQ ring ] ---> takes requests, does the I/O
|
reads CQE <--- [ CQ ring ] <--- writes results (any order)
এতে এমন কিছু পাওয়া যায়, যা epoll পারে না:
- Batching। ১০০টা Read-Write Request জমিয়ে একটা
io_uring_enterCall। CQ থেকে ফল পড়তে কোনো System Call-ই লাগে না। - File আর Socket একই পথে। Network আর Buffered Disk I/O-র একই Interface, আরও অনেক কিছু, যেমন
accept,connect,openat,fsync। - Copy আর খোঁজাখুঁজি কম। আগে থেকে Register করা Buffer আর File বারবারের প্রস্তুতি বাঁচায়, Zero-copy Send-ও আছে।
- কোনো System Call-ই না,
SQPOLL-এ: একটা Kernel Thread SQ-তে নজর রাখে, দাম হলো সক্রিয় থাকার সময় একটা Thread ঘুরতে থাকে।
এর নিজের কিছু ফাঁদও আছে। Completion Model-এ প্রতিটা ঝুলে থাকা Read-এ একটা Buffer লাগে, তাই ৫০,০০০ চুপচাপ Socket × ১৬ KB মানে ৮০০ MB বসে থাকে। এর সমাধান Provided Buffer, যেখানে Data এলে Kernel Shared Pool থেকে একটা বেছে নেয়, আর Multishot Receive। Completion আসা পর্যন্ত তোমার Buffer-কে অক্ষত রাখতে হয়, তাই Cancel করা কঠিন হয়। আর যে Feature চাও সেগুলো পেতে নতুন Kernel লাগে।
#নিরাপত্তার প্রশ্ন
io_uring-এ একটা Process আলাদা কোনো System Call ছাড়াই প্রায় সব কাজ করতে পারে, তাই seccomp Filter আর System Call দেখে চলা অনেক Security Tool বুঝতেই পারে না সে কী করছে। এই Code-এ Kernel-এর গুরুতর Vulnerability-ও এসেছে একের পর এক। Google ২০২৩ সালে জানায়, আগের বছর তাদের Kernel Bug-bounty-তে জমা পড়া বেশিরভাগ Exploit io_uring ব্যবহার করেছে, আর তারা Android, Chrome OS ও নিজেদের Production Server-এ এটা সীমিত করে। Docker-এর মতো Container Runtime-এর সাম্প্রতিক Default seccomp Profile এটা আটকে দেয়, আর Linux 6.6-এ এসেছে kernel.io_uring_disabled Sysctl, পুরো Host-এ বন্ধ করার জন্য।
তাই ভরসা করার আগে যাচাই করে নাও। Production-এ তোমার Code EPERM পেতে পারে, তখন epoll বা Thread Pool-এ ফিরতে হবে, ভালো Library-গুলো তাই করে। ব্যবহার বাড়ছে, তবে সাবধানে: PostgreSQL 18 Asynchronous Read-এ এটা ব্যবহার করতে পারে (io_method=io_uring), আর Storage Engine ও Database-ই এর স্বাভাবিক জায়গা। সাধারণ Network Server-এ epoll-ই এখনো রোজকার পছন্দ।
Readiness-এর বদলে Completion: io_uring
তোমার Program আর Kernel-এর মাঝে Shared দুটো Ring। epoll-এর সাথে মিলিয়ে দেখো, কয়েকটা Read এক System Call-এ পাঠাও, তারপর দাম কী, সেটাও দেখো।
পরিস্থিতি
“পড়া গেলে বলো।” তারপর তুমি পড়ো।
Kernel বলে তিনটা Socket Ready। Data এখনো নড়েনি।
#কোনটা বাছবে
| পরিস্থিতি | যুক্তিসঙ্গত পছন্দ | কেন |
|---|---|---|
| ভেতরের Service, কয়েকশো Connection, বেশিরভাগ CPU বা Database-এর কাজ | Blocking Thread আর একটা Pool | Code সহজ; অপেক্ষা তো Database-এ, তার Pool-ই আগে থামাবে |
| Chat, Push, Long Polling, হাজার হাজার চুপচাপ Connection | epoll বা kqueue দিয়ে Event Loop | চুপচাপ Connection-এর খরচ প্রায় শূন্য হতে হবে |
| Proxy, Load Balancer বা Gateway | প্রতি Core-এ একটা Event Loop | প্রায় সবটাই অপেক্ষা, প্রতি Request-এ CPU অল্প |
| Blocking ধাঁচের অনেক Business Code আর অনেক Connection | Goroutine বা Java Virtual Thread | Code সহজ, নিচে Runtime epoll-এ চালায় (Lesson ৩.৬) |
| খুব বেশি I/O-র Storage Engine বা Database | অনুমতি থাকলে io_uring, সাথে Fallback | Batching আর File-এর আসল Asynchrony কাজে আসে |
| সব Platform-এ চলার মতো Library | libuv, বা তোমার Language-এর Runtime | epoll, kqueue আর IOCP এক Interface-এর পেছনে থাকে |
#System Design-এ এর মানে কী
- Connection-এর সংখ্যা একটা পরিকল্পনা করার মতো Resource। “১০ লাখ WebSocket User হবে” মানে: Descriptor, প্রতি Connection-এ Memory, Event Loop, Machine-এর সংখ্যা, আর একটা মরে গেলে সবাই একসাথে আবার যুক্ত হলে কী হবে (Lesson ২.৬)।
- আটকানোর জায়গা সরে যায়। Event Loop-এর Front-end ১,০০,০০০ Connection নিতে পারে, আর পাঠাতে পারে এমন Database-এ যে ৫০০ সামলায়। Non-blocking I/O Thread-এর সীমা তোলে, Capacity-র সীমা না। Pool, Queue আর Limit দিয়ে নিচের দিকটা বাঁচাও।
- সামনের Load Balancer-এও একই যত্ন। সে প্রতি Connection-এ দুটো Descriptor ধরে, আর তার নিজের Ephemeral Port ও Connection-এর সীমা আছে (Chapter 25)।
- ধীর Client দিয়ে Test করো। চুপচাপ আর ধীর Connection-এই Design-গুলোর সবচেয়ে বড় পার্থক্য, আর দ্রুত Request পাঠানো Load Test সেটা দেখাবে না।
#Production-এর চেনা সমস্যা
| লক্ষণ | সম্ভাব্য কারণ | কী করবে |
|---|---|---|
ulimit -n বাড়ানোর পরে হঠাৎ Crash বা “buffer overflow detected” | কোনো Library ১০২৪ বা বড় Descriptor নিয়ে select() ডাকছে | poll বা epoll-এ বদলাও; Dependency দেখো |
| Traffic নেই, একটা Core ১০০% | Busy Loop: EPOLLOUT চালু, বা EOF-এর পরে Socket বন্ধ হয়নি, বা EMFILE-এর ঘূর্ণি | Process-এ strace চালিয়ে দেখো কী ঘুরছে |
| Connection আটকে, CPU অলস, Error নেই | ET-র Read Loop EAGAIN-এর আগেই থেমেছে | পুরোটা পড়ো, বা Level-Triggered-এ যাও |
Too many open files | সীমা কম, বা Leak | /proc/<pid>/limits-এর সাথে সংখ্যা মেলাও; CLOSE_WAIT দেখো |
| একসাথে সব Connection-এ Latency Spike | Loop-এ Blocking Call: Disk Read, DNS, ভারী CPU | Pool-এ সরাও; Loop Lag মাপো (Lesson ৩.৬) |
| একজন Worker গরম, বাকিরা অলস | Shared Socket-এ জাগানোর পক্ষপাত | SO_REUSEPORT |
| Worker-রা অলস দেখায়, তবু p99 Latency খারাপ | একজন আটকে পড়া Worker-এর পেছনে SO_REUSEPORT Queue জমছে | Blocking Call খোঁজো; Shared Socket এই ফাঁদ এড়ায় |
| ধীর Client-এর Response কাটা | আধা write উপেক্ষা করা হয়েছে | বাকিটা রেখে EPOLLOUT-এর অপেক্ষা করো |
শুধু Container-এ io_uring EPERM | seccomp Profile বা kernel.io_uring_disabled | Fallback করো; Profile যেনতেনভাবে বন্ধ করো না |
#নিজে করে দেখো
সব Command Linux-এর জন্য। Python-ও Linux-এ চালাও, কারণ macOS বা Windows-এ select.epoll নেই।
EAGAIN আর ১০০০ Byte-এর পরীক্ষা দেখো। এই Lesson-এর উদাহরণটাই আসল Kernel-এ:
import select, socket
a, b = socket.socketpair()
a.setblocking(False)
try:
a.recv(1024)
except BlockingIOError as e:
print("empty socket:", e)
for name, mask in [("LT", select.EPOLLIN), ("ET", select.EPOLLIN | select.EPOLLET)]:
ep = select.epoll()
ep.register(a, mask)
b.send(b"x" * 1000)
print(name, "first wait: ", ep.poll(0))
a.recv(100) # read only 100 of the 1000 bytes
print(name, "second wait:", ep.poll(0))
a.recv(65536) # drain the rest before the next round
ep.close()
LT-র দ্বিতীয় Wait আবার Socket-টা জানায়, আর ET-র দ্বিতীয় Wait দেয় খালি List: ওই খালি List-টাই আটকে যাওয়া।
১০২৪ নম্বর Descriptor-এ select ভাঙো।
import resource, select, socket
soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
resource.setrlimit(resource.RLIMIT_NOFILE, (min(2048, hard), hard))
socks = [socket.socket() for _ in range(1100)]
last = socks[-1]
print("descriptor number:", last.fileno())
p = select.poll()
p.register(last, select.POLLIN)
p.poll(0) # no complaint
print("poll is fine")
try:
select.select([last], [], [], 0)
except ValueError as e:
print("select refuses:", e) # filedescriptor out of range in select()
একটা আসল Server খুলে দেখো। Nginx বা যেকোনো ব্যস্ত Process নাও, দরকার হলে Root হয়ে:
ulimit -n # soft limit in this shell
ulimit -Hn # hard limit
PID=$(pgrep -n nginx) # or any process
grep "open files" /proc/$PID/limits # limits of the running process
ls /proc/$PID/fd | wc -l # descriptors open now
ls -l /proc/$PID/fd | grep eventpoll # its epoll instances
sysctl fs.file-nr fs.nr_open fs.file-max # system-wide counters and limits
sysctl fs.epoll.max_user_watches
ss -s # socket summary
ss -tn state close-wait | wc -l # sockets your app has not closed
Loop-টা দেখো। Nginx-এর Worker-এ strace লাগিয়ে একটা Page খোলো:
strace -p "$(pgrep -n nginx)" -e trace=epoll_wait,accept4,recvfrom,read,writev,sendfile
# a healthy loop sits in epoll_wait, then a short burst of calls, then epoll_wait again
কোনো Process বারবার epoll_wait-এ 1 ফেরত পেতে থাকলে সেটা ঘূর্ণি। কোন System Call বেশি চলছে দেখতে কয়েক সেকেন্ড strace -c -p $PID চালাও। strace Process-কে অনেক ধীর করে, তাই Production-এ সাবধান।
io_uring যাচাই করো।
sysctl kernel.io_uring_disabled # 0 allowed, 1 restricted, 2 disabled (Linux 6.6 or later)
uname -r # your kernel version
#সারসংক্ষেপ
মূল কথা
- প্রতিটা Read-এর দুই ধাপ, অপেক্ষা আর Copy। পাঁচটা Model (Blocking, Non-blocking, Multiplexing, Signal-driven, Asynchronous)-এর পার্থক্য হলো কোন ধাপে কে অপেক্ষা করে।
epollMultiplexing-এর একটা উপায়, ষষ্ঠ কোনো Model না। - Blocked Thread CPU খায় না। তার দাম একটা Stack, Pool-এর একটা জায়গা, আর Burst-এ Switching। ৮ MB Stack হলো Address Space, Memory না।
- Non-blocking Call অপেক্ষা না করে
EAGAINবাEWOULDBLOCKদেয়। তখন ছোট Read, আধা Write, নিজের State আর Backpressure সামলাতে হয়। select-কে থামায়FD_SETSIZE(সাধারণত ১০২৪, আর এটা Descriptor-এর নম্বরের সীমা), আর সে সব Scan করে;pollসীমা তোলে, কিন্তু Scan করেই।epollআরkqueueতালিকা Kernel-এ রাখে, তাই Wait-এর খরচ চলে Ready Socket-এ। IOCP Completion-ভিত্তিক, Readiness-ভিত্তিক না।epollমানে তিনটা Call (epoll_create1,epoll_ctl,epoll_wait) আর দুটো List (Interest-এর Tree, Ready-র List)।- Level-Triggered Ready থাকা পর্যন্ত বারবার জানায়; Edge-Triggered জানায় বদলে, তাই ন্যায্যতার সীমা রেখে
EAGAINপর্যন্ত পড়ো। না পড়লে চুপচাপ আটকে যাওয়া; LT-তেEPOLLOUTচালু রাখলে ১০০% CPU-র ঘূর্ণি। - এক Port-এ অনেক Worker: সাধারণ Shared Socket-এ Thundering Herd,
EPOLLEXCLUSIVEজাগা কমায় কিন্তু অসমান,SO_REUSEPORTসমান ভাগ করে কিন্তু আটকে পড়া Worker-এর পেছনে Connection আটকে রাখে। - Descriptor-এর সীমা জানো (
LimitNOFILE,fs.nr_open,fs.file-max),EMFILE-এর জন্য প্রস্তুত থাকো, আরCLOSE_WAITবাড়লে সেটাকে Leak ধরো। - Regular File সবসময় “Ready”, তাই ঠান্ডা Disk Read Event Loop থামিয়ে দেয়। Thread Pool বা
io_uringব্যবহার করো। io_uringShared Ring দিয়ে দেয় Batching, File-এর আসল Asynchrony আর কম System Call। seccomp প্রায়ই এটা আটকায়, তাই Fallback সবসময় রাখো।
#নিজেকে যাচাই করো
আগে নিজে উত্তর দাও, তারপর ব্যাখ্যাটা পড়ো। ঠিক হলেও পড়ো, কারণ ভুল Option-গুলো কেন ভুল, সেটাও ওখানে আছে।
নিজেকে যাচাই করোপ্রশ্ন
Socket থেকে পড়ার দুটো ধাপ কী?
নিজেকে যাচাই করোপ্রশ্ন
একটা Java Service-এর Pool-এ ২০০ Thread। Incident-এর সময় ২০০টাই ধীর Client-এর Socket Read-এ Blocked, নতুন Request Timeout হচ্ছে, আর CPU মাত্র ৪%। CPU এত কম কেন?
নিজেকে যাচাই করোপ্রশ্ন
তোমার Code একবার data = sock.recv(1024) করে data-কে পুরো ১০০০ Byte-এর Request ধরে নেয়। localhost-এ সব Test পাস, Production-এ মাঝে মাঝে ব্যর্থ। কেন?
নিজেকে যাচাই করোপ্রশ্ন
একটা Non-blocking Read ০ ফেরত দিল। তোমার Code কী করবে?
নিজেকে যাচাই করোপ্রশ্ন
একজন Developer এমন Server লিখল, যে ৫,০০০ Non-blocking Socket ঘুরে ঘুরে recv করে, আর কিছু না। Traffic নেই, তবু একটা Core ১০০%, বেশিরভাগ System Time-এ। সবচেয়ে ভালো সমাধান কী?
নিজেকে যাচাই করোপ্রশ্ন
POSIX-এর সংজ্ঞায় কোন Model সত্যিকারের Asynchronous?
নিজেকে যাচাই করোপ্রশ্ন
একটা দল ulimit -n করল ৬৫৫৩৫। কয়েক সপ্তাহ পরে, শুধু Peak Load-এ, Service buffer overflow detected বলে Abort করে। সবচেয়ে সম্ভাব্য কারণ কী?
নিজেকে যাচাই করোপ্রশ্ন
select-এর তুলনায় poll-এর উন্নতি নিয়ে কোন দুটো কথা ঠিক?
বেছে নাও ২
নিজেকে যাচাই করোপ্রশ্ন
একটা Server ১,০০,০০০ Socket নিয়ে poll() ব্যবহার করে। প্রতি Call-এ Kernel-এ মোটামুটি কত Data Copy হয়?
নিজেকে যাচাই করোপ্রশ্ন
epoll_wait-এর একটা Call-এর খরচ কীসের ওপর নির্ভর করে?
নিজেকে যাচাই করোপ্রশ্ন
একটা Database Proxy-র ২০টা Socket, প্রায় প্রতি Iteration-এ সবাই সক্রিয়। একজন Engineer poll বদলে epoll করল, কোনো লাভ নেই, বরং সামান্য ক্ষতি। এটা বিশ্বাসযোগ্য কেন?
নিজেকে যাচাই করোপ্রশ্ন
তোমার Event Loop Disk-এর একটা Regular File যোগ করতে epoll_ctl করে -1 পেল। Error কী, আর কেন?
নিজেকে যাচাই করোপ্রশ্ন
Level-Triggered Mode-এ Socket Buffer-এ ১০০০ Byte, তুমি পড়লে ১০০। পরের epoll_wait কী করবে?
নিজেকে যাচাই করোপ্রশ্ন
একটা Edge-Triggered Server প্রতি Event-এ ৪ KB পড়ে। ছোট Request চলে। প্রায় ১০ KB-র Request Timeout পর্যন্ত আটকে থাকে, CPU অলস, কোনো Error Log নেই। কারণ কী?
নিজেকে যাচাই করোপ্রশ্ন
Edge-Triggered Mode-এ Socket-কে Non-blocking হতেই হবে কেন?
নিজেকে যাচাই করোপ্রশ্ন
একটা Edge-Triggered Loop প্রতিটা Socket EAGAIN পর্যন্ত পড়ে। একজন Client পুরো গতিতে Upload করছে, আর বাকি সবার Latency লাফিয়ে উঠল। ঠিক সমাধান কী?
নিজেকে যাচাই করোপ্রশ্ন
একটা Level-Triggered Server প্রতিটা Connection স্থায়ীভাবে EPOLLIN আর EPOLLOUT দিয়ে Register করে। Traffic অল্প, তবু CPU ১০০%, আর strace-এ দেখা যায় epoll_wait বারবার সাথে সাথে ফিরছে। কী হচ্ছে?
নিজেকে যাচাই করোপ্রশ্ন
সাধারণ epoll Skeleton (Create, Listener যোগ, Wait, Handle) কোন দুটো জিনিস বাদ দেয়, যা Production Server-এ লাগবেই?
বেছে নাও ২
নিজেকে যাচাই করোপ্রশ্ন
এক Batch Event সামলাতে গিয়ে তোমার Loop ৭ নম্বর Descriptor বন্ধ করল, আর নতুন Client Accept করল, যে আবার ৭ পেল। একই Batch-এর পরের একটা Event ৭-এর কথা বলছে। কী হতে পারে, আর সমাধান কী?
নিজেকে যাচাই করোপ্রশ্ন
ষোলোটা Nginx-ধাঁচের Worker সাধারণ epoll দিয়ে একই Shared Listening Socket দেখে। perf-এ দেখা যায় EAGAIN-ফেরত accept-এ অনেক সময় যাচ্ছে। একে কী বলে?
নিজেকে যাচাই করোপ্রশ্ন
একটা Server Shared Listening Socket ছেড়ে SO_REUSEPORT নিল। Load সমান হলো, CPU কমল, কিন্তু কোনো Request মাঝে মাঝে একজন Worker-কে এক সেকেন্ড আটকে দিলে p99 Latency খারাপ হয়। কেন?
নিজেকে যাচাই করোপ্রশ্ন
EPOLLEXCLUSIVE নিয়ে কোন দুটো কথা ঠিক?
বেছে নাও ২
নিজেকে যাচাই করোপ্রশ্ন
File Descriptor-এর সীমায় পৌঁছে একটা Level-Triggered Server সেকেন্ডে হাজার হাজারবার accept: Too many open files লেখে, আর CPU ১০০%। কেন ঘোরে?
নিজেকে যাচাই করোপ্রশ্ন
তুমি Server-এ ulimit -n 100000 চালিয়ে systemctl দিয়ে Service Restart করলে, কিন্তু /proc/PID/limits এখনো ১০২৪ দেখাচ্ছে। কোন দুটো কাজ আসলে ঠিক করবে?
বেছে নাও ২
নিজেকে যাচাই করোপ্রশ্ন
একটা Service-এর খোলা Descriptor ক্রমাগত বাড়ে, কয়েকদিন পরপর Too many open files বলে থেমে যায়। ss-এ দেখা যায় হাজার হাজার Socket CLOSE_WAIT-এ। কী করবে?
নিজেকে যাচাই করোপ্রশ্ন
Node-ধাঁচের একটা Event Loop মাঝে মাঝে সব Connection-এ একসাথে ৮০ ms থেমে যায়। কারণ, Loop Thread-এ একটা Synchronous File Read। O_NONBLOCK কেন কাজ করল না, আর কোন দুটো সমাধান ঠিক?
বেছে নাও ২
নিজেকে যাচাই করোপ্রশ্ন
epoll-এর তুলনায় Windows-এর IOCP-কে ঠিকভাবে বর্ণনা করে কোনটা?
নিজেকে যাচাই করোপ্রশ্ন
epoll-এর তুলনায় io_uring-এর সত্যিকারের সুবিধা কোন দুটো?
বেছে নাও ২
নিজেকে যাচাই করোপ্রশ্ন
io_uring ব্যবহার করা একটা Service Staging-এর VM-এ চলে, আর Production Container-এ শুরুতেই EPERM দিয়ে ব্যর্থ হয়। সবচেয়ে সম্ভাব্য কারণ কী?
নিজেকে যাচাই করোপ্রশ্ন
একটা Chat Gateway ২,০০,০০০ চুপচাপ Socket ধরে, আর দলটা io_uring-এ গেল, প্রতি ঝুলে থাকা Receive-এ ১৬ KB Buffer। Memory বাড়ল প্রায় ৩.২ GB। ঠিক সমাধান কী?
নিজেকে যাচাই করোপ্রশ্ন
তুমি প্রায় ১৫০ জন একসাথে ব্যবহার করা একটা ভেতরের Admin API Design করছ, প্রতি Request-এ কয়েকটা Database Query। এক সহকর্মী Performance-এর জন্য নিজেদের Edge-Triggered epoll Server বানাতে বলল। সবচেয়ে ভালো উত্তর কী?