במדריך הזה, שמיועד למפתחי אתרים שרוצים ליהנות מהיתרונות של WebAssembly, נסביר איך להשתמש ב-Wasm כדי להעביר למיקור חוץ משימות שדורשות הרבה משאבי CPU, בעזרת דוגמה פעילה. המדריך כולל הכול, החל משיטות מומלצות לטעינת מודולי Wasm ועד לאופטימיזציה של הקומפילציה וההפעלה שלהם. בנוסף, המאמר דן בהעברת משימות עתירות CPU ל-Web Workers, ובוחן החלטות הטמעה שתצטרכו לקבל, כמו מתי ליצור את ה-Web Worker והאם להשאיר אותו פעיל באופן קבוע או להפעיל אותו כשצריך. המדריך מציג את הגישה באופן הדרגתי, ומציג דפוס ביצועים אחד בכל פעם, עד שהוא מציע את הפתרון הטוב ביותר לבעיה.
הנחות
נניח שיש לכם משימה שדורשת הרבה משאבי CPU ואתם רוצים להעביר אותה ל-WebAssembly (Wasm) כדי לקבל ביצועים שקרובים לביצועים של אפליקציה מקומית. המשימה שדורשת הרבה משאבי CPU שמשמשת כדוגמה במדריך הזה היא חישוב העצרת של מספר. העצרת היא מכפלת מספר שלם בכל המספרים השלמים שקטנים ממנו. לדוגמה, העצרת של ארבע (שמסומנת כ-4!
) שווה ל-24
(כלומר, 4 * 3 * 2 * 1
). המספרים גדלים מהר. לדוגמה, 16!
הוא 2,004,189,184
. דוגמה ריאליסטית יותר למשימה שדורשת הרבה משאבי CPU יכולה להיות סריקת ברקוד או מעקב אחרי תמונה רסטרית.
בדוגמת הקוד הבאה, שנכתבה ב-C++, מוצגת הטמעה איטרטיבית (ולא רקורסיבית) של פונקציה factorial()
עם ביצועים טובים.
#include <stdint.h>
extern "C" {
// Calculates the factorial of a non-negative integer n.
uint64_t factorial(unsigned int n) {
uint64_t result = 1;
for (unsigned int i = 2; i <= n; ++i) {
result *= i;
}
return result;
}
}
בהמשך המאמר נניח שיש מודול Wasm שמבוסס על קומפילציה של הפונקציה factorial()
באמצעות Emscripten בקובץ שנקרא factorial.wasm
, תוך שימוש בכל השיטות המומלצות לאופטימיזציה של קוד.
במאמר קריאה לפונקציות C שעברו קומפילציה מ-JavaScript באמצעות ccall/cwrap מוסבר איך עושים את זה.
הפקודה הבאה שימשה להידור של factorial.wasm
כ-standalone Wasm.
emcc -O3 factorial.cpp -o factorial.wasm -s WASM_BIGINT -s EXPORTED_FUNCTIONS='["_factorial"]' --no-entry
ב-HTML, יש form
עם input
שמשויך ל-output
ול-submit button
. ההפניה לרכיבים האלה מתבצעת מ-JavaScript על סמך השמות שלהם.
<form>
<label>The factorial of <input type="text" value="12" /></label> is
<output>479001600</output>.
<button type="submit">Calculate</button>
</form>
const input = document.querySelector('input');
const output = document.querySelector('output');
const button = document.querySelector('button');
טעינה, קומפילציה ויצירת מופע של המודול
כדי להשתמש במודול Wasm, צריך לטעון אותו. באינטרנט, התהליך הזה מתבצע באמצעות ה-API fetch()
. כיוון שאפליקציית האינטרנט שלכם תלויה במודול Wasm למשימה שדורשת הרבה משאבי CPU, כדאי לטעון מראש את קובץ ה-Wasm מוקדם ככל האפשר. כדי לעשות את זה, צריך להשתמש בfetch עם CORS בקטע <head>
של האפליקציה.
<link rel="preload" as="fetch" href="factorial.wasm" crossorigin />
בפועל, ה-API של fetch()
הוא אסינכרוני וצריך await
את התוצאה.
fetch('factorial.wasm');
בשלב הבא, מהדרים את מודול ה-Wasm ויוצרים ממנו מופע. יש פונקציות עם שמות מפתים כמו WebAssembly.compile()
(בנוסף ל-WebAssembly.compileStreaming()
) ו-WebAssembly.instantiate()
לביצוע המשימות האלה, אבל במקום זאת, השיטה WebAssembly.instantiateStreaming()
מבצעת קומפילציה של מודול Wasm ו יוצרת מופע שלו ישירות ממקור בסיסי שמוזרם כמו fetch()
– לא נדרש await
. זו הדרך היעילה והאופטימלית ביותר לטעינת קוד Wasm. בהנחה שמודול ה-Wasm מייצא פונקציה factorial()
, אפשר להשתמש בה באופן מיידי.
const importObject = {};
const resultObject = await WebAssembly.instantiateStreaming(
fetch('factorial.wasm'),
importObject,
);
const factorial = resultObject.instance.exports.factorial;
button.addEventListener('click', (e) => {
e.preventDefault();
output.textContent = factorial(parseInt(input.value, 10));
});
העברת המשימה ל-Web Worker
אם מריצים את הפעולה הזו בשרשור הראשי, עם משימות שדורשות הרבה משאבי CPU, יש סיכון לחסימה של כל האפליקציה. שיטה נפוצה היא להעביר משימות כאלה ל-Web Worker.
שינוי המבנה של ה-thread הראשי
כדי להעביר את המשימה שדורשת הרבה משאבי CPU ל-Web Worker, השלב הראשון הוא לשנות את המבנה של האפליקציה. ה-main thread יוצר עכשיו Worker
, ומלבד זאת, הוא רק שולח את הקלט ל-Web Worker, מקבל את הפלט ומציג אותו.
/* Main thread. */
let worker = null;
// When the button is clicked, submit the input value
// to the Web Worker.
button.addEventListener('click', (e) => {
e.preventDefault();
// Create the Web Worker lazily on-demand.
if (!worker) {
worker = new Worker('worker.js');
// Listen for incoming messages and display the result.
worker.addEventListener('message', (e) => {
output.textContent = e.result;
});
}
worker.postMessage({ integer: parseInt(input.value, 10) });
});
גרוע: המשימה מופעלת ב-Web Worker, אבל הקוד לא מסונכרן
ה-Web Worker יוצר מופע של מודול ה-Wasm, וכשהוא מקבל הודעה, הוא מבצע את המשימה שדורשת הרבה משאבי CPU ושולח את התוצאה בחזרה לשרשור הראשי.
הבעיה בגישה הזו היא שאתחול של מודול Wasm באמצעות WebAssembly.instantiateStreaming()
הוא פעולה אסינכרונית. המשמעות היא שהקוד הוא racy. במקרה הגרוע ביותר, ה-thread הראשי שולח נתונים כשה-Web Worker עדיין לא מוכן, וה-Web Worker אף פעם לא מקבל את ההודעה.
/* Worker thread. */
// Instantiate the Wasm module.
// 🚫 This code is racy! If a message comes in while
// the promise is still being awaited, it's lost.
const importObject = {};
const resultObject = await WebAssembly.instantiateStreaming(
fetch('factorial.wasm'),
importObject,
);
const factorial = resultObject.instance.exports.factorial;
// Listen for incoming messages, run the task,
// and post the result.
self.addEventListener('message', (e) => {
const { integer } = e.data;
self.postMessage({ result: factorial(integer) });
});
טוב יותר: המשימה פועלת ב-Web Worker, אבל יכול להיות שיש טעינה והידור מיותרים
פתרון עקיף לבעיה של יצירת מופע של מודול Wasm באופן אסינכרוני הוא להעביר את הטעינה, ההידור והיצירה של מודול Wasm אל מאזין האירועים, אבל המשמעות היא שהפעולה הזו תצטרך להתבצע בכל הודעה שמתקבלת. הפתרון הזה לא הכי גרוע, כי אפשר להשתמש בשמירה במטמון של HTTP, והמטמון של HTTP יכול לשמור במטמון את קוד הבייט של Wasm שעבר קומפילציה. אבל יש דרך טובה יותר.
אם מעבירים את הקוד האסינכרוני לתחילת ה-Web Worker ולא מחכים שההבטחה תתממש, אלא מאחסנים את ההבטחה במשתנה, התוכנית עוברת מיד לחלק של מאזין האירועים בקוד, ולא תאבד אף הודעה מהשרשור הראשי. אפשר להשתמש ב-await בתוך מאזין האירועים.
/* Worker thread. */
const importObject = {};
// Instantiate the Wasm module.
// 🚫 If the `Worker` is spun up frequently, the loading
// compiling, and instantiating work will happen every time.
const wasmPromise = WebAssembly.instantiateStreaming(
fetch('factorial.wasm'),
importObject,
);
// Listen for incoming messages
self.addEventListener('message', async (e) => {
const { integer } = e.data;
const resultObject = await wasmPromise;
const factorial = resultObject.instance.exports.factorial;
const result = factorial(integer);
self.postMessage({ result });
});
טוב: המשימה פועלת ב-Web Worker, והיא נטענת ועוברת קומפילציה רק פעם אחת
התוצאה של השיטה הסטטית WebAssembly.compileStreaming()
היא הבטחה שמובילה ל-WebAssembly.Module
.
תכונה שימושית של האובייקט הזה היא שאפשר להעביר אותו באמצעות postMessage()
.
המשמעות היא שאפשר לטעון ולבצע קומפילציה של מודול ה-Wasm רק פעם אחת בשרשור הראשי (או אפילו ב-Web Worker אחר שמתמקד רק בטעינה ובקומפילציה), ואז להעביר אותו ל-Web Worker שאחראי למשימה שדורשת הרבה משאבי CPU. הקוד הבא מציג את התהליך הזה.
/* Main thread. */
const modulePromise = WebAssembly.compileStreaming(fetch('factorial.wasm'));
let worker = null;
// When the button is clicked, submit the input value
// and the Wasm module to the Web Worker.
button.addEventListener('click', async (e) => {
e.preventDefault();
// Create the Web Worker lazily on-demand.
if (!worker) {
worker = new Worker('worker.js');
// Listen for incoming messages and display the result.
worker.addEventListener('message', (e) => {
output.textContent = e.result;
});
}
worker.postMessage({
integer: parseInt(input.value, 10),
module: await modulePromise,
});
});
בצד של Web Worker, כל מה שנותר הוא לחלץ את האובייקט WebAssembly.Module
וליצור ממנו מופע. מכיוון שההודעה עם WebAssembly.Module
לא מועברת בסטרימינג, הקוד ב-Web Worker משתמש עכשיו ב-WebAssembly.instantiate()
ולא בווריאנט instantiateStreaming()
הקודם. המודול שנוצר נשמר במשתנה במטמון, כך שפעולת יצירת המופע מתבצעת רק פעם אחת כשמפעילים את Web Worker.
/* Worker thread. */
let instance = null;
// Listen for incoming messages
self.addEventListener('message', async (e) => {
// Extract the `WebAssembly.Module` from the message.
const { integer, module } = e.data;
const importObject = {};
// Instantiate the Wasm module that came via `postMessage()`.
instance = instance || (await WebAssembly.instantiate(module, importObject));
const factorial = instance.exports.factorial;
const result = factorial(integer);
self.postMessage({ result });
});
מושלם: המשימה פועלת ב-Web Worker מוטבע, והיא נטענת ועוברת קומפילציה רק פעם אחת
גם עם שמירת נתונים במטמון של HTTP, השגת הקוד של Web Worker (שנשמר במטמון באופן אידיאלי) וגישה לרשת עלולה להיות יקרה. טריק נפוץ לשיפור הביצועים הוא להטמיע את Web Worker ולטעון אותו ככתובת URL של blob:
. עדיין צריך להעביר את מודול ה-Wasm שעבר קומפילציה אל Web Worker כדי ליצור מופע שלו, כי ההקשרים של Web Worker וה-thread הראשי שונים, גם אם הם מבוססים על אותו קובץ מקור של JavaScript.
/* Main thread. */
const modulePromise = WebAssembly.compileStreaming(fetch('factorial.wasm'));
let worker = null;
const blobURL = URL.createObjectURL(
new Blob(
[
`
let instance = null;
self.addEventListener('message', async (e) => {
// Extract the \`WebAssembly.Module\` from the message.
const {integer, module} = e.data;
const importObject = {};
// Instantiate the Wasm module that came via \`postMessage()\`.
instance = instance || await WebAssembly.instantiate(module, importObject);
const factorial = instance.exports.factorial;
const result = factorial(integer);
self.postMessage({result});
});
`,
],
{ type: 'text/javascript' },
),
);
button.addEventListener('click', async (e) => {
e.preventDefault();
// Create the Web Worker lazily on-demand.
if (!worker) {
worker = new Worker(blobURL);
// Listen for incoming messages and display the result.
worker.addEventListener('message', (e) => {
output.textContent = e.result;
});
}
worker.postMessage({
integer: parseInt(input.value, 10),
module: await modulePromise,
});
});
יצירה של Web Worker עצלן או חרוץ
עד עכשיו, כל דוגמאות הקוד הפעילו את Web Worker לפי דרישה, כלומר, כשלוחצים על הכפתור. בהתאם לאפליקציה, יכול להיות שכדאי ליצור את Web Worker מוקדם יותר, למשל כשהאפליקציה לא פעילה או אפילו כחלק מתהליך האתחול של האפליקציה. לכן, צריך להעביר את קוד היצירה של Web Worker מחוץ ל-event listener של הלחצן.
const worker = new Worker(blobURL);
// Listen for incoming messages and display the result.
worker.addEventListener('message', (e) => {
output.textContent = e.result;
});
האם להשאיר את Web Worker או לא
שאלה שכדאי לשאול היא אם כדאי לשמור את Web Worker באופן קבוע או ליצור אותו מחדש בכל פעם שצריך. שתי הגישות אפשריות, ולכל אחת מהן יש יתרונות וחסרונות. לדוגמה, אם משאירים Web Worker באופן קבוע, יכול להיות שגודל הזיכרון של האפליקציה יגדל ויהיה קשה יותר לטפל במשימות מקבילות, כי צריך למפות את התוצאות שמגיעות מה-Web Worker בחזרה לבקשות. מצד שני, קוד האתחול של Web Worker עשוי להיות מורכב למדי, ולכן יכול להיות שיהיה הרבה תקורה אם תיצרו קוד חדש בכל פעם. למזלכם, אפשר למדוד את זה באמצעות User Timing API.
עד עכשיו, דוגמאות הקוד שמרו על Web Worker קבוע אחד. בדוגמת הקוד הבאה נוצר Web Worker חדש לפי הצורך. חשוב לזכור שאתם צריכים לעקוב אחרי סיום הפעולה של Web Worker בעצמכם. (קטע הקוד מדלג על טיפול בשגיאות, אבל אם משהו משתבש, חשוב להקפיד על סיום בכל מקרה, בין אם הפעולה הצליחה או נכשלה).
/* Main thread. */
let worker = null;
const modulePromise = WebAssembly.compileStreaming(fetch('factorial.wasm'));
const blobURL = URL.createObjectURL(
new Blob(
[
`
// Caching the instance means you can switch between
// throw-away and permanent Web Worker freely.
let instance = null;
self.addEventListener('message', async (e) => {
// Extract the \`WebAssembly.Module\` from the message.
const {integer, module} = e.data;
const importObject = {};
// Instantiate the Wasm module that came via \`postMessage()\`.
instance = instance || await WebAssembly.instantiate(module, importObject);
const factorial = instance.exports.factorial;
const result = factorial(integer);
self.postMessage({result});
});
`,
],
{ type: 'text/javascript' },
),
);
button.addEventListener('click', async (e) => {
e.preventDefault();
// Terminate a potentially running Web Worker.
if (worker) {
worker.terminate();
}
// Create the Web Worker lazily on-demand.
worker = new Worker(blobURL);
worker.addEventListener('message', (e) => {
worker.terminate();
worker = null;
output.textContent = e.data.result;
});
worker.postMessage({
integer: parseInt(input.value, 10),
module: await modulePromise,
});
});
הדגמות
יש שתי הדגמות שאפשר להתנסות בהן. אחד עם Web Worker אד הוק (קוד מקור) ואחד עם Web Worker קבוע (קוד מקור).
אם פותחים את Chrome DevTools ובודקים את Console, אפשר לראות את היומנים של User Timing API שמודדים את הזמן שחלף מהרגע של לחיצה על הלחצן ועד שהתוצאה מוצגת על המסך. בכרטיסייה 'רשת' מוצגות בקשות blob:
לכתובות URL. בדוגמה הזו, ההבדל בתזמון בין מודעות אד-הוק לבין מודעות קבועות הוא בערך פי 3. בפועל, במקרה הזה, אי אפשר להבחין בין שתי האפשרויות בעין אנושית. התוצאות באפליקציה שלכם בחיים האמיתיים כנראה יהיו שונות.
מסקנות
בפוסט הזה בחנו כמה דפוסי ביצועים שקשורים ל-Wasm.
- ככלל, מומלץ להשתמש בשיטות הסטרימינג (
WebAssembly.compileStreaming()
ו-WebAssembly.instantiateStreaming()
) במקום בשיטות המקבילות שלא מבוססות על סטרימינג (WebAssembly.compile()
ו-WebAssembly.instantiate()
). - אם אפשר, כדאי להעביר משימות שדורשות ביצועים גבוהים למכונת Web Worker, ולבצע את הטעינה והקומפילציה של Wasm רק פעם אחת מחוץ למכונת Web Worker. כך, מכונת ה-Web Worker צריכה רק ליצור מופע של מודול ה-Wasm שהיא מקבלת מה-thread הראשי שבו הטעינה וההידור התרחשו באמצעות
WebAssembly.instantiate()
, מה שאומר שאפשר לשמור את המופע במטמון אם משאירים את מכונת ה-Web Worker באופן קבוע. - כדאי לשקול בקפידה אם כדאי להשאיר Web Worker קבוע אחד לזמן בלתי מוגבל, או ליצור Web Workers אד-הוק כשצריך אותם. כדאי גם לחשוב מתי הכי טוב ליצור את Web Worker. הדברים שכדאי לקחת בחשבון הם צריכת הזיכרון, משך ההפעלה של Web Worker, אבל גם המורכבות של טיפול אפשרי בבקשות מקבילות.
אם לוקחים בחשבון את הדפוסים האלה, אפשר להגיע לביצועים אופטימליים של Wasm.
תודות
המדריך הזה נבדק על ידי Andreas Haas, Jakob Kummerow, Deepti Gandluri, Alon Zakai, Francis McCabe, François Beaufort ועל ידי Rachel Andrew.