Terjemahan ini muncul berkat komentar baik 0x1000000 .

NET Framework. 4 memperkenalkan ruang System.Threading.Tasks, dan dengan itu kelas Tugas. Tipe ini dan Tugas <TResult> yang dihasilkannya telah menunggu lama sampai mereka dikenali oleh standar di .NET sebagai aspek kunci dari model pemrograman asinkron yang diperkenalkan pada C # 5 dengan pernyataan async / menunggu. Pada artikel ini, saya akan berbicara tentang tipe-tipe baru ValueTask / ValueTask <TResult>, yang dirancang untuk meningkatkan kinerja metode asinkron dalam kasus-kasus di mana alokasi alokasi memori harus diperhitungkan.
Tugas
Tugas bertindak dalam peran yang berbeda, tetapi yang utama adalah "janji" (janji), objek yang mewakili kemungkinan penyelesaian beberapa operasi. Anda memulai operasi dan mendapatkan objek Tugas untuk itu, yang akan dieksekusi ketika operasi selesai, yang dapat terjadi dalam mode sinkron sebagai bagian dari inisialisasi operasi (misalnya, menerima data yang sudah ada dalam buffer), dalam mode asinkron dengan eksekusi pada saat ketika Anda mendapatkan Tugas (menerima data bukan dari buffer, tetapi sangat cepat), atau dalam mode asinkron, tetapi setelah Anda memiliki Tugas (menerima data dari sumber daya jarak jauh). Karena operasi dapat berakhir secara tidak sinkron, Anda memblokir aliran eksekusi, menunggu hasilnya (yang sering membuat asinkron panggilan tidak berarti), atau membuat fungsi panggilan balik yang akan diaktifkan setelah operasi selesai. Di .Net 4, pembuatan callback diimplementasikan oleh metode ContinueWith dari objek Task, yang dengan jelas menunjukkan model ini dengan menerima fungsi delegasi untuk menjalankannya setelah Task dieksekusi:
SomeOperationAsync().ContinueWith(task => { try { TResult result = task.Result; UseResult(result); } catch (Exception e) { HandleException(e); } });
Namun dalam .NET Framework 4.5 dan C # 5, objek Tugas dapat dengan mudah dipanggil oleh operator yang menunggu, yang membuatnya mudah untuk mendapatkan hasil dari operasi asinkron, dan kode yang dihasilkan yang dioptimalkan untuk opsi di atas akan bekerja dengan benar dalam semua kasus ketika operasi selesai dalam mode sinkron, asinkron cepat atau asinkron dengan membuat panggilan balik:
TResult result = await SomeOperationAsync(); UseResult(result);
Tugas adalah kelas yang sangat fleksibel dan memiliki sejumlah keunggulan. Misalnya, Anda dapat melakukan menunggu beberapa kali untuk sejumlah konsumen sekaligus. Anda dapat meletakkannya di koleksi (kamus) untuk ditunggu-tunggu lagi di masa mendatang, untuk menggunakannya sebagai cache dari hasil panggilan tidak sinkron. Anda dapat memblokir eksekusi sambil menunggu Tugas selesai jika perlu. Dan Anda dapat menulis dan menerapkan berbagai operasi pada objek Task (kadang-kadang disebut "combinators"), misalnya, "when any" untuk asynchronous menunggu penyelesaian pertama beberapa Task.
Tetapi fleksibilitas ini menjadi berlebihan dalam kasus yang paling umum: panggil saja operasi asinkron dan tunggu tugas selesai:
TResult result = await SomeOperationAsync(); UseResult(result);
Di sini kita tidak perlu menunggu eksekusi beberapa kali. Kita tidak perlu memastikan bahwa harapan itu kompetitif. Kami tidak perlu melakukan penguncian sinkron. Kami tidak akan menulis kombinator. Kami hanya menunggu janji operasi asinkron diselesaikan. Pada akhirnya, ini adalah cara kami menulis kode sinkron (misalnya, hasil TResult = SomeOperation ();), dan biasanya diterjemahkan ke dalam async / menunggu.
Selain itu, Tugas memiliki kelemahan potensial, terutama ketika sejumlah besar instance dibuat, dan throughput dan kinerja tinggi adalah persyaratan utama - Tugas adalah kelas. Ini berarti bahwa setiap operasi yang membutuhkan Tugas dipaksa untuk membuat dan menempatkan objek, dan semakin banyak objek dibuat, semakin banyak pekerjaan untuk pengumpul sampah (GC), dan pekerjaan ini menghabiskan sumber daya yang bisa kita pakai untuk sesuatu yang lebih bermanfaat.
Pustaka runtime dan sistem membantu mengurangi masalah ini dalam banyak situasi. Sebagai contoh, jika kita menulis metode seperti ini:
public async Task WriteAsync(byte value) { if (_bufferedCount == _buffer.Length) { await FlushAsync(); } _buffer[_bufferedCount++] = value; }
sebagai aturan, akan ada ruang kosong yang cukup di buffer, dan operasi akan dijalankan secara serempak. Ketika ini terjadi, tidak perlu melakukan apa pun dengan Tugas, yang harus dikembalikan, karena tidak ada nilai kembali, ini menggunakan Tugas sebagai setara dengan metode sinkron mengembalikan nilai kosong (batal). Oleh karena itu, lingkungan dapat dengan mudah melakukan cache satu tugas non-generik dan menggunakannya lagi dan lagi sebagai hasil dari eksekusi untuk setiap metode async yang selesai secara sinkron (singleton cache ini dapat diperoleh melalui Task.CompletedTask). Atau, misalnya, Anda menulis:
public async Task<bool> MoveNextAsync() { if (_bufferedCount == 0) { await FillBuffer(); } return _bufferedCount > 0; }
dan secara umum, perkirakan data sudah ada di buffer, jadi metode ini cukup memeriksa nilai _bufferedCount, melihat bahwa itu lebih besar dari 0, dan mengembalikan true; dan hanya jika belum ada data dalam buffer, Anda perlu melakukan operasi asinkron. Dan karena hanya ada dua hasil yang mungkin dari tipe Boolean (benar dan salah), hanya ada dua objek Tugas yang mungkin diperlukan untuk mewakili hasil ini, lingkungan dapat men-cache objek ini dan mengembalikannya dengan nilai yang sesuai tanpa mengalokasikan memori. Hanya dalam hal penyelesaian asinkron, metode perlu membuat Tugas baru, karena itu harus dikembalikan sebelum hasil operasi diketahui.
Lingkungan menyediakan caching untuk beberapa jenis lain, tetapi tidak realistis untuk men-cache semua jenis yang mungkin. Sebagai contoh, metode berikut:
public async Task<int> ReadNextByteAsync() { if (_bufferedCount == 0) { await FillBuffer(); } if (_bufferedCount == 0) { return -1; } _bufferedCount--; return _buffer[_position++]; }
juga akan sering dieksekusi secara serempak. Tetapi tidak seperti varian dengan hasil tipe Boolean, metode ini mengembalikan Int32, yang memiliki sekitar 4 miliar nilai, dan caching semua varian Tugas <int> akan membutuhkan ratusan gigabytes memori. Lingkungan menyediakan cache kecil untuk Tugas <int>, tetapi sekumpulan nilai yang sangat terbatas, misalnya, jika metode ini berakhir secara serempak (data sudah ada dalam buffer) dengan nilai balik 4, itu akan menjadi tugas yang di-cache, tetapi jika nilai 42 dikembalikan, Anda harus membuat yang baru Tugas <int>, mirip dengan memanggil Task.FromResult (42).
Banyak metode pustaka mencoba untuk memuluskan ini dengan menyediakan cache mereka sendiri. Misalnya, kelebihan dalam .NET Framework 4.5 dari metode MemoryStream.ReadAsync selalu berakhir secara sinkron, karena membaca data dari memori. ReadAsync mengembalikan Tugas <int>, di mana hasil Int32 menunjukkan berapa banyak byte yang telah dibaca. Metode ini sering digunakan dalam satu lingkaran, seringkali dengan jumlah byte yang diperlukan yang sama untuk setiap panggilan, dan seringkali kebutuhan ini terpenuhi secara penuh. Jadi untuk panggilan berulang ke ReadAsync, masuk akal untuk mengharapkan bahwa Tugas <int> akan kembali secara sinkron dengan nilai yang sama seperti pada panggilan sebelumnya. Oleh karena itu, MemoryStream membuat cache untuk satu objek yang dikembalikan dalam panggilan terakhir yang berhasil. Dan pada panggilan berikutnya, jika hasilnya diulang, itu akan mengembalikan objek yang di-cache, dan jika tidak, buat yang baru dengan Task.FromResult, simpan ke cache dan kembalikan.
Namun demikian, ada banyak kasus lain ketika operasi dilakukan secara serempak, tetapi objek <TResult> Tugas dipaksa untuk dibuat.
ValueTask <TResult> dan eksekusi sinkron
Semua ini membutuhkan implementasi tipe baru di .NET Core 2.0, yang tersedia di versi .NET sebelumnya di Sistem NuGet .Threading.Tasks.Extensions: Paket ValueTask <TResult>.
ValueTask <TResult> dibuat di .NET Core 2.0 sebagai struktur yang mampu membungkus baik TResult maupun Task <TResult>. Ini berarti dapat dikembalikan dari metode async, dan jika metode ini dijalankan secara sinkron dan berhasil, Anda tidak perlu meletakkan objek apa pun di heap: Anda cukup menginisialisasi struktur ValueTask <TResult> ini dengan nilai TResult dan mengembalikannya. Hanya dalam kasus eksekusi asinkron, objek Task <TResult> akan ditempatkan, dan ValueTask <TResult> akan membungkusnya (untuk meminimalkan ukuran struktur dan mengoptimalkan kasus eksekusi yang sukses, metode async, yang berakhir dengan pengecualian yang tidak didukung, juga akan menempatkan Tugas <TResult>, jadi ValueTask <TResult> juga hanya membungkus Tugas <TResult>, dan tidak akan membawa bidang tambahan untuk menyimpan Pengecualian).
Berdasarkan ini, metode seperti MemoryStream.ReadAsync, tetapi mengembalikan ValueTask <int>, tidak boleh berurusan dengan caching, tetapi sebaliknya dapat ditulis seperti ini:
public override ValueTask<int> ReadAsync(byte[] buffer, int offset, int count) { try { int bytesRead = Read(buffer, offset, count); return new ValueTask<int>(bytesRead); } catch (Exception e) { return new ValueTask<int>(Task.FromException<int>(e)); } }
ValueTask <TResult> dan Eksekusi Asinkron
Kemampuan untuk menulis metode async yang dapat menyelesaikan secara serempak tanpa perlu penempatan tambahan untuk hasilnya adalah kemenangan besar. Itu sebabnya ValueTask <TResult> ditambahkan dalam .NET Core 2.0, dan metode baru yang mungkin digunakan dalam aplikasi yang membutuhkan kinerja sekarang diumumkan dengan kembalinya ValueTask <TResult> alih-alih Tugas <TResult>. Misalnya, ketika kami menambahkan kelebihan ReadAsync baru dari kelas Stream ke .NET Core 2.1, agar dapat meneruskan Memori alih-alih byte [], kami mengembalikan tipe ValueTask <int> di dalamnya. Dalam bentuk ini, objek Stream (di mana metode ReadAsync sangat sering dieksekusi secara serempak, seperti dalam contoh sebelumnya untuk MemoryStream) dapat digunakan dengan alokasi memori yang jauh lebih sedikit.
Namun, ketika kami bekerja dengan layanan dengan bandwidth yang sangat tinggi, kami masih ingin menghindari alokasi memori sebanyak mungkin, yang berarti mengurangi dan menghilangkan alokasi memori di sepanjang rute eksekusi yang tidak sinkron juga.
Dalam model penantian, untuk operasi apa pun yang menyelesaikan secara serempak, kita membutuhkan kemampuan untuk mengembalikan objek yang mewakili kemungkinan penyelesaian operasi: penelepon perlu mengarahkan kembali callback yang akan dimulai pada akhir operasi, dan ini membutuhkan objek unik di heap, yang dapat berfungsi sebagai saluran transmisi untuk operasi khusus ini. Ini, pada saat yang sama, tidak berarti apa-apa apakah objek ini akan digunakan kembali setelah operasi selesai. Jika objek ini dapat digunakan kembali, API dapat mengatur cache untuk satu atau lebih objek ini, dan menggunakannya untuk operasi sekuensial, dalam arti tidak menggunakan objek yang sama untuk beberapa operasi async menengah, tetapi menggunakannya untuk akses non-kompetitif.
Di .NET Core 2.1, kelas ValueTask <TResult> telah ditingkatkan untuk mendukung pengumpulan dan penggunaan kembali yang serupa. Alih-alih hanya membungkus TResult atau Tugas <TResult>, kelas yang direvisi dapat membungkus antarmuka IValueTaskSource <TResult> baru. Antarmuka ini menyediakan fungsionalitas dasar yang diperlukan untuk menemani operasi asinkron dengan objek ValueTask <TResult> dengan cara yang sama seperti Tugas <TResult>:
public interface IValueTaskSource<out TResult> { ValueTaskSourceStatus GetStatus(short token); void OnCompleted(Action<object> continuation, object state, short token, ValueTaskSourceOnCompletedFlags flags); TResult GetResult(short token); }
Metode GetStatus digunakan untuk mengimplementasikan properti seperti ValueTask <TResult> .IsCompleted, yang mengembalikan informasi apakah operasi asinkron dilakukan atau diselesaikan, dan bagaimana ia selesai (berhasil atau tidak). Metode OnCompleted digunakan oleh objek menunggu untuk melampirkan panggilan balik untuk melanjutkan eksekusi dari titik menunggu ketika operasi selesai. Dan metode GetResult diperlukan untuk mendapatkan hasil operasi, jadi setelah operasi selesai, pemanggil bisa mendapatkan objek TResult atau memberikan pengecualian yang dilemparkan.
Sebagian besar pengembang tidak memerlukan antarmuka ini: metode hanya mengembalikan objek ValueTask <TResult>, yang dapat dibuat sebagai pembungkus untuk objek yang mengimplementasikan antarmuka ini, dan metode pemanggilan akan tetap dalam kegelapan. Antarmuka ini untuk pengembang yang perlu menghindari alokasi memori saat menggunakan API yang kritis terhadap kinerja.
Ada beberapa contoh API semacam itu di .NET Core 2.1. Metode yang paling terkenal adalah Socket. Menerima ASync dan Socket. MengirimAsync dengan kelebihan baru ditambahkan pada 2.1, misalnya
public ValueTask<int> ReceiveAsync(Memory<byte> buffer, SocketFlags socketFlags, CancellationToken cancellationToken = default);
Kelebihan ini mengembalikan ValueTask <int>. Jika operasi selesai secara sinkron, cukup dengan mengembalikan ValueTask <int> dengan nilai yang sesuai:
int result = âĻ; return new ValueTask<int>(result);
Saat dihentikan secara asinkron, ia dapat menggunakan objek dari kumpulan yang mengimplementasikan antarmuka:
IValueTaskSource<int> vts = âĻ; return new ValueTask<int>(vts);
Implementasi Socket mendukung satu objek seperti itu di kumpulan untuk penerimaan, dan satu untuk transmisi, karena tidak boleh ada lebih dari satu objek untuk setiap arah yang menunggu untuk dieksekusi pada satu waktu. Kelebihan ini tidak mengalokasikan memori, bahkan dalam kasus operasi asinkron. Perilaku ini lebih jelas terlihat di kelas NetworkStream.
Misalnya, dalam .NET Core 2.1 Stream menyediakan:
public virtual ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken);
yang didefinisikan ulang di NetworkStream. Metode NetworkStream.ReadAsync hanya menggunakan metode Socket.ReceiveAsync, sehingga kemenangan dalam Socket disiarkan ke NetworkStream, dan NetworkStream.ReadAsync sebenarnya tidak mengalokasikan memori juga.
ValueTask yang dibagikan
Ketika ValueTask <TResult> muncul di .NET Core 2.0, itu hanya mengoptimalkan kasus eksekusi sinkron untuk mengecualikan penempatan objek Tugas <TResult>, jika nilai TResult sudah siap. Ini berarti bahwa kelas ValueTask non-generik tidak diperlukan: untuk kasus eksekusi sinkron, Task singleton. CompletedTask hanya dapat dikembalikan dari metode, dan ini dilakukan oleh lingkungan secara implisit dalam metode async yang mengembalikan Task.
Namun, dengan mendapatkan operasi asinkron tanpa mengalokasikan memori, penggunaan ValueTask yang dibagikan lagi menjadi relevan. Di .NET Core 2.1, kami memperkenalkan ValueTask umum dan IValueTaskSource. Mereka menyediakan setara langsung untuk versi generik, untuk penggunaan serupa, dengan hanya nilai pengembalian kosong.
Terapkan IValueTaskSource / IValueTaskSource <T>
Sebagian besar pengembang seharusnya tidak mengimplementasikan antarmuka ini. Apalagi itu tidak mudah. Jika Anda memutuskan untuk melakukan ini, beberapa implementasi di .NET Core 2.1 dapat berfungsi sebagai titik awal, misalnya:
- AocketableSocketAsyncEventArgs
- AsyncOperation <TResult>
- DefaultPipeReader
Untuk mempermudah ini, di .NET Core 3.0 kami berencana untuk memperkenalkan semua logika yang diperlukan yang termasuk dalam tipe ManualResetValueTaskSourceCore <TResult>, struktur yang dapat disematkan di objek lain yang mengimplementasikan IValueTaskSource <TResult> dan / atau IValueTaskSource, sehingga dapat didelegasikan ke Struktur ini adalah sebagian besar fungsi. Anda dapat mempelajari lebih lanjut tentang ini dari https://github.com/dotnet/corefx/issues/32664 di repositori dotnet / corefx.
Pola Aplikasi ValueTasks
Pada pandangan pertama, ruang lingkup ValueTask dan ValueTask <TResult> jauh lebih terbatas daripada Tugas dan Tugas <TResult>. Ini bagus, dan bahkan diharapkan, karena cara utama untuk menggunakannya hanya menggunakan operator yang menunggu.
Namun, karena mereka dapat membungkus objek yang digunakan kembali, ada batasan signifikan dalam penggunaannya dibandingkan dengan Tugas dan Tugas <TResult>, jika Anda menyimpang dari cara biasa menunggu yang sederhana. Dalam kasus umum, operasi berikut tidak boleh dilakukan dengan ValueTask / ValueTask <TResult>:
- Tunggu berulang ValueTask / ValueTask <TResult> Objek hasil mungkin sudah dibuang dan digunakan dalam operasi lain. Sebaliknya, Tugas / Tugas <TResult> tidak pernah transisi dari kondisi selesai ke yang tidak lengkap, sehingga Anda dapat mengharapkannya sebanyak yang diperlukan dan mendapatkan hasil yang sama setiap kali.
- ValueTask / ValueTask menunggu paralel. Objek hasil mengharapkan pemrosesan dengan hanya satu panggilan balik dari satu konsumen pada satu waktu, dan mencoba menunggu dari aliran yang berbeda pada saat yang sama dapat dengan mudah menyebabkan balapan dan kesalahan program yang halus. Selain itu, ini juga merupakan kasus yang lebih spesifik dari operasi "tunggu-ulang" yang tidak valid sebelumnya. Sebagai perbandingan, Tugas / Tugas <TResult> menyediakan sejumlah paralel yang menunggu.
- Menggunakan .GetAwaiter (). GetResult () ketika operasi belum selesai. Implementasi IValueTaskSource / IValueTaskSource <TResult> tidak perlu mengunci dukungan sampai operasi selesai, dan kemungkinan besar tidak akan melakukannya, sehingga operasi seperti itu pasti akan mengarah ke balap dan mungkin tidak akan dieksekusi seperti yang diharapkan oleh metode panggilan. Tugas / Tugas <TResult> memblokir utas panggilan sampai tugas selesai.
Jika Anda menerima ValueTask atau ValueTask <TResult>, tetapi Anda harus melakukan salah satu dari tiga operasi ini, Anda dapat menggunakan .AsTask (), dapatkan Tugas / Tugas <TResult> dan kemudian bekerja dengan objek yang diterima. Setelah itu, Anda tidak bisa lagi menggunakan ValueTask / ValueTask itu <TResult>.
Singkatnya, aturannya adalah ini: ketika menggunakan ValueTask / ValueTask <TResult> Anda harus menunggunya secara langsung (mungkin dengan .ConfigureAwait (false)) atau memanggil AsTask () dan tidak menggunakannya lagi:
// , ValueTask<int> public ValueTask<int\> SomeValueTaskReturningMethodAsync(); ... // GOOD int result = await SomeValueTaskReturningMethodAsync(); // GOOD int result = await SomeValueTaskReturningMethodAsync().ConfigureAwait(false); // GOOD Task<int> t = SomeValueTaskReturningMethodAsync().AsTask(); // WARNING ValueTask<int> vt = SomeValueTaskReturningMethodAsync(); // , // // BAD: await ValueTask<int> vt = SomeValueTaskReturningMethodAsync(); int result = await vt; int result2 = await vt; // BAD: await ( ) ValueTask<int> vt = SomeValueTaskReturningMethodAsync(); Task.Run(async () => await vt); Task.Run(async () => await vt); // BAD: GetAwaiter().GetResult(), ValueTask<int> vt = SomeValueTaskReturningMethodAsync(); int result = vt.GetAwaiter().GetResult();
Ada satu lagi pola canggih yang bisa diterapkan oleh para pemrogram, saya harap, hanya setelah pengukuran yang cermat dan mendapatkan keuntungan yang signifikan. Kelas ValueTask / ValueTask <TResult> memiliki beberapa properti yang melaporkan keadaan operasi saat ini, misalnya, properti IsCompleted mengembalikan true jika operasi selesai (yaitu, tidak lagi berjalan dan selesai dengan sukses atau tidak berhasil), dan properti IsCompletedSuccessfully mengembalikan true, hanya jika berhasil diselesaikan (sambil menunggu dan menerima hasilnya, itu tidak membuang pengecualian). Untuk utas eksekusi yang paling menuntut, di mana pengembang ingin menghindari biaya yang muncul dalam mode asinkron, properti ini dapat diperiksa sebelum operasi yang benar-benar menghancurkan objek ValueTask / ValueTask <TResult>, misalnya menunggu, .AsTask (). Misalnya, dalam implementasi SocketsHttpHandler di .NET Core 2.1, kode membaca dari koneksi dan menerima ValueTask <int>. Jika operasi ini dilakukan secara serempak, kami tidak perlu khawatir tentang penghentian operasi lebih awal. Tetapi jika itu berjalan secara tidak sinkron, kita harus menghubungkan proses interupsi sehingga permintaan interupsi memutuskan koneksi. Karena ini adalah bagian kode yang sangat menegangkan, jika pembuatan profil menunjukkan perlunya perubahan kecil berikut, ini dapat disusun seperti ini:
int bytesRead; { ValueTask<int> readTask = _connection.ReadAsync(buffer); if (readTask.IsCompletedSuccessfully) { bytesRead = readTask.Result; } else { using (_connection.RegisterCancellation()) { bytesRead = await readTask; } } }
Haruskah setiap metode API asinkron baru mengembalikan ValueTask / ValueTask <TResult>?
Untuk menjawab secara singkat: tidak, secara default masih layak untuk memilih Tugas / Tugas <TResult>.
Seperti yang disorot di atas, Tugas dan Tugas <TResult> lebih mudah digunakan dengan benar daripada ValueTask dan ValueTask <TResult>, dan selama persyaratan kinerja tidak melebihi persyaratan kepraktisan, Tugas dan Tugas <TResult> lebih disukai. Selain itu, ada biaya kecil yang terkait dengan mengembalikan ValueTask <TResult> alih-alih Tugas <TResult>, yaitu, micro-benchmark menunjukkan bahwa menunggu Task <TResult> lebih cepat daripada menunggu ValueTask <TResult>. Jadi, jika Anda menggunakan caching tugas, misalnya, metode Anda mengembalikan Tugas atau Tugas, untuk kinerja sebaiknya tetap menggunakan Tugas atau Tugas. Objek ValueTask / ValueTask <TResult> menempati beberapa kata dalam memori, oleh karena itu, ketika mereka diharapkan dan bidang mereka dicadangkan di mesin negara yang memanggil metode async, mereka akan menempati lebih banyak memori di dalamnya.
- ValueTask/ValueTask<TResult> : ) , await, ) , ) , . , / .
ValueTask ValueTask<TResult>?
.NET , Task/Task<TResult>, , ValueTask/ValueTask<TResult>, , . â IAsyncEnumerator<T>, .NET Core 3.0. IEnumerator<T> MoveNext, bool, IAsyncEnumerator<T> MoveNextAsync. , , Task, . , , , ( ), await foreach, ValueTask. , . C# , , , .