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

الكلمة المفتاحية synchronized

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

الكلمة المفتاحية synchronized

في الدرس السابق رأيت كيف تنشأ حالات التسابق عندما تقرأ خيوط متعددة وتكتب في حالة مشتركة دون تنسيق. الجواب الأقدم والأساسي في Java على هذه المشكلة هو الكلمة المفتاحية synchronized. فهمها بعمق — ليس فقط التركيب النحوي، بل ضمانات نموذج الذاكرة والمقايضات في الأداء — أمر ضروري قبل أن تنتقل إلى أدوات التزامن ذات المستوى الأعلى.

المراقِب: القفل المدمج في Java

كل كائن Java يحمل حقلًا خفيًا يُسمى المراقِب (يُعرف أيضًا بـ القفل الجوهري أو قفل الكائن). لا يمكنك رؤية هذا الحقل في كود المصدر؛ تديره JVM. يفرض المراقِب أمرين في آنٍ واحد:

  • الاستبعاد المتبادل — خيط واحد فقط يمكنه امتلاك المراقِب في أي لحظة. كل خيط آخر يحاول دخول قسم synchronized على نفس المراقِب يُحجَب حتى يُحرَّر القفل.
  • رؤية الذاكرة — عندما يُحرِّر خيط المراقِب، تُدفَع جميع الكتابات التي أجراها إلى الذاكرة الرئيسية. وعندما يكتسب خيط آخر نفس المراقِب لاحقًا، يرى تلك الكتابات. هذا يجعل synchronized حدًّا من نوع happens-before.
مراقِب واحد لكل كائن. المراقِب مرتبط بالكائن (أو، بالنسبة للأعضاء الساكنة، بكائن Class). طريقتان synchronized على نفس النسخة تشتركان في المراقِب ذاته وبالتالي لا يمكن تشغيلهما بالتزامن. أما طريقتان على نسختين مختلفتين فلهما مراقِبان منفصلان ويمكنهما التشغيل بالتزامن.

الدوال المزامَنة (Synchronized Methods)

أضف synchronized إلى تعريف الدالة وستكتسب JVM تلقائيًا مراقِب الكائن this عند الدخول وتُحرِّره عند الخروج — سواء أعادت الدالة قيمة بشكل طبيعي أو رمت استثناءً.

public class SafeCounter { private int count = 0; public synchronized void increment() { count++; // القراءة-التعديل-الكتابة أصبحت الآن ذرية } public synchronized int getCount() { return count; // القراءة أيضًا تحتاج إلى مزامنة } }

بدون synchronized، يُترجَم count++ إلى ثلاث تعليمات بايتكود (قراءة، جمع، كتابة). تبديل السياق بين أي اثنتين منها في خيط مختلف يتسبب في ضياع تحديث — حالة التسابق الكلاسيكية. جعل الدالتين synchronized يعني أن خيطًا واحدًا فقط يمكنه أن يكون داخل أي منهما في أي وقت.

لا تنسَ دالة القراءة. خطأ شائع هو مزامنة دالة الكتابة فقط. إذا عملت دالة القراءة بدون مزامنة، فـ JVM حرة في تخزين القيمة مؤقتًا في سجل ولا تعيد قراءتها من الذاكرة الرئيسية أبدًا. يرى الخيط قيمة قديمة حتى لو كان الكاتب قد حدّثها بالفعل. يجب أيضًا مزامنة قراءة الحالة القابلة للتعديل المشتركة.

الكتل المزامَنة: دقة أعلى

تقفل الدالة المزامَنة على this طوال فترة تشغيلها. أما الكتلة المزامَنة فتتيح لك اختيار الكائن الذي تقفل عليه ومقدار الكود الذي تحميه:

public class BankAccount { private final Object balanceLock = new Object(); // كائن قفل مخصص private double balance; private String lastAuditNote; // حالة غير مرتبطة — لا قفل مطلوب public void deposit(double amount) { // التحقق المكلف — يعمل دون حيازة أي قفل if (amount <= 0) throw new IllegalArgumentException("Must be positive"); synchronized (balanceLock) { balance += amount; // هذا السطر فقط يحتاج القفل } } public double getBalance() { synchronized (balanceLock) { return balance; } } public void updateAuditNote(String note) { // لا توجد حالة مشتركة قابلة للتعديل هنا — لا قفل مطلوب this.lastAuditNote = note; } }

بالقفل على Object خاص ونهائي بدلًا من this، تجعل القفل غير مرئي للمستدعين. إذا قفلت على this، يمكن للكود الخارجي أيضًا المزامنة على نفس الكائن وهذا قد يتسبب في تنازع غير متوقع أو توقف تام.

فضّل كائنات القفل الخاصة والنهائية. استخدم private final Object lock = new Object(); بدلًا من القفل على this أو الفئة. يمنع ذلك تدخل المستدعين ويجعل استراتيجية القفل صريحة وواضحة.

الدوال الساكنة المزامَنة

عندما تكون الدالة static وsynchronized في آنٍ واحد، يكون المراقِب المستخدَم هو كائن Class، لا أي نسخة. هذا القفل مشترك بين جميع نسخ الفئة.

public class IdGenerator { private static long nextId = 0; public static synchronized long generate() { return ++nextId; } }

القفل على كائن Class أشمل من القفل على نسخة. احرص ألا تخلط بين المزامنة الساكنة ومزامنة النسخة إذا كانتا تحميان نفس الحالة — فهما يستخدمان مراقِبين مختلفين ولا يوفران استبعادًا متبادلًا بينهما.

إعادة الدخول (Reentrancy)

مراقِبات Java قابلة لإعادة الدخول: الخيط الذي يمتلك بالفعل قفلًا يمكنه اكتسابه مجددًا دون أن يُحجَب. هذا يمنع الخيط من توقيف نفسه عندما تستدعي دالة مزامَنة دالة مزامَنة أخرى على نفس الكائن.

public class Node { private int value; public synchronized void set(int v) { value = v; audit(); // تستدعي دالة synchronized أخرى على نفس الكائن } public synchronized void audit() { System.out.println("value is now " + value); } }

عندما تستدعي set الدالةَ audit، يُعيد نفس الخيط دخول المراقِب الذي يمتلكه بالفعل. تحتفظ JVM بعداد دخول لكل خيط؛ يُحرَّر القفل فقط عندما يصل العداد إلى الصفر.

الأداء والمقايضات

كل نقطة دخول synchronized هي اختناق محتمل: جميع الخيوط تتنافس على نفس القفل وخيط واحد فقط يتقدم في كل مرة. فكر في هذه الاستراتيجيات لتقليل التنازع:

  • اجعل الأقسام الحرجة قصيرة. انقل عمليات الإدخال/الإخراج أو استدعاءات الشبكة أو الحسابات الثقيلة خارج الكتلة المزامَنة.
  • استخدم أقفالًا منفصلة للحالات المستقلة. إذا لم يُتاح وصول إلى حقلين معًا في أي وقت، فاحمِهما بكائني قفل مختلفين ليتوازى الخيوط في العمل عليهما.
  • فكّر في قفل القراءة/الكتابة. يسمح ReentrantReadWriteLock بقراءة متزامنة لعدد كبير من الخيوط حين لا يوجد كاتب — مكسب كبير في الأعباء الكثيفة القراءة.
  • فكّر في المتغيرات الذرية. للعدادات المتغيرة الواحدة، تقدم AtomicLong أو LongAdder إنتاجية غير محجوبة تفوق synchronized.
تحسينات القفل في JVM الحديثة. تُطبّق HotSpot JVM تحسينات تلقائية مثل التحيز في القفل والأقفال الرفيعة وتكثيف القفل. في الواقع العملي، كتل synchronized غير المتنازع عليها رخيصة جدًا — في الغالب بضع نانوثانية فقط. التنازع هو التكلفة الحقيقية؛ قللها بتضييق الأقسام الحرجة وتقسيم الأقفال.

الخلاصة

الكلمة المفتاحية synchronized هي الأداة الأساسية للاستبعاد المتبادل في Java. كل كائن له مراقِب؛ اكتسابه يضمن الاستبعاد ورؤية الذاكرة معًا. تقفل الدوال المزامَنة على this؛ أما الكتل المزامَنة فتتيح لك اختيار كائن القفل والنطاق الدقيق. فضّل كائنات القفل الخاصة، واجعل الأقسام الحرجة ضيقة قدر الإمكان، وتذكر أن كل قراءة مزامَنة ضرورية مثلما كل كتابة مزامَنة ضرورية. في الدرس التالي سنتناول volatile — أداة أخف تمنح الرؤية دون الاستبعاد.