Async 식

Async 식

이 문서는 F#에서 async 식(비동기 식)을 어떻게 지원하는지 설명할게요. async 식은 계산을 비동기로 수행하는 방법 한 가지인데요, 쉽게 말해 다른 작업의 실행을 막지 않고서도 계산을 진행하는 방식이에요. 예를 들어 비동기 계산을 쓰면 애플리케이션이 다른 일을 처리하는 동안에도 UI가 사용자 입력에 계속 반응할 수 있어요. F# Asynchronous Workflows 프로그래밍 모델은 스레드 전환의 세부 사항을 라이브러리 안에 숨겨 주면서 함수형 프로그램을 작성할 수 있게 해 줘요.

비동기 코드는 task 식으로도 작성할 수 있는데, 이쪽은 .NET 작업(task)을 직접 만든다는 특징이 있어요. .NET 라이브러리를 만들어 쓰거나 소비하는 작업들이 많고 그런 라이브러리와의 상호 운용이 폭넓게 필요할 때는 task 식이 더 선호돼요. 다만 F#에서 대부분의 비동기 코드를 작성할 때는 async 식이 더 간결하고 조합하기 쉬우며 .NET task와 관련된 몇 가지 주의점도 피할 수 있어서, async 식을 더 자주 쓰게 돼요.

출처: https://learn.microsoft.com/en-us/dotnet/fsharp/language-reference/async-expressions

본문

구문 (Syntax)

async { expression }

설명 (Remarks)

위 구문에서 expression이 나타내는 계산은 비동기로 실행되도록 설정돼요. 즉, 비동기 sleep 연산, I/O, 그리고 기타 비동기 연산이 수행되는 동안 현재 계산 스레드를 막지 않는다는 뜻이에요. 이 식의 타입은 Async<'T>인데, 여기서 'Treturn 키워드를 사용했을 때 식이 돌려주는 타입이에요.

Async 클래스는 여러 시나리오를 지원하는 메서드를 제공해요. 일반적인 흐름은 이렇게 돼요. 먼저 비동기로 실행하고 싶은 계산(들)을 나타내는 Async 객체를 만들고, 그다음 트리거 함수 중 하나를 사용해서 해당 계산을 시작하는 거예요. 어떤 트리거를 쓸지는 현재 스레드를 쓸지, 백그라운드 스레드를 쓸지, 아니면 .NET task 객체를 만들지에 따라 달라져요. 예를 들어 현재 스레드에서 async 계산을 시작하려면 Async.StartImmediate를 사용할 수 있어요. UI 스레드에서 async 계산을 시작하면 키 입력이나 마우스 동작 같은 사용자 액션을 처리하는 메인 이벤트 루프를 막지 않으므로, 애플리케이션이 계속 반응할 수 있게 돼요.

let! 을 사용한 비동기 바인딩

async 식 안에는 동기적으로 실행되는 식과 연산도 있고, 비동기로 실행되는 것도 있어요. 메서드를 비동기로 호출할 때는 일반적인 let 바인딩 대신 let!을 사용해요. let!이 하는 일은, 계산이 진행되는 동안 실행이 다른 계산이나 스레드로 이어질 수 있게 해 주는 거예요. let! 바인딩의 오른쪽이 값을 돌려주고 나면, 나머지 async 식이 다시 실행을 이어나가요.

아래 코드는 letlet!의 차이를 보여줘요. let을 사용한 줄은 그저 async 계산을 하나의 객체로 만들어 둘 뿐이라, 나중에 예를 들어 Async.StartImmediateAsync.RunSynchronously로 실행할 수 있어요. 반면 let!을 사용한 줄은 계산을 시작하고 비동기 대기를 수행해요. 즉, 결과가 준비될 때까지 스레드가 일시 중단됐다가 결과가 나오면 실행이 이어지는 방식이에요.

// let just stores the result as an asynchronous operation.
let (result1 : Async<byte[]>) = stream.AsyncRead(bufferSize)
// let! completes the asynchronous operation and returns the data.
let! (result2 : byte[])  = stream.AsyncRead(bufferSize)

let!은 F# async 계산인 Async<T>를 직접 기다릴 때만 사용할 수 있어요. 그 밖의 비동기 연산 종류는 간접적으로 기다릴 수 있는데, 그 방법을 정리하면 다음과 같아요.

  • .NET task, 즉 Task<TResult>와 제네릭이 아닌 TaskAsync.AwaitTask와 조합해요.

  • .NET value task, 즉 ValueTask<TResult>와 제네릭이 아닌 ValueTask.AsTask()Async.AwaitTask를 조합해요.

  • F# RFC FS-1097에 명시된 "GetAwaiter" 패턴을 따르는 객체라면, task { return! expr } |> Async.AwaitTask로 조합해요.

제어 흐름 (Control Flow)

async 식에는 for .. in .. do, while .. do, try .. with .., try .. finally .., if .. then .. else, if .. then .. 같은 제어 흐름 구문을 포함할 수 있어요. 이런 구문들은 그 안에 추가 async 구문을 다시 포함할 수도 있는데, 단 withfinally 핸들러는 동기적으로 실행되기 때문에 예외예요.

한편 F# async 식은 비동기 try .. finally ..를 지원하지 않아요. 이런 경우에는 task 식을 사용할 수 있어요.

use와 use! 바인딩

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

let!에 더해서 use!로도 비동기 바인딩을 수행할 수 있어요. let!use!의 차이는 letuse의 차이와 같아요. use!의 경우 객체는 현재 스코프가 끝날 때 정리돼요. 한 가지 알아둘 점은, 현재 F# 릴리스에서는 use는 가능하지만 use!는 값을 null로 초기화하는 것을 허용하지 않는다는 거예요.

비동기 프리미티브 (Asynchronous Primitives)

단일 비동기 작업을 수행하고 그 결과를 돌려주는 메서드를 비동기 프리미티브라고 부르는데, 이들은 특히 let!과 함께 쓰이도록 설계됐어요. F# 핵심 라이브러리에는 몇 가지 비동기 프리미티브가 정의되어 있어요. 웹 애플리케이션을 위한 두 가지 메서드가 FSharp.Control.WebExtensions 모듈에 정의되어 있는데, 바로 WebRequest.AsyncGetResponseHttpClient.GetStringAsync(F#의 비동기 모델과 맞도록 Async.AwaitTask로 감싼 버전)예요. 두 프리미티브 모두 URL이 주어지면 웹 페이지에서 데이터를 내려받아요. AsyncGetResponseSystem.Net.WebResponse 객체를 만들어 내고, GetStringAsync는 웹 페이지의 HTML을 나타내는 문자열을 만들어 내요.

비동기 I/O 연산을 위한 몇 가지 프리미티브는 FSharp.Control.CommonExtensions 모듈에 들어 있어요. System.IO.Stream 클래스의 이 확장 메서드들은 Stream.AsyncReadStream.AsyncWrite예요.

본문이 async 식인 함수나 메서드를 직접 정의해서 나만의 비동기 프리미티브를 만들 수도 있어요.

다른 비동기 모델을 위해 설계된 .NET Framework의 비동기 메서드를 F# 비동기 프로그래밍 모델과 함께 쓰려면, F# Async 객체를 돌려주는 함수를 만들면 돼요. F# 라이브러리에는 이 작업을 쉽게 해 주는 함수들이 마련되어 있어요.

async 식을 사용한 예시 하나를 여기 소개할게요. Async 클래스 메서드 문서에는 다른 예시도 많이 있으니 참고하시면 좋아요.

이 예시는 async 식을 사용해 코드를 병렬로 실행하는 방법을 보여줘요.

아래 코드 예시에서 fetchAsync 함수는 웹 요청에서 돌아온 HTML 텍스트를 가져와요. fetchAsync 함수에는 비동기 블록이 들어 있어요. 비동기 프리미티브의 결과에 바인딩할 때는 — 여기서는 AsyncDownloadStringlet 대신 let!을 사용해요.

Async.RunSynchronously 함수는 비동기 연산을 실행하고 그 결과를 기다리는 데 쓰여요. 여러 비동기 연산을 병렬로 실행하고 싶다면 Async.Parallel 함수를 Async.RunSynchronously와 함께 사용하면 돼요. Async.Parallel 함수는 Async 객체의 목록을 받아, 각 Async 작업 객체의 코드가 병렬로 실행되도록 설정하고, 병렬 계산을 나타내는 Async 객체를 돌려줘요. 단일 연산과 마찬가지로 Async.RunSynchronously를 호출하면 실행이 시작돼요.

runAll 함수는 세 개의 async 식을 병렬로 실행하고, 모두 완료될 때까지 기다려요.

open System.Net
open Microsoft.FSharp.Control.WebExtensions
open System.Net.Http

let urlList = [ "Microsoft.com", "http://www.microsoft.com/"
                "MSDN", "http://msdn.microsoft.com/"
                "Bing", "http://www.bing.com"
              ]

let fetchAsync(name, url:string) =
    async {
        try
            let uri = new System.Uri(url)
            let httpClient = new HttpClient()
            let! html = httpClient.GetStringAsync(uri) |> Async.AwaitTask
            printfn "Read %d characters for %s" html.Length name
        with
            | ex -> printfn "%s" (ex.Message);
    }

let runAll() =
    urlList
    |> Seq.map fetchAsync
    |> Async.Parallel
    |> Async.RunSynchronously
    |> ignore

runAll()

더 알아보기 (Learn more)