Synchronous Asynchronous, Blocking Non-blocking이라는 말은 운영체제(OS)를 공부하면서 듣게 된 개념이다.

프로그래밍을 함에 있어 중요한 개념인 만큼 이번 기회에 정리를 해보고자 한다.


Synchronous와 Asynchronous

Synchronous

Synchronous의 의미는 동기식이라는 의미로,

작업을 동시에 수행하거나, 동시에 끝나거나, 끝나는 동시에 시작함을 의미한다.

https://www.koyeb.com/blog/introduction-to-synchronous-and-asynchronous-processing

위 그림을 보면 Process A가 작업을 하는 도중 필요로 인해 Process B에게 작업을 요청한 상태이다.

이때 Process A는 Process B의 작업이 완료되는 즉시 결과에 대한 작업을 진행할 것 이다.

 

동기 방식은 요청한 작업의 결과를 즉시 처리하는 특징이 있다.

그렇기 때문에 위의 예시를 보았듯이 동기식의 흐름 방식은 직관적이기 때문에 이해하기가 쉽다.

Asynchronous

Asynchronous는 비동기식이라는 의미로,

시작, 종료가 일치하지 않으며, 끝나는 동시에 시작을 하지 않음을 의미한다.

https://www.koyeb.com/blog/introduction-to-synchronous-and-asynchronous-processing

위 그림 역시 동기식의 상황과는 똑같은 상태이다.

비동기식에서는 Process A는 Process B가 작업을 완료되어 돌아온 결과에 대한 작업을 진행할 수도 있고 안할 수 있다.

 

비동기 방식은 동기 방식과 다르게 요청한 작업의 결과를 즉시 처리하지 않는다는 특징이 있다.

동기 방식과는 다르게 작업 완료에 대한 결과를 예측하기 어렵기 때문에 직관적이지 못한면이 있다.

 

위의 설명만으로는 동기와 비동기에 대한 이해가 어려울 수 있기 때문에 식당의 예시를 살펴보자.

Synchronous Example

웨이터는 주문을 받고 해당 주문을 요리사에게 요청을 한 이후 음식을 제공하는 일을 한다.

 

웨이터가 동기 방식으로 일을 수행하게 된다면 요리사에게 제공할 음식을 만들어달라고 요청한 이후

기다리거나 다른 일을 하고 있을 것이다.

그 이후 따뜻한 요리를 곧장 제공하기 위해 요리가 나오는 즉시 손님에게 제공을 해줄 것 이다.

Asynchronous Example

요리사는 요리를 한 이후 서빙을 하라고 지시할 것이다. 이때 요리사는 기다리거나 다른 일을 하고 있을 것이다.

만약 식사가 끝난 손님의 빈 접시를 웨이터가 전해주게 된다면

요리사는 들어오는 주문에 맞는 요리를 하느라 빈 접시들이 들어오는 즉시 설거지를 하지 않을 것이다.

 

즉 동기와 비동기는 결과를 돌려주었을 때 순서와 결과에 관심이 있는지 없는지로 판단할 수 있다.


Blocking과 Non-blocking

Blocking

Blocking이란 자신의 작업을 진행하다가 다른 주체의 작업이 시작되면

해당 작업이 완료될 때까지 기다렸다가 자신의 작업을 재개하는 것을 의미한다.

Blocking Processing

위의 그림을 보면 Process A가 작업을 하는 도중 Process B의 작업이 시작됨에 따라

Process A의 작업이 일시 중지 되는 것을 볼 수 있다.

Process B의 작업이 끝난 이후 Process A의 작업이 재개되는 것을 확인할 수 있다.

즉, 작업을 진행하는 제어권을 일시적으로 위임했다고 볼 수 있다.

Non-blocking

Non-blocking이란 Blocking과 달리 다른 주체의 작업이 시작된다고 해서

해당 작업이 완료될 때까지 기다리지 않고 자신의 작업을 하는 것을 의미한다.

Non-blocking Processing

Non-blocking은 Blocking과 달리 Process B의 작업이 시작되어도 작업이 중단되지 않고

계속해서 작업을 진행하는 것을 볼 수 있다.

즉, 작업을 진행하다 다른 작업이 시작되어도 제어권을 잃지 않는다는 것을 알 수 있다.

 

이렇듯 Blocking과 Non-blocking은 작업을 진행할 때 본인의 제어권이 있느냐 없느냐로 판단할 수 있다.


마무리

지금까지 Synchronous와 Asynchronous의 차이 및 Blocking과 Non-blocking의 차이에 대해서 알아보았다.

위의 두 부류는 명확하게 다른 개념이므로 헷갈려선 안된다.

 

또한 더 나아가 다음 게시글에서는

Sync blocking, Sync non-blocking Async blocking, Async non-blocking에 대해서도 다뤄볼려고 한다.


참고 영상 : https://www.youtube.com/watch?v=oEIoqGd-Sns 

해시 테이블(HashTable)이란?

KeyValue로 데이터를 저장하는 방법 중 하나로 데이터를 빠르게 검색할 수 있는 자료구조이다.

해시 테이블은  Key 값을 '해시값'으로 생성한 이후 실제 데이터가 저장되는 장소인 '버킷(Buckets)'에 저장하는 방식이다.

https://ko.wikipedia.org/wiki/%ED%95%B4%EC%8B%9C_%ED%85%8C%EC%9D%B4%EB%B8%94

이때 해시값은 '해시 함수'를 통해 연산이 되어 생성이 되는데해시 함수는 해당 키 값에 대해서는 항상 고정적인 Index를 반환한다.이러한 과정을 통해 Key로 한번에 필요한 데이터를 찾는 게 가능하기 때문에평균적으로 O(1) 시간복잡도를 가진다.

해시 함수(Hash Function)

해시 함수는 임의의 길이의 데이터를 고정된 길이의 데이터로 매핑하는 함수이다.이러한 해시 함수를 적용하여 나온 고정된 길이의 값을 해시값, 해시 코드, 해시섬(sum), 체크섬 등으로 부른다.

 

해시 함수는 결정론적 알고리즘(Deterministic Algorithm)으로 작동해야 한다.그렇기 때문에 두 해시 값이 다르다면 그 해시값에 대한 원래 데이터가 달라야 한다.(역은 성립하지 않는다.)

 

하지만 이러한 해시값은 '고유의 값(Unique Value)'을 갖지 않기 때문에서로 다른 입력값에도 동일한 값이 출력되는 경우도 존재한다.이러한 경우를 '해시 충돌(Hash Collision)'이라고 한다.


해시 충돌(Hash Collision)

해시 테이블에선 동일한 해시값에 의한 충돌을 크게 2가지 방식으로 해결하고 있다.

분리 연결법(Separate Chaining)

각 Index에 데이터를 Linked List에 대한 포인터를 가지는 충돌 처리 방식이다.

동일한 Index로 인해 충돌이 발생한다면

해당 Index가 가리키고 있는 Linked List에 노드를 추가하여 값을 추가한다.

 

데이터를 추출할 때는 Key에 대한 Index를 통해

해당 Index가 가리키고 있는 Linked List를 선형 검색하여 데이터를 가져오게 된다.

삭제 역시 Index가 가리키고 있는 Linked List에서 해당 데이터를 삭제하면 된다.

 

새로운 데이터를 추가할 때 테이블이 가득 차도 각 버킷에 있는 Linked List에 추가할 수 있기 때문에

추가하는 데이터 수의 제약이 적다.

 

시간 복잡도 

중복된 해시값이 많아질수록 List에서 선형으로 검색이 이루어지기 때문에

데이터 삽입, 삭제, 검색에 있어 시간복잡도는 평균적으로 O(1)을 가진다.

하지만 최악의 경우 최대 O(n) 시간 복잡도까지 증가하게 되어 그만큼 캐시의 효율성이 떨어진다.

 

개방 주소법(Open Addressing)

빈 버킷을 찾아 데이터를 저장하는 충돌 처리 방식이며,

찾는 방식은 세 가지로 나뉜다.

 

1. 선형 탐사(Linear Probing)

충돌 발생 시 Index에 해당하는 버킷의 다음 버킷들을 순차적으로 탐색하며

빈 버킷 발견 시 해당 버킷에 데이터를 저장하는 방식으로 구현이 간단하다.

Linear Probing

위와 같이 Key % 9 해시 함수를 사용하는 해시 테이블이 있을 때

Key값이 1, 12인 경우는 충돌이 없어 데이터가 저장된 모습이다.

하지만 Key값이 10인 경우엔 1과 충돌이 나기 때문에

그 이후 존재하는 빈 버킷에 바로 데이터를 저장하고 있다.

 

하지만 충돌이 빈번해지면 특정 구간에 연속된 데이터가 몰리는 클러스터링(clustering) 문제가 발생하게 된다. 

이는 탐색 시간을 오래걸리게 하여 검색 효율이 떨어지게 된다.

 

2. 이차 탐사(Quadratic Probing)

선형 탐사와 비슷하지만 간격이 이차 함수 꼴로 증가하면서 빈 버킷을 찾는 방식이다.

 

예를 들어 Index가 3에서 충돌이 난다면 다음과 같은 형태로 Index가 증가한다.

3 → 3 + 1^2 → 3 + 2^2 → 3 + 3^3 → ... 

 

선형 탐색에 비해 클러스터링이 적게 일어나지만

만약 두 Key의 해시값이 동일하다면 빈 버킷을 찾는 과정이 동일하므로 같은 버킷을 탐색하게 된다.

즉 처음 충돌한 위치가 같다면 다음 충돌할 위치에서도 반복적으로 충돌이 나게 되는 것이다.

2차 클러스터링 문제가 발생하게 된다.

 

3. 이중 해싱(Double Hashing)

이중 해싱은 클러스터링 문제를 방지하기 위한 기법으로

두 개의 해시 함수를 사용해 빈 버킷을 찾는 방식이다.

 

첫 해시 함수를 통해 해시값을 얻었을 때 충돌이 날 경우

두 번째 해시 함수를 통해 새로운 해시값을 받아 빈 버킷을 찾는다.

만약 이후에도 충돌이 난다면 두 번째 해시 함수를 통해 계속해서 빈 버킷을 탐색한다.

 

이러한 방식은 충돌이 발생했을 때 무작위로 빈 버킷을 찾게 되어 클러스터링 문제를 피할 수 있다.

하지만 그만큼 구현이 복잡하며 위의 두 방식에 비해 연산이 오래 걸린다는 단점이 있다.

 

삭제

삭제를 하기 위한 버킷을 찾고 해당 버킷을 삭제되었다고 표시한다.해당 버킷을 삭제되었다고 표시하지 않고 완전히 삭제하게 된다면탐색을 하지 못하는 경우가 발생할 수 있다.

Open Addressing Delete Problem

위의 그림에서 모든 Key는 해시 함수를 통해 Index 1을 반환받게 된다.

Key 1의 경우 그대로 데이터가 적재되지만

나머지 10, 19의 경우 충돌로 인해 그 다음 Index에 연속적으로 적재된 것을 확인할 수 있다.

 

이때 Key 10을 완전히 삭제하게 될 경우 Key 19의 값을 찾을 수 없게 되는데

그 이유는 충돌을 해결하기 위해 사용된 연속적인 탐사 방식이 깨지기 때문이다.

Key 19의 값을 찾기 위해 Index 1부터 시작해 차례로 탐색을 하는 과정에서

빈 상태를 확인하게 되면 해당 자리에 값이 Key 19의 값이 없다고 판단하고 탐사를 중단해 버린다.

 

그렇기 때문에 완전히 삭제하지 않고 삭제되었다는 표시를 통해 유지한다.

해당 삭제 방식은 선형 탐사를 제외한 나머지 방식(이차 탐사, 이중 해시)에서도 똑같이 적용이 된다.

 

시간 복잡도

개방 주소형같은 경우도 충돌 시 찾아가는 횟수가 많아지므로

최상의 경우 O(1)에서 최악의 경우 O(n)으로 증가하게 된다.


리사이징(Resizing)

분리 연결법(Separate Chaining)의 경우 버킷이 일정 수준이상 차 버리면

각 버킷에 연결되어 있는 List의 길이가 늘어나기 때문에 성능 저하가 일어나고

주소 개방형(Open Addressing)의 경우 고정 크기 배열을 사용하기 때문에

일정 수준이상 차 버리면 성능 저하가 일어날 뿐더러 데이터를 적재할 공간이 남지 않게 된다.

 

즉 해시 테이블의 '부하율(Load Factor)'이 높아질수록 그만큼 충돌이 많아지고 성능이 저하되기 때문에

이러한 경우 리사이징이 일어난다.

리사이징 이후에는 테이블이 더 커지기 때문에 충돌이 줄어들어 검색 성능이 다시 O(1)에 가깝게 유지되게 된다.

 

하지만 리사이징을 통해 테이블의 크기가 늘어나면

모든 데이터를 새 해시 테이블로 재해싱하는 작업이 필요하므로 작업에 대한 큰 비용을 요구하게 된다.


참고 블로그

https://bcho.tistory.com/1072

https://yoongrammer.tistory.com/82

Asset이란?

유니티 프로젝트에서 사용하는 텍스처, 오디오, 스크립트, 애니메이션 등의 모든 데이터를 의미한다.

이러한 데이터들은 보통 외부에서 가져온 파일인데,

해당 파일들을 Unity에서 처리할 수 있는 형식으로 Import하여 사용할 수 있는 것이 'Asset'이다.

 

이번 게시글에서는 유니티에서 제공하는 기능을 통해 어떻게 에셋을 관리하고 어떤 부분에서 차이가 나는지에 대해 알아보고자 한다.

 

기본적으로 유니티에서 프로젝트를 빌드할 경우

각 씬(Scene)별로 패키징되어 씬을 로드할 때 해당 씬에 필요한 모든 에셋이 메모리에 로드된다.

Inspector창에서 Asset을 직접 참조

빌드 과정에서 에셋이 씬에 포함되지 않았거나, 위의 사진과 같이 Inspector 창에서 참조되지 않은 경우는 빌드 파일에 포함되지 않는다.


Resources

에셋을 동적으로 사용하기 위해서는 메모리에 올라와 있는 에셋을 참조하고 있는 매개체가 필요하다.

유니티에선 이러한 참조 매개체 역할을 해주는 Resources 시스템이 있다.

 

해당 시스템의 경우

'Asset' 폴더내에 'Resources' 폴더를 포함할 시 빌드 할 때 해당 폴더 내에 있는 모든 에셋을 패키징하여

리소스가 필요한 순간에 모든 에셋이 메모리에 로드되는 방식이다.

한 곳에 묶어진 Asset들

모든 에셋을 메모리에 로드하는 방식이기 때문에 일반적으로 다음과 같은 단점을 가진다.

  1. 세분화된 메모리 관리가 어려워 짐
  2. 애플리케이션 시작 시간과 빌드 시간이 길어짐
  3. 특정 플랫폼에 사용자 지정 콘텐츠를 제공하는 프로젝트의 기능을 저하시키고 점진적인 콘텐츠 업그레이드의 가능성을 제거

모든 에셋을 한 번에 패키징하기 때문에 메모리를 세분화로 관리하기 어려워지며

에셋이 많아질 수록 그만큼 시작과 빌드 시간이 길어지게 된다.또한 플랫폼 별 불필요한 에셋마저 빌드하기 때문에 프로젝트의 기능이 저하되며추가 콘텐츠 제작 시 새로운 에셋들만 빌드하기가 어렵기 때문에 업그레이드 가능성을 제거하는 것과 마찬가지이다.

 

유니티에서는 이러한 문제점들로 인해 Resources 시스템을 사용하는 것을 권장하지 않는다.

 

다만 리소스 폴더는 사용하기 쉬워 빠르게 프로토타입을 제작할 경우 사용하고,프로젝트가 정식 프로덕션 단계로 넘어갈 땐 제거하는 방식으로 사용하기도 한다.또한 일반적으로 프로젝트의 lifetime 동안 계속 필요한 에셋의 경우 사용한다.


AssetBundle

AssetBundle은 Resources 시스템의 단점을 보완하여 나온 것이다.

 

AssetBundle은 게임을 논리적 블록으로 분할할 수 있어

게임 빌드의 크기를 줄이면서 온디맨드(On-demand) 방식으로 콘텐츠를 제공하고 업데이트할 수 있다.

에셋 번들에는 프리팹, 머테리얼, 텍스처, 오디오 클립, 씬 등을 비롯한 모든 종류의 에셋을 포함할 수 있지만 스크립트는 포함할 수 없다.

종료 별로 묶어진 Asset들

AssetBundle은 일반적으로 두 부분으로 구성되어있다.

헤더(Header)

헤더에는 에셋 번들이 빌드될 때 유니티로부터 받은 많은 양의 정보가 들어있는 곳이다.

에셋번들의 식별자(Version에 대한 정보 같은), 압축 방식, 매니페스터 (manifest) 등과 같은 정보가 있다.

매니페스트에는 에셋의 이름을 키로 삼아 해당 에셋이 데이터 세그먼트에 위치한 바이트 인덱스를 저장하는 검색 테이블이다.

데이터 세그먼트(Data Segment)

직렬화 된 에셋의 실제 데이터가 저장되는 부분이며 위치를 찾을 수 있는 인덱스를 제공한다.

 

보통 검색의 경우 대부분 균형 검색 트리(balanced search tree)를 사용하며,Windows와 OSX계열(IOS포함) 플랫폼은 레드 블랙트리를 사용한다.

 

또한 그룹화하여 저장을 할 때 압축 방식을 선택할 수 있는데, 그 방식 3가지로 나뉜다.

  • LZMA : 직렬화된 모든 에셋에 대한 전체 바이트 배열이 압축되는 방식
  • LZ4 : 개별 에셋의 바이트가 개별적으로 압축되는 방식
  • 비압축 : 원시 바이트 스트림으로 유지

비압축 방식으로 빌드할 경우 애플리케이션을 다운로드 할 때 에셋 번들의 크기를 추가 다운로드 하므로 시간 소요가 제일 길다.

하지만 다운로드 이후 에셋을 빠르게 로드할 수 있다.

 

일반적으로 압축을 할 때 압축 방식을 NONE으로 빌드하게 되면

LZMA(Lempel-Ziv-Markov chain Algorithm) 방식으로 압축이 이루어진다.

에셋 번들 내에 있는 전체 에셋을 한 번에 압축하기 때문에 파일의 크기가 가장 작아진다.

하지만 에셋을 로드할 경우 전체 압축을 해제하기 때문에 그만큼 압축 해제가 느리다.

 

Unity 5.3 이후 버전부터는 LZ4 압축 방식이 등장하였다.

청크 기반 알고리즘을 사용하여 에셋을 부분적 또는 "청크" 단위로 로드될 수 있도록 한다.

그렇기 때문에 번들 내의 모든 에셋을 한꺼번에 압축 해제하지 않아 시간이 많이 소요되지 않는다.

하지만 LZMA 압축 방식에 비해 압축 크기는 큰 편이다.

 

특정 상황에 맞게 압축 방식을 선택하여 빌드를 한다면

사용자에게 있어 더 나은 경험을 전달할 수 있기 때문에 압축 방식에 대한 간단한 이해는 필수라고 생각한다.

더보기

유니티는 성능 최적화를 위해 LZMA로 압축된 에셋 번들은 다시 LZ4 압축으로 재압축되어 로컬 파일 시스템에서 캐시된다.

AsserBundle WorkFlow

빌드한 에셋 번들을 CDN(파일 저장 서버 및 클라우드)에 올린다.

CDN에 UnityWebRequest를 요청하여 다운받아 캐싱한다. (캐싱이 되면 다시 실행할 때는 다운할 필요가 없다.)

클라이언트에서 에셋 번들을 통해 에셋을사용한다.

 

AssetBundle도 좋은 장점을 지닌 반면 단점 또한 존재한다.

의존성

하나의 AssetBundle에 포함된 에셋이 다른 AssetBundle에 의존하고 있는 경우를 말한다.

의존성의 문제

의존성의 문제로 인해 하나의 리소스를 각 번들에 적재함으로서 동일한 에셋이 중복되어 메모리 낭비의 원인이 된다.

의존성의 문제

또한 위와 같이 세분화하여 번들을 나누더라도 A와 B Object는 Image를 의존하기 때문에

C번들에 있는 Image를 사용하기 위해 이외의 모든 에셋들을 메모리에 올려야 하므로 메모리 낭비의 원인이 된다.

 

이러한 의존성 문제를 해결하기 위해 개발자가 따로 관리해줄 필요가 있다.

관리가 간단하고 할 양이 많지 않다면 괜찮겠지만

프로젝트 규모에 따라 관리가 쉽지 않기 때문에 문제가 된다.

경로

에셋을 로드하기 위해서는 경로와 파일명을 통해 해당 에셋이 들어있는 번들과 에셋을 찾을 수 있는데

해당 경로와 파일명을 string으로 매칭시켜 수정에 매우 취약하다.

 

한 에셋의 이름이 수정될 경우 해당 에셋을 로드하는 코드(파일명)도

일일이 다 수정해야할 뿐더러

문자열 오타로 인해 매칭이 끊어져버릴 수 있기 때문에 번거로움이 많다.


Addressable

Addressable은 AssetBundle을 토대로 설계된 시스템으로완전히 새롭게 만들어진 것이 아니라 AssetBundle의 편의성을 개선하기 위해 등장한 시스템이다.

카탈로그(Catalog)

AssetBundle에 manifest가 있다면 Addressable에는 Catalog가 있다.

Catalog는 Addressable의 번들과 함께 생성되는 것으로

Asset의 Address(Group 및 Lable 정보)와 Asset의 매핑 정보가 기재된 파일이다.

또한 버전이 붙어서 생성되기 때문에 번들 자체의 버전 관리도 된다.

 

Addressable이 AssetBundle의 단점을 보완하여 나온 만큼 다음과 같은 보완을 통해

이전의 단점을 보완했다.

의존성

기존 AssetBundle의 경우 의존성으로 인해 메모리 낭비의 원인이 되는 문제가 있었다.

Addressable의 경우 번들 단위로 의존성이 묶이지 않는다.

의존성 문제 해결

서로 다른 번들이라도 의존성이 있다면  API에서 일괄 관리가 되기 때문에

기존 의존성에 의한 중복이나 수동관리가 해결됐다.

경로

기존 AssetBundle의 경우 Asset을 로드하기 위해

해당 Asset이 저장된 Path와 코드에서 사용하는 Path 값을 똑같이 맞춰야한다.

그렇기 때문에 한쪽이 변경된다면 다른 쪽도 변경해줘야하는 번거로움이 생겼다.

 

Addressable은 이름에서 볼 수 있듯이 Address(참조) 방식을 사용한다.Asset의 Path를 참조하기 때문에 Path를 참조하는 값만 처음에 맞춰서 작업해 놓는다면경로가 바뀌거나 에셋의 이름을 변경하더라도 별도로 수정할 번거로움이 없어졌다.

번들 로드 & 언로드

이전 Bundle의 경우 Asset을 로드하기 위해 Bundle을 수동으로 로드하여야 했고

또한 메모리 관리를 위해 쓰지 않는 Asset의 경우 수동으로 언로드 해야했다.

 

하지만 Addressable API의 경우 번들을 로드하고 에셋을 로드하는 과정이 한번에 묶여있기 때문에

기존에 보다 간단하게 구현된다.

즉, 에셋을 요청하면 자동으로 번들을 로드해준다는 뜻이다.

 

또한 언로드하는 과정도 자동으로 이루어지는데

이는 참조 카운팅 방식을 통해 메모리 관리를 효율적으로 도와준다.


마무리

Asset을 잘 관리하여야 메모리 효율을 높일 수 있으며

애플리케이션의 크기 또한 간소화 시킬 수 있다.

 

특히 모바일 애플리케이션의 경우 PC와는 달리 배포 가능한 애플리케이션의 크기가 상당히 제한되어 있기 때문에

애플리케이션의 크기를 간소화할 필요가 있다.

 

이렇게 Asset들을 관리하는 방식과 원리에 대해 자세히 알고 적용한다면

효율적인 빌드가 가능해진다.


참고 블로그

https://gus6615.tistory.com/m/85

https://medium.com/pinkfong/unity-addressable-asset-%EB%A5%BC-%EC%99%9C-3017f3fa2edc

참고 문서

https://docs.unity3d.com/kr/2021.2/Manual/AssetBundles-Building.html

C#에는 문자열 데이터를 저장하기 위한 타입 string이 존재한다.

오늘 게시글에선 C# string의 특징에 대해 자세히 알아보려고 한다.


참조형(Reference Type)

string은 int, float와 같은 기본형 타입으로 제공하는 것이 아닌,

namespace System.String의 별칭이다.

 

기본적으로 참조값 형태로 데이터가 저장되며, 그로 인해 스택 영역이 아닌 힙 영역에 데이터가 저장된다.

string이 참조형으로 설계된 이유는 데이터의 크기가 가변적이기 때문이다.

 

크기가 작을 경우 값 형식으로 설계하는 것이 맞지만,

앞서 말했듯이 크기가 가변적이기 때문에 크기가 클 수가 있다.

크기가 클 경우 복사 비용의 부담이 커 비효율적이기 때문에 참조형식을 통해 메모리의 효율성을 높였다.


불변성(Immutable)

string은 한 번 생성된 문자열을 변경할 수 없는 불변 객체(Immutable object)이다.

즉, 문자열 일부를 변경하게 될 경우 기존 문자열에서 수정되는 것이 아닌 새로운 문자열 객체를 생성하는 것이다.

string str1 = "ABC"; // ABC
string str2 = str1; // ABC
str1 = "CCC";
// str1 : CCC
// str2 : ABC

 

string이 불변성으로 설계된 이유는 문자열 interning이라는 최적화 기법을 사용하기 위해서이다.

이 최적화 기법은 동일한 문자열에 대해 여러 번 사용될 경우,

새로운 객체를 생성하는 것이 아닌 기존 동일한 문자열 객체를 재사용하는 기법이다.

string str1 = "Hello";
string str2 = "Hello";
bool ObjectEqual = Object.ReferenceEquals(str1, str2); // true


마무리

기본적으로 string 타입(클래스)은 최적화를 위해 설계가 되어있으나,

사용하는 방식에 따라 전체적인 성능에 있어 악영향을 끼칠 수 있다.

 

문자열 데이터를 변경할 때 마다 새로운 객체를 생성(동일한 문자열이 메모리에 없을 시)을 하게될 것 이고,

그로 인해 이전 데이터 같은 경우 더 이상 참조하지 않으면 GC(Garbage Collector)에 의해 수집될 것 이다.

 

이러한 현상이 반복되면 잦은 GC 호출을 초래하게 되고,

그로 인한 프레임 드랍과 같은 영향을 끼칠 수 있기 때문에

StringBuilder 혹은 String.Format 메소드와 같은 최적화 기법을 통해 효율적으로 사용하는 것이 좋다.


참고 문서 : https://learn.microsoft.com/ko-kr/dotnet/csharp/programming-guide/strings/

'C#' 카테고리의 다른 글

[C#] 가비지 컬렉터(Garbage Collector)  (0) 2024.07.13

유니티에서 스크립트는 사전에 지정한 순서대로 여러 개의 이벤트 함수가 실행된다.

이때 이 이벤트 함수들은 MonoBehaviour에 속해 있는 이벤트들이다.

 

스크립트는 에디터상에서 존재하는 오브젝트에 하나의 컴포넌트로 추가하여 프레임워크를 실행할 수 있는데,

여기서 말하는 프레임워크가 유니티 생명 주기(Unity Life Cycle)가 된다.

MonoBehaviour란?

유니티에서 게임 오브젝트를 제어하기 위해서는 MonoBehaviour라는 클래스를 상속한 스크립트의 컴포넌트를 추가해야만 한다.

MonoBehaviour는 유니티에서 제공되는 기본 클래스로써,

모든 스크립트는 처음 생성 시 default로 Monobehaviour에 파생되어 있다.

그렇기 때문에 여러 이벤트 함수들을 정의하여 사용할 수 있다.


생명 주기 실행 순서

라이프사이클 플로우차트(LifeCycle FlowChart)

https://docs.unity3d.com/kr/2019.4/Manual/ExecutionOrder.html#FirstSceneLoad

플로우 차트에 나와있는 이벤트들의 대부분은 MonoBehaviour를 통해 이벤트 발생 시의 로직을 추가할 수 있고,

그 외의 'Internal animation update'에 속해 있는 이벤트와 같은 경우 StateMachineBehaviour를 통해 추가가 가능하다.

 

플로우 차트중 회색 바탕으로 이루어진 이벤트들의 경우는 애니메이션을 처리할 때 호출되는 내부 함수이며,

직접 호출할 수 없는 이벤트이다.

 

또한 위에서 아래로 흐르면서 화살표로 표시된 부분들이 있다.

화살표로 표시된 부분 밑의 이벤트부터 그 다음 화살표로 표시된 부분 위의 이벤트까지 각 오브젝트별로 호출된다.

예를 들어 A와 B의 객체가 있다면 다음과 같은 호출을 가진다.

  • A의 Awake, OnEnable 호출
  • B의 Awake, OnEnable 호출
  • A의 Reset 호출
  • B의 Reset 호출
  • A의 Start 호출
  • B의 Start 호출
  • ...

초기화 (Initialization)

유니티 생명 주기에서의 초기화는 3단계에 걸쳐서 이루어지며, 프로그램 시작 시 시작 프레임에서 한 번만 호출된다.

  • Awake : 인스턴스화 된 직후에 호출
  • OnEnable : 오브젝트 활성화 직후 호출
  • Start : 첫 번째 프레임 업데이트 전에 호출

이 3가지의 초기화 함수는 게임 오브젝트가 시작하는 동안 비활성화 상태인 경우 호출되지 않는다.

에디터 (Editor)

  • Reset : 오브젝트에 컴포넌트를 처음 연결하거나, Reset 커맨드를 사용할 때 호출된다.

업데이트 (Update)

프로그램이 시작되고 매 프레임마다 혹은 주기적으로 계속 호출되는 이벤트들이다.

  • FixedUpdate : 신뢰할 수 있는 타이머를 통해 일정 시간마다 주기적으로 호출
  •  Update : 한 프레임당 한 번 호출
  • LateUpdate : Update가 끝난 이후, 한 프레임당 한 번 호출

FixedUpdate는 다른 Update들과 다르게 일정 시간을 주기로 호출되기 때문에,

프레임 속도에 따라 한 프레임당 여러 번 호출 혹은 한 프레임 사이에 호출되지 않을 수 있다.

물리 (Physics)

OnTrigger, OnCollision 등은 물리적 충돌과 관련 있는 함수들로

Enter, Stay, Exit로 나뉘며 기본적으로 FixedUpdate와 같은 주기로 충돌 검사와 호출이 이루어진다.

입력 이벤트 (Input Events)

OnMouseEnter, Exit, Over, Down 등의 이벤트들은 마우스와 오브젝트가 상호작용하는 함수들로

Update 함수와 비슷하게 매 프레임마다 상호작용을 체크하여 그에 대응하는 함수를 호출한다.

애니메이션 (Animation)

플로우차트에서 애니메이션 업데이트는 Physics와 Game Logic 두 부분에 나타나 있는 것을 알 수 있다.

그 이유는 프레임에 따른 업데이트와 물리 연산이 필요하여 일정 시간에 따른 업데이트로 나누기 때문이다.

  • OnStateMachineEnter/Exit : State Machine Update 단계 동안 컨트롤러의 상태 머신이 엔트리/종료 상태를 전환할 때 호출
  • Fire Animation Events : 애니메이션 클립에 있는 모든 애니메이션 이벤트들을 호출
  • StateMachineBehaviour callbacks : OnStateEnter/Update/Exit, 최대 3개의 활성 상태를 가지며, 각 조건이 만족될 때 정의된 콜백을 호출
  • OnAnimatorMove : Update 프레임마다 Animator 정보가 갱신될 시에 호출
  • OnStateMove : OnAnimatorMove가 호출된 직후 호출
  • OnAnimatorIK : Update 프레임마다 IK 정보가 갱신될 시에 호출
  • OnStateIK : OnAnimatorIK가 호출된 직후 호출

여기까진 오버라이딩을 통해 정의가 가능한 이벤트들이며,

이후는 따로 함수를 호출할 수 없고 Unity가 애니메이션을 처리할 때 호출되는 내부 함수이다.

  • WriteProperties : 애니메이션을 통해 변경된 프로퍼티를 씬에 적용할 때 호출
  • State Machine Update : 애니메이터 모드에 따라 주기적으로 호출
  • ProcessGraph : 모든 애니메이션 그래프를 평가할 때 호출
  • ProcessAnimation : 애니메이션 그래프의 결과를 블렌딩할 때 호출
  • WriteTransforms : 모든 애니메이션화된 트랜스폼을 워커 스레드에서 씬에 작성할 때 호출

상태 머신 평가는 보통 멀티 스레드이지만, 특정 콜백(OnStateMachineEnter, OnStateMachineExit)을 추가하면 멀티스레딩이 비활성화된다.

렌더링 (Rendering)

LateUpdate가 끝난 이후에 호출되는 함수들이다.

  • OnPreCull : 카메라가 씬을 컬링하기 전에 호출
  • OnBecameVisible/Invisible : 오브젝트가 카메라에 표시되거나/되지 않을 때 호출
  • OnWillRenderObject : 오브젝트가 표시되면 각 카메라에 한 번 호출
  • OnPreRender : 카메라가 씬 렌더링을 시작하기 전에 호출
  • OnRenderObject : 모든 일반 씬 렌더링이 처리된 후 호출
  • OnRenderImage : 씬 레더링이 완료된 후 호출
  • OnGUI : GUI 이벤트에 따라 프레임당 여러 번 호출
  • OnDrawGizmos : 시각화 목적을 위한 기즈모를 그릴 때 호출

코루틴 (Coroutine)

일반적으로 Update 함수가 반환된 이후에 실행되며,

YieldInstruction이 완료될 때까지 다수의 프레임으로 나누어 호출된다.

자세한 설명은 아래 포스팅을 참고하자.

https://lms0408.tistory.com/1

 

[Unity] Coroutine

Unity에서 Coroutine은 단일 프레임 내에서 작업을 수행하지 않고 다수 프레임에서 작업을 수행할 수 있다.그로 인해 비동기적 처리 방식을 위한 코드를 구현할 때는 Coroutine을 많이 사용하기도 한다

lms0408.tistory.com

  • yield WaitForSeconds : 지정한 시간이 지난 후, 모든 Update 함수가 프레임에 호출된 후 계속해서 호출
  • yield WaitForFixedUpdate : 모든 FixedUpdate가 모든 스크립트에 호출된 후 계속해서 호출
  • yield WWW : www 다운로드가 완료된 후 계속해서 호출
  • yield StartCoroutine : 해당 코루틴이 완료되기까지 기다렸다 계속해서 호출
  • yield WaitForEndOfFrame : 한 프레임에서 호출되는 모든 이벤트가 끝난 이후 계속해서 호출

일시 정지 (Pausing)

  • OnApplicationPause : 프로그램이 일시정지 혹은 재개 될 때 호출

해제 (Decommissioning)

  • OnApplicationQuit : 프로그램이 종료될 때 한번 호출
  • OnDisable : 오브젝트가 비활성화 혹은 씬이 종료될 때 한번 호출
  • OnDestroy : 씬이 종료될 때 한번 호출

반복 구간 (Loop)

유니티 생명 주기 플로우 차트를 보면 부분적으로 반복을 하고있다고 표시한 부분들이 있다.

ex) (FixedUpdate ~ yieldWaitForFixedUpdate), (OnGUI) 등

기본적으로 FixedUpdate부터 OnApplicationQuit까지 한 프레임을 기준으로 이루어진 생명 주기이다.

그러나 한 프레임에 여러 번 혹은 한 번도 호출되지 않는 이벤트의 경우 따로 Loop의 형태로 표시한다.


마무리

코드를 작성하다보면 객체의 초기화 순서에 차이로 오류가 발생하는 경우가 발생한다.

코드를 작성하고 그 흐름을 알기 위해선 생명 주기에 대한 이해는 필수이다.


참고 문서 : https://docs.unity3d.com/kr/2019.4/Manual/ExecutionOrder.html#FirstSceneLoad

                  :  https://docs.unity3d.com/kr/2020.3/Manual/class-MonoBehaviour.html

싱글톤 클래스와 정적 클래스는 공통된 속성이 있다.

대표적으로 다음과 같은 특징이 있다.

  1. 단 하나의 객체만 존재한다.
  2. 모든 객체에서 접근이 가능하다.

이러한 공통점으로 인해 싱글톤 클래스와 정적 클래스의 차이를 모르고 넘어가는 경우가 많다.

그렇기 때문에 오늘은 두 클래스의 차이점에 대해 알아보고자 한다.


인스턴스화

두 클래스의 차이점은 인스턴스화가 되느냐 안되느냐에서 명확히 드러난다.

 

정적 클래스는 new 키워드를 통해 인스턴스를 생성할 수 없으며,클래스의 모든 멤버는 정적으로 선언되어야 한다.

 

반면, 싱글톤 클래스의 경우 new 키워드를 통해 인스턴스를 생성할 수 있으며,정적이 아닌 멤버를 선언할 수 있다.

 

사실, 싱글톤 클래스를 두 개 이상으로 인스턴스화 하는 것은 그 클래스로 만드는 의미가 어긋나

실제론 단 하나의 인스턴스화만 하지만,

두 클래스의 명확한 차이점은 인스턴스화가 되는 여부에 따라 크게 차이난다는 것을 알려주기 위해 설명한 것 이다.


Lazy Initialization (게으른 초기화)

Lazy Initialization이란?

객체나 변수가 실제로 필요할 때까지 초기화를 지연시켜 성능을 최적화하는 방식이다.

 

싱글톤 클래스는 일반적으로 게으른 초기화를 바탕으로 객체가 생성된다.

즉, 객체가 필요한 시점에 초기화를 진행하고 참조할 수 있는 것이다.

 

"정적 클래스도 처음 호출 하는시점에 초기화가 되니깐,

필요한 시점에 호출해주면 Lazy Initialization(게으른 초기화)가 아닌가요?"

 

그렇게 생각할 수 있지만 답은 틀렸다.

정적 클래스는 기본적으로 처음 호출이후 모든 정적 멤버를 초기화하기 때문에 개별 객체나 변수가 처음 사용될 때 초기화하는 방식인 Lazy Initialization과의 개념과는 다르다고 볼 수 있다.


상속 (inheritance)

정적 클래스는 어떠한 클래스나 인터페이스에 상속할 수 없다.

즉, 상위 클래스로 정의하거나 정의된 인터페이스를 구현할 수 없어 캡슐화가 되지 않아 그 부분에서의 확장성이 떨어지며,

오로지 하나의 클래스 자체로만 사용된다.

 

반면 싱글톤 클래스의 경우 엄밀히 말하면 비정적 클래스이므로 캡슐화를 통한 정의가 가능하다.


마무리

두 클래스는 공통적인 부분도 있지만 그만큼의 차이점도 명확하다는 것을 알고 쓰임새에 맞게 사용해야한다.

 

일반적으로 정적 클래스의 경우 상태를 가지지 않아 유틸리티 클래스로 사용된다.

그외의 상태를 가져 사용할 경우 싱글톤 클래스로 정의하는 것이 좋다.


참고 :

https://bldev2473.github.io/programming/static-and-singleton

https://learn.microsoft.com/ko-kr/dotnet/csharp/programming-guide/classes-and-structs/static-classes-and-static-class-members

 

 

개발을 할 때 메모리 관리는 필수 요소이다.

메모리를 관리하지 않는다면 메모리 누수로 인해 예기치 못한 상황이 발생하게 될 것이다.

 

Unity에서는 C# .NET Framework의 기반인 만큼 가비지 컬렉터가 존재한다.(C# 가비지 컬렉터)

하지만 Unity의 가비지 컬렉터는  .NET Framework에서 지원하는 가비지 컬렉터와는 조금 다른데,

그 부분과 유니티의 Managed Heap은 어떤 식으로 관리되는지 알아보려고 한다.


유니티 가비지 컬렉터 작동 방식

유니티는 총 두가지의 방식으로 가비지 메모리를 수집할 수 있다.

보엠 가비지 컬렉션(Boehm Garbage Collection)

기본적으로 유니티 가비지 컬렉터는 Boehm GC를 사용하고 있다.

Boehm GC는 Mark and Sweep 알고리즘을 사용한다.

Boehm GC는 Stop-the-World 가비지 콜렉터이므로 유니티 가비지 컬렉션이 시작될 땐 프로그램 전체가 멈춘다.

 

즉 정리해야할 메모리가 많아진다면 그만큼 대기해야하는 시간이 길어지므로 가비지 컬렉터 호출을 최소화 해야한다.

점진적 가비지 컬렉션(Incremental Garbage Collection)

유니티는 2019.1에 점진적 가비지 컬렉션을 추가했다.

기존 Boehm GC같은 경우 가비지 컬렉션 수행 되는 동안 프로그램이 멈추게 되지만,

점진적 가비지 컬렉션을 사용하게 될 경우 작업을 다수 프레임으로 분산시켜 프로그램 실행을 보다 더 짧게 중단시킨다.

 

점진적 가비지 컬렉션을 활성화 하는 방법은 다음과 같다.( Edit > ProjectSettings > Player > Other Settings > Configuration > Use Incremental GC )

 

기본적으로 Mark and Sweep 방식을 통해 메모리를 관리하지만 Compaction 과정을 거치지 않기 때문에 메모리 누수 현상은 피해갈 수 없는 문제이다.


Unity Managed Heap

.NET Framework의 가비지 컬렉터는 세대별 가비지 컬렉션( Generation Garbage Collection)으로,

세대별로 관리되는 힙을 나누어 메모리를 관리한다.

 

https://docs.unity3d.com/kr/2021.2/Manual/performance-managed-memory.html

반면 Unity의 힙은 세대별, SOH와 LOH 등으로 힙을 따로 나누지 않고 모든 객체들을 한 곳에서 관리한다.

https://docs.unity3d.com/kr/2021.2/Manual/performance-managed-memory.html

또한 Compaction 과정을 하지 않으므로 위의 그림과 같이 메모리 세그먼트 사이에 "간극"이 생기게 되는데,

이 간극에는 해당 공간의 용량보다 작거나 같은 데이터밖에 저장할 수 없게 되어 메모리 누수가 발생하게 된다.

https://docs.unity3d.com/kr/2021.2/Manual/performance-managed-memory.html

위와 같이 int형 배열을 메모리에 할당하려고 할 때 전체적으로 할당 가능한 충분한 메모리임에도 간극으로 인해 메모리를 할당하지 못할경우 유니티 메모리 관리자는 어떻게 할까?

 

유니티 메모리 관리자는 위와 같은 상황에 처했을 때 다음과 같은 작업을 실행한다.

  • 우선, 가비지 컬렉터가 아직 실행되지 않은 경우 이를 실행시키고, 수용 가능한 충분한 공간을 만들 수 있도록 시도한다.
  • 가비지 컬렉터가 실행된 이후에도 수용가능한 충분한 공간이 없는 경우에는 힙을 확장시킨다. 이때 힙이 확장되는 구체적인 양은 플랫폼에 따라 다르지만, 대부분의 플랫폼에서는 힙이 확장될 때 이전 확장의 두 배만큼 확장된다.

이렇게 늘어난 힙 공간은 더 큰 할당이 발생하는 경우 힙을 재확장해야 할 필요가 없도록 하기 위해 많은 빈 공간이 존재하여도 그대로 유지한다.

하지만 이러한 방식은 결국 메모리를 무지하게 잡아먹는 원인이 되고,더 이상 확장할 메모리가 없게 된다면 예기치 못한 상황이 발생하게 될 것이다.


마무리

무분별한 메모리 할당은 잦은 가비지 컬렉터 호출로 이어지며,

더 큰 힙 메모리로 확장하게 되면서 시스템의 많은 메모리를 차지하게 될 것이다.

이러한 문제는 곧 성능 저하로 이어지며 사용자에게 좋지 않은 경험을 제공하기 때문에 더더욱 최적화에 신경을 써야한다.

 

예를 들어 재사용성이 높은 객체의 경우 한 번 사용한 이후 Destory 메서드를 호출하여 객체를 파괴하는 방식보단,

ObjectPooling을 이용하여 객체를 미리 할당한 이후 OnEnable과 OnDisable을 통해 객체를 재사용 하는 방식으로 메모리 누수를 방지하거나,

 

문자열 연산을 반복할 경우 StringBuilder 클래스를 통해 객체를 생성하지 않고 객체를 변형하는 방식을 사용하는 것이 좋다.

 

또한 유니티 프로파일러를 통해 메모리를 실시간 확인하며 관리하는 것도 좋은 방법이다.


참고 문서 : https://docs.unity3d.com/kr/2021.2/Manual/performance-managed-memory.html

 

 

지난번 게시물에서 가비지 컬렉터에 대해 작성하였는데,이번에는 C# .NET Framework의 VM(Virtual Machine)인 CLR(Common Language Runtime)에 있는 가비지 컬렉터는 어떤 작동 방식을 채택하였는지 알아보려고 한다.https://lms0408.tistory.com/3

 

가비지 컬렉터(Garbage Collector, GC)

오늘은 가비지 컬렉터(Garbage Collector, GC)에 관한 게시글을 작성하려한다. 우리가 프로그래밍을 할 때, 때에 따라 동적으로 메모리를 할당해야하는 상황이 주어진다.이때 할당된 메모리는 힙 영

lms0408.tistory.com


.Net Framework Garbage Collector

.Net Framework GC에서는 세대별 가비지 컬렉션(Generation Garbage Collection) 의 기법을 사용한다.

이때 관리되는 힙(managed heap)은 총 3개의 세대로 나뉜다.

 

GC 0

0세대는 new(혹은 CreateInstance)를 통해 새로이 생성된 객체들을 일컫는다.

특정 조건이 만족하여 GC가 호출되게 되면 0세대를 대상으로 가비지 컬렉션을 수행하게 된다.

GC 0

 

0세대를 대상으로 가비지 컬렉션을 진행한 후 Compact 과정까지 마무리되면,

남은 객체들은 1세대로 승격하여 두 번째 힙과 같은 형태로 바뀐다.

그 이후 할당되는 객체에 대해서는 1세대와 별개로 다시 0세대로 분류가 된다.

 

힙에 0세대와 1세대가 공존하는 상황에서 또 한 번에 GC가 호출 시 0세대를 대상으로만 가비지 컬렉션이 진행되는데,

위의 그림에서 D객체가 더 이상 참조되지 않았음에도 불구하고 마킹되지 않아 유지된 것을 보면 알 수 있다.

 

가비지 컬렉션의 모든 과정을 마친 후 이전과 마찬가지로 0세대에서 유지된 객체들은 1세대로 승격되는 모습을 확인할 수 있다.

GC 1

1세대는 0세대에서 유지된 객체들을 일컫는다.객체가 쌓이고 쌓여 0세대 가비지 컬렉션을 수행하는 것만으로 메모리 공간의 확보를 할 수 없을 때,1세대의 하위 세대(0세대)를 포함해 가비지 컬렉션이 이루어진다.

GC 1

0세대와 마찬가지로 1세대에서 유지된 객체들은 2세대로 승격하게 되며,

그 하위 세대인 0세대 역시 다음 세대(1세대)로 승격하는 것을 볼 수 있다.

 

여기까지 확인함으로써 'GC X'는 X세대와 그 하위의 세대에 대해 가비지 컬렉션을 수행한다는 것을 알 수 있다.

 

그림을 보면 GC 1이 연속 2회 진행된 것을 볼 수 있다.하지만 GC 1이 연속 2회가 진행되는 것은 극히 드물다고 볼 수 있다.

GC 2

2세대 역시 1세대에서 유지된 객체들을 일컫는다.

총 3개의 세대(Generation)중 마지막 세대이며,

GC 2가 실행되면 2세대와 그 하위 세대들을 포함해 가비지 컬렉션이 수행되어 전체적인 메모리를 정리하기 때문에 Full-GC라고도 불린다.

 

전체 객체를 대상으로 GC를 수행하기 때문에 그만큼 시간이 많이 걸린다.

 

LOH(Large Object Heap)

LOH란 85000B(약 83KB)이상 상대적으로 큰 객체가 할당되어 관리되는 힙이다.

.NET Framework Garbage Collector에 대한 공식 문서에 따르면 2세대이나, 3세대라고도 정의하고 있다.

즉, 큰 객체에 대해서는 곧장 2세대를 가지고 할당된다는 것이고, 2세대 Managed Heap에서만 존재한다는 것이다.

 

LOH의 큰 특징은 Compaction을 하지 않는다는 것인데, 그 이유는 복사 비용이 크기 때문에 성능 저하로 인해 압축을 시키지 않기 때문이다. (.NET Core 및 .NET Framework 4.5.1 이상에서는 GCSettings.LargeObjectHeapCompactionMode 속성을 사용하여 필요시 압축시킬 수 있다.)

 

그렇기 때문에 LOH의 경우 C와 C++의 할당 및 해제 방식과 유사한 형태로 가비지 컬렉션을 수행한다.

LOH

위의 그림을 보면 가비지 컬렉션이 수행될 때 Compaction을 하지 않는 것을 확인할 수 있다.

새로운 큰 객체가 할당이 될 경우 수용 가능한 크기의 빈 공간에 할당되고 있다.

 

크기가 큰 객체의 잦은 할당은 GC 2를 더 자주 호출하게 되어 성능 저하가 발생할 수 있다는 것을 의미한다.


가비지 컬렉션 발생 시기

특정 조건이 성립이 되면 GC 0 ~ 2를 호출하게 되는데 어떠한 기준으로 GC가 호출되는지는 다음과 같다.

Excessive Allocation

메모리 할당이 일정 임계치를 넘어섰을 때 가비지 컬렉션이 발생된다.

0~2세대, LOH는 각각 버짓(budget)이라는 할당 임계치를 가지고 있다.

버짓은 고정된 값이 아닌 메모리 상황, 가비지 컬렉션이 수행됨에 따라서 지속적으로 변화되고 튜닝되는 값이다.

GC.Collect 메서드 호출

C#에는 GC라는 클래스가 있는데,

해당 클래스에 있는 Collect 정적 메서드를 호출함으로써 가비지 컬렉션을 임의로 발생시킬 수 있다.

Collect() 모든 세대의 가비지 컬렉션을 즉시 수행한다.
Collect(Int32) 0세대에서 지정된 세대까지 가비지 컬렉션을 즉시 수행한다.
Collect(Int32, GCCollectionMode) GCCollectionMode 값에 지정된 시간에 0세대에서 지정된 세대까지 가비지 컬렉션을 수행한다.
Collect(Int32, GCCollectionMode, Boolean) 수집이 차단되어야 할지 여부를 지정하는 값을 사용하여 GCCollectionMode 값에 지정된 시간에 0세대에서 지정된 세대까지 가비지 수집을 강제로 실행한다.
Collect(Int32, GCCollectionMode, Boolean, Boolean) 컬렉션이 차단되고 압축되어야 할지 여부를 지정하는 값을 사용하여 GCCollectionMode 값에 지정된 시간에 0세대에서 지정된 세대까지 가비지 수집을 강제로 실행한다.

시스템 메모리 부족 상황

시스템 메모리가 부족한 경우 메모리 확보를 위해 프로세스에게 메모리 부족 상황을 알리는데,이때 CLR은 메모리 공간 확보를 위해 보다 많은 가비지 컬렉션을 수행하게 될 것이다.


Garbage Collection Modes

가비지 컬렉션이 어떤 스레드를 통해 작업이 수행되고,

수행되는 동안 CLR에 의해 관리되는 스레드(.NET 애플리케이션이 사용하는 스레드들)들의 중단(Suspend) 여부에 따라 크게 두 가지의 모드가 있는데, 그것은 바로 Workstation-GC, Server-GC이다.

 

또한 Workstation-GC는 다시 여러 부류의 모드로 나누어져 Non-Concurrent-GC 모드와 Concurrent-GC로 나뉘며 .NET Framework 4.0에서 새로이 추가된 Background-GC 모드도 있다.

 

Non-Concurrent-GC

Non-Concurrent-GC 모드는 가비지 컬렉션을 유발한 스레드에서 가비지 컬렉션을 수행한다.

이때 해당 스레드를 제외한 모든 스레드들의 작업은 중단(Suspend)을 하고 수행하게 된다.

Non-Concurrent-GC

위 그림에서 Thread 2에서 메모리 할당을 시도했을 때 메모리 부족으로 인해 가비지 컬렉션을 유발하였기 때문에 해당 스레드에서 가비지 컬렉션이 수행되고 있다.

이때 Thread 2를 제외한 나머지는 가비지 컬렉션이 모두 수행되기 전까지 중단되고 가비지 컬렉션이 모두 수행된 이후 작업을 다시 계속(resume)하고 있다.

 

가비지 컬렉션을 수행하는 스레드를 제외한 모두는 중단되므로,

가비지 컬렉션을 수행하는 시간이 길어질수록 애플리케이션이 정지되는 시간 역시 길어진다.

 

그렇기 때문에 많은 메모리를 사용하는 애플리케이션의 경우,2세대 가비지 컬렉션이 실행될 때 사용자의 경험이 좋지 않게 될 것이다.

 

Non-Concurrent-GC는 명시적으로 Concurrent-GC를 비활성화해주어야 사용할 수 있다.

 

Concurrent-GC

ConCurrent-GC 모드는 관리되는 스레드들의 중단 시간을 최소화하여 응답 시간을 향상하는 데에 중점을 두었다.

이 모드 역시 가비지 컬렉션을 유발한 스레드에서 가비지 컬렉션이 수행되고,

가비지 컬렉션을 수행하는 스레드를 제외한 모든 스레드들은 중단되는 방식이다.

여기까지는 앞서 알아보았던 Non-Concurrent-GC 모드와 별반 다를 게 없다.

Concurrent-GC

하지만 위 그림을 통해 알 수 있듯이 0세대, 1세대를 대상으로 수행하는 가비지 컬렉션의 경우 기존 방식과 같지만,

2세대를 대상으로 가비지 컬렉션을 수행할 경우 별도의 GC 스레드로 수행하며 다른 작업들도 수행하고 있는 것을 볼 수 있다.

 

2세대 managed heap을 가비지 컬렉션 하는 동안 다른 작업을 수행할 수 있는 이유는 새롭게 할당되는 객체들은 0세대에서 이루어지기 때문이다.LOH와 같은 큰 객체의 경우 2세대에서 할당이 되지만 LOH 힙은 Compaction을 수행하지 않기 때문에 할당과 메모리 정리를 동시에 진행할 수 있다.

 

따라서 비교적 빠른 GC 0, GC 1에 비해 GC 2를 별도의 스레드로 다른 작업과 함께 수행할 수 있기 때문에,Non-Concurrent-GC 보다 더 나은 사용자의 경험을 가져다줄 수 있다.

 

하지만 이런 Concurrent-GC도 한계가 있는데,2세대 가비지 컬렉션을 수행하는 도중 0세대 혹은 1세대 힙이 부족한 상황이 생겨 중첩되어 수행하는 경우가 발생할 수 있다.

 

이러한 복잡한 상황을  피하기 위해 CLR은 2세대 힙에 대한 가비지 컬렉션을 수행하는 도중 0세대 혹은 1세대 가비지 컬렉션 수행이 필요하게 된다면,GC 2를 제외한 모든 작업을 중단하여 가비지 컬렉션을 모두 수행 완료될 때까지 기다린 다음 다시 작업이 재개하게 된다.

 

또한 0세대, 1세대의 객체들이 2세대의 객체를 참조하고 있을 경우 2세대 힙을 Compaction 하는데 제약이 생길 수도 있다.이러한 경우는 2세대 가비지 컬렉션을 수행하는 도중 중간중간 잠시 동안 관리되는 다른 스레드들을 모두 중단하고 참조 값들을 업데이트를 할 수 있으며,때로는 Compaction 작업을 수행하지 않을 수도 있다.

 

이러한 이유로 인해 Concurrent-GC는 Non-Concurrent-GC보다 더 큰 힙을 구성하기도 한다.

 

Concurrent-GC 모드는 .NET Framework의 디폴트 가비지 컬렉션 모드(Default Garbage Collection Mode)이기 때문에 따로 설정을 하지 않는 이상 기본적으로 이 모드를 선택하게 되어있다.

 

BackGround GC

BackGround GC 모드는 Concurrent-GC를 대체하는 기능으로 .NET Framework 4.0에 새로이 도입된 Workstation-GC 모드이다.

기존 Concurrent-GC의 경우 2세대 힙을 정리하는 동안 0세대 혹은 1세대의 가비지 컬렉션이 발생할 수 없었다.

그러나 BackGround GC의 경우 2세대 힙을 정리하는 동안 0세대 혹은 1세대의 가비지 컬렉션을 수행할 수 있도록 되었다.

BackGround GC

BackGround GC는 2세대 가비지 컬렉션이 수행되는 도중 0세대 혹은 1세대 가비지 컬렉션이 필요하게 된다면,

2세대 가비지 컬렉션을 잠시 중단하고 0세대 혹은 1세대 가비지 컬렉션을 우선적으로 수행할 수 있도록 한다.

 

그렇기 때문에 Concurrent-GC에 비해 애플리케이션의 응답 시간이 전반적으로 좋아질 수 있다.

하지만 그만큼 스레드들 중단이 자주 발생할 수 있으며, 좀 더 큰 힙이 구성되기도 한다.

 

Sever-GC

Workstation-GC 모드와는 다르게 시스템 상의 프로세서 개수만큼 관리되는 힙을 만들고, 관리되는 힙의 개수만큼 가비지 컬렉션 스레드가 생성된다.

예를 들어 헥사(6) 코어에 하이퍼 스레드가 적용된 컴퓨터에는 12개의 관리되는 힙이 생성되고, 그 개수만큼의 가비지 컬렉션 스레드가 생성된다는 것이다.

 

이렇게 프로세서마다 고유의 힙을 생성하는 이유는 메모리 할당의 병행성을 높이기 위해서이다.

WorkStation-GC의 경우 2개의 스레드가 동시에 객체를 생성하고자 할 때,

힙이 1개만 존재하므로 어느 한 스레드가 메모리를 할당하는 동안 다른 스레드는 이를 기다려야한다.

 

반면 Server-GC의 경우 각 프로세서마다 고유의 힙을 가지고 있기 때문에 병렬적으로 메모리를 할당할 수 있다.

프로세서마다 존재하는 힙은 서로 다른 힙에 대한 참조를 가질 수 있으며, 별 다른 제약을 가지고 있지 않다.

 

또한 힙 마다 할당된 고유의 가비지 컬렉션 스레드가 힙을 정리하기 때문에 좀 더 빠르게 힙을 정리할 수 있다.

 

Server GC

위 그림의 예시는 4개의 논리 프로세서를 가진 시스템에서 수행되는 Server GC이다.

프로세서의 개수만큼 GC 스레드가 생성이 된 것을 확인할 수 있다.

 

가비지 컬렉션을 수행할 때는 다른 작업 스레드들은 모두 중단이 되며,

각각의 GC 스레드는 작업을 완료하는데 소요되는 시간이 다를 수도 있기 때문에

모든 GC 스레드의 작업이 완료되어야 다른 스레드들이 작업을 재개할 수 있다.

 

논리 프로세서마다 고유의 힙을 만들어 병렬적 수행이 가능해 힙을 상대적으로 빠르게 정리할 수 있다는 장점이 있지만,

논리 프로세서가 많아짐으로써 가상 메모리 공간이 부족해질 수 도 있게 된다.


참고 블로그 : http://www.simpleisbest.net/post/2011/04/01/Review-NET-Garbage-Collection.aspx

'C#' 카테고리의 다른 글

[C#] string  (1) 2024.09.07

오늘은 가비지 컬렉터(Garbage Collector, GC)에 관한 게시글을 작성하려한다.

 

우리가 프로그래밍을 할 때, 때에 따라 동적으로 메모리를 할당해야하는 상황이 주어진다.

이때 할당된 메모리는 힙 영역에 저장이 되고, 주소값을 통해 힙 영역에 저장된 데이터에 접근하게 된다.

 

하지만 이러한 메모리 공간은 유한하기 때문에, 메모리 공간의 관리를 잘 해주어야 한다.

 

C, C++과 같은 언어에서는 프로그래머가 동적으로 메모리를 할당하였다면(malloc, new) 수동으로 그 메모리를 해제(free, delete)해주어야 하는 빈번한 과정을 거쳐야 했다.

 

꼼꼼한 검수과정을 통해 수동으로 메모리를 해제해주는 작업을 잘 거친다면 메모리 누수 현상은 발생하지 않을 것이다.

하지만 그런 꼼꼼한 검수 과정을 거치더라도 메모리 누수 현상을 완벽하게 방지할 수는 없다...(사람은 누구나 실수를 하기 때문)

 

이러한 문제를 해결하기 위해 가비지 컬렉터가 나오게 된 것 이다.


가비지 컬렉터 작동 방식

가비지 콜렉터의 작동 방식은 크게 2가지 방식이 있다.

1. 추적 기반 가비지 컬렉터(Tracing Garbage Collection)

프로그램을 실행하는 도중 특정한 시점이 되었을 때 현재 할당된 모든 메모리를 조사하는 방식이다.

이때 할당된 메모리들은 현재 접근 가능한지 불가능한지 분류한 뒤, 접근이 불가능할 경우 가비지로 간주하여 해제시키는 방식이다.

여기서 접근 가능한 메모리를 root라고 한다. 

 

Mark-and-Sweep

Mark-and-Sweep은 위에 있는 설명을 그대로 구현한 방식이다.

GC를 실행하여 접근이 가능한 메모리를 대상으로 마킹(Marking)을 한 이후, 마킹이 되지 않은 메모리를 할당 해제(Sweep) 하는 방식이다.

위와 같이 힙 영역에 할당된 메모리 obj1, obj2, obj3, obj4가 있다고 가정하자.

GC가 실행되어 할당된 메모리 중 접근이 불가능한 메모리를 마킹한다.

여기선 obj1, obj3가 접근이 불가능한 메모리로 가정한다.

마킹된 메모리는 더 이상 필요하지 않는 메모리이므로 할당을 해제해준다.

 

하지만 지속적으로 메모리 할당과 해제를 반복하게 될 경우 중간 중간 빈 공간이 생기게 되는 메모리 단편화가 생기게 되어,연속적으로 메모리를 할당해야할 경우 남은 메모리가 충분함에도 불구하고 할당하지 못하는 상황이 발생한다.

 

Compact

이러한 문제점을 보완하기 위해 Mark, Sweep 과정을 거친 이후 Compact 과정을 한번 더 거치는 방법을 채택하였다.

 

Compact 과정은 간단하다.불필요한 메모리를 해제한 이후 남은 메모리들을 앞에서 부터 차례차례 빈공간을 채워나가는 과정이다.

 

추적 기반의 방식은 중간에 메모리가 변경되면 마킹을 다시해야하는 상황이 발생하므로 프로그램 전체를 정지(Stop-The-World) 시켜야 한다.

그렇기 때문에 중간 중간 끊기는 현상이 발생하여 사용자의 경험에 좋지않다는 문제가 발생한다.

 

1.1 점진적 가비지 컬렉터(Incremental Garbage Collection)

이 방식은 기존 방식에서 중간 중간 끊기는 현상이 발생하는 부분에 대해 어느정도 보완을 하기위해 만들어졌다.

기존 한 번에 마킹과 해제를 하는 방식과는 다르게 여러 번에 걸쳐서 수행하는 방식이다.

 

여러 번 걸쳐서 수행하는 만큼 마킹과 해제를 하는 한 사이클이 오래 걸리지만 프로그램이 정지하는 시간은 줄일 수 있다.

 

삼색 표시 기법(Tri-Color Marking)

점진적 가비지 컬렉터는 메모리를 세 가지로 마킹하는 방식인 삼색 표시 기법을 사용한다.기본적으로 흰색, 회색, 검은색으로 표현되며 각 색깔 별 의미는 다음과 같다.

  • 흰색 - 더 이상 접근 불가능한 객체
  • 회색 - 접근이 가능한 객체이지만, 이 객체에서 가리키는 객체들이 아직 검사되지 않은 객체
  • 검은색 - 접근이 가능한 객체

그리고 이 기법은 다음과 같은 순서로 이루어진다.

먼저 모든 객체를 흰색으로 표시한다.

그 이후 루트 집합(전역 변수, 스택 변수 등)에 도달 가능한 객체를 찾아 회색으로 표시해준다.

회색으로 표시된 객체가 참조하는 모든 객체를 회색으로 표시하고 자기 자신을 검은색으로 표시한다.

모든 회색 객체가 없어질 때까지 위와 같은 과정(1~3번)을 반복한다.

그 이후 남은 흰색 객체는 접근 불가능한 객체이므로 모두 해제한다.

(이 방식은 BFS(Breadth-First Search) 알고리즘과 유사하다.)

 

1.2 세대별 가비지 컬렉터(Generational Garbage Collection)

마찬가지로 이 방식도 기존 방식에 문제점을 보완하기 위해 만들어졌다.

 

위의 방식들을 토대로 지켜보았을 때 대부분의 객체들은 만들어진 이후 잠깐 쓰이고 금세 버려지며, 오래 살아남아서 쓰이는 경우는 그렇게 많지 않다는 경향을 발견하여 이 방식을 만들게 되었다.

 

Generation

위 경향을 토대로 크게 두 가지 영역(세대)으로 나누어 수집 대상을 정했다.

  • Young 혹은 New - 새로 할당된 객체 혹은 일정 수준 이하 동안 살아남은 객체들
  • Old - 새로 할당된 이후 일정 수준 이상 동안 살아남은 객체들

여기서 살아남았다는 말은 가비지 컬렉터가 호출된 이후 수집이 안된 횟수를 뜻한다.

즉, 일정 호출 횟수 이후까지 살아남아 있다면 그 객체는 Old세대가 되는 것이다.

 

또한 Young세대는 Old세대보다 비교적 더 작은 크기를 가지게 되는데,

그 이유는 전체의 일부분을 지속적으로 빠른 시간내에 GC가 수집하면서 메모리 공간을 확보하기 위해서이다.

 

Minor-GC와 Major-GC(Full-GC)

세대별로 나누어 수집하는 방식은 크게 두가지 방식으로 나뉜다.

 

1. Minor-GC

특정 조건에 만족하여 Minor-GC가 호출될 땐 Young세대에 있는 객체들을 대상으로 수집이 이루어진다.

빨간 색으로 표시된 객체들은 GC가 호출이 되었을 때 수집되는 대상이다.

Minor-GC로 호출되었기 때문에 Young세대에 있는 수집 대상만 마킹되고 수집하는 것을 확인할 수 있다.이때 수집된 곳은 빈 공간(Empty)이 된다.

 

2. Major-GC

특정 조건에 만족하여 Major-GC가 호출될 땐 전체 객체를 대상으로 수집이 이루어진다.

위의 Young세대 수집이 있은 이후의 객체들은 특정 횟수 이상을 만족하며 Old세대로 옮겨지고,

Young세대에는 새로운 객체들이 할당된 상태이다.

 

Major-GC로 호출되었기 때문에 전 세대에 있는 수집 대상들을 마킹하고 수집하는 것을 확인할 수 있다.

전체 대상으로 수집하기 때문에 Full-GC라고 부르기도 한다.


2.  참조 횟수 카운팅 기반 가비지 컬렉터(Reference Counting based Garbage Collection)

다른 메모리가 어떤 메모리를 얼마나 많이 참조하는지 횟수를 세어서 접근 가능과 불가능을 나누는 방식이다.

 

Reference Counting

한 메모리가 다른 메모리를 참조하면 그 다른 메모리는 참조 횟수에 1을 더하고 참조를 중단하면 참조 횟수에 1을 뺀다.

이때 1을 뺀 이후 참조 횟수가 0이 되면 해당 메모리에 접근이 불가능하므로 그 메모리를 해제하는 것이다.

 

단지 참조 횟수를 카운팅 해주고 0이 되었을 때 해제해주기 때문에 구현이 간단하다는 장점이 있다.

 

OverHead

하지만 매번 대입를 할 때마다 참조 횟수를 카운팅 해주고 참조 횟수가 감소될 때 조건문을 통해 해제가 가능한지 판별하는 것은 적지 않은 오버헤드를 발생시킬 수 있다 또한 참조 횟수를 저장해야하기 때문에 캐시효율 또한 낮아질 수도 있다.

 

Cyclic Reference

이러한 문제점 말고 한 가지 더 문제점이 있는데 그것은 바로 순환 참조이다.

 

예를 들어 A가 B를 가리키고 B에서 A를 가리키면 둘 다 참조 횟수가 1이 될 것 이다.

이때 A와 B 둘 중 어느 곳에도 접근할 수 없는데 참조 횟수가 0이 아니기 때문에 메모리를 해제할 수 없게 되며 메모리 누수가 발생하게 된다는 문제점이 있다.


현재는 위의 두 가지 방식중 대부분의 언어는 Tracing GC를 채택하고 있다.


마무리

가비지 컬렉터가 동적으로 할당하는 메모리를 어떤 식으로 관리하는지에 대해 알아보았다.

 

기존 수동으로 관리하는 방식에 비해 매우 편해졌지만 아직까지 가비지 컬렉터에게 모든 관리를 도 맡기에는 한계가 있다.

 

가비지 컬렉터가 수집하는 조건은 더 이상 참조하지 않는 객체에 대해만 할당을 해제하기 때문에 메모리 누수의 가능성은 항상 열려있다.또한 실행 시간에 작업을 수행하는 이상 성능 저하는 피할 수는 없다.

 

그렇기 때문에 가비지 컬렉터를 너무 맹신하고 메모리에 대한 문제를 신경쓰지 않는다면 심각한 성능 저하를 일으킬 수 있는 것이다.

Unity에서 Coroutine은 단일 프레임 내에서 작업을 수행하지 않고 다수 프레임에서 작업을 수행할 수 있다.

그로 인해 비동기적 처리 방식을 위한 코드를 구현할 때는 Coroutine을 많이 사용하기도 한다.

하지만 Coroutine의 이점만 바라보고 사용하기에는 불이점도 분명히 존재하기 때문에, Coroutine에 대해 더 자세히 알아보고 정리하자는 마음으로 이 글을 작성하게 되었다.

 


Coroutine은 비동기처리 방식?

비동기에 대한 이해가 부족하다면 아래의 글을 보고오자.

 

Synchronous vs Asynchronous & Blocking vs Non-blocking

Synchronous와 Asynchronous, Blocking과 Non-blocking이라는 말은 운영체제(OS)를 공부하면서 듣게 된 개념이다.프로그래밍을 함에 있어 중요한 개념인 만큼 이번 기회에 정리를 해보고자 한다.Synchronous와 Asyn

lms0408.tistory.com

 

 

Unity Documentation에 코루틴에 대한 설명을 보면 다음과 같다.

※ 코루틴은 HTTP 전송, 에셋 로드, 파일 I/O 완료 등을 기다리는 것과 같이 긴 비동기 작업을 처리해야 하는 경우 코루틴을 사용하는 것이 가장 좋습니다.

- https://docs.unity3d.com/kr/2022.3/Manual/Coroutines.html

 

코루틴 - Unity 매뉴얼

코루틴을 사용하면 작업을 다수의 프레임에 분산할 수 있습니다. Unity에서 코루틴은 실행을 일시 정지하고 제어를 Unity에 반환하지만 중단한 부분에서 다음 프레임을 계속할 수 있는 메서드입니

docs.unity3d.com

유니티의 Coroutine은 작업을 다수의 프레임에 분산하여 수행할 수 있는 Class이다.

즉, 작업이 완료되지 않더라도 다른 작업을 수행할 수 있다는 뜻이다.

그렇기 때문에 유니티의 Coroutine은 비동기 방식이다.


Coroutine 동작 원리

Unity의 Coroutine은 반환 키워드 yield와 Coroutine의 반환 타입인 IEnumerator를 통해 비동기 작업을 수행한다.

 

'yield'란 양도하다는 의미로 Unity에서 Coroutine을 사용할 때의 yield 키워드는 함수의 실행을 일시 중지하고, 호출자에게 제어를 반환한다는 의미라고 볼 수 있다.

또한 yield 키워드 뒤에 오는 표현식에 따라 Coroutine이 재개될 조건을 지정할 수 있다.

public interface IEnumerator
{
    object Current { get; }
    bool MoveNext();
    void Reset();
}

IEnumerator(열거자)는 컬렉션을 단순하게 반복할 수 있도록 지원해주며, System.Collections 네임스페이스에 속한 interface이다.

IEnumerator는 현재 위치의 데이터를 반환해주는 Object Property와 다음 데이터로이동 가능에 대한 반환과 이동을 시켜주는 Bool형 메서드, 인덱스를 초기 상태 위치로 변경해주는 Void형 메서드로 이루어져있다.

 

다수 프레임에서 작업을 나누어 수행할 수 있는 이유는

yield return 문이 컴파일시 코드의 위치를 저장하는 상태 머신으로 변환되어

yield를 기준으로 작업을 나누어 수행할 수 있기 때문이다.

 

코루틴을 실행하는 메서드는 기본적으로 Enumerator에 함수의 포인터 값으로 저장된다.코루틴 실행 시 반복문을 만나면서 MoveNext()가 호출된다.이때 IEnumerator 반환형 메서드가 yield문을 만나기 전 까지 실행이 된다.

 

yield문을 만나게 되면 Current인 YieldInstruction(waitForSeconds와 같은)이 실행되게 된다.이때 리턴되는 값이 존재한다면 MoveNext()의 결과값은 true로 반환되고,없을 시 false를 반환하여 반복문이 종료된다.

 

MoveNext()의 반환 값이 true가 되어 Current를 실행하였다면,다시 MoveNext()가 호출되면서 yield return이 일어났던 위치의 다음줄 부터 재실행이 된다.이때 역시 마찬가지로 yield 문을 만나기 이전까지 진행된다.

 

위의 과정을 반복하여 최종적으로 MoveNext()의 반환 값이 false가 반환되면 반복문이 종료되면서 코루틴 실행이 끝이 나게 된다.


Coroutine 사용법

Coroutine을 사용하기 위해서는 첫 번째로 IEnumerator 반환형으로 메서드를 정의해주어야 한다.

이때 반환형은 한 개 이상의 yield 키워드를 포함한 return 문이 작성되어야 한다.

IEnumerator CoroutineTest()
{
    // ...Something
    yield return ...
}

이렇게 정의된 메서드는 MonoBehaviour에 상속된 인스턴스에서 StartCoroutine() 메서드를 통해 실행시킬 수 있다.

public class CoroutineControl : MonoBehaviour
{
    // ... CoroutineTest Method
    void Start()
    {
    	StartCoroutine(CoroutineTest());
    }
}

StartCoroutine을 통해 생성된 Coroutine을 field 변수에 저장하여 Coroutine을 관리할 수 있다.

public class CoroutineControl : MonoBehaviour
{
    // ... CoroutineTest Method
    Coroutine coroutine;
    void Start()
    {
    	coroutine = StartCoroutine(CoroutineTest());
        if ( // ... Any Condition )
        {
            StopCoroutine(coroutine);
        }
    }
}

이렇게 정지된 Coroutine은 어떠한 곳에도 참조가 되어있지 않을 시 GC(Garbage Collector)에 의해 수집되게 된다.

yield

Coroutine을 사용할 때 대표적으로 쓰는 반환형들은 다음과 같다.

yield return null 다음 프레임까지 대기
yield return new WaitForSeconds() 입력한 초(sec)만큼 대기
yield return new WaitFixedUpdate() 다음 프레임의 FixedUpdate까지 대기
yield return new WaitForEndOfFrame() 모든 랜더링 작업이 끝날 때까지 대기
yield return new StartCoroutine() 입력한 다른 코루틴이 끝날 때까지 대기
yield return new WWW() 입력한 웹 통신 작업이 끝날 때까지 대기
yield return new AsyncIoeration 비동기 작업이 끝날때까지 대기
yield break 코루틴 종료

Coroutine의 문제점

Coroutine은  비동기적 방식과 유사한 방식으로 작업을 편리하게 수행할 수 있는 만큼 남용할 시 문제가 생길 수 있다.

 

다음 예는 게임 내에서 Monster의 공격이 Coroutine으로 구현되어있는 상황에서 메모리를 낭비하는 모습이다.

public class Monster : MonoBehaviour
{
    public IEnumurator DashAttack()
    {
    	float waitAttackTime = 1f;
    	yield return new WaitForSeconds(waitAttackTime);
        // ...Attack
        yield break;
    }
}

Monster의 수가 비약적으로 적은 경우에는 위의 코드를 사용했을 때 큰 문제는 발생하지 않을 것이다. (다만 Coroutine을 생성하여 공격한다는 점에서 메모리 손실이 발생한다.)

 

하지만 Monster의 수가 많아진다면 최대 n개(n개의 몬스터가 동시에 공격했을 때) 만큼 Coroutine의 인스턴스가 생성될 것이다.

 

더 최악의 상황은 이렇게 생성된 Coroutine이 재사용 가능성이 없게 된다면 사용한 Coroutine n개가 GC에 의해 수집이 되어 더 잦은 GC 호출을 초래할 수 있는 문제가 생긴다.

 

이렇게 잦은 GC 호출은 높은 CPU점유율로 인한 프레임 드랍 현상까지 이어지게 된다.

 

+ Recent posts