Coding Memo

volatile 전역변수 사용 - 쓰레드 본문

Language/C++

volatile 전역변수 사용 - 쓰레드

minttea25 2022. 9. 6. 13:35

이전 글:

https://minttea25.tistory.com/68

 

volatile 변수

본 포스팅은 인프런에 등록되어 있는 Rockiss 님의 강의를 보고 간단하게 정리한 글입니다. 변수를 선언 할 때, 타입 앞에 volatile을 붙여서 선언할 수 있다. volatile 변수는 C/C++ 만 아니라 Java 등의

minttea25.tistory.com


volatile 변수는 컴파일 시 최적화를 진행하지 않기 때문에 매번 값을 읽고 쓰기 위해 메모리에 접근하는 과정을 포함한다.

 

이전 글에서는 크게 문제가 될만한 사항이 없는 예제들에 대해서 작성하였지만 이번 글에서는 쓰레드에서 치명적인 문제가 발생할 수 있는 예시들에 작성하였다.

 

컴파일 최적화에서 어떤 변수에 대해 이 변수가 다른 쓰레드에서 사용을 하는지 하지 않는지 여부를 확인하지 않는다.

 

즉, 쓰레드는 그 변수의 값을 매번 메모리에 접근하여 값을 read 하지 않는다.

변수가 사용되기 전 필요한 값을 메모리에서 가져와 쓰레드 레지스터에 저장하여 사용한다. (al)

이후 그 값을 변경하면 레지스터의 값을 변경 후, 변경된 그 값을 해당 변수의 메모리에 복사한다.

 

문제가 없어보이지만 위 내용이 반복문과 조건문에 들어간다면 치명적인 오류가 발생할 수 있다.

 


#pragma once

#include <iostream>
#include <thread>

using namespace std;

bool flag = false;

void Thread1()
{
	while (true)
	{
		if (flag == false)
		{
			cout << "Thread1" << endl;
			flag = true;
		}
	}
}

void Thread2()
{
	while (true)
	{
		if (flag == true)
		{
			cout << "Thread2" << endl;
			flag = false;
		}
	}
}

int main()
{
	thread t1(Thread1);
	thread t2(Thread2);

	t1.join();
	t2.join();
}

Thread1에서는 flag가 false일 때 Thread1을 출력한 후 flag를 다시 true로 바꾼다. 이후에는 flag가 다른 쓰레드에 의해 false가 될 때까지 무한루프로 대기하게 된다.

 

Thread2에서는 flag가 true일 때 Thread2를 출력하는데, flag의 초기값이 false이므로 다른 쓰레드가 flag 값을 변경시키면 그제서야 Thread2를 출력하고 flag를 false로 바꿔준다.

 

Release 모드 빌드

아무것도 생각하지 않고 단순히 보면 Thread1과 Thread2가 번갈아 무한히 출력될 것처럼 보인다.

 

그렇다면 실제 위 실행코드의 결과는 어떻게 될까? (Release 빌드로 실행하였을 때 = 컴파일 최적화가 일어났을 때)

실행 결과는 아래 이미지와 같다.

출력 결과 1

Thread1이 출력되고 이후에 Thread2가 출력이 되어야되는데, 더 이상 아무것도 출력하지 않고 계속 대기하고 있는 상태가 된다. 

 

이유는 무엇일까?

 

간단하게 말하면 if문의 조건, flag==false에서 비교하는 값이 al (eax의 최하위 비트라고 한다.)이다.

이 값은 while(true) 위에서 가져온다. (movzx eax, byte ~~~)

따라서 al 값을 1(true)로 바꾸고 (mov al, 1)

메모리에 flag 값을 1(true)로 바꾸었지만 (mov byte ptr [flag ~~~)

if문은 여전히 쓰레드 레지스터의 값인 al 값으로 비교를 하고 있기 때문에 다른 쓰레드에서 flag 값을 바꿔준다고 하더라도 Thread1에서 영원히 flag 값은 true일 수 밖에 없는 것이다.

Thread1 - 디스 어셈블리 x64

 

Thread2 출력의 경우는 컴퓨터마다 차이가 있을 수 있다. -> movzx가 어느 시점에 호출이 되었는지에 따라 달라진다.

 

거의 동시에 Thread1과 Thread2가 실행이 되었기 때문에 flag 값이 false인 채로 쓰레드가 시작되게 되었다. 따라서 출력 결과가 위 이미지 처럼 Thread1 만 출력이 된 것이다.

Thread2 - 디스 어셈블리 x64

 

만약, movzx가 Thread1이 flag 값을 1(true)로 바꾼 후에,Thread2가 시작이 되어서 Thread2의 al에 있는 값이 true로 시작하게 된다면 Thread2가 출력 될 것이다.

(아래 main에서 Thread2 시작 시점을 100ms 뒤로 하여 강제로 늦추게 되면 예상했던 결과대로 Thread1, Thread2 출력이 된다. 이후에는 al 값 변경이 없으니 한번 씩만 출력되는 것은 당연하다.)

int main()
{
	thread t1(Thread1);
	this_thread::sleep_for(100ms);
	thread t2(Thread2);

	t1.join();
	t2.join();
}

추가 실험 결과


volatile 키워드를 flag 변수에 붙여주고 어셈블리가 어떻게 변했는지 비교해보자.

 

volatile bool flag = false;

 

결과 값은 당연히 Thread1 과 Thread2가 무한히 번갈아 출력되는 것을 알 수 있다.

출력 결과 - volatile

 

디스어셈블리를 보면 volatile를 사용하지 않았을때와의 차이를 바로 알 수 있다.

movzx가 if문에서 호출이 되었다. 즉 flag 값을 미리 eax에 올려 놓지 않고 if문이 호출 되었을 때마다 직접 메모리에 접근하여 flag 값을 read한다. (read 한후 eax에 저장, eax의 al 값을 비교)

 

따라서 메모리에 매번 직접 접근하여 flag 값을 읽기 때문에 메모리에 있는 flag의 최신화된 값을 가져와 조건을 확인할 수 있게 되었다.

 

여기서 또 하나 특이한 점은 flag=true 부분이다. 메모리에 값을 write 하는 부분은 같지만 레지스터를 거치지 않고 바로 메모리에 직접 접근하여 값을 쓴 다는 점이 눈에 띄었다. (좀 더 찾아볼 필요가 있는 부분일지도 모르겠다.)

Thread1 - 디스어셈블리 x64 - volatile

 

Thread2도 마찬가지이다.

Thread2 - 디스어셈블리 x64 - volatile

 

 


위 내용들은 실제로 사용할 일이 별로 없을 것이라고 생각한다. 

 

<<정리>>

1. volatile을 사용하면 편해지는 점도 있을 수 있지만 메모리에 매번 접근하는 것이기 때문에 느려질 수 밖에 없다.

2. 따라서 쓰레드에서 전역변수 뿐만 아니라 다른 어떤 값에 대해 wrtie, read 시  lock과 atomic을 이용해서 처리하자!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!


처음에는 단순하게 각각 Thread에서 flag 값이 한번 바뀌고 그 이후에는 내부에서 바꾸는 명령이 없으므로 컴파일러가 자동으로 if문을 무시할 것이라고 생각했다.

결론적으로는 내 처음 생각이 일부분 맞긴했다. 하지만 제대로 이해가 되지 않아 그 이유와 원리를 찾아보면서, 공부하면서 해보니까 훨씬 복잡해서 작성하는데 시간이 오래걸렸다. ㅠㅠ.. 어려워