Comportement simultané HttpClient différent lors de l'exécution dans Powershell que dans Visual Studio

10

Je migre des millions d'utilisateurs d'AD sur site vers Azure AD B2C à l'aide de l'API MS Graph pour créer les utilisateurs dans B2C. J'ai écrit une application console .Net Core 3.1 pour effectuer cette migration. Pour accélérer les choses, je fais des appels simultanés à l'API Graph. Cela fonctionne très bien - en quelque sorte.

Pendant le développement, j'ai rencontré des performances acceptables lors de l'exécution à partir de Visual Studio 2019, mais pour le test, j'exécute à partir de la ligne de commande dans Powershell 7. À partir de Powershell, les performances des appels simultanés à HttpClient sont très mauvaises. Il semble qu'il y ait une limite au nombre d'appels simultanés que HttpClient autorise lors de l'exécution à partir de Powershell, donc les appels en lots simultanés supérieurs à 40 à 50 demandes commencent à s'empiler. Il semble exécuter 40 à 50 requêtes simultanées tout en bloquant le reste.

Je ne cherche pas d'aide pour la programmation asynchrone. Je cherche un moyen de résoudre la différence entre le comportement d'exécution de Visual Studio et le comportement d'exécution de la ligne de commande Powershell. L'exécution en mode de libération à partir du bouton fléché vert de Visual Studio se comporte comme prévu. L'exécution à partir de la ligne de commande ne fonctionne pas.

Je remplis une liste de tâches avec des appels asynchrones, puis j'attends Task.WhenAll (tâches). Chaque appel prend entre 300 et 400 millisecondes. Lors de l'exécution à partir de Visual Studio, cela fonctionne comme prévu. Je fais des lots simultanés de 1000 appels et chacun se termine individuellement dans le délai prévu. L'ensemble du bloc de tâches ne prend que quelques millisecondes de plus que l'appel individuel le plus long.

Le comportement change lorsque j'exécute la même version à partir de la ligne de commande Powershell. Les 40 à 50 premiers appels prennent les 300 à 400 millisecondes attendus, mais la durée des appels individuels augmente jusqu'à 20 secondes chacun. Je pense que les appels sérialisent, donc seulement 40 à 50 sont exécutés à la fois pendant que les autres attendent.

Après des heures d'essais et d'erreurs, j'ai pu le réduire au HttpClient. Pour isoler le problème, j'ai simulé les appels à HttpClient.SendAsync avec une méthode qui exécute Task.Delay (300) et renvoie un résultat factice. Dans ce cas, l'exécution à partir de la console se comporte de manière identique à l'exécution à partir de Visual Studio.

J'utilise IHttpClientFactory et j'ai même essayé d'ajuster la limite de connexion sur ServicePointManager.

Voici mon code d'enregistrement.

    public static IServiceCollection RegisterHttpClient(this IServiceCollection services, int batchSize)
    {
        ServicePointManager.DefaultConnectionLimit = batchSize;
        ServicePointManager.MaxServicePoints = batchSize;
        ServicePointManager.SetTcpKeepAlive(true, 1000, 5000);

        services.AddHttpClient(MSGraphRequestManager.HttpClientName, c =>
        {
            c.Timeout = TimeSpan.FromSeconds(360);
            c.DefaultRequestHeaders.Add("User-Agent", "xxxxxxxxxxxx");
        })
        .ConfigurePrimaryHttpMessageHandler(() => new DefaultHttpClientHandler(batchSize));

        return services;
    }

Voici le DefaultHttpClientHandler.

internal class DefaultHttpClientHandler : HttpClientHandler
{
    public DefaultHttpClientHandler(int maxConnections)
    {
        this.MaxConnectionsPerServer = maxConnections;
        this.UseProxy = false;
        this.AutomaticDecompression = System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate;
    }
}

Voici le code qui définit les tâches.

        var timer = Stopwatch.StartNew();
        var tasks = new Task<(UpsertUserResult, TimeSpan)>[users.Length];
        for (var i = 0; i < users.Length; ++i)
        {
            tasks[i] = this.CreateUserAsync(users[i]);
        }

        var results = await Task.WhenAll(tasks);
        timer.Stop();

Voici comment je me suis moqué du HttpClient.

        var httpClient = this.httpClientFactory.CreateClient(HttpClientName);
        #if use_http
            using var response = await httpClient.SendAsync(request);
        #else
            await Task.Delay(300);
            var graphUser = new User { Id = "mockid" };
            using var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(JsonConvert.SerializeObject(graphUser)) };
        #endif
        var responseContent = await response.Content.ReadAsStringAsync();

Voici les mesures pour les utilisateurs B2C 10k créés via GraphAPI en utilisant 500 demandes simultanées. Les 500 premières demandes sont plus longues que la normale car les connexions TCP sont en cours de création.

Voici un lien vers les métriques d'exécution de la console .

Voici un lien vers les métriques d'exécution de Visual Studio .

Les temps de blocage dans les métriques d'exécution VS sont différents de ce que j'ai dit dans ce post parce que j'ai déplacé tous les accès aux fichiers synchrones à la fin du processus dans le but d'isoler le code problématique autant que possible pour les tests.

Le projet est compilé à l'aide de .Net Core 3.1. J'utilise Visual Studio 2019 16.4.5.

Mark Lauter
la source
2
Avez-vous revu l'état de vos connexions avec l'utilitaire netstat après le premier lot? Il pourrait donner un aperçu de ce qui se passe après la fin des premières tâches.
Pranav Negandhi
Si vous ne finissez pas par le résoudre de cette façon (asynchroniser la demande HTTP), vous pouvez toujours utiliser des appels HTTP de synchronisation pour chaque utilisateur dans un parallélisme consommateur / producteur ConcurrentQueue [objet]. J'ai récemment fait cela pour environ 200 millions de fichiers dans PowerShell.
thepip3r
1
@ thepip3r Je viens de relire votre éloge et je l'ai compris cette fois. Je garderai ça à l'esprit.
Mark Lauter
1
Non, je dis, si vous vouliez aller PowerShell au lieu de c #: leeholmes.com/blog/2018/09/05/… .
thepip3r
1
@ thepip3r Il suffit de lire l'entrée de blog de Stephen Cleary. Je devrais être bon.
Mark Lauter

Réponses:

3

Deux choses viennent à l'esprit. La plupart des microsoft powershell ont été écrits dans les versions 1 et 2. Les versions 1 et 2 ont System.Threading.Thread.ApartmentState de MTA. Dans les versions 3 à 5, l'état de l'appartement est devenu STA par défaut.

La deuxième pensée est qu'il semble qu'ils utilisent System.Threading.ThreadPool pour gérer les threads. Quelle est la taille de votre pool de threads?

Si ceux-ci ne résolvent pas le problème, commencez à creuser sous System.Threading.

Quand j'ai lu votre question, j'ai pensé à ce blog. https://devblogs.microsoft.com/oldnewthing/20170623-00/?p=96455

Un collègue a fait une démonstration avec un exemple de programme qui crée un millier d'éléments de travail, chacun simulant un appel réseau qui prend 500 ms. Dans la première démonstration, les appels réseau bloquaient les appels synchrones et l'exemple de programme a limité le pool de threads à dix threads afin de rendre l'effet plus apparent. Sous cette configuration, les premiers éléments de travail ont été rapidement distribués aux threads, mais la latence a commencé à se créer car il n'y avait plus de threads disponibles pour gérer les nouveaux éléments de travail, de sorte que les autres éléments de travail devaient attendre de plus en plus longtemps pour qu'un thread soit devenir disponible pour le réparer. La latence moyenne au début de l'élément de travail était supérieure à deux minutes.

Mise à jour 1: j'ai exécuté PowerShell 7.0 à partir du menu Démarrer et l'état du thread était STA. L'état du thread est-il différent dans les deux versions?

PS C:\Program Files\PowerShell\7>  [System.Threading.Thread]::CurrentThread

ManagedThreadId    : 12
IsAlive            : True
IsBackground       : False
IsThreadPoolThread : False
Priority           : Normal
ThreadState        : Running
CurrentCulture     : en-US
CurrentUICulture   : en-US
ExecutionContext   : System.Threading.ExecutionContext
Name               : Pipeline Execution Thread
ApartmentState     : STA

Mise à jour 2: je souhaite une meilleure réponse, mais vous devrez comparer les deux environnements jusqu'à ce que quelque chose se démarque.

PS C:\Windows\system32> [System.Net.ServicePointManager].GetProperties() | select name

Name                               
----                               
SecurityProtocol                   
MaxServicePoints                   
DefaultConnectionLimit             
MaxServicePointIdleTime            
UseNagleAlgorithm                  
Expect100Continue                  
EnableDnsRoundRobin                
DnsRefreshTimeout                  
CertificatePolicy                  
ServerCertificateValidationCallback
ReusePort                          
CheckCertificateRevocationList     
EncryptionPolicy            

Mise à jour 3:

https://docs.microsoft.com/en-us/uwp/api/windows.web.http.httpclient

En outre, chaque instance HttpClient utilise son propre pool de connexions, isolant ses demandes des demandes exécutées par d'autres instances HttpClient.

Si une application utilisant HttpClient et des classes connexes dans l'espace de noms Windows.Web.Http télécharge de grandes quantités de données (50 mégaoctets ou plus), l'application doit diffuser ces téléchargements et ne pas utiliser la mise en mémoire tampon par défaut. Si la mise en mémoire tampon par défaut est utilisée, l'utilisation de la mémoire du client deviendra très importante, entraînant potentiellement une baisse des performances.

Continuez simplement à comparer les deux environnements et le problème devrait ressortir

Add-Type -AssemblyName System.Net.Http
$client = New-Object -TypeName System.Net.Http.Httpclient
$client | format-list *

DefaultRequestHeaders        : {}
BaseAddress                  : 
Timeout                      : 00:01:40
MaxResponseContentBufferSize : 2147483647
Aaron
la source
Lors de l'exécution dans Powershell 7.0 System.Threading.Thread.CurrentThread.GetApartmentState () renvoie MTA à partir de Program.Main ()
Mark Lauter
Le pool de threads min par défaut était de 12, j'ai essayé d'augmenter la taille du pool min à la taille de mon lot (500 pour les tests). Cela n'a eu aucun effet sur le comportement.
Mark Lauter
Combien de threads sont générés dans les deux environnements?
Aaron
Je me demandais combien de threads le 'HttpClient' a parce qu'il fait tout sur le travail.
Aaron
Quel est l'état de l'appartement dans vos deux versions?
Aaron