Java : presque 9 000 requêtes par seconde avec 8 Mo de RAM

Lourd et lent, Java ?

Vous l’avez peut-être remarqué : mon avatar est aléatoire.

L’implémentation actuelle est faite avec trois lignes de PHP, ce qui m’ennuie un peu parce que c’est le seul outil qui a encore besoin de PHP sur mon serveur. Je me suis donc demandé : est-ce que je pourrais réimplémenter ça en Java ? Après tout, la partie dynamique est complètement triviale : c’est une URL qui réponds en HTTP et, quoi qu’on lui demande, renvoie un code retour HTTP 302 vers l’une des 21 URLs d’images de renard disponibles.

On colle le serveur dynamique derrière un Nginx pour faire proxy, HTTPS et serveur d’images, et ça roule.

Un peu de code

package fr.spacefox.avatar;

import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.util.Random;

public class AvatarHttpServer {

    private final Random random = new Random();
    private final int port;
    private final int imgCount;

    public AvatarHttpServer(final int port, final int imgCount) {
        this.port = port;
        this.imgCount = imgCount;
    }

    public void run() throws IOException {
        var server = HttpServer.create(new InetSocketAddress(port), 0);
        server.createContext("/", exchange -> {
            exchange.getResponseHeaders().add(
                    "Location",
                    "https://avatar.spacefox.fr/Renard-" + (random.nextInt(imgCount) + 1) + ".png");
            exchange.sendResponseHeaders(302, -1);
        });
        server.start();
    }

    public static void main(String[] args) throws IOException {
        new AvatarHttpServer(Integer.parseInt(args[0]), Integer.parseInt(args[1])).run();
    }
}

C’est du Java pur, sans aucune dépendance à aucune bibliothèque externe

33 lignes avec tout le boilerplate, c’est dix fois plus que l’implémentation actuelle. J’aurais pu gratter un peu en ne permettant pas la configuration du port ou du nombre d’images (la version actuelle ne le fait pas) mais ça ne change pas l’ordre de grandeur.

D’un côté, ça fait pas mal de blabla pour un fonctionnement aussi simple ; de l’autre, ça reste très raisonnable par rapport à ce qu’a pu être Java : pas besoin d’aller jouer manuellement avec des socket ou d’autres opérations acrobatiques.

Et surtout : c’est du Java pur, sans la moindre dépendance, qui va tourner directement sur n’importe quelle JVM (17 ou supérieure) – y compris les dérivées d’IBM J9. Le fichier .jar généré et exécutable (via java -jar) fait 2.02 ko.

Les performances

La question que je me suis posé ensuite, c’est : puisque le truc est tout petit, ça doit consommer presque rien en mémoire. D’accord, mais « presque rien » avec Java, c’est quoi ? Pour quelles performances ? Essayons donc.

Je définis le tas à 8 Mo avec -Xmx8m et limite les piles à 256 ko avec -Xss256k (par défaut c’est 1 Mo, clairement inutiles ici). Et je teste :

Débit en sortie

spacefox@shub-niggurath:~$ java -Xmx8m -Xss256k -jar AvatarServer-1.0-SNAPSHOT.jar 6666 21 &
[1] 654741
spacefox@shub-niggurath:~$ ab -n 100000 -c 10 http://localhost:6666/
This is ApacheBench, Version 2.3 <$Revision: 1879490 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking localhost (be patient)
Completed 10000 requests
Completed 20000 requests
Completed 30000 requests
Completed 40000 requests
Completed 50000 requests
Completed 60000 requests
Completed 70000 requests
Completed 80000 requests
Completed 90000 requests
Completed 100000 requests
Finished 100000 requests


Server Software:
Server Hostname:        localhost
Server Port:            6666

Document Path:          /
Document Length:        0 bytes

Concurrency Level:      10
Time taken for tests:   11.355 seconds
Complete requests:      100000
Failed requests:        0
Non-2xx responses:      100000
Total transferred:      16157218 bytes
HTML transferred:       0 bytes
Requests per second:    8806.68 [#/sec] (mean)
Time per request:       1.136 [ms] (mean)
Time per request:       0.114 [ms] (mean, across all concurrent requests)
Transfer rate:          1389.56 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.2      0      11
Processing:     0    1   0.8      1      66
Waiting:        0    1   0.8      1      65
Total:          0    1   0.8      1      66

Percentage of the requests served within a certain time (ms)
  50%      1
  66%      1
  75%      1
  80%      1
  90%      2
  95%      2
  98%      4
  99%      4
 100%     66 (longest request)

8806.68 requêtes par seconde en moyenne en local. C’est pas mal, surtout que le code complètement mono-thread et que le serveur sur lequel je lance ça tourne sur des Intel® Xeon® CPU E5–2690 v3 @ 2.60GHz, donc des processeurs assez lents – je suis plutôt à 17 000 sur mon ordinateur.

Je dis « c’est pas mal » mais je devrais plutôt écrire « c’est énorme » : le même test, sur le même serveur, sur un fichier statique hébergé sur cette même machine, via nginx et HTTPS, plafonne plutôt vers 450 requêtes par seconde. Ceci implique que le facteur limitant en conditions réelles sera le proxy (et tout ce qu’il doit gérer) plus que ce programme.

Mais surtout, les performances sont limitées par le CPU et par le passage du garbage collector : le simple fait d’assigner 16 Mo de mémoire au tas fait monter les performances à près de 10 000 requêtes par seconde – au-delà, l’amélioration n’est plus significative.

Le choix du garbage collector est important : ici le test est fait avec G1GC, celui par défaut dans l’OpenJDK 17. Mais si je passe à ZGC, un garbage collector alternatif qui a ses avantages mais n’est clairement pas conçu pour ce genre de charge, les performance sont diminuées de moitié.

La vérité sur la consommation mémoire

Vérifier la consommation mémoire réelle d’une application n’est pas quelque chose de trivial. Pour commencer, beaucoup d’outils affichent la mémoire demandée et pas celle réellement utilisée, ce qui fausse les chiffres à la hausse.

Or, le tas susmentionné (et généralement la première valeur que l’on modifie dans les programmes Java avec la directive -Xmx) n’est pas la seule zone mémoire utilisée par la JVM, il y en a d’autres.

Avec les outils d’introspection Java (dont je ne vous colle pas la sortie, de toutes façon illisible), j’estime la consommation réelle de cet outil à entre 30 et 40 Mo de mémoire – qui ne bouge pas, même après avoir servi des dizaines de millions de requêtes.

Alors, Java c’est lourd et lent et verbeux ?

Ben… oui et non.

Le simple fait d’utiliser une JVM impose de se réserver quelques dizaines de Mo et plusieurs threads invisibles (notamment à cause du Garbage Collector). Si ça pouvait être énorme à l’époque où les ordinateurs avaient quelques centaines de Mo de RAM au total, ça n’est plus un vrai problème aujourd’hui, surtout que le code métier prends très vite beaucoup plus de mémoire que cette consommation de base. De plus, beaucoup de programmes Java dans la nature consomment trop par rapport à ce qu’ils pourraient consommer pour deux raisons principales : d’une part la confusion entre garbage collector et mémoire magique qui crée beaucoup de fuites de mémoire , d’autre part à cause de réglages désastreux par défaut. Par exemple, j’ai encore croisé cette semaine un outil qui conseille de paramétrer 2 Go de tas là où 250 Mo suffisent largement…

Les performances sur ce genre d’exercice sont suffisantes pour que le processeur ne soit jamais un problème. En fait, cette assertion est généralisable : les performances des compilateurs just in time intégrés aux JVM (OpenJDK et dérivées, J9 et dérivées) sont excellentes, tant qu’on ne tape pas dans les programmes extrêmement calculatoires. Les goulets d’étranglement d’un programme Java correctement réalisé sont rarement au niveau du CPU (dans mon test c’est le cas, mais le test n’est pas représentatif d’un usage réel : le proxy ou la connexion satureront avant le CPU en usage réel).

Enfin, Java a fait d’énormes efforts en verbosité, même s’il reste des axes d’amélioration (j’aurais pu éviter le constructeur au prix d’un import – qui sont toujours masqués – et d’une annotation avec Lombok). Kotlin fait mieux de ce côté, mais impose de distribuer les classes de runtime, ce qui n’est clairement pas intéressant dans ce cas.

De plus, tout ça c’est avec un programme écrit en 10 minutes qui ne nécessite aucune dépendance (autre qu’une JVM).

Quoi ? Vous saviez déjà tout ça ? Pourtant, c’est encore des critiques qu’on entends très régulièrement.

Et dans votre langage préféré ?

Je serais curieux de savoir ce que peut donner l’exercice dans votre langage préféré. N’hésitez pas à le réaliser et à partager vos résultats !



23 commentaires

Je ne doutais pas un seul instant que le Java moderne soit capable de telles performances. J’avais d’ailleurs lu un article sur les nouveaux GC de la JVM (dont le fameux ZGC). C’est clairement inspirant pour entrevoir un avenir où on aurait des langages à la fois : massivement concurrents, performants (basse latence ou haut throughput selon le choix du développeur) et en gardant un GC.

Mon avatar actuel est choisi aléatoirement parmi 10 couleurs. C’est un Worker Cloudflare qui choisit une version au pif et répond avec une 302 sur une ressource statique SVG, de la même façon que ce que tu décris dans ton billet. Son code (en JavaScript) n’est pas très intéressant et je ne peux même pas m’amuser à en faire le benchmark local puisqu’il tourne dans les nuages flamboyants.

Je serais curieux de savoir ce que peut donner l’exercice dans votre langage préféré. N’hésitez pas à le réaliser et à partager vos résultats !

Ainsi voyons plutôt les deux pistes que j’ai explorées avant, plus intéressantes que la solution Cloudflare.

NGINX

Première piste. Le code pour gérer ça est si simple que je me demandais même s’il n’y avait pas moyen de le faire en pure configuration NGINX (après tout, on peut déjà en faire des choses sans backend). À la limite, j’aurais pu tenter de scripter NGINX avec de la configuration Lua, je pense.

En Go

Mais j’ai eu une autre idée plus rigolote : je voulais que l’éléphant de mon avatar puisse être de n’importe quelle couleur. Il s’agit d’un fichier SVG (texte) qui peut donc servir de template, j’ai donc implémenté un petit service en Go (sans autre chose que la lib standard) qui est capable de sortir l’avatar dans n’importe quelle couleur sur le format : example.com/a8db7d.svg par exemple. L’autre endpoint, example.com/random.svg renvoie lui une 302 sur une ressource tirée aléatoirement au préalable.

Handler pour choisir une couleur et renvoyer la 302 :

const universe = "0123456789abcdef"

// /random.svg
func pickRandom(w http.ResponseWriter, req *http.Request) {
	var hexColor strings.Builder
	r := rand.New(rand.NewSource(time.Now().Unix()))
	max := len(universe)
	for k := 0; k < 6; k++ {
		randIdx := r.Intn(max)
		hexColor.WriteByte(universe[randIdx])
	}
	resource := "/" + hexColor.String() + ".svg"
	http.Redirect(w, req, resource, 302)
}

Je ne sais pas si c’est la façon la plus performante possible. Il y a un appel système évitable (time.Now()) et il faut 6 coups aléatoires pour construire un code hexa en concaténant les caractères. Peut-être qu’une approche consistant à tirer aléatoirement un seul entier, ensuite converti en forme hexcode, aurait été plus performante.

Handler pour générer et envoyer l’avatar dynamique SVG :

const pathRegexStr = `^/([a-f]|[0-9])+\.svg$`  // ne gère pas la longueur
var pathRegex = regexp.MustCompile(pathRegexStr)

// /[hexcode].svg
func serveSVG(w http.ResponseWriter, req *http.Request) {
	path := req.URL.Path
	if !pathRegex.MatchString(path) {
		w.WriteHeader(http.StatusNotFound)
		return
	}
	parts := strings.Split(path, "/")
	resource := parts[1]
	parts = strings.Split(resource, ".")
	color := parts[0]
	w.Header().Set("Content-Type", "image/svg+xml; charset=utf-8")
	t := template.Must(template.ParseFiles("template.svg"))
	t.Execute(w, struct{ Color string }{color})
}

Pour l’endpoint random.svg, j’obtiens ~17.8k requêtes par secondes. Les options de compilation sont les suivantes :

# go version go1.18.2 linux/amd64
CGO_ENABLED=0 GOAMD64=v3 go build -o pgbg -v .

CPU : Intel(R) Core(TM) i7-10710U CPU @ 1.10GHz, mode performance activée (ce qui booste certains cores à 4 GHz).

Benchmark complet :

ab -n 100000 -c 10 "http://localhost:7778/random.svg"

...

Server Software:        
Server Hostname:        localhost
Server Port:            7778

Document Path:          /random.svg
Document Length:        34 bytes

Concurrency Level:      10
Time taken for tests:   5.624 seconds
Complete requests:      100000
Failed requests:        0
Non-2xx responses:      100000
Total transferred:      17600000 bytes
HTML transferred:       3400000 bytes
Requests per second:    17781.42 [#/sec] (mean)
Time per request:       0.562 [ms] (mean)
Time per request:       0.056 [ms] (mean, across all concurrent requests)
Transfer rate:          3056.18 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.1      0       1
Processing:     0    0   0.1      0       1
Waiting:        0    0   0.1      0       1
Total:          0    1   0.1      1       1

Percentage of the requests served within a certain time (ms)
  50%      1
  66%      1
  75%      1
  80%      1
  90%      1
  95%      1
  98%      1
  99%      1
 100%      1 (longest request)

En revanche, pour l’autre endpoint, c’est clairement plus lent : il faut parser un hexcode, puis rendre un template SVG avant de le renvoyer. On passe à ~12.6k requêtes par seconde.

ab -n 100000 -c 10 "http://localhost:7778/3a5174.svg"
...

Server Software:        
Server Hostname:        localhost
Server Port:            7778

Document Path:          /3a5174.svg
Document Length:        13528 bytes

Concurrency Level:      10
Time taken for tests:   7.943 seconds
Complete requests:      100000
Failed requests:        0
Total transferred:      1362800000 bytes
HTML transferred:       1352800000 bytes
Requests per second:    12590.47 [#/sec] (mean)
Time per request:       0.794 [ms] (mean)
Time per request:       0.079 [ms] (mean, across all concurrent requests)
Transfer rate:          167561.50 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.1      0       1
Processing:     0    1   0.2      1       2
Waiting:        0    0   0.2      0       2
Total:          0    1   0.2      1       2

Percentage of the requests served within a certain time (ms)
  50%      1
  66%      1
  75%      1
  80%      1
  90%      1
  95%      1
  98%      1
  99%      1
 100%      2 (longest request)

Je ne sais pas comment analyser correctement la consommation mémoire et la pression induite sur le GC de Go, mais j’aurais aimé. Si j’ai un peu de temps pour me pencher là-dessus, je reviendrai avec les résultats.

Finalement, je n’ai pas retenu ce programme même s’il fonctionne. Je ne voulais pas que les visiteurs aient à télcharger un nouveau SVG à chaque fois. J’ai donc décidé de rester sur 10 images alternatives statiques servies après une 302 et qui pourrait rester en cache navigateur et en cache CDN pour longtemps, limitant ainsi la consommation réseau de toute le monde.

@Amaury : remplacement d’un lien interne de brouillon de contenu par le lien public.

+3 -0

Argh, @sgble m’a coupé l’herbe sous le pied. J’obtiens exactement le même ordre de grandeur sur ma machine (17K requêtes par seconde pour une redirection simple).

J’ai à peu près les mêmes résultats. Pour ce qui est de la pression mémoire, je ne suis pas plus avancé. Un htop pendant l’exécution du programme montre juste que le process monte jusqu’à 12500 octets de mémoire résidente pendant l’exécution du bench. :)

Je colle quand même le code du programme complet (vraiment tout bête) que j’ai utilisé pour mes tests :

package main

import (
	"flag"
	"fmt"
	"math/rand"
	"net/http"
	"time"
)

var port int

func init() {
	rand.Seed(time.Now().UnixNano())
	flag.IntVar(&port, "p", 80, "port to serve to")
	flag.Parse()
}

func main() {
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Add("Location", fmt.Sprintf("http://foobar.com/%d.png", rand.Intn(500)))
		w.WriteHeader(302)
	})
	http.ListenAndServe(fmt.Sprintf(":%d", port), nil)
}
+2 -0

Je viens d’essayer ta version @nohar, vu qu’elle fait exactement la même chose que la mienne.

Sur le même serveur que mes tests, j’obtiens un peu plus de 13 000 requêtes par seconde, au prix d’une consommation CPU double (2 cœurs à 100 % chacun au lieu d’un seul une fois le JIT stabilisé – ce qui prends 2 secondes – avec Java).

Par contre je pense que tu voulais dire que le process monte jusqu’à 12 500 kilooctets de mémoire non ? Je le vois à 6 Mo chez moi.

Quant à la taille de l’exécutable… celui produit par Go fait 7,2 Mo, mais n’a pas besoin d’un runtime pour être exécuté, donc c’est pas tellement comparable :)

Par contre je pense que tu voulais dire que le process monte jusqu’à 12 500 kilooctets de mémoire non ? Je le vois à 6 Mo chez moi.

Au temps pour moi, oui. La subtilité de htop est que quand il ne donne pas d’unité, il parle en KB. Tu as raison.

+0 -0

J’ai tenté en Rust pour voir. Je n’y connais absolument rien en réseau donc j’ai peut être d’une part fait un truc débile, et d’autre part il y a sûrement des crates pour créer un serveur de façon moins pédestre et plus abstraite (genre pas écrire la réponse à la main… :-° ).

Le code :

use std::net::{TcpListener, Ipv4Addr};
use std::io::{Write, Read};
use std::env;
use rand::Rng;

fn main() {
    let args: Vec<_> = env::args().collect();
    let port: u16 = args[1].parse().unwrap();
    let n_images: u32 = args[2].parse().unwrap();

    let mut rng = rand::thread_rng();

    let local_host = Ipv4Addr::new(127, 0, 0, 1);
    let listener = TcpListener::bind((local_host, port)).unwrap();

    for stream in listener.incoming() {
        let mut stream = stream.unwrap();

        stream.read(&mut [0; 1024]).unwrap();

        let image = rng.gen_range(1..=n_images);
        let response = format!(
            "HTTP/1.1 302 OK\r\nLocation: https://avatar.spacefox.fr/Renard-{image}.png\r\n"
        );
        stream.write(response.as_bytes()).unwrap();
        stream.flush().unwrap();
    }
}

Le benchmark, sur un laptop vieillissant avec un Intel® Core™ i7–7700HQ CPU @ 2.80GHz.|

ab -n 100000 -c 10  "http://localhost:8080/"
This is ApacheBench, Version 2.3 <$Revision: 1879490 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking localhost (be patient)
Completed 10000 requests
Completed 20000 requests
Completed 30000 requests
Completed 40000 requests
Completed 50000 requests
Completed 60000 requests
Completed 70000 requests
Completed 80000 requests
Completed 90000 requests
Completed 100000 requests
Finished 100000 requests


Server Software:
Server Hostname:        localhost
Server Port:            8080

Document Path:          /
Document Length:        0 bytes

Concurrency Level:      10
Time taken for tests:   5.419 seconds
Complete requests:      100000
Failed requests:        0
Total transferred:      6856912 bytes
HTML transferred:       0 bytes
Requests per second:    18453.78 [#/sec] (mean)
Time per request:       0.542 [ms] (mean)
Time per request:       0.054 [ms] (mean, across all concurrent requests)
Transfer rate:          1235.70 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.0      0       0
Processing:     0    0   0.0      0       0
Waiting:        0    0   0.0      0       0
Total:          0    0   0.0      0       0

Percentage of the requests served within a certain time (ms)
  50%      0
  66%      0
  75%      0
  80%      0
  90%      0
  95%      0
  98%      0
  99%      0
 100%      0 (longest request)

J’obtiens un ordre de grandeur similaire avec 18k requêtes par seconde. Par contre, beaucoup moins de données sont transférées (6 Mb contre 17 Mb en Go et 16 Mb en Java, ça j’imagine que ça vient de ma réponse écrite à la main qui a moins d’info que celle créée par les stdlibs de ces langages). La latence max est à 0 ms contre 1±0.2 ms de moyenne pour le code en Go, je sais pas à quoi l’attribuer comme je vois mal le GC qui se réveille de temps en temps faire monter la moyenne autant mais pas le maximum ni l’écart type. Peut être que c’est même pas significatif de quoique ce soit ? Les résultats sont aussi (un peu étonnamment pour moi) a peu près identiques sans optimisations du compilo, avec juste l’exception d’une latence max à 1 ou 2 ms (reproductible, donc pas juste un effet du CPU surchargé à ce moment par hasard). La conso mémoire est de 850 ko (mémoire virtuelle) pendant le benchmark.

Aussi, question bête de néophyte, si je lis pas la requête (ligne 19) avant d’écrire la réponse, le benchmark plante avec apr_socket_recv: Connection reset by peer (104) même en faisant un benchmark avec une seule connection et une seule requête. Le serveur ne crash pas et répond correctement (dans le sens où je suis redirigé vers un avatar de SpaceFox) si j’accède à 127.0.0.1:8080 depuis mon navigateur. Quelqu’un a une idée de pourquoi ab se vautre alors que le serveur fonctionne ?

EDIT : ah oui, le binaire est de 3.7 Mo sans rien faire, et de 335 ko après un strip, sans runtime nécessaire.

+1 -0

Sur le même serveur que mes tests, j’obtiens un peu plus de 13 000 requêtes par seconde, au prix d’une consommation CPU double (2 cœurs à 100 % chacun au lieu d’un seul une fois le JIT stabilisé – ce qui prends 2 secondes – avec Java).

Et ça c’est parce que tu lui as donné 2 coeurs en fait. Go va toujours se débrouiller pour utiliser au maximum tous les coeurs que tu lui donnes, et c’est paramétrable au moyen de la variable d’environnement GOMAXPROCS.

+0 -0

Hello, merci pour ce billet. :)

J’ai tenté en Ada, d’abord avec les sockets proposées par la bibliothèque de GNAT (GNAT.Socket) mais Ada oblige, c’est assez fastidieux (il faut tout définir). J’ai donc utilisé AWS fourni avec tout environnement un peu moderne Ada.

Le code :

with Ada.Command_Line;
with Ada.Numerics.Discrete_Random;

with AWS.Response;
with AWS.Server;
with AWS.Status;

procedure Web_Server is
   type Image_Index_Type is range -21 .. -1; -- To avoid the stupid space in numbers image representation
   package Random_Image_Index is new Ada.Numerics.Discrete_Random (Image_Index_Type);
   
   function Server_Callback (Request : AWS.Status.Data) return AWS.Response.Data is
      Generator       : Random_Image_Index.Generator;
      Image_Index     : Image_Index_Type;
   begin
      Random_Image_Index.Reset (Generator);
      Image_Index := Random_Image_Index.Random (Generator);
      
      return AWS.Response.URL (Location => "https://avatar.spacefox.fr/Renard" & Image_Index'Img & ".png");
   end Server_Callback;
   
   Server : AWS.Server.HTTP;
   Port   : constant Natural := Natural'Value (Ada.Command_Line.Argument (1));
begin
   AWS.Server.Start (Web_Server     => Server,
                     Name           => "Image server",
                     Port           => Port,
                     Max_Connection => 30,
                     Callback       => Server_Callback'Unrestricted_Access);
   AWS.Server.Wait;
end Web_Server;

Le benchmark sur un laptop avec un Intel® Core™ i7–6700HQ CPU @ 2.60GHz :

ab -n 100000 -c 10 http://localhost:5555/
This is ApacheBench, Version 2.3 <$Revision: 1879490 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking localhost (be patient)
Completed 10000 requests
Completed 20000 requests
Completed 30000 requests
Completed 40000 requests
Completed 50000 requests
Completed 60000 requests
Completed 70000 requests
Completed 80000 requests
Completed 90000 requests
Completed 100000 requests
Finished 100000 requests


Server Software:        AWS
Server Hostname:        localhost
Server Port:            5555

Document Path:          /*
Document Length:        0 bytes

Concurrency Level:      10
Time taken for tests:   4.853 seconds
Complete requests:      100000
Failed requests:        0
Non-2xx responses:      100000
Total transferred:      20056846 bytes
HTML transferred:       0 bytes
Requests per second:    20606.35 [#/sec] (mean)
Time per request:       0.485 [ms] (mean)
Time per request:       0.049 [ms] (mean, across all concurrent requests)
Transfer rate:          4036.12 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.1      0       1
Processing:     0    0   0.1      0       4
Waiting:        0    0   0.1      0       4
Total:          0    0   0.1      0       5

Percentage of the requests served within a certain time (ms)
  50%      0
  66%      0
  75%      1
  80%      1
  90%      1
  95%      1
  98%      1
  99%      1
 100%      5 (longest request)

J’obtiens à peu près 20k requêtes/seconde. La mémoire consommée est de 4.8 Mo. Le binaire est à 6.0 Mo sans rien faire, 4.1 Mo après strip.

+2 -0

J’ai tenté en Rust pour voir. Je n’y connais absolument rien en réseau donc j’ai peut être d’une part fait un truc débile, et d’autre part il y a sûrement des crates pour créer un serveur de façon moins pédestre et plus abstraite (genre pas écrire la réponse à la main… :-° ).

adri1

En effet, malgré les perfs de Rust lui-même qui ne sont plus à démontrer, cette implémentation prend quand même de gros raccourcis qui biaisent assez lourdement la comparaison avec les serveurs HTTP des bibliothèques standard de Java et de Go qui sont production ready.

Entre autres :

  • Elles parsent les requêtes pour déterminer le chemin qui est requêté (la base de la base).
  • Elles sont prêtes à réagir de façon adéquate quand la connexion tombe inopinément.
  • Elles gèrent tout simplement le protocole HTTP avec toutes ses subtilités (le chunking, la réutilisation du transport sous-jacent, la négociation pour escalader celui-ci en TLS/SSL, …)

Pour le coup, je trouve qu’elles s’en sortent pas si mal si la différence de latence avec un echo server en Rust se borne à 1 milliseconde en conditions de test (c’est-à-dire anecdotique devant le Round Trip Time sur un réseau réel).

+0 -0

Pour le coup, je trouve qu’elles s’en sortent pas si mal si la différence de latence avec un echo server en Rust se borne à 1 milliseconde en conditions de test (c’est-à-dire anecdotique devant le Round Trip Time sur un réseau réel).

J’ai fait tourner ton implémentation pour voir, et en fait elle traite un peu plus de connections (~23 k/s contre 18 k/s) que mon implémentation Rust (je limite GOMAXPROCS à 1, mais en le laissant libre ça change pas grand chose). Et en regardant dans htop, ton implémentation utilise 100% du CPU sur lequel elle tourne alors que mon code Rust en utilise… 28 %. Je sais pas ce qui fait que le listener Rust glande autant. Peut être que c’est juste que on atteint un point où le listener est IO-bound (mais dans ce cas pourquoi on atteint pas les même perfs que le code Go? J’imagine qu’il fait des green-threads en arrière plan même si on limite le nombre de PROCS à 1?). J’ai aussi fait une implémentation avec hyper, qui fait de l’async avec tokio derrière et on retombe sur les perfs de Go en terme de nombres de connections (avec un shouia moins de mémoire consommée).

+0 -0

Je sais pas ce qui fait que le listener Rust glande autant.

Sans savoir du tout comment il est implémenté, il n’est pas impossible que ce listener soit purement séquentiel et donc que le code passe tout son temps à faire des appels systèmes successifs pour les IO, plutôt que de dispatcher l’échange sur le socket vers des threads distincts, pour justement utiliser tout le CPU disponible pendant que les handlers s’endorment en faisant leurs IO.

Le fait qu’une implem' avec tokio lève cette limite tendrait à confirmer cette hypothèse.

Edit:

mais dans ce cas pourquoi on atteint pas les même perfs que le code Go? J’imagine qu’il fait des green-threads en arrière plan même si on limite le nombre de PROCS à 1?

Dans le cas de Go, qu’il soit limité à un processeur ne l’empêche pas de créer plusieurs threads dans lesquels le runtime va scheduler un grand nombre de goroutines. Le MAXPROCS à 1 ne concerne vraiment que le nombre coeurs utilisés, c’est-à-dire que GOMAXPROCS est le nombre maximum de threads auxquels le runtime permettra d’être simultanément actifs pendant l’exécution du programme, pas le nombre max de threads existant simultanément.

Et donc sur ces N threads, le runtime va scheduler M goroutines avec M largement supérieur à N (le fameux "modèle de concurrence M:N").

La lib HTTP standard de Go se contente de faire le accept (ainsi qu’un peu d’intendance) sur la goroutine principale, et va lancer une nouvelle goroutine pour chaque connexion établie.

+0 -0

Cet échange m’a donné envie de faire un petit test. Je voulais notamment illustrer ce que j’explique juste au-dessus, en codant l’équivalent Go de la version Rust initiale d'@dri1, soit un listener TCP séquentiel, puis en lui ajoutant diverses stratégies de concurrence… mais ça ne s’est pas passé comme prévu. :D

J’ai donc implémenté un listener TCP séquentiel :

package main

import (
	"flag"
	"fmt"
	"math/rand"
	"net"
	"time"
)

var (
	port int
)

func init() {
	rand.Seed(time.Now().UnixNano())
	flag.IntVar(&port, "p", 80, "port to serve to")
	flag.Parse()
}

func main() {
	listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
	if err != nil {
		panic(err)
	}
	defer listener.Close()

	buf := make([]byte, 1024)
	for {
		conn, _ := listener.Accept()
		conn.Read(buf)
		fmt.Fprintf(conn,
			"HTTP/1.1 302 OK\r\nLocation: https://avatar.spacefox.fr/Renard-%d.png\r\n\r\n",
			rand.Intn(21),
		)
		conn.Close()
	}
}

Et là, de façon vraiment surprenante, ce code explose ma version HTTP plus haut en atteignant les 28k r/s. (contre 23k r/s de la version HTTP sur la même machine dans les mêmes conditions), tout en culminant à 70% d’utilisation d’un CPU. o_O

Quand on regarde ce qui se passe dans htop sur ma machine, on s’aperçoit que même s’il est écrit de façon parfaitement séquentielle, ce code trouve le moyen de répartir le travail sur plusieurs threads simultanés. J’imagine que Go doit faire de la magie en coulisses pour optimiser toutes ces IO, mais là comme ça, de but en blanc, ça me dépasse.

Ce qui est également un petit peu surprenant (mais moins), si on dispatche le travail des lignes 31 à 36 en spawnant des goroutines à part, le programme n’est pas plus performant pour autant (il plafonne aux alentours de 24k r/s). J’imagine que sur un exemple aussi simple où il n’y a vraiment aucun travail calculatoire (pas de parsing, rien, juste un bête Fprintf), l’overhead dû à l’ordonnancement des goroutines dans le runtime se fait sentir et ralentit effectivement les choses plus qu’il ne les améliore.

Testé avec Go 1.18 sur un Intel(R) Core(TM) i7-8565U CPU @ 1.80GHz. Le binaire lui-même pèse 2.6Mo et on ne dépasse pas les 8Mo de RAM.

+0 -0

J’ai essayé un truc, il suffit d’ajouter :

server.setExecutor(Executors.newCachedThreadPool());

juste avant le lancement du serveur en Java pour le rendre multi-thread. On augmente significativement les performances (entre x1.5 et x2), et là c’est le thread (unique) qui accepte les requêtes HTTP et qui les répartit qui sature.

Bonjour,

Merci pour ce billet. J’ignorais que Java 17 disposait d’un petit serveur HTTP en standard.

Je serais curieux de voir la version node.js, pour voir à quel point les super performances des I/O sont survendues ou pas.

+0 -0

En fait la classe date de Java 6 mais est peu utilisée. D’une part parce qu’elle est d’assez bas niveau et donc peu pratique pour des projets un peu complexe, d’autre part parce que c’est une API en com.sun.* que beaucoup de gens (y compris des outils automatiques !) confondent avec une API en sun.*, ces dernières ne devant pas être utilisées parce que privées (et supprimées des dernières versions de Java). Les API en com.sun.* sont pourtant standard (on les retrouve dans toutes les implémentations de Java, mêmes celles historiquement IBM) et sont simplement déconseillées dans quelques usages précis.

J’ai fait aussi un test rapide avec des outils plus « industriels » pour ce genre d’application (un microserveur minimaliste), comme vert.x. On a un peu moins de code un peu plus clair, et des performances sensiblement identiques (même la consommation RAM, ce qui m’a surpris). C’est aussi un facteur qui explique le manque d’intérêt pour HttpServer.

Vu qu’il y a des comparaison entre langages, j’ai voulu tester les performances d’un listener TCP séquentiel dans un mix moche de C et C++ en utilisant directement les sockets Linux. J’en ai aussi profité pour optimiser un peu comment la réponse est généré (0 allocation, 0 copie).

Mon premier benchmark me donne environ 55k requêtes par secondes. En comparaison, le listener TCP de @nohar gère environ 49k requêtes par secondes sur ma machine.

Le plus surprenant, c’est qu’après avoir implémenté du multithreading (qui est absolument trivial avec SO_REUSEPORT), les performances étaient exactement les mêmes. C’est à ce moment là que je me suis rendu compte que ab n’est pas multithreadé et que c’est lui le bottleneck, et de loin.

J’ai testé mon server C/C++ en lançant 5 ab en même temps (et un seul thread pour le server) et ils donnent chacun 28–29k requêtes par seconde, pour un total de 143k requêtes par seconde. Avec le même setup, le server Go donne seulement 67.5k requêtes par seconde, soit même pas la moitié des perfs de ma version.

Conclusion: le benchmark utilisé à la base n’est simplement pas capable de générer assez de requêtes pour mesurer correctement les performances des serveurs implémentés.

Pour référence, l’implémentation de mon serveur. Sans surprise, c’est nettement plus long que d’utiliser une lib plus haut niveau:

#include <arpa/inet.h>
#include <netinet/in.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>

#include <array>
#include <cstring>
#include <iostream>
#include <string_view>
#include <thread>

void warn(const std::string_view s) {
  std::cout << s << ": " << strerror(errno) << "\n";
}

void fail(const std::string_view s) {
  warn(s);
  exit(1);
}

void runServer(int i) {
  // Create a socket.
  const int sock = socket(AF_INET, SOCK_STREAM, 0);
  if (sock == -1) {
    fail("socket() failed");
  }

  // Allow multiple threads/processes to reuse the same port for trivial
  // multithreading.
  int optval = 1;
  if (setsockopt(sock, SOL_SOCKET, SO_REUSEPORT, &optval, sizeof(optval)) ==
      -1) {
    fail("setsockopt() failed");
  }

  // Bind the socket to port 8082.
  const sockaddr_in addr{
      .sin_family = AF_INET,
      .sin_port = htons(8082),
      .sin_addr = {INADDR_ANY},
  };
  if (bind(sock, reinterpret_cast<const sockaddr*>(&addr), sizeof(addr)) ==
      -1) {
    fail("bind() failed");
  }
  std::cout << "listen on 8082\n";

  // Listen to start accepting connections.
  if (listen(sock, 1024) == -1) {
    fail("listen() failed");
  }

  int num_reqs = 0;
#define PREFIX "HTTP/1.1 302 OK\r\nLocation: https://avatar.spacefox.fr/Renard-"
  char buf[1024];
  const int prefix_size = sizeof(PREFIX) - 1;
  char res1[] = PREFIX "0.png\r\n\r\n";
  char res2[] = PREFIX "00.png\r\n\r\n";
  for (uint32_t rand = 1337; true; rand = (rand * 48271) % 0x7fffffff) {
    // Accept a connection.
    sockaddr_in peeraddr;
    socklen_t peerlen = sizeof(peeraddr);
    const int peerfd =
        accept(sock, reinterpret_cast<sockaddr*>(&peeraddr), &peerlen);
    if (peerfd == -1) {
      fail("accept() failed");
    }

    // Basic logging to verify that requests are handled by multiple threads.
    ++num_reqs;
    if (num_reqs % 10000 == 0) {
      std::cout << "Thread " << i << " handled " << num_reqs << " requests\n";
    }

    char* res;
    int res_size;
    const char num = (rand % 21) + 1;
    if (num < 10) {
      res = res1;
      res_size = sizeof(res1) - 1;
      res1[prefix_size] = num + '0';
    } else {
      res = res2;
      res_size = sizeof(res2) - 1;
      res2[prefix_size] = (num / 10) + '0';
      res2[prefix_size + 1] = (num % 10) + '0';
    }

    // Write the response.
    if (write(peerfd, res, res_size) == -1) {
      warn("write() failed");
      goto cleanup;
    }
    // Read the request. This is necessary for some clients.
    if (read(peerfd, buf, sizeof(buf)) == -1) {
      warn("read() failed");
      goto cleanup;
    }
  cleanup:
    // Close the connection.
    if (close(peerfd) == -1) {
      warn("close() failed");
    }
  }
}

int main() {
  constexpr int num_threads = 1;
  std::array<std::jthread, num_threads> ts;
  for (int i = 0; i < ts.size(); ++i) {
    ts[i] = std::jthread(runServer, i);
  }

  return 0;
}

Conclusion: le benchmark utilisé à la base n’est simplement pas capable de générer assez de requêtes pour mesurer correctement les performances des serveurs implémentés.

C’est la preuve qu’on devrait toujours vérifier ses outils, parce pour moi un outil de bench qui prends un paramètre -c concurrency se devait d’être multithread quelque part -_-

Conclusion: le benchmark utilisé à la base n’est simplement pas capable de générer assez de requêtes pour mesurer correctement les performances des serveurs implémentés.

C’est la preuve qu’on devrait toujours vérifier ses outils, parce pour moi un outil de bench qui prends un paramètre -c concurrency se devait d’être multithread quelque part -_-

SpaceFox

C’est l’éternelle confusion entre concurrence et parallélisme. Ça arrive à tout le monde. :D

+0 -0

C’est l’éternelle confusion entre concurrence et parallélisme. Ça arrive à tout le monde. :D

nohar

Ben en fait, même pas. Mon propos, c’est que j’imaginais qu’un outil de bench (qui est donc amené à générer beaucoup de requêtes) était parallélisé pour permettre d’arriver à générer de grandes quantités de requêtes concurrentes sans devenir soi-même un goulet d’étranglement. C’est le fonctionnement de jMeter, entre autres.

Connectez-vous pour pouvoir poster un message.
Connexion

Pas encore membre ?

Créez un compte en une minute pour profiter pleinement de toutes les fonctionnalités de Zeste de Savoir. Ici, tout est gratuit et sans publicité.
Créer un compte