2022.08.12 경일 메타버스 19주차 5일 특강 수업내용. CLR C#과 Unity C#의 차이
CLR과 클래스 라이브러리 세트를 의미함
CLR은 C# 코드를 컴파일한 결과물인 IL 코드를 다시 해당 플랫폼에 맞는 코드로 변환하여 하나의 소스코드로 여러 플랫폼을 지원함
.NET에는 공용 타입 시스템이 있다.
모든 타입은 값 타입과 참조 타입으로 분류
모든 타입은 System.Object 타입을 상속 받음
이를 명확히 인지하고, 박싱과 언박싱을 피하도록 코드를 작성해야 함.
메모리를 수동으로 관리하는 것은 여러 문제점이 있음
메모리를 자동으로 관리하는 기술
가비지로 가정하는 방법
추적 : 도달 가능성으로 가비지 판단
참조 카운팅 : 참조 횟수로 가비지 판단
표준 .NET은 세대별 가비지 컬렉션을 사용
메모리 할당은 0세대에서만 일어남
메모리 해제 시, 가비지가 아닌 메모리는 윗 세대로 승격됨.
가비지 컬렉션이 수행되는 동안에는 프로그램이 멈추기 때문에 이에 유의하여 코드를 작성해야 함.
Unity의 스크립트 백엔드(스크립트 환경)는 Mono, IL2CPP 두 개가 있음.
=> 여기서 모든 차이가 발생
세대별로 관리하지 않으며, 메모리 압축 또한 없기 때문에 메모리 단편화가 쉽게 일어나고, 힙도 쉽게 확장된다. 그래서 최적화 이슈가 발생할 수 있다.
어셈블리
기계어에 일대일 대응
쓰기 불편함
C
쓰기 좋음
객체 지향적이지 못함 - 클래스 없음
C++
C with Class
클래스를 넣어 객체 지향적
다른 플랫폼을 지원할 때마다 코드 수정 + 새로 빌드
Java
원 소스 멀티 플랫폼
컴파일러 다음, 플랫폼마다 코드를 다르게 빌드해주는 중간 프로그램 존재 - 자바 버추얼 머신 (JVM; Java Virtual Machine)
성능이 좋지 못함
C#
완전 객체 지향적
Java와 비슷 (Java 바탕)
다양한 기능 - 강건성, 유지보수를 위한 기능
마이크로소프트에서 개발한 완전 객체 지향 프로그래밍
강건성과 유지보수를 위한 다양한 기능 제공
가비지 컬렉션
함수형 프로그래밍을 위한 람다식
비동기 프로그래밍
등
어떤 플랫폼이든 언어를 동작시킬 수 있도록 하는 프로그램, 사양
이 사양(CLI)에 맞춰 마이크로소프트가 구현한 중간 프로그램
.NET은 위의 중간 프로그램과 클래스 라이브러리 세트를 일컫는다.
.NET 위에서 동작하는 언어
C#
F#
Visual Basic
C#은 컴파일을 하면 CLI 사양을 준수하는 중간 언어(IL; Intermediate Language)로 컴파일 된다.
그리고 이러한 IL 코드와 프로그램에 사용되는 리소스(아이콘, 마우스 커서, 메뉴 등)가 함께 패키징 되어 어셈블리(Assembly)가 된다.
⇒ 여기서는 C / C++를 컴파일하면 생성되는 어셈블리 언어와 다른 개념이다.
어셈블리는 실행 파일(.exe) 또는 동적 연결 라이브러리(.dll)의 형태를 가지며, .NET 기반 애플리케이션에 대한 배포, 버전 제어, 재사용, 활성화 범위 및 보안 권한의 기본 단위를 형성한다.
C# 프로그램을 실행하면 어셈블리가 CLR에 로드 되는데, CLR은 IL 코드를 플랫폼에 따라 JIT(Just-In-Time) 컴파일 혹은 AOT(Ahead-Of-Time) 컴파일을 수행하여 네이티브 명령어로 변환한다.
JIT(Just-In-Time) 컴파일 :
프로그램 실행 중에 그때그때 컴파일을 하는 것
AOT(Ahead-Of-Time) 컴파일 :
프로그램 실행 전 미리 컴파일을 진행하는 것
.NET은 여러 .NET 언어를 지원하기 위해
공용 타입 시스템(CTS; Common Type System)을 지원
.NET의 모든 형식은
값 타입(Value Type) 혹은 참조 타입(Reference Type)으로 구분
모든 타입은 기본 타입 System.Object에서 파생
값 타입은 아래와 같은 특징을 갖는다.
구조체를 제외한 모든 타입은 System.ValueType에서 파생된다.
스택 메모리에 직접 값이 포함된다. 다시 말해 복사가 일어난다.
상속이 불가능하다.
구조체 멤버 중에 참조 타입이 있다면 메모리 주소가 복사된다.
참조 타입은 아래와 같은 특징이 있다.
힙 메모리에 인스턴스가 할당된다.
참조 타입의 변수는 인스턴스의 주소에 대한 참조를 가진다.
Null을 할당할 수 있다.
박싱(Boxing) :
값 타입을 object 타입 또는 값 타입에서 구현된 (구조체의) 임의의 인터페이스 타입으로 변환하는 프로세스
object 타입의 새로운 인스턴스를 생성해 값을 힙으로 복사한다.
암시적으로 이루어진다.
언박싱(Unboxing) :
박싱된 인스턴스에서 값 타입을 추출하는 프로세스
힙에서 스택으로 값이 복사된다.
명시적으로 이루어진다.
주의 :
박싱과 언박싱은 많은 계산 과정을 필요로 한다.
예시 코드의 결과는 다음과 같다.
박싱은 약 30배, 언박싱은 약 1.1배의 차이가 있다.
언박싱의 경우 int가 아닌 구조체라면 더 많은 시간이 필요할 것이다.
따라서 구현에서 박싱과 언박싱은 일어나지 않도록 주의하자.
C++에서는 메모리를 프로그래머가 관리한다.
즉, 동적 할당된 메모리를 전부 사용했다면 시스템에 돌려줘야 한다.
이 과정에서 여러가지 실수가 많이 발생하는 데 아래와 같다.
메모리 누수(Memory Leak)
이중 해제(Double Free)
이미 해제가 된 메모리임에도 불구하고 또 해제하는 것
섣부른 해제(Premature Free)
이런 불편함을 해결하기 위해 나온 기술이
자동 메모리 관리(Automatic Memory Management) 기술인
가비지 컬렉션(Garbage Collection)이다.
가비지 컬렉션은 가비지 컬렉터(Gabage Collector)가
더이상 사용하지 않는 메모리(가비지(Gabage))를 재사용
함으로써 동작된다.
하지만 애석하게도 어떤 객체가 아직 사용되고 있고, 사용되지 않는지(which object is still lived) 정확하게 판별할 수 있는 알고리즘이 없다.
그래서 다음과 같은 2가지 방법으로 객체의 사용 유무(생존, Liveness)를 가정한다.
추적 가비지 컬렉션(Tracing Garbage Collection)
도달 가능성(Reachability)으로 생존을 가정한다.
루트(Root)를 사용하여 해당 메모리까지 도달할 수 있는지 보고, 도달되지 못한 메모리는 가비지로 가정한다.
참조 카운팅(Reference Couting)
해당 메모리에 참조하는 것이 없을 때 가비지로 가정한다.
참조 카운팅 방법은 순환 참조(Circular Reference)를 주의해야 한다.
순환 참조를 방지하기 위해 약한 참조(Week Reference)라는 개념을 사용한다.
앞서 말한 스마트 포인터 라이브러리가 이 방식으로 동작한다.
추적 가비지 컬렉션과 참조 카운팅 두 방법은 하이브리드 형식으로 같이 사용될 수 있다.
가비지 컬렉션에도 여러 가지 종류가 있다.
보수적 가비지 컬렉션(Conservative Garbage Collection)
복제 가비지 컬렉션(Copying Garbage Collection)
분산 가비지 컬렉션(Disributed Garbage Collection)
증분 가비지 컬렉션(Incremental Garbage Collection)
등등
C#은 가비지 컬렉션을 지원한다.
엄밀히는 CLR이 지원한다.
가비지 컬렉션을 지원하는 언어를
매니지드 언어(Managed Language)라고 한다.
반대를 언매니지드 언어(Unmanaged Language)라고 한다.
언매니지드 언어는 프로그래머가 거의 모든 작업을 수행하고, 실제 프로그램이 운영체제에 의해 로드되므로
네이티브 언어(Native Language)라고도 한다.
C#에서 사용하는 방식은
세대별 가비지 수집(Generational Garbage Collection)이다.
세대별 가비지 수집을 이해하기 위해선 먼저 세대(Generation)에 대한 이해가 필요하다.
매니지드 힙(Managed Heap) :
가비지 컬렉터가 관리하는 메모리
세대를 나눈 이유는 다음과 같은 이유로
메모리를 재사용하기 용이하기 때문이다.
가비지 컬렉션이 일어날 때 파편화 방지를 위해 메모리를 압축하는 데, 힙 전체를 대상으로 하기보다 일부분에서만 수행하는 게 더 빠르다.
최근에 만들어진 객체일 수록 수명이 짧고 오래 사용된 객체일 수록 수명이 길어 재사용할 메모리를 빠르게 분류할 수 있다.
메모리 할당은 0세대에서만 주로 일어나는데 최근에 만들어진 객체끼리 서로 연관되는 경향이 있어 캐싱 측면에서 좋다.
매니지드 힙에는 여러 개의 포인터가 있으며 이를 이용해 세대를 구별한다.
C#에서 모든 참조 타입의 객체는 매니지드 힙의 0세대에 할당된다.
각 객체들은 주소 공간이 허락하는 한 인접한 곳에 위치하게 된다.
매니지드 힙은 메모리를 미리 시스템으로부터 할당 받아 놓기 때문에(메모리 풀링 (Memory Pooling)) 스택에서 메모리를 할당하는 속도만큼 빠르게 할당할 수 있고, 접근도 빠르게 할 수 있다.
단, 85KB 이상의 크기를 가지는 큰 객체는 LOH(Large Object Heap)라는 2세대 메모리에 할당
세대별 가비지 컬렉션은 추적 방식을 사용한다.
루트에는 스택 루트(스택 메모리), CPU 레지스터, 정적 필드 등이 있다.
수집 시기는 가비지 컬렉터가 자동으로 가장 적합한 때에 컬렉션을 수행한다.
매니지드 힙을 검사해 가비지를 찾으면 접근할 수 있는 객체를 압축해 가비지의 공간을 덮어버린다.
압축 :
단편화된 공간이 없게끔, 다른 데이터를 복사해 덮어넣는다.
LOH는 압축되지 않는다.
이 과정에서 참조 변수의 주소값과 각 세대의 시작을 가리키는 포인터 모두 수정한다.
가비지 컬렉션이 일어나는 순서는 다음과 같이 정해져 있다.
가장 먼저 가비지 컬렉션이 일어나는 세대는 0세대
만약 0세대에서 가비지 컬렉션을 수행했음에도 불구하고
새로운 객체를 만들기 위한 메모리 공간이 충분하지 않다면,
먼저 1, 2세대 순으로 수집을 수행한다.
그럼에도 또 부족하다면 세대 2, 1, 0의 순서로 수집을 수행한다. 이 경우에도 2세대를 제외하곤 세대 승격은 일어난다.
가비지 컬렉션에 대해서 명확히 이해해야 하는 이유는
성능과 직결되기 때문이다.
가비지 컬렉션은 자원을 많이 소모하는 연산이다.
멀티스레드 환경인 경우 가비지 컬렉션이 수행되는 동안 다른 스레드가 중단(Suspended)된다.
따라서 아래의 사항을 주의하자.
참조 카운팅 방식으로 가비지 수집이 일어나지 않는다.
필요하다면 약한 참조를 사용할 수 있다.
어떤 객체에 도달 가능할 때, 이를 객체에 대한 강한 참조(Strong Reference)를 갖는다고 표현한다.
하지만 객체의 도달 가능성에 영향을 주지 않으면서(객체의 생존에 영향을 주지 않으면서) 해당 객체를 참조하고 싶은 때, 약한 참조(Weak Reference)를 사용할 수 있다.
빈번한 할당을 조심하자.
가비지 컬렉션이 일어나는 조건 중 하나는 객체를 할당할 충분한 공간이 없을 때다.
객체를 빈번하게 생성하면 0세대에 여유 공간이 부족해 가비지 컬렉션이 일어날 수 있다.
너무 큰 객체 할당은 피하도록 하자.
85KB 이상의 크기를 가지는 객체는 LOH에 할당된다.
LOH에서는 메모리 압축이 일어나지 않아 내부 단편화가 발생할 수 있다.
복잡한 참조 관계를 피하자.
가비지 컬렉션 후에 메모리 주소 관리를 어렵게 한다.
특히 오래된 세대에서 새로운 세대에 대한 메모리를 참조하게 될 때 수집을 방지하기 위해 쓰기 장벽(Write Barrier)을 만든다.
관리되지 않는 리소스도 있다.
운영체제 리소스를 래핑하는 경우 제대로 정리가 되지 않는다.
이런 경우에는 IDisposable와 using 문을 이용할 수 있다. 자세한 것은 여기를 참고하자.
C# 프로그램은 결국 .NET 위에서 동작한다.
Unity C#의 다른 점은 바로 이 .NET에 있다.
Unity에서 사용하는 .NET은 2가지가 있다.
.NET 환경이 다르기에 가비지 컬렉션의 방식도 달라진다.
Unity(Mono)에서는 Boehm GC를 사용한다.
세대별로 관리되지 않는다.
메모리의 압축 또한 일어나지 않는다.
메모리 압축이 일어나지 않아 메모리 단편화가 일어나 힙이 쉽게 확장되어, 최적화 이슈가 발생할 수 있다.
가비지 컬렉션에 너무 많은 성능이 소모되는 것을 막기 위해
수행 제한 시간을 두어 조금씩 수행하는 증분 방식 또한 사용한다.