Przed .NET 4.0, jeśli musieliśmy używać klasy Dictionary w środowisku wielowątkowym, nie mieliśmy wyboru i musieliśmy samodzielnie wdrożyć synchronizację wątków, aby zabezpieczyć wątki.
Wielu deweloperów z pewnością wdrożyło podobne rozwiązanie bezpieczne dla wątków, tworząc zupełnie nowy typ słownika bezpiecznego dla wątków, albo po prostu enkapsulując obiekt słownika w klasie i dodając mechanizm blokujący do wszystkich metod, który nazywamy "Słownikiem + blokadami".
Ale teraz mamy ConcurrentDictionary. Opis bezpieczeństwa wątkowego w dokumentacji klas Dictionary na MSDN mówi, że jeśli musisz użyć implementacji bezpiecznej dla wątków, użyj ConcurrentDictionary.
Teraz, gdy mamy klasę słownika bezpieczną dla wątków, nie musimy już sami jej implementować. Świetnie, prawda?
Pochodzenie problemu
W rzeczywistości CocurrentDictionary użyłem tylko raz wcześniej, podczas testu, żeby sprawdzić jego responsywność. Ponieważ dobrze wypadł na testach, od razu zastąpiłem go moją klasą, zrobiłem testy, a potem coś poszło nie tak.
Więc co poszło nie tak? Nie mówiłeś, że to bezpieczne na nici?
Po dalszych badaniach znalazłem źródło problemu. Ale z jakiegoś powodu wersja MSDN 4.0 nie zawiera opisu sygnatury metody GetOrAdd wymagającego przekazania parametru typu delegate. Po przejrzeniu wersji 4.5 znalazłem tę notatkę:
Jeśli wywołasz GetOrAdd jednocześnie na różnych wątkach, addValueFactory może być wywoływany wielokrotnie, ale jego para klucz/wartość może nie być dodawana do słownika dla każdego wywołania. To jest problem, na który natrafiłem. Ponieważ nie było to wcześniej opisane w dokumentacji, musiałem przeprowadzić dodatkowe testy, aby potwierdzić problem. Oczywiście, problem, na który napotykam, dotyczy mojego użytkowania, generalnie używam typu słownika do buforowania niektórych danych:
Te dane są bardzo wolne w tworzeniu; Dane te mogą być utworzone tylko raz, ponieważ drugie utworzenie wygeneruje wyjątek lub wielokrotne tworzenie może prowadzić do wycieku zasobów itd.; Miałem problem z drugim warunkiem. Jeśli oba wątki okażą brak danego fragmentu, zostanie on utworzony raz, ale tylko jeden wynik zostanie pomyślnie zapisany. A co z tym drugim?
Jeśli proces, który tworzysz, wywołuje wyjątek, możesz użyć try: Catch (nie wystarczająco elegancki, ale rozwiązuje problem). Ale co jeśli zasób zostanie stworzony, a nie poddany recyklingowi?
Możesz powiedzieć, że obiekt jest tworzony i będzie zbierany śmieciami, jeśli nie będzie już w nim odwoływany. Jednak zastanów się, co by się stało, gdyby sytuacja opisana poniżej miała miejsce:
Generuj kod dynamicznie za pomocą Emit. Zastosowałem to podejście w frameworku Reremote i umieściłem wszystkie implementacje w asemblerze, którego nie dało się powtórzyć. Jeśli typ zostanie utworzony dwa razy, drugi zawsze będzie istnieć, nawet jeśli nigdy nie był używany. Stwórz wątek bezpośrednio lub pośrednio. Na przykład musimy zbudować komponent, który używa wątku własnościowego do przetwarzania wiadomości asynchronicznych i opiera się na kolejności ich odbioru. Po instancji komponentu tworzy się wątek. Gdy ta instancja komponentu zostanie zniszczona, wątek również zostaje zakończony. Ale jeśli usuniemy odniesienie do obiektu po zniszczeniu komponentu, to wątek z jakiegoś powodu się nie kończy i zachowuje odniesienie do obiektu. Jeśli nić nie oburze, przedmiot również nie zostanie poddany recyklingowi. Wykonaj operację P/Invoke. Wymagaj, aby liczba zamkniętych czasów dla otrzymanego uchwytu była taka sama jak liczba otwarci. Oczywiście, istnieje wiele podobnych sytuacji. Na przykład obiekt słownika będzie miał połączenie z usługą na serwerze zdalnym, które można zażądać tylko raz, a jeśli zostanie zażądane po raz drugi, druga usługa uzna, że wystąpił jakiś błąd i zapisze go w logu. (W firmie, w której pracowałem, istniały pewne kary prawne za ten stan.) ) Łatwo więc zauważyć, że Dictionary + Locks nie można pochopnie zastąpić ConcurrentDictionary, nawet jeśli dokumentacja mówi, że jest bezpieczny dla wątków.
Przeanalizuj problem
Nadal nie rozumiesz?
To prawda, że ten problem może nie pojawić się w przypadku podejścia Słownik + Zamki. Ponieważ zależy to od konkretnej implementacji, przyjrzyjmy się temu prostemu przykładowi:
W powyższym kodzie trzymamy blokadę słownika przed rozpoczęciem zapytania o wartość klucza. Jeśli określona para klucz-wartość nie istnieje, zostanie ona utworzona bezpośrednio. Jednocześnie, ponieważ już mamy blokadę na tym słowniku, możemy dodawać pary klucz-wartość bezpośrednio do słownika. Następnie zwolnij blokadę słownika i zwróć wynik. Jeśli dwa wątki zapytują tę samą wartość klucza jednocześnie, pierwszy wątek, który otrzyma blokadę słownika, dokończy tworzenie obiektu, a drugi wątek poczeka na zakończenie tego utworzenia i otrzyma wynik utworzonej wartości klucza po uzyskaniu blokady słownika.
To dobrze, prawda?
Naprawdę nie! Nie sądzę, żeby tworzenie obiektu równolegle, gdzie na końcu używany jest tylko jeden, nie powodowało problemu, o którym opisałem.
Sytuacja i problem, które próbuję rozwinąć, nie zawsze są powtarzalne – w środowisku równoległym możemy po prostu stworzyć dwa obiekty, a potem odrzucić jeden. Jak dokładnie porównać Dictionary + Locks i ConcurrentDictionary?
Odpowiedź brzmi: to zależy od strategii używania zamków i sposobu używania słownika.
Gra 1: Stwórz ten sam obiekt równolegle
Po pierwsze, załóżmy, że obiekt można utworzyć dwa razy, więc co się stanie, jeśli dwa wątki utworzą ten obiekt jednocześnie?
Po drugie, ile czasu poświęcamy na podobne kreacje?
Możemy po prostu zbudować przykład, w którym instancja obiektu zajmuje 10 sekund. Gdy pierwszy wątek tworzy obiekt 5 sekund później, druga implementacja próbuje wywołać metodę GetOrAdd, aby uzyskać obiekt, a ponieważ obiekt nadal nie istnieje, również zaczyna go tworzyć.
W tym stanie mamy 2 procesory pracujące równolegle przez 5 sekund, a gdy pierwszy wątek kończy pracę, drugi wątek musi działać jeszcze przez 5 sekund, aby ukończyć konstrukcję obiektu. Gdy drugi wątek kończy budowę obiektu, odkrywa, że obiekt już istnieje, i decyduje się użyć istniejącego obiektu oraz odrzucić nowo utworzony obiekt bezpośrednio.
Jeśli drugi wątek po prostu czeka, a drugi procesor wykonuje inną pracę (uruchamianie innych wątków lub aplikacji, oszczędzanie energii), po 5 sekundach zamiast 10 sekund otrzyma pożądany obiekt.
W tych warunkach Dictionary + Locks wygrywa małą grę.
Gra 2: Odwiedzanie różnych obiektów równolegle
Nie, sytuacja, o której mówisz, wcale nie jest prawdziwa!
Cóż, powyższy przykład jest trochę osobliwy, ale opisuje problem – po prostu to użycie jest bardziej ekstremalne. Zastanów się więc, co się stanie, jeśli pierwszy wątek tworzy obiekt, a drugi wątek musi uzyskać dostęp do innego obiektu klucz-wartość, a ten obiekt kluczowo-wartość już istnieje?
W ConcurrentDictionary konstrukcja bez blokady sprawia, że odczyty są bardzo szybkie, ponieważ nie ma blokady odczytu. W przypadku Dictionary + Locks operacja odczytu będzie zablokowana wzajemnie wykluczająca się, nawet jeśli jest to zupełnie inny klucz, co oczywiście spowolni operację odczytu.
W ten sposób ConcurrentDictionary cofnął grę.
Uwaga: Uważam, że tutaj rozumiesz kilka pojęć, takich jak Bucket/Node/Entry na zajęciach słownikowych, jeśli nie, polecam przeczytać artykuł Ofira Makmala "Understanding Generic Dictionary in-depth", który dobrze wyjaśnia te koncepcje.
Trzecia gra w grze: czytaj więcej i pisz singiel
Co się stanie, jeśli użyjesz Multiple Readers i Single Writer zamiast pełnej blokady słownika w Dictionary + Locks?
Jeśli wątek tworzy obiekt i utrzymuje blokadę do czasu jego utworzenia, blokada jest ulepszana do blokady zapisu, wtedy operacja odczytu może być wykonywana równolegle.
Możemy także rozwiązać problem, pozostawiając operację odczytu bezczynną przez 10 sekund. Ale jeśli jest znacznie więcej odczytów niż zapisów, przekonamy się, że ConcurrentDictionary nadal jest szybki, ponieważ implementuje odczyty w trybie bezblokowym.
Używanie ReaderWriterLockSlim do słowników pogarsza czytanie, dlatego zaleca się używanie Full Lock do słowników zamiast ReaderWriterLockSlim.
W tych warunkach ConcurrentDictionary wygrał kolejną grę.
Uwaga: Omówiłem klasy YieldReaderWriterLock i YieldReaderWriterSlim w poprzednich artykułach. Dzięki zastosowaniu tej blokady odczytu i zapisu prędkość została znacznie poprawiona (obecnie przekształcona w SpinReaderWriterLockSlim) i pozwala na jednoczesne wykonywanie wielu odczytów z niewielkim lub zerowym wpływem. Dopóki nadal korzystam w ten sposób, bezblokowy słownik ConcurrentDictionary oczywiście byłby szybszy.
Gra 4: Dodaj wiele par klucz-wartość
Starcie jeszcze się nie skończyło.
A co jeśli mamy wiele kluczowych wartości do dodania, a wszystkie się nie zderzają i są przypisane do różnych kategorii?
Na początku to pytanie było ciekawe, ale zrobiłem test, który nie do końca pasował. Użyłem słownika typu <int, int> a fabryka konstrukcji obiektu zwracała negatywny wynik bezpośrednio jako klucz.
Spodziewałem się, że ConcurrentDictionary będzie najszybszy, ale okazało się, że jest najwolniejszy. Dictionary + Locks natomiast działają szybciej. Dlaczego tak jest?
Wynika to z faktu, że ConcurrentDictionary przydziela węzły i umieszcza je w różnych kubełkach, co jest zoptymalizowane pod kątem projektu bezblokadowego dla operacji odczytu. Jednak przy dodawaniu elementów klucz-wartość proces tworzenia węzła staje się kosztowny.
Nawet w równoległych warunkach przydzielenie blokady węzła zajmuje więcej czasu niż użycie pełnej blokady.
Więc Dictionary + Locks wygrywa tę grę.
Grając w piątą grę: Częstotliwość operacji odczytu jest wyższa
Szczerze mówiąc, gdybyśmy mieli delegata, który potrafiłby szybko instancjonować obiekty, nie potrzebowalibyśmy słownika. Możemy bezpośrednio wezwać delegata, żeby zdobyć obiekt, prawda?
W rzeczywistości odpowiedź jest taka, że to zależy od sytuacji.
Wyobraź sobie, że typ klucza to string i zawiera mapy ścieżek dla różnych stron serwera WWW, a odpowiadającą mu wartością jest typ obiektu zawierający rekord aktualnych użytkowników uzyskujących dostęp do strony oraz liczbę wszystkich wizyt na stronie od momentu uruchomienia serwera.
Stworzenie takiego obiektu jest niemal natychmiastowe. A potem nie musisz tworzyć nowego obiektu, wystarczy zmienić zapisane w nim wartości. Możliwe jest więc umożliwienie utworzenia drogi dwukrotnie, aż zostanie użyta tylko jedna instancja. Jednak ponieważ ConcurrentDictionary alokuje zasoby węzłów wolniej, użycie Dictionary+ Locks przyspieszy czas tworzenia.
Zatem w tym przykładzie jest bardzo wyjątkowy, widzimy też, że Dictionary + Locks działa lepiej w tym warunku, zajmując mniej czasu.
Chociaż alokacja węzłów w ConcurrentDictionary jest wolniejsza, nie próbowałem wprowadzić do niego 100 milionów elementów danych, żeby sprawdzić czas. Bo to oczywiście zajmuje dużo czasu.
Ale w większości przypadków, gdy element danych zostanie utworzony, jest on zawsze odczytywany. To, jak zmienia się treść danych danych, to już inna sprawa. Nie ma więc znaczenia, ile milisekund więcej zajmie utworzenie elementu danych, ponieważ odczyty są szybsze (tylko o kilka milisekund szybsze), ale odczyty zdarzają się częściej.
Więc ConcurrentDictionary wygrał tę grę.
Gra 6: Stwórz obiekty, które zużywają różne czasy
Co się stanie, jeśli czas potrzebny na stworzenie różnych elementów danych będzie się różnił?
Stwórz wiele elementów danych, które zużywają różne czasy i dodaj je do słownika równolegle. To jest najsilniejszy punkt ConcurrentDictionary.
ConcurrentDictionary wykorzystuje różne mechanizmy blokowania, aby umożliwić jednoczesne dodawanie elementów danych, ale logika taka jak decyzja, której blokady użyć, prośba o zmianę rozmiaru kubełka itp., nie pomaga. Szybkość, z jaką elementy danych są umieszczane w jednym wiadrze, jest maszynowo szybka. To, co naprawdę sprawia, że ConcurrentDictionary wygrywa, to jego zdolność do tworzenia obiektów równolegle.
Jednak możemy zrobić to samo. Jeśli nie zależy nam, czy tworzymy obiekty równolegle lub czy niektóre z nich zostały odrzucone, możemy dodać blokadę, która wykryje, czy element danych już istnieje, potem zwolnić blokadę, utworzyć element danych, nacisnąć go, aby go uzyskać, ponownie sprawdzić, czy element danych istnieje, a jeśli nie, dodać ten element. Kod może wyglądać mniej więcej tak:
* Zwróć uwagę, że używam słownika typu <int, int>.
W prostej strukturze powyżej Dictionary+ Locks działa niemal tak dobrze jak ConcurrentDictionary przy tworzeniu i dodawaniu elementów danych w warunkach równoległych. Ale jest też ten sam problem, gdzie niektóre wartości mogą być generowane, ale nigdy nie używane.
konkluzja
Czy więc jest jakiś wniosek?
Na ten moment wciąż istnieją niektórzy:
Wszystkie zajęcia ze słownika są bardzo szybkie. Mimo że stworzyłem miliony danych, to wciąż jest szybkie. Zazwyczaj tworzymy tylko niewielką liczbę elementów danych, a między odczytami występują pewne odstępy czasowe, więc zazwyczaj nie zauważamy narzutu czasowego związanego z odczytem elementów. Jeśli ten sam obiekt nie może zostać utworzony dwa razy, nie używaj ConcurrentDictionary. Jeśli naprawdę zależy ci na wydajności, Dictionary + Locks może być dobrym rozwiązaniem. Ważnym czynnikiem jest liczba dodanych i usuniętych elementów danych. Ale jeśli jest wiele operacji odczytu, jest wolniejszy niż ConcurrentDictionary. Chociaż tego nie wprowadzałem, w rzeczywistości jest więcej swobody w korzystaniu ze schematu Słownik + Locks. Na przykład możesz zablokować raz, dodać wiele elementów danych, usunąć wiele elementów danych lub zapytać wielokrotnie, itd., a potem zwolnić blokadę. Generalnie unikaj korzystania z ReaderWriterLockSlim, jeśli jest znacznie więcej czytań niż zapisów. Typy słowników są już znacznie szybsze niż uzyskanie blokady odczytu w blokadzie odczytu-zapisu. Oczywiście zależy to także od czasu potrzebnego na utworzenie obiektu w blokadzie. Uważam więc, że podane przykłady są trochę ekstremalne, ale pokazują, że używanie ConcurrentDictionary nie zawsze jest najlepszym rozwiązaniem.
Poczuj różnicę
Napisałem ten artykuł z zamiarem znalezienia lepszego rozwiązania.
Już próbuję lepiej zrozumieć, jak działa konkretny kurs słownika (teraz czuję, że jestem bardzo jasny).
Można argumentować, że Bucket i Node w ConcurrentDictionary są bardzo proste. Zrobiłem coś podobnego, gdy próbowałem stworzyć kurs słownika. Zwykła klasa Słownika może wydawać się prostsza, ale w rzeczywistości jest bardziej złożona.
W ConcurrentDictionary każdy węzeł jest kompletną klasą. W klasie Słownik węzeł jest zaimplementowany za pomocą typu wartości, a wszystkie węzły są przechowywane w ogromnej tablicy, podczas gdy do indeksowania w tablicy służy kubeł. Jest również używany zamiast prostego odniesienia węzła do jego następnego węzła (w końcu jako węzeł typu struktury, nie może zawierać członka węzła typu struktury).
Podczas dodawania i usuwania słownika klasa Dictionary nie może po prostu utworzyć nowego węzła, musi sprawdzić, czy istnieje indeks oznaczający usunięty węzeł, a następnie ponownie go wykorzystać. Lub "Count" służy do określenia pozycji nowego węzła w tablicy. W rzeczywistości, gdy tablica jest pełna, klasa Słownika wymusza zmianę rozmiaru.
W ConcurrentDictionary węzeł można traktować jako nowy obiekt. Usunięcie węzła to po prostu usunięcie jego odniesienia. Dodanie nowego węzła może po prostu utworzyć nową instancję węzła. Zmiana rozmiaru ma na celu uniknięcie konfliktów, ale nie jest obowiązkowa.
Jeśli więc klasa Dictionary celowo używa bardziej złożonych algorytmów do obsługi tego rozwiązania, jak ConcurrentDictionary zapewni jej lepsze wyniki w środowisku wielowątkowym?
Prawda jest taka: umieszczenie wszystkich węzłów w jednej tablicy to najszybszy sposób na alokację i odczyt, nawet jeśli potrzebujemy innej tablicy, by śledzić, gdzie znaleźć te dane. Wygląda więc na to, że posiadanie tej samej liczby bucketów zużywa więcej pamięci, ale nowe elementy danych nie muszą być przenoszone, nie są potrzebne nowe synchronizacje obiektów i nie pojawia się nowe garbage collection. Bo wszystko jest już gotowe.
Jednak zastąpienie zawartości w węźle nie jest operacją atomową, co jest jednym z czynników czynych jego wątku niebezpiecznym. Ponieważ węzły są obiektami, początkowo tworzy się węzeł, a następnie aktualizuje się osobną referencję, która wskazuje na niego (tutaj operacja atomowa). Tak więc wątek odczytu może odczytać zawartość słownika bez blokady, a odczyt musi być jedną ze starych i nowych wartości, i nie ma szans na odczytanie wartości niekompletnej.
Prawda jest taka: jeśli nie potrzebujesz blokady, klasa Słownik jest szybsza w odczytach, bo to blokada spowalnia odczyt.
Ten artykuł został przetłumaczony z artykułu Paulo Zemeka "Dictionary + Locking versus ConcurrentDictionary" na CodeProject, a niektóre sformułowania mogą się zmienić ze względów zrozumienia.
|