Asinkron di C # dan F #. Perangkap asynchrony di C #

Halo, Habr! Untuk perhatian Anda, saya persembahkan terjemahan artikel "Async di C # dan F # Asynchronous gotchas in C #" oleh Tomas Petricek.



Kembali pada bulan Februari, saya menghadiri KTT MVP Tahunan, sebuah acara yang diselenggarakan oleh Microsoft untuk MVP. Saya mengambil kesempatan ini untuk mengunjungi Boston dan New York juga, melakukan dua pembicaraan di F #, dan merekam ceramah Channel 9 tentang penyedia tipe . Selain kegiatan lain (seperti mengunjungi pub, mengobrol dengan orang lain tentang F #, dan tidur siang yang lama di pagi hari), saya juga dapat melakukan beberapa diskusi.



gambar



Satu diskusi (tidak di bawah NDA) adalah Klinik Async berbicara tentang kata kunci baru di C # 5.0 - async dan menunggu. Lucian dan Stephen berbicara tentang masalah umum yang dihadapi pengembang C # saat menulis program asinkron. Dalam posting ini, saya akan melihat beberapa masalah dari perspektif F #. Percakapannya cukup meriah, dan seseorang menggambarkan reaksi audiens F # sebagai berikut:



gambar

(Ketika MVP menulis di F # lihat contoh kode C #, mereka cekikikan seperti perempuan)



Mengapa ini terjadi? Ternyata banyak kesalahan umum tidak mungkin (atau kemungkinan besar lebih kecil) saat menggunakan model asinkron F # (yang muncul di F # 1.9.2.7, dirilis pada 2007 dan dikirimkan dengan Visual Studio 2008).



Kesalahan # 1: Async tidak bekerja secara asinkron



Mari langsung ke aspek rumit pertama dari model pemrograman asinkron C #. Perhatikan contoh berikut dan coba bayangkan dalam urutan apa baris akan dicetak (saya tidak dapat menemukan kode persis yang ditunjukkan dalam ceramah, tetapi saya ingat Lucian mendemonstrasikan sesuatu yang serupa):



  async Task WorkThenWait()
  {
      Thread.Sleep(1000);
      Console.WriteLine("work");
      await Task.Delay(1000);
  }
 
  void Demo() 
  {
      var child = WorkThenWait();
      Console.WriteLine("started");
      child.Wait();
      Console.WriteLine("completed");
  }


Jika Anda berpikir bahwa "mulai", "pekerjaan" dan "selesai" akan dicetak, Anda salah. Kode mencetak "kerja", "mulai" dan "selesai", coba sendiri! Penulis ingin mulai bekerja (dengan memanggil WorkThenWait) dan kemudian menunggu hingga tugas selesai. Masalahnya adalah WorkThenWait dimulai dengan melakukan beberapa komputasi berat (di sini Thread.Sleep) dan hanya setelah itu menggunakan menunggu.



Di C #, bagian pertama kode dalam metode asinkron berjalan secara sinkron (di utas pemanggil). Anda bisa memperbaikinya, misalnya dengan menambahkan await Task.Yield () di awal.



Sesuai kode F #



Di F #, ini bukan masalah. Saat menulis kode asynchronous di F #, semua kode di dalam blok async {…} ditangguhkan dan dijalankan nanti (ketika Anda secara eksplisit menjalankannya). Kode C # di atas sesuai dengan berikut ini di F #:



let workThenWait() = 
    Thread.Sleep(1000)
    printfn "work done"
    async { do! Async.Sleep(1000) }
 
let demo() = 
    let work = workThenWait() |> Async.StartAsTask
    printfn "started"
    work.Wait()
    printfn "completed"
  


Jelas, fungsi workThenWait tidak melakukan pekerjaan (Thread.Sleep) sebagai bagian dari komputasi asinkron, dan itu akan dijalankan ketika fungsi dipanggil (dan bukan ketika alur kerja asinkron dimulai). Pola umum di F # adalah membungkus seluruh tubuh fungsi dalam asinkron. Di F #, Anda akan menulis yang berikut ini, yang berfungsi seperti yang diharapkan:



let workThenWait() = async
{ 
    Thread.Sleep(1000)
    printfn "work done"
    do! Async.Sleep(1000) 
}
  


Kesalahan # 2: Mengabaikan Hasil



Berikut masalah lain dengan model pemrograman asinkron C # (artikel ini diambil langsung dari slide Lucian). Tebak apa yang terjadi ketika Anda menjalankan metode asynchronous berikut:



async Task Handler() 
{
   Console.WriteLine("Before");
   Task.Delay(1000);
   Console.WriteLine("After");
}
 


Apakah Anda mengharapkannya untuk mencetak "Sebelum", tunggu 1 detik, lalu cetak "Setelah"? Salah! Kedua pesan akan dicetak sekaligus, tanpa penundaan menengah. Masalahnya adalah Task.Delay mengembalikan sebuah Tugas dan kami lupa menunggu hingga selesai (menggunakan await).



Sesuai kode F #



Sekali lagi, Anda mungkin tidak akan menemukan ini di F #. Anda mungkin menulis kode yang memanggil Async.Sleep dan mengabaikan Async yang dikembalikan:



let handler() = async
{
    printfn "Before"
    Async.Sleep(1000)
    printfn "After" 
}
 


Jika Anda menempelkan kode ini ke Visual Studio, MonoDevelop, atau Try F #, Anda akan segera menerima peringatan:



peringatan FS0020: Ekspresi ini harus memiliki tipe unit, tetapi memiliki tipe Async ‹unit›. Gunakan abaikan untuk membuang hasil ekspresi, atau biarkan mengikat hasil ke nama.


peringatan FS0020: Ekspresi ini harus bertipe unit tetapi bertipe Async ‹unit›. Gunakan abaikan untuk membuang hasil ekspresi, atau biarkan mengaitkan hasil dengan nama.




Anda masih dapat mengkompilasi kode dan menjalankannya, tetapi jika Anda membaca peringatan tersebut, Anda akan melihat bahwa ekspresi mengembalikan Async dan Anda perlu menunggu hasilnya menggunakan do!:



let handler() = async 
{
   printfn "Before"
   do! Async.Sleep(1000)
   printfn "After" 
}
 


Kesalahan # 3: Metode asinkron yang mengembalikan void



Cukup banyak percakapan yang dikhususkan untuk metode void asynchronous. Jika Anda menulis async void Foo () {…}, maka compiler C # akan menghasilkan metode yang mengembalikan void. Tapi di bawah tenda, itu menciptakan dan menjalankan tugas. Artinya, Anda tidak dapat memprediksi kapan pekerjaan akan benar-benar selesai.



Dalam pidatonya, rekomendasi berikut dibuat untuk menggunakan pola async void:



gambar

(Demi Tuhan, hentikan penggunaan async void!)



Sejujurnya, perlu dicatat bahwa metode asynchronous void dapatberguna saat menulis penangan acara. Penangan acara harus mengembalikan kekosongan, dan mereka sering memulai beberapa pekerjaan yang berlanjut di latar belakang. Tapi saya rasa itu tidak terlalu berguna di dunia MVVM (meskipun itu pasti melakukan demo yang bagus di konferensi).



Izinkan saya menunjukkan masalah dengan potongan dari artikel Majalah MSDN tentang Pemrograman Asinkron di C #:



async void ThrowExceptionAsync() 
{
    throw new InvalidOperationException();
}

public void CallThrowExceptionAsync() 
{
    try 
    {
        ThrowExceptionAsync();
    } 
    catch (Exception) 
    {
        Console.WriteLine("Failed");
    }
}
 


Apakah menurut Anda kode ini akan mencetak "Gagal"? Saya harap Anda sudah memahami gaya artikel ini ...

Memang, pengecualian tidak akan ditangani, karena setelah memulai pekerjaan, ThrowExceptionAsync akan segera keluar, dan pengecualian akan dibuang di suatu tempat di thread latar belakang.



Sesuai kode F #



Jadi, jika Anda tidak perlu menggunakan fitur-fitur bahasa pemrograman, mungkin sebaiknya Anda tidak menyertakan fitur itu sejak awal. F # tidak mengizinkan Anda untuk menulis fungsi void asinkron - jika Anda menggabungkan tubuh fungsi dalam blok {…} asinkron, tipe yang dikembalikan adalah Async. Jika Anda menggunakan anotasi tipe dan memerlukan unit, Anda akan mendapatkan ketidakcocokan tipe.



Anda dapat menulis kode yang cocok dengan kode C # di atas menggunakan Async. Mulai:



let throwExceptionAsync() = async {
    raise <| new InvalidOperationException()  }

let callThrowExceptionAsync() = 
  try
     throwExceptionAsync()
     |> Async.Start
   with e ->
     printfn "Failed"


Pengecualian tidak akan ditangani di sini juga. Tapi apa yang terjadi lebih jelas, karena kita harus menulis Async. Mulailah secara eksplisit. Jika tidak, kita mendapat peringatan bahwa fungsi mengembalikan Async dan kita mengabaikan hasilnya (seperti di bagian sebelumnya "Mengabaikan Hasil").



Kesalahan # 4: Fungsi lambda asinkron yang mengembalikan kekosongan



Situasinya menjadi lebih rumit saat Anda meneruskan fungsi lambda asinkron ke metode sebagai delegasi. Dalam kasus ini, kompilator C # menyimpulkan tipe metode dari tipe delegasi. Jika Anda menggunakan Action delegate (atau yang serupa), maka compiler membuat fungsi asynchronous void yang memulai pekerjaan dan mengembalikan void. Jika Anda menggunakan delegasi Func, kompilator menghasilkan fungsi yang mengembalikan Tugas.



Ini contoh dari slide Lucian. Kapan kode berikutnya (yang benar) selesai - satu detik (setelah semua tugas selesai menunggu) atau segera?



Parallel.For(0, 10, async i => 
{
    await Task.Delay(1000);
});


Anda tidak akan dapat menjawab pertanyaan ini jika Anda tidak tahu bahwa hanya ada kelebihan beban untuk delegasi yang menerima Action - dan dengan demikian lambda akan selalu dikompilasi sebagai async void. Ini juga berarti bahwa menambahkan beberapa (mungkin muatan) beban akan menjadi perubahan yang mengganggu.



Sesuai kode F #



F # tidak memiliki "fungsi lambda asinkron" khusus, tetapi Anda dapat menulis fungsi lambda yang mengembalikan komputasi asinkron. Fungsi seperti itu akan mengembalikan Async, sehingga tidak bisa diteruskan sebagai argumen ke metode yang mengharapkan delegasi yang mengembalikan void. Kode berikut tidak dapat dikompilasi:



Parallel.For(0, 10, fun i -> async {
  do! Async.Sleep(1000) 
})


Pesan kesalahan hanya mengatakan bahwa jenis fungsi int -> Async tidak kompatibel dengan delegasi Action (di F # harus int -> unit):



kesalahan FS0041: Tidak ada kecocokan kelebihan beban untuk metode Untuk. Kelebihan yang tersedia ditunjukkan di bawah ini (atau di jendela Daftar Kesalahan).


kesalahan FS0041: Tidak ada kelebihan beban yang ditemukan untuk metode Untuk. Kelebihan yang tersedia ditunjukkan di bawah ini (atau di kotak daftar kesalahan).




Untuk mendapatkan perilaku yang sama seperti pada kode C # di atas, kita harus mulai secara eksplisit. Jika Anda ingin menjalankan urutan asinkron di latar belakang, ini mudah dilakukan dengan Async.Start (yang mengambil komputasi asinkron yang mengembalikan unit, menjadwalkannya, dan mengembalikan unit):



Parallel.For(0, 10, fun i -> Async.Start(async {
  do! Async.Sleep(1000) 
}))


Anda tentu saja dapat menulis ini, tetapi cukup mudah untuk melihat apa yang sedang terjadi. Juga mudah untuk melihat bahwa kita membuang-buang sumber daya, sebagai kekhasan Parallel. Karena ia melakukan komputasi intensif CPU (yang biasanya merupakan fungsi sinkron) secara paralel.



Kesalahan # 5: Tugas bersarang



Saya pikir Lucian memasukkan batu ini hanya untuk menguji kecerdasan orang-orang yang hadir, tapi ini dia. Pertanyaannya adalah, apakah kode berikut akan menunggu 1 detik di antara dua pin ke konsol?



Console.WriteLine("Before");
await Task.Factory.StartNew(
    async () => { await Task.Delay(1000); });
Console.WriteLine("After");


Tak disangka, tidak ada penundaan di antara kesimpulan tersebut. Bagaimana ini mungkin? Metode StartNew mengambil delegasi dan mengembalikan Tugas di mana T adalah tipe yang dikembalikan oleh delegasi. Dalam kasus kami, delegasi mengembalikan Tugas, jadi kami mendapatkan Tugas sebagai hasilnya. await hanya menunggu hingga tugas luar selesai (yang langsung mengembalikan tugas dalam), sedangkan tugas dalam diabaikan.



Di C #, ini bisa diperbaiki dengan menggunakan Task.Run daripada StartNew (atau dengan menghapus async / await di fungsi lambda).



Bisakah Anda menulis sesuatu seperti ini di F #? Kita bisa membuat tugas yang akan mengembalikan Async menggunakan fungsi Task.Factory.StartNew dan fungsi lambda yang mengembalikan blok asinkron. Untuk menunggu tugas selesai, kita perlu mengubahnya menjadi eksekusi asinkron menggunakan Async.AwaitTask. Ini berarti kita akan mendapatkan Async <Async>:



async {
  do! Task.Factory.StartNew(fun () -> async { 
    do! Async.Sleep(1000) }) |> Async.AwaitTask }


Sekali lagi, kode ini tidak dapat dikompilasi. Masalahnya adalah do! membutuhkan Async di sisi kanan, tetapi sebenarnya menerima Async <Async>. Dengan kata lain, kita tidak bisa mengabaikan hasilnya. Kita perlu melakukan sesuatu tentang ini secara eksplisit

(Anda dapat menggunakan Async.Ignore untuk mereproduksi perilaku C #). Pesan kesalahan mungkin tidak sejelas yang sebelumnya, tetapi memberikan gambaran umum:



error FS0001: Ekspresi ini diharapkan memiliki tipe Async ‹unit› tetapi disini memiliki tipe unit


galat FS0001: Ekspresi asinkron 'unit' diharapkan, jenis unit ada


Kesalahan # 6: Async tidak berfungsi



Berikut ini potongan kode bermasalah lainnya dari slide Lucian. Kali ini, masalahnya cukup sederhana. Cuplikan berikut mendefinisikan metode FooAsync asinkron dan memanggilnya dari Handler, tetapi kode tidak dijalankan secara asinkron:



async Task FooAsync() 
{
    await Task.Delay(1000);
}
void Handler() 
{
    FooAsync().Wait();
}


Sangat mudah untuk menemukan masalahnya - kami memanggil FooAsync (). Tunggu (). Ini berarti kita membuat tugas dan kemudian, menggunakan Tunggu, kita memblokir program hingga selesai. Penghapusan sederhana Tunggu memecahkan masalah, karena kami hanya ingin memulai tugas.



Anda dapat menulis kode yang sama di F #, tetapi alur kerja asinkron tidak menggunakan tugas .NET (awalnya dirancang untuk komputasi terikat CPU), melainkan menggunakan jenis F # Async, yang tidak disertakan dengan Tunggu. Artinya Anda harus menulis:



let fooAsync() = async {
    do! Async.Sleep(1000) }
let handler() = 
    fooAsync() |> Async.RunSynchronously


Tentu saja, kode seperti itu dapat ditulis secara tidak sengaja, tetapi jika Anda dihadapkan pada masalah asynchrony yang rusak , Anda akan dengan mudah melihat bahwa kode tersebut memanggil RunSynchronous, sehingga pekerjaan selesai - seperti namanya - secara sinkron .



Ringkasan



Pada artikel ini, saya telah melihat enam kasus di mana model pemrograman asinkron di C # berperilaku tidak terduga. Kebanyakan dari mereka didasarkan pada percakapan Lucian dan Stephen di MVP Summit, jadi terima kasih kepada mereka berdua untuk daftar perangkap umum yang menarik!



Untuk F #, saya mencoba menemukan cuplikan kode terdekat yang relevan menggunakan alur kerja asinkron. Dalam kebanyakan kasus, kompilator F # akan mengeluarkan peringatan atau kesalahan - atau model pemrograman tidak memiliki cara (langsung) untuk mengekspresikan kode yang sama. Saya rasa ini menegaskan pernyataan yang saya buat di posting blog sebelumnya : “Model pemrograman F # tampaknya lebih cocok untuk bahasa pemrograman fungsional (deklaratif). Saya juga berpikir itu membuatnya lebih mudah untuk bernalar tentang apa yang sedang terjadi. "



Akhirnya, artikel ini tidak boleh dipahami sebagai kritik destruktif asynchrony di C # :-). Saya sepenuhnya memahami mengapa desain C # mengikuti prinsip yang sama yang mengikutinya - untuk C # masuk akal untuk menggunakan Task (bukan Async terpisah), yang memiliki sejumlah konsekuensi. Dan saya dapat memahami alasan untuk solusi lain - ini mungkin cara terbaik untuk mengintegrasikan pemrograman asinkron di C #. Tetapi pada saat yang sama, saya pikir F ​​# melakukan pekerjaan yang lebih baik - sebagian karena kemampuan pengomposisiannya, tetapi yang lebih penting karena add-on keren seperti agen F # . Selain itu, asynchrony di F # juga memiliki masalah (kesalahan yang paling umum adalah fungsi rekursif tail harus digunakan kembali! Daripada melakukan!, Untuk menghindari kebocoran), tetapi ini adalah topik untuk posting blog terpisah.



PS Dari penerjemah. Artikel itu ditulis pada tahun 2013, tetapi menurut saya cukup menarik dan relevan untuk diterjemahkan ke dalam bahasa Rusia. Ini adalah posting pertama saya di Habré, jadi jangan terlalu keras.



All Articles