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

I/O API ในเว็บเป็นแบบไม่พร้อมกัน แต่จะทำงานพร้อมกันในภาษาของระบบส่วนใหญ่ เมื่อคอมไพล์โค้ดไปยัง WebAssembly คุณจะต้องบริดจ์ API ประเภทหนึ่งไปยัง 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;
}

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

อย่างไรก็ตาม คุณจะใช้ API แบบซิงโครนัสได้ 2 จากเพียง 2 ประเภทเท่านั้น ซึ่งได้แก่ พื้นที่เก็บข้อมูลในหน่วยความจำและ localStorage และทั้ง 2 รายการนี้เป็นตัวเลือกที่จำกัดที่สุดสำหรับสิ่งที่คุณจะจัดเก็บได้และระยะเวลาการใช้งาน ส่วนตัวเลือกอื่นๆ ทั้งหมดจะมีเฉพาะ API แบบอะซิงโครนัสเท่านั้น

นี่เป็นคุณสมบัติหลักอย่างหนึ่งของการเรียกใช้โค้ดบนเว็บ กล่าวคือการดำเนินการที่ใช้เวลานาน ซึ่งรวมถึง I/O จะต้องเป็นการดำเนินการที่ใช้เวลาไม่พร้อมกัน

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

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

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

สิ่งสำคัญที่ต้องจำไว้เกี่ยวกับกลไกนี้คือ ในขณะที่โค้ด JavaScript ที่กำหนดเอง (หรือ WebAssembly) กำลังทำงาน ระบบจะบล็อกลูปเหตุการณ์และในขณะที่ไม่มีวิธีโต้ตอบกับเครื่องจัดการภายนอก เหตุการณ์ I/O ฯลฯ วิธีเดียวที่จะทำให้ผลลัพธ์ I/O กลับมาคือการลงทะเบียนการเรียกกลับ การเรียกใช้โค้ดให้เสร็จสิ้น และให้การควบคุมกลับมาที่เบราว์เซอร์ เมื่อ I/O เสร็จสิ้น ตัวจัดการจะกลายเป็นหนึ่งในงานเหล่านั้นและ จะได้รับการดำเนินการ

ตัวอย่างเช่น หากคุณต้องการเขียนตัวอย่างด้านบนใหม่ใน JavaScript สมัยใหม่และตัดสินใจที่จะอ่านชื่อจาก URL ระยะไกล ให้ใช้ API การดึงข้อมูลและไวยากรณ์ที่ไม่พร้อมกัน:

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 ง่ายๆ อย่าง "สลีป" ซึ่งทำให้แอปพลิเคชันรอตามจำนวนวินาทีที่ระบุ ก็เป็นรูปแบบหนึ่งของการดำเนินการ 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 ทำในการใช้งาน "สลีป" ตามค่าเริ่มต้น แต่วิธีนี้ไม่มีประสิทธิภาพมากนัก และจะบล็อก 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() ตัวอย่างเช่น หากคุณต้องการดึงข้อมูลหมายเลขจากทรัพยากรระยะไกลแทนการอ่านไฟล์ คุณสามารถใช้ข้อมูลโค้ดอย่างเช่นตัวอย่างด้านล่างนี้เพื่อส่งคำขอ ระงับโค้ด 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-รอ ของ JavaScript แทนการใช้ API แบบ Callback ได้ด้วย หากต้องการดำเนินการดังกล่าว ให้โทรหา Asyncify.handleAsync() แทน Asyncify.handleSleep() จากนั้นแทนที่จะต้องกำหนดเวลาเรียกกลับ wakeUp() คุณสามารถส่งฟังก์ชัน JavaScript ของ async แล้วใช้ await และ return ภายในเพื่อให้โค้ดดูเป็นธรรมชาติและซิงโครนัสมากยิ่งขึ้น ในขณะที่ไม่สูญเสียประโยชน์ของ 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 ภายนอกได้ และฟังก์ชันจะทำงานเหมือนกับ 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 Transform นั้นใช้งานง่าย โดยสามารถแปลงไฟล์ WebAssembly แบบกำหนดเองได้ไม่ว่าจะใช้คอมไพเลอร์ใด การเปลี่ยนรูปแบบนี้มีให้แยกต่างหากโดยเป็นส่วนหนึ่งของเครื่องมือเพิ่มประสิทธิภาพ wasm-opt จากเชนเครื่องมือไบนารี และเรียกใช้ได้ในลักษณะนี้

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

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 สมัครใช้บริการการเสร็จสิ้นสัญญา และเมื่อแก้ไขเรียบร้อยแล้ว ให้คืนค่าสแต็กการเรียกใช้และสถานะ รวมถึงดำเนินการต่อไปเสมือนว่าไม่มีอะไรเกิดขึ้น

เนื่องจากฟังก์ชันใดก็ตามในโมดูลอาจทำการเรียกแบบไม่พร้อมกัน การส่งออกทั้งหมดจึงจะไม่พร้อมกัน ดังนั้นระบบจะรวมการส่งออกทั้งหมดด้วย คุณอาจสังเกตเห็นจากตัวอย่างด้านบนว่าคุณต้องawaitผลลัพธ์ของ instance.exports.main() จึงจะทราบเมื่อการดำเนินการเสร็จสมบูรณ์อย่างแท้จริง

วิธีการทำงานขั้นสูง

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

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

เมื่อคอมไพล์ตัวอย่างสลีปแบบพร้อมกันที่แสดงก่อนหน้านี้:

puts("A");
async_sleep(1);
puts("B");

Asyncify จะนำโค้ดนี้มาเปลี่ยนรูปแบบให้คล้ายกับโค้ดต่อไปนี้ (ส่วน Pseudo-code ส่วนการเปลี่ยนรูปแบบจริงจะเกี่ยวข้องมากกว่า)

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" 2 ครั้ง และสาขาที่ "กรอกลับ" เข้ามาโดยตรงแทน เมื่อเข้าถึงแล้ว โค้ดจะคืนค่าในเครื่องที่จัดเก็บไว้ทั้งหมด เปลี่ยนโหมดกลับไปเป็น "ปกติ" และดำเนินการต่อไปราวกับว่าโค้ดไม่เคยหยุดทำงานตั้งแต่แรก

ต้นทุนการเปลี่ยนรูปแบบ

แต่ Asyncify Transform นั้นไม่ฟรีอย่างสิ้นเชิง เนื่องจากต้องใส่โค้ดสนับสนุนเล็กน้อยเพื่อจัดเก็บและกู้คืนไฟล์ในเครื่องทั้งหมด ไปยังส่วนต่างๆ ของการเรียกใช้สแต็กในโหมดต่างๆ และอีกมากมาย เครื่องมือนี้พยายามแก้ไขเฉพาะฟังก์ชันที่ทำเครื่องหมายว่าไม่พร้อมกันในบรรทัดคำสั่ง รวมถึงผู้ที่มีโอกาสเป็นผู้โทร แต่โอเวอร์เฮดของขนาดโค้ดอาจยังรวมได้สูงสุดประมาณ 50% ก่อนการบีบอัด

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

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

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

การสาธิตการใช้งานจริง

ตอนนี้คุณได้ดูตัวอย่างง่ายๆ แล้ว ฉันจะไปต่อกันที่สถานการณ์ที่ซับซ้อนมากขึ้น

ตามที่กล่าวไว้ในตอนต้นของบทความ ตัวเลือกพื้นที่เก็บข้อมูลแบบหนึ่งบนเว็บคือ File System Access API แบบอะซิงโครนัส มอบการเข้าถึงระบบไฟล์ โฮสต์จริงจากเว็บแอปพลิเคชัน

ในทางกลับกัน มีมาตรฐานแท้จริงชื่อว่า WASI สำหรับ WebAssembly I/O ในคอนโซลและฝั่งเซิร์ฟเวอร์ ซึ่งได้รับการออกแบบเป็นเป้าหมายการคอมไพล์สำหรับภาษาของระบบ และแสดงระบบไฟล์ทุกประเภทและการดำเนินการอื่นๆ ในรูปแบบซิงโครนัสดั้งเดิม

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

ในการสาธิตนี้ เราได้รวบรวมลังของ Rust coreutils ที่มีแพตช์เล็กๆ น้อยๆ กับ WASI ส่งผ่าน Asyncify Transform และนำการเชื่อมโยงแบบไม่พร้อมกันจาก WASI ไปใช้ File System Access API ในฝั่ง JavaScript เมื่อรวมเข้ากับคอมโพเนนต์เทอร์มินัล Xterm.js แล้ว คุณจะเห็น Shell ที่สมจริงซึ่งทำงานในแท็บเบราว์เซอร์และดำเนินการกับไฟล์ของผู้ใช้จริงเช่นเดียวกับเทอร์มินัลจริง

ดูการถ่ายทอดสดได้ที่ https://wasi.rreverser.com/

โดยกรณีการใช้งานที่ไม่พร้อมกันไม่ได้จำกัดอยู่เพียงตัวจับเวลาและระบบไฟล์เท่านั้น คุณสามารถไปได้ไกลขึ้นและใช้ API เฉพาะกลุ่มมากขึ้นบนเว็บ

ตัวอย่างเช่น การใช้ Asyncify ทำให้สามารถแมป libusb หรือไลบรารีในเครื่องที่นิยมใช้ทำงานกับอุปกรณ์ USB มากที่สุดกับ WebUSB API ซึ่งให้การเข้าถึงแบบไม่พร้อมกันไปยังอุปกรณ์ดังกล่าวบนเว็บ เมื่อแมปและคอมไพล์แล้ว ผมได้รับการทดสอบ Libusb มาตรฐานและตัวอย่างเพื่อเรียกใช้กับอุปกรณ์ที่เลือกในแซนด์บ็อกซ์ของหน้าเว็บโดยตรง

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

แต่อาจเป็นเรื่องสำหรับบล็อกโพสต์อื่น

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