সরাসরি পর্বে যান
পর্ব ৪৩ · ৬২-এর মধ্যে · মডিউল ৭

একসাথে অনেক client — fork, thread আর thread pool সার্ভার

Concurrent Servers — Iterative vs Fork-per-Connection vs Thread-per-Connection vs Thread Pools, Zombies, the &connfd Race, Bounded Queues, Backpressure, Timeouts & Graceful Shutdown

Taught in Bangla, this lesson builds concurrent TCP servers in C: the iterative server and its limits, fork-per-connection with zombie reaping and copy-on-write, thread-per-connection with pthreads and the &connfd race, shared-state races fixed with mutexes and atomics, thread pools with bounded queues and backpressure, pre-fork and the thundering herd, a five-model comparison, and timeouts, slow clients and graceful shutdown.

এই পর্বে যা শিখবেন

  • Iterative সার্ভার কেন ব্যর্থ হয় — হিসাব করে আর C প্রোগ্রামে সময় মেপে
  • fork() দিয়ে প্রতি সংযোগে প্রসেস: fd-র reference count, কে কোন fd বন্ধ করবে, SIGCHLD আর waitpid দিয়ে zombie ঠেকানো
  • pthread দিয়ে প্রতি সংযোগে থ্রেড: fd নিরাপদে পাঠানো, detach, ভাগের অবস্থায় data race আর তার তিন সমাধান
  • Thread pool আর bounded queue — producer-consumer, backpressure, প্রত্যাখ্যান, আর Little-এর সূত্র দিয়ে pool-এর আকার
  • পাঁচটি মডেলের তুলনা, বাস্তবের সার্ভার (Apache, nginx, Node, Go), প্রতি client-এর timeout আর পরিচ্ছন্নভাবে বন্ধ করা

১ · একজন একজন করে — iterative সার্ভার আর তার সীমা

পর্ব ৪১-এ (TCP socket প্রোগ্রামিং) যে echo সার্ভার বানানো হয়েছে, তার মূল লুপ মোটামুটি এই: accept() দিয়ে একটি সংযোগ নেওয়া, সেই client-এর সব কাজ শেষ করা, সংযোগ বন্ধ করা, তারপর আবার accept(). এমন সার্ভারকে বলে iterative সার্ভার — এক সময়ে ঠিক একজন client. শেখার জন্য চমৎকার, কিন্তু বাস্তবে প্রায় কখনো যথেষ্ট নয়।

পাড়ার একটি ছোট ফটোকপির দোকানে একজনই কর্মচারী। একজন গ্রাহকের ২০০ পাতা, আর মাঝে মাঝে তিনি বাড়ি থেকে পাতা আনতে যান; কর্মচারী তাঁর কাজ শেষ না করে পরের জনের দিকে তাকান না। পেছনের যাঁর মাত্র একটি পাতা, তিনিও আধা ঘণ্টা দাঁড়িয়ে থাকেন। কর্মচারী = iterative সার্ভার, গ্রাহকের লাইন = kernel-এর accept queue. সমাধান তিন রকম হতে পারে: প্রতি গ্রাহকের জন্য নতুন কর্মচারী নিয়োগ (fork বা thread), কয়েকজন স্থায়ী কর্মচারী আর একটি টোকেন-লাইন (thread pool), অথবা একজন অতি দ্রুত কর্মচারী, যিনি কারও জন্য অপেক্ষা না করে যার কাগজ তৈরি তার কাজটুকু করে দেন (event-driven, পর্ব ৪৪)।

সময়ের হিসাব — কেন শেষজন সবচেয়ে বেশি ভোগে

ধরা যাক প্রতিটি request সামলাতে সার্ভারের সময় লাগে S, আর N জন client একই মুহূর্তে আসে। Iterative সার্ভারে i-তম client (i = ১, ২, …, N) উত্তর পায় i × S সময়ে, কারণ তাকে আগের i − ১ জনের পুরো কাজ শেষ হওয়া পর্যন্ত অপেক্ষা করতে হয়। তাই:

  • শেষ client-এর অপেক্ষা = N × S
  • গড় অপেক্ষা = (S + 2S + … + NS) / N = S × N(N + 1) / (2N) = (N + 1) × S / 2
  • Throughput = N / (N × S) = ১ / S — client যতই বাড়ুক, সেকেন্ডে সামলানো request বাড়ে না

উদাহরণ: S = ২০০ ms, N = ৪ হলে চারজন উত্তর পান ২০০, ৪০০, ৬০০ আর ৮০০ ms-এ; গড় (৪ + ১) × ২০০ / ২ = ৫০০ ms. একসাথে সামলানো গেলে প্রত্যেকে পেতেন ২০০ ms-এ। N = ১০০ হলে শেষজনের অপেক্ষা ২০ সেকেন্ড — ততক্ষণে অ্যাপ সাধারণত হাল ছেড়ে দেয়।

আসল সমস্যা আরও খারাপ: সার্ভারের সময়ের বেশিরভাগ যায় অপেক্ষায় — ধীর মোবাইল সংযোগের client-এর পরের বাইট, ডেটাবেসের উত্তর, ডিস্ক। তখন CPU প্রায় অলস, অথচ iterative সার্ভার read()-এ আটকে থাকে। কেউ সংযোগ খুলে কিছুই না পাঠালে সার্ভার চিরকাল অপেক্ষা করবে, বাকিরা kernel-এর accept queue-তে জমবে, আর queue (backlog) ভরলে নতুন SYN উপেক্ষিত হবে (পর্ব ২৯)। একটি ধীর client = পুরো সেবা বন্ধ।

মূল ধারণা

সার্ভারের কাজ মূলত অপেক্ষা-প্রধান (I/O-bound). তাই একসাথে অনেক client সামলানোর মূল লক্ষ্য CPU বাড়ানো নয়, বরং একজনের অপেক্ষার সময়ে অন্যজনের কাজ করা. একে বলে concurrent সার্ভার. এর তিনটি ক্লাসিক পথ: প্রতি সংযোগে প্রসেস, প্রতি সংযোগে থ্রেড, আর থ্রেডের pool — এই পর্বের বিষয়। চতুর্থ পথ, এক থ্রেডে অনেক সংযোগ (select/poll/epoll), পর্ব ৪৪-এ।

২ · প্রতি সংযোগে একটি প্রসেস — fork() সার্ভার

Unix-এর সবচেয়ে পুরোনো সমাধান: প্রতিটি নতুন সংযোগের জন্য fork() দিয়ে একটি নতুন প্রসেস। fork() একবার ডাকলে দুবার ফেরে — parent-এ child-এর PID নিয়ে, আর নতুন child-এ 0 নিয়ে (বিস্তারিত অপারেটিং সিস্টেম কোর্সের process API পর্বে)। Child পায় parent-এর মেমরির একটি কপি আর — এই পর্বের জন্য সবচেয়ে জরুরি — parent-এর file descriptor টেবিলের কপি। তাই accept()-এর পরে fork করলে child-এর হাতেও সেই connected socket থাকে। কাঠামোটি এই:

for (;;) {
    int c = accept(ls, NULL, NULL);      /* parent waits for the next client */
    if (c < 0) continue;                 /* EINTR, ECONNABORTED ... try again */
    pid_t pid = fork();
    if (pid == 0) {                      /* ---- child ---- */
        close(ls);                       /* (1) child never accepts: drop the listening fd */
        serve(c);                        /* talk to this one client */
        close(c);
        _exit(0);
    }
    close(c);                            /* (2) parent never talks to the client: drop its copy */
}

দুটি close() কেন — socket-এর reference count

Kernel-এর ভেতরে একটি socket একটিই কাঠামো, কিন্তু তার দিকে একাধিক file descriptor নির্দেশ করতে পারে — fork-এর পরে parent-এর fd 4 আর child-এর fd 4 একই socket. Kernel গোনে কতগুলো fd এখনো এই socket খোলা রেখেছে — এটাই reference count. close() শুধু গণনা এক কমায়; গণনা শূন্য হলে তবেই socket সত্যিই বন্ধ হয়, আর TCP তখন অপর প্রান্তে FIN পাঠায় (পর্ব ২৯)। এ থেকে দুটি নিয়ম:

  • Parent connected fd বন্ধ না করলে: child close(c) করলেও গণনা ১-এ আটকে থাকে, FIN যায় না; যে client “সংযোগ বন্ধ হলে বুঝব উত্তর শেষ” ধরে অপেক্ষা করে, সে ঝুলে থাকে। তার ওপর parent-এর fd প্রতি সংযোগে একটি করে জমে; fd সীমা (ulimit -n, প্রায়ই ১,০২৪) পেরোলে accept() EMFILE দিয়ে ব্যর্থ হয়।
  • Child listening fd বন্ধ না করলে: প্রতিটি child listening socket ধরে রাখে; parent নতুন করে চালু হলে পুরোনো child-রা বেঁচে থাকা পর্যন্ত port “ব্যস্ত” থাকতে পারে, আর ভুল কোডে child নিজেও accept() করে ফেলতে পারে। নিয়ম: যে প্রসেস যে fd ব্যবহার করবে না, সে তা বন্ধ করবে।

নিচের stepper-এ একটি সংযোগের পুরো জীবন দেখুন — fd টেবিল, kernel-এর গণনা আর প্রসেসের অবস্থা ধাপে ধাপে।

  1. listenfd 3parent
  2. acceptfd 4নতুন client
  3. forkদুই কপিfd টেবিল কপি
  4. closechild: 3parent: 4
  5. exitzombieSIGCHLD
  6. waitpidWNOHANGreap
fd টেবিল, kernel-এর গণনা আর প্রসেসের অবস্থা
Parent-এর fd
Child-এর fd
Listen socket-এর গণনা
Conn socket-এর গণনা
Child-এর অবস্থা
  1. Parent শুনছে। socket(), bind(), listen() — listening socket পেল fd 3 (0, 1, 2 আগে থেকেই stdin, stdout, stderr). একটিমাত্র fd এটি ধরে আছে, তাই গণনা 1.
  2. Client এল। accept() একটি নতুন connected socket দিল — fd 4, গণনা 1. Listening socket আগের মতোই থাকল (পর্ব ২৭-এর রিসেপশনিস্ট আর কর্মকর্তার উপমা মনে করুন)।
  3. fork(). নতুন child (ধরা যাক PID 2001) parent-এর fd টেবিলের কপি পেল: তার কাছেও fd 3 আর fd 4. Socket নতুন তৈরি হয়নি — একই দুটি socket-এর দিকে এখন দুটি করে fd, তাই দুটির গণনাই 2.
  4. Child fd 3 বন্ধ করল। Child কখনো নতুন সংযোগ নেবে না, তাই close(3) — listen-এর গণনা 2 থেকে 1. Conn socket-এর গণনা এখনো 2, কারণ parent এখনো তার কপি বন্ধ করেনি।
  5. Parent fd 4 বন্ধ করল। Parent এই client-এর সাথে কথা বলবে না, তাই close(4) — conn-এর গণনা 1, এখন শুধু child-এর হাতে। Parent সঙ্গে সঙ্গে আবার accept()-এ ফিরতে পারে, আর পরের client আবার fd 4 পাবে (সবচেয়ে ছোট খালি নম্বর)।
  6. Child কাজ শেষে বের হলো। Child close(4) করতেই গণনা 0 — এবার socket সত্যিই বন্ধ, TCP FIN পাঠায়। তারপর _exit(0). কিন্তু প্রসেস টেবিলে তার একটি ছোট ভুক্তি থেকে যায় (exit status রাখার জন্য) — এটাই zombie (ps-এ অবস্থা Z). Kernel parent-কে SIGCHLD signal পাঠায়।
  7. Parent zombie তুলে নিল। SIGCHLD handler-এ while (waitpid(-1, NULL, WNOHANG) > 0) লুপ শেষ হওয়া সব child-এর status পড়ে নেয় (reap), ভুক্তি মুছে যায়। না করলে প্রতি সংযোগে একটি zombie জমত, আর একসময় প্রসেস-সীমা ফুরিয়ে fork ব্যর্থ হতো।

Zombie ঠেকানো — SIGCHLD আর WNOHANG লুপ কেন

Parent প্রতিটি child-এর জন্য সাধারণ wait() ডাকলে child শেষ না হওয়া পর্যন্ত আটকে থাকত — আবার iterative সার্ভার! তাই parent SIGCHLD ধরে, আর handler-এ waitpid()-কে WNOHANG দিয়ে ডাকে — “শেষ হওয়া কেউ থাকলে দাও, না থাকলে সঙ্গে সঙ্গে ফেরো”. লুপ কেন? সাধারণ Unix signal জমা হয় না: তিনটি child প্রায় একসাথে শেষ হলে parent হয়তো একটিমাত্র SIGCHLD পায়, আর একবার waitpid() করলে বাকি দুজন zombie থেকে যেত। লুপ চলে ০ (আর কেউ শেষ হয়নি) বা −১ (কোনো child নেই, ECHILD) পাওয়া পর্যন্ত। বিস্তারিত OS কোর্সের process API পর্বের অংশ ৬–৭ আর signal পর্বে।

তিনটি জরুরি বিবরণ। এক, handler sigaction() দিয়ে SA_RESTART-সহ বসান — নাহলে accept()-এ আটকে থাকা parent EINTR পাবে, আর অসতর্ক কোড সেটাকে মারাত্মক ভুল ভেবে বন্ধ হয়ে যাবে। দুই, handler-এ waitpid() নিরাপদ, কিন্তু printf() বা malloc() নয় (async-signal-safe নয়); errno-ও সংরক্ষণ করে ফিরিয়ে দিন। তিন, POSIX সিস্টেমে SIGCHLD-এর action SIG_IGN করলে kernel নিজেই child তুলে নেয় — সহজ, কিন্তু তখন exit status জানা যায় না।

নিজে চালিয়ে দেখুন: iterative বনাম fork — সময় মেপে

নিচের প্রোগ্রাম loopback-এ (127.0.0.1, port OS বেছে দেয়) একটি সার্ভার প্রসেস চালায়, যার প্রতিটি request-এ ২০০ ms লাগে (usleep দিয়ে ধীর client-এর অনুকরণ)। তারপর একসাথে ৪টি client পাঠিয়ে মাপে সবাই উত্তর পেতে কত সময় লাগল — আগে iterative, তারপর SIGCHLD reaping-সহ fork সার্ভারে; শেষে সার্ভার যাচাই করে কোনো zombie বাকি আছে কি না।

#include <stdio.h>
#include <errno.h>
#include <signal.h>
#include <time.h>
#include <unistd.h>
#include <sys/wait.h>
#include <arpa/inet.h>
static volatile sig_atomic_t reaped;
static void on_chld(int s) { (void)s; int e = errno; while (waitpid(-1, NULL, WNOHANG) > 0) reaped++; errno = e; }
static void serve(int c) { char b; read(c, &b, 1); usleep(200000); write(c, &b, 1); close(c); } /* 200 ms */
static double now(void) { struct timespec t; clock_gettime(CLOCK_MONOTONIC, &t); return t.tv_sec + t.tv_nsec / 1e9; }
int main(void) {
    for (int forking = 0; forking <= 1; forking++) {
        struct sockaddr_in a = {.sin_family = AF_INET, .sin_addr.s_addr = htonl(INADDR_LOOPBACK)}; socklen_t n = sizeof a;
        int ls = socket(AF_INET, SOCK_STREAM, 0); bind(ls, (struct sockaddr *)&a, n); listen(ls, 8);
        getsockname(ls, (struct sockaddr *)&a, &n); fflush(stdout); double t0 = now(); pid_t srv = fork();
        if (srv == 0) {                                          /* ===== server process ===== */
            struct sigaction sa = {.sa_handler = on_chld, .sa_flags = SA_RESTART}; sigaction(SIGCHLD, &sa, NULL);
            for (int i = 0; i < 4; i++) {
                int c = accept(ls, NULL, NULL);
                if (!forking) { serve(c); continue; }            /* iterative: one client at a time */
                if (fork() == 0) { close(ls); serve(c); _exit(0); } /* child: only this client */
                close(c); }                                      /* parent: drop its copy, accept again */
            while (forking && reaped < 4) usleep(1000);
            printf("  server: reaped=%d, zombies left: %s\n", (int)reaped,
                   waitpid(-1, NULL, WNOHANG) < 0 && errno == ECHILD ? "none (ECHILD)" : "YES");
            fflush(stdout); _exit(0); }
        close(ls); for (int i = 0; i < 4; i++) if (fork() == 0) {  /* ===== 4 clients at once ===== */
            int s = socket(AF_INET, SOCK_STREAM, 0); char b = 'x';
            connect(s, (struct sockaddr *)&a, sizeof a); write(s, &b, 1); read(s, &b, 1); _exit(0); }
        for (int left = 4, p; left > 0; ) if ((p = wait(NULL)) != srv) left--; else srv = 0;
        double t = now() - t0; if (srv) waitpid(srv, NULL, 0);
        printf("%-14s 4 clients x 200 ms -> all done in %.1f s\n", forking ? "fork-per-conn:" : "iterative:", t);
    }
}

আউটপুট (একটি রান):

  server: reaped=0, zombies left: none (ECHILD)
iterative:     4 clients x 200 ms -> all done in 0.8 s
  server: reaped=4, zombies left: none (ECHILD)
fork-per-conn: 4 clients x 200 ms -> all done in 0.2 s

সময়: iterative সার্ভারে চারজন একে একে সেবা পেলেন — ৪ × ২০০ ms ≈ ০.৮ সেকেন্ড, ঠিক অংশ ১-এর N × S. Fork সার্ভারে চারটি child একসাথে ২০০ ms করে ঘুমাল, তাই সবাই প্রায় একসাথে — ≈ ০.২ সেকেন্ড, চার গুণ দ্রুত। নিখুঁত মান প্রতিবার কয়েক মিলিসেকেন্ড বদলায়, তাই প্রোগ্রাম এক দশমিক ঘরে গোল করে ছাপে; অনুপাত ৪ : ১-টাই আসল কথা।

Zombie: fork সার্ভারে reaped=4 — চারটি handler child-ই SIGCHLD handler-এর লুপে তুলে নেওয়া হয়েছে, আর শেষে waitpid() −১ ফেরত দিয়ে ECHILD জানাল: কোনো child নেই, zombie-ও নেই। Iterative সার্ভার child তৈরিই করেনি, তাই reaped=0. কোডে child close(ls) আর parent close(c) করে — অংশ ২-এর দুই নিয়ম। প্রতিটি fork-এর আগে fflush(stdout) আছে, যাতে buffer-এর লেখা child-এর কপিতে দুবার না ছাপে; আর ছাপে শুধু সার্ভার ও মূল প্রসেস, ক্রম মেনে — তাই আউটপুটের ক্রম প্রতিবার একই।

৩ · fork-এর খরচ আর ইতিহাস — copy-on-write, inetd আর prefork

“প্রতি সংযোগে একটি পুরো প্রসেস কপি” — ১০০ MB-র সার্ভার হলে প্রতি client-এ আরও ১০০ MB? আধুনিক OS তা করে না। Copy-on-write (COW)-এ fork-এর সময় পাতা কপি হয় না; parent আর child একই পাতা শেয়ার করে, আর কেউ কোনো পাতায় লিখলে তবেই সেই পাতাটি কপি হয় (OS কোর্সের virtual memory পর্বের অংশ ৫)। তবু fork বিনামূল্যে নয়:

  • Page table কপি: পাতার মানচিত্র কপি করতে হয়; অনেক GB মেমরির প্রসেসে fork মিলিসেকেন্ড পর্যন্ত নিতে পারে।
  • পরে লেখার খরচ: child যত পাতায় লেখে, তত পাতা কপি হয় — page fault আর মেমরি দুটোই বাড়ে।
  • Context switch: প্রতিটি প্রসেসের আলাদা address space, তাই প্রসেস বদলাতে TLB-র মতো cache-এর উষ্ণতা হারায় (OS কোর্সের context switch পর্বের অংশ ৫)।
  • ভাগাভাগি কঠিন: মেমরি আলাদা, তাই “কতজন সংযুক্ত” জাতীয় একটি counter শেয়ার করতেও IPC লাগে।

বিনিময়ে fork দেয় সবচেয়ে বড় উপহার — বিচ্ছিন্নতা (isolation). একটি child-এ segmentation fault হলে শুধু সেই client-এর সংযোগ যায়, বাকিরা নিরাপদ; প্রতিটি child আলাদা ব্যবহারকারীর অধিকারেও চলতে পারে। এজন্য আজও PostgreSQL প্রতিটি client সংযোগে একটি আলাদা backend প্রসেস চালায়, আর OpenSSH-এর sshd প্রতিটি সংযোগে নতুন প্রসেস তৈরি করে।

ইতিহাসের পাতা: আশির দশকের BSD Unix-এ inetd নামে একটি “super-server” জনপ্রিয় হয়: সে একাই অনেক port-এ শুনত, সংযোগ এলে fork করে সেই সেবার প্রোগ্রাম চালাত, আর connected socket-টি দিত তার stdin আর stdout হিসেবে — ছোট সেবা লিখতে socket প্রোগ্রামিং জানারই দরকার ছিল না। systemd-এর “socket activation” একই ধারণার আধুনিক রূপ। প্রথম দিকের ওয়েব সার্ভারগুলোও (যেমন NCSA httpd) প্রতি request-এ fork করত বলে সাধারণত বলা হয়; ভিড় বাড়লে Apache httpd ১.x-এর মতো সার্ভার pre-fork মডেল নেয় — আগে থেকে কয়েকটি child, প্রত্যেকে নিজে accept() করে অনেক request সামলায় (অংশ ৭)।

৪ · প্রতি সংযোগে একটি থ্রেড — pthreads আর &connfd-এর ফাঁদ

একটি প্রসেসের ভেতরে একাধিক থ্রেড চলতে পারে — সবাই একই মেমরি, একই fd টেবিল, একই global variable শেয়ার করে, শুধু প্রত্যেকের নিজের stack আর register (OS কোর্সের থ্রেড পর্ব)। নতুন থ্রেড তৈরি fork-এর চেয়ে সস্তা: address space কপি নেই, page table কপি নেই। POSIX threads (pthreads) দিয়ে thread-per-connection সার্ভারের কাঠামো fork সার্ভারের মতোই, শুধু fork()-এর জায়গায় pthread_create():

static void *serve_thread(void *arg) {
    int c = *(int *)arg;  free(arg);      /* take my own copy, then free it */
    pthread_detach(pthread_self());       /* nobody will join me: free my resources at exit */
    serve(c);
    close(c);                             /* threads share ONE fd table: exactly one close */
    return NULL;
}
for (;;) {
    int *p = malloc(sizeof *p);
    *p = accept(ls, NULL, NULL);          /* each connection gets its own heap int */
    pthread_t t;
    if (*p < 0 || pthread_create(&t, NULL, serve_thread, p) != 0) { if (*p >= 0) close(*p); free(p); }
}

fork সার্ভারের সাথে দুটি পার্থক্য। প্রথমত, main connected fd বন্ধ করে না — থ্রেডেরা একটিই fd টেবিল শেয়ার করে; fork-এর অভ্যাসে main close(c) করলে থ্রেডের হাত থেকে socket কেড়ে নেওয়া হতো! দ্বিতীয়ত, pthread_detach(): শেষ হওয়া থ্রেডের কিছু অবশিষ্ট থাকে যতক্ষণ না কেউ pthread_join() করে — অনেকটা zombie-র মতো। এখানে কেউ join করবে না, তাই detach, যাতে শেষ হলেই সম্পদ ফেরত যায় (pthreads পর্বের অংশ ৩)।

ক্লাসিক বাগ: &connfd পাঠানো

অনেক টিউটোরিয়াল আর AI-এর কোডে দেখা যায়: connfd = accept(…); pthread_create(&t, NULL, serve, &connfd);. থ্রেডকে দেওয়া হলো main-এর একটি variable-এর ঠিকানা, মান নয়। pthread_create() ফেরার আগে নতুন থ্রেড চলা শুরু করবে, এমন নিশ্চয়তা নেই; এর মধ্যে আরেকটি client এলে main connfd-এ নতুন মান লেখে, আর প্রথম থ্রেড পরে *arg পড়ে পায় দ্বিতীয় client-এর fd. এটি একটি data race — এক থ্রেড লিখছে, অন্যটি পড়ছে, মাঝে কোনো সমন্বয় নেই।

1int connfd;                  // ONE variable in main2for (;;) {3  connfd = accept(ls, 0, 0);4  pthread_create(&t, 0, serve, &connfd); // BUG5}6void *serve(void *arg) {7  int fd = *(int *)arg;      // read LATER8  talk(fd); close(fd); }9// FIX: p = malloc; *p = connfd; pass p
মেমরি আর থ্রেডের অবস্থা
main-এর connfd
T1 পড়ল
T2 পড়ল
Client A (fd 5)
Client B (fd 7)
  1. Client A এল। accept() fd 5 দিল, main তা রাখল connfd-এ। এখনো সব ঠিক।
  2. T1 তৈরি, কিন্তু চলেনি। main T1-কে দিল &connfd — একটি ঠিকানা। T1 এখন “প্রস্তুত”, কিন্তু scheduler এখনো তাকে CPU দেয়নি। pthread_create() ফিরে এসেছে, main থামেনি।
  3. Client B এল। main লুপ ঘুরে আবার accept() — fd 7, আর তা লিখল একই connfd-এ। যে ঠিকানা T1-এর হাতে আছে, সেখানে এখন 7.
  4. T2 তৈরি। T2-ও পেল একই &connfd. দুটি থ্রেড, একটি ঠিকানা, কেউ এখনো পড়েনি।
  5. T1 অবশেষে চলল। সে *arg পড়ল — পেল 7, অথচ তার পাওয়ার কথা ছিল 5. T1 এখন client B-র সাথে কথা বলছে।
  6. ক্ষতির হিসাব। T2-ও পড়ল 7. ফল: client A-র fd 5-এ কেউ কখনো পড়বে না — A ঝুলে থাকবে, আর fd 5 leak হবে। B-র socket-এ দুই থ্রেড একসাথে, আর দুজনেই close(7) করবে; দ্বিতীয় close-এর আগে নতুন কোনো client fd 7 পেলে নির্দোষ এক তৃতীয় client-এর socket বন্ধ হবে। এমন বাগ ভারী চাপে মাঝে মাঝে দেখা দেয়, পরীক্ষায় প্রায় ধরা পড়ে না।
  7. সমাধান: প্রতি সংযোগে নিজস্ব কপি। প্রতিবার malloc() করে নতুন একটি int-এ fd রাখুন আর সেই ঠিকানা পাঠান; থ্রেড মান পড়ে নিজেই free() করবে। এখন দুটি আলাদা ঠিকানা (p1 → 5, p2 → 7), main পরে কী লিখল তাতে কিছু যায় আসে না। (আরেকটি পথ: (void *)(intptr_t)connfd হিসেবে মানটিই পাঠানো।)

নিজে চালিয়ে দেখুন: race নিরাপদে, প্রতিবার একই ফল

আসল race মাঝে মাঝে ঘটে, তাই পরীক্ষায় লুকিয়ে থাকে। নিচের প্রোগ্রাম ইচ্ছা করে “সবচেয়ে খারাপ scheduling” তৈরি করে: থ্রেডগুলো একটি pthread_barrier-এ অপেক্ষা করে, যতক্ষণ না main তিনটি সংযোগই accept() করে — stepper-এর ধাপ ২–৪, কিন্তু প্রতিবার নিশ্চিতভাবে। তারপর একই কাজ malloc করা কপি দিয়ে, সব loopback-এ।

#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
#include <arpa/inet.h>
static pthread_barrier_t gate;  /* threads wait here until main has accepted all 3 (= worst-case scheduling) */
static int heap;                /* 0 = pass &connfd (bug), 1 = pass a malloc'ed copy (fix) */
static void *serve(void *arg) {
    pthread_barrier_wait(&gate);
    int fd = *(int *)arg;       /* "which connection is mine?" -- read only now */
    if (heap) free(arg);        /* the thread owns its copy */
    return (void *)(intptr_t)fd;
}
int main(void) {
    struct sockaddr_in a = {.sin_family = AF_INET, .sin_addr.s_addr = htonl(INADDR_LOOPBACK)};
    socklen_t n = sizeof a; int ls = socket(AF_INET, SOCK_STREAM, 0), cl[3], acc[3], connfd;
    bind(ls, (struct sockaddr *)&a, n); listen(ls, 8); getsockname(ls, (struct sockaddr *)&a, &n);
    for (heap = 0; heap <= 1; heap++) {
        pthread_t t[3]; pthread_barrier_init(&gate, NULL, 4);
        printf(heap ? "FIX: arg = malloc'ed copy of connfd\n" : "BUG: arg = &connfd\n");
        for (int i = 0; i < 3; i++) {
            cl[i] = socket(AF_INET, SOCK_STREAM, 0); connect(cl[i], (struct sockaddr *)&a, sizeof a);
            acc[i] = connfd = accept(ls, NULL, NULL);
            int *p = &connfd;                                    /* the classic bug */
            if (heap) { p = malloc(sizeof *p); *p = connfd; }    /* the fix */
            pthread_create(&t[i], NULL, serve, p);
            printf("  main: accepted fd %d -> thread %d\n", connfd, i); }
        pthread_barrier_wait(&gate);                             /* now let the threads run */
        for (int i = 0; i < 3; i++) {
            void *r; pthread_join(t[i], &r); int fd = (int)(intptr_t)r;
            printf("  thread %d serves fd %d%s\n", i, fd, fd == acc[i] ? "" : "   <-- WRONG"); close(cl[i]); close(acc[i]); }
        pthread_barrier_destroy(&gate);
    }
}

আউটপুট:

BUG: arg = &connfd
  main: accepted fd 5 -> thread 0
  main: accepted fd 7 -> thread 1
  main: accepted fd 9 -> thread 2
  thread 0 serves fd 9   <-- WRONG
  thread 1 serves fd 9   <-- WRONG
  thread 2 serves fd 9
FIX: arg = malloc'ed copy of connfd
  main: accepted fd 5 -> thread 0
  main: accepted fd 7 -> thread 1
  main: accepted fd 9 -> thread 2
  thread 0 serves fd 5
  thread 1 serves fd 7
  thread 2 serves fd 9

fd নম্বরগুলো এভাবে এল: listener 3, প্রথম client-এর নিজের socket 4, সার্ভারের দিকে accept() 5; তারপর 6 আর 7; তারপর 8 আর 9. বাগওয়ালা রানে main তিনটি আলাদা fd (5, 7, 9) পেয়েছিল, কিন্তু থ্রেডগুলো যখন *arg পড়ল, connfd-এ শেষ মান 9 — তাই তিন থ্রেডই fd 9 দেখল. fd 5 আর 7-এর client-রা কখনো সেবা পেতেন না। ঠিক করা রানে প্রতিটি থ্রেড নিজের malloc করা int পড়ল — 5, 7, 9 — আর নিজেই free() করল। ক্রম নিশ্চিত রাখতে থ্রেডেরা নিজেরা ছাপে না; fd ফেরত দেয়, আর main ক্রম মেনে pthread_join() করে ছাপে। Barrier ছাড়া ফল রানে রানে বদলাত — কখনো ঠিক, কখনো ভুল — যা এই বাগকে এত বিপজ্জনক করে। (glibc ২.৩৪ থেকে pthread মূল C লাইব্রেরির ভেতরে, তাই এখানে -pthread ছাড়াই link হয়েছে; পুরোনো সিস্টেমে gcc -pthread দিন।)

প্রতি থ্রেডে কত মেমরি — আর থ্রেডের সীমা

প্রতিটি থ্রেডের নিজের stack লাগে। Linux-এ glibc সাধারণত এর আকার নেয় প্রসেসের stack সীমা থেকে (ulimit -s, অনেক distro-তে 8192 KB = ৮ MiB), যদি না প্রোগ্রাম pthread_attr_setstacksize() দিয়ে অন্য মান দেয়। তবে এটি virtual সংরক্ষণ — আসল RAM লাগে শুধু ছোঁয়া পাতার জন্য, একটি সাধারণ সংযোগ-থ্রেডে প্রায়ই কয়েক থেকে কয়েক দশ KB. অন্য OS-এ ডিফল্ট আলাদা (Windows-এ সাধারণত ১ MB, macOS-এ মূল থ্রেড ছাড়া অন্যগুলোতে সাধারণত ৫১২ KB) — নিজের সিস্টেমে মেপে নিন।

হিসাব: ১০,০০০ থ্রেড × ৮ MiB = ৮০,০০০ MiB ≈ ৭৮ GiB virtual address space. ৬৪-bit-এ সমস্যা নেই, কিন্তু ৩২-bit সিস্টেমে (user space মোটামুটি ৩ GiB) ৪০০-র কম থ্রেডেই জায়গা শেষ হতো — এক সময়ের বাস্তব দেয়াল। আজকের সীমা অন্যখানে: /proc/sys/kernel/threads-max, ulimit -u, container-এর pids সীমা, আর হাজার হাজার জেগে ওঠা থ্রেডের context switch. তাই thread-per-connection শত বা কয়েক হাজার সংযোগে ভালো, অগণিত সংযোগে নয়।

৫ · ভাগাভাগির অবস্থা — counter-এ race, mutex আর atomic

থ্রেডের সুবিধা — সবাই একই মেমরি দেখে — একই সাথে তার বিপদ। ধরুন সার্ভার রাখে “এখন কতজন সংযুক্ত” (active), যাতে সীমা পেরোলে নতুনদের ফেরানো যায়; প্রতিটি থ্রেড শুরুতে active++ আর শেষে active-- করে। C-তে এক লাইন, কিন্তু CPU-তে তিন ধাপ: মেমরি থেকে পড়া (load), এক যোগ করা (add), মেমরিতে লেখা (store). দুই থ্রেড প্রায় একসাথে করলে:

   T1                     T2                    active (মেমরিতে)
   load  41                                      41
                          load  41               41
   add → 42                                      41
                          add → 42               41
   store 42                                      42
                          store 42               42   ← দুটি সংযোগ, অথচ বাড়ল মাত্র ১

একটি বৃদ্ধি হারিয়ে গেল (lost update). active-- হারালে counter ধীরে ধীরে বাড়তেই থাকে, আর একদিন “সীমা পূর্ণ” ভেবে সার্ভার সবাইকে ফিরিয়ে দেয়, যদিও আসলে কেউ নেই। এমন data race কম লোডে প্রায় ঘটেই না, ধরা পড়ে ভিড়ের দিনে। সমাধান তিনটি:

long active;                                   /* (1) RACE: active++ = load, add, store */
void on_open_racy(void)   { active++; }

pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER; /* (2) MUTEX: one thread at a time */
void on_open_mutex(void)  { pthread_mutex_lock(&m); active++; pthread_mutex_unlock(&m); }

#include <stdatomic.h>                         /* (3) ATOMIC (C11): one indivisible step */
atomic_long active_a;
void on_open_atomic(void) { atomic_fetch_add(&active_a, 1); }
পথঠিক ফল?খরচকখন
সাধারণ active++না — ভিড়ে update হারায়সবচেয়ে সস্তা, কিন্তু অর্থহীনকখনো না (যদি একাধিক থ্রেড লেখে)
Mutexহ্যাঁপ্রতিযোগিতা না থাকলে সস্তা (Linux-এ futex-এর দ্রুত পথ); ভিড়ে থ্রেড ঘুমায়একসাথে কয়েকটি মান বদলাতে হলে — যেমন counter আর একটি তালিকা একসাথে
Atomicহ্যাঁএকটি CPU instruction (x86-এ lock xadd); কোনো ঘুম নেইএকটিমাত্র সংখ্যা — counter, পরিসংখ্যান

mutex দিয়ে ঘেরা অংশটুকুকে বলে critical section — সেখানে যত কম কাজ, তত ভালো। একটি সাধারণ ভুল: mutex ধরে রেখে নেটওয়ার্কে write() করা — ধীর client-এর জন্য সব থ্রেড লাইনে দাঁড়ায়, আর concurrent সার্ভার আবার iterative হয়ে যায়। তত্ত্ব আর ভেতরের কৌশল OS কোর্সের mutex আর semaphore, atomic আর spinlock আর race condition পর্বে।

৬ · Thread pool — bounded queue, backpressure আর pool-এর আকার

Thread-per-connection-এর দুটি দুর্বলতা: প্রতি সংযোগে থ্রেড তৈরি আর ধ্বংসের খরচ, আর সবচেয়ে বড় কথা — কোনো সীমা নেই. হঠাৎ ২০,০০০ client এলে সার্ভার ২০,০০০ থ্রেড তৈরির চেষ্টা করবে, আর মেমরি ও scheduler ভেঙে পড়বে। Thread pool দুটোরই সমাধান: শুরুতেই নির্দিষ্ট সংখ্যক (W) worker থ্রেড, যারা কখনো শেষ হয় না; আর মাঝখানে একটি bounded queue — নির্দিষ্ট ধারণক্ষমতার সারি।

ব্যাংকের শাখায় চারটি কাউন্টার আর টোকেন মেশিন। দরজার নিরাপত্তাকর্মী (accept loop) টোকেন দেন; বসার চেয়ার (queue) মাত্র ২০টি; কর্মকর্তারা (worker) একজনের কাজ শেষ হলেই পরের টোকেন ডাকেন। চেয়ার ভরে গেলে নিরাপত্তাকর্মী দরজা আটকে রাখেন — বাইরে লাইন হয় (backpressure) — অথবা বলেন, “এখন আর নয়, পরে আসুন” (প্রত্যাখ্যান)। যা কখনো করা হয় না: সীমাহীন মানুষ ঢুকিয়ে হল ভরে ফেলা।

এটি OS-এর ক্লাসিক producer-consumer সমস্যা (ক্লাসিক সমস্যা পর্বের অংশ ২): producer = accept loop, যে connected fd queue-তে রাখে; consumer = worker, যারা fd তুলে client-কে সেবা দেয়। লাগে একটি mutex আর দুটি condition variable: not_empty (queue খালি হলে worker এখানে ঘুমায়) আর not_full (queue ভরা হলে producer এখানে ঘুমায়)। অপেক্ষা সবসময় while লুপে — জেগে উঠে শর্ত আবার যাচাই করতে হয় (condition variable পর্বের অংশ ৩)।

Queue, worker আর producer (accept loop)-এর অবস্থা
ঘটনা
Queue (ধারণক্ষমতা ২)
W1
W2
Producer
  1. শুরু। দুটি worker তৈরি হয়েই mutex নিয়ে দেখল queue খালি (count == 0), তাই pthread_cond_wait(&not_empty, &m) — ঘুমিয়ে গেল, আর ঘুমানোর সময় mutex ছেড়ে দিল। Producer accept()-এ অপেক্ষা করছে।
  2. c1 এল। Producer mutex নিয়ে c1 queue-তে রাখল, not_empty-তে signal দিল, mutex ছাড়ল। W1 জেগে mutex আবার নিল, while-এর শর্ত আবার দেখল (এখন count 1), c1 তুলে নিল, not_full-এ signal দিল, mutex ছেড়ে mutex-এর বাইরে client-কে সেবা দিতে লাগল।
  3. c2 এল। একইভাবে W2 c2 নিল। এখন দুই worker-ই ব্যস্ত — এটাই সর্বোচ্চ concurrency (W = ২)।
  4. c3, c4 এল। কেউ খালি নেই, তাই দুটি সংযোগ queue-তে; count 2 = ধারণক্ষমতা। এই client-রা সংযুক্ত, কিন্তু কেউ তাঁদের কথা পড়ছে না — latency বাড়ছে।
  5. c5 এল — queue ভরা। Producer দেখল count == CAP, তাই not_full-এ ঘুমাল। এটাই backpressure: producer থামায় নতুন সংযোগ kernel-এর accept queue-তে জমে, সেটি ভরলে নতুন SYN উপেক্ষিত হয়। বিকল্প নীতি: অপেক্ষা না করে তখনই একটি ছোট “ব্যস্ত” উত্তর দিয়ে সংযোগ বন্ধ — HTTP-তে 503 Service Unavailable, সাথে Retry-After.
  6. W1 c1 শেষ করল। W1 mutex নিয়ে c3 তুলল (count 1), not_full-এ signal দিল। Producer জেগে while আবার যাচাই করল, c5 রাখল — queue এখন [c4, c5]. লক্ষ করুন, যে worker আগে মুক্ত হয় সে-ই পরের সংযোগ নেয়; W2 আগে শেষ করলে c3 যেত W2-র কাছে — কে কখন জাগবে, তা scheduler ঠিক করে।
  7. Graceful shutdown. বন্ধের সময় producer accept() বন্ধ করে stop = 1 লেখে আর pthread_cond_broadcast(&not_empty) দিয়ে সবাইকে জাগায়। Worker-রা queue-তে যা আছে (c4, c5) শেষ করে; queue খালি আর stop সত্য হলে লুপ থেকে বেরিয়ে আসে। Main সবাইকে pthread_join() করে — কোনো client-কে মাঝপথে ফেলে যাওয়া হয়নি।

নিজে চালিয়ে দেখুন: ৩ worker, queue ৪, ৮টি কাজ

নিচের প্রোগ্রামে producer ৮টি কাজ (বাস্তবে ৮টি connected fd) এক ঝটকায় queue-তে দিতে চায়; queue-তে জায়গা ৪টি, worker ৩টি, প্রতিটি কাজে ৫০ ms. প্রতিটি worker একটি mutex-রক্ষিত counter বাড়ায় আর লিখে রাখে কোন কাজ কে করল; শেষে main সারাংশ ছাপে।

#include <stdio.h>
#include <stdint.h>
#include <pthread.h>
#include <unistd.h>
#define CAP 4                 /* bounded queue capacity */
#define NJOBS 8               /* 8 connections arrive in a burst */
static int q[CAP], head, count, maxq, stop, served, by[NJOBS + 1];
static pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;
static pthread_cond_t not_empty = PTHREAD_COND_INITIALIZER, not_full = PTHREAD_COND_INITIALIZER;
static void put(int job) {                                   /* producer = the accept() loop */
    pthread_mutex_lock(&m);
    while (count == CAP) pthread_cond_wait(&not_full, &m);   /* full: wait = backpressure */
    q[(head + count++) % CAP] = job; if (count > maxq) maxq = count;
    pthread_cond_signal(&not_empty); pthread_mutex_unlock(&m);
}
static void *worker(void *arg) {                             /* consumer */
    for (int id = (int)(intptr_t)arg;;) {
        pthread_mutex_lock(&m);
        while (count == 0 && !stop) pthread_cond_wait(&not_empty, &m);
        if (count == 0) { pthread_mutex_unlock(&m); return NULL; }   /* stopping and drained */
        int job = q[head]; head = (head + 1) % CAP; count--;
        pthread_cond_signal(&not_full); pthread_mutex_unlock(&m);
        usleep(50000);                                       /* serve one slow client: 50 ms */
        pthread_mutex_lock(&m); served++; by[job] = id; pthread_mutex_unlock(&m);
    }
}
int main(void) {
    pthread_t w[3];
    for (intptr_t i = 0; i < 3; i++) pthread_create(&w[i], NULL, worker, (void *)(i + 1));
    for (int j = 1; j <= NJOBS; j++) put(j);
    pthread_mutex_lock(&m); stop = 1; pthread_cond_broadcast(&not_empty); pthread_mutex_unlock(&m);
    for (int i = 0; i < 3; i++) pthread_join(w[i], NULL);    /* graceful shutdown: finish, then join */
    printf("job:worker "); for (int j = 1; j <= NJOBS; j++) printf(" %d:W%d", j, by[j]);
    printf("\nserved=%d (expected %d), longest queue=%d (capacity %d)\n", served, NJOBS, maxq, CAP);
}

আউটপুট (একটি রান):

job:worker  1:W1 2:W2 3:W3 4:W3 5:W2 6:W1 7:W2 8:W1
served=8 (expected 8), longest queue=4 (capacity 4)

কোন worker কোন কাজ করল — এই লাইন রানে রানে বদলাতে পারে, আর সেটাই স্বাভাবিক: কোন ঘুমন্ত worker আগে জাগবে, তা scheduler ঠিক করে (এই রানে ৪ নম্বর গেল W3-এ)। যা কখনো বদলায় না: প্রতিটি কাজ ঠিক একবার, served=8 — mutex থাকায় counter-এর কোনো বৃদ্ধি হারায়নি; আর longest queue=4 — queue কখনো ধারণক্ষমতা পেরোয়নি। Worker প্রতিটি কাজে ৫০ ms ব্যস্ত, অথচ producer মাইক্রোসেকেন্ডে কাজ রাখে; তাই প্রথম তিনটি কাজ নেওয়ার পরে queue দ্রুত ভরে যায়, আর বাকি কাজ হাতে producer not_full-এ অপেক্ষা করে — backpressure. মোট সময় মোটামুটি ⌈৮ / ৩⌉ × ৫০ = ১৫০ ms. শেষে stop আর broadcast পেয়ে worker-রা queue খালি করে বেরিয়েছে, main তাদের join করেছে — ছোট একটি graceful shutdown.

Queue ভরা হলে — অপেক্ষা না প্রত্যাখ্যান?

Backpressure (producer থামে) সহজ, কাউকে সরাসরি “না” বলে না, কিন্তু চাপ ঠেলে দেয় kernel-এর accept queue-তে, যেখানে client অন্ধকারে অপেক্ষা করে হয়তো timeout হয়ে যান। দ্রুত প্রত্যাখ্যান (load shedding) সঙ্গে সঙ্গে জানায় “এখন ব্যস্ত” — client বা load balancer অন্য সার্ভারে যেতে পারে, আর ভেতরের সবার latency ঠিক থাকে। যে নীতিই নিন, সীমাহীন queue কখনো নয়: চাপের সময় সে শুধু মেমরি খায় আর latency বাড়ায় — পেছনের অনুরোধ যখন সেবা পায়, client ততক্ষণে চলে গেছেন।

Pool কত বড় হবে — দুটি নিয়ম আর Little-এর সূত্র

কোনো সূত্রই নিখুঁত নয়; শুরুর অনুমান নিয়ে মেপে ঠিক করতে হয়। দুটি প্রচলিত হাতের নিয়ম:

  • CPU-bound কাজ (হিসাব, compression): worker ≈ core-এর সংখ্যা (কখনো core + ১)। বেশি থ্রেড শুধু context switch বাড়ায়।
  • I/O-bound কাজ (ডেটাবেস বা ধীর client-এর অপেক্ষা): worker ≈ core × (১ + অপেক্ষার সময় / CPU-র সময়) — Brian Goetz-দের Java Concurrency in Practice বইয়ের মাধ্যমে পরিচিত রূপ। উদাহরণ: ৮ core, প্রতিটি request-এ ১০ ms CPU আর ৯০ ms অপেক্ষা → ৮ × (১ + ৯০/১০) = ৮০টি worker; তবু CPU-র সর্বোচ্চ সামর্থ্য ৮ ÷ ১০ ms = সেকেন্ডে ৮০০ request.

আরেকটি শক্ত হাতিয়ার Little-এর সূত্র (পর্ব ০৫-এর queue মনে করুন): দীর্ঘ সময়ের গড়ে L = λ × W — একসাথে থাকা request-এর গড় সংখ্যা = আগমনের হার × প্রতিটির গড় সময়। প্রতিটি request পুরো সময় একটি থ্রেড ধরে রাখলে worker লাগবে কমপক্ষে L. উদাহরণ: সেকেন্ডে ৫০০ request, প্রতিটিতে ২০০ ms → L = ৫০০ × ০.২ = ১০০; এর কম worker হলে queue অবিরাম বাড়বে। Queue-র আকার ঠিক হয় সহ্যযোগ্য অপেক্ষা দিয়ে — কাউকে ২ সেকেন্ডের বেশি বসানো চলবে না আর সার্ভার সেকেন্ডে ৫০০ সামলায়, তাহলে queue ≤ ১,০০০.

৭ · Pre-fork, pre-thread আর thundering herd

Thread pool-এ একজন producer accept() করে কাজ হাতবদল করে। আরও সরল একটি পুরোনো নকশা: সবাই নিজে accept() করে. সার্ভার শুরুতেই listening socket খুলে কয়েকটি প্রসেস fork করে (pre-forked) বা কয়েকটি থ্রেড তৈরি করে (pre-threaded), আর প্রত্যেকে একই listening fd-তে accept() ডেকে বসে থাকে; kernel-এর accept queue-ই সারি। Apache-র prefork MPM এমনই: অলস child-এর সংখ্যা একটি সীমার মধ্যে রাখা, আর মেমরি-লিক থেকে বাঁচতে নির্দিষ্ট সংখ্যক সংযোগের পরে child নতুন করে তৈরি (MaxConnectionsPerChild).

Thundering herd — একটি সংযোগের জন্য সবাই জাগে

একশটি প্রসেস একই socket-এর অপেক্ষায়, একটি সংযোগ এল — kernel সবাইকে জাগালে একজনই সংযোগ পায়, বাকি নিরানব্বই জন জেগে, CPU নিয়ে, আবার ঘুমায়। একে বলে thundering herd. পুরোনো Unix আর প্রথম দিকের Linux-এ blocking accept()-এ এই সমস্যা ছিল বলে প্রচলিত ইতিহাসে বলা হয়; Linux বহু বছর ধরে সেখানে সাধারণত একজনকেই জাগায়। কিন্তু প্রতিটি worker নিজের epoll দিয়ে listening socket পাহারা দিলে (পর্ব ৪৪) সমস্যা ফিরে আসে; এজন্য Linux-এ পরে EPOLLEXCLUSIVE যোগ হয় (kernel ৪.৫-এর দিকে), আর কিছু পুরোনো সার্ভার accept-এর আগে নিজেদের মধ্যে একটি lock নিত (Apache-র “accept mutex”).

SO_REUSEPORT — প্রত্যেকের নিজের listening socket

আধুনিক আরেকটি সমাধান SO_REUSEPORT (Linux-এ kernel ৩.৯ থেকে, ২০১৩ সালের দিকে; BSD-তে আচরণ কিছুটা আলাদা)। প্রতিটি worker নিজের listening socket খোলে, সবাই একই port-এ bind করে, আর kernel নতুন সংযোগ 4-tuple-এর hash দিয়ে কোনো একটির queue-তে দেয় — ভাগের queue নিয়ে টানাটানি নেই, বহু-core মেশিনে ভার ভালো ছড়ায় (nginx-এর listen … reuseport). মূল্য: একটি worker ব্যস্ত থাকলেও তার queue-তে নতুন সংযোগ আসতে থাকে, পাশের worker অলস থাকলেও; আর একটি listening socket বন্ধ হলে তার queue-র সংযোগ হারাতে পারে। (SO_REUSEADDR ভিন্ন জিনিস — TIME_WAIT থাকা অবস্থায় আবার bind করতে দেয়।)

৮ · পাঁচ মডেলের তুলনা আর বাস্তবের সার্ভার

বিষয়IterativeFork প্রতি সংযোগেThread প্রতি সংযোগেThread poolEvent-driven (পর্ব ৪৪)
একসাথে কতজন১প্রসেস-সীমা পর্যন্ত (শত থেকে কয়েক হাজার)থ্রেড-সীমা পর্যন্ত (কয়েক হাজার)W জন সেবায় + queue-তে Qহাজার থেকে লাখ (fd-সীমা পর্যন্ত)
প্রতি সংযোগে মেমরিসবচেয়ে কমসবচেয়ে বেশি (আলাদা প্রসেস, COW-এর পরেও)মাঝারি (stack + buffer)সংযোগ নয়, worker-প্রতি; স্থির আর পূর্বানুমেয়খুব কম (শুধু buffer আর অবস্থা)
Context switchপ্রায় নেইবেশি, আর ভারী (address space বদল)বেশি (একই address space, তুলনায় হালকা)W-এর মধ্যে সীমিতসবচেয়ে কম (core-প্রতি এক থ্রেড)
Crash-এর প্রভাবপুরো সার্ভারশুধু সেই client (সবচেয়ে ভালো বিচ্ছিন্নতা)পুরো প্রসেস — সব clientপুরো প্রসেস — সব clientপুরো প্রসেস (বা সেই worker প্রসেস)
ভাগের অবস্থাসহজকঠিন (IPC লাগে)সহজ, কিন্তু race-এর ঝুঁকিসহজ, কিন্তু race-এর ঝুঁকিসহজ (এক থ্রেডে race নেই)
কোডের জটিলতাসবচেয়ে সহজসহজ (সরলরেখার কোড)সহজ, কিন্তু lock সাবধানেমাঝারি (queue, দুই condition variable)বেশি (callback বা state machine, কোথাও block করা চলবে না)
ধীর client-এর প্রভাবসবাই আটকেএকটি প্রসেস আটকেএকটি থ্রেড আটকেএকটি worker আটকে — সব worker আটকালে pool অচলপ্রায় কিছুই নয় (অপেক্ষার সময় অন্যদের কাজ)

নিজে চালিয়ে দেখুন: হঠাৎ ভিড়ে কোন মডেল কেমন

সরল মডেল: t = 0-তে N জন ধীর client একসাথে এল; প্রতিটি request-এ ২০০ ms, প্রায় পুরোটা অপেক্ষা। একসাথে k-টি চলে, আরও Q-টি queue-তে বসে, বাকিরা সঙ্গে সঙ্গে প্রত্যাখ্যাত; ১ সেকেন্ডের বেশি অপেক্ষা করলে client হাল ছাড়েন (“late”). প্রতি থ্রেডে ২ MB আর মোট ৬৪ MB বাজেট — অনুমান, নিজের সার্ভারে মেপে বদলে নিন।

// A burst of N slow clients arrives at t = 0. Each request needs S ms, mostly WAITING (slow client, disk),
// so CPU is not the bottleneck. k requests run in parallel, Q more wait in a queue, the rest are refused.
// A client gives up after TIMEOUT ms. THREAD_MB and MEM_MB are assumptions -- measure your own server.
const S = 200, TIMEOUT = 1000, THREAD_MB = 2, MEM_MB = 64;
function run(name, N, k, Q) {
  let ok = 0, late = 0, refused = 0, sum = 0, worst = 0;
  for (let i = 0; i < N; i++) {
    if (i >= k + Q) { refused++; continue; }         // no worker, no queue slot: refuse fast (e.g. 503)
    const t = (Math.floor(i / k) + 1) * S;            // waits floor(i/k) rounds, then is served
    sum += t; worst = Math.max(worst, t);
    if (t <= TIMEOUT) ok++; else late++;              // late = client already gave up
  }
  const mem = Math.min(N, k) * THREAD_MB;
  const avg = ok + late ? Math.round(sum / (ok + late)) : 0;
  console.log(name.padEnd(24) + [N, ok, late, refused, avg, worst].map(v => String(v).padStart(8)).join("") +
    (mem + " MB" + (mem > MEM_MB ? " (!)" : "")).padStart(12));
}
console.log("model".padEnd(24) + ["N", "ok", "late", "refused", "avg ms", "worst"].map(h => h.padStart(8)).join("") +
  "memory".padStart(12));
for (const N of [20, 200]) {
  run("iterative", N, 1, Infinity);
  run("thread/conn, no cap", N, N, 0);
  run("thread/conn, cap " + MEM_MB / THREAD_MB, N, Math.min(N, MEM_MB / THREAD_MB), 0);
  run("pool 16 + queue 48", N, 16, 48);
}
console.log(`(!) = more than the ${MEM_MB} MB memory budget; ok = answered within ${TIMEOUT} ms`);

আউটপুট:

model                          N      ok    late refused  avg ms   worst      memory
iterative                     20       5      15       0    2100    4000        2 MB
thread/conn, no cap           20      20       0       0     200     200       40 MB
thread/conn, cap 32           20      20       0       0     200     200       40 MB
pool 16 + queue 48            20      20       0       0     240     400       32 MB
iterative                    200       5     195       0   20100   40000        2 MB
thread/conn, no cap          200     200       0       0     200     200  400 MB (!)
thread/conn, cap 32          200      32       0     168     200     200       64 MB
pool 16 + queue 48           200      64       0     136     500     800       32 MB
(!) = more than the 64 MB memory budget; ok = answered within 1000 ms

N = ২০: iterative-এ মাত্র ৫ জন ১ সেকেন্ডের মধ্যে উত্তর পেলেন, বাকি ১৫ জন হাল ছাড়লেন; গড় ২,১০০ ms = (২০ + ১) × ২০০ / ২ — অংশ ১-এর সূত্র হুবহু। Thread-per-connection সবাইকে ২০০ ms-এ দিল, ৪০ MB খরচে। Pool (১৬ worker + ৪৮-এর queue) ১৬ জনকে প্রথম দফায়, ৪ জনকে দ্বিতীয় দফায় দিল — গড় (১৬ × ২০০ + ৪ × ৪০০) / ২০ = ২৪০ ms, মেমরি ৩২ MB.

N = ২০০: সীমাহীন thread-per-connection সবাইকে দ্রুত দেয় — কিন্তু ৪০০ MB, বাজেটের ছয় গুণের বেশি ((!)); বাস্তবে এখানেই swap, OOM killer বা pthread_create-এর ব্যর্থতা। থ্রেডের সীমা ৩২ বসালে ৩২ জন পান, ১৬৮ জন প্রত্যাখ্যাত। Pool সময়সীমার মধ্যে ৬৪ জনকে দেয় (চার দফা, গড় ৫০০ ms), অর্ধেক মেমরিতে, আর বাকি ১৩৬ জনকে দ্রুত জানায় “ব্যস্ত”. মডেলটি ইচ্ছা করে সরল (CPU-র প্রতিযোগিতা ধরা হয়নি); বাস্তবে context switch থ্রেড-ভারী মডেলগুলোকে আরও ধীর করে।

বাস্তবের সার্ভার কী করে

  • Apache httpd: concurrency মডেল একটি বদলযোগ্য অংশ — MPM. prefork = প্রতি সংযোগে একটি প্রসেস; worker = কয়েকটি প্রসেস, প্রত্যেকে অনেক থ্রেড; event = worker-এর মতো, কিন্তু অলস keep-alive সংযোগ একটি আলাদা listener থ্রেড পাহারা দেয়। আধুনিক distro-তে সাধারণত event-ই ডিফল্ট।
  • nginx: শুরু থেকেই event-driven — একটি master আর সাধারণত core-প্রতি একটি worker প্রসেস, প্রত্যেকে epoll দিয়ে হাজার হাজার সংযোগ চালায়; একটি worker মরলে master নতুন worker চালায়।
  • Node.js: JavaScript চলে এক থ্রেডের event loop-এ (libuv); ফাইল সিস্টেম বা DNS-এর মতো blocking কাজ যায় libuv-এর ছোট thread pool-এ (ডিফল্ট ৪টি থ্রেড)।
  • Go: প্রতিটি সংযোগে একটি goroutine, সরলরেখার blocking কোড — কিন্তু goroutine হালকা (stack শুরু কয়েক KB-তে), আর runtime এগুলোকে অল্প কয়েকটি OS থ্রেডে চালায়, ভেতরে epoll-এর মতো netpoller দিয়ে। Java ২১-এর virtual thread একই পথে।
  • ডেটাবেস: PostgreSQL প্রতি সংযোগে প্রসেস, MySQL ডিফল্টে প্রতি সংযোগে থ্রেড — তাই সামনে প্রায়ই connection pool রাখা হয়।

কখন কোনটা? শেখা বা খুব কম ব্যবহারকারী — iterative. ঝুঁকিপূর্ণ বা আলাদা অধিকারের কাজ (লগইন) — fork. মাঝারি সংখ্যক ব্যস্ত সংযোগ আর সরল কোড — thread pool. অগণিত, বেশিরভাগ অলস সংযোগ (chat, WebSocket, IoT) — event-driven (পর্ব ৪৪) বা হালকা থ্রেড। বড় সার্ভার প্রায়ই মিশ্রণ: event loop সংযোগ পাহারা দেয়, blocking কাজ যায় thread pool-এ। পর্ব ৪৫-এর HTTP সার্ভার প্রজেক্টে এর একটি ছোট রূপ নিজে বানাবেন।

৯ · Timeout, ধীর client আর graceful shutdown

প্রতিটি client-এর timeout

থ্রেড আর প্রসেস মডেলে প্রতিটি সংযোগ একটি worker দখল করে রাখে। কেউ সংযোগ খুলে চুপ করে থাকলে read() চিরকাল অপেক্ষা করবে; ১৬ জন এমন client = ১৬ worker-এর pool অচল, অথচ CPU অলস। তাই প্রতিটি সংযোগে timeout বাধ্যতামূলক:

struct timeval tv = { .tv_sec = 10, .tv_usec = 0 };            /* 10 s of silence is enough */
setsockopt(c, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv);        /* read() fails with EAGAIN */
setsockopt(c, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof tv);        /* write() too, if client never reads */
ssize_t n = read(c, buf, sizeof buf);
if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) { close(c); /* give the worker back */ }

কিন্তু শুধু নীরবতার timeout যথেষ্ট নয়। Slowloris নামে পরিচিত আক্রমণ-ধারণায় (২০০৯ সালের দিকে ব্যাপক আলোচিত) একজন client অনেক সংযোগ খুলে প্রতিটিতে প্রতি কয়েক সেকেন্ডে header-এর কয়েকটি বাইট পাঠান, কখনো শেষ করেন না। প্রতিটি read() সময়মতো কিছু পায়, তাই idle timeout বাজে না, অথচ প্রতিটি সংযোগ একটি worker আটকে রাখে — অল্প bandwidth-এই একটি denial-of-service. প্রতিরক্ষা (নিজের সার্ভারে):

  • মোট সময়সীমা (deadline): “পুরো header ২০ সেকেন্ডের মধ্যে” ধরনের সীমা আর ন্যূনতম গতি (Apache-র mod_reqtimeout, nginx-এর client_header_timeout).
  • প্রতি IP-তে সংযোগের সীমা (nginx-এর limit_conn) — তবে NAT-এর পেছনে বহু ব্যবহারকারী একই IP শেয়ার করেন (পর্ব ১৯), তাই খুব কম নয়।
  • সামনে event-driven reverse proxy: nginx-এ অলস সংযোগের খরচ কয়েক KB; সে ধীর client সামলে শুধু সম্পূর্ণ request পেছনের অ্যাপে পাঠায় — সবচেয়ে প্রচলিত সমাধান।
  • দ্রুত প্রত্যাখ্যান: অংশ ৬-এর bounded queue. বড় আকারের আক্রমণ ও প্রতিরক্ষা পর্ব ৫০-এ।

Graceful shutdown — কাউকে মাঝপথে না ফেলে বন্ধ করা

হঠাৎ মেরে ফেললে (SIGKILL) চলমান সব request মাঝপথে কেটে যায় — কারও টাকা পাঠানোর মাঝখানে! Graceful shutdown-এর ধাপ:

  1. সংকেত: সাধারণত SIGTERM (systemd, docker stop, Kubernetes প্রথমে এটিই পাঠায়)। Handler শুধু একটি volatile sig_atomic_t পতাকা বসায় (signal পর্বের অংশ ৬ আর ৮)।
  2. নতুন সংযোগ বন্ধ: accept loop থামে, listening socket বন্ধ হয়; load balancer থাকলে আগে তাকে জানানো ভালো।
  3. চলমান কাজ শেষ: pool-এ stop = 1 আর broadcast (অংশ ৬-এর প্রোগ্রামের মতো); fork মডেলে parent child-দের অপেক্ষা করে।
  4. সময়সীমা: কেউ আটকে থাকলে নির্দিষ্ট সময় পরে জোর করে বন্ধ — Kubernetes-ও grace period (ডিফল্ট সাধারণত ৩০ সেকেন্ড) পরে SIGKILL পাঠায়।
  5. Join আর বের হওয়া: সব worker join, লগ flush, তারপর exit. nginx-এ nginx -s quit এই graceful পথ, nginx -s stop দ্রুত পথ।

আরও গভীরে: Linux-এ থ্রেড আর প্রসেস দুটিই kernel-এর কাছে “task” — পার্থক্য শুধু clone()-এর পতাকায় (CLONE_VM, CLONE_FILES দিলে মেমরি আর fd টেবিল শেয়ার হয়, অর্থাৎ থ্রেড)। তাই “fd-র reference count” আসলে দুই স্তরে: fd টেবিলের ভুক্তি একটি “open file” কাঠামোর দিকে নির্দেশ করে, আর fork সেই কাঠামোর গণনা বাড়ায়; থ্রেডেরা একটিই টেবিল শেয়ার করে বলে সেখানে গণনা বাড়েই না। একটি বহু-থ্রেডের প্রসেসে fork() করলে child-এ শুধু যে থ্রেড fork ডেকেছিল সেটিই থাকে — অন্য থ্রেডের ধরে রাখা mutex child-এ চিরকাল আটকে থাকতে পারে, তাই এমন child-এ সাধারণত সঙ্গে সঙ্গে exec() করা হয় (OS কোর্সের থ্রেড পর্বের অংশ ৭)।

পরীক্ষা ও ইন্টারভিউতে এই বিষয়ে প্রায়ই যা জিজ্ঞেস করা হয়:

  • Fork সার্ভারে parent connected fd আর child listening fd কেন বন্ধ করে? — Socket-এর reference count শূন্য হলেই তা বন্ধ হয় আর FIN যায়; parent না করলে FIN যায় না আর fd ফুরায়, child না করলে অপ্রয়োজনে listener ধরে রাখে।
  • Zombie কী, কীভাবে ঠেকাবেন? — শেষ হওয়া child, যার exit status parent এখনো পড়েনি; SIGCHLD handler-এ waitpid(-1, NULL, WNOHANG) লুপ (signal জমা হয় না বলে লুপ)।
  • pthread_create-এ &connfd পাঠালে কী ভুল? — থ্রেড পড়ার আগেই main পরের accept-এ মান বদলে দিতে পারে; প্রতি সংযোগে malloc করা কপি পাঠান, থ্রেড free করবে।
  • Iterative সার্ভারে N জন একসাথে, প্রতিটিতে S সময় — গড় অপেক্ষা? — (N + ১)S/২; শেষজনের NS.
  • Thread pool-এর queue ভরা হলে কী করবেন? — Producer-কে থামানো (backpressure) বা দ্রুত প্রত্যাখ্যান (503); সীমাহীন queue কখনো নয়।

১০ · AI-এর যুগে concurrent সার্ভার

“C-তে একটি multi-threaded TCP সার্ভার লিখে দাও” — AI কয়েক সেকেন্ডে কোড দেয়, আর সেটি প্রথম পরীক্ষায় ঠিকঠাক চলে। কিন্তু এই বিষয়ের বাগ দেখা দেয় ভিড়ের দিনে। AI-এর সার্ভার-কোডে বারবার দেখা যায় এমন ভুল:

AI-এর ভুলকেন বিপজ্জনককীভাবে ধরবেন
pthread_create(…, &connfd)ভিড়ে থ্রেড ভুল fd পায় (অংশ ৪)pthread_create-এর শেষ argument কোথায় থাকে খুঁজুন; ThreadSanitizer (-fsanitize=thread) চালান
Fork সার্ভারে parent close(connfd) করে না, বা SIGCHLD নেইfd leak, FIN যায় না, zombie জমেলোড দিয়ে ls /proc/PID/fd | wc -l আর ps -o stat-এ Z খুঁজুন
SIGCHLD handler-এ একবার wait(), বা ভেতরে printfsignal জমা হয় না — zombie থাকে; printf async-signal-safe নয়লুপ আর WNOHANG আছে কি না দেখুন
Thread-ভিত্তিক সার্ভারে fork-এর অভ্যাসে main-এ close(connfd)থ্রেডের হাত থেকে socket কেড়ে নেওয়ামনে রাখুন: থ্রেডরা একটিই fd টেবিল শেয়ার করে
সীমাহীন queue বা “যত সংযোগ, তত থ্রেড”চাপে মেমরি শেষ, latency অসীমqueue-র ধারণক্ষমতা আর ভরা হলে কী হয়, তা কোডে খুঁজুন
কোনো timeout নেইধীর client (Slowloris ধারণা) worker আটকে রাখেSO_RCVTIMEO বা poll-এর timeout আর মোট deadline খুঁজুন
if দিয়ে pthread_cond_waitspurious wakeup-এ খালি queue থেকে নেওয়াসবসময় while
“Thread pool-এ ১,০০০ থ্রেড দিন” বা “ulimit -n unlimited”আকার মাপা নয়, অনুমান; কিছু সিস্টেমে unlimited গ্রহণযোগ্যই নয়Little-এর সূত্র আর core × (১ + W/C) দিয়ে নিজে হিসাব, তারপর লোড টেস্ট

AI-কে সার্ভারের কোড লিখতে দিলে নকশার শর্তগুলো আগেই বলে দিন, আর যাচাইয়ের দায় নিজের হাতে রাখুন:

C-তে Linux-এর জন্য একটি TCP echo সার্ভার লিখে দাও, thread pool দিয়ে। শর্ত: ১. নির্দিষ্ট সংখ্যক worker (কমান্ড লাইন থেকে), আর একটি bounded queue — ধারণক্ষমতা কত আর ভরা হলে কী হবে (অপেক্ষা না প্রত্যাখ্যান) স্পষ্ট লেখো। ২. একটি mutex আর দুটি condition variable; প্রতিটি cond_wait while লুপে। ৩. connfd কখনো &connfd হিসেবে পাঠাবে না। ৪. প্রতিটি client-এ read/write timeout, আর header-এর জন্য মোট সময়সীমা। ৫. SIGTERM পেলে graceful shutdown: নতুন সংযোগ বন্ধ, চলমান কাজ শেষ, সব worker join; signal handler-এ শুধু একটি পতাকা। ৬. প্রতিটি system call-এর ফেরত মান যাচাই, EINTR সামলানো। ৭. কোডের পরে বলো: কোন ভাগের variable কোন lock রক্ষা করে, আর আমি কীভাবে ThreadSanitizer আর একটি loopback লোড টেস্ট দিয়ে যাচাই করব। নিশ্চিত না হলে কোনো default মান (stack size, backlog, timeout) বানিয়ে বলবে না — বলবে "নিজের সিস্টেমে মেপে দেখো"।

AI-এর সার্ভার-কোড মানার আগে — চেকলিস্ট

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।

প্র ০১প্রথম দিকের ওয়েব সার্ভার প্রতি request-এ fork করত, তারপর pre-fork, তারপর থ্রেড, আর শেষে event-driven. প্রতিটি ধাপে ঠিক কোন সমস্যা চাপ দিয়েছিল?

নব্বইয়ের দশকের শুরুতে ওয়েব ছিল ছোট: একটি পাতা মানে একটি ছোট ফাইল, একটি TCP সংযোগ, কয়েক মিলিসেকেন্ডের কাজ। NCSA httpd-এর মতো প্রথম দিকের সার্ভার প্রতিটি request-এ fork করত বলে সাধারণত বর্ণনা করা হয় — সরল, আর একটির ভুল অন্যটিকে ছোঁয় না। ব্যবহার বাড়তেই সমস্যা: প্রতি request-এ প্রসেস তৈরি আর ধ্বংসের খরচ তখনকার হার্ডওয়্যারে মোট কাজের বড় অংশ হয়ে দাঁড়াল।

প্রথম সমাধান খরচটা আগেভাগে মিটিয়ে রাখা — pre-fork. Apache ১.x-এ শুরুতেই কয়েকটি child থাকে, প্রত্যেকে নিজে accept() করে অনেক request সামলায়। Fork-এর খরচ গেল, বিচ্ছিন্নতা থাকল। কিন্তু HTTP/1.1-এর persistent connection (পর্ব ৩৬) আসার পরে client সংযোগ খোলা রাখেন, আর একটি পুরো প্রসেস অলস বসে থাকে — শত শত প্রসেস মানে প্রচুর মেমরি।

দ্বিতীয় ধাপ থ্রেড — Apache ২.০-র (২০০২ সালের দিকে) worker MPM-এর মতো নকশা কয়েকটি প্রসেসের ভেতরে অনেক থ্রেড চালায়। মেমরি কমল, কিন্তু প্রতিটি সংযোগে একটি থ্রেড — অলস keep-alive সংযোগ তখনও একটি থ্রেড আটকে রাখে।

তৃতীয় চাপ ছিল সংখ্যার। ১৯৯৯ সালের দিকে Dan Kegel “C10K problem” নামে একটি লেখায় প্রশ্ন তোলেন: একটি সার্ভার একসাথে দশ হাজার সংযোগ কীভাবে সামলাবে? থ্রেড-প্রতি-সংযোগে এর উত্তর ছিল না। উত্তর এল event-driven নকশায় — এক থ্রেডে অনেক সংযোগ, kernel-কে জিজ্ঞেস করে কোনটি প্রস্তুত (পর্ব ৪৪-এ বিস্তারিত)। ২০০৪ সালে প্রকাশিত nginx এই পথেই তৈরি, আর Apache-ও event MPM যোগ করে।

প্রতিটি ধাপে আগের সমাধান ভুল ছিল না — শুধু আগের সীমা আর বাধা ছিল না। Fork-এর বিচ্ছিন্নতা আজও PostgreSQL আর sshd-এ বেঁচে আছে, আর Go-র goroutine আবার “প্রতি সংযোগে একটি” সরল কোডের আরাম ফিরিয়ে এনেছে, শুধু ভেতরে event-driven দক্ষতা দিয়ে।

মূল উপলব্ধি: concurrency মডেলের বিবর্তন চালিয়েছে তিনটি খরচ — প্রতি সংযোগে তৈরির খরচ, অলস সংযোগের মেমরি আর একসাথে সংযোগের সংখ্যা। কোন মডেল “সেরা”, তা নির্ভর করে আপনার কাজের ধরনের ওপর কোন খরচ সবচেয়ে বেশি পড়ছে তার ওপর।

প্র ০২inetd প্রতিটি সংযোগে fork করে socket-টি প্রোগ্রামের stdin আর stdout বানিয়ে দিত। এই নকশার সৌন্দর্য কী, আর আজকের systemd socket activation কী নতুন যোগ করেছে?

আশির দশকের BSD Unix-এ প্রতিটি নেটওয়ার্ক সেবার জন্য একটি করে daemon সারাক্ষণ চালু রাখা ছিল অপচয় — মেমরি ছিল দামি, আর finger, telnet-এর মতো সেবা দিনে কয়েকবার মাত্র লাগত। inetd সমাধান দিল: একটিমাত্র প্রসেস সব কনফিগার করা port-এ শুনবে, আর সংযোগ এলে তবেই fork করে সংশ্লিষ্ট প্রোগ্রাম চালাবে।

সবচেয়ে সুন্দর অংশ fd-র কৌশল। fork-এর পরে child connected socket-টি dup2() দিয়ে fd 0, 1 আর 2-তে বসাত, তারপর exec() দিয়ে আসল প্রোগ্রাম চালাত। প্রোগ্রামটি ভাবত সে সাধারণ টার্মিনাল প্রোগ্রাম — stdin থেকে পড়ে, stdout-এ লেখে — অথচ কথা বলছে নেটওয়ার্কের ওপারের একজনের সাথে। এই পর্বের ভাষায়: inetd হলো parent, যে fork করে নিজের কপি বন্ধ করে; child listening socket বন্ধ করে connected socket-কে stdio বানায়। “সবকিছুই ফাইল” দর্শনের প্রায় নিখুঁত প্রদর্শনী।

দুর্বলতাও এখানেই: প্রতি সংযোগে fork আর exec ব্যয়বহুল, তাই ব্যস্ত সেবায় inetd মানানসই নয়। তবে প্রতি সংযোগে আলাদা প্রসেস থাকায় একটি সেবার ভুল অন্যটিকে ছুঁত না — fork মডেলের সেই বিচ্ছিন্নতা। আর তার “wait” মোডে (প্রধানত UDP সেবার জন্য) সে listening socket-টিই প্রোগ্রামকে দিয়ে দিত।

systemd-এর socket activation এই wait মোডের আধুনিক রূপ: systemd listening socket খুলে রাখে, প্রথম সংযোগ এলে সেবাটি চালিয়ে socket-টি হস্তান্তর করে, তারপর সেবা নিজেই অনেক সংযোগ সামলায়। বাড়তি সুবিধা: সেবা নতুন করে চালু হওয়ার মাঝেও socket খোলা থাকে, তাই নতুন সংযোগ accept queue-তে অপেক্ষা করে, প্রত্যাখ্যাত হয় না; আর বুট দ্রুত হয়। চাইলে inetd-ধাঁচে প্রতি সংযোগে নতুন instance-ও চালানো যায় (Accept=yes).

মূল উপলব্ধি: listening socket আর connected socket আলাদা জিনিস, আর fd হাতবদল করা যায় — fork দিয়ে, exec পেরিয়ে, এমনকি প্রসেস থেকে প্রসেসে। এই দুই তথ্য বুঝলে inetd, systemd আর pre-fork সার্ভার একই নকশার ভিন্ন রূপ বলে চেনা যায়।

প্র ০৩PostgreSQL আজও প্রতি সংযোগে একটি প্রসেস চালায়, আর MySQL প্রতি সংযোগে একটি থ্রেড। দুটি নকশার পক্ষে-বিপক্ষে কী, আর কেন দুটোর সামনেই connection pooler বসানো হয়?

PostgreSQL-এর postmaster প্রসেস port 5432-এ শোনে, আর প্রতিটি নতুন client সংযোগের জন্য fork করে একটি “backend” প্রসেস তৈরি করে, যা শুধু সেই client-এর query চালায় — এই পর্বের অংশ ২-এর হুবহু ছাঁচ। সুবিধা প্রধানত বিচ্ছিন্নতা: একটি backend-এ গুরুতর ভুল হলে (যেমন একটি extension-এর bug) ক্ষতি অনেকটাই সীমিত থাকে, আর postmaster বাকিদের পুনরুদ্ধার করতে পারে। একটির ভেতরের data race অন্যটিকে ছোঁয় না; ভাগের ডেটা (buffer cache) থাকে স্পষ্টভাবে ঘোষিত shared memory-তে।

দাম প্রতি সংযোগের খরচ: fork-এর সময়, প্রতিটির নিজের কাজের মেমরি, scheduler-এর চাপ। কয়েকশ সংযোগে ভালো চলে, কিন্তু হাজার হাজার — বিশেষ করে অলস — সংযোগে ভারী হয়ে যায়; তাই PostgreSQL-এর সর্বোচ্চ সংযোগের সংখ্যা সাধারণত কয়েকশর ঘরে রাখা হয়।

MySQL ডিফল্টে প্রতি সংযোগে একটি থ্রেড চালায়। থ্রেড সস্তা, ভাগের cache একই মেমরিতে। দাম উল্টো দিকে: একটি থ্রেডের মারাত্মক ভুল পুরো সার্ভার নামিয়ে দিতে পারে, আর সব ভাগের কাঠামো সাবধানে lock করতে হয়। খুব বেশি সংযোগে থ্রেডের সংখ্যাও সমস্যা — এজন্য MariaDB বা Percona Server-এর মতো বিতরণে thread pool বিকল্প আছে, অংশ ৬-এর নকশা।

দুটির মিল: সংযোগ = সার্ভারের ভেতরে একটি ভারী সম্পদ, অথচ ওয়েবে শত শত অ্যাপ-প্রসেস প্রত্যেকে কয়েকটি করে সংযোগ চায়। সমাধান সামনে একটি connection pooler (PostgreSQL-এর জন্য PgBouncer প্রচলিত) বা অ্যাপের ভেতরে connection pool — হাজার client-এর অনুরোধ কয়েক ডজন আসল সংযোগে ভাগ করা। এটাও producer-consumer.

মূল উপলব্ধি: প্রসেস বনাম থ্রেড হলো বিচ্ছিন্নতা বনাম সস্তা ভাগাভাগির বিনিময়। আর যে মডেলই নিন, ভারী সম্পদের সামনে একটি সীমিত pool বসানো প্রায় সর্বজনীন সমাধান।

প্র ০৪Thundering herd কেন এক সময় বড় সমস্যা ছিল, kernel আর সার্ভার কীভাবে তার মোকাবিলা করেছে, আর SO_REUSEPORT নতুন কোন বিনিময় এনেছে?

Pre-fork সার্ভারে ধরুন ১৫০টি child একই listening socket-এ accept()-এ ঘুমিয়ে আছে, আর একটি নতুন সংযোগ এল। Kernel-এর সরলতম আচরণ: অপেক্ষার তালিকার সবাইকে জাগানো। একজন সংযোগ পায়, বাকি ১৪৯ জন জেগে, CPU পেয়ে, কিছু না পেয়ে আবার ঘুমায়। সেকেন্ডে হাজার সংযোগে এই অপচয় CPU-র বড় অংশ খায় আর cache নষ্ট করে।

প্রথম মোকাবিলা সার্ভারের দিক থেকে: Apache-এর মতো সার্ভার accept()-এর আগে একটি lock (“accept mutex”) নিত, যাতে একসময়ে একজনই socket-এ অপেক্ষা করে — কাজ চলত, কিন্তু lock-ও একটি বাড়তি বাধা। পরে kernel উন্নত হলো — Linux বহু বছর ধরে blocking accept()-এ সাধারণত একজনকেই জাগায়। কিন্তু প্রতিটি worker নিজের epoll দিয়ে listening socket পাহারা দিলে সমস্যা ফিরে আসে; Linux পরে EPOLLEXCLUSIVE যোগ করে (৪.৫ সংস্করণের দিকে)।

SO_REUSEPORT (Linux ৩.৯, ২০১৩ সালের দিকে) সমস্যাটিকে অন্যভাবে দেখে: ভাগের socket-ই যদি না থাকে? প্রতিটি worker নিজের listening socket খোলে একই port-এ, আর kernel প্রতিটি নতুন সংযোগ 4-tuple-এর hash অনুযায়ী একটিতে পাঠায় — ভাগের অপেক্ষার তালিকা নেই, তাই herd নেই।

নতুন বিনিময় ভারসাম্যে। ভাগের queue-তে যে worker আগে মুক্ত, সে-ই পরের সংযোগ নেয় — স্বাভাবিক load balancing. Hash-ভিত্তিক ভাগে একটি worker ভারী request-এ আটকে থাকলেও তার queue-তে নতুন সংযোগ আসতেই থাকে, অন্য worker অলস থাকলেও — কিছু সংযোগের latency বাড়ে। Worker-এর সংখ্যা বদলালে ভাগও বদলায়, আর বন্ধ হওয়া socket-এর queue-র সংযোগ হারানোর ঝুঁকি থাকে; এজন্য পরে eBPF দিয়ে নিজস্ব ভাগের নিয়ম বসানোর পথও যোগ হয়েছে।

মূল উপলব্ধি: একটি ভাগের সারি স্বাভাবিকভাবে ভার ভাগ করে কিন্তু প্রতিযোগিতা আনে; আলাদা সারি প্রতিযোগিতা দূর করে কিন্তু ভারসাম্য নষ্ট করতে পারে। Concurrency-র প্রায় প্রতিটি নকশায় এই দ্বন্দ্ব ফিরে ফিরে আসে।

প্র ০৫Go-র goroutine আর Java-র virtual thread-কে বলা হয় “thread-per-connection-এর প্রত্যাবর্তন”। এর মানে কী, আর এতে কোন সমস্যা চলে যায়, কোনটা থেকে যায়?

এই পর্বের মডেলগুলোর মধ্যে একটি দ্বন্দ্ব আছে। Thread-per-connection-এর কোড সবচেয়ে পড়ার মতো: read, প্রক্রিয়া, write — সরলরেখায়। কিন্তু OS থ্রেড ভারী, লাখ সংযোগ চলে না। Event-driven (পর্ব ৪৪) লাখ সংযোগ চালায়, কিন্তু কোড ভেঙে যায় callback বা state machine-এ।

Go-র নকশা দুটোকে মেলায়। প্রোগ্রামার প্রতিটি সংযোগে একটি goroutine চালান আর blocking কোড লেখেন; কিন্তু runtime এগুলোকে অল্প কয়েকটি OS থ্রেডে চালায়, প্রতিটির stack শুরু হয় কয়েক KB দিয়ে। কোনো goroutine socket-এ ডেটা না পেলে runtime তাকে সরিয়ে রাখে, socket-টি ভেতরের netpoller-এ (Linux-এ epoll) নথিভুক্ত করে, আর সেই OS থ্রেডে অন্য goroutine চালায় — ভেতরে event loop, বাইরে সরল কোড। Java ২১-এ (২০২৩) স্থায়ী হওয়া virtual thread একই ধারণা JVM-এ এনেছে।

যা চলে যায়: প্রতি সংযোগের বড় মেমরি, থ্রেড তৈরির খরচ, অগণিত OS থ্রেডের context switch. লাখ সংযোগের সার্ভার এখন সরল, ওপর থেকে নিচে পড়ার মতো কোডে লেখা যায়।

যা থেকে যায়, তা এই পর্বের মূল শিক্ষা। এক, data race — goroutine-রাও মেমরি শেয়ার করে, তাই mutex বা atomic লাগে (Go-তে race detector আছে ঠিক এই কারণেই)। দুই, সীমা — প্রতিটি goroutine যদি একটি ডেটাবেস সংযোগ চায়, ডেটাবেস ভেঙে পড়বে; তাই semaphore বা pool দিয়ে নিচের সম্পদে ভিড় সীমিত রাখতে হয়। তিন, timeout — ধীর client goroutine আটকে রাখে, আর লাখ লাখ আটকে থাকা goroutine-এর মেমরি জমে। চার, graceful shutdown আর backpressure — নকশার প্রশ্ন, runtime নিজে থেকে করে না।

মূল উপলব্ধি: হালকা থ্রেড প্রতি সংযোগের খরচ কমায়, কিন্তু concurrency-র শৃঙ্খলা — race এড়ানো, সীমা, timeout, পরিচ্ছন্ন বন্ধ — একই থাকে। তাই fork আর pthread দিয়ে শেখা নিয়মগুলো যেকোনো আধুনিক ভাষায় কাজে লাগে।

অনুশীলন

খাতা-কলম নিন — সময়রেখা আঁকুন, fd আর queue ধরে ধরে হিসাব করুন।

  1. হাতে trace (সময়রেখা): ৬ জন client একই মুহূর্তে আসেন, প্রতিটি request-এ সার্ভারের ৩০০ ms লাগে। (ক) Iterative সার্ভারে (accept queue যথেষ্ট বড়) প্রত্যেকে কখন উত্তর পান, আর গড় কত? (খ) ২টি worker আর ধারণক্ষমতা ২-এর queue-র একটি thread pool, যা queue ভরা থাকলে সঙ্গে সঙ্গে 503 দিয়ে প্রত্যাখ্যান করে — কতজন সেবা পান, কখন, আর তাঁদের গড় অপেক্ষা কত?

    (ক) ৩০০, ৬০০, ৯০০, ১,২০০, ১,৫০০ আর ১,৮০০ ms. গড় = (৬ + ১) × ৩০০ / ২ = ১,০৫০ ms; যাচাই: (৩০০ + ৬০০ + … + ১,৮০০) / ৬ = ৬,৩০০ / ৬ = ১,০৫০.

    (খ) c1 আর c2 সঙ্গে সঙ্গে দুই worker-এ যান → ৩০০ ms-এ শেষ। c3 আর c4 queue-তে (এখন queue পূর্ণ) → প্রথম দফা শেষে worker-রা তাঁদের নেন → ৬০০ ms-এ শেষ। c5 আর c6 আসার মুহূর্তে কোনো worker বা queue-র জায়গা খালি নেই → সঙ্গে সঙ্গে 503. সেবা পান ৪ জন, গড় (৩০০ + ৩০০ + ৬০০ + ৬০০) / ৪ = ৪৫০ ms; ২ জন সঙ্গে সঙ্গে জেনে যান “ব্যস্ত”. Iterative-এর শেষ দুজন (১,৫০০ আর ১,৮০০ ms) হয়তো timeout পেরিয়ে যেতেন — দীর্ঘ অপেক্ষার পরে ব্যর্থতা, দ্রুত প্রত্যাখ্যানের চেয়েও খারাপ।

  2. আউটপুট অনুমান করুন: অংশ ২-এর iterative বনাম fork প্রোগ্রামে দুটি লাইন মুছে ফেলা হলো — sigaction(…)-এর লাইন (তাই কোনো SIGCHLD handler নেই) আর while (forking && reaped < 4) usleep(1000);. Fork সার্ভারের দুটি লাইন এখন কী ছাপবে? সময় কি বদলাবে?
      server: reaped=0, zombies left: YES
    fork-per-conn: 4 clients x 200 ms -> all done in 0.2 s

    Handler না থাকায় কেউ child তুলে নেয় না, তাই reaped 0-ই থাকে। সার্ভার চতুর্থ accept()-এর পরে সঙ্গে সঙ্গে waitpid(-1, NULL, WNOHANG) ডাকে। তখন চারটি child হয় এখনো ২০০ ms ঘুমাচ্ছে, নয়তো শেষ হয়ে zombie — দুই ক্ষেত্রেই child আছে, তাই waitpid −১ নয়, ০ (বা একটি PID) ফেরত দেয়, ECHILD নয়, আর ছাপা হয় YES. সময় বদলায় না (≈ ০.২ s), কারণ সেবা দেওয়া handler child-রা আগের মতোই সমান্তরালে চলে — zombie সঙ্গে সঙ্গে ক্ষতি করে না, ধীরে ধীরে প্রসেস-টেবিল ভরিয়ে ক্ষতি করে। (এই ছোট প্রোগ্রামে সার্ভার প্রসেস নিজে বের হলে তার zombie child-দের init বা subreaper তুলে নেয়; দীর্ঘদিন চলা সার্ভারে তা ঘটে না।) বাস্তবে এমন সার্ভারে ps -o pid,stat,cmd চালালে অনেকগুলো Z বা <defunct> দেখা যেত।

  3. কোড লিখুন: নিচের thread-per-connection লুপে তিনটি সমস্যা আছে — খুঁজে বের করুন আর ঠিক করা সংস্করণ লিখুন: for (;;) { int connfd = accept(ls, NULL, NULL); pthread_t t; pthread_create(&t, NULL, serve, &connfd); close(connfd); } — যেখানে serve() fd পড়ে client-এর সাথে কথা বলে আর শেষে বন্ধ করে। প্রতিটি client-এর জন্য ১৫ সেকেন্ডের read timeout-ও যোগ করুন।

    সমস্যা: (১) &connfd — লুপের ভেতরের variable-এর ঠিকানা, যা পরের ঘুরতিতে বদলায় বা আয়ু শেষ হয় (race বা undefined behavior). (২) main-এ close(connfd) — থ্রেডরা একই fd টেবিল শেয়ার করে, তাই এটি থ্রেডের socket কেড়ে নেয় (fork-এর অভ্যাস থ্রেডে ভুল)। (৩) থ্রেড join বা detach কোনোটাই করা হয়নি — প্রতিটি শেষ হওয়া থ্রেডের সম্পদ জমে থাকে। সাথে ফেরত মান যাচাই নেই।

    static void *serve_thread(void *arg) {
        int c = *(int *)arg; free(arg);
        struct timeval tv = { .tv_sec = 15 };
        setsockopt(c, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv);   /* slow client: read() gives up */
        serve(c);                                                /* serve() closes c at the end */
        return NULL;
    }
    for (;;) {
        int c = accept(ls, NULL, NULL);
        if (c < 0) continue;                                     /* EINTR, ECONNABORTED, EMFILE ... */
        int *p = malloc(sizeof *p);
        if (!p) { close(c); continue; }
        *p = c;
        pthread_t t;
        if (pthread_create(&t, NULL, serve_thread, p) != 0) { close(c); free(p); continue; }
        pthread_detach(t);                                       /* no join: free resources at exit */
    }

    এখানে main থ্রেড নিজেই pthread_detach(t) করছে — থ্রেডের ভেতরে pthread_detach(pthread_self())-ও সমান কাজ করে। সত্যিকারের সার্ভারে এর পরের ধাপ হতো অংশ ৬-এর pool, যাতে থ্রেডের সংখ্যারও সীমা থাকে।

  4. হিসাব (pool-এর নকশা): একটি ৪-core সার্ভারে প্রতিটি request-এ গড়ে ২০ ms CPU আর ১৮০ ms ডেটাবেসের অপেক্ষা। (ক) হাতের নিয়মে worker কতটি? (খ) CPU-র দিক থেকে সর্বোচ্চ throughput কত? (গ) সেই throughput-এ Little-এর সূত্রে একসাথে কতগুলো request সেবায় থাকে — (ক)-এর সাথে মেলে? (ঘ) কেউ যেন queue-তে ১ সেকেন্ডের বেশি না বসেন — queue-র ধারণক্ষমতা কত রাখবেন?

    (ক) worker ≈ core × (১ + অপেক্ষা / CPU) = ৪ × (১ + ১৮০/২০) = ৪ × ১০ = ৪০.

    (খ) প্রতিটি request ২০ ms CPU নেয়, তাই একটি core সেকেন্ডে ১,০০০ / ২০ = ৫০টি; চার core-এ সেকেন্ডে ২০০টি request. Worker যতই বাড়ান, এর বেশি হবে না।

    (গ) L = λ × W = ২০০ × (০.০২ + ০.১৮) = ২০০ × ০.২ = ৪০ — হাতের নিয়মের সাথে হুবহু মিলল, কারণ দুটি একই যুক্তি: CPU পুরো ব্যস্ত রাখতে যতগুলো request একসাথে চলা দরকার। কম worker হলে CPU অলস থাকবে, বেশি হলে queue শুধু থ্রেডের ভেতরে সরে যাবে।

    (ঘ) Queue খালি হয় সেকেন্ডে প্রায় ২০০টি হারে, তাই ধারণক্ষমতা ≈ ২০০; এর বেশি অনুরোধ দ্রুত প্রত্যাখ্যান (503) করাই ভালো। সব সংখ্যাই গড় — লোড টেস্ট দিয়ে যাচাই করে কিছুটা ছাড় রাখবেন।

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমএকটি fork-per-connection TCP সার্ভারে একটি client-এর সংযোগের জীবন — ঘটনাগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. Parent socket(), bind() আর listen() করে listening socket খোলে
  2. accept() একটি নতুন connected socket ফেরত দেয়
  3. fork() — child-এর কাছে দুটি fd-রই কপি আসে
  4. Child listening fd বন্ধ করে, parent connected fd বন্ধ করে আবার accept()-এ ফেরে
  5. Child client-কে সেবা দিয়ে connected fd বন্ধ করে (গণনা শূন্য, FIN যায়) আর exit করে
  6. Parent SIGCHLD পেয়ে waitpid(WNOHANG) লুপে zombie তুলে নেয়

একটি fork-per-connection TCP সার্ভারে parent প্রতিটি fork-এর পরে connected socket-এর fd বন্ধ করতে ভুলে যায়; child কাজ শেষে নিজের কপি বন্ধ করে exit করে। সময়ের সাথে কী ঘটবে?

  • কিছুই না — child বন্ধ করলেই socket বন্ধ হয়ে যায়
  • Listening socket বন্ধ হয়ে সার্ভার নতুন সংযোগ নিতে পারবে না সঙ্গে সঙ্গে
  • FIN যায় না আর parent-এর fd জমতে থাকে; একসময় accept() EMFILE দিয়ে ব্যর্থ হয়
  • প্রতিটি child zombie হয়ে থাকে

একটি fork সার্ভারের SIGCHLD handler-এ waitpid(-1, NULL, WNOHANG) একটি while লুপে ডাকা হয়, একবার নয়। লুপ কেন দরকার?

  • যাতে handler সব child শেষ না হওয়া পর্যন্ত অপেক্ষা করে
  • কয়েকটি child একসাথে শেষ হলেও signal একবারই আসতে পারে, তাই সবাইকে একসাথে তুলে নিতে হয়
  • waitpid প্রায়ই ব্যর্থ হয়, তাই কয়েকবার চেষ্টা করতে হয়
  • প্রতিটি child-এর listening socket বন্ধ করার জন্য

একটি thread-per-connection সার্ভার লুপে লেখে: connfd = accept(ls, NULL, NULL); pthread_create(&t, NULL, serve, &connfd); — আর serve() শুরুতেই *(int *)arg পড়ে। ভারী লোডে কী সমস্যা হতে পারে?

  • কোনো সমস্যা নেই, কারণ pthread_create নতুন থ্রেড চালু না হওয়া পর্যন্ত ফেরে না
  • থ্রেড fd ব্যবহার করতে পারে না, কারণ প্রতিটি থ্রেডের আলাদা fd টেবিল
  • Listening socket নষ্ট হয়ে যায়
  • থ্রেড পড়ার আগেই main connfd বদলে দিতে পারে — দুই থ্রেড একই fd পায়, অন্য একজন client উপেক্ষিত হন

একটি thread pool-এ ৮টি worker আর ধারণক্ষমতা ১০০-র একটি queue. হঠাৎ ৫,০০০ সংযোগ এল। নিচের কোন নীতিটি ভালো নকশা নয়?

  • Queue-র সীমা তুলে দেওয়া, যাতে কোনো সংযোগই ফেরত না যায়
  • Queue ভরা থাকলে accept loop অপেক্ষা করা (backpressure)
  • Queue ভরা থাকলে সঙ্গে সঙ্গে একটি 503 Service Unavailable দিয়ে সংযোগ বন্ধ করা
  • মেপে দেখে worker বাড়ানো, আর সামনে load balancer দিয়ে অন্য সার্ভারে ভাগ করা

একটি ৮-core সার্ভারের কাজ পুরোপুরি CPU-bound (ছবি compression, কোনো I/O অপেক্ষা নেই)। হাতের নিয়মে worker-এর সংখ্যা মোটামুটি কত রাখা উচিত?

  • ১
  • ৮০০
  • প্রায় ৮ (core-এর সংখ্যা, কখনো এক বেশি)
  • যতগুলো সংযোগ, ততগুলো

একটি thread pool-ভিত্তিক ওয়েব সার্ভারে ক্লায়েন্টরা অনেক সংযোগ খুলে প্রতিটিতে প্রতি কয়েক সেকেন্ডে header-এর কয়েকটি বাইট পাঠাচ্ছে, কখনো শেষ করছে না (Slowloris ধারণা)। প্রতি read()-এ ১০ সেকেন্ডের idle timeout আছে, তবু সব worker আটকে যাচ্ছে। সবচেয়ে সরাসরি প্রতিরক্ষা কোনটি?

  • Worker-এর সংখ্যা দ্বিগুণ করা
  • পুরো header-এর জন্য মোট সময়সীমা আর প্রতি IP-তে সংযোগের সীমা বসানো, সামনে event-driven proxy রাখা
  • Idle timeout ১০ থেকে বাড়িয়ে ৬০ সেকেন্ড করা
  • SO_REUSEPORT চালু করা

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. Iterative = এক সময়ে একজন: N জন একসাথে, প্রতিটিতে S হলে শেষজন NS, গড় (N + ১)S/২; একটি ধীর client সবাইকে আটকায়। সার্ভারের কাজ অপেক্ষা-প্রধান, তাই concurrency দরকার।
  2. Fork প্রতি সংযোগে: child listening fd, parent connected fd বন্ধ করে — reference count শূন্য হলে তবেই FIN. SIGCHLD handler-এ waitpid(-1, …, WNOHANG) লুপ (signal জমা হয় না), SA_RESTART. COW fork সস্তা করে, বিচ্ছিন্নতা সবচেয়ে ভালো।
  3. Thread প্রতি সংযোগে: একই মেমরি আর fd টেবিল — main fd বন্ধ করে না; &connfd নয়, malloc করা কপি; detach; ভাগের counter-এ mutex বা atomic. প্রতি থ্রেডে stack (Linux-এ প্রায়ই ৮ MiB virtual) আর থ্রেডের সীমা।
  4. Thread pool = producer-consumer: accept loop রাখে, worker নেয়; একটি mutex + not_empty আর not_full, অপেক্ষা while-এ। Queue সীমিত: ভরা হলে backpressure বা 503. আকার: CPU-bound ≈ core, I/O-bound ≈ core × (১ + W/C), Little: L = λW.
  5. Pre-fork, thundering herd, SO_REUSEPORT; প্রতি client-এ timeout আর মোট deadline (Slowloris), আর graceful shutdown: SIGTERM → accept বন্ধ → কাজ শেষ → join. অগণিত সংযোগে event-driven (পর্ব ৪৪)।

পরের পর্বে: এক থ্রেডে হাজার হাজার সংযোগ — select, poll আর epoll. Non-blocking socket, “কোন fd প্রস্তুত” জিজ্ঞেস করা, level আর edge trigger, আর কেন nginx আর Node.js এই পথে চলে — C10K সমস্যার পুরো গল্প।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান