Pełnofunkcjonalny reaktor I/O w czystym C

Pełnofunkcjonalny reaktor I/O w czystym C

Wprowadzenie

Reaktor I/O (jedno wątkowy cykl zdarzeń) — to wzorzec do pisania oprogramowania o dużym obciążeniu, wykorzystywany w wielu popularnych rozwiązaniach:

W artykule tym przyjrzymy się podsystemowi reaktora I/O oraz zasadzie jego działania, napiszemy implementację w mniej niż 200 linijkach kodu i zmusimy prosty serwer HTTP do obsługi ponad 40 milionów żądań na minutę.

Przedmowa

  • Artykuł został napisany w celu pomocy w zrozumieniu działania reaktora I/O, a co za tym idzie, uświadomienia sobie ryzyk związanych z jego używaniem.
  • Aby zrozumieć artykuł, wymagane jest podstawowe znawstwo języka C i niewielkie doświadczenie w tworzeniu aplikacji sieciowych.
  • Cały kod napisany jest w języku C ściśle wedługuwaga: długi PDF) standardu C11 dla systemu Linux i jest dostępny na GitHub.

Po co to potrzebne?

Wraz ze wzrostem popularności Internetu serwery WWW musiały obsługiwać wiele połączeń jednocześnie, w związku z czym wypróbowano dwa podejścia: blokujące I/O przy dużej liczbie wątków OS oraz nieblokujące I/O w połączeniu z systemem powiadamiania o zdarzeniach, nazywanym także "selekcjonowaniem systemowym" (epoll/kqueue/IOCP/etc).

Pierwsze podejście zakładało stworzenie nowego wątku systemu operacyjnego dla każdego przychodzącego połączenia. Jego wadą jest zła skalowalność: system operacyjny będzie musiał wykonać wiele przejść kontekstu и wywołań systemowych. Są to operacje kosztowne i mogą prowadzić do braku dostępnej pamięci RAM przy znacznej liczbie połączeń.

Zmodyfikowana wersja przydziela stałą liczbę wątków (puli wątków), co zapobiega awaryjnemu zakończeniu działania systemu, ale wprowadza nowy problem: jeśli w danym momencie pula wątków blokuje długotrwałe operacje odczytu, inne gniazda, które mogą już przyjąć dane, nie będą mogły tego zrobić.

Drugie podejście wykorzystuje system powiadamiania o zdarzeniach (selekcja systemowa), który jest dostarczany przez system operacyjny. W tym artykule omówiony zostanie najczęściej spotykany typ selektora systemowego, oparty na powiadomieniach (zdarzeniach, notyfikacjach) o gotowości do operacji I/O, a nie na powiadomieniach o ich zakończeniu. Uproszczony przykład jego zastosowania można przedstawić w następującej schemacie blokowym:

Pełnofunkcjonalny reaktor I/O w czystym 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 nie zdefragmentuje przychodzące pakiety IP do strumienia bajtów (TCP, odbieranie danych) lub nie zwolni się wystarczająco dużo miejsca w wewnętrznych buforach zapisu na wysyłanie przez NIC (wysyłanie danych).
  • Selektor systemowy po pewnym czasie informuje program, że system operacyjny już zdefragmentował pakiety IP (TCP, odbieranie danych) lub dostępne jest wystarczająco dużo miejsca w wewnętrznych buforach zapisu już (wysyłanie danych).

Podsumowując, rezerwacja strumienia systemu operacyjnego dla każdej operacji I/O to marnotrawstwo mocy obliczeniowej, ponieważ w rzeczywistości strumienie nie są zaangażowane w użyteczną pracę (stąd termin "przerwanie programowe"). Selektor systemowy rozwiązuje ten problem, umożliwiając programowi użytkownika dużo bardziej oszczędne zużycie zasobów CPU.

Model reaktora I/O

Reaktor I/O działa jako warstwa między selektorem systemowym a kodem użytkownika. Zasada jego działania opisana jest w poniższej schemacie blokowym:

Pełnofunkcjonalny reaktor I/O w czystym C

  • Przypominam, że zdarzenie to powiadomienie, że dany gniazdo jest w stanie wykonać nieblokującą operację I/O.
  • Handler zdarzeń to funkcja wywoływana przez reaktor I/O w momencie otrzymania zdarzenia, która następnie dokonuje nieblokującej operacji I/O.

Warto zaznaczyć, że reaktor I/O z definicji działa w jednym wątku, jednak nic nie stoi na przeszkodzie, aby wykorzystać ten koncept w środowisku wielowątkowym, stosując 1 wątek: 1 reaktor, co umożliwia wykorzystanie wszystkich rdzeni CPU.

Implementacja

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 na funkcję, która będzie wywoływana przez reaktor I/O, gdy pojawi się
 * zdarzenie od systemowego selektora.
 */
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 wypadku.
 */
Reactor *reactor_new(void);

/*
 * Zwolnij systemowy selektor, 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 cykl zdarzeń z czasem oczekiwania `timeout`.
 *
 * Ta funkcja przekaże kontrolę kodowi wywołującemu, gdy czas upłynie
 * lub/i gdy nie ma zarejestrowanych gniazd.
 */
int reactor_run(const Reactor *reactor, time_t timeout);

Struktura reaktora I/O składa się z deskryptora pliku selekora epoll и tablicy haszującej GHashTable, która przypisuje każde gniazdo do CallbackData (struktura zawierająca obsługę zdarzenia oraz 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;

Zauważ, że aktywowaliśmy możliwość obsługi niepełnego typu przez wskaźnik. W reactor.h deklarujemy strukturę reactor, a w reactor.c definiujemy ją, uniemożliwiając użytkownikowi jawne modyfikowanie jej pól. To jeden z wzorców ukrywania danych, elegancko wpisujący się w semantykę C.

Funkcje reactor_register, reactor_deregister и reactor_reregister aktualizują listę interesujących gniazd i odpowiednich obsługujących zdarzenia w systemowym selektorze oraz w tabeli 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 deskryptorem fd, wywołuje odpowiedniego obsługującego zdarzenie, przekazując mu fd, maskę bitową wygenerowanych zdarzeń oraz wskaźnik użytkownika na 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łaj obsługiwacze 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 będzie wyglądał następująco:

Pełnofunkcjonalny reaktor I/O w czystym C

Serwer jednowątkowy

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

Krótki przewodnik po protokole HTTP

HTTP — to protokół warstwy aplikacyjnej, najczęściej używany do komunikacji serwera z przeglądarką.

HTTP można łatwo używać nad protokół transportowy протокола TCP, wysyłając i odbierając wiadomości w formacie określonym specyfikacją.

Format zapytania

CRLF
CRLF
CRLF
CRLF CRLF

  • CRLF — to ciąg dwóch znaków: r и n, oddzielający pierwszą linię zapytania, nagłówki i dane.
  • <КОМАНДА> — jedna z CONNECT, DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT, TRACE. Przeglądarka wysyła do naszego serwera polecenie GET, co oznacza „Wyślij mi zawartość pliku”.
  • <URI>zunifikowany identyfikator zasobu. Na przykład, jeśli URI = /index.html, klient żąda strony głównej witryny.
  • <ВЕРСИЯ HTTP> — wersja protokołu HTTP w formacie HTTP/X.Y. Najczęściej używaną wersją w dzisiejszych czasach jest HTTP/1.1.
  • <ЗАГОЛОВОК N> — to para klucz-wartość w formacie :, wysyłana do serwera w celu dalszej analizy.
  • <ДАННЫЕ> — dane wymagane przez serwer do wykonania operacji. Często jest to po prostu JSON lub jakikolwiek inny format.

Format odpowiedzi

CRLF
CRLF
CRLF
CRLF CRLF

  • <КОД СТАТУСА> — to liczba, która reprezentuje wynik operacji. Nasz serwer zawsze będzie zwracał status 200 (operacja zakończona sukcesem).
  • <ОПИСАНИЕ СТАТУСА> — tekstowa reprezentacja kodu statusu. Dla kodu statusu 200 — jest to OK.
  • <ЗАГОЛОВОК N> — nagłówek w tym samym formacie, co w żądaniu. Będziemy zwracać nagłówki Content-Length (rozmiar pliku) i 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ż makro funkcji SAFE_CALL() i zdefiniowaną 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)

Funkcja fail() drukuje przekazane argumenty w 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);
}

Funkcja new_server() zwraca deskryptor pliku „serwera” gniazda, utworzonego przez wywołania systemowe socket(), bind() и listen() i jest w stanie przyjmować przychodzące połączenia w trybie bezblokują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;
}

  • Zauważ, że gniazdo jest początkowo tworzone w trybie bezblokującym z użyciem flagi SOCK_NONBLOCK, aby w funkcji on_accept() (czytaj dalej) wywołanie systemowe accept() nie zatrzymało wykonania wątku.
  • Jeśli reuse_port wynosi true, ta funkcja skonfiguruje gniazdo z opcją SO_REUSEPORT przez setsockopt(), aby używać tego samego portu w środowisku wielowątkowym (patrz sekcja „Serwer wielowątkowy”).

Handler zdarzeń on_accept() jest wywoływany po tym, jak system operacyjny wygeneruje zdarzenie EPOLLIN, w tym przypadku oznaczające, że nowe połączenie może być przyjęte. on_accept() przyjmuje nowe połączenie, przełącza je w tryb nieblokujący i rejestruje z obsługą zdarzeń on_recv() 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);
}

Handler zdarzeń on_recv() jest wywoływany po tym, jak system operacyjny wygeneruje zdarzenie EPOLLIN, w tym przypadku oznaczające, że połączenie zarejestrowane on_accept(), jest gotowe do otrzymania danych.

on_recv() odczytuje dane z połączenia, aż cały żądanie HTTP zostanie odebrane, następnie rejestruje obsługę on_send() do wysłania odpowiedzi HTTP. Jeśli klient przerwie połączenie, gniazdo zostaje wykreślone i zamknięte za pomocą close().

Pokaż funkcję on_recv()

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

    // Odbieramy dane wejściowe dopóki recv nie zwróci 0 lub błędu
    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, który blokuje wywołanie
    // wątku
    if (errno != EAGAIN && errno != EWOULDBLOCK) {
        request_buffer_destroy(buffer);
        fail("read");
    }

    // Otrzymano kompletną prośbę HTTP od klienta. Teraz rejestrujemy
    // obsługę zdarzeń na wysyłanie danych
    if (request_buffer_is_complete(buffer)) {
        request_buffer_clear(buffer);
        SAFE_CALL(reactor_reregister(reactor, fd, EPOLLOUT, on_send, buffer),
                  -1);
    }
}

Handler zdarzeń on_send() jest wywoływany po tym, jak system operacyjny wygeneruje zdarzenie EPOLLOUT, oznaczającego, że połączenie zarejestrowane on_recv(), jest gotowe do wysyłania danych. Ta funkcja wysyła odpowiedź HTTP zawierającą HTML z obrazem do klienta, a następnie zmienia obsługę zdarzeń ponownie na on_recv().

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

A na koniec, w pliku http_server.c, w funkcji main() tworzymy reaktor I/O za pomocą reactor_new(), tworzymy gniazdo serwera i rejestrujemy je, uruchamiamy reaktor za pomocą reactor_run() dokładnie na jedną 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);
}

Sprawdzimy, czy wszystko działa prawidłowo. Kompilujemy (chmod a+x compile.sh && ./compile.sh w katalogu głównym projektu) i uruchamiamy serwer, otwieramy http://127.0.0.1:18470 w przeglądarce i widzimy to, czego się spodziewaliśmy:

Pełnofunkcjonalny reaktor I/O w czystym C

Pomiar wydajności

Pokaż specyfikacje mojego sprzętu

$ 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

Zmierzmy wydajność serwera jednowątkowego. Otworzymy dwa terminale: w jednym uruchomimy ./http_server, w drugim — wrk. Po minucie w drugim terminalu pojawią się następujące statystyki:

$ 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"
Trwa test przez 1 minutę @ http://127.0.0.1:18470
  8 wątków i 100 połączeń
  Statystyki wątków   Średnia      Odchylenie     Maks.   +/- Odchylenie
    Opóźnienie   493.52µs   76.70µs  17.31ms   89.57%
    Żądania/sek    24.37k     1.81k   29.34k    68.13%
  11657769 żądań w 1.00m, 1.60GB odczytano
Żądania/sek: 193974.70
Transfer/sek:     27.19MB

Nasz serwer jednowątkowy zdołał obsłużyć ponad 11 milionów żądań na minutę, pochodzących z 100 połączeń. To niezły wynik, ale czy można go poprawić?

Serwer wielowątkowy

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

Pokaż 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 SO_REUSEPORT, aby używać go w środowisku wielowątkowym. Więcej na ten temat można przeczytać tutaj.

Druga część

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: pl-PL,pl;q=0.5" -H "Connection: keep-alive"
Uruchamianie testu przez 1 minutę na http://127.0.0.1:18470
  8 wątków i 100 połączeń
  Statystyki wątków   Śr      Odch. standardowe     Max   +/– Odch. standardowe
    Opóźnienie     1.14ms    2.53ms  40.73ms   89.98%
    Żądań/sek    79.98k    18.07k  154.64k    78.65%
  38208400 żądań w 1.00m, 5.23GB odczytanych
Żądań/sek: 635876.41
Transfer/sek:     89.14MB

Liczba przetworzonych żądań w ciągu 1 minuty wzrosła o ~3.28 razy! Brakowało ~dwóch milionów, spróbujmy to poprawić.

Najpierw przyjrzymy się statystykom, które wygenerowano 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      zegar-zadań (ms)         #    4,000 CPU wykorzystane          
         1 813 074      przełączenia kontekstu      #    0,007 M/sek                  
             4 689      migracje cpu            #    0,019 K/sek                  
               254      błędy strony               #    0,001 K/sek                  
   895 324 830 170      cykle                    #    3,693 GHz                    
   621 378 066 808      instrukcje              #    0,69  insn na cykl         
   119 926 709 370      gałęzie                  #  494,653 M/sek                  
     3 227 095 669      błędy gałęzi             #    2,69% wszystkich gałęzi        
           808 664      błędy pamięci cache                                                

      60,604330670 sekundy czasu minęło

Wykorzystanie powiązań CPU, kompilacja z -march=native, PGO, zwiększenie liczby trafień w cache, wzrost MAX_EVENTS i zastosowanie EPOLLET nie przyniosło znaczącego wzrostu wydajności. Co się jednak stanie, jeśli zwiększymy liczbę jednoczesnych połączeń?

Statystyki 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"
Przeprowadzanie testu przez 1 min @ http://127.0.0.1:18470
  8 wątków i 352 połączenia
  Statystyki wątków   Średnia      Odchylenie standardowe     Max   +/− Odchylenie
    Opóźnienie     2.12ms    3.79ms  68.23ms   87.49%
    Req/sec    83.78k    12.69k  169.81k    83.59%
  40006142 żądań w 1.00m, 5.48GB przeczytane
Żądania/sec: 665789.26
Transfer/sec:     93.34MB

Pożądany wynik uzyskany, a wraz z nim ciekawy wykres, który pokazuje zależność liczby obsłużonych żądań w ciągu 1 minuty od liczby połączeń:

Pełnofunkcjonalny reaktor I/O w czystym C

Widzimy, że po kilku setkach połączeń liczba obsłużonych żądań na obu serwerach znacznie spada (w przypadku wersji wielowątkowej jest to bardziej zauważalne). Czy jest to związane z implementacją stosu TCP/IP w systemie Linux? Z przyjemnością przyjmujemy wasze spostrzeżenia na temat tego zachowania wykresu i optymalizacji w wersjach wielowątkowej i jednowątkowej w komentarzach.

Jak zauważyliśmy w komentarzach, ten test wydajności nie pokazuje zachowania I/O reaktora pod realnymi obciążeniami, ponieważ prawie zawsze serwer współdziała z bazą danych, generuje logi, korzysta z kryptografii z TLS itd., co sprawia, że obciążenie staje się niejednorodne (dynamiczne). Testy wraz z zewnętrznymi komponentami będą przeprowadzone w artykule o I/O proaktorze.

Wady I/O reaktora

Należy rozumieć, że I/O reaktor ma swoje wady, a mianowicie:

  • Korzystanie z I/O reaktora w środowisku wielowątkowym jest nieco bardziej skomplikowane, ponieważ trzeba ręcznie zarządzać wątkami.
  • Praktyka pokazuje, że w większości przypadków obciążenie jest niejednorodne, co może prowadzić do sytuacji, w której jeden wątek będzie działał, podczas gdy inny będzie obciążony pracą.
  • Jeśli jeden handler zdarzenia zablokuje wątek, zablokuje się również sam systemowy selektor, co może prowadzić do trudnych do zdiagnozowania błędów.

Te problemy rozwiązuje I/O proaktor, który często ma harmonogram, który równomiernie rozdziela obciążenie w puli wątków oraz posiada bardziej przyjazne API. O tym będzie mowa później, w moim innym artykule.

Podsumowanie

Na tym kończymy naszą podróż z teorii prosto do wyjścia profanera.

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 są podane poniżej.

Do zobaczenia!

Interesujące projekty

Co jeszcze przeczytać?

Źródło: habr.com

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