Foundationপ্রথম নীতি থেকে
LEVEL 7লেসন ১৪/২২অ্যাডভান্সড১ ঘণ্টা

Sockets API ও kernel stack — syscall থেকে wire

Sockets API and the Kernel Stack

socket আসলে একটা file descriptor যার পেছনে kernel-এর protocol state machine; ছয়টা syscall-এর জীবনচক্র TCP state-এর সাথে মিলিয়ে পড়ুন, আর একটা byte কীভাবে write() থেকে NIC-এর wire পর্যন্ত যায় সেই পথ হাঁটুন।

এই লেসন শেষে আপনি পারবেন

  • socket, bind, listen, accept, connect, read/write, close — প্রতিটা call-কে TCP state machine-এর ঠিক কোন transition-এ বসে সেই mapping আঁকতে ও ব্যাখ্যা করতে পারবেন
  • sockaddr_in structure ঠিকভাবে fill করে htons/htonl কেন অপরিহার্য — network byte order বনাম host byte order — endianness-এর যুক্তি দিয়ে বলতে পারবেন
  • Default blocking behavior আর O_NONBLOCK সহ select/poll/epoll-এর overview-level পার্থক্য map করে, কোন অবস্থায় কোন model দরকার তা সিদ্ধান্ত নিতে পারবেন
  • write() call থেকে NIC-এর DMA transfer পর্যন্ত kernel send path-এর প্রতিটা stage — send buffer, segmentation, IP, qdisc, driver ring — ক্রমানুসারে আঁকতে ও ব্যাখ্যা করতে পারবেন
  • SO_REUSEADDR ঠিক কোন conflict উপেক্ষা করে কোনটা নয় তা বলতে পারবেন, আর strace ও ss output পড়ে socket-সংক্রান্ত সমস্যা diagnose করতে পারবেন

আগে যা বোঝা থাকা দরকার

আগে এটা বুঝি

গত দুই লেসনে আমরা wire-এর ওপাশে যাত্রা দেখেছি — UDP-র চিঠি ছুড়ে দেওয়া, TCP-র তিন-ধাপের hello আর state machine। কিন্তু একটা প্রশ্ন ঝুলেই রইল: আপনার প্রোগ্রাম তাহলে ঢুকবে কোথা দিয়ে? আপনার python3 বা browser তো kernel-এর TCP implementation- এর ভেতরে ঢুকতে পারে না — user-space থেকে kernel-এ যাওয়ার একমাত্র রাস্তা system call। আজকের লেসন সেই দরজার নাম: Sockets API — Berkeley-তে 1983-এ জন্ম নেওয়া, আজও প্রায় অবিকল চলা interface।

Unix-এর পুরনো প্রতিশ্রুতি: everything is a file। Disk-এর ফাইল open() করে integer পান, pipe-ও তা-ই, terminal-ও। Socket-ও একই ছাঁচে: socket() ডাকলে পান একটা ছোট integer — বাকি সব state, buffer, timer, retransmission logic kernel নিজের ভেতরে ঢুকিয়ে রাখে। আপনি শুধু সেই integer-টা ধরে পড়েন আর লেখেন।

আজকের যাত্রাপথ:

  • ছয়টা syscall — socket, bind, listen, accept, connect, close — প্রতিটা TCP state machine-এর ঠিক কোন ঘরে বসে
  • sockaddr_in ভরার সময় htons কেন ভুললেই বিপদ — endianness হাতে-কলমে
  • Blocking default বনাম non-blocking + select/poll/epoll — শুধু map, গভীর dive নয়
  • একটা write() চাপার পর byte-টা kernel-এর ভেতর দিয়ে NIC- এর wire পর্যন্ত ঠিক কোন ধাপে কোন ধাপে যায়
  • Address already in use error-এর আসল রহস্য আর SO_REUSEADDR আসলে ঠিক কী ছাড় দেয়

মূল ধারণা

Socket আসলে কী — kernel-এর ভেতরের বস্তু

User-space-এ socket মানে একটা int। Kernel-space-এ সেটা একটা সমৃদ্ধ বস্তু:

User দেখেKernel রাখে
fd = 3protocol control block — TCP state, seq/ack counter, timers
read/write callreceive buffer আর send buffer (কয়েক লক্ষ byte)
accept()-এর ফলSYN queue + accept queue (listening socket-এ)
কিছুই না4-tuple {src IP, src port, dst IP, dst port} দিয়ে demux index

TCP-র state machine (tcp-handshake লেসনে আঁকা) বাস্তবে থাকে এই kernel বস্তুর ভেতরে — আপনার প্রোগ্রামে নয়। API-টা হলো সেই state machine-কে remote-control করার বোতামতলা।

জীবনচক্র — ছয়টা call, state mapping

syscall          TCP state পরিবর্তন                 kernel-এ যা হয
---------------  ---------------------------------  ---------------------------
socket()         (কিছুই না — এখনো নির্ধারিত না)      খালি endpoint তৈরি
bind()           (state নেই, শুধু ঠিকানা)           local IP:port বসানো
listen()         CLOSED → LISTEN                    SYN + accept queue তৈরি
connect()        CLOSED → SYN_SENT → ESTABLISHED    সক্রিয় handshake শুরু
accept()         (connection-এর state একে না)       accept queue থেকে নতুন fd
read()/write()   ESTABLISHED-এ data-plane কাজ        buffer-এ ঢোকানো/তোলা
close()          ESTABLISHED → FIN_WAIT/...         সক্রিয় teardown

লক্ষ্য করুন passive আর active open-এর পার্থক্য: server bind → listen → accept পথে বসে থাকে — connection তার কাছে আসে (passive); client socket → connect পথে ছুটে যায় (active)। accept() নতুন কোনো handshake শুরু করে না — handshake তো kernel already শেষ করে ফেলেছে accept queue-তে তুলে রাখার আগেই। accept() শুধু সেই ready connection-টা তুলে app-কে একটা নতুন fd দেয়।

sockaddr_in আর byte order — হাতে-কলমে endianness

IPv4 socket-এর ঠিকানা structure:

struct sockaddr_in {
    uint8_t         sin_family;   // AF_INET
    in_port_t       sin_port;     // 16-bit — NETWORK byte order!
    struct in_addr  sin_addr;     // 32-bit IPv4 — NETWORK byte order!
    char            sin_zero[8];  // padding — শূন্যে ভরতে হয়
};

দুইটা বিষয় লক্ষ্য করুন। এক, field-গুলো ঠিক এই ক্রমে না থাকলে kernel আলাদা ভাষায় পড়ত — তাই আগে সব memset দিয়ে শূন্য করা নিয়ম। দুই, port আর address সবসময় big-endian-এ (network byte order) থাকতে হয় — ঠিক computer-representation module-এর endianness লেসনে দেখা convention-argument-টাই: wire একটা contract, দুই মেশিন একই রকম পড়বে বলেই সম্ভব।

sa.sin_port = htons(9090);   // host-to-network-short

htons ছাড়লে কী হয়? 9090 = 0x2382। Little-endian x86-এ htons ছাড়া লিখলে byte উল্টে 0x8223 = 33315 পড়ে — bind সফলই হবে, শুধু ভুল port-এ! Client-ও একই ভুল করলে দুজনে মিলে কাজ চালিয়ে ফেলবে, আর আসল client এলেই connection refused। htonl 32-bit address-এর জন্য, ntohs/ntohl উল্টো পথে।

Blocking default আর তার বিকল্প — শুধু নকশা

Default মোডে প্রতিটা call অপেক্ষা করে: connect() handshake শেষ না হওয়া পর্যন্ত, accept() কেউ এলে না হওয়া পর্যন্ত, recv() অন্তত এক byte আসা পর্যন্ত — thread-টা ঘুমিয়ে থাকে, CPU খায় না। এক-connection-per-thread model-এ এটা যথেষ্ট। কিন্তু দশ হাজার connection? দশ হাজার thread সস্তা নয়।

বিকল্পের সিঁড়ি:

  • O_NONBLOCK: fcntl দিয়ে flag বসালে call অপেক্ষা করে না — কাজ সম্ভব না হলে সঙ্গে সঙ্গে -1/EAGAIN ফেরত। আপনি নিজেই ঘুরেফিরে জিজ্ঞেস করবেন (busy polling — খারাপ) অথবা…
  • select()/poll(): একসাথে অনেক fd-র তালিকা দিন, kernel বলে দেয় কোনটায় এখন পড়া/লেখা ready। এক thread-এ হাজারো connection — এটাই IO multiplexing।
  • epoll (Linux): select-এর প্রতি-call O(n) স্ক্যানের বদলে event-ভিত্তিক registration — লক্ষ fd-তেও scale করে; macOS-এ সমতুল্য kqueue, BSD-ও এটাই।

SO_REUSEADDR — আসলে কোন তালা খোলে

Server restart-এর পরেই প্রায়ই দেখা error: bind(): Address already in use। কারণ সাধারণত দুইটা — port-টা এখনো কোনো live socket ধরে আছে, অথবা ওই port-সহ পুরনো connection-গুলো TIME_WAIT-এ বসে আছে (tcp-handshake-এর ২×MSL অপেক্ষা!)। SO_REUSEADDR set করলে bind-এর নিয়ম কিছুটা শিথিল হয়:

অবস্থা                                        REUSEADDR ছাড়া   সহ
--------------------------------------------  --------------  -----
কেউ ঐ port-এ LISTEN করছে                       fail            fail
TIME_WAIT connection-এর local port ঐটাই        fail            OK
বন্ধ হয়ে যাওয়া socket-এর অবশিষ্ট               fail            OK
দুইটা live bind একই port-এ                     fail            fail*

মূল কথা: REUSEADDR মৃত state-এর ছাড়, জীবিত listener- এর নয় — চলমান server-কে port ছিনতাই করা যায় না (-তার exception SO_REUSEPORT, যা live bind-ও allow করে কিন্তু security শর্তসহ — পরে realworld-এ)। তাই production server template-এ ওই এক লাইন setsockopt প্রায় সবসময় থাকে।

ভেতরে কী ঘটছে

Send path — write() থেকে wire পর্যন্ত

আপনি write(cfd, "hi", 2) ডাকলেন। এরপর কী হয়, ধাপে ধাপে:

একটা byte-এর kernel যাত্রা — send path
  1. write() syscalluser buffer থেকে kernel-এ copy — এখানেই syscall boundary পার হয়
  2. TCP: send bufferbyte-stream জমা হয়; retransmission-এর জন্য প্রতিটা segment-এর কপি ACK না আসা পর্যন্ত রাখা হয়
  3. TCP: segmentationMSS (~1448 byte)-এ কাটা, seq number, checksum, দরকার হলে Nagle-delay
  4. IP layerroute lookup, src/dst IP header, TTL কমানো — সাধারণত fragment হয় না (PMTU)
  5. qdisc (queueing discipline)interface-এর অপেক্ষার লাইন — pfifo, fq, tc দিয়ে shaping
  6. driver TX ringpacket-এর ঠিকানা descriptor-এ লিখে doorbell register বাজানো
  7. NIC DMAcard CPU-র সাহায্য ছাড়াই RAM থেকে frame টেনে wire-এ দেয়, তারপর TX interrupt

computer-architecture module-এ DMA-র যে মূলনীতি দেখেছেন — CPU bypass করে device সরাসরি memory পড়া — ঠিক সেটাই এখানে production scale-এ: NIC নিজের ring buffer থেকে descriptor পড়ে, RAM থেকে packet DMA করে আনে, checksum পর্যন্ত hardware-এ হিসাব করে। CPU-র হাত শুধু descriptor লেখা পর্যন্ত।

Receive path — উল্টো দিকটা সংক্ষেপে

NIC frame পৌঁছাতেই DMA-তে RX ring-এ বসিয়ে interrupt দেয়; উচ্চ load-এ Linux NAPI interrupt কমিয়ে polling-এ যায়। তারপর: IP validate → TCP 4-tuple hash দিয়ে ঠিক socket-টা খোঁজা (demux) → payload ওই socket-এর receive buffer-এ → যদি কোনো process recv()-এ ঘুমিয়ে থাকে, সে wake up। Application পড়ার আগেই data kernel-এ অপেক্ষা করে — Recv-Q তারই মাপ।

Syscall-এর হিসাব — এক message-এর anatomy

Echo round-trip-এর syscall বাজেট দুই ভাগে:

দায়িত্বSyscallকতবার
Setup (একবারই)socket, setsockopt, bind, listen, connect, acceptমোট ~৬
প্রতি messagerecv + send এক জোড়াপ্রতি direction-এ ১

Setup-এর খরচ fixed; steady-state-এ প্রতি message মাত্র ২টা syscall — আর প্রতিটা syscall মানে user↔kernel transition, context-switch-এর সম্ভাব্য খরচ। High-throughput server-এর performance যুদ্ধ অনেকটাই এই সংখ্যাটা কমানোর যুদ্ধ (batching, io_uring — পরে)। strace দিয়ে নিজেই গুনবেন — experiment-এ।

উদাহরণ

একটা পূর্ণ সেশন — strace-এর চোখে

Minimal echo server-client সেশনের syscall trace (annotation সহ):

# --- server boot ---
socket(AF_INET, SOCK_STREAM, IPPROTO_TCP)      = 3
setsockopt(3, SOL_SOCKET, SO_REUSEADDR, [1], 4) = 0
bind(3, {sa_family=AF_INET, sin_port=htons(9090),
         sin_addr=inet_addr("0.0.0.0")}, 16)   = 0
listen(3, 16)                                  = 0
accept4(3, NULL, NULL, 0)                      = 4   ← client আসার অপেক্ষায় ব্লক

# --- client connect করল, message পাঠাল ---
recvfrom(4, "bangla\n", 1024, 0, NULL, NULL)   = 7
sendto(4, "bangla\n", 7, 0, NULL, NULL)        = 7
recvfrom(4, "", 1024, 0, NULL, NULL)           = 0   ← 0 মানে EOF: peer close করেছে
close(4)                                       = 0

তিনটা সূক্ষ্মতা খেয়াল করুন:

  • bind-এর argument-এ sin_port=htons(9090) — strace নিজেই host-order-এ দেখিয়ে দেয়, কিন্তু memory-তে থাকে big-endian
  • accept4 return করেছে fd 4 — listening socket ছিল 3; প্রতিটা connection নতুন descriptor
  • recv-এর return 0 মানে error না — graceful EOF, stream- শেষ। এটাই close detection-এর একমাত্র portable উপায়

ss -tanp — state আর buffer এক নজরে

সেশন চলাকালীন:

sudo ss -tanp | grep 9090
State   Recv-Q Send-Q Local Address:Port  Peer Address:Port   Process
LISTEN  0      128    0.0.0.0:9090       0.0.0.0:*           ./echo server
ESTAB   0      0      127.0.0.1:9090     127.0.0.1:53112     ./echo server
ESTAB   0      0      127.0.0.1:53112    127.0.0.1:9090      ./echo client

তিনটা লাইন, তিনটা গল্প: listener (accept queue capacity 128 দেখাচ্ছে), আর একই connection-এর দুই মাথা — দুই process-এর দুই fd। Recv-Q/Send-Q শূন্য মানে কেউ কারো অপেক্ষায় ফেলে রাখছে না; message পাঠানোর মুহূর্তে sender-এর Send-Q উঠে যাবে, peer ACK দিলে নেমে আসবে — ঠিক আগের অংশের buffer-গুলোর live reflection।

ছবিতে

দুই process, এক kernel, এক wire

   process A (client)                  process B (server)
  ┌──────────────────┐               ┌──────────────────────┐
  │  fd 3 ───────────┼────────────┐  │  fd 3 (listener)     │
  │                  │            │  │  fd 4 ───────────────┼──┐
  └──────────────────┘            │  └──────────────────────┘  │
                                  ▼                            ▼
┌──────────────────────────────────────────────────────────────────┐
│ KERNEL                                                           │
│  socket{4-tuple C→S, ESTABLISHED,   socket{4-tuple S-side,       │
│          recv buf, send buf}             ESTABLISHED, bufs}      │
│        │                                     ▲                   │
│        ▼                                     │                   │
│  TCP seg → IP → qdisc → TX ring → NIC ── wire ──► NIC → RX ring  │
└──────────────────────────────────────────────────────────────────┘
Connection-টা বাস্তবে বাস করে কোথায় — দুই প্রান্তের app শুধু fd ধরে আছে; state, buffer আর timer সব মাঝের kernel-এ। localhost-এও দুই প্রান্তে আলাদা socket বস্তু।

আর send path-টা খাড়াভাবে — প্রতি ধাপে latency যোগ হয়, কিন্তু ধাপগুলো pipeline হয়ে চলে:

write() ─► copy ─► TCP buf ─► segment ─► IP ─► qdisc ─► ring ─► DMA ─► wire
  ▲                                                                      
  │ return (microseconds)                                                
  └─── বাকি যাত্রা: microseconds থেকে milliseconds, কেউ আপনাকে
       জানাবে না — শুধু পরের ACK আসলেই বোঝা যাবে পৌঁছেছে           
write() ফেরত দেওয়া মাত্র প্রথম ধাপের শেষ — বাকি পাঁচটা ধাপ অনেক সময় পরে, অন্য clock-domain-এ ঘটে।

নিজে চালিয়ে দেখুন

EXPERIMENT

Syscall গোনা, state দেখা, আর bind-fail reproduce করা

Linux (strace, ss)· ২০ মিনিট

Build section-এর echo প্রোগ্রামটা (বা নিচের যেকোনো সংস্করণ) compile করে রাখুন।

ধাপ ১ — syscall গণনা:

strace -o s.trace ./echo server &          # server trace হচ্ছে
printf 'bangla\n' | ./echo client
grep -c '' s.trace                         # মোট লাইন
grep -E 'recvfrom|sendto' s.trace | head

প্রত্যাশিত প্যাটার্ন: শুরুতে socket/setsockopt/bind/listen/ accept4-এর একগুচ্ছ (setup), তারপর প্রতি message-এ ঠিক recvfrom + sendto জোড়া, শেষে EOF-এর recvfrom(...)=0। Message বাড়ালে শুধু জোড়াই বাড়ে, setup না।

ধাপ ২ — state দেখা:

watch -n0.2 'ss -tan | grep 9090'
# অন্য terminal:  nc 127.0.0.1 9090   তারপর Ctrl-C

nc ঢুকলে ESTAB জ্বলে ওঠে; বন্ধ করলে এক ঝলকের জন্য FIN_WAIT/CLOSE_WAIT ঝিকমিক, তারপর যে পক্ষ আগে close করল তার পাশে TIME-WAIT জমে — tcp-handshake-এর state diagram এখন চোখের সামনে।

ধাপ ৩ — bind fail আর তার সমাধান:

# ১. client আগে close করলেও server-পাশে TIME_WAIT জমে না —
#    server-কেই আগে close করান:
pkill -f './echo server'                   # চলমান connection-সহ মেরে ফেলুন
# ২. সঙ্গে সঙ্গে restart (SO_REUSEADDR লাইনটা কমেন্ট করা binary দিয়ে):
./echo server                              # bind(): Address already in use!
ss -tan state time-wait | grep 9090        # প্রমাণ: TIME_WAIT-ই দোষী
# ৩. এবার SO_REUSEADDR-সহ original binary:
./echo server                              # listening on 9090 — ঝামেলা শেষ

macOS-এ netstat -an -ptcp | grep 9090 দিয়ে একই দেখা যায়; strace-এর বদলে সেখানে sudo fs_usage -w -f network চলে।

এটা কী প্রমাণ করে

steady-state-এ প্রতি message-এ মাত্র recv+send জোড়া চলে (setup খরচ একবারই), socket state-গুলো ss-তে হুবহু state-machine অনুযায়ী ফুটে ওঠে, আর TIME_WAIT-এর কারণেই restart-এ bind fail হয় যা SO_REUSEADDR ঠিক করে — তিনটাই চোখে দেখা প্রমাণ।

নিজে বানান

BUILD IT

Echo server + client — C-তে পুরো খাঁটি, তারপর Python-এ এক নিঃশ্বাসে

C / Python · ●●●○○
  1. একটা প্রোগ্রামেই দুই ভূমিকা: ./echo server আর ./echo client
  2. SIGPIPE ignore করুন — dead peer-এ লেখা মানে crash নয়, handled error
  3. send_all লুপে partial send সামলান, recv-এর EOF (return 0) আলাদা করে ধরুন
  4. Python সংস্করণে তুলনা করুন — API একই, শুধু boilerplate নেই
  5. দুই সংস্করণের strace পাশাপাশি ফেলে steady-state syscall-এর মিল দেখুন

লক্ষ্য: আজকের সব তত্ত্ব — state mapping, sockaddr_in, byte order, EOF semantics, SIGPIPE — একটা ~৯০ লাইনের সৎ, production- ধাঁচের echo প্রোগ্রামে একসাথে।

// echo.c — gcc -Wall -O2 -o echo echo.c
// চালান: ./echo server   (এক terminal)
//        ./echo client   (অন্য terminal) — টাইপ করুন, echo ফেরত পাবেন

#include <errno.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>

#define PORT     9090
#define BACKLOG  16
#define BUF_SIZE 1024

/* send()-এর guarantee: পুরো buf না পাঠানো পর্যন্ত লুপ —
   stream-এ partial send normal ব্যাপার, error না */
static int send_all(int fd, const char *buf, size_t len) {
    size_t done = 0;
    while (done < len) {
        ssize_t n = send(fd, buf + done, len - done, 0);
        if (n < 0) {
            if (errno == EINTR) continue;   /* signal — আবার চেষ্টা */
            return -1;                      /* সত্যিকারের ব্যর্থতা */
        }
        done += (size_t)n;
    }
    return 0;
}

static int run_client(void) {
    int fd = socket(AF_INET, SOCK_STREAM, 0);
    struct sockaddr_in sa = {0};            /* আগে শূন্য — নিয়ম */
    sa.sin_family = AF_INET;
    sa.sin_port   = htons(PORT);            /* ভুললে ভুল port! */
    inet_pton(AF_INET, "127.0.0.1", &sa.sin_addr);

    if (connect(fd, (struct sockaddr *)&sa, sizeof sa) < 0) {
        perror("connect");
        return 1;
    }

    char line[BUF_SIZE], rbuf[BUF_SIZE];
    while (fgets(line, sizeof line, stdin)) {
        size_t need = strlen(line);
        if (send_all(fd, line, need) < 0) break;

        /* ঠিক যত byte পাঠালাম ততটাই ফেরত — recv জোটানো stream,
           তাই কতবার কল হবে কেউ বলে দেয় না: লুপই একমাত্র পথ */
        size_t got = 0;
        while (got < need) {
            ssize_t n = recv(fd, rbuf + got, sizeof rbuf - got, 0);
            if (n == 0) { fprintf(stderr, "peer closed\n"); return 1; }
            if (n < 0)  { perror("recv"); return 1; }
            got += (size_t)n;
        }
        fwrite(rbuf, 1, got, stdout);
        fflush(stdout);
    }
    close(fd);                              /* FIN পাঠায় — graceful */
    return 0;
}

static void run_server(void) {
    signal(SIGPIPE, SIG_IGN);  /* মৃত peer-এ write = signal, crash নয় */

    int lfd = socket(AF_INET, SOCK_STREAM, 0);
    int yes = 1;
    setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof yes);

    struct sockaddr_in sa = {0};
    sa.sin_family      = AF_INET;
    sa.sin_port        = htons(PORT);
    sa.sin_addr.s_addr = htonl(INADDR_ANY); /* সব interface-এ শোনা */

    if (bind(lfd, (struct sockaddr *)&sa, sizeof sa) < 0) {
        perror("bind");                     /* Address already in use? */
        exit(1);
    }
    if (listen(lfd, BACKLOG) < 0) { perror("listen"); exit(1); }
    printf("listening on %d\n", PORT);

    for (;;) {
        int cfd = accept(lfd, NULL, NULL);  /* নতুন fd প্রতি connection-এ */
        if (cfd < 0) continue;

        char buf[BUF_SIZE];
        ssize_t n;
        while ((n = recv(cfd, buf, sizeof buf, 0)) > 0) {
            if (send_all(cfd, buf, (size_t)n) < 0) break;
        }
        close(cfd);                         /* recv==0 (EOF) হলেই এখানে */
    }
}

int main(int argc, char **argv) {
    if (argc > 1 && strcmp(argv[1], "client") == 0) return run_client();
    run_server();
    return 0;
}

একই প্রোগ্রাম Python-এ — API-র আকৃতি হুবহু এক, শুধু boilerplate আর manual loops-এর ভার লাইব্রেরি নিয়ে নিল:

# echo.py — python3 echo.py server / python3 echo.py client
import socket, sys

def server():
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    s.bind(("127.0.0.1", 9090))
    s.listen(16)
    while True:
        conn, _ = s.accept()
        with conn:                          # with = try/finally close
            for chunk in iter(lambda: conn.recv(1024), b""):
                conn.sendall(chunk)         # partial-send লুপ ভেতরেই

def client():
    with socket.create_connection(("127.0.0.1", 9090)) as s:
        for line in sys.stdin:
            s.sendall(line.encode())
            reply, need = b"", line.encode()
            while len(reply) < len(need):   # recv-জোটানো — একই নিয়ম
                part = s.recv(1024)
                if not part: return         # EOF
                reply += part
            print(reply.decode(), end="", flush=True)

if len(sys.argv) > 1 and sys.argv[1] == "client":
    client()
else:
    server()

strace diff — দুই সংস্করণ পাশাপাশি:

strace -c -o c.trace   ./echo client < input.txt
strace -c -o py.trace  python3 echo.py client < input.txt
diff <(grep -E 'socket|connect|recv|send' c.trace) \
     <(grep -E 'socket|connect|recv|send' py.trace) | head -20

Python trace-এর শুরুতে শত শত অতিরিক্ত syscall — interpreter boot, shared library আর module openat/mmap/read। কিন্তু network-সংক্রান্ত অংশে খুঁজে দেখুন: socket, connect, recvfrom, sendto — হুবহু এক, একই ক্রমে। Python আসলে ভেতরে C sockets API-ই ডাকে; abstraction লেয়ারটা আপনার সময় বাঁচায়, wire-এর খরচ বাড়ায় না। Steady-state-এ প্রতি message দুজনেরই খরচ একই ২টা syscall — hood অংশের হিসাব মিলে গেল।

নিজে বাড়ান:

  1. SO_REUSEADDR লাইনটা বাদ দিয়ে build করে experiment ধাপ ৩-এর fail-টা এই binary দিয়েই reproduce করুন
  2. Server-কে concurrent বানান — প্রতি accept-এ fork() বা pthread, তারপর দুই simultaneous client পরীক্ষা করুন
  3. shutdown(cfd, SHUT_WR) যোগ করে half-close আচরণ দেখুন — client পাঠানো বন্ধ করেও কেন উত্তর পেতে পারে, strace-এ প্রমাণ করুন

বাস্তব সিস্টেমে

বাস্তব জগতে

SO_REUSEPORT আর multi-process server। Nginx-এ reuseport on; লিখলে প্রতিটা worker process একই port-এ আলাদা listening socket খোলে — kernel incoming connection- গুলো 4-tuple hash করে workers-এ বাঁটে, lock contention প্রায় শূন্য। Cloudflare-এর blog-এ লেখা আছে কীভাবে একটাই flag তাদের accept throughput বদলে দিয়েছিল। শর্ত: একই effective UID-এর bind-ই allow — security তাই রক্ষা।

SIGPIPE — নীরব killer। Default-এ বন্ধ connection-এ write করলে process গুলি হয় signal-এ; log-এ কিছুই নেই, server শুধু হারিয়ে যায়। তাই প্রতিটা সৎ network daemon-এর জন্মলগ্নেই signal(SIGPIPE, SIG_IGN) বা MSG_NOSIGNAL — এই লেসনের C কোডের প্রথম defensive লাইনটা এসেছে সেই কারণে।

Auto-tuning buffer। আজকের Linux write-কে সাথে সাথে return দিতে দেয় কারণ send buffer নিজে থেকেই বেড়ে যায় (BDP-অনুযায়ী)। Long-fat network-এ (satellite, inter-continent) buffer-ই throughput-এর সীমা — ss-এর Send-Q-তে সেই tuning live দেখা যায়।

Kernel bypass-এর সোজা পথ। 100GbE-তে per-syscall খরচটাই bottleneck — তাই DPDK/XDP জাতীয় stack NIC-কে user-space-এ সোজা কথা বলায়, kernel stack skip করে। অর্থাৎ আজকে যে path আঁকলাম, extreme performance-এর জগতে সেটাকে পাশ কাটানোর প্রয়োজনই সেই path-এর গুরুত্বের প্রমাণ।

যে ভুলগুলো সবাই করে

“socket() call-টাই connection তৈরি করে।”

socket() শুধু একটা খালি endpoint + fd দেয় — wire-এ একটা packet-ও যায় না, state থাকে অনির্ধারিত। Connection জন্ম নেয় client-এর connect()-এ (handshake শুরু), server-পাশে সেটা complete হয় accept queue-তে — accept() তো শুধু ফসল তোলে। ছয়টা call-এর mapping table-টা আবার দেখুন।

“write() return করেছে মানে data peer-এ পৌঁছেছে।”

Return মানে শুধু kernel-এর send buffer-এ জায়গা পেয়েছে — segmentation, qdisc, wire, peer, ACK সব এর পরে, asynchronous, নীরব। Peer মরে গেলেও write সফল দেখাবে; সত্যি জানতে হলে পরের write/read-এর error বা keepalive timeout-এর অপেক্ষা। এই বিভ্রমের জন্যই distributed system-এ ack semantics app-level protocol-এ আলাদা করে design করতে হয়।

“Same-machine test-এ htons বাদ দিলে চলে — সবাই তো localhost-এ।”

Little-endian x86-এ htons বাদ দিলে port 9090 দাঁড়ায় 33315 — bind তবু সফল, আপনার test client-ও একই ভুল করলে সব কাজ করবে! ভাঙবে আসল client/network-এ গেলেই। Byte-order bug-গুলো এমন নীরব — তাই নিয়ম: address-এর প্রতিটা numeric field-এ conversion function, exception কোনোটাতেই নয়।

“accept() কল করলেই handshake শুরু হয়।”

Handshake চলে connect() আর kernel-এর মধ্যে — app-এর কোনো ভূমিকা নেই। accept() তখনি ফেরত দেয় যখন 3-way handshake already complete, connection accept queue-তে ready বসে। তাই backlog overflow-এর সময় (app ধীর) kernel connection reject করতে পারে accept ডাকার আগেই — queue-টা app-এর না, kernel-এর।

বুঝেছেন কি না দেখুন

1

ছয়টা syscall-কে TCP state-এর সাথে মিলান করুন: socket, bind, listen, connect, accept, close — প্রতিটার সাথে ঠিক কোন state transition বা কোন queue-র কাজ জড়িত?

স্মরণ

socket(): খালি endpoint, কোনো state না। bind(): state ছাড়া local ঠিকানা বসানো। listen(): CLOSED → LISTEN, সাথে SYN + accept queue তৈরি। connect(): CLOSED → SYN_SENT → ESTABLISHED (active open)। accept(): কোনো state change নয় — established connection accept queue থেকে তুলে নতুন fd। close(): ESTABLISHED → FIN_WAIT → … → TIME_WAIT (teardown)।

2

htons ছাড়া sin_port = 9090 লিখলে little-endian মেশিনে kernel কোন port-এ bind করবে? Hex দিয়ে হিসাব দেখান।

প্রয়োগ

9090 decimal = 0x2382। Little-endian memory-তে থাকে byte-kramে 82 23; kernel big-endian ধরে পড়ে 0x8223 = 33315। Bind সফলই হবে — শুধু সম্পূর্ণ ভুল port-এ; client ঠিক করলে connection refused, client-ও ভুল করলে নীরবে কাজ করবে — সবচেয়ে বিপজ্জনক ধরনের bug।

3

Fresh connection-এ আপনি ১ MB write করলেন, ফেরত এলো তৎক্ষণাৎ। Peer-এর RTT ৪০ ms — তাহলে ১ MB কি ৪০ ms-এর আগে পৌঁছাতে পারে? Kernel-এর ভেতরে ঠিক কী ঘটল?

যুক্তি

না — পারে না; RTT-র চেয়ে কম সময়ে data পৌঁছানো অসম্ভব। যা ঘটল: ১ MB default send buffer (auto-tuned, প্রায়ই MB-scale)-এ পুরোটা copy হয়ে গেছে বলেই write সঙ্গে সঙ্গে return দিয়েছে। বাকি সব — segmentation ~৭০০ segment-এ, qdisc, wire, ACK — asynchronous চলছে। Buffer ছোট হলে write আটকে যেত; এটাই blocking semantics-আর flow control-এর মিলনবিন্দু।

4

strace-এ দেখছেন accept4(...) দীর্ঘক্ষণ pending আছে। এই মুহূর্তে server process, kernel, আর ss output — তিনটাতেই কী দেখবেন?

প্রয়োগ

Process: blocking sleep-এ (CPU খাচ্ছে না), kernel-কে দিয়ে accept queue-র অপেক্ষায়। Kernel: listener LISTEN state-এ, SYN/accept queue — কেউ এলে queue হবে। ss: শুধু LISTEN 0 128 0.0.0.0:9090 লাইন — কোনো ESTAB লাইন নেই। accept4 return করার সাথে সাথেই নতুন ESTAB জোড়া লাইন দেখা যাবে।

5

SO_REUSEADDR set করা অবস্থাতেও অন্য একটা চলমান process-এর port-এ bind fail হলো — কেন? আর কোন flag-এ সেটাও সম্ভব?

যুক্তি

কারণ REUSEADDR শুধু মৃত state (TIME_WAIT, বন্ধ socket-এর অবশিষ্ট) উপেক্ষা করে — জীবিত LISTEN-এর conflict তার scope-ই নয়, নইলে যেকোনো process চলমান server ছিনতাই করত। Live share-এর জন্য আলাদা flag: SO_REUSEPORT — তবে সেটাও শর্তে (একই effective UID), আর উদ্দেশ্য ভিন্ন: ছিনতাই নয়, multi-process load balancing।

6

Zero-downtime restart চায় এমন high-traffic proxy design করুন: restart-এর সময় কোন socket option, কী ক্রমে — আর কেন শুধু SO_REUSEADDR যথেষ্ট নয়?

ডিজাইন

স্তরে স্তরে: (১) SO_REUSEADDR — TIME_WAIT-জনিত bind-fail দূর, কিন্তু এতে পুরনো আর নতুন process একসাথে একই port ধরতে পারে না; handoff-এর ফাঁকে connection loss। (২) SO_REUSEPORT — নতুন process সঙ্গে সঙ্গে একই port share করে, kernel hash-এ নতুন connection-গুলো ভাগ হতে থাকে; পুরনো process ধীরে drain করে বেরোয়। (৩) Graceful shutdown: listener বন্ধ, চলমান connection-গুলো idle-timeout পর্যন্ত serve। তাই উত্তর: REUSEADDR প্রয়োজন কিন্তু অপর্যাপ্ত — আসল হাতিয়ার REUSEPORT + drain।

এরপর কী

এরপর কী

আজ আমরা ঠিকানা দিয়ে ডাকলাম — connect(("93.184.216.34", 443))। কিন্তু মানুষ IP মনে রাখে না, মনে রাখে নাম। সেই নাম ঠিকানায় বদলে দেয় কে, কোথায়, কত দ্রুত, আর কারা সেই translation জালিয়ে আপনাকে ভুল server-এ পাঠাতে পারে?

পরের লেসন — DNS:

  • Root, TLD, authoritative — hierarchical namespace-এর সিঁড়ি
  • Recursive বনাম iterative resolution — একটা query-র পুরো বংশলতিকা
  • Wire format হাতে খুলে দেখা — আর compression pointer-এর 0xC0 ফাঁদ
  • নিজের হাতে DNS query builder + parser — কোনো library ছাড়া

আজ যে socket API শিখলেন, পরের লেসনের build সেটার UDP রূপটাই ব্যবহার করবে।

আরও পড়ুন

  • UNIX Network Programming, Volume 1: The Sockets Networking API — chapters 4–6 — W. Richard Stevens, Bill Fenner, Andrew Rudoff · Sockets API-র বাইবেল — প্রতিটা call-এর semantics আর ক্লাসিক echo server উভয়ই এখান থেকে
  • The Linux Programming Interface — chapters 56–61 (sockets) — Michael Kerrisk · Linux-নির্দিষ্ট সূক্ষ্মতা: SO_REUSEADDR-এর যথার্থ semantics, non-blocking IO, epoll-এর ভূমিকা
  • Beej's Guide to Network Programming Using Internet Sockets — Brian Hall · প্রথমবার socket কোড লেখার সবচেয়ে বন্ধুত্বপূর্ণ free guide — sockaddr_in আর byte order-এর ভালো ব্যাখ্যা