שימוש ב-WebAssembly ל-API לאינטרנט אסינכרוני

ממשקי ה-API של הקלט/פלט באינטרנט הם אסינכרונים, אבל הם סינכרוניים ברוב שפות המערכת. כשמפעילים הידור של קוד ל-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;
}

הדוגמה הזו לא עושה הרבה, אבל היא כבר מדגימה משהו שאפשר למצוא בכל אפליקציה, בכל גודל: היא קוראת כמה נתוני קלט מהעולם החיצוני, מעבדת אותם באופן פנימי וכותבת את הפלט בחזרה לעולם החיצוני. כל האינטראקציה עם העולם החיצון מתבצעת באמצעות כמה פונקציות שנקראות בדרך כלל פונקציות קלט-פלט, או בקיצור I/O.

כדי לקרוא את השם מ-C, צריך לפחות שתי קריאות חיוניות של קלט/פלט: fopen, כדי לפתוח את הקובץ, ו-fread כדי לקרוא ממנו נתונים. אחרי שאתם מאחזרים את הנתונים, אתם יכולים להשתמש בפונקציית קלט/פלט אחרת, printf, כדי להדפיס את התוצאה במסוף.

הפונקציות האלה נראות פשוטות למדי במבט ראשון, ואין צורך לחשוב פעמיים על המנגנון שבו הן קוראות או כותבות נתונים. עם זאת, בהתאם לסביבה, יכול להיות שיהיו דברים רבים שיתרחשו בתוך ה-container:

  • אם קובץ הקלט נמצא בכונן מקומי, האפליקציה צריכה לבצע סדרה של פעולות גישה לזיכרון ולדיסק כדי לאתר את הקובץ, לבדוק את ההרשאות, לפתוח אותו לקריאה ואז לקרוא את הבלוק אחרי הבלוק עד שמתקבל מספר הבייטים המבוקש. הפעולה הזו עשויה להיות איטית למדי, בהתאם למהירות הדיסק ולגודל המבוקש.
  • לחלופין, קובץ הקלט עשוי להיות ממוקם במיקום רשת מוצמד. במקרה כזה, גם סטאק הרשת יהיה מעורב, וכך יגברו המורכבות, זמן האחזור ומספר הניסיונות החוזרים האפשריים בכל פעולה.
  • לבסוף, גם הפקודה printf לא מבטיחה הדפסה של דברים במסוף, ויכול להיות שהיא תופנה אוטומטית לקובץ או למיקום ברשת. במקרה כזה, תצטרכו לבצע את אותם השלבים שמפורטים למעלה.

בקיצור, פעולות הקלט/פלט יכולות להיות איטיות, ואי אפשר לחזות כמה זמן תיקח קריאה מסוימת על סמך מבט מהיר בקוד. בזמן שהפעולה הזו פועלת, כל האפליקציה נראית קפואה ולא מגיבה למשתמש.

האפשרות הזו לא מוגבלת ל-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 החדש.

עם זאת, אפשר להשתמש באופן סינכרוני רק בשני ממשקי ה-API האלה – האחסון בזיכרון ו-localStorage – ושניהם הם האפשרויות המוגבלות ביותר מבחינת מה שאפשר לאחסן ובמשך כמה זמן. כל שאר האפשרויות מספקות רק ממשקי API אסינכררוניים.

זו אחת מהתכונות המרכזיות של ביצוע קוד באינטרנט: כל פעולה שאורכת זמן, כולל פעולות קלט/פלט, חייבת להיות אסינכרונית.

הסיבה לכך היא שבעבר האינטרנט היה מבוסס על שיטה של שרשור יחיד, וכל קוד משתמש שמשפיע על ממשק המשתמש צריך לפעול באותו שרשור שבו פועל ממשק המשתמש. הוא צריך להתחרות על זמן המעבד עם משימות חשובות אחרות כמו פריסה, עיבוד וטיפול באירועים. לא רוצים שקטע של JavaScript או WebAssembly יוכל להתחיל פעולת 'קריאת קובץ' ולחסום את כל שאר הדברים – הכרטיסייה כולה, או בעבר, את הדפדפן כולו – למשך טווח של אלפיות השנייה עד כמה שניות, עד שהפעולה תסתיים.

במקום זאת, הקוד יכול לתזמן רק פעולת קלט/פלט יחד עם קריאה חוזרת (callback) שתתבצע בסיום הפעולה. קריאות חזרה כאלה מתבצעות כחלק ממחזור האירועים של הדפדפן. לא אכנס כאן לפרטים, אבל אם אתם רוצים לדעת איך לולאת האירועים פועלת בפנים, תוכלו לקרוא את המאמר משימות, מיקרו-משימות, תורים ותזמונים, שבו מוסבר הנושא הזה לעומק.

בקצרה, הדפדפן מפעיל את כל קטעי הקוד במעין לולאה אינסופית, על ידי הוצאתם מהתור אחד אחרי השני. כשאירוע מסוים מופעל, הדפדפן מוציא את הטיפול המתאים מהתור ומריץ אותו בחזרה בחזרה (iteration) הבאה של הלולאה. המנגנון הזה מאפשר לדמות בו-זמניות ולהריץ הרבה פעולות במקביל באמצעות שימוש בשרשור אחד בלבד.

חשוב לזכור לגבי המנגנון הזה: בזמן שהקוד בהתאמה אישית של JavaScript (או WebAssembly) מופעל, לולאת האירועים חסומה, ובזמן שהיא חסומה אין אפשרות להגיב למטפלים חיצוניים, לאירועים, ל-I/O וכו'. הדרך היחידה לקבל חזרה את תוצאות ה-I/O היא לרשום פונקציית קריאה חוזרת (callback), לסיים את ביצוע הקוד ולהחזיר את השליטה לדפדפן כדי שיוכל להמשיך לעבד את המשימות בהמתנה. בסיום פעולות הקלט/פלט, הטיפול (handler) יהפוך לאחת מהמשימות האלה ויתבצע.

לדוגמה, אם רוצים לכתוב מחדש את הדוגמאות שלמעלה ב-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 הוא בעצם התחביר של קריאות חזרה (callbacks):

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

בדוגמה הבאה, שמפושטת קצת יותר, מתחילים בקשה ומירשם מינויים לתשובות באמצעות קריאת ה-back-end הראשונה. אחרי שהדפדפן מקבל את התגובה הראשונית – רק את כותרות ה-HTTP – הוא מפעיל את הפונקציה הזו באופן אסינכררוני. פונקציית ה-callback מתחילה לקרוא את הגוף כטקסט באמצעות response.text(), ומירשם אותה לתוצאה באמצעות פונקציית callback נוספת. בסיום, אחרי ש-fetch מאחזר את כל התוכן, הוא מפעיל את פונקציית ה-callback האחרונה, שמדפיסה את הטקסט "Hello, (username)!" במסוף.

בזכות האופי האסינכרוני של השלבים האלה, הפונקציה המקורית יכולה להחזיר את השליטה לדפדפן ברגע שה-I/O מתוזמן, ולהשאיר את כל ממשק המשתמש תגובה וזמין למשימות אחרות, כולל רינדור, גלילה וכו', בזמן שה-I/O פועל ברקע.

דוגמה אחרונה: גם ממשקי API פשוטים כמו 'sleep', שמאלצים את האפליקציה להמתין מספר שניות מסוים, הם סוג של פעולת קלט/פלט:

#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", אבל זו שיטה לא יעילה במיוחד, כי היא תחסום את כל ממשק המשתמש ולא תאפשר לטפל באירועים אחרים בינתיים. באופן כללי, לא כדאי לעשות זאת בקוד בסביבת הייצור.

במקום זאת, גרסה רגילה יותר של 'השהיה' ב-JavaScript תכלול קריאה ל-setTimeout() והרשמה באמצעות טיפול:

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

מה המשותף לכל הדוגמאות וממשקי ה-API האלה? בכל מקרה, הקוד הidiomatic בשפת המערכות המקורית משתמש ב-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(), אבל אפשר להשתמש בו בכל הקשר אחר שמקבל קריאות חזרה. לבסוף, אפשר להפעיל את 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() ולהעביר אותה ל-callback של 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.handleSleep(), צריך להפעיל את Asyncify.handleAsync(). לאחר מכן, במקום לתזמן קריאה חוזרת (callback) של wakeUp(), תוכלו להעביר פונקציית 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 של 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 כדגל הידור, כי הוא כבר כלול כברירת מחדל.

אוקיי, כל זה עובד מצוין ב-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 קוד לשמירה או לשחזור של ה-stack. בשפת C‏/C++‎, 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.

הוא מדמה API להפעלת מופע של WebAssembly רגיל, אבל במרחב שמות משלו. ההבדל היחיד הוא שב-WebAssembly API רגיל אפשר לספק רק פונקציות סינכרוניות כ-import, ואילו ב-Asyncify wrapper אפשר לספק גם import אסינכרוני:

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 מקבל את הקוד הזה וממיר אותו לקוד שדומה בערך לקוד הבא (קוד מדומה, ההמרה בפועל מורכבת יותר):

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 לא נקייה לחלוטין, כי צריך להחדיר הרבה קוד תומך כדי לאחסן ולשחזר את כל המשתנים המקומיים האלה, לנווט ב-call stack במצבים שונים וכו'. המערכת מנסה לשנות רק פונקציות שמסומנות כאסינכרניות בשורת הפקודה, וגם את כל הגורמים הקריאים הפוטנציאליים שלהן, אבל יתרת הקוד עדיין עשויה להגיע לכ-50% לפני הדחיסה.

תרשים שבו מוצגת העלויות הנלוות לגודל הקוד ביחס למדדי ביצועים שונים, מ-0% כמעט בתנאים מותאמים אישית ועד ליותר מ-100% במקרים הגרועים ביותר

המצב הזה לא אידיאלי, אבל במקרים רבים הוא מקובל כשהחלופה היא לא לקבל את הפונקציונליות בכלל או לבצע שכתוב משמעותי של הקוד המקורי.

חשוב תמיד להפעיל אופטימיזציה לגרסאות ה-build הסופיות כדי למנוע עלייה נוספת. אפשר גם לבדוק את אפשרויות האופטימיזציה הספציפיות ל-Asyncify כדי לצמצם את התקורה על ידי הגבלת הטרנספורמציות רק לפונקציות ספציפיות ו/או רק לקריאות ישירות לפונקציות. יש גם עלות קטנה על ביצועים בסביבת זמן הריצה, אבל היא מוגבלת לשיחות האסינכרוניות עצמן. עם זאת, בהשוואה לעלות של העבודה בפועל, בדרך כלל מדובר בעלות זניחה.

הדגמות בעולם האמיתי

אחרי שבדקתם את הדוגמאות הפשוטות, אסביר על תרחישים מורכבים יותר.

כפי שצוין בתחילת המאמר, אחת מאפשרויות האחסון באינטרנט היא File System Access API אסינכרוני. הוא מספק גישה למערכת הקבצים של המארח האמיתי מאפליקציית אינטרנט.

לעומת זאת, יש תקן למעשה שנקרא WASI לממשקי הקלט/פלט של 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) ולשפר את האבטחה, בלי לאבד את הפונקציונליות.