JeuWeb - Crée ton jeu par navigateur

Version complète : L'asynchrone : pourquoi et comment ?
Vous consultez actuellement la version basse qualité d’un document. Voir la version complète avec le bon formatage.
L'asynchrone : pourquoi et comment ?

Le problème des opérations longues et/ou planifiées

Lorsqu'on développe une application Web, on rencontre des tâches qui peuvent être longues et/ou qui doivent être planifiées dans le temps.

Parmi les tâches longues à effectuer, on trouve notamment :
  • L'envoi de mails, qu'on utilise souvent lorsque le joueur s'inscrit, pour notifier à un joueur qu'il a reçu un message privé, pour annoncer à tous les joueurs une nouveauté majeur, etc.

  • L'envoi de requêtes HTTP, pour envoyer un fichier sur Amazon S3, récupérer les informations Facebook d'un membre, etc.

  • Le traitement d'images, pour générer un avatar, réduire la taille d'une image, etc.


Parmi les opérations à planifier dans le temps (à instants fixes ou cycliques), on trouve :
  • Le déclenchement d'événements comme les changements de tour, la distribution de ressources, etc.

  • Les tâches de maintenance, comme le rafraîchissement des caches, l'actualisation des pages de classement, la suppression des comptes inactifs.

  • La planification de tâches, comme la publication de news écrites à l'avance.


Toutes ces tâches peuvent impacter négativement sur les performances de l'application et/ou l'expérience utilisateur. L'enjeu des traitements asynchrones sera alors de limiter voire supprimer ces aspects négatifs.

Effectuer des opérations longues

Effectuer les opérations longues au sein des requêtes HTTP

L'approche la plus évidente est d'effectuer ces opérations à même le code, à l'intérieur du cycle de vie de la requête HTTP, tout comme on ferait une affectation de variable ou une requête SQL.

Toutefois, cette approche a de nombreux défauts en terme de performance et d'expérience utilisateur.

Impact sur les performances

Lorsqu'un navigateur charge une page Web, il se connecte au serveur Web (Apache, par exemple) grâce au protocole HTTP. La connexion est initialisée, le serveur Web exécute le script et renvoie une réponse. Tout cela se passe généralement en moins d'une seconde (le plus souvent autour de 200 millisecondes).

Si plusieurs utilisateurs naviguent sur le site, le serveur Web est capable de gérer plusieurs requêtes simultannées (on dit que ces requêtes sont concurrentes).

Cependant, le nombre de requêtes HTTP que peut traiter un serveur Web dépend de sa configuration, qui dépend elle-même des performances du serveur : un serveur très puissant pourra traiter plus de requêtes concurrentes qu'un serveur plus modeste.

Si le serveur peut gérer 5 requêtes concurrentes et qu'à un même instant, 6 personnes chargent une page, toutes les files seront prises et le sixième visiteur devra attendre qu'une file se libère.

Ainsi, on voit qu'avoir des pages longues à charger réduit considérablement les capacités d'un serveur Web.

Impact sur l'expérience utilisateur

Outre le fait que le serveur soit plus rapidement surchargé quand les pages sont lentes, l'autre conséquence directe pour l'utilisateur, c'est bien d'attendre que les actions s'effectuent !

Ainsi, quand l'utilisateur s'inscrit et qu'un mail de confirmation est envoyé, l'opération prend souvent plus d'une seconde et le navigateur passe autant de temps à charger la page : ce n'est pas très agréable et ça donne déjà une mauvaise image du jeu. Imaginez alors le script qui permet à l'administrateur d'envoyer un email personnalisé à tous ses joueurs…

Effectuer les opérations longues en tâche de fond

Pour extraire ces opérations des requêtes HTTP, on peut les exécuter en tâche de fond grâce à un daemon installé aux côtés du serveur Web. L'utilisation de Cron est également une possibilité.

L'utilisation d'une telle solution implique l'utilisation d'un serveur dédié ou d'une plateforme cloud. Cela peut demander des compétences en administration système. De plus, cela implique certains coûts souvent supérieurs à ceux des hébergements mutualisés. Aux tarifs observés en septembre 2011, comptez 220€ par an environ pour une solution dédiée.

Pour implémenter ça en Ruby, j'utilise Delayed Job. Parmi les alternatives : Resque (avec Resque Scheduler pour planifier les tâches), qui ne sera pas couvert ici.

Des portages de ces deux solutions existent pour PHP avec DJJob et PHP Resque.

Exemples

Prenons un jeu de stratégie dans lequel les joueurs rejoignent des parties et s'y affrontent. Une fois le nombre de joueur atteint, chaque joueur peut commencer à déployer ses troupes. Cette phase est limitée dans le temps.

Pour implémenter ça simplement, il suffit — au moment où le déploiement commence — d'enregistrer une tâche qui sera exécutée à un instant précis.

class Game < ActiveRecord::Base
# ...
 
# Assign territories and set the limit time.
def start_deployment!
update_attributes!(state: Game:Big GrinEPLOYMENT, deployment_finish_at: 24.hours.from_now)
dispatch_territories!(Territory.all.shuffle, participations.shuffle)
 
job = FinishDeploymentJob.new(id) # On instancie notre tâche.
Delayed::Job.enqueue(job, run_at: deployment_finish_at) # On la planifie.
end
end

Et la classe associée, qui a seulement besoin d'implémenter une méthode perform.

class FinishDeploymentJob
attr_reader :game_id
 
# Constructeur de l'objet, on s'en sert pour donner des informations.
# L'objet sera serialisé, qu'on puisse accéder à ces informations lors
# de l'exécution.
def initialize(game_id)
@game_id = game_id
end
 
 
# Corps de la tâche. C'est là que ça se passe !
def perform
game = Game.find(game_id)
game.update_attribute(Confusedtate, Game::RUNNING)
 
mail = GameNotifier.notify_game_start(game)
mail.deliver
end
 
 
# On peut même configurer des callbacks qui seront appelés à différents moments
# du cycle de vie de la tâche. Ce n'est bien sûr pas obligatoire.
 
# Appelé avant l'exécution de la tâche.
def before(job)
end
 
# Appelé après l'exécution de la tâche, qu'elle réussie ou échoue.
def after(job)
end
 
# Appelé si la tâche est exécutée correctement.
def success(job)
end
 
# Appelé si une exception survient pendant l'exécution de la tâche.
def error(job, exception)
end
 
# Appelé si la tâche échoue.
def failure
end
end

Ensuite, on lance le processus qui traiteront les tâches : le worker. On peut d'ailleurs lancer plusieurs workers si le nombre de tâche en queue est important.

$ rake jobs:work

Mise en pratique des différentes approches

Pour déclencher des événements qui mènent à la mise à jour de certaines informations, certains développeurs testent des conditions à chaque chargement. Cela ajoute de la complexité au code et le rend plus difficile à tester.

Reprenons l'exemple de notre jeu de stratégie ou la durée du tour est limitée dans le temps et prenons les différentes approches.

Approche synchrone

À chaque chargement de page d'un joueur de la partie, le système teste si le tour du joueur actif n'est pas terminé depuis la dernière exécution de ce test.

Voici à quoi pourrait ressembler le code d'une telle page :

class GamesController < ApplicationController
before_filter :check_if_turn_must_change
 
def show
# ...
end
 
 
protected
 
def check_if_turn_must_change
@game = Game.find(params[:id])
@game.next_turn! if @game.turn_finish_at.past?
end
end

Ce n'est pas bien compliqué : on a une page show accessible à l'URL /games/42 (où 42 est l'ID de la partie) dans laquelle on récupère les informations de la partie, puis on teste si l'heure de fin du tour est passée. Si oui, on change de tour. Enfin, on rend la vue HTML de l'action.

Si la date n'est pas passée, rien ne se passe et le joueur accède à sa page normalement : même dans ce cas, le test a consommé des ressources inutilement. Ici, le test est très simple, mais il peut être plus complexe (dans le cas d'un calcul au prorata des ressources gagnées depuis le dernier passage du joueur, par exemple).

Si le joueur actif a changé, le système effectue les opérations nécessaires pour passer la main au joueur suivant. Ce traitement peut être long et le joueur a alors l'impression que sa page est longue à charger. C'est désagréable pour lui.

L'autre défaut de cette approche est que les joueurs ne remarquent les changements que quand ils actualisent la page.

Approche légèrement asynchrone

Ici, notre développeur utilise des requêtes asynchrones du client vers le serveur : ce qu'on appelle communément Ajax.

Régulièrement, les joueurs demanderont discrètement au serveur si l'état de la partie a changé. Attention : on devra continuer de faire les tests.

class GamesController < ApplicationController
before_filter :check_if_active_player_is_still_active
 
def show
# ...
end
 
def check_turn
response = { change_turn: @change_turn }
 
if @change_turn
response[:active_player] = @game.active_player
end
 
render json: response
end
 
 
protected
 
def check_if_active_player_is_still_active
@game = Game.find(params[:id])
@change_turn = @game.turn_finish_at.past?
 
@game.next_turn! if @change_turn
end
end

Les requêtes cycliques :

checkTurn = function(){
var url = '/game/42/check_turn';
$.get(url, function(data){
if(data.change_turn) visuallyChangeActivePlayer(data.active_player);
});
}
setInterval(checkTurn, 10 * 1000);

Ici, on ajoute une nouvelle action check_turn qui va elle aussi déclencher le filtre (et changer de tour si besoin). Cette action renverra un peu de JSON pour indiquer si le tour a changé ou non et le nouveau joueur actif s'il y a eu changement.

Le navigateur testera la réponse et apportera les changements nécessaires à l'interface pour notifier ce changement.

Au bout du compte, le changement de tour sera le plus souvent (mais pas toujours) effectué au sein d'une requête Ajax, les joueurs auront donc moins souvent cette sensation de lenteur du chargement.

Le problème, c'est que le serveur va régulièrement recevoir de nombreuses requêtes et que certaines risquent d'être longues à répondre (risquant donc de déclencher le mécanisme de file d'attente). Le problème d'expérience utilisateur est partiellement résolu mais ajoute le problème de la charge serveur.

Approche fortement asynchrone

Le développeur utilise des traitements asynchrones des deux côtés : les gros traitements s'effectuent en tâche de fond et les informations sont envoyées aux joueurs grâce au push.

Ici, l'approche est plus efficace : puisqu'on sait quand arrivera le tour suivant, on programme un événement. Il n'y aura pas de pertes :
  • Pas de tests inutiles au chargement des pages ;

  • Le serveur ne reçoit aucune requête inutile ;

  • Les opérations lourdes sont effectuées en dehors des requêtes ;

class GamesController < ApplicationController
def show
@game = Game.find(params[:id])
end
end
 
 
class Game
def next_turn!
next_turn_at = 8.hours.from_now
 
# La méthode next_active_player retourne… Le prochain joueur actif !
update_attribute(:active_player, next_active_player)
 
# On envoie les informations aux navigateurs grâce au push.
data = { next_turn_at: next_turn_at, active_player: active_player }
Push.publish("games/#{id}/change_turn", data)
 
# On planifie une tâche qui sera exécutée à la date du prochain tour (comme par hasard !).
Delayed::Job.enqueue(ChangeTurnJob.new(id), run_at: next_turn_at)
end
end
 
 
class ChangeTurnJob
def initialize(game_id)
@game_id = game_id
end
 
def perform
Game.find(@game_id).next_turn!
end
end

Côté client, on écoute les messages envoyés par le serveur :

Push.subscribe("/games/42/change_turn", function(data){
visuallyChangeActivePlayer(data.active_player);
});

Conclusion

Le mode de pensée asynchrone est très inhabituel pour un développeur Web débutant mais beaucoup plus efficace que le modèle synchrone. Il permet de tirer profit des ressources de sa machine en utilisant la parallélisation des processus et en évitant le gâchis de ressources. De plus, c'est un fonctionnement plus agréable pour vos utilisateurs.

La moralité est qu'en investissant un peu dans une infrastructure dédiée (comptez 220€ par an, en septembre 2011), on peut décupler les possibilités et la qualité de nos jeux.