Pemrograman asinkron di F#

Pemrograman asinkron adalah mekanisme yang penting untuk aplikasi modern karena berbagai alasan. Ada dua kasus penggunaan utama yang akan dihadapi sebagian besar pengembang:

  • Menyajikan proses server yang dapat melayani sejumlah besar permintaan masuk bersamaan, sambil meminimalkan sumber daya sistem yang digunakan sementara pemrosesan permintaan menunggu input dari sistem atau layanan di luar proses tersebut
  • Mempertahankan antarmuka pengguna responsif atau alur utama sambil secara bersamaan memajukan pekerjaan latar belakang

Meskipun pekerjaan latar belakang sering kali melibatkan penggunaan beberapa alur, penting untuk mempertimbangkan konsep asinkron dan multi-alur secara terpisah. Faktanya, konsep asinkron dan multi-alur adalah masalah yang terpisah, dan tidak berhubungan satu sama lain. Artikel ini menjelaskan konsep terpisah secara lebih detail.

Definisi asinkron

Poin sebelumnya - bahwa asinkron tidak bergantung pada penggunaan banyak alur - perlu dijelaskan lebih lanjut. Ada tiga konsep yang terkadang terkait, tetapi sangat berbeda satu sama lain:

  • Konkurensi; ketika beberapa komputasi dijalankan dalam periode waktu yang tumpang tindih.
  • Paralelisme; ketika beberapa komputasi atau beberapa bagian dari satu komputasi berjalan pada waktu yang sama.
  • Asinkron; ketika satu atau lebih komputasi dapat dijalankan secara terpisah dari alur program utama.

Ketiganya adalah konsep ortogonal, tetapi dapat dengan mudah digabungkan, terutama jika digunakan bersama-sama. Misalnya, Anda mungkin perlu menjalankan beberapa komputasi asinkron secara paralel. Hubungan ini tidak berarti bahwa paralelisme atau asinkron menyiratkan satu sama lain.

Jika Anda mempertimbangkan etimologi dari kata "asinkron", ada dua bagian yang terlibat:

  • "a", yang berarti "tidak".
  • "sinkron", yang berarti "pada saat yang sama".

Saat Anda menggabungkan kedua istilah ini, Anda akan melihat bahwa "asinkron" berarti "tidak pada waktu yang sama". Itu saja! Tidak ada implikasi konkurensi atau paralelisme dalam definisi ini. Hal ini juga berlaku dalam praktik.

Dalam istilah praktis, komputasi asinkron dalam F# dijadwalkan untuk dijalankan secara terpisah dari alur program utama. Eksekusi terpisah ini tidak menyiratkan konkurensi atau paralelisme, juga tidak menyiratkan bahwa komputasi selalu terjadi di latar belakang. Faktanya, komputasi asinkron bahkan dapat dijalankan secara sinkron, bergantung pada sifat komputasi dan lingkungan tempat komputasi dijalankan.

Poin utama yang harus Anda miliki adalah bahwa komputasi asinkron tidak bergantung pada alur program utama. Meskipun ada sedikit jaminan tentang kapan atau bagaimana komputasi asinkron dijalankan, ada beberapa pendekatan untuk mengatur dan menjadwalkannya. Sisa artikel ini menjelajahi konsep inti untuk asinkron F# dan cara menggunakan jenis, fungsi, serta ekspresi yang dibangun ke dalam F#.

Konsep inti

Dalam F#, pemrograman asinkron dipusatkan di sekitar dua konsep inti: komputasi dan tugas asinkron.

  • Jenis Async<'T> dengan async { } ekspresi, yang mewakili komputasi asinkron yang dapat disusun yang dapat dimulai untuk membentuk tugas.
  • Jenis, Task<'T> dengan task { } ekspresi, yang mewakili tugas .NET yang dijalankan.

Secara umum, Anda harus mempertimbangkan untuk menggunakan task {…} di atas async {…} dalam kode baru jika Anda beroperasi dengan pustaka .NET yang menggunakan tugas, dan jika Anda tidak mengandalkan tailcall kode asinkron atau propagasi token pembatalan implisit.

Konsep inti asinkron

Anda dapat melihat konsep dasar pemrograman "asinkron" dalam contoh berikut:

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

Dalam contoh, fungsi printTotalFileBytesUsingAsync berjenis string -> Async<unit>. Memanggil fungsi sebenarnya tidak menjalankan komputasi asinkron. Sebagai gantinya, fungsi mengembalikan Async<unit> yang bertindak sebagai spesifikasi pekerjaan yang akan dijalankan secara asinkron. Fungsi memanggil Async.AwaitTask di dalamnya, yang mengonversi hasil ReadAllBytesAsync menjadi jenis yang sesuai.

Baris penting lainnya adalah panggilan ke Async.RunSynchronously. Ini adalah salah satu fungsi awal modul Asinkron yang harus Anda panggil jika Anda ingin benar-benar menjalankan komputasi asinkron F#.

Ini adalah perbedaan mendasar dengan gaya pemrograman async C#/Visual Basic. Dalam F#, komputasi asinkron dapat dianggap sebagai Tugas dingin. Komputasi asinkron harus secara eksplisit mulai benar-benar dijalankan. Komputasi asinkron memiliki beberapa keuntungan, karena memungkinkan Anda menggabungkan dan mengurutkan pekerjaan asinkron jauh lebih mudah daripada di C# atau Visual Basic.

Menggabungkan komputasi asinkron

Berikut adalah contoh yang dibangun di atas yang sebelumnya dengan menggabungkan komputasi:

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

Seperti yang Anda lihat, fungsi main memiliki beberapa elemen lagi. Secara konseptual, fungsi tersebut melakukan hal berikut:

  1. Mengubah argumen baris perintah menjadi urutan komputasi Async<unit> dengan Seq.map.
  2. Membuat Async<'T[]> yang menjadwalkan dan menjalankan komputasi printTotalFileBytes secara paralel saat dijalankan.
  3. Membuat Async<unit> yang akan menjalankan komputasi paralel dan mengabaikan hasilnya (yang merupakan unit[]).
  4. Jalankan keseluruhan komputasi yang tersusun secara eksplisit dengan Async.RunSynchronously, blokir hingga selesai.

Saat program ini berjalan, printTotalFileBytes berjalan secara paralel untuk setiap argumen baris perintah. Karena komputasi asinkron dijalankan secara terpisah dari alur program, tidak ada urutan yang ditentukan di mana komputasi asinkron mencetak informasinya dan menyelesaikan eksekusi. Komputasi akan dijadwalkan secara paralel, tetapi urutan eksekusi komputasi tidak dijamin.

Komputasi asinkron urutan

Karena Async<'T> adalah spesifikasi pekerjaan dan bukan tugas yang sudah berjalan, Anda dapat melakukan transformasi yang lebih rumit dengan mudah. Berikut adalah contoh yang mengurutkan kumpulan komputasi Asinkron sehingga komputasi Asinkron dijalankan satu demi satu.

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

Komputasi asinkron akan menjadwalkan printTotalFileBytes untuk dijalankan dalam urutan elemen argv daripada menjadwalkannya secara paralel. Karena setiap operasi yang berurutan tidak akan dijadwalkan sampai setelah komputasi sebelumnya selesai dijalankan, komputasi diurutkan sedemikian rupa sehingga tidak ada tumpang tindih dalam eksekusi komputasi.

Fungsi modul Asinkron yang penting

Saat Anda menulis kode asinkron di F#, Anda biasanya akan berinteraksi dengan kerangka kerja yang menangani penjadwalan komputasi untuk Anda. Tetapi, hal ini tidak selalu terjadi, jadi ada baiknya untuk memahami berbagai fungsi yang dapat digunakan untuk menjadwalkan pekerjaan asinkron.

Karena komputasi asinkron F# adalah spesifikasi pekerjaan dan bukan representasi pekerjaan yang sudah dijalankan, komputasi asinkron harus secara eksplisit dimulai dengan fungsi awal. Ada banyak Metode awal asinkron yang berguna dalam konteks yang berbeda. Bagian berikut menjelaskan beberapa fungsi awal yang lebih umum.

Async.StartChild

Memulai komputasi turunan dalam komputasi asinkron. Hal ini memungkinkan beberapa komputasi asinkron dijalankan secara bersamaan. Komputasi turunan berbagi token pembatalan dengan komputasi induk. Jika komputasi induk dibatalkan, komputasi turunan juga dibatalkan.

Tanda tangan:

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

Kapan menggunakan:

  • Saat Anda ingin menjalankan beberapa komputasi asinkron secara bersamaan daripada satu per satu, tetapi tidak menjadwalkannya secara paralel.
  • Jika Anda ingin mengaitkan masa pakai komputasi turunan dengan masa pakai komputasi induk.

Apa yang harus diperhatikan:

  • Memulai beberapa komputasi dengan Async.StartChild tidak sama dengan menjadwalkannya secara paralel. Jika Anda ingin menjadwalkan komputasi secara paralel, gunakan Async.Parallel.
  • Membatalkan komputasi induk akan memicu pembatalan semua komputasi turunan yang dimulai.

Async.StartImmediate

Menjalankan komputasi asinkron, segera dimulai pada alur sistem operasi saat ini. Komputasi asinkron berguna jika Anda perlu memperbarui sesuatu di alur panggilan selama komputasi. Misalnya, jika komputasi asinkron harus memperbarui antarmuka pengguna (seperti memperbarui bilah kemajuan), maka Async.StartImmediate harus digunakan.

Tanda tangan:

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

Kapan menggunakan:

  • Saat Anda perlu memperbarui sesuatu di alur panggilan di tengah komputasi asinkron.

Apa yang harus diperhatikan:

  • Kode dalam komputasi asinkron akan berjalan pada alur apa pun yang dijadwalkan. Hal ini dapat menjadi masalah jika alur tersebut sensitif, seperti alur antarmuka pengguna. Dalam kasus seperti itu, Async.StartImmediate kemungkinan tidak cocok untuk digunakan.

Async.StartAsTask

Menjalankan komputasi di kumpulan alur. Mengembalikan Task<TResult> yang akan diselesaikan pada status terkait setelah komputasi berakhir (menghasilkan hasil, memunculkan pengecualian, atau dibatalkan). Jika tidak ada token pembatalan yang diberikan, maka token pembatalan default akan digunakan.

Tanda tangan:

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

Kapan menggunakan:

  • Saat Anda perlu memanggil ke API .NET yang menghasilkan Task<TResult> untuk mewakili hasil komputasi asinkron.

Apa yang harus diperhatikan:

  • Panggilan ini akan mengalokasikan objek Task tambahan, yang dapat meningkatkan biaya jika sering digunakan.

Async.Parallel

Menjadwalkan urutan komputasi asinkron untuk dijalankan secara paralel, menghasilkan larik hasil sesuai urutan yang diberikan. Derajat paralelisme dapat disetel/dibatasi secara opsional dengan menetapkan parameter maxDegreeOfParallelism.

Tanda tangan:

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

Kapan harus menggunakannya:

  • Jika Anda perlu menjalankan serangkaian komputasi pada saat yang sama dan tidak bergantung pada urutan eksekusi komputasi.
  • Jika Anda tidak memerlukan hasil dari komputasi yang dijadwalkan secara paralel hingga semuanya selesai.

Apa yang harus diperhatikan:

  • Anda hanya dapat mengakses larik nilai yang dihasilkan setelah semua komputasi selesai.
  • Komputasi akan dijalankan setiap kali komputasi dijadwalkan. Perilaku ini berarti Anda tidak dapat mengandalkan urutan eksekusi komputasi.

Async.Sequential

Menjadwalkan urutan komputasi asinkron yang akan dieksekusi sesuai urutan yang diteruskan komputasi. Komputasi pertama akan dijalankan, kemudian yang berikutnya, dan seterusnya. Tidak ada komputasi yang akan dijalankan secara paralel.

Tanda tangan:

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

Kapan harus menggunakannya:

  • Jika Anda perlu menjalankan beberapa komputasi secara berurutan.

Apa yang harus diperhatikan:

  • Anda hanya dapat mengakses larik nilai yang dihasilkan setelah semua komputasi selesai.
  • Komputasi akan dijalankan dalam urutan yang diteruskan ke fungsi ini, yang dapat berarti bahwa lebih banyak waktu akan berlalu sebelum hasil ditampilkan.

Async.AwaitTask

Mengembalikan komputasi asinkron yang menunggu Task<TResult> tertentu selesai dan mengembalikan hasilnya sebagai Async<'T>

Tanda tangan:

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

Kapan menggunakan:

  • Saat Anda menggunakan API .NET yang mengembalikan Task<TResult> dalam komputasi asinkron F#.

Apa yang harus diperhatikan:

  • Pengecualian dibungkus dalam AggregateException mengikuti konvensi Pustaka Paralel Tugas; perilaku ini berbeda dari cara asinkron F# umumnya memunculkan pengecualian.

Async.Catch

Membuat komputasi asinkron yang menjalankan Async<'T> tertentu, mengembalikan Async<Choice<'T, exn>>. Jika Async<'T> tertentu berhasil diselesaikan, maka Choice1Of2 dikembalikan dengan nilai yang dihasilkan. Jika pengecualian dimunculkan sebelum selesai, maka Choice2of2 dikembalikan dengan pengecualian yang dimunculkan. Jika digunakan pada komputasi asinkron yang terdiri dari banyak komputasi, dan salah satu komputasi memunculkan pengecualian, komputasi yang mencakup akan dihentikan seluruhnya.

Tanda tangan:

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

Kapan menggunakan:

  • Saat Anda melakukan pekerjaan asinkron yang mungkin gagal dengan pengecualian dan Anda ingin menangani pengecualian tersebut di pemanggil.

Apa yang harus diperhatikan:

  • Saat menggunakan komputasi asinkron gabungan atau berurutan, komputasi yang mencakup akan berhenti sepenuhnya jika salah satu komputasi "internal" memunculkan pengecualian.

Async.Ignore

Membuat komputasi asinkron yang menjalankan komputasi tertentu tetapi menghilangkan hasil komputasi.

Tanda tangan:

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

Kapan menggunakan:

  • Ketika Anda memiliki komputasi asinkron yang hasilnya tidak diperlukan. Hal ini bersifat analog dengan fungsi ignore untuk kode non-asinkron.

Apa yang harus diperhatikan:

  • Jika Anda harus menggunakan Async.Ignore karena ingin menggunakan Async.Start atau fungsi lain yang memerlukan Async<unit>, pertimbangkan apakah membuang hasilnya diperbolehkan. Hindari membuang hasil hanya agar sesuai dengan tanda tangan jenis.

Async.RunSynchronously

Menjalankan komputasi asinkron dan menunggu hasilnya di alur panggilan. Menyebarkan pengecualian jika komputasi menghasilkan pengecualian. Panggilan ini memblokir.

Tanda tangan:

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

Kapan harus menggunakannya:

  • Jika Anda membutuhkannya, gunakan hanya sekali dalam aplikasi - pada titik entri untuk executable.
  • Ketika Anda tidak peduli dengan performa dan ingin menjalankan serangkaian operasi asinkron lainnya sekaligus.

Apa yang harus diperhatikan:

  • Memanggil Async.RunSynchronously memblokir alur panggilan hingga eksekusi selesai.

Async.Start

Memulai komputasi asinkron yang mengembalikan unit di kumpulan alur. Tidak menunggu penyelesaian komputasi asinkron dan/atau mengamati hasil pengecualian. Komputasi berlapis yang dimulai dengan Async.Start dimulai secara terpisah dari komputasi induk yang memanggil komputasi berlapis; masa pakai komputasi berlapis tidak terikat dengan komputasi induk. Jika komputasi induk dibatalkan, tidak ada komputasi turunan yang dibatalkan.

Tanda tangan:

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

Gunakan hanya saat:

  • Anda memiliki komputasi asinkron yang tidak memberikan hasil dan/atau memerlukan pemrosesan.
  • Anda tidak perlu mengetahui kapan komputasi asinkron selesai.
  • Anda tidak peduli alur mana yang menjalankan komputasi asinkron.
  • Anda tidak perlu mengetahui atau melaporkan pengecualian yang dihasilkan dari eksekusi.

Apa yang harus diperhatikan:

  • Pengecualian yang dimunculkan oleh komputasi yang dimulai dengan Async.Start tidak disebarkan ke pemanggil. Tumpukan panggilan akan dibuka sepenuhnya.
  • Pekerjaan apa pun (seperti memanggil printfn) yang dimulai dengan Async.Start tidak akan mempengaruhi alur utama eksekusi program.

Beroperasi secara bersamaan dengan .NET

Jika menggunakan pemrograman async { }, Anda mungkin perlu beroperasi secara bersamaan dengan pustaka .NET atau basis kode C# yang menggunakan pemrograman asinkron gaya async/await. Karena C# dan sebagian besar pustaka .NET menggunakan jenis Task<TResult> dan Task sebagai abstraksi intinya, hal ini dapat mengubah cara Anda menulis kode asinkron F# Anda.

Salah satu opsi adalah beralih dengan menulis tugas .NET secara langsung menggunakan task { }. Atau, Anda dapat menggunakan fungsi Async.AwaitTask untuk menunggu komputasi asinkron .NET:

let getValueFromLibrary param =
    async {
        let! value = DotNetLibrary.GetValueAsync param |> Async.AwaitTask
        return value
    }

Anda dapat menggunakan fungsi Async.StartAsTask untuk meneruskan komputasi asinkron ke pemanggil .NET:

let computationForCaller param =
    async {
        let! result = getAsyncResult param
        return result
    } |> Async.StartAsTask

Untuk bekerja dengan API yang menggunakan Task (yaitu, komputasi asinkron .NET yang tidak mengembalikan nilai), Anda mungkin perlu menambahkan fungsi tambahan yang akan mengonversi Async<'T> menjadi Task:

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

Sudah ada Async.AwaitTask yang menerima Task sebagai input. Dengan ini dan fungsi startTaskFromAsyncUnit yang ditentukan sebelumnya, Anda dapat memulai dan menunggu jenis Task dari komputasi asinkron F#.

Menulis tugas .NET langsung di F#

Di F#, Anda dapat menulis tugas secara langsung menggunakan task { }, misalnya:

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

Dalam contoh, fungsi printTotalFileBytesUsingTasks berjenis string -> Task<unit>. Memanggil fungsi akan mulai menjalankan tugas. Panggilan ke task.Wait() menunggu tugas selesai.

Hubungan dengan multi-alur

Meskipun alur disebutkan di seluruh artikel ini, ada dua hal penting untuk diingat:

  1. Tidak ada afinitas antara komputasi asinkron dan alur, kecuali jika secara eksplisit dimulai pada alur saat ini.
  2. Pemrograman asinkron dalam F# bukan merupakan abstraksi untuk multi-alur.

Misalnya, komputasi sebenarnya dapat berjalan di alur pemanggil komputasi, bergantung pada sifat pekerjaan. Komputasi juga dapat "beralih" di antara alur, menggunakan alur dalam waktu singkat untuk melakukan pekerjaan yang berguna di antara periode "menunggu" (seperti saat panggilan jaringan sedang transit).

Meskipun F# menyediakan beberapa kemampuan untuk memulai komputasi asinkron pada alur saat ini (atau secara eksplisit tidak pada alur saat ini), asinkron umumnya tidak terkait dengan strategi alur tertentu.

Lihat juga