x86 - AspCompat과 STA COM 개체가 성능에 미치는 영향
테스트 환경은 다음과 같습니다.
Native 시대의 COM 개체를 잘 모르더라도 ASP.NET에서 기존 COM 개체를 사용하려면 Apartment 유형에 따라 AspCompat 옵션 사용 유무를 판단해야 한다는 것은 알아야 합니다. ^^
ASP.NET 스레드 풀 내에 존재하는 모든 스레드는 기본적으로 (MTA는 아니고) MTA 성격이기 때문에 STA COM 개체를 생성하려면 특별한 마크를 해주어야 합니다. 그것이 AspCompat인데요. 이 때문에 STA COM을 사용하는 전형적인 페이지는 다음과 같이 됩니다.
// ==== default.aspx ====
<%@ Page AspCompat="true" Title="Home Page" Language="C#" MasterPageFile="~/Site.master" AutoEventWireup="true"
CodeBehind="Default.aspx.cs" Inherits="WebApplication1._Default" %>
... [HTML 생략]...
// ==== default.aspx.cs ====
using System;
using System.Web;
using System.Web.UI;
using System.Web.UI.WebControls;
namespace WebApplication1
{
public partial class _Default : System.Web.UI.Page
{
protected void Page_Load(object sender, EventArgs e)
{
WebTestHelperLib.ComAptTestClass catc = new WebTestHelperLib.ComAptTestClass();
catc.DoMethod();
}
}
}
재미있는 것은, 위의 페이지를 실행하는 스레드의 흐름입니다. 이를 확인하려면 웹 페이지 코드를 다음과 같이 변경시키고,
public partial class _Default : System.Web.UI.Page
{
int _pageTid;
public _Default()
{
_pageTid = AppDomain.GetCurrentThreadId();
}
protected void Page_Load(object sender, EventArgs e)
{
WebTestHelperLib.ComAptTestClass catc = new WebTestHelperLib.ComAptTestClass();
int tid;
int aptKind = catc.DoMethod3(5, out tid);
StringBuilder sb = new StringBuilder();
sb.AppendFormat("ctor Thread: {0}<br />", _pageTid);
sb.AppendFormat("Page_Load Thread: {0}<br />", AppDomain.GetCurrentThreadId());
sb.AppendFormat("Com Thread: {0}<br />", tid);
sb.AppendFormat("Com Apt: {0}<br />", aptKind);
Label1.Text = sb.ToString();
}
}
COM 개체에서 제공되는 DoMethod3 메서드를 이렇게 구현해 주어야 합니다.
// sleepSecond: 메서드 실행 시 일부러 지연시킬 시간을 지정
// tid: DoMethod3을 실행하는 스레드의 Native ID가 반환됨
// aptKind: DoMethod3을 실행하는 스레드의 Apartment 유형을 반환
STDMETHODIMP CComAptTest::DoMethod3(LONG sleepSecond,LONG *tid, LONG *aptKind)
{
::Sleep(sleepSecond * 1000);
*tid = ::GetCurrentThreadId();
do
{
// 아래의 코드는 https://www.sysnet.pe.kr/2/0/1351 글을 참조.
BYTE *pTeb = (BYTE *)NtCurrentTeb();
int *pOle = (int *)(pTeb + 0xf80);
if (*pOle == 0)
{
printf("No apartment\n");
break;
}
int *pNativeApt = (int *)(*pOle + 0x50);
*aptKind = *(int *)(*pNativeApt + 0x0c);
} while (false);
return S_OK;
}
/*
*aptKind에 담기는 값은 다음 중의 하나입니다.
enum tagAPTKIND {
APTKIND_NEUTRALTHREADED = 1,
APTKIND_MULTITHREADED = 2,
APTKIND_APARTMENTTHREADED = 4,
APTKIND_APPLICATION_STA = 8
};
*/
자... 이렇게 하고 실행하면 다음과 같은 결과를 얻을 수 있습니다.
ctor Thread: 10992
Page_Load Thread: 5628
Com Thread: 5628
Com Apt: 4 (== APTKIND_APARTMENTTHREADED)
즉, 스레드의 실행 흐름이 다음과 같이 변경됩니다.
- 클라이언트의 요청을 ASP.NET Thread Pool 내의 10992 스레드가 받고,
- 10992 스레드가 Page 객체를 생성한 후,
- 10992 스레드가 AspCompat 값이 True임을 확인하고 STA COM 개체가 생성될 것임을 짐작,
- 따라서 별도로 생성해 둔 5628 STA 스레드로 Request 문맥을 전달,
- 5628은 이후 Page 개체의 나머지 실행 처리를 담당.
여기서 문제는 5628 STA 스레드가 모든 COM 개체의 사용에 대응된다는 것입니다. 따라서, 해당 COM 개체를 사용하는 모든 웹 페이지의 실행은 5628 스레드로 직렬화 되어 성능 저하가 심각해 집니다. 예를 들어, 위의 test.aspx.cs에서 COM 개체의 DoMethod3 메서드에 5초의 지연시간을 갖도록 지정해 보겠습니다. 2명의 사용자가 동시에 test.aspx 웹 페이지를 방문하면 다음과 같은 현상이 발생합니다.
- A와 B사용자가 동시에 test.aspx 웹 페이지 요청
- A 요청을 10992 스레드가 받아서 5628 STA 스레드에 전달
- B 요청은 10330 스레드가 받아서 5628 STA 스레드에 전달
- 5628 스레드는 먼저 전달된 A 요청을 실행 - 5초 걸림
- 5초 후 5628 스레드는 두 번째로 전달된 B 요청을 실행 - 5초 걸림
결국, 이런 직렬화 처리로 인해 B 요청은 A 요청과 거의 비슷한 시기에 전달되었음에도 불구하고 10초 후에나 응답을 받게 됩니다.
이 문제를 해결하려면, STA COM 개체를 MTA COM 개체로 만들어야 합니다.
그런데, 만약 AspCompat=True 값을 설정하지 않았다면 어떻게 될까요? 역시 같은 방법으로 2개의 요청을 동시에 보내보면 다음과 같은 결과를 얻을 수 있습니다.
=== A 요청 ====
ctor Thread: 34968
Page_Load Thread: 34968
Com Thread: 5628
Com Apt: 4
=== B 요청 ====
ctor Thread: 13432
Page_Load Thread: 13432
Com Thread: 5628
Com Apt: 4
Page_Load까지도 ASP.NET 스레드 풀 내의 스레드가 처리를 담당하다가 정작 STA COM 개체의 메서드 호출에는 별도로 생성되어 있는 STA 스레드에 맡겨지고 있습니다. 이 결과와 AspCompat=True였을 때의 상황과는 어떤 차이가 있을까요?
이에 대한 해답은 AspCompat=False이고, STA COM 개체를 썼을 때 2개의 동시 요청을 어떤 식으로 처리하는지 파악해 보면 알 수 있습니다.
- A와 B사용자가 동시에 test.aspx 웹 페이지 요청
- A 요청을 34968 스레드가 받아서 5628 STA 스레드에 전달하고 대기
- B 요청은 13432 스레드가 받아서 5628 STA 스레드에 전달하고 대기
- 5628 스레드는 먼저 전달한 A 요청을 실행(5초 걸림)하고 34968 스레드로 실행 반환
- 5628 스레드는 두 번째로 전달한 B 요청을 실행(5초 걸림)하고 13432 스레드로 실행 반환
차이를 아시겠어요? AspCompat=True일 때는 ASP.NET의 요청을 처리하는 스레드 풀의 스레드가 자유로웠지만, False일 때는 그 스레드가 STA 스레드의 실행이 끝날 때까지 대기한다는 문제가 발생합니다.
이 때문에, 스레드 풀의 스레드 수가 500개이면 위의 페이지가 500개의 요청을 받는 경우 모든 스레드가 바닥난다는 의미가 됩니다. 더 이상 다음의 요청을 받아서 처리할 수 없다는 것이지요.
AspCompat=True인 경우, STA COM 개체를 Page_Load가 아닌 생성자나 멤버 변수 정의시점에 만들면 상황이 더 재미있어집니다.
public partial class _Default : System.Web.UI.Page
{
WebTestHelperLib.ComAptTestClass catc; // 여기서 new를 하거나!
public _Default()
{
catc = new WebTestHelperLib.ComAptTestClass(); // 여기서 new를 한 경우!
}
protected void Page_Load(object sender, EventArgs e)
{
}
}
AspCompat 속성은 _Default 페이지 개체를 생성하는 순간에는 그 값을 알 수 없습니다. 따라서 ASP.NET이 AspCompat 속성을 보고 어떠한 조치를 취하기에는 너무 빠르다는 것인데요.
이 때문에 이런 요청을 A, B 두 사용자가 동시에 보낸다면 결과가 다음과 같이 나옵니다.
ctor Thread: 24344
Page_Load Thread: 23712
Com Thread: 5628
Com Apt: 4
ctor Thread: 348
Page_Load Thread: 23712
Com Thread: 5628
Com Apt: 4
차이가 눈에 보이시나요? ctor를 실행한 스레드는 ASP.NET의 스레드 풀 중의 하나입니다. 그리곤 뒤늦게 AspCompat=True 속성을 인식하고는 23712 스레드로 Page_Load를 실행하도록 변경하긴 했으나 이미 해당 COM 개체는 별도의 STA 스레드에 묶인 상태입니다.
이런 경우에는 스레드 풀의 모든 요청이 Page_Load 스레드에서 미리 직렬화됩니다. 23712 스레드가 24344 요청을 처리하고 COM 메서드를 실행한 다음 사용자에게 결과를 반환하고, 다시 348 요청을 COM 메서드를 실행한 다음 결과를 반환합니다.
AspCompat=True에서의 이런 문제점은 MSDN의 도움말에 다음과 같이 씌여 있습니다.
COM Component Compatibility
; https://docs.microsoft.com/en-us/previous-versions/aspnet/zwk9h2kb(v=vs.100)
COM components that are created at construction time run before the request is scheduled to the STA thread pool and consequently run on a multithreaded apartment (MTA) thread. This has a substantial negative performance impact and should be avoided. If you use AspCompat with STA components, you should create COM components only from the Page_Load event or later in the execution chain and not at page construction time.
결론을 내려 보면, STA COM 개체를 사용한다면 AspCompat=True로 설정하고 Page_Load 이후에 COM 개체를 생성하는 것이 좋습니다.
그리고, 만약 가능하다면 COM 개체를 만든 업체 측에 MTA로 해달라고 요청하는 것이 성능을 위해 가장 좋습니다.
참고 삼아 AspCompat=True인 경우의 콜 스택을 보면 다음과 같이 나옵니다.
WebApplication1.dll!WebApplication1.WebForm1.Page_Load(object sender, System.EventArgs e) Line 16 C#
System.Web.dll!System.Web.UI.Control.LoadRecursive() + 0x74 bytes
System.Web.dll!System.Web.UI.Page.ProcessRequestMain(bool includeStagesBeforeAsyncPoint, bool includeStagesAfterAsyncPoint) + 0xb5e bytes
System.Web.dll!System.Web.UI.Page.ProcessRequest(bool includeStagesBeforeAsyncPoint, bool includeStagesAfterAsyncPoint) + 0xb9 bytes
System.Web.dll!System.Web.UI.Page.ProcessRequest() + 0x77 bytes
System.Web.dll!System.Web.HttpApplication.ExecuteStep(System.Web.HttpApplication.IExecutionStep step, ref bool completedSynchronously) + 0xa5 bytes
System.Web.dll!System.Web.Util.AspCompatApplicationStep.ExecuteAspCompatCode() + 0x83 bytes
System.Web.dll!System.Web.Util.AspCompatApplicationStep.OnAspCompatExecution() + 0x79 bytes
ExecuteAspCompatCode, OnAspCompatExecution 2개의 메서드가 눈에 띕니다. 또한 HttpContext.Current가 유효한 단계는 System.Web.UI.Page.ProcessRequest 호출이므로 그 이후의 호출 스택에서만 HTTP 문맥 개체를 사용할 수 있습니다.
비교를 위해 다음은 일반적인 웹 페이지의 콜 스택을 보여주고 있습니다.
WebApplication1.dll!WebApplication1.WebForm1.Page_Load(object sender, System.EventArgs e) Line 16 C#
System.Web.dll!System.Web.UI.Control.LoadRecursive() + 0x74 bytes
System.Web.dll!System.Web.UI.Page.ProcessRequestMain(bool includeStagesBeforeAsyncPoint, bool includeStagesAfterAsyncPoint) + 0xb5e bytes
System.Web.dll!System.Web.UI.Page.ProcessRequest(bool includeStagesBeforeAsyncPoint, bool includeStagesAfterAsyncPoint) + 0xb9 bytes
System.Web.dll!System.Web.UI.Page.ProcessRequest() + 0x77 bytes
System.Web.dll!System.Web.UI.Page.ProcessRequest(System.Web.HttpContext context) + 0xa3 bytes
App_Web_cx5dgpsp.dll!ASP.default_aspx.ProcessRequest(System.Web.HttpContext context) + 0x33 bytes C#
System.Web.dll!System.Web.HttpApplication.CallHandlerExecutionStep.System.Web.HttpApplication.IExecutionStep.Execute() + 0x445 bytes
System.Web.dll!System.Web.HttpApplication.ExecuteStep(System.Web.HttpApplication.IExecutionStep step, ref bool completedSynchronously) + 0xa5 bytes
System.Web.dll!System.Web.HttpApplication.PipelineStepManager.ResumeSteps(System.Exception error) + 0x4de bytes
System.Web.dll!System.Web.HttpApplication.BeginProcessRequestNotification(System.Web.HttpContext context, System.AsyncCallback cb) + 0x85 bytes
System.Web.dll!System.Web.HttpRuntime.ProcessRequestNotificationPrivate(System.Web.Hosting.IIS7WorkerRequest wr, System.Web.HttpContext context) + 0x255 bytes
System.Web.dll!System.Web.Hosting.PipelineRuntime.ProcessRequestNotificationHelper(System.IntPtr rootedObjectsPointer, System.IntPtr nativeRequestContext, System.IntPtr moduleData, int flags) + 0x47e bytes
System.Web.dll!System.Web.Hosting.PipelineRuntime.ProcessRequestNotification(System.IntPtr rootedObjectsPointer, System.IntPtr nativeRequestContext, System.IntPtr moduleData, int flags) + 0x22 bytes
[Native to Managed Transition]
[Managed to Native Transition]
System.Web.dll!System.Web.Hosting.PipelineRuntime.ProcessRequestNotificationHelper(System.IntPtr rootedObjectsPointer, System.IntPtr nativeRequestContext, System.IntPtr moduleData, int flags) + 0x632 bytes
System.Web.dll!System.Web.Hosting.PipelineRuntime.ProcessRequestNotification(System.IntPtr rootedObjectsPointer, System.IntPtr nativeRequestContext, System.IntPtr moduleData, int flags) + 0x22 bytes
[Appdomain Transition]
[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]