Baru-baru ini, kami telah
berbicara tentang apakah akan menimpa Equals dan GetHashCode saat pemrograman dalam C #. Hari ini kita akan membahas parameter kinerja metode asinkron. Bergabunglah sekarang!

Dalam dua artikel terakhir di blog msdn, kami melihat
struktur internal metode asinkron dalam C # dan
poin ekstensi yang disediakan oleh kompiler C # untuk mengontrol perilaku metode asinkron.
Berdasarkan informasi dalam artikel pertama, kompiler melakukan banyak transformasi untuk membuat pemrograman asinkronik dengan sinkron mungkin. Untuk melakukan ini, ia membuat instance dari mesin keadaan, meneruskannya ke pembangun metode asinkron, yang memanggil objek penunggu untuk tugas, dll. Tentu saja, logika seperti itu memiliki harga, tetapi berapa biayanya bagi kita?
Sampai perpustakaan TPL muncul, operasi asinkron tidak digunakan dalam jumlah besar, oleh karena itu, biayanya tidak tinggi. Tetapi hari ini, bahkan aplikasi yang relatif sederhana dapat melakukan ratusan, jika tidak ribuan, operasi asinkron per detik. Pustaka tugas paralel TPL dibuat dengan beban kerja dalam pikiran, tetapi tidak ada keajaiban di sini dan Anda harus membayar semuanya.
Untuk memperkirakan biaya metode asinkron, kami akan menggunakan contoh yang sedikit dimodifikasi dari artikel pertama.
public class StockPrices { private const int Count = 100; private List<(string name, decimal price)> _stockPricesCache;
Kelas
StockPrices
harga saham dari sumber eksternal dan memungkinkan Anda untuk meminta mereka melalui API. Perbedaan utama dari contoh pada artikel pertama adalah transisi dari kamus ke daftar harga. Untuk memperkirakan biaya berbagai metode asinkron dibandingkan dengan metode sinkron, operasi itu sendiri harus melakukan pekerjaan tertentu, dalam kasus kami ini adalah pencarian linear untuk harga saham.
Metode
GetPricesFromCache
sengaja dibangun di sekitar loop sederhana untuk menghindari alokasi sumber daya.
Perbandingan metode sinkron dan metode asinkron berbasis tugas
Dalam pengujian kinerja pertama, kami membandingkan metode asinkron yang memanggil metode inisialisasi asinkron (
GetStockPriceForAsync
), metode sinkron yang memanggil metode inisialisasi asinkron (
GetStockPriceFor
), dan metode sinkron yang memanggil metode inisialisasi sinkron.
private readonly StockPrices _stockPrices = new StockPrices(); public SyncVsAsyncBenchmark() {
Hasilnya ditunjukkan di bawah ini:

Sudah pada tahap ini kami menerima data yang cukup menarik:
- Metode asinkron cukup cepat.
GetPricesForAsync
berjalan secara sinkron dalam pengujian ini dan kira-kira 15% (*) lebih lambat daripada metode yang murni sinkron. - Metode
GetPricesFor
sinkron, yang memanggil metode GetPricesFor
InitializeMapIfNeededAsync
asynchronous, bahkan memiliki biaya yang lebih rendah, tetapi yang paling mengejutkan, itu tidak mengalokasikan sumber daya sama sekali (dalam kolom Alokasi dalam tabel di atas, harganya 0 untuk kedua GetPricesDirectlyFromCache
dan GetStockPriceFor
).
(*) Tentu saja, tidak dapat dikatakan bahwa biaya menjalankan metode asinkron secara sinkron adalah 15% untuk semua kasus yang mungkin. Nilai ini secara langsung tergantung pada beban kerja yang dilakukan oleh metode. Perbedaan antara overhead doa murni metode asinkron (yang tidak melakukan apa-apa) dan metode sinkron (yang tidak melakukan apa-apa) akan sangat besar. Gagasan tes komparatif ini adalah untuk menunjukkan bahwa biaya metode asinkron, yang melakukan jumlah pekerjaan yang relatif kecil, relatif rendah.Bagaimana mungkin ketika Anda memanggil
InitializeMapIfNeededAsync
, sumber daya tidak dialokasikan sama sekali? Dalam artikel pertama dalam seri ini, saya menyebutkan bahwa metode asinkron harus mengalokasikan setidaknya satu objek di header yang dikelola - contoh tugas itu sendiri. Mari kita bahas hal ini secara lebih rinci.
Optimasi # 1: contoh tugas caching bila memungkinkan
Jawaban untuk pertanyaan di atas sangat sederhana:
AsyncMethodBuilder
menggunakan satu instance tugas untuk setiap operasi asinkron yang berhasil diselesaikan . Metode asinkron yang mengembalikan tugas menggunakan
AsyncMethodBuilder
dengan logika berikut dalam metode
SetResult
:
Metode
SetResult
dipanggil hanya untuk metode asinkron yang berhasil diselesaikan, dan
hasil yang berhasil untuk setiap metode berbasis Task
dapat digunakan secara bebas bersama-sama . Kami bahkan dapat melacak perilaku ini dengan tes berikut:
[Test] public void AsyncVoidBuilderCachesResultingTask() { var t1 = Foo(); var t2 = Foo(); Assert.AreSame(t1, t2); async Task Foo() { } }
Tapi ini bukan satu-satunya optimasi yang mungkin.
AsyncTaskMethodBuilder<T>
mengoptimalkan pekerjaan dengan cara yang serupa: itu cache tugas untuk
Task<bool>
dan beberapa jenis sederhana lainnya. Misalnya, cache semua nilai default untuk sekelompok tipe integer dan menggunakan cache khusus untuk
Task<int>
, menempatkan nilai dari rentang [-1; 9] (untuk lebih jelasnya, lihat
AsyncTaskMethodBuilder<T>.GetTaskForResult()
).
Ini dikonfirmasi oleh tes berikut:
[Test] public void AsyncTaskBuilderCachesResultingTask() {
Jangan terlalu mengandalkan perilaku seperti itu , tetapi selalu menyenangkan untuk menyadari bahwa pencipta bahasa dan platform melakukan segala yang mungkin untuk meningkatkan produktivitas dengan semua cara yang tersedia. Caching tugas adalah metode optimisasi populer yang juga digunakan di area lain. Sebagai contoh, implementasi baru
Socket
dalam repositori
corefx repo membuat penggunaan metode ini secara luas dan menerapkan
tugas-tugas yang di-cache jika memungkinkan.
Optimasi # 2: Menggunakan ValueTask
Metode optimasi yang dijelaskan di atas hanya berfungsi dalam beberapa kasus. Oleh karena itu, alih-alih, kita dapat menggunakan
ValueTask<T>
(**), jenis nilai khusus yang mirip dengan tugas; itu tidak akan mengalokasikan sumber daya jika metode ini berjalan secara sinkron.
ValueTask<T>
adalah kombinasi
T
dan
Task<T>
dibedakan: jika "nilai-tugas" selesai, maka nilai dasar akan digunakan. Jika alokasi dasar belum habis, maka sumber daya akan dialokasikan untuk tugas tersebut.
Jenis khusus ini membantu mencegah penyediaan tumpukan berlebihan saat melakukan operasi secara serempak. Untuk menggunakan
ValueTask<T>
, Anda perlu mengubah tipe pengembalian untuk
GetStockPriceForAsync
: alih-alih
Task<decimal>
harus menentukan
ValueTask<decimal>
:
public async ValueTask<decimal> GetStockPriceForAsync(string companyId) { await InitializeMapIfNeededAsync(); return DoGetPriceFromCache(companyId); }
Sekarang kita dapat mengevaluasi perbedaan menggunakan tes komparatif tambahan:
[Benchmark] public decimal GetStockPriceWithValueTaskAsync_Await() { return _stockPricesThatYield.GetStockPriceValueTaskForAsync("MSFT").GetAwaiter().GetResult(); }

Seperti yang Anda lihat, versi dengan
ValueTask
hanya sedikit lebih cepat daripada versi dengan Tugas. Perbedaan utama adalah bahwa alokasi tumpukan dicegah. Sebentar lagi kita akan membahas kelayakan transisi semacam itu, tetapi sebelum itu saya ingin berbicara tentang satu optimasi yang rumit.
Optimasi No. 3: meninggalkan metode asinkron dalam jalur umum
Jika Anda sangat sering menggunakan beberapa metode asinkron dan ingin mengurangi biaya lebih banyak lagi, saya sarankan Anda optimasi berikut: menghapus pengubah async, dan kemudian memeriksa status tugas di dalam metode dan melakukan seluruh operasi secara serempak, sepenuhnya meninggalkan pendekatan asinkron.
Terlihat rumit? Pertimbangkan sebuah contoh.
public ValueTask<decimal> GetStockPriceWithValueTaskAsync_Optimized(string companyId) { var task = InitializeMapIfNeededAsync();
Dalam kasus ini, pengubah
async
tidak digunakan dalam metode
GetStockPriceWithValueTaskAsync_Optimized
, jadi ketika menerima tugas dari metode
InitializeMapIfNeededAsync
, ia memeriksa status pelaksanaannya. Jika tugas selesai, metode ini hanya menggunakan
DoGetPriceFromCache
untuk segera mendapatkan hasilnya. Jika tugas inisialisasi masih berlangsung, metode memanggil fungsi lokal dan menunggu hasil.
Menggunakan fungsi lokal bukan satu-satunya, tetapi salah satu cara termudah. Tapi ada satu peringatan. Selama implementasi paling alami, fungsi lokal akan menerima keadaan eksternal (variabel dan argumen lokal):
public ValueTask<decimal> GetStockPriceWithValueTaskAsync_Optimized2(string companyId) {
Tapi, sayangnya, karena
kesalahan kompiler, kode ini akan menghasilkan penutupan, bahkan jika metode ini dieksekusi dalam jalur umum. Begini cara metode ini terlihat dari dalam:
public ValueTask<decimal> GetStockPriceWithValueTaskAsync_Optimized(string companyId) { var closure = new __DisplayClass0_0() { __this = this, companyId = companyId, task = InitializeMapIfNeededAsync() }; if (closure.task.IsCompleted) { return ... }
Seperti dibahas dalam artikel
Membedah fungsi lokal di C # , kompiler menggunakan contoh umum dari penutupan untuk semua variabel lokal dan argumen di area tertentu. Akibatnya, ada beberapa perasaan dalam pembuatan kode seperti itu, tetapi itu membuat seluruh perjuangan dengan mengalokasikan tumpukan tidak berguna.
TIP . Optimalisasi seperti itu adalah hal yang sangat berbahaya. Manfaatnya dapat diabaikan, dan bahkan jika Anda menulis fungsi lokal asli yang
benar , Anda dapat secara tidak sengaja mendapatkan status eksternal yang menyebabkan tumpukan dialokasikan. Anda masih dapat menggunakan optimasi jika Anda bekerja dengan pustaka yang umum digunakan (misalnya, BCL) dalam metode yang pasti akan digunakan pada bagian kode yang dimuat.
Biaya yang terkait dengan menunggu tugas
Saat ini, kami hanya mempertimbangkan satu kasus khusus: overhead metode asinkron yang berjalan secara sinkron. Ini dilakukan dengan sengaja. Semakin kecil metode sinkron, semakin terlihat biaya dalam kinerjanya secara keseluruhan. Metode asinkron yang lebih terperinci, sebagai aturan, berjalan secara sinkron dan melakukan beban kerja yang lebih kecil. Dan kami biasanya memanggil mereka lebih sering.
Tetapi kita harus menyadari biaya dari mekanisme asinkron ketika metode βmenungguβ untuk penyelesaian tugas yang luar biasa. Untuk memperkirakan biaya ini, kami akan melakukan perubahan ke
InitializeMapIfNeededAsync
dan akan memanggil
Task.Yield()
bahkan ketika cache diinisialisasi:
private async Task InitializeMapIfNeededAsync() { if (_stockPricesCache != null) { await Task.Yield(); return; }
Kami menambahkan metode berikut ke paket benchmark kami untuk pengujian perbandingan:
[Benchmark] public decimal GetStockPriceFor_Await() { return _stockPricesThatYield.GetStockPriceFor("MSFT"); } [Benchmark] public decimal GetStockPriceForAsync_Await() { return _stockPricesThatYield.GetStockPriceForAsync("MSFT").GetAwaiter().GetResult(); } [Benchmark] public decimal GetStockPriceWithValueTaskAsync_Await() { return _stockPricesThatYield.GetStockPriceValueTaskForAsync("MSFT").GetAwaiter().GetResult(); }

Seperti yang Anda lihat, perbedaannya bisa diraba - baik dalam hal kecepatan, dan dalam hal penggunaan memori. Jelaskan secara singkat hasilnya.
- Setiap menunggu operasi untuk tugas yang belum selesai membutuhkan sekitar 4 mikrodetik dan mengalokasikan hampir 300 byte (**) untuk setiap panggilan. Itulah sebabnya GetStockPriceFor berjalan hampir dua kali lebih cepat dari GetStockPriceForAsync dan mengalokasikan lebih sedikit memori.
- Metode asinkron berdasarkan ValueTask membutuhkan waktu lebih lama daripada varian dengan Tugas, saat metode ini tidak dijalankan secara serempak. Mesin keadaan metode berdasarkan ValueTask <T> harus menyimpan lebih banyak data daripada mesin keadaan metode berdasarkan Tugas <T>.
(**) Tergantung pada platform (x64 atau x86) dan sejumlah variabel lokal dan argumen dari metode asinkron.Kinerja Metode Asinkron 101
- Jika metode asinkron berjalan secara sinkron, biaya overhead cukup kecil.
- Jika metode asinkron dijalankan secara sinkron, maka overhead berikut terjadi dalam penggunaan memori: tidak ada overhead untuk metode Tugas async, dan untuk metode Tugas async <T> metode overrun adalah 88 byte per operasi (untuk platform x64).
- ValueTask <T> menghilangkan overhead yang disebutkan di atas untuk metode asinkron yang dijalankan secara sinkron.
- Ketika metode asinkron berdasarkan ValueTask <T> dieksekusi secara sinkron, dibutuhkan sedikit waktu lebih sedikit daripada metode dengan Tugas <T>, jika tidak ada sedikit perbedaan dalam mendukung opsi kedua.
- Kinerja overhead untuk metode asinkron menunggu untuk menyelesaikan tugas yang belum selesai secara signifikan lebih tinggi (sekitar 300 byte per operasi untuk platform x64).
Tentu saja, pengukuran adalah segalanya bagi kami. Jika Anda melihat bahwa operasi asinkron menyebabkan masalah kinerja, Anda dapat beralih dari
Task<T>
ke
ValueTask<T>
, cache tugas, atau buat jalur eksekusi keseluruhan sinkron, jika mungkin. Anda juga dapat mencoba menggabungkan operasi asinkron Anda. Ini akan membantu meningkatkan kinerja, menyederhanakan debugging dan analisis kode secara umum.
Tidak setiap kode kecil harus asinkron.