Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

การใช้เธรดเพื่อรันโค้ดพร้อมกัน

ในระบบปฏิบัติการปัจจุบันส่วนใหญ่ โค้ดของโปรแกรมที่กำลังทำงานจะถูกรันใน โพรเซส (process) และระบบปฏิบัติการจะจัดการโพรเซสหลายๆ โพรเซสพร้อมกัน ภายในโปรแกรมเดียวกัน คุณยังสามารถมีส่วนที่เป็นอิสระจากกันซึ่งรันพร้อมกันได้ ฟีเจอร์ที่รันส่วนที่เป็นอิสระเหล่านี้เรียกว่า เธรด (threads) ตัวอย่างเช่น เว็บเซิร์ฟเวอร์อาจมีหลายเธรดเพื่อตอบสนองต่อคำขอมากกว่าหนึ่งคำขอในเวลาเดียวกันได้

การแบ่งการคำนวณในโปรแกรมของคุณออกเป็นหลายเธรดเพื่อรันงานหลายอย่างพร้อมกันสามารถเพิ่มประสิทธิภาพการทำงานได้ แต่ก็เพิ่มความซับซ้อนขึ้นด้วย เนื่องจากเธรดสามารถรันพร้อมกันได้ จึงไม่มีการรับประกันตามธรรมชาติเกี่ยวกับลำดับที่ส่วนต่างๆ ของโค้ดบนเธรดที่ต่างกันจะถูกรัน สิ่งนี้อาจนำไปสู่ปัญหาต่างๆ เช่น:

  • สภาวะแย่งชิง (Race conditions) ซึ่งเธรดเข้าถึงข้อมูลหรือทรัพยากรในลำดับที่ไม่สอดคล้องกัน
  • เดดล็อก (Deadlocks) ซึ่งเธรดสองเธรดต่างคอยอีกเธรดหนึ่ง ทำให้ทั้งสองเธรดไม่สามารถทำงานต่อไปได้
  • บั๊กที่เกิดขึ้นเฉพาะในบางสถานการณ์เท่านั้น และยากที่จะจำลองซ้ำรวมถึงแก้ไขได้อย่างมั่นใจ

Rust พยายามลดผลกระทบเชิงลบจากการใช้เธรด แต่การเขียนโปรแกรมในบริบทแบบหลายเธรด (multithreaded) ยังคงต้องใช้ความคิดอย่างรอบคอบและต้องการโครงสร้างโค้ดที่แตกต่างจากโปรแกรมที่รันบนเธรดเดียว

ภาษาเขียนโปรแกรมใช้วิธีสร้างเธรดในรูปแบบที่แตกต่างกันเล็กน้อย และระบบปฏิบัติการหลายตัวก็จัดหา API ที่ภาษาเขียนโปรแกรมสามารถเรียกใช้เพื่อสร้างเธรดใหม่ได้ ไลบรารีมาตรฐานของ Rust ใช้โมเดลการทำงานแบบเธรด 1:1 โดยโปรแกรมจะใช้หนึ่งเธรดของระบบปฏิบัติการต่อหนึ่งเธรดของภาษา มีเครต (crates) ที่ใช้โมเดลของเธรดแบบอื่นซึ่งมีข้อดีข้อเสียแลกเปลี่ยนต่างจากโมเดล 1:1 (ระบบ async ของ Rust ซึ่งเราจะได้เห็นในบทถัดไป ก็มอบแนวทางอื่นสำหรับคอนเคอร์เรนซีด้วยเช่นกัน)

การสร้างเธรดใหม่ด้วย spawn

ในการสร้างเธรดใหม่ เราจะเรียกใช้ฟังก์ชัน thread::spawn และส่งโคลเชอร์ (เราพูดถึงโคลเชอร์ไปแล้วในบทที่ 13) ซึ่งบรรจุโค้ดที่เราต้องการรันในเธรดใหม่เข้าไป ตัวอย่างในโค้ดตัวอย่างที่ 16-1 จะพิมพ์ข้อความบางอย่างจากเธรดหลัก (main thread) และข้อความอื่นๆ จากเธรดใหม่

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

fn main() {
    thread::spawn(|| {
        for i in 1..10 {
            println!("hi number {i} from the spawned thread!");
            thread::sleep(Duration::from_millis(1));
        }
    });

    for i in 1..5 {
        println!("hi number {i} from the main thread!");
        thread::sleep(Duration::from_millis(1));
    }
}

โปรดสังเกตว่าเมื่อเธรดหลักของโปรแกรม Rust ทำงานเสร็จสิ้น เธรดที่ถูกสร้างขึ้นทั้งหมดจะถูกปิดลง ไม่ว่าพวกมันจะทำงานเสร็จแล้วหรือไม่ก็ตาม ผลลัพธ์จากโปรแกรมนี้อาจแตกต่างกันเล็กน้อยในแต่ละครั้งที่รัน แต่จะมีลักษณะคล้ายกับต่อไปนี้:

hi number 1 from the main thread!
hi number 1 from the spawned thread!
hi number 2 from the main thread!
hi number 2 from the spawned thread!
hi number 3 from the main thread!
hi number 3 from the spawned thread!
hi number 4 from the main thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!

การเรียกใช้ thread::sleep เป็นการบังคับให้เธรดหยุดการทำงานเป็นระยะเวลาสั้นๆ เพื่อเปิดโอกาสให้เธรดอื่นได้รัน เธรดต่างๆ น่าจะสลับกันทำงาน แต่ก็ไม่ได้รับการรับประกัน: ขึ้นอยู่กับว่าระบบปฏิบัติการของคุณจัดคิวการทำงาน (schedule) ของเธรดอย่างไร ในการรันครั้งนี้ เธรดหลักพิมพ์ออกมาก่อน แม้ว่าคำสั่งพิมพ์จากเธรดที่สร้างขึ้นใหม่จะปรากฏขึ้นก่อนในโค้ดก็ตาม และแม้ว่าเราจะบอกให้เธรดใหม่พิมพ์จนกว่า i จะเป็น 9 แต่โปรแกรมกลับไปถึงแค่ 5 เท่านั้นก่อนที่เธรดหลักจะปิดตัวลง

หากคุณรันโค้ดนี้แล้วเห็นเฉพาะผลลัพธ์จากเธรดหลัก หรือไม่เห็นการทำงานสลับกันเลย ลองเพิ่มตัวเลขในขอบเขต (ranges) เพื่อสร้างโอกาสให้ระบบปฏิบัติการสลับไปมาระหว่างเธรดมากขึ้น

การรอให้เธรดทั้งหมดทำงานเสร็จสิ้น

โค้ดในโค้ดตัวอย่างที่ 16-1 ไม่เพียงแต่จะหยุดเธรดที่สร้างขึ้นก่อนเวลาอันควรเนื่องจากการสิ้นสุดของเธรดหลักส่วนใหญ่แล้ว แต่เนื่องจากไม่มีการรับประกันเกี่ยวกับลำดับที่เธรดทำงาน เราจึงไม่สามารถรับประกันได้เลยว่าเธรดที่สร้างขึ้นจะถูกรันเลยหรือไม่!

เราสามารถแก้ไขปัญหาเธรดใหม่ไม่ทำงานหรือจบลงก่อนเวลาอันควรได้โดยการบันทึกค่าที่ส่งกลับมาจาก thread::spawn ไว้ในตัวแปร ชนิดค่าส่งกลับของ thread::spawn คือ JoinHandle<T> โดย JoinHandle<T> เป็นค่าที่มีความเป็นเจ้าของ ซึ่งเมื่อเราเรียกใช้เมธอด join บนนั้น มันจะรอให้เธรดของมันทำงานเสร็จสิ้น โค้ดตัวอย่างที่ 16-2 แสดงวิธีใช้ JoinHandle<T> ของเธรดที่เราสร้างในโค้ดตัวอย่างที่ 16-1 และวิธีเรียกใช้ join เพื่อให้แน่ใจว่าเธรดใหม่ทำงานเสร็จก่อนที่ main จะจบลง

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

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..10 {
            println!("hi number {i} from the spawned thread!");
            thread::sleep(Duration::from_millis(1));
        }
    });

    for i in 1..5 {
        println!("hi number {i} from the main thread!");
        thread::sleep(Duration::from_millis(1));
    }

    handle.join().unwrap();
}

การเรียกใช้ join บนแฮนเดิล (handle) จะทำการบล็อก (block) เธรดที่กำลังรันอยู่ในปัจจุบันไว้จนกว่าเธรดที่ถูกแทนด้วยแฮนเดิลนั้นจะสิ้นสุดลง การ บล็อก (Blocking) เธรดหมายความว่าเธรดนั้นถูกขัดขวางไม่ให้ทำงานต่อหรือจบการทำงาน เนื่องจากเราใส่การเรียกใช้ join ไว้หลังลูป for ของเธรดหลัก การรันโค้ดตัวอย่างที่ 16-2 จึงควรให้ผลลัพธ์คล้ายกับสิ่งนี้:

hi number 1 from the main thread!
hi number 2 from the main thread!
hi number 1 from the spawned thread!
hi number 3 from the main thread!
hi number 2 from the spawned thread!
hi number 4 from the main thread!
hi number 3 from the spawned thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!
hi number 6 from the spawned thread!
hi number 7 from the spawned thread!
hi number 8 from the spawned thread!
hi number 9 from the spawned thread!

ทั้งสองเธรดยังคงทำงานสลับกัน แต่เธรดหลักจะรอเนื่องจากการเรียกใช้ handle.join() และจะไม่จบลงจนกว่าเธรดใหม่จะทำงานเสร็จสิ้น

แต่ลองมาดูกันว่าจะเกิดอะไรขึ้นหากเราย้าย handle.join() ไปไว้ก่อนลูป for ใน main แทน ดังนี้:

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

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..10 {
            println!("hi number {i} from the spawned thread!");
            thread::sleep(Duration::from_millis(1));
        }
    });

    handle.join().unwrap();

    for i in 1..5 {
        println!("hi number {i} from the main thread!");
        thread::sleep(Duration::from_millis(1));
    }
}

เธรดหลักจะรอให้เธรดใหม่ทำงานเสร็จก่อนแล้วจึงค่อยรันลูป for ของตัวเอง ดังนั้นผลลัพธ์จะไม่ทำงานสลับกันอีกต่อไป ดังที่แสดงที่นี่:

hi number 1 from the spawned thread!
hi number 2 from the spawned thread!
hi number 3 from the spawned thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!
hi number 6 from the spawned thread!
hi number 7 from the spawned thread!
hi number 8 from the spawned thread!
hi number 9 from the spawned thread!
hi number 1 from the main thread!
hi number 2 from the main thread!
hi number 3 from the main thread!
hi number 4 from the main thread!

รายละเอียดเล็กๆ น้อยๆ เช่น จุดที่เรียกใช้ join สามารถส่งผลกระทบว่าเธรดของคุณจะทำงานในเวลาเดียวกันหรือไม่

การใช้โคลเชอร์แบบ move กับเธรด

เรามักใช้คีย์เวิร์ด move ร่วมกับโคลเชอร์ที่ส่งไปยัง thread::spawn เพราะโคลเชอร์จะรับเอาความเป็นเจ้าของของค่าที่ใช้งานจากสภาพแวดล้อม (environment) มา จึงเป็นการย้ายความเป็นเจ้าของของค่าเหล่านั้นจากเธรดหนึ่งไปยังอีกเธรดหนึ่ง ในหัวข้อ “การจับการอ้างอิงหรือการย้ายความเป็นเจ้าของ” ในบทที่ 13 เราได้พูดถึง move ในบริบทของโคลเชอร์ไปแล้ว คราวนี้เราจะเน้นไปที่การทำงานร่วมกันระหว่าง move และ thread::spawn มากขึ้น

สังเกตในโค้ดตัวอย่างที่ 16-1 ว่าโคลเชอร์ที่เราส่งให้ thread::spawn ไม่รับอาร์กิวเมนต์ใดๆ: เราไม่ได้ใช้ข้อมูลจากเธรดหลักในโค้ดของเธรดใหม่เลย หากต้องการใช้ข้อมูลจากเธรดหลักในเธรดใหม่ โคลเชอร์ของเธรดใหม่จะต้องจับ (capture) ค่าที่ต้องการใช้ โค้ดตัวอย่างที่ 16-3 แสดงความพยายามสร้างเวกเตอร์ในเธรดหลักและนำไปใช้ในเธรดใหม่ อย่างไรก็ตาม สิ่งนี้จะยังไม่ทำงาน ดังที่คุณจะได้เห็นในอีกสักครู่

use std::thread;

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

    let handle = thread::spawn(|| {
        println!("Here's a vector: {v:?}");
    });

    handle.join().unwrap();
}

โคลเชอร์มีการใช้ v ดังนั้นมันจะจับ v เข้าไปเป็นส่วนหนึ่งของสภาพแวดล้อมของโคลเชอร์ เนื่องจาก thread::spawn รันโคลเชอร์นี้ในเธรดใหม่ เราจึงควรจะเข้าถึง v ภายในเธรดใหม่นั้นได้ แต่เมื่อเราคอมไพล์ตัวอย่างนี้ เราจะได้รับข้อผิดพลาดต่อไปนี้:

$ cargo run
   Compiling threads v0.1.0 (file:///projects/threads)
error[E0373]: closure may outlive the current function, but it borrows `v`, which is owned by the current function
 --> src/main.rs:6:32
  |
6 |     let handle = thread::spawn(|| {
  |                                ^^ may outlive borrowed value `v`
7 |         println!("Here's a vector: {v:?}");
  |                                     - `v` is borrowed here
  |
note: function requires argument type to outlive `'static`
 --> src/main.rs:6:18
  |
6 |       let handle = thread::spawn(|| {
  |  __________________^
7 | |         println!("Here's a vector: {v:?}");
8 | |     });
  | |______^
help: to force the closure to take ownership of `v` (and any other referenced variables), use the `move` keyword
  |
6 |     let handle = thread::spawn(move || {
  |                                ++++

For more information about this error, try `rustc --explain E0373`.
error: could not compile `threads` (bin "threads") due to 1 previous error

Rust อนุมาน (infers) วิธีการจับ v และเนื่องจาก println! ต้องการเพียงแค่การอ้างอิงถึง v โคลเชอร์จึงพยายามยืม v แต่มีปัญหาอยู่ว่า: Rust ไม่สามารถบอกได้ว่าเธรดใหม่จะรันนานแค่ไหน ดังนั้นมันจึงไม่รู้ว่าการอ้างอิงถึง v จะยังคงใช้งานได้ (valid) อยู่เสมอหรือไม่

โค้ดตัวอย่างที่ 16-4 แสดงสถานการณ์ที่มีโอกาสสูงกว่าที่การอ้างอิงถึง v จะกลายเป็นสิ่งที่ไม่สามารถใช้งานได้

use std::thread;

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

    let handle = thread::spawn(|| {
        println!("Here's a vector: {v:?}");
    });

    drop(v); // oh no!

    handle.join().unwrap();
}

หาก Rust อนุญาตให้เรารันโค้ดนี้ ก็เป็นไปได้ว่าเธรดใหม่ที่สร้างขึ้นจะถูกส่งไปอยู่เบื้องหลังทันทีโดยไม่ได้รันเลย เธรดใหม่มีการอ้างอิงถึง v อยู่ข้างใน แต่เธรดหลักทิ้ง (drop) v ทันที โดยใช้ฟังก์ชัน drop ที่เราคุยกันในบทที่ 15 จากนั้น เมื่อเธรดใหม่เริ่มทำงาน v ก็ไม่ได้อยู่แล้ว ดังนั้นการอ้างอิงถึงมันจึงไม่ถูกต้องตามไปด้วย!

ในการแก้ไขข้อผิดพลาดคอมไพเลอร์ในโค้ดตัวอย่างที่ 16-3 เราสามารถใช้คำแนะนำจากข้อความแสดงข้อผิดพลาดได้:

help: to force the closure to take ownership of `v` (and any other referenced variables), use the `move` keyword
  |
6 |     let handle = thread::spawn(move || {
  |                                ++++

การเติมคีย์เวิร์ด move ไว้หน้าโคลเชอร์ เราจะเป็นการบังคับให้โคลเชอร์รับเอาความเป็นเจ้าของของค่าที่มันใช้งาน แทนที่จะยอมให้ Rust อนุมานเอาเองว่าควรจะยืมค่านั้น การแก้ไขโค้ดตัวอย่างที่ 16-3 ที่แสดงในโค้ดตัวอย่างที่ 16-5 จะคอมไพล์และทำงานได้ตามที่เราตั้งใจไว้

use std::thread;

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

    let handle = thread::spawn(move || {
        println!("Here's a vector: {v:?}");
    });

    handle.join().unwrap();
}

เราอาจพยายามทำแบบเดียวกันเพื่อแก้โค้ดในโค้ดตัวอย่างที่ 16-4 ซึ่งเธรดหลักเรียกใช้ drop โดยการใช้โคลเชอร์แบบ move อย่างไรก็ตาม การแก้ไขนี้จะไม่ทำงาน เพราะสิ่งที่โค้ดตัวอย่างที่ 16-4 พยายามทำนั้นไม่อนุญาตเนื่องด้วยเหตุผลอื่น หากเราเพิ่ม move ในโคลเชอร์ เราจะย้าย v เข้าไปในสภาพแวดล้อมของโคลเชอร์ และเราจะไม่สามารถเรียกใช้ drop กับมันในเธรดหลักได้อีกต่อไป เราจะได้รับข้อผิดพลาดคอมไพเลอร์นี้แทน:

$ cargo run
   Compiling threads v0.1.0 (file:///projects/threads)
error[E0382]: use of moved value: `v`
  --> src/main.rs:10:10
   |
 4 |     let v = vec![1, 2, 3];
   |         - move occurs because `v` has type `Vec<i32>`, which does not implement the `Copy` trait
 5 |
 6 |     let handle = thread::spawn(move || {
   |                                ------- value moved into closure here
 7 |         println!("Here's a vector: {v:?}");
   |                                     - variable moved due to use in closure
...
10 |     drop(v); // oh no!
   |          ^ value used here after move
   |
help: consider cloning the value before moving it into the closure
   |
 6 ~     let value = v.clone();
 7 ~     let handle = thread::spawn(move || {
 8 ~         println!("Here's a vector: {value:?}");
   |

For more information about this error, try `rustc --explain E0382`.
error: could not compile `threads` (bin "threads") due to 1 previous error

กฎความเป็นเจ้าของของ Rust ได้ช่วยเราไว้อีกครั้ง! เราได้รับข้อผิดพลาดจากโค้ดในโค้ดตัวอย่างที่ 16-3 เพราะ Rust ระมัดระวังและเลือกที่จะยืม v สำหรับเธรดเท่านั้น ซึ่งหมายความว่าเธรดหลักอาจทำให้การอ้างอิงของเธรดใหม่ไม่ถูกต้องโดยทฤษฎี การบอก Rust ให้ย้ายความเป็นเจ้าของของ v ไปยังเธรดใหม่ เป็นการรับประกันกับ Rust ว่าเธรดหลักจะไม่ใช้ v อีกต่อไป หากเราเปลี่ยนโค้ดตัวอย่างที่ 16-4 ในลักษณะเดียวกัน เราก็กำลังละเมิดกฎความเป็นเจ้าของเมื่อเราพยายามใช้ v ในเธรดหลัก คีย์เวิร์ด move จะยับยั้งค่าเริ่มต้นที่ระมัดระวังของ Rust ซึ่งก็คือการยืม แต่มันไม่ได้เปิดโอกาสให้เราละเมิดกฎความเป็นเจ้าของได้

เมื่อเราได้ครอบคลุมเกี่ยวกับความหมายของเธรดและเมธอดต่างๆ ที่จัดหาให้โดย thread API แล้ว ลองมาดูสถานการณ์บางอย่างที่เราสามารถใช้เธรดได้