Atomic

Date:     Updated:

카테고리:

태그:

인프런에 있는 루키스님의 게임 서버 강의를 듣고 정리한 내용입니다.
모두의 코드 씹어먹는 C++ 자료를 참고했습니다.


CPU Pipeline

사실 CPU는 내가 작성한 코드대로 하지 않는다. 이는 현대의 CPU가 한 번에 한 명령어씩만 실행하는 것이 아니기 때문이다. 아래 코드를 살펴보자.

int a = 0;
int b = 0;

void foo() {
    a = b + 1;
    b = 1;
}

위 코드를 그대로 컴파일 했을 때 생성되는 어셈블리는 다음과 같다.

thread8

놀랍게도 a = b + 1 부분이 끝나기 전에 b = 1 이 먼저 실행된다. 물론 foo 함수 입장에서 결과적으로 달라지는 것은 없다. 하지만 멀티 쓰레드 환경이라면 이야기가 달라진다.

CPU의 작동 방식을 이야기 하기 전에 빨래 과정을 예시로 들어보자.

thread10

먼저 세탁기를 돌리고, 세탁이 끝나면 건조기를, 건조가 끝나면 빨래를 개는 작업이 필요하다. 해야 할 빨래 바구니가 여러 개일 경우 이런 싱글 쓰레드 방식은 매우 비효율적이다.

thread9

따라서 건조를 하며 놀고있는 세탁기를 놀리지 말고 다른 세탁을 미리 돌리는게 훨씬 효율적이다. 이와 같이 한 작업(세탁-건조-개기)이 끝나기 전에, 다른 작업을 시작하는 방식으로 동시에 여러 개의 작업을 실행하는 것을 파이프라이닝(Pipelining)이라고 한다.

CPU도 마찬가지이다. 실제 CPU에서 명령어를 실행할 때는 명령어를 읽고(fetch), 읽은 명령어가 무엇인지 해석하고(decode), 해석된 명령어를 실행하고(execute), 마지막으로 결과를 쓰게 된다(write).

thread11

이때 중요한 점은 명령어마다 실행 속도가 다르다는 것이다. 따라서 매우 느린 명령어가 있다면, 해당 작업 때문에 다른 명령어들이 밀리는 수가 있다. 예컨데 세탁기는 30분인데 건조가 3시간이 걸린다면 빨래 파이프라인이 막힐 것이다. 따라서 컴파일러는 최종 결과물이 달라지지 않는 범위 내에서 CPU 파이프라인을 최대한 효율적으로 활용하기 위해 명령어들을 재배치하게 된다. (+ 캐싱 유무로 재배치 하기도 함)

Atomicity

원자적 연산이란 CPU가 명령어 1개로 처리하는 연산으로, 중간에 다른 쓰레드가 끼어들 여지가 전혀 없는 연산을 말한다. C++에서는 몇몇 타입들에 대해 원자적인 연산을 쉽게 할 수 있도록 여러가지 도구들을 지원한다. 또한 이러한 원자적 연산들은 뮤텍스가 필요하지 않기 때문에 속도가 더 빠르다.

atomic<int32> counter = 0;

void Add() {
	for (int i = 0; i < 100000; i++) {
		counter++;
	}
}


void main()
{
    thread t1(Add);

    t1.join();

    cout << counter << endl;
}

사용 방법은 간단하다. atomic의 템플릿 인자로 원자적으로 만들고 싶은 타입을 전달하면 된다. atomic으로 선언한 경우 컴파일된 counter++ 부분의 어셈블러는 다음고 같다.

thread12

참고로 컴파일러가 이러한 명령어를 사용할 수 있는 이유는 x86 컴파일러를 사용하기 때문이다. 따라서 오래된 CPU의 경우 위와 같은 명령이 없는 경우도 존재한다. atomic 객체를 사용할 수 있는 여부는 is_lock_free() 함수를 통해 확인할 수 있다.


Memory Order

atomic객체들의 경우 원자적 연산 시에 메모리 접근에 대한 옵션을 지정할 수 있다.

  • memory_order_relaxed: 동기화를 수행하지 않음.
  • memory_order_consume: 데이터 종속성을 보장 (거의 사용되지 않음).
  • memory_order_acquire: 이전 쓰기 연산이 완료되었음을 보장.
  • memory_order_release: 이후 읽기/쓰기 연산이 완료되었음을 보장.
  • memory_order_acq_rel: 읽기 및 쓰기 모두 동기화를 보장.
  • memory_order_seq_cst: 가장 강력한 동기화 보장 (기본 옵션).


atomic<bool> is_ready;
atomic<int> Data[3];

void producer() {
    Data[0].store(1, memory_order_relaxed);
    Data[1].store(2, memory_order_relaxed);
    Data[2].store(3, memory_order_relaxed);
    is_ready.store(true, memory_order_release);
}

void consumer() {
    // Data 가 준비될 때 까지 기다린다.
    while (!is_ready.load(memory_order_acquire)) {
    }

    cout << "Data[0] : " << Data[0].load(memory_order_relaxed) << endl;
    cout << "Data[1] : " << Data[1].load(memory_order_relaxed) << endl;
    cout << "Data[2] : " << Data[2].load(memory_order_relaxed) << endl;
}

int main() {
    vector<thread> threads;

    threads.push_back(thread(producer));
    threads.push_back(thread(consumer));

    for (int i = 0; i < 2; i++) {
        threads[i].join();
    }
}

thread13


Data[0].store(1, memory_order_relaxed);
Data[1].store(2, memory_order_relaxed);
Data[2].store(3, memory_order_relaxed);
is_ready.store(true, std::memory_order_release);

Data의 원소들을 store하는 명령어들은 모두 relaxed이기 때문에 자기들 끼리는 CPU가 마음대로 재배치할 수 있다. 하지만 아래 release 명령을 넘어가서 재배치될 수는 없다.

thread14



맨 위로 이동하기

Server 카테고리 내 다른 글 보러가기

댓글 남기기