الأنظمة الموزعة والموثوقية
التزامن والخيوط المتعددة
أخطاء التزامن من أصعب الأخطاء في إعادة الإنتاج والتصحيح. المحاورون يحبونها لأنها تكشف ما إذا كنت تفهم حقًا الحالة المشتركة والتنسيق والضمانات التي يوفرها وقت تشغيل لغتك. يغطي هذا الدرس الفئات الثلاث لمشاكل التزامن واستراتيجيات القفل والأنماط الخاصة بكل لغة لـ 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 أو أعلى. دون ذلك، العبء الإضافي لإدارة نوعي قفل لا يستحق — استخدم مزلاجًا بسيطًا.
أنماط حالات السباق
التحقق ثم التنفيذ — لماذا الحل في نطاق القفل لا في وجوده
python1balance = 1002lock = threading.Lock()34def withdraw(amount):5 with lock:6 current = balance # التحقق7 # القفل يُحرَّر هنا.8 # يستطيع خيط آخر السحب في هذه اللحظة.9 if current >= amount:10 with lock:11 balance -= amount # التنفيذ12 return True13 return False1415# خيطان، الرصيد 100، كل منهما يسحب 100.16# كلاهما يقرأ 100. كلاهما يجتاز التحقق. الرصيد النهائي: -100.
1balance = 1002lock = threading.Lock()34def withdraw(amount):5 with lock:6 # التحقق والتنفيذ داخل المقطع الحرج نفسه.7 # لا يستطيع أي خيط رؤية الرصيد بينهما.8 if balance >= amount:9 balance -= amount10 return True11 return False1213# الثابت المحمي ليس 'balance' نفسه،14# بل العلاقة بين قراءته وتغييره.
التحقق ثم التنفيذ (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)
جميع الشروط الأربعة يجب أن تتحقق معًا لحدوث الجمود:
- الاستبعاد المتبادل: الموارد لا يمكن مشاركتها
- الحمل والانتظار: خيط يحمل قفلاً أثناء انتظار آخر
- عدم الاستباق: لا يمكن أخذ الأقفال بالقوة من خيط
- الانتظار الدائري: الخيط 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)
التزامن الخاص بكل لغة
قبل التفاصيل الخاصة بكل لغة، إليك شكل الاختيار. الثلاثة جميعًا «شيء يمكنك امتلاك الكثير منه»، وتختلف في موضع واحد فقط يهم: مَن يملك المكدس.
ثلاث وحدات للتزامن، وما تكلّفه كل واحدة فعلًا
خيط نظام التشغيل
- توازٍ حقيقي عبر الأنوية دون وقت تشغيل وسيط
- الاستدعاءات الحاجبة مقبولة — فالنواة تعيد الجدولة حولها
- أدوات التنقيح والتحليل وتفريغ الذاكرة تفهمه كلها
- تُحجز مساحة العناوين لكل خيط، فيُستنفد العدد قبل المعالج بكثير
- كل تبديل سياق يعبر إلى النواة
- لا يمكنك إنشاء خيط لكل اتصال على نطاق الإنترنت، وهذا هو سبب وجود الوحدتين الأخريين أصلًا
Goroutine في Go
- يُقاس المكدس على عمق الاستدعاء الفعلي بدل الحجز المسبق، فتكاد الاتصالات الخاملة لا تكلّف شيئًا
- الاستدعاء الحاجب يسلّم خيط النظام إلى goroutine أخرى بدل تعطيله
- توفّر القنوات بدائية للتواصل لا لمجرد التوليد
- الـ goroutine المتسربة المحجوبة على قناة تبقى غير مرئية حتى تُفرّغ المكدسات — ولا يظهر أي خطأ
- النمو ليس مجانيًا: المكدس الذي يتجاوز مقطعه يُنسخ، وتدفع مسارات الاستدعاء العميقة الساخنة ثمن ذلك
- لا استباق لحلقة ضيّقة بلا استدعاءات دوال حتى يلتقطها الاستباق غير المتزامن في وقت التشغيل
الخيط الافتراضي في Java
- يستفيد الكود الحاجب القائم دون تعديل — لا إعادة كتابة غير متزامنة ولا دوال ملوّنة
- تبقى `Thread` وtry-with-resources والتزامن المهيكل صالحة كما هي
- تبديل واحد للمنفّذ يحوّل خادم خيط-لكل-طلب
- التثبيت (pinning): الخيط الافتراضي داخل كتلة `synchronized` أو إطار أصلي يحتجز خيطه الحامل، والمجموعة صغيرة — وهذا هو العطل الذي يفاجئ الفرق في الإنتاج
- متغيرات الخيط التي كانت رخيصة عند ألف خيط ليست رخيصة عند مليون
- لا ينفع إلا حين يكون الحجب على الإدخال/الإخراج؛ أما العمل المقيد بالمعالج فيظل محدودًا بعدد الأنوية
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: تبدأ بمكدس نحو 2 كيلوبايت ينمو
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 تبدأ بمكدس من بضعة كيلوبايتات — وهوstackMinفيruntime/stack.go، ويساوي حاليًا 2048 بايت — ويقوم وقت التشغيل بتكبيره وتصغيره حسب عمق الاستدعاءات. وقد تغيّر هذا الرقم من قبل (كان 8 كيلوبايت لإصدارين قبل ظهور المكدسات المتصلة)، لذا اذكر رتبة المقدار لا الثابت. أما خيط نظام التشغيل فيحجز مكدسه كاملًا كمساحة عناوين مقدمًا — إذ يُظهرulimit -sعلى جهاز Linux أو macOS نموذجي قيمة 8 ميجابايت، أي أكبر بنحو ثلاث مراتب عشرية — وتُخصَّص الصفحات فعليًا عند استخدامها. وهذه الفجوة، لا اللغة، هي سبب أن مليون goroutine أمر عادي بينما مليون خيط ليس كذلك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وallOfConcurrentHashMap: خريطة تجزئة آمنة للخيوط بقفل على مستوى الأجزاءsynchronizedمقابلReentrantLock:ReentrantLockيضيفtryLock()والعدالة ومتغيراتCondition
Python: قفل GIL والحلول البديلة
قفل المفسر العالمي (GIL) يسمح لخيط واحد فقط بتنفيذ بايتكود Python في وقت واحد. هذا يعني أن الخيوط لا توفر توازيًا للعمل المقيد بالمعالج.
قل «البناء الافتراضي» لا «Python». إذ يوفّر CPython أيضًا بناءً حرّ الخيوط (free-threaded) بلا GIL — راجع دليل free-threading لمعرفة حالته الحالية وأي المثبِّتات تتضمنه. وهو ليس مكسبًا مجانيًا: فوحدات الامتداد غير الجاهزة له تعيد تفعيل GIL، ومن ثم تحدّد المنظومة ما تحصل عليه فعليًا. راجع الدليل لا أي دورة، بما فيها هذه — فالحالة تتغير. وأهمية هذا في المقابلة أن عبارة «Python لا تستطيع التوازي» صارت ادعاءً له تاريخ، وأن تقول «البناء الافتراضي يسلسل تنفيذ البايتكود، والبناء حرّ الخيوط موجود والقيد هو جاهزية امتدادات C» هي الإجابة التي تُظهر أنك اطّلعت حديثًا.
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 وقواطع الدوائر والتتبع الموزع وهندسة الفوضى. :::
سجّل الدخول للتقييم