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

สรุปองค์รวม: Futures, Tasks และ Threads

ดังที่เราได้เห็นใน บทที่ 16 threads ให้แนวทางหนึ่งในการทำงานแบบพร้อมกัน (concurrency) และเราก็ได้เห็นอีกแนวทางหนึ่งในบทนี้ นั่นคือการใช้ async ร่วมกับ futures และ streams หากคุณสงสัยว่าจะเลือกใช้วิธีใดวิธีหนึ่งเมื่อใด คำตอบคือ: ขึ้นอยู่กับสถานการณ์! และในหลายๆ กรณี ทางเลือกไม่ได้เป็นเรื่องของ threads หรือ async แต่เป็น threads และ async ร่วมกัน

ระบบปฏิบัติการจำนวนมากได้จัดหาโมเดลการทำงานแบบพร้อมกันตามพื้นฐานของ thread มาเป็นเวลานานหลายทศวรรษแล้ว และด้วยเหตุนี้ภาษาโปรแกรมจำนวนมากจึงรองรับโมเดลเหล่านี้ อย่างไรก็ตาม โมเดลเหล่านี้ก็มีข้อแลกเปลี่ยน (tradeoffs) ในระบบปฏิบัติการหลายๆ ระบบ พวกมันใช้หน่วยความจำค่อนข้างมากสำหรับแต่ละ thread นอกจากนี้ Threads ยังเป็นตัวเลือกเฉพาะเมื่อระบบปฏิบัติการและฮาร์ดแวร์ของคุณรองรับเท่านั้น ไม่เหมือนกับคอมพิวเตอร์เดสก์ท็อปและมือถือกระแสหลัก ระบบฝังตัว (embedded systems) บางระบบไม่มี OS เลย ดังนั้นจึงไม่มี threads เช่นกัน

โมเดล async มอบชุดข้อแลกเปลี่ยนที่แตกต่างกัน—และเติมเต็มซึ่งกันและกันอย่างสมบูรณ์แบบ ในโมเดล async การทำงานแบบพร้อมกันไม่จำเป็นต้องมี thread ของตัวเองเสมอไป แต่สามารถรันบน task แทนได้ เช่น เมื่อเราใช้ trpl::spawn_task เพื่อเริ่มทำงานจากฟังก์ชันแบบซิงโครนัสในส่วนของ streams ตัว task คล้ายกับ thread แต่แทนที่จะได้รับการจัดการโดยระบบปฏิบัติการ มันจะได้รับการจัดการโดยโค้ดระดับไลบรารี: นั่นคือ runtime

มีเหตุผลที่ทำให้ API สำหรับการสร้าง thread และการสร้าง task มีความคล้ายคลึงกันมาก Thread ทำหน้าที่เป็นขอบเขตสำหรับชุดการทำงานแบบซิงโครนัส การทำงานแบบพร้อมกันสามารถเกิดขึ้นได้ ระหว่าง threads ส่วน Task ทำหน้าที่เป็นขอบเขตสำหรับชุดการทำงานแบบ อะซิงโครนัส การทำงานแบบพร้อมกันสามารถเกิดขึ้นได้ทั้ง ระหว่าง และ ภายใน tasks เพราะว่า task สามารถสลับการทำงานระหว่าง futures ต่างๆ ภายในตัวมันได้ ท้ายที่สุด Future คือหน่วยของการทำงานแบบพร้อมกันที่รายละเอียดที่สุดของ Rust และแต่ละ future อาจแทนโครงสร้างต้นไม้ของ futures อื่นๆ โดยที่ runtime—โดยเฉพาะอย่างยิ่ง executor ของมัน—จะจัดการ tasks และ tasks จะจัดการ futures ในแง่นั้น tasks จึงเปรียบเสมือน thread น้ำหนักเบาที่ถูกจัดการโดย runtime พร้อมกับความสามารถเพิ่มเติมที่มาจากการถูกจัดการโดย runtime แทนที่จะเป็นระบบปฏิบัติการ

นี่ไม่ได้หมายความว่า async tasks จะดีกว่า threads (หรือในทางกลับกัน) เสมอไป การทำงานแบบพร้อมกันด้วย threads มีรูปแบบการเขียนโปรแกรมที่เรียบง่ายกว่าในบางแง่มุมเมื่อเทียบกับการทำงานแบบพร้อมกันด้วย async ซึ่งนั่นอาจเป็นจุดแข็งหรือจุดอ่อนก็ได้ Threads จะมีลักษณะแบบ “ส่งแล้วลืม (fire and forget)” อยู่บ้าง พวกมันไม่มีสิ่งที่เทียบเท่ากับ future โดยธรรมชาติ ดังนั้นพวกมันจึงรันจนเสร็จโดยไม่ถูกขัดจังหวะ ยกเว้นโดยตัวระบบปฏิบัติการเอง

และปรากฏว่า threads และ tasks มักจะทำงานร่วมกันได้ดีมาก เพราะว่า tasks สามารถ (อย่างน้อยในบาง runtime) ถูกย้ายไปมาระหว่าง threads ได้ ในความเป็นจริง เบื้องหลัง runtime ที่เราใช้อยู่—รวมถึงฟังก์ชัน spawn_blocking และ spawn_task—เป็นแบบหลาย thread (multithreaded) โดยเริ่มต้น! Runtime จำนวนมากใช้วิธีการที่เรียกว่า work stealing เพื่อย้าย tasks ไปมาระหว่าง threads ได้อย่างโปร่งใส ตามการใช้งานของ threads ในขณะนั้น เพื่อปรับปรุงประสิทธิภาพโดยรวมของระบบ วิธีการดังกล่าวนั้นต้องอาศัย threads และ tasks และทำให้ต้องใช้ futures ร่วมด้วยในที่สุด

เมื่อคิดว่าจะใช้วิธีใดเมื่อใด ให้พิจารณาหลักการกว้างๆ เหล่านี้:

  • หากงานนั้น ประมวลผลแบบขนานได้ดีมาก (นั่นคือ เน้นภาระ CPU หรือ CPU-bound) เช่น การประมวลผลชุดข้อมูลขนาดใหญ่ที่แต่ละส่วนสามารถประมวลผลแยกกันได้ threads จะเป็นตัวเลือกที่ดีกว่า
  • หากงานนั้น เน้นการทำงานแบบพร้อมกันอย่างมาก (นั่นคือ เน้นภาระ I/O หรือ I/O-bound) เช่น การจัดการข้อความจากหลายแหล่งที่อาจเข้ามาในช่วงเวลาหรืออัตราความเร็วที่แตกต่างกัน async จะเป็นตัวเลือกที่ดีกว่า

และหากคุณต้องการทั้งการทำงานแบบขนาน (parallelism) และการทำงานแบบพร้อมกัน (concurrency) คุณไม่จำเป็นต้องเลือกระหว่าง threads หรือ async คุณสามารถใช้พวกมันร่วมกันได้อย่างอิสระ โดยปล่อยให้แต่ละส่วนทำหน้าที่ในสิ่งที่ตนเองถนัดที่สุด ตัวอย่างเช่น โค้ดตัวอย่างที่ 17-25 แสดงตัวอย่างการผสมผสานชนิดนี้ที่พบเห็นได้ทั่วไปในโค้ด Rust ของโลกแห่งความเป็นจริง

extern crate trpl; // for mdbook test

use std::{thread, time::Duration};

fn main() {
    let (tx, mut rx) = trpl::channel();

    thread::spawn(move || {
        for i in 1..11 {
            tx.send(i).unwrap();
            thread::sleep(Duration::from_secs(1));
        }
    });

    trpl::block_on(async {
        while let Some(message) = rx.recv().await {
            println!("{message}");
        }
    });
}

เราเริ่มต้นด้วยการสร้าง async channel จากนั้นสร้าง thread ที่รับความเป็นเจ้าของของฝั่งส่งข้อความ (sender side) ของ channel โดยใช้คีย์เวิร์ด move ภายใน thread เราส่งตัวเลข 1 ถึง 10 โดยหยุดพัก (sleep) 1 วินาทีในแต่ละช่วง ท้ายที่สุด เราจะรัน future ที่สร้างด้วยบล็อก async ซึ่งส่งต่อไปยัง trpl::block_on เช่นเดียวกับที่เราทำมาตลอดทั้งบท ใน future นั้นเรารอคอย (await) ข้อความเหล่านั้น เหมือนกับตัวอย่างการส่งข้อความอื่นๆ ที่เราเคยเห็นมา

หากย้อนกลับไปยังสถานการณ์ที่เราเปิดบทเรียนไว้ ให้จินตนาการถึงการรันชุดงานเข้ารหัสวิดีโอ (video encoding) โดยใช้ thread ที่แยกต่างหากโดยเฉพาะ (เพราะการเข้ารหัสวิดีโอเน้นการคำนวณหรือ compute-bound) แต่แจ้งเตือนส่วนประสานงานผู้ใช้ (UI) ว่าการทำงานเหล่านั้นเสร็จสิ้นแล้วด้วย async channel มีตัวอย่างชุดการผสมผสานประเภทนี้มากมายนับไม่ถ้วนในกรณีการใช้งานจริง

สรุป

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

ไม่ว่าคุณจะเลือกแนวทางใดก็ตาม Rust จะมอบเครื่องมือที่คุณต้องการเพื่อเขียนโค้ดที่ปลอดภัย รวดเร็ว และทำงานแบบพร้อมกันได้—ไม่ว่าจะสำหรับเว็บเซิร์ฟเวอร์ที่รองรับทราฟฟิกสูง (high-throughput) หรือระบบปฏิบัติการฝังตัวก็ตาม

ในบทถัดไป เราจะพูดถึงวิธีที่เป็นแบบฉบับ (idiomatic ways) ในการสร้างโมเดลปัญหาและจัดโครงสร้างแนวทางแก้ไขเมื่อโปรแกรม Rust ของคุณมีขนาดใหญ่ขึ้น นอกจากนี้ เราจะอภิปรายว่าสำนวน (idioms) ของ Rust เกี่ยวข้องกับสิ่งที่คุณอาจคุ้นเคยจากการเขียนโปรแกรมเชิงวัตถุ (object-oriented programming) อย่างไร