SELFLAB الستاك الكامل · ١١ إقليم SYSTEM ONLINE
الإقليم 02منهجٌ يُدرَس وحدك

الإقليم ٢ — Node من جذور C: كيف لا ينام خيطٌ واحد

يبني فوق: epoll/select/poll و non-blocking I/O والـ sockets من C ومنهج الشبكات؛ والإغلاق (closure) من الإقليم ١. يفتح: Express (٣)، ولماذا bcrypt غير متزامن (٧)، ولماذا التصحيح يفرّع عمليات (١٠).

النبذة

في الإقليم ١ تركنا لغزاً معلّقاً: server.ts ينادي app.listen(PORT, () => {...})، ثم لا ينتهي البرنامج — يظلّ يستقبل طلباتٍ بلا حدّ، بخيطٍ واحد، بلا while(1) ظاهرة، وawait فيه "ينتظر" دون أن يجمّد شيئاً. لو حاولتَ شرح هذا بعقلية C البحتة لانهار التفسير. لكن المفاجأة أنك تملك المفتاح أصلاً: Node ليس إلا epoll — الذي بنيتَ عليه فهمك للشبكات في C — وقد لُفّ بلغةٍ كانت مصمَّمةً مسبقاً لتلبسه. من يفهم هذا الإقليم، يتوقّف عن رؤية الـ backend سحراً.

هذا أعمق إقليمٍ مفاهيميٍّ في المنهج. خذ وقتك. كل ما بعده يقف عليه.


اللغز المستفزّ

أنت مبرمج C. اكتب (ذهنياً) خادماً يخدم عميلين عبر TCP. الطريقة الساذجة:

c
while (1) { int client = accept(server_fd, ...); // انتظر اتصالاً char buf[1024]; read(client, buf, sizeof buf); // ← اقرأ طلبه process_and_respond(client, buf); close(client); }

الآن السمّ: العميل الأول اتّصل لكنه بطيء — شبكتُه رديئة، أو يكتب طلبه حرفاً حرفاً، أو ما أرسل الجسم بعد. سطر read(client, ...) يحجب الخيط حتى تصل بايتات. وما دام الخيطُ محجوزاً على العميل الأول، العميل الثاني يتضوّر جوعاً — لم يصل حتى إلى accept الثانية. خادمك ذو "التزامن = ١".

الحلّ الذي تقفز إليه عقلية C فوراً: خيطٌ لكل عميل (pthread_create عند كل accept). جميل — حتى تصل ١٠٬٠٠٠ عميلٍ متزامن:

  • كل خيطٍ يحمل stack خاصاً (غالباً ١–٨ ميغابايت). ١٠٬٠٠٠ خيط = جيغاباياتٌ من الذاكرة، معظمها فارغ.
  • المجدوِل (scheduler) يقفز بينها (context switching) آلافَ المرّات في الثانية، وكلُّ قفزةٍ ضريبة. تقضي الـ CPU وقتها في التنقّل لا في العمل.

هذه مشكلةٌ لها اسمٌ تاريخيّ: C10K (كيف تخدم عشرة آلاف اتصالٍ متزامن؟). فالآن السؤال المستفزّ، وقد عرفتَ أن الخيوط لا تكفي:

ملاحظة

اخدم آلافَ العملاء البطيئين المتزامنين بخيطٍ واحد، بلا خيطٍ لكل عميل. كيف؟

أنت تعرف الجواب. استعملته في منهج الشبكات وفي C بلا أن تسمّيه "النموذج الذي يبني عليه خادمٌ بمليار مستخدم". اقعد معه قبل أن تكمل.


ليش: ثلاثة طرقٍ للتزامن، وأيّها اختارت Node ولماذا

الطريق ١ — الحجب + خيطٌ لكل عميل (الذي رفضناه)

بسيطٌ ذهنياً، لكنه ينهار عند الحجم (ذاكرة + context switching). يصلح لمئات الاتصالات، لا لعشرات الآلاف. هذا طريق خوادمَ كلاسيكية (نمط أباتشي القديم).

الطريق ٢ — non-blocking + busy polling (نصفُ حلّ)

اجعل الـ socket غير حاجب: fcntl(fd, F_SETFL, O_NONBLOCK). الآن read() لا يحجب — إن لم تكن هناك بايتات، يرجع فوراً بـ -1 وerrno == EAGAIN ("لا شيء الآن، عُد لاحقاً"). فتقدر تمرّ على كل العملاء بخيطٍ واحد:

c
for (each client fd) { n = read(fd, buf, size); if (n > 0) handle(fd, buf); else if (errno == EAGAIN) continue; // ليس جاهزاً، التالي } // كرّر للأبد...

حللنا الحجب! لكن خلقنا داءً جديداً: هذه الحلقة تدور بلا توقّفٍ تستنزف الـ CPU بنسبة ١٠٠٪ حتى لو لم يكن أحدٌ جاهزاً، تسأل آلافَ الـ fds "جاهز؟ جاهز؟" بلا راحة. هدرٌ صرف.

الطريق ٣ — non-blocking + إشعار الجاهزية من النواة (الحلّ)

ماذا لو قلتَ للنواة: "هذه ألفُ fd. نوّمني، وأيقظني فقط حين يصير أيٌّ منها جاهزاً، وقل لي أيّها"؟ هذا بالضبط ما تفعله select/poll، وبكفاءةٍ عالية epoll (لينكس) وkqueue (BSD/ماك):

c
int epfd = epoll_create1(0); // سجّل كل الـ fds التي تهمّك... while (1) { int n = epoll_wait(epfd, events, MAX, -1); // ← ينام هنا، صفر CPU، حتى جاهزية for (int i = 0; i < n; i++) { handle(events[i].data.fd); // عالِج فقط ما جهُز فعلاً } }

هذا الهيكل — حلقةٌ واحدةٌ تنام على epoll_wait، تستيقظ على الأحداث الجاهزة، تنادي لكلٍّ معالِجَه، ثم تعود تنام — له اسمٌ في تصميم الأنظمة: نمط المُفاعِل (reactor pattern)، أو حلقة الأحداث (event loop). خيطٌ واحد، لا حجب، لا busy poll، يخدم عشرات الآلاف لأن الانتظار "مجّانيٌّ" (نومٌ في النواة)، والعمل الوحيد هو معالجة ما جهُز فعلاً.

نموذج ذهني

النموذج الذهني (هذا هو Node كلّه): خادمٌ سريعٌ ليس خادماً يفعل أشياءَ كثيرةً دفعةً واحدة — بل خادمٌ لا ينتظر أبداً وهو واقف. ينام حين لا عمل، ويعمل فقط على ما جهُز. التزامن الظاهريّ ليس توازياً (parallelism)؛ إنه تشذيرٌ ذكيّ (interleaving): يقدّم خطوةً لهذا، فخطوةً لذاك، فلا أحد يحجب الآخر. الـ CPU خيطٌ واحد، لكنه لا يقف عند I/O أبداً.

فلماذا JavaScript بالذات لبست هذا النمط؟

هنا الجمال: JS وُلدت أصلاً على هذا الشكل. في المتصفّح خيطُ واجهةٍ واحد، وكلُّ شيءٍ حدثٌ ذو callback: "حين يُنقَر هذا الزرّ، نفّذ هذه الدالة". لا خيوط، لا read() حاجب — حلقةُ أحداثٍ تنادي callbacks. حين أراد Ryan Dahl خادماً يحلّ C10K بأناقة، وجد لغةً شكلُها هو شكلُ المُفاعِل سلفاً: خيطٌ واحد، دوالٌّ كقيمٍ (الإغلاق من الإقليم ١!)، وثقافةُ callbacks. ركّب محرّك V8 (الذي ينفّذ JS) فوق مكتبة libuv (التي تجرّد epoll/kqueue/IOCP عبر الأنظمة)، فولدت Node.

ملاحظة

Node = V8 (ينفّذ JS) + libuv (حلقة أحداثٍ فوق epoll/kqueue) + مكتباتٌ قياسية. "ضعف" JS المزعوم (خيطٌ واحد، callbacks) هو بالضبط شكلُ الحلّ الذي تحتاجه لمشكلة C10K. ما بدا عيباً صار الميزة.


كيف: تشريح آلة Node

القطعة الواحدة، والحلقة

في أي لحظة، خيطٌ واحدٌ ينفّذ JS — لديه call stack واحد، تماماً كـ C: تُنادى دالةٌ فتُكدَّس، تعود فتُنزَع. القاعدة الحديدية: ما دامت دالةٌ على الـ stack، لا شيءَ آخرَ يعمل — لا callback، لا طلب، لا شيء. الحلقة معطّلة حتى يفرغ الـ stack.

حين تنادي عمليةَ I/O غير متزامنة (اقرأ ملفاً، استعلم قاعدة بيانات، انتظر شبكة)، لا تحجب. بدلاً منها:

  1. Node يسلّم العملية لـ libuv (التي تسجّل الـ fd في epoll، أو ترميها لمجمّع خيوط — انظر أدناه).
  2. تسجّل callback (إغلاق! يحمل سياقه) ليُنادى عند الاكتمال.
  3. الدالة الحالية تكمل وتعود فوراً، يفرغ الـ stack، وتتحرّر الحلقة لخدمة غيرها.
  4. لاحقاً، حين تخبر النواةُ libuv أن العملية جهُزت، تضع الحلقةُ الـ callback في طابور لتنفّذه حين يفرغ الـ stack.

طابوران: المهامّ الكبرى والمهامّ الدقيقة (macro/microtasks)

ليس طابوراً واحداً، بل تراتبيّةٌ تحدّد ترتيب تنفيذ ما أُجِّل:

القاعدة التنفيذية: نفّذ الكود المتزامن حتى يفرغ الـ stack → فرّغ كل الـ microtasks → خذ macrotask واحدة → فرّغ كل الـ microtasks ثانيةً → وهكذا. هذا يفسّر اللغز الكلاسيكيّ:

js
console.log("1"); setTimeout(() => console.log("2"), 0); // macrotask Promise.resolve().then(() => console.log("3")); // microtask console.log("4"); // الترتيب: 1 ، 4 ، 3 ، 2 // المتزامن أولاً (1،4)، ثم تُفرَّغ microtasks (3)، ثم macrotask (2) — رغم أن مهلتها صفر!

setTimeout(fn, 0) لا يعني "نفّذ الآن"، بل "ضعه في طابور المهامّ الكبرى لأقرب دورة" — وكلُّ الـ microtasks تسبقه. هذه ليست تفاهة: ترتيبُ آثارٍ خفيٍّ كهذا مصدرُ أخطاءٍ حقيقيةٍ ستلتقيها.

حلُّ لغز await: ينتظر دون أن يحجب

الآن نحسم تناقض الإقليم ١. await somePromise:

  1. يعلّق الدالة الـ async عند هذا السطر، ويحفظ "بقيّتها" (ما بعد await) كأنها callback (إغلاقٌ يلتقط كل متغيّراتها المحلية).
  2. يعيد التحكّم فوراً للحلقة — يفرغ الـ stack، فتخدم الحلقةُ طلباتٍ أخرى.
  3. حين يُحَلّ الـ Promise، تُجدوَل "البقيّة" في طابور microtasks، فتُستأنف الدالة من حيث تعلّقت.

إذن "الانتظار" هنا = تنازلٌ للحلقة + استئنافٌ لاحقٌ عبر callback، لا حجزُ الخيط. لهذا يخدم خادمٌ بخيطٍ واحدٍ آلافَ الطلبات المتزامنة التي كلٌّ منها "ينتظر" قاعدةَ البيانات: بينما ينتظر طلبٌ استعلامَه، الخيطُ يخدم العشرات غيره. await سكّرٌ نحويٌّ جميلٌ فوق "سجّل callback وتنازل" — يجعل الكود يُقرأ متسلسلاً وهو يُنفَّذ مُشذَّراً. هذه عبقرية النموذج.

الشرخ الخطير: العمل المحسوب يجمّد الجميع

ما دام خيطٌ واحدٌ ينفّذ JS، فإن أي عملٍ متزامنٍ طويل (حلقةُ حسابٍ ثقيلة، تجزئةٌ متزامنة، معالجةُ ملفٍّ ضخمٍ في الذاكرة) يحتكر الـ stack ويجمّد الحلقة كلّها — كل العملاء يتسمّرون حتى ينتهي. هذا أخطر فخٍّ في Node:

js
// كارثة: يجمّد كل الطلبات لثوانٍ app.get("/bad", (req, res) => { let s = 0; for (let i = 0; i < 5e9; i++) s += i; // عملٌ محسوبٌ متزامن طويل res.json({ s }); });

علاجٌ ثلاثيّ، كلّه "أخرِج العمل الثقيل من خيط الحلقة":

قاعدة ذهبية

القاعدة الذهبية لإدارة أي خادم Node: خيطُ الحلقة للتنسيق لا للكدّ. كل ثانيةٍ يقضيها في حساب، يتجمّد فيها كلُّ مستخدميك. أبقِ المعالِجات قصيرةً ولا-حاجبة، وادفع الثقيل إلى المجمّع أو عمليّةٍ أو خيطِ عامل.


كيف: من أين تأتي express؟ (الوحدات و npm)

في الإقليم ١ سألت: import express from "express" بلا ./ — من أين؟ الجواب نظامُ حزم Node:

@types/express في devDependencies مشروعك يستحقّ وقفة: حزمةٌ تحوي تعليقات TypeScript فقط لمكتبةٍ كُتبت بـ JS. لأن الأنواع تُمحى (الإقليم ١)، تُوزَّع منفصلةً كي يحرسك المترجمُ وقتَ الكتابة دون أن تُثقِل وقت التشغيل. تطبيقٌ مباشرٌ للـ erasure.


كيف: process، البيئة، والـ globals

Node ليس متصفّحاً: لا window ولا document ولا DOM. بدلاً منها process (العمليّة نفسها) وعالمُ خادم:

Streams: الجسم يصل قطعاً (يربط تأطير الإقليم ٠)

req وres في Node ليسا قيمتين جاهزتين — هما streams: الجسم لا يصل دفعةً، بل قطعاً (chunks) عبر أحداث data، حتى حدثِ end. تذكّر تأطير الإقليم ٠: لا حدودَ للرسائل في TCP، فتقرأ حتى تستوفي Content-Length. هكذا تقرأ الجسمَ الخام في Node:

js
let body = ""; req.on("data", (chunk) => { body += chunk; }); // قطعةٌ قطعة req.on("end", () => { const data = JSON.parse(body); /* ... */ });

هذا مملٌّ ومتكرّرٌ لكل طلب — وهنا يولد العجز الذي يسدّه Express (الإقليم ٣): express.json() يفعل هذا التجميع والتحويل عنك. لكنك سترى الآلية الخام أولاً كي لا يكون الإطار سحراً.


اللغز / البناء من الصفر

أدواتك: node فقط (لا إطار، لا مكتبة خارجية). تستعمل وحدتَي Node القياسيّتين net (TCP خام) وhttp. هنا تحوّل سودوكود اللغز (د) من الإقليم ٠ إلى خادمٍ حقيقيّ.

اللغز أ — توقّع ترتيب الحلقة. اكتب بقلمك ترتيب الطباعة قبل التشغيل، ثم شغّل وفسّر كل سطرٍ بنموذج stack/microtask/macrotask:

js
console.log("A"); setTimeout(() => console.log("B"), 0); Promise.resolve().then(() => console.log("C")).then(() => console.log("D")); (async () => { console.log("E"); await null; console.log("F"); })(); console.log("G");

(حيثما أخطأت، لا تمرّ: أيُّ قاعدةٍ — تفريغُ المتزامن، أولويّةُ microtasks، تأجيلُ await لِما بعده — قادتك للخطأ؟)

اللغز ب — خادم HTTP خام بلا إطار. بوحدة http فقط، اكتب خادماً:

  • يردّ على GET /api/v1/health بـ JSON {"success":true,"data":{"status":"ok"}} ورمزِ 200، بضبط Content-Type وContent-Length بنفسك (تذكّر الإقليم ٠ — لا تدع المكتبة تخفيها عنك؛ تحقّق أنك تفهم لماذا يلزمان).
  • يقرأ جسم POST /api/v1/echo قطعةً قطعة عبر أحداث data/end، يحوّله بـ JSON.parse، ويعيده. لاحظ: ماذا يحدث لو أرسل العميل JSON مكسوراً؟ (بذرة الإقليم ٦: من يحرس هذا؟)
  • يردّ على أي مسارٍ آخر بـ 404 ومظروفٍ موحَّد.

اللغز ج — أثبت أن الخيط واحد (المختبر الحاسم). أضِف لخادم (ب) مسارين:

  • GET /slow-bad: ينفّذ حلقةً متزامنةً ثقيلة (for بمليارات) ثم يردّ.
  • GET /fast: يردّ فوراً.

شغّل الخادم، واطلب /slow-bad في تبويب، و/fast في آخر أثناء عمل الأول. لاحظ: /fast يتجمّد حتى ينتهي /slow-bad. هذا الشرخ بعينك — خيطٌ واحدٌ احتكرته حلقةُ حساب. ثم أصلِحها: حوّل العمل الثقيل إلى عمليّةٍ منفصلةٍ عبر child_process (أو قسّمه عبر setImmediate)، وأعِد التجربة، وراقب /fast يتنفّس. حين تراها بعينك، فهمتَ Node — وفهمت سلفاً لماذا يفرّع التصحيحُ (الإقليم ١٠) عمليّاتٍ بدل أن يصرّف C على خيط الحلقة.

اللغز د (تحليليّ، بلا كود): ارسم على ورقةٍ ما يحدث لحظةً بلحظةٍ على خيط الحلقة حين يصل طلبان متزامنان، كلٌّ منهما معالِجُه async ينتظر "قاعدة بيانات" (حاكِها بـ await new Promise(r => setTimeout(r, 1000))). أين يكون كلُّ معالِجٍ حين ينتظر الآخر؟ متى يفرغ الـ stack؟ لماذا لا يتجمّد أحدٌ مع أن كليهما "ينتظر ثانية"؟ هذا الرسم هو فهمُك للتزامن اللا-حاجب، محكَّ النار.

ملاحظة

لا تنتقل قبل أن ترى بعينك في اللغز (ج) كيف يجمّد العملُ المتزامنُ الجميعَ، وكيف يحرّرهم إخراجُه. هذه أهمّ مشاهدةٍ في الإقليم.


الخلاصة — أين تتّصل هذه العقدة بالشجرة

  • تحت: Node ليس لغزاً — هو نمط المُفاعِل فوق epoll/kqueue الذي تعرفه من C ومن منهج الشبكات، مغلَّفٌ بـ libuv، يقوده محرّك V8. خيطٌ واحدٌ + I/O لا-حاجب + دوالٌّ كـ callbacks (الإغلاق من الإقليم ١) + حلقةٌ ذاتُ طابورين = تزامنٌ بلا خيوطٍ لكل عميل. حُلّ C10K بالتشذير لا التوازي.
  • العقدة الجديدة: آلة التنفيذ — call stack واحد، macro/microtasks، await كتنازلٍ-واستئنافٍ لا كحجب، خيطُ الحلقة المقدّس الذي يجمّده العملُ المحسوب فيُدفَع للمجمّع/العمليّات. + نظامُ الوحدات و npm و process.env، والـ streams كقطعٍ تربط تأطير الإقليم ٠.
  • فوق: الـ streams الخام والـ 404 اليدويّ يولّدان عجزاً يسدّه Express (٣). خيطُ المجمّع يفسّر bcrypt اللا-متزامن (٧). child_process اللا-حاجب هو عمودُ التصحيح (١٠). وprocess.env هو كيف يصل DATABASE_URL إلى Prisma (٥) وJWT_SECRET إلى الهوية (٧).

الأبواب المفتوحة: كيف نحوّل if (url === ...) المتكرّر إلى توجيهٍ (routing) نظيف؟ (٣). كيف يصير تجميعُ الجسم وتحويله express.json()؟ (٣). كيف لا يحجب استعلامُ قاعدةِ البيانات الخيطَ؟ (٥، نفس مبدأ هذا الإقليم).


الوصلة للواقع (نمط SelfLab)

ارجع إلى backend/src/server.ts:

ts
const PORT = process.env.PORT || 3000; // البيئة، لا ثابت app.listen(PORT, () => { console.log(...) }); // يسجّل الخادم في الحلقة ويبقيها حيّة

app.listen يفتح socket استماعٍ ويسجّله في حلقة libuv، فلا "ينتهي" البرنامج — الحلقة حيّةٌ تنتظر اتصالات (نومٌ على epoll_wait، صفر CPU بين الطلبات). والمعالِج في user.controller.ts:

ts
export async function createUser(req: Request, res: Response) { // ... password: await bcrypt.hash(password, 10), // ينتظر مجمّعَ الخيوط بلا حجب الحلقة const newUser = await prisma.user.create(...) // ينتظر قاعدةَ البيانات بلا حجب }

async لأن فيه await؛ وكلُّ await هنا تنازلٌ للحلقة كي تخدم طلباتٍ أخرى أثناء انتظار التجزئة وقاعدة البيانات. بينما يُجزّئ هذا الطلبُ كلمةَ مرورٍ على المجمّع، يخدم خيطُ الحلقة عشراتٍ غيره. هذا هو "كيف لا ينام خيطٌ واحد" في كود مشروعك حرفياً.

بذرة الإقليم التالي

البذرة التالية: خادمُك الخام في اللغز (ب) اشتغل — لكن انظر كم سطرٍ كلّفك 404 واحد، وقراءةُ جسمٍ واحدة، وتمييزُ مسارٍ من مسار. تخيّل عشرين مساراً بأفعالٍ مختلفة، وتحقّقاً، وهويةً، ومعالجةَ أخطاءٍ موحّدة — بـ if/else على req.url وreq.method؟ جحيم. ولِد إطارٌ ليقتل هذا التكرار بفكرةٍ واحدةٍ أنيقة: سلسلةٌ من الدوال يمرّ بها كلُّ طلب. ننزل للإقليم ٣ ونبني Express المصغّر بيدنا، ثم نفهم لماذا وُجد الأصل.

الستاك الكامل · من باب الخادم إلى داخله ليش قبل كيف · صُمّم لـ يزيد