در این مقاله، ما به جزئیات یک راکتور ورودی/خروجی و نحوه کار آن نگاه میکنیم، پیادهسازی را در کمتر از 200 خط کد مینویسیم و یک سرور HTTP ساده را بیش از 40 میلیون درخواست در دقیقه پردازش میکنیم.
پیش گفتار
این مقاله برای کمک به درک عملکرد راکتور ورودی/خروجی و در نتیجه درک خطرات هنگام استفاده از آن نوشته شده است.
دانستن اصول اولیه برای درک مقاله لازم است. زبان C و کمی تجربه در توسعه برنامه های کاربردی شبکه.
تمام کدها به زبان C نوشته شده است و دقیقاً مطابق با (احتیاط: PDF طولانی) به استاندارد C11 برای لینوکس و در دسترس است GitHub.
چرا آن را انجام؟
با محبوبیت روزافزون اینترنت، سرورهای وب شروع به رسیدگی به تعداد زیادی اتصال به طور همزمان کردند و بنابراین دو رویکرد امتحان شد: مسدود کردن I/O در تعداد زیادی از رشتههای سیستم عامل و عدم مسدود کردن I/O در ترکیب با یک سیستم اعلان رویداد که به آن «انتخابگر سیستم» نیز گفته می شود (epoll/صف/IOCP/و غیره).
اولین رویکرد شامل ایجاد یک رشته سیستم عامل جدید برای هر اتصال ورودی بود. نقطه ضعف آن مقیاس پذیری ضعیف است: سیستم عامل باید بسیاری را پیاده سازی کند انتقال زمینه и تماس های سیستمی. آنها عملیات گران قیمت هستند و می توانند منجر به کمبود RAM رایگان با تعداد قابل توجه اتصالات شوند.
نسخه اصلاح شده برجسته است تعداد ثابت رشته ها (Thread Pool)، در نتیجه از توقف اجرای سیستم جلوگیری می کند، اما در عین حال مشکل جدیدی را ایجاد می کند: اگر یک Thread Pool در حال حاضر توسط عملیات خواندن طولانی مسدود شده باشد، دیگر سوکت هایی که قبلاً قادر به دریافت داده هستند، نمی توانند انجام دهید.
رویکرد دوم استفاده می کند سیستم اطلاع رسانی رویداد (انتخاب کننده سیستم) ارائه شده توسط سیستم عامل. این مقاله رایج ترین نوع انتخابگر سیستم را مورد بحث قرار می دهد، بر اساس هشدارها (رویدادها، اعلان ها) در مورد آمادگی برای عملیات I/O، نه بر اساس اطلاعیه در مورد تکمیل آنها. یک مثال ساده از استفاده از آن را می توان با بلوک دیاگرام زیر نشان داد:
تفاوت این رویکردها به شرح زیر است:
مسدود کردن عملیات I/O تعلیق کند جریان کاربر تا زمانتا زمانی که سیستم عامل به درستی انجام شود یکپارچه سازی ورودی بسته های IP به جریان بایت (TCP، دریافت داده ها) یا فضای کافی در بافرهای نوشتن داخلی برای ارسال بعدی از طریق وجود نخواهد داشت. NIC (ارسال داده ها).
انتخابگر سیستم در طول زمان به برنامه اطلاع می دهد که سیستم عامل قبلا بسته های IP یکپارچه شده (TCP، دریافت داده) یا فضای کافی در بافرهای نوشتن داخلی قبلا موجود (ارسال داده ها).
به طور خلاصه، رزرو یک رشته سیستم عامل برای هر ورودی/خروجی اتلاف قدرت محاسباتی است، زیرا در واقعیت، رشته ها کار مفیدی انجام نمی دهند (از این رو اصطلاح "وقفه نرم افزاری"). انتخابگر سیستم این مشکل را حل می کند و به برنامه کاربر اجازه می دهد تا از منابع CPU بسیار اقتصادی تر استفاده کند.
مدل راکتور ورودی/خروجی
راکتور I/O به عنوان یک لایه بین انتخابگر سیستم و کد کاربر عمل می کند. اصل عملکرد آن توسط بلوک دیاگرام زیر توضیح داده شده است:
اجازه دهید به شما یادآوری کنم که یک رویداد اعلانی است مبنی بر اینکه یک سوکت خاص قادر است یک عملیات I/O غیر مسدود کننده را انجام دهد.
کنترل کننده رویداد تابعی است که توسط راکتور ورودی/خروجی هنگام دریافت رویداد فراخوانی میشود و سپس یک عملیات ورودی/خروجی غیرمسدود را انجام میدهد.
توجه به این نکته مهم است که راکتور ورودی/خروجی بنا به تعریف تک رشته ای است، اما هیچ چیز مانع استفاده از این مفهوم در یک محیط چند رشته ای با نسبت 1 رشته به راکتور 1 نمی شود و بدین ترتیب تمام هسته های CPU بازیافت می شوند.
اجرا
رابط عمومی را در یک فایل قرار می دهیم reactor.h، و پیاده سازی - در reactor.c. reactor.h شامل اطلاعیه های زیر خواهد بود:
نمایش اعلامیه ها در reactor.h
typedef struct reactor Reactor;
/*
* Указатель на функцию, которая будет вызываться I/O реактором при поступлении
* события от системного селектора.
*/
typedef void (*Callback)(void *arg, int fd, uint32_t events);
/*
* Возвращает `NULL` в случае ошибки, не-`NULL` указатель на `Reactor` в
* противном случае.
*/
Reactor *reactor_new(void);
/*
* Освобождает системный селектор, все зарегистрированные сокеты в данный момент
* времени и сам I/O реактор.
*
* Следующие функции возвращают -1 в случае ошибки, 0 в случае успеха.
*/
int reactor_destroy(Reactor *reactor);
int reactor_register(const Reactor *reactor, int fd, uint32_t interest,
Callback callback, void *callback_arg);
int reactor_deregister(const Reactor *reactor, int fd);
int reactor_reregister(const Reactor *reactor, int fd, uint32_t interest,
Callback callback, void *callback_arg);
/*
* Запускает цикл событий с тайм-аутом `timeout`.
*
* Эта функция передаст управление вызывающему коду если отведённое время вышло
* или/и при отсутствии зарегистрированных сокетов.
*/
int reactor_run(const Reactor *reactor, time_t timeout);
ساختار راکتور ورودی/خروجی شامل توصیف کننده فایل انتخابگر epoll и جداول هشGHashTable، که هر سوکت را به آن نگاشت می کند CallbackData (ساختار کنترل کننده رویداد و آرگومان کاربر برای آن).
لطفا توجه داشته باشید که ما قابلیت رسیدگی را فعال کرده ایم نوع ناقص با توجه به شاخص که در reactor.h ما ساختار را اعلام می کنیم reactor، و در reactor.c ما آن را تعریف می کنیم، بنابراین کاربر را از تغییر صریح فیلدهای خود جلوگیری می کنیم. این یکی از الگوهاست پنهان کردن داده ها، که به طور خلاصه در معنای C قرار می گیرد.
توابع reactor_register, reactor_deregister и reactor_reregister لیست سوکت های مورد علاقه و کنترل کننده رویداد مربوطه را در انتخابگر سیستم و جدول هش به روز کنید.
پس از اینکه راکتور I/O رویداد را با توصیفگر رهگیری کرد fd، کنترل کننده رویداد مربوطه را فراخوانی می کند که به آن ارسال می کند fd, ماسک بیت رویدادهای تولید شده و یک اشاره گر کاربر به void.
نمایش تابع ()reactor_run
int reactor_run(const Reactor *reactor, time_t timeout) {
int result;
struct epoll_event *events;
if ((events = calloc(MAX_EVENTS, sizeof(*events))) == NULL)
abort();
time_t start = time(NULL);
while (true) {
time_t passed = time(NULL) - start;
int nfds =
epoll_wait(reactor->epoll_fd, events, MAX_EVENTS, timeout - passed);
switch (nfds) {
// Ошибка
case -1:
perror("epoll_wait");
result = -1;
goto cleanup;
// Время вышло
case 0:
result = 0;
goto cleanup;
// Успешная операция
default:
// Вызвать обработчиков событий
for (int i = 0; i < nfds; i++) {
int fd = events[i].data.fd;
CallbackData *callback =
g_hash_table_lookup(reactor->table, &fd);
callback->callback(callback->arg, fd, events[i].events);
}
}
}
cleanup:
free(events);
return result;
}
به طور خلاصه، زنجیره فراخوانی تابع در کد کاربر به شکل زیر خواهد بود:
سرور تک رشته ای
برای آزمایش راکتور I/O تحت بار زیاد، یک وب سرور HTTP ساده می نویسیم که به هر درخواستی با یک تصویر پاسخ می دهد.
ارجاع سریع به پروتکل HTTP
HTTP - این پروتکل است سطح برنامه، در درجه اول برای تعامل سرور و مرورگر استفاده می شود.
HTTP به راحتی قابل استفاده است حمل و نقل پروتکل TCP، ارسال و دریافت پیام در قالب مشخص شده است مشخصات.
CRLF دنباله ای از دو شخصیت است: r и n، خط اول درخواست، سرصفحه ها و داده ها را از هم جدا می کند.
<КОМАНДА> - یکی از CONNECT, DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT, TRACE. مرورگر دستوری را به سرور ما ارسال می کند GET، به معنی "محتوای فایل را برای من ارسال کنید."
<URI> - تعیین کننده هویت منابع یکشکل. به عنوان مثال، اگر URI = /index.html، سپس مشتری صفحه اصلی سایت را درخواست می کند.
<ВЕРСИЯ HTTP> - نسخه پروتکل HTTP در قالب HTTP/X.Y. متداول ترین نسخه امروزی است HTTP/1.1.
<ЗАГОЛОВОК N> یک جفت کلید-مقدار در قالب است <КЛЮЧ>: <ЗНАЧЕНИЕ>، برای تجزیه و تحلیل بیشتر به سرور ارسال می شود.
<ДАННЫЕ> - داده های مورد نیاز سرور برای انجام عملیات. اغلب ساده است JSON یا هر قالب دیگری
<КОД СТАТУСА> عددی است که نشان دهنده نتیجه عملیات است. سرور ما همیشه وضعیت 200 (عملیات موفقیت آمیز) را برمی گرداند.
<ОПИСАНИЕ СТАТУСА> - نمایش رشته ای از کد وضعیت. برای کد وضعیت 200 این است OK.
<ЗАГОЛОВОК N> - هدر با همان فرمت در درخواست. عناوین را برمی گردانیم Content-Length (اندازه فایل) و Content-Type: text/html (نوع داده برگشتی).
<ДАННЫЕ> - داده های درخواست شده توسط کاربر در مورد ما، این مسیر به تصویر در است HTML.
پرونده http_server.c (سرور تک رشته ای) شامل فایل است common.h، که شامل نمونه های اولیه تابع زیر است:
نمایش نمونه های اولیه تابع در common.h
/*
* Обработчик событий, который вызовется после того, как сокет будет
* готов принять новое соединение.
*/
static void on_accept(void *arg, int fd, uint32_t events);
/*
* Обработчик событий, который вызовется после того, как сокет будет
* готов отправить HTTP ответ.
*/
static void on_send(void *arg, int fd, uint32_t events);
/*
* Обработчик событий, который вызовется после того, как сокет будет
* готов принять часть HTTP запроса.
*/
static void on_recv(void *arg, int fd, uint32_t events);
/*
* Переводит входящее соединение в неблокирующий режим.
*/
static void set_nonblocking(int fd);
/*
* Печатает переданные аргументы в stderr и выходит из процесса с
* кодом `EXIT_FAILURE`.
*/
static noreturn void fail(const char *format, ...);
/*
* Возвращает файловый дескриптор сокета, способного принимать новые
* TCP соединения.
*/
static int new_server(bool reuse_port);
ماکرو عملکردی نیز توضیح داده شده است SAFE_CALL() و تابع تعریف شده است fail(). ماکرو مقدار عبارت را با خطا مقایسه می کند و اگر شرط درست باشد، تابع را فراخوانی می کند fail():
#define SAFE_CALL(call, error)
do {
if ((call) == error) {
fail("%s", #call);
}
} while (false)
تابع fail() آرگومان های ارسال شده را در ترمینال چاپ می کند (مانند printf()) و برنامه را با کد خاتمه می دهد EXIT_FAILURE:
تابع new_server() توصیفگر فایل سوکت "سرور" ایجاد شده توسط فراخوانی سیستم را برمی گرداند socket(), bind() и listen() و قابلیت پذیرش اتصالات ورودی در حالت غیر مسدود کننده را دارد.
توجه داشته باشید که سوکت در ابتدا در حالت غیر مسدود با استفاده از پرچم ایجاد می شود SOCK_NONBLOCKبه طوری که در تابع on_accept() (ادامه مطلب) تماس سیستمی accept() اجرای thread را متوقف نکرد.
اگر reuse_port برابر است true، سپس این تابع سوکت را با گزینه پیکربندی می کند SO_REUSEPORT از طریق setsockopt()برای استفاده از همان پورت در یک محیط چند رشته ای (به بخش "سرور چند رشته ای" مراجعه کنید).
مدیریت رویداد on_accept() پس از ایجاد یک رویداد توسط سیستم عامل فراخوانی می شود EPOLLIN، در این حالت به این معنی است که اتصال جدید را می توان پذیرفت. on_accept() اتصال جدید را می پذیرد، آن را به حالت غیر مسدود می کند و با یک کنترل کننده رویداد ثبت می کند on_recv() در یک راکتور I/O
مدیریت رویداد on_recv() پس از ایجاد یک رویداد توسط سیستم عامل فراخوانی می شود EPOLLIN، در این مورد به این معنی است که اتصال ثبت شده است on_accept()، آماده دریافت داده است.
on_recv() داده ها را از اتصال می خواند تا زمانی که درخواست HTTP به طور کامل دریافت شود، سپس یک handler را ثبت می کند on_send() برای ارسال پاسخ HTTP. اگر مشتری اتصال را قطع کند، سوکت از ثبت خارج شده و با استفاده از آن بسته می شود close().
نمایش تابع on_recv()
static void on_recv(void *arg, int fd, uint32_t events) {
RequestBuffer *buffer = arg;
// Принимаем входные данные до тех пор, что recv возвратит 0 или ошибку
ssize_t nread;
while ((nread = recv(fd, buffer->data + buffer->size,
REQUEST_BUFFER_CAPACITY - buffer->size, 0)) > 0)
buffer->size += nread;
// Клиент оборвал соединение
if (nread == 0) {
SAFE_CALL(reactor_deregister(reactor, fd), -1);
SAFE_CALL(close(fd), -1);
request_buffer_destroy(buffer);
return;
}
// read вернул ошибку, отличную от ошибки, при которой вызов заблокирует
// поток
if (errno != EAGAIN && errno != EWOULDBLOCK) {
request_buffer_destroy(buffer);
fail("read");
}
// Получен полный HTTP запрос от клиента. Теперь регистрируем обработчика
// событий для отправки данных
if (request_buffer_is_complete(buffer)) {
request_buffer_clear(buffer);
SAFE_CALL(reactor_reregister(reactor, fd, EPOLLOUT, on_send, buffer),
-1);
}
}
مدیریت رویداد on_send() پس از ایجاد یک رویداد توسط سیستم عامل فراخوانی می شود EPOLLOUT، به این معنی که اتصال ثبت شده است on_recv()، آماده ارسال داده است. این تابع یک پاسخ HTTP حاوی HTML با یک تصویر را برای مشتری ارسال می کند و سپس کنترل کننده رویداد را به آن تغییر می دهد. on_recv().
و در نهایت در فایل http_server.c، در عمل main() ما با استفاده از یک راکتور I/O ایجاد می کنیم reactor_new()، یک سوکت سرور ایجاد کنید و آن را ثبت کنید، راکتور را با استفاده از آن راه اندازی کنید reactor_run() دقیقاً یک دقیقه، و سپس منابع را آزاد کرده و از برنامه خارج می شویم.
بیایید بررسی کنیم که همه چیز همانطور که انتظار می رود کار می کند. تدوین (chmod a+x compile.sh && ./compile.sh در ریشه پروژه) و سرور خودنویس را راه اندازی کنید، باز کنید http://127.0.0.1:18470 در مرورگر و ببینید چه انتظاری داشتیم:
بیایید عملکرد یک سرور تک رشته ای را اندازه گیری کنیم. بیایید دو ترمینال را باز کنیم: در یکی اجرا خواهیم کرد ./http_server، در یک متفاوت - خنده. پس از یک دقیقه، آمار زیر در ترمینال دوم نمایش داده می شود:
سرور تک رشته ما قادر به پردازش بیش از 11 میلیون درخواست در دقیقه از 100 اتصال بود. نتیجه بدی نیست، اما آیا می توان آن را بهبود بخشید؟
سرور چند رشته ای
همانطور که در بالا ذکر شد، راکتور I/O را می توان در رشته های جداگانه ایجاد کرد و در نتیجه از تمام هسته های CPU استفاده کرد. بیایید این رویکرد را عملی کنیم:
لطفاً توجه داشته باشید که آرگومان تابع new_server() طرفداران true. یعنی ما گزینه را به سوکت سرور اختصاص می دهیم SO_REUSEPORTبرای استفاده از آن در یک محیط چند رشته ای. می توانید جزئیات بیشتر را بخوانید اینجا.
اجرای دوم
حالا بیایید عملکرد یک سرور چند رشته ای را اندازه گیری کنیم:
تعداد درخواست های پردازش شده در 1 دقیقه تا 3.28 برابر افزایش یافت! اما ما فقط XNUMX میلیون از عدد دور کم داشتیم، بنابراین بیایید سعی کنیم آن را برطرف کنیم.
استفاده از CPU Affinity، تالیف با -march=native, PGO، افزایش تعداد بازدیدها حافظه پنهان، افزایش دادن MAX_EVENTS و استفاده کنید EPOLLET افزایش قابل توجهی در عملکرد ایجاد نکرد. اما اگر تعداد اتصالات همزمان را افزایش دهید چه اتفاقی می افتد؟
نتیجه مورد نظر به دست آمد و با آن یک نمودار جالب نشان می دهد که وابستگی تعداد درخواست های پردازش شده در 1 دقیقه به تعداد اتصالات را نشان می دهد:
می بینیم که پس از چند صد اتصال، تعداد درخواست های پردازش شده برای هر دو سرور به شدت کاهش می یابد (در نسخه چند رشته ای این بیشتر قابل توجه است). آیا این مربوط به اجرای پشته TCP/IP لینوکس است؟ با خیال راحت می توانید فرضیات خود را در مورد این رفتار نمودار و بهینه سازی گزینه های چند رشته ای و تک رشته ای در نظرات بنویسید.
مانند اشاره شد در نظرات، این تست عملکرد رفتار راکتور I/O را تحت بارهای واقعی نشان نمی دهد، زیرا تقریباً همیشه سرور با پایگاه داده تعامل دارد، گزارش ها را خروجی می کند، از رمزنگاری با استفاده می کند. TLS و غیره که در نتیجه بار غیر یکنواخت (دینامیک) می شود. آزمایشات همراه با مؤلفه های شخص ثالث در مقاله مربوط به پرواکتور I/O انجام می شود.
معایب راکتور ورودی/خروجی
باید بدانید که راکتور I/O بدون اشکال نیست، یعنی:
استفاده از یک راکتور I/O در یک محیط چند رشته ای تا حدودی دشوارتر است، زیرا شما باید به صورت دستی جریان ها را مدیریت کنید.
تمرین نشان می دهد که در بیشتر موارد بار غیریکنواخت است، که می تواند منجر به ورود یک رشته شود در حالی که دیگری مشغول کار است.
اگر یک کنترل کننده رویداد یک رشته را مسدود کند، خود انتخابگر سیستم نیز مسدود می شود، که می تواند منجر به باگ های سخت شود.
این مشکلات را حل می کند پرواکتور ورودی/خروجی، که اغلب دارای یک زمانبندی است که بار را به طور مساوی به مجموعه ای از نخ ها توزیع می کند و همچنین دارای یک API راحت تر است. بعداً در مقاله دیگرم در مورد آن صحبت خواهیم کرد.
نتیجه
اینجاست که سفر ما از تئوری مستقیم به اگزوز پروفیلر به پایان رسیده است.
شما نباید روی این موضوع تمرکز کنید، زیرا بسیاری از رویکردهای به همان اندازه جالب دیگر برای نوشتن نرم افزار شبکه با سطوح مختلف راحتی و سرعت وجود دارد. جالب است، به نظر من، لینک های زیر داده شده است.