تعمل بوابات ترحيل شبكات VPN وخوادم التمويه وموجهات حافة الشبكات المؤسسية كحلقات وصل حيوية بين أنفاق العملاء المشفرة وعناقيد الخدمات الداخلية. في البنى البرمجية التقليدية، يتطلب توجيه حركة المرور العابرة من تطبيق وسيط تشغيل حلقات قراءة وكتابة متواصلة بين واصفات ملفات المقابس. وعند معالجة تدفقات شبكية مجمعة تتجاوز 20 إلى 40 جيجابت في الثانية عبر عشرات الآلاف من الاتصالات المتزامنة، يؤدي هذا النمط التقليدي إلى تشبع ناقل الذاكرة واستنزاف طاقة المعالجة سريعا.

ففي كل استدعاء للأمر التقليدي read(2) من مقبس شبكي وارد، تضطر نواة لينكس إلى نسخ حزم البيانات عبر حدود الامتيازات من مخازن المقابس في النواة (sk_buff) إلى ذاكرة التطبيق في مساحة المستخدم. وعقب ذلك مباشرة، ينفذ الوسيط أمر write(2) أو send(2) لدفع البيانات إلى مقبس النفق الصادر، مما يلزم النظام بإجراء عملية نسخ ثانية باتجاه النواة. وأثناء تدفق البيانات الكثيف، تستهلك أنوية المعالج ما يصل إلى 40% من دورات عملها في تنفيذ تعليمات نسخ الذاكرة (copy_user_generic_string)، مما يطرد مفاتيح التشفير النشطة من الذاكرة المخبأة السريعة L1 وL2.

توفر نواة لينكس حلا هندسيا متطورا عبر استدعاءات ربط الأنابيب: splice(2) وvmsplice(2). فمن خلال تمرير بيانات الحزم عبر حلقة مخازن صفحات الأنابيب داخل النواة مباشرة دون ملامسة مساحة المستخدم، تسمح هذه الأدوات لبوابات شبكات VPN بتحقيق النقل الصفري للبيانات (Zero-Copy) بأعلى كفاءة تشغيلية.

اختناقات النسخ التقليدي للذاكرة في خوادم الترحيل

لإدراك أسباب تدني الأداء في أنظمة الترحيل التقليدية، يمكن تتبع مسار حزمة البيانات المشفرة:

التوجيه التقليدي في مساحة المستخدم (عمليتا نسخ للذاكرة، 4 تبديلات للسياق):
[ بطاقة الشبكة الواردة ] ---> [ مخزن sk_buff الوارد ]
                                     |
                              copy_to_user (المرحلة 1: النواة -> مساحة المستخدم)
                                     v
                        [ مخزن الذاكرة في التطبيق ]
                                     |
                              copy_from_user (المرحلة 2: مساحة المستخدم -> النواة)
                                     v
[ بطاقة الشبكة الصادرة ] <--- [ مخزن sk_buff الصادر ]

يفرض هذا النمط الكلاسيكي ثلاثة قيود تقنية جوهرية:

  1. مضاعفة استهلاك ناقل الذاكرة: يمر كل بايت عابر عبر ناقل الذاكرة الرئيسي 4 مرات: من بطاقة الشبكة عبر DMA إلى الذاكرة، ثم القراءة إلى مسجلات المعالج، ثم الكتابة إلى ذاكرة المستخدم، ثم إعادة الكتابة إلى مخازن النواة. وبمعدل تدفق 40 جيجابت في الثانية، يتحمل ناقل الذاكرة عبئا يبلغ 160 جيجابت في الثانية.
  2. استنزاف الذاكرة المخبأة: يؤدي الضخ المستمر لملايين البايتات عبر مسجلات المعالج إلى إفراغ الذاكرة المخبأة السريعة L1/L2، مما يتسبب في إخلاء جداول التشفير ومفاتيح خوارزميات مثل ChaCha20-Poly1305 أو AES-GCM.
  3. أعباء تبديل السياق: يتسبب التناوب المستمر بين read(2) وwrite(2) في ملايين التحولات بين نمط المستخدم ونمط النواة، مما يهدر دورات المعالجة في إعادة تحميل جداول ترجمة العناوين (TLB).

بنية splice(2): حلقة مخازن صفحات الأنابيب

يقوم الاستدعاء البرمجي splice(2) بنقل البيانات مباشرة بين واصفي ملفات (بشرط أن يكون أحدهما على الأقل أنبوبا - pipe) دون نسخ البايتات بين النواة ومساحة المستخدم.

النقل الصفري داخل النواة عبر حلقة صفحات الأنبوب:
[ المقبس الوارد ] ═════════════════════════════════════════╗
        |                                                 ║
 splice(sock_in -> pipe)                                  ║
        v                                                 ║
[ struct pipe_inode_info ]                                ║  انعدام نسخ الذاكرة
  * حلقة struct pipe_buffer (مؤشرات الصفحات والإزاحات)    ║  (تبادل المؤشرات فقط)
        |                                                 ║
 splice(pipe -> sock_out)                                 ║
        v                                                 ║
[ المقبس الصادر ] ════════════════════════════════════════╝

آلية عمل مخازن الأنابيب في النواة

يعتمد splice(2) على البنية الداخلية للأنابيب في لينكس (fs/pipe.c). فالأنبوب ليس مجرد سيل من البايتات، بل هو مصفوفة حلقية من الواصفات struct pipe_buffer التي تشير إلى صفحات فعلية في الذاكرة (struct page *):

  • تبادل مؤشرات الصفحات: عندما يسحب splice(2) البيانات من المقبس إلى الأنبوب، لا تقوم النواة بنسخ المحتوى. بل تستخرج مؤشرات الصفحات الفعلية الحاملة للحزمة (skb_frag_t) وتدرج عناوينها في فتحات الحلقة الدائرية للأنبوب.
  • زيادة عداد المراجع: ترفع النواة عداد استخدام الصفحة (page_ref_inc). وبذلك تبقى الحزمة مستقرة في الذاكرة العشوائية بينما يتم تحرير هياكل المقبس الأصلية بأمان.
  • التوجيه نحو المقبس الخارجي: عند تنفيذ splice(2) لدفع البيانات من الأنبوب إلى المقبس الصادر، تربط طبقة الشبكة نفس الصفحات بقائمة التجميع/التشتيت (scatter-gather) لإرسالها عبر بطاقة الشبكة بتقنية DMA.

التوصيل عبر vmsplice(2)

بينما يعالج splice(2) عمليات النقل المباشرة داخل النواة، تتطلب بعض سيناريوهات بوابات VPN من التطبيق إضافة ترويسات بروتوكولية مخصصة أو فحص بيانات الجلسة. وهنا يأتي دور vmsplice(2).

يقوم الأمر vmsplice(2) بربط مصفوفة متجهات الذاكرة في مساحة المستخدم (struct iovec) مباشرة داخل أنبوب النواة:

ssize_t vmsplice(int fd, const struct iovec *iov, unsigned long nr_segs, unsigned int flags);

وعند استدعائه مع الراية SPLICE_F_GIFT، يتنازل التطبيق عن ملكية صفحات الذاكرة لصالح النواة. وإذا كانت الذاكرة محاذية لحدود الصفحات، تدمج النواة تلك الصفحات في حلقة الأنبوب دون نسخ. ويمكن لاستدعاء splice(2) اللاحق إرسال تلك الحزم فورا إلى بطاقة الشبكة.

التطبيق العملي: بناء وسيط ترحيل صفري النسخ

يقوم خادم الترحيل عالي الأداء بإنشاء زوج من الأنابيب ثنائية الاتجاه لكل اتصال نفق نشط. وتنفذ حلقة التوجيه استدعاءات splice(2) غير المحظورة تحت إدارة epoll(7):

#define _GNU_SOURCE
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <errno.h>

#define RELAY_CHUNK_SIZE 65536

// ترحيل البيانات بين المقبس الوارد والصادر عبر splice
int relay_socket_pair(int fd_in, int fd_out, int pipefd[2]) {
    // المرحلة 1: نقل البيانات من المقبس الوارد إلى أنبوب النواة
    ssize_t spliced_in = splice(fd_in, NULL, pipefd[1], NULL,
                                RELAY_CHUNK_SIZE,
                                SPLICE_F_MOVE | SPLICE_F_NONBLOCK);
    
    if (spliced_in <= 0) {
        if (errno == EAGAIN || errno == EWOULDBLOCK) return 0;
        return -1; // إغلاق المقبس أو حدوث خطأ
    }

    // المرحلة 2: دفع البيانات من الأنبوب إلى المقبس الصادر
    ssize_t spliced_out = 0;
    while (spliced_out < spliced_in) {
        ssize_t written = splice(pipefd[0], NULL, fd_out, NULL,
                                 spliced_in - spliced_out,
                                 SPLICE_F_MOVE | SPLICE_F_NONBLOCK);
        
        if (written <= 0) {
            if (errno == EAGAIN || errno == EWOULDBLOCK) {
                // بقاء بيانات في الأنبوب؛ سيقوم epoll بالإشعار عند إمكانية الكتابة
                break;
            }
            return -1;
        }
        spliced_out += written;
    }

    return spliced_out;
}

ضبط سعة الأنابيب

تخصص لينكس افتراضيا 16 صفحة (64 كيلوبايت) لكل أنبوب. وعند تدفق البيانات بمعدلات فائقة، قد تسبب هذه السعة ضغطا عكسيا. ويمكن للمسؤولين رفع سعة الأنبوب عبر الدالة fcntl مع F_SETPIPE_SZ:

// رفع سعة الأنبوب إلى 1 ميجابايت (256 صفحة) لتدفقات البيانات السريعة
fcntl(pipefd[0], F_SETPIPE_SZ, 1048576);

ويمكن تعديل الحد الأقصى للأنابيب على مستوى النظام عبر sysctl:

# ضبط الحد الأقصى لسعة الأنابيب إلى 4 ميجابايت
sysctl -w fs.pipe-max-size = 4194304

التطبيق في بنية RiceGuard VPN

يطبق محرك التوجيه في الطبقة الثالثة RiceGuard التابع لـ VPNRice تقنية أنابيب النواة عبر شبكة الترحيل الخاصة به:

  1. إدراج الترويسات عبر vmsplice: عند الحاجة إلى تغليف تدفقات الترانزيت بترويسات خاصة، يبني RiceGuard هياكل البروتوكول في مساحة المستخدم ويدفعها عبر vmsplice(2) براية SPLICE_F_GIFT، مما يمنع التكرار في حجز الذاكرة.
  2. عزل تدفقات النقل المباشر: في مسارات الترانزيت بين مراكز البيانات حيث يتم تبادل الحزم المشفرة، تربط مسارات معالجة RiceGuard أنفاق العملاء بخطوط الشبكة الرئيسية مباشرة عبر splice(2).
  3. مكاسب الكفاءة التشغيلية: في اختبارات الضغط بمعدل 40 جيجابت في الثانية عبر 12000 تدفق نشط، خفض استبدال حلقات recv(2)/send(2) بتقنية splice(2) استهلاك المعالج من 68% إلى 22%، مع تقليل زمن الانتقال الكلي بنسبة 32%.

الأسئلة الشائعة

هل يمكن لـ splice(2) نقل البيانات مباشرة بين مقبسين دون الحاجة لأنبوب؟

لا. تنص بنية نواة لينكس على ضرورة أن يكون أحد الواصفين في استدعاء splice(2) أنبوبا. ورغم ذلك، فإن تكلفة إنشاء زوج الأنابيب ضئيلة للغاية، وتعمل حلقة صفحات الأنبوب كطابور عالي الكفاءة داخل النواة.

هل يتوافق splice(2) مع بروتوكولات التشفير مثل TLS أو WireGuard؟

نعم. حينما تتم عمليات التشفير وفك التشفير داخل النواة (مثل Kernel TLS / kTLS أو أنفاق WireGuard في النواة)، يعمل splice(2) مباشرة على تدفقات المقابس دون الحاجة لرفع البيانات إلى مساحة المستخدم.

ماذا يحدث عند تجزؤ الحزم الشبكية داخل مخزن المقبس؟

تتعامل نواة لينكس مع المخازن المجزأة بسلاسة تامة. حيث يتتبع splice(2) كل قطعة في قائمة التشتيت والتجميع ويملأ خانات متتابعة في حلقة pipe_buffer، مما يضمن سلامة الحزم المنقولة بالكامل.