การใช้ API ของเว็บแบบไม่พร้อมกันจาก WebAssembly

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 รองรับ ซึ่งช่วยให้หยุดทั้งโปรแกรมชั่วคราวและกลับมาทำงานต่อแบบไม่สอดคล้องกันในภายหลังได้

กราฟการเรียกใช้ที่อธิบายการเรียกใช้ JavaScript -> WebAssembly -> web API -> งานที่ไม่พร้อมกัน โดย Asyncify จะเชื่อมต่อผลลัพธ์ของงานที่ไม่พร้อมกันกลับไปยัง WebAssembly

การใช้งานใน 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% ก่อนการบีบอัด

กราฟแสดงค่าใช้จ่ายเพิ่มเติมเกี่ยวกับขนาดโค้ดสําหรับการเปรียบเทียบต่างๆ ตั้งแต่เกือบ 0% ภายใต้เงื่อนไขที่มีการปรับแต่งอย่างละเอียดไปจนถึงมากกว่า 100% ในกรณีที่แย่ที่สุด

วิธีนี้ไม่ได้ผล แต่ในหลายกรณีก็ยอมรับได้หากทางเลือกอื่นไม่มีฟังก์ชันการทำงานร่วมกันหรือต้องเขียนใหม่อย่างมีนัยสำคัญให้กับโค้ดต้นฉบับ

อย่าลืมเปิดใช้การเพิ่มประสิทธิภาพสำหรับบิลด์สุดท้ายอยู่เสมอเพื่อหลีกเลี่ยงไม่ให้บิลด์เหล่านี้สูงขึ้นไปอีก นอกจากนี้ คุณยังตรวจสอบตัวเลือกการเพิ่มประสิทธิภาพสำหรับ 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 มาตรฐานและตัวอย่างสำหรับเรียกใช้บนอุปกรณ์ที่เลือกจากในแซนด์บ็อกซ์ของหน้าเว็บโดยตรง

ภาพหน้าจอของ libusb
เอาต์พุตการแก้ไขข้อบกพร่องในหน้าเว็บ ซึ่งแสดงข้อมูลเกี่ยวกับกล้อง Canon ที่เชื่อมต่ออยู่

แต่อาจเหมาะกับบล็อกโพสต์อื่นมากกว่า

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