Il s'agit d'un problème très délicat lié à la bibliothèque de tâches. En bref, il y a trop de tâches créées et planifiées pour que l'une des tâches que le pilote de MongoDB attend ne puisse pas être terminée. J'ai mis très longtemps à réaliser que ce n'était pas une impasse même si ça en avait l'air.
Voici l'étape à reproduire :
- Téléchargez le code source du pilote CSharp de MongoDB .
- Ouvrez cette solution et créez un projet de console à l'intérieur et référençant le projet de pilote.
- Dans la fonction Main, créez un System.Threading.Timer qui appellera TestTask à temps. Réglez la minuterie pour qu'elle démarre immédiatement une fois. À la fin, ajoutez un Console.Read().
- Dans TestTask, utilisez une boucle for pour créer 300 tâches en appelant Task.Factory.StartNew(DoOneThing). Ajoutez toutes ces tâches à une liste et utilisez Task.WaitAll pour attendre qu'elles soient toutes terminées.
- Dans la fonction DoOneThing, créez un MongoClient et effectuez une requête simple.
- Maintenant, lancez-le.
Cela échouera au même endroit que vous avez mentionné :MongoDB.Driver.Core.Clusters.Cluster.WaitForDescriptionChangedHelper.HandleCompletedTask(Task completedTask)
Si vous mettez des points d'arrêt, vous saurez que le WaitForDescriptionChangedHelper a créé une tâche de temporisation. Il attend ensuite que l'une des tâches DescriptionUpdate ou Timeout se termine. Cependant, la DescriptionUpdate ne se produit jamais, mais pourquoi ?
Maintenant, revenons à mon exemple, il y a une partie intéressante :j'ai démarré une minuterie. Si vous appelez directement la TestTask, elle s'exécutera sans aucun problème. En les comparant avec la fenêtre Tâches de Visual Studio, vous remarquerez que la version avec minuterie créera beaucoup plus de tâches que la version sans minuterie. Permettez-moi d'expliquer cette partie un peu plus tard. Il y a une autre différence importante. Vous devez ajouter des lignes de débogage dans le Cluster.cs
:
protected void UpdateClusterDescription(ClusterDescription newClusterDescription)
{
ClusterDescription oldClusterDescription = null;
TaskCompletionSource<bool> oldDescriptionChangedTaskCompletionSource = null;
Console.WriteLine($"Before UpdateClusterDescription {_descriptionChangedTaskCompletionSource?.Task.Id}, {_descriptionChangedTaskCompletionSource?.Task?.GetHashCode().ToString("F8")}");
lock (_descriptionLock)
{
oldClusterDescription = _description;
_description = newClusterDescription;
oldDescriptionChangedTaskCompletionSource = _descriptionChangedTaskCompletionSource;
_descriptionChangedTaskCompletionSource = new TaskCompletionSource<bool>();
}
OnDescriptionChanged(oldClusterDescription, newClusterDescription);
Console.WriteLine($"Setting UpdateClusterDescription {oldDescriptionChangedTaskCompletionSource?.Task.Id}, {oldDescriptionChangedTaskCompletionSource?.Task?.GetHashCode().ToString("F8")}");
oldDescriptionChangedTaskCompletionSource.TrySetResult(true);
Console.WriteLine($"Set UpdateClusterDescription {oldDescriptionChangedTaskCompletionSource?.Task.Id}, {oldDescriptionChangedTaskCompletionSource?.Task?.GetHashCode().ToString("F8")}");
}
private void WaitForDescriptionChanged(IServerSelector selector, ClusterDescription description, Task descriptionChangedTask, TimeSpan timeout, CancellationToken cancellationToken)
{
using (var helper = new WaitForDescriptionChangedHelper(this, selector, description, descriptionChangedTask, timeout, cancellationToken))
{
Console.WriteLine($"Waiting {descriptionChangedTask?.Id}, {descriptionChangedTask?.GetHashCode().ToString("F8")}");
var index = Task.WaitAny(helper.Tasks);
helper.HandleCompletedTask(helper.Tasks[index]);
}
}
En ajoutant ces lignes, vous découvrirez également que la version sans minuterie sera mise à jour deux fois, mais que la version avec minuterie ne sera mise à jour qu'une seule fois. Et le second vient du "MonitorServerAsync" dans ServerMonitor.cs. Il s'est avéré que, dans la version de la minuterie, le MontiorServerAsync a été exécuté, mais après avoir traversé ServerMonitor.HeartbeatAsync, BinaryConnection.OpenAsync, BinaryConnection.OpenHelperAsync et TcpStreamFactory.CreateStreamAsync, il a finalement atteint TcpStreamFactory.ResolveEndPointsAsync. La mauvaise chose se produit ici :Dns.GetHostAddressesAsync
. Celui-ci ne sera jamais exécuté. Si vous modifiez légèrement le code et le transformez en :
var task = Dns.GetHostAddressesAsync(dnsInitial.Host).ConfigureAwait(false);
return (await task)
.Select(x => new IPEndPoint(x, dnsInitial.Port))
.OrderBy(x => x, new PreferredAddressFamilyComparer(preferred))
.ToArray();
Vous pourrez trouver l'identifiant de la tâche. En regardant dans la fenêtre Tâches de Visual Studio, il est assez évident qu'il y a environ 300 tâches devant elle. Seuls plusieurs d'entre eux sont en cours d'exécution mais bloqués. Si vous ajoutez un Console.Writeline dans la fonction DoOneThing, vous verrez que le planificateur de tâches en démarre plusieurs presque en même temps mais ensuite, il ralentit à environ un par seconde. Cela signifie donc que vous devez attendre environ 300 secondes avant que la tâche de résolution du DNS ne commence à s'exécuter. C'est pourquoi il dépasse le délai de 30 secondes.
Maintenant, voici une solution rapide si vous ne faites pas de choses folles :
Task.Factory.StartNew(DoOneThing, TaskCreationOptions.LongRunning);
Cela forcera le ThreadPoolScheduler à démarrer un thread immédiatement au lieu d'attendre une seconde avant d'en créer un nouveau.
Cependant, cela ne fonctionnera pas si vous faites des choses vraiment folles comme moi. Changeons la boucle for de 300 à 30000, même cette solution pourrait également échouer. La raison en est qu'il crée trop de threads. Cela prend du temps et des ressources. Et cela pourrait commencer à lancer le processus du GC. Dans l'ensemble, il se peut qu'il ne soit pas en mesure de terminer la création de tous ces threads avant la fin du temps imparti.
Le moyen idéal est d'arrêter de créer de nombreuses tâches et d'utiliser le planificateur par défaut pour les planifier. Vous pouvez essayer de créer un élément de travail et le placer dans une file d'attente concurrente, puis créer plusieurs threads en tant que travailleurs pour consommer les éléments.
Cependant, si vous ne souhaitez pas trop modifier la structure d'origine, vous pouvez essayer la méthode suivante :
Créez un ThrottledTaskScheduler dérivé de TaskScheduler.
- Ce ThrottledTaskScheduler accepte un TaskScheduler comme sous-jacent qui exécutera la tâche réelle.
- Déchargez les tâches vers le planificateur sous-jacent, mais s'il dépasse la limite, placez-les plutôt dans une file d'attente.
- Si l'une des tâches est terminée, vérifiez la file d'attente et essayez de la vider dans le planificateur sous-jacent dans la limite.
- Utilisez le code suivant pour démarrer toutes ces nouvelles tâches folles :
·
var taskScheduler = new ThrottledTaskScheduler(
TaskScheduler.Default,
128,
TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler,
logger
);
var taskFactory = new TaskFactory(taskScheduler);
for (var i = 0; i < 30000; i++)
{
tasks.Add(taskFactory.StartNew(DoOneThing))
}
Task.WaitAll(tasks.ToArray());
Vous pouvez prendre System.Threading.Tasks.ConcurrentExclusiveSchedulerPair.ConcurrentExclusiveTaskScheduler comme référence. C'est un peu plus compliqué que celui dont nous avons besoin. C'est dans un autre but. Donc, ne vous inquiétez pas des parties qui vont et viennent avec la fonction à l'intérieur de la classe ConcurrentExclusiveSchedulerPair. Cependant, vous ne pouvez pas l'utiliser directement car il ne transmet pas le TaskCreationOptions.LongRunning lors de la création de la tâche d'encapsulation.
Ça marche pour moi. Bonne chance !
P.S. :La raison pour laquelle il y a beaucoup de tâches dans la version de la minuterie réside probablement dans le TaskScheduler.TryExecuteTaskInline. S'il se trouve dans le thread principal où le ThreadPool est créé, il pourra exécuter certaines des tâches sans les mettre dans la file d'attente.