تصویر مقاله
عکس از Priscilla Du Preez 🇨🇦 در Unsplash

در فصل پنجم دیدیم که کلاینت TCP چطور دو ورودی مجزا یعنی ورودی استاندارد (Standard Input) و سوکت TCP را به‌صورت هم‌زمان مدیریت می‌کرد. با این حال، وقتی کلاینت روی فراخوانی fgets مسدود (block) شده بود و فرایند سمت سرور متوقف می‌شد، با یک چالش اساسی روبه‌رو می‌شدیم: اگرچه سرور TCP بستهٔ FIN را به‌درستی به کلاینت TCP ارسال می‌کرد، اما از آن‌جا که فرایند کلاینت برای خواندن از ورودی استاندارد قفل (block) شده بود، هرگز موفق به دریافت و مشاهدهٔ EOF نمی‌شد.

برای حل این مشکل، به قابلیتی به نام I/O multiplexing نیاز داریم؛ امکانی که توابع select و poll در اختیارمان می‌گذارند. علاوه بر این، نسخهٔ جدیدتر و سازگار با POSIX یعنی pselect را نیز بررسی خواهیم کرد.

مدل‌های ورودی/خروجی (I/O Models)

پیش از آن‌که به سراغ بررسی دقیق select و poll برویم، بیایید ابتدا مدل‌های مختلف I/O را که در Unix به کار می‌روند مرور کنیم.

  • مدل I/O مسدودکننده (blocking I/O)
  • مدل I/O غیرمسدودکننده (nonblocking I/O)
  • مدل I/O multiplexing (با توابع select و poll)
  • مدل I/O مبتنی بر سیگنال یا signal-driven I/O (SIGIO)
  • مدل I/O ناهمگام یا asynchronous I/O (توابع aio_ در POSIX)

در تمامی مثال‌های پیش‌رو، عملیات خواندن ورودی در دو مرحلهٔ متمایز انجام می‌گیرد:

  1. ۱. انتظار برای آماده شدن داده‌ها
  2. ۲. کپی کردن داده‌ها از فضای Kernel به حافظهٔ فرایند (Process)

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

مدل blocking I/O

این مدل، رایج‌ترین رویکرد در مدیریت ورودی/خروجی به شمار می‌رود و به‌صورت پیش‌فرض، تمام سوکت‌ها روی حالت blocking تنظیم شده‌اند.

تصویر مقاله

برای ساده‌تر شدن درک موضوع، در این مثال به جای TCP از پروتکل UDP استفاده می‌کنیم.

وقتی فرایند تابع recvfrom را فرامی‌خواند، بلافاصله پاسخی دریافت نمی‌کند؛ بلکه این فراخوانی تا زمان رسیدن داده‌ها و کپی شدن کامل آن‌ها در بافر برنامه منتظر می‌ماند و پس از آن بازمی‌گردد (مگر اینکه خطایی رخ دهد). رایج‌ترین خطایی که ممکن است پیش بیاید، قطع شدن (interrupted) فراخوانی سیستمی توسط یک سیگنال (signal) است. به کل این بازهٔ زمانی—از فراخوانی recvfrom تا بازگشت پاسخ—می‌گوییم فرایند در حالت مسدود یا blocked قرار دارد.

مدل nonblocking I/O

«اگر عملیات I/O درخواست‌شده بدون به خواب رفتن (Sleep) فرایند قابل انجام نباشد، سیستم بلافاصله یک خطا بازمی‌گرداند.»

تصویر مقاله

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

هنگامی که یک برنامه در یک حلقهٔ مداوم، تابع recvfrom را روی یک دیسکریپتور غیرمسدودکننده (nonblocking descriptor) صدا می‌زند، به این رفتار اصطلاحاً polling می‌گویند. این رویکرد اگرچه منجر به هدررفت توان پردازشی CPU می‌شود (به دلیل busy waiting)، اما همچنان در برخی سناریوها کاربرد دارد.

مدل I/O multiplexing (با توابع select و poll)

در روش I/O multiplexing، فرایند به‌جای آن‌که مستقیماً در فراخوانی‌های سیستمی ورودی/خروجی مسدود شود، با اجرای توابعی نظیر select یا poll در انتظار رویدادها قفل (block) می‌شود.

تصویر مقاله

فرایند در تابع select منتظر می‌ماند و مسدود می‌شود تا زمانی که داده‌های سوکت برای خواندن آماده شوند. به محض آماده شدن سوکت، select برمی‌گردد و تنها در این مرحله است که recvfrom فراخوانی شده و داده‌ها را به بافر برنامه منتقل می‌کند.

شاید در نگاه اول در مقایسه با مدل blocking، این رویکرد به دلیل نیاز به دو بار فراخوانی سیستمی مزیتی نداشته باشد و حتی ناکارآمدتر به نظر برسد؛ اما مزیت واقعی select—که در ادامه بیشتر به آن خواهیم پرداخت—این است که به ما امکان می‌دهد به‌طور هم‌زمان در انتظار آماده شدن بیش از یک دیسکریپتور بمانیم.

نکته: این سازوکار شباهت زیادی به استفاده از چندنخی (Multithreading) همراه با blocking I/O دارد؛ با این تفاوت که در چندنخی معمولاً برای هر رشته (thread) یک توصیف‌کننده فایل (file descriptor) در نظر گرفته می‌شود، اما با select می‌توان چندین توصیف‌کننده فایل را به صورت یکجا مدیریت کرد.

مدل I/O مبتنی بر سیگنال یا signal-driven I/O (SIGIO)

روش دیگر، بهره‌گیری از سیگنال‌هاست؛ به این ترتیب که از Kernel می‌خواهیم هر زمان دیسکریپتور آماده شد، سیگنال SIGIO را برایمان ارسال کند. به این رویکرد، signal-driven I/O گفته می‌شود.

تصویر مقاله

در این مدل، سوکت را برای استفاده از signal-driven I/O فعال کرده و با استفاده از فراخوانی سیستمی sigaction یک هندلر سیگنال (signal handler) تعریف می‌کنیم. این فراخوانی بلافاصله پاسخ داده و بدون آن‌که فرایند را قفل (block) کند، اجرای برنامه ادامه می‌یابد. به محض آماده شدن داده‌ها برای خواندن، سیگنال SIGIO صادر می‌شود. در این مرحله می‌توانیم درون هندلر سیگنال، تابع recvfrom را فراخوانی کرده و داده‌ها را بخوانیم و به حلقهٔ اصلی برنامه اطلاع دهیم که داده‌ها آمادهٔ پردازش هستند (این موضوع در بخش ۲۵.۳ به تفصیل بررسی خواهد شد)، یا اینکه صرفاً به حلقهٔ اصلی اطلاع دهیم تا خود اقدام به خواندن داده‌ها کند.

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

مدل I/O ناهمگام یا asynchronous I/O (توابع aio_ در POSIX)

استاندارد I/O ناهمگام در مشخصات POSIX تعریف شده است. روند کلی این توابع به این صورت است که به Kernel اعلام می‌کنند عملیات را آغاز کند و پس از اتمام کامل آن، نتیجه را گزارش دهد.

  • در روش signal-driven I/O پیام این است: «عملیات I/O آغاز شده و داده آمادهٔ خواندن است.»
  • در حالی که در asynchronous I/O، پیام Kernel این است: «کل عملیات I/O با موفقیت به پایان رسید.»

هنگام فراخوانی aio_read (که توابع ناهمگام POSIX معمولاً با پیشوندهای aio_ یا lio_ آغاز می‌شوند)، اطلاعاتی نظیر دیسکریپتور، اشاره‌گر به بافر، اندازهٔ بافر، آفست فایل و نحوهٔ اطلاع‌رسانی پس از اتمام کامل عملیات به Kernel منتقل می‌شود. این فراخوانی سیستمی بلافاصله بازمی‌گردد و منتظر اتمام عملیات I/O نمی‌ماند. در این سناریو فرض بر این است که از Kernel خواسته‌ایم پس از پایان کار، سیگنالی ارسال کند؛ این سیگنال تنها زمانی صادر می‌شود که داده‌ها به‌طور کامل در بافر برنامه کپی شده باشند.

نکته: در زمان نگارش این مطلب، سیستم‌های انگشت‌شماری از استاندارد POSIX asynchronous I/O پشتیبانی می‌کنند.

مقایسهٔ مدل‌های مختلف ورودی/خروجی (I/O)

چهار مدل نخست در مرحلهٔ اول (نحوهٔ انتظار برای آماده‌سازی داده) با یکدیگر تفاوت دارند، اما در مرحلهٔ دوم همگی یکسان هستند؛ چرا که در تمامی آن‌ها فرایند هنگام اجرای recvfrom و انتقال داده مسدود می‌شود.

تصویر مقاله

استاندارد POSIX این تفاوت را به شکل زیر تعریف می‌کند:

  • عملیات ورودی/خروجی همگام (Synchronous I/O): فرایند درخواست‌کننده تا زمان تکمیل کامل عملیات I/O مسدود (block) باقی می‌ماند.
  • عملیات ورودی/خروجی ناهمگام (Asynchronous I/O): فرایند درخواست‌کننده در هیچ مرحله‌ای مسدود (block) نمی‌شود.

بر پایهٔ این تعریف، چهار مدل اول همگی در دستهٔ Synchronous I/O قرار می‌گیرند و تنها مدل آخر یک Asynchronous I/O واقعی به شمار می‌آید؛ چرا که در چهار مدل نخست، فراخوانی recvfrom در نهایت فرایند را قفل (block) می‌کند.

تابع select

این تابع به Kernel این امکان را می‌دهد تا به محض رخ دادن هر یک از چندین رویداد مشخص‌شده، یا با به پایان رسیدن مدت زمان تعیین‌شده (Timeout)، فرایند را مطلع کند.

به عنوان مثال، می‌توان با استفاده از select به Kernel دستور داد تنها در شرایط زیر پاسخ را برگرداند:

  • هنگامی که دست‌کم یکی از دیسکریپتورهای {۱، ۴، ۵} آمادهٔ خواندن باشد.
  • هنگامی که دست‌کم یکی از دیسکریپتورهای {۲، ۷} آمادهٔ نوشتن باشد.
  • وقتی در هر یک از descriptorهای مجموعه {1,4} وضعیت استثنایی (Exception) رخ دهد.
  • زمانی که ۱۰.۲ ثانیه سپری شود.

به عبارت دیگر، ما می‌توانیم به Kernel بگوییم به کدام descriptorها (برای خواندن، نوشتن یا بروز استثنا) علاقه‌مندیم و چقدر باید منتظر بماند. نکته جالب اینجاست که این descriptorها فقط به سوکت‌ها محدود نمی‌شوند و می‌توان از هر نوع descriptor دلخواهی استفاده کرد.

نکته: پیاده‌سازی‌های مشتق‌شده از برکلی قابلیت I/O multiplexing را برای هر نوع descriptor پشتیبانی می‌کردند. در مقابل، SVR3 در ابتدا I/O multiplexing را فقط به descriptorهای مربوط به دستگاه‌های STREAMS (که در فصل ۳۱ بررسی می‌شود) محدود کرده بود، اما این محدودیت در SVR4 برداشته شد.
C++
NONE
NONE
NONE

ساختار timeval

C++

ساختاری برای تعیین مدت زمان انتظار به Kernel؛ این ساختار مشخص می‌کند که دقیقاً چند ثانیه و چند میکروثانیه باید منتظر ماند.

  1. انتظار نامحدود (بی‌نهایت): در این حالت، تابع فقط زمانی بازمی‌گردد که حداقل یکی از I/O descriptorهای مشخص‌شده آماده شود. برای این کار کافی است اشاره‌گر timeout را روی Null تنظیم کنید.
  2. انتظار برای یک بازه زمانی مشخص: اگر یکی از I/O descriptorهای تعیین‌شده آماده شود، تابع پاسخ می‌دهد؛ اما در هر صورت، مدت زمان انتظار از مقدار ثانیه و میکروثانیه‌ای که در ساختار timeval مشخص شده، فراتر نخواهد رفت.
  3. عدم انتظار (بازگشت آنی): بلافاصله پس از بررسی descriptorهای مشخص‌شده پاسخ داده می‌شود که به این عمل Polling می‌گویند. برای این حالت، مقادیر زمانی در ساختار timeval باید برابر با صفر تنظیم شوند.

در دو سناریوی اول، چنانچه پروسس یک Signal دریافت کند و Signal Handler اجرا شده و بازگردد، فرایند انتظار متوقف (Interrupted) خواهد شد.

نکته: کرنل‌های برگرفته از برکلی هرگز select را به‌طور خودکار ری‌استارت نمی‌کنند؛ در حالی که SVR4 در صورت فعال بودن فلگ SA_RESTART در Signal Handler، آن را مجدداً اجرا می‌کند. بنابراین برای حفظ سازگاری برنامه، همیشه باید برای مواجهه با خطای EINTR از سوی select آماده باشید.
در برخی سیستم‌ها اگر فیلد tv_sec را روی ۱۰۰ میلیون ثانیه تنظیم کنید، با خطای EINVAL مواجه می‌شوید. این مسئله نشان می‌دهد که ساختار timeval می‌تواند مقادیری بسیار بزرگ‌تر از آنچه تابع select عملاً پشتیبانی می‌کند را در خود جای دهد.

استفاده از عبارت const برای آرگومان timeout نشان می‌دهد که تابع select هنگام بازگشت، مقدار آن را تغییر نمی‌دهد. به عنوان مثال، اگر تایمر را روی ۱۰ ثانیه تنظیم کرده باشید و تابع به دلیل آماده شدن یک descriptor یا رخ دادن خطای EINTR پیش از اتمام زمان بازگردد، مقدار ساختار timeval آپدیت نخواهد شد.

نکته: برخی از نسخه‌های Linux ساختار timeval را تغییر می‌دهند؛ به همین دلیل برای تضمین سازگاری، بهتر است فرض کنید که مقدار این ساختار پس از اجرای تابع نامشخص (Undefined) است و باید پیش از هر بار فراخوانی select آن را مجدداً مقداردهی اولیه کنید. این در حالی است که استاندارد POSIX صراحتاً وجود شناسه const را مشخص کرده است.

مجموعه‌های readset، writeset و exceptset

در حال حاضر تنها دو وضعیت استثنایی (Exception) پشتیبانی می‌شود که از آنجا که یا در این کتاب مطرح نشده‌اند یا در فصل‌های بعدی به تفصیل بررسی خواهند شد، فعلاً نیازی به پرداختن به آن‌ها نیست.

  1. ۱. رسیدن داده‌های Out-of-band برای سوکت (که در فصل ۲۴ به تفصیل شرح داده می‌شود).
  2. ۲. وجود اطلاعات Control status برای خواندن از سمت Master یک Pseudo-terminal در حالت Packet Mode (که در این کتاب به مبحث Pseudo-terminalها پرداخته نمی‌شود).

نحوه پیاده‌سازی Descriptor set در تابع select به معماری سیستم بستگی دارد، اما معمولاً به‌صورت آرایه‌ای از اعداد صحیح (Integer) پیاده‌سازی می‌شود که در آن هر بیت متناظر با یک descriptor است. به عنوان مثال، در یک سیستم با Integerهای ۳۲ بیتی، اولین عنصر آرایه نشان‌دهنده descriptorهای ۰ تا ۳۱ و دومین عنصر نمایانگر descriptorهای ۳۲ تا ۶۳ خواهد بود. تمام این جزئیات پیاده‌سازی از دید برنامه مخفی مانده و در قالب نوع داده fd_set و ماکروهای مربوطه انتزاع (Abstract) شده‌اند.

C++
نکته: نمایش descriptorها به شکل آرایه‌ای از اعداد صحیح صرفاً یک روش برای پیاده‌سازی select است، هرچند نمایش بیتی آنها بسیار رایج است. در بخش ۶.۱۰ خواهید دید که تابع poll از رویکردی کاملاً متفاوت استفاده می‌کند؛ به‌طوری‌که هر descriptor یک ساختار اختصاصی دارد و در قالب یک آرایه با طول متغیر تعریف می‌شود.
CSS
NONE

عدم مقداردهی اولیه fd set می‌تواند به نتایج غیرقابل‌پیش‌بینی منجر شود، بنابراین مقداردهی اولیه آن امری کاملاً ضروری است.

چنانچه به بررسی وضعیت خاصی نیاز نداشته باشید، می‌توانید آرگومان‌های میانی تابع select یعنی readset، writeset و exceptset را برابر با اشاره‌گر Null قرار دهید. جالب است بدانید اگر هر سه آرگومان Null باشند، می‌توان از این تابع به عنوان یک تایمر با دقتی به مراتب بالاتر از sleep در Unix استفاده کرد.

پارامتر maxfdp1

پارامتر maxfdp1 تعداد descriptorهایی را که باید بررسی شوند تعیین می‌کند؛ مقدار آن برابر است با «بزرگ‌ترین مقدار descriptor بعلاوه ۱». به این ترتیب، بیت‌های متناظر با descriptorهای ۰، ۱، ۲ تا maxfdp1-1 مورد بررسی قرار می‌گیرند.

ثابت FD_SETSIZE که در فایل هدر <sys/select.h> تعریف شده، حداکثر تعداد descriptorها در ساختار داده fd_set است که معمولاً ۱۰۲۴ در نظر گرفته می‌شود. با این حال، اکثر برنامه‌ها به این تعداد descriptor نیاز ندارند و وجود پارامتر maxfdp1 برنامه‌نویس را وادار می‌کند تا تعداد واقعی descriptorهای مورد نیاز را محاسبه کند.

نکته: تنها دلیل وجود این آرگومان، افزایش بهره‌وری و کارایی است؛ زیرا از کپی شدن بیهوده مجموعه‌های descriptor بین Kernel و Process و همچنین بررسی بیت‌های غیرضروری جلوگیری می‌کند.

تابع select مقدار descriptorهایی را که اشاره‌گرهای readset، writeset و exceptset به آن‌ها اشاره دارند تغییر می‌دهد؛ یعنی این سه آرگومان از نوع Value-result هستند. شما هنگام فراخوانی تابع مشخص می‌کنید که به کدام descriptorها علاقه‌مندید و هنگام بازگشت تابع، آماده بودن آن‌ها را بررسی می‌کنید. برای بررسی ساختار fd_set از ماکروی FD_ISSET استفاده می‌شود و تمام descriptorهایی که آماده نباشند، با مقدار ۰ مشخص خواهند شد.

نکته: دو اشتباه بسیار رایج در برنامه‌نویسی با این تابع:
۱. فراموش کردن افزودن عدد ۱ به بزرگ‌ترین مقدار descriptor برای تعیین maxfdp1.
۲. فراموش کردن ماهیت Value-result بودن Descriptor set؛ این خطا باعث می‌شود تصور کنید بیت یک descriptor همچنان فعال (۱) است، در حالی که توسط تابع روی ۰ تنظیم شده است.

مقدار بازگشتی (Return Value)

مقدار بازگشتی تابع نشان‌دهنده تعداد بیت‌های آماده در تمامی Descriptor setها است. اگر زمان تعیین‌شده به پایان برسد و هیچ descriptorی آماده نشده باشد، مقدار ۰ بازگردانده می‌شود. بازگشت مقدار ۱- به معنای وقوع خطا است؛ مثلاً زمانی که اجرای تابع توسط یک Signal قطع (Interrupted) شود.

نکته: در نسخه‌های اولیه SVR4 باگی وجود داشت که اگر یک descriptor هم برای خواندن و هم برای نوشتن آماده می‌شد، آن را تنها ۱ مورد محاسبه می‌کرد.

یک descriptor تحت چه شرایطی آماده تلقی می‌شود؟

معیارهای تابع select برای آماده در نظر گرفتن یک سوکت

یک سوکت در صورت برقراری هر یک از ۴ شرط زیر، «آماده برای خواندن» (Read Ready) در نظر گرفته می‌شود:

  1. ۱. تعداد بایت‌های داده در بافر دریافت سوکت، بزرگ‌تر یا مساوی با مقدار Low-water mark بافر دریافت باشد (آماده بودن داده برای خواندن). این مقدار با آپشن SO_RCVLOWAT قابل تنظیم بوده و پیش‌فرض آن در سوکت‌های TCP و UDP برابر با ۱ است.
  2. ۲. بسته شدن سمت خواندن اتصال (دریافت FIN در اتصال TCP). در این وضعیت عملیات خواندن روی سوکت مسدود نشده (Non-blocking) و مقدار ۰ (یعنی همان EOF) را برمی‌گرداند.
  3. ۳. سوکت در حالت Listening باشد و تعداد اتصالات تکمیل‌شده بزرگ‌تر از صفر باشد. در این حالت معمولاً فراخوانی تابع accept مسدود نخواهد شد (موارد استثنایی که منجر به Block شدن می‌شوند در بخش ۱۶.۶ توضیح داده شده‌اند).
  4. ۴. وجود یک خطای معلق (Pending Error) در سوکت. در این شرایط عملیات خواندن Block نمی‌شود و فوراً خطایی را به همراه مقدار مربوطه در errno برمی‌گرداند. این خطای معلق را می‌توان با فراخوانی getsockopt و تنظیم آپشن SO_ERROR پاک کرد.

یک سوکت در صورت برقراری هر یک از شرایط زیر، «آماده برای نوشتن» (Write Ready) در نظر گرفته می‌شود:

  1. ۱. فضای خالی در بافر ارسال سوکت بزرگ‌تر یا مساوی اندازه فعلی Low-water mark بافر ارسال باشد و سوکت متصل شده باشد؛ یا
  2. ۲. سوکت نیازی به برقراری اتصال نداشته باشد (مانند UDP). در چنین حالتی عملیات نوشتن از نوع Non-blocking خواهد بود (در فصل ۱۶ به تفصیل بررسی می‌شود) و تعداد بایت‌های پذیرفته‌شده در لایه انتقال بازگردانده می‌شود. مقدار Low-water mark با آپشن SO_SNDLOWAT قابل تنظیم بوده و مقدار پیش‌فرض آن برای TCP و UDP برابر ۲۰۴۸ است.
  3. ۳. بسته شدن سمت نوشتن اتصال؛ در این حالت تلاش برای نوشتن باعث تولید سیگنال SIGPIPE می‌شود.
  4. ۴. وجود یک خطای معلق (Pending Error) در سوکت (مباحث مربوط به Out-of-band data در فصل ۲۴ تشریح خواهد شد).

هدف از تنظیم Low-watermark در بافرهای ارسال و دریافت این است که برنامه بتواند حداقل داده مورد نیاز برای خواندن یا فضای خالی لازم برای نوشتن را تعیین کند تا تابع select بر اساس آن وضعیت خواندن یا نوشتن را گزارش دهد.

تصویر مقاله

حداکثر تعداد descriptorها برای تابع select

زمانی که تابع select برای اولین بار طراحی شد، سیستم‌عامل‌ها معمولاً سقف مشخصی برای حداکثر تعداد descriptorهای هر پروسس داشتند (مثلاً در 4.2BSD این رقم ۳۱ بود) و select نیز از همین محدودیت تبعیت می‌کرد. اما نسخه‌های امروزی Unix عملاً تعداد نامحدودی از descriptorها را برای هر پروسس مجاز می‌دانند.

C++

با توجه به این موضوع، شاید تصور شود که صرفاً با افزایش اندازه آرایه descriptorها و اضافه کردن هدر می‌توان محدودیت را دور زد؛ اما در عمل چنین تغییری نیازمند کامپایل مجدد Kernel خواهد بود.

البته برخی از ارائه‌دهندگان سیستم‌عامل، پیاده‌سازی تابع select را به گونه‌ای اصلاح کرده‌اند که به هر پروسس اجازه می‌دهد مقدار FD_SETSIZE را به عدد بزرگ‌تری تغییر دهد.

تابع str_cli (نسخه ۲)

تصویر مقاله

تابع قبلی str_cli را بازنویسی می‌کنیم تا با استفاده از select، به محض متوقف شدن پروسس سرور بلافاصله از آن مطلع شویم. در نسخهٔ قبل، برنامه هنگام فراخوانی fgets مسدود (block) می‌شد و اگر در همین حین رخدادی روی سوکت پیش می‌آمد با مشکل مواجه می‌شدیم. اما اکنون با کمک select می‌توانیم به صورت هم‌زمان منتظر آماده شدن ورودی استاندارد و سوکت برای خواندن داده‌ها بمانیم.

  1. هنگامی که پروتکل TCP داده‌ای را ارسال می‌کند، سوکت آمادهٔ خواندن شده و تابع read مقداری بزرگ‌تر از صفر (تعداد بایت‌های داده) بازمی‌گرداند.
  2. اگر TCP بستهٔ FIN ارسال کند، سوکت همچنان قابل خواندن خواهد بود اما تابع read مقدار ۰ (یعنی EOF) بازمی‌گرداند.
  3. در صورتی که TCP بستهٔ RST ارسال کند، سوکت مجدداً برای خواندن فعال می‌شود ولی read مقدار ۱- را برمی‌گرداند و کد خطای مربوطه درون errno قرار می‌گیرد.
C++

خطوط 8-13. برای آنکه صرفاً وضعیت خوانایی بررسی شود، فقط rset را مقداردهی می‌کنیم؛ در اینجا fp نشان‌دهنده ورودی/خروجی استاندارد و sockfp توصیف‌کنندهٔ سوکت است.

اشاره‌گرهای write-set و exception-set هر دو روی پوینتر null تنظیم شده‌اند. همچنین برای اینکه برنامه تا زمان آماده شدن سوکت منتظر بماند، آخرین آرگومان یعنی تنظیمات timeout را هم با پوینتر null مقداردهی می‌کنیم.

خطوط 14-18. به محض اینکه سوکت قابل خواندن شود، پیام دریافتی با تابع readline خوانده شده و از طریق fputs در خروجی نمایش داده می‌شود.

خطوط 19-23. هر زمان که ورودی استاندارد آمادهٔ خواندن باشد، داده‌ها با fgets دریافت شده و توسط writen روی سوکت نوشته می‌شوند.

تنها با اضافه کردن چند خط کد، برنامهٔ کلاینت بسیار پایدارتر و قدرتمندتری ساختیم.

ورودی دسته‌ای (Batch Input) و بافرینگ

با این حال، تابع str_cli در وضعیت فعلی هنوز هم خالی از اشکال نیست.

تصویر مقاله

از آنجا که پایپ ارتباطی به صورت کاملاً دوطرفه (full-duplex) است، در واقعیت ما هم‌اکنون تنها از یک‌هشتم ظرفیت مفید آن بهره می‌بریم.

تصویر مقاله

فرض کنید یک فایل ورودی با ۹ خط داده داریم که آخرین خط در زمان هشتم ارسال می‌شود. شیوهٔ فعلی مدیریت EOF به این صورت است که برنامه بلافاصله به تابع main برگشته و پایان می‌یابد. اما در حالت دسته‌ای (batch mode)، رسیدن به EOF در ورودی هرگز به معنای اتمام خواندن داده‌ها از سوکت نیست؛ چرا که ممکن است هنوز درخواست‌هایی در مسیر سرور باشند یا پاسخ‌هایی از طرف سرور در صف دریافت قرار داشته باشند.

راهکار واقعی این است که اتصال TCP را فقط از یک طرف ببندیم (half-close). یعنی با ارسال بستهٔ FIN به سرور اعلام کنیم کار ارسال داده تمام شده، اما توصیف‌کنندهٔ سوکت را برای خواندن باز نگه داریم. این قابلیت دقیقاً با استفاده از تابع shutdown پیاده‌سازی می‌شود.

تابع shutdown

روش متداول برای بستن یک اتصال شبکه، فراخوانی تابع close است؛ اما تابع close در مقایسه با shutdown با محدودیت‌های زیر همراه است:

  1. تابع close شمارندهٔ ارجاع (reference count) توصیف‌کننده را یک واحد کاهش می‌دهد و سوکت تنها زمانی بسته می‌شود که این مقدار به صفر برسد؛ در حالی که تابع shutdown می‌تواند بدون توجه به reference count، فرایند بستن اتصال TCP را کلید بزند.
  2. علاوه بر این، close تبادل داده را در هر دو جهت خواندن و نوشتن متوقف می‌کند. از آنجا که اتصالات TCP ذاتاً full-duplex هستند، گاهی پیش می‌آید که می‌خواهیم ارسال داده‌ها را خاتمه دهیم ولی دریافت همچنان ادامه یابد؛ دقیقاً همان شرایطی که در حالت ورودی دسته‌ای تابع str_cli تجربه کردیم.
تصویر مقاله
C++
  • پرچم SHUT_RD: بستن جهت خواندن اتصال (The read half of the connection is closed). در این حالت دیگر هیچ داده‌ای از طریق سوکت قابل خواندن نخواهد بود و داده‌های موجود در بافر دریافت نیز دور ریخته می‌شوند. پروسس دیگر اجازهٔ فراخوانی توابع خواندن روی این سوکت را ندارد و داده‌های دریافتی بعدی نیز بدون هیچ هشداری نادیده گرفته و دور ریخته می‌شوند.
  • پرچم SHUT_WR: بستن جهت نوشتن اتصال (The write half of the connection is closed). داده‌های حاضر در بافر ارسال سوکت فرستاده می‌شوند و سپس توالی خاتمهٔ ارتباط TCP آغاز می‌گردد. حتی اگر reference count توصیف‌کنندهٔ سوکت مثبت باشد، باز هم فرایند بستن بخش نوشتن با موفقیت پیش خواهد رفت.
  • پرچم SHUT_RDWR: بستن هر دو طرف ارتباط؛ این حالت دقیقاً معادل این است که تابع shutdown را یک بار با آرگومان SHUT_RD و بار دیگر با SHUT_WR اجرا کنیم.

نام‌های SHUT_xxx که ذکر شد بر اساس استاندارد POSIX هستند. مقادیر متداول برای آرگومان howto عبارتند از ۰ (بستن خواندن)، ۱ (بستن نوشتن) و ۲ (بستن هر دو جهت).

تابع str_cli (نسخه ۳)

C++

خطوط 5-8. متغیر stdineof پرچم (flag) جدیدی است که با ۰ مقداردهی می‌شود و تا زمانی که این مقدار صفر بماند، ورودی استاندارد تحت پایش select باقی خواهد ماند. خطوط 17-25. اگر قبلاً از ورودی استاندارد EOF دریافت کرده باشیم، با خواندن EOF از سوکت، برنامه به صورت طبیعی و موفق خاتمه می‌یابد؛ اما اگر هنوز از ورودی استاندارد EOF دریافت نکرده باشیم، نشان‌دهندهٔ پایان زودهنگام پروسس سرور است. در اینجا توابع read و write به جای خط به خط، روی بافر اعمال می‌شوند. خطوط 26-34. چنانچه از ورودی استاندارد EOF خوانده شود، فلگ stdineof روی ۱ تنظیم شده و تابع shutdown با آرگومان SHUT_WR فراخوانی می‌گردد. همچنین خواندن و نوشتن با read و writen به شکل بافری اصلاح شده است.

سرور اکو (Echo) مبتنی بر TCP

در این بخش، سرور اکوی TCP را به جای روش انشعاب پروسس (forking)، با کمک مکانیزم select بازپیاده‌سازی می‌کنیم.

تصویر مقاله

فرض می‌کنیم در صورت اجرای سرور در پیش‌زمینه (foreground)، توصیف‌کننده‌های ۰، ۱ و ۲ به ترتیب به ورودی استاندارد، خروجی استاندارد و خطای استاندارد اختصاص یابند. در این حالت، سوکت شنونده (listening socket) به نخستین توصیف‌کنندهٔ آزاد یعنی شماره ۳ تخصیص داده می‌شود. علاوه بر این، آرایه‌ای به نام client برای ذخیره توصیف‌کنندهٔ سوکت هر کلاینت متصل اضافه می‌کنیم که تمام عناصر آن در ابتدا با ۱- مقداردهی می‌شوند.

OBJECTIVEC

به لطف استفاده از select به جای fork، دیگر نیازی به ساخت پروسس جداگانه برای هر کلاینت نیست و همین موضوع سربار سیستم را به میزان چشمگیری کاهش می‌دهد.

حملات منع خدمت (Denial-of-Service)

اگر کلاینتی تنها یک بایت بفرستد و کاراکتر خط جدید (newline) را ارسال نکند، سرور در فراخوانی read دائماً در حالت انتظار متوقف خواهد ماند. این یعنی به دلیل تعلل یک کلاینت، پاسخ‌دهی به سایر کلاینت‌ها نیز به طور کامل مختل می‌گردد.

هنگامی که سرور به طور هم‌زمان چند کلاینت را مدیریت می‌کند، نباید به خاطر فراخوانی توابع مربوط به یک کلاینت دچار مسدودی (block) شود؛ در غیر این صورت سرور در برابر حملات DOS آسیب‌پذیر خواهد بود. راهکارهای زیر برای حل این معضل پیشنهاد می‌شود:

  1. استفاده از nonblocking I/O (که در فصل ۱۶ به آن خواهیم پرداخت)
  2. اختصاص پروسس یا Thread مستقل به ازای هر کلاینت
  3. تعیین محدودیت زمانی (timeout) برای عملیات ورودی/خروجی

تابع pselect

تابع pselect توسط استاندارد POSIX معرفی شده و امروزه در گسترهٔ وسیعی از نسخه‌های Unix پشتیبانی می‌شود.

C++

تابع pselect در دو زمینهٔ کلیدی با select تفاوت دارد:

  1. نخست اینکه pselect به جای ساختار timeval از ساختار timespec استفاده می‌کند.
C++

تفاوت اصلی این است که برخلاف timeval که بر حسب میکروثانیه است، ساختار جدید دقت نانوثانیه را ارائه می‌دهد. تفاوت دوم این است که pselect یک آرگومان پایانی به نام signal mask اضافه می‌کند که با مسدود کردن سیگنال‌های خاص از تداخل آن‌ها جلوگیری کرده و امکان بررسی ایمن متغیرهای سراسری تعیین‌شده توسط signal handler را فراهم می‌سازد.

در فصل ۲۰ به تفصیل به بررسی تابع pselect خواهیم پرداخت.

تابع poll

تابع poll ریشه در نسخهٔ SVR3 دارد و در ابتدا صرفاً به تجهیزات STREAMS (فصل ۳۱) محدود بود؛ اما در نسخهٔ SVR4 این محدودیت حذف شد تا بر روی تمامی توصیف‌کننده‌ها به درستی کار کند.

C++

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

C++

شرایط مدنظر برای بررسی در عضو events تعریف می‌شوند و تابع poll نتیجه را درون عضو revents قرار می‌دهد. این تفکیک میان اعضای events و revents به منظور پرهیز از پیچیدگی‌های آرگومان‌های value-result صورت گرفته است. ثوابت مورد استفاده در فلگ‌های events و revents به شرح زیر هستند:

تصویر مقاله

بخش اول این ثوابت به ورودی، بخش دوم به خروجی و بخش پایانی به مدیریت خطاها اختصاص دارد.

داده‌ها در سازوکار poll به سه دسته طبقه‌بندی می‌شوند: normal (عادی)، priority band (باند اولویت) و high-priority (اولویت بالا).

نکته: ثابت POLLIN را می‌توان به عنوان حاصل OR منطقی میان POLLRDNORM و POLLRDBAND تعریف کرد که صرفاً برای حفظ سازگاری با نسخه‌های قدیمی (backward compatibility) باقی مانده است. همچنین POLLOUT نیز مفهومی معادل POLLWRNORM دارد و برای سازگاری به گذشته نگهداری شده است.

در خصوص سوکت‌های TCP و UDP، شرایط زیر باعث می‌شوند تا تابع poll مقدار متناسبی را در فیلد revents برگرداند؛ هرچند متأسفانه استاندارد POSIX این موارد را به طور کامل و جامع مشخص نکرده است.

  • تمام داده‌های TCP و UDP همگی جزو دستهٔ normal به شمار می‌آیند.
  • داده‌های out-of-band در پروتکل TCP (که در فصل ۲۴ به طور مفصل به آن‌ها خواهیم پرداخت) جزو دسته‌بندی priority band در نظر گرفته می‌شوند.
  • بسته شدن سمت خواندن (Read) در یک اتصال TCP به عنوان normal data تلقی شده و در پی آن، هرگونه عملیات خواندن بعدی مقدار ۰ را بازمی‌گرداند.
  • وجود خطا در اتصال TCP می‌تواند به عنوان دادهٔ عادی (normal data) یا رویداد POLLERR در نظر گرفته شود.
  • امکان برقراری یک اتصال جدید روی سوکت listening را می‌توان در قالب normal data یا priority data دسته‌بندی کرد، هرچند که در اکثر پیاده‌سازی‌ها آن را جزو normal data به حساب می‌آورند.
  • تکمیل موفقیت‌آمیز یک connect در حالت nonblocking نشان‌دهندهٔ این است که سوکت آمادهٔ نوشتن (write) شده است.

با استفاده از آرگومان nfds می‌توان اندازهٔ آرایه ساختارها را تعیین کرد؛ با اینکه استفاده از unsigned int منطقی‌تر به نظر می‌رسد، اما به دلایل تاریخی نوع آن unsigned long در نظر گرفته شده است.

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

تصویر مقاله

ثابت INFTIM با مقداری منفی تعریف شده است و چنانچه سیستمی از دقت میلی‌ثانیه پشتیبانی نکند، این مقدار به نزدیک‌ترین مقدار ممکن تقریب زده می‌شود.

  • بر اساس استاندارد POSIX، ثابت INFTIM باید در هدر <poll.h> تعریف شود، اما هنوز در بسیاری از سیستم‌ها در فایل <sys/stropts.h> تعریف شده است.
  • درست همانند select، دقت زمان انقضا (timeout) در تابع poll نیز به دقت ساعت سیستم (clock resolution) در پیاده‌سازی مربوطه وابسته و محدود است (که معمولاً ۱۰ میلی‌ثانیه است).

مقادیر بازگشتی تابع poll

  • در صورت بروز خطا: مقدار ۱-
  • در صورتی که تا پایان مهلت انتظار هیچ descriptor آماده نشده باشد: مقدار ۰
  • تعداد descriptorهایی که مقدار فیلد revents آن‌ها غیرصفر است.

چنانچه دیگر تمایلی به بررسی یک descriptor خاص ندارید، کافی است مقدار عضو fd را منفی کنید؛ در این صورت، فیلد events نادیده گرفته شده و مقدار revents در پاسخ برابر با ۰ برگردانده می‌شود.

نکته: استاندارد POSIX پشتیبانی از هر دو تابع select و poll را لازم می‌داند؛ با این حال، امروزه از نظر سازگاری، سیستم‌های بیشتری به جای poll از select پشتیبانی می‌کنند. جالب است بدانید که POSIX نسخه‌ای ارتقایافته از select را با نام pselect معرفی کرده، اما چنین کاری را برای poll انجام نداده است.
C++

جمع‌بندی

در سیستم‌عامل‌های Unix به طور کلی پنج مدل مختلف برای I/O وجود دارد:

  • مدل blocking I/O
  • مدل nonblocking I/O
  • مدل I/O multiplexing
  • مدل signal driven I/O
  • مدل asynchronous I/O

محبوب‌ترین و پرکاربردترین تابع برای پیاده‌سازی I/O multiplexing تابع select است که پارامترهای زیر به عنوان ورودی به آن ارسال می‌شوند:

  1. مشخص کردن descriptors مورد نظر ما برای بررسی (شامل حالت‌های خواندن، نوشتن و استثناها)
  2. حداکثر مدت زمانی که باید منتظر رخ دادن رویداد ماند
  3. حداکثر تعداد descriptorها (به اضافهٔ یک)

ما کلاینت echo را با بهره‌گیری از select در حالت batch mode پیاده‌سازی کردیم؛ در این وضعیت، حتی اگر ورودی کاربر تمام شده باشد، ممکن است داده‌هایی کماکان در مسیر انتقال یا دریافت (درون پایپ) وجود داشته باشند. برای مدیریت صحیح این حالت، استفاده از تابع shutdown و بهره‌گیری از قابلیت نیمه‌بستن (half-close) در پروتکل TCP کاملاً ضروری است.

استاندارد POSIX با تعریف تابع pselect، دقت زمان را از میکروثانیه به نانوثانیه ارتقا داده و امکان دریافت اشاره‌گری به یک مجموعه سیگنال (signal set) را نیز به عنوان پارامتر ورودی فراهم کرده است.

تابع poll در System V عملکردی مشابه select ارائه می‌دهد و در عین حال اطلاعات بیشتری را در خصوص دستگاه‌های STREAMS فراهم می‌آورد. اگرچه استاندارد POSIX از هر دوی این توابع پشتیبانی می‌کند، اما در دنیای واقعی select بسیار بیشتر مورد استفاده قرار می‌گیرد.