การใช้ Web 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 แต่ละรายการนั้นเป็นไวยากรณ์เสริมสำหรับ callback ดังนี้

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 ทั้งหมด และจะไม่อนุญาตให้จัดการเหตุการณ์อื่นๆ ในระหว่างนี้ โดยทั่วไปแล้ว ไม่ควรทำเช่นนั้นในโค้ดเวอร์ชันที่ใช้งานจริง

แต่ "sleep" ใน 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 ซึ่งช่วยให้คุณจัดการการเปลี่ยนค่าระหว่าง JavaScript กับ C++ ได้ เครื่องมือนี้ยังรองรับ Asyncify ด้วย คุณจึงเรียก await() ใน Promise ภายนอกได้ และ await() จะทํางานเหมือนกับ await ในโค้ด JavaScript แบบ async-await ดังนี้

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

ค่าใช้จ่ายในการเปลี่ยนรูปแบบ

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