Devin.KR

Visual Basic · 심화

LINQ·비동기·설계로 깊어지는 Visual Basic

동시 작업 다루기 - 취소·시간 제한·병렬

CancellationToken, Task.WhenAll·WhenAny, 시간 제한, Parallel.For 와 공유 상태, SyncLock

개발자KR · 원고 갱신

이 장에서 배우는 것

앞 장에서 비동기 메서드를 호출하고 Await로 결과를 기다리는 방법을 살펴보았다. 이제 도서관 서비스가 여러 작업을 함께 시작했을 때 그 작업들을 어떻게 정리할지 다룬다. 대출 기록을 조회하는 동안 사용자가 화면을 닫을 수 있고, 예약 확인이 정해진 시간 안에 끝나지 않을 수도 있다. 연체료를 여러 작업에서 계산한다면 같은 합계 변수에 값을 더하는 방식도 다시 살펴봐야 한다.

여러 작업을 시작하는 일보다 중요한 것은 각 작업의 종료를 확인하는 일이다. 이 장에서는 취소 요청과 실제 종료를 구분하고, 작업을 모아서 기다리며, 시간 제한 뒤에 남는 작업까지 관찰하는 흐름을 만든다. 계산 결과와 출력 순서를 분리해 실행할 때마다 같은 결과를 얻는다.

  • CancellationToken으로 취소 요청을 전달하고 작업이 그 요청에 응답하게 한다.
  • Task.WhenAll과 Task.WhenAny가 각각 무엇을 기다리는지 구분한다.
  • 시간 제한을 넘긴 작업에 취소를 요청하고 종료까지 확인한다.
  • Parallel.For에서 공유 상태를 다룰 때 SyncLock이 필요한 이유를 설명한다.
  • 콘솔 검사 함수로 결과와 작업 정리 상태를 확인한다.

문제 상황

작은 동네 도서관에서는 하루가 끝날 때 대출 세 건의 연체료를 계산한다. 연체 일수는 각각 3일, 0일, 2일이며 하루 연체료는 300원이다. 한 건씩 순서대로 조회하면 앞선 조회가 끝나야 다음 조회를 시작한다. 서로 의존하지 않는 조회라면 함께 시작한 뒤 결과를 모을 수 있다.

예약 확인에는 다른 문제가 있다. 확인 요청을 보냈는데 응답이 계속 오지 않으면 이용자는 화면을 닫는다. 서비스는 취소 요청을 작업에 전달해야 한다. 또 사용자가 기다리더라도 서비스가 정한 시간 제한을 넘기면 기다림을 마무리해야 한다. 이때 화면의 기다림을 끝내는 것과 이미 시작한 작업을 끝내는 것은 서로 다른 일이다.

마지막으로 대출 건수가 늘어나면 계산을 여러 실행 흐름에 나누고 싶어진다. 모든 실행 흐름이 합계 변수에 연체료를 더하면 예상한 1,500원이 나오지 않을 수 있다. 덧셈 문장 하나도 내부적으로는 값을 읽고, 계산하고, 다시 저장하는 단계로 이루어지기 때문이다.

완성 프로그램은 이 세 상황을 작은 예제로 분리한다. 비동기 조회는 결과를 입력 순서로 모으고, 취소와 시간 제한은 끝나지 않는 대기를 중단하는 방식으로 확인한다. 병렬 계산에서는 배열의 개별 칸에 결과를 저장하고 공유 합계만 보호한다. 실제 네트워크나 현재 시각에 의존하지 않으므로 출력은 고정된다.

취소 요청과 작업 종료

취소는 협력적 취소(cooperative cancellation)로 이해해야 한다. 호출자가 요청을 보내면 작업이 그 요청을 확인하고 스스로 종료한다. CancellationTokenSource는 요청을 보내는 쪽에서 보관하고, CancellationToken은 요청을 확인하는 작업에 전달한다. 토큰은 작업을 강제로 중단하는 스위치가 아니다.

CancellationTokenSource.Cancel을 호출하면 연결된 토큰의 IsCancellationRequested가 True가 된다. 그러나 작업이 토큰을 확인하지 않거나 토큰을 지원하는 대기 메서드에 전달하지 않으면 작업은 계속 실행될 수 있다. 반복 계산에서는 적절한 간격으로 ThrowIfCancellationRequested를 호출하고, 비동기 대기에서는 해당 메서드의 토큰 매개변수에 전달한다.

ThrowIfCancellationRequested는 요청이 없으면 그대로 진행한다. 요청이 있으면 OperationCanceledException을 발생시킨다. 취소 가능한 Task.Delay도 취소를 감지하면 대기를 끝내고 취소를 나타내는 예외를 전달한다. 작업의 취소는 정상 결과를 반환한 성공과 구분된다.

취소 요청을 보낸 뒤에는 작업이 토큰을 확인하고 종료할 때까지 기다려야 한다

취소 요청을 보냈다고 바로 CancellationTokenSource를 정리하거나 작업 참조를 버리지 않는다. 해당 작업을 Await하여 종료를 확인해야 예외와 자원 정리가 어디에서 끝나는지 알 수 있다. 취소를 예상하는 경계에서 OperationCanceledException을 처리하고, 다른 오류까지 같은 것으로 취급하지 않는다.

완성 코드에서는 작업이 대기에 들어갈 준비를 마쳤다는 사실을 TaskCompletionSource로 알린다. 호출자는 준비 신호를 기다린 뒤 Cancel을 호출한다. 이렇게 하면 일정 시간 동안 잠들었다가 취소하는 방법에 비해 작업 시작 여부를 분명하게 확인할 수 있다. 준비 신호는 작업의 완료를 뜻하지 않으며, 실제 작업의 종료는 별도의 Task를 Await하여 확인한다.

CancellationTokenSource는 Using으로 범위를 정한다. Dispose는 취소 요청을 보내는 메서드가 아니므로 필요한 경우 먼저 Cancel을 호출한다. Using을 벗어나기 전에 연결된 작업을 기다리는 구조를 만들면 요청과 정리의 책임을 한곳에서 읽을 수 있다.

취소 관련 객체와 호출이 맡는 역할
이름역할주의할 점
CancellationTokenSource취소 요청의 소유자연결된 작업을 정리한 뒤 Dispose한다.
CancellationToken작업에 전달하는 요청 확인 수단전달만 하고 사용하지 않으면 작업이 멈추지 않는다.
Cancel취소를 요청한다.작업 종료까지 기다리지 않는다.
Await작업의 종료와 결과를 관찰한다.취소 또는 실패가 예외로 전달될 수 있다.

여러 작업을 기다리고 시간 제한을 정하기

모든 결과가 필요한 경우

Task.WhenAll은 전달받은 모든 작업이 종료될 때 완료되는 작업을 반환한다. 각 작업이 성공했을 때 Task(Of T)의 결과 배열은 입력 작업 배열의 순서에 대응한다. 먼저 끝난 작업의 결과가 배열 앞에 오는 방식이 아니다. 도서 번호 순서로 작업을 배열에 넣으면 완료 순서가 달라도 도서 번호 순서로 결과를 읽을 수 있다.

WhenAll이 작업을 시작하는 것은 아니다. 비동기 메서드를 호출하여 얻은 작업들을 WhenAll에 전달한다. 완성 코드에서는 세 조회를 먼저 호출하고 그 결과 작업을 배열에 넣은 다음 한 번에 기다린다. 작업 안에서는 Console.WriteLine을 호출하지 않는다. 결과를 받은 호출자가 출력하므로 실행 순서에 따른 출력의 흔들림을 피한다.

WhenAll에 전달한 작업 중 하나가 실패해도 다른 작업이 자동으로 취소되지는 않는다. WhenAll이 반환한 작업은 모든 입력 작업이 종료되어야 완료된다. 실패가 있으면 실패 상태가 되고, 실패가 없으면서 취소된 작업이 있으면 취소 상태가 된다. 모두 성공해야 결과 배열을 얻는다. Await가 전달한 예외 하나만으로 여러 작업의 모든 실패를 다 보았다고 판단하지 않는다. 전체 실패가 필요하면 모아 둔 작업의 Exception 정보도 확인한다.

먼저 끝난 작업을 알아야 하는 경우

Task.WhenAny는 전달받은 작업 중 하나가 종료되면 그 작업을 반환한다. 먼저 종료된 작업은 성공했을 수도 있고, 실패하거나 취소되었을 수도 있다. 따라서 WhenAny를 Await한 다음에는 반환된 작업도 Await해야 그 작업의 결과나 예외를 관찰할 수 있다.

시간 제한은 실제 작업과 제한 시간을 나타내는 Task.Delay를 함께 WhenAny에 전달하여 구성할 수 있다. 실제 작업이 먼저 끝나면 그 결과를 받는다. 지연 작업이 먼저 끝나면 제한을 넘긴 것으로 판단하고 실제 작업에 취소를 요청한다. WhenAny는 뒤에 남은 작업을 자동으로 취소하지 않는다.

시간 제한 작업이 먼저 끝나도 실제 작업은 따로 취소하고 종료를 확인해야 한다

완성 코드의 실제 작업은 Timeout.Infinite로 지정한 대기이며 취소 토큰을 받는다. 제한 시간 작업만 스스로 완료할 수 있으므로 어느 작업이 먼저 끝날지에 대한 결과가 고정된다. 제한 시간은 20밀리초로 지정하지만 실제로 경과한 시간은 출력하지 않는다. 운영체제의 스케줄링에 따라 재개 시점이 달라질 수 있고, 이 값은 실행 시간의 정밀한 보장이 아니기 때문이다.

실제 서비스에서는 작업이 먼저 끝날 수도 있다. 그 경로에서는 해당 작업을 Await하여 성공 또는 실패를 처리한다. 제한 시간 대기가 길다면 그 대기에도 별도 취소 토큰을 주어 불필요하게 남지 않도록 정리할 수 있다. 이 예제의 제한 시간 경로에서는 지연 작업이 이미 완료되었으므로 남은 실제 작업의 취소와 종료만 확인한다.

시간 제한을 구현하는 또 다른 방법은 CancellationTokenSource.CancelAfter로 일정 시간 뒤 취소를 요청하는 것이다. 작업이 토큰에 응답한다면 대기를 줄일 수 있다. 다만 사용자 취소와 시간 제한을 같은 토큰으로 전달하면 취소 원인을 구분할 정보가 줄어든다. 서로 다른 원인을 보여 줘야 하는 서비스에서는 요청의 출처와 처리 정책을 함께 설계한다.

Task.WaitAsync도 기다림에 시간 제한을 둘 수 있지만 원래 작업의 중단을 대신하지 않는다. 여기서는 작업 정리 과정을 드러내기 위해 WhenAny를 사용한다. 취소를 지원하지 않는 작업은 시간 제한을 넘긴 뒤에도 계속 실행될 수 있다. 그런 작업에서는 호출자에게 응답하는 시점과 남은 작업을 관찰하는 책임을 별도로 정해야 한다.

병렬 계산과 공유 상태 보호

병렬 처리(parallel processing)는 여러 계산을 나누어 수행하는 방법이다. 비동기 대기가 외부 응답을 기다리는 동안 호출 흐름을 붙잡지 않는 데 초점을 둔다면, Parallel.For는 반복 계산을 여러 실행 흐름에 분배한다. 호출 자체는 반복 작업이 끝날 때까지 기다리므로 반복문 뒤에서 완료된 결과를 사용할 수 있다.

Parallel.For가 매번 여러 스레드를 사용하거나 항목을 번호 순서대로 실행한다고 가정하지 않는다. 런타임은 사용 가능한 자원과 작업량을 고려하여 실행한다. 짧은 덧셈처럼 비용이 작은 계산에서는 분배와 동기화 비용 때문에 일반 For보다 느릴 수 있다. 이 장의 세 건은 속도를 높이기 위한 크기가 아니라 공유 상태를 설명하기 위한 크기다.

합계에 값을 더하는 total += fee는 하나의 보호된 연산이 아니다. 두 실행 흐름이 같은 이전 값을 읽으면 각자 계산한 값을 저장하면서 한쪽 결과를 덮어쓸 수 있다. 이를 경쟁 상태(race condition)라고 한다. 코드가 여러 번 올바른 합계를 출력했다고 해서 문제가 없는 것은 아니다.

SyncLock은 같은 잠금 객체를 사용하는 코드 구간에 한 번에 하나의 실행 흐름만 들어가게 한다. 완성 코드에서는 변하지 않는 gate 객체를 하나 만들고 합계 갱신을 그 객체로 보호한다. 계산과 배열 저장은 밖에서 수행하고, 공유 합계를 읽고 쓰는 짧은 부분만 안에 둔다.

각 반복은 결과 배열의 서로 다른 인덱스에 값을 저장한다. 한 인덱스를 여러 반복이 수정하지 않고 반복 중에 배열을 읽는 다른 코드도 없다. 따라서 이 배열 저장에는 잠금이 필요하지 않다. 배열이라는 이유로 항상 안전한 것이 아니라, 각 실행 흐름이 접근하는 위치가 겹치지 않는다는 조건이 중요하다.

SyncLock 안에서 Await를 사용할 수는 없다. 또 작업을 동기적으로 기다리거나 오래 걸리는 입출력을 수행하면 다른 실행 흐름이 잠금 앞에서 오래 대기한다. 보호 구간은 공유 상태를 일관되게 바꾸는 데 필요한 범위로 좁힌다. 프로그램이 더 커질수록 공유 값을 줄이는 방법도 검토해야 한다. 다음 장에서는 불변 객체와 값 동등성을 통해 데이터 변경 자체를 다루는 관점을 살펴본다.

완성 코드

새 콘솔 프로젝트의 Program.vb 내용을 다음 코드로 바꾼다. 취소 토큰은 작업으로 전달하고, Console 출력은 모든 관련 작업이 정리된 뒤 MainAsync에서 수행한다. Sub Main은 비동기 작업의 종료를 기다려 프로세스가 먼저 끝나지 않게 한다.

Option Strict On
Option Explicit On

Imports System
Imports System.Threading
Imports System.Threading.Tasks

Module Program
    Sub Main()
        MainAsync().GetAwaiter().GetResult()
    End Sub

    Private Async Function MainAsync() As Task
        Dim overdueDays As Integer() = {3, 0, 2}
        Dim checks As Integer = 0

        Dim jobs As Task(Of Integer)() = {
            ReadFeeAsync(overdueDays(0), CancellationToken.None),
            ReadFeeAsync(overdueDays(1), CancellationToken.None),
            ReadFeeAsync(overdueDays(2), CancellationToken.None)
        }

        Dim fees As Integer() = Await Task.WhenAll(jobs)
        Check(fees(0) = 900 AndAlso fees(1) = 0 AndAlso
              fees(2) = 600, "조회 결과 순서", checks)

        Dim asyncTotal As Integer = 0
        For Each fee As Integer In fees
            asyncTotal += fee
        Next
        Check(asyncTotal = 1500, "조회 합계", checks)

        Dim canceled As Boolean = Await CheckCancellationAsync()
        Check(canceled, "취소 후 종료", checks)

        Dim timedOut As Boolean = Await CheckTimeoutAsync()
        Check(timedOut, "시간 제한 후 종료", checks)

        Dim parallelFees(overdueDays.Length - 1) As Integer
        Dim parallelTotal As Integer = 0
        Dim gate As New Object()

        Parallel.For(
            0,
            overdueDays.Length,
            Sub(index As Integer)
                Dim fee As Integer = overdueDays(index) * 300
                parallelFees(index) = fee

                SyncLock gate
                    parallelTotal += fee
                End SyncLock
            End Sub)

        Check(parallelFees(0) = 900 AndAlso
              parallelFees(1) = 0 AndAlso
              parallelFees(2) = 600, "병렬 계산 결과", checks)
        Check(parallelTotal = asyncTotal, "병렬 합계", checks)

        Console.WriteLine(
            $"조회 연체료: {fees(0)}, {fees(1)}, {fees(2)}")
        Console.WriteLine($"조회 합계: {asyncTotal}원")
        Console.WriteLine(
            "사용자 취소: 요청 후 작업 종료 확인")
        Console.WriteLine(
            "시간 제한: 초과 후 작업 종료 확인")
        Console.WriteLine($"병렬 합계: {parallelTotal}원")
        Console.WriteLine($"검사 통과: {checks}개")
    End Function

    Private Async Function ReadFeeAsync(
        days As Integer,
        token As CancellationToken) As Task(Of Integer)

        token.ThrowIfCancellationRequested()
        Await Task.Delay(1, token)
        token.ThrowIfCancellationRequested()
        Return days * 300
    End Function

    Private Async Function WaitForStopAsync(
        ready As TaskCompletionSource(Of Boolean),
        token As CancellationToken) As Task

        token.ThrowIfCancellationRequested()
        ready.TrySetResult(True)
        Await Task.Delay(Timeout.Infinite, token)
    End Function

    Private Async Function CheckCancellationAsync() As Task(Of Boolean)
        Using source As New CancellationTokenSource()
            Dim ready As New TaskCompletionSource(Of Boolean)(
                TaskCreationOptions.RunContinuationsAsynchronously)
            Dim work As Task = WaitForStopAsync(ready, source.Token)

            Await ready.Task
            source.Cancel()

            Try
                Await work
            Catch ex As OperationCanceledException When source.IsCancellationRequested

                Return work.IsCanceled
            End Try

            Return False
        End Using
    End Function

    Private Async Function CheckTimeoutAsync() As Task(Of Boolean)
        Using source As New CancellationTokenSource()
            Dim ready As New TaskCompletionSource(Of Boolean)(
                TaskCreationOptions.RunContinuationsAsynchronously)
            Dim work As Task = WaitForStopAsync(ready, source.Token)

            Await ready.Task
            Dim limit As Task = Task.Delay(20)
            Dim completed As Task = Await Task.WhenAny(work, limit)

            If Object.ReferenceEquals(completed, work) Then
                Await work
                Return False
            End If

            source.Cancel()

            Try
                Await work
            Catch ex As OperationCanceledException When source.IsCancellationRequested

                Return work.IsCanceled
            End Try

            Return False
        End Using
    End Function

    Private Sub Check(
        condition As Boolean,
        name As String,
        ByRef checks As Integer)

        If Not condition Then
            Throw New InvalidOperationException(
                $"검사 실패: {name}")
        End If

        checks += 1
    End Sub
End Module

줄별 해설

Option Strict On은 암시적인 축소 변환과 늦은 바인딩 등을 제한한다. 금액과 검사 횟수는 Integer로 명시한다. 여기서는 입력과 금액 범위가 작으므로 Integer가 충분하다. 실제 금액 모델의 자료형은 서비스가 허용하는 범위와 소수 금액 정책에 맞추어 정한다.

Sub Main의 GetAwaiter().GetResult()는 콘솔 진입점에서 MainAsync가 끝날 때까지 기다린다. 내부 비동기 메서드에서는 Await를 사용한다. 이 진입점의 대기 방식을 사용자 인터페이스의 이벤트 처리 코드에 그대로 가져오면 화면 응답을 막을 수 있으므로 적용 위치를 구분한다.

overdueDays는 이번 실행의 입력이다. jobs 배열을 만드는 각 ReadFeeAsync 호출은 결과를 나타내는 작업을 반환한다. CancellationToken.None은 이 조회 묶음에서는 외부 취소를 연결하지 않는다는 뜻이다. 메서드 자체는 토큰을 받으므로 다른 호출자가 취소 가능한 토큰을 전달할 수 있다.

Await Task.WhenAll(jobs)는 세 조회의 종료를 기다리고 결과 배열을 얻는다. 첫 Check는 배열의 각 위치를 검사한다. 이어지는 For Each는 완료된 결과를 순서대로 합산한다. 이 합산은 한 실행 흐름에서 이루어지므로 공유 합계의 경쟁이 없다.

CheckCancellationAsync와 CheckTimeoutAsync는 요청만 보낸 뒤 True를 반환하지 않는다. 각각 work를 Await하고 취소된 상태까지 확인해야 True를 반환한다. 따라서 검사 이름의 “종료”는 요청을 보냈다는 뜻이 아니라 작업의 최종 상태를 관찰했다는 뜻이다.

Parallel.For의 첫 인수 0은 시작 인덱스이고 두 번째 인수 overdueDays.Length는 포함하지 않는 끝 인덱스다. 길이가 3이므로 0, 1, 2에 해당하는 반복을 수행한다. 람다 안의 fee는 각 호출의 지역 변수다. parallelTotal과 gate는 모든 반복이 함께 접근하는 변수다.

parallelFees(index)에는 해당 인덱스의 계산 결과를 저장한다. SyncLock gate부터 End SyncLock까지는 공유 합계의 갱신 구간이다. Parallel.For가 반환된 다음에는 모든 반복이 종료되어 있으므로 결과 배열과 합계를 검사할 수 있다. 출력도 그 뒤에서 수행한다.

ReadFeeAsync의 첫 ThrowIfCancellationRequested는 이미 취소된 요청으로 조회를 시작하지 않게 한다. Task.Delay에는 같은 토큰을 전달한다. 대기 후의 확인은 실제 계산 전에 다시 요청을 확인하는 위치를 보여 준다. 다만 이 확인 직후에도 취소 요청이 올 수 있다. 취소는 정해진 확인 지점에서 협력하는 방식이며, 성공과 취소가 경계에서 경쟁할 수 있음을 기억한다.

WaitForStopAsync는 준비 신호를 완료한 뒤 무기한 대기에 들어간다. RunContinuationsAsynchronously는 준비 신호를 완료하는 호출 안에서 연결된 후속 처리를 즉시 실행하지 않도록 지정한다. 준비 신호로 보낸 Boolean 값은 검사 대상이 아니며, 여기서는 신호가 완료되었다는 사실만 사용한다.

취소 예제의 Catch에는 요청 여부를 확인하는 필터가 있다. 이 예제의 작업은 source.Token 하나만 사용하므로 요청 출처도 분명하다. 여러 취소 토큰과 외부 작업을 함께 다루는 코드에서는 예외에 담긴 토큰과 호출 계약까지 확인하여 예상한 취소인지 판단해야 한다.

시간 제한 예제의 completed는 작업 결과가 아니라 먼저 끝난 작업 객체다. ReferenceEquals로 work인지 구분한다. 제한 시간이 먼저 끝났다면 Cancel을 호출하고 work를 기다린다. 작업이 먼저 끝난 경로에서도 Await work를 수행하므로 실패를 놓치지 않는다.

Check는 조건이 틀리면 예외를 발생시키고, 맞으면 검사 횟수를 하나 늘린다. 마지막 출력에 도달했다면 여섯 조건이 모두 통과한 것이다. 이 검사는 예제의 결과를 확인하는 용도다. 모든 실행 순서에서 동시성 문제가 없음을 자동으로 증명하는 검사는 아니다.

실행 결과

.NET 10 SDK가 설치된 환경에서 다음 명령으로 프로젝트를 만든다. 생성된 Program.vb를 완성 코드로 바꾼 뒤 실행한다. 별도 패키지는 설치하지 않는다.

dotnet new console -lang VB -f net10.0 -n LibraryConcurrency
cd LibraryConcurrency
dotnet run

dotnet run으로 실행한 프로그램의 예상 출력은 다음과 같다. 프로젝트 생성 명령의 안내 메시지는 아래 출력에 포함하지 않는다.

조회 연체료: 900, 0, 600
조회 합계: 1500원
사용자 취소: 요청 후 작업 종료 확인
시간 제한: 초과 후 작업 종료 확인
병렬 합계: 1500원
검사 통과: 6개

작업의 실제 실행 순서와 대기 시간은 달라질 수 있다. 그러나 조회 결과는 입력 순서로 모이고, 취소 예제는 요청 후 종료를 기다리며, 시간 제한 예제의 실제 작업은 취소 전에는 완료되지 않는다. 병렬 합계는 잠금으로 보호한다. 이 조건들이 출력의 결정성을 만든다.

실무에서 자주 틀리는 것

다음 코드는 완성 프로그램에서 사용하는 변수나 메서드가 있는 범위에 넣는 부분 예제다. 각각의 문제를 보여 주기 위한 조각이므로 별도의 진입점은 생략한다.

취소 요청을 작업 완료로 취급한다

다음 코드는 요청을 보낸 직후 작업 참조를 더 이상 관찰하지 않는다. 작업이 취소 처리나 자원 정리를 진행 중일 수 있고, 다른 오류로 끝날 수도 있다.

source.Cancel()
Console.WriteLine("작업 종료")

취소를 예상하는 위치에서 작업을 기다린다. 다른 오류는 취소 메시지로 바꾸지 않고 호출자에게 전달한다.

source.Cancel()

Try
    Await work
Catch ex As OperationCanceledException When
    source.IsCancellationRequested

    Console.WriteLine("취소 종료 확인")
End Try

WhenAny가 반환한 작업의 결과를 확인하지 않는다

WhenAny의 대기만 끝냈다고 실제 작업이 성공한 것은 아니다. 다음 코드에서는 work가 실패한 채 먼저 종료되어도 예외를 관찰하지 않는다.

Dim completed As Task = Await Task.WhenAny(work, limit)
If Object.ReferenceEquals(completed, work) Then
    Console.WriteLine("작업 성공")
End If

선택된 작업이 work라면 한 번 더 Await한다. 성공 메시지는 그 Await가 정상적으로 끝난 다음에 출력한다.

Dim completed As Task = Await Task.WhenAny(work, limit)
If Object.ReferenceEquals(completed, work) Then
    Await work
    Console.WriteLine("작업 성공")
End If

제한 시간 경로에서는 별도로 work의 취소와 종료를 처리해야 한다. 어느 경로가 선택되었든 시작한 작업의 최종 상태를 관찰할 책임은 남는다.

반복마다 서로 다른 잠금 객체를 만든다

잠금은 같은 객체를 공유할 때만 같은 보호 구간을 만든다. 다음 코드의 gate는 반복마다 새로 만들어지므로 다른 반복의 합계 갱신을 막지 못한다.

Parallel.For(
    0, overdueDays.Length,
    Sub(index As Integer)
        Dim gate As New Object()
        SyncLock gate
            total += overdueDays(index) * 300
        End SyncLock
    End Sub)

잠금 객체를 반복문 밖에서 한 번 만들고 모든 반복이 사용하게 한다. 같은 total에 접근하는 다른 코드가 있다면 그 코드도 같은 보호 규칙을 따라야 한다.

Dim gate As New Object()

Parallel.For(
    0, overdueDays.Length,
    Sub(index As Integer)
        Dim fee As Integer = overdueDays(index) * 300
        SyncLock gate
            total += fee
        End SyncLock
    End Sub)

Parallel.For에 비동기 람다를 넣는다

Parallel.For는 비동기 작업을 모아 기다리는 API가 아니다. 다음 람다는 Action에 대응하면서 Async Sub가 되므로 반복문이 비동기 대기의 완료를 추적하지 못한다. 예외 처리의 경계도 흐트러진다.

Parallel.For(
    0, overdueDays.Length,
    Async Sub(index As Integer)
        Await ReadFeeAsync(
            overdueDays(index), CancellationToken.None)
    End Sub)

비동기 메서드가 반환한 Task를 배열에 보관하고 WhenAll로 기다린다. 이 형태는 작업의 수만큼 요청을 시작하므로 실제 서비스에서는 허용할 동시 요청 수도 따로 검토한다.

Dim tasks(overdueDays.Length - 1) As Task(Of Integer)

For index As Integer = 0 To overdueDays.Length - 1
    tasks(index) = ReadFeeAsync(
        overdueDays(index), CancellationToken.None)
Next

Dim results As Integer() = Await Task.WhenAll(tasks)

한눈에 보기

작업을 기다리는 목적에 따른 도구 선택
도구완료 조건사용할 상황확인할 책임
CancellationToken자체적으로 작업을 완료하지 않는다.호출자의 중단 요청 전달작업이 토큰을 확인하는지 살핀다.
Task.WhenAll모든 입력 작업이 종료된다.여러 조회 결과를 함께 받는다.실패와 취소를 처리한다.
Task.WhenAny입력 작업 하나가 종료된다.먼저 끝난 작업이나 시간 제한을 구분한다.선택된 작업과 남은 작업을 관찰한다.
Parallel.For모든 반복이 종료된다.동기 계산을 분배한다.공유 상태와 실행 순서 의존성을 살핀다.
SyncLock보호 구간을 순서대로 통과한다.공유 값의 갱신을 보호한다.같은 잠금 객체와 짧은 보호 구간을 사용한다.

취소와 시간 제한은 요청, 판정, 종료 확인으로 나누어 읽는다. 여러 작업의 결과가 모두 필요하면 WhenAll을 사용하고, 먼저 끝난 작업을 구분해야 하면 WhenAny를 사용한다. 병렬 계산에서는 각 반복의 지역 데이터와 함께 수정하는 데이터를 먼저 구분한다.

API의 동작을 더 확인할 때는 공식 문서를 참고할 수 있다. 다음 링크는 사실 확인과 추가 탐색을 위한 자료다. 취소 모델, Task.WhenAll, Task.WhenAny, Parallel.For, SyncLock에서 각 기능의 계약을 확인한다.

연습 문제

  1. 이미 취소된 토큰을 ReadFeeAsync에 전달하는 검사를 추가하라. 결과 금액이 반환되지 않고 취소로 종료되는지 확인하라. 검사 횟수도 한 개 늘어나게 하라.
  2. CheckTimeoutAsync에서 실제 작업을 Task.CompletedTask로 바꾸어 작업이 먼저 완료되는 경로를 확인하라. 준비 신호가 더 이상 필요하지 않은 이유와 반환값이 False가 되는 이유를 설명하라.
  3. 병렬 합계의 SyncLock을 제거하고, Parallel.For가 반환된 뒤 parallelFees를 일반 For Each로 합산하도록 바꾸라. 이 구조에서 합계 변수에 잠금이 필요하지 않은 이유를 설명하라.
  4. 두 조회 작업이 각각 실패하는 상황에서 WhenAll의 반환 작업을 변수에 보관하라. Await가 예외를 전달한 뒤 전체 실패 정보를 어디에서 확인할지 설명하라.

정답과 해설

이미 취소된 토큰 검사

MainAsync의 마지막 출력 전에 다음 부분을 추가한다. ReadFeeAsync는 처음 토큰을 확인할 때 취소를 발견한다. 비동기 메서드의 작업을 Await하면 그 취소가 호출자에게 전달된다.

Using source As New CancellationTokenSource()
    source.Cancel()
    Dim wasCanceled As Boolean = False

    Try
        Dim unusedFee As Integer =
            Await ReadFeeAsync(3, source.Token)
        wasCanceled = False
    Catch ex As OperationCanceledException When
        source.IsCancellationRequested

        wasCanceled = True
    End Try

    Check(wasCanceled, "시작 전 취소", checks)
End Using

정상적으로 결과를 받으면 wasCanceled는 False로 남아 검사가 실패한다. 취소를 관찰해야 검사가 통과한다. 마지막 출력은 “검사 통과: 7개”로 바뀐다. 완성 코드에 있던 다른 출력은 같은 값을 유지한다.

작업이 먼저 완료되는 경로

CheckTimeoutAsync의 준비 신호 생성, WaitForStopAsync 호출, Await ready.Task를 제거하고 다음 두 줄로 바꾼다. 나머지 판정과 정리 코드는 유지한다.

Dim work As Task = Task.CompletedTask
Dim limit As Task = Task.Delay(20)

work는 이미 성공적으로 완료되어 있고 WhenAny의 입력에서도 먼저 배치되어 있으므로 work가 선택된다. Await work는 정상적으로 끝나고 False를 반환한다. False는 시간 제한이 발생하지 않았다는 뜻이다. 기다림에 진입하는 실제 작업이 없으므로 준비 신호도 필요하지 않다. 이 변경을 적용하면 MainAsync의 기존 시간 제한 검사는 Not timedOut 조건으로 바꾸고, 해당 출력 문장도 작업 정상 완료에 맞게 고쳐야 한다.

결과 배열을 통한 합계 계산

병렬 반복은 각자의 배열 칸만 채운다. 합계는 Parallel.For가 반환된 후 한 실행 흐름에서 계산한다.

Parallel.For(
    0, overdueDays.Length,
    Sub(index As Integer)
        parallelFees(index) = overdueDays(index) * 300
    End Sub)

Dim parallelTotal As Integer = 0
For Each fee As Integer In parallelFees
    parallelTotal += fee
Next

합계를 수정하는 실행 흐름이 하나이므로 합계 갱신에 잠금이 필요하지 않다. 병렬 반복이 끝나기 전에 합산을 시작하지 않는다는 조건도 중요하다. 완성 코드의 합계 검사는 그대로 통과한다.

여러 실패의 관찰

WhenAll이 반환한 작업을 combined에 보관한다. 다음 부분 예제는 이미 실패한 두 작업을 만들어 종료 상태와 예외 관찰에만 초점을 맞춘다.

Dim first As Task(Of Integer) =
    Task.FromException(Of Integer)(
        New InvalidOperationException("대출 조회 실패"))

Dim second As Task(Of Integer) =
    Task.FromException(Of Integer)(
        New ArgumentException("예약 번호 오류"))

Dim combined As Task(Of Integer()) =
    Task.WhenAll(first, second)

Try
    Dim ignored As Integer() = Await combined
Catch ex As Exception
    Dim failures As AggregateException = combined.Exception
    If failures Is Nothing Then
        Throw
    End If

    Check(failures.Flatten().InnerExceptions.Count = 2,
          "전체 실패 확인", checks)
End Try

Await로 전달된 예외 하나와 combined.Exception에 담긴 전체 실패 정보는 역할이 다르다. 이 예제는 의도적으로 만든 실패 작업만 다루므로 넓은 Catch 뒤에서 전체 개수를 확인한다. 실제 서비스에서는 필요한 오류만 처리하고 처리하지 못하는 오류는 전달한다. 검사 프로젝트를 별도로 구성할 때도 같은 관점을 적용하여 취소 요청 여부뿐 아니라 종료 상태, 결과 순서, 전체 오류 정보를 각각 검사한다. xUnit 프로젝트에서는 이런 조건을 독립된 테스트로 옮길 수 있지만, 이 장의 실행에는 별도 테스트 프로젝트가 필요하지 않다.

오탈자·오류 제보 비공개로 접수되어 원고 수정에 반영됩니다

이메일 등 개인정보는 받지 않습니다. 답변이 필요한 질문은 아래 댓글을 이용해 주세요.

READER FEEDBACK

질문·의견

내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.