تصویر مقاله
عکس از Alexander Grey در Unsplash
  1. socket
  2. bind
  3. connect
  4. listen
  5. accept
  6. fork
  7. exec
  8. close
  9. getsockname, getpeername
مدل سروری که در این فصل به آن می‌پردازیم، مدل one-process-per-client است که از تابع fork استفاده می‌کند. مدل one-thread-per-client نیز در فصل ۲۶ بررسی خواهد شد.
تصویر مقاله
توابع سوکت برای کلاینت/سرور مقدماتی TCP

نمودار بالا ترتیب فراخوانی و روابط میان توابعی را نشان می‌دهد که در ادامه توضیح خواهیم داد. مشاهدهٔ این تصویر هم‌زمان با مطالعهٔ متن، درک آن را بسیار ساده‌تر خواهد کرد.

۱. تابع socket

تابع socket نخستین تابعی است که برای انجام عملیات I/O شبکه باید فراخوانی شود.

تصویر مقاله
تابع socket

مقادیر ورودی برای پارامترهای family، type و protocol مطابق با شکل‌های ۴.۲، ۴.۳ و ۴.۴ در ادامه آمده است.

تصویر مقاله
پارامترهای family، type و protocol در تابع socket

البته همهٔ ترکیب‌های ممکن میان family و type معتبر نیستند؛ ترکیب‌های مجاز در ادامه آورده شده‌اند.

تصویر مقاله
ترکیب‌های معتبر family، type و protocol

در صورت اجرای موفقیت‌آمیز تابع socket، این تابع درست مانند یک file descriptor، یک socket descriptor (یا همان sockfd) را در قالب یک عدد صحیح غیرمنفی برمی‌گرداند. این socket descriptor اطلاعاتی مانند پروتکل و نوع سوکت را در خود نگه می‌دارد؛ اما در این مرحله، هنوز هیچ آدرس پروتکل محلی یا خارجی به socket descriptor اختصاص داده نشده است. (در فصل ۳ به بررسی ساختار آدرس سوکت پرداختیم؛ در ادامه خواهیم دید که تابع bind با استفاده از همین ساختار، آدرس محلی را به سوکت اختصاص می‌دهد).

ثابت‌های معرف family به دو شکل AF_* و PF_* وجود دارند؛ اما این دو چه تفاوتی با هم دارند؟
«AF_»: نشان‌دهندهٔ address family
«PF_»: نشان‌دهندهٔ protocol family
در ابتدا هدف این بود که هر protocol family بتواند از چندین address family پشتیبانی کند. به همین دلیل از PF_ هنگام ساخت سوکت و از AF_ در ساختار آدرس سوکت استفاده می‌شد. اما در عمل هیچ‌گاه یک protocol family از چند address family پشتیبانی نکرد و در نهایت در فایل <sys/socket.h> مقادیر PF_ و AF_ یکسان تعریف شدند. اگرچه تضمین قطعی برای یکسان بودن همیشگی آنها وجود ندارد، اما کدهای فراوانی با فرض برابری این دو، آن‌ها را به جای هم به کار می‌برند. ما در این کتاب منحصراً از AF_ استفاده خواهیم کرد.

۲. تابع bind

تصویر مقاله
تابع bind

تابع bind یک آدرس پروتکل محلی را به سوکت اختصاص می‌دهد. ساختار آدرس سوکت یعنی *myaddr باید حاوی آدرس IP و شماره پورتی باشد که قصد تخصیص آن را دارید.

آدرس IP باید متعلق به یکی از اینترفیس‌های هاست باشد. استفاده از آن توسط کلاینت TCP باعث تعیین source IP می‌شود، در حالی که استفاده از آن در سمت سرور، پذیرش اتصالات را تنها به همان آدرس IP محدود می‌کند.

تصویر مقاله

هنگام انتخاب آدرس IP به صورت wildcard، در IPv4 از INADDR_ANY و در IPv6 از in6addr_any استفاده می‌شود.

C

یک هاست می‌تواند چندین کارت یا رابط شبکه داشته باشد (Multihomed Host). هر رابط می‌تواند آدرس‌های IP جداگانه‌ای داشته باشد و بسته‌ها از اینترفیسی وارد می‌شوند که آدرس IP مقصد را داراست. اگر سرور یک IP از نوع wildcard (مانند «0.0.0.0») تعیین کند، بسته‌ها را از تمام اینترفیس‌ها دریافت می‌کند؛ اما اگر یک IP مشخص (غیر wildcard) اختصاص دهد، فقط بسته‌هایی را می‌پذیرد که از رابط شبکهٔ متناظر با همان IP وارد شده باشند. مفهوم Multihomed Host را در فصل ۸ با جزئیات بیشتری شرح خواهیم داد.

۳. تابع connect

تصویر مقاله
تابع connect

تابع connect توسط کلاینت TCP برای برقراری یک اتصال (connection) به سرور TCP به کار می‌رود. پارامتر sockfd همان socket descriptor حاصل از فراخوانی تابع socket است و ساختار آدرس سوکت (*servaddr) نیز باید شامل آدرس IP و شماره پورت سرور باشد.

کلاینت نیازی ندارد پیش از فراخوانی connect از تابع bind استفاده کند؛ چرا که اگر IP و پورتی به سوکت اختصاص نیافته باشد، کرنل به شکل خودکار یک source IP و یک پورت موقت (ephemeral port) به آن اختصاص می‌دهد.

در مورد سوکت‌های TCP، فراخوانی تابع connect فرایند دست‌تکانی سه‌مرحله‌ای (TCP three-way handshake) را آغاز می‌کند.

تصویر مقاله
فرایند TCP Three way handshake

حالت‌های بروز خطا در این فرایند به شرح زیر است:

  1. اگر پروتکل TCP در پاسخ به بستهٔ SYN segment هیچ پاسخی دریافت نکند، خطای ETIMEDOUT رخ می‌دهد.
  2. اگر پاسخ سرور به بستهٔ SYN کلاینت پیام RST (reset) باشد، بدین معناست که روی آن پورت هیچ پروسه‌ای در انتظار اتصال نیست. در این حالت، خطای ECONNREFUSED بازگردانده می‌شود.
  3. گاهی ممکن است ارسال SYN با دریافت پیام خطای گذرا (soft error) از نوع ICMP مبنی بر «destination unreachable» مواجه شود. در این وضعیت، کرنل کلاینت خطا را ثبت کرده و مجدداً بسته‌های SYN را ارسال می‌کند. اگر پس از گذشت زمانی مشخص پاسخی نرسد، خطای ثبت‌شدهٔ ICMP به پروسه بازگردانده می‌شود (EHOSTUNREACH یا ENETURNREACH). این اتفاق زمانی که سیستم مقصد در جدول مسیریابی (forwarding table) سیستم محلی در دسترس نباشد نیز رخ می‌دهد.
بستهٔ RST در واقع یک TCP segment است که هنگام بروز مشکل ارسال می‌شود؛ به عنوان مثال زمانی که سروری روی پورت مقصد فعال نباشد، یا TCP بخواهد یک اتصال فعال را لغو کند، و یا بسته‌ای برای اتصالی که اصلاً وجود ندارد دریافت شود.

۴. تابع listen

تصویر مقاله

تابع listen تابعی است که منحصراً در سمت سرور کاربرد دارد.

  • این تابع سوکتِ متصل‌نشده را به یک سوکت passive تبدیل می‌کند تا کرنل بتواند درخواست‌های اتصال ورودی به آن سوکت را دریافت و مدیریت کند.
  • پارامتر backlog: حداکثر تعداد درخواست‌های اتصالی را مشخص می‌کند که کرنل می‌تواند برای این سوکت در صف (queue) نگه دارد.

کرنل برای دریافت و مدیریت درخواست‌های اتصال TCP، دو صف مجزا را نگهداری می‌کند:

  • صف اتصالات ناقص (incomplete connection queue): درخواست‌های SYN ارسالی از کلاینت را جهت تکمیل فرایند three-way handshake نگهداری می‌کند.
  • صف اتصالات تکمیل‌شده (Completed Connection Queue): این صف شامل ورودی‌های (entry) مربوط به کلاینت‌هایی است که فرآیند Three-Way Handshake را با موفقیت پشت سر گذاشته‌اند.
تصویر مقاله

در فرآیند Three-Way Handshake، هنگامی که سرور یک بستهٔ SYN دریافت می‌کند، ابتدا یک ورودی در «صف اتصالات ناقص» ایجاد می‌کند. سپس با دریافت بستهٔ ACK از سوی کلاینت، این ورودی را به «صف اتصالات تکمیل‌شده» منتقل می‌نماید و در نهایت با فراخوانی تابع accept، این ورودی در اختیار پروسس قرار می‌گیرد.

تصویر مقاله

وقتی پروسس تابع accept را صدا می‌زند، نخستین ورودی موجود در صف اتصالات تکمیل‌شده به آن تحویل داده می‌شود. حال اگر این صف خالی باشد، پروسس به حالت Sleep می‌رود تا زمانی که ورودی جدیدی به صف اضافه شود.

اگر بستهٔ SYN از کلاینت برسد اما ظرفیت صف پر باشد، پروتکل TCP آن را نادیده می‌گیرد و هیچ بستهٔ RSTای هم ارسال نمی‌کند؛ چرا که پر بودن صف معمولاً وضعیتی گذرا و موقتی است و این کار به کلاینت فرصت می‌دهد تا دوباره بستهٔ SYN را ارسال کند.

آرگومان backlog در تابع listen، حداکثر مجموع طول این دو صف را تعیین می‌کند. البته تعریف واحد و دقیقی برای backlog وجود ندارد و در پیاده‌سازی‌های مختلف تفاوت‌های جزئی دارد؛ برای نمونه، در سیستم‌های مبتنی بر Berkeley مقدار backlog در ۱.۵ ضرب می‌شود و در Linux 2.4.7 نیز عدد ۳ به آن افزوده می‌شود.

۵. تابع accept

تصویر مقاله
تابع accept

تابع accept اتصالات برقرارشده را از صف اتصالات تکمیل‌شده دریافت می‌کند؛ و اگر این صف خالی باشد، پروسس وارد وضعیت Sleep می‌شود.

پارامترهای cliaddr و addrlen برای دریافت آدرس پروتکل کلاینت به کار می‌روند. پارامتر addrlen یک آرگومان از نوع Value-Result است (به فصل ۳ مراجعه کنید)؛ به این معنا که پیش از فراخوانی، اندازهٔ ساختار آدرس سوکت را در خود دارد و پس از بازگشت تابع، تعداد بایت‌های ذخیره‌شده توسط کرنل در آن ساختار را نشان می‌دهد.

در صورت موفقیت‌آمیز بودن accept، یک Socket Descriptor جدید که توسط کرنل ساخته شده بازگردانده می‌شود. این دیسکریپتور در واقع یک «سوکت متصل» (Connected Socket) است که ارتباط TCP فعال با کلاینت را در دست دارد.

باید توجه داشت که sockfd ورودی (Listening Socket) با سوکت بازگردانده‌شده (Connected Socket) کاملاً متفاوت است. سرور معمولاً تنها یک Listening Socket می‌سازد و تا پایان چرخهٔ حیات خود از آن استفاده می‌کند؛ در حالی که کرنل با هر اتصال جدید، یک Connected Socket جداگانه ایجاد می‌نماید. پس از پایان سرویس‌دهی به کلاینت مربوطه نیز این سوکت متصل بسته خواهد شد.

۶. تابع fork

تصویر مقاله
تابع fork

تابع fork تنها یک بار فراخوانی می‌شود، اما دو بار مقدار بازگشتی تولید می‌کند.

  1. این تابع به پروسس والد (پروسس فراخواننده)، شناسهٔ پروسس فرزند (PID) تازه‌تولیدشده را برمی‌گرداند.
  2. و به پروسس فرزند، مقدار ۰ را تحویل می‌دهد.

از آنجا که پروسس فرزند تنها یک والد دارد و می‌تواند شناسهٔ والد خود را با فراخوانی تابع getppid به دست آورد، نیازی به دریافت PID والد در خروجی ندارد. در مقابل، پروسس والد می‌تواند فرزندان متعددی داشته باشد؛ بنابراین شناسهٔ هر فرزند تازه را در خروجیِ fork دریافت می‌کند.

تمام دیسکریپتورهایی که پیش از اجرای fork باز شده‌اند، در اختیار پروسس فرزند نیز قرار می‌گیرند. به همین دلیل، با اجرای fork پس از فراخوانی accept، سوکت متصل (Connected Socket) میان پروسس والد و فرزند به اشتراک گذاشته می‌شود.

با بهره‌گیری از fork می‌توان یک سرور هم‌روند (Concurrent Server) با الگوی «یک پروسس به ازای هر کلاینت» پیاده‌سازی کرد.

تصویر مقاله

کد بالا نمونه‌ای از یک سرور هم‌روند را نشان می‌دهد. با برقراری اتصال و بازگشت تابع accept، سرور بلافاصله fork را اجرا می‌کند؛ پروسس فرزند وظیفهٔ پاسخ‌گویی به کلاینت را بر عهده می‌گیرد و پروسس والد به انتظار دریافت اتصالات بعدی می‌نشیند.

تصویر مقاله

جالب است بدانید اگر پروسس والد تابع close را صدا بزند، ارتباط با کلاینت قطع نخواهد شد؛ زیرا در File Table Entry مقداری به نام Reference Count وجود دارد که تعداد دیسکریپتورهای باز روی آن سوکت یا فایل را می‌شمارد. پس از اجرای fork، به دلیل اشتراک دیسکریپتور میان والد و فرزند، مقدار Reference Count به ۲ افزایش می‌یابد؛ بنابراین با اجرای close توسط والد، این مقدار به ۱ کاهش می‌یابد و سوکت بسته نمی‌شود. سوکت تنها زمانی به طور کامل آزاد می‌گردد که Reference Count به ۰ برسد.

۷. تابع exec

تصویر مقاله
تابع exec

هنگام فراخوانی تابع exec، تصویر پروسس جاری (Process Image) با یک فایل برنامه‌ای جدید جایگزین شده و اجرای برنامه از تابع main آغاز می‌شود؛ در این فرآیند شناسهٔ پروسس (PID) هیچ تغییری نمی‌کند.

توابع exec به ۶ شکل مختلف وجود دارند که تفاوت آن‌ها بر اساس سه معیار است: (۱) تعیین برنامهٔ اجرایی از طریق نام فایل یا مسیر دقیق آن، (۲) ارسال آرگومان‌ها به صورت تک‌تک یا در قالب لیستی از اشاره‌گرها، و (۳) استفاده از همان Environment پروسس فراخواننده یا تعریف یک Environment کاملاً جدید.

این توابع تنها زمانی مقداری را به فراخواننده بازمی‌گردانند که خطایی رخ داده باشد؛ در غیر این صورت، کنترل اجرای برنامه به طور کامل به برنامهٔ جدید واگذار می‌شود.

از میان توابع یادشده، تنها execve یک System Call واقعی است و سایر موارد، توابع کتابخانه‌ای هستند که در نهایت execve را فراخوانی می‌کنند.

تصویر مقاله
توابع execve

۸. تابع close

تصویر مقاله
تابع close

تابع close سوکت را می‌بندد و اتصال را خاتمه می‌دهد.

تصویر مقاله
فرآیند TCP 4-Way Handshake

در تصویر بالا کلاینت شروع‌کنندهٔ فراخوانی close است، اما این کار می‌تواند بدون هیچ مشکلی از سمت سرور نیز آغاز شود.

با اجرای تابع close، یک بستهٔ FIN به طرف مقابل ارسال می‌شود. دریافت FIN به این معناست که برنامهٔ مقصد هیچ دادهٔ دیگری روی این اتصال دریافت نخواهد کرد. در این حالت، برنامه ابتدا تمام داده‌های باقی‌مانده در صف را می‌خواند و سپس با وضعیت پایان فایل (EOF) مواجه می‌شود.

شمارنده مراجعات به دیسکریپتور (Descriptor Reference Counts)

تا زمانی که مقدار Reference Count بزرگ‌تر از صفر باشد، فراخوانی close فرآیند خاتمهٔ TCP را آغاز نخواهد کرد.

اگر قصد دارید ارسال بستهٔ FIN را اجباری کنید، باید به جای تابع close از تابع shutdown استفاده نمایید.

چنانچه پروسس والد سوکت متصل را با تابع close نبندد، تمام سهمیهٔ دیسکریپتورهای در دسترس خود را مصرف کرده و دیگر قادر به باز کردن دیسکریپتور جدید نخواهد بود. علاوه بر این، هیچ‌یک از اتصالات کلاینت‌ها نیز بسته نمی‌شوند؛ زیرا با اجرای close توسط پروسس فرزند، مقدار Reference Count همچنان ۱ باقی می‌ماند.

۹. توابع getsockname و getpeername

تصویر مقاله
توابع getsockname و getpeername

۱. با وجود این‌که کلمهٔ «name» در نام این توابع دیده می‌شود، اما این توابع هیچ ارتباطی با نام دامنه (Domain Name) ندارند.

۲. برای درک بهتر، می‌توانید مفهوم «Peer» را سوکت طرف مقابل (خارجی) بدانید که به سوکت محلی متصل شده است.

این دو تابع به ترتیب ساختار آدرس پروتکل محلی و ساختار آدرس پروتکل خارجی سوکت را دریافت کرده و متغیرهای *localaddr و *peeraddr را پر می‌کنند.

نمونه‌های کاربرد

  • زمانی که کلاینت بدون فراخوانی bind مستقیماً تابع connect را اجرا می‌کند، می‌توان با استفاده از تابع getsockname، آدرس IP محلی و شماره پورت اختصاص‌یافته توسط کرنل را به دست آورد.
  • اگر سرور با آدرس Wildcard IP تابع bind را فراخوانی کرده باشد، پس از برقراری ارتباط می‌توان با اجرای getsockname روی سوکت متصل (Connected Socket)، آدرس IP محلی دقیق را به دست آورد.
  • چنانچه پس از فراخوانی تابع accept، تابع exec اجرا شود، تنها راه سرور برای دریافت اطلاعات کلاینت، استفاده از تابع getpeername خواهد بود.

بیایید نمونهٔ سوم را با جزئیات بیشتری بررسی کنیم؛ تصویر زیر عملکرد سرویس inetd را به نمایش می‌گذارد.

تصویر مقاله
نمونه‌ای از عملکرد inetd

زمانی که پروسس فرزند exec را فراخوانی می‌کند، حافظهٔ حاوی ساختار آدرس طرف مقابل پاک شده و دسترسی مستقیم به آن از بین می‌رود. با این حال، از آنجا که پروسس فرزند همچنان دیسکریپتور سوکت متصل (connfd) را در اختیار دارد، می‌تواند با کمک تابع getpeername، آدرس IP و شماره پورت کلاینت را به راحتی استخراج کند.

البته پروسس فرزند باید بداند کدام دیسکریپتور همان connfd است. معمولاً این موضوع هنگام اجرای exec در قالب یک رشته به پروسس ارسال می‌شود، یا از یک قرارداد استاندارد استفاده می‌شود که در آن یک دیسکریپتور مشخص همیشه به سوکت متصل اختصاص دارد؛ مثلاً در inetd طبق یک قرارداد معین، دیسکریپتورهای ۰، ۱ و ۲ همیشه همان سوکت متصل هستند.

۱۰. مراجع

  1. Unix Network Programming