F#의 비동기 프로그래밍

비동기 프로그래밍은 다양한 이유로 최신 애플리케이션에 필수적인 메커니즘입니다. 대부분의 개발자가 발생하는 두 가지 기본 사용 사례가 있습니다.

  • 요청 처리가 해당 프로세스 외부의 시스템 또는 서비스의 입력을 기다리는 동안 점유된 시스템 리소스를 최소화하면서 상당한 수의 동시 수신 요청을 처리할 수 있는 서버 프로세스를 제공합니다.
  • 백그라운드 작업을 동시에 진행하는 동안 응답성이 뛰어난 UI 또는 기본 스레드 유지 관리

백그라운드 작업에는 여러 스레드의 사용률이 포함되는 경우가 많지만 비동기 및 다중 스레딩의 개념을 별도로 고려하는 것이 중요합니다. 사실, 그들은 별도의 관심사이며, 하나는 다른 암시하지 않습니다. 이 문서에서는 별도의 개념을 자세히 설명합니다.

정의된 비동기

비동기는 여러 스레드의 사용률과 독립적이라는 이전 점은 좀 더 설명할 가치가 있습니다. 때로는 관련이 있지만 서로 엄격하게 독립적인 세 가지 개념이 있습니다.

  • 동시성; 여러 계산이 겹치는 기간 동안 실행되는 경우
  • 병렬 처리; 여러 계산 또는 단일 계산의 여러 부분이 정확히 동시에 실행되는 경우
  • 비동기; 기본 프로그램 흐름과 별도로 하나 이상의 계산을 실행할 수 있는 경우

세 가지 모두 직교 개념이지만, 특히 함께 사용될 때 쉽게 수축할 수 있습니다. 예를 들어 여러 비동기 계산을 병렬로 실행해야 할 수 있습니다. 이 관계가 병렬 처리 또는 비동기에서 서로를 암시한다는 의미는 아닙니다.

"비동기"라는 단어의 어원을 고려하면 다음 두 가지가 포함됩니다.

  • "a"는 "not"을 의미합니다.
  • "동기"는 "동시에"를 의미합니다.

이 두 용어를 함께 사용하면 "비동기"가 "동시에는 아님"을 의미합니다. 정말 간단하죠. 이 정의에는 동시성 또는 병렬 처리의 의미가 없습니다. 실제로도 마찬가지입니다.

실질적으로 F#의 비동기 계산은 기본 프로그램 흐름과 독립적으로 실행되도록 예약됩니다. 이 독립적인 실행은 동시성 또는 병렬 처리를 의미하지 않으며 항상 백그라운드에서 계산이 발생함을 의미하지도 않습니다. 실제로 비동기 계산은 계산의 특성과 계산이 실행되는 환경에 따라 동기적으로 실행할 수도 있습니다.

기본 방법은 비동기 계산이 기본 프로그램 흐름과 독립적이라는 것입니다. 비동기 계산이 실행되는 시기 또는 방법에 대한 보장은 거의 없지만 오케스트레이션 및 예약에 대한 몇 가지 방법이 있습니다. 이 문서의 나머지 부분에는 F# 비동기의 핵심 개념과 F#에 기본 제공되는 형식, 함수 및 식을 사용하는 방법이 설명되어 있습니다.

핵심 개념

F#에서 비동기 프로그래밍은 비동기 계산 및 작업의 두 가지 핵심 개념을 중심으로 합니다.

  • Async<'T> 작업을 구성하기 시작할 수 있는 구성 가능한 비동기 계산을 나타내는 식이 있는 형식 async { } 입니다.
  • Task<'T> 실행 중인 .NET 작업을 나타내는 식이 있는 형식 task { } 입니다.

일반적으로 작업을 사용하는 .NET 라이브러리와 상호 운용하고 비동기 코드 tailcall 또는 암시적 취소 토큰 전파에 의존하지 않는 경우 새 코드에서 이상 async {…} 사용을 task {…} 고려해야 합니다.

비동기의 핵심 개념

다음 예제에서는 "비동기" 프로그래밍의 기본 개념을 확인할 수 있습니다.

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>입니다. 함수를 호출해도 실제로 비동기 계산이 실행되지는 않습니다. 대신 비동기적으로 실행하려는 작업의 사양으로 작동하는 값을 반환 Async<unit> 합니다. 본문에서 호출 Async.AwaitTask 되어 결과를 ReadAllBytesAsync 적절한 형식으로 변환합니다.

또 다른 중요한 줄은 호출입니다 Async.RunSynchronously. 실제로 F# 비동기 계산을 실행하려는 경우 호출해야 하는 비동기 모듈 시작 함수 중 하나입니다.

이는 C#/Visual Basic 프로그래밍 스타일 async 과 기본적인 차이점입니다. F#에서는 비동기 계산을 콜드 작업으로 간주할 수 있습니다. 실제로 실행하려면 명시적으로 시작해야 합니다. C# 또는 Visual Basic보다 비동기 작업을 훨씬 쉽게 결합하고 시퀀스할 수 있으므로 몇 가지 이점이 있습니다.

비동기 계산 결합

다음은 계산을 결합하여 이전 항목을 기반으로 하는 예제입니다.

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 몇 가지 요소가 더 있습니다. 개념적으로 다음을 수행합니다.

  1. 를 사용하여 명령줄 인수를 계산 시 Async<unit> 퀀스로 Seq.map변환합니다.
  2. 계산을 Async<'T[]> 실행할 때 병렬로 예약하고 실행하는 printTotalFileBytes 계산을 만듭니다.
  3. 병렬 계산을 Async<unit> 실행하고 결과(즉 unit[], )를 무시하는 값을 만듭니다.
  4. 완료될 때까지 차단을 사용하여 전체 구성된 계산 Async.RunSynchronously을 명시적으로 실행합니다.

이 프로그램이 실행되면 printTotalFileBytes 각 명령줄 인수에 대해 병렬로 실행됩니다. 비동기 계산은 프로그램 흐름과 독립적으로 실행되므로 해당 정보를 인쇄하고 실행을 완료하는 정의된 순서가 없습니다. 계산은 병렬로 예약되지만 실행 순서는 보장되지 않습니다.

시퀀스 비동기 계산

Async<'T> 이미 실행 중인 작업이 아닌 작업 사양이므로 더 복잡한 변환을 쉽게 수행할 수 있습니다. 다음은 일련의 비동기 계산을 순서대로 정렬하여 연달아 실행하는 예제입니다.

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 됩니다. 각 연속 작업은 이전 계산 실행이 완료될 때까지 예약되지 않으므로 계산은 실행에서 겹치지 않도록 시퀀싱됩니다.

중요한 비동기 모듈 함수

F#에서 비동기 코드를 작성하는 경우 일반적으로 계산 일정을 처리하는 프레임워크와 상호 작용합니다. 그러나 항상 그런 것은 아니므로 비동기 작업을 예약하는 데 사용할 수 있는 다양한 함수를 이해하는 것이 좋습니다.

F# 비동기 계산은 이미 실행 중인 작업의 표현이 아닌 작업 사양 이므로 시작 함수로 명시적으로 시작해야 합니다. 다양한 컨텍스트에서 유용한 많은 비동기 시작 메서드 가 있습니다. 다음 섹션에서는 몇 가지 일반적인 시작 함수에 대해 설명합니다.

Async.StartChild

비동기 계산 내에서 자식 계산을 시작합니다. 이렇게 하면 여러 비동기 계산을 동시에 실행할 수 있습니다. 자식 계산은 부모 계산과 취소 토큰을 공유합니다. 부모 계산이 취소되면 자식 계산도 취소됩니다.

서명:

computation: Async<'T> * ?millisecondsTimeout: int -> Async<Async<'T>>

사용해야 하는 경우:

  • 한 번에 하나씩이 아닌 여러 비동기 계산을 동시에 실행하지만 병렬로 예약하지 않으려는 경우
  • 자식 계산의 수명을 부모 계산의 수명과 연결하려는 경우

주의해야 할 사항:

  • 여러 계산을 Async.StartChild 사용하여 시작하는 것은 병렬로 예약하는 것과 동일하지 않습니다. 계산을 병렬로 예약하려면 .를 사용합니다 Async.Parallel.
  • 부모 계산을 취소하면 시작된 모든 자식 계산의 취소가 트리거됩니다.

Async.StartImmediate

현재 운영 체제 스레드에서 즉시 시작하여 비동기 계산을 실행합니다. 이는 계산 중에 호출 스레드에서 무언가를 업데이트해야 하는 경우에 유용합니다. 예를 들어 비동기 계산에서 진행률 표시줄 Async.StartImmediate 업데이트와 같은 UI를 업데이트해야 하는 경우 사용해야 합니다.

서명:

computation: Async<unit> * ?cancellationToken: CancellationToken -> unit

사용해야 하는 경우:

  • 비동기 계산 중간에 있는 호출 스레드에서 무언가를 업데이트해야 하는 경우

주의해야 할 사항:

  • 비동기 계산의 코드는 예약되는 스레드에서 실행됩니다. 이 문제는 스레드가 UI 스레드와 같이 어떤 식으로든 중요한 경우 문제가 될 수 있습니다. 이러한 경우 Async.StartImmediate 사용하기가 부적절할 수 있습니다.

Async.StartAsTask

스레드 풀에서 계산을 실행합니다. Task<TResult> 계산이 종료되면 해당 상태에서 완료되는 값을 반환합니다(결과를 생성하거나 예외를 throw하거나 취소됨). 취소 토큰이 제공되지 않으면 기본 취소 토큰이 사용됩니다.

서명:

computation: Async<'T> * ?taskCreationOptions: TaskCreationOptions * ?cancellationToken: CancellationToken -> Task<'T>

사용해야 하는 경우:

  • 비동기 계산의 결과를 나타내는 API를 생성하는 Task<TResult> .NET API를 호출해야 하는 경우

주의해야 할 사항:

  • 이 호출은 추가 Task 개체를 할당하므로 자주 사용되는 경우 오버헤드가 증가할 수 있습니다.

Async.Parallel

비동기 계산 시퀀스를 병렬로 실행하도록 예약하여 제공된 순서대로 결과 배열을 생성합니다. 매개 변수를 지정하여 병렬 처리 수준을 선택적으로 튜닝/제한할 수 있습니다 maxDegreeOfParallelism .

서명:

computations: seq<Async<'T>> * ?maxDegreeOfParallelism: int -> Async<'T[]>

사용 시기:

  • 계산 집합을 동시에 실행해야 하며 실행 순서에 의존하지 않는 경우
  • 모두 완료될 때까지 병렬로 예약된 계산의 결과가 필요하지 않은 경우

주의해야 할 사항:

  • 모든 계산이 완료된 후에만 결과 값 배열에 액세스할 수 있습니다.
  • 계산은 예약될 때마다 실행됩니다. 이 동작은 실행 순서에 의존할 수 없음을 의미합니다.

Async.Sequential

전달되는 순서대로 실행할 비동기 계산 시퀀스를 예약합니다. 첫 번째 계산이 실행되고 다음 계산이 실행됩니다. 계산은 병렬로 실행되지 않습니다.

서명:

computations: seq<Async<'T>> -> Async<'T[]>

사용 시기:

  • 여러 계산을 순서대로 실행해야 하는 경우

주의해야 할 사항:

  • 모든 계산이 완료된 후에만 결과 값 배열에 액세스할 수 있습니다.
  • 계산은 이 함수에 전달되는 순서대로 실행되므로 결과가 반환되기 전에 더 많은 시간이 경과할 수 있습니다.

Async.AwaitTask

지정된 Task<TResult> 계산이 완료될 때까지 기다렸다가 결과를 반환하는 비동기 계산을 반환합니다. Async<'T>

서명:

task: Task<'T> -> Async<'T>

사용해야 하는 경우:

  • F# 비동기 계산 내에서 반환 Task<TResult> 하는 .NET API를 사용하는 경우

주의해야 할 사항:

  • 예외는 작업 병렬 라이브러리의 규칙에 따라 래핑 AggregateException 됩니다. 이 동작은 F# 비동기에서 일반적으로 예외를 노출하는 방식과 다릅니다.

Async.Catch

지정된 Async<'T>계산을 실행하고 반환하는 비동기 계산을 Async<Choice<'T, exn>>만듭니다. 지정된 Async<'T> 작업이 성공적으로 Choice1Of2 완료되면 결과 값과 함께 반환됩니다. 예외가 완료 Choice2of2 되기 전에 throw된 경우 예외가 발생하면서 반환됩니다. 자체적으로 많은 계산으로 구성된 비동기 계산에 사용되고 이러한 계산 중 하나가 예외를 throw하는 경우 포괄 계산이 완전히 중지됩니다.

서명:

computation: Async<'T> -> Async<Choice<'T, exn>>

사용해야 하는 경우:

  • 예외로 실패할 수 있는 비동기 작업을 수행하고 호출자에서 해당 예외를 처리하려는 경우

주의해야 할 사항:

  • 결합되거나 시퀀싱된 비동기 계산을 사용하는 경우 "내부" 계산 중 하나에서 예외가 throw되면 포괄 계산이 완전히 중지됩니다.

Async.Ignore

지정된 계산을 실행하지만 결과를 삭제하는 비동기 계산을 만듭니다.

서명:

computation: Async<'T> -> Async<unit>

사용해야 하는 경우:

  • 결과가 필요하지 않은 비동기 계산이 있는 경우 비동기 코드의 ignore 함수와 유사합니다.

주의해야 할 사항:

  • 사용하려는 경우 또는 필요한 Async<unit>다른 함수를 사용해야 Async.StartAsync.Ignore 하는 경우 dis카드 결과가 괜찮은지 고려합니다. 형식 서명에 맞게 결과를 카드 않도록 합니다.

Async.RunSynchronously

비동기 계산을 실행하고 호출 스레드에서 결과를 기다립니다. 계산에서 예외를 생성할 경우 예외를 전파합니다. 이 호출이 차단되고 있습니다.

서명:

computation: Async<'T> * ?timeout: int * ?cancellationToken: CancellationToken -> 'T

사용 시기:

  • 필요한 경우 애플리케이션에서 실행 파일의 진입점에서 한 번만 사용합니다.
  • 성능에 신경 쓰지 않고 다른 비동기 작업 집합을 한 번에 실행하려는 경우

주의해야 할 사항:

  • 호출 Async.RunSynchronously 은 실행이 완료될 때까지 호출 스레드를 차단합니다.

Async.Start

스레드 풀에서 반환 unit 되는 비동기 계산을 시작합니다. 완료될 때까지 기다리거나 예외 결과를 관찰하지 않습니다. 시작된 중첩된 계산은 해당 계산을 호출한 부모 계산과 Async.Start 독립적으로 시작됩니다. 해당 수명은 부모 계산에 연결되지 않습니다. 부모 계산이 취소되면 자식 계산이 취소되지 않습니다.

서명:

computation: Async<unit> * ?cancellationToken: CancellationToken -> unit

다음 경우에만 사용합니다.

  • 결과를 생성하지 않거나 처리해야 하는 비동기 계산이 있습니다.
  • 비동기 계산이 언제 완료되었는지 알 필요가 없습니다.
  • 비동기 계산이 실행되는 스레드는 신경 쓰지 않습니다.
  • 실행으로 인한 예외를 인식하거나 보고할 필요가 없습니다.

주의해야 할 사항:

  • 계산에서 발생한 Async.Start 예외는 호출자에게 전파되지 않습니다. 호출 스택이 완전히 해제됩니다.
  • 시작된 Async.Start 모든 작업(예: 호출printfn)은 프로그램 실행의 기본 스레드에 영향을 주지 않습니다.

.NET과 상호 운용

프로그래밍을 사용하는 async { } 경우 비동기/await 스타일 비동기 프로그래밍을 사용하는 .NET 라이브러리 또는 C# 코드베이스와 상호 운용해야 할 수 있습니다. C# 및 대부분의 .NET 라이브러리는 해당 및 형식을 핵심 추상화로 사용 Task<TResult>Task 하므로 F# 비동기 코드를 작성하는 방법이 바뀔 수 있습니다.

한 가지 옵션은 .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

사용하는 API(즉, 값을 반환하지 않는 .NET 비동기 계산)를 사용 Task 하려면 다음으로 변환할 추가 함수를 Async<'T> 추가해야 할 Task수 있습니다.

module Async =
    // Async<unit> -> Task
    let startTaskFromAsyncUnit (comp: Async<unit>) =
        Async.StartAsTask comp :> Task

이미 Async.AwaitTask 입력으로 허용하는 항목이 Task 있습니다. 이 함수와 이전에 정의된 startTaskFromAsyncUnit 함수를 사용하면 F# 비동기 계산에서 형식을 시작하고 대기 Task 할 수 있습니다.

F에서 직접 .NET 작업 작성#

F#에서는 다음을 사용하여 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.Wait() 를 기다리는 호출입니다.

다중 스레딩에 대한 관계

스레딩은 이 문서 전체에서 멘션 있지만 기억해야 할 두 가지 중요한 사항이 있습니다.

  1. 현재 스레드에서 명시적으로 시작하지 않는 한 비동기 계산과 스레드 간에 선호도가 없습니다.
  2. F#의 비동기 프로그래밍은 다중 스레딩에 대한 추상화가 아닙니다.

예를 들어 계산은 작업의 특성에 따라 호출자의 스레드에서 실제로 실행될 수 있습니다. 또한 계산은 스레드 간에 "점프"하여 "대기 중" 기간(예: 네트워크 호출이 전송 중일 때) 사이에 유용한 작업을 수행하는 데 약간의 시간 동안 스레드를 대여할 수 있습니다.

F#은 현재 스레드에서 비동기 계산을 시작할 수 있는 몇 가지 기능을 제공하지만(또는 현재 스레드에서는 명시적으로 시작하지 않음) 비동기는 일반적으로 특정 스레딩 전략과 연결되지 않습니다.

참고 항목