도서 [게임 프로그래밍 패턴] 15장을 참고하였습니다.
P.297
이벤트
혹은통지
는 '몬스터가 죽었음' 같이 이미 발생한 사건을 표현한다.
메시지
혹은요청
은 '사운드 틀기' 같이 나중에 실행했으면 하는 행동을 표현한다.
이벤트 큐, 메시지 큐, 이벤트 루프, 모두 비슷한 의미이다.
(근데 메시지 큐는 좀 더 고수준의 구조를 부를 때 흔히 사용되고, 이벤트 큐가 애플리케이션 내부에 있다면 메시지 큐는 여러 애플리케이션끼리 통신하는 용도로 사용된다)
이벤트 큐는 메시지나 이벤트를 보내는/받는 시점과 처리하는 시점을 디커플링하기 위해 사용한다.
이번 포스트에서는 이 시점을 왜 디커플링해야하는지, 이벤트 큐가 언제 어울릴지 알아본다.
먼저, 우리는 UI 요소를 개발하면서 이벤트 프로그래밍을 모두 한번쯤은 접했을 것이다.
버튼을 클릭하거나, 메뉴를 선택하거나, 키보드를 눌러서 프로그램과 상호작용할 때마다,
운영체제는 이벤트를 만들어 프로그램 쪽으로 전달한다.
프로그램에서는 이를 받아서 원하는 행위를 처리하도록 이벤트 핸들러 코드에 전달해야 한다.
그리고 애플리케이션은 자기가 원할 때 이 이벤트를 가져온다.
사용자가 주변기기를 눌렀다고 해서 OS에서 우리 쪽 애플리케이션 코드를 바로 호출하는 것은 아니다.
(이런 식으로 작동하는 것이 인터럽트이다. 인터럽트가 발생하면 OS는 애플리케이션이 하던 작업을 중지한 후에, 인터럽트 핸들러로 실행 위치를 옮겨버린다.)
이벤트를 원할 때 가져올 수 있다는 얘기는 OS가 디바이스 드라이버로부터 입력 값을 받은 뒤 애플리케이션에서 가져갈 때까지 그 값을 어디엔가 저장해 둔다. 그 어딘가가 바로 큐이다. (큐인 이유: 선입선출)
이벤트는 큐를 통해 OS로부터 애플리케이션으로 전달된다.
이벤트는 큐를 통해 OS로부터 애플리케이션으로 전달된다.
사용자 입력이 들어오면, OS는 이를 아직 처리가 안 된 이벤트 큐에 추가하고,
이벤트 루프 코드에서는, 가장 먼저 들어온 이벤트부터 큐에서 pop해서 애플리케이션에 전달한다.
이런 이벤트 주도 방식(Event Driven Programming)으로 구현된 게임은 거의 없다 (게임 루프를 사용)
하지만! 게임에서 자체적으로 이벤트 큐를 만들어서 중추 통신 시스템으로 활용하는 경우는 흔하다.
그러면 게임 시스템은 큐에 이벤트를 보내거나, 큐로부터 이벤트를 받을 수 있다.
하나의 게임에서 사용하는 공유 큐가 있다고 가정하자.
그리고 예를 들어 전투 코드에서 매번 적을 죽일 때마다 '적이 죽었음' 이벤트를 큐에 보내고, 튜토리얼 코드는 '적이 죽었음' 이벤트를 받으면 알려달라고 큐에게 자기 자신을 등록한다.
이렇게 하면 의존성 없이 전투 시스템으로부터 튜토리얼 시스템으로 적이 죽었다는 사실을 전달할 수 있다.
그렇다고 이벤트 큐가 언제나 게임 전체 통신 시스템으로만 사용되어야 하는 건 아니다. (책에서는 권장하지 않는다고 함. 어떤 전역 변수/함수/객체가 프로그램 어디에서나 접근 가능하다면, 온갖 미묘한 상호의존성 문제가 생기기 마련이기 때문에)
클래스 하나, 특정 분야 하나에서도 유용할 수 있다. 그 예시로, 책에서는 사운드 시스템으로 새로운 예시를 들어줬다. 사운드id와 볼륨을 받아 사운드를 출력하는 단순한 오디오 엔진이다.
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);
}
이 API로 UI에서 메뉴선택 시 소리를 내고 싶다면 아래와 같이 코드를 사용할 것이다.
class Menu{
public:
void onSelect(int index){
Audio::playSound(SOUND_BLOOP, VOL_MAX);
...
}
};
이 상태에서 메뉴를 계속 선택하다보면 화면이 몇 프레임 정도 멈출 수 있다고 한다.
이 시스템의 문제점이 뭐길래?!
API는 오디오 엔진이 요청을 완전히 처리할 때까지 호출자를 Block한다.
playSound()
는 동기적(Synchronous)이다. 스피커로부터 삑 소리가 나기 전까지 API는 블록된다. 즉 playSound()
에서 리소스를 로딩해 실제로 스피커에서 소리가 나오기 전에는 아무것도 못 하고 기다려야 한다. 사운드 파일을 먼저 디스크에서 로딩하기라도 해야 한다면 더 오래 기다려야 하고, 그동안 게임은 멈춘다.
또한 몹 두 마리를 동시에 공격해서 몹의 공격 소리를 동시에 2개 틀게 되면, 하나의 소리를 2배 크기로 트는 것과 같은 거슬림이 있다. 심한 경우, 부하들이 우르르 몰려나오는 보스전이라면..? 하드웨어적으로 동시에 출력할 수 있는 소리에는 한계가 있기에 그 이상이 되면 사운드 출력이 무시되거나 끊길 수 있다.
요청을 모아서 처리할 수 없다.
최신 멀티코어 하드웨어에서 실행된다면? 멀티코어를 최대한 활용하기 위해 렌더링용 스레드, AI용 스레드처럼 게임 시스템들을 별도의 스레드로 나눠야 한다. 이렇게 여러 다른 게임 시스템에서 playSound()
를 호출하면 여러 스레드에서 동시에 실행되는데, playSound()
코드에 동기화 처리가 안되어있다면..?
요청이 원치 않는 스레드에서 처리된다.
이 모든 문제의 원인은 오디오 엔진이 playSound()
호출을 '하던 일을 멈추고 당장 사운드를 틀어!'라고 해석하는 데 있다. 즉시성이다. 다른 게임 시스템에서는 자기가 편할 때 playSound()
을 호출하는데, 오디오 엔진 입장에서는 playSound()
를 호출받았을 때가 사운드 요청을 처리하기에 항상 적당한 것은 아니다.
이를 해결하기 위해 요청을 받는 부분과 요청을 처리하는 부분을 분리하려 한다.
알림을 보내는 곳에서는 : 요청을 큐에 넣은 뒤에 결과를 기다리지 않고 리턴한다. 보내는 쪽에서 처리 응답을 받아야 한다면 큐를 쓰는 게 적합하지 않다.
요청을 처리하는 곳에서는 : 큐에 들어 있는 요청을 나중에 처리한다. 요청은 그곳에서 직접 처리될 수도 있고, 다른 여러 곳으로 보내질 수도 있다. (+루프에 빠질 수 있어서, 이벤트를 처리하는 코드 내에서는 이벤트를 보내지 않는 것이 일반적인 규칙이다)
키워드: 시점
- 메시지를 보내는 시점과 받는 시점을 분리(decoupling)하고 싶을 때만 큐가 필요하다. 다른 디커플링 패턴은 제공하지 못하고 큐만 제공할 수 있는 기능이다.
- 만약 메시지를 보내는 곳과 받는 곳을 분리하고 싶을 뿐이라면, 옵저버 패턴 또는 커맨드 패턴을 사용할 수 있다.
- 동기, 비동기에는 time out(에러처리)가 필수
- 모든 비동기에는 알림이 필수
비동기 프로그래밍을 사용하면 C에서 문제가 생겨도 A,B 전체 서비스까지에 영향을 미치지 않아서 동기 프로그래밍보다 장점이 있다.
그렇다면 모든 프로그래밍을 비동기로 해야 효율적인 것이 아닌가? 라고 물어본다면?!
아니다! B에서 A로 결과를 보낼 때 즉각적으로 보낼 필요가 있다면, 메시지 큐 형태는 상대적으로 느릴 수 있다.
중앙 이벤트 큐는 전역 변수와 같다. 앞서 말한 것처럼 전역 변수가 항상 좋은 건 아니다.
월드의 상태는 언제든 바뀔 수 있다. 특정 모듈의 실행이 끝날 때까지 기다리는 동기가 아니라, 비동기이다보니, 이벤트를 큐에서 꺼내 처리할 때쯤이면 몇 프레임 정도가 지나 있을 것이다. 그래서 그 당시 상황에 따라 처리해주려면 월드의 그 순간의 일시적인 상태에 대한 정보를 다양하게 알아야 한다.
아래 예시와 같은 루프(순환 문제) 에 빠질 수 있다. 메시징 시스템이 동기적이라면 스택 오버플로 크래시가 나기 때문에 순환을 금방 찾을 수 있지만, 문제는 큐 시스템일 때이다. 비동기이다 보니, 콜스택이 풀려서(함수 호출이 끝난 다음, 자신이 쌓아놓은 콜스택을 제거함) A와 B가 계속 이벤트를 주고받는 문제가 일어날 수 있다. 그래서 이벤트를 처리하는 코드 내에서는 이벤트를 보내지 않는 것이 일반적인 규칙이다.
예시) 루프
- A가 이벤트를 보낸다.
- B가 이벤트를 받아 응답으로 다른 이벤트를 보낸다.
- 이 이벤트가 우연찮게 A에서 처리해줘야 하는 작업이라, A가 이벤트를 받는다. 그에 대한 응답으로 다른 이벤트를 보낸다.
- 2번으로 간다.
playSound()
가 바로 리턴하게 하려면 사운드 출력 작업을 지연시킬 수 있어야 한다.
요청을 보류한 후 나중에 사운드를 출력할 때 필요한 정보를 저장할 구조체를 정의해준다.
struct playMessage {
SoundId id;
int volume;
};
동일한 데이터들을 저장하는 가장 좋은 방법은 기본 배열을 쓰는 것이다.
기본 배열의 장점
// 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()
에서 한 번에 하나의 요청만 처리하게 하려면 버퍼에서 요청을 꺼낼 수 있어야 한다. (= 진짜 큐가 필요하다)
큐를 구현하는 방법 중 원형 버퍼를 사용하면 이점이 있다.
head, tail 만 조절해주면 값을 옮겨주거나 memory reallocation 의 과정 없이 큐를 구현할 수 있다. tail 뿐만 아니라 head 도 오른쪽으로 움직이는데, 더 이상 사용하지 않는 배열 값이 배영ㄹ 앞부분에 쌓여 있기 때문에 tail이 배열의 끝에 도달하면 다시 배열의 앞으로 보내서 원형 배열처럼 사용할 수 있다.
동적 할당도 필요없고, 데이터를 옮길 필요도 없고, 캐시하기 좋은 큐를 만들 수 있다.
// 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; // 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;
}
}
// ...
}
위 코드에서는 어차피 취합하면서 없어질 요청, 미리 큐에 두지 않도록 큐에 넣기 전에 취합했다.
그러나 호출하는 쪽이 전체 큐를 쭉 돈 다음에 리턴하기 때문에, 요청을 처리하는 쪽에서 취합하는게 더 나을 수도 있다. (케바케)
(다른 자료구조, 예를 들면 해시테이블을 사용하면 탐색이 더 빨라지겠지만..)
또 큐에 넣은 요청이 실제로 처리될 때까지 걸리는 시간이 동작에 영향을 줄 수 있다는 점을 유의하자.
(너무 오래 큐에 있으면, 나중에 들어온 큐에 의해 취합되거나, 뒤에 들어온 큐가 버려질 수 있다.)
지금까지 멀티코어를 적용하기 위한 세 가지 요건을 충족했다.
남은 것은 큐가 동시에 수정되는 것만 막는 것 뿐이다. (뮤텍스 ?)