Coroutine과 Task를 사용할 때 DeadLock
버그 리포트를 처리 하던 중 처음 보는 유형의 버그를 처리해서 기록하려한다.
버그의 내용은 아래와 같다.
1.아이폰7에서 플레이를 했다.
2.게임에 들어간 직후 자동사냥을 한다.
3.게임이 멈춘 후 시간이 지난 후에 크래쉬가 난다.
시트의 내용을 처음 봤을 때 오래 걸릴 것이라고 바로 생각했다.
지금까지 경험으로 봤을 때 특정 기종 + 크래쉬 조합은 찾기가 제법 까다로웠기 때문이다.
직접 테스트 결과 100% 발생하는 것은 아니고 간혈적으로 일어 난다는 것을 깨닫았다.
혹시 다른 기종도 문제일까 ipad 12.9 5th로 여러 차례 테스트를 해봤지만 재현이 되지 않았다.
Android도 갤럭시 노트 10+로 테스트를 여러 차례 해보았지만 재현이 되지 않았다.
특정 기종 + 간혈적 + 크래쉬라는 3중고에 빠져서 잠시 패닉이 왔지만
기적적으로 몇일 전에 테스트 했을 때가 기억이 났다.
이 문제를 해결하기 전에 나는 메모리를 최대한 줄일 수 있는 것들을 테스트 하고 있었고 그 중에 이펙트 풀을 하 옵션에서는 로드를 안하는 것을 테스트를 했었는데, 단순히 미리 로드만 안하도록 막자 유니티에서 게임이 멈추는 것을 경험했었다.
다만 이 때는 멀쩡한 코드를 내가 잘못 수정했기 때문에 발생한 현상이라고 생각했고, 나에게 메모리를 줄일 수 있는 시간이 넉넉하지 않아서 로드 뿐만 아니라 호출하는 부분도 모두 막아서 테스트를 했었다.
아무튼 결과 적으로 이 과정에서 프리징을 겪었던게 큰 도움이 됐다. 그렇지 않았으면 아마 나는 이 문제에 더 많은 시간을 투자했어야 했을 테니깐
해당 스크립트로 찾아가 게임이 멈출만한 코드를 모두 찾은 후에 소거법으로 현상을 재현하는 것에 성공했다. 사실상 여기서 문제 해결은 90% 끝난 것이다.
다만 왜 게임이 프리징이 걸리는지 생각하는데 좀 더 많은 시간이 걸렸을 뿐
자세한 설명은 뒤에 더 하겠지만 요약하자면 'Unity에서만 작업을 하면서 비동기적인 작업을 할 때 Coroutine밖에 사용할 일이 거의 없다보니 Task가 어떻게 동작하는지 알지 못했기 때문이다'
뒤를 돌아보니 인터넷에 자주 나오는 케이스이며 매우 쉬운 문제였다.
아래는 문제가 일어났던 코드를 간단히 요약한 코드이다.
//이펙트 풀링
Dictionary<int,GameObject> dic;
//특정 상황에 사용하는 오브젝트
GameObject go;
//게임에 들어갈때 이펙트들을 비동기로 풀링한다.
public void Init()
{
StartCoroutine(LoadObject());
}
IEnumerator LoadObject()
{
for (int i = 0; i < 100;++i)
{
var v = Addressables.LoadAssetAsync<GameObject>(i);
yield return v;
dic.Add(i,v.Result);
}
}
// 이펙트를 가져오는 코드 풀링이 이미 되어 있으면 오브젝트를 바로 가져오고 없으면 생성
// 필자가 만든 코드는 아니지만 추측해보건데 IEnumerator를 사용 못하는 코드에서도 간단히 사용 할 수 있어서 이런식으로 작성한 것으로 보인다.
async Task<GameObject> Load(int id)
{
if (!dic.ContainsKey(id))
{
var v = await Addressables.LoadAssetAsync<GameObject>(id).Task;
dic.Add(id, v);
}
if (dic.ContainsKey(id))
{
return dic[id];
}
return null;
}
// 게임을 멈추는 코드
IEnumerator Set(int id)
{
var task = Load(id);
yield return task;
//프리징
go = task.Result;
}
결론부터 이야기 하면 예를 들어 유니티에서 자주 사용하는 Coroutine에서 yield return Addressables.LoadAssetAsync를 하면 해당 구문은 load가 끝날 때까지 기다리고 완료가 되면 return되서 넘어간다.
하지만 Task의 경우(위 코드의 async Task<GameObject> Load) 해당 코드안에 Task는 작업이 완료가 되지 않아도 완료가 되지 않았다는 내용의 Task가 return된다.
그럼 결국 yield return task; 구문은 아무런 영향이 없이 task.Result를 실행하게 되고 비완료된 Task에서 Result를 실행할 경우 컨텍스트를 동기적으로 차단하게 되고 결국 그 후에
Task Load가 완료 되더라도 이것이 컨텍스트가 이용가능 할 때까지 기다리기 때문에 교착상태 즉 deadlock이 발생해서 유니티의 메인쓰레드가 멈춰버린것이였다....
그럼 다시 처음으로 돌아가서 왜 지금까지 재현이 되지 않다가 아이폰7에서 재현이 되었나?
- 게임에 들어가서 Load함수를 호출 할 때 이미 로드가 된 상태면 바로 반환을 해주기 때문에 task.Result를 사용해도 아무런 이상이 없다. 즉 휴대폰에 성능이 좋은 폰이거나 게임에 들어가서 바로 혹은 빠르게 함수를 사용 하는 케이스가 아니면 보기 힘든 문제였다는 것이다.
간혈적으로 일어난 이유
- 해당 함수는 몬스터가 죽고 드랍아이템이 나올 때만 호출되는데 게임에 접속 후 빠르게 사냥을 했을 해도 드랍아이템이 나오는 것은 확률적이였기 때문
크래쉬가 난 이유
- 오래 동안 멈춰있어서 OS에서 앱을 죽였다.
그럼 해결을 어떻게 하면 되느냐 Set 함수가 async 였다면 await로 끝날 일이지만 Coroutine으로 되어 있었기 때문에 아래처럼 코드를 수정했다.
IEnumerator Set(int id)
{
var task = Load(id);
yield return new WaitUntil(() => task.IsCompleted);
go = task.Result;
}
간단하다 task가 완료 될 때까지 정상적으로 기다리게 수정해주면 해결이 된다.
게임이 바로 크래쉬가 나는건 여러번 경험해봤지만 오버플로를 제외하고는 멈추는것은 보지 못했는데 빨리 찾아서 다행인 버그였다.