Microsoft MVP성태의 닷넷 이야기
글쓴 사람
정성태 (techsharer at outlook.com)
홈페이지
첨부 파일
 
(연관된 글이 4개 있습니다.)
(시리즈 글이 4개 있습니다.)
.NET Framework: 412. HttpContext.Current를 통해 이해하는 CallContext와 ExecutionContext
; https://www.sysnet.pe.kr/2/0/1608

.NET Framework: 727. ASP.NET의 HttpContext.Current 구현에 대응하는 ASP.NET Core의 IHttpContextAccessor/HttpContextAccessor 사용법
; https://www.sysnet.pe.kr/2/0/11440

.NET Framework: 989. HttpContextAccessor를 통해 이해하는 AsyncLocal<T>
; https://www.sysnet.pe.kr/2/0/12467

.NET Framework: 1076. C# - AsyncLocal 기능을 CallContext만으로 구현하는 방법
; https://www.sysnet.pe.kr/2/0/12706




HttpContext.Current를 통해 이해하는 CallContext와 ExecutionContext

ASP.NET의 경우 HttpContext.Current를 사용해서 ASP.NET pipeline에서의 동일한 문맥 정보를 제공합니다. 일례로 요청을 처리하는 스레드에서 실행하는 모든 코드에서는 무조건 HttpContext.Current 속성을 이용하면 현재 문맥을 가져올 수 있습니다.

그런데, 도대체 HttpContext에 어떤 마법이 숨겨져 있는 걸까요? ^^ 이번엔 이에 대해서 간단하게(?) 파악해 보겠습니다.




우선, HttpContext.Current 속성의 소스 코드를 살펴볼까요?

// System.Web.dll
public sealed class HttpContext : IServiceProvider
{
    // ...[생략]...
    public static HttpContext Current
    {
        get
        {
            return (ContextBase.Current as HttpContext);
        }
        set
        {
            ContextBase.Current = value;
        }
    }
}

아하... ContextBase.Current를 형변환 한 것에 불과하군요. 이어서 ContextBase를 가보겠습니다.

// System.Web.dll
internal class ContextBase
{
    // ...[생략]...
    internal static object Current
    {
        get
        {
            return CallContext.HostContext;
        }
        set
        {
            CallContext.HostContext = value;
        }
    }
}

ContextBase.Current의 내부 구현은 결국 CallContext.HostContext임을 알 수 있습니다. 그리고 CallContext의 정적 공용 변수인 HostContext는 Thread마다 고유하게 할당된 ExecutionContext가 포함하고 있는 2개의 CallContext 인스턴스에 값을 읽고 쓰는 것에 불과합니다.

여기에서 CallContext.HostContext의 set을 살펴보면 다음과 같습니다.

public static void set_HostContext(object value)

    ExecutionContext mutableExecutionContext = Thread.CurrentThread.GetMutableExecutionContext();
    if (value is ILogicalThreadAffinative)
    {
        mutableExecutionContext.IllogicalCallContext.HostContext = null;
        mutableExecutionContext.LogicalCallContext.HostContext = value;
    }
    else
    {
        mutableExecutionContext.IllogicalCallContext.HostContext = value;
        mutableExecutionContext.LogicalCallContext.HostContext = null;
    }
}

여기서 재미있는 점이 바로, ExecutionContext가 관리하는 2개의 CallContext입니다. 왜 2개일까요? 이는 객체의 Serializable과 관련이 있습니다.

Identifying the differences between CallContext Data Slots 
; http://dotnetmustard.blogspot.kr/2008/08/identifying-differences-between.html

Thread 객체가 소유한 (Hashtable로 데이터를 관리하는) CallContext의 데이터를 스레드 간에 넘길 것이냐 말 것이냐에 따라 어느 CallContext에 데이터가 get/set 되는지가 결정되기 때문입니다. (보다 정확하게는 Thread 간이라고 볼 수는 없습니다. 왜냐하면 CallContext가 정의된 네임스페이스는 System.Runtime.Remoting.Messaging이고 이는 원격 메서드 호출에 관여하기 때문입니다. 물론 원격 메서드 호출은 확실히 스레드가 달라지긴 하지만.)

Thread가 가지고 있는 2개의 CallContext는 각기 2개의 클래스로 나뉘는데요. 하나는 LogicalCallContext이고, 다른 하나는 IllogicalCallContext입니다.

internal class LogicalCallContext : ISerializable, ICloneable
{
    // ...[생략]...
    private Hashtable m_Datastore;
    private object m_HostContext;
}

internal class IllogicalCallContext : ICloneable
{
    // ...[생략]...
    private Hashtable m_Datastore;
    private object m_HostContext;
}

전자가 직렬화 가능한 객체를 담고, 후자는 직렬화가 지원되지 않는 객체를 담습니다. 각각의 CallContext는 Thread 인스턴스에서 직접 접근할 수는 없고, CallContext의 정적 메서드를 사용해야만 가능합니다.

  • public static object GetData(string name);
  • public static void SetData(string name, object data);
  • public static object LogicalGetData(string name);
  • public static void LogicalSetData(string name, object data);

우선, LogicalSetData는 Thread.ExecutionContext.LogicalCallContext에 값을 set 합니다. 반면, SetData는 인자로 주어진 object data 인스턴스가 ILogicalThreadAffinative 인터페이스를 구현하고 있다면 LogicalCallContext로, 그렇지 않으면 IllogicalCallContext로 보관됩니다.




말로만 설명하면 너무 식상하니 ^^ 코딩을 해보겠습니다. 우선, Thread의 LogicalCallContext에 값을 보관하려면 다음과 같이 해주면 됩니다.

public class LTAObject : ILogicalThreadAffinative
{
    public string Value { get; set; }
}

class Program
{
    static void Main(string[] args)
    {
        // ILogicalThreadAffinative 인터페이스를 구현하지 않은 string 객체라도
        // 명시적으로 LogicalCallContext에 값을 보관
        CallContext.LogicalSetData("key1", "test");
        Debug.Assert(CallContext.LogicalGetData("key1") as string == "test");

        // ILogicalThreadAffinative 인터페이스를 상속받은 객체를 생성하고,
        LTAObject lta = new LTAObject();
        lta.Value = "test";

        // LogicalCallContext에 값을 보관
        CallContext.SetData("key1", lta);
        Debug.Assert((CallContext.LogicalGetData("key1") as LTAObject).Value == "test");
            
    }
}

반면, Thread의 IllogicalCallContext에 값을 보관하는 코드는 다음과 같습니다.

public class NoLTAObject
{
    public string Value { get; set; }
}

class Program
{
    static void Main(string[] args)
    {
        // string 객체는 ILogicalThreadAffinative를 상속받지 않았으므로 IllogicalCallContext에 보관됨.
        CallContext.SetData("key1", "test");
        Debug.Assert(CallContext.GetData("key1") as string == "test");
        Debug.Assert(CallContext.LogicalGetData("key1") == null); // LogicalCallContext에 없음.

        // NoLTAObject 객체도 ILogicalThreadAffinative를 상속받지 않았으므로 IllogicalCallContext에 보관됨.
        NoLTAObject noLta = new NoLTAObject();
        noLta.Value = "test";

        CallContext.SetData("key1", noLta);
        Debug.Assert((CallContext.GetData("key1") as NoLTAObject).Value == "test");
        Debug.Assert(CallContext.LogicalGetData("key1") == null); // LogicalCallContext에 없음.
    }
}




단일 스레드로 특정 작업이 처리되는 상황에서는 LogicalCallContext와 IllogicalCallContext 중에서 어디에 값을 보관하느냐는 문제될 것이 없습니다. 하지만, 그 작업과 관련되어 여러 가지 스레드들이 관여하게 된다면 그 스레드들이 공통으로 공유해야 할 문맥 정보가 필요합니다.

위에서 구현되었던 LTAObject와 NoLTAObject가 스레드를 넘어가는 경우의 차이점을 아래의 코드로 직접 확인해 볼 수 있습니다.

public class LTAObject : ILogicalThreadAffinative
{
    public string Value { get; set; }
}

public class NoLTAObject
{
    public string Value { get; set; }
}

class Program
{
    static void Main(string[] args)
    {
        // LogicalCallContext에 값을 보관
        CallContext.LogicalSetData("key1", "test");

        // IllogicalCallContext에 값을 보관
        CallContext.SetData("key2", "test");

        // ILogicalThreadAffinative 인터페이스를 상속받은 객체를 생성하고,
        LTAObject lta = new LTAObject();
        lta.Value = "test";

        // LogicalCallContext에 값을 보관
        CallContext.SetData("key3", lta);

        // NoLTAObject 객체도 ILogicalThreadAffinative를 상속받지 않았으므로 IllogicalCallContext에 보관됨.
        NoLTAObject noLta = new NoLTAObject();
        noLta.Value = "test";

        CallContext.SetData("key4", noLta);

        Thread thread = new Thread(threadFunc);
        thread.Start();
        thread.Join();

        return;
    }

    static void threadFunc()
    {
        // LogicalCallContext에 보관되었던 데이터들은 스레드 경계를 넘어옴
        Debug.Assert(CallContext.LogicalGetData("key1") as string == "test");
        Debug.Assert((CallContext.LogicalGetData("key3") as LTAObject).Value == "test");

        // IllogicalCallContext에 보관되었던 데이터들은 스레드 경계를 못 넘음.
        Debug.Assert(CallContext.GetData("key2") == null);
        Debug.Assert(CallContext.GetData("key4") == null);
    }
}

보시는 것처럼, LogicalCallContext에 보관되었던 항목들은 스레드 간에 자연스럽게 데이터 이관이 되었지만, IllogicalCallContext에 보관되었던 항목들은 안되었습니다.

그런데, 혹시 LogicalCallContext의 데이터도 스레드 간에 넘어가지 못하도록 막고 싶지는 않을까요? 데이터를 스레드 간에 넘긴다는 것은 그런 작업이 이뤄졌기 때문에 가능한 것이고 이것이 복잡한 경우 자칫 성능에 영향을 미칠 수 있습니다. 굳이 CallContext의 데이터들이 필요치 않다면 이를 넘기는데 괜한 오버헤드를 가져갈 이유가 없습니다.

이를 위해 ExecutionContext의 SuppressFlow / RestoreFlow 정적 메서드를 이용하면 됩니다.

ExecutionContext.SuppressFlow();

Thread thread = new Thread(threadFunc);
thread.Start();
thread.Join();

ExecutionContext.RestoreFlow();

static void threadFunc()
{
    // 모든 CallContext의 데이터들이 스레드 경계를 못 넘음
    Debug.Assert(CallContext.LogicalGetData("key1") == null);
    Debug.Assert(CallContext.GetData("key2") == null);

    Debug.Assert(CallContext.LogicalGetData("key3") == null);
    Debug.Assert(CallContext.GetData("key4") == null);
}

LogicalCallContext와 IllogicalCallContext 인스턴스는 ExecutionContext에 포함되어 있고, 다시 ExecutionContext는 Thread에 포함되어 있습니다. 따라서 관리는 ExecutionContext 측에서 이뤄지는데, 기본 동작이 스레드 간에 LogicalCallContext를 자동으로 넘겨주는 역할을 하고 이를 막으려면 개발자가 수작업으로 SuppressFlow / RestoreFlow를 호출해 주어야 합니다.




여기까지 이해하셨으면, ThreadPool 타입의 QueueUserWorkItem과 UnsafeQueueUserWorkItem 메서드에서도 CallContxt에 대한 차이가 있다는 것을 자연스럽게 파악할 수 있습니다.

즉, QueueUserWorkItem은 LogicalCallContext 데이터를 스레드 풀에서 가져올 스레드에 설정해 주는 반면, UnsafeQueueUserWorkItem은 LogicalCallContext 데이터 이관을 명시적으로 하지 않는다는 차이가 있습니다. 위의 예제 코드와 비교해서 설명해 보면 UnsafeQueueUserWorkItem은 SuppressFlow / RestoreFlow를 호출한 스레드처럼 동작하는 것입니다.

static void Main(string[] args)
{
    CallContext.LogicalSetData("key1", "test");

    ThreadPool.QueueUserWorkItem(threadFunc, null);
    ThreadPool.UnsafeQueueUserWorkItem(threadFunc, null);

    Console.ReadLine();
}

static void threadFunc(object objContext)
{
    object objValue = CallContext.GetData("key1");
    Console.WriteLine(objValue ?? "(null)"); // QueueUserWorkItem인 경우 "test" 출력
                                             // UnsafeQueueUserWorkItem인 경우 "(null)"
}

실제로 테스트해 보면 QueueUserWorkItem인 경우일지라도 그 전에 SuppressFlow를 호출해 주면 마찬가지로 LogicalCallContext 데이터가 이관되지 않는 것을 알 수 있습니다.

static void Main(string[] args)
{
    CallContext.LogicalSetData("key1", "test");

    ExecutionContext.SuppressFlow();
    ThreadPool.QueueUserWorkItem(threadFunc, null);
    ExecutionContext.RestoreFlow();

    ThreadPool.UnsafeQueueUserWorkItem(threadFunc, null);

    Console.ReadLine();
}

static void threadFunc(object objContext)
{
    object objValue = CallContext.GetData("key1");
    Console.WriteLine(objValue ?? "(null)"); // QueueUserWorkItem, UnsafeQueueUserWorkItem 모두 "(null)"
}

SuppressFlow 사용 중에 일부러 LogicalCallContext를 전달하고 싶다면 대신 다른 방법을 사용해야 합니다.

static void Main(string[] args)
{
    CallContext.LogicalSetData("key1", "test");
    ExecutionContext threadContext = ExecutionContext.Capture(); // 미리 capture 해두고,

    ExecutionContext.SuppressFlow(); // suppress를 해도,

 // ThreadPool.UnsafeQueueUserWorkItem(threadFunc, null);
    ExecutionContext.Run(threadContext, threadFunc, null); // capture했던 내용으로 직접 실행하는 것도 가능

    ExecutionContext.RestoreFlow();
 
    Console.ReadLine();
}

static void threadFunc(object objContext)
{
    object objValue = CallContext.GetData("key1");
    Console.WriteLine(objValue); // "test" 값을 출력
}




비동기 호출의 경우, LogicalCallContext의 데이터 이관이 어떻게 되는지 살펴보는 것도 흥미로운데요. 아래의 글을 보면,

Nested multithread operations tracing
; http://stackoverflow.com/questions/2651327/nested-multithread-operations-tracing

다음과 같은 코드 테스트가 나옵니다.

static void Main(string[] args)
{
    string key = "aaa";
    EventWaitHandle asyncStarted = new AutoResetEvent(false);
    IAsyncResult r = null;

    CallContext.LogicalSetData(key, "Root - op 0");
    Console.WriteLine("Initial: {0}", CallContext.LogicalGetData(key));

    Action a = () =>
    {
        CallContext.LogicalSetData(key, "Async - op 0");
        asyncStarted.Set();
    };
    r = a.BeginInvoke(null, null);

    asyncStarted.WaitOne();
    Console.WriteLine("AsyncOp started: {0}", CallContext.LogicalGetData(key));

    CallContext.LogicalSetData(key, "Root - op 1");
    Console.WriteLine("Current changed: {0}", CallContext.LogicalGetData(key));

    a.EndInvoke(r);
    Console.WriteLine("Async ended: {0}", CallContext.LogicalGetData(key));
}

// 출력 결과
Initial: Root - op 0
AsyncOp started: Root - op 0
Current changed: Root - op 1
Async ended: Async - op 0

처음 Main 메서드의 시작에서 key == "aaa"로 CallContext.LogicalSetData에 값을 넣었는데요. 비동기 호출되는 delegate 메서드 내에서 "aaa" 키에 해당하는 LogicalCallContext의 값을 변경하지만, 외부에서는 그 시점에 변화를 알 수 없습니다. 실제로 비동기 메서드 내에서의 LogicalCallContext 저장소가 그것의 부모 스레드의 LogicalCallContext에 병합되는 시점은 EndInvoke이라는 것이 특이합니다.




자, 이제 다시 최초의 HttpContext.Current로 돌아가 보겠습니다. 여기까지 살펴봤으면 이제 웹상에서 자주 나타나는 한 가지 질문에 답할 수 있게 됩니다.

새로 만든 스레드에서 HttpContext.Current를 접근하면 null이 나옵니다. 왜 그런 걸까요?

간단합니다. 소스 코드를 살펴보면 HttpContext 개체는 ILogicalThreadAffinative 인터페이스를 구현하지 않았습니다. 또한 CallContext.LogicalSetData를 통해서 값을 설정하지도 않았기 때문에 HttpContext 인스턴스 자체는 Thread의 IllogicalCallContext 영역에 보관되어 있습니다.

따라서, ASP.NET Page 내에서 다음과 같이 별도의 스레드로 넘어가버리면 해당 스레드에서는 HttpContext.Current에 아무런 값이 설정되어 있지 않은 상태입니다.

using System;
using System.Web;
using System.Threading;
using System.Runtime.Remoting.Messaging;

namespace WebApplication1
{
    public partial class _Default : System.Web.UI.Page
    {
        protected void Page_Load(object sender, EventArgs e)
        {
            ThreadPool.QueueUserWorkItem(threadFunc, null);
        }

        void threadFunc(object objContext)
        {
            object httpContext = HttpContext.Current; // == null
            object hostContxtObj = CallContext.HostContext; // null
        }
    }
}

애당초 이것이 가능하려면 마이크로소프트가 HttpContext를 직렬화 가능한 클래스로 만들었어야 합니다. 단지, 그렇게 만들지 않았던 것에는 몇 가지 타당한 이유가 있어 보입니다. 가령 HttpContext.Current에서는 Server, Request, Response 등의 객체를 노출시켜 주는데 그런 객체들을 모두 마샬링 가능한 타입으로 만드는 것은 그다지 효율적이지 않았을 수 있습니다. 그 외에도, 새롭게 생성된 스레드가 현재 요청이 처리된 후에 - 즉, HttpContext가 소멸된 이후에 - HttpContext.Current를 접근한다면 이미 모든 정보가 유효하지 않은 상태에서 그것을 접근하게 해주는 것도 자칫 복잡한 문제를 야기시킬 수 있습니다. 따라서 제 의견에도 HttpContext가 객체가 스레드 간에 넘나들지 않게 한 것은 옳은 결정으로 보입니다.

어쨌든 다른 스레드에서는 HttpContext.Current를 넘겨줄 수 없으므로 필요한 정보가 있다면 그것만을 별도로 취합해서 HttpContext가 아닌 CallContext를 직접 사용해서 넘겨주는 방법을 택해야 합니다.

using System;
using System.Web;
using System.Threading;
using System.Runtime.Remoting.Messaging;

namespace WebApplication1
{
    public partial class _Default : System.Web.UI.Page
    {
        protected void Page_Load(object sender, EventArgs e)
        {
            CallContext.LogicalSetData("RequestURL", HttpContext.Current.Request.Url.ToString());

            ThreadPool.QueueUserWorkItem(threadFunc, null);
        }

        void threadFunc(object objContext)
        {
            object obj1 = CallContext.LogicalGetData("RequestURL");
        }
    }
}

이 정도면 HttpContext.Current의 미스테리가 좀 풀렸겠지요. ^^




[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]

[연관 글]






[최초 등록일: ]
[최종 수정일: 1/10/2024]

Creative Commons License
이 저작물은 크리에이티브 커먼즈 코리아 저작자표시-비영리-변경금지 2.0 대한민국 라이센스에 따라 이용하실 수 있습니다.
by SeongTae Jeong, mailto:techsharer at outlook.com

비밀번호

댓글 작성자
 



2014-01-27 12시03분
[ryujh] 안녕하세요.

CallContext, ExecutionContext 를 이해하기 전에 HttpContext 를 이해해야 해서 결론을 보고 생각해본 것이,

페이지 호출 당 페이지클래스 인스턴스가 생성되고 그 인스턴스 코드내에 주스레드에서 HttpContext.Current 로 접근 가능하다는 것이 맞을까요?

그리고 ThreadPool.QueueUserWorkItem(threadFunc, null); 대신
ThreadPool.QueueUserWorkItem(threadFunc, HttpContext.Current); 를 넘기면 threadFunc 내에서 사용가능하지 않을지요?

감사합니다.
[guest]
2014-01-27 01시37분
1. 넵. HttpContext.Current로 접근할 수 있습니다.
2. HttpContext.Current를 넘기면 물론 가능합니다. 사실 그게 가장 쉽겠지요. ^^ 단지, 글의 주제상 CallContext를 언급했고 HttpContext가 CallContext에 보관된다는 고유 사실로부터 얻어질 수 있는 효과를 비교하면 아주 동일하진 않습니다. 가령, CallContext의 경우 AppDomain 경계를 넘을 수도 있습니다.
정성태
2020-10-13 09시48분
How to migrate CallContext to .NETStandard and .NETCore
; https://www.cazzulino.com/callcontext-netstandard-netcore.html
정성태
2020-12-30 07시32분
정성태

1  2  [3]  4  5  6  7  8  9  10  11  12  13  14  15  ...
NoWriterDateCnt.TitleFile(s)
13549정성태2/3/20242473개발 환경 구성: 706. C# - 컨테이너에서 실행하기 위한 (소켓) 콘솔 프로젝트 구성
13548정성태2/1/20242306개발 환경 구성: 705. "Docker Desktop for Windows" - ASP.NET Core 응용 프로그램의 소켓 주소 바인딩(IPv4/IPv6 loopback, Any)
13547정성태1/31/20242054개발 환경 구성: 704. Visual Studio - .NET 8 프로젝트부터 dockerfile에 추가된 "USER app" 설정
13546정성태1/30/20241894Windows: 255. (디버거의 영향 등으로) 대상 프로세스가 멈추면 Socket KeepAlive로 연결이 끊길까요?
13545정성태1/30/20241825닷넷: 2212. ASP.NET Core - 우선순위에 따른 HTTP/HTTPS 호스트:포트 바인딩 방법
13544정성태1/30/20241846오류 유형: 894. Microsoft.Data.SqlClient - Could not load file or assembly 'System.Security.Permissions, ...'
13543정성태1/30/20241822Windows: 254. Windows - 기본 사용 중인 5357 포트 비활성화는 방법
13542정성태1/30/20241875오류 유형: 893. Visual Studio - Web Application을 실행하지 못하는 IISExpress - 두 번째 이야기
13541정성태1/29/20241919VS.NET IDE: 188. launchSettings.json의 useSSL 옵션
13540정성태1/29/20242049Linux: 69. 리눅스 - "Docker Desktop for Windows" Container 환경에서 IPv6 Loopback Address 바인딩 오류
13539정성태1/26/20242143개발 환경 구성: 703. Visual Studio - launchSettings.json을 이용한 HTTP/HTTPS 포트 바인딩
13538정성태1/25/20242212닷넷: 2211. C# - NonGC(FOH) 영역에 .NET 개체를 생성파일 다운로드1
13537정성태1/24/20242258닷넷: 2210. C# - Native 메모리에 .NET 개체를 생성파일 다운로드1
13536정성태1/23/20242370닷넷: 2209. .NET 8 - NonGC Heap / FOH (Frozen Object Heap) [1]
13535정성태1/22/20242200닷넷: 2208. C# - GCHandle 구조체의 메모리 분석
13534정성태1/21/20242030닷넷: 2207. C# - SQL Server DB를 bacpac으로 Export/Import파일 다운로드1
13533정성태1/18/20242223닷넷: 2206. C# - TCP KeepAlive의 서버 측 구현파일 다운로드1
13532정성태1/17/20242134닷넷: 2205. C# - SuperSimpleTcp 사용 시 주의할 점파일 다운로드1
13531정성태1/16/20242019닷넷: 2204. C# - TCP KeepAlive에 새로 추가된 Retry 옵션파일 다운로드1
13530정성태1/15/20242009닷넷: 2203. C# - Python과의 AES 암호화 연동파일 다운로드1
13529정성태1/15/20241893닷넷: 2202. C# - PublishAot의 glibc에 대한 정적 링킹하는 방법
13528정성태1/14/20242030Linux: 68. busybox 컨테이너에서 실행 가능한 C++, Go 프로그램 빌드
13527정성태1/14/20241957오류 유형: 892. Visual Studio - Failed to launch debug adapter. Additional information may be available in the output window.
13526정성태1/14/20242045닷넷: 2201. C# - Facebook 연동 / 사용자 탈퇴 처리 방법
13525정성태1/13/20242011오류 유형: 891. Visual Studio - Web Application을 실행하지 못하는 IISExpress
13524정성태1/12/20242063오류 유형: 890. 한국투자증권 KIS Developers OpenAPI - GW라우팅 중 오류가 발생했습니다.
1  2  [3]  4  5  6  7  8  9  10  11  12  13  14  15  ...