المسار الأول: الاتصال بـSocket وإرسال البيانات من منظور الـClient. حان الوقت الآن لمناقشة العملية من الجهة الأخرى: الـServer.
المسار B: أنت Server
أنت Server ينتظر أن يتصل به الآخرون. وهذا يغيّر كل شيء تقريبًا باستثناء الخطوة الأولى، وهي الحصول على الـSocket من خلال استدعاء socket().
ومرة أخرى، ينطبق هذا المسار على Connection-Oriented Protocols مثل TCP فقط. أما UDP Servers فلا تحتاج إلى استدعاء
listen() أو accept(). وبدلًا من ذلك، يكون التدفق المعتاد كما يلي:
bind()
recvfrom()إذًا، يقوم الـServer باستدعاء socket()، وأصبح لديه الآن File Descriptor يشير إلى Socket Object تديره الـKernel. لكنه لا يمتلك هوية بعد، ولا يعرف على أي Address يمكن الوصول إليه.
أولًا: ربط الـSocket بعنوان محلي
نحتاج إلى تزويد هذا الاستدعاء ببعض المعلومات:
- أي Socket نريد ربط الـServer به.
- ما هو الـAddress، المكوّن من IP + Port، الذي يجب أن يمتلكه هذا الـSocket.
مرة أخرى، قد يمتلك الـServer عدة Network Interfaces، ويمكن أن يكون لكل واحدة منها IP Address مختلف: 127.0.0.1 للـLoopback، و192.168.1.10 للشبكة المحلية LAN، و203.0.113.25 كـPublic IP. ويحتاج نظام التشغيل إلى معرفة الـInterface التي يجب أن يستقبل عليها الـPackets الواردة إلى هذا الـSocket.
هذا الـIP Address هو العنوان الذي سيرسل الـClients البيانات إليه.
وماذا عن Port Number؟ يمكن لجهازك تشغيل العديد من الـServers، وقد تستخدم جميعها نفس الـIP Address. لذلك يحتاج نظام التشغيل إلى معرفة أي Server يجب أن يستقبل كل Packet وارد.
عادةً ما يعرف الـServer الـPort الذي يجب أن يستمع عليه، لأن الـClients يحتاجون إلى طريقة ثابتة ومتوقعة للوصول إليه. فوفقًا للعرف، تستخدم المتصفحات Port 443 للـHTTPS ما لم تحدد لها Port مختلفًا بشكل صريح. ويستخدم SSH Port 22 افتراضيًا، ويستخدم DNS Port 53، بينما يستخدم PostgreSQL عادةً Port 5432.
نحتاج أيضًا إلى تمرير طول الـAddress الذي أرسلناه. ومرة أخرى، يعيش التطبيق في User Space بينما تعيش الـKernel في Kernel Space. وهما لا تشتركان في نفس الذاكرة، لذلك يحتاج الـKernel إلى معرفة عدد الـBytes الذي يجب عليه قراءته بالضبط من User Space حتى يحصل على الـAddress كاملًا.
وبالتالي، يبدو استدعاء bind() بالشكل التالي:
bind(sockFileDescriptor, localAddress, sizeof(localAddress));وبذلك نقول للـKernel: «يا نظام التشغيل، قم بربط هذا الـSocket بالعنوان 0.0.0.0:8080».
بعد ذلك يتحقق الـKernel مما إذا كان هذا الـPort متاحًا. فإذا كان مستخدمًا بالفعل، يفشل الاستدعاء ويظهر الخطأ “Address already in use”.
أما إذا كان الـPort متاحًا، فيضيف الـKernel هذا الـAddress، المكوّن من IP وPort، إلى Socket Object.
إذًا أصبح لهذا الـSocket عنوان الآن، لكن Socket الخاص بـTCP يبقى في حالة CLOSED حتى يغيّرها listen() إلى LISTEN. وهو لا يقبل الاتصالات ولا ينقل بيانات التطبيق بعد.
ثانيًا: الاستماع إلى الاتصالات
أصبح للـSocket عنوان الآن، لكننا لم نخبر نظام التشغيل بعد بالدور الذي سيؤديه هذا الـSocket. هل سينتظر الاتصالات الواردة باعتباره Server، أم سيبدأ الاتصالات باعتباره Client؟
بما أننا نتحدث عن Connection-Oriented TCP، فإن الـClient يحتاج إلى إرسال SYN عند بدء الاتصال. وفي هذه اللحظة يجب أن يكون Socket الخاص بالـServer جاهزًا لاستقبال ومعالجة طلبات الاتصال الواردة.
وهنا يأتي دور استدعاء listen(). وفي لغة C، يبدو بالشكل التالي:
listen(sockFileDescriptor, backlog);يمثل backlog تلميحًا وحدًا لعدد الاتصالات المعلّقة التي تنتظر قبولها. وقد تستخدم أنظمة التشغيل Queue منفصلة للمصافحات غير المكتملة والاتصالات المكتملة، مع تطبيق حدود إضافية على مستوى الـKernel.
يؤدي هذا الاستدعاء إلى تغيير حالة الـSocket من “unconnected” إلى “listening”.
كما يقوم بتخصيص Data Structures داخلية لاثنين من الـQueues: الـSYN Queue، التي تخزّن SYN Packets الواردة التي تمثل طلبات بدء الـHandshake، وAccept Queue، التي تخزّن الاتصالات التي اكتمل فيها الـHandshake ولكن لم يقم التطبيق بقبولها بعد.
باستدعاء listen()، نكون قد عرّفنا هذا الـSocket على أنه Passive Socket. فهو يستقبل طلبات الاتصال ولا يقوم ببدئها.
ثالثًا: قبول الاتصالات
أصبح لدينا الآن Socket يمتلك عنوانًا وجاهزًا لاستقبال الاتصالات. نحتاج إلى قبول هذه الاتصالات. لكن فكّر فيما يحدث عندما يتصل العديد من الـClients في الوقت نفسه. كل Client يمثل TCP Connection مختلفًا، لأن اتصال TCP يُحدَّد برباعية تتكون من عنوان IP المحلي والمنفذ المحلي وعنوان IP البعيد والمنفذ البعيد.
تخيّل أننا استقبلنا اتصالين من Remote Addresses مختلفين، مثل 203.0.113.5:54321 و198.51.100.22:61000. كيف يستطيع الـServer التعامل معهما على أنهما اتصالان مختلفان إذا كنا نستخدم نفس الـSocket ونفس الذاكرة للتواصل مع كليهما؟
لهذا السبب، لا يمكن للـSocket الذي يستمع إلى الاتصالات أن يقوم بأكثر من كونه Listening Socket. فدوره هو استقبال طلبات الاتصال ووضعها في Queue إلى أن يقوم التطبيق بقبولها. لا شيء أكثر من ذلك.
إذًا، ماذا نستخدم للاتصال الذي تم قبوله؟ أين يمكننا استقبال البيانات من ذلك الـClient وإرسال الاستجابات إليه؟ من الواضح أننا نحتاج إلى Socket آخر.
وهذا يعني أنه لكل Connection يتم قبوله، يقوم الـKernel بإنشاء Socket جديد تمامًا. ويحصل التطبيق على File Descriptor جديد باستدعاء accept():
int clientSocket = accept(serverSocketFD, NULL, NULL);في الخلفية، هذا ما يقوم به نظام التشغيل.
أولًا، يفحص نظام التشغيل الـAccept Queue، التي تخزّن الاتصالات التي اكتمل فيها الـHandshake بالفعل. وإذا كانت الـQueue فارغة، فإن accept() يقوم بعمل Block وينتظر حتى يصل اتصال جديد.
إذا كان هناك اتصال داخل الـQueue، تحدث عملية مشابهة لما يحدث عندما نستدعي connect() من جهة الـClient. يقوم نظام التشغيل بإنشاء Socket Object جديد في ذاكرة الـKernel، ويضبط حالته مباشرةً على “connected”، ثم ينسخ الـRemote Address والـLocal Address إلى Socket Object هذا، ويخصص Send Buffers وReceive Buffers، وأخيرًا يعيد File Descriptor جديدًا تمامًا يشير إلى هذا الـSocket الجديد.
هذا يعني أن كل Client يحصل على Socket خاص به. فكّر في الأمر بهذه الطريقة: الـListening Socket مسؤول فقط عن استقبال طلبات الاتصال الجديدة، بينما يحصل كل Client يتم قبوله على Socket منفصل يستطيع الـServer من خلاله استقبال البيانات وإرسال الاستجابات.
وهذا هو السبب الأساسي الذي يجعل الـServer قادرًا على التعامل مع عدة TCP Connections في الوقت نفسه. يمكن لـListening Socket واحد قبول العديد من الاتصالات، ويحصل كل اتصال يتم قبوله على Socket وFile Descriptor مخصصين له.

