এই পর্বে যা শিখবেন
- C10K সমস্যা — কেন প্রতি সংযোগে একটি thread একসময় আর চলে না
- I/O-এর পাঁচটি মডেল, non-blocking socket, EAGAIN আর partial write
- select, poll আর epoll-এর API, সীমা আর খরচের তুলনা; level-triggered বনাম edge-triggered
- Reactor pattern: event loop, প্রতিটি সংযোগের state machine, timer আর accept loop
- nginx, Node.js, Redis, Go কীভাবে এই ধারণা ব্যবহার করে — আর কখন thread-ই যথেষ্ট
১ · সমস্যা — দশ হাজার সংযোগ, একটি সার্ভার
পর্ব ৪১-এ (TCP socket প্রোগ্রামিং) একটি সার্ভার একবারে একটি client-কে সেবা দিত। পর্ব ৪৩-এ (একসাথে অনেক client) সমাধান ছিল প্রতিটি সংযোগের জন্য একটি আলাদা process বা thread. সেই নকশা সহজ আর পরিষ্কার: প্রতিটি thread নিজের সংযোগে read() ডেকে অপেক্ষা করে, ডেটা এলে কাজ করে, উত্তর পাঠায়। কোড পড়লে মনে হয় একজন client-এর সাথেই কথা হচ্ছে।
কিন্তু এখনকার সার্ভারের সংযোগগুলোর চরিত্র আলাদা। একটি চ্যাট অ্যাপের সার্ভারে লাখো ফোন সারাদিন সংযুক্ত থাকে, অথচ যেকোনো মুহূর্তে বার্তা পাঠাচ্ছে হয়তো কয়েকশো। HTTP keep-alive, WebSocket, push notification — সবখানেই বেশিরভাগ সংযোগ বেশিরভাগ সময় চুপ। প্রতিটি চুপ সংযোগের জন্য একটি thread মানে একটি থেমে থাকা কর্মী, যার বেতন (মেমরি) দিতেই হচ্ছে।
একটি বড় সরকারি অফিসের রিসেপশনের কথা ভাবুন। এক নকশায় প্রতিটি দর্শনার্থীর জন্য আলাদা একজন কর্মী বরাদ্দ — দর্শনার্থী ফরম পূরণ করতে আধা ঘণ্টা নিলে কর্মীও আধা ঘণ্টা পাশে দাঁড়িয়ে থাকেন। দশজন দর্শনার্থী হলে ঠিক আছে, দশ হাজার হলে অফিসে কর্মীর জায়গাই হবে না। অন্য নকশায় একজন রিসেপশনিস্ট আর একটি টোকেন বোর্ড: যার ফরম তৈরি, সে ঘণ্টা বাজায়, রিসেপশনিস্ট শুধু তাকেই সেবা দেন। কাজ হয় শুধু “তৈরি” মানুষের জন্য, অপেক্ষায় কারও সময় নষ্ট হয় না। এই দ্বিতীয় নকশাই event-driven সার্ভার, আর টোকেন বোর্ডটি হলো select, poll বা epoll.
প্রতি সংযোগে একটি thread — খরচ কোথায়
- মেমরি: প্রতিটি thread-এর নিজস্ব stack লাগে। Linux-এ glibc সাধারণত ৮ MiB virtual জায়গা সংরক্ষণ করে (
ulimit -s-এর মান), যদিও আসলে ব্যবহার হয় অনেক কম। ধরুন প্রতিটি thread আসলে ৬৪ KiB ছোঁয়, তবু ২০,০০০ thread-এ প্রায় ১.২ GiB শুধু stack-এ — আর kernel-এর ভেতরের হিসাবরক্ষণ আলাদা। - Context switch: একটি thread ঘুমিয়ে আরেকটি জাগলে CPU-কে register, stack আর অনেক সময় cache-এর অবস্থা বদলাতে হয় — একে বলে context switch. হাজার হাজার সক্রিয় thread-এ CPU-র উল্লেখযোগ্য সময় যায় এই বদলে।
- Scheduler আর lock: অনেক thread একই ডেটা (cache, connection table) ছুঁলে lock-এর জন্য লাইন পড়ে; thread বাড়লে লাইন লম্বা হয়।
C10K — একটি ওয়েবপেজ থেকে একটি আন্দোলন। ১৯৯৯ সালের দিকে Dan Kegel “The C10K problem” নামে একটি ওয়েবপেজ লেখেন: একটি সাধারণ সার্ভার কি একসাথে দশ হাজার client সামলাতে পারবে? তখনকার হার্ডওয়্যার যথেষ্ট শক্তিশালী ছিল, কিন্তু OS-এর API আর প্রচলিত সার্ভার-নকশা ছিল বাধা। পেজটি select, poll, Solaris-এর /dev/poll, FreeBSD-র kqueue, signal-driven I/O — সব বিকল্পের তুলনা জমা করত। এর পরের কয়েক বছরে Linux-এ epoll আসে আর nginx-এর মতো event-driven সার্ভার জনপ্রিয় হয়। পরে লোকজন একই ধাঁচে “C10M” (এক কোটি সংযোগ) নিয়েও আলোচনা করেছেন। C10K নামটি আজও সার্ভার-নকশার আলোচনায় একটি মাইলফলক।
Event-driven নকশায় একটি thread kernel-কে জিজ্ঞেস করে: “আমার এই সব socket-এর মধ্যে কোনগুলোতে এখন কাজ করা যায়?” Kernel উত্তর দেয় একটি তালিকা দিয়ে, আর thread শুধু সেগুলো নিয়ে কাজ করে — কখনো কোনো একটি সংযোগের জন্য অপেক্ষায় আটকে থাকে না। একে বলে readiness notification, আর যে loop বারবার এই প্রশ্ন করে, সেটি event loop.
২ · I/O-এর পাঁচটি মডেল — Stevens-এর তালিকা
W. Richard Stevens-এর বিখ্যাত বই “UNIX Network Programming”-এ socket থেকে পড়াকে দুটি ধাপে ভাগ করা হয়েছে: (১) ডেটা আসার অপেক্ষা — নেটওয়ার্ক থেকে packet এসে kernel-এর socket buffer-এ জমা হওয়া; (২) কপি — kernel-এর buffer থেকে আপনার প্রোগ্রামের buffer-এ ডেটা আনা। কোন ধাপে প্রোগ্রাম আটকে থাকে, তা দিয়ে পাঁচটি মডেল আলাদা হয়। System call আর kernel-এর দিক থেকে বিস্তারিত OS কোর্সে (I/O সিস্টেম — blocking, non-blocking আর asynchronous).
কুরিয়ারে একটি পার্সেল আসার কথা। Blocking: আপনি কুরিয়ার অফিসে গিয়ে বসে থাকেন যতক্ষণ না পার্সেল আসে। Non-blocking: প্রতি পাঁচ মিনিটে ফোন করেন — “এসেছে?” — “না।” Multiplexing: একটি ট্র্যাকিং অ্যাপ আপনার দশটি পার্সেলের মধ্যে কোনগুলো পৌঁছেছে দেখায়; আপনি শুধু সেগুলো আনতে যান। Signal-driven: পার্সেল এলে SMS আসে, তারপর আপনি আনতে যান। Asynchronous: কুরিয়ার পার্সেল বাসায় পৌঁছে দিয়ে তারপর জানায় “দিয়ে গেছি” — আনতেও যেতে হয় না।
| মডেল | ধাপ ১: অপেক্ষা | ধাপ ২: কপি | উদাহরণ API | কোথায় দেখা যায় |
|---|---|---|---|---|
| Blocking I/O | call-এর ভেতরে ঘুম | call-এর ভেতরে | read(), recv() (ডিফল্ট) | পর্ব ৪১-৪৩-এর সার্ভার |
| Non-blocking I/O | সাথে সাথে ফেরে, EAGAIN | call-এর ভেতরে | O_NONBLOCK + read() | একা ব্যবহারে বারবার জিজ্ঞাসা (busy polling) |
| I/O multiplexing | অনেক fd-এর জন্য একসাথে একটি call-এ ঘুম | আলাদা read()-এ | select, poll, epoll_wait, kevent | nginx, Redis, Node.js — এই পর্বের মূল বিষয় |
| Signal-driven I/O | ঘুমায় না; তৈরি হলে SIGIO signal | আলাদা read()-এ | O_ASYNC, F_SETOWN | বিরল; TCP-তে signal খুব ঘনঘন আসে |
| Asynchronous I/O | ঘুমায় না | kernel নিজেই কপি করে, শেষে জানায় | POSIX aio_read, Windows IOCP, Linux io_uring | Windows সার্ভার, আধুনিক Linux-এর উচ্চ-ক্ষমতার I/O |
POSIX-এর সংজ্ঞায় প্রথম চারটিই আসলে synchronous: ধাপ ২-এ, অর্থাৎ কপির সময়, প্রোগ্রাম অপেক্ষা করে। শুধু পঞ্চমটিতে দুটি ধাপই প্রোগ্রামের অগোচরে ঘটে। এই পার্থক্যটি অংশ ৭-এ আবার আসবে — readiness (“এখন পড়তে পারেন”) বনাম completion (“পড়া হয়ে গেছে”)।
৩ · Non-blocking socket — EAGAIN, partial write আর নিজের output buffer
Event loop-এর প্রথম শর্ত: কোনো call যেন কখনো ঘুমিয়ে না পড়ে। তাই প্রতিটি socket-কে non-blocking করা হয়। Linux-এ তিনটি উপায় আছে:
/* ১) পরে বদলানো: আগের flag রেখে O_NONBLOCK যোগ */
int fl = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, fl | O_NONBLOCK);
/* ২) তৈরির সময়েই (Linux) */
int s = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0);
/* ৩) accept করা নতুন socket-ও সাথে সাথে non-blocking (Linux) */
int c = accept4(listen_fd, NULL, NULL, SOCK_NONBLOCK | SOCK_CLOEXEC);Non-blocking socket-এ read()-এর চারটি সম্ভাব্য ফল, আর প্রতিটির জন্য আলাদা কাজ:
| ফেরত মান | অর্থ | কী করবেন |
|---|---|---|
n > 0 | n byte পাওয়া গেছে (চাওয়ার চেয়ে কম হতে পারে) | ইনপুট buffer-এ যোগ করুন; আরও থাকতে পারে |
0 | অপর প্রান্ত লেখা বন্ধ করেছে (FIN, EOF) | বাকি উত্তর পাঠিয়ে বা সাথে সাথে সংযোগ বন্ধ |
-1, errno = EAGAIN / EWOULDBLOCK | এখন পড়ার কিছু নেই — ত্রুটি নয় | event loop-এ ফিরে যান, পরের event-এর অপেক্ষা |
-1, errno = EINTR | signal এসে call থামিয়েছে | আবার চেষ্টা |
-1, অন্য errno (যেমন ECONNRESET) | আসল ত্রুটি, যেমন RST | সংযোগ বন্ধ, সম্পদ মুক্ত |
EAGAIN আর EWOULDBLOCK — দুটি নাম, অর্থ একই: “এখন করলে আটকে যেত”. Linux-এ দুটির মান একই (11), কিন্তু POSIX আলাদা হওয়ার অনুমতি দেয়, তাই বহনযোগ্য কোডে দুটিই পরীক্ষা করা হয়।
Partial write — লেখাও অর্ধেক হতে পারে
নতুনদের সবচেয়ে বড় ভুল ধারণা: write(fd, buf, 50000) মানেই ৫০,০০০ byte চলে গেল। Kernel-এর socket send buffer-এ যতটুকু জায়গা আছে, non-blocking socket ঠিক ততটুকুই নেয় আর সেই সংখ্যা ফেরত দেয় — একে বলে partial write. Buffer পুরো ভরা থাকলে ফেরে -1 আর EAGAIN. পর্ব ৩১-এ (TCP flow control) দেখেছেন, ধীর প্রাপকের rwnd ছোট হলে প্রেরকের send buffer ভরে যায় আর non-blocking send() ঠিক এভাবে EAGAIN দেয় — এটাই backpressure-এর প্রোগ্রামের দিকের চেহারা।
তাহলে বাকি byte কোথায় যাবে? প্রোগ্রামকেই প্রতিটি সংযোগের জন্য একটি নিজস্ব output buffer রাখতে হয়। নিয়ম তিনটি:
- উত্তর তৈরি হলে আগে output buffer-এ রাখুন, তারপর যতটা পারা যায়
write()করুন। - বাকি থাকলে kernel-কে বলুন “এই socket-এ আবার লেখা যাবে কখন জানাবেন” (poll-এ
POLLOUT, epoll-এEPOLLOUT). - Buffer খালি হলে সেই অনুরোধ তুলে নিন — নইলে খালি send buffer-এর socket প্রায় সবসময় writable, loop অকারণে ঘুরতে থাকবে।
বন্ধ সংযোগে লিখলে প্রোগ্রাম মারা যেতে পারে। অপর প্রান্ত সংযোগ ভেঙে দেওয়ার পর লিখতে গেলে Linux প্রসেসকে SIGPIPE signal পাঠায়, যার ডিফল্ট কাজ প্রসেস শেষ করা — একটি client-এর দোষে পুরো সার্ভার বন্ধ! Event-driven সার্ভার সাধারণত শুরুতেই signal(SIGPIPE, SIG_IGN) করে, বা প্রতিটি send()-এ MSG_NOSIGNAL দেয়; তখন write শুধু EPIPE ত্রুটি ফেরত দেয়।
শুধু non-blocking দিয়ে কিন্তু সমস্যা মেটে না। ১০,০০০ socket-এ ঘুরে ঘুরে read() ডেকে EAGAIN পেতে থাকলে CPU পুরোটা যাবে অকারণ জিজ্ঞাসায় — কুরিয়ার অফিসে প্রতি মিনিটে ফোন করার মতো। দরকার এমন একটি call, যা অনেক socket-এর জন্য একসাথে ঘুমায় আর যেকোনোটি তৈরি হলে জাগে। সেটাই I/O multiplexing.
৪ · select() — প্রথম multiplexer
select() এসেছিল ১৯৮০-র দশকে BSD Unix-এর socket API-র সাথে, আর আজও প্রায় সব OS-এ আছে — Windows-এর Winsock-এও। এর কেন্দ্রে একটি fd_set: একটি bit-এর সারি, যেখানে bit নম্বর i মানে file descriptor i. আপনি যে fd-গুলোর খবর চান তাদের bit ১ করেন; select ফেরার সময় সেই একই সারিতে শুধু তৈরি fd-গুলোর bit ১ রেখে বাকিগুলো মুছে দেয়।
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
FD_ZERO(&set); /* সব bit শূন্য */
FD_SET(fd, &set); /* fd-এর bit ১ */
FD_CLR(fd, &set); /* fd-এর bit ০ */
FD_ISSET(fd, &set); /* ফেরার পর: fd কি তৈরি? */fd_set — আগে আর পরে
select() overwrites the set: only ready descriptors keep their bit
fd নম্বর: 0 1 2 3 4 5 6 7
select-এর আগে: 0 0 0 1 1 1 1 0 আগ্রহ: 3 (listen), 4, 5, 6
nfds = maxfd + 1 = 7
select-এর পরে: 0 0 0 0 1 0 1 0 তৈরি: 4 আর 6 — বাকি bit মুছে গেছে
এখান থেকেই select-এর চারটি পরিচিত বৈশিষ্ট্য — আর দুর্বলতা:
- nfds = সবচেয়ে বড় fd + ১. Kernel ০ থেকে nfds − ১ পর্যন্ত প্রতিটি bit দেখে। fd 3, 7, 12 দেখতে চাইলে nfds = 13 — fd-এর সংখ্যা (৩) নয়।
- প্রতি loop-এ নতুন করে সেট বানানো: select সেটটি বদলে দেয় (value-result argument), তাই আসল আগ্রহের তালিকা আলাদা রেখে প্রতিবার কপি করতে হয়। Linux-এ
timeout-ও বদলে যায় (বাকি সময় লেখা থাকে) — প্রতিবার নতুন করে বসাতে হয়। - FD_SETSIZE = 1024: glibc-এর fd_set ঠিক ১,০২৪ bit (১২৮ byte). fd ১,০২৪ বা তার বেশি হলে
FD_SETসারির বাইরে লেখে — undefined behavior, মেমরি নষ্ট। ব্যস্ত সার্ভারে fd নম্বর সহজেই ১,০২৪ ছাড়ায়। - O(n) খরচ: প্রতি call-এ kernel সব fd পরীক্ষা করে, পুরো সেট user-kernel-এ আসা-যাওয়া করে, আর ফেরার পর প্রোগ্রামকেও সব bit ঘুরে দেখতে হয় কোনটি ১ — যদিও তৈরি হয়তো মাত্র দুটি।
এবার একটি পূর্ণ উদাহরণ। একটি প্রসেস, একটি thread — তবু তিনটি client একসাথে। প্রোগ্রামটি fork() করে তিনটি client বানায় (fork-এর বিস্তারিত পর্ব ৪৩-এ); সংযোগগুলো ক্রমানুসারে তৈরি হয়, যাতে fd নম্বর প্রতিবার একই থাকে। Client 0 আর 1 শুরুর দিকে বার্তা পাঠায়, client 2 পাঠায় ০.৪ সেকেন্ডে, তারপর একে একে সংযোগ ছাড়ে। সার্ভার শুরুতে ইচ্ছে করে ০.২ সেকেন্ড “ব্যস্ত” থাকে, যাতে কাজ জমে যায় আর এক select-এ একাধিক fd তৈরি দেখা যায়।
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/select.h>
#include <sys/wait.h>
#include <arpa/inet.h>
static void client(int s, int i) { /* child: wait, send, check the echo */
int n; char m[32], r[32]; usleep(i < 2 ? 50000 * (i + 1) : 400000); /* 0,1 talk early; 2 at 0.4 s */
n = snprintf(m, sizeof m, "hello from client %d", i);
if (write(s, m, n) != n || read(s, r, sizeof r) != n || memcmp(m, r, n)) _exit(1);
usleep(400000 + 100000 * i); _exit(0); /* hang up later, one by one */
}
int main(void) {
int L = socket(AF_INET, SOCK_STREAM, 0), maxfd = L, conn[3], nc = 0, closed = 0, it = 0, ok = 0, st;
struct sockaddr_in a = { .sin_family = AF_INET, .sin_addr.s_addr = htonl(INADDR_LOOPBACK) };
socklen_t al = sizeof a; bind(L, (struct sockaddr *)&a, sizeof a); listen(L, 8); getsockname(L, (struct sockaddr *)&a, &al);
for (int i = 0; i < 3; i++) { int c = socket(AF_INET, SOCK_STREAM, 0); connect(c, (struct sockaddr *)&a, sizeof a);
fflush(stdout); if (fork() == 0) client(c, i); close(c); } /* clients connect in order 0,1,2 */
usleep(200000); /* server busy at start: work piles up */
while (closed < 3) {
fd_set rd; FD_ZERO(&rd); if (nc < 3) FD_SET(L, &rd); /* rebuilt on EVERY iteration */
for (int j = 0; j < nc; j++) if (conn[j] >= 0) FD_SET(conn[j], &rd);
int n = select(maxfd + 1, &rd, NULL, NULL, NULL); /* sleep until something is ready */
printf("iter %d: select()=%d, ready fds:", ++it, n);
for (int fd = 0; fd <= maxfd; fd++) if (FD_ISSET(fd, &rd)) printf(" %d", fd);
putchar('\n');
for (int j = 0; j < nc; j++) { int fd = conn[j]; char b[64]; if (fd < 0 || !FD_ISSET(fd, &rd)) continue;
ssize_t k = read(fd, b, sizeof b);
if (k > 0 && write(fd, b, k) == k) printf(" fd %d: echoed \"%.*s\"\n", fd, (int)k, b);
else { printf(" fd %d: EOF -> close\n", fd); close(fd); conn[j] = -1; closed++; } }
if (FD_ISSET(L, &rd)) { conn[nc] = accept(L, NULL, NULL); if (conn[nc] > maxfd) maxfd = conn[nc];
printf(" fd %d: accept() -> new fd %d\n", L, conn[nc]); nc++; } }
while (wait(&st) > 0) ok += WIFEXITED(st) && WEXITSTATUS(st) == 0;
printf("%d of 3 clients got their echo back -- one process, one thread\n", ok); return 0;
}আউটপুট:
iter 1: select()=1, ready fds: 3
fd 3: accept() -> new fd 4
iter 2: select()=2, ready fds: 3 4
fd 4: echoed "hello from client 0"
fd 3: accept() -> new fd 5
iter 3: select()=2, ready fds: 3 5
fd 5: echoed "hello from client 1"
fd 3: accept() -> new fd 6
iter 4: select()=1, ready fds: 6
fd 6: echoed "hello from client 2"
iter 5: select()=1, ready fds: 4
fd 4: EOF -> close
iter 6: select()=1, ready fds: 5
fd 5: EOF -> close
iter 7: select()=1, ready fds: 6
fd 6: EOF -> close
3 of 3 clients got their echo back -- one process, one thread
iter 1: সেটে শুধু listening socket (fd 3), আর accept queue-তে তিনটি সংযোগ অপেক্ষায় — select ১ ফেরত দিল, সার্ভার একটি accept করল, নতুন fd 4. iter 2: এবার সেটে 3 আর 4; দুটিই তৈরি — fd 3-এ আরও সংযোগ, আর fd 4-এ client 0-এর বার্তা আগেই (০.০৫ সেকেন্ডে) এসে বসে আছে। একটি select-এ দুটি ভিন্ন ধরনের কাজ — একটি echo, একটি accept. iter 3-এ একই কাহিনি client 1 (fd 5)-এর জন্য; fd 4 এখন চুপ, তাই তালিকায় নেই। iter 4: listening socket আর সেটে নেই (তিনটিই নেওয়া হয়ে গেছে); ০.৪ সেকেন্ডে fd 6-এ বার্তা। iter 5-7: client-রা একে একে সংযোগ ছাড়ে — read() ০ ফেরত দেয় (EOF), সার্ভার fd বন্ধ করে। শেষ লাইন: তিন client-ই নিজের পাঠানো বার্তা অবিকল ফেরত পেয়েছে, অথচ সার্ভারে একটিও বাড়তি thread নেই। খেয়াল করুন, প্রতিটি iter-এ FD_ZERO থেকে সেট নতুন করে বানানো হয়েছে, আর ফলাফল পড়তে ০ থেকে maxfd পর্যন্ত সব bit ঘোরা হয়েছে — এটাই O(n). এখানে socket-গুলো blocking রাখা হয়েছে সরলতার জন্য, কারণ select “তৈরি” বলার পর একটি read() আটকাবে না; বাস্তব সার্ভারে তবু non-blocking রাখাই নিরাপদ।
৫ · poll() — সীমা নেই, কিন্তু খরচ একই
poll() এসেছিল System V Unix থেকে, select-এর দুটি অসুবিধা দূর করতে। Bit-এর সারির বদলে এটি নেয় একটি struct-এর array:
struct pollfd {
int fd; /* কোন fd; ঋণাত্মক হলে kernel এড়িয়ে যায় */
short events; /* আপনি কী চান: POLLIN, POLLOUT ... */
short revents; /* kernel কী পেল: POLLIN, POLLOUT, POLLERR, POLLHUP, POLLNVAL */
};
int poll(struct pollfd *fds, nfds_t nfds, int timeout_ms); /* -1 = অনন্ত অপেক্ষা */- প্রশ্ন আর উত্তর আলাদা ঘরে:
eventsআপনার,reventskernel-এর। তাই প্রতি loop-এ array নতুন করে বানাতে হয় না। - ১,০২৪-এর সীমা নেই: array যত বড় খুশি — সীমা শুধু প্রসেসের খোলা fd-এর সীমা (
ulimit -n). - ত্রুটি আলাদা করে জানা যায়:
POLLERR,POLLHUP(সংযোগ বিচ্ছিন্ন) আরPOLLNVAL(fd খোলাই নেই) — এগুলো না চাইলেও revents-এ আসে। - কিন্তু খরচ এখনো O(n): প্রতি call-এ পুরো array (প্রতিটি ৮ byte) kernel-এ কপি হয়, kernel সবগুলো পরীক্ষা করে, প্রোগ্রাম সবগুলোর revents ঘুরে দেখে। ১০,০০০ সংযোগে প্রতি call-এ ৮০,০০০ byte আসা-যাওয়া — তৈরি fd হয়তো তিনটি।
নিচের প্রোগ্রামে poll দিয়ে অংশ ৩-এর partial write আর output buffer-এর নিয়ম হাতে-কলমে দেখা যাবে। সার্ভার একটি ধীর client-কে ১,০০০,০০০ byte-এর উত্তর পাঠাতে চায়। Socket non-blocking; দুই দিকের buffer ইচ্ছে করে ছোট (৩২ KiB চাওয়া), যাতে দ্রুত ভরে। Client প্রতিবার মাত্র ৬৪ KiB পড়ে। একই poll()-এ দুটি fd: সার্ভারের দিক (POLLOUT — শুধু যখন buffer-এ কিছু বাকি) আর client-এর দিক (POLLIN).
#include <stdio.h>
#include <string.h>
#include <errno.h>
#include <fcntl.h>
#include <poll.h>
#include <unistd.h>
#include <arpa/inet.h>
static char out[1000000], in[65536]; /* out = this connection's output buffer (1 MB reply) */
int main(void) {
int L = socket(AF_INET, SOCK_STREAM, 0), C = socket(AF_INET, SOCK_STREAM, 0), S, sz = 32768;
setsockopt(C, SOL_SOCKET, SO_RCVBUF, &sz, sizeof sz); /* small buffers so the demo fills them */
struct sockaddr_in a = { .sin_family = AF_INET, .sin_addr.s_addr = htonl(INADDR_LOOPBACK) };
socklen_t al = sizeof a; bind(L, (struct sockaddr *)&a, sizeof a); listen(L, 1);
getsockname(L, (struct sockaddr *)&a, &al); connect(C, (struct sockaddr *)&a, sizeof a); S = accept(L, NULL, NULL);
setsockopt(S, SOL_SOCKET, SO_SNDBUF, &sz, sizeof sz);
fcntl(S, F_SETFL, fcntl(S, F_GETFL) | O_NONBLOCK); /* server side: never block */
size_t sent = 0, got = 0; int writes = 0, wakeups = 0; ssize_t n;
while ((n = write(S, out + sent, sizeof out - sent)) > 0) { sent += n; writes++; printf("write() -> %zd\n", n); }
printf("write() -> %zd, errno = %s (EAGAIN)\n", n, strerror(errno));
printf("kernel took %zu bytes; %zu bytes wait in OUR output buffer\n", sent, sizeof out - sent);
while (got < sizeof out) {
struct pollfd p[2] = { { S, sent < sizeof out ? POLLOUT : 0, 0 }, { C, POLLIN, 0 } };
poll(p, 2, -1); /* sleep until either fd is ready */
if (p[1].revents & POLLIN) { /* the slow client reads 64 KB at a time */
ssize_t k = read(C, in, sizeof in); if (k > 0) got += k; }
if (p[0].revents & POLLOUT) { /* room again: flush more of our buffer */
wakeups++; n = write(S, out + sent, sizeof out - sent);
if (n > 0) { sent += n; writes++; }
if (wakeups <= 3) printf("POLLOUT #%d: write() -> %zd, still buffered %zu\n", wakeups, n, sizeof out - sent);
}
}
printf("done: client got %zu bytes; %d write() calls, %d POLLOUT wake-ups\n", got, writes, wakeups);
return 0;
}আউটপুট:
write() -> 65536
write() -> 16384
write() -> -1, errno = Resource temporarily unavailable (EAGAIN)
kernel took 81920 bytes; 918080 bytes wait in OUR output buffer
POLLOUT #1: write() -> 65536, still buffered 852544
POLLOUT #2: write() -> 65536, still buffered 787008
POLLOUT #3: write() -> 65536, still buffered 721472
done: client got 1000000 bytes; 17 write() calls, 15 POLLOUT wake-ups
প্রথম তিন লাইন: ১০ লাখ byte লিখতে চাওয়া হলো, kernel নিল মাত্র ৬৫,৫৩৬ — partial write. আবার চেষ্টায় আরও ১৬,৩৮৪, তারপর -1 আর EAGAIN (“Resource temporarily unavailable”) — send buffer আর client-এর receive buffer দুটিই ভরা। Blocking socket হলে প্রোগ্রাম এখানে আটকে থাকত, আর অন্য কোনো client-এর কাজ হতো না। চতুর্থ লাইন: kernel মোট ৮১,৯২০ byte নিয়েছে; বাকি ৯,১৮,০৮০ byte আমাদের নিজের output buffer-এ অপেক্ষায় (১০,০০,০০০ − ৮১,৯২০ = ৯,১৮,০৮০)। POLLOUT লাইনগুলো: client পড়লে জায়গা খালি হয়, kernel POLLOUT দিয়ে জানায়, আর আমরা buffer থেকে আরেকটু পাঠাই — প্রতিবার ৬৫,৫৩৬ byte (৯,১৮,০৮০ − ৬৫,৫৩৬ = ৮,৫২,৫৪৪, এভাবে)। শেষ লাইন: client পুরো ১০ লাখ byte পেয়েছে; ১৭টি write = শুরুর ২টি + ১৫টি POLLOUT-এর পরে। হিসাব মেলে: ৮১,৯২০ + ১৪ × ৬৫,৫৩৬ = ৯,৯৯,৪২৪, অর্থাৎ শেষ wake-up-এ বাকি ছিল মাত্র ৫৭৬ byte. ৩২ KiB চেয়ে কেন ৬৫,৫৩৬-এর টুকরো — Linux SO_SNDBUF দ্বিগুণ করে রাখে (পর্ব ৩১); সংখ্যাগুলো kernel-এর সংস্করণ আর সেটিংভেদে আপনার মেশিনে আলাদা হতে পারে, কিন্তু ধরনটি একই থাকবে।
POLLOUT কেবল দরকারের সময়। Loop-এর প্রথম লাইনে খেয়াল করুন: sent < sizeof out ? POLLOUT : 0. সব পাঠানো হয়ে গেলে POLLOUT চাওয়া বন্ধ — নইলে খালি send buffer-এর socket সবসময় “writable”, poll সাথে সাথে ফিরত, আর loop CPU-র ১০০% খেয়ে ঘুরত। epoll-এ একই কাজ হয় EPOLL_CTL_MOD দিয়ে EPOLLOUT যোগ বা বাদ দিয়ে।
৬ · epoll — শুধু তৈরি fd-গুলোই ফেরত (Linux)
select আর poll-এর মূল অপচয় একটি: প্রতিবার পুরো আগ্রহের তালিকা kernel-কে নতুন করে দেওয়া, আর kernel-কে প্রতিবার সব fd নতুন করে পরীক্ষা করা। অথচ তালিকাটি প্রায় বদলায় না — দশ হাজার সংযোগের মধ্যে প্রতি সেকেন্ডে যোগ-বিয়োগ হয় হাতে গোনা কয়েকটি। Linux-এর epoll (২০০২ সালের দিকে Linux 2.5/2.6-এ যুক্ত) এই অপচয় মূল থেকে কাটে: আগ্রহের তালিকা kernel নিজের কাছে রাখে, আর কোনো socket-এ ঘটনা ঘটলে kernel নিজেই সেটিকে একটি “ready list”-এ তোলে। epoll_wait() শুধু সেই তালিকাটি ফেরত দেয়।
int ep = epoll_create1(EPOLL_CLOEXEC); /* একটি epoll instance — নিজেও একটি fd */
struct epoll_event ev = { .events = EPOLLIN, .data.fd = conn };
epoll_ctl(ep, EPOLL_CTL_ADD, conn, &ev); /* একবার নিবন্ধন */
ev.events = EPOLLIN | EPOLLOUT;
epoll_ctl(ep, EPOLL_CTL_MOD, conn, &ev); /* আগ্রহ বদল */
epoll_ctl(ep, EPOLL_CTL_DEL, conn, NULL); /* বাদ */
struct epoll_event ready[64];
int n = epoll_wait(ep, ready, 64, timeout_ms); /* n = শুধু তৈরি fd-এর সংখ্যা */
for (int i = 0; i < n; i++) handle(ready[i].data.fd, ready[i].events);data ঘরে fd-এর বদলে একটি pointer (data.ptr) রাখা যায় — বাস্তব সার্ভার সেখানে সংযোগের পুরো struct-এর ঠিকানা রাখে, যাতে event এলেই সরাসরি সেই সংযোগের অবস্থা হাতে পাওয়া যায়। নিচের stepper-এ একটি loop-iteration দেখুন: আগ্রহের তালিকায় চারটি fd (3 = listening, 5, 6, 7 = সংযোগ), কিন্তু ঘটনা ঘটে দুটিতে।
| Loop কী করছে | |
| আগ্রহের তালিকা (kernel-এ) | |
| Ready list (kernel-এ) | |
| ev[] (প্রোগ্রামের হাতে) | |
| কাজের ফল |
- ঘুম। Loop
epoll_waitডেকে ঘুমায়। Timeout ১,৫০০ ms — সবচেয়ে কাছের timer (অংশ ৮) যত দূরে, তত। আগ্রহের তালিকা kernel আগেই জানে; এই call-এ কোনো তালিকা কপি হয় না। - দুটি ঘটনা। fd 5-এ একটি packet এলো, আর listening socket fd 3-এ একটি নতুন সংযোগের handshake শেষ হলো। দুই ক্ষেত্রেই kernel-এর একটি callback fd-টিকে ready list-এ তোলে। fd 6 আর 7-এ কিছু ঘটেনি — kernel তাদের দিকে তাকায়ও না।
- ফেরা।
epoll_wait২ ফেরত দেয় আর ev[] array-তে দুটি entry লেখে। খরচ তৈরি fd-এর সংখ্যার অনুপাতে, মোট সংযোগের নয় — সংযোগ ৪ হোক বা ৪০,০০০, এখানে কাজ শুধু ২টির। - fd 5 — পড়া আর লেখা। Handler
EAGAINপর্যন্ত পড়ে: ২৬ byte, একটি পূর্ণ request. উত্তর ৮,০০০ byte, কিন্তুwrite()নিল ৫,০০০ — বাকি ৩,০০০ সংযোগের output buffer-এ, আরEPOLL_CTL_MODদিয়ে EPOLLOUT যোগ হলো। - fd 3 — accept loop. Listening socket-ও non-blocking, তাই handler
EAGAINনা আসা পর্যন্তaccept4ডাকে: একটি নতুন সংযোগ (fd 8), তারপর আর নেই। fd 8 EPOLLIN দিয়ে নিবন্ধিত হলো। - Timer আর আবার শুরু। মেয়াদ পেরোনো timer নেই; fd 7-এর idle deadline আরও ১.২ সেকেন্ড পরে, তাই পরের
epoll_wait-এর timeout ১,২০০ ms. পরের iteration-এ fd 5 writable হলে EPOLLOUT event আসবে আর বাকি ৩,০০০ byte যাবে।
খরচের তুলনা — ধরুন সক্রিয় সংযোগ মাত্র ৫টি
| মোট সংযোগ n | select: প্রতি call-এ পরীক্ষা | poll: প্রতি call-এ পরীক্ষা (কপি) | epoll_wait: ফেরত (কপি) | poll ÷ epoll |
|---|---|---|---|---|
| ১০ | ~১০ fd (nfds পর্যন্ত) | ১০ (৮০ byte) | ৫ (৬০ byte) | ২ গুণ |
| ১,০০০ | ~১,০০০ | ১,০০০ (৮,০০০ byte) | ৫ (৬০ byte) | ২০০ গুণ |
| ১০,০০০ | সম্ভবই নয় — FD_SETSIZE ১,০২৪ | ১০,০০০ (৮০,০০০ byte) | ৫ (৬০ byte) | ২,০০০ গুণ |
(x86-64 Linux-এ struct pollfd ৮ byte আর struct epoll_event ১২ byte ধরে হিসাব।) epoll-এর বাড়তি খরচ একটিই: সংযোগ যোগ, বদল বা বাদের সময় একটি করে epoll_ctl system call. যে সার্ভারে সংযোগ খুব স্বল্পস্থায়ী আর প্রতিটিতে অনেকবার আগ্রহ বদলায়, সেখানে এই খরচ চোখে পড়ে; সংযোগ কম হলে poll-ও দিব্যি চলে।
Level-triggered বনাম edge-triggered
epoll দুইভাবে জানাতে পারে। Level-triggered (LT, ডিফল্ট): socket যতক্ষণ পড়ার মতো অবস্থায় আছে, প্রতিটি epoll_wait-এ ততক্ষণ event আসবে — select আর poll-ও এভাবেই কাজ করে। Edge-triggered (ET, EPOLLET flag): event আসে শুধু অবস্থা বদলের মুহূর্তে — নতুন ডেটা আসার সময় — একবার। পুরোনো, না-পড়া ডেটার জন্য আর মনে করিয়ে দেওয়া হয় না।
মোবাইলে মিসড কল। Level-triggered যেন সেই বন্ধু, যিনি আপনি না ধরা পর্যন্ত প্রতি মিনিটে কল দিতেই থাকেন। Edge-triggered যেন একবার একটি SMS — “আপনার পার্সেল এসেছে” — এরপর আর কোনো বার্তা নয়; আপনি অর্ধেক মাল এনে বাকিটা ভুলে গেলে সেটি গুদামেই পড়ে থাকবে, যতক্ষণ না নতুন কোনো পার্সেল এসে আরেকটি SMS আসে।
| ঘটনা | |
| socket-এ না-পড়া byte | |
| LT: epoll_wait | |
| ET: epoll_wait | |
| ফল |
- ডেটা এলো। খালি socket-এ ৩,০০০ byte এলো। LT-তে socket readable, তাই event; ET-তে “খালি থেকে ভরা” — একটি edge, তাই event. এখানে দুটি মোডে কোনো পার্থক্য নেই।
- ভুল handler. Handler একবার ১,০২৪ byte পড়ে loop-এ ফিরে গেল। Socket-এ পড়ে রইল ৩,০০০ − ১,০২৪ = ১,৯৭৬ byte.
- পার্থক্যের মুহূর্ত। LT-তে socket এখনো readable, তাই আবার event — বাকিটা পরের ধাপে পড়া হবে, শুধু একটু দেরি। ET-তে নতুন কোনো ডেটা আসেনি, তাই event নেই। Client উত্তরের অপেক্ষায়, সার্ভার event-এর অপেক্ষায় — দুই পক্ষই চুপ। এটাই edge-triggered-এর বিখ্যাত বাগ।
- ভাগ্যক্রমে মুক্তি। Client আরও ৫০০ byte পাঠাল — নতুন ডেটা মানে নতুন edge, তাই ET আবার event দিল। Socket-এ এখন ১,৯৭৬ + ৫০০ = ২,৪৭৬ byte. বাস্তবে request-response protocol-এ client প্রায়ই আর কিছু পাঠায় না, তখন সংযোগ timeout পর্যন্ত ঝুলে থাকে।
- সঠিক handler.
read()চলতে থাকে যতক্ষণ না-1আরEAGAIN: ১,০২৪ + ১,০২৪ + ৪২৮ = ২,৪৭৬ byte — socket পুরো খালি। - নিরাপদ ঘুম। কিছু পড়ে নেই, দুই মোডেই event নেই, আর থাকার কথাও নয়। ET-এর নিয়ম তাই এক লাইনের: event পেলে
EAGAINনা আসা পর্যন্ত পড়ুন (বা লিখুন, বা accept করুন)।
এবার একই কাহিনি আসল epoll-এ। Client আর সার্ভার একই প্রোগ্রামে, loopback-এ একটি TCP সংযোগের দুই মাথা। wait_show() একবার epoll_wait ডাকে (২০০ ms পর্যন্ত অপেক্ষা) আর ioctl(FIONREAD) দিয়ে দেখায় socket-এ কত byte না-পড়া পড়ে আছে।
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/epoll.h>
#include <sys/ioctl.h>
#include <arpa/inet.h>
static char buf[1024], data[3000]; static int ep, S;
static void wait_show(const char *tag) { /* one epoll_wait: how many events, how much is unread? */
struct epoll_event ev[4]; int n = epoll_wait(ep, ev, 4, 200), left = 0; ioctl(S, FIONREAD, &left);
printf("%-26s epoll_wait -> %d event(s), unread bytes in socket = %d\n", tag, n, left);
}
int main(void) {
int L = socket(AF_INET, SOCK_STREAM, 0), C = socket(AF_INET, SOCK_STREAM, 0); ssize_t k;
struct sockaddr_in a = { .sin_family = AF_INET, .sin_addr.s_addr = htonl(INADDR_LOOPBACK) };
socklen_t al = sizeof a; bind(L, (struct sockaddr *)&a, sizeof a); listen(L, 1);
getsockname(L, (struct sockaddr *)&a, &al); connect(C, (struct sockaddr *)&a, sizeof a); S = accept(L, NULL, NULL);
fcntl(S, F_SETFL, O_NONBLOCK); ep = epoll_create1(0);
struct epoll_event e = { .events = EPOLLIN | EPOLLET, .data.fd = S };
epoll_ctl(ep, EPOLL_CTL_ADD, S, &e); /* edge-triggered */
if (write(C, data, 3000) != 3000) return 1; /* client sends 3000 bytes */
wait_show("[ET] 3000 bytes arrive:");
k = read(S, buf, sizeof buf); printf(" BUGGY handler: read once -> %zd, then back to the loop\n", k);
wait_show("[ET] next iteration:"); /* no new edge = no event! */
if (write(C, data, 500) != 500) return 1; /* only NEW data makes a new edge */
wait_show("[ET] 500 more bytes:");
printf(" CORRECT handler: drain until EAGAIN:");
while ((k = read(S, buf, sizeof buf)) > 0) printf(" %zd", k); /* stop only at EAGAIN */
printf(" | %zd (EAGAIN)\n", k); wait_show("[ET] next iteration:");
e.events = EPOLLIN; epoll_ctl(ep, EPOLL_CTL_MOD, S, &e); /* switch to level-triggered */
if (write(C, data, 3000) != 3000) return 1;
wait_show("[LT] 3000 bytes arrive:");
k = read(S, buf, sizeof buf); printf(" same 'read once' handler -> %zd\n", k);
wait_show("[LT] next iteration:"); /* LT reminds us again */
return 0;
}আউটপুট:
[ET] 3000 bytes arrive: epoll_wait -> 1 event(s), unread bytes in socket = 3000
BUGGY handler: read once -> 1024, then back to the loop
[ET] next iteration: epoll_wait -> 0 event(s), unread bytes in socket = 1976
[ET] 500 more bytes: epoll_wait -> 1 event(s), unread bytes in socket = 2476
CORRECT handler: drain until EAGAIN: 1024 1024 428 | -1 (EAGAIN)
[ET] next iteration: epoll_wait -> 0 event(s), unread bytes in socket = 0
[LT] 3000 bytes arrive: epoll_wait -> 1 event(s), unread bytes in socket = 3000
same 'read once' handler -> 1024
[LT] next iteration: epoll_wait -> 1 event(s), unread bytes in socket = 1976
প্রথম তিন লাইন — বাগটি নিজের চোখে: ET মোডে ৩,০০০ byte আসায় ১টি event; ভুল handler ১,০২৪ পড়ল; পরের epoll_wait পুরো ২০০ ms অপেক্ষা করে ফিরল ০ event নিয়ে — অথচ socket-এ তখনো ১,৯৭৬ byte পড়ে আছে! পরের তিন লাইন: নতুন ৫০০ byte আসায় নতুন edge, event এলো (না-পড়া ২,৪৭৬); সঠিক handler EAGAIN পর্যন্ত পড়ল — ১,০২৪, ১,০২৪, ৪২৮, তারপর −১; এবার socket খালি (০) আর event না আসাটাই ঠিক। শেষ তিন লাইন: EPOLL_CTL_MOD দিয়ে LT-তে বদলে একই “একবার পড়া” handler চালানো হলো — পরের epoll_wait সাথে সাথে আবার ১ event দিল, কারণ ১,৯৭৬ byte এখনো পড়ে আছে। LT ভুল ক্ষমা করে, ET করে না।
আরও কয়েকটি flag
- EPOLLONESHOT — একটি event দেওয়ার পর fd-টি নিষ্ক্রিয় হয়ে যায়, যতক্ষণ না
EPOLL_CTL_MODদিয়ে আবার চালু করা হয়। একাধিক thread একই epoll instance থেকে event নিলে এটি নিশ্চিত করে যে একটি সংযোগ একসাথে দুটি thread-এর হাতে পড়বে না। - EPOLLRDHUP — অপর প্রান্ত লেখা বন্ধ করেছে (FIN পাঠিয়েছে), এটা আলাদা করে জানায়;
read()করে ০ পাওয়ার আগেই বোঝা যায় (Linux 2.6.17 থেকে)। - EPOLLERR আর EPOLLHUP — না চাইলেও সবসময় আসে; handler-কে এগুলোও সামলাতে হয়।
- EPOLLEXCLUSIVE — একই listening socket অনেক epoll instance-এ থাকলে নতুন সংযোগে সবাইকে না জাগিয়ে কয়েকটিকে জাগায় (Linux 4.5 থেকে) — “thundering herd” কমানোর জন্য, অংশ ৯-এ।
ET-তে ন্যায্যতার ফাঁদ। “EAGAIN পর্যন্ত পড়ুন” নিয়ম অক্ষরে অক্ষরে মানলে একটি খুব দ্রুত client (যে অনবরত পাঠাচ্ছে) loop-কে নিজের কাছেই আটকে রাখতে পারে — অন্যরা অপেক্ষায়। তাই বাস্তব সার্ভার প্রতি iteration-এ একটি সংযোগ থেকে সর্বোচ্চ একটি সীমা (ধরুন ৬৪ KiB) পড়ে, তারপর সংযোগটিকে নিজের একটি “এখনো তৈরি” তালিকায় রেখে পরের iteration-এ বাকিটা পড়ে। ET-এর নিয়ম ভাঙা হয় না, কারণ সংযোগটি নিজের তালিকায় মনে রাখা আছে।
আরও গভীরে: Linux kernel-এ একটি epoll instance আগ্রহের fd-গুলো একটি red-black tree-তে রাখে, আর প্রতিটি socket-এর wait queue-তে একটি callback বসায়; ডেটা এলে network stack সেই callback ডাকে, যা fd-টিকে একটি linked list (ready list)-এ তোলে আর ঘুমন্ত epoll_wait-কে জাগায় — তাই খরচ O(ready). একটি সূক্ষ্ম ফাঁদ: epoll নিবন্ধন আসলে fd নম্বরের নয়, ভেতরের “open file description”-এর; dup() বা fork()-এর পর একটি কপি খোলা থাকলে close(fd) করেও event আসতে পারে — তাই close-এর আগে EPOLL_CTL_DEL করাই নিরাপদ অভ্যাস। বিস্তারিত man 7 epoll-এর “Questions and answers” অংশে।
৭ · অন্য OS-এ: kqueue, IOCP আর io_uring
epoll শুধু Linux-এর। অন্য OS-এ একই সমস্যার নিজস্ব সমাধান আছে, আর এদের মধ্যে একটি গভীর নকশাগত বিভাজন আছে: readiness (“এখন পড়লে আটকাবেন না” — তারপর আপনি নিজে পড়েন) বনাম completion (“আপনার buffer-এ পড়ে দিয়েছি” — কাজ শেষ)।
kqueue (FreeBSD, macOS)
২০০০ সালে FreeBSD 4.1-এ আসে; macOS, OpenBSD, NetBSD-তেও আছে। একটি kevent() call-এ একসাথে আগ্রহ বদল আর অপেক্ষা। শুধু socket নয় — timer, signal, প্রসেস শেষ হওয়া, ফাইল বদল (filter: EVFILT_READ, EVFILT_TIMER, EVFILT_PROC…) সবই এক জায়গায়।
IOCP (Windows)
I/O Completion Port: প্রোগ্রাম আগে থেকেই buffer দিয়ে “overlapped” WSARecv শুরু করে; kernel ডেটা এনে buffer ভরে, তারপর completion port-এ একটি বার্তা রাখে। কয়েকটি worker thread GetQueuedCompletionStatus দিয়ে সেগুলো তোলে।
io_uring (Linux 5.1+)
২০১৯ সালে Linux 5.1-এ Jens Axboe-র কাজ হিসেবে আসে। প্রোগ্রাম আর kernel-এর মাঝে দুটি ভাগ করা ring buffer: submission queue-তে কাজের অনুরোধ, completion queue-তে ফল। অনেক কাজ একসাথে জমা দেওয়া যায়, system call কম লাগে।
completionলাইব্রেরি যা লুকিয়ে রাখে
libuv (Node.js), libevent, libev, Boost.Asio (C++), Rust-এর mio/tokio, Java NIO/Netty — সবাই নিচে OS অনুযায়ী epoll, kqueue বা IOCP বেছে নেয়, ওপরে একটি অভিন্ন API দেয়।
বহনযোগ্যতাReadiness মডেলে buffer লাগে শুধু ডেটা আসার পরে — দশ হাজার চুপ সংযোগের জন্য কোনো buffer আগাম আটকে থাকে না। Completion মডেলে প্রতিটি অপেক্ষমাণ read-এর জন্য আগে থেকেই buffer দিতে হয়, বিনিময়ে কপির কাজটিও kernel করে দেয় আর call-এর সংখ্যা কমে। io_uring নতুন আর ক্ষমতাশালী, তবে এর বড় API-তে বিভিন্ন সময়ে নিরাপত্তা-ত্রুটি পাওয়া গেছে; আমাদের জানামতে কিছু বড় প্রতিষ্ঠান আর অনেক container runtime-এর ডিফল্ট seccomp নীতি একে সীমিত বা বন্ধ রাখে — ব্যবহারের আগে নিজের পরিবেশে যাচাই করা দরকার। পরিচিত নেটওয়ার্ক সার্ভারগুলোর বেশিরভাগ আজও epoll-এই চলে।
সাধারণ ফাইল কখনো “অপেক্ষায়” থাকে না। epoll বা select-এ একটি সাধারণ disk ফাইল দিলে সেটি সবসময় “তৈরি” দেখায় (epoll তো সরাসরি EPERM দিয়ে প্রত্যাখ্যান করে), অথচ পড়তে গেলে disk-এর জন্য আসলে আটকে যেতে পারে। Readiness মডেল ফাইলের জন্য কাজ করে না — এজন্যই libuv ফাইলের কাজ একটি thread pool-এ পাঠায়, আর completion-ভিত্তিক io_uring এখানে বড় সুবিধা দেয়।
৮ · Reactor pattern — event loop, handler আর প্রতিটি সংযোগের state machine
epoll একটি যন্ত্র; তার চারপাশে সার্ভারের পুরো কাঠামোকে বলে reactor pattern. এর তিনটি অংশ: একটি demultiplexer (epoll/kqueue — “কে তৈরি?”), একটি dispatcher (event loop — “তৈরি fd-এর handler ডাকা”) আর handler-গুলো (প্রতিটি সংযোগের নির্দিষ্ট কাজ)। Completion-ভিত্তিক সমতুল্য নকশাকে বলে proactor (IOCP, io_uring).
struct conn { int fd; enum { READING, PROCESSING, WRITING } state;
buffer in, out; long deadline; };
for (;;) {
int timeout = ms_until(earliest_deadline()); /* timer থেকে */
int n = epoll_wait(ep, ev, 64, timeout);
for (int i = 0; i < n; i++) {
struct conn *c = ev[i].data.ptr;
if (c == LISTENER) accept_all(); /* EAGAIN পর্যন্ত */
else if (ev[i].events & (EPOLLERR | EPOLLHUP)) close_conn(c);
else {
if (ev[i].events & EPOLLIN) on_readable(c); /* state অনুযায়ী */
if (ev[i].events & EPOLLOUT) on_writable(c);
}
}
run_expired_timers(); /* idle সংযোগ বন্ধ */
}Blocking নকশায় একটি সংযোগের “কোথায় আছি” তথ্য থাকত thread-এর stack-এ আর কোডের লাইনে — “এখন read()-এ আটকে আছি, request পড়ছি”. Event loop-এ কোনো handler আটকে থাকতে পারে না, তাই এই তথ্য স্পষ্ট করে প্রতিটি সংযোগের struct-এ রাখতে হয়: একটি state machine. Request অর্ধেক এলে অর্ধেকটা input buffer-এ জমা থাকে, state থাকে READING; বাকি অর্ধেক পরের event-এ আসবে। নিচে একটি HTTP-ধাঁচের সংযোগের জীবন — request আসে দুই টুকরোয়, উত্তর যায় তিন টুকরোয়।
| ঘটনা | |
| state | |
| input buffer | |
| output buffer (বাকি) | |
| epoll-এ আগ্রহ | |
| idle deadline |
- জন্ম। Listening socket-এর handler
accept4দিয়ে fd 8 পেল, একটি নতুন সংযোগ-struct বানাল: state READING, buffer খালি, EPOLLIN দিয়ে নিবন্ধন, আর ১০ সেকেন্ডের idle deadline. - অর্ধেক request. প্রথম packet-এ এলো মাত্র ১৪ byte — “GET /report HT”. Request শেষ হয়নি (শেষের ফাঁকা লাইন আসেনি), তাই handler ১৪ byte input buffer-এ রেখে
EAGAINপেয়ে ফিরে গেল। State READING-ই রইল। Blocking সার্ভার হলে thread এখানে আটকে থাকত; এখানে loop অন্য সংযোগে চলে গেল। - পূর্ণ request. বাকি ১০ byte এলো (১৪ + ১০ = ২৪)। এবার request সম্পূর্ণ। রিপোর্ট বানানো ভারী কাজ, তাই handler সেটি একটি worker thread-এ পাঠাল (অংশ ৯), state PROCESSING, আর epoll-এ আগ্রহ আপাতত শূন্য — এই সংযোগ থেকে এখন কিছু পড়ার নেই।
- উত্তর তৈরি। Worker কাজ শেষ করে একটি eventfd বা pipe-এ লিখে loop-কে জাগাল। ৩,০০,০০০ byte-এর উত্তর output buffer-এ, প্রথম
writeনিল ১,২০,০০০; বাকি ১,৮০,০০০. State WRITING, আগ্রহ EPOLLOUT. - আরও লেখা। Client পড়েছে, send buffer-এ জায়গা হয়েছে — EPOLLOUT. আরও ১,২০,০০০ গেল, বাকি ৬০,০০০.
- উত্তর শেষ। শেষ ৬০,০০০ byte গেল, output buffer খালি। HTTP keep-alive, তাই সংযোগ বন্ধ নয়: state আবার READING, আগ্রহ EPOLLIN (EPOLLOUT বাদ — নইলে loop অকারণে ঘুরত), নতুন idle deadline.
- বিদায়। দশ সেকেন্ডে নতুন request আসেনি। Timer-এর অংশ সংযোগটিকে মেয়াদোত্তীর্ণ পেল:
EPOLL_CTL_DEL,close(8), struct মুক্ত। একই কাঠামোয় একটি thread হাজার হাজার এমন state machine একসাথে চালায়।
Timer — idle timeout কীভাবে রাখবেন
প্রতিটি সংযোগের একটি idle timeout দরকার — নইলে চুপ বা অসৎ client (যেমন খুব ধীরে এক-দুই byte পাঠানো slowloris-ধাঁচের আচরণ) চিরকাল fd আর মেমরি আটকে রাখবে। দুটি প্রচলিত উপায়:
- Deadline-এর min-heap: সব deadline একটি min-heap-এ (DSA কোর্সের heap পর্ব); সবচেয়ে কাছের deadline সবসময় মাথায়, O(1)-এ দেখা যায়। Loop প্রতিবার
epoll_wait-এর timeout = (সবচেয়ে কাছের deadline − এখন) দেয়, ফিরে এসে মেয়াদোত্তীর্ণগুলো তোলে — প্রতিটি O(log n). Deadline বদলালে পুরোনো entry মুছে না দিয়ে “অচল” চিহ্ন দিয়ে রাখা (lazy deletion) একটি সাধারণ কৌশল। অনেক সিস্টেম heap-এর বদলে timer wheel-ও ব্যবহার করে। - timerfd (Linux):
timerfd_create()একটি fd দেয়, যা নির্ধারিত সময়ে readable হয় — timer-ও তখন epoll-এর আরেকটি event. সাধারণত অনেক সংযোগের জন্য একটি timerfd রেখে তার সময় heap-এর মাথা অনুযায়ী বসানো হয়, প্রতিটির জন্য আলাদা নয়।
Accept loop আর EMFILE — চুপচাপ ১০০% CPU
Listening socket-ও non-blocking রাখুন, আর একটি event-এ EAGAIN না আসা পর্যন্ত accept করুন (ET-তে এটা বাধ্যতামূলক; LT-তেও কার্যকর, এক জাগরণে অনেক সংযোগ)। এখানে একটি কুখ্যাত ফাঁদ আছে: প্রসেসের খোলা fd-এর সীমা (ulimit -n) ফুরিয়ে গেলে accept -1 আর EMFILE দেয় — কিন্তু সংযোগটি accept queue-তেই থেকে যায়। LT মোডে listening socket তাই এখনো “readable”, loop সাথে সাথে আবার জাগে, আবার EMFILE… CPU ১০০%, অথচ কোনো কাজ হচ্ছে না।
static int spare_fd; /* শুরুতে: spare_fd = open("/dev/null", O_RDONLY); */
void accept_all(int lfd) {
for (;;) {
int c = accept4(lfd, NULL, NULL, SOCK_NONBLOCK | SOCK_CLOEXEC);
if (c >= 0) { add_connection(c); continue; }
if (errno == EAGAIN || errno == EWOULDBLOCK) return; /* queue খালি */
if (errno == EINTR || errno == ECONNABORTED) continue;
if (errno == EMFILE || errno == ENFILE) { /* fd ফুরিয়েছে */
close(spare_fd); /* একটি fd খালি করি */
c = accept(lfd, NULL, NULL); /* সংযোগটি তুলে */
if (c >= 0) close(c); /* ভদ্রভাবে বন্ধ */
spare_fd = open("/dev/null", O_RDONLY); /* আবার জমা রাখি */
log_warning("fd limit reached");
return;
}
return; /* অন্য ত্রুটি: log */
}
}এই “বাড়তি fd জমা রাখা” কৌশলটি libev-এর ডকুমেন্টেশনসহ নানা জায়গায় বর্ণিত। বিকল্প হলো EMFILE পেলে কিছুক্ষণ listening socket-কে epoll থেকে সরিয়ে রাখা। আসল সমাধান অবশ্য সীমা যথেষ্ট বড় রাখা আর idle সংযোগ সময়মতো বন্ধ করা।
৯ · Event loop কখনো আটকাবেন না — আর বাস্তবের সার্ভারগুলো
Event-driven সার্ভারের সবচেয়ে প্রচলিত, আর সবচেয়ে মারাত্মক, ভুল: কোনো handler-এর ভেতরে এমন কাজ করা যা সময় নেয়। এক thread মানে একসাথে একটিই কাজ। Handler ২০০ ms ধরে CPU-তে হিসাব করলে সেই ২০০ ms অন্য দশ হাজার সংযোগের কেউ একটি byte-ও পায় না, কোনো timer চলে না। “আটকানো কাজ”-এর তালিকা অনেকের ধারণার চেয়ে লম্বা: বড় JSON parse, ছবি resize, password hashing (bcrypt ইচ্ছে করেই ধীর), synchronous database বা ফাইলের call, sleep(), এমনকি getaddrinfo() — সাধারণ DNS lookup-ও blocking call (পর্ব ৩৫-এর DNS মনে করুন: একটি query-তে শত ms লাগতে পারে)।
নিচের simulator-এ একটি event loop, পাঁচটি request আর প্রতি ৫০ ms-এ একটি timer. Request B একটি বড় রিপোর্ট — ২০০ ms CPU. প্রথমবার B-এর কাজ loop-এর ভেতরেই চলে; দ্বিতীয়বার loop কাজটি ০.৫ ms-এ একটি worker pool-এ পাঠিয়ে দেয়, আর কাজ শেষের “done” event এলে উত্তর পাঠায়।
// One event loop, 5 requests + a 50 ms timer. Request B needs 200 ms of CPU (a big report).
// Mode "inline": B's work runs inside the loop. Mode "offload": the loop hands B to a worker pool.
const reqs = [["A", 0, 2], ["B", 1, 200], ["C", 3, 2], ["D", 5, 2], ["E", 10, 2]]; // [id, arrives(ms), cpu(ms)]
function run(mode) {
let ev = reqs.map(([id, t, cpu]) => ({ t, id, cpu, arr: t }));
for (let k = 50; k <= 150; k += 50) ev.push({ t: k, id: "timer", cpu: 0.1, arr: k });
let now = 0; const out = [];
while (ev.length) {
ev.sort((x, y) => x.t - y.t || (x.id < y.id ? -1 : 1));
const e = ev.shift(); now = Math.max(now, e.t); // loop picks the next ready event
if (mode === "offload" && e.cpu > 50) { // 0.5 ms to hand off; done-event later
now += 0.5; ev.push({ t: now + e.cpu, id: e.id, cpu: 1, arr: e.arr, back: true }); continue;
}
now += e.cpu; // NOTHING else runs during this time
out.push(e.id === "timer" ? `timer@${e.arr} fired ${(now - e.cpu - e.arr).toFixed(1)} ms late`
: `${e.id}: response after ${(now - e.arr).toFixed(1)} ms`);
}
console.log(`--- ${mode} ---\n` + out.join("\n"));
}
run("inline"); run("offload");আউটপুট:
--- inline ---
A: response after 2.0 ms
B: response after 201.0 ms
C: response after 201.0 ms
D: response after 201.0 ms
E: response after 198.0 ms
timer@50 fired 158.0 ms late
timer@100 fired 108.1 ms late
timer@150 fired 58.2 ms late
--- offload ---
A: response after 2.0 ms
C: response after 2.0 ms
D: response after 2.0 ms
E: response after 2.0 ms
timer@50 fired 0.0 ms late
timer@100 fired 0.0 ms late
timer@150 fired 0.0 ms late
B: response after 202.5 ms
inline: A শুরুতেই এসে ২ ms-এ উত্তর পেল। B এলো ১ ms-এ, loop তাকে ধরল ২ ms-এ, শেষ করল ২০২ ms-এ। এর মধ্যে C (৩ ms), D (৫ ms), E (১০ ms) এসে বসে রইল — প্রত্যেকের কাজ মাত্র ২ ms, অথচ উত্তর পেল ২০১, ২০১ আর ১৯৮ ms পরে (যেমন C: ২০২ থেকে ২০৪-এ শেষ, এসেছিল ৩-এ, তাই ২০১)। ৫০ ms-এর timer চলল ২০৮ ms-এ — ১৫৮ ms দেরিতে; idle timeout বা heartbeat এভাবে দেরি হলে client-রা সংযোগ মৃত ভাবতে পারে। offload: loop B-কে ০.৫ ms-এ হাতবদল করে মুক্ত; C, D, E আবার ২ ms-এ উত্তর পায়, timer একদম সময়মতো। B নিজে ২০২.৫ ms নেয় — তার কাজ তো ২০০ ms-এরই — কিন্তু সে আর অন্যদের শাস্তি দেয় না। শিক্ষা: event loop-এ latency সবার ভাগাভাগি করা; একজনের ধীরতা সবার ধীরতা।
প্রতিকার তিনটি: (১) CPU-ভারী কাজ একটি thread pool-এ পাঠান (পর্ব ৪৩-এর thread pool), কাজ শেষে eventfd বা pipe দিয়ে loop-কে জানান; (২) লম্বা কাজকে ছোট ছোট টুকরোয় ভাগ করে প্রতি iteration-এ একটু একটু করুন; (৩) blocking API-র non-blocking বিকল্প নিন — যেমন DNS-এর জন্য c-ares-এর মতো asynchronous resolver.
বাস্তব উদাহরণ — Redis-এর KEYS. Redis-এ একটি command পুরো ডেটাবেস ঘুরে মিল খোঁজে (KEYS pattern). কোটি key-র ডেটাবেসে এটি কয়েক সেকেন্ড নিতে পারে, আর সেই সময় অন্য সব client-এর সব command অপেক্ষা করে — কারণ command চলে একটি thread-এ। Redis-এর নিজের ডকুমেন্টেশন production-এ এর বদলে ধাপে ধাপে চলা SCAN ব্যবহারের পরামর্শ দেয় — ঠিক উপরের প্রতিকার (২)।
বাস্তবের সার্ভারগুলো কীভাবে সাজানো
| সিস্টেম | কাঠামো | লক্ষ করার বিষয় |
|---|---|---|
| nginx | একটি master process (config পড়ে, worker চালায়) + কয়েকটি worker process, সাধারণত প্রতি CPU core-এ একটি (worker_processes auto); প্রতিটি worker-এ একটি event loop (epoll/kqueue) | প্রতিটি worker হাজার হাজার সংযোগ ধরে (worker_connections); Igor Sysoev-এর লেখা, ২০০৪ সালে প্রকাশ্যে আসে, C10K-ই ছিল মূল লক্ষ্য। Reverse proxy হিসেবে বহুল ব্যবহৃত |
| Node.js + libuv | JavaScript চলে একটি thread-এ; libuv নিচে epoll/kqueue/IOCP চালায় | ফাইল, dns.lookup, কিছু crypto আর zlib-এর কাজ libuv-এর thread pool-এ যায় (ডিফল্ট ৪টি thread, UV_THREADPOOL_SIZE দিয়ে বদলানো যায়) |
| Redis | Command চলে একটি event loop-এ, এক thread-এ | Lock লাগে না, তাই প্রতিটি command atomic; সংস্করণ ৬ থেকে socket পড়া-লেখার জন্য ঐচ্ছিক I/O thread যোগ হয়েছে, কিন্তু command execution এখনো মূলত এক thread-এ |
| Go | প্রতি সংযোগে একটি goroutine, কোড দেখতে blocking | Runtime-এর “netpoller” নিচে epoll/kqueue/IOCP ব্যবহার করে: goroutine Read-এ আটকালে runtime তাকে সরিয়ে রেখে socket তৈরি হলে আবার চালায় — event loop প্রোগ্রামারের চোখের আড়ালে |
প্রতি core-এ একটি loop — SO_REUSEPORT
একটি event loop একটিই CPU core ব্যবহার করে। ১৬ core-এর মেশিনে তাই সাধারণত ১৬টি loop চলে — প্রতিটি আলাদা thread বা process-এ, নিজের সংযোগ নিয়ে, lock ছাড়াই। প্রশ্ন হলো নতুন সংযোগ কোন loop পাবে। পুরোনো উপায়ে সবাই একই listening socket-এ নজর রাখে; নতুন সংযোগ এলে সবাই একসাথে জাগে কিন্তু পায় একজন — বাকিরা খালি হাতে ফেরে। একে বলে thundering herd. আধুনিক Linux-এ (3.9 থেকে) SO_REUSEPORT দিয়ে প্রতিটি loop একই port-এ নিজের আলাদা listening socket খোলে, আর kernel নতুন সংযোগগুলো (ঠিকানা আর port-এর hash দিয়ে) এদের মধ্যে ভাগ করে দেয়। nginx-এর reuseport বিকল্প ঠিক এটাই করে। সংযোগ সমানভাবে ভাগ হয়, তবে একটি loop ধীর হয়ে গেলেও তার ভাগের নতুন সংযোগ তার কাছেই যায় — এটি এর একটি পরিচিত দুর্বলতা।
কখন thread-ই যথেষ্ট
Event-driven নকশা জটিল: প্রতিটি সংযোগের state machine, partial read/write, timer, worker pool-এর সাথে যোগাযোগ — debug করাও কঠিন, কারণ stack trace-এ “কোন request-এর কোন ধাপ” আর দেখা যায় না। তাই thread-per-connection এখনো অনেক জায়গায় সঠিক পছন্দ:
- সংযোগ কয়েকশো বা কয়েক হাজার — আধুনিক Linux কয়েক হাজার thread ভালোভাবেই চালায়।
- প্রতিটি request-ই CPU-ভারী — event loop থাকলেও কাজ শেষ পর্যন্ত thread-এ যেত।
- অভ্যন্তরীণ টুল, database-এর connection-ভিত্তিক সার্ভার, যেখানে সংযোগ সংখ্যা সীমিত আর কোডের সরলতা বেশি মূল্যবান।
- যে ভাষার runtime নিজেই সস্তা “হালকা thread” দেয় — Go-র goroutine, Java 21-এর virtual thread, Erlang-এর process — সেখানে প্রোগ্রামার blocking ধাঁচে লেখেন, আর runtime ভেতরে event-driven.
পরীক্ষা ও ইন্টারভিউতে এই ধরনের প্রশ্ন প্রায়ই আসে:
- select, poll আর epoll-এর পার্থক্য? — select: bitmask, ১,০২৪ সীমা, প্রতিবার সেট বানানো, O(n); poll: pollfd array, সীমা নেই, তবু O(n); epoll: kernel-এ নিবন্ধন, ফেরত শুধু তৈরি fd — O(ready).
- Level-triggered আর edge-triggered কী? — LT তৈরি থাকা পর্যন্ত বারবার জানায়; ET শুধু অবস্থা বদলের মুহূর্তে একবার, তাই EAGAIN পর্যন্ত পড়তে হয়।
- C10K সমস্যা কী? — প্রায় ১৯৯৯ সালে Dan Kegel-এর তোলা প্রশ্ন: এক সার্ভারে ১০,০০০ সমান্তরাল client; সমাধান non-blocking I/O + কার্যকর multiplexing (epoll/kqueue).
- Node.js এক thread-এ চললে ফাইল পড়া কীভাবে আটকায় না? — libuv ফাইলের কাজ নিজের thread pool-এ চালায়; নেটওয়ার্কের জন্য epoll/kqueue/IOCP.
- Non-blocking write কম byte ফেরত দিলে? — বাকিটা সংযোগের output buffer-এ রেখে writable event-এর অপেক্ষা; buffer খালি হলে writable-আগ্রহ বন্ধ।
১০ · AI-এর যুগে event-driven কোড — যাচাই না করে চালাবেন না
“C-তে একটি epoll echo server লিখে দিন” — AI কয়েক সেকেন্ডে চল্লিশ লাইন দেবে, আর প্রথম পরীক্ষায় সেটি চলবেও। সমস্যা ধরা পড়ে পরে: বড় request এলে, ধীর client এলে, হাজার সংযোগে বা fd ফুরিয়ে গেলে। এই বিষয়ে AI-এর ভুলগুলোর একটি চেনা নকশা আছে:
| AI-এর ভুল | কেন ভুল | কীভাবে ধরবেন |
|---|---|---|
EPOLLET দিয়ে handler-এ একবারই read() | বাকি ডেটার জন্য আর event আসে না — সংযোগ ঝুলে থাকে | বড় (৬৪ KiB+) request পাঠিয়ে পরীক্ষা; কোডে “EAGAIN পর্যন্ত loop” খুঁজুন |
write()-এর ফেরত মান উপেক্ষা | Partial write-এ উত্তরের শেষ অংশ হারায় | ধীর client দিয়ে বড় উত্তর নামিয়ে byte গুনুন |
| সবসময় EPOLLOUT চালু রাখা | Loop ১০০% CPU-তে ঘোরে | চুপ সার্ভারে top দেখুন |
বড় fd-এ select() | FD_SETSIZE ১,০২৪-এর বাইরে মেমরি নষ্ট | ulimit -n বাড়িয়ে ১,১০০ সংযোগে পরীক্ষা |
Handler-এ blocking call (getaddrinfo, ফাইল, sleep) | সব সংযোগ একসাথে থেমে যায় | একটি ধীর request চলাকালীন অন্য request-এর latency মাপুন |
| EMFILE, EINTR, SIGPIPE সামলানো নেই; “epoll macOS-এও আছে” দাবি | Production-এ হঠাৎ ক্র্যাশ বা ১০০% CPU; macOS-এ আছে kqueue | man 2 accept, man 7 epoll পড়ুন; লক্ষ্য OS-এ compile করুন |
AI-কে event-driven সার্ভারের কোড চাইলে শর্তগুলো আগেই বেঁধে দিন:
AI-এর কোড মানার আগে — চেকলিস্ট
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।
প্র ০১১৯৯৯ সালের দিকে C10K একটি কঠিন সমস্যা ছিল, আজ একটি সাধারণ সার্ভারে লাখো সংযোগ রাখা যায়। পরিবর্তনটা কি শুধু হার্ডওয়্যারের, নাকি সফটওয়্যারের?
হার্ডওয়্যার অবশ্যই বদলেছে — মেমরি, core আর নেটওয়ার্ক কার্ডের গতি বহু গুণ বেড়েছে। কিন্তু Dan Kegel-এর পেজের মূল কথা ছিল, তখনকার হার্ডওয়্যারও দশ হাজার সংযোগ সামলানোর মতো শক্তিশালী ছিল; বাধা ছিল সফটওয়্যারের নকশা। প্রথম বাধা API: select আর poll প্রতি call-এ সব fd পরীক্ষা করত, তাই সংযোগ দশ গুণ হলে প্রতিটি wakeup-এর খরচও দশ গুণ — সংযোগ বেশিরভাগ চুপ থাকলেও। দ্বিতীয় বাধা thread: তখনকার Linux-এর thread implementation (LinuxThreads) অনেক thread-এ ভালো চলত না; ২০০২-২০০৩ সালের দিকে NPTL এসে এই অবস্থা অনেক বদলে দেয়। তৃতীয় বাধা kernel-এর ভেতরের নানা linear তালিকা আর lock.
পরিবর্তন এসেছে তিন দিক থেকে। OS দিয়েছে O(ready) API — FreeBSD-তে kqueue (২০০০), Linux-এ epoll (২০০২ সালের দিকে), Solaris-এ /dev/poll আর পরে event port; Windows-এ IOCP আগে থেকেই ছিল। সার্ভার সফটওয়্যারের নকশা বদলেছে: nginx, lighttpd, পরে Node.js আর Netty event loop-কে মূলধারায় এনেছে। আর kernel-এর network stack নিজেই বহু core-এর জন্য নতুন করে সাজানো হয়েছে — SO_REUSEPORT, প্রতি-core queue, RSS-এর মতো network card-এর বৈশিষ্ট্য।
মজার ব্যাপার, পরে লোকজন একই প্রশ্ন আরও বড় আকারে তুলেছেন — “C10M”, এক কোটি সংযোগ — আর সেখানে অনেকে উল্টো পথে গেছেন: kernel-কেই পথ থেকে সরিয়ে (kernel bypass, যেমন DPDK) প্রোগ্রাম সরাসরি network card থেকে packet নেয়। অর্থাৎ প্রতিটি যুগে সীমা সরে যায়, আর প্রতিবার সমাধান আসে “কোন স্তরে অপ্রয়োজনীয় কাজ হচ্ছে” তা খুঁজে বের করে।
মূল উপলব্ধি: স্কেল করার সমস্যা প্রায়ই হার্ডওয়্যারের নয়, এমন API আর নকশার — যেখানে কাজের খরচ “মোট কতজন আছে” তার অনুপাতে, “কতজন সক্রিয়” তার অনুপাতে নয়।
প্র ০২Linux কেন poll-কেই দ্রুত না করে একটি সম্পূর্ণ নতুন API (epoll) বানাল? poll-এর ভেতরের implementation ভালো করলে কি একই ফল পাওয়া যেত না?
সমস্যাটা poll-এর implementation-এ নয়, তার চুক্তিতে। poll-এর প্রতিটি call নিজের মধ্যে সম্পূর্ণ: “এই n-টি fd-এ এই এই ঘটনার খবর চাই।” Kernel আগের call-এর কোনো স্মৃতি রাখে না, কারণ প্রোগ্রাম পরের call-এ সম্পূর্ণ ভিন্ন তালিকা দিতে পারে। ফলে প্রতি call-এ n-টি entry কপি করতে হয়, প্রতিটি fd-এর socket খুঁজে তার অবস্থা দেখতে হয়, প্রতিটির wait queue-তে নিজেকে যুক্ত করে আবার সরাতে হয়। কোনো চালাক implementation এই কাজ এড়াতে পারে না, কারণ API নিজেই বলে “প্রতিবার নতুন করে”।
সমাধানের জন্য দরকার ছিল state — kernel-এর কাছে একটি স্থায়ী আগ্রহের তালিকা। ১৯৯০-এর দশকের শেষে Solaris-এ /dev/poll এই পথ দেখায়: একটি বিশেষ ফাইলে আগ্রহ লিখে রাখা হতো। ২০০০ সালে Jonathan Lemon FreeBSD-তে kqueue আনেন, যা আরও সাধারণ — socket ছাড়াও timer, signal, প্রসেস, ফাইল বদল। Linux-এ কিছু পরীক্ষামূলক চেষ্টার পর Davide Libenzi-র epoll ২০০২ সালের দিকে 2.5 development kernel-এ যুক্ত হয় আর 2.6-এ সবার কাছে পৌঁছায়। তিনটিরই মূল কৌশল এক: নিবন্ধন একবার, তারপর socket-এর অবস্থা বদলালে kernel নিজেই callback দিয়ে ready list-এ তোলে। খরচ সরে যায় “প্রতিবার সবাই” থেকে “ঘটনার সময় শুধু সে”।
নতুন API-র দামও আছে। epoll-এর নিজস্ব কিছু অদ্ভুততা আছে — fd বনাম open file description-এর গোলমাল, fork-এর পর ভাগাভাগি, ET-এর ফাঁদ — যা নিয়ে বহু বছর ধরে সমালোচনা হয়েছে, আর অনেকে kqueue-এর নকশাকে বেশি পরিচ্ছন্ন মনে করেন। আর poll থেকে epoll-এ যেতে প্রতিটি সার্ভারকে কোড বদলাতে হয়েছে; তাই libevent, libev, libuv-এর মতো লাইব্রেরি জনপ্রিয় হয়েছে, যারা এই পার্থক্য লুকিয়ে রাখে।
মূল উপলব্ধি: একটি stateless API-র খরচ তার চুক্তিতেই বাঁধা; O(n) থেকে O(ready)-তে যেতে kernel-কে স্মৃতি দিতে হয়েছে, আর সেজন্য নতুন API লেগেছে।
প্র ০৩Windows বহু বছর ধরে completion-ভিত্তিক IOCP ব্যবহার করে, Unix-জগৎ বেছে নিয়েছিল readiness. Linux পরে io_uring-এ completion-এর দিকে গেল কেন? কোনটি ভালো?
Readiness মডেলে kernel বলে “এখন পড়তে পারেন”, তারপর প্রোগ্রাম নিজে read() ডেকে কপি করে। প্রতিটি ডেটা-টুকরোর জন্য অন্তত দুটি system call — একটি জানার, একটি পড়ার — আর একটি সুবিধা: চুপ সংযোগের জন্য কোনো buffer আগাম লাগে না। Completion মডেলে প্রোগ্রাম আগেই buffer দিয়ে বলে “ডেটা এলে এখানে রেখে জানাবেন”; kernel অপেক্ষা আর কপি দুটোই করে, শেষে একটি বার্তা দেয়। System call কম, আর kernel চাইলে কপির কাজ সুবিধাজনক সময়ে বা সরাসরি hardware থেকে করতে পারে।
Unix-এর socket API জন্ম থেকেই readiness-এর জন্য তৈরি — select, তারপর poll, তারপর epoll — আর নেটওয়ার্কের জন্য এটি যথেষ্ট ভালো কাজ করেছে। কিন্তু দুটি পরিবর্তন চাপ বাড়ায়। প্রথমত, disk দ্রুত হয়েছে (NVMe), আর সাধারণ ফাইলে readiness অর্থহীন — ফাইল সবসময় “তৈরি”, অথচ পড়তে গেলে আটকায়। Linux-এর পুরোনো asynchronous I/O (libaio) শুধু কিছু বিশেষ অবস্থায় সত্যিকারের asynchronous ছিল। দ্বিতীয়ত, CPU-র নিরাপত্তা-ত্রুটি (২০১৮ সালের Spectre/Meltdown) প্রশমনের পর system call-এর খরচ বেড়েছে, তাই call-এর সংখ্যা কমানো মূল্যবান হয়ে উঠেছে। io_uring দুটিই সমাধান করে: ভাগ করা ring-এ অনেক অনুরোধ জমা দিয়ে একটি call-এ — এমনকি কিছু মোডে কোনো call ছাড়াই — kernel-এর কাছে পাঠানো যায়, আর ফাইল, socket সব একই মডেলে।
কোনটি “ভালো” তা নির্ভর করে কাজের ধরনের ওপর। অসংখ্য চুপ সংযোগে readiness মেমরি বাঁচায়; সক্রিয়, উচ্চ-throughput I/O-তে completion call বাঁচায়। আবার io_uring-এর বিশাল আর দ্রুত-বদলানো API-তে বেশ কিছু নিরাপত্তা-ত্রুটি পাওয়া গেছে, তাই কিছু পরিবেশে এটি বন্ধ রাখা হয় — নতুন ক্ষমতার সাথে নতুন আক্রমণ-তলও আসে।
মূল উপলব্ধি: Readiness বনাম completion হলো মেমরি আর system call-এর মধ্যে আপস; হার্ডওয়্যার আর নিরাপত্তার পরিবেশ বদলালে সেরা আপসও বদলায়।
প্র ০৪Edge-triggered এত বিপজ্জনক হলে কেউ কেন ব্যবহার করে? Level-triggered-ই কি সবসময় নিরাপদ পছন্দ নয়?
Level-triggered ভুল ক্ষমা করে, কিন্তু তার একটি খরচ আছে। ধরুন একটি সংযোগে অনেক ডেটা এসেছে, আর আপনি ইচ্ছে করে প্রতি iteration-এ মাত্র ১৬ KiB পড়ছেন যাতে অন্যরাও সুযোগ পায়। LT প্রতিবার এই সংযোগটি আবার ফেরত দেবে — ঠিক আছে। কিন্তু writable-এর ক্ষেত্রে LT বিরক্তিকর: socket-এর send buffer প্রায় সবসময় খালি, তাই EPOLLOUT চালু রাখলে প্রতি call-এ event আসে; তাই LT-তে EPOLLOUT বারবার চালু-বন্ধ করতে হয়, প্রতিবার একটি epoll_ctl system call. ET-তে EPOLLIN | EPOLLOUT একবার নিবন্ধন করে রেখে দেওয়া যায় — writable event আসবে শুধু buffer ভরা থেকে খালি হওয়ার মুহূর্তে। ব্যস্ত সার্ভারে এই সাশ্রয় চোখে পড়ার মতো।
দ্বিতীয় কারণ একাধিক thread. একই epoll instance থেকে কয়েকটি thread event নিলে LT-তে একই তৈরি সংযোগ একসাথে দুটি thread-কে জাগাতে পারে। ET (প্রায়ই EPOLLONESHOT-সহ) এই সমস্যা কমায়, কারণ একটি edge একবারই রিপোর্ট হয়।
বাস্তবে দুটিই ব্যাপক। আমাদের জানামতে nginx সংযোগের জন্য edge-triggered ব্যবহার করে, আর libuv (Node.js) বহুদিন level-triggered-এ চলেছে — দুটিই বিশাল আকারে সফল। আসল পার্থক্য কোডের শৃঙ্খলায়: ET-তে প্রতিটি handler-কে EAGAIN পর্যন্ত কাজ করতে হয়, আর ন্যায্যতার জন্য নিজের “এখনো তৈরি” তালিকা রাখতে হয় (অংশ ৬-এর সতর্কবার্তা)। ভালো লাইব্রেরি এই শৃঙ্খলা একবার লিখে রাখে, তাই ওপরের প্রোগ্রামারকে আর ভাবতে হয় না। নিজে প্রথম event loop লিখলে LT দিয়ে শুরু করা বুদ্ধিমানের কাজ; মাপজোক করে প্রয়োজন দেখা দিলে তবেই ET.
মূল উপলব্ধি: ET কম system call আর পরিষ্কার multi-thread আচরণ দেয়, বিনিময়ে নিখুঁত শৃঙ্খলা দাবি করে; LT সহজ আর নিরাপদ — সিদ্ধান্ত মাপজোক দিয়ে, অনুমানে নয়।
প্র ০৫Go-তে প্রতি সংযোগে একটি goroutine, Java 21-এ virtual thread — এগুলো কি “প্রতি সংযোগে একটি thread” নকশায় ফিরে যাওয়া? তাহলে event loop কি অপ্রয়োজনীয় হয়ে গেল?
প্রোগ্রামারের চোখে, হ্যাঁ — ফিরে যাওয়া। Go-তে সার্ভার লেখা হয় blocking ধাঁচে: প্রতিটি সংযোগে go handle(conn), আর handler-এ সোজা conn.Read(). Java 21 (২০২৩)-এর virtual thread-এর লক্ষ্যও একই: পুরোনো thread-per-request কোড প্রায় না বদলে লাখো সমান্তরাল request চালানো। কোড পড়া সহজ, stack trace অর্থপূর্ণ, state machine হাতে লিখতে হয় না।
কিন্তু ভেতরে event loop হারিয়ে যায়নি — শুধু runtime-এর ভেতরে চলে গেছে। একটি goroutine-এর stack শুরু হয় কয়েক KiB দিয়ে, প্রয়োজনে বাড়ে; OS thread-এর মতো MiB আকারের জায়গা আগাম লাগে না। Goroutine কোনো socket থেকে পড়তে গিয়ে ডেটা না পেলে runtime তাকে “park” করে রাখে, socket-টি netpoller-এ (Linux-এ epoll) নিবন্ধন করে, আর সেই OS thread-এ অন্য goroutine চালায়। socket তৈরি হলে netpoller goroutine-টিকে আবার চালানোর তালিকায় তোলে। অর্থাৎ পুরো reactor pattern — non-blocking socket, epoll, state সংরক্ষণ — আছেই; শুধু state machine-এর বদলে goroutine-এর নিজের stack-ই “কোথায় আছি” মনে রাখে। Erlang-এর হালকা process আর Rust/Python/JavaScript-এর async/await-ও একই সমস্যার আরেক সমাধান: compiler বা runtime আপনার সরল কোডকে state machine-এ রূপান্তর করে।
মূল উপলব্ধি: আধুনিক runtime blocking ধাঁচের সরল কোড ফিরিয়ে দিয়েছে, কিন্তু নিচে event loop আর epoll-ই কাজ করছে — নকশাটি বিলুপ্ত হয়নি, অদৃশ্য হয়েছে।
অনুশীলন
খাতা-কলম নিন — হিসাবগুলো এককসহ লিখুন, আর কোডের প্রশ্নে আগে নিজে উত্তর ভেবে তারপর মেলান।
-
হাতে হিসাব (খরচের মডেল): একটি সার্ভারে ২০,০০০ খোলা সংযোগ। প্রতি সেকেন্ডে loop ২,০০০ বার জাগে, প্রতিবার গড়ে ৫০টি সংযোগ তৈরি থাকে। (ক) poll ব্যবহার করলে প্রতি সেকেন্ডে kernel মোট কতগুলো fd পরীক্ষা করে, আর প্রতি call-এ কত byte-এর pollfd array kernel-এ কপি হয় (pollfd = ৮ byte)? (খ) epoll_wait-এ প্রতি সেকেন্ডে কতগুলো event ফেরত আসে, প্রতি call-এ কত byte (epoll_event = ১২ byte)? (গ) একই সার্ভার thread-per-connection হলে, প্রতিটি thread আসলে ৬৪ KiB stack ব্যবহার করলে stack-এ মোট কত মেমরি?
(ক) প্রতি call-এ ২০,০০০ fd, সেকেন্ডে ২,০০০ call: ২০,০০০ × ২,০০০ = ৪ কোটি (৪ × ১০৭) fd-পরীক্ষা প্রতি সেকেন্ডে. প্রতি call-এ কপি ২০,০০০ × ৮ = ১,৬০,০০০ byte (প্রায় ১৫৬ KiB) — সেকেন্ডে ২,০০০ বার, অর্থাৎ শুধু ভেতরে পাঠাতেই প্রায় ৩২০ MB/s.
(খ) ৫০ × ২,০০০ = ১,০০,০০০ event প্রতি সেকেন্ডে; প্রতি call-এ ৫০ × ১২ = ৬০০ byte. অনুপাত: ৪ × ১০৭ ÷ ১০৫ = ৪০০ গুণ কম কাজ — ঠিক n ÷ ready = ২০,০০০ ÷ ৫০.
(গ) ২০,০০০ × ৬৪ KiB = ১২,৮০,০০০ KiB = ১,২৫০ MiB ≈ ১.২২ GiB. (Virtual জায়গা হিসেবে ডিফল্ট ৮ MiB ধরলে ২০,০০০ × ৮ MiB = ১,৬০,০০০ MiB ≈ ১৫৬ GiB address space সংরক্ষিত — 64-bit-এ জায়গা আছে, কিন্তু সীমা আর kernel-এর হিসাবরক্ষণে সমস্যা হতে পারে।) সাথে context switch-এর খরচ আলাদা।
-
আউটপুট অনুমান: অংশ ৬-এর
epoll_et.c-এ শুধু একটি বদল করা হলো:buf[1024]-এর জায়গায়buf[4096]. বাকি সব একই (client প্রথমে ৩,০০০, পরে ৫০০, পরে LT-তে ৩,০০০ byte পাঠায়)। নয়টি লাইনে কী কী সংখ্যা আসবে? এই ফল থেকে বাস্তব প্রোগ্রামিং নিয়ে কী শিক্ষা পাওয়া যায়?আমরা বদলানো প্রোগ্রামটি চালিয়ে দেখেছি; আউটপুট:
[ET] 3000 bytes arrive: epoll_wait -> 1 event(s), unread bytes in socket = 3000 BUGGY handler: read once -> 3000, then back to the loop [ET] next iteration: epoll_wait -> 0 event(s), unread bytes in socket = 0 [ET] 500 more bytes: epoll_wait -> 1 event(s), unread bytes in socket = 500 CORRECT handler: drain until EAGAIN: 500 | -1 (EAGAIN) [ET] next iteration: epoll_wait -> 0 event(s), unread bytes in socket = 0 [LT] 3000 bytes arrive: epoll_wait -> 1 event(s), unread bytes in socket = 3000 same 'read once' handler -> 3000 [LT] next iteration: epoll_wait -> 0 event(s), unread bytes in socket = 0৪,০৯৬ byte-এর buffer-এ ৩,০০০ byte এক read-এই ধরে যায়, তাই “ভুল” handler-এর পরও না-পড়া ০ — বাগটি লুকিয়ে গেল। LT আর ET-এর পার্থক্যও আর দেখা যায় না। শিক্ষা: edge-triggered-এর বাগ ছোট বার্তায় (পরীক্ষার সময়) ধরা পড়ে না, ধরা পড়ে production-এ যখন কোনো request buffer-এর চেয়ে বড় হয়, বা packet একসাথে জমে। তাই পরীক্ষায় ইচ্ছে করে buffer-এর চেয়ে বড় বার্তা পাঠাতে হয়, আর কোড পড়ে নিশ্চিত হতে হয় যে EAGAIN পর্যন্ত loop আছে — “চলছে” দেখে নয়।
-
কোড লিখুন: একটি non-blocking, edge-triggered socket-এর জন্য
flush(struct conn *c)ফাংশন লিখুন, যাc->outbuffer-এরc->offথেকেc->lenপর্যন্ত বাকি byte পাঠাবে। শর্ত: partial write সামলাবে, EINTR-এ আবার চেষ্টা করবে, EAGAIN-এ থামবে, অন্য ত্রুটিতে -1 ফেরত দেবে, আর সব পাঠানো হলে ১ ফেরত দেবে (বাকি থাকলে ০)।int flush(struct conn *c) { while (c->off < c->len) { ssize_t n = send(c->fd, c->out + c->off, c->len - c->off, MSG_NOSIGNAL); if (n > 0) { c->off += n; continue; } /* partial write: আরও চেষ্টা */ if (n < 0 && errno == EINTR) continue; if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) return 0; /* buffer ভরা: EPOLLOUT-এর অপেক্ষা */ return -1; /* EPIPE, ECONNRESET ...: বন্ধ করুন */ } c->off = c->len = 0; /* সব গেছে: buffer খালি */ return 1; }লক্ষ করুন: (১) ET-তে আমরা EAGAIN পর্যন্ত লিখি, তাই EPOLLOUT একবার নিবন্ধিত থাকলেই চলে; LT হলে ০ ফেরত পেলে EPOLLOUT চালু আর ১ পেলে বন্ধ করতে হবে। (২)
MSG_NOSIGNALSIGPIPE ঠেকায় (Linux; macOS-এ এর বদলে socket optionSO_NOSIGPIPE). (৩)send()০ ফেরত দেওয়ার কথা নয় যখন দৈর্ঘ্য শূন্য নয়, তাই সেটি এখানে ত্রুটি হিসেবেই গণ্য। -
তদন্ত (trace): একটি level-triggered epoll সার্ভার রাত ২টায় হঠাৎ এক core-এ ১০০% CPU নিচ্ছে, অথচ নতুন কোনো client সেবা পাচ্ছে না, পুরোনোরা ঠিক চলছে। Log-এ বারবার
accept: Too many open files. কী ঘটছে ধাপে ধাপে ব্যাখ্যা করুন, আর তিনটি প্রতিকার বলুন।ঘটনার শিকল: (১) সংযোগ জমতে জমতে প্রসেসের fd-সীমা (
ulimit -n, ধরুন ১,০২৪) পূর্ণ — হয়তো idle সংযোগ বন্ধ হচ্ছে না, বা কোথাও fd leak. (২) নতুন client handshake শেষ করে accept queue-তে বসে। (৩) Listening socket readable হয়, LT-তে epoll_wait event দেয়। (৪)accept()EMFILE দেয় — নতুন fd বানানো যাচ্ছে না — কিন্তু সংযোগটি queue-তেই থাকে। (৫) পরের epoll_wait সাথে সাথে আবার একই event দেয়, কারণ listening socket এখনো readable. Loop এভাবে বিরতিহীন ঘোরে — ১০০% CPU, শূন্য অগ্রগতি। পুরোনো সংযোগের event-ও একই loop-এ আসে বলে তারা চলছে।প্রতিকার: (ক) অংশ ৮-এর “বাড়তি fd” কৌশল — EMFILE পেলে জমা রাখা fd বন্ধ করে সংযোগটি accept করে সাথে সাথে বন্ধ, তারপর আবার জমা রাখা; অথবা কিছু সময়ের জন্য listening socket-কে epoll থেকে সরিয়ে রাখা। (খ) মূল কারণ ঠিক করা — idle timeout চালু, fd leak খোঁজা (
ls /proc/<pid>/fd | wc -lসময়ের সাথে বাড়ছে কি না)। (গ) সীমা প্রয়োজন অনুযায়ী বাড়ানো (ulimit -nবা systemd-এরLimitNOFILE), সাথে fd-এর ব্যবহার monitor করে আগাম সতর্কবার্তা।
নিজেকে যাচাই করুন
ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।
ক্রমepoll-ভিত্তিক event loop-এর একটি iteration — কাজগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)
- সবচেয়ে কাছের timer দেখে epoll_wait-এর timeout হিসাব
- epoll_wait-এ ঘুম, তারপর তৈরি fd-এর তালিকা নিয়ে ফেরা
- তালিকার প্রতিটি fd-এর handler ডাকা
- Handler EAGAIN পর্যন্ত পড়ে, পূর্ণ request হলে উত্তর বানায়
- উত্তরের না-যাওয়া অংশ output buffer-এ রেখে EPOLLOUT চালু
- মেয়াদ পেরোনো timer চালানো (যেমন idle সংযোগ বন্ধ), তারপর আবার শুরু
একটি প্রোগ্রাম select() দিয়ে file descriptor 3, 7 আর 12-এ পড়ার ঘটনার জন্য নজর রাখতে চায়। select()-এর প্রথম argument (nfds) কত দিতে হবে?
- 3
- 12
- 13
- 1024
Linux-এ একটি non-blocking TCP socket EPOLLIN | EPOLLET দিয়ে epoll-এ নিবন্ধিত। ৪,০০০ byte এলো; handler একবার read(fd, buf, 1024) করে loop-এ ফিরে গেল। Client উত্তরের অপেক্ষায়, আর কিছু পাঠাবে না। এরপর কী হবে?
- পরের epoll_wait-এ আবার event আসবে আর বাকি ডেটা পড়া হবে
- বাকি ২,৯৭৬ byte socket-এ পড়ে থাকবে; নতুন ডেটা না এলে আর event আসবে না, সংযোগ আটকে থাকবে
- Kernel বাকি byte ফেলে দেবে আর client-কে RST পাঠাবে
- epoll_wait নিজে EAGAIN ফেরত দেবে
একটি Linux সার্ভারে ২০,০০০ খোলা TCP সংযোগ, কিন্তু যেকোনো মুহূর্তে মাত্র ১০টিতে ডেটা আসে। কোন API-তে প্রতিটি wakeup-এর কাজ মোট সংযোগ নয়, শুধু তৈরি সংযোগের সংখ্যার অনুপাতে?
- select()
- poll()
- epoll_wait()
- এক thread-এ প্রতিটি socket-এ পালাক্রমে blocking read()
একটি non-blocking TCP socket-এ write(fd, buf, 50000) ১৮,০০০ ফেরত দিল। সার্ভারের সঠিক কাজ কী?
- এটা ত্রুটি ধরে সংযোগ বন্ধ করা
- পুরো ৫০,০০০ byte আবার পাঠানো
- বাকি ৩২,০০০ byte সংযোগের output buffer-এ রেখে socket writable হলে (POLLOUT/EPOLLOUT) পাঠানো
- সাথে সাথে একটি loop-এ বারবার write ডাকা যতক্ষণ না সব যায়
এক thread-এর event loop-এ চলা একটি সার্ভারে (Node.js বা Redis-এর মতো) একটি request-এর handler ৩০০ ms ধরে CPU-তে হিসাব করে। সেই সময়ে আসা অন্য ১০০টি client-এর ছোট (১ ms-এর) request-এর কী হয়?
- Kernel সেগুলো নিজে থেকে অন্য thread-এ চালায়
- সবাই অন্তত ভারী handler শেষ হওয়া পর্যন্ত অপেক্ষা করে; প্রত্যেকের উত্তর দেরিতে যায়
- epoll নিজেই তাদের উত্তর দিয়ে দেয়
- সেগুলো সাথে সাথে drop হয়ে যায়
Linux-এ glibc-এর select() দিয়ে file descriptor 1500-এ নজর রাখতে একটি প্রোগ্রাম FD_SET(1500, &set) ডাকল। কী ঘটার আশঙ্কা?
- কোনো সমস্যা নেই, select যেকোনো fd সামলায়
- select নিজে থেকে poll-এ বদলে যায়
- fd_set মাত্র ১,০২৪ bit, তাই সীমার বাইরের মেমরিতে লেখা হয় — undefined behavior, মেমরি নষ্ট হতে পারে
- Kernel EMFILE ত্রুটি দেয়
শব্দকোষ
লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:
মনে রাখুন
- C10K: বেশিরভাগ সংযোগ চুপ, তাই প্রতি সংযোগে একটি thread মেমরি আর context switch-এ অপচয়; event-driven সার্ভার একটি thread-এ শুধু তৈরি সংযোগে কাজ করে।
- Non-blocking-এর নিয়ম:
EAGAINত্রুটি নয় — loop-এ ফেরার সংকেত; partial write-এর বাকিটা সংযোগের output buffer-এ, আর writable-আগ্রহ শুধু buffer-এ কিছু থাকলে। - select (bitmask, ১,০২৪ সীমা, nfds = maxfd + ১, প্রতিবার সেট বানানো) → poll (array, সীমা নেই, তবু O(n)) → epoll (kernel-এ নিবন্ধন, O(ready)); BSD/macOS-এ kqueue, Windows-এ IOCP, নতুন Linux-এ io_uring.
- Edge-triggered: event শুধু বদলের মুহূর্তে — তাই EAGAIN পর্যন্ত পড়ুন, লিখুন, accept করুন; level-triggered বারবার মনে করায়, ভুল ক্ষমা করে।
- Reactor: event loop + প্রতিটি সংযোগের state machine + timer (min-heap বা timerfd); loop কখনো আটকাবেন না — ভারী কাজ worker pool-এ; বহু core-এ প্রতি core-এ একটি loop, SO_REUSEPORT.
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পর্ব ৪৫ · প্রজেক্ট — C-তে একটি ছোট HTTP সার্ভারপরবর্তী পর্বSocket, parsing আর concurrency একসাথে একটি চলমান সার্ভারে।
- পর্ব ৪৩ · একসাথে অনেক client — fork, thread আর thread pool সার্ভারআগের পর্বযে নকশার সাথে এই পর্বের তুলনা, আর worker pool.
- পর্ব ৪১ · TCP socket প্রোগ্রামিংভিত্তিsocket, bind, listen, accept, connect-এর মূল API.
- পর্ব ৩১ · TCP flow controlভিত্তিভরা buffer, zero window আর non-blocking send-এর EAGAIN.
- কম্পিউটার নেটওয়ার্ক · কোর্স হোম৬২টি পর্বপুরো কোর্সের রোডম্যাপ — কোন পর্ব কখন।
- অপারেটিং সিস্টেম · I/O সিস্টেমপাশের ট্র্যাকInterrupt, blocking, non-blocking আর asynchronous I/O-এর kernel-এর দিক।
- অপারেটিং সিস্টেম · Context switchপাশের ট্র্যাকঅনেক thread-এর খরচ কোথা থেকে আসে।
- DSA · Heap আর priority queueপাশের ট্র্যাকTimer-এর deadline রাখার min-heap.