Comment utiliser des API et des modèles asynchrones / wait non sécurisés pour les threads avec l’API Web ASP.NET?

Cette question a été posée par EF Data Context – Async / Await & Multithreading . J’ai répondu à cette question, mais je n’ai pas fourni de solution ultime.

Le problème initial est qu’il existe de nombreuses API .NET utiles (comme DbContext de Microsoft Entity Framework), qui fournissent des méthodes asynchrones conçues pour être utilisées avec await , mais elles sont documentées comme non thread-safe . Cela les rend parfaits pour une utilisation dans les applications d’interface utilisateur de bureau, mais pas pour les applications côté serveur. [EDITED] Cela pourrait ne pas s’appliquer à DbContext , voici la déclaration de Microsoft sur la sécurité des threads EF6 , jugez par vous-même. [/ÉDITÉ]

Certains modèles de code établis tombent également dans la même catégorie, tels que l’appel d’un proxy de service WCF avec OperationContextScope (demandé ici et ici ), par exemple:

 using (var docClient = CreateDocumentServiceClient()) using (new OperationContextScope(docClient.InnerChannel)) { return await docClient.GetDocumentAsync(docId); } 

Cela peut échouer car OperationContextScope utilise le stockage local du thread dans son implémentation.

AspNetSynchronizationContext est la source du problème. AspNetSynchronizationContext est utilisé dans les pages ASP.NET asynchrones pour répondre à davantage de demandes HTTP avec moins de threads provenant du pool de threads ASP.NET . Avec AspNetSynchronizationContext , une continuation en attente peut être mise en queue sur un thread différent de celui qui a initié l’opération async, tandis que le thread d’origine est libéré dans le pool et peut être utilisé pour servir une autre requête HTTP. Cela améliore considérablement l’évolutivité du code côté serveur. Le mécanisme est décrit en détail dans Tout ce qu’il faut sur le SynchronizationContext , à lire absolument. Ainsi, bien qu’il n’y ait pas d’access simultané à l’API , un commutateur de thread potentiel nous empêche toujours d’utiliser les API susmentionnées.

J’ai réfléchi à la façon de résoudre ce problème sans sacrifier l’évolutivité. Apparemment, le seul moyen de récupérer ces API consiste à conserver une affinité de thread pour l’étendue des appels asynchrones potentiellement affectés par un commutateur de thread.

Disons que nous avons une telle affinité de fil. La plupart de ces appels sont quand même liés à IO ( il n’y a pas de fil ). Lorsqu’une tâche asynchrone est en attente, le thread sur lequel elle a été créée peut être utilisé pour poursuivre la poursuite d’une autre tâche similaire, dont le résultat est déjà disponible. Ainsi, l’évolutivité ne devrait pas trop souffrir. Cette approche n’a rien de nouveau, en fait, Node.js a utilisé avec succès un modèle similaire à un seul thread . OMI, c’est une de ces choses qui rendent Node.js si populaire.

Je ne vois pas pourquoi cette approche ne pourrait pas être utilisée dans un contexte ASP.NET. Un planificateur de tâches personnalisé (appelons-le ThreadAffinityTaskScheduler ) peut gérer un pool séparé de threads “appartement d’affinité” afin d’améliorer encore davantage l’évolutivité. Une fois que la tâche a été mise en queue dans l’un de ces threads “appartement”, toutes les await à l’intérieur de la tâche auront lieu sur le même thread.

Voici comment une API non-thread-safe de la question liée peut être utilisée avec un tel ThreadAffinityTaskScheduler :

 // create a global instance of ThreadAffinityTaskScheduler - per web app public static class GlobalState { public static ThreadAffinityTaskScheduler TaScheduler { get; private set; } public static GlobalState { GlobalState.TaScheduler = new ThreadAffinityTaskScheduler( numberOfThreads: 10); } } // ... // run a task which uses non-thread-safe APIs var result = await GlobalState.TaScheduler.Run(() => { using (var dataContext = new DataContext()) { var something = await dataContext.someEntities.FirstOrDefaultAsync(e => e.Id == 1); var morething = await dataContext.someEntities.FirstOrDefaultAsync(e => e.Id == 2); // ... // transform "something" and "morething" into thread-safe objects and return the result return data; } }, CancellationToken.None); 

Je suis allé de l’avant et ThreadAffinityTaskScheduler implémenté ThreadAffinityTaskScheduler comme preuve de concept , basé sur l’excellent StaTaskScheduler Stephen Toub. Les threads de pool maintenus par ThreadAffinityTaskScheduler ne sont pas des threads STA au sens classique du COM, mais ils implémentent une affinité de thread pour les continuations en await ( SingleThreadSynchronizationContext est responsable).

Jusqu’à présent, j’ai testé ce code en tant qu’application console et il semble fonctionner correctement. Je ne l’ai pas encore testé dans une page ASP.NET. Je n’ai pas beaucoup d’expérience en développement ASP.NET en production, alors mes questions sont les suivantes:

  1. Est-il judicieux d’utiliser cette approche par rapport à la simple invocation synchrone d’API non thread-safe dans ASP.NET (l’objective principal est d’éviter de sacrifier l’évolutivité)?

  2. Existe-t-il des approches alternatives en plus d’utiliser des invocations d’API synchrones ou d’éviter ces APis?

  3. Quelqu’un at-il déjà utilisé quelque chose de similaire dans des projets ASP.NET MVC ou Web API et est-il prêt à partager son expérience?

  4. Tous les conseils sur la manière de soumettre à une contrainte le test et de profiler cette approche avec ASP.NET seraient les bienvenus.

Entity Framework va (devrait) gérer correctement les sauts de threads entre les points d’ await ; si ce n’est pas le cas, il s’agit d’un bug dans EF. OTOH, OperationContextScope est basé sur TLS et n’est pas en await .

1. Les API synchrones maintiennent votre contexte ASP.NET. cela inclut des éléments tels que l’identité et la culture de l’utilisateur qui sont souvent importants lors du traitement. En outre, un certain nombre d’API ASP.NET supposent qu’elles s’exécutent dans un contexte ASP.NET réel (je ne parle pas simplement d’utiliser HttpContext.Current ; je veux dire en fait en supposant que SynchronizationContext.Current est une instance de AspNetSynchronizationContext ).

2-3. J’ai utilisé mon propre contexte mono-thread nested directement dans le contexte ASP.NET, dans le but de faire en sorte que les actions enfant MVC async fonctionnent sans dupliquer le code. Cependant, non seulement vous perdez les avantages en termes d’évolutivité (pour cette partie de la demande, au moins), mais vous rencontrez également les API ASP.NET en supposant qu’elles s’exécutent dans un contexte ASP.NET.

Donc, je n’ai jamais utilisé cette approche en production. Je finis juste par utiliser les API synchrones lorsque cela est nécessaire.

Vous ne devez pas mêler le multithreading à l’asynchronisme. Le problème avec un object qui n’est pas thread-safe est lorsqu’une seule instance (ou statique) est accédée par plusieurs threads en même temps . Avec les appels asynchrones, le contexte est éventuellement accessible à partir d’un autre thread dans la suite, mais jamais en même temps (lorsqu’il n’est pas partagé entre plusieurs demandes, mais ce n’est pas une bonne chose en premier lieu).