تصویر مقاله
عکس از Lucas van Oort در Unsplash

فهرست مطالب

۱. Socket Timeout ۲. انواع مختلف توابع read/write (Variations of read/write functions) ۳. Ancillary Data ۴. بررسی صف بدون خواندن داده‌ها (Check Queue without reading data) ۵. Sockets and Standard I/O ۶. Advanced Polling

۱. مدیریت مهلت زمانی سوکت (Socket Timeout)

برای تنظیم مهلت زمانی (Timeout)، می‌توانیم از سه رویکرد متفاوت استفاده کنیم:

الف. استفاده از تابع alarm

تابع alarm با به پایان رسیدن زمان تعیین‌شده (Time expiring)، سیگنال SIGALRM را صادر می‌کند.

مثال ۱) افزودن Timeout به تابع connect با استفاده از سیگنال SIGALRM.

C++

مثال ۲) پیاده‌سازی Timeout برای تابع recvfrom به کمک سیگنال SIGALRM.

C++

با وجود سادگی، این روش چالش‌ها و محدودیت‌های متعددی به همراه دارد:

  1. این شیوه زمانی که بخواهید مهلت زمانی کوتاه‌تری نسبت به پیش‌فرض هسته (Kernel) تعریف کنید به خوبی جواب می‌دهد، اما امکان تنظیم تایم‌اوتی طولانی‌تر از زمان Kernel وجود ندارد.
  2. علاوه بر این، برخی از توابع کتابخانه‌ای خطای EINTR را مستقیماً بازنمی‌گردانند؛ بلکه آن را دریافت کرده و فراخوانی سیستمی (System Call) را مجدداً به صورت خودکار اجرا می‌کنند.
  3. همچنین استفاده از چنین سیگنال‌هایی در محیط‌های چندنخی (Multithreaded) بسیار دردسرساز است؛ مثلاً اگر یک آلارم فعال شود و منتظر دریافت چندین پاسخ باشیم، احتمال وقوع Race condition وجود دارد. به همین خاطر، در منابع مرجع نیز استفاده از این شیوه تنها برای برنامه‌های Single-thread توصیه می‌شود.

ب. استفاده از اشاره‌گر timeout در تابع select

در این روش، از متد کارآمد select که در فصل ششم معرفی شد کمک می‌گیریم.

مزیت بزرگ این روش این است که به جای تنظیم مستقیم تایم‌اوت روی متدهای read یا write، برنامه صرفاً منتظر آماده‌شدن Descriptor برای خواندن می‌ماند؛ به همین دلیل سازگاری فوق‌العاده‌ای دارد و برای هر دو پروتکل TCP و UDP قابل استفاده است.

مثال) پیاده‌سازی متد readable_timeo با استفاده از تابع select و بهره‌گیری از آن برای ایجاد Timeout در فرآیند خواندن.

متد readable_timeo

C++

به‌کارگیری متد readable_timeo هنگام انتظار برای دریافت پاسخ سرور

C++

ج. استفاده از گزینه‌های سوکت (Socket Options)

در این شیوه، مستقیماً از آپشن‌های SO_RCVTIMEO و SO_SNDTIMEO روی سوکت استفاده می‌شود.

  • با تنظیم این گزینه‌ها روی Descriptor، مهلت زمانی مشخص‌شده به تمام عملیات خواندن یا نوشتنی که روی آن انجام می‌گیرد، اعمال خواهد شد.
  • بزرگ‌ترین مزیت این روش، آسودگی در استفاده است؛ زیرا تنها با یک‌بار تنظیم کار می‌کند و نیازی به فراخوانی مکرر ندارد. البته محدودیت آن این است که برای توابعی مانند connect کاربرد نداشته و صرفاً روی read و write عمل می‌کند.

مثال) اعمال مهلت زمانی از طریق پیکربندی Socket Options.

C++

۲. نسخه‌های مختلف توابع read/write (Variations of read/write functions)

در این بخش به بررسی سه شکل تکامل‌یافته و متفاوت از توابع read و write می‌پردازیم.

الف. توابع recv و send

این دو تابع در حقیقت نسخه‌های پیشرفته‌تری هستند که امکان ارسال پرچم‌های (Flags) کنترلی را حین عملیات خواندن و نوشتن فراهم می‌کنند.

تصویر مقاله

اگر به ساختار پارامترهای این توابع نگاه کنید، متوجه می‌شوید که پارامترهای اصلی یکسان هستند و تنها آرگومان flags به آن‌ها اضافه شده است. پرچم‌های پرکاربرد شامل موارد زیر هستند:

پرچم MSG_DONTWAIT

  • این پرچم مشخص می‌کند که عملیات I/O باید بدون معطلی و در حالت غیرمسدودکننده (Non-blocking) اجرا شود.

پرچم MSG_PEEK

  • این فلگ به شما اجازه می‌دهد تا بدون مصرف کردن یا خارج کردن داده‌ها از بافر، نگاهی به داده‌های آماده خواندن بیندازید.

پرچم MSG_OOB

  • هنگام فراخوانی تابع send، برای اعلام این موضوع به کار می‌رود که داده‌های ارسالی از نوع داده‌های فوری یا خارج از باند (Out-of-band data) هستند.
  • از آنجا که در این فصل وارد جزئیات این مبحث نمی‌شویم، صرفاً دانستن کاربرد آن در ارتباطات OOB برای این بخش کفایت می‌کند.

پرچم MSG_DONTROUTE

  • این فلگ به هسته (Kernel) اعلام می‌کند که مقصد در شبکه محلی قرار دارد و نیازی به جستجو در جدول مسیریابی (Routing Table Lookup) نیست.

پرچم MSG_WAITALL

  • با تنظیم این فلگ، عملیات خواندن تا زمانی که حجم داده دریافت شده دقیقاً برابر با بایت‌های درخواستی نشود، ادامه خواهد داشت.
  • البته در شرایط خاص و استثنایی مانند دریافت سیگنال ناگهانی یا قطع اتصال، ممکن است داده‌ای کمتر از مقدار تقاضاشده بازگردانده شود.

نکته مهم این است که این گزینه‌ها برخلاف Socket Options فصل ۷، تنها بر یک عملیات واحد I/O اثر می‌گذارند؛ بنابراین نیازی به انجام چرخه «تنظیم آپشن ← اجرای عملیات ← لغو آپشن» نخواهید داشت.

  • نمونه جامع و کاربردی این موضوع را در بخش «۴. Check Queue without reading data» با ترکیب دو پرچم MSG_PEEK و MSG_DONTWAIT مشاهده خواهید کرد.

وضعیت دسترسی و پشتیبانی از این فلگ‌ها در توابع recv و send به شرح زیر است:

تصویر مقاله
توجه داشته باشید که این پرچم‌ها از نوع آرگومان‌های Value-result نیستند؛ در نتیجه نمی‌توانند مقداری را از سمت Kernel به فرآیند (Process) منتقل کنند.
در پروتکل‌های TCP/IP معمولاً نیازی به بازگرداندن فلگ از هسته احساس نمی‌شد و مشکلی وجود نداشت، اما با معرفی پرچم MSG_EOR (پایان رکوردها)، لزوم ارسال فلگ از سمت هسته به Process مطرح و پیاده‌سازی شد.
برای برطرف کردن این نیاز، به جای دستکاری رابط‌های موجود، ساختار درونی ساختار داده msghdr بازطراحی و به‌روزرسانی شد.

ب. توابع readv و writev

هدف اصلی این توابع، خواندن از یا نوشتن در چند بافر مختلف تنها با یک بار فراخوانی (single call) است.

این سازوکار در اصطلاح فنی scatter read / gather write نامیده می‌شود.

تصویر مقاله

این توابع در آرگومان دوم خود، اشاره‌گری به آرایه‌ای از ساختارهای iovec دریافت می‌کنند. هر ساختار iovec به صورت زیر تعریف می‌شود:

C++

ساختار آن چندان پیچیده نیست، اما برای درک بصری بهتر، بخشی از ساختار کلی داده را که در ادامه با آن سر و کار داریم آورده‌ایم:

تصویر مقاله

C. recvmsg / sendmsg

این دو تابع از پرکاربردترین و جامع‌ترین توابع I/O به شمار می‌روند.

در واقع، این توابع علاوه بر قابلیت‌های دو تابع قبلی، ویژگی‌های سایر توابع ورودی/خروجی را نیز به‌صورت یکپارچه در خود دارند.

تصویر مقاله

ورودی این توابع ساختاری به نام msghdr است که استاندارد POSIX آن را به این صورت تعریف می‌کند:

C++

این ساختار به همان اندازه که پیچیده به نظر می‌رسد، امکانات گسترده‌ای دارد و بارها به آن ارجاع داده می‌شود. اعضای اصلی این ساختار عبارت‌اند از:

msg_name, msg_namelen

  • فیلد msg_name هنگام فراخوانی sendmsg آدرس پروتکل مقصد و هنگام فراخوانی recvmsg آدرس پروتکل مبدأ را ذخیره می‌کند؛ به این معنی که این فیلد در سناریوهای بدون اتصال (connectionless) کاربرد دارد.
  • با این حال، در صورت وجود یک اتصال فعال (مانند TCP یا Connected UDP)، این مقدار باید روی اشاره‌گر تهی (null pointer) تنظیم شود.

msg_iov, msg_iovlen

  • این فیلد آرایه‌ای است که برای عملیات scatter / gather read / write (که در بخش B. readv / writev اشاره شد) به کار می‌رود و از ساختار iovec استفاده می‌کند.

msg_control, msg_controllen

  • این فیلدها برای انتقال داده‌های کمکی استفاده می‌شوند که در بخش «3. Ancillary Data» با جزئیات بیشتر به آن‌ها می‌پردازیم.

msg_flags

  • علاوه بر آرگومان flags که به عنوان ورودی تابع ارسال می‌شود، متغیر مجزایی به نام msg_flags نیز وجود دارد؛ البته نحوهٔ پردازش آن در توابع recvmsg و sendmsg کاملاً متفاوت است:
  • در recvmsg: مقدار آرگومان flags در فیلد msg_flags کپی شده و سپس بر اساس نتیجهٔ اجرای عملیات recvmsg به‌روزرسانی می‌شود.
  • در sendmsg: مستقیماً از مقدار آرگومان flags استفاده شده و فیلد msg_flags نادیده گرفته می‌شود.
تصویر مقاله

جدول زیر کاربرد مقادیر مختلف flags را خلاصه کرده است؛ همان‌طور که مشخص است، فیلد msg_flags در sendmsg هیچ کاربردی ندارد.

MSG_EOR: این فلگ نشان‌دهندهٔ پایان پیام است و به دلیل ماهیت جریان بایتی (byte stream) پروتکل TCP، در آن کاربردی ندارد.

سناریوی ارسال و دریافت دیتاگرام‌های UDP

اگر ساختار داده‌ها را هنگام فراخوانی recvmsg روی سوکت UDP در مسیر A → B به تصویر بکشیم، با چنین نمایی روبه‌رو خواهیم شد:

تصویر مقاله

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

توابع recvmsg و sendmsg نیز درست مانند readv و writev از ساختار iovec بهره برده و عملیات scatter / gather read / write را پیاده‌سازی می‌کنند.

تصویر مقاله

فرض کنید یک دیتاگرام UDP به حجم ۱۷۰ بایت از آدرس 192.6.38.100:2000 دریافت شود؛ ساختار دریافت آن به شکل زیر خواهد بود:

تصویر مقاله
  • به لطف فعال بودن فلگ IP_RECVDSTADDR، اطلاعات مبدأ و مقصد در دسترس است. این داده‌ها درون آرایهٔ msg_control که به داده‌های کمکی اختصاص دارد قرار می‌گیرند.
  • با رسیدن دیتاگرام، بافرها به ترتیب با مقادیر ۱۰۰، ۶۰ و ۱۰ بایت پر می‌شوند و در نهایت، تابع recvmsg عدد ۱۷۰ (مجموع بایت‌ها) را برمی‌گرداند.

3. Ancillary Data

ساختار cmsghdr

داده‌های کمکی از طریق فیلدهای msg_control و msg_controllen در ساختار msghdr منتقل می‌شوند.

فیلد msg_control از مجموعه‌ای از اشیای داده‌های کمکی (ancillary data object) تشکیل شده که هر کدام شامل ساختار cmsghdr و آرایه‌ای به نام cmsg_data هستند.

C++

فیلد cmsg_level اطلاعات پروتکل و cmsg_type نوع پیام مورد استفاده در آن پروتکل را مشخص می‌کند. اگرچه جدول کاملی برای این مقادیر وجود دارد، اما به دلیل کاربرد محدودشان در این بخش، از بررسی تک‌تک آن‌ها صرف‌نظر می‌کنیم.

با این حال، همان‌طور که می‌بینید، متغیر IP_RECVDSTADDR که در مثال قبلی به آن پرداختیم نیز در این جدول حضور دارد.

تصویر مقاله

آرایهٔ اشیای داده‌های کمکی

همان‌طور که اشاره شد، msg_control از آرایه‌ای از اشیای داده‌های کمکی (ancillary data object) تشکیل می‌شود که ساختار کلی آن به صورت زیر است:

تصویر مقاله

هر شیء دادهٔ کمکی از الگوی cmsghdr — pad — cmsgdata[] پیروی می‌کند و میان هدر و دیتا، و همچنین میان دیتا و هدرِ بعدی، فاصله‌گذاری یا پدینگ (padding) اعمال می‌شود.

این پدینگ بسته به شرایط ممکن است اعمال شود یا نشود. یکی از نمونه‌های ذکر شده در کتاب، انتقال اطلاعات دیسکریپتور است (که در فصل ۱۵ تحت عنوان passing descriptor به تفصیل بررسی می‌شود).

تصویر مقاله

به زبان ساده، مشخص نیست چند داده در آرایهٔ داده‌های کمکی قرار می‌گیرد یا اینکه آیا پدینگ اعمال می‌شود یا خیر؛ از این رو برای راحتی کار برنامه‌نویس، ماکروهای مشخصی برای مدیریت این محاسبات طراحی شده است.

تصویر مقاله

ماکروی SPACE مقداری است که اندازه را با احتساب پدینگ (padding) محاسبه می‌کند.

OBJECTIVEC

4. Check Queue without reading data

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

  1. نخست اینکه داده‌ها حتی پس از بررسی باید دست‌نخورده در صف باقی بمانند.
  2. دوم اینکه در صورت خالی بودن صف، اجرای برنامه نباید متوقف (block) شود و خواندن باید به صورت Non-Blocking I/O انجام گیرد.

بنابراین برای پیاده‌سازی این عملکرد، معمولاً دو فلگ MSG_PEEK و MSG_DONTWAIT با یکدیگر ترکیب و استفاده می‌شوند.

5. Sockets and Standard I/O

در این بخش، نحوهٔ انجام عملیات خواندن و نوشتن (read / write) با استفاده از کتابخانهٔ استاندارد ورودی/خروجی (standard I/O) بررسی می‌شود.

در این میان یک نکتهٔ کلیدی و مهم وجود دارد:

  1. از آنجا که select تنها با دیسکریپتورها کار می‌کند، برای فراخوانی select روی استریم‌های ورودی/خروجی استاندارد، حتماً باید فایل دیسکریپتور مربوط به آن استریم را دریافت کنید.
  2. استریم‌های استاندارد ورودی/خروجی (Standard I/O) درست مانند سوکت‌های TCP / UDP می‌توانند به‌صورت full-duplex کار کنند؛ اما اگر بخواهید در میان توابع ورودی از یک تابع خروجی استفاده کنید (یا برعکس)، باید توابع خاصی مثل fflush یا fseek را فراخوانی کنید. مشکل اینجاست که توابعی نظیر fseek، fsetpos و rewind در پس‌زمینه تابع lseek را فراخوانی می‌کنند که اساساً روی سوکت‌ها قابل اجرا نیست.

علاوه بر این، برخی از پیاده‌سازی‌های stdio ممکن است در مدیریت فایل دیسکریپتورهای بزرگ‌تر از ۲۵۵ نیز به مشکل بخورند (مانند Solaris).

به همین دلیل برای حل این چالش، معمولاً مرسوم است که دو استریم ورودی/خروجی استاندارد مجزا ایجاد کنند؛ یکی مختص خواندن و دیگری برای نوشتن.

فرض کنید در پیاده‌سازی یک سرور TCP echo، تابع str_echo را با استریم‌های استاندارد ورودی/خروجی پیاده کرده باشیم.

C++

در این وضعیت اگر طبق روال همیشگی انتظار عملکرد echo را داشته باشید، با مشکلات زیر مواجه خواهید شد:

تصویر مقاله
  • تمام ورودی‌ها و خروجی‌های استاندارد سرور بافر می‌شوند؛ چراکه در اغلب سیستم‌های Unix، کتابخانه ورودی/خروجی استاندارد تا زمانی که مستقیماً به یک ترمینال متصل نباشد، به‌صورت fully-buffered عمل می‌کند.
  • در نتیجه برنامه فقط داده‌ها را از ورودی استاندارد می‌خواند تا جایی که به EOF برسد.
  • تنها زمانی که تابع exit فراخوانی شود، بافر ورودی/خروجی خالی (flush) می‌شود. تازه در این لحظه است که درخواست از طریق fputs به سمت سرور ارسال شده و کلاینت می‌تواند پاسخ را تحویل بگیرد و پردازش کند.

برای جلوگیری از این رفتار، می‌توان استریم خروجی را به‌اجبار به حالت line-buffered درآورد یا مستقیماً fflush را صدا زد؛ اما این کار هم بسته‌های دیتای غیرضروری متعددی ایجاد می‌کند و چندان با الگوریتم Nagle سر سازگاری ندارد.

به همین خاطر همان‌طور که پیش‌تر گفتیم، کتاب توصیه می‌کند حتی اگر استفاده از کتابخانه‌های سطح بالا کار را ساده‌تر می‌کند، به دلیل وجود چنین چالش‌هایی از به‌کارگیری آن‌ها خودداری کنید.

۶. روش‌های پیشرفته Polling (Advanced Polling)

در این بخش به سراغ جایگزین‌های پیشرفته‌ای می‌رویم که سیستم‌عامل‌های مختلف برای select و poll معرفی کرده‌اند؛ به‌ویژه دو راهکار مطرح /dev/poll و kqueue.

A. رابط /dev/poll

این رابط راهکاری قدرتمند برای مانیتور و poll کردن تعداد بسیار زیادی file descriptor ارائه می‌دهد.

  • این قابلیت در سیستم‌عامل‌های خانواده Solaris در دسترس است.
  • یکی از نقاط ضعف روش‌های قدیمی select و poll این بود که باید در هر بار فراخوانی، لیست فایل دیسکریپتورها را از نو ارسال می‌کردید. اما در این روش، مدیریت فایل‌هایی که قرار است بررسی شوند بر عهده خودِ /dev/poll است؛ به بیان ساده‌تر، سیستم وضعیت‌مند (stateful) عمل می‌کند.
  • می‌توانید /dev/poll را نوعی اینترفیس دستگاه (device interface) در نظر بگیرید.

ساختار dvpoll

با کمک ساختار dvpoll، آرایه‌ای از ساختارهای pollfd را بر روی /dev/poll می‌نویسیم.

C++

سپس /dev/poll این اطلاعات را دریافت کرده و ioctl را با پرچم DP_POLL فراخوانی می‌کند. این فراخوانی تا زمانی که یک رویداد روی دیسکریپتورها رخ دهد یا زمان Timeout به پایان برسد، در وضعیت مسدودکننده (blocking) باقی می‌ماند.

مثال) پیاده‌سازی یک کلاینت echo با بهره‌گیری از رابط /dev/poll.

C++

B. رابط kqueue

این سازوکار یک سیستم صف رویداد در هسته (Kernel Event Queue) است که به فرآیندها اجازه می‌دهد فیلترهای رویداد (event filter) اختصاصی خود را ثبت کنند. رابط kqueue شامل توابع و ماکروهای زیر است:

  • این رابط در اکثر سیستم‌عامل‌های مبتنی بر BSD به کار گرفته می‌شود.
  • از آنجا که کرنل خودش مستقیماً رویدادهای مورد نیاز برنامه را ذخیره و مدیریت می‌کند، درست مانند /dev/poll، سربار ناشی از فراخوانی‌های مکرر select/poll به حداقل می‌رسد.
«این تصمیم در طراحی poll و select مبنی بر عدم نگهداری وضعیت (state) در سطح کرنل، علت اصلی ناکارآمدی در پیاده‌سازی‌های فعلی است. اگر کرنل می‌توانست دقیقاً ردیابی کند که برنامه به کدام دیسکریپتورها علاقه‌مند است و تنها همان دیسکریپتورهای فعال‌شده را بازگرداند، بخش اعظمی از این بار اضافه (overhead) حذف می‌شد.»
«رابط kqueue با هدف کاهش سربار ناشی از poll() و select() طراحی شده است؛ آن هم از طریق اطلاع‌رسانی کارآمد به کاربر درباره رویدادهایی که نیازمند توجه و رسیدگی هستند.»

منبع: https://people.freebsd.org/~jlemon/papers/kqueue.pdf

C++

پارامترهای changelist و nchanges

  • این پارامترها برای ثبت تغییرات فیلترهای رویداد استفاده می‌شوند؛ در صورتی که تغییری وجود نداشته باشد، مقادیر آن‌ها به ترتیب NULL و 0 خواهد بود.
  • در واقع می‌توان این بخش را ورودیِ لازم برای ثبت، ویرایش یا حذف رویدادها دانست.

پارامترهای eventlist و nevents

  • این بخش، رویدادهایی از میان فیلترهای ثبت‌شده را که دچار تغییر یا رخداد شده‌اند بازمی‌گرداند.
  • می‌توان آن را خروجی یا نتیجه نهایی برای دریافت تغییرات رویدادها تلقی کرد.

پارامتر timeout

  • مقدار بزرگ‌تر از ۰: تعیین یک بازه زمانی مشخص برای Timeout
  • مقدار برابر با ۰: بررسی وضعیت بدون مسدود شدن پردازش (Non-blocking)
  • مقدار NULL: عدم تعیین محدودیت زمانی (منتظر ماندن نامحدود)

پارامترهای changelist و eventlist در حقیقت آرایه‌ای از استراکچرهای kevent هستند و ساختار kevent به شکل زیر تعریف می‌شود:

C++

در این ساختار، رفتار رویداد از طریق مقدار action flag مشخص می‌شود و نوع فیلتر نیز توسط مقدار filter اعلام می‌گردد.

تصویر مقاله
تصویر مقاله

از آنجا که این مقادیر رویداد کاملاً شهودی و قابل درک هستند، اجازه دهید کاربردشان را در قالب یک مثال ببینیم.

مثال) پیاده‌سازی تابع کلاینت echo با استفاده از قابلیت‌های رابط kqueue.

C++

خلاصه فصل

  • با سه رویکرد متفاوت برای تعریف Timeout در عملکردهای سوکت آشنا شدیم.
  • انواع روش‌های توسعه‌یافته توابع read و write را بررسی کردیم و به‌طور ویژه با عملکرد و نحوه کار توابع پرکاربرد recvmsg و sendmsg آشنا شدیم.
  • ساختار ارسال داده‌های کمکی (Ancillary Data) و ماکروهایی را که کار با آن‌ها را ساده‌تر می‌کنند شناختیم.
  • چالش‌ها و ریزه‌کاری‌های پیاده‌سازی عملیات خواندن و نوشتن از طریق ورودی/خروجی استاندارد را تحلیل کردیم.
  • و در نهایت، پیاده‌سازی‌های پیشرفته‌تر را برای غلبه بر ناکارآمدی‌ها و بار اضافه select و poll مرور کردیم.