29-Bob: Primitiv Thread Sinxronizatsiya Konstruktsiyalari
Thread sinxronizatsiyasining eng past darajadagi qurilma bloklari: volatile, Interlocked, spinlock, event, semaphore va mutex
Thread sinxronizatsiya qulflari bilan uchinchi muammo shundaki, ular resursga bir vaqtning o'zida faqat bitta threadga kirish imkonini beradi. Bu qulfning mavjud bo'lishining butun sababi, lekin bu ham muammo, chunki threadni bloklash ko'proq threadlar yaratilishiga sabab bo'ladi. Masalan, agar thread pool threadi qulflash uchun urinsa va uni ola olmasa, thread pool CPUlarni band qilib turish uchun yangi thread yaratishi ehtimoli katta. 26-bobda ("Thread asoslari") muhokama qilinganidek, thread yaratish xotira va unumdorlik jihatidan juda qimmat. Bundan ham yomoni, bloklangan thread qayta ishga tushganda, u yangi thread pool threadi bilan ishlaydi; Windows endi CPUlardan ko'proq threadlarni rejalashtirmoqda va bu kontekst almashtirishni oshiradi, bu esa unumdorlikka salbiy ta'sir qiladi.
Bularning barchasining xulosasi shuki, thread sinxronizatsiyasi yomon, shuning uchun siz ilovalaringizni iloji boricha undan qochishga loyihalashingiz kerak. Buning uchun static maydonlar kabi umumiy ma'lumotlardan qochishingiz kerak. Thread new operatori yordamida obyekt yaratganda, new operatori obyektga havola qaytaradi. Bu vaqtda faqat obyektni yaratgan thread unga havolaga ega; boshqa hech qanday thread bu obyektga kira olmaydi. Agar siz bu havolani yaratuvchi thread bilan bir vaqtda obyektdan foydalanishi mumkin bo'lgan boshqa threadga o'tkazmasangiz, obyektga kirishni sinxronlash zarurati yo'q.
Qiymat turlarini ishlatishga harakat qiling, chunki ular doimo nusxalanadi, shuning uchun har bir thread o'z nusxasida ishlaydi. Va nihoyat, bir nechta threadlarning umumiy ma'lumotlarga bir vaqtda kirishida muammo yo'q — agar kirish faqat o'qish uchun bo'lsa. Masalan, String turi bunga misol: String obyekti yaratilgandan so'ng, u immutable (o'zgarmas); shuning uchun ko'p threadlar bitta String obyektiga String buzilish xavfisiz bir vaqtda kira oladi.
Klass Kutubxonalari va Thread Xavfsizligi
Endi klass kutubxonalari va thread sinxronizatsiyasi haqida qisqacha gapirib o'taman. Microsoft ning Framework Class Library (FCL) barcha statik metodlarning thread-safe ekanligiga kafolat beradi. Bu shuni anglatadiki, agar ikkita thread bir vaqtning o'zida statik metodni chaqirsa, hech qanday ma'lumot buzilmaydi. FCL buni ichki ravishda amalga oshirishi kerak edi, chunki turli assemblylarni ishlab chiqaradigan bir nechta kompaniyalar resursga arbitraj kirish uchun bitta qulf bo'yicha kelishib olishlarining iloji yo'q. Console klassi ichida static maydon bor va ko'p metodlari konsolga bir vaqtda faqat bitta thread kirishini ta'minlash uchun uni oladi va bo'shatadi.
Ma'lumot uchun aytilsa, metodni thread-safe qilish ichki ravishda thread sinxronizatsiya qulfini olishini anglatmaydi. Thread-safe metod shuni anglatadiki, ikkita thread bir vaqtda ma'lumotlarga kirishga harakat qilsa, ma'lumotlar buzilmaydi. System.Math klassi quyidagi kabi statik Max metodiga ega:
Bu metod hech qanday qulf olmasa ham thread-safe. Chunki Int32 qiymat turi bo'lgani uchun, Max ga uzatilgan ikkita Int32 qiymat unga nusxalanadi va shuning uchun bir nechta threadlar bir vaqtda Max ni chaqirishi mumkin, lekin har bir thread o'z ma'lumotlarida ishlaydi, boshqa threadlardan ajratilgan holda.
Boshqa tomondan, FCL misol (instance) metodlari thread-safe ekanligini kafolatlamaydi, chunki barcha qulflash kodini qo'shish unumdorlikni juda pasaytiradi. Aslida, agar har bir misol metodi qulf olsa, siz oxir-oqibat ilovangizda faqat bitta thread ishlashiga erishasiz, bu unumdorlikni yanada pasaytiradi. Oldinroq aytib o'tilganidek, thread obyektni yaratganda, faqat shu thread obyektga kirishga havolaga ega, va misol metodlarini chaqirishda hech qanday thread sinxronizatsiyasi talab qilinmaydi. Biroq, agar thread havolani boshqa threadlarga ochsa — uni static maydonga qo'yib, ThreadPool.QueueUserWorkItem ga argument sifatida uzatib va hokazo — unda threadlar bir vaqtda faqat o'qish bo'lmagan kirishga urinsa, thread sinxronizatsiyasi talab qilinadi.
O'z klass kutubxonalaringiz ham shu patternni kuzatishi tavsiya etiladi; ya'ni barcha statik metodlaringizni thread-safe qiling va barcha misol metodlaringizni thread-safe qilmang. Buning bitta istisnosi bor: agar misol metodining maqsadi threadlarni koordinatsiya qilish bo'lsa, u holda misol metodi thread-safe bo'lishi kerak. Masalan, bitta thread CancellationTokenSource ning Cancel metodini chaqirishi va boshqa thread tegishli CancellationToken ning IsCancellationRequested xususiyatini so'rashi mumkin.
Primitiv User-Mode va Kernel-Mode Konstruktsiyalar
Ushbu bobda men primitiv thread sinxronizatsiya konstruktsiyalarini tushuntiraman. Primitiv deganda, men kodingizda foydalanish uchun mavjud bo'lgan eng oddiy konstruktsiyalarni nazarda tutaman. Primitiv konstruktsiyalarning ikki turi mavjud: user-mode va kernel-mode.
Iloji boricha primitiv user-mode konstruktsiyalardan foydalanishingiz kerak, chunki ular kernel-mode konstruktsiyalarga qaraganda sezilarli darajada tezroq, chunki ular threadlarni koordinatsiya qilish uchun maxsus CPU ko'rsatmalaridan foydalanadi. Bu koordinatsiya apparatda sodir bo'layotganini anglatadi (bu uni tez qiladi). Bu shuningdek, Windows operatsion tizimi primitiv user-mode konstruktsiyada bloklangan threadni hech qachon aniqlamasligini anglatadi. User-mode primitiv konstruktsiyada bloklangan thread pool threadi hech qachon bloklangan hisoblanmagani uchun, thread pool vaqtincha bloklangan threadni almashtirish uchun yangi thread yaratmaydi. Bundan tashqari, bu CPU ko'rsatmalari threadni juda qisqa vaqt uchun bloklaydi.
Biroq, kamchiligi bor — faqat Windows operatsion tizim yadrosi threadni ishlashdan to'xtatishi mumkin, shunda u CPU vaqtini sarflamaydi. User mode da ishlaydigan thread tizim tomonidan oldindan olib tashlanishi (preempted) mumkin, lekin thread iloji boricha tezroq qayta rejalashtiriladi. Shunday qilib, resurs olmoqchi bo'lgan, lekin ola olmayotgan thread user mode da aylanadi (spin). Bu ko'p CPU vaqtini behuda sarflaydi.
Bu bizni primitiv kernel-mode konstruktsiyalarga olib keladi. Kernel-mode konstruktsiyalar Windows operatsion tizimining o'zi tomonidan ta'minlanadi. Shu sababli, ular ilovangiz threadlarining OT yadroda amalga oshirilgan funksiyalarni chaqirishini talab qiladi. Threadlarni user mode dan kernel mode ga va orqaga o'tkazish katta unumdorlik zarbasini beradi, shuning uchun kernel-mode konstruktsiyalaridan qochish kerak. Biroq, ularning ijobiy tomoni bor — thread kernel-mode konstruktsiyasidan foydalanib, boshqa thread egalik qilayotgan resursni olishga uringanda, Windows threadni bloklaydi va u endi CPU vaqtini behuda sarflamaydi. Keyin, resurs bo'shaganda, Windows threadni qayta tiklaydi va unga resursga kirishga ruxsat beradi.
Konstruktsiyada kutayotgan thread, agar konstruktsiyani hozirda ushlab turgan thread uni hech qachon bo'shatmasa, abadiy bloklangan bo'lishi mumkin. Agar konstruktsiya user-mode bo'lsa, thread CPU da abadiy ishlaydi va biz buni livelock deb ataymiz. Agar konstruktsiya kernel-mode bo'lsa, thread abadiy bloklangan va biz buni deadlock deb ataymiz. Ikkalasi ham yomon, lekin ikkalasi orasida deadlock doimo afzalroq, chunki livelock CPU vaqtini ham, xotirani ham (threadning steki va boshqalar) behuda sarflaydi, deadlock esa faqat xotirani sarflaydi.
Ideal dunyoda, biz ikkala dunyoning eng yaxshi tomonlarini birlashtiradigan konstruktsiyalarga ega bo'lishni xohlaymiz. Ya'ni, biz tortishuv bo'lmaganda tez va bloklamas (user-mode konstruktsiyalari kabi) va tortishuv bo'lganda operatsion tizim yadrosi tomonidan bloklashni xohlaymiz. Bunday ishlaydigan konstruktsiyalar mavjud; men ularni gibrid (hybrid) konstruktsiyalar deb atayman va ularni 30-bobda muhokama qilaman.
CLR ning thread sinxronizatsiya konstruktsiyalarining ko'pchiligi aslida Win32 thread sinxronizatsiya konstruktsiyalari atrofidagi obyektga yo'naltirilgan klass wrapperlardir. Nihoyat, CLR threadlari Windows threadlaridir, ya'ni Windows threadlarni rejalashtiradi va boshqaradi.
User-Mode Konstruktsiyalar
CLR quyidagi ma'lumot turlari o'zgaruvchilariga o'qish va yozishning atomik ekanligini kafolatlaydi: Boolean, Char, (S)Byte, (U)Int16, (U)Int32, (U)IntPtr, Single va reference turlari. Bu shuni anglatadiki, ushbu o'zgaruvchi ichidagi barcha baytlar bir vaqtda o'qiladi yoki yoziladi. Masalan, agar sizda quyidagi klass bo'lsa:
va agar thread quyidagi kod satrini bajarsa:
u holda x o'zgaruvchisi 0x00000000 dan 0x01234567 ga birdaniga (atomik ravishda) o'zgaradi. Boshqa thread oraliq holatdagi qiymatni ko'rishi mumkin emas. Masalan, boshqa threadning SomeType.x ni so'rab 0x01230000 qiymatini olishi mumkin emas.
Endi faraz qilaylik, oldingi SomeType klassidagi x maydoni Int64 bo'lsin. Agar thread quyidagi kodni bajarsa:
boshqa thread x ni so'rab 0x0123456700000000 yoki 0x0000000089abcdef qiymatini olishi mumkin, chunki o'qish va yozish operatsiyalari atomik emas. Bu torn read (yirtilgan o'qish) deb ataladi.
O'zgaruvchiga atomik kirish o'qish yoki yozishning birdaniga sodir bo'lishini kafolatlasa-da, kompilyator va CPU optimizatsiyalari tufayli o'qish yoki yozishning qachon sodir bo'lishini kafolatlamaydi. Ushbu bo'limda muhokama qilingan primitiv user-mode konstruktsiyalar bu atomik o'qish va yozish operatsiyalarining vaqtini boshqarish uchun ishlatiladi.
Primitiv user-mode thread sinxronizatsiya konstruktsiyalarining ikki turi mavjud:
- Volatile konstruktsiyalar — muayyan vaqtda oddiy ma'lumot turini o'z ichiga olgan o'zgaruvchi ustida atomik o'qish yoki yozish operatsiyasini bajaradi
- Interlocked konstruktsiyalar — muayyan vaqtda oddiy ma'lumot turini o'z ichiga olgan o'zgaruvchi ustida atomik o'qish va yozish operatsiyasini bajaradi
Barcha volatile va interlocked konstruktsiyalari oddiy ma'lumot turini o'z ichiga olgan o'zgaruvchiga havola (xotira manzili) uzatishingizni talab qiladi.
Volatile Konstruktsiyalar
Hisoblashning dastlabki kunlarida dasturiy ta'minot assembly tilida yozilgan. Assembly tili juda zerikarli, chunki dasturchilar hamma narsani aniq ko'rsatishlari kerak — shu CPU registeridan foydalaning, u yerga tarmoqlaning va hokazo. Dasturlashni soddalashtirish uchun yuqori darajadagi tillar yaratildi. Bu tillar if/else, switch/case, turli sikllar, lokal o'zgaruvchilar, argumentlar, virtual metod chaqiruvlari, operator ortiqcha yuklash va boshqa ko'plab foydali konstruktsiyalarni taqdim etdi. Oxir-oqibat, bu til kompilyatorlari yuqori darajadagi konstruktsiyalarni past darajadagi konstruktsiyalarga aylantirishi kerak.
Boshqacha aytganda, C# kompilyatori C# konstruktsiyalaringizni Intermediate Language (IL) ga tarjima qiladi, bu esa so'ngra just-in-time (JIT) kompilyator tomonidan native CPU ko'rsatmalariga aylantiriladi, bu esa so'ngra CPU o'zi tomonidan qayta ishlanishi kerak. Bundan tashqari, C# kompilyatori, JIT kompilyator va hatto CPU o'zi ham kodingizni optimallashtirishi mumkin. Masalan, quyidagi kulgili metod oxir-oqibat umuman hech narsaga kompilyatsiya qilinishi mumkin:
Bu kodda kompilyator value har doim 0 bo'lishini ko'radi; shuning uchun sikl hech qachon bajarilmaydi va demak, sikl ichidagi kodni kompilyatsiya qilish shart emas. Bu metod hech narsaga aylanishi mumkin.
C# kompilyatori, JIT kompilyator va CPU kodimizni optimallashtirganda, ular kodning niyati saqlanishiga kafolat beradi. Ya'ni, bitta threadli nuqtai nazardan, metod biz xohlagan narsani qiladi, garchi uni biz manba kodida tasvirlaganimizdek bajarmasligi mumkin. Biroq, ko'p threadli nuqtai nazardan niyat saqlanmasligi mumkin. Quyida optimizatsiyalar dastur kutilganidek ishlamasligiga olib keladigan misol keltirilgan:
Bu kodda Main metodi Worker metodini bajaradigan yangi thread yaratadi. Worker metodi to'xtatilishdan oldin iloji boricha yuqori raqamgacha sanaydi. Main metodi Worker threadiga 5 soniya ishlashga ruxsat beradi, so'ngra statik Boolean maydonni true qilib o'rnatish orqali to'xtashni buyuradi.
Oddiy ko'rinadi, to'g'rimi? Xo'sh, dasturda barcha optimizatsiyalar tufayli potentsial muammo bor. Ko'ring, Worker metodi kompilyatsiya qilinganda, kompilyator s_stopWorker yoki true yoki false ekanligini ko'radi va bu qiymat Worker metodining ichida hech qachon o'zgarmasligini ham ko'radi. Shuning uchun kompilyator avval s_stopWorker ni tekshiradigan kod ishlab chiqarishi mumkin. Agar s_stopWorker true bo'lsa, Worker: stopped when x=0 ko'rsatiladi. Agar s_stopWorker false bo'lsa, kompilyator x ni abadiy oshiruvchi cheksiz sikl yaratadi. Optimizatsiyalar sikl juda tez ishlashiga sabab bo'ladi, chunki s_stopWorker ni tekshirish faqat sikl boshlanishidan oldin bir marta sodir bo'ladi; u siklning har bir iteratsiyasida tekshirilmaydi.
Agar siz buni amalda ko'rmoqchi bo'lsangiz, kodni .cs fayliga joylashtiring va C# ning /platform:x86 va /optimize+ kalitlari yordamida kompilyatsiya qiling. Natijadagi EXE faylni ishga tushiring va dastur abadiy ishlashini ko'rasiz. x86 JIT kompilyatori x64 dan ko'ra yetukroq va agressivroq optimizatsiyalar qiladi. Bundan tashqari, debugger ostida ishga tushurmang, chunki debugger JIT kompilyatorni optimallashtirilmagan kod ishlab chiqarishga majbur qiladi.
Keling, ikkita threadning ikkita maydonga kiradigan yana bir misolni ko'rib chiqaylik:
Bu koddagi muammo shundaki, kompilyatorlar/CPU kodni shunday tarjima qilishi mumkinki, Thread1 metodidagi ikkita qator tartibini teskari o'zgartirishi mumkin. Axir, ikkita qatorni teskari o'zgartirish metodning niyatini o'zgartirmaydi. Metod m_value ga 5 va m_flag ga 1 qo'yishi kerak. Bitta threadli ilovaning nuqtai nazaridan, bu kodni bajarish tartibi ahamiyatsiz. Agar bu ikkita qator teskari tartibda bajarilsa, boshqa thread Thread2 metodini bajargan holda m_flag 1 ekanligini, lekin keyin 0 ko'rsatishini ko'rishi mumkin.
Bu hamma narsa juda qo'rqinchli va release buildda debug buildga qaraganda muammo paydo bo'lishi ko'proq ehtimol. Endi kodingizni qanday to'g'irlashni gapiraylik.
Statik System.Threading.Volatile klassi quyidagi ikkita statik metodni taklif qiladi:
Bu metodlar maxsus. Aslida, bu metodlar C# kompilyator, JIT kompilyator va CPU tomonidan odatda bajariladigan ba'zi optimizatsiyalarni o'chiradi. Metodlar quyidagicha ishlaydi:
Volatile.Writemetodi qiymatni chaqiruv nuqtasidalocationga yozilishini majbur qiladi. Bundan tashqari,Volatile.Writechaqiruvidan oldingi har qanday dastur tartibidagi yuklash va saqlashVolatile.Writechaqiruvidan oldin sodir bo'lishi kerak.Volatile.Readmetodi qiymatni chaqiruv nuqtasidalocationdan o'qilishini majbur qiladi. Bundan tashqari,Volatile.Readchaqiruvidan keyingi har qanday dastur tartibidagi yuklash va saqlashVolatile.Readchaqiruvidan keyin sodir bo'lishi kerak.
Bilaman, bu juda chalkash bo'lishi mumkin, shuning uchun uni oddiy qoida sifatida umumlashtiray. Threadlar umumiy xotira orqali bir-biri bilan muloqot qilganda, oxirgi qiymatni Volatile.Write ni chaqirish orqali yozing va birinchi qiymatni Volatile.Read ni chaqirish orqali o'qing.
Endi biz ThreadsSharingData klassini ushbu metodlar yordamida to'g'irlashimiz mumkin:
Thread1 metodi uchun Volatile.Write chaqiruvi barcha yuqoridagi yozishlar m_flag ga 1 yozilishidan oldin tugashini ta'minlaydi. m_value = 5 Volatile.Write chaqiruvidan oldin bo'lgani uchun, u avval tugashi kerak.
Thread2 metodi uchun Volatile.Read chaqiruvi undan keyingi barcha o'zgaruvchi o'qishlarning m_flag qiymati o'qilganidan keyin boshlanishini ta'minlaydi. m_value ni o'qish Volatile.Read chaqiruvidan keyin bo'lgani uchun, u m_flag qiymati o'qilganidan keyin o'qilishi kerak.
C# ning Volatile Maydonlar uchun Qo'llab-quvvatlashi
Dasturchilar Volatile.Read va Volatile.Write metodlarini to'g'ri chaqirishini ta'minlash juda ko'p ish talab qiladi. Dasturchilar uchun bularning barchasini eslab qolish va boshqa threadlar fon rejimida umumiy ma'lumotlar bilan nima qilayotganini tasavvur qilishni boshlash qiyin. Buni soddalashtirish uchun C# kompilyatori volatile kalit so'ziga ega bo'lib, u quyidagi turlardagi statik yoki misol maydonlariga qo'llanilishi mumkin: Boolean, (S)Byte, (U)Int16, (U)Int32, (U)IntPtr, Single yoki Char. Shuningdek, volatile ni reference turlariga va asosiy turi (S)Byte, (U)Int16 yoki (U)Int32 bo'lgan har qanday enum maydoniga qo'llashingiz mumkin.
JIT kompilyator volatile maydonga barcha kirishlarning volatile o'qish va yozish sifatida bajarilishini ta'minlaydi, shuning uchun Volatile ning statik Read yoki Write metodlarini aniq chaqirish shart emas. Bundan tashqari, volatile kalit so'zi C# va JIT kompilyatorlarga maydonni CPU registerida keshlamaslikni aytadi, bu barcha o'qish va yozishlarning aslida xotiradan sodir bo'lishini ta'minlaydi.
volatile kalit so'zidan foydalanib, ThreadsSharingData klassini quyidagicha qayta yozishimiz mumkin:
Ba'zi dasturchilar (shu jumladan men ham) C# ning volatile kalit so'zini yoqtirmaydilar va tilning uni taklif qilmasligi kerak deb o'ylaydilar. Bizning fikrimizcha, aksariyat algoritmlar maydonga kam sonli volatile o'qish yoki yozish operatsiyalarini talab qiladi va maydonga boshqa kirishlarning ko'pchiligi odatiy tarzda sodir bo'lishi mumkin. Masalan, quyidagi algoritmga volatile o'qish operatsiyalarini qanday qo'llashni tushunish qiyin:
Odatda, butun sonni ikki baravar oshirish barcha bitlarni 1 ga chapga siljitish orqali amalga oshirilishi mumkin va ko'p kompilyatorlar bu kodni ko'rib chiqib, bu optimizatsiyani bajarishi mumkin. Biroq, agar m_amount volatile maydon bo'lsa, bu optimizatsiyaga ruxsat berilmaydi. Kompilyator m_amount ni o'qish uchun registerga yuklaydigan, so'ngra uni yana boshqa registerga o'qiydigan, ikkita registrni qo'shadigan va natijani m_amount maydoniga qaytarib yozadigan kod ishlab chiqarishi kerak.
Bundan tashqari, C# volatile maydonni metodga reference bo'yicha uzatishni qo'llab-quvvatlamaydi. Masalan, agar m_amount volatile Int32 sifatida aniqlangan bo'lsa, Int32.TryParse ni chaqirishga urinish kompilyatorni ogohlantirish chiqarishga majbur qiladi:
Nihoyat, volatile maydonlar Common Language Specification (CLS) ga mos kelmaydi, chunki ko'p tillar (shu jumladan Visual Basic) ularni qo'llab-quvvatlamaydi.
Interlocked Konstruktsiyalar
Volatile ning Read metodi atomik o'qish operatsiyasini, Write metodi esa atomik yozish operatsiyasini bajaradi. Ya'ni, har bir metod atomik o'qish yoki atomik yozish operatsiyasini bajaradi. Ushbu bo'limda biz statik System.Threading.Interlocked klassining metodlarini ko'rib chiqamiz. Interlocked klassidagi har bir metod atomik o'qish va yozish operatsiyasini bajaradi. Bundan tashqari, barcha Interlocked metodlari to'liq xotira bariyerlaridir (memory fence). Ya'ni, Interlocked metodiga chaqiruvdan oldingi har qanday o'zgaruvchi yozishlar Interlocked metodi chaqiruvidan oldin bajariladi va undan keyingi har qanday o'zgaruvchi o'qishlar chaqiruvdan keyin bajariladi.
Int32 o'zgaruvchilari ustida ishlaydigan statik metodlar eng ko'p ishlatiladigan metodlar. Ularni bu yerda ko'rsataman:
Yuqoridagi metodlarning Int64 qiymatlari ustida ishlaydigan overloadlari ham mavjud. Bundan tashqari, Interlocked klassi Object, IntPtr, Single va Double ni qabul qiladigan Exchange va CompareExchange metodlarini va generik tur class ga cheklangan generik versiyasini taklif qiladi.
Men Interlocked metodlarini yaxshi ko'raman, chunki ular nisbatan tez va siz ular bilan juda ko'p narsani qilishingiz mumkin. Keling, sizga bir nechta veb-serverlarga asinxron so'rov yuborish va qaytarilgan ma'lumotlarni bir vaqtda qayta ishlash uchun Interlocked metodlarini ishlatadigan kodni ko'rsatay:
AsyncCoordinator klassi barcha thread koordinatsiya mantiqini o'z ichiga oladi. U hamma narsa uchun Interlocked metodlarini ishlatib, kodning juda tez ishlashini va hech qanday threadning hech qachon bloklanmasligini ta'minlaydi. Mana shu klass uchun kod:
m_opCount maydoni 1 ga (0 emas) ishga tushirilgan, bu juda muhim, chunki u konstruktor metodi hali ham veb-server so'rovlarini yuborayotgan paytda AllDone chaqirilmasligini ta'minlaydi. Konstruktor AllBegun ni chaqirmasdan oldin, m_opCount hech qachon 0 ga yetmaydi. Konstruktor AllBegun ni chaqirganda, AllBegun ichki ravishda JustEnded ni chaqiradi, bu m_opCount ni kamaytiradi va uni 1 ga ishga tushirish samarasini bekor qiladi. Endi m_opCount 0 ga yetishi mumkin, lekin faqat barcha veb-server so'rovlari boshlangan bo'lsa.
ReportStatus metodi barcha operatsiyalar tugatilishi, timeout sodir bo'lishi va Cancel chaqirilishi o'rtasidagi poyga holatini hal qiladi. ReportStatus m_callback metodining faqat bir marta chaqirilishini ta'minlashi kerak. G'olibni aniqlash Interlocked.Exchange ni chaqirish orqali amalga oshiriladi. Faqat Interlocked.Exchange 0 qaytargan birinchi thread g'olib bo'ladi va callback metodini chaqiradi.
Interlocked Anything Pattern
Ko'p odamlar Interlocked metodlariga qaraydilar va Microsoft nima uchun ko'proq stsenariylar uchun ishlatilishi mumkin bo'lgan boyroq interlocked metodlar to'plamini yaratmaganiga hayron bo'lishadi. Masalan, Interlocked klassi Multiply, Divide, Minimum, Maximum, And, Or, Xor va boshqa ko'plab metodlarni taklif qilgani yaxshi bo'lardi. Garchi Interlocked klassi bu metodlarni taklif qilmasa-da, Interlocked.CompareExchange yordamida Int32 da istalgan operatsiyani atomik tarzda bajarishga imkon beradigan mashhur pattern mavjud.
Bu pattern ma'lumotlar bazasi yozuvlarini o'zgartirish uchun ishlatiladigan optimistik parallellik patternlariga o'xshaydi. Mana shu pattern atomik Maximum metodini yaratish uchun ishlatilayotgan misol:
Metod boshida currentVal target dagi qiymat bilan ishga tushiriladi. Keyin, sikl ichida startVal shu qiymatga o'rnatiladi. startVal dan foydalanib, istalgan operatsiyani bajarishingiz mumkin. Oxir-oqibat, natijani desiredVal ga joylashtirishingiz kerak. Interlocked.CompareExchange target dagi qiymat startVal ga mos kelishini tekshiradi. Agar mos kelsa, CompareExchange uni desiredVal ga o'zgartiradi. Agar mos kelmasa, boshqa thread target ni o'zgartirgan, shuning uchun while sikli yangi qiymat bilan operatsiyani qaytadan urinadi.
Men bu patternni o'z kodimda ko'p ishlatganman va hatto uni inkapsulyatsiya qiluvchi generik Morph metodi yaratganman:
Oddiy Spin Lock ni Amalga Oshirish
Interlocked metodlari ajoyib, lekin ularning ko'pi faqat Int32 qiymatlari ustida ishlaydi. Agar siz klass obyektining bir nechta maydonlarini atomik tarzda boshqarishingiz kerak bo'lsa? Bunday holda, biz maydonlarni boshqaradigan kod mintaqasiga bir vaqtda faqat bitta threaddan boshqasining kirishini to'xtatishimiz kerak. Interlocked metodlaridan foydalanib, thread sinxronizatsiya qulfini qurish mumkin:
Va uni ishlatish uchun klass:
SimpleSpinLock implementatsiyasi juda oddiy. Agar ikkita thread bir vaqtda Enter ni chaqirsa, Interlocked.Exchange bitta threadning m_ResourceInUse ni 0 dan 1 ga o'zgartirishini ta'minlaydi. Bu thread Enter dan qaytadi va AccessResource metodidagi kodni bajarishda davom etadi. Boshqa thread m_ResourceInUse ni 1 dan 1 ga o'zgartiradi, ya'ni u 0 dan o'zgartirmadi, shuning uchun bu thread birinchi thread Leave ni chaqirmaguncha doimiy aylanib (spin), Exchange ni chaqirib turadi.
Bu oddiy thread sinxronizatsiya qulfining implementatsiyasi. Ushbu qulf bilan katta potentsial muammo shundaki, u qulf uchun tortishuv bo'lganda threadlarning aylanishiga sabab bo'ladi. Bu aylanish qimmatbaho CPU vaqtini behuda sarflaydi, CPU ni boshqa foydali ishlarni bajarishdan to'xtatadi. Natijada, spin locklar faqat juda tez bajariladigan kod mintaqalarini himoya qilish uchun ishlatilishi kerak.
Spin locklar odatda bitta CPU li mashinalarda ishlatilmasligi kerak, chunki qulfni ushlab turgan thread uni tezda bo'shata olmaydi. Holat yana yomonlashadi agar qulfni ushlab turgan thread past prioritetda bo'lsa va qulfni olmoqchi bo'lgan thread yuqori prioritetda bo'lsa — chunki qulfni ushlab turgan thread ishlash imkoniyatini umuman ololmasligi mumkin, bu esa livelock holatiga olib keladi.
FCL System.Threading.SpinLock strukturasini o'z ichiga oladi, bu mening SimpleSpinLock ga o'xshaydi, lekin SpinWait strukturasidan foydalanib unumdorlikni yaxshilaydi. SpinLock strukturasi timeout qo'llab-quvvatlashini ham taklif qiladi. SimpleSpinLock va FCL ning SpinLock i ikkalasi ham qiymat turlari ekanligini ta'kidlash qiziq. Bu ular yengil vaznli, xotiraga qulay obyektlar ekanligini anglatadi.
Thread Jarayonini Kechiktirish
"Black Magic" resurs olmoqchi bo'lgan threadning bajarilishini vaqtincha to'xtatishdir, shunda hozirda resursga ega bo'lgan thread o'z kodini bajarib, resursni bo'shatishi mumkin. Buning uchun SpinWait strukturasi ichki ravishda Thread ning statik Sleep, Yield va SpinWait metodlarini chaqiradi.
Thread.Sleep — thread ma'lum vaqt davomida rejalashtirilmasligini tizimga aytadi:
Bu metod threadni belgilangan vaqt o'tgunicha o'zini to'xtatishiga sabab bo'ladi. Sleep(0) ni chaqirish chaqiruvchi threadning o'z vaqt bo'lagining qolgan qismini boshqa threadga berishini bildiradi. Sleep(1) esa kontekst almashtirishni majbur qiladi va Windows threadni ichki tizim taymeri aniqligidan ko'proq muddat davomida uxlashga majbur qiladi.
Thread.Yield — joriy CPU da ishlashga tayyor bo'lgan boshqa threadni rejalashtirishni so'raydi:
Agar Windows joriy protsessorda ishlashga tayyor boshqa threadga ega bo'lsa, Yield true qaytaradi va Yield ni chaqirgan thread erta tugadi, tanlangan thread bitta vaqt bo'lagi uchun ishlaydi, keyin Yield ni chaqirgan thread qayta rejalanadi va yangi vaqt bo'lagi bilan ishlashni boshlaydi. Agar Windows joriy protsessorda ishlashga tayyor boshqa threadga ega bo'lmasa, Yield false qaytaradi va thread o'z vaqt bo'lagini davom ettiradi.
Thread.SpinWait — hyperthreaded CPU larda spin loop bajarayotganda foydali:
Bu metodni chaqirish aslida maxsus CPU ko'rsatmasini bajaradi; u Windows ga hech narsa qilishni aytmaydi (chunki Windows allaqachon CPU da ikki thread rejalashtirilgan deb biladi). Hyperthreaded bo'lmagan CPU da bu maxsus ko'rsatma oddiygina e'tibordan chetda qoldiriladi.
Kernel-Mode Konstruktsiyalar
Windows threadlarni sinxronlash uchun bir nechta kernel-mode konstruktsiyalarini taklif qiladi. Kernel-mode konstruktsiyalar user-mode konstruktsiyalardan ancha sekin. Buning sababi ular Windows operatsion tizimning o'zidan koordinatsiyani talab qiladi. Bundan tashqari, kernel obyektiga har bir metod chaqiruvi chaqiruvchi threadni managed koddan native user-mode kodga, so'ngra native kernel-mode kodga o'tkazadi va keyin butun yo'lni qaytadi. Bu o'tishlar ko'p CPU vaqtini talab qiladi va tez-tez bajarilsa, ilovangizning umumiy unumdorligiga salbiy ta'sir qilishi mumkin.
Biroq, kernel-mode konstruktsiyalar primitiv user-mode konstruktsiyalariga nisbatan ba'zi afzalliklarni taklif qiladi:
- Kernel-mode konstruktsiyasi resursga tortishuv aniqlaganda, Windows yutqazgan threadni bloklaydi, shunda u CPU da aylanmaydi va protsessor resurslarini behuda sarflamaydi.
- Kernel-mode konstruktsiyalar native va managed threadlarni sinxronlashtirishi mumkin.
- Kernel-mode konstruktsiyalar bitta mashinada turli jarayonlarda ishlaydigan threadlarni sinxronlashtirishi mumkin.
- Kernel-mode konstruktsiyalarga ruxsatsiz hisoblardan kirishni oldini olish uchun xavfsizlik qo'llanilishi mumkin.
- Thread to'plamdagi barcha kernel-mode konstruktsiyalar mavjud bo'lguncha yoki ulardan biri mavjud bo'lguncha bloklashi mumkin.
- Thread timeout qiymati bilan kernel-mode konstruktsiyasida bloklashi mumkin; agar thread belgilangan vaqtda resursga kira olmasa, thread blokdan chiqariladi.
Ikkita primitiv kernel-mode thread sinxronizatsiya konstruktsiyalari eventlar va semaforlardir. Boshqa kernel-mode konstruktsiyalar, masalan, mutex, bu ikki primitiv konstruktsiya ustiga qurilgan.
System.Threading namespace i abstrakt bazaviy klass WaitHandle ni taklif qiladi. WaitHandle klassi Win32 kernel obyekt handle ni o'rab oladigan oddiy klass. FCL WaitHandle dan hosila qilingan bir nechta klasslarni taqdim etadi. Klass iyerarxiyasi quyidagicha ko'rinadi:
WaitHandle bazaviy klassining eng muhim ochiq metodlari quyidagilar:
Bu metodlar haqida e'tibor berish kerak bo'lgan bir necha narsalar:
WaitOne— chaqiruvchi threadni asosiy kernel obyekti signallangunicha kutishga majbur qiladi. Ichki ravishda Win32WaitForSingleObjectExfunksiyasini chaqiradi. QaytarilganBoolean— agar obyekt signallangan bo'lsatrue, timeout sodir bo'lsafalse.WaitAll—WaitHandle[]da ko'rsatilgan barcha kernel obyektlari signallanguncha kutadi. Barcha obyektlar signallangan bo'lsatrue, timeout bo'lsafalseqaytaradi.WaitAny—WaitHandle[]da ko'rsatilgan kernel obyektlaridan istalgan biri signallanguncha kutadi. QaytarilganInt32signallangan kernel obyektning massiv indeksi yoki timeout bo'lsaWaitHandle.WaitTimeout.Dispose— asosiy kernel obyekt handle ni yopadi. Ichki ravishda Win32CloseHandlefunksiyasini chaqiradi.Disposeni aniq chaqirish o'rniga, garbage collector (GC) tozalashni bajarishiga ruxsat berish tavsiya etiladi.
WaitAny va WaitAll metodlariga uzatadigan massiv 64 tadan ko'p bo'lmagan elementni o'z ichiga olishi kerak, aks holda System.NotSupportedException tashlanadi. Timeout qabul qilmaydigan WaitOne va WaitAll versiyalari void qaytarish turiga ega sifatida prototiplangan, chunki bu metodlar doimo qaytadi va nazarda tutilgan timeout cheksiz (System.Threading.Timeout.Infinite).
Event Konstruktsiyalar
Eventlar oddiyroq qilib aytganda, yadro tomonidan boshqariladigan Boolean o'zgaruvchilaridir. Event da kutayotgan thread event false bo'lganda bloklanadi va event true bo'lganda blokdan chiqadi. Eventlarning ikki turi mavjud:
- Auto-reset event —
truebo'lganda, u faqat bitta bloklangan threadni uyg'otadi, chunki yadro birinchi threadni blokdan chiqargandan keyin eventni avtomatik ravishdafalsega qaytaradi. - Manual-reset event —
truebo'lganda, u barcha kutayotgan threadlarni blokdan chiqaradi, chunki yadro eventni avtomatik ravishdafalsega qaytarmaydi; kodingiz eventni qo'ldafalsega qaytarishi kerak.
Auto-reset event yordamida SimpleSpinLock ga o'xshash xatti-harakatga ega thread sinxronizatsiya qulfini osongina yaratishimiz mumkin:
Siz bu SimpleWaitLock ni SimpleSpinLock bilan xuddi bir xil tarzda ishlatasiz. Aslida, tashqi xatti-harakat bir xil; biroq, ikkala qulfning unumdorligi tubdan farq qiladi. Qulf uchun tortishuv bo'lmaganda, SimpleWaitLock SimpleSpinLock dan ancha sekin, chunki SimpleWaitLock ning Enter va Leave metodlariga har bir chaqiruv chaqiruvchi threadni managed koddan yadro kodiga va orqaga o'tishga majbur qiladi. Lekin tortishuv bo'lganda, yutqazgan thread bloklangan va CPU sikllarini behuda sarflamaydi — bu yaxshi.
Unumdorlik farqlarini yaxshiroq tushunish uchun, men quyidagi kodni yozdim:
Natijalar:
| Operatsiya | Vaqt (ms) | Farq |
|---|---|---|
x++ (hech narsasiz) | 8 | Eng tez |
x++ (bo'sh metodlar bilan) | 69 | ~9x sekinroq |
x++ (SpinLock bilan) | 164 | ~21x sekinroq |
x++ (SimpleWaitLock bilan) | 8,854 | ~1,107x sekinroq |
Ko'rib turganingizdek, shunchaki x ni oshirish faqat 8 millisekund oldi. Bo'sh metodlarni chaqirish operatsiyani to'qqiz marta sekinlashtirdi! User-mode konstruktsiyasini ishlatadigan metod 21 marta sekinroq ishladi (164 / 8). Lekin endi kernel-mode konstruktsiyasini ishlatgan dastur qanchalik sekinroq ishlaganini ko'ring: 1,107 marta (8,854 / 8) sekinroq! Demak, agar thread sinxronizatsiyasidan qochish imkoni bo'lsa, undan qoching. Agar thread sinxronizatsiyasi kerak bo'lsa, user-mode konstruktsiyalarini ishlating. Har doim kernel-mode konstruktsiyalaridan qochishga harakat qiling.
Semaphore Konstruktsiyalar
Semaforlar oddiyroq qilib aytganda, yadro tomonidan boshqariladigan Int32 o'zgaruvchilaridir. Semaforda kutayotgan thread semafor 0 bo'lganda bloklanadi va semafor 0 dan katta bo'lganda blokdan chiqadi. Thread semaforda blokdan chiqarilganda, yadro avtomatik ravishda semaforning hisobidan 1 ni ayiradi. Semaforlarning maksimal Int32 qiymati ham mavjud va joriy hisob hech qachon maksimal hisobdan oshishga ruxsat berilmaydi.
Bu uchta kernel-mode primitivlar qanday xatti-harakat ko'rsatishini umumlashtiray:
- Bir nechta threadlar auto-reset event da kutayotgan bo'lsa, eventni o'rnatish faqat bitta threadni blokdan chiqaradi.
- Bir nechta threadlar manual-reset event da kutayotgan bo'lsa, eventni o'rnatish barcha threadlarni blokdan chiqaradi.
- Bir nechta threadlar semaforda kutayotgan bo'lsa, semaforni bo'shatish
releaseCountta threadni blokdan chiqaradi (buSemaphore.Releasemetodiga uzatilgan argument).
Shuning uchun, auto-reset event maksimal hisob 1 bo'lgan semaforga juda o'xshash xatti-harakat ko'rsatadi. Farq shundaki, auto-reset event da Set ketma-ket bir necha marta chaqirilishi mumkin va faqat bitta thread blokdan chiqadi, semaford esa Release ni ketma-ket chaqirish ichki hisobni oshirib boradi va ko'p threadlarni blokdan chiqarishi mumkin. Agar semaforga Release ni juda ko'p marta chaqirsangiz va hisobi maksimal hisobdan oshib ketsa, SemaphoreFullException tashlanadi.
Semafor yordamida SimpleWaitLock ni qayta amalga oshirib, bir nechta threadlarga resursga bir vaqtda kirishga ruxsat berish mumkin (bu barcha threadlar resursga faqat o'qish rejimida kiradigan bo'lmasa, xavfsiz bo'lmasligi mumkin):
Kernel-mode konstruktsiyalarining keng tarqalgan foydalanish usullaridan biri — bir vaqtda o'zining faqat bitta nusxasi bajarilishiga ruxsat beradigan ilova yaratishdir. Yagona nusxa ilovalariga misollar: Microsoft Outlook, Windows Media Player va boshqalar. Mana yagona nusxa ilovani qanday amalga oshirish kerak:
Bu kodda men Semaphore ishlatmoqdaman, lekin EventWaitHandle yoki Mutex ishlatganimda ham ishlaydi, chunki men thread sinxronizatsiya xatti-harakatidan emas, yadro har qanday turdagi kernel obyektini yaratishda taklif qiladigan thread sinxronizatsiya xatti-harakatidan foydalanmoqdaman. Yadro belgilangan nom bilan faqat bitta kernel obyekti yaratilishini ta'minlaydi; obyektni yaratgan threadning createdNew o'zgaruvchisi true ga o'rnatiladi. Ikkinchi thread uchun Windows allaqachon mavjud kernel obyektini ko'radi va createdNew false ga o'rnatiladi.
Mutex Konstruktsiyalar
Mutex o'zaro istisno (mutual-exclusive) qulfni ifodalaydi. U AutoResetEvent yoki hisob 1 bo'lgan Semaphore ga o'xshash ishlaydi, chunki uchala konstruktsiya ham bir vaqtda faqat bitta kutayotgan threadni bo'shatadi.
Mutexlarda qo'shimcha mantiq mavjud bo'lib, bu ularni boshqa konstruktsiyalarga qaraganda murakkabroq qiladi:
- Thread egaligini kuzatish:
Mutexobyektlari qaysi thread uni olganini chaqiruvchi threadningInt32ID sini so'rash orqali yozib oladi. ThreadReleaseMutexni chaqirganda,Mutexchaqiruvchi threadningMutexni olgan xuddi shu thread ekanligini tekshiradi. Agar chaqiruvchi threadMutexni olgan thread bo'lmasa,Mutexning holati o'zgarmaydi vaReleaseMutexSystem.ApplicationExceptiontashlaydi. Shuningdek, agarMutexga ega bo'lgan thread boshqa sabab bilan tugatilsa,Mutexda kutayotgan threadSystem.Threading.AbandonedMutexExceptiontashlanishi bilan uyg'otiladi. - Rekursiya hisobi:
Mutexobyektlari egasi threadningMutexga necha marta ega ekanligini ko'rsatadigan rekursiya hisobini yuritadi. Agar thread hozirdaMutexga ega bo'lsa va keyin u shuMutexda yana kutsa, rekursiya hisob oshiriladi va threadga davom etishga ruxsat beriladi. ThreadReleaseMutexni chaqirganda, rekursiya hisob kamaytiriladi. Faqat rekursiya hisob 0 ga yetganda boshqa threadMutexning egasi bo'lishi mumkin.
Ko'p odamlar bu qo'shimcha mantiqni yoqtirmaydilar. Muammo shundaki, bu "xususiyatlar"ning narxi bor. Mutex obyekti qo'shimcha thread ID va rekursiya hisob ma'lumotlarini saqlash uchun ko'proq xotira talab qiladi. Bundan ham muhimi, Mutex kodi bu ma'lumotni yuritishi kerak, bu qulfni sekinlashtiradi. Shu sababli, ko'p odamlar Mutex obyektlaridan qochishadi.
Odatda, rekursiv qulf metod qulf olganida va keyin shuningdek qulf talab qiladigan boshqa metodni chaqirganida kerak bo'ladi:
Oldingi kodda SomeClass obyektidan foydalanadigan kod Method1 ni chaqirishi mumkin, u Mutex ni oladi, ba'zi thread-safe operatsiyalarni bajaradi va keyin xuddi shu Method2 ni chaqiradi. Mutex obyektlari rekursiyani qo'llab-quvvatlagani uchun, thread qulfni ikki marta oladi va keyin boshqa thread Mutex ga ega bo'lishidan oldin uni ikki marta bo'shatadi. Agar SomeClass Mutex o'rniga AutoResetEvent ishlatganda, thread Method2 ning WaitOne ni chaqirganda bloklanardi.
Agar sizga rekursiv qulf kerak bo'lsa, AutoResetEvent dan foydalanib osongina yaratishingiz mumkin:
RecursiveAutoResetEvent klassining xatti-harakati Mutex klassnikiga o'xshash bo'lsa-da, RecursiveAutoResetEvent obyekti thread rekursiv ravishda qulfni olishga uringanda ancha yaxshi unumdorlikka ega bo'ladi, chunki thread egaligini va rekursiyani kuzatish uchun zarur bo'lgan barcha kod endi managed kodda. Thread Windows yadrosiga faqat AutoResetEvent ni birinchi marta olganida yoki oxir-oqibat boshqa threadga bo'shatganida o'tishi kerak.