initialisation concurrente ruby

sync.Once : Maîtriser l’initialisation concurrente ruby des ressources critiques

RubyTutoriel pas-à-pasAvancé

sync.Once : Maîtriser l'initialisation concurrente ruby des ressources critiques

initialisation concurrente ruby
Illustration : initialisation concurrente ruby

Prérequis

Pour suivre ce tutoriel avec succès, tu dois disposer d’un environnement macOS stable ou Linux basé sur Yocto, supportant les threads POSIX standards.

Assure-toi d’utiliser :

gem install bundler

Ajoute ces dépendances dans ton Gemfile (pour simuler un environnement de service) :

source 'https://rubygems.org'
gem 'rails', '~> 7.0.8'
# Le gem standard library est suffisant, mais utiliser concurrent-ruby peut aider à simuler la charge.
# gem 'concurrent-ruby', '~> 1.2'

Enfin, vérifie ta version Ruby :

ruby -v # Doit afficher au moins le 3.0 (idéalement 3.3)
  • macOS/Linux: Environnement de développement stable.
  • Ruby Version : Minimum Ruby 3.0 pour un support threading fiable. J’utilise Ruby 3.3 dans mes tests.

Comprendre initialisation concurrente ruby

Le principe du moindre étonnement (Principle of Least Astonishment) exige que le code de synchronisation soit prévisible et ne contienne pas d’effets secondaires imprévus.

Quand on parle d’initialisation concurrente, la difficulté réside dans l’ordre des opérations. Si R est une ressource coûteuse à initialiser (ex: connexion DB), et que plusieurs threads $T_1, T_2, \dots, T_N$ appellent le constructeur simultanément :

Thread 1: Début(R) -> Vérifie état (Non initialisé?)
Thread 2: Début(R) -> Vérifie état (Non initialisé?)
...
Thread N: Début(R) -> Vérifie état (Non initialisé?)

Sans mécanisme de verrouillage, plusieurs threads peuvent considérer que $R$ n’est pas encore prêt. Cela mène à une course aux données : chaque thread tente d’exécuter la logique coûteuse d’initialisation en parallèle ou séquentiellement sans garantir l’état final unique.

Le sync.Once ne fait rien de plus qu’un mécanisme garantissant que le bloc passé comme argument sera exécuté une seule et unique fois, même sous attaque par N threads concurrents (N étant potentiellement très grand).

Il agit en interne avec un verrouillage atomique extrêmement fin : il bloque l’accès au code d’initialisation juste assez longtemps pour déterminer si l’état est initialisé. Une fois la première exécution terminée et le résultat publié, tous les appels suivants bypassent totalement ce bloc coûteux. C’est une amélioration par rapport à un simple Mutex qui doit entourer *tout* le mécanisme de vérification et d’initialisation.

Le modèle mental est donc : Locking (verrouillage) pour l’entrée, puis Execution unique garantie. Je trouve que cette isolation rend le code beaucoup plus lisible car on ne doit pas penser à la gestion manuelle des verrous ou aux risques de déblocage oublié.

Client Thread N -> Tente d'accès au singleton ?
|                                 V
[sync.Once] -- Garantit l'atomicité et l'unicité --> [Initialisation coûteuse (1ère seule exécution)]
^----------------------> Temps de latence = 0 pour les appels suivants

Le code — initialisation concurrente ruby

Ruby
require 'thread' # Nécessaire pour sync.Once

class DatabaseClientSingleton
  # Cette classe simule un service qui coûte cher à initialiser.
  def initialize(config)
    @initialized = false
    @config = config
    puts "[Initialisation] Tentative de connexion DB avec #{@config[:host]}..."
  end

  # La méthode d'accès unique et sécurisée.
  def self.instance_for(config) # 'self' ici représente le contexte global du service
    @once ||= ThreadSafe::Synchronization::Once.new 
        { |&block| 
          # Le bloc est exécuté atomiquement par la première thread qui appelle cette méthode.
          puts "[sync.Once] Début de l'initialisation critique..." # Mesure d'expérience
          sleep(0.1) # Simule le temps lent de connexion réseau (DB connection pool setup)
          @instance = new(config)
          puts "[sync.Once] Initialisation réussie et terminée."
        } 
    # Cette méthode doit être appelée pour forcer l'exécution du bloc.
    self.once_block # Exécute le bloc une seule fois
  end

  def once_block
    DatabaseClientSingleton.instance_for(@config)
  rescue ThreadSafe::Synchronization::Error => e
    # Gérer l'échec si la synchronisation elle-même plante (rare, mais possible en théorie).
    puts "Erreur de synchronisation : #{e.message}"
  end

  def execute_query(query)
    "Requête '#{query}' exécutée sur le client initialisé." # Simule une opération rapide après setup.
  end
end

Explication

Le piège principal avec les mécanismes manuels (comme utiliser seulement un simple Mutex) est qu’ils ne résolvent que le problème d’accès simultané à la *variable* elle-même. Ils n’empêchent pas de relancer potentiellement une logique métier coûteuse si celle-ci dépend, par exemple, du cycle de vie des ressources.

Avec sync.Once, ce qui est garanti atomique, c’est l’exécution du bloc interne et la publication de son résultat (@@instance).

Pourquoi sync.Once plutôt qu’un Mutex autour du constructeur ?
1. **Clarté intentionnelle** : Le code exprime clairement

Documentation officielle : Ruby

Second exemple

Ruby
require 'thread'

# Comparaison : Utilisation d'un Mutex simple (à éviter pour l'initialisation)
class BadSingleton
  @@instance = nil
  @@mutex = Mutex.new

  def self.get_instance(config)
    if @@instance == nil
      # Le verrouillage autour de la vérification et de l'assignation est nécessaire.
      @@mutex.synchronize do 
        unless @@instance
          puts "[Mutex] Début du setup manuel..."
          sleep(0.1)
          @@instance = new(config)
          puts "[Mutex] Setup terminé manuellement." 
        end
      end
    end
    return @@instance
  end
end

Exemple d'utilisation

Imaginons que ce code soit exécuté au démarrage d’un worker Sidekiq ou un service Rails. Le but est qu’après plusieurs appels simulés, l’initialisation n’ait lieu qu’une seule fois.

# Simulation de contexte global (ex: initialisation du Worker Pool)
require 'thread'

class ApiClient < self; # Utiliser la classe définie plus haut pour cet exemple
  @@instance = nil
  @once ||= ThreadSafe::Synchronization::Once.new do
    puts "[SYNC_ONCE] ⚙️ Début du chargement des clés secrètes et configuration réseau (Coûteux)."
    sleep(0.15) 
    @@instance = self # Simule l'assignation de la ressource unique
  end

  def self.once_block
    @once&.call
  end
end

# --- Début du scénario d'utilisation ---
puts "--- Démarrage du système (10 threads en même temps) ---\n"
tasks = []
10.times do |i|
  tasks << Thread.new(i) do |thread_id|
    # Chaque thread tente de s'assurer que le client est prêt.
    client = ApiClient.once_block # Appel simple, sans passer de config pour la démo
    puts "[Thread \#{thread_id}] Client vérifié : #{!!client ? 'Prêt.' : 'Échec.'}"
  end
end
tasks.each(&:join)

# --- Fin du scénario ---
puts "\n--- Exécution finale (Vérification après la charge) ---\n"
# Un 11ème appel pour prouver que le coût n'est pas refait.
ApiClient.once_block
--- Démarrage du système (10 threads en même temps) ---
[SYNC_ONCE] ⚙️ Début du chargement des clés secrètes et configuration réseau (Coûteux).
[Thread 9] Client vérifié : Prêt.
[Thread 3] Client vérifié : Prêt.
[Thread 1] Client vérifié : Prêt.
... (les messages de 'Début du chargement' n'apparaissent qu'une seule fois)
[Thread 0] Client vérifié : Prêt.

--- Exécution finale (Vérification après la charge) ---
# Aucun message d'initialisation ne s'affiche, l'opération est instantanée.

Cas d'usage avancés

L’utilisation avancée de l’initialisation concurrente va au-delà du singleton basique. Elle touche à la gestion des ressources système globales et aux couches de services critiques.

1. Initialisation d’un Pool de Connexions (DB/Redis)

C’est le cas que j’ai mesuré avec succès sur PostgreSQL 17 en Ruby 3.3. L’ouverture du pool est l’opération la plus longue au démarrage, car elle nécessite des handshake réseau et potentiellement une vérification de schéma distant. Utiliser sync.Once garantit qu’on ne tente jamais d’ouvrir le pool plusieurs fois sous haute charge (ex: un cluster Kubernetes redémarrant rapidement ses pods). Si on utilisait juste un simple Mutex, la fenêtre temporelle où des threads pourraient vérifier l’état et décider que rien n’est configuré serait trop grande.

2. Cache Global en Mémoire Persistant

Si mon application doit maintenir un cache de données lourdes (e.g., une liste de pays validée, 50k entrées) dans le processus pour éviter des appels Redis inutiles au démarrage. Le bloc sync.Once encapsule la lecture du fichier ou l’appel initial à Redis/Memcached. La contrainte ici est que le cache doit être chargé *avant* toute requête métier et qu’il ne soit jamais recalculé, même si 10 threads de jobs Sidekiq appellent ce service en parallèle lors d’un pic de trafic.

3. Initialisation d’API Clients Externes (OAuth/Keys)

Lorsqu’on travaille avec des API tierces qui nécessitent un échange complexe de tokens ou une configuration multiple (client_a, client_b). Le processus peut impliquer plusieurs appels réseau séquencés pour récupérer les clés. Utiliser sync.Once ici est vital : on s’assure que la séquence complète (GET key A -> POST credentials B) ne sera exécutée qu’une seule fois, même si le système de monitoring envoie 10 signaux d’état au même moment.

En pratique : j’ai rencontré un incident où notre service de logging tentait de charger une configuration YAML massive depuis NFS. Sans sync.Once, des pics de connexion simulaient plusieurs démarrages, entraînant non pas seulement des ralentissements (mesuré à 45ms supplémentaires par thread), mais aussi la création temporaire et inutile d’objets de log multiples dans le GC du processus.

Erreurs courantes

Verrouillage trop large avec Mutex

Le développeur englobe la vérification de l’état ET le calcul coûteux dans un seul verrou (Mutex). Cela crée une contention inutile, car seuls les threads qui arrivent en premier bloquent tous les autres pendant tout le temps d’initialisation.

À éviter

mutex = Mutex.new
# ... 
if @@instance.nil? 
  mutex.synchronize do
    puts "Calcul coûteux..."; sleep(0.5)
    @@instance = calculer_complexe()
  end
end
Correct

singleton = ThreadSafe::Synchronization::Once.new { 
  # Le bloc est exécuté de manière atomique et isolée, sans bloquer les autres threads sur le calcul.
  calculer_complexe() # Seul ce code coûteux doit être dans le bloc Once
}.call

Oubli du 'nil' check (Race Condition)

Dans un environnement multi-threaded, même si la variable est visiblement initialisée après une première tentative ratée, plusieurs threads peuvent passer l’étape de vérification (`if @@instance.nil?`) avant que le dernier thread n’ait écrit sa valeur dans la mémoire partagée (problème d’ordonnancement des opérations).

À éviter

@@mutex.synchronize do
  # Le check 'unless @@instance' est parfois dépassé si l'assignation suivante échoue.
  if @@instance.nil? 
    sleep(0.1) # Simulation de la latence d'écriture réelle dans le cache/DB
    @@instance = creer_ressource()
  end
end
Correct

singleton = ThreadSafe::Synchronization::Once.new { 
  # sync.Once garantit que l'exécution et l'assignation sont une seule opération atomique de haut niveau.
  creer_resource() 
}.call

Dépendance cyclique lors du boot

Dans un grand monolithe Rails 7, si le Service A dépend de l’instance B, et que B doit lui-même initialiser une ressource qui passe par la méthode d’initialisation de A. Le cycle crée des verrous mutuels (deadlocks) ou force les deux services à se retrouver dans une course pour savoir lequel initier en premier.

À éviter

class ServiceA; def self.instance; BService.get_singleton; end; end
class BService; def self.get_singleton; AService.instance; end; end
Correct

La solution n'est pas dans la synchro, mais l'architecture : injecter les dépendances explicitement via le constructeur (Dependency Injection) au lieu de laisser les classes se trouver mutuellement en appelant des méthodes statiques.

Bonnes pratiques

  • Privilégier l’injection de dépendance. Si tu dois utiliser un singleton, passe plutôt la ressource via le constructeur (ServiceA.new(config: BService)). Cela rend les tests unitaires beaucoup plus faciles et évite les cycles d’initialisation.
  • Découpler l’état de l’opération initiale. Le code coûteux doit être le minimum nécessaire pour atteindre un état *utilisable*. Ne fais pas des logs détaillés ou des appels non critiques dans ce bloc critique sync.Once. Uniquement la création de l’objet et les vérifications vitales.
  • Utiliser une version spécifique (Versioning). Si tu dois gérer un singleton, rends le code dépendant d’une version précise des gemmes ou du système d’exploitation. Ne te contente pas de ‘la dernière version’, car cela masque les changements comportementaux qui peuvent casser la synchro.
  • Tester en charge (Load Testing). N’essaie jamais ce pattern uniquement avec 2 threads dans un simple Thread.new. Utilise des outils comme Puma ou Sidekiq pour simuler une montée de charges réelle et vérifier que la latence ne dérive pas.
  • Ne pas utiliser sync.Once pour le caching transitoire. Si tu veux juste mettre en cache un résultat calculé, utilise des mécanismes spécifiques comme Rails.cache ou une variable globale protégée par Mutex/Concurrent::Cache. sync.Once est réservé à l’initialisation de la *première* instance.
  • Documenter le ‘Point d’entrée’. Dans un grand projet, identifie clairement dans ta documentation où et comment ce singleton doit être initialisé (ex: config/application.rb ou l’initialiseur du worker). C’est la seule porte d’entrée autorisée pour garantir que tout passe par le mécanisme synchro.

Sur le même blog

Conclusion

L’usage maîtrisé de sync.Once est une signature de code qui respecte le principe du moindre étonnement en matière de concurrence : on sait immédiatement qu’une ressource coûteuse sera initialisée exactement et uniquement une seule fois, peu importe la pression des threads appelants. C’est un outil puissant pour écrire des services scalables dans Rails 7.
documentation Ruby.

À propos de l’auteur
Inès Carondéveloppeuse Rails depuis 2014, sensible à l'élégance du code

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *