أطلق العنان لتعدد المهام الحقيقي في جافاسكريبت. يغطي هذا الدليل الشامل SharedArrayBuffer و Atomics و Web Workers والمتطلبات الأمنية لتطبيقات الويب عالية الأداء.
JavaScript SharedArrayBuffer: نظرة عميقة على البرمجة المتزامنة على الويب
لعقود من الزمن، كانت طبيعة جافاسكريبت ذات الخيط الأحادي مصدراً لبساطتها وعائقاً كبيراً في الأداء. يعمل نموذج حلقة الأحداث بشكل رائع لمعظم المهام الموجهة بواجهة المستخدم، لكنه يواجه صعوبة عند التعامل مع العمليات الحسابية المكثفة. يمكن أن تؤدي الحسابات طويلة الأمد إلى تجميد المتصفح، مما يخلق تجربة مستخدم محبطة. بينما قدمت Web Workers حلاً جزئياً من خلال السماح بتشغيل النصوص البرمجية في الخلفية، إلا أنها جاءت مع قيودها الرئيسية الخاصة: عدم كفاءة نقل البيانات.
وهنا يأتي دور SharedArrayBuffer
(SAB)، وهي ميزة قوية تغير قواعد اللعبة بشكل أساسي من خلال تقديم مشاركة ذاكرة حقيقية ومنخفضة المستوى بين الخيوط على الويب. بالاقتران مع كائن Atomics
، يفتح SAB حقبة جديدة من التطبيقات المتزامنة عالية الأداء مباشرة في المتصفح. ومع ذلك، مع القوة العظمى تأتي مسؤولية كبيرة—وتعقيد.
سيأخذك هذا الدليل في رحلة عميقة إلى عالم البرمجة المتزامنة في جافاسكريبت. سنستكشف لماذا نحتاجها، وكيف يعمل SharedArrayBuffer
و Atomics
، والاعتبارات الأمنية الحاسمة التي يجب عليك معالجتها، وأمثلة عملية لتبدأ بها.
العالم القديم: نموذج جافاسكريبت أحادي الخيط وحدوده
قبل أن نتمكن من تقدير الحل، يجب أن نفهم المشكلة تماماً. يتم تنفيذ جافاسكريبت في المتصفح تقليدياً على خيط واحد، يُطلق عليه غالباً "الخيط الرئيسي" أو "خيط واجهة المستخدم".
حلقة الأحداث
الخيط الرئيسي مسؤول عن كل شيء: تنفيذ كود جافاسكريبت الخاص بك، وعرض الصفحة، والاستجابة لتفاعلات المستخدم (مثل النقرات والتمرير)، وتشغيل رسوم CSS المتحركة. يدير هذه المهام باستخدام حلقة أحداث، والتي تعالج باستمرار قائمة انتظار من الرسائل (المهام). إذا استغرقت مهمة ما وقتاً طويلاً لإكمالها، فإنها تمنع قائمة الانتظار بأكملها. لا يمكن أن يحدث أي شيء آخر—تتجمد واجهة المستخدم، وتتلعثم الرسوم المتحركة، وتصبح الصفحة غير مستجيبة.
Web Workers: خطوة في الاتجاه الصحيح
تم تقديم Web Workers للتخفيف من هذه المشكلة. إن Web Worker هو في الأساس نص برمجي يعمل على خيط خلفية منفصل. يمكنك تفريغ الحسابات الثقيلة إلى عامل (worker)، مما يحافظ على الخيط الرئيسي حراً للتعامل مع واجهة المستخدم.
يحدث الاتصال بين الخيط الرئيسي والعامل عبر واجهة برمجة التطبيقات postMessage()
. عند إرسال البيانات، يتم التعامل معها بواسطة خوارزمية النسخ الهيكلي (structured clone algorithm). هذا يعني أن البيانات يتم تسلسلها ونسخها ثم إلغاء تسلسلها في سياق العامل. على الرغم من فعاليتها، فإن هذه العملية لها عيوب كبيرة لمجموعات البيانات الكبيرة:
- عبء الأداء: يعد نسخ ميغابايت أو حتى غيغابايت من البيانات بين الخيوط بطيئاً ومكثفاً لوحدة المعالجة المركزية.
- استهلاك الذاكرة: يؤدي إلى إنشاء نسخة مكررة من البيانات في الذاكرة، وهو ما يمكن أن يمثل مشكلة كبيرة للأجهزة ذات الذاكرة المحدودة.
تخيل محرر فيديو في المتصفح. سيكون إرسال إطار فيديو كامل (والذي يمكن أن يكون عدة ميغابايت) ذهاباً وإياباً إلى عامل لمعالجته 60 مرة في الثانية أمراً باهظ التكلفة. هذه هي المشكلة التي صُمم SharedArrayBuffer
لحلها بالضبط.
المُغيّر لقواعد اللعبة: تقديم SharedArrayBuffer
إن SharedArrayBuffer
هو مخزن مؤقت للبيانات الثنائية الخام ثابت الطول، يشبه ArrayBuffer
. الفرق الحاسم هو أنه يمكن مشاركة SharedArrayBuffer
عبر خيوط متعددة (على سبيل المثال، الخيط الرئيسي وواحد أو أكثر من Web Workers). عندما "ترسل" SharedArrayBuffer
باستخدام postMessage()
، فأنت لا ترسل نسخة؛ بل ترسل مرجعاً إلى نفس كتلة الذاكرة.
هذا يعني أن أي تغييرات يتم إجراؤها على بيانات المخزن المؤقت بواسطة خيط واحد تكون مرئية على الفور لجميع الخيوط الأخرى التي لديها مرجع إليه. هذا يلغي خطوة النسخ والتسلسل المكلفة، مما يتيح مشاركة البيانات بشكل شبه فوري.
فكر في الأمر على هذا النحو:
- Web Workers مع
postMessage()
: هذا يشبه زميلين يعملان على مستند عن طريق إرسال نسخ عبر البريد الإلكتروني ذهاباً وإياباً. يتطلب كل تغيير إرسال نسخة جديدة بالكامل. - Web Workers مع
SharedArrayBuffer
: هذا يشبه زميلين يعملان على نفس المستند في محرر مشترك عبر الإنترنت (مثل Google Docs). تكون التغييرات مرئية لكليهما في الوقت الفعلي.
خطر الذاكرة المشتركة: حالات التسابق (Race Conditions)
مشاركة الذاكرة الفورية قوية، لكنها تقدم أيضاً مشكلة كلاسيكية من عالم البرمجة المتزامنة: حالات التسابق (race conditions).
تحدث حالة التسابق عندما تحاول خيوط متعددة الوصول إلى نفس البيانات المشتركة وتعديلها في وقت واحد، وتعتمد النتيجة النهائية على الترتيب غير المتوقع الذي يتم تنفيذه به. لنفترض وجود عداد بسيط مخزن في SharedArrayBuffer
. يريد كل من الخيط الرئيسي والعامل زيادته.
- يقرأ الخيط A القيمة الحالية، وهي 5.
- قبل أن يتمكن الخيط A من كتابة القيمة الجديدة، يوقفه نظام التشغيل مؤقتاً وينتقل إلى الخيط B.
- يقرأ الخيط B القيمة الحالية، والتي لا تزال 5.
- يحسب الخيط B القيمة الجديدة (6) ويكتبها مرة أخرى في الذاكرة.
- يعود النظام إلى الخيط A. لا يعرف أن الخيط B فعل أي شيء. يستأنف من حيث توقف، ويحسب قيمته الجديدة (5 + 1 = 6) ويكتب 6 مرة أخرى في الذاكرة.
على الرغم من زيادة العداد مرتين، فإن القيمة النهائية هي 6، وليس 7. لم تكن العمليات ذرية (atomic)—كانت قابلة للمقاطعة، مما أدى إلى فقدان البيانات. هذا هو السبب الدقيق الذي يجعلك لا تستطيع استخدام SharedArrayBuffer
بدون شريكه الحاسم: كائن Atomics
.
حارس الذاكرة المشتركة: كائن Atomics
يوفر كائن Atomics
مجموعة من التوابع الثابتة (static methods) لإجراء عمليات ذرية على كائنات SharedArrayBuffer
. العملية الذرية مضمونة التنفيذ بالكامل دون مقاطعتها من قبل أي عملية أخرى. إما أن تحدث بالكامل أو لا تحدث على الإطلاق.
يمنع استخدام Atomics
حالات التسابق من خلال ضمان تنفيذ عمليات القراءة والتعديل والكتابة على الذاكرة المشتركة بأمان.
توابع Atomics
الرئيسية
دعنا نلقي نظرة على بعض أهم التوابع التي يوفرها Atomics
.
Atomics.load(typedArray, index)
: يقرأ القيمة بشكل ذري عند فهرس معين ويعيدها. هذا يضمن أنك تقرأ قيمة كاملة وغير تالفة.Atomics.store(typedArray, index, value)
: يخزن قيمة بشكل ذري عند فهرس معين ويعيد تلك القيمة. هذا يضمن عدم مقاطعة عملية الكتابة.Atomics.add(typedArray, index, value)
: يضيف قيمة بشكل ذري إلى القيمة عند الفهرس المحدد. يعيد القيمة الأصلية في ذلك الموضع. هذا هو المكافئ الذري لـx += value
.Atomics.sub(typedArray, index, value)
: يطرح قيمة بشكل ذري من القيمة عند الفهرس المحدد.Atomics.compareExchange(typedArray, index, expectedValue, replacementValue)
: هذه عملية كتابة شرطية قوية. تتحقق مما إذا كانت القيمة عندindex
تساويexpectedValue
. إذا كانت كذلك، فإنها تستبدلها بـreplacementValue
وتعيدexpectedValue
الأصلية. إذا لم تكن كذلك، فإنها لا تفعل شيئاً وتعيد القيمة الحالية. هذه لبنة أساسية لتنفيذ بدائيات تزامن أكثر تعقيداً مثل الأقفال (locks).
المزامنة: ما وراء العمليات البسيطة
في بعض الأحيان تحتاج إلى أكثر من مجرد قراءة وكتابة آمنة. تحتاج إلى أن تنسق الخيوط وتنتظر بعضها البعض. من الأنماط السيئة الشائعة "الانتظار المشغول" (busy-waiting)، حيث يجلس الخيط في حلقة ضيقة، ويتحقق باستمرار من موقع ذاكرة بحثاً عن تغيير. هذا يهدر دورات وحدة المعالجة المركزية ويستنزف عمر البطارية.
يوفر Atomics
حلاً أكثر كفاءة بكثير مع wait()
و notify()
.
Atomics.wait(typedArray, index, value, timeout)
: يخبر هذا الخيط بالذهاب إلى وضع السكون. يتحقق مما إذا كانت القيمة عندindex
لا تزالvalue
. إذا كان الأمر كذلك، ينام الخيط حتى يتم إيقاظه بواسطةAtomics.notify()
أو حتى الوصول إلىtimeout
الاختياري (بالمللي ثانية). إذا تغيرت القيمة عندindex
بالفعل، فإنه يعود على الفور. هذا فعال بشكل لا يصدق حيث أن الخيط النائم لا يستهلك أي موارد لوحدة المعالجة المركزية تقريباً.Atomics.notify(typedArray, index, count)
: يستخدم هذا لإيقاظ الخيوط النائمة على موقع ذاكرة معين عبرAtomics.wait()
. سيوقظ على الأكثرcount
من الخيوط المنتظرة (أو كلها إذا لم يتم توفيرcount
أو كانInfinity
).
تجميع كل شيء معاً: دليل عملي
الآن بعد أن فهمنا النظرية، دعنا نمر عبر خطوات تنفيذ حل باستخدام SharedArrayBuffer
.
الخطوة 1: المتطلب الأمني - العزل عبر المصادر (Cross-Origin Isolation)
هذه هي العقبة الأكثر شيوعاً للمطورين. لأسباب أمنية، يتوفر SharedArrayBuffer
فقط في الصفحات الموجودة في حالة معزولة عبر المصادر (cross-origin isolated). هذا إجراء أمني للتخفيف من الثغرات الأمنية للتنفيذ التخميني مثل Spectre، والتي يمكن أن تستخدم مؤقتات عالية الدقة (أصبحت ممكنة بفضل الذاكرة المشتركة) لتسريب البيانات عبر المصادر.
لتمكين العزل عبر المصادر، يجب عليك تكوين خادم الويب الخاص بك لإرسال ترويستين HTTP محددتين لمستندك الرئيسي:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Opener-Policy: same-origin
(COOP): يعزل سياق التصفح لمستندك عن المستندات الأخرى، ويمنعها من التفاعل المباشر مع كائن النافذة الخاص بك.Cross-Origin-Embedder-Policy: require-corp
(COEP): يتطلب أن تكون جميع الموارد الفرعية (مثل الصور والنصوص البرمجية و iframes) التي يتم تحميلها بواسطة صفحتك إما من نفس المصدر أو تم تمييزها صراحةً على أنها قابلة للتحميل عبر المصادر باستخدام ترويسةCross-Origin-Resource-Policy
أو CORS.
قد يكون إعداد هذا أمراً صعباً، خاصة إذا كنت تعتمد على نصوص برمجية أو موارد من جهات خارجية لا توفر الترويسات اللازمة. بعد تكوين الخادم الخاص بك، يمكنك التحقق مما إذا كانت صفحتك معزولة عن طريق التحقق من الخاصية self.crossOriginIsolated
في وحدة تحكم المتصفح. يجب أن تكون true
.
الخطوة 2: إنشاء ومشاركة المخزن المؤقت
في النص البرمجي الرئيسي الخاص بك، تقوم بإنشاء SharedArrayBuffer
و "عرض" (view) عليه باستخدام TypedArray
مثل Int32Array
.
main.js:
// تحقق من العزل عبر المصادر أولاً!
if (!self.crossOriginIsolated) {
console.error("This page is not cross-origin isolated. SharedArrayBuffer will not be available.");
} else {
// أنشئ مخزنًا مؤقتًا مشتركًا لعدد صحيح واحد بحجم 32 بت.
const buffer = new SharedArrayBuffer(4);
// أنشئ عرضًا على المخزن المؤقت. تحدث جميع العمليات الذرية على العرض.
const int32Array = new Int32Array(buffer);
// تهيئة القيمة عند الفهرس 0.
int32Array[0] = 0;
// أنشئ عاملًا جديدًا.
const worker = new Worker('worker.js');
// أرسل المخزن المؤقت المشترك إلى العامل. هذا نقل مرجعي وليس نسخة.
worker.postMessage({ buffer });
// استمع للرسائل من العامل.
worker.onmessage = (event) => {
console.log(`Worker reported completion. Final value: ${Atomics.load(int32Array, 0)}`);
};
}
الخطوة 3: إجراء عمليات ذرية في العامل
يستقبل العامل المخزن المؤقت ويمكنه الآن إجراء عمليات ذرية عليه.
worker.js:
self.onmessage = (event) => {
const { buffer } = event.data;
const int32Array = new Int32Array(buffer);
console.log("Worker received the shared buffer.");
// لنجري بعض العمليات الذرية.
for (let i = 0; i < 1000000; i++) {
// زيادة القيمة المشتركة بأمان.
Atomics.add(int32Array, 0, 1);
}
console.log("Worker finished incrementing.");
// أرسل إشارة إلى الخيط الرئيسي بأننا انتهينا.
self.postMessage({ done: true });
};
الخطوة 4: مثال أكثر تقدماً - الجمع المتوازي مع المزامنة
دعنا نتناول مشكلة أكثر واقعية: جمع مصفوفة كبيرة جداً من الأرقام باستخدام عدة عمال. سنستخدم Atomics.wait()
و Atomics.notify()
للمزامنة الفعالة.
سيكون للمخزن المؤقت المشترك ثلاثة أجزاء:
- الفهرس 0: علامة حالة (0 = قيد المعالجة، 1 = مكتمل).
- الفهرس 1: عداد لعدد العمال الذين انتهوا.
- الفهرس 2: المجموع النهائي.
main.js:
if (self.crossOriginIsolated) {
const NUM_WORKERS = 4;
const DATA_SIZE = 10_000_000;
// [status, workers_finished, result_low, result_high]
// نستخدم عددين صحيحين بحجم 32 بت للنتيجة لتجنب تجاوز السعة للمجاميع الكبيرة.
const sharedBuffer = new SharedArrayBuffer(4 * 4); // 4 أعداد صحيحة
const sharedArray = new Int32Array(sharedBuffer);
// توليد بعض البيانات العشوائية لمعالجتها
const data = new Uint8Array(DATA_SIZE);
for (let i = 0; i < DATA_SIZE; i++) {
data[i] = Math.floor(Math.random() * 10);
}
const chunkSize = Math.ceil(DATA_SIZE / NUM_WORKERS);
for (let i = 0; i < NUM_WORKERS; i++) {
const worker = new Worker('sum_worker.js');
const start = i * chunkSize;
const end = Math.min(start + chunkSize, DATA_SIZE);
// إنشاء عرض غير مشترك لجزء البيانات الخاص بالعامل
const dataChunk = data.subarray(start, end);
worker.postMessage({
sharedBuffer,
dataChunk // يتم نسخ هذا
});
}
console.log('Main thread is now waiting for workers to finish...');
// انتظر حتى تصبح علامة الحالة عند الفهرس 0 هي 1
// هذا أفضل بكثير من حلقة while!
Atomics.wait(sharedArray, 0, 0); // انتظر إذا كانت sharedArray[0] تساوي 0
console.log('Main thread woken up!');
const finalSum = Atomics.load(sharedArray, 2);
console.log(`The final parallel sum is: ${finalSum}`);
} else {
console.error('Page is not cross-origin isolated.');
}
sum_worker.js:
self.onmessage = ({ data }) => {
const { sharedBuffer, dataChunk } = data;
const sharedArray = new Int32Array(sharedBuffer);
// حساب المجموع لجزء هذا العامل
let localSum = 0;
for (let i = 0; i < dataChunk.length; i++) {
localSum += dataChunk[i];
}
// إضافة المجموع المحلي بشكل ذري إلى المجموع المشترك
Atomics.add(sharedArray, 2, localSum);
// زيادة عداد "العمال المنتهون" بشكل ذري
const finishedCount = Atomics.add(sharedArray, 1, 1) + 1;
// إذا كان هذا هو آخر عامل ينتهي ...
const NUM_WORKERS = 4; // يجب تمريره في تطبيق حقيقي
if (finishedCount === NUM_WORKERS) {
console.log('Last worker finished. Notifying main thread.');
// 1. اضبط علامة الحالة على 1 (مكتمل)
Atomics.store(sharedArray, 0, 1);
// 2. أبلغ الخيط الرئيسي الذي ينتظر عند الفهرس 0
Atomics.notify(sharedArray, 0, 1);
}
};
حالات الاستخدام والتطبيقات الواقعية
أين تحدث هذه التكنولوجيا القوية ولكن المعقدة فرقاً فعلياً؟ إنها تتفوق في التطبيقات التي تتطلب حسابات ثقيلة قابلة للتوازي على مجموعات بيانات كبيرة.
- WebAssembly (Wasm): هذه هي حالة الاستخدام القاتلة. تتمتع لغات مثل C++ و Rust و Go بدعم ناضج لتعدد المهام. يسمح Wasm للمطورين بتجميع هذه التطبيقات الحالية عالية الأداء ومتعددة الخيوط (مثل محركات الألعاب وبرامج CAD والنماذج العلمية) لتعمل في المتصفح، باستخدام
SharedArrayBuffer
كآلية أساسية لاتصال الخيوط. - معالجة البيانات داخل المتصفح: يمكن تسريع تصور البيانات على نطاق واسع، واستدلال نماذج التعلم الآلي من جانب العميل، والمحاكاة العلمية التي تعالج كميات هائلة من البيانات بشكل كبير.
- تحرير الوسائط: يمكن تقسيم تطبيق المرشحات على الصور عالية الدقة أو إجراء معالجة صوتية على ملف صوتي إلى أجزاء ومعالجتها بالتوازي بواسطة عدة عمال، مما يوفر ملاحظات في الوقت الفعلي للمستخدم.
- الألعاب عالية الأداء: تعتمد محركات الألعاب الحديثة بشكل كبير على تعدد المهام للفيزياء والذكاء الاصطناعي وتحميل الأصول. يجعل
SharedArrayBuffer
من الممكن بناء ألعاب بجودة المنصات تعمل بالكامل في المتصفح.
التحديات والاعتبارات النهائية
في حين أن SharedArrayBuffer
تحويلي، إلا أنه ليس حلاً سحرياً. إنها أداة منخفضة المستوى تتطلب معالجة دقيقة.
- التعقيد: البرمجة المتزامنة صعبة للغاية. يمكن أن يكون تصحيح أخطاء حالات التسابق والجمود (deadlocks) أمراً صعباً للغاية. يجب أن تفكر بشكل مختلف حول كيفية إدارة حالة تطبيقك.
- الجمود (Deadlocks): يحدث الجمود عندما يتم حظر خيطين أو أكثر إلى الأبد، كل منهما ينتظر الآخر لتحرير مورد. يمكن أن يحدث هذا إذا قمت بتنفيذ آليات قفل معقدة بشكل غير صحيح.
- العبء الأمني: يعد متطلب العزل عبر المصادر عقبة كبيرة. يمكن أن يكسر التكامل مع خدمات الجهات الخارجية والإعلانات وبوابات الدفع إذا كانت لا تدعم ترويسات CORS/CORP اللازمة.
- ليس لكل مشكلة: للمهام الخلفية البسيطة أو عمليات الإدخال/الإخراج، غالباً ما يكون نموذج Web Worker التقليدي مع
postMessage()
أبسط وكافياً. لا تلجأ إلىSharedArrayBuffer
إلا عندما يكون لديك عنق زجاجة واضح مرتبط بوحدة المعالجة المركزية ويتضمن كميات كبيرة من البيانات.
الخاتمة
يمثل SharedArrayBuffer
، بالاقتران مع Atomics
و Web Workers، تحولاً نموذجياً لتطوير الويب. إنه يحطم حدود النموذج أحادي الخيط، ويدعو فئة جديدة من التطبيقات القوية وعالية الأداء والمعقدة إلى المتصفح. إنه يضع منصة الويب على قدم المساواة مع تطوير التطبيقات الأصلية للمهام الحسابية المكثفة.
إن الرحلة إلى جافاسكريبت المتزامنة صعبة، وتتطلب نهجاً صارماً لإدارة الحالة والمزامنة والأمان. ولكن بالنسبة للمطورين الذين يتطلعون إلى دفع حدود ما هو ممكن على الويب—من التوليف الصوتي في الوقت الفعلي إلى العرض ثلاثي الأبعاد المعقد والحوسبة العلمية—لم يعد إتقان SharedArrayBuffer
مجرد خيار؛ بل هو مهارة أساسية لبناء الجيل القادم من تطبيقات الويب.