Bukankah pedoman async / menunggu penggunaan dalam C # bertentangan dengan konsep arsitektur yang baik dan lapisan abstraksi?

103

Pertanyaan ini menyangkut bahasa C #, tetapi saya berharap untuk mencakup bahasa lain seperti Java atau TypeScript.

Microsoft merekomendasikan praktik terbaik dalam menggunakan panggilan asinkron di .NET. Di antara rekomendasi ini, mari kita pilih dua:

  • ubah tanda tangan dari metode async sehingga mereka mengembalikan Task atau Task <> (dalam TypeScript, itu akan menjadi Janji <>)
  • ubah nama metode async menjadi diakhiri dengan xxxAsync ()

Sekarang, ketika mengganti komponen sinkron level rendah dengan async, ini berdampak pada tumpukan penuh aplikasi. Karena async / menunggu memiliki dampak positif hanya jika digunakan "all up up", itu berarti tanda tangan dan nama metode setiap lapisan dalam aplikasi harus diubah.

Arsitektur yang baik sering melibatkan penempatan abstraksi di antara setiap lapisan, sehingga mengganti komponen tingkat rendah dengan yang lain tidak terlihat oleh komponen tingkat atas. Dalam C #, abstraksi mengambil bentuk antarmuka. Jika kami memperkenalkan komponen baru, level rendah, async, setiap antarmuka dalam tumpukan panggilan perlu dimodifikasi atau diganti oleh antarmuka baru. Cara masalah dipecahkan (async atau sinkronisasi) di kelas pelaksana tidak tersembunyi (abstrak) untuk penelepon lagi. Penelepon harus tahu apakah itu sinkron atau asinkron.

Bukankah async / menunggu praktik terbaik yang bertentangan dengan prinsip "arsitektur yang baik"?

Apakah ini berarti bahwa setiap antarmuka (katakanlah IEnumerable, IDataAccessLayer) memerlukan rekanan async mereka (IAsyncEnumerable, IAsyncDataAccessLayer) sedemikian rupa sehingga dapat diganti dalam tumpukan saat beralih ke dependensi async?

Jika kita mendorong masalah sedikit lebih jauh, bukankah akan lebih mudah untuk menganggap setiap metode sebagai async (untuk mengembalikan Tugas <> atau Janji <>), dan untuk metode untuk menyinkronkan panggilan async ketika sebenarnya tidak async? Apakah ini sesuatu yang diharapkan dari bahasa pemrograman masa depan?

corentinaltepe
sumber
5
Meskipun ini terdengar seperti pertanyaan diskusi yang hebat, saya pikir ini terlalu berdasarkan pendapat untuk dijawab di sini.
Euforia
22
@ Euphoric: Saya pikir masalah yang dibuat di sini lebih dalam daripada pedoman C #, itu hanya gejala dari fakta bahwa mengubah bagian-bagian aplikasi menjadi perilaku asinkron dapat memiliki efek non-lokal pada keseluruhan sistem. Jadi nyali saya mengatakan kepada saya bahwa harus ada jawaban tanpa pendapat untuk ini, berdasarkan pada fakta teknis. Oleh karena itu, saya mendorong semua orang di sini untuk tidak menutup pertanyaan ini terlalu dini, sebagai gantinya mari kita tunggu jawaban apa yang akan datang (dan jika mereka terlalu keras, kita masih bisa memilih untuk menutup).
Doc Brown
25
@DocBrown Saya pikir pertanyaan yang lebih dalam di sini adalah "Bisakah bagian sistem diubah dari sinkron ke asinkron tanpa bagian yang bergantung padanya juga harus berubah?" Saya pikir jawabannya jelas "tidak". Dalam hal ini, saya tidak melihat bagaimana "konsep arsitektur dan pelapisan yang baik" berlaku di sini.
Euforia
6
@ Euphoric: kedengarannya seperti dasar yang bagus untuk jawaban tanpa pendapat ;-)
Doc Brown
5
@ Gherman: karena C #, seperti banyak bahasa, tidak dapat melakukan overload berdasarkan tipe return saja. Anda akan berakhir dengan metode async yang memiliki tanda tangan yang sama dengan rekan sinkronisasi mereka (tidak semua dapat mengambil CancellationToken, dan mereka yang memang ingin menyediakan default). Menghapus metode sinkronisasi yang ada (dan secara proaktif memecah semua kode) jelas bukan starter.
Jeroen Mostert

Jawaban:

111

Apa Warna Fungsi Anda?

Anda mungkin tertarik dengan Bob Nystrom's What Color Is Your Function 1 .

Dalam artikel ini, ia menggambarkan bahasa fiksi di mana:

  • Setiap fungsi memiliki warna: biru atau merah.
  • Fungsi merah dapat memanggil fungsi biru atau merah, tidak ada masalah.
  • Fungsi biru hanya dapat memanggil fungsi biru.

Meskipun fiktif, ini terjadi cukup teratur dalam bahasa pemrograman:

  • Di C ++, metode "const" hanya dapat memanggil metode "const" lainnya this.
  • Di Haskell, fungsi non-IO hanya dapat memanggil fungsi non-IO.
  • Dalam C #, fungsi sinkronisasi hanya dapat memanggil fungsi sinkronisasi 2 .

Seperti yang Anda sadari, karena aturan ini, fungsi merah cenderung menyebar di sekitar basis kode. Anda memasukkan satu, dan sedikit demi sedikit itu menjajah seluruh basis kode.

1 Bob Nystrom, selain ngeblog, juga bagian dari tim Dart dan telah menulis seri Crafting Interpreters kecil ini; sangat direkomendasikan untuk bahasa pemrograman / compiler apa pun.

2 Tidak sepenuhnya benar, karena Anda dapat memanggil fungsi async dan memblokirnya sampai kembali, tetapi ...

Batasan Bahasa

Ini pada dasarnya adalah batasan bahasa / run-time.

Bahasa dengan M: N threading, misalnya, seperti Erlang dan Go, tidak memiliki asyncfungsi: setiap fungsi berpotensi async dan "serat" -nya hanya akan ditangguhkan, ditukar, dan ditukar kembali ketika sudah siap lagi.

C # menggunakan model threading 1: 1, dan oleh karena itu memutuskan untuk memunculkan sinkronisitas dalam bahasa untuk menghindari pemblokiran utas secara tidak sengaja.

Di hadapan keterbatasan bahasa, pedoman pengkodean harus beradaptasi.

Matthieu M.
sumber
4
Fungsi IO memang memiliki kecenderungan untuk menyebar, tetapi dengan rajin, Anda sebagian besar dapat mengisolasi mereka ke fungsi dekat (dalam tumpukan saat memanggil) titik masuk kode Anda. Anda dapat melakukan ini dengan meminta fungsi-fungsi tersebut memanggil fungsi IO dan kemudian memiliki fungsi lain memproses output dari mereka dan mengembalikan hasil yang diperlukan untuk IO lebih lanjut. Saya menemukan bahwa gaya ini membuat basis kode saya lebih mudah untuk dikelola dan dikerjakan. Saya ingin tahu apakah ada akibat wajar dengan sinkronisitas.
jpmc26
16
Apa yang Anda maksud dengan threading "M: N" dan "1: 1"?
Kapten Man
14
@CaptainMan: threading 1: 1 berarti memetakan satu utas aplikasi ke satu utas OS, ini merupakan kasus dalam bahasa seperti C, C ++, Java atau C #. Sebaliknya, threading M: N berarti memetakan thread aplikasi M ke thread N OS; dalam kasus Go, utas aplikasi disebut "goroutine", dalam kasus Erlang, ini disebut "aktor", dan Anda mungkin juga pernah mendengarnya sebagai "benang hijau" atau "serat". Mereka menyediakan konkurensi tanpa membutuhkan paralelisme. Sayangnya artikel Wikipedia tentang topik ini agak jarang.
Matthieu M.
2
Ini agak terkait tetapi saya juga berpikir ide "fungsi warna" ini juga berlaku untuk fungsi yang memblokir input dari pengguna, misalnya, kotak dialog modal, kotak pesan, beberapa bentuk konsol I / O, dll, yang merupakan alat yang Kerangka kerja telah sejak awal.
jrh
2
@ MatthieuM. C # tidak memiliki satu utas aplikasi per satu utas OS dan tidak pernah melakukannya. Ini sangat jelas ketika Anda berinteraksi dengan kode asli, dan terutama jelas ketika berjalan di MS SQL. Dan tentu saja, rutinitas kooperatif selalu dimungkinkan (dan bahkan lebih sederhana dengan async); memang, itu adalah pola yang cukup umum untuk membangun UI responsif. Apakah itu secantik Erlang? Nggak. Tapi itu masih jauh dari C :)
Luaan
82

Anda benar ada kontradiksi di sini, tetapi bukan "praktik terbaik" yang buruk. Itu karena fungsi asynchronous pada dasarnya berbeda dari yang sinkron. Alih-alih menunggu hasil dari dependensinya (biasanya beberapa IO) itu menciptakan tugas yang akan ditangani oleh loop acara utama. Ini bukan perbedaan yang bisa disembunyikan dengan baik di bawah abstraksi.

max630
sumber
27
Jawabannya sesederhana IMO ini. Perbedaan antara proses sinkron dan asinkron bukanlah detail implementasi - ini adalah kontrak yang secara semantik berbeda.
Ant P
11
@AntP: Saya tidak setuju bahwa sesederhana itu; itu muncul dalam bahasa C #, tetapi tidak dalam bahasa Go misalnya. Jadi ini bukan properti inheren dari proses asinkron, ini masalah bagaimana proses asinkron dimodelkan dalam bahasa yang diberikan.
Matthieu M.
1
@ MatthieuM. Ya, tetapi Anda dapat menggunakan asyncmetode dalam C # untuk memberikan kontrak sinkron juga, jika Anda mau. Satu-satunya perbedaan adalah bahwa Go asinkron secara default sedangkan C # sinkron secara default. asyncmemberi Anda model pemrograman kedua - async adalah abstraksi (apa yang sebenarnya ia lakukan tergantung pada runtime, penjadwal tugas, konteks sinkronisasi, implementasi yang menunggu ...).
Luaan
6

Metode asinkron berperilaku berbeda dari yang sinkron, karena saya yakin Anda sadar. Saat runtime, untuk mengonversi panggilan async menjadi panggilan sinkron adalah sepele, tetapi sebaliknya tidak bisa dikatakan. Jadi karena itu logikanya menjadi, mengapa kita tidak membuat metode async dari setiap metode yang mungkin memerlukannya dan membiarkan pemanggil "mengkonversi" yang diperlukan untuk metode sinkron?

Dalam arti itu seperti memiliki metode yang melempar pengecualian dan lainnya yang "aman" dan tidak akan melempar bahkan jika terjadi kesalahan. Pada titik mana pembuat kode berlebihan untuk menyediakan metode ini yang jika tidak dapat dikonversi satu sama lain?

Dalam hal ini ada dua aliran pemikiran: satu adalah untuk membuat beberapa metode, masing-masing memanggil metode lain yang mungkin pribadi memungkinkan untuk memberikan parameter opsional atau perubahan kecil pada perilaku seperti tidak sinkron. Yang lain adalah untuk meminimalkan metode antarmuka untuk telanjang penting menyerahkannya kepada pemanggil untuk melakukan modifikasi yang diperlukan sendiri.

Jika Anda dari sekolah pertama, ada logika tertentu untuk mendedikasikan kelas menuju panggilan sinkron dan asinkron untuk menghindari menggandakan setiap panggilan. Microsoft cenderung mendukung aliran pemikiran ini, dan dengan konvensi, untuk tetap konsisten dengan gaya yang disukai oleh Microsoft, Anda juga harus memiliki versi Async, dengan cara yang hampir sama dengan antarmuka yang hampir selalu dimulai dengan "I". Biarkan saya menekankan bahwa itu tidak salah , karena itu lebih baik untuk menjaga gaya yang konsisten dalam proyek daripada melakukannya dengan "cara yang benar" dan secara radikal mengubah gaya untuk pengembangan yang Anda tambahkan ke proyek.

Yang mengatakan, saya cenderung menyukai sekolah kedua, yaitu untuk meminimalkan metode antarmuka. Jika saya pikir suatu metode dapat dipanggil dengan cara asinkron, metode untuk saya adalah asinkron. Penelepon dapat memutuskan apakah akan menunggu sampai tugas itu selesai sebelum melanjutkan. Jika antarmuka ini adalah antarmuka ke pustaka, ada cara yang lebih masuk akal untuk melakukannya dengan cara ini untuk meminimalkan jumlah metode yang harus Anda hilangkan atau sesuaikan. Jika antarmuka untuk penggunaan internal dalam proyek saya, saya akan menambahkan metode untuk setiap panggilan yang diperlukan di seluruh proyek saya untuk parameter yang disediakan dan tidak ada metode "ekstra", dan bahkan kemudian, hanya jika perilaku metode ini belum tercakup. dengan metode yang ada.

Namun, seperti banyak hal di bidang ini, sebagian besar bersifat subjektif. Kedua pendekatan memiliki pro dan kontra. Microsoft juga memulai konvensi penambahan huruf indikatif jenis di awal nama variabel, dan "m_" untuk menunjukkan itu adalah anggota, yang mengarah ke nama variabel seperti m_pUser. Maksud saya adalah bahwa bahkan Microsoft tidak bisa salah, dan dapat membuat kesalahan juga.

Yang mengatakan, jika proyek Anda mengikuti konvensi Async ini, saya akan menyarankan Anda untuk menghormatinya dan melanjutkan gaya. Dan hanya sekali Anda diberi proyek Anda sendiri, Anda dapat menulisnya dengan cara yang Anda inginkan.

Neil
sumber
6
"Saat runtime, untuk mengonversi panggilan async menjadi panggilan sinkron adalah sepele" Saya tidak yakin persis seperti itu. Di .NET, menggunakan .Wait()metode dan suka dapat menyebabkan konsekuensi negatif, dan dalam js, sejauh yang saya tahu, itu tidak mungkin sama sekali.
maks 630
2
@ max630 Saya tidak mengatakan tidak ada masalah bersamaan untuk dipertimbangkan, tetapi jika itu awalnya merupakan tugas yang sinkron , kemungkinan itu bukan membuat kebuntuan. Yang mengatakan, sepele bukan berarti "klik dua kali di sini untuk mengkonversi ke sinkron". Di js, Anda mengembalikan instance Janji, dan memanggil tekadnya.
Neil
2
ya itu benar-benar menyebalkan untuk mengubah async kembali untuk menyinkronkan
Ewan
4
@Neil Dalam javascript, bahkan jika Anda menelepon Promise.resolve(x)dan kemudian menambahkan panggilan balik ke sana, panggilan balik itu tidak akan segera dieksekusi.
NickL
1
@ Neil jika sebuah antarmuka memperlihatkan metode asinkron, berharap bahwa menunggu pada Tugas tidak akan membuat jalan buntu bukanlah asumsi yang baik. Jauh lebih baik bagi antarmuka untuk menunjukkan bahwa itu sebenarnya sinkron dalam tanda tangan metode, daripada janji dalam dokumentasi yang bisa berubah di versi yang lebih baru.
Carl Walsh
2

Mari kita bayangkan ada cara untuk memungkinkan Anda memanggil fungsi secara async tanpa mengubah tandatangan mereka.

Itu akan sangat keren dan tidak ada yang akan merekomendasikan Anda mengubah nama mereka.

Tapi, fungsi asinkron yang sebenarnya, bukan hanya yang menunggu fungsi async lain, tetapi level terendah memiliki beberapa struktur untuk mereka yang spesifik dengan sifat async mereka. misalnya

public class HTTPClient
{
    public HTTPResponse GET()
    {
        //send data
        while(!timedOut)
        {
            //check for response
            if(response) { 
                this.GotResponse(response); 
            }
            this.YouCanWait();
        }
    }

    //tell calling code that they should watch for this event
    public EventHander GotResponse
    //indicate to calling code that they can go and do something else for a bit
    public EventHander YouCanWait;
}

Itu adalah dua bit informasi yang dibutuhkan oleh kode panggilan untuk menjalankan kode dengan cara async yang disukai Taskdan asyncdiringkas.

Ada lebih dari satu cara untuk melakukan fungsi asinkron, async Taskhanya satu pola yang dibangun ke dalam kompiler melalui jenis kembali sehingga Anda tidak perlu secara manual menautkan acara

Ewan
sumber
0

Saya akan membahas poin utama dengan cara yang kurang C # ness dan lebih umum:

Bukankah async / menunggu praktik terbaik yang bertentangan dengan prinsip "arsitektur yang baik"?

Saya akan mengatakan bahwa itu hanya tergantung pada pilihan yang Anda buat dalam desain API Anda dan apa yang Anda izinkan kepada pengguna.

Jika Anda ingin satu fungsi API Anda hanya async, ada sedikit minat mengikuti konvensi penamaan. Selalu saja mengembalikan Tugas <> / Janji <> / Masa Depan <> / ... sebagai jenis pengembalian, ini adalah dokumentasi sendiri. Jika ingin jawaban yang disinkronkan, ia masih dapat melakukan itu dengan menunggu, tetapi jika ia selalu melakukan itu, itu membuat sedikit pelat baja.

Namun, jika Anda hanya menyinkronkan API Anda, itu berarti bahwa jika pengguna menginginkannya menjadi async, ia harus mengelola sendiri bagian async itu.

Ini dapat membuat banyak pekerjaan ekstra, namun juga dapat memberikan lebih banyak kontrol kepada pengguna tentang berapa banyak panggilan serentak yang ia izinkan, tempatkan waktu habis, retrys, dan sebagainya.

Dalam sistem besar dengan API besar, menerapkan sebagian besar dari mereka untuk disinkronkan secara default mungkin lebih mudah dan lebih efisien daripada mengelola secara independen setiap bagian dari API Anda, khususnya jika mereka berbagi sumber daya (sistem file, CPU, database, ...).

Bahkan untuk bagian yang paling kompleks, Anda dapat memiliki dua implementasi yang sempurna dari bagian yang sama dari API Anda, satu sinkron melakukan hal-hal praktis, satu asinkron dengan mengandalkan sinkron yang menangani hal-hal dan hanya mengelola konkurensi, beban, waktu tunggu, dan coba lagi .

Mungkin orang lain bisa berbagi pengalamannya dengan itu karena saya kurang pengalaman dengan sistem seperti itu.

Walfrat
sumber
2
@Miral Anda telah menggunakan "panggil metode async dari metode sinkronisasi" di kedua kemungkinan.
Adrian Wragg
@AdrianWragg Jadi saya lakukan; otak saya pasti memiliki kondisi balapan. Saya akan memperbaikinya.
Miral
Itu sebaliknya; itu sepele untuk memanggil metode async dari metode sinkronisasi, tetapi tidak mungkin untuk memanggil metode sinkronisasi dari metode async. (Dan hal-hal yang benar-benar berantakan adalah ketika seseorang mencoba melakukan yang terakhir, yang dapat menyebabkan kebuntuan.) Jadi, jika Anda harus memilih satu, async secara default adalah pilihan yang lebih baik. Sayangnya itu juga pilihan yang lebih sulit, karena implementasi async hanya dapat memanggil metode async.
Miral
(Dan dengan ini saya tentu saja berarti metode sinkronisasi pemblokiran . Anda dapat memanggil sesuatu yang membuat perhitungan terikat CPU murni secara sinkron dari metode async - walaupun Anda harus mencoba menghindari melakukannya kecuali Anda tahu Anda berada dalam konteks pekerja. daripada konteks UI - tetapi memblokir panggilan yang menunggu menganggur di kunci atau I / O atau untuk operasi lain adalah ide yang buruk.)
Miral