캐시라인 크기 경계 Cache Line Size Boundary
CPU의 메모리 갱신은 언제나 한 틱에 일어날까? 싱글스레드 프로그램이라면 이러한 문제를 생각 할 필요가 없다. 완전히 갱신되지 않으면 다음 라인이 실행되지 않기 때문이다. 하지만 멀티스레드 프로그램일 경우 큰 메모리 공간이 완전히 갱신되지 않았을 때 다른 스레드가 그 메모리의 값을 읽어갈 수 있기 때문에 이 문제는 중요하다. 이 장에서는 캐시라인 크기 경계(Cache Line Size Boundary)에 대해 알아보자.
원본 소스코드
#include <iostream>
#include <chrono>
#include <thread>
using namespace std;
using namespace chrono;
volatile bool IsDone = false;
volatile int* pBound = nullptr;
int error = 0;
int g_arr[64];
void threadFunc0()
{
for(int i = 0; i <= 100000000; ++i)
{
*pBound = -(1 + *pBound);
}
IsDone = true;
}
void threadFunc1()
{
while(!IsDone)
{
int value = *pBound;
if(value == 0 || value == -1)
++error;
}
}
void main()
{
int nPtr = (int)&g_arr[32];
nPtr = nPtr & 0xFFFFFFC0;
nPtr -= 2;
pBound = (int*)nPtr;
*pBound = 0;
thread t1 = thread{ threadFunc0 };
thread t2 = thread{ threadFunc1 };
t1.join();
t2.join();
cout << "에러 횟수: " << error << endl;
getchar();
}
void threadFunc0()
메서드에서는 1억번간 pBound위치의 값을 0이나 -1로 바꾼다. 이 작업의 의미는 메모리의 모든 값을 0으로 했다가 F로 했다가를 반복하는 것이다. 1억번의 작업이 끝난 뒤에는 IsDone의 값을 true로 설정한다.
void threadFunc1()
메서드에서는 threadFunc0()
이 작업중인 순간동안 수시로 pBound위치의 값을 읽어와 0이나 -1이 아닌 순간의 에러카운트를 증가시킨다.
main()
메서드의 윗부분에는 g_arr[]의 임의위치를 가리키도록 pBound위치를 설정하고, pBound위치의 값을 0으로 설정한다.
pBound위치를 결정하는 부분을 조금 더 설명하면 다음과 같다.
- g_arr[3]의 주소값을 nPtr에 대입
- nPtr의 값을 64의 배수가 되도록 &마스크 연산 (캐시라인의 크기는 64byte이기 때문)
- nPtr의 값을 -2하여 포인터 위치를 살짝 이동 (int의 크기는 4byte이기 대문에 -2만큼 밀어 int의 중간지점을 가리키도록 함)
- nPtr의 값을 포인터로 캐스팅하여 pBound가 가리키도록 하고 그 위치의 값을 0으로 초기화
CPU의 메모리 갱신이 언제나 한 틱안에 전부 일어난다면 error값은 항상 0일것이다. 그러나 실행 결과는 다음과 같다.
1억번 동안 741,983번. 즉 0.74%의 확률로 에러가 발생했다. 이로 인해 멀티스래드 프로그램에서 큰 메모리 공간은 한 틱 안에 갱신되지 않을 수 있다는것을 확인했다.
원인 분석
조금 더 자세히 알아보기 위해 에러가 발생했을 때의 value값을 출력했다.
에러 횟수가 압도적으로 줄어든 것은 콘솔창 출력에 의한 오버헤드로 무의미하다.
value가 0이나 -1이 아닌 경우는 65535와 -65536 딱 두가지 경우 뿐이다. 아까 위에서 pBound값을 -2하여 int의 중앙으로 옮긴것이 기억나는가? 이 값을 -1이나 -3으로 바꾼다면 다른 결과가 출력될 것이다.
포인터 위치 | int 값 | 2진수 표현 |
---|---|---|
-1 | 255 | 0000 0000 0000 0000 0000 0000 1111 1111 |
-1 | -256 | 0000 0000 1111 1111 1111 1111 1111 1111 |
-2 | 65535 | 0000 0000 0000 0000 1111 1111 1111 1111 |
-2 | -65536 | 1111 1111 1111 1111 0000 0000 0000 0000 |
-3 | 16,777,215 | 1111 1111 1111 1111 1111 1111 0000 0000 |
-3 | -16,777,216 | 1111 1111 0000 0000 0000 0000 0000 0000 |
이제 어느정도 감이 잡힐 것이다. pBound를 밀어둔 만큼 0과 1이 바뀌어가는 지점이 미처 갱신되지 못한 체 노출되는 것이다.
위 그림은 g_arr[64]의 메모리를 그림으로 표현한 것이다. 한 줄은 CPU의 캐시라인으로 64byte의 크기를 가진다. 예제의 소스코드대로 -2만큼 pBound를 밀었다면 pBound는 앞 캐시라인의 62byte 위치부터 다음 캐시라인의 2byte까지를 가리키고 있는 것이다. 이 부분에서 발생하는 오차는 g_arr가 아직 갱신중인 상태를 의미하며, 이것으로 캐시라인을 초과하는 메모리는 한번에 갱신되지 않는다는 사실을 확인할 수 있다.
이러한 문제에 대비하기 위해 x86계열의 CPU에서는 다음 사항을 유의해야 한다.
- byte인 경우는 믿을 수 있다.
- 값 타입인 경우 컴파일러가 캐시라인에 걸치지 않도록 컴파일하여 문제가 발생하지 않는다.
- 포인터는 결코 믿을 수 없다.