เครื่องปฏิกรณ์ Bare-C I/O ที่มีคุณลักษณะครบถ้วน

เครื่องปฏิกรณ์ Bare-C I/O ที่มีคุณลักษณะครบถ้วน

การแนะนำ

เครื่องปฏิกรณ์ I/O (เกลียวเดียว ห่วงเหตุการณ์) เป็นรูปแบบสำหรับการเขียนซอฟต์แวร์ที่มีโหลดสูง ซึ่งใช้ในโซลูชันยอดนิยมมากมาย:

ในบทความนี้ เราจะดูข้อมูลโดยละเอียดของเครื่องปฏิกรณ์ I/O และวิธีการทำงาน เขียนการใช้งานโดยใช้โค้ดน้อยกว่า 200 บรรทัด และทำให้เซิร์ฟเวอร์ HTTP แบบธรรมดาประมวลผลคำขอมากกว่า 40 ล้านคำขอ/นาที

คำปรารภ

  • บทความนี้เขียนขึ้นเพื่อช่วยให้เข้าใจการทำงานของเครื่องปฏิกรณ์ I/O และเข้าใจถึงความเสี่ยงเมื่อใช้งาน
  • จำเป็นต้องมีความรู้พื้นฐานจึงจะเข้าใจบทความได้ ภาษาซี และมีประสบการณ์ในการพัฒนาแอพพลิเคชั่นเครือข่ายบ้าง
  • รหัสทั้งหมดเขียนด้วยภาษา C อย่างเคร่งครัดตาม (ข้อควรระวัง: PDF ยาว) ตามมาตรฐาน C11 สำหรับ Linux และมีให้ใช้งานบน GitHub.

จะทำทำไม?

ด้วยความนิยมที่เพิ่มขึ้นของอินเทอร์เน็ต เว็บเซิร์ฟเวอร์เริ่มจำเป็นต้องจัดการการเชื่อมต่อจำนวนมากพร้อมกัน ดังนั้นจึงมีการลองใช้สองวิธี: การบล็อก I/O บนเธรด OS จำนวนมาก และ I/O ที่ไม่บล็อกร่วมกับ ระบบแจ้งเตือนเหตุการณ์ หรือเรียกอีกอย่างว่า “ตัวเลือกระบบ” (โพล/คิว/ไอโอซีพี/ฯลฯ)

แนวทางแรกเกี่ยวข้องกับการสร้างเธรด OS ใหม่สำหรับการเชื่อมต่อขาเข้าแต่ละรายการ ข้อเสียของมันคือความสามารถในการปรับขนาดได้ไม่ดี: ระบบปฏิบัติการจะต้องใช้งานหลายอย่าง การเปลี่ยนบริบท и การโทรของระบบ. เป็นการดำเนินการที่มีราคาแพงและอาจนำไปสู่การขาด RAM ว่างพร้อมจำนวนการเชื่อมต่อที่น่าประทับใจ

ไฮไลท์เวอร์ชันที่แก้ไขแล้ว จำนวนเธรดคงที่ (เธรดพูล) จึงป้องกันไม่ให้ระบบยกเลิกการดำเนินการ แต่ในขณะเดียวกันก็ทำให้เกิดปัญหาใหม่: หากเธรดพูลถูกบล็อกโดยการดำเนินการอ่านแบบยาว ซ็อกเก็ตอื่น ๆ ที่สามารถรับข้อมูลอยู่แล้วจะไม่สามารถทำได้ ทำเช่นนั้น

วิธีที่สองใช้ ระบบแจ้งเตือนเหตุการณ์ (ตัวเลือกระบบ) จัดทำโดยระบบปฏิบัติการ บทความนี้จะกล่าวถึงตัวเลือกระบบประเภทที่พบบ่อยที่สุด โดยอิงตามการแจ้งเตือน (เหตุการณ์ การแจ้งเตือน) เกี่ยวกับความพร้อมสำหรับการดำเนินการ I/O แทนที่จะเป็นบน การแจ้งเตือนเกี่ยวกับความสำเร็จของพวกเขา. ตัวอย่างการใช้งานแบบง่ายสามารถแสดงได้ด้วยแผนภาพบล็อกต่อไปนี้:

เครื่องปฏิกรณ์ Bare-C I/O ที่มีคุณลักษณะครบถ้วน

ความแตกต่างระหว่างแนวทางเหล่านี้มีดังนี้:

  • การบล็อกการดำเนินการ I/O ระงับ การไหลของผู้ใช้ จนกระทั่งจนกว่า OS จะเรียบร้อย การจัดเรียงข้อมูล เข้ามา แพ็กเก็ต IP เป็นไบต์สตรีม (TCPการรับข้อมูล) หรือพื้นที่ว่างในบัฟเฟอร์การเขียนภายในภายในจะไม่เพียงพอสำหรับการส่งผ่านในภายหลัง นิค (ส่งข้อมูล)
  • ตัวเลือกระบบ ล่วงเวลา แจ้งโปรแกรมว่า OS แล้ว แพ็กเก็ต IP ที่จัดเรียงข้อมูล (TCP, การรับข้อมูล) หรือพื้นที่เพียงพอในบัฟเฟอร์การเขียนภายใน แล้ว ที่มีอยู่ (ส่งข้อมูล)

โดยสรุป การสำรองเธรด OS สำหรับแต่ละ I/O เป็นการสิ้นเปลืองพลังในการประมวลผล เนื่องจากในความเป็นจริง เธรดไม่ได้ทำงานที่เป็นประโยชน์ (นี่คือที่มาของคำนี้ "ซอฟต์แวร์ขัดจังหวะ"). ตัวเลือกระบบช่วยแก้ปัญหานี้ ทำให้โปรแกรมผู้ใช้ใช้ทรัพยากร CPU ในเชิงเศรษฐกิจได้มากขึ้น

แบบจำลองเครื่องปฏิกรณ์ I/O

เครื่องปฏิกรณ์ I/O ทำหน้าที่เป็นชั้นระหว่างตัวเลือกระบบและรหัสผู้ใช้ หลักการทำงานอธิบายไว้ในแผนภาพบล็อกต่อไปนี้:

เครื่องปฏิกรณ์ Bare-C I/O ที่มีคุณลักษณะครบถ้วน

  • ฉันขอเตือนคุณว่าเหตุการณ์คือการแจ้งเตือนว่าซ็อกเก็ตบางตัวสามารถดำเนินการ I/O แบบไม่บล็อกได้
  • ตัวจัดการเหตุการณ์คือฟังก์ชันที่เรียกโดยเครื่องปฏิกรณ์ I/O เมื่อได้รับเหตุการณ์ ซึ่งจะดำเนินการดำเนินการ I/O แบบไม่ปิดกั้น

สิ่งสำคัญที่ควรทราบก็คือ เครื่องปฏิกรณ์ I/O เป็นแบบเธรดเดียวตามคำจำกัดความ แต่ไม่มีอะไรหยุดยั้งแนวคิดนี้จากการใช้ในสภาพแวดล้อมแบบมัลติเธรดในอัตราส่วน 1 เธรด: 1 เครื่องปฏิกรณ์ ดังนั้นจึงรีไซเคิลแกน CPU ทั้งหมด

การดำเนินงาน

เราจะวางอินเทอร์เฟซสาธารณะไว้ในไฟล์ reactor.hและการนำไปปฏิบัติ-ใน reactor.c. reactor.h จะประกอบด้วยประกาศดังต่อไปนี้

แสดงการประกาศใน reactor.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;

โปรดทราบว่าเราได้เปิดใช้งานความสามารถในการจัดการแล้ว ประเภทที่ไม่สมบูรณ์ ตามดัชนี ใน 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;
}

โดยสรุป สายการเรียกใช้ฟังก์ชันในโค้ดผู้ใช้จะอยู่ในรูปแบบต่อไปนี้:

เครื่องปฏิกรณ์ Bare-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ซึ่งมีฟังก์ชันต้นแบบดังต่อไปนี้:

แสดงฟังก์ชันต้นแบบใน common.h

/*
 * Обработчик событий, который вызовется после того, как сокет будет
 * готов принять новое соединение.
 */
static void on_accept(void *arg, int fd, uint32_t events);

/*
 * Обработчик событий, который вызовется после того, как сокет будет
 * готов отправить HTTP ответ.
 */
static void on_send(void *arg, int fd, uint32_t events);

/*
 * Обработчик событий, который вызовется после того, как сокет будет
 * готов принять часть HTTP запроса.
 */
static void on_recv(void *arg, int fd, uint32_t events);

/*
 * Переводит входящее соединение в неблокирующий режим.
 */
static void set_nonblocking(int fd);

/*
 * Печатает переданные аргументы в stderr и выходит из процесса с
 * кодом `EXIT_FAILURE`.
 */
static noreturn void fail(const char *format, ...);

/*
 * Возвращает файловый дескриптор сокета, способного принимать новые
 * TCP соединения.
 */
static int new_server(bool reuse_port);

มีการอธิบายแมโครการทำงานด้วย SAFE_CALL() และฟังก์ชันถูกกำหนดไว้แล้ว fail(). แมโครจะเปรียบเทียบค่าของนิพจน์กับข้อผิดพลาด และหากเงื่อนไขเป็นจริง จะเรียกใช้ฟังก์ชัน fail():

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

ฟังก์ชัน fail() พิมพ์อาร์กิวเมนต์ที่ส่งผ่านไปยังเทอร์มินัล (like 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 ในเบราว์เซอร์และดูสิ่งที่เราคาดหวัง:

เครื่องปฏิกรณ์ Bare-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. หลังจากผ่านไปหนึ่งนาที สถิติต่อไปนี้จะแสดงในเทอร์มินัลที่สอง:

$ 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 นาทีกับจำนวนการเชื่อมต่อ:

เครื่องปฏิกรณ์ Bare-C I/O ที่มีคุณลักษณะครบถ้วน

เราจะเห็นว่าหลังจากการเชื่อมต่อสองสามร้อยครั้ง จำนวนคำขอที่ประมวลผลสำหรับเซิร์ฟเวอร์ทั้งสองลดลงอย่างรวดเร็ว (ในเวอร์ชันแบบมัลติเธรดจะสังเกตเห็นได้ชัดเจนยิ่งขึ้น) สิ่งนี้เกี่ยวข้องกับการใช้งานสแต็ก Linux TCP/IP หรือไม่ อย่าลังเลที่จะเขียนสมมติฐานของคุณเกี่ยวกับพฤติกรรมของกราฟและการเพิ่มประสิทธิภาพสำหรับตัวเลือกแบบมัลติเธรดและเธรดเดี่ยวในความคิดเห็น

ในขณะที่ ข้อสังเกต ในความคิดเห็น การทดสอบประสิทธิภาพนี้ไม่ได้แสดงพฤติกรรมของเครื่องปฏิกรณ์ I/O ภายใต้โหลดจริง เนื่องจากเซิร์ฟเวอร์จะโต้ตอบกับฐานข้อมูลเกือบทุกครั้ง บันทึกผลลัพธ์ และใช้การเข้ารหัสด้วย TLS ฯลฯ ซึ่งส่งผลให้โหลดไม่สม่ำเสมอ (ไดนามิก) การทดสอบร่วมกับส่วนประกอบของบริษัทอื่นจะดำเนินการในบทความเกี่ยวกับ I/O proactor

ข้อเสียของเครื่องปฏิกรณ์ I/O

คุณต้องเข้าใจว่าเครื่องปฏิกรณ์ I/O ไม่ได้มีข้อเสียอยู่เลย กล่าวคือ:

  • การใช้เครื่องปฏิกรณ์ I/O ในสภาพแวดล้อมแบบมัลติเธรดนั้นค่อนข้างยากกว่า เนื่องจาก คุณจะต้องจัดการโฟลว์ด้วยตนเอง
  • การปฏิบัติแสดงให้เห็นว่าในกรณีส่วนใหญ่ โหลดจะไม่สม่ำเสมอ ซึ่งสามารถนำไปสู่การบันทึกเธรดหนึ่งในขณะที่อีกเธรดหนึ่งกำลังยุ่งอยู่กับงาน
  • หากตัวจัดการเหตุการณ์ตัวหนึ่งบล็อกเธรด ตัวเลือกระบบเองก็จะบล็อกเช่นกัน ซึ่งอาจนำไปสู่ข้อผิดพลาดที่หายาก

แก้ไขปัญหาเหล่านี้ โปรแอคเตอร์ I/Oซึ่งมักจะมีตัวกำหนดตารางเวลาที่กระจายโหลดไปยังกลุ่มเธรดอย่างสม่ำเสมอและยังมี API ที่สะดวกกว่าอีกด้วย เราจะพูดถึงเรื่องนี้ในภายหลังในบทความอื่นของฉัน

ข้อสรุป

นี่คือจุดที่การเดินทางของเราจากทฤษฎีไปสู่ท่อไอเสียของ Profiler สิ้นสุดลงแล้ว

คุณไม่ควรจมอยู่กับเรื่องนี้ เนื่องจากมีวิธีการอื่นๆ ที่น่าสนใจพอๆ กันในการเขียนซอฟต์แวร์เครือข่ายที่มีระดับความสะดวกและความเร็วต่างกัน ที่น่าสนใจในความคิดของฉันมีลิงค์อยู่ด้านล่าง

แล้วพบกันใหม่!

โครงการที่น่าสนใจ

ฉันควรอ่านอะไรอีก?

ที่มา: will.com

เพิ่มความคิดเห็น