Détection des problèmes de latence
Le premier indicateur à surveiller est l'histogramme de latence de planification fourni par tokio-metrics. Cette métrique mesure le délai entre la disponibilité d’une tâche (par ex. données reçues sur un socket) et le moment où le runtime la poll réellement. Un écart important entre le P99 et le P50 indique que certains polls durent beaucoup plus longtemps que la moyenne, signe typique de long polls. Si les durées de poll dépassent les 10‑100 µs recommandées, il faut vérifier si ces longs polls impactent les métriques utilisateurs ou s’ils restent neutres.
Yield et agrégation du travail
Pour réduire la latence, il faut céder le contrôle au runtime plus fréquemment. Dans un serveur qui lit des requêtes pipelinées, chaque appel à read_frame peut retourner Poll::Ready plusieurs fois sans interruption, créant ainsi une file d’attente où une connexion monopolise le worker. Insérer tokio::task::yield_now().await après chaque requête ou après un petit groupe de requêtes permet de redistribuer les cycles CPU entre les clients, ce qui a montré une amélioration de l’ordre de 10× dans des tests de pipeline Redis.
async fn handle_conn(&mut self) -> crate::Result<()> {
while !self.shutdown.is_shutdown() {
let frame = tokio::select! {
res = self.connection.read_frame() => res?,
_ = self.shutdown.recv() => return Ok(()),
};
execute_command(&self.db, &mut self.connection, frame).await?;
// tokio::task::yield_now().await;
}
}
En parallèle, il faut regrouper les opérations bloquantes. Chaque appel à spawn_blocking crée une tâche supplémentaire dans le pool de blocage, qui possède un coût non négligeable. Regrouper plusieurs accès au système de fichiers ou d’autres appels bloquants en un seul bloc réduit le nombre d’envois vers le pool et diminue le nombre de polls associés. Sur un hôte 32‑cœurs, le dépassement d’environ 50 000 tâches bloquantes par seconde a déjà montré des goulots d’étranglement visibles dans les flamegraphs.
Gestion des ressources globales
Le runtime Tokio utilise deux structures partagées : la file d’attente globale des tâches et le pool de blocage. La file globale n’est sollicitée que lorsque les files locales débordent ou lorsqu’un travail est planifié depuis l’extérieur d’un worker. Un afflux massif de tâches externes peut donc entraîner des retards de planification mesurés par l’histogramme de latence. Le pool de blocage, quant à lui, est limité en taille et partagé entre tous les workers. Un nombre excessif d’appels spawn_blocking entraîne une saturation, augmentant le temps d’attente avant l’exécution du code bloquant.
La règle d’or est de minimiser les interactions avec ces ressources globales. Lorsque le travail est court (quelques microsecondes) et prévisible, il est souvent plus efficace de le laisser aux workers classiques, qui utilisent le vol de travail (work‑stealing) pour équilibrer la charge. En revanche, pour des opérations longues ou fortement bloquantes, un thread dédié ou un groupe de tâches agrégées reste préférable.