เอ้า มาเรียนเรื่อง Thread ใน Rust กันเถอะ

Rust Logo

เราจะเริ่มกันที่หัวข้อแรกที่เป็นพื้นฐานที่สุด

  1. Main Thread คือเจ้าชีวิต ถ้า Main thread ทำงานเสร็จและจบโปรแกรม Thread อื่นๆ ที่สร้างไว้จะถูกปิดตัวลงทันที ไม่ว่ามันจะทำงานเสร็จหรือไม่ก็ตาม
  2. ใน Rust เราใช้ฟังก์ชัน thread::spawn เพื่อสร้างการทำงานแยกออกมาจาก Thread หลัก (Main thread)
  3. เราจะส่งโค้ดเข้าไปใน spawn ผ่านสิ่งที่เรียกว่า Closure (คล้ายๆ Lambda ในภาษาอื่น)
  4. Closure ที่ส่งเข้าไปใน spawn จะต้องเป็นแบบ ‘static ซึ่งหมายความว่ามันไม่สามารถอ้างอิงตัวแปรภายนอกที่มีอายุสั้นกว่าได้

เรื่อง static closure เนี่ยะ ต้องรู้ก่อนว่าอะไรที่เป็น static จะมีอายุยาวนานตลอดการทำงานของโปรแกรม (จนกว่าโปรแกรมจะจบ) ดังนั้น lifetime มันจึงยาวมากๆ จนทำให้ตัวแปรด้านนอก มักจะอายุสั้นกว่ามัน ทั้งสิ้น ถ้าอ่านลงไปด้านล่างจะพบว่า เราจะต้อง move สิทธิ์การเป็นเจ้าของตัวแปรเข้าไปใน Thread ใหม่ เพื่อให้มันมีอายุยาวนานพอที่จะใช้งานได้ใน Closure นั้น แต่ว่า static closure มันไม่ได้อยู่ตลอดไปจริงๆ มันแค่มีความสามารถที่จะอายุยืนยาวเท่าตัวโปรแกรมเอง แต่ถ้ามันจบก่อนโปรแกรมจบ มันก็จะตายไปตามปกติ

โค้ดตัวอย่างง่ายๆ ให้ดู แล้วคุณลองสังเกตผลลัพธ์ จากนั้นตอบคำถาม

use std::thread;
use std::time::Duration;

fn main() {
    thread::spawn(|| {
        for i in 1..10 {
            println!("สวัสดีจาก thread ใหม่! ลำดับที่ {}", i);
            thread::sleep(Duration::from_millis(1));
        }
    });

    for i in 1..5 {
        println!("สวัสดีจาก thread หลัก! ลำดับที่ {}", i);
        thread::sleep(Duration::from_millis(1));
    }
}

คำถาม: จากโค้ดข้างบน คุณคิดว่า Thread ใหม่จะทำงานจนถึงลำดับที่ 10 หรือไม่? และเพราะอะไรครับ? (ลองเดาได้เลย ไม่ต้องกลัวผิด!)

คำตอบ: Thread ใหม่จะไม่ทำงานจนถึงลำดับที่ 10 เพราะว่าเมื่อ Main thread ทำงานเสร็จและจบโปรแกรม (หลังจากลำดับที่ 5) Thread ใหม่ที่ถูกสร้างขึ้นจะถูกปิดตัวลงทันที ไม่ว่าจะทำงานเสร็จหรือไม่ก็ตาม นี่คือข้อจำกัดของการทำงานแบบ Multithreading ใน Rust ที่เราต้องระวัง!

โดยที่จริงๆ”คาดเดาจังหวะได้ยาก” ว่า Thread ที่สร้างขึ้นมาใหม่นั้น จะจบที่ Loop ที่เท่าไหร่ คาดเดาว่าประมาณแถวๆ รอบที่ 5 อันนี้คือสัจธรรมของ Concurrency เลยครับ เพราะมันขึ้นอยู่กับตัวจัดการของระบบปฏิบัติการ (OS Scheduler) ว่าจะสลับให้ใครรันตอนไหน


เพื่อแก้ปัญหานี้ เราต้องรู้จักเครื่องมือตัวที่สองครับ นั่นคือ Join Handle

เมื่อเราสร้าง Thread ใหม่ด้วย thread::spawn มันจะคืนค่าเป็น JoinHandle ซึ่งเป็นตัวแทนของ Thread ที่เราสร้างขึ้นมา เราสามารถใช้ JoinHandle นี้เพื่อรอให้ Thread ที่สร้างขึ้นมาทำงานเสร็จสมบูรณ์ก่อนที่ Main thread จะจบโปรแกรมได้

โค้ดตัวอย่างที่ใช้ JoinHandle จะเป็นแบบนี้ครับ

use std::thread;

fn main() {
    // 1. เก็บ Handle ไว้
    let handle = thread::spawn(|| {
        for i in 1..10 {
            println!("สวัสดีจาก thread ใหม่! ลำดับที่ {}", i);
        }
    });

    for i in 1..5 {
        println!("สวัสดีจาก thread หลัก! ลำดับที่ {}", i);
    }

    // 2. สั่งให้ Main รอตรงนี้จนกว่า thread ใหม่จะทำงานจบ
    handle.join().unwrap();

    println!("--- จบการทำงานทุก thread อย่างสมบูรณ์ ---");
}

ลองทายดูครับ: ถ้าผมย้ายบรรทัด handle.join().unwrap(); ขึ้นไปไว้ “ก่อน” Loop ของ thread หลัก (คือบรรทัดถัดจาก spawn ทันที) ผลลัพธ์การทำงานจะเปลี่ยนไปอย่างไรครับ? (ใบ้ให้ว่า: ลำดับการ Print จะเปลี่ยนไปแบบเห็นได้ชัดเลย)

use std::thread;
fn main() {
    let handle = thread::spawn(|| {
        for i in 1..10 {
            println!("สวัสดีจาก thread ใหม่! ลำดับที่ {}", i);
        }
    });

    // ย้ายบรรทัดนี้ขึ้นไปก่อน Loop ของ Main thread
    handle.join().unwrap();

    for i in 1..5 {
        println!("สวัสดีจาก thread หลัก! ลำดับที่ {}", i);
    }

    println!("--- จบการทำงานทุก thread อย่างสมบูรณ์ ---");
}

คำตอบ: ถ้าเราย้ายบรรทัด handle.join().unwrap(); ขึ้นไปไว้ก่อน Loop ของ thread หลัก ผลลัพธ์จะเปลี่ยนไปอย่างชัดเจน เพราะ Main thread จะรอให้ Thread ใหม่ทำงานเสร็จสมบูรณ์ก่อนที่จะดำเนินการต่อไปยัง Loop ของ Main thread นั่นหมายความว่า เราจะเห็นข้อความจาก Thread ใหม่ทั้งหมดก่อนที่เราจะเห็นข้อความจาก Main thread

ถ้าคำตอบของคุณคือแบบนี้ แสดงว่าคุณเข้าใจกลไกของ Blocking แล้ว การเรียก .join() คือการบอก Main thread ว่า “หยุดรอตรงนี้จนกว่าลูกน้องจะทำงานเสร็จนะ” ถ้าเอาไปไว้ข้างบน มันก็เลยกลายเป็น Sequential (ทำทีละอย่าง) แทนที่จะเป็น Parallel (ทำขนานกัน)


การใช้ move Closure (การย้าย Ownership เข้า Thread)

ทีนี้เรามาถึงด่านปราบเซียนของจริง ซึ่งเป็นจุดที่ Rust แสดงความเหนือชั้นด้านความปลอดภัย สมมติว่าเรามีข้อมูลบางอย่างใน Main thread แล้วอยากส่งเข้าไปให้ Thread ใหม่ใช้งานล่ะ?

ลองดูโค้ดที่ “จงใจทำให้พัง” ตัวนี้ก่อน

use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(|| {
        println!("ข้อมูลใน Vector: {:?}", v); // <--- ตรงนี้แหละที่ Rust จะด่าเรา!
    });

    handle.join().unwrap();
}

ทำไมถึงพัง?

คอมไพเลอร์ของ Rust จะกังวลว่า: “ถ้า Main thread เผลอทำลาย (drop) ตัวแปร v ทิ้งไปก่อนที่ Thread ใหม่จะทำงานเสร็จล่ะ? Thread ใหม่ก็หน้าแตกสิ เพราะข้อมูลหายไปแล้ว!” นี่คือปัญหาเรื่อง Dangling Pointer ที่ในภาษาอื่นอาจจะทำให้โปรแกรม Crash หรือ Error แปลกๆ แต่ Rust จะไม่ยอมให้คุณคอมไพล์ผ่านตั้งแต่แรก

ทางแก้: คีย์เวิร์ด move

เราต้องใส่ move ไว้หน้า Closure เพื่อบอกว่า “ยกความเป็นเจ้าของ (Ownership) ของตัวแปร v ให้ Thread ใหม่ไปเลย!”

use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(move || {
        println!("ข้อมูลใน Vector: {:?}", v); // ตอนนี้มันจะทำงานได้แล้ว!
    });

    handle.join().unwrap();
}

โค้ดยาวหน่อย แต่สังเกตุ Keyword move ตรง thread::spawn(..) ไหม นั่นแหละที่ทำให้มันย้าย Ownership ของ v เข้าไปใน Thread ใหม่ได้อย่างปลอดภัย

คำถามวัดใจ

ถ้าเราใส่ move เพื่อยก v ให้ Thread ใหม่ไปแล้ว… หลังจากบรรทัด handle.join().unwrap(); ใน Main thread ถ้าผมพยายามจะสั่ง println!(“”, v); อีกครั้งหนึ่ง คุณคิดว่าจะเกิดอะไรขึ้นครับ?

use std::thread;
fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(move || {
        println!("ข้อมูลใน Vector: {:?}", v);
    });

    handle.join().unwrap();

    // ลองสั่งพิมพ์ v อีกครั้งหลังจากที่มันถูกย้ายไปแล้ว
    println!("ลองพิมพ์ v อีกครั้ง: {:?}", v); // <--- ตรงนี้จะเกิดอะไรขึ้น?
}

เฉลย

ถ้าคุณพยายามจะใช้ v ใน Main thread หลังจากที่ move มันไปแล้ว คอมไพเลอร์จะตะโกนด่าทันทีว่า: “Value used here after move” เพราะสิทธิ์การเป็นเจ้าของได้ถูกย้ายขาดไปอยู่กับ Thread ใหม่แล้ว Main thread ไม่มีสิทธิ์ยุ่งกับข้อมูลชุดนั้นอีกต่อไป นี่แหละคือสิ่งที่ทำให้ Rust แข็งแกร่ง เพราะมันกำจัดโอกาสที่จะเกิด Data Race (การแย่งกันใช้ข้อมูลจนพัง) ทิ้งไปตั้งแต่ตอนเขียนโค้ดเลย


ตอนนี้เราเรียนรู้วิธี “ย้าย” ข้อมูลเข้า Thread ไปแล้ว… แต่ชีวิตจริงเรามักจะอยาก “สื่อสาร” ระหว่าง Thread ใช่ไหมครับ? เช่น Thread หนึ่งหาข้อมูลเสร็จ แล้วส่งกลับมาบอกอีก Thread หนึ่ง

เรามาถึงหัวข้อที่ 4 การสื่อสารผ่าน Channels (mpsc)

Rust มีสโลแกนหนึ่งที่ยืมมาจากภาษา Go คือ: “Do not communicate by sharing memory; instead, share memory by communicating.” (อย่าสื่อสารด้วยการใช้หน่วยความจำร่วมกัน แต่จงแบ่งปันหน่วยความจำผ่านการสื่อสาร)

อ่านแล้วอาจจะงงๆ แต่จริงๆ มันหมายความว่า แทนที่เราจะให้หลาย Thread เข้าถึงข้อมูลเดียวกัน (ซึ่งเสี่ยงต่อการเกิด Data Race) เราควรจะให้แต่ละ Thread มีข้อมูลของตัวเอง แล้วใช้ช่องทางการสื่อสาร (Channel) เพื่อส่งข้อมูลระหว่างกันแทน

เราใช้เครื่องมือที่เรียกว่า mpsc (Multi-producer, Single-consumer)

หลักการทำงาน:

  1. สร้าง Channel ขึ้นมา ซึ่งจะได้ของมา 2 อย่าง: Transmitter (tx) ตัวส่ง และ Receiver (rx) ตัวรับ
  2. ส่ง tx เข้าไปใน Thread ใหม่ (โดยใช้ move)
  3. Thread ใหม่ส่งข้อมูลผ่าน tx
  4. Main thread นั่งรอรับข้อมูลที่ rx

ลองดูโค้ดตัวอย่างนี้ครับ

use std::sync::mpsc;
use std::thread;

fn main() {
    // 1. สร้างท่อสื่อสาร
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let val = String::from("สวัสดีจากท่อส่งข้อมูล!");
        // 2. ส่งข้อมูลผ่านท่อ
        tx.send(val).unwrap();
    });

    // 3. รอรับข้อมูลจากปลายท่อ
    let received = rx.recv().unwrap();
    println!("ได้รับข้อความ: {}", received);
}

คำถามชวนคิด

ถ้าใน Thread ใหม่ ผมส่ง val ผ่าน tx.send(val) ไปเรียบร้อยแล้ว… หลังจากบรรทัดนั้น ผมยังสามารถใช้ตัวแปร val ภายใน Thread นั้นต่อได้ไหมครับ? (ลองใช้กฎ Ownership ที่คุณเพิ่งตอบมาเมื่อกี้มาวิเคราะห์ดู!)

use std::sync::mpsc;
use std::thread;
fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let val = String::from("สวัสดีจากท่อส่งข้อมูล!");
        tx.send(val).unwrap();

        // ลองใช้ val อีกครั้งหลังจากที่มันถูกส่งไปแล้ว
        println!("ลองใช้ val อีกครั้ง: {}", val); // <--- ตรงนี้จะเกิดอะไรขึ้น?
    });

    let received = rx.recv().unwrap();
    println!("ได้รับข้อความ: {}", received);
}

คำตอบ

val ถูกย้ายไปอยู่ใน tx แล้ว เพราะ tx(..) คือการเรียกฟังชั่น และการส่งตัวแปร tx(val) พฤติกรรมจึงเหมือนการเรียกฟังชั่น ที่เราได้เรียนพื้นฐาน rust กันมาแล้ว ดังนั้นหลังจากที่เราเรียก tx.send(val) แล้ว val จะถูกย้ายไปอยู่ใน tx และเราจะไม่สามารถใช้ val ได้อีกต่อไปภายใน Thread นั้น เพราะมันถูกย้ายไปแล้ว นี่คือหลักการของ Ownership ที่ Rust ใช้เพื่อป้องกันปัญหาเรื่องการเข้าถึงข้อมูล


Shared State (Arc & Mutex)

เรามาถึงด่านสุดท้ายของพื้นฐาน Fearless Concurrency ซึ่งเป็นเรื่องที่ “ยากที่สุด” แต่ “ทรงพลังที่สุด” เมื่อเราต้องการให้หลายๆ Thread “รุมแก้ไขข้อมูลตัวเดียวกัน” พร้อมกัน

บางครั้งเราใช้ Channel ไม่ได้ เพราะเราต้องการให้ทุก Thread เข้าถึงและแก้ไขข้อมูลก้อนเดียวกัน (เช่น ตัวนับ Global Counter)

ในภาษาอื่น ถ้าสอง Thread พยายามแก้ตัวเลขตัวเดียวกันพร้อมกัน ข้อมูลจะเพี้ยน (Data Race)

แต่ใน Rust เรามี “คู่หูอันตราย” ที่จะมาจัดการเรื่องนี้

  1. Mutex (Mutual Exclusion): เปรียบเสมือน "กุญแจห้อง" ใครจะแก้ข้อมูลต้องมาเอา "กุญแจ" (Lock) ไปก่อน พอแก้เสร็จก็คืนกุญแจ คนอื่นถึงจะเข้าได้
  2. Arc (Atomic Reference Counted): เป็นตัวช่วยในการ "ปั๊มตัวชี้" (Smart Pointer) เพื่อให้หลายๆ Thread สามารถ "เป็นเจ้าของ" ข้อมูลก้อนเดียวกันได้พร้อมกัน (โดยที่ข้อมูลไม่ถูก Drop ทิ้งจนกว่า Thread สุดท้ายจะเลิกใช้)

ลองดูโค้ดที่เป็น “ร่างทอง” ของความปลอดภัยนี้ครับ

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    // สร้างตัวนับที่ถูกห่อด้วย Mutex และ Arc
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        // สร้าง "สำเนาตัวชี้" (clone) ให้แต่ละ Thread
        let counter = Arc::clone(&counter);

        let handle = thread::spawn(move || {
            // ขอ "กุญแจ" ก่อนจะแก้เลข
            let mut num = counter.lock().unwrap();
            *num += 1;
            // พอกจบ Scope นี้ กุญแจจะถูกคืนให้อัตโนมัติ (Drop)
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("ผลลัพธ์สุดท้าย: {}", *counter.lock().unwrap());
}

ในโค้ดนี้ เราสร้างตัวนับที่ถูกห่อด้วย Arc และ Mutex เพื่อให้หลายๆ Thread สามารถเข้าถึงและแก้ไขตัวนับได้อย่างปลอดภัย โดยที่ Mutex จะช่วยให้แน่ใจว่าแต่ละ Thread จะได้ “กุญแจ” ไปแก้ไขข้อมูลทีละคน และ Arc จะช่วยให้ข้อมูลไม่ถูก Drop จนกว่า Thread สุดท้ายจะเลิกใช้

คำถามอีกแล้ว

ทำไมต้อง เอา Mutex ยัดใส่ Arc

let counter = Arc::new(Mutex::new(0));

คำตอบคือ

Mutex ทำหน้าที่ “กันคนแย่งกันเขียน” แต่ Arc ทำหน้าที่ “ช่วยกันถือครองสิทธิ์”

ลองจินตนาการตามผมนะ

  1. ถ้ามีแค่ Mutex (ไม่มี Arc)
  • Mutex เปรียบเหมือน “ตู้เซฟที่มีกุญแจดอกเดียว”
  • คุณมีตู้เซฟ Mutex::new(0) อยู่ใน Main thread
  • พอคุณจะส่งเข้าไปใน Thread ที่ 1 คุณต้องใช้ move
  • ผลคือ: Main thread เสียความเป็นเจ้าของตู้เซฟไปให้ Thread ที่ 1 แล้ว!
  • พอ Loop รอบที่ 2 จะส่งให้ Thread ที่ 2… พังครับ! เพราะ Main thread ไม่มีตู้เซฟเหลือให้ส่งแล้ว (Ownership ถูกย้ายขาดไปแล้ว)
  1. ถ้ามีแค่ Arc (ไม่มี Mutex)
  • Arc (Atomic Reference Counted) เปรียบเหมือน “บัตรผ่านเข้าชมตู้เซฟ” ที่เราปั๊มแจกให้ทุกคนได้
  • ทุกคนถือ Arc คนละใบ ทุกคนชี้ไปที่ข้อมูลก้อนเดียวกันได้พร้อมกัน (Shared Ownership)
  • ปัญหาคือ: Arc อนุญาตให้ “อ่าน” ได้อย่างเดียวครับ (Immutability)
  • ถ้า Thread ที่ 1 และ 2 พยายามจะ “แก้เลข” พร้อมกันผ่าน Arc โดยตรง… Rust จะไม่อนุยอม เพราะมันไม่ปลอดภัย (Data Race)

ดังนั้นต้อง “รวมร่าง” (Arc + Mutex)

เราจึงต้องเอา ตู้เซฟ (Mutex) ใส่ไว้ใน บัตรผ่าน (Arc) เพื่อให้ได้ความสามารถ 2 อย่างพร้อมกัน:

  1. Arc: ทำให้เราสามารถ clone() “สิทธิ์ความเป็นเจ้าของ” ส่งไปให้หลายๆ Thread ถือไว้ได้ (แก้ปัญหาเรื่อง Ownership)
  2. Mutex: ทำให้ Thread ที่ถือสิทธิ์นั้น ต้อง “Lock” เพื่อขอแก้ข้อมูลทีละคน (แก้ปัญหาเรื่อง Data Race)

คำถามที่ยากขึ้นหน่อย

ทำไมเราถึงต้องใช้ Arc::clone(&counter) ก่อนที่จะ move เข้าไปใน Thread ครับ? ทำไมเราไม่ move ตัวแปร counter ตัวหลักเข้าไปตรงๆ เลยใน Loop แรก? (ลองจินตนาการว่าถ้าทำแบบนั้น Loop ที่ 2 จะเกิดอะไรขึ้น)

for _ in 0..10 {
    // ทำไมต้อง clone ก่อน move เข้าไปใน Thread?
    let counter = Arc::clone(&counter);

    let handle = thread::spawn(move || {
        let mut num = counter.lock().unwrap();
        *num += 1;
    });
    handles.push(handle);
}

คำตอบ

ถ้าเราไม่ clone แต่ใช้ตัวหลัก move เข้าไปใน Loop แรกเลย จะกลายเป็นว่า

  1. Loop รอบที่ 1: counter ตัวจริงถูกโยน เข้าไปใน Thread ที่ 1
  2. Loop รอบที่ 2: พอจะ move อีกครั้ง… “อ้าว! ของหาย” คอมไพเลอร์จะดักตบมือเราทันที เพราะ counter ใน Main thread แตกดับไปตั้งแต่รอบแรกแล้ว

มาดูตัวอย่างจริงกันดีกว่า

ลองจินตนาการว่าเรามี 10 สาขา ทุกสาขากำลังขายของพร้อมกัน และต้องส่งยอดกลับมาที่ สำนักงานใหญ่ (Main Thread) เพื่ออัปเดตยอดรวมใน ฐานข้อมูลจำลอง (Shared State) ครับ

use std::sync::{Arc, Mutex, mpsc};
use std::thread;
use std::time::Duration;

fn main() {
    // 1. [Shared State] สร้างยอดรวมกลางที่ทุกสาขาต้องมาช่วยกันบวก
    let total_sales = Arc::new(Mutex::new(0));

    // 2. [Channels] สร้างท่อส่งข้อความเพื่อรายงานสถานะ "การปิดจ็อบ" ของแต่ละสาขา
    let (tx, rx) = mpsc::channel();

    let mut handles = vec![];

    for branch_id in 1..=5 { // สมมติมี 5 สาขา
        let tx_clone = tx.clone();
        let total_sales_clone = Arc::clone(&total_sales);

        // 3. [spawn & move] แยกสาขาออกไปทำงานของตัวเอง
        let handle = thread::spawn(move || {
            println!("สาขาที่ {} เริ่มเปิดร้าน...", branch_id);

            // จำลองการขายของ 3 ชิ้น
            for _ in 0..3 {
                thread::sleep(Duration::from_millis(100)); // เสียเวลาขายหน่อย

                // 4. [Mutex Lock] ขอคิวเข้าไปอัปเดตยอดรวมกลาง
                let mut data = total_sales_clone.lock().unwrap();
                *data += 100; // ขายชิ้นละ 100 บาท
            }

            // 5. [Channel Send] รายงานสำนักงานใหญ่ว่าสาขานี้ขายเสร็จแล้ว
            tx_clone.send(format!("สาขาที่ {} ปิดยอดเรียบร้อย!", branch_id)).unwrap();
        });

        handles.push(handle);
    }

    // ต้อง drop tx ตัวต้นฉบับใน Main ทิ้ง ไม่งั้น rx จะรอรับข้อมูลไม่จบ (เพราะนึกว่ายังมีคนถือท่ออยู่)
    drop(tx);

    // 6. [Receiver Loop] Main thread นั่งอ่านรายงานที่ส่งมาจาก Channel
    while let Ok(msg) = rx.recv() {
        println!("สำนักงานใหญ่ได้รับรายงาน: {}", msg);
    }

    // [Join] เพื่อความชัวร์ว่าทุกสาขาเก็บกวาดร้านเสร็จหมดแล้ว
    for h in handles { h.join().unwrap(); }

    println!("--------------------------------------");
    println!("ยอดขายรวมทุกสาขาคือ: {} บาท", *total_sales.lock().unwrap());
}

คำถาม โค้ดนี้ทำงานอย่างไร? และผลลัพธ์ที่คาดว่าจะได้คืออะไรครับ? (ลองอธิบายเป็นขั้นตอนตามลำดับการทำงานของ Thread แต่ละตัวดูนะครับ)

ไม่มีคำตอบให้ครับ ผมคิดว่าถึงจุดนี้คุณน่าจะอธิบายได้ถูกต้องแล้วหละ

แต่ เรามาดูจุดที่น่าสนใจในโค้ดนี้กันดีกว่า

let mut data = total_sales_clone.lock().unwrap();
*data += 100;
  1. ทำไมมีดอกจันทร์ หน้า data
  2. แล้วที่บอกว่า lock ยังไม่เห็นจุด unlock

ตอบตามหัวข้อเลย

  1. ทำไมต้องมีดอกจัน (*data) ? เมื่อเราเรียก .lock().unwrap() สิ่งที่เราได้กลับมาไม่ใช่ตัวเลข i32 ตรงๆ ครับ แต่เราได้สิ่งที่เรียกว่า MutexGuard data คือ “ผู้พิทักษ์” (Guard) ที่ถือสิทธิ์การเข้าถึงข้อมูลไว้ *data (Dereferencing): การใส่ดอกจันคือการบอกว่า “ฉันไม่ได้อยากได้ตัว Guard แต่ฉันอยากเข้าถึง ‘ค่าข้างใน’ ที่ Guard นี้ปกป้องอยู่” เหมือนคุณมี “กล่องใส่เงิน” (Mutex) พอคุณใช้ “กุญแจ” (Lock) ไขออกมาแล้ว คุณจะได้ “ถาดใส่เงิน” (Guard) มาถือไว้… ถ้าคุณอยากจะเพิ่มเงินเข้าไป คุณต้องควักเงินลงไปในถาดนั้น (*data) นั่นเองครับ

  2. ไหนล่ะจุด Unlock? (The Magic of Scope) นี่คือจุดเด่นที่สุดของ Rust เลยครับ เราไม่ต้องสั่ง Unlock เอง! Rust ใช้หลักการที่เรียกว่า RAII (Resource Acquisition Is Initialization): Lock: เกิดขึ้นตอนเราสั่ง .lock() Unlock: เกิดขึ้น “อัตโนมัติ” ทันทีที่ตัวแปร data (ซึ่งเป็น Guard) หลุดออกจากขอบเขต (Scope) หรือ “ตาย” ไปนั่นเองครับ

แล้วถ้า จงใจ unlock เพราะ thread อาจยังไม่จบงานหละ

บางทีเราต้องการ “แก้เลขเสร็จปุ๊บ คืนกุญแจปั๊บ” เพื่อให้เพื่อนคนอื่นไม่ต้องรอนาน ในขณะที่ Thread เรายังต้องทำงานอื่นต่อ (เช่น ต้องไปคำนวณเลขหนักๆ หรือส่ง HTTP Request ต่อ)

เรามีวิธี “จงใจสั่งคืนกุญแจก่อนเวลา” อยู่ 2 วิธีหลักๆ

  1. ใช้ฟังก์ชัน drop() (วิธีที่นิยมที่สุด)

เราสามารถใช้ฟังก์ชัน drop เพื่อทำลายตัวแปร Guard ทิ้งด้วยมือเราเองได้เลยครับ

let mut data = total_sales_clone.lock().unwrap();
*data += 100;

// ฉันแก้เสร็จแล้ว คืนกุญแจเลย! คนอื่นจะได้ไม่ต้องรอ
drop(data);

// --- หลังจากจุดนี้ data จะใช้งานไม่ได้แล้ว ---
// Thread นี้ยังทำงานอื่นที่ใช้เวลานานๆ ต่อไปได้ โดยไม่กั๊กกุญแจไว้
do_some_heavy_computation();

2.ใช้ Scope หลอก ({ }) เพื่อบีบขอบเขต

เราสามารถเอาปีกกา { } ไปครอบเฉพาะจุดที่เราจะใช้งาน Mutex ได้ วิธีนี้ดูสะอาดตาและเป็นที่นิยมมากในโค้ด Rust ระดับโปรครับ

thread::spawn(move || {
    // จังหวะแก้เลข
    {
        let mut data = total_sales_clone.lock().unwrap();
        *data += 100;
        // พ้นปีกกาปุ๊บ data ตาย -> คืนกุญแจทันที!
    }

    // จังหวะทำงานอื่นต่อ
    println!("คืนกุญแจแล้วนะ กำลังทำงานอื่นต่อ...");
    thread::sleep(Duration::from_secs(5));
});

จบหลักสูตร Concurrency ขั้นพื้นฐาน ตอนนี้คุณเข้าใจทั้งการสร้าง, การส่ง, การแชร์, การล็อค, และการปลดล็อคแล้ว