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

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

קלט/פלט בשפות מערכת

אתחיל בדוגמה פשוטה באות ג'. נניח שאתם רוצים לקרוא את שם המשתמש מקובץ ולשלוח לו את ההודעה "שלום, (שם המשתמש)!":

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

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

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

במקום זאת, הקוד יכול לתזמן פעולת קלט/פלט (I/O) רק עם קריאה חוזרת (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)!" במסוף.

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

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

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

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

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

גשר על הפער באמצעות 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 רגיל אפשר לספק רק פונקציות סינכרוניות כמיבוא, ואילו ב-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");

אסינכרוני משתמש בקוד הזה ומשנה אותו כך שיהיה דומה לקוד הבא (קוד מדומה, טרנספורמציה אמיתית מעורבת יותר מזה):

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

לאחר מכן, כשהבעיה ב-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 יכול לעזור לגשר על הפער ולנתב את כל האפליקציות לאינטרנט, וכך לקבל גישה לפלטפורמות שונות, הרצה בארגז חול (sandboxing) ואבטחה טובה יותר – והכל בלי לאבד פונקציונליות.