La concurrence sur PHP / NodeJS / Golang / Erlang

Voir la vidéo

Je vous propose aujourd'hui de jeter un oeil sur la notion de concurrence et d'analyser le fonctionnement de quelque langages de programmation afin de mieux comprendre leurs spécificités.

Avant de nous lancer dans les explications, on va faire le point sur le vocabulaire employé :

  • un Thread désigne le fil d'éxécution, c'est une unité logiciel qui désigne un ensemble d'instructions que l'on va transmettre au processeur.
  • La concurrence désigne la capacité qu'a notre système de démarrer plusieurs threads et d'alterner afin de traiter plusieurs tâches en même temps (sans nécessairement faire les choses en parallèle). Je vous conseille d'alleurs ce talk de Rob Pike (co-créateur de GoLang) sur le sujet
  • Un langage qui "scale" est un langage qui bénéficie plus ou moins des différentes méthode de scaling
    • scaling vertical qui consiste à améliorer les performances de notre serveur (meilleur CPU, meilleur disque dur...)
    • scaling horizontal qui consiste à distribuer le système sur plusieurs serveurs.

tl;dnr : On ne peut pas comparer les oranges et les pommes, les langages ont des approches très différentes vis à vis de la concurrence ce qui peut donner des performances complètements différentes suivant les situation. Dans tous les cas ils "scalent" si on distribue la charge sur plusieurs serveurs (on lance X fois notre application avec en amont un load balancer).

PHP

Même si PHP dispose d'un module permettant le multi-threading il n'est quasiment jamais utilisé et le code est conçu pour ne fonctionner que sur un seul thread. Un processus PHP ne sera donc pas en mesure de traiter plusieurs requête de manière concurrente et chaque visiteur devra attendre son tour lors du traitement.

En production, PHP-FPM qui va se charger de créer un ensemble de processus PHP (pool), chacun étant capable de gérer les requêtes sur un seul thread. Cette méthode permet de traiter un traffic plus important à condition de configurer correctement le nombre de processus à générer.

  • Si on crée trop peu de processus, il y a de forte chance que notre processeur ne soit pas correctement utilisé et que tous nos processus se retrouvent bloqué en attendant des retour depuis le disque dur ou le réseau.
  • Si on crée trop de processus on va forcer notre système à sauter de l'un à l'autre ce qui peut ralentir considérablement notre application. Le risque est aussi de saturer la mémoire en créant trop de processus qui interprètent de grosses librairies PHP.

Malheureusement, il n'existe pas de méthode miracle pour configurer correctement PHP-FPM et il faudra faire pas mal d'essais pour obtenir une configuration qui convient à votre situation.

Ruby, Python, Java...

Ruby, Python et Java permettent la création de threads plus facilement avec la présence de classes dédiées dans le langage. Ceci dit, il est assez rare de voir cette capacité utilisée dans la logique métier de notre application web. Cette capacité de multi-threading va plutôt être utilisé lors de la conception du serveur web. C'est par exemple le cas avec puma, qui va, comme pour php-fpm, créer plusieurs thread ruby. La problématique sera alors la même que PHP-FPM (combien de workers doit-on lancer ?).

NodeJS

A single instance of Node.js runs in a single thread.

NodeJS est souvent désigné comme un outil capable de gérer des milliers de requêtes pourtant en lisant la documentation il ne semble pas si différent des langages évoqués précédemment.

Si on écrit le code de manière "traditionel", NodeJS ne va pas agir miraculeusement et les performances ne seront pas éloigné de PHP ou autre.

const http = require('http')
const sleep = require('sleep')

http.createServer((req, res) => {
  res.writeHead(200, {'Content-Type': 'text/plain'})
  sleep.msleep(100) 
  res.end('Hello')
}).listen(3000, '0.0.0.0')

Ici le sleep.msleep(100) va bloquer le processus pendant 100ms, l'empéchant de gérer plus de 10 requêtes par secondes.

Node.js uses an event-driven, non-blocking I/O model that makes it
lightweight and efficient

Pour profiter des capacités non bloquante de NodeJS il va falloir écrire notre code de manière asynchrone :

const http = require('http')

http.createServer((req, res) => {
    res.writeHead(200, {'Content-Type': 'text/plain'})
    setTimeout(function () {
        res.end('Hello')
    }, 100)
}).listen(3000, '0.0.0.0')

On remarque que la structure de notre code est légèrement différente. Ce code va permettre de dire à NodeJS "écrit le head, met ce code dans la fil d'attente pendant 100ms, tu éxécutera la suite plus tard" ce qui permet donc à notre thread de gérer d'autres requêtes pendant l'intervale d'attente.

Cette méthodologie est appliquée pour toutes les opération "lentes" (souvent l'I/O ou l'accès réseau).

// Pour lire un fichier
const fs = require('fs')
fs.readFile('/etc/hosts', 'utf8', function (err,data) {
  if (err) {
    return console.log(err)
  }
  console.log(data)
})

// Appeler une URL
const request = require("request")

request({
    url: "http://fake-api-demo/users.json",
    json: true
}, function (error, response, body) {
    if (!error && response.statusCode === 200) {
        console.log(body)
    }
})

Mais cette approche apporte aussi une certaine lourdeur dans le cas où on souhaite faire les opérations de manière séquentielle :

// Exemple provenant de http://callbackhell.com/
fs.readdir(source, function (err, files) {
  if (err) {
    console.log('Error finding files: ' + err)
  } else {
    files.forEach(function (filename, fileIndex) {
      console.log(filename)
      gm(source + filename).size(function (err, values) {
        if (err) {
          console.log('Error identifying file size: ' + err)
        } else {
          console.log(filename + ' : ' + values)
          aspect = (values.width / values.height)
          widths.forEach(function (width, widthIndex) {
            height = Math.round(width / aspect)
            console.log('resizing ' + filename + 'to ' + height + 'x' + height)
            this.resize(width, height).write(dest + 'w' + width + '_' + filename, function(err) {
              if (err) console.log('Error writing file: ' + err)
            })
          }.bind(this))
        }
      })
    })
  }
})

Ceci dit, l'introduction des promesses et de l'async / await simplifiera grandement l'utilisation de la nature asynchrone de NodeJS.

Comme vu dans le premier exemple, le code JavaScript lui va rester bloquant et si vous faites des opérations un peu lourdes la boucle d'évènement de NodeJS peut bloquer l'ensemble de votre application.

Si vous disposez de plusieurs core il est possible de distribuer la charge en utilisant un système de cluster qui va avoir pour effet de créer un système de workers pour distribuer la charge. En revanche, il ne sera pas possible de distribuer un ensemble d'opération sur plusieurs core, le code JavaScript n'est éxécuté que sur un seul thread à la fois.

Pour plus d'information sur NodeJS n'hésitez pas à faire un tour sur la formation dédiée

Golang

Golang est un langage qui intègre la notion de concurrence directement dans son langage à travers les goroutines et les channels.

Une goroutine est une fonction qui peut fonctionnée en concurrence avec le fil principal. Elles sont beaucoup plus légères qu'un thread (quelques ko) et on peut en créer/détruire des milliers très rapidement. C'est l'environnement d'éxécution qui déterminera comment distribuer les goroutines au seins des différents threads afin d'éviter d'éventuels blocages.

Pour créer une goroutine il suffit d'utiliser le mot clef go lors de l'appel à une fonction

package main

import "fmt"

func f(n int) {
  for i := 0; i < 10; i++ {
    fmt.Println(n, ":", i)
  }
}

func main() {
  go f(0)
  go f(2)
  fmt.Println("Salut !")
}

// Affiche "Salut !"

Les goroutines s'éxécutent de manière concurrente mais le programme n'attend pas nécessairement la fin de l'éxécution de ces dernières. Pour communiquer avec ces goroutines Golang offre un système de channels qui permet de faire passer des messages.

package main

import "fmt"
import "time"

func worker(done chan bool) {
    fmt.Print("chargement...")
    time.Sleep(time.Second)
    fmt.Println("finit !")
    done <- true
}

func main() {

    // On crée un channel
    done := make(chan bool, 1)
    // On lance notre worker dans une goroutine
    go worker(done)

    // On attend le retour dans le canal
    <-done
}

C'est cette stratégie qui est utilise par le module "http" de golang qui va créer une goroutine par requête. En revanche cela peut poser des problèmes si des milliers de goroutines se mettent à monopoliser le CPU en même temps.

package main

import (
    "fmt"
    "net/http"
    "time"

    "golang.org/x/crypto/bcrypt"
)

func handlerRoot(w http.ResponseWriter, r *http.Request) {
    time.Sleep(100 * time.Millisecond)
    fmt.Fprintf(w, "Hello")
}

func handleBcrypt(w http.ResponseWriter, r *http.Request) {
    hashedPassword, err := bcrypt.GenerateFromPassword([]byte("Hello"), 12)
    if err != nil {
        panic(err)
    }
    fmt.Fprint(w, string(hashedPassword))
}

func main() {
    fmt.Println("Starting server")
    http.HandleFunc("/", handlerRoot)
    http.HandleFunc("/bcrypt", handleBcrypt)
    http.ListenAndServe(":3000", nil)
}

Si 100 requêtes arrivent sur la page bcrypt il va essayer de lancer 100 hashage bcrypt de manière concurrente ce qui aura un impact négatif sur les performances du reste de notre application.

Mais vu que l'on a la main sur les goroutines il est possible de créer des structures plus complexes pour gérer la concurrences. Par exemple on peut créer un pool de goroutines pour gérer le hashage des mots de passes.

package main

import (
    "fmt"
    "net/http"
    "time"

    "golang.org/x/crypto/bcrypt"
)

func worker(id int, in <-chan string, out chan<- string) {
    for {
        w := <-in
        fmt.Println("worker", id)
        h, _ := bcrypt.GenerateFromPassword([]byte(w), 12)
        fmt.Println("/worker", id)
        out <- string(h)
    }
}

func pool(workers int) (chan string, chan string) {
    in := make(chan string)
    out := make(chan string)

    for i := 0; i < workers; i++ {
        go worker(i, in, out)
    }

    return in, out
}

func handleBcryptPool(in chan string, out chan string) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        in <- "Hello"
        fmt.Fprint(w, <-out)
    }
}

func main() {
    fmt.Println("Starting server")
    in, out := pool(3)
    http.HandleFunc("/bcrypt", handleBcryptPool(in, out))
    http.ListenAndServe(":3000", nil)
}

Cette méthode permet de s'assurer que le processeur (un quad core dans notre cas) ne se trouve jamais saturé par les opérations de hashage. C'est un exemple relativement naïf mais ça permet de mettre en avant le degré de contrôle possible.

Golang, au travers des goroutines, permet donc de penser la concurrence au sein de son application.

Un grand pouvoir implique de grandes responsabilités

En revanche, il faudra du coup réfléchir à l'utilisation de ces dernières pour concevoir un système qui soit adapté aux différentes situations possibles.

Elixir / Erlang

Erlang est une technologie qui a été conçu avec cette notion de concurrence / distribution dès le début et possède, comme pour golang, un système de process interne très léger.

Erlang is designed for massive concurrency. Erlang processes are lightweight (grow and shrink dynamically) with small memory footprint, fast to create and terminate, and the scheduling overhead is low.

Sans forcément rentrer dans les détails, une application erlang va être conçu en adoptant une architecture OTP où chaque processus va être surveillé par un superviseur capable de le relancer en cas de plantage.

Pour les exemples je vais ici utiliser Elixir qui est un langage compilé pour la machine virtuelle erlang (le langage est un peu plus simple à comprendre et possède des primitives pour utiliser l'OTP plus facilement).

Si on reprend l'exemple de notre application web, on commence par créer le supervisteur principal qui va avoir 2 enfants.

defmodule Demo.Application do

  @moduledoc false

  use Application

  def start(_type, _args) do
    import Supervisor.Spec, warn: false

    # On crée 2 sous process
    children = [
      worker(Demo.Web, []),     # Notre serveur HTTP (qui utilise Plug / Cowboy)
      worker(Demo.Bcrypt, [])   # Notre process qui hash les mot de passes
    ]

    # Stratégie à utiliser en cas de crash, si un process plante, on le relance
    opts = [strategy: :one_for_one, name: Demo.Supervisor]
    # On démarre le superviseur
    Supervisor.start_link(children, opts)
  end
end

Afin de ne pas impacter notre application on décide de séparer le hashage de mot de passe dans son propre processus. Elixir fournit une manière très simple de définir un processus gràce à l'utilisation de GenServer.

defmodule Demo.Bcrypt do

  use GenServer

  @name :bcrypt

  def start_link do
    GenServer.start_link(__MODULE__, [], name: @name)
  end

  # Fonction servant de racourci pour communiquer avec notre processus
  def hash(key) do
    GenServer.call(@name, {:hash, key}, 150000)
  end

  # Fonctions propre à notre processus (GenServer)
  # Quand le processus reçoit le message {:hash, ...} alors il répond avec la hash
  def handle_call({:hash, key}, _from, state) do
    hash = Comeonin.Bcrypt.hashpass(key, Comeonin.Bcrypt.gen_salt(12, true))
    {:reply, hash, state}
  end

  # Si il recçoit un autre message il ne fait rien
  def handle_call(request, from, state) do
    super(request, from, state)
  end

end

Gràce à GenServer on vient de créer un processus séparé qui va être capable de recevoir des messages et d'effectuer des opérations de manière concurrente par rapport à notre process principal. Si on souhaite chiffrer un mot de passe on peut alors faire appel à ce process à n'importe quel moment.

GenServer.call(:bcrypt, {:hash, key}, 5000)

On peut plus tard décider de transformer ce process en pool de workers, ou même le déplacer sur son propre serveur afin de distribuer la charge de notre application (Erlang dispose d'un système de distribution automatique sur un réseau).

Enfin le serveur web va être plutôt simple gràce à l'utilisation de Plug et Cowboy.

defmodule Demo.Web do  

  use Plug.Router
  require Logger

  plug Plug.Logger
  plug :match
  plug :dispatch

  def init(options) do
    options
  end

  def start_link() do
    {:ok, _} = Plug.Adapters.Cowboy.http(__MODULE__, [])
  end

  # Cette fonction ne bloque pas le processeur on peut en lancer des milliers
  get "/" do
    :timer.sleep(100)
    conn
    |> send_resp(200, "Hello")
    |> halt
  end

  # Cette fonction bloque le processeur et en lancer trop ralentira notre application
  get "/bcrypt" do
    hash = Comeonin.Bcrypt.hashpass("Hello", Comeonin.Bcrypt.gen_salt(10, true))
    conn
    |> send_resp(200, hash)
    |> halt
  end

  # Ici on fait appel à notre processus créé précédemment afin de "limiter" le nombre
  # de hashage concurrent
  get "/one-process-bcrypt" do
    conn
    |> send_resp(200, Demo.Bcrypt.hash("Hello"))
    |> halt
  end

  match _ do  
    conn
    |> send_resp(404, "Nothing here")
    |> halt
  end

end  

Erlang / Elixir offre une meilleur gestion de la concurrence en permettant au développeur de découper son application sous forme de processus, chacun responsable de son propre état et de son fil d'éxécution. Il offre une couche supplémentaire avec les superviseurs qui permettent de surveiller les processus et qui agissent automatiquement en cas d'erreur.

Du coup "C'est quoi le mieux ?"

Comme vous avez pu le voir au fil de cet article les différents langages présentés ici ont des approches complètements différentes vis à vis de la concurrence, ce qui peut amener à des écarts de performances considérables suivant les modèles de concurrence choisis et les situations.

  • PHP, apporte une approche stateless où chaque requête est traitée de manière complètement isolée. Si une page a une erreur, le process en question la renvoit et se prépare à répondre à la prochaine requête. En revanche il faudra adapter la configuration de php-fpm suivant la capacité du serveur et la charge.
  • Ruby / Python / Java, intègre le multi-threading dans le langage mais est peu utilisé dans l'écritue d'application web. Le multi-threading est surtout utilisé pour l'architecture du serveur web qui se base sur un système de worker.
  • NodeJS, permet de gérer plus de choses sur un seul thread en gérant l'I/O de manière asynchrone, par contre il faudra faire attention à ne pas bloquer la boucle d'évènement. Vous pouvez utiliser le système de cluster pour distribuer la charge et utiliser des outils comme pm2 pour gérer les crash en cas d'erreurs non capturées.
  • Golang, intègre la notion de concurrence dans le langage gràce aux goroutines et aux channels. Il est possible de créer un système concurrent facilement mais il faudra se construire ses propres modèles. Il faudra faire attention à capturer les erreurs possibles pour éviter qu'une erreur plante une des goroutine (très chiant à debug).
  • Erlang / Elixir, offre un découpage en processus avec l'utilisation de superviseur pour assurer une très grande stabilité. En revanche, il est nécessaire de penser son application web différemment avec un découpage en processus pour réellement observer des gains de performances.

Dans tous les cas, peu importe le langage, il est tout a fait possible de scaler votre application horizontalement en la dupliquant et en utilisant un load balancer en amont.

Il est aussi possible de séparer votre application en plusieurs morceaux et de mélanger les technologies suivant les cas de figure (serveur http en PHP, serveur websocket en NodeJS, traitements lourds en Golang...).

Publié
Technologies utilisées
Auteur :
Grafikart
Partager