[게임 프로그래밍 패턴] Chapter15 이벤트 큐

Jangmanbo·2023년 11월 26일
0

이벤트 큐를 사용해 메시지나 이벤트를 보내는 시점과 처리하는 시점을 디커플링


GUI 이벤트 루프

이벤트 큐를 사용하는 예시!

마우스, 키보드 같은 디바이스로 프로그램과 상호작용(ex. 버튼 클릭)할 때마다 웅영체제는 이벤트를 만들어 프로그램으로 전달한다.
그럼 프로그램은 이벤트를 받아 이벤트 핸들러 코드에 전달한다.

while(running) {
    Event event = getNextEvent();	// 아직 처리하지 않은 사용자 입력 가져오기
    // 이벤트 처리 코드
}

대강 이런 이벤트 루프에서 이벤트를 받고 있을 것이다.
getNextEvent()로 가져온 사용자 입력을 이벤트 핸들러로 보낸다.

여기서 중요한 건 프로그램이 자기가 원할 때 이벤트를 가져온다는 것이다.
OS가 디바이스 드라이버로부터 받은 입력갔을 프로그램이 getNextEvent()로 가져갈 때까지 큐에 저장한다는 말이다.
(마우스 클릭했다고 OS가 바로 프로그램 코드를 호출하는게 아니다!)

ClickUpDownShift

운영체제가 Enqueue하면 애플리케이션이 getNextEvent()로 Dequeue



중앙 이벤트 버스

자체 이벤트 큐를 만들어 중추 통신 시스템으로 활용하는 게임이 많다.

첫번째 적을 처치하였을 때 튜토리얼을 보여주는 시스템을 만든다고 하자.
전투 시스템 코드 내에 튜토리얼 코드를 넣을 수는 없다. (너무 복잡해진다.)

중앙 이벤트 큐를 만들어 어느 게임 시스템에서도 큐에 이벤트를 보내고, 받을 수 있도록 만든다.
그리고 전투 시스템이 적을 처치할 때마다 이벤트를 보내고, 튜토리얼 시스템은 적 처치 이벤트를 받으면 알려달라고 큐에 자기 자신을 등록한다.

클래스에서 사용되는 이벤트 큐

이벤트 큐가 꼭 거대한 전역 시스템에서나 사용되는 건 아니다.
클래스 하나, 분야 하나에서도 유용하게 사용된다. (ex. 사운드 시스템)



사운드 시스템 예제

class Audio {
public:
    static void playSound(soundId id, int volume);
};

void Audio::playSound(SoundId id, int volume) {
    ResourceId resource = loadSound(id);
    int channel = findOpenChannel();
    if(channel == -1) return;
    startSound(resource, channel, volume);
}

이렇게 Audio를 싱글턴으로 만들면 어디서나 playSound()를 호출할 수 있다.

class Menu {
public:
    void onSelect(int index) {
        Audio::playSound(SOUND_BLOOP, VOL_MAX);
        // ...
    }
}

예를 들면 이런 식으로..
그런데 이 상태에서 메뉴를 옮겨다니다 보면 화면이 몇 프레임 정도 멈출 때가 있다. 왜??

문제 1. API는 오디오 엔진이 요청을 완전히 처리할 때까지 호출자를 block 한다.

playSound() API는 동기적이기 때문에, 오디오 엔진이 스피커로부터 소리가 나게 하기까지 블락한다. 즉, 게임이 멈춘다.
또, 몬스터 2마리가 한 프레임에 동시에 피해를 받아 같은 비명 소리를 동시에 두 개 튼다면 하나의 소리를 두 배 크기로 듣게 된다.

이런 문제들을 해결하려면 전체 사운드 호출을 취합하여 우선순위에 따라 나열해야 한다.

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

playSound() API는 동기적이기 때문에, 호출한 게임 시스템 스레드에서 실행된다.
여러 게임 시스템에서 playSound()를 호출하면, 여러 스레드에서 동시에 실행된다.
그런데 playSound()에는 동기화 처리가 없다..

그렇다고 오디오용 스레드를 별도로 만들면, 이 스레드는 다른 스레드가 서로를 침범하는 동안 아무것도 하지 않는다.

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

앞서 말한 문제들의 원인은 즉시성이다. playSound()를 호출하면, 곧바로 사운드를 재생하기 때문이다.
오디오 엔진 입장에서는 playSound()를 호출받았을 때 사운드 요청을 처리하기 어려운 상황일 수 있다.

이를 해결하려면 요청을 받는 부분과 요청을 처리하는 부분을 분리해야 한다.



이벤트 큐 패턴

  • 알림을 보내는 곳에서는 요청을 큐에 넣은 뒤에 결과를 기다리지 않고 리턴한다.
  • 요청을 처리하는 곳에서는 큐에 들어있는 요청을 나중에 처리한다.
  • 요청은 그곳에서 직접 처리될 수도, 다른 여러 곳으로 보내질 수도 있다.

=> 요청을 보내는 쪽과 받는 쪽 코드를 디커플링, 시간 측면에서도 디커플링

언제 쓸 것인가?

단순히 메시지를 보내는 곳과 받는 곳을 분리하고 싶을 뿐이라면, 관찰자 패턴이나 명령 패턴으로 보다 간단히 처리할 수 있다.
메시지를 보내는 시점과 받는 시점을 분리하고 싶을 때 이벤트 큐가 필요하다.

이벤트 큐 패턴은 요청을 받는 쪽에 제어권을 제공한다.
보내는 쪽은 요청을 큐에 넣는 것밖에 할 수 없지만,
받는 쪽은 이벤트 처리를 지연할 수도, 요청을 모아서 처리하거나 버릴 수도 있다.

주의사항

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

어떤 상태가 어디서나 접근 가능하면 온갖 미묘한 상호의존성 문제가 생길 수 있다.

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

이벤트를 받았을 때의 월드 상태와 이벤트가 만들어졌을 때의 월드 상태가 다를 수 있다.
때문에 동기적으로 처리되는 이벤트보다 큐에 들어가는 이벤트에는 데이터가 훨씬 많이 필요하다.

3. 피드백 루프에 빠질 수 있다

동기적이라면 스택 오버플로 크래시로 인해 금방 알아채겠지만,
이벤트 큐 패턴은 비동기기 때문에 콜스텍이 풀려 알아내기 쉽지 않다.

이벤트를 처리하는 코드 내에서 이벤트를 보내지 않는 것이 일반적인 방법이다.
(이게 현실적으로 가능한가? 내가 맨날 하는 짓인데..)



이벤트 큐 패턴 예제

호출자 block 문제 해결하기

playSound()가 바로 리턴하게 하려면 사운드 출력 작업을 지연시킬 수 있어야 한다.

구조체 정의

요청을 보류한 후 나중에 사운드를 출력할 때 필요한 정보를 저장할 구조체를 정의하자.

struct playMessage {
    SoundId id;
    int volume;
};

Audio 클래스가 사운드 관련 메시지 저장할 배열 만들기

동일한 데이터들을 저장하는 가장 좋은 방법은 기본 배열을 쓰는 것이다.

기본 배열의 장점

  • 동적 할당이 필요없다.
  • 메모리에 추가 정보나 포인터를 저장하지 않아도 된다.
  • 메모리가 이어져 있어 캐시하기 좋다.
// Audio.h
class Audio {
public:
    static void init() { numPending_ = 0; }
    void playSound(SoundId id, int volume);
    static void update();

    // ...

private:
    static const int MAX_PENDING = 16;	// 배열 크기는 최악의 경우에 맞춤
    static PlayMessage pending_[MAX_PENDING];
    static int numPending_;
};

//Audio.cpp
void Audio::playSound(SoundId id, int volume) {
    assert(numPending_ < MAX_PENDING);
    
    pending_[numPending_].id = id;
    pending_[numPending_].volume = volume;
    numPending_++;
}

void Audio::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;
}

update()를 적당한 곳(ex. 메인 게임 루프, 별도의 오디오 스레드, ...)에서 호출한다.

이제 playSound()는 바로 리턴한다.
그러나 이 코드는 update() 한 번 호출만으로 모든 사운드 요청을 다 처리한다고 가정하고 있다.
비동기적으로 update()에서 한 번에 하나의 요청만 처리하게 하려면 버퍼에서 요청을 꺼낼 수 있어야 한다. (= 큐가 필요하다!)

원형 버퍼

// Audio.h
class Audio {
public:
    static void init() {
        head_ = 0;
        tail_ = 0;
    }
    void playSound(SoundId id, int volume);
    static void update();

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


//Audio.cpp
void Audio::playSound(SoundId id, int volume) {
	// 큐에 남은 자리가 있는지 확인
    assert((tail_ + 1) % MAX_PENDING != head_);

    pending_[tail_].id = id;
    pending_[tail_].volume = volume;
    tail_ = (tail_ + 1) % MAX_PENDING;
}

void Audio::update() {
    // 보류된 요청이 없으면 리턴
    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;
}

원형버퍼를 사용하면 동적 할당도 필요없고, 데이터를 옮길 필요도 없고, 캐시하기 좋은 큐를 만들 수 있다.

요청 취합하기

한 프레임에 같은 소리를 동시에 틀어 소리가 커지는 현상을 막을 수 있다.
대기 중인 요청을 확인해 같은 요청이 있으면 병합해버리면 된다.

void Audio::playSound(SoundId id, int volume) {
    // 보류 중인 요청 살펴보기
    for(int i = head_; i != tail_; i = (i + 1) % MAX_PENDING) {
        if(pending_[i].id == id) {
            // 둘 중에 소리가 큰 값으로 덮어쓰기 (취합)
            pending_[i].volume = max(volume, pending_[i].volume);
            // 요청을 새로 큐에 넣지 않고 리턴
            return;
        }
    }
    // ...
}

위 코드에서는 어차피 취합하면서 없어질 요청, 미리 큐에 두지 않도록 큐에 넣기 전에 취합했다.
그러나 호출하는 쪽이 전체 큐를 쭉 돈 다음에 리턴하기 때문에, 요청을 처리하는 쪽에서 취합하는게 더 나을 수도 있다.
(다른 자료구조, 예를 들면 해시테이블을 사용하면 탐색이 더 빨라지겠지만..)

또 큐에 넣은 요청이 실제로 처리될 때까지 걸리는 시간이 동작에 영향을 줄 수 있다는 점을 유의하자.
(너무 오래 큐에 있으면, 나중에 들어온 큐에 의해 취합되거나, 뒤에 들어온 큐가 버려질 수 있다.)

멀티스레드

지금까지 구현한 바로 멀티코어를 적용하기 위한 세 가지 요건을 충족했다.

  1. 사운드 요청과 사운드 재생 코드가 분리
  2. 양쪽 코드 사이에 마샬링을 제공하기 위한 큐 존재
  3. 큐는 나머지 코드로부터 캡슐화

남은 것은 큐가 동시에 수정되는 것만 막는 것 뿐이다. (뮤텍스 생각하면 될 듯)

디자인 결정

생략..

0개의 댓글