API ของ I/O บนเว็บเป็นแบบไม่พร้อมกัน แต่เป็นแบบพร้อมกันในภาษาระบบส่วนใหญ่ เมื่อคอมไพล์โค้ดเป็น WebAssembly คุณต้องเชื่อมต่อ API ประเภทหนึ่งกับอีกประเภทหนึ่ง และการเชื่อมต่อนี้คือ Asyncify ในโพสต์นี้ คุณจะได้เรียนรู้ว่าเมื่อใดและอย่างไรจึงควรใช้ Asyncify รวมถึงวิธีการทำงานเบื้องหลัง
I/O ในภาษาของระบบ
ฉันจะเริ่มด้วยตัวอย่างง่ายๆ ใน C สมมติว่าคุณต้องการอ่านชื่อผู้ใช้จากไฟล์และทักทาย ผู้ใช้ด้วยข้อความ "สวัสดี (ชื่อผู้ใช้)!"
#include <stdio.h>
int main() {
FILE *stream = fopen("name.txt", "r");
char name[20+1];
size_t len = fread(&name, 1, 20, stream);
name[len] = '\0';
fclose(stream);
printf("Hello, %s!\n", name);
return 0;
}
แม้ว่าตัวอย่างนี้จะไม่ได้ทำอะไรมากนัก แต่ก็แสดงให้เห็นถึงสิ่งที่พบได้ในแอปพลิเคชันทุกขนาด นั่นคือการอ่านอินพุตจากภายนอก ประมวลผลภายใน และเขียนเอาต์พุตกลับไปยังภายนอก การโต้ตอบกับโลกภายนอกทั้งหมดดังกล่าวเกิดขึ้นผ่านฟังก์ชัน 2-3 ฟังก์ชันที่เรียกกันทั่วไปว่าฟังก์ชันอินพุต-เอาต์พุต หรือเรียกสั้นๆ ว่า I/O
หากต้องการอ่านชื่อจาก C คุณต้องมีการเรียก I/O ที่สำคัญอย่างน้อย 2 รายการ ได้แก่ fopen เพื่อเปิดไฟล์ และ
fread เพื่ออ่านข้อมูลจากไฟล์ เมื่อดึงข้อมูลแล้ว คุณจะใช้ฟังก์ชัน I/O อื่น printf
เพื่อพิมพ์ผลลัพธ์ไปยังคอนโซลได้
ฟังก์ชันเหล่านั้นดูเรียบง่ายเมื่อมองแวบแรก และคุณไม่ต้องคิดมากเกี่ยวกับกลไกที่เกี่ยวข้องในการอ่านหรือเขียนข้อมูล อย่างไรก็ตาม ภายในสภาพแวดล้อมอาจมีสิ่งต่างๆ เกิดขึ้นมากมาย ดังนี้
- หากไฟล์อินพุตอยู่ในไดรฟ์ภายใน แอปพลิเคชันจะต้องดำเนินการเข้าถึงหน่วยความจำและดิสก์หลายครั้งเพื่อค้นหาไฟล์ ตรวจสอบสิทธิ์ เปิดไฟล์เพื่ออ่าน แล้วอ่านทีละบล็อกจนกว่าจะดึงข้อมูลไบต์ตามจำนวนที่ขอ การดำเนินการนี้อาจค่อนข้างช้า ขึ้นอยู่กับความเร็วของดิสก์และขนาดที่ขอ
- หรือไฟล์อินพุตอาจอยู่ในตำแหน่งเครือข่ายที่เมานต์ไว้ ในกรณีนี้ สแต็กเครือข่ายจะเกี่ยวข้องด้วย ซึ่งจะเพิ่มความซับซ้อน เวลาในการตอบสนอง และจำนวนการลองใหม่ที่อาจเกิดขึ้นสำหรับการดำเนินการแต่ละอย่าง
- สุดท้ายนี้ แม้แต่
printfก็ไม่รับประกันว่าจะส่งเข้าจอข้อมูลไปยังคอนโซลและอาจเปลี่ยนเส้นทางไปยังไฟล์หรือตำแหน่งเครือข่าย ในกรณีนี้จะต้องดำเนินการตามขั้นตอนเดียวกันกับด้านบน
กล่าวโดยสรุปคือ I/O อาจช้าและคุณไม่สามารถคาดการณ์ได้ว่าการเรียกใช้ฟังก์ชันหนึ่งๆ จะใช้เวลานานเท่าใดเพียงแค่ ดูโค้ดอย่างรวดเร็ว ในขณะที่การดำเนินการนั้นทำงานอยู่ แอปพลิเคชันทั้งหมดจะค้าง และไม่ตอบสนองต่อผู้ใช้
ซึ่งไม่ได้จำกัดเฉพาะ C หรือ C++ ภาษาของระบบส่วนใหญ่จะแสดงอินพุต/เอาต์พุตทั้งหมดในรูปแบบของ API แบบซิงโครนัส เช่น หากคุณแปลตัวอย่างเป็น Rust API อาจดูเรียบง่ายขึ้น แต่หลักการเดียวกันนี้ก็ยังคงใช้ได้ คุณเพียงแค่โทรและรอให้ฟังก์ชันส่งคืนผลลัพธ์แบบซิงโครนัส ในขณะที่ฟังก์ชันดำเนินการที่มีค่าใช้จ่ายสูงทั้งหมดและส่งคืนผลลัพธ์ในที่สุดในการเรียกใช้ครั้งเดียว
fn main() {
let s = std::fs::read_to_string("name.txt");
println!("Hello, {}!", s);
}
แต่จะเกิดอะไรขึ้นเมื่อคุณพยายามคอมไพล์ตัวอย่างเหล่านั้นเป็น WebAssembly และแปลเป็น เว็บ หรือหากจะยกตัวอย่างที่เฉพาะเจาะจง การดำเนินการ "อ่านไฟล์" จะแปลเป็นอะไรได้บ้าง โดยจะต้องอ่านข้อมูลจากพื้นที่เก็บข้อมูลบางส่วน
รูปแบบอะซิงโครนัสของเว็บ
เว็บมีตัวเลือกพื้นที่เก็บข้อมูลที่หลากหลายซึ่งคุณสามารถแมปได้ เช่น พื้นที่เก็บข้อมูลในหน่วยความจำ (ออบเจ็กต์ JS), localStorage,
IndexedDB, พื้นที่เก็บข้อมูลฝั่งเซิร์ฟเวอร์
และ File System Access API ใหม่
อย่างไรก็ตาม มีเพียง 2 API เท่านั้นที่ใช้ได้แบบพร้อมกัน ได้แก่ ที่เก็บข้อมูลในหน่วยความจำและ localStorage
และทั้ง 2 API นี้เป็นตัวเลือกที่จำกัดที่สุดในสิ่งที่คุณจัดเก็บได้และระยะเวลาในการจัดเก็บ ส่วนตัวเลือกอื่นๆ ทั้งหมดมีเฉพาะ API แบบไม่พร้อมกัน
นี่เป็นหนึ่งในคุณสมบัติหลักของการเรียกใช้โค้ดบนเว็บ ซึ่งการดำเนินการที่ใช้เวลานาน รวมถึง I/O ใดๆ จะต้องเป็นแบบไม่พร้อมกัน
เนื่องจากในอดีตเว็บเป็นแบบ Single-Thread และโค้ดของผู้ใช้ที่แตะ UI จะต้องทำงานในเธรดเดียวกับ UI โดยจะต้องแข่งขันกับงานสำคัญอื่นๆ เช่น เลย์เอาต์ การแสดงผล และการจัดการเหตุการณ์สำหรับเวลา CPU คุณคงไม่ต้องการให้ JavaScript หรือ WebAssembly เริ่มการดำเนินการ "อ่านไฟล์" และบล็อกทุกอย่างอื่นๆ ไม่ว่าจะเป็นทั้งแท็บหรือทั้งเบราว์เซอร์ (ในอดีต) เป็นเวลาตั้งแต่ระดับมิลลิวินาทีไปจนถึง 2-3 วินาทีจนกว่าจะเสร็จสิ้น
แต่โค้ดจะกำหนดเวลาการดำเนินการ I/O พร้อมกับโค้ดเรียกกลับให้ดำเนินการได้เมื่อเสร็จสิ้นแล้วเท่านั้น ระบบจะเรียกใช้ฟังก์ชันเรียกกลับดังกล่าวเป็นส่วนหนึ่งของวนรอบเหตุการณ์ของเบราว์เซอร์ เราจะไม่ลงรายละเอียดในที่นี้ แต่หากคุณสนใจที่จะเรียนรู้ว่า Event Loop ทำงานอย่างไรเบื้องหลัง โปรดดูTasks, microtasks, queues and schedules ซึ่งอธิบายหัวข้อนี้อย่างละเอียด
พูดสั้นๆ ก็คือ เบราว์เซอร์จะเรียกใช้โค้ดทั้งหมดในลักษณะของลูปที่ไม่มีที่สิ้นสุด โดย นำโค้ดจากคิวมาทีละรายการ เมื่อมีการทริกเกอร์เหตุการณ์บางอย่าง เบราว์เซอร์จะจัดคิวแฮนเดิลที่เกี่ยวข้อง และในการวนซ้ำครั้งถัดไป ระบบจะนำแฮนเดิลออกจากคิวและเรียกใช้ กลไกนี้ช่วยให้จำลองการทำงานพร้อมกันและเรียกใช้การดำเนินการแบบขนานจำนวนมากได้ในขณะที่ใช้เพียงเธรดเดียว
สิ่งสำคัญที่ควรทราบเกี่ยวกับกลไกนี้คือในขณะที่โค้ด JavaScript (หรือ WebAssembly) ที่กำหนดเองทำงาน วนรอบเหตุการณ์จะถูกบล็อก และในขณะที่ถูกบล็อก คุณจะไม่มีวิธีตอบสนองต่อตัวแฮนเดิลภายนอก เหตุการณ์ I/O ฯลฯ วิธีเดียวที่จะรับผลลัพธ์ I/O กลับมาคือการลงทะเบียน Callback ดำเนินการโค้ดให้เสร็จสิ้น และส่งคืนการควบคุมไปยังเบราว์เซอร์เพื่อให้เบราว์เซอร์ประมวลผลงานที่รอดำเนินการต่อไปได้ เมื่อ I/O เสร็จสิ้นแล้ว ตัวแฮนเดิลจะกลายเป็นหนึ่งในงานเหล่านั้นและจะได้รับการดำเนินการ
ตัวอย่างเช่น หากต้องการเขียนตัวอย่างข้างต้นใหม่ใน JavaScript สมัยใหม่และตัดสินใจที่จะอ่านชื่อจาก URL ระยะไกล คุณจะต้องใช้ Fetch API และไวยากรณ์ async-await
async function main() {
let response = await fetch("name.txt");
let name = await response.text();
console.log("Hello, %s!", name);
}
แม้ว่าลักษณะการทำงานจะดูเหมือนเป็นการทำงานแบบซิงโครนัส แต่ในเบื้องหลังแล้ว await แต่ละรายการก็เป็นเพียงไวยากรณ์ที่ทำให้โค้ดอ่านง่ายขึ้นสำหรับ การเรียกกลับ
function main() {
return fetch("name.txt")
.then(response => response.text())
.then(name => console.log("Hello, %s!", name));
}
ในตัวอย่างที่ลดความซับซ้อนนี้ ซึ่งมีความชัดเจนขึ้นเล็กน้อย จะมีการเริ่มคำขอและมีการสมัครรับข้อมูลการตอบกลับด้วยการเรียกกลับครั้งแรก เมื่อเบราว์เซอร์ได้รับการตอบกลับครั้งแรก ซึ่งมีเพียงส่วนหัว HTTP
เท่านั้น เบราว์เซอร์จะเรียกใช้การเรียกกลับนี้แบบไม่พร้อมกัน โดยการเรียกกลับจะเริ่มอ่านเนื้อหาเป็นข้อความโดยใช้
response.text() และสมัครรับผลลัพธ์ด้วยการเรียกกลับอีกรายการ สุดท้ายนี้ เมื่อ fetch ได้
ดึงข้อมูลเนื้อหาทั้งหมดแล้ว ก็จะเรียกใช้การเรียกกลับสุดท้าย ซึ่งจะพิมพ์ "สวัสดี (ชื่อผู้ใช้)!" ไปยังคอนโซล
ด้วยลักษณะการทำงานแบบอะซิงโครนัสของขั้นตอนเหล่านั้น ฟังก์ชันเดิมจึงสามารถส่งคืนการควบคุมไปยังเบราว์เซอร์ได้ทันทีที่กำหนดเวลา I/O และปล่อยให้ UI ทั้งหมดตอบสนองและพร้อมใช้งานสำหรับงานอื่นๆ ซึ่งรวมถึงการแสดงผล การเลื่อน และอื่นๆ ในขณะที่ I/O ทำงานในเบื้องหลัง
ตัวอย่างสุดท้าย แม้แต่ API ง่ายๆ อย่าง "sleep" ซึ่งทำให้แอปพลิเคชันรอตามจำนวนวินาทีที่ระบุ ก็เป็นรูปแบบหนึ่งของการดำเนินการ I/O เช่นกัน
#include <stdio.h>
#include <unistd.h>
// ...
printf("A\n");
sleep(1);
printf("B\n");
แน่นอนว่าคุณสามารถแปลในลักษณะที่ตรงไปตรงมามากซึ่งจะบล็อกเทรดปัจจุบัน จนกว่าเวลาจะหมดอายุได้
console.log("A");
for (let start = Date.now(); Date.now() - start < 1000;);
console.log("B");
ในความเป็นจริง Emscripten ทำเช่นนั้นในการใช้งาน "sleep" โดยค่าเริ่มต้น แต่การทำเช่นนี้ไม่มีประสิทธิภาพอย่างมาก จะบล็อก UI ทั้งหมด และไม่อนุญาตให้จัดการเหตุการณ์อื่นๆ ในขณะเดียวกัน โดยทั่วไปแล้วไม่ควรทำเช่นนั้นในโค้ดเวอร์ชันที่ใช้งานจริง
แต่ใน JavaScript การเรียกใช้ setTimeout() และ
การสมัครใช้บริการด้วยตัวแฮนเดิลจะเป็นการใช้คำว่า "sleep" ที่เป็นสำนวนมากกว่า
console.log("A");
setTimeout(() => {
console.log("B");
}, 1000);
ตัวอย่างและ API เหล่านี้มีอะไรที่เหมือนกัน ในแต่ละกรณี โค้ดสำนวนในภาษาของระบบเดิมจะใช้ API ที่บล็อกสำหรับ I/O ในขณะที่ตัวอย่างที่เทียบเท่าสำหรับเว็บจะใช้ API แบบอะซิงโครนัสแทน เมื่อคอมไพล์ไปยังเว็บ คุณจะต้องแปลงระหว่างรูปแบบการดำเนินการทั้ง 2 รูปแบบนี้ และ WebAssembly ยังไม่มีความสามารถในตัวที่จะทำเช่นนั้นได้
การเชื่อมช่องว่างด้วย Asyncify
Asyncify จึงเข้ามามีบทบาทในจุดนี้ Asyncify เป็นฟีเจอร์ในเวลาคอมไพล์ที่ Emscripten รองรับ ซึ่งช่วยให้หยุดโปรแกรมทั้งหมดชั่วคราวและ กลับมาทำงานต่อแบบไม่พร้อมกันได้ในภายหลัง
การใช้งานใน C / C++ ด้วย Emscripten
หากต้องการใช้ Asyncify เพื่อใช้การหยุดชั่วคราวแบบอะซิงโครนัสสำหรับตัวอย่างสุดท้าย คุณสามารถทำได้ดังนี้
#include <stdio.h>
#include <emscripten.h>
EM_JS(void, async_sleep, (int seconds), {
Asyncify.handleSleep(wakeUp => {
setTimeout(wakeUp, seconds * 1000);
});
});
…
puts("A");
async_sleep(1);
puts("B");
EM_JS คือ
มาโครที่อนุญาตให้กำหนดข้อมูลโค้ด JavaScript ราวกับว่าเป็นฟังก์ชัน C ภายใน ให้ใช้ฟังก์ชัน
Asyncify.handleSleep()
ซึ่งจะบอกให้ Emscripten ระงับโปรแกรมและระบุแฮนเดิล wakeUp() ที่ควร
เรียกใช้เมื่อการดำเนินการแบบไม่พร้อมกันเสร็จสิ้น ในตัวอย่างด้านบน ระบบจะส่งตัวแฮนเดิลไปยัง
setTimeout() แต่คุณจะใช้ตัวแฮนเดิลในบริบทอื่นๆ ที่ยอมรับการเรียกกลับก็ได้ สุดท้าย คุณสามารถเรียกใช้ async_sleep() ได้ทุกที่ที่ต้องการเหมือนกับ sleep() ปกติหรือ API แบบซิงโครนัสอื่นๆ
เมื่อคอมไพล์โค้ดดังกล่าว คุณต้องบอก Emscripten ให้เปิดใช้งานฟีเจอร์ Asyncify โดยทำได้ด้วยการส่ง -s ASYNCIFY รวมถึง -s ASYNCIFY_IMPORTS=[func1,
func2] พร้อมรายการฟังก์ชันที่อาจเป็นแบบอะซิงโครนัสในลักษณะอาร์เรย์
emcc -O2 \
-s ASYNCIFY \
-s ASYNCIFY_IMPORTS=[async_sleep] \
...
ซึ่งจะช่วยให้ Emscripten ทราบว่าการเรียกฟังก์ชันเหล่านั้นอาจต้องบันทึกและกู้คืน สถานะ ดังนั้นคอมไพเลอร์จะแทรกโค้ดสนับสนุนรอบๆ การเรียกดังกล่าว
ตอนนี้เมื่อคุณเรียกใช้โค้ดนี้ในเบราว์เซอร์ คุณจะเห็นบันทึกเอาต์พุตที่ราบรื่นตามที่คาดไว้ โดย B จะปรากฏหลังจาก A เพียงเล็กน้อย
A
B
คุณยังส่งคืนค่าจาก
ฟังก์ชัน Asyncify ได้ด้วย สิ่งที่คุณต้องทำคือส่งคืนผลลัพธ์ของ handleSleep() และส่งผลลัพธ์ไปยังแฮนเดิล wakeUp()
ตัวอย่างเช่น หากคุณต้องการดึงข้อมูลตัวเลขจากแหล่งข้อมูลระยะไกลแทนที่จะอ่านจากไฟล์
คุณสามารถใช้ข้อมูลโค้ดเช่นข้อมูลโค้ดด้านล่างเพื่อส่งคำขอ ระงับโค้ด C และ
ดำเนินการต่อเมื่อดึงข้อมูลเนื้อหาการตอบกลับแล้ว ทั้งหมดนี้จะดำเนินการได้อย่างราบรื่นราวกับว่าการเรียกใช้เป็นแบบซิงโครนัส
EM_JS(int, get_answer, (), {
return Asyncify.handleSleep(wakeUp => {
fetch("answer.txt")
.then(response => response.text())
.then(text => wakeUp(Number(text)));
});
});
puts("Getting answer...");
int answer = get_answer();
printf("Answer is %d\n", answer);
ในความเป็นจริงแล้ว สำหรับ API ที่อิงตาม Promise เช่น fetch() คุณยังสามารถรวม Asyncify กับฟีเจอร์ async-await ของ JavaScript แทนการใช้ API ที่อิงตามการเรียกกลับได้ด้วย หากต้องการทำเช่นนั้น โปรดโทรหา Asyncify.handleAsync() แทน Asyncify.handleSleep() จากนั้นแทนที่จะต้องกำหนดเวลาwakeUp() Callback คุณสามารถส่งฟังก์ชัน JavaScript async และใช้ await และ return ภายในได้ ซึ่งจะทำให้โค้ดดูเป็นธรรมชาติและซิงโครนัสมากยิ่งขึ้นโดยที่ยังคงได้รับประโยชน์จาก อินพุต/เอาต์พุตแบบอะซิงโครนัส
EM_JS(int, get_answer, (), {
return Asyncify.handleAsync(async () => {
let response = await fetch("answer.txt");
let text = await response.text();
return Number(text);
});
});
int answer = get_answer();
กำลังรอค่าที่ซับซ้อน
แต่ตัวอย่างนี้ยังคงจำกัดให้คุณใช้ได้เฉพาะตัวเลข จะเกิดอะไรขึ้นหากคุณต้องการใช้ตัวอย่างเดิม ที่ฉันพยายามดึงชื่อผู้ใช้จากไฟล์เป็นสตริง คุณก็ทำได้เช่นกัน
Emscripten มีฟีเจอร์ที่เรียกว่า Embind ซึ่งช่วยให้คุณจัดการการแปลงค่าระหว่าง JavaScript กับ C++ ได้ นอกจากนี้ยังรองรับ Asyncify ด้วย คุณจึงเรียกใช้ await() ใน Promise ภายนอกได้ และจะทำงานเหมือนกับ await ในโค้ด JavaScript แบบอะซิงค์-รอ
val fetch = val::global("fetch");
val response = fetch(std::string("answer.txt")).await();
val text = response.call<val>("text").await();
auto answer = text.as<std::string>();
เมื่อใช้วิธีนี้ คุณไม่จำเป็นต้องส่ง ASYNCIFY_IMPORTS เป็นแฟล็กการคอมไพล์ เนื่องจากระบบจะรวมไว้ให้โดยค่าเริ่มต้น
อยู่แล้ว
โอเค ทั้งหมดนี้ทำงานได้ดีใน Emscripten แล้วเครื่องมือและภาษาอื่นๆ ล่ะ
การใช้งานจากภาษาอื่นๆ
สมมติว่าคุณมีการเรียกแบบซิงโครนัสที่คล้ายกันในโค้ด Rust ที่ใดที่หนึ่งซึ่งคุณต้องการแมปกับ API แบบไม่พร้อมกันบนเว็บ แต่คุณก็ทำได้เช่นกัน
ก่อนอื่นคุณต้องกำหนดฟังก์ชันดังกล่าวเป็นการนำเข้าปกติผ่านบล็อก extern (หรือไวยากรณ์ของภาษาที่คุณเลือกสำหรับฟังก์ชันภายนอก)
extern {
fn get_answer() -> i32;
}
println!("Getting answer...");
let answer = get_answer();
println!("Answer is {}", answer);
และคอมไพล์โค้ดเป็น WebAssembly
cargo build --target wasm32-unknown-unknown
ตอนนี้คุณต้องวัดคุมไฟล์ WebAssembly ด้วยโค้ดสำหรับจัดเก็บ/กู้คืนสแต็ก สำหรับ C / C++ Emscripten จะดำเนินการนี้ให้เรา แต่ไม่ได้ใช้ที่นี่ ดังนั้นกระบวนการจึงต้องทำด้วยตนเองมากขึ้น
โชคดีที่การแปลง Asyncify เองนั้นไม่ขึ้นอยู่กับเครื่องมือใดๆ โดยสามารถแปลงไฟล์ WebAssembly ใดก็ได้ ไม่ว่าคอมไพเลอร์ใดจะเป็นผู้สร้าง การแปลงจะแยกต่างหาก
เป็นส่วนหนึ่งของเครื่องมือเพิ่มประสิทธิภาพ wasm-opt จากชุดเครื่องมือ Binaryen และเรียกใช้ได้ดังนี้
wasm-opt -O2 --asyncify \
--pass-arg=asyncify-imports@env.get_answer \
[...]
ส่ง --asyncify เพื่อเปิดใช้การเปลี่ยนรูปแบบ แล้วใช้ --pass-arg=… เพื่อระบุรายการฟังก์ชันแบบไม่พร้อมกันที่คั่นด้วยคอมมา
ซึ่งควรระงับสถานะโปรแกรมและกลับมาทำงานต่อในภายหลัง
สิ่งที่คุณต้องทำก็คือระบุโค้ดรันไทม์ที่รองรับซึ่งจะทำหน้าที่ระงับและกลับมาทำงานต่อของโค้ด WebAssembly อีกครั้งที่ในกรณีของ C / C++ Emscripten จะรวมไว้ให้ แต่ตอนนี้คุณต้องมี โค้ดกาว JavaScript ที่กำหนดเองซึ่งจะจัดการไฟล์ WebAssembly ที่กำหนดเอง เราได้สร้างคลัง สำหรับเรื่องนี้โดยเฉพาะ
คุณจะดูได้ใน GitHub ที่
https://github.com/GoogleChromeLabs/asyncify หรือ npm
ภายใต้ชื่อ asyncify-wasm
ซึ่งจำลองAPI การเริ่มต้น WebAssembly มาตรฐาน แต่ภายใต้เนมสเปซของตัวเอง ความแตกต่างเพียงอย่างเดียวคือภายใต้ WebAssembly API ปกติ คุณจะระบุได้เฉพาะฟังก์ชันแบบซิงโครนัสเป็นการนำเข้า ในขณะที่ภายใต้ Asyncify Wrapper คุณจะระบุการนำเข้าแบบไม่พร้อมกันได้ด้วย
const { instance } = await Asyncify.instantiateStreaming(fetch('app.wasm'), {
env: {
async get_answer() {
let response = await fetch("answer.txt");
let text = await response.text();
return Number(text);
}
}
});
…
await instance.exports.main();
เมื่อคุณพยายามเรียกใช้ฟังก์ชันแบบอะซิงโครนัสดังกล่าว เช่น get_answer() ในตัวอย่างข้างต้น จากฝั่ง WebAssembly ไลบรารีจะตรวจหา Promise ที่ส่งคืน ระงับและบันทึกสถานะของแอปพลิเคชัน WebAssembly สมัครรับการแจ้งเตือนเมื่อ Promise เสร็จสมบูรณ์ และต่อมาเมื่อ Promise ได้รับการแก้ไขแล้ว จะกู้คืนสแต็กการเรียกใช้และสถานะอย่างราบรื่น และดำเนินการต่อราวกับว่าไม่มีอะไรเกิดขึ้น
เนื่องจากฟังก์ชันใดๆ ในโมดูลอาจทำการเรียกแบบไม่พร้อมกัน การส่งออกทั้งหมดจึงอาจเป็นแบบไม่พร้อมกันด้วย ดังนั้นจึงต้องมีการห่อหุ้มเช่นกัน คุณอาจสังเกตเห็นในตัวอย่างด้านบนว่าคุณ
ต้องawaitผลลัพธ์ของ instance.exports.main() เพื่อให้ทราบว่าการดำเนินการเสร็จสมบูรณ์เมื่อใด
การทำงานเบื้องหลังของฟีเจอร์นี้
เมื่อ Asyncify ตรวจพบการเรียกใช้ฟังก์ชันใดฟังก์ชันหนึ่งของ ASYNCIFY_IMPORTS ระบบจะเริ่มการดำเนินการแบบไม่พร้อมกัน
บันทึกสถานะทั้งหมดของแอปพลิเคชัน รวมถึงสแต็กการเรียกใช้และตัวแปรเฉพาะที่ชั่วคราว
และต่อมาเมื่อการดำเนินการนั้นเสร็จสิ้น ระบบจะกู้คืนหน่วยความจำและสแต็กการเรียกใช้ทั้งหมด
และดำเนินการต่อจากที่เดิมและมีสถานะเดียวกันราวกับว่าโปรแกรมไม่เคยหยุดทำงาน
ซึ่งคล้ายกับฟีเจอร์ async-await ใน JavaScript ที่ฉันแสดงไปก่อนหน้านี้ แต่ต่างจาก JavaScript ตรงที่ไม่ต้องใช้ไวยากรณ์พิเศษหรือการรองรับรันไทม์จากภาษา และทำงานโดยการแปลงฟังก์ชันแบบซิงโครนัสธรรมดาในเวลาคอมไพล์แทน
เมื่อคอมไพล์ตัวอย่างการนอนหลับแบบอะซิงโครนัสที่แสดงก่อนหน้านี้
puts("A");
async_sleep(1);
puts("B");
Asyncify จะนำโค้ดนี้ไปแปลงให้มีลักษณะคล้ายกับโค้ดต่อไปนี้ (รหัสเทียม การแปลงจริง มีความซับซ้อนมากกว่านี้)
if (mode == NORMAL_EXECUTION) {
puts("A");
async_sleep(1);
saveLocals();
mode = UNWINDING;
return;
}
if (mode == REWINDING) {
restoreLocals();
mode = NORMAL_EXECUTION;
}
puts("B");
โดยค่าเริ่มต้น mode จะตั้งค่าเป็น NORMAL_EXECUTION ในทำนองเดียวกัน เมื่อมีการเรียกใช้โค้ดที่แปลงแล้วดังกล่าวเป็นครั้งแรก ระบบจะประเมินเฉพาะส่วนที่นำไปสู่ async_sleep() ทันทีที่กำหนดเวลา การดำเนินการแบบไม่พร้อมกัน Asyncify จะบันทึกตัวแปรเฉพาะที่ทั้งหมดและคลายสแต็กโดย กลับจากแต่ละฟังก์ชันไปจนถึงด้านบนสุด ซึ่งจะทำให้การควบคุมกลับไปที่วนรอบเหตุการณ์ของเบราว์เซอร์
จากนั้นเมื่อ async_sleep() แก้ไขแล้ว โค้ดการสนับสนุน Asyncify จะเปลี่ยน mode เป็น REWINDING และ
เรียกใช้ฟังก์ชันอีกครั้ง คราวนี้ ระบบจะข้ามกิ่ง "การดำเนินการปกติ" เนื่องจากได้ดำเนินการไปแล้ว เมื่อครั้งที่แล้ว และฉันไม่ต้องการพิมพ์ "A" สองครั้ง แต่จะไปที่กิ่ง "การกรอกลับ" โดยตรงแทน เมื่อถึงจุดนั้นแล้ว ฟังก์ชันจะคืนค่าตัวแปรเฉพาะที่ที่จัดเก็บไว้ทั้งหมด เปลี่ยนโหมดกลับเป็น "ปกติ" และดำเนินการต่อราวกับว่าโค้ดไม่เคยหยุดทำงานตั้งแต่แรก
ค่าใช้จ่ายในการเปลี่ยนแปลง
น่าเสียดายที่การแปลง Asyncify ไม่ได้ฟรีทั้งหมด เนื่องจากต้องแทรกโค้ดสนับสนุนจำนวนมากเพื่อจัดเก็บและกู้คืนตัวแปรเฉพาะที่ทั้งหมด นำทางสแต็กการเรียกใช้ภายใต้โหมดต่างๆ และอื่นๆ โดยจะพยายามแก้ไขเฉพาะฟังก์ชันที่ทำเครื่องหมายเป็นแบบไม่พร้อมกันในบรรทัดคำสั่ง รวมถึงผู้เรียกที่อาจเกิดขึ้น แต่ค่าใช้จ่ายเพิ่มเติมด้านขนาดโค้ดอาจยังคงเพิ่มขึ้นได้ถึงประมาณ 50% ก่อนการบีบอัด

แม้จะไม่ใช่วิธีที่ดีที่สุด แต่ในหลายๆ กรณีก็ยอมรับได้เมื่อไม่มีทางเลือกอื่น หรือต้องเขียนโค้ดต้นฉบับใหม่ทั้งหมด
โปรดเปิดใช้การเพิ่มประสิทธิภาพสําหรับบิลด์สุดท้ายเสมอเพื่อไม่ให้ค่าใช้จ่ายสูงขึ้น นอกจากนี้ คุณยังดูตัวเลือกการเพิ่มประสิทธิภาพเฉพาะ Asyncify เพื่อลดค่าใช้จ่ายได้โดย จำกัดการแปลงเฉพาะฟังก์ชันที่ระบุและ/หรือเฉพาะการเรียกฟังก์ชันโดยตรง นอกจากนี้ ยังมี ค่าใช้จ่ายเล็กน้อยต่อประสิทธิภาพรันไทม์ แต่จะจำกัดเฉพาะการเรียกแบบไม่พร้อมกันเท่านั้น อย่างไรก็ตาม เมื่อเทียบกับค่าใช้จ่ายของงานจริงแล้ว ค่าธรรมเนียมนี้มักจะน้อยมาก
การสาธิตในสถานการณ์จริง
ตอนนี้คุณได้ดูตัวอย่างง่ายๆ แล้ว ฉันจะไปที่สถานการณ์ที่ซับซ้อนมากขึ้น
ดังที่กล่าวไว้ตอนต้นของบทความ ตัวเลือกพื้นที่เก็บข้อมูลอย่างหนึ่งบนเว็บคือ File System Access API แบบไม่พร้อมกัน โดยจะให้สิทธิ์เข้าถึงระบบไฟล์ของโฮสต์จริงจากเว็บแอปพลิเคชัน
ในทางกลับกัน มีมาตรฐานที่ใช้กันโดยทั่วไปที่เรียกว่า WASI สำหรับ I/O ของ WebAssembly ในคอนโซลและฝั่งเซิร์ฟเวอร์ โดยได้รับการออกแบบมาให้เป็นเป้าหมายการคอมไพล์สำหรับภาษาของระบบ และแสดงการดำเนินการทุกประเภทในระบบไฟล์และการดำเนินการอื่นๆ ในรูปแบบซิงโครนัสแบบดั้งเดิม
จะดีแค่ไหนหากคุณแมปสิ่งหนึ่งกับอีกสิ่งหนึ่งได้ จากนั้นคุณจะคอมไพล์แอปพลิเคชันใดก็ได้ในภาษาต้นฉบับใดก็ได้ ด้วย Toolchain ใดก็ได้ที่รองรับเป้าหมาย WASI และเรียกใช้ในแซนด์บ็อกซ์บนเว็บได้ ขณะเดียวกันก็ยัง อนุญาตให้ดำเนินการกับไฟล์ของผู้ใช้จริงได้ด้วย Asyncify ช่วยให้คุณทำสิ่งนี้ได้
ในการสาธิตนี้ ฉันได้คอมไพล์ Crate coreutils ของ Rust ด้วยแพตช์เล็กๆ น้อยๆ 2-3 รายการไปยัง WASI ซึ่งส่งผ่านผ่านการแปลง Asyncify และใช้การเชื่อมโยงแบบไม่พร้อมกันจาก WASI ไปยัง File System Access API ในฝั่ง JavaScript เมื่อรวมกับคอมโพเนนต์เทอร์มินัล Xterm.js แล้ว ฟีเจอร์นี้จะให้เชลล์ที่สมจริงซึ่งทำงานในแท็บเบราว์เซอร์และดำเนินการกับไฟล์ของผู้ใช้จริง เหมือนกับเทอร์มินัลจริง
ดูการสาธิตแบบสดๆ ได้ที่ https://wasi.rreverser.com/
กรณีการใช้งาน Asyncify ไม่ได้จำกัดอยู่แค่ตัวจับเวลาและระบบไฟล์ คุณสามารถทำสิ่งต่างๆ ได้มากขึ้นและ ใช้ API เฉพาะกลุ่มเพิ่มเติมบนเว็บ
ตัวอย่างเช่น Asyncify ยังช่วยให้สามารถแมป libusb ซึ่งอาจเป็นไลบรารีแบบเนทีฟที่ได้รับความนิยมมากที่สุดสำหรับการทำงานกับอุปกรณ์ USB กับ WebUSB API ซึ่งจะให้สิทธิ์เข้าถึงอุปกรณ์ดังกล่าวแบบไม่พร้อมกันบนเว็บ เมื่อแมปและรวบรวมแล้ว ฉันก็ได้รับการทดสอบและตัวอย่าง libusb มาตรฐานเพื่อเรียกใช้กับอุปกรณ์ที่เลือกในแซนด์บ็อกซ์ของหน้าเว็บโดยตรง

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