[서버-6] 멀티스레드 프로그래밍의 흔한 실수들

#서버 프로그래밍

멀티스레드 프로그래밍시 발생할 수 있는 7가지 패턴


멀티스레드 프로그래밍은 기본적으로 어렵다. 코드의 규모가 커지고 개발에 참여하는 인원이 많을수록 멀티스레드 관련 버그를 찾기 어렵다. 버그를 일으키는 곳과 버그로 오류나는 곳이 서로 다를 때가 많다.

흔히 발생하는 패턴 7가지는 다음과 같다.

1. 읽기와 쓰기 모두에 잠금하지 않기

메모리에 값을 쓰고 있는 도중에 다른 스레드가 그 값을 읽으면 잘못된 값을 읽을 수 있다는 것은 쉽게 인지하지만, 그냥 값을 읽는 것은 잠금을 하지 않더라도 안전하다는 생각이 문제로 발생할 수 있다.

int a;
mutex a_mutex;
 
void func1()
{
  // lock(a_mutex); 누락
  print(a);
}
 
void func2()
{
  lock(a_mutex);
  a = a + 10;
}

위 코드는 기본적으로 충돌이 발생하지 않는다. 그러나 func1 함수에서 a 값을 읽을 때 가끔 정상적이지 않다는 버그가 존재한다. 매우 가끔 발생하는 버그이지만 코드의 규모가 커질수록 해당 버그가 발생할 때 버그의 원인을 찾지 못할 수 있다.

2. 잠금 순서 꼬임

저번 포스트에서 다룬 잠금 순서 규칙을 따르면 문제가 없다.

기억해야 할 것은 다음과 같다.

  1. 교착 상태를 예방하려면 첫 번째 잠금 순서를 지켜야 한다(거꾸로 가지 말아야 한다).
  2. 잠금의 해제 순서는 교착 상태에 영항을 주지 않는다. 해제는 마음대로 해도 된다.
int a;
mutex a_mutex;
 
int b;
mutex b_mutex;
 
void func1()
{
  lock(a_mutex);
  a...;
  lock(b_mutex);
  b...;
}
 
void func2()
{
  lock(b_mutex);
  b...;
  lock(a_mutex);
  a...;
}

위 코드는 문제가 있다. func1 함수a -> b 순서로 잠금을 하지만, func2 함수b -> a 순서로 잠금을 하여 서로 역방항이다. 이럴 경우 교착 상태에 빠질 수 있다.

3. 너무 좁은 잠금 범위

잠금의 범위가 너무 넓으면 컨텍스트 스위치가 발생할 때 운영체제가 할 일이 너무 많아지고, 병렬성도 떨어져서 멀티스레드의 의의가 퇴색되기도 한다.

반대로 잠금 범위를 좁히면 컨텍스트 스위치의 확률이 떨어지기는 하지만 임계 영역 잠금이 컨텍스트 스위치보다는 훨씬 적더라도 단순한 산술 연산보다는 더 많은 처리 시간을 차지한다. 따라서 임계 영역을 적당한 수준으로 나누는 것이 좋다.

class A
{
  int a;
  mutex a_mutex;
 
  int b;
  mutex b_mutex;
};

위 코드는 클래스의 멤버 변수마다 각각 잠금을 하고 있다. 너무 지나치게 잘게 자른 케이스이다.

임계 영역 잠금과 잠금 해제는 1회 이상의 원자 조작(atomic operation)을 합니다. 원자 조작은 통상적인 메모리 액세스보다 시간을 몇 배나 차지합니다. 여러 CPU가 같은 메모리를 원자 조작하는 경우 시간이 수십 배 걸리기도 합니다.

class A
{
  int a;
  int b;
  mutex mutex;
};

가능하면 위 코드처럼 잠금 하나로 모든 멤버 변수를 보호하는 편이 낫지만 무조건은 아니다. 어떤 멤버 변수를 접근하는 동안 많은 연산량이 필요하다면 잠금을 둘 이상으로 쪼갤 필요가 있다.

4. 디바이스 타임이 섞인 잠금

처리 시간이 긴 디바이스 타임을 일으키는 코드는 잠금 영역에 넣으면 병목 현상이 발생할 있다. 먼저 디바이스 타임을 처리한 후 잠금을 할지 말지 결정해야 한다.

우리에게 익숙한 콘솔 출력 함수(printf나 cout)는 운영체제 안에서 꽤 무거운 일을 한다. 디버깅을 위해서 여기저기 찍어 논 콘솔 출력이 병목 현상을 일으킬 수 있다.

void func()
{
  lock(mutex);
 
  a...;
  b...;
 
  cout << a << b; // 콘솔 출력
}

5. 잠금의 전염성으로 발생한 실수

잠금으로 보호되는 리소스에서 얻어 온 값이나 포인터 주소 값 등이 로컬 변수로 있는 경우에도 잠금 상태를 유지해야 할 때가 많다. 이를 잠금의 전염성이라고 한다.

class A
{
  int x;
  int y;
};
 
mutex list_mutex;
List<A> list;
 
void func()
{
  lock(list_mutex);
  A* a = list.GetFirst(); 
  unlock(list_mutex);
 
  a->x++; // 문제가 되는 부분
}

리스트 자체는 잠금으로 보호되고 있으나, 리스트의 목록 하나를 가리키는 로컬 변수 a가 있고 변수에 읽기/쓰기 작업을 하고 있다. 리스트의 잠금은 로컬 변수로 전염된 상태이다.

이 상태에서 잠금을 해제하고 로컬 변수가 가리키는 주소 값을 액세스하게 되면 데이터 레이스로 이어진다. 리스트 자체를 액세스하는 것이 아니더라도 로컬 변수가 리스트의 무언가를 가리키고 있기 때문이다.

변수 x의 일부를 로컬 변수로 가키는 변수 y가 존재할 때 변수 y가 변수 x를 접근하려면 변수 x에 대한 잠금을 해제하지 말아야 한다.

class Player
{
  int x;
  int y;
};
 
mutex mutex;
List<Player*> playerList;
 
void func()
{
  lock(mutex);
  Player* p = playerList.GetPlayer("John");
  // ➊ 여기서 잠금 해제 X
  p->x...;
  p->y...;
}

위 코드의 ➊ 부분에 잠금 해제를 하지 말아야 한다.

6. 잠금된 뮤텍스나 임계 영역 삭제

흔히 발생하는 실수 중에 하나가 잠금된 뮤텍스나 임계 영역을 아래와 같이 삭제하는 것이다.

class A
{
  mutex mutex;
  int a;
};
 
void func()
{
  A* a = new A();
  lock(a->mutex);
  delete a;
}

위 문제는 뮤텍스나 임계 영역의 파괴자 함수 안에 잠금이 된 상태라면 오류를 내는 코드를 추가하면 쉽게 감지할 수 있다.

7. 일관성 규칙 깨기

잠금 범위가 여럿일 때 내가 정의한 일관성 규칙을 깨는 실수를 범할 수 있따. 예를 들어 아래 코드를 보면

class Node
{
  Node* next;
};
 
Node* list = null;
int listCount = 0;
 
mutex listMutex;
mutex listCountMutex;
 
void func()
{
  lock(listMutex);
  Node* newNode = new Node();
  newNode->next = list;
  list = newNode;
  unlock(listMutex);
 
  lock(listCountMutex);
  listCount++;
  unlock(listCountMutex);
} 

연결 리스트 변수와 연결리스트의 항목 개수를 나타내는 변수가 있는데 서로 다른 뮤텍스나 임계 영역으로 보호되고 있다.

일단 충돌은 일으키지 않는다. 그러나 실제 리스트 항목 개수와 그 값을 나타내는 listCount 변수의 값이 서로 다를 수 있다.

변수 list에 변화를 가하는 동안은 listCount는 잠금 상태가 아니므로 다른 스레드에서 listCount 값을 건들 수 있다. 반대로 listCount를 조정하는 동안에 다른 스레드에서 list 자체에 변화를 가할 수 있다.

참고

게임 서버 프로그래밍 교과서