في هذا الدليل المخصّص لمطوّري الويب الذين يريدون الاستفادة من WebAssembly، ستتعرّف على كيفية استخدام Wasm لتفويض المهام التي تتطلّب الكثير من وحدة المعالجة المركزية (CPU) بمساعدة مثال عملي. يغطّي الدليل كل شيء، بدءًا من أفضل الممارسات لتحميل وحدات Wasm إلى تحسين عملية تجميعها وإنشائها. ويناقش أيضًا نقل المهام التي تتطلّب استخدامًا مكثّفًا لوحدة المعالجة المركزية إلى Web Workers، ويتناول قرارات التنفيذ التي ستواجهها، مثل وقت إنشاء Web Worker وما إذا كان يجب إبقاؤه نشطًا بشكل دائم أو تشغيله عند الحاجة. يقدّم الدليل بشكل متكرّر النهج ويطرح نمط أداء واحدًا في كل مرة، إلى أن يقترح أفضل حل للمشكلة.
الفرضيّات
لنفترض أنّ لديك مهمة تتطلّب استخدامًا مكثّفًا لوحدة المعالجة المركزية (CPU) وتريد الاستعانة بـ WebAssembly (Wasm) لتنفيذها بسبب أدائها القريب من الأداء الأصلي. المهمة التي تتطلّب استخدامًا مكثّفًا لوحدة المعالجة المركزية (CPU)
المستخدَمة كمثال في هذا الدليل تحسب مضروب العدد. المضروب هو ناتج ضرب عدد صحيح في جميع الأعداد الصحيحة الأصغر منه. على سبيل المثال، مضروب العدد أربعة (المكتوب على النحو 4!
) يساوي 24
(أي 4 * 3 * 2 * 1
). وتزداد الأرقام بسرعة. على سبيل المثال، 16!
هو
2,004,189,184
. من الأمثلة الأكثر واقعية على المهام التي تتطلّب قدرًا كبيرًا من وحدة المعالجة المركزية
مسح رمز شريطي أو
تتبُّع صورة نقطية.
في ما يلي نموذج تعليمات برمجية مكتوبة بلغة 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.
إعادة هيكلة سلسلة التعليمات الرئيسية
لنقل المهمة التي تستهلك الكثير من وحدة المعالجة المركزية إلى 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، وعند تلقّي رسالة، ينفّذ المهمة التي تتطلّب استخدامًا مكثّفًا لوحدة المعالجة المركزية ويرسل النتيجة إلى سلسلة التعليمات الرئيسية.
المشكلة في هذا الأسلوب هي أنّ إنشاء مثيل لوحدة 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 جديدًا مخصصًا كلما دعت الحاجة إلى ذلك. يُرجى العِلم أنّه عليك تتبُّع إنهاء 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
في هذا المثال، يبلغ الفرق في التوقيت بين التوقيع المخصص والتوقيع الدائم حوالي 3 أضعاف. ومن الناحية العملية، لا يمكن التمييز بينهما بالعين المجردة في هذه الحالة. من المرجّح أن تختلف نتائج تطبيقك الفعلي.
الاستنتاجات
استعرضت هذه المشاركة بعض أنماط الأداء للتعامل مع WebAssembly.
- كقاعدة عامة، يُفضّل استخدام طرق البث (
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.
الإقرارات
تمت مراجعة هذا الدليل من قِبل أندرياس هاس وجاكوب كوميرو وديبتي غاندلوري وألون زكاي وفرانسيس مكابي وفرانسوا بوفورت وراشيل أندرو.