การแนะนำ
ในบทความนี้ เราจะดูข้อมูลโดยละเอียดของเครื่องปฏิกรณ์ I/O และวิธีการทำงาน เขียนการใช้งานโดยใช้โค้ดน้อยกว่า 200 บรรทัด และทำให้เซิร์ฟเวอร์ HTTP แบบธรรมดาประมวลผลคำขอมากกว่า 40 ล้านคำขอ/นาที
คำปรารภ
- บทความนี้เขียนขึ้นเพื่อช่วยให้เข้าใจการทำงานของเครื่องปฏิกรณ์ I/O และเข้าใจถึงความเสี่ยงเมื่อใช้งาน
- จำเป็นต้องมีความรู้พื้นฐานจึงจะเข้าใจบทความได้
ภาษาซี และมีประสบการณ์ในการพัฒนาแอพพลิเคชั่นเครือข่ายบ้าง - รหัสทั้งหมดเขียนด้วยภาษา C อย่างเคร่งครัดตาม (ข้อควรระวัง: PDF ยาว)
ตามมาตรฐาน C11 สำหรับ Linux และมีให้ใช้งานบนGitHub .
จะทำทำไม?
ด้วยความนิยมที่เพิ่มขึ้นของอินเทอร์เน็ต เว็บเซิร์ฟเวอร์เริ่มจำเป็นต้องจัดการการเชื่อมต่อจำนวนมากพร้อมกัน ดังนั้นจึงมีการลองใช้สองวิธี: การบล็อก I/O บนเธรด OS จำนวนมาก และ I/O ที่ไม่บล็อกร่วมกับ ระบบแจ้งเตือนเหตุการณ์ หรือเรียกอีกอย่างว่า “ตัวเลือกระบบ” (
แนวทางแรกเกี่ยวข้องกับการสร้างเธรด OS ใหม่สำหรับการเชื่อมต่อขาเข้าแต่ละรายการ ข้อเสียของมันคือความสามารถในการปรับขนาดได้ไม่ดี: ระบบปฏิบัติการจะต้องใช้งานหลายอย่าง
ไฮไลท์เวอร์ชันที่แก้ไขแล้ว
วิธีที่สองใช้
ความแตกต่างระหว่างแนวทางเหล่านี้มีดังนี้:
- การบล็อกการดำเนินการ I/O ระงับ การไหลของผู้ใช้ จนกระทั่งจนกว่า OS จะเรียบร้อย
การจัดเรียงข้อมูล เข้ามาแพ็กเก็ต IP เป็นไบต์สตรีม (TCP การรับข้อมูล) หรือพื้นที่ว่างในบัฟเฟอร์การเขียนภายในภายในจะไม่เพียงพอสำหรับการส่งผ่านในภายหลังนิค (ส่งข้อมูล) - ตัวเลือกระบบ ล่วงเวลา แจ้งโปรแกรมว่า OS แล้ว แพ็กเก็ต IP ที่จัดเรียงข้อมูล (TCP, การรับข้อมูล) หรือพื้นที่เพียงพอในบัฟเฟอร์การเขียนภายใน แล้ว ที่มีอยู่ (ส่งข้อมูล)
โดยสรุป การสำรองเธรด OS สำหรับแต่ละ I/O เป็นการสิ้นเปลืองพลังในการประมวลผล เนื่องจากในความเป็นจริง เธรดไม่ได้ทำงานที่เป็นประโยชน์ (นี่คือที่มาของคำนี้
แบบจำลองเครื่องปฏิกรณ์ I/O
เครื่องปฏิกรณ์ 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
เรากำหนดไว้ ดังนั้นจึงป้องกันไม่ให้ผู้ใช้เปลี่ยนฟิลด์ของตนอย่างชัดเจน นี่คือหนึ่งในรูปแบบ
ฟังก์ชั่น 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 ภายใต้ภาระงานสูง เราจะเขียนเว็บเซิร์ฟเวอร์ HTTP แบบธรรมดาที่ตอบสนองต่อคำขอใดๆ ด้วยรูปภาพ
การอ้างอิงอย่างรวดเร็วถึงโปรโตคอล HTTP
สามารถใช้ HTTP ได้อย่างง่ายดาย
รูปแบบคำขอ
<КОМАНДА> <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
ในรูทโปรเจ็กต์) และเปิดเซิร์ฟเวอร์ที่เขียนเอง จากนั้นเปิด
การวัดประสิทธิภาพ
แสดงข้อมูลจำเพาะของรถของฉัน
$ 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 ล้านเท่านั้น ดังนั้นเรามาลองแก้ไขกัน
ก่อนอื่นเรามาดูสถิติที่สร้างขึ้นกันก่อน
$ 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
-march=native
, 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 นาทีกับจำนวนการเชื่อมต่อ:
เราจะเห็นว่าหลังจากการเชื่อมต่อสองสามร้อยครั้ง จำนวนคำขอที่ประมวลผลสำหรับเซิร์ฟเวอร์ทั้งสองลดลงอย่างรวดเร็ว (ในเวอร์ชันแบบมัลติเธรดจะสังเกตเห็นได้ชัดเจนยิ่งขึ้น) สิ่งนี้เกี่ยวข้องกับการใช้งานสแต็ก Linux TCP/IP หรือไม่ อย่าลังเลที่จะเขียนสมมติฐานของคุณเกี่ยวกับพฤติกรรมของกราฟและการเพิ่มประสิทธิภาพสำหรับตัวเลือกแบบมัลติเธรดและเธรดเดี่ยวในความคิดเห็น
ในขณะที่
ข้อเสียของเครื่องปฏิกรณ์ I/O
คุณต้องเข้าใจว่าเครื่องปฏิกรณ์ I/O ไม่ได้มีข้อเสียอยู่เลย กล่าวคือ:
- การใช้เครื่องปฏิกรณ์ I/O ในสภาพแวดล้อมแบบมัลติเธรดนั้นค่อนข้างยากกว่า เนื่องจาก คุณจะต้องจัดการโฟลว์ด้วยตนเอง
- การปฏิบัติแสดงให้เห็นว่าในกรณีส่วนใหญ่ โหลดจะไม่สม่ำเสมอ ซึ่งสามารถนำไปสู่การบันทึกเธรดหนึ่งในขณะที่อีกเธรดหนึ่งกำลังยุ่งอยู่กับงาน
- หากตัวจัดการเหตุการณ์ตัวหนึ่งบล็อกเธรด ตัวเลือกระบบเองก็จะบล็อกเช่นกัน ซึ่งอาจนำไปสู่ข้อผิดพลาดที่หายาก
แก้ไขปัญหาเหล่านี้
ข้อสรุป
นี่คือจุดที่การเดินทางของเราจากทฤษฎีไปสู่ท่อไอเสียของ Profiler สิ้นสุดลงแล้ว
คุณไม่ควรจมอยู่กับเรื่องนี้ เนื่องจากมีวิธีการอื่นๆ ที่น่าสนใจพอๆ กันในการเขียนซอฟต์แวร์เครือข่ายที่มีระดับความสะดวกและความเร็วต่างกัน ที่น่าสนใจในความคิดของฉันมีลิงค์อยู่ด้านล่าง
แล้วพบกันใหม่!
โครงการที่น่าสนใจ
ฉันควรอ่านอะไรอีก?
https://linux.die.net/man/7/socket https://stackoverflow.com/questions/1050222/what-is-the-difference-between-concurrency-and-parallelism http://www.kegel.com/c10k.html https://kernel.dk/io_uring.pdf https://aturon.github.io/blog/2016/09/07/futures-design/ https://tokio.rs/blog/2019-10-scheduler/ https://www.artima.com/articles/io_design_patterns.html https://habr.com/en/post/183832/
ที่มา: will.com