CHAPTER 01

Chapitre 1 : Le modèle mental de l'asynchrone : le trio Future, Waker et exécuteur

Projet concerné : tokio-rs/tokio · Progression de l'ouvrage : chapitre 1 / 14 · État de vérification : ancrage réel des numéros de ligne FACT

La programmation asynchrone en Rust n'est pas une bibliothèque, mais un protocole au niveau du langage. Si Tokio a pu devenir un runtime de niveau production, ce n'est pas parce qu'il a inventé Future, mais parce qu'il implémente précisément les conditions limites de chaque contrat de ce protocole. Ce chapitre ne se précipite pas dans le code de l'ordonnanceur de Tokio, mais commence par expliquer en profondeur les frontières de responsabilité et le flux de contrôle inversé du « trio » — Future, Waker, Executor. Une fois compris comment ces trois éléments s'engrènent, l'assemblage du Runtime, l'ordonnancement work-stealing et le pilote d'I/O des chapitres suivants trouveront leur point d'ancrage.

1.1 Du blocage au tirage : pourquoi Rust choisit poll plutôt que les callbacks

Modèle intuitif

Imaginez que vous commandez dans un restaurant un plat qui doit être préparé à la minute. L'asynchrone à base de callbacks (comme le style initial de Node.js) équivaut à laisser votre numéro de téléphone : une fois le plat prêt, le chefvous appelle activement— le contrôle est entre les mains du chef, votre code ne fait que répondre passivement. L'asynchrone à base de tirage (le choix de Rust) équivaut à recevoir un ticket de retrait : vousdécidez vous-mêmequand aller demander au guichet « est-ce prêt ? » : si ce n'est pas prêt, vous faites autre chose ; si c'est prêt, vous récupérez.

Cette différence semble minime, mais elle détermine la forme de tout le système. Dans le modèle à callbacks, chaque opération asynchrone doit porter une closure « que faire une fois terminé », les closures s'imbriquent couche par couche pour former l'enfer des callbacks, et l'annulation est extrêmement difficile — vous ne pouvez pas « retirer » un callback déjà enregistré. Dans le modèle à tirage, un Future n'est qu'une machine à états,pollest une pure action d'interrogation : sans progression, aucune ressource n'est consommée ; annuler revient à drop, proprement et sans détour.

Le contrat central du modèle à tirage

La bibliothèque standard Rust définit le traitFutureavec seulement deux éléments : une méthodepollet un type associéOutput. Tokio ne redéfinit pas ce trait, mais réutilise directement l'implémentation de la bibliothèque standard. Ce point est clairement visible dans le code source :

rust
// tokio/src/future/mod.rs
cfg_not_trace! {
    cfg_rt! {
        pub(crate) use std::future::Future;
    }
}

📎 tokio/src/future/mod.rs:24-28

Ce code révèle un fait important : lorsque la fonctionnalitétracingn'est pas activée, leFutureinterne de Tokio est un alias destd::future::Future, sans aucun emballage. Ce n'est que lorsquetracingest activé queInstrumentedFuturele remplace :

rust
cfg_trace! {
    mod trace;
    #[allow(unused_imports)]
    pub(crate) use trace::InstrumentedFuture as Future;
}

📎 tokio/src/future/mod.rs:18-22

〔Inférence de conception et compromis architecturaux〕

Cette conception « zéro coût par défaut, instrumentation à la demande » est la philosophie constante de Tokio : le chemin critique n'introduit aucune couche d'abstraction supplémentaire, l'observabilité s'ajoute comme fonctionnalité optionnelle.InstrumentedFutureL'existence de

montre que l'équipe Tokio estime que le coût d'instrumentation de tracing ne doit pas être supporté par tous les utilisateurs.

pollLes trois contraintes implicites du contrat pollfn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>La signature de la méthode

est Pin<&mut Self>. Cette signature cache trois contrats ; violer l'un d'eux entraîne un comportement indéfini ou une erreur logique :

Contrat un : Pin garantit la sûreté des auto-références.signifie qu'une fois qu'un Future est poll, son adresse mémoire ne peut plus être déplacée. Cela vient du fait qu'un bloc async, une fois compilé, génère une machine à états contenant des auto-références — les variables locales peuvent détenir des références vers d'autres champs de la même machine à états. Si le déplacement était autorisé, ces références deviendraient pendantes.pollContrat deux : Pending doit avoir enregistré un réveil.Poll::PendingLorsquecx.waker()Obtention et sauvegarde du Waker, ou enregistrement du Waker auprès d'une source d'événements. Sinon, l'exécuteur ne saura jamais quand ce Future peut être à nouveau poll, ce qui entraînerait une suspension permanente de la tâche.

Contrat trois : après Ready, il ne faut plus poll.Une fois quepollretournePoll::Ready, poll à nouveau le même Future est une erreur logique (bien que cela ne provoque pas d'UB, le comportement est indéfini). L'exécuteur a la responsabilité de ne plus planifier cette tâche après avoir reçu Ready.

Parmi ces trois contrats, le contrat deux est l'endroit le plus susceptible de provoquer des erreurs, et c'est aussi la raison fondamentale de l'existence du Waker.

1.2 Waker : le vecteur du flux de contrôle inverse

Modèle intuitif

Le Waker est le « bipeur de retrait » que le restaurant vous donne. Vous n'avez pas besoin de rester devant le comptoir à demander sans cesse « c'est prêt ? » — cela vous ferait perdre votre temps. Vous devez simplement, lors de votre première visite au comptoir, remettre le bipeur au chef (enregistrer le Waker), puis vaquer tranquillement à d'autres occupations. Quand le plat est prêt, le chef appuie sur le bouton, le bipeur vibre (appel dewake), vous recevez le signal puis retournez au comptoir retirer le plat (poll à nouveau).

Sans le Waker, l'exécuteur n'aurait que deux choix : soit interroger en boucle toutes les tâches (gaspillage de CPU), soit ne jamais poll les tâches ayant retourné Pending (famine des tâches). Le Waker est l'unique mécanisme permettant de briser ce blocage.

Disposition mémoire et conception de la table virtuelle du Waker

Le Waker est un type de la bibliothèque standard, mais sa conception influence directement la structure des tâches de Tokio.WakerIl s'agit essentiellement d'un pointeur épais : uneRawWakerstructure, contenant un pointeur de données et un pointeur de table virtuelle.

rust
// 标准库中的定义(非 Tokio 源码,此处为背景说明)
pub struct RawWaker {
    data: *const (),
    vtable: &'static RawWakerVTable,
}

pub struct RawWakerVTable {
    clone: unsafe fn(*const ()) -> RawWaker,
    wake: unsafe fn(*const ()),
    wake_by_ref: unsafe fn(*const ()),
    drop: unsafe fn(*const ()),
}
〔Inférence de conception et compromis architecturaux〕

L'ingéniosité de cette conception réside dans le fait que :Wakerlui-même ne se soucie pas de ce que signifie concrètement « réveiller ». Il n'est que le support de quatre pointeurs de fonction. Tokio peut fournir un Waker dont lawakefonction repousse la tâche dans la file de planification ; tandis qu'un autre runtime (par exemplefuturesleblock_onde la crate) peut fournir une implémentation de Waker complètement différente. Ce modèle « données + table virtuelle » permet au Waker d'être transmis entre différents runtimes sans perdre sa sémantique.

wakeLa différence entrewake_by_refetwakeest cruciale :wake_by_refconsomme la propriété du Waker (le Waker est drop après l'appel), tandis quewake_by_refne fait qu'emprunter. L'exécuteur implémente généralementwakecomme « marquer la tâche comme prête et l'enfiler », tandis que

gère en plus la décrémentation du compteur de références. Dans la structure de tâche de Tokio, le pointeur de données du Waker pointe vers l'en-tête du compteur de références de la tâche ; chaque clone incrémente le compteur, chaque drop le décrémente, et lorsque le compteur atteint zéro, la mémoire de la tâche est libérée.

Chronologie complète du réveil

mermaid
sequenceDiagram
    participant App as 应用任务
    participant Exec as 调度器 Worker
    participant Future as TcpStream::read Future
    participant Reactor as I/O 驱动 (epoll)
    participant Kernel as 操作系统内核

    App->>Future: poll(cx) 携带 Waker
    Future->>Reactor: 注册可读兴趣 + 保存 Waker
    Reactor->>Kernel: epoll_ctl(ADD, fd, EPOLLIN)
    Future-->>Exec: 返回 Poll::Pending
    Note over Exec: 任务挂起,Worker 去执行其他任务
    Kernel-->>Reactor: epoll_wait 返回 fd 就绪
    Reactor->>Reactor: 查找 fd 对应的 Waker
    Reactor->>Exec: waker.wake_by_ref()
    Note over Exec: 任务重新入队
    Exec->>Future: 再次 poll(cx)
    Future->>Kernel: read(fd, buf) 非阻塞读取
    Kernel-->>Future: 返回数据
    Future-->>App: 返回 Poll::Ready(n)

CopieLe point clé de ce diagramme est que :Le Waker est l'unique canal permettant d'atteindre l'Executor depuis le Reactor en sens inverse

. Le Reactor ne détient aucune autre information sur la tâche ; il sait seulement « quand ce fd est prêt, appeler ce Waker ». Ce découplage permet au driver d'I/O d'être implémenté indépendamment du planificateur, les deux ne communiquant que via cette interface étroite qu'est le Waker.

Réveil fallacieux : la zone grise du contrat

Normally, tasks are scheduled only if they have been woken by calling wake on their waker. However, this is not guaranteed, and Tokio may schedule tasks that have not been woken under some circumstances.

📎 tokio/src/runtime/mod.rs:306-309

La documentation de Tokio reconnaît explicitement l'existence de réveils fallacieux :

〔Inférence de conception et compromis architecturaux〕pollCela signifie que l'implémentation de

doit pouvoir tolérer le cas où elle est poll à nouveau « sans avoir été réveillée ». Un Future correct, après avoir retourné Pending, même si aucun événement ne s'est produit, doit retourner Pending et non panic ou produire un résultat erroné lorsqu'il est poll à nouveau. Cette contrainte semble laxiste, mais elle impose en réalité des exigences sur la conception de la machine à états : on ne peut pas supposer qu'« un événement se produit nécessairement entre deux poll ».

1.3 Executor : de Future à l'encapsulation en tâche

Modèle intuitif

L'Executor est le dispatcheur du restaurant. Il a une pile de commandes (file de tâches) et décide quelle commande traiter en premier et par qui. Quand le bipeur vibre, il replace la commande correspondante dans la file. Sans dispatcheur, les chefs ne sauraient pas quel plat préparer ni quand changer de travail.Mais la responsabilité de l'Executor va bien au-delà du simple « poll du Future ». Il doit résoudre trois problèmes fondamentaux :Gestion du cycle de vie des tâches(création, planification, achèvement, annulation),Garantie d'équité(empêcher qu'une tâche affame les autres),Intégration des pilotes de ressources

(comment les événements d'I/O et de minuterie se transforment en réveils).

Disposition mémoire de la tâche : du Future au Tasktokio::spawnLors de l'appel deTask, le Future passé n'est pas directement placé dans la file. Il est encapsulé dans une

rust
/// Boundary value to prevent stack overflow caused by a large-sized
/// Future being placed in the stack.
pub(crate) const BOX_FUTURE_THRESHOLD: usize = if cfg!(debug_assertions)  {
    2048
} else {
    16384
};

pub(crate) struct AutoBox<T>(std::marker::PhantomData<T>);

impl<T> AutoBox<T> {
    /// `true` if a value of type `T` is larger than [`BOX_FUTURE_THRESHOLD`].
    pub(crate) const SHOULD_BOX: bool = std::mem::size_of::<T>() > BOX_FUTURE_THRESHOLD;
}

📎 tokio/src/runtime/mod.rs:649-673

CopieAutoBoxCe code résout un problème très concret : si le Future est trop grand (plus de 16 Ko, 2 Ko en mode debug), l'incorporer directement dans la structure Task provoquerait un débordement de pile ou un gaspillage de mémoire.SHOULD_BOXDécide si le Future doit être boxé via la constante de compilation

〔Inférence de conception et compromis architecturaux〕

Le commentaire souligne particulièrement « utiliser une constante associée plutôt qu'unif» : si l'on utilise une évaluation à l'exécution, le compilateur instancie pour chaqueTsimultanément le code des deux branches (une qui traiteT, une qui traitePin<Box<T>>), ce qui entraîne un gonflement du code. Avec une branche constante, le collecteur de monomorphisation élimine les branches inaccessibles et ne génère du code que pour les types réellement utilisés. C'est une optimisation typique consistant à « remplacer l'évaluation à l'exécution par le système de types ».

Équité d'ordonnancement : les nombres magiques 31 et 61

La documentation du planificateur de Tokio définit une garantie d'équité formelle :

If the total number of tasks does not grow without bound, and no task is blocking the thread, then it is guaranteed that tasks are scheduled fairly.

📎 tokio/src/runtime/mod.rs:279-281

La mise en œuvre de cette garantie repose sur deux paramètres clés. Pour le runtime current-thread :

The runtime will prefer to choose the next task to schedule from the local queue, and will only pick a task from the global queue if the local queue is empty, or if it has picked a task from the local queue 31 times in a row.

📎 tokio/src/runtime/mod.rs:328-333

The runtime will check for new IO or timer events whenever there are no tasks ready to be scheduled, or when it has scheduled 61 tasks in a row.

📎 tokio/src/runtime/mod.rs:335-337

Ces deux nombres (31 et 61) ne sont pas choisis au hasard. 31 est 2 puissance 5 moins 1, ce qui permet un test rapide par opération bit à bit ; 61 sert à garantir que les événements d'E/S ne seront pas différés indéfiniment — même si la file de tâches n'est jamais vide, une vérification des E/S doit avoir lieu toutes les 61 planifications.

〔Inférence de conception et compromis architecturaux〕

Pourquoi 31 et non 32 ? Parce que le compteur part de 0, s'incrémente de 1 à chaque planification, et déclenche la vérification de la file globale lorsqu'il atteint 31. Utilisercounter & 31 == 31pour tester est plus efficace quecounter % 32 == 0(bien que les compilateurs modernes l'optimisent automatiquement). Le choix de 61 est plus subtil : il doit être suffisamment grand pour éviter le coût fréquent des appels système epoll_wait, et suffisamment petit pour garantir une latence d'E/S acceptable.

Optimisation du slot LIFO dans le runtime multithread

Le runtime multithread ajoute, en plus de l'équité, une optimisation de performance — le slot LIFO :

The multi thread runtime uses the lifo slot optimization: Whenever a task wakes up another task, the other task is added to the worker thread's lifo slot instead of being added to a queue.

📎 tokio/src/runtime/mod.rs:373-377

L'intuition derrière cette optimisation est la suivante : lorsqu'une tâche réveille une autre tâche, la tâche réveillée a probablement une dépendance de données avec la tâche courante (par exemple dans un modèle producteur-consommateur). En la plaçant dans le slot LIFO, elle s'exécute immédiatement après la fin de la tâche courante, ce qui permet d'exploiter les données chaudes du cache CPU.

Mais le slot LIFO dispose d'un mécanisme anti-abus :

if a worker thread uses the lifo slot three times in a row, it is temporarily disabled until the worker thread has scheduled a task that didn't come from the lifo slot.

📎 tokio/src/runtime/mod.rs:380-382

〔Inférence de conception et compromis architecturaux〕

Cette règle de « désactivation après trois utilisations consécutives » vise à empêcher deux tâches de se réveiller mutuellement et de former un livelock. Si la tâche A réveille la tâche B, et que B réveille A, sans cette limite, le slot LIFO serait occupé en permanence par ces deux tâches, et les autres tâches ne seraient jamais planifiées. La limite de trois donne aux autres tâches une chance de s'insérer.

Annulation de tâche : la sémantique réelle d'abort

JoinHandle::abortLe comportement de

Be aware that calls to JoinHandle::abort just schedule the task for cancellation, and will return before the cancellation has completed.

📎 tokio/src/task/mod.rs:146-148

est souvent mal compris. La documentation précise clairement :abortn'est pas synchrone. Il ne fait que positionner un indicateur, et la tâche vérifiera cet indicateur au prochain point.awaitet se terminera d'elle-même. Si la tâche exécute du code intensif en CPU sans point.await,abortne prendra pas effet immédiatement.

Plus subtil encore :

Note that aborting a task does not guarantee that it fails with a cancelled error, since it may complete normally first.

📎 tokio/src/task/mod.rs:134-138

〔Inférence de conception et compromis architecturaux〕

La motivation de conception de cette sémantique est que l'annulation est une opération « au mieux ». Tokio ne tue pas les tâches de force (Rust n'offre pas de mécanisme sûr de terminaison forcée), mais demande coopérativement aux tâches de se terminer d'elles-mêmes. Cela est cohérent avec la conception des tâchesspawn_blockingnon annulables — les tâches bloquantes n'ont pas de point.awaitet ne peuvent pas vérifier l'indicateur d'annulation.

1.4 Réflexions de conception : les frontières et le coût du trio

Pourquoi Future n'inclut pas Executor

Le traitFuturede Rust n'inclut délibérément pas d'information sur « comment se planifier ». C'est une décision de découplage mûrement réfléchie. Si un Future connaissait son Executor, alors :

1. Le même Future ne pourrait pas s'exécuter sur différents runtimes (par exemple migrer de Tokio vers async-std)

2. Lors des tests, il serait impossible d'utiliser un simpleblock_onpour le piloter

3. Les combinateurs (commeselect!、join!) ne pourraient pas fonctionner à travers les runtimes

L'existence de Waker vise précisément à préserver ce découplage tout en permettant au Future de notifier l'Executor. Waker est un « jeton de capacité » — le Future sait seulement « je peux appeler ceci pour demander une replanification », mais ignore comment la planification se produit concrètement.

Le coût de l'ordonnancement coopératif

Les tâches de Tokio sont coopératives : une tâche ne cède le contrôle qu'aux points.await. Cela signifie :

code that spends a long time without reaching an .await will prevent other tasks from running.

📎 tokio/src/lib.rs:178-179

〔Inférence de conception et compromis architecturaux〕

C'est le coût fondamental de l'ordonnancement coopératif. Le système d'exploitation peut préempter un thread à n'importe quelle frontière d'instruction, mais Tokio ne peut changer de tâche qu'aux points.await. Si une tâche exécute une boucle intensive en CPU de 10 secondes sans point.awaitintermédiaire, alors toutes les autres tâches du même thread worker seront bloquées pendant 10 secondes. La stratégie de Tokio consiste à fournirspawn_blockingetblock_in_place, pour transférer ce type de travail vers un pool de threads dédié. Mais c'est la responsabilité de l'utilisateur, le runtime ne peut pas le détecter automatiquement.

Conditions aux limites de la garantie d'équité

La garantie d'équité de Tokio a deux prérequis : le nombre total de tâches est borné, et aucune tâche ne bloque le thread. Ces deux conditions sont souvent violées en environnement de production réel :

  • Si des tâches ne cessent de spawn de nouvelles tâches sans les recycler, le nombre total de tâches n'est pas borné et la garantie d'équité devient caduque
  • Si une tâche exécute un appel système bloquant (par exemple des E/S fichier synchrones), elle bloque tout le thread worker
〔Inférence de conception et compromis architecturaux〕

C'est pourquoi la documentation de Tokio insiste à plusieurs reprises : « n'exécutez pas d'opérations bloquantes dans des tâches asynchrones ». La garantie d'équité n'est pas une garantie stricte du runtime, mais une garantie « sous réserve d'une utilisation correcte ». Le runtime ne détecte pas les violations, car la détection elle-même a un coût.

1.5 Résumé de ce chapitre

Ce chapitre établit les trois pierres angulaires pour comprendre Tokio :

Future est une machine à états de type pull. pollest une pure action de requête, retournePendingdoit avoir enregistré un waker au moment du retour,Readyne doit plus être poll après le retour. Tokio réutilise directementstd::future::Future, sans encapsulation supplémentaire (sauf si tracing est activé).

Waker est le seul canal de contrôle inverse du flux.Il réalise l'indépendance vis-à-vis du runtime grâce à une conception « pointeur de données + table virtuelle ».wakeconsomme la propriété,wake_by_refemprunte seulement. Les réveils spurieux sont autorisés, Future doit les tolérer.

Executor est responsable du cycle de vie, de l'équité et de l'intégration des ressources.Il encapsule Future en Task, viaAutoBoxdécide à la compilation s'il faut boxer, équilibre l'ordonnancement entre la file locale et la file globale via les deux nombres magiques 31/61, et optimise les performances des scénarios de dépendance de données via le slot LIFO.

Ces trois composants sont découplés par des interfaces étroites : Future ne connaît quepoll, Waker ne connaît quewake, Executor ne connaît que « poll jusqu'à Pending ou Ready ». C'est précisément ce découplage qui permet à Tokio d'implémenter l'ordonnancement work-stealing, l'intégration du driver I/O, le budget coopératif et d'autres fonctionnalités avancées sans modifier la définition de Future.

Réflexions et auto-évaluation de ce chapitre

Q1 : Si l'on changeAutoBox::SHOULD_BOXd'une constante de compilation en unif size_of::<T>() > THRESHOLDà l'exécution, quel impact cela aurait-il sur le binaire compilé ? Pourquoi les commentaires de Tokio insistent-ils particulièrement sur ce point ?

Analyse de référence: Selon les commentaires de📎 tokio/src/runtime/mod.rs:657-667, si l'on utilise unifà l'exécution, le compilateur instanciera simultanément le code des deux branches pour chaqueT— une branche gérant le cas oùTest directement inliné, une autre gérant le casPin<Box<T>>. Cela signifie que chaque type de Future spawné générera deux copies du code de pilotage de tâche (task harness), doublant la taille du binaire. En utilisant la constante associéeSHOULD_BOX, comme elle est une constante de compilation une foisTdéterminé, le collecteur de monomorphisation éliminera les branches inaccessibles et ne générera du code que pour le chemin réellement utilisé. C'est une optimisation typique « remplacer le jugement à l'exécution par le système de types », au prix queAutoBoxdoit être une structure générique et non une fonction ordinaire.

Q2 : Supposons qu'une tâche retournepolldansPending, mais oublie d'enregistrer un Waker. Que se passe-t-il pour cette tâche dans un runtime current-thread et dans un runtime multi-thread ? Tokio dispose-t-il d'un mécanisme pour détecter cette situation ?

Analyse de référence: Selon📎 tokio/src/runtime/mod.rs:306-309, Tokio autorise les réveils spurieux, ce qui signifie qu'une tâche peut être réordonnancée sans avoir été réveillée. Mais cela ne signifie pas qu'oublier d'enregistrer un Waker est sûr. Dans un runtime current-thread, si la file locale et la file globale sont toutes deux vides, le runtime entre dans un étatparken attente d'événements I/O ou de timers. Une tâche qui a oublié d'enregistrer un Waker ne sera jamais remise en file, provoquant une suspension permanente. Dans un runtime multi-thread, la situation est similaire, mais si d'autres tâches réveillent continuellement, cette tâche pourrait être réordonnancée accidentellement en raison de réveils spurieux — mais cela n'est pas fiable. Tokio n'a pas de mécanisme de détection à l'exécution pour découvrir le cas « retourne Pending mais n'a pas enregistré de Waker », car cela nécessiterait de vérifier après chaque poll si le Waker a été utilisé, ce qui coûterait trop cher. C'est la responsabilité de l'implémenteur de Future.

Q3 : La règle « désactivation après trois utilisations consécutives » du slot LIFO vise à prévenir quel scénario concret ? Si l'on supprimait cette limitation, dans quel mode de dépendance entre tâches d'autres tâches seraient-elles affamées ?

Analyse de référence: Selon📎 tokio/src/runtime/mod.rs:380-382, le slot LIFO est temporairement désactivé après trois utilisations consécutives, jusqu'à ce qu'une tâche provenant d'une source non-LIFO soit ordonnancée. Le scénario que cette règle prévient est : deux tâches qui se réveillent mutuellement formant une boucle serrée. Par exemple, la tâche A réveille la tâche B après avoir traité un lot de données, et la tâche B réveille immédiatement la tâche A après avoir terminé. Sans la limite de trois, A et B occuperaient éternellement le slot LIFO, le thread worker basculerait infiniment entre ces deux tâches, et les autres tâches de la file locale et de la file globale n'obtiendraient jamais de chance d'exécution. La limite de trois garantit qu'après chaque cycle de trois tours de « réveil mutuel », au moins une autre tâche est ordonnancée, brisant le livelock. Le choix de ce nombre est empirique : trop petit réduit le gain de l'optimisation LIFO, trop grand augmente la latence des autres tâches.

À ce stade, les frontières de responsabilité et les mécanismes de collaboration entre Future, Waker et Executor sont clairs : Future définit le calcul, Waker est responsable du réveil, Executor pilote l'exécution. Mais un composant isolé ne peut pas fonctionner indépendamment ; ils doivent être assemblés dans un environnement d'exécution unifié. Dans le prochain chapitre, nous suivrons la chaîne d'assemblage complète de Runtime::new et Builder::build, pour voir comment le scheduler, le driver I/O, le driver temporel et le pool de threads bloquants sont injectés dans la même instance Runtime, et révéler les différences fondamentales entre les formes current_thread et multi_thread au stade de l'assemblage.

CHAPTER 02

Chapitre 2 : L'assemblage du Runtime : comment Builder assemble les drivers, le scheduler et le pool de threads

Projet : tokio-rs/tokio · Progression du livre : Chapitre 2 / 14 · État de vérification : lignes FACT réellement ancrées

DeBuilderàRuntime: le parcours complet d'un assemblage

Dans le chapitre précédent, nous avons clarifié les frontières de responsabilité entre Future, Waker et Executor. Mais un runtime réellement utilisable va bien au-delà d'« un Executor » — il nécessite également une boucle d'événements I/O, un timer, un pool de threads bloquants, et ces composants doivent partager le même ensemble de handles et le même cycle de vie. Ce chapitre retrace la chaîne d'assemblage complète deBuilder::buildet répond à une question centrale :Quels composants existe-t-il à l'intérieur d'unRuntime, comment sont-ils assemblés et comment partagent-ils les handles。

Le point d'entrée de l'assemblage de Tokio estBuilder. Il s'agit en soi d'un pur conteneur de configuration, tous ses champs sont des « déclarations d'intention » et ne détiennent aucune ressource d'exécution. La véritable création de ressources se produit lors de l'appel àbuild().

Modèle intuitif : le Builder est le « plan de décoration », le Runtime est la « maison livrée »

Builderressemble à un plan de décoration : vous y annotez « combien de pièces (worker_threads) », « faut-il l'eau courante (enable_io) », « faut-il l'électricité (enable_time) », « la limite de sous-traitants (max_blocking_threads) ». Le plan lui-même ne produit aucune entité. Ce n'est qu'au moment de l'appel àbuild()que l'équipe de construction se met au travail selon le plan, érige réellement les « pièces » que sont le scheduler, les drivers et le pool de threads, et livre une instance deRuntime.

Sans la coucheBuilder, l'utilisateur devrait manuellement instancier chaque composant, câbler manuellement, et gérer manuellement le rollback en cas d'échec — toute erreur d'ordre entraînerait des handles suspendus ou des fuites de ressources.BuilderLa valeur deréside dans : la séparation complète entre « configuration » et « construction », permettant au processus de construction de centraliser la validation, le nettoyage en cas d'échec et le partage des handles。

Disposition mémoire :Builderpartitionnement des champs de

BuilderLes champs depeuvent être divisés en quatre groupes selon leur responsabilité. Le premier groupe est:kindForme et commutateursenable_io / enable_timedétermine la forme du scheduler,

📎 tokio/src/runtime/builder.rs:55-68

rust
pub struct Builder {
    kind: Kind,
    name: Option<String>,
    enable_io: bool,
    nevents: usize,
    nevents_busy: Option<usize>,
    enable_time: bool,
    start_paused: bool,
    // ...
}

CopieLe deuxième groupe est:worker_threadsParamètres du pool de threadsOption<usize>,Noneestmax_blocking_threadssignifie « différer jusqu'au build pour détecter automatiquement selon le nombre de cœurs CPU » ;

📎 tokio/src/runtime/builder.rs:73-79

rust
worker_threads: Option<usize>,
max_blocking_threads: usize,

CopieLe troisième groupe estHooks de rappelOption<Arc<dyn Fn ...>>, tous sontArc. Notez qu'ils utilisentBoxplutôt queConfig。

📎 tokio/src/runtime/builder.rs:87-97

rust
pub(super) after_start: Option<Callback>,
pub(super) before_stop: Option<Callback>,
pub(super) before_park: Option<Callback>,
pub(super) after_unpark: Option<Callback>,

de chaque thread workerCopie:global_queue_interval、event_interval、disable_lifo_slot、seed_generator。

📎 tokio/src/runtime/builder.rs:116-134

rust
pub(super) global_queue_interval: Option<u32>,
pub(super) event_interval: u32,
pub(super) disable_lifo_slot: bool,
pub(super) seed_generator: RngSeedGenerator,

Heuristiques de scheduling et graine aléatoireKindCopieCopyIl y a ici un design digne d'attention :

📎 tokio/src/runtime/builder.rs:261-265

rust
#[derive(Clone, Copy)]
pub(crate) enum Kind {
    CurrentThread,
    #[cfg(feature = "rt-multi-thread")]
    MultiThread,
}

MultiThreadpetit enum, avec seulement deux variantes.rt-multi-threadCopiertLa varianteKindest conditionnée par la featurebuild(). Cela signifie que dans une compilation où seule la featurematchest activée,n'a qu'une seule variante,。

et le

Builder::newdeenable_iosera optimisé par le compilateur en une seule branche —enable_timeUtiliser le système de types plutôt qu'une vérification à l'exécution pour éliminer la taille de code du scheduler multi-threadfalse。

📎 tokio/src/runtime/builder.rs:309-318

rust
// I/O defaults to "off"
enable_io: false,
nevents: 1024,
nevents_busy: None,

// Time defaults to "off"
enable_time: false,

// The clock starts not-paused
start_paused: false,
est le point d'entrée commun à toutes les constructions. Il définit

et#[tokio::main]tous deux àenable_all()。

enable_all()Copie

📎 tokio/src/runtime/builder.rs:398-419

rust
pub fn enable_all(&mut self) -> &mut Self {
    #[cfg(any(
        feature = "net",
        all(unix, feature = "process"),
        all(unix, feature = "signal")
    ))]
    self.enable_io();

    #[cfg(all(
        tokio_unstable,
        feature = "io-uring",
        // ...
    ))]
    self.enable_io_uring();

    #[cfg(feature = "time")]
    self.enable_time();

    self
}

Ce choix de valeur par défaut est délibéré : créer un driver I/O nécessite de demander un handle epoll/kqueue au système d'exploitation, créer un driver time nécessite de démarrer l'infrastructure de timer. Si l'utilisateur veut simplement un scheduler de tâches purement calculatoire (par exemple exécuter une logique async intensive en CPU), forcer la création de ces drivers est un pur gaspillage.enable_io()La macronet、processest « prête à l'emploi » parce qu'elle appelle en internesignalL'implémentation detime feature,enable_all()révèle comment le feature gating influence la sémantique du « tout activé ».

Copiebuild()Notez que

build()n'est appelé que lorsque la featurekindou

📎 tokio/src/runtime/builder.rs:1146-1152

rust
pub fn build(&mut self) -> io::Result<Runtime> {
    match &self.kind {
        Kind::CurrentThread => self.build_current_thread_runtime(),
        #[cfg(feature = "rt-multi-thread")]
        Kind::MultiThread => self.build_threaded_runtime(),
    }
}

n'ouvrira pas le driver I/O — car le code du driver I/O n'existe tout simplement pas dans le binaire compilé.

Chemin principal d'assemblage :

build_current_thread_runtimela bifurcation debuild_current_thread_runtime_componentsest le point de départ de l'assemblage, il se bifurque selonRuntime。

📎 tokio/src/runtime/builder.rs:1725-1736

rust
fn build_current_thread_runtime(&mut self) -> io::Result<Runtime> {
    use crate::runtime::runtime::Scheduler;

    let (scheduler, handle, blocking_pool) =
        self.build_current_thread_runtime_components(None)?;

    Ok(Runtime::from_parts(
        Scheduler::CurrentThread(scheduler),
        handle,
        blocking_pool,
    ))
}

Copiebuild_current_thread_runtime_componentsLa différence entre ces deux chemins va bien au-delà de « un thread vs plusieurs threads ». Développons-les séparément ci-dessous.

📎 tokio/src/runtime/builder.rs:1760-1766

rust
let mut cfg = self.get_cfg();
cfg.timer_flavor = TimerFlavor::Traditional;
let (driver, driver_handle) = driver::Driver::new(cfg)?;

// Blocking pool
let blocking_pool = blocking::create_blocking_pool(self, self.max_blocking_threads, 0);
let blocking_spawner = blocking_pool.spawner().clone();

est lui-même très mince, il délègue àdriver, puis encapsule le triplet retourné dans(driver, driver_handle)Copie?La véritable logique d'assemblage se trouve dansbuild. Son ordre d'exécution est crucial :ErrCopie

La première étape créespawner, retourne une paire despawner. Notez qu'ici

propage directement l'erreur vers le haut — si l'initialisation du driver I/O échoue (par exemple échec de création d'epoll), tout

📎 tokio/src/runtime/builder.rs:1768-1770

rust
let seed_generator_1 = self.seed_generator.next_generator();
let seed_generator_2 = self.seed_generator.next_generator();
, à ce moment le blocking pool n'est pas encore créé, aucun nettoyage n'est nécessaire.

La deuxième étape crée le blocking pool, et en extrait immédiatement le clone de sonseed_generator_1. CeConfigsera injecté dans le scheduler, donnant au scheduler la capacité de soumettre des tâches bloquantes au pool de threads.select!La troisième étape génère deux générateurs de graines RNG indépendants.seed_generator_2CopieCurrentThread::new〔Inférence de conception et compromis architecturaux〕rng_seedPourquoi en faut-il deux ?

est placé dansConfig, pour un usage interne au scheduler (par exemple l'ordre de branchement aléatoire deCurrentThread::new。

📎 tokio/src/runtime/builder.rs:1776-1807

rust
let (scheduler, handle) = CurrentThread::new(
    driver,
    driver_handle,
    blocking_spawner,
    seed_generator_2,
    Config {
        before_park: self.before_park.clone(),
        after_unpark: self.after_unpark.clone(),
        // ...
        global_queue_interval: self.global_queue_interval,
        event_interval: self.event_interval,
        // ...
        enable_eager_driver_handoff: false,
        seed_generator: seed_generator_1,
        // ...
    },
    local_tid,
    self.name.clone(),
);

est transmis àenable_eager_driver_handoff, pour un usage côté tâche. Séparer les deux générateurs permet d'éviter que la consommation de nombres aléatoires en interne par le scheduler n'affecte la séquence aléatoire visible par l'utilisateur, garantissant ainsi la reproductibilité defalse。

📎 tokio/src/runtime/builder.rs:1795-1798

rust
// This setting never makes sense for a current thread runtime,
// as it only configures how the I/O driver is stolen across
// workers.
enable_eager_driver_handoff: false,
La quatrième étape est le cœur : remettre ensemble le driver, driver_handle, blocking_spawner, les graines et

Ce commentaire souligne l'essence de cette option : elle décrit « comment plusieurs workers se disputent le driver I/O », or current_thread n'a qu'un seul thread, il n'y a donc pas de préemption, d'où la désactivation forcée. C'est un exemple typique de « sémantique d'une option de configuration fortement corrélée à sa forme » — le mêmeBuilderchamp a une signification différente selon la forme.

Enfin,CurrentThread::newlehandleretourné est encapsulé dansscheduler::Handle::CurrentThread, puis dans leHandle。

📎 tokio/src/runtime/builder.rs:1816-1822

rust
let handle = Handle {
    inner: scheduler::Handle::CurrentThread(handle),
};

Ok((scheduler, handle, blocking_pool))

Chemin deux : l'assemblage de multi_thread

build_threaded_runtimeLe squelette de est similaire à celui de current_thread, mais présente trois différences essentielles. La première concerne la détermination du nombre de threads worker :

📎 tokio/src/runtime/builder.rs:2185

rust
let worker_threads = self.worker_threads.unwrap_or_else(num_cpus);

Noneest ici résolu ennum_cpus(). C'est le point d'application de la « détection automatique différée » — la détection a lieu au moment du build et non au moment deBuilder::new, car l'affinité CPU peut changer entre les deux.

La deuxième différence réside dans le calcul de la capacité du blocking pool :

📎 tokio/src/runtime/builder.rs:2189-2192

rust
let blocking_pool =
    blocking::create_blocking_pool(self, self.max_blocking_threads + worker_threads, worker_threads);
let blocking_spawner = blocking_pool.spawner().clone();

Noter quemax_blocking_threads + worker_threads. En comparaison, le chemin current_thread passeself.max_blocking_threadset0。

📎 tokio/src/runtime/builder.rs:1765

rust
let blocking_pool = blocking::create_blocking_pool(self, self.max_blocking_threads, 0);
〔Inférence de conception et compromis architecturaux〕

Cette différence révèle la sémantique de la capacité du blocking pool : sous multi_thread,max_blocking_threadsreprésente la limite « supplémentaire » de threads bloquants ; la limite totale réelle doit ajouter le nombre de threads worker. Le troisième paramètre (0 pour current_thread,worker_threadspour multi_thread) est très probablement une indication du « nombre de threads réservés » ou du « nombre de threads initiaux ». Cette conception maintient la cohérence sémantique demax_blocking_threadsentre les deux formes : il décrit « combien de threads bloquants supplémentaires peuvent être ouverts au-delà des workers principaux ».

La troisième différence est queMultiThread::newretourne un triplet plutôt qu'un couple :

📎 tokio/src/runtime/builder.rs:2198-2226

rust
let (scheduler, handle, launch) = MultiThread::new(
    worker_threads,
    driver,
    driver_handle,
    blocking_spawner,
    seed_generator_2,
    Config {
        // ...
        enable_eager_driver_handoff: self.enable_eager_driver_handoff,
        // ...
    },
    self.timer_flavor,
    self.name.clone(),
);

Lelaunchsupplémentaire est un « handle de démarrage ».MultiThread::newse charge uniquement de construire la structure du scheduler,sans démarrer immédiatement les threads worker. Le démarrage effectif a lieu plus tard :

📎 tokio/src/runtime/builder.rs:2228-2234

rust
let handle = Handle { inner: scheduler::Handle::MultiThread(handle) };

// Spawn the thread pool workers
let _enter = handle.enter();
launch.launch();

Ok(Runtime::from_parts(Scheduler::MultiThread(scheduler), handle, blocking_pool))

handle.enter()entre dans le contexte runtime, puislaunch.launch()spawn réellement tous les threads worker. Cette conception en deux phases « construire d'abord, démarrer ensuite » est cruciale.

〔Inférence de conception et compromis architecturaux〕

Pourquoi ne pas démarrer en même temps que l'on construit ? Parce qu'une fois lancés, les threads worker commencent immédiatement à poller des tâches, et ces tâches peuvent référencerhandle. Sihandlen'est pas encore entièrement construit, on obtient une course où « le worker détient un handle à moitié fini ». La conception en deux phases garantit que :au démarrage de tous les threads worker, leHandlecomplet est déjà prêt。_enterLe guard garantit que les threads worker sont, dès l'instant de leur démarrage, dans le bon contexte runtime.

Diagramme de flux d'assemblage

Le schéma ci-dessous réunit l'ordre d'assemblage, les branches clés et les chemins d'erreur des deux chemins. Noter que lorsquedriver::Driver::newéchoue, on retourne directementErr, le blocking pool n'étant pas encore créé à ce stade.

mermaid
flowchart TD
    start["Builder::build()"] --> match_kind{"self.kind?"}

    match_kind -->|CurrentThread| ct_cfg["get_cfg() + timer_flavor=Traditional"]
    match_kind -->|MultiThread| mt_workers["worker_threads = self.worker_threads.unwrap_or_else(num_cpus)"]

    ct_cfg --> ct_driver["driver::Driver::new(cfg)?"]
    mt_workers --> mt_driver["driver::Driver::new(self.get_cfg())?"]

    ct_driver -->|Err| ret_err["return Err(io::Error)"]
    mt_driver -->|Err| ret_err

    ct_driver -->|Ok driver, driver_handle| ct_pool["create_blocking_pool(self, max_blocking_threads, 0)"]
    mt_driver -->|Ok driver, driver_handle| mt_pool["create_blocking_pool(self, max_blocking_threads + worker_threads, worker_threads)"]

    ct_pool --> ct_seed["next_generator() x2"]
    mt_pool --> mt_seed["next_generator() x2"]

    ct_seed --> ct_new["CurrentThread::new(driver, driver_handle, blocking_spawner, ...)"]
    mt_seed --> mt_new["MultiThread::new(worker_threads, driver, ...) -> (scheduler, handle, launch)"]

    ct_new --> ct_wrap["Handle { inner: CurrentThread(handle) }"]
    mt_new --> mt_wrap["Handle { inner: MultiThread(handle) }"]

    ct_wrap --> ct_rt["Runtime::from_parts(Scheduler::CurrentThread, handle, blocking_pool)"]
    mt_wrap --> mt_enter["handle.enter()"]
    mt_enter --> mt_launch["launch.launch() 启动 worker 线程"]
    mt_launch --> mt_rt["Runtime::from_parts(Scheduler::MultiThread, handle, blocking_pool)"]

Partage de handle :Handlecomment devient un « laissez-passer » inter-composants

Une fois l'assemblage terminé,Runtimedétient le trioscheduler、handle、blocking_pool. Parmi eux,handleest le cœur partagé. En interne, c'est une énumération :

📎 tokio/src/runtime/scheduler/mod.rs:29-41

rust
#[derive(Debug, Clone)]
pub(crate) enum Handle {
    #[cfg(feature = "rt")]
    CurrentThread(Arc<current_thread::Handle>),

    #[cfg(feature = "rt-multi-thread")]
    MultiThread(Arc<multi_thread::Handle>),

    #[cfg(not(feature = "rt"))]
    #[allow(dead_code)]
    Disabled,
}

Noter que les deux variantes encapsulentArc. Cela signifie que le clone deHandleest un incrément de compteur de références peu coûteux, pouvant être distribué librement à n'importe quel thread.Handlefournit une interface d'accès unifiée, encapsulant les différences de forme à l'intérieur dematch. Par exempledriver():

📎 tokio/src/runtime/scheduler/mod.rs:53-64

rust
pub(crate) fn driver(&self) -> &driver::Handle {
    match *self {
        #[cfg(feature = "rt")]
        Handle::CurrentThread(ref h) => &h.driver,

        #[cfg(feature = "rt-multi-thread")]
        Handle::MultiThread(ref h) => &h.driver,

        #[cfg(not(feature = "rt"))]
        Handle::Disabled => unreachable!(),
    }
}

blocking_spawner()utilise la macromatch_flavor!pour éliminer la répétition :

📎 tokio/src/runtime/scheduler/mod.rs:96-98

rust
pub(crate) fn blocking_spawner(&self) -> &blocking::Spawner {
    match_flavor!(self, Handle(h) => &h.blocking_spawner)
}

Cette macro se développe exactement endriver()comme ci-dessusmatch. Son intérêt : lorsqu'on ajoute un accesseur nécessitant une distribution selon la forme, une seule ligne dematch_flavor!suffit, sans avoir à écrire deux fois les branchesmatch.

LeHandlepublic est une fine enveloppe autour duscheduler::Handleinterne :

📎 tokio/src/runtime/handle.rs:13-15

rust
pub struct Handle {
    pub(crate) inner: scheduler::Handle,
}

LeHandleobtenu par l'utilisateur peut être cloné entre threads, peutspawn, peutblock_on。spawnL'implémentation de illustre la branche à la compilation deAutoBox:

📎 tokio/src/runtime/handle.rs:197-208

rust
pub fn spawn<F>(&self, future: F) -> JoinHandle<F::Output>
where
    F: Future + Send + 'static,
    F::Output: Send + 'static,
{
    let fut_size = mem::size_of::<F>();
    if AutoBox::<F>::SHOULD_BOX {
        self.spawn_named(Box::pin(future), SpawnMeta::new_unnamed(fut_size))
    } else {
        self.spawn_named(future, SpawnMeta::new_unnamed(fut_size))
    }
}

AutoBox::<F>::SHOULD_BOXest une constante associée, dérivée de la comparaison entresize_of::<F>()et un seuil.

📎 tokio/src/runtime/mod.rs:668-673

rust
pub(crate) struct AutoBox<T>(std::marker::PhantomData<T>);

impl<T> AutoBox<T> {
    pub(crate) const SHOULD_BOX: bool = std::mem::size_of::<T>() > BOX_FUTURE_THRESHOLD;
}
〔Inférence de conception et compromis architecturaux〕

Le commentaire explique pourquoi utiliser une constante associée plutôt qu'unifà l'exécution : avec un test à l'exécution,spawn_namedserait monomorphisé deux fois (une fois pourF, une fois pourPin<Box<F>>), ce qui générerait deux copies du harness de tâche pour chaque future spawné, doublant la taille du code. Avec une branche constante, le collecteur de monomorphisation ne conserve que la branche réellement empruntée.

Réflexions de conception : ordre d'assemblage, récupération d'erreur et pièges en production

L'ordre est un contrat. L'ordre d'assemblagedriver -> blocking_pool -> schedulern'est pas arbitraire. Le driver est créé en premier, car c'est la seule étape susceptible d'échouer par manque de ressources OS et qui, en cas d'échec, ne nécessite le nettoyage d'aucun autre composant. Le blocking_pool vient après le driver et avant le scheduler, car le scheduler a besoin du blocking_spawner. Si la création du blocking_pool échoue (en pratique, elle échoue rarement), le driver est nettoyé automatiquement par drop.

La branchelocal_tidde current_thread。build_localempruntebuild_current_thread_local_runtime, en y passant l'ID du thread courant :

📎 tokio/src/runtime/builder.rs:1738-1751

rust
fn build_current_thread_local_runtime(&mut self) -> io::Result<LocalRuntime> {
    use crate::runtime::local_runtime::LocalRuntimeScheduler;

    let tid = std::thread::current().id();

    let (scheduler, handle, blocking_pool) =
        self.build_current_thread_runtime_components(Some(tid))?;

    Ok(LocalRuntime::from_parts(
        LocalRuntimeScheduler::CurrentThread(scheduler),
        handle,
        blocking_pool,
    ))
}

Cetidest stocké dansHandle, et par la suitecan_spawn_local_on_local_runtimel'utilise pour vérifier « si spawn_local est appelé sur le thread owner » :

📎 tokio/src/runtime/scheduler/mod.rs:140-147

rust
pub(crate) fn can_spawn_local_on_local_runtime(&self) -> bool {
    match self {
        Handle::CurrentThread(h) => h.local_tid.is_some_and(|x| std::thread::current().id() == x),

        #[cfg(feature = "rt-multi-thread")]
        Handle::MultiThread(_) => false,
    }
}
〔Inférence de conception et compromis architecturaux〕

C'est la pierre angulaire de la sûreté deLocalRuntime:!Sendle future de ne peut être pollé que sur son thread owner, etlocal_tidest précisément le point de contrôle à l'exécution de cette contrainte. Sans cette vérification, un spawn_local inter-threads entraînerait un accès concurrent aux données de!Send, provoquant un UB.

Piège en production un :worker_threads(0)provoque un panic。worker_threadsLa méthode comporte une assertion :

📎 tokio/src/runtime/builder.rs:582-586

rust
pub fn worker_threads(&mut self, val: usize) -> &mut Self {
    assert!(val > 0, "Worker threads cannot be set to 0");
    self.worker_threads = Some(val);
    self
}

Cette assertion échoue dès la phase de configuration, plutôt que d'attendre le build. L'avantage est que l'erreur est localisée plus tôt, l'inconvénient est que si le nombre de threads provient d'une valeur dynamique du fichier de configuration, l'utilisateur doit la valider lui-même avant l'appel.

Piège de production n°2 :max_blocking_threadsUne valeur trop petite provoque un blocage. La documentation avertit explicitement :

📎 tokio/src/runtime/builder.rs:600-601

rust
/// It's recommended to not set this limit too low in order to avoid hanging on operations
/// requiring [`spawn_blocking`].
〔Inférence de conception et compromis architecturaux〕

Parce que la file d'attente du blocking pool n'a pas de contre-pression — les tâches s'accumulent jusqu'à ce qu'un thread soit disponible. Si tous les threads bloquants attendent une opération qui « nécessite un nouveau thread bloquant pour se terminer », il y a interblocage. La phrase de la documentation « the queue does not apply any backpressure, it could potentially grow unbounded » est précisément la note de bas de page de ce risque.

Piège de production n°3 :UnhandledPanic::ShutdownRuntimeSeul current_thread est pris en charge。

📎 tokio/src/runtime/builder.rs:1374-1381

rust
pub fn unhandled_panic(&mut self, behavior: UnhandledPanic) -> &mut Self {
    if !matches!(self.kind, Kind::CurrentThread) && matches!(behavior, UnhandledPanic::ShutdownRuntime) {
        panic!("UnhandledPanic::ShutdownRuntime is only supported in current thread runtime");
    }

    self.unhandled_panic = behavior;
    self
}
〔Inférence de conception et compromis architecturaux〕

La raison de cette limitation est que : en multi_thread, « arrêter immédiatement le runtime » nécessite de coordonner l'arrêt de tous les worker threads, ce qui est complexe à implémenter et sémantiquement ambigu (que faire des autres tâches en cours de poll ?). current_thread n'a qu'un seul thread, la sémantique d'arrêt est claire.

Résumé de ce chapitre

Ce chapitre a retracé laBuilder::buildchaîne d'assemblage complète de . Conclusion principale :

1. Builderest un pur conteneur de configuration,build()est ce qui crée les ressources. L'ordre d'assemblagedriver -> blocking_pool -> schedulerest déterminé par les besoins de récupération d'erreur.

2. La différence entre current_thread et multi_thread ne se limite pas au nombre de threads : le calcul de la capacité du blocking pool diffère (max_blocking_threads vs max_blocking_threads + worker_threads), multi_thread possède unlaunchdémarrage en deux phases supplémentaire,enable_eager_driver_handoffest forcé à la fermeture sous current_thread.

3. Handleest le cœur partagé entre les composants, utilisant en interneArcpour envelopper les handles spécifiques à chaque forme, accessible uniformément viamatchou lamatch_flavor!macro .

4. AutoBoxutilise des constantes associées pour décider à la compilation s'il faut boxer le future, évitant le doublement de la taille du code.

5. local_tidest leLocalRuntimepoint de contrôle à l'exécution de la sécurité de .

Dans le prochain chapitre, nous entrerons dans le cycle de vie des tâches :spawncomment transformer un Future en entité planifiable,JoinHandlecomment interagir avec la machine à états des tâches, et les transitions d'état des tâches entrePENDING / RUNNING / COMPLETE.

Réflexions et auto-évaluation de ce chapitre

Q1 : Si l'on change dansbuild_threaded_runtimele paramètre de capacité decreate_blocking_pooldeself.max_blocking_threads + worker_threadsenself.max_blocking_threads, dans quel scénario cela provoquerait-il la famine des tâches bloquantes ? Pourquoi le chemin current_thread peut-il passerself.max_blocking_threads?

Analyse de référence: Selon📎 tokio/src/runtime/builder.rs:2189-2192, le chemin multi_thread passeself.max_blocking_threads + worker_threads, tandis que le chemin current_thread📎 tokio/src/runtime/builder.rs:1765passeself.max_blocking_threads. La racine de la différence réside dans le fait que : en multi_thread, les worker threads eux-mêmes exécutent aussi des tâches bloquantes (par exempleblock_in_placeconvertit temporairement un worker thread en thread bloquant), donc le budget total de threads bloquants doit inclure le nombre de worker threads. Si l'on change pour ne passer queself.max_blocking_threads, lorsquemax_blocking_threadsest défini à une valeur faible (par exemple 1) et qu'un worker thread occupe déjà le budget dansblock_in_place, les nouvelles tâchesspawn_blockingn'auront plus de thread disponible et s'accumuleront dans la file sans contre-pression, provoquant la suspension permanente des tâches async dépendant de ces tâches bloquantes. current_thread n'a qu'un seul thread et ne prend pas en charge la sémantique de conversion de worker deblock_in_place, donc il n'est pas nécessaire d'ajouter le nombre de workers.

Q2: MultiThread::newretourne lelaunchhandle , c'estlaunch.launch()qui démarre réellement les worker threads. Si l'on supprimehandle.enter()cette ligne et appelle directementlaunch.launch(), que se passerait-il ?

Analyse de référence: Selon📎 tokio/src/runtime/builder.rs:2230-2232, avant le démarrage il y alet _enter = handle.enter();puis seulementlaunch.launch()。handle.enter(). Le rôle de est de définir le contexte thread-local, faisant « paraître » le thread courant à l'intérieur du runtime. Les worker threads commencent immédiatement à poller des tâches après leur démarrage, et le code des tâches peut appelerHandle::current()、tokio::spawnet d'autres API dépendant du contexte. Si l'on supprime_enter, la configuration du contexte au moment du démarrage du worker thread pourrait être incomplète (selon quelaunchdéfinit lui-même le contexte en interne), et dans le pire des cas, le code d'initialisation exécuté sur le worker thread appelantHandle::current()provoquerait un panic (CONTEXT_MISSING_ERROR). Même silaunchdéfinit le contexte pour chaque worker en interne,_entergarantit que « l'action de démarrage elle-même » se produit dans le bon contexte, évitant les conditions de course lors du démarrage.

Q3: AutoBox::<F>::SHOULD_BOXutilise des constantes associées plutôt qu'unif size_of::<F>() > THRESHOLDà l'exécution . Supposons que l'on passe à une vérification à l'exécution, outre le doublement de la taille du code, dans quels cas cela provoquerait-il une dégradation des performances ?

Analyse de référence: Selon📎 tokio/src/runtime/mod.rs:657-673les commentaires de , unifà l'exécution ferait quespawn_namedmonomorphise deux fois chaqueT(TetPin<Box<T>>une fois chacun). Outre le doublement de la taille du code, la dégradation des performances se manifeste par : 1) une pression accrue sur le cache d'instructions (i-cache), car les deux ensembles de code harness doivent résider ; 2) le compilateur ne peut pas optimiser le fait que « seul une branche est réellement empruntée », la prédiction de branche à l'exécution est généralement précise, mais la branche elle-même et les différences d'allocation de registres entre les deux ensembles de code s'accumulent ; 3) plus insidieux encore,Pin<Box<T>>le chemin force une allocation sur le tas, si le jugement à l'exécution, pour une raison quelconque (par exemplesize_ofnon complètement replié en constante dans un contexte générique), se trompe, les petits futures seraient aussi boxés, ajoutant une allocation sur le tas à chaque spawn. Les constantes associées permettent au collecteur de monomorphisation d'élaguer dès la compilation les branches non empruntées, pour un coût nul à l'exécution.

CHAPTER 03

Chapitre 3 : La vie d'une tâche (partie 1) : comment spawn transforme un Future en entité planifiable

Projet concerné : tokio-rs/tokio · Progression de l'ouvrage : Chapitre 3 / 14 · Statut de vérification : lignes FACT réellement ancrées

Dans le chapitre précédent, nous avons terminé l'assemblage du Runtime : le driver I/O, le driver time, le blocking pool et le scheduler sont injectés dans une même instanceRuntime,Handledevenant un handle partagé permettant d'accéder à ces composants depuis plusieurs threads. Mais le runtime ainsi assemblé n'est encore qu'une coquille vide — il possède le moteur pour piloter les tâches, mais aucune tâche à piloter. La question à laquelle ce chapitre répond est précisément : lorsque vous tapeztokio::spawn(async { ... }), ce que ce blocasynca réellement traversé pour passer d'un simple code Rust à une entité « pouvant être prise en charge par le scheduler, réveillée et jointe ». C'est la première mi-temps de « la vie d'une tâche », centrée sur la naissance : depuisHandle::spawn, en passant par l'allocation par comptage de références denew_task, jusqu'à la disposition mémoire deCell<T, S>, pour finalement voir comment la tâche est déposée dans la file locale d'un worker ou dans la file d'injection globale. La seconde mi-temps (chapitre 4) abordera la boucle de scheduling et la boucle fermée poll/wake.

3.1 Un Future n'est pas une tâche : ce qu'un spawn crée réellement

Modèle intuitif

ImaginezFuturecomme une « recette de cuisine », et la tâche comme « un plat en cours de cuisson dans la cuisine ». La recette elle-même est statique, copiable, sans aucun état d'exécution ; ce n'est que lorsque la cuisine (le scheduler) décide « de faire ce plat maintenant », lui attribue une plaque (worker), un numéro de commande (TaskId) et un passe de sortie (JoinHandle), qu'il devient un « plat en préparation ». Sans cet emballage, le scheduler ne saurait pas « où en est ce plat », « qui l'attend », « qui notifier une fois prêt » — il ne verrait qu'une recette, ingérable.

Structures de données et disposition mémoire

Tokio utiliseTask<S>pour représenter « une référence de tâche possédée par le runtime », un wrapper transparent autour deRawTask:

rust
#[repr(transparent)]
pub(crate) struct Task<S: 'static> {
    raw: RawTask,
    _p: PhantomData<S>,
}

📎 tokio/src/runtime/task/mod.rs:233-238

#[repr(transparent)]signifie queTask<S>etRawTasksont parfaitement identiques en mémoire, sans surcoût.PhantomData<S>n'est qu'un marqueur de type à la compilation, indiquant à quel type de scheduler appartient cette tâcheS。

Ce qui porte réellement tout l'état de la tâche, c'estCell<T, S>, dont la disposition est la pierre angulaire de tout le module de tâches :

rust
#[repr(C)]
pub(super) struct Cell<T: Future, S> {
    pub(super) header: Header,
    pub(super) core: Core<T, S>,
    pub(super) trailer: Trailer,
}

📎 tokio/src/runtime/task/core.rs:126-136

Les trois champs sont ordonnés selon « chaud-tiède-froid ».Headerest une donnée chaude (accédée à chaque scheduling, à chaque transition d'état),Coreest une donnée tiède (accédée lors du poll),Trailerest une donnée froide (accédée uniquement à la création et à la destruction). Le commentaire indique explicitement :Headerdoit être le premier champ, car la structure de tâche sera référencée simultanément par*mut Cellet*mut Header📎 tokio/src/runtime/task/core.rs:37-43。

Plus crucial encore est l'alignement sur les lignes de cache.Cellporte une longue série de#[cfg_attr(..., repr(align(...)))], choisissant le nombre d'octets d'alignement selon l'architecture cible : x86_64/aarch64/powerpc64 utilisent 128 octets, arm/mips/sparc/hexagon 32 octets, m68k 16 octets, s390x 256 octets, et 64 octets par défaut pour le reste📎 tokio/src/runtime/task/core.rs:64-125. Le commentaire explique pourquoi x86_64 utilise 128 plutôt que 64 : depuis Intel Sandy Bridge, le prefetcher spatial récupère en une foispar pairesles lignes de cache de 64 octets, il faut donc s'aligner sur 128 octets pour éviter le faux partage📎 tokio/src/runtime/task/core.rs:45-53。

〔Inférence de conception et compromis architecturaux〕

Le coût de cette stratégie d'alignement est qu'au moins une ligne de cache est gaspillée par tâche. Mais les bits d'état de la tâche (state) sont lus et écrits à haute fréquence par plusieurs threads worker — un thread définit le bit RUNNING lors du poll, un autre lit le bit NOTIFIED lors du réveil — si les bits d'état de deux tâches tombent sur la même ligne de cache, chaque transition d'état déclenche un va-et-vient de la ligne de cache entre les cœurs (cache line ping-pong), dont la perte de performance dépasse largement le gaspillage mémoire. Tokio choisit d'échanger de l'espace contre du temps.

Headerest lui-même contraint à moins de 8 tailles de pointeur :

rust
#[test]
#[cfg(not(loom))]
fn header_lte_cache_line() {
    assert!(std::mem::size_of::<Header>() <= 8 * std::mem::size_of::<*const ()>());
}

📎 tokio/src/runtime/task/core.rs:591-593

Ce test garantit queHeaderne dépasse pas 64 octets (8 × 8), et tient donc entièrement dans une ligne sur les architectures à ligne de cache de 64 octets.HeaderLes champs destate: Statecomprennent :queue_next: UnsafeCell<Option<NonNull<Header>>>(bits d'état atomiques),vtable: &'static Vtable(pointeur de liste chaînée de la file d'injection),owner_id: UnsafeCell<Option<NonZeroU64>>(table de pointeurs de fonctions),OwnedTasks(ID de la liste descheduled_at: UnsafeCell<ScheduleLatencyInstant>d'appartenance),📎 tokio/src/runtime/task/core.rs:169-198。

Core<T, S>(mesure de latence de scheduling)scheduler: Sdétient le handle du schedulertask_id: Id, l'ID de tâchestage: CoreStage<T> 📎 tokio/src/runtime/task/core.rs:148-165。Stage, et le cœur

rust
#[repr(C)]
pub(super) enum Stage<T: Future> {
    Running(T),
    Finished(super::Result<T::Output>),
    Consumed,
}

📎 tokio/src/runtime/task/core.rs:225-229

CopieStage::RunningC'est précisément la clé du « Future et Output partagent le même bloc mémoire » : pendant l'exécution de la tâche,Stage::Finished(output)détient le future, une fois terminé il est remplacé sur place parJoinHandle, et après avoir été retiré parStage::Consumed。#[repr(C)]il devient📎 tokio/src/runtime/task/core.rs:225-229。

TrailerLe commentaire pointe vers un issue Miri, indiquant que cette disposition impose des exigences strictes de correction au code unsafeowned: linked_list::Pointers<Header>(OwnedTasksstocke les données froides :waker: UnsafeCell<Option<Waker>>(pointeur de liste chaînée),hooks: TaskHarnessScheduleHooks 📎 tokio/src/runtime/task/core.rs:205-213。

(waker du consommateur attendant la fin de la tâche),

Étape par étape : du spawn à la mise en filetokio::spawn(async { 42 })。

Prenons un scénario concret : dans un runtime multi_thread, le thread worker A exécute new_taskPremière étape : construire le trio de la tâche.

rust
fn new_task<T, S>(
    task: T,
    scheduler: S,
    id: Id,
    spawned_at: SpawnLocation,
) -> (Task<S>, Notified<S>, JoinHandle<T::Output>)

📎 tokio/src/runtime/task/mod.rs:336-346

CopieRawTask::new::<T, S>Il appelleCellpour allouerraw, puis dérive trois références à partir du même pointeurTask(référence owned, généralement placée immédiatement dansOwnedTasks)、Notified(référence de notification, remise au scheduler),JoinHandle(handle de lecture du résultat)📎 tokio/src/runtime/task/mod.rs:347-363. Notez que les trois partagent le mêmeraw, chacun détenant un compteur de références.

Deuxième étape : allouerCellet écrire l'état initial. Cell::newAllouer la structure entière sur le tas :

rust
let result = Box::new(Cell {
    trailer: Trailer::new(scheduler.hooks()),
    header: new_header(state, vtable, ...),
    core: Core {
        scheduler,
        stage: CoreStage {
            stage: UnsafeCell::new(Stage::Running(future)),
        },
        task_id,
        ...
    },
});

📎 tokio/src/runtime/task/core.rs:261-278

vtablegénéré parraw::vtable::<T, S>(), c'est une table de pointeurs de fonctions monomorphisée pour unTet unSspécifiques📎 tokio/src/runtime/task/core.rs:260. Le future est déplacé directement dansStage::Running, sans boxing supplémentaire.

Troisième étape : l'assertion de debug vérifie la disposition.Sousdebug_assertions,Cell::newappelle la fonctioncheck, en utilisantHeader::get_trailer、Header::get_scheduler、Header::get_id_ptret d'autres opérations de pointeurs basées sur les offsets de la vtable, pour vérifier un par un que « l'adresse du champ retrouvée via le header » correspond à « l'adresse réelle du champ »📎 tokio/src/runtime/task/core.rs:280-321. C'est une auto-vérification à l'exécution de la validité des offsets de la vtable.

Quatrième étape : soumettre au planificateur.Le planificateur, après avoir reçuNotified<S>, appelleSchedule::schedule 📎 tokio/src/runtime/task/mod.rs:315. En multi_thread, cela passe parpush_back_or_overflow, poussant la tâche dans la file locale du worker courant, débordant vers la file d'injection lorsque la file est pleine.

La figure ci-dessous décrit le flux de contrôle et les branchements depuisnew_taskjusqu'à la mise en file :

mermaid
flowchart TD
    spawn_call["Handle::spawn(future)"] --> new_task["new_task::<T,S>(future, scheduler, id)"]
    new_task --> raw_new["RawTask::new::<T,S>"]
    raw_new --> cell_new["Cell::new: Box::new(Cell{header, core, trailer})"]
    cell_new --> vtable["raw::vtable::<T,S>() 生成函数指针表"]
    cell_new --> stage["Stage::Running(future) 移入"]
    cell_new --> debug_check{"debug_assertions?"}
    debug_check -->|是| check_layout["check(): 断言 trailer/scheduler/id 偏移量"]
    debug_check -->|否| skip_check["跳过"]
    check_layout --> triple["派生 (Task, Notified, JoinHandle)"]
    skip_check --> triple
    triple --> owned["Task 存入 OwnedTasks"]
    triple --> sched["Notified 交给 Schedule::schedule"]
    sched --> push{"本地队列有容量?"}
    push -->|是| local_push["push_back_finish: 写入 buffer[tail & MASK]"]
    push -->|否| overflow_check{"steal == real?"}
    overflow_check -->|否, 有并发窃取| inject_only["overflow.push(task) 仅注入"]
    overflow_check -->|是| push_overflow["push_overflow: CAS 认领后半批"]
    push_overflow --> cas_ok{"CAS 成功?"}
    cas_ok -->|是| inject_batch["overflow.push_batch(后半批 + 当前 task)"]
    cas_ok -->|否| retry["返回 Err(task), 重试 push_back_or_overflow"]
    retry --> push

Cette figure révèle plusieurs branchements clés : l'assertion de debug n'est active qu'en build de débogage ; lorsque la file locale est pleine, on ne déborde pas directement, mais on vérifie d'abord s'il existe un voleur concurrent (steal != real), et si oui, on ne pousse que la tâche courante dans la file d'injection, car l'espace libéré par le voleur sera bientôt disponible.

Réflexion de conception : pourquoi trois références plutôt qu'une

new_taskrenvoie trois références, et non une seule. C'est le cœur de la conception du comptage de références :Taskreprésente « le runtime possède cette tâche »,Notifiedreprésente « cette tâche a été notifiée, en attente de planification »,JoinHandlereprésente « quelqu'un s'intéresse à son résultat ». Les trois ont des durées de vie indépendantes —JoinHandlepeut être drop (la tâche continue de s'exécuter, le résultat est abandonné),Notifieddisparaît après le poll,Taskest libéré une fois la tâche terminée et retirée deOwnedTasks. S'il n'y avait qu'une seule référence, il serait impossible d'exprimer l'état « la tâche tourne encore mais personne ne la join ».

UnownedTaskest un autre branchement important : il détientdeuxcompteurs de références, utilisés pour les tâches blocking (non stockées dansOwnedTasks)📎 tokio/src/runtime/task/mod.rs:286-295。unownedla fonctionmem::forget(task)fusionne les deux références dansmem::forget(notified)viaUnownedTask 📎 tokio/src/runtime/task/mod.rs:388-397etOwnedTasks. La motivation de cette conception à « deux références » est : les tâches blocking n'ont pas de

liste pour détenir une référence owned, il faut donc un compteur de références supplémentaire pour garantir que la tâche ne soit pas libérée pendant son exécution.

3.2 Bits d'état : comment un usize encode tout le cycle de vie d'une tâche

Modèle intuitifImaginez l'état d'une tâche comme un « bulletin d'examen médical » comportant plusieurs cases à cocher indépendantes : est-elle en cours de poll, est-elle terminée, a-t-elle été notifiée, a-t-elle été annulée, quelqu'un la join-il. Tokio n'utilise pas plusieurs champs booléens, mais compresse ces bits dansAtomicUsizeun

. Ainsi, chaque transition d'état ne nécessite qu'un seul CAS, au lieu de plusieurs verrous. Sans cette conception, les transitions d'état des tâches deviendraient un emboîtement de multiples verrous, faisant grimper en flèche le risque de deadlock et les coûts.

StateDisposition des champs de bits📎 tokio/src/runtime/task/mod.rs:32-53:

  • RUNNINGLes champs de bits desont entièrement définis dans la documentation du module 📎 tokio/src/runtime/task/mod.rs:37-38。
  • COMPLETE: la tâche est-elle en cours de poll ou annulée.RUNNINGCe bit sert également de verrou à la tâche📎 tokio/src/runtime/task/mod.rs:40-41。
  • NOTIFIED: le future est entièrement terminé et drop. Une fois positionné, il n'est jamais effacé, et jamais positionné en même temps queNotified:📎 tokio/src/runtime/task/mod.rs:43。
  • CANCELLED: existe-t-il actuellement un objet📎 tokio/src/runtime/task/mod.rs:45-46。
  • JOIN_INTEREST:JoinHandle 📎 tokio/src/runtime/task/mod.rs:48。
  • JOIN_WAKER: la tâche doit être annulée dès que possible📎 tokio/src/runtime/task/mod.rs:50-51。

: il existe📎 tokio/src/runtime/task/mod.rs:53。

RUNNING: bit de contrôle d'accès servant de join handle wakerRUNNINGLes bits restants servent au comptage de références📎 tokio/src/runtime/task/mod.rs:130-133Le fait que le bitRUNNINGserve de verrou mérite d'être développé. La section Safety de la documentation du module indique : tout accès mutable au future doit se faire après avoir acquis le verrou en modifiant le bit

, garantissant ainsi un accès exclusif

JOIN_WAKER. Cela signifie que lors du poll d'une tâche, le thread effectue d'abord un CAS pour positionnerwaker, et en cas de succès obtient l'exclusivité sur le future ; en cas d'échec, cela signifie qu'un autre thread est en train de poll, et ce poll retourne directement. Cela fusionne « l'exclusion mutuelle du poll » et « la transition d'état » en une seule opération atomique, évitant un mutex séparé.TrailerProtocole de contrôle d'accès de JOIN_WAKERLe bitest la partie la plus ingénieuse de toute la machine à états. Il résout le problème suivant :JoinHandlele champ(dans) est accédé concurremment par deux threads — le runtime, à la fin de la tâche,📎 tokio/src/runtime/task/mod.rs:75-120:

1. JOIN_WAKERle

lit pour réveiller le joiner,JoinHandlelors du poll

l'écritJoinHandlepour enregistrer le waker. La documentation du module donne 7 règles

est initialement à 0.COMPLETE2. Lorsqu'il est à 0,

5. JoinHandlea un accès exclusif (mutable) au champ waker.JOIN_WAKER3. Lorsqu'il est à 1,JOIN_WAKERn'a qu'un accès partagé (lecture seule).

6. JoinHandle4. Lorsqu'il est à 1 et queCOMPLETEest à 1, le runtime a un accès partagé (lecture seule) au champ waker.JOIN_WAKERPour écrire le waker, il faut : (i) réussir à mettreCOMPLETEà 0 pour obtenir l'exclusivité, (ii) écrire le waker, (iii) réussir à mettre

à 1.JOIN_INTERESTne peut modifierCOMPLETEque lorsque

est à 0 ; le runtime ne peut le modifier que lorsqueCOMPLETEest à 1.📎 tokio/src/runtime/task/mod.rs:110-1207. Si

est à 0 et

Taskest à 1, le runtime a un accès exclusif au champ waker (pour drop le waker).UnownedTaskle drop décrémente deux fois :

rust
impl<S: 'static> Drop for Task<S> {
    fn drop(&mut self) {
        if self.header().state.ref_dec() {
            self.raw.dealloc();
        }
    }
}

📎 tokio/src/runtime/task/mod.rs:580-586

rust
impl<S: 'static> Drop for UnownedTask<S> {
    fn drop(&mut self) {
        if self.raw.header().state.ref_dec_twice() {
            self.raw.dealloc();
        }
    }
}

📎 tokio/src/runtime/task/mod.rs:590-596

ref_decretournetrueindique qu'il s'agit de la dernière référence, et c'est seulement à ce moment que la libération a réellement lieuCellmémoire.ref_dec_twiceestUnownedTaskest la manifestation directe de la détention de deux compteurs.

Réflexion de conception : pourquoi les bits d'état et le compteur de références partagent-ils un même atomique

〔Inférence de conception et compromis architecturaux〕

Placer les bits d'état et le compteur de références dans le mêmeAtomicUsizevise à permettre aux deux actions « décrémenter le compteur de références » et « définir les bits d'état » d'être accomplies enun seul CAS. La documentation du module mentionne explicitement dans le commentaire deSchedule::release: « le module de tâches traite par lots le ref-dec et la définition d'autres options »📎 tokio/src/runtime/task/mod.rs:302-304. Si les bits d'état et le compteur de références appartenaient à deux variables atomiques distinctes, alors il existerait une fenêtre entre « libérer la dernière référence » et « marquer comme terminé », nécessitant une synchronisation supplémentaire. Après fusion,ref_decpeut accomplir atomiquement « décrémenter le compteur + vérifier s'il atteint zéro », évitant les problèmes de type ABA.

3.3 JoinHandle : comment le résultat traverse les frontières de tâche pour être renvoyé

Modèle intuitif

JoinHandleest comme le « ticket de retrait » que vous donne un restaurant. Lorsque la tâche (la cuisine) se termine, elle place le plat (output) au passe (Stage::Finished), puis fait sonner votre bip de retrait (waker). Vous venez le récupérer avec le ticket ; le ticket lui-même ne contient pas le plat, c'est juste un pointeur vers le passe. Si vous perdez le ticket (dropJoinHandle), le plat sera directement jeté (output est drop), mais la cuisine ne s'arrêtera pas pour autant.

Structure de données

JoinHandle<T>est également un emballage transparent autour deRawTask:

rust
pub struct JoinHandle<T> {
    raw: RawTask,
    _p: PhantomData<T>,
}

📎 tokio/src/runtime/task/join.rs:163-166

PhantomData<T>marque le type de sortie.JoinHandle<T>n'estT: Sendque lors deSend/Sync 📎 tokio/src/runtime/task/join.rs:169-170, ce qui garantit qu'une sortie non-Send ne sera pas déplacée entre threads.

Étape par étape : await un JoinHandle

JoinHandleimplémenteFuture, dontpollest le cœur du renvoi du résultat :

rust
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
    ready!(crate::trace::trace_leaf());
    let mut ret = Poll::Pending;
    let coop = ready!(crate::task::coop::poll_proceed(cx));
    unsafe {
        self.raw.try_read_output(&mut ret, cx.waker());
    }
    if ret.is_ready() {
        coop.made_progress();
    }
    ret
}

📎 tokio/src/runtime/task/join.rs:327-354

Noter quelques détails :trace_leafest utilisé pour l'instrumentation tracing ;coop::poll_proceedconsomme le budget de coopération (détaillé au chapitre 12) ;try_read_outputefface les génériques via la vtable, place la valeur de retour sur la pile et la transmet à*mut ()via📎 tokio/src/runtime/task/join.rs:327-354. Cette technique de « valeur de retour sur la pile » existe parce que les fonctions de vtable ne peuvent pas génériciser le type de retourT, et ne peuvent réécrire que via un pointeur brut.

〔Inférence de conception et compromis architecturaux〕

try_read_outputlogique interne (dans raw.rs, dont le code source n'est pas fourni dans ce chapitre) : vérifie d'abord le bitCOMPLETE, s'il est déjà positionné, appelletake_outputpour retirer le résultat deStage::Finished; sinon enregistrecx.waker()dans le champTrailer::waker, retournePending. Le processus d'enregistrement suit précisément le protocoleJOIN_WAKERde la section 3.2.

Transfert de propriété du résultat

La section « Non-Send output » de la documentation du module décrit précisément les règles de propriété du résultat📎 tokio/src/runtime/task/mod.rs:151-170:

  • Lorsque la tâche se termine, output est placé dansStage, puis la transition « définir COMPLETE » est exécutée, et la valeurJOIN_INTERESTest lue à cet instant.
  • SiJOIN_INTERESTvaut 0 (aucunJoinHandle), output est immédiatement drop📎 tokio/src/runtime/task/mod.rs:157-158。
  • SiJOIN_INTERESTvaut 1,JoinHandleest responsable du nettoyage de output📎 tokio/src/runtime/task/mod.rs:160-161。

Pour une sortie non-Send, la documentation donne un argument en trois étapes : output est créé sur le thread qui poll le future ;JoinHandle<Output>n'est pas non plus Send lorsque Output n'est pas Send, donc il est aussi sur le thread de spawn ; par conséquentJoinHandlene déplace pas output entre threads lors du retrait ou du drop📎 tokio/src/runtime/task/mod.rs:164-170。

Le drop de JoinHandle : deux chemins, rapide et lent

rust
impl<T> Drop for JoinHandle<T> {
    fn drop(&mut self) {
        if self.raw.state().drop_join_handle_fast().is_ok() {
            return;
        }
        self.raw.drop_join_handle_slow();
    }
}

📎 tokio/src/runtime/task/join.rs:358-364

drop_join_handle_fasttente d'accomplir en un seul CAS « effacer le bitJOIN_INTEREST+ décrémenter le compteur de références ». En cas d'échec (par exemple la tâche est en cours de finalisation, le bit d'état étant occupé), on emprunte le chemin lent dedrop_join_handle_slow. C'est le schéma typique « chemin rapide optimiste + chemin lent pessimiste ».

Réflexion de conception : pourquoi JoinHandle ne détient-il pas directement output

〔Inférence de conception et compromis architecturaux〕

SiJoinHandledétenait directement output, alors output devrait être déplacé vers le thread où se trouveJoinHandleà la fin de la tâche. MaisJoinHandlepeut être déplacé vers n'importe quel thread (tant queT: Send), tandis que le thread de production de output est le thread de poll. Une détention directe entraînerait un déplacement inter-threads où « output est produit sur le thread de poll, mais doit être drop sur le thread de join », ce qui violerait directement le système de types pour une sortie non-Send. Tokio choisit de laisser output dansCell(Stage::Finished),JoinHandlene détient qu'unCellpointant versRawTask, et retire le résultat sur place viatake_output. Ainsi le drop de output se produit sur le thread où se trouveJoinHandle, à condition que ce thread soit le même que le thread de poll (ce qui est vrai dans le scénario non-Send).

3.4 File locale : structure producteur-consommateur du work-stealing

Modèle intuitif

Chaque worker dispose d'une « liste de tâches privée » (file locale), de capacité 256. Le worker lui-même retire les tâches depuisla tête(LIFO, exploitant la localité de cache), les autres workers volent les tâches depuisla queue(FIFO, retirant les plus anciennes, les plus susceptibles d'être déjà terminées). Sans file locale, toutes les tâches s'entasseraient dans la file globale, et chaque retrait de tâche nécessiterait de se disputer le verrou global, ce qui ferait s'effondrer la scalabilité multicœur.

Disposition mémoire : séparation de head et tail

rust
pub(crate) struct Inner<T: 'static> {
    head: AtomicUnsignedLong,
    tail: AtomicUnsignedShort,
    buffer: Box<[UnsafeCell<MaybeUninit<task::Notified<T>>>; LOCAL_QUEUE_CAPACITY]>,
}

📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:36-57

headestAtomicUnsignedLong(64 bits, si la plateforme supporte u64),tailestAtomicUnsignedShort(32 bits). Le commentaire explique pourquoi les indices sont plus larges que nécessaire : pour atténuer l'ABA, et pour distinguer un tampon « plein » d'un tampon « vide »📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:37-49。

heademballe en internedeux UnsignedShort: le bit bas est la « real head » (tête réelle), le bit haut est la « steal head » (première position traitée par le voleur). Lorsqu'ils sont égaux, il n'y a pas de voleur actif📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:39-49. Cet empaquetage à deux valeurs est l'astuce centrale de la file work-stealing : le voleur effectue d'abord un CAS pour mettre à jour la valeur steal afin de « revendiquer » un lot de tâches, puis une fois terminé, rattrape la valeur real avec la valeur steal, indiquant la fin du vol.

LOCAL_QUEUE_CAPACITYvaut 256 hors loom, et est réduit à 4 sous loom pour tester davantage de cas limites📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:62-69。MASK = LOCAL_QUEUE_CAPACITY - 1, utilisé pour l'index du buffer circulaire📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:71。

Step-by-Step : les branches complètes de push_back_or_overflow

C'est la fonction la plus complexe de la file locale, nous allons l'analyser branche par branche :

rust
pub(crate) fn push_back_or_overflow<O: Overflow<T>>(
    &mut self,
    mut task: task::Notified<T>,
    overflow: &O,
    stats: &mut Stats,
) {
    let tail = loop {
        let head = self.inner.head.load(Acquire);
        let (steal, real) = unpack(head);
        let tail = unsafe { self.inner.tail.unsync_load() };

        if tail.wrapping_sub(steal) < LOCAL_QUEUE_CAPACITY as UnsignedShort {
            break tail;
        } else if steal != real {
            overflow.push(task);
            return;
        } else {
            match self.push_overflow(task, real, tail, overflow, stats) {
                Ok(_) => return,
                Err(v) => { task = v; }
            }
        }
    };
    self.push_back_finish(task, tail);
}

📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:188-223

Trois branches :

1. Capacité disponible(tail - steal < CAPACITY):break tail, après avoir quitté la boucle, appellepush_back_finishpour écrire dans le buffer.

2. Pas de capacité mais des voleurs concurrents(steal != real) : les voleurs libéreront de l'espace, donc on pousse seulement la tâche courante dans la file d'injection et on retourne immédiatement📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:204-208。

3. Pas de capacité et pas de voleur: appellepush_overflowpour déverser la seconde moitié des tâches dans la file d'injection📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:209-219. Si le CAS échoue (perdu face à un voleur concurrent),push_overflowretourneErr(task), et la boucle réessaie.

push_back_finishécrit la tâche et met à jour tail :

rust
fn push_back_finish(&self, task: task::Notified<T>, tail: UnsignedShort) {
    let idx = tail as usize & MASK;
    self.inner.buffer[idx].with_mut(|ptr| {
        unsafe { ptr::write((*ptr).as_mut_ptr(), task); }
    });
    self.inner.tail.store(tail.wrapping_add(1), Release);
}

📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:226-244

ReleaseL'ordre garantit que la tâche écrite est visible pour les voleurs.

push_overflow : pourquoi déverser la seconde moitié

rust
const NUM_TASKS_TAKEN: UnsignedShort = (LOCAL_QUEUE_CAPACITY / 2) as UnsignedShort;

📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:265

Lors du déversement, on retire 128 tâches. Le commentaire explique en détail pourquoi on prendla seconde moitiéplutôt que la première moitié📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:295-306: lors du retrait de tâches depuis la file d'injection, elles sont toujours placées dans la première moitié. Donc si une tâche se trouve dans la seconde moitié, on peut être certain qu'elle ne vient pas d'être retirée de la file d'injection. Cela garantit qu'« une tâche retirée de la file d'injection ne sera pas immédiatement remise dans la file d'injection » (du moins avant d'avoir été poll au moins une fois).

CAS pour revendiquer la seconde moitié :

rust
if self.inner.head.compare_exchange_weak(
    pack(head, head), pack(tail, tail), Release, Relaxed
).is_err() {
    return Err(task);
}

📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:283-293

Metheadde(head, head)à(tail, tail), c'est-à-dire avance simultanément steal et real jusqu'à tail, revendiquant toutes les tâches. En cas de succès, recule tail àtail + NUM_TASKS_TAKEN, indiquant que la première moitié reste dans la file locale📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:314-316。

pop et steal_into : les deux chemins de retrait de tâches

popest le retrait de tâche par le worker lui-même (depuis la tête, LIFO) :

rust
pub(crate) fn pop(&mut self) -> Option<task::Notified<T>> {
    let mut head = self.inner.head.load(Acquire);
    let idx = loop {
        let (steal, real) = unpack(head);
        let tail = unsafe { self.inner.tail.unsync_load() };
        if real == tail { return None; }
        let next_real = real.wrapping_add(1);
        let next = if steal == real {
            pack(next_real, next_real)
        } else {
            assert_ne!(steal, next_real);
            pack(steal, next_real)
        };
        let res = self.inner.head.compare_exchange_weak(head, next, AcqRel, Acquire);
        match res {
            Ok(_) => break real as usize & MASK,
            Err(actual) => head = actual,
        }
    };
    Some(self.inner.buffer[idx].with(|ptr| unsafe { ptr::read(ptr).assume_init() }))
}

📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:361-399

Branche clé : sisteal == real(aucun voleur), avance les deux simultanément ; sinon n'avance que real, en laissant steal inchangé📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:377-384。assert_ne!(steal, next_real)garantit de ne pas avancer real jusqu'à la position de steal, sinon l'état de revendication du voleur serait corrompu.

steal_intoest le chemin de vol, on vérifie d'abord si la file cible a suffisamment d'espace :

rust
if dst_tail.wrapping_sub(steal) > LOCAL_QUEUE_CAPACITY as UnsignedShort / 2 {
    return None;
}

📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:431-435

Si la file cible est plus qu'à moitié pleine, on ne vole pas, pour éviter qu'un vol soit immédiatement suivi d'un déversement.

steal_into2est le cœur du vol, calcule la quantité à voler :

rust
let n = src_tail.wrapping_sub(src_head_real);
let n = n - n / 2;

📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:487-488

Vole la moitié (arrondie au supérieur). Puis CAS pour mettre à jour la valeur steal de head afin de revendiquer :

rust
let steal_to = src_head_real.wrapping_add(n);
next_packed = pack(src_head_steal, steal_to);
let res = self.0.head.compare_exchange_weak(prev_packed, next_packed, AcqRel, Acquire);

📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:496-506

Notez qu'ici seule la valeur real est mise à jour (pack(src_head_steal, steal_to)dans steal reste inchangé), en avançant real jusqu'àsteal_to. Cela signifie « ces tâches ont été revendiquées, les autres voleurs ne peuvent plus y toucher ». Une fois le vol terminé, on rattrape steal avec real :

rust
loop {
    let head = unpack(prev_packed).1;
    next_packed = pack(head, head);
    let res = self.0.head.compare_exchange_weak(prev_packed, next_packed, AcqRel, Acquire);
    match res {
        Ok(_) => return n,
        Err(actual) => prev_packed = actual,
    }
}

📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:548-561

Le diagramme temporel ci-dessous décrit l'interaction concurrente à trois entre « producteur push, consommateur pop, voleur steal » :

mermaid
sequenceDiagram
    participant P as "Worker A (生产者)"
    participant Q as "Local 队列 Inner"
    participant C as "Worker A (消费者 pop)"
    participant S as "Worker B (窃取者)"

    P->>Q: "load head (Acquire)"
    P->>Q: "unsync_load tail"
    Note over P: "tail - steal < 256?"
    P->>Q: "push_back_finish: buffer[idx] = task"
    P->>Q: "store tail+1 (Release)"

    C->>Q: "load head (Acquire)"
    C->>Q: "unsync_load tail"
    Note over C: "real == tail? 空则返回 None"
    C->>Q: "CAS head: pack(real+1, real+1)"
    Q-->>C: "Ok, 读取 buffer[real & MASK]"

    S->>Q: "load head (Acquire)"
    S->>Q: "load tail (Acquire)"
    Note over S: "src_head_steal != src_head_real? 返回 0"
    S->>Q: "CAS head: pack(steal, real+n) 认领一半"
    Q-->>S: "Ok, 拷贝 n 个任务到 dst"
    S->>Q: "CAS head: pack(real+n, real+n) 完成窃取"
    Q-->>S: "返回 n"

Réflexion de conception : pourquoi la file locale est LIFO et le vol est FIFO

〔Inférence de conception et compromis architecturaux〕

Le worker retire lui-même depuis la tête (LIFO), car la tâche la plus récemment poussée est la plus susceptible d'être encore dans le cache CPU, et la plus susceptible d'être « fraîchement réveillée, avec des données encore chaudes ». Le voleur retire depuis la queue (FIFO), car la tâche la plus ancienne a probablement déjà accompli la majeure partie de son travail, et la voler permet de réduire le plus rapidement la charge de la victime. Cette combinaison « LIFO local + FIFO vol » est la conception classique de l'ordonnancement work-stealing, conciliant localité de cache et équilibrage de charge.

À ce stade, la tâche a achevé sa métamorphose de Future en entité ordonnançable : elle s'est vu attribuer un comptage de références, a été placée dansCellla disposition mémoire, et a été transmise avec succès à la file locale du worker ou à la file d'injection globale. Mais placer une tâche dans une file n'est que le début ; ce qui la fait réellement tourner, c'est la boucle d'ordonnancement du thread worker. Dans le prochain chapitre, nous entrerons dans la seconde moitié de « la vie d'une tâche », en traçant comment le worker retire une tâche de la file, appelleFuture::poll, et lors du retour dePendingenregistre un réveil viaWaker, déclenchant finalement la remise en file parschedule— le chemin d'appel complet de la boucle fermée « réveil → mise en file → re-poll », ainsi que la stratégie work-stealing et l'optimisation des slots LIFO, seront révélés là-bas.

CHAPTER 04

Chapitre 4 : La vie d'une tâche (suite) : boucle d'ordonnancement, poll et boucle fermée de réveil

Projet : tokio-rs/tokio · Progression du livre : Chapitre 4 / 14 · État de vérification : ancrage réel des numéros de ligne FACT

De la file à l'exécution : le squelette de la boucle principale du worker

Dans le chapitre précédent, nous avons envoyé la tâche dans laLocalfile ou la file d'injection globale. Mais la file n'est qu'une « liste de choses à faire » ; ce qui fait réellement tourner la tâche, c'est cette boucle sans fin dans le thread worker. Dans ce chapitre, nous traçonsContext::run— c'est le cœur de tout l'ordonnanceur multithread.

Établissons d'abord l'intuition : le thread worker est comme un cuisinier, devant lui une pile de ses propres commandes (run_queue), et à côté un présentoir de commandes public (inject). Le cuisinier regarde d'abord la commande la plus proche à portée de main (lifo_slot), s'il n'y en a pas, il en prend dans sa propre pile, s'il n'y en a toujours pas, il en attrape une poignée sur l'étagère commune, et si ça ne suffit toujours pas, il en vole quelques-unes dans la pile d'un autre cuisinier. Ce n'est que lorsque tout est vide qu'il va se reposer, mais pendant le repos ses oreilles restent dressées — dès qu'une commande arrive, il se réveille immédiatement.

Sans cette boucle, une tâche une fois mise en file d'attente resterait éternellement dans la file,Future::pollne serait jamais appelée, et tout le runtime ne serait qu'un tas de données mortes.

Disposition mémoire et champs d'état de Core

Tout l'état mutable du worker est contenu dansCore, il estBoxalloué sur le tas, et transmis viaAtomicCell<Core>entreWorkeret leContextlocal au thread.

CoreLes champs clés de📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:113-167:

  • tick: u32sont les suivants : incrémenté à chaque tour de boucle, utilisé pour déclencher périodiquement la maintenance (maintenance) et la vérification de la file globale.
  • lifo_slot: Option<Notified>:Emplacement LIFO, c'est la conception la plus ingénieuse de ce chapitre. Lorsqu'un worker planifie lui-même une tâche, il ne l'insère pas dansrun_queue, mais la place dans cet emplacement, et lors de la prochaine récupération de tâche ilprioritairementla prend ici.
  • lifo_enabled: bool: interrupteur de l'emplacement LIFO, utilisé pour éviter la famine dans les scénarios de ping-pong.
  • run_queue: queue::Local<Arc<Handle>>: file locale, la structureLocalanalysée dans le chapitre précédent.
  • is_searching: bool: indique si le worker est en train de chercher des tâches à voler.
  • is_shutdown: bool / is_traced: bool: indicateurs d'arrêt et de traçage.
  • park: Option<Parker>: parker, enveloppé avecOptionpour faciliter l'extraction/remise en place sous le borrow checker.
  • global_queue_interval: u32: fréquence de vérification de la file globale.
  • rand: FastRand: générateur de nombres aléatoires rapide, utilisé pour choisir aléatoirement le point de départ du vol.
〔Inférence de conception et compromis architecturaux〕

Notez quelifo_slotestOption<Notified>et non une file — il ne stockequ'une seuletâche. La motivation de cette conception est clairement expliquée dans les commentaires du code source📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:117-121: les tâches planifiées par le worker lui-même sont stockées dans cet emplacement, le worker le vérifierun_queue avantde vérifier , avec pour effet « la dernière tâche planifiée s'exécute en premier » (LIFO). C'est pour améliorer la localité, particulièrement efficace pour les modèles de passage de messages, et permet de réduire la latence.

Pourquoi le LIFO réduit-il la latence ? Considérons un scénario typique de passage de messages : la tâche A, après avoir traité un message, réveille la tâche B, et B après traitement réveille A. Si B s'exécute immédiatement après que A l'a réveillée, les données dont B a besoin sont probablement encore dans le cache CPU (car A vient d'y accéder). Si B est placée en queue de file, le temps que des dizaines de tâches devant elle s'exécutent, le cache aura depuis longtemps été écrasé.

Mais le LIFO présente un risque de famine. Le code source utiliseMAX_LIFO_POLLS_PER_TICK = 3pour limiter📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:263-263: à chaque tick, l'emplacement LIFO est priorisé au maximum 3 fois, au-delà il est désactivé, donnant aux autres tâches une chance de s'exécuter.

Parcours de la boucle principale : un cycle de planification complet

Plaçons-nous dans un scénario concret : le worker 0 vient de se réveiller depuispark,run_queuecontient 5 tâches,lifo_slotcontient 1 tâche, la file globale contient 3 tâches.

Le point d'entrée de la boucle principale estContext::run 📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:570-642. Il réinitialise d'abordlifo_enabled(car le core a pu êtreblock_in_placevolé, l'état doit être remis en place)📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:571-573, puis entre dans la bouclewhile !core.is_shutdown.

Chaque tour de boucle fait quatre choses :

Première étape : tick et maintenance. core.tick()incrémente le compteur📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:587. Ensuiteself.maintenance(core)vérifietick % event_interval == 0, et si c'est le cas appellepark_yieldpour piloter les E/S et les timers avec un timeout de 0📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:809-826。

Deuxième étape : récupération de tâche. core.next_task(&self.worker)est la logique centrale de récupération de tâche📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1090-1156. Elle se divise en deux chemins :

  • Lorsquetick % global_queue_interval == 0,prioritairementon prend depuis la file globale, et si on n'en trouve pas on prend depuis la file locale📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1091-1098. C'est pour éviter que les tâches de la file globale ne meurent de faim.
  • Sinonprioritairementon prend la tâche locale📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1090-1156。

La récupération locale de tâche est effectuée parnext_local_task📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1158-1160:

rust
fn next_local_task(&mut self) -> Option<Notified> {
    self.lifo_slot.take().or_else(|| self.run_queue.pop())
}

on prend d'abord l'emplacement LIFO, puis la tête de file (pop LIFO). C'est ce que le chapitre précédent appelait « LIFO local ».

Si le local est vide mais la file globale non vide, le workerpar lotsretire des tâches de la file globale📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1110-1154. La taille du lotnest calculée avec soin :min(inject.len() / remotes.len() + 1, cap), oùcapprend à son tourmin(remaining_slots, max_capacity / 2). Les commentaires du code source expliquent pourquoi on limite à la moitié de la capacité de la file📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1120-1131: garantir que les tâches retirées tombent dans lapremière moitiéde la file locale, de sorte que même en cas de débordement ultérieur, ces tâches ne soient pas repoussées vers la file globale (le débordement n'affecte que la seconde moitié).

Troisième étape : exécution de la tâche.une fois la tâche obtenue, appellerun_task 📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:647-796. C'est la fonction la plus complexe de ce chapitre, que nous détaillerons dans la section suivante.

Quatrième étape : vol ou park.Sinext_taskretourneNone, cela signifie qu'il n'y a plus rien à faire ni en local ni en global, on appellesteal_work 📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1167-1195. Si le vol échoue, on entre dansparkoupark_yield 📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:613-621。

Le flux de contrôle complet est le suivant :

mermaid
flowchart TD
    start["Context::run 进入循环"] --> tick["core.tick() 自增"]
    tick --> maint{"tick % event_interval == 0?"}
    maint -->|是| park_yield["park_yield 驱动 I/O 与定时器"]
    maint -->|否| next
    park_yield --> next["core.next_task()"]
    next --> has_task{"取到任务?"}
    has_task -->|是| run_task["run_task 执行 poll"]
    run_task --> cont{"core 还在?"}
    cont -->|是| tick
    cont -->|否| ret["return 退出"]
    has_task -->|否| steal["core.steal_work()"]
    steal --> steal_ok{"窃取成功?"}
    steal_ok -->|是| run_task
    steal_ok -->|否| defer_check{"defer 非空?"}
    defer_check -->|是| py["park_yield"]
    defer_check -->|否| pk["park 阻塞等待"]
    py --> tick
    pk --> tick

run_task : boucle fermée entre poll et emplacement LIFO

run_taskest l'endroit où la tâche est réellementpoll, et aussi le point de convergence de la boucle fermée « réveil → mise en file → re-poll ».

La première chose faite en entrant dans la fonction estassert_owner 📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:648, convertirNotifiedenTask, tout en affirmant que le thread courant est bien le owner de cette tâche (assertion debug).

Ensuitetransition_from_searching 📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:652— si le worker était précédemment en état de recherche, maintenant qu'il a trouvé une tâche, il doit sortir de l'état de recherche, et peut éventuellement réveiller d'autres workers parked.

Puis l'enveloppement clé du budget📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:695-795:

rust
coop::budget(|| {
    task.run();
    let mut lifo_polls = 0;
    loop {
        let mut core = match self.core.borrow_mut().take() {
            Some(core) => core,
            None => return ControlFlow::Break(()),
        };
        let task = match core.lifo_slot.take() {
            Some(task) => task,
            None => {
                self.reset_lifo_enabled(&mut core);
                core.stats.end_poll();
                return ControlFlow::Continue(core);
            }
        };
        if !coop::has_budget_remaining() {
            core.run_queue.push_back_or_overflow(task, ...);
            return ControlFlow::Continue(core);
        }
        lifo_polls += 1;
        if lifo_polls >= MAX_LIFO_POLLS_PER_TICK {
            core.lifo_enabled = false;
        }
        let task = self.worker.handle.shared.owned.assert_owner(task);
        *self.core.borrow_mut() = Some(core);
        task.run();
    }
})

Ce code révèle la boucle fermée complète de l'emplacement LIFO :task.run()exécuteFuture::poll, si pendant le poll la tâche se réveille elle-même ou réveille une autre tâche,schedule_localplacera la nouvelle tâche danslifo_slot 📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1396-1408. Après le retour du poll, la boucle vérifie immédiatementlifo_slot, et s'il y a une tâche elle continue de s'exécuter —sans revenir à la boucle principale, en enchaînant directement les poll dans le même budget.

C'est la manifestation du « réveil → mise en file → re-poll » sur le chemin LIFO : au réveil la tâche est placée danslifo_slot, et après le retour du poll elle est immédiatement retirée et re-poll, formant une boucle fermée étroite.

Notez la brancheself.core.borrow_mut().take()deNone📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:716-724: si le core a été volé (par exemple si la tâche a appeléblock_in_place), le worker doit retournerControlFlow::Break(()), laissantContext::runsortir. C'estblock_in_placePoints d'interaction avec la boucle d'ordonnancement.

Chemin de réveil : comment le Waker déclenche la remise en file

LorsqueFuture::pollretournePending, la tâche doit enregistrer unWaker, et être réveillée lorsque l'événement est prêt. L'implémentationWakerde Tokio est extrêmement concise — c'est simplement un pointeur brut vers laHeaderde la tâche plus une vtable.

waker_refConstruireWakerRef 📎 tokio/src/runtime/task/waker.rs:11-34, envelopper avecManuallyDroppourWakeréviter de décrémenter le compteur de références lors du drop. La vtable est un📎 tokio/src/runtime/task/waker.rs:119-119:

rust
static WAKER_VTABLE: RawWakerVTable =
    RawWakerVTable::new(clone_waker, wake_by_val, wake_by_ref, drop_waker);

Les quatre fonctions se contentent de restaurer le pointeur brut enHeader, puis d'appeler la méthode correspondante deRawTask📎 tokio/src/runtime/task/waker.rs:70-116. Par exemplewake_by_refappelle finalementraw.wake_by_ref() 📎 tokio/src/runtime/task/waker.rs:106-116。

wake_by_refLa sémantique est : faire passer l'état de la tâche dePENDINGàSCHEDULED, et si la transition réussit (c'est-à-dire qu'elle était bien PENDING auparavant), appelerSchedule::schedulepour remettre la tâche en file.

Pour l'ordonnanceur multi-thread,schedulel'implémentation deHandle::schedule_task 📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1353-1376:

rust
pub(super) fn schedule_task(&self, task: Notified, is_yield: bool) {
    with_current(|maybe_cx| {
        if let Some(cx) = maybe_cx {
            if self.ptr_eq(&cx.worker.handle) {
                if let Some(core) = cx.core.borrow_mut().as_mut() {
                    self.schedule_local(core, task, is_yield);
                    return;
                }
            }
        }
        self.push_remote_task(task);
        self.notify_parked_remote();
    });
}

La logique se divise en deux branches :

  • Si le thread courant est un worker de cet ordonnanceur et détient le core, on passe parschedule_local 📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1385-1417— placement dans le slot LIFO ou la file locale.
  • Sinon (réveil depuis un thread externe, ou core volé), on passe parpush_remote_taskpousser dans la file d'injection globale, etnotify_parked_remoteréveiller un worker parked📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1379-1383。

schedule_localEn interne, cela se divise à nouveau en deux branches📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1385-1417: si c'estyieldou que le LIFO est désactivé, pousser enrun_queuequeue ; sinon placer danslifo_slot, et pousser la tâche précédemment dans le slot vers la queue de la file.

mermaid
sequenceDiagram
    participant Future as "Future::poll"
    participant Waker as "Waker(wake_by_ref)"
    participant RawTask as "RawTask::wake_by_ref"
    participant Handle as "Handle::schedule_task"
    participant Core as "Core(schedule_local)"
    participant Inject as "InjectQueue"
    participant Parker as "Unparker"

    Future->>Waker: "返回 Pending, 注册 waker"
    Note over Future: "事件就绪(如 epoll)"
    Waker->>RawTask: "raw.wake_by_ref()"
    RawTask->>RawTask: "state: PENDING -> SCHEDULED"
    RawTask->>Handle: "schedule(Notified)"
    alt 当前线程是同一 worker 且持有 core
        Handle->>Core: "schedule_local: 放入 lifo_slot"
    else 外部线程或 core 被偷走
        Handle->>Inject: "push_remote_task"
        Handle->>Parker: "notify_parked_remote().unpark()"
    end

park et unpark : atomicité de la machine d'état et du réveil

Le worker doit park lorsqu'il n'a rien à faire, mais park/unpark est l'endroit le plus propice aux races. Tokio utilise une machine d'étatAtomicUsizeplusCondvarcomme filet de sécurité pour résoudre cela.

InnerLes champs📎 tokio/src/runtime/scheduler/multi_thread/park.rs:31-43:state: AtomicUsize、mutex: Mutex<()>、condvar: Condvar、shared: Arc<Shared>de📎 tokio/src/runtime/scheduler/multi_thread/park.rs:36-45:

  • EMPTY = 0. Il y a quatre constantes d'état
  • PARKED_CONDVAR = 1: non parké.
  • PARKED_DRIVER = 2: parké sur le condvar.
  • NOTIFIED = 3: parké sur le driver I/O.

: déjà réveillé.stateDiagram-v2C'est une machine d'état explicite, que nous utilisons pour dessiner le diagramme d'état (c'est le seul endroit de ce chapitre qui satisfait aux critères d'admission de

mermaid
stateDiagram-v2
    [*] --> Empty
    Empty --> ParkedCondvar : "park_condvar() CAS(EMPTY->PARKED_CONDVAR)"
    Empty --> ParkedDriver : "park_driver() CAS(EMPTY->PARKED_DRIVER)"
    Empty --> Notified : "unpark() swap(NOTIFIED)"
    ParkedCondvar --> Empty : "condvar 唤醒后 CAS(NOTIFIED->EMPTY)"
    ParkedCondvar --> Empty : "超时 swap(EMPTY)"
    ParkedDriver --> Empty : "driver 返回后 swap(EMPTY)"
    Notified --> Empty : "park() CAS(NOTIFIED->EMPTY) 消费通知"
    Notified --> Notified : "再次 unpark() swap(NOTIFIED)"

unparkCopie📎 tokio/src/runtime/scheduler/multi_thread/park.rs:277-290L'implémentation deswaputilise📎 tokio/src/runtime/scheduler/multi_thread/park.rs:277-290plutôt que CAS, le commentaire du code source explique pourquoiNOTIFIED: il faut effectuer une opération release pour que le thread park observe les écritures précédant unpark, donc même si state est déjà

parkil faut écrire une fois.📎 tokio/src/runtime/scheduler/multi_thread/park.rs:132-149On tente d'abord de consommer une notification existanteNOTIFIED -> EMPTY: si le CAS📎 tokio/src/runtime/scheduler/multi_thread/park.rs:143-148。

park_condvarréussit, cela signifie qu'on a déjà été réveillé, on retourne directement sans bloquer. Sinon on tente de prendre le verrou du driver, si on l'obtient on park sur le driver, sinon on utilise le condvar comme filet de sécurité📎 tokio/src/runtime/scheduler/multi_thread/park.rs:162-180Il y a un double contrôle classique dansEMPTY -> PARKED_CONDVAR: d'abord CASNOTIFIED, si cela échoue et que c'estswap(EMPTY), cela signifie qu'on a été réveillé avant de définir l'état, il faut alors📎 tokio/src/runtime/scheduler/multi_thread/park.rs:167-177pour synchroniser l'écriture de unparkNOTIFIED. Le commentaire souligne particulièrement : même si l'on sait que c'estNOTIFIEDil faut quand même lire une fois, car unpark peut avoir été appelé une fois de plus après notre lecture de

unpark_condvar.📎 tokio/src/runtime/scheduler/multi_thread/park.rs:292-307Le commentairePARKEDdewaitmet en évidence le piège classique du condvar : il y a une fenêtre entre le moment où le thread parked définit l'étatmutexet le moment où ildrop(self.mutex.lock())réellement, et si un notify survient pendant cette période il sera ignoré. La solution est que le thread park détient alorsnotify_one。

, et le thread unpark doit d'abord

acquérir le verrou (attendant ainsi que le thread park le libère), puis

Réflexion de conception : pourquoi le slot LIFO est un slot unique plutôt qu'une file

MAX_LIFO_POLLS_PER_TICK = 3〔Inférence de conception et compromis architecturaux〕📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:263-263La conception à slot unique est un compromis délibéré. Si l'on utilisait une file, chaque réveil nécessiterait une mise en file et chaque prise de tâche une sortie de file, ce qui coûterait plus cher ; de plus la file accumulerait plusieurs tâches, brisant l'hypothèse de localité « le plus récemment réveillé s'exécute en premier ». La sémantique du slot unique est « ne se souvenir que du plus récent », les tâches évincées allant dans la file normale — ce qui correspond exactement à la loi des rendements décroissants de la localité : la tâche la plus récente est la plus chaude, la deuxième moins, et au-delà de la troisième le gain devient très faible.

Ce nombre magiquesteal_workest aussi une valeur empirique. Le commentaire du code source dit que « quelques passages dans le slot LIFO semblent suffire à bénéficier de la localité, au-delà de 3 cela pourrait surpondérer ». Cela empêche le scénario ping-pong où A réveille B et B réveille A d'affamer les autres tâches.📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1158-1160Une autre conception notable est la stratégie de « recherche par moitié » detransition_to_searching: ce n'est que lorsque moins de la moitié des workers sont en recherche qu'un nouveau worker tente réellement de voler. Cela évite la contention CAS causée par tous les workers volant frénétiquement en même temps.idle.transition_worker_to_searching()On coordonne via📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1197-1203。

Le vol commence à partir d'un point de départ aléatoire📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1172-1174, parcourt tous les remote, saute soi-même📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1179-1182, appellesteal_intopour tenter de voler. Après échec de tout, on retombe sur la file globale📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1197-1203。

Résumé de ce chapitre

La boucle principale du workerContext::runest le cœur de l'ordonnanceur : après chaque tick on prend d'abord une tâche (slot LIFO → file locale → file globale), si on en prend une onrun_taskexécute le poll, sinon on vole, et si le vol échoue on park.run_taskLa boucle LIFO interne àWakercompresse « réveil → mise en file → re-poll » dans le même budget, formant une boucle fermée à faible latence.wake_by_refest un pointeur brut plus une vtable statique,scheduledéclenchepark/unparkvia une transition d'état, et selon que le thread courant est le même worker ou non, décide d'aller vers la file locale ou la file globale.

utilise une machine atomique à quatre états plus un condvar comme filet de sécurité, résolvant la race classique de perte de réveil.WakerDans le prochain chapitre nous quitterons l'ordonnanceur pour entrer dans le monde de l'I/O : comment le Reactor traduit les événements epoll enAsyncFdréveils, faisant dePendingleReady。

de

Réflexions et auto-évaluation de ce chapitrenext_local_taskQ1 : Si l'on modifiaitrun_queueEnsuite, prendrelifo_slot, quelles seraient les conséquences dans un scénario à forte intensité de passage de messages ?

Analyse de référence:next_local_taskL'implémentation actuelle estself.lifo_slot.take().or_else(|| self.run_queue.pop()) 📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1158-1160, on prend d'abord l'emplacement LIFO. Si à l'inverse on prenait d'abordrun_queue, alors les tâches qui viennent d'être réveillées, dont les données sont encore chaudes, seraient exécutées après les autres tâches de la file. Dans un modèle de passage de messages A→B→A, B ne s'exécute pas immédiatement après son réveil, mais attend que les autres tâches de la file terminent ; à ce moment, les données écrites par A peuvent avoir été évincées du cache CPU, et le bénéfice de localité est perdu. Plus grave encore,lifo_slotles tâches dansrun_queueattendront que📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:117-121soit vidé pour être exécutées, ce qui augmente significativement la latence. Le commentaire du code source

Q2: park_condvarindique explicitement que cet ordre vise à « améliorer la localité, bénéficier du modèle de passage de messages et réduire la latence ».Err(NOTIFIED)Dansself.state.swap(EMPTY, SeqCst), si on supprimereturndans la branche

, en ne gardant que, quel serait le problème ?Err(NOTIFIED)Analyse de référencelet old = self.state.swap(EMPTY, SeqCst) 📎 tokio/src/runtime/scheduler/multi_thread/park.rs:167-177: le code source exécute📎 tokio/src/runtime/scheduler/multi_thread/park.rs:168-173dans la brancheNOTIFIED. Le commentaire expliquereturn: unpark peut avoir été appelé une nouvelle fois après que nous ayons luNOTIFIED, il faut exécuter une opération acquire pour se synchroniser avec cet unpark, afin d'observer toutes les écritures qui l'ont précédé. Si on ne fait queNOTIFIED -> EMPTYsans swap, state resterait à

Q3: run_task, et au prochain park, le CASself.core.borrow_mut().take()réussirait et retournerait immédiatement (consommant une notification déjà expirée), mais plus grave, l'écriture release de unpark ne serait pas synchronisée, et le thread park pourrait ne pas voir les données écrites avant unpark, entraînant un problème de visibilité mémoire. C'est un double bug typique de « réveil perdu + ordre mémoire ».NoneDansControlFlow::Break(()), quandContinue?

retourne:self.core.borrow_mut().take(), pourquoi retournerNoneau lieu de📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:716-724Analyse de référenceblock_in_placeretournermaybe_move_runtimesignifie que le core a déjà été volécx.core. La seule façon pour le core d'être volé est qu'une tâche ait appelé en interne📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:473-497, qui viaContinue,Context::runretire le core decore.next_task()et le confie à un nouveau threadself.core. À ce moment, le thread courant ne détient plus la capacité de planification ; s'il retournaitBreak, il continuerait la boucle et appelleraitContext::runet d'autres méthodes nécessitant le core, mais le core n'est plus dansreturn 📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:594-597, ce qui provoquerait un panic ou une incohérence d'état. Retournerrunpermet àcx.defer.wake() 📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:564de directement📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:719-721, rendant le contrôle à la fonctionreset_lifo_enabled, qui gère la suite (par exempleContext::run). Le commentaire précise aussi

CHAPTER 05

Retour en haut ↑

Chapitre suivant : Chapitre 5 → · Chapitre 5 : Notification de disponibilité I/O : comment le Reactor traduit les événements epoll en réveils Waker · Projet : tokio-rs/tokio

Progression du livre : Chapitre 5 / 14

Statut de vérification : lignes FACT réellement ancréesDriverDans le chapitre précédent, nous avons suivi la boucle principale du thread worker : la tâche est poll, et lorsqu'elle retourne Pending, le Waker est stocké quelque part ; une fois l'événement prêt, le Waker est déclenché et la tâche est remise en file. Mais où est ce « quelque part » ? Comment le Waker est-il retrouvé lorsque l'événement epoll arrive ? C'est précisément la question à laquelle le Reactor répond. Établissons d'abord un modèle intuitif : imaginons tout le mécanisme de notification de disponibilité I/O comme le système d'appel des commandes dans un restaurant — le client (la tâche) ne reste pas debout à attendre devant le comptoir après avoir commandé, mais prend un buzzer (Waker) et retourne à sa place ; une fois le plat prêt en cuisine (epoll du noyau), l'accueil (le Reactor) retrouve le buzzer correspondant grâce au numéro de commande (Token) et appuie sur le bouton. Sans ce système, chaque tâche ne pourrait que poller le socket, brûlant le CPU ; ou bien utiliser un thread bloquant en attente, un thread par connexion, ce qui ne passe pas à l'échelle. Le Reactor de Tokio est constitué de trois fichiers formant une structure à trois couches, avec une séparation stricte des responsabilités : driver.rs est le corps de la boucle d'événements, détient mio::Poll, est responsable d'appeler poll() en attente bloquante des événements du noyau, et traduit les événements en lectures/écritures sur ScheduledIo ; registration.rs est le handle d'enregistrement orienté utilisateur, celui que TcpStream détient en interne, offrant des API comme poll_read_ready / poll_write_ready ; scheduled_io.rs est l'emplacement d'état de chaque fd, stockant les bits de disponibilité en lecture/écriture et la liste des Wakers, c'est le pont entre les événements et les tâches. La relation d'assemblage des modules est visible dans tokio/src/runtime/io/mod.rs:5-16 : driver exporte Driver, Handle, ReadyEvent, registration exporte Registration, scheduled_io exporte ScheduledIo. Le schéma ci-dessous ancre le flux de données complet à suivre dans ce chapitre : TcpStream → Registration → ScheduledIo → Handle/Driver → noyau → retour à ScheduledIo → Waker. Décomposons maintenant couche par couche.HandleCouche driver :

et

Driverrépartition des responsabilitésModèle intuitifmio::Pollestla seule entité possédant&mut, elle ne peut être accédée que dans un seul threadHandle— c'est l'exigence d'exclusivité de la boucle d'événements. Tandis queest, tout thread souhaitant enregistrer un nouveau fd passe par lui. Sans cette séparation, il faudrait soit ajouter un verrou àmio::Poll(chaque enregistrement entre en compétition), soit faire revenir tous les enregistrements vers le thread driver (ce qui introduirait une file de messages inter-threads). Tokio choisit de laisserHandledétenir directement le clone demio::Registry, les opérations d'enregistrement peuvent se faire en concurrence, et seule l'attente réelle d'événements nécessite l'exclusivité.

Disposition mémoire et champs

Regardons d'abordDriverles champs de📎 tokio/src/runtime/io/driver.rs:25-38:

  • signal_ready: bool: si un événement de signal Unix est arrivé, utilisé pour le pilotage par signal.
  • events: mio::Events: tampon d'événements principal, réutilisé à travers les appelsturn, pour éviter une allocation à chaque fois.
  • events_busy: Option<mio::Events>:Tampon dédié au poll non bloquant, présent uniquement lorsquemax_io_events_per_busy_tickest défini.
  • poll: mio::Poll: encapsulation de la file d'événements du noyau.

Regardons ensuiteHandle 📎 tokio/src/runtime/io/driver.rs:41-75:

  • registry: mio::Registry:mio::Poll::registry()le clone deregister/deregister。
  • registrations: RegistrationSet, utilisé pourToken: l'ensemble de tous les enregistrements actifs, responsable de l'allocation deScheduledIo。
  • synced: Mutex<registration_set::Synced>etRegistrationSet: protège l'état de synchronisation de
  • waker: mio::Waker: utilisé pour réveiller depuis n'importe quel thread le driver bloqué dansturn.
  • metrics: IoDriverMetrics: compte le nombre de fd, le nombre d'événements prêts.

Il y a ici une conception clé :events_busyl'existence de📎 tokio/src/runtime/io/driver.rs:25-38vise à résoudrele problème où le poll non bloquant avale les événements. Le commentaire📎 tokio/src/runtime/io/driver.rs:189-190le dit clairement : si les événements retirés par le poll non bloquant restent dans le tampon principal, le prochain poll ne les verra plus ; avec un tampon séparé, les événements non traités restent dans la file du noyau et seront renvoyés au prochain poll.

Étape par étape : une exécution deturn

turnest la fonction centrale du driver📎 tokio/src/runtime/io/driver.rs:184-261. Supposons qu'un thread worker constate qu'il n'y a aucune tâche à exécuter et appellepark → turn(handle, None)pour attendre en bloquant :

Première étape: affirmer que shutdown n'est pas📎 tokio/src/runtime/io/driver.rs:185, et libérer les enregistrements en attente de nettoyage📎 tokio/src/runtime/io/driver.rs:187。release_pending_registrationsvérifierneeds_release(), et si présent appelerregistrations.release() 📎 tokio/src/runtime/io/driver.rs:336-340。

Deuxième étape: choisir le tampon d'événements📎 tokio/src/runtime/io/driver.rs:191-194. Simax_waitest zéro et queevents_busyexiste, utiliser le tampon busy ; sinon utiliser le tampon principal.

Troisième étape: appelerself.poll.poll(events, max_wait) 📎 tokio/src/runtime/io/driver.rs:198. C'est ici que l'on bloque réellement dans epoll_wait. La gestion des erreurs est très mesurée :Interruptedest directement ignoré (une interruption par signal est normale)📎 tokio/src/runtime/io/driver.rs:200, sous WASIInvalidInputest également ignoré📎 tokio/src/runtime/io/driver.rs:201-205, les autres erreurs provoquent directement un panic📎 tokio/src/runtime/io/driver.rs:206。

Quatrième étape: parcourir les événements📎 tokio/src/runtime/io/driver.rs:211-233. Pour chaqueevent:

  • sitoken == TOKEN_WAKEUP(valeur 0)📎 tokio/src/runtime/io/driver.rs:214, ne rien faire — c'estunparkqui est utilisé pour interrompre le blocage.
  • Sitoken == TOKEN_SIGNAL(valeur 1)📎 tokio/src/runtime/io/driver.rs:216, définirsignal_ready = true。
  • sinon c'est un événement d'E/S ordinaire📎 tokio/src/runtime/io/driver.rs:218-231: convertirmio::ReadyenReadyde Tokio, utiliserEXPOSE_IO.from_exposed_addr(token.0)pour restaurer le token en pointeur*const ScheduledIo, puisset_readiness(Tick::Set, |curr| curr | ready)accumuler les bits de disponibilité, puisio.wake(ready)déclencher laWaker。

dans la direction correspondanteEXPOSE_IOIciPtrExposeDomain<ScheduledIo> 📎 tokio/src/runtime/io/mod.rs:21-22est unusize, il « expose » le pointeur comme unmio::Tokenen tant que📎 tokio/src/runtime/io/driver.rs:222-225. Le commentaire de sûretéexplique pourquoi cette conversion unsafe est sûre : le pointeur ne sera pas libéré avant d'être désenregistré de mioetArc<ScheduledIo>que le driver ne fasse plus de poll concurrent, et le driver détient la propriété de

.Cinquième étape📎 tokio/src/runtime/io/driver.rs:235-258: traiter la file de complétion io_uring (Linux + tokio_unstable uniquement)

, y compris la boucle de flush en cas de débordement de la CQ.Sixième étape📎 tokio/src/runtime/io/driver.rs:265-267。

mermaid
flowchart TD
    start["turn(handle, max_wait)"] --> assert["debug_assert!(!is_shutdown)"]
    assert --> release["release_pending_registrations()"]
    release --> pick{"max_wait == 0<br/>且 events_busy 存在?"}
    pick -->|是| busy["events = events_busy"]
    pick -->|否| main["events = events"]
    busy --> poll["poll.poll(events, max_wait)"]
    main --> poll
    poll --> pollres{"poll 返回?"}
    pollres -->|"Ok / Interrupted"| iter["遍历 events.iter()"]
    pollres -->|"其他 Err"| panic["panic!(unexpected error)"]
    iter --> tok{"event.token()?"}
    tok -->|"TOKEN_WAKEUP"| skip["忽略,仅用于打断阻塞"]
    tok -->|"TOKEN_SIGNAL"| sig["signal_ready = true"]
    tok -->|"普通 fd token"| cast["EXPOSE_IO.from_exposed_addr(token.0)"]
    cast --> setr["io.set_readiness(Tick::Set, curr | ready)"]
    setr --> wake["io.wake(ready)"]
    wake --> iter
    skip --> iter
    sig --> iter
    iter --> uring["dispatch_completions() (io-uring)"]
    uring --> metrics["metrics.incr_ready_count_by(ready_count)"]

copierHandleRéflexion de conception : pourquoimio::Waker

unpark 📎 tokio/src/runtime/io/driver.rs:280-283doit-il détenirself.waker.wake()appelermio::Waker. CeDriver::newlors deTOKEN_WAKEUPutilise📎 tokio/src/runtime/io/driver.rs:124pour enregistrerpoll.poll(). Lorsque le driver est bloqué dansunpark, un autre thread appelantTOKEN_WAKEUPinsérera un événementpolldans epoll,📎 tokio/src/runtime/io/driver.rs:214-215。

retourne immédiatement, et lors du parcours, ce token est directement ignoré

〔Inférence de conception et compromis architecturaux〕deregister_sourceCe mécanisme est utilisé dans📎 tokio/src/runtime/io/driver.rs:315-334: après avoir désenregistré une source, siregistrations.deregisterretourne true (indiquant qu'il s'agit de la dernière référence), alorsunpark(). Pourquoi ? Parce que le driver peut être bloqué danspollen attente d'un événement pour ce fd, alors que le fd a déjà été désenregistré et que le noyau ne produira plus d'événement ; il faut réveiller activement le driver pour qu'il réexamine l'ensemble des enregistrements et puisse sortir du blocage. Sinon, le driver dormirait jusqu'au timeout demax_wait, retardant le shutdown.

Autre détail :deregister_sourceappelle d'abordself.registry.deregister(source) 📎 tokio/src/runtime/io/driver.rs:322, puis nettoieregistrations 📎 tokio/src/runtime/io/driver.rs:315-334. Le commentaire📎 tokio/src/runtime/io/driver.rs:320-321dit « Cleanup ALWAYS happens » — même si le deregister au niveau de l'OS échoue, il faut nettoyer l'état interne, et seulement ensuite retourner l'erreur de l'OS📎 tokio/src/runtime/io/driver.rs:336-340. C'est un modèle typique oùle nettoyage des ressources prime sur la propagation des erreurs.

Couche d'enregistrement :Registrationcomment stockerWakerdansScheduledIo

Modèle intuitif

Registrationestun contrat entre la tâche et le fd. Il détient deux choses : unscheduler::Handle(utilisé pour accéder au runtime si nécessaire), unArc<ScheduledIo>(le slot d'état du fd). Lorsqu'une tâche appellepoll_read_ready,RegistrationconfieWakeràScheduledIopour qu'il le garde ; lorsque le driver reçoit un événement, il extraitScheduledIodeWakerpour le réveiller.

Disposition mémoire et champs

Registrationn'a que deux champs📎 tokio/src/runtime/io/registration.rs:46-54:

  • handle: scheduler::Handle: le handle runtime, le commentaire📎 tokio/src/runtime/io/registration.rs:46-54dit « TODO: this can probably be moved into ScheduledIo », indiquant que l'auteur pense que la position de ce champ peut être optimisée.
  • shared: Arc<ScheduledIo>: état partagé,Arcgarantit que le driver et la tâche peuvent tous deux y accéder.
〔Inférence de conception et compromis architecturaux〕

Notons queRegistrationimplémente manuellementSendetSync 📎 tokio/src/runtime/io/registration.rs:57-58. Pourquoi unsafe impl est-il nécessaire ? Parce quescheduler::Handlepeut contenir en interne des champs nonSend/Sync(commeRc), mais le scénario d'utilisation deRegistrationexige qu'il puisse traverser les threads. Le commentaire de documentation📎 tokio/src/runtime/io/registration.rs:28-33donne la contrainte clé :L'appelant doit garantir qu'au plus deux tâches utilisent concurremment le mêmeRegistration, une en lecture, une en écriture. Violer cette contrainte reste sûr pour la mémoire, mais entraîne une perte de notifications et la suspension des tâches.

Step-by-Step:poll_read_readyla chaîne d'appels de

Supposons que la tâche, dansTcpStream::poll_readdécouvre que le socket n'a pas de données, il faut enregistrer un intérêt de lecture. La chaîne d'appels estTcpStream::poll_read_priv → PollEvented::poll_read → Registration::poll_read_io → poll_io → poll_ready。

poll_readyest le cœur📎 tokio/src/runtime/io/registration.rs:155-171:

Première étape:trace_leaf() 📎 tokio/src/runtime/io/registration.rs:160, utilisé pour l'instrumentation tracing.

Deuxième étape:coop::poll_proceed(cx) 📎 tokio/src/runtime/io/registration.rs:155-171. C'est le mécanisme de budget coopératif qui sera abordé au chapitre 12. Si le budget est épuisé, retournePendinget enregistre unWakerspécial, pour que la tâche soit replanifiée au tour suivant.

Troisième étape:self.shared.poll_readiness(cx, direction) 📎 tokio/src/runtime/io/registration.rs:155-171. C'est ici que se fait la véritable interaction avecScheduledIo: vérifie le bit de disponibilité actuel, s'il est déjà prêt retourne immédiatementReady; sinon stockecx.waker()dansScheduledIole slot directionnel correspondant dePending。

, retourneQuatrième étapeev.is_shutdown 📎 tokio/src/runtime/io/registration.rs:155-171: vérifieRUNTIME_SHUTTING_DOWN_ERROR。

. Si le runtime est en cours d'arrêt, retourne:coop.made_progress() 📎 tokio/src/runtime/io/registration.rs:169Cinquième étape

poll_io, marque la consommation du budget, retourne l'événement de disponibilité.poll_readyajoute une boucle de retry au-dessus de📎 tokio/src/runtime/io/registration.rs:173-192:

rust
loop {
    let ev = ready!(self.poll_ready(cx, direction))?;
    match f() {
        Ok(ret) => return Poll::Ready(Ok(ret)),
        Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => {
            self.clear_readiness(ev);
        }
        Err(e) => return Poll::Ready(Err(e)),
    }
}

Ceci illustrereadiness est un indice, pas une garantiel'idée centrale depoll_readydit lisible, mais lors du véritableread()il peut retournerWouldBlock(par exemple un autre thread a lu les données en premier). Il faut alorsclear_readiness(ev) 📎 tokio/src/runtime/io/registration.rs:187effacer le bit de disponibilité, puis boucler pour attendre à nouveau. Si on ne l'efface pas, la tâche tombera dans une boucle active « je crois pouvoir lire → read échoue → je crois encore pouvoir lire ».

Réflexion de conception :try_ioetasync_iola répartition des rôles

try_io 📎 tokio/src/runtime/io/registration.rs:194-213est la version synchrone : d'abordready_event(interest)vérifie le bit de disponibilité, s'il est vide retourne directementWouldBlock 📎 tokio/src/runtime/io/registration.rs:194-213; sinon exécutef(), sif()retourneWouldBlockalors efface le bit de disponibilité📎 tokio/src/runtime/io/registration.rs:207-210. Iln'enregistre pas de Waker, adapté aux scénarios de typetry_read« essayer une fois et partir ».

async_io 📎 tokio/src/runtime/io/registration.rs:225-245est la version asynchrone :readiness(interest).awaitenregistre un Waker et attend, puis exécutef(),WouldBlocken effaçant le bit de disponibilité et en bouclant. Notez qu'il appelle aussicoop::poll_proceed 📎 tokio/src/runtime/io/registration.rs:233dans la boucle, pour éviter d'épuiser le budget lors de nombreuxWouldBlockretries.

Pièges en production :Drople nettoyage du Waker dans

Registration::drop 📎 tokio/src/runtime/io/registration.rs:253-262appelleself.shared.clear_wakers(). Le commentaire📎 tokio/src/runtime/io/registration.rs:253-262explique la raison :ScheduledIoleWakerstocké dansArc<driver::Inner>peut détenirdriver::Inner, etScheduledIodétient à son tourRegistration, formant une référence circulaire. Nettoyer le Waker est un moyen de briser le cycle. Mais le commentaire admet aussi que c'est une « imperfect solution » — siWakerlui-même est stocké dans

, le cycle persiste. C'est le problème discuté dans tokio-rs/tokio#3481.

〔Inférence de conception et compromis architecturaux〕clear_wakersLe comportement en production est le suivant : si un grand nombre de connexions sont drop mais que le runtime ne s'arrête pas, la mémoire n'est pas immédiatement récupérée, jusqu'au prochainScheduledIoou au shutdown du runtime. Pour les services à connexions longues, ce n'est généralement pas un problème ; mais pour les scénarios à connexions courtes créées/détruites à haute fréquence, il faut surveiller le moment de récupération de

DeTcpStream::readàWakerla chaîne complète de réveil

Modèle intuitif

Maintenant relions les trois couches. L'utilisateur appelleTcpStreamsur.read().await, ce qui exécute en réalitéAsyncRead::poll_read → PollEvented::poll_read → Registration::poll_read_io. Quand les données ne sont pas arrivées,Wakerest stocké dansScheduledIo; quand epoll signale la lisibilité, le driver retireScheduledIodeWakeret réveille, la tâche est replanifiée, et lors du prochain pollpoll_readinessdécouvre que le bit de disponibilité est positionné, retourne directementReady,read()avec succès.

Step-by-Step : une attente de lecture complète

Phase un : enregistrement de l'intérêt。TcpStream::new 📎 tokio/src/net/tcp/stream.rs:166-169appellePollEvented::new(connected), qui appelle en interneRegistration::new_with_interest_and_handle 📎 tokio/src/runtime/io/registration.rs:73-81, puishandle.driver().io().add_source(io, interest) 📎 tokio/src/runtime/io/registration.rs:73-81。

add_source 📎 tokio/src/runtime/io/driver.rs:288-312fait trois choses :

1. registrations.allocate(&mut synced.lock())alloue unScheduledIo, obtienttoken 📎 tokio/src/runtime/io/driver.rs:293-294。

2. self.registry.register(source, token, interest.to_mio())enregistre📎 tokio/src/runtime/io/driver.rs:298auprès du noyau. En cas d'échec,il fautretirer leScheduledIoqui vient d'être alloué de l'ensemble📎 tokio/src/runtime/io/driver.rs:300-303, sinon fuite.

3. metrics.incr_fd_count()compte📎 tokio/src/runtime/io/driver.rs:309。

Phase deux : attente de disponibilité. La tâche pollTcpStream::poll_read 📎 tokio/src/net/tcp/stream.rs:1492-1498 → poll_read_priv 📎 tokio/src/net/tcp/stream.rs:1451-1458 → PollEvented::poll_read → Registration::poll_read_io 📎 tokio/src/runtime/io/registration.rs:133-139 → poll_io → poll_ready → ScheduledIo::poll_readiness. Si à ce moment ce n'est pas prêt,Wakerest stocké dansScheduledIole slot de lecture dePending。

, retournePhase trois : arrivée de l'événementturn. Lepoll.poll()du driver retire l'événement📎 tokio/src/runtime/io/driver.rs:198deio.set_readiness(Tick::Set, |curr| curr | ready), et lors du parcours exécute pour chaque événement fdio.wake(ready) 📎 tokio/src/runtime/io/driver.rs:228-229。wakeetWakerretire en interne lewake()。

de la direction correspondante et appelle。Waker::wake()Phase quatre : replanification de la tâchepoll_readinessremet la tâche en file dans la file locale du worker (vu au chapitre précédent). Le worker poll à nouveau cette tâche,Ready,read()découvre que le bit de disponibilité est positionné, retourne

mermaid
sequenceDiagram
    participant Task as "任务 (worker 线程)"
    participant Reg as "Registration"
    participant SIO as "ScheduledIo"
    participant Drv as "Driver (I/O 线程)"
    participant OS as "epoll/kqueue"

    Task->>Reg: "poll_read_ready(cx)"
    Reg->>SIO: "poll_readiness(cx, Read)"
    SIO-->>Reg: "Pending (Waker 已存入读槽位)"
    Reg-->>Task: "Poll::Pending"
    Note over Task: 任务让出,worker 去跑别的任务
    Drv->>OS: "poll.poll(events, max_wait)"
    OS-->>Drv: "event(token=fd_ptr, READABLE)"
    Drv->>SIO: "set_readiness(Tick::Set, curr | READABLE)"
    Drv->>SIO: "wake(READABLE)"
    SIO->>Task: "Waker::wake() 重新入队"
    Note over Task: worker 再次 poll 该任务
    Task->>Reg: "poll_read_ready(cx)"
    Reg->>SIO: "poll_readiness(cx, Read)"
    SIO-->>Reg: "Ready(ReadyEvent{ready: READABLE})"
    Reg-->>Task: "Poll::Ready(Ok(ev))"
    Task->>Task: "read() 成功返回数据"

copieassume_readyBranche importante :

TcpStream::new_accepted 📎 tokio/src/net/tcp/stream.rs:174-181optimisationacceptest une optimisation notable.new_acceptedLe socket retourné parassume_ready(Ready::READABLE | Ready::WRITABLE) 📎 tokio/src/net/tcp/stream.rs:174-181。

assume_readyest naturellement inscriptible, et détient généralement déjà le premier lot d'octets du pair. Si on attend le premier événement du driver, sous forte charge cet événement peut être placé après tous les événements des connexions déjà établies, causant de la latence. Donc📎 tokio/src/runtime/io/registration.rs:103-105appelle directementWouldBlockle commentaire deWouldBlock,poll_iodit : « A wrong guess costs one, which clears the readiness again. » — le coût d'une mauvaise supposition n'est qu'unela boucle de

effacera le bit de disponibilité et attendra à nouveau. C'est une conception

supposition optimiste + correction rapide

.DriverRéflexion de conception : pourquoi le driver I/O est découplé du schedulerDriver〔Inférence de conception et compromis architecturaux〕block_onD'après la structure du code source,Handleet les threads worker sont séparés :

1. est placé à un emplacement dédié du runtime (généralement le thread:Handleou un thread I/O dédié), tandis que les threads worker ne détiennent quemio::Registry. Ce découplage apporte plusieurs avantages :

2. Enregistrement sans verroudétient un clone deepoll_wait, n'importe quel worker peut enregistrer concurremment de nouveaux fd, sans revenir au thread driver.

3. Centralisation de l'attente d'événements: un seul thread bloque surScheduledIo, évitant le problème de thundering herd où plusieurs threads pollent simultanément le même fd epoll.Waker::wake(),wake()Chemin de réveil court

: après réception d'un événement, le driver manipule directementScheduledIoet appelleset_readinesspousse en interne la tâche dans la file du worker, sans passage de messages inter-threads.poll_readinessLe coût est que

doit gérer les accès concurrents (is_shutdownetRUNTIME_SHUTTING_DOWN_ERROR

poll_readypeuvent se produire simultanément), ce qui est résolu par des opérations atomiques et des verrous internes.ev.is_shutdown 📎 tokio/src/runtime/io/registration.rs:155-171Pièges en production :gone() 📎 tokio/src/runtime/io/registration.rs:265-267etRUNTIME_SHUTTING_DOWN_ERROR。

vérifie

, si vrai retourneshutdown 📎 tokio/src/runtime/io/driver.rs:174-182parcourt tous les enregistrements et appelleio.shutdown(), metis_shutdownà 1 et réveille tous les waiters. Si cet indicateur n'est pas vérifié, une tâche peut encore tenter de lire le socket après que le runtime a cessé d'ordonnancer, provoquant un comportement indéfini ou un blocage. En production, si vous voyezRUNTIME_SHUTTING_DOWN_ERROR, cela signifie généralement qu'une tâche s'exécute encore après le drop du runtime — vérifiez si des tâchesspawnn'ont pas été correctement join.

Un autre piège estderegister_sourcedeunpark 📎 tokio/src/runtime/io/driver.rs:328. Si le driver est bloqué danspoll, et que le dernierRegistrationest drop à ce moment,unparkréveillera le driver. Mais si le driver n'est pas en état de blocage (par exemple, il traite d'autres événements),unparkfait simplement que le prochainturnretourne immédiatement📎 tokio/src/runtime/io/driver.rs:280-283. Cette sémantique est documentée dans les commentaires deHandle::unpark.

Réflexion de conception : les trois compromis clés du Reactor

Compromis un :Tokenutilise des pointeurs plutôt que des indices。EXPOSE_IO.from_exposed_addr(token.0) 📎 tokio/src/runtime/io/driver.rs:220traitemio::Tokendirectement comme*const ScheduledIol'adresse deToken → ScheduledIo. Cela évite de maintenir une table de correspondance📎 tokio/src/runtime/io/driver.rs:222-225。

, la recherche est O(1) et sans verrou. Le coût est que la sécurité dépend d'une gestion stricte des durées de vie : le pointeur ne doit être libéré qu'après désenregistrement et après que le driver ne poll plus。RegistrationCompromis deux : deux slots Waker en lecture/écriture📎 tokio/src/runtime/io/registration.rs:24-26La documentationWakerdit « A registration instance represents two separate readiness streams » — lecture et écriture ont chacune unpoll_read_readyslot indépendant. Cela permet aux tâches de lecture et d'écriture du même socket de s'enregistrer séparément, sans interférence. Mais le commentaire📎 tokio/src/net/tcp/stream.rs:549-552depoll_read_ready/poll_read/poll_peekrappelle : des appels multiples àWakerne conservent que le dernier

— la direction de lecture n'a qu'un seul slot.events_busyCompromis trois :le tampon indépendant de📎 tokio/src/runtime/io/driver.rs:364-386. Le testDriver::new(16, Some(2))vérifie ce comportement :turncrée un driver avec une capacité busy de 2, enregistre 5 sources lisibles, puis le📎 tokio/src/runtime/io/driver.rs:375-376non bloquant ne prend que 2 événementsturn, les 3 restants demeurent dans la file du noyau, et le prochain📎 tokio/src/runtime/io/driver.rs:379-380bloquant récupère

. Cela empêche un poll non bloquant d'engloutir tous les événements d'un coup, ce qui affamerait les polls suivants.

Résumé de ce chapitreTcpStream::readCe chapitre a retracé la chaîne Reactor complète derrière

  • ::DriverCouche drivermio::Poll,turnmonopoliseEXPOSE_IOen attente bloquante d'événements, utiliseTokenpour restaurerScheduledIoenset_readiness + wakepointeur, appelleWaker。Handlepour déclencherunparkfournit un point d'entrée d'enregistrement inter-threads,
  • sert à interrompre le blocage.:RegistrationCouche enregistrementArc<ScheduledIo>,poll_readydétientWaker,poll_iovérifie les bits de disponibilité ou stocke dansWouldBlockutilisetry_io/async_ioune boucle de réessai pour gérer les faux positifs,
  • sert respectivement les scénarios synchrones et asynchrones.:ScheduledIoCouche étatWakerest le slot d'état du fd, stockant les bits de disponibilité lecture/écriture et les deux

slots, c'est le seul pont entre les événements et les tâches.

Réflexions et auto-évaluation de ce chapitrepoll_ioQ1 : Si l'on supprimeWouldBlockdans la brancheself.clear_readiness(ev)de

, dans quel scénario cela provoquerait-il une boucle active (busy-loop) de la tâche ? Pourquoi ?:poll_ioAnalyse de référence📎 tokio/src/runtime/io/registration.rs:173-192La bouclef()deWouldBlockappelleclear_readiness(ev) 📎 tokio/src/runtime/io/registration.rs:187。evlorsquepoll_readyretourneReadyEventest leclear_readinessretourné parScheduledIo, contenant les bits de disponibilité actuels.

efface ces bits depoll_ready → poll_readiness.ScheduledIoSi l'on ne nettoie pas, au prochain appel de la boucle àpoll_readiness,Readyconserve encore l'ancien bit « lisible »,f()retourne immédiatementread()(car les bits de disponibilité ne sont pas vides), puisWouldBlockexécute à nouveauPending, et si le socket n'a effectivement pas de données, retourne encore

, la boucle continue. Comme les bits de disponibilité ne sont jamais effacés, cette boucle n'entrera jamais dansRegistration, la tâche occupera le CPU en polling permanent.📎 tokio/src/runtime/io/registration.rs:28-33Scénario déclencheur : plusieurs tâches partagent la direction de lecture du même socket (bien que la documentationtry_readdepoll_readdise au maximum deux tâches, la direction de lecture n'a qu'un seul slot), ouread()etWouldBlocksont mélangés. Plus courant encore : après qu'epoll signale la lisibilité, un autre thread lit les données en premier, le

Q2: add_sourcede la tâche courante retourneregistry.register, il faut alors effacer le bit de disponibilité, sinon il réessaiera indéfiniment.registrations.removePourquoi appeler

lorsque:add_source 📎 tokio/src/runtime/io/driver.rs:288-312échoue dansregistrations.allocate? Que se passe-t-il si on ne l'appelle pas ?ScheduledIo 📎 tokio/src/runtime/io/driver.rs:293Analyse de référenceregistry.registeralloue d'abord📎 tokio/src/runtime/io/driver.rs:298, puisScheduledIoenregistreRegistrationSetauprès du noyau. Si l'enregistrement échoue,

a déjà été alloué mais aucun fd n'y est associé ; si on ne le retire pas, il restera éternellement dans📎 tokio/src/runtime/io/driver.rs:296-297.scheduled_io from the registrations set if registering the source with the OS fails. Otherwise it will leak the scheduled_ioLe commentaire

removedit explicitement : « we should remove the📎 tokio/src/runtime/io/driver.rs:300-303. » — c'est une fuite de mémoire.ScheduledIoL'appel àRegistrationSetest enveloppé dans un bloc unsafe, carRegistrationSetfait partie deToken, et l'opération de retrait doit garantir qu'il n'y a pas d'autres références. Conséquences de la fuite :allocatecroît continuellement,

Q3: deregister_sourcel'espace est gaspillé, ce qui peut finalement provoquer l'échec deunpark()ou l'épuisement de la mémoire. Dans les scénarios de création/destruction fréquente de connexions (comme un serveur à connexions courtes), si le taux d'échec d'enregistrement est élevé (par exemple, épuisement des fd), la fuite accélère l'épuisement des ressources.registrations.deregisterDans

, pourquoi:deregister_source 📎 tokio/src/runtime/io/driver.rs:315-334n'est-il appelé que lorsqueregistry.deregister(source)retourne true ? Quel problème y aurait-il à l'appeler inconditionnellement ?📎 tokio/src/runtime/io/driver.rs:322Analyse de référenceregistrations.deregisterLa logique de📎 tokio/src/runtime/io/driver.rs:315-334est : d'abordunpark() 📎 tokio/src/runtime/io/driver.rs:328。

registrations.deregisterdésenregistreScheduledIoauprès du noyau, puispollnettoie l'état interneunpark, et si cela retourne true, alorsmio::Wakerretourner true signifie que c'est la dernière référence,TOKEN_WAKEUPest réellement retiré. À ce moment, le driver peut être bloqué dans📎 tokio/src/runtime/io/driver.rs:280-283en attente d'un événement pour ce fd, mais le fd est déjà désenregistré, le noyau ne produira plus d'événements.pollvia

injecte ununparkévénementScheduledIodans epoll, faisant queTcpStreamretourne immédiatement, le driver revérifie l'ensemble des enregistrements et peut sortir du blocage.splitpuis lecture-écriture en deux moitiés), chaque drop d'une moitié réveille le driver, augmentant la charge CPU. Plus grave encore

Dans ce chapitre, nous avons décomposé comment Reactor traduit les événements epoll en réveils Waker : en partant de poll_read_ready de TcpStream, en passant par l'enregistrement et l'interrogation de Registration, jusqu'aux bits de disponibilité et aux emplacements Waker de ScheduledIo, puis le Driver localise et déclenche le réveil dans la boucle d'événements en fonction du Token. Les conceptions clés incluent : le Token comme pointeur pour une recherche O(1), les deux emplacements Waker lecture-écriture pour supporter la séparation lecture-écriture concurrente, le tampon indépendant events_busy pour éviter la famine d'événements, et assume_ready pour optimiser le scénario accept par estimation optimiste. À ce stade, la boucle fermée de notification de disponibilité I/O est complète. Mais le runtime asynchrone doit encore gérer un autre type de « disponibilité » — le temps. Dans le prochain chapitre, nous analyserons l'implémentation de tokio::time::sleep et timeout : comment les temporisateurs sont insérés dans la roue temporelle, comment la roue temporelle est hiérarchisée par temps d'expiration, et comment le driver calcule le timeout du prochain park et déclenche les tâches expirées. Vous verrez l'abstraction unifiée « le temps est aussi un événement I/O », ainsi que comment start_paused et l'horloge de test rendent le temps contrôlable dans les tests.

CHAPTER 06

Chapitre 6 : Pilotage temporel : comment la roue temporelle, Sleep et les timeouts sont réveillés

Projet concerné : tokio-rs/tokio · Progression du livre : Chapitre 6 / 14 · État de vérification : les numéros de ligne FACT sont réellement ancrés

Dans le chapitre précédent, nous avons suivi la chaîne complète de TcpStream::read, en voyant comment ScheduledIo traduit les événements de disponibilité fd d'epoll en réveils Waker. Mais le runtime asynchrone doit encore gérer un autre type de « disponibilité » : un Future sleep(100ms) doit être réveillé après 100ms. Ce type d'événement ne provient pas d'un fd du noyau, mais du « temps lui-même ». Le choix de conception de Tokio est de traiter le temps comme un événement I/O : la structure Driver n'a qu'un seul champ park: IoStack, qui réutilise le mécanisme park/unpark du driver I/O. Lorsque la roue temporelle calcule le « prochain instant d'expiration », le driver appelle park_timeout pour faire dormir le thread jusqu'à cet instant ; après le réveil, il extrait les entrées expirées de la roue temporelle et déclenche leurs Waker. Ainsi, le planificateur n'a besoin que d'une entrée park unifiée pour attendre simultanément les deux types d'événements : « fd prêt » et « temporisateur expiré ». Ce chapitre répond à trois questions : comment les temporisateurs sont insérés dans la roue temporelle ? Comment la roue temporelle est hiérarchisée par temps d'expiration ? Comment le driver calcule le timeout du prochain park et déclenche les tâches expirées ?

I. Roue temporelle : structure de hachage hiérarchique à six niveaux de 64 emplacements

Modèle intuitif

Imaginez une horloge mécanique : l'aiguille des secondes fait un tour et entraîne l'aiguille des minutes, qui fait un tour et entraîne l'aiguille des heures. S'il n'y avait qu'une aiguille des secondes, pour représenter « dans 12 jours », il faudrait compter 1 million de cases ; après hiérarchisation, l'aiguille des secondes ne gère que la précision dans les 64 secondes, l'aiguille des minutes gère 64 minutes, l'aiguille des heures gère 64 heures — chaque niveau n'a besoin que de 64 emplacements pour couvrir jusqu'à 2 ans.

Sans hiérarchisation, insérer un temporisateur lointain nécessiterait soit un parcours O(N), soit un tableau gigantesque. La roue temporelle utilise la « hiérarchisation par temps d'expiration » pour réduire l'insertion et le déclenchement à un coût approximativement O(1).

Disposition mémoire et champs

Wheelne possède que trois champs principaux📎 tokio/src/runtime/time/wheel/mod.rs:22-40:

rust
pub(crate) struct Wheel {
    elapsed: u64,                          // 自 wheel 创建以来经过的毫秒数
    levels: Box<[Level; NUM_LEVELS]>,      // 6 层,每层 64 槽
    pending: LinkedList<TimerShared>,      // 已到期、待触发的条目
}

NUM_LEVELS = 6,BITS_PER_LEVEL = 6(soit 64 emplacements par niveau)📎 tokio/src/runtime/time/wheel/mod.rs:45-47。MAX_DURATION = 1 << (6 * 6) = 1 << 36millisecondes, environ 2 ans📎 tokio/src/runtime/time/wheel/mod.rs:50。

La granularité des six niveaux selon les commentaires de documentation est📎 tokio/src/runtime/time/wheel/mod.rs:22-40:

NiveauGranularité de l'emplacementPlage couverte
01 ms64 ms
164 ms~4 s
2~4 s~4 min
3~4 min~4 hr
4~4 hr~12 day
5~12 day~2 yr

pendingest une liste chaînée intrusive (LinkedList<TimerShared>), contenant les entrées déjà retirées de la roue et en attente de déclenchement du Waker. Notez qu'il s'agit deLinkedListet non deVec: l'entrée elle-même est intégrée dansTimerShared, l'insertion/suppression ne nécessite aucune allocation.

Scénario guidé : insertion d'un sleep de 100ms

Lorsquesleep(100ms)est poll pour la première fois,Sleep::poll_elapsedconstruitTimer::newet appelleinit 📎 tokio/src/time/sleep.rs:436-440。initappelle finalementHandle::reregister, puis appelleWheel::insert。

insertLa première étape consiste à vérifier si déjà expiré📎 tokio/src/runtime/time/wheel/mod.rs:90-98:

rust
let when = unsafe { item.sync_when() };
if when <= self.elapsed {
    return Err((item, InsertError::Elapsed));
}

Siwhenest déjà tombé avantelapsed(par exemple deadline dépassée), retourne directementElapsed, l'appelant déclenchera immédiatement ce temporisateur.

Sinon, calcule dans quel niveau cette entrée doit être placée📎 tokio/src/runtime/time/wheel/mod.rs:90-114:

rust
let level = self.level_for(when);
unsafe { self.levels[level].add_entry(item); }

level_forest le cœur de l'algorithme de hiérarchisation📎 tokio/src/runtime/time/wheel/mod.rs:276-289:

rust
fn level_for(elapsed: u64, when: u64) -> usize {
    const SLOT_MASK: u64 = (1 << BITS_PER_LEVEL) - 1;
    let masked = elapsed ^ when | SLOT_MASK;
    if masked >= MAX_DURATION {
        return NUM_LEVELS - 1;
    }
    masked.ilog2() as usize / BITS_PER_LEVEL
}

Ici, on utiliseelapsed ^ whenplutôt quewhen - elapsed, ce qui est une technique ingénieuse : le bit de poids fort du XOR reflète « à partir de quel bit les deux horodatages commencent à différer », c'est-à-dire « quelle granularité est nécessaire pour les distinguer ».| SLOT_MASKforce les 6 bits de poids faible à 1, évitant queilog2ne calcule un niveau trop petit lorsqu'ils tombent dans le même emplacement.ilog2() / 6mappe la largeur de bits au numéro de niveau. Si le résultat XOR dépasseMAX_DURATION(soit plus de 2 ans), il est forcé dans le niveau le plus élevé — c'est le « fudge the timer into the top level ».

Pour un sleep de 100ms, en supposant queelapsedest proche de 0,when ≈ 100,elapsed ^ when ≈ 100,ilog2(100) = 6,6 / 6 = 1, donc il tombe dans la couche 1 (granularité de 64 ms). Cela signifie qu'il attendra dans un slot de la couche 1 jusqu'à ce que le temps avance jusqu'à la limite de ce slot pour être descendu dans la couche 0.

Descente par niveaux : process_expiration

Lorsquepoll(now)avance le temps,Wheel::pollappelle en bouclenext_expirationetprocess_expiration 📎 tokio/src/runtime/time/wheel/mod.rs:142-166:

rust
pub(crate) fn poll(&mut self, now: u64) -> Option<TimerHandle> {
    loop {
        if let Some(handle) = self.pending.pop_back() {
            return Some(handle);
        }
        match self.next_expiration() {
            Some(ref expiration) if expiration.deadline <= now => {
                self.process_expiration(expiration);
                self.set_elapsed(expiration.deadline);
            }
            _ => {
                self.set_elapsed(now);
                break;
            }
        }
    }
    self.pending.pop_back()
}

process_expirationest responsable de « faire descendre » les entrées expirées d'une couche vers la couche suivante, ou (dans la couche 0) de les marquer comme pending📎 tokio/src/runtime/time/wheel/mod.rs:218-251:

rust
let mut entries = self.take_entries(expiration);
while let Some(item) = entries.pop_back() {
    match unsafe { item.mark_pending(expiration.deadline) } {
        Ok(()) => {
            self.pending.push_front(item);   // 真正到期
        }
        Err(expiration_tick) => {
            let level = level_for(expiration.deadline, expiration_tick);
            unsafe { self.levels[level].add_entry(item); }  // 下沉到更低层
        }
    }
}

mark_pendingest essentiel : il vérifie si le deadline réel de l'entrée est déjà atteint. Si c'est le cas, il retourneOk(()), l'entrée entre dans lapendingliste chaînée ; si ce n'est pas encore le cas (seule la limite du slot est atteinte), il retourneErr(expiration_tick), et l'entrée est réinsérée dans une couche plus fine.

Notez le point souligné dans les commentaires📎 tokio/src/runtime/time/wheel/mod.rs:219-228: il faut d'abord retirer toutes les entrées du slot entier avant de les traiter, car certaines entrées peuvent être réinsérées dans le même slot (cela se produit lorsque le temps d'insertion dépasseMAX_DURATION, provoquant un wraparound). Si l'on retire et insère en même temps, on peut tomber dans une boucle infinie.

Calcul du prochain instant d'expiration

next_expirationparcourt des couches basses vers les couches hautes et retourne le premier point d'expiration non vide📎 tokio/src/runtime/time/wheel/mod.rs:169-191:

rust
fn next_expiration(&self) -> Option<Expiration> {
    if !self.pending.is_empty() {
        return Some(Expiration { level: 0, slot: 0, deadline: self.elapsed });
    }
    for (level_num, level) in self.levels.iter().enumerate() {
        if let Some(expiration) = level.next_expiration(self.elapsed) {
            debug_assert!(self.no_expirations_before(level_num + 1, expiration.deadline));
            return Some(expiration);
        }
    }
    None
}

Sipendingn'est pas vide, cela signifie qu'il y a des entrées expirées à déclencher, et il retourne immédiatement leelapsedactuel comme deadline (ainsi le driver se parkera avec un timeout de 0 et reviendra immédiatement les traiter). Sinon, il parcourt les couches et retourne le deadline du premier slot non vide.debug_assertvalide un invariant : une couche supérieure ne peut pas avoir un point d'expiration plus précoce que la couche actuelle.

mermaid
flowchart TD
    start["Wheel::poll(now)"] --> check_pending{"pending 非空?"}
    check_pending -->|是| pop["pop_back 返回 TimerHandle"]
    check_pending -->|否| next_exp{"next_expiration() 有到期点?"}
    next_exp -->|无| set_elapsed["set_elapsed(now) 后 break"]
    next_exp -->|有| cmp{"expiration.deadline <= now?"}
    cmp -->|否| set_elapsed
    cmp -->|是| proc["process_expiration(expiration)"]
    proc --> take["take_entries 取出整槽"]
    take --> mark{"item.mark_pending()"}
    mark -->|Ok 已到期| push_pending["pending.push_front(item)"]
    mark -->|Err 未到期| reinsert["level_for 后 add_entry 下沉"]
    push_pending --> set_elapsed2["set_elapsed(expiration.deadline)"]
    reinsert --> set_elapsed2
    set_elapsed2 --> check_pending
    set_elapsed --> pop2["pending.pop_back() 返回"]

---

II. La boucle park du Driver : connecter la roue temporelle à la pile I/O

Modèle intuitif

La roue temporelle elle-même ne « tourne » pas toute seule. Elle a besoin d'une boucle externe qui lui demande sans cesse : « Quand est la prochaine expiration ? » puis dort jusqu'à cet instant, et à son réveil avance le temps. Cette boucle estDriver::park_internal. Elle traduit « la prochaine expiration de la roue temporelle » en une durée pourpark_timeout, confiée à la pile I/O sous-jacente pour dormir.

Sans cette boucle, les timers ne se déclencheraient jamais — la roue temporelle n'est qu'une structure de données statique, il faut quelqu'un pour la « faire tourner ».

Structures de données : Driver et InnerState

Drivern'a qu'un seul champpark: IoStack 📎 tokio/src/runtime/time/mod.rs:90-93. Le véritable état est dansHandle, distingué via l'énumérationInnerentre l'implémentation traditionnelle et l'implémentation expérimentale📎 tokio/src/runtime/time/mod.rs:95-127. L'implémentation traditionnelle deInnerStatecontient deux champs📎 tokio/src/runtime/time/mod.rs:130-136:

rust
struct InnerState {
    next_wake: Option<NonZeroU64>,   // 承诺的最早唤醒时刻
    wheel: wheel::Wheel,
}

next_wakeutiliseNonZeroU64plutôt queOption<u64>pour l'imbrication, afin de tirer parti de l'optimisation niche —Option<NonZeroU64>etu64ont la même taille. Il enregistre « avant quel tick le driver s'engage à se réveiller », utilisé lors dereregisterpour déterminer s'il fautunpark。

is_shutdownest unAtomicBoolindépendant, et les commentaires expliquent pourquoi il a été séparé du Mutex📎 tokio/src/runtime/time/mod.rs:90-93:Handleil faut pouvoir vérifieris_shutdownsans verrouiller le mutex. C'est une optimisation typique de type « beaucoup de lectures, peu d'écritures » — le shutdown n'arrive qu'une fois, mais la vérification peut être fréquente.

Piloté par scénario : le flux complet d'un park

park_internalest le cœur📎 tokio/src/runtime/time/mod.rs:213-256:

rust
fn park_internal(&mut self, rt_handle: &driver::Handle, limit: Option<Duration>) {
    let handle = rt_handle.time();
    let mut lock = handle.inner.lock();
    assert!(!handle.is_shutdown());

    let next_wake = lock.wheel.next_expiration_time();
    lock.next_wake = next_wake.map(|t| NonZeroU64::new(t).unwrap_or_else(|| NonZeroU64::new(1).unwrap()));
    drop(lock);

    match next_wake {
        Some(when) => {
            let now = handle.time_source.now(rt_handle.clock());
            let mut duration = handle.time_source.tick_to_duration(when.saturating_sub(now));
            if duration > Duration::from_millis(0) {
                if let Some(limit) = limit {
                    duration = std::cmp::min(limit, duration);
                }
                self.park_thread_timeout(rt_handle, duration);
            } else {
                self.park.park_timeout(rt_handle, Duration::from_secs(0));
            }
        }
        None => {
            if let Some(duration) = limit {
                self.park_thread_timeout(rt_handle, duration);
            } else {
                self.park.park(rt_handle);
            }
        }
    }

    handle.process(rt_handle.clock());
}

Analyse étape par étape :

1. Prendre le verrou, lire la prochaine expiration:lock.wheel.next_expiration_time()retourneOption<u64>, c'est-à-dire le prochain tick d'expiration. En même temps, il l'écrit danslock.next_wake, pour quereregisterpuisse déterminer s'il faut unpark.

2. Libérer le verrou:drop(lock)doit être fait avant le park, sinon d'autres threads ne peuvent pas insérer de timers pendant le park.

3. Calculer la durée du park:when.saturating_sub(now)obtient le nombre de ticks restants,tick_to_durationconvertit enDuration. Les commentaires indiquent qu'en pratique on arrondit au supérieur à 1 ms📎 tokio/src/runtime/time/mod.rs:228-230, pour éviter qu'un sleep de l'ordre de la microseconde soit traité comme de longueur nulle par l'OS.

4. Traiter la limite: si l'appelant a passélimit(par exemplepark_timeoutun timeout explicite), prendremin(limit, duration), pour garantir de ne pas dormir trop longtemps.

5. Cas particulier: siduration == 0(déjà expiré), utiliserpark_timeout(0)pour retourner immédiatement, sans vraiment dormir.

6. Sans timer: sinext_wakeestNone, aveclimitalorspark_thread_timeout(limit), sinonpark。

7. infini Traitement après réveil:handle.process(clock)avance la roue temporelle et déclenche les entrées expirées.

process_at_time : déclencher les entrées expirées

processappelleprocess_at_time 📎 tokio/src/runtime/time/mod.rs:296-337:

rust
pub(self) fn process_at_time(&self, mut now: u64) {
    let mut waker_list = WakeList::new();
    let mut lock = self.inner.lock();

    if now < lock.wheel.elapsed() {
        // 时间倒流保护
        now = lock.wheel.elapsed();
    }

    while let Some(entry) = lock.wheel.poll(now) {
        debug_assert!(unsafe { entry.is_pending() });
        if let Some(waker) = unsafe { entry.fire(Ok(())) } {
            waker_list.push(waker);
            if !waker_list.can_push() {
                drop(lock);
                waker_list.wake_all();
                lock = self.inner.lock();
            }
        }
    }

    lock.next_wake = lock.wheel.poll_at()
        .map(|t| NonZeroU64::new(t).unwrap_or_else(|| NonZeroU64::new(1).unwrap()));
    drop(lock);
    waker_list.wake_all();
}

Quelques points clés :

  • Protection contre le retour en arrière du temps 📎 tokio/src/runtime/time/mod.rs:301-309: sinow < wheel.elapsed(), cela signifie que l'horloge recule. Les commentaires indiquent que cela ne devrait normalement pas arriver (Rust garantit queInstantest monotone), mais cela se produit dans une VM Linux sur un hôte Windows, car std fait confiance à tort à la monotonie de l'horloge matérielle. La protection consiste à ramenernowàelapsed。
  • Réveil par lots:WakeListcollecte les Waker, et lorsqu'il est plein (!can_push()), il libère temporairement le verrou, réveille un lot, puis reprend le verrou. Les commentaires soulignent que c'est pour éviter un deadlock📎 tokio/src/runtime/time/mod.rs:319. Si l'on appelle un Waker en tenant le verrou, et que le Waker tente à son tour d'opérer sur la roue temporelle (par exemple réenregistrer un timer), on provoque un deadlock.
  • Mettre à jour next_wake: après traitement, recalculerpoll_at(), mettre à journext_wake。

reregister : réenregistrement et unpark

LorsqueSleep::resetest appelé, le timer doit être réenregistré.reregistergère ce scénario📎 tokio/src/runtime/time/mod.rs:398-450:

rust
pub(self) unsafe fn reregister(&self, unpark: &IoHandle, new_tick: u64, entry: NonNull<TimerShared>) {
    let waker = unsafe {
        let mut lock = self.inner.lock();
        if unsafe { entry.as_ref().might_be_registered() } {
            lock.wheel.remove(entry);
        }
        let entry = entry.as_ref().handle();
        if self.is_shutdown() {
            unsafe { entry.fire(Err(crate::time::error::Error::shutdown())) }
        } else {
            entry.set_expiration(new_tick);
            match unsafe { lock.wheel.insert(entry) } {
                Ok(when) => {
                    if lock.next_wake.is_none_or(|next_wake| when < next_wake.get()) {
                        unpark.unpark();
                    }
                    None
                }
                Err((entry, crate::time::error::InsertError::Elapsed)) => unsafe {
                    entry.fire(Ok(()))
                },
            }
        }
    };
    if let Some(waker) = waker {
        waker.wake();
    }
}

Logique clé : après une insertion réussie, si le nouvel instant d'expiration est plus précoce quenext_wake, appelerunpark.unpark()pour réveiller le driver. En effet, le driver peut être en train de dormir jusqu'à un instant plus tardif, et doit être réveillé plus tôt pour recalculer la durée du park.

Notez queunparkest appeléen tenant le verrou, tandis quewaker.wake()est appeléaprès avoir libéré le verrou. Les commentaires expliquent📎 tokio/src/runtime/time/mod.rs:441: il faut libérer le verrou avant d'appeler le Waker pour éviter un deadlock. Maisunparkest différent — il ne fait qu'injecter un événement dans epoll, sans rappeler de code utilisateur, donc l'appeler en tenant le verrou est sûr.

mermaid
sequenceDiagram
    participant Sleep as Sleep::poll
    participant Handle as time::Handle
    participant Wheel as Wheel
    participant Driver as Driver::park_internal
    participant IoStack as IoStack

    Sleep->>Handle: reregister(unpark, new_tick, entry)
    Handle->>Handle: lock.inner.lock()
    Handle->>Wheel: wheel.remove(entry) [若已注册]
    Handle->>Wheel: wheel.insert(entry)
    Wheel-->>Handle: Ok(when)
    alt when < next_wake
        Handle->>IoStack: unpark.unpark()
    end
    Handle->>Handle: drop(lock)
    Handle-->>Sleep: 返回 waker (若有)

    Note over Driver: 另一线程
    Driver->>Handle: lock.inner.lock()
    Driver->>Wheel: next_expiration_time()
    Wheel-->>Driver: Some(when)
    Driver->>Driver: drop(lock)
    Driver->>IoStack: park_timeout(duration)
    IoStack-->>Driver: 被 unpark 或超时
    Driver->>Handle: process(clock)
    Handle->>Wheel: poll(now)
    Wheel-->>Handle: TimerHandle
    Handle->>Sleep: waker.wake()

---

III. Sleep et Timeout : la couche API visible par l'utilisateur

Modèle intuitif

Sleepest le Future que l'utilisateur.awaitdirectement,Timeoutest un adaptateur qui enveloppe un autre Future. Ils ne gèrent pas eux-mêmes la roue temporelle, ils traduisent simplement le « deadline » en tick, et délèguent àTimeretHandle。

Disposition mémoire de Sleep

Sleeputilisepin_project!la macro pour définir📎 tokio/src/time/sleep.rs:221-227:

rust
pub struct Sleep {
    deadline: Instant,
    driver: scheduler::Handle,
    inner: Inner,
    #[pin]
    timer: Option<Timer>,
}

timerestOption<Timer>et avec#[pin]: avant le premier poll c'estNone, c'est seulement lors du premier poll queTimerest créé et enregistré. Cette « initialisation paresseuse » évite d'accéder au runtime lors de l'appel àsleep()—sleep()peut être appelé en dehors du runtime, tant que l'enregistrement réel n'a lieu qu'au moment de.await.

PinnedDropL'implémentation garantit l'annulation du timer lors du drop📎 tokio/src/time/sleep.rs:230-235:

rust
impl PinnedDrop for Sleep {
    fn drop(this: Pin<&mut Self>) {
        let this = this.project();
        if let Some(timer) = this.timer.as_pin_mut() {
            timer.cancel(this.driver);
        }
    }
}

Flux complet de poll_elapsed

poll_elapsedest le cœur deSleep📎 tokio/src/time/sleep.rs:396-454:

rust
fn poll_elapsed(self: Pin<&mut Self>, cx: &mut task::Context<'_>) -> Poll<Result<(), Error>> {
    ready!(crate::trace::trace_leaf());
    let mut this = self.project();

    // coop 预算
    let coop = ready!(crate::task::coop::poll_proceed(cx));

    let handle = this.driver;
    let timer = match this.timer.as_mut().as_pin_mut() {
        Some(timer) => timer,
        None => {
            let time_source = handle.driver().time().time_source();
            let deadline = time_source.deadline_to_tick(*this.deadline);
            let timer = Timer::new(handle, deadline);
            this.timer.set(Some(timer));
            let mut timer = this.timer.as_pin_mut().unwrap();
            timer.as_mut().init(handle, deadline);
            timer
        }
    };

    let result = timer.poll_elapsed(cx, handle).map(move |r| {
        coop.made_progress();
        r
    });
    result
}

Étapes :

1. Vérification du budget coop:poll_proceed(cx)consomme un budget coopératif. Si le budget est épuisé, retournePendinget cède l'exécution. C'est le mécanisme de Tokio pour empêcher qu'une seule tâche affame les autres.

2. Création paresseuse du Timer: sitimerestNone, convertitdeadlineen tick, créeTimeret appelleinitpour l'enregistrer dans la roue temporelle.

3. Délégation à Timer::poll_elapsed: la vérification réelle de l'expiration est effectuée parTimer.

4. Marquage de la progression en cas de succès:coop.made_progress()indique que ce poll a réellement progressé.

Poll de Timeout : d'abord poll la valeur, puis poll le délai

TimeoutL'ordre de poll de📎 tokio/src/time/timeout.rs:210-224:

rust
fn poll(self: Pin<&mut Self>, cx: &mut task::Context<'_>) -> Poll<Self::Output> {
    let me = self.project();
    let had_budget_before = coop::has_budget_remaining();

    // 先 poll 被包裹的 future
    if let Poll::Ready(v) = me.value.poll(cx) {
        return Poll::Ready(Ok(v));
    }

    match me.delay.as_pin_mut() {
        Some(delay) => poll_delay(had_budget_before, delay, cx).map(Err),
        None => Poll::Pending,
    }
}

Le commentaire indique explicitement📎 tokio/src/time/timeout.rs:24-26: le future est d'abord poll, puis le timeout est vérifié. Donc si le future se termine sans yield, il peut retournerOkmême après avoir dépassé le timeout. C'est un choix de conception, pas un bug.

poll_delayGère un scénario subtil📎 tokio/src/time/timeout.rs:229-251:

rust
fn poll_delay(had_budget_before: bool, delay: Pin<&mut Sleep>, cx: &mut task::Context<'_>) -> Poll<Elapsed> {
    let delay_poll = || match delay.poll(cx) {
        Poll::Ready(()) => Poll::Ready(Elapsed::new()),
        Poll::Pending => Poll::Pending,
    };

    let has_budget_now = coop::has_budget_remaining();

    if let (true, false) = (had_budget_before, has_budget_now) {
        // 如果预算是被底层 future 耗尽的,用无约束预算 poll delay
        coop::with_unconstrained(delay_poll)
    } else {
        delay_poll()
    }
}

Logique : si en entrant danspollil reste du budget, mais qu'après avoir poll la value le budget est épuisé, cela signifie que c'est la value qui a consommé le budget. À ce moment, si on poll le delay avec un budget restreint, le delay pourrait retournerPendingimmédiatement, rendant impossible de déterminer si le timeout est atteint. Donc on utilisewith_unconstrainedpour lever temporairement la restriction de budget. Le commentaire appelle cela les « pathological cases »📎 tokio/src/time/timeout.rs:243-246。

Gestion du débordement du deadline de timeout

timeoutLa fonction utilisechecked_addpour gérer le débordement📎 tokio/src/time/timeout.rs:86-99:

rust
Timeout {
    value: future.into_future(),
    delay: match Instant::now().checked_add(duration) {
        Some(deadline) => Some(Sleep::new_timeout(deadline, trace::caller_location())),
        None => None,
    },
}

SiInstant::now() + durationdéborde (duration extrêmement grande),delaydevientNone, et le poll retourne directementPoll::Pending 📎 tokio/src/time/timeout.rs:222. Cela équivaut à « ne jamais expirer », un comportement de dégradation raisonnable.

---

Réflexions de conception et pièges en production

Pourquoi utiliser XOR plutôt que la soustraction pour calculer le niveau ? elapsed ^ whenLe bit de poids fort reflète directement « à partir de quel bit deux horodatages diffèrent », ce qui est précisément la mesure de « quelle granularité est nécessaire ». La soustractionwhen - elapsedlorsqueelapsedest proche dewhendonne des bits de poids fort tous à 0,ilog2calculerait un niveau trop petit. XOR gère naturellement les scénarios de wraparound.

Nécessité de la protection contre le retour en arrière du temps 📎 tokio/src/runtime/time/mod.rs:301-309: Rust garantit queInstantest monotone, mais l'OS sous-jacent peut ne pas le garantir. Dans une VM Linux sur un hôte Windows, std fait confiance à l'horloge matérielle, ce qui provoque un recul deInstant. Tokio utilisenow = lock.wheel.elapsed()pour clamper, évitant l'échec de l'assert deset_elapsed.

Réveil par lots et deadlock 📎 tokio/src/runtime/time/mod.rs:319: appeler un Waker en tenant le verrou de la roue temporelle est dangereux — le Waker peut déclencher un re-poll de la tâche, qui appelle à son tourSleep::reset, tentant de réacquérir le verrou de la roue temporelle, causant un deadlock.WakeListLe mécanisme par lots de

next_wakelibère temporairement le verrou quand il est plein, c'est le modèle standard de « callback hors verrou ». 📎 tokio/src/runtime/time/mod.rs:130-136:Option<NonZeroU64>Optimisation niche deu64est de même taille queNone, car 0 est utilisé comme niche deNonZeroU64::new(t).unwrap_or_else(|| NonZeroU64::new(1).unwrap()). Mais tick 0 est une valeur légale, donc le code utilise📎 tokio/src/runtime/time/mod.rs:221pour mapper 0 vers 1

process_expiration. C'est une gestion de bordure subtile : tick 0 est traité comme tick 1, causant au plus un réveil supplémentaire de 1ms. 📎 tokio/src/runtime/time/wheel/mod.rs:219-228Le « prendre d'abord, traiter ensuite » deMAX_DURATION: il faut d'abord extraire toutes les entrées du slot avant de les traiter, car les entrées dépassant

Timeoutvont faire un wraparound et se réinsérer dans le même slot. Si on insère en même temps qu'on extrait, on boucle à l'infini. 📎 tokio/src/time/timeout.rs:24-26Le piège de l'ordre de poll deOk: le future est poll d'abord, le timeout est vérifié ensuite. Si le future est intensif en CPU et ne yield pas, il peut retournertimeoutmême après avoir dépassé le timeout. En production, ne comptez pas sur

---

pour forcer l'interruption d'un future non coopératif.

Résumé de ce chapitre

1. Ce chapitre a décomposé la structure à trois couches du driver temporel de Tokio :(WheelRoue temporelleelapsed ^ when) : structure hiérarchique de hachage à six niveaux de 64 slots, utilisant la largeur de bits dependingpour déterminer le niveau des entrées, insertion et déclenchement en approximativement O(1).process_expirationLa liste chaînée stocke les entrées expirées,

2. Driver(Driver::park_internalest responsable de la descente niveau par niveau.next_expiration_time) : traduit lepark_timeoutde la roue temporelle en duréeprocess_at_time, réutilise le park/unpark de la pile I/O.

3. Après le réveil, fait avancer la roue temporelle, déclenche les Waker par lots, et gère la protection contre le retour en arrière du temps et le deadlock.(Sleep / Timeout):SleepAPI utilisateurTimerCréation paresseuse deTimeoutet enregistrement,with_unconstrainedpoll d'abord la value puis le delay, utilise

pour gérer le scénario d'épuisement du budget.next_wakeLa conception centrale est que « le temps est aussi un événement I/O » : le driver n'a qu'une seule entrée park, attendant simultanément la disponibilité des fd et l'expiration des timers.reregisterenregistre l'instant de réveil promis,unparklors de l'insertion d'un timer plus précoce,

réveille le driver pour recalculer.Mutex、SemaphoreDans le prochain chapitre, nous aborderons les primitives de synchronisation :

comment

et les canaux implémentent l'attente asynchrone. Vous verrez comment ils réutilisent le mécanisme Waker de ce chapitre, et comment le « comptage de permissions » et la « file d'attente » coopèrent.Wheel::insertRéflexions et auto-évaluation de ce chapitreif when <= self.elapsedQ1 : Si dansif when < self.elapsed(supprimer le signe égal), dans quels scénarios cela entraînerait-il que le timer ne soit jamais déclenché ?

Analyse de référence:when == self.elapsedindique que l'instant d'expiration du timer est exactement égal au temps actuellement avancé. Le code original utilise<=pour le considérer commeElapsed, l'appelant déclenche immédiatement📎 tokio/src/runtime/time/wheel/mod.rs:96-98. Si on le change en<, cette entrée sera insérée dans la couche calculée parlevel_for(elapsed, when). Commeelapsed ^ when == 0,masked = 0 | SLOT_MASK = 63,ilog2(63) = 5,5 / 6 = 0, elle tombe dans la couche 0. Mais lenext_expirationde la couche 0 retournera un slot dedeadline >= elapsed, et la condition deWheel::pollestexpiration.deadline <= now. Sinow == elapsed, la condition est remplie,process_expirationretirera cette entrée,mark_pending(elapsed)vérifie si le deadline réel est atteint — à ce momentwhen == elapsed,mark_pendingretourneOk, l'entrée passe en pending. Donc en réalité elle sera quand même déclenchée, mais avec un détour supplémentaire. Le vrai risque est : sielapseda déjà avancé au-delà dewhen(when < elapsed), le code original retourneElapsedet déclenche immédiatement, après modification on insère dans un slot déjà passé,next_expirationpeut retournerdeadline < elapsed,set_elapsed, l'assertelapsed <= whenéchouera avec un panic📎 tokio/src/runtime/time/wheel/mod.rs:253-264. Donc ce signe égal est la frontière clé pour éviter l'échec de l'assert.

Q2: process_at_timeDansWakeList, une fois quedrop(lock)est plein, pourquoi faut-ilwake_all()puislockpuis re-

? Si on supprime ce drop, dans quels scénarios de concurrence y aurait-il un deadlock ?:WakeListAnalyse de référence📎 tokio/src/runtime/time/mod.rs:318-325collecte les Waker, une fois plein il faut en réveiller un lot pour libérer de la placeself.inner.lock(). Si on appellewaker.wake()en tenantSleep::reset, la tâche réveillée peut s'exécuter immédiatement sur un autre thread (ou le scheduler du même thread), appelantSleep::poll_elapsedouHandle::reregister, puis appelantreregister, et la première chose que faitself.inner.lock() 📎 tokio/src/runtime/time/mod.rs:405eststd::sync::Mutex. Commeprocess_at_timen'est pas réentrant, le même thread se deadlockera ; même sur un thread différent, il bloquera jusqu'à ce queprocess_at_timelibère le verrou, alors quewake_allattend que📎 tokio/src/runtime/time/mod.rs:319retourne, formant une attente circulaire. Le commentaire dit explicitement « To avoid deadlock, we must do this with the lock temporarily dropped »while let Some(entry) = lock.wheel.poll(now). Après le drop, lors du re-lock, l'état de la roue temporelle peut avoir été modifié par d'autres threads (par exemple un nouveau timer inséré), donc

Q3: Timeout::pollcontinuera à prendre des entrées depuis le nouvel état, ce qui est sûr.had_budget_beforeDanshas_budget_now, la combinaison de(true, false)etwith_unconstrainedavec la condition(false, true)pourquoi n'est-elle utilisée que lorsque « il y a un budget à l'entrée, mais plus de budget après le poll de value »

? Si c'était l'inverse:had_budget_beforeque se passerait-il ?📎 tokio/src/time/timeout.rs:208-208,has_budget_nowAnalyse de référence📎 tokio/src/time/timeout.rs:239。(true, false)enregistrepoll_proceedavant le poll de value,Pendingenregistrewith_unconstrainedaprès le poll de value.📎 tokio/src/time/timeout.rs:247。(false, true)signifie que le budget a été épuisé pendant le poll de value, indiquant que value est un « consommateur de budget ». À ce moment si on poll delay avec un budget limité,with_unconstrainedretournerait immédiatement(false, false), delay ne serait jamais réellement vérifié, le jugement de timeout serait invalide. Donc on utilisePendingpour lever temporairement la restriction.poll_proceedest impossible — le budget ne peut qu'être consommé, pas restauré (sauf(true, true)explicite, mais il n'y en a pas ici).

signifie qu'il n'y avait déjà plus de budget à l'entrée, à ce moment le poll de value peut déjà avoir retourné

CHAPTER 07

Jusqu'ici, nous avons vu clairement comment le temps est abstrait comme un événement I/O, permettant aux timers et à la disponibilité des fd de partager la même entrée d'attente park/unpark. Cependant, lorsque plusieurs tâches se disputent le même verrou ou transmettent des messages via des canaux, l'objet d'attente n'est plus un fd ou une horloge, mais le changement d'état d'une autre tâche. Ce chapitre entre dans la famille tokio::sync, pour découvrir où un lock().await ou recv().await stocke réellement le Waker lors du blocage, et comment il est re-schedulé lors du réveil.

Pourquoi le Mutex asynchrone ne peut pas réutiliser l'implémentation de std · Modèle intuitif : de « occuper la place » à « céder le siège » · Le

de

lorsque le verrou est occupé

bloque le thread courant

std::sync::Mutex— le thread est suspendu par le système d'exploitation jusqu'à la libération du verrou. C'est catastrophique dans un runtime asynchrone : un thread worker peut conduire simultanément des centaines voire des milliers de tâches, s'il bloque en attendant un verrou, toutes les autres tâches qu'il porte s'arrêtent. L'exigence fondamentale du Mutex asynchrone est : lors de l'attente du verrou,lock()céder le thread, enregistrer le fait « j'attends ce verrou » dans une file, puis retourner, laissant l'exécuteur aller exécuter d'autres tâches.Lede Tokio n'implémente pas sa propre file d'attente, maisPending,让执行器去跑别的任务。

Tokio 的 Mutex 没有自己实现等待队列,而是Entièrement construit sur des sémaphores。

Structures de données et disposition mémoire

Mutex<T>Les champs de sont minimalistes :

📎 tokio/src/sync/mutex.rs:133-138

rust
pub struct Mutex<T: ?Sized> {
    #[cfg(all(tokio_unstable, feature = "tracing"))]
    resource_span: tracing::Span,
    s: semaphore::Semaphore,
    c: UnsafeCell<T>,
}

Les trois champs ont chacun leur rôle :sest unsémaphore avec un nombre de permis de 1,cestUnsafeCell<T>les données protégées enveloppées par . Notez ici quesemaphoreestbatch_semaphoreun alias de📎 tokio/src/sync/mutex.rs:3-3, c'est-à-dire l'implémentation sous-jacente, et nonsync::Semaphorela couche d'encapsulation publique.

MutexGuard<'a, T>ne détient qu'une référence versMutex:

📎 tokio/src/sync/mutex.rs:151-157

rust
pub struct MutexGuard<'a, T: ?Sized> {
    #[cfg(all(tokio_unstable, feature = "tracing"))]
    resource_span: tracing::Span,
    lock: &'a Mutex<T>,
}

Il y a ici une conception clé :MutexGuard ne détient pas l'objet permis du sémaphore, mais seulement&Mutex. L'action de libérer le verrou se produit dansDrop, en appelant directementself.lock.s.release(1) 📎 tokio/src/sync/mutex.rs:959-961. Cela diffère deSemaphorePermitqui détientpermits: usizeun compteur et le restitue lors du Drop — le nombre de permis du Mutex est constamment 1, aucun comptage n'est nécessaire.

Send/SyncLes limites de méritent un examen séparé :

📎 tokio/src/sync/mutex.rs:258-259

rust
unsafe impl<T> Send for Mutex<T> where T: ?Sized + Send {}
unsafe impl<T> Sync for Mutex<T> where T: ?Sized + Send {}

Syncn'exige queT: Sendet nonT: Sync— c'est raisonnable, car l'accès mutuellement exclusif garantit qu'un seul thread à la fois peut toucherT, transférer la propriété deTentre threads (Send) suffit, il n'est pas nécessaire queTlui-même soit partageable (Sync). C'est précisément ce qui permet àMutex<T>de transformer unSyncnonTenSync.

Step-by-Step : le parcours complet d'unlock().awaitMise en situation : la tâche A appelle

, le verrou est alors libre.mutex.lock().awaitPremière étape,

construit un bloc async, à l'intérieur d'abordlock(), puis en cas de succès construitself.acquire().awaitDeuxième étape,MutexGuard 📎 tokio/src/sync/mutex.rs:434-443。

délègue directement au sémaphore :acquire()Copie

📎 tokio/src/sync/mutex.rs:655-663

rust
async fn acquire(&self) {
    crate::trace::async_trace_leaf().await;
    self.s.acquire(1).await.unwrap_or_else(|_| {
        unreachable!()
    });
}

unwrap_or_else(|_| unreachable!())ne retournera jamaisacquire. Cela élimine au niveau du type le chemin d'erreur « fermeture du sémaphore ».ErrTroisième étape, si le verrou est occupé,

retournes.acquire(1), le Waker de la tâche courante est enregistré dans la file d'attente du sémaphore.PendingOù est stocké le Waker ?La réponse se trouve dansla file d'attente de (le fichier source n'est pas développé dans le matériel de ce chapitre, mais son rôle est : chaque attendeur détient un Waker, en file FIFO).batch_semaphoreQuatrième étape, lorsque la tâche B qui détient le verrou le libère,

appelleMutexGuard::drop, le sémaphore remet le permis au premier de la file et réveille son Waker, la tâche A est replanifiée,s.release(1) 📎 tokio/src/sync/mutex.rs:965-975retourneacquire, construisantOkL'ensemble du processus peut être décrit par le diagramme de séquence suivant :MutexGuard。

Copie

mermaid
sequenceDiagram
    participant TaskA as 任务 A
    participant Mutex as Mutex.s (batch_semaphore)
    participant TaskB as 任务 B (持锁者)
    participant Exec as Executor

    TaskA->>Mutex: acquire(1).await
    Mutex-->>TaskA: Pending (Waker 入队)
    TaskA->>Exec: 让出,调度其他任务
    Note over TaskB: 持有锁执行临界区
    TaskB->>Mutex: MutexGuard::drop -> release(1)
    Mutex->>TaskA: 唤醒队首 Waker
    Exec->>TaskA: 重新 poll
    TaskA->>Mutex: acquire(1) 重试
    Mutex-->>TaskA: Ok(()) 获得许可
    TaskA->>TaskA: 构造 MutexGuard

La documentation déclare explicitement que le Mutex de Tokio garantit FIFO

. Cette équité provient de la sémantique de file d'attente du sémaphore sous-jacent. Le coût de l'équité est : une📎 tokio/src/sync/mutex.rs:20-22annulée (par exemple en perdant danslock) vous feraselect!perdre votre position dans la file. Ce n'est pas un bug, mais une conséquence inévitable de la file FIFO — l'annulation signifie un retrait de la file, et un nouveau 📎 tokio/src/sync/mutex.rs:415-419nécessite de refaire la queue.lockUne autre conception contre-intuitive est que

n'empoisonne pasest marqué comme poisoned lorsqu'un thread détenant le verrou panique, les(no poisoning)。std::sync::Mutexsuivants retournentlock. Le Mutex de Tokio ne fait pas cela : lorsque le détenteur panique, le verrou est libéré normalementErr. La documentation avertit que si le panic est capturé, les données protégées peuvent se trouver dans un état incohérent. C'est un compromis pragmatique dans un contexte asynchrone — un panic dans une tâche asynchrone signifie généralement la terminaison de la tâche, et le mécanisme d'empoisonnement ne ferait qu'ajouter de la complexité.📎 tokio/src/sync/mutex.rs:122-125La série de méthodes mérite une mention. Elle permet de dégrader l'ensemble

MutexGuard::mapen unMutexGuard<T>ne protégeant qu'un sous-champ. En implémentation, elle calcule d'abord le pointeur du sous-champ via une closureMappedMutexGuard<U>, puis décompose le guard original en undataqui ne déclenche pas Drop viaskip_drop, et enfin construit un nouveau guardMutexGuardInnerutilise📎 tokio/src/sync/mutex.rs:869-883。skip_droppour transférer la propriété du champ, évitant queManuallyDrop + ptr::readsoit appelé deux foisDrop. C'est une technique classique en Rust pour « transférer la propriété sans déclencher le destructeur ».📎 tokio/src/sync/mutex.rs:827-836Semaphore : comment le comptage de permis et la file d'attente implémentent la contre-pression

Modèle intuitif : les places de parking

Le sémaphore est comme un parking :

c'est entrer en voiture, s'il y a une place on entre, sinon on fait la queue à l'entrée ;acquirec'est sortir en voiture, libérer une place notifie la première voiture de la file d'entrer. Le nombre de permis est le nombre total de places,releasec'est un grand véhicule occupant n places.acquire_many(n)Structures de données et disposition mémoire

Le public

n'est qu'une fine encapsulation duSemaphoresous-jacent :batch_semaphore::SemaphoreCopie

📎 tokio/src/sync/semaphore.rs:427-432

rust
pub struct Semaphore {
    ll_sem: ll::Semaphore,
    #[cfg(all(tokio_unstable, feature = "tracing"))]
    resource_span: tracing::Span,
}

SemaphorePermit<'a>Copie

📎 tokio/src/sync/semaphore.rs:442-445

rust
pub struct SemaphorePermit<'a> {
    sem: &'a Semaphore,
    permits: usize,
}

permits.forget/merge/splitmetforgetà zéropermits, ainsi lors du Drop 0 permis est restitué — équivalent à « consommer définitivement » ces permis.📎 tokio/src/sync/semaphore.rs:1193-1195découpe n permis du compteur actuel pour le nouveau permitsplitfusionne le compteur d'un autre permit, et affirme que les deux proviennent du même sémaphore📎 tokio/src/sync/semaphore.rs:1260-1271。merge〔Inférence de conception et compromis architecturaux〕📎 tokio/src/sync/semaphore.rs:1230-1240。

est

MAX_PERMITS. Pourquoi un décalage à droite de 3 bits ? Leusize::MAX >> 3 📎 tokio/src/sync/semaphore.rs:476-479sous-jacent doit encoder des drapeaux d'état (comme le drapeau de fermeture) dans les bits de poids fort, donc le nombre de permis disponibles est limité aux bits de poids faible, laissant les bits de poids fort pour les drapeaux. C'est une technique courante pour compresser « compteur + état » dans un seulbatch_semaphore.usizeStep-by-Step : le flux de permis entre acquire et release

Scénario : le sémaphore a initialement 2 permis, la tâche A

, la tâche Bacquire()délègue àacquire_many(2)。

acquire(), puis en cas de succès construitll_sem.acquire(1)similaire, mais passe 2SemaphorePermit { permits: 1 } 📎 tokio/src/sync/semaphore.rs:614-631。acquire_many(2)Si les permis sont insuffisants,📎 tokio/src/sync/semaphore.rs:661-679。

retournell_sem.acquire(n), le Waker est mis en file. Il y a ici un détail d'équité : la documentation indique que si la tête de file est unPendinget qu'il ne reste que 3 permis, même si unacquire_many(5)suivant pourrait être immédiatement satisfait, il doit attendre — car le grand véhicule en tête occupe la fileacquire(1). C'est le coût du FIFO strict, qui évite la famine.📎 tokio/src/sync/semaphore.rs:19-24Le chemin de libération est dans le Drop :

Copie

📎 tokio/src/sync/semaphore.rs:1402-1404

rust
impl Drop for SemaphorePermit<'_> {
    fn drop(&mut self) {
        self.sem.add_permits(self.permits);
    }
}

add_permits, la couche sous-jacente restitue le permis à la file d'attente et réveille les attendeurs pouvant réunir suffisamment de permis.ll_sem.release(n) 📎 tokio/src/sync/semaphore.rs:568-570Concernant l'ordre mémoire, la documentation donne une garantie forte : acquire, release, close sont tous des

opérations, totalement ordonnées entre elles, équivalentes à celles sur une seule variable atomiqueAcqRel 操作,彼此全序,等价于单个原子变量上的 AcqRel 📎 tokio/src/sync/semaphore.rs:35-42Cela signifie que l'écriture « écrire d'abord les données, puis release le permis » est visible pour la tâche qui « acquire le permis ensuite » — le sémaphore peut transmettre des données entre tâches en toute sécurité.

Réflexion de conception : close et backpressure

close()fait en sorte que tous les waiters reçoiventAcquireError, et ensuitetry_acquireretourneClosed 📎 tokio/src/sync/semaphore.rs:1161-1163. C'est la base d'une fermeture élégante : lorsque le récepteur n'a plus besoin de données, le sémaphore close permet à tous les senders bloqués d'échouer et de retourner immédiatement, au lieu d'attendre indéfiniment.

L'essence du backpressure est la plus claire dans mpsc. La section suivante montrera que le contrôle de capacité de mpsc est implémenté avec un sémaphore dont le nombre de permis est égal à la taille du buffer.

La famille des canaux : différents compromis entre file de waiters et réveil par Waker

Modèle intuitif : quatre types de canaux, quatre stratégies d'attente

oneshotest une « enveloppe à usage unique » — on ne peut envoyer qu'une seule lettre, le sender n'attend pas (sendest synchrone), le récepteurawaitattend la lettre.mpscest un « tapis roulant borné » — le sender attend lorsque le tapis est plein, le récepteur attend lorsqu'il est vide, la capacité est contrôlée par un sémaphore.broadcastetwatchsont un « haut-parleur de diffusion » — un sender, plusieurs récepteurs, mais leur traitement du « retard » est radicalement différent.

Le matériel source de cette section se concentre suroneshotetmpsc::bounded, que nous allons décomposer un par un.

oneshot : une poignée de main minimaliste encodée par des bits d'état

oneshotLa structureInnerde est au cœur de la compréhension de sa conception :

📎 tokio/src/sync/oneshot.rs:386-409

rust
struct Inner<T> {
    state: AtomicUsize,
    value: UnsafeCell<Option<T>>,
    tx_task: Task,
    rx_task: Task,
}

stateest unAtomicUsize, qui encode tout l'état du canal avec des bits de drapeau. Les quatre bits de drapeau sont définis à la fin du fichier :

📎 tokio/src/sync/oneshot.rs:1488-1505

rust
const RX_TASK_SET: usize = 0b00001;
const VALUE_SENT: usize = 0b00010;
const CLOSED: usize = 0b00100;
const TX_TASK_SET: usize = 0b01000;

valueestUnsafeCell<Option<T>>,tx_tasketrx_tasksont de typeTask, à l'intérieur se trouveUnsafeCell<MaybeUninit<Waker>> 📎 tokio/src/sync/oneshot.rs:411-411. NoterMaybeUninit— le Waker peut être non initialisé, sa validité est déterminée par le bitstatedansRX_TASK_SET/TX_TASK_SET📎 tokio/src/sync/oneshot.rs:396-399。

L'essence de cette conception:VALUE_SENTLe bit indique non seulement « la valeur a été envoyée », mais détermine aussi à qui appartient l'accès àUnsafeCell. Le commentaire est très explicite📎 tokio/src/sync/oneshot.rs:1491-1496: siVALUE_SENTest positionné,UnsafeCellne peut être accédé que par le récepteur ; s'il n'est pas positionné, il ne peut être accédé que par le sender. Ainsi, un seul bit atomique permet un transfert de propriété sans verrou, évitant un verrou supplémentaire.

sendLe flux de :

📎 tokio/src/sync/oneshot.rs:622-646

rust
pub fn send(mut self, t: T) -> Result<(), T> {
    let inner = self.inner.take().unwrap();
    inner.value.with_mut(|ptr| unsafe {
        *ptr = Some(t);
    });
    if !inner.complete() {
        unsafe {
            return Err(inner.consume_value().unwrap());
        }
    }
    Ok(())
}

écrit d'abord la valeur dansUnsafeCell(à ce momentVALUE_SENTn'est pas positionné, le récepteur n'y accède pas), puis appellecomplete()pour tenter de positionnerVALUE_SENT。complete()est une boucle CAS :

📎 tokio/src/sync/oneshot.rs:1516-1549

rust
fn set_complete(cell: &AtomicUsize) -> State {
    let mut state = cell.load(Ordering::Relaxed);
    loop {
        if State(state).is_closed() {
            break;
        }
        match cell.compare_exchange_weak(
            state, state | VALUE_SENT, Ordering::AcqRel, Ordering::Acquire,
        ) {
            Ok(_) => break,
            Err(actual) => state = actual,
        }
    }
    State(state)
}

Pourquoi utiliser CAS plutôt qu'un simplefetch_or? Le commentaire l'explique clairement📎 tokio/src/sync/oneshot.rs:1517-1529: si le canal est déjàCLOSED, ilne faut paspositionnerVALUE_SENTà nouveau. Car une fois positionné, le récepteur pensera pouvoir accéder àUnsafeCell, alors que le sender s'apprête à reprendre la valeur (consume_value), et un accès simultané des deux côtés provoquerait une data race. Donc la boucle CAS, en découvrantCLOSED, fait un break anticipé sans positionner.

complete()Après le retour de , si le positionnement a réussi et queRX_TASK_SETest positionné, on réveille le récepteur :

📎 tokio/src/sync/oneshot.rs:1300-1315

rust
fn complete(&self) -> bool {
    let prev = State::set_complete(&self.state);
    if prev.is_closed() {
        return false;
    }
    if prev.is_rx_task_set() {
        unsafe {
            self.rx_task.with_task(Waker::wake_by_ref);
        }
    }
    true
}

Lepoll_recvdu récepteur est le cœur de la machine à états :

📎 tokio/src/sync/oneshot.rs:1317-1384

Il charge d'abord l'état, siis_complete()alors directementconsume_valueretourne ; siis_closed()retourneErr; sinon entre dans la branche « enregistrer le Waker ». Lors de l'enregistrement, on vérifie d'abordis_rx_task_set(), si déjà défini et quewill_wakejuge qu'il s'agit du même Waker, on ne le redéfinit pas ; si différent, on unset puis set. Il y a ici une gestion subtile de race : après unset, si l'on découvre queis_complete()est devenu vrai, il fautre-set le bit de drapeau 📎 tokio/src/sync/oneshot.rs:1342-1344, sinon le Waker fuira lors du Drop (car le Drop dépend du bit de drapeau pour décider s'il faut drop le Waker).

Ce modèle « unset puis re-set » apparaît aussi danspoll_closed📎 tokio/src/sync/oneshot.rs:839-848, c'est la technique standard de oneshot pour gérer les réveils concurrents.

mpsc::bounded : backpressure piloté par sémaphore

Le contrôle de capacité de mpsc est entièrement confié au sémaphore.channelLa fonction crée un sémaphore dont le nombre de permis est égal à la taille du buffer :

📎 tokio/src/sync/mpsc/bounded.rs:159-171

rust
pub fn channel<T>(buffer: usize) -> (Sender<T>, Receiver<T>) {
    assert!(buffer > 0, "mpsc bounded channel requires buffer > 0");
    let semaphore = Semaphore {
        semaphore: semaphore::Semaphore::new(buffer),
        bound: buffer,
    };
    let (tx, rx) = chan::channel(semaphore);
    let tx = Sender::new(tx);
    let rx = Receiver::new(rx);
    (tx, rx)
}

Semaphoreest un wrapper interne à mpsc, qui détient à la fois le sémaphore sous-jacent etbound(capacité maximale)📎 tokio/src/sync/mpsc/bounded.rs:176-179。boundest utilisé pour la requêtemax_capacity, tandis queavailable_permitsdonne la capacité actuelle📎 tokio/src/sync/mpsc/bounded.rs:591-593。

Le chemin d'envoisendfait d'abordreservepuissend:

📎 tokio/src/sync/mpsc/bounded.rs:816-824

rust
pub async fn send(&self, value: T) -> Result<(), SendError<T>> {
    match self.reserve().await {
        Ok(permit) => {
            permit.send(value);
            Ok(())
        }
        Err(_) => Err(SendError(value)),
    }
}

reserveappelle en internereserve_inner(1), qui vérifie d'abordn > max_capacityretourne directement une erreur, puisacquire(n) 📎 tokio/src/sync/mpsc/bounded.rs:1272-1311. Il y a ici un ingénieuxWakeReceiverOnDropguard :

📎 tokio/src/sync/mpsc/bounded.rs:1286-1301

rust
struct WakeReceiverOnDrop<'a, T> {
    chan: &'a chan::Tx<T, Semaphore>,
}
impl<T> Drop for WakeReceiverOnDrop<'_, T> {
    fn drop(&mut self) {
        use chan::Semaphore;
        let semaphore = self.chan.semaphore();
        if semaphore.is_closed() && semaphore.is_idle() {
            self.chan.wake_rx();
        }
    }
}

Le commentaire explique la motivation📎 tokio/src/sync/mpsc/bounded.rs:1279-1285: sireserveest annulé après avoir obtenu une partie des permis (par exempleselect!échoue), leAcquiresous-jacent restituera ces permis lors du Drop, maisnenotifiera pas le récepteur comme le feraitPermit. Si à ce moment le canal est fermé et inactif, le récepteur pourrait ne jamais recevoir la notification « canal fermé ». Ce guard ajoute ce réveil lors du Drop. En cas de succès, on utilisemem::forget(guard)pour annuler le guard📎 tokio/src/sync/mpsc/bounded.rs:1306-1306, car le chemin de succès voit la responsabilité de notification reprise parPermit.

PermitLe Drop de fait la même chose :

📎 tokio/src/sync/mpsc/bounded.rs:1732-1745

rust
impl<T> Drop for Permit<'_, T> {
    fn drop(&mut self) {
        use chan::Semaphore;
        let semaphore = self.chan.semaphore();
        semaphore.add_permit();
        if semaphore.is_closed() && semaphore.is_idle() {
            self.chan.wake_rx();
        }
    }
}

Permit::sendutilisemem::forgetpour sauter le Drop, évitant de restituer les permis📎 tokio/src/sync/mpsc/bounded.rs:1721-1728。

Le chemin de réceptionrecvutilisepoll_fnpour envelopperchan.recv(cx) 📎 tokio/src/sync/mpsc/bounded.rs:243-246。poll_recvdélègue directement à📎 tokio/src/sync/mpsc/bounded.rs:650-652. La vraie logique de file d'attente se trouve dans le modulechan(non développé dans ce chapitre), mais on peut déduire : le Waker du récepteur est stocké danschan::Rx, et est réveillé lorsque le sender faitsend.

try_sendmontre le chemin non bloquant :

📎 tokio/src/sync/mpsc/bounded.rs:924-934

rust
pub fn try_send(&self, message: T) -> Result<(), TrySendError<T>> {
    match self.chan.semaphore().semaphore.try_acquire(1) {
        Ok(()) => {}
        Err(TryAcquireError::Closed) => return Err(TrySendError::Closed(message)),
        Err(TryAcquireError::NoPermits) => return Err(TrySendError::Full(message)),
    }
    self.chan.send(message);
    Ok(())
}

try_acquireLes deux types d'erreur de se mappent précisément àClosedetFull, distinguant les deux échecs « canal fermé » et « buffer plein ».

Réflexion de conception : cancel safety et perte de messages

La documentation de mpsc insiste à plusieurs reprises sur la cancel safety📎 tokio/src/sync/mpsc/bounded.rs:776-784:senden cas d'échec dansselect!,le message sera perdu. Pour éviter la perte, il faut utiliserreservepour obtenirPermitpuissend— carPermita déjà réservé la capacité,sendest synchrone et ne peut pas être interrompu.

recvest en revanche cancel safe📎 tokio/src/sync/mpsc/bounded.rs:199-204: sirecvéchoue dansselect!, il est garanti qu'aucun message n'a été consommé. C'est parce que lerecvdepoll_recvne retourneReady,Pendingque lorsqu'un message est réellement obtenu, et ne touche pas à la file dans le cas contraire.

oneshotLeReceiverde en tant que Future est aussi cancel safe📎 tokio/src/sync/oneshot.rs:246-251. Mais attention :oneshotlesendde est synchrone, donc il n'y a pas de problème de « send annulé » — soit il est envoyé, soitErrretourne la valeur d'origine.

Réflexions de conception et pièges en production

Piège 1 : utiliser un Mutex asynchrone pour protéger des données pures.La documentation recommande explicitement📎 tokio/src/sync/mutex.rs:26-36: si ce qui est protégé est constitué de données pures (sans.awaitbesoin), utiliserstd::sync::Mutexouparking_lotest plus rapide. Le coût d'un Mutex asynchrone réside dans les opérations atomiques du sémaphore et l'éventuelle planification de tâches. Ce n'est que lorsqu'il faut maintenir le verrou pendant.await(par exemple, maintenir le verrou pour accéder à une connexion de base de données) qu'il faut utiliser un Mutex asynchrone.

Piège 2 : maintenir le verrou à travers.awaitprovoque un interblocage.C'est le piège le plus dangereux du Mutex asynchrone. Si la tâche A, après avoir pris le verrou,.awaitattend un événement qui nécessite l'achèvement de la tâche B, et que la tâche B attend à son tour ce verrou, il y a interblocage.std::sync::MutexLe guard deSendn'est pas.await(dans une tâche déplaçable), le compilateur empêche de maintenir le verrou à traversSend 📎 tokio/src/sync/mutex.rs:314-314; mais le guard d'un Mutex asynchrone est

, le compilateur ne vous en empêche pas, il faut garantir soi-même l'absence d'attente circulaire.reservePiège 3 :send。 Permitoublier après📎 tokio/src/sync/mpsc/bounded.rs:1732-1745Le Drop de

rend le permisoneshot, donc il n'y a pas de fuite de capacité. Mais si le canal est déjà fermé et inactif, le Drop réveille le récepteur — ce réveil est nécessaire, sinon le récepteur pourrait ne jamais recevoir la notification de fermeture.pollPiège 4 :Pending。Le📎 tokio/src/sync/oneshot.rs:236-242depollpeut être faussementPendingLa documentation précise

: même si le message a été envoyé,forget_permitspeut retourner forget_permits(n). Ce n'est pas un bug, mais un phénomène normal dans une situation de concurrence — l'appelant sera réveillé pour réessayer, le message n'est pas perdu, juste retardé.📎 tokio/src/sync/semaphore.rs:576-578Piège 5 :

la sémantique de

.tokio::syncTente de réduire n permis, retourne le nombre réellement réduit. Il ne bloque pas et ne réveille pas les attendeurs — il « avale » simplement les permis. Utilisé pour réduire dynamiquement la capacité du sémaphore.。

  • MutexRésumé de ce chapitreMutexGuardCe chapitre révèlerelease(1)le modèle central de
  • Semaphore:SemaphorePermitToutes les primitives d'attente asynchrone sont construites sur « file d'attente d'attendeurs + réveil par Waker », et l'implémentation concrète de la file varie selon le scénariopermitsréutilise un sémaphore avec un nombre de permis de 1,forget/merge/split,MAX_PERMITSne détient qu'une référence, au Drop
  • oneshot, FIFO équitable mais sans empoisonnement.AtomicUsizeest un compteur de permis + file d'attente d'attente,VALUE_SENTutiliseUnsafeCellun compteur pour prendre en chargeCLOSEDun décalage à droite de 3 bits pour laisser place aux indicateurs d'état.
  • mpsc::boundedutilise un seulWakeReceiverOnDropindicateur de bits pour encoder l'état,

les bits déterminent simultanément

l'attribution du droit d'accès, la boucle CAS empêche de positionner aprèsset_complete.fetch_or(VALUE_SENT)utilise un sémaphore dont le nombre de permis est égal au buffer pour implémenter la contre-pression,

le garde gère la compensation de réveil lors d'une annulation.:set_completeRéflexions et auto-évaluation de ce chapitrefetch_orQ : Si l'on remplace📎 tokio/src/sync/oneshot.rs:1517-1529la boucle CAS deVALUE_SENTpar un simpleCLOSED, dans quel scénario de concurrence cela déclencherait-il une course de données ?fetch_orAnalyse de référenceclose()La raison d'utiliser une boucle CAS plutôt queCLOSED 📎 tokio/src/sync/oneshot.rs:1569-1574est indiquée dans les commentairessend: il faut vérifierfetch_or(VALUE_SENT)avant de positionnerVALUE_SENT. Si l'on remplace par unCLOSEDinconditionnel, considérons cette séquence : le récepteur appelle d'abordpoll_recvpositionneis_complete(), l'émetteur ensuiteconsume_valueécrit la valeur et📎 tokio/src/sync/oneshot.rs:1325-1330. À ce momentcomplete()etprev.is_closed()sont positionnés simultanément, leconsume_valuedu récepteur voit📎 tokio/src/sync/oneshot.rs:1300-1315comme vrai, appelleUnsafeCellpour retirer la valeurCLOSED; et leVALUE_SENTde l'émetteur, après retour, parce que

Q: reserve_innerest vrai, appelleWakeReceiverOnDroppour récupérer la valeurmem::forget. Les deux côtés accèdent simultanément àforget, course de données. La boucle CAS, en découvrant

, fait un break anticipé, ne positionne pas, garantissant ainsi l'invariant « après fermeture, l'émetteur a le droit d'accès exclusif ».📎 tokio/src/sync/mpsc/bounded.rs:1290-1298Leacquire(n)garde dansOkutilisePermitpour sauter sur le chemin de succès ; que se passerait-il si l'on retirait cePermit?reserve_innerAnalyse de référenceis_idle: la logique Drop du garde est « si le sémaphore est fermé et inactif, réveiller le récepteur »Permit. Sur le chemin de succès,mem::forgetretourneforget, l'appelant obtient le permis et construiraacquire, c'estOkqui est responsable des notifications ultérieures. Si l'on ne retire pas le garde, le garde au retour de la fonction fait un Drop, vérifie une fois de plus « fermé et inactif » — mais à ce moment le permis est déjà détenu par l'appelant dePermit, le sémaphore n'est pas inactif (

est faux), donc en pratique il n'y aura pas de réveil en double. Mais plus important encore est la clarté sémantique : la responsabilité de réveil sur le chemin de succès doit incomber entièrement àMutexGuard, le garde ne s'occupe que de la compensation sur le chemin « annulation/échec ».SemaphorePermitexprime clairement l'intention « ce chemin n'a pas besoin de garde ». Si l'on retire

et que le sémaphore se trouve justement dans l'état limite « fermé et inactif » (par exempleretourneMutexGuardmais le permis n'a pas encore été pris en charge par&Mutex), cela peut produire un réveil superflu — bien que cela ne cause pas d'erreur, cela gaspille une planification.self.lock.s.release(1) 📎 tokio/src/sync/mutex.rs:959-961Q : Si l'on changeaitMutexGuard::mappour détenir un objet permis de sémaphore (commeMappedMutexGuard), quels problèmes cela introduirait-il ?📎 tokio/src/sync/mutex.rs:869-883Analyse de référenceMappedMutexGuard: actuellement&Semaphorene détient que📎 tokio/src/sync/mutex.rs:190-199, au Drop appelleself.s.release(1) 📎 tokio/src/sync/mutex.rs:1252-1262. Si l'on changeait pour détenir un objet permis, cela introduirait plusieurs problèmes. Premièrement,MappedMutexGuardla série de méthodespermits: usizedoit décomposer le guard enMutexGuard, ne protégeant que le sous-champSend/Sync. Dans la conception actuelle,unsafe impln'a qu'à détenir📎 tokio/src/sync/mutex.rs:260-263et le pointeur de sous-champmap。

, au Droptokio::sync. Si le guard détenait un objet permis, le map devrait transférer la propriété de l'objet permis, etspawn_blockingla disposition des champs deblock_onserait plus complexe. Deuxièmement, l'objet permis porte généralement un compteur

L'emplacement de stockage du Waker varie selon la primitive : Mutex/Semaphore le stockent dans la file d'attente du sémaphore sous-jacent, oneshot dans les champs tx_task/rx_task de Inner, mpsc dans les files d'envoi/réception du module chan. Mais le mécanisme de réveil est unifié : lors d'un changement d'état, le Waker est extrait et wake_by_ref est appelé, l'exécuteur replanifie la tâche. À ce stade, l'attente et le réveil au sein des primitives asynchrones sont clairement visibles. Cependant, tout le code ne peut pas être rendu asynchrone — le chapitre suivant examinera comment utiliser spawn_blocking pour pontifier les opérations bloquantes, et comment block_on pilote un Future dans un contexte non asynchrone.

CHAPTER 08

Chapitre 8 : Blocage et pontification : le pool de threads spawn_blocking et les frontières de block_on

Projet concerné : tokio-rs/tokio · Progression du livre : Chapitre 8 / 14 · État de vérification : ancrage réel des numéros de ligne FACT

Nous avons vu dans le chapitre précédent que la raison pour laquelle le Mutex asynchrone et les canaux peuvent attendre sans occuper un thread réside dans le stockage du Waker dans la file d'attente, puis la replanification de la tâche par le réveilleur une fois la condition satisfaite. Mais tout cela présuppose que la tâche peut céder activement le thread en état Pending. Dès que le code appelle std::fs::read, libsqlite3 ou une boucle de compression purement CPU, il monopolise le thread worker jusqu'à son retour, affamant toutes les autres tâches sur ce thread. La solution de Tokio consiste à externaliser ce type de travail vers un pool de threads bloquants dédié, et à utiliser block_on pour piloter un Future dans un contexte non asynchrone. Ce chapitre décompose ces deux frontières.

8.1 Disposition mémoire du pool de threads bloquants : Inner et la file à double implémentation

Modèle intuitif:spawn_blockingLe pool de threads ressemble à un « pool d'aides externalisées » d'un restaurant. Les serveurs (threads worker) ne s'occupent que de la prise de commande et du service, et lorsqu'ils rencontrent un plat nécessitant une cuisson lente, ils écrivent un bon de travail et le déposent dans la fenêtre de transfert de la cuisine (file d'attente), les aides (threads bloquants) récupérant les bons depuis cette fenêtre. Sans ce pool, le serveur devrait cuisiner lui-même, et tout le restaurant s'arrêterait.

Structure centrale. L'ensemble du pool est détenu parBlockingPoolqui ne stocke que deux choses : unSpawnerclonable (point d'entrée de soumission) et unshutdown_rx(récepteur du signal de fermeture)📎 tokio/src/runtime/blocking/pool.rs:20-23。Spawnercontient en interneArc<Inner>, tous les soumetteurs partagent le même état📎 tokio/src/runtime/blocking/pool.rs:26-28。

Innerest l'état complet du pool, les champs méritent d'être examinés un par un📎 tokio/src/runtime/blocking/pool.rs:77-104:

  • inner_impl: InnerImpl: implémentation de file + notification + topologie de verrou, c'est une énumération avecLockedetShardeddeux variantes📎 tokio/src/runtime/blocking/pool.rs:107-110. C'est l'abstraction la plus cruciale de ce chapitre — elle unifie deux topologies, « file à verrou unique » et « file fragmentée », sous une même interface.
  • thread_cap: usize: nombre maximal de threads, soitmax_blocking_threads。
  • scheduler_threads: usize: nombre de threads worker du planificateur, utilisé pour déduire dans les métriques, afin quenum_blocking_threadsne compte que les threads bloquants📎 tokio/src/runtime/blocking/pool.rs:455-460。
  • keep_alive: Duration: durée de survie des threads inactifs, par défautKEEP_ALIVE = 10s 📎 tokio/src/runtime/blocking/pool.rs:231。
  • metrics: SpawnerMetrics: trois compteurs atomiques —num_threads、num_idle_threads、queue_depth 📎 tokio/src/runtime/blocking/pool.rs:31-35。
〔Inférence de conception et compromis architecturaux〕

Pourquoi utiliser des compteurs atomiques plutôt que des champs sous verrou ? num_idle_threadsest lu sur le chemin critique despawn_task(pour déterminer s'il faut réveiller un thread inactif) ; s'il était caché dansMutex, chaque soumission devrait d'abord acquérir le verrou puis lire. En le rendantMetricAtomicUsize, le chemin de soumission peut effectuer un jugement rapide sans détenir le verrou de la file. Le coût est qu'il n'y a aucune garantie d'atomicité entre ces compteurs et l'état de la file, c'est pourquoi le code utilise le compteurnum_notifypour compenser — voir ci-dessous.

État de gestion des threads。ThreadManagementStateest extrait séparément pour être réutilisé par les deux implémentations de file📎 tokio/src/runtime/blocking/pool.rs:135-150:

  • shutdown: bool: indicateur de fermeture.
  • shutdown_tx: Option<shutdown::Sender>: chaque thread worker en détient un clone, une fois tous dropésshutdown_rxreçoit la notification.
  • last_exiting_thread: Option<JoinHandle<()>>: handle du dernier thread ayant expiré par timeout.
  • worker_threads: HashMap<usize, JoinHandle<()>>: handles de tous les workers vivants.
  • worker_thread_index: usize: allocateur d'ID de thread à incrémentation monotone.

last_exiting_threadLa motivation de conception de📎 tokio/src/runtime/blocking/pool.rs:135-150。worker_timed_outest clairement écrite dans les commentaires : un thread expiré par timeout joindra le précédent thread expiré par timeout, évitant les faux positifs de Valgrindlast_exiting_threadest précisément l'implémentation de ce join en chaîne — il retire son propre handle, échange l'ancien📎 tokio/src/runtime/blocking/pool.rs:172-178。

et le retourne à l'appelant pour le joinEncapsulation de tâcheTask. La file stocke desUnownedTask<BlockingSchedule>, qui encapsule unMandatoryet un📎 tokio/src/runtime/blocking/pool.rs:187-191。Mandatoryindicateurshutdown_or_run_if_mandatorydétermine si, lors de la fermeture, cette tâche est abandonnée ou exécutée de force :NonMandatoryappelleshutdown()lors deMandatory, et appellerun() 📎 tokio/src/runtime/blocking/pool.rs:223-228lors despawn_blocking. C'est la différence entrespawn_mandatory_blocking(non forcé) et📎 tokio/src/runtime/blocking/pool.rs:233-265。

(forcé, utilisé par fs)。LockedImplDisposition mémoire de l'implémentation à verrou uniqueMutex<LockedInner>est la topologie la plus primitive : unCondvar 📎 tokio/src/runtime/blocking/pool.rs:113-116。LockedInnerplus unVecDeque<Task>、num_notify: u32contientthread_mgmt_state 📎 tokio/src/runtime/blocking/pool.rs:118-124etnum_notify. Notez quethread_mgmt_stateetnum_idle_threadssont sous le même verrou, tandis que

est une quantité atomique hors verrou — cette disposition hybride « une partie de l'état sous verrou, une partie hors verrou » est précisément la source de toutes les subtilités de concurrence qui suivent.

8.2 Chemin de soumission : de spawn_blocking au réveil de threadScénariotokio::task::spawn_blocking(move || heavy_compute(data)): un appel à

dans une tâche asynchrone, que se passe-t-il à cet instant ?。Spawner::spawn_blockingPremière étape : décision de boxing et construction de la tâchefn_sizemesure d'abord la taille de la closureAutoBox::<F>::SHOULD_BOX, puis selonBoxdécide s'il faut📎 tokio/src/runtime/blocking/pool.rs:359-389la closure

. C'est la stratégie générique de Tokio de « boxing automatique des grands Future » : lorsque la closure est trop grande, on la boxe pour éviter le gonflement de la structure de tâche.spawn_blocking_innerEn entrant dansblocking_task, on alloue d'abord un ID de tâche, puis on utilisetask::unownedpour encapsuler la closure en un Future, et enfin on utiliseUnownedTaskpour construireJoinHandle 📎 tokio/src/runtime/blocking/pool.rs:440-449et(JoinHandle<R>, Result<(), SpawnError>). Notez que ce qui est retourné ici est le tuple

— le handle et le résultat de soumission sont retournés séparément.Deuxième étape : trois traitements du résultat de soumissionspawn_blocking. Retour àspawn_result, on effectue un match sur📎 tokio/src/runtime/blocking/pool.rs:381-388:

  • Ok(()): normal, retourne le handle.
  • Err(ShuttingDown):ne pas paniquer, et retourne quand même le handle. Le commentaire explique qu'il s'agit d'une considération de compatibilité — le handle ne sera jamais résolu, mais l'appelant ne plantera pas parce que le runtime est en cours d'arrêt.
  • Err(NoThreads(e)): l'OS ne peut pas créer de thread et personne dans le pool ne prend le relais, donc panic direct.

Troisième étape : mise en file et décision de réveil。spawn_taskon passeon_no_idlela closure àInnerImpl::spawn_task, et c'est l'implémentation concrète qui décide quand l'appeler📎 tokio/src/runtime/blocking/pool.rs:462-506. RegardonsLockedImpl::spawn_taskla section critique de📎 tokio/src/runtime/blocking/pool.rs:603-639:

rust
let mut locked = self.mutex.lock();

if locked.thread_mgmt_state.shutdown {
    task.task.shutdown();
    return Err(SpawnError::ShuttingDown);
}

locked.queue.push_back(task);
metrics.inc_queue_depth();

if metrics.num_idle_threads() == 0 {
    on_no_idle(&mut locked.thread_mgmt_state)?;
} else {
    metrics.dec_num_idle_threads();
    locked.num_notify += 1;
    self.condvar.notify_one();
}

Il y a ici deux points clés. Premièrement, la vérification de fermeture a lieu avant la mise en file, et même si la tâche estMandatoryelle est directementshutdown()— le commentaire explique : elle n'a été planifiée qu'après le début de la fermeture, donc la rejeter est légitime📎 tokio/src/runtime/blocking/pool.rs:614-620. Deuxièmement, la décision de réveil dépend denum_idle_threadshors du verrou : s'il vaut 0, on appelleon_no_idlepour tenter de lancer un nouveau thread ; sinon on décrémente le compteur d'inactifs, on incrémentenum_notify、notify_one。

num_notifyPourquoi doit-il exister ?Parce queCondvarpeut produire des réveils spurieux (spurious wakeup). Si on utilisait seulementnotify_onesans compter, un thread réveillé spurieusement croirait à tort qu'il y a une tâche à prendre, découvrirait que la file est vide et se rendormirait, tandis que le thread réellement réveillé pourrait ne jamais recevoir la notification.num_notifyOn transforme le « réveil légitime » en jeton comptable : le côté émetteur+1, le côté réveillé ne considère le réveil comme légitime etnum_notify != 0que lorsque-1 📎 tokio/src/runtime/blocking/pool.rs:674-684。

Quatrième étape : lancer un nouveau thread。on_no_idlela closure s'exécute en tenant le verrou de la file📎 tokio/src/runtime/blocking/pool.rs:462-506. Elle vérifie d'abordnum_threads == thread_cap, et si la limite est atteinte, retourne directementOk(())— la tâche reste dans la file en attendant qu'un thread existant la traite, c'est la contre-pression. Sinon on cloneshutdown_tx, on appellespawn_threadpour créer le thread, et en cas de succès on incrémentenum_threads, on incrémenteworker_thread_index, on insère le handle dansworker_threads。

spawn_threadon utilisethread::Builderpour définir le nom du thread et la taille de pile, puis on spawn une closure : on entre dans le contexte du runtimert.enter(), on appelleinner.run(id), et enfin on dropshutdown_tx 📎 tokio/src/runtime/blocking/pool.rs:508-528。

Tolérance aux échecs de création de thread OS。spawn_threadpeut échouer. Le code classe les erreurs📎 tokio/src/runtime/blocking/pool.rs:488-500: si c'estWouldBlock(une erreur temporaire, déterminée paris_temporary_os_thread_error) et qu'il y a déjà un thread bloqué dans le pool, alors📎 tokio/src/runtime/blocking/pool.rs:750-752on ignore silencieusement— la tâche finira par être prise par un thread actuellement occupé. Sinon on retourne, ce qui finit par provoquer un panic.SpawnError::NoThreadsRésumons les branches de décision du chemin de soumission avec un graphe de flux de contrôle :

Copier

mermaid
flowchart TD
    call["Spawner::spawn_blocking(func)"] --> box{"AutoBox::SHOULD_BOX?"}
    box -->|是| boxed["Box::new(func)"]
    box -->|否| raw["func"]
    boxed --> inner["spawn_blocking_inner"]
    raw --> inner
    inner --> unowned["task::unowned -> Task + JoinHandle"]
    unowned --> spawn_task["InnerImpl::spawn_task"]
    spawn_task --> lock["LockedImpl: mutex.lock()"]
    lock --> shutting{"thread_mgmt_state.shutdown?"}
    shutting -->|是| discard["task.task.shutdown()"]
    discard --> err_sd["Err(ShuttingDown)"]
    shutting -->|否| push["queue.push_back(task)"]
    push --> idle{"num_idle_threads == 0?"}
    idle -->|是| on_no_idle["on_no_idle(thread_mgmt_state)"]
    on_no_idle --> cap{"num_threads == thread_cap?"}
    cap -->|是| backpressure["返回 Ok, 任务留队列"]
    cap -->|否| spawn_th["spawn_thread(shutdown_tx, rt, id)"]
    spawn_th --> th_ok{"spawn 成功?"}
    th_ok -->|是| reg["inc_num_threads, 注册 JoinHandle"]
    th_ok -->|否| tmp{"WouldBlock 且已有线程?"}
    tmp -->|是| ignore["忽略, 等忙碌线程取走"]
    tmp -->|否| err_nt["Err(NoThreads)"]
    idle -->|否| notify["dec_num_idle_threads, num_notify+=1, notify_one"]
    err_sd --> ret["返回 JoinHandle"]
    backpressure --> ret
    reg --> ret
    ignore --> ret
    err_nt --> panic_os["panic: OS can't spawn worker thread"]

Modèle intuitif

: chaque thread bloquant est un « aide en attente ». Quand il y a des ordres, il travaille en continu (BUSY), quand il n'y en a pas, il somnole (IDLE), et s'il somnole plus deil quitte le service (sortie par timeout). Sans récupération par timeout, le pool conserverait indéfiniment tous les threads créés au pic, gaspillant mémoire et coût de调度 noyau.keep_aliveStructure de la boucle principale

est une boucle。LockedImpl::run_worker, qui alterne en interne entre les deux phases BUSY et IDLE'main. Attention : ici BUSY/IDLE sont des📎 tokio/src/runtime/blocking/pool.rs:642-735phasesdans la boucle, pas des états d'énumération explicites, donc on décrit ci-dessous avec un organigramme plutôt qu'un diagramme d'états.Phase BUSY

: la boucle interneprend continuellement des tâcheswhile let Some(task) = locked.queue.pop_front(). Après en avoir pris une, on décrémente📎 tokio/src/runtime/blocking/pool.rs:655-661on drop le verrouqueue_depth,, on exécute, puis on reprend le verrou. L'étape de drop du verrou est cruciale — une tâche bloquante peut durer longtemps, il ne faut jamais l'exécuter en tenant le verrou.task.run()Phase IDLE

: la file est vide, on incrémente, on définitnum_idle_threads, puis on entre dans la boucle d'attenteis_counted_idle = true. Le cœur est📎 tokio/src/runtime/blocking/pool.rs:663-696, et après le retour on vérifie trois choses :condvar.wait_timeout(locked, keep_alive): réveil légitime. On décrémente

1. num_notify != 0, on définitnum_notify(car le côté émetteur a déjà décrémentéis_counted_idle = false), break vers BUSYnum_idle_threads2. non fermé et timeout : on appelle📎 tokio/src/runtime/blocking/pool.rs:674-684。

pour récupérer le handle du dernier thread sorti,worker_timed_outon sort de la bouclebreak 'main3. sinon c'est un réveil spurieux, on continue d'attendre.📎 tokio/src/runtime/blocking/pool.rs:689-693。

Vidage de la file lors de la fermeture

. Siest vrai, on entre dans la logique de vidagethread_mgmt_state.shutdown: on dépile les tâches une à une, on drop le verrou, on appelle📎 tokio/src/runtime/blocking/pool.rs:698-710— les tâches non forcées sont rejetées, les tâches forcées s'exécutent normalement. Puis break pour sortir de la boucle principale.task.shutdown_or_run_if_mandatory()Nettoyage à la sortie

. Avant que le thread ne se termine, on décrémente. Sinum_threads 📎 tokio/src/runtime/blocking/pool.rs:714est vrai, on décrémente aussiis_counted_idle, et on utilisenum_idle_threadspour affirmer qu'il n'y a pas de sous-dépassementassert_ne!(prev_idle, 0). Cette assertion est un garde-fou en phase de débogage : dès que📎 tokio/src/runtime/blocking/pool.rs:716-726la comptabilité est erronée, on panic immédiatement ici plutôt que de laisser l'erreur se propager silencieusement.num_idle_threadsEnfin, si on est en train de fermer et que

(le dernier thread),num_threads == 0on réveille l'initiateur de fermeture qui pourrait être en attentenotify_one. On retourne📎 tokio/src/runtime/blocking/pool.rs:728-730, etjoin_on_threadfait un join avant de se terminerInner::runPoignée de main de fermeture📎 tokio/src/runtime/blocking/pool.rs:755-771。

on appelle d'abord。BlockingPool::shutdownpour récupérer tous les handles de workerbegin_shutdownon définit le drapeau de fermeture, on drop📎 tokio/src/runtime/blocking/pool.rs:310-312。LockedImpl::begin_shutdownon réveille tous les threads en attenteshutdown_tx、notify_all. Ensuite📎 tokio/src/runtime/blocking/pool.rs:740-745on bloque en attendantshutdown_rx.wait(timeout)L'implémentation de📎 tokio/src/runtime/blocking/pool.rs:324。

shutdown::Receiver::waitest soignée📎 tokio/src/runtime/blocking/shutdown.rs:37-70: on traite d'abord le chemin rapide detimeout == 0qui retourne directement false ; puis on appelletry_enter_blocking_region()pour entrer dans la zone bloquante, et en cas d'échec, si on est actuellement en panic on retourne false, sinon on panic avec le message « on ne peut pas drop le runtime dans un contexte asynchrone »📎 tokio/src/runtime/blocking/shutdown.rs:44-57. Enfin, selon le timeout, on appelleblock_on_timeoutoublock_onpour piloter ce oneshot.

shutdown_txLe mécanisme deArc<oneshot::Sender<()>>est le suivant : chaque thread worker détient un clone de📎 tokio/src/runtime/blocking/shutdown.rs:12-14. Une fois tous les threads terminés, tous les clones sont drop,Arcle compteur revient à zéro,oneshot::Senderest drop,Receiverreçoit la notification. C'est le schéma classique « le Receiver est réveillé après que tous les Sender sont drop ».

mermaid
sequenceDiagram
    participant App as "应用线程 (drop Runtime)"
    participant Pool as "BlockingPool::shutdown"
    participant Locked as "LockedImpl"
    participant Worker as "阻塞 worker 线程"
    participant Rx as "shutdown::Receiver"

    App->>Pool: shutdown(timeout)
    Pool->>Locked: begin_shutdown()
    Locked->>Locked: thread_mgmt_state.begin_shutdown() 设 shutdown=true, shutdown_tx=None
    Locked->>Worker: condvar.notify_all()
    Locked-->>Pool: Some((last_exited_thread, workers))
    Pool->>Rx: wait(timeout)
    Worker->>Worker: 从 wait_timeout 醒来, 见 shutdown=true
    Worker->>Worker: 排空队列 shutdown_or_run_if_mandatory()
    Worker->>Worker: dec_num_threads, 退出 run_worker
    Worker->>Worker: drop(shutdown_tx) 克隆
    Worker-->>Rx: 最后一个 Sender drop, oneshot 完成
    Rx-->>Pool: 返回 true
    Pool->>Worker: join 所有 worker 句柄

8.4 block_on : piloter un Future dans un contexte non asynchrone

Modèle intuitif:block_onest la « porte principale » du runtime. Elle transforme le thread courant en exécuteur temporaire, et poll le Future passé en boucle jusqu'à completion. Sans elle,mainla fonction ne pourrait démarrer aucun code asynchrone.

Entrée et boxing。Runtime::block_onon teste également d'abord la taille, et selonSHOULD_BOXon décide siBox::pin, puis on entre dansblock_on_inner 📎 tokio/src/runtime/runtime.rs:343-350。block_on_inneril y a deux blocs de trace conditionnellement compilés (taskdump et tracing), puisself.enter()on entre dans le contexte du runtime, et enfin on dispatche selon le type de scheduler📎 tokio/src/runtime/runtime.rs:353-383:

rust
let _enter = self.enter();

match &self.scheduler {
    Scheduler::CurrentThread(exec) => exec.block_on(&self.handle.inner, future),
    Scheduler::MultiThread(exec) => exec.block_on(&self.handle.inner, future),
}

Les deux types de schedulerblock_onLa sémantique est différente, la documentation le dit clairement📎 tokio/src/runtime/runtime.rs:302-320:

  • Planificateur multi-thread: Future s'exécute dans le contexte du pilote d'E/S et du minuteur,block_onles tâches déjà lancées via spawn continuent de s'exécuter après le retour.
  • Planificateur de thread courant:block_onpeut être appelé concurremment par plusieurs threads, le premier appelant acquiert la propriété du pilote d'E/S et du minuteur, les autres threads s'y « raccrochent ». Une fois le premierblock_onterminé, les autres threads peuvent « voler » le pilote.block_onles tâches déjà lancées via spawn sont suspendues après le retour, un nouvel appel àblock_onles reprendra.

Contrainte clé : ne peut pas être appelé dans un contexte asynchrone. La documentation précise explicitement queblock_onun appel dans un contexte d'exécution asynchrone provoquera un panic📎 tokio/src/runtime/runtime.rs:321-324. La raison est directe :block_onbloque le thread courant jusqu'à ce que le Future soit terminé ; si le thread courant est lui-même un thread worker, cela bloquera tout l'exécuteur — c'est précisémentspawn_blockingle problème que

cherche à résoudre, donc les deux sont mutuellement exclusifs.。Runtime::dropChemin de fermeture📎 tokio/src/runtime/runtime.rs:506-521dispatché selon le type de planificateurtry_set_current: le planificateur de thread courant doit d'abordshutdown_timeoutentrer dans le contexte puis shutdown (garantissant que les tâches sont drop dans le contexte du runtime) ; le planificateur multi-thread fait shutdown directement (les threads worker sont déjà dans le contexte).📎 tokio/src/runtime/runtime.rs:457-461,shutdown_backgroundFermer d'abord le planificateur puis le pool bloquantshutdown_timeout(Duration::from_nanos(0)) 📎 tokio/src/runtime/runtime.rs:494-496。

équivaut à

Réflexions de conception, récupération d'erreurs et pièges en productionspawn_blockingPourquoiShuttingDownle 📎 tokio/src/runtime/blocking/pool.rs:383-384despawn_blockingne panic pas ?JoinHandleLe commentaire indique que c'est pour des raisons de compatibilité.Resultretourneawaitplutôt queblock_on, car paniquer lors de la fermeture transformerait un état prévisible — « le runtime est en cours de fermeture » — en crash. Retourner un handle qui ne se résout jamais fait que l'appelant

max_blocking_threadsrestera suspendu indéfiniment — mais à ce moment le runtime est déjà fermé, tout lese terminera aussi, donc en pratique il n'y aura pas de fuite permanente.spawn_blockingSémantique de backpressure de📎 tokio/src/task/blocking.rs:94-100. La valeur par défaut est très grande (512), car

spawn_blockingest souvent utilisé pour les E/S de fichiers. Mais la documentation avertit : lors de l'exécution de tâches intensives en CPU, il faut utiliser un sémaphore pour limiter la concurrence, sinon un grand nombre de threads sera créé. Une fois la limite atteinte, les tâches font la queue dans la file, formant une backpressure — mais attention, cette backpressure n'agit que sur le pool bloquant, elle ne remonte pas vers le planificateur asynchrone.abortn'est pas annulable📎 tokio/src/task/blocking.rs:106-120. La documentation précise :shutdown_timeoutest sans effet sur une tâche bloquante déjà démarrée, la tâche continuera jusqu'au bout

num_idle_threads. Seules les tâches pas encore démarrées peuvent être empêchées par abort. Lors de la fermeture, le runtime attendra toutes les tâches bloquantes déjà démarrées,。is_counted_idleaprès le timeout ces threads seront fuités.num_idle_threadsLe piège de comptabilité denum_notify != 0L'existence du flagis_counted_idle = falseindique que ce comptage est facile à erroner. Le déposant décrémente📎 tokio/src/runtime/blocking/pool.rs:679-682lors du réveil, le réveillé voitassert_ne!(prev_idle, 0)puis met📎 tokio/src/runtime/blocking/pool.rs:722-725, évitant une double décrémentationnum_idle_threads. Si ce chemin a un bug,

paniquera à la sortie

last_exiting_thread. En production, si vous voyez «underflowed on thread exit », cela signifie que la logique de comptabilité du pool est corrompue.📎 tokio/src/runtime/blocking/pool.rs:172-178〔Inférences de conception et compromis architecturaux〕

InnerImplLe coût du join en chaîne. Un thread qui se termine par timeout joindra le thread précédent terminé par timeoutLocked. Cela forme une chaîne de join : chaque thread sortant doit attendre que le précédent se termine réellement. Dans les scénarios de création/destruction fréquente de threads bloquants, cette chaîne peut s'allonger, entraînant une accumulation de retard à la sortie des threads. C'est un compromis fait pour éviter les faux positifs de Valgrind ; l'impact en production normale est limité, mais cela mérite attention sous des charges où les threads expirent fréquemment.ShardedLa signification de l'abstraction par énumération📎 tokio/src/runtime/blocking/pool.rs:537-539。spawn_task、run_worker、begin_shutdown. Le commentaire explique que📎 tokio/src/runtime/blocking/pool.rs:548-582la variante

a un comportement identique à avant la refactorisation, tandis que

la variantespawn_blockingréserve un emplacement symétrique pour une future file concurrenteInnerles trois méthodes sont dispatchées via l'énumérationLockedImpl. Cette conception « dispatch par énumération + section critique propre à chaque variante » fait que l'ajout d'une nouvelle topologie de file ne nécessite pas de modifier les appelants.CondvarRésumé de ce chapitrenum_notifyCe chapitre a décomposé les deux frontières par lesquelles Tokio accueille du code synchrone.max_blocking_threadsdépose les closures dans un pool de threads bloquants séparé :block_ondétient la file, la limite de threads, la durée de vie et les métriques atomiques ;shutdown_txutilise un verrou unique +Arcpour implémenter la file,oneshotle compteur compense les réveils spurieux ; le worker boucle entre BUSY/IDLE, et après expiration du timeout d'inactivité sort par join en chaîne ;

une fois la limite atteinte, les tâches font la queue formant une backpressure.

quant à lui, pilote les Future dans un contexte non asynchrone, la sémantique du planificateur multi-thread et du planificateur de thread courant est différente, et il est strictement interdit de l'appeler dans un contexte asynchrone. Le chemin de fermeture passe parLockedImpl::spawn_taskleif metrics.num_idle_threads() == 0deon_no_idledont le compteur atteint zéro déclenche

, réalisant la poignée de main « réveiller l'initiateur de la fermeture après la sortie de tous les workers ».:on_no_idleRéflexions et auto-évaluation de ce chapitrenum_threads == thread_capQ1 : Si dans📎 tokio/src/runtime/blocking/pool.rs:471-487on changeait le test dethread_capen toujours vrai (c'est-à-dire appelernotify_oneà chaque fois), que se passerait-il dans un scénario de dépôt à haute concurrence ? Pourquoi ?on_no_idleAnalyse de référenceelsevérifienum_notify += 1; notify_one 📎 tokio/src/runtime/blocking/pool.rs:627-636, et si la limite n'est pas atteinte crée un nouveau thread

Q2: LockedImpl::run_worker. Si le test était toujours vrai, même avec des threads inactifs on tenterait de lancer de nouveaux threads, faisant grimper rapidement le nombre de threads jusqu'àtask.run(). Plus grave, les threads inactifs ne seraient pas réveillés pardrop(locked) 📎 tokio/src/runtime/blocking/pool.rs:657-658(car on passerait par la branchedropau lieu de la branche

du:task.run()), les tâches dans la file pourraient rester sans personne pour les traiter, jusqu'à ce qu'un nouveau thread démarre et découvre que la file n'est pas vide. Cela créerait un état de fausse mort « threads saturés mais tâches toujours en file ». Le sens du test original est précisément : privilégier le réveil des threads inactifs quand il y en a, évitant des créations de threads inutiles.spawn_blockingDans la phase BUSY, avant d'exécuterLockedImpl::spawn_taskon faitself.mutex.lock() 📎 tokio/src/runtime/blocking/pool.rs:612. Si on supprimait cestd::sync::MutexNon réentrant, provoque un interblocage direct. De plus, exécuter une tâche longue en tenant le verrou bloque toutes les opérations de récupération de tâches des autres soumetteurs et workers ; même sans interblocage, cela sérialise tout le pool.drop(locked)est nécessaire.

Q3: shutdown::Receiver::waitDanstry_enter_blocking_region()retourne false en cas d'échec et si un panic est en cours, sinon panic📎 tokio/src/runtime/blocking/shutdown.rs:44-57. Pourquoi un traitement spécial en cas de panic ? Si l'on supprime cette branche, dans quels scénarios cela poserait-il problème ?

Analyse de référence:try_enter_blocking_regionL'échec signifie que l'on est actuellement dans un contexte asynchrone, où le blocage n'est pas autorisé. Normalement, il faudrait panic pour indiquer à l'utilisateur « on ne peut pas drop le runtime dans un contexte asynchrone ». Mais si le thread courant est déjà en train de panic (std::thread::panicking()est vrai), un nouveau panic provoquerait un double panic, et le comportement par défaut de Rust est d'abort directement le processus. Scénario : l'utilisateur drop un Runtime dans une tâche asynchrone, et cette tâche est elle-même en train de panic pour une autre raison ; le shutdown déclenché par le drop provoque alors un second panic. Retourner false permet au shutdown d'abandonner l'attente, évitant l'abort du processus et laissant à l'utilisateur la possibilité de voir le message de panic original. C'est un traitement typique de « panic safety ».

Le pool de threads bloquants et block_on délimitent les capacités du runtime asynchrone : le premier isole dans des threads dédiés le travail qui ne peut pas céder son thread, le second permet à des points d'entrée non asynchrones de piloter des Futures. Mais ces deux frontières ne sont souvent pas écrites à la main dans le code — dans le chapitre suivant, nous entrerons dans le monde des macros, pour voir comment #[tokio::main], select! et join! génèrent ce code d'exécution à la compilation.

CHAPTER 09

Chapitre 9 : La magie des macros : la génération de code derrière #[tokio::main], select! et join!

Projet concerné : tokio-rs/tokio · Progression du livre : Chapitre 9 / 14 · Statut de vérification : lignes FACT réellement ancrées

Dans le chapitre précédent, nous avons vu commentblock_onet le pool de threads bloquants délimitent les capacités du runtime asynchrone, alors que les utilisateurs n'écrivent presque jamais ces frontières à la main — ils écrivent#[tokio::main]、select!、join!, laissant la macro déployer ce code boilerplate à la compilation. Les macros sont la première couche de sucre syntaxique offerte par Tokio aux utilisateurs, et aussi l'endroit où le code d'exécution est réellement généré à la compilation. Ce chapitre se concentre sur la cratetokio-macrosettokio/src/macros/select.rs, décompose les trois chemins d'expansion de macros les plus utilisés, et répond principalement à une question : après expansion de la macro, à quoi ressemble réellement la chaîne d'appels, et pourquoi la sémantique de cancel safety deselect!doit être surveillée séparément.

9.1 #[tokio::main] : réécrire async fn en Runtime::block_on

Modèle intuitif:#[tokio::main]C'est comme un « mandat de rénovation ». Vous confiez un logement brut (async fn main), il installe la plomberie et l'électricité (construit le Runtime), pose portes et fenêtres (enable_all), puis emménage vos meubles d'origine (le corps de la fonction). Sans lui, chaquemaindevrait écrire manuellementBuilder::new_multi_thread().enable_all().build().unwrap().block_on(...), et le code boilerplate noierait la logique métier.

Structures de données et disposition mémoire

La macro elle-même ne produit pas de structures de données à l'exécution, mais la configuration qu'elle analyse est rangée dans deux structures.Configurationest un « accumulateur mutable de la phase d'analyse », dont tous les champs sontOption, car les paramètres d'attribut peuvent être absents, répétés ou invalides📎 tokio-macros/src/entry.rs:74-84. Notez queworker_threads、start_paused、unhandled_panicportent tousSpan— c'est pour localiser l'erreur sur la ligne écrite par l'utilisateur lors d'un signalement, et non à l'intérieur de la macro📎 tokio-macros/src/entry.rs:74-84。FinalConfigest quant à lui le « résultat immuable après validation »,flavorn'est plusOption, carbuild()a déjà été couvert pardefault_flavor📎 tokio-macros/src/entry.rs:55-62。

RuntimeFlavorn'a que trois variantes :CurrentThread、Threaded、Local 📎 tokio-macros/src/entry.rs:10-14。from_strfournit exprès des messages d'erreur conviviaux pour les noms hérités :single_threadindique qu'il faudrait appelercurrent_thread,basic_schedulerindique un renommage,threaded_schedulerindique un renommage en📎 tokio-macros/src/entry.rs:17-27. C'est une conception typique de la macro comme « premier point de contact utilisateur » : le message d'erreur fait office de documentation.

Déroulement de l'expansion pas à pas

Mise en situation : l'utilisateur écrit#[tokio::main(flavor = "multi_thread", worker_threads = 4)] async fn main() { ... }。

Première étape,mainl'entrée analyse d'abord l'item en unItemFn 📎 tokio-macros/src/entry.rs:577-580personnalisé. CeItemFnn'est passyn::ItemFn, mais un analyseur implémenté par Tokio lui-même, dont la raison est écrite dans les commentaires : il ne veut pas analyser récursivement toute la déclaration, mais faire une analyse légère « mise en tampon par arbre de tokens, découpage à chaque point-virgule »📎 tokio-macros/src/entry.rs:720-764. Cela évite le coût de construction d'un AST complet sur le corps de la fonction dans la macro.

Deuxième étape,build_configvérifie si le mot-cléasyncest présent, et signale "theasync keyword is missing" 📎 tokio-macros/src/entry.rs:346-349s'il manque. Ensuite, il parcourt les paramètres d'attribut et dispatcheworker_threads、flavor、start_paused、crate、unhandled_panic、namevers le setter correspondant📎 tokio-macros/src/entry.rs:369-399. Notez quecore_threadsest explicitement rejeté avec un message indiquant le renommage en📎 tokio-macros/src/entry.rs:379-382。

Troisième étape,Configuration::buildeffectue une validation de cohérence entre champs. Il y a ici trois contraintes clés :worker_threadsn'autorise quemulti_thread 📎 tokio-macros/src/entry.rs:197-217;start_pausedn'autorise quecurrent_thread/local 📎 tokio-macros/src/entry.rs:219-229;unhandled_panicn'autorise également quecurrent_thread/local 📎 tokio-macros/src/entry.rs:231-241. Si l'utilisateur choisitmulti_threadmais que la featurert-multi-threadn'est pas activée, le message d'erreur diffère selon que le flavor est explicitement spécifié ou non📎 tokio-macros/src/entry.rs:209-216。

Quatrième étape,parse_knobsgénère le code. Il efface d'abordasyncness 📎 tokio-macros/src/entry.rs:441, puis choisit le point de départ du builder selon le flavor :CurrentThread/LocalutiliseBuilder::new_current_thread(),ThreadedutiliseBuilder::new_multi_thread() 📎 tokio-macros/src/entry.rs:468-477。LocalLa particularité est que l'appel à build estbuild_local(Default::default())et nonbuild() 📎 tokio-macros/src/entry.rs:479-483. Ensuite, il ajoute en chaîne selon les besoins.worker_threads(#v)、.start_paused(#v)、.unhandled_panic(...)、.name(#v) 📎 tokio-macros/src/entry.rs:485-497。

Cinquième étape, génération du corps de fonction final. Le cœur estlast_block:return #rt.enable_all().#build.expect("Failed building the Runtime").block_on(body) 📎 tokio-macros/src/entry.rs:509-522. Notez lereturnexplicite, dont le commentaire pointe vers tokio-rs/tokio#4636, pour corriger un problème d'inférence de type📎 tokio-macros/src/entry.rs:508。

Sixième étape, le corps de la fonction est enveloppé dansasync #bodyet soumis à une vérification de type. Hors chemin test, si le type de retour n'est pas!et ne contient pasimpl Trait, on insèreif false { let _: &dyn Future<Output = #output_type> = &body; }pour une assertion à la compilation📎 tokio-macros/src/entry.rs:551-571. Le chemin test utilise quant à luipin!Épingler le body sur la pile et le convertir enPin<&mut dyn Future>, le commentaire explique que c'est pour réduireblock_onla surcharge de compilation des instanciations génériques📎 tokio-macros/src/entry.rs:526-548。

mermaid
flowchart TD
    entry["main(args, item)"] --> parse_item{"syn::parse2(item) 成功?"}
    parse_item -->|否| err_ret["token_stream_with_error 返回原始 item + 编译错误"]
    parse_item -->|是| check_main{"ident == main 且有参数?"}
    check_main -->|是| err_args["报错: main 不能接受参数"]
    check_main -->|否| parse_args["AttributeArgs::parse_terminated"]
    parse_args --> build_cfg["build_config 校验 async 与各字段"]
    build_cfg --> cfg_ok{"config 构建成功?"}
    cfg_ok -->|否| fallback["parse_knobs(DEFAULT_ERROR_CONFIG) + 错误"]
    cfg_ok -->|是| knobs["parse_knobs 生成 Builder 链 + block_on"]
    knobs --> out["输出同步 fn main"]

Réflexions de conception et pièges en production

mainettestpartagentparse_knobs, mais le flavor par défaut diffère :testpar défautCurrentThread,mainpar défautThreaded 📎 tokio-macros/src/entry.rs:91-94. Cela explique pourquoi#[tokio::test]est monothread par défaut — les tests n'ont généralement pas besoin de plusieurs cœurs, et le monothread est plus facile à reproduire.

Un piège facilement négligé : après l'expansion de la macro, chaque appel de la fonction crée un nouveau Runtime. La documentation avertit explicitement que si la fonction est appelée fréquemment, il faut utiliser Builder pour réutiliser le Runtime📎 tokio-macros/src/lib.rs:31-35. Utiliser#[tokio::main]sur une fonction ordinaire est légal, mais chaque appel paie le coût de construction d'un Runtime.

Un autre piège est le renommage decrate. Lorsque l'utilisateuruse tokio as tokio1, letokio::runtime::Buildergénéré par défaut à l'intérieur de la macro ne trouvera pas le chemin, il faut explicitementcrate = "tokio1" 📎 tokio-macros/src/lib.rs:239-264。parse_knobsdanscrate_pathla valeur par défaut deIdent::new("tokio", ...) 📎 tokio-macros/src/entry.rs:456-462, c'est précisément la source de l'erreur dans le scénario de renommage.

9.2 select! : polling multi-branches, masque de bits et équité aléatoire

Modèle intuitif:select!ressemble à « un serveur qui surveille simultanément plusieurs comptoirs de retrait ». Le comptoir qui sert en premier, il emporte le plat, et la file d'attente des autres comptoirs est annulée. Sans lui, l'utilisateur devrait écrire manuellementpoll_fnpour mettre plusieurs Future dans un tuple et les poll un par un, et gérer lui-même la logique « une fois qu'une branche est prête, les autres branches doivent être abandonnées ».

Structures de données et disposition mémoire

select!génère après expansion un module local__tokio_select_util, contenant une énumérationOutet un alias de typeMask 📎 tokio/src/macros/select.rs:615-619。Outles noms de variantes sont_0、_1…… une par branche, plus unDisabledreprésentant l'invalidation de toutes les branches📎 tokio-macros/src/select.rs:33-39。Maskle type sous-jacent est choisi dynamiquement selon le nombre de branches : ≤8 utiliseu8, ≤16 utiliseu16, ≤32 utiliseu32, ≤64 utiliseu64, au-delà de 64 panic directement📎 tokio-macros/src/select.rs:17-31. Ce masque de bits estselect!l'état central : le i-ème bit à 1 signifie que la i-ème branche a été désactivée.

Tous les Future sont stockés dans un tuplefutures, chaque élément passe d'abord parIntoFuture::into_futureconversion📎 tokio/src/macros/select.rs:654-656. Notez qu'ici on construit d'abordfutures_initpuis un par uninto_future, le commentaire explique que c'est pour tirer parti de la prolongation de la durée de vie temporaire📎 tokio/src/macros/select.rs:641-646. Ensuitelet mut futures = &mut futures;rétrograde le tuple en référence mutable, évitant que la closurepoll_fnne s'empare de la propriété📎 tokio/src/macros/select.rs:658-662。

Processus de polling étape par étape

Mise en situation :select! { v = stream1.next() => ..., v = stream2.next() => ..., else => break }。

Première étape, correspondance des règles d'entrée de la macro. S'il y a un préfixebiased;,start=0 📎 tokio/src/macros/select.rs:801-803; sinonstartest une expression aléatoirethread_rng_n(BRANCHES) 📎 tokio/src/macros/select.rs:805-809. C'est ce que la documentation appelle « sélectionner aléatoirement une branche à vérifier en premier », la source de l'équité📎 tokio/src/macros/select.rs:61-65。

Deuxième étape, normalisation. Le tt-muncher normalise chaque branche en forme(skip) pat = fut, if cond => handler,,skipest une séquence de_, de longueur égale au nombre de branches précédant cette branche📎 tokio/src/macros/select.rs:770-793。skipsert à la fois à générer l'accès au champ du tuplefutures_init.$($skip)*, et àcount!calculer l'index de la branche.

Troisième étape, évaluation des préconditions. Pour chaque brancheif $c, si false, alorsdisabled |= 1 << index 📎 tokio/src/macros/select.rs:631-636. Attention : même si la branche est désactivée, son expression$futsera quand même évaluée, mais ne sera pas poll📎 tokio/src/macros/select.rs:39-41。

Quatrième étape, entrer dans la closurepoll_fn. Vérifier d'abord le budget de coopération :ready!(poll_budget_available(cx)), budget épuisé retourne directementPending 📎 tokio/src/macros/select.rs:664-667. Cela garantit queselect!n'accapare pas le worker.

Cinquième étape, bouclefor i in 0..BRANCHES,branch = (start + i) % BRANCHES 📎 tokio/src/macros/select.rs:680-685. Pour chaque branch : vérifier d'aborddisabled & mask == mask, si désactivée alorscontinue 📎 tokio/src/macros/select.rs:694-699; sinon extraire le Future du tuple, l'envelopper avecPin::new_unchecked(la sécurité dépend du fait que le Future est sur la pile et n'est pas déplacé)📎 tokio/src/macros/select.rs:701-707; le poll,Ready(out)alors d'aborddisabled |= maskpuis correspondre au motif📎 tokio/src/macros/select.rs:710-730。

Sixième étape, correspondance de motif. Sioutcorrespond à$bind, retournerPoll::Ready(Out::_i(out)) 📎 tokio/src/macros/select.rs:727-733; si pas de correspondance,continuecontinuer à poll les autres branches — c'est précisément ce que dit l'étape 5 de la documentation « si le motif ne correspond pas, désactiver la branche courante »📎 tokio/src/macros/select.rs:44-47。

Septième étape, fin de boucle. Siis_pendingest vrai retournerPending, sinon toutes les branches sont invalidées, retournerOut::Disabled 📎 tokio/src/macros/select.rs:740-745. La couche externematch outputmappeOut::_ivers le handler correspondant,Disabledmappe verselsel'expression📎 tokio/src/macros/select.rs:749-755。

mermaid
flowchart TD
    start["poll_fn 闭包被调用"] --> budget{"poll_budget_available(cx)?"}
    budget -->|否| pending_budget["返回 Pending"]
    budget -->|是| init["is_pending = false; start = $start"]
    init --> loop{"i < BRANCHES?"}
    loop -->|否| check_pending{"is_pending?"}
    check_pending -->|是| pending["返回 Pending"]
    check_pending -->|否| disabled_out["返回 Out::Disabled"]
    loop -->|是| branch["branch = (start+i) % BRANCHES"]
    branch --> is_disabled{"disabled & mask == mask?"}
    is_disabled -->|是| next_i["i += 1"]
    is_disabled -->|否| poll_fut["Pin::new_unchecked(fut).poll(cx)"]
    poll_fut --> poll_res{"Poll 结果?"}
    poll_res -->|Pending| set_pending["is_pending = true; i += 1"]
    poll_res -->|Ready| disable["disabled |= mask"]
    disable --> pat_match{"out 匹配 $bind?"}
    pat_match -->|否| next_i
    pat_match -->|是| ready_out["返回 Out::_i(out)"]
    next_i --> loop
    set_pending --> loop

Réflexions de conception et pièges en production

Pourquoi utiliser un masque de bits plutôt queVec<bool>?Le masque de bits est un simple entier sur la pile, sans allocation sur le tas, etdisabled |= maskest une seule instruction. Pourselect!sur le chemin chaud, cela évite l'accès au tas à chaque itération.

Pourquoi désactiver la branche en cas de non-correspondance de motif ?C'estselect!la différence clé avec une « simple race ». ConsidéronsSome(v) = stream.next() => ..., sistream.next()retourneNone(fin de flux), le motif ne correspond pas, la branche est définitivement désactivée, évitant de poll indéfiniment un flux terminé. L'exemple de la documentation s'appuie précisément sur cette sémantique pour collecter deux flux jusqu'à ce que les deux soient terminés📎 tokio/src/macros/select.rs:198-223。

La véritable signification de la sécurité d'annulation:select!Une fois qu'une branche est prête, les Future des autres branches sont drop. Si un Future droppé a déjà consommé des données mais n'a pas encore retourné, les données sont perdues. La documentation liste explicitementread_exact、read_to_end、write_allnon sûr à l'annulation📎 tokio/src/macros/select.rs:119-124, tandis queMutex::lock、Semaphore::acquireà cause de l'équité de file d'attente, l'annulation perd la position dans la file📎 tokio/src/macros/select.rs:126-133. Méthode de détermination : trouver le point.await, si redémarrer la fonction à.awaitreste correct, alors c'est sûr à l'annulation📎 tokio/src/macros/select.rs:135-139。

ifLe piège de course des préconditions: la documentation donne un exemple d'erreur classique — utiliserif !sleep.is_elapsed()pour garder la branchesleep, maisis_elapsed()peut devenir true entre la vérificationwhileetselect!, entraînant un timeout manqué📎 tokio/src/macros/select.rs:336-376. La bonne façon est de retirerif, laisser la branchesleeptoujours participer au polling, après le timeoutbreak 📎 tokio/src/macros/select.rs:378-405。

biased;le coût de: le RNG aléatoire a un coût CPU, et certains scénarios nécessitent un ordre de polling déterminé📎 tokio/src/macros/select.rs:67-74. Maisbiased;confie la responsabilité de l'équité à l'utilisateur : si une branche est toujours prête, les branches suivantes seront affamées📎 tokio/src/macros/select.rs:75-81。

9.3 join! et les contraintes d'ingénierie de l'expansion de macro

Modèle intuitif:join!ressemble à « attendre simultanément que tous les colis arrivent ». Contrairement àselect!qui annule les autres dès que l'un arrive, il agrège les valeursReadyde tous les Future en un tuple. Sans lui, l'utilisateur devrait écrire manuellementpoll_fnpour maintenir l'état d'achèvement de chaque Future.

Structures de données et disposition mémoire

join!Le déploiement de est également basé sur un tuple stockant des Future, mais l'état n'est pas un masque de bits, mais un tuple de « valeurs terminées ». Une fois chaque Future terminé, sa valeur est extraite et stockée dans le tuple de résultats, et l'emplacement correspondant est marqué comme terminé. Contrairement àselect!,join!ne drop pas les Future non terminés — il doit attendre que tous les Future soient terminés avant de retourner.

Processus étape par étape

join!La logique de polling de partage le squelette « tuple stockant des Future +select!pilotage » avecpoll_fn, mais la sémantique est inverse :select!est « retourne dès qu'un est prêt »,join!est « retourne seulement quand tous sont prêts ». Chaque tour de poll parcourt tous les Future non terminés, si l'un retournePendingalors l'ensemblePending, si tousReadyalors agrège et retourne.

mermaid
flowchart LR
    subgraph input["输入"]
        f1["Future A"]
        f2["Future B"]
        f3["Future C"]
    end
    subgraph poll["poll_fn 驱动"]
        tuple["元组 (A, B, C)"]
        state["完成状态元组"]
    end
    subgraph output["输出"]
        result["(A::Output, B::Output, C::Output)"]
    end
    f1 --> tuple
    f2 --> tuple
    f3 --> tuple
    tuple --> state
    state -->|"全部 Ready"| result
    state -->|"任一 Pending"| pending["返回 Pending"]

Réflexions de conception et pièges en production

join!La sémantique de sûreté d'annulation de diffère deselect!:join!Lorsqu'il est drop, tous les Future non terminés sont drop, ce qui peut également entraîner une perte de données. Mais commejoin!n'annule activement aucune branche, il n'annule pas cette branche « parce qu'une autre branche est prête » commeselect!. Le vrai risque réside dans le fait quejoin!soit globalement annulé par unselect!externe ou un timeout.

join!La différence entre ettry_join!mérite attention :try_join!retourne immédiatement lorsqu'un Future retourneErr, annulant les autres Future, il hérite donc du risque de sûreté d'annulation deselect!.

Réflexions de conception

Les limites des macros en tant que générateurs de code à la compilation。#[tokio::main]place la validation de configuration à la compilation, les combinaisons illégales (commemulti_thread + start_paused) échouent directement à la compilation, plutôt qu'un panic à l'exécution. C'est l'avantage principal des macros par rapport au Builder : erreur en avance.

Architecture hybride macro déclarative + macro procédurale。select!Le corps de estmacro_rules!, mais deux logiques clés sont déléguées aux macros procédurales :select_priv_declare_output_enumgénère l'énumérationOutet le typeMasknettoie📎 tokio-macros/src/lib.rs:658-660,select_priv_clean_patterndans le patternref/mut 📎 tokio-macros/src/lib.rs:666-668. Pourquoi ? L'explication en commentaire : les macros déclaratives ont du mal à générer du code qui « sélectionne dynamiquement le type entier selon le nombre de branches », et ont également du mal à effectuer un nettoyage au niveau des tokens dans les positions de pattern📎 tokio/src/macros/select.rs:577-579。

clean_patternLa nécessité de。select!fait correspondreoutsous forme de&outau pattern📎 tokio/src/macros/select.rs:727, si l'utilisateur écritref v, cela devient&ref vprovoquant une erreur de type.clean_patternSuppression récursive deby_ref、mutability, ainsi queReferencedu patternmutability 📎 tokio-macros/src/select.rs:68-73📎 tokio-macros/src/select.rs:100-103. C'est le compromis que fait la macro entre « l'intuition de l'utilisateur » et « le borrow checker ».

La réalité d'ingénierie de la limite de 64 branches。count!、count_field!、select_variant!Les trois macros écrivent chacune manuellement les règles de correspondance de 0 à 64📎 tokio/src/macros/select.rs:821-1017📎 tokio/src/macros/select.rs:1021-1217📎 tokio/src/macros/select.rs:1221-1414. Le commentaire dit franchement « I'm not happy about it either »📎 tokio/src/macros/select.rs:816-817. C'est le prix à payer pour qu'une macro déclarative ne puisse pas faire d'arithmétique : on ne peut que coder en dur la correspondance entre le nombre de tokens et un entier.

Résumé de ce chapitre

Réflexions et auto-évaluation de ce chapitre

Q1: select!Ledisabledmasque de bits de est réinitialisé àselect!chaque fois qu'on entre dansDefault::default() 📎 tokio/src/macros/select.rs:627. Si l'on déplace cette ligne à l'intérieur de la closurepoll_fn, que se passe-t-il dans le scénario « appel en boucle de select! et un pattern de branche ne correspond pas » ?

Analyse de référence:disabledSi initialisé à l'intérieur de la closure, chaque poll le réinitialise, ce qui fait que les branches désactivées au tour précédent à cause d'une non-correspondance de pattern participent à nouveau au polling. ConsidéronsSome(v) = stream.next() => ...etstreamdéjà terminé (retourneNone), après non-correspondance du pattern cette branche devrait être définitivement désactivée. Sidisabledest réinitialisé, le prochain tour de poll va à nouveau poll ce flux déjà terminé, et si le flux n'est pas fused (c'est-à-dire qu'un poll après terminaison peut panic ou retourner un comportement indéfini), cela posera problème. Même si le flux est fused, cela gaspille du CPU à poll en boucle un flux qui retourne toujoursNone. La documentation dit explicitement « Re-entering select! due to a loop clears the disabled state »📎 tokio/src/macros/select.rs:37-38, ce qui signifie ré-entrer dans la macroselect!(nouveau tour de boucle), et non les multiples poll au sein d'un mêmeselect!.disableddoit être initialisé en dehors de la closure, pour maintenir l'état entre les multiples poll d'un même appelselect!.

Q2: select!Après avoir poll jusqu'àReady(out), exécute d'aborddisabled |= maskpuis fait correspondre le pattern📎 tokio/src/macros/select.rs:720-730. Si l'on retiredisabled |= mask, que se passe-t-il dans le scénario où le pattern ne correspond pas et que ce Future retourne immédiatementReadyà chaque poll ?

Analyse de référence: après avoir retirédisabled |= mask, sioutne correspond pas à$bind, le code passe parcontinueet continue à poller les autres branches. Mais au prochain tour oùpoll_fnest appelé (par exemple après qu'une autre branche retournePendinget qu'on poll à nouveau), cette branche n'est toujours pas désactivée et sera pollée à nouveau. Si ce Future retourne immédiatementReadyà chaque poll et que la valeur ne correspond pas au pattern, cela forme un livelock « poll -> Ready -> non-correspondance -> continue -> autre branche Pending -> retourne Pending -> poll à nouveau -> Ready à nouveau -> ... », le CPU tourne à vide.disabled |= maskPositionné immédiatement aprèsReady, pour garantir que même si le pattern ne correspond pas, cette branche ne sera pas pollée à nouveau. Notez que le positionnement a lieu avant la correspondance de pattern, donc les deux cas « Ready mais pattern ne correspond pas » et « Ready et pattern correspond » désactivent la branche — le premier pour éviter le livelock, le second pour éviter la consommation répétée.

Q3: parse_knobsInsèreif false { let _: &dyn Future<Output = #output_type> = &body; }dans le chemin non-test pour faire une vérification de type📎 tokio-macros/src/entry.rs:557-561, mais saute la vérification pour les types retournant!ou contenantimpl Trait. Pourquoi📎 tokio-macros/src/entry.rs:551-556doit-il être sauté ? Que se passerait-il si l'on forçait la vérification ?impl TraitAnalyse de référence

À la position de retour, est un « type opaque », le compilateur n'autorise pas à le convertir de force en:impl Trait, car&dyn Future<Output = impl Trait>exige un type concret, alors quedyn 要求具体类型,而 impl TraitLe type concret de n'est pas visible en dehors de la fonction. Si l'on insère de force une vérification, on obtient des erreurs du type « the size for values of typeimpl Futurecannot be known at compilation time » ou « cannot be made into an object ». Il en va de même pour le type de retour!:!peut être converti en n'importe quel type, mais&dyn Future<Output = !>leOutput = !lui-même peut déclencher des problèmes liés à l'instabilité de la never type. Le coût de l'omission de la vérification est le suivant : si l'utilisateur écritasync fn main() -> impl Traitmais que le type de retour réel ne correspond pas àimpl Trait, l'erreur ne sera révélée qu'au niveau deblock_on, et le message d'erreur peut être moins clair qu'avec une vérification explicite. C'est un compromis entre « l'exhaustivité de la vérification à la compilation » et « les limitations du système de types ».

La macro prend en charge le code boilerplate et la validation à la compilation à la place de l'utilisateur, mais ce qu'elle génère reste de simples Future et des appelspoll. Dans le chapitre suivant, nous quitterons le monde de la compilation des macros pour entrer dans la couche d'abstraction d'I/O à l'exécution, afin de voir commentAsyncRead/AsyncWritedécoupe les flux d'octets en trames, et commentFramedle framework de codec fonctionne correctement sous les contraintes de sûreté à l'annulation deselect!.

#[tokio::main]L'essence de est « analyse de configuration + génération de chaîne Builder +block_onenveloppement », la validation de configuration s'effectue à la compilation, le flavor détermine le point de départ du builder et la méthode build.select!Le cœur de est « stockage des Future dans un tuple + masque de bits pour marquer les désactivations + point de départ aléatoire pour garantir l'équité », une non-concordance de motif désactive la branche, la sûreté à l'annulation dépend de si le Future abandonné peut être redémarré au niveau de.await.join!etselect!partagent le même squelette mais ont une sémantique opposée : le premier attend que tout soit terminé, le second retourne dès qu'un seul est prêt. Les trois illustrent ensemble le compromis central de la conception des macros Tokio : confier le code boilerplate et la validation à la compilation aux macros, et laisser à l'utilisateur la compréhension explicite de la complexité sémantique à l'exécution (en particulier la sûreté à l'annulation). Après avoir compris comment les macros génèrent le code à l'exécution, la question naturelle suivante est : lorsque ce code commence réellement à lire et écrire des flux d'octets, quelles abstractions Tokio fournit-il ? Le chapitre 10 analyseraAsyncRead/AsyncWriteet le framework de codec, pour voir commentBufReader/BufWriterréduit les appels système, commentcopy_bidirectionalpilote le transfert bidirectionnel, commentFrameddécoupe les flux d'octets en trames, répondant ainsi à la question « où se situent les frontières de l'abstraction des I/O asynchrones ».

CHAPTER 10

Chapitre 10 : Abstraction d'I/O en flux : AsyncRead/AsyncWrite et framework de codec

Projet concerné : tokio-rs/tokio · Progression du livre : Chapitre 10 / 14 · État de vérification : ancrage réel des numéros de ligne FACT

Le chapitre précédent a décomposé le processus d'expansion de tokio-macros ; nous avons vu comment #[tokio::main], select!, join! prennent en charge le code boilerplate et la validation à la compilation à la place de l'utilisateur. Mais ce que les macros génèrent reste de simples Future et appels poll — lorsque ces Future commencent réellement à lire et écrire des octets, les abstractions de bas niveau fournies par Tokio ne sont que deux traits : AsyncRead et AsyncWrite. Leur problème est qu'ils sont « trop bas niveau » : un poll_read ne garantit que « quelques octets ont été lus », pas « un message complet a été lu ». Or la grande majorité des protocoles (HTTP, Redis, gRPC, RPC personnalisés) sont orientés « trames » plutôt que « flux d'octets ». La question centrale à laquelle ce chapitre répond est : où doivent se situer les frontières de l'abstraction des I/O asynchrones ? La réponse de Tokio se divise en deux couches : tokio::io fournit des traits et outils au niveau du flux d'octets (BufReader/BufWriter/copy_bidirectional), et le framework codec de tokio-util fournit par-dessus une adaptation Stream/Sink au niveau des trames (Framed/LengthDelimitedCodec). Comprendre la répartition des rôles entre ces deux couches, c'est comprendre « pourquoi les implémentations de protocoles commencent presque toutes par Framed ».

I. AsyncRead/AsyncWrite : pourquoi ne pas réutiliser directement std::io::Read

Modèle intuitif

std::io::Read::readest un « retrait bloquant » : vous vous tenez devant le guichet, et tant que la marchandise n'est pas arrivée, vous attendez, le thread est suspendu.AsyncRead::poll_readest un « retrait avec ticket de repas » : vous demandez « c'est prêt ? », si ce n'est pas prêt (Poll::Pending), vous vaquez à d'autres occupations tout en laissant un Waker pour que le système vous appelle quand la marchandise arrive. Sans ce trait, toute l'I/O asynchrone devrait être écrite manuellement avec l'enregistrementepollet le mapping Waker — c'est exactement ce que fait le Reactor du chapitre 5, etAsyncReadest la façade unifiée qu'il expose aux couches supérieures.

Structures de données et disposition mémoire

AsyncReadLa définition de est extrêmement concise, avec une seule méthode :

rust
pub trait AsyncRead {
    fn poll_read(
        self: Pin<&mut Self>,
        cx: &mut Context<'_>,
        buf: &mut ReadBuf<'_>,
    ) -> Poll<io::Result<()>>;
}

📎 tokio/src/io/async_read.rs:44-60

Les trois paramètres ont chacun leur importance.self: Pin<&mut Self>plutôt que&mut self: parce queAsyncReadest souvent détenu par le Future généré parasync fn, et qu'un Future, une fois poll, ne peut plus être déplacé (auto-référence),Pinest un contrat imposé par le compilateur.cx: &mut Context<'_>porte le Waker, c'est le canal de transmission du « dispositif de retrait ».buf: &mut ReadBuf<'_>est l'encapsulation par Tokio de&mut [u8]— il enregistre simultanément la « longueur remplie » et la « capacité non initialisée », évitant ainsistd::io::ReadCette ambiguïté du type « renvoie le nombre d'octets lus mais le tampon peut être non initialisé ».

La documentation énumère explicitement trois sémantiques de retour📎 tokio/src/io/async_read.rs:15-32:Ready(Ok(()))indique que les données ont été écritesbuf, la quantité lue étant déterminée par l'incrément de longueur deReadBuf::filled; si l'incrément est 0, il s'agit soit d'EOF, soit debuf.remaining() == 0(tampon de capacité nulle) ;Pendingindique que la lecture est actuellement impossible mais qu'un réveil a été enregistré ;Ready(Err(e))est une erreur d'E/S sous-jacente. Voici un piège facile à négliger :« quantité lue égale à 0 » n'est pas synonyme d'EOF— si l'appelant passe un tampon de capacité nulle,poll_readrenvoie immédiatementReady(Ok(()))mais n'a rien lu. Si la couche supérieure traite « 0 octet » comme un EOF, elle diagnostiquera à tort une fermeture de connexion.

Walkthrough guidé par scénario : lire une séquence d'octets depuis&[u8]Considérons l'implémentation la plus simple — la copie de

vers&[u8]Analysons étape par étape :AsyncRead:

rust
impl AsyncRead for &[u8] {
    fn poll_read(
        mut self: Pin<&mut Self>,
        _cx: &mut Context<'_>,
        buf: &mut ReadBuf<'_>,
    ) -> Poll<io::Result<()>> {
        let amt = std::cmp::min(self.len(), buf.remaining());
        let (a, b) = self.split_at(amt);
        buf.put_slice(a);
        *self = b;
        Poll::Ready(Ok(()))
    }
}

📎 tokio/src/io/async_read.rs:98-108

est la capacité restante du tampon cible, on prend la plus petite des deuxself.len()On découpe le segment en « lebuf.remaining()à copier cette fois » et « leamt。split_at(amt)restant à lire »aOn copieb」。buf.put_slice(a)dansaet on avance son pointeur filled.ReadBufOn avance le segment lui-même vers la partie restante — c'est là le point clé de*self = ben tant que « curseur » : après chaque poll,&[u8]pointe vers la partie non lue. Enfin on renvoieself, car un segment mémoire est toujours « prêt », il ne fera jamaisReady(Ok(()))Notons quePending。

est ignoré : une source de données en mémoire n'a pas besoin de Waker. Cela contraste avec un socket réseau — ce dernier, en l'absence de données, renvoie_cxet enregistre un intérêt pour la lisibilité.PendingL'implémentation de

io::Cursor<T>ajoute une couche de vérification de limites📎 tokio/src/io/async_read.rs:113-134: on prend d'abordposition(), et sipos > slice.len()(position hors limites) on renvoie directementReady(Ok(()))sans panic📎 tokio/src/io/async_read.rs:113-134. C'est une conception défensive :Cursorla position deset_positionpeut être définie à une valeur arbitraire par un

externe ; en cas de dépassement, la traiter comme « déjà lu » est plus conforme à la sémantique d'E/S qu'un panic.

AsyncReadRéflexion de conception : la macro deref et la propagation de PinBox<T>、&mut T、Pin<P>fournit une implémentation de transfert pourderef_async_read!. Les deux premiers génèrent📎 tokio/src/io/async_read.rs:64-70via la macroPin::new(&mut **self).poll_read(cx, buf), l'essentiel étantPin<&mut Box<T>>— déréférencerPin<&mut T>versPin<P>puis transférer.📎 tokio/src/io/async_read.rs:87-93L'implémentation decrate::util::pin_as_deref_mut(self)est plus subtilePin<&mut Pin<P>>: elle appellePin<&mut P::Target>, projetantPinvers

. Cette couche de projection est nécessaire, sinon l'imbrication de

entraînerait une incompatibilité de types.Box<dyn AsyncRead>、&mut T〔Inférence de conception et compromis architecturaux〕poll_readLa motivation de conception ici est le « zéro-coût abstrait » : les implémentations de transfert permettent à des types enveloppants commePinde ne pas avoir à écrire manuellement

---

, tout en préservant une sémantique correcte de

. Le coût est qu'à chaque couche de transfert s'introduit un appel indirect, que le compilateur peut généralement éliminer par inlining.

copy_bidirectionalII. copy_bidirectional : la machine à états du transfert bidirectionnelcopyModèle intuitifselect!est un « serveur de plats bidirectionnel » : il surveille simultanément les deux directions A→B et B→A, et dès qu'un côté lit des données, il les écrit vers l'autre. Sans lui, implémenter un proxy TCP nécessiterait d'écrire manuellement deuxselect!Future et de les combiner aveccopy_bidirectional— or la contrainte de sûreté à l'annulation de

(chapitre 9) ferait perdre les données « lues à moitié puis annulées ».

utilise une machine à états explicite pour conserver les états intermédiaires « lecture-écriture-fermeture », assurant ainsi la sûreté à l'annulation.

rust
enum TransferState {
    Running(CopyBuffer),
    ShuttingDown(u64),
    Done(u64),
}

📎 tokio/src/io/util/copy_bidirectional.rs:10-14

RunningLe cœur est une énumération à trois états :CopyBufferCopieShuttingDown(u64)détientDone(u64)(contenant un tampon de 8 Ko et les compteurs de lecture/écriture), représentant « transfert de données en cours ».porte le nombre d'octets déjà copiés, représentant « le côté lecture est à EOF, fermeture du côté écriture en cours ».。

CopyBufferreprésente « fermeture terminée, enregistrement du nombre final d'octets ». Cette énumération est la clé de la sûreté à l'annulation :copy.rsà tout moment si elle est drop, l'état est conservé dans l'énumération, et le prochain poll peut reprendre depuis le point d'interruptionDEFAULT_BUF_SIZEprovient de📎 tokio/src/io/util/copy_bidirectional.rs:76-88, la taille par défaut étant déterminée parCopyBuffer(8 Ko)

. Chaque direction détient un

copy_bidirectional_implindépendant, donc la consommation mémoire est de 16 Ko.poll_fnWalkthrough guidé par scénario : le cycle de vie complet d'un transfert bidirectionnel

rust
let mut a_to_b = TransferState::Running(a_to_b_buffer);
let mut b_to_a = TransferState::Running(b_to_a_buffer);
poll_fn(|cx| {
    let a_to_b = transfer_one_direction(cx, &mut a_to_b, a, b)?;
    let b_to_a = transfer_one_direction(cx, &mut b_to_a, b, a)?;
    let a_to_b = ready!(a_to_b);
    let b_to_a = ready!(b_to_a);
    Poll::Ready(Ok((a_to_b, b_to_a)))
})
.await

📎 tokio/src/io/util/copy_bidirectional.rs:127-151

:transfer_one_directionCopiePoll。ready!Notons l'ordre d'appel dePending: on avance d'abord a→b, puis b→a, les deux renvoyantLa macro renvoie immédiatementsi l'une des directions n'est pas terminée📎 tokio/src/io/util/copy_bidirectional.rs:143-144— maisready!l'état de l'autre direction a déjà été avancéDone(count). C'est précisément ce que le commentaire souligne :

transfer_one_directionmême silooprenvoie prématurément, l'autre direction renverra encore

rust
loop {
    match state {
        TransferState::Running(buf) => {
            let count = ready!(buf.poll_copy(cx, r.as_mut(), w.as_mut()))?;
            *state = TransferState::ShuttingDown(count);
        }
        TransferState::ShuttingDown(count) => {
            ready!(w.as_mut().poll_shutdown(cx))?;
            *state = TransferState::Done(*count);
        }
        TransferState::Done(count) => return Poll::Ready(Ok(*count)),
    }
}

📎 tokio/src/io/util/copy_bidirectional.rs:29-42

RunningÀ l'intérieur depoll_copyse trouve unShuttingDown。ShuttingDown, qui avance selon l'état :poll_shutdownCopieDone。DoneDans l'état

on appelle

mermaid
flowchart TD
    start["transfer_one_direction 进入 loop"] --> match_state{"当前 TransferState?"}
    match_state -->|Running| poll_copy["buf.poll_copy(cx, r, w)"]
    poll_copy --> copy_ready{"poll_copy 结果?"}
    copy_ready -->|Pending| ret_pending["返回 Poll::Pending<br/>状态保持 Running"]
    copy_ready -->|Err| ret_err["返回 Poll::Ready(Err)<br/>错误向上传播"]
    copy_ready -->|Ok(count)| to_shutdown["state = ShuttingDown(count)"]
    to_shutdown --> match_state
    match_state -->|ShuttingDown| poll_shutdown["w.poll_shutdown(cx)"]
    poll_shutdown --> shutdown_ready{"shutdown 结果?"}
    shutdown_ready -->|Pending| ret_pending2["返回 Poll::Pending<br/>状态保持 ShuttingDown"]
    shutdown_ready -->|Err| ret_err
    shutdown_ready -->|Ok| to_done["state = Done(count)"]
    to_done --> match_state
    match_state -->|Done| ret_done["返回 Poll::Ready(Ok(count))"]

on appelle

pour fermer le côté écriture (envoi de FIN), puis une fois terminé on passe à

on renvoie directement le compteur.transfer_one_directionLe diagramme de flux ci-dessous illustre la logique d'avancement de la machine à états unidirectionnelle et les branches d'erreur :async fnCopieCopyBufferRéflexion de conception : pourquoi une machine à états explicite plutôt qu'une async fncopy_bidirectional〔Inférence de conception et compromis architecturaux〕Siétait écrit commeasync fn, le compilateur générerait un Future dont l'état interne (select!, compteur déjà copié) serait caché dans la machine à états générée. Cela ne pose pas de problème en usage unidirectionnel, maisTransferStatedoit avancer les deux directionspoll_fndans le même cycle de poll

simultanément — si l'on utilisait deuxpoll_copyplusErr, dès qu'une direction se termine l'autre serait drop, perdant son tampon interne et son compteur, violant la sûreté à l'annulation. Un?explicite expose l'état sur la pile,📎 tokio/src/io/util/copy_bidirectional.rs:32et à chaque réentrée l'état est toujours là, garantissant ainsi « la reprise depuis le point d'interruption après annulation ».📎 tokio/src/io/util/copy_bidirectional.rs:67-70En matière de gestion d'erreurs, lerenvoyé parest immédiatement propagé vers le haut viacopy_bidirectional. La documentation précise explicitement

copy_bidirectional_with_sizes: les lectures/écritures interrompues sont réessayées, les autres erreurs sont renvoyées immédiatement, et📎 tokio/src/io/util/copy_bidirectional.rs:99-125des données partiellement lues peuvent être perduespoll_copyretourne toujoursReady(Ok(0))est incorrectement interprété comme EOF, formant une boucle d'attente active.

---

III. Framed : découper un flux d'octets en trames

Modèle intuitif

Framedest une « machine à saucisses » : en amont, un flux continu d'eau (AsyncRead/AsyncWrite), en aval, des segments de saucisse découpés (Stream<Item = Frame> / Sink<Frame>)。Decoderest chargé de « découper un segment du flux »,Encoderest chargé d'« emballer un segment en flux ». SansFramed, chaque implémentation de protocole devrait écrire manuellement « gestion de tampon + traitement des paquets partiels + découpage des paquets collés » — c'est précisément le travail répétitif que le framework codec vise à éliminer.

Structures de données et disposition mémoire

Framedn'est en soi qu'un mince emballage :

rust
pub struct Framed<T, U> {
    #[pin]
    inner: FramedImpl<T, U, RWFrames>
}

📎 tokio-util/src/codec/framed.rs:38-41

Le véritable état se trouve dansFramedImpldestate: RWFrames, contenantread: ReadFrameetwrite: WriteFramedeux parties.ReadFrameLes champs dewith_capacitysont visibles dans📎 tokio-util/src/codec/framed.rs:107-126:eof: bool(si le côté lecture est EOF),is_readable: bool(si l'intérêt en lecture est enregistré),buffer: BytesMut(tampon de lecture),has_errored: bool(si une erreur s'est produite, pour éviter les lectures répétées).WriteFrameChamps📎 tokio-util/src/codec/framed.rs:119-122:buffer: BytesMut(tampon d'écriture),backpressure_boundary: usize(seuil de contre-pression).

backpressure_boundaryest la clé du mécanisme de contre-pression : lorsque le tampon d'écriture dépasse ce seuil,poll_readyretourneraPendingjusqu'à ce que les données soient vidées, appliquant ainsi une contre-pression en amont surSink. Par défaut égal àcapacity 📎 tokio-util/src/codec/framed.rs:121, ajustable viaset_backpressure_boundarypour modifier📎 tokio-util/src/codec/framed.rs:271-273。

Parcours guidé par scénario : lire une trame depuis un socket

FrameddeStreaml'implémentation se contente de déléguer àFramedImpl::poll_next 📎 tokio-util/src/codec/framed.rs:309-311. La véritable logique se trouve dansFramedImpl(ce fichier n'est pas fourni dans ce chapitre, mais la chaîne d'appels peut être déduite de l'interface deFramed) :

1. poll_nextvérifie d'abordread.buffersi une trame complète existe déjà (appel àcodec.decode);

2. SidecoderetourneSome(frame), produire directement, sans toucher aux E/S sous-jacentes ;

3. Si retourneNone(paquet partiel), vérifierread.eof: si EOF et tampon non vide, cela signifie qu'il reste des données non décodables, retourner une erreur ouNone;

4. Sinon, appeler leAsyncRead::poll_readsous-jacent pour lire plus d'octets dansread.buffer;

5. Les octets lus tentent à nouveaudecode, en boucle jusqu'à produire une trame ouPending。

Cet ordre « d'abord decode puis read » est important : il garantit queun seul read peut produire plusieurs trames(paquets collés), et queune trame peut s'étendre sur plusieurs read(paquets partiels).is_readableLe flagPendingévite de réenregistrer l'intérêt en lecture — si le poll précédent l'a déjà enregistré et qu'il n'est pas prêt, cette fois on retourne directement

Sinksans rappeler la couche sous-jacente.📎 tokio-util/src/codec/framed.rs:315-338:start_sendChaîne d'appels de l'implémentationcodec.encode(item, &mut write.buffer)appellepoll_flushpour encoder la trame dans le tampon d'écriture ;write.buffervideAsyncWrite;poll_readyvers la couche sous-jacentewrite.buffer.len() >= backpressure_boundaryvérifie

, si le seuil est dépassé, flush d'abord puis retourne prêt.FramedLe diagramme de séquence ci-dessous montre

mermaid
sequenceDiagram
    participant App as 应用层
    participant F as FramedImpl
    participant C as Decoder/Encoder
    participant IO as AsyncRead/AsyncWrite

    App->>F: poll_next(cx)
    F->>C: decode(&mut read.buffer)
    alt 缓冲中已有完整帧
        C-->>F: Some(frame)
        F-->>App: Poll::Ready(Some(frame))
    else 半包
        C-->>F: None
        F->>IO: poll_read(cx, &mut read.buffer)
        alt 数据就绪
            IO-->>F: Ready(Ok(()))
            F->>C: decode(&mut read.buffer)
            C-->>F: Some(frame) 或 None
        else 无数据
            IO-->>F: Pending
            F-->>App: Poll::Pending
        end
    end

    App->>F: start_send(frame)
    F->>C: encode(frame, &mut write.buffer)
    C-->>F: Ok(())
    App->>F: poll_flush(cx)
    F->>IO: poll_write(cx, &write.buffer)
    IO-->>F: Ready(Ok(n))
    F->>IO: poll_flush(cx)
    IO-->>F: Ready(Ok(()))

Copier

FramedSécurité d'annulation : avertissement de la documentation de Framed📎 tokio-util/src/codec/framed.rs:23-30:SinkExt::sendLa documentation deselect!liste spécifiquement la sémantique de sécurité d'annulationSi dansun autre branche le complète en premier,sendle message est garanti non envoyé, mais le message lui-même est perdupoll_ready— carstart_sendeffectue d'abordpoll_readypuisitem, si dropé pendant la phaseStreamExt::next,

a déjà été consommé mais non encodé. Alors que

est sûr à l'annulation : il ne détient qu'une référence au stream sous-jacent, le drop ne perdra pas les trames déjà décodées.read.buffer〔Inférences de conception et compromis architecturaux〕FramedCette asymétrie provient de la différence entre les chemins de lecture et d'écriture : l'état du chemin de lecture (next) est conservé à l'intérieur deitem, dropersendne fait qu'abandonner l'action « prendre une trame », le tampon n'est pas affecté ; l'état du chemin d'écriture (select!en attente d'envoi) se trouve sur la pile Future desend, le drop le perd. Dans le code de production, si l'on utilise

dansinto_parts, il faut s'assurer que le message peut être renvoyé ou accepter sa perte.map_codec

FramedRéflexion de conception :into_parts/from_partset📎 tokio-util/src/codec/framed.rs:290-298 📎 tokio-util/src/codec/framed.rs:155-166。map_codecfournissent📎 tokio-util/src/codec/framed.rs:221-234pour « changer de codec tout en conservant le tampon »into_partsest implémenté sur cette paire de méthodesio/codec/read_buf/write_buf: d'abordmapextraitfrom_parts, puis utilise la fonction

FramedPartspour convertir le codec, enfin_priv: ()réassemble. Cette conception permet de conserver les données déjà tamponnées lors d'une mise à niveau de protocole (par exemple, passage du texte en clair à TLS), évitant une relecture.📎 tokio-util/src/codec/framed.rs:373-375Le champnew/from_partsde

---

est une technique de « structure non exhaustive » : les champs privés empêchent la construction directe externe, forçant le passage par

, permettant ainsi d'ajouter des champs à l'avenir sans casser la compatibilité.

LengthDelimitedCodecIV. LengthDelimitedCodec : machine à états pour l'encodage/décodage à préfixe de longueurDecodeStateModèle intuitif

est un couteau spécialisé pour « découper les saucisses selon la longueur » : il suppose qu'un champ de longueur à nombre d'octets fixe précède chaque trame, lit d'abord la longueur puis le payload. Sans lui, implémenter un protocole à préfixe de longueur nécessiterait d'écrire manuellement une machine à états « lire 4 octets → parser la longueur → lire N octets → boucler » — c'est précisément ce que fait son

rust
pub struct LengthDelimitedCodec {
    builder: Builder,
    state: DecodeState,
}

enum DecodeState {
    Head,
    Data(usize),
}

📎 tokio-util/src/codec/length_delimited.rs:451-457

DecodeStateStructures de données et disposition mémoireHeadCopierData(n)est une machine à états explicite :decodesignifie « en train de lire le champ de longueur »,signifie « longueur n parsée, en train de lire le payload ». Cet état persiste à travers les appels。

Builder, donc📎 tokio-util/src/codec/length_delimited.rs:416-435:max_frame_lendans les scénarios de paquets partiels, la progression n'est pas perduelength_field_lendétient toute la configurationlength_field_offset(par défaut 8MB),length_adjustment(par défaut 4 octets),num_skip(par défaut 0),None(par défaut 0),offset + len)、length_field_is_big_endian(par défaut

, c'est-à-dire

decode(par défaut true).

rust
fn decode(&mut self, src: &mut BytesMut) -> io::Result<Option<BytesMut>> {
    let n = match self.state {
        DecodeState::Head => match self.decode_head(src)? {
            Some(n) => {
                self.state = DecodeState::Data(n);
                n
            }
            None => return Ok(None),
        },
        DecodeState::Data(n) => n,
    };

    match self.decode_data(n, src) {
        Some(data) => {
            self.state = DecodeState::Head;
            src.reserve(self.builder.num_head_bytes().saturating_sub(src.len()));
            Ok(Some(data))
        }
        None => Ok(None),
    }
}

📎 tokio-util/src/codec/length_delimited.rs:579-603

Headest le point d'entrée de la machine à états :decode_headCopierNoneDans l'étatOk(None), appel àSome(n). Si retourneData(n)。Data(données insuffisantes), retourner directementdecode_data(n, src)en attendant plus de données ; si retournesplit_to(n), l'état passe àHeadDans l'étatNone, prendre directement n. Puis appel à

decode_head: si le tampon contient déjà n octets,

rust
let head_len = self.builder.num_head_bytes();
let field_len = self.builder.length_field_len;

if src.len() < head_len {
    return Ok(None);
}

let n = {
    let mut src = Cursor::new(&mut *src);
    src.advance(self.builder.length_field_offset);
    let n = if self.builder.length_field_is_big_endian {
        src.get_uint(field_len)
    } else {
        src.get_uint_le(field_len)
    };

    if n > self.builder.max_frame_len as u64 {
        return Err(io::Error::new(
            io::ErrorKind::InvalidData,
            LengthDelimitedCodecError { _priv: () },
        ));
    }

    let n = n as usize;
    let n = if self.builder.length_adjustment < 0 {
        n.checked_sub(-self.builder.length_adjustment as usize)
    } else {
        n.checked_add(self.builder.length_adjustment as usize)
    };

    match n {
        Some(n) => n,
        None => {
            return Err(io::Error::new(
                io::ErrorKind::InvalidInput,
                "provided length would overflow after adjustment",
            ));
        }
    }
};

src.advance(self.builder.get_num_skip());
src.reserve(n.saturating_sub(src.len()));
Ok(Some(n))

📎 tokio-util/src/codec/length_delimited.rs:504-562

, et réserve l'espace pour l'en-tête de la trame suivante ; sinon retournesrc.len() >= head_lenen attente.None 📎 tokio-util/src/codec/length_delimited.rs:499-502est la logique de parsing centrale :CursorCopiersrcParsing progressif : vérifier d'abordadvance/get_uint, si insuffisant retourneradvance(length_field_offset). Utiliser📎 tokio-util/src/codec/length_delimited.rs:517pour envelopperfield_lenafin de permettre les opérations📎 tokio-util/src/codec/length_delimited.rs:520-524。

sans consommer le tampon original.saute le préfixe d'en-têten > max_frame_len. Lire selon l'endiannessInvalidDatala valeur de longueur sur📎 tokio-util/src/codec/length_delimited.rs:526-531octets

Défense critiquechecked_sub/checked_add: si📎 tokio-util/src/codec/length_delimited.rs:537-541, retourner immédiatement l'erreurInvalidInputerreur plutôt que panic.get_num_skip()retournenum_skipou la valeur par défautoffset + len 📎 tokio-util/src/codec/length_delimited.rs:1070-1073, en sautant le reste de l'en-tête. Enfinreserve(n.saturating_sub(src.len()))réserve l'espace pour le payload📎 tokio-util/src/codec/length_delimited.rs:559——on utilisesaturating_subparce quesrcpeut déjà contenir une partie du payload.

Le diagramme ci-dessous illustredecodele chemin de décision complet :

mermaid
flowchart TD
    entry["decode(src)"] --> check_state{"self.state?"}
    check_state -->|Head| head["decode_head(src)"]
    head --> head_result{"结果?"}
    head_result -->|Ok(None)| ret_none1["返回 Ok(None)<br/>等待更多数据"]
    head_result -->|Err| ret_err1["返回 Err<br/>长度超限或溢出"]
    head_result -->|Ok(Some(n))| set_data["state = Data(n)"]
    set_data --> decode_data
    check_state -->|Data(n)| decode_data["decode_data(n, src)"]
    decode_data --> data_result{"src.len() >= n?"}
    data_result -->|否| ret_none2["返回 Ok(None)<br/>等待更多数据"]
    data_result -->|是| split["src.split_to(n)<br/>state = Head<br/>reserve 下一帧头部"]
    split --> ret_frame["返回 Ok(Some(frame))"]

Réflexion de conception : rognage de max_frame_len et protection contre le débordement

Builder::adjust_max_frame_lenlors de la construction du codec, on rognemax_frame_lenà la valeur maximale représentable par le champ de longueur📎 tokio-util/src/codec/length_delimited.rs:1075-1081。max_allowed_frame_lencalculemax_length_field_value + length_adjustment 📎 tokio-util/src/codec/length_delimited.rs:1083-1089, oùmax_length_field_valueutilisechecked_shlpour gérerlength_field_len == 8le débordement de décalage lors de📎 tokio-util/src/codec/length_delimited.rs:1091-1096. Ce rognage empêche l'utilisateur de définir une configuration contradictoire comme « champ de longueur sur 2 octets mais max_frame_len fixé à 1 Mo » — 2 octets peuvent représenter au maximum 65535, après rognage max_frame_len devient 65535.

Protection symétrique du chemin d'encodage :encodevérifien > max_frame_lenretourneInvalidInput 📎 tokio-util/src/codec/length_delimited.rs:607-607, l'ajustement de longueur utilise égalementchecked_add/checked_sub 📎 tokio-util/src/codec/length_delimited.rs:620-631. Notez que la direction d'ajustement à l'encodage est inverse de celle du décodage : au décodage c'est « longueur lue ± adjustment = longueur du payload », à l'encodage c'est « longueur du payload ∓ adjustment = champ de longueur écrit »📎 tokio-util/src/codec/length_delimited.rs:620-624。

〔Inférence de conception et compromis architecturaux〕

Cette conception symétrique « addition au décodage, soustraction à l'encodage » vise à unifier la sémantique delength_adjustment: il représente « la différence entre la valeur du champ de longueur et la longueur du payload ». Lorsque le champ de longueur du protocole inclut l'en-tête (comme dans l'Example 3),adjustment = -2, au décodagen - (-2) = n + 2obtient la longueur du payload, à l'encodagepayload - (-2) = payload + 2réécrit le champ de longueur.

---

Réflexion de conception : les trois niveaux de la frontière d'abstraction

En revisitant ce chapitre, l'abstraction d'I/O de Tokio présente une structure claire en trois couches :

Première couche : le trait de flux d'octets (AsyncRead/AsyncWrite). Il promet seulement « lire/écrire quelques octets », sans garantir les frontières de trames. C'est l'interface minimale, que toute source d'I/O (socket, fichier, tranche mémoire) peut implémenter. Le coût est que la couche supérieure doit gérer elle-même les paquets partiels/agglutinés.

Deuxième couche : les utilitaires de flux d'octets (BufReader/BufWriter/copy_bidirectional). Au-dessus du trait, ils fournissent des capacités génériques comme « réduire les appels système » et « transfert bidirectionnel ».copy_bidirectionalla machine à états explicite de

démontre comment la « sûreté à l'annulation » s'implémente au niveau des utilitaires — l'état est conservé sur la pile plutôt qu'à l'intérieur du Future.Framed/Decoder/Encoder)Troisième couche : l'adaptation de trames (Stream<Frame>/Sink<Frame>. Elle élève le flux d'octets enLengthDelimitedCodec, permettant aux implémentations de protocole de se soucier uniquement du « codage/décodage des trames » plutôt que de la « gestion des tampons ».DecodeStateest l'exemple standard de cette couche, samax_frame_lenmachine à états et sa

protection sont des motifs que tous les protocoles à préfixe de longueur devraient réutiliser.

〔Inférence de conception et compromis architecturaux〕tokio-utilCette division en trois couches n'est pas fortuite : elle correspond aux trois gradients de la « fuite d'abstraction ». Plus on est bas, plus c'est générique mais difficile à utiliser ; plus on est haut, plus c'est pratique mais spécialisé. Tokio choisit de placer la « trame » comme citoyen de première classe danstokioplutôt que danstokiole cœur, parce que la définition d'une trame varie selon le protocole —tokio-utilne fournit que le flux d'octets,Decoder/Encoder。

---

fournit le cadre de trames, et les protocoles concrets (HTTP/Redis/gRPC) implémentent

  • AsyncRead::poll_readRésumé de ce chapitrePin<&mut Self> + Context + ReadBufutilisestd::io::Read::readles trois paramètresReady(Ok(()))pour remplacer
  • copy_bidirectional, transformant « l'attente bloquante » en « enregistrer un Waker + retourner Pending ».TransferStateet lorsque la quantité lue est 0, il faut distinguer EOF d'un tampon de capacité nulle.Running/ShuttingDown/Doneutiliseselect!l'énumération à trois états (
  • Framed) pour conserver l'état intermédiaire, permettant au transfert bidirectionnel de se rétablir même sousAsyncRead/AsyncWriteannulation. En cas d'erreur, des données partielles peuvent être perdues.Stream/Sink,ReadFrame/WriteFrameadapteSinkExt::sendenStreamExt::nextgérant séparément les tampons de lecture/écriture et la contre-pression.
  • LengthDelimitedCodecnon sûr à l'annulation (perte de messages),DecodeState(Head/Data(n)sûr à l'annulation.max_frame_lenutilisechecked_add/checked_sub) la machine à états pour gérer les paquets partiels,

protège le champ de longueur contre les DoS,

Q1: copy_bidirectionalprotège contre le débordement d'ajustement.transfer_one_directionRéflexions et auto-évaluation de ce chapitreTransferState::ShuttingDowndans leready!(w.as_mut().poll_shutdown(cx))?de*state = TransferState::Done(*count), si l'on remplace

la branche:poll_shutdownpar unDonedirect (en sautant shutdown), dans quels scénarios la connexion distante ne pourra-t-elle pas se fermer correctement ?readAnalyse de référenceShuttingDownle rôle de📎 tokio/src/io/util/copy_bidirectional.rs:35-39est d'envoyer un paquet FIN à l'homologue, notifiant « je n'ai plus de données à envoyer ». Si on le saute et passe directement àpoll_shutdown, le côté écriture ne se ferme pas, l'homologue attendra indéfiniment des données, formant une « connexion semi-ouverte » — l'homologue peut rester bloqué indéfiniment surPendingjusqu'au timeout. Dans un scénario de proxy TCP, cela provoque une fuite de connexions : le client s'est déconnecté, mais la connexion du proxy vers le backend reste maintenue. Dans le code source, l'existence de l'étatready!sert

Q2: LengthDelimitedCodec::decode_headprécisément à garantir la fermeture explicite du côté écriture après EOF. Notez queif n > self.builder.max_frame_len as u64lui-même peut retourner📎 tokio-util/src/codec/length_delimited.rs:526-531(par exemple si le tampon d'envoi est plein), il faut donc utiliser0xFFFFFFFFpour attendre plutôt que d'ignorer.length_adjustmentdans

, si l'on supprimela vérificationn, quelles conséquences déclencherait un client malveillant envoyant un en-tête de trame avec un champ de longueur deusize(4 Go) ? Pourquoi cette vérification doit-elle être faite avantdecode_data。decode_data?src.len() < nAnalyse de référenceNone: après suppression de la vérification,decode_headserait converti ensrc.reserve(n.saturating_sub(src.len())) 📎 tokio-util/src/codec/length_delimited.rs:559et transmis àlength_adjustmentvérifielength_adjustmentretourne-2, mais0xFFFFFFFF - 2à la fin dechecked_subtentera de réserver 4 Go de mémoire, provoquant un OOM ou un panic d'échec d'allocation. La vérification doit être faite avant

Nous avons ainsi clarifié les deux niveaux d'abstraction de Tokio entre flux d'octets et trames de messages : tokio::io se charge du transport d'octets, le framework codec de tokio-util se charge du découpage en trames et du codage/décodage. Si Framed est devenu le point de départ de l'implémentation de protocoles, c'est précisément parce qu'il encapsule le besoin fréquent de « lire un message complet » en une adaptation Stream/Sink réutilisable. Mais une trame n'est qu'un conteneur de données ; lorsque le protocole doit gérer des ensembles de tâches dynamiques, une annulation structurée ou des compositions de flux plus complexes, Framed seul ne suffit plus. Le chapitre suivant abordera les mécanismes d'extension de tokio-stream et tokio-util, pour voir comment les combinateurs de StreamExt, StreamMap/JoinSet/TaskTracker ainsi que CancellationToken réutilisent les mécanismes sous-jacents de Waker et d'ordonnancement, offrant des outils de plus haut niveau pour l'itération asynchrone et la gestion des tâches.

CHAPTER 11

Chapitre 11 : Écosystème Stream et couche d'outils : mécanismes d'extension de tokio-stream et tokio-util

Projet concerné : tokio-rs/tokio · Progression du livre : Chapitre 11 / 14 · État de vérification : lignes FACT réellement ancrées

Dans le chapitre précédent, nous avons décomposé le mécanisme au niveau des octets de Framed : le Decoder découpe BytesMut en trames, le Sink réécrit les trames, et la frontière d'abstraction de l'I/O asynchrone devient ainsi claire. Mais une trame n'est qu'un conteneur de données ; une implémentation réelle de protocole rencontre immédiatement trois problèmes que ni tokio::io ni Framed ne résolvent : l'itération asynchrone — Framed implémente Stream, mais Stream n'a que poll_next, sans next().await, filter, take, merge ; écrire poll_fn à la main est à la fois verbeux et propice aux pièges de la sécurité d'annulation ; l'ensemble de tâches dynamique — un service de chat doit s'abonner simultanément à N canaux, les canaux rejoignant et quittant à tout moment, alors que le nombre de branches de select! est fixé à la compilation et ne peut exprimer un ensemble de flux variant à l'exécution ; l'annulation structurée — select! peut annuler une branche unique, mais ne peut propager l'arrêt de tout l'arbre de tâches, ni attendre que toutes les tâches aient réellement terminé. tokio-stream et tokio-util sont nés précisément pour ces trois choses, et leur principe de conception clé est de ne pas repartir de zéro : chaque combinateur de StreamExt n'est qu'un emballage autour de poll_next, StreamMap réutilise la sémantique d'enregistrement de Waker, CancellationToken se construit directement sur tokio::sync::Notify, et TaskTracker encode tout son état dans un AtomicUsize. Les comprendre, c'est essentiellement comprendre comment réaliser une abstraction à coût nul sur les mécanismes existants de Waker et d'ordonnancement. Ce chapitre progresse en trois couches : itération, collections, annulation : d'abord comment StreamExt transforme poll_next en itérateur composable, puis comment StreamMap et TaskTracker gèrent les collections dynamiques, et enfin comment CancellationToken propage le signal d'annulation à tout l'arbre de tâches au moyen d'un arbre.

StreamExt : transformer poll_next en itérateur composable

Modèle intuitif

Streamest àFuture, ce queIteratorest à une valeur :Futureproduit « une valeur »,Streamproduit « une suite de valeurs ». MaisStreamne définit quepoll_nextcomme unique primitive, tout commeIteratorne définit quenext. SansStreamExt, chaque filtrage, mapping, troncature nécessiterait d'écrire à la main une closurepoll_fnet de gérer manuellementPin— c'est précisément là que les premiers utilisateurs du cratefuturessouffraient le plus.StreamExtLe rôle deStreamest de doterIteratord'un écosystème de combinateurs comme

. Sans lui, la catastrophe à laquelle le système ferait face ne serait pas un manque de fonctionnalités, maisun effondrement systémique de la sécurité d'annulation: chaquepoll_fnécrit à la main pourrait, lors d'une annulation parselect!, perdre un élément déjàpoll.

Structures de données et disposition mémoire

StreamExtest untrait d'extension, qui ne détient lui-même aucune donnée :

📎 tokio-stream/src/stream_ext.rs:106-106

rust
pub trait StreamExt: Stream {

Toutes ses méthodes renvoient unestructure de combinateur concrète, et nonBox<dyn Stream>. C'est une conception clé :maprenvoieMap<Self, F>,filterrenvoieFilter<Self, F>,takerenvoieTake<Self>. Ces structures sont toutes des emballages génériques sans allocation sur le tas, et le compilateur peut inliner toute la chaîne en une succession d'appelspoll_next.

Noter le blanket impl du trait :

📎 tokio-stream/src/stream_ext.rs:1213-1213

rust
impl<St: ?Sized> StreamExt for St where St: Stream {}

ToutStreamobtient automatiquement tous les combinateurs, sans implémentation manuelle.?Sizedpermet àdyn Streamde bénéficier aussi des méthodes d'extension.

La déclaration des modules de combinateurs révèle la surface de capacité complète de ce trait :

📎 tokio-stream/src/stream_ext.rs:4-59

rust
mod all; use all::AllFuture;
mod any; use any::AnyFuture;
mod chain; pub use chain::Chain;
pub(crate) mod collect; use collect::{Collect, FromStream};
mod filter; pub use filter::Filter;
mod filter_map; pub use filter_map::FilterMap;
mod fold; use fold::FoldFuture;
mod fuse; pub use fuse::Fuse;
mod map; pub use map::Map;
mod map_while; pub use map_while::MapWhile;
mod merge; pub use merge::Merge;
mod next; use next::Next;
mod skip; pub use skip::Skip;
mod skip_while; pub use skip_while::SkipWhile;
mod take; pub use take::Take;
mod take_while; pub use take_while::TakeWhile;
mod then; pub use then::Then;
mod try_next; use try_next::TryNext;
mod peekable; pub use peekable::Peekable;

Il y a ici une distinction à noter :next、try_next、all、any、fold、collectrenvoieFuture(Next、TryNext、AllFuture…), car ils consomment tout le flux en une valeur ; tandis quemap、filter、takeetc. renvoientStream, car ils préservent la forme du flux.nextLe type de retour deNext<'_, Self>est

📎 tokio-stream/src/stream_ext.rs:144-149

rust
fn next(&mut self) -> Next<'_, Self>
where
    Self: Unpin,
{
    Next::new(self)
}

Self: UnpinCopiernextLa contraintePinest délibérée :!Unpinne prend pas possession du flux, il l'emprunte seulement, et ne peut donc pasBox::pinle flux. Si le flux estpin_mut!, l'utilisateur doit d'abord

📎 tokio-stream/src/stream_ext.rs:116-121

rust
/// Note that because `next` doesn't take ownership over the stream,
/// the [`Stream`] type must be [`Unpin`]. If you want to use `next` with
/// a [`!Unpin`](Unpin) stream, you'll first have to pin the stream. This can
/// be done by boxing the stream using [`Box::pin`] or
/// pinning it to the stack using the `pin_mut!` macro from the `pin_utils`
/// crate.

. La documentation souligne explicitement ce compromis :mergede l'interrogation

mergeest le meilleur exemple pour comprendre comment les combinateurs réutilisent le Waker. Il entrelace la production de deux flux, etgarantit l'équité— si les deux flux sont prêts simultanément, ils produisent en alternance. La documentation avertit explicitement de ne pas enchaîner les appelsmerge:

📎 tokio-stream/src/stream_ext.rs:319-321

rust
/// simultaneously, the merge stream alternates between them. This provides
/// some level of fairness. You should not chain calls to `merge`, as this
/// will break the fairness of the merging.

mergede la signature exige que les deux flux aient le mêmeItemtype :

📎 tokio-stream/src/stream_ext.rs:398-404

rust
fn merge<U>(self, other: U) -> Merge<Self, U>
where
    U: Stream<Item = Self::Item>,
    Self: Sized,
{
    Merge::new(self, other)
}

Lorsque l'appelant.next().await, le flux d'exécution est le suivant :

1. Next::pollappelleMerge::poll_next。

2. Mergemaintient en interne un indicateur booléen « à qui le tour la dernière fois ». Il commence parpollle flux qui n'a pas produit la dernière fois ; siPending, puispolll'autre.

3. Si les deuxPending,MergeretournentPending, maisles Wakers respectifs des deux flux sont déjà enregistrés— dès que l'un est prêt, la tâche courante est réveillée.

4. Si un flux retourneReady(None)(fin),Mergeenregistre que ce flux est terminé, et ensuite nepolll'autre flux, jusqu'à ce qu'il se termine aussi.

Le point clé ici est :Mergen'a pas sa propre logique de gestion de Waker, il transmetcxtel quel aux deux flux internespoll_next。L'enregistrement du Waker est entièrement à la charge des flux sous-jacents,Mergedécide seulement « à qui demander en premier cette fois ». C'est exactement le sens littéral de « réutiliser le mécanisme de Waker sous-jacent ».

merge_size_hintsLa fonction auxiliaire montre comment les combinateurs fusionnent les indications de capacité :

📎 tokio-stream/src/stream_ext.rs:1216-1226

rust
fn merge_size_hints(
    (left_low, left_high): (usize, Option<usize>),
    (right_low, right_high): (usize, Option<usize>),
) -> (usize, Option<usize>) {
    let low = left_low.saturating_add(right_low);
    let high = match (left_high, right_high) {
        (Some(h1), Some(h2)) => h1.checked_add(h2),
        _ => None,
    };
    (low, high)
}

Notez le choix entresaturating_addetchecked_add: la borne inférieure utilise l'addition saturante (plutôt sous-estimer que déborder en panic), la borne supérieure utilise l'addition vérifiée (si l'un est inconnu, l'ensemble est inconnu). C'est le traitement typique du contrat desize_hint.

Réflexion de conception : sécurité à l'annulation etchunks_timeoutprotection contre les panics

StreamExtLa documentation annote chaque méthode avecCancel safety. Prenonsnextcomme exemple :

📎 tokio-stream/src/stream_ext.rs:123-127

rust
/// # Cancel safety
///
/// This method is cancel safe. The returned future only
/// holds onto a reference to the underlying stream,
/// so dropping it will never lose a value.

nextest sûr à l'annulation parce qu'il emprunte seulement le flux, ne consomme pas d'éléments —Nextlorsque le future est drop, l'état du flux lui-même reste inchangé, le prochainnextreferapoll。

Mais tous les combinateurs ne sont pas sûrs à l'annulation.chunks_timeouteffectue une validation des paramètres dès la construction :

📎 tokio-stream/src/stream_ext.rs:1178-1185

rust
#[track_caller]
fn chunks_timeout(self, max_size: usize, duration: Duration) -> ChunksTimeout<Self>
where
    Self: Sized,
{
    assert!(max_size > 0, "`max_size` must be non-zero.");
    ChunksTimeout::new(self, max_size, duration)
}
〔Inférence de conception et compromis architecturaux〕

#[track_caller]fait pointer la position du panic vers l'appelant plutôt que vers l'intérieur de la bibliothèque,assert!rejette dès la phase de constructionmax_size == 0. Pourquoi faut-il vérifier à la construction ? Si l'on autorisaitmax_size == 0,ChunksTimeoutla logique de traitement par lots tomberait dans une boucle infinie « jamais assez pour remplir un lot » ou produirait des lots vides, et ce type de bug est extrêmement difficile à localiser à l'exécution. Le panic à la construction avance l'erreur au point observable le plus précoce.

timeoutettimeout_repeatingLa différence mérite aussi attention :timeoutretourne une erreur après le timeout, maiscontinue d'interroger le flux interne;timeout_repeatingquant à lui, selonIntervalproduit continuellement des erreurs de timeout, jusqu'à ce que le flux interne produise une valeur. La documentation décrit précisément cette différence avec deux exemples :

📎 tokio-stream/src/stream_ext.rs:985-1001

rust
/// Once a timeout error is received, no further events will be received
/// unless the wrapped stream yields a value (timeouts do not repeat).

📎 tokio-stream/src/stream_ext.rs:1071-1072

rust
/// Timeout errors will be continuously produced at the specified interval
/// until the wrapped stream yields a value.

---

StreamMap : ensemble dynamique de flux et interrogation équitable

Modèle intuitif

select!Le nombre de branches est fixé à la compilation. Mais le nombre de canaux à souscrire pour un service de chat, le nombre de connexions à suivre pour un crawler, ne sont connus qu'à l'exécution.StreamMapest précisément unselect!ajoutable et supprimable à l'exécution : il place un nombre arbitraire de flux dans un ensemble, chaquenextretourne(key, value), vous indiquant de quel flux provient cette valeur. Sans lui, vous ne pourriez entasser tous les flux dans unmpsccanal, avec une couche de surcoût de transfert en plus.

Structure de données et disposition mémoire

StreamMapLe stockage est extrêmement simple — unVec:

📎 tokio-stream/src/stream_map.rs:204-208

rust
#[derive(Debug)]
pub struct StreamMap<K, V> {
    /// Streams stored in the map
    entries: Vec<(K, V)>,
}

La documentation explique explicitement le coût de ce choix :

📎 tokio-stream/src/stream_map.rs:38-44

rust
/// `StreamMap` is backed by a `Vec<(K, V)>`. There is no guarantee that this
/// internal implementation detail will persist in future versions, but it is
/// important to know the runtime implications. In general, `StreamMap` works
/// best with a "smallish" number of streams as all entries are scanned on
/// insert, remove, and polling. In cases where a large number of streams need
/// to be merged, it may be advisable to use tasks sending values on a shared
/// [`mpsc`] channel.
〔Inférence de conception et compromis architecturaux〕

Pourquoi ne pas utiliserHashMap? Parce queStreamMapl'opération centrale deinterroger tous les flux, et non une recherche par clé.VecLe balayage linéaire deswap_removeest favorable au cache CPU, etHashMapest O(1). Si l'on utilisaitpoll_next, chaqueinsertdevrait parcourir les buckets de hachage, avec une localité de cache bien pire.removeet

insertLe balayage O(n) de

📎 tokio-stream/src/stream_map.rs:446-454

rust
pub fn insert(&mut self, k: K, stream: V) -> Option<V>
where
    K: Hash + Eq,
{
    let ret = self.remove(&k);
    self.entries.push((k, stream));

    ret
}

removeL'implémentation deswap_removereflète la sémantique « supprimer puis insérer » :

📎 tokio-stream/src/stream_map.rs:471-483

rust
pub fn remove<Q>(&mut self, k: &Q) -> Option<V>
where
    K: Borrow<Q>,
    Q: Hash + Eq + ?Sized,
{
    for i in 0..self.entries.len() {
        if self.entries[i].0.borrow() == k {
            return Some(self.entries.swap_remove(i).1);
        }
    }

    None
}

utilise

StreamMappour échanger l'élément supprimé avec le dernier élément puis le dépiler, évitant un déplacement O(n) :poll_next_entryCopieParcours guidé par scénario : point de départ aléatoire et correction du curseur de poll_next_entryLe cœur de

📎 tokio-stream/src/stream_map.rs:515-550

rust
fn poll_next_entry(&mut self, cx: &mut Context<'_>) -> Poll<Option<(usize, V::Item)>> {
    let start = self::rand::thread_rng_n(self.entries.len() as u32) as usize;
    let mut idx = start;

    for _ in 0..self.entries.len() {
        let (_, stream) = &mut self.entries[idx];

        match Pin::new(stream).poll_next(cx) {
            Poll::Ready(Some(val)) => return Poll::Ready(Some((idx, val))),
            Poll::Ready(None) => {
                // Remove the entry
                self.entries.swap_remove(idx);

                // Check if this was the last entry, if so the cursor needs
                // to wrap
                if idx == self.entries.len() {
                    idx = 0;
                } else if idx < start && start <= self.entries.len() {
                    // The stream being swapped into the current index has
                    // already been polled, so skip it.
                    idx = idx.wrapping_add(1) % self.entries.len();
                }
            }
            Poll::Pending => {
                idx = idx.wrapping_add(1) % self.entries.len();
            }
        }
    }

    // If the map is empty, then the stream is complete.
    if self.entries.is_empty() {
        Poll::Ready(None)
    } else {
        Poll::Pending
    }
}

. Il part d'un

point de départ aléatoire thread_rng_npour commencer l'interrogation, afin de garantir l'équité — si l'on commençait toujours à l'indice 0, le premier flux affamerait les suivants :FastRandCopiexorshift64+Ce code comporte trois subtilités, décomposées une à une :

📎 tokio-stream/src/stream_map.rs:765-768

rust
/// Implement `xorshift64+`: 2 32-bit `xorshift` sequences added together.
/// Shift triplet `[17,7,16]` was calculated as indicated in Marsaglia's
/// `Xorshift` paper

fastrand_nutilise un% n:

📎 tokio-stream/src/stream_map.rs:787-792

rust
pub(crate) fn fastrand_n(&self, n: u32) -> u32 {
    // This is similar to fastrand() % n, but faster.
    // See https://lemire.me/blog/2016/06/27/a-fast-alternative-to-the-modulo-reduction/
    let mul = (self.fastrand() as u64).wrapping_mul(n as u64);
    (mul >> 32) as u32
}

:swap_removeCopieutilise le modulo multiplicatif de Lemire à la place deidxCopieNoneDeuxièmement,swap_removela correction du curseur aprèsidx. Lorsque l'indicedu flux retourneest retiré,startdéplace le dernier élément versidx < start && start <= self.entries.len(). Cet élément déplacé peutidx = idx.wrapping_add(1) % lenavoir déjà été interrogéidx == len(si son indice d'origine était avant

). Le code utilisePoll::Pendingpour détecter ce cas, et si oui le saute (). Si c'est le dernier élément qui est retiré (Pending), le curseur revient à 0.

poll_nextTroisièmement,poll_next_entryla sémantique de

📎 tokio-stream/src/stream_map.rs:676-683

rust
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
    if let Some((idx, val)) = ready!(self.poll_next_entry(cx)) {
        let key = self.entries[idx].0.clone();
        Poll::Ready(Some((key, val)))
    } else {
        Poll::Ready(None)
    }
}

. À ce moment, les Wakers de tous les flux sont enregistrés, et dès que l'un est prêt, il y a réveil.ready!ajoute la clé au-dessus depoll_next_entry:PendingCopiepoll_nextNotez la macroPending。K: Clone: sikey.clone()。

retourne

next_many, toutStreamMapretourne immédiatement

📎 tokio-stream/src/stream_map.rs:581-583

rust
pub async fn next_many(&mut self, buffer: &mut Vec<(K, V::Item)>, limit: usize) -> usize {
    poll_fn(|cx| self.poll_next_many(cx, buffer, limit)).await
}

Réflexion de conception : sémantique par lots et sécurité à l'annulation de next_many

📎 tokio-stream/src/stream_map.rs:573-578

rust
/// # Cancel safety
///
/// This method is cancel safe. If `next_many` is used as the event in a
/// [`tokio::select!`] statement and some other branch completes first,
/// it is guaranteed that no items were received on any of the underlying
/// streams.

, collectant autant d'éléments prêts que possible en une fois :next_manyCopieSa garantie de sécurité à l'annulation est cruciale :bufferCopiebufferPourquoibufferest-il sûr à l'annulation ? Parce qu'il pousse les éléments

poll_next_manyimmédiatement dans lepoll_next_entryfourni par l'appelant, plutôt que de les stocker temporairement en interne. Si le future est drop, les éléments déjà poussés restent dans

📎 tokio-stream/src/stream_map.rs:597-666

rust
pub fn poll_next_many(
    &mut self,
    cx: &mut Context<'_>,
    buffer: &mut Vec<(K, V::Item)>,
    limit: usize,
) -> Poll<usize> {
    if limit == 0 || self.entries.is_empty() {
        return Poll::Ready(0);
    }

    let mut added = 0;

    let start = self::rand::thread_rng_n(self.entries.len() as u32) as usize;
    let mut idx = start;

    while added < limit {
        // Indicates whether at least one stream returned a value when polled or not
        let mut should_loop = false;

        for _ in 0..self.entries.len() {
            let (_, stream) = &mut self.entries[idx];

            match Pin::new(stream).poll_next(cx) {
                Poll::Ready(Some(val)) => {
                    added += 1;

                    let key = self.entries[idx].0.clone();
                    buffer.push((key, val));

                    should_loop = true;

                    idx = idx.wrapping_add(1) % self.entries.len();

                    if added == limit {
                        break;
                    }
                }
                Poll::Ready(None) => {
                    // Remove the entry
                    self.entries.swap_remove(idx);

                    // Check if this was the last entry, if so the cursor needs
                    // to wrap
                    if idx == self.entries.len() {
                        idx = 0;
                    } else if idx < start && start <= self.entries.len() {
                        // The stream being swapped into the current index has
                        // already been polled, so skip it.
                        idx = idx.wrapping_add(1) % self.entries.len();
                    }
                }
                Poll::Pending => {
                    idx = idx.wrapping_add(1) % self.entries.len();
                }
            }
        }

        if !should_loop {
            break;
        }
    }

    if added > 0 {
        Poll::Ready(added)
    } else if self.entries.is_empty() {
        Poll::Ready(0)
    } else {
        Poll::Pending
    }
}

peut déjà contenir une partie des éléments — l'appelant doit en être conscient.while added < limitLa structure de boucle deforest plus complexe queshould_loop = true, car il doit collecter autant que possible en un seul tour :limitCopie

📎 tokio-stream/src/stream_map.rs:588-591

rust
/// * `Poll::Pending` if no items are available but the `StreamMap` is not empty.
/// * `Poll::Ready(count)` where `count` is the number of items successfully received and
///   stored in `buffer`. This can be less than, or equal to, `limit`.
/// * `Poll::Ready(0)` if `limit` is set to zero or when the `StreamMap` is empty.

size_hintL'implémentation de montre comment agréger les indices de capacité de plusieurs flux :

📎 tokio-stream/src/stream_map.rs:685-701

rust
fn size_hint(&self) -> (usize, Option<usize>) {
    let mut ret: (usize, Option<usize>) = (0, Some(0));

    for (_, stream) in &self.entries {
        let hint = stream.size_hint();

        ret.0 = ret.0.saturating_add(hint.0);

        match (ret.1, hint.1) {
            (Some(a), Some(b)) => ret.1 = a.checked_add(b),
            (Some(_), None) => ret.1 = None,
            _ => {}
        }
    }

    ret
}

Identique àmerge_size_hints: saturation de la borne inférieure par addition, vérification de la borne supérieure par addition, et si l'un est inconnu, l'ensemble est inconnu.

Ci-dessous, un diagramme de flux illustrepoll_next_entryle chemin de décision de :

mermaid
flowchart TD
    start["poll_next_entry(cx)"] --> rand["start = thread_rng_n(len)"]
    rand --> loop{"遍历 len 次?"}
    loop -->|"未完成"| poll["Pin::new(stream).poll_next(cx)"]
    poll -->|"Ready(Some(val))"| ret_val["返回 Ready(Some((idx, val)))"]
    poll -->|"Ready(None)"| remove["entries.swap_remove(idx)"]
    remove --> wrap{"idx == entries.len()?"}
    wrap -->|"是"| set_zero["idx = 0"]
    wrap -->|"否"| check_swap{"idx < start && start <= len?"}
    check_swap -->|"是"| skip["idx = idx.wrapping_add(1) % len"]
    check_swap -->|"否"| loop
    set_zero --> loop
    skip --> loop
    poll -->|"Pending"| advance["idx = idx.wrapping_add(1) % len"]
    advance --> loop
    loop -->|"遍历完成"| empty{"entries.is_empty()?"}
    empty -->|"是"| ret_none["返回 Ready(None)"]
    empty -->|"否"| ret_pending["返回 Pending"]

---

TaskTracker : encoder tous les états avec un seul AtomicUsize

Modèle intuitif

Une fermeture élégante nécessite deux choses :notifier aux tâches l'arrêt du travail(CancellationTokenen est responsable), etattendre que les tâches se terminent réellement(TaskTrackeren est responsable).TaskTrackerest comme une combinaison de « compteur de tâches + interrupteur de fermeture » : tant qu'il y a des tâches en cours d'exécution, ou queclose,wait()n'a pas été appelé, il ne retournera pas. Sans lui, vous ne pourriez utiliser queJoinSet, maisJoinSetaccumulerait la valeur de retour de chaque tâche, et un service fonctionnant à long terme provoquerait un OOM.

Structure de données et disposition mémoire

TaskTrackerest unArcwrapper :

📎 tokio-util/src/task/task_tracker.rs:158-178

rust
pub struct TaskTracker {
    inner: Arc<TaskTrackerInner>,
}

/// Represents a task tracked by a [`TaskTracker`].
#[must_use]
#[derive(Debug)]
pub struct TaskTrackerToken {
    task_tracker: TaskTracker,
}

struct TaskTrackerInner {
    /// Keeps track of the state.
    ///
    /// The lowest bit is whether the task tracker is closed.
    ///
    /// The rest of the bits count the number of tracked tasks.
    state: AtomicUsize,
    /// Used to notify when the last task exits.
    on_last_exit: Notify,
}

C'est la disposition mémoire la plus ingénieuse de ce chapitre :unAtomicUsizeencode simultanément « est-ce fermé » et « le compteur de tâches ». Le bit le plus bas est le drapeau de fermeture, les autres bits sont le nombre de tâches (car le compteur de tâches à chaque+2, le bit le plus bas est toujours 0). Ainsiis_closed_and_emptyne nécessite qu'un seul chargement atomique :

📎 tokio-util/src/task/task_tracker.rs:216-222

rust
fn is_closed_and_empty(&self) -> bool {
    // If empty and closed bit set, then we are done.
    //
    // The acquire load will synchronize with the release store of any previous call to
    // `set_closed` and `drop_task`.
    self.state.load(Ordering::Acquire) == 1
}
〔Inférence de conception et compromis architecturaux〕

state == 1signifie « le bit de fermeture est 1, le compteur est 0 ». Pourquoi ne pas utiliser deux variables atomiques ? Deux variables nécessitent deux chargements, et il est impossible de déterminer atomiquement « les deux conditions sont satisfaites simultanément ». L'encodage à variable unique fait deis_closed_and_emptyun seulAcquirechargement, et sur le chemin rapide dewaitaucun verrou n'est nécessaire.

Parcours guidé par scénario : la course entre close et drop_task

Considérons un scénario typique : le thread principal appelletracker.close(), tandis que la dernière tâche est en train de se terminer (TaskTrackerToken::dropappelledrop_task). Les deux peuvent être concurrents, et il faut garantir que quel que soit celui qui agit en premier,wait()puisse être réveillé.

Regardons d'abordset_closed:

📎 tokio-util/src/task/task_tracker.rs:225-249

rust
fn set_closed(&self) -> bool {
    // The AcqRel ordering makes the closed bit behave like a `Mutex<bool>` for synchronization
    // purposes. ...
    let state = self.state.fetch_or(1, Ordering::AcqRel);

    // If there are no tasks, and if it was not already closed:
    if state == 0 {
        self.notify_now();
    }

    (state & 1) == 0
}

fetch_or(1, AcqRel)définit atomiquement le bit de fermeture et retourne l'ancienne valeur. Si l'ancienne valeur est 0 (précédemment non fermé et aucune tâche), cela signifie « après fermeture, immédiatement satisfait vide + fermé », on appellenotify_now. La valeur de retour(state & 1) == 0indique « cet appel a effectivement changé l'état ».

Regardons ensuitedrop_task:

📎 tokio-util/src/task/task_tracker.rs:264-271

rust
fn drop_task(&self) {
    let state = self.state.fetch_sub(2, Ordering::Release);

    // If this was the last task and we are closed:
    if state == 3 {
        self.notify_now();
    }
}

fetch_sub(2, Release)décrémente le compteur. Si l'ancienne valeur est 3 (binaire11: bit de fermeture 1 + compteur 1), cela signifie « c'est la dernière tâche et déjà fermé », on appellenotify_now。

Analyse de course des deux chemins :

  • close s'exécute en premier:set_closedvoit l'ancienne valeur2(compteur 1, non fermé), ne notifie pas. Ensuitedrop_taskvoit l'ancienne valeur3, notifie. ✓
  • drop_task s'exécute en premier:drop_taskvoit l'ancienne valeur2(compteur 1, non fermé), ne notifie pas. Ensuiteset_closedvoit l'ancienne valeur0(compteur 0, non fermé), notifie. ✓
  • Concurrence:fetch_oretfetch_subsont atomiques, quel que soit l'ordre d'entrelacement, il y en aura toujours un qui verra la combinaison « fermé + vide » et notifiera. ✓

notify_nowcontient unAcquirechargement facile à négliger :

📎 tokio-util/src/task/task_tracker.rs:274-285

rust
#[cold]
fn notify_now(&self) {
    // Insert an acquire fence. This matters for `drop_task` but doesn't matter for
    // `set_closed` since it already uses AcqRel.
    //
    // This synchronizes with the release store of any other call to `drop_task`, and with the
    // release store in the call to `set_closed`. That ensures that everything that happened
    // before those other calls to `drop_task` or `set_closed` will be visible after this load,
    // and those things will also be visible to anything woken by the call to `notify_waiters`.
    self.state.load(Ordering::Acquire);

    self.on_last_exit.notify_waiters();
}

Pourquoidrop_taskutiliseReleaseplutôt queAcqRel? Parce que ledrop_taskdefetch_subnécessite seulement « rendre les écritures précédentes visibles aux lecteurs suivants » (sémantique Release), et non « voir les écritures des autres threads précédents » (sémantique Acquire). Maisnotify_nownécessite Acquire pour établir le happens-before : garantir que tout le travail de nettoyage effectué avant la fin de la tâche soit visible pour le code après le retour dewait(). Le résultat de celoadest jeté, purement pour son effet de bord sur l'ordre mémoire — c'est un usage typique de « chargement de type fence » dans les opérations atomiques de Rust.

Réflexion de conception : la résistance à l'ABA de wait et la sémantique de drop de TrackedFuture

waitretourne unTaskTrackerWaitFuture, qui détient en interneNotified:

📎 tokio-util/src/task/task_tracker.rs:318-327

rust
pub fn wait(&self) -> TaskTrackerWaitFuture<'_> {
    TaskTrackerWaitFuture {
        future: self.inner.on_last_exit.notified(),
        inner: if self.inner.is_closed_and_empty() {
            None
        } else {
            Some(&self.inner)
        },
    }
}

Notons le champinner: si à la création c'est déjà « fermé et vide », on le met directement àNone,pollet on retourne immédiatementReady. C'est le chemin rapide.

La documentation souligne particulièrement la résistance à l'ABA :

📎 tokio-util/src/task/task_tracker.rs:304-307

rust
/// The `wait` future is resistant against [ABA problems][aba]. That is, if the `TaskTracker`
/// becomes both closed and empty for a short amount of time, then it is guarantee that all
/// `wait` futures that were created before the short time interval will trigger, even if they
/// are not polled during that short time interval.

Cette garantie provient de la sémantique deNotify::notified():Notifiedle future enregistre son identité de « waiter » dès sa création, même sinotify_waitersest appelé avant qu'il ne soitpoll, il verra la notification lors de son premierpoll.TaskTrackerWaitFuture::pollL'implémentation de

📎 tokio-util/src/task/task_tracker.rs:697-712

rust
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
    let me = self.project();

    let inner = match me.inner.as_ref() {
        None => return Poll::Ready(()),
        Some(inner) => inner,
    };

    let ready = inner.is_closed_and_empty() || me.future.poll(cx).is_ready();
    if ready {
        *me.inner = None;
        Poll::Ready(())
    } else {
        Poll::Pending
    }
}

CopierpollChaqueis_closed_and_empty()vérifie d'abordpoll Notified, puisNotified. Cet ordre garantit : même si

TrackedFuturen'est pas réveillé pour une raison quelconque, la vérification d'état peut servir de filet de sécurité.TaskTrackerLa sémantique de drop deJoinSetest la différence fondamentale entre

📎 tokio-util/src/task/task_tracker.rs:488-494

rust
/// The task is removed from the collection when it is dropped, not when [`poll`] returns
/// [`Poll::Ready`].

CopierReadyCela signifie : même si le future a déjà retournéTrackedFuture, tant queTaskTrackerlui-même n'a pas été drop,

📎 tokio-util/src/task/task_tracker.rs:33-35

rust
/// When a call to [`wait`] returns, it is guaranteed that all tracked tasks have exited and that
/// the destructor of the future has finished running. However, there might be a short amount of
/// time where [`JoinHandle::is_finished`] returns false.

TaskTrackerTokenCopierDropLe

📎 tokio-util/src/task/task_tracker.rs:670-672

rust
impl Drop for TaskTrackerToken {
    /// Dropping the token indicates to the [`TaskTracker`] that the task has exited.
    #[inline]
    fn drop(&mut self) {
        self.task_tracker.inner.drop_task();
    }
}

TrackedFutureest le point de déclenchement de la décrémentation du compteur :pin_project!Copiertokenempaquettefutureettokenviaspawn_blocking, le drop de

📎 tokio-util/src/task/task_tracker.rs:452-464

déclenche automatiquement la décrémentation du compteur.

CHAPTER 12

Chapitre 12 : Ordonnancement coopératif et budget : comment le mécanisme coop empêche les tâches d'affamer l'ordonnanceur

Projet concerné : tokio-rs/tokio · Progression du livre : Chapitre 12 / 14 · Statut de vérification : lignes FACT réellement ancrées

Dans le chapitre précédent, nous avons vu comment tokio-stream et tokio-util réutilisent le Waker et le mécanisme d'ordonnancement sous-jacents pour étendre les capacités du cœur. Mais quel que soit le nombre de combinateurs étendus, la contradiction fondamentale d'un runtime asynchrone demeure : l'ordonnanceur doit répartir équitablement le temps CPU entre les tâches, alors que les tâches elles-mêmes ne sont pas préemptibles — une fois que le poll d'un Future commence à s'exécuter, l'ordonnanceur ne peut pas l'interrompre de l'extérieur. Si une tâche traite cent mille messages en boucle dans un seul poll, ou attend en boucle un Future toujours prêt dans une boucle, elle monopolisera le thread worker, privant à jamais les autres tâches du même thread de toute opportunité de polling. C'est le problème classique de la « tâche qui affame l'ordonnanceur ». La solution de Tokio n'est pas la préemption, mais la coopération : allouer à chaque tâche un budget limité par cycle d'ordonnancement, les opérations sur ressources consomment ce budget, et une fois le budget épuisé, la tâche doit céder volontairement. Ce chapitre explore en profondeur l'implémentation de ce mécanisme coop.

12.1 Le support du budget : stockage local au thread et structure Budget

〔Inférence de conception et compromis architecturaux〕

Si l'on compare l'ordonnanceur au seul serveur d'un restaurant, les tâches à des clients qui commandent sans cesse, alors le budget coop est la règle « chaque client peut commander au maximum N plats » — le serveur n'a pas besoin d'interrompre le client de force, il lui suffit de dire après N plats : « Reposez-vous un instant, je sers le client suivant ». Sans cette règle, un client bavard suffirait à paralyser tout le restaurant.

Le budget doit satisfaire deux contraintes : premièrement, il doit être accessible depuis une pile d'appels de profondeur arbitraire, sans devoir passer de paramètres couche par couche ; deuxièmement, il doit pouvoir distinguer « si l'on est actuellement à l'intérieur du runtime Tokio » — en dehors du runtime, l'appel àpollne doit pas être soumis à la contrainte de budget. Tokio choisit d'utiliser leblock_onstockage local au thread (TLS)pour porter le budget, et de le gérer uniformément via le module.contextLe type central du budget est

. Bien que l'extrait de code source de ce chapitre ne donne pas directement la définition complète decoop::Budget, on peut déduire son contrat d'interface à partir des points d'utilisation decoop.rs:worker.rsCopier

📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:695-795

rust
coop::budget(|| {
    // ... 轮询任务 ...
    task.run();
    // ...
    loop {
        // ...
        if !coop::has_budget_remaining() {
            // 预算耗尽,把 LIFO 任务推回队列
            core.run_queue.push_back_or_overflow(task, ...);
            return ControlFlow::Continue(core);
        }
        // ...
    }
})

établit une portée de budget,coop::budget(closure)interroge le budget restant, et plus loin nous verronscoop::has_budget_remaining()etcoop::stop(). La sémantique decoop::set()。budgetest : à l'entrée dans la closure, réinitialiser le budget du thread courant à une valeur pleine (128 par défaut) ; pendant l'exécution de la closure, toutes les opérations sur ressources partagent ce quota ; à la sortie de la closure, restaurer le budget extérieur.

〔Inférence de conception et compromis architecturaux〕

La valeur de budget 128 est une valeur empirique : elle est suffisamment grande pour qu'une boucle normale de traitement de messages (par exemple, traiter quelques dizaines de messages par poll) ne déclenche pas fréquemment de cession ; et suffisamment petite pour qu'une boucle incontrôlée ne puisse effectuer au maximum 128 opérations sur ressources avant de devoir céder, maintenant la latence dans une plage acceptable.

BudgetDans le TLS, il existe généralement sous la formeCell<Option<Budget>>. La sémantique externe deOptionest « si le thread courant se trouve dans le contexte du runtime Tokio » :Noneindique qu'on n'est pas dans le runtime (par exemple unblock_onen dehors du runtime), auquel cas toutes les vérifications de budget passent directement.

12.2 Les points de consommation du budget : comment les opérations sur ressources le déduisent

Le budget ne se consomme pas de nulle part ; seules lesopérations sur ressourcesle déduisent. Par opérations sur ressources, on entend les API qui peuvent être appelées en boucle infinie et qui interagissent avec le monde extérieur — lesend/recvdes channels, la lecture/écriture d'I/O,yield_now, etc. Prenonsmpsc::Sender::reservecomme exemple : c'est le point d'entrée commun à tous les chemins d'envoi :

📎 tokio/src/sync/mpsc/bounded.rs:1272-1311

rust
async fn reserve_inner(&self, n: usize) -> Result<(), SendError<()>> {
    crate::trace::async_trace_leaf().await;

    if n > self.max_capacity() {
        return Err(SendError(()));
    }
    // ... WakeReceiverOnDrop guard ...
    let guard = WakeReceiverOnDrop { chan: &self.chan };
    let result = self.chan.semaphore().semaphore.acquire(n).await;
    // ...
}

reserve_innerAvant d'acquérir réellement le permis du sémaphore,crate::trace::async_trace_leaf()passe parasync_trace_leaf. Cet appel, qui semble n'être que du tracing, est en réalité l'un des points d'ancrage de la déduction du budget.coop::poll_proceedappelle en interne une fonction du typeProceed: si le budget est suffisant, déduire 1 et retournerPending; si le budget est épuisé, enregistrer une action de « cession » — remettre le Waker de la tâche courante à l'ordonnanceur, retourner

, et faire terminer la tâche prématurément lors de ce poll.C'est là la subtilité de coop :Pendingl'épuisement du budget ne lève pas d'erreur, mais déguise la « cession » en unPendingordinaire. Le Future supérieur, voyant

yield_now, retourne naturellement ; l'ordonnanceur remet la tâche en file d'attente, et au prochain ordonnancement le budget est réinitialisé, la tâche reprenant là où elle s'était interrompue. Tout le processus est totalement transparent pour le code métier.est l'expression la plus directe du mécanisme de budget : il ne consomme pas de budget, mais:

📎 tokio/src/task/yield_now.rs:38-60

rust
pub async fn yield_now() {
    let mut yielded = false;
    poll_fn(|cx| {
        ready!(crate::trace::trace_leaf());

        if yielded {
            return Poll::Ready(());
        }

        yielded = true;

        // Don't wake the task immediately, as that would push it right back
        // onto the run queue and it could be polled again before other tasks
        // or the IO/timer driver get a chance to run. Instead, hand the waker
        // to the scheduler, which wakes deferred tasks only after it has run
        // out of ready tasks and polled the driver. When polled from outside
        // a Tokio runtime, the waker is woken immediately.
        context::defer(cx.waker());

        Poll::Pending
    })
    .await
}

Copiercontext::defer(cx.waker())Notez la lignewake. Elle ne fait pas directement, mais confie le Waker à lafile defer

de l'ordonnanceur. Pourquoi ? Les commentaires du code source le disent clairement : si l'on réveille immédiatement, la tâche est aussitôt repoussée dans la file d'exécution et peut être pollée à nouveau avant que le pilote I/O/timer ne s'exécute, ce qui prive la cession de son sens. La sémantique de la file defer est « attendre que le worker courant ait terminé les tâches prêtes et ait pollé les pilotes, puis réveiller ces tâches ».ContextLa file defer est définie dans le

📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:247-257

rust
pub(crate) struct Context {
    worker: Arc<Worker>,
    core: RefCell<Option<Box<Core>>>,
    /// Tasks to wake after resource drivers are polled. This is mostly to
    /// handle yielded tasks.
    pub(crate) defer: Defer,
}

deferCopier

📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:613-621

rust
} else {
    // Wait for work
    core = if !self.defer.is_empty() {
        self.park_yield(core)
    } else {
        self.park(core)
    };
    core.stats.start_processing_scheduled_tasks();
}

Si la file defer n'est pas vide, le worker appellepark_yield— avec un timeout de 0, ce qui pilote les E/S et les timers, puis réveille les tâches dans defer. Cela garantit que les tâches « cédées » ne sont replanifiées qu'après l'exécution du pilote.

12.3 Établissement et restauration de la portée du budget : run_task et block_in_place

La portée du budget est établie dansrun_task. Chaque fois qu'une tâche est interrogée,coop::budgetenglobe tout le processus d'interrogation :

📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:691-704

rust
// Make the core available to the runtime context
*self.core.borrow_mut() = Some(core);

// Run the task
coop::budget(|| {
    // ...
    task.run();
    // ...
})

coop::budgetÀ l'entrée, le budget dans le TLS est défini au maximum, et restauré à la sortie. Cela signifie quechaque tâche obtient un budget entièrement nouveau à chaque interrogation. Peu importe combien de foisawaitdes opérations sur les ressources sont effectuées dans la tâche, si une seulepollconsomme plus de 128, la cession est forcée.

Mais il y a un problème subtil ici : les tâches dans le slot LIFO sontdans la mêmebudgetfermetureinterrogées. Regardez la boucle derun_task:

📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:709-750

rust
let mut lifo_polls = 0;

// As long as there is budget remaining and a task exists in the
// `lifo_slot`, then keep running.
loop {
    let mut core = match self.core.borrow_mut().take() {
        Some(core) => core,
        None => {
            return ControlFlow::Break(());
        }
    };

    let task = match core.lifo_slot.take() {
        Some(task) => task,
        None => {
            self.reset_lifo_enabled(&mut core);
            core.stats.end_poll();
            return ControlFlow::Continue(core);
        }
    };

    if !coop::has_budget_remaining() {
        core.stats.end_poll();
        // Not enough budget left to run the LIFO task, push it to
        // the back of the queue and return.
        core.run_queue.push_back_or_overflow(task, ...);
        debug_assert!(core.lifo_enabled);
        return ControlFlow::Continue(core);
    }
    // ...
}

Point clé : les tâches dans le slot LIFOpartagent le budget de la tâche externe. Le commentaire au début derun_taskdit : « Tasks from the LIFO slot inherit the "parent"'s limits ». C'est une conception intentionnelle — si chaque tâche LIFO réinitialisait le budget, alors dans un scénario ping-pong (la tâche A réveille B, B réveille A), les deux tâches se planifieraient mutuellement à l'infini, le budget serait toujours réinitialisé, et le problème de famine persisterait. Le partage du budget signifie que A et B consomment ensemble au maximum 128 opérations sur les ressources, après quoi elles doivent céder.

Le slot LIFO lui-même possède également un limiteur de débit indépendantMAX_LIFO_POLLS_PER_TICK:

📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:756-766

rust
// Disable the LIFO slot if we reach our limit
//
// In ping-ping style workloads where task A notifies task B,
// which notifies task A again, continuously prioritizing the
// LIFO slot can cause starvation as these two tasks will
// repeatedly schedule the other. To mitigate this, we limit the
// number of times the LIFO slot is prioritized.
if lifo_polls >= MAX_LIFO_POLLS_PER_TICK {
    core.lifo_enabled = false;
    super::counters::inc_lifo_capped();
}

MAX_LIFO_POLLS_PER_TICKLa valeur de

📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:263-263

rust
/// Value picked out of thin-air. Running the LIFO slot a handful of times
/// seems sufficient to benefit from locality. More than 3 times probably is
/// over-weighting. The value can be tuned in the future with data that shows
/// improvements.
const MAX_LIFO_POLLS_PER_TICK: usize = 3;

CopierC'estla deuxième ligne de défense

: même si le budget n'est pas épuisé, le slot LIFO est désactivé après avoir été priorisé 3 fois consécutivement, et les tâches suivantes passent par la file normale. Le budget gère le « volume total d'opérations sur les ressources », le limiteur LIFO gère le « nombre de réveils mutuels entre la même paire de tâches », les deux sont complémentaires.block_in_placeLa portée du budget a une exception importante dansblock_in_place.transfère le worker core à un autre thread, et le thread actuel entre en état de blocage. Le code bloquant n'est pas soumis au budget, il faut doncsuspendre

📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:406-417

rust
if had_entered {
    // Unset the current task's budget. Blocking sections are not
    // constrained by task budgets.
    let _reset = Reset {
        take_core,
        budget: coop::stop(),
    };

    crate::runtime::context::exit_runtime(f)
} else {
    f()
}

coop::stop()CopierNoneretourne le budget actuel et le définit àReset(c'est-à-dire « pas dans le runtime »),Drople

📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:374-397

rust
impl Drop for Reset {
    fn drop(&mut self) {
        with_current(|maybe_cx| {
            if let Some(cx) = maybe_cx {
                if self.take_core {
                    let core = cx.worker.core.take();
                    // ...
                    *cx_core = core;
                }

                // Reset the task budget as we are re-entering the
                // runtime.
                coop::set(self.budget);
            }
        });
    }
}

coop::set(self.budget)est restauré après la fin du blocage :stop()Copierblock_in_placerestaure le budget précédemment sauvegardé par

. Ainsi, le code bloquant synchrone dans

mermaid
flowchart TD
    start["Context::run 主循环"] --> next["core.next_task()"]
    next --> has_task{"有本地任务?"}
    has_task -->|是| run_task["run_task(task, core)"]
    has_task -->|否| steal["core.steal_work()"]
    steal --> stolen{"窃取到任务?"}
    stolen -->|是| run_task
    stolen -->|否| defer_check{"defer 队列非空?"}
    defer_check -->|是| park_yield["park_yield: 驱动 IO/timer 后唤醒"]
    defer_check -->|否| park["park: 阻塞等待"]
    park_yield --> start
    park --> start

    run_task --> budget["coop::budget 建立满额预算"]
    budget --> poll["task.run() 轮询"]
    poll --> lifo_check{"lifo_slot 有任务?"}
    lifo_check -->|否| done["返回 ControlFlow::Continue"]
    lifo_check -->|是| budget_rem{"coop::has_budget_remaining()?"}
    budget_rem -->|否| push_back["push_back_or_overflow 推回队列"]
    push_back --> done
    budget_rem -->|是| lifo_limit{"lifo_polls >= 3?"}
    lifo_limit -->|是| disable["core.lifo_enabled = false"]
    lifo_limit -->|否| poll_lifo["task.run() 轮询 LIFO 任务"]
    disable --> poll_lifo
    poll_lifo --> lifo_check
    done --> start

La figure ci-dessous montre le flux de contrôle complet depuis la planification d'une tâche jusqu'à la cession par épuisement du budget :push_back_or_overflowCopier

On peut voir deux chemins de cession dans la figure : lorsque le budget est épuisé, la tâche LIFO est repoussée dans la file (

), et lorsque la priorité LIFO consécutive dépasse la limite, le slot LIFO est désactivé. Les deux reviennent à la boucle principale, donnant au worker l'opportunité de traiter d'autres tâches ou de piloter.12.4 Réflexions de conception, récupération d'erreurs et pièges en productionBudgetPourquoi utiliser TLS plutôt que le passage explicite de paramètres ?#[thread_local]Les points de contrôle du budget sont dispersés profondément dans les modules channel, I/O, time, etc. Si les paramètres étaient passés explicitement, chaque API devrait avoir un paramètre

supplémentaire, polluant toute l'interface publique. Le TLS rend le budget totalement transparent pour le code métier, au prix d'un accès TLS à chaque vérification. Tokio utiliseou un TLS rapide spécifique à la plateforme pour réduire ce coût.reserve_innerInteraction entre épuisement du budget et sécurité d'annulation.PendingLorsque l'épuisement du budget fait queselect!retourneselect!, la tâche peut se trouver dans une branche dereserve_inner. Si à ce moment une autre branche est prête,WakeReceiverOnDropannule la branche actuelle —

📎 tokio/src/sync/mpsc/bounded.rs:1286-1299

rust
struct WakeReceiverOnDrop<'a, T> {
    chan: &'a chan::Tx<T, Semaphore>,
}

impl<T> Drop for WakeReceiverOnDrop<'_, T> {
    fn drop(&mut self) {
        use chan::Semaphore;

        let semaphore = self.chan.semaphore();
        if semaphore.is_closed() && semaphore.is_idle() {
            self.chan.wake_rx();
        }
    }
}

dePendingguard vérifie au drop que « le sémaphore est fermé et inactif » et réveille le récepteur :PendingCopier

L'existence de ce guard montre que : ledéclenché par le budget et le véritablespawn« sans permis »yield_now。

doivent se comporter de manière identique sur le chemin d'annulation, sinon le récepteur pourrait ne jamais recevoir la notification « channel fermé ».block_in_placePiège en production : latence cachée causée par l'épuisement du budget.Un phénomène courant est : une tâche traitant des messages ralentit soudainement, mais l'utilisation CPU n'est pas élevée. Lors du diagnostic, on soupçonne facilement une contention de verrous ou des E/S, alors qu'en réalité la tâche a traité plus de 128 messages dans un seul poll, déclenchant la cession de budget, et chaque cession passe par un cycle complet « remise en file → replanification → interrogation du pilote ». Si le traitement des messages est lui-même rapide, ce coût de planification peut représenter une proportion élevée. La solution est de découper le traitement par lots en plusieurs tâchesblock_in_place, ou d'insérer explicitementcoop::stop()dans la boucle.coop::stop()Frontière entre budget ethad_entered.block_in_placeOn a vu précédemment quef()faitmaybe_move_runtimesuspendre le budget. Mais attention :

📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:424-464

rust
with_current(|maybe_cx| {
    match (
        crate::runtime::context::current_enter_context(),
        maybe_cx.is_some(),
    ) {
        (context::EnterRuntime::Entered { .. }, true) => {
            had_entered = true;
        }
        (
            context::EnterRuntime::Entered {
                allow_block_in_place,
            },
            false,
        ) => {
            if allow_block_in_place {
                had_entered = true;
                return Ok(());
            } else {
                return Err(
                    "can call blocking only when running on the multi-threaded runtime",
                );
            }
        }
        (context::EnterRuntime::NotEntered, true) => {
            return Ok(());
        }
        (context::EnterRuntime::NotEntered, false) => {
            return Ok(());
        }
    }
    // ...
})

est vrai, c'est-à-dire lorsqu'on est effectivement sur un thread worker du runtime. Siblock_onest appelé en dehors du runtime,block_in_places'exécute directement, l'état du budget reste inchangé. Ce branchement est effectué dans

:

CopierLes quatre combinaisons correspondent respectivement à : dans un thread worker,Builderoption. C'est intentionnel : la valeur du budget influence le compromis entre équité d'ordonnancement et débit ; si l'on permettait aux utilisateurs de l'ajuster librement, il serait facile de produire une configuration où « un budget trop grand provoque la famine » ou « un budget trop petit fait exploser le coût d'ordonnancement ». Tokio choisit d'en faire un invariant interne.

Résumé du chapitre

Le mécanisme coop résout le problème d'équité d'un ordonnanceur non préemptif grâce à une conception en trois couches :

1. Porteur du budget:coop::Budgetstocké dans le TLS,Optionla couche externe distingue l'intérieur et l'extérieur du runtime,coop::budgetétablit une portée à pleine capacité,coop::stop/coop::setprend en charge la pause et la reprise (block_in_placescénario).

2. Points de consommation: opérations sur les ressources (envoi/réception sur channel, I/O,yield_now) viacoop::poll_proceeddécrémentent le budget ; lorsqu'il est épuisé, le « yield » est déguisé enPending, de manière transparente pour le code métier.

3. Chemin de yield:yield_nowviacontext::deferremet le Waker à la file defer, garantissant une reprogrammation seulement après le polling du driver ; les tâches du slot LIFO partagent le budget de la tâche parente et disposent d'une limitation de débit indépendante deMAX_LIFO_POLLS_PER_TICK = 3.

L'idée clé de ce mécanisme est :l'équité n'exige pas la préemption, il suffit que la « boucle infinie » s'interrompe naturellement après un nombre fini d'étapes. Le budget est la mesure de ce « nombre fini d'étapes ».

Réflexions et auto-évaluation du chapitre

Q1 : Si l'on modifiaitrun_taskdanscoop::budgetla boucle LIFO à l'intérieur de la closure pour appelercoop::budgetà chaque polling d'une tâche LIFO afin de réinitialiser le budget, que se passerait-il dans un scénario ping-pong (la tâche A réveille B, B réveille A) ? Pourquoi le code source choisit-il de faire partager le budget de la tâche parente aux tâches LIFO ?

Analyse de référence: le code source indique explicitement dans les commentaires derun_taskque « Tasks from the LIFO slot inherit the "parent"'s limits »📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:679-682. Si chaque tâche LIFO réinitialisait le budget, alors dans le scénario ping-pong A→B→A→B, chaque polling obtiendrait un budget plein, et les deux tâches pourraient s'ordonnancer mutuellement à l'infini, sans jamais céder par épuisement du budget. Bien queMAX_LIFO_POLLS_PER_TICK = 3la limitation de débit désactive le slot LIFO après 3 fois📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:756-766, une fois le LIFO désactivé, les tâches passent par la file normale ; si la file ne contient que A et B, elles continueront d'être ordonnancées en alternance, mais sans bénéficier de la priorité LIFO. Le partage du budget garantit au niveau du volume total d'opérations sur les ressources : A et B réunis peuvent consommer au plus 128 opérations sur les ressources avant de devoir céder, laissant une chance aux autres tâches et au driver. Les deux lignes de défense sont complémentaires et indispensables.

Q2: yield_nowutilisecontext::defer(cx.waker())plutôt quecx.waker().wake_by_ref(). Supposons que l'on remplacedeferpar unwakedirect ; dans un scénario mono-worker multi-tâches, quelles seraient les conséquences si une tâche appelaityield_nowen boucle ? Analysez en lien avec la branchepark_yieldde la boucle principale du worker.

Analyse de référence:yield_nowles commentaires de📎 tokio/src/task/yield_now.rs:49-54expliquent la raison : un wake direct remettrait immédiatement la tâche dans la file d'exécution, et elle pourrait être re-pollée avant que le driver I/O/timer ne s'exécuteyield_now. Dans un scénario mono-worker, si une tâche appellenext_tasken boucle et fait un wake direct à chaque fois, la boucle principale du workerpark_yieldprendrait immédiatement cette tâche et la re-pollerait,📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:613-621la branche (chargée de piloter les I/O et les timers)deferne serait jamais exécutée, car la file defer est vide et la file locale contient toujours des tâches. Il en résulterait que les événements I/O et les timers ne seraient jamais traités, et tout le runtime serait « faussement actif » — les tâches tournent, mais les événements du monde extérieur ne peuvent pas progresser.

Q3: block_in_placela file garantit qu'une tâche cédée ne sera réveillée qu'après le polling du driver, laissant ainsi une fenêtre d'exécution au driver.coop::stop()dansNone,Reset::dropdéfinit le budget àcoop::set(self.budget)dansblock_in_placerestaure. Si à l'intérieur de la closurefdeblock_in_placeon appelle à nouveaumaybe_move_runtime(imbrication), que devient l'état du budget ?

Quelle branche degère ce cas ?block_in_placeAnalyse de référencemaybe_move_runtime: l'imbrication de(context::EnterRuntime::NotEntered, true)est gérée par la branche📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:454-458dansreturn Ok(()). Cette branche fait directementhad_entered, sans définirblock_in_place, donc leif had_enteredde la couche externecoop::stop()est évalué comme faux, etResetne sera pas appelé à nouveau ni un nouveauf()créé. Le commentaire précise : « This is a nested call to block_in_place (we already exited). All the necessary setup has already been done. » — la couche externe a déjà mis le budget en pause et transféré le core ; la couche interne n'a qu'à exécuter directementcoop::stop(). Si la couche interne appelait à nouveauNone, elle sauvegarderait une seconde fois un budget déjàReset::drop, et la restaurationNonepourrait rétablir une valeur erronée (

au lieu du budget d'origine de la couche externe), entraînant une perte définitive du budget ; toutes les opérations ultérieures sur les ressources de la tâche ne seraient alors plus contraintes.

CHAPTER 13

Chapitre suivant : Chapitre 13 →

Chapitre 13 : Pièges de production et conditions limites : sécurité à l'annulation, propagation de panic et ordre d'arrêt · Projet concerné : tokio-rs/tokio · Progression du livre : Chapitre 13 / 14

Dans le chapitre précédent, nous avons décomposé le budget de coopération coop : chaque tâche ne dispose que d'un budget limité au cours d'un cycle de planification, et doit céder une fois épuisé, évitant ainsi qu'une seule tâche affame les autres. Mais le mécanisme de budget ne résout que le problème de « planification équitable » ; dans un environnement de production réel, il existe une autre catégorie de pièges plus insidieux — la sûreté d'annulation, la propagation de panic et l'ordre d'arrêt. Lorsque select! annule un Future, lorsqu'un panic de tâche est capturé, lorsque le Runtime commence à s'arrêter, le comportement aux limites du code va souvent à l'encontre de l'intuition. Ce chapitre commence par la sûreté d'annulation, en examinant d'abord ce qu'un Future abandonné par drop perd réellement.

13.2 Propagation de panic : comment JoinError capture les effondrements

Modèle intuitif

Un panic de tâche Tokio ne fait pas planter tout le processus (sauf si panic=abort), mais est capturé, empaqueté enJoinError, et renvoyé viaJoinHandle::await. C'est comme un accident à un poste sur une chaîne de montage : le filet de sécurité rattrape l'ouvrier, mais le produit est mis au rebut — vous obtenez un « rapport d'accident » plutôt que le produit.

Structure de données et états

JoinHandle<T>LeFuture::Outputdesuper::Result<T>estResult<T, JoinError> 📎 tokio/src/runtime/task/join.rs:325。JoinError, c'est-à-dire que

rust
let join_handle = tokio::spawn(async { panic!("boom"); });
let err = join_handle.await.unwrap_err();
assert!(err.is_panic());

📎 tokio/src/runtime/task/join.rs:121-127

CopierRawTaskLe mécanisme de capture du panic se trouve dans le chemin poll decatch_unwind: lors du poll de la tâche, on l'enveloppe avecJoinHandle::poll, et après le panic, la payload est stockée dans le slot de sortie de la tâche, l'état est marqué comme complete, puis le join waker est réveillé.try_read_outputCe qui est lu viaErr(JoinError::panic(payload))。

est

mermaid
sequenceDiagram
    participant App as 应用任务
    participant Worker as Worker 线程
    participant Raw as RawTask
    participant JH as JoinHandle

    App->>Worker: spawn(async { panic!("boom") })
    Worker->>Raw: poll 任务 Future
    Raw->>Raw: catch_unwind 捕获 panic
    Raw->>Raw: 存储 panic payload 到输出槽
    Raw->>Raw: state 标记 complete
    Raw->>JH: 唤醒 join waker
    JH->>App: await 返回 Err(JoinError::panic)

CopierJoinErrorPoint clé : la payload du panic est intégralement préservée,std::error::Errorimplémenteinto_panic(), on peut récupérerBox<dyn Any + Send>viadowncast_ref::<&str>(), puis extraire le message de panic avec

Réflexions de conception et pièges

Piège 1 :JoinHandleLeUnwindSafede

rust
impl<T> UnwindSafe for JoinHandle<T> {}
impl<T> RefUnwindSafe for JoinHandle<T> {}

📎 tokio/src/runtime/task/join.rs:176-181

CopierT: UnwindSafeC'est une implémentation inconditionnelle, qui n'exige pasJoinHandle. Raison :T,Tne détient pas lui-mêmecatch_unwindDans l'allocation de tâche sur le tas, lors d'un panic, il a déjà été isolé parT. Donc même siUnwindSafe,JoinHandlen'est pas

, c'est sûr.Piège 2 : un panic ne se propage pas automatiquement à la tâche parente.JoinHandleSi la tâche A spawn la tâche B, et que B panic, A n'en est pas notifiée automatiquement, sauf si A a await le

de B. Si A n'a pas await, le panic de B est silencieusement avalé. C'est l'une des sources de bugs les plus insidieuses en production.spawn_blockingPiège 3 :Le panic decatch_unwindest également capturé.MutexLes workers du pool de threads bloquants enveloppent aussi les tâches avecstd::sync::Mutex; après un panic, le thread ne meurt pas, mais retourne dans le pool pour continuer à prendre du travail. Mais si vous détenez un

dans une tâche bloquante et ne le libérez pas lors du panic, cela provoque un empoisonnement de verrou — c'est le comportement inhérent de, Tokio n'intervient pas.catch_unwindPiège 4 : panic lors du drop du Runtime.

Si une tâche panic pendant le drop du Runtime,

reste effectif, mais à ce moment le join waker peut déjà être invalide, et la payload du panic sera abandonnée. C'est un sous-ensemble du problème d'ordre d'arrêt, développé dans la section suivante.

13.3 Ordre d'arrêt : nettoyage des threads bloquants et des ressources d'E/S

Modèle intuitif

RuntimeL'arrêt du Runtime ressemble à la fermeture d'un restaurant : d'abord on demande à l'accueil d'arrêter de prendre des clients (arrêter d'accepter de nouvelles tâches), puis on attend que la cuisine termine les plats en cours (les tâches asynchrones atteignent le prochain point de yield), enfin on attend que les extras sous-traitants terminent (les threads bloquants retournent). Un ordre erroné pose problème — par exemple, si l'on renvoie d'abord les extras, les plats de la cuisine ne seront jamais terminés.

rust
pub struct Runtime {
    scheduler: Scheduler,
    handle: Handle,
    blocking_pool: BlockingPool,
}

📎 tokio/src/runtime/runtime.rs:97-106

DropLes trois champs de

rust
impl Drop for Runtime {
    fn drop(&mut self) {
        match &mut self.scheduler {
            Scheduler::CurrentThread(current_thread) => {
                let _guard = context::try_set_current(&self.handle.inner);
                current_thread.shutdown(&self.handle.inner);
            }
            Scheduler::MultiThread(multi_thread) => {
                multi_thread.shutdown(&self.handle.inner);
            }
        }
    }
}

📎 tokio/src/runtime/runtime.rs:506-521

CopierDropImplémentation descheduler,:blocking_pool。blocking_poolCopierDropRemarque :Runtime::dropne traite quescheduler → handle → blocking_poolne traite pas explicitement

L'arrêt deshutdown_timeoutse produit dans son propre

rust
pub fn shutdown_timeout(mut self, duration: Duration) {
    self.handle.inner.shutdown();
    self.blocking_pool.shutdown(Some(duration));
}

📎 tokio/src/runtime/runtime.rs:457-461

. L'ordre de drop des champs est l'ordre de déclaration :handle.inner.shutdown(). Donc le pool bloquant est arrêté en dernier.blocking_pool.shutdown(Some(duration))Maisduration。

contrôle explicitement l'ordre :

blocking/shutdown.rsCopier

rust
pub(super) struct Sender {
    _tx: Arc<oneshot::Sender<()>>,
}

pub(super) struct Receiver {
    rx: oneshot::Receiver<()>,
}

📎 tokio/src/runtime/blocking/shutdown.rs:13-19

notifie le planificateur et le driver d'E/S de s'arrêter, puisSenderattend les tâches bloquantes, au maximumArc<oneshot::Sender>Mécanisme sous-jacent de l'arrêt du pool bloquantSenderutilise un ingénieux oneshot channel :ReceiverCopierwaitChaque worker bloquant détient un clone de

rust
pub(crate) fn wait(&mut self, timeout: Option<Duration>) -> bool {
    use crate::runtime::context::try_enter_blocking_region;

    if timeout == Some(Duration::from_nanos(0)) {
        return false;
    }

    let mut e = match try_enter_blocking_region() {
        Some(enter) => enter,
        _ => {
            if std::thread::panicking() {
                return false;
            } else {
                panic!(
                    "Cannot drop a runtime in a context where blocking is not allowed. \
                    This happens when a runtime is dropped from within an asynchronous context."
                );
            }
        }
    };

    if let Some(timeout) = timeout {
        e.block_on_timeout(&mut self.rx, timeout).is_ok()
    } else {
        let _ = e.block_on(&mut self.rx);
        true
    }
}

📎 tokio/src/runtime/blocking/shutdown.rs:37-70

). Lorsque tous les workers ont quitté et que tous les

1. timeout == Some(0)sont drop,shutdown_backgroundreçoit la notification.

2. try_enter_blocking_region()MéthodeNone。

:

Copierblock_on_timeoutAnalyse étape par étape :

retourne directement false — c'est le chemin de

mermaid
flowchart TD
    start["Runtime::drop 或 shutdown_timeout"] --> sched{"scheduler 类型?"}
    sched -->|CurrentThread| ct["try_set_current + current_thread.shutdown"]
    sched -->|MultiThread| mt["multi_thread.shutdown"]
    ct --> handle_drop["handle 字段 drop"]
    mt --> handle_drop
    handle_drop --> bp_drop["blocking_pool 字段 drop"]
    bp_drop --> bp_wait{"shutdown_timeout 已调用?"}
    bp_wait -->|是| explicit["blocking_pool.shutdown(Some(duration))"]
    bp_wait -->|否| implicit["BlockingPool::drop 默认等待"]
    explicit --> wait_check{"try_enter_blocking_region 成功?"}
    implicit --> wait_check
    wait_check -->|否且在 panic| skip["返回 false 不等待"]
    wait_check -->|否且不在 panic| panic_err["panic: Cannot drop a runtime in async context"]
    wait_check -->|是| block_on["block_on 等待所有 Sender drop"]

Tente d'entrer dans la zone bloquante. Si l'on est actuellement dans un contexte asynchrone (par exemple, drop du Runtime dans une tâche async), retourne

3. En cas d'échec d'entrée, si un panic est en cours, retourne false (ne pas panic pendant un panic) ; sinon panic avec un message d'erreur explicite.Le message d'erreur est très clair : « Cannot drop a runtime in a context where blocking is not allowed »📎 tokio/src/runtime/blocking/shutdown.rs:51-54. La solution consiste à utilisershutdown_background(), ce qui équivaut àshutdown_timeout(Duration::from_nanos(0)) 📎 tokio/src/runtime/runtime.rs:494-496, sans attendre les tâches bloquantes.

Piège 2 :shutdown_backgroundva fuiter des tâches bloquantes.La documentation avertit explicitement « this may result in a resource leak (in that any blocking tasks are still running until they return) »📎 tokio/src/runtime/runtime.rs:470-472. Les tâches bloquantes continueront de s'exécuter jusqu'à leur retour naturel, mais le Runtime ayant déjà été drop, les ressources qu'elles détiennent peuvent être déjà invalides.

Piège 3 : les ressources d'E/S deviennent invalides après le drop du Runtime.La documentation indique « Once the runtime has been dropped, any outstanding I/O resources bound to it will no longer function »📎 tokio/src/runtime/runtime.rs:52-54。is_rt_shutdown_errLa fonction📎 tokio/src/runtime/runtime.rs:585-593。

sert précisément à détecter ce type d'erreur.DropPiège 4 :attend indéfiniment par défaut.Drop implementation waits forever for this」📎 tokio/src/runtime/runtime.rs:43-44La documentation précise « Theshutdown_timeout. Si une tâche bloquante se bloque (par exemple une boucle infinie), le drop du Runtime suspendra indéfiniment. En production, il faut utiliser

pour fixer une limite.

13.4 Gestion des signaux et conflits entre plusieurs Runtime

Modèle intuitifSignalLes signaux Unix sont au niveau du processus, mais le

de Tokio est lié au Runtime. C'est comme si tout l'immeuble partageait une alarme incendie, mais que chaque pièce installait son propre récepteur — la première personne à installer un récepteur modifie le câblage de l'alarme, et les suivants ne peuvent que partager cette modification.

signal_enableStructures de données et état global

rust
fn signal_enable(signal: SignalKind, handle: &Handle) -> io::Result<()> {
    let signal = signal.0;
    if signal <= 0 || signal_hook_registry::FORBIDDEN.contains(&signal) {
        return Err(Error::other(format!(
            "Refusing to register signal {signal}"
        )));
    }

    handle.check_inner()?;

    let globals = globals();
    let siginfo = match globals.storage().get(signal as EventId) {
        Some(slot) => slot,
        None => return Err(io::Error::other("signal too large")),
    };

    siginfo
        .init
        .get_or_init(|| {
            unsafe { signal_hook_registry::register(signal, move || action(globals, signal)) }
                .map(|_| ())
                .map_err(|e| e.raw_os_error())
        })
        .map_err(|e| {
            e.map_or_else(
                || Error::other("registering signal handler failed"),
                || Error::from_raw_os_error,
            )
        })
}

📎 tokio/src/signal/unix.rs:266-296

Copier

1. signal <= 0 || FORBIDDEN.contains(&signal)Points clés :

2. handle.check_inner()rejette les signaux illégaux.

3. siginfo.init.get_or_init(...)vérifie si le driver de signaux est en cours d'exécution — si le Runtime est déjà fermé, cela échouera ici.OnceLockutiliseget_or_initpour garantir qu'un seul handler OS est enregistré par signal.signal_hook_registry::registerLa closure de

appelleaction(globals, signal), qui est un enregistrement global, au niveau du processus.globals.record_event(signal)4. Le handler enregistré est📎 tokio/src/signal/unix.rs:252-259。

, il fait deux choses :

globals()enregistre l'événement, puis écrit un octet dans le pipe pour réveiller le driverGlobals,OsExtraDataOrigine des conflits entre plusieurs RuntimeUnixStreamCe que retourne

rust
pub(crate) struct OsExtraData {
    sender: UnixStream,
    pub(crate) receiver: UnixStream,
}

📎 tokio/src/signal/unix.rs:61-64

Defaultglobal au niveau du processus.UnixStream 📎 tokio/src/signal/unix.rs:61-64Le

danssignal_enableest également global :handle.check_inner()CopierL'implémentation decrée une paire designal_hook_registry::register. Ce pipe est globalement unique, tous les drivers de signaux des Runtime le partagent.Le problème se pose :Dans,vérifie le driver de signaux duget_or_initRuntime actuelOk(()). Mais le handler enregistré par

est

mermaid
sequenceDiagram
    participant OS as 操作系统
    participant Handler as 全局 signal handler
    participant Pipe as 全局 UnixStream pipe
    participant RtA as Runtime A 信号驱动
    participant RtB as Runtime B 信号驱动

    Note over RtA: signal(SIGINT) 注册
    RtA->>Handler: signal_hook_registry::register(SIGINT, action)
    Note over RtB: signal(SIGINT) 注册
    RtB->>Handler: get_or_init 返回已有 Ok,不重复注册
    OS->>Handler: 投递 SIGINT
    Handler->>Pipe: write(&[1])
    Pipe->>RtA: 可读事件
    Pipe->>RtB: 可读事件
    Note over RtA,RtB: 两个 Runtime 竞争读取,只有一个能读到字节

, et il écrit dans le

pipe global. Si le Runtime A enregistre SIGINT en premier, puis le Runtime B enregistre aussi SIGINT,📎 tokio/src/signal/unix.rs:379-380retournera directement leSignalexistant, sans réenregistrer. Mais le driver de signaux du Runtime B lira les données depuis le pipe global — les deux Runtime se disputeront les octets du même pipe.📎 tokio/src/signal/unix.rs:338-340。

Walkthrough guidé par scénario : compétition de signaux entre plusieurs RuntimeCopierpoll is called, all signal notifications are coalesced into one item returned from poll」📎 tokio/src/signal/unix.rs:312-315Réflexions de conception et pièges

Piège 1 : le gestionnaire de signaux n'est jamais désenregistré.La documentation avertit explicitement « Once a signal handler is registered with the process the underlying libc signal handler is never unregistered »signal_hook. Même si l'instance

est drop, les signaux suivants seront toujours capturés par Tokio, et le comportement par défaut ne sera pas restaurésignalPiège 2 : les signaux sont fusionnés.La documentation indique « beforert feature flag is not enabled」📎 tokio/src/signal/unix.rs:398-405. Si vous recevez 10 SIGINT mais ne poll qu'une seule fois, vous ne verrez qu'un seul événement. C'est une caractéristique des signaux Unix eux-mêmes (les signaux standard ne sont pas mis en file d'attente), Tokio n'effectue pas de fusion supplémentaire.signal()Piège 3 : les signaux peuvent être perdus avec plusieurs Runtime.

Comme le pipe global est lu en compétition par plusieurs Runtime, un Runtime peut consommer l'octet tandis qu'un autre attendra indéfiniment. En production, il faut traiter les signaux dans un seul Runtime, ou utiliserrecv()pour gérer soi-même.Piège 4 :tokio::select! and another branch completes first, then it is guaranteed that no signal is lost」📎 tokio/src/signal/unix.rs:423-427conditions de panic de la fonction.EventInfoLa documentation indique « This function panics if there is no current reactor set, or if therecv(). Appeler

en dehors d'un Runtime provoquera un panic.

Piège 5 :la sûreté d'annulation de。

  • JoinHandle. La documentation garantit « This method is cancel safe. If you use it as a branch in
  • . Cela s'explique par le fait que les événements de signaux sont stockés dans leHandle, un ordre incorrect entraînera un deadlock ou une panique.
  • Les signaux entrent en conflit avec plusieurs Runtime, car le handler et le pipe sont des états globaux au niveau du processus, tandis queSignalest une vue au niveau du Runtime.

Après avoir compris ce modèle, la liste des pièges à éviter peut être résumée en trois principes :

1. Annulation sûre = l'état est en dehors du Future.Si le Future contient un tampon interne, le drop entraînera une perte de données.JoinHandle、Signal::recv、tokio::sync::mpsc::Receiver::recvsatisfont tous cette condition.

2. Ordre de fermeture = ordre inverse de la direction des dépendances.Celui qui dépend de l'autre, on ferme d'abord celui dont on dépend. Le planificateur dépend du pilote d'E/S, donc on ferme d'abord le planificateur ; le pool bloquant est indépendant, on le ferme en dernier.

3. État global = conflit entre plusieurs instances.Toute ressource au niveau du processus (handler de signaux, pipe, table des descripteurs de fichiers) entrera en conflit avec plusieurs Runtime ; soit on se limite à un seul Runtime, soit on utilise une synchronisation externe.

Résumé de ce chapitre

Réflexions et auto-évaluation de ce chapitre

Q1 : Si l'on supprimeJoinHandle::polldanscoop::poll_proceed(cx), dans quel scénario cela entraînerait-il la famine d'autres tâches ? Pourquoitry_read_outputne consomme-t-il pas de budget en lui-même ?

Analyse de référence:coop::poll_proceed(cx)consomme le budget de coopération à l'endroit📎 tokio/src/runtime/task/join.rs:325-325. Si on le supprime, une tâche qui, dans une boucle, appelle répétitivementselect!plusieursJoinHandlepeut, en un seul cycle de planification, interroger indéfiniment tous les handles sans jamais retournerPending, affamant ainsi les autres tâches sur le même worker.try_read_outputne consomme pas de budget en lui-même, car il ne s'agit que d'une lecture mémoire + éventuellement d'un stockage de waker, sans E/S ni contention de verrou, avec un coût extrêmement faible. L'intention de conception du mécanisme de budget est de contraindre les « opérations susceptibles de s'exécuter longtemps », et non de facturer chaque poll. Notez quecoop.made_progress()n'appelleret.is_ready()que lorsque📎 tokio/src/runtime/task/join.rs:349-351, c'est-à-dire qu'il ne restitue le budget que lorsqu'il obtient réellement une sortie — cela vise à empêcher que des opérations « interrogées mais sans résultat » accumulent une consommation de budget.

Q2:blocking/shutdown.rsDans la méthodewaitdetry_enter_blocking_region(), siNoneretournefalseet qu'un panic est en cours, pourquoi choisir de retourner

plutôt que de continuer à attendre ? Que se passerait-il si l'on changeait pour continuer à attendre ?:try_enter_blocking_region()Analyse de référenceNoneretourner📎 tokio/src/runtime/blocking/shutdown.rs:44-57indique que l'on est actuellement dans un contexte asynchrone, où il n'est pas permis de bloquerfalse. Si un panic est en cours à ce moment-là, le code choisit de retourner📎 tokio/src/runtime/blocking/shutdown.rs:47-49sans attendreblock_on. La raison est la suivante : un nouveau panic pendant le déroulement d'un panic entraîne un abort du processus (double panic). Si l'on changeait pour continuer à attendre, il faudrait appelerblock_on, or dans un contexte asynchronefalseprovoque un panic — un panic pendant le déroulement d'un panic abortit directement le processus, perdant toutes les informations de diagnostic. Retourner

permet au drop de se terminer, préservant les informations de panic. C'est une conception de « dégradation gracieuse » : une fermeture incomplète vaut mieux qu'un effondrement du processus.SignalQ3 : Supposons que vous ayez créé dans le Runtime ASignalpour écouter SIGTERM, puis déplacésignal_enabledans le Runtime B pour le poll.handle.check_inner()DansSignal, quel Runtime

vérifie-t-il ? Si le Runtime A est drop en premier, le:signal_enabledans le Runtime B peut-il encore recevoir le signal ?signal()Analyse de référencehandles'exécute lors de l'appel📎 tokio/src/signal/unix.rs:398-405。check_inner(), à ce moment-là📎 tokio/src/signal/unix.rs:275。Signalest celui du Runtime ARxFuturevérifie le pilote de signaux du Runtime Awatch::Receiver<()> 📎 tokio/src/signal/unix.rs:366-368contient en interneGlobals, qui encapsuleEventInfo, ce receiver est enregistré sur lerecord_eventglobalEventInfo. Si le Runtime A est drop, son pilote de signaux cesse de lire les données du pipe global, mais le handler global continuera àSignalet à écrire dans le pipe. Si le pilote de signaux du Runtime B est également en cours d'exécution, il lira les données du pipe et déclencheraSignal , réveillant ainsi le waker de. Donc leSignaldans le Runtime B peut

encore recevoir le signal, mais cela dépend de si le Runtime B a un pilote de signaux en cours d'exécution. Si le Runtime B n'a pas de pilote de signaux (par exemple, la feature signal n'est pas activée ou le pilote est fermé), personne ne lit les données du pipe,

n'obtiendra jamais de réveil. C'est la fragilité de la gestion des signaux avec plusieurs Runtime.catch_unwindTransition de fin de chapitreGlobalsAnnulation sûre, propagation de panic, ordre de fermeture, conflit de signaux — la racine commune de ces quatre problèmes est l'ambiguïté de la « propriété de l'état » aux frontières asynchrones. Tokio, en plaçant l'état sur le tas, en gérant le cycle de vie par comptage de références, en isolant les panic avec

, et en partageant l'état des signaux via un

Nous avons ainsi parcouru les zones limites les plus piégeuses de Tokio en production : la sécurité d'annulation repose sur un stockage de sortie sur le tas et sur l'atomicité de try_read_output ; JoinHandle::drop n'annule pas la tâche, seul abort l'annule réellement mais reste sans effet sur spawn_blocking ; un panic capturé par catch_unwind est empaqueté en JoinError et silencieusement perdu si on ne l'await pas ; l'arrêt du Runtime suit un ordre strict, et un drop dans un contexte async provoque un panic ; les handlers de signaux sont un état global au niveau du processus, jamais désenregistrés après inscription. Derrière ces règles se cachent les arbitrages répétés de Tokio entre correction et performance. Dans le prochain chapitre, nous quitterons les mécanismes concrets pour prendre de la hauteur architecturale, revenir sur l'origine de ces arbitrages, et envisager où io_uring, la refonte des drivers et l'interface d'exécuteur personnalisé mèneront Tokio.

CHAPTER 14

Chapitre 14 : Arbitrages architecturaux et évolution future : de io_uring aux drivers enfichables

Projet : tokio-rs/tokio · Progression du livre : Chapitre 14 / 14 · État de vérification : lignes FACT ancrées sur des emplacements réels

Dans le chapitre précédent, nous avons passé en revue quatre catégories de pièges de production : sécurité d'annulation, propagation de panic, ordre d'arrêt et conflits de signaux. Bien qu'ils semblent dispersés, ils pointent tous vers le même problème architectural : comment la propriété de l'état est clairement délimitée aux frontières asynchrones. Or, la manière de délimiter cette propriété est précisément déterminée par les trois décisions architecturales les plus fondamentales du runtime — comment les tâches sont ordonnancées, comment les événements d'I/O sont distribués, et comment la correction concurrente est vérifiée. Ce chapitre ne plonge plus dans les détails d'implémentation d'une fonction spécifique, mais se place au niveau architectural pour revenir sur les choix de Tokio concernant ces décisions, et, en suivant les pistes d'évolution déjà présentes dans la documentation officielle et le code source, examiner où io_uring, la refonte des drivers et l'interface d'exécuteur personnalisé mèneront Tokio. Après ce chapitre, vous devriez pouvoir répondre à une question pratique : quand faut-il étendre Tokio, et quand faut-il le contourner.

I. Trois arbitrages historiques : pourquoi les choses sont ce qu'elles sont

Modèle intuitif

Imaginez Tokio comme un restaurant ouvert depuis dix ans. La rotation en cuisine (work-stealing), l'effectif indépendant des serveurs (séparation du driver d'I/O et de l'ordonnanceur), et le système d'inspection sanitaire en cuisine (vérification de concurrence par loom) n'ont pas été conçus dès le premier jour, mais ont évolué progressivement au fil de l'augmentation du nombre de clients et de la complexité des plats. Comprendre ces évolutions permet de distinguer les choix visionnaires des héritages historiques.

Arbitrage un : work-stealing plutôt qu'une file globale

〔Inférence de conception et arbitrage architectural〕

Une file globale est l'implémentation la plus simple : toutes les tâches entrent dans uneMutex<VecDeque>, et les threads worker se disputent le verrou pour prendre des tâches. Mais la contention de verrou s'aggrave avec le nombre de cœurs, et la localité de cache est mauvaise — le cœur sur lequel une tâche est créée et celui sur lequel elle est exécutée sont totalement aléatoires.

Le compromis du work-stealing est le suivant : chaque worker possède une file locale,spawnprivilégie l'entrée dans la file locale (sans verrou, favorable au cache), et ne vole à la queue de la file d'un autre worker que lorsque la file locale est vide. Le coût est un délai dans l'équilibrage de charge, et le vol lui-même nécessite des opérations atomiques et des barrières mémoire. Tokio a choisi cette option parce que les serveurs modernes ont souvent des dizaines de cœurs, et le coût de la contention de verrou est bien supérieur au coût occasionnel du vol.

〔Inférence de conception et arbitrage architectural〕

La condition limite de cette décision est la suivante :la granularité des tâches ne doit pas être trop fine. Si chaque tâche ne fait que quelques microsecondes de travail, la proportion du coût de vol et d'ordonnancement devient incontrôlable. C'est aussi pourquoi Tokio, en plus despawn_blocking, exige que les tâches longuesyield_now()activement — l'ordonnancement coopératif sert essentiellement de filet de sécurité au work-stealing.

Arbitrage deux : le driver d'I/O indépendant de l'ordonnanceur

C'est l'un des points les plus intéressants du matériel source de ce chapitre. Regardez la structure des modules detokio/src/runtime/io/mod.rs:

📎 tokio/src/runtime/io/mod.rs:5-22

rust
mod driver;
use driver::{Direction, Tick};
pub(crate) use driver::{Driver, Handle, ReadyEvent};

mod registration;
pub(crate) use registration::Registration;

mod registration_set;
use registration_set::RegistrationSet;

mod scheduled_io;
use scheduled_io::ScheduledIo;

mod metrics;
use metrics::IoDriverMetrics;

use crate::util::ptr_expose::PtrExposeDomain;
static EXPOSE_IO: PtrExposeDomain<ScheduledIo> = PtrExposeDomain::new();

Notez quedriver、registration、scheduled_iosont trois modules indépendants, et n'exposent publiquement que les typesDriver、Handle、ReadyEvent、Registration.ScheduledIoestpub(crate)de — il est enveloppé parPtrExposeDomain, utilisé pour exposer les pointeurs bruts à la vérification de concurrence sous les tests loom.

〔Inférence de conception et arbitrage architectural〕

Pourquoi le driver d'I/O n'est-il pas directement intégré dans l'ordonnanceur ? Parce que leurs cycles de vie et modèles de concurrence diffèrent. L'ordonnanceur se soucie de « quelle tâche doit s'exécuter », le driver d'I/O se soucie de « quel fd est prêt ». S'ils étaient couplés, chaque ajustement de la stratégie d'ordonnancement nécessiterait de modifier le chemin d'I/O, et inversement. Plus important encore,block_onle runtime mono-thread a aussi besoin d'un driver d'I/O, mais pas d'un ordonnanceur work-stealing — la séparation permet aux deux runtimes de réutiliser la même implémentation d'I/O.

Arbitrage trois : loom pour la vérification du modèle de concurrence

tokio/src/loom/mod.rsne fait que 14 lignes, mais révèle la stratégie de vérification de la correction concurrente de Tokio :

📎 tokio/src/loom/mod.rs:1-14

rust
//! This module abstracts over `loom` and `std::sync` depending on whether we
//! are running tests or not.

#![allow(unused)]

#[cfg(not(all(test, loom)))]
mod std;
#[cfg(not(all(test, loom)))]
pub(crate) use self::std::*;

#[cfg(all(test, loom))]
mod mocked;
#[cfg(all(test, loom))]
pub(crate) use self::mocked::*;

Le point clé est la condition#[cfg(all(test, loom))]: ce n'est que lorsque les deux cfgtestetloomsont activés simultanément que le modulemockedremplacestd. Cela signifie que le code de loom n'existe pas du tout dans les builds de production, avec un coût d'exécution nul.

〔Inférence de conception et arbitrage architectural〕

La valeur de loom réside dans sa capacité à énumérer exhaustivement « tous les ordres d'entrelacement possibles des threads ». Comme dansScheduledIo,AtomicUsizela lecture-modification-écriture deWaitersL'insertion et la suppression dans une liste chaînée peuvent s'exécuter un million de fois sans erreur sur du matériel réel, mais loom peut construire en quelques secondes un entrelacement déclenchant une race condition. Le coût est une exécution de test lente et une empreinte mémoire élevée, donc cela ne peut servir qu'aux tests unitaires, pas en production.

Réflexions de conception

Ces trois compromis partagent une caractéristique commune :Ils ont tous choisi la solution « plus complexe mais plus extensible », en confinant la complexité à l'intérieur. La complexité du work-stealing est cachée dans le planificateur, celle de l'I/O piloté par les événements est cachée dansScheduledIo, et celle de loom est cachée dans les conditions cfg. L'API exposée reste toujoursspawn、TcpStream::readces interfaces simples.

〔Inférences de conception et compromis architecturaux〕

C'est aussi le premier principe pour juger « quand étendre Tokio » :Si votre besoin peut être exprimé par l'API existante, ne touchez pas aux structures internes. Dès que vous commencez à dépendre despub(crate)types ou des cfg detokio_unstable, cela signifie que vous vous liez à l'implémentation interne de Tokio, et vous en paierez le prix lors des mises à jour.

---

II. Refonte du driver : de « un waker, une direction » à « un ensemble d'intérêts arbitraire »

Modèle intuitif

Les premiers types d'I/O de Tokio avaient une limitation stricte :async fn read(&mut self)nécessite&mut self. C'est comme un restaurant avec un seul guichet de retrait, où une seule personne peut faire la queue à la fois — car le waker est stocké à l'intérieur de la ressource d'I/O, et non dans le Future correspondant à l'opération.tokio/docs/reactor-refactor.mddocumente complètement la cause de cette limitation et le plan de refonte.

Les points faibles de l'ancienne architecture

Le document expose le problème dès l'introduction :

📎 tokio/docs/reactor-refactor.md:16-20

rust
Currently, I/O types require `&mut self` for `async` functions. The reason for
this is the task's waker is stored in the I/O resource's internal state
(`ScheduledIo`) instead of in the future returned by the `async` function.
Because of this limitation, I/O types limit the number of wakers to one per
direction (a direction is either read-related events or write-related events).
〔Inférences de conception et compromis architecturaux〕

Stocker le waker à l'intérieur de la ressource signifie qu'« une direction ne peut avoir qu'un seul attendeur ». Si vous voulez lire et écrire simultanément sur le mêmeTcpStream, vous devez lesplit()en deux moitiés, chacune détenant son propre emplacement de waker. C'est la raison d'être deTcpStream::split()— ce n'est pas une préférence de conception d'API, mais une contrainte directe de la structure de données interne.

Nouvelle architecture : déplacer le waker dans le Future

L'idée centrale de la refonte est de « déplacer le waker de l'état de la ressource vers le Future de l'opération », permettant ainsi d'enregistrer plusieurs wakers par opération :

📎 tokio/docs/reactor-refactor.md:22-25

rust
Moving the waker from the internal I/O resource's state to the operation's
future enables multiple wakers to be registered per operation. The "intrusive
wake list" strategy used by `Notify` applies to this case, though there are some
concerns unique to the I/O driver.

La nouvelle structureScheduledIoest la suivante :

📎 tokio/docs/reactor-refactor.md:97-134

rust
#[derive(Debug)]
pub(crate) struct ScheduledIo {
    /// Resource's known state packed with other state that must be
    /// atomically updated.
    readiness: AtomicUsize,

    /// Tracks tasks waiting on the resource
    waiters: Mutex<Waiters>,
}

#[derive(Debug)]
struct Waiters {
    // List of intrusive waiters.
    list: LinkedList<Waiter>,

    /// Waiter used by `AsyncRead` implementations.
    reader: Option<Waker>,

    /// Waiter used by `AsyncWrite` implementations.
    writer: Option<Waker>,
}

// This struct is contained by the **future** returned by `readiness()`.
#[derive(Debug)]
struct Waiter {
    /// Intrusive linked-list pointers
    pointers: linked_list::Pointers<Waiter>,

    /// Waker for task waiting on I/O resource
    waiter: Option<Waker>,

    /// Readiness events being waited on. This is
    /// the value passed to `readiness()`
    interest: mio::Ready,

    /// Should not be `Unpin`.
    _p: PhantomPinned,
}

Voici plusieurs points de conception ingénieux qui méritent d'être développés :

Premièrement,readinessestAtomicUsize,waitersestMutex<Waiters>。Pourquoi ne pas utiliser un seul verrou pour protéger les deux ? Parce que les opérations de lecture dereadinesssont extrêmement fréquentes (vérifiées à chaque appel dereadiness()), tandis que les écritures n'ont lieu qu'à la réception d'un événement mio. Utiliser une variable atomique pour rendre le chemin de lecture sans verrou est une optimisation typique de séparation lecture-écriture.

Deuxièmement,Waiterest un nœud de liste chaînée intrusive. pointers: linked_list::Pointers<Waiter>fait queWaiterdevient lui-même une partie de la liste chaînée, sans allocation supplémentaire de nœud._p: PhantomPinnedle marque explicitement comme nonUnpin— car une fois l'adresse d'un nœud de liste intrusive déplacée, la liste est rompue.

Troisièmement,readeretwriterdeuxOption<Waker>sont destinés àAsyncRead/AsyncWrite.Le document explique la raison :

📎 tokio/docs/reactor-refactor.md:210-213

rust
The `AsyncRead` and `AsyncWrite` traits use a "poll" based API. This means that
it is not possible to use an intrusive linked list to track the waker.
Additionally, there is no future associated with the operation which means it is
not possible to cancel interest in the readiness events.
〔Inférences de conception et compromis architecturaux〕

C'est une coexistence de compromis entre les deux mécanismes, ancien et nouveau :async fnle chemin utilise une liste intrusive (support de plusieurs attendeurs, annulable),pollle chemin utilise des emplacements fixes (pas d'annulation, mais compatible avec le trait). Cette « coexistence de deux mécanismes » est le coût typique d'une refonte progressive.

Conditions de course et mécanisme de tick

Le problème le plus épineux de la refonte est la condition de course. Le document donne un scénario concret d'interblocage :

📎 tokio/docs/reactor-refactor.md:175-175

rust
If care is not taken, if between `mio_socket.read(buf)` returning and
`clear_readiness(event)` is called, a readiness event arrives, the `read()`
function could deadlock. This happens because the readiness event is received,
`clear_readiness()` unsets the readiness event, and on the next iteration,
`readiness().await` will block forever as a new readiness event is not received.

La solution est d'introduire un mécanisme de tick, en découpantreadinessceAtomicUsizeen plusieurs segments de bits :

📎 tokio/docs/reactor-refactor.md:199-199

code
| shutdown | generation |  driver tick | readiness |
|----------+------------+--------------+-----------|
|   1 bit  |   7 bits   +    8 bits    +  16 bits  |
〔Inférences de conception et compromis architecturaux〕

Cette disposition de bits est un cas classique d'« échange d'espace contre correction ».ticks'incrémente à chaquemio::poll(),ReadyEventporte le tick au moment de la lecture.clear_readiness()n'efface l'état prêt que si le tick correspond — si le tick ne correspond pas, cela signifie qu'un nouvel événement est arrivé entre-temps et qu'on ne peut pas effacer. Ainsi, la course entre « effacement » et « arrivée d'un nouvel événement » est résolue dans une seule lecture-modification-écriture atomique.

Le diagramme de flux ci-dessous décrit le chemin de décision entrereadiness()etclear_readiness():

mermaid
flowchart TD
    start["readiness(interest).await"] --> check_ready{"已知 readiness<br/>与 interest 有交集?"}
    check_ready -->|是| ret_event["返回 ReadyEvent<br/>携带当前 tick"]
    check_ready -->|否| wait["注册 Waiter 到<br/>ScheduledIo.waiters"]
    wait --> mio_poll["mio.poll() 收到事件<br/>tick 递增"]
    mio_poll --> notify["遍历 waiters<br/>interest 匹配者唤醒"]
    notify --> ret_event
    ret_event --> do_read["mio_socket.read(buf)"]
    do_read --> read_ok{"read 结果?"}
    read_ok -->|Ok| done["返回 Ok(v)"]
    read_ok -->|WouldBlock| clear["clear_readiness(event)"]
    read_ok -->|其他 Err| err["返回 Err(e)"]
    clear --> tick_match{"event.tick ==<br/>当前 readiness.tick?"}
    tick_match -->|是| clear_ok["清除 readiness 位"]
    tick_match -->|否| skip["跳过清除<br/>保留新事件"]
    clear_ok --> start
    skip --> start

La branche clé de ce diagramme esttick_match: si le tick ne correspond pas,clear_readinessdoit abandonner l'effacement, sinon il perdra l'événement qui vient d'arriver, provoquant un blocage permanent dureadiness()suivant.

Annulation d'intérêt et fuite mémoire

La liste intrusive introduit un nouveau problème : si le Future retourné parreadiness()est abandonné prématurément, le nœud de liste doit être retiré. Le document avertit explicitement :

📎 tokio/docs/reactor-refactor.md:144-148

rust
The future returned by `readiness()` uses an intrusive linked list to store the
waker with `ScheduledIo`. Because `readiness()` can be called concurrently, many
wakers may be stored simultaneously in the list. If the `readiness()` future is
dropped early, it is essential that the waker is removed from the list. This
prevents leaking memory.
〔Inférences de conception et compromis architecturaux〕

C'est précisément la manifestation au niveau I/O de la « sûreté d'annulation » du chapitre précédent.readiness()Le Future deDropdoit se retirer lui-même de la liste dans l'implémentation deScheduledIo, sinon le nœud restera définitivement dans

, à la fois en fuyant de la mémoire et en étant réveillé à tort lors de l'arrivée du prochain événement.

Réflexions de conception et pièges en productionVec<Waker>Pourquoi ne pas utilisermais une liste intrusive ?&ResourceLe document donne la réponse en discutant de l'implémentation de

📎 tokio/docs/reactor-refactor.md:228-233

rust
It is only possible to implement `AsyncRead` and `AsyncWrite` for resource types
themselves and not for `&Resource`. Implementing the traits for `&Resource`
would permit concurrent operations to the resource. Because only a single waker
is stored per direction, any concurrent usage would result in deadlocks. An
alternate implementation would call for a `Vec<Waker>` but this would result in
memory leaks.
Copier

Vec<Waker>〔Inférences de conception et compromis architecturaux〕

Le problème de:TcpStream::by_ref()est que : après l'abandon d'un Future, le waker correspondant reste dans le Vec sans pouvoir être localisé et supprimé, et il faut attendre l'arrivée du prochain événement pour découvrir que « ce waker est déjà invalide ». La liste intrusive fait que l'adresse du nœud est celle du champ interne du Future, permettant un retrait précis lors du drop.TcpStreamRefPièges en productionread_waiterLewrite_waiterretourné par

📎 tokio/docs/reactor-refactor.md:238-244

rust
struct TcpStreamRef<'a> {
    stream: &'a TcpStream,

    // `Waiter` is the node in the intrusive waiter linked-list
    read_waiter: Waiter,
    write_waiter: Waiter,
}
et

:TcpStreamRefCopierselect!〔Inférences de conception et compromis architecturaux〕by_ref()Cela signifie qu'une foisTcpStreamRefabandonné, les deux nœuds waiter deviennent invalides simultanément. Si vous partagez une référence deTcpStreamentre branches dansselect!, faites attention aux durées de vie —

---

ne peut pas vivre plus longtemps que

Modèle intuitif

Parfois, vous ne voulez pas utiliser l'ordonnanceur de Tokio, mais seulement profiter de ses E/S et de ses timers. C'est comme si vous ne vouliez pas manger sur place au restaurant, mais seulement utiliser son comptoir de vente à emporter.examples/custom-executor.rsillustre ce « mode hybride » : utiliserfutures::executor::ThreadPoolpour l'ordonnancement, et Tokio pour les E/S.

Mécanisme central : TokioContext

La clé de tout l'exemple réside dansTokioContextce type d'enveloppe :

📎 examples/custom-executor.rs:51-54

rust
impl ThreadPool {
    fn spawn(&self, f: impl Future<Output = ()> + Send + 'static) {
        let handle = self.rt.handle().clone();
        self.inner.spawn_ok(TokioContext::new(f, handle));
    }
}
〔Inférence de conception et compromis architecturaux〕

TokioContext::new(f, handle)lie le Future et leHandlede Tokio ensemble. Lorsqu'un exécuteur externe poll ce Future enveloppé,TokioContextentre d'abord dans le contexte d'exécution de Tokio (en définissant leHandlelocal au thread), puis poll lefinterne. Ainsi,florsqu'on appelleTcpListener::bind, on peut trouver le pilote d'E/S de Tokio.

Regardons la structure de l'exemple complet :

📎 examples/custom-executor.rs:38-48

rust
static EXECUTOR: Lazy<ThreadPool> = Lazy::new(|| {
    // Spawn tokio runtime on a single background thread
    // enabling IO and timers.
    let rt = tokio::runtime::Builder::new_multi_thread()
        .enable_all()
        .build()
        .unwrap();
    let inner = futures::executor::ThreadPool::builder().create().unwrap();

    ThreadPool { inner, rt }
});
〔Inférence de conception et compromis architecturaux〕

Ici, le runtime Tokio est créé maisn'est pasblock_onpiloté— il « existe » simplement, fournissant le pilote d'E/S et les timers. La véritable ordonnancement des tâches est assuré parfutures::executor::ThreadPool. Dans ce mode, les threads worker de Tokio tournent en réalité à vide (en attente d'événements d'E/S), et l'exécution des tâches se produit dans le pool de threads de futures.

Flux de données : le voyage inter-exécuteur d'un TcpListener::bind

mermaid
sequenceDiagram
    participant App as "应用 (main)"
    participant FE as "futures::ThreadPool"
    participant TC as "TokioContext"
    participant TR as "tokio::Runtime (后台线程)"
    participant IO as "I/O 驱动 (mio)"

    App->>FE: spawn_ok(TokioContext::new(f, handle))
    FE->>TC: poll(cx)
    TC->>TC: enter(handle) 设置线程局部上下文
    TC->>TC: f.poll(cx) 执行 TcpListener::bind
    TC->>TR: 通过 Handle 访问 I/O 驱动
    TR->>IO: Registration::new 注册 fd
    IO-->>TR: 注册完成
    TR-->>TC: 返回 Pending 或 Ready
    TC-->>FE: 返回 poll 结果
    Note over FE,TR: I/O 就绪时,Tokio 驱动唤醒 waker<br/>FE 重新调度该任务

Le point clé de ce diagramme de séquence est :le poll de la tâche se produit dans le pool de threads futures, mais l'attente des événements d'E/S se produit dans le thread d'arrière-plan de Tokio. Les deux sont connectés viaHandleet le waker.

Réflexion de conception : quand faut-il contourner Tokio

〔Inférence de conception et compromis architecturaux〕

L'existence même de cet exemple est un signal : l'architecture de Tokio permet « d'utiliser uniquement le pilote d'E/S, sans l'ordonnanceur ». Les critères de décision peuvent se résumer en trois points :

1. Si vous devez vous intégrer à un écosystème d'exécuteur existant(par exemple, certains frameworks imposentfutures::executor), utiliserTokioContextest la solution la moins intrusive.

2. Si vous avez besoin d'un contrôle total de la stratégie d'ordonnancement(par exemple, un système temps réel exige un ordonnancement déterministe), le work-stealing de Tokio ne répond pas aux besoins, mais son pilote d'E/S reste utilisable.

3. Si vous trouvez simplement l'API de Tokio trop complexe, alors il ne faut pas la contourner —TokioContextla frontière inter-exécuteur introduite par

Pièges en production:TokioContextDans le modeblock_on, leRuntime::shutdowndu runtime Tokio n'est jamais appelé, ce qui signifie que la logique de nettoyage deRuntimene se déclenchera pas automatiquement. Vous devez explicitement drop

avant la fin du programme, sinon les threads d'arrière-plan du pilote d'E/S risquent de ne pas se fermer proprement.

Relation avec io_uring

tokio/src/runtime/io/mod.rs〔Inférence de conception et compromis architecturaux〕

📎 tokio/src/runtime/io/mod.rs:1-4

rust
#![cfg_attr(
    not(all(feature = "rt", feature = "net", feature = "io-uring", tokio_unstable)),
    allow(dead_code)
)]

Copierfeature = "io-uring"Notez quetokio_unstableetapparaissent simultanément. Cela signifie que le support d'io_uring est actuellementexpérimentalallow(dead_code), et qu'il faut activer simultanément la feature unstable pour compiler.allowindique quant à lui que : lorsque ces features ne sont pas activées, une partie du code du module n'est pas utilisée, et le compilateur émettra un avertissement — utilisez

pour le supprimer.

〔Inférence de conception et compromis architecturaux〕read/writeLa différence fondamentale entre io_uring et epoll réside dans le fait que : epoll est une « notification de disponibilité », io_uring est une « notification d'achèvement ». Le premier nécessite que l'application lance elle-même l'appel systèmeScheduledIo, le second fait que le noyau effectue directement l'E/S et renvoie le résultat. Cela a un impact énorme sur le modèlereadiness()de Tokio —

---

la sémantique de

ne s'applique plus sous io_uring, et une abstraction entièrement nouvelle de « soumission-achèvement » est nécessaire. C'est aussi pourquoi le support d'io_uring reste si longtemps en unstable : il ne s'agit pas simplement d'ajouter un backend, mais de refondre toute la couche d'abstraction du pilote d'E/S.

Résumé de ce chapitre:

  • Ce chapitre a passé en revue, du point de vue architectural, les trois compromis fondamentaux de Tokio, et a esquissé trois pistes d'évolution :
  • Compromis historiquesblock_onLe work-stealing échange la complexité d'ordonnancement contre l'extensibilité multicœur, avec pour limite que la granularité des tâches ne doit pas être trop fine ;
  • le pilote d'E/S est indépendant de l'ordonnanceur, permettant à

et au runtime multithread de réutiliser la même implémentation d'E/S ;(reactor-refactor.md):

  • loom disparaît complètement des builds de production via des conditions cfg, et n'énumère les entrelacements de threads qu'en phase de test.ScheduledIoRefonte du pilote
  • Déplacer le waker de l'intérieur deAtomicUsizevers le Future d'opération, en utilisant une liste intrusive pour supporter plusieurs waiters ;clear_readinessutiliser la disposition en champs de bits de
  • AsyncRead/AsyncWrite(shutdown/generation/tick/readiness) pour éliminer la course dereader/writer;

comme la sémantique de poll ne permet pas d'utiliser une liste intrusive, conserver:

  • avec des slots fixes comme compromis.tokio_unstableÉvolution future
  • TokioContextio_uring nécessite une nouvelle abstraction « soumission-achèvement », actuellement protégée par
  • ;

permet d'utiliser uniquement le pilote d'E/S sans l'ordonnanceur, mais nécessite une gestion manuelle du cycle de vie du Runtime ;

le critère pour décider « étendre ou contourner » : si l'on peut exprimer la chose avec l'API existante, ne pas toucher aux structures internes.ScheduledIoRéflexions et auto-évaluation de ce chapitrereadinessQ1 : Dans la disposition en champs de bits detickduclear_readiness, si l'on réduit le champ

de 8 bits à 4 bits, dans quels scénarios cela déclencherait-il une erreur ? Analysez en combinant avec la logique de correspondance des ticks de:tickAnalyse de référencemio::poll()incrémente📎 tokio/docs/reactor-refactor.md:185-185。clear_readinessà chaqueevent.tick == 当前 readiness.tick, et n'efface les bits de disponibilité que lors de📎 tokio/docs/reactor-refactor.md:199-199. Si le tick ne fait que 4 bits, alors il y aura un wraparound tous les 16 polls. Supposons qu'unReadyEventporte tick=15, et qu'au moment où il estclear_readinessAuparavant, mio a de nouveau effectué 1 poll, et le tick est revenu à 0. À ce moment,clear_readinesson découvre que le tick ne correspond pas (15 != 0), et on saute incorrectement l'effacement — alors qu'en réalité aucun nouvel événement n'est peut-être arrivé entre-temps, le tick a simplement bouclé. Cela entraîne la conservation permanente des bits de disponibilité, et par la suitereadiness()retourne immédiatement maisreadreste toujoursWouldBlock, ce qui provoque une boucle active. Un tick de 8 bits suffit sous charge normale (un cycle read-clear se termine en 256 polls), mais sous une concurrence extrêmement élevée, un risque de bouclage subsiste ; c'est une limite inhérente à la disposition des champs de bits.

Q2: examples/custom-executor.rs, le runtime Tokio est créé mais jamaisblock_on. Que se passe-t-il si on appellert.shutdown_timeout()à ce moment-là ? Pourquoi cet exemple choisit-il de ne pas l'appeler ?

Analyse de référence:rt.shutdown_timeout()attend que toutes les tâches se terminent et ferme le pilote d'E/S. Mais dans cet exemple, les tâches s'exécutent en réalité surfutures::executor::ThreadPoolsur📎 examples/custom-executor.rs:51-54, il n'y a aucune tâche dans le runtime Tokio — il ne fournit que le pilote d'E/S. Si on appelleshutdown_timeout, il retournera immédiatement (car il n'y a aucune tâche), mais le thread d'arrière-plan du pilote d'E/S peut encore être en cours d'exécution. L'exemple choisit de ne pas l'appeler parce queEXECUTORest uneLazyvariable statique, gérée par le mécanisme de destruction des statiques de Rust à la sortie du programme. Le vrai piège est le suivant : si le Future encapsulé parTokioContextest encore en cours d'exécution et queRuntimeest drop, alors les opérations d'E/S dans le Future paniqueront (contexte de runtime introuvable). En production, il faut garantir que tous lesTokioContextFutures sont terminés avant de drop le Runtime.

Q3 : Supposons que vous vouliez ajouter à Tokio un backend d'E/S basé sur io_uring. D'aprèsreactor-refactor.mddansreadiness()la sémantique de

, quelles parties peuvent être réutilisées directement et lesquelles doivent être réécrites ?Analyse de référenceRegistration: ce qui peut être réutilisé directement, c'estScheduledIol'interface d'enregistrement dewaiterset la structure de liste chaînée dereadiness()— elles gèrent « qui attend », indépendamment du fait que la couche inférieure soit epoll ou io_uring. Ce qui doit être réécrit, c'est la sémantique declear_readiness: sous epoll, elle retourne « fd prêt » ; sous io_uring, il n'y a pas de concept de « prêt », seulement « le SQE soumis est terminé ».readiness()Le mécanisme de tick deWaiterdoit également être repensé — les événements de completion d'io_uring portent leur propre identifiant user_data, et n'ont pas besoin de tick pour distinguer les événements anciens des nouveaux. Le changement le plus fondamental est que :interestle Future retourné partokio_unstabledevrait, sous io_uring, devenir « soumettre un SQE et attendre le CQE », ce qui signifie que la structure📎 tokio/src/runtime/io/mod.rs:1-4doit porter les paramètres du SQE, et pas seulement

. C'est aussi pourquoi le support d'io_uring est protégé par

⚡ Chargement de tous les fichiers sources... · 🇺🇸 EN · Tous les fichiers sources sont montés · 🇰🇷 한국어 · 🇨🇳 中文 · 🇪🇸 ES · 🇩🇪 DE · 🇫🇷 FR · 🇧🇷 PT · 🇷🇺 RU