詹迷的父亲 發表於 2023-10-23 12:54:00

浅析 C# Console 控制台为什么也会卡死

<h2 id="一背景">一:背景</h2>
<h3 id="1-讲故事">1. 讲故事</h3>
<p>在分析旅程中,总会有几例控制台的意外卡死导致的生产事故,有经验的朋友都知道,控制台卡死一般是动了 <code>快速编辑窗口</code> 的缘故,截图如下:</p>
<p><img src="https://img2023.cnblogs.com/blog/214741/202310/214741-20231023125413029-231277666.png" alt="" loading="lazy"></p>
<p>虽然知道缘由,但一直没有时间探究底层原理,市面上也没有对这块的底层原理介绍,昨天花了点时间简单探究了下,算是记录分享吧。</p>
<h2 id="二几个疑问解答">二:几个疑问解答</h2>
<h3 id="1-界面为什么会卡死">1. 界面为什么会卡死</h3>
<p>相信有很多朋友会有这么一个疑问?控制台程序明明没有 <code>message loop</code> 机制,为什么还能响应 窗口事件 呢?</p>
<p>说实话这是一个好问题,其实 Console 之所以能响应 窗口事件,是因为它开了一个配套的 conhost 窗口子进程,用它来承接 UI 事件,为了方便阐述,上一段定时向控制台输出的测试代码。</p>
<pre><code class="language-C#">
      static void Main(string[] args)
      {
            for (int i = 0; i &lt; int.MaxValue; i++)
            {
                Console.WriteLine($"i={i}");
                Thread.Sleep(1000);
            }
      }

</code></pre>
<p>将程序跑起来,再用 process explorer 观察<strong>进程树</strong>即可。</p>
<p><img src="https://img2023.cnblogs.com/blog/214741/202310/214741-20231023125413026-1162149216.png" alt="" loading="lazy"></p>
<p>接下来用 windbg 附加到 conshost 进程上,观察下有没有 <code>GetMessageW</code>。</p>
<pre><code class="language-C#">
0:005&gt; ~* k
   0Id: 3ec8.2c20 Suspend: 1 Teb: 000000d2`92014000 Unfrozen
# Child-SP          RetAddr               Call Site
00 000000d2`922ff798 00007fff`a3e45746   ntdll!NtWaitForSingleObject+0x14
01 000000d2`922ff7a0 00007fff`a60b5bf1   KERNELBASE!DeviceIoControl+0x86
02 000000d2`922ff810 00007ff6`9087a790   KERNEL32!DeviceIoControlImplementation+0x81
03 000000d2`922ff860 00007fff`a60b7614   conhost!ConsoleIoThread+0xd0
04 000000d2`922ff9e0 00007fff`a66a26a1   KERNEL32!BaseThreadInitThunk+0x14
05 000000d2`922ffa10 00000000`00000000   ntdll!RtlUserThreadStart+0x21
...
   2Id: 3ec8.1b70 Suspend: 1 Teb: 000000d2`9201c000 Unfrozen
# Child-SP          RetAddr               Call Site
00 000000d2`9227f858 00007fff`a4891b9e   win32u!NtUserGetMessage+0x14
01 000000d2`9227f860 00007ff6`908735c5   user32!GetMessageW+0x2e
02 000000d2`9227f8c0 00007fff`a60b7614   conhost!ConsoleInputThreadProcWin32+0x75
03 000000d2`9227f920 00007fff`a66a26a1   KERNEL32!BaseThreadInitThunk+0x14
04 000000d2`9227f950 00000000`00000000   ntdll!RtlUserThreadStart+0x21
...

</code></pre>
<h3 id="2-进程间如何通讯">2. 进程间如何通讯</h3>
<p>这个问题再细化一点就是Client 端通过 <code>Console.WriteLine($"i={i}");</code> 写入的内容是如何被 Server 端的<code>conhost!ConsoleIoThread</code> 方法接收到的。</p>
<p>熟悉 Windows 编程的朋友都知道:Console.WriteLine 的底层调用逻辑是 <code>ntdll!NtWriteFile -&gt; nt!IopSynchronousServiceTail</code> ,前者是用户态进入到内核态的网关函数,后者是用户将irp丢到<strong>线程的请求包队列</strong>后进入休眠(KeWaitForSingleObject),直到驱动提取并处理完之后唤醒。</p>
<p>说了这么多,怎么去验证呢?</p>
<ul>
<li>客户端下断点</li>
</ul>
<pre><code class="language-C#">
0: kd&gt; !process 0 0 ConsoleApp2.exe
PROCESS ffffe001b5e51840
    SessionId: 1Cid: 0e8c    Peb: 7ff7ab226000ParentCid: 09d4
    DirBase: 18079000ObjectTable: ffffc00036965200HandleCount: &lt;Data Not Accessible&gt;
    Image: ConsoleApp2.exe

0: kd&gt; bp /p ffffe001b5e51840 nt!IopSynchronousServiceTail
0: kd&gt; g
Breakpoint 0 hit
nt!IopSynchronousServiceTail:
fffff802`a94f3410 48895c2420      mov   qword ptr ,rbx
3: kd&gt; k
# Child-SP          RetAddr               Call Site
00 ffffd000`f6477988 fffff802`a94f2e80   nt!IopSynchronousServiceTail
01 ffffd000`f6477990 fffff802`a916db63   nt!NtWriteFile+0x680
02 ffffd000`f6477a90 00007ffc`2fed38aa   nt!KiSystemServiceCopyEnd+0x13
03 0000009f`0743dbd8 00007ffc`2cd1d478   ntdll!NtWriteFile+0xa
04 0000009f`0743dbe0 00000000`00000005   0x00007ffc`2cd1d478
05 0000009f`0743dbe8 0000009f`0743dcf0   0x5
06 0000009f`0743dbf0 0000009f`0978c9b8   0x0000009f`0743dcf0
07 0000009f`0743dbf8 00007ffc`2986e442   0x0000009f`0978c9b8
08 0000009f`0743dc00 0000009f`0743dc30   0x00007ffc`2986e442
09 0000009f`0743dc08 0000009f`0743de00   0x0000009f`0743dc30
0a 0000009f`0743dc10 00000000`00000005   0x0000009f`0743de00
0b 0000009f`0743dc18 00000000`00000000   0x5

3: kd&gt; tc
nt!IopSynchronousServiceTail+0x70:
fffff802`a94f3480 e8ebf1b5ff      call    nt!IopQueueThreadIrp (fffff802`a9052670)

</code></pre>
<ul>
<li>服务端下断点</li>
</ul>
<p>conhost端的提取逻辑是在 <code>conhost!ConsoleIoThread</code> 方法中,它的内部调用的是 <code>kernelbase!DeviceIoControl</code> 函数,这个方法挺有意思,可以直接给驱动程序下达命令,方法签名如下:</p>
<pre><code class="language-C#">
BOOL DeviceIoControl(
HANDLE       hDevice,
DWORD      dwIoControlCode,
LPVOID       lpInBuffer,
DWORD      nInBufferSize,
LPVOID       lpOutBuffer,
DWORD      nOutBufferSize,
LPDWORD      lpBytesReturned,
LPOVERLAPPED lpOverlapped
);

</code></pre>
<p>提取完了之后会通过 <code>conhost!DoWriteConsole</code> 向控制台输出,接下来可以下个断点验证下。</p>
<pre><code class="language-C#">
0:000&gt; bp conhost!DoWriteConsole
0:000&gt; g
Breakpoint 0 hit
conhost!DoWriteConsole:
00007ff6`90876ec0 48895c2410      mov   qword ptr ,rbx ss:00000095`d627f738=0000000000000000
0:000&gt; r
rax=000000000000000c rbx=00000095d627f7b0 rcx=000002370df76cc0
rdx=00000095d627f768 rsi=00000095d627f7c0 rdi=00000095d627f7f0
rip=00007ff690876ec0 rsp=00000095d627f728 rbp=00000095d627f8f9
r8=000002370bedf010r9=00000095d627f7b0 r10=000002370df76cc0
r11=000002370e0c9d00 r12=00000095d627f970 r13=000002370bedf010
r14=000002370bedf010 r15=0000000000000000
iopl=0         nv up ei pl zr na po nc
cs=0033ss=002bds=002bes=002bfs=0053gs=002b             efl=00000246
conhost!DoWriteConsole:
00007ff6`90876ec0 48895c2410      mov   qword ptr ,rbx ss:00000095`d627f738=0000000000000000
0:000&gt; du 000002370df76cc0
00000237`0df76cc0"i=18.."

</code></pre>
<p>可以看到果然有一个 <code>i=18</code>,这里要提醒一下,要想看方法的顺序逻辑,可以借助 perfview。</p>
<p><img src="https://img2023.cnblogs.com/blog/214741/202310/214741-20231023125413077-1983410700.png" alt="" loading="lazy"></p>
<h3 id="3-为什么快捷编辑之后就卡死">3. 为什么快捷编辑之后就卡死</h3>
<p>conhost 的源码不是公开的,不过可以感官上推测出来。</p>
<ol>
<li>
<p>快速编辑窗口 被用户启用后, GetMessage 会感知到这个自定义的 MSG 消息。</p>
</li>
<li>
<p>这个消息的逻辑会让 server 处理Client消息的流程一直处于等待中,导致 Client 的 IopSynchronousServiceTail 不能被唤醒,导致一直处于阻塞中,类似 Task 的完成状态一直不被设置。</p>
</li>
</ol>
<p>接下来可以验证下 <code>快速编辑窗口</code> 的处理消息码是多少,只要在控制台点一下鼠标。参考脚本如下:</p>
<pre><code class="language-C#">
0:004&gt; bp win32u!NtUserGetMessage "dp ebp-30 L2 ; g"
0:004&gt; g
00000095`d61ffae000000000`00130e6e 00000000`00000404
00000095`d61ffae000000000`00130e6e 00000000`00000404
00000095`d61ffae000000000`00130e6e 00000000`00000201
00000095`d61ffae000000000`00130e6e 00000000`00000405
00000095`d61ffae000000000`00130e6e 00000000`00000202
00000095`d61ffae000000000`00130e6e 00000000`00000200

</code></pre>
<p><img src="https://img2023.cnblogs.com/blog/214741/202310/214741-20231023125413033-1308081540.png" alt="" loading="lazy"></p>
<p>从 chaggpt 中对每个消息码的介绍,可以看到会有一个 405 的自定义消息,这个就是和 <code>快速编辑窗口</code> 有关的。</p>
<h2 id="三总结">三:总结</h2>
<p>这篇就是我个人对窗口卡死的推测和记录,高级调试不易,如果大家感兴趣,欢迎补充细节。</p>
<img src="https://images.cnblogs.com/cnblogs_com/huangxincheng/345039/o_210929020104最新消息优惠促销公众号关注二维码.jpg" width="700" height="300" alt="图片名称" align="center"><br><br>
来源:https://www.cnblogs.com/huangxincheng/p/17782167.html
頁: [1]
查看完整版本: 浅析 C# Console 控制台为什么也会卡死