C # Asynchronous Programming: Bagaimana kinerja Anda?

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; // Async version public async Task<decimal> GetStockPriceForAsync(string companyId) { await InitializeMapIfNeededAsync(); return DoGetPriceFromCache(companyId); } // Sync version that calls async init public decimal GetStockPriceFor(string companyId) { InitializeMapIfNeededAsync().GetAwaiter().GetResult(); return DoGetPriceFromCache(companyId); } // Purely sync version public decimal GetPriceFromCacheFor(string companyId) { InitializeMapIfNeeded(); return DoGetPriceFromCache(companyId); } private decimal DoGetPriceFromCache(string name) { foreach (var kvp in _stockPricesCache) { if (kvp.name == name) { return kvp.price; } } throw new InvalidOperationException($"Can't find price for '{name}'."); } [MethodImpl(MethodImplOptions.NoInlining)] private void InitializeMapIfNeeded() { // Similar initialization logic. } private async Task InitializeMapIfNeededAsync() { if (_stockPricesCache != null) { return; } await Task.Delay(42); // Getting the stock prices from the external source. // Generate 1000 items to make cache hit somewhat expensive _stockPricesCache = Enumerable.Range(1, Count) .Select(n => (name: n.ToString(), price: (decimal)n)) .ToList(); _stockPricesCache.Add((name: "MSFT", price: 42)); } } 

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() { // Warming up the cache _stockPrices.GetStockPriceForAsync("MSFT").GetAwaiter().GetResult(); } [Benchmark] public decimal GetPricesDirectlyFromCache() { return _stockPrices.GetPriceFromCacheFor("MSFT"); } [Benchmark(Baseline = true)] public decimal GetStockPriceFor() { return _stockPrices.GetStockPriceFor("MSFT"); } [Benchmark] public decimal GetStockPriceForAsync() { return _stockPrices.GetStockPriceForAsync("MSFT").GetAwaiter().GetResult(); } 

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 :

 // AsyncMethodBuilder.cs from mscorlib public void SetResult() { // Ie the resulting task for all successfully completed // methods is the same -- s_cachedCompleted. m_builder.SetResult(s_cachedCompleted); } 

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() { // These values are cached Assert.AreSame(Foo(-1), Foo(-1)); Assert.AreSame(Foo(8), Foo(8)); // But these are not Assert.AreNotSame(Foo(9), Foo(9)); Assert.AreNotSame(Foo(int.MaxValue), Foo(int.MaxValue)); async Task<int> Foo(int n) => n; } 

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(); // Optimizing for acommon case: no async machinery involved. if (task.IsCompleted) { return new ValueTask<decimal>(DoGetPriceFromCache(companyId)); } return DoGetStockPricesForAsync(task, companyId); async ValueTask<decimal> DoGetStockPricesForAsync(Task initializeTask, string localCompanyId) { await initializeTask; return DoGetPriceFromCache(localCompanyId); } } 

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) { // Oops! This will lead to a closure allocation at the beginning of the method! var task = InitializeMapIfNeededAsync(); // Optimizing for acommon case: no async machinery involved. if (task.IsCompleted) { return new ValueTask<decimal>(DoGetPriceFromCache(companyId)); } return DoGetStockPricesForAsync(); async ValueTask<decimal> DoGetStockPricesForAsync() { await task; return DoGetPriceFromCache(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 ... } // The rest of the code } 

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; } // Old initialization logic } 

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.

Source: https://habr.com/ru/post/id420627/


All Articles