Wielofunkcyjny reaktor I/O w gołym C

Wielofunkcyjny reaktor I/O w gołym C

Wprowadzenie

Reaktor I/O (jednowątkowy cykl zdarzeń) — to wzorzec do pisania oprogramowania o wysokiej wydajności, stosowany w wielu popularnych rozwiązaniach:

W tym artykule przyjrzymy się zasadzie działania reaktora I/O, napiszemy implementację w mniej niż 200 linijkach kodu i sprawimy, że prosty serwer HTTP będzie obsługiwał ponad 40 milionów żądań/min.

Wstęp

  • Artykuł napisano, aby pomóc zrozumieć działanie reaktora I/O, a tym samym uświadomić sobie ryzyka związane z jego używaniem.
  • Do zrozumienia artykułu wymagana jest znajomość podstaw języka C i niewielkie doświadczenie w tworzeniu aplikacji sieciowych.
  • Cały kod został napisany w języku C zgodnie z (uwaga: długi PDF) standardem C11 dla Linuxa i jest dostępny na GitHub.

Po co to potrzebne?

Wraz z rosnącą popularnością Internetu serwery WWW zaczęły wymagać obsługi dużej liczby połączeń jednocześnie, dlatego wypróbowano dwa podejścia: blokujące I/O na dużej liczbie wątków systemu operacyjnego oraz nieblokujące I/O w połączeniu z systemem powiadamiania o zdarzeniach, zwanym również «selektywnym systemem» (epoll/kqueue/IOCP/etc).

Pierwsze podejście polegało na tworzeniu nowego wątku systemu operacyjnego dla każdego przychodzącego połączenia. Jego wadą jest słaba skalowalność: system operacyjny musi przeprowadzać wiele przejść kontekstu i wywołań systemowych. Są to kosztowne operacje i mogą prowadzić do niedoboru wolnej pamięci RAM przy dużej liczbie połączeń.

Zmodyfikowana wersja przydziela stałą liczbę wątków (pula wątków), co zapobiega nagłemu zakończeniu wykonywania przez system, ale jednocześnie wprowadza nowy problem: jeśli w danym momencie pula wątków blokuje długotrwałe operacje odczytu, inne gniazda, które już są gotowe do przyjęcia danych, nie będą mogły tego zrobić.

Drugie podejście używa systemu powiadamiania o zdarzeniach (selektywny system), który zapewnia system operacyjny. W tym artykule omówiono najczęściej spotykaną wersję selektywnego systemu, opartą na powiadomieniach (zdarzeniach, informacjach) o gotowości do operacji I/O, a nie na powiadomieniach o ich zakończeniu. Uproszczony przykład jego użycia można przedstawić w następującej schemacie blokowym:

Wielofunkcyjny reaktor I/O w gołym C

Różnica między tymi podejściami polega na następujących kwestiach:

  • Operacje I/O blokujące wstrzymują strumień użytkownika dopókisystem operacyjny odpowiednio nie zdefragmentuje przychodzących pakietów IP do strumienia bajtów (TCP, odbieranie danych) lub nie zwolni się wystarczająco dużo miejsca w buforach zapisu dla późniejszej wysyłki przez NIC (wysyłanie danych).
  • Selektor systemowy po pewnym czasie informuje program, że system operacyjny już zdefragmentował pakiety IP (TCP, odbieranie danych) lub wystarczająco dużo miejsca w buforach zapisu już jest dostępne (wysyłanie danych).

Podsumowując, rezerwacja strumienia systemu operacyjnego dla każdego I/O to marnotrawstwo mocy obliczeniowej, ponieważ w rzeczywistości strumienie nie są zajęte użyteczną pracą (stąd pochodzi termin «przerwanie programowe»). Selektor systemowy rozwiązuje ten problem, pozwalając programowi użytkownika znacznie efektywniej wykorzystać zasoby CPU.

Model reaktora I/O

Reaktor I/O działa jako pośrednik między selektorem systemowym a kodem użytkownika. Zasada jego działania jest opisana przez poniższy diagram blokowy:

Wielofunkcyjny reaktor I/O w gołym C

  • Przypomnę, że zdarzenie to powiadomienie, że dany gniazdo jest gotowe do wykonania nieblokującej operacji I/O.
  • Handler zdarzeń to funkcja, która jest wywoływana przez reaktor I/O po otrzymaniu zdarzenia, która następnie wykonuje nieblokującą operację I/O.

Warto zauważyć, że reaktor I/O z definicji jest jednowątkowy, ale nic nie stoi na przeszkodzie, aby używać tej koncepcji w środowisku wielowątkowym w konfiguracji 1 wątek: 1 reaktor, tym samym wykorzystując wszystkie rdzenie CPU.

Realizacja

Publiczny interfejs umieścimy w pliku reactor.h, a implementację w reactor.c. reactor.h będzie składać się z następujących deklaracji:

Pokaż deklaracje w reactor.h

typedef struct reactor Reactor;


/*
 * Wskaźnik do funkcji, która będzie wywoływana przez reaktor I/O po otrzymaniu
 * zdarzenia od selektora systemowego.
 */
typedef void (*Callback)(void *arg, int fd, uint32_t events);

/*
 * Zwraca `NULL` w przypadku błędu, wskaźnik różny od `NULL` na `Reactor` w
 * przeciwnym razie.
 */
Reactor *reactor_new(void);

/*
 * Zwalnia selektor systemowy, wszystkie zarejestrowane gniazda w danym momencie
 * oraz sam reaktor I/O.
 *
 * Następujące funkcje zwracają -1 w przypadku błędu, 0 w przypadku sukcesu.
 */
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);

/*
 * Uruchamia pętlę zdarzeń z czasem oczekiwania `timeout`.
 *
 * Ta funkcja przekaże kontrolę kodowi wywołującemu, jeśli przydzielony czas minął
 * lub/lub przy braku zarejestrowanych gniazd.
 */
int reactor_run(const Reactor *reactor, time_t timeout);

Struktura reaktora I/O składa się z deskryptora pliku selektera epoll i tablicy haszującej GHashTable, która łączy każde gniazdo z CallbackData (struktura zawierająca obsługę zdarzenia i argument użytkownika dla niej).

Pokaż Reactor i CallbackData

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

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

Zwróć uwagę, że wykorzystaliśmy możliwość odwołania się do niepełnego typu za pomocą wskaźnika. W reactor.h ogłaszamy strukturę reaktora, a w reactor.c definiując ją w ten sposób, uniemożliwiając użytkownikowi jawne modyfikowanie jej pól. To jeden z wzorców ukrywania danych, zgrabnie wpasowujący się w semantykę C.

Funkcje reactor_register, reactor_deregister i reactor_reregister aktualizują listę interesujących gniazd oraz odpowiadające im obsługi zdarzeń w selektorze systemowym i w tablicy haszującej.

Pokaż funkcje rejestracji

#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;
}

Po tym jak reaktor I/O przechwyci zdarzenie z deskryptora fd, wywołuje odpowiednią obsługę zdarzenia, do której przekazuje fd, maskę bitową wygenerowanych zdarzeń oraz wskaźnik użytkownika do void.

Pokaż funkcję 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) {
        // Błąd
        case -1:
            perror("epoll_wait");
            result = -1;
            goto cleanup;
        // Czas minął
        case 0:
            result = 0;
            goto cleanup;
        // Operacja zakończona sukcesem
        default:
            // Wywołanie obsługiwaczy zdarzeń
            for (int i = 0; i table, &fd);
                callback->callback(callback->arg, fd, events[i].events);
            }
        }
    }

cleanup:
    free(events);
    return result;
}

Podsumowując, łańcuch wywołań funkcji w kodzie użytkownika przyjmie następującą formę:

Wielofunkcyjny reaktor I/O w gołym C

Serwer jednowątkowy

Aby przetestować reaktywator I/O pod dużym obciążeniem, napiszemy prosty serwer WWW, który odpowiada obrazem na każde zapytanie.

Krótki przewodnik po protokole HTTP

HTTP — to protokół warstwy aplikacji, głównie używany do komunikacji między serwerem a przeglądarką.

HTTP można łatwo używać na warstwie transportowej protokołu TCP, wysyłając i odbierając wiadomości w formacie określonym przez specyfikacją.

Format zapytania

CRLF
CRLF
CRLF
CRLF CRLF

  • CRLF — to sekwencja dwóch znaków: r i n, oddzielająca pierwszą linię zapytania, nagłówki i dane.
  • <КОМАНДА> — jedna z CONNECT, USUŃ, i login/hasło: admin/admin., HEAD, OPTIONS, PATCH, POST, Żądanie PUT:, TRACE. Przeglądarka wysyła do naszego serwera komendę i login/hasło: admin/admin., oznaczającą „Wyślij mi zawartość pliku”.
  • <URI>ułożony identyfikator zasobu. Na przykład, jeśli URI = /index.html, to klient żąda strony głównej witryny.
  • <ВЕРСИЯ HTTP> — wersja protokołu HTTP w formacie HTTP/X.Y. Najczęściej używana wersja to obecnie — HTTP/1.1.
  • <ЗАГОЛОВОК N> — to para klucz-wartość w formacie :, wysyłana do serwera w celu dalszej analizy.
  • <ДАННЫЕ> — dane, które serwer potrzebuje do wykonania operacji. Często są to po prostu YANG lub inny format.

Format odpowiedzi

CRLF
CRLF
CRLF
CRLF CRLF

  • <КОД СТАТУСА> — to liczba, która reprezentuje wynik operacji. Nasz serwer zawsze zwróci status 200 (operacja zakończona sukcesem).
  • <ОПИСАНИЕ СТАТУСА> — tekstowa reprezentacja kodu statusu. Dla kodu statusu 200 — to OK.
  • <ЗАГОЛОВОК N> — nagłówek w tym samym formacie, co w żądaniu. Będziemy zwracać nagłówki Content-Length (rozmiar pliku) oraz Content-Type: text/html (typ zwracanych danych).
  • <ДАННЫЕ> — dane żądane przez użytkownika. W naszym przypadku jest to ścieżka do obrazu w HTML.

Plik http_server.c (serwer jednowątkowy) zawiera plik common.h, który zawiera następujące prototypy funkcji:

Pokaż prototypy funkcji w 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);

Zdefiniowano również funkcjonalny makros SAFE_CALL() i zdefiniowano funkcję fail(). Makro porównuje wartość wyrażenia z błędem, a jeśli warunek został spełniony, wywołuje funkcję fail():

#define SAFE_CALL(call, error)                                                 
    do {                                                                       
        if ((call) == error) {                                                   
            fail("%s", #call);                                                 
        }                                                                      
    } while (false)

veth_xdp_flush_bq() fail() drukuje przekazane argumenty na terminalu (jak printf()) i kończy działanie programu z kodem 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);
}

veth_xdp_flush_bq() new_server() zwraca deskryptor pliku 'serwera' gniazda, utworzonego za pomocą wywołań systemowych socket(), bind() i listen() i mogącego przyjmować przychodzące połączenia w trybie nieblokującym.

Pokaż funkcję 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;
}

jest wywoływany po tym, jak system operacyjny wygeneruje zdarzenie on_accept() EPOLLIN , w tym przypadku oznaczające, że nowe połączenie może być zaakceptowane.przyjmuje nowe połączenie, zmienia je w tryb nieblokujący i rejestruje z obsługiwaczem zdarzenia on_accept() on_recv() w reaktorze I/O. w reaktorze I/O.

Pokaż funkcję 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);
}

jest wywoływany po tym, jak system operacyjny wygeneruje zdarzenie w reaktorze I/O. EPOLLIN , w tym przypadku oznaczające, że nowe połączenie może być zaakceptowane., co w tym przypadku oznacza, że połączenie zostało zarejestrowane on_accept(), gotowe do przyjęcia danych.

w reaktorze I/O. odczytuje dane z połączenia, dopóki cały HTTP request nie zostanie odebrany, następnie rejestruje handler on_send() do wysłania odpowiedzi HTTP. Jeśli klient przerwie połączenie, gniazdo jest deregisterowane i zamykane za pomocą close().

Pokaż funkcję on_recv()

static void on_recv(void *arg, int fd, uint32_t events) {
    RequestBuffer *buffer = arg;

    // Odbieramy dane do momentu, gdy recv zwróci 0 lub wystąpi błąd
    ssize_t nread;
    while ((nread = recv(fd, buffer->data + buffer->size,
                         REQUEST_BUFFER_CAPACITY - buffer->size, 0)) > 0)
        buffer->size += nread;

    // Klient przerwał połączenie
    if (nread == 0) {
        SAFE_CALL(reactor_deregister(reactor, fd), -1);
        SAFE_CALL(close(fd), -1);
        request_buffer_destroy(buffer);
        return;
    }

    // read zwrócił błąd inny niż błąd, w którym wywołanie zablokuje
    // wątek
    if (errno != EAGAIN && errno != EWOULDBLOCK) {
        request_buffer_destroy(buffer);
        fail("read");
    }

    // Otrzymano pełny HTTP request od klienta. Teraz rejestrujemy handler
    // zdarzeń, aby wysłać dane
    if (request_buffer_is_complete(buffer)) {
        request_buffer_clear(buffer);
        SAFE_CALL(reactor_reregister(reactor, fd, EPOLLOUT, on_send, buffer),
                  -1);
    }
}

jest wywoływany po tym, jak system operacyjny wygeneruje zdarzenie on_send() EPOLLIN EPOLLOUT, co oznacza, że połączenie zostało zarejestrowane w reaktorze I/O., gotowe do wysłania danych. Ta funkcja wysyła odpowiedź HTTP zawierającą HTML z obrazem do klienta, a następnie zmienia handler zdarzeń z powrotem na w reaktorze I/O..

Pokaż funkcję 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);
}

I w końcu, w pliku http_server.c, w funkcji main() tworzymy reaktor I/O za pomocą reactor_new(), tworzymy gniazdo serwerowe i rejestrujemy je, uruchamiamy reaktor przy pomocy reactor_run() dokładnie na minutę, a następnie zwalniamy zasoby i wychodzimy z programu.

Pokaż 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);
}

Sprawdźmy, czy wszystko działa jak powinno. Kompilujemy (chmod a+x compile.sh && ./compile.sh w głównym katalogu projektu) i uruchamiamy serwer własnej roboty, otwieramy http://127.0.0.1:18470 w przeglądarce i obserwujemy to, czego się spodziewaliśmy:

Wielofunkcyjny reaktor I/O w gołym C

Pomiar wydajności

Pokaż specyfikacje mojego komputera

$ 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

Mierzymy wydajność serwera jednowątkowego. Otworzymy dwa terminale: w jednym uruchomimy ./http_server, w drugim — wrk. Po minucie w drugim terminalu wyświetli się następująca statystyka:

$ 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

Nasz serwer jednowątkowy był w stanie obsłużyć ponad 11 milionów zapytań na minutę, pochodzących z 100 połączeń. Niezły wynik, ale czy można go poprawić?

Serwer wielowątkowy

Jak już wspomniano, reaktor I/O można tworzyć w osobnych wątkach, tym samym wykorzystując wszystkie rdzenie CPU. Zastosujmy to podejście w praktyce:

Zobacz 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);
    }
}

Teraz każdy wątek ma własny reaktor:

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

Zauważ, że argumentem funkcji new_server() jest true. To oznacza, że przypisujemy opcję do gniazda serwera, poprzezaby używać go w wielowątkowym środowisku. Szczegóły można przeczytać tutaj.

Drugie podejście

Teraz zmierzmy wydajność serwera wielowątkowego:

$ 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

Liczba przetworzonych zapytań w ciągu 1 minuty wzrosła o ~3.28 razy! Ale do okrągłej liczby brakowało tylko ~dwa miliony, spróbujmy to naprawić.

Najpierw spojrzimy na statystykę wygenerowaną perf:

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

 Statystyki liczników wydajności dla './http_server_multithreaded':

     242446,314933      task-clock (msec)         #    4,000 CPU wykorzystane          
         1 813 074      przełączenia kontekstu         #    0,007 M/s                  
             4 689      migracje CPU            #    0,019 K/s                  
               254      błędy stron               #    0,001 K/s                  
   895 324 830 170      cykle                    #    3,693 GHz                    
   621 378 066 808      instrukcje              #    0,69  instrukcji na cykl         
   119 926 709 370      rozgałęzienia                  #  494,653 M/s                  
     3 227 095 669      błędy rozgałęzień             #    2,69% wszystkich rozgałęzień        
           808 664      błędy pamięci podręcznej                                                

      60,604330670 sekund czasu upłynęło

Wykorzystanie przynależności CPU, kompilacja z -march=native, PGO, zwiększenie liczby trafień w pamięci podręcznej, zwiększenie MAX_EVENTS i użycie EPOLLET nie przyniosło znaczącego wzrostu wydajności. Ale co się stanie, jeśli zwiększymy liczbę jednoczesnych połączeń?

Statystyka przy 352 jednoczesnych połączeniach:

$ 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"
Uruchamianie testu 1m @ http://127.0.0.1:18470
  8 wątków i 352 połączenia
  Statystyki wątku   Średnia      Odch. standardowe     Maks.   +/- Odch. standardowe
    Latencja     2.12ms    3.79ms  68.23ms   87.49%
    Żądania/Sek    83.78k    12.69k  169.81k    83.59%
  40006142 żądań w 1.00m, 5.48GB odczytane
Żądania/s: 665789.26
Transfer/s:     93.34MB

Oczekiwany rezultat osiągnięty, a wraz z nim interesujący wykres, pokazujący zależność liczby obsłużonych żądań w ciągu 1 minuty od liczby połączeń:

Wielofunkcyjny reaktor I/O w gołym C

Widzimy, że po kilku setkach połączeń liczba obsłużonych żądań na obu serwerach dramatycznie spada (w przypadku wersji wielowątkowej jest to bardziej wyraźne). Czy ma to związek z implementacją stosu TCP/IP w systemie Linux? Zachęcamy do dzielenia się swoimi spostrzeżeniami na temat takiego zachowania wykresu i optymalizacji wersji wielowątkowej i jednowątkowej w komentarzach.

Jak zauważyli w komentarzach, ten test wydajności nie pokazuje zachowania I/O reaktora pod prawdziwym obciążeniem, ponieważ prawie zawsze serwer współpracuje z bazą danych, zapisuje logi, korzysta z kryptografii z TLS itd., co sprawia, że obciążenie staje się niejednorodne (dynamiczne). Testy z innymi komponentami będą przeprowadzone w artykule o I/O reaktorze.

Wady I/O reaktora

Należy zrozumieć, że I/O reaktor nie jest pozbawiony wad, a mianowicie:

  • Korzystanie z I/O reaktora w środowisku wielowątkowym jest nieco trudniejsze, ponieważ trzeba ręcznie zarządzać wątkami.
  • Praktyka pokazuje, że w większości przypadków obciążenie jest nierównomierne, co może prowadzić do sytuacji, gdzie jeden strumień będzie zajęty, podczas gdy inny jest obciążony pracą.
  • Jeśli jeden obsługiwacz zdarzeń zablokuje wątek, to również zablokuje sam systemowy selektor, co może prowadzić do trudnych do wykrycia błędów.

Te problemy rozwiązuje I/O proaktor, często mający harmonogram, który równomiernie rozkłada obciążenie w puli wątków, a także oferujący bardziej przyjazne API. O nim będzie mowa później, w moim innym artykule.

Podsumowanie

Na tym nasze podróż z teorii prosto do wydechu profilera dobiegła końca.

Nie warto na tym poprzestawać, ponieważ istnieje wiele innych, równie interesujących podejść do pisania oprogramowania sieciowego o różnym poziomie wygody i szybkości. Interesujące, moim zdaniem, linki znajdują się poniżej.

Do zobaczenia!

Interesujące projekty

Co jeszcze poczytać?

Źródło: habr.com

Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS 🔥 Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS | ProHoster