
简介这是一份面向C#初学者与WinForm网络编程入门者的TCP通信示例源码包含服务端FrmTcpServer与客户端FrmTcpClient两个独立工程可帮助读者理解TcpListener、TcpClient、NetworkStream及StreamReader/StreamWriter在真实项目中的配合方式适用于在线聊天、文件传输等客户端-服务器通信场景的学习与二次开发。压缩包共61个文件以cs源码、csproj与sln工程文件、config配置、resx与resources资源、exe与pdb编译产物为主整体约120KB结构完整可直接用Visual Studio打开运行。目前已有2090人学习下载。通过阅读与调试读者能掌握监听端口、接受连接、收发数据与关闭连接的完整流程并以此为基础扩展自定义协议、多客户端并发或消息广播等功能是入门TCP网络编程的实用模板。1. 从一份 WinForm 双工程压缩包说起它到底能跑通什么如果你手头正好有一个叫FrmTcpServer TcpClient.rar的压缩包解压后看到两个独立的解决方案——FrmTcpServer.sln和FrmTcpClient.sln每个里面都躺着Form1.cs、Program.cs、App.config和Properties文件夹那你拿到的是一份用 C# WinForm 写的 TCP 通信最小可运行示例。它不花哨没有依赖注入没有日志框架甚至连异步都没有但恰恰因为这样它把TcpListener和TcpClient这对组合最核心的调用链路完整暴露出来了服务端监听端口、阻塞等待连接、拿到NetworkStream读写客户端发起Connect、获取流、发数据收响应。适合谁适合刚接触System.Net.Sockets命名空间、想在一个能直接 F5 跑起来的界面上看清 TCP 连接生命周期的 C# 开发者。你不需要配数据库不需要装中间件两个 exe 一前一后启动就能看到消息在两端来回。2. 拆开两个 sln服务端与客户端的工程结构对照2.1 服务端 FrmTcpServer 的启动链路服务端工程的入口在Program.cs标准 WinForm 模板Application.Run(new Form1())。真正干活的是Form1.cs里面通常会在窗体加载时初始化TcpListener。我一般会先确认App.config里有没有把端口和 IP 抽成配置项如果原包没抽自己加一个appSettings也不费事。下面这段是服务端监听的核心骨架参数含义我逐行标了// Form1.cs 中服务端监听逻辑 private TcpListener listener; private Thread listenThread; private void StartServer() { // IPAddress.Any 表示监听本机所有网卡端口选 9000 避免与常用端口冲突 listener new TcpListener(IPAddress.Any, 9000); listener.Start(); // 开始监听底层调用 socket listen listenThread new Thread(AcceptLoop); // 单独线程跑 Accept避免卡 UI listenThread.IsBackground true; // 后台线程窗体关闭时自动退出 listenThread.Start(); } private void AcceptLoop() { while (true) { // AcceptTcpClient 是阻塞调用没有客户端连接时线程停在这里 TcpClient client listener.AcceptTcpClient(); // 每来一个客户端开一个线程处理示例项目常见做法 Thread t new Thread(() HandleClient(client)); t.IsBackground true; t.Start(); } }逻辑说明IPAddress.Any等价于0.0.0.0意味着局域网内其他机器也能连进来如果你只想本机测试改成IPAddress.Loopback更安全。AcceptTcpClient()返回的TcpClient对象封装了一个已建立的 socket 连接后续所有读写都通过它。参数上唯一需要你动手改的就是端口号9000 只是示例实际用的时候先netstat -ano | findstr 9000确认没被占用。2.2 客户端 FrmTcpClient 的连接与收发客户端工程结构和服务端对称Form1.cs里通常有一个“连接”按钮和一个“发送”按钮。连接动作对应TcpClient.Connect发送动作对应往NetworkStream写字节。这里有个细节很多示例直接用StreamWriter包NetworkStream写的时候记得Flush()否则数据可能留在缓冲区里对面一直收不到这种问题在 UI 上表现为“点了发送但服务端没反应”新手很容易在这里卡住。// Form1.cs 中客户端连接与发送逻辑 private TcpClient client; private NetworkStream stream; private void btnConnect_Click(object sender, EventArgs e) { client new TcpClient(); // 同步连接超时时间受系统默认 TCP 超时控制局域网内通常瞬间完成 client.Connect(IPAddress.Parse(127.0.0.1), 9000); stream client.GetStream(); // 开启接收线程否则接收会阻塞 UI Thread recvThread new Thread(ReceiveLoop); recvThread.IsBackground true; recvThread.Start(); } private void btnSend_Click(object sender, EventArgs e) { byte[] data Encoding.UTF8.GetBytes(txtSend.Text); stream.Write(data, 0, data.Length); // 直接写字节数组 stream.Flush(); // 确保立即发出 }参数说明Connect的第一个参数是服务端 IP本机测试用127.0.0.1第二个是端口必须和服务端TcpListener绑定的端口一致。Encoding.UTF8.GetBytes把字符串转成字节数组Write的三个参数分别是缓冲区、偏移量、写入长度。如果你用StreamWriter构造时把AutoFlush设为true也能省掉手动Flush但性能上会有细微差别示例项目里怎么选都行关键是别漏。2.3 两个工程怎么同时跑起来在 Visual Studio 里你可以分别打开两个.sln各自 F5 启动也可以把两个项目加到一个解决方案里右键解决方案属性 → 启动项目 → 多启动把两个都设为“启动”。运行顺序必须是先服务端后客户端因为客户端Connect时如果服务端还没Start会直接抛SocketException提示“由于目标计算机积极拒绝无法连接”。这个报错信息很明确看到它就去检查服务端是不是没跑起来或者端口写错了。3. 把示例改成能用的工具三个必须动刀的地方3.1 接收循环的缓冲区与粘包处理原包里的接收逻辑大概率是stream.Read(buffer, 0, buffer.Length)然后直接Encoding.UTF8.GetString。这在短消息场景下能跑但一旦发送频率上来或者单条消息超过缓冲区大小就会出现“粘包”和“半包”——你收到的一次Read可能包含两条消息也可能只有半条。TCP 是字节流协议它不保证你的Write和对方的Read一一对应。常见做法是加一个长度前缀发送前先写 4 字节的 int 表示消息体长度接收端先读 4 字节拿到长度再循环读到足够字节为止。// 发送端先写长度再写内容 byte[] body Encoding.UTF8.GetBytes(message); byte[] lenPrefix BitConverter.GetBytes(body.Length); stream.Write(lenPrefix, 0, 4); stream.Write(body, 0, body.Length); // 接收端先读 4 字节长度再按长度读满 byte[] lenBuf new byte[4]; int read 0; while (read 4) read stream.Read(lenBuf, read, 4 - read); int bodyLen BitConverter.ToInt32(lenBuf, 0); byte[] bodyBuf new byte[bodyLen]; read 0; while (read bodyLen) read stream.Read(bodyBuf, read, bodyLen - read); string msg Encoding.UTF8.GetString(bodyBuf);这段代码的关键在于两个while循环它们保证“读满”指定字节数才继续。BitConverter默认小端序两端一致就行。如果你不想自己处理粘包也可以换用StreamReader.ReadLine按行读但那样消息里就不能包含换行符适用场景有限。3.2 跨线程更新 UI 的正确姿势WinForm 控件只能在创建它的线程UI 线程上访问。接收线程里如果直接txtLog.AppendText(msg)在调试模式下会抛InvalidOperationException提示“线程间操作无效”。解决办法是用Invoke或BeginInvoke把更新操作封送回 UI 线程。private void AppendLog(string msg) { if (txtLog.InvokeRequired) // 判断当前线程是否是 UI 线程 { // BeginInvoke 异步封送不阻塞接收线程 txtLog.BeginInvoke(new Actionstring(AppendLog), msg); } else { txtLog.AppendText(${DateTime.Now:HH:mm:ss} {msg}\r\n); } }InvokeRequired为true说明当前不在 UI 线程BeginInvoke把AppendLog自己重新排队到 UI 线程执行。用BeginInvoke而不是Invoke是为了避免接收线程被阻塞——如果 UI 线程正忙Invoke会一直等接收循环就卡住了。3.3 断开连接与资源释放示例项目里点关闭按钮时如果没显式Close掉TcpClient和NetworkStream端口会处于TIME_WAIT状态短时间内重新启动服务端会报“地址已在使用”。正确做法是在窗体FormClosing事件里按顺序关闭先关流再关客户端最后停监听器。private void Form1_FormClosing(object sender, FormClosingEventArgs e) { stream?.Close(); // 关闭网络流 client?.Close(); // 关闭客户端连接 listener?.Stop(); // 停止监听释放端口 }Stop()内部会关闭底层 socket 并释放端口绑定。如果你只Close了客户端而没Stop监听器服务端进程虽然退出了但端口可能还被占用几秒到几十秒具体取决于操作系统回收策略。4. 避坑排查五个让示例跑不起来的常见问题现象一客户端Connect立刻抛异常“目标计算机积极拒绝”。原因服务端没启动或者端口号不一致或者服务端绑定了127.0.0.1而客户端连的是局域网 IP。 解决先确认服务端窗体已经显示且监听已启动用netstat -ano | findstr 端口号看端口是否处于LISTENING检查两端 IP 和端口是否完全一致。现象二连接建立成功但发送数据后服务端收不到。原因用了StreamWriter但没Flush数据留在缓冲区或者服务端接收循环里Read的缓冲区太小一次没读完。 解决发送端加Flush()或设AutoFlush true接收端改用长度前缀方案确保读满。现象三接收线程里更新文本框报“线程间操作无效”。原因直接在非 UI 线程访问了控件。 解决用InvokeRequired判断后BeginInvoke封送参考 3.2 的代码。现象四关闭客户端后服务端还在往已断开的连接写数据抛IOException。原因服务端没有感知到客户端已断开继续Write会触发异常。 解决在服务端的HandleClient里用try/catch包住读写循环捕获IOException和ObjectDisposedException后跳出循环并清理该客户端资源。现象五服务端重启时报“通常每个套接字地址只允许使用一次”。原因上一次的TcpListener没有Stop端口处于TIME_WAIT。 解决确保FormClosing里调用了listener.Stop()如果还不行可以在Start之前设置listener.Server.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true)但更推荐先排查是否真的释放了。5. 从示例到可用加一个心跳与断线重连的进阶技巧示例项目跑通之后你大概率会想把它用到真实场景里比如设备状态上报或者简单的消息推送。这时候第一个绕不开的问题就是TCP 连接可能因为网络波动、中间设备超时回收而悄悄断掉而两端都不一定立刻知道。我一般会加一个心跳机制——客户端每隔固定时间发一个极短的约定包服务端收到后回一个确认如果客户端连续几次没收到确认就主动断开重连。// 客户端心跳线程 private void HeartbeatLoop() { while (isConnected) { try { byte[] ping Encoding.UTF8.GetBytes(PING); stream.Write(ping, 0, ping.Length); stream.Flush(); } catch { isConnected false; // 写失败说明连接已断 Reconnect(); // 触发重连逻辑 } Thread.Sleep(5000); // 5 秒一次按实际网络质量调整 } }参数上心跳间隔太短会浪费带宽和电量太长则断线发现不及时。局域网内 5 到 10 秒比较常见公网环境可以放宽到 30 秒。服务端收到PING后回PONG客户端在接收循环里识别到这个约定包就不往 UI 上刷避免日志被心跳淹没。重连逻辑里要注意加退避比如第一次隔 1 秒重试第二次 2 秒第三次 4 秒避免服务端还没恢复就被客户端疯狂重连打满连接队列。还有一个我踩过的坑重连时不要复用原来的TcpClient对象直接new TcpClient()重新Connect旧对象该Close就Close。复用旧对象在某些 .NET 版本上会抛ObjectDisposedException而且底层 socket 状态已经不可信了。从那以后我每次写 TCP 客户端都会把“连接建立”和“连接恢复”当成两条独立路径来测先手动断网再恢复看心跳能不能把连接拉回来。希望这份拆解能帮你把这份 WinForm TCP 示例真正用起来而不只是跑个 Demo 就放下。本文还有配套的精品资源点击获取