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

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

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

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

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

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

עובדי אינטרנט

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

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

SharedArrayBuffer

זיכרון WebAssembly מיוצג על ידי אובייקט WebAssembly.Memory ב-JavaScript API. כברירת מחדל, WebAssembly.Memory הוא wrapper מסביב ל-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, שמשמש בדרך כלל לתקשורת בין ה-thread הראשי ל-Web Workers, השימוש ב-SharedArrayBuffer לא מחייב העתקת נתונים ואפילו המתנה ללולאת האירוע כדי לשלוח ולקבל הודעות. במקום זאת, כל השרשורים רואים את השינויים כמעט מיד, ולכן זהו יעד הידור הרבה יותר טוב לפרימיטיבים המסורתיים בסנכרון.

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

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

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

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

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

אטומיקה של WebAssembly

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

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

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

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

זיהוי תכונות

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

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

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

אפשר להפעיל את pthread_join מאוחר יותר בכל שלב, כדי להמתין עד שה-thread יסיים את הביצוע, ויחזיר את התוצאה מהקריאה החוזרת. הוא מקבל את נקודת האחיזה הקודמת של השרשור וגם את הסמן לשמירת התוצאה. במקרה הזה אין תוצאות, ולכן הפונקציה מקבלת 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 שממתינה שה-thread ברקע יסתיים. עם זאת, ב-Web Workers נעשה שימוש מאחורי הקלעים בקוד הזה באמצעות Emscripten. כלומר, pthread_create רק מתזמנ יצירה של שרשור Worker חדש בהפעלה הבאה של לולאת אירוע, אבל אז pthread_join חוסם מיד את לולאת האירוע כדי להמתין לעובד הזה, וכך מונע את היצירה שלה אף פעם. זו דוגמה קלאסית למבוי סתום.

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

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

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

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

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

  • pthread_detach
  • -s PROXY_TO_PTHREAD
  • Worker ו-Comlink בהתאמה אישית

pthread_detach

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

PROXY_TO_PTHREAD

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

שלישית, אם אתם עובדים על ספרייה ואתם עדיין צריכים לחסום, תוכלו ליצור Worker משלכם, לייבא את הקוד שנוצר באמצעות Emscripten ולחשוף אותו באמצעות Comlink ל-thread הראשי. ה-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 וממשקי API רגילים כמו std::thread לא יפעלו כשמבצעים הידור ל-WebAssembly.

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

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

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

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

כדי להתאים לפלטפורמות בלי לעבוד עם std::thread, Rayon מספקת קטעי הוק (hooks) שמאפשרים להגדיר לוגיקה מותאמת אישית ליצירה וליציאה של שרשורים.

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

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 הראשי כדי להמתין לתוצאות החלקיות משרשורים אחרים.

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

תוכלו לראות הדגמה מקצה לקצה שמציגה את הדוגמה של Wasm-bindgen-rayon:

מקרי שימוש במציאות

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

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

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

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