Task Expressions

Task Expressions (F# 작업식)

F#에는 비동기 작업을 다루는 **작업식(task expression)**이 있어요. async 식과 비슷하지만, .NET의 작업(Task)을 직접 만들 수 있다는 점이 달라요. async 식과 마찬가지로 작업식도 코드를 비동기로 실행하니까, 다른 작업의 실행을 막지 않아요.

비동기 코드는 보통 async 식으로 작성하죠. 그런데 .NET의 작업을 만들거나 소비하는 라이브러리와 많이 연동해야 한다면 작업식이 더 선호돼요. 작업식은 성능과 디버깅 경험도 개선할 수 있어요. 다만 작업식은 몇 가지 제약이 있는데, 이 글 뒤쪽에서 자세히 다룰게요.

출처: F# Task Expressions — Microsoft Learn (원문을 한국어로 번역한 문서예요.)

본문

Syntax (구문)

task { expression }

위 구문에서 expression이 나타내는 계산은 .NET 작업으로 실행되도록 준비돼요. 이 코드가 실행되자마자 작업이 시작되고, 첫 번째 비동기 작업(예: 비동기 sleep, 비동기 I/O, 또는 그 외 기본 비동기 연산)이 수행되기 전까지는 현재 스레드에서 실행돼요. 이 식의 타입은 Task<'T>인데, 여기서 'Treturn 키워드를 썼을 때 식이 반환하는 타입이에요.

let!로 바인딩하기

작업식 안에는 동기적으로 동작하는 식과 연산도 있고, 비동기로 동작하는 것도 있어요. 비동기 연산의 결과를 기다릴 때는 일반 let 바인딩 대신 let!을 써요. let!의 효과는 계산을 수행하는 동안 다른 계산이나 스레드에서 실행을 이어갈 수 있게 해 주는 거예요. let! 바인딩의 오른쪽이 반환되면, 나머지 작업이 다시 실행을 재개해요.

아래 코드는 letlet!의 차이를 보여줘요. let을 쓰는 줄은 단지 작업을 나중에 기다릴 수 있는 객체로 만들어 둘 뿐이에요(예를 들어 task.Wait()task.Result로 기다릴 수 있죠). 반면 let!을 쓰는 줄은 작업을 시작하고 그 결과를 기다려요.

// let just stores the result as a task.
let (result1 : Task<int>) = stream.ReadAsync(buffer, offset, count, cancellationToken)
// let! completes the asynchronous operation and returns the data.
let! (result2 : int)  = stream.ReadAsync(buffer, offset, count, cancellationToken)

F#의 task { } 식은 다음과 같은 종류의 비동기 연산을 기다릴 수 있어요.

return

작업식 안에서 return expr은 작업의 결과를 반환할 때 써요.

return!

작업식 안에서 return! expr은 다른 작업의 결과를 반환할 때 써요. 이것은 let!으로 결과를 받은 뒤 곧바로 돌려주는 것과 같아요.

제어 흐름 (Control flow)

작업식은 for .. in .. do, while .. do, try .. with .., try .. finally .., if .. then .. else, if .. then .. 같은 제어 흐름 구조를 포함할 수 있어요. 이 구조들은 다시 작업 구조를 포함할 수도 있는데, with 핸들러와 finally 핸들러는 동기적으로 실행되기 때문에 예외예요. 비동기 try .. finally ..가 필요하다면, IAsyncDisposable 타입의 객체와 함께 use 바인딩을 사용하세요.

use, use! 바인딩

작업식 안에서 use 바인딩은 IDisposable 또는 IAsyncDisposable 타입의 값에 바인딩할 수 있어요. 후자의 경우 정리(disposal) 작업이 비동기로 실행돼요.

let!에 더해 비동기 바인딩을 수행할 때 use!도 쓸 수 있어요. let!use!의 차이는 letuse의 차이와 같아요. use!의 경우 객체는 현재 스코프가 끝날 때 정리돼요. 참고로 F# 6에서는 use와 달리 use!는 값을 null로 초기화하는 것을 허용하지 않아요.

open System
open System.IO
open System.Security.Cryptography
task {
    // use IDisposable
    use httpClient = new Net.Http.HttpClient()
    // use! Task<IDisposable>
    use! exampleDomain = httpClient.GetAsync "https://example.com/data.enc"

    // use IDisposable
    use aes = Aes.Create()
    aes.KeySize <- 256
    aes.GenerateIV()
    aes.GenerateKey()
    // do! Task
    do! File.WriteAllTextAsync("key.iv.txt", $"Key: {Convert.ToBase64String aes.Key}\nIV: {Convert.ToBase64String aes.IV}")

    // use IAsyncDisposable
    use outputStream = File.Create "secret.enc"
    // use IDisposable
    use encryptor = aes.CreateEncryptor()
    // use IAsyncDisposable
    use cryptoStream = new CryptoStream(outputStream, encryptor, CryptoStreamMode.Write)
    // do! Task
    do! exampleDomain.Content.CopyToAsync cryptoStream
}

여기서 use는 순수한 IDisposable 객체를, use!는 비동기 작업의 결과(Task<IDisposable> 같은 것)를 바인딩할 때 쓰는 걸 볼 수 있어요. do!는 반환값이 없는 비동기 작업(Task)을 기다릴 때 사용해요.

Value Tasks (값 작업)

값 작업(value task)은 작업 기반 프로그래밍에서 할당(allocations)을 피하기 위해 쓰는 구조체예요. 값 작업은 일시적인 값으로, .AsTask()를 통해 실제 작업으로 바꿀 수 있어요.

작업식에서 값 작업을 만들려면 |> ValueTask<ReturnType> 또는 |> ValueTask를 사용하세요. 예를 들어:

let makeTask() =
    task { return 1 }

makeTask() |> ValueTask<int>

and! 바인딩 (F# 10부터)

작업식 안에서는 여러 비동기 연산(Task<'T>, ValueTask<'T>, Async<'T> 등)을 동시에 기다리는 것이 가능해요. 비교해 볼게요:

// We'll wait for x to resolve and then for y to resolve. Overall execution time is sum of two execution times.
let getResultsSequentially() =
    task {
        let! x = getX()
        let! y = getY()
        return x, y
    }

// x and y will be awaited concurrently. Overall execution time is the time of the slowest operation.
let getResultsConcurrently() =
    task {
        let! x = getX()
        and! y = getY()
        return x, y
    }

취소 토큰과 취소 검사 추가하기

F# async 식과 달리 작업식은 취소 토큰을 암시적으로 전달하지 않고, 취소 검사도 암시적으로 수행하지 않아요. 코드에 취소 토큰이 필요하다면 매개변수로 취소 토큰을 명시해서 지정해야 해요. 예를 들어:

open System.Threading

let someTaskCode (cancellationToken: CancellationToken) =
    task {
        cancellationToken.ThrowIfCancellationRequested()
        printfn $"continuing..."
    }

코드를 제대로 취소 가능하게 만들려면, 취소를 지원하는 모든 .NET 라이브러리 연산에 취소 토큰을 전달했는지 꼼꼼히 확인해야 해요. 예를 들어 Stream.ReadAsync는 여러 오버로드가 있는데 그중 하나가 취소 토큰을 받아요. 이 오버로드를 쓰지 않으면, 그 특정 비동기 읽기 연산은 취소할 수 없어요.

백그라운드 작업 (Background tasks)

기본적으로 .NET 작업은 SynchronizationContext.Current가 있으면 이를 사용해 예약돼요. 이 덕분에 작업이 UI 스레드에서 UI를 막지 않고 협력적·인터리브 방식의 에이전트 역할을 할 수 있어요. 컨텍스트가 없다면 작업 연속(continuation)은 .NET 스레드 풀에 예약돼요.

실무에서는 작업을 만드는 라이브러리 코드가 동기화 컨텍스트를 무시하고, 필요하다면 항상 .NET 스레드 풀로 전환하는 것이 바람직한 경우가 많아요. backgroundTask { }를 사용하면 이렇게 할 수 있어요:

backgroundTask { expression }

백그라운드 작업은 SynchronizationContext.Current를 다음과 같은 의미로 무시해요. SynchronizationContext.Current가 null이 아닌 스레드에서 시작하면 Task.Run을 사용해 스레드 풀의 백그라운드 스레드로 전환하고, null인 스레드에서 시작하면 그 스레드에서 그대로 실행돼요.

참고: 실제로는 이 때문에 F# 작업 코드에서 ConfigureAwait(false) 호출이 보통 필요 없어요. 대신 백그라운드에서 실행되도록 의도된 작업은 backgroundTask { ... }로 작성하면 돼요. 백그라운드 작업에 바인딩하는 바깥쪽 작업은 그 백그라운드 작업이 완료되면 SynchronizationContext.Current로 다시 재동기화돼요.

꼬리 호출에 관한 작업의 제한 (Limitations of tasks regarding tailcalls)

F# async 식과 달리 작업식은 꼬리 호출(tailcall)을 지원하지 않아요. 즉 return!을 실행하면 현재 작업이, 결과를 반환할 작업을 기다리는 중으로 등록돼요. 이 말은 작업식으로 구현한 재귀 함수와 메서드가 무한정 이어지는 작업 체인을 만들 수 있고, 그 체인은 무한정 늘어나는 스택이나 힙을 사용할 수 있다는 뜻이에요. 다음 코드를 볼게요:

let rec taskLoopBad (count: int) : Task<string> =
    task {
        if count = 0 then
            return "done!"
        else
            printfn $"looping..., count = {count}"
            return! taskLoopBad (count-1)
    }

let t = taskLoopBad 10000000
t.Wait()

이런 코딩 방식은 작업식과 함께 쓰면 안 돼요. 10000000개의 작업 체인을 만들어서 StackOverflowException을 일으킬 거예요. 각 루프 호출에 비동기 연산이 추가되면 코드는 사실상 무한정의 힙을 사용하게 돼요. 이 코드를 명시적인 루프로 바꾸는 것을 고려해 보세요. 예를 들어:

let taskLoopGood (count: int) : Task<string> =
    task {
        for i in count .. 1 do
            printfn $"looping... count = {count}"
        return "done!"
    }

let t = taskLoopGood 10000000
t.Wait()

비동기 꼬리 호출이 필요하다면 꼬리 호출을 지원하는 F# async 식을 사용하세요. 예를 들어:

let rec asyncLoopGood (count: int) =
    async {
        if count = 0 then
            return "done!"
        else
            printfn $"looping..., count = {count}"
            return! asyncLoopGood (count-1)
    }

let t = asyncLoopGood 1000000 |> Async.StartAsTask
t.Wait()

Task 구현 (Task implementation)

작업은 F# 6에 새로 추가된 기능인 Resumable Code(재개 가능한 코드)로 구현돼요. 작업은 F# 컴파일러에 의해 "Resumable State Machines"(재개 가능한 상태 머신)로 컴파일돼요. 이에 대한 자세한 내용은 The Resumable code RFCF# 컴파일러 커뮤니티 세션에서 확인할 수 있어요.

더 알아보기