الأنظمة الموزعة والموثوقية

التزامن والخيوط المتعددة

5 دقيقة للقراءة

أخطاء التزامن من أصعب الأخطاء في إعادة الإنتاج والتصحيح. المحاورون يحبونها لأنها تكشف ما إذا كنت تفهم حقًا الحالة المشتركة والتنسيق والضمانات التي يوفرها وقت تشغيل لغتك. يغطي هذا الدرس الفئات الثلاث لمشاكل التزامن واستراتيجيات القفل والأنماط الخاصة بكل لغة لـ Go و Java و Python.

الفئات الثلاث لمشاكل التزامن

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

كل سؤال مقابلة عن التزامن يندرج تحت فئة واحدة (أو أكثر) من هذه الفئات. تحديد الفئة فورًا يُضيّق نطاق حلولك.

المزالج (Mutexes) والأقفال

المزلاج (الاستبعاد المتبادل) يضمن أن خيطًا واحدًا فقط يصل إلى القسم الحرج في وقت واحد.

// Go: sync.Mutex
var mu sync.Mutex
var balance int

func Deposit(amount int) {
    mu.Lock()
    defer mu.Unlock()  // فك القفل دائمًا، حتى لو حدث panic
    balance += amount
}

func Balance() int {
    mu.Lock()
    defer mu.Unlock()
    return balance
}
// Java: ReentrantLock (مُفضّل على synchronized لـ tryLock والعدالة)
private final ReentrantLock lock = new ReentrantLock();
private int balance = 0;

public void deposit(int amount) {
    lock.lock();
    try {
        balance += amount;
    } finally {
        lock.unlock();  // دائمًا في كتلة finally
    }
}
# Python: threading.Lock
import threading

lock = threading.Lock()
balance = 0

def deposit(amount: int) -> None:
    with lock:  # مدير السياق يتعامل مع الاكتساب/الإصدار
        global balance
        balance += amount

أقفال القراءة-الكتابة

عندما تفوق القراءات الكتابات بكثير، قفل القراءة-الكتابة يسمح بـ قراءات متزامنة لكن كتابات حصرية:

// Go: sync.RWMutex
var rwmu sync.RWMutex
var config map[string]string

func GetConfig(key string) string {
    rwmu.RLock()         // عدة goroutines يمكنها حمل RLock
    defer rwmu.RUnlock()
    return config[key]
}

func SetConfig(key, value string) {
    rwmu.Lock()          // حصري — يحجب جميع القراء والكتّاب
    defer rwmu.Unlock()
    config[key] = value
}

متى تستخدم: أقفال القراءة-الكتابة تفيد عندما تكون نسبة القراءة للكتابة 10:1 أو أعلى. دون ذلك، العبء الإضافي لإدارة نوعي قفل لا يستحق — استخدم مزلاجًا بسيطًا.

أنماط حالات السباق

التحقق ثم التنفيذ (TOCTOU)

خطأ وقت التحقق إلى وقت الاستخدام: تتحقق من شرط ثم تتصرف بناءً عليه، لكن الشرط يتغير بين التحقق والتنفيذ.

# معطّل: حالة سباق بين التحقق والتنفيذ
def withdraw(amount: int) -> bool:
    if balance >= amount:    # الخيط A يتحقق: balance=100, amount=80
        # الخيط B ينفذ withdraw(50) هنا — balance يصبح 50
        balance -= amount    # الخيط A يطرح 80 من 50 = -30!
        return True
    return False

# الإصلاح: حافظ على القفل عبر التحقق والتنفيذ معًا
def withdraw(amount: int) -> bool:
    with lock:
        if balance >= amount:
            balance -= amount
            return True
        return False

القراءة-التعديل-الكتابة

عدة خيوط تقرأ قيمة وتعدلها ثم تكتبها — تحديثات مفقودة.

// معطّل: counter++ هي فعليًا ثلاث عمليات (قراءة، زيادة، كتابة)
private int counter = 0;
public void increment() {
    counter++;  // ليست ذرية! الخيط A يقرأ 5، الخيط B يقرأ 5،
                // كلاهما يكتب 6 — زيادة واحدة مفقودة
}

// الإصلاح: استخدم AtomicInteger مع CAS (المقارنة والتبديل)
private final AtomicInteger counter = new AtomicInteger(0);
public void increment() {
    counter.incrementAndGet();  // عملية CAS ذرية في حلقة
}

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

CAS هي البدائية المادية خلف البرمجة بدون أقفال:

CAS(موقع_الذاكرة, القيمة_المتوقعة, القيمة_الجديدة):
    ذريًا:
        إذا *موقع_الذاكرة == القيمة_المتوقعة:
            *موقع_الذاكرة = القيمة_الجديدة
            أرجع true
        وإلا:
            أرجع false  // خيط آخر عدّلها — أعد المحاولة

مشكلة ABA: خيط يقرأ القيمة A، خيط آخر يغيرها إلى B ثم يعيدها إلى A، وعملية CAS للخيط الأول تنجح رغم أن القيمة عُدّلت. الحل: المؤشرات الموسومة (إلحاق عداد إصدار بالقيمة).

الجمود (Deadlock)

الجمود يحدث عندما ينتظر خيطان أو أكثر بعضهم البعض لتحرير الأقفال، ولا أحد يستطيع المتابعة.

الشروط الأربعة الضرورية (Coffman, 1971)

جميع الشروط الأربعة يجب أن تتحقق معًا لحدوث الجمود:

  1. الاستبعاد المتبادل: الموارد لا يمكن مشاركتها
  2. الحمل والانتظار: خيط يحمل قفلاً أثناء انتظار آخر
  3. عدم الاستباق: لا يمكن أخذ الأقفال بالقوة من خيط
  4. الانتظار الدائري: الخيط A ينتظر B، وB ينتظر A
  مشكلة الفلاسفة المتناولين:

  الفيلسوف 0         الفيلسوف 1
       │                    │
       ▼                    ▼
    [شوكة 0] ◄──────── [شوكة 1]
       │                    │
       │    الفيلسوف 2      │
       │         │          │
       └────► [شوكة 2] ◄───┘

  كل فيلسوف يلتقط الشوكة اليسرى، ثم يحاول اليمنى.
  إذا التقط الجميع الشوكة اليسرى في وقت واحد ← جمود.
  لا أحد يستطيع الحصول على شوكته اليمنى لأن جاره يحملها.

استراتيجيات المنع

الاستراتيجية يكسر أي شرط التنفيذ
ترتيب الأقفال الانتظار الدائري اكتسب الأقفال دائمًا بترتيب عالمي ثابت (مثلاً، حسب معرف المورد)
المهلة الحمل والانتظار استخدم tryLock(timeout) — حرّر جميع الأقفال إذا انتهت المهلة
محاولة القفل الحمل والانتظار tryLock() تُرجع false إذا القفل غير متوفر — تراجع وأعد المحاولة
قفل واحد الحمل والانتظار استخدم قفلاً خشنًا واحدًا بدل عدة أقفال دقيقة
// Go: ترتيب الأقفال لمنع الجمود
func Transfer(from, to *Account, amount int) {
    // اقفل الحساب ذو المعرف الأصغر دائمًا أولاً
    first, second := from, to
    if from.ID > to.ID {
        first, second = to, from
    }
    first.mu.Lock()
    defer first.mu.Unlock()
    second.mu.Lock()
    defer second.mu.Unlock()

    from.Balance -= amount
    to.Balance += amount
}

اكتشاف الجمود

ابنِ رسم بيان الانتظار: العقد هي الخيوط، والحواف تشير من خيط منتظر إلى الخيط الحامل للقفل المطلوب. دورة في هذا الرسم البياني تعني جمودًا.

  رسم بيان الانتظار:

  الخيط A ──ينتظر──► الخيط B
     ▲                    │
     │                    │
     └────ينتظر───────────┘

  دورة مكتشفة ← جمود!

القفل المتفائل مقابل المتشائم

القفل المتشائم

اقفل المورد قبل القراءة. لا يمكن لأي معاملة أخرى تعديله حتى تنتهي.

-- متشائم: اقفل الصف قبل القراءة
BEGIN;
SELECT balance FROM accounts WHERE id = 42 FOR UPDATE;
-- الصف مقفل الآن — المعاملات الأخرى تنتظر هنا
UPDATE accounts SET balance = balance - 100 WHERE id = 42;
COMMIT;

القفل المتفائل

اقرأ بدون قفل، تحقق من التعارضات عند الكتابة باستخدام عمود الإصدار.

-- متفائل: اقرأ الإصدار، تحقق عند الكتابة
SELECT balance, version FROM accounts WHERE id = 42;
-- balance=500, version=3

-- لاحقًا، حاول التحديث:
UPDATE accounts
SET balance = 400, version = 4
WHERE id = 42 AND version = 3;
-- إذا 0 صفوف تأثرت ← الإصدار تغير ← تعارض! أعد المحاولة.

متى تستخدم كل منهما

السيناريو الخيار الأفضل لماذا
تنافس عالٍ (كتّاب كثر على نفس الصف) متشائم إعادة المحاولة مكلفة عندما تكون التعارضات متكررة
تنافس منخفض (تعارضات نادرة) متفائل بدون عبء قفل؛ التعارضات نادرة فإعادة المحاولة رخيصة
معاملات طويلة متشائم الاحتفاظ بإصدار لدقائق يزيد احتمال التعارض
معاملات قصيرة وسريعة متفائل نافذة صغيرة للتعارضات
أحمال كثيفة القراءة متفائل بدون أقفال على القراءات — أقصى إنتاجية

هياكل البيانات بدون أقفال

الخوارزميات بدون أقفال تضمن أن خيطًا واحدًا على الأقل يحقق تقدمًا في عدد محدود من الخطوات (حتى لو توقفت خيوط أخرى). تستخدم عمليات CAS بدلاً من الأقفال.

أمثلة شائعة:

  • العدادات الذرية: AtomicInteger.incrementAndGet() في Java، atomic.AddInt64() في Go
  • الطوابير بدون أقفال: طابور Michael-Scott (مستخدم في ConcurrentLinkedQueue في Java)
  • خرائط التجزئة بدون أقفال: مقسمة لأجزاء مع عمليات CAS مستقلة (ConcurrentHashMap في Java)

التزامن الخاص بكل لغة

Go: الغوروتينات والقنوات

نموذج التزامن في Go مبني على CSP (العمليات المتسلسلة المتواصلة): شارك الذاكرة بالتواصل، وليس بالحالة المشتركة.

func FanOut(urls []string) []Result {
    ch := make(chan Result, len(urls)) // قناة مؤقتة
    var wg sync.WaitGroup

    for _, url := range urls {
        wg.Add(1)
        go func(u string) {   // goroutine: خفيفة (~4 كيلوبايت مكدس)
            defer wg.Done()
            ch <- fetch(u)
        }(url)
    }

    // أغلق القناة عند اكتمال جميع الغوروتينات
    go func() {
        wg.Wait()
        close(ch)
    }()

    var results []Result
    for r := range ch {   // كرر على القناة حتى تُغلق
        results = append(results, r)
    }
    return results
}

البدائيات الرئيسية:

  • go func(): تُنشئ goroutine (~4 كيلوبايت مكدس، تنمو حسب الحاجة، ملايين ممكنة)
  • chan T: قناة مُنمطة للتواصل بين الغوروتينات
  • select: تعدد الإرسال عبر عدة قنوات (مثل epoll للقنوات)
  • sync.WaitGroup: ينتظر مجموعة من الغوروتينات لتنتهي
  • errgroup.Group: مثل WaitGroup لكن ينقل أول خطأ

Java: الخيوط الافتراضية (Java 21+)

Java 21 قدمت الخيوط الافتراضية — خيوط خفيفة يديرها JVM وليس نظام التشغيل.

// خيوط افتراضية: ملايين ممكنة (بخلاف خيوط المنصة ~1 ميغابايت لكل واحدة)
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    List<Future<String>> futures = urls.stream()
        .map(url -> executor.submit(() -> fetch(url)))
        .toList();

    List<String> results = new ArrayList<>();
    for (var future : futures) {
        results.add(future.get());  // يحجب الخيط الافتراضي، لا خيط OS
    }
}

// CompletableFuture: عمليات غير متزامنة قابلة للتركيب
CompletableFuture<String> future = CompletableFuture
    .supplyAsync(() -> fetchUser(userId))
    .thenApply(user -> enrichProfile(user))
    .thenApply(profile -> serialize(profile))
    .exceptionally(ex -> fallbackResponse(ex));

البدائيات الرئيسية:

  • الخيوط الافتراضية: Thread.ofVirtual().start(runnable) أو Executors.newVirtualThreadPerTaskExecutor()
  • CompletableFuture: غير متزامن قابل للتركيب مع thenApply وthenCompose وallOf
  • ConcurrentHashMap: خريطة تجزئة آمنة للخيوط بقفل على مستوى الأجزاء
  • synchronized مقابل ReentrantLock: ReentrantLock يضيف tryLock() والعدالة ومتغيرات Condition

Python: قفل GIL والحلول البديلة

قفل المفسر العالمي (GIL) يسمح لخيط واحد فقط بتنفيذ بايتكود Python في وقت واحد. هذا يعني أن الخيوط لا توفر توازيًا للعمل المقيد بالمعالج.

import asyncio
import concurrent.futures

# مقيد بالإدخال/الإخراج: استخدم asyncio (تعدد مهام تعاوني، خيط واحد)
async def fetch_all(urls: list[str]) -> list[str]:
    async with aiohttp.ClientSession() as session:
        tasks = [fetch(session, url) for url in urls]
        return await asyncio.gather(*tasks)  # إدخال/إخراج متزامن

# مقيد بالمعالج: استخدم multiprocessing (عمليات منفصلة، بدون GIL)
def process_all(items: list[dict]) -> list[dict]:
    with concurrent.futures.ProcessPoolExecutor() as executor:
        return list(executor.map(heavy_computation, items))

# مقيد بالإدخال/الإخراج: الخيوط تعمل (GIL يُحرر أثناء انتظار الإدخال/الإخراج)
def download_all(urls: list[str]) -> list[bytes]:
    with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
        return list(executor.map(download, urls))
نوع العمل أفضل نهج لماذا
مقيد بالإدخال/الإخراج (HTTP, DB) asyncio أو threading GIL يُحرر أثناء انتظار الإدخال/الإخراج
مقيد بالمعالج (حسابات) multiprocessing عمليات منفصلة تتجاوز GIL
مختلط asyncio + ProcessPoolExecutor إدخال/إخراج غير متزامن مع مجمع عمليات للعمل بالمعالج

تجميع الاتصالات (Connection Pooling)

فتح اتصال قاعدة بيانات مكلف (مصافحة TCP، TLS، المصادقة). مجمعات الاتصالات تحتفظ بمجموعة اتصالات قابلة لإعادة الاستخدام.

صيغة الحجم

الاتصالات = (أنوية_المعالج * 2) + عدد_الأقراص_الفعال
  • لأقراص SSD، عدد الأقراص الفعال عادة 1
  • خادم بـ 4 أنوية: (4 * 2) + 1 = 9 اتصالات
  • اتصالات أكثر من هذا تسبب عبء تبديل السياق وتنافسًا على قاعدة البيانات

خطأ شائع: ضبط حجم المجمع على 100+ "للأمان". هذا فعليًا يُدهور الأداء لأن قاعدة البيانات تقضي وقتًا أكثر في تبديل السياق بين الاتصالات من تنفيذ الاستعلامات. PostgreSQL يوصي باستخدام PgBouncer مع مجمع أصغر.

تكوين المجمع

# تكوين نموذجي لمجمع HikariCP (Java)
pool:
  minimum_idle: 5         # حافظ على 5 اتصالات خاملة جاهزة
  maximum_pool_size: 10   # الحد الأقصى الصارم
  connection_timeout: 30s # انتظر اتصالاً متاحاً
  idle_timeout: 600s      # أغلق الاتصالات الخاملة بعد 10 دقائق
  max_lifetime: 1800s     # استبدل الاتصالات كل 30 دقيقة

القرارات الرئيسية:

  • الحد الأدنى للخمول: اضبطه على حملك الأساسي لتجنب تأخر البداية الباردة
  • الحد الأقصى للمجمع: اضبطه باستخدام الصيغة أعلاه — قاوم الرغبة في الزيادة
  • مهلة الاتصال: افشل بسرعة (30 ثانية) بدلاً من الانتظار إلى ما لا نهاية
  • العمر الأقصى: دوّر الاتصالات لمنع الحالة القديمة واحترام مهلات جانب قاعدة البيانات

التالي: الموثوقية والمراقبة — SLOs وقواطع الدوائر والتتبع الموزع وهندسة الفوضى. :::

اختبار

اختبار الوحدة 5: الأنظمة الموزعة والموثوقية

خذ الاختبار