.NET: Alat untuk bekerja dengan multithreading dan asinkron. Bagian 1

Saya menerbitkan artikel asli di Habr, terjemahannya diposting di perusahaan posting blog.

Kebutuhan untuk melakukan sesuatu secara asinkron, tanpa menunggu hasilnya di sini dan saat ini, atau untuk membagi pekerjaan besar di antara beberapa unit yang melaksanakannya, sudah ada sebelum munculnya komputer. Dengan munculnya mereka, kebutuhan ini menjadi sangat nyata. Sekarang, di tahun 2019, saya mengetik artikel ini di laptop dengan prosesor Intel Core 8-core, di mana lebih dari seratus proses berjalan secara paralel, dan bahkan lebih banyak thread. Di dekatnya ada ponsel yang agak lusuh, dibeli beberapa tahun lalu, memiliki prosesor 8-inti. Sumber daya tematik penuh dengan artikel dan video yang penulisnya mengagumi smartphone andalan tahun ini yang menampilkan prosesor 16-inti. MS Azure menyediakan mesin virtual dengan prosesor 20 inti dan RAM 128 TB dengan harga kurang dari $2/jam. Sayangnya, tidak mungkin untuk mengekstrak secara maksimal dan memanfaatkan kekuatan ini tanpa mampu mengatur interaksi benang.

Terminologi

Proses - Objek OS, ruang alamat terisolasi, berisi utas.
Benang - objek OS, unit eksekusi terkecil, bagian dari suatu proses, thread berbagi memori dan sumber daya lainnya di antara mereka sendiri dalam suatu proses.
Multitasking - Properti OS, kemampuan untuk menjalankan beberapa proses secara bersamaan
Multi-inti - properti prosesor, kemampuan untuk menggunakan beberapa inti untuk pemrosesan data
Multiproses - properti komputer, kemampuan untuk bekerja secara bersamaan dengan beberapa prosesor secara fisik
Multithread — properti suatu proses, kemampuan untuk mendistribusikan pemrosesan data di antara beberapa thread.
Paralelisme - melakukan beberapa tindakan secara fisik secara bersamaan per satuan waktu
Asinkron — eksekusi suatu operasi tanpa menunggu selesainya pemrosesan ini; hasil eksekusi dapat diproses nanti.

Metafora

Tidak semua definisi bagus dan beberapa memerlukan penjelasan tambahan, jadi saya akan menambahkan metafora tentang memasak sarapan ke dalam terminologi yang diperkenalkan secara resmi. Memasak sarapan dalam metafora ini adalah sebuah proses.

Sambil menyiapkan sarapan di pagi hari aku (CPU) Saya datang ke dapur (Komputer). Saya punya 2 tangan (Warna). Ada sejumlah peralatan di dapur (IO): oven, ketel, pemanggang roti, kulkas. Saya nyalakan gas, taruh wajan di atasnya dan tuangkan minyak ke dalamnya tanpa menunggu panas (secara asinkron, Non-Blocking-IO-Wait), telur saya keluarkan dari lemari es dan pecahkan ke dalam piring, lalu kocok dengan satu tangan (Utas #1), dan kedua (Utas #2) memegang piring (Sumber Daya Bersama). Sekarang saya ingin menyalakan ketel, tetapi tangan saya tidak cukup (Kelaparan Benang) Selama waktu ini, penggorengan memanas (Memproses hasilnya) ke dalamnya saya menuangkan apa yang telah saya kocok. Saya meraih ketel dan menyalakannya dan dengan bodohnya melihat air mendidih di dalamnya (Memblokir-IO-Tunggu), meskipun selama ini dia bisa saja mencuci piring tempat dia mengocok telur dadar.

Saya memasak telur dadar hanya dengan 2 tangan, dan saya tidak punya tangan lagi, tetapi pada saat yang sama, pada saat mengocok telur dadar, terjadi 3 operasi sekaligus: mengocok telur dadar, memegang piring, memanaskan penggorengan CPU adalah bagian tercepat dari komputer, IO adalah bagian yang paling sering memperlambat segalanya, sehingga sering kali solusi yang efektif adalah dengan menyibukkan CPU dengan sesuatu saat menerima data dari IO.

Melanjutkan metafora:

  • Kalau dalam proses pembuatan telur dadar saya juga mencoba berganti pakaian, ini contoh multitasking. Nuansa penting: komputer jauh lebih baik dalam hal ini daripada manusia.
  • Dapur dengan beberapa koki, misalnya di restoran - komputer multi-core.
  • Banyak restoran di food court di pusat perbelanjaan - pusat data

Alat .NET

.NET pandai bekerja dengan thread, seperti banyak hal lainnya. Dengan setiap versi baru, ia memperkenalkan lebih banyak alat baru untuk bekerja dengannya, lapisan abstraksi baru pada thread OS. Saat bekerja dengan konstruksi abstraksi, pengembang kerangka kerja menggunakan pendekatan yang memberikan peluang, saat menggunakan abstraksi tingkat tinggi, untuk turun satu atau lebih level di bawahnya. Seringkali hal ini tidak diperlukan, bahkan hal ini membuka pintu untuk menembak kaki Anda sendiri dengan senapan, namun terkadang, dalam kasus yang jarang terjadi, ini mungkin satu-satunya cara untuk memecahkan masalah yang tidak terpecahkan pada tingkat abstraksi saat ini. .

Yang saya maksud dengan alat adalah antarmuka pemrograman aplikasi (API) yang disediakan oleh kerangka kerja dan paket pihak ketiga, serta seluruh solusi perangkat lunak yang menyederhanakan pencarian masalah apa pun yang terkait dengan kode multi-utas.

Memulai sebuah thread

Kelas Thread adalah kelas paling dasar di .NET untuk bekerja dengan thread. Konstruktor menerima salah satu dari dua delegasi:

  • ThreadStart — Tanpa parameter
  • ParametrizedThreadStart - dengan satu parameter tipe objek.

Delegasi akan dieksekusi di thread yang baru dibuat setelah memanggil metode Start. Jika delegasi bertipe ParametrizedThreadStart diteruskan ke konstruktor, maka objek harus diteruskan ke metode Start. Mekanisme ini diperlukan untuk mentransfer informasi lokal apa pun ke aliran. Perlu dicatat bahwa membuat thread adalah operasi yang mahal, dan thread itu sendiri adalah objek yang berat, setidaknya karena thread tersebut mengalokasikan 1MB memori pada stack dan memerlukan interaksi dengan OS API.

new Thread(...).Start(...);

Kelas ThreadPool mewakili konsep kumpulan. Di .NET, kumpulan thread adalah sebuah rekayasa, dan pengembang di Microsoft telah berupaya keras untuk memastikan kumpulan thread berfungsi secara optimal dalam berbagai skenario.

Konsep umum:

Sejak aplikasi dimulai, ia membuat beberapa utas cadangan di latar belakang dan menyediakan kemampuan untuk menggunakannya. Jika thread sering digunakan dan dalam jumlah besar, kumpulan akan diperluas untuk memenuhi kebutuhan pemanggil. Ketika tidak ada thread gratis di kumpulan pada waktu yang tepat, thread tersebut akan menunggu salah satu thread kembali, atau membuat thread baru. Oleh karena itu, kumpulan thread sangat bagus untuk beberapa tindakan jangka pendek dan kurang cocok untuk operasi yang dijalankan sebagai layanan di seluruh pengoperasian aplikasi.

Untuk menggunakan thread dari pool, ada metode QueueUserWorkItem yang menerima delegasi tipe WaitCallback, yang memiliki tanda tangan yang sama dengan ParametrizedThreadStart, dan parameter yang diteruskan ke sana menjalankan fungsi yang sama.

ThreadPool.QueueUserWorkItem(...);

Metode kumpulan thread yang kurang dikenal RegisterWaitForSingleObject digunakan untuk mengatur operasi IO non-pemblokiran. Delegasi yang diteruskan ke metode ini akan dipanggil ketika WaitHandle yang diteruskan ke metode tersebut “Dirilis”.

ThreadPool.RegisterWaitForSingleObject(...)

.NET memiliki pengatur waktu thread dan berbeda dari pengatur waktu WinForms/WPF karena pengendalinya akan dipanggil pada thread yang diambil dari kumpulan.

System.Threading.Timer

Ada juga cara yang agak eksotis untuk mengirim delegasi untuk dieksekusi ke thread dari pool - metode BeginInvoke.

DelegateInstance.BeginInvoke

Saya ingin membahas secara singkat fungsi yang dapat dipanggil oleh banyak metode di atas - CreateThread dari Kernel32.dll Win32 API. Berkat mekanisme metode eksternal, ada cara untuk memanggil fungsi ini. Saya telah melihat panggilan seperti itu hanya sekali dalam contoh kode warisan yang buruk, dan motivasi penulis yang melakukan hal ini masih tetap menjadi misteri bagi saya.

Kernel32.dll CreateThread

Melihat dan Men-debug Thread

Utas yang Anda buat, semua komponen pihak ketiga, dan kumpulan .NET dapat dilihat di jendela Utas Visual Studio. Jendela ini hanya akan menampilkan informasi thread ketika aplikasi sedang dalam debug dan dalam mode Istirahat. Di sini Anda dapat dengan mudah melihat nama tumpukan dan prioritas setiap thread, dan mengalihkan proses debug ke thread tertentu. Dengan menggunakan properti Prioritas dari kelas Thread, Anda dapat mengatur prioritas thread, yang akan dianggap oleh OC dan CLR sebagai rekomendasi saat membagi waktu prosesor antar thread.

.NET: Alat untuk bekerja dengan multithreading dan asinkron. Bagian 1

Perpustakaan Paralel Tugas

Perpustakaan Paralel Tugas (TPL) diperkenalkan di .NET 4.0. Sekarang ini adalah standar dan alat utama untuk bekerja dengan asinkron. Kode apa pun yang menggunakan pendekatan lama dianggap warisan. Unit dasar TPL adalah kelas Task dari namespace System.Threading.Tasks. Tugas adalah abstraksi pada thread. Dengan versi baru bahasa C#, kami mendapatkan cara elegan untuk bekerja dengan Tasks - operator async/await. Konsep-konsep ini memungkinkan untuk menulis kode asynchronous seolah-olah sederhana dan sinkron, hal ini memungkinkan bahkan bagi orang-orang dengan sedikit pemahaman tentang cara kerja internal thread untuk menulis aplikasi yang menggunakannya, aplikasi yang tidak membeku ketika melakukan operasi yang lama. Menggunakan async/await adalah topik untuk satu atau bahkan beberapa artikel, tapi saya akan mencoba memahami intinya dalam beberapa kalimat:

  • async adalah pengubah metode yang mengembalikan Tugas atau batal
  • dan menunggu adalah operator Tugas menunggu non-pemblokiran.

Sekali lagi: operator menunggu, dalam kasus umum (ada pengecualian), akan melepaskan thread eksekusi saat ini lebih lanjut, dan ketika Tugas menyelesaikan eksekusinya, dan thread tersebut (sebenarnya, akan lebih tepat untuk mengatakan konteksnya , tetapi akan dibahas lebih lanjut nanti) akan terus menjalankan metode ini lebih lanjut. Di dalam .NET, mekanisme ini diimplementasikan dengan cara yang sama seperti pengembalian hasil, ketika metode tertulis diubah menjadi seluruh kelas, yang merupakan mesin status dan dapat dieksekusi dalam bagian terpisah bergantung pada status ini. Siapa pun yang tertarik dapat menulis kode sederhana apa pun menggunakan async/await, mengkompilasi dan melihat perakitan menggunakan JetBrains dotPeek dengan Compiler Generated Code diaktifkan.

Mari kita lihat opsi untuk meluncurkan dan menggunakan Task. Pada contoh kode di bawah ini, kita membuat tugas baru yang tidak berguna (Thread.Tidur(10000)), tetapi dalam kehidupan nyata ini seharusnya merupakan pekerjaan kompleks yang membutuhkan banyak CPU.

using TCO = System.Threading.Tasks.TaskCreationOptions;

public static async void VoidAsyncMethod() {
    var cancellationSource = new CancellationTokenSource();

    await Task.Factory.StartNew(
        // Code of action will be executed on other context
        () => Thread.Sleep(10000),
        cancellationSource.Token,
        TCO.LongRunning | TCO.AttachedToParent | TCO.PreferFairness,
        scheduler
    );

    //  Code after await will be executed on captured context
}

Tugas dibuat dengan sejumlah opsi:

  • LongRunning adalah petunjuk bahwa tugas tidak akan selesai dengan cepat, yang berarti mungkin ada baiknya mempertimbangkan untuk tidak mengambil utas dari kumpulan, tetapi membuat utas terpisah untuk Tugas ini agar tidak merugikan orang lain.
  • AttachedToParent - Tugas dapat diatur dalam hierarki. Jika opsi ini digunakan, maka Tugas mungkin berada dalam keadaan telah selesai dan menunggu eksekusi turunannya.
  • PreferFairness - berarti akan lebih baik untuk mengeksekusi Tugas yang dikirim untuk dieksekusi lebih awal sebelum dikirim nanti. Namun ini hanyalah rekomendasi dan tidak menjamin hasilnya.

Parameter kedua yang diteruskan ke metode ini adalah CancellationToken. Untuk menangani pembatalan operasi dengan benar setelah dimulai, kode yang dieksekusi harus diisi dengan pemeriksaan status CancellationToken. Jika tidak ada pemeriksaan, maka metode Batal yang dipanggil pada objek CancellationTokenSource akan dapat menghentikan eksekusi Tugas hanya sebelum dimulai.

Parameter terakhir adalah objek penjadwal bertipe TaskScheduler. Kelas ini dan turunannya dirancang untuk mengontrol strategi pendistribusian Tugas di seluruh thread; secara default, Tugas akan dieksekusi pada thread acak dari kumpulan.

Operator menunggu diterapkan pada Tugas yang dibuat, yang berarti kode yang ditulis setelahnya, jika ada, akan dieksekusi dalam konteks yang sama (seringkali ini berarti pada thread yang sama) dengan kode sebelum menunggu.

Metode ini ditandai sebagai async void, yang berarti dapat menggunakan operator menunggu, namun kode pemanggil tidak akan dapat menunggu eksekusi. Jika fitur tersebut diperlukan, maka metode tersebut harus mengembalikan Tugas. Metode bertanda async void cukup umum: biasanya, ini adalah pengendali peristiwa atau metode lain yang bekerja berdasarkan prinsip api dan lupakan. Jika Anda tidak hanya perlu memberikan kesempatan untuk menunggu hingga eksekusi selesai, tetapi juga mengembalikan hasilnya, maka Anda perlu menggunakan Task.

Pada Tugas yang dikembalikan oleh metode StartNew, dan juga metode lainnya, Anda dapat memanggil metode ConfigureAwait dengan parameter false, kemudian eksekusi setelah menunggu akan dilanjutkan bukan pada konteks yang diambil, tetapi pada konteks yang sewenang-wenang. Ini harus selalu dilakukan ketika konteks eksekusi tidak penting untuk kode setelah menunggu. Ini juga merupakan rekomendasi dari MS saat menulis kode yang akan dikirimkan dalam bentuk paket di perpustakaan.

Mari kita membahas lebih jauh tentang bagaimana Anda bisa menunggu selesainya suatu Tugas. Di bawah ini adalah contoh kode, dengan komentar mengenai kapan ekspektasi tersebut dilakukan dengan baik secara kondisional dan kapan ekspektasi tersebut dilakukan dengan buruk secara kondisional.

public static async void AnotherMethod() {

    int result = await AsyncMethod(); // good

    result = AsyncMethod().Result; // bad

    AsyncMethod().Wait(); // bad

    IEnumerable<Task> tasks = new Task[] {
        AsyncMethod(), OtherAsyncMethod()
    };

    await Task.WhenAll(tasks); // good
    await Task.WhenAny(tasks); // good

    Task.WaitAll(tasks.ToArray()); // bad
}

Pada contoh pertama, kita menunggu hingga Tugas selesai tanpa memblokir thread pemanggil; kita akan kembali memproses hasilnya hanya jika hasilnya sudah ada; hingga saat itu, thread pemanggil diserahkan ke perangkatnya sendiri.

Pada opsi kedua, kami memblokir thread pemanggil hingga hasil metode dihitung. Ini buruk bukan hanya karena kita telah menempati thread, sumber daya program yang sangat berharga, dengan kemalasan sederhana, tetapi juga karena jika kode metode yang kita panggil berisi menunggu, dan konteks sinkronisasi memerlukan kembali ke thread pemanggil setelahnya. menunggu, maka kita akan mendapatkan kebuntuan : Thread pemanggil menunggu hasil dari metode asynchronous dihitung, metode asynchronous mencoba dengan sia-sia untuk melanjutkan eksekusinya di thread pemanggil.

Kerugian lain dari pendekatan ini adalah penanganan kesalahan yang rumit. Faktanya adalah kesalahan dalam kode asinkron saat menggunakan async/await sangat mudah ditangani - perilakunya sama seperti jika kode tersebut sinkron. Sementara jika kita menerapkan pengusiran setan menunggu sinkron ke Tugas, pengecualian asli berubah menjadi AggregateException, yaitu. Untuk menangani pengecualian ini, Anda harus memeriksa tipe InnerException dan menulis rantai if sendiri di dalam satu blok catch atau menggunakan konstruksi catch ketika, alih-alih rantai blok catch yang lebih familiar di dunia C#.

Contoh ketiga dan terakhir juga ditandai buruk karena alasan yang sama dan berisi semua masalah yang sama.

Metode WhenAny dan WhenAll sangat nyaman untuk menunggu sekelompok Tugas; metode ini menggabungkan sekelompok Tugas menjadi satu, yang akan diaktifkan ketika Tugas dari grup pertama kali dipicu, atau ketika semuanya telah menyelesaikan eksekusinya.

Menghentikan thread

Karena berbagai alasan, aliran mungkin perlu dihentikan setelah dimulai. Ada beberapa cara untuk melakukan ini. Kelas Thread memiliki dua metode yang diberi nama dengan tepat: Batalkan и interrupt. Yang pertama sangat tidak disarankan untuk digunakan, karena setelah memanggilnya kapan saja, selama pemrosesan instruksi apa pun, pengecualian akan dilempar ThreadAbortedException. Anda tidak mengharapkan pengecualian seperti itu terjadi saat menambah variabel integer apa pun, bukan? Dan ketika menggunakan metode ini, ini adalah situasi yang sangat nyata. Jika Anda perlu mencegah CLR menghasilkan pengecualian seperti itu di bagian kode tertentu, Anda dapat menggabungkannya dalam panggilan Thread.BeginCriticalRegion, Thread.EndCriticalRegion. Kode apa pun yang ditulis dalam blok akhirnya dibungkus dengan panggilan tersebut. Karena alasan ini, di kedalaman kode kerangka kerja Anda dapat menemukan blok dengan percobaan kosong, tetapi bukan akhirnya yang kosong. Microsoft sangat tidak menganjurkan metode ini sehingga mereka tidak memasukkannya ke dalam .net core.

Metode Interupsi bekerja lebih dapat diprediksi. Itu dapat mengganggu thread dengan pengecualian Pengecualian ThreadInterrupted hanya pada saat-saat ketika thread berada dalam kondisi menunggu. Ia memasuki keadaan ini saat hang sambil menunggu WaitHandle, mengunci, atau setelah memanggil Thread.Sleep.

Kedua opsi yang dijelaskan di atas buruk karena sifatnya yang tidak dapat diprediksi. Solusinya adalah dengan menggunakan struktur Token Pembatalan dan kelas PembatalanTokenSource. Intinya begini: sebuah instance dari kelas CancellationTokenSource dibuat dan hanya pemiliknya yang dapat menghentikan operasi dengan memanggil metode tersebut Cancel. Hanya CancellationToken yang diteruskan ke operasi itu sendiri. Pemilik CancellationToken tidak dapat membatalkan operasinya sendiri, tetapi hanya dapat memeriksa apakah operasi tersebut telah dibatalkan. Ada properti Boolean untuk ini Apakah Pembatalan Diminta dan metode ThrowIfCancelDiminta. Yang terakhir akan memberikan pengecualian Pengecualian TaskCancelled jika metode Batal dipanggil pada instance CancellationToken yang di-parrot. Dan inilah metode yang saya rekomendasikan untuk digunakan. Ini merupakan peningkatan dibandingkan opsi sebelumnya dengan mendapatkan kendali penuh atas kapan operasi pengecualian dapat dibatalkan.

Opsi paling brutal untuk menghentikan thread adalah dengan memanggil fungsi Win32 API TerminateThread. Perilaku CLR setelah memanggil fungsi ini mungkin tidak dapat diprediksi. Di MSDN berikut ini ditulis tentang fungsi ini: “TerminateThread adalah fungsi berbahaya yang hanya boleh digunakan dalam kasus yang paling ekstrim. “

Mengubah API lama menjadi Berbasis Tugas menggunakan metode FromAsync

Jika Anda cukup beruntung untuk mengerjakan proyek yang dimulai setelah Tasks diperkenalkan dan tidak lagi menimbulkan kengerian bagi sebagian besar pengembang, maka Anda tidak perlu berurusan dengan banyak API lama, baik pihak ketiga maupun tim Anda. telah menyiksa di masa lalu. Untungnya, tim .NET Framework menjaga kami, meskipun mungkin tujuannya adalah untuk menjaga diri kami sendiri. Bagaimanapun, .NET memiliki sejumlah alat untuk dengan mudah mengubah kode yang ditulis dalam pendekatan pemrograman asinkron lama ke yang baru. Salah satunya adalah metode FromAsync dari TaskFactory. Dalam contoh kode di bawah ini, saya menggabungkan metode async lama dari kelas WebRequest dalam Tugas menggunakan metode ini.

object state = null;
WebRequest wr = WebRequest.CreateHttp("http://github.com");
await Task.Factory.FromAsync(
    wr.BeginGetResponse,
    we.EndGetResponse
);

Ini hanyalah sebuah contoh dan kemungkinan besar Anda tidak perlu melakukan ini dengan tipe bawaan, tetapi proyek lama mana pun hanya dipenuhi dengan metode BeginDoSomething yang mengembalikan metode IAsyncResult dan EndDoSomething yang menerimanya.

Ubah API lama menjadi Berbasis Tugas menggunakan kelas TaskCompletionSource

Alat penting lainnya untuk dipertimbangkan adalah kelas Sumber Penyelesaian Tugas. Dalam hal fungsi, tujuan dan prinsip operasi, ini mungkin mengingatkan pada metode RegisterWaitForSingleObject dari kelas ThreadPool, yang saya tulis di atas. Dengan menggunakan kelas ini, Anda dapat dengan mudah dan nyaman menggabungkan API asinkron lama di Tasks.

Anda akan mengatakan bahwa saya telah membicarakan tentang metode FromAsync dari kelas TaskFactory yang ditujukan untuk tujuan ini. Di sini kita harus mengingat seluruh sejarah pengembangan model asinkron di .net yang ditawarkan Microsoft selama 15 tahun terakhir: sebelum Pola Asinkron Berbasis Tugas (TAP), ada Pola Pemrograman Asinkron (APP), yang mana adalah tentang metode MulaiLakukan Sesuatu kembali Hasil IAsync dan metode AkhirDoSomething yang menerimanya dan warisan tahun-tahun ini metode FromAsync sempurna, namun seiring berjalannya waktu, metode ini digantikan oleh Pola Asinkron Berbasis Peristiwa (EAP), yang mengasumsikan bahwa suatu peristiwa akan dimunculkan ketika operasi asinkron selesai.

TaskCompletionSource sempurna untuk menggabungkan Tugas dan API lama yang dibangun berdasarkan model peristiwa. Inti dari kerjanya adalah sebagai berikut: objek kelas ini memiliki properti publik bertipe Task, yang statusnya dapat dikontrol melalui metode SetResult, SetException, dll. dari kelas TaskCompletionSource. Di tempat di mana operator menunggu diterapkan pada Tugas ini, itu akan dieksekusi atau gagal dengan pengecualian tergantung pada metode yang diterapkan pada TaskCompletionSource. Jika masih belum jelas, mari kita lihat contoh kode ini, di mana beberapa API EAP lama dibungkus dalam Tugas menggunakan TaskCompletionSource: ketika acara diaktifkan, Tugas akan ditransfer ke status Selesai, dan metode yang menerapkan operator menunggu untuk Tugas ini akan melanjutkan eksekusinya setelah menerima objek mengakibatkan.

public static Task<Result> DoAsync(this SomeApiInstance someApiObj) {

    var completionSource = new TaskCompletionSource<Result>();
    someApiObj.Done += 
        result => completionSource.SetResult(result);
    someApiObj.Do();

    result completionSource.Task;
}

Tip & Trik Sumber Penyelesaian Tugas

Membungkus API lama bukanlah satu-satunya hal yang dapat dilakukan menggunakan TaskCompletionSource. Menggunakan kelas ini membuka kemungkinan menarik untuk merancang berbagai API pada Tugas yang tidak menempati thread. Dan alirannya, seperti yang kita ingat, adalah sumber daya yang mahal dan jumlahnya terbatas (terutama karena jumlah RAM). Keterbatasan ini dapat dengan mudah dicapai dengan mengembangkan, misalnya, aplikasi web yang dimuat dengan logika bisnis yang kompleks. Mari kita pertimbangkan kemungkinan yang saya bicarakan ketika menerapkan trik seperti Long-Polling.

Singkatnya, inti dari triknya adalah ini: Anda perlu menerima informasi dari API tentang beberapa peristiwa yang terjadi di sisinya, sedangkan API, karena alasan tertentu, tidak dapat melaporkan peristiwa tersebut, tetapi hanya dapat mengembalikan status. Contohnya adalah semua API yang dibangun di atas HTTP sebelum zaman WebSocket atau ketika karena alasan tertentu tidak mungkin menggunakan teknologi ini. Klien dapat bertanya ke server HTTP. Server HTTP tidak dapat memulai komunikasi dengan klien sendiri. Solusi sederhana adalah dengan melakukan polling ke server menggunakan pengatur waktu, tetapi hal ini menimbulkan beban tambahan di server dan penundaan tambahan rata-rata TimerInterval / 2. Untuk menyiasatinya, sebuah trik yang disebut Long Polling diciptakan, yang melibatkan penundaan respons dari server sampai Timeout berakhir atau suatu peristiwa akan terjadi. Jika peristiwa sudah terjadi maka diproses, jika belum maka permintaan dikirim kembali.

while(!eventOccures && !timeoutExceeded)  {

  CheckTimout();
  CheckEvent();
  Thread.Sleep(1);
}

Namun solusi seperti itu akan menjadi buruk segera setelah jumlah klien yang menunggu acara meningkat, karena... Setiap klien tersebut menempati seluruh thread menunggu suatu acara. Ya, dan kami mendapat tambahan penundaan 1 ms saat peristiwa dipicu, sering kali hal ini tidak signifikan, tetapi mengapa membuat perangkat lunak menjadi lebih buruk dari yang seharusnya? Jika kita menghapus Thread.Sleep(1), maka sia-sia kita akan memuat satu inti prosesor 100% menganggur, berputar dalam siklus yang tidak berguna. Dengan menggunakan TaskCompletionSource Anda dapat dengan mudah membuat ulang kode ini dan menyelesaikan semua masalah yang diidentifikasi di atas:

class LongPollingApi {

    private Dictionary<int, TaskCompletionSource<Msg>> tasks;

    public async Task<Msg> AcceptMessageAsync(int userId, int duration) {

        var cs = new TaskCompletionSource<Msg>();
        tasks[userId] = cs;
        await Task.WhenAny(Task.Delay(duration), cs.Task);
        return cs.Task.IsCompleted ? cs.Task.Result : null;
    }

    public void SendMessage(int userId, Msg m) {

        if (tasks.TryGetValue(userId, out var completionSource))
            completionSource.SetResult(m);
    }
}

Kode ini belum siap produksi, tetapi hanya demo. Untuk menggunakannya dalam kasus nyata, Anda juga perlu, setidaknya, menangani situasi ketika pesan tiba pada saat tidak ada yang mengharapkannya: dalam hal ini, metode AsseptMessageAsync harus mengembalikan Tugas yang sudah selesai. Jika ini adalah kasus yang paling umum, Anda dapat mempertimbangkan untuk menggunakan ValueTask.

Saat kami menerima permintaan pesan, kami membuat dan menempatkan TaskCompletionSource di kamus, lalu menunggu apa yang terjadi terlebih dahulu: interval waktu yang ditentukan telah habis atau pesan diterima.

ValueTask: mengapa dan bagaimana

Operator async/await, seperti operator pengembalian hasil, menghasilkan mesin status dari metode tersebut, dan ini adalah pembuatan objek baru, yang hampir selalu tidak penting, namun dalam kasus yang jarang terjadi dapat menimbulkan masalah. Kasus ini mungkin merupakan metode yang sering dipanggil, kita berbicara tentang puluhan dan ratusan ribu panggilan per detik. Jika metode seperti itu ditulis sedemikian rupa sehingga dalam banyak kasus ia mengembalikan hasil yang melewati semua metode menunggu, maka .NET menyediakan alat untuk mengoptimalkannya - struktur ValueTask. Agar lebih jelas mari kita lihat contoh penggunaannya: ada cache yang sangat sering kita kunjungi. Ada beberapa nilai di dalamnya dan kemudian kita mengembalikannya; jika tidak, maka kita pergi ke beberapa IO lambat untuk mendapatkannya. Saya ingin melakukan yang terakhir secara asinkron, yang berarti seluruh metode menjadi asinkron. Jadi, cara penulisan metode yang jelas adalah sebagai berikut:

public async Task<string> GetById(int id) {

    if (cache.TryGetValue(id, out string val))
        return val;
    return await RequestById(id);
}

Karena keinginan untuk sedikit mengoptimalkan, dan sedikit ketakutan dengan apa yang akan dihasilkan Roslyn saat mengkompilasi kode ini, Anda dapat menulis ulang contoh ini sebagai berikut:

public Task<string> GetById(int id) {

    if (cache.TryGetValue(id, out string val))
        return Task.FromResult(val);
    return RequestById(id);
}

Memang benar, solusi optimal dalam hal ini adalah mengoptimalkan jalur panas, yaitu memperoleh nilai dari kamus tanpa alokasi yang tidak perlu dan memuat pada GC, sementara dalam kasus yang jarang terjadi ketika kita masih perlu pergi ke IO untuk mendapatkan data , semuanya akan tetap plus / minus cara lama:

public ValueTask<string> GetById(int id) {

    if (cache.TryGetValue(id, out string val))
        return new ValueTask<string>(val);
    return new ValueTask<string>(RequestById(id));
}

Mari kita lihat lebih dekat potongan kode ini: jika ada nilai di cache, kita membuat struktur, jika tidak, tugas sebenarnya akan dibungkus dengan yang bermakna. Kode pemanggil tidak peduli di jalur mana kode ini dieksekusi: ValueTask, dari sudut pandang sintaksis C#, akan berperilaku sama seperti Task biasa dalam kasus ini.

Penjadwal Tugas: mengelola strategi peluncuran tugas

API berikutnya yang ingin saya pertimbangkan adalah kelasnya Penjadwal Tugas dan turunannya. Saya telah menyebutkan di atas bahwa TPL memiliki kemampuan untuk mengelola strategi untuk mendistribusikan Tugas di seluruh thread. Strategi tersebut didefinisikan dalam turunan kelas TaskScheduler. Hampir semua strategi yang Anda perlukan dapat ditemukan di perpustakaan. Ekstensi ParalelEkstra, dikembangkan oleh Microsoft, tetapi bukan bagian dari .NET, tetapi disediakan sebagai paket Nuget. Mari kita lihat secara singkat beberapa di antaranya:

  • Penjadwal Tugas Thread Saat Ini — menjalankan Tugas pada thread saat ini
  • Penjadwal Tugas Tingkat Konkurensi Terbatas — membatasi jumlah Tugas yang dijalankan secara bersamaan dengan parameter N, yang diterima di konstruktor
  • Penjadwal Tugas yang Dipesan — didefinisikan sebagai LimitedConcurrencyLevelTaskScheduler(1), sehingga tugas akan dijalankan secara berurutan.
  • Penjadwal Tugas Pencurian Kerja - mengimplementasikan mencuri pekerjaan pendekatan pembagian tugas. Pada dasarnya ini adalah ThreadPool terpisah. Memecahkan masalah bahwa di .NET ThreadPool adalah kelas statis, satu untuk semua aplikasi, yang berarti kelebihan beban atau penggunaan yang salah di satu bagian program dapat menyebabkan efek samping di bagian lain. Selain itu, sangat sulit untuk memahami penyebab cacat tersebut. Itu. Mungkin ada kebutuhan untuk menggunakan WorkStealingTaskSchedulers terpisah di bagian program di mana penggunaan ThreadPool mungkin bersifat agresif dan tidak dapat diprediksi.
  • Penjadwal Tugas Antrian — memungkinkan Anda melakukan tugas sesuai dengan aturan antrian prioritas
  • Penjadwal ThreadPerTask — membuat thread terpisah untuk setiap Tugas yang dijalankan di dalamnya. Dapat berguna untuk tugas-tugas yang memerlukan waktu penyelesaian yang sangat lama.

Ada detail yang bagus artikel tentang Penjadwal Tugas di blog microsoft.

Untuk kemudahan debugging segala sesuatu yang berhubungan dengan Tasks, Visual Studio memiliki jendela Tasks. Di jendela ini Anda dapat melihat status tugas saat ini dan melompat ke baris kode yang sedang dijalankan.

.NET: Alat untuk bekerja dengan multithreading dan asinkron. Bagian 1

PLinq dan kelas Paralel

Selain Tugas dan semua yang dikatakan tentangnya, ada dua alat menarik lainnya di .NET: PLinq (Linq2Parallel) dan kelas Parallel. Yang pertama menjanjikan eksekusi paralel dari semua operasi Linq di banyak thread. Jumlah thread dapat dikonfigurasi menggunakan metode ekstensi WithDegreeOfParallelism. Sayangnya, seringkali PLinq dalam mode defaultnya tidak memiliki cukup informasi tentang internal sumber data Anda untuk memberikan peningkatan kecepatan yang signifikan, di sisi lain, biaya percobaannya sangat rendah: Anda hanya perlu memanggil metode AsParallel sebelumnya rantai metode Linq dan menjalankan tes kinerja. Selain itu, informasi tambahan dapat diteruskan ke PLinq tentang sifat sumber data Anda menggunakan mekanisme Partisi. Anda dapat membaca lebih lanjut di sini и di sini.

Kelas statis Paralel menyediakan metode untuk melakukan iterasi melalui koleksi Foreach secara paralel, mengeksekusi perulangan For, dan mengeksekusi beberapa delegasi secara paralel Invoke. Eksekusi thread saat ini akan dihentikan hingga penghitungan selesai. Jumlah thread dapat dikonfigurasi dengan meneruskan ParallelOptions sebagai argumen terakhir. Anda juga dapat menentukan TaskScheduler dan CancellationToken menggunakan opsi.

Temuan

Ketika saya mulai menulis artikel ini berdasarkan bahan laporan saya dan informasi yang saya kumpulkan selama bekerja setelahnya, saya tidak menyangka jumlahnya akan sebanyak itu. Sekarang, ketika editor teks tempat saya mengetik artikel ini dengan nada mencela memberi tahu saya bahwa halaman 15 telah hilang, saya akan merangkum hasil sementara. Trik, API, alat visual, dan jebakan lainnya akan dibahas di artikel berikutnya.

Kesimpulan:

  • Anda perlu mengetahui alat untuk bekerja dengan thread, asinkron, dan paralelisme untuk menggunakan sumber daya PC modern.
  • .NET memiliki banyak alat berbeda untuk tujuan ini
  • Tidak semuanya muncul sekaligus, sehingga Anda sering dapat menemukan API lama, namun ada cara untuk mengonversi API lama tanpa banyak usaha.
  • Bekerja dengan thread di .NET diwakili oleh kelas Thread dan ThreadPool
  • Metode Thread.Abort, Thread.Interrupt, dan Win32 API TerminateThread berbahaya dan tidak direkomendasikan untuk digunakan. Sebaliknya, lebih baik menggunakan mekanisme CancellationToken
  • Arus adalah sumber daya yang berharga dan persediaannya terbatas. Situasi dimana thread sedang sibuk menunggu event harus dihindari. Untuk ini akan lebih mudah untuk menggunakan kelas TaskCompletionSource
  • Alat .NET yang paling kuat dan canggih untuk bekerja dengan paralelisme dan asinkroni adalah Tasks.
  • Operator c# async/await menerapkan konsep menunggu non-pemblokiran
  • Anda dapat mengontrol distribusi Tugas di seluruh thread menggunakan kelas turunan TaskScheduler
  • Struktur ValueTask dapat berguna dalam mengoptimalkan jalur panas dan lalu lintas memori
  • Jendela Tugas dan Utas Visual Studio menyediakan banyak informasi yang berguna untuk men-debug kode multi-utas atau asinkron
  • PLinq adalah alat yang keren, tetapi mungkin tidak memiliki informasi yang cukup tentang sumber data Anda, tetapi hal ini dapat diperbaiki menggunakan mekanisme partisi
  • Untuk dilanjutkan ...

Sumber: www.habr.com

Tambah komentar