الگوهای عملکرد WebAssembly برای برنامه های وب

در این راهنما، با هدف توسعه دهندگان وب که می خواهند از WebAssembly بهره مند شوند، می آموزید که چگونه از Wasm برای برون سپاری وظایف فشرده CPU با کمک یک مثال در حال اجرا استفاده کنید. این راهنما همه چیز را از بهترین شیوه ها برای بارگذاری ماژول های Wasm تا بهینه سازی کامپایل و نمونه سازی آنها را پوشش می دهد. در ادامه به بررسی تغییر وظایف فشرده CPU به Web Workers می‌پردازد و تصمیمات اجرایی را بررسی می‌کند، مانند زمان ایجاد Web Worker و اینکه آیا آن را برای همیشه زنده نگه دارید یا آن را در صورت نیاز چرخانید. راهنما به طور مکرر رویکرد را توسعه می دهد و یک الگوی عملکرد را در یک زمان معرفی می کند تا زمانی که بهترین راه حل را برای مشکل پیشنهاد کند.

مفروضات

فرض کنید یک کار بسیار فشرده CPU دارید که می‌خواهید آن را به WebAssembly (Wasm) برای عملکرد نزدیک به بومی آن برون سپاری کنید. کار فشرده CPU که به عنوان مثال در این راهنما استفاده می شود فاکتوریل یک عدد را محاسبه می کند. فاکتوریل حاصل ضرب یک عدد صحیح و تمام اعداد صحیح زیر آن است. به عنوان مثال، فاکتوریل چهار (نوشته شده به عنوان 4! ) برابر است با 24 (یعنی 4 * 3 * 2 * 1 ). اعداد به سرعت بزرگ می شوند. مثلا 16! 2,004,189,184 است. یک مثال واقعی تر از یک کار فشرده CPU می تواند اسکن بارکد یا ردیابی یک تصویر شطرنجی باشد.

اجرای تکرار شونده (به جای بازگشتی) از یک تابع factorial() در نمونه کد زیر نوشته شده در C++ نشان داده شده است.

#include <stdint.h>

extern "C" {

// Calculates the factorial of a non-negative integer n.
uint64_t factorial(unsigned int n) {
    uint64_t result = 1;
    for (unsigned int i = 2; i <= n; ++i) {
        result *= i;
    }
    return result;
}

}

برای بقیه مقاله، فرض کنید یک ماژول Wasm بر اساس کامپایل کردن این تابع factorial() با Emscripten در فایلی به نام factorial.wasm با استفاده از بهترین شیوه‌های بهینه‌سازی کد وجود دارد. برای اطلاع از نحوه انجام این کار، فراخوانی توابع C کامپایل شده از جاوا اسکریپت با استفاده از ccall/cwrap را بخوانید. دستور زیر برای کامپایل factorial.wasm به عنوان Wasm مستقل استفاده شد.

emcc -O3 factorial.cpp -o factorial.wasm -s WASM_BIGINT -s EXPORTED_FUNCTIONS='["_factorial"]'  --no-entry

در HTML، یک form با یک input جفت شده با یک output و یک button ارسال وجود دارد. این عناصر بر اساس نامشان از جاوا اسکریپت ارجاع داده می شوند.

<form>
  <label>The factorial of <input type="text" value="12" /></label> is
  <output>479001600</output>.
  <button type="submit">Calculate</button>
</form>
const input = document.querySelector('input');
const output = document.querySelector('output');
const button = document.querySelector('button');

بارگذاری، کامپایل و نمونه سازی ماژول

قبل از اینکه بتوانید از ماژول Wasm استفاده کنید، باید آن را بارگذاری کنید. در وب، این از طریق fetch() API اتفاق می افتد. همانطور که می‌دانید برنامه وب شما به ماژول Wasm برای کارهای فشرده CPU وابسته است، باید فایل Wasm را در اسرع وقت بارگذاری کنید. شما این کار را با واکشی با CORS فعال در بخش <head> برنامه خود انجام می دهید.

<link rel="preload" as="fetch" href="factorial.wasm" crossorigin />

در واقع، fetch() API ناهمزمان است و باید await نتیجه باشید.

fetch('factorial.wasm');

سپس، ماژول Wasm را کامپایل و نمونه سازی کنید. توابعی با نام‌های وسوسه‌انگیز به نام‌های WebAssembly.compile() (به علاوه WebAssembly.compileStreaming() ) و WebAssembly.instantiate() برای این وظایف وجود دارد، اما در عوض، متد WebAssembly.instantiateStreaming() مستقیماً یک ماژول استریم شده را کامپایل و نمونه‌سازی می‌کند. منبع اصلی مانند fetch() — نیازی await نیست. این کارآمدترین و بهینه ترین راه برای بارگیری کد Wasm است. با فرض اینکه ماژول Wasm تابع factorial() را صادر می کند، می توانید بلافاصله از آن استفاده کنید.

const importObject = {};
const resultObject = await WebAssembly.instantiateStreaming(
  fetch('factorial.wasm'),
  importObject,
);
const factorial = resultObject.instance.exports.factorial;

button.addEventListener('click', (e) => {
  e.preventDefault();
  output.textContent = factorial(parseInt(input.value, 10));
});

وظیفه را به Web Worker منتقل کنید

اگر این را در رشته اصلی، با وظایف واقعاً فشرده CPU اجرا کنید، در معرض خطر مسدود کردن کل برنامه هستید. یک روش معمول این است که چنین وظایفی را به یک Web Worker منتقل کنید.

بازسازی رشته اصلی

برای انتقال کار فشرده CPU به Web Worker، اولین گام بازسازی برنامه است. اکنون موضوع اصلی یک Worker ایجاد می کند و جدای از آن، فقط با ارسال ورودی به Web Worker و سپس دریافت خروجی و نمایش آن سروکار دارد.

/* Main thread. */

let worker = null;

// When the button is clicked, submit the input value
//  to the Web Worker.
button.addEventListener('click', (e) => {
  e.preventDefault();

  // Create the Web Worker lazily on-demand.
  if (!worker) {
    worker = new Worker('worker.js');

    // Listen for incoming messages and display the result.
    worker.addEventListener('message', (e) => {
      output.textContent = e.result;
    });
  }

  worker.postMessage({ integer: parseInt(input.value, 10) });
});

بد: Task در Web Worker اجرا می‌شود، اما کد بسیار بد است

Web Worker ماژول Wasm را نمونه سازی می کند و با دریافت یک پیام، وظیفه فشرده CPU را انجام می دهد و نتیجه را به رشته اصلی ارسال می کند. مشکل این رویکرد این است که نمونه سازی یک ماژول Wasm با WebAssembly.instantiateStreaming() یک عملیات ناهمزمان است. این به این معنی است که کد مسابقه ای است. در بدترین حالت، رشته اصلی زمانی که Web Worker هنوز آماده نیست، داده ها را ارسال می کند و Web Worker هرگز پیام را دریافت نمی کند.

/* Worker thread. */

// Instantiate the Wasm module.
// 🚫 This code is racy! If a message comes in while
// the promise is still being awaited, it's lost.
const importObject = {};
const resultObject = await WebAssembly.instantiateStreaming(
  fetch('factorial.wasm'),
  importObject,
);
const factorial = resultObject.instance.exports.factorial;

// Listen for incoming messages, run the task,
// and post the result.
self.addEventListener('message', (e) => {
  const { integer } = e.data;
  self.postMessage({ result: factorial(integer) });
});

بهتر: Task در Web Worker اجرا می‌شود، اما احتمالاً با بارگذاری و کامپایل اضافی

یک راه حل برای مشکل نمونه سازی ناهمزمان ماژول Wasm این است که بارگذاری، کامپایل و نمونه سازی ماژول Wasm را به شنونده رویداد منتقل کنید، اما این بدان معناست که این کار باید در هر پیام دریافتی انجام شود. با وجود حافظه پنهان HTTP و حافظه پنهان HTTP که قادر است بایت کد Wasm کامپایل شده را کش کند، این بدترین راه حل نیست، اما راه بهتری وجود دارد.

با انتقال کد ناهمزمان به ابتدای Web Worker و عدم انتظار برای تحقق وعده، بلکه ذخیره وعده در یک متغیر، برنامه بلافاصله به قسمت شنونده رویداد از کد می‌رود و هیچ پیامی از طرف کد ارسال نمی‌شود. موضوع اصلی از بین خواهد رفت. در داخل شنونده رویداد، می توان منتظر قول بود.

/* Worker thread. */

const importObject = {};
// Instantiate the Wasm module.
// 🚫 If the `Worker` is spun up frequently, the loading
// compiling, and instantiating work will happen every time.
const wasmPromise = WebAssembly.instantiateStreaming(
  fetch('factorial.wasm'),
  importObject,
);

// Listen for incoming messages
self.addEventListener('message', async (e) => {
  const { integer } = e.data;
  const resultObject = await wasmPromise;
  const factorial = resultObject.instance.exports.factorial;
  const result = factorial(integer);
  self.postMessage({ result });
});

خوب: Task در Web Worker اجرا می شود و فقط یک بار بارگیری و کامپایل می شود

نتیجه متد استاتیک WebAssembly.compileStreaming() یک وعده است که به WebAssembly.Module حل می شود. یکی از ویژگی های خوب این شی این است که می توان آن را با استفاده از postMessage() منتقل کرد. این بدان معناست که ماژول Wasm را می توان فقط یک بار در رشته اصلی بارگذاری و کامپایل کرد (یا حتی Web Worker دیگری که صرفاً به بارگذاری و کامپایل مربوط می شود) و سپس به Web Worker که وظیفه CPU فشرده را بر عهده دارد منتقل می شود. کد زیر این جریان را نشان می دهد.

/* Main thread. */

const modulePromise = WebAssembly.compileStreaming(fetch('factorial.wasm'));

let worker = null;

// When the button is clicked, submit the input value
// and the Wasm module to the Web Worker.
button.addEventListener('click', async (e) => {
  e.preventDefault();

  // Create the Web Worker lazily on-demand.
  if (!worker) {
    worker = new Worker('worker.js');

    // Listen for incoming messages and display the result.
    worker.addEventListener('message', (e) => {
      output.textContent = e.result;
    });
  }

  worker.postMessage({
    integer: parseInt(input.value, 10),
    module: await modulePromise,
  });
});

در سمت Web Worker، تنها چیزی که باقی می ماند استخراج شی WebAssembly.Module و نمونه سازی آن است. از آنجایی که پیام با WebAssembly.Module پخش نمی شود، کد موجود در Web Worker اکنون به جای نوع قبلی instantiateStreaming() از WebAssembly.instantiate() استفاده می کند. ماژول نمونه سازی شده در یک متغیر ذخیره می شود، بنابراین کار نمونه سازی فقط باید یک بار پس از چرخش Web Worker انجام شود.

/* Worker thread. */

let instance = null;

// Listen for incoming messages
self.addEventListener('message', async (e) => {
  // Extract the `WebAssembly.Module` from the message.
  const { integer, module } = e.data;
  const importObject = {};
  // Instantiate the Wasm module that came via `postMessage()`.
  instance = instance || (await WebAssembly.instantiate(module, importObject));
  const factorial = instance.exports.factorial;
  const result = factorial(integer);
  self.postMessage({ result });
});

Perfect: Task در Web Worker درون خطی اجرا می شود و فقط یک بار بارگیری و کامپایل می شود

حتی با ذخیره HTTP، دریافت کد Web Worker ذخیره شده (به طور ایده آل) و به طور بالقوه ضربه زدن به شبکه گران است. یک ترفند رایج عملکرد این است که Web Worker را درون خطی کنید و آن را به صورت یک blob: URL بارگذاری کنید. این همچنان نیاز دارد که ماژول Wasm کامپایل‌شده برای نمونه‌سازی به Web Worker ارسال شود، زیرا زمینه‌های Web Worker و رشته اصلی متفاوت است، حتی اگر بر اساس یک فایل منبع جاوا اسکریپت باشند.

/* Main thread. */

const modulePromise = WebAssembly.compileStreaming(fetch('factorial.wasm'));

let worker = null;

const blobURL = URL.createObjectURL(
  new Blob(
    [
      `
let instance = null;

self.addEventListener('message', async (e) => {
  // Extract the \`WebAssembly.Module\` from the message.
  const {integer, module} = e.data;
  const importObject = {};
  // Instantiate the Wasm module that came via \`postMessage()\`.
  instance = instance || await WebAssembly.instantiate(module, importObject);
  const factorial = instance.exports.factorial;
  const result = factorial(integer);
  self.postMessage({result});
});
`,
    ],
    { type: 'text/javascript' },
  ),
);

button.addEventListener('click', async (e) => {
  e.preventDefault();

  // Create the Web Worker lazily on-demand.
  if (!worker) {
    worker = new Worker(blobURL);

    // Listen for incoming messages and display the result.
    worker.addEventListener('message', (e) => {
      output.textContent = e.result;
    });
  }

  worker.postMessage({
    integer: parseInt(input.value, 10),
    module: await modulePromise,
  });
});

ایجاد تنبل یا مشتاق Web Worker

تا اینجای کار، تمام نمونه‌های کد Web Worker را با تنبلی بر اساس تقاضا، یعنی زمانی که دکمه فشار داده می‌شد، چرخانده بودند. بسته به برنامه شما، ایجاد Web Worker با اشتیاق بیشتر می تواند منطقی باشد، به عنوان مثال، زمانی که برنامه بیکار است یا حتی به عنوان بخشی از فرآیند بوت استرپ برنامه. بنابراین، کد ایجاد Web Worker را به خارج از شنونده رویداد دکمه منتقل کنید.

const worker = new Worker(blobURL);

// Listen for incoming messages and display the result.
worker.addEventListener('message', (e) => {
  output.textContent = e.result;
});

Web Worker را در اطراف نگه دارید یا نه

یک سوال که ممکن است از خود بپرسید این است که آیا باید Web Worker را به طور دائم در اطراف خود نگه دارید یا هر زمان که به آن نیاز داشتید دوباره آن را ایجاد کنید. هر دو روش ممکن است و مزایا و معایب خود را دارند. به عنوان مثال، نگه داشتن یک Web Worker به طور دائم در اطراف ممکن است ردپای حافظه برنامه شما را افزایش دهد و انجام کارهای همزمان را سخت‌تر کند، زیرا به نحوی باید نتایجی را که از Web Worker می‌آید به درخواست‌ها ترسیم کنید. از سوی دیگر، کد راه‌اندازی Web Worker شما ممکن است نسبتاً پیچیده باشد، بنابراین اگر هر بار یک کد جدید ایجاد کنید، ممکن است هزینه‌های زیادی وجود داشته باشد. خوشبختانه این چیزی است که می توانید با User Timeming API اندازه گیری کنید.

نمونه‌های کد تاکنون یک Web Worker دائمی را در اطراف نگه داشته است. نمونه کد زیر در صورت نیاز یک Web Worker ad hoc جدید ایجاد می کند. توجه داشته باشید که باید خودتان پیگیری خاتمه Web Worker را داشته باشید. (قطعه کد از رسیدگی به خطاها عبور می کند، اما در صورت بروز مشکل، مطمئن شوید که در همه موارد، موفقیت یا شکست، خاتمه داده می شود.)

/* Main thread. */

let worker = null;

const modulePromise = WebAssembly.compileStreaming(fetch('factorial.wasm'));

const blobURL = URL.createObjectURL(
  new Blob(
    [
      `
// Caching the instance means you can switch between
// throw-away and permanent Web Worker freely.
let instance = null;

self.addEventListener('message', async (e) => {
  // Extract the \`WebAssembly.Module\` from the message.
  const {integer, module} = e.data;
  const importObject = {};
  // Instantiate the Wasm module that came via \`postMessage()\`.
  instance = instance || await WebAssembly.instantiate(module, importObject);
  const factorial = instance.exports.factorial;
  const result = factorial(integer);
  self.postMessage({result});
});  
`,
    ],
    { type: 'text/javascript' },
  ),
);

button.addEventListener('click', async (e) => {
  e.preventDefault();
  // Terminate a potentially running Web Worker.
  if (worker) {
    worker.terminate();
  }
  // Create the Web Worker lazily on-demand.
  worker = new Worker(blobURL);
  worker.addEventListener('message', (e) => {
    worker.terminate();
    worker = null;
    output.textContent = e.data.result;
  });
  worker.postMessage({
    integer: parseInt(input.value, 10),
    module: await modulePromise,
  });
});

دموها

دو دمو برای بازی وجود دارد. یکی با وب کارگر موقت ( کد منبع ) و دیگری با یک وب کارگر دائمی ( کد منبع ). اگر Chrome DevTools را باز کنید و Console را بررسی کنید، می‌توانید گزارش‌های User Timing API را ببینید که زمان از کلیک روی دکمه تا نتیجه نمایش داده شده روی صفحه را اندازه‌گیری می‌کند. تب Network blob: درخواست(های) URL را نشان می دهد. در این مثال، تفاوت زمانی بین ad hoc و دائمی حدود 3× است. در عمل، برای چشم انسان، هر دو در این مورد قابل تشخیص نیستند. نتایج برای برنامه زندگی واقعی شما به احتمال زیاد متفاوت خواهد بود.

برنامه آزمایشی Factorial Wasm با یک کارگر موقت. Chrome DevTools باز است. دو لکه وجود دارد: درخواست‌های URL در تب Network و کنسول دو زمان محاسبه را نشان می‌دهد.

برنامه آزمایشی Factorial Wasm با Worker دائمی. Chrome DevTools باز است. فقط یک لکه وجود دارد: درخواست URL در تب Network و کنسول چهار زمان محاسبه را نشان می دهد.

نتیجه گیری

این پست برخی از الگوهای عملکردی برای مقابله با Wasm را بررسی کرده است.

  • به عنوان یک قاعده کلی، روش‌های پخش ( WebAssembly.compileStreaming() و WebAssembly.instantiateStreaming() ) را نسبت به همتایان غیر جریانی ( WebAssembly.compile() و WebAssembly.instantiate() ) ترجیح دهید.
  • اگر می توانید، وظایف سنگین عملکرد را در Web Worker برون سپاری کنید و کار بارگذاری و کامپایل Wasm را فقط یک بار خارج از Web Worker انجام دهید. به این ترتیب، Web Worker فقط باید ماژول Wasm را که از رشته اصلی دریافت می‌کند، نمونه‌سازی کند، جایی که بارگیری و کامپایل با WebAssembly.instantiate() انجام شده است، به این معنی که اگر Web Worker را به طور دائم در اطراف نگه دارید، می‌توان نمونه را در کش ذخیره کرد.
  • به دقت اندازه گیری کنید که آیا منطقی است که یک Web Worker دائمی را برای همیشه در اطراف خود نگه دارید، یا ایجاد Web Workers ad hoc در هر زمان که به آنها نیاز است. همچنین فکر کنید که بهترین زمان برای ایجاد Web Worker چه زمانی است. مواردی که باید در نظر گرفته شوند مصرف حافظه، مدت زمان نمونه سازی Web Worker و همچنین پیچیدگی احتمالاً رسیدگی به درخواست های همزمان است.

اگر این الگوها را در نظر بگیرید، در مسیر درستی برای عملکرد بهینه Wasm هستید.

قدردانی

این راهنما توسط آندریاس هاس ، یاکوب کومرو ، دیپتی گاندلوری ، آلون زکای ، فرانسیس مک کیب ، فرانسوا بوفورت و ریچل اندرو بازبینی شده است.