Bu makalede, bir I/O reaktörünün tüm detaylarına ve nasıl çalıştığına bakacağız, 200 satırdan daha az kodla bir uygulama yazacağız ve 40 milyon istek/dakikanın üzerinde basit bir HTTP sunucusu işlemi gerçekleştireceğiz.
Önsöz
Makale, G/Ç reaktörünün işleyişini anlamaya ve dolayısıyla onu kullanırken ortaya çıkabilecek riskleri anlamaya yardımcı olmak için yazılmıştır.
Makaleyi anlamak için temel bilgileri bilmek gerekir. C dili ve ağ uygulaması geliştirme konusunda biraz deneyim.
Tüm kodlar kesinlikle C dilinde ('ye göre yazılmıştır)dikkat: uzun PDF) C11 standardına göre Linux için ve şu adreste mevcuttur: GitHub.
Neden mi?
İnternetin popülaritesinin artmasıyla birlikte, web sunucuları aynı anda çok sayıda bağlantıyı yönetme ihtiyacı duymaya başladı ve bu nedenle iki yaklaşım denendi: çok sayıda işletim sistemi iş parçacığında G/Ç'nin engellenmesi ve "sistem seçici" olarak da adlandırılan bir olay bildirim sistemi (epol/kuyruk/IOCP/vesaire).
İlk yaklaşım, gelen her bağlantı için yeni bir işletim sistemi iş parçacığı oluşturmayı içeriyordu. Dezavantajı zayıf ölçeklenebilirliktir: işletim sisteminin birçok uygulama yapması gerekecektir. bağlam geçişleri и sistem çağrıları. Bunlar pahalı işlemlerdir ve etkileyici sayıda bağlantıya sahip boş RAM eksikliğine yol açabilir.
Değiştirilmiş sürüm öne çıkıyor sabit sayıda iş parçacığı (iş parçacığı havuzu), böylece sistemin yürütmeyi durdurması önlenir, ancak aynı zamanda yeni bir sorun ortaya çıkar: bir iş parçacığı havuzu şu anda uzun okuma işlemleri nedeniyle engellenmişse, o zaman zaten veri alabilen diğer soketler bu işlemi gerçekleştiremeyecektir. böyle yap.
İkinci yaklaşım şunları kullanır: olay bildirim sistemi (sistem seçici) işletim sistemi tarafından sağlanır. Bu makalede, G/Ç işlemlerine hazır olma durumu yerine uyarılara (olaylar, bildirimler) dayalı olarak en yaygın sistem seçici türü anlatılmaktadır. tamamlandığına dair bildirimler. Kullanımının basitleştirilmiş bir örneği aşağıdaki blok diyagramla gösterilebilir:
Bu yaklaşımlar arasındaki fark aşağıdaki gibidir:
G/Ç işlemlerini engelleme askıya almak kullanıcı akışı a kadarişletim sistemi düzgün bir şekilde kuruluncaya kadar birleştirmeler gelen IP paketleri bayt akışına (TCP, veri alıyor) veya dahili yazma arabelleklerinde sonraki gönderimler için yeterli alan olmayacak NIC (veri gönderiliyor).
Sistem seçici bir süre sonra programa işletim sisteminin bildirildiğini bildirir zaten birleştirilmiş IP paketleri (TCP, veri alımı) veya dahili yazma arabelleklerinde yeterli alan zaten mevcut (veri gönderiliyor).
Özetlemek gerekirse, her bir G/Ç için bir işletim sistemi iş parçacığı ayırmak, bilgi işlem gücünün israfıdır, çünkü gerçekte iş parçacıkları yararlı işler yapmaz (bu nedenle terim "yazılım kesintisi"). Sistem seçici bu sorunu çözerek kullanıcı programının CPU kaynaklarını çok daha ekonomik kullanmasına olanak tanır.
G/Ç reaktör modeli
G/Ç reaktörü, sistem seçici ile kullanıcı kodu arasında bir katman görevi görür. Çalışma prensibi aşağıdaki blok diyagramda açıklanmaktadır:
Bir olayın, belirli bir soketin engellenmeyen bir G/Ç işlemi gerçekleştirebildiğine dair bir bildirim olduğunu hatırlatmama izin verin.
Olay işleyici, bir olay alındığında G/Ç reaktörü tarafından çağrılan ve daha sonra engellemesiz bir G/Ç işlemi gerçekleştiren bir işlevdir.
G/Ç reaktörünün tanım gereği tek iş parçacıklı olduğuna dikkat etmek önemlidir, ancak konseptin çok iş parçacıklı bir ortamda 1 iş parçacığı: 1 reaktör oranında kullanılmasını, dolayısıyla tüm CPU çekirdeklerinin geri dönüştürülmesini engelleyen hiçbir şey yoktur.
uygulama
Genel arayüzü bir dosyaya yerleştireceğiz reactor.hve uygulama - içinde reactor.c. reactor.h aşağıdaki duyurulardan oluşacaktır:
Bildirimleri reaktör.h'de göster
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);
G/Ç reaktör yapısı aşağıdakilerden oluşur: dosya tanımlayıcı seçici epol и karma tablolarıGHashTableher bir soketi eşleyen CallbackData (bir olay işleyicisinin yapısı ve bunun için bir kullanıcı argümanı).
Lütfen işleme yeteneğini etkinleştirdiğimizi unutmayın. tamamlanmamış tür indekse göre. İÇİNDE reactor.h yapıyı ilan ediyoruz reactorVe içinde reactor.c bunu tanımlarız, böylece kullanıcının alanlarını açıkça değiştirmesini engelleriz. Bu modellerden biri verileri gizlemeC anlambilimine kısa ve öz bir şekilde uyan.
fonksiyonlar reactor_register, reactor_deregister и reactor_reregister sistem seçicide ve karma tablosunda ilgilenilen yuvaların ve karşılık gelen olay işleyicilerinin listesini güncelleyin.
G/Ç reaktörü olayı tanımlayıcıyla yakaladıktan sonra fd, iletildiği ilgili olay işleyicisini çağırır fd, bit maskesi oluşturulan olaylar ve bir kullanıcı işaretçisi void.
reaktör_run() işlevini göster
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;
}
Özetlemek gerekirse, kullanıcı kodundaki işlev çağrıları zinciri aşağıdaki formu alacaktır:
Tek iş parçacıklı sunucu
I/O reaktörünü yüksek yük altında test etmek için herhangi bir isteğe bir görüntüyle yanıt veren basit bir HTTP web sunucusu yazacağız.
HTTP protokolüne hızlı bir referans
HTTP - protokol bu uygulama seviyesi, öncelikle sunucu-tarayıcı etkileşimi için kullanılır.
HTTP kolaylıkla kullanılabilir Ulaşım protokol TCP, belirtilen formatta mesaj gönderip almak Şartname.
CRLF iki karakterden oluşan bir dizidir: r и nisteğin ilk satırını, başlıkları ve verileri ayırarak.
<КОМАНДА> - biri CONNECT, DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT, TRACE. Tarayıcı sunucumuza bir komut gönderecek GET, "Dosyanın içeriğini bana gönder" anlamına gelir.
<URI> - Tekdüzen Kaynak Tanımlayıcı. Örneğin, eğer URI = /index.html, ardından müşteri sitenin ana sayfasını ister.
<ВЕРСИЯ HTTP> — HTTP protokolünün formattaki versiyonu HTTP/X.Y. Günümüzde en sık kullanılan versiyon HTTP/1.1.
<ЗАГОЛОВОК N> biçiminde bir anahtar/değer çiftidir <КЛЮЧ>: <ЗНАЧЕНИЕ>, daha fazla analiz için sunucuya gönderildi.
<ДАННЫЕ> — işlemi gerçekleştirmek için sunucunun ihtiyaç duyduğu veriler. Çoğu zaman basittir JSON veya başka herhangi bir format.
/*
* Обработчик событий, который вызовется после того, как сокет будет
* готов принять новое соединение.
*/
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);
İşlevsel makro da açıklanmıştır SAFE_CALL() ve fonksiyon tanımlandı fail(). Makro, ifadenin değerini hatayla karşılaştırır ve koşul doğruysa işlevi çağırır. fail():
#define SAFE_CALL(call, error)
do {
if ((call) == error) {
fail("%s", #call);
}
} while (false)
Fonksiyon fail() iletilen argümanları terminale yazdırır (gibi printf()) ve programı kodla sonlandırır EXIT_FAILURE:
Fonksiyon new_server() sistem çağrıları tarafından oluşturulan "sunucu" soketinin dosya tanımlayıcısını döndürür socket(), bind() и listen() ve gelen bağlantıları engellemeyen bir modda kabul etme yeteneğine sahiptir.
Soketin başlangıçta engellemesiz modda bayrak kullanılarak oluşturulduğunu unutmayın. SOCK_NONBLOCKböylece fonksiyonda on_accept() (devamını oku) sistem çağrısı accept() iş parçacığının yürütülmesini durdurmadı.
Eğer reuse_port olduğunu true, o zaman bu işlev soketi seçenekle yapılandıracaktır. SO_REUSEPORT sayesinde setsockopt()aynı bağlantı noktasını çok iş parçacıklı bir ortamda kullanmak için ("Çok iş parçacıklı sunucu" bölümüne bakın).
Olay işleyicisi on_accept() işletim sistemi bir olay oluşturduktan sonra çağrılır EPOLLIN, bu durumda yeni bağlantının kabul edilebileceği anlamına gelir. on_accept() yeni bir bağlantıyı kabul eder, engellemesiz moda geçirir ve bir olay işleyicisine kaydolur on_recv() bir G/Ç reaktöründe.
Olay işleyicisi on_recv() işletim sistemi bir olay oluşturduktan sonra çağrılır EPOLLIN, bu durumda bağlantının kayıtlı olduğu anlamına gelir on_accept(), veri almaya hazır.
on_recv() HTTP isteği tamamen alınana kadar bağlantıdaki verileri okur, ardından bir işleyiciyi kaydeder on_send() Bir HTTP yanıtı göndermek için. İstemci bağlantıyı keserse, soketin kaydı silinir ve kullanılarak kapatılır. close().
on_recv() fonksiyonunu göster
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);
}
}
Olay işleyicisi on_send() işletim sistemi bir olay oluşturduktan sonra çağrılır EPOLLOUT, bağlantının kayıtlı olduğu anlamına gelir on_recv(), veri göndermeye hazır. Bu işlev, istemciye bir görüntü içeren HTML içeren bir HTTP yanıtı gönderir ve ardından olay işleyicisini tekrar şu şekilde değiştirir: on_recv().
Ve son olarak dosyada http_server.c, işlevde main() kullanarak bir G/Ç reaktörü oluşturuyoruz reactor_new(), bir sunucu soketi oluşturun ve kaydedin, kullanarak reaktörü başlatın reactor_run() tam olarak bir dakika, sonra kaynakları serbest bırakıp programdan çıkıyoruz.
Her şeyin beklendiği gibi çalışıp çalışmadığını kontrol edelim. Derleniyor (chmod a+x compile.sh && ./compile.sh proje kökünde) ve kendi kendine yazılan sunucuyu başlatın, açın http://127.0.0.1:18470 tarayıcıda ne beklediğimizi görün:
Tek iş parçacıklı bir sunucunun performansını ölçelim. İki terminal açalım: birinde koşacağız ./http_server, farklı bir şekilde - iş. Bir dakika sonra ikinci terminalde aşağıdaki istatistikler görüntülenecektir:
Tek iş parçacıklı sunucumuz, 11 bağlantıdan kaynaklanan, dakikada 100 milyondan fazla isteği işleyebildi. Kötü bir sonuç değil ama geliştirilebilir mi?
Çok iş parçacıklı sunucu
Yukarıda bahsedildiği gibi, G/Ç reaktörü ayrı iş parçacıklarında oluşturulabilir, böylece tüm CPU çekirdekleri kullanılabilir. Bu yaklaşımı uygulamaya koyalım:
Lütfen fonksiyon argümanının new_server() eylemler true. Bu, seçeneği sunucu soketine atadığımız anlamına gelir SO_REUSEPORTçok iş parçacıklı bir ortamda kullanmak için. Daha fazla ayrıntı okuyabilirsiniz burada.
İkinci çalıştırma
Şimdi çok iş parçacıklı bir sunucunun performansını ölçelim:
CPU Benzeşimini Kullanma, ile derleme -march=native, PGO, isabet sayısında artış nakit, arttırmak MAX_EVENTS ve kullan EPOLLET performansta ciddi bir artış sağlamadı. Peki eşzamanlı bağlantıların sayısını artırırsanız ne olur?
İstenilen sonuç elde edildi ve bununla birlikte 1 dakika içinde işlenen talep sayısının bağlantı sayısına bağımlılığını gösteren ilginç bir grafik oluştu:
Birkaç yüz bağlantıdan sonra, her iki sunucu için de işlenen istek sayısının keskin bir şekilde düştüğünü görüyoruz (çok iş parçacıklı sürümde bu daha belirgindir). Bu Linux TCP/IP yığın uygulamasıyla mı ilgili? Grafiğin bu davranışı ve çok iş parçacıklı ve tek iş parçacıklı seçeneklere yönelik optimizasyonlar hakkındaki varsayımlarınızı yorumlara yazmaktan çekinmeyin.
Gibi ünlü Yorumlarda, bu performans testi, G/Ç reaktörünün gerçek yükler altındaki davranışını göstermez çünkü neredeyse her zaman sunucu veritabanıyla etkileşime girer, günlükleri çıkarır ve kriptografiyi kullanır. TLS vb. bunun sonucunda yük düzensiz (dinamik) hale gelir. I/O proactor hakkındaki makalede üçüncü taraf bileşenlerle birlikte testler gerçekleştirilecektir.
G/Ç reaktörünün dezavantajları
G/Ç reaktörünün dezavantajlarının olduğunu anlamalısınız:
Çok iş parçacıklı bir ortamda bir G/Ç reaktörünün kullanılması biraz daha zordur çünkü akışları manuel olarak yönetmeniz gerekecektir.
Uygulama çoğu durumda yükün tek biçimli olmadığını göstermektedir; bu da bir iş parçacığının günlüğe kaydedilmesine neden olurken diğerinin işle meşgul olmasına neden olabilir.
Bir olay işleyicisi bir iş parçacığını engellerse, sistem seçicinin kendisi de engeller ve bu da bulunması zor hatalara yol açabilir.
Bu sorunları çözer G/Ç proaktörüGenellikle yükü bir iş parçacığı havuzuna eşit olarak dağıtan bir zamanlayıcıya ve ayrıca daha kullanışlı bir API'ye sahiptir. Bunu daha sonra diğer yazımda konuşacağız.
Sonuç
Teoriden doğrudan profil oluşturucu egzozuna olan yolculuğumuzun sona erdiği yer burasıdır.
Bunun üzerinde durmamalısınız çünkü ağ yazılımı yazmaya yönelik farklı düzeyde kolaylık ve hıza sahip, eşit derecede ilginç başka yaklaşımlar da vardır. Bana göre ilginç olan bağlantılar aşağıda verilmiştir.