Siapakah di antara kita yang tidak memotong? Saya secara teratur menemukan kesalahan dalam kode asinkron dan melakukannya sendiri. Untuk menghentikan roda Samsara ini, saya berbagi dengan Anda kusen yang paling khas dari mereka yang kadang-kadang cukup sulit untuk ditangkap dan diperbaiki.

Teks ini terinspirasi oleh blog Stephen Clary , seorang pria yang tahu segalanya tentang daya saing, asinkron, multithreading, dan kata-kata menakutkan lainnya. Dia adalah penulis buku Concurrency di C # Cookbook , yang telah mengumpulkan sejumlah besar pola untuk bekerja dengan kompetisi.
Kebuntuan Asinkron Klasik
Untuk memahami kebuntuan tidak sinkron, sebaiknya cari tahu utas mana yang mengeksekusi metode yang dipanggil menggunakan kata kunci tunggu.
Pertama, metode ini akan menyelidiki rantai panggilan metode async hingga menemukan sumber asinkron. Bagaimana tepatnya sumber asynchrony diimplementasikan adalah topik yang berada di luar cakupan artikel ini. Sekarang untuk kesederhanaan, kami menganggap bahwa ini adalah operasi yang tidak memerlukan alur kerja sambil menunggu hasilnya, misalnya, permintaan basis data atau permintaan HTTP. Awal yang sinkron dari operasi semacam itu berarti bahwa sambil menunggu hasilnya di sistem akan ada setidaknya satu untai tertidur yang menghabiskan sumber daya tetapi tidak melakukan pekerjaan yang bermanfaat.
Dalam panggilan asinkron, kami jenis memecah aliran eksekusi perintah pada "sebelum" dan "setelah" operasi asinkron, dan dalam. NET tidak ada jaminan bahwa kode yang terletak setelah menunggu akan dieksekusi di utas yang sama dengan kode sebelum menunggu. Dalam kebanyakan kasus, ini tidak perlu, tetapi apa yang harus dilakukan ketika perilaku seperti itu penting bagi program untuk bekerja? Perlu menggunakan SynchronizationContext
. Ini adalah mekanisme yang memungkinkan Anda untuk menerapkan batasan tertentu pada utas di mana kode dieksekusi. Selanjutnya, kita akan membahas dua konteks sinkronisasi ( WindowsFormsSynchronizationContext
dan AspNetSynchronizationContext
), tetapi Alex Davis menulis dalam bukunya bahwa ada sekitar selusin dari mereka di .NET. Tentang SynchronizationContext
ditulis dengan baik di sini , di sini , dan di sini penulis telah menerapkan sendiri, yang sangat ia hormati.
Jadi, segera setelah kode tiba di sumber asynchrony, ia menyimpan konteks sinkronisasi, yang berada di properti thread-static dari SynchronizationContext.Current
, maka operasi asinkron memulai dan membebaskan utas saat ini. Dengan kata lain, saat kami menunggu penyelesaian operasi asinkron, kami tidak memblokir satu utas dan ini adalah keuntungan utama dari operasi asinkron dibandingkan dengan yang sinkron. Setelah menyelesaikan operasi asinkron, kita harus mengikuti instruksi yang terletak setelah sumber asinkron, dan di sini, untuk memutuskan di mana utas untuk mengeksekusi kode setelah operasi asinkron, kita perlu berkonsultasi dengan konteks sinkronisasi yang disimpan sebelumnya. Seperti yang dia katakan, kami akan melakukannya. Dia akan memberitahu Anda untuk mengeksekusi di utas yang sama dengan kode sebelum menunggu - kami akan mengeksekusi di utas yang sama, tidak akan mengatakan - kami akan mengambil utas pertama dari kolam.
Tetapi bagaimana jika, dalam kasus khusus ini, penting bagi kami bahwa kode setelah menunggu dijalankan di utas gratis dari kumpulan utas? Anda perlu menggunakan mantra ConfigureAwait(false)
. Nilai palsu diteruskan ke parameter continueOnCapturedContext
memberi tahu sistem bahwa utas apa pun dari kumpulan dapat digunakan. Dan apa yang terjadi jika pada saat pelaksanaan metode dengan menunggu tidak ada konteks sinkronisasi sama sekali ( SynchronizationContext.Current == null
), seperti misalnya dalam aplikasi konsol. Dalam hal ini, kami tidak memiliki batasan pada utas di mana kode harus dieksekusi setelah menunggu dan sistem akan mengambil utas pertama dari kumpulan, seperti dalam kasus ConfigureAwait(false)
.
Jadi apa itu kebuntuan asinkron?
Jalan buntu di WPF dan WinForms
Perbedaan antara aplikasi WPF dan WinForms adalah konteks sinkronisasi yang sangat. Konteks sinkronisasi WPF dan WinForms memiliki utas khusus - utas antarmuka pengguna. Ada satu utas UI per SynchronizationContext
dan hanya dari utas ini yang dapat berinteraksi dengan elemen antarmuka pengguna. Secara default, kode yang mulai bekerja di utas UI melanjutkan operasi setelah operasi asinkron di dalamnya.
Sekarang mari kita lihat sebuah contoh:
private void Button_Click(object sender, System.Windows.RoutedEventArgs e) { StartWork().Wait(); } private async Task StartWork() { await Task.Delay(100); var s = "Just to illustrate the instruction following await"; }
Apa yang terjadi ketika Anda memanggil
StartWork().Wait()
:
- Utas panggilan (dan ini adalah utas antarmuka pengguna) akan masuk ke metode
StartWork
dan StartWork
ke await Task.Delay(100)
. - Utas UI akan memulai operasi
Task.Delay(100)
asynchronous, dan itu akan mengembalikan kontrol ke metode Button_Click
, dan di sana metode Wait()
dari kelas Task
akan menunggu untuk itu. Ketika metode Wait()
dipanggil, utas UI akan memblokir hingga akhir operasi asinkron, dan kami berharap bahwa segera setelah selesai, utas UI akan segera mengambil eksekusi dan melangkah lebih jauh di sepanjang kode, namun, semuanya akan salah. - Segera setelah
Task.Delay(100)
selesai, utas UI pertama-tama harus terus menjalankan metode StartWork()
dan untuk ini diperlukan utas persis di mana eksekusi dimulai. Tetapi utas UI sekarang menunggu hasil operasi. StartWork()
: StartWork()
tidak dapat melanjutkan eksekusi dan mengembalikan hasilnya, dan Button_Click
sedang menunggu hasil yang sama, dan karena fakta bahwa eksekusi dimulai pada utas antarmuka pengguna, aplikasi hanya hang tanpa kesempatan untuk terus bekerja.
Situasi ini dapat ditangani dengan cukup sederhana dengan mengubah panggilan ke
Task.Delay(100)
menjadi
Task.Delay(100).ConfigureAwait(false)
:
private void Button_Click(object sender, System.Windows.RoutedEventArgs e) { StartWork().Wait(); } private async Task StartWork() { await Task.Delay(100).ConfigureAwait(false); var s = "Just to illustrate the instruction following await"; }
Kode ini akan bekerja tanpa kebuntuan, karena sekarang utas dari kumpulan dapat digunakan untuk menyelesaikan metode StartWork()
, daripada utas UI yang diblokir. Stephen Clary merekomendasikan menggunakan ConfigureAwait(false)
di semua "metode perpustakaan" di blog-nya, tetapi secara khusus menekankan bahwa menggunakan ConfigureAwait(false)
untuk mengobati kebuntuan bukanlah praktik yang baik. Sebagai gantinya, ia menyarankan TIDAK untuk menggunakan metode pemblokiran seperti Wait()
, Result
, GetAwaiter().GetResult()
dan GetAwaiter().GetResult()
semua metode untuk menggunakan async / tunggu, jika mungkin (yang disebut Async all the way principle).
Jalan buntu di ASP.NET
ASP.NET juga memiliki konteks sinkronisasi, tetapi memiliki batasan yang sedikit berbeda. Ini memungkinkan Anda untuk menggunakan hanya satu utas per permintaan sekaligus dan juga mengharuskan kode setelah menunggu dieksekusi di utas yang sama dengan kode sebelum menunggu.
Contoh:
public class HomeController : Controller { public ActionResult Deadlock() { StartWork().Wait(); return View(); } private async Task StartWork() { await Task.Delay(100); var s = "Just to illustrate the code following await"; } }
Kode ini juga akan menyebabkan kebuntuan, karena pada saat panggilan ke StartWork().Wait()
utas yang dibolehkan hanya akan diblokir dan akan menunggu operasi StartWork()
, dan itu tidak akan pernah berakhir, karena utas di mana eksekusi harus dilanjutkan sedang sibuk menunggu.
Ini semua diperbaiki oleh ConfigureAwait(false)
sama ConfigureAwait(false)
.
Jalan buntu di ASP.NET Core (sebenarnya tidak)
Sekarang mari kita coba menjalankan kode dari contoh untuk ASP.NET dalam proyek untuk ASP.NET Core. Jika kita melakukan ini, kita akan melihat bahwa tidak akan ada jalan buntu. Ini karena ASP.NET Core tidak memiliki konteks sinkronisasi . Hebat! Dan sekarang Anda dapat menutupi kode dengan memblokir panggilan dan tidak takut kebuntuan? Sebenarnya, ya, tapi ingat bahwa ini menyebabkan utas tertidur saat menunggu, yaitu, utas menghabiskan sumber daya, tetapi tidak melakukan pekerjaan yang bermanfaat.
Ingat bahwa penggunaan panggilan pemblokiran menghilangkan semua keuntungan pemrograman asinkron mengubahnya menjadi sinkron . Ya, kadang-kadang tanpa menggunakan Wait()
itu tidak akan berhasil untuk menulis sebuah program, tetapi alasannya harus serius.
Penggunaan Task.Run () yang salah
Metode Task.Run()
dibuat untuk memulai operasi di utas baru. Seperti layaknya metode yang ditulis dalam pola TAP, itu mengembalikan Task
atau Task<T>
dan orang-orang yang dihadapkan dengan async / menunggu untuk pertama kalinya memiliki keinginan yang besar untuk membungkus kode sinkron di Task.Run()
dan melihat hasil dari metode ini. Kode sepertinya menjadi tidak sinkron, tetapi pada kenyataannya, tidak ada yang berubah. Mari kita lihat apa yang terjadi dengan penggunaan Task.Run()
.
Contoh:
private static async Task ExecuteOperation() { Console.WriteLine($"Before: {Thread.CurrentThread.ManagedThreadId}"); await Task.Run(() => { Console.WriteLine($"Inside before sleep: {Thread.CurrentThread.ManagedThreadId}"); Thread.Sleep(1000); Console.WriteLine($"Inside after sleep: {Thread.CurrentThread.ManagedThreadId}"); }); Console.WriteLine($"After: {Thread.CurrentThread.ManagedThreadId}"); }
Hasil dari kode ini adalah:
Before: 1 Inside before sleep: 3 Inside after sleep: 3 After: 3
Di sini Thread.Sleep(1000)
adalah semacam operasi sinkron yang membutuhkan utas untuk menyelesaikan. Misalkan kita ingin membuat solusi kita asinkron dan agar operasi ini dapat di-eutanasia, kita membungkusnya dalam Task.Run()
.
Segera setelah kode mencapai metode Task.Run()
, utas lain diambil dari kumpulan utas dan kode yang kami Task.Run()
ke Task.Run()
dijalankan di dalamnya. Utas lama, seperti layaknya utas yang layak, kembali ke kolam dan menunggu untuk dipanggil lagi untuk melakukan pekerjaan. Thread baru mengeksekusi kode yang ditransmisikan, mencapai operasi sinkron, menjalankannya secara sinkron (menunggu hingga operasi selesai) dan melangkah lebih jauh di sepanjang kode. Dengan kata lain, operasi tetap sinkron: kami, seperti sebelumnya, menggunakan aliran selama pelaksanaan operasi sinkron. Satu-satunya perbedaan adalah bahwa kami menghabiskan waktu untuk mengganti konteks saat memanggil Task.Run()
dan kembali ke ExecuteOperation()
. Semuanya menjadi sedikit lebih buruk.
Harus dipahami bahwa meskipun pada baris Inside after sleep: 3
dan After: 3
kita melihat Id yang sama dari stream, konteks eksekusi benar-benar berbeda di tempat-tempat ini. ASP.NET lebih pintar dari kami dan mencoba menghemat sumber daya saat mengalihkan konteks dari kode di dalam Task.Run()
ke kode eksternal. Di sini dia memutuskan untuk tidak mengubah setidaknya aliran eksekusi.
Dalam kasus seperti itu, tidak masuk akal untuk menggunakan Task.Run()
. Sebaliknya, Clary menyarankan agar semua operasi tidak sinkron, yaitu, dalam kasus kami, mengganti Thread.Sleep(1000)
dengan Task.Delay(1000)
, tetapi ini, tentu saja, tidak selalu memungkinkan. Apa yang harus dilakukan jika kami menggunakan perpustakaan pihak ketiga yang kami tidak bisa atau tidak ingin menulis ulang dan menjadikannya tidak sinkron sampai akhir, tetapi karena satu dan lain alasan kami memerlukan metode async? Lebih baik menggunakan Task.FromResult()
untuk membungkus hasil metode vendor di Task. Ini, tentu saja, tidak akan membuat kode asinkron, tetapi setidaknya kita akan menghemat pada pengalihan konteks.
Lalu mengapa menggunakan Task.Run ()? Jawabannya sederhana: untuk operasi yang terikat CPU, ketika Anda perlu mempertahankan responsif UI atau memparalelkan perhitungan. Harus dikatakan di sini bahwa operasi yang terikat CPU bersifat sinkron. Itu untuk meluncurkan operasi sinkron dalam gaya asinkron yang Task.Run()
ditemukan.
Penyalahgunaan batal async
Kemampuan untuk menulis metode asinkron yang mengembalikan
void
ditambahkan untuk menulis pengendali event asinkron. Mari kita lihat mengapa mereka dapat menyebabkan kebingungan jika digunakan untuk tujuan lain:
- Anda tidak bisa menunggu hasilnya.
- Penanganan pengecualian melalui try-catch tidak didukung.
- Tidak mungkin untuk menggabungkan panggilan melalui
Task.WhenAll()
, Task.WhenAny()
dan metode serupa lainnya.
Dari semua alasan ini, poin paling menarik adalah penanganan pengecualian. Faktanya adalah bahwa dalam metode async yang mengembalikan Task
atau Task<T>
, pengecualian ditangkap dan dibungkus dalam objek Task
, yang kemudian akan diteruskan ke metode pemanggilan. Dalam artikelnya untuk MSDN, Clary menulis bahwa karena tidak ada nilai balik dalam metode async-void, tidak ada yang membungkus pengecualian dan mereka dilemparkan langsung dalam konteks sinkronisasi. Hasilnya adalah pengecualian yang tidak tertangani karena prosesnya macet, memiliki waktu untuk, mungkin, menulis kesalahan ke konsol. Anda bisa mendapatkan dan memesan pengecualian seperti itu dengan berlangganan acara AppDomain.UnhandledException
, tetapi Anda tidak akan lagi dapat menghentikan proses crash bahkan di pawang acara ini. Perilaku ini tipikal hanya untuk event handler, tetapi tidak untuk metode biasa, dari mana kami mengharapkan kemungkinan penanganan pengecualian standar melalui try-catch.
Misalnya, jika Anda menulis seperti ini di aplikasi ASP.NET Core, proses dijamin jatuh:
public IActionResult ThrowInAsyncVoid() { ThrowAsynchronously(); return View(); } private async void ThrowAsynchronously() { throw new Exception("Obviously, something happened"); }
Tapi ada baiknya mengubah jenis ThrowAsynchronously
metode ThrowAsynchronously
ke Task
(bahkan tanpa menambahkan kata kunci menunggu) dan pengecualian akan ditangkap oleh penangan kesalahan standar ASP.NET Core, dan proses akan terus hidup meskipun eksekusi.
Hati-hati dengan metode async-void - mereka dapat menempatkan Anda dalam prosesnya.
menunggu dalam metode garis tunggal
Antipattern terakhir tidak seseram yang sebelumnya. Intinya adalah bahwa tidak masuk akal untuk menggunakan async / menunggu dalam metode yang, misalnya, hanya meneruskan hasil dari metode async lainnya lebih lanjut, dengan kemungkinan pengecualian menggunakan menunggu dalam menggunakan .
Alih-alih kode ini:
public async Task MyMethodAsync() { await Task.Delay(1000); }
akan sangat mungkin (dan lebih disukai) untuk menulis:
public Task MyMethodAsync() { return Task.Delay(1000); }
Mengapa ini berhasil? Karena kata kunci yang menunggu dapat diterapkan ke objek seperti Tugas, dan tidak untuk metode yang ditandai dengan kata kunci async. Pada gilirannya, kata kunci async hanya memberi tahu kompiler bahwa metode ini perlu digunakan untuk mesin negara, dan membungkus semua nilai kembali dalam Task
(atau di objek seperti Tugas lain).
Dengan kata lain, hasil dari versi pertama dari metode ini adalah Task
, yang akan Completed
segera setelah menunggu Task.Delay(1000)
berakhir, dan hasil dari versi kedua dari metode ini adalah Task
, dikembalikan oleh Task.Delay(1000)
, yang akan menjadi Completed
secepat 1000 milidetik berlalu .
Seperti yang Anda lihat, kedua versi itu setara, tetapi pada saat yang sama, yang pertama membutuhkan lebih banyak sumber daya untuk membuat "body kit" yang tidak sinkron.
Alex Davis menulis bahwa biaya untuk menggunakan metode asinkron secara langsung dapat sepuluh kali lipat dari biaya untuk menggunakan metode sinkron , jadi ada sesuatu yang dapat dicoba.
UPD:Seperti yang ditunjukkan oleh komentar dengan tepat, melihat async / menunggu dari metode garis tunggal menyebabkan efek samping negatif. Misalnya, saat melempar pengecualian, metode yang melempar Tugas tidak akan terlihat di tumpukan. Oleh karena itu,
menghapus default tidak direkomendasikan secara default .
Posting Clary dengan parsing.