لغات البرمجة عملياً الأدلة التقنية

التزامن Concurrency في Rust: الخيوط أم Tokio بالقياس

قسنا مئة استدعاء بطيء وهاشاً ثقيلاً بالخيوط وTokio وRayon، ثم رأينا ما يرفضه المترجم من أخطاء التزامن وما يمر منه: مخزون بيع مرتين وجمود يظهر مرة من ثلاث.

التزامن Concurrency في Rust: الخيوط أم Tokio بالقياس

المتجر الذي نتابعه في هذه السلسلة كبر قليلاً، وصار عند الفريق عملان ينتظران التسريع. الأول كل صباح: الكود يسأل واجهة المورد عن السعر الحالي لمئة منتج، وكل سؤال يأخذ حوالي 50 ملي ثانية، فينتظر البرنامج خمس ثوان وهو لا يفعل شيئاً تقريباً. والثاني عمل حسابي ثقيل: هاش متكرر لكل طلب من طلبات اليوم، يشبه ما تفعله دوال تخزين كلمات المرور أو توقيع الإيصالات، ويشغل المعالج بالكامل. وزميلك الذي جاء من Java يقول: «نفتح خيوطاً»، والذي جاء من JavaScript يقول: «async/await»، والذي جاء من Go يسأل: «أين ال Goroutines؟». وفي Rust الإجابات الثلاث موجودة، ولكل منها مكان، ومعها شيء لا تجده في تلك اللغات: مترجم يرفض بعض أخطاء التزامن قبل أن يعمل البرنامج.

وقد شرحنا في مقال الخيوط Threads والأقفال وasync للمبرمجين المفاهيم العامة: الخيط والقفل وحالة السباق والجمود وحلقة الأحداث، وفي مقال المعالج والخيوط والتزامن الفرق بين العمل الذي ينتظر والعمل الذي يحسب. وهنا نأخذ نفس العملين، ونقيسهما في Rust بثلاث طرق: بالترتيب، وبخيوط النظام، وبالمكتبة Tokio، ثم نحاول أن نصنع سباق بيانات فيرفضه المترجم، ثم نصنع سباقاً منطقياً وجموداً حقيقياً لا يرفضهما، لأن هذا هو الحد الدقيق لما تعدك به Rust.

وسوف نناقش في هذا المقال ما يلي:

  • ماذا تعني عبارة «التزامن بلا خوف» Fearless Concurrency، وما الذي لا تعد به.
  • العمل الذي ينتظر: مئة استدعاء لنقطة بطيئة بالترتيب، وبعشرة خيوط، وبمئة خيط، وبمهام Tokio، مع الأزمنة.
  • العمل الذي يحسب: SHA-256 لألفي نص في ألفي جولة، بالترتيب وبالخيوط وبالمكتبة Rayon، ونسبة التسريع.
  • ماذا يحدث إذا وضعت عملاً حسابياً داخل كود async، وكيف يحله spawn_blocking.
  • ما يرفضه المترجم: عداد مشترك بين خيوط، وRc بين خيوط، والسمتان Send وSync.
  • ما لا يرفضه: بيع عشرين قطعة من مخزون فيه عشر، وجمود Deadlock يظهر مرة من ثلاث.
  • قفل مأخوذ عبر .await، وجدول قرار، وأخطاء شائعة.

التزامن بلا خوف Fearless Concurrency: الوعد بالضبط

يسمي كتاب Rust فصل التزامن التزامن بلا خوف Fearless Concurrency، والعبارة تحتاج قراءة دقيقة. فالوعد أن الكود الآمن في Rust لا يمكن أن يحتوي سباق بيانات Data Race، وتعريفه أن يصل خيطان إلى نفس الذاكرة في نفس الوقت وأحدهما على الأقل يكتب، دون أي تنسيق بينهما. وهذا النوع من الأخطاء هو الأسوأ في C وC++ وJava، لأن نتيجته غير محددة، وقد يظهر مرة في الأسبوع على جهاز العميل ولا يظهر أبداً على جهازك. وتحقق Rust هذا الوعد بنفس قواعد الملكية والاستعارة التي رأيناها في مقال الأنواع والملكية: مرجع تعديل واحد أو عدة مراجع قراءة، وهذه القاعدة عندما تمتد إلى الخيوط تعني أنه لا يمكن أن يكتب خيط وغيره يقرأ.

ولكن الوعد لا يشمل شيئين، والكتاب نفسه يقولهما: الجمود Deadlock، أي خيطان ينتظر كل منهما قفلاً يملكه الآخر، والسباق المنطقي Race Condition، أي أن يكون كل وصول إلى الذاكرة محمياً بقفل، ولكن ترتيب العمليات خاطئ، مثل أن تفحص المخزون بقفل ثم تخصمه بقفل آخر. وهذا الفرق ليس تفصيلاً، فأغلب أخطاء التزامن في الأنظمة الحقيقية من النوع الثاني، وسوف نصنع الاثنين في المختبر لنرى أن البرنامج يترجم ويعمل ويعطي نتيجة خاطئة.

تجهيز المختبر Lab Setup: نقطة بطيئة ومشروع واحد

المشروع lab/conc فيه خمسة برامج. الأول slow هو واجهة المورد المزيفة، مكتوبة بالمكتبة Axum، وترد على /price بعد 50 ملي ثانية بالضبط بالدالة tokio::time::sleep، وتعمل في Container مستقل على شبكة Docker خاصة. والبقية io وcpu وoversell وdeadlock تعمل في Container آخر على نفس الشبكة. والإصدارات من ملف القفل: Tokio 1.54.1، وAxum 0.8.9، وreqwest 0.12.28، وRayon 1.12.0، وsha2 0.10.9، والمترجم Rust 1.99.1:

[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", default-features = false, features = ["blocking"] }
sha2 = "0.10"
rayon = "1"
docker network create slr-rs-net
docker run -d --name slr-rs-slow --network slr-rs-net --network-alias slow -v slr-rs-target:/target slr-rs-dev:1 /target/release/slow
docker run --rm --network slr-rs-net -v "$PWD/lab":/lab -v slr-rs-target:/target -e CARGO_TARGET_DIR=/target slr-rs-dev:1 sh /lab/run.sh

والجهاز فيه ثمانية معالجات منطقية، ويعمل عليه في نفس الوقت مختبرات أخرى، ولهذا شغلنا كل قياس مرتين أو ثلاثاً، وسوف ترى أن الأرقام تتغير بين التشغيلات، وسوف نقرأها بهذا الحذر. والمخرج الكامل في lab/run-output.txt وlab/cpu-run3.txt.

💡
قبل أن تثق بأي قياس لنقطة بطيئة، تأكد أن الاستدعاءات بالترتيب تأخذ فعلاً قريباً من 50 ملي ثانية لكل منها. فالسيرفر الذي يكتب الترويسات والجسم في دفعتين على اتصال Keep-Alive قد يصطدم بخوارزمية Nagle مع التأكيد المتأخر Delayed ACK في TCP، فيضيف حوالي 40 ملي ثانية لكل طلب، ويصبح كل قياس بعده خاطئاً. وفي مختبرنا أخذت المئة استدعاء بالترتيب 5.8 و6.2 ثانية، أي حوالي 60 ملي ثانية لكل استدعاء، فلا توجد هذه المشكلة هنا.

العمل الذي ينتظر I/O-bound: مئة استدعاء بأربع طرق

بالترتيب ثم بخيوط النظام std::thread

الطريقة الأولى حلقة عادية بعميل HTTP متزامن reqwest::blocking::Client، والثانية نفس الحلقة موزعة على خيوط نظام التشغيل. ولاحظ الدالة std::thread::scope، فهي من أهم ما أضيف إلى المكتبة القياسية في السنوات الأخيرة، لأنها تسمح للخيوط أن تستعير متغيرات من الدالة، مثل العميل c هنا، دون Arc، والسبب أن النطاق يضمن أن كل الخيوط انتهت قبل أن يخرج منه، فالمترجم يعرف أن المراجع لن تعيش أطول من قيمها، كما في توثيق scope:

fn threads(workers: usize) -> usize {
    let c = reqwest::blocking::Client::new();
    let per = N / workers;
    std::thread::scope(|s| {
        let hs: Vec<_> = (0..workers)
            .map(|_| {
                let c = &c;
                s.spawn(move || (0..per).map(|_| c.get(url()).send().unwrap().text().unwrap().len()).sum::<usize>())
            })
            .collect();
        hs.into_iter().map(|h| h.join().unwrap()).sum()
    })
}

بمهام Tokio Tasks مع async/await

والطريقة الثالثة كود غير متزامن بالمكتبة Tokio، وهي بيئة التشغيل Runtime الأشهر في Rust. ولا توجد بيئة تشغيل غير متزامنة داخل اللغة نفسها، فالكلمتان async وawait من اللغة، ولكن الذي يشغل المهام ويوقظها عندما تصل البيانات مكتبة تختارها، وهذا اختلاف كبير عن JavaScript حيث حلقة الأحداث جزء من البيئة، كما شرحنا في مقال حلقة الأحداث Event Loop:

async fn tasks(limit: usize) -> usize {
    let c = reqwest::Client::new();
    let sem = Arc::new(tokio::sync::Semaphore::new(limit));
    let mut set = tokio::task::JoinSet::new();
    for _ in 0..N {
        let (c, sem) = (c.clone(), sem.clone());
        set.spawn(async move {
            let _permit = sem.acquire().await.unwrap();
            c.get(url()).send().await.unwrap().text().await.unwrap().len()
        });
    }
    let mut total = 0;
    while let Some(r) = set.join_next().await {
        total += r.unwrap();
    }
    total
}

في الكود أعلاه لاحظ التالي:

  • كل استدعاء مهمة Task مستقلة بالدالة spawn، والمجموعة JoinSet تجمع نتائجها بترتيب انتهائها.
  • الإشارة Semaphore تحدد كم مهمة تسأل المورد في نفس الوقت، وهذا مهم في الواقع لأن أغلب الواجهات الخارجية لها حد للطلبات المتزامنة، وإرسال مئة طلب دفعة واحدة قد يحظرك. وقسنا حدين: 10 ومئة.
  • العميل reqwest::Client ينسخ بالدالة clone() لكل مهمة، وهذا رخيص لأنه مرجع مشترك Arc إلى نفس مجموعة الاتصالات، وليس عميلاً جديداً.
  • الكلمة move تنقل ملكية النسخ إلى المهمة، لأن المهمة قد تعيش أطول من الحلقة، والمترجم يرفض أن تستعير منها.

والمخرج لتشغيلين:

### io run 1
sequential, 1 thread                  5780 ms  (300 bytes)
std::thread, 10 threads x 10           744 ms  (300 bytes)
std::thread, 100 threads x 1           494 ms  (300 bytes)
tokio, 100 tasks, limit 10             595 ms  (300 bytes)
tokio, 100 tasks, no limit             243 ms  (300 bytes)
tokio current_thread, 100 tasks        222 ms  (300 bytes)
### io run 2
sequential, 1 thread                  6178 ms  (300 bytes)
std::thread, 10 threads x 10           638 ms  (300 bytes)
std::thread, 100 threads x 1           382 ms  (300 bytes)
tokio, 100 tasks, limit 10             603 ms  (300 bytes)
tokio, 100 tasks, no limit             201 ms  (300 bytes)
tokio current_thread, 100 tasks        240 ms  (300 bytes)

في المخرج أعلاه لاحظ التالي:

  • بالترتيب: حوالي 6 ثوان، أي مئة مرة 50 ملي ثانية ومعها ثمن الطلب نفسه. والمعالج طوال هذا الوقت ينتظر.
  • بعشرة خيوط أو عشر مهام: حوالي 600 إلى 750 ملي ثانية، أي عشر دفعات من 60 ملي ثانية تقريباً. والأرقام متقاربة جداً بين الخيوط والمهام، فعندما يكون عدد العمال واحداً فالنموذج لا يهم، والذي يحدد الوقت هو عدد الطلبات المتزامنة.
  • بمئة خيط: 382 إلى 494 ملي ثانية، وليس 50 كما قد تتوقع، والسبب أن كل خيط يحتاج إنشاء من النواة وذاكرة Stack، وكل طلب متزامن يحتاج اتصال TCP جديداً لأن مجموعة الاتصالات لا تملك مئة اتصال جاهز، ونقطة المورد نفسها على جهاز مشغول تستقبل مئة اتصال في نفس اللحظة.
  • بمئة مهمة Tokio دون حد: 201 إلى 243 ملي ثانية، الأسرع بين الطرق، وبنفس ثمن الاتصالات الجديدة. والفرق مع مئة خيط هو ثمن إنشاء الخيوط نفسها.
  • والسطر الأخير هو الأهم: بيئة current_thread، أي خيط واحد فقط يشغل كل المهام، أعطت 222 و240 ملي ثانية، أي مثل البيئة متعددة الخيوط. فالعمل الذي ينتظر لا يحتاج أنوية كثيرة، وإنما يحتاج ألا يقف الخيط ينتظر، وهذه هي فكرة async كلها.

ولماذا لا تكتفي بالخيوط وهي أبسط؟ لأن ثمنها يظهر مع العدد. فكل خيط في Rust يأخذ Stack حجمها الافتراضي 2 MiB كما في توثيق std::thread، وهي ذاكرة افتراضية تحجز ولا تستعمل كلها، ولكن عشرة آلاف اتصال مفتوح تعني عشرة آلاف خيط وتبديلاً مستمراً بينها في النواة. أما المهمة في Tokio فهي آلة حالات State Machine يولدها المترجم من الدالة async، حجمها بحجم المتغيرات التي تعيش عبر await فقط، وقد يكون بضع مئات من البايتات. ولهذا السيرفرات التي تستقبل آلاف الاتصالات، ومنها خدمة الطلبات في المقال التالي، مكتوبة كلها تقريباً بنموذج async.

مئة استدعاء لنقطة ترد بعد 50 ملي ثانية في تشغيلين: بالترتيب 5780 و6178 ملي ثانية، وبعشرة خيوط 744 و638، وبمئة خيط 494 و382، وبمهام Tokio بحد 10 كانت 595 و603، وبمئة مهمة دون حد 243 و201، وببيئة current_thread بخيط واحد 222 و240
خيط واحد يكفي للعمل الذي ينتظر إذا لم يقف ينتظر

كيف يعمل async في Rust من الداخل؟ المستقبل Future والاستطلاع Poll

الدالة async fn لا تعمل عندما تستدعيها، وإنما ترجع قيمة نوعها ينفذ السمة Future، أي «عمل سوف ينتهي». ولا يحدث شيء حتى يأتي أحد ويستطلعها Poll، فتعمل حتى أول await لا تجد نتيجته جاهزة، فترجع Pending وتترك مع بيئة التشغيل وسيلة توقظها بها، وتذهب البيئة لتشغيل مهمة أخرى. وعندما يصل الرد من الشبكة يوقظ نظام التشغيل البيئة عبر epoll في Linux، فتستطلع المهمة مرة أخرى، فتكمل من حيث توقفت. وهذا يسمى نموذج الاستطلاع Poll-based، وهو مختلف عن JavaScript حيث الوعد Promise يبدأ العمل لحظة إنشائه. والنتيجة العملية التي يجب أن تتذكرها: المستقبل الذي لم تنتظره بالكلمة await ولم تعطه للدالة spawn لا يعمل أبداً، والمترجم ينبهك بتحذير unused implementer of Future that must be used. والتفاصيل في كتاب async الرسمي.

من يشغل المهام؟ بيئة التشغيل Runtime ومجدول سرقة العمل Work Stealing

عندما تكتب #[tokio::main] فوق الدالة main، فهذا ماكرو يحولها إلى دالة عادية تنشئ بيئة تشغيل متعددة الخيوط ثم تشغل داخلها كودك. وعدد خيوط العمل Worker Threads الافتراضي في هذه البيئة هو عدد المعالجات المتاحة، كما يقول توثيق Builder في Tokio، ويحسب بالدالة available_parallelism من المكتبة القياسية، وهي تحترم حدود الـ Container، فإذا شغلت الخدمة بالخيار --cpus=2 فسوف تجد خيطي عمل لا ثمانية، وهذا ما تحققنا منه في المختبر. وكل خيط عمل له طابور مهام خاص، وإذا فرغ طابوره سرق مهام من طابور خيط آخر، وهذا ما يسمى سرقة العمل Work Stealing، وهو نفس ما تفعله Rayon مع الحساب ومجدول Go مع ال Goroutines. وبجانب خيوط العمل توجد مجموعة ثانية منفصلة للعمل الذي يوقف الخيط، وهي التي تستخدمها spawn_blocking، وحدها الأعلى الافتراضي 512 خيطاً تنشأ عند الحاجة وتنتهي بعد فترة خمول. ولاحظ الفرق مع Go: في Go بيئة التشغيل جزء من اللغة، وكل برنامج فيه مجدول ال Goroutines شئت أم أبيت، أما في Rust فالبيئة مكتبة تختارها، وبرنامج سطر الأوامر الذي لا يحتاج async لا يحمل أي بيئة تشغيل، والبرنامج الذي يعمل على متحكم صغير يستطيع أن يستخدم بيئة أخرى مصممة له.

وجاءت async/await إلى Rust المستقرة متأخرة نسبياً، في الإصدار 1.39 في نوفمبر 2019، بعد سنوات من النقاش حول تصميم يحقق شرطاً صعباً: ألا يكون للمستقبل أي ثمن وقت التشغيل غير ما تكتبه، فلا يوجد حجز في ال Heap لكل await ولا جامع قمامة يتابع المهام. ولهذا السبب تجد في النظام البيئي أكثر من بيئة تشغيل، وتجد أن المكتبة التي تكتب لبيئة Tokio لا تعمل دائماً مع غيرها، وهذا من نقاط الضعف التي يعترف بها المجتمع، والحل العملي أن تختار Tokio في السيرفرات لأن أغلب المكتبات مبنية عليها.

العمل الذي يحسب CPU-bound: الخيوط والمكتبة Rayon

العمل الثاني لا ينتظر شيئاً، وإنما يحسب: لكل نص من ألفي نص مثل order-17 نحسب SHA-256، ثم الهاش للهاش، ألفي جولة، أي أربعة ملايين عملية هاش. وكل نتيجة نجمع أول بايت منها في رقم تحقق Checksum لنتأكد أن كل الطرق حسبت نفس الشيء:

fn hash_rounds(i: usize) -> u8 {
    let mut h: [u8; 32] = Sha256::digest(format!("order-{i}")).into();
    for _ in 1..ROUNDS {
        h = Sha256::digest(h).into();
    }
    h[0]
}

// threads: thread t takes strings t, t+n, t+2n ...
s.spawn(move || checksum((t..STRINGS).step_by(n).map(hash_rounds)))

// rayon: the same iterator, made parallel by one word
checksum((0..STRINGS).into_par_iter().map(hash_rounds).collect::<Vec<_>>().into_iter())

والمكتبة Rayon تستحق وقفة، فهي تحول المكرر العادي iter() إلى مكرر متوازي par_iter() بكلمة واحدة، وتوزع العمل على مجموعة خيوط بعدد الأنوية بطريقة سرقة العمل Work Stealing، أي أن الخيط الذي ينهي نصيبه يأخذ من نصيب غيره. وهذا ممكن بأمان في Rust تحديداً، لأن المترجم يرفض المكرر المتوازي إذا كانت الدالة داخله تعدل شيئاً مشتركاً دون حماية. والنتائج لثلاثة تشغيلات:

### cpu run 1
sequential                                  4653 ms  (checksum 253682)
std::thread::scope, 2 threads               3162 ms  speedup x1.47
std::thread::scope, 4 threads               1655 ms  speedup x2.81
std::thread::scope, 8 threads                987 ms  speedup x4.71
rayon par_iter                              1132 ms  speedup x4.11
### cpu run 2
sequential                                  2667 ms  (checksum 253682)
std::thread::scope, 2 threads               2242 ms  speedup x1.19
std::thread::scope, 4 threads               1373 ms  speedup x1.94
std::thread::scope, 8 threads               1099 ms  speedup x2.43
rayon par_iter                              1754 ms  speedup x1.52
### cpu run 3
sequential                                  3780 ms  (checksum 253682)
std::thread::scope, 2 threads               2949 ms  speedup x1.28
std::thread::scope, 4 threads               1289 ms  speedup x2.93
std::thread::scope, 8 threads                865 ms  speedup x4.37
rayon par_iter                               830 ms  speedup x4.56

وهذه الأرقام تحتاج قراءة حذرة:

  • رقم التحقق 253682 في كل سطر من كل تشغيل، فكل الطرق حسبت نفس النتيجة، وهذا أول ما تتحقق منه في أي تسريع متوازي.
  • الطريقة بالترتيب نفسها أخذت 4653 ثم 2667 ثم 3780 ملي ثانية، أي أن نفس الكود على نفس الجهاز تغير بنسبة 75% بين تشغيلين. والسبب مختبرات أخرى كانت تأخذ المعالج في نفس الوقت، وقد رأينا في docker stats أثناء التشغيل Container آخر يأخذ ما يعادل خمسة معالجات. ولهذا نسبة التسريع في التشغيل الثاني منخفضة، فالطريقة بالترتيب كانت محظوظة والمتوازية لم تكن.
  • في التشغيلين الأول والثالث وصل التسريع بثمانية خيوط إلى 4.4 و4.7 مرات، وليس ثماني مرات. والمعالجات المنطقية الثمانية ليست ثماني أنوية حقيقية دائماً، فإذا كانت أربع أنوية بخيطين لكل نواة Hyper-Threading فإن الخيطين على نفس النواة يتقاسمان وحدات الحساب، وعمل الهاش يشغل نفس الوحدات. ومع الضغط من المختبرات الأخرى، فالنتيجة قريبة مما تتوقعه.
  • Rayon كانت قريبة من الخيوط اليدوية في التشغيلين الأول والثالث، والفرق في الثاني ضوضاء. والفائدة الحقيقية ليست السرعة وإنما أنك لم تكتب أي كود توزيع.
نسبة التسريع لهاش SHA-256 لألفي نص في ألفي جولة في ثلاثة تشغيلات: بخيطين 1.47 و1.19 و1.28 مرة، وبأربعة 2.81 و1.94 و2.93، وبثمانية 4.71 و2.43 و4.37، وRayon 4.11 و1.52 و4.56، والطريقة بالترتيب نفسها أخذت 4653 و2667 و3780 ملي ثانية
التسريع حقيقي، والضوضاء أيضاً حقيقية

ماذا يحدث إذا وضعت الحساب داخل async؟ إيقاف بيئة التشغيل Blocking the Runtime

هذا هو الخطأ الذي يقع فيه أغلب الفرق عندما تكون الخدمة كلها مكتوبة بنموذج async: دالة تحسب شيئاً ثقيلاً، مثل هاش كلمة مرور أو ضغط ملف، تستدعى مباشرة داخل معالج الطلب. ولكي نرى أثره شغلنا ربع العمل الحسابي داخل بيئة current_thread، ومعه مهمة أخرى صغيرة تستيقظ كل 10 ملي ثانية وتسجل أكبر تأخير لاحظته، وهي تمثل باقي الطلبات التي تنتظر دورها. ثم أعدنا نفس العمل بالدالة spawn_blocking التي ترسله إلى مجموعة خيوط منفصلة مخصصة للعمل الذي يوقف:

let c = if blocking {
    tokio::task::spawn_blocking(|| checksum((0..STRINGS / 4).map(hash_rounds))).await.unwrap()
} else {
    checksum((0..STRINGS / 4).map(hash_rounds)) // runs on the runtime thread itself
};
run 1: async, hashing on the runtime thread   1344 ms, worst ticker delay 1307 ms
       async + spawn_blocking (current_thread)  913 ms, worst ticker delay   20 ms
run 2: async, hashing on the runtime thread    736 ms, worst ticker delay  711 ms
       async + spawn_blocking (current_thread)  852 ms, worst ticker delay  115 ms
run 3: async, hashing on the runtime thread   1249 ms, worst ticker delay 1228 ms
       async + spawn_blocking (current_thread)  914 ms, worst ticker delay   31 ms

عندما كان الحساب على خيط البيئة نفسه تأخرت المهمة الصغيرة 711 إلى 1307 ملي ثانية، أي طوال مدة الحساب كلها، فلم يكن هناك أي شيء آخر يعمل. والسبب أن Tokio تعتمد على التعاون Cooperative Scheduling: المهمة تترك الخيط فقط عند await، والحلقة الحسابية لا تحتوي أي await، فلا تتركه أبداً. ومع spawn_blocking نزل أسوأ تأخير إلى 20 و31 ملي ثانية، و115 في التشغيل الأكثر ضغطاً. وفي خدمة حقيقية يعني الرقم الأول أن كل الطلبات الأخرى، حتى طلب فحص الصحة Health Check البسيط، تنتظر ثانية كاملة، فيظن الموزع Load Balancer أن الخدمة متوقفة. والبيئة متعددة الخيوط تخفف هذا لأن عندها خيوطاً أخرى، ولكنها لا تحله: يكفي أن تأتي ثمانية طلبات ثقيلة في نفس الوقت لتوقف كل الخيوط. وقد شرح فريق Tokio هذه المشكلة ومحاولته للتخفيف منها في مقال عن الإيقاف التعاوني. والقاعدة: await للانتظار، وspawn_blocking لما يوقف الخيط من مكتبات متزامنة أو عمل قصير ثقيل، وRayon للحساب الكبير الذي يستفيد من كل الأنوية.

أسوأ تأخير لمهمة تستيقظ كل 10 ملي ثانية أثناء حساب الهاش في بيئة Tokio بخيط واحد: عندما كان الحساب على خيط البيئة 1307 و711 و1228 ملي ثانية في ثلاثة تشغيلات، ومع spawn_blocking 20 و115 و31 ملي ثانية
حلقة حسابية بلا await توقف كل المهام الأخرى على نفس الخيط

ما يرفضه المترجم Compile-Time: سباق البيانات Data Race

الآن نحاول أن نكتب الخطأ الكلاسيكي: أربعة خيوط تزيد عداد المبيعات المشترك ألف مرة لكل منها. وفي Java أو C هذا يترجم ويعطيك رقماً أقل من 4000 في أغلب التشغيلات، كما رأينا في مقال الخيوط، لأن العملية sold += 1 قراءة ثم جمع ثم كتابة، ويتداخل الخيطان بينها:

let mut sold = 0;
let mut handles = Vec::new();
for _ in 0..4 {
    handles.push(thread::spawn(|| {
        for _ in 0..1000 {
            sold += 1;
        }
    }));
}
error[E0373]: closure may outlive the current function, but it borrows `sold`, which is owned by the current function
 --> /lab/fails/race_counter.rs:7:36
  |
7 |         handles.push(thread::spawn(|| {
  |                                    ^^ may outlive borrowed value `sold`
8 |             for _ in 0..1000 {
9 |                 sold += 1;
  |                 ---- `sold` is borrowed here
help: to force the closure to take ownership of `sold` (and any other referenced variables), use the `move` keyword

error[E0499]: cannot borrow `sold` as mutable more than once at a time
  |                        -             ^^ `sold` was mutably borrowed here in the previous iteration of the loop

error: aborting due to 3 previous errors

ثلاثة أخطاء، وأهمها اثنان: الأول E0373 يقول إن الخيط قد يعيش أطول من الدالة، لأن thread::spawn لا تعد بأن الخيط سينتهي قبل أن ينتهي المتغير، فلا يجوز أن يستعيره. والثاني E0499 هو نفس الخطأ الذي رأيناه في مقال الملكية: مرجعا تعديل لنفس المتغير، والمرجع الأول من الدورة السابقة في الحلقة ما زال حياً في الخيط الأول. والخطأ الثاني هو سباق البيانات نفسه، مكتوباً بلغة قواعد الاستعارة.

فخ صغير: الاقتراح move مع نسخة Copy يترجم ويطبع صفراً

المترجم اقترح move، فلنجربها كما يفعل أغلب الناس:

warning: value captured by `sold` is never read
 --> /lab/fails/race_counter_move.rs:9:17
  |
9 |                 sold += 1;
  |                 ^^^^
  |
  = help: did you mean to capture by reference instead?
sold 0

البرنامج ترجم بتحذير وطبع sold 0. والسبب أن الرقم i32 من نوع Copy، فالكلمة move أعطت كل خيط نسخة خاصة منه، فزاد كل خيط نسخته إلى 1000 ثم رماها، وبقي الأصل صفراً. فلا يوجد سباق، ولكن النتيجة خاطئة، والتحذير يقولها صراحة: «هل قصدت الالتقاط بالمرجع؟». ولهذا لا تتجاهل تحذيرات المترجم في Rust، فهي أدق مما تعودت عليه في لغات أخرى.

الإصلاح: العدد الذري Atomic أو الخيوط المحددة بنطاق Scoped Threads

الإصلاح الصحيح أن يكون العداد مشتركاً فعلاً، وآمناً للكتابة من عدة خيوط. والأبسط لعداد هو العدد الذري AtomicU64 الذي يجعل القراءة والجمع والكتابة عملية واحدة لا تتجزأ في المعالج، كما في توثيق atomic، ويشارك بين الخيوط عبر Arc، وهو عد مراجع ذري يحرر العداد عندما ينتهي آخر خيط:

let sold = Arc::new(AtomicU64::new(0));
let handles: Vec<_> = (0..4)
    .map(|_| {
        let sold = sold.clone();
        thread::spawn(move || {
            for _ in 0..1000 {
                sold.fetch_add(1, Ordering::Relaxed);
            }
        })
    })
    .collect();
sold 4000, threads finished 4
totals [5490, 12800], prices still usable: 4

والسطر الثاني من برنامج آخر في lab/ok/scoped.rs يبين طريقة ثالثة: إذا كان كل خيط يعمل على جزء مختلف من البيانات، فقسمها بالدالة split_at_mut إلى جزأين منفصلين، وأعط كل خيط جزءه داخل thread::scope، فلا تحتاج أي قفل ولا أي Arc، والمترجم يقبل لأنه يرى أن المرجعين لا يتداخلان. وهذا هو التفكير الذي تشجعه Rust: بدلاً من مشاركة البيانات وحمايتها، قسمها حتى لا تحتاج حماية.

لماذا لا يمر Rc بين الخيوط؟ السمتان Send و Sync

error[E0277]: `Rc<Vec<i32>>` cannot be sent between threads safely
 --> /lab/fails/rc_send.rs:7:19
  |
7 |     thread::spawn(move || println!("{}", p.len())).join().unwrap();
  |     ------------- -------^^^^^^^^^^^^^^^^^^^^^^^^
  |     |             |
  |     |             `Rc<Vec<i32>>` cannot be sent between threads safely
  = help: within `{closure@/lab/fails/rc_send.rs:7:19: 7:26}`, the trait `Send` is not implemented for `Rc<Vec<i32>>`

العداد في Rc عادي وليس ذرياً، لأنه أسرع، فإذا نسخه خيطان في نفس الوقت فقد يضيع تحديث ويحرر الكائن مبكراً. والمترجم يعرف ذلك من سمتين خاصتين، كما يشرح فصل Send وSync في Rustonomicon: النوع Send يمكن نقل ملكيته إلى خيط آخر، والنوع Sync يمكن مشاركة مرجع إليه بين خيوط. والمترجم يحسبهما تلقائياً لكل نوع من حقوله، فالهيكل الذي فيه Rc ليس Send دون أن تكتب شيئاً، والدالة thread::spawn تطلب Send في توقيعها. ولهذا الحل هنا Arc بدلاً من Rc، وهو نفس الشيء بعداد ذري. وهذه أذكى فكرة في تصميم التزامن في Rust: الأمان ليس قواعد خاصة في المترجم للخيوط، وإنما سمتان عاديتان في المكتبة، ونفس القواعد تعمل مع Tokio ومع Rayon ومع أي مكتبة تكتبها أنت.

ما يرفضه المترجم وما لا يرفضه: عداد مشترك بين خيوط يرفض بالأخطاء E0373 وE0499، وRc بين خيوط يرفض بالخطأ E0277 لأنه ليس Send، وقفل std عبر await داخل tokio::spawn يرفض لأن المستقبل ليس Send. ويقبل المترجم: فحص المخزون ثم خصمه بقفلين فباع 19 قطعة من 10 والمخزون -9، وقفلين بترتيب معاكس فتوقف البرنامج مرة من ثلاث، وmove مع رقم Copy فطبع صفراً بتحذير
الوعد يغطي سباق البيانات، ولا يغطي ترتيب منطقك

النمط الذي سوف تراه في كل مكان: Arc مع Mutex

عندما تجمع ما سبق تحصل على التركيب الذي تجده في أغلب كود Rust المتزامن: Arc<Mutex<T>>. والقطعتان تحلان مشكلتين مختلفتين، ومن المفيد أن تفصلهما في ذهنك. فالمؤشر Arc يحل مشكلة الملكية: البيانات يجب أن تعيش ما دام أي خيط يحتاجها، ولا يوجد مالك واحد واضح، فيصبح لها عدة مالكين بعداد ذري. والقفل Mutex يحل مشكلة التعديل: Arc وحده يعطي كل خيط مرجع قراءة فقط، لأن قاعدة الاستعارة ما زالت تعمل، ولا يمكن أن يكون هناك عدة مراجع تعديل، والقفل هو الذي يحول مرجع القراءة المشترك إلى مرجع تعديل حصري لخيط واحد في كل لحظة. ولاحظ أن البيانات في Rust تعيش داخل القفل نفسه، فلا يمكنك أن تصل إلى المخزون دون أن تأخذ القفل، على عكس Java وC حيث القفل كائن منفصل بجانب البيانات، ويمكنك أن تنسى أخذه في مكان ما ويترجم الكود. وإذا كانت البيانات تقرأ كثيراً وتكتب نادراً، مثل قائمة الأسعار، فالقفل RwLock يسمح بعدة قارئين في نفس الوقت.

ما لا يرفضه المترجم: بيع عشرين قطعة من عشر Race Condition

الآن الجزء الذي يجب أن يتذكره كل من يقرأ «التزامن بلا خوف». عشرون مشترياً في عشرين خيطاً، والمخزون عشر قطع، وكل مشتري يفحص المخزون، ثم يتصل ببوابة الدفع خمس ملي ثانيات، ثم يخصم قطعة. وكل وصول إلى المخزون محمي بقفل Mutex، والبرنامج يترجم دون أي تحذير:

let available = *stock.lock().unwrap() > 0; // guard dropped at the end of this line
if available {
    thread::sleep(Duration::from_millis(5)); // payment call
    *stock.lock().unwrap() -= 1;
    *sold.lock().unwrap() += 1;
}
two locks   run 1: sold 19, stock left  -9
two locks   run 2: sold 19, stock left  -9
two locks   run 3: sold 17, stock left  -7
one guard   run 1: sold 10, stock left   0
one guard   run 2: sold 10, stock left   0
one guard   run 3: sold 10, stock left   0

بعنا 19 و17 قطعة من مخزون فيه عشر، وصار المخزون سالباً. والسبب أن القفل في السطر الأول يفك عند نهاية نفس السطر، لأن الحارس Guard الذي يرجعه lock() قيمة مؤقتة تنتهي هناك، فبين الفحص والخصم خمس ملي ثانيات يمر فيها كل الآخرين من نفس الفحص. فلا يوجد سباق بيانات، فكل قراءة وكتابة محمية، ولكن يوجد سباق منطقي: الفحص والخصم يجب أن يكونا عملية واحدة. والإصلاح أن تأخذ الحارس مرة واحدة وتبقيه حتى تنتهي:

let mut s = stock.lock().unwrap(); // one guard for check and decrement
if *s > 0 {
    thread::sleep(Duration::from_millis(5));
    *s -= 1;
    *sold.lock().unwrap() += 1;
}

فبعنا عشراً بالضبط في كل تشغيل. ولاحظ الثمن: المشترون الآن ينتظرون بعضهم طوال اتصال الدفع، وهذا مقبول في المختبر وغير مقبول في متجر حقيقي فيه آلاف المنتجات. ولهذا لا يعيش المخزون الحقيقي في ذاكرة الخدمة أصلاً، وإنما في قاعدة البيانات، والقفل هناك على صف المنتج وحده بالعبارة SELECT ... FOR UPDATE، وهذا ما سوف نبنيه ونختبره بعشرين طلباً متوازياً في المقال التالي. وأنواع الأقفال في قواعد البيانات شرحناها في مقال الأقفال Locks والجمود Deadlock في قواعد البيانات.

الجمود Deadlock: يظهر مرة ويختفي مرتين

الخطأ الثاني الذي لا يرفضه المترجم. خيط البيع يأخذ قفل المخزون ثم قفل السجل، وخيط المراجعة يأخذ قفل السجل ثم قفل المخزون، ألف مرة لكل منهما، ومعهما خيط مراقب Watchdog يوقف البرنامج برمز 3 إذا لم ينته العمل في ثانيتين:

// sale thread
let mut s = s1.lock().unwrap(); // stock first
let mut l = l1.lock().unwrap(); // then ledger

// audit thread
let l = l2.lock().unwrap(); // ledger first: the opposite order
let s = s2.lock().unwrap();
### deadlock run 1
watchdog: no progress after 2.011282949s, both threads are waiting for each other
exit 3
### deadlock run 2
done in 6.398065ms, ledger has 1000 lines
exit 0
### deadlock run 3
done in 3.375165ms, ledger has 1000 lines
exit 0
### deadlock fixed
done in 740.844µs, ledger has 1000 lines
exit 0

هذا المخرج هو أهم ما في القسم: نفس البرنامج توقف إلى الأبد في التشغيل الأول، وانتهى في 6 و3 ملي ثانيات في التشغيلين التاليين. فالجمود يحتاج أن يأخذ كل خيط قفله الأول في نفس اللحظة تقريباً، وهذا يعتمد على توقيت الجدولة، ولذلك قد تشغل الاختبارات مئة مرة فتنجح، ثم يتوقف السيرفر في الإنتاج ليلة الجمعة عندما يزيد الضغط. والإصلاح أن يأخذ الخيطان القفلين بنفس الترتيب دائماً، وهو ما فعلناه في التشغيل الأخير. وفي مشروع كبير تكتب القاعدة صراحة، مثل «المخزون قبل السجل دائماً»، أو تجمع البيانات التي تتغير معاً تحت قفل واحد. ولاحظ أن قفل std::sync::Mutex في Rust يرجع Result من lock()، وهذا ليس للجمود، وإنما للتسمم Poisoning: إذا توقف خيط بـ Panic وهو يمسك القفل، فإن الخيوط التالية تعرف أن البيانات قد تكون في حالة ناقصة.

خيط البيع يأخذ قفل المخزون ثم ينتظر قفل السجل، وخيط المراجعة يأخذ قفل السجل ثم ينتظر قفل المخزون، فينتظر كل منهما الآخر. في ثلاثة تشغيلات: الأول توقف وأنهاه المراقب بعد ثانيتين برمز 3، والثاني والثالث انتهيا في 6.4 و3.4 ملي ثانية، وبعد توحيد الترتيب انتهى في 0.74 ملي ثانية
الجمود يعتمد على التوقيت، ولهذا تنجح الاختبارات ويتوقف الإنتاج

قفل عبر await Lock Across Await: خطأ يرفضه المترجم بطريقة غير متوقعة

في كود async يظهر نوع آخر من نفس المشكلة: أن تأخذ قفلاً من المكتبة القياسية، ثم تنتظر بالكلمة await وأنت تمسكه، مثل اتصال الدفع هنا:

tokio::spawn(async move {
    let mut guard = s.lock().unwrap();
    tokio::time::sleep(Duration::from_millis(5)).await; // payment call while holding the lock
    *guard -= 1;
})
error: future cannot be sent between threads safely
   --> examples/await_guard.rs:9:5
    |
  9 | /     tokio::spawn(async move {
 10 | |         let mut guard = s.lock().unwrap();
 11 | |         tokio::time::sleep(Duration::from_millis(5)).await; // payment call while holding the lock
 12 | |         *guard -= 1;
 13 | |     })
    | |______^ future created by async block is not `Send`
    |
    = help: within `{async block@examples/await_guard.rs:9:18: 9:28}`, the trait `Send` is not implemented for `std::sync::MutexGuard<'_, i32>`
note: future is not `Send` as this value is used across an await

والمترجم هنا لا يعرف شيئاً عن الجمود، وإنما يطبق نفس السمة Send: حارس القفل من المكتبة القياسية مرتبط بالخيط الذي أخذه، فلا يجوز نقله إلى خيط آخر، والمهمة في Tokio قد تكمل بعد await على خيط مختلف. ولكن النتيجة مفيدة، لأن إمساك قفل عادي أثناء الانتظار يعني أن كل المهام الأخرى التي تريد القفل توقف خيوطها كلها. والحل الأول، والمفضل، أن تفك القفل قبل await، فتنقل الانتظار خارج الكتلة التي تمسك فيها الحارس. والحل الثاني، إذا كان يجب أن يبقى القفل أثناء الانتظار فعلاً، هو القفل غير المتزامن tokio::sync::Mutex الذي ينتظر بالكلمة await بدلاً من إيقاف الخيط، وقد ترجم وطبع stock 9. ولاحظ أن توثيق Tokio نفسه ينصح بالقفل العادي من المكتبة القياسية في أغلب الحالات لأنه أرخص، وبالقفل غير المتزامن فقط عندما تحتاج إمساكه عبر await، مثل اتصال قاعدة بيانات. وفي Clippy قاعدة اسمها await_holding_lock تنبهك إلى هذا حتى في الحالات التي لا يرفضها المترجم.

القنوات Channels: أرسل الرسالة بدلاً من مشاركة الذاكرة

كل ما رأيناه حتى الآن بيانات مشتركة يحميها قفل أو عدد ذري. وهناك طريقة أخرى يفضلها كثيرون، وشعارها في Go «لا تتواصل بمشاركة الذاكرة، وإنما شارك الذاكرة بالتواصل». وفي Rust تجدها في المكتبة القياسية std::sync::mpsc، أي قناة بعدة مرسلين ومستقبل واحد، وفي Tokio نسخة غير متزامنة tokio::sync::mpsc. ولو أعدنا كتابة برنامج البيع الزائد بهذه الطريقة، فإن المخزون لا يكون في Mutex أصلاً، وإنما يملكه خيط واحد فقط، والمشترون العشرون يرسلون إليه رسائل «أريد قطعة» عبر القناة، وهو يرد على كل رسالة بالقبول أو الرفض بالترتيب. فلا يمكن أن يحدث السباق المنطقي، لأن الفحص والخصم يحدثان في خيط واحد بالترتيب، ولا يمكن أن يحدث الجمود بين قفلين، لأنه لا توجد أقفال. والذي يجعل هذا آمناً في Rust تحديداً هو الملكية مرة أخرى: إرسال قيمة عبر القناة ينقل ملكيتها، فلا يستطيع المرسل أن يعدلها بعد الإرسال، والمترجم يرفض ذلك بنفس الخطأ E0382 الذي رأيناه في مقال الملكية. والثمن أن الخيط المالك قد يصبح عنق زجاجة إذا كثرت الرسائل، وأن القناة غير المحدودة قد تكبر في الذاكرة إذا كان المرسلون أسرع من المستقبل، ولهذا تفضل القنوات ذات الحد Bounded Channels التي توقف المرسل عندما تمتلئ، وهذا ما يسمى الضغط العكسي Backpressure.

كيف تختبر الكود المتزامن؟ Testing Concurrency

رأينا أن الجمود ظهر مرة من ثلاث، فكيف تثق باختبار ينجح؟ والإجابة أن الاختبار العادي يجرب ترتيباً واحداً من ترتيبات كثيرة ممكنة للخيوط، وهو الترتيب الذي اختاره المجدول في تلك اللحظة. وهناك ثلاث طبقات من الحلول. الأولى أن تكرر السيناريو مرات كثيرة مع مهلة، كما فعلنا بالمراقب، فيرتفع احتمال ظهور الترتيب السيئ، ولا يعلق خط CI إذا ظهر. والثانية أن تجعل الكود نفسه أقل اعتماداً على الترتيب، بقفل واحد أو بالقنوات كما في القسم السابق، وهذا أقوى من أي اختبار. والثالثة أدوات تجرب الترتيبات بشكل منهجي: المكتبة loom من فريق Tokio تشغل اختبارك تحت مجدول خاص يجرب كل الترتيبات الممكنة للخيوط في نموذج صغير، فتجد الجمود والسباق الذي يظهر مرة في المليون، وهي التي يختبر بها فريق Tokio نفسه بنى التزامن داخل المكتبة. والأداة Miri تشغل الكود في مفسر يكشف سباق البيانات في الكود unsafe الذي لا يراه المترجم. ولكن تذكر أن السباق المنطقي في المخزون لا تكشفه أي أداة عامة، لأن البرنامج لا يخرق أي قاعدة للغة، وإنما قاعدة عملك أنت، ولهذا تحتاج اختباراً يقول صراحة: عشرون مشترياً ومخزون عشر قطع، وبالضبط عشر عمليات بيع ناجحة. وهذا الاختبار بالضبط هو ما سوف نكتبه ضد قاعدة بيانات حقيقية في المقال التالي.

مقارنة بما تعرفه من لغات أخرى Comparison

إذا جئت من لغة أخرى فالجدول التالي يضع ما رأيناه بجانب ما تعرفه، ثم نشرح أهم فرق فيه:

اللغةنموذج الانتظارنموذج الحساب المتوازيمن يمنع سباق البيانات؟
Rustasync مع بيئة تختارها، غالباً Tokioخيوط النظام أو Rayonالمترجم، بالسمتين Send وSync
GoGoroutines يجدولها وقت التشغيلنفس ال Goroutines على كل الأنويةأداة -race وقت التشغيل إذا شغلتها
Java 21 وما بعدهاالخيوط الافتراضية Virtual Threadsخيوط النظام ومجمعاتهاالمبرمج، والأقفال والمجموعات الآمنة
JavaScriptحلقة الأحداث وPromiseWorker Threads منفصلة بلا ذاكرة مشتركة تقريباًالتصميم، لأن الكود يعمل على خيط واحد
Pythonasyncioالعمليات Processes، أو الخيوط في النسخة الحرة من GILالمبرمج، والقفل العام GIL يخفي بعض الأخطاء

والفرق الأهم في العمود الأخير. ففي Go تكتب Goroutines بسهولة كبيرة، والنموذج يشبه الخيوط فلا تحتاج أن تلون الدوال بالكلمة async، ولكن سباق البيانات ممكن تماماً، والأداة التي تكشفه تعمل وقت التشغيل وتكشف فقط ما يحدث في التشغيل الذي راقبته. وفي Java الخيوط الافتراضية حلت مشكلة ثمن الخيط، فصار ممكناً أن تفتح مليون خيط ينتظر، ولكن مسؤولية الأقفال بقيت على المبرمج. أما Rust فاختارت الطريق الأصعب في الكتابة: الدوال الملونة بالكلمة async، وقواعد Send التي تظهر في رسائل خطأ طويلة كما رأيت، وفي المقابل يأتي الضمان الوحيد الذي لا تعطيه اللغات الأخرى: إذا ترجم الكود الآمن فلا يوجد فيه سباق بيانات، في أي تشغيل وعلى أي جهاز. وهذا هو سبب الصعوبة التي يشعر بها القادمون إلى Rust في الأسابيع الأولى، وهو أيضاً سبب أن الفرق التي تتجاوز هذه الأسابيع نادراً ما تقضي ليلة في البحث عن خطأ تزامن لا يتكرر. ولكن لا تنس ما رأيته في هذا المقال: المخزون السالب والجمود لا يدخلان في هذا الضمان، وأغلب ليالي البحث في الأنظمة الحقيقية تأتي منهما.

جدول قرار Decision Table: أي نموذج لأي عمل؟

العملالنموذج المناسبوالسبب من المختبر
عدد قليل من العمليات التي تنتظر في أداة أو سكربتخيوط std::thread::scopeعشرة خيوط وعشر مهام أعطت نفس الوقت تقريباً، والخيوط أبسط
سيرفر بآلاف الاتصالات، أو مئات الطلبات الخارجيةasync مع Tokioمئة مهمة على خيط واحد 222 ملي ثانية، ولا يوجد Stack لكل اتصال
حساب كبير على بيانات كثيرةRayon أو خيوط محددة بنطاقتسريع 4.1 إلى 4.7 مرات على 8 معالجات منطقية
حساب ثقيل أو مكتبة متزامنة داخل كود asyncspawn_blockingأسوأ تأخير نزل من 1307 إلى 20 ملي ثانية
عداد أو علم مشتركنوع ذري Atomic*4000 بالضبط دون قفل
بيانات مشتركة تتغير معاًقفل واحد Mutex حول كل البياناتقفلان منفصلان أعطيا مخزوناً سالباً وجموداً
حالة مشتركة بين عدة خدماتقاعدة البيانات وأقفالهاالخدمة الحقيقية في المقال التالي

أخطاء شائعة Common Mistakes

  • الظن أن «التزامن بلا خوف» يعني لا أخطاء تزامن: باع البرنامج 19 قطعة من 10 دون أي تحذير.
  • فك القفل بين الفحص والتعديل: حارس lock() المؤقت ينتهي في نهاية السطر نفسه.
  • أخذ قفلين بترتيبين مختلفين في مكانين: جمود مرة من ثلاث تشغيلات، ولا يظهر في الاختبار غالباً.
  • حساب ثقيل داخل دالة async دون spawn_blocking: كل المهام الأخرى على الخيط انتظرت أكثر من ثانية.
  • استخدام move مع رقم مشترك لأن المترجم اقترحها: كل خيط زاد نسخته وطبع البرنامج صفراً، والتحذير كان هناك.
  • إمساك قفل من المكتبة القياسية عبر await: فك القفل قبل الانتظار، أو استخدم tokio::sync::Mutex إذا كان ضرورياً.
  • فتح خيط لكل اتصال في سيرفر: 2 MiB من ذاكرة Stack الافتراضية لكل خيط، والمهام أخف بكثير.
  • إرسال مئة طلب متزامن إلى واجهة خارجية دون حد: استخدم Semaphore بالحد الذي يسمح به المورد.
  • قياس التسريع المتوازي مرة واحدة على جهاز مشترك: الطريقة بالترتيب نفسها تغيرت بين 2.7 و4.7 ثانية.
  • إنشاء مستقبل دون await أو spawn: لا يعمل أبداً في Rust، على عكس Promise في JavaScript.

قائمة تحقق Checklist قبل نشر كود متزامن

  • كل فحص يليه تعديل على نفس البيانات تحت حارس قفل واحد، أو في معاملة قاعدة بيانات واحدة.
  • ترتيب الأقفال مكتوب في تعليق أو في وثيقة الفريق، ولا يوجد مكان يخالفه.
  • لا يوجد حساب ثقيل أو استدعاء متزامن يوقف الخيط داخل async دون spawn_blocking.
  • كل طلب خارجي متوازي له حد Semaphore ومهلة Timeout.
  • القاعدة clippy::await_holding_lock مفعلة في CI.
  • اختبار تزامن يكرر السيناريو عشرات المرات، ومراقب Watchdog أو مهلة للاختبار حتى لا يعلق خط CI إلى الأبد عند الجمود.
  • القياسات تتكرر، ورقم التحقق متطابق بين الطرق.

تدريب عملي

  • أعد كتابة برنامج البيع الزائد بحيث يكون المخزون AtomicI32 مع الدالة fetch_update، وتأكد أنه يبيع عشراً بالضبط دون Mutex.
  • غير برنامج الجمود ليستخدم try_lock مع إعادة المحاولة، وقس كم مرة فشلت المحاولة الأولى في ألف دورة.
  • أضف إلى برنامج io مهلة 30 ملي ثانية لكل طلب بالدالة tokio::time::timeout، ولاحظ كم طلباً يفشل ولماذا.

حذف المختبر Cleanup

docker rm -f slr-rs-slow
docker network rm slr-rs-net
docker volume rm slr-rs-cargo slr-rs-target
docker rmi slr-rs-dev:1

الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

  • الوعد في Rust هو غياب سباق البيانات في الكود الآمن، ويتحقق بقواعد الاستعارة نفسها وبالسمتين Send وSync، ولا يشمل الجمود ولا السباق المنطقي.
  • العمل الذي ينتظر: مئة استدعاء من 6 ثوان بالترتيب إلى حوالي 600 ملي ثانية بعشرة عمال، وإلى حوالي 220 ملي ثانية بمئة مهمة Tokio، حتى على خيط واحد.
  • العمل الذي يحسب: تسريع 4.4 إلى 4.7 مرات بثمانية خيوط أو Rayon في التشغيلات الهادئة، والضوضاء غيرت الطريقة بالترتيب نفسها بنسبة 75%.
  • الحساب داخل async بلا spawn_blocking أوقف باقي المهام أكثر من ثانية، ومعها نزل التأخير إلى عشرات الملي ثانية.
  • المترجم رفض العداد المشترك بالخطأين E0373 وE0499، ورفض Rc بين الخيوط بالخطأ E0277، ورفض القفل العادي عبر await في مهمة Tokio.
  • المترجم قبل فحصاً وخصماً بقفلين فبيعت 19 قطعة من 10، وقبل قفلين بترتيب معاكس فتوقف البرنامج في تشغيل من ثلاثة.

وفي المقال التالي نجمع كل ما سبق في خدمة حقيقية: Axum مع PostgreSQL، وطلب شراء في معاملة واحدة تقفل صفوف المنتجات، واختبار عشرين طلباً متوازياً على مخزون فيه عشر قطع، والقياسات في Container محدود الموارد.

دليلRust للمطورين: الأدوات Cargo والأساسيات في يوم واحدثبتنا Rust 1.99.1 من الصفر وبنينا مكتبة تحسب قيمة الطلب والضريبة بالهللات، ومررنا عليها بكل أداة ستكتبها يومياً مع المخرج الحقيقي، وما يفاجئ القادم من Python وJava وGo.دليلالأنواع والملكية Ownership والأخطاء Result في Rustقسنا أحجام الأنواع وعدد الحجوزات والذاكرة عند النقل والاستنساخ، وقرأنا ست رسائل حقيقية من مدقق الاستعارة، ثم رأينا كيف تفشل Rust بالأخطاء والتوقف ورموز الخروج.دليلThreads وasync/await: كود لا يتجمد ولا يضيع بياناتهكيف تكتب كوداً يعمل في أكثر من خيط Thread دون أن تضيع بياناته أو يتجمد، ولماذا تظهر هذه الأخطاء على السيرفر ولا تظهر على جهازك.دليلالتزامن Concurrency والخيوط: ماذا يعني لسيرفرك وتطبيقاتكلماذا يكون المعالج عند 100% والتطبيق بطيئاً، ولا تسرع الخيوط كود Python، وكيف تضبط عدد العمال وحدود ال Containers.دليلحلقة الأحداث Event Loop: لماذا تتأخر النقرة في صفحتك؟كيف ترتب JavaScript المهام والوعود والرسم على خيط واحد، ولماذا تنتظر النقرة خلف مهمة طويلة، وكيف تقسم العمل لتبقى الصفحة سريعة.دليلالأقفال Locks والجمود Deadlock في قواعد البياناتلماذا يوقف أمر ALTER TABLE صغير كل الاستعلامات خلفه، وكيف تقرأ pg_locks وتتجنب الجمود وظواهر العزل في PostgreSQL.

سجل التحديثات

  • أكتوبر 2026: كتابة المقال، وتنفيذ التجارب داخل Container من rust:1.99.1-slim-trixie مع Rust 1.99.1 وTokio 1.54.1 وAxum 0.8.9 وreqwest 0.12.28 وRayon 1.12.0 وsha2 0.10.9، على نواة Linux 5.15 (WSL2) ومعالج بثماني أنوية منطقية، على Docker Engine 29.8.0.
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.