원자적(atomic) 연산과 순서(ordering) 제약

Ian Jun
Spoonlabs
Published in
8 min readMay 14, 2020

멀티코어, 멀티쓰레드 환경의 고성능 병렬(parallel) 프로그래밍에는 여러가지 함정이 있다. 그중에 하나는 프로그래머의 의도와는 상관없는 시스템의 최적화이다. 이 글은 시스템의 최적화에 프로그래머의 의도를 전달하는 두가지 기법, 원자적(atomic) 연산과 순서(ordering) 제약을 소개할 예정이다.

원자적 연산은 처리 중간에 다른 것이 끼어들 여지를 주지 않는다. “반쯤 했다.”라는 것은 없고, 단지 “했다”와 “안했다”만 존재하는 연산이다. 순차(sequential) 프로그래밍에서는 그리 중요하지 않지만, 병렬 프로그래밍에서는 이것을 사용해야 할 때가 있다.

예를 들어, 멀티쓰레드 프로그래밍에서 공유 변수를 읽고 쓰는 문제를 살펴보자. A 쓰레드에서 공유 변수 k에 반만 썻는데 다른 B 쓰레드가 k를 읽을 수도 있고, C 쓰레드가 k에 나머지 반을 쓰고 있을 수도 있다. 이와 같이 여러 쓰레드가 공용 자원을 동시에 접근하는 것을 데이터 레이스(data race)라고 한다. 이러한 상황에 원자적 연산을 사용하여 다른 연산이 중간에 끼어들지 못하게 할 수 있다.

원자적인 연산은 멀티 코어의 CPU 캐시(cache)간 불일치의 문제 역시 해결한다. 이전에 설명한 A, B, C 쓰레드가 각기 다른 CPU 코어에서 실행된다고 하자. A 쓰레드는 공유 변수 k에 새로운 값을 썼다. 문제는 자신이 실행하는 코어의 캐시에 기록했을 뿐이라서, 다른 코어에서 이 값을 볼 수 없다. 즉, B 쓰레드가 k를 읽었을 때, A 쓰레드가 기록한 값이 아닐 수 있다. 원자적 연산은 A 쓰레드가 공유 변수 k에 새로운 값을 썼다면 B 쓰레드는 이 값을 읽을 수 있도록 보장한다. Java 용어로 가시성(visibility)을 보장한다.

데이터 레이스와 가시성의 문제는 상대적으로 잘 알려져있다. 반면, 다음에 소개할 연산 재배열(reordering)과 이로 인해서 발생하는 문제는 간과하기 쉽고 찾아내기는 더 어렵다.

연산 재배열은 캐시를 전략적으로 운영하여 성능은 높이는 방법이다. 캐시 전략의 기본은 메인 메모리에 접근하는 횟수를 최소화하는 것이다. 왜냐하면 메모리는 캐시에 비해서 매우 느리기 때문이다. 이를 위해서 컴파일러와 CPU는 캐시를 효과적으로 할 수 있도록 메모리 연산을 재배열한다. 싱글 쓰레드 내에서 실행결과가 바뀌지 않는 선에서 연산의 순서를 바꾼다.

a = 1;
b = 2;

// ...
// something not related to 'a' and 'b'
// ...

a += 10;
b += 15;

이와 같은 코드를 아래와 같이 바꾸어도 결과는 변하지 않는다.

a = 1;
a += 10;

// ...
// something not related to 'a' and 'b'
// ...

b = 2;
b += 15;

반면, 후자는 전자 보다 캐시 적중율이 높다. a와 b의 연산을 캐시에서 계속 사용할 수 있도록 재배열 했기 때문이다.

이와같은 연산 재배열은 순차적인 프로그래밍에서는 괜찮다. 그러나, 멀티코어/멀티쓰레드에서는 이와 같은 행위가 원하지 않는 결과를 초래할 수 있다. 원자적 메모리 연산의 앞/뒤로 변수의 가시성에 변화가 있기 때문이다.

예를 들어보자. value, is_ready 모두 쓰레드간에 공유된 자원이다. is_ready는 value에 새로운 값이 쓰였는지는 판단할 목적으로 사용하기로 했다.

아래는 value의 값을 변경하고, 이것이 바뀌었다는 것을 알리기 위해서 is_ready를 true로 변경한다. is_ready의 true를 다른 쓰레드에서도 볼 수 있도록 atomic_store()를 실행하였다.

value = 123;
atomic_store(is_ready, true);

atomic_store() 이전에 캐시에 쓰인 값(value)은 원자적 쓰기 이전에 또는 최소한 연산와 함께 메모리에 갱신된다.

 Thread              Cache                Memory
| | |
| value | |
|------------------>| |
| | |
| is_ready | atomic_store |
|------------------>|------------------->|
| | (value & is_ready) |
| | |
| | |

다음 코드는 is_ready를 확인하고 true이면 새로운 값으로 갱신되었다고 판단하여 이 값으로 뭔가를 수행한다.

int t1 = atomic_load(is_ready);
int t2 = value;
if t1 {
do_something(t2);
}

atomic_load() 이후에 읽는 값(value)은 원자적 읽기와 함께 또는 이후에 필요할 때 메모리에서 캐시로 불러들인다.

Memory               Cache              Thread
| | |
| atomic_load | |
|------------------->|----------------->|
| (value & is_ready) | is_ready |
| | |
| |----------------->|
| | value |
| | |

전자는 Thread A에서 후자는 Thread B에서 실행된다면 아래와 같은 데이터 흐름으로 표현할 수 있다.

Thread A      Cache #1          Memory        Cache #2    Thread B
| | | | |
| value | | | |
|------------>| | | |
| | | | |
| is_ready | atomic_store | | |
|------------>|--------------->| | |
| | (value | atomic_load | |
| | & is_ready) |--------------->|----------->|
| | | (value | is_ready |
| | | & is_ready) | |
| | | |----------->|
| | | | value |

이대로만 실행된다면, is_ready가 값이 value의 변경 여부를 확실히 보장하며, is_ready가 true이면 반드시 새롭게 변경된 value를 사용한다.

그런데, 컴파일러와 CPU는 코드를 재배열 할 수 있다고 했다. value 쓰기 코드는 다음과 같이 재배열되었고

atomic_store(is_ready, true);
value += 123;

value 읽기 코드 역시 아래와 같이 재배열되었다고 가정하자.

int t2 = value;
int t1 = atomic_load(is_ready);
if t1 {
do_something(t2);
}

이러면 의도한 대로 동작하지 않는다.

Thread A      Cache #1          Memory        Cache #2    Thread B
| | | | |
| is_ready | atomic_store | | |
|------------>|--------------->| | |
| | (value | | |
| value | & is_ready) | | |
|------------>| | | |
| | | | |
| | | |----------->|
| | | | value |
| | | atomic_load | |
| | |--------------->|----------->|
| | | (value | is_ready |
| | | & is_ready) | |

is_ready가 true가 된 싯점에 갱신된 value를 사용하리라 기대하지만, 이 값은 다른 코어의 캐시에 저장되어 있거나, 자신의 캐시의 예전 값일 수 있다. 이는 프로그래머의 의도와 다른 동작이다.

이와 같은 연산 재배열 문제는 연산 순서 제약을 사용하여 해결할 수 있다. 아래 몇가지 메모리 연산 순서 제약을 잘 선택하여 프로그래머의 의도를 전달해야 한다.

relaxed

원자적 연산에만 관심이 있다. 메모리 연산의 순서를 변경해도 괜찮다. 다른 제약에 비해서 속도의 개선이 가능하다. 단순히 원자적 연산만 사용할 의도라면 relaxed를 사용한다.

release

원자적 쓰기 이전의 순서를 이후로 변경하지 않도록 제약한다.

value = 123;
atomic_store(is_ready, true, ordering::release);
unrel_val = 30;

위 코드는 ‘value = 123’의 순서는 보장이 된다. 이 연산은 반드시 원자적 쓰기 이전에 실행된다. 반면, 원자적 쓰기 다음에 위치하는 ‘unrel_val = 30’은 순서를 보장하지 않는다. 즉, 원자적 쓰기 연산의 앞뒤로 얼마든지 재배열될 수 있다.

acquire

원자적 읽기 이후의 순서를 이전으로 변경하지 않도록 제약한다.

int u = unrel_val;
int t1 = atomic_load(is_ready, ordering::acquire);
int t2 = value;
if t1 {
c = t2;
}

‘t2 = value’의 순서는 원자적 읽기 이후에만 실행된다. 반면, ‘u = unrel_val’는 어디든지 재배열 가능하다.

seqcst

메모리에 접근하는 코드와 동일한 순서로 발생하도록 제약한다. 즉, 순차적인 일관성(sequential consistency)을 보장한다. 대신에 여기 나열된 것 중에서 제일 느리다. 꼭 필요한 경우만 사용한다.

지금까지 원자적 연산과 순서 제약에 관한여 알아보았다. 멀티쓰레드와 멀티코어 환경에서 컴파일러와 CPU의 최적화는 종종 원하지 않는 결과를 초래한다. 프로그래머는 이 글에서 소개한 것을 사용하여 시스템에 프로그래머의 의도를 명확히 전달할 수 있다.

--

--