Futures และไวยากรณ์ Async
องค์ประกอบสำคัญของการเขียนโปรแกรมแบบอะซิงโครนัสใน Rust คือ ฟิวเจอร์ส (futures) และคีย์เวิร์ด async กับ await ของ Rust
ฟิวเจอร์ (future) คือค่าที่อาจจะยังไม่พร้อมในขณะนี้ แต่จะพร้อมในจุดใดจุดหนึ่งในอนาคต (แนวคิดเดียวกันนี้ปรากฏในหลายภาษา บางครั้งใช้ชื่ออื่น เช่น task หรือ promise) Rust จัดหาเทรต Future ไว้เป็นบล็อกสร้าง เพื่อให้การทำงานแบบ async ที่แตกต่างกันสามารถถูกสร้างขึ้นด้วยโครงสร้างข้อมูลที่ต่างกันแต่มีส่วนต่อประสาน (interface) ร่วมกัน ใน Rust ฟิวเจอร์สคือชนิดข้อมูลที่ใช้เทรต Future ฟิวเจอร์แต่ละตัวจะถือครองข้อมูลความคืบหน้าที่เกิดขึ้น และความหมายของคำว่า “พร้อม” ของตัวมันเอง
คุณสามารถใช้คีย์เวิร์ด async กับบล็อกและฟังก์ชันเพื่อระบุว่าพวกมันสามารถถูกขัดจังหวะและกลับมาทำงานต่อได้ ภายในบล็อก async หรือฟังก์ชัน async คุณสามารถใช้คีย์เวิร์ด await เพื่อ รอฟิวเจอร์ (await a future) (นั่นคือ รอให้มันพร้อม) จุดใดก็ตามที่คุณรอฟิวเจอร์ภายในบล็อกหรือฟังก์ชัน async จะเป็นจุดที่อาจจะเกิดการหยุดพักชั่วคราวและกลับมาทำงานต่อของบล็อกหรือฟังก์ชันนั้น กระบวนการตรวจสอบกับฟิวเจอร์เพื่อดูว่าค่าของมันพร้อมใช้งานแล้วหรือยังเรียกว่า โพลลิง (polling)
ภาษาอื่นๆ บางภาษา เช่น C# และ JavaScript ก็ใช้คีย์เวิร์ด async และ await สำหรับการเขียนโปรแกรมแบบ async เช่นกัน หากคุณคุ้นเคยกับภาษาเหล่านั้น คุณอาจสังเกตเห็นข้อแตกต่างที่สำคัญในวิธีที่ Rust จัดการกับไวยากรณ์ นั่นเป็นเพราะมีเหตุผลที่ดี ดังที่เราจะได้เห็น!
เมื่อเขียน async ใน Rust เราจะใช้คีย์เวิร์ด async และ await ส่วนใหญ่ คอมไพเลอร์ของ Rust จะแปลพวกมันเป็นโค้ดที่เทียบเท่ากันโดยใช้เทรต Future คล้ายกับวิธีที่แปลลูป for เป็นโค้ดเทียบเท่าโดยใช้เทรต Iterator อย่างไรก็ตาม เนื่องจาก Rust จัดหาเทรต Future ไว้ให้ คุณจึงสามารถใช้เทรตนี้กับชนิดข้อมูลของคุณเองได้เมื่อจำเป็น ฟังก์ชันจำนวนมากที่เราจะได้เห็นตลอดทั้งบทนี้จะคืนค่าชนิดข้อมูลที่มีการใช้เทรต Future ของตนเอง เราจะย้อนกลับมาดูคำนิยามของเทรตนี้ตอนท้ายบทและเจาะลึกวิธีการทำงานของมัน แต่นี่เป็นรายละเอียดที่เพียงพอที่จะทำให้เราเดินหน้าต่อไปได้
ทั้งหมดนี้อาจฟังดูเป็นนามธรรมไปสักหน่อย ดังนั้นเรามาเขียนโปรแกรม async ตัวแรกของเรากันดีกว่า: นั่นคือเว็บสแครปเปอร์ (web scraper) เล็กๆ เราจะส่ง URL สองตัวจากบรรทัดคำสั่ง ดึงข้อมูลทั้งสองหน้าแบบคอนเคอร์เรนต์ และคืนค่าผลลัพธ์ของ URL ตัวใดก็ตามที่เสร็จก่อน ตัวอย่างนี้จะมีไวยากรณ์ใหม่ค่อนข้างมาก แต่นึกวางใจได้—เราจะอธิบายทุกอย่างที่คุณจำเป็นต้องรู้ไปเรื่อยๆ
โปรแกรม Async ตัวแรกของเรา
เพื่อรักษาจุดสนใจของบทนี้ไว้ที่การเรียนรู้เรื่อง async มากกว่าการสาละวนกับชิ้นส่วนต่างๆ ในอีโคซิสเต็ม เราได้สร้างเครต trpl ขึ้นมา (trpl ย่อมาจาก “The Rust Programming Language”) ซึ่งทำการ re-export ชนิดข้อมูล เทรต และฟังก์ชันทั้งหมดที่คุณต้องการ โดยส่วนใหญ่มาจากเครต futures และ tokio เครต futures เป็นศูนย์กลางอย่างเป็นทางการในการทดลองโค้ด async ของ Rust และเป็นสถานที่ซึ่งเทรต Future ถูกออกแบบขึ้นในตอนแรก ส่วน Tokio คือรันไทม์ async ที่ถูกใช้งานแพร่หลายที่สุดใน Rust ในปัจจุบัน โดยเฉพาะสำหรับเว็บแอปพลิเคชัน มีรันไทม์ที่ดีอื่นๆ อีกหลายตัวที่นั่น และพวกมันอาจเหมาะกับวัตถุประสงค์ของคุณมากกว่า เราใช้เครต tokio อยู่เบื้องหลังสำหรับ trpl เนื่องจากมันได้รับการทดสอบมาเป็นอย่างดีและถูกใช้งานอย่างกว้างขวาง
ในบางกรณี trpl ยังเปลี่ยนชื่อหรือห่อหุ้ม API ดั้งเดิมเพื่อให้คุณจดจ่อกับรายละเอียดที่เกี่ยวข้องกับบทนี้ หากคุณต้องการเข้าใจว่าเครตนี้ทำอะไร เราขอแนะนำให้ลองเข้าไปดู ซอร์สโค้ดของมัน คุณจะสามารถเห็นได้ว่าการ re-export แต่ละอย่างมาจากเครตใด และเราได้ทิ้งคอมเมนต์อธิบายสิ่งที่เครตทำไว้อย่างละเอียด
สร้างโปรเจกต์แบบไบนารีใหม่ชื่อ hello-async และเพิ่มเครต trpl เป็นพึ่งพา (dependency):
$ cargo new hello-async
$ cd hello-async
$ cargo add trpl
คราวนี้เราสามารถใช้ชิ้นส่วนต่างๆ ที่จัดหาโดย trpl เพื่อเขียนโปรแกรม async ตัวแรกของเรา เราจะสร้างเครื่องมือบรรทัดคำสั่งเล็กๆ ที่ดึงเว็บเพจสองหน้า สกัดเอาเอลิเมนต์ <title> จากแต่ละหน้า และพิมพ์ชื่อเรื่องของหน้าใดก็ตามที่ทำกระบวนการทั้งหมดเสร็จเป็นหน้าแรก
การนิยามฟังก์ชัน page_title
ลองเริ่มจากการเขียนฟังก์ชันที่รับ URL หน้าเว็บหนึ่งตัวเป็นพารามิเตอร์ ทำการส่งคำขอไป และคืนค่าข้อความของเอลิเมนต์ <title> ออกมา (ดูโค้ดตัวอย่างที่ 17-1)
extern crate trpl; // required for mdbook test
fn main() {
// TODO: we'll add this next!
}
use trpl::Html;
async fn page_title(url: &str) -> Option<String> {
let response = trpl::get(url).await;
let response_text = response.text().await;
Html::parse(&response_text)
.select_first("title")
.map(|title| title.inner_html())
}
ขั้นแรก เรานิยามฟังก์ชันชื่อ page_title และทำเครื่องหมายไว้ด้วยคีย์เวิร์ด async จากนั้นเราใช้ฟังก์ชัน trpl::get เพื่อดึง URL ใดก็ตามที่ส่งเข้ามา แล้วเพิ่มคีย์เวิร์ด await เพื่อรอคำตอบกลับ (response) ในการดึงข้อความของ response เราเรียกใช้เมธอด text ของมันและรอคอยมันอีกครั้งด้วยคีย์เวิร์ด await ขั้นตอนทั้งสองนี้เป็นแบบอะซิงโครนัส สำหรับฟังก์ชัน get เราต้องรอให้เซิร์ฟเวอร์ส่งส่วนแรกของคำตอบกลับมา ซึ่งจะรวมถึงส่วนหัว HTTP (HTTP headers), คุกกี้ และอื่นๆ ซึ่งสามารถส่งแยกต่างหากจากส่วนเนื้อหา (body) ของคำตอบ โดยเฉพาะอย่างยิ่งหากเนื้อหามีขนาดใหญ่มาก ก็อาจใช้เวลาสักพักกว่าที่เนื้อหาทั้งหมดจะมาถึง และเนื่องจากเราต้องรอให้คำตอบมาถึง ทั้งหมด เมธอด text จึงเป็น async ด้วยเช่นกัน
เราต้องทำการรอ (await) ฟิวเจอร์สทั้งสองนี้อย่างชัดเจน เนื่องจากฟิวเจอร์สใน Rust มีลักษณะ ขี้เกียจ (lazy): พวกมันจะไม่ทำอะไรเลยจนกว่าคุณจะขอให้ทำด้วยคีย์เวิร์ด await (ในความเป็นจริง Rust จะแสดงคำเตือนคอมไพเลอร์หากคุณไม่ใช้งานฟิวเจอร์) สิ่งนี้อาจทำให้คุณนึกถึงการอภิปรายเกี่ยวกับตัวซ้ำ (iterators) ในหัวข้อ “การประมวลผลลำดับสิ่งของด้วยตัวซ้ำ” ในบทที่ 13 ตัวซ้ำจะไม่ทำอะไรเลยจนกว่าคุณจะเรียกใช้เมธอด next ของพวกมัน—ไม่ว่าจะโดยตรง หรือโดยการใช้ลูป for หรือเมธอดเช่น map ที่ใช้ next อยู่เบื้องหลัง ในทำนองเดียวกัน ฟิวเจอร์สจะไม่ทำอะไรเลยจนกว่าคุณจะขอร้องพวกมันอย่างชัดเจน ความขี้เกียจนี้ช่วยให้ Rust หลีกเลี่ยงการรันโค้ด async จนกว่าจะจำเป็นต้องใช้งานจริง
หมายเหตุ: สิ่งนี้แตกต่างจากพฤติกรรมที่เราเห็นเมื่อใช้
thread::spawnในหัวข้อ “การสร้างเธรดใหม่ด้วย spawn” ในบทที่ 16 ซึ่งโคลเชอร์ที่เราส่งไปยังอีกเธรดหนึ่งเริ่มทำงานทันที นอกจากนี้ยังแตกต่างจากวิธีที่หลายภาษาอื่นใช้วิธีจัดการ async แต่มันมีความสำคัญต่อ Rust ในการมอบการรับประกันประสิทธิภาพ เช่นเดียวกับตัวซ้ำ
เมื่อเราได้ response_text มาแล้ว เราสามารถพาร์ส (parse) มันเข้าไปในอินสแตนซ์ของชนิดข้อมูล Html โดยใช้ Html::parse แทนที่จะเป็นสตริงดิบ ตอนนี้เรามีชนิดข้อมูลที่เราสามารถใช้ทำงานกับ HTML ในฐานะโครงสร้างข้อมูลที่สมบูรณ์ยิ่งขึ้น โดยเฉพาะอย่างยิ่ง เราสามารถใช้เมธอด select_first เพื่อค้นหาอินสแตนซ์แรกของ CSS selector ที่กำหนด โดยการส่งสตริง "title" เราจะได้เอลิเมนต์ <title> แรกในเอกสาร หากมีอยู่ เนื่องจากอาจไม่มีเอลิเมนต์ที่ตรงกันเลย select_first จึงคืนค่าเป็น Option<ElementRef> ท้ายที่สุด เราใช้เมธอด Option::map ซึ่งช่วยให้เราทำงานกับไอเทมใน Option หากมันมีอยู่ และไม่ทำอะไรหากไม่มี (เราสามารถใช้พฤติกรรม match ที่นี่ได้เช่นกัน แต่ map มีความเป็นสำนวนสไตล์ Rust มากกว่า) ในส่วนของฟังก์ชันที่เรามอบให้แก่ map เราเรียกใช้ inner_html บน title เพื่อดึงเนื้อหาภายใน ซึ่งก็คือ String เมื่อทำทั้งหมดเสร็จสิ้น เราจะได้ Option<String>
โปรดสังเกตว่าคีย์เวิร์ด await ของ Rust จะอยู่ ตามหลัง นิพจน์ที่คุณกำลังรอ ไม่ใช่ก่อนหน้า กล่าวคือ มันเป็นคีย์เวิร์ดแบบ ต่อท้าย (postfix) สิ่งนี้อาจแตกต่างจากสิ่งที่คุณคุ้นเคยหากคุณเคยใช้ async ในภาษาอื่น แต่ใน Rust มันทำให้การเชื่อมต่อเมธอดเป็นสาย (chains) ทำงานได้งดงามยิ่งขึ้น ผลลัพธ์คือเราสามารถเปลี่ยนเนื้อหาของ page_title เพื่อเชื่อมการเรียกใช้ฟังก์ชัน trpl::get และ text เข้าด้วยกัน โดยมี await คั่นกลาง ดังแสดงในโค้ดตัวอย่างที่ 17-2
extern crate trpl; // required for mdbook test
use trpl::Html;
fn main() {
// TODO: we'll add this next!
}
async fn page_title(url: &str) -> Option<String> {
let response_text = trpl::get(url).await.text().await;
Html::parse(&response_text)
.select_first("title")
.map(|title| title.inner_html())
}
เพียงเท่านี้ เราก็ได้เขียนฟังก์ชัน async ตัวแรกของเราสำเร็จแล้ว! ก่อนที่เราจะเพิ่มโค้ดใน main เพื่อเรียกใช้มัน ลองมาพูดถึงสิ่งที่เราเขียนและสิ่งที่มันหมายถึงเพิ่มขึ้นอีกเล็กน้อย
เมื่อ Rust เห็น บล็อก ที่ทำเครื่องหมายด้วยคีย์เวิร์ด async คอมไพเลอร์จะแปลมันไปเป็นชนิดข้อมูลนิรนามที่ไม่ซ้ำใครซึ่งใช้เทรต Future เมื่อ Rust เห็น ฟังก์ชัน ที่ทำเครื่องหมายด้วย async คอมไพเลอร์จะแปลมันเป็นฟังก์ชันที่ไม่ใช่ async ซึ่งเนื้อหาภายในเป็นบล็อก async ชนิดค่าส่งกลับของฟังก์ชัน async คือชนิดข้อมูลนิรนามที่คอมไพเลอร์สร้างขึ้นสำหรับบล็อก async นั้น
ดังนั้น การเขียน async fn จึงเทียบเท่ากับการเขียนฟังก์ชันที่คืนค่า ฟิวเจอร์ ของชนิดค่าส่งกลับ สำหรับคอมไพเลอร์ คำนิยามฟังก์ชัน เช่น async fn page_title ในโค้ดตัวอย่างที่ 17-1 จะเทียบเท่าโดยคร่าวๆ กับฟังก์ชันที่ไม่ใช่ async ที่นิยามไว้แบบนี้:
#![allow(unused)]
fn main() {
extern crate trpl; // required for mdbook test
use std::future::Future;
use trpl::Html;
fn page_title(url: &str) -> impl Future<Output = Option<String>> {
async move {
let text = trpl::get(url).await.text().await;
Html::parse(&text)
.select_first("title")
.map(|title| title.inner_html())
}
}
}
ลองไล่ดูทีละส่วนของเวอร์ชันที่ถูกแปลง:
- มันใช้ไวยากรณ์
impl Traitที่เราได้อภิปรายกันในบทที่ 10 ในหัวข้อ “เทรตในฐานะพารามิเตอร์” - ค่าที่คืนกลับมาจะใช้เทรต
Futureที่มีชนิดข้อมูลเกี่ยวเนื่อง (associated type) เป็นOutputสังเกตว่าชนิดข้อมูลOutputคือOption<String>ซึ่งเหมือนกับชนิดค่าส่งกลับดั้งเดิมจากเวอร์ชันasync fnของpage_title - โค้ดทั้งหมดที่ถูกเรียกใช้ในเนื้อหาของฟังก์ชันดั้งเดิมถูกห่อหุ้มอยู่ในบล็อก
async moveจำไว้ว่าบล็อกเป็นนิพจน์ (expressions) บล็อกทั้งหมดนี้คือนิพจน์ที่คืนค่าจากฟังก์ชัน - บล็อก async นี้สร้างค่าที่มีชนิดข้อมูล
Option<String>ดังที่เพิ่งอธิบายไป ค่านั้นตรงกับชนิดOutputในชนิดค่าส่งกลับ สิ่งนี้เหมือนกับบล็อกอื่นๆ ที่คุณเคยเห็น - เนื้อหาฟังก์ชันใหม่เป็นบล็อก
async moveเนื่องจากวิธีที่มันใช้พารามิเตอร์url(เราจะพูดถึงasyncกับasync moveมากยิ่งขึ้นในภายหลังของบทนี้)
คราวนี้เราสามารถเรียกใช้ page_title ใน main ได้แล้ว
การเรียกใช้ฟังก์ชัน Async ด้วยรันไทม์
ในการเริ่มต้น เราจะดึงชื่อเรื่องของหน้าเว็บเพียงหน้าเดียว แสดงในโค้ดตัวอย่างที่ 17-3 แต่น่าเสียดายที่โค้ดนี้ยังไม่คอมไพล์
extern crate trpl; // required for mdbook test
use trpl::Html;
async fn main() {
let args: Vec<String> = std::env::args().collect();
let url = &args[1];
match page_title(url).await {
Some(title) => println!("The title for {url} was {title}"),
None => println!("{url} had no title"),
}
}
async fn page_title(url: &str) -> Option<String> {
let response_text = trpl::get(url).await.text().await;
Html::parse(&response_text)
.select_first("title")
.map(|title| title.inner_html())
}
เราทำตามรูปแบบเดียวกับที่เราใช้ดึงอาร์กิวเมนต์บรรทัดคำสั่งในหัวข้อ “การรับอาร์กิวเมนต์บรรทัดคำสั่ง” ในบทที่ 12 จากนั้นเราส่งอาร์กิวเมนต์ URL ไปยัง page_title และรอผลลัพธ์ เนื่องจากค่าที่สร้างขึ้นโดยฟิวเจอร์คือ Option<String> เราจึงใช้ นิพจน์ match เพื่อพิมพ์ข้อความที่แตกต่างกันเพื่อรองรับว่าหน้าเว็บมี <title> หรือไม่
สถานที่เดียวที่เราสามารถใช้คีย์เวิร์ด await ได้คือในฟังก์ชันหรือบล็อก async และ Rust จะไม่อนุญาตให้เราทำเครื่องหมายฟังก์ชันพิเศษ main ว่าเป็น async
error[E0752]: `main` function is not allowed to be `async`
--> src/main.rs:6:1
|
6 | async fn main() {
| ^^^^^^^^^^^^^^^ `main` function is not allowed to be `async`
เหตุผลที่ main ไม่สามารถทำเครื่องหมายว่า async ได้ คือโค้ด async จำเป็นต้องมี รันไทม์ (runtime): ซึ่งเป็นเครต Rust ที่จัดการรายละเอียดการประมวลผลโค้ดแบบอะซิงโครนัส ฟังก์ชัน main ของโปรแกรมสามารถ เริ่มต้นทำงาน (initialize) รันไทม์ได้ แต่มันไม่ใช่รันไทม์ ในตัวเอง (เราจะเห็นเพิ่มเติมว่าทำไมจึงเป็นเช่นนี้ในอีกสักครู่) ทุกโปรแกรม Rust ที่ประมวลผลโค้ด async จะต้องมีอย่างน้อยหนึ่งจุดที่ตั้งค่ารันไทม์เพื่อประมวลผลฟิวเจอร์ส
ภาษาส่วนใหญ่ที่รองรับ async จะแถมรันไทม์มาด้วย แต่ Rust ไม่ได้ทำเช่นนั้น แต่กลับมีรันไทม์ async ที่แตกต่างกันให้เลือกใช้มากมาย ซึ่งแต่ละรันไทม์ก็มีข้อดีข้อเสียแลกเปลี่ยนต่างกันที่เหมาะกับกรณีการใช้งานเป้าหมายของมัน ตัวอย่างเช่น เว็บเซิร์ฟเวอร์ที่มีปริมาณงานสูงพร้อมด้วยคอร์ CPU จำนวนมากและ RAM ขนาดใหญ่ย่อมมีความต้องการที่ต่างกันอย่างมากจากไมโครคอนโทรลเลอร์ที่มีคอร์เดียว RAM ขนาดเล็ก และไม่มีความสามารถในการจองหน่วยความจำฮีป (heap allocation) เครตที่จัดหารันไทม์เหล่านั้นมักจะมอบฟังก์ชันการทำงานเวอร์ชัน async สำหรับงานทั่วไป เช่น File หรือ Network I/O ด้วย
ในบทนี้และบทที่เหลือ เราจะใช้ฟังก์ชัน block_on จากเครต trpl ซึ่งรับเอาฟิวเจอร์เป็นอาร์กิวเมนต์และบล็อกเธรดปัจจุบันไว้จนกว่าฟิวเจอร์นี้จะรันจนเสร็จสมบูรณ์ เบื้องหลังการเรียกใช้ block_on จะเป็นการตั้งค่ายังรันไทม์โดยใช้เครต tokio ซึ่งใช้รันฟิวเจอร์ที่ส่งเข้ามา (พฤติกรรม block_on ของเครต trpl จะคล้ายกับฟังก์ชัน block_on ของเครตรันไทม์อื่นๆ) เมื่อฟิวเจอร์ทำงานเสร็จสมบูรณ์แล้ว block_on จะคืนค่าอะไรก็ตามที่ฟิวเจอร์นั้นสร้างขึ้นมา
เราสามารถส่งฟิวเจอร์ที่คืนค่าโดย page_title ไปยัง block_on โดยตรงได้ และเมื่อมันเสร็จสิ้น เราก็สามารถ match บน Option<String> ที่เป็นผลลัพธ์ตามที่เราพยายามทำในโค้ดตัวอย่างที่ 17-3 อย่างไรก็ตาม สำหรับตัวอย่างส่วนใหญ่ในบทนี้ (และโค้ด async ส่วนใหญ่ในโลกจริง) เราจะทำมากกว่าแค่เรียกใช้ฟังก์ชัน async เพียงฟังก์ชันเดียว ดังนั้นเราจะส่งบล็อก async และรอคอยผลลัพธ์ของการเรียกใช้ page_title อย่างชัดเจนดังในโค้ดตัวอย่างที่ 17-4
extern crate trpl; // required for mdbook test
use trpl::Html;
fn main() {
let args: Vec<String> = std::env::args().collect();
trpl::block_on(async {
let url = &args[1];
match page_title(url).await {
Some(title) => println!("The title for {url} was {title}"),
None => println!("{url} had no title"),
}
})
}
async fn page_title(url: &str) -> Option<String> {
let response_text = trpl::get(url).await.text().await;
Html::parse(&response_text)
.select_first("title")
.map(|title| title.inner_html())
}
เมื่อเรารันโค้ดนี้ เราจะได้พฤติกรรมที่เราคาดไว้ในตอนแรก:
$ cargo run -- "https://www.rust-lang.org"
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.05s
Running `target/debug/async_await 'https://www.rust-lang.org'`
The title for https://www.rust-lang.org was
Rust Programming Language
เฮ้อ—ในที่สุดเราก็มีโค้ด async ที่ทำงานได้แล้ว! แต่ก่อนที่เราจะเพิ่มโค้ดเพื่อนำสองเว็บไซต์มาแข่งกัน ลองหันกลับมาให้ความสนใจสั้นๆ ว่าฟิวเจอร์สทำงานอย่างไร
แต่ละ จุดรอ (await point)—นั่นคือทุกจุดที่โค้ดใช้คีย์เวิร์ด await—จะแทนสถานที่ซึ่งการควบคุมถูกส่งกลับไปยังรันไทม์ ในการทำให้สิ่งนี้ทำงานได้ Rust จำเป็นต้องคอยติดตามสถานะ (state) ที่เกี่ยวข้องในบล็อก async เพื่อให้รันไทม์สามารถเริ่มงานอื่นและจากนั้นย้อนกลับมาเมื่อพร้อมจะลองผลักดันงานแรกอีกครั้ง สิ่งนี้คือสเตตแมชชีน (state machine) ที่มองไม่เห็น คล้ายกับว่าคุณเขียน enum แบบนี้ไว้เพื่อบันทึกสถานะปัจจุบัน ณ แต่ละจุดรอ:
#![allow(unused)]
fn main() {
extern crate trpl; // required for mdbook test
enum PageTitleFuture<'a> {
Initial { url: &'a str },
GetAwaitPoint { url: &'a str },
TextAwaitPoint { response: trpl::Response },
}
}
ทว่า การเขียนโค้ดเพื่อสลับเปลี่ยนระหว่างแต่ละสถานะด้วยตนเองจะน่าเบื่อและเกิดข้อผิดพลาดได้ง่าย โดยเฉพาะอย่างยิ่งเมื่อคุณจำเป็นต้องเพิ่มฟังก์ชันการทำงานและสถานะเพิ่มเติมให้กับโค้ดในภายหลัง โชคดีที่คอมไพเลอร์ของ Rust จะสร้างและจัดการโครงสร้างข้อมูลสเตตแมชชีนสำหรับโค้ด async โดยอัตโนมัติ กฎการยืมและความเป็นเจ้าของปกติรอบๆ โครงสร้างข้อมูลยังคงมีผลใช้บังคับอยู่ และเป็นเรื่องน่ายินดีที่คอมไพเลอร์ยังช่วยจัดการตรวจสอบกฎเหล่านั้นให้เราและแสดงข้อความข้อผิดพลาดที่มีประโยชน์ เราจะลองศึกษาตัวอย่างเหล่านั้นในภายหลังของบทนี้
ท้ายที่สุด จะต้องมีบางอย่างประมวลผลสเตตแมชชีนนี้ และสิ่งนั้นก็คือรันไทม์ (นี่คือเหตุผลที่คุณอาจพบการเอ่ยถึง executors เมื่อศึกษาเกี่ยวกับรันไทม์: executor คือส่วนหนึ่งของรันไทม์ที่รับผิดชอบการประมวลผลโค้ด async)
ตอนนี้คุณคงเห็นแล้วว่าทำไมคอมไพเลอร์จึงห้ามไม่ให้เราทำฟังก์ชัน main เป็น async ในโค้ดตัวอย่างที่ 17-3 หาก main เป็นฟังก์ชัน async สิ่งอื่นจำเป็นต้องคอยจัดการสเตตแมชชีนสำหรับฟิวเจอร์ใดก็ตามที่ main คืนค่ามา แต่ main คือจุดเริ่มต้นของโปรแกรม! แต่เราเรียกใช้ฟังก์ชัน trpl::block_on ใน main แทนเพื่อตั้งค่ารันไทม์และรันฟิวเจอร์ที่คืนค่าโดยบล็อก async จนกว่าจะเสร็จสิ้น
หมายเหตุ: รันไทม์บางตัวจัดหามาโครไว้เพื่อให้คุณ สามารถ เขียนฟังก์ชัน
mainแบบ async ได้ มาโครเหล่านั้นจะเขียนasync fn main() { ... }ใหม่ให้กลายเป็นfn mainปกติ ซึ่งทำสิ่งเดียวกับที่เราทำด้วยมือในโค้ดตัวอย่างที่ 17-4: นั่นคือเรียกใช้ฟังก์ชันที่รันฟิวเจอร์จนเสร็จสมบูรณ์ในแบบเดียวกับที่trpl::block_onทำ
คราวนี้ลองนำชิ้นส่วนเหล่านี้มารวมกันและดูว่าเราจะเขียนโค้ดแบบคอนเคอร์เรนต์ได้อย่างไร
การนำ URL สองตัวมาแข่งกันแบบคอนเคอร์เรนต์
ในโค้ดตัวอย่างที่ 17-5 เราเรียกใช้ page_title ด้วย URL สองตัวที่ส่งมาจากบรรทัดคำสั่ง และนำมันมาแข่งกันโดยเลือกฟิวเจอร์ตัวที่เสร็จก่อน
extern crate trpl; // required for mdbook test
use trpl::{Either, Html};
fn main() {
let args: Vec<String> = std::env::args().collect();
trpl::block_on(async {
let title_fut_1 = page_title(&args[1]);
let title_fut_2 = page_title(&args[2]);
let (url, maybe_title) =
match trpl::select(title_fut_1, title_fut_2).await {
Either::Left(left) => left,
Either::Right(right) => right,
};
println!("{url} returned first");
match maybe_title {
Some(title) => println!("Its page title was: '{title}'"),
None => println!("It had no title."),
}
})
}
async fn page_title(url: &str) -> (&str, Option<String>) {
let response_text = trpl::get(url).await.text().await;
let title = Html::parse(&response_text)
.select_first("title")
.map(|title| title.inner_html());
(url, title)
}
เราเริ่มจากการเรียกใช้ page_title สำหรับแต่ละ URL ที่ผู้ใช้ระบุ เราบันทึกฟิวเจอร์สที่เป็นผลลัพธ์เป็น title_fut_1 และ title_fut_2 จำไว้ว่าพวกมันยังไม่ได้ทำอะไรในตอนนี้ เนื่องจากฟิวเจอร์สมีความขี้เกียจและเรายังไม่ได้ทำการรอ (await) พวกมัน จากนั้นเราส่งฟิวเจอร์สไปยัง trpl::select ซึ่งจะคืนค่าที่ระบุว่าฟิวเจอร์สตัวใดส่งเข้ามาทำงานเสร็จก่อน
หมายเหตุ: เบื้องหลัง
trpl::selectถูกสร้างขึ้นบนฟังก์ชันselectทั่วไปซึ่งถูกนิยามในเครตfuturesฟังก์ชันselectของเครตfuturesสามารถทำได้หลายอย่างที่ฟังก์ชันtrpl::selectทำไม่ได้ แต่ก็นำมาซึ่งความซับซ้อนเพิ่มเติมที่เราสามารถข้ามไปก่อนได้ในตอนนี้
ฟิวเจอร์สตัวใดตัวหนึ่งสามารถ “ชนะ” ได้อย่างถูกต้องชอบธรรม ดังนั้นจึงไม่มีเหตุผลที่จะคืนค่าเป็น Result แต่ trpl::select จะคืนค่าชนิดข้อมูลที่เรายังไม่เคยเห็นมาก่อน นั่นคือ trpl::Either ชนิดข้อมูล Either คล้ายคลึงกับ Result ในแง่ที่มีสองกรณี ทว่าแตกต่างจาก Result ตรงที่ไม่มี่แนวคิดเรื่องความสำเร็จหรือล้มเหลวฝังอยู่ใน Either แต่มันใช้ Left และ Right เพื่อบ่งบอกว่า “อย่างใดอย่างหนึ่ง”:
#![allow(unused)]
fn main() {
enum Either<A, B> {
Left(A),
Right(B),
}
}
ฟังก์ชัน select จะคืนค่า Left พร้อมกับผลลัพธ์ของฟิวเจอร์นั้นหากอาร์กิวเมนต์ตัวแรกชนะ และคืนค่า Right พร้อมผลลัพธ์ของฟิวเจอร์ตัวที่สองหากตัว นั้น ชนะ สิ่งนี้ตรงตามลำดับที่อาร์กิวเมนต์ปรากฏเมื่อเรียกใช้ฟังก์ชัน: อาร์กิวเมนต์แรกอยู่ทางซ้ายของอาร์กิวเมนต์ที่สอง
เรายังอัปเดต page_title ให้คืนค่า URL เดียวกับที่ส่งเข้ามาด้วย ด้วยวิธีนั้น หากหน้าเว็บที่คืนค่ากลับมาเป็นหน้าแรกไม่มี <title> ให้เราแก้ไขได้ เราก็ยังสามารถพิมพ์ข้อความที่มีความหมายได้ เมื่อมีข้อมูลนั้นพร้อมใช้งาน เราปิดท้ายด้วยการอัปเดตผลลัพธ์ println! เพื่อระบุทั้ง URL ตัวใดที่เสร็จก่อน และข้อความ <title> (หากมี) สำหรับเว็บเพจที่ URL นั้น
ตอนนี้คุณได้สร้างเว็บสแครปเปอร์ขนาดเล็กที่ทำงานได้แล้ว! เลือก URL สองสามตัวแล้วรันเครื่องมือบรรทัดคำสั่ง คุณอาจค้นพบว่าบางไซต์เร็วกว่าไซต์อื่นอย่างต่อเนื่อง ในขณะที่กรณีอื่น ไซต์ที่เร็วกว่าอาจเปลี่ยนไปในแต่ละครั้งที่รัน ที่สำคัญกว่านั้น คุณได้เรียนรู้พื้นฐานของการทำงานกับฟิวเจอร์สแล้ว คราวนี้เราสามารถเจาะลึกลงไปในสิ่งที่เราทำได้กับ async