Mengapa semua fungsi tidak boleh asinkron secara default?

107

Pola async-await dari .net 4.5 sedang mengubah paradigma. Hampir terlalu bagus untuk menjadi kenyataan.

Saya telah mem-port beberapa kode IO-berat ke async-await karena pemblokiran sudah berlalu.

Beberapa orang membandingkan async-await dengan infestasi zombi dan menurut saya cukup akurat. Kode asinkron menyukai kode asinkron lainnya (Anda memerlukan fungsi asinkron untuk menunggu fungsi asinkron). Jadi, semakin banyak fungsi menjadi asinkron dan ini terus berkembang di basis kode Anda.

Mengubah fungsi menjadi async adalah pekerjaan yang berulang dan tidak imajinatif. Lemparasync kata kunci dalam deklarasi, bungkus nilai yang dikembalikan dengan Task<>dan Anda sudah cukup banyak selesai. Agak meresahkan betapa mudahnya seluruh proses, dan segera skrip pengganti teks akan mengotomatiskan sebagian besar "porting" untuk saya.

Dan sekarang pertanyaannya .. Jika semua kode saya perlahan berubah menjadi asinkron, mengapa tidak menjadikan semuanya asinkron secara default?

Alasan jelas yang saya asumsikan adalah kinerja. Async-await memiliki overhead dan kode yang tidak perlu async, sebaiknya tidak. Tetapi jika kinerja adalah satu-satunya masalah, pasti beberapa pengoptimalan yang cerdas dapat menghilangkan overhead secara otomatis saat tidak diperlukan. Saya telah membaca tentang pengoptimalan "jalur cepat" , dan menurut saya pengoptimalan itu sendirilah yang menangani sebagian besar.

Mungkin ini sebanding dengan pergeseran paradigma yang dilakukan para pemulung. Di masa awal GC, membebaskan memori Anda sendiri pasti lebih efisien. Tetapi massa masih memilih pengumpulan otomatis untuk mendukung kode yang lebih aman, lebih sederhana yang mungkin kurang efisien (dan bahkan itu bisa dibilang tidak benar lagi). Mungkin ini yang harus terjadi di sini? Mengapa semua fungsi tidak boleh asinkron?

talkol
sumber
8
Berikan penghargaan kepada tim C # karena telah menandai peta. Seperti yang dilakukan ratusan tahun lalu, "naga berbaring di sini". Anda bisa melengkapi sebuah kapal dan pergi ke sana, kemungkinan besar Anda akan bertahan dengan matahari bersinar dan angin di punggung Anda. Dan terkadang tidak, mereka tidak pernah kembali. Sama seperti async / await, SO diisi dengan pertanyaan dari pengguna yang tidak mengerti bagaimana mereka keluar dari peta. Padahal mereka mendapat peringatan yang cukup bagus. Sekarang ini masalah mereka, bukan masalah tim C #. Mereka menandai naga.
Hans Passant
1
@Sayse meskipun Anda menghapus perbedaan antara fungsi sinkronisasi dan asinkron, fungsi panggilan yang diterapkan secara sinkron akan tetap sinkron (seperti contoh WriteLine Anda)
talkol
2
"Membungkus nilai pengembalian dengan Tugas <>" Kecuali metode Anda harus async(untuk memenuhi beberapa kontrak), ini mungkin ide yang buruk. Anda mendapatkan kerugian dari async (peningkatan biaya panggilan metode; kebutuhan untuk menggunakan awaitkode panggilan), tetapi tidak ada keuntungannya.
svick
1
Ini adalah pertanyaan yang sangat menarik tapi mungkin tidak cocok untuk SO. Kami mungkin mempertimbangkan untuk memigrasikannya ke Pemrogram.
Eric Lippert
1
Mungkin saya melewatkan sesuatu, tetapi saya memiliki pertanyaan yang persis sama. Jika async / await selalu berpasangan dan kode masih harus menunggu hingga selesai dijalankan, mengapa tidak hanya memperbarui kerangka .NET yang ada, dan membuat metode yang perlu menjadi asinkron - asinkron secara default TANPA membuat kata kunci tambahan? Bahasa ini sudah menjadi apa yang dirancang untuk melarikan diri - kata kunci spaghetti. Saya pikir mereka seharusnya berhenti melakukan itu setelah "var" disarankan. Sekarang kami memiliki "dinamis", asyn / menunggu ... dll ... Mengapa kalian tidak hanya javascript .NET-ify? ;))
monstro

Jawaban:

127

Pertama, terima kasih atas kata-kata baik Anda. Ini memang fitur yang luar biasa dan saya senang menjadi bagian kecil darinya.

Jika semua kode saya perlahan berubah menjadi asinkron, mengapa tidak menjadikan semuanya asinkron secara default?

Nah, Anda melebih-lebihkan; semua kode Anda tidak berubah menjadi asinkron. Saat Anda menambahkan dua bilangan bulat "biasa", Anda tidak menunggu hasilnya. Ketika Anda menambahkan dua bilangan bulat masa depan bersama-sama untuk mendapatkan bilangan bulat masa depan ketiga - karena itulah Task<int>, itu adalah bilangan bulat yang akan Anda dapatkan aksesnya di masa depan - tentu saja Anda kemungkinan besar akan menunggu hasilnya.

Alasan utama untuk tidak menjadikan semuanya asinkron adalah karena tujuan async / await adalah untuk mempermudah penulisan kode di dunia dengan banyak operasi latensi tinggi . Sebagian besar operasi Anda bukanlah latensi tinggi, jadi tidak masuk akal untuk mengambil hasil kinerja yang mengurangi latensi itu. Sebaliknya, beberapa kunci dari operasi Anda adalah latensi tinggi, dan operasi tersebut menyebabkan infestasi zombie dari async di seluruh kode.

jika kinerja adalah satu-satunya masalah, pasti beberapa pengoptimalan yang cerdas dapat menghilangkan overhead secara otomatis saat tidak diperlukan.

Secara teori, teori dan praktek serupa. Dalam praktiknya, mereka tidak pernah seperti itu.

Izinkan saya memberi Anda tiga poin terhadap transformasi semacam ini yang diikuti dengan pengoptimalan.

Poin pertama lagi adalah: async di C # / VB / F # pada dasarnya adalah bentuk kelanjutan yang terbatas . Sejumlah besar penelitian dalam komunitas bahasa fungsional telah mencari cara untuk mengidentifikasi cara mengoptimalkan kode yang memanfaatkan gaya penerusan lanjutan. Tim kompilator mungkin harus memecahkan masalah yang sangat mirip di dunia di mana "async" adalah defaultnya dan metode non-async harus diidentifikasi dan dide-async-ified. Tim C # tidak terlalu tertarik untuk menangani masalah penelitian terbuka, jadi itulah poin besar yang harus dibantah di sana.

Hal kedua yang bertentangan adalah bahwa C # tidak memiliki tingkat "transparansi referensial" yang membuat pengoptimalan semacam ini lebih mudah diatur. Yang saya maksud dengan "transparansi referensial" adalah properti yang nilai ekspresi tidak bergantung saat dievaluasi . Ekspresi seperti 2 + 2secara referensial transparan; Anda dapat melakukan evaluasi pada waktu kompilasi jika diinginkan, atau menundanya hingga waktu proses dan mendapatkan jawaban yang sama. Tetapi ekspresi seperti x+ytidak dapat dipindahkan seiring waktu karena x dan y mungkin berubah seiring waktu .

Asinkron membuat lebih sulit untuk bernalar tentang kapan efek samping akan terjadi. Sebelum asinkron, jika Anda mengatakan:

M();
N();

dan M()itu void M() { Q(); R(); }, dan N()itu void N() { S(); T(); }, dan Rdan Sefek samping hasil, maka Anda tahu bahwa efek samping R terjadi sebelum efek samping S. Tetapi jika Anda async void M() { await Q(); R(); }tiba-tiba itu keluar jendela. Anda tidak punya jaminan apakah R()akan terjadi sebelum atau sesudah S()(kecuali tentu saja M()ditunggu; tapi tentu saja Tasktidak perlu ditunggu sampai setelahnya N().)

Sekarang bayangkan bahwa properti ini tidak lagi mengetahui efek samping urutan apa yang terjadi berlaku untuk setiap bagian kode dalam program Anda kecuali yang berhasil di-de-async-ify oleh pengoptimal. Pada dasarnya Anda tidak memiliki petunjuk lagi ekspresi mana yang akan dievaluasi dalam urutan apa, yang berarti bahwa semua ekspresi harus transparan secara referensial, yang sulit dalam bahasa seperti C #.

Hal ketiga yang menentang adalah bahwa Anda kemudian harus bertanya "mengapa asinkron begitu istimewa?" Jika Anda akan berargumen bahwa setiap operasi harus benar-benar menjadi, Task<T>maka Anda harus dapat menjawab pertanyaan "mengapa tidak Lazy<T>?" atau "kenapa tidak Nullable<T>?" atau "kenapa tidak IEnumerable<T>?" Karena kita bisa melakukannya dengan mudah. Mengapa tidak setiap operasi diangkat menjadi nullable ? Atau setiap operasi dihitung dengan malas dan hasilnya disimpan dalam cache untuk nanti , atau hasil dari setiap operasi adalah urutan nilai, bukan hanya satu nilai . Anda kemudian harus mencoba untuk mengoptimalkan situasi di mana Anda tahu "oh, ini tidak boleh nol, jadi saya dapat menghasilkan kode yang lebih baik", dan seterusnya.

Intinya: tidak jelas bagi saya bahwa Task<T>sebenarnya itu istimewa untuk menjamin pekerjaan sebanyak ini.

Jika hal-hal semacam ini menarik minat Anda, saya sarankan Anda menyelidiki bahasa fungsional seperti Haskell, yang memiliki transparansi referensial yang lebih kuat dan mengizinkan semua jenis evaluasi out-of-order dan melakukan caching otomatis. Haskell juga memiliki dukungan yang jauh lebih kuat dalam sistem tipenya untuk jenis "pengangkatan monad" yang telah saya singgung.

Eric Lippert
sumber
Memiliki fungsi async yang dipanggil tanpa menunggu tidak masuk akal bagi saya (dalam kasus umum). Jika kita akan menghapus fitur ini, kompilator bisa memutuskan sendiri apakah suatu fungsi asinkron atau tidak (apakah itu memanggil await?). Kemudian kita bisa memiliki sintaks yang identik untuk kedua kasus (async dan sinkronisasi), dan hanya menggunakan menunggu di panggilan sebagai pembeda. Infestasi zombie diselesaikan :)
talkol
Saya telah melanjutkan diskusi di programmer sesuai permintaan Anda: programmers.stackexchange.com/questions/209872/…
talkol
@EricLippert - Jawaban yang sangat bagus seperti biasa :) Saya ingin tahu apakah Anda dapat menjelaskan "latensi tinggi"? Apakah ada kisaran umum dalam milidetik di sini? Saya hanya mencoba mencari tahu di mana garis batas bawah untuk menggunakan async karena saya tidak ingin menyalahgunakannya.
Travis J
7
@TravisJ: Panduannya adalah: jangan memblokir utas UI lebih dari 30 md. Lebih dari itu dan Anda menjalankan risiko jeda terlihat oleh pengguna.
Eric Lippert
1
Yang menantang saya adalah apakah sesuatu dilakukan secara sinkron atau tidak adalah detail implementasi yang dapat berubah. Tetapi perubahan dalam implementasi itu memaksa efek riak melalui kode yang memanggilnya, kode yang memanggilnya, dan seterusnya. Kami akhirnya mengubah kode karena kode itu bergantung padanya, yang merupakan sesuatu yang biasanya berusaha keras untuk dihindari. Atau kami gunakan async/awaitkarena sesuatu yang tersembunyi di bawah lapisan abstraksi mungkin tidak sinkron.
Scott Hannen
23

Mengapa semua fungsi tidak boleh asinkron?

Performa adalah salah satu alasannya, seperti yang Anda sebutkan. Perhatikan bahwa opsi "jalur cepat" yang Anda tautkan memang meningkatkan kinerja dalam kasus Tugas yang sudah selesai, tetapi masih memerlukan lebih banyak instruksi dan overhead dibandingkan dengan satu panggilan metode. Dengan demikian, bahkan dengan "jalur cepat" di tempat, Anda menambahkan banyak kerumitan dan overhead dengan setiap panggilan metode asinkron.

Kompatibilitas mundur, serta kompatibilitas dengan bahasa lain (termasuk skenario interop), juga akan menjadi masalah.

Yang lainnya adalah masalah kompleksitas dan niat. Operasi asinkron menambah kompleksitas - dalam banyak kasus, fitur bahasa menyembunyikannya, tetapi ada banyak kasus di mana metode pembuatan asyncbenar-benar menambah kompleksitas pada penggunaannya. Ini terutama benar jika Anda tidak memiliki konteks sinkronisasi, karena metode asinkron kemudian dapat dengan mudah menyebabkan masalah threading yang tidak terduga.

Selain itu, ada banyak rutinitas yang tidak, menurut sifatnya, tidak sinkron. Itu lebih masuk akal sebagai operasi sinkron. Memaksa Math.Sqrtuntuk menjadi Task<double> Math.SqrtAsyncakan konyol, misalnya, karena tidak ada alasan sama sekali untuk itu menjadi asynchronous. Alih-alih asyncmendorong melalui aplikasi Anda, Anda akan berakhir dengan awaitmenyebar ke mana-mana .

Ini juga akan mematahkan paradigma saat ini sepenuhnya, serta menyebabkan masalah dengan properti (yang secara efektif hanya merupakan pasangan metode .. apakah mereka akan menjadi asinkron juga?), Dan memiliki dampak lain di seluruh desain kerangka kerja dan bahasa.

Jika Anda melakukan banyak pekerjaan terikat IO, Anda akan cenderung menemukan bahwa menggunakan asyncpervasively adalah tambahan yang bagus, banyak dari rutinitas Anda juga async. Namun, ketika Anda mulai melakukan pekerjaan terikat CPU, secara umum, membuat sesuatu asyncsebenarnya tidak baik - ini menyembunyikan fakta bahwa Anda menggunakan siklus CPU di bawah API yang tampak asinkron, tetapi sebenarnya tidak selalu benar-benar asinkron.

Reed Copsey
sumber
Persis apa yang akan saya tulis (kinerja), kompatibilitas mundur mungkin menjadi hal lain, dll untuk digunakan dengan bahasa yang lebih lama yang tidak mendukung async / menunggu juga
Sayse
Membuat sqrt async tidak konyol jika kita hanya menghilangkan perbedaan antara fungsi sinkronisasi dan async
talkol
@ Talkol Saya rasa saya akan membalikkan ini - mengapa setiap panggilan fungsi harus mengambil kompleksitas asynchrony?
Reed Copsey
2
@talkol Saya berpendapat itu belum tentu benar - asinkron dapat menambahkan bug itu sendiri yang lebih buruk daripada memblokir ...
Reed Copsey
1
@talkol Bagaimana await FooAsync()lebih sederhana dari Foo()? Dan alih-alih efek domino kecil beberapa waktu, Anda memiliki efek domino besar sepanjang waktu dan Anda menyebutnya peningkatan?
svick
4

Selain kinerja - asinkron dapat menimbulkan biaya produktivitas. Pada klien (WinForms, WPF, Windows Phone) ini adalah keuntungan bagi produktivitas. Tetapi di server, atau dalam skenario non-UI lainnya, Anda membayar produktivitas. Anda tentu tidak ingin menggunakan asinkron secara default di sana. Gunakan saat Anda membutuhkan keunggulan skalabilitas.

Gunakan saat di sweet spot. Dalam kasus lain, jangan.

usr
sumber
2
+1 - Upaya sederhana untuk memiliki peta mental kode yang menjalankan 5 operasi asinkron secara paralel dengan urutan penyelesaian acak bagi kebanyakan orang akan memberikan rasa sakit yang cukup untuk sehari. Penalaran tentang perilaku kode asinkron (dan karenanya secara inheren paralel) jauh lebih sulit daripada kode sinkron lama yang baik ...
Alexei Levenkov
2

Saya yakin ada alasan bagus untuk membuat semua metode asinkron jika tidak diperlukan - ekstensibilitas. Metode pembuatan selektif asinkron hanya berfungsi jika kode Anda tidak pernah berkembang dan Anda tahu bahwa metode A () selalu terikat dengan CPU (Anda tetap menyinkronkannya) dan metode B () selalu terikat I / O (Anda menandainya sebagai asinkron).

Tapi bagaimana jika ada perubahan? Ya, A () sedang melakukan penghitungan tetapi di beberapa titik di masa mendatang Anda harus menambahkan pencatatan di sana, atau pelaporan, atau panggilan balik yang ditentukan pengguna dengan implementasi yang tidak dapat diprediksi, atau algoritme telah diperpanjang dan sekarang tidak hanya mencakup penghitungan CPU tetapi juga beberapa I / O? Anda harus mengonversi metode menjadi asinkron tetapi ini akan merusak API dan semua pemanggil di atas tumpukan juga perlu diperbarui (dan mereka bahkan dapat menjadi aplikasi berbeda dari vendor yang berbeda). Atau Anda perlu menambahkan versi asinkron bersama dengan versi sinkronisasi tetapi ini tidak membuat banyak perbedaan - menggunakan versi sinkronisasi akan memblokir dan karenanya hampir tidak dapat diterima.

Akan sangat bagus jika memungkinkan untuk membuat metode sinkronisasi yang ada menjadi asinkron tanpa mengubah API. Tetapi pada kenyataannya kami tidak memiliki opsi seperti itu, saya yakin, dan menggunakan versi async meskipun saat ini tidak diperlukan adalah satu-satunya cara untuk menjamin Anda tidak akan pernah mengalami masalah kompatibilitas di masa mendatang.

Alex
sumber
Meskipun ini tampak seperti komentar yang ekstrem, ada banyak kebenaran di dalamnya. Pertama, karena masalah ini: "ini akan merusak API dan semua pemanggil di atas tumpukan" async / await membuat aplikasi lebih erat digabungkan. Ini dapat dengan mudah melanggar prinsip Substitusi Liskov jika sebuah subclass ingin menggunakan async / await, misalnya. Selain itu, sulit membayangkan arsitektur layanan mikro di mana sebagian besar metode tidak memerlukan async / await.
ItsAllABadJoke