العربية

أطلق العنان لتعدد المهام الحقيقي في جافاسكريبت. يغطي هذا الدليل الشامل 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()، فأنت لا ترسل نسخة؛ بل ترسل مرجعاً إلى نفس كتلة الذاكرة.

هذا يعني أن أي تغييرات يتم إجراؤها على بيانات المخزن المؤقت بواسطة خيط واحد تكون مرئية على الفور لجميع الخيوط الأخرى التي لديها مرجع إليه. هذا يلغي خطوة النسخ والتسلسل المكلفة، مما يتيح مشاركة البيانات بشكل شبه فوري.

فكر في الأمر على هذا النحو:

خطر الذاكرة المشتركة: حالات التسابق (Race Conditions)

مشاركة الذاكرة الفورية قوية، لكنها تقدم أيضاً مشكلة كلاسيكية من عالم البرمجة المتزامنة: حالات التسابق (race conditions).

تحدث حالة التسابق عندما تحاول خيوط متعددة الوصول إلى نفس البيانات المشتركة وتعديلها في وقت واحد، وتعتمد النتيجة النهائية على الترتيب غير المتوقع الذي يتم تنفيذه به. لنفترض وجود عداد بسيط مخزن في SharedArrayBuffer. يريد كل من الخيط الرئيسي والعامل زيادته.

  1. يقرأ الخيط A القيمة الحالية، وهي 5.
  2. قبل أن يتمكن الخيط A من كتابة القيمة الجديدة، يوقفه نظام التشغيل مؤقتاً وينتقل إلى الخيط B.
  3. يقرأ الخيط B القيمة الحالية، والتي لا تزال 5.
  4. يحسب الخيط B القيمة الجديدة (6) ويكتبها مرة أخرى في الذاكرة.
  5. يعود النظام إلى الخيط A. لا يعرف أن الخيط B فعل أي شيء. يستأنف من حيث توقف، ويحسب قيمته الجديدة (5 + 1 = 6) ويكتب 6 مرة أخرى في الذاكرة.

على الرغم من زيادة العداد مرتين، فإن القيمة النهائية هي 6، وليس 7. لم تكن العمليات ذرية (atomic)—كانت قابلة للمقاطعة، مما أدى إلى فقدان البيانات. هذا هو السبب الدقيق الذي يجعلك لا تستطيع استخدام SharedArrayBuffer بدون شريكه الحاسم: كائن Atomics.

حارس الذاكرة المشتركة: كائن Atomics

يوفر كائن Atomics مجموعة من التوابع الثابتة (static methods) لإجراء عمليات ذرية على كائنات SharedArrayBuffer. العملية الذرية مضمونة التنفيذ بالكامل دون مقاطعتها من قبل أي عملية أخرى. إما أن تحدث بالكامل أو لا تحدث على الإطلاق.

يمنع استخدام Atomics حالات التسابق من خلال ضمان تنفيذ عمليات القراءة والتعديل والكتابة على الذاكرة المشتركة بأمان.

توابع Atomics الرئيسية

دعنا نلقي نظرة على بعض أهم التوابع التي يوفرها Atomics.

المزامنة: ما وراء العمليات البسيطة

في بعض الأحيان تحتاج إلى أكثر من مجرد قراءة وكتابة آمنة. تحتاج إلى أن تنسق الخيوط وتنتظر بعضها البعض. من الأنماط السيئة الشائعة "الانتظار المشغول" (busy-waiting)، حيث يجلس الخيط في حلقة ضيقة، ويتحقق باستمرار من موقع ذاكرة بحثاً عن تغيير. هذا يهدر دورات وحدة المعالجة المركزية ويستنزف عمر البطارية.

يوفر Atomics حلاً أكثر كفاءة بكثير مع wait() و notify().

تجميع كل شيء معاً: دليل عملي

الآن بعد أن فهمنا النظرية، دعنا نمر عبر خطوات تنفيذ حل باستخدام SharedArrayBuffer.

الخطوة 1: المتطلب الأمني - العزل عبر المصادر (Cross-Origin Isolation)

هذه هي العقبة الأكثر شيوعاً للمطورين. لأسباب أمنية، يتوفر SharedArrayBuffer فقط في الصفحات الموجودة في حالة معزولة عبر المصادر (cross-origin isolated). هذا إجراء أمني للتخفيف من الثغرات الأمنية للتنفيذ التخميني مثل Spectre، والتي يمكن أن تستخدم مؤقتات عالية الدقة (أصبحت ممكنة بفضل الذاكرة المشتركة) لتسريب البيانات عبر المصادر.

لتمكين العزل عبر المصادر، يجب عليك تكوين خادم الويب الخاص بك لإرسال ترويستين HTTP محددتين لمستندك الرئيسي:


Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

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

سيكون للمخزن المؤقت المشترك ثلاثة أجزاء:

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);
  }
};

حالات الاستخدام والتطبيقات الواقعية

أين تحدث هذه التكنولوجيا القوية ولكن المعقدة فرقاً فعلياً؟ إنها تتفوق في التطبيقات التي تتطلب حسابات ثقيلة قابلة للتوازي على مجموعات بيانات كبيرة.

التحديات والاعتبارات النهائية

في حين أن SharedArrayBuffer تحويلي، إلا أنه ليس حلاً سحرياً. إنها أداة منخفضة المستوى تتطلب معالجة دقيقة.

  1. التعقيد: البرمجة المتزامنة صعبة للغاية. يمكن أن يكون تصحيح أخطاء حالات التسابق والجمود (deadlocks) أمراً صعباً للغاية. يجب أن تفكر بشكل مختلف حول كيفية إدارة حالة تطبيقك.
  2. الجمود (Deadlocks): يحدث الجمود عندما يتم حظر خيطين أو أكثر إلى الأبد، كل منهما ينتظر الآخر لتحرير مورد. يمكن أن يحدث هذا إذا قمت بتنفيذ آليات قفل معقدة بشكل غير صحيح.
  3. العبء الأمني: يعد متطلب العزل عبر المصادر عقبة كبيرة. يمكن أن يكسر التكامل مع خدمات الجهات الخارجية والإعلانات وبوابات الدفع إذا كانت لا تدعم ترويسات CORS/CORP اللازمة.
  4. ليس لكل مشكلة: للمهام الخلفية البسيطة أو عمليات الإدخال/الإخراج، غالباً ما يكون نموذج Web Worker التقليدي مع postMessage() أبسط وكافياً. لا تلجأ إلى SharedArrayBuffer إلا عندما يكون لديك عنق زجاجة واضح مرتبط بوحدة المعالجة المركزية ويتضمن كميات كبيرة من البيانات.

الخاتمة

يمثل SharedArrayBuffer، بالاقتران مع Atomics و Web Workers، تحولاً نموذجياً لتطوير الويب. إنه يحطم حدود النموذج أحادي الخيط، ويدعو فئة جديدة من التطبيقات القوية وعالية الأداء والمعقدة إلى المتصفح. إنه يضع منصة الويب على قدم المساواة مع تطوير التطبيقات الأصلية للمهام الحسابية المكثفة.

إن الرحلة إلى جافاسكريبت المتزامنة صعبة، وتتطلب نهجاً صارماً لإدارة الحالة والمزامنة والأمان. ولكن بالنسبة للمطورين الذين يتطلعون إلى دفع حدود ما هو ممكن على الويب—من التوليف الصوتي في الوقت الفعلي إلى العرض ثلاثي الأبعاد المعقد والحوسبة العلمية—لم يعد إتقان SharedArrayBuffer مجرد خيار؛ بل هو مهارة أساسية لبناء الجيل القادم من تطبيقات الويب.