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

المتغيرات الذرية

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

المتغيرات الذرية

في الدروس السابقة رأيت كيف أن عداد int بسيط مشتركًا بين الخيوط يُنتج نتائج خاطئة لأن تسلسل القراءة-التعديل-الكتابة ليس عملية ذرية. رأيت أيضًا أن synchronized يحل المشكلة، لكنه يفرض ثمنًا: يُجبر جميع الخيوط على المرور تسلسليًا عبر قفل واحد، مما قد يتحول إلى عنق زجاجة عند ازدحام الخيوط.

تُقدّم Java حلًا وسطًا من خلال حزمة java.util.concurrent.atomic: متغيرات آمنة للخيوط خالية من الأقفال، تعتمد على تعليمات ذرية على مستوى المعالج — تحديدًا عملية المقارنة والتعيين (CAS) — بدلًا من mutex. يغطي هذا الدرس أهم أعضاء تلك الحزمة ومتى ولماذا تلجأ إليها.

عملية المقارنة والتعيين (CAS)

CAS هي تعليمة واحدة في المعالج تؤدي ثلاثة أشياء بشكل ذري:

  1. قراءة القيمة الحالية لموقع في الذاكرة.
  2. مقارنتها بقيمة متوقعة.
  3. إن تطابقتا، كتابة قيمة جديدة؛ وإلا لا تفعل شيئًا.

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

CAS مقابل القفل — القفل يُجبر الخيط الخاسر على الانتظار (التنازل عن المعالج). CAS يُجبر الخيط الخاسر على إعادة المحاولة (الدوران). للعمليات القصيرة جدًا، الدوران أسرع لأنه يتجنب تكلفة تبديل السياق. للعمليات الطويلة، الدوران يُهدر دورات المعالج؛ والقفل أفضل.

AtomicInteger — العمود الفقري

تُغلّف AtomicInteger قيمة int وتُتيح كل عملية تعديل شائعة كعملية ذرية. أهم الدوال:

  • get() / set(int) — قراءة أو كتابة مع رؤية كاملة للذاكرة.
  • incrementAndGet() — تضيف 1 ذريًا وتُعيد القيمة الجديدة.
  • getAndIncrement() — تضيف 1 ذريًا وتُعيد القيمة القديمة (مثل i++).
  • addAndGet(int delta) — تضيف delta ذريًا وتُعيد القيمة الجديدة.
  • compareAndSet(int expected, int update) — تُنفّذ CAS الخام؛ تُعيد true عند النجاح.
  • updateAndGet(IntUnaryOperator) — تُطبّق تعبيرًا لامدا ذريًا (Java 8+).

إليك نفس العداد الذي رأيته يتعطل سابقًا، مكتوبًا الآن بـ AtomicInteger:

import java.util.concurrent.atomic.AtomicInteger; public class AtomicCounter { private final AtomicInteger count = new AtomicInteger(0); public void increment() { count.incrementAndGet(); // خطوة ذرية واحدة — لا حاجة لقفل } public int get() { return count.get(); } public static void main(String[] args) throws InterruptedException { AtomicCounter counter = new AtomicCounter(); Thread t1 = new Thread(() -> { for (int i = 0; i < 100_000; i++) counter.increment(); }); Thread t2 = new Thread(() -> { for (int i = 0; i < 100_000; i++) counter.increment(); }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println("Final count: " + counter.get()); // دائمًا 200000 } }

لا synchronized، لا Lock، ومع ذلك النتيجة دائمًا 200 000 بالضبط.

حلقة CAS اليدوية باستخدام compareAndSet

تُعدّ compareAndSet اللبنة الأساسية لمنطق أكثر تعقيدًا خالٍ من الأقفال. لنفترض أنك تريد تحديد سقف للعداد بشكل ذري:

import java.util.concurrent.atomic.AtomicInteger; public class BoundedCounter { private final AtomicInteger value = new AtomicInteger(0); private final int max; public BoundedCounter(int max) { this.max = max; } /** * يزيد العداد فقط إن كانت القيمة الحالية أقل من الحد الأقصى. * يُعيد true عند تطبيق الزيادة. */ public boolean tryIncrement() { while (true) { int current = value.get(); if (current >= max) return false; // CAS: اكتب فقط إذا كانت القيمة لا تزال 'current' if (value.compareAndSet(current, current + 1)) return true; // إن خسر CAS السباق، كرّر الحلقة واقرأ القيمة الجديدة } } public int get() { return value.get(); } }
نمط الدوران وإعادة المحاولة هو جوهر كل خوارزمية خالية من الأقفال: اقرأ القيمة الحالية، احسب القيمة الجديدة المطلوبة، استدعِ compareAndSet. إن فشل CAS، فخيط آخر غيّر القيمة بين قراءتك وكتابتك، فابدأ من جديد. للعمليات الحسابية على الذاكرة، نادرًا ما تعمل حلقة إعادة المحاولة أكثر من مرة في ظل ازدحام واقعي.

بقية عائلة المتغيرات الذرية

تحتوي الحزمة على نظائر لأنواع بدائية أخرى وللمراجع:

  • AtomicLong — نفس واجهة AtomicInteger لكن لنوع long.
  • AtomicBoolean — مفيدة لعلامة تقرأها خيوط كثيرة لكن لا يجب أن يُقلبها إلا خيط واحد.
  • AtomicReference<V> — CAS على مرجع كائن؛ مثالية لتبديل لقطة غير قابلة للتغيير بشكل ذري.
  • AtomicIntegerArray، AtomicLongArray، AtomicReferenceArray<E> — وصول ذري لكل عنصر داخل مصفوفة.

تُستخدم AtomicBoolean شائعًا كعلامة تُنفَّذ مرة واحدة فقط:

import java.util.concurrent.atomic.AtomicBoolean; public class OneTimeAction { private final AtomicBoolean fired = new AtomicBoolean(false); public void runOnce(Runnable action) { // compareAndSet(false, true) ينجح فقط للخيط الأول الذي يستدعيها if (fired.compareAndSet(false, true)) { action.run(); } } }

LongAdder — للعدادات عالية الإنتاجية

تحت ازدحام شديد، تتسبب الخيوط الكثيرة المتنافسة على AtomicLong واحدة في ظاهرة ارتداد سطر الكاش — كل CAS ناجح يُبطل سطر الكاش في كل نواة معالج أخرى، مما يُجبر الخيوط على إعادة المحاولة. قدّمت Java 8 الصنف LongAdderDoubleAdder) لحل هذا: يحتفظ داخليًا بـ خلية لكل خيط ويجمعها فقط عند استدعاء sum(). الكتابات لا تتنافس أبدًا؛ القراءات تدفع تكلفة تجميع صغيرة.

import java.util.concurrent.atomic.LongAdder; LongAdder hits = new LongAdder(); // تُستدعى من آلاف الخيوط — تنافس يكاد يكون صفرًا hits.increment(); // تُستدعى بشكل غير متكرر لقراءة المقياس long total = hits.sum();
LongAdder ليست بديلًا مباشرًا لـ AtomicLong. لا تدعم compareAndSet، وsum() لا تُعطي لقطة متسقة إن كانت الزيادات لا تزال تحدث بشكل متزامن. استخدم LongAdder حين تحتاج مجرد مجموع متراكم، واستخدم AtomicLong حين تحتاج القراءة والتحديث الشرطي في خطوة واحدة.

AtomicReference ومشكلة ABA

لـ CAS على المراجع مشكلة خفية: إن تغيّر مرجع من A إلى B ثم عاد إلى A، فسينجح CAS الذي يتوقع A رغم أن الكائن استُبدل في المنتصف. هذه هي مشكلة ABA. الحل هو AtomicStampedReference<V>، الذي يقرن المرجع بـ ختم صحيح (عداد إصدار) — يجب أن يتطابق المرجع والختم معًا لكي ينجح CAS.

import java.util.concurrent.atomic.AtomicStampedReference; AtomicStampedReference<String> ref = new AtomicStampedReference<>("initial", 0); int[] stampHolder = new int[1]; String current = ref.get(stampHolder); // يملأ stampHolder[0] بالختم الحالي // ينجح CAS فقط حين يتطابق كل من المرجع والختم boolean ok = ref.compareAndSet( current, "updated", stampHolder[0], stampHolder[0] + 1);

متى تختار المتغيرات الذرية بدلًا من synchronized

  • استخدم المتغيرات الذرية حين تكون العملية المحمية تحديثًا حسابيًا أو تحديث مرجع واحد. إنها أبسط وأسرع تحت ازدحام منخفض إلى متوسط.
  • استخدم synchronized أو ReentrantLock حين تحتاج إلى حماية تسلسل متعدد الخطوات كوحدة واحدة (مثلًا، التحقق من شرط وتحديث حقلين معًا).
  • استخدم LongAdder حين لديك عداد زيادة بحت تحت حمل متوازٍ شديد ولا تحتاج دلالات CAS.

الخلاصة

تستبدل المتغيرات الذرية الأقفال لتحديثات المتغير الواحد باستخدام تعليمة CAS على مستوى المعالج. تُغطي AtomicInteger وAtomicLong العمليات الحسابية؛ تتعامل AtomicBoolean مع العلامات؛ تبدّل AtomicReference مؤشرات الكائنات بأمان. حلقة الدوران وإعادة المحاولة — اقرأ، احسب، compareAndSet، كرّر عند الفشل — هي النمط الشامل لبناء منطق خالٍ من الأقفال. للعدادات ذات الحجم الكبير، تذهب LongAdder أبعد بإزالة التنافس من خلال خلايا لكل خيط. اختر الأداة المناسبة لحجم العملية: متغيرات ذرية للمنطق أحادي المتغير، وأقفال للقسم الحرج متعدد الخطوات.