Sử dụng luồng WebAssembly từ C, C++ và Rust

Tìm hiểu cách đưa ứng dụng đa luồng được viết bằng các ngôn ngữ khác lên WebAssembly.

Tính năng hỗ trợ luồng WebAssembly là một trong những bổ sung hiệu suất quan trọng nhất cho WebAssembly. Phương thức này cho phép bạn chạy song song các phần mã trên các lõi riêng biệt hoặc cùng một mã trên các phần độc lập của dữ liệu đầu vào, nhờ đó mở rộng mã tới số lượng lõi tuỳ ý người dùng và giảm đáng kể tổng thời gian thực thi.

Trong bài viết này, bạn sẽ tìm hiểu cách sử dụng các luồng WebAssembly để đưa các ứng dụng đa luồng viết bằng các ngôn ngữ như C, C++ và Rust lên web.

Cách hoạt động của luồng WebAssembly

Các luồng WebAssembly không phải là một tính năng riêng biệt, mà là sự kết hợp của nhiều thành phần cho phép các ứng dụng WebAssembly sử dụng các mô hình đa luồng truyền thống trên web.

Trình chạy web

Thành phần đầu tiên là các Worker thông thường mà bạn biết và yêu thích trong JavaScript. Các luồng WebAssembly sử dụng hàm khởi tạo new Worker để tạo các luồng cơ bản mới. Mỗi luồng tải một keo JavaScript, sau đó luồng chính sử dụng phương thức Worker#postMessage để chia sẻ WebAssembly.Module đã biên dịch cũng như một WebAssembly.Memory dùng chung (xem bên dưới) với các luồng khác đó. Việc này giúp thiết lập hoạt động giao tiếp và cho phép tất cả các luồng đó chạy cùng một mã WebAssembly trên cùng một bộ nhớ dùng chung mà không cần phải xem lại JavaScript.

Web Worker đã hoạt động hơn một thập kỷ nay, được hỗ trợ rộng rãi và không cần có cờ đặc biệt nào.

SharedArrayBuffer

Bộ nhớ WebAssembly được biểu thị bằng một đối tượng WebAssembly.Memory trong API JavaScript. Theo mặc định, WebAssembly.Memory là một trình bao bọc xung quanh ArrayBuffer – vùng đệm byte thô chỉ có thể được truy cập bằng một luồng duy nhất.

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

Để hỗ trợ hoạt động đa luồng, WebAssembly.Memory cũng đã có được một biến thể dùng chung. Khi được tạo bằng cờ shared thông qua API JavaScript hoặc bằng chính tệp nhị phân WebAssembly, cờ này sẽ trở thành một trình bao bọc xung quanh SharedArrayBuffer. Đây là biến thể của ArrayBuffer có thể được chia sẻ với các luồng khác và đọc hoặc sửa đổi đồng thời ở cả hai bên.

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

Không giống như postMessage, thường dùng để giao tiếp giữa luồng chính và Trình chạy web, SharedArrayBuffer không yêu cầu sao chép dữ liệu hay thậm chí là chờ vòng lặp sự kiện gửi và nhận tin nhắn. Thay vào đó, mọi thay đổi đều được tất cả các luồng xem gần như ngay lập tức, giúp đối tượng này trở thành mục tiêu biên dịch tốt hơn nhiều cho các dữ liệu gốc đồng bộ hoá truyền thống.

SharedArrayBuffer có lịch sử phức tạp. Ban đầu, thư viện này được phát hành trên một số trình duyệt vào giữa năm 2017, nhưng đã bị vô hiệu hoá vào đầu năm 2018 do phát hiện lỗ hổng Spectre. Lý do cụ thể là việc trích xuất dữ liệu trong Spectre dựa vào các cuộc tấn công xác định thời gian — đo lường thời gian thực thi của một đoạn mã cụ thể. Để làm cho kiểu tấn công này khó khăn hơn, trình duyệt đã giảm độ chính xác của các API thời gian chuẩn như Date.nowperformance.now. Tuy nhiên, bộ nhớ dùng chung kết hợp với một vòng lặp bộ đếm đơn giản chạy trong một luồng riêng cũng là một cách rất đáng tin cậy để lấy thời gian có độ chính xác cao và khó giảm thiểu nếu không điều tiết đáng kể hiệu suất thời gian chạy.

Thay vào đó, Chrome 68 (giữa năm 2018) đã bật lại SharedArrayBuffer bằng cách tận dụng tính năng Cách ly trang web. Đây là tính năng đưa các trang web khác nhau vào các quy trình khác nhau và khiến việc sử dụng các cuộc tấn công kênh bên (như Spectre) trở nên khó khăn hơn nhiều. Tuy nhiên, giải pháp giảm thiểu này vẫn chỉ giới hạn ở Chrome trên máy tính, vì Tách biệt trang web là một tính năng khá tốn kém và không thể bật theo mặc định cho tất cả các trang web trên thiết bị di động có bộ nhớ thấp và cũng chưa được các nhà cung cấp khác triển khai.

Đến năm 2020, Chrome và Firefox đều triển khai tính năng Tách biệt trang web và là một cách tiêu chuẩn để các trang web chọn sử dụng tính năng này khi có tiêu đề COOP và COEP. Cơ chế chọn tham gia cho phép sử dụng tính năng Cách ly trang web ngay cả trên các thiết bị công suất thấp, vì việc bật tính năng này cho mọi trang web sẽ quá tốn kém. Để chọn sử dụng, hãy thêm các tiêu đề sau vào tài liệu chính trong cấu hình máy chủ của bạn:

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

Sau khi chọn sử dụng, bạn sẽ có quyền truy cập vào SharedArrayBuffer (bao gồm cả WebAssembly.Memory được SharedArrayBuffer hỗ trợ), bộ tính giờ chính xác, hoạt động đo lường bộ nhớ và các API khác yêu cầu nguồn gốc riêng biệt vì lý do bảo mật. Hãy xem bài viết Làm cho trang web của bạn "bị tách biệt nhiều nguồn gốc" bằng COOP và COEP để biết thêm chi tiết.

Nguyên tử WebAssembly

Mặc dù SharedArrayBuffer cho phép từng luồng đọc và ghi vào cùng một bộ nhớ, nhưng để giao tiếp chính xác, bạn nên đảm bảo chúng không thực hiện các thao tác xung đột cùng một lúc. Ví dụ: một luồng có thể bắt đầu đọc dữ liệu từ địa chỉ được chia sẻ, trong khi một luồng khác đang ghi vào đó, vậy nên luồng đầu tiên sẽ nhận được kết quả lỗi. Danh mục lỗi này được gọi là điều kiện tranh đấu. Để ngăn điều kiện tranh đấu, bạn cần bằng cách nào đó đồng bộ hoá các quyền truy cập đó. Đây là nơi phát sinh các phép toán nguyên tử.

WebAssembly atomics là phần mở rộng của tập lệnh WebAssembly cho phép đọc và ghi các ô dữ liệu nhỏ (thường là số nguyên 32 và 64 bit) "nguyên tử". Điều này giúp đảm bảo rằng không có hai luồng nào đọc hoặc ghi vào cùng một ô cùng lúc, nhờ đó ngăn chặn những xung đột như vậy ở cấp thấp. Ngoài ra, các nguyên tử WebAssembly chứa hai loại lệnh khác —"chờ" và "thông báo" — cho phép một luồng ngủ ("chờ") trên một địa chỉ nhất định trong một bộ nhớ dùng chung cho đến khi một luồng khác đánh thức luồng đó qua tính năng "thông báo".

Tất cả dữ liệu gốc đồng bộ hoá cấp cao hơn, bao gồm kênh, mutex và khoá đọc-ghi được xây dựng dựa trên các lệnh đó.

Cách sử dụng luồng WebAssembly

Phát hiện tính năng

WebAssembly atom và SharedArrayBuffer là các tính năng tương đối mới và chưa có sẵn trong mọi trình duyệt có hỗ trợ WebAssembly. Bạn có thể tìm thấy những trình duyệt hỗ trợ các tính năng WebAssembly mới trên lộ trình webassembly.org.

Để đảm bảo mọi người dùng đều có thể tải ứng dụng, bạn cần triển khai tính năng nâng cao tăng dần bằng cách tạo 2 phiên bản khác nhau của Wasm — một phiên bản có hỗ trợ đa luồng và một phiên bản không có hỗ trợ đa luồng. Sau đó, tải phiên bản được hỗ trợ tuỳ thuộc vào kết quả phát hiện tính năng. Để phát hiện hỗ trợ luồng WebAssembly trong thời gian chạy, hãy sử dụng thư viện wasm-feature-detect và tải mô-đun như sau:

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

Bây giờ, hãy tìm hiểu cách tạo phiên bản đa luồng của mô-đun WebAssembly.

C

Trong C, đặc biệt là trên các hệ thống giống Unix, cách phổ biến để sử dụng các luồng là thông qua Luồng POSIX do thư viện pthread cung cấp. Mô tả cung cấp cách triển khai tương thích với API của thư viện pthread được tạo trên Web Worker, bộ nhớ dùng chung và nguyên tử để cùng một mã có thể hoạt động trên web mà không cần thay đổi.

Hãy cùng tham khảo một ví dụ:

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;
}

Ở đây, tiêu đề của thư viện pthread được đưa vào thông qua pthread.h. Bạn cũng có thể thấy một số hàm quan trọng để xử lý các luồng.

pthread_create sẽ tạo một luồng trong nền. Đích đến để lưu trữ một xử lý luồng, một số thuộc tính tạo luồng (ở đây không truyền bất kỳ thuộc tính nào, vì vậy chỉ là NULL), lệnh gọi lại sẽ được thực thi trong luồng mới (ở đây là thread_callback) và một con trỏ đối số tuỳ chọn để truyền đến lệnh gọi lại đó trong trường hợp bạn muốn chia sẻ một số dữ liệu từ luồng chính. Trong ví dụ này, chúng ta đang chia sẻ con trỏ đến một biến arg.

Bạn có thể gọi pthread_join vào bất cứ lúc nào để chờ luồng hoàn tất quá trình thực thi và nhận kết quả được trả về từ lệnh gọi lại. Hàm này chấp nhận ô điều khiển luồng đã chỉ định trước đó cũng như một con trỏ để lưu trữ kết quả. Trong trường hợp này, không có kết quả nào nên hàm sẽ lấy NULL làm đối số.

Để biên dịch mã bằng các luồng bằng Emscripten, bạn cần gọi emcc và truyền một tham số -pthread, như khi biên dịch cùng một mã bằng Clang hoặc GCC trên các nền tảng khác:

emcc -pthread example.c -o example.js

Tuy nhiên, khi cố gắng chạy mã này trong trình duyệt hoặc Node.js, bạn sẽ thấy cảnh báo và sau đó chương trình sẽ bị treo:

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…]

Điều gì đã xảy ra? Vấn đề là hầu hết các API tốn thời gian trên web đều không đồng bộ và dựa vào vòng lặp sự kiện để thực thi. Giới hạn này là một điểm khác biệt quan trọng so với môi trường truyền thống, trong đó các ứng dụng thường chạy I/O theo cách đồng bộ và chặn. Hãy xem bài đăng trên blog về Sử dụng API web không đồng bộ từ WebAssembly nếu bạn muốn tìm hiểu thêm.

Trong trường hợp này, mã sẽ gọi pthread_create một cách đồng bộ để tạo một luồng trong nền, rồi tiếp theo bằng một lệnh gọi đồng bộ khác đến pthread_join để chờ luồng trong nền hoàn tất quá trình thực thi. Tuy nhiên, Web Workers được sử dụng trong nền khi mã này được biên dịch bằng Emscripten lại không đồng bộ. Vì vậy, điều sẽ xảy ra là pthread_create chỉ lên lịch tạo luồng Worker mới trong lần chạy vòng lặp sự kiện tiếp theo, nhưng sau đó pthread_join ngay lập tức chặn vòng lặp sự kiện để chờ Worker đó và bằng cách làm như vậy sẽ ngăn không cho luồng đó được tạo. Đây là một ví dụ điển hình về tình trạng tắc nghẽn.

Một cách để giải quyết vấn đề này là tạo trước một nhóm Worker, trước khi chương trình bắt đầu. Khi pthread_create được gọi, hàm này có thể lấy một Worker sẵn sàng sử dụng từ nhóm, chạy lệnh gọi lại được cung cấp trên luồng trong nền và đưa Worker này trở lại nhóm. Tất cả quá trình này có thể được thực hiện đồng bộ nên sẽ không có bất kỳ tình trạng tắc nghẽn nào, miễn là nhóm đủ lớn.

Đây chính là điều mà Emscripten cho phép thực hiện với tuỳ chọn -s PTHREAD_POOL_SIZE=.... Hàm này cho phép chỉ định một số luồng – một số cố định hoặc một biểu thức JavaScript như navigator.hardwareConcurrency để tạo số lượng luồng tuỳ theo số lõi trên CPU. Tuỳ chọn thứ hai sẽ hữu ích khi mã của bạn có thể mở rộng theo số lượng luồng tuỳ ý.

Trong ví dụ trên, chỉ có một luồng được tạo. Vì vậy, thay vì đặt trước tất cả lõi, bạn chỉ cần sử dụng -s PTHREAD_POOL_SIZE=1:

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

Lần này, khi bạn thực thi lệnh, mọi thứ sẽ hoạt động thành công:

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

Tuy nhiên, vẫn xảy ra một vấn đề khác: bạn có muốn xem sleep(1) trong ví dụ về mã không? Hàm này sẽ thực thi trong lệnh gọi lại luồng, nghĩa là nằm ngoài luồng chính, nên sẽ không có vấn đề gì phải không? Không phải vậy.

Khi pthread_join được gọi, phải đợi quá trình thực thi luồng hoàn tất, nghĩa là nếu luồng đã tạo đang thực hiện các tác vụ chạy trong thời gian dài (trong trường hợp này là ngủ 1 giây), thì luồng chính cũng sẽ phải chặn trong cùng một khoảng thời gian cho đến khi kết quả trả về. Khi JS này được thực thi trong trình duyệt, thao tác này sẽ chặn luồng giao diện người dùng trong 1 giây cho đến khi lệnh gọi lại luồng được trả về. Điều này sẽ mang đến trải nghiệm không tốt cho người dùng.

Sau đây là một số giải pháp cho vấn đề này:

  • pthread_detach
  • -s PROXY_TO_PTHREAD
  • Worker tuỳ chỉnh và Comlink

pthread_detach

Trước tiên, nếu chỉ cần chạy một số tác vụ ngoài luồng chính nhưng không cần đợi kết quả, thì bạn có thể sử dụng pthread_detach thay vì pthread_join. Thao tác này sẽ khiến lệnh gọi lại luồng chạy ở chế độ nền. Nếu đang sử dụng tuỳ chọn này, bạn có thể tắt cảnh báo bằng -s PTHREAD_POOL_SIZE_STRICT=0.

PROXY_TO_PTHREAD

Thứ hai, nếu đang biên dịch ứng dụng C thay vì thư viện, bạn có thể sử dụng tuỳ chọn -s PROXY_TO_PTHREAD. Lựa chọn này sẽ giảm tải mã xử lý ứng dụng chính sang một luồng riêng ngoài mọi luồng lồng nhau do chính ứng dụng tạo ra. Bằng cách này, mã chính có thể chặn an toàn bất cứ lúc nào mà không cần đóng băng giao diện người dùng. Ngẫu nhiên, khi sử dụng tuỳ chọn này, bạn cũng không phải tạo trước nhóm luồng. Thay vào đó, Emscripten có thể tận dụng luồng chính để tạo các Trình thực thi cơ bản mới, sau đó chặn luồng trợ giúp trong pthread_join mà không gây tắc nghẽn.

Thứ ba, nếu đang làm việc trên một thư viện và vẫn cần chặn, bạn có thể tạo Worker của riêng mình, nhập mã do Emscripten tạo và hiển thị mã đó bằng Comlink vào luồng chính. Luồng chính sẽ có thể gọi bất kỳ phương thức đã xuất nào dưới dạng các hàm không đồng bộ, đồng thời giúp tránh việc chặn giao diện người dùng.

Trong một ứng dụng đơn giản như ví dụ trước, -s PROXY_TO_PTHREAD là tuỳ chọn tốt nhất:

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

C++

Tất cả các cảnh báo và logic tương tự đều áp dụng theo cách tương tự cho C++. Điều mới duy nhất bạn nhận được là quyền truy cập vào các API cấp cao hơn như std::threadstd::async, sử dụng thư viện pthread đã thảo luận trước đó.

Vì vậy, ví dụ trên có thể được viết lại trong C++ thành ngữ hơn như sau:

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;
}

Khi được biên dịch và thực thi với các tham số tương tự, tham số này sẽ hoạt động giống như ví dụ C:

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

Kết quả:

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

Không giống như Emscripten, Rust không có mục tiêu web chuyên biệt toàn diện, mà thay vào đó cung cấp một mục tiêu wasm32-unknown-unknown chung cho đầu ra WebAssembly chung.

Nếu Wasm nhằm mục đích sử dụng trong môi trường web, thì mọi hoạt động tương tác với API JavaScript đều được để lại cho các thư viện bên ngoài và công cụ như wasm-bindgenwasm-pack. Rất tiếc, điều này có nghĩa là thư viện chuẩn không nhận biết được Web Worker và các API chuẩn như std::thread sẽ không hoạt động khi được biên dịch sang WebAssembly.

May mắn là phần lớn hệ sinh thái phụ thuộc vào các thư viện cấp cao hơn để xử lý đa luồng. Ở cấp độ đó, bạn sẽ dễ dàng hơn trong việc loại bỏ tất cả sự khác biệt của nền tảng.

Cụ thể, Rayon là lựa chọn phổ biến nhất cho tính năng song song dữ liệu trong Rust. Nhờ đó, bạn có thể lấy chuỗi phương thức trên các trình lặp thông thường và (thường chỉ cần thay đổi một dòng), hãy chuyển đổi các chuỗi phương thức này theo cách mà chúng sẽ chạy song song trên tất cả các luồng có sẵn thay vì tuần tự. Ví dụ:

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

Với thay đổi nhỏ này, mã sẽ chia tách dữ liệu đầu vào, tính toán x * x và tổng một phần trong các luồng song song và cuối cùng cộng các kết quả từng phần đó lại với nhau.

Để phù hợp với các nền tảng không cần std::thread hoạt động, Rayon cung cấp các móc cho phép xác định logic tuỳ chỉnh cho việc tạo và thoát các luồng.

wasm-bindgen-rayon nhấn vào các hook đó để tạo các luồng WebAssembly dưới dạng Trình chạy web. Để sử dụng thư viện này, bạn cần thêm phần phụ thuộc này dưới dạng phần phụ thuộc và làm theo các bước cấu hình được mô tả trong docs. Ví dụ trên sẽ có dạng như sau:

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()
}

Sau khi hoàn tất, JavaScript đã tạo sẽ xuất thêm một hàm initThreadPool. Hàm này sẽ tạo một nhóm Worker và sử dụng lại các Worker đó trong suốt thời gian hoạt động của chương trình cho mọi thao tác đa luồng do Rayon thực hiện.

Cơ chế gộp này tương tự như tuỳ chọn -s PTHREAD_POOL_SIZE=... trong Emscripten đã giải thích trước đó, đồng thời cũng cần được khởi động trước mã chính để tránh tắc nghẽn:

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

Lưu ý rằng các lưu ý tương tự về việc chặn luồng chính cũng áp dụng ở đây. Ngay cả ví dụ sum_of_squares vẫn cần phải chặn luồng chính để chờ kết quả từng phần từ các luồng khác.

Đó có thể là thời gian chờ rất ngắn hoặc dài, tuỳ thuộc vào độ phức tạp của trình lặp và số lượng luồng có sẵn, nhưng để an toàn, công cụ trình duyệt sẽ chủ động ngăn việc chặn hoàn toàn luồng chính và mã như vậy sẽ báo lỗi. Thay vào đó, bạn nên tạo một Worker, nhập mã do wasm-bindgen tạo tại đó và hiển thị API của mã đó bằng một thư viện như Comlink đến luồng chính.

Hãy xem ví dụ về wasm-bindgen-rayon để xem bản minh hoạ toàn diện cho thấy:

Trường hợp sử dụng thực tế

Chúng tôi tích cực sử dụng các luồng WebAssembly trong Squoosh.app để nén hình ảnh phía máy khách — cụ thể là đối với các định dạng như AVIF (C++), JPEG-XL (C++), OxiPNG (Rust) và WebP v2 (C++). Chỉ riêng ở đa luồng, chúng tôi đã thấy được tốc độ tăng gấp 1, 5 – 3 lần cho WebD (cụ thể là khi kết hợp các bộ mã hoá và giải mã của Web với tốc độ nhất quán từ 1,5 – 3x)

Google Earth là một dịch vụ đáng chú ý khác đang sử dụng các luồng WebAssembly cho phiên bản web của dịch vụ này.

FFMPEG.WASM là một phiên bản WebAssembly của chuỗi công cụ đa phương tiện FFmpeg phổ biến, sử dụng các luồng WebAssembly để mã hoá video một cách hiệu quả ngay trong trình duyệt.

Còn có rất nhiều ví dụ thú vị khác về việc sử dụng luồng WebAssembly. Hãy nhớ xem các bản minh hoạ và đưa các ứng dụng và thư viện đa luồng của riêng bạn lên web!