[C++ 서버] Condition Variable

이정석·2023년 11월 20일

CppServer

목록 보기
5/8

Condition Variable

멀티쓰레드 환경에서 동기화를 위해 사용하는 방법중 하나로 '특정 조건이나 이벤트가 발생할 때까지 대기하게 한다는 점'에서Event와 동작 방식이 비슷하다고 할 수 있다.

하지만 Event는 Lock에 대한 관리를 수동으로 해주어야한다는 단점이 있다. 아래 코드는 [C++ 서버] Event에서 다룬 Producer-Consumer 예제이다.

  mutex m;
  queue<int32> q;
  HANDLE handle;

  void Producer() {
      while (true) {
          {
              unique_lock<mutex> lock(m);
              q.push(100);
          }

          ::SetEvent(handle);
      }
  }

  void Consumer() {
      while (true) {
          ::WaitForSingleObject(handle, INFINITE);

          unique_lock<mutex> lock(m);
          if (q.empty() == false)
          {
              int32 data = q.front();
              q.pop();
              cout << data << endl;
          }
      }
  }

위 예제에서 동기화를 위해 Event를 사용했는데 Consumer에서 쓰레드가 대기상태로부터 깨어나고 Queue에 대한 Lock을 잡기 전에 다른 쓰레드가 공유자원인 Queue에 접근하지 않는다는 보장이 있을까? 정답은 아니다. 당장 위의 예제만 보아도 Producer는 SetEvent()를 하고 지속적으로 Lock과 push를 반복한다.

이러한 문제가 발생한 이유 중 하나는 '쓰레드가 대기상태에서 벗어나는 단계''공유자원에 대한 Lock을 얻는 단계'가 분리되어 있기 때문이다. Producer에서 Consumer 쓰레드를 깨운 시점에 Consumer가 Lock을 얻기 위해서, condition_variable를 사용할 수 있다.

  mutex m;
  queue<int32> q;
  HANDLE handle;

  condition_variable cv;

  void Producer() {
      while (true) {
          {
              unique_lock<mutex> lock(m);
              q.push(100);
          }

          cv.notify_one();
      }
  }

  void Consumer() {
      while (true) {
          unique_lock<mutex> lock(m);
          cv.wait(lock, []() { return q.empty() == false; });

          // if (q.empty() == false)
          {
              int32 data = q.front();
              q.pop();
              cout << q.size() << endl;
          }
      }
  }

Event는 Windows API의 일부이고, condistion_variable은 C++ 표준 라이브러리임을 주의하자.

  • notify_one: 대기중인 쓰레드중 무작위로 하나를 선택해 깨운다.
  • notify_all: 모든 대기중인 쓰레드들을 깨운다.
  • wait: notify가 올때까지 대기한다.
  • wait_for: 특정 시간동안 대기한다.
  • wait_until: 특정 시점까지 대기한다.

wait종류 함수의 경우 인자로 unique_lock를 받는다. 조건변수를 이용한 동기화 방법은 대기상태에서 실행상태로 전환될 때 전달받은 unique_lock에 대한 lock을 얻기 때문에 이 과정에서 사용할 Lock 객체를 받는다.

추가로, 특정 조건을 매개변수로 입력할 수 있다. 특정 조건이 의미하는 것은 실행 상태에 있을 때 Lock을 얻기 위한 조건으로 true면 Lock을 얻고, false면 다시 대기 상태로 돌아간다.

2. 동작순서

Producer의 동작순서는 기존에 Event를 사용하는 방식과 동일하다.

  1. Lock을 잡는다.
  2. 공유 자원에 접근한다.
  3. Lock을 해제한다.
  4. 조건변수를 통해 다른 쓰레드에게 통지한다.

하지만, Consumer는 다르게 동작한다.

  1. Lock을 잡는다.
  2. 조건변수의 조건을 확인한다.
  3. 조건이 만족한다면 코드를 진행한다.
  4. 조건이 만족하지 않는다면 Lock을 풀고 대기상태로 전환한다.

'Consumer가 여러 쓰레드가 존재한다면 전부 Lock에 잡혀 CPU자원을 낭비하는것이 아닐까?'라는 생각이 들 수 있다. 가장 중요한 것은 Lock을 잡은 후 얼마나 빠른 시간내에 Lock을 반환하는 것인데, 조건이 false인 경우 매우 빠른시간 내에 Lock을 반환할 것이고 true인 경우에는 형성된 임계 구역이 얼마나 빠른 시간에 처리되는 지에 따라 달라질 것이다.

3. Spurious wakeup

Producer에서 Queue에 데이터를 notify를 한다면, Consumer에서 Queue에 데이터가 있다고 확신할 수 있고 결국 바로 Lock을 얻은 후 Queue에 접근하면 되지 않을까? 결론부터 말하자면 그렇지 않다. Consumer에서 Lock을 잡기 전에 다른 쓰레드에서 Queue의 데이터를 변경할 수도 있기 때문이다. 이런 상황을 'Spurious wakeup'라고 한다. 문자 그대로 '이상한 기상'인 이 현상은 Producer에서 notify와 Consumer의 Lock 사이에 다른 누군가가 개입할 수 있는 상황에서 발생 가능한 현상이다.

이런 현상을 완화하기 위한 방법중 하나로 위에서 언급한 특정 조건에 대한 명시를 하는 것이다. Lock을 얻고 공유자원에 대한 조건을 확인함으로 공유자원의 상태에 대한 확신을 하고 코드를 진행할 수 있다.

profile
게임 개발자가 되고 싶은 한 소?년

0개의 댓글