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

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

Tính năng hỗ trợ luồng WebAssembly là một trong những điểm bổ sung quan trọng nhất về hiệu suất cho WebAssembly. Tính năng này cho phép bạn chạy song song các phần mã trên các nhân 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, mở rộng mã đó cho nhiều nhân như số nhân mà người dùng có và giảm đáng kể thời gian thực thi tổng thể.

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

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 một số 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 từ JavaScript. 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 phần mềm kết nối 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ư WebAssembly.Memory dùng chung (xem bên dưới) với các luồng khác đó. Thao tác này 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 trải qua JavaScript một lần nữa.

Web Worker đã xuất hiện được hơn một thập kỷ, được hỗ trợ rộng rãi và không yêu cầu bất kỳ cờ đặc biệt nào.

SharedArrayBuffer

Bộ nhớ WebAssembly được biểu thị bằng đố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 – một vùng đệm byte thô mà chỉ một luồng duy nhất có thể truy cập.

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

Để hỗ trợ tính năng đa luồng, WebAssembly.Memory cũng có một biến thể dùng chung. Khi được tạo bằng cờ shared thông qua API JavaScript hoặc chính tệp nhị phân WebAssembly, tệp này sẽ trở thành trình bao bọc xung quanh SharedArrayBuffer. Đây là một 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 từ cả hai bên.

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

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

SharedArrayBuffer có một quá trình phát triển phức tạp. Ban đầu, tính năng này được cung cấp trong một số trình duyệt vào giữa năm 2017, nhưng phải bị vô hiệu hoá vào đầu năm 2018 do phát hiện các 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 theo thời gian – đo lường thời gian thực thi của một đoạn mã cụ thể. Để khiến loại 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 tính 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 biệt cũng là một cách rất đáng tin cậy để có được thời gian chính xác cao và khó giảm thiểu hơn nhiều nếu không làm giảm đá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 Phân tách trang web – một tính năng giúp đư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, biện pháp giảm thiểu này vẫn chỉ giới hạn ở Chrome dành cho máy tính, vì tính năng 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, cũng như các nhà cung cấp khác chưa triển khai tính năng này.

Chuyển đến năm 2020, cả Chrome và Firefox đều đã triển khai tính năng Tách biệt trang web và một cách thức tiêu chuẩn để các trang web chọn sử dụng tính năng này bằng tiêu đề COOP và COEP. Cơ chế chọn sử dụng cho phép sử dụng tính năng Tách biệt trang web ngay cả trên các thiết bị có công suất thấp, nơi việc bật tính năng này cho tất cả 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ủ:

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 hỗ trợ bởi SharedArrayBuffer), bộ hẹn giờ chính xác, tính năng đo lường bộ nhớ và các API khác yêu cầu một nguồn gốc riêng biệt vì lý do bảo mật. Hãy xem bài viết Tạo "tính năng tách biệt nhiều nguồn gốc" cho trang web bằng COOP và COEP để biết thêm thông tin chi tiết.

Nguyên tử WebAssembly

Mặc dù SharedArrayBuffer cho phép mỗi luồng đọc và ghi vào cùng một bộ nhớ, nhưng để giao tiếp chính xác, bạn cần đảm bảo rằng các luồ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ừ một địa chỉ dùng chung, trong khi một luồng khác đang ghi vào địa chỉ đó, vì vậy, luồng đầu tiên hiện sẽ nhận được kết quả bị hỏng. Danh mục lỗi này được gọi là tình huống tương tranh. Để ngăn chặn tình trạng tương tranh, bạn cần đồng bộ hoá các quyền truy cập đó theo cách nào đó. Đây là lúc các thao tác nguyên tử phát huy tác dụng.

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

Tất cả các nguyên hàm đồng bộ hoá cấp cao hơn, bao gồm cả kênh, mutex và khoá đọc-ghi, đều dựa trên các hướng dẫn đó.

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

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

WebAssembly atomics và SharedArrayBuffer là các tính năng tương đối mới và chưa có trong tất cả trình duyệt 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 rằng tất cả người dùng đều có thể tải ứng dụng, bạn cần triển khai tính năng cải tiến dần bằng cách tạo hai phiên bản Wasm khác nhau – một phiên bản hỗ trợ nhiều luồng và một phiên bản không hỗ trợ. 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 tính năng hỗ trợ luồng WebAssembly trong thời gian chạy, hãy sử dụng thư viện phát hiện tính năng wasm 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 xem cách tạo phiên bản nhiều 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 luồng là thông qua Luồng POSIX do thư viện pthread cung cấp. Emscripten cung cấp phương thức triển khai tương thích với API của thư viện pthread được xây dựng dựa trên Web Worker, bộ nhớ dùng chung và nguyên tử, nhờ đó, 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 ví dụ dưới đây:

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

Tại đây, các tiêu đề cho 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ý luồng.

pthread_create sẽ tạo một luồng nền. Phương thức này cần một đích đến để lưu trữ một handle 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ố không bắt buộc để 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ẻ một con trỏ đến biến arg.

Bạn có thể gọi pthread_join sau này bất cứ lúc nào để chờ luồng thực thi xong và nhận kết quả được trả về từ lệnh gọi lại. Phương thức này chấp nhận handle luồng được chỉ định trước đó cũng như 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 luồng với Emscripten, bạn cần gọi emcc và truyền 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 trong trình duyệt hoặc Node.js, bạn sẽ thấy một 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 các 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ộ, 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ẽ đồng bộ gọi pthread_create để tạo luồng trong nền, sau đó tiếp tục 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 Worker được sử dụng ở chế độ nền khi mã này được biên dịch bằng Emscripten là không đồng bộ. Vì vậy, điều xảy ra là pthread_create chỉ lên lịch tạo một 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 sẽ ngay lập tức chặn vòng lặp sự kiện để chờ Worker đó và việc này sẽ ngăn không cho Worker đó đượ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, thậm chí trước khi chương trình bắt đầu. Khi được gọi, pthread_create 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 nền và trả Worker về nhóm. Bạn có thể thực hiện tất cả các thao tác này một cách đồng bộ, vì vậy sẽ không có tắc nghẽn nào xảy ra miễn là nhóm đủ lớn.

Đây chính xác là những gì Emscripten cho phép với tuỳ chọn -s PTHREAD_POOL_SIZE=.... Phương thức này cho phép chỉ định số lượng luồng – một số cố định hoặc biểu thức JavaScript như navigator.hardwareConcurrency để tạo số lượng luồng bằng với số lõi trên CPU. Tuỳ chọn sau 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ả cá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, 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 còn một vấn đề khác: bạn có thấy sleep(1) trong ví dụ về mã không? Hàm này thực thi trong lệnh gọi lại luồng, nghĩa là ngoài luồng chính, nên sẽ không có vấn đề gì phải không? Không phải.

Khi được gọi, pthread_join phải chờ quá trình thực thi luồng hoàn tất, nghĩa là nếu luồng được 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 có kết quả. Khi JS này được thực thi trong trình duyệt, JS 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 trả về. Điều này dẫn đến trải nghiệm người dùng kém.

Có một vài 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ả, bạn có thể sử dụng pthread_detach thay vì pthread_join. Thao tác này sẽ để 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 một ứng dụng C thay vì một thư viện, bạn có thể sử dụng tuỳ chọn -s PROXY_TO_PTHREAD. Tuỳ chọn này sẽ giảm tải mã ứng dụng chính sang một luồng riêng biệt 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 một cách an toàn bất cứ lúc nào mà không làm treo giao diện người dùng. Tình cờ, khi sử dụng tuỳ chọn này, bạn cũng không cần tạo trước nhóm luồng. Thay vào đó, Emscripten có thể tận dụng luồng chính để tạo Worker cơ bản mới, sau đó chặn luồng trợ giúp trong pthread_join mà không bị 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 cho luồng chính. Luồng chính sẽ có thể gọi mọi phương thức đã xuất dưới dạng hàm không đồng bộ và cách đó cũng sẽ tránh 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à lựa 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 có được là quyền truy cập vào các API cấp cao hơn như std::threadstd::async. Các API này sử dụng thư viện pthread đã thảo luận trước đó.

Vì vậy, bạn có thể viết lại ví dụ trên bằng C++ theo cách phù hợp 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ự, mã này sẽ hoạt động giống như ví dụ về 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 toàn diện chuyên biệt, mà thay vào đó cung cấp mục tiêu wasm32-unknown-unknown chung cho đầu ra WebAssembly chung.

Nếu Wasm được dùng trong môi trường web, mọi hoạt động tương tác với API JavaScript sẽ do các thư viện và công cụ bên ngoài thực hiện, chẳng hạn 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 thay, 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ý nhiều luồng. Ở cấp độ đó, bạn có thể dễ dàng trừu tượng hoá tất cả sự khác biệt về 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. Phương thức này cho phép bạn 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, chuyển đổi các chuỗi đó theo cách chúng 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ẽ phân tách dữ liệu đầu vào, tính toán x * x và các tổng phụ trong các luồng song song, và cuối cùng cộng các kết quả phụ đó lại với nhau.

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

wasm-bindgen-rayon khai thác các trình nối đó để tạo các luồng WebAssembly dưới dạng Trình chạy web. Để sử dụng, bạn cần thêm phần phụ thuộc này và làm theo các bước định cấu hình được mô tả trong tài liệu. 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 được 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ế nhóm này tương tự như tuỳ chọn -s PTHREAD_POOL_SIZE=... trong Emscripten đã giải thích trước đó và cũng cần được khởi chạy 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

Xin lưu ý rằng các thận trọng 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 chặn luồng chính để chờ kết quả một phần từ các luồng khác.

Thời gian chờ có thể rất ngắn hoặc rất 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 để đảm bảo an toàn, các công cụ trình duyệt chủ động ngăn chặn việc chặn hoàn toàn luồng chính và mã đó sẽ gửi 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 Worker đó bằng một thư viện như Comlink cho luồng chính.

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

Các trường hợp sử dụng thực tế

Chúng tôi chủ động 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à cho các định dạng như AVIF (C++), JPEG-XL (C++), OxiPNG (Rust) và WebP v2 (C++). Nhờ tính năng đa luồng, chúng tôi đã thấy tốc độ tăng lên một cách nhất quán từ 1,5 đến 3 lần (tỷ lệ chính xác khác nhau tuỳ theo bộ mã hoá và giải mã) và có thể đẩy những con số đó lên cao hơn nữa bằng cách kết hợp các luồng WebAssembly với WebAssembly SIMD!

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

FFMPEG.WASM là 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 trực tiếp trong trình duyệt một cách hiệu quả.

Có 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!