Course-এর সূচি

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 কেন ধীর হয় না তা বোঝাতে।
  • একটা কাজ করা epoll Server পড়তে ও লিখতে, আর আটকে যাওয়া বা 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 RequestNetwork আর ওই ServiceData Center-এর ভেতরে ১ Millisecond-এর মতো, পৃথিবীর ওপারে ১০০ ms বা বেশি
Database Readএকটা Network Round Trip, সাথে Database-এর নিজের Disk আর Lock১ থেকে কয়েকশো Millisecond
File পড়াDisk, যদি না Data আগে থেকেই Memory-তে থাকেMicrosecond (Cache-এ) থেকে Millisecond
Disk-এ লেখাসাধারণত শুধু Memory-তে Copy; জোর করলে তবেই DiskMicrosecond, 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, খরচও অল্প।

Client-রা
Server Process
Database
২০০ Request

২০০ Thread-এ খরচ

জিনিসপরিমাণ
Stack-এর আসল Memoryপ্রায় ১৬ MB
অপেক্ষায় ঘুমন্ত Threadবেশিরভাগ
ধাপ 1 / 2
1

দুইশো 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 ভরে গেলে Blocking send() ঘুমিয়ে পড়ে। তাই 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 এভাবে চলে।

তোমার Threaddata = sock.recv(1024)
KernelSocket BufferReceive Buffer: খালি
Client
recv(fd, buf, 1024)
ধাপ 1 / 5
1

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 না ঘুমিয়ে সাথে সাথে উত্তর দেয়।

তোমার Thread
KernelSocket BufferReceive Buffer: খালি
Client
recv(fd, buf, 1024)
ধাপ 1 / 4
1

Socket-এ O_NONBLOCK দেওয়া আছে। Buffer খালি, কিন্তু এবার Kernel Thread-কে ঘুম পাড়াবে না।

#পুরো মানচিত্র: পাঁচটা I/O Model

W. Richard Stevens-এর UNIX Network Programming বইয়ে পাঁচটা Model-এর নাম আছে, Interview-তে এখনো এই তালিকাই চলে। পার্থক্য হলো প্রতিটা ধাপে তোমার Thread কী করে:

Modelধাপ ১: Data-র অপেক্ষাধাপ ২: CopyLinux-এর উদাহরণ
BlockingThread BlockedThread BlockedDefault read, recv
Non-blockingThread বারবার জিজ্ঞেস করে (EAGAIN)Thread BlockedO_NONBLOCK
I/O MultiplexingThread Blocked, তবে এক Call-এ অনেক SocketThread Blockedselect, poll, epoll
Signal-drivenThread স্বাধীন, Signal এসে জানায়Thread BlockedO_ASYNC, SIGIO (কম ব্যবহার হয়)
AsynchronousThread স্বাধীন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।

012345678910সময় (উদাহরণের একক)তোমার ThreadKernel
Thread কাজের কাজ করছেThread BlockedThread বৃথা জিজ্ঞেস করছেKernel Data-র অপেক্ষায়Kernel Data Copy করছে
ধাপ 1 / 3
1

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নজরে থাকা সংখ্যা ধরেনিজের কোনো সীমা নেই
epollLinuxReadinessReady সংখ্যা ধরেfs.epoll.max_user_watches
kqueueFreeBSD, macOSReadinessReady সংখ্যা ধরেKernel Memory
IOCPWindowsCompletionশেষ হওয়া কাজের সংখ্যা ধরেKernel Memory

#select()

সবচেয়ে পুরনো, ১৯৮০-র দশকের 4.2BSD থেকে। তুমি Descriptor নম্বরের Bitmap দাও, সে বলে কোনগুলো Ready। সীমাবদ্ধতা অনেক:

  • FD_SETSIZE, সাধারণত ১০২৪, Descriptor-এর নম্বরকেই সীমিত করে, শুধু কতগুলো দেখছ তা না। ১৫০০ নম্বর Descriptor একা হলেও চলবে না। FD_SET দিয়ে লিখলে Bitmap উপচে পড়ে, আর এটা Production-এর সত্যিকারের Bug: ulimit -n বাড়ালে, ১,০২৪ Connection পেরোলে, আর ভুলে যাওয়া একটা Library select() ডাকলে 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।

চুপচাপ Socketএই Call দেখেছেData আছেতোমার হাতে এল
Kernel-এ Copy
পুরো Bitmap, প্রতি Call-এ
সবচেয়ে বড় Descriptor
১০২৩ (FD_SETSIZE)
ধাপ 1 / 3
1

তুমি 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 এটুকুই।

Event Loopএকটা Thread
epoll InstanceKernel-এInterest: নেই, Ready: নেই
Client-রা
epoll_create1(0)
ধাপ 1 / 5
1

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-TriggeredEdge-Triggered
EventReady থাকা পর্যন্ত জানায়অবস্থা বদলালে জানায়
সামলানোতুলনায় সহজসাবধানে করতে হয়
Non-blocking Socketউপকারী (করাই উচিত)অপরিহার্য
Data পড়াধাপে ধাপে করা যায়সাধারণত EAGAIN পর্যন্ত পড়তে হয়
ঝুঁকিবাড়তি Notification আর System CallEvent হারানো: Connection আটকে যাওয়া
কারা ব্যবহার করেRedis, libuvNginx, 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-কে সে ক্ষমা করে।

Event Loop
epollKernel-এBuffer: ১০০০ Byte
Client
১০০০ Byte
ধাপ 1 / 4
1

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 MutexWorker-রা 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 দেখে।

নতুন Connection
Shared Socket :443
Worker ১ঘুমন্ত
Worker ২ঘুমন্ত
Worker ৩ঘুমন্ত
ধাপ 1 / 3
1

Master Socket খোলে আর Worker-দের Fork করে, তারা সেটা উত্তরাধিকারে পায়, আর প্রত্যেকে নিজের epoll Instance-এ যোগ করে। তিনজনই epoll_wait-এ ঘুমন্ত।

#File Descriptor আর তার সীমা

প্রতিটা Socket, File, Pipe আর epoll Instance একটা File Descriptor (Lesson ৩.১), আর সীমা আছে কয়েক স্তরে:

সীমাপরিধিকোথায় বদলাবেError
Soft RLIMIT_NOFILEProcess, প্রায়ই ১০২৪ulimit -Sn, systemd-তে LimitNOFILE=, Docker-এ --ulimit nofile=EMFILE
Hard RLIMIT_NOFILEProcess, Soft Limit-এর ছাদulimit -Hn, একই SettingEMFILE
fs.nr_openকোনো Process সর্বোচ্চ যত দিতে পারে, প্রায়ই ১০,৪৮,৫৭৬sysctlসীমা বসানোই ব্যর্থ
fs.file-maxপুরো MachinesysctlENFILE
fs.epoll.max_user_watchesপ্রতি User-এ Register করা epoll Entrysysctlepoll_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() ভুলে গেছে। সীমায় পৌঁছানো পর্যন্ত Socket CLOSE_WAIT-এ পড়ে থাকে, আর সীমা বাড়ালে Outage শুধু পিছিয়ে যায়। CLOSE_WAIT বাড়তে থাকা সবসময় তোমার Application-এর Bug, Network-এর না।

File Descriptor ফুরিয়ে গেলে

প্রতিটা Socket একটা Descriptor। Event Loop-এর ভেতর থেকে সীমাটা কেমন দেখায়, কেন ১০০% CPU-তে ঘুরতে পারে, আর Descriptor Leak কীভাবে একই জায়গায় নিয়ে যায়, দেখো।

পরিস্থিতি

Default Soft Limit অবাক করার মতো ছোট।

Event Loopulimit -n = 1024১,০২৪ Descriptor খোলা
KernelAccept Queue
Client-রা
১,০২০ নম্বর Connection
ধাপ 1 / 3
1

Log File, epoll-এর Descriptor আর Listening Socket-ও গোনা হয়, তাই ১,০২৪ Connection-এর একটু আগেই সীমা চলে আসে।

#Regular File সবসময় “Ready”

Readiness Model বানানো হয়েছিল এমন জিনিসের জন্য, যার Data বাইরে থেকে আসে, যেমন Socket আর Pipe। Regular File আলাদা:

  • select আর poll Regular 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 PoolRead-টা একটা সাহায্যকারী Thread-কে দাও, শেষ হলে খবর পাওচিরচেনা: Node.js-এর File কাজে libuv-এর Pool, Nginx-এ aio threads। সহজ, সব জায়গায় চলে
io_uringRead জমা দাও, ফল তুলে নাওসত্যিকারের Asynchronous File I/O, Buffered Read-এও
preadv2 আর RWF_NOWAITBlock না হলে তবেই পড়ে; নইলে 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 কোনো কাজ করে না।

Event Loop
KernelPage Cache
Disk
epoll_ctl(EPOLL_CTL_ADD, file_fd)
ধাপ 1 / 4
1

Loop একটা File-কে Socket-এর মতো নজরে রাখতে চায়।

#Readiness আর Completion, আর io_uring

#দুটো আলাদা প্রশ্ন

Readiness (Reactor)Completion (Proactor)
তুমি বলো“পড়া গেলে বলো”“এই Buffer-এ পড়ে রাখো, হলে জানিও”
I/O করে কেতুমি, আরেকটা System Call-এKernel
Buffer লাগেশুধু Socket Ready হলেকাজ শুরুর মুহূর্ত থেকে
File-এ চলেনাহ্যাঁ
উদাহরণselect, poll, epoll, kqueueIOCP, 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_enter Call। 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-এ পাঠাও, তারপর দাম কী, সেটাও দেখো।

পরিস্থিতি

“পড়া গেলে বলো।” তারপর তুমি পড়ো।

তোমার Program
Shared RingSQ + CQ
Kernel
epoll_wait: ৩টা Socket Ready
ধাপ 1 / 2
1

Kernel বলে তিনটা Socket Ready। Data এখনো নড়েনি।

#কোনটা বাছবে

পরিস্থিতিযুক্তিসঙ্গত পছন্দকেন
ভেতরের Service, কয়েকশো Connection, বেশিরভাগ CPU বা Database-এর কাজBlocking Thread আর একটা PoolCode সহজ; অপেক্ষা তো Database-এ, তার Pool-ই আগে থামাবে
Chat, Push, Long Polling, হাজার হাজার চুপচাপ Connectionepoll বা kqueue দিয়ে Event Loopচুপচাপ Connection-এর খরচ প্রায় শূন্য হতে হবে
Proxy, Load Balancer বা Gatewayপ্রতি Core-এ একটা Event Loopপ্রায় সবটাই অপেক্ষা, প্রতি Request-এ CPU অল্প
Blocking ধাঁচের অনেক Business Code আর অনেক ConnectionGoroutine বা Java Virtual ThreadCode সহজ, নিচে Runtime epoll-এ চালায় (Lesson ৩.৬)
খুব বেশি I/O-র Storage Engine বা Databaseঅনুমতি থাকলে io_uring, সাথে FallbackBatching আর File-এর আসল Asynchrony কাজে আসে
সব Platform-এ চলার মতো Librarylibuv, বা তোমার Language-এর Runtimeepoll, 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 SpikeLoop-এ Blocking Call: Disk Read, DNS, ভারী CPUPool-এ সরাও; 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 EPERMseccomp Profile বা kernel.io_uring_disabledFallback করো; 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)-এর পার্থক্য হলো কোন ধাপে কে অপেক্ষা করে। epoll Multiplexing-এর একটা উপায়, ষষ্ঠ কোনো 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_uring Shared Ring দিয়ে দেয় Batching, File-এর আসল Asynchrony আর কম System Call। seccomp প্রায়ই এটা আটকায়, তাই Fallback সবসময় রাখো।

#নিজেকে যাচাই করো