أنماط أداء WebAssembly لتطبيقات الويب

في هذا الدليل الموجَّه لمطوّري الويب الذين يريدون الاستفادة من WebAssembly، ستتعرّف على كيفية الاستفادة من Wasm لتفويض المهام التي تتطلّب استخدامًا مكثّفًا لوحدة المعالجة المركزية (CPU) باستخدام مثال قيد التنفيذ. يتناول الدليل كل شيء بدءًا من أفضل الممارسات لتحميل وحدات Wasm ووصولاً إلى تحسين عملية تجميعها وإنشاء مثيلات لها. وتناقش كذلك كيفية تحويل المهام ذات الاستهلاك الكثيف لوحدة المعالجة المركزية إلى "عمال الويب" وتبحث في قرارات التنفيذ التي ستواجهها مثل وقت إنشاء "عامل الويب" وما إذا كان تريد إبقائه نشطًا بشكل دائم أو تدويره عند الحاجة. يطوّر الدليل النهج بشكلٍ متكرّر ويقدّم نمط أداء واحدًا في كل مرة، إلى أن يقترح أفضل حلّ للمشكلة.

الفرضيّات

لنفترض أنّ لديك مهمة تتطلّب استخدامًا مكثّفًا لوحدة المعالجة المركزية (CPU) وتريد الاستعانة بمصادر خارجية من أجل تنفيذها باستخدام تكنولوجيا WebAssembly (Wasm) بسبب أدائها المقارب للأداء الأصلي. إنّ المهمة التي تستهلك موارد وحدة المعالجة المركزية والمُستخدَمة كمثال في هذا الدليل تحسب عامل ضرب رقم معيّن. ناتج الضرب في نظرية ناتج الضرب هو حاصل ضرب عدد صحيح وجميع الأعداد الصحيحة التي تقلّ عنه. على سبيل المثال، معامل ضرب أربعة (يُكتب بالصيغة 4!) يساوي 24 (أي 4 * 3 * 2 * 1). تزداد الأرقام بسرعة. على سبيل المثال، 16! هو 2,004,189,184. من الأمثلة الأكثر واقعية على المهام التي تستهلك وحدة المعالجة المركزية (CPU) مسح رمز شريطي أو تتبُّع صورة نقطية.

يظهر في نموذج التعليمات البرمجية التالي المكتوب بلغة C++ تنفيذ factorial() دالة فعّال بشكل متكرّر (بدلاً من تكراري).

#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 المجمّعة من JavaScript باستخدام 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. تتم الإشارة إلى هذه العناصر من JavaScript استنادًا إلى أسمائها.

<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() . بما أنّ تطبيق الويب يعتمد على وحدة Wasm لأداء المهام المكثفة استخدامًا لوحدة المعالجة المركزية، عليك تحميل ملف Wasm مسبقًا في أقرب وقت ممكن. ويمكنك تنفيذ ذلك باستخدام عملية جلب باستخدام CORS في قسم <head> من تطبيقك.

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

في الواقع، واجهة برمجة التطبيقات fetch() غير متزامنة وعليك await النتيجة.

fetch('factorial.wasm');

بعد ذلك، يجب تجميع وحدة Wasm وإنشاء مثيل لها. هناك دوال برمجية سميّتها ملفتة للنظر، وهي WebAssembly.compile() (بالإضافة إلى WebAssembly.compileStreaming()) و WebAssembly.instantiate() لهذه المهام، ولكن بدلاً من ذلك، تُجمِّع الوسيطة WebAssembly.instantiateStreaming() و مثيلًا لمكوّن Wasm مباشرةً من مصدر أساسي يتم بثّه، مثل 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

في حال تنفيذ ذلك في سلسلة المهام الرئيسية، مع المهام التي تستهلك وحدة المعالجة المركزية بشكل كبير، فإنّك تخاطر بحظر التطبيق بأكمله. ومن الممارسات الشائعة نقل هذه المهام إلى 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) });
});

سيئ: يتم تشغيل المهمة في 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) });
});

أفضل: يتم تشغيل المهمة في 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 });
});

جيد: يتم تشغيل المهمة في Web Worker، ويتم تحميلها وتجميعها مرة واحدة فقط

ينتج عن استخدام طريقة WebAssembly.compileStreaming() الثابتة وعود يحلّ محلّها الرمز WebAssembly.Module. من الميزات الرائعة لهذا العنصر أنّه يمكن نقله باستخدام postMessage(). وهذا يعني أنّه يمكن تحميل وحدة Wasm وتجميعها مرة واحدة فقط في السلسلة الرئيسية (أو حتى Web Worker آخر معنيّ فقط بالتحميل والتجميع)، ثم نقلها إلى Web Worker المسؤول عن مهمة المكثفة لوحدة المعالجة المركزية. يوضح الرمز التالي هذا التدفق.

/* 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 الآن WebAssembly.instantiate() بدلاً من الصيغة instantiateStreaming() السابقة. يتم تخزين الوحدات التي تم إنشاؤها في ذاكرة التخزين المؤقت في متغيّر، لذا لا يلزم تنفيذ عملية الإنشاء إلا مرّة واحدة عند بدء 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 });
});

مثالي: يتم تشغيل المهمة في Web Worker مضمّن، ويتم تحميلها وتجميعها مرة واحدة فقط.

حتى مع ميزة التخزين المؤقت لبروتوكول HTTP، فإنّ الحصول على رمز Web Worker المخزّن مؤقتًا (بشكل مثالي) واستخدام الشبكة في بعض الحالات هو إجراء باهظ التكلفة. من الحيل الشائعة لتحسين الأداء هو إدراج Web Worker وتحميله كعنوان URL blob:. ولا يزال هذا يتطلّب تمرير وحدة Wasm المجمّعة إلى Web Worker لإنشاء مثيل مثيل، حيث تختلف سياقات Web Worker عن سلسلة التعليمات الرئيسية، حتى لو كانت تستند إلى ملف JavaScript المصدر نفسه.

/* 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 Timing API.

حتى الآن، احتفظت عيّنات التعليمات البرمجية بعامل واحد دائم على الويب. ينشئ ملف ملف رمز برمجي التالي Web Worker جديدًا بشكل مخصّص عند الحاجة. يُرجى العِلم أنّه عليك تتبُّع إنهاء 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,
  });
});

إصدارات تجريبية

يتوفّر عرضان توضيحيان يمكنك اللعب بهما. أحدهما يستخدم Web Worker مخصّصًا (رمز المصدر) والآخر يستخدم Web Worker دائمًا (رمز المصدر). إذا فتحت "أدوات مطوّري البرامج" في Chrome وتحقّقت من وحدة التحكّم، يمكنك الاطّلاع على سجلّات User Timing API التي تقيس الوقت المستغرَق من النقر على الزر إلى النتيجة المعروضة على الشاشة. تعرِض علامة التبويب "الشبكة" blob:طلبات عناوين URL . في هذا المثال، يُعدّ الفرق في التوقيت بين الإعدادات المخصّصة والإعدادات الدائمة مقاربًا لثلاثة أضعاف. من الناحية العملية، لا يمكن تمييز الفرق بين الإعدادَين بالعين المجردة في هذا المثال. من المرجّح أن تختلف نتائج تطبيقك في الحياة الواقعية.

تطبيق تجريبي لـ Factorial Wasm مع Worker مخصّص تكون &quot;أدوات مطوّري البرامج في Chrome&quot; مفتوحة. هناك عنصران من عناصر البيانات غير المنظَّمة: طلبات عناوين URL في علامة التبويب &quot;الشبكة&quot;، وتعرض وحدة التحكّم توقيتَين للحساب.

تطبيق تجريبي لـ Factorial Wasm يتضمّن Worker دائمًا تكون &quot;أدوات مطوّري البرامج في Chrome&quot; مفتوحة. هناك عنصر واحد فقط: طلب عنوان URL في علامة التبويب &quot;الشبكة&quot;، وتعرض وحدة التحكّم أربعة أوقات احتساب.

الاستنتاجات

لقد استكشَفت هذه المشاركة بعض أنماط الأداء للتعامل مع Wasm.

  • كقاعدة عامة، يُفضَّل استخدام طرق البث (WebAssembly.compileStreaming() وWebAssembly.instantiateStreaming()) على نظيراتها غير المستخدَمة في بث البيانات (WebAssembly.compile() و WebAssembly.instantiate()).
  • إذا أمكن، يمكنك الاستعانة بمهام خارجية تتطلّب أداءً عاليًا في Web Worker، وتنفيذ عملية تحميل Wasm وتجميعها مرة واحدة فقط خارج Web Worker. بهذه الطريقة، يحتاج Web Worker إلى إنشاء مثيل لوحدة Wasm التي يتلقّاها من سلسلت المعالجة الرئيسية التي حدث فيها التحميل والتجميع باستخدام WebAssembly.instantiate()، ما يعني أنّه يمكن تخزين المثيل في ذاكرة التخزين المؤقت إذا أبقيت Web Worker قيد التشغيل بشكل دائم.
  • عليك قياس ما إذا كان من المنطقي إبقاء Web Worker دائم مفعّلًا بشكل دائم أو إنشاء Web Worker مؤقت كلما دعت الحاجة. فكر أيضًا في الوقت المناسب لإنشاء Web Worker. من الأشياء التي يجب مراعاتها استهلاك الذاكرة ومدة إنشاء مثيل Web Worker، ولكن أيضًا مدى تعقيد الاضطرار إلى التعامل مع الطلبات المتزامنة.

إذا أخذت هذه الأنماط في الاعتبار، ستكون على الطريق الصحيح لتحقيق أفضل أداء ممكن لتكنولوجيا معالجة تطبيقات الويب (Wasm).

الشكر والتقدير

تمت مراجعة هذا الدليل بواسطة أندرياس هاس وجاكوب كومروف وديبتي غاندلوري وألون زاكاي وفرانسيس مكابي وفرانسوا بوفورت ورايشيل أندرو.