Future 같은 경우에는 단발성으로 실행되는 이벤트에 쓰기에 굉장히 좋다.
예를들어
int64 Calculate()
{
int64 sum = 0;
for (int32 i = 0; i < 100'000; i++)
sum += i;
return sum;
}
이렇게 더하는 함수가 있다고 가정을 해보고 이 작업이 굉장히 오래 걸린다고 가정을 해보자.
이것을 우리는 비동기로 어떻게 처리할 수 있을까?
thread t(Calculate);
t.join();
이런식으로 쓰레드를 사용해서 처리할 수 있다. 하지만 우리의 가정은 Calculate 오래걸리고 단발성인 Calulate 함수를 처리하기위해 lock을 걸고 condition variable을 쓰고 하는 귀찮은 작업보다 조금더 쉽게 작업할 수 있는 방법이 있다.
std::future<int64> future = std::async(std::launch::async, Calculate);
//다른 코드가 돌아감
int64 sum = future.get(); //결과물이 이제서야 필요하다.
위 코드처럼 future 라는 객체를 사용하면 된다. 그리고 특정 코드가 실행이 완료되면 future.get으로 결과 값을 가져올수 있다.
future의 사용법은 다음과 같다.
만약 당장 결과물이 필요 없다고 했을 때 async 가 타게 되면 비동기 적으로 처리 할 수 있다.
1) deferred -> lazy evealuation 지연실행
2) async -> 별도의 쓰레드를 통해서 실행
3) deferred | async -> 둘 중 알아서 골라서 실행
캐시의 철학
1) Temporal Locality
시간적으로 보면, 방금 주문한 테이블에서 추가 주문이 나올 확률이 높다.
방금 주문한걸 메모해 놓으면 편하지 않을까?
2) Spatial Locality
공간적으로 봤을때, 방금 주문한 사람 근처에 있는 사람이 추가 주문을 할 확률이 높다.
방금 주문한 사람과 합석하고 잇는 사람들의 주문 목록도 메모해 놓으면 편하지 않을까?
다음과 같은 코드가 있다고 가정해보자
int buffer[10000][10000];
int main()
{
memset(buffer, 0, sizeof(buffer));
{
int sum = 0;
int start = GetTickCount64();
for (int i = 0; i < 10000; i++)
for (int j = 0; j < 10000; j++)
sum += buffer[i][j];
int end = GetTickCount64();
cout << (end - start) << endl;
}
{
int sum = 0;
int start = GetTickCount64();
for (int i = 0; i < 10000; i++)
for (int j = 0; j < 10000; j++)
sum += buffer[j][i];
int end = GetTickCount64();
cout << (end - start) << endl;
}
}
일단 memset 함수를 통해 배열을 캐싱을하였다.
코드를 실행 시켜보면 위에 있는 블록과 밑에 있는 블록의 실행시간의 차이가 꽤 난다.
위에 있는 블록이 3배정도 빠르다.
2차원 배열을 풀어놓으면 아래처럼 된다는 것은 누구든지 알것이다.
[1.1][1.2][1.3][1.4][1.5][1.6] //1
[2.1][2.2][2.3][2.4][2.5][2.6] //2
[3.1][3.2][3.3][3.4][3.5][3.6] //3
위에 있는 블록은 buffer[i][j]로 바로 옆에있는 배열 구조상 1.1, 1.2 이런식으로 바로 옆에 있는 인접한 배열의 원소에 접근을 한다.
하지만 밑에 있는 블록은 buffer[j][i]로 1.1, 2.1 이런식으로 배열에 접근을 한다. 바로 옆에있는 데이터를 꺼내쓰는게 아니라 j 를 먼저 꺼내쓴다. 캐시는 기껏 바로 옆에 있는 데이터들끼리 사용할거라고 했는데 이 경우는 띄엄띄엄 꺼내서 쓰기때문에 실행시간이 위 블록보다 당연히 느려질 수 밖에 없다.
CPU 는 사용하세요 하고 바로 사용되는게 아니라,
Fetch, Decode, Execute, Write-back 4개의 영역으로 나뉜다.
1. Fetch 명령어를 가지고 옴
2. Decode 명령어를 해독
3. Execute 실행
4. Write-back 결과를 반환
이렇게 나눠진다.
CPU 파이프라인이 멀티쓰레드 환경에서는 실행시킨 순서대로 작업을 처리하는게 아니라 특정 작업이 오래 걸리면 그 작업은 pending이 되고 빠르게 처리할 수 있는 작업부터 수행하게 된다.
캐시나 CPU 파이프라인을 통해서 우리가 멀티쓰레드 환경이면 코드가 항상 우리가 짠 대로 돌아가지 않을수도 있다는 것을 알았다.
예를 들어
int x = 0;
int y = 0;
void Thread_1(){
x = 10;
y = 0;
}
void Thread_1(){
x = 0;
y = 10;
}
int main () {
while (true) {
x = y = 0;
thread t1(Producer);
thread t2(Consumer);
t1.join();
t2.join();
if(x == 0 && y == 0) {
break;
}
}
}
이 무한루프는 논리적으로 절대로 끝날 수 없다. 왜냐하면 x가 10이 되면 y는 0이 되고 y가 10이 되면 x는 0이 되기 때문에 절대로 끝날 수 없다. 하지만 실행을 해보면 무한루프가 종료가 된다.
왜냐하면 이 동작이 원자적으로 실행되지 않기 때문이다. 컴파일러가 동작을 수행하면서 CPU 파이프라인 이라는 이론을 통해 코드를 재배치 시킨다.
그래서 우리는 atmoic 라이브러리를 사용하여 해결할 수 있다. (컴파일러에게 코드 최적화를 하지 말라고 지정할수 있음.)
atomic<bool> ready;
ready = false;
void Producer()
{
value = 10;
ready.store(true, memory_order_seq_cst);
}
void Consumer()
{
while (ready.load(memory_order_seq_cst) == false)
;
cout << value << endl;
}
int main()
{
ready = false;
value = 0;
thread t1(Producer);
thread t2(Consumer);
t1.join();
t2.join();
// Memory Model (정책)
// 1. Sequentially Consistent (가장 엄격) => 코드 재배치 해결
// 2. Acquire-Release => release 명령 이전의 메모리 명령들이 해당 명령 이휴로 재배치 되는것을 금지
// 3. Relaxed (자유롭다 => 컴파일러 최적화 여지가 많음) => 코드 재배치 컴파일러가 가능
}
말 그대로 Thread 마다 각자 가지고 있는 로컬 저장소이다.
사용법은 아래와 같다.
thread_local int32 LThreadId = 0;
void ThreadMain(int32 threadId)
{
LThreadId = threadId;
while (true)
{
cout << "I'm Thread" << LThreadId << endl;
this_thread::sleep_for(1ms);
}
}
int main()
{
vector<thread> threads;
for(int32 i = 0; i < 10; i++)
{
int32 threadId = i + 1;
threads.push_back(thread(ThreadMain, threadId));
}
for(thread& t: threads)
{
t.join();
}
}
thread_local을 통해서 사용할수 있는데, 코드를 실행해 보면 각각의 쓰레드 아이디가 1에서 10까지 부여된것을 확인할수 있다.