[Multicore] Race Condition과 Locks

kindof

·

2022. 3. 11. 17:37

1. 들어가면서

멀티쓰레딩에서 Race Condition과 Lock은 가장 기초적이면서 중요한 개념입니다. 멀티쓰레딩이라는 개념을 단순하게 정의해봐도, 여러 개의 쓰레드가 공유되는 자원을 바탕으로 분산 처리를 하는 것이기 때문입니다.

 

그래서 이번 포스팅은 운영체제 시간에 배웠던 Race Condition과 Lock 개념을 되짚어보고, 코드를 통해 좀 더 구체적으로 공부해보려고 합니다. 

 

 

먼저 아래는 Race Condition과 Lock의 정의입니다.

 

[1] Race Condition

 
A race condition occurs when two threads access a shared variable at the same time. The first thread reads the variable, and the second thread reads the same value from the variable.

 

[2] Lock

In computer science, a lock or mutex (from mutual exclusion) is a synchronization primitive: a mechanism that enforces limits on access to a resource when there are many threads of execution

 

Race Condition이란, 두 개 이상의 쓰레드가 공유 메모리에 있는 동일한 자원에 접근하려는 상황을 의미합니다.

 

이런 상황에서는 공유된 자원에 대한 Read, Write 동작의 결과가 일관된 결과를 보장해주지 않을 수 있다는 문제점이 있습니다.

 

한편, Lock(Mutex)은 위와 같은 Race Condition에서 발생하는 문제를 해결하기 위한 개념이자 도구입니다. 즉, 두 개 이상의 쓰레드가 '동시에' 자원에 접근하지 못하도록 제어 역할을 하는 것인데요.

 

실제 코드를 보면서 구체적인 이야기를 해보겠습니다.

 

 

2. Race Condition으로 인한 문제

아래 코드는 크기가 N인 배열에 0을 채워넣고, 해당 배열에서 0의 개수를 세는 간단한 코드입니다.

 

counter.cpp

#include <stdlib.h>
#include <stdio.h>
#include <iostream>

using namespace std;

int main(int argc, char *argv[])
{
    const int N = atoi(argv[1]);
    int *array = new int[N];
    for (int i = 0; i < N; i++)
    {
        array[i] = 0;
    }
    int count = 0;
    for (int i = 0; i < N; i++)
    {
        if (array[i] == 0)
        {
            count++;
        }
    }
    cout << "There are " << count << " of zeros" << endl;
}

위 코드를 컴파일하고, N = 100일 때, N = 1,000,000일 때로 실행시켜보겠습니다. 위 코드는 싱글 프로세스를 기반으로 작성된 것이기 때문에 문제없이 결과가 나오는 것을 확인할 수 있습니다.

SUCCESS

 

자, 그렇다면 멀티쓰레드를 통해 배열에 있는 0의 개수를 세는 프로그램을 작성해보겠습니다.

#include <iostream>
#include <vector>
#include <thread>

using namespace std;

void worker(int* input, int start, int size, int* output)
{
    for (int i = 0; i < size; i++)
    {
        if (input[start + i] == 0)
        {
            (*output)++;
        }
    }
}

int main(int argc, char *argv[])
{
    const int N = atoi(argv[1]);
    const int NT = atoi(argv[2]);
    int *array = new int[N];
    for (int i = 0; i < N; i++)
    {
        array[i] = 0;
    }

    int count = 0;
    vector<thread> threads;
    for (int t = 0; t < NT; t++)
    {
        int size = N / NT;
        int start = t * size;
        threads.push_back(thread(worker, array, start, size, &count));
    }

    for (auto& thread:threads)
    {
        thread.join();
    }

    cout << "There are " << count << " of zeros" << endl;
}

NT는 'Number of Threads'로 아래에서 4개의 쓰레드를 생성할 예정입니다. 코드를 간략히 살펴보면, 맨 처음에 배열의 모든 원소를 0으로 초기화하는 부분은 동일하며, thread를 담는 vector 컨테이너를 생성했습니다. 그리고 4개의 쓰레드마다 구간을 설정하여 worker(...)함수를 호출하여 0의 개수를 세도록 하고, 쓰레드의 작업이 끝나면 join() 함수를 호출했습니다.

 

하지만, 위 코드를 실행시켜보면 아래와 같이 원래보다 적은 숫자의 0이 카운트되는 것을 볼 수 있습니다.

 

FAIL

 

이렇게 결과값이 작게 나오는 것은 count 변수에 대한 Race Condition 때문입니다.

Thread1 Count Thread2
  0  
Read count(0)    
    Read count(0)
count++ -> 1    
    count++ -> 1
Write count 1  
  1 Write count

위 표를 보면, Thread1이 count 변수를 1 증가시키기 전에 Thread2가 기존의 count값(=0)을 읽은 뒤 그 값을 기준으로 1을 증가시키기 때문에 총 두 번의 카운트 횟수가 한 번으로 취급되는 것입니다.

 

지금까지 이야기한 것을 정리해보면, Race Condition은 쓰레드의 실행 순서에 따라 결과가 달라지는 것을 말한다고 할 수 있습니다.

 

 

3. Lock(Mutex)을 이용해서 문제 해결하기

이제 Mutex(Lock)을 이용해서 이 문제를 해결해보겠습니다.

 

Mutex는 Race Condition으로 인한 간섭을 방지하기 위해 사용되는 하나의 값(Value)인데요. 이를 통해 한 번에 하나의 쓰레드만 Mutex에 대해 Lock을 걸고, 다른 쓰레드들은 해당 Mutex가 Unlock되기 전까지 자원을 이용하지 못하도록 하는 것입니다.

 

Using Mutex(lock_guard)

Mutex를 사용하기 위해 위와 같이 코드를 수정했습니다. worker 함수 안에서 increase 함수를 다시 호출하는데요. increase 함수를 보면 lock_guard라는 개념이 등장합니다.

 

lock_guard는 함수가 호출되서 생성되는 순간 Mutex에 대해 Lock을 걸고, 끝나는 시점에서 Unlock을 해주는 녀석입니다. 이를 통해 아래와 같이 실수로 Unlock을 하지 않는 실수를 방지할 수 있습니다. 참고로 아래 코드도 완전히 동일한 기능을 합니다.

 

lock_guard 없이 구현한 코드

최종적인 코드는 아래와 같고, 똑같이 실행시켜보겠습니다.

#include <iostream>
#include <vector>
#include <thread>

using namespace std;

mutex global_mutex;

void increase(int* output){
    lock_guard<mutex> guard(global_mutex);
    (*output)++;
}

void worker(int* input, int start, int size, int* output)
{
    for (int i = 0; i < size; i++)
    {
        if (input[start + i] == 0)
        {
            increase(output);
        }
    }
}


int main(int argc, char *argv[])
{
    const int N = atoi(argv[1]);
    const int NT = atoi(argv[2]);
    int *array = new int[N];
    for (int i = 0; i < N; i++)
    {
        array[i] = 0;
    }

    int count = 0;
    vector<thread> threads;
    for (int t = 0; t < NT; t++)
    {
        int size = N / NT;
        int start = t * size;
        threads.push_back(thread(worker, array, start, size, &count));
    }

    for (auto& thread:threads)
    {
        thread.join();
    }

    cout << "There are " << count << " of zeros" << endl;
}

SUCCESS

 

 

이렇게 이번 포스팅에서는 실제 코드를 가지고 Race Condition과 Lock에 대해 공부해봤습니다.

 

Thread-Safe한 코드를 짜기 위해서는 위와 같이 언제 Race Condition이 발생하고, 어느 부분에 lock을 걸어야 하는지에 대한 이해가 필수적이기 때문에 잘 알아둬야 할 것 같습니다.

 

감사합니다.