태크놀로지

멀티쓰레드 프로그램 작성시 주의해야할점 - 컴파일러 본문

멀티코어

멀티쓰레드 프로그램 작성시 주의해야할점 - 컴파일러

원택 2020. 9. 16. 22:26

다음의 멀티쓰레드 프로그램에 어떠한 문제점이 있을까?

bool data_ready = false;
int g_data;

void Consumer()
{
	while (data_ready == false);
	int my_data = g_data;
	cout << "Received : " << my_data << endl;
}

void Producer()
{
	g_data = 999;
	data_ready = true;

	cout << "data ready = true\n";
	cout << "Producer End\n";
}

int main()
{
	thread c(Consumer);
	thread p(Producer);
	
	p.join();
	c.join();
}

디버깅시 결과는 잘 나오지만 릴리즈모드시 무한루프에 빠지게 된다. 

-> 디스어셈블리 디버깅을 해보자

 

디버깅 디스어셈블리 창

void Consumer()
{
005D1010  mov         al,byte ptr [data_ready (05D53F8h)]  
	while (data_ready == false);
005D1015  test        al,al  
	while (data_ready == false);
005D1017  je          Consumer+5h (05D1015h)  
	int my_data = g_data;
	cout << "Received : " << my_data << endl;
.
.
.

005D1010 mov - al, byte ptr [data_ready (05D53F8h)] : al은 레지스터이다. bool(1byte)값을 저장한다.

005D1015 test - al, al : 각 레지스터 al을 검사한 다음에 flag 레지스터에 등록한다. ( 0인가? 아닌가? / 어느쪽이 더 큰가? / 차이가 음수인가 양수인가? )

005D1017 je - Consumer+5h(05D1015h) : je(jump equal)은 (05D1015h)주소로 점프를 해라.

 

05D53F8h의 메모리

bool값도 1(true)값이 잘들어간걸 확인할 수 있다.

 

디스어셈블리코드를 보면 005D1017 je - Consumer+5h(05D1015h)에서 while문 조건과 상관없이 05D1015h로 점프하고 있다.

 

릴리즈는 왜 다르게 작동하는가?

컴파일 개수가 줄어든다. (자동 최적화)

메모리 버스는 하나이기 때문에 다른 코어에 영향을 준다.

C언어는 싱글쓰레드 기반이여서 멀티쓰레드에 대한 정의가 없다.

 

해결책

Case1: 애초에 data ready가 데이터 레이스다. lock을 걸어보자.

void Consumer()
{
	m_CP.lock();
	while (data_ready == false);
	m_CP.unlock();

	int my_data = g_data;
	cout << "Received : " << my_data << endl;
}

void Producer()
{
	g_data = 999;

	m_CP.lock();
	data_ready = true;
	m_CP.unlock();

	cout << "data ready = true\n";
	cout << "Producer End\n";
}

이럴시 데드락 현상이 발생, while문 내에서 조건문에 따라 lock/unlock을 사용해서 데드락문제를 해결했어도 성능상에 영향을 끼침.

 

Case2: Volatile을 사용하면 된다.

  • 반드시 메모리를 읽고 쓴다.
  • 변수를 레지스터에 할당하지 않는다.
  • 읽고 쓰는 순서를 지킨다.

[volatile를 사용한 디버깅 디스어셈블리 창]

	while (data_ready == false);
00FE1010  cmp         byte ptr [data_ready (0FE53F8h)],0  
00FE1017  je          Consumer (0FE1010h)  

cmp부분이 추가 되었다. volatile은 무조건 메모리에서 읽고 무조건 메모리에 쓴다(레지스터 사용안함)

 

volatile을 만든 목적 memory wrapped I/O

Memory wrapped I/O란, 기계어엔 연산 점프등이 있는데 이것만으로는 대화가 안되기 때문에 I/O 명령어를 만듬. 컴파일러는 I/O장치를 최적화하면 안되고(I/O메모리에 값을 사용해야하는데 쓰지 않을때) 메모리만 최적화 해야하는데 이를 위해 volatile을 만듬.

 

volatile 사용시 주의해야할점

무엇이 문제일까?

struct Qnode {
	volatile int data;
    volatile Qnode* next;
};

void ThreadFunc1 ()
{
	...
    while (qnode->next == NULL) {}
    my_data = qnode->next->data;
    ...
}

while(qnode->next == NULL) {}

 

  • volatile int* a
    • *a = 1; // 메모리에 접근한다.
    • a = b; // 메모리에 접근하지 않는다.
  • int* volatile a;
    • *a = 1 // 메모리에 접근하지 않는다.
    • a = b // 메모리에 접근한다.

※ 포인터를 사용해야 할 경우를 주의해야한다.

 

volatile 디스어셈블리 코드 분석

  • volatile x
    • 자동으로 최적화해주고 있다.
  • int* volatile
    • mov: eax에 dword ptr [sum] 배열 주소를 복사한다.
    • add: 2를 더해준다.
    • sub: 1바이트로 stack 1개를 할당한다.
  • volatile int*
    • mov: eax에 dword ptr [esi+ecx]를 복사한다.
    • mov: dword ptr [sum] 배열 주소를 복사한다.
    • add: 2를 더해준다.
    • mov: dword ptr[esi+ecx]에 eax를 다시 복사한다.
    • 하단 내용은 int* volatile과 동일하다.
    • pop: 스택공간을 해제하고 esi값을 스택에서 뺀다.

volatile int*는 dword ptr[esi+ecx] 메모리까지 접근하고 있다.

 

최종정리

  • 여러개의 쓰레드가 공유하는 변수는 volatile을 사용해야한다.
  • volatile을 사용하면 컴파일러는 프로그래머가 지시한 대로 메모리에 접근한다.
  • volatile이 없으면 컴파일러는 싱글쓰레드를 기준으로 프로그램을 최적화한다.

출처

한국산업기술대학교 게임공학부 정내훈 교수님 강의 - 멀티코어 프로그래밍