캐시라인 크기 경계 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위치를 결정하는 부분을 조금 더 설명하면 다음과 같다.

  1. g_arr[3]의 주소값을 nPtr에 대입
  2. nPtr의 값을 64의 배수가 되도록 &마스크 연산 (캐시라인의 크기는 64byte이기 때문)
  3. nPtr의 값을 -2하여 포인터 위치를 살짝 이동 (int의 크기는 4byte이기 대문에 -2만큼 밀어 int의 중간지점을 가리키도록 함)
  4. 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인 경우는 믿을 수 있다.
  • 값 타입인 경우 컴파일러가 캐시라인에 걸치지 않도록 컴파일하여 문제가 발생하지 않는다.
  • 포인터는 결코 믿을 수 없다.

조금 더 알아보기 - 마스크 연산