Microsoft MVP성태의 닷넷 이야기
글쓴 사람
정성태 (techsharer at outlook.com)
홈페이지
첨부 파일
 

async/await 사용시 hang 문제가 발생하는 경우 - 두번째 이야기

오호~~~ 재미있는 글이 쓰여졌군요. ^^

SynchronizationContext와 async/await - 1. What the?
; http://blog.naver.com/vactorman/220371851151

SynchronizationContext와 async/await - 2. 얉은 구조
; http://blog.naver.com/vactorman/220371861151

SynchronizationContext와 async/await - 3. Custom SynchronizationContext 구현
; http://blog.naver.com/vactorman/220371881666

SynchronizationContext와 async/await - 4. async/await 궁합 
; http://blog.naver.com/vactorman/220371896727

4번째 "SynchronizationContext와 async/await - 4. async/await 궁합 " 글에 보면 제가 쓴 다음의 글도 소개하고 있습니다.

async/await 사용시 hang 문제가 발생하는 경우
; https://www.sysnet.pe.kr/2/0/1541

그런데, 4번째 글에서 언급하고 있는 내용이 제 글의 내용과 충돌합니다. 제 글에서는 스레드 블록킹 현상의 원인이 "SynchronizationContext"의 "Post"처리를 하지 않고 "Send" 처리를 했기 때문이라고 했는데 "SynchronizationContext와 async/await - 4. async/await 궁합 " 글에서는 "async/await 사용 시 await 이하 구문은 현재 SynchronizationContext의 Post()로 재진입하여 수행된다"라고 언급합니다.

분명히 누구 하나는 틀린 이야기를 하고 있습니다. ^^




그래서 천천히 검증을 한번 해봤습니다. ^^ 우선, 소스 코드 디버깅을 하기 위해 cmd.exe 창을 띄우고 아래의 글에 따라,

COMPLUS_ZapDisable - JIT 최적화 코드 생성 제어
; https://www.sysnet.pe.kr/2/0/646

다음과 같이 최적화 옵션을 끄고 개발 환경을 실행했습니다. (F5 디버깅을 위해!)

C:\Program Files (x86)\Microsoft Visual Studio 12.0>set COMPLUS_ZapDisable=1
C:\Program Files (x86)\Microsoft Visual Studio 12.0>devenv

그런 다음 ".NET Reflector"의 소스 코드 디버깅 옵션을 mscorlib.dll, System.dll, System.Core.dll System.Windows.Forms.dll 들에 대해 "Generate PDB"를 적용해 두었습니다.

자... 그래서 "async/await 사용시 hang 문제가 발생하는 경우"의 글에 첨부한 예제 프로젝트를 실행시켜 hang이 걸리게 실행한 다음 콜 스택을 통해 실행이 멈춘 지점을 확인해 보았습니다. "this.Text = textTask.Result" 코드의 실행 부분의 콜 스택에 다음의 InternalWait 호출을 확인할 수 있습니다.

[System.Threading.Tasks.Task<TResult>]

        internal TResult GetResultCore(bool waitCompletionNotification)
        {
            if (!base.IsCompleted)
            {
                base.InternalWait(-1, new CancellationToken());
            }
            if (waitCompletionNotification)
            {
                base.NotifyDebuggerOfWaitCompletionIfNecessary();
            }
            if (!base.IsRanToCompletion)
            {
                base.ThrowIfExceptional(true);
            }
            return this.m_result;
        }

InternalWait 함수를 내려가 보면,

[System.Threading.Tasks.Task]

        [MethodImpl(MethodImplOptions.NoOptimization)]
        internal bool InternalWait(int millisecondsTimeout, System.Threading.CancellationToken cancellationToken)
        {
            TplEtwProvider log = TplEtwProvider.Log;
            bool flag = log.IsEnabled();
            if (flag)
            {
                Task internalCurrent = InternalCurrent;
                log.TaskWaitBegin((internalCurrent != null) ? internalCurrent.m_taskScheduler.Id : TaskScheduler.Default.Id, (internalCurrent != null) ? internalCurrent.Id : 0, this.Id, TplEtwProvider.TaskWaitBehavior.Synchronous);
            }
            bool isCompleted = this.IsCompleted;
            if (!isCompleted)
            {
                Debugger.NotifyOfCrossThreadDependency();
                if (((millisecondsTimeout == -1) && !cancellationToken.CanBeCanceled) && (this.WrappedTryRunInline() && this.IsCompleted))
                {
                    isCompleted = true;
                }
                else
                {
                    isCompleted = this.SpinThenBlockingWait(millisecondsTimeout, cancellationToken);
                }
            }
            if (flag)
            {
                Task task2 = InternalCurrent;
                if (task2 != null)
                {
                    log.TaskWaitEnd(task2.m_taskScheduler.Id, task2.Id, this.Id);
                    return isCompleted;
                }
                log.TaskWaitEnd(TaskScheduler.Default.Id, 0, this.Id);
            }
            return isCompleted;
        }

this.SpinThenBlockingWait 호출로 이어지고,

[System.Threading.Tasks.Task]

        private bool SpinThenBlockingWait(int millisecondsTimeout, System.Threading.CancellationToken cancellationToken)
        {
            bool flag = millisecondsTimeout == -1;
            uint num = flag ? 0 : ((uint) Environment.TickCount);
            bool flag2 = this.SpinWait(millisecondsTimeout);
            if (!flag2)
            {
                SetOnInvokeMres action = new SetOnInvokeMres();
                try
                {
                    this.AddCompletionAction(action, true);
                    if (flag)
                    {
                        return action.Wait(-1, cancellationToken);
                    }
                    uint num2 = ((uint) Environment.TickCount) - num;
                    if (num2 < millisecondsTimeout)
                    {
                        flag2 = action.Wait((int) (millisecondsTimeout - ((int) num2)), cancellationToken);
                    }
                }
                finally
                {
                    if (!this.IsCompleted)
                    {
                        this.RemoveContinuation(action);
                    }
                }
            }
            return flag2;
        }

보는 바와 같이 SpinThenBlockingWait 내에서는 SetOnInvokeMres 타입의 인스턴스를 생성한 후 AddCompletionAction을 호출 한후 action.Wait 대기 상태로 빠집니다. 그럼, SetOnInvokeMres 타입과 AddCompletionAction으로 들어가 볼까요?

[System.Threading.Tasks.Task]

        private sealed class SetOnInvokeMres : ManualResetEventSlim, ITaskCompletionAction
        {
            internal SetOnInvokeMres() : base(false, 0)
            {
            }
            
            public void Invoke(Task completingTask)
            {
                base.Set();
            }
        }

        private void AddCompletionAction(ITaskCompletionAction action, bool addBeforeOthers) // addBeforeOthers == true
        {
            if (!this.AddTaskContinuation(action, addBeforeOthers)) // AddTaskContinuation 호출이 return == true 반환해서 메서드를 빠져나감.
            {
                action.Invoke(this); // 호출되지 않았음.
            }
        }

만약 위의 코드에서 AddTaskContinuation 메서드가 false를 반환했다면 action.Invoke가 불렸을 테고, SetOnInvokeMres.Invoke 메서드에서는 "base.Set()"의 호출로 이벤트를 Signal하기 때문에 이후의 action.Wait 대기가 곧바로 해제되어 hang 현상은 없었을 것입니다. 그럼, 다시 AddTaskContinuation을 들어가 보면,

[System.Threading.Tasks.Task]

        private bool AddTaskContinuation(object tc, bool addBeforeOthers)
        {
            if (this.IsCompleted)
            {
                return false;
            }
            return (((this.m_continuationObject == null) && (Interlocked.CompareExchange(ref this.m_continuationObject, tc, null) == null)) || this.AddTaskContinuationComplex(tc, addBeforeOthers));
        }

hang 현상이 발생했을 때의 코드 실행은 this.m_continuationObject == null인 상태로, Interlocked.CompareExchange 코드에 의해 tc (즉, SetOnInvokeMres 인스턴스) 객체를 this.m_continuationObject 에 할당하는 것으로 마무리를 지은 후 true를 반환합니다.

최종적으로는 결국, this.m_continuationObject 객체의 Invoke 메서드가 호출되지 않았기 때문에 발생하는 것입니다. 그렇다면 해당 객체의 Invoke 메서드는 언제 발생하는 것일까요? 추적해 보면 특별한 예외 상황이 발생하지 않는 경우를 제외한다면,

[System.Threading.Tasks.Task]

        [SecuritySafeCritical]
        internal void FinishContinuations()
        {
            object obj2 = Interlocked.Exchange(ref this.m_continuationObject, s_taskCompletionSentinel);
            TplEtwProvider.Log.RunningContinuation(this.Id, obj2);
            if (obj2 != null)
            {
                if (AsyncCausalityTracer.LoggingOn)
                {
                    AsyncCausalityTracer.TraceSynchronousWorkStart(CausalityTraceLevel.Required, this.Id, CausalitySynchronousWork.CompletionNotification);
                }
                bool allowInlining = (((this.m_stateFlags & 0x8000000) == 0) && (Thread.CurrentThread.ThreadState != ThreadState.AbortRequested)) && ((this.m_stateFlags & 0x40) == 0);
                Action action = obj2 as Action;
                if (action != null)
                {
                    AwaitTaskContinuation.RunOrScheduleAction(action, allowInlining, ref t_currentTask);
                    this.LogFinishCompletionNotification();
                }
                else
                {
                    ITaskCompletionAction action2 = obj2 as ITaskCompletionAction;
                    if (action2 != null)
                    {
                        action2.Invoke(this);
                        this.LogFinishCompletionNotification();
                    }
                    else
                    {
                        // ...[생략]...
                    }
                }
            }
        }

FinishContinuations 메서드가 나오고, 그것을 호출하는 메서드들은 FinishStageThree, FinishStageTwo를 이어 Finish 메서드까지 이어갑니다.

[System.Threading.Tasks.Task]

        internal void FinishStageThree()
        {
            this.m_action = null;
            if (((this.m_parent != null) && ((this.m_parent.CreationOptions & TaskCreationOptions.DenyChildAttach) == TaskCreationOptions.None)) && (((this.m_stateFlags & 0xffff) & 4) != 0))
            {
                this.m_parent.ProcessChildCompletion(this);
            }
            this.FinishContinuations();
        }
        
        internal void FinishStageTwo()
        {
            int num;
            this.AddExceptionsFromChildren();
            // ...[생략]...
            Interlocked.Exchange(ref this.m_stateFlags, this.m_stateFlags | num);
            ContingentProperties contingentProperties = this.m_contingentProperties;
            if (contingentProperties != null)
            {
                contingentProperties.SetCompleted();
                contingentProperties.DeregisterCancellationCallback();
            }
            this.FinishStageThree();
        }
        
        internal void Finish(bool bUserDelegateExecuted)
        {
            if (!bUserDelegateExecuted)
            {
                this.FinishStageTwo();
            }
            else
            {
                ContingentProperties contingentProperties = this.m_contingentProperties;
                if (((contingentProperties == null) || ((contingentProperties.m_completionCountdown == 1) && !this.IsSelfReplicatingRoot)) || (Interlocked.Decrement(ref contingentProperties.m_completionCountdown) == 0))
                {
                    this.FinishStageTwo();
                }
                else
                {
                    this.AtomicStateUpdate(0x800000, 0x1600000);
                }
                // ...[생략]...
            }
        }

마지막 최상위 호출은 ExecuteWithThreadLocal에서 이뤄집니다.

[System.Threading.Tasks.Task]

        [SecurityCritical]
        private void ExecuteWithThreadLocal(ref Task currentTaskSlot)
        {
            Task task = currentTaskSlot;
            TplEtwProvider log = TplEtwProvider.Log;
            Guid oldActivityThatWillContinue = new Guid();
            bool flag = log.IsEnabled();
            if (flag)
            {
                if (log.TasksSetActivityIds)
                {
                    EventSource.SetCurrentThreadActivityId(TplEtwProvider.CreateGuidForTaskID(this.Id), out oldActivityThatWillContinue);
                }
                if (task != null)
                {
                    log.TaskStarted(task.m_taskScheduler.Id, task.Id, this.Id);
                }
                else
                {
                    log.TaskStarted(TaskScheduler.Current.Id, 0, this.Id);
                }
            }
            if (AsyncCausalityTracer.LoggingOn)
            {
                AsyncCausalityTracer.TraceSynchronousWorkStart(CausalityTraceLevel.Required, this.Id, CausalitySynchronousWork.Execution);
            }
            try
            {
                currentTaskSlot = this;
                ExecutionContext capturedContext = this.CapturedContext;
                if (capturedContext == null)
                {
                    this.Execute();
                }
                else
                {
                    if (this.IsSelfReplicatingRoot || this.IsChildReplica)
                    {
                        this.CapturedContext = CopyExecutionContext(capturedContext);
                    }
                    ContextCallback callback = s_ecCallback;
                    if (callback == null)
                    {
                        s_ecCallback = callback = new ContextCallback(Task.ExecutionContextCallback);
                    }
                    ExecutionContext.Run(capturedContext, callback, this, true);
                }
                if (AsyncCausalityTracer.LoggingOn)
                {
                    AsyncCausalityTracer.TraceSynchronousWorkCompletion(CausalityTraceLevel.Required, CausalitySynchronousWork.Execution);
                }
                this.Finish(true);
            }
            finally
            {
                currentTaskSlot = task;
                if (flag)
                {
                    if (task != null)
                    {
                        log.TaskCompleted(task.m_taskScheduler.Id, task.Id, this.Id, this.IsFaulted);
                    }
                    else
                    {
                        log.TaskCompleted(TaskScheduler.Current.Id, 0, this.Id, this.IsFaulted);
                    }
                    if (log.TasksSetActivityIds)
                    {
                        EventSource.SetCurrentThreadActivityId(oldActivityThatWillContinue);
                    }
                }
            }
        }

ExecuteWithThreadLocal이 실행될 때의 callstack을 보면,

mscorlib.dll!System.Threading.Tasks.Task.ExecuteWithThreadLocal(ref System.Threading.Tasks.Task currentTaskSlot) Line 1003   
mscorlib.dll!System.Threading.Tasks.Task.ExecuteEntry(bool bPreventDoubleExecution) Line 929     
mscorlib.dll!System.Threading.Tasks.Task.System.Threading.IThreadPoolWorkItem.ExecuteWorkItem() Line 2217    
mscorlib.dll!System.Threading.ThreadPoolWorkQueue.Dispatch() Line 119    
mscorlib.dll!System.Threading._ThreadPoolWaitCallback.PerformWaitCallback() Line 12  

_ThreadPoolWaitCallback.PerformWaitCallback이 루트인데, 결국 ThreadPool의 작업자 스레드의 하나로 실행된 것입니다. 이 스레드는 await로 인해 최초 비동기 호출을 했을 때의 바로 그 스레드입니다.

public async Task<string> GetHtmlTextAsync()
{
    string result = await GetTextAsync();
    return result;
}

여기까지 정리한 것을 우리가 만들었던 C# 소스코드에 적용해 설명해 보면, 대충 이렇습니다.

private void Form1_Load(object sender, EventArgs e)
{
    var textTask = GetHtmlTextAsync(); // testTask는 Task 인스턴스이고,
              // testTask 인스턴스에 할당된 스레드는 _ThreadPoolWaitCallback.PerformWaitCallback ~ ExecuteWithThreadLocal 호출의 작업자 스레드임
    this.Text = textTask.Result;
              // 이곳에서 action.Wait의 호출로 Task 작업자 스레드가 모든 작업이 완료될때까지 대기.
}

public async Task<string> GetHtmlTextAsync()
{
    string result = await GetTextAsync(); // _ThreadPoolWaitCallback.PerformWaitCallback 작업자 스레드에 의해
                    // Task.Factory.StartNew에 전달된 익명 함수를 실행하고,

                    // 나머지 다음의 작업을,
                    //    string result = ...
                    //    return result;
                    // _ThreadPoolWaitCallback.PerformWaitCallback을 실행하던 작업자 스레드가 마저 실행해야 함.
                    // 그런데, 윈도우 폼 응용 프로그램에는 미리 설정된 SynchronizationContext가 있으므로,
                    // _ThreadPoolWaitCallback.PerformWaitCallback을 실행하던 작업자 스레드가 나머지 작업을 직접 실행하지 못하고,
                    // WindowsFormsSynchronizationContext.Post로 전달하고,
                    // 이 단계에서 Task의 작업자 스레드는 모든 처리를 완료했으므로 스레드 풀에 반환됨.
    return result;                      
}

public Task<string> GetTextAsync()
{
    return Task.Factory.StartNew(
    () =>
    {
        Thread.Sleep(1000);
        return "Hello World";
    });
}

자, 여기가 문제입니다. 저는 WindowsFormsSynchronizationContext의 Post가 아닌 Send로 했기 때문에 블록킹이 발생했을 거라고 예상했는데요. 반면, "SynchronizationContext와 async/await - 4. async/await 궁합" 글에서는 await은 Post로 전달한다고 쓰여 있는 것입니다. 확인은 WindowsFormsSynchronizationContext.Post 메서드에 BP를 거는 것으로 금방 알 수 있습니다. 그리고, 결과는 실제로 ^^; Send가 아닌 Post가 실행되었습니다.

synch_context_1.png

그렇습니다. Send가 아닌 Post가 맞습니다.




아니, 그럼 아래의 글에서 설명한 hang 현상의 원인은 도대체 무엇입니까?

async/await 사용시 hang 문제가 발생하는 경우
; https://www.sysnet.pe.kr/2/0/1541

결론먼저 말하면 이 원인은, Send/Post의 문제가 아니고 어찌되었든 "this.Text = textTask.Result;" 코드의 호출로 인해 UI 스레드가 blocking이 되면서 WindowsFormsSynchronizationContext에서 Post로 전달해 준 await 이후의 분리된 코드를 호출하지 못해서 발생하는 것입니다. 이는 WindowsFormsSynchronizationContext.Post의 호출에 전달된 SendOrPostCallback 델리게이트 타입인 d 인자의 값을 보면 어느 정도 추측할 수 있습니다. 위의 그림에서 보면 "d.Target" 인자의 값이 "System.Threading.Tasks.SynchronizationContextAwaitTaskContinuation" 임을 알 수 있는데, 이때의 코드가 바로 await 이후로 분리된 코드인 것입니다. (정확히는, 분리된 코드는 MoveNext의 switch 코드의 하나이고, 그 MoveNext를 호출하고 Task.Finish를 호출해줄 코드가 담긴 "Void <_cctor>b__3(System.Object)" 메서드입니다.)

문제의 시나리오를 정리해 보면 이렇게 됩니다.

1. UI Thread가 Form1_Load를 실행
    1.1. UI Thread가 GetHtmlTextAsync 메서드를 실행
        1.1.1 UI Thread가 TestClass.GetTextAsync 메서드를 비동기로 실행
    1.2. UI Thread는 TestClass.GetTextAsync 메서드에 반환된 Task 인스턴스인 textTask의 get_Result 프로퍼티를 호출.
         내부적으로 Task 타입의 Result는 해당 Task가 담당한 모든 작업이 끝날 때 까지 대기
         여기서 Task가 담당한 작업은 == await 대상 코드 + await 이후의 코드

2. ThreadPool의 자유 스레드가 TestClass.GetTextAsync 메서드에서 반환한 Task에 할당됨.
    2.1. Task.Factory.StartNew에 넘겨진 익명 메서드를 실행
    2.2. 메서드 실행 후 결과 값 반환
    2.3. await 처리로 인해 분리된 "return result;" 코드를 SynchronizationContextAwaitTaskContinuation 델리게이트로 WindowsFormsSynchronizationContext.Post에 전달
    2.4  전달된 SynchronizationContextAwaitTaskContinuation 델리게이트 코드까지 실행되어야만 Task의 모든 작업이 완료되는데,
         UI Thread는 현재 Task 타입의 Result 호출로 인해 블록 상태이므로, WindowsFormsSynchronizationContext가 전달한 델리게이트를 실행하지 못함.

휴~~~ 이렇게 정리하고 나니, 속이 시원하군요. ^^ 암튼, "SynchronizationContext와 async/await - 4. async/await 궁합 " 글 덕분에 문제를 좀더 바로 보게 되어 다행입니다.




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

[연관 글]





[최초 등록일: ]
[최종 수정일: 6/15/2020 ]

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

비밀번호

댓글 쓴 사람
 



2015-06-01 04시55분
멋진 정리 감사합니다. async/await 의 사용에 이런 문제가 있었던지는 몰랐습니다.
모르는 상태에서 처음 보고 당황하게 될 많은 이들을(저 포함) 구제해주는 글이 아닐까 싶습니다.
Beren Ko
2015-06-01 03시24분
결국, 이 모든 문제의 근원은... 'UI를 만든 스레드만이 UI를 접근해야 한다'라서, 이런 속내를 알지 못하면 그야말로 황당한 hang 현상일 수 있습니다. (아마 모르긴 해도... 마이크로소프트도 이 기능을 넣으면서 꽤나 고민했을 듯 합니다. ^^)
정성태

[1]  2  3  4  5  6  7  8  9  10  11  12  13  14  15  ...
NoWriterDateCnt.TitleFile(s)
12379정성태10/21/202045.NET Framework: 955. .NET 메서드의 Signature 바이트 코드 분석파일 다운로드2
12378정성태10/20/2020129.NET Framework: 954. C# - x86/x64 환경에 따라 달라지는 P/Invoke 함수의 export 이름파일 다운로드1
12377정성태10/15/2020140디버깅 기술: 172. windbg - 파일 열기 시점에 bp를 걸어 파일명 알아내는 방법(Managed/Unmanaged)
12376정성태10/15/202048오류 유형: 669. windbg - sos의 name2ee 명령어 실행 시 "Failed to request module list." 오류
12375정성태10/15/2020172Windows: 177. 윈도우 탐색기에서 띄우는 cmd.exe 창의 디렉터리 구분 문자가 'Yen(&#0165;)' 기호로 나오는 경우 [1]
12374정성태10/14/2020141.NET Framework: 953. C# 9.0 - (6) Function pointers파일 다운로드2
12373정성태10/14/202073.NET Framework: 952. OpCodes.Box와 관련해 IL 형식으로 직접 코딩 시 유의할 점
12372정성태10/14/2020147.NET Framework: 951. C# 9.0 - (5) Attributes on local functions파일 다운로드1
12371정성태10/13/202054개발 환경 구성: 519. Visual Studio의 Ctrl+Shift+U (Edit.MakeUppercase) 단축키가 동작하지 않는 경우
12370정성태10/13/202054Linux: 33. Linux - nmcli를 이용한 고정 IP 설정
12369정성태10/21/2020840Windows: 176. Raymond Chen이 한글날에 밝히는 윈도우의 한글 자모 분리 현상 [1]
12368정성태10/12/202050오류 유형: 668. VSIX 확장 빌드 - The "GetDeploymentPathFromVsixManifest" task failed unexpectedly.
12367정성태10/12/202050오류 유형: 667. Ubuntu - Temporary failure resolving 'kr.archive.ubuntu.com'
12366정성태10/13/2020135.NET Framework: 950. C# 9.0 - (4) Native ints파일 다운로드1
12365정성태10/12/2020129.NET Framework: 949. C# 9.0 - (3) Lambda discard parameters파일 다운로드1
12364정성태10/11/2020169.NET Framework: 948. C# 9.0 - (2) Skip locals init파일 다운로드1
12363정성태10/11/2020181.NET Framework: 947. C# 9.0 - (1) Target-typed new파일 다운로드1
12362정성태10/11/2020160VS.NET IDE: 151. Visual Studio 2019에 .NET 5 rc/preview 적용하는 방법
12361정성태10/19/2020240.NET Framework: 946. C# 9.0을 위한 개발 환경 구성
12360정성태10/8/202069오류 유형: 666. The type or namespace name '...' does not exist in the namespace 'Microsoft.VisualStudio.TestTools' (are you missing an assembly reference?)
12359정성태10/7/202064오류 유형: 665. Windows - 재부팅 후 iSCSI 연결이 끊기는 문제
12358정성태10/7/202052오류 유형: 664. Web Deploy 설치 시 "A newer version of Microsoft Web Deploy 3.6 was found on this machine." 오류
12357정성태10/7/202052오류 유형: 663. 이벤트 로그 - The storage optimizer couldn't complete retrim on New Volume
12356정성태10/7/202077오류 유형: 662. ASP.NET Core와 500.19, 500.21 오류 (0x8007000d)
12355정성태10/3/2020100오류 유형: 661. Hyper-V Linux VM의 Internal 유형의 가상 Switch에 대한 IP 연결이 되지 않는 경우
[1]  2  3  4  5  6  7  8  9  10  11  12  13  14  15  ...