F# 비동기 프로그래밍: Async와 병렬 처리
F# 비동기 프로그래밍: Async와 병렬 처리
F#에서 비동기 프로그래밍이라고 하면 크게 두 축을 떠올리게 돼요. 하나는 독립적으로 실행되는 계산을 다루는 **비동기(asynchrony)**이고, 다른 하나는 같은 시점에 여러 계산을 동시에 돌리는 **병렬(parallelism)**이죠. 이 글에서는 이 둘이 어떻게 다른지, F#이 Async<'T> 계산과 task { }를 어떻게 다루는지, 그리고 Async 모듈의 핵심 함수들이 각각 언제 어떻게 쓰면 좋은지 하나씩 짚어볼게요.
출처: Async and Task Programming - F# | Microsoft Learn
본문
Asynchrony(비동기성) 정의
앞에서 말한 "비동기는 여러 스레드를 쓰는 것과 무관하다"는 점은 조금 더 풀어볼 가치가 있어요. 서로 관계가 있긴 하지만 완전히 독립된 개념이 세 가지 있어요.
- Concurrency(동시성) — 여러 계산이 시간적으로 겹치는 구간에서 실행될 때.
- Parallelism(병렬성) — 여러 계산, 또는 하나의 계산을 이루는 여러 부분이 정확히 같은 시점에 실행될 때.
- Asynchrony(비동기성) — 하나 이상의 계산이 메인 프로그램 흐름과 분리되어 실행될 수 있을 때.
셋 다 서로 직교하는 개념이라, 함께 쓰일 때 특히 쉽게 혼동돼요. 예를 들어 여러 비동기 계산을 병렬로 돌려야 할 수도 있는데, 그렇다고 해서 병렬성이 비동기성을 의미하거나 그 반대를 의미하지는 않아요.
"asynchronous"라는 단어의 어원을 보면 두 부분으로 나눌 수 있어요.
- "a" — "아니다(not)"라는 뜻.
- "synchronous" — "같은 시점에(at the same time)"라는 뜻.
이 둘을 합치면 "asynchronous"는 곧 "같은 시점이 아님"이라는 뜻이 돼요. 그게 전부예요! 이 정의 안에 동시성이나 병렬성의 의미는 들어 있지 않아요. 실제로도 그렇고요.
실용적인 관점에서, F#의 비동기 계산은 메인 프로그램 흐름과 독립적으로 실행되도록 스케줄됩니다. 하지만 이 "독립 실행"이 동시성이나 병렬성을 의미하지도, 계산이 항상 백그라운드에서 일어난다는 뜻도 아니에요. 실제로 비동기 계산은 계산의 성격과 실행 환경에 따라 동기적으로 실행될 수도 있어요.
여기서 핵심은, 비동기 계산은 메인 프로그램 흐름과 독립적이라는 점이에요. 비동기 계산이 언제 어떻게 실행될지에 대한 보장은 거의 없지만, 계산을 조율하고 스케줄하는 방법은 몇 가지 있어요. 이 글의 나머지 부분에서는 F# 비동기성의 핵심 개념과, F#에 내장된 타입·함수·식의 사용법을 살펴볼게요.
핵심 개념 (Core concepts)
F#에서 비동기 프로그래밍은 두 가지 핵심 개념을 중심으로 이뤄져요. 바로 async 계산과 task(태스크)예요.
- async { } 식을 쓰는
Async<'T>타입 — 시작하면 task로 만들 수 있는, 조합 가능한 비동기 계산을 나타내요. - task { } 식을 쓰는
Task<'T>타입 — 실행 중인 .NET task를 나타내요.
일반적으로 새 코드를 작성할 때, task를 사용하는 .NET 라이브러리와 상호운용하거나 비동기 코드의 꼬리 호출(tailcall)이나 암시적 취소 토큰 전파에 의존하지 않는다면 async {…}보다 task {…}를 고려해 보는 게 좋아요.
async의 핵심 개념
"async" 프로그래밍의 기본 개념은 다음 예시에서 확인할 수 있어요.
open System
open System.IO
// Perform an asynchronous read of a file using 'async'
let printTotalFileBytesUsingAsync (path: string) =
async {
let! bytes = File.ReadAllBytesAsync(path) |> Async.AwaitTask
let fileName = Path.GetFileName(path)
printfn $"File {fileName} has %d{bytes.Length} bytes"
}
[<EntryPoint>]
let main argv =
printTotalFileBytesUsingAsync "path-to-file.txt"
|> Async.RunSynchronously
Console.Read() |> ignore
0
예시에서 printTotalFileBytesUsingAsync 함수의 타입은 string -> Async<unit>이에요. 이 함수를 호출한다고 해서 비동기 계산이 실제로 실행되지는 않아요. 대신 비동기로 실행될 작업의 명세(specification) 역할을 하는 Async<unit>을 하나 돌려줄 뿐이에요. 함수 본문에서는 Async.AwaitTask를 호출해서 ReadAllBytesAsync가 돌려준 결과를 적절한 타입으로 변환하고 있어요.
또 하나 중요한 줄이 바로 Async.RunSynchronously 호출이에요. 이 함수는 F# 비동기 계산을 실제로 실행하고 싶을 때 호출해야 하는 Async 모듈의 시작 함수 중 하나예요.
여기가 C#/Visual Basic 스타일의 async 프로그래밍과 근본적으로 다른 지점이에요. F#의 비동기 계산은 Cold task(아직 시작되지 않은 작업)로 생각할 수 있어요. 실제로 실행되려면 반드시 명시적으로 시작해야 하죠. 이 덕분에 C#이나 Visual Basic보다 비동기 작업을 조합하고 순서를 매기기가 훨씬 수월하다는 장점이 있어요.
비동기 계산 조합하기 (Combine)
앞선 예시를 발전시켜 계산 여럿을 조합(combine)하는 예시를 볼게요.
open System
open System.IO
let printTotalFileBytes path =
async {
let! bytes = File.ReadAllBytesAsync(path) |> Async.AwaitTask
let fileName = Path.GetFileName(path)
printfn $"File {fileName} has %d{bytes.Length} bytes"
}
[<EntryPoint>]
let main argv =
argv
|> Seq.map printTotalFileBytes
|> Async.Parallel
|> Async.Ignore
|> Async.RunSynchronously
0
보시다시피 main 함수에 요소가 꽤 늘어났어요. 개념적으로는 다음 일을 하는 거예요.
Seq.map으로 명령줄 인수를Async<unit>계산의 시퀀스로 변환.- 실행될 때
printTotalFileBytes계산들을 병렬로 스케줄·실행하는Async<'T[]>를 생성. - 병렬 계산을 실행하고 그 결과(
unit[])를 무시하는Async<unit>을 생성. Async.RunSynchronously로 조합된 전체 계산을 명시적으로 실행하고, 완료될 때까지 차단(block).
이 프로그램이 실행되면 printTotalFileBytes가 명령줄 인수마다 병렬로 실행돼요. 비동기 계산은 프로그램 흐름과 독립적으로 실행되므로, 각각이 언제 출력을 내고 언제 끝날지에 정해진 순서는 없어요. 계산들은 병렬로 스케줄되지만 실행 순서는 보장되지 않는다는 점을 기억하세요.
비동기 계산 순서대로 실행하기 (Sequence)
Async<'T>는 이미 실행 중인 작업이 아니라 작업의 명세이기 때문에, 더 정교한 변환도 쉽게 할 수 있어요. 다음은 일련의 Async 계산을 하나씩 차례로 실행하도록 순서를 매기는 예시예요.
let printTotalFileBytes path =
async {
let! bytes = File.ReadAllBytesAsync(path) |> Async.AwaitTask
let fileName = Path.GetFileName(path)
printfn $"File {fileName} has %d{bytes.Length} bytes"
}
[<EntryPoint>]
let main argv =
argv
|> Seq.map printTotalFileBytes
|> Async.Sequential
|> Async.Ignore
|> Async.RunSynchronously
|> ignore
이 코드는 병렬로 스케줄하는 대신 argv 요소의 순서대로 printTotalFileBytes가 실행되도록 해요. 각각의 다음 작업은 앞선 계산이 끝난 뒤에야 스케줄되기 때문에, 계산들이 겹치지 않게 차례로 실행돼요.
중요한 Async 모듈 함수
F#에서 async 코드를 작성하다 보면 대개 계산 스케줄링을 알아서 처리해 주는 프레임워크와 작업하게 돼요. 하지만 항상 그런 건 아니기 때문에, 비동기 작업을 스케줄하는 데 쓸 수 있는 여러 함수를 이해해 두는 게 좋아요.
F# 비동기 계산은 이미 실행 중인 작업의 표현이 아니라 작업의 명세이므로, 반드시 시작 함수로 명시적으로 시작해야 해요. 상황마다 유용한 Async 시작 메서드가 여럿 있는데, 이번에는 비교적 자주 쓰는 시작 함수들을 살펴볼게요.
Async.StartChild
비동기 계산 안에서 자식(child) 계산을 시작해요. 이렇게 하면 여러 비동기 계산을 동시(concurrently)에 실행할 수 있어요. 자식 계산은 부모 계산과 취소 토큰을 공유하므로, 부모 계산이 취소되면 자식 계산도 취소돼요.
Signature(시그니처):
computation: Async<'T> * ?millisecondsTimeout: int -> Async<Async<'T>>
언제 쓰나요:
- 여러 비동기 계산을 하나씩 순차 실행하는 게 아니라 동시에 실행하고 싶을 때(단, 병렬로 스케줄할 필요는 없을 때).
- 자식 계산의 수명을 부모 계산의 수명에 묶고 싶을 때.
주의할 점:
Async.StartChild로 여러 계산을 시작하는 것과 그것들을 병렬로 스케줄하는 것은 같지 않아요. 병렬로 스케줄하고 싶다면Async.Parallel을 쓰세요.- 부모 계산을 취소하면 그것이 시작한 모든 자식 계산도 취소돼요.
Async.StartImmediate
현재 운영체제 스레드에서 즉시 시작되는 비동기 계산을 실행해요. 계산이 진행되는 동안 호출 스레드에서 무언가를 갱신해야 할 때 유용해요. 예를 들어 비동기 계산이 진행률 막대 같은 UI를 갱신해야 하는 상황이라면 Async.StartImmediate를 쓰는 게 맞아요.
Signature:
computation: Async<unit> * ?cancellationToken: CancellationToken -> unit
언제 쓰나요:
- 비동기 계산이 진행되는 중간에 호출 스레드에서 무언가를 갱신해야 할 때.
주의할 점:
- 계산 안의 코드는 스케줄된 그 스레드에서 실행돼요. 그 스레드가 UI 스레드처럼 어떤 면에서 민감하다면 문제가 될 수 있어요. 그런 경우엔
Async.StartImmediate가 부적절할 가능성이 높아요.
Async.StartAsTask
스레드 풀(thread pool)에서 계산을 실행해요. 계산이 종료되면(결과 생성, 예외 발생, 취소 중 하나) 해당 상태에 맞춰 완료되는 Task
Signature:
computation: Async<'T> * ?taskCreationOptions: TaskCreationOptions * ?cancellationToken: CancellationToken -> Task<'T>
언제 쓰나요:
- 비동기 계산의 결과를 나타내는 Task
를 돌려주는 .NET API를 호출해야 할 때.
주의할 점:
- 이 호출은 Task 객체를 하나 더 할당하기 때문에, 자주 쓰면 오버헤드가 커질 수 있어요.
Async.Parallel
일련의 비동기 계산을 병렬로 실행하도록 스케줄하고, 제공된 순서대로 결과 배열을 돌려줘요. 병렬 처리 정도(degree of parallelism)는 maxDegreeOfParallelism 매개변수로 필요에 맞춰 조절/제한할 수 있어요.
Signature:
computations: seq<Async<'T>> * ?maxDegreeOfParallelism: int -> Async<'T[]>
언제 쓰나요:
- 여러 계산을 같은 시점에 실행해야 하고, 실행 순서에 의존하지 않을 때.
- 병렬로 스케줄된 계산들의 결과가 전부 완료될 때까지 기다려도 괜찮을 때.
주의할 점:
- 모든 계산이 끝나야만 결과 값 배열에 접근할 수 있어요.
- 계산은 스케줄된 대로 실행되므로, 그 실행 순서에 기대를 걸면 안 돼요.
Async.Sequential
전달된 순서대로 일련의 비동기 계산을 실행하도록 스케줄해요. 첫 번째 계산이 실행되고, 다음, 그다음 순으로 진행돼요. 어떤 계산도 병렬로 실행되지 않아요.
Signature:
computations: seq<Async<'T>> -> Async<'T[]>
언제 쓰나요:
- 여러 계산을 순서대로 실행해야 할 때.
주의할 점:
- 모든 계산이 끝나야만 결과 값 배열에 접근할 수 있어요.
- 계산은 전달된 순서대로 실행되므로, 결과가 돌아오기까지 시간이 더 걸릴 수 있어요.
Async.AwaitTask
주어진 TaskAsync<'T>로 돌려주는 비동기 계산을 만들어요.
Signature:
task: Task<'T> -> Async<'T>
언제 쓰나요:
- F# 비동기 계산 안에서 Task
를 돌려주는 .NET API를 사용할 때.
주의할 점:
- 예외는 Task Parallel Library의 규칙을 따라 AggregateException에 감싸져요. F# async가 평소에 예외를 드러내는 방식과 다르다는 점을 기억하세요.
Async.Catch
주어진 Async<'T>를 실행하고 Async<Choice<'T, exn>>를 돌려주는 비동기 계산을 만들어요. 주어진 Async<'T>가 성공하면 결과 값을 담은 Choice1Of2가 돌아오고, 완료 전에 예외가 발생하면 던져진 예외를 담은 Choice2Of2가 돌아와요. 여러 계산을 조합한 비동기 계산에 이 함수를 쓰고 그중 한 계산이 예외를 던지면, 계산 전체가 완전히 중단돼요.
Signature:
computation: Async<'T> -> Async<Choice<'T, exn>>
언제 쓰나요:
- 예외로 실패할 수 있는 비동기 작업을 수행하면서, 그 예외를 호출하는 쪽에서 처리하고 싶을 때.
주의할 점:
- 조합되거나 순서가 매겨진 비동기 계산을 쓸 때, 그 "내부" 계산 중 하나가 예외를 던지면 계산 전체가 완전히 멈춰요.
Async.Ignore
주어진 계산을 실행하되 그 결과를 버리는 비동기 계산을 만들어요.
Signature:
computation: Async<'T> -> Async<unit>
언제 쓰나요:
- 결과가 필요 없는 비동기 계산을 다룰 때. 비동기가 아닌 코드의
ignore함수와 비슷하다고 보면 돼요.
주의할 점:
Async.Start처럼Async<unit>을 요구하는 함수를 쓰기 위해Async.Ignore를 동원해야 하는 상황이라면, 결과를 버려도 괜찮은지 먼저 생각해 보세요. 타입 시그니처에 맞추려고 무작정 결과를 버리지 않는 게 좋아요.
Async.RunSynchronously
비동기 계산을 실행하고 호출 스레드에서 그 결과를 기다려요. 계산이 예외를 던지면 그 예외를 전파해요. 이 호출은 **차단(blocking)**돼요.
Signature:
computation: Async<'T> * ?timeout: int * ?cancellationToken: CancellationToken -> 'T
언제 쓰나요:
- 필요하다면 애플리케이션에서 딱 한 번만 써요 — 실행 파일의 진입점(entry point)에서.
- 성능을 크게 신경 쓰지 않고 여러 다른 비동기 작업을 한 번에 실행하고 싶을 때.
주의할 점:
Async.RunSynchronously를 호출하면 실행이 끝날 때까지 호출 스레드가 차단돼요.
Async.Start
스레드 풀에서 unit을 돌려주는 비동기 계산을 시작해요. 완료를 기다리지 않고, 예외 결과도 관찰하지 않아요. Async.Start로 시작된 중첩 계산은 자기 자신을 호출한 부모 계산과 독립적으로 시작되며, 그 수명이 어떤 부모 계산에도 묶이지 않아요. 부모 계산이 취소돼도 자식 계산은 취소되지 않아요.
Signature:
computation: Async<unit> * ?cancellationToken: CancellationToken -> unit
이런 경우에만 쓰세요:
- 결과를 돌려주지 않고(또는 결과 처리가 필요 없고) 무언가를 수행하기만 하는 비동기 계산일 때.
- 비동기 계산이 언제 완료되는지 알 필요가 없을 때.
- 비동기 계산이 어떤 스레드에서 실행되는지 신경 쓰지 않을 때.
- 실행으로 발생하는 예외를 인지하거나 보고할 필요가 없을 때.
주의할 점:
Async.Start로 시작된 계산이 던진 예외는 호출자에게 전파되지 않아요. 호출 스택이 완전히 풀려버리죠.Async.Start로 시작한 작업(예:printfn호출)은 프로그램 메인 스레드에서 그 효과를 일으키지 않아요.
.NET과 상호운용하기 (Interoperate with .NET)
async { } 프로그래밍을 쓰다 보면 async/await 스타일의 비동기 프로그래밍을 쓰는 .NET 라이브러리나 C# 코드베이스와 상호운용해야 할 때가 있어요. C#과 대부분의 .NET 라이브러리는 Task
한 가지 방법은 task { }를 써서 .NET task를 직접 작성하는 거예요. 아니면 Async.AwaitTask 함수를 써서 .NET 비동기 계산을 기다릴 수도 있어요.
let getValueFromLibrary param =
async {
let! value = DotNetLibrary.GetValueAsync param |> Async.AwaitTask
return value
}
Async.StartAsTask 함수를 쓰면 비동기 계산을 .NET 호출자에게 넘길 수 있어요.
let computationForCaller param =
async {
let! result = getAsyncResult param
return result
} |> Async.StartAsTask
값을 돌려주지 않는 .NET 비동기 계산, 즉 Task를 사용하는 API를 다룰 때는, Async<'T>를 Task로 변환하는 함수를 하나 더 추가해야 할 수 있어요.
module Async =
// Async<unit> -> Task
let startTaskFromAsyncUnit (comp: Async<unit>) =
Async.StartAsTask comp :> Task
입력으로 Task를 받는 Async.AwaitTask는 이미 있으니, 이 함수와 앞서 정의한 startTaskFromAsyncUnit을 함께 쓰면 F# async 계산에서 Task 타입을 시작하고 기다릴 수 있어요.
F#에서 .NET task 직접 작성하기
F#에서는 task { }로 task를 직접 작성할 수 있어요. 예를 들면 이렇게요.
open System
open System.IO
/// Perform an asynchronous read of a file using 'task'
let printTotalFileBytesUsingTasks (path: string) =
task {
let! bytes = File.ReadAllBytesAsync(path)
let fileName = Path.GetFileName(path)
printfn $"File {fileName} has %d{bytes.Length} bytes"
}
[<EntryPoint>]
let main argv =
let task = printTotalFileBytesUsingTasks "path-to-file.txt"
task.Wait()
Console.Read() |> ignore
0
예시에서 printTotalFileBytesUsingTasks 함수의 타입은 string -> Task<unit>이에요. 이 함수를 호출하면 task 실행이 시작돼요. 그리고 task.Wait() 호출이 그 task가 완료될 때까지 기다려 줘요.
멀티스레딩과의 관계
이 글 곳곳에서 스레드가 언급되지만, 꼭 기억해야 할 중요한 사실 두 가지가 있어요.
- 비동기 계산과 스레드 사이에는 고정된 연결(affinity)이 없어요. 단, 현재 스레드에서 명시적으로 시작한 경우는 예외예요.
- F#의 비동기 프로그래밍은 멀티스레딩을 위한 추상화가 아니에요.
예를 들어 어떤 계산은 작업의 성격에 따라 호출자 자신의 스레드에서 실행될 수도 있어요. 또 어떤 계산은 스레드 사이를 "오가며" — 네트워크 호출이 전송되는 동안 같은 "대기" 구간 사이에서 잠깐씩 스레드를 빌려 유용한 작업을 할 수도 있어요.
F#은 현재 스레드에서(또는 명시적으로 현재 스레드가 아닌 곳에서) 비동기 계산을 시작할 수 있는 기능을 일부 제공하긴 하지만, 비동기성 자체가 특정 스레딩 전략과 결부되는 것은 일반적이지 않아요.