Munculnya async / menunggu di C # telah menyebabkan redefinisi tentang cara menulis kode paralel yang sederhana dan benar. Seringkali, menggunakan pemrograman asinkron, programmer tidak hanya tidak menyelesaikan masalah yang ada di utas, tetapi juga memperkenalkan yang baru. Kebuntuan dan penerbangan tidak pergi ke mana pun - mereka hanya menjadi lebih sulit untuk didiagnosis.
Dmitry Ivanov - Analisis Perangkat Lunak TeamLead di Huawei, mantan techlide JetBrains Rider dan pengembang inti ReSharper: struktur data, cache, multithreading, dan pembicara reguler di konferensi DotNext .Di bawah cutscene - rekaman video dan transkrip teks dari laporan Dmitry dari konferensi DotNext 2019 Piter.Narasi lebih lanjut atas nama pembicara.Dalam kode multi-utas atau asinkron, sesuatu sering rusak. Alasannya bisa jadi jalan buntu dan ras. Sebagai aturan, sebuah perlombaan crash sekali dari seribu, seringkali tidak secara lokal, tetapi hanya pada server build, dan dibutuhkan beberapa hari untuk menangkapnya. Saya yakin bagi banyak orang ini adalah situasi yang akrab.Selain itu, melihat kode asinkron bahkan oleh pengembang berpengalaman, saya mendapati diri saya berpikir bahwa beberapa hal dapat ditulis tiga kali lebih pendek dan lebih tepat.Ini menunjukkan bahwa masalahnya bukan pada orang, tetapi pada instrumen. Orang-orang hanya menggunakan alat dan ingin menyelesaikan masalah mereka. Alat itu sendiri memiliki sejumlah besar kemampuan (kadang-kadang bahkan berlebihan), pengaturan, konteks tersirat, yang mengarah pada fakta bahwa sangat mudah digunakan secara tidak benar. Mari kita coba mencari tahu cara menggunakan async / menunggu dan bekerja dengan kelas Task
di .NET.Rencana
- Masalah dengan pendekatan yang diselesaikan dengan async / menunggu.
- Contoh desain kontroversial.
- Sebuah tugas dari kehidupan nyata yang akan kita selesaikan secara tidak sinkron.
Async / menunggu dan masalah yang harus diselesaikan
Mengapa kita perlu async / menunggu? Katakanlah kita memiliki kode yang berfungsi dengan memori bersama yang dibagikan.Pada awal pekerjaan, kami membaca permintaan, dalam hal ini, file dari antrian pemblokiran (misalnya, dari Internet atau dari disk), menggunakan permintaan pemblokiran Dequeue (permintaan pemblokiran akan ditandai dengan warna merah pada gambar dengan contoh).Pendekatan ini membutuhkan banyak utas, dan setiap utas membutuhkan sumber daya, membuat beban pada penjadwal. Tapi ini bukan masalah utama. Misalkan orang dapat menulis ulang sistem operasi sehingga sistem ini mendukung seratus ribu dan sejuta utas. Tetapi masalah utama adalah bahwa beberapa utas tidak dapat diambil. Misalnya, Anda memiliki utas antarmuka pengguna. Tidak ada kerangka kerja UI normal yang memadai di mana akses ke data tidak hanya dari satu utas, belum. Utas UI tidak dapat diblokir. Dan agar tidak memblokirnya, kita memerlukan kode asinkron.Sekarang mari kita bicara tentang tugas kedua. Setelah kami membaca file, itu perlu diproses. Kami akan melakukannya secara paralel.Banyak dari Anda telah mendengar bahwa paralelisme tidak sama dengan asinkron. Dalam hal ini, muncul pertanyaan: bisakah asynchrony membantu menulis kode paralel yang lebih kompak, cantik, dan lebih cepat?Tugas terakhir adalah bekerja dengan memori bersama. Apakah kita perlu menyeret mekanisme ini dengan kunci, sinkronisasi ke kode asinkron, atau bisakah ini dihindari? Bisakah async / menunggu bantuan dengan ini?Jalan ke async / tunggu
Mari kita lihat evolusi pemrograman asinkron secara umum di dunia dan .NET.Telepon balik
Void Foo(params, Action callback) {…}
Void OurMethod() {
…
Foo(params,() =>{
…
});
}
Pemrograman asinkron dimulai dengan callback. Artinya, pertama Anda perlu memanggil beberapa bagian dari kode secara sinkron, dan bagian kedua - secara tidak sinkron. Misalnya, Anda membaca dari file, dan ketika data siap, entah bagaimana akan dikirimkan kepada Anda. Bagian asinkron ini dilewatkan sebagai panggilan balik .Lebih banyak panggilan balik
void Foo(params, Action callback) {...}
void Bar(Action callback) {...}
void Baz(Action callback) {...}
void OurMethod() {
...
Foo(params, () => {
...
Bar(() => {
Baz(() => {
});
});
});
}
Dengan demikian, dari satu panggilan balik Anda dapat mendaftarkan panggilan balik lain , dari mana Anda dapat mendaftarkan panggilan balik ketiga, dan pada akhirnya semuanya berubah menjadi Neraka Panggilan Balik .
Panggilan balik: pengecualian
void Foo(params, Action onSuccess, Action onFailure) {...}
void OurMethod() {
...
Foo(params, () => {
...
},
() => {
...
});
}
Bagaimana cara bekerja dengan pengecualian? Misalnya, ReSharper, ketika secara terpisah merespons pengecualian dan eksekusi yang baik, tidak menunjukkan bagian kode yang paling indah - ada panggilan balik terpisah untuk situasi yang luar biasa dan untuk kelanjutan yang sukses. Hasilnya hanya seperti panggilan balik neraka , tetapi tidak linier, tetapi seperti pohon, yang bisa sangat membingungkan.
Dalam .NET, pendekatan panggilan balik pertama disebut Asynchronous Programming Model (APM). Metode ini akan dipanggil AsyncCallback
, yang pada dasarnya sama dengan Action
, tetapi pendekatan tersebut memiliki beberapa fitur. Pertama-tama, metode harus dimulai dengan kata "Mulai" (membaca dari file adalah BeginRead), yang mengembalikan beberapa AsyncResult
. DiriAsyncResult
- Ini adalah pawang yang tahu bahwa operasi telah selesai dan yang memiliki mekanisme WaitHandle
. Anda WaitHandle
dapat menunggu, menunggu operasi selesai secara tidak sinkron. Di sisi lain, Anda dapat menelepon EndOperation
, yaitu, membuat EndRead
dan menggantung secara bersamaan (yang sangat mirip dengan properti Task.Result
).Pendekatan ini memiliki sejumlah masalah. Pertama, itu tidak melindungi kita dari neraka panggilan balik . Kedua, masih sepenuhnya tidak jelas apa yang harus dilakukan dengan pengecualian. Ketiga, tidak jelas di utas mana panggilan balik ini akan dipanggil - kami tidak memiliki kendali atas panggilan itu. Keempat, muncul pertanyaan, bagaimana menggabungkan potongan kode dengan panggilan balik?
Model kedua disebut Pola Asynchronous Berbasis Acara. Ini adalah pendekatan panggilan balik reaktif. Ide dari metode ini adalah bahwa kita meneruskan ke metode OperationNameAsync
beberapa objek yang memiliki acara Selesai dan berlangganan ke acara ini. Seperti yang Anda perhatikan, BeginOperationName
berubah menjadi OperationNameAsync
. Kebingungan dapat terjadi ketika Anda masuk ke kelas Socket, di mana dua pola dicampur: ConnectAsync
dan BeginConnect
.Harap dicatat bahwa Anda harus menelepon untuk membatalkan OperationNameAsyncCancel
. Karena dalam .NET ini tidak ditemukan di tempat lain, biasanya semua orang mengirim CancellingT s . Jadi, jika Anda secara tidak sengaja menemukan metode di pustaka yang berakhir dengan Async
, Anda perlu memahami bahwa itu tidak selalu kembali Task
, tetapi dapat mengembalikan konstruksi yang sama.
Pertimbangkan model yang dikenal di Jawa sebagaiBerjangka , dalam JavaScript, sebagai Janji , dan dalam. NET, sebagai Pola Asinkron Tugas , dengan kata lain, "tugas." Metode ini mengasumsikan bahwa Anda memiliki beberapa objek perhitungan, dan Anda dapat melihat status objek ini (berjalan atau selesai). Dalam. NET, ada RnToCompletion
pemisahan dua status yang disebut nyaman: awal tugas dan penyelesaian tugas. Kesalahan umum terjadi ketika suatu metode dipanggil pada tugas IsCompleted
yang mengembalikan kelanjutan tidak berhasil, tetapi RnToCompletion
, Canceled
dan Faulted
. Dengan demikian, hasil mengklik "Batalkan" di aplikasi UI harus berbeda dari pengembalian pengecualian (eksekusi). Di .NET, perbedaan telah dibuat: jika eksekusi adalah kesalahan Anda yang ingin Anda amankan, maka Batalkan- operasi paksa.Dalam. NET, sebuah konsep juga diperkenalkan TaskScheduler
- itu adalah semacam abstraksi di atas utas yang memberi tahu di mana harus menjalankan tugas. Dalam hal ini, dukungan pembatalan dirancang pada tingkat desain. Hampir semua operasi di pustaka di .NET memiliki CancellationToken
yang dapat diteruskan. Ini tidak berfungsi untuk semua bahasa: misalnya, di Kotlin Anda dapat membatalkan tugas, tetapi di .NET Anda tidak bisa. Solusinya mungkin adalah pembagian tanggung jawab antara mereka yang membatalkan tugas, dan tugas itu sendiri. Ketika Anda menerima tugas, Anda tidak dapat membatalkannya selain secara eksplisit - Anda harus meneruskannya CancellationToken
.Objek khusus TaskCompletionSoure
memungkinkan Anda untuk dengan mudah menyesuaikan API lama yang terkait dengan Pola Asinkron Berbasis Kejadian atau Model Pemrograman Asinkron. Ada dokumen yang harus Anda baca jika Anda memprogram dalam tugas. Ini menggambarkan semua perjanjian tentang tasas. Misalnya, metode apa pun, mengembalikan tugas, harus mengembalikannya dalam keadaan berjalan, yang berarti tidak bisa Created
, sementara semua operasi tersebut harus berakhir Async
.Menggabungkan kelanjutan
Task ourMethod() {
return Task.RunSynchronously(() =>{
...
})
.ContinueWith(_ =>{
Foo();
})
.ContinueWith(_ =>{
Bar();
})
.ContinueWith(_ =>{
Baz();
})
}
Adapun kombinasi, dengan mempertimbangkan neraka panggilan balik , itu dapat muncul dalam bentuk yang lebih linier, meskipun ada potongan kode berulang dengan perubahan minimal. Tampaknya kodenya membaik dengan cara ini, tetapi ada jebakan di sini juga.Mulai & lanjutkan tugas
Task.Factory.StartNew(Action,
TaskCreationOptions,
TaskScheduler,
CancellationToken
)
Task.ContinueWith(Action<Task>,
TaskContinuationOptions,
TaskScheduler,
CancellationToken
)
Mari kita beralih ke tiga parameter selama peluncuran tugas standar: yang pertama adalah opsi untuk memulai tugas, yang kedua adalah yang scheduler
di mana tugas diluncurkan, dan yang ketiga - CancellationToken
.
TaskScheduler memberi tahu di mana tugas dimulai dan merupakan objek yang bisa Anda timpa secara independen. Misalnya, Anda dapat mengganti metode Queue
. Jika Anda melakukannya TaskScheduler
untuk thread pool
, metode Queue
mengambil benang dari thread pool
dan mengirimkan tugas Anda di sana.Jika Anda mengambil scheduler
alih utas utama, ia menempatkan semuanya dalam satu antrian, dan tugas dieksekusi secara berurutan pada utas utama. Namun, masalahnya adalah. NET Anda dapat menjalankan tugas tanpa melewati TaskScheduler
. Timbul pertanyaan: bagaimana. NET menghitung tugas apa yang diberikan padanya? Saat tugas dimulai dari StartNew
dalamAction
, ThreadStatic
. Current
dipamerkan di salah satu TaskScheduler
yang kami berikan padanya.Desain ini tampaknya agak kontroversial karena konteksnya yang implisit. Ada kasus ketika itu TaskScheduler
berisi kode asinkron yang mewarisi suatu tempat yang sangat dalam TaskScheduler.Current
dan tumpang tindih dengan penjadwal lain, yang menyebabkan kebuntuan. Dalam hal ini, Anda dapat menggunakan opsi TaskCreationOption.HideScheduler
. Ini adalah bel alarm yang mengatakan bahwa kita memiliki beberapa opsi yang mengesampingkan ThreadStatic
pengaturan.Semuanya sama dengan kelanjutan. Timbul pertanyaan: dari mana datangnya TaskScheduler
untuk kelanjutan? Pertama-tama, ini diambil dalam metode di mana Anda memulai Continuation
. Itu juga TaskScheduler
diambil dari ThreadStatic. Adalah penting bahwa untuk menunggu / menunggu, kelanjutan bekerja dengan sangat berbeda.
Kami beralih ke parameter TaskCreationOptions
dan TaskContinuationOptions
. Masalah utama mereka adalah ada banyak dari mereka. Beberapa parameter ini saling membatalkan, beberapa lainnya saling eksklusif. Semua parameter ini dapat digunakan dalam semua kombinasi yang memungkinkan, jadi sulit untuk mengingat semua yang dapat terjadi dengan kerinduan. Beberapa opsi ini bekerja sangat tidak bisa dimengerti.
Misalnya, parameter ExecuteSynchronously
dan RunContinuationsAsynchronously
mewakili dua opsi aplikasi yang mungkin, tetapi apakah kelanjutan akan diluncurkan secara serempak atau tidak serempak bergantung pada begitu banyak hal yang tidak akan Anda ketahui.
Contoh lain: kami meluncurkan tugas, meluncurkan kelanjutan dan secara bersamaan memberikan dua parameterTaskContinuations.ExecuteSynchronously
, setelah itu mereka mulai melanjutkan secara tidak sinkron. Apakah akan dieksekusi di tumpukan yang sama di mana tugas sebelumnya berakhir, atau akankah itu ditransfer ke thread pool
? Dalam hal ini, akan ada opsi ketiga: itu tergantung.
TaskCompletionSource
Pertimbangkan TaskCompletionSource
. Saat Anda membuat tugas, Anda mengatur hasilnya SetResult
untuk menyesuaikan pola asinkron sebelumnya dengan dunia tugas. Anda TaskCompletionSource
dapat meminta tcs.Task
, dan tugas ini akan masuk ke kondisi finish
saat Anda menelepon tcs.SetResult
. Namun, jika Anda menjalankan ini di kumpulan utas , Anda akan menemui jalan buntu . Pertanyaannya adalah, mengapa jika kita tidak menulis apa pun secara serempak?
Kami membuat TaskCompletionSource
, memulai tugas baru, dan kami memiliki utas kedua yang memulai sesuatu dalam tugas ini. Ia pergi dan jatuh ke dalam harapan selama seratus milidetik. Lalu utas utama kami - hijau - menunggu dan hanya itu. Dia melepaskan tumpukan, tumpukan hang, menunggu untuk dipanggil dalam kelanjutantask.Wait
saat tcs
terpapar.Di utas biru kita sampai tcs
, dan kemudian yang paling menarik. Berdasarkan pertimbangan internal .NET, ia TaskCompletionSource
percaya bahwa kelanjutan dari ini tcs
dapat dilakukan secara serempak, yaitu, langsung pada tumpukan yang sama, maka ini task.Wait
dilakukan secara serempak pada tumpukan yang sama. Ini sangat aneh, meskipun faktanya kita belum menulis di mana pun ExecuteSynchronously
. Ini mungkin masalah dengan pencampuran kode sinkron dan asinkron.
Masalah lain dengan ini TaskCompletionSource
adalah bahwa ketika kami memanggil di SetResult
bawah kunci , Anda tidak dapat memanggil kode arbitrer, karena di bawah kunci Anda hanya dapat melakukan beberapa aktivitas granular kecil. Jalankan di bawah beberapa aksi-s, mustahil datang dari tempat asalnya. Bagaimana cara mengatasi masalah ini?var tcs = new TaskCompletionSource<int>(
TaskContinuationsOptions.RunContinuationsAsynchronously
) ;
lock(mylock)
{
tcs.SetResult(O);
});
TaskCompletionSource
Layak digunakan hanya untuk adaptasi kode Tugas tidak di perpustakaan. Hampir semuanya bisa diselesaikan melalui menunggu. Dalam hal ini, selalu sangat disarankan untuk meresepkan parameter "TaskCompletionSource.RunContinuationsAsynchronously" . Anda hampir selalu perlu menjalankan kelanjutan secara tidak sinkron. Dalam hal ini, Anda tcs.SetResult
memiliki sesuatu yang tidak dapat meluncurkan apa pun.
Mengapa kelanjutan harus dilakukan secara serempak? Karena RunContinuationsAsynchronously
mengacu pada yang berikut ContinueWith
, dan bukan milik kita. Agar dia berhubungan dengan kita, Anda perlu menulis yang berikut:
Contoh ini menunjukkan bagaimana parameter tidak intuitif, bagaimana mereka bersinggungan satu sama lain, bagaimana mereka memperkenalkan kompleksitas kognitif - sangat sulit untuk menulis.Hirarki orangtua-anak
Task.Factory.StartNew(() =>
{
Task.Factory.StartNew(() => {
})
})
.ContinueWith(...)
Ada opsi lain untuk menggunakan parameter. Misalnya, hierarki Orangtua-anak muncul ketika Anda meluncurkan satu tugas dan menjalankan yang lain di bawahnya. Dalam hal ini, jika Anda menulis ContinueWith
, Anda ContinueWith
tidak akan menunggu tugas diluncurkan di dalam.
Jika Anda menulis TaskCreationOptions.AttachedToParent
, itu ContinueWith
akan menunggu. Anda dapat menggunakan properti ini dalam produk Anda. Saya pikir semua orang dapat memberikan contoh di mana ada hierarki tugas, dengan tugas menunggu subtugas, dan subtugas untuk subtugasnya. Tidak perlu menulis di mana pun WaitForChildren
, tunggu ini terjadi secara serempak. Artinya, tubuh tugas orang tua berakhir, dan setelah itu tugas orang tua tidak dianggap selesai, tidak memulai kelanjutannya sampai tugas anak berhasil.Task.Factory.StartNew(() =>
{
Foo();
})
.ContinueWith(...)
void Foo() {
Task.Factory.StartNew(() => {
}, TaskCreationOptions.AttachedToParent);
}
Mungkin ada masalah saat tugas ditransfer ke suatu tempat ThreadStatic
, maka semua yang Anda mulai AttachedToParent
akan ditambahkan ke tugas induk ini, yang merupakan bel alarm.Task.Factory.StartNew(() =>
{
Foo();
}, TaskCreationOptions.DenyChildAttach)
.ContinueWith(...)
void Foo() {
Task.Factory.StartNew(() => {
}, TaskCreationOptions.AttachedToParent);
}
Di sisi lain, ada opsi yang membatalkan opsi sebelumnya DenyChildAttach
. Aplikasi semacam itu sering terjadi.Task.Run(() =>
{
Foo();
})
.ContinueWith(...)
void Foo() {
Task.Factory.StartNew(() => {
}, TaskCreationOptions.AttachedToParent);
}
Perlu diingat bahwa Task.Run
ini adalah cara standar untuk memulai, yang secara default menyiratkan DenyChildAttach
.Konteks implisit yang Anda masukkan ThreadStatic
menambah kompleksitas bagi Anda. Anda tidak mengerti bagaimana tugas itu bekerja, karena Anda perlu tahu konteksnya. Masalah lain yang mungkin timbul terkait dengan status siaga async / menunggu. Itu karena di async / menunggu Anda tidak memiliki tugas, tetapi tindakan. Kelanjutan bukanlah tugas yang jujur, tetapi tindakan. Saat Anda menulis kode async / AttachedToParent
wait , Anda tidak perlu menggunakannya karena Anda secara eksplisit mengikat tugas-tugas untuk menunggu, dan ini adalah pendekatan yang tepat.
Anda memiliki enam opsi tentang cara memulai kelanjutan. Anda meluncurkan tugas, diluncurkanContinueWith
. Pertanyaan: Apa status kelanjutan ini? Ada lima kemungkinan jawaban:- kelanjutan umum akan selesai dengan sukses, RunToCompletion akan terjadi;
- tugas akan salah;
- pembatalan akan terjadi;
- tugas tidak akan mencapai penyelesaian sama sekali, itu akan menjadi semacam limbo;
- opsi - "tergantung".
Dalam hal ini, tugas akan berada dalam status "dibatalkan", meskipun tidak ada kata "dibatalkan" di mana pun. Di sini kita mengadakan resepsi dan tidak melakukan apa pun. Masalahnya adalah ketika Anda membaca kode orang lain dengan banyak opsi - bahkan jika Anda tahu tentang opsi ini 10 menit yang lalu - Anda masih lupa apa yang terjadi di sini. Jadi jangan menulis.Pembatalan
Task.Factory.StartNew(() =>
{
throw new OperationCanceledException();
});
Failed
Parameter ketiga di awal tugas adalah kancellation. Anda menulis OperationCanceledException
, yaitu, tindakan khusus yang menempatkan tugas dalam status "Dibatalkan". Dalam hal ini, tugas akan berada dalam status "Gagal", karena tidak semua OperationCanceledException
sama.Task.Factory.StartNew(() =>
{
throw new OperationCanceledException(cancellationToken);
}, cancellationToken);
Canceled
Agar tugas dapat dilakukan Canceled
, Anda harus membuangnya OperationCanceledException
bersama dengan PembatalanTokennya. Pada kenyataannya, Anda tidak pernah secara eksplisit melakukan ini, tetapi lakukan seperti ini:Task.Factory.StartNew(() =>
{
cancellationToken.ThrowIfCancellationRequested();
}, cancellationToken);
Canceled
Apakah perlu untuk membedakan cancellationToken? Di suatu tempat di dalam tugas, Anda memeriksa bahwa seseorang menghapus Anda: membuang pembatalan lemparan, lalu tugas tersebut masuk ke status Canceled
. Atau seseorang mengklik "Batalkan" pada waktu berjalan dan membatalkan tugas. Latihan kami di JetBrains menunjukkan bahwa Anda tidak perlu membedakan antara token ini. Jika Anda mendapatkan OperationCanceledException - jenis khusus yang terjadi ketika beberapa pembatalan telah terjadi, Anda dapat membedakannya. Dalam hal ini, Anda hanya perlu menyelesaikan tugas secara normal, jangan masuk, dan ketika Anda menerima eksekusi - masuk.Tumpukan yang dalam
Task.Factory.StartNew(() =>
{
Foo();
}, cancellationToken);
void Foo() {
Bar() {
...
Baz() {
}
}
}
Katakanlah Anda memiliki tumpukan yang dalam. Ini CancellationToken
adalah satu-satunya parameter eksplisit yang kami diskusikan. Ini harus ditransmisikan ke mana-mana melalui semua hierarki. Apa yang harus saya lakukan jika, di hadapan hierarki yang mendalam, Anda perlu membatalkan tugas Anda di suatu tempat, pada tingkat yang paling rendah, untuk membuang resepsi? Ada trik khusus yang kami gunakan. Dia dipanggil AsyncLocal
.static AsyncLocal<Cancelation> asyncLocalCancellation;
Task.Factory.StartNew(() =>
{
asyncLocalCancellation.Set(cancellationToken)
Foo();
}, cancellationToken);
void Foo() {
async Bar() {
...
Baz() {
asyncLocalCancellation.Value.CheckForInterrupt();
}
}
}
Ini sama dengan, ThreadStatic
hanya yang spesial ThreadLocal
yang bertahan dari async / menunggu perjalanan kode. Karena kode Anda asinkron, dan Anda memiliki kancellation ini, Anda memasukkannya AsyncLocal
, dan di suatu tempat pada level yang lebih dalam Anda dapat mengatakan " CheckForInterrupt Throw If Cancellation Requested
". Sekali lagi, ini adalah satu-satunya parameter CancellationToken
yang perlu sepenuhnya mengolesi seluruh kode, tetapi, menurut pendapat saya, untuk sebagian besar tugas Anda hanya perlu tahu apa yang terjadi OperationCanceledException
, dan dari sini menarik kesimpulan yang menyatakan: Dibatalkan atau Gagal.Kompleksitas kognitif
Task.Factory.StartNew(Action,
TaskCreationOptions,
TaskScheduler,
CancellationToken
)
JetBrains.Lifetimes
lifetime.Start(TaskScheduler, Action)
lifetime.StartMainRead(Action)
lifetime.StartMainWrite(TaskScheduler, Action)
lifetime.StartBackgroundRead(TaskScheduler, Action)
Semakin sulit kode dibaca saat memulai tugas, semakin tinggi risiko kesalahan. Melihat kode setelah satu tahun, Anda akan lupa apa yang dilakukannya, karena ada sejumlah besar parameter. Tetapi kami memiliki pustaka JetBrains.Lifetimes , yang menawarkan masa hidup modern, CancellToken yang dioptimalkan dengan baik, yang dengannya metode Start ditulis ulang dan masalah dengan pengulangan potongan kode dipecahkan, seperti dengan Task.Factory.StartNew
dan TaskCreationOptions
.Ada sejumlah kecil penjadwal yang memungkinkan Anda untuk menjadwalkan tugas pada utas utama dengan kunci baca. Artinya, baca kunci bukanlah sesuatu yang Anda pilih secara eksplisit, itu adalah penjadwal khusus yang menjadwalkan kode Anda pada utas utama dengan kunci baca, serta utas utama dengan kunci tulis, utas latar belakang - dan sekarang metode menjadi sangat sederhana untuk memulai shuffle. Pada saat yang sama, masa hidup secara otomatis membatalkan AsyncLocal
, menyederhanakan kode secara signifikan.
Mari kita lihat bagaimana cara menyinkronkan masalah ini, dan masalah apa yang mereka perkenalkan.Dalam contoh ini, bagian dari kode dieksekusi secara sinkron, kemudian menunggu dan kode asinkron. Pertama, itu baik bahwa ada potongan kode berulang yang jauh lebih sedikit ( boiler-plate ). Kedua, bagus bahwa kode asinkron sangat mirip dengan kode sinkron, inilah gunanya async / menunggu . Anda dapat menulis secara tidak sinkron dengan cara yang sama seperti Anda menulis secara sinkron, tanpa mengambil utas.Dalam hal apa kompiler akan digunakan? Kode sinkron akan dieksekusi secara sinkron, setelah itu tugas InnerAsync
akan dijalankan secara sinkron , dari mana objek GetAwaiter khusus berasal. Dalam hal ini, kami tertarik TaskAwaiter
. Anda dapat menulis penunggu untuk objek apa pun. Akibatnya, kami menunggu tugas untuk menyelesaikan InnerAsync
dan secara sinkron melaksanakannya continuationCode
. Jika tugas tidak selesai, maka kode lanjutan dijadwalkan pada penjadwal konteks . Mungkin saja, meskipun Anda menulis menunggu , semuanya pasti akan dipanggil secara serempak.async Task MyFuncAsync() {
synchronousCode();
await InnerAsync();
await Task.Yield();
continuationCode();
}
Ada satu trik Task.Yield
- ini adalah tugas khusus yang memastikan bahwa penantunya tidak akan selalu kembali kepada Anda IsCompleted
. Dengan demikian, continuation
itu tidak akan dipanggil secara serempak di tempat ini. Untuk utas UI, ini bisa menjadi penting karena Anda tidak menggunakan utas ini untuk waktu yang lama.
Bagaimana memilih utas untuk kelanjutan? Filosofi async / await adalah ini: Anda menulis kode asinkron sama dengan sinkron. Jika Anda memiliki kumpulan utas , tidak ada bedanya bagi Anda - continuationCode akan dieksekusi di utas lainnya. Terlepas dari apakah itu InnerAsync
selesai ketika Anda mengatakan menunggu atau tidak, Anda perlu segalanya untuk dijalankan di utas UI.Mekanisme untuk tugas menunggu adalah sebagai berikut: itu diambil static
, itu disebutSynchronizationContext
dan darinya diciptakan TaskScheduler
. SynchronizationContext adalah sesuatu dengan metode Post, yang sangat mirip dengan metode ini Queue
. Bahkan TaskScheduler
, yang sebelumnya, hanya mengambil SynchronizationContext
dan melalui Post melakukan tugasnya di atasnya.async Task MyFuncAsync() {
synchronousCode();
await InnerAsync().ConfigureAwait(false);
continuationCode();
}
Ada cara untuk mengubah perilaku ini menggunakan parameter ContinueOnCapturedContext
. API paling menjijikkan yang ada di .NET disebut ConfigureAwait
. Dalam hal ini, API membuat penunggu khusus yang berbeda dari TaskAwaiter
yang menggeser kelanjutan, itu berjalan di utas yang sama, dalam konteks yang sama di mana metode berakhir InnerAsync
dan di mana tugas berakhir.async Task MyFuncAsync() {
synchronousCode();
await InnerAsync().ConfigureAwait(continueOnCapturedContext: false);
continuationCode();
}
Ada sejumlah saran gila di Internet: jika Anda menemui jalan buntu , mohon apakan semua kode ConfigureAwait Anda dan semuanya akan baik-baik saja. Ini cara yang salah. ConfigureAwait
dapat digunakan dalam kasus di mana Anda ingin sedikit meningkatkan kinerja, atau di akhir metode, di beberapa metode pustaka.Jalan buntu
async Task MyFuncAsync() {
synchronousCode();
await Task.Delay(10).ConfigureAwait(continueOnCapturedContext: false);
continuationCode();
}
myFuncAsync().Wait()
Ini adalah kebuntuan klasik . Di utas UI, mereka menunggu sepuluh detik dan melakukannya Wait
. Karena apa yang telah Anda lakukan Wait
, itu continuationCode
tidak akan pernah diluncurkan, Wait
oleh karena itu , tidak akan pernah kembali. Semua itu terjadi di awal.async Task OnBluttionClick() {
int v = Button.Text.ParseInt();
await Task.Delay(10).ConfigureAwait(continueOnCapturedContext: false);
Button.Text.Set((v+1).ToString());
}
myFuncAsync().Wait()
Bayangkan ini adalah aktivitas nyata. Kami mengklik tombol, mengambilnya Button.ParseInt
, menunggu , menulis, ConfigureAwait
Kami berkata: "Tolong jangan tutup aliran UI kami, lakukan kelanjutan." Masalahnya adalah bahwa kita ingin bagian kedua setelah ConfigureAwait
juga dieksekusi di utas UI, karena ini adalah filosofi menunggu . Artinya, kode asinkron Anda terlihat sama dengan kode sinkron, dan berjalan dalam konteks yang sama. Dalam hal ini, tentu saja, akan ada kesalahan. Dan selain Button.Text.Set
itu bisa ada sejumlah panggilan metode yang juga mengasumsikan konteksnya. Apa yang harus dilakukan dalam situasi ini? Kamu bisa melakukan ini:async Task MyFuncAsync() {
synchronousCode();
await Task.Delay(10).ConfigureAwait(continueOnCapturedContext: false);
continuationCode();
}
PumpUntil(() => task.IsCompleted);
Dengan utas UI, Anda harus melarang melakukannya Wait
pada utas yang memiliki antrian pesan umum. Alih-alih melakukan Wait
atau menulis ConfigureAwait
, Anda dapat memompa antrian pesan ini, dan pada saat yang sama, kontinum juga akan dipompa. Jika Anda tidak dapat mencampur kode sinkron dan asinkron, maka Anda tidak boleh mencampurnya. Namun terkadang ini tidak bisa dihindari.Misalnya, Anda memiliki kode lama, dan Anda harus mencampurnya, lalu Anda memompa aliran UI. Visual Studio memompa utas UI sesuai harapan, bahkan SynchronizationContext
sedikit berubah. Jika Anda masuk ke WaitHandle di mana saja Wait
, maka ketika Anda hang, aliran UI Anda dipompa. Dengan demikian, mereka memilih antara kebuntuan dan ras demi ras.Pumpuntil- Ini adalah API yang tidak ideal, yaitu, ketika Anda melakukan kontinuitas acak di tempat yang sewenang-wenang, mungkin ada nuansa. Sayangnya, tidak ada cara lain. Campur kode sinkron dan asinkron. Jika ada, seluruh Penunggang begitu diatur di tempat-tempat lama, jadi kadang-kadang ada nuansa juga.Ubah konteks
async Task MyFuncAsync() {
synchronousCode();
await myTaskScheduler;
continuationCode();
}
Ada cara lain yang menarik untuk menggunakan async / menunggu . Anda dapat menulis Awaiter
tentang scheduler
dan melompat pada benang. Saya membaca posting di Visual Studio, mereka menulis untuk waktu yang sangat lama bahwa tidak baik untuk bolak-balik di tengah metode, tetapi sekarang mereka melakukannya sendiri. Visual Studio memiliki API yang melompat pada utas melalui penjadwal. Untuk penggunaan normal, melakukan ini tidak baik.Konkurensi terstruktur
async Task MyFuncAsync() {
synchronousCode();
await Task.Factory.StartNew(() => {...}, myTaskScheduler);
continuationCode();
}
Untuk perendaman yang nyaman dalam konteks baru dan kembali ke yang lama, beberapa kompetisi struktural, atau paralelisme struktural, harus dibangun. Misalnya, pada tahun enam puluhan, operator GoTo dianggap berbahaya karena melanggar strukturalitas. Jadi di sini. Melompat dengan benang melanggar struktur. Anehnya, menggunakan mesin negara async sepertinya jalan keluar yang bagus. Yaitu, di mana struktur biasa Anda dilanggar, Anda melompat di GoTo, Anda dapat melanggar struktur utas: jangan menunggu , campur dengan tag. Ini adalah situasi yang sangat aneh dan langka ketika Anda perlu melakukan ini. Namun, lebih baik ketika menunggu kembali ke konteks yang sama. Dengan demikian, kumpulan utas tidak akan memiliki utas yang sama, tetapi konteks yang sama seperti aslinya.Perilaku berurutan
Mengapa menunggu tidak sama dengan eksekusi paralel? Menunggu eksekusi adalah eksekusi berurutan. Dalam hal ini, kami memulai tugas pertama, tunggu, mulai tugas kedua - kami menunggu. Kami tidak memiliki paralelisme. Untuk sebagian besar kegunaan, paralelisme tidak diperlukan. Paralelisme itu sendiri lebih kompleks daripada urutan. Kode serial lebih sederhana daripada paralel, ini adalah aksioma. Tetapi kadang-kadang Anda perlu menjalankan sesuatu dalam kode paralel, dan Anda melakukannya seperti ini:async Task MyAsync() {
var task1 = StartTask1Async();
await task1;
var task2 = StartTask2Async();
await task2;
}
Perilaku serentak
async Task MyAsync() {
var task1 = StartTask1Async();
var task2 = StartTask2Async();
await task1;
await task2;
}
Di sini tugas dimulai secara paralel. Jelas bahwa metode dapat mengembalikan tugas segera dalam keadaan berjalan, maka tidak akan ada paralelisme. Misalkan saja kedua tasky melakukan eksekusi. Dan Anda menunggu tugas pertama, lalu pada menunggu pertama berangkat. Artinya, segera setelah Anda menulis await task1
, Anda lepas landas dan tidak memproses exception task2
. Menariknya, ini adalah kode yang benar-benar valid. Dan kode inilah yang menyebabkan .NET pada fakta bahwa dalam versi 4.5 perilaku bekerja dengan eksekusi telah berubah.Penanganan pengecualian
async Task MyAsync() {
var task1 = StartTask1Async();
var task2 = StartTask2Async();
await task1;
await task2;
}
Sebelumnya, eksekusi tidak tertangani hanya membuang proses, dan jika Anda tidak menangkap beberapa eksekusi UnobservedExceptionHandler
(ini juga beberapa static
yang dapat Anda lampirkan pada penjadwal), maka proses ini tidak dieksekusi. Sekarang ini adalah kode yang benar-benar valid. Meskipun .NET mengubah perilakunya, tetap mempertahankan pengaturan untuk mengembalikan perilaku ke arah yang berlawanan.async Task MyAsync(CancellationToken cancellationToken) {
await SomeTask1 Async(cancellationToken);
await Some Task2Async( cancellation Token);
}
try {
await MyAsync( cancellation Token);
} catch (OperationException e) {
} catch (Exception e) {
log.Error(e);
}
Lihat bagaimana pemrosesan eksekusi berjalan. PembatalanToken-s harus ditransmisikan, perlu untuk "smear" PembatalanToken-s semua kode. Perilaku normal async adalah Anda tidak memeriksa di mana pun Task.Status ancellationToken
, Anda bekerja dengan kode asinkron dengan cara yang sama seperti dengan sinkron. Yaitu, dalam kasus pembatalan, Anda mendapatkan eksekusi, dan dalam kasus ini, Anda tidak melakukan apa pun ketika Anda menerimanya OperationCanceledException
.Perbedaan antara status Dibatalkan dan Kesalahan adalah bahwa Anda tidak menerima OperationCanceledException
, tetapi eksekusi biasa. Dan dalam hal ini, kita bisa menjanjikannya, Anda hanya perlu mendapatkan eksekusi dan menarik kesimpulan berdasarkan ini. Jika Anda memulai tugas secara eksplisit, melalui Tugas, Anda akan diterbangkan AggregateException
. Dan di async, dalam kasus ini mereka AggregateException
selalu membuang eksekusi pertama yang ada di dalamnya (dalam hal ini - OperationCanceled
).Dalam praktek
Metode sinkron
DataTable<File, ProcessedFile> sharedMemory;
void SynchronousWorker(...) {
File f = blockingQueue.Dequeue();
ProcessedFile p = ProcessInParallel(f);
lock (_lock) {
sharedMemory.add(f, p);
}
}
Misalnya, setan berfungsi di ReSharper - editor yang memberi warna pada file untuk Anda. Jika file dibuka di editor, maka ada beberapa aktivitas yang menempatkannya dalam antrian pemblokiran. Proses kami worker
membaca dari sana, setelah itu melakukan banyak tugas yang berbeda dengan file ini, mewarnai, mem-parsing, membangun, setelah itu file-file ini ditambahkan sharedMemory
. Dengan sharedMemory
kunci, mekanisme lain sudah bekerja dengannya.Metode asinkron
Saat menulis ulang kode menjadi asinkron, pertama-tama kita akan menggantinya void
dengan async Task
. Pastikan untuk menulis kata "Async" di akhir. Semua metode asinkron harus diakhiri dengan Async - ini adalah konvensi.DataTable<File, ProcessedFile> sharedMemory;
async Task WorkerAsync(...) {
File f = blockingQueue.Dequeue();
ProcessedFile p = ProcessInParallel(f);
lock (_lock) {
sharedMemory.add(f, p);
}
}
Setelah itu, Anda perlu melakukan sesuatu dengan kami blockingQueue
. Jelas, jika ada beberapa primitif sinkron, maka harus ada beberapa primitif asinkron.
Primitif ini disebut saluran: saluran yang hidup dalam paket System.Threading.Channels
. Anda dapat membuat saluran dan antrian, terbatas dan tidak terbatas, yang dapat Anda tunggu secara tidak sinkron. Selain itu, Anda dapat membuat saluran dengan nilai "nol", artinya tidak memiliki buffer sama sekali. Saluran semacam itu disebut saluran pertemuan dan secara aktif dipromosikan di Go dan Kotlin. Dan pada prinsipnya, jika dimungkinkan untuk menggunakan saluran dalam kode asinkron, ini adalah pola yang sangat bagus. Artinya, kami mengubah antrian ke saluran tempat ada metode ReadAsync
dan WriteAsync
.ProcessInParallel adalah sekelompok kode paralel yang melakukan pemrosesan file dan mengubahnya menjadiProcessedFile
. Bisakah async membantu kita menulis bukan asinkron, tetapi kode paralel lebih kompak?Sederhanakan Kode Paralel
Kode dapat ditulis ulang dengan cara ini:DataTable<File, ProcessedFile> sharedMemory;
async Task WorkerAsync(...) {
File f = await channel.ReadAsync();
ProcessedFile p = await ProcessInParallelAsync(f);
lock (_lock) {
sharedMemory.add(f, p);
}
}
Mereka seperti apa ProcessInParallel
? Sebagai contoh, kami memiliki file. Pertama, kita memecahnya menjadi leksem, dan kita dapat memiliki dua tugas secara paralel: membangun cache pencarian dan membangun pohon sintaksis. Setelah itu muncul tugas "mencari kesalahan semantik." Penting di sini bahwa semua tugas ini membentuk grafik asiklik terarah. Artinya, Anda dapat menjalankan beberapa bagian dalam utas paralel, sebagian tidak bisa, dan jelas ada ketergantungan tugas mana yang harus menunggu untuk tugas lain. Anda mendapatkan grafik dari tugas-tugas tersebut, Anda ingin entah bagaimana menyebarkannya di sepanjang utas. Apakah mungkin untuk menulisnya dengan indah, tanpa kesalahan? Dalam kode kami, masalah ini diselesaikan beberapa kali, setiap kali dengan cara yang berbeda. Jarang terjadi ketika kode ini ditulis tanpa kesalahan.
Kami mendefinisikan grafik tugas ini sebagai berikut: misalkan setiap tugas memiliki tugas lain yang bergantung padanya, kemudian menggunakan kamus ExecuteBefore kami menulis kerangka metode kami.Solusi kerangka
Dictionary<Action<ProcessedFile>, Action<ProcessedFile>[]> ExecuteBefore; async Task<ProcessedFile> ProcessInParallelAsync() {
var res = new ProcessedFile();
return res;
}
Jika Anda menyelesaikan masalah ini secara langsung, maka Anda perlu melakukan pengurutan topologi dari grafik ini. Kemudian ambil tugas yang tidak memiliki tugas yang tergantung, jalankan, analisis struktur di bawah kunci, lihat tugas mana yang tidak memiliki yang tergantung. Jalankan, sebarkan mereka entah bagaimana Task Runner
. Kami menulisnya sedikit lebih kompak: pengurutan topologi dari grafik + pelaksanaan tugas-tugas tersebut pada utas yang berbeda.Async malas
Dictionary<Action<ProcessedFile>, Action<ProcessedFile>[]> ExecuteBefore;
async Task<ProcessedFile> ProcessInParallelAsync() {
var res = new ProcessedFile();
var lazy = new Dictionary<Action<ProcessedFile>, Lazy<Task>>();
foreach ((action, beforeList) in ExecuteBefore)
lazy[action] = new Lazy<Task>(async () =>
{
await Task.WhenAll(beforeList.Select(b => lazy[b].Value))
await Task.Yield();
action(res);
}
await Task.WhenAll(lazy.Values.Select(l => l.Value))
return res;
}
Ada pola yang disebut Async Lazy
. Kami membuat milik kami ProcessedFile
di mana berbagai tindakan harus dijalankan. Mari kita membuat kamus: kita akan memformat setiap tahap kita (Action ProcessedFile) menjadi beberapa Tugas, atau lebih tepatnya, ke Malas dari Tugas dan berjalan di sepanjang grafik asli. Variabel action
akan memiliki tindakan itu sendiri , dan di beforeList - tindakan-tindakan yang harus dilakukan sebelum kita. Lalu buat Lazy
dari action
. Kami menulis di Tugas await
. Dengan demikian, kami menunggu semua tugas yang harus diselesaikan sebelum itu. Di beforeList, pilih Lazy
yang ada di kamus ini.Harap perhatikan bahwa di sini tidak ada yang akan dijalankan secara sinkron, sehingga kode ini tidak akan jatuh ItemNotFoundException in Dictionary
. Kami melakukan semua tugas yang ada di hadapan kami, melakukan pencarian dengan tindakanLazy Task
. Lalu kami melakukan tindakan kami. Pada akhirnya, Anda hanya perlu meminta setiap tugas untuk memulai, jika tidak, Anda tidak akan pernah tahu jika sesuatu tidak dimulai. Dalam hal ini, tidak ada yang dimulai. Ini solusinya. Metode ini ditulis dalam 10 menit, sangat jelas.Dengan demikian, kode asinkron membuat keputusan kami, awalnya ia menempati beberapa layar dengan kode kompetitif yang kompleks. Di sini dia sangat konsisten. Saya bahkan tidak menggunakannya ConcurrentDictionary
, saya menggunakan yang biasa Dictionary
, karena kami tidak menulis apa pun untuk itu secara kompetitif. Ada kode yang konsisten dan konsisten. Kami memecahkan masalah penulisan kode paralel menggunakan async-s dengan indah, yang berarti - tanpa bug.Singkirkan kunci
DataTable<File, ProcessedFile> sharedMemory;
async Task WorkerAsync(...) {
File f = await channel.ReadAsync();
ProcessedFile p = await ProcessInParallelAsync(f);
lock (_lock) {
sharedMemory.add(f, p);
}
}
Apakah perlu menarik async dan kunci-kunci ini? Sekarang ada semua jenis kunci async, async semaphores, yaitu upaya untuk menggunakan primitif yang ada dalam kode sinkron dan asinkron. Konsep ini tampaknya salah, karena dengan kunci Anda melindungi sesuatu dari eksekusi paralel. Tugas kami adalah menerjemahkan eksekusi paralel menjadi berurutan, karena lebih mudah. Dan jika lebih sederhana - lebih sedikit kesalahan.Channel<Pair<File, ProcessedFile>> output;
async Task WorkerAsync(...) {
File f = await channel.ReadAsync();
ProcessedFile p = await ProcessInParallelAsync(f);
await output.WriteAsync();
}
Kita dapat membuat beberapa saluran dan meletakkan di sana beberapa File dan ProcessedFile, dan ReadAsync
beberapa prosedur lain akan memproses saluran ini , dan itu akan melakukannya secara berurutan. Mengunci sendiri, selain melindungi struktur, pada dasarnya membuat akses linier, tempat semua utas dari yang berurutan menjadi paralel. Dan kami mengganti ini secara eksplisit dengan saluran.
Arsitekturnya adalah sebagai berikut: pekerja menerima file dari input
dan mengirimnya ke suatu tempat ke prosesor, yang juga memproses semuanya secara berurutan, tidak ada paralelisme. Kode terlihat lebih sederhana. Saya mengerti bahwa tidak semuanya bisa dilakukan dengan cara ini. Arsitektur seperti itu, ketika Anda bisa membangun pipa data, tidak selalu berhasil.
Mungkin saja Anda memiliki saluran kedua yang masuk ke prosesor Anda dan bukan grafik berarah asiklik yang terbentuk dari saluran, tetapi grafik dengan siklus. Ini adalah contoh yang dikatakan Roman Elizarov kepada KotlinConf pada tahun 2018. Dia menulis contoh di Kotlin dengan saluran-saluran ini, dan ada siklus di sana, dan contoh ini ditutup. Masalahnya adalah jika Anda memiliki siklus seperti itu dalam grafik, maka semuanya menjadi lebih rumit di dunia asinkron. Kebuntuan asinkron buruk karena jauh lebih sulit dipecahkan daripada sinkron ketika Anda memiliki setumpuk utas, dan jelas apa yang terjadi. Oleh karena itu, ini adalah alat yang harus digunakan dengan benar.Ringkasan
- Hindari sinkronisasi dalam kode asinkron.
- Kode serial lebih sederhana daripada paralel.
- Kode asinkron bisa sederhana dan menggunakan parameter minimum dan konteks implisit yang mengubah perilakunya.
Jika Anda telah mengembangkan kebiasaan menulis kode sinkron, dan meskipun kode asinkron sangat mirip dengan kode sinkron, Anda tidak perlu menyeret primitif di sana, yang biasa Anda gunakan dalam kode sinkron async mutex
. Gunakan feed, jika mungkin, dan primitif pesan yang lewat lainnya .Kode serial lebih sederhana daripada paralel. Jika Anda dapat menulis arsitektur Anda sehingga terlihat berurutan, tanpa menjalankan kode paralel dan mengunci, maka tulis arsitektur secara berurutan.Dan hal terakhir yang kami lihat dari sejumlah besar contoh dengan tugas. Saat Anda mendesain sistem Anda, cobalah untuk tidak terlalu bergantung pada konteks implisit. Konteks implisit mengarah pada kesalahpahaman tentang apa yang terjadi dalam kode, dan Anda bisa melupakan masalah tersirat dalam setahun. Dan jika orang lain mengerjakan kode ini dan mengulang sesuatu di dalamnya, ini dapat menyebabkan kesulitan yang pernah Anda ketahui, dan programmer baru tidak tahu karena konteks yang tersirat. Akibatnya, desain yang buruk dicirikan oleh sejumlah besar parameter, kombinasi dan konteks implisit.Apa yang harus dibaca
-10 . DotNext .