.NET Core 6下SSH.NET异步代码同步执行问题排查
问题根因
当前代码无法实现逐行实时输出、异步执行失效,是以下几个明确的逻辑错误导致的:
- 读取SSH输出使用了
ReadToEndAsync()方法,该方法会持续阻塞直到对应流被完全关闭(即SSH命令执行结束、所有输出全部写入完成)才会返回结果,因此你只能在命令执行完成时拿到一次全量输出,不可能在执行过程中逐行推送。 - 循环中使用
Thread.Yield()做等待是无效逻辑,该方法仅会将当前CPU时间片让给同核心的其他就绪线程,不会主动等待SSH输出数据到达,会导致循环空转占满CPU资源,完全起不到异步等待IO的作用。 - 控制器Action没有正确使用异步等待:直接
Return Ok(Server.BashAsync(Model.BashCmd))没有加Await关键字,会导致Action在BashAsync任务还未执行完成时就直接将Task对象序列化返回,请求生命周期提前终止,后续的输出上报逻辑根本无法正常关联到当前请求。 - 自行创建的
CancellationToken是空值,没有绑定任何取消触发源,后续无论是前端中断请求还是Quartz任务终止,都无法正常中断正在执行的SSH命令,会产生资源泄漏。 - 单循环交替读取标准输出、标准错误流的逻辑存在阻塞问题:如果其中一个流暂时没有数据,会直接卡住另一个流的读取,导致输出延迟甚至丢失。
修正代码
控制器层修正
将Action改为异步方法,正确等待任务执行,绑定请求取消令牌:
Public Async Function AsyncBash(Model As KvmBashRequest) As Task(Of IActionResult) ' 绑定请求取消令牌,前端断开连接时自动触发取消 Dim requestCt = HttpContext.RequestAborted Dim execResult = Await Server.BashAsync(Model.BashCmd, requestCt) Return Ok(execResult) End Function
BashAsync方法修正
不要创建空的取消令牌,将Progress实例的上报逻辑和SignalR推送对接:
Public Async Function BashAsync(BashCmd As String, ct As CancellationToken) As Task(Of String) If Not SshClient.IsConnected Then Return "SSH not connected" End If Try Dim sshCmd = SshClient.CreateCommand(BashCmd) ' 进度上报直接对接SignalR推送逻辑 Dim pushProgress = New Progress(Of SshOutputLine)(Sub(outputLine) ' 替换为你自己的SignalR推送逻辑,将outputLine推送到前端 Debug.WriteLine($"[SSH Output] IsError:{outputLine.IsErrorLine}, Content:{outputLine.Line}") End Sub) Await sshCmd.ExecuteAsync(pushProgress, ct) Return "Command execute success" Catch ex As OperationCanceledException Return "Command canceled" Catch ex As Exception Return $"Execute failed: {ex.Message}" End Try End Function
SSH扩展方法修正
替换无效的ReadToEnd和Thread.Yield逻辑,用独立任务分别读取两个输出流,逐行上报:
Imports System.IO Imports System.Runtime.CompilerServices Imports System.Threading Imports Renci.SshNet Module SshCommandExtensions <Extension()> Async Function ExecuteAsync( sshCmd As SshCommand, outputProgress As IProgress(Of SshOutputLine), ct As CancellationToken) As Task ' 注册取消回调,触发时主动终止SSH命令 Using cancelReg = ct.Register(Sub() sshCmd.CancelAsync()) Dim execAsyncResult = sshCmd.BeginExecute() Using stdoutReader = New StreamReader(sshCmd.OutputStream), stderrReader = New StreamReader(sshCmd.ExtendedOutputStream) ' 启动两个独立任务分别读取标准输出、错误输出,避免互相阻塞 Dim readStdoutTask = ReadLinesToProgressAsync(stdoutReader, False, outputProgress, ct) Dim readStderrTask = ReadLinesToProgressAsync(stderrReader, True, outputProgress, ct) ' 等待SSH命令执行完成 Await Task.Factory.FromAsync(execAsyncResult, AddressOf sshCmd.EndExecute) ' 等待剩余所有输出读取完成 Await Task.WhenAll(readStdoutTask, readStderrTask) End Using End Using End Function ''' <summary> ''' 逐行读取流内容,每读到一行就立刻上报进度 ''' </summary> Private Async Function ReadLinesToProgressAsync( reader As TextReader, isErrorLine As Boolean, progress As IProgress(Of SshOutputLine), ct As CancellationToken) As Task Dim lineContent As String While True ct.ThrowIfCancellationRequested() lineContent = Await reader.ReadLineAsync() ' 流读取到末尾时退出循环 If lineContent Is Nothing Then Exit While If Not String.IsNullOrWhiteSpace(lineContent) Then progress.Report(New SshOutputLine(lineContent, isErrorLine)) End If End While End Function End Module
后续对接Quartz与SignalR的注意事项
- 持续数小时的长任务不要放在HTTP请求线程内执行,即使修正了异步逻辑,请求超时、前端断开连接、应用回收都可能导致任务意外终止。对接Quartz时要将SSH执行逻辑放到Quartz的调度工作线程中,任务启动后立刻返回任务ID,执行过程中通过
IHubContext主动向前端推送输出和状态,不依赖HTTP请求上下文。 SshClient实例不要跨线程、跨命令共享,避免连接状态错乱、输出串流问题,建议按任务维度创建独立的SSH连接,任务执行完成后主动释放。- 长任务需要持久化任务状态、输出日志,前端断线重连时可以根据任务ID拉取历史输出和当前状态,避免消息丢失。
内容的提问来源于stack exchange,提问作者user18928007
相关产品推荐
相关产品推荐

