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에서 명령어를 실행할 때:
fetch
(명령어 읽음), decode
(읽은 명령어가 뭔지 해석), execute
(해석된 명령어를 실행), write
(결과를 씀)의 과정을 거침.
그런데 예컨데, 세탁이나 빨래 개기는 30분인데 건조가 3시간이나 걸려서 건조기 기다리느라 빨래를 계속할 수 없다면, 비효율적일 수 있다.
따라서 컴파일러는 CPU의 파이프라인을 효율적으로 활용할 수 있도록 명령어를 재배치함.
또한 컴파일러가 아니더라도, 캐시 상 더 빨리 처리할 수 있는 걸 CPU에서 먼저 실행할 수도 있음.
수정 순서: 만약 어떤 객체의 값을 실시간으로 확인할 수 있는 전지전능한 무언가가 있다고 했을 때, 해당 객체의 값의 변화를 기록한 것
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
함수로 알 수 있음.
atomic 객체들은 원자적 연산 시, 메모리 접근 방식을 지정 가능.
가장 느슨한 조건.
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 (쓰기)
#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();
}
}
memory_order_release
: 해당 명령 이전의 모든 메모리 명령들이 해당 명령 이후로 재배치되는 것을 금지함.memory_order_acquire
으로 읽는 쓰레드가 있으면, memory_order_release
이전에 오는 모든 메모리 명령들이 해당 쓰레드에 의해서 관찰될 수 있어야 함. (❓)따라서 이 두 개의 다른 스레드들이 같은 변수의 release, acquire 를 통해 동기화(synchronize)를 수행하게 됨.
acquire와 release를 모두 수행하는 것.
읽기, 쓰기를 모두 수행하는 명령들 예를 들어 fetch_add와 같은 함수 속에서 사용될 수 있음.
메모리 명령의 순차적 일관성을 보장.
메모리 명령 재배치도 없고, 모든 쓰레드에서 모든 시점에 동일한 값을 관찰할 수 있는 방식