Keywords: concurrency, ownership, borrowing, tooling
อ่านแบบคน Python:
- ถ้าอยากเอา “ภาพรวม” ก่อน: จำว่า shared state = หลายที่เห็นข้อมูลก้อนเดียว และปัญหาคือ “ลำดับเวลา”
- ถ้าอยาก “ลงมือทำ”: โฟกัส
Arc(แชร์ได้) +Mutex/RwLock(กันแก้พร้อมกัน) แล้วค่อยดูตัวอย่าง - ถ้าติด: เปิด 12-learning-playbook.md
บทนี้สอน “หลักการแชร์ state ให้ปลอดภัย” (conceptual) และ pattern พื้นฐานของ Rust (Arc/Mutex/RwLock) เพื่อให้คุณเข้าใจว่า ควรใช้เมื่อไร และ เพราะอะไร
ขอบเขตของบทนี้ (ตั้งใจให้ปลอดภัย):
- ไม่สอนการทำเครื่องมือ network/proxy/tunnel
- โฟกัสที่ mental model + pitfalls ที่เจอบ่อย + ตัวอย่างเล็ก ๆ ที่รันจบ
อ้างอิงมาตรฐาน:
Arc: https://doc.rust-lang.org/std/sync/struct.Arc.htmlMutex: https://doc.rust-lang.org/std/sync/struct.Mutex.htmlRwLock: https://doc.rust-lang.org/std/sync/struct.RwLock.html
- shared state: มีข้อมูล “ก้อนเดียว” ที่หลาย execution context (threads/tasks) เห็นร่วมกัน
- mutable shared state: หลายที่สามารถ “เขียน/แก้” state เดียวกันได้
- race condition: ลำดับการทำงาน (timing) ทำให้ผลลัพธ์ผิด แม้จะ memory-safe
- data race: เข้าถึงหน่วยความจำพร้อมกันแบบไม่ synchronize (Rust กันอย่างจริงจังใน safe code)
แนวคิด:
- concurrency ไม่ใช่แค่ “ทำพร้อมกันเร็วขึ้น” แต่คือ “ทำพร้อมกันโดยยังรักษา invariant ของ state”
ใน Python:
- global dict แก้ง่าย
- แต่ race condition เกิดง่าย และ debug ยาก
ปัญหาคลาสสิกคือการอัปเดตแบบ read-modify-write:
- ที่เราคิดว่าเป็นหนึ่งการกระทำ:
counter += 1 - แต่จริง ๆ คือ อ่านค่าเดิม → บวก → เขียนค่าใหม่ และช่วงนี้สามารถ interleave กับ thread อื่นได้
- “มี GIL แล้วปลอดภัย” → ไม่จริงสำหรับ correctness ของ state เสมอไป
- GIL ช่วยเรื่อง memory safety ของ interpreter
- แต่ไม่ได้ทำให้ logic ของโปรแกรมถูกเสมอ (interleaving ยังทำให้ invariant พังได้)
- “แค่ทดสอบแล้วผ่าน” → concurrency bug มักเป็น heisenbug
- ออกอาการยาก
- ขึ้นกับ timing และ load
Rust เลือกแนวทางตรงข้าม: ถ้าจะ “แชร์และแก้ไข” คุณต้องบอกกลไกให้ชัดเจนใน type
ถ้าอยู่ใน thread เดียว:
- ส่ง
&mut AppStateไปฟังก์ชัน
Mental model:
&mut Tแปลว่า “ตอนนี้มีคนถือสิทธิ์แก้Tอยู่คนเดียว”- ถ้ามีใครจะเข้ามาแก้พร้อมกัน compiler จะไม่ยอมให้คอมไพล์
เหมาะกับ:
- โปรแกรมที่ทำงานตามลำดับ (single-threaded)
- code path ที่อยากให้เรียบง่ายและ predictable
ข้อควรระวัง:
- เมื่อระบบเริ่มซับซ้อน คนมัก “เผลอ” อยากแชร์ state ไปหลายจุดพร้อมกัน → ถึงตอนนั้นค่อยเลือก pattern ใหม่
ถ้าต้องแชร์ state ข้าม threads และต้องเขียนร่วมกัน:
- ใช้
Arc<Mutex<AppState>>
หมายเหตุจากโลกจริง (เชิงเตือน):
- ถ้าโค้ดเริ่ม “ยิงงานเป็นร้อย/พัน threads” (เช่นใช้
_thread.start_new_threadแบบไม่คุม lifecycle) ระบบจะ debug ยากมาก และมักพังแบบคาดเดายาก - ใน Rust คุณถูกบังคับให้ “คิดเรื่อง owner ของงาน + จุด join + ขอบเขตของ lock” ชัดขึ้น ซึ่งช่วยลดคลาสบั๊กนี้
Mental model (อธิบายแบบจับภาพ):
Arc<T>= shared ownership แบบ thread-safe (refcount แบบ atomic)Mutex<T>= ประตูที่อนุญาตให้ “เข้าไปแก้Tได้ทีละคน”lock()จะได้ guard (ตัวแปรที่ถือสิทธิ์ lock) บอกว่า “ตอนนี้ฉันถือ lock อยู่”- พอ guard หลุด scope → lock ถูกปล่อยอัตโนมัติ (แนวคิดเดียวกับ
with lock:ใน Python แต่ Rust ใช้ scope/RAII)
เหมาะกับ:
- state ถูกแก้โดยหลาย thread
- critical section สั้น ๆ (ถือ lock ไม่นาน)
ข้อควรระวัง:
- อย่าถือ lock แล้วไปทำ I/O หนัก ๆ / sleep / join thread อื่น เพราะจะทำให้ระบบหน่วงและเสี่ยง deadlock
ถ้าอ่านเยอะ เขียนน้อย:
- ใช้
Arc<RwLock<AppState>>
Mental model:
RwLock<T>แยก “อ่าน” กับ “เขียน”- อ่าน (
read()) หลายคนพร้อมกันได้ - เขียน (
write()) ต้องได้สิทธิ์แบบ exclusive
- อ่าน (
เหมาะกับ:
- อ่านถี่มาก เขียนน้อย
- อยากให้หลาย thread อ่าน snapshot ได้พร้อมกัน
ข้อควรระวัง:
RwLockไม่ได้ดีกว่าMutexเสมอ (ถ้าเขียนถี่Mutexอาจง่ายและเร็วกว่า)- บาง workload อาจเสี่ยง writer starvation: ถ้าอ่านถี่มาก ฝั่งเขียนอาจรอคิวนาน (ขึ้นกับ implementation)
Python (แนวคิดเทียบ):
- แชร์ dict/list ข้าม thread ได้ง่าย แต่ต้อง “ล็อกเอง” เพื่อกัน race
ตัวอย่าง (Python):
import threading
def main() -> None:
state = {"counter": 0}
lock = threading.Lock()
def worker() -> None:
with lock:
state["counter"] += 1
threads = [threading.Thread(target=worker) for _ in range(4)]
for t in threads:
t.start()
for t in threads:
t.join()
print("counter=", state["counter"])
main()Output (example):
counter= 4
Rust:
use std::sync::{Arc, Mutex};
use std::thread;
#[derive(Default)]
struct AppState {
counter: u64,
}
fn main() {
let state = Arc::new(Mutex::new(AppState::default()));
let mut handles = vec![];
for _ in 0..4 {
let state = Arc::clone(&state);
handles.push(thread::spawn(move || {
let mut guard = state.lock().unwrap();
guard.counter += 1;
}));
}
for h in handles {
h.join().unwrap();
}
let final_value = state.lock().unwrap().counter;
println!("counter={final_value}");
}Output (example):
counter=4
จุดสอน (อ่านให้เข้าใจจริง):
Arc::clone(&state)เพิ่ม refcount (ไม่ copy ข้อมูล)moveใน closure คือ “ย้าย ownership ของ Arc clone” เข้าไปใน thread เพื่อให้ state อยู่ได้พอสำหรับ lifetime ของ thread- guard เป็นตัวกำหนดช่วงเวลาที่ lock ถูกถืออยู่
lock() อาจ fail ได้ถ้า mutex ถูก “poisoned” (เช่น thread อื่น panic ขณะถือ lock)
- ในบทเรียนใช้
unwrap()เพื่อให้สั้น - ในงานจริงคุณควรรู้ว่ามันหมายถึงอะไร และตัดสินใจว่าจะ recover/log/abort อย่างไร
หลักสั้น ๆ ที่ใช้ได้จริง:
- ถือ lock ให้น้อยที่สุดเท่าที่จำเป็น (ลด lock scope)
- อย่าถือ lock แล้วไปทำงานหนัก (I/O, sleep, join)
- ระวัง lock ซ้อนกัน (deadlock)
- ถ้าเป้าหมายคือ “ส่งคำสั่ง/เหตุการณ์” ข้าม thread ให้คิดถึง message passing (เช่น channel) ก่อน shared mutable
Pitfalls ที่พบบ่อย:
- ถือ lock กว้างเกินไป
- อาการ: โปรแกรม “ช้าแปลก ๆ” ทั้งที่ไม่มี error
- สาเหตุ: คนอื่นต้องรอ lock นาน
- ล็อกซ้อนกันโดยไม่มี order ที่ชัด
- อาการ: โปรแกรมค้าง (deadlock)
- สาเหตุ: Thread A ล็อก X แล้วรอ Y, ขณะเดียวกัน Thread B ล็อก Y แล้วรอ X
- แนวคิด: ถ้าจำเป็นต้องมีหลาย lock ให้กำหนดลำดับการล็อก (lock ordering) แบบตายตัวทั้งโปรเจกต์
- เรียกฟังก์ชันอื่นขณะถือ lock (re-entrancy surprise)
- อาการ: debug ยาก เพราะดูเหมือนไม่มีจุดค้างตรง ๆ
- สาเหตุ: ฟังก์ชันที่ถูกเรียกไปอาจไปล็อกตัวเดิม/ตัวอื่นเพิ่ม (โดยไม่ตั้งใจ)
- สับสน race condition vs data race
- Rust กัน data race ได้ดีใน safe code
- แต่ race condition ยังทำให้ logic พังได้ ถ้าคุณออกแบบ critical section/ordering ไม่ดี
เวลาคุณมีงานที่ต้องรัน “ตลอดเวลา” เช่น
- อัปเดตสถิติเป็นช่วง ๆ
- ดูแล state (rebuild/recount/report)
- push/pull ข้อมูล
ข้อผิดพลาดที่พบบ่อยใน Python คือ “ยิง thread ไปเรื่อย ๆ” โดยไม่มีขอบเขตและไม่มีจุดหยุด → สุดท้ายกลายเป็นระบบที่ควบคุมไม่ได้
แนวคิดที่ใช้ได้จริงใน Rust:
- มี owner ของงานชัด (ใคร start/stop)
- มี handle/ช่องทางให้สั่งหยุด (cancel) และ
joinให้จบแบบสะอาด - shared state อยู่หลัง
Arc<Mutex<_>>/Arc<RwLock<_>>และ lock scope แคบ
ภาพ pseudo-structure:
- main สร้าง state และส่ง clone เข้า worker
- worker loop อ่านสัญญาณหยุด แล้วทำงานเป็นช่วง ๆ
(บทนี้ยังคงโฟกัส conceptual ไม่ลงรายละเอียด network/ops)
- เปลี่ยนจาก
Mutexเป็นRwLockแล้วทำอ่านค่าหลายครั้ง
- เป้าหมาย: เข้าใจความต่างระหว่าง
lock()(exclusive) กับread()/write()(shared vs exclusive) - ระวัง: อย่าถือ read lock แล้วพยายาม upgrade เป็น write ใน flow เดียวกันโดยไม่คิดเรื่อง deadlock
- เพิ่ม field
last_message: Option<String>แล้วเขียนค่าจาก thread เดียว
- เป้าหมาย: แยกกรณี “เขียนโดย thread เดียว” กับ “อ่านโดยหลาย thread”
- แนวคิด: ถ้าจุดเขียนมีเพียงจุดเดียว บางทีการจัด architecture ให้ชัด (single owner) จะง่ายกว่าใส่ lock หนัก ๆ