พื้นฐานของการเขียนโปรแกรมแบบอะซิงโครนัส: Async, Await, Futures และ Streams
การทำงานหลายอย่างที่เราสั่งให้คอมพิวเตอร์ทำอาจใช้เวลาสักพักจึงจะเสร็จสิ้น จะดีเพียงใดหากเราสามารถทำอย่างอื่นได้ในระหว่างรอให้กระบวนการที่ใช้เวลานานเหล่านั้นทำงานจนเสร็จ คอมพิวเตอร์สมัยใหม่เสนอสองเทคนิคในการทำงานกับคำสั่งมากกว่าหนึ่งคำสั่งในเวลาเดียวกัน นั่นคือ พาราเลลลิซึมหรือการทำงานขนานกัน (parallelism) และคอนเคอร์เรนซี (concurrency) อย่างไรก็ตาม ตรรกะของโปรแกรมเรามักจะถูกเขียนในรูปแบบที่เป็นเส้นตรง (linear fashion) เราอยากจะระบุคำสั่งที่โปรแกรมควรทำงานและจุดที่ฟังก์ชันสามารถหยุดพักชั่วคราวเพื่อให้ส่วนอื่นของโปรแกรมทำงานแทนได้ โดยไม่จำเป็นต้องระบุล่วงหน้าอย่างละเอียดถึงลำดับและรูปแบบที่โค้ดแต่ละส่วนควรจะรัน การเขียนโปรแกรมแบบอะซิงโครนัส (Asynchronous programming) เป็นนามธรรมที่ช่วยให้เราแสดงโค้ดในรูปแบบของจุดที่อาจหยุดพักชั่วคราวและผลลัพธ์ในท้ายที่สุด โดยจะช่วยจัดการรายละเอียดของการประสานงานแทนเรา
บทนี้ต่อยอดจากการใช้เธรดในบทที่ 16 สำหรับพาราเลลลิซึมและคอนเคอร์เรนซี โดยการแนะนำแนวทางทางเลือกในการเขียนโค้ด นั่นคือ futures, streams ของ Rust และไวยากรณ์ async และ await ที่ช่วยให้เราแสดงได้ว่าการทำงานสามารถเป็นแบบอะซิงโครนัสได้อย่างไร ตลอดจนเครตภายนอกที่ใช้สร้างรันไทม์แบบอะซิงโครนัส (asynchronous runtimes): ซึ่งเป็นโค้ดที่จัดการและประสานงานการประมวลผลคำสั่งแบบอะซิงโครนัส
ลองพิจารณาตัวอย่างหนึ่ง สมมติว่าคุณกำลังส่งออก (export) วิดีโอฉลองงานครอบครัวที่คุณสร้างขึ้น การทำงานนี้อาจใช้เวลาตั้งแต่ไม่กี่นาทีไปจนถึงหลายชั่วโมง การส่งออกวิดีโอจะใช้กำลังของ CPU และ GPU มากที่สุดเท่าที่จะทำได้ หากคุณมีคอร์ CPU เพียงคอร์เดียวและระบบปฏิบัติการของคุณไม่ได้หยุดพักการส่งออกนั้นชั่วคราวจนกว่าจะเสร็จสิ้น—กล่าวคือ หากมันประมวลผลการส่งออก แบบซิงโครนัส (synchronously)—คุณจะไม่สามารถทำอย่างอื่นบนคอมพิวเตอร์ได้เลยในระหว่างที่งานนั้นกำลังรันอยู่ นั่นอาจเป็นประสบการณ์ที่น่าอึดอัดใจทีเดียว โชคดีที่ระบบปฏิบัติการของคอมพิวเตอร์คุณสามารถและได้ทำการขัดจังหวะ (interrupt) การส่งออกนั้นโดยที่คุณไม่เห็นบ่อยพอที่จะเปิดโอกาสให้คุณทำงานอื่นไปพร้อมกันได้
คราวนี้สมมติว่าคุณกำลังดาวน์โหลดวิดีโอที่ผู้อื่นแชร์มา ซึ่งอาจใช้เวลาสักพักเช่นกันแต่ไม่ได้ใช้เวลาของ CPU มากเท่าใด ในกรณีนี้ CPU ต้องรอให้ข้อมูลส่งมาจากเครือข่าย แม้ว่าคุณจะเริ่มอ่านข้อมูลเมื่อมันเริ่มมาถึงได้ แต่ก็อาจใช้เวลาสักพักกว่าที่ข้อมูลทั้งหมดจะมาครบ และต่อให้ข้อมูลทั้งหมดมาถึงแล้ว หากวิดีโอมีขนาดใหญ่พอสมควร ก็อาจใช้เวลาอย่างน้อยหนึ่งหรือสองวินาทีในการโหลดทั้งหมด ฟังดูอาจไม่นานนัก แต่นั่นเป็นเวลาที่ยาวนานมากสำหรับโปรเซสเซอร์สมัยใหม่ ซึ่งสามารถประมวลผลคำสั่งได้นับพันล้านคำสั่งในทุกๆ วินาที อีกครั้ง ระบบปฏิบัติการของคุณจะขัดจังหวะโปรแกรมของคุณโดยที่คุณไม่เห็น เพื่อให้ CPU ทำงานอื่นได้ในขณะที่รอการเรียกใช้เครือข่ายเสร็จสิ้น
การส่งออกวิดีโอเป็นตัวอย่างของการทำงานที่มี ขีดจำกัดซีพียู (CPU-bound) หรือ ขีดจำกัดการคำนวณ (compute-bound) ซึ่งถูกจำกัดโดยความเร็วการประมวลผลข้อมูลของคอมพิวเตอร์ภายใน CPU หรือ GPU และความเร็วที่สามารถอุทิศให้กับการทำงานนั้นได้ การดาวน์โหลดวิดีโอเป็นตัวอย่างของการทำงานที่มี ขีดจำกัดไอโอ (I/O-bound) เพราะมันถูกจำกัดโดยความเร็วของ การนำเข้าและส่งออกข้อมูล (input and output) ของคอมพิวเตอร์ มันจะทำงานได้เร็วเท่าที่ข้อมูลสามารถถูกส่งข้ามเครือข่ายมาได้เท่านั้น
ในทั้งสองตัวอย่างนี้ การขัดจังหวะแบบไม่เห็นของระบบปฏิบัติการได้มอบรูปแบบหนึ่งของคอนเคอร์เรนซี ทว่าคอนเคอร์เรนซีนั้นเกิดขึ้นในระดับของทั้งโปรแกรมเท่านั้น: ระบบปฏิบัติการขัดจังหวะโปรแกรมหนึ่งเพื่อให้โปรแกรมอื่นๆ ทำงานได้ ในหลายๆ กรณี เนื่องจากเราเข้าใจโปรแกรมของเราในระดับที่ละเอียดยิ่งกว่าระบบปฏิบัติการ เราจึงสามารถมองเห็นโอกาสสำหรับคอนเคอร์เรนซีที่ระบบปฏิบัติการมองไม่เห็นได้
ตัวอย่างเช่น หากเรากำลังสร้างเครื่องมือจัดการการดาวน์โหลดไฟล์ เราควรเขียนโปรแกรมของเราเพื่อให้การเริ่มดาวน์โหลดไฟล์หนึ่งจะไม่ทำให้ UI ค้าง และผู้ใช้ควรจะสามารถเริ่มดาวน์โหลดหลายๆ ไฟล์ในเวลาเดียวกันได้ อย่างไรก็ตาม API ของระบบปฏิบัติการจำนวนมากสำหรับโต้ตอบกับเครือข่ายเป็นแบบ บล็อก (blocking) กล่าวคือ พวกมันบล็อกความคืบหน้าของโปรแกรมไว้จนกว่าข้อมูลที่กำลังประมวลผลอยู่จะพร้อมโดยสมบูรณ์
หมายเหตุ: หากลองคิดดู นี่คือวิธีที่การเรียกใช้ฟังก์ชัน ส่วนใหญ่ ทำงาน ทว่าคำว่า บล็อก (blocking) มักจะถูกสงวนไว้สำหรับการเรียกใช้ฟังก์ชันที่โต้ตอบกับไฟล์ เครือข่าย หรือทรัพยากรอื่นๆ บนคอมพิวเตอร์ เนื่องจากนั่นคือกรณีที่โปรแกรมแต่ละโปรแกรมจะได้ประโยชน์จากการทำงานแบบ ไม่บล็อก (non-blocking)
เราสามารถหลีกเลี่ยงการบล็อกเธรดหลักของเราได้โดยการสร้างเธรดเฉพาะขึ้นมาดาวน์โหลดแต่ละไฟล์ อย่างไรก็ตาม โหลดส่วนเกิน (overhead) ของทรัพยากรระบบที่ถูกใช้โดยเธรดเหล่านั้นจะกลายเป็นปัญหาในที่สุด มันจะดีกว่าหากการเรียกใช้งานนั้นไม่บล็อกตั้งแต่แรก และเราสามารถกำหนดจำนวนงานที่เราอยากให้โปรแกรมของเราทำจนเสร็จสิ้น รวมถึงปล่อยให้รันไทม์เลือกลำดับและรูปแบบที่ดีที่สุดในการรันงานเหล่านั้นได้
นั่นคือสิ่งที่นามธรรม async (ย่อมาจาก asynchronous) ของ Rust มอบให้เรา ในบทนี้ คุณจะได้เรียนรู้ทั้งหมดเกี่ยวกับ async โดยเราจะครอบคลุมหัวข้อต่อไปนี้:
- วิธีการใช้ไวยากรณ์
asyncและawaitของ Rust และการรันฟังก์ชันแบบอะซิงโครนัสด้วยรันไทม์ - วิธีการใช้โมเดล async เพื่อแก้ปัญหาความท้าทายแบบเดียวกับที่เราดูในบทที่ 16
- วิธีการที่มัลติเธรดดิ้งและ async มอบโซลูชันที่ส่งเสริมกันซึ่งคุณสามารถนำมาผสมผสานกันได้ในหลายๆ กรณี
ก่อนที่เราจะเห็นว่า async ทำงานอย่างไรในทางปฏิบัติ เราต้องอ้อมสั้นๆ เพื่ออภิปรายถึงความแตกต่างระหว่างพาราเลลลิซึม (การทำงานขนานกัน) และคอนเคอร์เรนซี
พาราเลลลิซึมและคอนเคอร์เรนซี
จนถึงตอนนี้ เราได้ปฏิบัติต่อพาราเลลลิซึม (การทำงานขนานกัน) และคอนเคอร์เรนซีในลักษณะที่เกือบจะแทนที่กันได้ คราวนี้เราจำเป็นต้องแยกแยะระหว่างสองคำนี้อย่างแม่นยำยิ่งขึ้น เนื่องจากความแตกต่างจะปรากฏขึ้นเมื่อเราเริ่มทำงาน
พิจารณาวิธีการต่างๆ ที่ทีมสามารถแบ่งงานในโปรเจกต์ซอฟต์แวร์ได้ คุณสามารถมอบหมายหลายๆ งานให้สมาชิกคนเดียว มอบหมายคนละหนึ่งงานให้สมาชิกแต่ละคน หรือใช้ทั้งสองวิธีผสมผสานกัน
เมื่อบุคคลหนึ่งทำงานหลายงานที่แตกต่างกันก่อนที่งานใดงานหนึ่งจะเสร็จสิ้น สิ่งนี้คือ คอนเคอร์เรนซี (concurrency) วิธีหนึ่งในการใช้คอนเคอร์เรนซีจะคล้ายกับการที่คุณมีสองโปรเจกต์ที่ต่างกันเปิดอยู่บนคอมพิวเตอร์ และเมื่อคุณเบื่อหรือติดขัดในโปรเจกต์หนึ่ง คุณก็สลับไปยังอีกโปรเจกต์หนึ่ง คุณเป็นเพียงคนๆ เดียว ดังนั้นคุณจึงไม่สามารถสร้างความคืบหน้าในทั้งสองงานได้ในเวลาเดียวกันเป๊ะๆ แต่คุณสามารถทำงานหลายอย่างสลับกัน (multitask) โดยสร้างความคืบหน้าทีละงานได้โดยการสลับไปมาสลับมา (ดูรูปภาพที่ 17-1)
เมื่อทีมแบ่งกลุ่มงานโดยให้สมาชิกแต่ละคนรับไปหนึ่งงานและทำงานนั้นโดยลำพัง สิ่งนี้คือ พาราเลลลิซึม (parallelism) สมาชิกแต่ละคนในทีมสามารถสร้างความคืบหน้าได้พร้อมกันในเวลาเดียวกันเป๊ะๆ (ดูรูปภาพที่ 17-2)
ในเวิร์กโฟลว์ทั้งสองแบบนี้ คุณอาจต้องประสานงานระหว่างงานต่างๆ ที่ต่างกัน บางทีคุณอาจคิดว่างานที่มอบหมายให้คนๆ หนึ่งเป็นอิสระจากงานของคนอื่นโดยสมบูรณ์ แต่จริงๆ แล้วงานนั้นกลับต้องการให้อีกคนในทีมทำงานของเขาให้เสร็จก่อน งานบางส่วนอาจทำแบบขนาน (parallel) ได้ แต่งานบางส่วนกลับเป็นแบบ อนุกรม (serial): กล่าวคือสามารถเกิดขึ้นเป็นลำดับ ต่อเนื่องกันไป งานหนึ่งหลังอีกงานหนึ่ง ดังในรูปภาพที่ 17-3
ในทำนองเดียวกัน คุณอาจตระหนักได้ว่างานชิ้นหนึ่งของคุณเองขึ้นอยู่กับอีกงานชิ้นหนึ่งของคุณ ตอนนี้งานแบบคอนเคอร์เรนต์ของคุณก็กลายเป็นงานแบบอนุกรมเช่นกัน
พาราเลลลิซึมและคอนเคอร์เรนซีสามารถเกี่ยวข้องกันและกันได้ด้วย หากคุณพบว่าเพื่อนร่วมงานติดขัดจนกว่าคุณจะทำงานชิ้นหนึ่งของคุณเสร็จ คุณน่าจะมุ่งความพยายามทั้งหมดไปที่งานนั้นเพื่อ “ปลดบล็อก” เพื่อนร่วมงานของคุณ คุณและเพื่อนร่วมงานจะไม่สามารถทำงานแบบขนานกันได้อีกต่อไป และคุณก็ไม่สามารถทำงานแบบคอนเคอร์เรนต์ในงานของตัวคุณเองได้อีกด้วย
กลไกพื้นฐานเดียวกันนี้เกิดขึ้นกับซอฟต์แวร์และฮาร์ดแวร์ บนเครื่องที่มีคอร์ CPU เดียว CPU สามารถประมวลผลคำสั่งได้เพียงหนึ่งคำสั่ง ณ เวลาหนึ่งเท่านั้น แต่มันยังคงสามารถทำงานแบบคอนเคอร์เรนต์ได้ โดยการใช้เครื่องมือ เช่น เธรด โพรเซส และ async คอมพิวเตอร์สามารถหยุดพักกิจกรรมหนึ่งชั่วคราวและสลับไปทำกิจกรรมอื่นก่อนที่จะวนกลับมากิจกรรมแรกอีกครั้ง บนเครื่องที่มีคอร์ CPU หลายคอร์ คอมพิวเตอร์สามารถทำงานแบบขนาน (parallel) ได้ด้วย คอร์หนึ่งสามารถประมวลผลงานหนึ่งในขณะที่อีกคอร์หนึ่งประมวลผลงานที่ไม่เกี่ยวข้องกันโดยสิ้นเชิง และการทำงานเหล่านั้นเกิดขึ้นในเวลาเดียวกันเป๊ะๆ
การรันโค้ด async ใน Rust มักเกิดขึ้นแบบคอนเคอร์เรนต์ ขึ้นอยู่กับฮาร์ดแวร์ ระบบปฏิบัติการ และรันไทม์ async ที่เราใช้ (รายละเอียดเพิ่มเติมเกี่ยวกับรันไทม์ async ในอีกสักครู่) คอนเคอร์เรนซีนั้นอาจใช้พาราเลลลิซึมอยู่เบื้องหลังด้วย
คราวนี้ ลองมาเจาะลึกกันว่าการเขียนโปรแกรมแบบ async ใน Rust ทำงานอย่างไรจริงๆ