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

নেটওয়ার্কিং — socket, TCP সার্ভার আর Asio

Networking — Sockets, TCP Servers and Asio

Taught in Bangla, this lesson covers network programming in C++: IP, ports, DNS, TCP versus UDP, the socket lifecycle for servers and clients, wrapping POSIX sockets in a move-only RAII class, partial reads and message framing, timeouts and non-blocking I/O, threads versus event loops with epoll, and Asio servers with coroutines.

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

  • নেটওয়ার্কের স্তর — IP, port, DNS, TCP বনাম UDP, client আর server — আর একটি বার্তা কীভাবে স্তরে স্তরে মোড়ানো হয়
  • server আর client-এর socket জীবনচক্র, আর POSIX socket-কে move-only RAII class-এ মুড়ে std::system_error দিয়ে ভুল জানানো
  • partial send/recv কেন হয়, লুপ কেন লাগে, আর newline বা length prefix দিয়ে message framing
  • timeout, retry, non-blocking I/O, client-প্রতি thread বনাম event loop (select/poll/epoll), আর C10K সমস্যা
  • Asio-র io_context, completion handler, strand আর coroutine সার্ভার; Winsock, TLS, HTTP লাইব্রেরি — আর AI-এর লেখা নেটওয়ার্ক কোড যাচাই

১ · স্তরে স্তরে নেটওয়ার্ক — যন্ত্র, প্রোগ্রাম আর বার্তা

আগের দুই পর্বে আপনি একটি প্রোগ্রামের ভেতরে অনেক কাজ একসাথে চালিয়েছেন। এবার দুটি আলাদা প্রোগ্রাম কথা বলবে — হয়তো একই ল্যাপটপে, হয়তো একটি ঢাকায় আর অন্যটি সিঙ্গাপুরের কোনো ডেটা সেন্টারে। বিকাশের ব্যালান্স দেখা থেকে অনলাইন গেম — সবকিছুর নিচে একই যন্ত্রপাতি।

রাজশাহী থেকে ঢাকার একটি অফিসে কুরিয়ারে কাগজ পাঠাচ্ছেন। খামের ওপর লেখেন ভবনের ঠিকানা — এটি কুরিয়ারকে বলে কোন বাড়িতে যেতে হবে। তার সাথে লেখেন ফ্ল্যাট নম্বর — ভবনের ভেতরে কে পাবে। নেটওয়ার্কে ভবনের ঠিকানা হলো IP address, আর ফ্ল্যাট নম্বর হলো port. এখন দুই রকম সেবা আছে। রেজিস্টার্ড কুরিয়ার প্রতিটি প্যাকেট ট্র্যাক করে, হারালে আবার পাঠায়, আর ক্রম ঠিক রেখে পৌঁছে দেয় — এটি TCP. আর রাস্তায় লিফলেট বিলি — দ্রুত, সস্তা, কিন্তু কোনোটা হারালে কেউ খোঁজ নেয় না — এটি UDP.

নেটওয়ার্কের কাজ কয়েকটি স্তরে ভাগ করা, যাকে বলে protocol stack. প্রতিটি স্তর শুধু নিজের দায়িত্ব পালন করে, আর নিচের স্তরকে বিশ্বাস করে। প্রতিটি স্তরের নিয়মকে বলে protocol — দুই পক্ষের লিখিত চুক্তি।

স্তরউদাহরণযে প্রশ্নের উত্তর দেয়
ApplicationHTTP, আপনার নিজের “CHAL” protocolবার্তার মানে কী? কোন অনুরোধের কী উত্তর?
TransportTCP, UDPকোন প্রোগ্রাম (port)? ক্রম আর নির্ভরযোগ্যতা লাগবে কি?
NetworkIP (IPv4, IPv6)কোন যন্ত্র? কোন পথে যাবে (routing)?
LinkWi-Fi, Ethernet, মোবাইল ডেটাপাশের যন্ত্র পর্যন্ত বিটগুলো কীভাবে যাবে?

মানুষ নাম মনে রাখে, যন্ত্র নম্বর বোঝে। abcltech.com থেকে IP address বের করে দেয় DNS — ইন্টারনেটের ফোনবুক। আর একটি সংযোগে সাধারণত দুই ভূমিকা থাকে: server একটি নির্দিষ্ট port-এ অপেক্ষা করে, আর client নিজে উদ্যোগ নিয়ে সংযোগ শুরু করে। নিজের কম্পিউটারকেই বোঝাতে আছে বিশেষ ঠিকানা 127.0.0.1, যার নাম loopback — এখানে পাঠানো ডেটা বাইরের নেটওয়ার্কে যায়ই না। এই পর্বের চালানো যায় এমন উদাহরণ এটিই ব্যবহার করবে।

TCP

আগে সংযোগ (handshake), তারপর একটানা byte-এর স্রোত। হারানো অংশ আবার পাঠায়, ক্রম ঠিক রাখে। ওয়েব, ইমেইল, ব্যাংকিং, ফাইল।

reliable stream

UDP

সংযোগ নেই, প্রতিটি বার্তা আলাদা (datagram). হারাতে পারে, ক্রম বদলাতে পারে। লাইভ ভিডিও কল, অনলাইন গেম, DNS-এর ছোট প্রশ্ন।

fast datagram

এখন দেখুন, আপনার প্রোগ্রাম যখন দোকানের সার্ভারকে CHAL লিখে চালের দাম জানতে চায়, সেই পাঁচটি byte কীভাবে স্তরে স্তরে মোড়ানো হয়ে যায় আর অন্য প্রান্তে খোলা হয়। প্রতিটি স্তর নিজের একটি “খাম” (header) যোগ করে — একে বলে encapsulation:

  1. ApplicationCHAL\nআপনার বার্তা
  2. TCPport 5555কোন প্রোগ্রাম
  3. IP203.0.113.7কোন যন্ত্র
  4. LinkWi-Fi frameপাশের যন্ত্র
  5. সার্ভারrecv()খাম খোলা
send(fd, "CHAL\n", 5, 0)শুধু আপনার ৫টি byte
┌──────────────┐
│ C H A L \n   │   ← application data
└──────────────┘
TCP headerport আর ক্রম নম্বর যোগ হলো
┌───────────────────────────┬──────────────┐
│ TCP: src 51234 → dst 5555 │ C H A L \n   │
│ seq, ack, checksum        │              │
└───────────────────────────┴──────────────┘
IP headerউৎস ও গন্তব্যের যন্ত্র
┌──────────────────────┬─────────┬──────┐
│ IP: 192.168.0.10 →   │ TCP hdr │ CHAL │
│ 203.0.113.7, TTL 64  │         │      │
└──────────────────────┴─────────┴──────┘
router শুধু এই খামটুকু দেখে পথ ঠিক করে
Link frameWi-Fi বা Ethernet-এর খাম
┌──────────┬────────┬─────────┬──────┬─────┐
│ Wi-Fi    │ IP hdr │ TCP hdr │ CHAL │ CRC │
└──────────┴────────┴─────────┴──────┴─────┘
প্রতিটি router-এ এই বাইরের খাম বদলায়
recv(conn, buf, 64, 0)উল্টো ক্রমে খোলা
Link   → খাম খুলে IP-কে দিল
IP     → "এটা আমার ঠিকানা" → TCP-কে দিল
TCP    → port 5555 কার? → আপনার সার্ভারের socket
সার্ভার → C H A L \n   (শুধু data, কোনো header নয়)
  1. আপনার বার্তা। প্রোগ্রাম শুধু পাঁচটি byte দিল: C, H, A, L আর newline. এর মানে জানে শুধু আপনার application protocol; নিচের কোনো স্তর তা বোঝে না।
  2. TCP-র খাম। OS-এর ভেতরের TCP অংশ একটি header যোগ করল: উৎস port (OS নিজে বেছে নেওয়া একটি অস্থায়ী নম্বর) আর গন্তব্য port 5555, সাথে ক্রম নম্বর (sequence) আর checksum. ক্রম নম্বর দিয়েই অন্য প্রান্ত হারানো বা উল্টাপাল্টা অংশ চিনে ঠিক করে।
  3. IP-র খাম। এবার যোগ হলো উৎস আর গন্তব্যের IP address. এই মোড়ানো একককে সাধারণভাবে packet বলে। বড় ডেটা অনেক packet-এ ভাগ হয়, আর সেগুলো ভিন্ন পথেও যেতে পারে।
  4. Link-এর খাম। Wi-Fi বা Ethernet শুধু পাশের যন্ত্র (যেমন বাসার router) পর্যন্ত নেওয়ার দায়িত্ব নেয়। প্রতিটি লাফে এই বাইরের খাম খুলে নতুন খাম পরানো হয়, ভেতরের IP প্যাকেট একই থাকে।
  5. উল্টো ক্রমে খোলা। সার্ভারের যন্ত্রে প্রতিটি স্তর নিজের খাম খুলে ওপরে দেয়। TCP port দেখে বোঝে কোন socket-এর জন্য, ক্রম মিলিয়ে রাখে, আর আপনার সার্ভারের recv পায় শুধু সেই পাঁচটি byte. আপনাকে কোনো header লিখতে বা পড়তে হয় না — socket API আপনাকে শুধু ওপরের স্তরটি দেখায়।

২ · C++-এ standard networking নেই — তাহলে কী ব্যবহার করবেন?

প্রথমেই একটি সৎ কথা: লেখার সময় (২০২৬) পর্যন্ত C++ standard লাইব্রেরিতে কোনো networking অংশ নেই। std::thread, std::filesystem, std::format আছে, কিন্তু std::socket নেই। Asio-ভিত্তিক একটি প্রস্তাব “Networking TS” নামে ২০১৮ সালে একটি Technical Specification হিসেবে প্রকাশিত হয়েছিল, কিন্তু সেটি মূল standard-এ মেশানো হয়নি। async কাজের মৌলিক নকশা (executor) নিয়ে কমিটির দীর্ঘ বিতর্ক শেষে C++26-এ এসেছে std::execution (পর্ব ৪৭-এ দেখেছেন), কিন্তু networking এখনো বাইরে। ইতিহাসটা ভাবনার প্রশ্নে আছে।

তাহলে বাস্তবে C++ প্রোগ্রামাররা তিনটি পথের একটি নেন:

১. OS-এর socket API

Linux/macOS-এ POSIX socket, Windows-এ Winsock. এগুলো C ফাংশন — socket, bind, connect… C++-এ এদের RAII class-এ মুড়ে নিতে হয়।

low level

২. Asio / Boost.Asio

সবচেয়ে প্রচলিত C++ নেটওয়ার্ক লাইব্রেরি। এক কোডে Linux, Windows, macOS; async I/O, timer, TLS, coroutine. ব্যবহারে header-only.

de facto standard

৩. উঁচু স্তরের লাইব্রেরি

HTTP-র জন্য libcurl, cpr বা Boost.Beast; RPC-র জন্য gRPC. বেশিরভাগ অ্যাপে আপনি এই স্তরেই কাজ করবেন।

high level

তাহলে নিচের স্তর শিখবেন কেন? কারণ ওপরের সব লাইব্রেরি শেষ পর্যন্ত এই একই socket ডাকে, আর partial read, timeout, ধীর client-এর সমস্যা সেখানেও আসে — শুধু অন্য নামে।

C জানলে

socket ফাংশনগুলো হুবহু এক — socket, bind, listen, accept, send, recv, htons; এগুলো C API, C++ সরাসরি ডাকে। পার্থক্য চারপাশে: C-তে প্রতিটি পথে হাতে close লিখতে হয় আর -1 দেখে errno পড়তে হয়; C++-এ RAII class নিজে বন্ধ করে, ভুল আসে std::system_error হয়ে, বাফার যায় std::span বা std::string_view হয়ে, আর thread হয় std::jthread. handshake, byte order আর HTTP-র গভীর আলোচনা আছে C কোর্সের TCP socket পর্বে — এখানে তার ওপরে C++-এর স্তরটি গড়ব।

৩ · Socket-এর জীবনচক্র — server আর client

একটি socket হলো নেটওয়ার্ক যোগাযোগের একটি প্রান্ত। POSIX সিস্টেমে OS এটিকে একটি ছোট পূর্ণসংখ্যা দিয়ে চেনে — file descriptor (সংক্ষেপে fd), ঠিক খোলা ফাইলের মতো। প্রতিটি socket ফাংশন আসলে OS-এর কাছে একটি অনুরোধ, অর্থাৎ system call; ব্যর্থ হলে -1 ফেরত দেয় আর কারণ লেখে errno-তে।

ফাংশনকে ডাকেকী করে
socket(AF_INET, SOCK_STREAM, 0)দুজনেইনতুন TCP socket বানায়, fd ফেরত দেয় (UDP হলে SOCK_DGRAM)
bind(fd, addr, len)serversocket-কে একটি IP আর port-এ বাঁধে
listen(fd, backlog)serversocket-কে “অপেক্ষার” অবস্থায় নেয়; আসা সংযোগ সারিতে রাখে
accept(fd, ...)serverসারি থেকে একটি সংযোগ নেয়, আর সেই client-এর জন্য নতুন fd দেয়
connect(fd, addr, len)clientserver-এর সাথে সংযোগ গড়ে (TCP handshake)
send / recvদুজনেইbyte পাঠায় / পড়ে; যতটা পেরেছে ততটার সংখ্যা ফেরত দেয়
close(fd)দুজনেইসংযোগ বন্ধ করে, fd OS-কে ফেরত দেয়

সবচেয়ে গুরুত্বপূর্ণ বিষয়টি হলো server-এ দুই ধরনের socket থাকে। একটি listening socket, যা শুধু নতুন সংযোগ গ্রহণ করে — দোকানের দরজার দারোয়ানের মতো। আর প্রতিটি accept একটি নতুন connected socket দেয় — প্রতিটি ক্রেতার জন্য আলাদা বিক্রয়কর্মী। ধাপে ধাপে দেখুন:

1int srv = socket(AF_INET, SOCK_STREAM, 0);   // server2bind(srv, 127.0.0.1 : 5555);3listen(srv, 8);4int c = socket(...); connect(c, server);      // client5int conn = accept(srv, nullptr, nullptr);     // server6send(c, "CHAL\n") → recv(conn) → send(conn, "75 taka\n")7close(c);  recv(conn) == 0 → close(conn);
অবস্থা (fd নম্বর উদাহরণমাত্র)
srv (fd 3)
conn
client c
  1. socket() — প্রান্ত তৈরি। server OS-এর কাছে একটি TCP socket চাইল, পেল একটি fd. এখনো এর কোনো ঠিকানা নেই, কেউ একে খুঁজেও পাবে না — শুধু একটি খালি ফোন সেট।
  2. bind() — ঠিকানা লাগানো। socket-কে 127.0.0.1 আর port 5555-এ বাঁধা হলো। port আগে থেকে অন্য কেউ ব্যবহার করলে এখানেই ব্যর্থ হয় — EADDRINUSE. (port ১০২৪-এর নিচে বাঁধতে সাধারণত বিশেষ অনুমতি লাগে।)
  3. listen() — দরজা খোলা। socket এখন “passive”: সে নিজে কাউকে ডাকে না, শুধু আসা সংযোগ গ্রহণ করে। 8 হলো backlog — OS সর্বোচ্চ কতগুলো সংযোগ সারিতে রাখবে, যতক্ষণ প্রোগ্রাম accept না করে (আসল সীমা OS কিছুটা বদলাতে পারে)।
  4. connect() — client-এর হাত বাড়ানো। client নিজের socket বানিয়ে server-এর ঠিকানায় connect করল। OS দুই পক্ষে তিনটি বার্তা চালাচালি করে — SYN, SYN-ACK, ACK — যার নাম three-way handshake. খেয়াল করুন: handshake OS নিজেই শেষ করে আর সংযোগটি সারিতে রাখে, server প্রোগ্রাম তখনো accept ডাকেনি। client-এর port OS নিজে বেছে দেয়।
  5. accept() — নতুন socket. server সারি থেকে সংযোগটি নিল এবং পেল একটি নতুন fd — শুধু এই client-এর সাথে কথা বলার জন্য। srv আগের মতোই দরজায় দাঁড়িয়ে আছে। সারি খালি থাকলে accept অপেক্ষা করে (block).
  6. send/recv — কথা বলা। client CHAL\n পাঠাল, server conn থেকে পড়ল, দাম পাঠাল। লক্ষ করুন, server srv-এ নয়, conn-এ পড়ছে-লিখছে। আর দুই দিকেই কথা চলতে পারে একসাথে — TCP পূর্ণ দ্বিমুখী।
  7. close() — বিদায়। client বন্ধ করলে অন্য প্রান্তে recv ফেরত দেয় 0 — এর মানে “অন্য পক্ষ লেখা শেষ করেছে” (end of stream), কোনো ভুল নয়। server তখন conn বন্ধ করে। প্রতিটি fd বন্ধ না করলে OS-এর সীমিত fd ফুরিয়ে যাবে, আর একসময় accept ব্যর্থ হবে EMFILE দিয়ে। এটিই পরের অনুচ্ছেদের RAII-এর কারণ।

আজ প্রায় সব OS যে socket API ব্যবহার করে, তার জন্ম ক্যালিফোর্নিয়া বিশ্ববিদ্যালয়, বার্কলিতে। ১৯৮৩ সালে প্রকাশিত Unix সংস্করণ 4.2BSD-তে প্রথম TCP/IP-এর সাথে এই API আসে, তাই একে বলে Berkeley sockets. Windows-এর Winsock, পরে POSIX standard — সবাই এই নকশাই নকল করেছে।

৪ · RAII দিয়ে socket মোড়ানো — একজন মালিক, নিশ্চিত বন্ধ

একটি fd হলো ধার করা রিসোর্স — ঠিক new দিয়ে নেওয়া মেমরি বা খোলা ফাইলের মতো। C-স্টাইলে প্রতিটি ফেরার পথে, প্রতিটি ভুলের পথে, প্রতিটি exception-এর পথে হাতে close লিখতে হয়। একটি পথ ভুলে গেলেই fd leak — সার্ভার কয়েক দিন চলার পর হঠাৎ নতুন client নিতে পারে না। পর্ব ১৭-এ শিখেছেন এর সমাধান: RAII. রিসোর্সটি একটি object-এর হাতে দিন; object-এর জীবন শেষ হলে destructor নিজেই ফেরত দেবে — স্বাভাবিক পথে হোক বা exception-এ।

কিন্তু একটি সাবধানতা আছে। এই class কপি করা গেলে দুটি object একই fd ধরবে, আর দুজনেই destructor-এ close ডাকবে। দ্বিতীয় close হয় ব্যর্থ হবে, নয়তো আরও খারাপ — এর মধ্যে OS একই নম্বর অন্য কোনো নতুন সংযোগকে দিয়ে থাকলে আপনি অন্য কারও সংযোগ বন্ধ করে দেবেন। তাই socket class হবে move-only: কপি নিষিদ্ধ, কিন্তু মালিকানা হস্তান্তর (move) চলবে — পর্ব ২২ আর পর্ব ৩৬-এর ঠিক সেই নিয়ম, যেমন std::unique_ptr বা std::thread.

class Socket {
public:
    Socket() = default;                                    // empty: owns nothing
    explicit Socket(int fd) : fd_(fd) {
        if (fd_ < 0) throw std::system_error(errno, std::generic_category(), "socket");
    }
    Socket(const Socket&) = delete;                        // two owners = double close
    Socket& operator=(const Socket&) = delete;
    Socket(Socket&& o) noexcept : fd_(std::exchange(o.fd_, -1)) {}
    Socket& operator=(Socket&& o) noexcept {
        if (this != &o) { reset(); fd_ = std::exchange(o.fd_, -1); }
        return *this;
    }
    ~Socket() { reset(); }
    int get() const noexcept { return fd_; }
private:
    void reset() noexcept { if (fd_ >= 0) ::close(fd_); fd_ = -1; }
    int fd_ = -1;
};

তিনটি জিনিস খেয়াল করুন। এক, move-এর পর পুরোনো object-এ -1 বসানো হয় (std::exchange দিয়ে), তাই তার destructor কিছুই বন্ধ করে না। দুই, ভুল জানানো হয় std::system_error দিয়ে — এটি errno-এর সংখ্যা আর তার পড়ার মতো বার্তা (“Connection refused”) দুটোই বয়ে নেয়, আর পর্ব ৩৭-এর মতো সাধারণ catch-এ ধরা যায়। e.code() একটি std::error_code, যা দিয়ে নির্দিষ্ট কারণ মিলিয়ে দেখা যায়, যেমন e.code() == std::errc::connection_refused. তিন, destructor-এ close-এর ভুল উপেক্ষা করা হয় — destructor থেকে exception ছোড়া চলে না। পর্ব ১৭-এর মতো std::unique_ptr-এ custom deleter দিয়েও একই কাজ করা যায়, তবে fd pointer নয় বলে নিজের ছোট class-ই বেশি পরিষ্কার।

এবার পুরো চক্রটি চালিয়ে দেখুন। একটি ছোট মুদি দোকানের price-lookup সার্ভার: client লেখে CHAL, সার্ভার উত্তর দেয় 75 taka. দুটোই একই প্রোগ্রামে — সার্ভার main-এ, client একটি std::jthread-এ (পর্ব ৪৬), আর যোগাযোগ চলে loopback-এ। জায়গা বাঁচাতে এখানে socket class-টি ছোট রাখা হয়েছে:

#include <arpa/inet.h>
#include <print>
#include <system_error>
#include <thread>
#include <unistd.h>
int check(long r, const char* what) {            // -1 -> exception with the errno text
    if (r < 0) throw std::system_error(errno, std::generic_category(), what);
    return static_cast<int>(r);
}
struct Socket {                                  // RAII: closed in the destructor
    int fd;
    explicit Socket(int f = ::socket(AF_INET, SOCK_STREAM, 0)) : fd(check(f, "socket")) {}
    Socket(Socket&& o) noexcept : fd(o.fd) { o.fd = -1; }       // move-only
    ~Socket() { if (fd >= 0) ::close(fd); }
    void put(std::string_view s) const { check(::send(fd, s.data(), s.size(), 0), "send"); }
    std::string get() const { char b[64]; return std::string(b, check(::recv(fd, b, sizeof b, 0), "recv")); }
};
int main() {
    Socket srv;
    sockaddr_in a{}; a.sin_family = AF_INET; a.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
    auto* sa = reinterpret_cast<sockaddr*>(&a); socklen_t len = sizeof a;   // port 0 = OS picks
    check(::bind(srv.fd, sa, len), "bind"); check(::listen(srv.fd, 8), "listen");
    check(::getsockname(srv.fd, sa, &len), "getsockname");   // which port did we get?
    std::jthread client([&] { Socket c; check(::connect(c.fd, sa, len), "connect");
                              c.put("CHAL\n"); std::print("client got: {}", c.get()); });
    Socket conn(::accept(srv.fd, nullptr, nullptr));
    std::print("server got: {}", conn.get()); conn.put("75 taka\n");
}

আউটপুট দুই লাইন: server got: CHAL আর client got: 75 taka. ক্রম প্রতিবার একই, কারণ client উত্তর পায় কেবল সার্ভার পড়ে উত্তর পাঠানোর পর। খেয়াল করুন: এক, port 0 দিয়ে bind করা মানে “OS, একটি খালি port দাও”; কোনটি পেলাম, তা getsockname জানায় — তাই port আগে থেকে দখল থাকলেও প্রোগ্রাম ভাঙে না (আসল সার্ভারে অবশ্য নির্দিষ্ট port লাগে, যাতে client জানে কোথায় আসবে)। দুই, htonl ঠিকানাটিকে network byte order-এ সাজায় — অনুচ্ছেদ ৫-এ এর কারণ। তিন, কোথাও close লেখা নেই: conn, c, srv — সবাই নিজের scope শেষে নিজেই বন্ধ হয়, আর jthread নিজের destructor-এ join করে। কোনো ডাক ব্যর্থ হলে check একটি std::system_error ছোড়ে, আর তখনো সব খোলা socket বন্ধ হয়। চার — সৎ সতর্কতা: এখানে put একবার send ডাকে আর get একবার recv. ছোট বার্তায় loopback-এ তা কাজ করে, কিন্তু আসল নেটওয়ার্কে যথেষ্ট নয় — ঠিক এই সমস্যাটিই পরের অনুচ্ছেদের বিষয়।

৫ · TCP একটি স্রোত — partial read, লুপ আর message framing

নেটওয়ার্ক কোডের সবচেয়ে প্রচলিত ভুল ধারণা: “client একবার send করেছে, তাই আমি একবার recv করলেই পুরো বার্তা পাব।” TCP এমন কোনো প্রতিশ্রুতি দেয় না। TCP দেয় একটানা byte-এর স্রোত — পাইপের পানির মতো। আপনি দুই বালতি ঢাললে অন্য প্রান্তে কেউ জানে না কোথায় এক বালতি শেষ হলো। ফলে দুটি ঘটনা স্বাভাবিক:

  • Partial recv: ১০০ byte পাঠানো হয়েছে, কিন্তু একটি recv হয়তো ৪০ byte দিল — বাকিটা পরের ডাকে। আবার দুটি ছোট বার্তা জুড়ে একটি recv-এই চলে আসতে পারে।
  • Partial send: send হয়তো আপনার ১০ হাজার byte-এর মধ্যে মাত্র ৬ হাজার নিল (OS-এর পাঠানোর buffer ভরা ছিল), আর ফেরত দিল 6000. বাকিটা আবার পাঠানোর দায়িত্ব আপনার।

loopback-এ ছোট বার্তায় এসব প্রায় দেখা যায় না, তাই টেস্টে সব ঠিক চলে। আসল মোবাইল নেটওয়ার্কে, বড় বার্তায়, ব্যস্ত সার্ভারে — হঠাৎ ভুল। তাই লেখার নিয়ম একটাই: যতক্ষণ সব না যায়, লুপ করুন।

void send_all(int fd, std::string_view data) {
    while (!data.empty()) {
        ssize_t n = ::send(fd, data.data(), data.size(), MSG_NOSIGNAL);
        if (n < 0) {
            if (errno == EINTR) continue;                  // interrupted by a signal: retry
            throw std::system_error(errno, std::generic_category(), "send");
        }
        data.remove_prefix(static_cast<std::size_t>(n));  // drop what was sent
    }
}

এখানে std::string_view-এর remove_prefix পাঠানো অংশটুকু বাদ দেয়, কোনো কপি ছাড়াই। MSG_NOSIGNAL (Linux) একটি নিরাপত্তা: অন্য পক্ষ সংযোগ বন্ধ করার পর লিখলে OS ডিফল্টভাবে SIGPIPE signal পাঠায়, যা পুরো প্রোগ্রাম নিঃশব্দে মেরে ফেলে। এই flag দিলে তার বদলে send ব্যর্থ হয় EPIPE দিয়ে, যা আপনি ধরতে পারেন। (macOS-এ একই কাজের জন্য socket option SO_NOSIGPIPE, বা পুরো প্রোগ্রামে SIGPIPE উপেক্ষা করা।)

Message framing — বার্তার সীমা নিজে ঠিক করুন

পড়ার দিকে সমস্যাটা গভীর: স্রোত থেকে কোথায় একটি বার্তা শেষ, তা বোঝার নিয়ম আপনার protocol-কেই দিতে হয়। একে বলে message framing. প্রচলিত দুটি পথ:

Newline দিয়ে

প্রতিটি বার্তা একটি লাইন, শেষে \n. মানুষ পড়তে পারে, টার্মিনাল দিয়েই টেস্ট করা যায়। SMTP, Redis-এর কমান্ড, HTTP-র header এভাবেই। শর্ত: বার্তার ভেতরে newline থাকবে না।

text protocol

দৈর্ঘ্যের prefix দিয়ে

আগে ৪ byte-এ দৈর্ঘ্য, তারপর ঠিক ততগুলো byte. যেকোনো binary ডেটা (ছবি, protobuf) চলে। কিন্তু দৈর্ঘ্যটি আসে অচেনা কারও কাছ থেকে — যাচাই না করলে বিপদ।

binary protocol

আমাদের দোকানের protocol newline-ভিত্তিক — একটি text protocol. নিচের প্রোগ্রামে কোনো socket নেই; বরং recv স্রোতটিকে যেভাবে টুকরো করে দিতে পারে, সেই টুকরোগুলো হাতে বানিয়ে দেওয়া হয়েছে। দেখুন, টুকরো যেখানেই ভাঙুক, বার্তা ঠিক বের হয়:

#include <map>
#include <print>
#include <stdexcept>
#include <string>
#include <vector>
// TCP gives a byte stream: keep leftovers in `pending`, cut complete lines at '\n'
std::vector<std::string> take_lines(std::string& pending, std::string_view chunk) {
    if (pending.size() + chunk.size() > 256) throw std::length_error("line too long");
    pending += chunk;
    std::vector<std::string> lines;
    for (std::size_t pos; (pos = pending.find('\n')) != std::string::npos; pending.erase(0, pos + 1))
        lines.push_back(pending.substr(0, pos));
    return lines;
}
int main() {
    const std::map<std::string, int, std::less<>> price{{"CHAL", 75}, {"DAL", 120}, {"DIM", 150}};
    std::string pending;
    for (std::string_view chunk : {"CH", "AL\nDI", "M\nMIL", "K\n"})   // how recv() might split it
        for (const auto& item : take_lines(pending, chunk))
            if (auto it = price.find(item); it != price.end()) std::println("{} -> {} taka", item, it->second);
            else std::println("{} -> unknown item", item);
}

আউটপুট তিন লাইন: CHAL -> 75 taka, DIM -> 150 taka আর MILK -> unknown item. চারটি টুকরোর একটিও একটি সম্পূর্ণ বার্তা নয় — "CH" অর্ধেক শব্দ, "AL\nDI"-তে এক বার্তার শেষ আর পরেরটির শুরু। pending হলো বাকি থাকা অংশের buffer; প্রতিটি নতুন টুকরো তার পেছনে জোড়া লাগে, আর যতগুলো সম্পূর্ণ লাইন পাওয়া যায়, কেটে বের করা হয়। সবচেয়ে গুরুত্বপূর্ণ লাইনটি প্রথম শর্ত: buffer ২৫৬ byte ছাড়ালে exception. এটি না থাকলে কেউ newline ছাড়া অবিরাম ডেটা পাঠিয়ে আপনার সার্ভারের মেমরি ভরিয়ে দিতে পারত। আসল সার্ভারে এই exception ধরে সংযোগটি বন্ধ করা হয়।

এবার একই ঘটনা ধাপে ধাপে, শেষে একটি আক্রমণসহ:

1n = recv(fd, buf, sizeof buf, 0);   // যা এসেছে2if (pending.size() + n > 256) throw ...;3pending.append(buf, n);4while ((pos = pending.find('\n')) != npos)5    handle(pending.substr(0, pos)), pending.erase(0, pos + 1);
প্রতিটি recv-এর পর
এই recv পেল
pending
সম্পূর্ণ বার্তা
  1. অর্ধেক শব্দ। প্রথম recv পেল মাত্র দুটি byte — CH. newline নেই, তাই কোনো বার্তা সম্পূর্ণ নয়। সার্ভার উত্তর দেয় না, শুধু জমিয়ে রাখে। “একটি recv = একটি বার্তা” ধরে নিলে সার্ভার এখানেই “CH নামে কোনো পণ্য নেই” বলে ভুল উত্তর দিত।
  2. এক বার্তার শেষ, পরেরটির শুরু। এবার এল AL\nDI. জোড়া দিলে CHAL\nDI — newline পর্যন্ত কেটে CHAL বার্তা হলো, উত্তর গেল ৭৫ টাকা। DI পরের বার্তার শুরু, buffer-এ থেকে গেল।
  3. আবার একই নকশা। M\nMIL এল; DI-এর সাথে জুড়ে DIM সম্পূর্ণ হলো। MIL অপেক্ষায়। লক্ষ করুন, টুকরো কোথায় ভাঙল তাতে ফলাফল বদলায় না — এটিই framing-এর উদ্দেশ্য।
  4. শেষ বার্তা। K\n এসে MILK সম্পূর্ণ করল। দামের তালিকায় নেই, তাই “unknown item”. buffer এখন খালি। একটি recv-এ যতগুলো লাইনই আসুক, while লুপ সবগুলো বের করে — দুটি বার্তা জুড়ে এলেও কোনোটা হারায় না।
  5. আক্রমণ। কেউ newline ছাড়া ১০ হাজার byte পাঠাল — হয়তো ভুলে, হয়তো ইচ্ছা করে, যাতে আপনার সার্ভার মেমরিতে ডুবে যায়। সীমা যাচাই আগে হয়, তাই ডেটা buffer-এ যোগই হয় না; exception উঠে সংযোগ বন্ধ। নিয়মটি মনে রাখুন: নেটওয়ার্ক থেকে আসা প্রতিটি আকারের একটি উপরের সীমা থাকবে।

দৈর্ঘ্যের prefix আর network byte order

binary protocol-এ প্রথম ৪ byte দৈর্ঘ্য বলে। কিন্তু একটি uint32_t-এর ৪টি byte কোন ক্রমে পাঠানো হবে? x86 আর বেশিরভাগ ARM CPU মেমরিতে ছোট ঘরের byte আগে রাখে (little-endian) — এটিই endianness-এর প্রশ্ন। ইন্টারনেট protocol-গুলো ঠিক করেছে বড় ঘরের byte আগে যাবে — একে বলে network byte order (big-endian). তাই পাঠানোর আগে htonl (host to network long) আর পড়ার পরে ntohl; port-এর জন্য ১৬ বিটের htons/ntohs. C++23-এ std::byteswap আর C++20-এর std::endian দিয়েও একই কাজ করা যায়।

constexpr std::uint32_t kMaxMessage = 64 * 1024;          // our protocol's limit
std::uint32_t net_len;
read_exact(fd, &net_len, sizeof net_len);                // loop until all 4 bytes arrive
std::uint32_t len = ntohl(net_len);                       // network -> host order
if (len == 0 || len > kMaxMessage)                        // NEVER trust a size from the network
    throw std::runtime_error("bad message length");
std::vector<std::byte> body(len);
read_exact(fd, body.data(), len);

যাচাইয়ের লাইনটি বাদ দিলে কী হয়? একজন আক্রমণকারী দৈর্ঘ্য পাঠাল প্রায় ৪০০ কোটি — আপনার সার্ভার ৪ GB মেমরি চাইবে, আর হয় ক্র্যাশ করবে, নয়তো অন্য সবাইকে ধীর করে দেবে। আর স্থির আকারের char buf[1024]-এ যাচাই ছাড়া len byte পড়লে হয় buffer overflow. input validation নেটওয়ার্ক কোডে ঐচ্ছিক নয়।

৬ · Timeout, retry আর non-blocking I/O

ডিফল্টভাবে socket ডাকগুলো blocking: recv ডেটা না আসা পর্যন্ত thread-কে চিরকাল আটকে রাখতে পারে। অন্য পক্ষের ফোনের নেটওয়ার্ক চলে গেলে, বা কেউ ইচ্ছা করে সংযোগ খুলে চুপ করে থাকলে — আপনার thread আর ফেরে না। শত শত এমন “ধীর client” একটি সার্ভারের সব thread আর fd আটকে দিতে পারে; Slowloris নামে পরিচিত আক্রমণ ঠিক এটাই করে, এক ধরনের denial of service. সমাধান: প্রতিটি অপেক্ষার একটি সময়সীমা।

timeval tv{.tv_sec = 5, .tv_usec = 0};                     // wait at most 5 seconds
check(::setsockopt(s.fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv), "setsockopt");
ssize_t n = ::recv(s.fd, buf, sizeof buf, 0);
if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) { /* timed out: give up on this client */ }

এই socket timeout (SO_RCVTIMEO) প্রতিটি recv-কে বলে: এতক্ষণে কিছু না এলে -1 আর EAGAIN দিয়ে ফিরে এসো। লেখার জন্য আছে SO_SNDTIMEO. তবে খেয়াল করুন, এটি প্রতি-ডাকের সীমা — কেউ প্রতি ৪ সেকেন্ডে একটি করে byte পাঠালে কখনো timeout হবে না। তাই আসল সার্ভার পুরো অনুরোধের জন্যও একটি মোট সময়সীমা রাখে।

Client: timeout আর retry

একটি client-কে দোকানের সার্ভার থেকে দাম আনতে হবে, আর মোবাইল নেটওয়ার্ক অনির্ভরযোগ্য। বাস্তব নকশা: প্রতিটি চেষ্টায় timeout, ব্যর্থ হলে একটু অপেক্ষা করে আবার, প্রতিবার অপেক্ষা দ্বিগুণ (exponential backoff), আর সীমিত সংখ্যক চেষ্টা:

std::optional<std::string> ask_price(const sockaddr_in& server, std::string_view item) {
    using namespace std::chrono_literals;
    auto wait = 200ms;
    for (int attempt = 1; attempt <= 3; ++attempt) {
        try {
            Socket s(::socket(AF_INET, SOCK_STREAM, 0));
            timeval tv{.tv_sec = 2, .tv_usec = 0};
            check(::setsockopt(s.get(), SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv), "setsockopt");
            check(::connect(s.get(), reinterpret_cast<const sockaddr*>(&server), sizeof server), "connect");
            send_all(s.get(), std::string(item) + "\n");
            return read_line(s.get(), 256);                // throws on timeout or a too-long line
        } catch (const std::system_error& e) {
            std::println(stderr, "attempt {} failed: {}", attempt, e.what());
            std::this_thread::sleep_for(wait);
            wait *= 2;                                     // 200 ms, 400 ms, 800 ms
        }
    }
    return std::nullopt;                                   // the caller decides what to show
}

দুটি সূক্ষ্ম কথা। প্রথমত, retry নিরাপদ শুধু সেই অনুরোধে, যা দুবার হলেও ক্ষতি নেই (idempotent) — দাম জানতে চাওয়া। কিন্তু “৫০০ টাকা পাঠাও” অনুরোধ timeout হলে আপনি জানেন না সেটি পৌঁছেছে কি না; অন্ধভাবে আবার পাঠালে টাকা দুবার কাটতে পারে। পেমেন্ট সিস্টেম তাই প্রতিটি অনুরোধে একটি অনন্য ID দেয়, যাতে server দ্বিতীয়টি চিনে বাদ দিতে পারে। দ্বিতীয়ত, connect-এর নিজের অপেক্ষা SO_RCVTIMEO দিয়ে নিয়ন্ত্রিত হয় না; তার জন্য non-blocking connect আর poll, অথবা Asio-র timer লাগে।

Non-blocking I/O — “এখন না পারলে বলে দাও”

আরেকটি পথ: socket-কে non-blocking করা (fcntl(fd, F_SETFL, O_NONBLOCK)). তখন কোনো ডাক অপেক্ষা করে না — ডেটা না থাকলে recv সাথে সাথে -1 আর EAGAIN দেয়, অর্থাৎ “এখন কিছু নেই, পরে এসো”। নিজে নিজে বারবার জিজ্ঞেস করা (busy loop) CPU পোড়ায়, তাই non-blocking socket সবসময় একটি event loop-এর সাথে ব্যবহার হয়, যা OS-কে জিজ্ঞেস করে “কোন socket এখন তৈরি?” — পরের অনুচ্ছেদ।

৭ · একসাথে অনেক client — thread, তার সীমা, আর event loop

আমাদের সার্ভার এখন একজন client-এর সাথে কথা বলে বন্ধ হয়ে যায়। আসল দোকানের সার্ভারে একসাথে অনেক মোবাইল অ্যাপ দাম জানতে চায়। একজনের সাথে কথা বলার সময় অন্যরা দরজায় দাঁড়িয়ে থাকলে চলবে না। সবচেয়ে সরল সমাধান: প্রতিটি client-এর জন্য একটি thread.

void serve(Socket conn);                          // read lines, answer prices, until EOF

std::vector<std::jthread> workers;
for (;;) {
    Socket conn(::accept(srv.get(), nullptr, nullptr));
    workers.emplace_back(serve, std::move(conn));   // the thread now owns the socket
}

খেয়াল করুন std::move(conn): socket-এর মালিকানা main থেকে নতুন thread-এ চলে গেল। move-only class-এর সৌন্দর্য এখানেই — কম্পাইলার নিশ্চিত করে, একই সংযোগ দুই thread-এর হাতে থাকবে না। serve শেষ হলে socket নিজেই বন্ধ হয়। (আসল কোডে শেষ হওয়া thread-গুলো তালিকা থেকে সরাতে হবে, নইলে vector অবিরাম বাড়বে; আর client-এর সংখ্যার একটি সীমা রাখতে হবে।)

অল্প client-এ — ধরুন একটি অফিসের ভেতরের টুলে ৫০ জন — এই নকশা চমৎকার: প্রতিটি client-এর কোড সরল, উপর থেকে নিচে পড়া যায়। কিন্তু হাজারো client-এ সমস্যা। প্রতিটি thread-এর নিজের stack (Linux-এ প্রায়ই ডিফল্ট ৮ MB ভার্চুয়াল জায়গা), প্রতিটির জন্য OS-এর হিসাব, আর অনেক thread-এর মাঝে CPU-র পালাবদলে (context switch) সময় নষ্ট। অথচ বেশিরভাগ thread বেশিরভাগ সময় কিছুই করছে না — শুধু recv-এ বসে ডেটার অপেক্ষা করছে। ১৯৯৯ সালে Dan Kegel এই প্রশ্নের নাম দেন C10K problem: একটি সার্ভার একসাথে দশ হাজার client কীভাবে সামলাবে?

Event loop — একজন বিক্রয়কর্মী, অনেক ক্রেতা

চায়ের দোকানে একজন দোকানি দশজন ক্রেতা সামলান। তিনি একজনের চা শেষ হওয়া পর্যন্ত তার পাশে দাঁড়িয়ে থাকেন না। বরং দেখেন — কার কাপ খালি, কে টাকা দিতে হাত বাড়িয়েছে, কার কেটলি ফুটছে — আর শুধু যে এখন তৈরি, তার কাজটি করেন। দোকানির জন্য দশজন কর্মচারী লাগে না। এটিই event loop.

OS এর জন্য একটি প্রশ্ন করার ব্যবস্থা দেয়: “এই socket-গুলোর মধ্যে কোনটিতে এখন পড়ার মতো ডেটা আছে, বা কোনটিতে লেখা যাবে?” পুরোনো উত্তর select (4.2BSD থেকে) আর poll; এরা প্রতিবার পুরো তালিকা ঘেঁটে দেখে, তাই হাজারো socket-এ ধীর। আধুনিক উত্তর OS-ভেদে আলাদা: Linux-এ epoll, BSD ও macOS-এ kqueue, Windows-এ IOCP. এরা শুধু তৈরি socket-গুলোর তালিকা দেয়, তাই একটি thread হাজারো সংযোগ সামলাতে পারে।

std::vector<pollfd> fds{{srv.get(), POLLIN, 0}};        // start with the listening socket
for (;;) {
    ::poll(fds.data(), fds.size(), -1);                    // sleep until something is ready
    for (auto& p : fds) {
        if (!(p.revents & POLLIN)) continue;
        if (p.fd == srv.get()) { /* accept, set O_NONBLOCK, add to fds */ }
        else                   { /* recv what is there, feed that client's line buffer */ }
    }
}

দাম: প্রতিটি client-এর অবস্থা (pending buffer, প্রোটোকলের কোন ধাপ) আর local ভ্যারিয়েবলে থাকে না — আলাদা কাঠামোতে রাখতে হয়, কোড টুকরো হয়ে যায়। এই জটিলতা লুকাতেই Asio আর coroutine.

চ্যাট-রুমের উদাহরণ: একটি গ্রুপ চ্যাট সার্ভার ভাবুন — একজনের পাঠানো প্রতিটি লাইন বাকি সবার কাছে যাবে (broadcast). thread-per-client নকশায় সব client-এর তালিকা একটি mutex দিয়ে পাহারা দিতে হয় (পর্ব ৪৬)। event loop-এ সব একই thread-এ চলে, তাই তালাই লাগে না। কিন্তু দুই নকশাতেই একটি ফাঁদ আছে: একজন ধীর পাঠকের (দুর্বল নেটওয়ার্কের ফোন) জন্য যদি সবাই অপেক্ষা করে, পুরো রুম থেমে যায়। সমাধান: প্রতিটি client-এর নিজের পাঠানোর সারি, তার একটি সর্বোচ্চ আকার, আর সারি ভরে গেলে সেই client-কে বিদায়।

৮ · Asio — event loop, completion handler আর coroutine

Asio হলো C++-এর সবচেয়ে প্রচলিত নেটওয়ার্ক লাইব্রেরি। এটি দুই রূপে পাওয়া যায়: Boost-এর অংশ হিসেবে Boost.Asio (#include <boost/asio.hpp>, namespace boost::asio), আর Boost ছাড়া আলাদা standalone Asio (#include <asio.hpp>, namespace asio). দুটির কোড প্রায় এক। ব্যবহারে এটি header-only; প্রজেক্টে যোগ করার সহজ পথ vcpkg বা Conan (পর্ব ৫০) আর CMake (পর্ব ৪৯)। ভেতরে Asio প্রতিটি OS-এর সেরা ব্যবস্থাটি ব্যবহার করে — Linux-এ epoll, Windows-এ IOCP — আর আপনাকে একটিই ইন্টারফেস দেয়।

অংশকাজ
io_contextevent loop নিজে। run() যে thread ডাকে, সেই thread-এ সব completion handler চলে
tcp::acceptorlistening socket — bind, listen, accept একসাথে
tcp::socketএকটি সংযোগ; move-only, destructor-এ নিজে বন্ধ হয় (RAII)
tcp::resolverDNS — নাম থেকে ঠিকানা
async_read_until, async_write“পুরোটা হলে জানাও” — partial read/write-এর লুপ লাইব্রেরি নিজে করে
steady_timertimeout আর বিলম্ব — এটিও একটি async কাজ

Callback-এর রূপ — completion handler

প্রতিটি async_ ফাংশন সাথে সাথে ফেরে; কাজটি শেষ হলে Asio আপনার দেওয়া একটি callback ডাকে — একে বলে completion handler. এক client-এর echo session এমন দেখায়:

class Session : public std::enable_shared_from_this<Session> {
    tcp::socket sock_;
    std::string buf_;
public:
    explicit Session(tcp::socket s) : sock_(std::move(s)) {}
    void read() {
        auto self = shared_from_this();                   // keep the session alive
        asio::async_read_until(sock_, asio::dynamic_buffer(buf_, 1024), '\n',
            [this, self](boost::system::error_code ec, std::size_t n) {
                if (ec) return;                           // EOF, error, or line too long
                auto line = std::make_shared<std::string>(buf_.substr(0, n));
                buf_.erase(0, n);
                asio::async_write(sock_, asio::buffer(*line),
                    [this, self, line](boost::system::error_code ec2, std::size_t) {
                        if (!ec2) read();                 // wait for the next line
                    });
            });
    }
};

এই কোডে তিনটি শিক্ষা আছে। এক, dynamic_buffer(buf_, 1024)-এর দ্বিতীয় argument সর্বোচ্চ আকার — newline ছাড়া ১০২৪ byte ছাড়ালে read ভুল দিয়ে শেষ হয়। দুই, জীবনকাল: handler পরে চলবে, ততক্ষণ session আর পাঠানোর buffer বেঁচে থাকা চাই। তাই shared_ptr-এর self আর line lambda-র ভেতরে ধরে রাখা হয়েছে। এটি ভুলে গেলে handler একটি মুছে যাওয়া object ছোঁয় — undefined behavior (পর্ব ১৮)। তিন, কোডটি আর উপর থেকে নিচে পড়া যায় না; প্রতিটি ধাপ একটি নতুন lambda-য়, যাকে মজা করে বলা হয় “callback hell”.

Strand: একাধিক thread থেকে io.run() ডাকলে handler-গুলো সমান্তরালে চলতে পারে, আর একই session-এর দুটি handler একসাথে চললে data race. strand হলো Asio-র নিশ্চয়তা: “এই strand-এ দেওয়া handler-গুলো কখনো একসাথে চলবে না, একটির পর একটি চলবে” — তালা ছাড়াই। একটি session-এর socket asio::make_strand(io) দিয়ে তৈরি করলে তার সব handler এক সারিতে চলে। একটিমাত্র thread-এ run() চললে strand-এর দরকারই নেই।

Coroutine-এর রূপ — async কোড, সাধারণ কোডের চেহারায়

পর্ব ৪৩-এ দেখেছেন, C++20-এর coroutine এমন ফাংশন, যা মাঝপথে থেমে পরে সেখান থেকেই চলতে পারে। Asio এর সাথে সুন্দরভাবে মেলে: co_await লিখলে coroutine থামে, thread ছেড়ে দেয়, আর কাজ শেষ হলে io_context তাকে আবার চালায়। পুরো echo সার্ভার:

#include <boost/asio.hpp>
#include <string>
namespace asio = boost::asio;
using asio::ip::tcp;
using asio::awaitable;
using asio::use_awaitable;

awaitable<void> session(tcp::socket sock) {            // one coroutine per client
    std::string buf;
    try {
        for (;;) {
            std::size_t n = co_await asio::async_read_until(
                sock, asio::dynamic_buffer(buf, 1024), '\n', use_awaitable);
            std::string line = buf.substr(0, n);
            buf.erase(0, n);
            co_await asio::async_write(sock, asio::buffer(line), use_awaitable);
        }
    } catch (const std::exception&) {}                 // client left, or line too long
}

awaitable<void> listener(tcp::acceptor acc) {
    for (;;) {
        tcp::socket s = co_await acc.async_accept(use_awaitable);
        asio::co_spawn(acc.get_executor(), session(std::move(s)), asio::detached);
    }
}

int main() {
    asio::io_context io;
    asio::co_spawn(io, listener(tcp::acceptor(io, {tcp::v4(), 5555})), asio::detached);
    io.run();                                          // one thread serves every client
}

কেন এটি “চালান” বোতাম ছাড়া? এই কোর্সের অনলাইন compiler-এ Boost নেই, আর সার্ভারটি নিজে থেকে থামে না। আমরা কোডটি Wandbox-এ GCC 13.2 আর Boost 1.83 দিয়ে যাচাই করেছি — একটি পরীক্ষামূলক client CHAL\n পাঠালে সার্ভার হুবহু CHAL ফেরত দেয়, আর ১০২৪ byte-এর বেশি লম্বা লাইন পাঠালে session বন্ধ হয়। নিজের কম্পিউটারে চালাতে Boost (বা standalone Asio) ইনস্টল করে g++ -std=c++20 echo.cpp দিয়ে build করুন, তারপর অন্য টার্মিনাল থেকে nc localhost 5555 দিয়ে লিখে দেখুন।

callback সংস্করণের সাথে তুলনা করুন: shared_from_this নেই, lambda-র ভেতরে lambda নেই। buf আর sock সাধারণ local ভ্যারিয়েবল — coroutine থামলেও তারা coroutine frame-এ বেঁচে থাকে। ভুল আসে সাধারণ exception হয়ে। co_spawn একটি coroutine-কে io_context-এ চালু করে, আর use_awaitable বলে “ফল callback-এ নয়, co_await-এ দাও”। আর সব client একটি thread-এ চললেও কেউ কারও জন্য আটকে থাকে না। কীভাবে? ধাপে ধাপে দেখুন:

1co_spawn(io, listener(...), detached);2io.run();3s = co_await acc.async_accept(use_awaitable);4co_spawn(..., session(std::move(s)), detached);5n = co_await async_read_until(sock, buf, '\n', ...);6co_await async_write(sock, buffer(line), ...);
একটি thread, অনেক কাজ
io_context-এর সারি
listener
session
thread
  1. কাজ জমা, চালানো নয়। co_spawn listener coroutine-টি io_context-এর সারিতে রাখল। এখনো কিছু চলেনি — কোনো thread io_context চালাচ্ছে না। (callback-এর রূপে একই ভূমিকা async_accept-এর: সে শুধু কাজ নথিভুক্ত করে, সাথে সাথে ফেরে।)
  2. run() — loop শুরু। main thread io.run() ডাকল। listener চলতে শুরু করে প্রথম co_await-এ পৌঁছায়: accept এখনই সম্ভব নয়, তাই coroutine থামে, আর Asio OS-কে বলে “এই socket-এ সংযোগ এলে জানিও” (Linux-এ epoll). সারিতে আর কাজ নেই, তাই thread ঘুমায় — CPU খরচ শূন্য।
  3. সংযোগ এল — আবার চলা। একটি client connect করল। OS io_context-কে জাগাল, io_context listener-কে ঠিক থামার জায়গা থেকে আবার চালাল, আর co_await ফেরত দিল নতুন tcp::socket. listener co_spawn দিয়ে এই client-এর জন্য একটি session চালু করল (সারিতে রাখল), তারপর লুপ ঘুরে আবার accept-এ থামল।
  4. session পড়ার অপেক্ষায়। io_context সারি থেকে session চালাল। সে async_read_until-এ পৌঁছে থামল, কারণ এখনো কোনো লাইন আসেনি। thread আবার মুক্ত। এই অবস্থায় একশো client থাকলে একশোটি coroutine থেমে আছে — কিন্তু thread একটিই, আর প্রতিটি coroutine frame মাত্র কয়েকশো byte থেকে কয়েক KB.
  5. ডেটা এল। CHAL\n পৌঁছাল। Asio নিজেই যতবার দরকার পড়ল (partial read-এর লুপ লাইব্রেরির ভেতরে), newline পেয়ে session-কে জাগাল: n = 5. session লাইনটি async_write-এ দিল আর আবার থামল — সব byte পাঠানো শেষ হওয়া পর্যন্ত।
  6. লুপ — আর সোনালি নিয়ম। লেখা শেষ, session আবার read-এ থামল। এর মধ্যে thread অন্য client-দের কাজ করছে। পুরো ব্যবস্থা চলে একটি শর্তে: কোনো handler বা coroutine io_context-এর thread আটকাবে না। session-এর ভেতরে std::this_thread::sleep_for, একটি blocking recv, বা ভারী হিসাব লিখলে সেই সময়টুকু সব client থেমে থাকবে। ভারী কাজ আলাদা thread pool-এ পাঠান, অপেক্ষার জন্য steady_timer ব্যবহার করুন।

পুরোনো Asio-র ফাঁদ: অনলাইনের অনেক পুরোনো টিউটোরিয়াল io_service, io_service::work, socket.get_io_service() লেখে। Boost 1.66 (২০১৭) থেকে নাম হয়েছে io_context, আর সাম্প্রতিক সংস্করণগুলোতে পুরোনো নামগুলো সরিয়ে ফেলা হয়েছে — তাই সেই কোড আজকের Boost-এ compile-ই হয় না। AI প্রায়ই এই পুরোনো রূপ লেখে।

৯ · বাস্তবের নেটওয়ার্ক — Windows, TLS, HTTP আর নিরাপত্তা

Windows-এ Winsock

Windows-এর socket API, Winsock, Berkeley sockets থেকেই এসেছে, তাই ফাংশনের নাম প্রায় এক, কিন্তু কয়েকটি পার্থক্য প্রোগ্রাম ভাঙায়। প্রথমে WSAStartup(MAKEWORD(2, 2), &data) ডেকে লাইব্রেরি চালু করতে হয়, আর শেষে WSACleanup() — এই জোড়াটিও একটি ছোট RAII object-এ রাখার মতো। socket-এর টাইপ int নয়, SOCKET (একটি unsigned সংখ্যা), তাই ভুল বোঝাতে -1 নয়, INVALID_SOCKET. বন্ধ করতে close নয়, closesocket. ভুলের কারণ errno-তে নয়, WSAGetLastError()-এ, যা std::system_category() দিয়ে std::system_error-এ ভরা যায়। আর link করতে হয় ws2_32 লাইব্রেরি। এই পার্থক্যগুলো লুকানোও Asio ব্যবহারের একটি বড় কারণ।

TLS — এনক্রিপশন আর পরিচয় যাচাই

এ পর্যন্ত সব ডেটা খোলা অবস্থায় গেছে — পথের যেকোনো Wi-Fi বা router তা পড়তে বা বদলাতে পারে। আসল অ্যাপে প্রায় সবসময় TLS লাগে: এটি ডেটা encrypt করে, আর server আসলেই যে দাবি করছে সে-ই কি না, তা certificate দিয়ে যাচাই করে। C++-এ সাধারণত OpenSSL-এর ওপর asio::ssl ব্যবহার হয়:

asio::ssl::context ctx(asio::ssl::context::tls_client);
ctx.set_default_verify_paths();                              // trust the OS certificate store
asio::ssl::stream<tcp::socket> tls(io, ctx);
tls.set_verify_mode(asio::ssl::verify_peer);                 // NEVER verify_none
tls.set_verify_callback(asio::ssl::host_name_verification("shop.example.com"));
SSL_set_tlsext_host_name(tls.native_handle(), "shop.example.com");   // SNI
asio::connect(tls.lowest_layer(), resolver.resolve("shop.example.com", "443"));
tls.handshake(asio::ssl::stream_base::client);

certificate যাচাই কখনো বন্ধ করবেন না। টেস্টের সময় certificate-এর ভুল এলে অনেকে (আর অনেক AI) লেখে verify_none বা curl-এ CURLOPT_SSL_VERIFYPEER = 0. তখন সংযোগ encrypt হয় ঠিকই, কিন্তু কার সাথে, তা আর জানা থাকে না — একটি নকল Wi-Fi hotspot মাঝখানে বসে দুই দিকের সব পড়তে পারে (man-in-the-middle). ভুলের আসল কারণ ঠিক করুন: সঠিক hostname, OS-এর certificate store, বা টেস্টের জন্য নিজের CA ফাইল যোগ করা।

HTTP চাই? নিজে লিখবেন না

বেশিরভাগ অ্যাপের দরকার একটি REST API ডাকা — নিজের TCP protocol নয়। HTTP দেখতে সহজ লাইনভিত্তিক লেখা, কিন্তু chunked encoding, redirect, compression, keep-alive, HTTP/2 — নিজে লিখলে বাগে ভরা হবে। প্রচলিত পছন্দ: libcurl (C লাইব্রেরি, প্রায় সব প্ল্যাটফর্মে আছে, অসংখ্য যন্ত্রে চলে), cpr (libcurl-এর ওপর আধুনিক C++ মোড়ক — cpr::Get(cpr::Url{...})), আর Boost.Beast (Asio-র ওপর HTTP ও WebSocket, যখন নিজের HTTP সার্ভার বা পূর্ণ নিয়ন্ত্রণ লাগে)। সবগুলোতেই TLS যাচাই ডিফল্টে চালু — চালুই রাখুন।

নিরাপত্তার নিয়ম — নেটওয়ার্ক কাউকে বিশ্বাস করে না

  • প্রতিটি আকার যাচাই: দৈর্ঘ্যের prefix, লাইনের দৈর্ঘ্য, বার্তার সংখ্যা — সবকিছুর উপরের সীমা। ডেটা পড়ুন std::vector বা std::string-এ, আর সীমা দেখে।
  • প্রতিটি অপেক্ষায় সময়সীমা: read, write, connect, আর পুরো অনুরোধ। ধীর client-কে বিদায় করুন।
  • রিসোর্সের সীমা: সর্বোচ্চ কতগুলো সংযোগ, প্রতি client-এর buffer কত বড় হতে পারে।
  • ইনপুট মানে টেক্সট, কমান্ড নয়: নেটওয়ার্ক থেকে আসা লেখা কখনো সরাসরি shell কমান্ড, SQL বা ফাইলের পথে বসাবেন না।
  • টেস্টে ভাঙার চেষ্টা: AddressSanitizer দিয়ে build করে ভুল আকারের, অর্ধেক, অসীম লম্বা বার্তা পাঠিয়ে দেখুন (fuzzing-এর কথা পর্ব ৫১-এ)।
অন্য ভাষায় একই কাজ

C-তে একই socket ফাংশন হাতে close আর errno দিয়ে — C কোর্সের TCP socket পর্ব। Java-তে ServerSocket আর Socket class, try-with-resources দিয়ে নিশ্চিত বন্ধ — Java কোর্সের নেটওয়ার্কিং পর্ব। Python-এ socket মডিউল, আর HTTP-র জন্য requests বা httpx — Python কোর্সের HTTP ও API পর্ব। সব ভাষাতেই নিচে Berkeley sockets, আর সব ভাষাতেই partial read আর framing-এর সমস্যা একই।

পরীক্ষা ও ইন্টারভিউতে
  • TCP আর UDP-র পার্থক্য? — TCP সংযোগভিত্তিক, নির্ভরযোগ্য, ক্রম ঠিক রাখা byte stream; UDP সংযোগহীন, আলাদা datagram, হারাতে বা ক্রম বদলাতে পারে, কিন্তু দ্রুত ও হালকা।
  • listen আর accept-এর কাজ? — listen socket-কে অপেক্ষার অবস্থায় নেয় ও সারি বানায়; accept সারি থেকে একটি সংযোগ নিয়ে সেই client-এর জন্য নতুন socket দেয়।
  • একটি recv কি পুরো বার্তা দেয়? — না; TCP stream, তাই কম বা বেশি আসতে পারে। লুপ আর framing (newline বা length prefix) লাগে।
  • htons কেন? — port আর ঠিকানা network byte order (big-endian)-এ পাঠাতে হয়, আর বেশিরভাগ CPU little-endian.
  • Asio-র io_context কী? — event loop; async কাজ শেষ হলে run() ডাকা thread-এ completion handler বা coroutine আবার চালায়।

১০ · AI-এর যুগে — “আমার মেশিনে চলে” নেটওয়ার্ক কোড

AI-এর লেখা নেটওয়ার্ক কোড প্রায় সবসময় loopback-এ প্রথম চেষ্টাতেই চলে — ঠিক সেজন্যই বিপজ্জনক। loopback-এ partial read প্রায় হয় না, ধীর client নেই, আক্রমণকারী নেই; ভুলগুলো ধরা পড়ে ব্যবহারকারীর ফোনে। সাধারণ ভুলগুলো:

AI যা করেসমস্যাযা হওয়া উচিত
একটি recv-কে একটি বার্তা ধরেঅর্ধেক বা জোড়া লাগা বার্তা — মাঝে মাঝে ভুল উত্তরpending buffer আর framing
send-এর ফেরত মান দেখে নাpartial send-এ ডেটার শেষ অংশ হারায়send_all লুপ, বা Asio-র async_write
buf[n] = '\0', যেখানে n পুরো buffer-এর সমান হতে পারেএকটি byte বাইরে লেখা — buffer overflowstd::string(buf, n), সীমা যাচাই
কাঁচা int fd, ভুলের পথে close নেইfd leak — কয়েক দিন পর EMFILEmove-only RAII socket
Asio handler-এ sleep বা blocking কাজপুরো io_context — সব client — থেমে যায়steady_timer, আলাদা thread pool
TLS-এ verify_noneman-in-the-middle-এর দরজা খোলাverify_peer আর hostname যাচাই
io_service, পুরোনো Asio APIআধুনিক Boost-এ compile হয় নাio_context, coroutine বা আধুনিক handler
দৈর্ঘ্যের prefix যাচাই ছাড়াই new char[len]মেমরি ফুরানো বা overflow — সহজ DoSসর্বোচ্চ সীমা, std::vector

AI-কে নেটওয়ার্ক কোড লিখতে বলার সময় নিয়মগুলো আগেই দিয়ে দিন, আর নিজের কোডের দুর্বলতা নিজেকেই খুঁজতে বলুন:

আমি C++23 শিখছি (g++, -Wall -Wextra, Linux). একটি ছোট TCP সার্ভার লেখো: client দোকানের পণ্যের নাম লাইন হিসেবে পাঠাবে (যেমন CHAL), সার্ভার দাম ফেরত দেবে। নিয়ম: ১. socket একটি move-only RAII class-এ থাকবে; কোথাও হাতে close লিখবে না। ২. প্রতিটি system call-এর ফেরত মান দেখবে; ভুল হলে std::system_error ছুড়বে। ৩. send সবসময় লুপে (partial send); recv-এর ডেটা একটি pending buffer-এ জমিয়ে newline দিয়ে বার্তা কাটবে — একটি recv-কে একটি বার্তা ধরবে না। ৪. লাইনের সর্বোচ্চ দৈর্ঘ্য ২৫৬ byte; বেশি হলে সংযোগ বন্ধ। প্রতিটি client-এ ৩০ সেকেন্ডের read timeout. ৫. অনেক client-এর জন্য std::jthread, সর্বোচ্চ ১০০টি সংযোগ। ৬. Asio ব্যবহার করলে io_context (io_service নয়), আর কোনো handler-এ blocking কাজ নয়। কোডের পরে একটি তালিকা দাও: একজন আক্রমণকারী বা ধীর client এই সার্ভারকে কোন কোন উপায়ে বিপদে ফেলতে পারে, আর কোডের কোন লাইন প্রতিটি ঠেকাচ্ছে।

AI-এর নেটওয়ার্ক কোড যাচাইয়ের চেকলিস্ট

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

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন। এগুলোই আপনাকে কোড-লেখক থেকে সিস্টেম-চিন্তক বানাবে।

প্র ০১১৯৮৩ সালের 4.2BSD-র Berkeley sockets API কীভাবে প্রায় সব OS আর ভাষার নেটওয়ার্কিংয়ের ভিত্তি হয়ে গেল? এর নকশার কোন সিদ্ধান্ত আজও আমাদের ভোগায়?

১৯৮০-র দশকের শুরুতে আমেরিকার প্রতিরক্ষা গবেষণা সংস্থা DARPA চাইছিল ARPANET-এর নতুন protocol TCP/IP একটি সহজলভ্য OS-এ চলুক। ক্যালিফোর্নিয়া বিশ্ববিদ্যালয়, বার্কলির Computer Systems Research Group তখন Unix-এর নিজস্ব সংস্করণ BSD বানাত। ১৯৮৩ সালে প্রকাশিত 4.2BSD-তে তারা TCP/IP-কে kernel-এর ভেতরে বসাল, আর প্রোগ্রামারের জন্য দিল একটি নতুন API — socket. একই বছর ARPANET আনুষ্ঠানিকভাবে TCP/IP-তে চলে যায়, তাই ইন্টারনেটের প্রথম প্রজন্মের প্রায় সব সার্ভার প্রোগ্রাম এই API-তেই লেখা হয়।

নকশাটির মূল ভাবনা Unix-এর দর্শন থেকে: “সবকিছুই ফাইল”। একটি socket পায় একটি file descriptor, আর তাতে read/write চলে, ঠিক ফাইলের মতো। এর সুবিধা বিশাল — একই select দিয়ে ফাইল, টার্মিনাল আর নেটওয়ার্ক একসাথে দেখা যায়। API-টি protocol-নিরপেক্ষও: socket(AF_INET, SOCK_STREAM, 0)-এ address family আর socket-এর ধরন আলাদা, তাই পরে IPv6 (AF_INET6) বা Unix domain socket যোগ করতে API বদলাতে হয়নি। পরে Microsoft-এর Winsock (১৯৯০-এর দশকের শুরু) প্রায় হুবহু এটি নিল, আর POSIX একে standard করল।

কিন্তু কিছু সিদ্ধান্ত আজও ভোগায়। sockaddr-এর generic pointer আর cast — টাইপ-নিরাপত্তা নেই, ভুল দৈর্ঘ্য দিলে কম্পাইলার কিছু বলে না। ভুল জানানো হয় -1 আর global errno দিয়ে, যা ভুলে যাওয়া সহজ। send/recv partial ফল দেয়, অথচ নাম দেখে মনে হয় পুরোটা পাঠায়। ডিফল্টে blocking, আর SIGPIPE-এর মতো চমক। আর সবচেয়ে বড় কথা, ১৯৮৩ সালে কেউ ভাবেনি একটি সার্ভারে দশ হাজার সংযোগ থাকবে — সেই সমস্যা মেটাতে পরে epoll, kqueue আর IOCP-র মতো আলাদা, অ-সমন্বিত ব্যবস্থা এসেছে।

মূল উপলব্ধি: একটি API একবার সর্বজনীন হয়ে গেলে তার ভুলসহই টিকে থাকে, কারণ লক্ষ লক্ষ প্রোগ্রাম তার ওপর নির্ভর করে। C++-এর কাজ তাই API বদলানো নয়, বরং RAII, exception আর টাইপ দিয়ে তার ধারালো কোণগুলো ঢেকে দেওয়া।

প্র ০২Python, Java, Go-র standard লাইব্রেরিতে networking আছে, অথচ C++-এ ২০২৬ সালেও নেই। Networking TS-এর কী হলো, আর executor বিতর্ক কীভাবে এতে জড়িয়ে গেল?

প্রস্তাবের অভাব ছিল না। Asio-র লেখক Christopher Kohlhoff ২০০০-এর দশকের মাঝামাঝি থেকেই Asio-ভিত্তিক একটি networking লাইব্রেরি standard-এর জন্য প্রস্তাব করেন। বহু বছরের সংশোধনের পর এটি ২০১৮ সালে ISO-র একটি Technical Specification হিসেবে প্রকাশিত হয় — সাধারণত “Networking TS” বলা হয়। TS মানে একটি পরীক্ষামূলক ধাপ: লাইব্রেরি প্রস্তুতকারকেরা বাস্তবায়ন করে দেখবেন, তারপর মূল standard-এ নেওয়া হবে।

বাধা এল একটি মৌলিক প্রশ্ন থেকে: async কাজ কোথায় আর কীভাবে চলবে, তার সাধারণ ভাষা — executor — কী হবে? Networking TS-এর নিজস্ব executor মডেল ছিল Asio থেকে নেওয়া। কিন্তু একই সময়ে কমিটি চাইছিল একটি সর্বজনীন মডেল, যা নেটওয়ার্ক ছাড়াও thread pool, GPU, parallel algorithm সব জায়গায় চলবে। কয়েক বছর ধরে একাধিক প্রস্তাব (যেমন P0443 “unified executors”) নিয়ে আলোচনা চলে, আর শেষে ২০২১ সালের দিকে কমিটির সংশ্লিষ্ট দল senders/receivers (P2300) মডেলকে ভবিষ্যতের পথ হিসেবে বেছে নেয়। Networking TS-এর নকশা সেই মডেলের সাথে মেলেনি; তাকে নতুন করে সাজাতে হতো। senders/receivers C++26-এ std::execution হিসেবে এসেছে, কিন্তু তার ওপর গড়া networking এখনো প্রস্তাবের পর্যায়ে।

আরও যুক্তি ছিল: TLS ছাড়া আজকের networking লাইব্রেরি অর্ধেক কাজ, অথচ নিরাপত্তা-লাইব্রেরি standard-এ রাখলে প্রতিটি ত্রুটি সারাতে compiler আপডেট লাগবে। আবার package manager-এর যুগে (vcpkg, Conan) Asio যোগ করা এমনিতেই সহজ।

মূল উপলব্ধি: standard লাইব্রেরিতে কিছু ঢোকানো মানে কয়েক দশকের জন্য প্রতিশ্রুতি — তাই কমিটি ভিত্তি (executor) ঠিক না করে ওপরের তলা (networking) বানাতে চায়নি। ফলাফল বাস্তবে একটাই: আজ C++-এ নেটওয়ার্ক মানে OS API বা Asio-র মতো লাইব্রেরি।

প্র ০৩Christopher Kohlhoff-এর Asio (২০০৩) কীভাবে Boost.Asio হয়ে C++ নেটওয়ার্কিংয়ের অঘোষিত standard হলো? “proactor” নকশা বলতে কী বোঝায়?

অস্ট্রেলীয় প্রোগ্রামার Christopher Kohlhoff ২০০৩ সালের দিকে Asio লেখা শুরু করেন — নামটি “asynchronous input/output”-এর সংক্ষেপ। লক্ষ্য ছিল একটি portable C++ লাইব্রেরি, যা Linux, Windows, BSD সব জায়গায় একই কোডে নেটওয়ার্ক আর অন্যান্য async I/O চালাবে, আর ভেতরে প্রতিটি OS-এর সবচেয়ে দক্ষ ব্যবস্থা ব্যবহার করবে। ২০০৫ সালের শেষ দিকে Boost-এর রিভিউতে এটি গৃহীত হয়, আর Boost 1.35 (২০০৮)-এ Boost.Asio হিসেবে প্রথম প্রকাশিত হয়। Boost-এর ছাপ মানে পর্যালোচিত, পরীক্ষিত কোড, তাই দ্রুত শিল্পে ছড়িয়ে পড়ে। Kohlhoff Boost-নির্ভরতা ছাড়া standalone সংস্করণও রক্ষণাবেক্ষণ করেন।

নকশার মূল ধারণাটি “proactor” প্যাটার্ন। দুটি পদ্ধতির পার্থক্য দেখুন। Reactor (epoll-এর স্বাভাবিক রূপ) জানায়: এই socket এখন পড়ার জন্য তৈরি, এবার প্রোগ্রাম নিজে পড়বে। Proactor-এ প্রোগ্রাম আগেই বলে রাখে কোন buffer-এ পড়তে হবে; OS পড়া শেষ করে তারপর জানায় — ফল তৈরি। Windows-এর IOCP স্বভাবতই proactor. Asio আপনাকে সবখানে proactor-এর ইন্টারফেস দেয় — async_read(socket, buffer, handler) — আর Linux-এ ভেতরে epoll-এর reactor দিয়ে সেটি বানিয়ে নেয়। ফলে আপনার কোড একই থাকে, OS বদলায়।

Asio-র আরেকটি দূরদর্শী সিদ্ধান্ত ছিল “completion token” — একই async_read callback, future বা coroutine — যেকোনো রূপে ফল দিতে পারে। এজন্যই C++20 coroutine আসার পর Asio-তে use_awaitable যোগ করা সহজ হয়েছে, পুরোনো কোড না ভেঙে। standard না হয়েও এটি C++ নেটওয়ার্কিংয়ের সাধারণ ভাষা — আর Networking TS-এর ভিত্তিও ছিল এটিই।

মূল উপলব্ধি: ভালো abstraction OS-গুলোর পার্থক্য লুকায়, কিন্তু খরচ লুকায় না — Asio-তেও আপনাকে জীবনকাল, buffer-এর সীমা আর “handler-এ blocking নয়” নিয়ম মানতে হয়। লাইব্রেরি শেখা মানে তার নকশার কারণ শেখা।

প্র ০৪Dan Kegel-এর ১৯৯৯ সালের “C10K problem” কী ছিল, আর তার উত্তরে epoll, kqueue আর IOCP কীভাবে সার্ভার লেখার ধরন বদলে দিল?

১৯৯৯ সালে ইন্টারনেট দ্রুত বাড়ছিল, আর হার্ডওয়্যার যথেষ্ট শক্তিশালী হয়ে উঠছিল — তবু সার্ভারগুলো কয়েক হাজার একসাথে সংযোগে হাঁপিয়ে উঠত। Dan Kegel তাঁর ওয়েবপেজে প্রশ্নটি স্পষ্টভাবে লেখেন: একটি সাধারণ সার্ভার কেন দশ হাজার client (10K connections) একসাথে সামলাতে পারবে না? তিনি দেখান, সমস্যা হার্ডওয়্যারে নয়, সফটওয়্যারের নকশায় আর OS-এর API-তে।

সমস্যার দুই উৎস ছিল। প্রথমত, client-প্রতি process বা thread: প্রতিটির মেমরি, OS-এর হিসাব আর context switch-এর খরচ হাজারো গুণ হয়ে যায়। দ্বিতীয়ত, event loop লিখতে গেলে তখনকার হাতিয়ার ছিল select আর poll. প্রতিবার ডাকার সময় পুরো socket-তালিকা kernel-এ পাঠাতে হতো, আর kernel প্রতিটি socket ঘেঁটে দেখত — দশ হাজার socket-এর মধ্যে মাত্র পাঁচটিতে ডেটা এলেও। অর্থাৎ খরচ সংযোগের সংখ্যার সমানুপাতিক, সক্রিয় সংযোগের নয়। select-এ আবার fd-এর সংখ্যার একটি স্থির সীমাও ছিল (প্রায়ই ১০২৪)।

উত্তর এল OS-গুলো থেকে, প্রত্যেকের নিজের মতো করে। FreeBSD ২০০০ সালে আনল kqueue; Linux-এ epoll যোগ হলো ২০০২ সালের দিকে, আর 2.6 সংস্করণ থেকে সাধারণ ব্যবহারে এল। মূল ভাবনা: আগ্রহের তালিকা একবারই kernel-এ নথিভুক্ত হয়, আর প্রতিটি ডাক ফেরত দেয় শুধু তৈরি socket-গুলো — খরচ সক্রিয় সংযোগের সমানুপাতিক। Windows আরও আগে থেকেই, NT-এর সময় থেকে, IOCP-র proactor মডেল দিত। এই ভিত্তির ওপর গড়ে উঠল nginx, Node.js-এর libuv, Redis আর Asio — সবই event loop-ভিত্তিক। আজ আলোচনায় আছে Linux-এর io_uring-এর মতো আরও নতুন ব্যবস্থা।

মূল উপলব্ধি: স্কেলের সমস্যা প্রায়ই অ্যালগরিদমের সমস্যা — “প্রতিবার সব দেখো” থেকে “শুধু যা বদলেছে তা জানাও”-তে যাওয়া। এজন্যই আধুনিক সার্ভার অপেক্ষার জন্য thread খরচ করে না।

প্র ০৫নেটওয়ার্কে সংখ্যা big-endian ক্রমে যায় কেন, আর htons/ntohs ভুলে গেলে কী ধরনের বাগ হয়? “endian” নামটাই বা এল কোথা থেকে?

একটি ১৬ বিটের সংখ্যা, ধরুন port 5555, মেমরিতে দুটি byte: 0x15 আর 0xB3. কোনটি আগে রাখা হবে? বড় ঘরেরটি আগে রাখলে big-endian, ছোট ঘরেরটি আগে রাখলে little-endian. দুটিই যুক্তিসংগত, আর ইতিহাসে দুই শিবিরই ছিল: IBM-এর মেইনফ্রেম, Motorola 68000 বা SPARC ছিল big-endian; Intel x86 আর DEC-এর কিছু যন্ত্র little-endian. আজ x86 আর বেশিরভাগ ARM ব্যবস্থা little-endian-এ চলে।

নামটি এসেছে Jonathan Swift-এর ১৭২৬ সালের Gulliver's Travels থেকে — যেখানে দুই রাজ্য যুদ্ধ করে ডিম বড় প্রান্ত (big end) থেকে ভাঙা হবে, না ছোট প্রান্ত থেকে, তা নিয়ে। ১৯৮০ সালে Danny Cohen “On Holy Wars and a Plea for Peace” নামের একটি লেখায় এই নাম byte-ক্রমের বিতর্কে ব্যবহার করেন, আর তাঁর মূল বক্তব্য ছিল: কোনটি “ভালো” তা নিয়ে যুদ্ধ অর্থহীন, দরকার একটি চুক্তি। ইন্টারনেট protocol-গুলো (১৯৮১ সালের IP-র বিবরণসহ) সেই চুক্তি করে big-endian-এ — একে বলা হয় network byte order. প্রচলিত ব্যাখ্যা হলো, এটি মানুষের লেখার ক্রমের মতো (বড় অঙ্ক আগে), সেই সময়ের অনেক নেটওয়ার্ক যন্ত্র big-endian ছিল, আর router ঠিকানার বড় অংশ আগে পড়ে পথ ঠিক করা শুরু করতে পারে।

ভুলে গেলে বাগটি বিশেষভাবে চতুর। x86 ল্যাপটপে sin_port = 5555 লিখলে (htons ছাড়া) byte দুটি উল্টে যায় — সার্ভার আসলে বসে port 45845-এ (0xB315). একই ভুল client-এও থাকলে দুজনেই সেই ভুল port-এ মিলে যায় — প্রোগ্রাম “চলে”! সমস্যা ধরা পড়ে কেবল যখন অন্য কোনো প্রোগ্রাম, অন্য ভাষার client বা big-endian যন্ত্র যোগ দেয়। দৈর্ঘ্যের prefix-এ একই ভুল হলে ৫ byte-এর বার্তার দৈর্ঘ্য পড়া হয় প্রায় ৮ কোটি ৩৯ লাখ — যাচাই না থাকলে বিশাল মেমরি বরাদ্দ। নিয়ম: তারে যা যায়, তা সবসময় নির্দিষ্ট ক্রমে লিখুন আর পড়ুন — htons/htonl, ntohs/ntohl, বা C++23-এর std::byteswap আর C++20-এর std::endian দিয়ে — আর কখনো একটি struct সরাসরি send করবেন না, কারণ padding আর byte-ক্রম দুটোই যন্ত্রভেদে আলাদা।

মূল উপলব্ধি: একই যন্ত্রে দুই প্রান্ত একই ভুল করলে বাগ লুকিয়ে থাকে। protocol মানে লিখিত চুক্তি — প্রতিটি byte-এর ক্রম আর অর্থ সেখানে নির্দিষ্ট থাকতে হবে, “আমার কম্পিউটার যা করে” নয়।

অনুশীলন

বেশিরভাগ কাজে কোড লিখতে হবে না — আগে অনুমান করুন, তারপর উত্তর মিলিয়ে নিন।

  1. আচরণ অনুমান: একটি client দ্রুত পরপর send(fd, "CHAL\n") আর send(fd, "DAL\n") করল। সার্ভার প্রতিবার একটি recv(fd, buf, 64, 0) করে পুরো ফলকে একটি পণ্যের নাম ধরে। কী কী ঘটতে পারে?

    অন্তত তিনটি সম্ভাবনা, আর কোনটি ঘটবে তা নেটওয়ার্কের ওপর নির্ভর করে। (১) সব ঠিক — দুটি আলাদা recv-এ দুটি বার্তা (loopback-এ প্রায়ই এটাই দেখবেন)। (২) একটি recv-এ "CHAL\nDAL\n" একসাথে — সার্ভার এই পুরো লেখাকে একটি নাম ভেবে “unknown item” বলে, আর client দ্বিতীয় উত্তরের অপেক্ষায় আটকে থাকে। (৩) অর্ধেক — প্রথম recv-এ "CHA", পরেরটিতে "L\nDAL\n". সমাধান: pending buffer আর newline পর্যন্ত কাটা (অনুচ্ছেদ ৫)।

  2. বাগ শিকার: একটি AI এই সার্ভার-লুপ দিয়েছে — int c = accept(s, 0, 0); char buf[256]; int n = recv(c, buf, 256, 0); buf[n] = 0; if (strcmp(buf, "QUIT") == 0) return; send(c, reply, strlen(reply), 0); close(c);. কয়টি সমস্যা খুঁজে পান?

    অন্তত পাঁচটি। (১) accept আর recv-এর -1 দেখা হয়নি; n == -1 হলে buf[-1]-এ লেখা — undefined behavior. (২) n == 256 হলে buf[256] = 0 buffer-এর বাইরে এক byte লেখে — buffer overflow; পড়া উচিত sizeof buf - 1 পর্যন্ত, বা ভালো, std::string(buf, n). (৩) QUIT-এর পথে return-এর আগে close(c) নেই — fd leak; RAII socket এটি অসম্ভব করে। (৪) একটি recv-কে পুরো বার্তা ধরা, আর newline-সহ "QUIT\n" এলে strcmp মিলবে না — framing নেই। (৫) send-এর ফেরত মান দেখা হয়নি — partial send. সাথে timeout নেই, তাই চুপ থাকা client সার্ভার আটকে রাখে।

  3. সঠিক নকশা বেছে নিন: কারণসহ বলুন — (ক) একটি অফিসের ভেতরের টুল, সর্বোচ্চ ২০ জন ব্যবহারকারী (খ) ৫০ হাজার IoT সেন্সর প্রতি মিনিটে একটি ছোট মাপ পাঠায় (গ) একটি অনলাইন গেম প্রতি সেকেন্ডে ৩০ বার খেলোয়াড়ের অবস্থান পাঠায় (ঘ) একটি অ্যাপ একটি পেমেন্ট সেবার REST API ডাকে।

    (ক) client-প্রতি std::jthread — কোড সরল, ২০টি thread কোনো সমস্যা নয়। (খ) event-ভিত্তিক — Asio (coroutine-সহ) বা epoll; ৫০ হাজার thread অবাস্তব, আর বেশিরভাগ সংযোগ বেশিরভাগ সময় চুপ। (গ) UDP — পুরোনো অবস্থান দেরিতে পৌঁছালে তা আর কাজের নয়; হারানো একটি প্যাকেট আবার পাঠানোর চেয়ে পরেরটি পাঠানো ভালো। TCP-তে একটি হারানো packet পরের সবগুলোকে আটকে রাখত। (ঘ) নিজের socket নয় — libcurl, cpr বা Boost.Beast দিয়ে HTTPS, certificate যাচাই চালু, timeout সহ; আর retry শুধু idempotency key দিয়ে, যাতে টাকা দুবার না কাটে।

  4. নিজে লিখুন (কোড): উপরের line_framing.cpp-এর মতো একটি take_frames ফাংশন লিখুন, যা newline-এর বদলে length prefix ব্যবহার করে: প্রতিটি বার্তার আগে ৪ byte-এ big-endian দৈর্ঘ্য। সর্বোচ্চ বার্তা ১০২৪ byte; বেশি হলে exception.

    মূল লুপ: যতক্ষণ pending.size() >= 4, প্রথম ৪ byte থেকে দৈর্ঘ্য বানান — len = (b0 << 24) | (b1 << 16) | (b2 << 8) | b3, যেখানে প্রতিটি byte আগে static_cast<unsigned char> করা (নইলে signed char-এর ঋণাত্মক মান সব নষ্ট করবে)। তারপর সাথে সাথে যাচাই: if (len > 1024) throw std::length_error("frame too long"); — পুরো বার্তা আসার অপেক্ষা না করে। pending.size() < 4 + len হলে লুপ থামান (বার্তা এখনো অসম্পূর্ণ); নইলে pending.substr(4, len) বার্তা হিসেবে নিন আর pending.erase(0, 4 + len). টেস্ট করুন টুকরোগুলো দৈর্ঘ্যের ৪ byte-এর মাঝখানে ভেঙে — যেমন প্রথম টুকরোয় শুধু ২ byte. byte-গুলো হাতে shift করে বানানোয় এই কোড যেকোনো CPU-তে একই ফল দেয়, ntohl-এরও দরকার নেই।

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

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

ক্রমএকটি TCP সার্ভারে POSIX socket ডাকগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. socket() — নতুন socket তৈরি
  2. bind() — IP আর port-এ বাঁধা
  3. listen() — সংযোগের সারি চালু
  4. accept() — একজন client-এর জন্য নতুন socket
  5. recv()/send() — কথা বলা
  6. close() — সংযোগ বন্ধ

একটি TCP client একবার send দিয়ে ১০০ byte পাঠাল। সার্ভারের প্রথম recv(fd, buf, 1000, 0) ফেরত দিল ৪০। এর সঠিক ব্যাখ্যা কোনটি?

  • বাকি ৬০ byte হারিয়ে গেছে, client-কে আবার পাঠাতে হবে
  • এটি একটি ভুল; recv-এর উচিত ছিল -1 ফেরত দেওয়া
  • স্বাভাবিক — TCP একটি byte stream; বাকিটা পরের recv-এ আসবে
  • সার্ভারের buffer খুব ছোট ছিল

নেটওয়ার্কে একটি কম্পিউটারের ভেতরে কোন প্রোগ্রাম ডেটা পাবে, তা কী দিয়ে ঠিক হয়?

  • IP address
  • port নম্বর
  • DNS নাম
  • MAC address

C++-এ একটি file descriptor মোড়ানো RAII Socket class-এর কপি constructor কেন delete করা উচিত?

  • কপি করা খুব ধীর, তাই
  • OS একই fd দুবার ব্যবহার করতে দেয় না
  • compiler এমনিতেই কপি করতে দেয় না
  • দুই object একই fd বন্ধ করবে — double close, এমনকি অন্যের সংযোগ বন্ধ

Asio-তে একটি async_read-এর completion handler কোন thread-এ চলে?

  • যে thread io_context::run() ডাকছে, সেখানে
  • প্রতিটি async কাজের জন্য Asio-র খোলা একটি নতুন thread-এ
  • async_read ডাকার মুহূর্তেই, একই লাইনে
  • OS-এর kernel-এর ভেতরে

POSIX socket-এ addr.sin_port = htons(5555); — এখানে htons কেন লাগে?

  • port নম্বর encrypt করতে
  • ৩২ বিটের সংখ্যাকে ১৬ বিটে ছোট করতে
  • host-এর byte ক্রম থেকে network byte order (big-endian)-এ সাজাতে
  • port নম্বরটি বৈধ কি না যাচাই করতে

একটি length-prefixed protocol-এর সার্ভার একটি বার্তার শুরুতে দৈর্ঘ্য পেল ৪০০ কোটি byte, অথচ protocol-এর সর্বোচ্চ সীমা ৬৪ KB. সার্ভারের কী করা উচিত?

  • ততটা মেমরি বরাদ্দ করে পুরো বার্তা পড়া
  • বার্তাটি প্রত্যাখ্যান করে সংযোগ বন্ধ করা
  • প্রথম ৬৪ KB পড়ে বাকিটা উপেক্ষা করা
  • দৈর্ঘ্যটি ঋণাত্মক ধরে উল্টো দিকে পড়া

শব্দকোষ

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

মনে রাখুন

  1. IP যন্ত্র খোঁজে, port প্রোগ্রাম খোঁজে; TCP নির্ভরযোগ্য byte stream, UDP দ্রুত কিন্তু নিশ্চয়তাহীন datagram. C++-এ standard networking নেই — OS API বা Asio.
  2. server: socket → bind → listen → accept → recv/send → close; client: socket → connect → send/recv → close. accept প্রতিটি client-এর জন্য নতুন socket দেয়।
  3. socket একটি move-only RAII class-এ রাখুন, ভুল জানান std::system_error দিয়ে — কোনো পথেই fd leak হবে না।
  4. একটি recv ≠ একটি বার্তা। লুপে পাঠান ও পড়ুন, newline বা length prefix দিয়ে framing করুন, প্রতিটি আকারের সীমা আর প্রতিটি অপেক্ষার timeout রাখুন।
  5. অনেক client-এ event loop (epoll/kqueue/IOCP, বা Asio); Asio-তে handler কখনো blocking নয়, coroutine দিয়ে কোড সরল রাখুন, আর TLS যাচাই কখনো বন্ধ নয়।

পরের পর্বে: এই পর্বের সার্ভারকে আসল প্রজেক্টে রূপ দিতে লাগবে কয়েকটি ফাইল, Asio-র মতো বাইরের লাইব্রেরি আর একাধিক প্ল্যাটফর্মে build — সেই কাজের যন্ত্র CMake: target, লাইব্রেরি যোগ করা আর পরিষ্কার build ব্যবস্থা।

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

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