이 글은 기계 번역의 미러 문서이며, 원본 기사로 바로 이동하려면 여기를 클릭해 주세요.

보기: 20809|회답: 1

[출처] 동시 사전 vs. 사전+잠금장치 - Dennis Gao

[링크 복사]
게시됨 2016. 9. 13. 오후 1:33:04 | | |
.NET 4.0 이전에는 멀티스레드 환경에서 Dictionary 클래스를 사용해야 할 때, 스레드를 안전하게 유지하기 위해 직접 스레드 동기화를 구현할 수밖에 없었습니다.

많은 개발자들이 완전히 새로운 스레드 안전 사전 타입을 만들거나, 사전 객체를 클래스에 캡슐화하고 모든 메서드에 잠금 메커니즘을 추가하는 유사한 스레드 안전 솔루션을 구현한 사례가 있습니다. 이를 "Dictionary + Locks"라고 부릅니다.

하지만 지금은 ConcurrentDictionary가 있습니다. MSDN 상의 Dictionary 클래스 문서의 스레드 안전 설명에는 스레드 안전 구현을 사용해야 할 경우 ConcurrentDictionary를 사용하라고 명시되어 있습니다.

이제 스레드 안전 사전 클래스가 생겼으니, 더 이상 직접 구현할 필요가 없습니다. 멋지지 않니?

문제의 기원

사실 저는 CocurrentDictionary를 반응성을 테스트하기 위해 테스트할 때 단 한 번만 사용해봤습니다. 시험에서 잘 나와서 바로 제 수업으로 교체하고 몇 가지 테스트를 했는데, 뭔가 문제가 생겼습니다.

그럼, 무슨 문제가 있었던 걸까요? 실이 안전하다고 하지 않았나요?

더 많은 테스트 끝에 문제의 근원을 찾았습니다. 하지만 이상하게도 MSDN 4.0 버전에는 Delegate 타입 매개변수를 전달해야 하는 GetOrAdd 메서드 서명 설명이 포함되어 있지 않습니다. 버전 4.5를 살펴보던 중 다음과 같은 메모를 발견했습니다:

GetOrAdd를 서로 다른 스레드에서 동시에 호출하면 addValueFactory가 여러 번 호출될 수 있지만, 그 키/값 쌍이 모든 호출마다 사전에 추가되지 않을 수 있습니다.
그게 제가 겪은 문제입니다. 문서에 이전에 설명되어 있지 않아서 문제를 확인하기 위해 추가 테스트를 해야 했습니다. 물론, 제가 겪고 있는 문제는 제 사용 방식과 관련이 있습니다. 일반적으로 저는 사전을 사용해 데이터를 캐시합니다:

이 데이터는 생성 속도가 매우 느립니다;
이 데이터는 한 번만 생성할 수 있는데, 두 번째 생성 시 예외가 발생하거나 여러 번 생성하면 자원 누수 등이 발생할 수 있기 때문입니다;
두 번째 조건에 문제가 있었어요. 두 스레드 모두 데이터 조각이 존재하지 않는다고 판단하면 한 번 생성되지만, 성공적으로 저장되는 결과는 하나뿐입니다. 다른 사람은 어때?

만약 생성하는 프로세스가 예외를 던지면, try를 사용할 수 있습니다: 캐치(우아하지는 않지만 문제를 해결합니다). 하지만 자원이 만들어지고 재활용되지 않는다면 어떻게 될까요?

객체가 생성되어 더 이상 참조되지 않으면 가비지 컬렉션이 된다고 말할 수 있습니다. 하지만 아래에 설명된 상황이 발생한다면 어떻게 될지 생각해 보라:

Emit으로 동적으로 코드를 생성하세요. 이 방식을 Remoting 프레임워크에서 사용했고, 모든 구현을 재활용할 수 없는 어셈블리에 넣었습니다. 한 타입이 두 번 생성되면, 두 번째 타입은 한 번도 사용되지 않았더라도 항상 존재합니다.
직접적이든 간접적이든 스레드를 만들어보세요. 예를 들어, 비동기 메시지를 처리하기 위해 독점 스레드를 사용하고 수신 순서에 의존하는 컴포넌트를 만들어야 합니다. 컴포넌트가 인스턴스화되면 스레드가 생성됩니다. 이 구성 요소가 파괴되면 스레드도 종료됩니다. 하지만 컴포넌트를 파기한 후 오브젝트에 대한 참조를 삭제했는데, 스레드가 어떤 이유로 끝나지 않고 오브젝트에 대한 참조를 유지한다면, 그리고 실이 죽지 않으면 그 물체도 재활용되지 않습니다.
P/Invoke 작업을 수행합니다. 수신된 핸들의 닫힌 시간 수가 개방 횟수와 같아야 한다고 요구합니다.
물론 비슷한 상황이 많이 있습니다. 예를 들어, 사전 객체는 원격 서버의 서비스에 대한 연결을 유지하며, 이 서비스는 한 번만 요청할 수 있고, 두 번째로 요청되면 다른 서비스가 어떤 오류가 발생했다고 인식하여 로그에 기록합니다. (제가 일했던 회사에서는 이 상태에 대해 법적 처벌이 있었습니다.) )
따라서 Dictionary + Locks가 문서에 스레드 안전하다고 적혀 있더라도 ConcurrentDictionary로 급히 대체할 수 없다는 점은 쉽게 알 수 있습니다.

문제를 분석하세요

아직도 이해가 안 돼?

이 문제는 Dictionary + Locks 접근법에서는 발생하지 않을 수도 있습니다. 구체적인 구현에 따라 다르므로, 이 간단한 예를 살펴보겠습니다:


위 코드에서는 키 값을 쿼리하기 전에 사전 잠금을 유지합니다. 지정된 키-값 쌍이 존재하지 않으면 직접 생성됩니다. 동시에, 이미 그 사전에 잠금을 가지고 있기 때문에 키-값 쌍을 직접 사전에 추가할 수 있습니다. 그 다음 사전 잠금을 해제하고 결과를 반환하세요. 두 스레드가 동시에 같은 키 값을 쿼리한다면, 사전을 처음 받은 스레드가 객체 생성을 완료하고, 다른 스레드는 이 생성이 완료될 때까지 기다렸다가 사전 잠금을 받은 후 생성된 키 값 결과를 받게 됩니다.

좋지, 그렇지?

정말 그렇지 않아요! 이렇게 병렬로 객체를 만들어서 결국 하나만 사용하는 경우, 제가 설명한 문제를 일으키지 않는다고 생각합니다.

제가 설명하려는 상황과 문제는 항상 재현 가능하지 않을 수 있습니다. 병렬 환경에서는 두 개의 객체를 만들고 하나를 버릴 수 있습니다. 그렇다면 Dictionary + Locks와 ConcurrentDictionary를 정확히 어떻게 비교할 수 있을까요?

답은: 잠금 사용 전략과 사전 사용 방식에 달려 있습니다.

게임 1: 병렬로 같은 객체를 생성하기

먼저, 한 객체가 두 번 생성될 수 있다고 가정해 봅시다. 그렇다면 두 스레드가 동시에 이 객체를 생성하면 어떻게 될까요?

둘째, 비슷한 창작물에 얼마나 시간을 투자할까요?

객체를 인스턴스화하는 데 10초가 걸린다는 예제를 간단히 만들 수 있습니다. 첫 번째 스레드가 5초 후에 객체를 생성하면, 두 번째 구현은 GetOrAdd 메서드를 호출해 객체를 가져오려 하고, 객체가 아직 존재하지 않으니 객체 생성도 시작합니다.

이 상태에서 두 CPU가 5초 동안 병렬로 작동하고, 첫 번째 스레드가 작업을 마치면 두 번째 스레드는 객체 구성을 완료하기 위해 5초간 계속 실행해야 합니다. 두 번째 스레드가 객체를 완성하면, 이미 존재하는 객체가 있음을 발견하고 기존 객체를 사용하고 새로 생성된 객체를 직접 버리기로 선택합니다.

두 번째 스레드가 단순히 대기하고 두 번째 CPU가 다른 작업을 수행한다면(다른 스레드나 애플리케이션 실행, 전력 절감), 원하는 객체는 10초 대신 5초 후에 도달합니다.

이런 조건에서 Dictionary + Locks가 작은 게임에서 이깁니다.

게임 2: 여러 대상을 병행해서 방문하기

아니요, 당신이 말한 상황은 전혀 사실이 아닙니다!

위의 예시는 다소 특이하지만, 문제를 설명하고 있습니다. 다만 이 용법이 더 극단적일 뿐입니다. 그렇다면, 첫 번째 스레드가 객체를 만들고 있는데, 두 번째 스레드가 다른 키값 객체에 접근해야 하는데, 그 키값 객체가 이미 존재한다면 어떻게 될까요?

ConcurrentDictionary에서는 락이 없는 설계가 읽기에 락이 없기 때문에 읽기가 매우 빠릅니다. Dictionary + Locks의 경우, 읽기 연산은 완전히 다른 키라도 상호 배타적으로 잠기며, 이는 읽기 연산을 당연히 느리게 만듭니다.

이렇게 해서 ConcurrentDictionary는 한 게임을 후퇴시켰습니다.

참고: 여기서 저는 사전 수업에서 버킷/노드/항목 같은 여러 개념을 이해하고 있다고 생각하며, 그렇지 않다면 Ofir Makmal의 "Understanding Generic Dictionary in-depth"라는 글을 읽는 것을 추천합니다. 이 글은 이 개념들을 잘 설명합니다.

게임의 세 번째 게임: 더 읽고 하나 쓰기

Dictionary + Locks에서 사전 전체 잠금 대신 다중 리더와 단일 작성자를 사용하면 어떻게 되나요?

스레드가 객체를 생성하고 객체가 생성될 때까지 업그레이드 가능한 잠금을 유지한다면, 잠금이 쓰기 잠금으로 업그레이드되면 읽기 작업을 병렬로 수행할 수 있습니다.

또한 읽기 연산을 10초간 유휴 상태로 두는 것도 문제를 해결할 수 있습니다. 하지만 읽기 수가 쓰기보다 훨씬 많다면, ConcurrentDictionary는 잠금 모드 읽기를 구현하기 때문에 여전히 빠릅니다.

사전 작성에 ReaderWriterLockSlim을 사용하면 독이 더 나빠지므로, 일반적으로 ReaderWriterLockSlim 대신 Full Lock for Dictionary를 사용하는 것이 권장됩니다.

이런 조건에서 ConcurrentDictionary가 또 한 번 승리한 셈입니다.

참고: 저는 이전 기사에서 YieldReaderWriterLock과 YieldReaderWriterLockSlim 클래스에 대해 다룬 바 있습니다. 이 읽기-쓰기 잠금을 사용함으로써 속도가 크게 향상되었으며(현재는 SpinReaderWriterLockSlim으로 발전), 여러 번의 읽기를 병렬로 거의 또는 전혀 영향 없이 실행할 수 있습니다. 아직 이 방식을 사용하고 있지만, 잠금 없는 ConcurrentDictionary가 당연히 더 빠를 것입니다.

게임 4: 여러 키값 쌍 추가하기

대결은 아직 끝나지 않았다.

추가할 키 값이 여러 개 있는데, 모두 충돌하지 않고 서로 다른 버킷에 할당된다면 어떻게 될까요?

처음에는 이 질문이 궁금했지만, 맞지 않는 테스트를 해봤습니다. 저는 <int, int> 타입의 사전을 사용했는데, 객체의 구조 팩토리가 키로 직접 음의 결과를 반환했습니다.

ConcurrentDictionary가 가장 빠를 거라 예상했는데, 오히려 가장 느렸어요. 반면 Dictionary + Locks는 더 빠르게 작동합니다. 왜일까요?

이는 ConcurrentDictionary가 노드를 할당하고 서로 다른 버킷에 배치하기 때문인데, 이는 읽기 작업에 있어 락프리 설계를 충족하도록 최적화되어 있기 때문입니다. 하지만 키값 항목을 추가할 때 노드를 생성하는 과정이 비용이 많이 듭니다.

병렬 상태에서도 노드 락을 할당하는 것은 전체 잠금을 사용하는 것보다 더 많은 시간이 소요됩니다.

그래서 Dictionary + Locks가 이 게임에서 이깁니다.

다섯 번째 게임 플레이: 읽기 연산 빈도가 높아집니다

솔직히 말해, 객체를 빠르게 인스턴스화할 수 있는 대리인이 있다면 사전이 필요 없을 것입니다. 대표에게 직접 전화해서 물건을 받을 수 있죠?

사실, 답은 상황에 따라 다르다는 것입니다.

키 타입이 문자열이며 웹 서버 내 다양한 페이지의 경로 맵을 포함하고, 해당 값은 현재 페이지에 접속한 사용자의 기록과 서버 시작 이후 페이지에 대한 모든 방문 수를 포함하는 객체 타입이라고 상상해 보세요.

이런 객체를 만드는 것은 거의 즉각적입니다. 그 후에는 새 객체를 만들 필요 없이 저장된 값을 바꾸면 됩니다. 따라서 하나의 인스턴스만 사용할 때까지 웨이를 두 번 생성할 수 있습니다. 하지만 ConcurrentDictionary는 노드 자원 할당이 더 느리기 때문에, Dictionary + Locks를 사용하면 생성 시간이 더 빨라집니다.

이 예시는 매우 특별하며, Dictionary + Locks가 이 조건에서 더 나은 성능을 내고 시간이 적게 걸린다는 점도 알 수 있습니다.

ConcurrentDictionary의 노드 할당이 느리긴 하지만, 시간을 테스트하려고 1억 개의 데이터 항목을 넣으려 하지는 않았습니다. 왜냐하면 그건 분명히 많은 시간이 걸리기 때문이죠.

하지만 대부분의 경우, 데이터 항목이 생성되면 항상 읽힙니다. 데이터 항목의 내용이 어떻게 변하는지는 또 다른 문제입니다. 그래서 데이터 항목을 만드는 데 몇 밀리초가 더 걸리든 상관없어요. 읽기는 더 빠르고(몇 밀리초 정도 빠를 뿐), 읽기는 더 자주 일어납니다.

그래서 ConcurrentDictionary가 승리했습니다.

게임 6: 서로 다른 시간을 소비하는 객체를 만들기

서로 다른 데이터 항목을 만드는 데 걸리는 시간이 다르면 어떻게 되나요?

서로 다른 시간을 소비하는 여러 데이터 항목을 만들어 사전에 병행하여 추가하세요. 이것이 ConcurrentDictionary의 가장 강력한 장점입니다.

ConcurrentDictionary는 데이터 항목을 동시에 추가할 수 있도록 여러 가지 잠금 메커니즘을 사용하지만, 어떤 잠금 장치를 사용할지 결정하거나 버킷 크기를 변경하기 위해 락을 요청하는 등의 논리는 도움이 되지 않습니다. 데이터 항목이 버킷에 들어가는 속도는 기계 속도에 불과합니다. ConcurrentDictionary가 진짜 성공하는 이유는 객체를 병렬로 생성할 수 있는 능력입니다.

하지만 실제로 같은 일을 할 수 있습니다. 객체를 병렬로 생성하든 일부 버려졌든 상관없다면, 데이터 항목이 이미 존재하는지 감지하는 잠금을 추가한 뒤, 잠금을 해제한 후 데이터 항목을 생성하고 잠금을 눌러 잠금을 얻고, 다시 확인한 후 없다면 데이터 항목을 추가할 수 있습니다. 코드는 다음과 같이 생겼을 수 있습니다:

* 참고로 저는 <int, int> 타입의 사전을 사용합니다.

위의 단순 구조에서 Dictionary + Locks는 병렬 조건에서 데이터 항목을 생성하고 추가할 때 ConcurrentDictionary와 거의 동등한 성능을 보입니다. 하지만 같은 문제도 있는데, 일부 값은 생성되었지만 절대 사용되지 않을 수 있습니다.

결론

그래서, 결론이 있나?

현재 이 시점에도 다음과 같은 존재들이 남아 있습니다:

모든 사전 수업은 매우 빠릅니다. 수백만 개의 데이터를 만들었음에도 불구하고 여전히 빠릅니다. 보통 우리는 소수의 데이터 항목만 만들고, 읽기 사이에 일정한 시간 간격이 있어서 데이터 항목 읽기에는 시간 오버헤드를 거의 느끼지 못합니다.
같은 객체를 두 번 만들 수 없다면 ConcurrentDictionary를 사용하지 마세요.
성능이 정말 걱정된다면 Dictionary + Locks가 여전히 좋은 해결책일 수 있습니다. 중요한 요소 중 하나는 추가되고 삭제된 데이터 항목의 수입니다. 하지만 읽기 작업이 많으면 ConcurrentDictionary보다 느립니다.
제가 직접 도입하지는 않았지만, 사실 Dictionary + Locks 방식을 사용할 수 있는 자유가 더 많습니다. 예를 들어, 한 번 잠그고, 여러 데이터 항목을 추가하거나, 여러 데이터를 삭제하거나, 여러 번 쿼리하는 식으로 잠금 해제할 수 있습니다.
일반적으로 읽기 횟수가 쓰기 횟수보다 훨씬 많다면 ReaderWriterLockSlim 사용을 피하세요. 사전 타입은 이미 읽기-쓰기 잠금에서 읽기 락을 얻는 것보다 훨씬 빠릅니다. 물론, 이것은 잠금에서 객체를 생성하는 데 걸리는 시간에도 달려 있습니다.
그래서 제시된 예시들은 다소 극단적이라고 생각하지만, ConcurrentDictionary 사용이 항상 최선의 해결책은 아니라는 것을 보여줍니다.

차이를 느껴보세요

저는 더 나은 해결책을 찾기 위해 이 글을 썼습니다.

이미 특정 사전 수업이 어떻게 작동하는지 더 깊이 이해하려고 노력 중입니다(이제는 아주 명확하게 이해된 것 같아요).

ConcurrentDictionary의 Bucket과 Node는 매우 단순하다고 할 수 있습니다. 저도 사전 클래스를 만들려고 할 때 비슷한 일을 했습니다. 일반 사전 클래스는 더 단순해 보일 수 있지만, 사실 더 복잡합니다.

ConcurrentDictionary에서는 각 노드가 완전한 클래스입니다. Dictionary 클래스에서는 Node가 값 타입으로 구현되며, 모든 노드는 거대한 배열에 보관되고, Bucket은 배열에서 인덱싱을 담당합니다. 또한 노드가 다음 노드에 단순히 참조하는 대신 사용되기도 합니다(결국 구조체 타입의 노드이므로 구조체 타입의 노드 멤버를 포함할 수 없습니다).

사전을 추가하거나 제거할 때, 사전 클래스는 단순히 새 노드를 생성할 수 없으며, 삭제된 노드를 표시하는 인덱스가 있는지 확인한 후 재사용해야 합니다. 또는 "카운트"를 사용해 배열 내 새 노드의 위치를 얻기도 합니다. 실제로 배열이 가득 차면 사전 클래스가 크기 변경을 강제합니다.

ConcurrentDictionary에서는 노드를 새로운 객체로 생각할 수 있습니다. 노드를 제거하는 것은 단순히 그 참조를 제거하는 것입니다. 새로운 노드를 추가하면 단순히 새로운 노드 인스턴스를 생성할 수 있습니다. 크기 변경은 충돌을 피하기 위한 것일 뿐, 필수는 아닙니다.

그렇다면 Dictionary 클래스가 의도적으로 더 복잡한 알고리즘을 사용한다면, ConcurrentDictionary는 멀티스레드 환경에서 어떻게 더 나은 성능을 낼 수 있을까요?

사실은: 모든 노드를 하나의 배열에 넣는 것이 할당과 읽기에 가장 빠른 방법이며, 데이터 항목을 어디에 위치할지 추적하기 위해 또 다른 배열이 필요하더라도 말입니다. 즉, 버킷 수가 같으면 메모리를 더 많이 쓰지만, 새 데이터 항목은 재할당할 필요가 없고, 새로운 객체 동기화도 필요 없으며, 새로운 가비지 컬렉션도 발생하지 않는 것 같습니다. 모든 것이 이미 준비되어 있기 때문입니다.

하지만 노드 내 콘텐츠 교체는 원자 연산이 아니며, 이것이 스레드가 안전하지 못하게 만드는 요인 중 하나입니다. 노드는 모두 객체이기 때문에 처음에는 노드가 생성되고, 이후 별도의 참조가 업데이트되어 이를 가리킵니다(여기서 원자 연산). 따라서 리드 스레드는 잠금장치 없이 사전 내용을 읽을 수 있고, 읽기는 이전 값과 새 값 중 하나여야 하며, 불완전한 값을 읽을 가능성은 없습니다.

그래서 사실은: 잠금이 필요 없다면, 사전 클래스가 읽기 속도가 더 빠릅니다. 잠금이 읽기 속도를 늦추기 때문입니다.

이 글은 CodeProject에 올린 Paulo Zemek의 "Dictionary + Locking 대 ConcurrentDictionary" 글에서 번역되었으며, 이해를 위해 일부 문장이 변경될 수 있습니다.







이전의:IoC 효율 오토팩
다음:알리바바에서 JS 스크립트를 사용해 달병을 급히 사러 갔다가 4명이 해고됐습니다
 집주인| 게시됨 2016. 9. 13. 오후 1:33:15 |
ConcurrentDictionary는 새로운 업데이트 및 업데이트 기능을 지원합니다
http://www.itsvse.com/thread-2955-1-1.html
(출처: 코드 농업 네트워크)
면책 조항:
Code Farmer Network에서 발행하는 모든 소프트웨어, 프로그래밍 자료 또는 기사는 학습 및 연구 목적으로만 사용됩니다; 위 내용은 상업적 또는 불법적인 목적으로 사용되지 않으며, 그렇지 않으면 모든 책임이 사용자에게 부담됩니다. 이 사이트의 정보는 인터넷에서 가져온 것이며, 저작권 분쟁은 이 사이트와는 관련이 없습니다. 위 내용은 다운로드 후 24시간 이내에 컴퓨터에서 완전히 삭제해야 합니다. 프로그램이 마음에 드신다면, 진짜 소프트웨어를 지원하고, 등록을 구매하며, 더 나은 진짜 서비스를 받아주세요. 침해가 있을 경우 이메일로 연락해 주시기 바랍니다.

Mail To:help@itsvse.com