การใช้ชุดข้อความ WebAssembly จาก C, C++ และ Rust

ดูวิธีนําแอปพลิเคชันแบบหลายเธรดที่เขียนด้วยภาษาอื่นมาใช้กับ WebAssembly

การรองรับชุดข้อความ WebAssembly เป็นหนึ่งในการเพิ่มประสิทธิภาพที่สำคัญที่สุดสำหรับ WebAssembly ซึ่งช่วยให้คุณเรียกใช้โค้ดบางส่วนแบบขนานในคอร์แยกต่างหาก หรือเรียกใช้โค้ดเดียวกันกับข้อมูลอินพุตบางส่วนที่ไม่เกี่ยวข้องกัน โดยปรับขนาดให้เข้ากับจำนวนคอร์ทั้งหมดที่ผู้ใช้มี และลดเวลาในการดำเนินการโดยรวมได้อย่างมาก

บทความนี้จะอธิบายวิธีใช้เธรด WebAssembly เพื่อนําแอปพลิเคชันแบบหลายเธรดที่เขียนด้วยภาษาต่างๆ เช่น C, C++ และ Rust ไปใช้ในเว็บ

เทรดของ WebAssembly ไม่ใช่ฟีเจอร์แยกต่างหาก แต่เป็นชุดคอมโพเนนต์หลายรายการที่ช่วยให้แอป WebAssembly ใช้รูปแบบการแยกหลายชุดข้อความแบบดั้งเดิมบนเว็บได้

Web Worker

คอมโพเนนต์แรกคือ Workers ปกติที่คุณรู้จักและชื่นชอบจาก JavaScript เทรด WebAssembly ใช้ตัวสร้าง new Worker เพื่อสร้างเทรดใหม่ที่อยู่เบื้องหลัง แต่ละเธรดจะโหลดกาว JavaScript จากนั้นเธรดหลักจะใช้เมธอด Worker#postMessage เพื่อแชร์ไฟล์ที่คอมไพล์แล้ว WebAssembly.Module รวมถึง WebAssembly.Memory ที่แชร์ (ดูด้านล่าง) กับเธรดอื่นๆ ซึ่งจะสร้างการสื่อสารและอนุญาตให้เธรดทั้งหมดเหล่านั้นเรียกใช้โค้ด WebAssembly เดียวกันในหน่วยความจำที่แชร์เดียวกันโดยไม่ต้องผ่าน JavaScript อีกครั้ง

Web Worker มีมานานกว่า 10 ปีแล้ว ได้รับการรองรับอย่างกว้างขวาง และไม่จําเป็นต้องใช้ Flag พิเศษ

SharedArrayBuffer

หน่วยความจำ WebAssembly จะแสดงโดยออบเจ็กต์ WebAssembly.Memory ใน JavaScript API โดยค่าเริ่มต้น WebAssembly.Memory จะเป็นตัวแฝงรอบ ArrayBuffer ซึ่งเป็นบัฟเฟอร์ไบต์ดิบที่มีเพียงเธรดเดียวเท่านั้นที่เข้าถึงได้

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

WebAssembly.Memory ได้รับตัวแปรที่แชร์ด้วยเพื่อรองรับการแยกหลายชุดข้อความ เมื่อสร้างด้วย Flag shared ผ่าน JavaScript API หรือโดยไบนารี WebAssembly เอง ไฟล์ดังกล่าวจะกลายเป็น Wrapper ของ SharedArrayBuffer แทน ซึ่งเป็นรูปแบบหนึ่งของ ArrayBuffer ที่แชร์กับชุดข้อความอื่นๆ ได้ รวมถึงอ่านหรือแก้ไขจากทั้ง 2 ด้านพร้อมกัน

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

ซึ่งแตกต่างจาก postMessage ที่ปกติจะใช้สำหรับการรับส่งข้อมูลระหว่างเธรดหลักกับเวิร์กเกอร์เว็บ โดย SharedArrayBuffer ไม่จำเป็นต้องคัดลอกข้อมูลหรือรอให้ลูปเหตุการณ์ส่งและรับข้อความ แต่ทุกเธรดจะเห็นการเปลี่ยนแปลงเกือบจะทันที ซึ่งทำให้เป็นเป้าหมายการคอมไพล์ที่ดีกว่ามากสำหรับพรอมิเตอรีการซิงค์แบบดั้งเดิม

SharedArrayBuffer มีประวัติที่ซับซ้อน ฟีเจอร์นี้เปิดตัวครั้งแรกในหลายเบราว์เซอร์เมื่อกลางปี 2017 แต่ต้องปิดใช้ในช่วงต้นปี 2018 เนื่องจากการค้นพบช่องโหว่ Spectre สาเหตุที่เจาะจงคือ การดึงข้อมูลใน Spectre อาศัยการโจมตีตามเวลา ซึ่งก็คือการวัดเวลาในการเรียกใช้โค้ดหนึ่งๆ เบราว์เซอร์ได้ลดความแม่นยำของ API การกำหนดเวลามาตรฐาน เช่น Date.now และ performance.now เพื่อทำให้โจมตีประเภทนี้ทำได้ยากขึ้น อย่างไรก็ตาม หน่วยความจําที่แชร์ร่วมกับลูปตัวนับแบบง่ายที่ทํางานในเธรดแยกต่างหากยังเป็นวิธีที่เชื่อถือได้มากในการรับการกําหนดเวลาที่มีความแม่นยําสูง และลดผลกระทบได้ยากกว่ามากหากไม่จํากัดประสิทธิภาพรันไทม์อย่างมาก

แต่ Chrome 68 (กลางปี 2018) ได้เปิดใช้ SharedArrayBuffer อีกครั้งโดยใช้ประโยชน์จากการแยกเว็บไซต์ ซึ่งเป็นฟีเจอร์ที่แยกเว็บไซต์ต่างๆ ออกเป็นกระบวนการที่แตกต่างกันและทำให้การใช้การโจมตีช่องทางข้างเคียง เช่น สเปคเตอร์ ทำได้ยากขึ้นมาก อย่างไรก็ตาม การลดความเสี่ยงนี้ยังคงจำกัดไว้เฉพาะใน Chrome บนเดสก์ท็อปเท่านั้น เนื่องจากการแยกเว็บไซต์เป็นฟีเจอร์ที่มีราคาค่อนข้างสูง และไม่สามารถเปิดใช้โดยค่าเริ่มต้นสำหรับเว็บไซต์ทั้งหมดในอุปกรณ์เคลื่อนที่ที่มีหน่วยความจำต่ำ หรือยังไม่มีการใช้งานโดยผู้ให้บริการรายอื่น

ในปัจจุบันทั้ง 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 จะอนุญาตให้แต่ละเธรดอ่านและเขียนไปยังหน่วยความจำเดียวกัน แต่คุณก็ต้องตรวจสอบว่าเธรดเหล่านั้นไม่ดำเนินการที่ขัดแย้งกันพร้อมกัน เพื่อให้การสื่อสารถูกต้อง ตัวอย่างเช่น เป็นไปได้ที่ชุดข้อความหนึ่งจะเริ่มอ่านข้อมูลจากที่อยู่ที่ใช้ร่วมกัน ขณะที่อีกชุดข้อความหนึ่งกำลังเขียนข้อมูลอยู่ ดังนั้นชุดข้อความแรกจึงได้รับผลลัพธ์ที่เสียหาย ข้อบกพร่องหมวดหมู่นี้เรียกว่าเงื่อนไขการแข่งขัน คุณต้องซิงค์การเข้าถึงเหล่านั้นเพื่อป้องกันการเกิด Race Condition การดำเนินการแบบอะตอมมิกจึงเข้ามามีบทบาทในจุดนี้

WebAssembly แบบอะตอมคือการขยายชุดคำสั่ง WebAssembly ที่อนุญาตให้อ่านและเขียนเซลล์ข้อมูลขนาดเล็ก (โดยปกติคือจำนวนเต็ม 32 และ 64 บิต) "แบบอะตอม" กล่าวคือ การดำเนินการนี้รับประกันว่าไม่มี 2 เทรดอ่านหรือเขียนไปยังเซลล์เดียวกันในเวลาเดียวกัน ซึ่งจะช่วยป้องกันความขัดแย้งดังกล่าวในระดับต่ำ นอกจากนี้ อะตอมิกของ WebAssembly ยังมีคำสั่งอีก 2 ประเภท ได้แก่ "wait" และ "notify" ซึ่งอนุญาตให้เธรดหนึ่งหยุดทำงาน ("wait") ในที่อยู่หนึ่งๆ ในหน่วยความจำที่ใช้ร่วมกันจนกว่าเธรดอื่นจะปลุกให้ทำงานอีกครั้งผ่าน "notify"

คำสั่งพื้นฐานทั้งหมดในระดับที่สูงขึ้น ซึ่งรวมถึงแชแนล Mutex และล็อกอ่าน/เขียน จะอิงตามคำสั่งเหล่านั้น

วิธีใช้เธรด WebAssembly

การตรวจหาองค์ประกอบ

อะตอมิกของ WebAssembly และ SharedArrayBuffer เป็นฟีเจอร์ที่ค่อนข้างใหม่และยังไม่พร้อมใช้งานในเบราว์เซอร์ทั้งหมดที่รองรับ WebAssembly คุณสามารถดูเบราว์เซอร์ที่รองรับฟีเจอร์ WebAssembly ใหม่ได้ในแผนงานของ webassembly.org

คุณจะต้องใช้งานการเพิ่มประสิทธิภาพแบบเป็นขั้นเป็นตอนโดยสร้าง Wasm 2 เวอร์ชันที่แตกต่างกัน เวอร์ชันหนึ่งรองรับการแยกหลายเธรด และอีกเวอร์ชันหนึ่งไม่รองรับ จากนั้นโหลดเวอร์ชันที่รองรับโดยขึ้นอยู่กับผลการค้นหาฟีเจอร์ หากต้องการตรวจหาการรองรับเธรด WebAssembly ที่รันไทม์ ให้ใช้ไลบรารี wasm-feature-detect และโหลดโมดูลดังนี้

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 Threads ที่ได้จากไลบรารี pthread Emscriptenมีการใช้งานที่เข้ากันได้กับ API ของไลบรารี pthread ที่สร้างขึ้นบน Web Worker, หน่วยความจำที่ใช้ร่วมกัน และอะตอมิก เพื่อให้โค้ดเดียวกันทำงานบนเว็บได้โดยไม่ต้องเปลี่ยนแปลง

มาดูตัวอย่างกัน

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 นอกจากนี้ คุณยังดูฟังก์ชันสําคัญ 2-3 รายการสําหรับจัดการชุดข้อความได้ด้วย

pthread_create จะสร้างเธรดเบื้องหลัง โดยจะใช้ปลายทางเพื่อจัดเก็บแฮนเดิลเธรด, แอตทริบิวต์การสร้างเธรดบางส่วน (ในที่นี้ไม่ได้ส่งแอตทริบิวต์ใดๆ เลย จึงมีเพียง NULL), ฟังก์ชันการเรียกกลับที่จะดำเนินการในเธรดใหม่ (ในที่นี้คือ thread_callback) และตัวชี้อาร์กิวเมนต์ที่ไม่บังคับซึ่งจะส่งไปยังฟังก์ชันการเรียกกลับในกรณีที่คุณต้องการแชร์ข้อมูลบางอย่างจากเธรดหลัก ในตัวอย่างนี้เราจะแชร์ตัวชี้ไปยังตัวแปร arg

pthread_join สามารถเรียกใช้ภายหลังได้ทุกเมื่อเพื่อรอให้เธรดดำเนินการเสร็จสิ้นและรับผลลัพธ์ที่แสดงผลจาก callback โดยรับแฮนเดิลเธรดที่กําหนดไว้ก่อนหน้านี้ รวมถึงพอยน์เตอร์เพื่อจัดเก็บผลลัพธ์ ในกรณีนี้ไม่มีผลลัพธ์ใดๆ เลย ฟังก์ชันจึงใช้ 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 ส่วนใหญ่ที่ใช้เวลานานบนเว็บเป็นแบบไม่พร้อมกันและอาศัยลูปเหตุการณ์เพื่อดำเนินการ ข้อจํากัดนี้มีความแตกต่างจากสภาพแวดล้อมแบบดั้งเดิมอย่างสําคัญ ซึ่งโดยปกติแล้วแอปพลิเคชันจะเรียกใช้ I/O ในลักษณะแบบซิงค์ที่บล็อก อ่านข้อมูลเพิ่มเติมได้ในบล็อกโพสต์เกี่ยวกับการใช้ Web API แบบแอซิงโครนัสจาก WebAssembly

ในกรณีนี้ โค้ดจะเรียกใช้ pthread_create แบบซิงค์เพื่อสร้างเธรดเบื้องหลัง และเรียกใช้ pthread_join แบบซิงค์อีกรายการที่รอให้เธรดเบื้องหลังดำเนินการเสร็จ อย่างไรก็ตาม Web Worker ที่ใช้อยู่เบื้องหลังเมื่อคอมไพล์โค้ดนี้ด้วย Emscripten จะเป็นแบบไม่พร้อมกัน สิ่งที่เกิดขึ้นคือ pthread_create กำหนดเวลาให้สร้างเธรดผู้ปฏิบัติงานใหม่เมื่อมีการเรียกใช้ลูปเหตุการณ์ครั้งถัดไปเท่านั้น แต่จากนั้น pthread_join จะบล็อกลูปเหตุการณ์ทันทีเพื่อรอผู้ปฏิบัติงานดังกล่าว ซึ่งทำให้ไม่เกิดการสร้างผู้ปฏิบัติงานขึ้น นี่เป็นตัวอย่างคลาสสิกของDeadlock

วิธีแก้ปัญหานี้อย่างหนึ่งคือการสร้างพูลของ Worker ล่วงหน้าก่อนที่โปรแกรมจะเริ่มทำงาน เมื่อเรียกใช้ pthread_create จะใช้ Worker ที่พร้อมใช้งานจากพูล เรียกใช้การเรียกกลับที่ระบุในเธรดแบ็กกราวด์ และส่ง Worker กลับไปยังพูล การดำเนินการทั้งหมดนี้ทำได้แบบซิงค์กัน จึงจะไม่เกิด Deadlock ตราบใดที่พูลมีขนาดใหญ่เพียงพอ

ซึ่ง 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 ซึ่งหมายความว่าไม่ได้อยู่ในเทรดหลัก จึงน่าจะไม่มีปัญหาใช่ไหม แต่ไม่ใช่

เมื่อเรียก pthread_join จะต้องรอให้การดำเนินการของชุดข้อความเสร็จสิ้น ซึ่งหมายความว่าหากชุดข้อความที่สร้างกำลังทำงานที่ใช้เวลานาน (ในกรณีนี้คือการหยุดทำงาน 1 วินาที) ชุดข้อความหลักจะต้องบล็อกเป็นเวลาเท่ากันจนกว่าจะได้รับผลลัพธ์ เมื่อ JS นี้ทำงานในเบราว์เซอร์ ระบบจะบล็อกเธรด UI เป็นเวลา 1 วินาทีจนกว่าการเรียกกลับของเธรดจะแสดงผล ซึ่งส่งผลให้ผู้ใช้ได้รับประสบการณ์การใช้งานที่ไม่ดี

วิธีแก้ปัญหามีดังนี้

  • pthread_detach
  • -s PROXY_TO_PTHREAD
  • ผู้ปฏิบัติงานและ Comlink ที่กําหนดเอง

pthread_detach

ขั้นแรก หากคุณต้องการเรียกใช้บางงานจากเธรดหลักเท่านั้น แต่ไม่ต้องรอผลลัพธ์ ให้ใช้ pthread_detach แทน pthread_join ซึ่งจะทำให้การเรียกกลับของชุดข้อความทำงานอยู่เบื้องหลัง หากใช้ตัวเลือกนี้ คุณสามารถปิดคำเตือนได้ด้วย -s PTHREAD_POOL_SIZE_STRICT=0

PROXY_TO_PTHREAD

ประการที่ 2 หากคุณคอมไพล์แอปพลิเคชัน C แทนไลบรารี คุณสามารถใช้ตัวเลือก -s PROXY_TO_PTHREAD ซึ่งจะส่งออกโค้ดแอปพลิเคชันหลักไปยังเธรดแยกต่างหากนอกเหนือจากเธรดที่ฝังอยู่ซึ่งแอปพลิเคชันสร้างขึ้นเอง วิธีนี้ช่วยให้โค้ดหลักบล็อกได้อย่างปลอดภัยทุกเมื่อโดยไม่ทำให้ UI ค้าง นอกจากนี้ เมื่อใช้ตัวเลือกนี้ คุณไม่จําเป็นต้องสร้างพูลเธรดล่วงหน้าด้วย Emscripten สามารถใช้เธรดหลักในการสร้าง Worker ใหม่ที่อยู่เบื้องหลังได้ แล้วบล็อกเธรดตัวช่วยใน pthread_join โดยไม่เกิดปัญหาการล็อกตาย

ประการที่ 3 หากคุณกำลังทำงานกับไลบรารีและยังคงต้องบล็อกอยู่ คุณสามารถสร้าง Worker ของคุณเอง นำเข้าโค้ดที่ Emscripten สร้างขึ้น และแสดงด้วย Comlink ไปยังเธรดหลัก เทรดหลักจะเรียกใช้เมธอดที่ส่งออกเป็นฟังก์ชันแบบไม่พร้อมกันได้ ซึ่งจะช่วยหลีกเลี่ยงการบล็อก UI ด้วย

ในแอปพลิเคชันง่ายๆ เช่น ตัวอย่างก่อนหน้านี้ -s PROXY_TO_PTHREAD เป็นตัวเลือกที่ดีที่สุด

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

C++

ข้อควรระวังและตรรกะทั้งหมดที่ใช้กับ 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

Rust ไม่มีเป้าหมายเว็บจากต้นทางถึงปลายทางที่เฉพาะเจาะจง ต่างจาก Emscripten แต่มีเป้าหมาย wasm32-unknown-unknown ทั่วไปสำหรับเอาต์พุต WebAssembly ทั่วไป

หากมีไว้เพื่อใช้ในสภาพแวดล้อมเว็บ การโต้ตอบกับ JavaScript API จะขึ้นอยู่กับไลบรารีและเครื่องมือภายนอก เช่น wasm-bindgen และ wasm-pack แต่ข้อเสียคือ ไลบรารีมาตรฐานจะไม่รู้จัก Web Worker และ API มาตรฐาน เช่น std::thread จะไม่ทำงานเมื่อคอมไพล์เป็น WebAssembly

แต่โชคดีที่ระบบนิเวศส่วนใหญ่ใช้ไลบรารีระดับสูงขึ้นเพื่อจัดการการทํางานหลายชุด ในระดับนั้น คุณจะมองข้ามความแตกต่างทั้งหมดของแพลตฟอร์มได้ง่ายขึ้นมาก

โดยเฉพาะอย่างยิ่ง Rayon เป็นตัวเลือกที่ได้รับความนิยมมากที่สุดสําหรับการทํางานแบบขนานของข้อมูลใน Rust ซึ่งช่วยให้คุณใช้เชนเมธอดในตัวดำเนินการตามปกติได้ และมักจะเปลี่ยนเพียงบรรทัดเดียวเพื่อแปลงเชนเมธอดให้ทำงานพร้อมกันในเธรดทั้งหมดที่มีอยู่แทนการทำงานตามลำดับ เช่น

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

การเปลี่ยนแปลงเล็กๆ น้อยๆ นี้จะทำให้โค้ดแยกข้อมูลอินพุต คำนวณ x * x และผลรวมบางส่วนในเธรดแบบขนาน และในตอนท้ายจะรวมผลลัพธ์บางส่วนเหล่านั้นเข้าด้วยกัน

Rayon มีฮุกที่ช่วยกำหนดตรรกะที่กำหนดเองสำหรับการสร้างและออกจากเธรดเพื่อให้รองรับแพลตฟอร์มที่ไม่มี std::thread

wasm-bindgen-rayon ใช้ประโยชน์จากฮุกเหล่านั้นเพื่อสร้างเธรด WebAssembly เป็น Web Worker หากต้องการใช้ คุณต้องเพิ่มเป็นข้อกําหนดเบื้องต้นและทำตามขั้นตอนการกําหนดค่าที่อธิบายไว้ในเอกสารประกอบ ตัวอย่างข้างต้นจะมีลักษณะดังนี้

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 เพิ่มเติม ฟังก์ชันนี้จะสร้างพูลผู้ปฏิบัติงานและนำกลับมาใช้ใหม่ตลอดอายุการใช้งานของโปรแกรมสําหรับการดำเนินการแบบหลายเธรดทั้งหมดที่ 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

โปรดทราบว่าข้อควรระวังเดียวกันเกี่ยวกับการบล็อกชุดข้อความหลักจะมีผลที่นี่ด้วย แม้แต่ตัวอย่าง sum_of_squares ก็ยังต้องบล็อกเธรดหลักเพื่อรอผลลัพธ์บางส่วนจากเธรดอื่นๆ

ระยะเวลารออาจสั้นหรือนานมาก ขึ้นอยู่กับความซับซ้อนของ Iterator และจำนวนเธรดที่พร้อมใช้งาน แต่เพื่อความปลอดภัย เครื่องมือของเบราว์เซอร์จะป้องกันไม่ให้บล็อกเธรดหลักโดยสิ้นเชิง และโค้ดดังกล่าวจะแสดงข้อผิดพลาด แต่คุณควรสร้าง Worker แล้วนําเข้าโค้ดที่ wasm-bindgen สร้างขึ้นในนั้น และแสดง API ของ Worker นั้นด้วยไลบรารีอย่าง 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 เป็นเครื่องมือบรรทัดคำสั่งมัลติมีเดีย FFmpeg เวอร์ชัน WebAssembly ที่ได้รับความนิยม ซึ่งใช้เธรด WebAssembly เพื่อเข้ารหัสวิดีโอในเบราว์เซอร์โดยตรงอย่างมีประสิทธิภาพ

นอกจากนี้ยังมีตัวอย่างที่น่าตื่นเต้นอีกมากมายเกี่ยวกับการใช้เธรด WebAssembly อย่าลืมดูตัวอย่างและนําแอปพลิเคชันและไลบรารีแบบหลายเธรดของคุณเองไปใช้กับเว็บ