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 পর্যন্ত যায় সেই পথ হাঁটুন।
আগে এটা বুঝি
গত দুই লেসনে আমরা 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 useerror-এর আসল রহস্য আর SO_REUSEADDR আসলে ঠিক কী ছাড় দেয়
মূল ধারণা
Socket আসলে কী — kernel-এর ভেতরের বস্তু
User-space-এ socket মানে একটা int। Kernel-space-এ সেটা একটা
সমৃদ্ধ বস্তু:
| User দেখে | Kernel রাখে |
|---|---|
fd = 3 | protocol control block — TCP state, seq/ack counter, timers |
| read/write call | receive 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-shorthtons ছাড়লে কী হয়? 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) ডাকলেন। এরপর কী হয়, ধাপে ধাপে:
- write() syscalluser buffer থেকে kernel-এ copy — এখানেই syscall boundary পার হয়
- TCP: send bufferbyte-stream জমা হয়; retransmission-এর জন্য প্রতিটা segment-এর কপি ACK না আসা পর্যন্ত রাখা হয়
- TCP: segmentationMSS (~1448 byte)-এ কাটা, seq number, checksum, দরকার হলে Nagle-delay
- IP layerroute lookup, src/dst IP header, TTL কমানো — সাধারণত fragment হয় না (PMTU)
- qdisc (queueing discipline)interface-এর অপেক্ষার লাইন — pfifo, fq, tc দিয়ে shaping
- driver TX ringpacket-এর ঠিকানা descriptor-এ লিখে doorbell register বাজানো
- 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 | মোট ~৬ |
| প্রতি message | recv + 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-endianaccept4return করেছে fd 4 — listening socket ছিল 3; প্রতিটা connection নতুন descriptorrecv-এর return 0 মানে error না — graceful EOF, stream- শেষ। এটাই close detection-এর একমাত্র portable উপায়
ss -tanp — state আর buffer এক নজরে
সেশন চলাকালীন:
sudo ss -tanp | grep 9090State 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 │
└──────────────────────────────────────────────────────────────────┘আর send path-টা খাড়াভাবে — প্রতি ধাপে latency যোগ হয়, কিন্তু ধাপগুলো pipeline হয়ে চলে:
write() ─► copy ─► TCP buf ─► segment ─► IP ─► qdisc ─► ring ─► DMA ─► wire
▲
│ return (microseconds)
└─── বাকি যাত্রা: microseconds থেকে milliseconds, কেউ আপনাকে
জানাবে না — শুধু পরের ACK আসলেই বোঝা যাবে পৌঁছেছে নিজে চালিয়ে দেখুন
Syscall গোনা, state দেখা, আর bind-fail reproduce করা
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-Cnc ঢুকলে 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 ঠিক করে — তিনটাই চোখে দেখা প্রমাণ।
নিজে বানান
Echo server + client — C-তে পুরো খাঁটি, তারপর Python-এ এক নিঃশ্বাসে
- একটা প্রোগ্রামেই দুই ভূমিকা: ./echo server আর ./echo client
- SIGPIPE ignore করুন — dead peer-এ লেখা মানে crash নয়, handled error
- send_all লুপে partial send সামলান, recv-এর EOF (return 0) আলাদা করে ধরুন
- Python সংস্করণে তুলনা করুন — API একই, শুধু boilerplate নেই
- দুই সংস্করণের 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 -20Python 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 অংশের হিসাব মিলে গেল।
নিজে বাড়ান:
SO_REUSEADDRলাইনটা বাদ দিয়ে build করে experiment ধাপ ৩-এর fail-টা এই binary দিয়েই reproduce করুন- Server-কে concurrent বানান — প্রতি
accept-এfork()বা pthread, তারপর দুই simultaneous client পরীক্ষা করুন 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)।
2htons ছাড়া sin_port = 9090 লিখলে little-endian মেশিনে
kernel কোন port-এ bind করবে? Hex দিয়ে হিসাব দেখান।
প্রয়োগ
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।
3Fresh connection-এ আপনি ১ MB write করলেন, ফেরত এলো তৎক্ষণাৎ।
Peer-এর RTT ৪০ ms — তাহলে ১ MB কি ৪০ ms-এর আগে পৌঁছাতে পারে?
Kernel-এর ভেতরে ঠিক কী ঘটল?
যুক্তি
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-এর মিলনবিন্দু।
4strace-এ দেখছেন accept4(...) দীর্ঘক্ষণ pending আছে। এই
মুহূর্তে server process, kernel, আর ss output — তিনটাতেই
কী দেখবেন?
প্রয়োগ
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 জোড়া লাইন দেখা যাবে।
5SO_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।
6Zero-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-এর ভালো ব্যাখ্যা