في هذا الدليل الموجّه لمطوّري الويب الذين يريدون الاستفادة من WebAssembly، ستتعرّف على كيفية الاستفادة من Wasm لتفويض المهام التي تتطلّب استخدامًا مكثّفًا لوحدة المعالجة المركزية (CPU) باستخدام مثال قيد التنفيذ. يتناول الدليل كل شيء بدءًا من أفضل الممارسات لتحميل وحدات Wasm ووصولاً إلى تحسين عملية تجميعها وإنشاء مثيلات لها. ويتناول القسم أيضًا نقل المهام التي تستهلك موارد وحدة المعالجة المركزية إلى Web Worker، وينظر في قرارات التنفيذ التي ستواجهها، مثل وقت إنشاء Web Worker وما إذا كان يجب إبقاءه قيد التشغيل بشكل دائم أو تشغيله عند الحاجة. يطوّر الدليل النهج بشكلٍ متكرّر ويقدّم نمط أداء واحدًا في كل مرة، إلى أن يقترح أفضل حلّ للمشكلة.
الفرضيّات
لنفترض أنّ لديك مهمة تتطلّب استخدامًا مكثّفًا لوحدة المعالجة المركزية (CPU) وتريد الاستعانة بمصادر خارجية من أجل استخدام مكتبة
WebAssembly (Wasm) بسبب أدائها المقارب للأداء الأصلي. إنّ المهمة التي تستهلك موارد وحدة المعالجة المركزية
والمُستخدَمة كمثال في هذا الدليل تحسب عامل ضرب رقم معيّن. ناتج العبارة
factorial هو حاصل ضرب عدد صحيح وجميع الأعداد الصحيحة التي تقلّ عنه. على سبيل المثال، حاصل ضرب الأعداد المربّعة (المكتوبة على النحو التالي: 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
وsubmit
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:
. يتطلّب ذلك مع ذلك نقل ملف VMS compiled إلى Web Worker لإنشاء مثيل له، لأنّ ملف VMS compiled يختلف عن ملف JavaScript source file، حتى إذا كانا يستندان إلى ملف JavaScript source file نفسه.
/* 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
. في هذا المثال، يُعدّ الفرق في التوقيت بين الإعدادات المخصّصة والإعدادات الدائمة
مقاربًا لثلاثة أضعاف. من الناحية العملية، لا يمكن تمييز الفرق بين الإعدادَين بالعين المجردة في
هذا المثال. من المرجّح أن تختلف نتائج تطبيقك في الحياة الواقعية.
الاستنتاجات
لقد استكشَفت هذه المشاركة بعض أنماط الأداء للتعامل مع 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).
الشكر والتقدير
تمت مراجعة هذا الدليل من قِبل أندرياس هاس، جاكوب كوميروف، ديبتي غاندلوري، ألون زاكي، فرانسيس مكابي، فرنسوا بافوي، راشيل أندرو.