ข้าม​ไป​ยัง​เนื้อหา

async I/O กับ tokio::net — echo server ที่​รับ​พัน​คอน​เนกชัน​ด้วย task ไม่ใช่ thread

สาม​บท​แรก​เป็นการ​ปู​พื้น: บท 1 พิสูจน์​ว่า future ขี้เกียจ​และ​เขียน block_on เอง บท 2 เอา runtime ตัว​จริง​มา​แทน บท 3 แตก task ออก​ไป​ด้วย spawn บท​นี้​คือ​บท​ที่​ทั้ง​คอร์ส​เดิน​มา​หา — เรา​กลับ​ไป​หา code ของ kaen-kvstore จาก #22 ตรง​จุด​ที่​มัน​มี​เพดาน​ต่ำ​ที่สุด คือ accept loop ที่​ส่ง​แต่ละ​คอน​เนกชัน​เข้า ThreadPool ขนาด​คงที่ 4 worker แล้ว​ปล่อย​ให้​คอน​เนกชัน​นั้น ยึด worker ไว้​ทั้ง​เส้น แล้ว​เปลี่ยน​มัน​เป็น task

ข่าวดี​คือ code แทบ​ไม่​เปลี่ยน​รูป​เลย tokio::net จงใจ​ลอก​รูป​ของ std::net มา​แทบ 1 ต่อ 1 — TcpListener::bind ยัง​ชื่อ bind accept() ยัง​คืน (TcpStream, SocketAddr) เหมือน​เดิม สิ่ง​ที่​เพิ่ม​มา​คือ .await ต่อ​ท้าย​ทุก​บรรทัด​ที่​แตะ I/O และ thread::spawn กลาย​เป็น tokio::spawn ข่าว​ร้าย​คือ​มี​กำแพง​สาม​อัน​ซ่อน​อยู่​ใน​ความ​คล้าย​นั้น อัน​แรก​ดัง​มาก (compile ไม่​ผ่าน​ทันที) อัน​ที่​สอง​เงียบ​สนิท (compile ผ่าน แต่ protocol พัง​เงียบๆ) และ​อัน​ที่​สาม​คือ borrow checker ยื่น​บิล​มา​เก็บ​ตอน​คุณ​แยก read กับ write ออก​เป็น2 task

📦 kaen-kvstore

คอร์ส​นี้ ต่อยอด repo kaen-kvstore จาก #22 (code ตัวอย่าง​กำลัง​จัด​ทำ) — บท​นี้​คือ​จุด​ที่​เรา​ยก transport layer ทั้ง​ชั้น​ขึ้น​มา​บน Tokio: accept loop, byte echo, และ wire format เดิม​ทั้งดุ้น คือ u32 little-endian นำ​หน้า​ความ​ยาว​แล้ว​ตาม​ด้วย payload ตัว logic ของ store (SET/GET, WAL, fsync) ยัง​ไม่​ถูก​แตะ​ใน​บท​นี้​เลย — เรา​เปลี่ยน​แค่ วิธี​รับ byte เข้า​มา ซึ่ง​เป็น​ประเด็น​ทั้งหมด​ของ async

toolchain + feature scope ของ​บท​นี้

ทุก snippet pin ที่ rustc 1.97.1 (8bab26f4f 2026-07-14) · edition = “2024” · tokio 1.53.1 (ปล่อย 2026-07-20) และ [dependencies] ของ​บท​นี้​คือ​บรรทัด​นี้​เท่านั้น:

[dependencies]
tokio = { version = "1.53.1", features = ["rt-multi-thread", "net", "io-util", "macros"] }

สร้าง​เอง​ได้​ด้วย cargo new kvnet && cargo add tokio@1.53.1 --features rt-multi-thread,net,io-util,macros (เขียน tokio@1.53.1 ไม่ใช่ tokio@=1.53.1 — ตัว​หลัง cargo add จะ​เขียน​ลง Cargo.toml เป็น version = "=1.53.1" ซึ่ง​ไม่​ตรง​กับ​บรรทัด​ข้าง​บน · และ cargo new แจก src/main.rs มา​ให้ ลบ​ทิ้ง​ได้​เลย เพราะ project นี้​เป็น lib + หลาย binary ให้​สร้าง src/lib.rs กับ folder src/bin/ ขึ้น​มา​แทน) ห้าม features = ["full"] เด็ดขาด สังเกต​ความ​ต่าง​จาก​บท 1: ตอน​นั้น scope ["rt", "macros"] resolve มา​แค่ 7 crate และ​เรา​ชี้​ว่า​ยัง​ไม่มี mio/socket2/libc พอ​เปิด "net" ปุ๊บ cargo tree ก็​โผล่​มา​ครบ — tokio, bytes, libc, mio, pin-project-lite, socket2, tokio-macros บวก proc-macro2/quote/syn/unicode-ident ที่​เป็น build dep ของ​มาโคร รวม 11 crate บน Linux — ตัวเลข 11 นี้​นับ​เฉพาะ dependency ที่ compile จริง​บน target x86_64-unknown-linux-gnu (7 ตัว​เดิม​ของ​บท 1 + bytes/libc/mio/socket2 อีก​สี่) ถ้า​ไป​นับ​รายการ​ใน Cargo.lock จะ​ได้ 15 รายการ ซึ่ง​ตรง​กับ​ที่​บท 2 พยากรณ์​ไว้​พอดี เพราะ lock นับ crate ของ​เรา​เอง​เข้าไป​ด้วย และ​ยัง​บันทึก​รายการ​ฝั่ง wasi/windows ที่​ไม่​ได้​ถูก compile บน​เครื่อง​นี้​เลย — สอง​ตัวเลข​นี้​ถูก​ทั้ง​คู่ ต่าง​กัน​แค่​ฐาน​นับ นี่​คือ​หลักฐาน​ที่​จับ​ต้อง​ได้​ว่า "net" = การ​ดึง reactor ตัว​จริง​เข้า​มา

project ของ​บท​นี้​มี6 file ที่​เขียว ทุก file ผ่าน cargo build / cargo test / cargo clippy -- -D warnings แบบ zero-warnings บน target x86_64-unknown-linux-gnu และ รัน​จริง ทุก​บรรทัด output ข้าง​ล่าง​คือ​ข้อความ​จริง​ที่​พิมพ์​ออก​มา: src/lib.rs (read_frame / write_frame / read_frame_or_eof + test สอง​ตัว) · src/bin/echo_server.rs · src/bin/echo_client.rs · src/bin/frame_server.rs · src/bin/frame_client.rs · src/bin/split_demo.rs บวก​อีก2 file ❌ ที่​มี​ไว้​พัง​โดย​เฉพาะ​แล้ว​ลบ​ทิ้ง คือ src/bin/bad_endian.rs กับ src/bin/bad_split.rs

เลข​บรรทัด​ใน​ข้อความ error ทุก​ก้อน​ของ​บท​นี้​ตรง​กับ file ที่​แสดง​ไว้​เป๊ะ ฉะนั้น snippet ข้าง​ล่าง​จึง​ไม่มี​คอมเมนต์​ชื่อ file คั่น​หัว file — ชื่อ file อยู่​ใน​ย่อหน้า​ก่อนหน้า​แทน

นี่​คือ src/bin/echo_server.rs ทั้ง file วาง​ข้างๆ code std::net ของ #22 แล้ว​อ่านที​ละ​บรรทัด​ได้​เลย:

use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
#[tokio::main]
async fn main() -> std::io::Result<()> {
let listener = TcpListener::bind("127.0.0.1:8080").await?;
println!("echo server listening on {}", listener.local_addr()?);
loop {
let (mut socket, addr) = listener.accept().await?;
println!("accepted {addr}");
tokio::spawn(async move {
let mut buf = [0u8; 1024];
loop {
match socket.read(&mut buf).await {
Ok(0) => {
println!("peer closed: {addr}");
return;
}
Ok(n) => {
let _ = socket.write_all(&buf[..n]).await;
}
Err(_) => return,
}
}
});
}
}

และ src/bin/echo_client.rs ที่​จับ​คู่​กัน:

use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
#[tokio::main]
async fn main() -> std::io::Result<()> {
let mut stream = TcpStream::connect("127.0.0.1:8080").await?;
stream.write_all(b"hello").await?;
let mut buf = [0u8; 1024];
let n = stream.read(&mut buf).await?;
println!("echo_back={}", String::from_utf8_lossy(&buf[..n]));
Ok(())
}

สั่ง cargo run --bin echo_server ค้าง​ไว้ แล้ว​อีก​เทอร์มินัล cargo run --bin echo_client ฝั่ง client พิมพ์:

echo_back=hello

ฝั่ง server พิมพ์ (เลข port ฝั่ง client เป็น ephemeral port จึง​เปลี่ยน​ทุก​ครั้ง):

echo server listening on 127.0.0.1:8080
accepted 127.0.0.1:47814
peer closed: 127.0.0.1:47814

ความ​ต่าง​จาก #22 มี​สาม​จุด​เท่านั้น และ​จุด​ที่​สาม​คือ​หัวใจ:

  1. bind และ accept และ read และ write_all ทุก​ตัว​มี .await ต่อ​ท้าย — เพราะ​ทุก​ตัว​คืน future ที่​ไม่​ทำ​อะไร​จนกว่า​จะ​ถูก poll (บท 1)
  2. #[tokio::main] แทน fn main() เปล่าๆ — ต้อง​มี runtime มา​หมุน​ให้ (บท 2)
  3. tokio::spawn แทน pool.execute(...) ที่​ดึง OS thread — 1 connection = 1 task ไม่ใช่1 thread

ข้อ 3 คือ​เหตุผล​ทั้งหมด​ที่​คุณ​อ่าน​คอร์ส​นี้ ThreadPool ของ #22 มี worker แค่ 4 ตัว และ​คอน​เนกชัน​หนึ่ง​สาย​ยึด worker ตัว​หนึ่ง​ไว้​จนกว่า​จะ​วาง​สาย — คอน​เนกชัน​ที่ 5 จึง​ต้อง​รอ ไม่ใช่​เพราะ CPU ไม่​พอ แต่​เพราะ worker ทั้ง​สี่​กำลัง block รอ byte อยู่​เฉยๆ (จะ​ขยาย pool ให้​ใหญ่​ขึ้น​ก็ได้ แต่​แต่ละ worker คือ OS thread ที่​ขอ stack จาก kernel เป็น​เมกะ byte และ​ให้ kernel scheduler สลับ​ให้) ส่วน task ที่​เอกสาร Tokio ระบุ​ขนาด​ไว้​ว่า​เป็น “single allocation and 64 bytes” — คอน​เนกชัน​ที่​กำลัง​รอ byte อยู่​จึง​มี​ต้นทุน​แค่ struct ก้อน​หนึ่ง​ใน​หน่วย​ความ​จำ ไม่ใช่ OS thread ที่​หลับ​อยู่ นี่​คือ​ที่มา​ของ​ประโยค “รับ​พัน​คอน​เนกชัน” ใน​หัว​บท และ​เป็น​เส้น​ที่​ตรง​กับ #22 พอดี: protocol เดิม โครง​เดิม เปลี่ยน​แค่​หน่วย​ของ​การ​ทำงาน​พร้อม​กัน

ประโยค​เดียว​ที่​ต้อง​จำ​จาก​บท​นี้

tokio::net ให้​รูป​เดิม​ของ std::net มา แต่​หน่วย concurrency เปลี่ยน​จาก OS thread เป็น task — ที่​เหลือ​ทั้ง​บท​คือ​ราคา​ที่​ต้อง​จ่าย​ให้การ​เปลี่ยน​หน่วย​นั้น

บรรทัด​แรก​สุด​ของ echo_server.rs คือ use tokio::io::{AsyncReadExt, AsyncWriteExt}; และ​มัน​ไม่ใช่​ของ​ประดับ ลบ​บรรทัด​นั้น​ทิ้ง (ทั้ง file จึง​เลื่อน​ขึ้น​หนึ่ง​บรรทัด) แล้ว cargo build ตอบ​กลับ​มา​แบบ​นี้ (ตัด​เหลือ error ตัว​แรก​และ​บรรทัด​ที่​มี​น้ำหนัก):

error[E0599]: no method named `read` found for struct `tokio::net::TcpStream` in the current scope
--> src/bin/echo_server.rs:13:30
|
13 | match socket.read(&mut buf).await {
| ^^^^
|
::: /home/nook/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tokio-1.53.1/src/io/util/async_read_ext.rs:177:12
|
177 | fn read<'a>(&'a mut self, buf: &'a mut [u8]) -> Read<'a, Self>
| ---- the method is available for `tokio::net::TcpStream` here
|
= help: items from traits can only be used if the trait is in scope
help: trait `AsyncReadExt` which provides `read` is implemented but not in scope; perhaps you want to import it
|
1 + use tokio::io::AsyncReadExt;
|

อีก error หนึ่ง​ตัว​หน้าตา​เหมือนกันเป๊ะ​สำหรับ write_all กับ AsyncWriteExt เติม use กลับ​ไป​แล้ว​เขียว​ทันที

กลไก​เบื้องหลัง: เทรต​ฐาน​ของ tokio คือ AsyncRead กับ AsyncWrite และ​สิ่ง​ที่​มัน​บังคับ​ให้ implement มี​แต่ method ทรง poll_* ล้วนๆ — AsyncRead มี method เดียว​คือ poll_read ส่วน AsyncWrite มี​สาม​ตัว​คือ poll_write / poll_flush / poll_shutdown ทุก​ตัว​หน้าตา​เหมือน poll ในบท 1 เป๊ะ (รับ Pin<&mut Self> กับ &mut Context คืน Poll) method ที่​คุณ​อยาก​เรียกจริงๆ — read read_exact write_all flush read_u32_leไม่​ได้​อยู่​บน​เทรต​ฐาน แต่​อยู่​บน extension trait คือ AsyncReadExt และ AsyncWriteExt ซึ่ง​มี blanket impl ให้​ทุก​ตัว​ที่ implement เทรต​ฐาน​อยู่​แล้ว แปล​ว่า TcpStream มี method พวก​นี้​ครบ​ตั้งแต่​แรก — มัน​แค่ มอง​ไม่​เห็น จน​กว่า​เทรตจะ​เข้า​มา​อยู่​ใน scope

ญาติ​ที่​ใกล้​ที่สุด​ใน C#

C# ไม่มี​อาการ​นี้​เพราะ NetworkStream.ReadAsync เป็น method จริง​บน class แต่​คุณ​เคย​เจอ​รูปแบบ​เดียวกัน​มา​แล้ว: .Where() และ .Select() ไม่ compile จนกว่า​จะ​มี using System.Linq; — extension method ต้อง​ถูก​ดึง​เข้า scope ก่อน​ถึง​จะ​เห็น กรอบ​คิด​ที่​ถูก​คือ verb ของ async I/O ใน Rust เป็น extension method ทั้งหมด ต่าง​กัน​แค่ Rust บังคับ​เข้ม​กว่า​และ IDE เดา​ให้​น้อย​กว่า

บท 1 ทิ้ง​คำถาม​ไว้​ข้อ​หนึ่ง: future ที่​ตอบ Poll::Pending ต้อง​เก็บ Waker ไว้​แล้ว​เรียก wake() ตอน​ไป​ต่อ​ได้ — แต่​ใน Countdown เรา​โกง​ด้วย​การ wake_by_ref() ทันที​ใน​รอบ​เดียวกัน คำถาม​คือ กับ socket จริง ใคร​เป็น​คน​เรียก wake()

คำ​ตอบ​คือ reactor (บท 2 ประกอบ​มัน​เข้า runtime ไป​แล้ว) ตอน socket.read(&mut buf).await แล้ว kernel ยัง​ไม่มี byte ให้ สิ่ง​ที่​เกิด​ขึ้น​ไม่ใช่​การ​รอ​เปล่าๆ แต่​คือ: tokio เอา file descriptor ตัว​นั้น​ไป​ลง​ทะเบียน​กับ epoll ผ่าน crate mio (ตัว​ที่​เพิ่ง​โผล่​ใน cargo tree ตอน​เปิด "net") พร้อม​ฝาก Waker ของ task ไว้ แล้ว​คืน Pending ให้ scheduler เอา worker thread ไป​หมุน task อื่น​ต่อ​ทันที เมื่อ kernel มี byte จริง epoll_wait ใน thread I/O driver ตื่น​ขึ้น มัน​ค้น​ว่า fd นี้​ผูก​กับ Waker ตัว​ไหน แล้ว​เรียก wake() — task ถูก​ดัน​กลับ​เข้า​คิว และ​ถูก poll ซ้ำ คราว​นี้ read คืน Ready

sequenceDiagram
    participant T as task ของ connection
    participant R as reactor คือ mio บน epoll
    participant K as kernel
    T->>R: poll แล้วยังไม่มี byte จึงฝาก Waker ไว้
    R->>K: ลงทะเบียน fd นี้เข้า epoll
    T-->>T: คืน Pending ตัว task หลับ worker thread ว่างไปหมุน task อื่น
    K-->>R: epoll_wait ตื่น เพราะ fd พร้อมอ่านแล้ว
    R->>T: เรียก wake บน Waker ที่ฝากไว้
    T->>T: scheduler ดัน task กลับเข้าคิว แล้ว poll ซ้ำ คราวนี้ได้ Ready

คำ​บรรยาย​ภาพ: เส้น​ทางการ​ปลุก task ใน​โลก I/O จริง — task เรียก read แล้ว​ยัง​ไม่มี​ข้อมูล จึง​ฝาก Waker ไว้​กับ reactor ซึ่ง​ลง​ทะเบียน file descriptor นั้น​กับ epoll ของ kernel task คืน Pending แล้ว​หลับ ปล่อย​ให้ worker thread ไป​หมุน​งาน​อื่น เมื่อ kernel บอกว่า fd พร้อม reactor เรียก wake ตัว​เดิม scheduler จึง​ดัน task กลับ​เข้า​คิว​ไป poll ซ้ำ ทั้งหมด​นี้​คือ handshake poll กับ wake อัน​เดิม​ของ​บท 1 เพียง​แต่​คน​กด​ปุ่ม wake เปลี่ยน​จาก thread unpark มา​เป็น kernel

เทียบ​กับ .NET แล้ว​โครง​นี้ เกือบ เหมือน — CLR ก็มี I/O completion port (หรือ epoll บน Linux) คอย​รับ​สัญญาณ​จาก kernel เหมือน​กัน ความ​ต่าง​อยู่​ที่​ทิศทาง: ฝั่ง .NET เมื่อ I/O เสร็จ ระบบ push continuation ที่​คุณ​ลง​ทะเบียน​ไว้​เข้า​คิว ThreadPool ให้​รัน​ต่อ ส่วน​ฝั่ง Rust reactor แค่ เคาะ​ประตู​บอกว่า “กลับ​มา​ถาม​ใหม่​ได้​แล้ว” ตัว​งาน​จริง​ยัง​ต้อง​รอ executor เดิน​มา poll ซ้ำ​อยู่ดี นี่​คือ model pull ของ​บท 1 ที่​ขยาย​มา​ถึง​ระดับ syscall

byte echo ข้าง​บน​สวย​แต่​ไร้​ประโยชน์ เพราะ kaen-kvstore ไม่​ได้​พูด​ภาษา byte เปล่า มัน​พูด length-prefixed-framinglength-prefixed-framingwire ของ #22: `u32` LE ความ​ยาว ตาม​ด้วย payload อ่าน​ด้วย `read_exact`u32 little-endian 4 byte บอก​ความ​ยาว แล้ว​ตาม​ด้วย payload เท่านั้น​พอดี วินัย​นี้​คือ​สิ่ง​ที่ #22 ยืนยัน​มา​แล้ว​ว่า TCP ไม่​รักษา​ขอบเขต​ข้อความ​ให้ คุณ​ต้อง​ขีด​เส้น​เอง

async mirror ของ​มัน​สั้น​กว่า​ที่​คิด เพราะ AsyncReadExt แจก read_u32_le มาให้ตรงๆ นี่​คือ​หัว file src/lib.rs ทั้งหมด:

use tokio::io::{AsyncReadExt, AsyncWriteExt};
pub async fn read_frame<R: AsyncReadExt + Unpin>(r: &mut R) -> std::io::Result<Vec<u8>> {
let len = r.read_u32_le().await? as usize;
let mut buf = vec![0u8; len];
r.read_exact(&mut buf).await?;
Ok(buf)
}
pub async fn write_frame<W: AsyncWriteExt + Unpin>(w: &mut W, bytes: &[u8]) -> std::io::Result<()> {
w.write_u32_le(bytes.len() as u32).await?;
w.write_all(bytes).await?;
w.flush().await?;
Ok(())
}

เทียบ​บรรทัด​ต่อ​บรรทัด​กับ #22: read_u32_le().await? คือ read_exact(&mut [0u8; 4]) แล้ว u32::from_le_bytes รวม​กัน​เป็น​บรรทัด​เดียว ส่วน read_exact(&mut buf).await? คือ​ตัว​เดิม​เป๊ะ แค่ awaited วินัย​ไม่​เปลี่ยน​เลย protocol ไม่​เปลี่ยน​เลย เปลี่ยน​แค่​ว่า​มัน​ยอม​ปล่อย worker thread ระหว่าง​รอ

ข้อสังเกต​เรื่อง bound: R: AsyncReadExt + Unpin ต้อง​มี Unpin เพราะ method พวก​นี้​ประกาศ​ไว้​ว่า where Self: Unpin — future ที่ read คืน​มา​ต้อง​ถือ &mut R ไว้​ข้าม .await และ​จะ​ทำ​แบบ​นั้น​ได้​ต้อง​รู้​ว่า R ย้าย​ที่​ได้​อย่าง​ปลอดภัย TcpStream เป็น Unpin อยู่​แล้ว จึง​ไม่มี​อะไร​ต้อง​ทำ​เพิ่ม

นี่​คือ​กับดัก​ที่​อันตราย​กว่า E0599 หลาย​เท่า เพราะ ไม่มี error ไม่มี warning ไม่มี​อะไร​เลย ลอง​เปลี่ยน​ฝั่ง server ให้​อ่าน​ด้วย read_u32() (ซึ่ง​เป็น big-endian ตาม​ธรรมเนียม network byte order) ขณะ​ที่ client ยัง​เขียน​ด้วย write_u32_le() ตาม wire ของ #22 — file ทดลอง​ชื่อ src/bin/bad_endian.rs และ​มัน​คือ ❌ version ดิบ ของ​บท​นี้:

use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
#[tokio::main]
async fn main() -> std::io::Result<()> {
let listener = TcpListener::bind("127.0.0.1:8083").await?;
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
// ผิด: read_u32 คือ big-endian ส่วน client เขียนด้วย write_u32_le
let len = socket.read_u32().await.unwrap() as usize;
println!("server thinks the frame is {len} bytes long");
let mut buf = vec![0u8; len];
let err = socket.read_exact(&mut buf).await.unwrap_err();
println!("read_exact failed: {:?}", err.kind());
});
let mut client = TcpStream::connect("127.0.0.1:8083").await?;
client.write_u32_le(18).await?;
client.write_all(b"SET greeting hello").await?;
client.shutdown().await?;
server.await.unwrap();
Ok(())
}

cargo run --bin bad_endian compile ผ่าน​สะอาด แล้ว​พิมพ์:

server thinks the frame is 301989888 bytes long
read_exact failed: UnexpectedEof

18 เขียน​แบบ little-endian คือ byte 12 00 00 00 อ่าน​กลับ​แบบ big-endian ได้ 0x12000000 = 301,989,888 server จึง​จอง Vec ขนาด ~288 MB ขึ้น​มา​รอ payload ที่​ไม่มี​วัน​มา ใน file ทดลอง​นี้ client เรียก shutdown() ทันทีหลัง​ส่ง 18 byte read_exact จึง​ชน EOF แล้ว​คืน UnexpectedEof ออก​มา​เลย โปรแกรม​ไม่​ได้​ค้าง​ให้​เห็น — ถ้า client เป็น​ของ​จริง​ที่​ยัง​เปิด​คอน​เนกชัน​ค้าง​ไว้ server ตัว​นี้​จะ​รอ​ต่อ​ไป​เรื่อยๆ พร้อม​กับ​หน่วย​ความ​จำ​ก้อน​นั้น​คา​อยู่​ใน​มือ นี่​คือ​รูป​ที่​โชค​ดี — server ตาย​เสียง​ดัง ใน​กรณี​ที่​โชค​ร้ายกว่า (ตัวเลข​ความ​ยาว​ที่​อ่าน​ผิด​แล้ว​บังเอิญ​เล็ก) server จะ​อ่าน​ไม่​ครบ frame แล้ว byte ที่​เหลือกลาย​เป็น “หัว frame ถัด​ไป” ทันที — stream desync แบบ​เงียบ​สนิท​ที่ debug ยาก​ที่สุด​แบบ​หนึ่ง

ทาง​กัน​คือ​กฎ​เดียว: ตัดสิน​ใจ​เรื่อง endianness ครั้ง​เดียว​แล้ว​เขียน​มัน​ลง test kaen-kvstore เลือก little-endian ตั้งแต่ #22 เพราะ​มัน​ตรง​กับ layout ของ​เครื่อง x86 และ ARM ที่​รัน​อยู่​จริง (และ​ตรง​กับ CLR ด้วย ซึ่ง​จะ​ได้​ใช้​ใน​หัวข้อ C# ข้าง​ล่าง) test คู่​นี้​ต่อ​ท้าย src/lib.rs และ​เป็น​ตาข่าย​ที่​จับ​กรณี​ข้าง​บน​ได้​ทันที:

#[cfg(test)]
mod tests {
use super::*;
use tokio::net::{TcpListener, TcpStream};
#[tokio::test]
async fn frame_round_trips_over_tcp() -> std::io::Result<()> {
let listener = TcpListener::bind("127.0.0.1:0").await?;
let addr = listener.local_addr()?;
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let frame = read_frame(&mut socket).await.unwrap();
write_frame(&mut socket, &frame).await.unwrap();
});
let mut client = TcpStream::connect(addr).await?;
write_frame(&mut client, b"SET greeting hello").await?;
let echoed = read_frame(&mut client).await?;
assert_eq!(&echoed, b"SET greeting hello");
server.await.unwrap();
Ok(())
}
#[tokio::test]
async fn truncated_frame_is_unexpected_eof() -> std::io::Result<()> {
let listener = TcpListener::bind("127.0.0.1:0").await?;
let addr = listener.local_addr()?;
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
read_frame(&mut socket).await.unwrap_err()
});
let mut client = TcpStream::connect(addr).await?;
client.write_u32_le(32).await?;
client.write_all(b"only5").await?;
client.shutdown().await?;
drop(client);
let err = server.await.unwrap();
assert_eq!(err.kind(), std::io::ErrorKind::UnexpectedEof);
Ok(())
}
}

cargo test ให้​ผล​นี้ (สังเกต bind("127.0.0.1:0") — ให้ kernel เลือก port ว่าง​ให้ แล้ว​อ่าน​กลับ​ด้วย local_addr() test จึง​รัน​ขนาน​กัน​ได้​โดย​ไม่​ชน​กันเอง · และ​เพราะ​มัน​รัน​ขนาน​กัน ลำดับ​บรรทัด test … ok ที่​คุณ​เห็น​อาจ​สลับ​กับ​ที่​พิมพ์​ไว้​ข้าง​ล่าง​นี้ เช่น​เดียว​กับ​เลข ephemeral port ที่​เปลี่ยน​ทุก​ครั้ง สิ่ง​ที่​คงที่​คือ​บรรทัด​สรุป 2 passed; 0 failed):

running 2 tests
test tests::truncated_frame_is_unexpected_eof ... ok
test tests::frame_round_trips_over_tcp ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s

test ตัว​ที่​สอง​ข้าง​บน​ตอก​กฎ​ข้อ​หนึ่ง​ไว้​แล้ว มา​ดู​ทั้ง​คู่​พร้อม​กัน เพราะ​มัน​คือ​คู่​ที่​คน​สับสน​กัน​มาก​ที่สุด:

  • read() คืน Ok(0) = peer ปิด​คอน​เนกชัน​อย่าง​สะอาด ไม่ใช่ error ไม่ใช่ timeout ไม่ใช่ “ยัง​ไม่มี​ข้อมูล” (กรณี​นั้น​คือ Pending ซึ่ง​ถูก .await กลืน​ไป​แล้ว) เป็น EOF จริง นี่​คือ​เหตุผล​ที่ echo server ข้าง​บน match Ok(0) => return เป็น arm แรก ลอง​ไล่​ดู​ว่า​ถ้า​ลบ arm นั้น​ทิ้ง​จะ​เกิด​อะไร​ขึ้น: Ok(n) จะ​รับ n = 0 ไป​แทน แล้ว​เขียน &buf[..0] ซึ่ง​ไม่​ส่ง​อะไร​เลย loop วน​กลับ​ไป read ใหม่​ซึ่ง​คืน Ok(0) อีก​ทันที (EOF จะ​คืน Ok(0) ตลอด​ไป ไม่ใช่​ครั้ง​เดียว) — loop จึง​ไม่มี​จุดจบ​และ​ไม่มี​จุด​ไหน​ยอม Pending ให้ scheduler ได้​พัก นี่​เป็นการ​ไล่​เหตุผล​จาก​สัญญา​ของ API ไม่ใช่​ตัวเลข​ที่​บท​นี้​วัด​มา — เรา​ไม่​ได้ ship file ❌ ตัว​นี้​ให้​รัน​ดู ต่าง​จาก​อีก​สาม​กับดัก​ข้าง​บน
  • read_exact คืน Err(ErrorKind::UnexpectedEof) เมื่อ stream จบ​ก่อน​อ่าน​ครบ buf.len() — คือ​กรณี “ประกาศ​ว่า​จะ​ส่ง 32 byte แต่​ส่ง​มา 5 แล้ว​ปิด” ซึ่ง​เป็น ความ​ผิด​ของ protocol ไม่ใช่​การ​ปิด​ปกติ

ปัญหา​คือ read_frame ข้าง​บน​แยก​สอง​กรณี​นี้​ไม่​ออก เพราะ​ถ้า peer ปิด​ตรง​ขอบ frame พอดี (ซึ่ง​เป็นการ​ปิด​ที่​ถูกต้อง​สมบูรณ์) read_u32_le() ก็​คืน UnexpectedEof เหมือน​กัน server จะ log ว่า “protocol พัง” ทั้ง​ที่ client แค่​บอกลา​อย่าง​สุภาพ ทาง​แก้​คือ​อ่าน​หัว frame4 byte ด้วย​มือ แล้ว​เช็ก Ok(0) เฉพาะ​ตอน​ที่​ยัง​ไม่​ได้​อ่าน​อะไร​เลย function นี้​อยู่​ใน src/lib.rs ต่อ​จาก write_frame:

pub async fn read_frame_or_eof<R: AsyncReadExt + Unpin>(
r: &mut R,
) -> std::io::Result<Option<Vec<u8>>> {
let mut hdr = [0u8; 4];
let mut filled = 0;
while filled < 4 {
match r.read(&mut hdr[filled..]).await? {
0 if filled == 0 => return Ok(None),
0 => {
return Err(std::io::Error::new(
std::io::ErrorKind::UnexpectedEof,
"header truncated",
));
}
n => filled += n,
}
}
let len = u32::from_le_bytes(hdr) as usize;
let mut buf = vec![0u8; len];
r.read_exact(&mut buf).await?;
Ok(Some(buf))
}

Ok(None) = “จบ​อย่าง​สะอาด” · Err(UnexpectedEof) = “พัง​กลาง​ทาง” 2 arm นี้​แยก​กัน​ชัด​แล้ว loop while filled < 4 จำเป็น​เพราะ read() มี​สิทธิ์​คืน​น้อย​กว่า​ที่​ขอ (short read) เสมอ — เหมือน NetworkStream.ReadAsync ใน C# เป๊ะ

server ที่​ใช้​มัน​คือ src/bin/frame_server.rs (ชื่อ crate ใน project นี้​คือ kvnet ตาม​ที่ cargo new kvnet ตั้ง​ให้):

use kvnet::{read_frame_or_eof, write_frame};
use tokio::net::TcpListener;
#[tokio::main]
async fn main() -> std::io::Result<()> {
let listener = TcpListener::bind("127.0.0.1:8081").await?;
println!("frame server listening on {}", listener.local_addr()?);
loop {
let (mut socket, addr) = listener.accept().await?;
tokio::spawn(async move {
loop {
match read_frame_or_eof(&mut socket).await {
Ok(Some(frame)) => {
println!("frame from {addr}: {} bytes", frame.len());
if write_frame(&mut socket, &frame).await.is_err() {
return;
}
}
Ok(None) => {
println!("clean close at frame boundary: {addr}");
return;
}
Err(e) => {
println!("broken frame from {addr}: {:?}", e.kind());
return;
}
}
}
});
}
}

และ client ที่​จับ​คู่​กัน​คือ src/bin/frame_client.rs:

use kvnet::{read_frame, write_frame};
use tokio::net::TcpStream;
#[tokio::main]
async fn main() -> std::io::Result<()> {
let mut stream = TcpStream::connect("127.0.0.1:8081").await?;
for cmd in [b"SET greeting hello".as_slice(), b"GET greeting".as_slice()] {
write_frame(&mut stream, cmd).await?;
let reply = read_frame(&mut stream).await?;
println!("reply={}", String::from_utf8_lossy(&reply));
}
Ok(())
}

รัน server ค้าง​ไว้​แล้ว​ยิง client — ฝั่ง client:

reply=SET greeting hello
reply=GET greeting

ฝั่ง server:

frame server listening on 127.0.0.1:8081
frame from 127.0.0.1:46810: 18 bytes
frame from 127.0.0.1:46810: 12 bytes
clean close at frame boundary: 127.0.0.1:46810

บรรทัด​สุดท้าย​คือ​รางวัล​ของ​หัวข้อ​นี้: การ​ปิด​ที่​สุภาพ​ถูก​จัด​ว่า สะอาด ไม่ใช่ error

nc ทดสอบ protocol นี้​ไม่​ได้

สัญชาตญาณ​แรก​ของ​ทุก​คน​คือ nc 127.0.0.1 8081 แล้ว​พิมพ์​อะไร​สัก​อย่าง — ใช้​ไม่​ได้ เพราะ nc ส่ง byte ดิบ​ตาม​ที่​พิมพ์ ไม่มี u32 prefix นำ​หน้า​ให้ server จะ​เอา4 byte แรก​ของ​สิ่ง​ที่​คุณ​พิมพ์​ไป​ตีความ​เป็น ความ​ยาว ทันที ซึ่ง​กลาย​เป็น​ตัวเลขมั่วๆ ตัว​หนึ่ง​เสมอ nc จึง​ใช้ได้​เฉพาะ​กับ echo_server ที่​พูด byte ดิบ​เท่านั้น สำหรับ protocol ที่​มี framing ต้อง​มี client ที่​พูด​ภาษา​เดียวกัน ซึ่ง​คือ​เหตุผล​ที่​บท​นี้ ship frame_client.rs มา​ให้​ครบ​คู่

จนถึง​ตรง​นี้1 task อ่าน​แล้ว​เขียน​สลับ​กัน​ไป ซึ่ง​พอ​สำหรับ request/response แต่​ทันที​ที่​คุณ​อยาก push ข้อมูล​ไป​หา client โดย​ไม่​รอ request (ซึ่ง​บท 8 จะ​ต้อง​ใช้) คุณ​ต้อง​แยก read กับ write ออก​เป็น2 task ที่​เดิน​อิสระ​กัน และ borrow checker จะ​ยื่น​บิล​มา​เก็บ​ทันที ลอง version ตรง​ไป​ตรง​มา​ก่อน:

version ดิบ ตัว​นี้​อยู่​ใน file src/bin/bad_split.rs ทั้ง file ตาม​นี้ (เลข​บรรทัด​ใน error ข้าง​ล่าง​อ้าง file นี้ตรงๆ):

use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
#[tokio::main]
async fn main() -> std::io::Result<()> {
let listener = TcpListener::bind("127.0.0.1:8084").await?;
let (mut socket, _) = listener.accept().await?;
let (mut rd, mut wr) = socket.split();
tokio::spawn(async move {
let mut buf = [0u8; 1024];
let _ = rd.read(&mut buf).await;
});
tokio::spawn(async move {
let _ = wr.write_all(b"hi").await;
});
Ok(())
}

cargo build ตอบ (ตัด​เหลือ​บรรทัด​ที่​มี​น้ำหนัก):

error[E0597]: `socket` does not live long enough
--> src/bin/bad_split.rs:8:28
|
7 | let (mut socket, _) = listener.accept().await?;
| ---------- binding `socket` declared here
8 | let (mut rd, mut wr) = socket.split();
| ^^^^^^ borrowed value does not live long enough
9 |
10 | / tokio::spawn(async move {
11 | | let mut buf = [0u8; 1024];
12 | | let _ = rd.read(&mut buf).await;
13 | | });
| |______- argument requires that `socket` is borrowed for `'static`
...
18 | }
| - `socket` dropped here while still borrowed
|
note: requirement that the value outlives `'static` introduced here
--> /home/nook/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tokio-1.53.1/src/task/spawn.rs:176:28
|
176 | F: Future + Send + 'static,
| ^^^^^^^

อ่าน note ท้าย​สุด​แล้ว​เรื่อง​จบ​ใน​บรรทัด​เดียว: tokio::spawn ต้องการ F: Future + Send + 'static (บท 3 ตอก​ไว้​แล้ว) ส่วน split(&mut self) คืน ReadHalf<'a> กับ WriteHalf<'a> ที่ ยืม socket มา — มัน​มี lifetime ผูก​กับ stack frame นี้ ไม่ใช่ 'static ตัว task ที่ spawn ไป​อาจ​อยู่​นาน​กว่า function ที่​สร้าง​มัน compiler จึง​ปฏิเสธ

ทาง​แก้​คือ​ใช้ version owned:

let (mut rd, mut wr) = socket.into_split(); // (OwnedReadHalf, OwnedWriteHalf)

into_split(self) กิน socket เข้าไป​แล้ว​คืน​สอง​ครึ่ง​ที่​เป็น​เจ้าของ​ร่วม​กัน (จ่าย heap allocation หนึ่ง​ครั้ง​เป็น​ค่า​ผ่าน​ทาง) ทั้ง​คู่​จึง​เป็น 'static และ​ย้าย​เข้า async move block ได้​สบาย demo เต็ม​ที่​รัน​จริง​คือ src/bin/split_demo.rs ซึ่ง​ยัด server และ client ไว้​ใน process เดียว​เพื่อ​ให้ output เรียง​เหมือน​เดิม​ทุก​ครั้ง:

use kvnet::{read_frame, write_frame};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
#[tokio::main]
async fn main() -> std::io::Result<()> {
let listener = TcpListener::bind("127.0.0.1:8082").await?;
println!("split demo listening on {}", listener.local_addr()?);
let server = tokio::spawn(async move {
let (socket, addr) = listener.accept().await.unwrap();
let (mut rd, mut wr) = socket.into_split();
let reader = tokio::spawn(async move {
let mut buf = [0u8; 1024];
loop {
match rd.read(&mut buf).await {
Ok(0) => {
println!("reader task: peer closed {addr}");
return;
}
Ok(n) => println!("reader task: got {n} bytes"),
Err(_) => return,
}
}
});
let writer = tokio::spawn(async move {
write_frame(&mut wr, b"BANNER kaen-kvstore v4").await.unwrap();
println!("writer task: banner sent");
});
writer.await.unwrap();
reader.await.unwrap();
});
let mut client = TcpStream::connect("127.0.0.1:8082").await?;
let banner = read_frame(&mut client).await?;
println!("client: {}", String::from_utf8_lossy(&banner));
client.write_all(b"PING").await?;
client.shutdown().await?;
server.await.unwrap();
Ok(())
}

cargo run --bin split_demo พิมพ์:

split demo listening on 127.0.0.1:8082
writer task: banner sent
client: BANNER kaen-kvstore v4
reader task: got 4 bytes
reader task: peer closed 127.0.0.1:46222

สังเกต​ว่า​เรา​เก็บ JoinHandle ของ​ทั้ง reader และ writer ไว้​แล้ว .await มัน​ตาม​ลำดับ (บท 3) — ไม่ใช่​เพื่อ​ความ​สวยงาม แต่​เพราะ​ถ้า​ไม่​ทำ main จะ​จบ​ก่อน​ที่ task จะ​ได้​พิมพ์ ทำให้ output ไม่​แน่นอน​ทุก​ครั้ง​ที่​รัน

ตรง​นี้​คือ​จุด​ที่​สัญชาตญาณ​จาก C# หลอก​แรง​ที่สุด​ใน​บท​นี้: ใน .NET คุณ​ส่ง NetworkStream ตัว​เดียวกัน​เข้าไป​ใน​สอง Task ได้ตรงๆ ไม่มี​ใคร​ห้าม (จะ thread-safe หรือ​ไม่​เป็น​เรื่อง​ที่​คุณ​ต้อง​ดู​เอง) ส่วน Rust ห้าม​ที่​ระดับ type ว่า​จะ​มี &mut สอง​ตัว​ชี้​ไป​ที่​เดียวกัน​ไม่​ได้ ทาง​เดียว​คือ​แบ่ง​ความ​เป็น​เจ้าของ​ออก​จาก​กันจริงๆ ซึ่ง into_split() ทำให้ และ​ผลพลอยได้​คือ​คุณ​ได้​ความ​ปลอดภัย​ฟรี — OwnedReadHalf ไม่มี​ทาง​เขียน OwnedWriteHalf ไม่มี​ทาง​อ่าน ความ​สับสน​แบบ “2 task เขียน​ทับ​กัน​กลาง frame” หาย​ไป​ตั้งแต่​ตอน compile

กับดัก feature scope ที่​ยัง​รอ​อยู่

ถ้า​ลด [dependencies] เหลือ features = ["rt", "net", "io-util", "macros"] แล้ว​ยัง​เขียน #[tokio::main] เปล่าๆ ตาม code ทั้ง​บท​นี้ cargo build ตอบ​ทันที:

error: The default runtime flavor is `multi_thread`, but the `rt-multi-thread` feature is disabled.
--> src/bin/echo_server.rs:4:1
|
4 | #[tokio::main]
| ^^^^^^^^^^^^^^
|
= note: this error originates in the attribute macro `tokio::main` (in Nightly builds, run with -Z macro-backtrace for more info)

ทาง​แก้​มี​สอง​ทาง​และ ไม่​เท่า​กัน: เติม "rt-multi-thread" กลับ​เข้าไป (สิ่ง​ที่​บท​นี้​ทำ เพราะ​เรา​อยาก​ได้ worker หลาย​ตัว​มา​รับ​หลาย​คอน​เนกชัน) หรือ​เขียน #[tokio::main(flavor = "current_thread")] ซึ่ง​ใช้ scheduler thread เดียว​และ​ต้องการ​แค่ "rt" — ตัว​หลัง​ยัง​รับ​พัน​คอน​เนกชัน​ได้ (เพราะ concurrency มา​จาก task ไม่ใช่ thread) แต่​ใช้ CPU ได้​แค่​คอร์​เดียว 🔁 บท 2 เทียบ2 flavor นี้​ไว้​ครบ​แล้ว

C# / .NETRust + tokioจุด​ที่​ต่าง​จริง
TcpListener.Start() + await AcceptTcpClientAsync()TcpListener::bind(addr).await + accept().awaitฝั่ง Rust bind เอง​ก็ async และ​คืน io::Result ที่​ต้อง​จัดการ​ด้วย ?
await new TcpClient().ConnectAsync(...)TcpStream::connect(addr).awaitฝั่ง Rust ไม่มี​ชั้น TcpClient คร่อม NetworkStreamTcpStream คือ stream เลย
NetworkStream.ReadAsync / WriteAsyncread / write_all บน AsyncReadExt / AsyncWriteExtต้อง use เทรตก่อน ไม่​งั้น error[E0599] method หาย​ไป​เฉยๆ
Stream.ReadExactlyAsync (มาใน .NET 7 · 2022-11-08)read_exactความหมาย​ตรง​กัน รวม​ถึง​การ​โยน/คืน error เมื่อ stream จบ​ก่อน​ครบ
BinaryPrimitives.ReadUInt32LittleEndianread_u32_le / write_u32_leCLR เป็น little-endian ดังนั้น BinaryWriter.Write(uint) แมตช์ write_u32_le เป๊ะ — wire ของ #22 ข้าม​ภาษา​ได้​เลย
ส่ง NetworkStream ตัว​เดียว​เข้า​สอง Task ได้​เลยต้อง into_split() ก่อนborrow checker ห้าม &mut สอง​ตัว​ชี้​ที่​เดียวกัน — split() ที่​ยืม​มา​จะ​ติด 'static ของ spawn
หนึ่ง​คอน​เนกชัน = งาน​บน ThreadPool ที่​มี thread หลัก​ร้อยหนึ่ง​คอน​เนกชัน = task ขนาด “single allocation and 64 bytes”Tokio มี worker เท่า​จำนวน​คอร์ แต่ task เป็น​ล้าน​ได้
honesty spine — บท​นี้​แตะ​ครบ​ทั้ง​สี่​เส้น

A — Future ขี้เกียจ: let f = socket.read(&mut buf); แล้ว​ไม่ .await = ไม่มี syscall ไหน​ถูก​ยิง​เลย socket ยัง​ไม่​ถูก​แตะ​ด้วย​ซ้ำ ต่าง​จาก ReadAsync ใน C# ที่​เริ่ม​ยิง I/O ทันที​ตั้งแต่​ก่อน​คุณ await

B — std ไม่มี runtime: std::net::TcpStream มี read ที่ block thread ทั้ง​เส้น ส่วน tokio::net::TcpStream ต้อง​มี reactor คอย​ขับ และ reactor นั้น​ไม่​ได้​มา​จาก std แต่​มา​จาก crate ที่​เรา​เพิ่ง​ดึง​เข้า​มา — หลักฐาน​อยู่​ใน cargo tree ตรงๆ ว่าการ​เปิด feature "net" คือ​สิ่ง​ที่​พา mio / socket2 / libc เข้า​มา​ใน project ทั้ง​ที่​บท 1 ด้วย scope ["rt", "macros"] ยัง​ไม่มี​สาม​ตัว​นี้​เลย

C — function colouring: read_frame เป็น async fn function sync ตัว​ไหน​ก็​เรียก​มัน​ไม่​ได้ ผล​คือ kaen-kvstore เดิม​ที่​เป็น sync ทั้ง repo จะ​โดน async ไต่​ขึ้น​ไป​ตาม call stack ที​ละ​ชั้น​เมื่อ​เรา​ยก transport มา​ไว้​ตรง​นี้ บท 7 ว่าด้วย​เรื่อง​นี้​ทั้ง​บท

D — ทุก​อย่าง​ใน​บท​นี้​รัน​จริง ไม่มี​อัน​ไหน​เป็น​ไดอะแกรม​อย่าง​เดียว (ยกเว้น snippet ที่​ติด​ป้าย ❌ ซึ่ง​ตั้งใจ​ให้​พัง): คำ​ว่า “รับ​พัน​คอน​เนกชัน” ใน​หัว​บท​เป็นการ​เล่า​ถึง model ไม่ใช่​ตัวเลข​ที่​เรา​วัด — บท​นี้​ไม่​ได้​ยิง​โหลด​พัน​สาย​ใส่ server เพื่อ​พิสูจน์ สิ่ง​ที่​ยืนยัน​ได้​จริง​มี​สอง​อย่าง​คือ​ขนาด task ที่​เอกสาร Tokio ระบุ​ไว้​เอง (“single allocation and 64 bytes”) กับ​ข้อเท็จจริง​ว่า​คอน​เนกชัน​ที่​รอ​อยู่​ไม่​ได้​ยึด OS thread ไว้​อีก​ต่อ​ไป ส่วน6 file ที่​ระบุ​ใน callout ด้าน​บน​ผ่าน build/run/test/clippy บน target native x86_64-unknown-linux-gnu (กลเม็ด musl + rust-lld ที่ #22/#23 ใช้ได้​เพราะ​เป็น zero-crate ปลดระวาง​ไป​ตั้งแต่​บท 1 เพราะ tokio ลาก mio/socket2/libc เข้า​มา) ส่วน bad_endian.rs compile ผ่าน​แล้ว รัน​จริง จน​ได้​เลข 301,989,888 มา ส่วน bad_split.rs compile ไม่​ผ่าน​โดย​เจตนา เรา​จึง​ลง​ข้อความ error[E0597] ของ​มัน​ไว้​แทน output ทุก output ข้าง​บน​คือ​ข้อความ​จริง​ที่​พิมพ์​ออก​มา และ​ทุก error message คือ​ข้อความ​จริง​ที่ rustc 1.97.1 ตอบ​กลับ​มา รวม​ถึง error[E0597] ของ split() — เรา​ลอก​มา​ตาม​ที่ compiler พูด ไม่​ได้​ปรับ​ให้​ตรง​กับ​ที่​เดา​ไว้​ล่วงหน้า ข้อ​ยกเว้น​ข้อ​เดียว​ที่​เป็น การ​ไล่​เหตุผล ไม่ใช่​ผล​ที่​วัด​มา​คือ​กรณี “ลืม arm Ok(0) แล้ว loop ไม่​จบ” ใน​หัวข้อ​กฎ correctness ซึ่ง​เรา​ไม่​ได้ ship file ให้​รัน​ดู ส่วน​สิ่ง​ที่​ไม่ deterministic คือ​เลข ephemeral port ฝั่ง client กับ​ลำดับ​บรรทัด​ของ cargo test ที่​รัน​ขนาน​กัน ซึ่ง​เปลี่ยน​ได้​ทุก​ครั้ง​ที่​รัน

tokio::net ยก​รูป​ของ std::net มา​เกือบ 1 ต่อ 1 — bind / accept / connect ชื่อ​เดิม คืน​ค่า​เดิม ต่าง​แค่​มี .await ต่อ​ท้าย​และ​หนึ่ง​คอน​เนกชัน​กลาย​เป็น1 task ไม่ใช่1 OS thread; verb ของ I/O ทั้งหมด (read write_all read_exact flush read_u32_le) อยู่​บน extension trait จึง​ต้อง use tokio::io::{AsyncReadExt, AsyncWriteExt}; ไม่​งั้น​ได้ error[E0599] ว่า method ไม่มี​อยู่ ทั้ง​ที่​มัน​มี​อยู่; reactor คือ​คน​ที่​เอา fd ไป​ลง​ทะเบียน​กับ epoll ผ่าน mio แล้ว​เรียก wake() ให้ task ตอน kernel บอกว่า​พร้อม — คือ handshake เดิม​ของ​บท 1 ที่​เปลี่ยน​คน​กด​ปุ่ม; wire ของ #22 ย้าย​มา​ได้​ทั้งดุ้น​ด้วย read_u32_le + read_exact และ endianness ผิด​คือ​กับดัก​เงียบ ที่ compile ผ่าน​แล้ว​อ่าน​ความ​ยาว 18 เป็น 301,989,888; กฎ correctness สอง​ข้อ​คือ read() คืน Ok(0) แปล​ว่า​ปิด​สะอาด ส่วน read_exact ที่​ขาด​กลางคัน​คืน UnexpectedEof และ​ถ้า​อยาก​แยก​สอง​อย่าง​นี้​ออก​จาก​กันจริงๆ ต้อง​อ่าน​หัว frame ด้วย​มือ​แบบ read_frame_or_eof; และ​สุดท้าย read กับ write ที่​อยู่​คนละ task ต้อง​ใช้ into_split() ที่​คืน owned halves เพราะ split() คืน​ของ​ที่​ยืม​มา​ซึ่ง​ชน 'static ของ spawn ตรงๆ ด้วย error[E0597]

บท 5 เรา​จะ​เจอ​คำถาม​ที่ .NET dev ถาม​เป็น​ข้อ​แรก​เสมอ: accept loop ใน​บท​นี้​วน​ไม่รู้​จบ​และ​ไม่มี​ทางออก — แล้ว CancellationToken ของ Rust อยู่​ไหน คำ​ตอบ​คือ​ไม่มี เพราะ​การ​ยกเลิก​ใน async Rust คือ​การ drop future ทิ้ง บท​หน้า​เรา​จะ​ใช้ select! แข่ง accept() กับ​สัญญาณ​หยุด เจอ timeout ที่​คืน Result แทน​การ​โยน exception และ​เจอ​กฎ cancellation safety ที่​บอกว่า read_exact ของ​บท​นี้ ไม่​ปลอดภัย ที่​จะ​วาง​ไว้​ใน select! ตรงๆ


🔗 อ้างอิง​ต้นทาง​ของ​บท​นี้

บท​นี้​อิง​ต้นทาง​ที่​ลง​วัน​ที่​กำกับ อ่าน​ต่อ​ได้​โดยตรง:

  • tokio 1.53.1 บน crates.io API (เข้าถึง 2026-07-27) — รุ่น​ที่​ทั้ง​คอร์ส pin ไว้ ปล่อย​เมื่อ 2026-07-20 และ​รายการ feature ที่​มี​ให้​เลือก รวม​ถึง net, io-util, rt, rt-multi-thread, macros
  • tokio::io::AsyncReadExt — docs.rs, tokio 1.53.1 (เข้าถึง 2026-07-27) — extension trait ที่​แจก read / read_exact / read_u32_le และ​กติกา​ว่า read คืน Ok(0) เมื่อ peer ปิด​คอน​เนกชัน (pattern Ok(0) => return ของ echo server มา​จาก​หน้า​นี้)
  • AsyncReadExt::read_exact — docs.rs, tokio 1.53.1 (เข้าถึง 2026-07-27) — อ่าน​ให้​ครบ buf.len() หรือ​คืน Err(ErrorKind::UnexpectedEof) ถ้า stream จบ​ก่อน
  • TcpStream::into_split — docs.rs, tokio 1.53.1 (เข้าถึง 2026-07-27) — คืน owned halves ที่​ย้าย​ข้าม task ได้ แลก​กับ heap allocation หนึ่ง​ครั้ง ต่าง​จาก split ที่​คืน​ของ​ยืม​ซึ่ง​ย้าย​เข้า task แยก​ไม่​ได้
  • tokio::task::spawn — docs.rs, tokio 1.53.1 (เข้าถึง 2026-07-27) — bound F: Future + Send + 'static ที่​เป็นต้นเหตุ​ของ error[E0597] ใน​หัวข้อ split()
  • #[tokio::main] — docs.rs, tokio 1.53.1 (เข้าถึง 2026-07-27) — default flavor คือ multi_thread จึง​ต้อง​เปิด feature rt-multi-thread; flavor = "current_thread" ต้องการ​แค่ rt กับ macros
  • Tokio Tutorial — Spawning (เข้าถึง 2026-07-27) — ที่มา​ของ​ตัวเลข task ขนาด “single allocation and 64 bytes” และ pattern accept loop ที่ spawn 1 task ต่อ​หนึ่ง​คอน​เนกชัน
  • Stream.ReadExactlyAsync — Microsoft Learn (เข้าถึง 2026-07-27) — คู่​เทียบ​ฝั่ง .NET ของ read_exact เข้า​มา​ใน .NET 7 (2022-11-08) ก่อนหน้า​นั้น​ต้อง​เขียน loop อ่าน​ซ้ำ​เอง

เช็กความเข้าใจ — บทที่ 4

ข้อ 1 / 3

คุณเขียน echo server ด้วย tokio::net::TcpStream แล้ว cargo build ตอบว่า error[E0599] no method named read found for struct tokio::net::TcpStream in the current scope สาเหตุคืออะไร?