Преди .NET 4.0, ако трябваше да използваме класа Dictionary в многонишкова среда, нямахме друг избор освен сами да реализираме синхронизация на нишките, за да запазим нишките в безопасност.
Много разработчици със сигурност са реализирали подобно решение за нишки, или чрез създаване на изцяло нов тип речник, безопасен за нишки, или просто чрез капсулиране на речник в клас и добавяне на механизъм за заключване към всички методи, който наричаме "Речник + Заключвания".
Но сега имаме ConcurrentDictionary. Описанието на Dictionary Class Documentation в MSDN гласи, че ако трябва да използвате нитко-безопасна имплементация, използвайте ConcurrentDictionary.
Така че, сега, когато имаме курс по речник, безопасен за нишки, вече не е нужно да го прилагаме сами. Страхотно, нали?
Произход на проблема
Всъщност съм използвал CocurrentDictionary само веднъж преди, в теста си, за да тествам неговата отзивчивост. Тъй като се представи добре на тестовете, веднага го замених с моя клас, направих някои тестове и после нещо се обърка.
И така, какво се обърка? Не казахте ли, че е безопасно за конец?
След още тестове открих корена на проблема. Но по някаква причина MSDN версия 4.0 не включва описание на подписа на метода GetOrAdd, което изисква предаване на параметър тип делегат. След като разгледах версията 4.5, намерих тази бележка:
Ако извикаш GetOrAdd едновременно в различни нишки, addValueFactory може да се извиква многократно, но двойката ключ/стойност може да не се добавя в речника за всяко повикване. Това беше проблемът, с който се сблъсках. Тъй като не беше описано по-рано в документацията, трябваше да направя допълнителни тестове, за да потвърдя проблема. Разбира се, проблемът, с който се сблъсквам, е свързан с употребата ми, обикновено използвам речниковия тип за кеширане на някои данни:
Тези данни се създават много бавно; Тези данни могат да бъдат създадени само веднъж, защото второто създаване ще създаде изключение, или няколко творения могат да доведат до изтичане на ресурси и т.н.; Имах проблем с второто състояние. Ако и двете нишки установят, че дадена част от данни не съществува, тя ще бъде създадена веднъж, но само един резултат ще бъде успешно запазен. А другото?
Ако процесът, който създаваш, дава изключение, можеш да използваш try: Catch (не е достатъчно елегантно, но решава проблема). Но какво ако ресурс бъде създаден, а не рециклиран?
Може да кажете, че обектът е създаден и ще бъде събиран като боклук, ако вече не се споменава в него. Въпреки това, помислете какво би се случило, ако описаната по-долу ситуация се случи:
Генерирай код динамично с Emit. Използвах този подход в Remoting фреймуърк и сложих всички реализации в асемблер, който не можеше да се рециклира. Ако един тип бъде създаден два пъти, вторият винаги ще съществува, дори и никога да не е използван. Създайте тема директно или косвено. Например, трябва да изградим компонент, който използва собствена нишка за обработка на асинхронни съобщения и разчита на реда, в който са получени. Когато компонентът бъде инстанциран, се създава нишка. Когато този компонентен екземпляр бъде унищожен, нишката също се прекратява. Но ако изтрием препратката към обекта след унищожаване на компонента, нишката не приключва по някаква причина и запазва препратката към обекта. Тогава, ако конецът не умре, обектът също няма да бъде рециклиран. Изпълнете операция P/Invoke. Изисквайте броят на затворените времена за получената дръжка да е равен на броя на отварянията. Разбира се, има много подобни ситуации. Например, обект в речника ще задържи връзка към услуга на отдалечен сървър, която може да бъде поискана само веднъж, а ако бъде поискана втори път, другата услуга ще прецени, че е възникнала някаква грешка и ще я регистрира в лога. (В компания, за която работех, имаше някои законови санкции за това състояние.) ) Така че е лесно да се види, че Dictionary + Locks не може бързо да се замени с ConcurrentDictionary, дори ако документацията казва, че е безопасен за нишки.
Анализирайте проблема
Все още не разбираш?
Вярно е, че този проблем може да не възникне при подхода Речник + Ключалки. Тъй като това зависи от конкретната реализация, нека разгледаме този прост пример:
В горния код държим заключването на речника, преди да започнем да търсим ключовата стойност. Ако посочената двойка ключ-стойност не съществува, тя ще бъде създадена директно. В същото време, тъй като вече държим ключ върху този речник, можем да добавяме двойки ключ-стойност директно към речника. След това освобождавам заключването на речника и връщам резултата. Ако две нишки търсят една и съща стойност на ключа едновременно, първата нишка, която получи заключване на речника, ще завърши създаването на обекта, а другата нишка ще изчака завършването на това създаване и ще получи резултата за създадената ключова стойност след получаване на заключването на речника.
Това е добре, нали?
Наистина не е! Не мисля, че създаването на обект паралелно по този начин, където накрая се използва само един, не създава проблема, който описах.
Ситуацията и проблемът, които се опитвам да обясня, може да не винаги са възпроизводими – в паралелна среда можем просто да създадем два обекта и след това да изхвърлим един. И така, как точно да сравним Dictionary + Locks и ConcurrentDictionary?
Отговорът е: зависи от стратегията за използване на заключването и начина, по който се използва речникът.
Игра 1: Създаване на един и същ обект паралелно
Първо, нека приемем, че обект може да бъде създаден два пъти, какво се случва, ако две нишки създадат този обект едновременно?
Второ, колко време прекарваме в подобни творения?
Можем просто да създадем пример, при който инстанцирането на обект отнема 10 секунди. Когато първата нишка създаде обекта 5 секунди по-късно, втората имплементация се опитва да извика метода GetOrAdd, за да получи обекта, и тъй като обектът все още не съществува, тя също започва да създава обекта.
В това състояние имаме 2 процесора, работещи паралелно за 5 секунди, и когато първата нишка приключи, втората нишка трябва да продължи да работи 5 секунди, за да завърши изграждането на обекта. Когато втората нишка завърши изграждането на обекта, тя установява, че обектът вече съществува, и избира да използва съществуващия обект и да изхвърли новосъздадения обект директно.
Ако втората нишка просто чака и вторият процесор върши друга работа (пуска други нишки или приложения, спестявайки малко енергия), той ще получи желания обект след 5 секунди вместо 10 секунди.
Така че, при тези условия, Речник + Ключалки печелят малка игра.
Игра 2: Посещавайте различни обекти паралелно
Не, ситуацията, която каза, изобщо не е вярна!
Горният пример е малко странен, но описва проблема, просто тази употреба е по-екстремна. Помислете какво се случва, ако първата нишка създава обект, а втората нишка трябва да достъпи друг обект с ключова стойност, а този обект вече съществува?
В ConcurrentDictionary дизайнът без заключване прави четенето много бързо, защото няма заключване на четенето. В случая с речник + заключвания, операцията за четене ще бъде заключена взаимно изключващо, дори и да е напълно различен ключ, което очевидно ще забави операцията по четене.
По този начин ConcurrentDictionary оттегли една игра.
Забележка: Тук смятам, че разбирате няколко концепции като Bucket/Node/Entry в курса по речника, ако не сте, препоръчително е да прочетете статията на Офир Макмал "Разбиране на общия речник в дълбочина", която обяснява добре тези понятия.
Третата игра в играта: чети повече и пиши на единично
Какво се случва, ако използвате Multiple Readers и Single Writer вместо пълна заключваща връзка на речника в Dictionary + Locks?
Ако нишка създава обект и държи надграждаема заключваща система, докато обектът не бъде създаден, заключването се надгражда до заключване за запис, след което операцията по четене може да се извършва паралелно.
Можем също да решим проблема, като оставим операцията по четене в покой за 10 секунди. Но ако има много повече четения, отколкото записи, ще установим, че ConcurrentDictionary все още е бърз, защото реализира четене в режим без заключване.
Използването на ReaderWriterLockSlim за речници влошава четенето и обикновено се препоръчва да се използва Full Lock за речници вместо ReaderWriterLockSlim.
Така че, при тези условия, ConcurrentDictionary спечели още една игра.
Забележка: В предишни статии съм разглеждал класовете YieldReaderWriterLock и YieldReaderWriterLockSlim. Чрез използването на това заключване на четене-запис, скоростта е значително подобрена (сега еволюирала в SpinReaderWriterLockSlim) и позволява множество четения да се изпълняват паралелно с малко или никакво въздействие. Докато все още използвам този начин, заключващият ConcurrentDictionary очевидно би бил по-бърз.
Игра 4: Добавете няколко двойки ключ-стойност
Сблъсъкът още не е приключил.
Ами ако имаме няколко ключови стойности за добавяне, и всички те не се сблъскват и са разпределени в различни кофи?
Първоначално въпросът ми беше любопитен, но направих тест, който не ми пасна напълно. Използвах речник с тип <int, int> и фабриката за конструиране на обекта връщаше отрицателен резултат директно като ключ.
Очаквах ConcurrentDictionary да е най-бързият, но се оказа най-бавният. Dictionary + Locks, от друга страна, работи по-бързо. Защо?
Това е така, защото ConcurrentDictionary разпределя възлите и ги поставя в различни кофи, оптимизирано да отговаря на дизайна без заключвания за операции с четене. Въпреки това, при добавяне на елементи с ключова стойност, процесът на създаване на възел става скъп.
Дори при паралелни условия, разпределянето на заключване на възел отнема повече време, отколкото при използване на пълен заключване.
Така че, Dictionary + Locks печели тази игра.
Играя петата игра: Честотата на операциите по четене е по-висока
Честно казано, ако имахме делегат, който може бързо да инстанцира обекти, нямаше да ни трябва речник. Можем директно да се обадим на делегата, за да получим обекта, нали?
Всъщност отговорът е, че зависи и от ситуацията.
Представете си, че типът ключ е низ и съдържа карти на пътищата за различни страници в уеб сървъра, а съответната стойност е тип обект, който съдържа записа на текущите потребители, които са достъпили страницата, и броя на всички посещения на страницата от стартирането на сървъра.
Създаването на такъв обект е почти мигновено. И след това не е нужно да създаваш нов обект, просто променяй стойностите, запазени в него. Така че е възможно да се позволи създаването на начин два пъти, докато не се използва само един инстанс. Въпреки това, тъй като ConcurrentDictionary разпределя ресурсите на Node по-бавно, използването на Dictionary + Locks ще доведе до по-бързо създаване на ресурси.
Този пример е много специален, виждаме също, че Dictionary + Locks се представя по-добре при това състояние, отнема по-малко време.
Въпреки че разпределението на възли в ConcurrentDictionary е по-бавно, не се опитах да сложа 100 милиона елемента данни, за да тествам времето. Защото това очевидно отнема много време.
Но в повечето случаи, след като даден елемент от данни бъде създаден, той винаги се чете. Как се променя съдържанието на елемента от данни е друг въпрос. Така че няма значение колко милисекунди отнема създаването на данни, защото четенето е по-бързо (само няколко милисекунди по-бързо), но четенето се случва по-често.
Така че ConcurrentDictionary спечели играта.
Игра 6: Създаване на обекти, които заемат различно време
Какво се случва, ако времето, необходимо за създаване на различни елементи от данни, варира?
Създайте няколко елемента от данни, които заемат различно време, и ги добавете паралелно към речника. Това е най-силната страна на ConcurrentDictionary.
ConcurrentDictionary използва редица различни механизми за заключване, за да позволи добавяне на елементи от данни едновременно, но логика като избор на кода да се използва, искане на заключване за промяна на размера на кофата и др., не помага. Скоростта, с която данните се въвеждат в кофа, е машинно бърза. Това, което наистина прави ConcurrentDictionary успешен, е способността му да създава обекти паралелно.
Въпреки това, ние всъщност можем да направим същото. Ако не ни интересува дали създаваме обекти паралелно или някои от тях са изхвърлени, можем да добавим заключване, за да открием дали елементът от данни вече съществува, след това да освободим заключването, да създадем елемента от данните, да го натиснем, за да получим заключването, да проверим отново дали елементът с данни съществува, а ако не съществува, да добавим елемента от данните. Кодът може да изглежда приблизително така:
* Обърнете внимание, че използвам речник от типа <int, int>.
В простата структура по-горе, Dictionary + Locks се представя почти толкова добре, колкото ConcurrentDictionary при създаване и добавяне на данни в паралелни условия. Но има и същия проблем, при който някои стойности могат да бъдат генерирани, но никога да не се използват.
извод
И така, има ли заключение?
В момента все още има някои:
Всички курсове по речник са много бързи. Въпреки че съм създал милиони данни, все пак е бързо. Обикновено създаваме само малък брой елементи от данни, а между четенията има някои времеви интервали, така че обикновено не забелязваме времевите разходи при четене на данни. Ако един и същ обект не може да бъде създаден два пъти, не използвайте ConcurrentDictionary. Ако наистина се притесняваш за производителността, Dictionary + Locks все още може да е добро решение. Важен фактор е броят на добавените и премахваните данни. Но ако има много операции за четене, това е по-бавно от ConcurrentDictionary. Въпреки че не го въведох, всъщност има повече свобода да използвате схемата Речник + Ключалки. Например, можете да заключите веднъж, да добавите няколко елемента с данни, да изтриете няколко елемента или да направите заявки многократно и след това да освободите заключването. Обикновено избягвайте използването на ReaderWriterLockSlim, ако има много повече четения, отколкото записи. Типовете речници вече са много по-бързи от заключването на четене в заключване за четене-записване. Разбира се, това зависи и от времето, което е отделено за създаване на обект в заключване. Така че мисля, че дадените примери са малко крайни, но показват, че използването на ConcurrentDictionary не винаги е най-доброто решение.
Усети разликата
Написах тази статия с намерението да намеря по-добро решение.
Вече се опитвам да разбера по-дълбоко как работи конкретен курс по речник (сега ми се струва, че съм много ясен).
Може да се каже, че Bucket и Node в ConcurrentDictionary са много прости. Направих нещо подобно, когато се опитах да създам курс по речник. Обикновеният курс по речник може да изглежда по-прост, но всъщност е по-сложен.
В ConcurrentDictionary всеки възел е пълен клас. В класа Dictionary Node се реализира с тип стойност, като всички възли се съхраняват в огромен масив, докато Bucket се използва за индексиране в масива. Той се използва и вместо простата препратка на възел към следващия възел (в крайна сметка, като възел от тип структура, не може да съдържа член на възел от тип структура).
При добавяне и премахване на речник, класът Речник не може просто да създаде нов възел, той трябва да провери дали има индекс, маркиращ изтрит възел, и след това да го използва отново. Или "Count" се използва, за да се определи позицията на новия възел в масива. Всъщност, когато масивът е пълен, класът Речник налага промяна на размера.
За ConcurrentDictionary възел може да се разглежда като нов обект. Премахването на възел е просто премахване на неговата референция. Добавянето на нов възел може просто да създаде нова инстанция на възел. Промяната на размера е само за да се избегнат конфликти, но не е задължителна.
Така че, ако класът Dictionary умишлено използва по-сложни алгоритми за обработка, как ConcurrentDictionary ще гарантира, че той работи по-добре в многонишкова среда?
Истината е: поставянето на всички възли в един масив е най-бързият начин за разпределяне и четене, дори ако ни трябва друг масив, за да следим къде да намерим тези данни. Изглежда, че същият брой кофи ще използва повече памет, но новите елементи от данни не се нуждаят от преразпределяне, не са нужни нови синхронизации на обекти и не се случва ново събиране на боклук. Защото всичко вече е на мястото си.
Въпреки това, замяната на съдържание в Node не е атомарна операция, което е един от факторите, които правят нишката несигурна. Тъй като възлите са всички обекти, първоначално се създава възел, а след това се обновява отделна препратка, която да го посочи (атомна операция тук). Така четенето може да чете съдържанието на речника без заключване, а четенето трябва да е една от старите и новите стойности, и няма шанс да се прочете непълна стойност.
Така че, истината е: ако не ти трябва заключване, класът Речник е по-бърз при четене, защото именно заключването забавя четенето.
Тази статия е преведена от статията на Пауло Земек "Речник + Заключване срещу ConcurrentDictionary" в CodeProject, като някои твърдения ще се променят поради разбиране.
|