데이터 레이스 Data Race

데이터 레이스는 멀티스레드 프로그래밍에서 제법 흔히 알려진 문제중 하나이다. 이 장에서는 데이터 레이스 발생상황을 제현하고 문제를 해결하기 위한 방법을 다룬다.

원본 소스코드

#include <iostream>
#include <vector>
#include <chrono>
#include <thread>
using namespace std;
using namespace chrono;

volatile int g_sum = 0;

void threadFunc(int threadNum)
{
    for(int i = 0; i < 50000000 / threadNum; ++i)
        g_sum += 2;
}

void main()
{
    vector<thread> threads;
    for(int i = 1; i <= 16; i *= 2)
    {
        g_sum = 0;
        threads.clear();
        auto startTime = high_resolution_clock::now();

        for(int j = 0; j < i; ++j)
            threads.push_back(thread{threadFunc, i});

        for(auto &t : threads)
            t.join();

        auto deltaTime = high_resolution_clock::now() - startTime;
        cout << i << "Threads.\t" << duration_cast<milliseconds>(deltaTime).count() << "ms\tSum = " << g_sum << endl;
    }
    getchar();
}

이 실험은 스래드를 N개 사용하여 1억까지 덧셈을 하는 프로그램이다. main()의 첫번째 for문은 스레드의 수를 의미하며 스레드는 2의 배수로 증가하게 된다. threadFunc에서는 5천만을 스레드의 수로 나눈 횟수만큼 2를 더하게 되는데 프로그램이 정상이라면 스레드 수에 관계없이 g_sum = 1억이 되어야 한다. 하지만 프로그램의 실행 결과는 우리의 기대와 달랐다.

스레드의 수가 증가할 수록 걸린 시간이 줄어드는 것은 좋다. 하지만 싱글스레드의 경우를 제외하고 나머지는 1억이 출력되지 않았다. 속도가 빨라져도 엉뚱한 결과를 출력하는 프로그램은 필요가 없다.

원인 분석

문제의 원인은 g_sum += 2;에 있다. 이 코드를 동시에 여러 스레드가 동시에 수행하려고 하기 때문에 다른 스레드가 더해놓은 값이 누적되지 않는 문제가 발생한다. 이 코드는 우리가 볼 때는 한줄이라 끼어들 곳이 없어보이지만 어셈블리라면 상황이 다르다.

싱글스레드 상황에서 CPU의 동작과 그 동작으로 인한 메모리 변화를 도식화 한 그림이다. 도식화 한 CPU는 g_sum += 2;를 수행하기 위해 다음 3단계를 거친다.

순서 CPU 동작 메모리 상황
1 메모리 읽기 이 때 200이 들어있었다고 가정한다.
2 값 변경 아직 메모리에 값이 반영된 것은 아니다.
3 메모리 쓰기 이제야 200 + 2의 결과인 202가 반영되었다.

이러한 일련의 과정은 싱글스레드에서는 정상적으로 동작한다. 그러나 멀티스레드라면 상황이 다르다.

순서 1번 스레드 동작 2번 스레드 동작 메모리 상황
1 메모리 읽기 마찬가지로 200이 들어있다고 가정한다.
2 값 변경 메모리 로드 2번 스레드는 메모리에 들어있는 값 200을 로드한다.
3 메모리 쓰기 값 변경 1번 스레드에 의해 202가 반영된다.
4 메모리 쓰기 2번 스레드는 자신의 계산 결과인 202를 반영한다.

최종적으로 g_sum = 1억이 되지 못하는 이유는 복수의 스레드가 같은 메모리 공간에 값을 쓰면서 발생하게 되며, 이러한 현상을 데이터 레이스 (Data Race)라고 한다.

조금 더 알아보기 - 1

필자의 컴퓨터 환경은 Intel® Core™ i7-2600 CPU @ 3.40GHz, 3401Mhz, 4코어, 8 논리 프로세서이다.

이 정보는 Windows 7의 경우 시작 > 실행 > msinfo32 에서 확인할 수 있다.

위 실행 결과에서는 4Threads일때 총 합이 31751392로 가장 적었는데, 이것으로 미루어 봤을 때 4스레드에서 경헙이 가장 많이 발생했다는 것을 알 수 있다. 이는 필자의 코어 수와 일치한다.

또한 스레드의 수가 증가할 수록 걸린 시간이 줄어드는 것은 좋다. 라고 써놨지만 사실 8Threads 에서 처리 속도가 오히려 증가하였다. 눈치 채었는가? 멀티 코어에 논리 프로세서 수가 많다고 하더라도 CPU가 각 스레드를 번갈아가며 처리하는데는 문맥 전환 Context switching이 필요하며 이는 오버헤드를 발생시킨다. 문맥 전환으로 인한 오버헤드는 8스레드에서 가장 심하게 발생하였으며, 이는 필자의 논리 프로세서 수와 일치한다.

Lock을 사용하여 문제 해결

Lock은 데이터 레이스문제를 해결하기 위해 가장 널리 쓰이는 방법이다.

환경 Lock Unlock
C++11 mutex.lock() mutex.unlock()
Windows EnterCriticalSection() LeaveCriticalSection()
Linux pthread_mutex_lock() pthread_mutex_unlock()

여기서 필자는 C++11에서 표준으로 제공하는 mutex를 사용하여 데이타 레이스 문제를 해결하고자 한다.

void threadFunc(int threadNum)
{
    for(int i = 0; i < 50000000 / threadNum; ++i)
    {
        g_lock.lock();
        g_sum += 2;
        g_lock.unlock();
    }
}

스레드 수에 관계없이 모든 값이 1억으로 정상 처리되었다. 그럼 이것으로 모든 문제가 끝난 것일까? 처리시간을 자세히 보자.

스레드 수 No lock (ms) Lock (ms) No lock과 비교
1 107 2420 22.62배 느려짐
2 54 11539 213.69배 느려짐
4 38 9198 242.05배 느려짐
8 41 10755 262.32배 느려짐
16 37 241655 6531.22배 느려짐

수 백배에서 수 천배까지 느려졌다. 만약 모든 프로그래밍을 이렇게 해야 한다면 멀티스레드 프로그래밍을 하는 이유가 없을 것이다. Lock은 결과를 보장하기는 하지만 그에 걸맞는 오버헤드를 발생시킨다.

원자적으로 프로그래밍하여 문제 해결

원자성 Atomic이란 더이상 쪼개지지 않는 최소 단위를 의미하며, 멀티스레드 프로그래밍에서 매우 중요한 성질중 하나이다. 소스코드의 g_sum += 2;는 어셈블리로 봤을 때 여러단계로 동작하는 일련의 과정이었다. 이를 원자적으로 프로그래밍하여 다른 스레드의 개입을 막는다면 데이터 레이스 문제는 해결될 것이다.

void threadFunc(int threadNum)
{
    for(int i = 0; i < 50000000 / threadNum; ++i)
        _asm lock add g_sum, 2
}

위 내용은 어샘블리 블록을 사용하여 코딩하였으며 디스어셈블리로 봐도 한줄이라 반드시 원자적으로 동작한다. 실행 결과는 다음과 같다.

스레드 수 No lock (ms) Lock (ms) _asm Atomic (ms) No lock과 비교
1 107 2420 331 3.09배 느려짐
2 54 11539 942 17.44배 느려짐
4 38 9198 1150 30.26배 느려짐
8 41 10755 1217 29.68배 느려짐
16 37 241655 1219 32.95배 느려짐

결과는 스레드 수에 상관없이 정확히 1억이 출력되었으며, 시간도 3배에서 수십배 느려진것에 불과했다. No lock과 비교하면 느려지기는 했지만 Lock을 사용한 수천배 느려지는 결과보다는 만족스럽다. 그러나 실제 프로그래밍에서 모든 데이터 레이스 발생부분을 찾아내기도 어렵고, 찾아낸다 하여도 예제와 같이 쉽게 원자적으로 바꿀 수 있는것도 아닐 것이다. 결국 이 방법 또한 근본적인 해결방법은 아니다.

리팩터링하여 문제 해결

Lock으로 해결하는 방법은 엄청난 오버해드가 생기고, Atomic으로 해결하는 방법은 실제 적용하가 어려웠다. 그렇다면 데이터 레이스를 효과적으로 해결할 수 있는 방법은 무엇일까? 다음 코드를 보자.

void threadFunc(int threadNum)
{
    volatile int local_sum = 0;

    for(int i = 0; i < 50000000 / threadNum; ++i)
        local_sum += 2;

    g_lock.lock();
    g_sum += local_sum;
    g_lock.unlock();
}

lock으로 문제를 해결해던 소스코드로 돌아가 threadFunc(...)함수를 다음과 같이 리팩터링 하였다. 스레드 지역 변수에서 원자적이지 않은 계산을 수행한 뒤, 데이터 레이스가 발생하는 전역변수에 더하는 부분만 lock을 사용하여 처리하고 있다.

스레드 수 No lock (ms) Lock (ms) _asm Atomic (ms) Refactoring (ms) No lock과 비교
1 107 2420 331 109 1.02배 느려짐
2 54 11539 942 54 1.00배 느려짐
4 38 9198 1150 29 0.76배 느려짐
8 41 10755 1217 28 0.68배 느려짐
16 37 241655 1219 28 0.76배 느려짐

결과값이 정확할 뿐만 아니라 스레드 수 증가에 따라 싱글스레드보다 처리속도가 빨라지는 쾌거를 이루었다.

이처럼 데이터 레이스 문제를 해결하려면 지금까지 프로그래밍과는 다른 구조적인 변경이 필요하다. 멀티스레드 프로그래밍은 어렵지만 CPU의 논리 코어 수 증가에 따라 프로그램 변경 없이 성능 향상을 누릴 수 있는 점은 대단히 매력적이지 않을 수 없다.

조금 더 알아보기 - 2

위 결과에서 8Threads보다 16Threads의 결과가 오히려 느린것을 확인할 수 있다. 이는 위에서 알아본 것과 마찬가지로 문맥 전환으로 인한 오버헤드이다. 이처럼 지나치게 많은 스레드 수는 오히려 처리속도를 저하시키며, 멀티스레드 프로그래밍을 할 때 스레드의 수는 현재 컴퓨터의 논리 코어수를 고려해야 할 것이다.

조금 더 알아보기 - 3

리팩터링하여 문제를 해결한 코드에서 지역변수가 volatile이 아니라면 어떻게 될까? 일행 결과는 다음과 같다.

디스어셈블리를 확인하기 위해 local_sum += 2;에 브레이크 포인트를 걸면 해당 코드를 건너 뛰고 g_locka.lock();에서 멈추는것을 볼 수 있는데 이는 컴파일러 최적화 때문이다. 컴파일러 최적화는 무의미하다고 판단되는 변수를 실제 메모리에 생성하지 않고 바로 계산하여 사용한다. 따라서 이 예제의 해결방법을 보여주기 위해서 컴파일러 최적화를 무시했다. 그렇다고 이 예제의 해결방법이 무의미한 것은 아니다. 실제로 데이터 레이스의 해결방법은 기획적, 설계적으로 데이터 레이스 구간을 최소화 하고, 그를 위해 스레드 로컬 리소스를 가능한 많이 활용하여 경합 부분을 최소화 하는것이기 때문이다.