خيوط الإقليم ٠٨ حقيقية وآمنة — لكنها مكلفة: ميغابايتات من الـ stack لكل خيط، وتبديل سياق تديره نواة النظام. عشرة آلاف اتصال متزامن بعشرة آلاف خيط جدار عملي، لا نظري. Node حلّت هذا بخيط واحد وevent loop تعرفه من fullstack (V8 + libuv، epoll تحت الغطاء). هذا الإقليم يعطيك حلّ Rust — وهو مختلف جذريًا في فلسفته عن حل Node، رغم تشابه النتيجة الظاهرية.
الدرس ١: صمّم "الانتظار" بنفسك
اللغز التأسيسي
TcpStream::read العادية تحجب (blocks) الخيط كاملًا حتى تصل بيانات. خيط واحد يخدم عشرة آلاف اتصال يحتاج طريقة لقول: "حاول الآن؛ إن لم تكن البيانات جاهزة، لا تتجمّد — أخبرني بذلك، واسألني مرة أخرى لاحقًا حين تصير جاهزة." صمّم بنفسك الشكل الذي يمثّل هذه الفكرة كنوع Rust: ما أبسط enum (نعم — enum؛ فكّر بالإقليم ٠٣) يمثّل "إمّا جاهز بقيمة، أو غير جاهز بعد"؟ وما توقيع الدالة/الطريقة التي "تحاول التقدّم خطوة واحدة" وتُعيد ذلك الـenum؟
الحل: Future وPoll
enum Poll<T> {
Ready(T),
Pending,
}
trait Future {
type Output;
fn poll(&mut self, cx: &mut Context) -> Poll<Self::Output>; // Context لاحقًا
}
قارنها فورًا بـ Option من الإقليم ٠٣ — الشكل مطابق تقريبًا حرفيًا، والفرق الدلالي هو كل ما يفصل بين الاثنين ويستحق أن يُنقَش: None تعني "لا قيمة، نهائيًا، توقّف عن السؤال." Pending تعني "لا قيمة الآن، لكن اسألني مجددًا — قد أكون جاهزًا لاحقًا." الأول غياب دائم؛ الثاني تأجيل مؤقت. هذا الفارق الدقيق هو محرّك كل ما يلي.
async fn و.await سكّر نحوي كامل فوق تطبيق يدوي لـ Future — تمامًا كما for كانت سكّرًا فوق Iterator (الإقليم ٠٦). حين تكتب:
async fn read_two(a: &mut Conn, b: &mut Conn) -> (u8, u8) {
let x = read_one_byte(a).await;
let y = read_one_byte(b).await;
(x, y)
}
يولّد المترجم — تقريبًا — نوعًا يشبه هذا (تبسيط أمين، لا مخلّ، للفكرة الجوهرية):
enum ReadTwoState {
Start,
WaitingA { /* حالة محفوظة */ },
WaitingB { x: u8 /* + حالة محفوظة */ },
Done,
}
لاحظ: enum مجددًا. كل نقطة .await تصبح فرعًا محتملًا لحالة الآلة؛ poll() على هذه الآلة يفحص أين توقفنا آخر مرة، يحاول التقدّم خطوة (عبر استدعاء poll على المستقبل الفرعي الذي ننتظره)، ويعيد Pending إن لم يتقدّم، أو ينتقل للحالة التالية ويكرّر حتى Done. async fn وحدها لا تُنفِّذ شيئًا عند استدعائها — فقط تبني قيمة هذه الآلة، خامدة تمامًا، تمامًا كسل المنتِجات في الإقليم ٠٦ لكن بخطوة أبعد: لا حركة إطلاقًا بلا مُستدعٍ خارجي صريح لـpoll.
كل هذا كود عادي مُولَّد، بلا heap، بلا جدول قفزات ديناميكي، بلا مفسّر يمشي بايت-كود — نسخة أخرى، ربما الأعمق، من زيرو-كوست الإقليم ٠٤: الآلة المولَّدة مطابقة تقريبًا لما كنت لتكتبه يدويًا بحالة صريحة وcallbacks متسلسلة، لولا أن المترجم فعل ذلك عنك.
الدرس ٢: من يستدعي poll أصلًا؟ — المنفِّذ (executor)
هنا القرار المعماري الذي يفصل Rust عن Node جذريًا. Node تضمن دائمًا event loop واحدًا (جزء من V8+libuv، لا خيار آخر). Rust لا تشحن أي منفِّذ افتراضيًا — Future سمة في std، لكن من يستدعي poll() متكررًا، ويتكامل مع النظام (epoll/kqueue — نفس ما بنى عليه libuv الذي تعرفه) ليُنيّم الخيط فعليًا حتى تصير مهمة جاهزة بدل اللف الحارق للمعالج، مكتبة خارجية تختارها أنت.
لماذا هذا القرار تحديدًا؟ لأن احتياجات "المنفِّذ" تتضارب جذريًا بين الاستعمالات: خادم ويب يريد آلاف المهام الصغيرة على عدة أنوية؛ نظام مُدمَج (embedded) على معالج صغير يريد منفِّذًا بلا أي heap إطلاقًا؛ برنامج علمي يريد التوازي الأقصى. فرض منفِّذ واحد على الجميع يعني إجبار كل برنامج على دفع ثمن حالة لا يحتاجها — نقض مباشر لروح زيرو-كوست. الحل: معيار مشترك (Future)، وتنفيذ حرّ (tokio، async-std، smol، أو منفِّذك الخاص الذي ستبنيه الآن).
ابنِ منفِّذًا مصغّرًا — معلم البناء الأول في هذا الإقليم
قبل الاعتماد على tokio، ابنِ نسخة مبسَّطة قصدًا وصريحة عن تبسيطها: منفِّذ يلفّ (busy-poll) بدل أن ينام حتى يُوقَظ. هذا غير فعّال في الإنتاج (يستهلك نواة معالج كاملة فارغة) — لكنه صحيح، ويكفي تمامًا ليكشف لك الآلة الحقيقية بلا التعقيد الإضافي لجدول Waker الحقيقي الذي يستعمله tokio (ذاك يستحق منهجًا بذاته).
المتطلبات:
- عرّف نسخة مبسَّطة من السمة تتجاهل
Context/Wakerتمامًا (اللف الحارق يغني عنهما مؤقتًا):trait TinyFuture { type Output; fn poll(&mut self) -> Poll<Self::Output>; }. - ابنِ يدويًا (لا عبر
async fn) مستقبَلًا بسيطًا يُحاكي عملًا بطيئًا:struct Countdown { remaining: u32 }يُعيدPoll::Pendingفي أول N نداء لـpoll، ثمPoll::Ready(value). - ابنِ
struct Executor { tasks: Vec<Box<dyn TinyFuture<Output = ()>>> }بطريقةrun(&mut self): تلفّ على كل مهمة، تستدعيpoll، وتُزيل أي مهمة انتهت (Ready)، وتكرّر حتى تفرغ القائمة. - شغّل عدة
Countdownبعتبات مختلفة معًا على منفِّذك، واطبع ترتيب اكتمالها — تحقق أنه يطابق توقعك (الأسرع أولًا، لا بالضرورة الأقدم إضافة).
بعد أن يعمل هذا: حاول (بلا ضغط لإكماله — استكشاف حرّ) كتابة async fn حقيقية وتشغيلها على منفِّذك المصغَّر. ستصطدم بجدار: السمة الحقيقية std::future::Future تشترط Pin<&mut Self> وContext حقيقيَين — وهنا موعدك مع الدرس التالي.
الدرس ٣: لماذا Pin؟ — حين تشير الآلة لنفسها
تخيّل آلة حالة مولَّدة من async fn تحوي متغيّرًا محليًا x، ومرجعًا لـ x نفسه يعبر نقطة .await (شائع: let x = compute(); let r = &x; something(r).await;). كلاهما — x والمرجع إليه — يعيشان داخل نفس الحقل المولَّد لآلة الحالة. هذه بنية ذاتية الإشارة (self-referential): حقل يشير لحقل آخر في نفس القيمة.
تذكّر الإقليم ٠١: النقل (move) في Rust نسخ بتّي للموضع الجديد. لو تحرّكت هذه الآلة في الذاكرة (نُقلت لموضع جديد على الـ stack، كما يحدث بحرّية تامة لأي قيمة عادية)، يبقى المرجع الداخلي r يشير للعنوان القديم — متدلٍّ فورًا، رغم أن كل شيء بدا سليمًا وقت الإنشاء. هذا نوع جديد تمامًا من الخطر، لم تقابله من قبل، لأن كل بنية بنيتَها سابقًا كانت إمّا بلا مراجع داخلية، أو مراجعها لأشياء خارجها (محروسة بأعمار ٠٥ العادية).
Pin<P> (حيث P مؤشر كـ &mut T أو Box<T>) هي وعدٌ في النوع: "القيمة خلف هذا المؤشر لن تتحرك في الذاكرة بعد الآن، طالما هي مثبَّتة." هذا الوعد يسمح لآلات الحالة ذاتية الإشارة بالوجود بأمان — والمترجم يفرضه عبر سمة Unpin (تلقائية كـSend/Sync من الإقليم ٠٨؛ معظم الأنواع Unpin — أي "لا مشكلة في تحريكها"، وآلات الحالة التي async تولّدها هي الاستثناء المتعمَّد). لن تبني Pin بنفسك في هذا المنهج — الأساس الذي تقف عليه (مؤشرات وعقود غير مُثبَتة بالنوع وحده، مضمونة برمجيًا) هو نفسه جوهر الإقليم ٠٩؛ يكفيك الآن أن ترى لماذا كل توقيع async حقيقي في Rust يذكر Pin<Box<dyn Future<...>>> بكثرة، ولا يبدو لك سحرًا غامضًا.
الدرس ٤: العالم الحقيقي — tokio
#[tokio::main]
async fn main() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:7878").await.unwrap();
loop {
let (socket, _addr) = listener.accept().await.unwrap();
tokio::spawn(async move {
handle_connection(socket).await;
});
}
}
tokio::spawn توأم thread::spawn (٠٨) — لكنها تجدول مهمة (task) لا خيط نظام: بضع مئات البايتات (حجم آلة الحالة) بدل ميغابايتات، وجدولتها تعاونية عبر منفِّذ tokio (متعدد الخيوط افتراضيًا — توازي حقيقي عبر الأنوية، بخلاف خيط event loop الوحيد في Node ما لم تلجأ لـ worker threads يدويًا). هذا ما يجعل عشرات الآلاف من الاتصالات المتزامنة عملية: لا تدفع ثمن خيط نظام كامل لكل اتصال، بل ثمن آلة حالة صغيرة يُعاد جدولتها بكفاءة.
فخّ يستحق معرفته قبل أن تصطدم به
std::sync::Mutex (الإقليم ٠٨) تُنيّم الخيط بأكمله عند الانتظار على قفل مشغول — مقبول بين خيوط نظام، لكن كارثي داخل مهمة async: تنويم الخيط يجمّد كل المهام الأخرى المجدولة على ذلك الخيط أيضًا، لا مهمتك وحدها. الأخطر: الاحتفاظ بـ MutexGuard من std::sync::Mutex عبر نقطة .await قد يُنتج قفلًا معلَّقًا فعليًا (deadlock) لأن آلة الحالة قد تُستأنف على خيط آخر يحاول قفل نفس الـMutex بينما القفل الأول ما زال ممسوكًا من مهمة معلَّقة على خيط مختلف تمامًا. الحل الاصطلاحي: إمّا تأكد أن MutexGuard يُسقَط قبل أي .await (نطاق {} صريح ضيق حول القفل)، أو استعمل tokio::sync::Mutex (مصمَّمة خصيصًا لتكون آمنة عبر .await، بثمن أداء أعلى قليلًا من النسخة المتزامنة). احمل هذا في ذهنك قبل الكابستون التالي — ستحتاجه فعليًا.
الكابستون النهائي: خادم مفتاح/قيمة متزامن
ابنِ خادم TCP يتحدث بروتوكولًا نصيًا بسيطًا تصممه أنت بالتفصيل فوق الهيكل التالي، ويخدم عملاء متعددين بالتزامن الحقيقي:
البروتوكول (الشكل العام — املأ التفاصيل):
- كل سطر أمر منفصل:
SET <key> <value>,GET <key>,DEL <key>. - ردود:
OK,VALUE <value>,NOT_FOUND,ERROR <message>— كل ردٍّ سطر واحد ينتهي بـ\n.
المتطلبات المعمارية:
- حلقة قبول (
accept loop) عبرtokio::net::TcpListener؛ كل اتصال يُدار فيtokio::spawnمستقلة (أعد استعمال نمط الدرس ٤). - حالة مشتركة واحدة (
HashMap<String, String>) خلفArc<Mutex<...>>أوArc<tokio::sync::Mutex<...>>— قرارك، لكن وثّق لماذا اخترتَ أحدهما بالرجوع لفخّ الدرس ٤ صراحةً في تعليق. - تحليل كل سطر وارد إلى أمر عبر
enum Command(أعد توظيف مهارات ٠٣ ومهارات تحليل الأسطر بالمنتِجات من ٠٦) — أمر غير مفهوم يُنتجERRORللعميل، لا panic يُسقط الاتصال، ولا بأي حال يُسقط الخادم كاملًا. - اتصال ينقطع فجأة (خطأ قراءة/كتابة) يُغلَق ويُنظَّف بهدوء (اطبع سطر تشخيصي في طرف الخادم إن أردت) — بقية الاتصالات تستمر بلا تأثر.
- اختبره يدويًا بأداة
nc(تعرفها من أدوات الشبكة في Network_Holberton): افتح جلستين متوازيتين بـnc 127.0.0.1 <port>، نفّذSET/GETمن كليهما بالتبادل والتزامن، وتأكد أن كلا الجلستين ترى نفس الحالة المشتركة محدَّثة بشكل صحيح، وأن قفل جلسة واحدة (بانتظار مدخل المستخدم) لا يجمّد الجلسة الأخرى — هذا هو الإثبات العملي أن التزامن يعمل فعليًا لا شكليًا.
لا حلّ مرجعيًا لهذا الكابستون. حين يعمل، وتُثبت بيدك أن جلستين منفصلتين لا تُعطّل إحداهما الأخرى — تلك اللحظة هي إثبات ملموس أنك بنيت، من الصفر عمليًا، الحل الذي حاول C10K كسره منذ عقدين، بلغة رفضت GC ورفضت runtime مفروضًا وربحت المعركة رغم ذلك.
خاتمة المنهج
لا "الخلاصة" المعتادة هنا — لا إقليم قادم يستقبل بذرة. بدل ذلك: انظر خلفك على الخريطة كاملة.
بدأتَ ببديهيتين اثنتين فقط (٠١، ٠٢). كل شيء تالٍ — الأنواع الجبرية (٠٣)، التجريد بلا ثمن (٠٤)، تسمية الزمن (٠٥) — كان توسعة مباشرة لهما. ثم رأيتَ ثلاثية {تمليك، استعارة، استعارة حصرية} تتكرر متنكّرة في كل زاوية من اللغة (٠٦). ثم رأيتَ البديهيتين تتفاوضان بأدوات صريحة بدل أن تنكسرا (٠٧). ثم رأيتَهما تحلّان مشكلة لم تُصمَّما لها أصلًا (٠٨). ثم نزلتَ تحتهما لترى القلب غير الآمن الذي تقف عليه المكتبة القياسية بأكملها (٠٩). وأخيرًا رأيتَ enum بسيطًا آخر (Poll) يبني، عبر المترجم، لغة كاملة ثانية فوق كل ما سبق (١٠). هذه ليست عشر ميزات منفصلة تحفظها — إنها بنية واحدة، من سطرين، تفرّعت.
افتح الآن الملاحق: شجرة المهارات مكتملة، النماذج الذهنية، والورقة المرجعية. اقرأها كخريطة لما بنيتَه، لا كمادة جديدة.
ما تركناه عمدًا خارج هذا المنهج (بذور لمن يريد الغوص أعمق لاحقًا): عمق Pin/Unpin الكامل وWaker الحقيقي، الماكرو (macro_rules! وproc macros)، const generics وGATs، no_std/الأنظمة المدمجة، تجميع WASM، تفاصيل variance مع الأعمار، وأطر عمل الويب الكاملة (axum/actix) وserde. كل واحدة منها تقف الآن على أرض صلبة بنيتَها بيدك.