Dalam artikel ini, kita akan melihat selok-belok reaktor I/O dan cara ia berfungsi, menulis pelaksanaan dalam kurang daripada 200 baris kod dan membuat proses pelayan HTTP yang mudah melebihi 40 juta permintaan/min.
Perutusan
Artikel itu ditulis untuk membantu memahami fungsi reaktor I/O, dan oleh itu memahami risiko apabila menggunakannya.
Pengetahuan asas diperlukan untuk memahami artikel. bahasa C dan beberapa pengalaman dalam pembangunan aplikasi rangkaian.
Semua kod ditulis dalam bahasa C dengan ketat mengikut (awas: PDF panjang) kepada standard C11 untuk Linux dan tersedia pada GitHub.
Mengapa melakukannya?
Dengan populariti Internet yang semakin meningkat, pelayan web mula perlu mengendalikan sejumlah besar sambungan secara serentak, dan oleh itu dua pendekatan telah dicuba: menyekat I/O pada sejumlah besar utas OS dan I/O tidak menyekat dalam kombinasi dengan sistem pemberitahuan acara, juga dipanggil "pemilih sistem" (epoll/kqueue/IOCP/dan lain-lain).
Pendekatan pertama melibatkan mencipta benang OS baharu untuk setiap sambungan masuk. Kelemahannya ialah kebolehskalaan yang lemah: sistem pengendalian perlu melaksanakan banyak perkara peralihan konteks и panggilan sistem. Ia adalah operasi yang mahal dan boleh menyebabkan kekurangan RAM percuma dengan bilangan sambungan yang mengagumkan.
Versi yang diubah suai menyerlahkan bilangan benang tetap (kolam benang), dengan itu menghalang sistem daripada membatalkan pelaksanaan, tetapi pada masa yang sama memperkenalkan masalah baharu: jika kumpulan benang pada masa ini disekat oleh operasi baca lama, maka soket lain yang sudah dapat menerima data tidak akan dapat berbuat demikian.
Pendekatan kedua menggunakan sistem pemberitahuan acara (pemilih sistem) yang disediakan oleh OS. Artikel ini membincangkan jenis pemilih sistem yang paling biasa, berdasarkan makluman (peristiwa, pemberitahuan) tentang kesediaan untuk operasi I/O, bukannya pada pemberitahuan tentang penyiapan mereka. Contoh ringkas penggunaannya boleh diwakili oleh gambarajah blok berikut:
Perbezaan antara pendekatan ini adalah seperti berikut:
Menyekat operasi I/O menangguhkan aliran pengguna sehinggasehingga OS betul defragmen masuk paket IP kepada aliran bait (TCP, menerima data) atau tidak akan ada ruang yang mencukupi dalam penimbal tulis dalaman untuk penghantaran berikutnya melalui NIC (menghantar data).
Pemilih sistem lebih masa memberitahu program bahawa OS sudah paket IP defragmented (TCP, penerimaan data) atau ruang yang mencukupi dalam penimbal tulis dalaman sudah tersedia (menghantar data).
Kesimpulannya, menempah benang OS untuk setiap I/O adalah satu pembaziran kuasa pengkomputeran, kerana pada hakikatnya, benang tidak melakukan kerja yang berguna (di sinilah istilah itu berasal "gangguan perisian"). Pemilih sistem menyelesaikan masalah ini, membenarkan program pengguna menggunakan sumber CPU dengan lebih menjimatkan.
Model reaktor I/O
Reaktor I/O bertindak sebagai lapisan antara pemilih sistem dan kod pengguna. Prinsip operasinya diterangkan oleh gambarajah blok berikut:
Izinkan saya mengingatkan anda bahawa peristiwa ialah pemberitahuan bahawa soket tertentu dapat melakukan operasi I/O yang tidak menyekat.
Pengendali peristiwa ialah fungsi yang dipanggil oleh reaktor I/O apabila peristiwa diterima, yang kemudiannya melakukan operasi I/O tanpa menyekat.
Adalah penting untuk ambil perhatian bahawa reaktor I/O mengikut takrifan berbenang tunggal, tetapi tiada apa-apa yang menghalang konsep daripada digunakan dalam persekitaran berbilang benang pada nisbah 1 benang: 1 reaktor, dengan itu mengitar semula semua teras CPU.
Реализация
Kami akan meletakkan antara muka awam dalam fail reactor.h, dan pelaksanaan - dalam reactor.c. reactor.h akan terdiri daripada pengumuman berikut:
Tunjukkan pengisytiharan dalam reaktor.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);
Struktur reaktor I/O terdiri daripada deskriptor fail pemilih epoll и jadual hashGHashTable, yang memetakan setiap soket ke CallbackData (struktur pengendali acara dan hujah pengguna untuknya).
Sila ambil perhatian bahawa kami telah mendayakan keupayaan untuk mengendalikan jenis tidak lengkap mengikut indeks. DALAM reactor.h kami mengisytiharkan struktur reactor, dan dalam reactor.c kami mentakrifkannya, dengan itu menghalang pengguna daripada mengubah medannya secara eksplisit. Ini adalah salah satu corak menyembunyikan data, yang secara ringkas sesuai dengan semantik C.
Fungsi reactor_register, reactor_deregister и reactor_reregister kemas kini senarai soket minat dan pengendali acara yang sepadan dalam pemilih sistem dan jadual cincang.
Selepas reaktor I/O telah memintas peristiwa dengan deskriptor fd, ia memanggil pengendali acara yang sepadan, yang dilaluinya fd, sedikit topeng peristiwa yang dijana dan penunjuk pengguna kepada void.
Tunjukkan fungsi 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;
}
Untuk meringkaskan, rangkaian panggilan fungsi dalam kod pengguna akan mengambil bentuk berikut:
Pelayan berulir tunggal
Untuk menguji reaktor I/O di bawah beban tinggi, kami akan menulis pelayan web HTTP mudah yang bertindak balas kepada sebarang permintaan dengan imej.
Rujukan pantas kepada protokol HTTP
HTTP - ini adalah protokol peringkat permohonan, digunakan terutamanya untuk interaksi pelayan-pelayar.
HTTP boleh digunakan dengan mudah pengangkutan protokol TCP, menghantar dan menerima mesej dalam format yang ditentukan spesifikasi.
CRLF ialah urutan dua aksara: r и n, memisahkan baris pertama permintaan, pengepala dan data.
<КОМАНДА> - satu daripada CONNECT, DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT, TRACE. Penyemak imbas akan menghantar arahan kepada pelayan kami GET, yang bermaksud "Hantar saya kandungan fail."
<URI> - pengecam sumber seragam. Sebagai contoh, jika URI = /index.html, kemudian pelanggan meminta halaman utama tapak.
<ВЕРСИЯ HTTP> — versi protokol HTTP dalam format HTTP/X.Y. Versi yang paling biasa digunakan hari ini ialah HTTP/1.1.
<ЗАГОЛОВОК N> ialah pasangan nilai kunci dalam format <КЛЮЧ>: <ЗНАЧЕНИЕ>, dihantar ke pelayan untuk analisis lanjut.
<ДАННЫЕ> — data yang diperlukan oleh pelayan untuk melaksanakan operasi. Selalunya ia mudah JSON atau mana-mana format lain.
<КОД СТАТУСА> ialah nombor yang mewakili hasil operasi. Pelayan kami akan sentiasa mengembalikan status 200 (operasi yang berjaya).
<ОПИСАНИЕ СТАТУСА> — perwakilan rentetan kod status. Untuk kod status 200 ini adalah OK.
<ЗАГОЛОВОК N> — pengepala format yang sama seperti dalam permintaan. Kami akan mengembalikan tajuk Content-Length (saiz fail) dan Content-Type: text/html (kembali jenis data).
<ДАННЫЕ> — data yang diminta oleh pengguna. Dalam kes kami, ini ialah laluan ke imej dalam HTML.
fail http_server.c (pelayan berulir tunggal) termasuk fail common.h, yang mengandungi prototaip fungsi berikut:
Tunjukkan prototaip fungsi yang sama.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);
Makro berfungsi juga diterangkan SAFE_CALL() dan fungsi ditakrifkan fail(). Makro membandingkan nilai ungkapan dengan ralat, dan jika keadaan itu benar, memanggil fungsi tersebut fail():
#define SAFE_CALL(call, error)
do {
if ((call) == error) {
fail("%s", #call);
}
} while (false)
Fungsi fail() mencetak argumen yang diluluskan ke terminal (seperti printf()) dan menamatkan program dengan kod EXIT_FAILURE:
Fungsi new_server() mengembalikan deskriptor fail soket "pelayan" yang dibuat oleh panggilan sistem socket(), bind() и listen() dan mampu menerima sambungan masuk dalam mod tidak menyekat.
Ambil perhatian bahawa soket pada mulanya dicipta dalam mod tidak menyekat menggunakan bendera SOCK_NONBLOCKsupaya dalam fungsi on_accept() (baca lebih lanjut) panggilan sistem accept() tidak menghentikan pelaksanaan benang.
Jika reuse_port sama dengan true, maka fungsi ini akan mengkonfigurasi soket dengan pilihan SO_REUSEPORT melalui setsockopt()untuk menggunakan port yang sama dalam persekitaran berbilang benang (lihat bahagian "Pelayan berbilang benang").
Pengurus acara on_accept() dipanggil selepas OS menjana acara EPOLLIN, dalam kes ini bermakna sambungan baharu boleh diterima. on_accept() menerima sambungan baharu, menukarnya kepada mod tidak menyekat dan mendaftar dengan pengendali acara on_recv() dalam reaktor I/O.
Pengurus acara on_recv() dipanggil selepas OS menjana acara EPOLLIN, dalam kes ini bermakna sambungan didaftarkan on_accept(), sedia menerima data.
on_recv() membaca data daripada sambungan sehingga permintaan HTTP diterima sepenuhnya, kemudian ia mendaftarkan pengendali on_send() untuk menghantar respons HTTP. Jika pelanggan memutuskan sambungan, soket dibatalkan pendaftaran dan ditutup menggunakan close().
Tunjukkan fungsi 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);
}
}
Pengurus acara on_send() dipanggil selepas OS menjana acara EPOLLOUT, bermakna sambungan itu didaftarkan on_recv(), sedia untuk menghantar data. Fungsi ini menghantar respons HTTP yang mengandungi HTML dengan imej kepada klien dan kemudian menukar pengendali acara kembali kepada on_recv().
Dan akhirnya, dalam fail http_server.c, dalam fungsi main() kami mencipta reaktor I/O menggunakan reactor_new(), cipta soket pelayan dan daftarkannya, mulakan reaktor menggunakan reactor_run() tepat satu minit, dan kemudian kami mengeluarkan sumber dan keluar dari program.
Mari periksa sama ada semuanya berfungsi seperti yang diharapkan. Menyusun (chmod a+x compile.sh && ./compile.sh dalam akar projek) dan lancarkan pelayan yang ditulis sendiri, buka http://127.0.0.1:18470 dalam penyemak imbas dan lihat apa yang kami jangkakan:
Mari kita ukur prestasi pelayan satu benang. Mari buka dua terminal: dalam satu kita akan jalankan ./http_server, dalam berbeza - celaka. Selepas seminit, statistik berikut akan dipaparkan dalam terminal kedua:
Pelayan satu benang kami dapat memproses lebih 11 juta permintaan seminit yang berasal daripada 100 sambungan. Bukan hasil yang buruk, tetapi bolehkah ia diperbaiki?
Pelayan berbilang benang
Seperti yang dinyatakan di atas, reaktor I/O boleh dibuat dalam benang berasingan, dengan itu menggunakan semua teras CPU. Mari kita praktikkan pendekatan ini:
Sila ambil perhatian bahawa hujah fungsi new_server() peguam bela true. Ini bermakna kami memberikan pilihan kepada soket pelayan SO_REUSEPORTuntuk menggunakannya dalam persekitaran berbilang benang. Anda boleh membaca butiran lanjut di sini.
Larian kedua
Sekarang mari kita ukur prestasi pelayan berbilang benang:
Bilangan permintaan yang diproses dalam 1 minit meningkat sebanyak ~3.28 kali! Tetapi kami hanya kurang ~XNUMX juta daripada nombor pusingan, jadi mari kita cuba membetulkannya.
Mula-mula mari kita lihat statistik yang dihasilkan sempurna:
Menggunakan CPU Affinity, kompilasi dengan -march=native, PGO, peningkatan dalam bilangan hits cache, meningkat MAX_EVENTS dan gunakan EPOLLET tidak memberikan peningkatan yang ketara dalam prestasi. Tetapi apa yang berlaku jika anda meningkatkan bilangan sambungan serentak?
Hasil yang diingini diperolehi, dan dengannya graf menarik yang menunjukkan pergantungan bilangan permintaan yang diproses dalam 1 minit pada bilangan sambungan:
Kami melihat bahawa selepas beberapa ratus sambungan, bilangan permintaan yang diproses untuk kedua-dua pelayan menurun secara mendadak (dalam versi berbilang benang ini lebih ketara). Adakah ini berkaitan dengan pelaksanaan tindanan TCP/IP Linux? Jangan ragu untuk menulis andaian anda tentang tingkah laku graf ini dan pengoptimuman untuk pilihan berbilang benang dan satu benang dalam ulasan.
bagaimana tercatat dalam ulasan, ujian prestasi ini tidak menunjukkan kelakuan reaktor I/O di bawah beban sebenar, kerana hampir selalu pelayan berinteraksi dengan pangkalan data, mengeluarkan log, menggunakan kriptografi dengan TLS dan lain-lain, akibatnya beban menjadi tidak seragam (dinamik). Ujian bersama dengan komponen pihak ketiga akan dijalankan dalam artikel tentang proaktor I/O.
Kelemahan reaktor I/O
Anda perlu memahami bahawa reaktor I/O bukan tanpa kelemahannya, iaitu:
Menggunakan reaktor I/O dalam persekitaran berbilang benang agak lebih sukar, kerana anda perlu menguruskan aliran secara manual.
Amalan menunjukkan bahawa dalam kebanyakan kes beban adalah tidak seragam, yang boleh membawa kepada satu pembalakan benang manakala yang lain sibuk dengan kerja.
Jika satu pengendali acara menyekat urutan, pemilih sistem itu sendiri juga akan menyekat, yang boleh membawa kepada pepijat yang sukar ditemui.
Menyelesaikan masalah ini proaktor I/O, yang selalunya mempunyai penjadual yang mengagihkan beban secara sama rata kepada kumpulan benang, dan juga mempunyai API yang lebih mudah. Kami akan membincangkannya kemudian, dalam artikel saya yang lain.
Kesimpulan
Di sinilah perjalanan kami dari teori terus ke ekzos profiler telah berakhir.
Anda tidak sepatutnya memikirkan perkara ini, kerana terdapat banyak pendekatan lain yang sama menarik untuk menulis perisian rangkaian dengan tahap kemudahan dan kelajuan yang berbeza. Menarik, pada pendapat saya, pautan diberikan di bawah.