기본형 변수의 Setter, Getter 는 Thread Safe 한가? (어셈블리어로 분석하기)

주싱·2021년 5월 9일
0

Software Engineering

목록 보기
4/12
post-thumbnail

멀티쓰레드 환경에서 기본 타입 변수의 Setter, Getter 간에 Thread-Safe함을 보장하기 위한 동기화가 필요할까? 필요하지 않을까? 이에 대한 답을 위해 먼저 동시성 문제가 왜 일어나는지 정확히 그 원리를 이해해 보자. 이를 위해 MFC 프레임워크 C++ 코드에서 8바이트 부호없는 정수(Long형)간의 복사 코드 한줄이 실제로 Intel CPU에 의해 어떻게 동작하는지 어셈블리 코드를 분석해 본다. (어셈블리 코드 확인 방법은 글 마지막에 기록함)

샘플 소스코드

8바이트 부호없는 정수간의 복사 코드 한줄을 작성한다.

unsigned long long lastStatusLong = 0; 
unsigned long long newStatusLong = 0x0123456776543210;

void Test()
{
	lastStatusLong = newStatusLong;
}

컴파일 후 생성된 어셈블리 코드

unsigned long long lastStatusLong = 0;
unsigned long long newStatusLong = 0x0123456776543210;

void Test()
{
......
	lastStatusLong = newStatusLong;
00E31D18  mov         eax,dword ptr [newStatusLong (0E3C018h)]  
00E31D1D  mov         ecx,dword ptr ds:[0E3C01Ch]  
00E31D23  mov         dword ptr [lastStatusLong (0E3C168h)],eax  
00E31D28  mov         dword ptr ds:[0E3C16Ch],ecx 

어셈블리 코드 분석

고급 언어(C++)로 작성된 코드 한 줄은 실제로 CPU에 의해 하나의 명령어로 처리되는 것이 아님을 확인할 수 있다.

	lastStatusLong = newStatusLong;
00E31D18  mov         eax,dword ptr [newStatusLong (0E3C018h)]  // 1번
00E31D1D  mov         ecx,dword ptr ds:[0E3C01Ch]  // 2번 
00E31D23  mov         dword ptr [lastStatusLong (0E3C168h)],eax // 3번  
00E31D28  mov         dword ptr ds:[0E3C16Ch],ecx  // 4번

위 어셈블리 코드 4줄은 아래와 같은 의미를 가진다.

  1. newStatusLong의 하위 4바이트 영역에 있는 값을 eax라는 CPU 레지스터에 복사한다.
  2. newStatusLong의 상위 4바이트 영역에 있는 값을 ecx라는 CPU 레지스터에 복사한다.
  3. eax에 있는 값을 lastStatusLong 하위 4바이트 영역에 복사한다.
  4. ecx에 있는 값을 lastStatusLong 상위 4바이트 영역에 복사한다.

동시성 문제 분석

만약 A 쓰레드에서 위 어셈블리 코드가 3번까지만 수행되고, lastStatusLong에 접근하는 다른 쓰레드 B로 실행제어(Context Switch)가 넘어간다면 어떤일이 일어날까?

우리가 lastStatusLong에 저장되길 기대하는 값은 기존 값 (0x0000000000000000) 이거나 새로운 값 (0x1234567887654321) 일 것이다. 그러나 이런 경우 lastStatusLong에는 하위 4바이트 값만 복사된 0x0000000087654321 값이 저장되어 있을 것이다.

이런 케이스가 별 문제가 안되는 상황도 있을 수 있다. 그러나 만약 lastStatusLong 값이 누군가의 계좌 잔고 데이터라면 어떻겠는가? 만약 어떤 장비의 중요한 상태 값이라면 어떻겠는가?

위와 같은 상황은 매우매우 간헐적으로 일어 날 수 있다. 위와 같은 상황을 이해하지 못한다면 내 코드가 지금 당장 Test Case를 통과하기 때문에 항상 Test Case를 통과한다고 생각하는 오류를 범할 수 있다.

64비트 CPU?

그런데 만약 위의 코드가 x64 플랫폼 설정으로 컴파일되면 어떤 차이가 발생할까?

아래는 x64 플랫폼 설정으로 컴파일 했을 때 생성된 어셈블리 코드이다. (64비트 CPU 전용으로 실행될 수 있는 코드라고 볼 수 있다. 기본적으로 프로젝트를 생성하면 32비트 64비트 호환 가능한 x86 플랫폼으로 설정되어 있다. )

위 코드를 보면 8바이트 레지스터인 것으로 추정되는 CPU 레지스터에 한번에 8바이트를 가지고 오고 8바이트를 한 번에 메모리에 쓴다. 이렇게 되면 아래 코드는 멀티쓰레드에 의해 접근되어도 Thread-Safe 하다.

만약 1번까지만 수행되고 실행제어가 다른 쓰레드로 넘어간다해도 메모리에 있는 변수 값은 변경되지 않았음으로 아무런 문제가 되지 않는다.

(21.09.30 수정)
그러나 64비트 CPU 일지라도, 멀티 CPU 환경이라면 메모리 캐싱과 관련된 Memory Visibility 문제로 인해 Thread-Safe 하지 않는 상황이 발생할 수 있다. Java 에서는 이를 volatile 키워드를 통해 해결할 수 있음을 알게 되었다. 이 부분은 다음에 자세히 정리해야 겠다. 👉 [ 17.7. Non-Atomic Treatment of double and long ]

unsigned long long lastStatusLong = 0;
unsigned long long newStatusLong = 0x0123456776543210;

void Test()
{
......
	lastStatusLong = newStatusLong;
00007FF7FE4221CA  mov         rax,qword ptr [newStatusLong (07FF7FE42D018h)]  // 1번
00007FF7FE4221D1  mov         qword ptr [lastStatusLong (07FF7FE42D1A0h)],rax  // 2번 

결론 (Long)

위와 같은 단순한 8바이트 크기의 정수형 변수의 Setter/Getter에 의한 접근도 동기화 메커니즘을 적용해 주는 것이 안전하다. (동기화 메커니즘은 언어에 따라 다양하고 검색하면 쉽게 찾을 수 있을 것이다.)

고급 언어를 통해 작성된 하나의 명령이 실제 CPU에 의해서는 다수의 명령어로 나누어 실행 될 수 있다는 사실을 이해하고 있다면 더 복잡한 상황들도 이해하고 대처해 나가는데 도움이 될 것이다.

더 나아가기 (Int, Short, Byte)

그럼 4바이트, 2바이트, 1바이트 정수의 Setter, Getter 는 Thread-Safe한가?

위 결론의 요지는 이 문제가 CPU 아키텍처에 종속적인 문제라는 것이다.

이쯤에서 솔직히 말하면 나는 Intel CPU의 어셈블리 구조를 완전히 알지 못한다. 그러나 잠시 맛본 바로는 4바이트 정수 읽기 쓰기 동작 역시 2바이트 레지스터를 활용해 쪼개어져 수행되도록 프로그래밍이 가능하다. 그렇다면 4바이트 정수의 Setter, Getter도 위와 같은 원리로 쓰레드 동기화 문제가 발생할 수 있을 것이라고 추론해 볼 수 있다.

그리고 우리가 Intel CPU 기반에서만 프로그래밍을 하는가? 임베디드 프로그래밍을 한다면 훨신 다양한 CPU 기반에서 코드를 작성한다. 해당 CPU는 어떻게 동작할지 사실 제대로 아는 사람은 잘 없다.

그래서 나는 사실 2바이트 이상의 기본 타입 변수를 멀티쓰레드 환경에서 동시접근할 때는 항상 동기화 메커니즘을 적용한다.

한가지 예외적으로는 2바이트든 4바이트든 실제로 사용되는 값이 1바이트 범위라면 동기화를 하지 않는다. 그리고 당연히 1바이트 변수는 동기화를 하지 않는다.

글을 마치며 혹시 위 글에 잘못된 내용이 있거나, 저에게 더 알려주고 싶은 내용이 있다면 공유해 주시면 더욱 감사할 따름이다.

[부록] 어셈블리 코드 확인방법

Visual Studio에서 디버그 모드로 프로그램을 실행 후 어셈블리 코드를 보고자 하는 코드 라인에 Break Point를 잡고 Debug > Window > Disassembly 메뉴 선택(Alt+8, 2019버전에서 Ctrl+Alt+D)하면 해당 코드의 어셈블리 코드를 확인할 수 있다.

profile
소프트웨어 엔지니어, 일상

0개의 댓글