I/O API บนเว็บเป็นแบบไม่พร้อมกัน แต่เป็นแบบพร้อมกันในภาษาของระบบส่วนใหญ่ เมื่อคอมไพล์โค้ดเป็น WebAssembly คุณจะต้องเชื่อม API ประเภทหนึ่งกับอีกประเภทหนึ่ง ซึ่งสะพานเชื่อมนี้ก็คือ Asyncify ในโพสต์นี้ คุณจะได้ทราบกรณีและวิธีใช้ 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++ เช่นกัน ภาษาของระบบส่วนใหญ่จะแสดง I/O ทั้งหมดในรูปแบบของ 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 แบบไม่พร้อมกัน
การดำเนินการที่ใช้เวลานาน ซึ่งรวมถึง I/O จะต้องเป็นแบบไม่พร้อมกัน ซึ่งเป็นคุณสมบัติหลักอย่างหนึ่งของการดำเนินการโค้ดบนเว็บ
เหตุผลคือ เว็บเคยเป็นแบบเธรดเดียว และโค้ดของผู้ใช้ที่ทำงานกับ UI ต้องทำงานบนเธรดเดียวกับ UI และต้องแย่งเวลา CPU กับงานสำคัญอื่นๆ เช่น เลย์เอาต์ การแสดงผล และการจัดการเหตุการณ์ คุณไม่ต้องการให้ JavaScript หรือ WebAssembly ชิ้นหนึ่งเริ่มการดำเนินการ "อ่านไฟล์" และบล็อกทุกอย่างอื่นๆ ไม่ว่าจะเป็นทั้งแท็บ หรือในอดีตคือทั้งเบราว์เซอร์ เป็นเวลาตั้งแต่ไม่กี่มิลลิวินาทีไปจนถึง 2-3 วินาทีจนกว่าการดำเนินการจะเสร็จสิ้น
แต่อนุญาตให้โค้ดกำหนดเวลาการดำเนินการ I/O พร้อมกับการเรียกกลับเพื่อดำเนินการเมื่อดำเนินการเสร็จแล้วเท่านั้น การเรียกกลับดังกล่าวจะดำเนินการเป็นส่วนหนึ่งของลูปเหตุการณ์ของเบราว์เซอร์ เราจะไม่อธิบายอย่างละเอียดในบทความนี้ แต่หากคุณสนใจที่จะเรียนรู้วิธีการทำงานของลูปเหตุการณ์ในเบื้องหลัง โปรดดูหัวข้องาน ไมโครแทสก์ คิว และกำหนดการซึ่งอธิบายหัวข้อนี้อย่างละเอียด
พูดสั้นๆ ก็คือเบราว์เซอร์จะเรียกใช้โค้ดทั้งหมดในลักษณะของลูปที่ไม่มีที่สิ้นสุด โดยดึงโค้ดออกจากคิวทีละรายการ เมื่อมีการทริกเกอร์เหตุการณ์บางอย่าง เบราว์เซอร์จะจัดคิวเครื่องจัดการที่เกี่ยวข้อง และในการวนซ้ำครั้งถัดไป ระบบจะนำออกจากคิวและดำเนินการ กลไกนี้ช่วยให้จำลองการเกิดขึ้นพร้อมกันและเรียกใช้การดำเนินการพร้อมกันจำนวนมากขณะใช้เพียงเทรดเดียว
สิ่งที่ควรทราบเกี่ยวกับกลไกนี้คือขณะที่โค้ด JavaScript (หรือ WebAssembly) ที่กําหนดเองทํางานอยู่ ลูปเหตุการณ์จะถูกบล็อก และขณะที่บล็อกอยู่ จะไม่มีวิธีตอบสนองต่อตัวแฮนเดิลภายนอก เหตุการณ์ I/O ฯลฯ วิธีเดียวที่จะได้รับผลลัพธ์ I/O กลับคืนมาคือการลงทะเบียนการเรียกกลับ เรียกใช้โค้ดให้เสร็จ และส่งการควบคุมกลับไปยังเบราว์เซอร์เพื่อให้ประมวลผลงานที่รอดําเนินการอยู่ต่อไป เมื่อ 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()
และการสมัครรับข้อมูลด้วยเครื่องจัดการ:
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()
callback ตัวอย่างเช่น หากต้องการดึงข้อมูลตัวเลขจากแหล่งข้อมูลระยะไกลแทนการอ่านจากไฟล์ คุณสามารถใช้ข้อมูลโค้ดต่อไปนี้เพื่อส่งคำขอ ระงับโค้ด 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()
จากนั้น คุณสามารถส่งผ่านฟังก์ชัน async
JavaScript และใช้ await
และ return
ข้างในแทนที่จะต้องกำหนดเวลาwakeUp()
การเรียกกลับ ซึ่งจะทำให้โค้ดดูเป็นธรรมชาติและทำงานพร้อมกันมากขึ้น โดยไม่สูญเสียประโยชน์ใดๆ ของ I/O แบบไม่พร้อมกัน
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 ซึ่งจะช่วยให้คุณจัดการ Conversion ระหว่างค่า JavaScript และค่า C++ ได้ ซึ่งรองรับ Asyncify ด้วย คุณจึงเรียกใช้ await()
ใน Promise
ภายนอกได้ และโค้ด JavaScript นี้จะทำงานเหมือนกับ 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
เป็น Flag การคอมไพล์ เนื่องจากมีอยู่แล้วโดยค่าเริ่มต้น
โอเค ทุกอย่างทำงานได้ดีใน 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 จะทําเรื่องนี้ให้เรา แต่เราไม่ได้ใช้ Emscripten ที่นี่ ดังนั้นกระบวนการจึงต้องทําด้วยตนเองมากขึ้น
แต่โชคดีที่การแปลง Asyncify เองนั้นไม่เกี่ยวข้องกับเครื่องมือทางเทคนิคใดๆ ทั้งสิ้น ซึ่งสามารถเปลี่ยนรูปแบบไฟล์ WebAssembly ได้อย่างอิสระ ไม่ว่าจะคอมไพล์โดยคอมไพเลอร์ใดก็ตาม การเปลี่ยนรูปแบบจะระบุแยกต่างหากเป็นส่วนหนึ่งของเครื่องมือเพิ่มประสิทธิภาพ wasm-opt
จาก Binaryen
toolchain และสามารถเรียกใช้ได้ดังนี้
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
โดยจำลอง WebAssembly การสร้างอินสแตนซ์ API มาตรฐาน แต่อยู่ภายใต้เนมสเปซของตัวเอง ความแตกต่างเพียงอย่างเดียวคือ ใน 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 สมัครรับข้อมูลการทำตามคำสัญญา จากนั้นเมื่อแก้ปัญหาแล้ว ก็จะคืนค่า Call Stack และสถานะนั้นได้อย่างราบรื่นและดำเนินการต่อไปราวกับว่าไม่มีอะไรเกิดขึ้น
เนื่องจากฟังก์ชันใดๆ ในโมดูลอาจทำการเรียกใช้แบบไม่พร้อมกัน การส่งออกทั้งหมดจึงอาจเป็นแบบไม่พร้อมกันด้วย จึงมีการรวมฟังก์ชันเหล่านั้นด้วย คุณอาจสังเกตเห็นในตัวอย่างด้านบนว่าคุณต้อง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
และเรียกใช้ฟังก์ชันอีกครั้ง ครั้งนี้ระบบข้าม Branch "การดำเนินการตามปกติ" เนื่องจากได้ทำงานไปแล้วในครั้งที่แล้วและต้องการหลีกเลี่ยงการพิมพ์ "A" 2 ครั้ง แต่ให้พิมพ์ "A" 2 ครั้ง แต่ให้พิมพ์ตรง Branch ที่ "กรอกลับ" โดยตรง เมื่อถึงจุดนั้น ระบบจะกู้คืนตัวแปรที่เก็บไว้ทั้งหมด เปลี่ยนโหมดกลับไปเป็น "ปกติ" และดำเนินการต่อราวกับว่าโค้ดไม่เคยหยุดทำงานตั้งแต่แรก
ค่าใช้จ่ายในการเปลี่ยนรูปแบบ
แต่การแปลง Asyncify นั้นไม่ได้ทำให้โค้ดของคุณเป็น Async ทั้งหมด เนื่องจากต้องแทรกโค้ดสนับสนุนจำนวนมากสำหรับจัดเก็บและกู้คืนตัวแปรภายในทั้งหมด ไปยังกองซ้อนการเรียกใช้ในโหมดต่างๆ และอื่นๆ โดยจะพยายามแก้ไขเฉพาะฟังก์ชันที่ทําเครื่องหมายเป็น "แบบไม่สอดคล้อง" ในบรรทัดคําสั่ง รวมถึงฟังก์ชันที่อาจเรียกใช้ แต่ค่าใช้จ่ายเพิ่มเติมเกี่ยวกับขนาดโค้ดอาจยังคงเพิ่มขึ้นประมาณ 50% ก่อนการบีบอัด
วิธีนี้ไม่ได้ผล แต่ในหลายกรณีก็ยอมรับได้หากทางเลือกอื่นไม่มีฟังก์ชันการทำงานร่วมกันหรือต้องเขียนใหม่อย่างมีนัยสำคัญให้กับโค้ดต้นฉบับ
อย่าลืมเปิดใช้การเพิ่มประสิทธิภาพสำหรับบิลด์สุดท้ายอยู่เสมอเพื่อหลีกเลี่ยงไม่ให้บิลด์เหล่านี้สูงขึ้นไปอีก นอกจากนี้ คุณยังตรวจสอบตัวเลือกการเพิ่มประสิทธิภาพสำหรับ Asyncify โดยเฉพาะเพื่อลดค่าใช้จ่ายเพิ่มเติมได้ด้วย โดยจำกัดการเปลี่ยนรูปแบบไว้เฉพาะฟังก์ชันที่ระบุและ/หรือการเรียกใช้ฟังก์ชันโดยตรงเท่านั้น นอกจากนี้ ประสิทธิภาพรันไทม์ยังอาจลดลงเล็กน้อย แต่จำกัดอยู่ที่การเรียกแบบแอสซิงค์เท่านั้น อย่างไรก็ตาม ค่าใช้จ่ายนี้มักจะไม่มากเมื่อเทียบกับค่าใช้จ่ายของงานจริง
การสาธิตการใช้งานจริง
ตอนนี้คุณได้เห็นตัวอย่างง่ายๆ แล้ว เราจะไปดูสถานการณ์ที่ซับซ้อนมากขึ้น
ดังที่ได้กล่าวไว้ในตอนต้นของบทความ ตัวเลือกพื้นที่เก็บข้อมูลบนเว็บอย่างหนึ่งคือ File System Access API แบบไม่พร้อมกัน ซึ่งให้สิทธิ์เข้าถึงระบบไฟล์ของโฮสต์จริงจากเว็บแอปพลิเคชัน
ในทางกลับกัน ก็มีมาตรฐานที่ยอมรับกันโดยทั่วไปที่เรียกว่า WASI สำหรับ I/O ของ WebAssembly ในคอนโซลและฝั่งเซิร์ฟเวอร์ ภาษานี้ออกแบบมาเพื่อใช้เป็นเป้าหมายการคอมไพล์สำหรับภาษาของระบบ และแสดงระบบไฟล์และการดำเนินการอื่นๆ ทุกประเภทในรูปแบบแบบซิงค์ดั้งเดิม
จะเกิดอะไรขึ้นหากคุณแมปข้อมูลหนึ่งกับอีกข้อมูลหนึ่งได้ จากนั้นคุณจะคอมไพล์แอปพลิเคชันใดก็ได้ในภาษาต้นฉบับใดก็ได้ด้วยเครื่องมือทางเทคนิคที่รองรับเป้าหมาย WASI และเรียกใช้แอปพลิเคชันในแซนด์บ็อกซ์บนเว็บได้ ขณะเดียวกันก็ยังอนุญาตให้แอปพลิเคชันทำงานกับไฟล์ของผู้ใช้จริงได้ Asyncify ช่วยให้คุณทำสิ่งนั้นได้
ในการแสดงตัวอย่างนี้ เราได้คอมไพล์แพ็กเกจ coreutils ของ Rust พร้อมแพตช์เล็กน้อยสำหรับ WASI ซึ่งส่งผ่านผ่านการเปลี่ยนรูปแบบ Asyncify และติดตั้งใช้งานการเชื่อมโยงแบบไม่พร้อมกันจาก WASI ไปยัง File System Access API ฝั่ง JavaScript เมื่อรวมเข้ากับคอมโพเนนต์เทอร์มินัล Xterm.js แล้ว คุณจะพบกับเชลล์ที่ทำงานได้อย่างสมจริงในแท็บเบราว์เซอร์และดำเนินการกับไฟล์ของผู้ใช้จริง เหมือนกับเทอร์มินัลจริง
ดูการถ่ายทอดสดได้ที่ https://wasi.rreverser.com/
กรณีการใช้งาน Asyncify ไม่ได้จำกัดอยู่แค่ตัวจับเวลาและระบบไฟล์เท่านั้น คุณยังดําเนินการต่อและ ใช้ API เฉพาะทางเพิ่มเติมบนเว็บได้
ตัวอย่างเช่น Asyncify ยังช่วยให้คุณแมป libusb ซึ่งเป็นไลบรารีเนทีฟที่ได้รับความนิยมมากที่สุดสำหรับการทำงานกับอุปกรณ์ USB ไปยัง WebUSB API ซึ่งให้สิทธิ์เข้าถึงแบบแอซิงโครนัสกับอุปกรณ์ดังกล่าวบนเว็บ เมื่อทำแผนที่และคอมไพล์แล้ว ผมได้รับการทดสอบ Libusb มาตรฐานและตัวอย่างสำหรับเรียกใช้บนอุปกรณ์ที่เลือกจากในแซนด์บ็อกซ์ของหน้าเว็บโดยตรง
แต่อาจเหมาะกับบล็อกโพสต์อื่นมากกว่า
ตัวอย่างเหล่านี้แสดงให้เห็นว่า Asyncify มีประสิทธิภาพเพียงใดในการเชื่อมโยงช่องว่างและพอร์ตแอปพลิเคชันทุกประเภทไปยังเว็บ ซึ่งจะช่วยให้คุณเข้าถึงข้ามแพลตฟอร์ม Sandbox และรักษาความปลอดภัยได้ดียิ่งขึ้น ทั้งหมดนี้โดยไม่สูญเสียฟังก์ชันการทำงาน