Home [게임 프로그래밍 패턴] Decoupling Patterns: Event Queue
Post
Cancel

[게임 프로그래밍 패턴] Decoupling Patterns: Event Queue

Event Queue


  • 메시자나 이벤트를 보내는 시점과 처리하는 시점을 디커플링한다.

동기


  • 메시지 큐, 이벤트 루프, 메시지 펌프 등등..
GUI 이벤트 루프
  • UI 프로그래밍

  • 버튼 클릭, 메뉴 선택, 키보드 입력 등등..
  • 상호작용 => 운영체제는 이벤트를 만들어 프로그램으로 전달.
  • 프로그램 => 이벤트 핸들러 코드에 전달

    이벤트 주도 프로그래밍(event-driven programming)

  • 이벤트받기 위해서는 이벤트 루프가 있어야함
1
2
3
4
5
while (running)
{
  Event event = getNextEvent();
  // Handle event...
}
  • 프로그램이 원할 때 이벤트를 가져온다.
    • 즉, OS가 디바이스 드라이버로부터 입력값을 받은 후, 그 값을 어딘가에 저장해 둔다는 뜻.
    • 그 장소는 큐(queue)

  • 사용자 입력 => OS는 아직 처리 안된 이벤트 큐에 추가한다.
중앙 이벤트 버스
  • 이벤트 주도 방식으로 구현된 게임은 거의 없음(게임 루프 사용)
  • 게임에서 자체 이벤트 큐를 만들어 통신 시스템으로 활용하는 경우는 흔함.
  • “central”, “global”, or “main” 같은 형용사가 흔히 붙음.
    • 게임 시스템들이 디커플링 상태를 유지한 채, 서로 고수준 통신을 하고 싶을 경우 이를 사용한다.
  • 예시)특정 인-게임 이벤트가 발생할 때, 말풍선을 보여주는 튜토리얼 시스템
    • ‘적이 죽었음’이라는 이벤트를 받으면, 알려달라고 큐에 자기 자신을 등록한다.
    • 전투, 튜토리얼 시스템이 서로 몰라도, 사실을 전달 가능.
  • 이벤트 큐가 언제나 게임 전체 통신 시스템으로만 사용되어야하는 건 아님. - 클래스 하나, 분야(domain) 하나에서도 유용함.

    개체가 정보를 보내고 통지를 받을 수 있는 공용공간 == AI 분야의 흑판(blackboard) 시스템과 비슷하다.

Say What?(문제있는 단순한 사운드 시스템)
  • 사운드 시스템을 추가하기 위해 아이디와 볼륨을 받아 사운드를 출력하는 API를 제공하는 오디오 엔진 부터 만들어 보자

시스템 하드웨어에 보통 오디오 출력은 하나이므로, 싱글턴 패턴이 유용할 수 있다.

1
2
3
4
5
class Audio
{
public:
  static void playSound(SoundId id, int volume);
};
  • 오디오 엔진
    • 적당한 사운드 리소스 로딩, 출력할 수 있는 채널을 찾아 틀어줌.
  • playSound는 다음과 같다.
1
2
3
4
5
6
7
void Audio::playSound(SoundId id, int volume)
{
  ResourceId resource = loadSound(id);
  int channel = findOpenChannel();
  if (channel == -1) return;
  startSound(resource, channel, volume);
}
  • 위 코드를 소스 관리 툴에 체크인하고 사운드 파일을 만들고 나면 코드 아무곳에서 playSound을 호출할 수 있다.

  • 아래는 UI코드에서 선택한 메뉴가 바뀔 때 소리를 내는 코드이다.

1
2
3
4
5
6
7
8
9
class Menu
{
public:
  void onSelect(int index)
  {
    Audio::playSound(SOUND_BLOOP, VOL_MAX);
    // Other stuff...
  }
};
  • 이 상태에서 메뉴를 옮겨다니다 보면, 화면이 멈출 가능성이 있다.

  • **문제1:**

    API는 오디오 엔진이 요청을 완전히 처리할 때까지 호출자를 블록(block)한다.

    • 동기적(ssynchronous)인 함수로, 소리가 나기 전까지 API는 블록됨.
    • 공격을 하면, 몹 두마리가 한 프레임에 같이 맞을 수 있음.
      • 같은 소리 파형 두 개를 동시에 출력하면 하나의 소리를 두 배 크기로 출력하는것과 같아 좋지 않음.
    • 전체 사운드 호출을 취합하고, 우선순위에 따라 나열해서 해결 가능
    • 하지만 playsound는 하나씩 처리하기 때문에 사운드 요청을 한 번에 하나밖에 볼 수 없다.
  • **문제2:**

    요청을 모아서 처리할 수 없다.

    • 여러 다른 시스템이 playsound를 마음대로 호출
    • 멀티코어 하드웨어에서 실행된다면 게임 시스템들을 별도의 스레드로 나눠야한다.
    • playsound API가 동기식이기 때문에 코드는 호출한 스레드에서 실행된다.
    • playsound는 여러 스레드에서 동시에 실행됨
    • 동기화 처리가 없으므로 문제
    • 오디오용 스레드를 별도로 만들면 문제가 더 심각해짐.
  • **문제3:**

    요청이 원치 않는 스레드에서 처리된다.

    • 모든 문제의 원인: 오디오 엔진이 playsound 호출을 ‘하던 일을 멈추고 사운드 재생’이라고 해석

    • 즉시성문제

    • 오디오 엔진이 요청을 받을 때 이를 처리하기에 항상 적당한 것은 아님
    • 해결: 요청을 받는 부분과 요청을 처리하는 부분을 분리해야함.

The pattern


  • : 요청이나 알림을 받는 순서대로 저장.
  • 알림을 보내는 : 요청을 큐에 넣은 뒤 결과를 기다리지 않고 리턴.
  • 요청을 처리하는 : 큐에 들어 있는 요청을 나중에 처리한다.

  • 요청: 그곳에서 직접 처리 or 다른 여러 곳으로 보내질 수 있다.

  • 이렇게 요청을 보내는 쪽과 받는 쪽을 코드뿐만이 아니라 시간 측면에서도 디커플링한다.

언제 사용할 것인가?


  • 메시지를 보내는 곳과 받는 곳을 분리하고 싶을 뿐이라면 옵저버 패턴이나 명령 패턴같은 걸로 덜 복잡하게 이를 처리할 수 있다.

  • 메시지를 보내는 시점과 받는 시점을 분리하고 싶은 경우에 큐가 필요하다.

  • pushing, pulling
    • 일을 요청: 밀어 넣기
    • 일을 받기: 가져와서 처리
    • 둘 사이에 버퍼가 필요
    • 큐만 제공할 수 있는 기능
  • 큐는 요청을 받는 쪽에 제어권을 제공한다.
    • 받는 쪽: 처리 지연가능, 모아서 처리 또는 버릴 수 있음.
  • 보내는쪽: 요청을 큐에 넣은 뒤, 잘 처리하기를 바라는 것밖에 없음.

  • 보내는 쪽에서 처리 응답을 받아야 한다면 큐를 쓰는 게 적합하지 않다.

주의 사항


  • 이벤트 큐는 복잡하고 게임 구조에 전반적으로 영향을 미침.

  • 잘 생각해야함.

중앙 이벤트 큐는 전역변수와 같다

  • 이벤트 큐는 모든 게임 시스템에서 서로 메시지를 주고받는 복잡한 중앙역에 많이 사용됨.
  • 전역변수 == 좋지 못함
    • 상호 의존성이 생김.
    • 관리 문제 발생

월드 상태는 언제든 바뀔 수 있다

  • 다양한 월드 상태를 알아야함.
    • 어떤 상황에서 어떤 객체가 죽었는지 등에 관한 정보
    • 바로바로 이벤트가 처리되지 않으면 정보가 사라질 수 있음.
  • 현재 월드 상태가 이벤트가 만들어진 당시 상태와 다를 수 있음.
    • 큐에 들어가는 이벤트에 데이터가 훨씬 더 많이 필요.
  • 동기 이벤트: 알림 받는 쪽이 바로 상황 확인 가능

피드백 루프에 빠질 수 있다

  • 모든 이벤트, 메시지 시스템은 순환이 생기지 않도록 주의해야함,

    1. A에게 이벤트를 보냄
    2. B가 이벤트를 받아 응답으로 다른 이벤트를 보냄
    3. 이 이벤트가 우연하게 A에서 처리해야하는 작업이라 A가 이벤트 보냄
    4. 2-3 반복
  • 동기적이면, 스택 오버플로가 나기 때문에 순환을 금방 찾을 수 있다.
  • 비동기적 => 콜스택이 풀려 계속 이벤트를 서로 주고 받을 수 있다.

    • 이 문제를 피하는 일반적인 규칙: 이벤트를 처리하는 코드 내에서는 이벤트를 보내지 않는것.

    이벤트 시스템에 간단한 디버깅용 로거를 집어넣는것도 괜찮다.

예제 코드


  • 위에서 다룬 사운드 엔진의 playSound()의 문제점을 해결하려고함.

  • 문제1: API가 블록된다.

playsound가 바로 리턴하게, 기본 배열 사용하여 메시지 저장 => 큐가 필요
  • playsound가 바로 리턴하게 만들려면 사운드 출력 작업을지연 시켜야한다.

    • 나중에 사운드를 출력할 때 필요한 정보를 저장할 수있도록 간단히 구조체부터 정의해야한다.
    1
    2
    3
    4
    5
    
    struct PlayMessage
    {
      SoundId id;
      int volume;
    };
    
    • 보류된 메시지를 저장할 수 있도록 저장 공간을만들어야한다.
      • 피보나치 힙, 스킵 리스트, 링크드리스트 등
      • 실무에서 동일한 데이터들을 저장하는 가장 좋은 방법은 그냥 배열을 쓰는것이다.
  • 기본 배열의 장점
    • 동적할당이 필요없음.
    • 메모리에 추가 정보나 포인터저장하지 않아도됨.
    • 메모리가 이어져있어 캐시하기 좋음.(데이터 지역성 패턴 참고)
  • 코드는 다음과 같다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Audio
{
public:
  static void init()
  {
    numPending_ = 0;
  }

  // Other stuff...
private:
  static const int MAX_PENDING = 16;

  static PlayMessage pending_[MAX_PENDING];
  static int numPending_;
};
  • 배열의 크기는 최악의 경우에 맞추어 조정한다.
  • 메시지는 배열 제일 맨 뒤에 넣는다.
1
2
3
4
5
6
7
8
void Audio::playSound(SoundId id, int volume)
{
  assert(numPending_ < MAX_PENDING);

  pending_[numPending_].id = id;
  pending_[numPending_].volume = volume;
  numPending_++;
}
  • 사운드 출력은 update 메서드에 놓는다.(업데이트 메서드 패턴)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Audio
{
public:
  static void update()
  {
    for (int i = 0; i < numPending_; i++)
    {
      ResourceId resource = loadSound(pending_[i].id);
      int channel = findOpenChannel();
      if (channel == -1) return;
      startSound(resource, channel, pending_[i].volume);
    }

    numPending_ = 0;
  }

  // Other stuff...
};
  • 이제 update를 적절한 곳에서 호출하면 된다.
    • 메인 게임 루프 or 별도의 오디오 스레드
  • update() 한 번 호출로 모든 사운드 요청을 다 처리할 수 있다고 가정하고 있음.

  • 사운드 리소스가 로딩된 다음에 비동기적으로 요청을 처리해야한다면 이렇게 안됨.

  • update()에서 한 번에 하나의 요청만 처리하게 하려면 버퍼에서 요청을 하나씩 꺼낼 수 있어야함. - 큐가 필요
원형 버퍼
  • 큐를 구현하는 방법중 하나.
  • 큐 앞에서부터 순차적으로 데이터를 가져올 수 있다.

  • 머리(head): 큐에서 요청을 읽을 위치.(가장 먼저 보류된 요청)
  • 꼬리(tail): 배열에서 새로운 요청이 들어갈 자리. (마지각 요청의 다음을 가리킴, 반 열린구간)
  • playSound()는 배열 맨 뒤에 요청을 추가한다.

  • 코드는 다음과 같다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Audio
{
public:
  static void init()
  {
    head_ = 0;
    tail_ = 0;
  }

  // Methods...
private:
  static int head_;
  static int tail_;

  // Array...
};
  • numPending* 이 tail*로 바뀌었다.
1
2
3
4
5
6
7
8
9
void Audio::playSound(SoundId id, int volume)
{
  assert(tail_ < MAX_PENDING);

  // Add to the end of the list.
  pending_[tail_].id = id;
  pending_[tail_].volume = volume;
  tail_++;
}
  • update()는 다음과 같이 바뀌었다.
1
2
3
4
5
6
7
8
9
10
11
12
void Audio::update()
{
  // If there are no pending requests, do nothing.
  if (head_ == tail_) return;

  ResourceId resource = loadSound(pending_[head_].id);
  int channel = findOpenChannel();
  if (channel == -1) return;
  startSound(resource, channel, pending_[head_].volume);

  head_++;
}
  • 이제 버퍼를 원형 버퍼로 만들어보자

  • 머리 앞부분의 빈 공간을 사용하게끔, 꼬리를 다시 배열 앞으로 보내면 된다.

  • playsound에서의 구현은 다음과 같다.
1
2
3
4
5
6
7
8
9
void Audio::playSound(SoundId id, int volume)
{
  assert((tail_ + 1) % MAX_PENDING != head_);

  // Add to the end of the list.
  pending_[tail_].id = id;
  pending_[tail_].volume = volume;
  tail_ = (tail_ + 1) % MAX_PENDING;
}
  • update는 다음과 같이 수정한다.
1
2
3
4
5
6
7
8
9
10
11
12
void Audio::update()
{
  // If there are no pending requests, do nothing.
  if (head_ == tail_) return;

  ResourceId resource = loadSound(pending_[head_].id);
  int channel = findOpenChannel();
  if (channel == -1) return;
  startSound(resource, channel, pending_[head_].volume);

  head_ = (head_ + 1) % MAX_PENDING;
}
  • 동적 할당도 필요 없고, 데이터를 옮길 필요도 없고 단순 배열만큼이나 캐시하기 좋은 큐가 완성되었다.

큐의 최대용량 MAX_PENDING이 신경쓰인다면, 늘어나는 배열을 사용하면 된다. 배열이 늘어날 때 데이터를 복사해야하지만, 그 외에 삽입등 작업은 평균적으로 상수시간 안에 가능하다.

사운드 겹치는 문제 => 요청 취합하기
  • 첫 번째 문제는 같은 소리를 동시에 틀면 소리가 너무 커지는 현상이 일어난다는 것이다.
    • 대기 중인 요청을 확인하여 같은 요청은 병합하는것
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void Audio::playSound(SoundId id, int volume)
{
  // Walk the pending requests.
  for (int i = head_; i != tail_;
       i = (i + 1) % MAX_PENDING)
  {
    if (pending_[i].id == id)
    {
      // Use the larger of the two volumes.
      pending_[i].volume = max(volume, pending_[i].volume);

      // Don't need to enqueue.
      return;
    }
  }

  // Previous code...
}
  • 보류 중인 요청을 조회하여 둘 중 소리가 큰 값 하나로 합치는것.

  • 배치(batch) 작업도 같은 방식이다.

  • 요청을 처리할 때가 아니라 큐에 넣기 전에 일어나는 일.

  • 호출하는 쪽의 처리 부담이 늘어난다는 단점이 생김.
    • 큐가 큰 경우 update()에서 요청을 취합하는게 더 나을 수 있음.
    • 아니면 해시 테이블 같은 걸 사용하면 상수 시간에 중복여부 확인 가능하다.
  • 주의

    : 취합 가능한 최대 동시 요청 수는 큐의 크기와 같다.

    • 요청을 너무 빨리 처리하면, 요청을 합칠 가능성이 줄어든다.
    • 요청 처리가 늦어져 큐가 거의 차있으면 합칠만한 요청을 찾을 가능성이 더 높다.
  • 이벤트 큐 패턴은 요청자가 실제로 요청이 언제 처리되는지를 모르게 막는다.
    • 이벤트를 없애므로
    • 큐가 이렇게 상황에 따라 다르게 반응한다면, 큐에 넣은 요청이 실제로 처리될 때까지 걸리는 시간이 동작에 영향을 미칠 수 있다.
멀티 스레드
  • 동기식으로 만든 오디오 API에서는 playsound를 호출한 스레드에서 요청도 같이 처리해야 했음.

  • 멀티코어 하드웨어의 성능을 최대한 끌어내야함.

    • 분야별로 할당하는 전략을 많이 사용한다.
  • 앞에서 멀티코어를 적용하기 위한 세 가지 주요 조건을 준비했음

    1. 사운드 요청 코드와 사운드 재생 코드 분리
    2. 양쪽 코드 사이에 마샬링(marshalling)을 제공하기 위한 큐
    3. 큐는 나머지 코드로 부터 캡슐화됨
  • 이제 playsound, update를 스레드 안전하게 만들기만 하면된다.

  • 큐가 동시에 수정되는 것만 막으면 된다.

    • update()에서는 조건 변수 같은 것으로 기다리게 만들면 처리할 요청이 없는 동안 CPU 낭비를 막을 수 있다.
    • playSound는 작업이 많지 않아, 블록해도 오래걸리지 않음

서버 프로그래머들은 애플리케이션을 여러 프로세스로 나눠서 이를 보완한다. 이러면 OS에서 프로세스들을 별도 코어에서 병렬적으로 실행시켜준다.

게임 클라이언트는 대부분 프로세스를 하나만 사용하므로, 멀티스레딩을 적용하면 많은 도움을 얻을 수 있다.

디자인 결정


  • 간단한 것부터 만들어보는게 좋다.

큐에 무엇을 넣을것인가?

  • 이벤트 와 메시지

큐에 이벤트를 넣는 경우

  • 이벤트 혹은 통지(notification): 이미 발생한 사건을 표현한다.
    • “몬스터가 죽었음”
    • 큐에 이벤트를 넣으면, 다른 객체가 비동기 옵저버 패턴 같은 방식으로 이벤트에 대해 반응 가능
  • 복수 개의 리스너를 지원해야 할 때도 많다.: 큐에는 이미 발생한 일이 들어 있기 때문, 보내는 쪽에서는 누가 그걸 받는지 신경 쓰지 않는다.

  • 큐의 범위가 더 넓은 편이다.: 이벤트 큐는 이벤트를 원하는 누구에게든지 전파하는 용도로 사용된다. 리스너가 최대한 유연할 수 있도록, 큐를 더 전역적으로 노출해야 할 수 있다.

큐에 메시지를 넣는 경우

  • 메시지 또는 요청: 나중에 실행했으면 하는 행동을 표현

    • “사운드 틀기”
    • 서비스에 비동기적으로 API를 호출하는 것과 비슷.

      요청은 명령 패턴에서의 명령과 같음.

  • 대부분 리스너가 하나다.

누가 큐를 읽는가?

  • 예제: 큐가 캡슐솨, Audio 클래스에서만 읽을 수 있었음.
    • UI의 이벤트 시스템 같은 곳에서 원하는 대로 리스너를 등록할 수 있다.
    • 싱글캐스트나 브로드캐스트 같은 용어를 사용한다.

싱글캐스트 큐

  • 큐가 어떤 클래스의 API 일부일 때 적합.

    • 호출하는 쪽에서는 playSound 메서드만 보일 뿐.
  • 큐는 밖에서는 보이지 않는 내부 구현이 된다.: 보내는 쪽에서는 메시지를 보냈다는 것만 안다.
  • 큐가 더 캡슐화되어 있다.: 캡슐화 되어 있을수록 좋다.
  • 리스너 간에 경쟁을 고민하지 않아도 된다.: 리스너가 여러 개라면 모든 리스너에 이벤트를 보낼지, 하나의 리스너에 이벤트를 하나씩 나눠줄지(작업큐) 정해야한다.
    • 리스너들은 중복 작업을 하거나 서로 간섭함 => 리스너가 하나면 복잡성이 사라짐

브로드캐스트 큐

  • 대부분의 ‘이벤트’ 시스템

    • 리스너가 10개일 경우 이벤트가 하나 들어왔을 때 10개의 리스너 모두가 그 이벤트를 볼 수 있다.
  • 이벤트가 무시될 수 있다.: 리스너가 하나도 없다면, 이벤트는 그대로 벌진다.

  • 이벤트 필터링이 필요: 이벤트 개수 x 리스너 개수만큼 이벤트 핸들러가 자주 호출된다.

  • 이벤트 핸들러 호출 횟수를 최소화 => 리스너가 받고 싶은 이벤트 집합을 조절할 수 있게

작업 큐

  • 작업 큐에도 리스너가 여러 개 있다.
    • 큐에 들어 있는 데이터가 리스너 중 한곳에만 간다.
    • 스레드 여러 개가 동시에 실행 중인 스레드 풀에 작업을 나눠줘야 할 때 일반적으로 사용하는 패턴이다.
  • 작업을 분배해야 한다.: 큐에 들어 있는 데이터가 하나의 리스너에만 가기 때문에 큐는 어느 리스너에 보내면 좋을지 알아야 한다.
    • 라운드 로빈이나 좀 더 복잡한 우선순위 시스템을 적용해도 된다.

누가 큐에 값을 넣는가?

  • 이벤트 큐 패턴: 일대일, 일대다, 다대일, 다대다 등 모든 읽기/쓰기 조합으로 사용할 수 있다.

넣는 측이 하나

  • 동기형 옵저버 패턴에 가까운 형태.

    • 특정 객체에서만 이벤트 만들기 가능, 나머지는 받을 수만 있음.
  • 어디에서 이벤트가 오는지 암시적으로 안다
  • 리스너가 여러 개

넣는 측이 여러개

  • 위의 오디오 엔진이 이런 예
  • public 함수로 어디에서나 큐에 요청 가능, 전역 or 중앙 이벤트 버스도 이와 같이 작동

  • 이벤트 순환을 주의해야 함.
    • 이벤트 처리 도중에 큐를 넣으면 피드백 루프 문제 발생 가능
  • 이벤트를 보낸 객체에 대한 레퍼런스를 이벤트에 추가해야 할 필요가 있을 수 있다.
    • 리스너에서 보낸 쪽의 정보가 필요하다면, 정보를 같이 넣어줘야함,

큐에 들어간 객체의 생명주기는 어떻게 관리할 것인가?

  • 동기형일 경우, 보내는 쪽은 기다려야함.
    • 메시지 == 스택에 들어가는 지역 변수이기만 해도 충분.
    • 큐에다 메시지를 추가하는 함수 호출이 끝난 후에도 메시지 객체가 유지되어야함.
    • GC지원 언어의 경우 큐에 들어 있는 데이터는 필요한 만큼 메모리에 유지됨.

소유권 전달

  • 받는 쪽에서 소유권을 가져가고 메시지 해제도 해야함.
    • unique_ptr<T>

소유권을 공유

  • 메시지를 참조하는 곳이 어디 하나라도 있다면 계속 메모리에 남아 있음.
  • 참조하지 않으면 알아서 해제
    • shared_ptr<T>

큐가 소유권을 가진다

  • 메시지 큐가 생명을 관리 - 큐에서 새로운 메시지를 하나 달라고 요청 - 큐는 미리 할당해놓은 메시지의 레퍼런스를 반환하고 보내는 쪽에서는 여기에 값을 채움. - 처리가 끝나면, 받는 쪽에서 이 메시지 참조

    오브젝트 풀이 큐의 보조 기억장치가 되는것.

관련 자료


  • 이벤트 큐 == 옵저버 패턴의 비동기형
  • 여러 이름으로 불림.
    • 이벤트 큐 == 애플리케이션 내부
    • 메시지 큐 == 여러 애플리케이션 끼리 통신
    • 발행자/구독자(publish/subcribe, pubsub): 대규모 분산처리 시스템
  • GOF의 상태 패턴과 유사한 FSM에서는 입력값을 stream으로 받음.
    • FSM 입력에 비동기적으로 응답하게 하고 싶다면 입력에 큐를 넣어야함.
    • 서로 통신을 주고받는 여러 상태 기계가 있고, 각자 입력을 보류하기 위해 소형 큐(mailbox)를 사용한다면, 계산 액터 모델(actor model)을 만든것과 같다.
  • GO에서는 이벤트 큐나 메시지 큐로 사용하는 channel이라는 자료형을 언어 차원에서 지원한다.

출처


event-queue

This post is licensed under CC BY 4.0 by the author.

[게임 프로그래밍 패턴] Decoupling Patterns: Component

[게임 프로그래밍 패턴] Decoupling Patterns: Service Locator