محدودیت همزمانی: چرا select و poll در مقیاس بزرگ متوقف می‌شوند

تمرکزدهنده‌های پیشرفته VPN در سطح حامل‌های مخابراتی و گیت‌وی‌های سازمانی اغلب ده‌ها یا صدها هزار نشست همزمان کلاینت را روی یک سرور فیزیکی واحد مدیریت می‌کنند. در ساختارهای مدرن دسترسی راه دور، هر تونل فعال نیازمند نگهداری وضعیت سوکت UDP یا TCP، کلیدهای رمزنگاری نشست و زمان‌سنج‌های دوره‌ای بررسی حیات (keepalive) است.

در دیمن‌های شبکه قدیمی، چندپخشی یا تسهیم ورودی/خروجی (I/O Multiplexing) بر فراخوان‌های سنتی سیستم برگرفته از BSD متکی بود: select() و poll(). اگرچه این ابزارها از دید مفهومی ساده بودند، اما پیچیدگی الگوریتمی خطی $O(N)$ آن‌ها مانع از کارکرد پایدار در گیت‌وی‌های پرترافیک می‌شد:

  1. پیمایش خطی در هر تکرار حلقه رویداد: سامانه‌های select() و poll() در هر فراخوانی، آرایه توصیف‌گرها را از فضای کاربر به فضای هسته کپی می‌کنند. هسته سیستم‌عامل ناچار است تک‌تک سوکت‌ها را بررسی کند، حتی اگر از میان ۵۰٬۰۰۰ اتصال تنها ۵ سوکت دارای بسته‌های آماده خواندن باشند. با افزایش شمار کاربران، پردازنده بخش عمده سیکل‌های پردازشی خود را صرف پیمایش سوکت‌های غیرفعال می‌کند.
  2. تخصیص مکرر حافظه و کپی داده‌ها میان لایه‌ها: در هر چرخه اجرای select() یا poll()، تخصیص دوباره حافظه و ارسال بیت‌ماسک‌های حجیم در مرز فضای کاربر و هسته انجام می‌گیرد. برای گیت‌وی با ۱۰۰٬۰۰۰ اتصال فعال، جابه‌جایی مگابایت‌ها متادیتا برای چندین بار در ثانیه پهنای باند حافظه را اشغال کرده و حافظه نهان پردازنده (CPU Cache) را مختل می‌سازد.
  3. سقف ثابت توصیف‌گرهای فایل: استاندارد POSIX محدودیتی با نام FD_SETSIZE (معمولاً ۱۰۲۴ توصیف‌گر) برای select() تعیین کرده است که در معماری‌های قدیمی باعث ایجاد فرآیندهای متعدد و ناکارآمد تنها با هدف مدیریت توصیف‌گرها می‌شد.

برای هدایت ترافیک انبوه با بازدهی قطعی $O(1)$، سیستم‌عامل لینوکس زیرسیستم تخصصی event poll به نام epoll را معرفی نمود.

معماری داخلی زیرسیستم Linux epoll

زیرسیستم epoll که در لینوکس ۲.۶ معرفی شد، گلوگاه مقیاس‌پذیری را با تفکیک عملیات نظارت به دو فاز مجزا برطرف کرد: ثبت وضعیت و دریافت رویدادها. به جای ارسال کل فهرست توصیف‌گرها در هر چرخه، هسته یک درخت وضعیت دائمی را در حافظه خود نگهداری می‌کند.

+-------------------------------------------------------------------------+
|                    حلقه رویداد VPN در فضای کاربر (Event Loop)           |
+-------------------------------------------------------------------------+
       |                                                    ^
       | epoll_ctl(EPOLL_CTL_ADD / MOD / DEL)               | epoll_wait()
       v                                                    |
+-------------------------------------------------------------------------+
|                           بافت کرنل در epoll                            |
|                                                                         |
|   +-----------------------------------------------------------------+   |
|   |                درخت قرمز-سیاه پایش وضعیت (rbr)                  |   |
|   |         - درج، جستجو و حذف در زمان O(log N)                     |   |
|   |         - پایداری وضعیت سوکت‌ها در فواصل فراخوانی               |   |
|   +-----------------------------------------------------------------+   |
|                                    |                                    |
|                                    v [وقفه کارت شبکه / Wakeup]          |
|   +-----------------------------------------------------------------+   |
|   |                 فهرست پیوندی سوکت‌های آماده (rdllist)           |   |
|   |         - بازیابی سوکت‌های فعال با پیچیدگی O(1)                 |   |
|   +-----------------------------------------------------------------+   |
+-------------------------------------------------------------------------+
       |                                                    |
       v فریم شبکه DMA                                      v ارسال بسته‌ها
+-------------------------------------------------------------------------+
|                    کارت شبکه فیزیکی / ابزار مجازی TUN/TAP               |
+-------------------------------------------------------------------------+

سه فراخوان بنیادین سیستم

کارکرد این زیرسیستم بر سه فراخوان سیستمی متکی است:

  • epoll_create1(int flags): یک توصیف‌گر epoll در فضای هسته ایجاد می‌کند. استفاده از فلگ EPOLL_CLOEXEC تضمین می‌کند که با اجرای execve()، این توصیف‌گر به صورت خودکار بسته شود.
  • epoll_ctl(int epfd, int op, int fd, struct epoll_event *event): درخت قرمز-سیاه (rbr) نگهدارنده سوکت‌ها را تغییر می‌دهد. عملیات افزودن (EPOLL_CTL_ADD)، تغییر (EPOLL_CTL_MOD) و حذف (EPOLL_CTL_DEL) با زمان $O(\log N)$ بدون نیاز به کپی کامل داده‌ها از فضای کاربر انجام می‌شود.
  • epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout): نخ جاری را تا زمان رخداد رویداد روی یک یا چند سوکت متوقف می‌سازد. این تابع آرایه‌ای را بازمی‌گرداند که تنها شامل سوکت‌های فعال است و مقیاس‌پذیری واقعی $O(1)$ را بدون وابستگی به کل اتصالات فراهم می‌آورد.

مقایسه حالت‌های Level-Triggered و Edge-Triggered (EPOLLET)

به طور پیش‌فرض، epoll در حالت Level-Triggered (LT) عمل می‌کند. اما برای راه‌اندازی سرورهای VPN با توان پردازش بالا، درک تفاوت و مهاجرت به حالت Edge-Triggered (ET) از طریق فلگ EPOLLET اهمیت ساختاری دارد.

Level-Triggered (LT):
داده‌ها در بافر سوکت موجود هستند ──────────────────────────────────────────>
فراخوان epoll_wait() رویداد را برمی‌گرداند    [بله]    [بله]    [بله]   ...
(تا زمانی که بافر به ۰ بایت نرسد، پیوسته اعلان ارسال می‌شود)

Edge-Triggered (ET - EPOLLET):
ورود داده جدید (تغییر وضعیت: خالی -> دارای داده) ─────────────────────────>
فراخوان epoll_wait() رویداد را برمی‌گرداند    [بله]    [خیر]    [خیر]   ...
(تنها در لحظه تغییر وضعیت اعلان می‌شود؛ نخ پردازنده باید کل بافر را خالی کند)

چالش‌های Level-Triggered در سرورهای چندنخی

در حالت Level-Triggered، تابع epoll_wait() تا زمانی که داده در بافر باقی مانده باشد، مکرراً به برنامه اعلان می‌فرستد. چنانچه نخ پردازنده تنها بخشی از بسته رمزشده را بخواند، چرخه‌های بعدی باعث بیدار شدن مکرر نخ‌ها می‌شوند. در معماری‌های چندنخی، این امر منجر به تعویض زمینه شدید پردازنده و رقابت بر سر قفل‌ها می‌گردد.

هدایت حالت Edge-Triggered (EPOLLET)

هنگامی که فلگ EPOLLET تنظیم شود، هسته تنها در لحظه تغییر وضعیت آمادگی سوکت اعلان می‌فرستد (برای نمونه تبدیل از وضعیت غیرقابل خواندن به قابل خواندن). در سناریوهای ترافیک پرحجم، این رویکرد بیش از ۶۰ درصد از اعلان‌های هسته را کاهش می‌دهد.

استفاده از حالت Edge-Triggered مستلزم رعایت دو اصل است:

  1. الزام به سوکت‌های غیرمسدودکننده (Non-Blocking): کلیه توصیف‌گرهای تحت نظر باید با فلگ O_NONBLOCK از طریق fcntl() تنظیم شوند. اگر نخی در یک فراخوان همگام recv() مسدود شود، تمام حلقه پردازش رویدادها متوقف خواهد شد.
  2. خواندن مکرر در حلقه تا خطای EAGAIN یا EWOULDBLOCK: از آنجا که هسته تا رسیدن بسته‌های جدید اعلان دیگری نمی‌فرستد، نخ پردازشی باید تابع خواندن را تا زمان دریافت کد EAGAIN که نشان‌دهنده خالی شدن کامل بافر سخت‌افزاری است، ادامه دهد.
// الگوی خواندن داده در حالت Edge-Triggered به زبان C
while (1) {
    ssize_t count = recv(client_fd, buffer, sizeof(buffer), 0);
    if (count == -1) {
        if (errno == EAGAIN || errno == EWOULDBLOCK) {
            // بافر کاملاً تخلیه شد، بازگشت ایمن به epoll_wait
            break;
        }
        // مدیریت خطای پیش‌بینی نشده در ارتباط
        close(client_fd);
        break;
    } else if (count == 0) {
        // نشست توسط کلاینت بسته شد
        close(client_fd);
        break;
    }
    // پردازش بسته شبکه رمزنگاری‌شده
    process_vpn_packet(buffer, count);
}

مدیریت همروندی چندنخی با فلگ EPOLLONESHOT

در گیت‌وی‌های مدرن VPN، معمولاً استخری از نخ‌های پردازشی به طور مشترک یک توصیف‌گر epoll را کنترل می‌کنند. این طرح با دو مشکل اساسی روبرو می‌شود: چالش thundering herd و شرایط رقابت بین نخ‌ها (Race Conditions).

خطرات شرایط رقابت میان نخ‌ها

فرض کنید نخ A و نخ B روی توصیف‌گر مشترک منتظر فراخوان epoll_wait() باشند. بسته‌های UDP به سوکت ۴ می‌رسند. نخ A بیدار شده و خواندن را آغاز می‌کند. در حالی که نخ A مشغول رمزگشایی است، بسته جدیدی روی سوکت ۴ دریافت می‌شود. بدون تفکیک، هسته نخ B را نیز برای همان سوکت بیدار می‌کند.

در نتیجه، نخ A و نخ B همزمان از سوکت ۴ داده می‌خوانند که به برهم خوردن ترتیب رمزگشایی، تخریب پنجره مقابله با بازپخش (Replay Window) و آسیب به وضعیت نشست منجر می‌شود.

راهکار: EPOLLONESHOT

برای رفع این شرایط رقابت بدون به‌کارگیری قفل‌های سنگین در سطح نرم‌افزار، لینوکس فلگ EPOLLONESHOT را فراهم کرده است.

۱. epoll_ctl(epfd, EPOLL_CTL_ADD, sock_fd, EPOLLIN | EPOLLET | EPOLLONESHOT)
   - سوکت با محافظت اعلان یک‌باره ثبت می‌شود.

۲. بسته‌ها می‌رسند -> نخ A از طریق epoll_wait() بیدار می‌شود.
   - هسته خودکار اعلان‌های بعدی برای sock_fd را غیرفعال می‌سازد.

۳. بسته‌های جدید حین پردازش نخ A می‌رسند.
   - نخ B در خواب باقی می‌ماند؛ sock_fd نخ دیگری را بیدار نخواهد کرد.

۴. نخ A عملیات رمزگشایی و تخلیه سوکت را به پایان می‌رساند.
   - نخ A فراخوانی می‌کند: epoll_ctl(epfd, EPOLL_CTL_MOD, sock_fd, ... | EPOLLONESHOT)
   - سوکت مجدداً برای رویدادهای آینده فعال می‌گردد.

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

مقیاس‌پذیری تمرکزدهنده‌های چندنخی RiceGuard

در معماری توزیع‌شده VPNRice، موتور اختصاصی RiceGuard مفاهیم EPOLLET و EPOLLONESHOT را با شتاب‌دهنده‌های بسته‌ای در سطح هسته لینوکس ادغام کرده تا مقیاس‌پذیری غیرمسدودکننده را در خوشه‌های گیت‌وی پرترافیک محقق سازد.

+-------------------------------------------------------------------+
|                     گیت‌وی عملیاتی شبکه VPNRice                   |
|                                                                   |
|   +-----------------------------------------------------------+   |
|   |         بیش از ۱۰۰٬۰۰۰ نشست همزمان کلاینت (UDP / TCP)         |   |
|   +-----------------------------------------------------------+   |
|                                 |                                 |
|                                 v                                 |
|   +-----------------------------------------------------------+   |
|   |            توزیع‌کننده اصلی epoll (ساختار درختی rbr)          |   |
|   |            پیکربندی همزمان EPOLLET و EPOLLONESHOT             |   |
|   +-----------------------------------------------------------+   |
|         |                      |                      |           |
|         v                      v                      v           |
|  +-------------+        +-------------+        +-------------+    |
|  | نخ پردازشی ۰ |        | نخ پردازشی ۱ |        | نخ پردازشی ۲ |    |
|  | هسته پردازنده|        | هسته پردازنده|        | هسته پردازنده|    |
|  | رمزگشایی    |        | رمزگشایی    |        | رمزگشایی    |    |
|  | ChaCha20    |        | ChaCha20    |        | ChaCha20    |    |
|  +-------------+        +-------------+        +-------------+    |
|         |                      |                      |           |
|         v فعال‌سازی دوباره     v فعال‌سازی دوباره     v فعال‌سازی |
|   epoll_ctl(MOD)         epoll_ctl(MOD)         epoll_ctl(MOD)    |
+-------------------------------------------------------------------+

۱. توزیع نشست‌ها بدون قفل سراسری

پلتفرم RiceGuard جریان داده‌های کلاینت‌ها را به نخ‌های پردازشی اختصاصی متصل به هسته‌های مجزا هدایت می‌کند. با نگهداری بافت اتصالات در نمونه‌های مستقل epoll یا مدیریت مشترک از طریق EPOLLONESHOT، نخ‌های محاسباتی نیازی به قفل‌های سراسری اسپین‌لاک ندارند. مصافحه اولیه، مانیتورینگ ارتباط و رمزگشایی بسته‌ها با کمترین میزان تأخیر انجام می‌شوند.

۲. بهره‌وری دسته‌ای با recvmmsg

به منظور بهینه‌سازی فراخوان‌های سیستمی در لحظات افزایش ترافیک، RiceGuard اعلان‌های Edge-Triggered را با فراخوان recvmmsg() تلفیق می‌کند. با اعلان آمادگی از سوی epoll_wait()، نخ کاری تا ۶۴ دیتاگرام را با یک تعویض زمینه دریافت کرده و بردارها را جهت رمزگشایی ChaCha20-Poly1305 مستقیماً به دستورالعمل‌های برداری SIMD ارسال می‌کند.

۳. ایزوله‌سازی فضاهای نام شبکه

تمامی بخش‌ها در شبکه RiceGuard درون فضاهای نام مجزای شبکه لینوکس (Network Namespace) فعالیت می‌کنند. مسیریابی رویدادهای epoll منحصراً در محدوده ایزوله انجام می‌شود که از نشت بسته‌های رمزنگاری‌نشده به رابط‌های فیزیکی عمومی جلوگیری می‌کند. در کنار پروکسی‌های رمزنگاری‌شده DNS، این طراحی رویدادمحور پایداری اتصال و حریم خصوصی داده‌ها را تضمین می‌نماید.