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

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

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

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

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

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

สิ่งสำคัญที่ควรจำเกี่ยวกับกลไกนี้คือ แม้ว่า 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 ก็คือน้ำตาลทางไวยากรณ์สำหรับ Callback:

function main() {
  return fetch("name.txt")
    .then(response => response.text())
    .then(name => console.log("Hello, %s!", name));
}

ในตัวอย่างที่ตัดระดับน้ำตาลแล้วนี้ ซึ่งชัดเจนขึ้นเล็กน้อย คือคำขอเริ่มขึ้นและมีการติดตามการตอบกลับด้วย Callback แรก เมื่อเบราว์เซอร์ได้รับการตอบสนองเริ่มต้น เพียงแค่ HTTP ส่วนหัว - ระบบจะเรียกใช้ Callback นี้แบบไม่พร้อมกัน Callback จะเริ่มอ่านเนื้อหาเป็นข้อความโดยใช้ response.text() และสมัครรับผลลัพธ์ที่มีการติดต่อกลับอีกครั้ง สุดท้าย เมื่อ fetch เรียกดูเนื้อหาทั้งหมดแล้ว ระบบจะเรียกใช้ Callback ล่าสุดซึ่งจะพิมพ์คำว่า "Hello, (username)!" ไปยัง คอนโซลผู้ดูแลระบบ

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

แต่ใช้คำว่า "นอน" ที่ดูสำนวนมากกว่า ใน JavaScript จะต้องเรียก setTimeout() และ การสมัครใช้บริการด้วยเครื่องจัดการ:

console.log("A");
setTimeout(() => {
    console.log("B");
}, 1000);

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

ลดช่องว่างด้วย Asyncify

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

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

wasm-opt -O2 --asyncify \
      --pass-arg=[email protected]_answer \
      [...]

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

เพียงใส่โค้ดรันไทม์ที่รองรับซึ่งจะช่วยได้จริง ให้ระงับและทำต่อ โค้ด WebAssembly ขอย้ำอีกครั้งว่าในเคส C / C นี้ Emscripten จะรวมอยู่ด้วย แต่ตอนนี้คุณต้อง JavaScript Glue Code ที่กำหนดเองซึ่งจะจัดการไฟล์ WebAssembly ที่กำหนดเอง เราได้สร้างไลบรารีแล้ว แค่นี้เอง

คุณจะดูได้ใน GitHub ที่ https://github.com/GoogleChromeLabs/asyncify or 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 ให้สมัครใช้บริการรับคำมั่นสัญญาให้เสร็จสมบูรณ์ และเมื่อปัญหาได้รับการแก้ไขแล้ว กู้คืนสแต็กการเรียกใช้และสถานะได้อย่างราบรื่น รวมถึงดำเนินการต่อราวกับว่าไม่มีอะไรเกิดขึ้น

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

วิธีการทำงานทั้งหมดในเบื้องหลัง

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

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

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

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

Asyncify จะใช้โค้ดนี้และเปลี่ยนรูปแบบให้เหมือนกับโค้ดต่อไปนี้คร่าวๆ (โค้ดเทียม, Real มีการเปลี่ยนแปลงอย่างไรมากกว่านี้)

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

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

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

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

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

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

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

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

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

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

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