멀티쓰레드 환경에서 공유되는 자원에 대한 문제와 Lock

쓰레드는 개별적으로 자기만의 스택 영역을 갖지만, 같은 프로세스 내에서 힙이나 데이터 영역 등은 공유하기 때문에, 쓰레드 간에 동일한 변수에 접근하게 될 수 있다.

예를 들어, 쓰레드들이 실행할 함수가 counter 라는 이름의 전역 변수를 증가시키는 쓰레드라고 하면, 이는 전역변수이기 때문에 모든 쓰레드들이 공유한다.

#include <stdio.h>
#include <pthread.h>

static volatile int counter = 0;

void* test(void* arg)
{
    printf("%s: begin\n", (char*)arg);
    int i;
    for(i = 0 ; i < 10000 ; i++)
    {
        counter += 1;
    }
    printf("%s: end\n", (char*)arg);
    return NULL;
}

int main(int argc, char* argv[])
{
    pthread_t t1, t2;
    printf("main: begin, counter = %d\n", counter);
    pthread_create(&t1, NULL, test, "T1");
    pthread_create(&t2, NULL, test, "T2");
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    printf("main: end, counter = %d\n", counter);
    return 0;
}

counter 변수는 전역변수로써 공유되지만, 각각의 쓰레드가 루프를 돌 때(함수 test) 사용하는 지역변수 i 는 스택에 배치되기 때문에 공유되지 않는다.

따라서 생성되는 모든 쓰레드들은 반드시 10000번씩 반복문을 돈다는 사실은 변하지 않는다.

하나의 쓰레드가 만번 루프를 돌며 카운터를 증가시키기 때문에 두개의 쓰레드를 실행시키면 카운터 값이 2만까지 증가될 것이라고 기대할 수 있지만, 실제로는 2만에 못미치는 결과가 나온다.

bconfiden2@h01:~/blog/_posts/system$ ./a.out
main: begin, counter = 0
T1: begin
T2: begin
T1: end
T2: end
main: end, counter = 18158
bconfiden2@h01:~/blog/_posts/system$ ./a.out
main: begin, counter = 0
T1: begin
T2: begin
T1: end
T2: end
main: end, counter = 11012
bconfiden2@h01:~/blog/_posts/system$ ./a.out
main: begin, counter = 0
T1: begin
T2: begin
T1: end
T2: end
main: end, counter = 15167

심지어 실행시킬 때 마다 다른 결과가 나오기도 한다.

이런 현상이 발생하는 이유는, counter 변수의 값을 1 증가시키는 부분의 코드 counter +=1이 atomic 하게 이루어지지 않기 때문이다.

고급 언어인 C의 코드 상으로는 1줄이기 때문에 아토믹해 보이지만, 컴파일되어 실행될 때의 어셈블리/기계어 코드로 봤을 때는 크게 3가지 과정을 거친다.

  1. counter 변수의 값을 메모리에서 읽어서 레지스터로 LD
  2. 레지스터의 값을 1 증가
  3. 갱신된 레지스터 값을 counter 변수의 메모리에 ST

만약에 어떤 쓰레드가 명령어를 실행하면서 갱신된 값을 메모리에 쓰기 전에 인터럽트가 걸림으로써 다른 쓰레드로 컨텍스트 스위칭이 되었다면, 바뀐 쓰레드는 기존의 상태를 모르기 때문에 갱신되지 않은 값을 메모리에서 읽어오게 된다.

심지어는 다른 쓰레드들이 열심히 작업해서 변수의 값을 증가시켜놨다고 할지라도, 다시 기존 쓰레드로 돌아올 경우에는 문맥 교환 과정에서 이전의 레지스터값들이 복원되어, 자기가 쓰려고 하던 레지스터값으로 다시 날려버린다.

이러다 보니 멀티쓰레드 환경에서 동일한 자원에 접근하는 경우에는, 우리가 기대하던대로 실행되지 않는 것이다.

이런 현상들은 아래와 같은 용어를 사용해서 정의할 수 있다.

- critical section : 공유되는 리소스(변수나 자료구조 등)에 접근하는 부분의 코드 조각(예시에서의 counter += 1)
- race condition : 여러 쓰레드가 크리티컬 섹션을 경쟁적으로 실행하다보니, 동시에 상태를 변화시키려고 하는 상황
- indeterminate : 경쟁조건이 발생하는 프로그램에서, 결과는 결정적이지 않다는 현상(실제로 실행시킨 결과 18158 - 11012 - 15167 처럼 다르게 나옴)
- mutual exclusion : 프로그램을 쓰레드별로 상호 배제적이게끔 만들어서 이런 현상이 발생하지 않도록 하는 방식(한 순간에 하나의 쓰레드만이 크리티컬 섹션을 실행)

마지막의 mutual exclusion 을 위해서(경쟁 조건이 발생하지 않도록 하기 위해서), Lock 이라는 개념을 만들어서 도입할 수 있다.

쓰레드들이 크리티컬 섹션 부분의 코드에 진입할 때, 락을 걸어버림으로써 추후에 다른 쓰레드들이 접근하지 못하고 대기하도록 하게 만드는 것이다.

T1 쓰레드가 크리티컬 섹션에 접근하여 명령어들을 수행하고 있는 동안에는 T2 쓰레드는 들어오지 말고 밖에서 기다림으로써 경쟁 조건을 해결하는게 락의 원리이다.

그렇다면 당연히 T1 은 자신이 할 일을 마치면 락을 풀어줘야 하며, T2 쓰레드는 락이 풀리게 되면 그제서야 크리티컬 섹션에 진입함으로써 자기가 또 락을 걸어서 안전하게 수행한다.

누군가가 구현해놓은 락이 라이브러리로 제공되고 있으며, 크리티컬 섹션 영역 앞뒤로 lock, unlock 으로 감싸주면 락을 걸 수 있다.

// ...
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;

void* test(void* arg)
{
    // ...
    for(i = 0 ; i < 10000 ; i++)
    {
        pthread_mutex_lock(&lock);
        counter += 1;
        pthread_mutex_unlock(&lock);
    }
    // ...
}
bconfiden2@h01:~/blog/_posts/system$ ./a.out
main: begin, counter = 0
T1: begin
T2: begin
T1: end
T2: end
main: end, counter = 20000

락이 걸림으로써 최종 카운터값이 기대하던대로 20000 으로 출력되는 걸 볼 수 있다.