Skip to content

Latest commit

 

History

History
264 lines (187 loc) · 14.9 KB

File metadata and controls

264 lines (187 loc) · 14.9 KB

07 — Shared State & Concurrency (conceptual, safe)

TOC · Prev · Next

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 ที่เจอบ่อย + ตัวอย่างเล็ก ๆ ที่รันจบ

อ้างอิงมาตรฐาน:


0) ก่อนเริ่ม: แปลคำสำคัญให้ชัด

  • shared state: มีข้อมูล “ก้อนเดียว” ที่หลาย execution context (threads/tasks) เห็นร่วมกัน
  • mutable shared state: หลายที่สามารถ “เขียน/แก้” state เดียวกันได้
  • race condition: ลำดับการทำงาน (timing) ทำให้ผลลัพธ์ผิด แม้จะ memory-safe
  • data race: เข้าถึงหน่วยความจำพร้อมกันแบบไม่ synchronize (Rust กันอย่างจริงจังใน safe code)

แนวคิด:

  • concurrency ไม่ใช่แค่ “ทำพร้อมกันเร็วขึ้น” แต่คือ “ทำพร้อมกันโดยยังรักษา invariant ของ state”

1) Python threads + global dict: ทำไมเสี่ยง

ใน Python:

  • global dict แก้ง่าย
  • แต่ race condition เกิดง่าย และ debug ยาก

ปัญหาคลาสสิกคือการอัปเดตแบบ read-modify-write:

  • ที่เราคิดว่าเป็นหนึ่งการกระทำ: counter += 1
  • แต่จริง ๆ คือ อ่านค่าเดิม → บวก → เขียนค่าใหม่ และช่วงนี้สามารถ interleave กับ thread อื่นได้

1.1 ความเข้าใจคลาดเคลื่อนที่เจอบ่อย

  1. “มี GIL แล้วปลอดภัย” → ไม่จริงสำหรับ correctness ของ state เสมอไป
  • GIL ช่วยเรื่อง memory safety ของ interpreter
  • แต่ไม่ได้ทำให้ logic ของโปรแกรมถูกเสมอ (interleaving ยังทำให้ invariant พังได้)
  1. “แค่ทดสอบแล้วผ่าน” → concurrency bug มักเป็น heisenbug
  • ออกอาการยาก
  • ขึ้นกับ timing และ load

Rust เลือกแนวทางตรงข้าม: ถ้าจะ “แชร์และแก้ไข” คุณต้องบอกกลไกให้ชัดเจนใน type


2) รูปแบบ 3 แบบที่ควรรู้ (เลือกให้เหมาะกับโจทย์)

2.1 Single-thread (ง่ายสุด)

ถ้าอยู่ใน thread เดียว:

  • ส่ง &mut AppState ไปฟังก์ชัน

Mental model:

  • &mut T แปลว่า “ตอนนี้มีคนถือสิทธิ์แก้ T อยู่คนเดียว”
  • ถ้ามีใครจะเข้ามาแก้พร้อมกัน compiler จะไม่ยอมให้คอมไพล์

เหมาะกับ:

  • โปรแกรมที่ทำงานตามลำดับ (single-threaded)
  • code path ที่อยากให้เรียบง่ายและ predictable

ข้อควรระวัง:

  • เมื่อระบบเริ่มซับซ้อน คนมัก “เผลอ” อยากแชร์ state ไปหลายจุดพร้อมกัน → ถึงตอนนั้นค่อยเลือก pattern ใหม่

2.2 Multi-thread shared mutable

ถ้าต้องแชร์ 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

2.3 Read-heavy shared state

ถ้าอ่านเยอะ เขียนน้อย:

  • ใช้ Arc<RwLock<AppState>>

Mental model:

  • RwLock<T> แยก “อ่าน” กับ “เขียน”
    • อ่าน (read()) หลายคนพร้อมกันได้
    • เขียน (write()) ต้องได้สิทธิ์แบบ exclusive

เหมาะกับ:

  • อ่านถี่มาก เขียนน้อย
  • อยากให้หลาย thread อ่าน snapshot ได้พร้อมกัน

ข้อควรระวัง:

  • RwLock ไม่ได้ดีกว่า Mutex เสมอ (ถ้าเขียนถี่ Mutex อาจง่ายและเร็วกว่า)
  • บาง workload อาจเสี่ยง writer starvation: ถ้าอ่านถี่มาก ฝั่งเขียนอาจรอคิวนาน (ขึ้นกับ implementation)

3) ตัวอย่าง: Arc<Mutex<_>> แบบสั้น

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 ถูกถืออยู่

3.1 เรื่อง lock().unwrap() และ mutex poisoning

lock() อาจ fail ได้ถ้า mutex ถูก “poisoned” (เช่น thread อื่น panic ขณะถือ lock)

  • ในบทเรียนใช้ unwrap() เพื่อให้สั้น
  • ในงานจริงคุณควรรู้ว่ามันหมายถึงอะไร และตัดสินใจว่าจะ recover/log/abort อย่างไร

4) Checklist กัน bug / deadlock (สำหรับมือใหม่)

หลักสั้น ๆ ที่ใช้ได้จริง:

  • ถือ lock ให้น้อยที่สุดเท่าที่จำเป็น (ลด lock scope)
  • อย่าถือ lock แล้วไปทำงานหนัก (I/O, sleep, join)
  • ระวัง lock ซ้อนกัน (deadlock)
  • ถ้าเป้าหมายคือ “ส่งคำสั่ง/เหตุการณ์” ข้าม thread ให้คิดถึง message passing (เช่น channel) ก่อน shared mutable

Pitfalls ที่พบบ่อย:

  1. ถือ lock กว้างเกินไป
  • อาการ: โปรแกรม “ช้าแปลก ๆ” ทั้งที่ไม่มี error
  • สาเหตุ: คนอื่นต้องรอ lock นาน
  1. ล็อกซ้อนกันโดยไม่มี order ที่ชัด
  • อาการ: โปรแกรมค้าง (deadlock)
  • สาเหตุ: Thread A ล็อก X แล้วรอ Y, ขณะเดียวกัน Thread B ล็อก Y แล้วรอ X
  • แนวคิด: ถ้าจำเป็นต้องมีหลาย lock ให้กำหนดลำดับการล็อก (lock ordering) แบบตายตัวทั้งโปรเจกต์
  1. เรียกฟังก์ชันอื่นขณะถือ lock (re-entrancy surprise)
  • อาการ: debug ยาก เพราะดูเหมือนไม่มีจุดค้างตรง ๆ
  • สาเหตุ: ฟังก์ชันที่ถูกเรียกไปอาจไปล็อกตัวเดิม/ตัวอื่นเพิ่ม (โดยไม่ตั้งใจ)
  1. สับสน race condition vs data race
  • Rust กัน data race ได้ดีใน safe code
  • แต่ race condition ยังทำให้ logic พังได้ ถ้าคุณออกแบบ critical section/ordering ไม่ดี

5) Background tasks & lifecycle (ภาพแบบงานจริง)

เวลาคุณมีงานที่ต้องรัน “ตลอดเวลา” เช่น

  • อัปเดตสถิติเป็นช่วง ๆ
  • ดูแล 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)


6) แบบฝึกหัด

  1. เปลี่ยนจาก Mutex เป็น RwLock แล้วทำอ่านค่าหลายครั้ง
  • เป้าหมาย: เข้าใจความต่างระหว่าง lock() (exclusive) กับ read()/write() (shared vs exclusive)
  • ระวัง: อย่าถือ read lock แล้วพยายาม upgrade เป็น write ใน flow เดียวกันโดยไม่คิดเรื่อง deadlock
  1. เพิ่ม field last_message: Option<String> แล้วเขียนค่าจาก thread เดียว
  • เป้าหมาย: แยกกรณี “เขียนโดย thread เดียว” กับ “อ่านโดยหลาย thread”
  • แนวคิด: ถ้าจุดเขียนมีเพียงจุดเดียว บางทีการจัด architecture ให้ชัด (single owner) จะง่ายกว่าใส่ lock หนัก ๆ