أساسيّات التزامن

volatile ورؤية الذاكرة

15 دقيقة الدرس 6 من 13

volatile ورؤية الذاكرة

تعرفت سابقًا على ظاهرة سباق البيانات (race condition) التي تحدث حين يعدّل خيطان بياناتٍ مشتركة دون تزامن. لكن ثمة فئة أكثر خفاءً وخطرًا من أخطاء التزامن: يمكن لخيط ما أن يقرأ قيمة قديمة — ليس لأن خيطًا آخر يكتب في نفس اللحظة، بل لأن JVM أو المعالج مسموح له بالاحتفاظ بنسخة خاصة مخزّنة مؤقتًا. فهم هذا هو صميم نموذج الذاكرة في Java.

مشكلة الرؤية

لا تقرأ المعالجات الحديثة من الذاكرة الرئيسية وتكتب إليها في كل تعليمة. لكل نواة سجلاتها الخاصة وطبقات ذاكرة مخبأ (L1 و L2 و L3). يستغل JVM هذا بكثافة: قيمة كتبها الخيط A قد تبقى في ذاكرة المخبأ الخاصة به دون أن تُصرَّف إلى الذاكرة الرئيسية. الخيط B الذي يعمل على نواة مختلفة يقرأ من مخبئه الخاص ويرى القيمة القديمة إلى أجل غير مسمى.

إليك هذا المثال الكلاسيكي:

public class VisibilityDemo { // لا تزامن، لا volatile private static boolean keepRunning = true; public static void main(String[] args) throws InterruptedException { Thread worker = new Thread(() -> { int count = 0; while (keepRunning) { // قد يستمر إلى الأبد count++; } System.out.println("Stopped. Count = " + count); }); worker.start(); Thread.sleep(1000); keepRunning = false; // يكتب الخيط الرئيسي System.out.println("Flag set to false"); } }

على كثير من بيئات JVM والأجهزة، لا ينتهي هذا البرنامج أبدًا. يخزّن الخيط العامل keepRunning في سجل ولا يُعيد القراءة من الذاكرة الرئيسية. الكتابة التي يجريها الخيط الرئيسي تبقى غير مرئية له.

هذه ليست ثغرة في منطقك — إنها ميزة في المنصة. تسمح مواصفة لغة Java (JLS) ونموذج الذاكرة فيها (JMM) بهذه التحسينات عن قصد. بدونها ستتطلب كل وصول للذاكرة تفريغ كاملًا لذاكرة المخبأ، مما يجعل برامج Java أبطأ بكثير. الثمن هو أن عليك أنت كمبرمج أن تُصرّح بالمتغيرات التي تحتاج ضمانات رؤية.

happens-before: الضمان الرسمي

لا يفكّر نموذج الذاكرة في Java من منظور ذاكرة المخبأ أو تعليمات المعالج. بدلًا من ذلك يعرّف قاعدة واحدة مستقلة عن الأجهزة تُسمى happens-before.

علاقة happens-before بين فعلين (كتابة وقراءة) تضمن أن الكتابة مرئية للقراءة. بعبارة أخرى: إذا كان الفعل A يسبق الفعل B وفق happens-before، فإن كل كتابة للذاكرة أجراها A (أو أي فعل قبله) مضمونة الرؤية لـ B.

قواعد happens-before الأساسية في JMM:

  • ترتيب البرنامج: داخل خيط واحد، كل تعليمة تسبق التالية وفق happens-before.
  • فك قفل المزامنة ← قفلها: فك قفل كتلة synchronized يسبق أي قفل لاحق على نفس الشاشة (monitor).
  • كتابة volatile ← قراءة volatile: الكتابة في متغير volatile تسبق كل قراءة لاحقة لنفس المتغير.
  • تشغيل الخيط: Thread.start() يسبق أي فعل في الخيط المُشغَّل.
  • الانتظار للخيط: كل الأفعال في خيط ما تسبق عودة Thread.join().
الانتقالية مهمة. إذا كان A يسبق B، وB يسبق C، فإن A يسبق C. يتيح لك هذا أن تستدل على سلاسل الضمانات وليس فقط أزواجًا فردية.

الكلمة المحجوزة volatile

الإعلان عن حقل بالكلمة volatile يُنشئ حافة happens-before بين كل كتابة لذلك الحقل وكل قراءة لاحقة له، عبر جميع الخيوط.

يترتب على ذلك تأثيران ملموسان:

  1. الرؤية: الكتابة في حقل volatile تُصرَّف فورًا إلى الذاكرة الرئيسية. والقراءة تجلب دائمًا من الذاكرة الرئيسية (متجاوزةً ذاكرة المخبأ). تختفي مشكلة القراءة القديمة.
  2. لا إعادة ترتيب حول volatile: يُمنع JVM والمعالج من نقل قراءات أو كتابات متغيرات أخرى عبر وصول volatile. هذا هو تأثير "حاجز الذاكرة".

إصلاح المثال السابق أمر بسيط:

public class VisibilityFixed { private static volatile boolean keepRunning = true; public static void main(String[] args) throws InterruptedException { Thread worker = new Thread(() -> { int count = 0; while (keepRunning) { // يُعيد القراءة دائمًا من الذاكرة الرئيسية count++; } System.out.println("Stopped. Count = " + count); }); worker.start(); Thread.sleep(1000); keepRunning = false; System.out.println("Flag set to false"); } }

إضافة volatile تضمن أن الخيط العامل سيلاحظ الكتابة في وقت محدود.

volatile ليس بديلًا عن synchronized

volatile يضمن الرؤية لكن لا يضمن الأتمية. خطأ كلاسيكي شائع:

public class BrokenCounter { private volatile int count = 0; // يُستدعى من خيوط متعددة public void increment() { count++; // ليس أتميًا — قراءة ثم جمع ثم كتابة: ثلاث عمليات منفصلة } }

حتى لو كانت كل قراءة لـ count تأتي من الذاكرة الرئيسية، فإن العملية المركّبة اقرأ-عدّل-اكتب الخاصة بـ count++ لا تزال سباقًا: قد يقرأ خيطان نفس القيمة، يضيف كل منهما 1، ويكتبان النتيجة، فيضيع عدّ واحد.

للعمليات المركّبة تحتاج إما إلى synchronized أو AtomicInteger (يُغطّى في الدرس التالي). حالات الاستخدام الصحيحة لـ volatile هي:

  • علامة منطقية (boolean) يضبطها خيط واحد ويقرأها الآخرون (مثل علامة الإيقاف).
  • مرجع (أو قيمة أولية) يكتبها خيط واحد ويقرأها كثيرون، حيث أحدث قيمة هي كل ما يهم.
  • حقول تعمل كحواجز نشر — كتابة حقل volatile بعد بناء كائن ما تضمن أن الكائن المبني مرئي بأمان للخيوط التي تقرأ ذلك الحقل لاحقًا.

النشر الآمن عبر volatile

أحد استخدامات volatile الدقيقة والمهمة هو النشر الآمن. بدونه قد ترى الخيوط الأخرى كائنًا مبنيًا جزئيًا حتى لو كُتب المرجع بعد عودة المُنشئ — لأن JVM يمكنه إعادة ترتيب الكتابات في حقول الكائن مع إسناد المرجع.

public class Config { public final String host; public final int port; public Config(String host, int port) { this.host = host; this.port = port; } } public class Server { // volatile يضمن أنه حين تُقرأ config غير null، // تكون جميع حقولها مرئية أيضًا private volatile Config config; public void reload(String host, int port) { config = new Config(host, port); // كتابة volatile } public void handleRequest() { Config c = config; // قراءة volatile if (c != null) { System.out.println(c.host + ":" + c.port); } } }
لا تخلط بين الرؤية والأتمية. هذا أكثر الأخطاء شيوعًا عند تعلّم volatile. الرؤية تعني: حين تُكتب قيمة ما، سيراها القرّاء. الأتمية تعني: عملية مركّبة (مثل ++ أو تحديث شرطي) تنجز كخطوة واحدة لا تتجزأ. volatile يمنحك الأولى فقط.

اعتبارات الأداء

قراءة volatile أرخص من synchronized لكنها ليست مجانية. كل قراءة تُجبر على رحلة بروتوكول تماسك ذاكرة المخبأ على الأجهزة الحديثة (حاجز "mfence" على x86 أو ما يعادله). في الحلقات المحكمة التي تقرأ volatile ملايين المرات في الثانية تصبح التكلفة ملموسة. النمط المعياري هو نسخ حقل volatile إلى متغير محلي في بداية الدالة والعمل بالمتغير المحلي داخل الحلقة، وإعادة قراءة volatile فقط عند الحاجة:

public void process() { boolean running = this.keepRunning; // قراءة volatile واحدة while (running) { doWork(); running = this.keepRunning; // إعادة القراءة دوريًا عند الحاجة } }

الخلاصة

volatile هو أخف أداة للتزامن في Java. يحلّ مشكلة الرؤية من خلال إنشاء علاقة happens-before بين الكتابات والقراءات، مانعًا الخيوط من العمل على قيم مخزّنة قديمة. لكنه لا يوفر الأتمية للعمليات المركّبة — هذه مهمة synchronized أو حزمة java.util.concurrent.atomic. استخدم volatile حين يكون لديك كاتب واحد، أو حين يكون المطلوب فقط أن تكون أحدث قيمة مرئية دائمًا دون حماية ثوابت متعددة الخطوات.