ממשקי ה-API של קלט/פלט באינטרנט הם אסינכרוניים, אבל הם סינכרוניים ברוב שפות המערכת. כשמבצעים קומפילציה של קוד ל-WebAssembly, צריך לגשר בין סוג אחד של ממשקי API לסוג אחר – והגשר הזה הוא Asyncify. במאמר הזה נסביר מתי ואיך להשתמש ב-Asyncify, ואיך הוא פועל מאחורי הקלעים.
קלט/פלט בשפות המערכת
אתחיל עם דוגמה פשוטה ב-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) חשובות: fopen, כדי לפתוח את הקובץ, ו-fread כדי לקרוא ממנו נתונים. אחרי שמאחזרים את הנתונים, אפשר להשתמש בפונקציית קלט/פלט אחרת printf
כדי להדפיס את התוצאה במסוף.
מבט ראשון בפונקציות האלה מגלה שהן פשוטות למדי, ולא צריך לחשוב פעמיים על המנגנון שנדרש כדי לקרוא או לכתוב נתונים. עם זאת, בהתאם לסביבה, יכול להיות שמתרחשים הרבה דברים בתוך המערכת:
- אם קובץ הקלט נמצא בכונן מקומי, האפליקציה צריכה לבצע סדרה של גישות לזיכרון ולדיסק כדי לאתר את הקובץ, לבדוק את ההרשאות, לפתוח אותו לקריאה ואז לקרוא אותו בלוק אחר בלוק עד לאחזור מספר הבייטים המבוקש. הפעולה הזו יכולה להיות איטית למדי, בהתאם למהירות הדיסק ולגודל המבוקש.
- לחלופין, קובץ הקלט עשוי להיות ממוקם במיקום ברשת שמוגדר ככונן וירטואלי, ובמקרה כזה, גם מחסנית הרשת תהיה מעורבת, מה שיגדיל את המורכבות, את זמן האחזור ואת מספר הניסיונות החוזרים הפוטנציאליים לכל פעולה.
- לבסוף, גם
printfלא מבטיח הדפסה של דברים במסוף, ויכול להיות שהוא יופנה מחדש לקובץ או למיקום ברשת. במקרה כזה, הוא יצטרך לעבור את אותם השלבים שצוינו למעלה.
בקיצור, פעולות קלט/פלט יכולות להיות איטיות ואי אפשר לחזות כמה זמן ייקח לביצוע קריאה מסוימת רק על סמך מבט מהיר בקוד. בזמן שהפעולה הזו פועלת, כל האפליקציה תיראה קפואה ולא תגיב למשתמש.
הבעיה הזו לא מוגבלת ל-C או ל-C++. ברוב שפות המערכת, כל הקלט והפלט מוצגים בצורה של ממשקי API סינכרוניים. לדוגמה, אם מתרגמים את הדוגמה ל-Rust, יכול להיות שממשק ה-API ייראה פשוט יותר, אבל העקרונות זהים. פשוט מבצעים קריאה וממתינים באופן סינכרוני עד שהיא תחזיר את התוצאה, בזמן שהיא מבצעת את כל הפעולות היקרות ובסופו של דבר מחזירה את התוצאה בהפעלה אחת:
fn main() {
let s = std::fs::read_to_string("name.txt");
println!("Hello, {}!", s);
}
אבל מה קורה כשמנסים לקמפל את אחת הדוגמאות האלה ל-WebAssembly ולתרגם אותן לאינטרנט? או, כדי לתת דוגמה ספציפית, איך אפשר לתרגם את הפעולה file read? האפליקציה תצטרך לקרוא נתונים מאחסון כלשהו.
מודל אסינכרוני של האינטרנט
יש באינטרנט מגוון אפשרויות אחסון שאפשר למפות אליהן, כמו אחסון בזיכרון (אובייקטים של JS), localStorage, IndexedDB, אחסון בצד השרת ו-File System Access API חדש.
עם זאת, רק שני ממשקי API האלה – האחסון בזיכרון ו-localStorage – יכולים לשמש באופן סינכרוני, ושניהם האפשרויות הכי מגבילות מבחינת מה שאפשר לאחסן וכמה זמן אפשר לאחסן. כל שאר האפשרויות מספקות רק ממשקי API אסינכרוניים.
זו אחת מהתכונות העיקריות של הפעלת קוד באינטרנט: כל פעולה שדורשת זמן רב, כולל כל פעולת קלט/פלט, חייבת להיות אסינכרונית.
הסיבה לכך היא שהאינטרנט הוא היסטורית בעל תהליך יחיד, וכל קוד משתמש שמשפיע על ממשק המשתמש צריך לפעול באותו תהליך כמו ממשק המשתמש. הוא צריך להתחרות עם משימות חשובות אחרות כמו פריסה, עיבוד וטיפול באירועים על זמן המעבד. לא רוצים שקוד JavaScript או WebAssembly יוכל להתחיל פעולה של קריאת קובץ ולחסום את כל השאר – את כל הכרטיסייה, או בעבר את כל הדפדפן – למשך כמה אלפיות השנייה עד כמה שניות, עד שהפעולה תסתיים.
במקום זאת, הקוד יכול רק לתזמן פעולת קלט/פלט יחד עם קריאה חוזרת שתופעל כשהפעולה תסתיים. הקריאות החוזרות האלה מופעלות כחלק מלולאת האירועים של הדפדפן. לא אפרט כאן, אבל אם אתם רוצים לדעת איך לולאת האירוע פועלת מתחת לפני השטח, כדאי לעיין במאמר Tasks, microtasks, queues and schedules שבו מוסבר הנושא הזה לעומק.
הגרסה הקצרה היא שהדפדפן מריץ את כל חלקי הקוד בלולאה אינסופית, על ידי שליפתם מהתור אחד אחרי השני. כשאירוע מסוים מופעל, הדפדפן מוסיף את הגורם המטפל המתאים לתור, ובאיטרציה הבאה של הלולאה הוא מוצא מהתור ומופעל. המנגנון הזה מאפשר לדמות בו-זמניות (concurrency) ולהריץ הרבה פעולות מקבילות תוך שימוש בשרשור אחד בלבד.
הדבר החשוב שצריך לזכור לגבי המנגנון הזה הוא שבזמן שהקוד המותאם אישית של JavaScript (או WebAssembly) פועל, לולאת האירועים חסומה, ובזמן הזה אין אפשרות להגיב לכל מטפלים חיצוניים, אירועים, קלט/פלט וכו'. הדרך היחידה לקבל את תוצאות הקלט/פלט בחזרה היא לרשום קריאה חוזרת (callback), לסיים את הפעלת הקוד ולהחזיר את השליטה לדפדפן כדי שהוא יוכל להמשיך לעבד את כל המשימות שממתינות. אחרי שפעולות הקלט/פלט יסתיימו, ה-handler יהפוך לאחת מהמשימות האלה ויופעל.
לדוגמה, אם רוצים לשכתב את הדוגמאות שלמעלה ב-JavaScript מודרני ולהחליט לקרוא שם מכתובת URL מרוחקת, צריך להשתמש ב-Fetch API וב-async-await syntax:
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));
}
בדוגמה הזו, שהיא קצת יותר ברורה, מתחילה בקשה והתגובות נרשמות באמצעות הקריאה החוזרת הראשונה. אחרי שהדפדפן מקבל את התגובה הראשונית – רק כותרות ה-HTTP – הוא מפעיל את הקריאה החוזרת הזו באופן אסינכרוני. הקריאה החוזרת מתחילה לקרוא את הגוף כטקסט באמצעות
response.text(), ונרשמת לתוצאה באמצעות קריאה חוזרת נוספת. לבסוף, אחרי ש-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