C++ 쓰레드 -3

·2022년 7월 10일
0

cpp_study

목록 보기
24/25

atomic 객체와 명령어 재배치

CPU와 컴퓨터 메모리(RAM)은 물리적으로 떨어져 있음.
따라서 CPU에서 메모리를 읽는데 걸리는 시간: 약 42사이클(add 한번 하는데 걸리는 시간: 1사이클)

캐시

CPU 칩 안에 있는 조그마한 메모리.
램과는 달리 CPU에서 연산을 수행하는 부분이랑 거의 붙어 있어, 읽기/쓰기 속도가 매우 빠름

CPU에서 차례로 가장 많이 접근하는 메모리 영역: L1 > L2 > L3 캐시 순

CPU가 특정 주소에 있는 데이터에 접근하려 하면
1. 일단 캐시에 있는 지 확인
2. 캐시에 있다면 해당 값을 읽음(Cache hit)
3. 없으면 메모리까지 갔다가 옴(Cache miss)

CPU가 어떻게 어느 영역의 메모리에 자주 접근할 지 어떻게 아는가?
-> 알 수 없다.

CPU에서 캐시가 작동하는 방식은

  • 메모리를 읽으면 일단 캐시에 저장한다.
  • 만일 캐시가 다 차면 특정 방식에 따라 처리한다.

이때 특정 방식에는 대표적으로 LRU 방식이 있음(Least Recently Used).
최근에 접근한 데이터를 자주 반복해서 접근하면 매우 유리함.

컴퓨터 컴파일 실행 순서

int a = 0;
int b = 0;

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

그런데 이때 a = b + 1 부분의 실행이 끝나기 전에 b = 1이 먼저 실행이 끝남.
-> 만약 다른 쓰레드가 있어 a와 b의 값을 확인했을 때, 코드가 순서대로 실행되었으면 b =1이면 a = 1이어야 하지만, a=0인데 b=1일 수 있다.

CPU 파이프라이닝

한 작업이 끝나기 전에, 다음 작업을 시작하는 방식으로 동시에 여러 개의 작업을 동시에 실행하는 것

실제 CPU에서 명령어를 실행할 때:
fetch(명령어 읽음), decode(읽은 명령어가 뭔지 해석), execute(해석된 명령어를 실행), write(결과를 씀)의 과정을 거침.

그런데 예컨데, 세탁이나 빨래 개기는 30분인데 건조가 3시간이나 걸려서 건조기 기다리느라 빨래를 계속할 수 없다면, 비효율적일 수 있다.

따라서 컴파일러는 CPU의 파이프라인을 효율적으로 활용할 수 있도록 명령어를 재배치함.
또한 컴파일러가 아니더라도, 캐시 상 더 빨리 처리할 수 있는 걸 CPU에서 먼저 실행할 수도 있음.

수정 순서 modification order

수정 순서: 만약 어떤 객체의 값을 실시간으로 확인할 수 있는 전지전능한 무언가가 있다고 했을 때, 해당 객체의 값의 변화를 기록한 것

C++의 모든 객체들은 수정 순서(modification order)라는 것을 정의할 수 있음.
즉, 원자적 연산을 할 경우 모든 쓰레드에서 같은 객체에 대해 동일한 수정 순서를 관찰할 수 있다.

💡 BUT 같은 시간에 변수 a의 값을 관찰했다고 해서 굳이 모든 쓰레드들이 동일한 값을 관찰할 필요는 없다.

-> 쓰레드 간에 같은 시간에 변수의 값을 읽었을 때 다른 값을 리턴할 수 있음.
-> CPU 캐시가 각 코어별로 존재하기 때문.
-> 각 코어가 각각 자신들의 L1, L2 캐시들을 가지고 있음(동기화 작업이 필요하나, 시간을 꽤나 잡아먹음)

원자성

원자적인 연산이 아닌 경우: 모든 스레드에서 같은 수정 순서를 관찰할 수 있음이 보장되지 않음. 직접 적정한 동기화 방법을 통해 처리해야 함.

원자적: CPU가 명령어 1개로 처리하는 명령. 중간에 다른 스레드가 끼어들 여지가 전혀 없는 연산.

#include <atomic>
#include <iostream>
#include <thread>
#include <vector>

void worker(std::atomic<int>& counter) {
  for (int i = 0; i < 10000; i++) {
    counter++;
  }
}

int main() {
  // ❗ 이레와 같은 아토믹 객체를 만들 수 있음.
  std::atomic<int> counter(0);
  std::vector<std::thread> workers;
  for (int i = 0; i < 4; i++) {
    workers.push_back(std::thread(worker, ref(counter)));
  }
  for (int i = 0; i < 4; i++) {
    workers[i].join();
  }
  std::cout << "Counter 최종 값 : " << counter << std::endl;
}

atomic의 템플릿 인자로 원자적으로 만들고 싶은 타입을 전달할 수 있음.

그런데 CPU에 따라 어느 CPU에서 실행할 지 컴파일러가 알고 있음
-> CPU 특이적인 명령어를 제공할 수 있다는 걸 is_lock_free 함수로 알 수 있음.

memory order

atomic 객체들은 원자적 연산 시, 메모리 접근 방식을 지정 가능.

memory_order_relaxed

가장 느슨한 조건.
memory_order_relaxed 방식으로 메모리에서 읽거나 쓸 경우,
주위의 다른 메모리 접근들과 순서가 바뀌어도 무방함.

#include <atomic>
#include <cstdio>
#include <thread>
#include <vector>

using std::memory_order_relaxed;

void t1(std::atomic<int>* a, std::atomic<int>* b) {
  b->store(1, memory_order_relaxed); // b = 1 (쓰기)
  int x = a->load(memory_order_relaxed); // x = a (읽기)
  printf("x : %d \n", x);
}

void t2(std::atomic<int>* a, std::atomic<int>* b) {
  a->store(1, memory_order_relaxed); // a = 1 (쓰기)
  int y = b->load(memory_order_relaxed); // y = b (읽기)
  printf("y : %d \n", y);
}

int main() {
  std::vector<std::thread> threads;
  std::atomic<int> a(0);
  std::atomic<int> b(0);
  
  threads.push_back(std::thread(t1, &a, &b));
  threads.push_back(std::thread(t2, &a, &b));
  for (int i = 0; i < 2; i++) {
    threads[i].join();
  }
}

store과 load는 atomic 객체들에 대해 원자적으로 쓰기, 읽기를 지원해 주는 함수.

장점

CPU에서 메모리 연산 순서에 관련해서 무한한 자유를 줌.
-> CPU에서 매우 빠른 속도로 실행함.

즉, 위 경우가 아닌 counter++ 10000번씩, 4개의 쓰레드로 ++하는 경우 더 유용함.
비록 다른 메모리연산들 보다 counter++ 이 늦게 된다고 하더라도 결과적으로 증가되기만 하면 문제 될게 없기 때문(순서가 중요하지 않은 경우에 더 유용!)

단점

위 코드에서 결과로
x : 0
y : 0
가 나올 수도 있음.

즉, 아래와 같이 CPU가 코드 순서를 재배치해서 실행할 수도 있음.

int x = a->load(memory_order_relaxed); // x = a (읽기)
b->store(1, memory_order_relaxed); // b = 1 (쓰기)

동기화: memory_order_acquire 과 memory_order_release

#include <atomic>
#include <iostream>
#include <thread>
#include <vector>

using std::memory_order_relaxed;

void producer(std::atomic<bool>* is_ready, int* data) {
  *data = 10;
  is_ready->store(true, memory_order_relaxed);
  // ❗ *data = 10과 순서가 바뀌어서 실행되면 is_ready가 true라도 *data = 10이 실행이 끝나지 않을 수 있음.
}

void consumer(std::atomic<bool>* is_ready, int* data) {
  // data 가 준비될 때 까지 기다린다.
  while (!is_ready->load(memory_order_relaxed)) {
  }
  std::cout << "Data : " << *data << std::endl;
}

int main() {
  std::vector<std::thread> threads;
  std::atomic<bool> is_ready(false);
  int data = 0;
  
  threads.push_back(std::thread(producer, &is_ready, &data));
  threads.push_back(std::thread(consumer, &is_ready, &data));
  
  for (int i = 0; i < 2; i++) {
    threads[i].join();
  }
}

*data = 10과 순서가 바뀌어서 실행되면 is_ready가 true라도 *data = 10이 실행이 끝나지 않을 수 있기 때문에,
consumer 스레드에서 is_ready가 true가 되어도 제대로 된 data를 읽을 수 없음.

이건 consumer 스레드에서도 마찬가지임.

while (!is_ready->load(memory_order_relaxed)) {
}
std::cout << "Data : " << *data << std::endl;

위 코드에서 data를 읽는 부분과 is_ready에서 읽는 부분 순서가 바뀌면, is_ready가 true가 되기 전의 data값을 읽을 수 있음.

-> memory_order_relaxed를 사용할 수 없음.

#include <atomic>
#include <iostream>
#include <thread>
#include <vector>

void producer(std::atomic<bool>* is_ready, int* data) {
  *data = 10;
  is_ready->store(true, std::memory_order_release);
  	// ❗ 1. memory_order_release: 해당 명령 이전의 모든 메모리 명령들이 해당 명령 이후로 재배치되는 것을 금지함.
}

void consumer(std::atomic<bool>* is_ready, int* data) {
  // data 가 준비될 때 까지 기다린다.
  while (!is_ready->load(std::memory_order_acquire)) {
  }
  // ❗ 2. 같은 변수를 memory_order_acquire으로 읽는 쓰레드가 있으면, memory_order_release 이전에 오는 모든 메모리 명령들이 해당 쓰레드에 의해서 관찰될 수 있어야 함.
  std::cout << "Data : " << *data << std::endl;
}

int main() {
  std::vector<std::thread> threads;
  std::atomic<bool> is_ready(false);
  int data = 0;
  
  threads.push_back(std::thread(producer, &is_ready, &data));
  threads.push_back(std::thread(consumer, &is_ready, &data));
  
  for (int i = 0; i < 2; i++) {
    threads[i].join();
  }
}
  1. memory_order_release: 해당 명령 이전의 모든 메모리 명령들이 해당 명령 이후로 재배치되는 것을 금지함.
  2. 같은 변수를 memory_order_acquire으로 읽는 쓰레드가 있으면, memory_order_release 이전에 오는 모든 메모리 명령들이 해당 쓰레드에 의해서 관찰될 수 있어야 함. (❓)
    즉, 해당 명령 뒤에 오는 모든 메모리 명령들이 해당 명령 위로 재배치되는 것을 금지함.

따라서 이 두 개의 다른 스레드들이 같은 변수의 release, acquire 를 통해 동기화(synchronize)를 수행하게 됨.

memory_order_acq_rel

acquire와 release를 모두 수행하는 것.
읽기, 쓰기를 모두 수행하는 명령들 예를 들어 fetch_add와 같은 함수 속에서 사용될 수 있음.

memory_order_seq_cst

메모리 명령의 순차적 일관성을 보장.

순차적 일관성

메모리 명령 재배치도 없고, 모든 쓰레드에서 모든 시점에 동일한 값을 관찰할 수 있는 방식

특징

  • 멀티 코어 시스템에서 memory_order_seq_cst가 꽤나 비싼 연산임.
  • 인텔, AMD x86 CPU는 사실 거의 순차적 일관성이 보장됨. memory_order_seq_cst를 강제해도 그 차이가 그렇게 크지 않음.
  • ARM 계열의 CPU와 같은 경우 순차적 일관성을 보장하기 위해 CPU의 동기화 비용이 매우 큼.
    -> 따라서 정말 꼭 필요할 때만 사용해야 함.
profile
이것저것 개발하는 것 좋아하지만 서버 개발이 제일 좋더라구요..

0개의 댓글