Closed dkglee closed 2 months ago
TMap
에서 Add
를 하게 되었을 경우의 로직이다.template <typename InitKeyType = KeyType, typename InitValueType = ValueType>
ValueType& Emplace(InitKeyType&& InKey, InitValueType&& InValue)
{
const FSetElementId PairId = Pairs.Emplace(TPairInitializer<InitKeyType&&, InitValueType&&>(Forward<InitKeyType>(InKey),
Forward<InitValueType>(InValue)));
return Pairs[PairId].Value;
}
FSetElementId Emplace(ArgsType&& Args, bool* bIsAlreadyInSetPtr = nullptr)
{
// Create a new element.
FSparseArrayAllocationInfo ElementAllocation = Elements.AddUninitialized();
SetElementType& Element = *new (ElementAllocation) SetElementType(Forward<ArgsType>(Args));
SizeType NewHashIndex = ElementAllocation.Index;
uint32 KeyHash = KeyFuncs::GetKeyHash(KeyFuncs::GetSetKey(Element.Value));
if (!TryReplaceExisting(KeyHash, Element, NewHashIndex, bIsAlreadyInSetPtr))
{
RehashOrLink(KeyHash, Element, NewHashIndex);
}
return FSetElementId(NewHashIndex);
}
bool TryReplaceExisting(uint32 KeyHash, SetElementType& Element, SizeType& InOutElementIndex, bool* bIsAlreadyInSetPtr)
{
bool bIsAlreadyInSet = false;
if (!KeyFuncs::bAllowDuplicateKeys)
{
// If the set doesn't allow duplicate keys, check for an existing element with the same key as the element being added.
// Don't bother searching for a duplicate if this is the first element we're adding
if (Elements.Num() != 1)
{
SizeType ExistingIndex = FindIndexByHash(KeyHash, KeyFuncs::GetSetKey(Element.Value));
bIsAlreadyInSet = ExistingIndex != INDEX_NONE;
if (bIsAlreadyInSet)
{
// If there's an existing element with the same key as the new element, replace the existing element with the new element.
MoveByRelocate(Elements[ExistingIndex].Value, Element.Value);
// Then remove the new element.
Elements.RemoveAtUninitialized(InOutElementIndex);
// Then point the return value at the replaced element.
InOutElementIndex = ExistingIndex;
}
}
}
if (bIsAlreadyInSetPtr)
{
*bIsAlreadyInSetPtr = bIsAlreadyInSet;
}
return bIsAlreadyInSet;
}
template<typename T>
FORCEINLINE void MoveByRelocate(T& A, T& B)
{
// Destruct the previous value of A.
A.~T();
// Relocate B into the 'hole' left by the destruction of A, leaving a hole in B instead.
RelocateConstructItems<T>(&A, &B, 1);
}
template <typename DestinationElementType, typename SourceElementType, typename SizeType>
FORCEINLINE void RelocateConstructItems(void* Dest, const SourceElementType* Source, SizeType Count)
{
if constexpr (UE::Core::Private::MemoryOps::TCanBitwiseRelocate<DestinationElementType, SourceElementType>::Value)
{
/* All existing UE containers seem to assume trivial relocatability (i.e. memcpy'able) of their members,
* so we're going to assume that this is safe here. However, it's not generally possible to assume this
* in general as objects which contain pointers/references to themselves are not safe to be trivially
* relocated.
*
* However, it is not yet possible to automatically infer this at compile time, so we can't enable
* different (i.e. safer) implementations anyway. */
FMemory::Memmove(Dest, Source, sizeof(SourceElementType) * Count);
}
else
{
while (Count)
{
// We need a typedef here because VC won't compile the destructor call below if SourceElementType itself has a member called SourceElementType
typedef SourceElementType RelocateConstructItemsElementTypeTypedef;
new (Dest) DestinationElementType(*Source);
++(DestinationElementType*&)Dest;
(Source++)->RelocateConstructItemsElementTypeTypedef::~RelocateConstructItemsElementTypeTypedef();
--Count;
}
}
}
즉, 로직 순서는 다음과 같다.
Element
를 새로 하나 만듬.Element
에 값을 새팅함.Element
가 있는지 확인함.Memmove
(memcpy와 같은 역할) 함즉, 한번 생긴 키에 대한 부분은 키를 제거하지 않은 이상 해당 포인터 변수는 항상 일정하다.
즉, 해당 위치에 이전에 담고 있던 객체의 소멸자를 호출하여 객체를 해제하고 새로운 객체를 단순히 메모리를 이동함으로써 해결하는 것을 볼 수 있다.
현재의 로직(우리의 게임의 경우)에는 TMap이 포함하고 있는 객체가 내부적으로 동적할당한 값이 없고, string의 경우에도 Protobuf가 내부적으로 void*
로 관리를 하고 있기 때문에 포인터 변수의 크기는 8바이트로 "불변"하다.
그 말은 Find로 했을 때 터질 걱정이 없다.
제가 아는 지식 선에서는 당장의 문제가 발생할 여지는 없어 보입니다. 하지만 언제든지 해당 문제가 발생하거나 틀린 부분이 있다면 comment를 남겨주세요!
감사합니다!
Issue - 문제 설명
위의 코드에서 Contains에서
true
를 반환하여 이후에[]
연산자로 값을 조회하였지만 해당 키가 존재하지 않는다는 에러가 발생하게 됨.현재의 로직
클라이언트에서 서버로부터 받은 정보를 사용하는 로직
-
FDataHub
의gunfireData
의 경우, 멀티스레드로 Thead A가 쓰는 작업을 담당하고 현재 문제가 발생하는 부분에서 Thread B는 읽는 작업만을 진행함.lock
으로 발생할 수 있는 속도 문제를 벗어날 수 있을 것이라고 생각했었음.예상되는 원인
해결 방안.
이를 해결하기 위해서 다음의 세 가지 방안을 고려했다.
첫 번째 - 읽고 쓸 때,
lock
시스템을 제공할 것.단점
lock
으로 인한 오버헤드가 발생할 수 있다.두 번째 - 읽는 구조를 서버와 동일하게 가져가기
lock
으로data race
를 막아주면lock
을 호출하는 횟수가 이전보다는 줄어들기 때문에 더욱 더 효과적일 수 있다.MESI
프로토콜(캐시 일관성 문제)도 해결함으로써 공유되는 캐시 자원에 대한 읽고 쓰는 행위에서 오버헤드를 줄일 수 있다.단점
하지만 여전히 스위칭하는데
lock
을 사용하기 때문에 성능상 문제가 발생할 수 있다. 서버의 경우, 각각의 데이터를 처리함에 있어서 데이터의 중요도가 높기 때문에, 데이터 무결성을 확실하게 막을 필요가 있지만, 클라이언트의 경우에는 아니다.여담
alignas(64)
를 붙이게 되면 더욱 더 큰 효과를 얻을 수 있다. 이는 현대의 캐시 라인의 크기가 대부분 64바이트이기 때문에 해당 읽기 및 쓰기 전용 버퍼의 시작 위치를 64 바이트 단위로 정렬을 하게 될 경우, 캐시 일관성 문제를 피할 수 있다점세 번째 -
Contains
함수를 사용하지 않고Find
함수 사용하기즉, 처음
Contains
함수의 경우 해당 키에 맞는 데이터가 존재하는 경우 단순true
혹은false
를 반환하게 된다.이 경우, 일시적으로는 해당 키가 존재해
true
를 반환하지만,gunfireData[PlayerId]
를 진행하는 찰나의 시간에 다른 스레드에서 일시적으로 키를 비활성화 할 수 있게 된다.그렇다면
[]
연산자는 다음의 함수를 호출하도록 오버로딩 되어 있다.이제껏 프로그램이 터지게 된 이유가
check(Pair != nullptr);
메크로에서 터지게 되는 것이다. 이는 이전의Pairs.Find(Key)
를 실행하기 전에 다른 스레드에서 해당 키에 대한 정보가 수정되었기 때문이다.하지만
Contains
가 아니라 애초에Find
함수를 사용하게 되면 이는 바로 해당 값을 참조하는 포인터를 반환하게 된다.그리고 두 번
Find
를 호출하게 되는 이전의 로직보다 훨씬 더 빠르며 (지금의 로직은Find
한번으로 해결 가능), 중간에 해당 값에 동시에 접근해도 문제가 없고, 데이터 무결성을 지키지 못하더라도 어차피 데이터는 빠르게 변경되며 당장의 업데이트 된 데이터가 그렇게까지 중요하지 않다.그렇기 때문에
Find
를 사용하는 것이 가장 빨라 보인다.단점 및 여전한 문제점
하지만 만약 Thread A에서 값을 넣는 과정이
new
를 통해서 동적할당으로 넣어주게 되는 것이고 이전의 값을 해제하는 형식이라면 이것은 커다란 문제가 되기는 한다.왜냐하면 참조하는 객체가 중간에 파괴되는 경우가 발생하기 때문이다.
결론
우선은 Find 함수를 통해서 위의 문제를 해결하는 방향으로 결정을 하였다.
데이터를 처리하는 부분에 있어서 해당 데이터의 중요도가 그렇게 높지 않기 때문에,
lock
으로 인한 오버헤드를 가장 피할 수 있는Find
를 사용하는 것이 가장 최적의 판단이라고 들게 되었다.데이터 일관성의 완벽한 유지 보다는 시스템의 전반적인 성능과 리소스 관리를 우선하는 것이 더 합리적이라 판단했다.
하지만 위에서 작성한
Find
의 문제점이 발생하게 될 여지가 있다면 언제든지 구조를 바꿀 것이다.