Pada artikel ini, kita akan melihat seluk beluk reaktor I/O dan cara kerjanya, menulis implementasi dalam kurang dari 200 baris kode, dan membuat server HTTP sederhana memproses lebih dari 40 juta permintaan/menit.
kata pengantar
Artikel ini ditulis untuk membantu memahami fungsi reaktor I/O, dan oleh karena itu memahami risiko saat menggunakannya.
Pengetahuan tentang dasar-dasar diperlukan untuk memahami artikel. bahasa C dan beberapa pengalaman dalam pengembangan aplikasi jaringan.
Semua kode ditulis dalam bahasa C secara ketat sesuai dengan (hati-hati: PDF panjang) ke standar C11 untuk Linux dan tersedia di GitHub.
Mengapa melakukannya?
Dengan semakin populernya Internet, server web mulai perlu menangani sejumlah besar koneksi secara bersamaan, dan oleh karena itu ada dua pendekatan yang dicoba: memblokir I/O pada sejumlah besar thread OS dan I/O non-pemblokiran yang dikombinasikan dengan sistem pemberitahuan peristiwa, juga disebut "pemilih sistem" (epolling/antrian/IOCP/dll).
Pendekatan pertama melibatkan pembuatan thread OS baru untuk setiap koneksi masuk. Kerugiannya adalah skalabilitas yang buruk: sistem operasi harus mengimplementasikan banyak hal transisi konteks и panggilan sistem. Ini adalah operasi yang mahal dan dapat menyebabkan kurangnya RAM kosong dengan jumlah koneksi yang mengesankan.
Sorotan versi modifikasi jumlah thread yang tetap (kumpulan thread), sehingga mencegah sistem membatalkan eksekusi, tetapi pada saat yang sama menimbulkan masalah baru: jika kumpulan thread saat ini diblokir oleh operasi pembacaan yang lama, maka soket lain yang sudah dapat menerima data tidak akan dapat untuk lakukan itu.
Pendekatan kedua menggunakan sistem pemberitahuan acara (pemilih sistem) yang disediakan oleh OS. Artikel ini membahas jenis pemilih sistem yang paling umum, berdasarkan peringatan (peristiwa, pemberitahuan) tentang kesiapan untuk operasi I/O, bukan pada pemberitahuan tentang penyelesaiannya. Contoh sederhana penggunaannya dapat diwakili oleh diagram blok berikut:
Perbedaan antara pendekatan-pendekatan ini adalah sebagai berikut:
Memblokir operasi I/O menskors aliran pengguna sampaisampai OS benar defragment masuk paket IP ke aliran byte (TCP, menerima data) atau tidak akan tersedia cukup ruang di buffer tulis internal untuk pengiriman selanjutnya melalui NIC (mengirimkan data).
Pemilih sistem lembur memberi tahu program bahwa OS sudah paket IP yang didefragmentasi (TCP, penerimaan data) atau ruang yang cukup di buffer tulis internal sudah tersedia (mengirim data).
Singkatnya, memesan thread OS untuk setiap I/O adalah pemborosan daya komputasi, karena pada kenyataannya, thread tersebut tidak melakukan pekerjaan yang berguna (dari sinilah istilah tersebut berasal. "interupsi perangkat lunak"). Pemilih sistem memecahkan masalah ini, memungkinkan program pengguna menggunakan sumber daya CPU dengan lebih hemat.
Model reaktor I/O
Reaktor I/O bertindak sebagai lapisan antara pemilih sistem dan kode pengguna. Prinsip pengoperasiannya dijelaskan oleh diagram blok berikut:
Izinkan saya mengingatkan Anda bahwa suatu peristiwa adalah pemberitahuan bahwa soket tertentu mampu melakukan operasi I/O non-pemblokiran.
Pengendali peristiwa (event handler) adalah fungsi yang dipanggil oleh reaktor I/O ketika suatu peristiwa diterima, yang kemudian melakukan operasi I/O non-pemblokiran.
Penting untuk dicatat bahwa reaktor I/O menurut definisinya adalah reaktor berulir tunggal, namun tidak ada yang menghentikan konsep tersebut untuk digunakan dalam lingkungan multi-utas dengan rasio reaktor 1 utas: 1, sehingga mendaur ulang semua inti CPU.
Implementasi
Kami akan menempatkan antarmuka publik dalam sebuah file reactor.h, dan implementasi - masuk reactor.c. reactor.h akan terdiri dari pengumuman berikut:
Tampilkan deklarasi di 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 dari deskriptor file pemilih epolling и tabel hashGHashTable, yang memetakan setiap soket ke dalamnya CallbackData (struktur pengendali kejadian dan argumen pengguna untuk itu).
Harap dicatat bahwa kami telah mengaktifkan kemampuan untuk menangani tipe tidak lengkap menurut indeks. DI DALAM reactor.h kami mendeklarasikan strukturnya reactor, dan dalam reactor.c kami mendefinisikannya, sehingga mencegah pengguna mengubah bidangnya secara eksplisit. Ini adalah salah satu polanya menyembunyikan data, yang secara ringkas cocok dengan semantik C.
Fungsi reactor_register, reactor_deregister и reactor_reregister perbarui daftar soket yang diminati dan event handler terkait di pemilih sistem dan tabel hash.
Setelah reaktor I/O mencegat peristiwa dengan deskriptor fd, ia memanggil event handler terkait, yang diteruskannya fd, topeng kecil peristiwa yang dihasilkan dan penunjuk pengguna ke void.
Tampilkan fungsi reaktor_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;
}
Ringkasnya, rangkaian pemanggilan fungsi dalam kode pengguna akan mengambil bentuk berikut:
Server berulir tunggal
Untuk menguji reaktor I/O pada beban tinggi, kami akan menulis server web HTTP sederhana yang merespons permintaan apa pun dengan gambar.
Referensi cepat ke protokol HTTP
HTTP - ini adalah protokolnya tingkat aplikasi, terutama digunakan untuk interaksi server-browser.
HTTP dapat dengan mudah digunakan mengangkut protokol TCP, mengirim dan menerima pesan dalam format yang ditentukan spesifikasi.
CRLF adalah urutan dua karakter: r и n, memisahkan baris pertama permintaan, header, dan data.
<КОМАНДА> - satu dari CONNECT, DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT, TRACE. Browser akan mengirimkan perintah ke server kami GET, artinya "Kirimkan saya isi file tersebut."
<КОД СТАТУСА> adalah angka yang mewakili hasil operasi. Server kami akan selalu mengembalikan status 200 (operasi berhasil).
<ОПИСАНИЕ СТАТУСА> — representasi string dari kode status. Untuk kode status 200 ini OK.
<ЗАГОЛОВОК N> — header dengan format yang sama seperti pada permintaan. Kami akan mengembalikan gelarnya Content-Length (ukuran file) dan Content-Type: text/html (mengembalikan tipe data).
<ДАННЫЕ> — data yang diminta oleh pengguna. Dalam kasus kami, ini adalah jalur menuju gambar masuk HTML.
berkas http_server.c (server berulir tunggal) termasuk file common.h, yang berisi prototipe fungsi berikut:
Tampilkan prototipe 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 fungsional juga dijelaskan SAFE_CALL() dan fungsinya ditentukan fail(). Makro membandingkan nilai ekspresi dengan kesalahan, dan jika kondisinya benar, memanggil fungsinya fail():
#define SAFE_CALL(call, error)
do {
if ((call) == error) {
fail("%s", #call);
}
} while (false)
Fungsi fail() mencetak argumen yang diteruskan ke terminal (seperti printf()) dan mengakhiri program dengan kode EXIT_FAILURE:
Fungsi new_server() mengembalikan deskriptor file dari soket "server" yang dibuat oleh panggilan sistem socket(), bind() и listen() dan mampu menerima koneksi masuk dalam mode non-pemblokiran.
Perhatikan bahwa soket awalnya dibuat dalam mode non-pemblokiran menggunakan flag SOCK_NONBLOCKsehingga dalam fungsinya on_accept() (baca lebih lanjut) panggilan sistem accept() tidak menghentikan eksekusi thread.
Jika reuse_port sama dengan true, maka fungsi ini akan mengkonfigurasi soket dengan opsi SO_REUSEPORT melalui setsockopt()untuk menggunakan port yang sama di lingkungan multi-thread (lihat bagian “Server multi-thread”).
Penangan Acara on_accept() dipanggil setelah OS menghasilkan suatu peristiwa EPOLLIN, dalam hal ini berarti koneksi baru dapat diterima. on_accept() menerima koneksi baru, mengalihkannya ke mode non-pemblokiran dan mendaftar dengan event handler on_recv() dalam reaktor I/O.
Penangan Acara on_recv() dipanggil setelah OS menghasilkan suatu peristiwa EPOLLIN, dalam hal ini berarti koneksi telah terdaftar on_accept(), siap menerima data.
on_recv() membaca data dari koneksi hingga permintaan HTTP diterima sepenuhnya, lalu mendaftarkan penangan on_send() untuk mengirim respons HTTP. Jika klien memutus koneksi, soket dibatalkan pendaftarannya dan ditutup menggunakan close().
Tampilkan 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);
}
}
Penangan Acara on_send() dipanggil setelah OS menghasilkan suatu peristiwa EPOLLOUT, artinya koneksi telah terdaftar on_recv(), siap mengirim data. Fungsi ini mengirimkan respon HTTP yang berisi HTML dengan gambar ke klien dan kemudian mengubah event handler kembali on_recv().
Dan terakhir, di file http_server.c, dalam fungsi main() kami membuat reaktor I/O menggunakan reactor_new(), buat soket server dan daftarkan, mulai reaktor menggunakan reactor_run() selama tepat satu menit, lalu kami melepaskan sumber daya dan keluar dari program.
Mari kita periksa apakah semuanya berfungsi seperti yang diharapkan. Kompilasi (chmod a+x compile.sh && ./compile.sh di root proyek) dan luncurkan server yang ditulis sendiri, buka http://127.0.0.1:18470 di browser dan lihat apa yang kami harapkan:
Mari kita mengukur kinerja server single-threaded. Mari kita buka dua terminal: di satu terminal kita akan menjalankannya ./http_server, di tempat yang berbeda - kerja. Semenit kemudian, statistik berikut akan ditampilkan di terminal kedua:
Server single-threaded kami mampu memproses lebih dari 11 juta permintaan per menit yang berasal dari 100 koneksi. Bukan hasil yang buruk, tapi bisakah diperbaiki?
Server multithread
Seperti disebutkan di atas, reaktor I/O dapat dibuat dalam thread terpisah, sehingga memanfaatkan semua inti CPU. Mari kita praktikkan pendekatan ini:
Harap dicatat bahwa argumen fungsi new_server() pendukung true. Ini berarti kami menetapkan opsi ke soket server SO_REUSEPORTuntuk menggunakannya dalam lingkungan multi-thread. Anda dapat membaca lebih detailnya di sini.
Putaran kedua
Sekarang mari kita ukur kinerja server multi-thread:
Jumlah permintaan yang diproses dalam 1 menit meningkat ~3.28 kali lipat! Tapi kita hanya kekurangan ~XNUMX juta untuk mencapai angka bulatnya, jadi mari kita coba memperbaikinya.
Pertama mari kita lihat statistik yang dihasilkan Perf:
Menggunakan Afinitas CPU, kompilasi dengan -march=native, PGO, peningkatan jumlah hit cache, meningkatkan MAX_EVENTS dan gunakan EPOLLET tidak memberikan peningkatan kinerja yang signifikan. Namun apa jadinya jika Anda menambah jumlah koneksi simultan?
Hasil yang diinginkan diperoleh, dan dengan itu grafik menarik yang menunjukkan ketergantungan jumlah permintaan yang diproses dalam 1 menit pada jumlah koneksi:
Kami melihat bahwa setelah beberapa ratus koneksi, jumlah permintaan yang diproses untuk kedua server turun tajam (dalam versi multi-utas hal ini lebih terlihat). Apakah ini terkait dengan implementasi tumpukan TCP/IP Linux? Jangan ragu untuk menulis asumsi Anda tentang perilaku grafik ini dan pengoptimalan untuk opsi multi-thread dan single-thread di komentar.
Как dicatat di komentar, uji kinerja ini tidak menunjukkan perilaku reaktor I/O di bawah beban nyata, karena hampir selalu server berinteraksi dengan database, mengeluarkan log, menggunakan kriptografi dengan TLS dll, akibatnya beban menjadi tidak seragam (dinamis). Pengujian bersama dengan komponen pihak ketiga akan dilakukan di artikel tentang proaktor I/O.
Kekurangan reaktor I/O
Perlu Anda pahami bahwa reaktor I/O bukannya tanpa kekurangan, yaitu:
Menggunakan reaktor I/O dalam lingkungan multi-thread agak lebih sulit, karena Anda harus mengelola arus secara manual.
Praktek menunjukkan bahwa dalam banyak kasus, muatannya tidak seragam, yang dapat menyebabkan satu thread logging sementara thread lainnya sibuk dengan pekerjaan.
Jika salah satu pengendali peristiwa memblokir sebuah thread, maka pemilih sistem itu sendiri juga akan memblokir, yang dapat menyebabkan bug yang sulit ditemukan.
Memecahkan masalah ini proaktor I/O, yang sering kali memiliki penjadwal yang mendistribusikan beban secara merata ke kumpulan thread, dan juga memiliki API yang lebih nyaman. Kita akan membicarakannya nanti, di artikel saya yang lain.
Kesimpulan
Di sinilah perjalanan kita dari teori langsung ke knalpot profiler berakhir.
Anda tidak boleh memikirkan hal ini, karena ada banyak pendekatan lain yang sama menariknya dalam menulis perangkat lunak jaringan dengan tingkat kenyamanan dan kecepatan berbeda. Menarik, menurut saya, tautannya diberikan di bawah ini.