Network Computing · แล็ปโน้ต 1 จาก 5

แล็ป Network Programming — จาก TCP/UDP สู่โปรแกรมจริง

เราจะไม่ได้หยุดแค่เขียนโปรแกรมให้เชื่อมต่อกันได้ แต่จะเปิดดูสิ่งที่เกิดขึ้น ทำให้ข้อความขาด ทำให้ client หลุด และใช้หลักฐานอธิบายว่าทำไมโปรแกรมจึงมีพฤติกรรมเช่นนั้น

🔗
ต่อยอดจาก: OSI L4 Transport Layer (TCP/UDP, port, sliding window) และ Socket API พื้นฐาน — บทความนี้ขยายจากทฤษฎีไปสู่การเขียนโปรแกรม socket จริงแบบละเอียด พร้อม pitfall ที่พบบ่อย

1Socket คือจุดเชื่อมระหว่าง Application กับ Transport Layer

นิยาม
Socket คือ endpoint ของการสื่อสารสองทิศทาง (bidirectional communication endpoint) ที่ระบบปฏิบัติการมอบให้ application ใช้เรียกบริการของ transport layer (TCP/UDP) โดยไม่ต้องรู้รายละเอียดของ protocol stack ด้านล่างเลย — เป็น abstraction ที่ซ่อน sliding window, retransmission, congestion control ของ TCP ไว้ทั้งหมด application เห็นแค่ "ส่งได้ อ่านได้"

ในเชิงระบบปฏิบัติการ socket คือ file descriptor ชนิดหนึ่ง — เขียน/อ่านผ่าน socket คล้ายเขียน/อ่านไฟล์ แต่ข้อมูลวิ่งผ่านเครือข่ายแทนที่จะเขียนลงดิสก์ การระบุ socket หนึ่งตัวให้ไม่ซ้ำกับตัวอื่นในเครื่องใช้ 5-tuple:

5-Tuple ที่ระบุ TCP connection
(protocol, local IP, local port, remote IP, remote port)
เปลี่ยนค่าใดค่าหนึ่งก็เป็นคนละ connection ทันที — เหตุผลที่ server ตัวเดียวรับ connection พร้อมกันจากหลาย client ที่ port เดียวกันได้โดยไม่ปนกัน

พารามิเตอร์หลักตอนสร้าง socket: address family (AF_INET = IPv4, AF_INET6 = IPv6) และ socket type (SOCK_STREAM = TCP, SOCK_DGRAM = UDP) — สองค่านี้กำหนดว่า socket ที่ได้จะมีพฤติกรรมแบบ connection-oriented/reliable หรือ connectionless/best-effort

2Client–Server Model

เกือบทุกแอปพลิเคชันเครือข่ายตั้งอยู่บน asymmetry ระหว่างสองบทบาท:

ServerClient
ใครเริ่มก่อนรอ (passive open) — bind ที่ well-known port แล้วรอเริ่มเชื่อมต่อ (active open) — ต้องรู้ address+port ของ server ล่วงหน้า
อายุการทำงานรันตลอดเวลา (long-lived) รอรับหลาย connection ตามลำดับ/พร้อมกันมักสั้น — เชื่อม ทำงาน แล้วจบ
จำนวนมักมีตัวเดียว (หรือกลุ่มเล็กหลัง load balancer)มีได้จำนวนมาก เชื่อมต่อ server เดียวกันพร้อมกัน
ข้อควรระวัง
"Client" และ "Server" เป็นบทบาทของ connection ไม่ใช่คุณสมบัติถาวรของโปรแกรม — โปรแกรมเดียวกันเป็น server ในการเชื่อมต่อหนึ่ง และเป็น client ในอีกการเชื่อมต่อหนึ่งพร้อมกันได้ (แนวคิดนี้จะกลับมาสำคัญมากใน หัวข้อที่ 2 — Network Services)

3TCP Socket Lifecycle

ฝั่ง server และ client เดินผ่านลำดับ syscall ที่ต่างกัน:

ฝั่ง Server
socket() → bind() → listen() → accept() → recv()/send() (วน) → close()
ฝั่ง Client
socket() → connect() → send()/recv() (วน) → close()
Syscallใช้ที่ฝั่งหน้าที่
socket()ทั้งคู่สร้าง socket descriptor ใหม่ ยังไม่ผูกกับ address ใดๆ
bind()Server (ปกติ)ผูก socket เข้ากับ (IP, port) ที่ระบุ — ทำให้ client รู้ว่าจะเชื่อมมาที่ไหน
listen()Serverเปลี่ยน socket ให้เป็น passive/listening socket พร้อมกำหนดขนาด backlog queue (คิว connection ที่รอ accept)
accept()Serverบล็อกรอจนมี connection ใหม่เข้าคิว แล้วคืน socket ใหม่ สำหรับคุยกับ client ตัวนั้นโดยเฉพาะ
connect()Clientเริ่ม TCP 3-way handshake ไปยัง server ที่ระบุ บล็อกจนเชื่อมสำเร็จหรือ error
send() / recv()ทั้งคู่ส่ง/อ่านข้อมูลผ่าน socket ที่เชื่อมต่อแล้ว
close()ทั้งคู่ปิด socket เริ่มกระบวนการ TCP 4-way close
จุดที่นักศึกษาสับสนบ่อยที่สุด
accept() ไม่คืน socket ตัวเดิมที่ listen() ไว้ — มันสร้างsocket ใหม่เฉพาะสำหรับคุยกับ client รายนั้น ส่วน listening socket ตัวเดิมยังว่างพร้อมรับ connection รายถัดไปต่อทันที นี่คือกลไกที่ทำให้ server ตัวเดียวรับหลาย client พร้อมกันได้ที่ port เดียวกัน
TCP Echo Server/Client ขั้นต่ำ (Python)
# echo_server.py
import socket

srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("0.0.0.0", 9000))
srv.listen(5)                      # backlog = 5
print("listening on :9000")

while True:
    conn, addr = srv.accept()      # บล็อกจนมี client ใหม่
    print("connected:", addr)
    while True:
        data = conn.recv(4096)
        if not data:                # b"" = client ปิด connection แล้ว
            break
        conn.sendall(data)          # echo กลับ
    conn.close()
# echo_client.py
import socket

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(("127.0.0.1", 9000))
s.sendall(b"hello server")
print(s.recv(4096))
s.close()

4TCP เป็น Byte Stream ไม่ใช่ Message

คุณสมบัติที่ต้องจำให้แม่น
TCP รับประกันว่าไบต์ที่ส่งจะมาถึงปลายทางครบ เรียงลำดับ ไม่ซ้ำ — แต่ไม่รับประกันขอบเขตของ message ฝั่งส่งเรียก send() กี่ครั้งก็ไม่สัมพันธ์กับจำนวนครั้งที่ฝั่งรับต้องเรียก recv() เพื่อได้ข้อมูลชุดเดียวกัน

สาเหตุ: TCP stack อาจรวม (coalesce) ข้อมูลจากหลาย send() เข้า segment เดียว หรือแบ่ง (fragment) ข้อมูลจาก send() เดียวออกเป็นหลาย segment ตาม MSS/MTU/สภาพเครือข่าย — ทั้งหมดนี้เกิดโปร่งใสโดยที่ application ไม่รู้และควบคุมไม่ได้

สถานการณ์จริงที่ต้องเจอ
Client เรียก send(b"SET course NetworkComputing\n") แล้วเรียก send(b"GET course\n") ทันที — ฝั่ง server เรียก recv(4096) ครั้งเดียว อาจได้รับ b"SET course NetworkComputing\nGET course\n" ครบทั้งสองคำสั่งรวมกัน หรืออาจได้รับแค่ b"SET course Net" ก็ได้ — ทั้งสองกรณีถูกต้องตามสเปกของ TCP ทั้งคู่
ข้อค้นพบสำคัญของหัวข้อนี้
recv() ครั้งเดียวอาจไม่ได้ข้อความครบ — ถ้าโค้ด parse ข้อมูลจาก recv() ครั้งเดียวโดยสมมติว่าคือ 1 message เสมอ โปรแกรมจะพังแบบสุ่ม (ทำงานได้ตอนทดสอบใน localhost ที่ latency ต่ำ แต่พังตอน deploy จริงข้าม network ที่ packet มาไม่พร้อมกัน)

5Message Framing — ใครเป็นคนกำหนดขอบเขตข้อความ

เพราะ TCP ไม่ทำให้ ตัว application-layer protocol ที่เราออกแบบเองต้องกำหนดขอบเขต message เอง เรียกว่า framing — มี 3 เทคนิคหลัก:

เทคนิควิธีทำงานตัวอย่างที่ใช้จริง
Delimiter-basedใช้ตัวอักษรพิเศษ (เช่น \n) คั่นท้าย message แต่ละอัน อ่านไปเรื่อยจนเจอ delimiterSMTP, HTTP header, Redis inline command, protocol key-value ของ lab นี้
Length-prefixedส่งความยาว message (เช่น 4 byte integer) นำหน้า payload — ฝั่งรับอ่าน header ก่อนรู้ว่าต้องอ่าน payload กี่ byteProtobuf-based RPC (gRPC), HTTP/2 frame, TLS record
Fixed-lengthทุก message ยาวเท่ากันเป๊ะ ไม่ต้องมี header เลยระบบ real-time ที่ payload ขนาดคงที่ เช่น sensor reading
Delimiter-based framing (Python) — อ่านทีละบรรทัดให้ครบเสมอ
def recv_line(sock, buf=b""):
    """อ่านจาก socket จนกว่าจะเจอ \\n ครบหนึ่งบรรทัด คืน (line, buf_ที่เหลือ)"""
    while b"\n" not in buf:
        chunk = sock.recv(4096)
        if not chunk:
            raise ConnectionError("peer closed before line completed")
        buf += chunk
    line, _, rest = buf.partition(b"\n")
    return line, rest

สังเกตว่าฟังก์ชันนี้ วนเรียก recv() ซ้ำ จนกว่าจะเจอ delimiter ครบ — แก้ปัญหา "recv() ครั้งเดียวอาจไม่ได้ข้อความครบ" ได้โดยตรง และต้อง buffer ส่วนเกิน (rest) ไว้ใช้กับ message ถัดไปด้วย ไม่ทิ้งไป

Length-prefixed framing (Python) — อ่านให้ครบตามจำนวน byte ที่ระบุ
import struct

def recv_exact(sock, n):
    """อ่านให้ได้ครบ n byte เป๊ะ ไม่งั้น error"""
    data = b""
    while len(data) < n:
        chunk = sock.recv(n - len(data))
        if not chunk:
            raise ConnectionError("peer closed mid-message")
        data += chunk
    return data

def recv_framed(sock):
    header = recv_exact(sock, 4)              # 4-byte length prefix
    (length,) = struct.unpack("!I", header)
    return recv_exact(sock, length)

def send_framed(sock, payload: bytes):
    sock.sendall(struct.pack("!I", len(payload)) + payload)

6Serialization ด้วย JSON

Framing แก้ปัญหา "message เริ่ม/จบตรงไหน" ส่วน serialization แก้ปัญหาคนละชั้น: "โครงสร้างข้อมูลในโปรแกรม (dict, object, list) แปลงเป็นลำดับ byte ที่ส่งผ่านเครือข่ายได้อย่างไร แล้วแปลงกลับอย่างไร" — Presentation Layer พูดถึงแนวคิดนี้ในเชิงทฤษฎี (XDR, ASN.1) ส่วนในทางปฏิบัติปัจจุบัน JSON เป็นตัวเลือกที่นิยมที่สุดเพราะอ่านง่าย มีไลบรารีรองรับแทบทุกภาษา

รวม Framing + JSON เข้าด้วยกัน (Newline-Delimited JSON)
import json

def send_json_line(sock, obj: dict):
    line = json.dumps(obj) + "\n"
    sock.sendall(line.encode("utf-8"))

def recv_json_line(sock, buf=b""):
    line, buf = recv_line(sock, buf)           # ใช้ recv_line จากหัวข้อก่อน
    return json.loads(line.decode("utf-8")), buf

รูปแบบนี้เรียกว่า NDJSON — แต่ละบรรทัดคือ JSON object ที่สมบูรณ์ในตัวเอง ผสมข้อดีของ delimiter framing (ง่าย, debug ได้ด้วยตาเปล่า) กับความยืดหยุ่นของ JSON (โครงสร้างข้อมูลซับซ้อนได้)

ข้อจำกัดที่ควรรู้
ต้องมั่นใจว่า payload เองไม่มี \n ปนอยู่ (JSON string ที่ escape ถูกต้องจะไม่มีปัญหานี้) — ถ้า payload เป็น binary data ดิบ (เช่น รูปภาพ) ควรใช้ length-prefixed framing แทน delimiter-based เพราะ binary data อาจมี byte ที่ตรงกับ delimiter โดยบังเอิญ

7Blocking I/O และ Concurrent Server

Echo server ในหัวข้อ 3 มีข้อจำกัดใหญ่: accept() และ recv() เป็น blocking call — ขณะ server กำลังคุยกับ client รายหนึ่ง (ติดอยู่ใน loop recv()/send()) จะไม่กลับไปเรียก accept() รับ client รายใหม่ได้เลย client รายที่สองต้องรอจนรายแรกตัดการเชื่อมต่อ

โมเดลวิธีทำงานข้อดี / ข้อเสีย
Sequential (blocking เดี่ยว)รับ client ทีละราย เสร็จค่อยรับรายถัดไปง่ายที่สุด แต่รองรับ client พร้อมกันไม่ได้เลย — ใช้ได้แค่ demo
Thread-per-connectionทุกครั้งที่ accept() ได้ connection ใหม่ สร้าง thread แยกไปดูแล connection นั้น แล้ววนกลับไป accept() ต่อทันทีเขียนง่าย เข้าใจง่าย (โค้ดแต่ละ thread เขียนแบบ blocking ปกติ) แต่ thread จำนวนมากกินหน่วยความจำ/context-switch overhead เมื่อ client เยอะมาก
I/O Multiplexing / Async (select/epoll/asyncio)Thread เดียวคอยดูหลาย socket พร้อมกัน จัดการเฉพาะ socket ที่ "พร้อม" อ่าน/เขียนจริงๆscale ได้สูงมาก (หมื่น connection ด้วย thread เดียว) แต่โค้ดซับซ้อนกว่า ต้องคิดแบบ event-driven
Thread-per-connection Server (Python)
import socket, threading

def handle_client(conn, addr):
    print("connected:", addr)
    try:
        buf = b""
        while True:
            line, buf = recv_line(conn, buf)
            conn.sendall(b"echo: " + line + b"\n")
    except ConnectionError:
        pass
    finally:
        conn.close()
        print("disconnected:", addr)

srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("0.0.0.0", 9000))
srv.listen(50)

while True:
    conn, addr = srv.accept()
    t = threading.Thread(target=handle_client, args=(conn, addr), daemon=True)
    t.start()

8Timeout และการปิด Connection

ทำไมต้องมี Timeout
ถ้าไม่ตั้ง timeout ให้ socket recv()/accept()/connect() จะบล็อกไม่มีกำหนดเมื่อฝ่ายตรงข้ามค้าง/หายไปกลางทาง — thread หรือ process นั้นจะถูกยึดครองตลอดไป (resource leak) โดยเฉพาะอันตรายในโมเดล thread-per-connection ที่ thread ค้างสะสมจนกิน resource หมด
sock.settimeout(5.0)          # ให้เวลารอสูงสุด 5 วินาที
try:
    data = sock.recv(4096)
except socket.timeout:
    print("no response within 5s — treat as failure")
ตรวจจับว่า Client หลุดกลางทางอย่างไร
  • ปิดแบบสุภาพ (graceful) — client เรียก close() ปกติ ฝั่ง server จะเห็น recv() คืนค่า b"" (empty bytes) — ไม่ใช่ exception
  • หลุดกลางทาง (abrupt) — สาย network ขาด, process client ถูก kill, เครื่อง client ดับ — ฝั่ง server มักไม่รู้ทันที จนกว่าจะพยายาม send() แล้วได้ ConnectionResetError/BrokenPipeError หรือจนกว่า timeout จะหมดเวลา

Half-close: TCP รองรับการปิดทีละทิศทาง — sock.shutdown(socket.SHUT_WR) บอกอีกฝ่ายว่า "เราจะไม่ส่งอะไรเพิ่มแล้ว" แต่ยังอ่านข้อมูลที่อีกฝ่ายส่งมาต่อได้ ต่างจาก close() ที่ปิดทั้งสองทิศทางทันที มีประโยชน์เมื่อต้องส่ง request เสร็จแล้วรอ response ยาวๆ

9Lab: ออกแบบ Protocol สำหรับ Key–Value Server

รวมทุกหัวข้อข้างต้นเข้าด้วยกัน — ออกแบบ protocol ข้อความบรรทัดเดียว (delimiter-based framing ด้วย \n) สำหรับ key-value store อย่างง่าย:

คำสั่งรูปแบบตัวอย่าง Response
SETSET <key> <value>OK
GETGET <key>VALUE NetworkComputing หรือ NOT_FOUND
DELETEDELETE <key>OK หรือ NOT_FOUND
ตัวอย่าง session จริง
> SET course NetworkComputing
< OK
> GET course
< VALUE NetworkComputing
> DELETE course
< OK
> GET course
< NOT_FOUND
Key-Value Server เต็มรูปแบบ (Python, thread-per-connection)
import socket, threading

store = {}
lock = threading.Lock()

def handle(conn, addr):
    buf = b""
    try:
        while True:
            line, buf = recv_line(conn, buf)
            parts = line.decode("utf-8", errors="replace").split(" ", 2)
            cmd = parts[0].upper() if parts else ""

            if cmd == "SET" and len(parts) == 3:
                with lock:
                    store[parts[1]] = parts[2]
                resp = "OK"
            elif cmd == "GET" and len(parts) == 2:
                with lock:
                    val = store.get(parts[1])
                resp = f"VALUE {val}" if val is not None else "NOT_FOUND"
            elif cmd == "DELETE" and len(parts) == 2:
                with lock:
                    existed = store.pop(parts[1], None) is not None
                resp = "OK" if existed else "NOT_FOUND"
            else:
                resp = "ERROR unknown or malformed command"

            conn.sendall((resp + "\n").encode("utf-8"))
    except ConnectionError:
        pass
    finally:
        conn.close()

srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("0.0.0.0", 9000))
srv.listen(50)
print("key-value server on :9000")
while True:
    conn, addr = srv.accept()
    threading.Thread(target=handle, args=(conn, addr), daemon=True).start()
Edge case ที่ต้องออกแบบรองรับ (ไม่ใช่แค่ทำให้ demo รัน)
  • คำสั่งผิดรูปแบบ — เช่น SET key ไม่มี value, หรือ command ที่ไม่รู้จัก → ต้องตอบ error กลับไป ไม่ใช่ crash
  • Value ที่มีช่องว่างstr.split(" ", 2) ในตัวอย่างจำกัดจำนวนครั้งที่ split เพื่อให้ value เป็น "ส่วนที่เหลือทั้งหมด" ได้ (เช่น SET note hello world → value = "hello world")
  • Concurrent access ต่อ dict เดียวกัน — หลาย thread เขียน store พร้อมกันได้ ต้องมี lock ป้องกัน race condition
  • Client ปิดกลางคำสั่งrecv_line โยน ConnectionError เมื่อ peer ปิดก่อนได้ข้อความครบ ต้อง catch ไว้ไม่ให้ทั้ง server ล้ม

10แผนการทดลอง: ทำให้เห็นสิ่งที่ Socket ซ่อนไว้

เป้าหมายของแล็ป
เมื่อจบแล็ป นักศึกษาควรสร้าง TCP client/server ได้ ออกแบบขอบเขตข้อความได้ รองรับหลาย client ได้ และอธิบาย failure จากผลที่สังเกตได้ ไม่ใช่ตอบเพียงว่า “โค้ดรันได้” การส่งข้อมูลผ่าน TCP เปรียบเหมือนส่งน้ำผ่านท่อ: ผู้ส่งเทน้ำเป็นแก้ว แต่ปลายท่ออาจได้รับทีละครึ่งแก้วหรือหลายแก้วรวมกัน ท่อรับประกันลำดับของน้ำ ไม่ได้รับประกันรูปทรงของแก้ว โปรแกรมจึงต้องกำหนด framing เอง

เตรียมเครื่องมือและสมุดบันทึก

  1. ใช้ Python 3 สอง terminal โดย terminal แรกเป็น server และอีก terminal เป็น client ถ้ามีสองเครื่องในวง LAN เดียวกันให้ทดลองข้ามเครื่องหลังจาก localhost สำเร็จ
  2. ทุกครั้งให้บันทึกคำสั่ง เวลา IP/port ข้อความที่ส่ง ผลที่ได้ และข้อสรุป อย่าเก็บเฉพาะภาพหน้าจอ เพราะภาพบอกว่าเกิดอะไรขึ้นแต่ยังไม่บอกเหตุผล
  3. เพิ่ม log ก่อนและหลัง accept(), connect(), recv() และ sendall() พร้อมเวลาและ address เพื่อให้เรียงเหตุการณ์ย้อนหลังได้
  4. ถ้าใช้ Wireshark ให้กรอง tcp.port == 9000 ถ้าใช้ tcpdump ให้ใช้ tcpdump -i any -nn tcp port 9000 แล้วเปรียบเทียบ packet กับ log ของโปรแกรม

การทดลองที่ 1 — แยก listening socket ออกจาก connected socket

เริ่ม server ก่อน client และดูว่าโปรแกรมหยุดรอที่บรรทัดใด จากนั้นเชื่อม client สองตัวพร้อมกัน พิมพ์ค่า getsockname() และ getpeername() ของ socket ที่ได้จาก accept() แล้วเติมตารางต่อไปนี้

สิ่งที่สังเกตหลักฐานที่ต้องเก็บคำอธิบาย
server ใช้ local port 9000 เหมือนเดิมผล getsockname()port นี้เป็นประตูหน้าของร้าน
client แต่ละตัวมี source port ต่างกันผล getpeername()เหมือนลูกค้าแต่ละคนมีหมายเลขคิว จึงไม่ปนกันแม้เข้าประตูเดียว
accept() คืน socket ตัวใหม่พิมพ์ object หรือ file descriptorlistening socket รับแขก ส่วน connected socket สนทนากับแขกคนนั้น
ทำให้พังอย่างตั้งใจ
เปิด client ก่อน server จดชนิด error และเวลาที่ใช้ แล้วเปิด server แต่เปลี่ยน port ฝั่ง client เป็น 9001 เปรียบเทียบกับกรณี firewall ทิ้ง packet ซึ่งมักรอจน timeout ความต่างนี้สำคัญ: refused คล้ายไปถึงบ้านแล้วมีคนบอกว่าไม่มีใครรับ ส่วน timeout คล้ายเคาะประตูแล้วไม่มีเสียงตอบ เรารู้เพียงว่ายังไม่ได้คำตอบ ไม่รู้ว่าบ้านหายไปหรือทางเดินขาด

การทดลองที่ 2 — พิสูจน์ว่า TCP ไม่มีขอบเขตข้อความ

  1. ให้ client เรียก sendall(b"ONE"), sendall(b"TWO"), sendall(b"THREE") ติดกัน ส่วน server เรียก recv(4) ซ้ำและพิมพ์ repr(data)
  2. ทำซ้ำโดยหน่วงระหว่างการส่ง 0.5 วินาที แล้วทำซ้ำอีกครั้งด้วย payload ยาว 100 KB ห้ามสรุปจากการทดลอง localhost เพียงรอบเดียว
  3. อธิบายว่าจำนวนครั้งที่ sendall() ไม่จำเป็นต้องเท่ากับจำนวนครั้งที่ recv() และเหตุใดการเขียน json.loads(sock.recv(4096)) จึงเป็นระเบิดเวลาของโปรแกรมจริง
# ตัวช่วยทำให้การแบ่งชิ้นมองเห็นง่ายขึ้น
while True:
    block = conn.recv(4)
    if block == b"":
        break
    print(len(block), repr(block))
คำถามระหว่างทำ
ถ้ารอบหนึ่งได้ b'ONET' แปลว่า TCP ทำข้อมูลเสียหรือไม่? คำตอบคือไม่ ข้อมูลและลำดับยังครบ เพียง TCP รวมปลายข้อความแรกกับต้นข้อความถัดไป โปรแกรมของเราต่างหากที่ยังไม่ได้บอกว่าข้อความจบตรงไหน

การทดลองที่ 3 — เปรียบเทียบ newline framing กับ length prefix

สร้าง protocol สองรุ่น รุ่น A จบข้อความด้วย \n รุ่น B นำหน้าด้วยความยาว 4 byte แบบ network byte order ทดลองส่งภาษาไทย ช่องว่าง newline ใน value และ binary data แล้วตอบว่าแต่ละแบบเหมาะกับงานใด

# length prefix: ซองจดหมายเขียนหน้าซองว่าของข้างในยาวกี่ byte
import struct
payload = "สวัสดี network".encode("utf-8")
sock.sendall(struct.pack("!I", len(payload)) + payload)

def recv_exact(sock, n):
    chunks = []
    while n:
        part = sock.recv(n)
        if part == b"":
            raise ConnectionError("peer closed before message completed")
        chunks.append(part); n -= len(part)
    return b"".join(chunks)

Metaphor ของ framing คือการเอาจดหมายใส่ซอง TCP เป็นรถไปรษณีย์ที่อาจขนหลายซองในเที่ยวเดียว แต่ถ้าเราเทกระดาษทุกแผ่นลงรถโดยไม่ใช้ซอง ผู้รับย่อมไม่รู้ว่ากระดาษหน้าใดเป็นของจดหมายฉบับไหน newline คือรอยคั่นระหว่างซอง ส่วน length prefix คือเลขจำนวนแผ่นที่เขียนไว้หน้าซอง

การทดลองที่ 4 — Protocol ที่อ่านได้ไม่เท่ากับ Protocol ที่แข็งแรง

  1. ใช้ Key–Value Server เดิมทดสอบคำสั่งปกติ แล้วทดสอบคำสั่งว่าง command ไม่รู้จัก UTF-8 ผิดรูปแบบ key ยาวมาก และ client ที่ส่งครึ่งคำสั่งแล้วหยุด
  2. กำหนดขนาดข้อความสูงสุด เช่น 64 KiB ถ้า buffer เกินให้ตอบ error แล้วปิด connection เพื่อป้องกัน client ที่ค่อย ๆ ส่งข้อมูลจนกินหน่วยความจำ
  3. เปลี่ยน response เป็น JSON ที่มี status, data, error และ request_id ให้ client แยกความผิดพลาดของ protocol ออกจากความผิดพลาดของระบบได้
  4. เขียน protocol contract หนึ่งหน้า ระบุ encoding, framing, command, response, error, maximum size และพฤติกรรมเมื่อ connection ปิดกลางข้อความ

การทดลองที่ 5 — หลาย Client, Race Condition และ Backpressure

เปิด client 20 ตัวพร้อมกัน ให้ทุกตัวเพิ่มค่า counter 1,000 ครั้ง เริ่มจาก server ที่ไม่มี lock แล้วใส่ lock เปรียบเทียบผลสุดท้าย เวลา และจำนวน thread จากนั้นให้ client ตัวหนึ่งเชื่อมต่อแต่ไม่ส่งข้อมูล เพื่อดูว่ามันยึด thread ไว้อย่างไร

สิ่งที่ต้องวัดค่า counter ที่คาดหวังกับค่าจริง จำนวน connection/thread เวลาตอบสนอง และจำนวน timeout
สิ่งที่ต้องอธิบายlock ป้องกันข้อมูลร่วม แต่ไม่ได้แก้จำนวน thread ไม่จำกัด ส่วน timeout คืนทรัพยากรจาก client ที่ค้าง แต่ค่าที่สั้นเกินไปอาจตัด client ที่เพียงช้า

การทดลองที่ 6 — การปิดแบบสุภาพ การหลุด และ Half-close

  1. ปิด client ด้วย close() แล้วจดว่า server เห็น recv() คืน b"" เมื่อใด
  2. บังคับหยุด process และตัดเครือข่ายชั่วคราว เปรียบเทียบว่าฝั่ง server รู้ทันทีหรือรู้เมื่อส่งครั้งถัดไป/timeout เท่านั้น
  3. ใช้ shutdown(SHUT_WR) หลังส่ง request ยาว ฝั่ง server อ่านจน EOF แล้วส่งผลกลับ แสดงให้เห็นว่า TCP ปิดทิศทางส่งได้โดยยังเปิดทิศทางรับ
  4. เพิ่ม cleanup ใน finally และทดสอบว่า file descriptor ไม่เพิ่มค้างหลังเชื่อมต่อซ้ำ 100 รอบ

ชิ้นงานที่ส่ง

ชิ้นงานสิ่งที่ต้องมี
Source codeclient/server, README วิธีรัน และ protocol contract
บันทึกการทดลองอย่างน้อย 6 การทดลอง มีสมมติฐาน คำสั่ง ผลจริง และคำอธิบาย
หลักฐานเครือข่ายpacket capture หรือ output เครื่องมือที่ผูกกับ log ของโปรแกรมได้
บทสะท้อนเลือก failure หนึ่งกรณี อธิบายสาเหตุ วิธีตรวจพบ และวิธีออกแบบป้องกัน

11สรุปและขั้นตอนถัดไป

เป้าหมายของหัวข้อนี้ไม่ใช่แค่ "เขียน socket ได้" — แต่คือการเจอปัญหาเหล่านี้ด้วยตัวเองระหว่างทดสอบ:

1. recv() ครั้งเดียวอาจไม่ได้ข้อความครบโดยเฉพาะเมื่อทดสอบข้าม network จริง (ไม่ใช่ localhost) หรือส่ง message ยาว
2. Client อาจหลุดกลางทางต้องแยกให้ออกระหว่าง "ปิดแบบสุภาพ" (recv() คืน b"") กับ "หลุดกะทันหัน" (exception)
3. Server อาจรอไม่สิ้นสุดถ้าไม่ตั้ง timeout และ client ค้างไม่ส่งอะไรเลย thread นั้นจะติดอยู่ใน recv() ตลอดไป
4. Protocol ต้องกำหนดขอบเขตข้อความเองTCP ไม่ทำให้ — framing (delimiter หรือ length-prefixed) เป็นความรับผิดชอบของ application layer เสมอ
คำถามซ้อมสอบ
  1. อธิบายว่าทำไม TCP ถึงเรียกว่า "byte stream" ไม่ใช่ "message stream" พร้อมยกตัวอย่างสถานการณ์ที่ทำให้เกิดปัญหาถ้าไม่เข้าใจสิ่งนี้
  2. เปรียบเทียบ delimiter-based framing กับ length-prefixed framing — แบบไหนเหมาะกับข้อมูล binary มากกว่า เพราะเหตุใด
  3. อธิบายความแตกต่างระหว่าง listening socket กับ socket ที่ accept() คืนมา
  4. เขียน pseudocode ของฟังก์ชันที่อ่านจาก socket จนครบ n byte โดยจัดการกรณี recv() คืนค่าไม่ครบในครั้งเดียว
  5. ทำไม thread-per-connection ถึง scale ได้จำกัดกว่า I/O multiplexing เมื่อจำนวน client เพิ่มขึ้นมาก