.Net 4.5의 비동기 HttpClient가 집중로드 애플리케이션에 적합하지 않습니까?


130

최근에 고전적인 다중 스레드 방식에 비해 비동기 방식으로 생성 될 수있는 HTTP 호출 처리량을 테스트하기위한 간단한 응용 프로그램을 만들었습니다.

애플리케이션은 사전 정의 된 수의 HTTP 호출을 수행 할 수 있으며 결국에는이를 수행하는 데 필요한 총 시간을 표시합니다. 테스트하는 동안 모든 HTTP 호출이 로컬 IIS 서버에 이루어졌으며 작은 텍스트 파일 (12 바이트 크기)을 검색했습니다.

비동기 구현을위한 코드의 가장 중요한 부분은 다음과 같습니다.

public async void TestAsync()
{
    this.TestInit();
    HttpClient httpClient = new HttpClient();

    for (int i = 0; i < NUMBER_OF_REQUESTS; i++)
    {
        ProcessUrlAsync(httpClient);
    }
}

private async void ProcessUrlAsync(HttpClient httpClient)
{
    HttpResponseMessage httpResponse = null;

    try
    {
        Task<HttpResponseMessage> getTask = httpClient.GetAsync(URL);
        httpResponse = await getTask;

        Interlocked.Increment(ref _successfulCalls);
    }
    catch (Exception ex)
    {
        Interlocked.Increment(ref _failedCalls);
    }
    finally
    { 
        if(httpResponse != null) httpResponse.Dispose();
    }

    lock (_syncLock)
    {
        _itemsLeft--;
        if (_itemsLeft == 0)
        {
            _utcEndTime = DateTime.UtcNow;
            this.DisplayTestResults();
        }
    }
}

멀티 스레딩 구현의 가장 중요한 부분은 다음과 같습니다.

public void TestParallel2()
{
    this.TestInit();
    ServicePointManager.DefaultConnectionLimit = 100;

    for (int i = 0; i < NUMBER_OF_REQUESTS; i++)
    {
        Task.Run(() =>
        {
            try
            {
                this.PerformWebRequestGet();
                Interlocked.Increment(ref _successfulCalls);
            }
            catch (Exception ex)
            {
                Interlocked.Increment(ref _failedCalls);
            }

            lock (_syncLock)
            {
                _itemsLeft--;
                if (_itemsLeft == 0)
                {
                    _utcEndTime = DateTime.UtcNow;
                    this.DisplayTestResults();
                }
            }
        });
    }
}

private void PerformWebRequestGet()
{ 
    HttpWebRequest request = null;
    HttpWebResponse response = null;

    try
    {
        request = (HttpWebRequest)WebRequest.Create(URL);
        request.Method = "GET";
        request.KeepAlive = true;
        response = (HttpWebResponse)request.GetResponse();
    }
    finally
    {
        if (response != null) response.Close();
    }
}

테스트를 실행하면 멀티 스레드 버전이 더 빠릅니다. 10k 요청에 대해 완료하는 데 약 0.6 초가 소요되었으며 비동기 요청은 동일한 양의로드에 대해 완료하는 데 약 2 초가 걸렸습니다. 비동기식이 더 빠를 것으로 기대했기 때문에 이것은 약간의 놀라움이었습니다. 내 HTTP 호출이 매우 빠르기 때문일 수 있습니다. 실제 시나리오에서는 서버가보다 의미있는 작업을 수행해야하고 네트워크 대기 시간도 있어야하는 경우 결과가 반전 될 수 있습니다.

그러나 실제로 관심이있는 것은로드가 증가 할 때 HttpClient가 작동하는 방식입니다. 10k 메시지를 전달하는 데 약 2 초가 걸리기 때문에 10 배의 메시지 수를 전달하는 데 약 20 초가 소요될 것이라고 생각했지만 테스트를 실행하면 100k 메시지를 전달하는 데 약 50 초가 소요되는 것으로 나타났습니다. 또한 일반적으로 200k 메시지를 배달하는 데 2 ​​분 이상이 걸리며 다음과 같은 경우를 제외하고 수천 개 (3-4k)가 실패하는 경우가 많습니다.

시스템에 충분한 버퍼 공간이 없거나 큐가 가득 찼기 때문에 소켓에서 작업을 수행 할 수 없습니다.

IIS 로그와 실패한 작업이 서버에 도착하지 않았 음을 확인했습니다. 그들은 클라이언트 내에서 실패했습니다. 임시 포트의 기본 범위 49152 ~ 65535를 사용하여 Windows 7 시스템에서 테스트를 실행했습니다. netstat를 실행하면 테스트 중에 약 5-6k 포트가 사용되는 것으로 나타 났으므로 이론적으로는 더 많은 것이 가능했을 것입니다. 포트가 실제로 예외의 원인 인 경우 netstat가 상황을 제대로보고하지 않았거나 HttClient가 최대 포트 수만 사용하고 예외가 발생하기 시작합니다.

대조적으로, HTTP 호출을 생성하는 멀티 스레드 접근 방식은 매우 예측 가능합니다. 10k 메시지의 경우 약 0.6 초, 100k 메시지의 경우 약 5.5 초, 1 백만 메시지의 경우 약 55 초가 걸렸습니다. 실패한 메시지가 없습니다. 또한 실행하는 동안 Windows 작업 관리자에 따라 55MB 이상의 RAM을 사용하지 않았습니다. 메시지를 비동기 적으로 보낼 때 사용되는 메모리는로드에 비례하여 증가했습니다. 200k 메시지 테스트 중에 약 500MB의 RAM을 사용했습니다.

위의 결과에는 두 가지 주요 이유가 있다고 생각합니다. 첫 번째는 HttpClient가 서버와의 새로운 연결을 만드는 데 매우 탐욕스러워 보인다는 것입니다. netstat에 의해보고 된 사용 된 포트 수가 많으면 HTTP 연결 유지 기능의 이점이 크지 않을 수 있습니다.

두 번째는 HttpClient에 조절 메커니즘이없는 것입니다. 실제로 이것은 비동기 작업과 관련된 일반적인 문제인 것 같습니다. 매우 많은 수의 작업을 수행해야하는 경우 모두 한 번에 시작되고 사용 가능한대로 연속이 실행됩니다. 이론적으로 이것은 비동기 작업에서 부하가 외부 시스템에 있기 때문에 이상이 없지만 위에서 입증 된 것처럼 이것은 전부는 아닙니다. 한 번에 많은 수의 요청을 시작하면 메모리 사용량이 증가하고 전체 실행 속도가 느려집니다.

간단하지만 기본 지연 메커니즘으로 최대 비동기 요청 수를 제한하여 더 나은 결과, 메모리 및 실행 시간을 현명하게 얻을 수있었습니다.

public async void TestAsyncWithDelay()
{
    this.TestInit();
    HttpClient httpClient = new HttpClient();

    for (int i = 0; i < NUMBER_OF_REQUESTS; i++)
    {
        if (_activeRequestsCount >= MAX_CONCURENT_REQUESTS)
            await Task.Delay(DELAY_TIME);

        ProcessUrlAsyncWithReqCount(httpClient);
    }
}

HttpClient에 동시 요청 수를 제한하는 메커니즘이 포함되어 있으면 정말 유용합니다. .Net 스레드 풀을 기반으로하는 Task 클래스를 사용하는 경우 동시 스레드 수를 제한하여 제한을 자동으로 수행합니다.

완전한 개요를 위해 HttpClient가 아닌 HttpWebRequest를 기반으로 한 비동기 테스트 버전을 만들었으며 훨씬 더 나은 결과를 얻을 수있었습니다. 시작을 위해 동시 연결 수에 대한 제한을 설정할 수 있습니다 (ServicePointManager.DefaultConnectionLimit 또는 구성을 통해). 포트가 부족하지 않고 모든 요청에서 실패하지 않았 음을 의미합니다 (기본적으로 HttpClient는 HttpWebRequest를 기반으로 함) 연결 제한 설정을 무시하는 것 같습니다).

비동기 HttpWebRequest 접근 방식은 여전히 ​​멀티 스레딩 접근 방식보다 약 50-60 % 느리지 만 예측 가능하고 신뢰할 수 있습니다. 그것의 유일한 단점은 큰로드에서 엄청난 양의 메모리를 사용한다는 것입니다. 예를 들어 백만 건의 요청을 보내려면 약 1.6GB가 필요했습니다. 동시 요청 수를 제한함으로써 (HttpClient에서 위에서했던 것처럼) 사용 된 메모리를 20MB로 줄이고 멀티 스레딩 방식보다 10 % 느린 실행 시간을 얻었습니다.

이 긴 프리젠 테이션 후에, 나의 질문은 다음과 같습니다. .Net 4.5의 HttpClient 클래스가 집중적 인로드 응용 프로그램에 적합하지 않습니까? 내가 언급 한 문제를 해결 해야하는 방법이 있습니까? HttpWebRequest의 비동기 풍미는 어떻습니까?

업데이트 (@Stephen Cleary 덕분에)

알 수 있듯이 HttpClient는 HttpWebRequest (기본적으로 기반으로 함)와 마찬가지로 ServicePointManager.DefaultConnectionLimit로 제한된 동일한 호스트에서 동시 연결 수를 가질 수 있습니다. 이상한 점은 MSDN 에 따르면 연결 제한의 기본값은 2입니다. 디버거를 사용하여 내면에서 실제로 2가 기본값임을 확인했습니다. 그러나 값을 ServicePointManager.DefaultConnectionLimit으로 명시 적으로 설정하지 않으면 기본값이 무시됩니다. HttpClient 테스트 중에 명시 적으로 값을 설정하지 않았으므로 무시되었다고 생각했습니다.

ServicePointManager.DefaultConnectionLimit를 100으로 설정 한 후 HttpClient는 신뢰할 수 있고 예측 가능해졌습니다 (netstat는 100 개의 포트만 사용됨을 확인합니다). 여전히 비동기 HttpWebRequest (약 40 %)보다 느리지 만 이상하게도 더 적은 메모리를 사용합니다. 백만 건의 요청이 포함 된 테스트의 경우 비동기 HttpWebRequest의 1.6GB와 비교하여 최대 550MB를 사용했습니다.

따라서 ServicePointManager.DefaultConnectionLimit 조합의 HttpClient는 안정성을 보장하는 것처럼 보이지만 (적어도 모든 호스트가 동일한 호스트를 향한 시나리오의 경우) 적절한 조절 메커니즘이 없기 때문에 성능에 부정적인 영향을 미치는 것으로 보입니다. 동시 요청 수를 구성 가능한 값으로 제한하고 나머지를 대기열에 넣는 것은 확장 성이 높은 시나리오에 훨씬 적합합니다.


4
HttpClient존중해야합니다 ServicePointManager.DefaultConnectionLimit.
Stephen Cleary

2
관찰 한 내용은 조사 할 가치가있는 것 같습니다. 한 가지 사실은 나를 귀찮게합니다 : 한 번에 수천 개의 비동기 IO를 발행하는 것이 매우 중요하다고 생각합니다. 나는 프로덕션에서 이것을하지 않을 것입니다. 당신이 비동기 적이라는 사실이 다양한 자원을 소비하는 견딜 수 없다는 의미는 아닙니다. (마이크로 소프트 공식 샘플도 이와 관련하여 약간의 오해의 소지가있다.)
usr

1
그러나 시간 지연으로 조절하지 마십시오. 경험적으로 결정되는 고정 된 동시성 수준에서 조절합니다. 간단한 솔루션은 SemaphoreSlim.WaitAsync이지만, 많은 양의 작업에는 적합하지 않습니다.
usr

1
@FlorinDumitrescu 제한을 위해 SemaphoreSlim이미 언급했듯이 또는 ActionBlock<T>TPL Dataflow 에서을 사용할 수 있습니다 .
svick

1
@svick, 제안 해 주셔서 감사합니다. 스로틀 링 / 동시성 제한 메커니즘을 수동으로 구현하는 데 관심이 없습니다. 언급했듯이 내 질문에 포함 된 구현은 이론을 테스트하고 검증하기위한 것입니다. 프로덕션에 들어 가지 않기 때문에 개선하려고하지 않습니다. 내가 관심이있는 것은 .Net 프레임 워크가 비동기 IO 작업 (HttpClient 포함)의 동시성을 제한하기위한 내장 메커니즘을 제공하는지입니다.
Florin Dumitrescu

답변:


64

이 질문에 언급 된 테스트 외에도 최근에는 훨씬 적은 수의 HTTP 호출 (이전에 1 백만에 비해 5000 건)이 걸리지 만 실행하는 데 훨씬 오래 걸리는 요청 (이전에는 1 밀리 초에 비해 500 밀리 초)이 새로 추가되었습니다. 동기식 멀티 스레드 애플리케이션 (HttpWebRequest 기반)과 비동기 I / O 애플리케이션 (HTTP 클라이언트 기반) 둘 다 테스터 애플리케이션은 CPU의 약 3 %와 30MB의 메모리를 사용하여 실행하는 데 약 10 초가 소요됩니다. 두 테스터의 유일한 차이점은 멀티 스레드가 310 스레드를 사용하고 비동기 스레드는 22를 사용한다는 것입니다.

내 테스트의 결론으로, 매우 빠른 요청을 처리 할 때 비동기 HTTP 호출이 최선의 옵션이 아닙니다. 그 이유는 비동기 I / O 호출이 포함 된 작업을 실행할 때 작업이 시작되는 스레드가 비동기 호출이 수행되고 나머지 작업이 콜백으로 등록되는 즉시 종료되기 때문입니다. 그런 다음 I / O 작업이 완료되면 사용 가능한 첫 번째 스레드에서 콜백이 큐에 대기됩니다. 이 모든 것이 오버 헤드를 생성하므로, 시작한 스레드에서 실행될 때 빠른 I / O 작업의 효율성이 높아집니다.

비동기 HTTP 호출은 I / O 작업이 완료 될 때까지 대기중인 스레드를 유지하지 않기 때문에 길거나 잠재적으로 긴 I / O 작업을 처리 할 때 좋은 옵션입니다. 이렇게하면 응용 프로그램에서 사용하는 전체 스레드 수가 줄어 CPU 바인딩 작업에 더 많은 CPU 시간을 소비 할 수 있습니다. 또한 제한된 수의 스레드 만 할당하는 응용 프로그램 (웹 응용 프로그램의 경우와 같이)에서 비동기 I / O는 스레드 풀 스레드 고갈을 방지하여 I / O 호출을 동 기적으로 수행 할 경우 발생할 수 있습니다.

따라서 비동기 HttpClient는 집중적 인로드 응용 프로그램의 병목 현상이 아닙니다. 그것은 본질적으로 매우 빠른 HTTP 요청에 적합하지 않으며, 길거나 잠재적으로 긴 요청, 특히 제한된 수의 스레드 만 사용할 수있는 응용 프로그램에 이상적입니다. 또한 ServicePointManager.DefaultConnectionLimit를 통해 동시성을 제한하는 것은 좋은 수준의 병렬 처리를 보장하기에 충분히 높지만 임시 포트 고갈을 방지하기에 충분히 낮은 값으로하는 것입니다. 이 질문에 대한 테스트 및 결론에 대한 자세한 내용은 여기를 참조하십시오 .


3
"매우 빠릅니다"는 얼마나 빠릅니까? 1ms? 100ms? 1,000ms?
Tim P.

Windows에 배포 된 WebLogic 웹 서버에서로드를 재생하기 위해 "비동기식"접근 방식과 같은 것을 사용하고 있지만 다소 빠른 포트 고갈 문제가 발생합니다. ServicePointManager.DefaultConnectionLimit을 건드리지 않았으며 각 요청에 대한 모든 (HttpClient 및 응답)을 삭제하고 다시 만듭니다. 연결이 열린 상태를 유지하고 포트를 고갈시키는 원인이 무엇인지 알고 있습니까?
Iravanchi

@TimP. 위에서 언급했듯이 내 테스트의 경우 "매우 빠른"요청은 완료하는 데 1 밀리 초 밖에 걸리지 않았습니다. 실제 세계에서는 항상 주관적입니다. 내 관점에서 볼 때 로컬 네트워크 데이터베이스의 작은 쿼리에 해당하는 것이 빠르다고 생각할 수 있지만 인터넷을 통한 API 호출에 해당하는 것이 느리거나 잠재적으로 느릴 수 있습니다.
Florin Dumitrescu 2016 년

1
@Iravanchi는 "비동기식"접근 방식에서 요청 전송 및 응답 처리가 별도로 수행됩니다. 많은 통화가있는 경우 모든 요청이 매우 빠르게 전송되고 응답이 도착하면 처리됩니다. 응답이 도착한 후에 만 ​​연결을 폐기 할 수 있으므로 많은 수의 동시 연결이 누적되어 임시 포트를 고갈시킬 수 있습니다. ServicePointManager.DefaultConnectionLimit를 사용하여 최대 동시 연결 수를 제한해야합니다.
Florin Dumitrescu

1
@FlorinDumitrescu, 나는 또한 네트워크 호출은 본질적으로 예측할 수 없다고 덧붙였다. 시간의 10ms 90 %에서 실행되는 항목은 해당 네트워크 리소스가 정체되거나 다른 10 %의 시간 동안 사용 불가능할 때 차단 문제를 일으킬 수 있습니다.
Tim P.

27

결과에 영향을 줄 수있는 한 가지 고려해야 할 사항은 HttpWebRequest를 사용하면 ResponseStream을 얻지 않고 해당 스트림을 소비한다는 것입니다. HttpClient를 사용하면 기본적으로 네트워크 스트림을 메모리 스트림에 복사합니다. 현재 HttpWebRquest를 사용하는 것과 같은 방식으로 HttpClient를 사용하려면 다음을 수행해야합니다.

var requestMessage = new HttpRequestMessage() {RequestUri = URL};
Task<HttpResponseMessage> getTask = httpClient.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead);

다른 점은 스레딩 관점에서 실제로 어떤 차이가 있는지 실제로 확실하지 않다는 것입니다. HttpClientHandler의 깊이를 파헤 치면 비동기 요청을 수행하기 위해 Task.Factory.StartNew를 수행합니다. 스레딩 동작은 HttpWebRequest 예제를 사용한 예제와 동일한 방식으로 동기화 컨텍스트에 위임됩니다.

의심 할 여지없이, HttpClient는 기본적으로 HttpWebRequest를 전송 라이브러리로 사용하므로 약간의 오버 헤드를 추가합니다. 따라서 HttpClientHandler를 사용하는 동안 HttpWebRequest를 사용하여 항상 더 나은 성능을 얻을 수 있습니다. HttpClient가 제공하는 이점은 HttpResponseMessage, HttpRequestMessage, HttpContent 및 모든 강력한 형식의 헤더와 같은 표준 클래스입니다. 그 자체로는 성능 최적화가 아닙니다.


(오래된 대답이지만) HttpClient사용하기 쉬운 것처럼 보였고 비동기식이 나아갈 길이라고 생각했지만이 주위에 많은 "그러나 경우"가있는 것 같습니다. 어쩌면 HttpClient더 직관적으로 사용할 수 있도록 다시 작성해야합니까? 아니면 문서가 실제로 가장 효율적으로 사용하는 방법에 대한 중요한 사항을 강조했습니까?
mortb

@mortb, Flurl.Http의 flurl.io는 HttpClient를 사용 래퍼에 더 직관적이다
마이클 Freidgeim

1
@MichaelFreidgeim : 감사합니다. 지금까지 HttpClient와 함께 사는 법을 배웠지 만 ...
mortb

17

이것은 OP 질문의 '비동기'부분에 직접 대답하지는 않지만, 사용중인 구현의 오류를 해결합니다.

애플리케이션을 확장하려면 인스턴스 기반 HttpClient를 사용하지 마십시오. 차이점은 거대합니다! 부하에 따라 성능 수치가 매우 다릅니다. HttpClient는 여러 요청에서 재사용되도록 설계되었습니다. 이것은 BCL 팀의 직원들에 의해 확인되었습니다.

내가 최근에 수행 한 프로젝트는 매우 유명한 대형 온라인 컴퓨터 소매 업체가 일부 새로운 시스템의 Black Friday / holiday 트래픽으로 확장 할 수 있도록 돕는 것이 었습니다. HttpClient 사용과 관련된 성능 문제가 발생했습니다. 그것은 구현하기 때문에 개발자 IDisposable는 인스턴스를 생성하고 using()명령문 안에 배치하여 일반적으로하는 일을했습니다 . 우리가로드 테스트를 시작하면 앱이 서버를 무릎 꿇게했습니다. 그렇습니다. 앱뿐만 아니라 서버도 마찬가지입니다. 그 이유는 모든 HttpClient 인스턴스가 서버에서 I / O 완료 포트를 열기 때문입니다. GC의 결정적이지 않은 마무리와 여러 OSI 계층에 걸쳐있는 컴퓨터 리소스를 사용하고 있기 때문에 네트워크 포트를 닫는 데 시간이 오래 걸릴 수 있습니다. 실제로 Windows OS 자체포트를 닫는 데 최대 20 초가 소요될 수 있습니다 (Microsoft 당). 우리는 닫힐 수있는 것보다 빨리 포트를 열었습니다. 서버 포트 소진으로 인해 CPU가 100 %로 떨어졌습니다. 내 문제는 HttpClient를 정적 인스턴스로 변경하여 문제를 해결하는 것이 었습니다. 예, 일회용 자원이지만 성능의 차이로 인해 오버 헤드가 훨씬 더 큽니다. 앱의 작동 방식을 확인하기 위해로드 테스트를 수행하는 것이 좋습니다.

또한 아래 링크에서 답변했습니다.

WebAPI 클라이언트에서 호출 당 새로운 HttpClient를 작성하는 오버 헤드는 무엇입니까?

https://www.asp.net/web-api/overview/advanced/calling-a-web-api-from-a-net-client


클라이언트에서 TCP 포트 소진을 생성하는 것과 정확히 동일한 문제가 발견되었습니다. 해결책은 각 호출에 대해 작성 및 처리하지 않고 반복 호출이 수행되는 장기간 HttpClient 인스턴스를 임대하는 것이 었습니다. 내가 도달 한 결론은 "그것이 Dispose를 구현한다고해서 폐기하기에 값이 싼 것은 아닙니다."였습니다.
PhillipH

따라서 HttpClient가 정적이고 다음 요청에서 헤더를 변경 해야하는 경우 첫 번째 요청은 어떻게됩니까? HttpClient가 정적이기 때문에 HttpClient를 변경하면 해가 있습니까 (예 : HttpClient.DefaultRequestHeaders.Accept.Clear ();). ? 예를 들어, 토큰을 통해 인증하는 사용자가있는 경우 해당 토큰을 API에 대한 요청에 헤더로 추가해야합니다. HttpClient를 정적으로 사용하지 않고 HttpClient 에서이 헤더를 변경하면 부정적인 영향을 미칩니 까?
crizzwald

헤더 / 쿠키 등의 HttpClient 인스턴스 멤버를 사용해야하는 경우 정적 HttpClient를 사용하지 않아야합니다. 그렇지 않으면 인스턴스 데이터 (헤더, 쿠키)가 모든 요청에 ​​대해 동일 할 것입니다. 확실히 원하는 것은 아닙니다.
Dave Black

이 경우이기 때문에 ...로드에 대해 게시물에서 위에서 설명한 내용을 어떻게 방지합니까? 로드 밸런서 및 더 많은 서버를 던져?
crizzwald

@ crizzwald-내 게시물에서 사용 된 솔루션에 주목했습니다. HttpClient의 정적 인스턴스를 사용하십시오. HttpClient에서 헤더 / 쿠키를 사용해야하는 경우 대안을 사용하려고합니다.
Dave Black
당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.