Параллельное поведение HttpClient при работе в Powershell отличается от поведения в Visual Studio - PullRequest
10 голосов
/ 16 марта 2020

Я перемещаю миллионы пользователей из локальной AD в Azure AD B2 C, используя MS Graph API для создания пользователей в B2 C. Я написал консольное приложение. Net Core 3.1 для выполнения этой миграции. Чтобы ускорить процесс, я делаю параллельные вызовы Graph API. Это работает отлично - вроде.

Во время разработки я испытал приемлемую производительность при запуске из Visual Studio 2019, но для тестирования я запускаю из командной строки в Powershell 7. Из Powershell производительность параллельных вызовов HttpClient очень плохо. Похоже, что существует ограничение на количество одновременных вызовов, которые HttpClient разрешает при запуске из Powershell, поэтому вызовы в одновременных пакетах, превышающих 40-50 запросов, начинают складываться. Кажется, он выполняет от 40 до 50 одновременных запросов при блокировке остальных.

Я не ищу помощи в асинхронном программировании c. Я ищу способ устранить разницу между поведением во время выполнения Visual Studio и поведением командной строки Powershell. Работа в режиме выпуска из зеленой кнопки Visual Studio ведет себя как ожидалось. Запуск из командной строки не выполняется.

Я заполняю список задач асинхронными вызовами c, а затем жду Task.WhenAll (tasks). Каждый вызов занимает от 300 до 400 миллисекунд. При запуске из Visual Studio все работает как положено. Я делаю одновременные партии по 1000 звонков, и каждый из них выполняется индивидуально в течение ожидаемого времени. Весь блок задач занимает всего несколько миллисекунд дольше, чем самый длинный индивидуальный вызов.

Поведение меняется, когда я запускаю ту же сборку из командной строки Powershell. Первые 40–50 вызовов занимают ожидаемые 300–400 миллисекунд, но затем время отдельного вызова увеличивается до 20 секунд каждый. Я думаю, что вызовы сериализуются, поэтому только 40–50 выполняются одновременно, в то время как остальные ждут.

После нескольких часов проб и ошибок я смог сузить его до HttpClient. Чтобы изолировать проблему, я смоделировал вызовы HttpClient.SendAsyn c с помощью метода, который выполняет Task.Delay (300) и возвращает ложный результат. В этом случае запуск с консоли ведет себя аналогично запуску из Visual Studio.

Я использую IHttpClientFactory, и я даже пытался настроить ограничение соединения в ServicePointManager.

Вот мой регистрационный код.

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

Вот 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;
    }
}

Вот код, который устанавливает задачи.

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

Вот как я смоделировал 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();

Здесь приведены показатели для пользователей 10k B2 C, созданных с помощью GraphAPI с использованием 500 одновременных запросов. Первые 500 запросов длиннее, чем обычно, поскольку создаются соединения TCP.

Вот ссылка на метрики запуска консоли .

Вот ссылка на Метрики выполнения Visual Studio .

Время блокировки в метриках запуска VS отличается от того, что я говорил в этом посте, потому что я перенес весь синхронный доступ к файлу в конец процесса, пытаясь максимально изолировать проблемный код c для тестовых прогонов.

Проект компилируется с использованием. Net Core 3.1. Я использую Visual Studio 2019 16.4.5.

1 Ответ

3 голосов
/ 19 марта 2020

На ум приходят две вещи. Большинство Microsoft PowerShell было написано в версии 1 и 2. В версиях 1 и 2 System.Threading.Thread.ApartmentState MTA. В версиях с 3 по 5 состояние квартиры по умолчанию изменилось на STA.

Вторая мысль: похоже, они используют System.Threading.ThreadPool для управления потоками. Насколько велик ваш пул потоков?

Если те не решают проблему, начните копаться в System.Threading.

Когда я прочитал ваш вопрос, я подумал об этом блоге. https://devblogs.microsoft.com/oldnewthing/20170623-00/?p=96455

Коллега продемонстрировал пример программы, которая создает тысячу рабочих элементов, каждый из которых имитирует сетевой вызов, выполнение которого занимает 500 мс. В первой демонстрации сетевые вызовы блокировали синхронные вызовы, и пример программы ограничил пул потоков десятью потоками, чтобы сделать эффект более очевидным. В этой конфигурации первые несколько рабочих элементов были быстро отправлены в потоки, но затем задержка начала накапливаться, поскольку больше не было доступных потоков для обслуживания новых рабочих элементов, поэтому оставшиеся рабочие элементы должны были ждать дольше и дольше, чтобы поток стать доступным для обслуживания. Средняя задержка начала рабочего элемента составляла более двух минут.

Обновление 1: я запустил PowerShell 7.0 из меню «Пуск», и состояние потока было STA. Различается ли состояние потока в двух версиях?

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

Обновление 2: лучше ответить sh, но вам придется сравнивать две среды, пока что-нибудь не выделится.

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

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

Обновление 3:

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

Кроме того, каждый экземпляр HttpClient использует собственный пул соединений, изолируя его запросы от запросов, выполняемых другими экземплярами HttpClient.

Если приложение, использующее HttpClient и связанные классы в пространстве имен Windows .Web.Http, загружает большие объемы данных (50 мегабайт или более), то приложение должно выполнять потоковую передачу эти загрузки и не использовать буферизацию по умолчанию. Если используется буферизация по умолчанию, использование памяти клиентом будет очень большим, что может привести к снижению производительности.

Просто продолжайте сравнивать две среды, и проблема должна выделяться

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
...