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

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

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

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

#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 כדי להדפיס את התוצאה במסוף.

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

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

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

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

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

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

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

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

אוקיי, כל זה עובד מצוין ב-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");

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

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

לדוגמה, בעזרת אסינכרוני, אפשר למפות את libusb – כנראה הספרייה המקורית הפופולרית ביותר לעבודה עם התקני USB – ל-WebUSB API, שמעניק גישה אסינכרונית למכשירים כאלה באינטרנט. אחרי מיפוי והידור, קיבלתי בדיקות ליבוס סטנדרטיות ודוגמאות להרצה במכשירים נבחרים בארגז החול של דף אינטרנט.

צילום מסך של פלט ניפוי הבאגים של libusb בדף אינטרנט, עם מידע על מצלמת Canon המחוברת

אבל זה כנראה סיפור לפוסט אחר בבלוג.

הדוגמאות האלה מראות עד כמה Asyncify יכולה להיות יעילה לגשר על הפער ולהעביר כל סוג של אפליקציה לאינטרנט, וכך לאפשר לכם לקבל גישה בפלטפורמות שונות, להשתמש בארגז חול (sandbox) ולשפר את האבטחה, בלי לאבד את הפונקציונליות.