I/O মডেল — blocking থেকে epoll পর্যন্ত, আর C10K সমস্যা যেটা এই পুরো ইতিহাসটা চালিয়েছে
I/O Models: From Blocking to epoll
প্রতিটা I/O অপারেশনে একটা মৌলিক প্রশ্ন লুকিয়ে থাকে -- ডেটা প্রস্তুত না থাকলে প্রক্রিয়াটার কী হবে? এই একটা প্রশ্নের পাঁচটা ভিন্ন উত্তর থেকে পাঁচটা I/O model তৈরি হয়েছে, আর প্রতিটার জন্ম হয়েছে আগেরটার একটা নির্দিষ্ট সীমাবদ্ধতা ভাঙতে গিয়ে। thread-per-connection মেমরি আর context-switch-এ ডুবে যায়; select 1024 fd-তে আটকে যায় আর প্রতি কলে O(n) স্ক্যান করে; poll সীমা তোলে কিন্তু O(n) থেকেই যায়; epoll কার্নেল-পাশে একটা registered interest list রেখে O(1) readiness দেয় -- আর এটাই ছিল C10K সমস্যার আসল সমাধান। এই লেসনে পাঁচটা মডেল, তিনটা বাস্তবায়ন, আর একটা প্রোডাকশন-গ্রেড echo server।
আগে এটা বুঝি
গত লেসনে VFS দেখিয়েছিল একটা read() কীভাবে ext4, NFS, procfs, FUSE-এ সমানভাবে কাজ করে। কিন্তু একটা প্রশ্ন সচেতনভাবে এড়িয়ে গিয়েছিলাম: যখন সেই ডেটা তাৎক্ষণিক প্রস্তুত না থাকে — একটা socket-এ এখনো কোনো প্যাকেট আসেনি — তখন read() কল করা প্রক্রিয়াটার কী হয়?
উত্তরটা “নির্ভর করে”, আর ঠিক কীসের উপর নির্ভর করে সেটাই এই পুরো লেসনের বিষয়। Unix-এর ইতিহাসে এই একটা প্রশ্নের পাঁচটা ভিন্ন উত্তর তৈরি হয়েছে — পাঁচটা I/O model — আর প্রতিটার জন্ম হয়েছে আগেরটার একটা নির্দিষ্ট, বেদনাদায়ক সীমাবদ্ধতা থেকে।
গল্পটা শুরু করা যাক একটা সংখ্যা দিয়ে যেটা ১৯৯৯ সালে ইন্টারনেটকে চমকে দিয়েছিল। Dan Kegel একটা প্রবন্ধ লিখলেন যার শিরোনাম ছিল নিছক একটা সংখ্যা — C10K: একটা সার্ভার কি একসাথে ১০,০০০ client সামলাতে পারে? সেই সময়ের হার্ডওয়্যারে (কয়েকশ MHz CPU, কয়েকশ MB RAM) এটা একটা গুরুতর প্রশ্ন ছিল, আর তার উত্তর খুঁজতে গিয়েই আজকের epoll, kqueue, আর শেষ পর্যন্ত io_uring-এর জন্ম।
মূল ধারণা
পাঁচটা মডেল, একটা প্রশ্নের পাঁচটা উত্তর
প্রতিটা I/O model আসলে দুইটা প্রশ্নের একটা সংমিশ্রণ: (১) ডেটা প্রস্তুত না থাকলে আমার thread কি block করে? আর (২) প্রকৃত ডেটা কপি করার কাজটা কে করে, কখন?
| Model | ডেটা প্রস্তুত না থাকলে? | প্রকৃত I/O কে করে | Thread সংখ্যা প্রয়োজন |
|---|---|---|---|
| Blocking | Thread ঘুমিয়ে পড়ে | Kernel, thread জেগে ওঠার আগে | প্রতি concurrent কাজে একটা |
| Non-blocking (polling) | সাথে সাথে EAGAIN/EWOULDBLOCK ফেরত | Application, বারবার চেষ্টা করে | একটা, কিন্তু CPU-ক্ষুধার্ত busy-loop |
| I/O multiplexing | select/poll/epoll block করে, fd না | Application, “ready” জানার পরে | একটা, বহু fd পাহারা দেয় |
| Signal-driven | Thread free, সংকেত এলে handler চলে | Application, signal handler-এ | একটা, কিন্তু signal handling জটিল |
| Asynchronous (true AIO) | Thread কখনোই block করে না, কাজও করে না | Kernel, সম্পূর্ণভাবে | একটা, শুধু completion reap করে |
লক্ষ করুন প্রথম চারটার একটা মিল আছে — এরা সবাই readiness-ভিত্তিক: প্রক্রিয়াটা কোনো না কোনোভাবে জানতে চায় “এখন কি ডেটা আছে”, তারপর নিজে read()/write() কল করে প্রকৃত কাজটা করে। শুধু পঞ্চম মডেল — সত্যিকারের asynchronous — completion-ভিত্তিক: কাজটা জমা দাও, kernel নিজে করুক, পরে ফলাফল জানাক। io_uring লেসনে আমরা এই পার্থক্যটা বিস্তারিত দেখেছি; এই লেসনে প্রথম চারটার ইতিহাস আর মেকানিক্স।
Blocking I/O — সবচেয়ে সরল, সবচেয়ে ব্যয়বহুল মডেল
ডিফল্ট আচরণ। read(fd, buf, n) কল করলে, ডেটা প্রস্তুত না থাকলে thread ঘুমিয়ে পড়ে — kernel তাকে TASK_INTERRUPTIBLE অবস্থায় একটা wait queue-তে রাখে, scheduler অন্য কোনো runnable thread চালায়, আর ডেটা আসার সাথে সাথে (একটা interrupt-এর মাধ্যমে) thread-টা আবার TASK_RUNNING হয়ে scheduler-এর queue-তে ফেরে।
ssize_t n = read(fd, buf, sizeof buf); /* ডেটা না থাকলে এখানেই থেমে যায় */
/* এই লাইনে পৌঁছালে মানে n বাইট প্রস্তুত (বা EOF, বা error) */সরলতার দাম হলো concurrency = thread সংখ্যা। ১০,০০০ concurrent connection সামলাতে হলে ১০,০০০ thread লাগবে — কারণ যেকোনো মুহূর্তে যেকোনো সংখ্যক connection block হয়ে থাকতে পারে, আর একটা thread একবারে একটাই blocking call-এ থাকতে পারে।
Non-blocking I/O — polling-এর মূল্য
fcntl(fd, F_SETFL, O_NONBLOCK) দিয়ে একটা fd-কে non-blocking করা যায়। এরপর read() ডেটা না থাকলে অপেক্ষা না করেই -1 আর errno = EAGAIN (বা EWOULDBLOCK, POSIX-এ দুটো সমার্থক) ফেরত দেয়।
ssize_t n = read(fd, buf, sizeof buf);
if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
/* এখন ডেটা নেই -- পরে আবার চেষ্টা করো */
}এটা একা কখনো ব্যবহারযোগ্য সমাধান নয় — “পরে আবার চেষ্টা করো” মানে যদি এটাই একমাত্র প্রক্রিয়া হয়, তাহলে একটা busy-loop-এ CPU ১০০% পুড়বে হাজার হাজার বার EAGAIN পাওয়ার জন্য। কিন্তু এই মোডটা multiplexing-এর একটা প্রয়োজনীয় উপাদান — select/poll/epoll “ready” বলার পরেও প্রকৃত read() non-blocking রাখা edge-triggered ব্যবহারে বাধ্যতামূলক, যা পরে দেখব।
I/O Multiplexing — একটা thread, বহু fd পাহারা
এখানেই আসল সমাধানের প্রথম ধাপ শুরু। ধারণাটা: একটা thread-কে একসাথে অনেকগুলো fd-র উপর অপেক্ষা করতে দাও, আর যেকোনো একটা প্রস্তুত হলেই জাগিয়ে দাও।
thread একবার ঘুমায় ৫০০টা fd-র উপর
│
▼
[কোনো একটা fd প্রস্তুত হলো]
│
▼
thread জাগে, জানে ঠিক কোন fd(গুলো) প্রস্তুত
│
▼
প্রতিটার জন্য non-blocking read()/write()এই বিভাগে তিনটা API-র ইতিহাস — select, poll, epoll — যেখানে প্রতিটা আগেরটার একটা নির্দিষ্ট সমস্যা ঠিক করেছে।
select — প্রথম চেষ্টা, তিনটা গভীর সীমাবদ্ধতা
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);fd_set একটা bitmap — প্রতিটা বিট একটা fd নম্বর represent করে। তিনটা সীমাবদ্ধতা, প্রতিটা কাঠামোগত (bug নয়, নকশার সীমা):
| সীমাবদ্ধতা | বিস্তারিত |
|---|---|
FD_SETSIZE = 1024 | Bitmap-এর আকার compile-time-এ নির্ধারিত, সাধারণত 1024। এর বেশি fd নম্বরের কোনো bit-ই bitmap-এ ধরে না — 1025+ fd নম্বর ব্যবহার করলে undefined behavior |
| O(n) rescan প্রতি কলে | select ফেরত দেয় শুধু “কতগুলো fd ready”, কোনগুলো তা নয় — caller-কে পুরো bitmap আবার loop করে FD_ISSET() দিয়ে প্রতিটা fd চেক করতে হয়, fd সংখ্যা যতই হোক |
| fd_set প্রতি কলে ধ্বংস হয় | select input bitmap-টাকেই output হিসেবে পুনর্ব্যবহার করে (in-place modify) — মানে প্রতিবার কল করার আগে FD_ZERO + সব fd আবার FD_SET করে বিটম্যাপ পুনর্নির্মাণ করতে হয় |
তিনটা মিলিয়ে ফলাফল: ৫০০টা fd পাহারা দিতে প্রতি iteration-এ ৫০০ বার bitmap পুনর্নির্মাণ + kernel-এ ৫০০ বিট কপি + ফেরার পর আবার ৫০০ বার scan। fd সংখ্যা বাড়লে এই খরচ linearly বাড়ে, connection সংখ্যার সাথে সাথে।
poll — সীমা তুলে দিল, কিন্তু O(n) থেকেই গেল
struct pollfd { int fd; short events; short revents; };
int poll(struct pollfd *fds, nfds_t nfds, int timeout);poll bitmap-এর বদলে একটা array নেয় — কোনো compile-time সীমা নেই, যত ইচ্ছা fd রাখা যায় (শুধু memory-র সীমা)। আর প্রতিটা struct pollfd-এ আলাদা events/revents ফিল্ড থাকায় array পুনর্নির্মাণের দরকার নেই (fd/events অপরিবর্তিত থাকে, শুধু revents প্রতি কলে রিসেট)।
কিন্তু মৌলিক সমস্যাটা থেকেই যায়: kernel-কে এখনো পুরো array স্ক্যান করতে হয় কে ready তা বের করতে, আর caller-কেও ফেরত আসা array পুরোটা স্ক্যান করতে হয় revents চেক করতে। ৫০০টা fd-তে ১টা ready হলেও, kernel আর caller দুইজনকেই ৫০০টা entry দেখতে হয়। এটাই O(n) rescan সমস্যা, শুধু bitmap থেকে array-তে সরে গেছে।
| select | poll | |
|---|---|---|
| fd সীমা | 1024 (FD_SETSIZE) | নেই (memory-সীমিত) |
| প্রতি কলে পুনর্নির্মাণ | হ্যাঁ (in-place ধ্বংস) | না (events অপরিবর্তিত থাকে) |
| ready fd খুঁজতে জটিলতা | O(n) | O(n) |
| kernel-এ ready তালিকা মনে রাখা | না | না |
epoll — কেন এবং কীভাবে O(1)
মূল অন্তর্দৃষ্টিটা সরল: select/poll-এর সমস্যা হলো প্রতিটা কলে “কোন fd-গুলো নিয়ে আগ্রহী” এই তথ্যটা আবার নতুন করে kernel-কে জানাতে হয়, যদিও বেশিরভাগ সময় fd-এর তালিকা কল থেকে কলে প্রায় একই থাকে (একটা সার্ভারের connection set ধীরে ধীরে বদলায়, প্রতি epoll_wait-এ নয়)।
epoll তাই দুইটা অপারেশনকে সম্পূর্ণ আলাদা করে দিল:
int epoll_create1(int flags); /* একবার -- একটা kernel-পাশে object তৈরি */
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *ev); /* fd যোগ/বাদ/পরিবর্তন -- যখনই দরকার */
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout); /* শুধু ready fd-গুলো ফেরত */epoll_ctl(EPOLL_CTL_ADD, ...) fd-টাকে kernel-এর একটা registered interest list-এ (ভেতরে একটা red-black tree) একবার যোগ করে। তারপর প্রতিটা epoll_wait শুধু ready হওয়া fd-গুলোর একটা আলাদা ready list থেকে পড়ে — এই ready list interrupt handler-এ populate হয়, যখনই কোনো fd-তে ডেটা আসে। ফলাফল: epoll_wait-এর জটিলতা O(ready fd সংখ্যা), মোট registered fd সংখ্যার সাথে সম্পর্কহীন।
| select/poll | epoll | |
|---|---|---|
| Interest জানানো | প্রতি কলে সম্পূর্ণ তালিকা | একবার epoll_ctl, kernel মনে রাখে |
| Ready fd খুঁজতে জটিলতা | O(মোট fd) | O(ready fd) |
| ১ লাখ fd-তে ১টা ready হলে | ১ লাখ entry স্ক্যান | ১টা entry ফেরত |
select/poll প্রতি কল:
[fd0][fd1][fd2]...[fd999] ◄── পুরো তালিকা kernel-এ কপি, পুরোটা স্ক্যান
│
▼ (মাত্র fd42, fd781 ready)
caller আবার পুরো array স্ক্যান করে বের করে কোনগুলো ready
epoll:
registered interest (একবার তৈরি, red-black tree):
[fd0][fd1][fd2]...[fd999] ◄── এটা প্রতি wait-এ স্ক্যান হয় না
ready list (interrupt handler populate করে, শুধু যেগুলো আসলে ready):
[fd42][fd781] ◄── epoll_wait শুধু এটুকু ফেরত দেয়Level-triggered বনাম edge-triggered — একটা সূক্ষ্ম কিন্তু গুরুত্বপূর্ণ পার্থক্য
epoll-এর দুইটা মোড আছে, epoll_ctl-এ EPOLLLT (ডিফল্ট) বা EPOLLET flag দিয়ে বেছে নেওয়া যায়।
Level-triggered (LT) — যতক্ষণ buffer-এ পড়ার মতো ডেটা আছে, ততক্ষণ প্রতিটা epoll_wait সেই fd-কে ready হিসেবে দেখাতেই থাকবে। আপনি পুরো buffer না পড়লেও সমস্যা নেই — পরের বার আবার জানাবে। এটা select/poll-এর স্বাভাবিক আচরণেরই সমতুল্য, তাই বেশিরভাগ প্রোগ্রামারের কাছে স্বজ্ঞাত।
Edge-triggered (ET) — fd শুধু তখনই ready হিসেবে জানানো হয় যখন তার অবস্থা বদলায় — খালি থেকে non-খালি হয়। একবার জানানোর পর, আপনি পুরোটা না পড়লেও, দ্বিতীয়বার আর জানানো হবে না যতক্ষণ না নতুন কিছু আসে (state আবার বদলায়)।
Level-triggered: Edge-triggered:
ডেটা আসে → ready জানানো ডেটা আসে → ready জানানো (একবার)
আংশিক পড়া → এখনো ready জানানো আংশিক পড়া → আর জানানো হবে না!
আরও পড়া → এখনো ready জানানো (নতুন ডেটা না আসা পর্যন্ত)তাহলে ET কেন ব্যবহার করব, যখন LT নিরাপদ ও সহজ? উত্তর: কম notification, বিশেষ করে উচ্চ throughput-এ। একটা ব্যস্ত connection-এ LT মোডে প্রতিটা epoll_wait cycle-এ সেই fd আবার আবার তালিকায় আসতে থাকে যতক্ষণ ডেটা আছে — ET-তে শুধু নতুন অবস্থা-পরিবর্তনে একবার। বেশিরভাগ high-performance সার্ভার (nginx, redis-এর কিছু কনফিগারেশন) ET ব্যবহার করে ঠিক এই কারণে, কিন্তু সঠিক drain-loop discipline মেনে।
kqueue — BSD/macOS-এর সমতুল্য
Linux-এর বাইরে, FreeBSD আর macOS-এ একই সমস্যার সমাধান kqueue। ধারণাগতভাবে একই — kernel-পাশে registered interest, O(1) readiness — কিন্তু API-টা এক ধাপ বেশি সংহত:
int kqueue(void);
int kevent(int kq, const struct kevent *changelist, int nchanges,
struct kevent *eventlist, int nevents, const struct timespec *timeout);epoll-এর তিনটা syscall (create1, ctl, wait) এর জায়গায় kqueue মাত্র দুইটা — kqueue() একবার, আর kevent() একই কলে register (changelist) আর wait (eventlist) দুটোই করতে পারে, একটা batch-এ। এছাড়া kqueue শুধু fd নয়, file, signal, timer, process-exit — অনেক বেশি event type একই interface-এ পাহারা দিতে পারে। macOS-এ Node.js, libuv, আর প্রায় সব cross-platform event loop এই পার্থক্যটা একটা abstraction layer দিয়ে আড়াল করে (libuv-এ uv_poll_t Linux-এ epoll, macOS-এ kqueue ব্যবহার করে — কলার একই API দেখে)।
Signal-driven I/O — সংক্ষিপ্ত উল্লেখ
পঞ্চম readiness-নিকটবর্তী মডেল, ঐতিহাসিক গুরুত্বের জন্য উল্লেখযোগ্য হলেও ব্যবহারিকভাবে বিরল: fcntl(fd, F_SETOWN, pid) আর O_ASYNC flag দিয়ে fd-তে ডেটা প্রস্তুত হলে kernel একটা SIGIO সংকেত পাঠায়। Thread কোনো select/epoll কলে block না করে অন্য কাজ করতে পারে, সংকেত এলে handler চলে।
সমস্যা: একাধিক fd থেকে সংকেত এলে কোনটা ready তা signal handler থেকে জানার নির্ভরযোগ্য উপায় নেই (POSIX signal queue-তে coalescing হতে পারে), আর signal handler-এর ভেতরে করণীয় কাজের উপর ভয়াবহ সীমাবদ্ধতা (async-signal-safe ফাংশন ছাড়া কিছু কল করা যায় না)। ব্যবহারিকভাবে epoll-এর তুলনায় এটা প্রায় সবসময় খারাপ পছন্দ, আজকাল mostly ঐতিহাসিক আগ্রহের বিষয়।
ভেতরে কী ঘটছে
Thread-per-connection কেন ভেঙে পড়ে — দুইটা স্বাধীন কারণ
C10K সমস্যাটা ভালোভাবে বুঝতে thread-per-connection মডেলের ভেতরের যন্ত্রপাতি দেখা দরকার। দুইটা সম্পূর্ণ স্বাধীন কারণে এটা ভেঙে পড়ে — একটা memory, একটা CPU সময়।
- accept() ফেরত দেয়, নতুন connectionএকটা নতুন client socket fd
- pthread_create() -- নতুন thread বরাদ্দkernel stack (৮ KB, প্রায়ই), userspace stack (ডিফল্ট ৮ MB Linux-এ, ulimit -s দিয়ে দেখা যায়)
- thread স্বাধীনভাবে চলে, blocking read()এই thread-টা এখন scheduler-এর runqueue-তে একটা আলাদা entity
- ১০,০০০ connection = ১০,০০০ thread১০,০০০ × ৮ MB stack = ৮০ GB virtual memory (বেশিরভাগ commit হয় না, কিন্তু address space reserve হয়)
- context switch storm১০,০০০ thread যখন সবাই মাঝেমধ্যে runnable হয়, scheduler-কে ক্রমাগত তাদের মধ্যে সুইচ করতে হয় -- প্রতিটা সুইচে TLB flush, cache pollution
দুইটা খরচ আলাদা করে দেখা যাক।
মেমরি খরচ। Linux-এ ডিফল্ট thread stack size ৮ MB (ulimit -s)। এটা virtual memory, পুরোটা physical RAM-এ commit হয় না (demand paging) — কিন্তু address space নিজেই একটা সীমিত সম্পদ, আর প্রতিটা thread-এর kernel-পাশেও একটা task_struct (কয়েক KB) + kernel stack (৮ KB, swap করা যায় না) লাগে। ১০,০০০ thread-এ শুধু kernel stack-এই ৮০ MB non-swappable memory — সেই মেশিনে যদি ১ GB RAM থাকে (১৯৯৯-এর প্রেক্ষাপটে বাস্তবসম্মত), এটা একাই উল্লেখযোগ্য চাপ।
Context-switch খরচ। প্রতিটা context switch-এ CPU register save/restore, TLB flush (বা ASID/PCID না থাকলে সম্পূর্ণ flush), আর সবচেয়ে খারাপ — CPU cache-এর warm data হারিয়ে যাওয়া (নতুন thread-এর কাজের ডেটা দিয়ে cache line replace হয়)। এই খরচটা Level 7 (লেসন ৭, context-switching)-এ বিস্তারিত দেখা হয়েছে বা হবে; এখানে গুরুত্বপূর্ণ হলো এই খরচ thread সংখ্যার সাথে non-linearly বাড়ে — বেশি thread মানে বেশি ঘন ঘন সুইচ, প্রতিটা সুইচে বেশি cache miss, কারণ প্রতিটা thread-এর “working set” একে অপরকে cache থেকে তাড়িয়ে দেয়।
epoll-এর ভেতরে — interrupt থেকে ready list পর্যন্ত
epoll_wait-এর O(1) দাবিটা আসলে কোথা থেকে আসে তা একধাপ গভীরে দেখা যাক।
1. NIC-তে প্যাকেট আসে → hardware interrupt
2. Interrupt handler (দ্রুত অংশ) → softirq তে network stack processing schedule করে
3. Network stack প্যাকেট socket buffer-এ রাখে
4. ★ socket-টা যদি কোনো epoll interest list-এর সদস্য হয়,
সেই socket-এর wait queue callback (ep_poll_callback) চলে
5. সেই callback socket-এর epitem-টাকে epoll-এর ready list-এ যোগ করে (O(1) insert)
6. epoll_wait ঘুমন্ত থাকলে, তাকে জাগানো হয়
7. epoll_wait জেগে ready list থেকে entry-গুলো copy করে userspace-এ ফেরত দেয়গুরুত্বপূর্ণ অংশটা ধাপ ৪-৫ — kernel প্রতিটা প্যাকেট আসার সময়েই জানে কোন epoll instance আগ্রহী, কারণ epoll_ctl(ADD) করার সময়েই socket-এর wait queue-তে একটা callback রেজিস্টার হয়ে গেছে। epoll_wait কল হওয়ার সময় কোনো স্ক্যান লাগে না — ready list ইতিমধ্যে populate হয়ে বসে আছে, শুধু সেটা copy করে ফেরত দেওয়া বাকি। এটাই “kernel-side registered interest” কথাটার প্রকৃত অর্থ।
উদাহরণ
তিনটা স্থাপত্যের সংখ্যায় তুলনা — ১০,০০০ connection
একটা কংক্রিট হিসাব — ১০,০০০ concurrent connection, প্রতিটা মাঝে মাঝে সক্রিয় (৫% সবসময় ready, বাকিরা idle — একটা বাস্তবসম্মত chat/notification সার্ভারের প্যাটার্ন)।
| স্থাপত্য | Thread সংখ্যা | মোট stack memory | প্রতি “চেক” খরচ | context switch/সেকেন্ড (আনুমানিক) |
|---|---|---|---|---|
| Thread-per-connection | ১০,০০০ | ১০,০০০ × ৮ MB = ৮০ GB (virtual) | — (blocking, কোনো poll নেই) | কয়েক হাজার (idle thread-ও scheduler-এর runqueue বিবেচনায় আসে) |
select, single thread | ১ | নগণ্য | O(১০,০০০) প্রতি cycle — kernel + userspace দুই দিকেই | নগণ্য (single thread) |
epoll, single thread | ১ | নগণ্য | O(৫০০) প্রতি cycle (শুধু ready fd, ৫% এর হিসাবে) | নগণ্য |
ধরা যাক প্রতিটা “চেক” এর মূল খরচ প্রতি fd ৫০ ns (bitmap/array touch + branch):
সেকেন্ডে ১০০০ বার এই cycle চললে (একটা ব্যস্ত event loop-এ অস্বাভাবিক নয়):
| প্রতি সেকেন্ডে মোট scan খরচ | |
|---|---|
select | ৫০০ ms — অর্ধেক CPU সময় শুধু scan-এই |
epoll | ২৫ ms — ২.৫% |
২০× পার্থক্য, শুধু এই একটা connection সংখ্যায়। আর connection সংখ্যা যদি ১০ লাখ হয় (আধুনিক নয়নসেকেন্ড স্কেলে বাস্তবসম্মত), select ব্যবহারই করা যাবে না (FD_SETSIZE সীমা ছাড়িয়ে যাবে), আর epoll-এর খরচ ready fd সংখ্যার (৫%, অর্থাৎ ৫০,০০০) সমানুপাতিক থেকে যাবে — মোট connection সংখ্যা যত বাড়ুক না কেন।
নিজে চালিয়ে দেখুন
তিনটা সার্ভার স্থাপত্য -- thread-per-conn, select, epoll -- মেমরি ও throughput তুলনা
তিনটা ছোট সার্ভার লাগবে — একটা সরল thread-per-connection echo সার্ভার, একটা select-ভিত্তিক, আর build সেকশনের epoll সার্ভার। প্রথমে thread-per-connection সংস্করণ:
# thread_srv.py -- প্রতি connection একটা thread
import socket, threading
def handle(conn):
while True:
data = conn.recv(4096)
if not data: break
conn.sendall(data)
conn.close()
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("0.0.0.0", 9001)); srv.listen(2000)
print("thread-per-conn সার্ভার :9001")
while True:
conn, _ = srv.accept()
threading.Thread(target=handle, args=(conn,), daemon=True).start()লোড টুল দিয়ে অনেক connection বানান (একটা সহজ ক্লায়েন্ট স্ক্রিপ্ট বা nc লুপ ব্যবহার করা যায়; বড় স্কেলে wrk/tcpkali ভালো):
python3 thread_srv.py &
SRV_PID=$!
# ৫০০০টা connection খুলে ধরে রাখি (idle), memory মাপার জন্য
python3 - <<'EOF'
import socket, time
conns = []
for i in range(5000):
s = socket.create_connection(("127.0.0.1", 9001))
conns.append(s)
print("৫০০০ connection খোলা")
time.sleep(60)
EOFদ্বিতীয় terminal-এ memory মাপুন:
grep VmRSS /proc/$SRV_PID/status
ps -o nlwp= -p $SRV_PID # thread সংখ্যাVmRSS: 412364 kB
5001৫০০০ connection-এ ~৪১২ MB RSS আর ৫০০১ thread (৫০০০ handler + ১ main)। এবার build সেকশনের epoll সার্ভার দিয়ে একই পরীক্ষা:
gcc -O2 epoll_echo.c -o epoll_echo && ./epoll_echo 9002 &
SRV_PID=$!
# একই ৫০০০ connection বানান, epfd 9002-তে
grep VmRSS /proc/$SRV_PID/status
ps -o nlwp= -p $SRV_PIDVmRSS: 18220 kB
1
| thread-per-conn | epoll | |
|---|---|---|
| RSS (৫০০০ idle connection) | ~৪১২ MB | ~১৮ MB |
| Thread সংখ্যা | ৫০০১ | ১ |
| অনুপাত | — | ~২৩× কম মেমরি |
ss -s দিয়ে সামগ্রিক socket পরিসংখ্যানও দেখা যায়:
ss -sTotal: 5012 (kernel 5024)
TCP: 5008 (estab 5006, closed 2, orphaned 0, timewait 2)এটা connection সংখ্যা নিশ্চিত করে, কিন্তু server process-এর ভেতরের thread/memory পার্থক্যটা দেখায় না — সেজন্যই /proc/<pid>/status দরকার।
thread সংখ্যা বাড়লে thread-per-connection-এর memory footprint linearly বাড়ে, আর epoll-এর CPU খরচ connection সংখ্যার প্রায় স্বাধীন থাকে (ready fd সংখ্যার উপর নির্ভরশীল, মোট fd সংখ্যার উপর নয়)।
edge-triggered বাগ নিজের চোখে দেখা -- partial read আর hang
Build সেকশনের epoll_echo.c-এর একটা ইচ্ছাকৃত-বাগযুক্ত সংস্করণ বানান যেখানে drain loop-এ একবারই read() করা হয় (EAGAIN পর্যন্ত না):
/* buggy_et.c -- শুধু একবার read(), EAGAIN পর্যন্ত loop করে না */
/* (বাকি অংশ epoll_echo.c-এর মতোই, শুধু handle_client()-এ পরিবর্তন) */
ssize_t n = read(fd, buf, sizeof buf); /* একবারই */
if (n > 0) write(fd, buf, n);
/* কোনো while(1) loop নেই -- EAGAIN আসার আগেই থেমে গেল */কম্পাইল করে চালান, তারপর একসাথে অনেক ডেটা পাঠান যাতে single read()-এ পুরোটা না আসে:
gcc -O2 buggy_et.c -o buggy_et && ./buggy_et 9003 &
python3 - <<'EOF'
import socket, time
s = socket.create_connection(("127.0.0.1", 9003))
s.sendall(b"X" * 200000) # একবারে বড় burst -- kernel buffer-এ ধাপে ধাপে জমা হবে
time.sleep(1)
try:
s.settimeout(3)
reply = s.recv(200000)
print(f"ফেরত পেলাম {len(reply)} বাইট")
except socket.timeout:
print("TIMEOUT -- connection hang হয়ে গেছে, বাকি ডেটা কখনো আসবে না")
EOFTIMEOUT -- connection hang হয়ে গেছে, বাকি ডেটা কখনো আসবে নাপ্রথম read()-এ যা এসেছিল (kernel socket buffer-এর আকার অনুযায়ী, প্রায়ই ~৬৪-১৩০ KB) তা echo হয়েছে, কিন্তু বাকিটা socket buffer-এ পড়ে আছে — আর যেহেতু নতুন কোনো ডেটা আসছে না (client শুধু অপেক্ষা করছে), edge-triggered epoll_wait আর কখনো এই fd-কে ready দেখাবে না। এখন সঠিক সংস্করণে (epoll_echo.c, drain-to-EAGAIN সহ) একই পরীক্ষা চালিয়ে নিশ্চিত করুন সম্পূর্ণ ২০০,০০০ বাইট ফেরত আসে।
edge-triggered মোডে EAGAIN পর্যন্ত drain না করলে connection স্থায়ীভাবে stall হয়ে যায় -- এটা যেকোনো ET-based সার্ভারের সবচেয়ে সাধারণ বাগ।
নিজে বানান
একটা সম্পূর্ণ non-blocking, edge-triggered epoll echo server (~১৫০ লাইন C)
- listening socket তৈরি করে non-blocking সেট করুন
- epoll instance বানিয়ে listening socket EPOLLIN | EPOLLET দিয়ে যোগ করুন
- main loop-এ epoll_wait -- নতুন connection হলে accept-loop (EAGAIN পর্যন্ত), ডেটা থাকলে read-write drain-loop (EAGAIN পর্যন্ত)
- কম্পাইল করে multiple client দিয়ে টেস্ট করুন
- strace -c দিয়ে দেখুন syscall প্যাটার্ন কেমন -- একটা epoll_wait বনাম বহু accept/read/write
/* epoll_echo.c -- সম্পূর্ণ non-blocking, edge-triggered epoll echo server
* বিল্ড: gcc -O2 -Wall epoll_echo.c -o epoll_echo
* চালান: ./epoll_echo 9002
*/
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/socket.h>
#include <sys/epoll.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#define MAX_EVENTS 1024
#define BUF_SZ 65536
/* ---- একটা fd-কে non-blocking করা -- readiness-ভিত্তিক প্রতিটা মডেলের ভিত্তি ---- */
static int set_nonblocking(int fd) {
int flags = fcntl(fd, F_GETFL, 0);
if (flags < 0) return -1;
return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
static int make_listener(int port) {
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) { perror("socket"); exit(1); }
int one = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one);
struct sockaddr_in addr = {0};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = INADDR_ANY;
addr.sin_port = htons(port);
if (bind(fd, (struct sockaddr *)&addr, sizeof addr) < 0) {
perror("bind"); exit(1);
}
if (listen(fd, SOMAXCONN) < 0) { perror("listen"); exit(1); }
set_nonblocking(fd);
return fd;
}
/* ---- একটা client fd থেকে যা পড়া যায় সব পড়ে সাথে সাথে echo করা,
* EAGAIN পর্যন্ত -- এই drain-loop-টাই ET মোডের কেন্দ্রীয় নিয়ম ---- */
static void drain_and_echo(int epfd, int fd) {
char buf[BUF_SZ];
for (;;) {
ssize_t n = read(fd, buf, sizeof buf);
if (n > 0) {
/* সরলতার জন্য পুরোটা এক write()-এ পাঠাচ্ছি; production কোডে
* partial write সামলাতে একটা per-connection output buffer
* আর EPOLLOUT-এর জন্য register করা লাগবে -- extension ৩ দেখুন */
ssize_t off = 0;
while (off < n) {
ssize_t w = write(fd, buf + off, n - off);
if (w < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK) continue;
goto closeit;
}
off += w;
}
continue; /* আরও পড়ার চেষ্টা করো -- হয়তো buffer-এ আরও আছে */
}
if (n == 0) goto closeit; /* peer বন্ধ করেছে */
if (errno == EAGAIN || errno == EWOULDBLOCK) return; /* ★ drain সম্পূর্ণ */
if (errno == EINTR) continue;
goto closeit; /* প্রকৃত error */
}
closeit:
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
close(fd);
}
/* ---- নতুন connection-গুলো accept-loop-এ EAGAIN পর্যন্ত নেওয়া --
* listening socket-ও ET মোডে, তাই একই drain-নিয়ম প্রযোজ্য ---- */
static void accept_all(int epfd, int lfd) {
for (;;) {
int cfd = accept(lfd, NULL, NULL);
if (cfd < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK) return; /* ★ */
if (errno == EINTR) continue;
perror("accept");
return;
}
set_nonblocking(cfd);
int one = 1;
setsockopt(cfd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof one);
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET; /* edge-triggered */
ev.data.fd = cfd;
if (epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &ev) < 0) {
perror("epoll_ctl ADD"); close(cfd);
}
}
}
int main(int argc, char **argv) {
int port = argc > 1 ? atoi(argv[1]) : 9002;
int lfd = make_listener(port);
int epfd = epoll_create1(0);
if (epfd < 0) { perror("epoll_create1"); exit(1); }
struct epoll_event ev = { .events = EPOLLIN | EPOLLET, .data.fd = lfd };
epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &ev);
struct epoll_event events[MAX_EVENTS];
printf("epoll echo সার্ভার :%d (non-blocking, edge-triggered)\n", port);
for (;;) {
int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
if (n < 0) {
if (errno == EINTR) continue;
perror("epoll_wait"); break;
}
for (int i = 0; i < n; i++) {
int fd = events[i].data.fd;
if (events[i].events & (EPOLLHUP | EPOLLERR)) {
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
close(fd);
continue;
}
if (fd == lfd) {
accept_all(epfd, lfd); /* নতুন connection-গুলো drain */
} else {
drain_and_echo(epfd, fd); /* ডেটা drain করে echo */
}
}
}
close(epfd);
close(lfd);
return 0;
}চালানো ও টেস্ট:
gcc -O2 -Wall epoll_echo.c -o epoll_echo
./epoll_echo 9002 &
printf 'hello epoll\n' | nc -q1 127.0.0.1 9002hello epollstrace -c দিয়ে syscall প্যাটার্ন দেখুন (কয়েকটা client দিয়ে কিছুক্ষণ লোড দিয়ে Ctrl-C):
strace -c -f -p $(pgrep epoll_echo) &
# ... কয়েক সেকেন্ড লোড ...% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
45.12 0.003201 4 800 epoll_wait
28.44 0.002018 2 950 read
19.88 0.001410 1 850 write
6.56 0.000465 2 200 50 accept4
------ ----------- ----------- --------- --------- ----------------লক্ষ করুন epoll_wait-এর কল সংখ্যা connection সংখ্যার সাথে সম্পর্কহীন — এটা event loop-এর iteration সংখ্যার সমানুপাতিক, আর প্রতিটা কল শুধু ready fd ফেরত দেয়, পুরো তালিকা নয়। accept4-এ ৫০টা error দেখাচ্ছে — এগুলোই সেই EAGAIN যা accept-loop-এর সঠিক সমাপ্তি নির্দেশ করে, বাগ নয়।
নিজে বাড়ান
১. Partial-write handling যোগ করুন। বর্তমান কোড write()-এ EAGAIN পেলে busy-loop করে (continue), যা EPOLLOUT-এর সঠিক ব্যবহার নয়। একটা per-connection output buffer রাখুন; write() EAGAIN দিলে বাকিটা buffer-এ রেখে সেই fd-কে EPOLLIN | EPOLLOUT | EPOLLET-এ পরিবর্তন করুন, আর EPOLLOUT event এলে buffer থেকে বাকি অংশ পাঠান। এটাই প্রকৃত production সার্ভারের (nginx-সহ) আচরণ।
২. Level-triggered সংস্করণ বানান, তুলনা করুন। EPOLLET flag সরিয়ে একই সার্ভার চালান। strace -c-এ epoll_wait কল সংখ্যা কীভাবে বদলায় তা লক্ষ করুন — LT মোডে ব্যস্ত connection-এ প্রতি cycle-এ আবার আবার ready দেখাবে, ET-তে শুধু state-change-এ। উচ্চ throughput-এ এই পার্থক্যটা measurable হয়ে ওঠে।
৩. select-ভিত্তিক আর thread-per-connection সংস্করণ বানিয়ে তিনটার memory/throughput তুলনা করুন। Experiment সেকশনের পদ্ধতি অনুসরণ করে ১০০, ১০০০, ১০,০০০ connection-এ RSS আর perf stat-এর CPU time গ্রাফ করুন। select সংস্করণ ~১০০০ connection-এর কাছাকাছি স্পষ্টভাবে ভেঙে পড়তে দেখবেন (FD_SETSIZE সীমার কাছাকাছি)।
৪. SO_REUSEPORT দিয়ে multi-core স্কেল করুন। একাধিক প্রক্রিয়া/thread একই পোর্টে SO_REUSEPORT দিয়ে bind করে প্রতিটা নিজস্ব epoll instance চালাতে পারে — kernel connection-গুলো তাদের মধ্যে ভাগ করে দেয় (হ্যাশ-ভিত্তিক)। এটা একটা single-threaded epoll loop-কে multi-core-এ স্কেল করার আধুনিক পদ্ধতি (nginx worker_processes এভাবেই কাজ করে)।
৫. kqueue সংস্করণ লিখুন (macOS/FreeBSD-তে থাকলে)। একই echo server kqueue/kevent দিয়ে বানিয়ে API-র পার্থক্যটা হাতে-কলমে অনুভব করুন — বিশেষ করে কীভাবে একটাই kevent() কল register আর wait দুটোই করতে পারে।
বাস্তব সিস্টেমে
যেখানে এই মডেলগুলো বাস্তবে চলছে
nginx — epoll-ভিত্তিক event loop, প্রতিটা worker process edge-triggered মোডে single-threaded event loop চালায় (এই লেসনের build সেকশনের সার্ভারের একটা industrial-grade সংস্করণ ভাবা যায়)। একটা nginx worker কয়েক হাজার concurrent connection সামলায় মাত্র কয়েক MB মেমরিতে — এই লেসনের experiment সেকশনের সংখ্যাগুলোই এর ব্যাখ্যা।
Redis — একক-থ্রেডেড event loop (ae লাইব্রেরি), যেটা Linux-এ epoll, macOS/BSD-এ kqueue, আর কোনোটাই না থাকলে select-এ fallback করে — ঠিক এই লেসনের abstraction hierarchy। Redis-এর বিখ্যাত single-threaded ডিজাইনের সম্ভাব্যতাই এই readiness-ভিত্তিক event loop-এর দক্ষতার উপর নির্ভরশীল।
Node.js / libuv — libuv প্রতিটা OS-এর সেরা multiplexing mechanism বেছে নেয় (Linux: epoll, macOS/BSD: kqueue, Windows: IOCP — যা সরাসরি completion-ভিত্তিক, readiness নয়)। JavaScript-এর event loop model পুরোপুরি এই লেসনের multiplexing ধারণার উপর দাঁড়িয়ে, যদিও ডেভেলপারের কাছে callback/promise হিসেবে আড়াল করা।
HAProxy, Envoy — উচ্চ-থ্রুপুট proxy-গুলো edge-triggered epoll-এর drain-to-EAGAIN discipline নিখুঁতভাবে মেনে চলে, কারণ একটা মিসড drain মানে stall হওয়া connection — একটা লোড balancer-এর জন্য বিশেষভাবে ক্ষতিকর।
C10K থেকে C10M — Kegel-এর মূল প্রবন্ধের ২৫ বছর পরে আলোচনা এখন C10M (১ কোটি concurrent connection) নিয়ে — আর সেখানে epoll-ও যথেষ্ট নয় বলে দাবি করা হয় (kernel-এর per-connection বুককিপিং, network stack-এর lock contention)। সমাধানগুলো এখন kernel bypass (DPDK), io_uring, বা userspace network stack-এর দিকে যাচ্ছে — এই লেসনের পরের ধাপ।
যে ভুলগুলো সবাই করে
“epoll সবসময় select/poll-এর চেয়ে দ্রুত -- তাই ছোট সংখ্যক fd-তেও epoll ব্যবহার করা উচিত।”
সংখ্যায় দেখা যায় এটা সবসময় সত্য নয়। select/poll-এর সরলতা মানে কম setup overhead — কোনো kernel object তৈরি, কোনো epoll_ctl কল দরকার নেই, শুধু stack-এ একটা array বানিয়ে সরাসরি কল। ৫-১০টা fd পাহারা দেওয়ার জন্য (একটা ছোট CLI টুল, একটা সাধারণ proxy যার সামনে অল্প কয়েকটা upstream) poll-এর O(n) স্ক্যান কার্যত বিনামূল্যে, কারণ n নিজেই ছোট — অথচ epoll_create1 + epoll_ctl × n + epoll_wait-এর সেটআপ খরচ কোনো সুবিধা ছাড়াই যোগ হয়।
epoll-এর সুবিধা প্রকাশ পায় শুধু n বড় হলে, বিশেষ করে যখন ready fd সংখ্যা মোট fd সংখ্যার তুলনায় ছোট (এই লেসনের example সেকশনের ৫% ready ধরে নেওয়া দৃশ্যকল্প)। যদি প্রায় সব fd-ই সবসময় ready থাকে, epoll-এর সুবিধা কমে আসে — কারণ তখন ready list প্রায় পুরো তালিকার সমান।
সঠিক নিয়ম: fd সংখ্যা ছোট এবং স্থির থাকলে poll যথেষ্ট এবং সরল; fd সংখ্যা বড় বা গতিশীল (connection প্রায়ই খোলা-বন্ধ হয়) হলে epoll।
“epoll_wait মানেই আসল I/O -- ready হওয়া fd থেকে ডেটা পড়ার জন্য আলাদা read() লাগে না।”
এটা readiness আর completion মডেলের গুলিয়ে ফেলা — এই লেসনের সবচেয়ে গুরুত্বপূর্ণ পার্থক্যের ভুল প্রয়োগ। epoll_wait শুধু বলে “এই fd-তে এখন কাজ করা যাবে” — প্রকৃত ডেটা এখনো kernel-এর socket buffer-এ বসে আছে, আপনার প্রোগ্রামের নিজস্ব buffer-এ আসেনি। সেই কপি করার কাজটা একটা আলাদা read()/recv() syscall, যা epoll_wait-এর পরে আপনাকে নিজেই কল করতে হবে।
io_uring লেসনের হিসাবটা মনে করুন — এই কারণেই epoll_wait + ৫০০টা read + ৫০০টা write মিলিয়ে ১০০১টা syscall লাগে একটা batch-এ, যেখানে completion-ভিত্তিক io_uring-এ একটাই io_uring_enter যথেষ্ট। epoll শুধু “কখন কাজ করব” প্রশ্নের সমাধান, “কাজটা কে করবে” প্রশ্নের নয় — সেই প্রশ্নের উত্তর সবসময় “আপনি নিজে, একটা আলাদা syscall-এ”।
“edge-triggered মোড সবসময় level-triggered-এর চেয়ে ভালো performance দেয়, তাই যত্ন করে drain-loop লেখার কষ্টটা সবসময় করা উচিত।”
পার্থক্যটা প্রধানত notification সংখ্যায়, প্রকৃত I/O throughput-এ নয়। একটা connection-এ যদি প্রতি event loop cycle-এ একবারই ডেটা আসে আর পুরোটা এক read()-এ পড়ে ফেলা যায়, LT আর ET-এর মধ্যে ব্যবহারিক পার্থক্য প্রায় শূন্য — উভয় ক্ষেত্রেই একটা epoll_wait notification, একটা read()।
ET-এর সুবিধা প্রকাশ পায় শুধু উচ্চ-throughput, বার্স্টি connection-এ, যেখানে একটা connection-এ একই cycle-এ বহুবার ডেটা আসতে পারে — LT মোডে প্রতিটা partial read-এর পরেও fd আবার ready দেখাবে, যা redundant epoll_wait wake-up তৈরি করে। কিন্তু ET-এর দাম হলো সঠিকতার দায়িত্ব সম্পূর্ণ আপনার — drain-to-EAGOAIN বাধ্যতামূলক, নাহলে experiment সেকশনের hang বাগ। একটা মাঝারি-throughput সার্ভারে LT-র সরলতা প্রায়ই ET-র সামান্য efficiency-লাভের চেয়ে বেশি মূল্যবান — বিশেষ করে যদি টিমের অভিজ্ঞতা কম থাকে এই ধরনের bug-প্রবণ কোডে।
“C10K সমস্যা সমাধান হয়ে গেছে, epoll-এর পর এটা আর কোনো ব্যবহারিক চিন্তার বিষয় নয়।”
সংখ্যাটা বদলেছে, সমস্যার শ্রেণিটা নয়। realworld সেকশনে দেখা C10M আলোচনা প্রমাণ করে — একটা নির্দিষ্ট স্কেলে (কোটি-সংখ্যক connection) epoll-এর নিজস্ব সীমাবদ্ধতা প্রকাশ পায়: প্রতিটা connection-এর জন্য kernel-এ socket structure, network stack-এর ভেতরে lock contention (বিশেষ করে multi-core-এ), আর epoll_wait-এর userspace copy খরচ (ready fd সংখ্যা বিশাল হলে)।
এই সীমাবদ্ধতাগুলোই io_uring-এর (batching দিয়ে syscall কমানো) আর kernel-bypass কৌশলের (DPDK, XDP — network stack সম্পূর্ণ এড়িয়ে userspace-এ প্যাকেট প্রসেস করা, Level 7-এর networking module-এ) প্রেরণা। “সমস্যা সমাধান হয়ে গেছে” ভাবাটা historically ভুল প্রমাণিত হয়েছে বারবার — প্রতিটা প্রজন্মের সমাধান পরের স্কেলে নতুন সীমাবদ্ধতা প্রকাশ করেছে, thread-per-connection থেকে select, select থেকে epoll, epoll থেকে io_uring/kernel-bypass।
বুঝেছেন কি না দেখুন
1একটা সার্ভারে ২০০০টা connection আছে, যার মধ্যে গড়ে ৫০টা যেকোনো মুহূর্তে ready থাকে। select, poll, আর epoll — প্রতিটাতে একটা “চেক cycle”-এর (readiness জানা থেকে শুরু করে কোন fd ready তা নির্ণয় পর্যন্ত) জটিলতা কী, আর কেন poll-এর জটিলতা select-এর চেয়ে ভালো না হলেও poll তবু ব্যবহারযোগ্য বলে বিবেচিত হয়?
যুক্তি
select, poll, আর epoll — প্রতিটাতে একটা “চেক cycle”-এর (readiness জানা থেকে শুরু করে কোন fd ready তা নির্ণয় পর্যন্ত) জটিলতা কী, আর কেন poll-এর জটিলতা select-এর চেয়ে ভালো না হলেও poll তবু ব্যবহারযোগ্য বলে বিবেচিত হয়?জটিলতা:
| API | জটিলতা | কারণ |
|---|---|---|
select | O(২০০০) | Bitmap পুরোটা kernel-এ কপি + userspace-এ পুরোটা FD_ISSET স্ক্যান, প্রতিটা কলে |
poll | O(২০০০) | Array পুরোটা kernel স্ক্যান করে + caller পুরোটা revents স্ক্যান করে, প্রতিটা কলে |
epoll | O(৫০) | শুধু ready list-এর entry ফেরত (এই দৃশ্যকল্পে ৫০টা), registered ২০০০টার সাথে সম্পর্কহীন |
select আর poll-এর জটিলতা সমান (দুটোই O(মোট fd)) — তাহলে poll কেন তবু উন্নতি হিসেবে গণ্য হয়? কারণ concept সেকশনে দেখা তিনটা সীমাবদ্ধতার মধ্যে poll দুইটা ঠিক করেছে, জটিলতা-সংক্রান্ত তৃতীয়টা নয়:
১. FD_SETSIZE (1024) সীমা তুলে দিয়েছে — ২০০০ fd select-এ আদৌ সম্ভব নয় standard কনফিগারেশনে, poll-এ সহজ।
২. প্রতি কলে array পুনর্নির্মাণের দরকার নেই — fd/events স্থির থাকে, শুধু revents রিসেট হয়। select-এ প্রতিবার bitmap ধ্বংস হয় বলে FD_ZERO + সব FD_SET আবার করতে হয়, যা নিজেই একটা O(n) খরচ যোগ করে সামগ্রিক scan-এর উপরে।
অর্থাৎ poll ব্যবহারিকভাবে ভালো (বড় fd সংখ্যায় ব্যবহারযোগ্য, প্রতি-কল overhead কম), কিন্তু asymptotic জটিলতার দিক থেকে epoll-এর মৌলিক অগ্রগতিটা (O(n) থেকে O(ready) — kernel-এ registered interest list রাখার মাধ্যমে) poll স্পর্শই করেনি। এটা একটা ভালো উদাহরণ যে “improvement” সবসময় asymptotic হতে হয় না — constant-factor আর usability-এর উন্নতিও বাস্তবে মূল্যবান, যদিও epoll-এর মতো fundamental redesign শেষ পর্যন্ত বড় স্কেলে অপরিহার্য।
2নিচের edge-triggered epoll কোডে একটা বাগ আছে যা build সেকশনের drain-loop-এর নিয়ম ভাঙছে। খুঁজে বের করুন এবং ব্যাখ্যা করুন ঠিক কোন পরিস্থিতিতে এটা connection hang করবে।
ev.events = EPOLLIN | EPOLLET;
epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &ev);
/* ... event loop-এ ... */
ssize_t n = read(fd, buf, sizeof buf);
if (n > 0) {
process(buf, n);
if (n == sizeof buf) {
/* buffer পুরো ভরে গেছে -- হয়তো আরও ডেটা আছে, আবার read() করি */
n = read(fd, buf, sizeof buf);
if (n > 0) process(buf, n);
}
}
প্রয়োগ
ev.events = EPOLLIN | EPOLLET;
epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &ev);
/* ... event loop-এ ... */
ssize_t n = read(fd, buf, sizeof buf);
if (n > 0) {
process(buf, n);
if (n == sizeof buf) {
/* buffer পুরো ভরে গেছে -- হয়তো আরও ডেটা আছে, আবার read() করি */
n = read(fd, buf, sizeof buf);
if (n > 0) process(buf, n);
}
}বাগ: drain loop শর্তসাপেক্ষ, EAGAIN পর্যন্ত নিঃশর্ত loop নয়।
কোডটা ধরে নিচ্ছে “যদি প্রথম read() পুরো buffer ভরে ফেলে (n == sizeof buf), তাহলে হয়তো আরও ডেটা আছে, তাই একবার আরও read() করি”। এটা একটা ভালো heuরিস্টিক প্রেরণা, কিন্তু ভুল বাস্তবায়ন — কারণ এটা সর্বোচ্চ দুইবার read() করে, যেখানে ET-এর নিয়ম হলো EAGAIN না পাওয়া পর্যন্ত যতবার দরকার ততবার।
নির্দিষ্ট পরিস্থিতি যেখানে এটা hang করবে: ধরুন buf এর আকার 4096, আর client একসাথে 12,000 বাইট পাঠিয়েছে (একটা বার্স্ট)।
| Read # | ফেরত n | কী ঘটে |
|---|---|---|
| ১ম | 4096 | n == sizeof buf, তাই দ্বিতীয় read() চেষ্টা |
| ২য় | 4096 | process() করা হলো, কিন্তু কোড আর তৃতীয়বার চেষ্টা করছে না — কোড শুধু “একবার আরও” চেক করে |
| — | — | বাকি 3808 বাইট socket buffer-এই থেকে গেল |
যেহেতু edge-triggered মোডে fd-র অবস্থা “খালি → non-খালি” আর বদলায়নি (নতুন কোনো প্যাকেট আসেনি, পুরনো ডেটাই এখনো আংশিক pending), epoll_wait আর কখনো এই fd-কে ready দেখাবে না। বাকি 3808 বাইট চিরকাল সেই socket buffer-এ আটকে থাকবে, client হয়তো উত্তরের অপেক্ষায় hang করবে।
সঠিক প্যাটার্ন (build সেকশনের drain_and_echo-এর মতো) — একটা নিঃশর্ত loop যা শুধু EAGAIN/EWOULDBLOCK-এ থামে:
for (;;) {
ssize_t n = read(fd, buf, sizeof buf);
if (n > 0) { process(buf, n); continue; } /* আবার চেষ্টা -- buffer size দিয়ে অনুমান নয় */
if (n == 0) { /* peer বন্ধ */ break; }
if (errno == EAGAIN || errno == EWOULDBLOCK) break; /* ★ একমাত্র বৈধ থামার শর্ত */
if (errno == EINTR) continue;
/* প্রকৃত error */ break;
}মূল শিক্ষা: “buffer পুরো ভরেছে কি না” দিয়ে অনুমান করা যথেষ্ট নয় — কারণ ঠিক সেই সময়েই আরও ডেটা কতটা আছে তা নিশ্চিতভাবে জানার একমাত্র উপায় EAGAIN না পাওয়া পর্যন্ত read করতে থাকা। এই বাগ-শ্রেণিটা প্রোডাকশন কোডে বিরল ঘটে (লোড টেস্টিং-এই ধরা পড়ে সবচেয়ে বেশি, তাই বিল্ড সেকশনের দ্বিতীয় experiment-টা গুরুত্বপূর্ণ) কারণ ছোট ডেটাতে (একটা read()-এই সব চলে আসে) বাগ প্রকাশ পায় না — শুধু বার্স্টি, বড় payload-এ ধরা পড়ে।
3Windows-এর IOCP (I/O Completion Ports) কে প্রায়ই “Windows-এর epoll” বলা হয়, কিন্তু কাঠামোগতভাবে এটা io_uring-এর সাথে বেশি মেলে। কেন? readiness বনাম completion পার্থক্যের ভিত্তিতে ব্যাখ্যা করুন, আর এর একটা ব্যবহারিক ফলাফল বলুন।
যুক্তি
পার্থক্যটা এই লেসনের কেন্দ্রীয় বিভাজনেই — readiness বনাম completion।
epoll readiness-ভিত্তিক: epoll_wait বলে “এই fd-তে এখন কাজ করা যাবে”, তারপর caller নিজে একটা read()/write() syscall কল করে প্রকৃত কাজটা করে। IOCP সম্পূর্ণ ভিন্ন মডেল: আপনি প্রথমে ReadFile/WriteFile (asynchronous variant) কল করে কাজটা জমা দেন, kernel সেটা সম্পূর্ণ করে, আর তারপর completion port থেকে ফলাফল (কতটা বাইট পড়া হয়েছে, buffer-এ ডেটা ইতিমধ্যে আছে) সংগ্রহ করেন। io_uring-এ ঠিক এই একই প্যাটার্ন — SQE জমা দেওয়া (কাজ) → CQE সংগ্রহ (ফলাফল)।
epoll | IOCP | io_uring | |
|---|---|---|---|
| মডেল | Readiness | Completion | Completion |
| প্রথম পদক্ষেপ | Interest register | কাজ জমা দেওয়া (ReadFile) | কাজ জমা দেওয়া (SQE) |
| Wait-এর পরে যা পাওয়া যায় | “fd ready” সংকেত | প্রকৃত ফলাফল (বাইট সংখ্যা, ডেটা প্রস্তুত) | প্রকৃত ফলাফল (CQE) |
| এরপর আরেকটা syscall লাগে? | হ্যাঁ (read/write) | না | না |
| Regular file-এ কাজ করে? | না | হ্যাঁ | হ্যাঁ |
তাই নামের মিল (“সবগুলোই বহু I/O একসাথে পাহারা দেয়”) থাকলেও কাঠামোগতভাবে IOCP আর io_uring একই পরিবারের — উভয়েই সেই সমস্যাটা সমাধান করে যা epoll কখনো করেনি: regular file I/O, আর “readiness জানার পরে আরেকটা syscall লাগবে কেন” প্রশ্ন।
ব্যবহারিক ফলাফল: যে libuv-এর মতো cross-platform library একই async I/O API সব প্ল্যাটফর্মে দিতে চায়, তাদের Linux-এ (epoll, readiness) কোনো কাজ জমা দেওয়ার আগেই non-blocking read করতে হয়, কিন্তু Windows-এ (IOCP, completion) কাজ আগেই জমা দিতে হয় আর buffer আগেভাগে বরাদ্দ করতে হয় — এই দুইটা মডেলের প্রকৃত পার্থক্য আড়াল করে একটা অভিন্ন API দেওয়া libuv-এর অন্যতম কঠিন engineering সমস্যা, আর এটাই ব্যাখ্যা করে Linux-এ io_uring backend যোগ করা libuv-এর জন্য কেন Windows IOCP backend-এর সাথে বেশি মিল রাখে epoll-ভিত্তিক পুরনো backend-এর চেয়ে।
4আপনি একটা নতুন high-throughput proxy সার্ভার ডিজাইন করছেন যেটা ১০ লাখ concurrent connection সামলাবে, প্রতিটা connection-এ কম ট্রাফিক (mostly idle, মাঝেমধ্যে ছোট মেসেজ)। thread-per-connection, single-threaded epoll, আর multi-threaded/multi-process epoll (SO_REUSEPORT সহ) — তিনটা স্থাপত্য বিবেচনা করে সিদ্ধান্ত নিন, trade-off সহ।
ডিজাইন
Thread-per-connection সম্পূর্ণ বাতিল — hood সেকশনের হিসাব সরাসরি প্রযোজ্য। ১০ লাখ thread মানে (৮ MB ডিফল্ট stack ধরলে, যদিও কমানো যায়) কয়েক TB virtual memory আর kernel stack-এই বহু GB non-swappable memory — বাস্তবসম্মত নয় একটা সাধারণ সার্ভারে। এমনকি stack size কমিয়ে ৬৪ KB করলেও (সম্ভব, কিন্তু deep call stack-এ ঝুঁকিপূর্ণ), ১০ লাখ thread-এর scheduler overhead (context switch storm, বেশিরভাগ idle থাকলেও kernel-কে তাদের সবার হিসাব রাখতে হয়) একটা mostly-idle workload-এর জন্য অপ্রয়োজনীয় খরচ।
Single-threaded epoll বনাম multi-threaded/process — এখানেই আসল সিদ্ধান্ত।
| বিবেচনা | Single-threaded epoll | Multi-process + SO_REUSEPORT |
|---|---|---|
| CPU ব্যবহার | একটা core-এই সীমাবদ্ধ | সব core জুড়ে স্কেল করে |
| ১০ লাখ connection-এ memory | সবচেয়ে কম (একটাই process, shared everything) | প্রতি process-এর নিজস্ব overhead (N গুণ), কিন্তু তবুও thread-per-conn-এর চেয়ে বহুগুণ কম |
| Connection distribution | সব একটা loop-এ, single-core বাধা হয়ে দাঁড়াতে পারে | kernel হ্যাশ-ভিত্তিক বণ্টন করে, প্রায় সমান ভাগ |
| Code জটিলতা | সরল — কোনো shared state সমস্যা নেই | প্রতিটা worker স্বাধীন, কিন্তু cross-connection state (যদি দরকার হয়) shared memory/IPC লাগবে |
| একটা worker crash করলে | পুরো সার্ভার down | শুধু সেই worker-এর connection-গুলো প্রভাবিত (nginx-এর মতো master-worker রিস্টার্ট সম্ভব) |
সিদ্ধান্ত: multi-process epoll + SO_REUSEPORT, একটা worker per CPU core। যুক্তি:
১. ১০ লাখ connection-এ, mostly idle হলেও, packet processing (TCP ACK, keep-alive, occasional message parsing) আছে যথেষ্ট যে multi-core ব্যবহার না করলে single core বাধা হয়ে দাঁড়াবে — CPU-bound না হলেও kernel network stack-এর processing single core-এ concentrate হবে।
২. Fault isolation বাস্তব সুবিধা — একটা worker crash করলে বাকি connection অক্ষত থাকে (nginx-এর master-worker মডেল ঠিক এই কারণে)।
৩. SO_REUSEPORT-এর kernel-side load balancing মানে কোনো userspace load balancer বা lock contention লাগে না worker-দের মধ্যে connection ভাগ করতে — প্রতিটা worker স্বাধীনভাবে নিজস্ব epoll loop চালায়, shared state ছাড়াই বেশিরভাগ ক্ষেত্রে।
Worker সংখ্যা CPU core সংখ্যার সমান রাখা shy (over-subscription এড়াতে) — একটা ৩২-core মেশিনে ৩২টা worker, প্রতিটাতে গড়ে ~৩১,২৫০ connection, যা single-threaded epoll-এর জন্য সহজেই সামলানো যায় (এই লেসনের experiment-এর সংখ্যার তুলনায় অনেক ছোট স্কেল প্রতি worker-এ)।
এই ধরনের horizontal scaling-এর সাধারণ নীতি — একক bottleneck resource (এখানে single-threaded event loop-এর CPU) সনাক্ত করে সেটাকে independent unit-এ ভাগ করা যেগুলো shared-nothing চলতে পারে — Level 9-এর distributed systems module-এর কেন্দ্রীয় থিম, আর Level 12-এর cloud module-এ horizontal pod autoscaling-এর একই নীতির প্রয়োগ দেখা যাবে।
5একটা epoll সার্ভারে EPOLLONESHOT flag ব্যবহার করা হয়েছে (EPOLLIN | EPOLLET | EPOLLONESHOT)। এই flag-টা কী করে, কেন এটা multi-threaded epoll worker pool-এ প্রয়োজনীয় হয়ে ওঠে, আর এটা ব্যবহার করলে event handler-এর শেষে কোন অতিরিক্ত কাজটা করতে ভুলে গেলে সার্ভার স্থায়ীভাবে সেই connection-এ সাড়া দেওয়া বন্ধ করে দেবে?
প্রয়োগ
epoll সার্ভারে EPOLLONESHOT flag ব্যবহার করা হয়েছে (EPOLLIN | EPOLLET | EPOLLONESHOT)। এই flag-টা কী করে, কেন এটা multi-threaded epoll worker pool-এ প্রয়োজনীয় হয়ে ওঠে, আর এটা ব্যবহার করলে event handler-এর শেষে কোন অতিরিক্ত কাজটা করতে ভুলে গেলে সার্ভার স্থায়ীভাবে সেই connection-এ সাড়া দেওয়া বন্ধ করে দেবে?EPOLLONESHOT-এর কাজ: একটা fd-তে একবার event রিপোর্ট করার পর, সেই fd স্বয়ংক্রিয়ভাবে interest list থেকে disabled হয়ে যায় — নতুন করে event আসলেও epoll_wait আর সেটা রিপোর্ট করবে না, যতক্ষণ না আপনি স্পষ্টভাবে epoll_ctl(EPOLL_CTL_MOD, ...) কল করে সেটা আবার re-arm করছেন।
কেন এটা multi-threaded worker pool-এ দরকার: ধরুন একাধিক worker thread একই epoll instance-এর উপর epoll_wait কল করছে (একটা সাধারণ প্যাটার্ন — একটা shared epoll fd, N worker thread)। EPOLLONESHOT ছাড়া, একই fd-তে একাধিক event দ্রুত এলে দুইটা ভিন্ন worker thread একই সময়ে একই fd-র ডেটা প্রসেস করতে শুরু করতে পারে — একটা race condition, কারণ epoll নিজে থেকে নিশ্চিত করে না যে একটা fd একসাথে একটামাত্র thread-এই যাবে।
EPOLLONESHOT দিয়ে: worker A একটা event পায়, fd স্বয়ংক্রিয়ভাবে disabled হয়ে যায় — তাই worker B একই মুহূর্তে epoll_wait কল করলেও এই fd আর দেখাবে না। Worker A কাজ শেষ করে (buffer drain করে) তারপর EPOLL_CTL_MOD দিয়ে আবার arm করে — এখন থেকেই অন্য কোনো worker (বা এই worker নিজেই) পরের event পেতে পারে।
ভুলে গেলে কী হয়: যদি handler-এর শেষে epoll_ctl(EPOLL_CTL_MOD, fd, EPOLLIN | EPOLLET | EPOLLONESHOT) কল করতে ভুলে যান, সেই fd স্থায়ীভাবে disabled থেকে যাবে — connection-টা এখনো খোলা, kernel socket buffer-এ হয়তো নতুন ডেটাও আসছে, কিন্তু epoll আর কখনো সেটা রিপোর্ট করবে না। ঠিক experiment সেকশনের edge-triggered drain-বাগের মতোই একটা “নীরব hang”, কিন্তু ভিন্ন কারণে — এখানে ডেটা পড়া সম্পূর্ণ হয়েছে (drain ঠিকই হয়েছে), কিন্তু fd-টা re-arm হয়নি।
| বাগ | কারণ | লক্ষণ |
|---|---|---|
| ET drain-বাগ (build/experiment সেকশন) | EAGAIN পর্যন্ত read না করা | কিছু ডেটা buffer-এই আটকে থাকে, connection stall |
| ONESHOT re-arm ভুলে যাওয়া | Handler শেষে EPOLL_CTL_MOD কল না করা | পুরো ডেটা পড়া হয়েছে, কিন্তু fd আর কখনো ready হিসেবে দেখাবে না |
দুইটা বাগই একই পরিবারের — edge/ধরনের event notification মডেলে “handler-এর দায়িত্ব সম্পূর্ণভাবে পালন করা” প্রোগ্রামারের হাতে, kernel জোর করে মনে করিয়ে দেয় না। এটা concurrency primitives-এর একটা সাধারণ প্যাটার্ন — lock নেওয়ার পর unlock করতে ভুলে যাওয়ার মতোই একটা discipline-নির্ভর চুক্তি, যা Level 4-এর synchronization primitives লেসনে (lock/unlock জোড়া) এবং Level 6-এর concurrent data structure ডিজাইনে বারবার ফিরে আসবে।
এরপর কী
পরের ধাপ — io_uring, যা আপনি ইতিমধ্যে পড়েছেন
এই মডিউলের পাঠক্রম-ক্রমে আপনি ইতিমধ্যে io_uring লেসনটা পড়ে ফেলেছেন — কিন্তু এখন যেহেতু select/poll/epoll-এর পূর্ণ ইতিহাসটা হাতে আছে, সেই লেসনের প্রথম দুই অনুচ্ছেদটা আবার পড়ার মূল্য আছে একটা নতুন দৃষ্টিকোণ থেকে: epoll C10K সমস্যা সমাধান করেছিল readiness-ভিত্তিক multiplexing দিয়ে, কিন্তু readiness নিজেই একটা সীমা তৈরি করে — প্রতিটা ready fd-তে এখনো একটা আলাদা syscall লাগে প্রকৃত কাজের জন্য, আর regular file-এ এই মডেলটা কাজই করে না। io_uring সেই সীমাটা ভাঙে সম্পূর্ণ ভিন্ন প্রশ্ন জিজ্ঞেস করে — “প্রস্তুত কি না” নয়, বরং “কাজটা করে দাও, শেষ হলে জানিও”।
এই লেসনের epoll_echo.c-কে io_uring-ভিত্তিক একটা সংস্করণে রূপান্তর করা (io_uring লেসনের build সেকশনের extension ৫-এ উল্লেখ করা হয়েছিল) এখন একটা স্বাভাবিক পরবর্তী প্রকল্প — দুইটা লেসনের সব ধারণা একসাথে প্রয়োগ করার একটা সুযোগ। এরপরের লেসনে আমরা interrupt আর device driver-এর জগতে যাব — সেই স্তরে যেখানে “ready” বা “complete” হওয়ার সংকেতটা আসলে কোথা থেকে আসে, হার্ডওয়্যার থেকে।
আরও পড়ুন
- The C10K problem — Dan Kegel · মূল প্রবন্ধ যেখানে ১৯৯৯ সালে এই সমস্যাটা প্রথম স্পষ্টভাবে বিবৃত হয় -- epoll/kqueue-র জন্মের প্রত্যক্ষ প্রেরণা
- epoll(7), select(2), poll(2) -- Linux man pages · epoll(7)-এর 'Level-triggered and edge-triggered' অংশটা এই লেসনের edge-triggered আলোচনার প্রামাণ্য উৎস
- kqueue(2) -- FreeBSD/macOS man page · epoll-এর BSD/macOS সমতুল্য -- একটাই syscall দিয়ে register আর wait দুটোই করা যায়, epoll-এর তিনটার তুলনায়
- Efficient IO with io_uring — Jens Axboe · readiness বনাম completion পার্থক্যের পূর্ণ প্রয়োগ -- এই লেসনের next সেকশন সরাসরি এই ডকুমেন্টের দিকে যাচ্ছে