การใช้เธรดเพื่อรันโค้ดพร้อมกัน
ในระบบปฏิบัติการปัจจุบันส่วนใหญ่ โค้ดของโปรแกรมที่กำลังทำงานจะถูกรันใน โพรเซส (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 แล้ว ลองมาดูสถานการณ์บางอย่างที่เราสามารถใช้เธรดได้