المسار C: استخدام Connectionless Protocol مثل UDP
الآن دعونا نتحدث عما يحدث عندما نستخدم Connectionless Protocol مثل UDP.
الخطوة الأولى: إنشاء الـSocket
هذه الخطوة متطابقة سواء كنا نستخدم Connection-Oriented Protocol أو Connectionless Protocol. فما زلنا بحاجة إلى الحصول على Socket وتمرير معلومات مشابهة إلى نظام التشغيل. والاختلاف هنا هو أننا نُعدّ الـSocket للعمل مع Datagrams، وهي رسائل لها حدود محددة.
في لغة C، يبدو الاستدعاء بالشكل التالي:
sockFileDescriptor = socket(SOCKET_FAMILY, SOCKET_TYPE, PROTOCOL);يعيد نظام التشغيل File Descriptor يشير إلى Socket Object الذي تم إنشاؤه، تمامًا كما رأينا سابقًا.
الخطوة الثانية: لا يوجد اتصال، بل Datagram
في UDP، لا يوجد اتصال يجب إنشاؤه. وبدلًا من ذلك، نستخدم sendto() لإرسال كل Datagram مباشرةً إلى وجهته:
sendto(sockFileDescriptor, buf, len, 0, &destAddr, sizeof(destAddr));كما ترى، نحن نستهدف الـSocket الذي أنشأناه في الخطوة الأولى. نمرر buf، وهو Pointer يشير إلى أول Byte من البيانات المراد إرسالها؛ وlen، الذي يخبر الـKernel بعدد الـBytes من البيانات التي نريد إرسالها؛ وdestAddr، الذي يحتوي على Destination IP Address وPort؛ وsizeof(destAddr)، الذي يسمح للـKernel بمعرفة حجم بنية عنوان الوجهة.
الفرق الجوهري هنا هو التالي: كل استدعاء لـsendto() مستقل عن الآخر. يمكننا استخدام نفس الـSocket لإرسال البيانات إلى وجهات مختلفة، لأن عنوان الوجهة يتم تحديده في كل استدعاء.
فعلى سبيل المثال، من الصحيح تمامًا إرسال Datagram إلى 8.8.8.8:53 ثم إرسال Datagram أخرى مباشرةً إلى 1.1.1.1:53. لا يوجد اتصال يربط الـSocket بشكل دائم بوجهة واحدة.
الشيء الذي يبقى ثابتًا هو Source Address. بمجرد أن يحدد نظام التشغيل الـLocal IP Address والـPort الخاصين بالـSocket، يبقى هذا الـLocal Endpoint مرتبطًا بالـSocket.
استجابةً لاستدعاء sendto()، ينفّذ نظام التشغيل عدة عمليات.
أولًا، يستخدم نظام التشغيل Routing Table لتحديد Source IP Address المناسب وNetwork Interface الذي يجب استخدامه.
ثانيًا، يختار Port Number من الـEphemeral Ports المتاحة إذا لم يكن التطبيق قد قام مسبقًا بربط الـSocket بـLocal Port محدد.
ثالثًا، يخزّن Source Address، المكوّن من الـLocal IP Address والـPort، داخل Socket Object.
رابعًا، يقوم الـKernel بنسخ البيانات من buf، بحد أقصى len من الـBytes، إلى ذاكرة الـKernel. ثم يضيف UDP Stack الـUDP Header لتكوين UDP Datagram، ويمررها إلى IP Layer.
لاحظ ما لم يحدث: لم يتم إنشاء أي اتصال، ولم يحدث أي Handshake، ولم يتم ربط الـSocket بوجهة دائمة. يتم إرسال هذا الـDatagram إلى وجهة معينة، بينما يمكن إرسال الـDatagram التالية إلى وجهة مختلفة تمامًا. ولا يضمن UDP وصول البيانات أو ترتيبها أو منع تكرارها أو إعادة إرسالها؛ وإذا احتاج التطبيق إلى هذه الخصائص فعليه تنفيذها بنفسه أو استخدام بروتوكول يوفرها.
بعد ذلك تقوم IP Layer بتغليف UDP Datagram داخل IP Packet. وقد يقوم IPv4 بتجزئة الـPacket عند الضرورة، رغم أن التطبيقات تحاول عادةً تجنب ذلك؛ أما أجهزة التوجيه في IPv6 فلا تقوم بتجزئة الـPackets. ثم يحدد الـKernel كيفية توجيه الـPacket باستخدام Routing Table.
على سبيل المثال، قد تحتوي Routing Table على السطر التالي: default via 10.234.98.197 dev wlo1 src 10.234.98.27. وهذا يعني أن الـNext Hop هو 10.234.98.197، وهو الـGateway أو الـRouter الخاص بنا.
قبل إرسال الـPacket عبر الشبكة المحلية، يحتاج الـKernel إلى معرفة MAC Address الخاص بالجهاز الذي يمثل الـNext Hop. وبما أن Destination IP، مثل 8.8.8.8، ليس موجودًا ضمن نفس الـSubnet الخاصة بجهازنا، يتم إرسال الـPacket إلى الـGateway بدلًا من إرسالها مباشرةً إلى 8.8.8.8.
لذلك يبحث الـKernel عن MAC Address الخاص بالـGateway في Neighbor Table. على سبيل المثال: 10.234.98.197 dev wlo1 lladdr aa:bb:cc:dd:ee:ff REACHABLE.
بعد ذلك تقوم Link Layer بتغليف الـIP Packet داخل L2 Frame، وتضيف MAC Addresses المناسبة. ثم يتم وضع هذا الـFrame في NIC’s Transmit Ring Buffer، وهي منطقة من Shared Memory تُستخدم للتواصل بين نظام التشغيل وNetwork Hardware.
بعد ذلك تقرأ الـNIC الـFrame، وتحول البيانات إلى إشارات كهربائية أو لاسلكية أو غيرها من الإشارات الفيزيائية المطلوبة بواسطة تقنية الشبكة المستخدمة، ثم تقوم بإرسالها إلى الشبكة.
وماذا عن الـReceiver؟ يستخدم
bind() لحجز عنوان محلي، ثم يستخدم recvfrom()لاستقبال كل Datagram.
الخطوة الأولى: إنشاء الـSocket
إنشاء الـSocket مطابق تمامًا للحالات الأخرى. والاختلاف هنا هو أننا نُعرّفه للعمل مع Connectionless UDP Protocol.
الخطوة الثانية: ربط الـSocket بعنوان محلي
يقوم الـServer باستدعاء
bind(sockFileDescriptor, localAddress, sizeOfLocalAddr). وهذا يمنح الـSocket هوية من خلال ربطه بـLocal IP Address وPort. ومن هذه اللحظة، يمكن تسليم الـPackets التي تصل إلى هذا العنوان المحلي إلى هذا الـSocket.
عندما يستقبل الـReceiver UDP Packet، يفحص الـKernel عنوان الوجهة، ثم يضعها في Receive Buffer الخاص بالـSocket المطابق إذا اجتازت فحوصات الصحة وقواعد التصفية. ومع ذلك قد يتم إسقاط الـPacket بسبب الأخطاء أو جدار الحماية أو امتلاء الـBuffer أو عدم وجود Socket يستمع إلى الوجهة.
الخطوة الثالثة: استقبال Datagram
يمكن للتطبيق بعد ذلك استدعاء recvfrom() بالشكل التالي:
recvfrom(sockFileDescriptor, buffer, sizeOfBuffer, senderAddr, sizeOfSenderAddr);الـbuffer هو Block من الذاكرة موجود داخل تطبيقك، وليس داخل الـKernel. وهذه نقطة مهمة جدًا لفهم ما يحدث. إنها ذاكرة يقوم التطبيق بتخصيصها لاستقبال البيانات الواردة وتخزينها.
يحدد sizeOfBuffer الحد الأقصى من البيانات التي يمكن لنظام التشغيل نسخها بأمان إلى Buffer الخاص بالتطبيق. ويتحقق نظام التشغيل من هذا الحجم عند نسخ البيانات.
يختلف senderAddr عن الـLocal Address المخزّن في الـSocket. فعنوان الـSender لا تتم إضافته بشكل دائم إلى Socket Object. وبدلًا من ذلك، يقوم الـKernel بنسخه إلى التطبيق من خلال senderAddr، في حال أراد التطبيق إرسال رد إلى ذلك الـSender.
لا يقوم الـSocket نفسه بتخزين عنوان الـSender بشكل دائم، لأننا أوضحنا سابقًا أن UDP Socket يمكنه استقبال رسائل من العديد من الـClients المختلفين. إنها ليست Peer-to-Peer Connection؛ وفي الحقيقة لا يوجد Connection أصلًا. ولهذا السبب يستطيع UDP Socket واحد التواصل مع آلاف الـClients المختلفين.
يمكنك التفكير في senderAddr على أنه Data Structure يوفرها التطبيق، ويستطيع الـKernel الكتابة فيها لتمرير عنوان الـSender إلى التطبيق حتى يتمكن التطبيق من استخدامه.
إذًا، ماذا يفعل recvfrom() فعليًا؟
أولًا، ينظر نظام التشغيل إلى Receive Buffer المرتبط بهذا الـSocket. وإذا لم تكن هناك أي Packets متاحة، ينتظر recvfrom() حتى تصل Packet جديدة.
إذا كانت هناك Packets متاحة، يختار نظام التشغيل أقدم Packet، ثم ينسخ الـPayload الخاص بها، دون Network Headers، من Receive Buffer الخاص بالـSocket إلى الذاكرة التي خصصها التطبيق لاستقبال البيانات.
كما يقوم بنسخ عنوان الـSender إلى الذاكرة التي تم تمريرها من خلال senderAddr.
وأخيرًا، يعيد recvfrom() حجم البيانات التي تم نسخها، وهو ما يمثل عادةً حجم الـPayload. وإذا كان Buffer التطبيق أصغر من الـDatagram فقد تُقتطع الرسالة؛ ويمكن للتطبيقات التي تحتاج إلى اكتشاف ذلك استخدام أعلام مناسبة مثل MSG_TRUNC.
الآن أصبح لدى تطبيقك ثلاث معلومات مهمة: البيانات الفعلية التي تم إرسالها، وعنوان الـSender، والطول الحقيقي للرسالة.

