.NET multithreading: ketika kinerja kurang



Platform .NET menyediakan banyak primitif sinkronisasi pre-built dan koleksi thread-safe. Jika Anda perlu menerapkan, misalnya, cache thread-safe atau antrian permintaan ketika mengembangkan aplikasi, solusi siap pakai ini biasanya digunakan, kadang-kadang beberapa sekaligus. Dalam beberapa kasus, ini menyebabkan masalah kinerja: menunggu lama pada kunci, konsumsi memori yang berlebihan dan pengumpulan sampah yang lama.

Masalah-masalah ini dapat diselesaikan jika kita mempertimbangkan bahwa solusi standar dibuat cukup umum - mereka dapat memiliki overhead dalam skenario kita yang berlebihan. Karenanya, Anda dapat menulis, misalnya, koleksi aman-aman Anda sendiri untuk kasus tertentu.

Di bawah cutscene adalah video dan transkrip laporan saya dari konferensi DotNext , di mana saya menganalisis beberapa contoh ketika menggunakan alat dari perpustakaan .NET standar (Task.Delay, SemaphoreSlim, ConcurrentDictionary) menyebabkan penurunan kinerja, dan saya mengusulkan solusi yang dirancang untuk tugas-tugas tertentu dan tanpa kekurangan ini.


Pada saat laporan itu dia bekerja di sirkuit. Kontur mengembangkan berbagai aplikasi untuk bisnis, dan tim saya bekerja dalam berurusan dengan infrastruktur dan mengembangkan berbagai layanan dukungan dan perpustakaan yang membantu pengembang di tim lain menciptakan layanan produk.

Tim Infrastruktur membangun gudang data, sistem hosting aplikasi untuk Windows dan berbagai perpustakaan untuk pengembangan layanan-layanan microser. Aplikasi kami didasarkan pada arsitektur layanan mikro - semua layanan berinteraksi satu sama lain melalui jaringan, dan, tentu saja, mereka menggunakan cukup banyak kode asinkron dan multi-berulir. Beberapa aplikasi ini cukup kritis kinerja, mereka harus dapat menangani banyak permintaan.

Apa yang akan kita bicarakan hari ini?

  • Multithreading dan asynchrony di .NET;
  • Mengisi primitif dan koleksi sinkronisasi;
  • Apa yang harus dilakukan jika pendekatan standar tidak dapat mengatasi beban?

Mari kita menganalisis beberapa fitur bekerja dengan kode multithreaded dan asynchronous di .NET. Mari kita lihat beberapa sinkronisasi primitif dan koleksi bersamaan, lihat bagaimana mereka diatur di dalamnya. Kami akan membahas apa yang harus dilakukan jika tidak ada kinerja yang cukup, jika kelas standar tidak dapat mengatasi beban, dan apakah sesuatu dapat dilakukan dalam situasi ini.

Saya akan menceritakan empat kisah yang terjadi di lokasi produksi kami.

Riwayat 1: Tugas.Hapus & TimerQueue


Kisah ini sudah cukup terkenal, termasuk tentang hal itu di DotNext sebelumnya. Namun, ada sekuel yang cukup menarik, jadi saya menambahkannya. Jadi apa gunanya?

1.1 Polling dan polling panjang


Server melakukan operasi lama, klien menunggu untuk mereka.
Polling: klien secara berkala bertanya kepada server tentang hasilnya.
Polling panjang: klien mengirimkan permintaan dengan batas waktu yang lama, dan server merespons ketika operasi selesai.

Keuntungan:

  • Lebih sedikit lalu lintas
  • Klien belajar tentang hasilnya lebih cepat

Bayangkan bahwa kita memiliki server yang dapat menangani beberapa permintaan panjang, misalnya, aplikasi yang mengubah file XML ke PDF, dan ada klien yang menjalankan tugas ini untuk diproses dan ingin menunggu hasilnya secara sinkron. Bagaimana harapan seperti itu dapat terwujud?

Cara pertama adalah polling . Klien memulai tugas di server, kemudian secara berkala memeriksa status tugas ini, sementara server mengembalikan status tugas ("selesai" / "tidak selesai" / "selesai dengan kesalahan"). Klien secara berkala mengirimkan permintaan sampai hasilnya muncul.

Cara kedua adalah polling panjang . Perbedaannya di sini adalah bahwa klien mengirim permintaan dengan waktu tunggu yang lama. Server, menerima permintaan seperti itu, tidak akan segera melaporkan bahwa tugas tersebut belum selesai, tetapi akan mencoba menunggu beberapa saat hingga hasilnya muncul.
Jadi apa keuntungan dari pemungutan suara yang lama dibandingkan pemungutan suara biasa? Pertama, lebih sedikit lalu lintas yang dihasilkan. Kami membuat lebih sedikit permintaan jaringan - lebih sedikit lalu lintas yang dikejar di seluruh jaringan. Selain itu, klien akan dapat mengetahui tentang hasil lebih cepat daripada dengan polling biasa, karena ia tidak perlu menunggu interval antara beberapa permintaan polling. Apa yang ingin kita dapatkan bisa dimengerti. Bagaimana kita menerapkan ini dalam kode?
Tugas: batas waktu
Kami ingin menunggu Tugas dengan batas waktu
menunggu SendAsync ();
Misalnya, kami memiliki Tugas yang mengirimkan permintaan ke server, dan kami ingin menunggu hasilnya dengan batas waktu, yaitu, kami akan mengembalikan hasil Tugas ini atau mengirim beberapa jenis kesalahan. Kode C # akan terlihat seperti ini:

var sendTask = SendAsync(); var delayTask = Task.Delay(timeout); var task = await Task.WhenAny(sendTask, delayTask); if (task == delayTask) return Timeout; 

Kode ini meluncurkan Tugas kami, hasil yang ingin kami tunggu, dan Task.Delay. Selanjutnya, menggunakan Task.WhenAny, kami menunggu Task atau Task.Delay kami. Jika ternyata Task.Delay dijalankan terlebih dahulu, maka waktunya habis dan kami memiliki batas waktu, kami harus mengembalikan kesalahan.

Kode ini, tentu saja, tidak sempurna dan dapat ditingkatkan. Sebagai contoh, tidak ada salahnya untuk membatalkan Task.Delay jika SendAsync kembali lebih awal, tetapi ini tidak terlalu menarik bagi kami sekarang. Intinya adalah bahwa jika kita menulis kode seperti itu dan menerapkannya untuk pemungutan suara lama dengan batas waktu yang lama, kita akan mendapatkan beberapa masalah kinerja.

1.2 Masalah dengan polling panjang


  • Batas waktu besar
  • Banyak permintaan bersamaan
  • => Penggunaan CPU tinggi

Dalam hal ini, masalahnya adalah konsumsi sumber daya prosesor yang tinggi. Mungkin terjadi bahwa prosesor terisi penuh pada 100%, dan aplikasi umumnya berhenti bekerja. Tampaknya kita sama sekali tidak mengonsumsi sumber daya prosesor: kami melakukan beberapa operasi tidak sinkron, menunggu respons dari server, dan prosesor tersebut masih dimuat bersama kami.

Ketika kami menghadapi situasi ini, kami menghapus dump memori dari aplikasi kami:

  ~*e!clrstack System.Threading.Monitor.Enter(System.Object) System.Threading.TimerQueueTimer.Change(โ€ฆ) System.Threading.Timer.TimerSetup(โ€ฆ) System.Threading.Timer..ctor(โ€ฆ) System.Threading.Tasks.Task.Delay(โ€ฆ) 

Untuk menganalisis dump, kami menggunakan alat WinDbg. Kami memasukkan perintah yang menampilkan tumpukan jejak dari semua utas yang dikelola, dan melihat hasil seperti itu. Kami memiliki banyak utas dalam proses yang menunggu beberapa kunci. Metode Monitor.Enter adalah apa yang dikunci oleh konstruksi di C #. Kunci ini ditangkap di dalam kelas yang disebut Timer dan TimerQueueTimer. Di Timer, kami berasal dari Task.Delay ketika kami mencoba membuatnya. Apa itu Ketika Task.Delay dimulai, kunci di dalam TimerQueue ditangkap.

1.3 Konvoi kunci


  • Banyak utas yang mencoba mengunci satu kunci
  • Di bawah kunci, kode kecil dijalankan
  • Waktu dihabiskan untuk sinkronisasi utas, bukan eksekusi kode.
  • Blokir diblokir - tidak terbatas

Kami memiliki konvoi kunci dalam aplikasi. Banyak utas yang mencoba menangkap kunci yang sama. Di bawah kunci ini, beberapa kode dijalankan. Sumber daya prosesor di sini tidak dihabiskan untuk kode aplikasi itu sendiri, tetapi pada operasi untuk menyinkronkan utas di antara mereka sendiri di kunci ini. Perlu juga diperhatikan fitur yang terkait dengan .NET: utas yang berpartisipasi dalam konvoi kunci adalah utas dari kumpulan utas.

Oleh karena itu, jika utas dari kumpulan utas diblokir, mereka mungkin berakhir - jumlah utas di kumpulan utas terbatas. Ini dapat dikonfigurasi, tetapi masih ada batas atas. Setelah tercapai, semua utas threadpool akan berpartisipasi dalam konvoi kunci, dan kode apa pun yang melibatkan threadpool akan berhenti dieksekusi dalam aplikasi. Ini sangat memperburuk situasi.

1.4 TimerQueue


  • Mengelola penghitung waktu dalam aplikasi .NET.
  • Pengatur waktu digunakan di:
    - Tugas. Terlambat
    - PembatalanTocken.Batal Setelah
    - HttpClient

TimerQueue adalah kelas yang mengatur semua penghitung waktu dalam aplikasi .NET. Jika Anda pernah memprogram di WinForms, Anda mungkin telah membuat penghitung waktu secara manual. Bagi mereka yang tidak tahu apa itu timer: mereka digunakan di Task.Delay (ini hanya kasus kami), mereka juga digunakan di dalam CancellingToken, dalam metode CancelAfter. Artinya, mengganti Task.Delay dengan PembatalanToken.CancelAfter tidak akan membantu kami dengan cara apa pun. Selain itu, timer digunakan di banyak kelas .NET internal, misalnya, di HttpClient.

Sejauh yang saya tahu, beberapa implementasi dari handler HttpClient memiliki timer. Bahkan jika Anda tidak menggunakannya secara eksplisit, jangan memulai Task.Delay, kemungkinan besar, Anda tetap menggunakannya.

Sekarang mari kita lihat bagaimana TimerQueue diatur di dalamnya.

  • Status global (per-appdomain):
    - Daftar tautan ganda TimerQueueTimer
    - Mengunci objek
  • Callback Pengatur Waktu Rutin
  • Pengatur waktu tidak dipesan oleh waktu tanggapan
  • Menambahkan penghitung waktu: O (1) + kunci
  • Penghapusan Timer: O (1) + kunci
  • Mulai timer: O (N) + kunci

Di dalam TimerQueue ada keadaan global, itu adalah daftar objek yang terhubung dua kali lipat dari tipe TimerQueueTimer. TimerQueueTimer berisi tautan ke TimerQueueTimer lainnya, yang bersebelahan dengan daftar tertaut, juga berisi waktu penghitung waktu dan panggilan balik, yang akan dipanggil saat penghitung waktu menyala. Daftar tertaut dua kali ini dilindungi oleh objek kunci, hanya satu di mana konvoi kunci terjadi dalam aplikasi kita. Juga di dalam TimerQueue ada Rutin yang meluncurkan panggilan balik yang terkait dengan timer kami.

Pengatur waktu sama sekali tidak dipesan oleh waktu tanggapan, seluruh struktur dioptimalkan untuk menambah / menghapus pengatur waktu baru. Ketika Rutin dimulai, itu berjalan melalui seluruh daftar yang ditautkan ganda, memilih pengatur waktu yang akan berfungsi, dan memanggil mereka kembali.

Kompleksitas operasi di sini sedemikian rupa. Menambah dan menghapus timer terjadi O per unit, dan dimulainya timer terjadi per baris. Selain itu, jika semuanya dapat diterima dengan kompleksitas algoritmik, ada satu masalah: semua operasi ini menangkap kunci, yang tidak terlalu baik.

Situasi apa yang bisa terjadi? Kami memiliki terlalu banyak timer yang terakumulasi di TimerQueue, jadi ketika Routine dimulai, ia mengunci operasi linearnya yang panjang, pada saat itu mereka yang mencoba memulai atau menghapus timer dari TimerQueue tidak dapat berbuat apa-apa. Karena itu, konvoi kunci terjadi. Masalah ini telah diperbaiki di .NET Core.
Kurangi pertentangan kunci Timer (coreclr # 14527)
  • Kunci pecahan
    - Lingkungan.Prosesor Timer TimerQueue TimerQueueTimer
  • Antrian terpisah untuk penghitung waktu pendek / umur panjang
  • Timer pendek: waktu <= 1/3 detik

https://github.com/dotnet/coreclr/issues/14462
https://github.com/dotnet/coreclr/pull/14527
Bagaimana cara memperbaikinya? Mereka menggerebek TimerQueue: alih-alih satu TimerQueue, yang statis untuk seluruh AppDomain, untuk seluruh aplikasi, beberapa TimerQueue dibuat. Ketika utas tiba di sana dan mencoba memulai penghitung waktu mereka, penghitung waktu ini akan jatuh ke TimerQueue acak, dan utas akan memiliki lebih sedikit peluang bertabrakan pada satu kunci.

NET Core juga menerapkan beberapa optimasi. Pengatur waktu dibagi menjadi jangka panjang dan pendek, TimerQueue terpisah sekarang digunakan untuk mereka. Pengatur waktu singkat dipilih kurang dari 1/3 detik. Saya tidak tahu mengapa konstanta seperti itu dipilih. Di .NET Core, kami gagal menangkap masalah dengan penghitung waktu.



https://github.com/Microsoft/dotnet-framework-early-access/blob/master/release-notes/NET48/dotnet-48-changes.md
https://github.com/dotnet/coreclr/labels/netfx-port-consider

Perbaikan ini di-backport ke .NET Framework, versi 4.8. Tag netfx-port-pertimbangkan ditunjukkan dalam tautan di atas, jika Anda pergi ke repositori .NET Core, CoreCLR, CoreFX, Anda dapat mencari masalah ini yang akan di-backport ke .NET Framework, sekarang ada sekitar lima puluh di antaranya. Artinya, open source .NET banyak membantu, beberapa bug diperbaiki. Anda dapat membaca changelog .NET Framework 4.8: banyak bug telah diperbaiki, lebih banyak daripada rilis NET lainnya. Menariknya, perbaikan ini dimatikan secara default di .NET Framework 4.8. Itu termasuk dalam seluruh file yang Anda kenal disebut App.config

Pengaturan di App.config yang memungkinkan perbaikan ini disebut UseNetCoreTimer. Sebelum .NET Framework 4.8 keluar, agar aplikasi kami berfungsi dan tidak masuk ke konvoi kunci, Anda harus menggunakan implementasi Task.Delay Anda. Di dalamnya, kami mencoba menggunakan tumpukan biner untuk lebih efisien memahami timer mana yang harus dipanggil sekarang.

1.5 Task.Delay: implementasi asli


  • Biner
  • Sharding
  • Itu membantu, tetapi tidak dalam semua kasus

Menggunakan tumpukan biner memungkinkan Anda untuk mengoptimalkan Rutin, yang memanggil panggilan balik, tetapi memperburuk waktu yang diperlukan untuk menghapus timer sewenang-wenang dari antrian - untuk ini Anda perlu membangun kembali tumpukan. Ini kemungkinan besar mengapa .NET menggunakan daftar tertaut ganda. Tentu saja, hanya menggunakan tumpukan biner tidak akan membantu kami di sini, kami juga harus mengerjakan TimerQueue. Solusi ini berfungsi untuk sementara waktu, tetapi tetap saja semuanya jatuh ke konvoi kunci lagi karena fakta bahwa timer digunakan tidak hanya di mana mereka berjalan secara eksplisit dalam kode, tetapi juga di perpustakaan pihak ketiga dan kode .NET. Untuk sepenuhnya memperbaiki masalah ini, Anda harus memutakhirkan ke .NET Framework versi 4.8 dan mengaktifkan perbaikan dari .NET developer.

1.6. Tugas.Delay: kesimpulan


  • Perangkap di mana-mana - bahkan dalam hal-hal yang paling sering digunakan
  • Lakukan stress testing
  • Beralih ke Core, dapatkan perbaikan bug (dan bug baru) terlebih dahulu :)

Apa kesimpulan dari keseluruhan cerita ini? Pertama, jebakan dapat ditemukan benar-benar di mana-mana, bahkan di kelas yang Anda gunakan setiap hari tanpa berpikir, misalnya, Tugas yang sama, Tugas.

Saya sarankan melakukan pengujian stres terhadap proposal Anda. Masalah ini baru saja kami identifikasi pada tahap pengujian beban. Kami kemudian memotretnya beberapa kali pada produksi di aplikasi lain, tetapi, bagaimanapun, stress testing membantu kami untuk menunda waktu sebelum kami menghadapi masalah ini dalam kenyataan.

Beralih ke .NET Core - Anda akan menjadi yang pertama menerima perbaikan bug (dan bug baru). Di mana tanpa bug baru?

Cerita tentang penghitung waktu telah berakhir, dan kami beralih ke yang berikutnya.

Kisah 2: SemaphoreSlim


Kisah berikut adalah tentang SemaphoreSlim yang terkenal.

2.1 Pelambatan server


  • Diperlukan untuk membatasi jumlah permintaan yang diproses secara bersamaan di server

Kami ingin menerapkan pembatasan pada server. Apa ini Anda mungkin semua tahu pembatasan pada CPU: ketika prosesor terlalu panas, ia menurunkan frekuensi untuk menjadi dingin, dan ini membatasi kinerjanya. Jadi di sini. Kami tahu bahwa server kami dapat memproses permintaan N secara paralel dan tidak jatuh. Apa yang ingin kita lakukan? Batasi jumlah permintaan yang diproses secara bersamaan untuk konstanta ini dan buatlah sehingga jika lebih banyak permintaan datang, mereka mengantri dan menunggu sampai permintaan yang datang lebih awal dieksekusi. Bagaimana mengatasi masalah ini? Perlu untuk menggunakan semacam sinkronisasi primitif.

Semaphore adalah primitif sinkronisasi tempat Anda dapat menunggu N kali, setelah itu orang yang tiba N + pertama dan seterusnya akan menunggu sampai mereka yang memasukkannya lebih dulu melepaskan Semaphore. Ternyata seperti ini: dua utas eksekusi, dua pekerja di bawah Semaphore, sisanya berdiri dalam barisan.



Tentu saja, hanya saja Semaphore tidak terlalu cocok untuk kita, itu ada di .NET sinkron, jadi kami mengambil SemaphoreSlim dan menulis kode ini:

 var semaphore = new SemaphoreSlim(N); โ€ฆ await semaphore.WaitAsync(); await HandleRequestAsync(request); semaphore.Release(); 

Kami membuat SemaphoreSlim, tunggu, di bawah Semaphore kami memproses permintaan Anda, setelah itu kami merilis Semaphore. Tampaknya ini adalah implementasi ideal pelambatan server, dan tidak bisa lebih baik lagi. Tapi semuanya jauh lebih rumit.

2.2 Pelambatan server: komplikasi


  • Memproses permintaan dalam urutan LIFO
  • SemaphoreSlim
  • Concurrentstack
  • TaskCompletionSource

Kami lupa sedikit tentang logika bisnis. Permintaan yang datang ke pembatasan adalah permintaan http asli. Sebagai aturan, mereka memiliki batas waktu, yang ditetapkan oleh mereka yang mengirim permintaan ini secara otomatis, atau batas waktu pengguna yang menekan F5 setelah beberapa waktu. Dengan demikian, jika Anda memproses permintaan dalam urutan antrian, seperti Semaphore biasa, maka pertama-tama permintaan dari antrian yang telah habis waktu mungkin sudah diproses. Jika Anda bekerja dalam urutan tumpukan - proses pertama dari semua permintaan yang datang terakhir, masalah seperti itu tidak akan muncul.

Selain SemaphoreSlim, kami harus menggunakan ConcurrentStack, TaskCompletionSource, untuk membungkus banyak kode di sekitar semua ini, sehingga semuanya bekerja sesuai urutan yang kami butuhkan. TaskCompletionSource adalah hal semacam itu, yang mirip dengan CancertokenSource, tetapi tidak untuk Cancertoken, tetapi untuk Task. Anda dapat membuat TaskCompletionSource, menarik Tugas dari itu, memberikannya dan kemudian memberi tahu TaskCompletionSource bahwa Anda perlu mengatur hasil untuk Tugas ini, dan mereka yang menunggu Tugas ini akan mencari tahu tentang hasil ini.

Kita semua sudah menerapkannya. Kode ini mengerikan. dan yang terburuk, ternyata tidak bisa beroperasi.

Beberapa bulan setelah dimulainya penggunaannya dalam aplikasi yang agak sarat muatan, kami menemui masalah. Dengan cara yang sama seperti pada kasus sebelumnya, konsumsi CPU telah meningkat menjadi 100%. Kami melakukan hal yang sama, menghapus tempat sampah, melihatnya di WinDbg, dan kembali menemukan konvoi kunci.



Kali ini kunci konvoi terjadi di dalam SemaphoreSlim.WaitAsync dan SemaphoreSlim.Release. Ternyata ada kunci di dalam SemaphoreSlim, itu tidak bebas kunci. Ini menjadi kekurangan yang cukup serius bagi kami.



Di dalam SemaphoreSlim ada keadaan internal (sebuah counter dari berapa banyak pekerja masih bisa masuk di bawahnya), dan daftar yang saling terkait dari mereka yang menunggu di Semaphore ini. Gagasannya hampir sama: Anda bisa menunggu di Semaphore ini, Anda dapat membatalkan harapan Anda - untuk meninggalkan antrian ini. Ada kunci yang menghancurkan hidup kita.

Kami memutuskan: turun dengan semua kode mengerikan yang harus kami tulis.



Mari kita menulis Semaphore kita, yang akan segera bebas kunci dan yang akan segera bekerja dalam susunan tumpukan. Membatalkan penantian tidak penting bagi kami.



Tentukan kondisi ini. Ini akan menjadi jumlah currentCount - ini adalah berapa banyak lagi ruang yang tersisa di Semaphore. Jika tidak ada kursi yang tersisa di Semaphore, angka ini akan negatif dan akan menunjukkan berapa banyak pekerja dalam antrian. Juga akan ada ConcurrentStack, yang terdiri dari TaskCompletionSource'ov - ini hanya setumpuk wait'ov dari mana mereka akan ditarik jika perlu. Mari kita menulis metode WaitAsync.

 var decrementedCount = Interlocked.Decrement(ref currentCount); if (decrementedCount >= 0) return Task.CompletedTask; var waiter = new TaskCompletionSource<bool>(); waiters.Push(waiter); return waiter.Task; 

Pertama, kita mengurangi meja, mengambil satu tempat di Semaphore untuk diri kita sendiri, jika kita memiliki tempat bebas, dan kemudian kita berkata: "Itu saja, kamu pergi di bawah Semaphore".

Jika tidak ada tempat di Semaphore, kami membuat TaskCompletionSource, membuangnya di tumpukan waiter'ov dan mengembalikan Task ke dunia luar. Ketika saatnya tiba, Tugas ini akan bekerja, dan pekerja akan dapat melanjutkan pekerjaannya dan akan pergi di bawah Semaphore.

Sekarang mari kita menulis metode Rilis.

 var countBefore = Interlocked.Increment(ref currentCount) - 1; if (countBefore < 0) { if (waiters.TryPop(out var waiter)) waiter.TrySetResult(true); } 

Metode rilis adalah sebagai berikut:

  • Gratis Satu Kursi di Semaphore
  • Jumlah saat ini bertambah

Jika kita dapat mengetahui dengan currentCount apakah ada pelayan di dalam tumpukan yang perlu kita beri sinyal, kita menarik pelayan seperti itu keluar dari tumpukan dan memberi sinyal. Di sini pelayan adalah TaskCompletionSource. Pertanyaan untuk kode ini: sepertinya masuk akal, tetapi apakah ini bekerja? Ada masalah apa? Ada nuansa yang terkait dengan tempat kelanjutan dan TugasCompletionSource'y diluncurkan.



Pertimbangkan kode ini. Kami menciptakan TaskCompletionSource dan meluncurkan dua Task. Tugas pertama menampilkan unit, mengatur hasilnya menjadi TaskCompletionSource, dan kemudian menampilkan deuce pada konsol. Tugas kedua menunggu pada TaskCompletionSource ini, pada tugasnya, dan kemudian selamanya memblokir utasnya dari kumpulan utas.

Apa yang akan terjadi di sini? Tugas 2 pada kompilasi akan dibagi menjadi dua metode, yang kedua adalah kelanjutan yang berisi Thread.Sleep. Setelah mengatur hasil dari TaskCompletionSource, kelanjutan ini akan dieksekusi di utas yang sama di mana Tugas pertama dieksekusi. Dengan demikian, aliran Tugas pertama akan diblokir selamanya, dan deuce ke konsol tidak akan lagi dicetak.

Menariknya, saya mencoba mengubah kode ini, dan jika saya menghapus output ke unit konsol, kelanjutan diluncurkan pada utas lainnya dari kumpulan utas dan deuce dicetak. Dalam hal ini kelanjutan akan dieksekusi di utas yang sama, dan di mana - akan sampai ke kumpulan utas - sebuah pertanyaan bagi pembaca.

 var tcs = new TaskCompletionSource<bool>( TaskCreationOptions.RunContinuationsAsynchronously); /* OR */ Task.Run(() => tcs.TrySetResult(true)); 

Untuk mengatasi masalah ini, kita bisa membuat TaskCompletionSource dengan RunContinuations yang sesuai menandai secara tidak sinkron, atau memanggil metode TrySetResult di dalam Task.Run/ThreadPool.QueueUserWorkItem agar tidak berjalan di utas kami. Jika dijalankan di utas kami, kami mungkin memiliki efek samping yang tidak diinginkan. Selain itu, ada masalah kedua, kami akan membahasnya lebih detail.



Lihatlah metode WaitAsync dan Release dan cobalah untuk menemukan masalah lain dalam metode Release.

Kemungkinan besar, untuk menemukannya sangat mustahil. Ada perlombaan di sini.



Hal ini disebabkan oleh fakta bahwa dalam metode WaitAsync perubahan negara bukan atom. Pertama kita mengurangi meja dan kemudian mendorong pelayan ke tumpukan. Jika Kebetulan bahwa Rilis dieksekusi antara penurunan dan dorongan, itu bisa keluar sehingga tidak menarik apa pun dari tumpukan. Ini harus diperhitungkan, dan dalam metode Pelepasan, tunggu sampai pelayan muncul di tumpukan.

 var countBefore = Interlocked.Increment(ref currentCount) - 1; if (countBefore < 0) { Waiter waiter; var spinner = new SpinWait(); while (!waiter.TryPop(out waiter)) spinner.SpinOnce(); waiter.TrySetResult(true); } 

Di sini kita melakukannya dalam satu lingkaran sampai kita berhasil menariknya keluar. Agar tidak membuang siklus prosesor sekali lagi, kami menggunakan SpinWait.

Dalam beberapa iterasi pertama, itu akan berputar dalam satu lingkaran. Jika ada banyak iterasi, pelayan tidak akan muncul untuk waktu yang lama, maka utas kami akan menuju Thread. Tidur, agar tidak membuang sumber daya CPU sekali lagi.

Bahkan, Semaphore-order LIFO bukan hanya ide kami.
LowLevelLifoSemaphore
  • Sinkron
  • Pada Windows menggunakan port IO Completion sebagai tumpukan Windows

https://github.com/dotnet/corert/blob/master/src/System.Private.CoreLib/src/System/Threading/LowLevelLifoSemaphore.cs
Ada Semaphore di NET itu sendiri, tetapi tidak di CoreCLR, tidak di CoreFX, tetapi di CoreRT. Terkadang cukup berguna untuk mengintip ke dalam repositori .NET. Ada Semaphore yang disebut LowLevelLifoSemaphore. Semaphore ini tidak cocok untuk kita: itu sinkron.

Hebatnya, pada Windows ini berfungsi melalui port IO Completion. Mereka memiliki properti yang dapat ditunggu oleh utas, dan utas ini akan dirilis hanya dalam urutan LIFO. Fitur ini digunakan di sana, itu benar-benar LowLevel.

2.3 Kesimpulan:


  • Jangan harap pengisian kerangka akan bertahan di bawah beban Anda
  • Lebih mudah untuk menyelesaikan masalah tertentu daripada kasus umum.
  • Tes stres tidak selalu membantu
  • Waspadalah terhadap pemblokiran

Apa kesimpulan dari keseluruhan cerita ini? Pertama-tama, jangan berharap bahwa beberapa kelas dari kerangka kerja yang Anda gunakan dari pustaka standar akan mengatasi beban Anda. Saya tidak ingin mengatakan bahwa SemaphoreSlim buruk, hanya saja ternyata tidak cocok secara khusus dalam skenario ini.

Ternyata jauh lebih mudah bagi kita untuk menulis Semaphore kita untuk tugas tertentu. Misalnya, itu tidak mendukung pembatalan menunggu. Fitur ini tersedia di SemaphoreSlim biasa, kami tidak memilikinya, tetapi ini memungkinkan kami untuk menyederhanakan kode.

Pengujian beban, meskipun membantu, mungkin tidak selalu membantu.

.NET , โ€” . lock, : ยซ ?ยป CPU 100%, lock', , , - .NET. .

.

3: (A)sync IO


/, .



lock convoy, stack trace Overlapped PinnableBufferCache. lock. : Overlapped PinnableBufferCache?

OVERLAPPED โ€” Windows, /. , . , . , lock convoy. , lock convoy, , .



, , .NET 4.5.1 4.5.2. .NET 4.5.2, , .NET 4.5.2. .NET 4.5.1 OverlappedDataCache, Overlapped โ€” , , . , lock-free, ConcurrentStack, . .NET 4.5.2 : OverlappedDataCache PinnableBufferCache.

? PinnableBufferCache , Overlapped , , โ€” . , , . PinnableBufferCache . , lock-free, ConcurrentStack. , . , , - lock-free list lock'.

3.1 PinnableBufferCache


LockConvoy:


lock convoy , - . list , lock , , .

PinnableBufferCache , . :

 PinnableBufferCache_System.ThreadingOverlappedData_MinCount 

, . : ยซ ! - ยป. -:

 Environment.SetEnvironmentVariable( "PinnableBufferCache_System.Threading.OverlappedData_MinCount", "10000"); new Overlapped().GetHashCode(); for (int i = 0; i < 3; i++) GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced); 

? , Overlapped , , . , , , , PinnableBufferCache lock convoy'. , .

.NET Core PinnableBufferCache , OverlappedData . , , Garbage collector , . .NET Core . .NET Framework, , .

3.2 :


  • .NET Core

, . , .NET , . , , .NET Core. , , -.

key-value .

4: Concurrent key-value collections


.NET concurrent-. lock-free ConcurrentStack ConcurrentQueu, . ConcurrentDictionary, . lock-free , , . ConcurrentDictionary?

4.1 ConcurrentDictionary


:


Pro:

  • (TryAdd/TryUpdate/AddOrUpdate)
  • Lock-free
  • Lock-free enumeration

, memory-, , . , , .NET Framework. . , , (enumeration) lock-free. , .

, , - .NET. key-value - :



-, bucket'. bucket', . , bucket , .

โ€” , ConcurrentDictionary. ConcurrentDictionary ยซ-ยป . , , , memory traffic. ConcurrentDictionary, lock'. โ€” .

, Dictionary.



Dictionary , Concurrent, . : buckets, entries. buckets bucket' entries. ยซ-ยป entries. . ยซ-ยป int, bucket'.

memory overhead, ConcurrentDictionary Dictionary.



Dictionary. Memory overhea' , . Dictionary overhead - , int'. 8 .

ConcurrentDictionary. ConcurrentDictionary ConcurrentDictionary.Node. , . int hashCode . , table ( 16 ), int hashCode . , 64- 28 overhead'. Dictionary.

memory overhead', ConcurrentDictionary GC , . Benchmark. ConcurrentDictionary , GC.Collect. ?



. ConcurrentDictionary 10 , , , . Dictionary . , , , . .

, ConcurrentDictionary?

4.2


  • TTL
  • Dictionary+lock
  • Sharding

. ConcurrentDictionary. 10 . , . TTL , . Dictionary lock'. , , lock . Dictionary lock' , - , lock. , .

4.3


  • in-memory <Guid,Guid>
  • >10 6

. โ€” , in-memory Guid' Guid, . . - - , . , 15 . . Semaphore ConcurrentDictionary.



, lock-free , overhead GC. , . , , , . , - , , . , , Large Object Heap. ?

, , Dictionary .



Dictionary bucket', Entry. Entry , , , .



Dictionary , , . , - .

, - ? -, , , , . . Dictionary, , buckets, entries, Interlocked. , .
Dictionary
  • ,
  • , ?
    โ€” Resize buckets entries
    โ€” -
    โ€” Dictionary.Entry
    โ€” -

https://blogs.msdn.microsoft.com/tess/2009/12/21/high-cpu-in-net-app-using-a-static-generic-dictionary/
, Dictionary - bucket'. , . , , . , , .

Entry Dictionary. - - . , .



.NET Framework 1.1. Hashtable, Dictionary, object'. MSDN , . , -. . , Hashtable . , .

4.4 Dictionary.Entry



? Dictionary.Entry , , 8 , , , , . ?

 bool writing; int version; this.writing = true; buckets[index] = โ€ฆ; this.version++; this.writing = false; 

: ( , ) int-. , . , , , , .

 bool writing; int version; while (true) { int version = this.version; bucket = bickets[index]; if (this.writing || version != this.version) continue; break; } 

, , . , . , 8 .

4.5 -


, .



Dictionary bucket , .

Dictionary, . : 0 2. bucket, 1 2. ? 0. , , 2. . , 2, , , 1. 1 2 โ€” bucket. , , . 1 โ€” , bucket. Hashtable , bucket' -. โ€” double hashing .

4.6





  • , resize



  • ,

. , Buckets, Entries ( Buckets, Entries). - , , , , .

. , .

: , , , , . , , .



, , โ€” .

? , - 2. - Capacity , . โ€” 2. , . 2. ? , , , . - , , 3. , , , , , .

, Hashtable, . , double hashing. , , , .

, , โ€” , . Hashtable. , โ€” โ€” . . , bucket', - , . .

, , lock-free LOH.



lock-free ? MSDN Hashtable , . , , .



, , , bucket'. Dictionary bucket', -, bucket' . - bucket, bucket . , .

, Large Object Heap.



. CustomDictionary CustomDictionarySegment . Dictionary, , . โ€” Dictionary, . , Large Object Heap. , bucket' . , , , bucket, - - .

. ConcurrentDictionary, .NET, , .

4.7


  • .NET
  • ,

? .NET . . , , . - โ€” - . , , , .

- , , , , . , , , , , . โ€” , , .



โ€” ConcurrentDictionary. , , ( Diafilm ), .

GitHub. โ€” , , LIFO-Semaphore, . , .
6-7 DotNext 2019 Moscow ยซ.NET: ยป , .NET Framework .NET Core, , .

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


All Articles