Node.js 26.6: نقل الـ TCP sockets إلى worker threads

في 3 أغسطس 2026 نزل إصدار Node.js 26.6.0، ومعه تغيير سطره في سجل التغييرات قصير لكن أثره المعماري كبير: صار بالإمكان نقل كائنات net.Server وnet.Socket بين الـ worker threads. يعني الاتصال الشبكي نفسه — لا نسخة منه — ينتقل من thread إلى آخر داخل نفس العملية.

المشكلة: نواة واحدة تشتغل والبقية تتفرج

كود JavaScript في Node.js يعمل على thread واحد. الجهاز فيه ثمان أنوية أو ست عشرة، والعملية الواحدة تستهلك واحدة منها فقط. الحل التقليدي كان وحدة cluster: تشغّل عدة عمليات منفصلة، ويوزّع النظام الاتصالات بينها.

هذا الحل يعمل، لكن ثمنه معروف لمن جرّبه في الإنتاج. كل عملية تحمل ذاكرة مستقلة، وقاعدة اتصالات مستقلة بقاعدة البيانات، ونسخة مستقلة من الـ cache في الذاكرة. أي بيانات مشتركة تحتاج طبقة خارجية مثل Redis. والتواصل بين العمليات يمر عبر IPC، وهذا يعني تحويل كل رسالة إلى تسلسل بايتات ثم إعادة بنائها في الطرف الآخر.

الـ worker threads قدّمت البديل منذ سنوات: threads داخل عملية واحدة، تتشارك الذاكرة عبر SharedArrayBuffer، وبلا تكلفة عمليات منفصلة. لكن بقي فيها حاجز واحد: الشبكة. الاتصال الذي يُقبَل على الـ thread الرئيسي يبقى مربوطاً به، وكل ما يمكن فعله هو نسخ البيانات إلى الـ worker ثم نسخ النتيجة رجوع.

ما الذي تغيّر

القائمة transferList في postMessage() صارت تقبل نوعين جديدين: net.Server وnet.Socket. التوثيق الرسمي يذكرهما الآن إلى جانب ArrayBuffer وMessagePort وFileHandle.

هذا يفتح نمطين عمليين:

  • نقل الـ server نفسه: ينتقل الـ listening socket ومعه الاتصالات المنتظرة في طابور القبول إلى الـ event loop الخاص بالـ thread المستقبِل، ويكمل عمله هناك.
  • نقل الاتصالات فرادى: تقبل الاتصالات على thread واحد وتوزّعها على مجموعة من الـ workers. هذا هو نمط موازن الحمل، وهو الأكثر فائدة عملياً.

مثال عملي

الملف الرئيسي يقبل ويوزّع:

js
const net = require('node:net');
const { Worker } = require('node:worker_threads');

const pool = Array.from({ length: 4 }, () => new Worker('./worker.js'));
let next = 0;

const server = net.createServer((socket) => {
  const worker = pool[next++ % pool.length];
  // الاتصال ينتقل فعلياً، بلا نسخ وبلا تسلسل
  worker.postMessage({ socket }, [socket]);
});

server.listen(8000);

والـ worker يستقبل الاتصال الحي ويتعامل معه كأنه قَبِله بنفسه:

js
const { parentPort } = require('node:worker_threads');
const http = require('node:http');

const app = http.createServer((req, res) => {
  res.end('handled inside a worker thread\n');
});

parentPort.on('message', ({ socket }) => {
  app.emit('connection', socket);
});

الفرق الجوهري في السطر worker.postMessage({ socket }, [socket]). الـ socket هنا لا يُنسخ ولا يُسلسَل؛ الـ handle نفسه ينتقل ملكيته إلى الـ thread الآخر.

قيود لا بد من معرفتها

  • أنظمة Unix فقط. على Windows يرمي postMessage() خطأ ERR_WORKER_HANDLE_TRANSFER_UNSUPPORTED. أي فريق يطوّر على Windows وينشر على Linux يحتاج مساراً بديلاً.
  • TCP فقط. لا دعم لـ UDP ولا للـ pipes.
  • الاتصال يجب أن يكون طازجاً. لم يبدأ القراءة، ولا يحمل بيانات في الذاكرة المؤقتة، ولا هو في طور الاتصال أو مُدمَّر. غير ذلك يرمي ERR_WORKER_HANDLE_NOT_TRANSFERABLE. عملياً: انقله داخل دالة الاتصال مباشرة، قبل أن تربط به أي مستمعين.
  • النقل انتقال ملكية، لا مشاركة. الـ socket يُدمَّر على الطرف المرسِل، وأي استخدام لاحق يرمي ERR_STREAM_DESTROYED بدل أن يبتلع البيانات بصمت — وهذا قرار تصميمي محمود.
  • الإصدار 26 لا يزال Current وليس LTS. الإصدار المستقر للإنتاج اليوم هو 24. من نشر على Node.js 26 فليتابع أيضاً تحديثات الأمان في نموذج الأذونات.
  • العزل يبقى ميزة الـ cluster. العمليات المنفصلة تعزل الأعطال؛ الـ threads تتشارك المصير. انهيار worker قد يُسقط العملية كاملة.

الخلاصة

المفاضلة لم تعد بين "نواة واحدة" و"عدة نوى"، بل بين شكلين من التوازي. الـ cluster يعطي عزلاً للأعطال ودعماً لكل الأنظمة واستقرار LTS اليوم. والـ worker threads مع الـ sockets القابلة للنقل تعطي ذاكرة واحدة مشتركة، و cache واحداً، ومجموعة اتصالات واحدة بقاعدة البيانات، بلا تكلفة تسلسل في المنتصف.

هذا ليس تفصيلاً في سجل تغييرات؛ هذه إعادة رسم لحدود ما تستطيع عملية Node.js واحدة أن تفعله.

المصدر الرسمي: توثيق net.Server و net.Socket في Node.js وملاحظات إصدار v26.6.0.

Node.js 26.6: نقل الـ TCP sockets إلى worker threads · bahashwan.dev