
Mari kita lihat bagaimana pemrograman paralel dan paralel dalam. Net bekerja, menggunakan masalah filsuf makan sebagai contoh. Rencana semacam itu, dari sinkronisasi utas / proses, hingga model aktor (di bagian berikut). Artikel ini mungkin berguna untuk kenalan pertama atau untuk menyegarkan kembali pengetahuan Anda.
Mengapa bisa melakukan ini? Transistor mencapai ukuran minimumnya, hukum Moore bertumpu pada batasan kecepatan cahaya, dan oleh karena itu pertumbuhan diamati dalam kuantitas, lebih banyak transistor dapat dilakukan. Pada saat yang sama, jumlah data bertambah, dan pengguna mengharapkan reaksi langsung dari sistem. Dalam situasi seperti itu, pemrograman "normal", ketika kita memiliki satu utas pelaksana, tidak lagi efektif. Penting untuk entah bagaimana menyelesaikan masalah eksekusi simultan atau kompetitif. Selain itu, masalah ini ada pada tingkat yang berbeda: pada tingkat aliran, pada tingkat proses, pada tingkat mesin dalam jaringan (sistem terdistribusi). .NET memiliki teknologi berkualitas tinggi yang telah teruji oleh waktu untuk menyelesaikan masalah tersebut dengan cepat dan efektif.
Tantangan
Edsger Dijkstra mengajukan masalah ini kepada murid-muridnya pada awal 1965. Kata-kata yang digunakan adalah ini. Ada beberapa (biasanya lima) jumlah filsuf dan sebanyak garpu. Mereka duduk di meja bundar, bercabang di antaranya. Para filsuf dapat makan dari piring mereka dengan makanan tanpa akhir, berpikir atau menunggu. Untuk makan filsuf, Anda perlu mengambil dua garpu (yang terakhir berbagi garpu dengan yang pertama). Untuk mengambil dan meletakkan garpu - dua tindakan terpisah. Semua filsuf diam. Tugasnya adalah menemukan algoritma yang sedemikian rupa sehingga mereka semua berpikir dan muak bahkan setelah 54 tahun.
Pertama, mari kita coba selesaikan masalah ini dengan menggunakan ruang bersama. Garpu ada di atas meja dan filsuf hanya mengambil mereka ketika mereka dan meletakkannya kembali. Ada masalah dengan sinkronisasi, kapan tepatnya mengambil colokan? Apa yang harus dilakukan jika tidak ada plug? dan lainnya, tetapi pertama-tama, mari kita luncurkan para filsuf.
Untuk memulai utas, gunakan kumpulan utas melalui metode Task.Run
:
var cancelTokenSource = new CancellationTokenSource(); Action<int> create = (i) => RunPhilosopher(i, cancelTokenSource.Token); for (int i = 0; i < philosophersAmount; i++) { int icopy = i;
Kumpulan utas dibuat untuk mengoptimalkan pembuatan dan penghapusan utas. Kelompok ini memiliki antrian tugas dan CLR membuat atau menghapus utas tergantung pada jumlah tugas ini. Satu kumpulan untuk semua AppDomains. Kolam ini harus digunakan hampir selalu, karena Anda tidak perlu repot-repot membuat, menghapus utas, antriannya, dll. Ini dimungkinkan tanpa kumpulan, tetapi kemudian Anda harus menggunakan Thread
secara langsung, disarankan untuk kasus-kasus ketika Anda perlu mengubah prioritas utas, saat kami memiliki operasi yang panjang, untuk Latar depan utas, dll.
Dan kelas System.Threading.Tasks.Task
membuatnya mudah untuk bekerja dengan kumpulan utas ini (atau bahkan melakukannya tanpa itu). Ini adalah operasi asinkron. Secara kasar, ini adalah Thread
sama, tetapi dengan segala macam kemudahan: kemampuan untuk meluncurkan tugas setelah satu blok tugas lain, mengembalikannya dari fungsi, mudah untuk menginterupsi mereka, dan banyak lagi. dll. Mereka diperlukan untuk mendukung konstruksi asinkron / menunggu (Pola Asinkron berbasis Tugas, gula sintaksis untuk menunggu operasi IO). Kami akan membicarakan ini lagi.
CancelationTokenSource
diperlukan di sini agar utas itu sendiri dapat diakhiri oleh sinyal utas panggilan.
Sinkronkan Masalah
Filsuf yang diblokir
Oke, kita bisa buat utas, mari kita coba makan siang:
Di sini kita pertama-tama mencoba untuk mengambil garpu kiri dan kanan, dan jika berhasil, maka kita makan dan mengembalikannya. Mengambil satu garpu adalah atom, mis. dua aliran tidak dapat mengambil satu pada saat yang sama (salah: yang pertama membaca bahwa steker bebas, yang kedua juga, yang pertama, yang kedua). Untuk melakukan ini, Interlocked.CompareExchange
, yang harus diimplementasikan menggunakan instruksi prosesor ( TSL
, XCHG
), yang memblokir sepotong memori untuk membaca dan menulis sekuensial atom. Dan SpinWait setara dengan konstruksi while(true)
dengan hanya sedikit "keajaiban" - utas mengambil prosesor ( Thread.SpinWait
), tetapi kadang-kadang mentransfer kontrol ke utas lain ( Thread.Yeild
) atau tertidur ( Thread.Sleep
).
Tetapi solusi ini tidak berhasil, karena aliran akan segera diblokir (untuk saya dalam satu detik): semua filsuf mengambil garpu kiri mereka, tetapi bukan yang benar. Array fork kemudian memiliki nilai: 1 2 3 4 5.

Dalam gambar, memblokir benang (kebuntuan). Hijau menunjukkan eksekusi, merah menunjukkan sinkronisasi, dan abu-abu menunjukkan tidur. Berlian menunjukkan waktu mulai dari Tugas.
Kelaparan para filsuf
Meskipun tidak perlu terlalu banyak memikirkan makanan, tetapi Anda harus memaksa siapa pun untuk menyerah pada filosofi. Mari kita coba mensimulasikan situasi aliran puasa dalam masalah kita. Kelaparan adalah ketika sungai bekerja, tetapi tanpa kerja yang signifikan, dengan kata lain, itu adalah jalan buntu yang sama, hanya sekarang sungai tidak tidur, tetapi secara aktif mencari sesuatu untuk dimakan, tetapi tidak ada makanan. Untuk menghindari pemblokiran yang sering, kami akan memasang kembali steker jika kami tidak dapat mengambil yang lain.
Dalam kode ini, penting bahwa dua dari empat filsuf lupa untuk meletakkan garpu kiri mereka. Dan ternyata mereka makan lebih banyak, sementara yang lain mulai kelaparan, meskipun arusnya memiliki prioritas yang sama. Di sini mereka tidak benar-benar kelaparan, karena filsuf yang buruk kadang-kadang mengembalikan garpu mereka. Ternyata yang baik makan sekitar 5 kali lebih sedikit dari yang buruk. Jadi kesalahan kecil dalam kode menyebabkan penurunan kinerja. Di sini perlu dicatat bahwa situasi yang langka mungkin terjadi ketika semua filsuf mengambil garpu kiri, tidak ada hak, mereka meletakkan kiri, menunggu, mengambil kiri lagi, dll. Situasi ini juga kelaparan, lebih seperti jalan buntu. Saya tidak bisa mengulanginya. Di bawah ini adalah gambaran untuk situasi di mana dua filsuf jahat mengambil garpu dan dua filsuf baik kelaparan.

Di sini Anda dapat melihat bahwa utas terkadang bangun dan mencoba mendapatkan sumber daya. Dua dari empat core tidak melakukan apa-apa (grafik hijau di atas).
Kematian sang filsuf
Nah, masalah lain yang dapat mengganggu jamuan makan malam para filsuf adalah jika salah satu dari mereka tiba-tiba mati dengan garpu di tangannya (dan mereka akan menguburnya seperti itu). Maka tetangga akan dibiarkan tanpa makan siang. Anda dapat NullReferenceException
kode contoh untuk kasus ini sendiri, misalnya, NullReferenceException
dilemparkan setelah filsuf mengambil garpu. Dan, omong-omong, pengecualian tidak akan diproses dan kode panggilan tidak akan menangkapnya (untuk ini, AppDomain.CurrentDomain.UnhandledException
, dll.). Oleh karena itu, penangan kesalahan diperlukan di utas sendiri dan dengan terminasi yang benar.
Pelayan
Nah, bagaimana kita mengatasi masalah ini dengan kebuntuan, kelaparan, dan kematian? Kami hanya akan mengizinkan satu filsuf untuk bercabang, menambahkan saling pengecualian aliran untuk tempat ini. Bagaimana cara melakukannya? Misalkan seorang pelayan berdiri di sebelah para filsuf yang memberikan izin kepada seorang filsuf untuk mengambil garpu. Bagaimana kita membuat pelayan ini dan bagaimana para filsuf bertanya kepadanya, pertanyaan-pertanyaan menarik.
Cara paling sederhana adalah ketika para filsuf terus-menerus meminta pelayan untuk akses ke garpu. Yaitu sekarang para filsuf tidak akan menunggu untuk plug di dekatnya, tetapi menunggu atau meminta pelayan. Pertama, kami hanya menggunakan Ruang Pengguna untuk ini, di dalamnya kami tidak menggunakan interupsi untuk memanggil prosedur apa pun dari kernel (tentang mereka di bawah).
Solusi Ruang Pengguna
Di sini kita akan melakukan hal yang sama seperti dulu dengan satu garpu dan dua filsuf, kita akan berputar dalam satu siklus dan menunggu. Tapi sekarang semua filsuf dan seolah-olah hanya satu garpu, yaitu. kita bisa mengatakan hanya akan ada filsuf yang mengambil "garpu emas" dari pelayan. Untuk ini kami menggunakan SpinLock.
private static SpinLock spinLock = new SpinLock();
SpinLock
adalah pemblokir, dengan, secara kasar, while(true) { if (!lock) break; }
while(true) { if (!lock) break; }
, tetapi dengan lebih banyak "sihir" daripada di SpinWait
(yang digunakan di sana). Sekarang dia tahu bagaimana cara menghitung mereka yang menunggu, untuk membuat mereka sedikit tidur, dan banyak lagi. dll. Secara umum, melakukan segala yang mungkin untuk mengoptimalkan. Tetapi kita harus ingat bahwa ini masih merupakan siklus aktif yang sama yang memakan sumber daya prosesor dan membuat utas yang dapat menyebabkan kelaparan jika salah satu filsuf menjadi prioritas di atas yang lain, tetapi tidak memiliki garpu emas (masalah Prioritas Pembalikan). Oleh karena itu, kami menggunakannya hanya untuk perubahan yang sangat singkat dalam memori bersama, tanpa panggilan pihak ketiga, kunci bersarang, dll. Kejutan.

Gambar untuk SpinLock
. Aliran terus-menerus "berjuang" untuk garpu emas. Kegagalan terjadi - pada gambar, area yang dipilih. Inti tidak sepenuhnya digunakan: hanya sekitar 2/3 dari empat utas ini.
Solusi lain di sini adalah dengan menggunakan hanya Interlocked.CompareExchange
dengan harapan aktif yang sama, seperti yang ditunjukkan dalam kode di atas (dalam filsuf yang kelaparan), tetapi ini, sebagaimana telah disebutkan, secara teoritis dapat menyebabkan pemblokiran.
Tentang Interlocked
perlu dikatakan bahwa tidak hanya CompareExchange
, tetapi juga metode lain untuk membaca atom DAN menulis. Dan dengan mengulangi perubahan seandainya thread lain berhasil membuat perubahannya (baca 1, baca 2, tulis 2, tulis 1 buruk), itu dapat digunakan untuk perubahan kompleks dari satu nilai (pola Apa Saja yang Terkunci).
Solusi mode kernel
Untuk menghindari kehilangan sumber daya dalam satu lingkaran, mari kita lihat bagaimana Anda dapat memblokir aliran. Dengan kata lain, melanjutkan contoh kita, kita akan melihat bagaimana pelayan menempatkan filsuf untuk tidur dan membangunkannya hanya jika diperlukan. Pertama, mari kita lihat bagaimana melakukan ini melalui mode kernel dari sistem operasi. Semua struktur di sana sering kali ternyata lebih lambat daripada yang ada di ruang pengguna. Beberapa kali lebih lambat, misalnya AutoResetEvent
bisa 53 kali lebih lambat daripada SpinLock
[Richter]. Tetapi dengan bantuan mereka, Anda dapat menyinkronkan proses di seluruh sistem, dikelola atau tidak.
Konstruksi utama di sini adalah semafor yang diusulkan oleh Dijkstroy lebih dari setengah abad yang lalu. Semafor adalah, secara sederhana, bilangan bulat positif yang dikendalikan oleh suatu sistem, dan dua operasi di atasnya - bertambah dan berkurang. Jika reduksi tidak bekerja, nol, maka utas panggilan diblokir. Ketika jumlah bertambah oleh beberapa thread / proses aktif lainnya, maka utas dilewati, dan semaphore kembali berkurang dengan jumlah yang dilewati. Anda bisa membayangkan kereta dalam kemacetan dengan semaphore. .NET menawarkan beberapa desain dengan fitur serupa: AutoResetEvent
, ManualResetEvent
, Mutex
dan Semaphore
itu sendiri. Kami akan menggunakan AutoResetEvent
, ini adalah yang paling sederhana dari konstruksi ini: hanya dua nilai 0 dan 1 (false, true). Metode WaitOne()
memblokir utas panggilan jika nilainya 0, dan jika 1, lalu turun menjadi 0 dan melompatinya. Dan metode Set()
meningkat menjadi 1 dan melompati satu menunggu, yang lagi-lagi turun menjadi 0. Bertindak seperti pintu putar di kereta bawah tanah.
Kami akan mempersulit solusi dan akan menggunakan kunci untuk setiap filsuf, dan tidak untuk semua orang sekaligus. Yaitu sekarang ada beberapa filsuf sekaligus, dan bukan satu. Tapi sekali lagi kami memblokir akses ke meja untuk mengambil garpu dengan benar, menghindari balapan (kondisi balapan).
Untuk memahami apa yang terjadi di sini, perhatikan kasus ketika filsuf gagal mengambil percabangan, maka tindakannya akan seperti itu. Dia sedang menunggu akses ke meja. Setelah menerimanya, ia mencoba mengambil garpu. Itu tidak berhasil. Dia memberi akses ke meja (saling pengecualian). Dan itu melewati "turnstile" ( AutoResetEvent
) (pada awalnya mereka terbuka). Memasuki siklus lagi, karena dia tidak punya garpu. Mencoba untuk mengambilnya dan berhenti di pintu putar nya. Beberapa tetangga yang lebih beruntung di kanan atau di kiri, setelah selesai makan, membuka kunci filsuf kita, "membuka pintu putar-nya." Filsuf kita melewatinya (dan dia menutup di belakangnya) untuk kedua kalinya. Mencoba untuk ketiga kalinya untuk mengambil garpu. Semoga beruntung Dan melewati pintu putar untuk makan.
Ketika ada kesalahan acak dalam kode tersebut (selalu ada), misalnya, tetangga tidak ditentukan dengan benar atau objek AutoResetEvent
sama AutoResetEvent
untuk semua orang ( Enumerable.Repeat
), maka filsuf akan menunggu pengembang, karena menemukan kesalahan dalam kode semacam itu adalah tugas yang agak sulit. Masalah lain dengan solusi ini adalah tidak menjamin bahwa filsuf mana pun tidak akan kelaparan.
Solusi hibrida
Kami memeriksa dua pendekatan untuk sinkronisasi ketika kami tetap dalam mode pengguna dan berputar dalam satu lingkaran dan ketika kami memblokir utas melalui kernel. Metode pertama baik untuk kunci pendek, yang kedua untuk yang panjang. Seringkali, pertama-tama Anda perlu secara singkat mengharapkan variabel untuk berubah dalam loop, dan kemudian memblokir utas ketika menunggu lama. Pendekatan ini diimplementasikan dalam apa yang disebut desain hibrida. Di sini ada konstruksi yang sama untuk mode kernel, tetapi sekarang dengan loop dalam mode pengguna: SemaphorSlim
, ManualResetEventSlim
, dll. Konstruksi yang paling populer di sini adalah Monitor
, karena C # memiliki sintaks lock
terkenal. Monitor
adalah semaphore yang sama dengan nilai maksimum 1 (mutex), tetapi dengan dukungan untuk menunggu dalam satu lingkaran, rekursi, pola Variabel Kondisi (tentangnya di bawah), dll. Mari kita lihat solusi dengan itu.
Di sini kita lagi mengunci seluruh meja untuk akses ke garpu, tetapi sekarang kita membuka semua aliran sekaligus, dan bukan tetangga ketika seseorang selesai makan. Yaitu pertama, seseorang makan dan memblokir tetangga, dan ketika seseorang ini selesai, tetapi ingin makan lagi segera, dia masuk ke kunci dan membangunkan tetangganya, karena waktu tunggunya lebih pendek.
Jadi kita menghindari kebuntuan dan kelaparan beberapa filsuf. Kami menggunakan loop untuk menunggu sebentar dan memblokir aliran untuk yang lama. Membuka kunci sekaligus bekerja lebih lambat daripada jika hanya tetangga yang dibuka, seperti dalam solusi dengan AutoResetEvent
, tetapi perbedaannya tidak boleh besar, karena utas harus tetap dalam mode pengguna terlebih dahulu.
lock
sintaksis memiliki kejutan yang tidak menyenangkan. Mereka merekomendasikan menggunakan Monitor
secara langsung [Richter] [Eric Lippert]. Salah satunya adalah lock
selalu keluar dari Monitor
, bahkan jika ada pengecualian, dan utas lainnya dapat mengubah status memori bersama. Dalam kasus seperti itu, seringkali lebih baik pergi ke jalan buntu atau menyelesaikan program dengan aman. Kejutan lainnya adalah Monitor menggunakan blok sinkronisasi ( SyncBlock
), yang ada di semua objek. Oleh karena itu, jika Anda memilih objek yang salah, Anda dapat dengan mudah mendapatkan kebuntuan (misalnya, jika Anda membuat kunci pada string yang diinternir). Kami selalu menggunakan objek tersembunyi untuk ini.
Pola Variabel Kondisi memungkinkan Anda untuk lebih menerapkan ekspektasi kondisi yang kompleks. Dalam. NET, itu tidak lengkap, menurut pendapat saya, karena dalam teori harus ada beberapa antrian pada beberapa variabel (seperti pada Thread Posix), dan tidak pada satu kunci. Maka orang dapat membuat mereka untuk semua filsuf. Tetapi bahkan dalam formulir ini, ini memungkinkan Anda untuk mengurangi kode.
Banyak filsuf atau async
/ await
Oke, sekarang kita dapat secara efektif memblokir utas. Tetapi, bagaimana jika kita mendapatkan banyak filsuf? 100? 10000? Misalnya, kami menerima 100.000 permintaan ke server web. Membuat aliran untuk setiap permintaan akan menjadi overhead, karena begitu banyak utas tidak akan dieksekusi secara paralel. Hanya sebanyak core logis yang akan dieksekusi (saya punya 4). Dan semua orang hanya akan mengambil sumber daya. Salah satu solusi untuk masalah ini adalah pola async / await. Idenya adalah bahwa suatu fungsi tidak menahan aliran, jika Anda perlu menunggu untuk melanjutkan. Dan ketika dia melakukan ini sesuatu terjadi, dia melanjutkan eksekusinya (tetapi tidak harus di utas yang sama!). Dalam kasus kami, kami akan menunggu colokannya.
SemaphoreSlim
memiliki metode WaitAsync()
untuk ini. Berikut ini adalah implementasi menggunakan pola ini.
async
/ await
, Task
. , , Task. , , . , , , , . . async
/ await
.
. 100 4 , 8 . Monitor 4 , . 4 2. async / await 100, 6.8 . , 6 . Monitor .
Kesimpulan
, .NET . , , , . . , , , TPL Dataflow, Reactive , Software Transaction .
Sumber