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

مشروع: عداد آمن للخيوط

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

مشروع: عداد آمن للخيوط

على مدار هذا الفصل الدراسي استعرضت كل أداة أساسية في عالم التزامن: الخيوط، والمزامنة، وكلمة volatile، والمتغيرات الذرية، وأوامر wait/notify، والأقفال المتعثرة. يربط هذا الدرس الختامي كل شيء معًا من خلال بناء عداد آمن للخيوط صحيح وجاهز للإنتاج — نبدأ بنسخة مكسورة ساذجة، ثم نتطور عبر تصاميم متعاقبة أفضل، مقارنين المقايضات في كل مرحلة، حتى نصل إلى الاختيار الصحيح لكل سياق.

لماذا العداد مثال دراسي مثالي؟

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

النسخة الأولى: العداد المعطوب

ابدأ بالنهج الواضح والخاطئ حتى ترى بدقة ما يكسر:

public class BrokenCounter { private int count = 0; public void increment() { count++; // ليست عملية ذرية: قراءة، إضافة، كتابة — 3 عمليات منفصلة } public int get() { return count; } }

تُترجَم عبارة count++ إلى ثلاث تعليمات في رمز البايت: اقرأ القيمة الحالية، أضف 1، اكتب النتيجة. يمكن لخيطين أن يقرآ نفس القيمة القديمة معًا، ويحسبا نفس النتيجة، ثم يكتبانها — مما يُضيّع تحديثًا واحدًا. نفّذها مع 10 خيوط تُنفّذ كل منها 100 000 عملية زيادة، وستجد النتيجة النهائية نادرًا ما تساوي 1 000 000.

لا تثق في "عادةً ما يعمل." شروط التسابق احتمالية. على جهاز بنواة واحدة أو تحت حِمل خفيف، قد يبدو العداد المعطوب صحيحًا لأشهر، ثم يفشل بشكل مدوٍّ في الإنتاج على خادم حديث متعدد الأنوية.

النسخة الثانية: synchronized — صحيحة لكن خشنة

ضع كلمة synchronized على كلا التابعين باستخدام نفس المراقب:

public class SynchronizedCounter { private int count = 0; public synchronized void increment() { count++; } public synchronized int get() { return count; } public synchronized void reset() { count = 0; } }

هذا التنفيذ صحيح. يضمن القفل الداخلي على this الاستبعاد المتبادل ورؤية مُتسقة للذاكرة عبر علاقة الحدوث-قبل. كما أنه الكود الأوضح — أي قارئ يفهم العقد فورًا.

التكلفة هي التنافس: كل استدعاء لـincrement() يحجب كل الخيوط الأخرى، حتى حين تكون القراءات أكثر بكثير من الكتابات. لعداد عام بسيط هذا مقبول. أما لعداد عالي الإنتاجية مشترك بين مئات الخيوط فقد يُصبح نقطة اختناق.

النسخة الثالثة: AtomicInteger — صحيحة وسريعة

استبدل حقل int بـAtomicInteger من java.util.concurrent.atomic:

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 void reset() { count.set(0); } // زيادة مشروطة: زِد فقط إن كانت القيمة دون حد معين public boolean incrementIfBelow(int limit) { int current; do { current = count.get(); if (current >= limit) return false; } while (!count.compareAndSet(current, current + 1)); return true; } }

يستخدم AtomicInteger تعليمة CPU تُسمى Compare-And-Swap (CAS) بدلًا من mutex. تحاول CAS تحديث القيمة فقط إذا كانت لا تزال تساوي القيمة المتوقعة؛ فإن غيّرها خيط آخر أولًا، تفشل CAS وتُعيد الحلقة المحاولة. لا يوجد حجب، ولا تبديل سياق، ولا خيط "متوقف" ينتظر قفلًا. تحت التزامن العالي هذا أسرع بكثير من synchronized.

CAS متفائلة. تفترض أن التنافس نادر وتُعيد المحاولة عند الفشل. أما synchronized فمتشائمة — تحجب الجميع مسبقًا. للأقسام الحرجة القصيرة مع خيوط كثيرة، تفوز CAS المتفائلة. للأقسام الحرجة الطويلة، قد يكون القفل المتشائم أفضل لأن حلقات CAS تُهدر وقت المعالج.

النسخة الرابعة: LongAdder — أقصى إنتاجية

حين تحتاج فقط المجموع النهائي ولا تحتاج لقراءة متسقة أثناء العد (كالمقاييس وعدادات الزيارات ومحددات المعدل)، يكون LongAdder أسرع:

import java.util.concurrent.atomic.LongAdder; public class LongAdderCounter { private final LongAdder adder = new LongAdder(); public void increment() { adder.increment(); } public long get() { return adder.sum(); // تقريبي إذا جرت الزيادات بشكل متزامن } public void reset() { adder.reset(); } }

يحتفظ LongAdder بمصفوفة خلايا — يُحدّث كل خيط معالج خليته الخاصة عادةً، مما يُقلّل التنافس على CAS إلى ما يقارب الصفر. يجمع sum() كل الخلايا معًا. المقايضة: sum() ليست ذرية نسبةً لاستدعاءات increment() المتزامنة، لذا قد تقرأ مجموعًا قديمًا بعض الشيء. للعدادات التي تكفي فيها الصحة النهائية (عدد مشاهدات الصفحة، مقاييس الإنتاجية) هذا هو الخيار الصحيح.

النسخة الخامسة: فئة حساب مصرفي آمنة للخيوط

تحتاج الأنظمة الحقيقية لثوابت أغنى. فيما يلي حساب مصرفي يمنع الأرصدة السالبة باستخدام synchronized مع عمليات مركبة:

public class BankAccount { private final String owner; private long balanceCents; // نخزّن بالسنت لتجنب مشاكل الفاصلة العائمة public BankAccount(String owner, long initialBalanceCents) { this.owner = owner; this.balanceCents = initialBalanceCents; } public synchronized void deposit(long cents) { if (cents <= 0) throw new IllegalArgumentException("يجب أن يكون الإيداع موجبًا"); balanceCents += cents; } public synchronized boolean withdraw(long cents) { if (cents <= 0) throw new IllegalArgumentException("يجب أن يكون السحب موجبًا"); if (balanceCents < cents) return false; // رصيد غير كافٍ balanceCents -= cents; return true; } public synchronized long getBalance() { return balanceCents; } // يجب أن يقفل التحويل كلا الحسابين — لكن بترتيب ثابت لمنع التعثر public static void transfer(BankAccount from, BankAccount to, long cents) { // حدّد ترتيب القفل عبر رمز الهوية لمنع الأقفال المتعثرة BankAccount first = System.identityHashCode(from) <= System.identityHashCode(to) ? from : to; BankAccount second = first == from ? to : from; synchronized (first) { synchronized (second) { if (!from.withdraw(cents)) { throw new IllegalStateException("رصيد غير كافٍ للتحويل"); } to.deposit(cents); } } } }
ترتيب القفل يمنع التعثر. خيط A يقفل حساب-1 ثم حساب-2، بينما خيط B يقفل حساب-2 ثم حساب-1 — هذا تعثر كلاسيكي. بالاستحواذ على الأقفال دائمًا بترتيب ثابت (هنا بحسب رمز الهوية) يتفق كلا الخيطين على التسلسل ويستحيل التعثر.

اختيار التنفيذ المناسب

  • عداد بسيط قليل التنافس: توابع synchronized — أوضح كود وأسهل تفكيرًا.
  • زيادة/نقصان عالي التنافس مع الحاجة لقراءات متسقة: AtomicInteger / AtomicLong — غير محجوب وسريع.
  • تراكم صافٍ والقراءات التقريبية مقبولة: LongAdder — أعلى إنتاجية للمسارات الساخنة كالمقاييس.
  • ثوابت مركبة (تحقق-ثم-تصرف، تحديثات متعددة الحقول): كتل synchronized — الذرية تمتد عبر حقول/فحوصات متعددة لا تستطيع المتغيرات الذرية التعبير عنها.

ربط كل شيء معًا: عرض قابل للتشغيل

import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; public class CounterDemo { public static void main(String[] args) throws InterruptedException { final int THREADS = 10; final int OPS_EACH = 100_000; AtomicInteger atomicCounter = new AtomicInteger(0); ExecutorService pool = Executors.newFixedThreadPool(THREADS); for (int i = 0; i < THREADS; i++) { pool.submit(() -> { for (int j = 0; j < OPS_EACH; j++) { atomicCounter.incrementAndGet(); } }); } pool.shutdown(); pool.awaitTermination(30, TimeUnit.SECONDS); System.out.println("المتوقع : " + (THREADS * OPS_EACH)); System.out.println("الفعلي : " + atomicCounter.get()); // يطبع دائمًا: المتوقع: 1000000 / الفعلي: 1000000 } }

الخلاصة

العداد الآمن للخيوط هو تجسيد مصغّر لكل برمجة تزامنية: عليك تحديد الحالة المتغيرة المشتركة، واختيار استراتيجية المزامنة الصحيحة، والتفكير في العمليات المركبة. أصبح بحوزتك الآن أربع أدوات — synchronized، وAtomicInteger، وLongAdder، والقفل متعدد الكائنات المرتّب — لكل منها حالة استخدام واضحة. اختر الأبسط التي تلبي متطلبات الصحة والأداء، ووثّق الثوابت في الكود، وستكتب Java متزامنة صحيحة وسريعة معًا.