.NET Framework Run-Time 기술에 대한 성능 고려 사항

 

엠마누엘 샨저
Microsoft Corporation

2001년 8월

요약: 이 문서에는 관리되는 세계에서 일하는 다양한 기술에 대한 설문 조사와 성능에 미치는 영향에 대한 기술적 설명이 포함되어 있습니다. 가비지 수집, JIT, 원격, ValueTypes, 보안 등에 대해 알아봅니다. (27페이지 인쇄)

콘텐츠

개요
가비지 수집
스레드 풀
The JIT
AppDomain
보안
원격 통신
ValueTypes
추가 리소스
부록: 서버 런타임 호스팅

개요

.NET 런타임에는 보안, 개발 용이성 및 성능을 목표로 하는 몇 가지 고급 기술이 도입되었습니다. 개발자는 각 기술을 이해하고 코드에서 효과적으로 사용하는 것이 중요합니다. 런타임에서 제공하는 고급 도구를 사용하면 강력한 애플리케이션을 쉽게 빌드할 수 있지만 해당 애플리케이션을 빠르게 빠르게 만들 수 있도록 하는 것은 개발자의 책임입니다.

이 백서는 .NET에서 작업 중인 기술에 대한 심층적인 이해를 제공하고 코드를 속도에 맞게 조정하는 데 도움이 됩니다. 참고: 사양 시트가 아닙니다. 이미 견고한 기술 정보가 많이 있습니다. 여기서 목표는 성능에 대한 강력한 기울기로 정보를 제공하는 것이며, 가지고 있는 모든 기술적 질문에 답하지 않을 수 있습니다. 여기에서 찾는 답변을 찾을 수 없는 경우 MSDN 온라인 라이브러리에서 더 자세히 살펴보는 것이 좋습니다.

다음 기술을 다루면서 그 목적과 성능에 영향을 미치는 이유에 대한 개략적인 개요를 제공합니다. 그런 다음 몇 가지 하위 수준 구현 세부 정보를 자세히 알아보고 샘플 코드를 사용하여 각 기술에서 속도를 얻는 방법을 설명합니다.

가비지 수집

기본 사항

GC(가비지 수집)는 더 이상 사용되지 않는 개체에 대한 메모리를 해제하여 프로그래머가 일반적이고 디버그하기 어려운 오류를 방지합니다. 개체의 수명 동안 이어지는 일반 경로는 관리 코드와 네이티브 코드 모두에서 다음과 같습니다.

Foo a = new Foo();      // Allocate memory for the object and initialize
...a...                  // Use the object   
delete a;               // Tear down the state of the object, clean up
                        // and free the memory for that object

네이티브 코드에서는 이러한 모든 작업을 직접 수행해야 합니다. 할당 또는 정리 단계가 누락되면 디버그하기 어려운 완전히 예측할 수 없는 동작이 발생할 수 있으며 개체를 해제하지 않으면 메모리 누수로 인해 발생할 수 있습니다. CLR(공용 언어 런타임)의 메모리 할당 경로는 방금 설명한 경로와 매우 가깝습니다. GC 관련 정보를 추가하면 매우 유사한 내용으로 끝납니다.

Foo a = new Foo();      // Allocate memory for the object and initialize
...a...                  // Use the object (it is strongly reachable)
a = null;               // A becomes unreachable (out of scope, nulled, etc)
                        // Eventually a collection occurs, and a's resources
                        // are torn down and the memory is freed

개체를 해제할 수 있을 때까지 두 세계에서 동일한 단계를 수행합니다. 네이티브 코드에서는 개체를 완료할 때 개체를 해제해야 합니다. 관리 코드에서 개체에 더 이상 연결할 수 없으면 GC에서 수집할 수 있습니다. 물론 리소스를 해제해야 하는 경우(예: 소켓 닫기) GC가 올바르게 닫는 데 도움이 필요할 수 있습니다. 리소스를 해제하기 전에 리소스를 클린 위해 이전에 작성한 코드는 Dispose()Finalize() 메서드 형식으로 계속 적용됩니다. 나는 나중에이 두 가지의 차이점에 대해 이야기 할 것이다.

리소스에 대한 포인터를 유지하는 경우 GC는 나중에 사용할 것인지 알 방법이 없습니다. 즉, 명시적으로 개체를 해제하기 위해 네이티브 코드에서 사용한 모든 규칙이 여전히 적용되지만 대부분의 경우 GC에서 모든 것을 처리합니다. 메모리 관리 100%에 대해 걱정하는 대신 약 5%의 시간만 걱정하면 됩니다.

CLR 가비지 수집기는 세대별 마크 앤 컴팩트 수집기입니다. 그것은 우수한 성능을 달성 할 수 있도록 몇 가지 원칙을 따릅니다. 첫째, 수명이 짧은 개체는 더 작고 자주 액세스된다는 개념이 있습니다. GC는 할당 그래프를 세대라고 하는 여러 하위 그래프로 나눕니다. 이를 통해 가능한 한 적은 시간을 수집할 수 있습니다*.* Gen 0에는 자주 사용되는 젊고 자주 사용되는 개체가 포함됩니다. 또한 가장 작은 경향이 있으며 수집하는 데 약 10밀리초가 걸립니다. GC는 이 컬렉션 중에 다른 세대를 무시할 수 있으므로 훨씬 더 높은 성능을 제공합니다. G1 및 G2는 더 크고 오래된 개체를 위한 것이며 덜 자주 수집됩니다. G1 컬렉션이 발생하면 G0도 수집됩니다. G2 컬렉션은 전체 컬렉션이며 GC가 전체 그래프를 트래버스하는 유일한 시간입니다. 또한 CPU 캐시를 지능적으로 사용하여 실행되는 특정 프로세서에 대한 메모리 하위 시스템을 튜닝할 수 있습니다. 이는 네이티브 할당에서 쉽게 사용할 수 없는 최적화이며 애플리케이션의 성능을 향상시키는 데 도움이 될 수 있습니다.

컬렉션은 언제 발생하나요?

시간 할당이 수행되면 GC는 컬렉션이 필요한지 확인합니다. 컬렉션의 크기, 남은 메모리 양 및 각 세대의 크기를 확인하고 추론을 사용하여 결정을 내립니다. 컬렉션이 발생할 때까지 개체 할당 속도는 일반적으로 C 또는 C++보다 빠르거나 빠릅니다.

컬렉션이 발생하면 어떻게 되나요?

수집 중에 가비지 수집기가 수행하는 단계를 살펴보겠습니다. GC는 GC 힙을 가리키는 루트 목록을 유지 관리합니다. 개체가 라이브 상태이면 힙의 위치에 루트가 있습니다. 힙의 개체는 서로를 가리킬 수도 있습니다. 이 포인터 그래프는 GC가 공간을 확보하기 위해 검색해야 하는 그래프입니다. 이벤트의 순서는 다음과 같습니다.

  1. 관리되는 힙은 모든 할당 공간을 연속 블록에 유지하고 이 블록이 요청된 양보다 작으면 GC가 호출됩니다.

  2. GC는 각 루트 및 뒤에 있는 모든 포인터를 따라 연결할 수 없는 개체 목록을 유지 관리합니다.

  3. 루트에서 연결할 수 없는 모든 개체는 수집 가능한 것으로 간주되며 컬렉션으로 표시됩니다.

    그림 1. 컬렉션 이전: 루트에서 모든 블록에 연결할 수 있는 것은 아닙니다.

  4. 연결성 그래프에서 개체를 제거하면 대부분의 개체를 수집할 수 있습니다. 그러나 일부 리소스는 특별히 처리해야 합니다. 개체를 정의할 때 Dispose() 메서드 또는 Finalize() 메서드(또는 둘 다)를 작성할 수 있습니다. 나는 둘 사이의 차이점에 대해 이야기 할 것이다, 나중에 그들을 사용하는 경우.

  5. 컬렉션의 마지막 단계는 압축 단계입니다. 사용 중인 모든 개체가 연속 블록으로 이동되고 모든 포인터와 루트가 업데이트됩니다.

  6. GC는 라이브 개체를 압축하고 사용 가능한 공간의 시작 주소를 업데이트하여 모든 사용 가능한 공간이 연속되도록 유지 관리합니다. 개체를 할당할 공간이 충분한 경우 GC는 프로그램에 컨트롤을 반환합니다. 그렇지 않으면 가 발생합니다 OutOfMemoryException.

    그림 2. 컬렉션 후: 연결할 수 있는 블록이 압축되었습니다. 더 많은 여유 공간!

메모리 관리에 대한 자세한 기술 정보는 Jeffrey Richter의 Microsoft Windows용 프로그래밍 애플리케이션 3장(Microsoft Press, 1999)을 참조하세요.

개체 정리

일부 개체는 리소스를 반환하기 전에 특별한 처리가 필요합니다. 이러한 리소스의 몇 가지 예는 파일, 네트워크 소켓 또는 데이터베이스 연결. 이러한 리소스를 정상적으로 닫기를 원하기 때문에 힙에서 메모리를 해제하는 것만으로는 충분하지 않습니다. 개체 정리를 수행하려면 Dispose() 메서드, Finalize() 메서드 또는 둘 다를 작성할 수 있습니다.

Finalize() 메서드:

  • GC에서 호출됩니다.
  • 어떤 순서로든 또는 예측 가능한 시간에 호출되도록 보장되지 않습니다.
  • 호출된 후 다음 GC 후 메모리를 해제합니다.
  • 다음 GC까지 모든 자식 개체를 라이브로 유지

Dispose() 메서드:

  • 프로그래머가 호출합니다.
  • 프로그래머가 주문하고 예약합니다.
  • 메서드가 완료되면 리소스를 반환합니다.

관리되는 리소스만 보유하는 관리되는 개체에는 이러한 메서드가 필요하지 않습니다. 프로그램은 몇 가지 복잡한 리소스만 사용할 수 있으며, 필요할 때 해당 리소스가 무엇인지 알고 있을 수 있습니다. 이러한 두 가지를 모두 알고 있다면 수동으로 정리를 수행할 수 있으므로 종료자에 의존할 이유가 없습니다. 이 작업을 수행하려는 몇 가지 이유가 있으며 모두 종료자 큐와 관련이 있습니다.

GC에서 종료자가 있는 개체가 수집 가능으로 표시되면 해당 개체와 이 개체가 가리키는 모든 개체가 특수 큐에 배치됩니다. 별도의 스레드가 큐에 있는 각 항목의 Finalize() 메서드를 호출하여 이 큐를 따라 이동합니다. 프로그래머가 이 스레드 또는 큐에 배치된 항목의 순서를 제어할 수 없습니다. GC는 큐에 있는 개체를 완료하지 않고도 프로그램에 컨트롤을 반환할 수 있습니다. 이러한 개체는 오랫동안 큐에 자리 잡고 메모리에 남아 있을 수 있습니다. 종료를 위한 호출은 자동으로 수행되며 호출 자체의 직접적인 성능 영향은 없습니다. 그러나 최종화에 대한 비결정적 모델은 분명히 다른 간접적인 결과를 초래할 수 있습니다.

  • 특정 시간에 릴리스해야 하는 리소스가 있는 시나리오에서는 종료자를 사용하여 제어할 수 없게 됩니다. 파일이 열려 있고 보안상의 이유로 파일을 닫아야 한다고 가정해 봅시다. 개체를 null로 설정하고 GC를 즉시 강제 적용하는 경우에도 Finalize() 메서드가 호출될 때까지 파일이 열린 상태로 유지되며 언제 이런 일이 발생할 수 있는지 알 수 없습니다.
  • 특정 순서로 삭제해야 하는 N 개체가 올바르게 처리되지 않을 수 있습니다.
  • 거대한 개체와 그 자식은 너무 많은 메모리를 차지하여 추가 컬렉션이 필요하고 성능이 저하될 수 있습니다. 이러한 개체는 오랫동안 수집되지 않을 수 있습니다.
  • 완료할 작은 개체에는 언제든지 해제할 수 있는 큰 리소스에 대한 포인터가 있을 수 있습니다. 이러한 개체는 완료할 개체가 처리될 때까지 해제되지 않으므로 불필요한 메모리 압력이 발생하며 컬렉션이 자주 발생합니다.

그림 3의 상태 다이어그램은 개체가 종료 또는 폐기 측면에서 수행할 수 있는 다양한 경로를 보여 줍니다.

그림 3. 개체가 사용할 수 있는 삭제 및 종료 경로

보듯이 종료는 개체의 수명에 여러 단계를 추가합니다. 개체를 직접 삭제하면 개체를 수집하고 다음 GC에서 메모리를 반환할 수 있습니다. 완료해야 하는 경우 실제 메서드가 호출될 때까지 기다려야 합니다. 이 경우 어떤 보장도 받지 않으므로 많은 메모리가 묶여 있고 종료 큐의 자비에 있을 수 있습니다. 이는 개체가 개체의 전체 트리에 연결되어 있고 모두 종료가 발생할 때까지 메모리에 있는 경우 매우 문제가 될 수 있습니다.

사용할 가비지 수집기 선택

CLR에는 워크스테이션(mscorwks.dll) 및 서버(mscorsvr.dll)의 두 가지 GC가 있습니다. 워크스테이션 모드에서 실행하는 경우 대기 시간은 공간이나 효율성보다 더 중요합니다. 여러 프로세서와 클라이언트가 네트워크를 통해 연결된 서버는 약간의 대기 시간을 감당할 수 있지만 처리량은 이제 최우선 순위입니다. Microsoft는 이러한 두 시나리오를 모두 단일 GC 체계로 구슬링하는 대신 각 상황에 맞게 조정된 두 개의 가비지 수집기를 포함시켰습니다.

서버 GC:

  • MP(다중 프로세서) 확장성, 병렬
  • CPU당 하나의 GC 스레드
  • 표시 중에 프로그램이 일시 중지됨

워크스테이션 GC:

  • 전체 컬렉션 중에 동시에 실행하여 일시 중지 최소화

서버 GC는 최대 처리량을 위해 설계되었으며 매우 높은 성능으로 확장됩니다. 서버의 메모리 조각화는 워크스테이션보다 훨씬 더 심각한 문제이므로 가비지 수집을 매력적인 제안으로 만듭니다. 유니프로세서 시나리오에서 두 수집기는 동시에 수집하지 않고 워크스테이션 모드와 동일한 방식으로 작동합니다. MP 컴퓨터에서 Workstation GC는 두 번째 프로세서를 사용하여 컬렉션을 동시에 실행하여 처리량을 줄이면서 지연을 최소화합니다. 서버 GC는 여러 힙 및 컬렉션 스레드를 사용하여 처리량을 최대화하고 크기를 더 잘 조정합니다.

런타임을 호스트할 때 사용할 GC를 선택할 수 있습니다. 프로세스에 런타임을 로드할 때 사용할 수집기를 지정합니다. API 로드는 .NET Framework 개발자 가이드에서 설명합니다. 런타임을 호스트하고 서버 GC를 선택하는 간단한 프로그램의 예를 보려면 부록을 살펴보세요.

신화: 가비지 수집은 손으로 하는 것보다 항상 느립니다.

실제로 컬렉션이 호출될 때까지 GC는 C에서 직접 수행하는 것보다 훨씬 빠릅니다. 이것은 많은 사람들을 놀라게, 그래서 몇 가지 설명 가치가있다. 우선, 사용 가능한 공간을 찾는 것은 일정한 시간에 발생합니다. 모든 여유 공간이 연속적이므로 GC는 포인터를 따라 가며 충분한 공간이 있는지 확인합니다. C에서 malloc() 를 호출하면 일반적으로 자유 블록의 연결된 목록을 검색합니다. 특히 힙이 심하게 조각화된 경우 시간이 오래 걸릴 수 있습니다. 설상가상으로 C 런타임의 여러 구현은 이 절차 중에 힙을 잠급니다. 메모리가 할당되거나 사용되면 목록을 업데이트해야 합니다. 가비지 수집 환경에서 할당은 해제되고 메모리는 수집 중에 해제됩니다. 고급 프로그래머는 큰 메모리 블록을 예약하고 해당 블록 자체 내에서 할당을 처리합니다. 이 방법의 문제는 메모리 조각화가 프로그래머에게 큰 문제가 되고 애플리케이션에 많은 메모리 처리 논리를 추가하도록 강제한다는 것입니다. 결국 가비지 수집기는 오버헤드를 많이 추가하지 않습니다. 할당 속도가 빠르거나 빠르며 압축이 자동으로 처리되므로 프로그래머가 애플리케이션에 집중할 수 있습니다.

나중에 가비지 수집기는 더 빠르게 다른 최적화를 수행할 수 있습니다. 핫스폿 식별 및 더 나은 캐시 사용이 가능하며 엄청난 속도 차이를 만들 수 있습니다. 더 스마트한 GC는 페이지를 보다 효율적으로 압축하여 실행 중에 발생하는 페이지 페치 수를 최소화할 수 있습니다. 이러한 모든 작업은 직접 작업을 수행하는 것보다 가비지 수집 환경을 더 빠르게 만들 수 있습니다.

어떤 사람들은 C 또는 C++와 같은 다른 환경에서 GC를 사용할 수 없는 이유를 궁금해할 수 있습니다. 대답은 형식입니다. 이러한 언어를 사용하면 모든 형식에 대한 포인터를 캐스팅할 수 있으므로 포인터가 무엇을 참조하는지 알기가 매우 어렵습니다. CLR과 같은 관리되는 환경에서는 GC를 가능하게 하기 위해 포인터에 대해 충분히 보장할 수 있습니다. 관리되는 세계는 GC를 수행하기 위해 스레드 실행을 안전하게 중지할 수 있는 유일한 위치이기도 합니다. C++에서는 이러한 작업이 안전하지 않거나 매우 제한적입니다.

속도 조정

관리되는 환경에서 프로그램에 대한 가장 큰 걱정은 메모리 보존입니다. 관리되지 않는 환경에서 찾을 수 있는 문제 중 일부는 관리되는 환경에서 문제가 되지 않습니다. 메모리 누수 및 현수 포인터는 여기서 별로 문제가 되지 않습니다. 대신 프로그래머는 리소스가 더 이상 필요하지 않을 때 연결된 상태로 두는 것에 주의해야 합니다.

성능에 대한 가장 중요한 추론은 네이티브 코드를 작성하는 데 사용되는 프로그래머에게 배울 수 있는 가장 쉬운 추론이기도 합니다. 할당을 추적하고 완료되면 해제합니다. GC는 유지되는 개체의 일부인 경우 빌드한 20KB 문자열을 사용하지 않을 것임을 알 수 없습니다. 이 개체가 어딘가에 벡터에 숨겨져 있고 해당 문자열을 다시 사용하지 않으려는 경우를 가정해 보겠습니다. 필드를 null로 설정하면 다른 용도로 개체가 필요한 경우에도 나중에 GC에서 해당 20KB를 수집할 수 있습니다. 개체가 더 이상 필요하지 않은 경우 개체에 대한 참조를 유지하지 않는지 확인합니다. (네이티브 코드와 마찬가지로) 더 작은 개체의 경우 문제가 적습니다. 네이티브 코드의 메모리 관리에 익숙한 프로그래머의 경우 여기에 문제가 없습니다. 모든 동일한 상식 규칙이 적용됩니다. 당신은 그들에 대해 너무 편집증 될 필요가 없습니다.

두 번째 중요한 성능 문제는 개체 정리를 다룹니다. 앞서 언급했듯이, 마무리는 성능에 큰 영향을 미칩니다. 가장 일반적인 예는 관리되지 않는 리소스에 대한 관리되는 처리기의 예입니다. 일종의 정리 메서드를 구현해야 하며 성능이 문제가 되는 곳입니다. 종료에 의존하는 경우 앞에서 나열한 성능 문제를 직접 확인할 수 있습니다. 유의해야 할 다른 점은 GC가 네이티브 세계의 메모리 압력을 크게 인식하지 못하기 때문에 관리되는 힙에 포인터를 유지하는 것만으로도 관리되지 않는 리소스의 톤을 사용할 수 있다는 것입니다. 단일 포인터는 많은 메모리를 차지하지 않으므로 컬렉션이 필요하기까지 시간이 걸릴 수 있습니다. 이러한 성능 문제를 해결하려면 메모리 보존과 관련하여 안전하게 재생하면서 특별한 정리가 필요한 모든 개체에 대해 작업할 디자인 패턴을 선택해야 합니다.

프로그래머에는 개체 정리를 처리할 때 다음 네 가지 옵션이 있습니다.

  1. 둘 다 구현

    개체 정리에 권장되는 디자인입니다. 관리되지 않는 리소스와 관리되는 리소스가 혼합된 개체입니다. 예를 들어 System.Windows.Forms.Control이 있습니다. 여기에는 관리되지 않는 리소스(HWND) 및 잠재적으로 관리되는 리소스(DataConnection 등)가 있습니다. 관리되지 않는 리소스를 사용하는 시기를 잘 모르는 경우 에서 프로그램의 ILDASM`` 매니페스트를 열고 네이티브 라이브러리에 대한 참조를 검사 수 있습니다. 또 다른 대안은 를 사용하여 vadump.exe 프로그램과 함께 로드되는 리소스를 확인하는 것입니다. 이 두 가지 모두 사용하는 네이티브 리소스의 종류에 대한 인사이트를 제공할 수 있습니다.

    아래 패턴은 정리 논리를 재정의하는 대신 사용자에게 권장되는 단일 방법을 제공합니다( Dispose(bool)를 재정의). 이렇게 하면 Dispose() 가 호출되지 않는 경우 catch-all뿐만 아니라 최대 유연성을 제공합니다. 최대 속도와 유연성뿐만 아니라 안전망 접근 방식의 조합은 이 설계를 사용하기에 가장 적합한 디자인입니다.

    예제:

    public class MyClass : IDisposable {
      public void Dispose() {
        Dispose(true);
        GC.SuppressFinalizer(this);
      }
      protected virtual void Dispose(bool disposing) {
        if (disposing) {
          ...
        }
          ...
      }
      ~MyClass() {
        Dispose(false);
      }
    }
    
  2. Dispose() 전용 구현

    개체에 관리되는 리소스만 있고 정리가 결정적인지 확인하려는 경우입니다. 이러한 개체의 예로 System.Web.UI.Control이 있습니다.

    예제:

    public class MyClass : IDisposable {
      public virtual void Dispose() {
        ...
      }
    
  3. Finalize() 전용 구현

    이것은 매우 드문 상황에서 필요하며, 나는 그것에 대해 강력하게 권장합니다. Finalize() 전용 개체의 의미는 프로그래머가 개체가 수집될 시기를 알지 못하지만 특별한 정리가 필요할 만큼 리소스 복합을 사용하고 있다는 것입니다. 이 상황은 잘 설계된 프로젝트에서 발생하지 않아야하며, 그 안에 자신을 발견하면 돌아가서 무엇이 잘못되었는지 알아내야합니다.

    예제:

    public class MyClass {
      ...
      ~MyClass() {
        ...
      }
    
  4. 둘 다 구현 안 함

    이는 삭제할 수 없고 완료할 수 없는 다른 관리되는 개체만 가리키는 관리되는 개체에 대한 것입니다.

권장

메모리 관리를 처리하기 위한 권장 사항은 잘 알고 있어야 합니다. 작업을 마쳤을 때 개체를 해제하고 개체에 대한 포인터를 남기지 않도록 주의해야 합니다. 개체 정리와 관련하여 관리되지 않는 리소스가 있는 개체에 대해 Finalize()Dispose() 메서드를 모두 구현합니다. 이렇게 하면 나중에 예기치 않은 동작을 방지하고 좋은 프로그래밍 방법을 적용합니다.

여기서 단점은 사람들이 Dispose()를 호출하도록 강요한다는 것입니다. 여기서는 성능 손실이 없지만 개체를 삭제하는 것에 대해 생각해야 하는 것이 실망스러울 수도 있습니다. 그러나 의미 있는 모델을 사용하는 것이 악화되는 가치가 있다고 생각합니다. 게다가, 이것은 사람들이 할당 하는 개체에 더 세심 하 게 강제, 그들은 맹목적으로 GC 항상 그들을 돌봐 신뢰할 수 없기 때문에. C 또는 C++ 배경에서 오는 프로그래머의 경우 Dispose() 에 대한 호출을 강제하는 것이 도움이 될 수 있습니다.

Dispose() 는 그 아래의 개체 트리에서 관리되지 않는 리소스를 유지하는 개체에서 지원되어야 합니다. 그러나 Finalize() 는 OS 핸들 또는 관리되지 않는 메모리 할당과 같이 이러한 리소스를 특히 유지하는 개체에만 배치해야 합니다. 부모 개체의 Dispose()에서 호출할 Dispose()를 지원하는 것 외에도 Finalize(,)를 구현하기 위한 "래퍼"로 작은 관리 개체를 만드는 것이 좋습니다. 부모 개체에는 종료자가 없으므로 개체의 전체 트리는 Dispose() 가 호출되었는지 여부에 관계없이 컬렉션에서 유지되지 않습니다.

종료자에 대한 엄지 손가락의 좋은 규칙은 종료가 필요한 가장 기본 개체에서만 사용하는 것입니다. 데이터베이스 연결을 포함하는 대규모 관리되는 리소스가 있다고 가정합니다. 연결 자체를 완료할 수 있도록 하지만 나머지 개체를 삭제할 수 있도록 합니다. 이렇게 하면 연결이 완료될 때까지 기다릴 필요 없이 Dispose() 를 호출하고 개체의 관리되는 부분을 즉시 해제할 수 있습니다. 기억하세요. 필요한 경우에만Finalize()를 사용합니다.

참고 C 및 C++ 프로그래머: C#의 소멸자 의미 체계는 폐기 방법이 아닌 종료자를 만듭니다.

스레드 풀

기본 사항

CLR의 스레드 풀은 여러 가지 면에서 NT 스레드 풀과 유사하며 프로그래머 부분에 대한 새로운 이해가 거의 필요하지 않습니다. 대기 스레드가 있습니다. 이 스레드는 다른 스레드에 대한 블록을 처리하고 반환해야 할 때 이를 알리고 다른 작업을 수행할 수 있도록 합니다. 새 스레드를 생성하고 다른 스레드를 차단하여 런타임에 CPU 사용률을 최적화하여 가장 많은 유용한 작업이 수행되도록 보장할 수 있습니다. 또한 스레드가 완료되면 스레드를 재활용하여 새 스레드를 죽이고 생성하는 오버헤드 없이 다시 시작합니다. 이는 스레드를 수동으로 처리하는 데 비해 상당한 성능 향상이지만 모든 것을 catch할 수는 없습니다. 스레드 풀을 사용해야 하는 시기를 아는 것은 스레드 애플리케이션을 튜닝할 때 필수적입니다.

NT 스레드 풀에서 알고 있는 내용:

  • 스레드 풀은 스레드 만들기 및 정리를 처리합니다.
  • I/O 스레드(NT 플랫폼에만 해당)에 대한 완성 포트를 제공합니다.
  • 콜백은 파일 또는 다른 시스템 리소스에 바인딩할 수 있습니다.
  • 타이머 및 대기 API를 사용할 수 있습니다.
  • 스레드 풀은 마지막 삽입 이후 지연, 현재 스레드 수 및 큐 크기와 같은 추론을 사용하여 활성화해야 하는 스레드 수를 결정합니다.
  • 공유 큐에서 스레드 피드.

.NET의 다른 기능:

  • 관리 코드(예: 가비지 수집, 관리 대기로 인해)에서 차단되는 스레드를 인식하고 그에 따라 스레드 삽입 논리를 조정할 수 있습니다.
  • 개별 스레드에 대한 서비스는 보장되지 않습니다.

스레드를 직접 처리해야 하는 경우

스레드 풀을 효과적으로 사용하는 것은 스레드에서 필요한 것을 아는 것과 밀접하게 연결됩니다. 서비스를 보장해야 하는 경우 직접 관리해야 합니다. 대부분의 경우 풀을 사용하면 최적의 성능을 제공합니다. 하드 제한이 있고 스레드를 엄격하게 제어해야 하는 경우 어쨌든 네이티브 스레드를 사용하는 것이 더 합리적이므로 관리되는 스레드를 직접 처리하는 것에 주의해야 합니다. 관리 코드를 작성하고 직접 스레딩을 처리하기로 결정한 경우 연결별로 스레드를 생성하지 않도록 합니다. 이렇게 하면 성능이 저하됩니다. 엄지 손가락의 규칙으로, 거의 수행되지 않는 크고 시간이 많이 걸리는 작업이있는 매우 구체적인 시나리오에서 관리되는 세계에서 스레드를 직접 처리하도록 선택해야합니다. 한 가지 예는 백그라운드에서 큰 캐시를 채우거나 디스크에 큰 파일을 작성하는 것입니다.

속도 조정

스레드 풀은 활성 상태여야 하는 스레드 수에 대한 제한을 설정하고, 많은 스레드가 차단되면 풀이 굶어 죽게 됩니다. 이상적으로는 단기, 비블로킹 스레드에 스레드 풀을 사용해야 합니다. 서버 애플리케이션에서는 각 요청에 빠르고 효율적으로 응답하려고 합니다. 모든 요청에 대해 새 스레드를 스핀업하는 경우 많은 오버헤드를 처리합니다. 해결 방법은 스레드를 재활용하고 완료 시 모든 스레드의 상태를 클린 반환하는 데 주의를 기울이는 것입니다. 이러한 시나리오는 스레드 풀이 주요 성능 및 디자인 승리이며 기술을 잘 활용해야 하는 시나리오입니다. 스레드 풀은 상태 정리를 처리하고 지정된 시간에 최적의 스레드 수를 사용하고 있는지 확인합니다. 다른 상황에서는 스레딩을 직접 처리하는 것이 더 합리적일 수 있습니다.

CLR은 형식 안전성을 사용하여 AppDomains가 동일한 프로세스를 공유할 수 있도록 프로세스를 보장할 수 있지만 스레드와 이러한 보장은 없습니다. 프로그래머가 잘 동작하는 스레드를 작성할 책임이 있으며 네이티브 코드의 모든 지식은 여전히 적용됩니다.

아래에는 스레드 풀을 활용하는 간단한 애플리케이션의 예가 있습니다. 여러 작업자 스레드를 만든 다음, 닫기 전에 간단한 작업을 수행하게 합니다. 일부 오류 검사를 수행했지만 "Samples\Threading\Threadpool" 아래의 Framework SDK 폴더에서 찾을 수 있는 것과 동일한 코드입니다. 이 예제에서는 간단한 작업 항목을 만들고 스레드 풀을 사용하여 프로그래머가 이러한 항목을 관리할 필요 없이 여러 스레드가 이러한 항목을 처리하도록 하는 몇 가지 코드가 있습니다. 자세한 내용은 ReadMe.html 파일을 확인하세요.

using System;
using System.Threading;

public class SomeState{
  public int Cookie;
  public SomeState(int iCookie){
    Cookie = iCookie;
  }
};


public class Alpha{
  public int [] HashCount;
  public ManualResetEvent eventX;
  public static int iCount = 0;
  public static int iMaxCount = 0;
  public Alpha(int MaxCount) {
    HashCount = new int[30];
    iMaxCount = MaxCount;
  }


   //   The method that will be called when the Work Item is serviced
   //   on the Thread Pool
   public void Beta(Object state){
     Console.WriteLine(" {0} {1} :", 
               Thread.CurrentThread.GetHashCode(), ((SomeState)state).Cookie);
     Interlocked.Increment(ref HashCount[Thread.CurrentThread.GetHashCode()]);

     //   Do some busy work
     int iX = 10000;
     while (iX > 0){ iX--;}
     if (Interlocked.Increment(ref iCount) == iMaxCount) {
       Console.WriteLine("Setting EventX ");
       eventX.Set();
     }
  }
};

public class SimplePool{
  public static int Main(String[] args)   {
    Console.WriteLine("Thread Simple Thread Pool Sample");
    int MaxCount = 1000;
    ManualResetEvent eventX = new ManualResetEvent(false);
    Console.WriteLine("Queuing {0} items to Thread Pool", MaxCount);
    Alpha oAlpha = new Alpha(MaxCount);
    oAlpha.eventX = eventX;
    Console.WriteLine("Queue to Thread Pool 0");
    ThreadPool.QueueUserWorkItem(new WaitCallback(oAlpha.Beta),new SomeState(0));
       for (int iItem=1;iItem < MaxCount;iItem++){
         Console.WriteLine("Queue to Thread Pool {0}", iItem);
         ThreadPool.QueueUserWorkItem(new WaitCallback(oAlpha.Beta),
                                   new SomeState(iItem));
       }
    Console.WriteLine("Waiting for Thread Pool to drain");
    eventX.WaitOne(Timeout.Infinite,true);
    Console.WriteLine("Thread Pool has been drained (Event fired)");
    Console.WriteLine("Load across threads");
    for(int iIndex=0;iIndex<oAlpha.HashCount.Length;iIndex++)
      Console.WriteLine("{0} {1}", iIndex, oAlpha.HashCount[iIndex]);
    }
    return 0;
  }
}

The JIT

기본 사항

모든 VM과 마찬가지로 CLR은 중간 언어를 네이티브 코드로 컴파일하는 방법이 필요합니다. CLR에서 실행할 프로그램을 컴파일할 때 컴파일러는 상위 수준의 언어에서 MSIL(Microsoft Intermediate Language) 및 메타데이터의 조합으로 원본을 가져옵니다. 이러한 파일은 PE 파일로 병합되며 CLR 지원 컴퓨터에서 실행할 수 있습니다. 이 실행 파일을 실행하면 JIT는 IL을 네이티브 코드로 컴파일하고 실제 컴퓨터에서 해당 코드를 실행하기 시작합니다. 이 작업은 메서드별로 수행되므로 JITing 지연은 실행하려는 코드에 필요한 경우에만 수행됩니다.

JIT는 매우 빠르며 매우 좋은 코드를 생성합니다. 수행되는 최적화 중 일부(및 각 항목에 대한 설명)는 아래에 설명되어 있습니다. 이러한 최적화의 대부분은 JIT가 너무 많은 시간을 소비하지 않도록 제한됩니다.

  • 상수 접기 - 컴파일 시간에 상수 값을 계산합니다.

    이전 After
    x = 5 + 7 x = 12
  • 상수 및 복사 전파 - 이전의 자유 변수로 대체합니다.

    이전 After
    x = a x = a
    y = x y = a
    z = 3 + y z = 3 + a
  • 메서드 인라인화 - 인수를 호출 시 전달된 값으로 바꾸고 호출을 제거합니다. 그런 다음, 다른 많은 최적화를 수행하여 데드 코드를 차단할 수 있습니다. 속도상의 이유로 현재 JIT에는 인라인으로 사용할 수 있는 것에 대한 여러 경계가 있습니다. 예를 들어 작은 메서드만 인라인 처리되고(IL 크기가 32보다 작음) 흐름 제어 분석은 상당히 기본적입니다.

    이전 After
    ...

    x=foo(4, true);

    ...

    }

    foo(int a, bool b){

    if(b){

    return a + 5;

    } else {

    return 2a + bar();

    }

    ...

    x = 9

    ...

    }

    foo(int a, bool b){

    if(b){

    return a + 5;

    } else {

    return 2a + bar();

    }

  • 코드 게양 및 지배자 - 외부에 중복된 경우 내부 루프에서 코드를 제거합니다. 아래의 '이전' 예제는 모든 배열 인덱스를 확인해야 하므로 실제로 IL 수준에서 생성되는 예제입니다.

    이전 After
    for(i=0; i< a.length;i++){

    if(i < a.length()){

    a[i] = null

    } else {

    raise IndexOutOfBounds;

    }

    }

    for(int i=0; i<a.length; i++){

    a[i] = null;

    }

  • 루프 언롤링 - 카운터를 증가시키고 테스트를 수행하는 오버헤드를 제거할 수 있으며 루프의 코드를 반복할 수 있습니다. 매우 타이트한 루프의 경우 성능이 향상됩니다.

    이전 After
    for(i=0; i< 3; i++){

    print("flaming monkeys!");

    }

    print("flaming monkeys!");

    print("flaming monkeys!");

    print("flaming monkeys!");

  • 일반적인 SubExpression 제거 - 라이브 변수에 다시 계산되는 정보가 여전히 포함된 경우 대신 사용합니다.

    이전 After
    x = 4 + y

    z = 4 + y

    x = 4 + y

    z = x

  • 등록 - 여기에 코드 예제를 제공하는 것은 유용하지 않으므로 설명으로 충분해야 합니다. 이 최적화는 함수에서 로컬 및 temps가 사용되는 방식을 살펴보고 가능한 한 효율적으로 등록 할당을 처리하려고 할 수 있습니다. 이는 매우 비용이 많이 드는 최적화일 수 있으며, 현재 CLR JIT는 등록을 위해 최대 64개의 지역 변수만 고려합니다. 고려되지 않는 변수는 스택 프레임에 배치됩니다. 이는 JITing의 제한 사항의 전형적인 예입니다. 이는 시간의 99%에 해당하지만, 100개 이상의 로컬이 있는 매우 특이한 함수는 기존의 시간이 많이 걸리는 사전 컴파일을 사용하여 더 잘 최적화됩니다.

  • Misc - 다른 간단한 최적화가 수행되지만 위의 목록은 좋은 샘플입니다. 또한 JIT는 데드 코드 및 기타 엿보기 최적화를 위해 통과합니다.

코드는 언제 JITed되나요?

코드가 실행될 때 통과하는 경로는 다음과 같습니다.

  1. 프로그램이 로드되고 IL을 참조하는 포인터를 사용하여 함수 테이블이 초기화됩니다.
  2. Main 메서드는 네이티브 코드로 JITed된 후 실행됩니다. 함수에 대한 호출은 테이블을 통해 간접 함수 호출로 컴파일됩니다.
  3. 다른 메서드가 호출되면 런타임은 테이블을 확인하여 JITed 코드를 가리키는지 확인합니다.
    1. 다른 호출 사이트에서 호출되었거나 미리 컴파일된 경우 제어 흐름이 계속됩니다.
    2. 그렇지 않으면 메서드가 JITed이고 테이블이 업데이트됩니다.
  4. 호출될수록 점점 더 많은 메서드가 네이티브 코드로 컴파일되고 테이블의 더 많은 항목이 증가하는 x86 명령 풀을 가리킵니다.
  5. 프로그램이 실행될 때 JIT는 모든 항목이 컴파일될 때까지 덜 자주 호출됩니다.
  6. 메서드는 호출될 때까지 JITed되지 않으며 프로그램을 실행하는 동안 다시 JITed되지 않습니다. 사용한 항목에 대해서만 요금을 지불합니다.

오해: JITED 프로그램은 미리 컴파일된 프로그램보다 느리게 실행됩니다.

이는 매우 드문 경우입니다. JITing과 관련된 몇 가지 메서드의 오버헤드는 디스크에서 몇 페이지를 읽는 데 소요된 시간에 비해 미미하며, 필요한 경우에만 JITed가 적용됩니다. JIT에서 소요된 시간은 너무 미미하여 거의 눈에 띄지 않으며 메서드가 JITed되면 해당 메서드에 대한 비용이 다시 발생하지 않습니다. 이에 대한 자세한 내용은 코드 미리 컴파일 섹션에서 설명합니다.

위에서 설명한 대로 version1(v1) JIT는 컴파일러가 수행하는 대부분의 최적화를 수행하며 고급 최적화가 추가됨에 따라 다음 버전(vNext)에서만 더 빠르게 수행됩니다. 더 중요한 것은 JIT는 일반 컴파일러에서 수행할 수 없는 몇 가지 최적화(예: CPU별 최적화 및 캐시 튜닝)를 수행할 수 있다는 것입니다.

JIT-Only 최적화

JIT는 런타임에 활성화되므로 컴파일러가 인식하지 못하는 많은 정보가 있습니다. 이렇게 하면 런타임에만 사용할 수 있는 몇 가지 최적화를 수행할 수 있습니다.

  • 프로세서별 최적화 — 런타임에 JIT는 SSE 또는 3DNow 명령을 사용할 수 있는지 여부를 알고 있습니다. 실행 파일은 P4, Athlon 또는 향후 프로세서 제품군용으로 특별히 컴파일됩니다. 한 번 배포하면 JIT 및 사용자 컴퓨터와 함께 동일한 코드가 향상됩니다.
  • 함수 및 개체 위치를 런타임에 사용할 수 있으므로 간접 참조 수준을 최적화합니다.
  • JIT는 어셈블리 간에 최적화를 수행할 수 있으므로 정적 라이브러리를 사용하여 프로그램을 컴파일할 때 얻을 수 있는 많은 이점을 제공하지만 동적 라이브러리를 사용하는 유연성과 작은 공간을 유지합니다.
  • 런타임 동안 제어 흐름을 인식하므로 더 자주 호출되는 공격적인 인라인 함수입니다. 최적화는 상당한 속도 향상을 제공할 수 있으며 vNext에서 추가적인 개선을 위한 많은 공간이 있습니다.

이러한 런타임 개선은 작은 일회성 시작 비용을 희생하며 JIT에 소요된 시간을 상쇄할 수 있습니다.

코드 미리 컴파일(ngen.exe 사용)

애플리케이션 공급업체의 경우 설치 중에 코드를 미리 컴파일하는 기능이 매력적인 옵션입니다. Microsoft는 전체 프로그램에서 일반 JIT 컴파일러를 한 번 실행하고 결과를 저장할 수 있는 형식 ngen.exe으로 이 옵션을 제공합니다. 사전 컴파일 중에는 런타임 전용 최적화를 수행할 수 없으므로 생성된 코드는 일반적으로 일반 JIT에서 생성된 코드만큼 좋지 않습니다. 그러나 JIT 메서드를 즉시 사용할 필요 없이 시작 비용이 훨씬 낮아지고 일부 프로그램이 눈에 띄게 빠르게 시작됩니다. 앞으로 ngen.exe 단순히 동일한 런타임 JIT를 실행하는 것보다 더 많은 작업을 수행할 수 있습니다. 즉, 런타임보다 더 높은 범위의 더 적극적인 최적화, 개발자에게 부하 순서 최적화 노출(코드가 VM 페이지로 압축되는 방식 최적화) 및 미리 컴파일하는 동안 시간을 활용할 수 있는 더 복잡하고 시간이 많이 소요되는 최적화입니다.

시작 시간을 줄이면 두 가지 경우에 도움이 되며, 다른 모든 경우에는 일반 JITing에서 수행할 수 있는 런타임 전용 최적화와 경쟁하지 않습니다. 첫 번째 상황은 프로그램 초기에 엄청난 수의 메서드를 호출하는 것입니다. 많은 메서드를 미리 JIT해야 하므로 허용할 수 없는 로드 시간이 발생합니다. 대부분의 사람들에게는 그렇지 않지만 JITing 이전이 사용자에게 영향을 미치는 경우 의미가 있을 수 있습니다. 이러한 라이브러리를 훨씬 더 자주 로드하는 비용을 지불하므로 대규모 공유 라이브러리의 경우에도 미리 컴파일하는 것이 좋습니다. 대부분의 애플리케이션에서 CLR을 사용하기 때문에 Microsoft는 CLR에 대한 프레임워크를 미리 컴파일합니다.

ngen.exe 사용하여 미리 컴파일하는 것이 답인지 쉽게 확인할 수 있으므로 사용해 보는 것이 좋습니다. 그러나 대부분의 경우 일반적인 JIT를 사용하고 런타임 최적화를 활용하는 것이 좋습니다. 그들은 엄청난 보수를 가지고 있으며, 대부분의 상황에서 일회성 시작 비용을 상쇄하는 것보다 더 많은 것입니다.

속도 조정

프로그래머에게는 주목할 만한 두 가지가 있습니다. 첫째, JIT는 매우 지능적입니다. 컴파일러를 능가하지 마세요. 일반적인 방식으로 코딩합니다. 예를 들어 다음과 같은 코드가 있다고 가정하겠습니다.

...

for(int i = 0; i < myArray.length; i++){

...

}

...

...

int l = myArray.length;

for(int i = 0; i < l; i++){

...

}

...

일부 프로그래머는 오른쪽 예제와 같이 길이 계산을 이동하고 임시로 저장하여 속도 향상을 얻을 수 있다고 믿습니다.

사실 이와 같은 최적화는 거의 10년 동안 도움이 되지 않았습니다. 최신 컴파일러가 이 최적화를 수행할 수 있는 능력 이상입니다. 사실, 때로는 이런 것들이 실제로 성능을 해칠 수 있습니다. 위의 예제에서 컴파일러는 myArray의 길이가 상수인지 확인하고 for 루프의 비교에 상수를 삽입하는 검사. 그러나 오른쪽의 코드는 가 루프 전체에서 라이브이므로 이 값을 레지스터 l 에 저장해야 한다고 생각하게 컴파일러를 속일 수 있습니다. 결론은 가장 읽을 수 있고 가장 적합한 코드를 작성하는 것입니다. 컴파일러를 능가하는 데 도움이 되지 않으며 경우에 따라 손상될 수 있습니다.

두 번째로 이야기해야 할 것은 비상 전화입니다. 현재 C# 및 Microsoft® Visual Basic® 컴파일러에서는 비상 호출을 사용하도록 지정하는 기능을 제공하지 않습니다. 이 기능이 실제로 필요한 경우 한 가지 옵션은 디스어셈블러에서 PE 파일을 열고 MSIL .tail 명령을 대신 사용하는 것입니다. 이는 우아한 솔루션은 아니지만 테일 호출은 스키마 또는 ML과 같은 언어에서처럼 C# 및 Visual Basic에서 유용하지 않습니다. tail-calls를 실제로 활용하는 언어에 대한 컴파일러를 작성하는 사람 이 명령을 사용해야 합니다. 대부분의 사람들에게 현실은 꼬리 호출을 사용하도록 IL을 수동으로 조정하더라도 엄청난 속도 혜택을 제공하지 않는다는 것입니다. 경우에 따라 보안상의 이유로 런타임이 실제로 이러한 호출을 일반 호출로 다시 변경합니다. 아마도 향후 버전에서는 비상 호출을 지원하기 위해 더 많은 노력이 투입될 것이지만, 현재 성능 향상은 이를 보증하기에 충분하지 않으며 프로그래머가 이를 활용하고자 하는 사람은 거의 없습니다.

AppDomain

기본 사항

프로세스 간 통신은 점점 더 일반화되고 있습니다. 안정성 및 보안상의 이유로 OS는 애플리케이션을 별도의 주소 공간에 유지합니다. 간단한 예는 모든 16비트 애플리케이션이 NT에서 실행되는 방식입니다. 별도의 프로세스에서 실행되는 경우 한 애플리케이션이 다른 애플리케이션의 실행을 방해할 수 없습니다. 여기서 문제는 컨텍스트 전환의 비용과 프로세스 간의 연결을 여는 것입니다. 이 작업은 비용이 많이 들고 성능이 많이 저하됩니다. 종종 여러 웹 애플리케이션을 호스트하는 서버 애플리케이션에서 이는 성능과 확장성 모두에서 큰 문제입니다.

CLR은 애플리케이션에 대한 자체 포함 공간이라는 측면에서 프로세스와 유사한 AppDomain의 개념을 소개합니다. 그러나 AppDomains는 프로세스당 하나로 제한되지 않습니다. 관리 코드에서 제공하는 형식 안전성 덕분에 동일한 프로세스에서 완전히 관련이 없는 두 개의 AppDomain을 실행할 수 있습니다. 여기서 성능 향상은 일반적으로 프로세스 간 통신 오버헤드에서 많은 실행 시간을 소비하는 상황에서 엄청납니다. 어셈블리 간 IPC는 NT의 프로세스보다 5배 더 빠릅니다. 이 비용을 크게 줄이면 프로그램 디자인 중에 속도 향상과 새로운 옵션을 모두 얻을 수 있습니다. 이제는 너무 비싸기 전에 별도의 프로세스를 사용하는 것이 좋습니다. 이전과 동일한 보안으로 동일한 프로세스에서 여러 프로그램을 실행하는 기능은 확장성 및 보안에 엄청난 영향을 미칩니다.

AppDomains에 대한 지원은 OS에 없습니다. AppDomains는 CLR 호스트(예: ASP.NET, 셸 실행 파일 또는 Microsoft 인터넷 Explorer)에서 처리됩니다. 직접 작성할 수도 있습니다. 각 호스트는 애플리케이션이 처음 시작될 때 로드되고 프로세스가 종료될 때만 닫혀 있는 기본 도메인을 지정합니다. 프로세스에 다른 어셈블리를 로드할 때 특정 AppDomain에 로드되도록 지정하고 각 어셈블리에 대해 서로 다른 보안 정책을 설정할 수 있습니다. 이 내용은 Microsoft .NET Framework SDK 설명서에 자세히 설명되어 있습니다.

속도 조정

AppDomains를 효과적으로 사용하려면 작성 중인 애플리케이션의 종류와 수행해야 하는 작업 종류에 대해 생각해야 합니다. AppDomains는 애플리케이션이 다음 특성 중 일부에 맞는 경우에 가장 효과적입니다.

  • 새 복사본을 자주 생성합니다.
  • 다른 애플리케이션과 함께 작동하여 정보를 처리합니다(예: 웹 서버 내의 데이터베이스 쿼리).
  • 애플리케이션에서만 작동하는 프로그램을 사용하여 IPC에서 많은 시간을 보냅니다.
  • 다른 프로그램이 열리고 닫힙니다.

AppDomains가 유용한 상황의 예는 복잡한 ASP.NET 애플리케이션에서 볼 수 있습니다. 서로 다른 vRoot 간에 격리를 적용하려는 경우: 네이티브 공간에서 각 vRoot를 별도의 프로세스에 배치해야 합니다. 이는 상당히 비용이 많이 들며, 컨텍스트 간 전환은 많은 오버헤드입니다. 관리되는 환경에서 각 vRoot는 별도의 AppDomain일 수 있습니다. 이렇게 하면 오버헤드를 크게 줄이면서 필요한 격리가 유지됩니다.

AppDomains는 애플리케이션이 다른 프로세스 또는 그 자체의 다른 인스턴스와 긴밀하게 작업해야 할 정도로 복잡한 경우에만 사용해야 합니다. iter-AppDomain 통신은 프로세스 간 통신보다 훨씬 빠르지만 AppDomain을 시작하고 닫는 데 드는 비용은 실제로 더 비쌀 수 있습니다. AppDomains는 잘못된 이유로 사용될 때 성능이 저하될 수 있으므로 올바른 상황에서 사용하고 있는지 확인합니다. 관리되지 않는 코드는 보안을 보장할 수 없으므로 관리 코드만 AppDomain에 로드할 수 있습니다.

도메인 간의 격리를 유지하기 위해 여러 AppDomains 간에 공유되는 어셈블리는 모든 도메인에 대해 JITed여야 합니다. 이로 인해 코드가 많이 생성되고 메모리가 낭비됩니다. 일종의 XML 서비스로 요청에 응답하는 애플리케이션의 경우를 고려합니다. 특정 요청을 서로 격리해야 하는 경우 다른 AppDomains로 라우팅해야 합니다. 여기서 문제는 모든 AppDomain에 동일한 XML 라이브러리가 필요하고 동일한 어셈블리가 여러 번 로드된다는 것입니다.

이에 대한 한 가지 방법은 어셈블리를 도메인 중립으로 선언하는 것입니다. 즉, 직접 참조가 허용되지 않으며 간접 참조를 통해 격리가 적용됩니다. 어셈블리가 JITed이므로 시간이 한 번만 절약됩니다. 또한 중복된 항목이 없으므로 메모리를 저장합니다. 아쉽게도 간접 참조가 필요하므로 성능이 저하됩니다. 어셈블리를 도메인 중립으로 선언하면 메모리가 문제가 되거나 너무 많은 시간이 낭비되는 경우 JITing 코드에서 성능이 향상됩니다. 이와 같은 시나리오는 여러 도메인에서 공유하는 대규모 어셈블리의 경우 일반적입니다.

보안

기본 사항

코드 액세스 보안은 강력하고 매우 유용한 기능입니다. 사용자에게 반 신뢰할 수 있는 코드를 안전하게 실행하고, 악성 소프트웨어 및 여러 종류의 공격으로부터 보호하며, 리소스에 대한 제어된 ID 기반 액세스를 허용합니다. 네이티브 코드에서는 형식 안전성이 거의 없고 프로그래머가 메모리를 처리하므로 보안을 제공하기가 매우 어렵습니다. CLR에서 런타임은 대부분의 프로그래머에게 새로운 기능인 강력한 보안 지원을 추가하기 위해 코드를 실행하는 것에 대해 충분히 알고 있습니다.

보안은 애플리케이션의 속도와 작업 집합 크기에 모두 영향을 줍니다. 그리고 대부분의 프로그래밍 영역과 마찬가지로 개발자가 보안을 사용하는 방식은 성능에 미치는 영향을 크게 결정할 수 있습니다. 보안 시스템은 성능을 염두에 두고 설계되었으며 대부분의 경우 애플리케이션 개발자가 거의 또는 전혀 생각하지 않고 잘 작동해야 합니다. 그러나 보안 시스템에서 성능의 마지막 비트를 짜내기 위해 할 수 있는 몇 가지 작업이 있습니다.

속도 조정

보안 검사 수행하려면 일반적으로 현재 메서드를 호출하는 코드에 올바른 권한이 있는지 확인하기 위해 스택 워크가 필요합니다. 런타임에는 전체 스택을 걷는 것을 방지하는 데 도움이 되는 몇 가지 최적화가 있지만 프로그래머가 도울 수 있는 몇 가지 작업이 있습니다. 이렇게 하면 명령적 보안과 선언적 보안이라는 개념이 표시됩니다. 선언적 보안은 형식 또는 해당 멤버를 다양한 사용 권한으로 표시하지만 명령적 보안은 보안 개체를 만들고 이에 대한 작업을 수행합니다.

  • 선언적 보안은 Assert, DenyPermitOnly로 이동하는 가장 빠른 방법입니다. 이러한 작업은 일반적으로 올바른 호출 프레임을 찾기 위해 스택 워크가 필요하지만 이러한 한정자를 명시적으로 선언하는 경우 이를 방지할 수 있습니다. 요구는 명령적으로 수행되는 경우 더 빠릅니다.
  • 비관리 코드와 interop을 수행하는 경우 SuppressUnmanagedCodeSecurity 특성을 사용하여 런타임 보안 검사를 제거할 수 있습니다. 이렇게 하면 검사 훨씬 빠른 연결 시간으로 이동합니다. 주의해야 할 점은 코드가 제거된 검사 안전하지 않은 코드로 악용할 수 있는 다른 코드에 보안 허점을 노출하지 않는지 확인합니다.
  • ID 검사는 코드 검사보다 비용이 많이 듭니다. 대신 LinkDemand를 사용하여 링크 시간에 이러한 검사를 수행할 수 있습니다.

보안을 최적화하는 방법에는 두 가지가 있습니다.

  • 런타임 대신 링크 타임에 검사를 수행합니다.
  • 보안 검사를 명령적이 아닌 선언적으로 만듭니다.

가장 먼저 집중해야 할 것은 이러한 검사를 최대한 많이 이동하여 시간을 연결하는 것입니다. 이는 애플리케이션의 보안에 영향을 미칠 수 있으므로 런타임 상태에 의존하는 링커로 검사를 이동하지 않도록 합니다. 연결 시간으로 최대한 이동한 후에는 선언적 또는 명령적 보안을 사용하여 런타임 검사를 최적화해야 합니다. 사용하는 특정 종류의 검사 가장 적합한 항목을 선택해야 합니다.

원격 통신

기본 사항

.NET의 원격 기술은 네트워크를 통해 풍부한 형식 시스템과 CLR의 기능을 확장합니다. XML, SOAP 및 HTTP를 사용하여 프로시저를 호출하고 동일한 컴퓨터에서 호스트된 것처럼 개체를 원격으로 전달할 수 있습니다. 이를 DCOM 또는 CORBA의 .NET 버전으로 생각할 수 있습니다. 이 버전은 해당 기능의 상위 집합을 제공한다는 것입니다.

이는 서로 다른 서비스를 호스트하는 여러 서버가 있는 서버 환경에서 특히 유용하며, 이러한 서비스를 원활하게 연결하기 위해 서로 통신합니다. 기능이 손실되지 않고 여러 컴퓨터에서 프로세스를 물리적으로 분할할 수 있으므로 확장성도 향상되었습니다.

속도 조정

원격은 종종 네트워크 대기 시간 측면에서 페널티를 발생시키기 때문에 항상 있는 CLR에 동일한 규칙이 적용됩니다. 전송하는 트래픽의 양을 최소화하고 나머지 프로그램이 원격 호출이 반환될 때까지 기다리지 않도록 합니다. 다음은 원격을 사용하여 성능을 최대화할 때 사용할 수 있는 몇 가지 좋은 규칙입니다.

  • 번잡한 통화 대신 청키 만들기 - 원격으로 수행해야 하는 호출 수를 줄일 수 있는지 확인합니다. 예를 들어 get() 및 set() 메서드를 사용하여 원격 개체에 대한 일부 속성을 설정 한다고 가정합니다. 개체를 만들 때 설정된 속성을 사용하여 개체를 원격으로 다시 만들 수 있는 시간을 절약할 수 있습니다. 단일 원격 호출을 사용하여 이 작업을 수행할 수 있으므로 네트워크 트래픽에서 낭비되는 시간을 절약할 수 있습니다. 경우에 따라 개체를 로컬 컴퓨터로 이동하고 속성을 설정한 다음 다시 복사하는 것이 좋습니다. 대역폭 및 대기 시간에 따라 한 솔루션이 다른 솔루션보다 더 적합한 경우가 있습니다.
  • 네트워크 부하와 CPU 부하의 균형 조정 - 네트워크를 통해 수행할 작업을 보내는 것이 합리적일 수도 있고, 작업을 직접 수행하는 것이 더 좋은 경우도 있습니다. 네트워크를 통과하는 데 많은 시간을 낭비하는 경우 성능이 저하됩니다. CPU를 너무 많이 사용하는 경우 다른 요청에 응답할 수 없습니다. 애플리케이션의 크기를 조정하려면 이 두 가지 간에 적절한 균형을 찾는 것이 중요합니다.
  • 비동기 호출 사용 - 네트워크를 통해 전화를 걸 때 실제로 필요하지 않은 한 비동기 호출인지 확인합니다. 그렇지 않으면 애플리케이션이 응답을 받을 때까지 중단되고 사용자 인터페이스 또는 대용량 서버에서 허용되지 않을 수 있습니다. 살펴보는 좋은 예는 .NET과 함께 제공되는 Framework SDK의 "Samples\technologies\remoting\advanced\asyncdelegate"에서 확인할 수 있습니다.
  • 개체 최적 사용 - 모든 요청에 대해 새 개체를 만들거나(SingleCall) 모든 요청에 동일한 개체가 사용되도록 지정할 수 있습니다(Singleton). 모든 요청에 대해 단일 개체를 갖는 것은 리소스 집약적이지 않지만 요청에서 요청까지 개체의 동기화 및 구성에 주의해야 합니다.
  • 플러그형 채널 및 포맷터를 사용합니다. 원격의 강력한 기능은 모든 채널 또는 포맷터를 애플리케이션에 연결하는 기능입니다. 예를 들어 방화벽을 통과해야 하는 경우가 아니면 HTTP 채널을 사용할 이유가 없습니다. TCP 채널을 연결하면 성능이 훨씬 향상됩니다. 가장 적합한 채널 또는 포맷터를 선택해야 합니다.

ValueTypes

기본 사항

개체에서 제공하는 유연성은 작은 성능 가격으로 제공됩니다. 힙 관리형 개체는 스택 관리 개체보다 할당, 액세스 및 업데이트하는 데 더 많은 시간이 걸립니다. 예를 들어 C++의 구조체가 개체보다 훨씬 효율적인 이유입니다. 물론 개체는 구조체가 할 수 없는 작업을 수행할 수 있으며 훨씬 더 다양합니다.

그러나 때로는 모든 유연성이 필요하지 않습니다. 구조체처럼 간단한 것을 하고 성능 비용을 지불하지 않으려는 경우가 있습니다. CLR은 ValueType이라고 하는 항목을 지정하는 기능을 제공하며 컴파일 시 구조체처럼 처리됩니다. ValueType은 스택에서 관리되며 구조체의 모든 속도를 제공합니다. 예상대로 구조체의 유연성이 제한적입니다(예: 상속이 없음). 하지만 구조체만 필요한 경우 ValueTypes는 놀라운 속도 향상을 제공합니다. ValueTypes 및 CLR 형식 시스템의 나머지 부분에 대한 자세한 내용은 MSDN 라이브러리에서 확인할 수 있습니다.

속도 조정

ValueType은 구조체로 사용하는 경우에만 유용합니다. ValueType을 개체로 처리해야 하는 경우 런타임에서 개체의 boxing 및 unboxing을 처리합니다. 그러나, 이것은 처음에 개체로 만드는 것보다 훨씬 더 비싸다!

다음은 많은 수의 개체 및 ValueType을 만드는 데 걸리는 시간을 비교하는 간단한 테스트의 예입니다.

using System;
using System.Collections;

namespace ConsoleApplication{
  public struct foo{
    public foo(double arg){ this.y = arg; }
    public double y;
  }
  public class bar{
    public bar(double arg){ this.y = arg; }
    public double y;
  }
class Class1{
  static void Main(string[] args){
    Console.WriteLine("starting struct loop....");
    int t1 = Environment.TickCount;
    for (int i = 0; i < 25000000; i++) {
      foo test1 = new foo(3.14);
      foo test2 = new foo(3.15);
       if (test1.y == test2.y) break; // prevent code from being 
       eliminated JIT
    }
    int t2 = Environment.TickCount;
    Console.WriteLine("struct loop: (" + (t2-t1) + "). starting object 
       loop....");
    t1 = Environment.TickCount;
    for (int i = 0; i < 25000000; i++) {
      bar test1 = new bar(3.14);
      bar test2 = new bar(3.15);
      if (test1.y == test2.y) break; // prevent code from being 
      eliminated JIT
    }
    t2 = Environment.TickCount;
    Console.WriteLine("object loop: (" + (t2-t1) + ")");
    }

직접 시도해 보세요. 시간 간격은 몇 초 단위입니다. 이제 런타임이 구조체를 상자로 지정하고 압축을 풀도록 프로그램을 수정해 보겠습니다. ValueType 사용의 속도 이점은 완전히 사라졌습니다! 여기서 도덕은 ValueTypes를 개체로 사용하지 않는 매우 드문 상황에서만 사용된다는 것입니다. 성능 승리가 바로 사용할 때 매우 크기 때문에 이러한 상황을 주의하는 것이 중요합니다.

using System;
using System.Collections;

namespace ConsoleApplication{
  public struct foo{
    public foo(double arg){ this.y = arg; }
    public double y;
  }
  public class bar{
    public bar(double arg){ this.y = arg; }
    public double y;
  }
  class Class1{
    static void Main(string[] args){
      Hashtable boxed_table = new Hashtable(2);
      Hashtable object_table = new Hashtable(2);
      System.Console.WriteLine("starting struct loop...");
      for(int i = 0; i < 10000000; i++){
        boxed_table.Add(1, new foo(3.14)); 
        boxed_table.Add(2, new foo(3.15));
        boxed_table.Remove(1);
      }
      System.Console.WriteLine("struct loop complete. 
                                starting object loop...");
      for(int i = 0; i < 10000000; i++){
        object_table.Add(1, new bar(3.14)); 
        object_table.Add(2, new bar(3.15));
        object_table.Remove(1);
      }
      System.Console.WriteLine("All done");
    }
  }
}

Microsoft는 ValueTypes를 큰 방식으로 사용합니다. 프레임워크의 모든 기본 형식은 ValueTypes입니다. 구조체에 가려움증을 느낄 때마다 ValueTypes를 사용하는 것이 좋습니다. 당신이 상자 / unbox하지 않는 한, 그들은 엄청난 속도 향상을 제공 할 수 있습니다.

한 가지 매우 중요한 점은 ValueTypes에 interop 시나리오에서 마샬링이 필요하지 않는다는 것입니다. 마샬링이 네이티브 코드와 상호 운용할 때 가장 큰 성능 적중 중 하나이기 때문에 ValueTypes를 네이티브 함수에 대한 인수로 사용하는 것이 아마도 가장 큰 성능 조정일 것입니다.

추가 리소스

.NET Framework 성능과 관련된 topics 다음과 같습니다.

디자인, 아키텍처 및 코딩 철학 개요, 관리되는 세계의 성능 분석 도구 연습, .NET과 현재 사용 가능한 다른 엔터프라이즈 애플리케이션의 성능 비교를 포함하여 현재 개발 중인 향후 문서를 확인하세요.

부록: 서버 런타임 호스팅

#include "mscoree.h"
#include "stdio.h"
#import "mscorlib.tlb" named_guids no_namespace raw_interfaces_only \
no_implementation exclude("IID_IObjectHandle", "IObjectHandle")

long main(){
  long retval = 0;
  LPWSTR pszFlavor = L"svr";

  // Bind to the Run time.
  ICorRuntimeHost *pHost = NULL;
  HRESULT hr = CorBindToRuntimeEx(NULL,
               pszFlavor, 
               NULL,
               CLSID_CorRuntimeHost, 
               IID_ICorRuntimeHost, 
               (void **)&pHost);

  if (SUCCEEDED(hr)){
    printf("Got ICorRuntimeHost\n");
      
    // Start the Run time (this also creates a default AppDomain)
    hr = pHost->Start();
    if(SUCCEEDED(hr)){
      printf("Started\n");
         
      // Get the Default AppDomain created when we called Start
      IUnknown *pUnk = NULL;
      hr = pHost->GetDefaultDomain(&pUnk);

      if(SUCCEEDED(hr)){
        printf("Got IUnknown\n");
            
        // Ask for the _AppDomain Interface
        _AppDomain *pDomain = NULL;
        hr = pUnk->QueryInterface(IID__AppDomain, (void**)&pDomain);
            
        if(SUCCEEDED(hr)){
          printf("Got _AppDomain\n");
               
          // Execute Assembly's entry point on this thread
          BSTR pszAssemblyName = SysAllocString(L"Managed.exe");
          hr = pDomain->ExecuteAssembly_2(pszAssemblyName, &retval);
          SysFreeString(pszAssemblyName);
               
          if (SUCCEEDED(hr)){
            printf("Execution completed\n");

            //Execution completed Successfully
            pDomain->Release();
            pUnk->Release();
            pHost->Stop();
            
            return retval;
          }
        }
        pDomain->Release();
        pUnk->Release();
      }
    }
    pHost->Release();
  }
  printf("Failure, HRESULT: %x\n", hr);
   
  // If we got here, there was an error, return the HRESULT
  return hr;
}

이 문서에 대한 질문이나 의견이 있는 경우 .NET Framework 성능 문제에 대한 프로그램 관리자인 클라우디오 칼다토(Claudio Caldato)에게 문의하세요.