Параллельное поведение HttpClient при работе в Powershell отличается от поведения в Visual Studio

10

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

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

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

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

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

После нескольких часов проб и ошибок я смог сузить его до HttpClient. Чтобы изолировать проблему, я смоделировал вызовы HttpClient.SendAsync с помощью метода, который выполняет 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 B2C, созданных через GraphAPI с использованием 500 одновременных запросов. Первые 500 запросов длиннее обычного, так как создаются соединения TCP.

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

Вот ссылка на показатели запуска Visual Studio .

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

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

Марк Лаутер
источник
2
Проверяли ли вы состояние ваших соединений с помощью утилиты netstat после первого пакета? Это может дать некоторое представление о том, что происходит после того, как первые несколько задач будут выполнены.
Пранав Неганди
Если вы не решите его таким образом (асинхронный HTTP-запрос), вы всегда можете использовать синхронизацию HTTP-вызовов для каждого пользователя в параллелизме потребителя / производителя ConcurrentQueue [объект]. Недавно я сделал это примерно для 200 миллионов файлов в PowerShell.
thepip3r
1
@ thepip3r Я только что перечитал твой комментарий и понял его на этот раз. Я буду иметь это в виду.
Марк Лаутер
1
Нет, я говорю, если вы хотите использовать PowerShell вместо c #: leeholmes.com/blog/2018/09/05/… .
thepip3r
1
@ thepip3r Просто прочитайте запись в блоге от Стивена Клири. Я должен быть хорошим.
Марк Лаутер

Ответы:

3

На ум приходят две вещи. Большинство 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: Я хотел бы получить лучший ответ, но вам придется сравнивать два окружения, пока что-то не выделяется.

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
Аарон
источник
При запуске в Powershell 7.0 System.Threading.Thread.CurrentThread.GetApartmentState () возвращает MTA изнутри Program.Main ()
Марк Лаутер
Минимальный пул потоков по умолчанию был 12, я попытался увеличить размер минимального пула до размера моего пакета (500 для тестирования). Это не повлияло на поведение.
Марк Лаутер
Сколько потоков генерируется в обеих средах?
Аарон
Мне было интересно, сколько потоков у HttpClient, потому что он делает все на работе.
Аарон
Каково состояние квартиры в обеих ваших версиях?
Аарон