Microsoft MVP성태의 닷넷 이야기
글쓴 사람
정성태 (techsharer at outlook.com)
홈페이지
첨부 파일
(연관된 글이 1개 있습니다.)

운영체제의 스레드 문맥 교환(Context Switch)을 유사하게 구현하는 방법

며칠 전에, 사내에서 비동기 스레드와 관련된 약식 세미나를 했는데요, 관련해서 Thread Context를 설명하면서 생각난 것이 바로 이번 글의 주제였습니다. 사실 지난번 글은 본격적으로 쓰기 전에 잠시 머물렀던 것이고. ^^

C 언어의 setjmp/longjmp 기능을 Thread Context를 이용해 유사하게 구현하는 방법
; https://www.sysnet.pe.kr/2/0/12695

이번에도 역시 그냥 재미로 봐주시면 되겠습니다. ^^ (현업에서 쓸 일이 없습니다.)




예를 들기 위해, 스레드 2개를 생성해 1초마다 스레드 ID를 출력하는 코드를 작성해 보겠습니다.

// x64 환경에서 64비트로만 실행

#include <iostream>
#include <windows.h>

volatile bool g_threadRun = true;

DWORD WINAPI threadFunc1(LPVOID lpThreadParameter)
{
    while (g_threadRun)
    {
        Sleep(1000);
        ::printf("threadFunc1: %d\n", GetCurrentThreadId());
    }

    return 0;
}

DWORD WINAPI threadFunc2(LPVOID lpThreadParameter)
{
    while (g_threadRun)
    {
        Sleep(1000);
        ::printf("threadFunc2: %d\n", GetCurrentThreadId());
    }

    return 0;
}

int main()
{
    HANDLE vCpu1 = ::CreateThread(nullptr, 0, threadFunc1, nullptr, 0, nullptr);
    HANDLE vCpu2 = ::CreateThread(nullptr, 0, threadFunc2, nullptr, 0, nullptr);

    Sleep(3000);
    
    ::SuspendThread(vCpu1);
    ::SuspendThread(vCpu2);

    ::ResumeThread(vCpu1);
    ::ResumeThread(vCpu2);

    Sleep(3000);

    g_threadRun = false;
}

보다시피 코드는 어렵지 않으며 단지 2개의 Thread 함수를 생성, 3초 후에 잠시 멈췄다가 다시 실행을 반복해 3초 후에 응용 프로그램을 종료합니다. 따라서, 대충 다음과 같은 식의 출력 결과를 볼 수 있습니다.

threadFunc1: 2844
threadFunc2: 10948
threadFunc1: 2844
threadFunc2: 10948
...

이제 한 가지 가정을 해봅니다. vCpu1 스레드가 가상 CPU1, vCpu2 스레드가 가상 CPU2라고 가정하는 것입니다. 그래서, 가상 CPU1이 처음에는 threadFunc1을, 가상 CPU2가 threadFunc2를 실행하다가 (임의로 만든) 스레드 스케줄러가 구동되면 가상 CPU1에 threadFunc2가, 가상 CPU2에 threadFunc1이 할당이 된 것을 구현하는 것입니다.

자, 그럼 SuspendThread와 ResumeThread 사이에 GetThreadContext/SetThreadContext를 이용해 2개의 스레드 Context를 통째로 바꿔보겠습니다.

volatile bool g_switched = false;

DWORD WINAPI threadFunc1(LPVOID lpThreadParameter)
{
    ::printf("threadFunc1: %d\n", GetCurrentThreadId());

    while (g_threadRun)
    {
        Sleep(1000);

        if (g_switched == true)
        {
            ::printf("[switch] threadFunc1: %d\n", GetCurrentThreadId());
        }
    }

    return 0;
}

DWORD WINAPI threadFunc2(LPVOID lpThreadParameter)
{
    ::printf("[switch] threadFunc2: %d\n", GetCurrentThreadId());

    while (g_threadRun)
    {
        Sleep(1000);

        if (g_switched == true)
        {
            ::printf("[switch] threadFunc2: %d\n", GetCurrentThreadId());
        }
    }

    return 0;
}

int main()
{
    // ...[생략]...

    Sleep(3000);
    
    ::SuspendThread(vCpu1);
    ::SuspendThread(vCpu2);

    CONTEXT ctxThread1 = { 0 };
    CONTEXT ctxThread2 = { 0 };

    ctxThread1.ContextFlags = CONTEXT_ALL;
    ctxThread2.ContextFlags = CONTEXT_ALL;

    ::GetThreadContext(vCpu1, &ctxThread1);
    ::GetThreadContext(vCpu2, &ctxThread2);

    ::SetThreadContext(vCpu1, &ctxThread1);
    ::SetThreadContext(vCpu2, &ctxThread2);

    g_switched = true;

    ::ResumeThread(vCpu1);
    ::ResumeThread(vCpu2);

    // ...[생략]...
}

실행하면 어떻게 될까요? 아쉽게도 현재는 이런 예외가 발생합니다.

Unhandled exception at 0x00007FFD3FFB5C88 (ucrtbased.dll) in ConsoleApplication1.exe: 0xC00000FD: Stack overflow (parameters: 0x0000000000000001, 0x0000003151103000).

다소 뜬금없지만 Stack overflow가 다음의 호출 스택상에서 발생합니다.

>    ucrtbased.dll!__chkstk() Line 109   Unknown Symbols loaded.
    ucrtbased.dll!write_text_ansi_nolock(const int fh, const char * const buffer, const unsigned int buffer_size) Line 381  C++ Symbols loaded.
    ucrtbased.dll!_write_nolock(int fh=0x00000001, const void * buffer=0x0000019d6b0e6a40, unsigned int buffer_size=0x00000033) Line 667    C++ Symbols loaded.
    ucrtbased.dll!_write(int fh=0x00000001, const void * buffer=0x0000019d6b0e6a40, unsigned int size=0x00000033) Line 64   C++ Symbols loaded.
    // ...[생략]...
    ConsoleApplication1.exe!printf(const char * const _Format=0x00007ff7df71bdb0, ...) Line 960 C++ Symbols loaded.
    ConsoleApplication1.exe!threadFunc1(void * lpThreadParameter=0x0000000000000000) Line 34    C++ Symbols loaded.
    kernel32.dll!BaseThreadInitThunk() Unknown Symbols loaded.
    ntdll.dll!RtlUserThreadStart() Unknown Symbols loaded.




일단 직접적인 오류 원인은 __chkstk에서의 스택 검증 코드 때문인데요, 왜 이런 오류가 발생하는지는 Visual Studio IDE 내에서 Disassembly 창을 통해 역어셈블한 코드를 보면 알 수 있습니다.

--- VCCRT\vcstartup\src\misc\amd64\chkstk.asm ----------------------------------
00007FFD328A5C50 48 83 EC 10          sub         rsp,10h  
00007FFD328A5C54 4C 89 14 24          mov         qword ptr [rsp],r10  
00007FFD328A5C58 4C 89 5C 24 08       mov         qword ptr [rsp+8],r11  
00007FFD328A5C5D 4D 33 DB             xor         r11,r11  
00007FFD328A5C60 4C 8D 54 24 18       lea         r10,[rsp+18h]  
00007FFD328A5C65 4C 2B D0             sub         r10,rax  
00007FFD328A5C68 4D 0F 42 D3          cmovb       r10,r11  
00007FFD328A5C6C 65 4C 8B 1C 25 10 00 00 00 mov         r11,qword ptr gs:[10h]  
00007FFD328A5C75 4D 3B D3             cmp         r10,r11  
00007FFD328A5C78 F2 73 17             bnd jae     cs10+11h (07FFD328A5C92h)  
00007FFD328A5C7B 66 41 81 E2 00 F0    and         r10w,0F000h  
00007FFD328A5C81 4D 8D 9B 00 F0 FF FF lea         r11,[r11-1000h]  
00007FFD328A5C88 41 C6 03 00          mov         byte ptr [r11],0  
00007FFD328A5C8C 4D 3B D3             cmp         r10,r11  
00007FFD328A5C8F F2 75 EF             bnd jne     cs10 (07FFD328A5C81h)  
00007FFD328A5C92 4C 8B 14 24          mov         r10,qword ptr [rsp]
00007FFD328A5C96 4C 8B 5C 24 08       mov         r11,qword ptr [rsp+8]  
00007FFD328A5C9B 48 83 C4 10          add         rsp,10h  
00007FFD328A5C9F F2 C3                bnd ret  

위에서 보면 gs:[10h]을 접근하는 것이 보이는데, 관련해서 다음의 글에서 정보를 얻을 수 있습니다.

What is the purpose of the _chkstk() function?
; https://stackoverflow.com/questions/8400118/what-is-the-purpose-of-the-chkstk-function

Win32 Thread Information Block
; https://en.wikipedia.org/wiki/Win32_Thread_Information_Block

pointer FS:[0x04]   GS:[0x08]   Win9x and NT    Stack Base / Bottom of stack (high address)
pointer FS:[0x08]   GS:[0x10]   Win9x and NT    Stack Limit / Ceiling of stack (low address)

그러니까, ucrtbased.dll!__chkstk 함수는 Stack 상황에 대한 유효성을 검사하는데, 그 과정에서 GS:[0x10], 즉 TEBStackLimit 값을 활용하고 있는 것입니다.

아울러, __chkstk 함수는 printf 호출로 발생한 것이므로 (__chkstk을 포함하지 않는) 코드만을 실행하면 위와 같은 예외는 발생하지 않습니다.




그런데, 왜 저것이 문제가 되는 걸까요? 이것은 그림으로 보면 더 쉽게 이해가 됩니다. 예를 들어, 아래는 스레드 문맥 교환을 하기 전의 상태를 나타냅니다.

thread_context_1.png

저 상태에서 스레드 문맥 교환을 하면, 상대방의 RSP 레지스터를 취하게 되므로 다음의 그림과 같이 바뀌게 됩니다.

thread_context_2.png

명확하죠? ^^ 스레드 자체가 가지고 있는 TEB(Thread Environment Block) 정보가 context switch 이후의 상황과 맞지 않아서 발생하는 것입니다.

여기서 재미있는 것은, 이번 글의 예제에서 생성한 2개의 스레드 중 두 번째의 것을 주석 처리하면,

::ResumeThread(vCpu1);
// ::ResumeThread(vCpu2); // 두 번째 스레드의 동작은 crash 발생

crash가 발생하지 않습니다. 그 이유는, __chkstk이 스택의 정합성을 stackBase와 stackLimit 범위로 결정하지 않기 때문입니다. 다시 chkstak.asm의 코드를 보면,

...[생략]...
00007FFD328A5C68 4D 0F 42 D3          cmovb       r10,r11  
00007FFD328A5C6C 65 4C 8B 1C 25 10 00 00 00 mov         r11,qword ptr gs:[10h]  
00007FFD328A5C75 4D 3B D3             cmp         r10,r11
00007FFD328A5C78 F2 73 17             bnd jae     cs10+11h (07FFD328A5C92h)
00007FFD328A5C7B 66 41 81 E2 00 F0    and         r10w,0F000h  
00007FFD328A5C81 4D 8D 9B 00 F0 FF FF lea         r11,[r11-1000h]  
00007FFD328A5C88 41 C6 03 00          mov         byte ptr [r11],0  
00007FFD328A5C8C 4D 3B D3             cmp         r10,r11  
00007FFD328A5C8F F2 75 EF             bnd jne     cs10 (07FFD328A5C81h)  
00007FFD328A5C92 4C 8B 14 24          mov         r10,qword ptr [rsp]  
00007FFD328A5C96 4C 8B 5C 24 08       mov         r11,qword ptr [rsp+8]  
00007FFD328A5C9B 48 83 C4 10          add         rsp,10h  
00007FFD328A5C9F F2 C3                bnd ret  

r11에 담긴 stackLimit와 r10에 담긴 RSP 값을 비교해 RSP 값이 크거나 같으면(CF=0) 0x00007FFD328A5C92 주소로 곧바로 점프해 그 사이의 (crash를 유발하는) 코드를 생략합니다. 따라서 위의 두 번재 그림을 보면 둘 중 하나의 스레드는 RSP 값이 stackLimit보다 크게 되고 그 스레드에 한해서는 __chkstk의 crash 코드를 피하게 되는 것입니다.

자, 그럼 crash가 하필 Access Violation도 아니고 왜 StackOverflow인지 궁금한데요, 이것은 위의 jae 점프 문이 동작하지 않았을 때 실행되는 중간 코드를 보면 알 수 있습니다.

00007FFD2D9C5C81  lea         r11,[r11-1000h]  
00007FFD2D9C5C88  mov         byte ptr [r11],0  
00007FFD2D9C5C8C  cmp         r10,r11  
00007FFD2D9C5C8F  bnd jne     cs10 (07FFD2D9C5C81h)

위의 코드는 (r10에 담긴) RSP 값을 (앞선 코드에서 실행한 "and r10w,0F000h"로 인해) 4KB 정렬된 값을 stackLimit와 비교해 같을 때까지 계속해서 stackLimit 값을 (아래로) 늘리기 때문입니다. 즉, stackLimit에서 계속 4KB씩 스택이 자라면서 결국 상한(기본값 - 32비트 1MB / 64비트 4MB)을 넘게 돼 stack-overflow 예외가 발생하는 것입니다.




자, 이렇게 해서 원인 파악을 제대로 했으니 이제 문제를 수정할 차례입니다. 당연히, 우리가 해야 할 것은 해당 스레드의 stackBase와 stackLimit 값을 서로의 값으로 바꾸면 되는데, 이것은 다음의 Q&A에서 공개한 코드를 통해 해결할 수 있습니다.

Getting the TIB/TEB of a Thread by it's Thread Handle (2015)
; https://stackoverflow.com/questions/32297431/getting-the-tib-teb-of-a-thread-by-its-thread-handle-2015

따라서 대충 이런 식으로 Context Switch와 함께 추가하면,

// ...[생략]...

int main()
{   
    // ...[생략]...

    SwitchStackInfoInTEB(vCpu1, vCpu2);

    ::SetThreadContext(vCpu2, &amp;ctxThread1);
    ::SetThreadContext(vCpu1, &amp;ctxThread2);
    // ...[생략]...
}

void SwitchStackInfoInTEB(HANDLE vCpu1, HANDLE vCpu2)
{
    DWORD64 stackBase1, stackLimit1;
    DWORD64 stackBase2, stackLimit2;
    void* teb1 = GetThreadStackTopAddress(GetCurrentProcess(), vCpu1, &stackBase1, &stackLimit1);
    void* teb2 = GetThreadStackTopAddress(GetCurrentProcess(), vCpu2, &stackBase2, &stackLimit2);

    *(DWORD64*)((BYTE*)teb1 + 0x08) = stackBase2;
    *(DWORD64*)((BYTE*)teb1 + 0x10) = stackLimit2;

    *(DWORD64*)((BYTE*)teb2 + 0x08) = stackBase1;
    *(DWORD64*)((BYTE*)teb2 + 0x10) = stackLimit1;
}

// https://stackoverflow.com/questions/32297431/getting-the-tib-teb-of-a-thread-by-its-thread-handle-2015
void* GetThreadStackTopAddress(HANDLE hProcess, HANDLE hThread, /* OUT */ DWORD64* pStackBase, /* OUT */ DWORD64* pStackLimit)
{
    HMODULE module = GetModuleHandle(L"ntdll.dll");

    if (module == nullptr)
    {
        return nullptr;
    }

    NTSTATUS(__stdcall * NtQueryInformationThread)(HANDLE ThreadHandle, THREADINFOCLASS ThreadInformationClass, PVOID ThreadInformation, ULONG ThreadInformationLength, PULONG ReturnLength);
    NtQueryInformationThread = reinterpret_cast<decltype(NtQueryInformationThread)>(GetProcAddress(module, "NtQueryInformationThread"));

    if (NtQueryInformationThread)
    {
        NT_TIB tib = { 0 };
        THREAD_BASIC_INFORMATION tbi = { 0 };

        NTSTATUS status = NtQueryInformationThread(hThread, ThreadBasicInformation, &tbi, sizeof(tbi), nullptr);
        if (status >= 0)
        {
            ReadProcessMemory(hProcess, tbi.TebBaseAddress, &tib, sizeof(tbi), nullptr);

            *pStackBase = (DWORD64)tib.StackBase;
            *pStackLimit = (DWORD64)tib.StackLimit;

            return tbi.TebBaseAddress;
        }
    }

    return nullptr;
}

정상적인 실행 결과를 얻을 수 있습니다. ^^

threadFunc1: 396
threadFunc2: 13496
[switch] threadFunc1: 13496
[switch] threadFunc2: 396
...[생략]...

결국 OS의 Context Switch는 CPU 레지스터의 문맥 교환뿐 아니라 TEB 영역의 스택 정보까지 보정하는 역할을 하고 있는 것입니다. 이런 면에서 생각했을 때 엄밀히 위의 코드는 안정적인 실행을 보장하지 않습니다. 왜냐하면 TEB의 또 어떤 값이 업데이트 되어야 하는지에 대한 정보가 없어 자칫 (__chkstk처럼) 그 영역을 접근하는 데이터가 있다면 정합성이 맞지 않아 마찬가지로 crash 등의 문제가 발생할 수 있기 때문입니다.

(첨부 파일은 이 글의 예제 코드를 포함합니다.)




마지막으로, 한 가지 더 실험을 해볼까요? ^^

이번 글의 예제처럼, 스레드 1과 스레드 2가 실행하는 함수의 구조가 동일하고 문맥 변경을 하는 순간의 위치가 같다면 (TEB 정보를 바꾸지 않고) RSP 레지스터를 보존하는 식으로 수행해도 다행히 실행 중 crash 문제는 벗어납니다.

::GetThreadContext(vCpu1, &ctxThread1);
::GetThreadContext(vCpu2, &ctxThread2);

DWORD64 rsp1 = ctxThread1.Rsp;
ctxThread1.Rsp = ctxThread2.Rsp;
ctxThread2.Rsp = rsp1;

// SwitchStackInfoInTEB(vCpu1, vCpu2);

::SetThreadContext(vCpu2, &ctxThread1);
::SetThreadContext(vCpu1, &ctxThread2);

재미있는 것은, 저렇게 했을 때의 출력 결과를 보면 마치 SetThreadContext 호출을 하지 않은 것처럼 동작을 합니다.

threadFunc1: 22344
threadFunc2: 1872
[switch] threadFunc1: 22344
[switch] threadFunc2: 1872
// ...[생략]...

보는 바와 같이 threadFunc1과 2를 실행하는 Thread ID가 동일합니다. 이런 결과가 나온 것은, 문맥 변경 시 2개의 스레드 모두 Kernel로 진입하는 API를 호출했기 때문입니다. 예제에서처럼 Sleep의 경우 NtDelayExecution 함수에서 syscall을 이용해 커널로 진입하게 되는데,

--- NtDelayExecution
...[생략]...
00007FFDCBFED488  nop         dword ptr [rax+rax]  
00007FFDCBFED490  mov         r10,rcx  
00007FFDCBFED493  mov         eax,34h  
00007FFDCBFED498  test        byte ptr [7FFE0308h],1  
00007FFDCBFED4A0  jne         NtDelayExecution+15h (07FFDCBFED4A5h)  
00007FFDCBFED4A2  syscall  
00007FFDCBFED4A4  ret  

syscall 명령어는 사용자 모드의 RIP를 커널 진입/탈출 시 보존/복원하기 때문에,

SYSCALL — Fast System Call
; https://www.felixcloutier.com/x86/syscall

우리가 수행했던 SetThreadContext로 인한 RIP 변경은 커널에서의 Sleep 동작이 끝나 반환되면서 무효화됩니다. 또한 syscall 명령어 자체는 RSP 레지스터에 대한 보존/복원은 하지 않지만, 대신 윈도우 운영체제 측의 KiSystemCall64에서 이것을 담당합니다.

// https://github.com/DarthTon/HyperBone/blob/master/src/Hooks/Syscall.asm
// https://stackoverflow.com/questions/65367333/breakpoint-setting-in-ntkisystemcall64-not-working
0: kd> u fffff802`3681e6c0
nt!KiSystemCall64:
fffff802`3681e6c0 0f01f8          swapgs
fffff802`3681e6c3 654889242510000000 mov   qword ptr gs:[10h],rsp
fffff802`3681e6cc 65488b2425a8010000 mov   rsp,qword ptr gs:[1A8h]
fffff802`3681e6d5 6a2b            push    2Bh
fffff802`3681e6d7 65ff342510000000 push    qword ptr gs:[10h]
fffff802`3681e6df 4153            push    r11
fffff802`3681e6e1 6a33            push    33h
fffff802`3681e6e3 51              push    rcx

위의 코드를 보면, 값의 저장을 gs:[10h]에 하고 있지만 이때의 gs는 TEB 영역을 가리키는 것이 아니고 그 이전의 swapgs 때문에 kernel 영역의 MSR이 가리키는 주솟값으로 바뀌게 됩니다. 즉 별도로 커널 영역에 사용자 모드의 RSP 값을 보관한 후, 커널에서의 작업을 마치고 나서 다시 사용자 모드의 RSP 값을 swapgs/gs:[10h]로 복원을 해줍니다.

결국 Kernel로 진입한 동안에 스레드 문맥을 (RSP를 포함해) 교환하면 해당 함수 호출이 완료된 후에는 이전에 보관해 두었던 RSP/RIP가 복원되기 때문에 스레드 문맥 교환이 발생하지 않은 것처럼 동작하게 되는 것입니다.

이에 대한 검증은, 스레드 함수에 아래와 같이 커널 진입을 하지 않는 코드로 확인할 수 있습니다.

DWORD WINAPI threadFunc1(LPVOID lpThreadParameter)
{
    ::printf("threadFunc1: %d\n", GetCurrentThreadId());

    while (g_switched == false) // 사용자 모드에서 문맥 교환이 될 때까지 무한 루프
    {
        static int i = 0;
        i ++;
    }

    while (g_threadRun)
    {
        Sleep(1000);

        if (g_switched == true)
        {
            ::printf("[switch] threadFunc1: %d\n", GetCurrentThreadId());
        }
    }

    return 0;
}

DWORD WINAPI threadFunc2(LPVOID lpThreadParameter)
{
    ::printf("threadFunc2: %d\n", GetCurrentThreadId());

    while (g_switched == false) // 사용자 모드에서 문맥 교환이 될 때까지 무한 루프
    {
        static int i = 0;
        i ++;
    }

    while (g_threadRun)
    {
        Sleep(1000);

        if (g_switched == true)
        {
            ::printf("[switch] threadFunc2: %d\n", GetCurrentThreadId());
        }
    }

    return 0;
}

그럼, while 루프 동안에 Context Switch가 발생하게 되고, 출력 결과를 보면 스레드 ID가 바뀌어 있습니다.

threadFunc1: 1904
threadFunc2: 3164
[switch] threadFunc1: 3164
[switch] threadFunc2: 1904
...[생략]...
F:\ConsoleApp1\x64\Debug\ConsoleApp1.exe (process 19572) exited with code -1073740791.

그렇긴 해도, 보는 바와 같이 스레드의 실행 자체는 되었지만 exit code가 0xC0000409(-1073740791)로 비정상 종료를 합니다.




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

[연관 글]






[최초 등록일: ]
[최종 수정일: 7/6/2021]

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

비밀번호

댓글 작성자
 




1  2  3  4  5  6  7  8  9  [10]  11  12  13  14  15  ...
NoWriterDateCnt.TitleFile(s)
13370정성태6/14/20233288개발 환경 구성: 680. C# - Ubuntu + Microsoft.Data.SqlClient + SQL Server 2008 R2 연결 방법 - TLS 1.2 지원
13369정성태6/13/20233085개발 환경 구성: 679. PyCharm(을 비롯해 JetBrains에 속한 여타) IDE에서 내부 Window들의 탭이 없어진 경우
13368정성태6/13/20233222개발 환경 구성: 678. openssl로 생성한 인증서를 SQL Server의 암호화 인증서로 설정하는 방법
13367정성태6/10/20233321오류 유형: 864. openssl로 만든 pfx 인증서를 Windows Server 2016 이하에서 등록 시 "The password you entered is incorrect" 오류 발생
13366정성태6/10/20233111.NET Framework: 2128. C# - 윈도우 시스템에서 지원하는 암호화 목록(Cipher Suites) 나열파일 다운로드1
13365정성태6/8/20232883오류 유형: 863. MODIFY FILE encountered operating system error 112(failed to retrieve text for this error. Reason: 15105)
13364정성태6/8/20233665.NET Framework: 2127. C# - Ubuntu + Microsoft.Data.SqlClient + SQL Server 2008 R2 연결 방법 [1]
13363정성태6/7/20233233스크립트: 49. 파이썬 - "Transformers (신경망 언어모델 라이브러리) 강좌" - 1장 2절 코드 실행 결과
13362정성태6/1/20233153.NET Framework: 2126. C# - 서버 측의 요청 제어 (Microsoft.AspNetCore.RateLimiting)파일 다운로드1
13361정성태5/31/20233609오류 유형: 862. Facebook - ASP.NET/WebClient 사용 시 graph.facebook.com/me 호출에 대해 403 Forbidden 오류
13360정성태5/31/20233020오류 유형: 861. WSL/docker - failed to start shim: start failed: io.containerd.runc.v2: create new shim socket
13359정성태5/19/20233341오류 유형: 860. Docker Desktop - k8s 초기화 무한 반복한다면?
13358정성태5/17/20233633.NET Framework: 2125. C# - Semantic Kernel의 Semantic Memory 사용 예제 [1]파일 다운로드1
13357정성태5/16/20233457.NET Framework: 2124. C# - Semantic Kernel의 Planner 사용 예제파일 다운로드1
13356정성태5/15/20233751DDK: 10. Device Driver 테스트 설치 관련 오류 (Code 37, Code 31) 및 인증서 관련 정리
13355정성태5/12/20233663.NET Framework: 2123. C# - Semantic Kernel의 ChatGPT 대화 구현 [1]파일 다운로드1
13354정성태5/12/20233925.NET Framework: 2122. C# - "Use Unicode UTF-8 for worldwide language support" 설정을 한 경우, 한글 입력이 '\0' 문자로 처리
13352정성태5/12/20233544.NET Framework: 2121. C# - Semantic Kernel의 대화 문맥 유지파일 다운로드1
13351정성태5/11/20234055VS.NET IDE: 185. Visual Studio - 원격 Docker container 내에 실행 중인 응용 프로그램에 대한 디버깅 [1]
13350정성태5/11/20233317오류 유형: 859. Windows Date and Time - Unable to continue. You do not have permission to perform this task
13349정성태5/11/20233641.NET Framework: 2120. C# - Semantic Kernel의 Skill과 Function 사용 예제파일 다운로드1
13348정성태5/10/20233566.NET Framework: 2119. C# - Semantic Kernel의 "Basic Loading of the Kernel" 예제
13347정성태5/10/20233924.NET Framework: 2118. C# - Semantic Kernel의 Prompt chaining 예제파일 다운로드1
13346정성태5/10/20233757오류 유형: 858. RDP 원격 환경과 로컬 PC 간의 Ctrl+C, Ctrl+V 복사가 안 되는 문제
13345정성태5/9/20235018.NET Framework: 2117. C# - (OpenAI 기반의) Microsoft Semantic Kernel을 이용한 자연어 처리 [1]파일 다운로드1
13344정성태5/9/20236306.NET Framework: 2116. C# - OpenAI API 사용 - 지원 모델 목록 [1]파일 다운로드1
1  2  3  4  5  6  7  8  9  [10]  11  12  13  14  15  ...