שימוש בשרשורים של WebAssembly מ-C, C++ ו-Rust

איך מעבירים אפליקציות עם כמה שרשורים שנכתבו בשפות אחרות ל-WebAssembly?

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

במאמר הזה נסביר איך להשתמש בשרשראות של WebAssembly כדי להעביר לאינטרנט אפליקציות עם כמה שרשורים שנכתבו בשפות כמו C,‏ C++‎ ו-Rust.

איך פועלים השרשור של WebAssembly

שרשראות של WebAssembly הן לא תכונה נפרדת, אלא שילוב של כמה רכיבים שמאפשר לאפליקציות WebAssembly להשתמש במודלים מסורתיים של ריבוי שרשורים באינטרנט.

Web Workers

הרכיב הראשון הוא Workers הרגילים שאתם מכירים ומעריכים מ-JavaScript. בשרתי WebAssembly, ה-constructor‏ new Worker משמש ליצירת שרשורים בסיסיים חדשים. כל שרשור טוען דבק JavaScript, ואז השרשור הראשי משתמש בשיטה Worker#postMessage כדי לשתף את WebAssembly.Module המקודד וגם את WebAssembly.Memory המשותף (ראו בהמשך) עם שאר השרשורים. כך נוצרת תקשורת ומאפשרת לכל השרשור האלה להריץ את אותו קוד WebAssembly באותה זיכרון משותף בלי לעבור שוב דרך JavaScript.

Web Workers קיימים כבר יותר מעשור, יש להם תמיכה רחבה והם לא דורשים דגלים מיוחדים.

SharedArrayBuffer

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

> new WebAssembly.Memory({ initial:1, maximum:10 }).buffer
ArrayBuffer {  }

כדי לתמוך בריבוי שרשורים, ל-WebAssembly.Memory יש גם וריאנט משותף. כשיוצרים אותו באמצעות דגל shared דרך API של JavaScript או על ידי הקובץ הבינארי של WebAssembly, הוא הופך ל-wrapper במסגרת SharedArrayBuffer. מדובר בווריאציה של ArrayBuffer שאפשר לשתף עם שרשורים אחרים ולקרוא או לשנות אותו במקביל מכל צד.

> new WebAssembly.Memory({ initial:1, maximum:10, shared:true }).buffer
SharedArrayBuffer {  }

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

ל-SharedArrayBuffer יש היסטוריה מורכבת. הוא נשלח בהתחלה בכמה דפדפנים באמצע 2017, אבל היה צריך להשבית אותו בתחילת 2018 בגלל שזוהו נקודות חולשה של ספקטר. הסיבה הספציפית לכך היא שחילוץ הנתונים ב-Spectre מתבסס על התקפות תזמון – מדידת זמן הביצוע של קטע קוד מסוים. כדי להקשות על התקפה כזו, הדפדפנים הפחיתו את הדיוק של ממשקי ה-API הרגילים לניהול תזמון, כמו Date.now ו-performance.now. עם זאת, זיכרון משותף בשילוב עם לולאת ספירה פשוטה שפועלת בשרשור נפרד היא גם דרך אמינה מאוד לקבל תזמון מדויק, וקשה הרבה יותר לצמצם את השימוש בה בלי לצמצם באופן משמעותי את הביצועים בסביבת זמן הריצה.

במקום זאת, בגרסה 68 של Chrome (אמצע 2018) הפעלנו מחדש את SharedArrayBuffer באמצעות Site Isolation – תכונה שמציבה אתרים שונים בתהליכים שונים ומקשה מאוד להשתמש בהתקפות בערוץ צדדי כמו Spectre. עם זאת, הפעולה הזו הייתה מוגבלת רק ל-Chrome למחשב, כי בידוד האתרים הוא תכונה יקרה למדי, ולא ניתן להפעיל אותה כברירת מחדל לכל האתרים במכשירים ניידים עם זיכרון נמוך, וגם עדיין לא ספקים אחרים הטמיעו אותה.

הגענו לשנת 2020, ו-Chrome ו-Firefox כוללים הטמעות של בידוד אתרים, ויש דרך רגילה לאתרים להביע הסכמה לשימוש בתכונה באמצעות כותרות COOP ו-COEP. מנגנון הסכמה מאפשר להשתמש בבידוד של אתר גם במכשירים עם הספק נמוך, שבהם הפעלת הבידוד לכל האתרים יקרה מדי. כדי להביע הסכמה, מוסיפים את הכותרות הבאות למסמך הראשי בהגדרות השרת:

Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Opener-Policy: same-origin

אחרי שתאשרו את ההסכמה, תקבלו גישה ל-SharedArrayBuffer (כולל WebAssembly.Memory שמגובים על ידי SharedArrayBuffer), לטיימרים מדויקים, למדידת זיכרון ולממשקי API אחרים שדורשים מקור מבודד מטעמי אבטחה. לפרטים נוספים, קראו את המאמר בידוד האתר מ-origin שונים באמצעות COOP ו-COEP.

פעולות אטומיות ב-WebAssembly

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

WebAssembly היא תוסף לקבוצת ההוראות של WebAssembly שמאפשרת לקרוא ולכתוב תאים קטנים של נתונים (בדרך כלל מספרים שלמים של 32 ו-64 ביט) "באופן אטומי". כלומר, באופן שמבטיח שאף שני חוטים לא קוראים או כותבים באותו תא בו-זמנית, וכך מונעים התנגשויות כאלה ברמה נמוכה. בנוסף, פונקציות האטומיות של WebAssembly מכילות עוד שני סוגים של הוראות – 'wait' ו-'notify' – שמאפשרות לשרשור אחד להיכנס למצב שינה ('wait') בכתובת מסוימת בזיכרון משותף, עד ששרשור אחר יעיר אותו באמצעות 'notify'.

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

איך משתמשים בשרשור של WebAssembly

זיהוי תכונות

SharedArrayBuffer ו-WebAssembly atomics הן תכונות חדשות יחסית, שעדיין לא זמינות בכל הדפדפנים עם תמיכה ב-WebAssembly. בתוכנית העבודה של webassembly.org תוכלו למצוא אילו דפדפנים תומכים בתכונות החדשות של WebAssembly.

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

import { threads } from 'wasm-feature-detect';

const hasThreads = await threads();

const module = await (
  hasThreads
    ? import('./module-with-threads.js')
    : import('./module-without-threads.js')
);

// …now use `module` as you normally would

עכשיו נראה איך יוצרים גרסה עם כמה שרשורים של מודול WebAssembly.

C

ב-C, במיוחד במערכות כמו Unix, הדרך הנפוצה להשתמש בשרשורים היא באמצעות שרשורי POSIX שמסופקים על ידי הספרייה pthread. Emscripten מספק הטמעה תואמת-API של הספרייה pthread שנבנתה על גבי Web Workers, זיכרון משותף ואטומיים, כך שאותו קוד יכול לפעול באינטרנט ללא שינויים.

בואו נראה דוגמה:

example.c:

#include <stdio.h>
#include <unistd.h>
#include <pthread.h>

void *thread_callback(void *arg)
{
    sleep(1);
    printf("Inside the thread: %d\n", *(int *)arg);
    return NULL;
}

int main()
{
    puts("Before the thread");

    pthread_t thread_id;
    int arg = 42;
    pthread_create(&thread_id, NULL, thread_callback, &arg);

    pthread_join(thread_id, NULL);

    puts("After the thread");

    return 0;
}

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

pthread_create תיצור שרשור ברקע. הפונקציה מקבלת יעד לאחסון ה-handle של השרשור, מאפייני יצירה של שרשור (כאן לא מעבירים אף מאפיין, כך שהערך הוא רק NULL), את הפונקציה הלא חוזרת (callback) שתתבצע בשרשור החדש (כאן thread_callback) ואת הפונקציה הלא חוזרת (callback) האופציונלית להעברת הארגומנטים, למקרה שרוצים לשתף נתונים מהשרשור הראשי. בדוגמה הזו אנחנו משתפים את הפונקציה הלא חוזרת (callback) של המשתנה arg.

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

כדי לקמפל קוד באמצעות חוטים ב-Emscripten, צריך להפעיל את emcc ולהעביר פרמטר -pthread, כמו כשמקמפלים את אותו קוד באמצעות Clang או GCC בפלטפורמות אחרות:

emcc -pthread example.c -o example.js

עם זאת, כשמנסים להריץ אותו בדפדפן או ב-Node.js, מופיעה אזהרה ואז התוכנית נתקעת:

Before the thread
Tried to spawn a new thread, but the thread pool is exhausted.
This might result in a deadlock unless some threads eventually exit or the code
explicitly breaks out to the event loop.
If you want to increase the pool size, use setting `-s PTHREAD_POOL_SIZE=...`.
If you want to throw an explicit error instead of the risk of deadlocking in those
cases, use setting `-s PTHREAD_POOL_SIZE_STRICT=2`.
[…hangs here…]

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

במקרה כזה, הקוד קורא לסינכרון ל-pthread_create כדי ליצור שרשור ברקע, ולאחר מכן מבצע קריאה סינכרנית נוספת ל-pthread_join שמחכה לסיום ההרצה של שרשור הרקע. עם זאת, משימות Web Workers, שמשמשות מאחורי הקלעים כשהקוד הזה מופעל באמצעות Emscripten, הן אסינכרוניות. אז מה שקורה הוא ש-pthread_create מתזמן רק שרשור עובד חדש שייווצר בהרצה הבאה של לולאת האירוע, אבל לאחר מכן pthread_join חוסם מיד את לולאת האירוע כדי להמתין לעובד הזה. הפעולה הזו מונעת את היצירה שלו. זוהי דוגמה קלאסית לנעילה גורפת.

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

זה בדיוק מה ש-Emscripten מאפשר באמצעות האפשרות -s PTHREAD_POOL_SIZE=.... היא מאפשרת לציין מספר של שרשורים – מספר קבוע או ביטוי JavaScript כמו navigator.hardwareConcurrency כדי ליצור כמה שרשורים שיש ליבות ב-CPU. האפשרות השנייה מועילה אם הקוד יכול להתאים לעומס (scaling) למספר שרירותי של שרשורים.

בדוגמה שלמעלה, נוצר רק חוט אחד, ולכן במקום להקצות את כל הליבות, מספיק להשתמש ב--s PTHREAD_POOL_SIZE=1:

emcc -pthread -s PTHREAD_POOL_SIZE=1 example.c -o example.js

הפעם, כשמריצים את הפקודה, הכל עובד כמו שצריך:

Before the thread
Inside the thread: 42
After the thread
Pthread 0x701510 exited.

אבל יש בעיה אחרת: רואים את sleep(1) בדוגמת הקוד? הוא מופעל ב-thread callback, כלומר מחוץ ל-thread הראשי, אז זה אמור להיות בסדר, נכון? לא, זה לא.

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

יש כמה פתרונות לבעיה הזו:

  • pthread_detach
  • -s PROXY_TO_PTHREAD
  • 'עובד' ו'קשר' בהתאמה אישית

pthread_detach

קודם כל, אם רוצים רק להריץ חלק מהמשימות מהשרשור הראשי בלי לחכות לתוצאות, אפשר להשתמש ב-pthread_detach במקום ב-pthread_join. כך פונקציית ה-callback של השרשור תמשיך לפעול ברקע. אם אתם משתמשים באפשרות הזו, תוכלו להשבית את האזהרה באמצעות -s PTHREAD_POOL_SIZE_STRICT=0.

PROXY_TO_PTHREAD

שנית, אם אתם עורכים הידור של אפליקציית C ולא של ספרייה, תוכלו להשתמש באפשרות -s PROXY_TO_PTHREAD, שתעביר את קוד האפליקציה הראשי לשרשור נפרד, בנוסף לכל השרשורים המטמיעים שנוצרו על ידי האפליקציה עצמה. כך הקוד הראשי יכול לחסום בבטחה בכל שלב בלי להקפיא את ממשק המשתמש. במקרה, כשמשתמשים באפשרות הזו, אין צורך ליצור מראש את מאגר השרשורים – במקום זאת, Emscripten יכול להשתמש ב-thread הראשי כדי ליצור Workers בסיסיים חדשים, ואז לחסום את ה-thread הראשי ב-pthread_join בלי לנעול את השרשור.

שלישית, אם אתם עובדים על ספרייה ועדיין צריכים לבצע חסימה, תוכלו ליצור Worker משלכם, לייבא את הקוד שנוצר על ידי Emscripten ולהציג אותו לשרשור הראשי באמצעות Comlink. כך, ה-thread הראשי יוכל להפעיל כל שיטות הייצוא כפונקציות אסינכרוניות, וכך גם למנוע חסימה של ממשק המשתמש.

באפליקציה פשוטה כמו הדוגמה הקודמת, -s PROXY_TO_PTHREAD היא האפשרות הטובה ביותר:

emcc -pthread -s PROXY_TO_PTHREAD example.c -o example.js

C++‎

כל הסייגים והלוגיקה חלים באותו אופן על C++. הדבר החדש היחיד שמרוויחים הוא גישה לממשקי API ברמה גבוהה יותר כמו std::thread ו-std::async, שמתבססים על ספריית pthread שהסברנו קודם לכן.

לכן אפשר לכתוב מחדש את הדוגמה שלמעלה בקוד C++‎ שמתאים יותר לסגנון הכתיבה:

example.cpp:

#include <iostream>
#include <thread>
#include <chrono>

int main()
{
    puts("Before the thread");

    int arg = 42;
    std::thread thread([&]() {
        std::this_thread::sleep_for(std::chrono::seconds(1));
        std::cout << "Inside the thread: " << arg << std::endl;
    });

    thread.join();

    std::cout << "After the thread" << std::endl;

    return 0;
}

אם הם מקודדים ומפעילים אותם עם פרמטרים דומים, הם יתנהגו באותו אופן כמו בדוגמה C:

emcc -std=c++11 -pthread -s PROXY_TO_PTHREAD example.cpp -o example.js

פלט:

Before the thread
Inside the thread: 42
Pthread 0xc06190 exited.
After the thread
Proxied main thread 0xa05c18 finished with return code 0. EXIT_RUNTIME=0 set, so
keeping main thread alive for asynchronous event operations.
Pthread 0xa05c18 exited.

Rust

בניגוד ל-Emscripten, ל-Rust אין יעד אינטרנט מותאם אישית מקצה לקצה, אלא יעד wasm32-unknown-unknown כללי לפלט WebAssembly כללי.

אם אתם מתכננים להשתמש ב-Wasm בסביבת אינטרנט, כל אינטראקציה עם ממשקי API של JavaScript תתבצע באמצעות ספריות וכלים חיצוניים כמו wasm-bindgen ו-wasm-pack. לצערנו, המשמעות היא שהספרייה הרגילה לא מכירה ב-Web Workers, ו-APIs רגילים כמו std::thread לא יפעלו כשהם יקובצו ל-WebAssembly.

למרבה המזל, רוב הסביבה העסקית תלויה בספריות ברמה גבוהה יותר שמטפלות בשרשור משימות. ברמה הזו קל יותר להתמקד בעיקר ולהתעלם מהבדלים בין הפלטפורמות.

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

pub fn sum_of_squares(numbers: &[i32]) -> i32 {
  numbers
  .iter()
  .par_iter()
  .map(|x| x * x)
  .sum()
}

בעקבות השינוי הקטן הזה, הקוד יחלק את נתוני הקלט, יחשב את x * x ואת הסכומים החלקים בשרשוריים מקבילים, ובסוף יסכם את התוצאות החלקיות האלה.

כדי להתאים לפלטפורמות שבהן std::thread לא פועל, ב-Rayon יש ווקים שמאפשרים להגדיר לוגיקה מותאמת אישית ליצירה וליציאה של חוטים.

wasm-bindgen-rayon משתמש בווקריטים האלה כדי ליצור חוטי WebAssembly כ-Web Workers. כדי להשתמש בו, צריך להוסיף אותו כיחס תלות ולפעול לפי שלבי ההגדרה שמפורטים במסמכי העזרה. הדוגמה שלמעלה תיראה כך:

pub use wasm_bindgen_rayon::init_thread_pool;

#[wasm_bindgen]
pub fn sum_of_squares(numbers: &[i32]) -> i32 {
  numbers
  .par_iter()
  .map(|x| x * x)
  .sum()
}

בסיום, קוד ה-JavaScript שנוצר ייצא פונקציית initThreadPool נוספת. הפונקציה הזו תיצור מאגר של Workers ותשתמש בהם שוב ושוב במהלך כל משך החיים של התוכנית, לכל פעולה עם כמה שרשורים שמתבצעת על ידי Rayon.

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

import init, { initThreadPool, sum_of_squares } from './pkg/index.js';

// Regular wasm-bindgen initialization.
await init();

// Thread pool initialization with the given number of threads
// (pass `navigator.hardwareConcurrency` if you want to use all cores).
await initThreadPool(navigator.hardwareConcurrency);

// ...now you can invoke any exported functions as you normally would
console.log(sum_of_squares(new Int32Array([1, 2, 3]))); // 14

חשוב לזכור שגם כאן חלים אותם אזהרות לגבי חסימה של ה-thread הראשי. גם בדוגמה sum_of_squares עדיין צריך לחסום את ה-thread הראשי כדי להמתין לתוצאות החלקיות מה-threads האחרים.

זמן ההמתנה עשוי להיות קצר או ארוך, בהתאם למורכבות של האינטרטוררים ומספר השרשוריים הזמינים, אבל כדי להימנע מבעיות, מנועי הדפדפנים מונעים באופן פעיל חסימה של השרשור הראשי לגמרי, וקובץ קוד כזה יגרום להצגת שגיאה. במקום זאת, צריך ליצור Worker, לייבא אליו את הקוד שנוצר על ידי wasm-bindgen ולהציג את ה-API שלו לשרשור הראשי באמצעות ספרייה כמו Comlink.

כדאי לעיין בדוגמה של wasm-bindgen-rayon כדי לראות הדגמה מקצה לקצה שמראה:

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

אנחנו משתמשים באופן פעיל בשרתי WebAssembly ב-Squoosh.app כדי לדחוס תמונות בצד הלקוח, במיוחד בפורמטים כמו AVIF‏ (C++),‏ JPEG-XL‏ (C++),‏ OxiPNG‏ (Rust) ו-WebP v2‏ (C++). בזכות השימוש בכמה שרתים בו-זמנית, ראינו שיפור עקבי של פי 1.5 עד פי 3 (היחס המדויק משתנה בהתאם לקודק), והצלחנו לשפר את הנתונים האלה עוד יותר על ידי שילוב של שרתים של WebAssembly עם WebAssembly SIMD.

Google Earth הוא שירות נוסף שבו נעשה שימוש בשרשראות WebAssembly בגרסה לאינטרנט.

FFMPEG.WASM היא גרסת WebAssembly של כלי מולטימדיה פופולרי של FFmpeg שמשתמש בשרשורי WebAssembly כדי לקודד סרטונים ביעילות ישירות בדפדפן.

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