본문

C++에서의 원자성(Atomicity)설명과 그 예

1. Atomicity(원자성)이란?

원자단위 연산(Atomic Operation)은 실행중에 중단(inturruption)하지 않는, 하나 이상의 순차적인 기계어 명령(machine instruction)으로 이루어져있다. 대개 2개이상의 기계어 명령으로 이루어진 경우에는 원자단위 연산이라 하지 않는다. 명령 실행 도중에 운영체제가 다른 작업을 위해, 현재 진행중인 명령을 중지(suspend)시킬 수 있기 때문이다. 만약 여러 명령들이 원자성을 가지게 하고싶다면, locking등의 동기화 방법을 사용해야 한다. 단, 하나의 기계어 명령은 항상 원자성을 갖는다는 점을 기억하자(CPU는 하나의 기계어 명령을 수행하는 도중 멈추지 않는다). 이로서 알수있는 것은, 어떤 C++ 구문이 하나의 기계어 명령으로 표현되어진다면 그것은 자연적으로 원자성을 갖는다(naturally atomic)는 것이며, 이러한 경우, 굳이 명시적인 동기화 방법을 사용하지 않아도 된다는 것이다.


2. 그럼 어떤 C++ 구문이 자연적으로 원자성을 가지는가?
하드웨어 종류에 따라 c++구문을 처리하는 방식이 다를 수 있기 때문에, 공통적인 규칙이라고 할 수 있는 사항은 많지 않다. 많은 서적에서 ++, -- 단항 연산자들이 int, pointer에서 원자성을 갖는다고 적어놓는다. Dennis Ritchie와 Brian Kernighan이 C를 처음 설계했을 때 이 연산자들을 추가했는데, 이를 통해 많은 기기들이 지원하는 빠른 INC(increment) 어셈블리 지시어(directive)의 장점을 살리고자 했기 때문이었다. 하지만, C나 C++ 표준에는 이러한 연산자들이 원자성을 가진다는 보장은 하지 않는다. 또한 Ritchie와 Kernighan은 C언어 설계에 있어 원자성 보다는 수행 속도에 중점을 두었다. 따라서 컴파일러의 결과를 확인하지 않은 채, ‘특정 C++구문이 원자성을 가질것이다’라는 가정을 해서는 안된다. c++구문에서는 한줄로 표현되는 구문이 실제로는 길고 복잡한 기계어 명령으로 이루어 질지도 모른다.

*참고 : 종종 혼용되긴 하지만, 기계어와 어셈블리어는 동일한것이 아니다. 어셈블리어는 inc와 add와 같은 니모닉(mnemonic) 예약어(keyword)를 사용하지만, 기계어는 CPU가 이해할 수 있는 바이너리 형태의 코드를 사용한다. 즉, 어셈블리어는 인간이 알아볼 수 있도록 한, 기계어의 표현방법인 것이다.


3. C++ 정수형, 포인터형 연산의 예(=원자성을 갖는다)
int와 pointer에 적용되는 증감연산의 다음 예를 보자.
int x=7, y=0;
long double * p =0;

1. p++;, x++;, ++y;, y--; 
p++;는 Win32 플랫폼에서 다음과 같이 어셈블리 지시어로 표현된다. 여기에서 나오는 지시어의 의미보다는 이 지시어가 단 하나의 어셈블리 코드로 이루어졌다는것이 더 중요하다. 따라서 포인터의 경우 ++연산은 해당 플랫폼에서 원자성을 가진다고 할 수 있다. 마찬가지로 int에서의 후위증가연산은 원자성을 가진다. 단, 컴파일러는 포인터 연산에 쓰인 add 대신 정수형 연산에는 inc 연산을 사용했다는것을 확인하라. 또한 int의 전위 증가 연산의 경우도 마찬가지의 결과를 보여준다. 이것은 (적어도 정수형 연산에 있어서) 전위 연산과 후위연산에 차이가 없다는 것을 보여준다.
p++; => add dword ptr [ebp-0x64],0x0a
x++; => inc dword ptr [ebp-0x58]

++y; => inc dword ptr [ebp-0x5c]

y--; => dec dword ptr [ebp-0x5c]


2. y=y+2;, p+=10;

컴파일러는 정확한 포인터 위치를 정적으로 계산할 수 있으므로 포인터 대입 또한 정수 대입과 마찬가지로 원자성을 가진다. 즉 정수형과 포인터형에 있어서의 증가와 감소연산 또한 원자성을 가진다고 할 수 있다. 또한, 이들의 대입연산 역시 원자성을 가진다.

y=y+2; => add dword ptr [ebp-0x64],0x02

p+=10; => add dword ptr [ebp-0x64],0x64


4. C++ Iterator의 예(-원자성을 갖는다)

표준 라이브러리는 범용 포인터로서 iterator를 사용한다. 단, iterator가 포인터만큼 효율적일까? c++ 표준은 iterator가 어떻게 구현되는가에 대해서는 서술해 놓지 않았다. (모든 경우는 아니지만) 대부분의 경우, iterator는 typedef로 이름만 바뀐 포인터에 불과 하다. 아래 iterator 예제를 보면서 iterator의 원자성에 대해 알아보자. 결과로서 vector<int>::iterator의 증가연산은 int*의 증가연산과 동일하다. 따라서 이 iterator는 결국 정수형 변수에 대한 포인터임을 알 수 있다.

vector<int> vi;

vi.push_back(9);

vector<int>::iterator it=vi.begin();

it++; => add dword ptr [ebp-0x54],0x04


5. C++ 오버로드 된 연산자의 예(=원자성을 갖지 않는다)

struct S{

int x;

inline int operator++ () { return ++x;} //prefix

};

S s1;

최적화 옵션이 모두 비활성화된 상태에서, 컴파일러는 s1++의 결과를 아래와 같이 출력한다. 바로 알수 있다시피 이 연산은 원자성을 가지지 않는다 뿐만 아니라 call S::operator++()에서 또 다른 부분을 호출하여 결국 C++ 한줄짜리 구문이 많은 연산을 이끌어 낼 수도 있다는 사실을 알 수 있다. 

lea eax, [ebp-0x60]

push eax

call S::operator++()

pop ecx



====================================
외국 페이지를 보다보면 장황하게 늘어놓다가 결국 아무 내용도 아닌 경우가 있다. 초반 페이지에 잘 설명되어있고 스크롤 길이도 어느정도 있길래 내용이 좋구나 번역해야겠다 하고 시작했는데 예제부분부터 필요한 부분만 요약해서 정리해놓았다. 정리하다가 알게된것은 i++와 ++i에 차이가 없다는 것이다.

// Prefix
Integer& Integer::operator++(){
    *this += 1;
    return *this;
}

// Postfix
const Integer Integer::operator++(int){
    Integer oldValue = *this;
    ++(*this);
    return oldValue;
}


위의 소스와 같이, 기본 보관했던 값을 반환해야 한다는 점에서 i++와 ++i에 차이가 있다고 여겨져 왔다, 하지만 컴파일러 측에서 최적화를 거쳐 동일한 결과를 내보낸다. 하지만 단순한 정수 계산에서만 차이가 없는것이고, 여태까지 버릇을 잘 들여놔서 앞으로도 쭉 전위 연산자를 사용할 것이다. 

다시 아리송해지는 것은 증가연산을 할때 레지스터에 값을 저장하고 연산하고 다시 값을 돌려놓는 작업을 하는 일련의 과정이 있는데 이 모든 과정이 하나의 명령으로 표기되어 atomic하다고 할지라도 이것이 thread-safe라고 말할 수 있는것인가?에 대한것이다. 이와 유사한 질문으로 I've heard i++ isn't thread safe, is ++i thread-safe? 가 있는데 요약하자면 상황에 따라 다르다? 멀티코어 환경에서는 메모리에 대한 접근이 여러 CPU 상에서 이루어 질 수 있으므로 thread-unsafe라는 것이다. 

좀 더 자세히 질문에 대해 구체화 해보자면, 위의 링크에서 가장 많은 추천을 받은 답변자의 글처럼, 컴파일러가 그것을 여러줄의 코드로 번역을 했다면, 그것은 당연히 atomic하다고 할 수 없을것이다. 반대로 본문의 컴파일러의 결과물 처럼 한줄로 표기된 경우에는 (정의에 의해) atomic하다고 할 수 있다. 하지만 atomic하다고 해서 cpu의 microinstrcution 단위로 더욱 더 쪼개질 때에 있어 concurrency가 보장되느냐 하는 것이다. 하기야 microinstruction의 독립성이 보장되지 않는다면 지금 사용하고 있는 시스템 자체에 대한 의문으로 확장되는군.. 그리고 흔히말하는 interrupt와 cpu cycle내의 그것은 다른 의미를 지닌다. 

따라서 결론을 짓기위해, 다시 본문의 내용으로 넘어가 정리해본다. 컴파일러의 출력에 따라 연산의 atomic의 여부가 결정되며, 컴파일러의 출력 결과가 서로 다를 수 있기 떄문에 이를 명확히 하기 위해 언어가 제공하는 기법이나 라이브러리 등을 사용하는 것이다. atomic하다면 한번의 사이클내에 처리가 되기 떄문에 데이터의 프로세스가 중첩되지 않는다. 하지만 멀티프로세서 환경에서 memory fetch/store 부분이 있는 경우에는 각기 메모리에 접근하는 시간이 상이할 수 있기 떄문에 atomic하더라도 thread-safe라고 할 수 없다 라고 정리하는게 좋을 것 같다.

댓글

Holic Spirit :: Tistory Edition

design by tokiidesu. powerd by kakao.