High-Performance 관리되는 애플리케이션 작성: 입문서

 

그레고라 노리스킨
Microsoft CLR 성능 팀

2003년 6월

적용 대상:
   Microsoft® .NET Framework

요약: 성능 관점에서 .NET Framework 공용 언어 런타임에 대해 알아봅니다. 관리 코드 성능 모범 사례를 식별하는 방법과 관리되는 애플리케이션의 성능을 측정하는 방법을 알아봅니다. (19페이지 인쇄)

CLR Profiler를 다운로드합니다. (330KB)

콘텐츠

소프트웨어 개발을 위한 은유로서의 저글링
.NET 공용 언어 런타임
관리되는 데이터 및 가비지 수집기
할당 프로필
프로파일링 API 및 CLR 프로파일러
서버 GC 호스팅
종료
삭제 패턴
약한 참조에 대한 참고 사항
관리 코드 및 CLR JIT
값 형식
예외 처리
스레딩 및 동기화
반사
지연 바인딩
보안
COM Interop 및 플랫폼 호출
성능 카운터
기타 도구
결론
리소스

소프트웨어 개발을 위한 은유로서의 저글링

저글링은 소프트웨어 개발 프로세스를 설명하기 위한 훌륭한 은유입니다. 저글링에는 일반적으로 3개 이상의 항목이 필요하지만 저글링을 시도할 수 있는 항목 수에 대한 상한은 없습니다. 저글링하는 방법을 배우기 시작하면 각 공을 잡을 때 개별적으로 watch 것을 알게됩니다. 당신이 진행으로 당신은 공의 흐름에 초점을 시작, 각 개별 공반대. 저글링을 마스터했을 때, 다시 한 번 한 번 공 한 개에 집중하여 코에 그 공을 분산시키는 동시에 다른 공을 저글링할 수 있습니다. 당신은 공이 될 것입니다 직관적으로 알고 그들을 잡으려고 올바른 장소에 손을 넣을 수 있습니다. 그렇다면 소프트웨어 개발은 어떻게 될까요?

소프트웨어 개발 프로세스의 다양한 역할은 서로 다른 "trinities"를 저글링합니다. 프로젝트 및 프로그램 관리자는 기능, 리소스 및 시간을 저글링하고 소프트웨어 개발자는 정확성, 성능 및 보안을 저글링합니다. 하나는 항상 더 많은 항목을 저글링하려고 할 수 있지만, 저글링의 학생이 증명 할 수 있듯이, 하나의 공을 추가하면 기하 급수적으로 공중에 공을 유지하기가 더 어려워집니다. 기술적으로 3 개 미만의 공을 저글링하는 경우 전혀 저글링하지 않습니다. 소프트웨어 개발자로서 작성 중인 코드의 정확성, 성능 및 보안을 고려하지 않는 경우 작업을 수행하지 않는 경우를 확인할 수 있습니다. 처음에 정확성, 성능 및 보안을 고려하기 시작하면 한 번에 한 가지 측면에 집중해야 합니다. 그들은 당신의 일상적인 연습의 일부가 될 때 당신은 당신이 특정 측면에 집중할 필요가 없다는 것을 발견 할 것이다, 그들은 단순히 당신이 일하는 방식의 일부가 될 것입니다. 이를 익히면 직관적으로 장단점 작업을 수행하고 노력을 적절하게 집중할 수 있습니다. 저글링과 마찬가지로 연습이 핵심입니다.

고성능 코드를 작성하는 것은 그 자체로 삼위일체가 있습니다. 목표 설정, 측정 및 대상 플랫폼 이해. 코드의 속도를 모르는 경우 언제 완료되었는지 어떻게 알 수 있나요? 코드를 측정하고 프로파일을 지정하지 않으면 목표를 달성한 시기 또는 목표를 달성하지 못하는 이유를 어떻게 알 수 있나요? 대상으로 하는 플랫폼을 이해하지 못하는 경우 목표를 달성하지 못하는 경우 최적화할 항목을 어떻게 알 수 있나요? 이러한 원칙은 일반적으로 대상으로 하는 플랫폼에 해당하는 고성능 코드 개발에 적용됩니다. 이 삼위일체를 언급하지 않고 고성능 코드 작성에 대한 문서는 완전하지 않을 것입니다. 세 가지 모두 똑같이 중요하지만 이 문서는 Microsoft® .NET Framework 대상으로 하는 고성능 애플리케이션 작성에 적용할 때 후자의 두 가지 측면에 초점을 맞출 것입니다.

모든 플랫폼에서 고성능 코드를 작성하는 기본 원칙은 다음과 같습니다.

  1. 성능 목표 설정
  2. 측정, 측정 및 측정
  3. 애플리케이션이 대상으로 하는 하드웨어 및 소프트웨어 플랫폼 이해

.NET 공용 언어 런타임

.NET Framework 핵심은 CLR(공용 언어 런타임)입니다. CLR은 코드에 대한 모든 런타임 서비스를 제공합니다. Just-In-Time 컴파일, 메모리 관리, 보안 및 기타 여러 서비스. CLR은 고성능으로 설계되었습니다. 즉, 그 성능을 활용할 수있는 방법과 당신이 그것을 방해 할 수있는 방법이 있다고 말했다.

이 문서의 목표는 성능 관점에서 공용 언어 런타임의 개요를 제공하고, 관리 코드 성능 모범 사례를 식별하고, 관리되는 애플리케이션의 성능을 측정하는 방법을 보여 주는 것입니다. 이 문서는 .NET Framework 성능 특성에 대한 완전한 논의가 아닙니다. 이 문서의 목적을 위해 처리량, 확장성, 시작 시간 및 메모리 사용량을 포함하도록 성능을 정의합니다.

관리되는 데이터 및 가비지 수집기

성능이 중요한 애플리케이션에서 관리 코드를 사용하는 것에 대한 개발자의 주요 관심사 중 하나는 GC(가비지 수집기)에서 수행하는 CLR의 메모리 관리 비용입니다. 메모리 관리 비용은 형식의 instance 연결된 메모리 할당 비용, instance 수명 동안 해당 메모리를 관리하는 비용 및 더 이상 필요하지 않은 경우 해당 메모리를 해제하는 비용의 함수입니다.

관리되는 할당은 일반적으로 매우 저렴합니다. 대부분의 경우 C/C++ malloc 또는 new보다 시간이 적게 소요됩니다. 이는 CLR이 사용 가능한 목록을 스캔하여 새 개체를 보유할 수 있을 만큼 큰 다음 연속 메모리 블록을 찾을 필요가 없기 때문입니다. 메모리의 다음 자유 위치에 대한 포인터를 유지합니다. 관리되는 힙 할당은 "stack like"라고 생각할 수 있습니다. 할당으로 인해 GC에서 새 개체를 할당하기 위해 메모리를 해제해야 하는 경우 컬렉션이 발생할 수 있습니다. 이 경우 할당 비용이 또는 new보다 malloc 비쌉니다. 고정된 개체는 할당 비용에도 영향을 줄 수 있습니다. 고정된 개체는 일반적으로 개체의 주소가 네이티브 API에 전달되었기 때문에 GC가 컬렉션 중에 이동하지 않도록 지시받은 개체입니다.

또는 newmalloc 달리 개체의 수명 동안 메모리 관리와 관련된 비용이 발생합니다. CLR GC는 세대입니다. 즉, 전체 힙이 항상 수집되지는 않습니다. 그러나 GC는 수집되는 힙 부분의 나머지 힙 루트 개체에 있는 라이브 개체가 있는지 여전히 알고 있어야 합니다. 젊은 세대의 개체에 대한 참조를 포함하는 개체가 포함된 메모리는 개체의 수명 동안 관리하는 데 비용이 많이 듭니다.

GC는 세대별 표시 및 스윕 가비지 수집기입니다. 관리되는 힙에는 3세대가 포함됩니다. 0세대는 모든 새 개체를 포함하고, 1세대는 약간 수명이 긴 개체를 포함하고, 2세대는 수명이 긴 개체를 포함합니다. GC는 애플리케이션이 계속할 수 있는 충분한 메모리를 확보할 수 있는 힙의 가장 작은 섹션을 수집합니다. 세대 컬렉션에는 모든 젊은 세대의 컬렉션이 포함되며, 이 경우 1세대 컬렉션도 0세대를 수집합니다. 0세대는 프로세서 캐시의 크기와 애플리케이션의 할당 속도에 따라 동적으로 크기가 조정되며 일반적으로 수집하는 데 10밀리초 미만이 걸립니다. 1세대는 애플리케이션의 할당 속도에 따라 동적으로 크기가 조정되며 일반적으로 수집하는 데 10~30밀리초가 걸립니다. 2세대 크기는 수집하는 데 걸리는 시간과 마찬가지로 애플리케이션의 할당 프로필에 따라 달라집니다. 이러한 2세대 컬렉션은 애플리케이션의 메모리 관리 성능 비용에 가장 큰 영향을 줍니다.

힌트 GC는 자체 조정 중이며 애플리케이션 메모리 요구 사항에 따라 자체 조정됩니다. 대부분의 경우 프로그래밍 방식으로 GC를 호출하면 해당 튜닝이 방해됩니다. GC를 호출하여 GC를 "지원" 합니다. 수집 은 애플리케이션 성능을 향상시키지 못할 가능성이 높습니다.

GC는 컬렉션 중에 라이브 개체를 재배치할 수 있습니다. 이러한 개체가 큰 경우 재배치 비용이 높으므로 해당 개체는 큰 개체 힙이라는 힙의 특수 영역에 할당됩니다. 큰 개체 힙은 수집되지만 압축되지 않습니다. 예를 들어 큰 개체는 재배치되지 않습니다. 큰 개체는 80kb보다 큰 개체입니다. 이는 이후 버전의 CLR에서 변경될 수 있습니다. 큰 개체 힙을 수집해야 하는 경우 전체 컬렉션이 강제로 적용되고 Gen 2 컬렉션 중에 큰 개체 힙이 수집됩니다. 큰 개체 힙에 있는 개체의 할당 및 사망률은 애플리케이션 메모리 관리의 성능 비용에 상당한 영향을 미칠 수 있습니다.

할당 프로필

관리되는 애플리케이션의 전체 할당 프로필은 가비지 수집기가 애플리케이션과 연결된 메모리를 관리하기 위해 얼마나 열심히 작업해야 하는지 정의합니다. GC가 메모리 관리를 더 어렵게 할수록 GC에 걸리는 CPU 주기 수가 증가하고 CPU가 애플리케이션 코드를 실행하는 데 소요되는 시간이 줄어듭니다. 할당 프로필은 할당된 개체 수, 해당 개체의 크기 및 수명에 대한 함수입니다. GC 압력을 완화하는 가장 확실한 방법은 단순히 더 적은 수의 개체를 할당하는 것입니다. 개체 지향 디자인 기술을 사용하여 확장성, 모듈성 및 재사용을 위해 설계된 애플리케이션은 거의 항상 할당 수가 증가합니다. 추상화 및 "우아함"에 대한 성능 저하가 있습니다.

GC 친화적인 할당 프로필에는 애플리케이션의 시작 부분에 할당된 다음 애플리케이션의 수명 동안 남아 있는 일부 개체가 있고 다른 모든 개체는 수명이 짧습니다. 수명이 긴 개체에는 수명이 짧은 개체에 대한 참조가 거의 또는 전혀 없습니다. 할당 프로필이 이것에서 벗어나면 GC는 애플리케이션 메모리를 관리하기 위해 더 열심히 노력해야 합니다.

GC 비우호적 할당 프로필에는 2세대로 남아 있는 개체가 많거나, 수명이 짧은 개체가 큰 개체 힙에 할당됩니다. 2세대에 들어가서 죽을 만큼 오래 살아남는 물체는 관리하는 데 가장 비쌉니다. GC 기간 동안 젊은 세대의 개체에 대한 참조를 포함하는 기성 세대의 개체 앞에서 언급했듯이 컬렉션 비용도 증가합니다.

일반적인 실제 할당 프로필은 위에서 언급한 두 할당 프로필 사이에 있습니다. 할당 프로필의 중요한 메트릭은 GC에서 소요되는 총 CPU 시간의 백분율입니다. .NET CLR 메모리: GC 성능 카운터의 % 시간에서 이 숫자를 가져올 수 있습니다. 이 카운터의 평균 값이 30%를 초과하는 경우 할당 프로필을 자세히 살펴보는 것이 좋습니다. 그렇다고 해서 할당 프로필이 "나쁜" 것은 아닙니다. 이 수준의 GC가 필요하고 적절한 메모리 집약적 애플리케이션이 있습니다. 이 카운터는 성능 문제가 발생할 경우 가장 먼저 확인해야 합니다. 할당 프로필이 문제의 일부인지 즉시 표시해야 합니다.

힌트 .NET CLR 메모리: % Time in GC 성능 카운터에서 애플리케이션이 GC에서 평균 30% 이상의 시간을 소비하고 있음을 나타내는 경우 할당 프로필을 자세히 살펴봐야 합니다.

힌트 GC 친화적인 애플리케이션에는 2세대 컬렉션보다 훨씬 더 많은 0세대 컬렉션이 있습니다. 이 비율은 NET CLR 메모리: # Gen 0 컬렉션 및 NET CLR 메모리: # Gen 2 컬렉션 성능 카운터를 비교하여 설정할 수 있습니다.

프로파일링 API 및 CLR 프로파일러

CLR에는 타사에서 관리되는 애플리케이션에 대한 사용자 지정 프로파일러를 작성할 수 있는 강력한 프로파일링 API가 포함되어 있습니다. CLR Profiler는 이 프로파일링 API를 사용하는 CLR 제품 팀에서 작성한 지원되지 않는 할당 프로파일링 샘플 도구입니다. CLR Profiler를 사용하면 개발자가 관리 애플리케이션의 할당 프로필을 볼 수 있습니다.

그림 1 CLR 프로파일러 주 창

CLR Profiler에는 할당된 형식의 히스토그램, 할당 및 호출 그래프, 다양한 세대의 GC를 보여 주는 시계열 및 해당 컬렉션 이후 관리되는 힙의 결과 상태, 메서드별 할당 및 어셈블리 로드를 보여 주는 호출 트리를 포함하여 할당 프로필의 매우 유용한 여러 보기가 포함되어 있습니다.

그림 2 CLR 프로파일러 할당 그래프

힌트 CLR Profiler를 사용하는 방법에 대한 자세한 내용은 zip에 포함된 추가 정보 파일을 참조하세요.

CLR Profiler에는 고성능 오버헤드가 있으며 애플리케이션의 성능 특성이 크게 변경됩니다. CLR Profiler를 사용하여 애플리케이션을 실행할 때 새로운 스트레스 버그가 사라질 수 있습니다.

서버 GC 호스팅

CLR에는 워크스테이션 GC 및 서버 GC라는 두 가지 가비지 수집기를 사용할 수 있습니다. 콘솔 및 Windows Forms 애플리케이션은 워크스테이션 GC를 호스트하고 ASP.NET 서버 GC를 호스트합니다. 서버 GC는 처리량 및 다중 프로세서 확장성에 최적화되어 있습니다. 서버 GC는 표시 및 스윕 단계를 포함하여 컬렉션의 전체 기간 동안 관리 코드를 실행하는 모든 스레드를 일시 중지하고, GC는 우선 순위가 높은 전용 CPU 선호 스레드에서 프로세스에 사용할 수 있는 모든 CPU에서 병렬로 발생합니다. 스레드가 GC 중에 네이티브 코드를 실행하는 경우 네이티브 호출이 반환되는 경우에만 해당 스레드가 일시 중지됩니다. 다중 프로세서 컴퓨터에서 실행하려는 서버 애플리케이션을 빌드하는 경우 서버 GC를 사용하는 것이 좋습니다. 의 애플리케이션이 ASP.NET 호스팅되지 않는 경우 CLR을 명시적으로 호스트하는 네이티브 애플리케이션을 작성해야 합니다.

힌트 확장 가능한 서버 애플리케이션을 빌드하는 경우 서버 GC를 호스트합니다. 관리되는 앱에 대한 사용자 지정 공용 언어 런타임 호스트 구현을 참조하세요.

워크스테이션 GC는 일반적으로 클라이언트 애플리케이션에 필요한 짧은 대기 시간에 최적화되어 있습니다. 일반적으로 클라이언트 성능은 원시 처리량에 의해 측정되지 않고 인식된 성능에 의해 측정되기 때문에 GC 중에 클라이언트 애플리케이션에서 눈에 띄는 일시 중지를 원하지 않습니다. Workstation GC는 동시 GC를 수행합니다. 즉, 관리 코드가 계속 실행되는 동안 단계 표시를 수행합니다. GC는 스윕 단계를 수행해야 하는 경우에만 관리 코드를 실행하는 스레드를 일시 중지합니다. 워크스테이션 GC에서 GC는 하나의 스레드에서만 수행되므로 하나의 CPU에서만 수행됩니다.

종료

CLR은 형식의 instance 연결된 메모리가 해제되기 전에 클린 자동으로 수행되는 메커니즘을 제공합니다. 이 메커니즘을 Finalization이라고 합니다. 일반적으로 Finalization은 네이티브 리소스를 해제하는 데 사용됩니다. 이 경우 개체에서 사용 중인 데이터베이스 연결 또는 운영 체제 핸들입니다.

마무리는 비용이 많이 드는 기능이며 GC에 가하는 압력을 증가합니다. GC는 종료 가능한 큐에서 종료가 필요한 개체를 추적합니다. 컬렉션 중에 GC가 더 이상 라이브 상태가 아니지만 종료가 필요한 개체를 찾으면 Finalizable Queue에 있는 해당 개체의 항목이 FReachable Queue로 이동됩니다. 종료는 종료자 스레드라는 별도의 스레드에서 발생합니다. Finalizer를 실행하는 동안 개체의 전체 상태가 필요할 수 있으므로 개체와 개체가 가리키는 모든 개체가 다음 세대로 승격됩니다. 개체 또는 개체 그래프와 연결된 메모리는 다음 GC 동안에만 해제됩니다.

해제해야 하는 리소스는 가능한 한 작은 Finalizable 개체로 래핑되어야 합니다. instance 경우 클래스에 관리되는 리소스와 관리되지 않는 리소스 모두에 대한 참조가 필요한 경우 새 Finalizable 클래스에서 관리되지 않는 리소스를 래핑하고 해당 클래스를 클래스의 멤버로 만들어야 합니다. 부모 클래스는 Finalizable이 아니어야 합니다. 즉, 관리되지 않는 리소스가 포함된 클래스만 승격됩니다(관리되지 않는 리소스가 포함된 클래스에서 부모 클래스에 대한 참조를 보유하지 않는다고 가정). 유의해야 할 또 다른 점은 완료 스레드가 하나만 있다는 것입니다. Finalizer로 인해 이 스레드가 차단되면 후속 Finalizer가 호출되지 않고 리소스가 해제되지 않으며 애플리케이션이 누출됩니다.

힌트 종료자는 가능한 한 간단하게 유지해야 하며 차단해서는 안 됩니다.

힌트 정리가 필요한 관리되지 않는 개체를 중심으로 래퍼 클래스만 마무리할 수 있도록 합니다.

마무리는 참조 계산의 대안으로 생각할 수 있습니다. 참조 계산을 구현하는 개체는 참조 수가 0일 때 리소스를 해제할 수 있도록 참조가 있는 다른 개체의 수를 추적합니다(잘 알려진 문제가 발생할 수 있음). CLR은 참조 계산을 구현하지 않으므로 개체에 대한 참조가 더 이상 보유되지 않을 때 리소스를 자동으로 해제하는 메커니즘을 제공해야 합니다. 종료는 해당 메커니즘입니다. 일반적으로 클린 필요한 개체의 수명이 명시적으로 알려지지 않은 경우에만 완료가 필요합니다.

삭제 패턴

개체의 수명이 명시적으로 알려진 경우 개체와 연결된 관리되지 않는 리소스를 열심히 해제해야 합니다. 이를 개체 "삭제"라고 합니다. Dispose 패턴은 IDisposable 인터페이스를 통해 구현됩니다(직접 구현하는 것은 간단하지만). 클래스에 대해 즉시 종료를 사용할 수 있도록 하려면(예: 클래스의 인스턴스를 삭제 가능으로 설정) 개체가 IDisposable 인터페이스를 구현하고 Dispose 메서드에 대한 구현을 제공해야 합니다. Dispose 메서드에서 Finalizer에 있는 동일한 정리 코드를 호출하고 GC를 호출하여 개체를 더 이상 완료할 필요가 없음을 GC에 알릴 것입니다. SuppressFinalization 메서드. Dispose 메서드와 Finalizer가 공통 종료 함수를 호출하여 클린 코드의 한 버전만 유지 관리하도록 하는 것이 좋습니다. 또한 개체의 의미 체계가 Close 메서드가 Dispose 메서드보다 논리적이면 Close 도 구현되어야 합니다. 이 경우 데이터베이스 연결 또는 소켓이 논리적으로 "닫힘"됩니다. CloseDispose 메서드를 호출할 수 있습니다.

Finalizer를 사용하여 클래스에 Dispose 메서드를 제공하는 것이 좋습니다. 수명이 명시적으로 알려질지 여부를 instance 해당 클래스가 어떻게 사용될지 확신할 수 없습니다. 사용 중인 클래스가 Dispose 패턴을 구현하고 개체를 완료할 때 명시적으로 알고 있는 경우 Dispose를 가장 확실하게 호출합니다.

힌트 종료 가능한 모든 클래스에 대해 Dispose 메서드를 제공합니다.

힌트Dispose 메서드에서 종료를 표시하지 않습니다.

힌트 일반적인 정리 함수를 호출합니다.

힌트 사용 중인 개체가 IDisposable을 구현하고 개체가 더 이상 필요하지 않은 경우 Dispose를 호출합니다.

C#은 개체를 자동으로 삭제하는 매우 편리한 방법을 제공합니다. using 키워드(keyword) 여러 삭제 가능한 개체에서 Dispose가 호출되는 코드 블록을 식별할 수 있습니다.

C#의 키워드(keyword) 사용

using(DisposableType T)
{
   //Do some work with T
}
//T.Dispose() is called automatically

약한 참조에 대한 참고 사항

스택, 레지스터, 다른 개체 또는 다른 GC 루트 중 하나에 있는 개체에 대한 모든 참조는 GC 중에 개체를 활성 상태로 유지합니다. 일반적으로 애플리케이션이 해당 개체로 수행되지 않는다는 것을 고려하면 이는 일반적으로 매우 좋은 일입니다. 그러나 개체에 대한 참조를 사용하려고 하지만 수명에 영향을 미치지 않으려는 경우가 있습니다. 이러한 경우 CLR은 이를 위해 약한 참조라는 메커니즘을 제공합니다. 강력한 참조(instance 개체를 뿌리 내는 참조)는 약한 참조로 전환할 수 있습니다. 약한 참조를 사용할 수 있는 경우의 예는 데이터 구조를 트래버스할 수 있지만 개체의 수명에 영향을 미치지 않아야 하는 외부 커서 개체를 만들려는 경우입니다. 또 다른 예는 메모리 압력이 있을 때 플러시되는 캐시를 만들려는 경우입니다. GC가 발생하는 경우 instance.

C에서 약한 참조 만들기#

MyRefType mrt = new MyRefType();
//...

//Create weak reference
WeakReference wr = new WeakReference(mrt); 
mrt = null; //object is no longer rooted
//...

//Has object been collected?
if(wr.IsAlive)
{
   //Get a strong reference to the object
   mrt = wr.Target;
   //object is rooted and can be used again
}
else
{
   //recreate the object
   mrt = new MyRefType();
}

관리 코드 및 CLR JIT

관리 코드 배포 단위인 관리되는 어셈블리에는 MSIL 또는 IL(Microsoft Intermediate Language)이라는 프로세서 독립적 언어가 포함되어 있습니다. CLR JIT(Just-In-Time)는 IL을 최적화된 네이티브 X86 지침으로 컴파일합니다. JIT는 최적화 컴파일러이지만, 컴파일은 런타임에 발생하고 메서드가 처음 호출될 때만 발생하기 때문에 컴파일을 수행하는 데 걸리는 시간과 균형을 유지해야 하는 최적화의 수입니다. 일반적으로 시작 시간과 응답성은 일반적으로 문제가 되지 않지만 클라이언트 애플리케이션에 중요하므로 서버 애플리케이션에는 중요하지 않습니다. NGEN.exe 사용하여 설치 시 컴파일을 수행하여 시작 시간을 개선할 수 있습니다.

JIT에서 수행하는 대부분의 최적화에는 프로그래밍 방식 패턴이 연결되어 있지 않습니다. instance 경우 명시적으로 코딩할 수는 없지만 그렇게 하는 숫자가 있습니다. 다음 섹션에서는 이러한 최적화 중 일부에 대해 설명합니다.

힌트 NGEN.exe 유틸리티를 사용하여 설치 시 애플리케이션을 컴파일하여 클라이언트 애플리케이션의 시작 시간을 개선합니다.

메서드 인라인 처리

메서드 호출과 관련된 비용이 있습니다. 인수를 스택에 푸시하거나 레지스터에 저장해야 하며, 메서드 프롤로그 및 에필로그를 실행해야 합니다. 호출하는 메서드의 메서드 본문을 호출자의 본문으로 이동하기만 하면 특정 메서드의 경우 이러한 호출 비용을 방지할 수 있습니다. 이를 메서드 인라이닝이라고 합니다. JIT는 여러 추론을 사용하여 메서드가 줄 지어 있어야 하는지 여부를 결정합니다. 다음은 그 중 더 중요한 목록입니다(이는 완전하지 않음).

  • IL의 32바이트보다 큰 메서드는 인라인되지 않습니다.
  • 가상 함수는 인라인되지 않습니다.
  • 복잡한 흐름 제어가 있는 메서드는 줄 지어 있지 않습니다. 복잡한 흐름 제어는 이 경우 switch 또는 while이외의 흐름 제어 if/then/else; 입니다.
  • 예외 처리 블록을 포함하는 메서드는 인라인 처리되지 않지만 예외를 throw하는 메서드는 여전히 인라인 처리 후보입니다.
  • 메서드의 공식 인수가 구조체인 경우 메서드는 인라인되지 않습니다.

이러한 추론은 향후 버전의 JIT에서 변경될 수 있으므로 명시적으로 코딩하는 것이 좋습니다. 인라인 처리되도록 보장하기 위해 메서드의 정확성을 손상시키지 마세요. C++의 inline__inline 키워드가 컴파일러가 메서드를 인라인으로 표시한다고 보장하지는 않습니다(그렇지만 __forceinline ).

속성 가져오기 및 설정 메서드는 일반적으로 개인 데이터 멤버를 초기화하는 것만 하기 때문에 인라인화에 적합한 후보입니다.

**HINT **인라인을 보장하기 위해 메서드의 정확성을 손상시키지 마세요.

범위 검사 제거

관리 코드의 많은 이점 중 하나는 자동 범위 검사입니다. array[index] 의미 체계를 사용하여 배열에 액세스할 때마다 JIT는 검사 내보내 인덱스가 배열의 범위에 있는지 확인합니다. 반복 수가 많고 반복당 실행되는 명령 수가 적은 루프의 컨텍스트에서 이러한 범위 검사는 비용이 많이 들 수 있습니다. JIT가 이러한 범위 검사가 불필요하다는 것을 감지하고 루프 본문에서 검사 제거하고 루프 실행이 시작되기 전에 한 번만 확인하는 경우가 있습니다. C#에는 이러한 범위 검사가 제거되도록 하는 프로그래밍 방식 패턴이 있습니다. "for" 문에서 배열 길이를 명시적으로 테스트합니다. 이 패턴의 미묘한 편차로 인해 검사 제거되지 않고 이 경우 인덱스에 값을 추가합니다.

C에서 범위 검사 제거#

//Range check will be eliminated
for(int i = 0; i < myArray.Length; i++) 
{
   Console.WriteLine(myArray[i].ToString());
}

//Range check will NOT be eliminated
for(int i = 0; i < myArray.Length + y; i++) 
{ 
   Console.WriteLine(myArray[i+x].ToString());
}

내부 및 외부 루프의 범위 검사 모두 제거되므로 instance 큰 들쭉날쭉한 배열을 검색할 때 최적화가 특히 두드러집니다.

변수 사용량 추적이 필요한 최적화

여러 JIT 컴파일러 최적화를 사용하려면 JIT가 공식 인수 및 지역 변수의 사용을 추적해야 합니다. 예를 들어 가 처음 사용되는 경우와 메서드 본문에서 마지막으로 사용되는 경우입니다. CLR 버전 1.0 및 1.1에서는 JIT가 사용량을 추적할 총 변수 수에 대해 64라는 제한이 있습니다. 사용량 추적이 필요한 최적화의 예는 Enregistration입니다. 등록은 변수가 스택 프레임(예: RAM)이 아닌 프로세서 레지스터에 저장되는 경우입니다. Enregistered 변수에 대한 액세스는 프레임의 변수가 프로세서 캐시에 있는 경우에도 스택 프레임에 있는 경우보다 훨씬 빠릅니다. 등록에 대해 64개의 변수만 고려됩니다. 다른 모든 변수는 스택에 푸시됩니다. 사용량 추적에 의존하는 등록 이외의 다른 최적화가 있습니다. JIT 최적화의 최대 수를 보장하려면 메서드의 공식 인수 및 로컬 수를 64 미만으로 유지해야 합니다. 이 숫자는 CLR의 이후 버전에 대해 변경될 수 있습니다.

힌트 메서드를 짧게 유지합니다. 여기에는 메서드 인라인화, 등록 및 JIT 기간을 비롯한 여러 가지 이유가 있습니다.

기타 JIT 최적화

JIT 컴파일러는 상수 및 복사 전파, 루프 고정 게양 및 기타 여러 가지 최적화를 수행합니다. 이러한 최적화를 얻기 위해 사용해야 하는 명시적 프로그래밍 패턴은 없습니다. 무료입니다.

Visual Studio에서 이러한 최적화가 표시되지 않는 이유는 무엇인가요?

디버그 메뉴에서 시작을 사용하거나 F5 키를 눌러 Visual Studio에서 애플리케이션을 시작할 때 릴리스 또는 디버그 버전을 빌드했든 관계없이 모든 JIT 최적화가 비활성화됩니다. 관리되는 애플리케이션이 디버거에 의해 시작되면 애플리케이션의 디버그 빌드가 아니더라도 JIT는 최적화되지 않은 x86 명령을 내보낸다. JIT가 최적화된 코드를 내보내도록 하려면 Windows Explorer 애플리케이션을 시작하거나 Visual Studio 내에서 Ctrl+F5를 사용합니다. 최적화된 디스어셈블리를 보고 최적화되지 않은 코드와 대조하려면 cordbg.exe 사용할 수 있습니다.

힌트 cordbg.exe 사용하여 JIT에서 내보낸 최적화된 코드와 최적화되지 않은 코드의 디스어셈블리를 확인합니다. cordbg.exe 사용하여 애플리케이션을 시작한 후 다음을 입력하여 JIT 모드를 설정할 수 있습니다.

(cordbg) mode JitOptimizations 1
JIT's will produce optimized code

(cordbg) mode JitOptimizations 0

JIT는 디버깅 가능(최적화되지 않은) 코드를 생성합니다.

값 형식

CLR은 참조 형식과 값 형식의 두 가지 형식 집합을 노출합니다. 참조 형식은 항상 관리되는 힙에 할당되며 이름에서 알 수 있듯이 참조로 전달됩니다. 값 형식은 힙에 있는 개체의 일부로 스택 또는 인라인에 할당되며, 참조로 전달할 수도 있지만 기본적으로 값으로 전달됩니다. 값 형식은 할당하기에 매우 저렴하며, 작고 단순하게 유지한다고 가정하면 인수로 전달하는 것이 저렴합니다. 값 형식을 적절하게 사용하는 좋은 예는 xy 좌표를 포함하는 점 값 형식입니다.

점 값 형식

struct Point
{
   public int x;
   public int y;
   
   //
}

값 형식을 개체로 처리할 수도 있습니다. instance 경우 개체 메서드를 호출하거나 개체로 캐스팅하거나 개체가 필요한 위치에 전달할 수 있습니다. 그러나 이 경우 값 형식은 Boxing이라는 프로세스를 통해 참조 형식으로 변환됩니다. 값 형식이 Boxed이면 관리되는 힙에 새 개체가 할당되고 값이 새 개체로 복사됩니다. 이는 비용이 많이 드는 작업이며 값 형식을 사용하여 얻은 성능을 줄이거나 완전히 부정할 수 있습니다. Boxed 형식이 암시적으로 또는 명시적으로 값 형식으로 다시 캐스팅되면 Unboxed입니다.

Box/Unbox 값 형식

C #:

int BoxUnboxValueType()
{
   int i = 10;
   object o = (object)i; //i is Boxed
   return (int)o + 3; //i is Unboxed
}

Msil:

.method private hidebysig instance int32
        BoxUnboxValueType() cil managed
{
  // Code size       20 (0x14)
  .maxstack  2
  .locals init (int32 V_0,
           object V_1)
  IL_0000:  ldc.i4.s   10
  IL_0002:  stloc.0
  IL_0003:  ldloc.0
  IL_0004:  box        [mscorlib]System.Int32
  IL_0009:  stloc.1
  IL_000a:  ldloc.1
  IL_000b:  unbox      [mscorlib]System.Int32
  IL_0010:  ldind.i4
  IL_0011:  ldc.i4.3
  IL_0012:  add
  IL_0013:  ret
} // end of method Class1::BoxUnboxValueType

사용자 지정 값 형식(C#의 구조체)을 구현하는 경우 ToString 메서드를 재정의하는 것이 좋습니다. 이 메서드를 재정의하지 않으면 값 형식에서 ToString 을 호출하면 형식이 Boxed가 됩니다. ToString이 가장 자주 호출되는 메서드이지만 System.Object에서 상속되는 다른 메서드(이 경우 Equals)에도 마찬가지입니다. 값 형식이 Boxed인지와 시기를 알고 싶다면 위의 코드 조각과 같이 ildasm.exe 유틸리티를 사용하여 MSIL에서 명령을 찾을 box 수 있습니다.

Boxing을 방지하기 위해 C#에서 ToString() 메서드 재정의

struct Point
{
   public int x;
   public int y;

   //This will prevent type being boxed when ToString is called
   public override string ToString()
   {
      return x.ToString() + "," + y.ToString();
   }
}

컬렉션(예: float의 ArrayList)을 만들 때 컬렉션에 추가할 때 모든 항목이 Boxed가 됩니다. 배열을 사용하거나 값 형식에 대한 사용자 지정 컬렉션 클래스를 만드는 것이 좋습니다.

C에서 컬렉션 클래스를 사용하는 경우 암시적 Boxing#

ArrayList al = new ArrayList();
al.Add(42.0F); //Implicitly Boxed becuase Add() takes object
float f = (float)al[0]; //Unboxed

예외 처리

일반적인 흐름 제어로 오류 조건을 사용하는 것이 일반적입니다. 이 경우 Active Directory instance 사용자를 프로그래밍 방식으로 추가하려고 할 때 사용자를 추가하기만 하면 E_ADS_OBJECT_EXISTS HRESULT가 반환되면 디렉터리에 이미 있다는 것을 알 수 있습니다. 또는 디렉터리에서 사용자를 검색한 다음 검색에 실패한 경우에만 사용자를 추가할 수 있습니다.

일반적인 흐름 제어에 이러한 오류를 사용하는 것은 CLR 컨텍스트에서 성능 안티 패턴입니다. CLR의 오류 처리는 구조적 예외 처리로 수행됩니다. 관리되는 예외는 throw할 때까지 매우 저렴합니다. CLR에서 예외가 throw되면 throw된 예외에 대한 적절한 예외 처리기를 찾으려면 스택 워크가 필요합니다. 스택 워킹은 비용이 많이 드는 작업입니다. 예외는 이름에서 알 수 있듯이 사용해야 합니다. 예외적이거나 예기치 않은 상황에서

**HINT **성능에 중요한 메서드에 대해 예외를 throw하는 것이 아니라 예상 결과에 대해 열거된 결과를 반환하는 것이 좋습니다.

**HINT **애플리케이션에서 throw되는 예외 수를 알려주는 여러 .NET CLR 예외 성능 카운터가 있습니다.

**HINT **VB.NET 대신 예외를 On Error Goto사용하는 경우 오류 개체는 불필요한 비용입니다.

스레딩 및 동기화

CLR은 고유한 스레드, 스레드 풀 및 다양한 동기화 기본 형식을 만드는 기능을 포함하여 풍부한 스레딩 및 동기화 기능을 노출합니다. CLR에서 스레딩 지원을 활용하기 전에 스레드 사용을 신중하게 고려해야 합니다. 스레드를 추가하면 실제로 처리량을 늘리는 것이 아니라 처리량을 줄일 수 있으며 메모리 사용률을 높일 수 있습니다. 다중 프로세서 컴퓨터에서 실행하려는 서버 애플리케이션에서 스레드를 추가하면 실행을 병렬화하여 처리량을 크게 향상시킬 수 있습니다(예: 실행의 serialization과 같은 잠금 경합의 양에 따라 다름). 클라이언트 애플리케이션에서 작업 및/또는 진행률을 표시하는 스레드를 추가하면 인식된 성능(적은 처리량 비용)을 향상시킬 수 있습니다.

애플리케이션의 스레드가 특정 작업에 대해 특수화되지 않았거나 연결된 특수 상태가 있는 경우 스레드 풀을 사용하는 것이 좋습니다. 이전에 Win32 스레드 풀을 사용한 경우 CLR의 스레드 풀은 매우 친숙할 것입니다. 관리되는 프로세스당 스레드 풀의 단일 instance 있습니다. 스레드 풀은 만드는 스레드 수에 대해 스마트하며 머신의 부하에 따라 자체 조정됩니다.

동기화를 논의하지 않고는 스레딩을 논의할 수 없습니다. 다중 스레딩이 애플리케이션에 제공할 수 있는 모든 처리량 향상은 잘못 작성된 동기화 논리에 의해 무효화될 수 있습니다. 잠금의 세분성은 잠금을 만들고 관리하는 비용과 잠금이 잠재적으로 실행을 직렬화할 수 있다는 사실 때문에 애플리케이션의 전체 처리량에 크게 영향을 줄 수 있습니다. 이 점을 설명하기 위해 트리에 노드를 추가하려는 예제를 사용합니다. 트리가 공유 데이터 구조가 될 경우 instance 애플리케이션을 실행하는 동안 여러 스레드가 액세스해야 하며 트리에 대한 액세스를 동기화해야 합니다. 노드를 추가하는 동안 전체 트리를 잠그도록 선택할 수 있습니다. 즉, 단일 잠금을 만드는 비용만 발생하지만 트리에 액세스하려는 다른 스레드는 차단될 수 있습니다. 이는 거친 잠금의 예입니다. 또는 트리를 트래버스할 때 각 노드를 잠글 수 있습니다. 즉, 노드당 잠금을 만드는 비용이 발생하지만 잠긴 특정 노드에 액세스하려고 시도하지 않는 한 다른 스레드는 차단되지 않습니다. 이는 벌금이 부과된 잠금의 예입니다. 작동 중인 하위 트리만 잠그는 것이 잠금의 보다 적절한 세분성일 수 있습니다. 이 예제에서는 여러 판독기에서 동시에 액세스할 수 있어야 하므로 RWLock(공유 잠금)을 사용할 수 있습니다.

동기화된 작업을 수행하는 가장 간단하고 성능이 가장 높은 방법은 System.Threading.Interlocked 클래스를 사용하는 것입니다. Interlocked 클래스는 증분, 감소, ExchangeCompareExchange와 같은 여러 하위 수준의 원자성 연산을 노출합니다.

C에서 System.Threading.Interlocked 클래스 사용#

using System.Threading;
//...
public class MyClass
{
   void MyClass() //Constructor
   {
      //Increment a global instance counter atomically
      Interlocked.Increment(ref MyClassInstanceCounter);
   }

   ~MyClass() //Finalizer
   {
      //Decrement a global instance counter atomically
      Interlocked.Decrement(ref MyClassInstanceCounter);
      //... 
   }
   //...
}

가장 일반적으로 사용되는 동기화 메커니즘은 모니터 또는 중요 섹션일 수 있습니다. 모니터 잠금은 직접 사용하거나 C#의 lock 키워드(keyword) 사용하여 사용할 수 있습니다. lock 키워드(keyword) 지정된 개체에 대한 액세스를 특정 코드 블록과 동기화합니다. 상당히 가볍게 경합되는 모니터 잠금은 성능 관점에서 상대적으로 저렴하지만 경쟁이 치열하면 비용이 더 많이 듭니다.

C# 잠금 키워드(keyword)

//Thread will attempt to obtain the lock
//and block until it does
lock(mySharedObject)
{
   //A thread will only be able to execute the code
   //within this block if it holds the lock
}//Thread releases the lock

RWLock은 공유 잠금 메커니즘을 제공합니다. 예를 들어 "판독기"는 잠금을 다른 "판독기"와 공유할 수 있지만 "작성기"는 공유할 수 없습니다. 이 기능을 적용할 수 있는 경우 RWLock은 모니터를 사용하는 것보다 처리량이 향상될 수 있으며, 단일 판독기 또는 기록기만 한 번에 잠금을 가져올 수 있습니다. System.Threading 네임스페이스에는 Mutex 클래스도 포함됩니다. 뮤텍스는 프로세스 간 동기화를 허용하는 동기화 기본 형식입니다. 이는 중요한 섹션보다 훨씬 더 비싸며 프로세스 간 동기화가 필요한 경우에만 사용해야 합니다.

반사

리플렉션은 CLR에서 제공하는 메커니즘으로, 런타임에 프로그래밍 방식으로 형식 정보를 가져올 수 있습니다. 리플렉션은 관리되는 어셈블리에 포함된 메타데이터에 크게 의존합니다. 많은 리플렉션 API에는 비용이 많이 드는 작업인 메타데이터를 검색하고 구문 분석해야 합니다.

리플렉션 API는 세 개의 성능 버킷으로 그룹화할 수 있습니다. 형식 비교, 멤버 열거형 및 멤버 호출. 이러한 각 버킷은 점점 더 비쌉니다. 형식 비교 작업(이 경우 C#, GetTypetypeof, is, IsInstanceOfType 등)은 리플렉션 API 중 가장 저렴하지만 결코 저렴하지는 않습니다. 멤버 열거형을 사용하면 클래스의 메서드, 속성, 필드, 이벤트, 생성자 등을 프로그래밍 방식으로 검사할 수 있습니다. 이러한 항목이 사용될 수 있는 예는 디자인 타임 시나리오에 있습니다. 이 경우 Visual Studio의 속성 브라우저에 대한 세관 웹 컨트롤의 속성을 열거합니다. 가장 비싼 리플렉션 API는 클래스의 멤버를 동적으로 호출하거나 동적으로 JIT를 내보내고 메서드를 실행할 수 있는 API입니다. 어셈블리의 동적 로드, 형식 인스턴스화 및 메서드 호출이 필요한 늦은 바인딩 시나리오가 있지만 이러한 느슨한 결합을 위해서는 명시적 성능 절충이 필요합니다. 일반적으로 성능에 민감한 코드 경로에서는 리플렉션 API를 피해야 합니다. 리플렉션을 직접 사용하지는 않지만 사용하는 API는 리플렉션을 사용할 수 있습니다. 따라서 리플렉션 API의 전이적 사용도 알고 있어야 합니다.

지연 바인딩

지연 바인딩된 호출은 커버 아래에 리플렉션을 사용하는 기능의 예입니다. 시각적 Basic.NET 및 JScript.NET 모두 늦은 바인딩된 호출을 지원합니다. instance 사용하기 전에 변수를 선언할 필요가 없습니다. 지연 바인딩된 개체는 실제로 형식 개체이며 리플렉션은 런타임에 개체를 올바른 형식으로 변환하는 데 사용됩니다. 지연 바인딩된 호출은 직접 호출보다 크기가 느린 순서입니다. 특히 늦은 바인딩된 동작이 필요하지 않으면 성능에 중요한 코드 경로에서 사용하지 않아야 합니다.

힌트 VB.NET 사용 중이고 명시적으로 지연 바인딩이 필요하지 않은 경우 소스 파일의 맨 위에 및 Option Strict On 를 포함하여 Option Explicit On 컴파일러에 이를 허용하지 않도록 지시할 수 있습니다. 이러한 옵션을 사용하면 변수를 선언하고 강력하게 입력하고 암시적 캐스팅을 해제합니다.

보안

보안은 CLR의 필수 요소이며 관련 성능 비용이 있습니다. 코드가 완전 신뢰되고 보안 정책이 기본값인 경우 보안은 애플리케이션의 처리량 및 시작 시간에 약간의 영향을 주어야 합니다. 부분적으로 신뢰할 수 있는 코드(예: 인터넷 또는 인트라넷 영역의 코드) 또는 MyComputer Grant Set의 범위를 좁히면 보안 성능 비용이 증가합니다.

COM Interop 및 플랫폼 호출

COM Interop 및 Platform Invoke는 거의 투명한 방식으로 네이티브 API를 관리 코드에 노출합니다. 대부분의 네이티브 API를 호출하려면 일반적으로 몇 번의 마우스 클릭이 필요할 수 있지만 특수 코드가 필요하지 않습니다. 예상할 수 있듯이 관리 코드에서 네이티브 코드를 호출하는 것과 관련된 비용이 있으며 그 반대의 경우도 마찬가지입니다. 이 비용에는 네이티브 코드와 관리 코드 간의 전환을 수행하는 것과 관련된 고정 비용과 필요할 수 있는 인수 및 반환 값의 마샬링과 관련된 가변 비용이라는 두 가지 구성 요소가 있습니다. COM Interop 및 P/Invoke 모두에 대한 비용에 대한 고정 기여도는 작습니다. 일반적으로 50개 미만의 지침입니다. 관리되는 형식을 마샬링하는 비용은 경계의 양쪽에 표현이 얼마나 다른지에 따라 달라집니다. 상당한 양의 변환이 필요한 형식은 비용이 더 많이 듭니다. 예를 들어 CLR의 모든 문자열은 유니코드 문자열입니다. ANSI 문자 배열이 필요한 P/Invoke를 통해 Win32 API를 호출하는 경우 문자열의 모든 문자를 좁혀야 합니다. 그러나 네이티브 정수 배열이 필요한 경우 관리되는 정수 배열이 전달되는 경우 마샬링이 필요하지 않습니다.

네이티브 코드 호출과 관련된 성능 비용이 있으므로 비용이 정당화되는지 확인해야 합니다. 네이티브 호출을 수행하려는 경우 네이티브 호출이 수행하는 작업이 호출과 관련된 성능 비용을 정당화하는지 확인합니다. 메서드를 "수다스러운" 대신 "청키"로 유지합니다. 네이티브 호출의 비용을 측정하는 좋은 방법은 인수를 사용하지 않고 반환 값이 없는 네이티브 메서드의 성능을 측정한 다음 호출하려는 네이티브 메서드의 성능을 측정하는 것입니다. 차이점은 마샬링 비용을 나타냅니다.

힌트 "Chatty" 호출이 아닌 "Chunky" COM Interop 및 P/Invoke 호출을 수행하고 호출 비용이 호출이 수행하는 작업의 양에 따라 정당화되는지 확인합니다.

관리되는 스레드와 연결된 스레딩 모델은 없습니다. COM Interop 호출을 수행할 때 호출할 스레드가 올바른 COM 스레딩 모델로 초기화되었는지 확인해야 합니다. 이는 일반적으로 MTAThreadAttribute 및 STAThreadAttribute를 사용하여 수행됩니다(프로그래밍 방식으로도 수행할 수 있음).

성능 카운터

.NET CLR에 대해 많은 Windows 성능 카운터가 노출됩니다. 이러한 성능 카운터는 성능 문제를 처음 진단하거나 관리되는 애플리케이션의 성능 특성을 식별하려고 할 때 개발자가 선택하는 무기여야 합니다. 메모리 관리 및 예외와 관련된 몇 가지 카운터를 이미 언급했습니다. CLR 및 .NET Framework 거의 모든 측면에 대한 성능 카운터가 있습니다. 이러한 성능 카운터는 항상 사용할 수 있으며 비침습적입니다. 오버헤드가 적고 애플리케이션의 성능 특성을 변경하지 않습니다.

기타 도구

성능 카운터 및 CLR Profiler 외에 기존 프로파일러를 사용하여 애플리케이션에서 가장 많은 시간을 사용하고 가장 자주 호출되는 메서드를 확인하려고 합니다. 이러한 메서드는 먼저 최적화하는 메서드가 될 것입니다. Compuware의 DevPartner Studio Professional Edition 7.0 및 Intel의 VTune™ 성능 분석기 7.0을 포함하여 관리 코드를 지원하는 다수의 상용 프로파일러를 사용할 수 있습니다®. 또한 Compuware는 DevPartner Profiler Community Edition이라는 관리 코드에 대한 무료 프로파일러를 생성합니다.

결론

이 문서에서는 성능 관점에서 CLR 및 .NET Framework 대한 검사를 시작합니다. CLR 아키텍처와 애플리케이션의 성능에 영향을 주는 .NET Framework 다른 많은 측면이 있습니다. 개발자에게 제공할 수 있는 가장 좋은 지침은 애플리케이션이 대상으로 하는 플랫폼 및 사용 중인 API의 성능에 대해 가정하지 않는 것입니다. 모든 것을 측정합니다!

행복한 저글링.

리소스