使用PowerShell的New-ScriptBlockCallback实现HttpListener异步处理仍阻塞
问题分析与修复方案
我帮你排查了代码里的问题,导致异步失效、请求全部挂起的核心原因有两个,下面逐一拆解并给出修复后的完整代码:
核心问题根源
1. PowerShell事件处理的单线程瓶颈
你用Register-ObjectEvent注册的回调Action,所有请求都会在同一个共享线程上排队执行。当/timeout触发sleep 10时,这个线程会被完全阻塞10秒,后续所有请求的回调都得等这个线程释放才能执行,直接导致整个服务“假死”。
2. 主循环的轮询逻辑不合理
原来的主循环靠同步轮询$context.IsCompleted来发起新的监听,不仅效率低,而且时序上容易出现漏洞——一旦事件线程被阻塞,主循环的轮询虽然还在跑,但新的请求根本没机会被处理。
修复后的完整代码
function New-ScriptBlockCallback { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')] param( [parameter(Mandatory)] [ValidateNotNullOrEmpty()] [scriptblock]$Callback ) if (-not ('CallbackEventBridge' -as [type])) { Add-Type @' using System; public sealed class CallbackEventBridge { public event AsyncCallback CallbackComplete = delegate { }; private CallbackEventBridge() {} private void CallbackInternal(IAsyncResult result) { CallbackComplete(result); } public AsyncCallback Callback { get { return new AsyncCallback(CallbackInternal); } } public static CallbackEventBridge Create() { return new CallbackEventBridge(); } } '@ } $bridge = [callbackeventbridge]::create() # 保存事件句柄,防止被GC回收,方便后续清理 $eventHandle = Register-ObjectEvent -InputObject $bridge -EventName callbackcomplete -Action $Callback -MessageData $args $bridge | Add-Member -MemberType NoteProperty -Name EventHandle -Value $eventHandle $bridge.Callback } $listener = New-Object System.Net.HttpListener $listener.Prefixes.Add('http://+:8080/') $listener.Start() Write-Host 'Listening on http://localhost:8080/' -ForegroundColor Green $requestListener = { [cmdletbinding()] param($result) [System.Net.HttpListener]$listener = $result.AsyncState; try { $context = $listener.EndGetContext($result); $request = $context.Request $response = $context.Response if ($request.Url -match '/timeout$') { $timeout = 10 # 将耗时操作放入后台作业,彻底释放事件线程 $job = Start-Job -ScriptBlock { param($context, $timeout) try { Start-Sleep -Seconds $timeout $message = "Timeout $timeout seconds completed at $(Get-Date -Format 'HH:mm:ss')" [byte[]]$buffer = [System.Text.Encoding]::UTF8.GetBytes($message) $context.Response.ContentLength64 = $buffer.Length $context.Response.ContentType = 'text/html' $output = $context.Response.OutputStream $output.Write($buffer, 0, $buffer.Length) } finally { # 确保资源被正确释放 $context.Response.OutputStream.Close() $context.Response.Close() } } -ArgumentList $context, $timeout # 自动清理完成的后台作业,避免内存泄漏 Register-ObjectEvent -InputObject $job -EventName StateChanged -Action { if ($sender.State -eq [System.Management.Automation.JobState]::Completed) { $sender | Remove-Job -Force } } | Out-Null } else { # 快速响应逻辑保持不变 $message = "No timeout - response at $(Get-Date -Format 'HH:mm:ss')" $response.ContentType = 'text/html' [byte[]]$buffer = [System.Text.Encoding]::UTF8.GetBytes($message) $response.ContentLength64 = $buffer.Length $output = $response.OutputStream $output.Write($buffer, 0, $buffer.Length) $output.Close() $response.Close() } } finally { # 无论请求处理成功/失败,都立即发起新的异步监听 $null = $listener.BeginGetContext((New-ScriptBlockCallback -Callback $requestListener), $listener) } } # 启动第一个异步监听 $null = $listener.BeginGetContext((New-ScriptBlockCallback -Callback $requestListener), $listener) # 主循环改为等待用户输入终止,替代低效的轮询 Write-Host 'Press any key to stop the listener...' $null = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown') # 清理所有资源 $listener.Stop() $listener.Close() Get-EventSubscriber | Unregister-Event -Force Get-Job | Remove-Job -Force Write-Host 'Listener terminated.' -ForegroundColor Yellow
关键修改说明
耗时操作后台化
把原来阻塞线程的sleep 10放到Start-Job后台作业里,事件线程只需启动作业就立即返回,继续处理其他请求的回调,彻底解决单线程阻塞问题。监听逻辑内移
移除原来的轮询循环,改为在每个请求处理的finally块里主动发起新的BeginGetContext,确保服务始终处于监听状态,时序更可靠。资源清理优化
添加了事件句柄保存、后台作业自动清理的逻辑,避免长时间运行导致内存泄漏。
测试验证
启动修复后的脚本后:
- 打开第一个控制台,执行循环请求:
while ($true) { Invoke-RestMethod http://localhost:8080/; Start-Sleep -Milliseconds 500 } - 打开第二个控制台,执行超时请求:
Invoke-RestMethod http://localhost:8080/timeout
你会看到第一个控制台的循环请求完全不受影响,继续正常响应;10秒后第二个控制台会收到超时结果。
内容的提问来源于stack exchange,提问作者coder9001
相关产品推荐
相关产品推荐

