☰
C#实现SFTP上传下载带进度条:基于Renci.SshNet与Stream包装类
2026/10/8 8:28:29 网站建设 项目流程

简介:面向需要在C#项目中实现SFTP安全文件传输的开发者,这份示例工程围绕Renci.SshNet库封装了上传与下载操作,并通过回调机制呈现实时进度,解决默认接口无进度反馈的问题。工程共26个文件,包含6个C#源码文件、编译生成的exe可执行程序、WinForms界面资源、Renci.SshNet.dll依赖库及pdb调试符号等,压缩包仅533KB,结构清晰便于直接运行与二次开发。已有1680人学习下载,适合初学者对照理解SFTP连接、文件流读写与进度回调触发流程,也适合有经验的开发者快速复用代码。工程内附完整源码与可运行程序,覆盖Form1界面设计、Program入口、项目配置及依赖组件,演示了带回调的UploadFile/DownloadFile调用方式,可在此基础上扩展为WinForms或WPF进度条控件。

1. C#实现SFTP文件上传下载带进度条:先解决UI线程的堵车问题

一个WinForms工具要往远程Linux服务器传文件,还得让用户看着进度条从0跑到100%,这需求听着不难,但第一版直接在按钮点击事件里调用Renci.SshNet的SftpClient.UploadFile,窗体立刻白屏,进度条纹丝不动,转圈光标转了好几分钟。问题不在SFTP协议本身,而是C#的文件传输与进度条刷新天然不落在同一条时间线上。这篇笔记记录我拆完这个需求后的完整落地方案:用Renci.SshNet做C# SFTP文件上传下载,自建Stream包装类实现字节级进度回调,覆盖UI线程隔离、超时参数调优与五个高频踩坑点。适合做WinForms上位机、桌面运维工具、以及想在.NET项目里接SFTP但不想被同步阻塞坑住的人。

2. 进度回调的底层逻辑:为什么Renci.SshNet没有内置进度事件

2.1 UI线程被文件传输堵死的根因

WinForms控件更新依赖UI线程的消息循环。消息循环不仅要处理按钮点击、鼠标移动,还要处理WM_PAINT重绘消息和进度条刷新消息。当你直接在按钮事件里调用UploadFile时,UI线程被一个长时间运行的同步方法占住,消息队列里的重绘消息根本没有机会执行。于是窗体标题栏出现“未响应”,进度条卡在初始位置,操作系统甚至会弹窗问你要不要强制结束进程。

这里得想明白一件事:任何文件传输框架,只要它的API是同步阻塞的,直接放在UI线程里都会导致界面假死。SftpClient.UploadFile就是这种方法——从本地流读取数据、加密、通过SSH通道发送,整个过程不交还控制权给消息循环。这不是库的缺陷,而是桌面程序与网络IO的经典矛盾。

常见的解法是丢到后台线程。但丢到后台线程之后,紧接着就是第二个问题:怎么把传输进度实时反馈给UI线程。Renci.SshNet这个库在文件级没有提供现成的ProgressChanged事件,所以进度反馈只能自己造。造的过程中,还得先搞清楚它底层是怎么读流的,不然回调触发的频率和位置都找不准。

2.2 选型理由:纯托管实现与可控的Stream入口

我为什么选Renci.SshNet而不是WinSCP命令行或者SharpSSH?WinSCP有命令行模式和.NET COM接口,但它依赖外部安装,输出的文本进度需要解析,适合运维脚本而不适合嵌入WinForms界面。SharpSSH是个老库,底层依赖Granados和Tamir.SharpSsh,多年不更新,.NET Core下基本不可用。Renci.SshNet(NuGet包名SSH.NET,命名空间Renci.SshNet)是纯托管实现,NuGet一键安装,兼容.NET Framework 4.5到.NET 6/8,桌面工具、控制台、Windows服务都能用。

关键点在SftpClient的两个重载方法上。上传时调用UploadFile(Stream input, string path),下载时调用DownloadFile(string path, Stream output)。这两个重载都接收Stream参数,意味着可以传入一个自定义Stream子类,拦截底层对Read和Write的调用,在拦截处统计字节数、触发进度回调。Renci内部根本感知不到这层包装,它只看到自己在操作一个普通流。

方案进度反馈方式依赖适合场景
Renci.SshNet + 自定义Stream精确到字节数的回调NuGet包WinForms/WPF桌面工具
WinSCP命令行解析控制台输出文本外部软件运维脚本
SharpSSH无进度接口老第三方库新项目不推荐

Renci的SftpClient构造参数支持host、port、username、password这套基础组合,也支持私钥登录,用PrivateKeyFile类加载OpenSSH格式的私钥文件。私钥文件在Linux服务器上会被严格检查权限,这个细节放到后面避坑章节展开。

2.3 BufferSize决定进度条是平滑还是跳格子

SftpClient有一个BufferSize属性,它控制着每次从我们传入的Stream中读取多少字节。这个值直接决定回调频率:100MB文件、BufferSize设64KB时,Read大约被调用1600次;如果BufferSize设1MB,回调就只有100次左右。回调次数越多进度条越平滑,但每次回调背后都跟着一次加密和网络写入,过小的BufferSize会明显拖慢吞吐。

我一般会先把BufferSize设成256KB,局域网环境下传百兆文件,进度条刷新手感比较舒服;跨公网传输时降到64KB,因为公网延迟大,分块太碎容易触发对端的拥塞控制,整块太大又让单次TCP窗口填不满。这个参数属于跑起来之后再看效果调整的选项,不用一开始就追求最优值。另外,千万别用轮询远程文件大小的方式来算进度——每GetAttributes一次就是一次完整的SFTP请求,给服务器增加无谓负担,而且服务器端在写入过程中远程文件大小不一定按本地方向递增,算出来的进度根本不准。

3. 上传下载落地:包装Stream实现进度回调的完整代码

3.1 自定义ProgressStream:把Read和Write变成进度计数器

先写一个ProgressStream类,继承System.IO.Stream,内部包一个真实流。重写Read和Write两个方法,每次调用后累加已处理字节数,再把进度百分比抛给回调函数。其他抽象成员直接转发给内部流,保证SftpClient能像操作普通流一样操作它。

public class ProgressStream : Stream { private readonly Stream _inner; private readonly long _totalBytes; private long _processedBytes; private readonly Action<double> _progressCallback; public ProgressStream(Stream inner, long totalBytes, Action<double> progressCallback) { _inner = inner; _totalBytes = totalBytes; _progressCallback = progressCallback; } public override int Read(byte[] buffer, int offset, int count) { int bytesRead = _inner.Read(buffer, offset, count); _processedBytes += bytesRead; _progressCallback?.Invoke(GetPercent()); return bytesRead; } public override void Write(byte[] buffer, int offset, int count) { _inner.Write(buffer, offset, count); _processedBytes += count; _progressCallback?.Invoke(GetPercent()); } private double GetPercent() => _totalBytes == 0 ? 100 : (double)_processedBytes / _totalBytes * 100.0; public override bool CanRead => _inner.CanRead; public override bool CanSeek => _inner.CanSeek; public override bool CanWrite => _inner.CanWrite; public override long Length => _inner.Length; public override long Position { get => _inner.Position; set => _inner.Position = value; } public override void Flush() => _inner.Flush(); public override long Seek(long offset, SeekOrigin origin) => _inner.Seek(offset, origin); public override void SetLength(long value) => _inner.SetLength(value); }

这段代码是整个进度方案的核心。Renci上传文件时会调用ProgressStream的Read方法读取本地文件内容,每读一次,_processedBytes就累加一次,除以_totalBytes就能算出实时进度。下载时Renci把ProgressStream当作目标流,往里走Write分支。所以这一个类两头通用,上传和下载共用。构造函数里的totalBytes,上传时从FileInfo.Length拿,下载时从GetAttributes(remotePath).Size拿。回调参数用double而不是int,因为小数位的百分比在UI上显示起来更细腻。

3.2 上传文件:把回调接到WinForms控件上

private void UploadFileWithProgress(string localFile, string remoteFile) { using (var client = new SftpClient("192.168.1.100", 22, "sftpuser", "password")) { client.Connect(); var localInfo = new FileInfo(localFile); using (var stream = File.OpenRead(localFile)) using (var progress = new ProgressStream(stream, localInfo.Length, OnUploadProgress)) { client.UploadFile(progress, remoteFile); } client.Disconnect(); } } private void OnUploadProgress(double percent) { progressBar.BeginInvoke(new Action(() => { progressBar.Maximum = 100; progressBar.Value = (int)Math.Min(percent, 100); lblStatus.Text = $"上传中:{percent:F1}%"; })); }

File.OpenRead返回的FileStream是真实数据源,ProgressStream包一层之后再传给UploadFile。Renci在内部循环里不断调用progress.Read,统计逻辑就在这个循环里触发。OnUploadProgress被回调时仍然在后台线程,所以必须用BeginInvoke丢回UI线程更新控件。Math.Min(percent, 100)是防呆写法,因为浮点计算可能得到100.0001这种值。

3.3 下载文件:远程文件大小决定进度上限

private void DownloadFileWithProgress(string remoteFile, string localFile) { using (var client = new SftpClient("192.168.1.100", 22, "sftpuser", "password")) { client.Connect(); var attrs = client.GetAttributes(remoteFile); long totalBytes = attrs.Size; using (var stream = File.Create(localFile)) using (var progress = new ProgressStream(stream, totalBytes, OnDownloadProgress)) { client.DownloadFile(remoteFile, progress); } client.Disconnect(); } }

下载时Renci把progress当作目标流,频繁往里Write。远程文件大小通过GetAttributes拿,这是SFTP协议属性里的文件长度字段。这里有个隐含前提:传输期间远程文件大小不能变化。如果源文件正在被服务器上的另一个进程持续写入,totalBytes会偏小,进度会提前走到100%。稳妥做法是传输前再读一次大小,如果拿到的Size为0,直接给出提示而不是傻等。

3.4 异步执行与按钮状态管理

上面的传输方法在按钮事件里直接调用,UI还是会被挡,只是挡的时间从传输全程变成了连接等待阶段。正确做法是用Task.Run把传输丢到线程池:

private async void BtnUpload_Click(object sender, EventArgs e) { btnUpload.Enabled = false; progressBar.Value = 0; try { await Task.Run(() => UploadFileWithProgress(localFile, remoteFile)); lblStatus.Text = "上传完成"; } catch (Exception ex) { MessageBox.Show($"传输失败:{ex.Message}"); } finally { btnUpload.Enabled = true; progressBar.Value = 0; } }

事件处理器写成async void是WinForms里的合法用法,因为事件委托签名要求void返回。await Task.Run(...)会把传输任务丢到线程池执行,UI线程利用await里的调度器继续处理消息循环。这样进度条回调里的BeginInvoke才能真正起作用。按钮先禁用再恢复,防止用户在传输过程中重复点击造成两个连接并发操作同一个SftpClient实例——Renci的SftpClient不是线程安全的,并发调用会得到不可预期的行为。

4. 参数调优:超时、BufferSize与临时文件策略

4.1 ConnectionTimeout与OperationTimeout:两个容易搞混的超时

client.ConnectionTimeout = TimeSpan.FromSeconds(15); client.OperationTimeout = TimeSpan.FromSeconds(60); client.KeepAliveInterval = TimeSpan.FromSeconds(30);

ConnectionTimeout覆盖从TCP建连到SSH密钥交换完成的阶段,OperationTimeout覆盖单个SFTP请求的等待,比如打开远程文件句柄、读取目录、重命名文件。KeepAliveInterval是维护SSH会话的心跳包间隔,适合连接长时间挂着的场景。

这两个超时的区分对排错的意义很大。局域网里连接秒开,但服务器SSH服务端没响应时,ConnectionTimeout会先触发;传输中途物理断网时,TCP连接已经建立过,ConnectionTimeout不再生效,OperationTimeout才管用。我遇到过上传到一半卡死二十分钟的情况,最后发现OperationTimeout默认值太长,断网后线程在等待远程确认消息,界面就挂在那里。经验是把OperationTimeout控制在60秒以内,配合重试逻辑,比让用户干等要好得多。

4.2 BufferSize与网络环境的匹配经验

BufferSize影响的是Renci从我们传入Stream里读取的块大小,也直接控制加密和网络发送的频率。数值设置不是越大越快,也不是越小越平滑,要看实际网络环境。

网络环境BufferSize建议现象反馈
局域网(千兆)256KB进度条刷新平滑,传输速度可跑满带宽
跨公网(10M~50M)64KB~128KB回调频率适中,丢包重传代价可控
弱网(Wi-Fi/高延迟)32KB~64KB每块传输时间短,UI响应快,但吞吐略降

BufferSize过大时回调次数会变少,100MB文件如果设成4MB,整个过程只回调25次左右,进度条显示就会出现明显跳格。设太小时每个块都经历一次加密和网络往返,整体吞吐掉得很明显。建议先用默认值跑一版,抓到实际网络环境后再调。

4.3 临时文件与重试:让传输失败不产生脏数据

直接往最终路径上传有风险。传了一半断网,远程路径上留下一坨不完整文件,其他程序如果扫描到了就会当完整文件处理。我一般先传到xxx.part,完成后再重命名:

string tempRemote = remotePath + ".part"; client.UploadFile(progress, tempRemote); if (client.Exists(tempRemote)) { client.RenameFile(tempRemote, remotePath); }

重试逻辑放在调用层,只对网络类异常重试:

int maxRetry = 3; for (int i = 0; i < maxRetry; i++) { try { UploadFileCore(localPath, remotePath); break; } catch (SshException ex) when (i < maxRetry - 1) { Thread.Sleep(1000 * (i + 1)); } catch (SocketException ex) when (i < maxRetry - 1) { Thread.Sleep(1000 * (i + 1)); } }

SshException和SocketException这类网络层异常值得重试,但“远程路径不存在”“权限拒绝”这种确定性错误重试一百次也是同样结果。重试之前必须把.part临时文件清掉,否则第二次上传会基于残留文件的状态,远程文件名冲突或者大小对不上。这些参数组合起来,才能让进度条下的文件传输在恶劣网络里站得住。

5. 避坑:SFTP文件传输最常见的五个坑与排查路径

5.1 第一次连接就报主机密钥未知

现象:SftpClient.Connect()抛异常,提示remote host key is unknown,或者连接直接被拒。

原因:Renci默认不信任任何主机公钥,这是安全设计,不像某些FTP客户端会把未知密钥弹窗让用户确认。程序里不做处理,它就拒绝建立连接。

解决:处理HostKeyReceived事件,代码里显式信任:

client.HostKeyReceived += (sender, e) => e.CanTrust = true;

生产环境别这么裸奔。正确做法是把服务器公钥指纹固定下来,在回调里比对:

client.HostKeyReceived += (sender, e) => { e.CanTrust = e.FingerPrint.SequenceEqual(expectedFingerprint); };

FingerPrint属性是SHA256哈希的字节数组,第一次连接时用ssh-keyscan工具把服务器指纹抓下来存到配置里。这是SFTP连接层的第一个坑,几乎每个新项目都会撞一次。

5.2 进度条卡在99%或者100%后假死

现象:进度显示已经到100%,但程序还停在那里,转圈图标一直转,状态栏文案不变。

原因:UploadFile在读完流、发完数据之后,还要做flush、关闭远程句柄、发送SSH通道结束消息。这个阶段没有任何数据流动,我们的进度统计已经到100%,但方法还没返回。如果这时候在UI上弹“完成”提示,用户看到的就是完成弹窗弹出来之后界面还在卡。

解决:回调里最多刷到99%,剩余那1%交给UploadFile返回后的代码:

client.UploadFile(progress, remotePath); client.Disconnect(); lblStatus.Text = "上传完成";

这个习惯我沿用到现在,所有带进度条的文件传输都没让进度先于业务逻辑到达100%。而且不要在进度回调里做任何耗时操作,回调频率高的时候,在UI线程里更新一下进度条就够了,写日志都放到节流之外。

5.3 上传成功后远程文件是0字节

现象:进度跑完,远程文件也存在,但大小是0。

原因:File.OpenRead打开的流没有内容可读。常见于本地文件路径写错、或者另一个程序正在往这个文件里写数据还没flush、或者ProgressStream包装类里忘了转发Position属性导致Renci内部Seek出错。实际排查中,Renci的UploadFile不会随意Seek输入流,最可能的原因就是路径错了或者流确实为空。

解决:上传前检查FileInfo.Length是否大于0,上传后用GetAttributes对比远程大小和本地大小:

if (localInfo.Length != client.GetAttributes(remotePath).Size) { // 传输不完整,删除远程文件重新传 }

如果远程文件不存在,GetAttributes会抛FileNotFoundException,这个检查本身就能暴露路径问题。

5.4 远程路径权限与绝对路径问题

现象:Connect成功,但UploadFile抛异常,异常信息里带Permission denied或者No such file。

原因:SFTP用户对目标目录没有写权限,或者路径用了相对路径。Renci会把相对路径解析到用户home目录下,结果和预期位置完全对不上。

解决:远程路径统一用/开头的全路径,上传前先探测目录存在性,再验证写权限:

if (!client.Exists("/var/data/uploads")) { throw new DirectoryNotFoundException("远程目录不存在"); }

权限问题需要用服务器侧配置解决,chown调整目录归属或者在SFTP服务器配置里限定可写目录。注意SFTP是SSH子系统,目录权限检查逻辑与shell登录一致,能登录不一定能写目标目录。

5.5 跨线程更新进度条直接抛异常

现象:后台线程里执行progressBar.Value = 80,抛出InvalidOperationException,提示线程间操作无效。

原因:WinForms控件和创建它的线程绑定,后台线程直接改属性属于违规访问,这是WinForms更新状态栏与进度条时最经典的一个报错。

解决:用BeginInvoke包装更新操作:

private void SafeUpdateProgress(int value) { if (progressBar.IsHandleCreated) { progressBar.BeginInvoke(new Action(() => progressBar.Value = value)); } }

加IsHandleCreated判断是因为窗体关闭后BeginInvoke会抛ObjectDisposedException。这个判断是WinForms里更新任何控件的标准保险写法,不只是进度条。

6. 进阶:取消传输、大小校验与断点续传的取舍

给ProgressStream加一个CancellationToken参数,在Read和Write方法里检查取消状态:

public override int Read(byte[] buffer, int offset, int count) { _token.ThrowIfCancellationRequested(); int bytesRead = _inner.Read(buffer, offset, count); _processedBytes += bytesRead; _progressCallback?.Invoke(GetPercent()); return bytesRead; }

用户点击取消按钮后触发_cts.Cancel(),下一次Read或Write立刻抛OperationCanceledException,Renci原样把这个异常抛给调用方。捕获到取消异常后,要做的第一件事是清理远程.part临时文件,避免磁盘上堆积垃圾文件。

关于断点续传,Renci.SshNet没有直接的AppendFile接口,SftpClient的Open(remotePath, FileMode.Append, FileAccess.Write)可以用,但SFTP协议对Append模式的支持依赖服务器端实现,很多服务器用的是OpenSSH的SFTP子系统,对Append支持并不总是如预期。与其赌服务器行为,不如走“失败重传整个文件”的路线,配合临时文件和大小校验,实际体验比半吊子的续传要可靠得多。

完整性校验方面,SFTP协议没有原生的远程哈希接口,Renci也没提供计算远程MD5的能力。如果同时能拿到SSH shell访问权限,可以调用md5sum远程计算再和本地比对,但那已经超出SFTP协议范围了。纯SFTP场景里最实用的校验就是大小对比:上传完成后GetAttributes拿远程大小,和本地FileInfo.Length比对。百兆以上的大文件,字节数都能对上,基本可以排除截断风险;对不上就删除重传。这个校验逻辑配合.part临时文件策略,就能组成一条完整的传输闭环。

从那次“进度条100%但远程文件0字节”的翻车之后,我给自己定了个硬习惯:任何一次SFTP传输,必须走完“临时文件名、进度回调、大小校验、改名”四步才算完。代码的异常分支里,先把远程残留文件清掉再谈重试。这套流程在几个项目里跑了很长时间,没有一次因为传输中断背锅。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询