다음을 통해 공유


비동기 프로그래밍 시나리오

코드가 네트워크 데이터 요청, 데이터베이스 액세스 또는 파일 시스템 읽기/쓰기를 지원하기 위해 I/O 바인딩된 시나리오를 구현하는 경우 비동기 프로그래밍이 가장 좋은 방법입니다. 비용이 많이 드는 계산과 같은 CPU 바인딩 시나리오에 대한 비동기 코드를 작성할 수도 있습니다.

C#에는 콜백을 저글링하거나 비동기를 지원하는 라이브러리를 따르지 않고도 비동기 코드를 쉽게 작성할 수 있는 언어 수준 비동기 프로그래밍 모델이 있습니다. 모델은 TAP(작업 기반 비동기 패턴)따릅니다.

비동기 프로그래밍 모델 살펴보기

TaskTask<T> 개체는 비동기 프로그래밍의 핵심을 나타냅니다. 이러한 개체는 asyncawait 키워드를 지원하여 비동기 작업을 모델링하는 데 사용됩니다. 대부분의 경우 모델은 I/O 바인딩된 시나리오와 CPU 바인딩된 시나리오 모두에 대해 매우 간단합니다. async 메서드 내부:

  • I/O 바운드된 코드Task 메서드 내에서 Task<T> 또는 async 객체로 표현된 작업을 시작합니다.
  • CPU 바인딩된 코드Task.Run 메서드를 사용하여 백그라운드 스레드에서 작업을 시작합니다.

두 경우 모두 활성 Task 완료되지 않을 수 있는 비동기 작업을 나타냅니다.

await 키워드가 마법이 일어나는 곳입니다. await 식을 포함하는 메서드의 호출자에게 제어를 생성하고 궁극적으로 UI가 응답하거나 서비스가 탄력적일 수 있도록 합니다. await 방법이 있지만 이 문서에서는 언어 수준 구문에 중점을 둡니다.

참고

이 문서에 제시된 몇 가지 예제에서는 System.Net.Http.HttpClient 클래스를 사용하여 웹 서비스에서 데이터를 다운로드합니다. 예제 코드에서 s_httpClient 개체는 Program 클래스 형식의 정적 필드입니다.

private static readonly HttpClient s_httpClient = new();

자세한 내용은 이 문서의 끝에 전체 예제 코드를 참조하세요.

기본 개념 검토

C# 코드에서 비동기 프로그래밍을 구현하면 컴파일러가 프로그램을 상태 컴퓨터로 변환합니다. 이 구문은 코드가 await 식에 도달할 때 실행을 생성하고 백그라운드 작업이 완료되면 실행을 다시 시작하는 등 코드의 다양한 작업 및 상태를 추적합니다.

컴퓨터 과학 이론의 관점에서 비동기 프로그래밍은 비동기 Promise 모델의 구현입니다.

비동기 프로그래밍 모델에는 다음 몇 가지 주요 개념을 이해해야 합니다.

  • I/O 바인딩 코드와 CPU 바인딩된 코드 모두에 비동기 코드를 사용할 수 있지만 구현은 다릅니다.
  • 비동기 코드는 백그라운드에서 실행되는 작업을 모델링하기 위한 구문으로 Task<T>Task 개체를 사용합니다.
  • async 키워드는 메서드를 비동기 메서드로 선언하므로 메서드 본문에서 await 키워드를 사용할 수 있습니다.
  • await 키워드를 적용하면 코드는 호출 메서드를 일시 중단하고 작업이 완료될 때까지 해당 호출자에게 컨트롤을 다시 생성합니다.
  • 비동기 메서드에서만 await 식을 사용할 수 있습니다.

I/O 바인딩된 예제: 웹 서비스에서 데이터 다운로드

이 예제에서는 사용자가 단추를 선택하면 앱이 웹 서비스에서 데이터를 다운로드합니다. 다운로드 프로세스 중에 앱에 대한 UI 스레드를 차단하지 않으려는 경우 다음 코드는 이 작업을 수행합니다.

s_downloadButton.Clicked += async (o, e) =>
{
    // This line will yield control to the UI as the request
    // from the web service is happening.
    //
    // The UI thread is now free to perform other work.
    var stringData = await s_httpClient.GetStringAsync(URL);
    DoSomethingWithData(stringData);
};

이 코드는 Task 개체 조작 시 위험에 빠지지 않고 의도(데이터를 비동기식으로 다운로드)를 표현합니다.

CPU 바인딩 예제: 게임 계산 실행

다음 예제에서 모바일 게임은 단추 이벤트에 대한 응답으로 화면의 여러 에이전트에 손상을 입힙니다. 피해 계산을 수행하면 비용이 많이 들 수 있습니다. UI 스레드에서 계산을 실행하면 계산 중에 표시 및 UI 상호 작용 문제가 발생할 수 있습니다.

작업을 처리하는 가장 좋은 방법은 백그라운드 스레드를 시작하여 Task.Run 메서드로 작업을 완료하는 것입니다. 이 연산은 await 식을 사용하여 산출됩니다. 작업이 완료되면 작업이 다시 시작됩니다. 이 방법을 사용하면 백그라운드에서 작업이 완료되는 동안 UI를 원활하게 실행할 수 있습니다.

static DamageResult CalculateDamageDone()
{
    return new DamageResult()
    {
        // Code omitted:
        //
        // Does an expensive calculation and returns
        // the result of that calculation.
    };
}

s_calculateButton.Clicked += async (o, e) =>
{
    // This line will yield control to the UI while CalculateDamageDone()
    // performs its work. The UI thread is free to perform other work.
    var damageResult = await Task.Run(() => CalculateDamageDone());
    DisplayDamage(damageResult);
};

코드는 단추 Clicked 이벤트의 의도를 명확하게 표현합니다. 백그라운드 스레드를 수동으로 관리할 필요가 없으며 비블로킹 방식으로 작업을 완료합니다.

CPU 바인딩 및 I/O 바인딩된 시나리오 인식

이전 예제에서는 I/O 바인딩 및 CPU 바인딩된 작업에 async 한정자 및 await 식을 사용하는 방법을 보여 줍니다. 각 시나리오에 대한 예제에서는 작업이 바인딩된 위치에 따라 코드가 어떻게 다른지 보여 줍니다. 구현을 준비하려면 작업이 I/O 바인딩 또는 CPU 바인딩된 시기를 식별하는 방법을 이해해야 합니다. 구현 선택은 코드의 성능에 큰 영향을 줄 수 있으며 잠재적으로 구문을 잘못 사용할 수 있습니다.

코드를 작성하기 전에 해결해야 할 두 가지 주요 질문이 있습니다.

질문 시나리오 이행
코드에서 데이터베이스의 데이터와 같은 결과 또는 작업을 기다려야 하나요? I/O에 의해 제한된 async 메서드를 Task.Run 사용합니다.

작업 병렬 라이브러리를 사용하지 않습니다.
코드에서 비용이 많이 드는 계산을 실행해야 하나요? CPU 바인딩된 async 한정자와 await 식을 사용하지만 Task.Run 메서드를 사용하여 다른 스레드에서 작업을 생성합니다. 이 방법은 CPU 응답성과 관련된 문제를 해결합니다.

작업이 동시성 및 병렬 처리에 해당할 경우 작업 병렬 라이브러리를 사용할 것을 고려할 수도 있습니다.

항상 코드 실행을 측정합니다. 멀티스레딩 시 컨텍스트 스위치의 오버헤드에 비해 CPU에 묶인 작업의 비용이 충분히 크지 않다는 것을 알게 될 수 있습니다. 모든 선택에는 절충이 있습니다. 상황에 맞는 올바른 절충을 선택합니다.

다른 예제 살펴보기

이 섹션의 예제에서는 C#에서 비동기 코드를 작성할 수 있는 여러 가지 방법을 보여 줍니다. 발생할 수 있는 몇 가지 시나리오를 다룹니다.

네트워크에서 데이터 추출

다음 코드는 지정된 URL에서 HTML을 다운로드하고 HTML에서 문자열 ".NET"이 발생하는 횟수를 계산합니다. 이 코드는 ASP.NET 사용하여 작업을 수행하고 개수를 반환하는 Web API 컨트롤러 메서드를 정의합니다.

참고

프로덕션 코드에서 HTML 구문 분석을 수행하려는 경우 정규식을 사용하지 마세요. 대신 구문 분석 라이브러리를 사용하세요.

[HttpGet, Route("DotNetCount")]
static public async Task<int> GetDotNetCount(string URL)
{
    // Suspends GetDotNetCount() to allow the caller (the web server)
    // to accept another request, rather than blocking on this one.
    var html = await s_httpClient.GetStringAsync(URL);
    return Regex.Matches(html, @"\.NET").Count;
}

유니버설 Windows 앱에 대해 비슷한 코드를 작성하고 단추를 누른 후 계산 작업을 수행할 수 있습니다.

private readonly HttpClient _httpClient = new HttpClient();

private async void OnSeeTheDotNetsButtonClick(object sender, RoutedEventArgs e)
{
    // Capture the task handle here so we can await the background task later.
    var getDotNetFoundationHtmlTask = _httpClient.GetStringAsync("https://dotnetfoundation.org");

    // Any other work on the UI thread can be done here, such as enabling a Progress Bar.
    // It's important to do the extra work here before the "await" call,
    // so the user sees the progress bar before execution of this method is yielded.
    NetworkProgressBar.IsEnabled = true;
    NetworkProgressBar.Visibility = Visibility.Visible;

    // The await operator suspends OnSeeTheDotNetsButtonClick(), returning control to its caller.
    // This action is what allows the app to be responsive and not block the UI thread.
    var html = await getDotNetFoundationHtmlTask;
    int count = Regex.Matches(html, @"\.NET").Count;

    DotNetCountLabel.Text = $"Number of .NETs on dotnetfoundation.org: {count}";

    NetworkProgressBar.IsEnabled = false;
    NetworkProgressBar.Visibility = Visibility.Collapsed;
}

여러 작업이 완료될 때까지 대기

일부 시나리오에서는 코드가 여러 데이터 조각을 동시에 검색해야 합니다. Task API는 여러 백그라운드 작업에서 차단 해제 대기를 수행하는 비동기 코드를 작성할 수 있는 메서드를 제공합니다.

다음 예제에서는 User 개체 집합에 대한 userId 개체 데이터를 가져오는 방법을 보여 있습니다.

private static async Task<User> GetUserAsync(int userId)
{
    // Code omitted:
    //
    // Given a user Id {userId}, retrieves a User object corresponding
    // to the entry in the database with {userId} as its Id.

    return await Task.FromResult(new User() { id = userId });
}

private static async Task<IEnumerable<User>> GetUsersAsync(IEnumerable<int> userIds)
{
    var getUserTasks = new List<Task<User>>();
    foreach (int userId in userIds)
    {
        getUserTasks.Add(GetUserAsync(userId));
    }

    return await Task.WhenAll(getUserTasks);
}

LINQ를 사용하여 이 코드를 보다 간결하게 작성할 수 있습니다.

private static async Task<User[]> GetUsersAsyncByLINQ(IEnumerable<int> userIds)
{
    var getUserTasks = userIds.Select(id => GetUserAsync(id)).ToArray();
    return await Task.WhenAll(getUserTasks);
}

LINQ를 사용하여 코드를 적게 작성하지만 LINQ를 비동기 코드와 혼합할 때는 주의해야 합니다. LINQ는 지연(또는 느린) 실행을 사용합니다. 생성된 시퀀스가 foreach 또는 .ToList() 메서드를 호출하여 반복하도록 강제하지 않는 한 비동기 호출은 .ToArray() 루프에서와 마찬가지로 즉시 발생하지 않습니다. 이 예제에서는 Enumerable.ToArray 메서드를 사용하여 쿼리를 열심히 수행하고 결과를 배열에 저장합니다. 이 방법은 id => GetUserAsync(id) 문을 강제로 실행하고 작업을 시작합니다.

비동기 프로그래밍에 대한 고려 사항 검토

비동기 프로그래밍을 사용하면 예기치 않은 동작을 방지할 수 있는 몇 가지 세부 정보를 염두에 두어야 합니다.

async() 메서드 본문 내에서 await 사용

async 한정자를 사용하는 경우 메서드 본문에 하나 이상의 await 식을 포함해야 합니다. 컴파일러가 await 식을 발견하지 못하면 메서드가 결과를 반환하지 않습니다. 컴파일러에서 경고를 생성하지만 코드는 여전히 컴파일되고 컴파일러는 메서드를 실행합니다. 비동기 메서드에 대해 C# 컴파일러에서 생성된 상태 컴퓨터는 아무 작업도 수행하지 않으므로 전체 프로세스는 매우 비효율적입니다.

비동기 메서드 이름에 "Async" 접미사 추가

.NET 스타일 규칙은 모든 비동기 메서드 이름에 "Async" 접미사를 추가하는 것입니다. 이 방법을 사용하면 동기 메서드와 비동기 메서드를 보다 쉽게 구분할 수 있습니다. 코드에서 명시적으로 호출되지 않는 특정 메서드(예: 이벤트 처리기 또는 웹 컨트롤러 메서드)는 이 시나리오에서 반드시 적용되지는 않습니다. 이러한 항목은 코드에서 명시적으로 호출되지 않으므로 명시적 이름을 사용하는 것은 중요하지 않습니다.

이벤트 처리기에서만 'async void'를 반환합니다.

이벤트 처리기는 void 반환 형식을 선언해야 하며 다른 메서드와 마찬가지로 TaskTask<T> 개체를 사용하거나 반환할 수 없습니다. 비동기 이벤트 처리기를 작성하는 경우 처리기에 대한 async 반환 메서드에서 void 한정자를 사용해야 합니다. async void 반환 메서드의 다른 구현은 TAP 모델을 따르지 않으며 다음과 같은 문제를 제시할 수 있습니다.

  • async void 메서드에서 던진 예외는 그 메서드 외부에서 잡을 수 없습니다.
  • async void 메서드는 테스트하기 어렵습니다.
  • async void 메서드는 호출자가 비동기 메서드를 기대하지 않는 경우 부정적인 부작용을 일으킬 수 있습니다.

LINQ에서 비동기 람다에 주의 사용

LINQ 식에서 비동기 람다를 구현할 때는 주의해야 합니다. LINQ의 람다 식은 지연된 실행을 사용합니다. 즉, 예기치 않은 시간에 코드를 실행할 수 있습니다. 이 시나리오에 차단 태스크가 도입되면 코드가 올바르게 작성되지 않으면 교착 상태가 쉽게 발생할 수 있습니다. 또한 비동기 코드를 중첩하면 코드 실행을 추론하기가 어려울 수 있습니다. 비동기 및 LINQ는 강력하지만 이러한 기술은 가능한 한 신중하고 명확하게 함께 사용해야 합니다.

비블로킹 방식으로 작업을 양보하다

프로그램에 태스크 결과가 필요한 경우 차단 해제 방식으로 await 식을 구현하는 코드를 작성합니다. Task 항목이 완료될 때까지 동기적으로 대기하는 수단으로 현재 스레드를 차단하면 교착 상태와 차단된 컨텍스트 스레드가 발생할 수 있습니다. 이 프로그래밍 방법을 사용하려면 더 복잡한 오류 처리가 필요할 수 있습니다. 다음 표에서는 비블로킹 방식으로 작업에서 액세스 결과를 가져오는 방법에 대한 지침을 제공합니다.

작업 시나리오 현재 코드 'await'로 바꾸기
백그라운드 작업 결과를 검색합니다. Task.Wait 또는 Task.Result await
작업이 완료되면 계속 Task.WaitAny await Task.WhenAny
계속 모든 작업이 완료될 때 Task.WaitAll await Task.WhenAll
일정 시간 후 계속하기 Thread.Sleep await Task.Delay

ValueTask 형식 사용 고려

비동기 메서드가 Task 개체를 반환하면 특정 경로에 성능 병목 현상이 발생할 수 있습니다. Task 참조 형식이므로 힙에서 Task 개체가 할당됩니다. async 한정자를 사용하여 선언된 메서드가 캐시된 결과를 반환하거나 동기적으로 완료하는 경우 추가 할당은 성능에 중요한 코드 섹션에서 상당한 시간 비용을 발생시킬 수 있습니다. 이 시나리오는 엄격한 루프에서 할당이 발생할 때 비용이 많이 들 수 있습니다. 자세한 내용은 일반화된 비동기 반환 형식을 참조하세요.

ConfigureAwait(false)를 설정하는 시기 이해

개발자들은 종종 Task.ConfigureAwait(Boolean) 부울을 언제 사용해야 하는지 문의합니다. 이 API를 사용하면 Task 인스턴스가 await 식을 구현하는 상태 컴퓨터에 대한 컨텍스트를 구성할 수 있습니다. 부울 값이 올바르게 설정되지 않으면 성능이 저하되거나 교착 상태가 발생할 수 있습니다. 자세한 내용은 ConfigureAwait FAQ참조하세요.

상태 의존성이 적은 코드 작성

전역 개체의 상태 또는 특정 메서드의 실행에 따라 달라지는 코드를 작성하지 마세요. 대신, 메서드의 반환 값에만 의존합니다. 상태 저장이 적은 코드를 작성할 때는 다음과 같은 많은 이점이 있습니다.

  • 코드에 대한 추론이 더 쉽습니다.
  • 더 쉽게 코드를 테스트할 수 있습니다.
  • 비동기 및 동기 코드를 혼합하는 것이 더 간단합니다.
  • 코드에서 경합 상태를 방지할 수 있습니다.
  • 반환 값에 따라 비동기 코드 조정이 간단합니다.
  • (보너스) 코드에서 의존성 주입이 잘 작동합니다.

권장되는 목적은 코드에서 완전하거나 거의 완전한 참조 투명성을 달성하는 것입니다. 이 방법을 사용하면 예측 가능하고 테스트 가능하며 유지 관리 가능한 코드베이스가 생성됩니다.

전체 예제 검토

다음 코드는 Program.cs 예제 파일에서 사용할 수 있는 전체 예제를 나타냅니다.

using System.Text.RegularExpressions;
using System.Windows;
using Microsoft.AspNetCore.Mvc;

class Button
{
    public Func<object, object, Task>? Clicked
    {
        get;
        internal set;
    }
}

class DamageResult
{
    public int Damage
    {
        get { return 0; }
    }
}

class User
{
    public bool isEnabled
    {
        get;
        set;
    }

    public int id
    {
        get;
        set;
    }
}

public class Program
{
    private static readonly Button s_downloadButton = new();
    private static readonly Button s_calculateButton = new();

    private static readonly HttpClient s_httpClient = new();

    private static readonly IEnumerable<string> s_urlList = new string[]
    {
            "https://learn.microsoft.com",
            "https://learn.microsoft.com/aspnet/core",
            "https://learn.microsoft.com/azure",
            "https://learn.microsoft.com/azure/devops",
            "https://learn.microsoft.com/dotnet",
            "https://learn.microsoft.com/dotnet/desktop/wpf/get-started/create-app-visual-studio",
            "https://learn.microsoft.com/education",
            "https://learn.microsoft.com/shows/net-core-101/what-is-net",
            "https://learn.microsoft.com/enterprise-mobility-security",
            "https://learn.microsoft.com/gaming",
            "https://learn.microsoft.com/graph",
            "https://learn.microsoft.com/microsoft-365",
            "https://learn.microsoft.com/office",
            "https://learn.microsoft.com/powershell",
            "https://learn.microsoft.com/sql",
            "https://learn.microsoft.com/surface",
            "https://dotnetfoundation.org",
            "https://learn.microsoft.com/visualstudio",
            "https://learn.microsoft.com/windows",
            "https://learn.microsoft.com/maui"
    };

    private static void Calculate()
    {
        // <PerformGameCalculation>
        static DamageResult CalculateDamageDone()
        {
            return new DamageResult()
            {
                // Code omitted:
                //
                // Does an expensive calculation and returns
                // the result of that calculation.
            };
        }

        s_calculateButton.Clicked += async (o, e) =>
        {
            // This line will yield control to the UI while CalculateDamageDone()
            // performs its work. The UI thread is free to perform other work.
            var damageResult = await Task.Run(() => CalculateDamageDone());
            DisplayDamage(damageResult);
        };
        // </PerformGameCalculation>
    }

    private static void DisplayDamage(DamageResult damage)
    {
        Console.WriteLine(damage.Damage);
    }

    private static void Download(string URL)
    {
        // <UnblockingDownload>
        s_downloadButton.Clicked += async (o, e) =>
        {
            // This line will yield control to the UI as the request
            // from the web service is happening.
            //
            // The UI thread is now free to perform other work.
            var stringData = await s_httpClient.GetStringAsync(URL);
            DoSomethingWithData(stringData);
        };
        // </UnblockingDownload>
    }

    private static void DoSomethingWithData(object stringData)
    {
        Console.WriteLine($"Displaying data: {stringData}");
    }

    // <GetUsersForDataset>
    private static async Task<User> GetUserAsync(int userId)
    {
        // Code omitted:
        //
        // Given a user Id {userId}, retrieves a User object corresponding
        // to the entry in the database with {userId} as its Id.

        return await Task.FromResult(new User() { id = userId });
    }

    private static async Task<IEnumerable<User>> GetUsersAsync(IEnumerable<int> userIds)
    {
        var getUserTasks = new List<Task<User>>();
        foreach (int userId in userIds)
        {
            getUserTasks.Add(GetUserAsync(userId));
        }

        return await Task.WhenAll(getUserTasks);
    }
    // </GetUsersForDataset>

    // <GetUsersForDatasetByLINQ>
    private static async Task<User[]> GetUsersAsyncByLINQ(IEnumerable<int> userIds)
    {
        var getUserTasks = userIds.Select(id => GetUserAsync(id)).ToArray();
        return await Task.WhenAll(getUserTasks);
    }
    // </GetUsersForDatasetByLINQ>

    // <ExtractDataFromNetwork>
    [HttpGet, Route("DotNetCount")]
    static public async Task<int> GetDotNetCount(string URL)
    {
        // Suspends GetDotNetCount() to allow the caller (the web server)
        // to accept another request, rather than blocking on this one.
        var html = await s_httpClient.GetStringAsync(URL);
        return Regex.Matches(html, @"\.NET").Count;
    }
    // </ExtractDataFromNetwork>

    static async Task Main()
    {
        Console.WriteLine("Application started.");

        Console.WriteLine("Counting '.NET' phrase in websites...");
        int total = 0;
        foreach (string url in s_urlList)
        {
            var result = await GetDotNetCount(url);
            Console.WriteLine($"{url}: {result}");
            total += result;
        }
        Console.WriteLine("Total: " + total);

        Console.WriteLine("Retrieving User objects with list of IDs...");
        IEnumerable<int> ids = new int[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 0 };
        var users = await GetUsersAsync(ids);
        foreach (User? user in users)
        {
            Console.WriteLine($"{user.id}: isEnabled={user.isEnabled}");
        }

        Console.WriteLine("Application ending.");
    }
}

// Example output:
//
// Application started.
// Counting '.NET' phrase in websites...
// https://learn.microsoft.com: 0
// https://learn.microsoft.com/aspnet/core: 57
// https://learn.microsoft.com/azure: 1
// https://learn.microsoft.com/azure/devops: 2
// https://learn.microsoft.com/dotnet: 83
// https://learn.microsoft.com/dotnet/desktop/wpf/get-started/create-app-visual-studio: 31
// https://learn.microsoft.com/education: 0
// https://learn.microsoft.com/shows/net-core-101/what-is-net: 42
// https://learn.microsoft.com/enterprise-mobility-security: 0
// https://learn.microsoft.com/gaming: 0
// https://learn.microsoft.com/graph: 0
// https://learn.microsoft.com/microsoft-365: 0
// https://learn.microsoft.com/office: 0
// https://learn.microsoft.com/powershell: 0
// https://learn.microsoft.com/sql: 0
// https://learn.microsoft.com/surface: 0
// https://dotnetfoundation.org: 16
// https://learn.microsoft.com/visualstudio: 0
// https://learn.microsoft.com/windows: 0
// https://learn.microsoft.com/maui: 6
// Total: 238
// Retrieving User objects with list of IDs...
// 1: isEnabled= False
// 2: isEnabled= False
// 3: isEnabled= False
// 4: isEnabled= False
// 5: isEnabled= False
// 6: isEnabled= False
// 7: isEnabled= False
// 8: isEnabled= False
// 9: isEnabled= False
// 0: isEnabled= False
// Application ending.