프로그래밍 관련/언어들의 코딩들 C++ JAVA C# 등..

C, C++ 멀티스레드에서 shared_ptr 사용시 주의사항

AlrepondTech 2020. 9. 10. 04:36
반응형

 

 

 

=======================

=======================

=======================

 

 

 

 

 

출처: http://lab.gamecodi.com/board/zboard.php?id=GAMECODILAB_Proudnet_Lec&page=1&sn1=&divpage=1&sn=off&ss=on&sc=on&select_arrange=headnum&desc=asc&no=14

 

 

본 내용은 프라우드넷에 국한된 내용이 아닌 일반적인 프로그래밍의 이야기입니다. 어쨌건 프라우드넷을 개발하다가 튀어나온 이슈인지라 여기에 적어봅니다.

 

프라우드넷 오래전 버전 내부에서는 shared_ptr을 거의 쓰지를 않았습니다. 내부적으로는 처리속도를 우선하기 위해 shared_ptr이나 여타 smart ptr 객체를 안 썼습니다. 그 댓가로, 직접 포인터를 관리하는 방식이었죠. 네. 수동 new delete 말입니다.

하지만 내부 코드를 유지보수하는 것이 힘이 들대로 들어서, shared_ptr을 중간에 도입을 했었습니다.

 

그런데 이렇게 하고 났더니, 스트레스 테스트 결과 성능이 평소보다 다섯배 이상으로 하락하는 것이 발견됐었습니다. 결국 해결했습니다... 어떻게 해결했는지 소개하겠습니다.

문제의 원인은 shared_ptr의 복사 연산자였습니다.

 

코드로 예를 들게요.

 

shared_ptr<X> a = xxx;

shared_ptr<X> b = a; // [1]

 

여기서 [1]의 속도가 어마무시하게 느렸다는거죠.

이 코드가 항상 느린 것은 아닙니다. 일반적인 경우 느리게 작동 하지 않습니다. 둘 이상의 스레드가 [1]에서 경합할 때만 엄청나게 느렸죠.

 

프라우드넷은 리눅스, 윈도 모두 멀티코어 활용을 전제로 하고 있습니다. 포인터가 가리키는 객체에 대한 액세스 즉 off the pointer는 격리를 시키지만 정작 이 객체들을 가리키는 포인터 변수에 자체에 대해서는 격리되어 있지 않는 경우들이 많습니다. (멀티스레드 플밍하면 당연한 얘기죠.)

 

결국 이 문제는 두가지로 해결했습니다.

  1. move semantics.
  2. 파라메터 전달을 할 때 byval 대신 const byref를 하기.

 

이 두가지를 적용하고 나니 원래 속도에 근접하게 되돌아왔습니다. 물론 shared_ptr을 쓰면서 발생하는 약간의 성능 저하는 그냥 감수하기로 했고요. shared_ptr을 도입한 후부터 유지보수하기가 훨씬 좋아졌는데 어떻게 버리겠어요. ㅎㅎ

 

문제의 원인은 shared_ptr의 복사 연산자 내부에서 atomic operation을 하는 부분이 심각하게 느리다는데 있었습니다. 네. InterlockedIncrement나 gcc builtin atomic operations 자체가 엄청나게 느렸다는데 있었죠.

 

shared_ptr의 복사 연산자 안에서는 reference count를 1 증가시킵니다. 그리고 shared_ptr 변수가 사라질 때는 1 감소하고요. 이 증가와 감소 연산 말고도 다른 뭔가를 하는 것들이 있습니다.

shared_ptr 안에서는 이 증가와 감소를 atomic operation으로 하고 있습니다. 기계어로 풀어보면

lock xchg xxx xxx

딱 한줄입니다.

 

이 기계어 명령 한줄은 경합이 없으면 non atomic보다 2배 느린 수준에서 끝납니다. 그러나  경합이 있으면 이 기계어 한줄은 40배 정도 느려집니다.

 

그런데 byref나 move semantics를 쓰면 atomic op 자체를 건너뜁니다. 따라서 저 극악의 성능 저하가 예방되는거죠.

 

mutex나 critical section을 lock 하는 것보다 아토믹 연산이 훨씬 빠르다고 알려져 있습니다. 그러나 아토믹 연산도 결국 경합 앞에서는 엄청나게 느립니다. 다만 소프트웨어로 경합을 중재하는 것보다 훨씬 빠를 뿐 경합이 전혀 없을 때 보다는 훨씬 느리게 작동을 합니다. 안타깝게도 이 문제는 하드웨어 엔지니어 선에서 해결해야 합니다.

 

글만 쭈욱 적었는데, 예시 코드로 보여드립니다.

 

void func1()

{

shared_ptr<X> x;

func2(x);

func3(x);

}

 

void func2(shared_ptr<X> a) // byval

{

a->xxx();

}

 

void func3(const shared_ptr<X>& a) // const byref

{

a->xxx();

}

 

func2와 func3의 속도 차이는 평소에는 별로 없습니다. func2와 func3을 여러 스레드에서 병렬 실행을 해도 별 차이가 안납니다. 그러나 둘 이상의 스레드 안에서 동일한 shared_ptr 객체에 대해 func2를 동시에 실행할때와 func3를 동시에 실행하게 되면, func2 쪽이 훨씬 느리게 작동합니다. 이게 shared_ptr에 대한 byval과 byref의 차이입니다.

 

한줄 결론: atomic operation도 너무 믿지 말자.

 

-------------------------------------------------------------------------------------------------------------------------------------------------------------------------

 

 

 

 

반응형

 

728x90

 

 

저도 비슷한거 느꼈었는데요 ... 저는 그냥 서로 다른 변수를 조작할때 일어낫어요
같은 클래스의 변수 a,b,c 를 각각 다른 쓰레드가 조작한 상황인데
인접한 메모리를 조작할 때도, 같은 변수를 조작할때처럼 느려지느것 같더라구요 memory alignment 때문인지.
CPU는 레지스터만 접근하고 레지스터까지 데이터를 끌여올리느라 L1,2,3 캐시를 통하니까
CPU가 어떤 메모리를 변경하면, 혹시 다른 CPU의 L1,2,3 캐시의 데이터도 싱크를 맞추기위한 동작을 하느라 느려지는것 같다는 추측을 하고 있어요


저같은 경우는 이런 문제를 피하기 위해서 ... 그냥 쓰레딩 디자인을 크게크게 합니다.
외부와 접촉하는 부분은 최소한으로하고.
저런 코딩으로 피하는 방법은 힘들기도 하고 ... 유지보수도 힘들것 같아요.

2016-10-08
15:42:56
 
    근데 shared_ptr을 참조로 넘기면 레퍼런스카운트 증가 하나요?
그럴바엔 그냥 스마트 포인터 안쓰는게 좋지 않나요?
2016-10-08
15:47:02
 
    음 func3 같이 하면 shared_ptr이 제 역할을 못하게 되니...
func1이 shared_ptr을 확실히 잡고 있어야 되겠군요.
2016-10-08
22:00:44
 
    노코드,제오//

그러고보니 그렇네요.
func1이 쥐고 있지 않으면 위험하네요.
const shared_ptr<X>&을 쓸거면 X*를 쓰는게 차라리 낫겠네요. 헷갈리지 않게 말이죠.

2016-10-09
00:01:45
 
    ㄴ   
개인적으로 
shared_ptr<X> 를 쓸때는 소유권 공유 혹은 이전 개념으로 씁니다
 
그러니까  소유권에 대한 권리가 들어가는 개념이라면 shared_ptr<X>를 복사 혹은 move 시키고
사용만 한다면 imays님 말씀 처럼 그냥 포인터만 사용합니다.
 
그렇게 하니 문제가 생길일은 없더군요




스마트 포인터를 쓸때 소유권 개념이 없이 쓰면 나중에  재귀 참조도 하게 되어 메모리 해제가 안될수도 있더군요.







2016-10-09
00:58:36
 
    나체 포인터(...)는, 뭐랄까, callee 입장에서 그 포인터가 가리키는 개체를 삭제해도 된다는 건지, 다른 걸로 치환해도 된다는 건지 등등을 (주석이나 매뉴얼에 의존하지 않고는) 알 수 없기 때문에, 좋지 않은 것 같습니다. (결국 나체 포인터는 어느 경우든 쓰지 않는 게 좋을 듯해요)포인터가 가리키는 개체에 대한 어떤 권한도 없다는 것을 명시하는 타입을 하나 만들거나 가져다 쓰면 좋을 것 같아요. 이름은 대강... borrowed_ptr, proxy_ptr, derived_ptr 정도가 어떨까 합니다.
한편으로, func1이 확실히 쥐고 있다는 것을 감안한다면 shared_ptr을 넘겨주지 않는 것 자체는 옳은 길 같습니다.
2016-10-09
13:55:33
 
    저도 이 문제를 고민했었는데, by ref 가 위험할 수 있다고 생각해서,
for () 할 때만 레퍼런스로 쓰고 있습니다.

vector<shared_ptr<X>> vec;
for (auto &ptr : vec)
{
 .... 
}

imays님
const shared_ptr<X> & 에서 X* 쓰다가 저는 X& 로 바꾸었습니다.
문득 해제하지 말라는 코드를 남기고 싶더라고요.(해제하려 들지 않겠지만)
2016-10-09
17:43:27
 
    vs2015로 byval, byref, const byref 테스트 해보니

byref, const byref 할때는 레퍼런스 카운터 안올라가네용...

 

 

 

=======================

=======================

=======================

 

 

 

반응형