Sử dụng API web không đồng bộ từ WebAssembly

Các API I/O trên web không đồng bộ, nhưng có tính đồng bộ trong hầu hết các ngôn ngữ hệ thống. Khi biên dịch mã thành WebAssembly, bạn cần cầu nối một loại API với một loại API khác và cầu nối này sẽ Không đồng bộ hoá. Trong bài đăng này, bạn sẽ tìm hiểu về thời điểm và cách thức sử dụng tính năng Đồng bộ hoá cũng như cách thức hoạt động của tính năng này.

I/O bằng ngôn ngữ hệ thống

Tôi sẽ bắt đầu với một ví dụ đơn giản trong C. Giả sử bạn muốn đọc tên người dùng trên một tệp và chào họ bằng thông báo "Hello, (username)!" (Xin chào, (tên người dùng)!):

#include <stdio.h>

int main() {
    FILE *stream = fopen("name.txt", "r");
    char name[20+1];
    size_t len = fread(&name, 1, 20, stream);
    name[len] = '\0';
    fclose(stream);
    printf("Hello, %s!\n", name);
    return 0;
}

Mặc dù không có tác dụng gì nhiều, nhưng ví dụ này đã minh hoạ những gì bạn sẽ tìm thấy trong một ứng dụng thuộc mọi quy mô: ứng dụng đọc một số dữ liệu đầu vào từ thế giới bên ngoài, xử lý chúng trong nội bộ và ghi đầu ra trở lại thế giới bên ngoài. Tất cả hoạt động tương tác như vậy với thế giới bên ngoài xảy ra thông qua một số hàm thường được gọi là các hàm đầu vào-đầu ra, cũng được rút ngắn thành I/O.

Để đọc tên từ C, bạn cần có ít nhất hai lệnh gọi I/O quan trọng: fopen để mở tệp và fread để đọc dữ liệu từ tệp đó. Sau khi truy xuất dữ liệu, bạn có thể sử dụng một hàm I/O khác printf để in kết quả ra bảng điều khiển.

Các hàm đó trông khá đơn giản khi mới xem qua và bạn không phải suy nghĩ kỹ về máy móc liên quan đến việc đọc hoặc ghi dữ liệu. Tuy nhiên, tuỳ thuộc vào môi trường, có thể có khá nhiều việc xảy ra bên trong:

  • Nếu tệp đầu vào nằm trên ổ cục bộ, ứng dụng cần thực hiện một loạt quyền truy cập vào bộ nhớ và ổ đĩa để tìm tệp, kiểm tra quyền, mở tệp để đọc, sau đó đọc theo từng khối cho đến khi truy xuất được số byte yêu cầu. Quá trình này có thể khá chậm, tuỳ thuộc vào tốc độ của ổ đĩa và dung lượng yêu cầu.
  • Hoặc tệp đầu vào có thể nằm trên một vị trí mạng được gắn kết, trong trường hợp đó, ngăn xếp mạng cũng sẽ tham gia, làm tăng độ phức tạp, độ trễ và số lần thử lại có thể có cho mỗi thao tác.
  • Cuối cùng, ngay cả printf cũng không đảm bảo in mọi thứ ra bảng điều khiển và có thể được chuyển hướng đến một tệp hoặc vị trí mạng, trong trường hợp đó, trình xử lý sẽ phải thực hiện các bước tương tự như trên.

Tóm lại, I/O có thể chậm và bạn không thể dự đoán thời gian của một lệnh gọi cụ thể nếu xem nhanh mã. Trong khi thao tác đó đang chạy, toàn bộ ứng dụng của bạn sẽ bị treo và không phản hồi người dùng.

Điều này cũng không giới hạn ở C hoặc C++. Hầu hết các ngôn ngữ hệ thống đều hiển thị tất cả I/O dưới dạng API đồng bộ. Ví dụ: nếu bạn dịch ví dụ sang Rust, thì API có thể trông đơn giản hơn, nhưng các nguyên tắc tương tự sẽ được áp dụng. Bạn chỉ cần thực hiện một lệnh gọi và đồng bộ chờ lệnh gọi đó trả về kết quả, trong khi thực hiện mọi thao tác tiêu tốn nhiều tài nguyên và cuối cùng trả về kết quả trong một lệnh gọi duy nhất:

fn main() {
    let s = std::fs::read_to_string("name.txt");
    println!("Hello, {}!", s);
}

Nhưng điều gì sẽ xảy ra khi bạn cố gắng biên dịch bất kỳ mẫu nào trong số đó lên WebAssembly và dịch chúng lên web? Hoặc để cho ví dụ cụ thể thì thao tác "đọc tệp" có thể dịch sang gì? Ứng dụng sẽ cần đọc dữ liệu từ một số bộ nhớ.

Mô hình web không đồng bộ

Web có nhiều tuỳ chọn bộ nhớ mà bạn có thể liên kết, chẳng hạn như bộ nhớ trong bộ nhớ (đối tượng JS), localStorage, IndexedDB, bộ nhớ phía máy chủ và API Truy cập hệ thống tệp mới.

Tuy nhiên, chỉ hai trong số các API đó (bộ nhớ trong bộ nhớ và localStorage) có thể được sử dụng đồng bộ, và cả hai đều là những lựa chọn hạn chế nhất về nội dung bạn có thể lưu trữ và trong thời gian bao lâu. Tất cả các tuỳ chọn khác chỉ cung cấp các API không đồng bộ.

Đây là một trong những thuộc tính cốt lõi của việc thực thi mã trên web: bất kỳ thao tác tốn thời gian nào, bao gồm bất kỳ I/O nào, đều phải không đồng bộ.

Lý do là trước đây, web chỉ đơn luồng và bất kỳ mã người dùng nào chạm vào giao diện người dùng đều phải chạy trên cùng một luồng với giao diện người dùng. Nó phải cạnh tranh với các nhiệm vụ quan trọng khác như bố cục, kết xuất và xử lý sự kiện trong thời gian của CPU. Bạn sẽ không muốn một đoạn JavaScript hoặc WebAssembly có thể bắt đầu thao tác "đọc tệp" và chặn mọi thứ khác – toàn bộ thẻ hoặc trước đây là toàn bộ trình duyệt – trong khoảng thời gian từ mili giây đến vài giây, cho đến khi hoạt động kết thúc.

Thay vào đó, mã chỉ được phép lên lịch thao tác I/O cùng với lệnh gọi lại sẽ được thực thi sau khi hoàn tất. Những lệnh gọi lại như vậy được thực thi trong vòng lặp sự kiện của trình duyệt. Tôi sẽ không đi vào chi tiết ở đây, nhưng nếu bạn muốn tìm hiểu cách hoạt động nâng cao của vòng lặp sự kiện, hãy xem Tác vụ, vi nhiệm vụ, hàng đợi và lịch biểu giải thích chi tiết về chủ đề này.

Phiên bản ngắn là trình duyệt chạy tất cả các đoạn mã theo một vòng lặp vô hạn, bằng cách lấy từng đoạn mã từ hàng đợi. Khi một số sự kiện được kích hoạt, trình duyệt sẽ xếp trình xử lý tương ứng vào hàng đợi và trong lần lặp tiếp theo, sự kiện này sẽ được lấy ra khỏi hàng đợi và được thực thi. Cơ chế này cho phép mô phỏng tính năng đồng thời và chạy nhiều hoạt động song song trong khi chỉ sử dụng một luồng duy nhất.

Điều quan trọng cần nhớ về cơ chế này là trong khi mã JavaScript (hoặc WebAssembly) tuỳ chỉnh của bạn thực thi, vòng lặp sự kiện sẽ bị chặn và mặc dù không có cách nào để phản ứng với bất kỳ trình xử lý, sự kiện, sự kiện bên ngoài nào, v.v. Cách duy nhất để lấy lại kết quả I/O là đăng ký lệnh gọi lại, hoàn tất việc thực thi mã và cung cấp lại quyền điều khiển cho trình duyệt để tiếp tục xử lý mọi tác vụ đang chờ xử lý. Sau khi I/O hoàn tất, trình xử lý của bạn sẽ trở thành một trong những tác vụ đó và sẽ được thực thi.

Ví dụ: nếu muốn viết lại các mẫu ở trên trong JavaScript hiện đại và quyết định đọc tên từ một URL từ xa, bạn sẽ sử dụng API Tìm nạp và cú pháp async-await:

async function main() {
  let response = await fetch("name.txt");
  let name = await response.text();
  console.log("Hello, %s!", name);
}

Mặc dù có vẻ đồng bộ, nhưng về cơ bản, mỗi await đều là đường dẫn cú pháp cho các lệnh gọi lại:

function main() {
  return fetch("name.txt")
    .then(response => response.text())
    .then(name => console.log("Hello, %s!", name));
}

Trong ví dụ đã giải quyết này, rõ ràng hơn một chút, một yêu cầu được bắt đầu và phản hồi được đăng ký trong lệnh gọi lại đầu tiên. Sau khi nhận được phản hồi ban đầu (chỉ tiêu đề HTTP), trình duyệt sẽ gọi lệnh gọi lại này một cách không đồng bộ. Lệnh gọi lại bắt đầu đọc phần nội dung dưới dạng văn bản bằng response.text() và đăng ký kết quả bằng một lệnh gọi lại khác. Cuối cùng, sau khi truy xuất tất cả nội dung, fetch sẽ gọi lệnh gọi lại gần đây nhất để in dòng chữ "Hello, (username)!" đến bảng điều khiển.

Nhờ tính chất không đồng bộ của các bước đó, hàm ban đầu có thể trả về quyền kiểm soát cho trình duyệt ngay sau khi I/O được lên lịch, đồng thời để toàn bộ giao diện người dùng thích ứng và có thể thực hiện các tác vụ khác (bao gồm cả kết xuất, cuộn, v.v.) trong khi I/O đang thực thi trong nền.

Ví dụ cuối cùng là những API đơn giản như "ngủ" ( khiến ứng dụng phải chờ một số giây nhất định) cũng là một dạng thao tác I/O:

#include <stdio.h>
#include <unistd.h>
// ...
printf("A\n");
sleep(1);
printf("B\n");

Chắc chắn rồi, bạn có thể dịch theo cách rất đơn giản để chặn luồng hiện tại cho đến khi hết thời gian:

console.log("A");
for (let start = Date.now(); Date.now() - start < 1000;);
console.log("B");

Trên thực tế, đó chính xác là những gì Emscripten thực hiện trong cách triển khai mặc định của tính năng "ngủ", nhưng rất không hiệu quả, sẽ chặn toàn bộ giao diện người dùng và sẽ không cho phép xử lý bất kỳ sự kiện nào khác trong thời gian chờ đợi. Nhìn chung, không làm việc đó trong mã phát hành.

Thay vào đó, một phiên bản đặc trưng hơn của "ngủ" trong JavaScript sẽ liên quan đến việc gọi setTimeout() và đăng ký bằng một trình xử lý:

console.log("A");
setTimeout(() => {
    console.log("B");
}, 1000);

Điểm chung của tất cả các ví dụ và API này là gì? Trong mỗi trường hợp, mã thành ngữ bằng ngôn ngữ hệ thống ban đầu sẽ sử dụng một API chặn cho I/O, trong khi một ví dụ tương đương cho web sử dụng API không đồng bộ. Khi biên dịch lên web, bằng cách nào đó bạn cần chuyển đổi giữa 2 mô hình thực thi đó và WebAssembly chưa có tính năng tích hợp sẵn để thực hiện việc này.

Thu hẹp khoảng cách bằng Asyncify

Đây là lúc Asyncify phát huy tác dụng. Asyncify là một tính năng thời gian biên dịch do Emscripten hỗ trợ, cho phép tạm dừng toàn bộ chương trình và tiếp tục không đồng bộ vào lúc khác.

Biểu đồ lệnh gọi mô tả JavaScript -> WebAssembly -> web API -> lệnh gọi tác vụ không đồng bộ, trong đó Asyncify kết nối kết quả của tác vụ không đồng bộ trở lại WebAssembly

Cách sử dụng trong C / C++ bằng Emscripten

Nếu muốn sử dụng Asyncify để triển khai chế độ ngủ không đồng bộ trong ví dụ trước, bạn có thể làm như sau:

#include <stdio.h>
#include <emscripten.h>

EM_JS(void, async_sleep, (int seconds), {
    Asyncify.handleSleep(wakeUp => {
        setTimeout(wakeUp, seconds * 1000);
    });
});
…
puts("A");
async_sleep(1);
puts("B");

EM_JS là một macro cho phép xác định các đoạn mã JavaScript như thể chúng là các hàm C. Bên trong, hãy sử dụng hàm Asyncify.handleSleep() để yêu cầu Emscripten tạm ngưng chương trình và cung cấp một trình xử lý wakeUp() cần được gọi sau khi hoạt động không đồng bộ kết thúc. Trong ví dụ trên, trình xử lý được truyền đến setTimeout(), nhưng cũng có thể được sử dụng trong bất kỳ ngữ cảnh nào khác chấp nhận lệnh gọi lại. Cuối cùng, bạn có thể gọi async_sleep() ở bất cứ đâu bạn muốn giống như sleep() thông thường hoặc mọi API đồng bộ khác.

Khi biên dịch mã như vậy, bạn cần yêu cầu Emscripten kích hoạt tính năng Asyncify. Hãy làm điều đó bằng cách truyền -s ASYNCIFY cũng như -s ASYNCIFY_IMPORTS=[func1, func2] bằng danh sách các hàm giống như mảng có thể không đồng bộ.

emcc -O2 \
    -s ASYNCIFY \
    -s ASYNCIFY_IMPORTS=[async_sleep] \
    ...

Điều này cho Emscripten biết rằng mọi lệnh gọi đến các hàm đó có thể yêu cầu lưu và khôi phục trạng thái, vì vậy, trình biên dịch sẽ chèn mã hỗ trợ xung quanh các lệnh gọi đó.

Giờ đây, khi thực thi mã này trong trình duyệt, bạn sẽ thấy một nhật ký đầu ra liền mạch như mong đợi, với B sẽ xuất hiện sau một khoảng thời gian trễ ngắn sau A.

A
B

Bạn cũng có thể trả về giá trị từ hàm Asyncify. Việc bạn cần làm là trả về kết quả của handleSleep() và chuyển kết quả đó đến lệnh gọi lại wakeUp(). Ví dụ: nếu muốn tìm nạp một số từ một tài nguyên từ xa thay vì đọc từ tệp, bạn có thể sử dụng một đoạn mã như đoạn mã dưới đây để đưa ra yêu cầu, tạm ngưng mã C và tiếp tục sau khi nội dung phản hồi được truy xuất – tất cả được thực hiện liền mạch như thể lệnh gọi được đồng bộ.

EM_JS(int, get_answer, (), {
     return Asyncify.handleSleep(wakeUp => {
        fetch("answer.txt")
            .then(response => response.text())
            .then(text => wakeUp(Number(text)));
    });
});
puts("Getting answer...");
int answer = get_answer();
printf("Answer is %d\n", answer);

Trên thực tế, đối với các API dựa trên Promise như fetch(), bạn thậm chí có thể kết hợp tính năng Asyncify với tính năng chờ không đồng bộ của JavaScript thay vì sử dụng API dựa trên lệnh gọi lại. Do đó, thay vì Asyncify.handleSleep(), hãy gọi Asyncify.handleAsync(). Sau đó, thay vì phải lên lịch gọi lại wakeUp(), bạn có thể chuyển hàm JavaScript async và sử dụng awaitreturn bên trong, làm cho mã trông tự nhiên và đồng bộ hơn mà vẫn không mất đi bất kỳ lợi ích nào của I/O không đồng bộ.

EM_JS(int, get_answer, (), {
     return Asyncify.handleAsync(async () => {
        let response = await fetch("answer.txt");
        let text = await response.text();
        return Number(text);
    });
});

int answer = get_answer();

Đang chờ các giá trị phức tạp

Nhưng ví dụ này vẫn chỉ giới hạn ở các con số. Nếu bạn muốn triển khai ví dụ ban đầu, trong đó tôi cố gắng lấy tên của người dùng trong một tệp dưới dạng chuỗi thì sao? Chà, bạn cũng có thể làm thế!

Emscripten cung cấp một tính năng có tên là Embind, cho phép bạn xử lý lượt chuyển đổi giữa các giá trị JavaScript và C++. Lớp này cũng hỗ trợ tính năng Asyncify, vì vậy, bạn có thể gọi await() trên các Promise bên ngoài và mã này sẽ hoạt động giống như await trong mã JavaScript chờ không đồng bộ:

val fetch = val::global("fetch");
val response = fetch(std::string("answer.txt")).await();
val text = response.call<val>("text").await();
auto answer = text.as<std::string>();

Khi sử dụng phương thức này, bạn thậm chí không cần phải truyền ASYNCIFY_IMPORTS dưới dạng cờ biên dịch, vì phương thức này đã được đưa vào theo mặc định.

Tất cả đều hoạt động tốt trong Emscripten. Còn các chuỗi công cụ và ngôn ngữ khác thì sao?

Mức sử dụng các ngôn ngữ khác

Giả sử bạn có một lệnh gọi đồng bộ tương tự ở đâu đó trong mã Rust và bạn muốn ánh xạ đến một API không đồng bộ trên web. Hoá ra bạn cũng có thể làm được!

Trước tiên, bạn cần xác định một hàm như vậy dưới dạng dữ liệu nhập thông thường thông qua khối extern (hoặc cú pháp cho hàm nước ngoài trong ngôn ngữ bạn đã chọn).

extern {
    fn get_answer() -> i32;
}

println!("Getting answer...");
let answer = get_answer();
println!("Answer is {}", answer);

Và biên dịch mã của bạn thành WebAssembly:

cargo build --target wasm32-unknown-unknown

Bây giờ, bạn cần thiết lập tệp WebAssembly bằng mã để lưu trữ/khôi phục ngăn xếp. Đối với C/C++, Emscripten sẽ thực hiện việc này cho chúng ta, nhưng ở đây không được sử dụng, nên quy trình sẽ thủ công hơn một chút.

Thật may là bản thân phép biến đổi Asyncify hoàn toàn không phụ thuộc vào chuỗi công cụ. Công cụ này có thể biến đổi các tệp WebAssembly tuỳ ý, bất kể nó do trình biên dịch nào tạo ra. Phép biến đổi được cung cấp riêng biệt như một phần của trình tối ưu hoá wasm-opt từ chuỗi công cụ Binaryen và có thể được gọi như sau:

wasm-opt -O2 --asyncify \
      --pass-arg=asyncify-imports@env.get_answer \
      [...]

Truyền --asyncify để bật tính năng biến đổi, sau đó sử dụng --pass-arg=… để cung cấp danh sách các hàm không đồng bộ được phân tách bằng dấu phẩy, trong đó trạng thái chương trình sẽ bị tạm ngưng rồi tiếp tục.

Tất cả việc còn lại là cung cấp mã thời gian chạy hỗ trợ thực sự làm điều đó – tạm ngưng và tiếp tục mã WebAssembly. Xin nhắc lại, trong trường hợp C / C++, thuộc tính này sẽ được Emscripten đưa vào, nhưng bây giờ bạn cần mã keo JavaScript tuỳ chỉnh để xử lý các tệp WebAssembly tuỳ ý. Chúng tôi đã tạo một thư viện chỉ dành cho mục đích đó.

Bạn có thể tìm thấy mã này trên GitHub tại https://github.com/GoogleChromeLabs/asyncify hoặc npm dưới tên asyncify-wasm.

Thao tác này mô phỏng một API tạo thực thể WebAssembly tiêu chuẩn, nhưng trong không gian tên riêng của nó. Điểm khác biệt duy nhất là trong API WebAssembly thông thường, bạn chỉ có thể cung cấp các hàm đồng bộ làm mục nhập, trong khi trong trình bao bọc Asyncify, bạn cũng có thể cung cấp các lệnh nhập không đồng bộ:

const { instance } = await Asyncify.instantiateStreaming(fetch('app.wasm'), {
    env: {
        async get_answer() {
            let response = await fetch("answer.txt");
            let text = await response.text();
            return Number(text);
        }
    }
});
…
await instance.exports.main();

Sau khi bạn cố gắng gọi một hàm không đồng bộ như get_answer() trong ví dụ trên – từ phía WebAssembly, thư viện sẽ phát hiện Promise được trả về, tạm ngưng và lưu trạng thái của ứng dụng WebAssembly, đăng ký hoàn thành lời hứa và sau khi giải quyết xong, hãy khôi phục liền mạch ngăn xếp và trạng thái lệnh gọi để tiếp tục thực thi như thể chưa có gì xảy ra.

Vì bất kỳ hàm nào trong mô-đun đều có thể thực hiện lệnh gọi không đồng bộ, nên tất cả các lượt xuất dữ liệu cũng có khả năng không đồng bộ, vì vậy, chúng cũng được bao bọc. Bạn có thể nhận thấy trong ví dụ trên, bạn cần await kết quả của instance.exports.main() để biết thời điểm thực sự hoàn tất.

Tính năng này hoạt động như thế nào?

Khi phát hiện thấy lệnh gọi đến một trong các hàm ASYNCIFY_IMPORTS, Asyncify sẽ bắt đầu một hoạt động không đồng bộ, lưu toàn bộ trạng thái của ứng dụng, bao gồm ngăn xếp lệnh gọi và mọi nội dung cục bộ tạm thời. Sau đó, khi thao tác đó kết thúc, sẽ khôi phục toàn bộ bộ nhớ và ngăn xếp lệnh gọi, đồng thời tiếp tục từ cùng một vị trí và với trạng thái tương tự như khi chương trình chưa từng dừng.

Tính năng này khá giống với tính năng async-await trong JavaScript mà tôi đã trình bày trước đó, nhưng khác với JavaScript, tính năng này không yêu cầu cú pháp đặc biệt hoặc hỗ trợ thời gian chạy nào từ ngôn ngữ này, mà hoạt động bằng cách biến đổi các hàm đồng bộ thuần tuý tại thời điểm biên dịch.

Khi biên dịch ví dụ về giấc ngủ không đồng bộ được hiển thị trước đó:

puts("A");
async_sleep(1);
puts("B");

Asyncify sẽ lấy mã này và chuyển đổi mã này thành mã gần giống như sau (mã giả, phép biến đổi thực quan trọng hơn thế này):

if (mode == NORMAL_EXECUTION) {
    puts("A");
    async_sleep(1);
    saveLocals();
    mode = UNWINDING;
    return;
}
if (mode == REWINDING) {
    restoreLocals();
    mode = NORMAL_EXECUTION;
}
puts("B");

Ban đầu, mode được đặt thành NORMAL_EXECUTION. Tương ứng, trong lần đầu tiên thực thi mã đã chuyển đổi như vậy, chỉ phần dẫn đến async_sleep() mới được đánh giá. Ngay sau khi hoạt động không đồng bộ được lên lịch, Asyncify sẽ lưu tất cả các cục bộ và gỡ bỏ ngăn xếp bằng cách quay lại từ từng hàm đến trên cùng, bằng cách này mang lại quyền kiểm soát cho vòng lặp sự kiện của trình duyệt.

Sau khi async_sleep() phân giải, mã hỗ trợ Asyncify sẽ thay đổi mode thành REWINDING và gọi lại hàm này. Lần này, nhánh "thực thi thông thường" sẽ bị bỏ qua – vì nhánh này đã thực hiện công việc lần trước và tôi muốn tránh in "A" hai lần – và thay vào đó, nhánh sẽ chuyển thẳng đến nhánh "tua lại". Sau khi đạt đến giới hạn, mã sẽ khôi phục tất cả các cục bộ đã lưu trữ, thay đổi chế độ về trạng thái "bình thường" và tiếp tục thực thi như thể mã chưa bao giờ bị dừng từ đầu.

Chi phí chuyển đổi

Thật không may, phép biến đổi Asyncify không hoàn toàn miễn phí vì nó phải chèn khá nhiều mã hỗ trợ để lưu trữ và khôi phục tất cả các cục bộ đó, điều hướng ngăn xếp lệnh gọi ở các chế độ khác nhau, v.v. Cấu trúc này chỉ cố gắng sửa đổi các hàm được đánh dấu là không đồng bộ trên dòng lệnh, cũng như bất kỳ phương thức gọi tiềm năng nào, nhưng mức hao tổn kích thước mã vẫn có thể tăng lên đến khoảng 50% trước khi nén.

Một biểu đồ cho thấy mức hao tổn kích thước mã cho nhiều điểm chuẩn, từ gần 0% trong các điều kiện được tinh chỉnh đến hơn 100% trong các trường hợp xấu nhất

Điều này không lý tưởng, nhưng trong nhiều trường hợp vẫn có thể chấp nhận được khi phương án thay thế không hoàn toàn có chức năng hoặc phải viết lại đáng kể mã gốc.

Hãy đảm bảo luôn bật tính năng tối ưu hoá cho các bản dựng cuối cùng để tránh tình trạng tính toán cao hơn nữa. Bạn cũng có thể đánh dấu các tuỳ chọn tối ưu hoá dành riêng cho không đồng bộ để giảm mức hao tổn bằng cách chỉ giới hạn các phép biến đổi đối với các hàm được chỉ định và/hoặc chỉ các lệnh gọi hàm trực tiếp. Ngoài ra, chi phí cũng nhỏ đối với hiệu suất trong thời gian chạy, nhưng chỉ giới hạn ở chính các lệnh gọi không đồng bộ. Tuy nhiên, so với chi phí thực tế, chi phí này thường không đáng kể.

Bản minh hoạ thực tế

Sau khi bạn đã xem các ví dụ đơn giản, tôi sẽ chuyển sang các tình huống phức tạp hơn.

Như đã đề cập trong phần đầu của bài viết, một trong những lựa chọn lưu trữ trên web là API Truy cập hệ thống tệp không đồng bộ. Thư viện này cung cấp quyền truy cập vào một hệ thống tệp lưu trữ thực tế qua một ứng dụng web.

Mặt khác, có một tiêu chuẩn thực tế có tên là WASI dành cho I/O WebAssembly trong bảng điều khiển và phía máy chủ. Thư viện này được thiết kế làm mục tiêu biên dịch cho các ngôn ngữ hệ thống, hiển thị mọi loại hệ thống tệp cũng như các thao tác khác ở dạng đồng bộ truyền thống.

Điều gì sẽ xảy ra nếu bạn có thể liên kết một địa chỉ với nhau? Sau đó, bạn có thể biên dịch mọi ứng dụng bằng ngôn ngữ nguồn bất kỳ bằng bất kỳ chuỗi công cụ nào hỗ trợ mục tiêu WASI và chạy ứng dụng đó trong một hộp cát trên web, đồng thời vẫn cho phép ứng dụng đó hoạt động trên các tệp của người dùng thực! Với Asyncify, bạn có thể làm được điều đó.

Trong bản minh hoạ này, tôi đã biên dịch thùng Rust coreutils với một số bản vá nhỏ cho WASI, được truyền qua tính năng biến đổi Asyncify và triển khai các liên kết không đồng bộ từ WASI với API Truy cập hệ thống tệp ở phía JavaScript. Sau khi kết hợp với thành phần thiết bị đầu cuối Xterm.js, thành phần này cung cấp một shell thực tế chạy trong thẻ trình duyệt và hoạt động trên các tệp của người dùng thực – giống như một thiết bị đầu cuối thực tế.

Hãy xem trực tiếp tại https://wasi.rreverser.com/.

Các trường hợp sử dụng để đồng bộ hoá không chỉ bị giới hạn ở bộ tính giờ và hệ thống tệp. Bạn có thể tiến xa hơn và sử dụng các API phù hợp hơn trên web.

Ví dụ: cũng với sự trợ giúp của Asyncify, bạn có thể ánh xạ libusb (có thể là thư viện gốc phổ biến nhất để tương tác với thiết bị USB) với một WebUSB API để cấp quyền truy cập không đồng bộ vào các thiết bị như vậy trên web. Sau khi ánh xạ và biên dịch, tôi đã có các thử nghiệm và ví dụ libusb tiêu chuẩn để chạy trên các thiết bị đã chọn ngay trong hộp cát của trang web.

Ảnh chụp màn hình kết quả gỡ lỗi libusb trên một trang web, hiển thị thông tin về máy ảnh Canon đã kết nối

Tuy nhiên, nó có thể là một câu chuyện cho một bài đăng khác trên blog.

Những ví dụ đó minh hoạ sức mạnh của Asyncify trong việc thu hẹp khoảng cách và chuyển đổi mọi loại ứng dụng lên web, cho phép bạn truy cập trên nhiều nền tảng, hộp cát và tăng cường bảo mật mà không bị mất chức năng.