• Home
  • Blog
  • Wydajność serwera http w Node i C/C++ (popularne frameworki)

TECHNOLOGIE

Wydajność serwera http w Node i C/C++ (popularne frameworki)

23.11.2016 - Przeczytasz w 8 min.

Mój wybór padł na HTTP. Dlaczego? Podszedłem do tego eksperymentalnie - sprawdzę, zobaczę, a w razie problemów zmienię koncepcję.

RST_software_masters

Jakiś czas temu w mojej głowie

Wydajność serwera http w Node i C/C++ (popularne frameworki)

zrodził się pomysł dotyczący Raspberry PI i innych podobnych mini-komputerów, który polega na wystawieniu prostego interfejsu, do którego będzie można się odwoływać z dowolnego miejsca i z jego pomocą sterować np. GPIO oraz innymi zasobami takiej płytki.

Docelowe rozwiązanie powinno być wydajne (nie określiłem sobie konkretnych liczb) i zajmować mało pamięci RAM, nawet pod dużym obciążeniem. Jak to bywa opcji mamy mnóstwo: WebSockety, TCP, sockety Unixowe, HTTP, itd.

 

Mój wybór padł na HTTP. Dlaczego?

Podszedłem do tego eksperymentalnie – sprawdzę, zobaczę, a w razie problemów zmienię koncepcję. Do tego wydaje mi się, że jest to jeden z najprostszych, jeśli nie najprostszy interfejs do komunikacji od strony klienta. Natomiast co do samej implementacji aplikacji webowej, każdy zrobi po swojemu – jeden użyje API, np. REST do zapalania i gaszenia diody w Raspberry PI, a innemu wystarczy prosta strona z przyciskiem.

Ok, ok, ale aplikacja ma być wydajna. Jak zapewne wszyscy wiemy, PHP do najbardziej wydajnych technologii nie należy (tak, w wersji 7 też). Inne języki skryptowe już na wstępie odpuściłbym. Cóż – Java? Odpada, bo RAM, więc zostaje C/C++, zwłaszcza że do GPIO jest całkiem przyjemna biblioteka dla tych języków – WiringPI. NodeJS także wydaje się rozsądną opcją, ze względu na to, że napisany jest w C na bazie wydajnego silnika JavaScript, także w dalszej części tego artykułu wezmę na warsztat Node 7 i C++.

O ile w Node napiszemy około 5 linijek i już mamy wystawiony serwer HTTP, który zwraca stały string, to jak wystawić aplikację C++ przez HTTP? Z PHP-FPM kojarzymy FastCGI, do tego dochodzi np. Nginx, tak? Bingo! Trzeba po prostu zaimplementować interfejs FastCGI w naszej aplikacji C/C++. Z pomocą przychodzi biblioteka libfcgi, która jest implementacją protokołu FastCGI.

Mamy aplikację, która zwraca na razie stały ciąg znaków, więc musimy jeszczę ją uruchomić w kontekście FastCGI i skonfigurować Nginx. Do wystawienia socketu FastCGI można użyć przykładowo spawn-fcgi.

Odpytujemy odpowiedni host i port przez przeglądarkę, nasza aplikacja działa, jest super, czujemy się świetnie, prawie tak jakbyśmy pozbyli się głodu na świecie, ale czegoś tu brakuje…frameworka! Przecież w Node mamy do wyboru XYZ+ różnych frameworków webowych. Lecz spokojnie, bez paniki, dla C/C++ także są gotowe frameworki/biblioteki do tworzenia aplikacji webowych, ale ja postanowiłem napisać coś swojego – ot tak dla zabawy i w celu podskillowania C++, a przyznam, że frajdy miałem sporo oraz wiele się nauczyłem 🙂

Przedstawiam Wam Hetach.

Hetach jest jeszcze w fazie developmentu, więc do pierwszego release’a trochę mu brakuje, ale prostą aplikację webową można już na nim postawić. W osobnym repozytorium znajdują się przykłady.

Teraz chyba najciekawsza część tego artykułu – liczby, testy, ciekawostki, taka sytuacja 🙂

Zacznę od Node’a więc. Na początek wydajność “gołego” serwera http, zatem pierwsza aplikacja, jaką się posłużę wygląda następująco:

var http = require('http');

http.createServer(function (req, res) {
    res.writeHead(200, {'Content-Type': 'text/plain'});
    res.write("test");
    res.end();
}).listen(8888);

Jak sprawdzam wydajność poszczególnych programów? Mam przygotowanego JMetera, który:

  • uruchamia 50 wątków w ciągu 1 sekundy
  • odpytuje jeden konkretny url (/api/rest/companies/1)
  • działa przez 30 sekund

Wynik po tych 30 sekundach działania JMetera umieszczam tutaj. Każda użyta aplikacja jest jednowątkowa (także % przy użyciu CPU to wykorzystanie jednego rdzenia). No to do dzieła – uruchamiam kontener:

docker run -it --rm --name node-http -v $(pwd):/usr/src -w /usr/src -p "8888:8888" node:7 node write.js

I nasyłam na aplikację legion, po czym dostaję taki oto wynik:

Min [ms] Max [ms] Średnia [ms] Przepustowość [req/s] Użycie CPU i RAM
0 49 39 1223,9 10% CPU
40Mb RAM

W tym momencie nie wiem czy to źle, czy dobrze. Pora na jakiś framework, więc odpalam Google, wpisuję “node js web frameworks”, klikam pierwszy wynik i wybieram pierwszy z brzegu framework o dumnej nazwie “Hapi”. Kolejna aplikacja wygląda tak:

var Hapi = require('hapi');
var server = new Hapi.Server();

server.connection({ port: 8888, host: '0.0.0.0' });

server.route({
    method: 'GET',
    path: '/api/rest/companies/{name}',
    handler: function (request, reply) {
        reply('Hello, ' + encodeURIComponent(request.params.name) + '!');
    }
});

server.start(function () {
    console.log('Server running at:', server.info.uri);
});

Można tutaj zauważyć jeden parametr w linku, który potem jest wyświetlany, identyczną rzecz będą robić pozostałe aplikacje wykorzystujące jakiś framework. No więc ponownie uruchamiam kontener, JMetera i oto wynik:

Min [ms] Max [ms] Średnia [ms] Przepustowość [req/s] Użycie CPU i RAM
1 67 41 1186 90% CPU
85Mb RAM

W tym momencie coś mi zaczęło śmierdzieć…i to wcale nie był oddech mojego psa przebywającego obok. Mało możliwe, aby narzut frameworka (zerknąłem w jego kod i uwierzcie mi – MAŁO możliwe) spowodował utratę jedynie około 40 req/s… Tak więc ze strony wybieram kolejny framework z listy (na drugim miejscu Socket.IO, więc skip) i trafiłem na “Express”. Copy-paste i mamy:

var express = require('express')
var app = express()

app.get('/api/rest/companies/:id', function (req, res) {
  res.send('hello' + req.params.id)
})

app.listen(8888, '0.0.0.0');

Testy pokazują już coś zupełnie innego:

Min [ms] Max [ms] Średnia [ms] Przepustowość [req/s] Użycie CPU i RAM
0 29 6 7695,7 100% CPU
70Mb RAM

Chwila konsternacji…no to rzucam okiem w jego kod i widzę, że zamiast response.write() z pierwszego przykładu, używają response.end(). Mhm, no to zmieniam ten pierwszy przykład, a konkretnie response.write() zastępuję res.end(„test”, „utf8”), odpalam testy i:

Min [ms] Max [ms] Średnia [ms] Przepustowość [req/s] Użycie CPU i RAM
0 24 3 14951,8 100% CPU
65Mb RAM

Myślę: całkiem nieźle, wow, uszanowanko. 😀 No ale nie oszukujmy się, to jest jednak JavaScript, więc przy takim ruchu aplikacja napisana w C++ będzie – zakładam – sobie odpoczywać, prawda? Na razie pomijam Hetach i analogicznie jak przy Node, sprawdzam najpierw najprostszą możliwą aplikację wystawiającą socket FastCGI, która wygląda tak:

#include <iostream>

#include "fcgio.h"

using namespace std;

int main()
{
    streambuf * cin_streambuf  = cin.rdbuf();
    streambuf * cout_streambuf = cout.rdbuf();
    streambuf * cerr_streambuf = cerr.rdbuf();

    FCGX_Request request;

    FCGX_Init();
    FCGX_InitRequest(&request, 0, 0);

    while (FCGX_Accept_r(&request) == 0) {
        fcgi_streambuf cin_fcgi_streambuf(request.in);
        fcgi_streambuf cout_fcgi_streambuf(request.out);
        fcgi_streambuf cerr_fcgi_streambuf(request.err);


        cin.rdbuf(&cin_fcgi_streambuf);
        cout.rdbuf(&cout_fcgi_streambuf);
        cerr.rdbuf(&cerr_fcgi_streambuf);

        cout << "Content-Type: text/html\r\n\r\n";

        cout << "test";
    }

    cin.rdbuf(cin_streambuf);
    cout.rdbuf(cout_streambuf);
    cerr.rdbuf(cerr_streambuf);


    return 0;
}

Za dużo się tu nie dzieje, ostatecznie w przeglądarce dostaniemy “test”. Tak więc przez dockera uruchamiam jeden kontener z powyższą aplikacją wystawioną za pomocą spawn-fcgi, a w drugim kontenerze Nginx z podstawową konfiguracją:

events {
  worker_connections 1024;
}

http {
  server {
    listen 80;
    server_name localhost;

    location / {
      fastcgi_pass   application:8000;

      fastcgi_param  GATEWAY_INTERFACE  CGI/1.1;
      fastcgi_param  SERVER_SOFTWARE    nginx;
      fastcgi_param  QUERY_STRING       $query_string;
      fastcgi_param  REQUEST_METHOD     $request_method;
      fastcgi_param  CONTENT_TYPE       $content_type;
      fastcgi_param  CONTENT_LENGTH     $content_length;
      fastcgi_param  SCRIPT_FILENAME    $document_root$fastcgi_script_name;
      fastcgi_param  SCRIPT_NAME        $fastcgi_script_name;
      fastcgi_param  REQUEST_URI        $request_uri;
      fastcgi_param  DOCUMENT_URI       $document_uri;
      fastcgi_param  DOCUMENT_ROOT      $document_root;
      fastcgi_param  SERVER_PROTOCOL    $server_protocol;
      fastcgi_param  REMOTE_ADDR        $remote_addr;
      fastcgi_param  REMOTE_PORT        $remote_port;
      fastcgi_param  SERVER_ADDR        $server_addr;
      fastcgi_param  SERVER_PORT        $server_port;
      fastcgi_param  SERVER_NAME        $server_name;
    }
  }
}

Oczywiście uruchamiam JMetera z myślą, że Node zostanie za chwilę zaorany…a tu zonk:

Min [ms] Max [ms] Średnia [ms] Przepustowość [req/s] Użycie CPU i RAM
0 19 6 7406,7 Nginx:
100% CPU
10Mb RAM
Aplikacja:
40% CPU
5Mb RAM

…tego to się nie spodziewałem. Zacząłem kombinować z ustawieniami Nginxa – dodawać workery, zwiększać maksymalną ilość połączeń, ale nic to nie dało. Co bym nie zrobił, to przepustowość i tak utrzymywała się na poziomie 7-8 tysięcy na sekundę…trochę bieda, nie?

Ale zaraz, zaraz – chwila rozkminy – przecież FastCGI jest protokołem do komunikacji pomiędzy dwoma różnymi procesami. Serwer http Node’a i aplikacja, którą napisaliśmy działa przecież w obrębie jednego procesu. Dodatkowo nie wiem, czy to Nginx jest tutaj wąskim gardłem, czy socket wystawiony przez spawn-fcgi (powyżej widać, że Nginx pracuje na 100%, ale przy 4 workerach było około 50-60% użycia CPU). Wygooglałem, że ludzie wyciskają z Nginx grubo ponad 100k requestów i się zastanawiają czemu tak wolno. Postanowiłem jednak nie drążyć tego tematu. Pora pogooglować w poszukiwaniu informacji o serwerach http napisanych w C++. Znalazłem gotowca, ale miał zależność w postaci boosta – za czym nie przepadam. Ostatecznie zdecydowałem się na bibliotekę libevent, która udostępnia odpowiednie funkcjonalności potrzebne do postawienia serwera http. Uruchomiłem prosty przykład w postaci:

#include <memory>
#include <cstdint>
#include <iostream>
#include <evhttp.h>

int main()
{
    if (!event_init()) {
        std::cerr << "Failed to init libevent." << std::endl;
        return -1;
    }

    char const SrvAddress[] = "127.0.0.1";
    std::uint16_t SrvPort = 8888;

    evhttp *server = evhttp_start(SrvAddress, SrvPort);

    if (!server) {
        std::cerr << "Failed to init http server." << std::endl;
        return -1;
    }

    void (*OnReq)(evhttp_request *req, void *) = [] (evhttp_request *req, void *) {
        auto *OutBuf = evhttp_request_get_output_buffer(req);

        if (!OutBuf) {
            return;
        }

        evbuffer_add_printf(OutBuf, "test");
        evhttp_send_reply(req, HTTP_OK, "", OutBuf);
    };

    evhttp_set_gencb(server, OnReq, nullptr);

    if (event_dispatch() == -1) {
        std::cerr << "Failed to run message loop." << std::endl;
        return -1;
    }

    return 0;
}

Moim oczom ukazały się takie oto liczby:

Min [ms] Max [ms] Średnia [ms] Przepustowość [req/s] Użycie CPU i RAM
0 25 0 38702 100% CPU
1-2Mb RAM

I to mi się podoba! 🙂 W Hetach, oprócz FastCGI zaimplementowałem także prosty serwer http, właśnie na bazie libevent. Tak więc skupię się teraz na testowaniu Hetach. Od razu powiem, że ostatecznie przez FastCGI i tak osiągam tylko 7-8 tysięcy zapytań na sekundę, więc uwagę poświęcę wbudowanemu serwerowi. Każdy test odbywa się na aplikacji z przykładów.

Min [ms] Max [ms] Średnia [ms] Przepustowość [req/s] Użycie CPU i RAM
1 81 19 2494,8 100% CPU
5Mb RAM

Co tu dużo mówić…zbyt różowo to nie wygląda. Po sprofilowaniu aplikacji Valgrindem okazało się, że wyrażenia w C++ jednak do najszybszego mechanizmu nie należą. Używałem ich w dwóch miejsach: w routerze i w przykładowym programie (klucz id w zwrotce JSON pobierany z url). Tak więc router przepisałem, przykład również zmodyfikowałem, do tego doszło jeszcze kilka optymalizacji samego Hetach. Rezultat tej optymalizacji znajduje się poniżej:

Min [ms] Max [ms] Średnia [ms] Przepustowość [req/s] Użycie CPU i RAM
0 26 3 14422,7 100% CPU
5Mb RAM

Dużo lepiej, prawda? Zapewne nadal jest coś, co można tam zrobić lepiej, ale ale…zerkam w nagłówki odpowiedzi z aplikacji Node’a i puszczonej przez Nginx i moim oczom ukazuje się nagłówek: Connection: keep-alive. Takie cwaniaczki jeden z drugim 🙂 Ja używam w serwerze Hetach Connection: closed z tego względu, że z nim zacząłem optymalizować framework, więc potraktowałem to jako punkt odniesienia. To nie będę gorszy:

Min [ms] Max [ms] Średnia [ms] Przepustowość [req/s] Użycie CPU i RAM
0 19 2 20000,1 100% CPU
5Mb RAM

Mam jeszcze jednego asa w rękawie – optymalizacja kodu podczas kompilowania. Włączyłem w bibliotece Hetach i w aplikacji testowej optymalizację na poziomie 3 – tak od razu z grubej rury, a co 😀

Min [ms] Max [ms] Średnia [ms] Przepustowość [req/s] Użycie CPU i RAM
0 23 1 27558,6 100% CPU
5Mb RAM

Jeden parametr w g++, a zyskaliśmy prawie 8 tysięcy requestów profitu.

Na początku artykułu wspomniałem o Raspberry PI. Ja mam podobną płytkę – Orange PI One (dla której zamiennikiem WiringPI jest WiringOP) z procesorem 600MHz, mój komputer posiada procesor AMD z taktowaniem 3,5GHz. Użyję aplikacji, które osiągnęły najlepszy wynik w powyższych testach. Sprawdźmy zatem różnicę w przypadku Node’a (akurat tutaj w wersji 0.10):

Min [ms] Max [ms] Średnia [ms] Przepustowość [req/s] Użycie CPU i RAM
7 321 80 608,4 100% CPU
25Mb RAM

Oraz Hetach:

Min [ms] Max [ms] Średnia [ms] Przepustowość [req/s] Użycie CPU i RAM
1 31 18 2581,3 100% CPU
2Mb RAM

 

 

Wnioski są następujące:

  1. Nie zależy Ci na zużyciu pamięci, ale jednocześnie aplikacja nie musi być wysoko wydajna? Polecam Node, jednakże warto zwrócić uwagę na wybrany/wybierany framework,
  2. Zależy Ci bardzo na pamięci lub wydajności i możesz poświęcić trochę więcej czasu na programowanie? Świetną opcją jest C++,
  3. Potrzebujesz wystawić interfejs API-REST lub inny webowy biblioteki, np. WiringPI? Polecam Hetach lub podobne rozwiązania,
  4. Masz zamiar uruchomić swoją aplikację na nisko wydajnej platformie? Obie omawiane technologie są dobrym rozwiązaniem, wszystko zależy od Twoich potrzeb.

Ocena artykułu

Udostępnij

Gracjan Orzechowski-RST Software

Gracjan Orzechowski

Developer

Młody i zdolny. Pasjonat programowania, co ciekawe, doświadczenie zdobył ucząc się na własną rękę. Zawodowo programowaniem zajmuje się od 6 lat. Prywatnie miłości AI i Machine Learningu, czym zajmuje się po pracy, rozwijając swoje własne projekty robotów.

Newsletter

Newsletter

Dziękujemy, Twój email został wysłany.

Nasz serwis internetowy używa plików cookies do prawidłowego działania strony. Korzystanie z serwisu bez zmiany ustawień dla plików cookies oznacza, że będą one zapisywane w pamięci urządzenia. Ustawienia te można zmieniać w przeglądarce internetowej. Więcej informacji udostępniamy w Polityce plików cookies.