Γυμνός αντιδραστήρας I/O με πλήρη χαρακτηριστικά

Γυμνός αντιδραστήρας I/O με πλήρη χαρακτηριστικά

Εισαγωγή

I/O αντιδραστήρας (μονό σπείρωμα βρόχος συμβάντος) είναι ένα μοτίβο για τη σύνταξη λογισμικού υψηλού φορτίου, που χρησιμοποιείται σε πολλές δημοφιλείς λύσεις:

Σε αυτό το άρθρο, θα εξετάσουμε τις λεπτομέρειες ενός αντιδραστήρα I/O και πώς λειτουργεί, θα γράψουμε μια υλοποίηση σε λιγότερες από 200 γραμμές κώδικα και θα κάνουμε μια απλή διαδικασία διακομιστή HTTP πάνω από 40 εκατομμύρια αιτήματα/λεπτό.

πρόλογος

  • Το άρθρο γράφτηκε για να βοηθήσει στην κατανόηση της λειτουργίας του αντιδραστήρα εισόδου/εξόδου και, επομένως, στην κατανόηση των κινδύνων κατά τη χρήση του.
  • Απαιτείται γνώση των βασικών για την κατανόηση του άρθρου. Γλώσσα Γ και κάποια εμπειρία στην ανάπτυξη εφαρμογών δικτύου.
  • Όλος ο κώδικας είναι γραμμένος στη γλώσσα C αυστηρά σύμφωνα με (Προσοχή: μεγάλο PDF) στο πρότυπο C11 για Linux και διαθέσιμο στο GitHub.

Γιατί το κάνει;

Με την αυξανόμενη δημοτικότητα του Διαδικτύου, οι διακομιστές ιστού άρχισαν να χρειάζεται να χειρίζονται μεγάλο αριθμό συνδέσεων ταυτόχρονα, και ως εκ τούτου δοκιμάστηκαν δύο προσεγγίσεις: αποκλεισμός I/O σε μεγάλο αριθμό νημάτων του λειτουργικού συστήματος και μη αποκλεισμός I/O σε συνδυασμό με ένα σύστημα ειδοποίησης συμβάντων, που ονομάζεται επίσης "επιλογέας συστήματος" (επολ/ουρά/IOCP/και τα λοιπά).

Η πρώτη προσέγγιση περιλάμβανε τη δημιουργία ενός νέου νήματος λειτουργικού συστήματος για κάθε εισερχόμενη σύνδεση. Το μειονέκτημά του είναι η κακή επεκτασιμότητα: το λειτουργικό σύστημα θα πρέπει να εφαρμόσει πολλά μεταβάσεις περιβάλλοντος и κλήσεις συστήματος. Είναι ακριβές λειτουργίες και μπορεί να οδηγήσουν σε έλλειψη ελεύθερης μνήμης RAM με εντυπωσιακό αριθμό συνδέσεων.

Η τροποποιημένη έκδοση επισημαίνει σταθερός αριθμός νημάτων (ομάδα νημάτων), εμποδίζοντας έτσι το σύστημα να ματαιώσει την εκτέλεση, αλλά ταυτόχρονα εισάγει ένα νέο πρόβλημα: εάν μια ομάδα νημάτων είναι επί του παρόντος αποκλεισμένη από λειτουργίες μακράς ανάγνωσης, τότε άλλες υποδοχές που είναι ήδη σε θέση να λάβουν δεδομένα δεν θα μπορούν να να το κάνεις.

Η δεύτερη προσέγγιση χρησιμοποιεί σύστημα ειδοποίησης συμβάντων (επιλογέας συστήματος) που παρέχεται από το Λ.Σ. Αυτό το άρθρο εξετάζει τον πιο συνηθισμένο τύπο επιλογέα συστήματος, με βάση ειδοποιήσεις (συμβάντα, ειδοποιήσεις) σχετικά με την ετοιμότητα για λειτουργίες I/O, αντί για ειδοποιήσεις για την ολοκλήρωσή τους. Ένα απλοποιημένο παράδειγμα χρήσης του μπορεί να αναπαρασταθεί από το ακόλουθο μπλοκ διάγραμμα:

Γυμνός αντιδραστήρας I/O με πλήρη χαρακτηριστικά

Η διαφορά μεταξύ αυτών των προσεγγίσεων είναι η εξής:

  • Αποκλεισμός λειτουργιών I/O αναστέλλω ροή χρήστη μέχριμέχρι το λειτουργικό σύστημα να είναι σωστά ανασυγκροτήσεις εισερχόμενος Πακέτα IP σε ροή byte (TCP, λήψη δεδομένων) ή δεν θα υπάρχει αρκετός διαθέσιμος χώρος στα εσωτερικά buffer εγγραφής για μετέπειτα αποστολή μέσω NIC (αποστολή στοιχείων).
  • Επιλογέας συστήματος στο περασμα του χρονου ειδοποιεί το πρόγραμμα ότι το Λ.Σ ήδη ανασυγκροτημένα πακέτα IP (TCP, λήψη δεδομένων) ή αρκετός χώρος σε εσωτερικά buffer εγγραφής ήδη διαθέσιμα (αποστολή δεδομένων).

Συνοψίζοντας, η κράτηση ενός νήματος λειτουργικού συστήματος για κάθε I/O είναι σπατάλη υπολογιστικής ισχύος, επειδή στην πραγματικότητα, τα νήματα δεν κάνουν χρήσιμη δουλειά (εξ ου και ο όρος "διακοπή λογισμικού"). Ο επιλογέας συστήματος λύνει αυτό το πρόβλημα, επιτρέποντας στο πρόγραμμα χρήστη να χρησιμοποιεί τους πόρους της CPU πολύ πιο οικονομικά.

Μοντέλο αντιδραστήρα I/O

Ο αντιδραστήρας I/O λειτουργεί ως στρώμα μεταξύ του επιλογέα συστήματος και του κωδικού χρήστη. Η αρχή της λειτουργίας του περιγράφεται από το ακόλουθο μπλοκ διάγραμμα:

Γυμνός αντιδραστήρας I/O με πλήρη χαρακτηριστικά

  • Επιτρέψτε μου να σας υπενθυμίσω ότι ένα συμβάν είναι μια ειδοποίηση ότι μια συγκεκριμένη υποδοχή μπορεί να εκτελέσει μια λειτουργία I/O χωρίς αποκλεισμό.
  • Ένας χειριστής συμβάντων είναι μια συνάρτηση που καλείται από τον αντιδραστήρα I/O όταν λαμβάνεται ένα συμβάν, το οποίο στη συνέχεια εκτελεί μια λειτουργία I/O χωρίς αποκλεισμό.

Είναι σημαντικό να σημειωθεί ότι ο αντιδραστήρας I/O είναι εξ ορισμού μονού νήματος, αλλά τίποτα δεν εμποδίζει τη χρήση της ιδέας σε περιβάλλον πολλαπλών νημάτων σε αναλογία 1 νήμα: 1 αντιδραστήρα, ανακυκλώνοντας έτσι όλους τους πυρήνες της CPU.

Реализация

Θα τοποθετήσουμε τη δημόσια διεπαφή σε ένα αρχείο reactor.h, και υλοποίηση - in 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 (δομή ενός χειριστή συμβάντων και ένα όρισμα χρήστη για αυτό).

Εμφάνιση Reactor και 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;
}

Αφού ο αντιδραστήρας I/O αναχαιτίσει το συμβάν με τον περιγραφέα fd, καλεί τον αντίστοιχο χειριστή συμβάντων, στον οποίο μεταβιβάζει fd, μάσκα μπιτ συμβάντα που δημιουργούνται και έναν δείκτη χρήστη σε void.

Εμφάνιση συνάρτησης reactor_run().

int reactor_run(const Reactor *reactor, time_t timeout) {
    int result;
    struct epoll_event *events;
    if ((events = calloc(MAX_EVENTS, sizeof(*events))) == NULL)
        abort();

    time_t start = time(NULL);

    while (true) {
        time_t passed = time(NULL) - start;
        int nfds =
            epoll_wait(reactor->epoll_fd, events, MAX_EVENTS, timeout - passed);

        switch (nfds) {
        // Ошибка
        case -1:
            perror("epoll_wait");
            result = -1;
            goto cleanup;
        // Время вышло
        case 0:
            result = 0;
            goto cleanup;
        // Успешная операция
        default:
            // Вызвать обработчиков событий
            for (int i = 0; i < nfds; i++) {
                int fd = events[i].data.fd;

                CallbackData *callback =
                    g_hash_table_lookup(reactor->table, &fd);
                callback->callback(callback->arg, fd, events[i].events);
            }
        }
    }

cleanup:
    free(events);
    return result;
}

Συνοψίζοντας, η αλυσίδα των κλήσεων συναρτήσεων στον κωδικό χρήστη θα έχει την ακόλουθη μορφή:

Γυμνός αντιδραστήρας I/O με πλήρη χαρακτηριστικά

Διακομιστής με ένα σπείρωμα

Προκειμένου να δοκιμάσουμε τον αντιδραστήρα I/O υπό υψηλό φορτίο, θα γράψουμε έναν απλό διακομιστή web 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 στο πρόγραμμα περιήγησης και δείτε τι περιμέναμε:

Γυμνός αντιδραστήρας 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 μπορεί να δημιουργηθεί σε ξεχωριστά νήματα, χρησιμοποιώντας έτσι όλους τους πυρήνες της CPU. Ας κάνουμε πράξη αυτήν την προσέγγιση:

Εμφάνιση 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 εκατομμύρια από τον αριθμό του γύρου, οπότε ας προσπαθήσουμε να το διορθώσουμε.

Πρώτα ας δούμε τα στατιστικά που δημιουργούνται Perf:

$ 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, αύξηση του αριθμού των επισκέψεων μετρητά, αυξάνουν 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 λεπτό από τον αριθμό των συνδέσεων:

Γυμνός αντιδραστήρας I/O με πλήρη χαρακτηριστικά

Βλέπουμε ότι μετά από μερικές εκατοντάδες συνδέσεις, ο αριθμός των επεξεργασμένων αιτημάτων και για τους δύο διακομιστές μειώνεται απότομα (στην έκδοση πολλαπλών νημάτων αυτό είναι πιο αισθητό). Σχετίζεται αυτό με την υλοποίηση στοίβας TCP/IP του Linux; Μη διστάσετε να γράψετε τις υποθέσεις σας σχετικά με αυτήν τη συμπεριφορά του γραφήματος και τις βελτιστοποιήσεις για επιλογές πολλαπλών νημάτων και μονών νημάτων στα σχόλια.

πως διάσημος στα σχόλια, αυτή η δοκιμή απόδοσης δεν δείχνει τη συμπεριφορά του αντιδραστήρα I/O κάτω από πραγματικά φορτία, επειδή σχεδόν πάντα ο διακομιστής αλληλεπιδρά με τη βάση δεδομένων, εξάγει αρχεία καταγραφής, χρησιμοποιεί κρυπτογραφία με TLS κ.λπ., με αποτέλεσμα το φορτίο να γίνεται ανομοιόμορφο (δυναμικό). Οι δοκιμές μαζί με στοιχεία τρίτων θα πραγματοποιηθούν στο άρθρο σχετικά με τον προβολέα I/O.

Μειονεκτήματα του αντιδραστήρα I/O

Πρέπει να καταλάβετε ότι ο αντιδραστήρας I/O δεν είναι χωρίς μειονεκτήματα, δηλαδή:

  • Η χρήση ενός αντιδραστήρα I/O σε περιβάλλον πολλαπλών νημάτων είναι κάπως πιο δύσκολη, γιατί θα πρέπει να διαχειριστείτε χειροκίνητα τις ροές.
  • Η πρακτική δείχνει ότι στις περισσότερες περιπτώσεις το φορτίο είναι ανομοιόμορφο, γεγονός που μπορεί να οδηγήσει σε καταγραφή ενός νήματος ενώ ένα άλλο είναι απασχολημένο με εργασία.
  • Εάν ένας χειριστής συμβάντων μπλοκάρει ένα νήμα, τότε ο ίδιος ο επιλογέας συστήματος θα αποκλείσει επίσης, γεγονός που μπορεί να οδηγήσει σε δυσεύρετα σφάλματα.

Λύνει αυτά τα προβλήματα I/O proactor, το οποίο συχνά έχει έναν χρονοπρογραμματιστή που κατανέμει ομοιόμορφα το φορτίο σε μια δεξαμενή νημάτων και έχει επίσης ένα πιο βολικό API. Θα μιλήσουμε για αυτό αργότερα, σε άλλο άρθρο μου.

Συμπέρασμα

Εδώ τελείωσε το ταξίδι μας από τη θεωρία κατευθείαν στην εξάτμιση του profiler.

Δεν πρέπει να μείνετε σε αυτό, επειδή υπάρχουν πολλές άλλες εξίσου ενδιαφέρουσες προσεγγίσεις για τη σύνταξη λογισμικού δικτύου με διαφορετικά επίπεδα ευκολίας και ταχύτητας. Ενδιαφέρον, κατά τη γνώμη μου, οι σύνδεσμοι δίνονται παρακάτω.

Τα λέμε σύντομα!

Ενδιαφέροντα έργα

Τι άλλο να διαβάσω;

Πηγή: www.habr.com

Προσθέστε ένα σχόλιο