Współbieżne zachowanie HttpClient różni się podczas uruchamiania w programie Powershell niż w programie Visual Studio


10

Migruję miliony użytkowników z tymczasowej usługi AD do usługi Azure AD B2C za pomocą interfejsu API MS Graph, aby utworzyć użytkowników w B2C. Napisałem aplikację konsolową .Net Core 3.1 do przeprowadzenia tej migracji. Aby przyspieszyć, wykonuję jednoczesne połączenia z Graph API. To działa świetnie - w pewnym sensie.

Podczas programowania doświadczyłem zadowalającej wydajności podczas uruchamiania z Visual Studio 2019, ale do testu uruchamiam z wiersza poleceń w Powershell 7. Z Powershell wydajność jednoczesnych wywołań do HttpClient jest bardzo zła. Wygląda na to, że istnieje ograniczenie liczby jednoczesnych wywołań, na które HttpClient zezwala podczas uruchamiania z Powershell, więc wywołania w równoległych partiach większych niż 40 do 50 żądań zaczynają się nakładać. Wygląda na to, że blokuje pozostałe 40 do 50 równoczesnych żądań.

Nie szukam pomocy w programowaniu asynchronicznym. Szukam sposobu, aby rozwiązać problem z różnicą między zachowaniem w czasie wykonywania programu Visual Studio a zachowaniem w wierszu polecenia programu PowerShell. Uruchamianie w trybie zwolnienia z zielonego przycisku strzałki w programie Visual Studio działa zgodnie z oczekiwaniami. Uruchamianie z wiersza poleceń nie.

Wypełniam listę zadań wywołaniami asynchronicznymi, a następnie oczekuję na Task.WhenAll (zadania). Każde połączenie trwa od 300 do 400 milisekund. Podczas uruchamiania z Visual Studio działa zgodnie z oczekiwaniami. Wykonuję równoczesne partie 1000 połączeń i każda z nich kończy się indywidualnie w oczekiwanym czasie. Cały blok zadań zajmuje tylko kilka milisekund dłużej niż najdłuższe indywidualne wywołanie.

Zachowanie zmienia się, gdy uruchamiam tę samą kompilację z wiersza polecenia Powershell. Pierwsze 40 do 50 połączeń zajmuje oczekiwane 300 do 400 milisekund, ale potem poszczególne czasy połączeń rosną do 20 sekund. Myślę, że połączenia są serializowane, więc tylko 40 do 50 jest wykonywanych jednocześnie, podczas gdy inni czekają.

Po wielu godzinach prób i błędów udało mi się zawęzić go do HttpClient. Aby wyodrębnić problem, wyśmiewałem wywołania HttpClient.SendAsync metodą, która wykonuje Task.Delay (300) i zwraca próbny wynik. W takim przypadku uruchamianie z konsoli zachowuje się identycznie jak uruchamianie z Visual Studio.

Korzystam z IHttpClientFactory, a nawet próbowałem dostosować limit połączeń w ServicePointManager.

Oto mój kod rejestracyjny.

    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;
    }

Oto 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;
    }
}

Oto kod konfigurujący zadania.

        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();

Oto jak wyśmiewałem 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();

Oto miary dla 10 000 użytkowników B2C utworzonych za pomocą GraphAPI przy użyciu 500 równoczesnych żądań. Pierwsze 500 żądań jest dłuższych niż zwykle, ponieważ tworzone są połączenia TCP.

Oto link do wskaźników uruchamiania konsoli .

Oto link do metryk uruchamiania programu Visual Studio .

Czasy blokowania w pomiarach przebiegu VS są inne niż w tym, co powiedziałem w tym poście, ponieważ przeniosłem cały dostęp do pliku synchronicznego na koniec procesu, aby jak najbardziej odizolować problematyczny kod dla przebiegów testowych.

Projekt jest kompilowany przy użyciu .Net Core 3.1. Używam Visual Studio 2019 16.4.5.


2
Czy sprawdziłeś stan swoich połączeń z narzędziem netstat po pierwszej partii? Może dać pewien wgląd w to, co się dzieje po ukończeniu pierwszych kilku zadań.
Pranav Negandhi

Jeśli nie rozwiążesz go w ten sposób (zsynchronizuj żądanie HTTP), zawsze możesz użyć synchronizujących wywołań HTTP dla każdego użytkownika w równoległości konsument / producent ConcurrentQueue [obiekt]. Ostatnio zrobiłem to dla około 200 milionów plików w PowerShell.
thepip3r

1
@ thepip3r Właśnie przeczytałem ponownie twoje polecenie i tym razem je zrozumiałem. Zapamiętam to.
Mark Lauter

1
Nie, mówię, jeśli chcesz użyć programu PowerShell zamiast c #: leeholmes.com/blog/2018/09/05/… .
thepip3r

1
@ thepip3r Wystarczy przeczytać wpis na blogu od Stephena Cleary'ego. Powinienem być dobry
Mark Lauter

Odpowiedzi:


3

Przychodzą mi na myśl dwie rzeczy. Większość Microsoft PowerShell został napisany w wersji 1 i 2. Wersja 1 i 2 mają System.Threading.Thread.ApartmentState MTA. W wersjach od 3 do 5 stan mieszkania domyślnie zmienił się na STA.

Druga myśl to brzmi, jakby używali System.Threading.ThreadPool do zarządzania wątkami. Jak duży jest twój wątek?

Jeśli to nie rozwiąże problemu, zacznij kopać w System.Threading.

Kiedy przeczytałem twoje pytanie, pomyślałem o tym blogu. https://devblogs.microsoft.com/oldnewthing/20170623-00/?p=96455

Współpracownik zademonstrował przykładowy program, który tworzy tysiąc elementów pracy, z których każdy symuluje połączenie sieciowe, które trwa 500 ms. W pierwszej demonstracji wywołania sieciowe blokowały wywołania synchroniczne, a przykładowy program ograniczył pulę wątków do dziesięciu wątków, aby efekt był bardziej widoczny. W tej konfiguracji kilka pierwszych elementów pracy zostało szybko wysłanych do wątków, ale potem zaczęło się budować opóźnienie, ponieważ nie było już dostępnych wątków do obsługi nowych elementów pracy, więc pozostałe elementy pracy musiały czekać dłużej i dłużej na wątek stają się dostępne do obsługi. Średni czas oczekiwania na rozpoczęcie pracy wynosił ponad dwie minuty.

Aktualizacja 1: Uruchomiłem PowerShell 7.0 z menu Start, a stan wątku to STA. Czy stan wątku jest inny w dwóch wersjach?

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

Aktualizacja 2: Chciałbym lepszej odpowiedzi, ale będziesz porównywał oba środowiska, aż coś się wyróżni.

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

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

Aktualizacja 3:

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

Ponadto każda instancja HttpClient korzysta z własnej puli połączeń, izolując swoje żądania od żądań wykonanych przez inne instancje HttpClient.

Jeśli aplikacja korzystająca z HttpClient i powiązanych klas w przestrzeni nazw Windows.Web.Http pobiera duże ilości danych (50 megabajtów lub więcej), to aplikacja powinna przesyłać strumieniowo te pobrane pliki i nie używać domyślnego buforowania. Jeśli zostanie użyte domyślne buforowanie, użycie pamięci klienta będzie bardzo duże, co może potencjalnie obniżyć wydajność.

Po prostu porównuj oba środowiska, a problem powinien się wyróżniać

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

Podczas pracy w Powershell 7.0 System.Threading.Thread.CurrentThread.GetApartmentState () zwraca MTA z poziomu Program.Main ()
Mark Lauter

Domyślna minimalna pula wątków wynosiła 12, próbowałem zwiększyć minimalny rozmiar puli do mojego rozmiaru partii (500 do testowania). Nie miało to wpływu na zachowanie.
Mark Lauter

Ile wątków jest generowanych w obu środowiskach?
Aaron

Zastanawiałem się, ile wątków ma „HttpClient”, ponieważ wykonuje on całą pracę.
Aaron

Jaki jest stan mieszkania w obu twoich wersjach?
Aaron
Korzystając z naszej strony potwierdzasz, że przeczytałeś(-aś) i rozumiesz nasze zasady używania plików cookie i zasady ochrony prywatności.
Licensed under cc by-sa 3.0 with attribution required.