Ամբողջական հնարավորություններով բաց-C I/O ռեակտոր

Ամբողջական հնարավորություններով բաց-C I/O ռեակտոր

Ներածություն

I/O ռեակտոր (մեկ թելերով իրադարձությունների հանգույց) բարձր բեռնված ծրագրակազմ գրելու օրինաչափություն է, որն օգտագործվում է շատ հայտնի լուծումներում.

Այս հոդվածում մենք կդիտարկենք I/O ռեակտորի մանրամասներն ու ելքերը և թե ինչպես է այն աշխատում, կգրենք ներդրում 200 տողից պակաս կոդով և կկատարենք պարզ HTTP սերվերի գործընթաց՝ ավելի քան 40 միլիոն հարցում/րոպե:

Նախաբան

  • Հոդվածը գրվել է, որպեսզի օգնի հասկանալ I/O ռեակտորի աշխատանքը և, հետևաբար, հասկանալ ռիսկերը այն օգտագործելիս:
  • Հոդվածը հասկանալու համար անհրաժեշտ է հիմունքների իմացություն: C լեզու և ցանցային հավելվածների մշակման որոշակի փորձ:
  • Բոլոր ծածկագրերը գրված են C լեզվով խստորեն համաձայն (զգուշություն՝ երկար PDF) C11 ստանդարտին Linux-ի համար և հասանելի է GitHub.

Թե ինչու է դա անել.

Ինտերնետի աճող ժողովրդականության հետ մեկտեղ վեբ սերվերները սկսեցին միաժամանակ մեծ թվով կապեր վարելու կարիք ունենալ, և, հետևաբար, փորձարկվեց երկու մոտեցում՝ արգելափակել I/O մեծ թվով OS շղթաների վրա և չարգելափակել I/O-ի հետ համատեղ: իրադարձությունների ծանուցման համակարգ, որը նաև կոչվում է «համակարգի ընտրիչ» (էպոլլ/հերթ/IOCP/ և այլն):

Առաջին մոտեցումը ներառում էր յուրաքանչյուր մուտքային կապի համար OS-ի նոր թեմա ստեղծելը: Դրա թերությունը վատ մասշտաբայնությունն է. օպերացիոն համակարգը ստիպված կլինի իրականացնել շատերը համատեքստի անցումներ и համակարգային զանգեր. Դրանք թանկ գործողություններ են և կարող են հանգեցնել տպավորիչ թվով կապերի ազատ RAM-ի բացակայության:

Փոփոխված տարբերակը կարևորում է ֆիքսված թվով թելեր (թելերի լողավազան), դրանով իսկ թույլ չտալով համակարգը դադարեցնել կատարումը, բայց միևնույն ժամանակ ներկայացնելով նոր խնդիր. եթե շղթաների լողավազանը ներկայումս արգելափակված է երկար ընթերցման գործողություններով, ապա այլ վարդակներ, որոնք արդեն ի վիճակի են տվյալներ ստանալ, չեն կարողանա ստանալ այդպես վարվեք։

Երկրորդ մոտեցումը օգտագործում է իրադարձությունների ծանուցման համակարգ (համակարգի ընտրիչ) տրամադրված ՕՀ-ի կողմից: Այս հոդվածը քննարկում է համակարգի ընտրիչի ամենատարածված տեսակը, որը հիմնված է I/O գործողությունների պատրաստության մասին ահազանգերի (իրադարձությունների, ծանուցումների) վրա, այլ ոչ թե ծանուցումներ դրանց ավարտի մասին. Դրա օգտագործման պարզեցված օրինակը կարող է ներկայացվել հետևյալ բլոկային դիագրամով.

Ամբողջական հնարավորություններով բաց-C I/O ռեակտոր

Այս մոտեցումների միջև տարբերությունը հետևյալն է.

  • I/O գործողությունների արգելափակում կասեցնել օգտագործողի հոսքը մինչևքանի դեռ ՕՀ-ն ճիշտ է դեֆրագմենտներ մուտքային IP փաթեթներ դեպի բայթ հոսք (TCP, տվյալների ստացում) կամ ներքին գրման բուֆերներում բավարար տեղ չի լինի՝ հետագա միջոցով ուղարկելու համար NIC (տվյալների ուղարկում):
  • Համակարգի ընտրիչ ժամանակի ընթացքում տեղեկացնում է ծրագրին, որ ՕՀ արդեն ապաֆրագմենտացված IP փաթեթներ (TCP, տվյալների ընդունում) կամ բավականաչափ տեղ գրելու ներքին բուֆերներում արդեն հասանելի (տվյալների ուղարկում):

Ամփոփելու համար նշենք, որ յուրաքանչյուր I/O-ի համար OS շարանը վերապահելը հաշվողական ուժի վատնում է, քանի որ իրականում թելերը օգտակար աշխատանք չեն կատարում (այստեղից է գալիս տերմինը. «ծրագրային ընդհատում») Համակարգի ընտրիչը լուծում է այս խնդիրը՝ թույլ տալով օգտվողի ծրագրին շատ ավելի տնտեսապես օգտագործել պրոցեսորի ռեսուրսները:

I/O ռեակտորի մոդել

I/O ռեակտորը գործում է որպես շերտ համակարգի ընտրիչի և օգտագործողի կոդի միջև: Դրա գործողության սկզբունքը նկարագրված է հետևյալ բլոկային դիագրամով.

Ամբողջական հնարավորություններով բաց-C I/O ռեակտոր

  • Հիշեցնեմ, որ իրադարձությունը ծանուցում է, որ որոշակի վարդակից ի վիճակի է կատարել ոչ արգելափակող I/O գործողություն։
  • Իրադարձությունների մշակիչը գործառույթ է, որը կանչվում է I/O ռեակտորի կողմից, երբ ստացվում է իրադարձություն, որն այնուհետ կատարում է ոչ արգելափակող I/O գործողություն:

Կարևոր է նշել, որ I/O ռեակտորը, ըստ սահմանման, մեկ թելերով է, բայց ոչինչ չի խանգարում հայեցակարգին օգտագործել բազմաթելային միջավայրում՝ 1 թել: 1 ռեակտոր հարաբերակցությամբ, դրանով իսկ վերամշակելով բոլոր պրոցեսորային միջուկները:

Իրականացման

Մենք հանրային ինտերֆեյսը կտեղադրենք ֆայլի մեջ reactor.h, իսկ իրականացումը` մեջ reactor.c. reactor.h բաղկացած կլինի հետևյալ հայտարարություններից.

Ցույց տալ հայտարարությունները ռեակտորում.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);

I/O ռեակտորի կառուցվածքը բաղկացած է ֆայլի նկարագրիչ ընտրիչ էպոլլ и հեշ աղյուսակներ GHashTable, որը քարտեզագրում է յուրաքանչյուր վարդակից CallbackData (իրադարձությունների մշակողի կառուցվածքը և դրա համար օգտագործողի փաստարկը):

Ցույց տալ ռեակտորը և վերադարձի տվյալները

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

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

Խնդրում ենք նկատի ունենալ, որ մենք միացրել ենք կարգավորելու հնարավորությունը թերի տեսակ ըստ ցուցանիշի։ IN 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;
}

Այն բանից հետո, երբ 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;
}

Ամփոփելու համար օգտագործողի կոդի մեջ ֆունկցիաների կանչերի շղթան կունենա հետևյալ ձևը.

Ամբողջական հնարավորություններով բաց-C I/O ռեակտոր

Մեկ պարուրակ սերվեր

I/O ռեակտորը մեծ ծանրաբեռնվածության տակ փորձարկելու համար մենք կգրենք պարզ 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, որը պարունակում է հետևյալ ֆունկցիաների նախատիպերը.

Ցույց տալ գործառույթների նախատիպերը ընդհանուր.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() I/O ռեակտորում:

Ցույց տալ 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() մենք ստեղծում ենք I/O ռեակտոր՝ օգտագործելով 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 զննարկիչում և տեսեք, թե ինչ էինք սպասում.

Ամբողջական հնարավորություններով բաց-C I/O ռեակտոր

Կատարողականի չափում

Ցույց տալ իմ մեքենայի բնութագրերը

$ 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 -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 կապից: Վատ արդյունք չէ, բայց կարելի՞ է այն բարելավել։

Բազմաթելային սերվեր

Ինչպես նշվեց վերևում, I/O ռեակտորը կարող է ստեղծվել առանձին թելերով՝ դրանով իսկ օգտագործելով պրոցեսորի բոլոր միջուկները: Եկեք այս մոտեցումը կիրառենք գործնականում.

Ցույց տալ 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 անգամ: Բայց մենք ընդամենը XNUMX միլիոնով պակաս էինք կլոր թվից, ուստի եկեք փորձենք դա ուղղել:

Նախ եկեք նայենք ստեղծված վիճակագրությանը պերֆ:

$ 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

Օգտագործելով CPU Affinity, կազմման հետ -march=native, PGO- ն, հարվածների քանակի ավելացում cache, աճ 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

Ստացվեց ցանկալի արդյունքը և դրա հետ մեկտեղ հետաքրքիր գրաֆիկ, որը ցույց է տալիս 1 րոպեում մշակված հարցումների քանակի կախվածությունը կապերի քանակից.

Ամբողջական հնարավորություններով բաց-C I/O ռեակտոր

Մենք տեսնում ենք, որ մի քանի հարյուր կապից հետո երկու սերվերների համար էլ մշակված հարցումների թիվը կտրուկ նվազում է (բազմաթելային տարբերակում դա ավելի նկատելի է): Արդյո՞ք սա կապված է Linux TCP/IP փաթեթի իրականացման հետ: Ազատորեն գրեք ձեր ենթադրությունները գրաֆիկի այս վարքագծի և բազմաթելային և միաթելային տարբերակների օպտիմալացումների վերաբերյալ մեկնաբանություններում:

Ինչպես նշել է, Մեկնաբանություններում այս կատարողականի թեստը չի ցույց տալիս I/O ռեակտորի վարքագիծը իրական բեռների տակ, քանի որ գրեթե միշտ սերվերը փոխազդում է տվյալների բազայի հետ, թողարկում է տեղեկամատյաններ, օգտագործում գաղտնագրություն TLS և այլն, որի արդյունքում բեռը դառնում է ոչ միատեսակ (դինամիկ): Փորձարկումները երրորդ կողմի բաղադրիչների հետ միասին կիրականացվեն I/O պրոակտորի մասին հոդվածում:

I/O ռեակտորի թերությունները

Դուք պետք է հասկանաք, որ I/O ռեակտորը զերծ չէ իր թերություններից, մասնավորապես.

  • Բազմաթելային միջավայրում I/O ռեակտոր օգտագործելը որոշ չափով ավելի դժվար է, քանի որ դուք ստիպված կլինեք ձեռքով կառավարել հոսքերը:
  • Պրակտիկան ցույց է տալիս, որ շատ դեպքերում ծանրաբեռնվածությունը ոչ միատեսակ է, ինչը կարող է հանգեցնել մի թելերի հատման, մինչդեռ մյուսը զբաղված է աշխատանքով:
  • Եթե ​​իրադարձությունների մշակողներից մեկը արգելափակում է շարանը, ապա համակարգի ընտրիչն ինքը նույնպես արգելափակում է, ինչը կարող է հանգեցնել դժվար գտնելու սխալների:

Լուծում է այս խնդիրները I/O պրոակտոր, որը հաճախ ունի ժամանակացույց, որը հավասարաչափ բաշխում է բեռը թելերի լողավազանի վրա, ինչպես նաև ունի ավելի հարմար API։ Այդ մասին կխոսենք ավելի ուշ՝ իմ մյուս հոդվածում։

Ամփոփում

Այստեղ ավարտվեց մեր ճանապարհորդությունը տեսությունից ուղիղ դեպի պրոֆիլի արտանետում:

Դուք չպետք է կանգ առեք դրա վրա, քանի որ կան շատ այլ նույնքան հետաքրքիր մոտեցումներ ցանցային ծրագրակազմ գրելու համար՝ հարմարության և արագության տարբեր մակարդակներով: Հետաքրքիր է, իմ կարծիքով, հղումները տրված են ստորև:

Կտեսնվենք!

Հետաքրքիր նախագծեր

Էլ ի՞նչ կարդալ:

Source: www.habr.com

Добавить комментарий