การใช้ Web API แบบไม่พร้อมกันจาก WebAssembly

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

กราฟการเรียก
ที่อธิบายการเรียกใช้ 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-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% ก่อนการบีบอัด

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

แม้จะไม่ใช่วิธีที่ดีที่สุด แต่ในหลายๆ กรณีก็ยอมรับได้เมื่อไม่มีทางเลือกอื่น หรือต้องเขียนโค้ดต้นฉบับใหม่ทั้งหมด

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

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

แต่เรื่องนี้อาจต้องเขียนเป็นบล็อกโพสต์อีกเรื่อง

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