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