مفاعل الإدخال/الإخراج العاري كامل المواصفات

مفاعل الإدخال/الإخراج العاري كامل المواصفات

مقدمة

مفاعل الإدخال/الإخراج (خيط واحد حلقة الحدث) هو نمط لكتابة برامج عالية التحميل، يُستخدم في العديد من الحلول الشائعة:

في هذه المقالة، سنلقي نظرة على خصوصيات وعموميات مفاعل الإدخال/الإخراج وكيفية عمله، وكتابة التنفيذ في أقل من 200 سطر من التعليمات البرمجية، وإجراء عملية خادم HTTP بسيطة تزيد عن 40 مليون طلب/دقيقة.

مقدمة

  • تمت كتابة المقالة للمساعدة في فهم عمل مفاعل الإدخال/الإخراج، وبالتالي فهم المخاطر عند استخدامه.
  • مطلوب معرفة الأساسيات لفهم المقال. لغة سي وبعض الخبرة في تطوير تطبيقات الشبكة.
  • تتم كتابة جميع التعليمات البرمجية بلغة C بدقة وفقًا لـ (تحذير: ملف PDF طويل) لمعيار C11 لنظام التشغيل Linux ومتاح على GitHub جيثب:.

لماذا نفعل ذلك؟

مع تزايد شعبية الإنترنت، بدأت خوادم الويب في الحاجة إلى التعامل مع عدد كبير من الاتصالات في وقت واحد، وبالتالي تم تجربة طريقتين: حظر الإدخال/الإخراج على عدد كبير من سلاسل عمليات نظام التشغيل وعدم حظر الإدخال/الإخراج بالاشتراك مع نظام إعلام بالحدث، ويسمى أيضًا "محدد النظام" (إبول/com.kqueue/IOCP/إلخ).

يتضمن الأسلوب الأول إنشاء مؤشر ترابط نظام تشغيل جديد لكل اتصال وارد. عيبه هو ضعف قابلية التوسع: سيتعين على نظام التشغيل تنفيذ الكثير انتقالات السياق и مكالمات النظام. إنها عمليات باهظة الثمن ويمكن أن تؤدي إلى نقص ذاكرة الوصول العشوائي المجانية مع عدد هائل من الاتصالات.

يسلط الضوء على النسخة المعدلة عدد محدد من المواضيع (تجمع مؤشرات الترابط)، وبالتالي منع النظام من إحباط التنفيذ، ولكن في نفس الوقت يقدم مشكلة جديدة: إذا تم حظر تجمع مؤشرات الترابط حاليًا من خلال عمليات القراءة الطويلة، فلن تتمكن المقابس الأخرى القادرة بالفعل على تلقي البيانات من ذلك القيام بذلك.

يستخدم النهج الثاني نظام إعلام الحدث (محدد النظام) الذي يوفره نظام التشغيل. تتناول هذه المقالة النوع الأكثر شيوعًا لمحدد النظام، استنادًا إلى التنبيهات (الأحداث والإشعارات) حول الاستعداد لعمليات الإدخال/الإخراج، وليس على إخطارات حول الانتهاء منها. يمكن تمثيل مثال مبسط لاستخدامه من خلال الرسم التخطيطي التالي:

مفاعل الإدخال/الإخراج العاري كامل المواصفات

والفرق بين هذه الأساليب هو كما يلي:

  • منع عمليات الإدخال/الإخراج تعليق تدفق المستخدم حتىحتى يتم تشغيل نظام التشغيل بشكل صحيح إلغاء التجزئة واردة حزم IP إلى دفق البايت (TCPأو تلقي البيانات) أو لن تتوفر مساحة كافية في مخازن الكتابة المؤقتة الداخلية للإرسال اللاحق عبرها NIC (إرسال البيانات).
  • محدد النظام متأخر , بعد فوات الوقت يخطر البرنامج أن نظام التشغيل قد حزم IP التي تم إلغاء تجزئتها (TCP، استقبال البيانات) أو مساحة كافية في مخازن الكتابة المؤقتة الداخلية قد متاح (إرسال البيانات).

لتلخيص ذلك، فإن حجز خيط نظام التشغيل لكل إدخال/إخراج يعد إهدارًا لقوة الحوسبة، لأنه في الواقع، لا تقوم الخيوط بعمل مفيد (ومن هنا جاء المصطلح "مقاطعة البرمجيات"). يقوم محدد النظام بحل هذه المشكلة، مما يسمح لبرنامج المستخدم باستخدام موارد وحدة المعالجة المركزية بشكل اقتصادي أكثر.

نموذج مفاعل الإدخال/الإخراج

يعمل مفاعل الإدخال/الإخراج كطبقة بين محدد النظام ورمز المستخدم. يتم وصف مبدأ عملها من خلال الرسم التخطيطي التالي:

مفاعل الإدخال/الإخراج العاري كامل المواصفات

  • اسمحوا لي أن أذكرك أن الحدث هو إشعار بأن مأخذ توصيل معين قادر على إجراء عملية إدخال / إخراج غير محظورة.
  • معالج الحدث هو وظيفة يتم استدعاؤها بواسطة مفاعل الإدخال/الإخراج عند تلقي حدث ما، والذي يقوم بعد ذلك بتنفيذ عملية إدخال/إخراج غير محظورة.

من المهم ملاحظة أن مفاعل الإدخال/الإخراج هو بحكم التعريف خيط مفرد، ولكن لا يوجد ما يمنع استخدام المفهوم في بيئة متعددة الخيوط بنسبة خيط واحد: مفاعل واحد، وبالتالي إعادة تدوير جميع نوى وحدة المعالجة المركزية.

تطبيق

سنضع الواجهة العامة في ملف reactor.hوالتنفيذ - في reactor.c. reactor.h ستتكون من الإعلانات التالية:

إظهار الإعلانات في React.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);

يتكون هيكل مفاعل الإدخال/الإخراج من واصف الملف محدد إبول и جداول التجزئة GHashTable، الذي يعين كل مأخذ توصيل لـ CallbackData (بنية معالج الحدث ووسيطة المستخدم له).

إظهار المفاعل وبيانات رد الاتصال

struct reactor {
    int epoll_fd;
    GHashTable *table; // (int, CallbackData)
};

typedef struct {
    Callback callback;
    void *arg;
} CallbackData;

يرجى ملاحظة أننا قمنا بتمكين القدرة على التعامل نوع غير مكتمل وفقا للمؤشر. في reactor.h نعلن الهيكل reactorوفي reactor.c نقوم بتعريفه، وبالتالي نمنع المستخدم من تغيير حقوله بشكل صريح. هذا هو واحد من الأنماط إخفاء البيانات، والتي تتناسب بإيجاز مع دلالات لغة C.

وظائف reactor_register, reactor_deregister и reactor_reregister قم بتحديث قائمة المقابس محل الاهتمام ومعالجات الأحداث المقابلة في محدد النظام وجدول التجزئة.

إظهار وظائف التسجيل

#define REACTOR_CTL(reactor, op, fd, interest)                                 
    if (epoll_ctl(reactor->epoll_fd, op, fd,                                   
                  &(struct epoll_event){.events = interest,                    
                                        .data = {.fd = fd}}) == -1) {          
        perror("epoll_ctl");                                                   
        return -1;                                                             
    }

int reactor_register(const Reactor *reactor, int fd, uint32_t interest,
                     Callback callback, void *callback_arg) {
    REACTOR_CTL(reactor, EPOLL_CTL_ADD, fd, interest)
    g_hash_table_insert(reactor->table, int_in_heap(fd),
                        callback_data_new(callback, callback_arg));
    return 0;
}

int reactor_deregister(const Reactor *reactor, int fd) {
    REACTOR_CTL(reactor, EPOLL_CTL_DEL, fd, 0)
    g_hash_table_remove(reactor->table, &fd);
    return 0;
}

int reactor_reregister(const Reactor *reactor, int fd, uint32_t interest,
                       Callback callback, void *callback_arg) {
    REACTOR_CTL(reactor, EPOLL_CTL_MOD, fd, interest)
    g_hash_table_insert(reactor->table, int_in_heap(fd),
                        callback_data_new(callback, callback_arg));
    return 0;
}

بعد أن اعترض مفاعل الإدخال/الإخراج الحدث باستخدام الواصف 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;
}

لتلخيص ذلك، فإن سلسلة استدعاءات الوظائف في كود المستخدم ستأخذ الشكل التالي:

مفاعل الإدخال/الإخراج العاري كامل المواصفات

خادم مترابطة واحدة

من أجل اختبار مفاعل الإدخال/الإخراج تحت التحميل العالي، سنكتب خادم ويب HTTP بسيطًا يستجيب لأي طلب بصورة.

إشارة سريعة إلى بروتوكول HTTP

HTTP - هذا هو البروتوكول مستوى التطبيق، يُستخدم بشكل أساسي للتفاعل بين الخادم والمتصفح.

يمكن استخدام HTTP بسهولة ينقل بروتوكول TCPوإرسال واستقبال الرسائل بالتنسيق المحدد تخصيص.

تنسيق الطلب

<КОМАНДА> <URI> <ВЕРСИЯ HTTP>CRLF
<ЗАГОЛОВОК 1>CRLF
<ЗАГОЛОВОК 2>CRLF
<ЗАГОЛОВОК N>CRLF CRLF
<ДАННЫЕ>

  • 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 أو أي تنسيق آخر.

تنسيق الاستجابة

<ВЕРСИЯ HTTP> <КОД СТАТУСА> <ОПИСАНИЕ СТАТУСА>CRLF
<ЗАГОЛОВОК 1>CRLF
<ЗАГОЛОВОК 2>CRLF
<ЗАГОЛОВОК N>CRLF CRLF
<ДАННЫЕ>

  • <КОД СТАТУСА> هو رقم يمثل نتيجة العملية. سيعود خادمنا دائمًا بالحالة 200 (عملية ناجحة).
  • <ОПИСАНИЕ СТАТУСА> - تمثيل سلسلة لرمز الحالة. بالنسبة لرمز الحالة 200 هذا هو OK.
  • <ЗАГОЛОВОК N> - رأس بنفس التنسيق كما في الطلب. سوف نعيد العناوين Content-Length (حجم الملف) و Content-Type: text/html (نوع بيانات الإرجاع).
  • <ДАННЫЕ> - البيانات التي يطلبها المستخدم. في حالتنا، هذا هو المسار إلى الصورة HTML.

ملف http_server.c (خادم مترابط واحد) يتضمن الملف 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:

static noreturn void fail(const char *format, ...) {
    va_list args;
    va_start(args, format);
    vfprintf(stderr, format, args);
    va_end(args);
    fprintf(stderr, ": %sn", strerror(errno));
    exit(EXIT_FAILURE);
}

وظيفة new_server() إرجاع واصف الملف لمقبس "الخادم" الذي تم إنشاؤه بواسطة مكالمات النظام socket(), bind() и listen() وقادر على قبول الاتصالات الواردة في وضع عدم الحظر.

إظهار وظيفة new_server()

static int new_server(bool reuse_port) {
    int fd;
    SAFE_CALL((fd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, IPPROTO_TCP)),
              -1);

    if (reuse_port) {
        SAFE_CALL(
            setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &(int){1}, sizeof(int)),
            -1);
    }

    struct sockaddr_in addr = {.sin_family = AF_INET,
                               .sin_port = htons(SERVER_PORT),
                               .sin_addr = {.s_addr = inet_addr(SERVER_IPV4)},
                               .sin_zero = {0}};

    SAFE_CALL(bind(fd, (struct sockaddr *)&addr, sizeof(addr)), -1);
    SAFE_CALL(listen(fd, SERVER_BACKLOG), -1);
    return fd;
}

  • لاحظ أنه يتم إنشاء مأخذ التوصيل في البداية في وضع عدم الحظر باستخدام العلامة SOCK_NONBLOCKبحيث في الوظيفة on_accept() (اقرأ المزيد) استدعاء النظام accept() لم يوقف تنفيذ الموضوع.
  • إذا reuse_port يساوي true، ستقوم هذه الوظيفة بتكوين المقبس مع الخيار SO_REUSEPORT من خلال setsockopt()لاستخدام نفس المنفذ في بيئة متعددة الخيوط (راجع قسم "خادم متعدد الخيوط").

معالج الأحداث on_accept() يتم استدعاؤه بعد قيام نظام التشغيل بإنشاء حدث EPOLLIN، وهذا يعني في هذه الحالة أنه يمكن قبول الاتصال الجديد. on_accept() يقبل اتصالاً جديدًا، ويحوله إلى وضع عدم الحظر، ويسجل باستخدام معالج الأحداث on_recv() في مفاعل الإدخال/الإخراج.

إظهار وظيفة on_accept()

static void on_accept(void *arg, int fd, uint32_t events) {
    int incoming_conn;
    SAFE_CALL((incoming_conn = accept(fd, NULL, NULL)), -1);
    set_nonblocking(incoming_conn);
    SAFE_CALL(reactor_register(reactor, incoming_conn, EPOLLIN, on_recv,
                               request_buffer_new()),
              -1);
}

معالج الأحداث on_recv() يتم استدعاؤه بعد قيام نظام التشغيل بإنشاء حدث EPOLLIN، في هذه الحالة يعني أن الاتصال مسجل on_accept()، جاهز لاستقبال البيانات.

on_recv() يقرأ البيانات من الاتصال حتى يتم تلقي طلب HTTP بالكامل، ثم يقوم بتسجيل المعالج 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().

إظهار وظيفة on_send()

static void on_send(void *arg, int fd, uint32_t events) {
    const char *content = "<img "
                          "src="https://habrastorage.org/webt/oh/wl/23/"
                          "ohwl23va3b-dioerobq_mbx4xaw.jpeg">";
    char response[1024];
    sprintf(response,
            "HTTP/1.1 200 OK" CRLF "Content-Length: %zd" CRLF "Content-Type: "
            "text/html" DOUBLE_CRLF "%s",
            strlen(content), content);

    SAFE_CALL(send(fd, response, strlen(response), 0), -1);
    SAFE_CALL(reactor_reregister(reactor, fd, EPOLLIN, on_recv, arg), -1);
}

وأخيرا، في الملف http_server.c، فى مهمة main() نقوم بإنشاء مفاعل الإدخال/الإخراج باستخدام reactor_new()، قم بإنشاء مقبس خادم وتسجيله، وابدأ استخدام المفاعل reactor_run() لمدة دقيقة واحدة بالضبط، ثم نقوم بتحرير الموارد والخروج من البرنامج.

إظهار http_server.c

#include "reactor.h"

static Reactor *reactor;

#include "common.h"

int main(void) {
    SAFE_CALL((reactor = reactor_new()), NULL);
    SAFE_CALL(
        reactor_register(reactor, new_server(false), EPOLLIN, on_accept, NULL),
        -1);
    SAFE_CALL(reactor_run(reactor, SERVER_TIMEOUT_MILLIS), -1);
    SAFE_CALL(reactor_destroy(reactor), -1);
}

دعونا نتحقق من أن كل شيء يعمل كما هو متوقع. تجميع (chmod a+x compile.sh && ./compile.sh في جذر المشروع) وقم بتشغيل الخادم المكتوب ذاتيًا، وافتحه http://127.0.0.1:18470 في المتصفح وشاهد ما توقعناه:

مفاعل الإدخال/الإخراج العاري كامل المواصفات

مقياس الاداء

عرض مواصفات سيارتي

$ screenfetch
 MMMMMMMMMMMMMMMMMMMMMMMMMmds+.        OS: Mint 19.1 tessa
 MMm----::-://////////////oymNMd+`     Kernel: x86_64 Linux 4.15.0-20-generic
 MMd      /++                -sNMd:    Uptime: 2h 34m
 MMNso/`  dMM    `.::-. .-::.` .hMN:   Packages: 2217
 ddddMMh  dMM   :hNMNMNhNMNMNh: `NMm   Shell: bash 4.4.20
     NMm  dMM  .NMN/-+MMM+-/NMN` dMM   Resolution: 1920x1080
     NMm  dMM  -MMm  `MMM   dMM. dMM   DE: Cinnamon 4.0.10
     NMm  dMM  -MMm  `MMM   dMM. dMM   WM: Muffin
     NMm  dMM  .mmd  `mmm   yMM. dMM   WM Theme: Mint-Y-Dark (Mint-Y)
     NMm  dMM`  ..`   ...   ydm. dMM   GTK Theme: Mint-Y [GTK2/3]
     hMM- +MMd/-------...-:sdds  dMM   Icon Theme: Mint-Y
     -NMm- :hNMNNNmdddddddddy/`  dMM   Font: Noto Sans 9
      -dMNs-``-::::-------.``    dMM   CPU: Intel Core i7-6700 @ 8x 4GHz [52.0°C]
       `/dMNmy+/:-------------:/yMMM   GPU: NV136
          ./ydNMMMMMMMMMMMMMMMMMMMMM   RAM: 2544MiB / 7926MiB
             .MMMMMMMMMMMMMMMMMMM

دعونا نقيس أداء الخادم ذو الخيط الواحد. لنفتح محطتين: سنعمل في إحداهما ./http_server، بشكل مختلف - WRK. بعد دقيقة، سيتم عرض الإحصائيات التالية في المحطة الثانية:

$ wrk -c100 -d1m -t8 http://127.0.0.1:18470 -H "Host: 127.0.0.1:18470" -H "Accept-Language: en-US,en;q=0.5" -H "Connection: keep-alive"
Running 1m test @ http://127.0.0.1:18470
  8 threads and 100 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency   493.52us   76.70us  17.31ms   89.57%
    Req/Sec    24.37k     1.81k   29.34k    68.13%
  11657769 requests in 1.00m, 1.60GB read
Requests/sec: 193974.70
Transfer/sec:     27.19MB

كان خادمنا المفرد قادرًا على معالجة أكثر من 11 مليون طلب في الدقيقة من 100 اتصال. ليست نتيجة سيئة، ولكن هل يمكن تحسينها؟

خادم متعدد الخيوط

كما ذكر أعلاه، يمكن إنشاء مفاعل الإدخال/الإخراج في سلاسل منفصلة، ​​وبالتالي الاستفادة من جميع مراكز وحدة المعالجة المركزية. دعونا نضع هذا النهج موضع التنفيذ:

إظهار http_server_multithreaded.c

#include "reactor.h"

static Reactor *reactor;
#pragma omp threadprivate(reactor)

#include "common.h"

int main(void) {
#pragma omp parallel
    {
        SAFE_CALL((reactor = reactor_new()), NULL);
        SAFE_CALL(reactor_register(reactor, new_server(true), EPOLLIN,
                                   on_accept, NULL),
                  -1);
        SAFE_CALL(reactor_run(reactor, SERVER_TIMEOUT_MILLIS), -1);
        SAFE_CALL(reactor_destroy(reactor), -1);
    }
}

الآن كل موضوع يملك بلده مفاعل:

static Reactor *reactor;
#pragma omp threadprivate(reactor)

يرجى ملاحظة أن وسيطة الوظيفة new_server() أعمال true. هذا يعني أننا نقوم بتعيين الخيار لمقبس الخادم SO_REUSEPORTلاستخدامه في بيئة متعددة الخيوط. يمكنك قراءة المزيد من التفاصيل هنا.

الجولة الثانية

الآن دعونا نقيس أداء خادم متعدد الخيوط:

$ wrk -c100 -d1m -t8 http://127.0.0.1:18470 -H "Host: 127.0.0.1:18470" -H "Accept-Language: en-US,en;q=0.5" -H "Connection: keep-alive"
Running 1m test @ http://127.0.0.1:18470
  8 threads and 100 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency     1.14ms    2.53ms  40.73ms   89.98%
    Req/Sec    79.98k    18.07k  154.64k    78.65%
  38208400 requests in 1.00m, 5.23GB read
Requests/sec: 635876.41
Transfer/sec:     89.14MB

زاد عدد الطلبات التي تتم معالجتها في دقيقة واحدة بمقدار 1 مرة تقريبًا! لكننا لم نحقق سوى 3.28 مليون تقريبًا من الرقم التقريبي، لذا دعونا نحاول إصلاح ذلك.

أولا دعونا نلقي نظرة على الإحصائيات التي تم إنشاؤها الأداء الإقتصادي الأداء:

$ sudo perf stat -B -e task-clock,context-switches,cpu-migrations,page-faults,cycles,instructions,branches,branch-misses,cache-misses ./http_server_multithreaded

 Performance counter stats for './http_server_multithreaded':

     242446,314933      task-clock (msec)         #    4,000 CPUs utilized          
         1 813 074      context-switches          #    0,007 M/sec                  
             4 689      cpu-migrations            #    0,019 K/sec                  
               254      page-faults               #    0,001 K/sec                  
   895 324 830 170      cycles                    #    3,693 GHz                    
   621 378 066 808      instructions              #    0,69  insn per cycle         
   119 926 709 370      branches                  #  494,653 M/sec                  
     3 227 095 669      branch-misses             #    2,69% of all branches        
           808 664      cache-misses                                                

      60,604330670 seconds time elapsed

استخدام تقارب وحدة المعالجة المركزية، تجميع مع -march=native, PGO، زيادة في عدد الزيارات مساء، يزيد MAX_EVENTS والاستخدام EPOLLET لم يعط زيادة كبيرة في الأداء. ولكن ماذا يحدث إذا قمت بزيادة عدد الاتصالات المتزامنة؟

إحصائيات 352 اتصالاً متزامناً:

$ wrk -c352 -d1m -t8 http://127.0.0.1:18470 -H "Host: 127.0.0.1:18470" -H "Accept-Language: en-US,en;q=0.5" -H "Connection: keep-alive"
Running 1m test @ http://127.0.0.1:18470
  8 threads and 352 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency     2.12ms    3.79ms  68.23ms   87.49%
    Req/Sec    83.78k    12.69k  169.81k    83.59%
  40006142 requests in 1.00m, 5.48GB read
Requests/sec: 665789.26
Transfer/sec:     93.34MB

تم الحصول على النتيجة المرجوة ومعها رسم بياني مثير للاهتمام يوضح اعتماد عدد الطلبات التي تتم معالجتها في دقيقة واحدة على عدد الاتصالات:

مفاعل الإدخال/الإخراج العاري كامل المواصفات

نرى أنه بعد بضع مئات من الاتصالات، ينخفض ​​\u200b\u200bعدد الطلبات المعالجة لكلا الخادمين بشكل حاد (في الإصدار متعدد الخيوط يكون هذا أكثر وضوحًا). هل يرتبط هذا بتنفيذ مكدس Linux TCP/IP؟ لا تتردد في كتابة افتراضاتك حول سلوك الرسم البياني والتحسينات للخيارات متعددة الخيوط والمفردة في التعليقات.

كيف وأشار في التعليقات، لا يُظهر اختبار الأداء هذا سلوك مفاعل الإدخال/الإخراج في ظل الأحمال الحقيقية، لأن الخادم يتفاعل دائمًا مع قاعدة البيانات، ويخرج السجلات، ويستخدم التشفير مع TLS وما إلى ذلك، ونتيجة لذلك يصبح الحمل غير منتظم (ديناميكي). سيتم إجراء الاختبارات مع مكونات الطرف الثالث في المقالة حول جهاز الإدخال/الإخراج.

عيوب مفاعل الإدخال/الإخراج

عليك أن تفهم أن مفاعل الإدخال/الإخراج لا يخلو من عيوبه، وهي:

  • يعد استخدام مفاعل الإدخال / الإخراج في بيئة متعددة الخيوط أكثر صعوبة إلى حد ما، لأنه سيكون عليك إدارة التدفقات يدويًا.
  • توضح الممارسة أنه في معظم الحالات يكون الحمل غير منتظم، مما قد يؤدي إلى تسجيل مؤشر ترابط واحد بينما يكون الآخر مشغولاً بالعمل.
  • إذا قام أحد معالجي الأحداث بحظر سلسلة رسائل، فسيقوم محدد النظام نفسه أيضًا بالحظر، مما قد يؤدي إلى أخطاء يصعب العثور عليها.

يحل هذه المشاكل جهاز الإدخال/الإخراج، والذي غالبًا ما يحتوي على برنامج جدولة يوزع الحمل بالتساوي على مجموعة من سلاسل الرسائل، ويحتوي أيضًا على واجهة برمجة تطبيقات أكثر ملاءمة. وسنتحدث عنها لاحقا في مقالتي الأخرى.

اختتام

هذا هو المكان الذي انتهت فيه رحلتنا من النظرية مباشرة إلى عادم الملف التعريفي.

لا ينبغي عليك الخوض في هذا الأمر، لأن هناك العديد من الأساليب الأخرى المثيرة للاهتمام بنفس القدر لكتابة برامج الشبكة بمستويات مختلفة من الراحة والسرعة. ومن المثير للاهتمام، في رأيي، الروابط أدناه.

حتى في المرة القادمة!

مشاريع مثيرة للاهتمام

ماذا يجب أن أقرأ؟

المصدر: www.habr.com

إضافة تعليق