동기 I/O 안티패턴

I/O가 완료되는 동안 호출 스레드를 차단하면 성능이 저하되고 수직 확장성에 영향을 줄 수 있습니다.

문제 설명

동기 I/O 작업은 I/O가 완료하는 동안 호출 스레드를 차단합니다. 호출 스레드는 대기 상태가 되며 이 간격 동안 유용한 작업을 수행할 수 없으므로 처리 리소스를 낭비합니다.

I/O의 일반적인 예는 다음과 같습니다.

  • 데이터 검색 또는 데이터베이스나 임의 유형의 영구 스토리지에 데이터 유지.
  • 웹 서비스에 요청을 보냅니다.
  • 메시지를 게시하거나 큐에서 메시지를 검색합니다.
  • 로컬 파일에 쓰거나 로컬 파일에서 읽습니다.

이런 안티패턴이 발생하는 일반적인 이유는 다음과 같습니다.

  • 작업을 수행하는 가장 직관적인 방법으로 보입니다.
  • 애플리케이션이 요청에서 응답을 요구합니다.
  • 애플리케이션은 I/O에 대한 동기 메서드만 제공하는 라이브러리를 사용합니다.
  • 외부 라이브러리는 내부적으로 동기 I/O 작업을 수행합니다. 단일 동기 I/O 호출은 전체 호출 체인을 차단할 수 있습니다.

다음 코드는 Azure Blob Storage에 파일을 업로드합니다. 코드가 동기 I/O, CreateIfNotExists 메서드 및 메서드를 대기하는 두 가지 위치가 UploadFromStream 있습니다.

var blobClient = storageAccount.CreateCloudBlobClient();
var container = blobClient.GetContainerReference("uploadedfiles");

container.CreateIfNotExists();
var blockBlob = container.GetBlockBlobReference("myblob");

// Create or overwrite the "myblob" blob with contents from a local file.
using (var fileStream = File.OpenRead(HostingEnvironment.MapPath("~/FileToUpload.txt")))
{
    blockBlob.UploadFromStream(fileStream);
}

다음은 외부 서비스에서의 응답을 위해 대기하는 예제입니다. 메서드는 GetUserProfile .를 반환하는 원격 서비스를 호출합니다 UserProfile.

public interface IUserProfileService
{
    UserProfile GetUserProfile();
}

public class SyncController : ApiController
{
    private readonly IUserProfileService _userProfileService;

    public SyncController()
    {
        _userProfileService = new FakeUserProfileService();
    }

    // This is a synchronous method that calls the synchronous GetUserProfile method.
    public UserProfile GetUserProfile()
    {
        return _userProfileService.GetUserProfile();
    }
}

이 두 예제에 대한 전체 코드는 여기에서 찾을 수 있습니다.

문제를 해결하는 방법

동기 I/O 작업을 비동기 작업으로 대체합니다. 이렇게 하면 현재 스레드가 차단이 아닌 의미 있는 작업을 계속 수행할 수 있으며 컴퓨팅 리소스의 사용률을 개선하는 데 도움이 됩니다. I/O를 비동기로 수행하는 방법은 클라이언트 애플리케이션에서의 예기치 않은 요청 급증을 처리할 때 특히 효율적입니다.

많은 라이브러리는 동기 및 비동기 버전의 메서드를 모두 제공합니다. 가능하면 비동기 버전을 사용합니다. 다음은 Azure Blob Storage에 파일을 업로드하는 이전 예제의 비동기 버전입니다.

var blobClient = storageAccount.CreateCloudBlobClient();
var container = blobClient.GetContainerReference("uploadedfiles");

await container.CreateIfNotExistsAsync();

var blockBlob = container.GetBlockBlobReference("myblob");

// Create or overwrite the "myblob" blob with contents from a local file.
using (var fileStream = File.OpenRead(HostingEnvironment.MapPath("~/FileToUpload.txt")))
{
    await blockBlob.UploadFromStreamAsync(fileStream);
}

await 연산자는 비동기 작업이 수행되는 동안 제어권을 호출 환경에 반환합니다. 이 문 뒤의 코드는 비동기 작업이 완료될 때 실행되는 연속으로 작동합니다.

잘 설계된 서비스는 비동기 작업도 제공해야 합니다. 다음은 사용자 프로필을 반환하는 웹 서비스의 비동기 버전입니다. 이 메서드는 GetUserProfileAsync 사용자 프로필 서비스의 비동기 버전에 따라 달라집니다.

public interface IUserProfileService
{
    Task<UserProfile> GetUserProfileAsync();
}

public class AsyncController : ApiController
{
    private readonly IUserProfileService _userProfileService;

    public AsyncController()
    {
        _userProfileService = new FakeUserProfileService();
    }

    // This is a synchronous method that calls the Task based GetUserProfileAsync method.
    public Task<UserProfile> GetUserProfileAsync()
    {
        return _userProfileService.GetUserProfileAsync();
    }
}

비동기 버전의 작업을 제공하지 않는 라이브러리의 경우 선택한 동기 메서드를 중심으로 비동기 래퍼를 만들 수 있습니다. 이 방법을 주의해서 따르세요. 이 접근법은 비동기 래퍼를 호출하는 스레드 응답성을 개선할 수 있지만 실제로는 더 많은 리소스를 사용합니다. 추가 스레드를 만들 수 있으며 이 스레드에서 수행한 작업을 동기화하는 것과 관련된 오버헤드가 있습니다. 이 블로그 게시물 에서 몇 가지 장단점이 설명되어 있습니다. 동기 메서드에 대한 비동기 래퍼를 노출해야 하나요?

다음은 동기 메서드에 대한 비동기 래퍼의 예입니다.

// Asynchronous wrapper around synchronous library method
private async Task<int> LibraryIOOperationAsync()
{
    return await Task.Run(() => LibraryIOOperation());
}

이제 호출 코드가 래퍼에서 대기할 수 있습니다.

// Invoke the asynchronous wrapper using a task
await LibraryIOOperationAsync();

고려 사항

  • 수명이 매우 짧을 것으로 예상되고 경합을 일으킬 가능성이 낮은 I/O 작업은 동기 작업으로 더 성능이 높아질 수 있습니다. 예를 들어 SSD 드라이브에서 작은 파일을 읽을 수 있습니다. 작업을 다른 스레드로 디스패치하고 작업이 완료되면 해당 스레드와 동기화하는 오버헤드가 비동기 I/O의 이점보다 클 수 있습니다. 그러나 이러한 경우는 비교적 드물며 대부분의 I/O 작업은 비동기로 수행해야 합니다.

  • I/O 성능이 개선되면 시스템의 다른 부분에 병목이 발생할 수 있습니다. 예를 들어 스레드를 차단 해제하면 동시 공유 리소스 요청이 더 많아지고 그에 따라 리소스 부족 또는 제한이 발생할 수 있습니다. 이러한 상황이 문제가 된다면 경합을 줄이기 위해 웹 서버 수의 규모를 확장하거나 데이터 저장소를 분할해야 할 수 있습니다.

문제를 감지하는 방법

사용자 입장에서는 애플리케이션이 주기적으로 응답하지 않는 것으로 보일 수 있습니다. 시간 제한 예외로 애플리케이션이 실패할 수 있습니다. 이러한 장애는 HTTP 500(내부 서버) 오류를 반환할 수도 있습니다. 서버 쪽에서는 들어오는 클라이언트 요청이 스레드가 사용할 수 있게 될 때까지 차단되어 요청 큐 길이가 너무 커질 수 있으며 이는 HTTP 503(서비스를 사용할 수 없음) 오류로 명시됩니다.

다음 단계를 수행하여 문제를 식별할 수 있습니다.

  1. 프로덕션 시스템을 모니터링하고 차단된 작업자 스레드가 처리량을 제한하는지 여부를 확인합니다.

  2. 스레드 부족으로 인해 요청이 차단되는 경우 애플리케이션을 검토하여 I/O를 동기적으로 수행할 수 있는 작업을 확인합니다.

  3. 동기 I/O를 수행하는 각 작업의 제어된 부하 테스트를 수행하여 해당 작업이 시스템 성능에 영향을 미치는지 확인합니다.

예제 진단

다음 섹션에서는 이러한 단계를 앞에서 설명한 애플리케이션 예제에 적용합니다.

웹 서버 성능 모니터링

Azure 웹 애플리케이션 및 웹 역할의 경우 IIS 웹 서버의 성능을 모니터링할 가치가 있습니다. 특히 요청 큐 길이에 주의를 기울여 작업량이 많은 기간 동안 요청이 차단되어 사용할 수 있는 스레드를 위해 대기하는지 여부를 확인해야 합니다. Azure 진단 사용하도록 설정하여 이 정보를 수집할 수 있습니다. 자세한 내용은 다음을 참조하세요.

애플리케이션을 계측하여 요청이 수락된 후 처리되는 방법을 확인합니다. 요청 흐름을 추적하면 느리게 실행되는 호출을 수행하고 현재 스레드를 차단하는지 여부를 식별하는 데 도움이 될 수 있습니다. 스레드 프로파일링은 차단 중인 요청을 강조 표시할 수도 있습니다.

애플리케이션 부하 테스트

다음 그래프는 최대 4,000명의 동시 사용자의 다양한 부하에서 이전에 표시된 동기 GetUserProfile 메서드의 성능을 보여 줍니다. 애플리케이션은 Azure Cloud Service 웹 역할에서 실행 중인 ASP.NET 애플리케이션입니다.

Performance chart for the sample application performing synchronous I/O operations

동기 작업은 2초 동안 휴면하고 동기 I/O를 시뮬레이션하도록 하드 코딩되었으므로 최소 응답 시간은 2초를 조금 넘습니다. 부하가 약 2,500명의 동시 사용자에게 도달하면 평균 응답 시간이 고원에 도달하지만 초당 요청 볼륨은 계속 증가합니다. 이 두 측정값의 배율은 로그입니다. 초당 요청 수는 이 시점과 테스트 종료 시점 간에 두 배가 됩니다.

격리에서 이 테스트에서 동기 I/O가 문제인지 여부가 반드시 명확하지는 않습니다. 부하가 더 많은 애플리케이션은 웹 서버가 더 이상 적시에 요청을 처리할 수 없는 티핑 포인트에 도달하여 클라이언트 애플리케이션이 시간 제한 예외를 수신하도록 할 수 있습니다.

들어오는 요청은 IIS 웹 서버에서 큐에 대기하고 ASP.NET 스레드 풀에서 실행되는 스레드에 전달됩니다. 각 작업이 I/O를 동기식으로 수행하므로 스레드는 작업이 완료될 때까지 차단됩니다. 워크로드가 증가함에 따라 결국 스레드 풀의 모든 ASP.NET 스레드가 할당되고 차단됩니다. 그 시점에서 추가로 들어오는 요청은 사용할 수 있는 스레드를 위해 큐에서 대기해야 합니다. 큐 길이가 늘어나면 요청 시간이 초과하기 시작합니다.

솔루션 구현 및 결과 확인

다음 그래프는 코드의 비동기 버전을 부하 테스트한 결과를 보여줍니다.

Performance chart for the sample application performing asynchronous I/O operations

처리량이 훨씬 높습니다. 이전 테스트와 동일한 기간 동안 시스템은 초당 요청 수로 측정된 처리량의 거의 10배 증가를 성공적으로 처리합니다. 또한 평균 응답 시간이 비교적 일정하며 이전 테스트보다 약 25배 더 작게 유지됩니다.