ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C# TCP/IP网络编程:TcpListener/TcpClient完整例程与避坑指南

C# TCP/IP网络编程:TcpListener/TcpClient完整例程与避坑指南 简介面向初学网络编程的C#开发者这份TCP/IP通信例程提供了最简客户端与服务端实现完整演示TcpListener监听、TcpClient连接、字节流收发等基础操作。源码基于System.Net.Sockets命名空间编写逻辑简洁、注释清晰便于理解TCP与IP的分工以及套接字编程的核心步骤。压缩包为RAR格式共55个文件包体仅450KB其中12个C#源码、8个可直接运行的exe程序还有项目工程文件sln/csproj、界面资源resx/ico和配置文件等适合在Visual Studio中直接打开运行。已有206人浏览学习。下载后既能查看源码逐行学习Socket调用流程也能运行客户端和服务端程序直观观察本机通信效果为进一步扩展多客户端处理、异常捕获和数据编码等实战技能奠定基础。1. TCP/IP 与 C# 例程为什么这是最省事的客户端/服务端启蒙我最早写 TCP/IP 程序是给一台扫码设备做上位机联调。对方只丢给我一份通讯协议说明所有收发都要自己写网上一搜又是几百行的 Socket 封装看着头皮发麻。后来发现 C# 自带的 TcpClient 和 TcpListener 这套封装能把客户端和服务端最基本的数据往来压到一百行以内而且不需要懂底层细节改一改就能直接用。这份例程做的事情很单纯把一个能真实收发数据的 TCP/IP 客户端和服务端跑起来把连接、发送、接收、断开这几个环节完整走一遍。适合刚开始写 C# 网络程序的人也适合要在局域网里快速搭一个联调通道的工程师先跑通再谈优化。2. 选型与环境TcpListener/TcpClient 还是原生 Socket很多人在动手前会纠结一个问题C# 网络编程到底该从 Socket 开始还是直接用封装好的类。我的建议很直接做客户端/服务端例程优先选 TcpListener 和 TcpClient这两个类已经覆盖了 90% 的联调场景代码量小、错误少、调试直观。等真正遇到高并发或特殊协议需求再回头上原生 Socket 不迟。2.1 为什么选 TcpListener/TcpClient 而不是裸 SocketTcpListener 和 TcpClient 都位于 System.Net.Sockets 命名空间内部包装的是同一个 Socket只是把常见的流程收敛了。裸 Socket 写服务端要自己处理 Bind、Listen、BeginAccept、EndAccept、BeginReceive、EndReceive一个完整的收发流程配齐事件回调至少两百行起步而且新手很容易在异步回调里把上下文搞丢。TcpListener 帮你把 Bind 和 Listen 封装成了构造参数加 Start()接受连接只要一句 AcceptTcpClientAsyncTcpClient 把 Connect 封装成一句 ConnectAsync连上之后 GetStream() 拿到 NetworkStream读写就像操作普通流一样。但封装不是万能。如果你需要精细控制 TCP 层行为比如自定义 KeepAlive 的探活间隔、用 Raw Socket 收发 ICMP、或者要支撑上万并发连接TcpClient 和 TcpListener 会显得笨重。这时要么换原生 Socket 配合 SocketAsyncEventArgs要么直接上更高层的框架。判断标准很简单你的目标是不是“把数据可靠地传来传去”是就用封装类。下面这个对比表可以帮你快速决策方案典型代码量适合场景不适合场景TcpListener/TcpClient几十到一百行原型验证、上位机联调、小型工具高并发网关、自定义 TCP 行为原生 Socket两百到五百行需要控制 SocketOption、超时、高性能读写日常业务通信复杂度不值得ASP.NET Core / SignalR视框架而定Web 场景、跨语言、需要协议标准纯局域网裸 TCP 通信我在实际项目中 80% 的 TCP 通信都是 TcpClient 加 TcpListener 完成只有做实时数据网关时才动 Socket。例程阶段完全没必要给自己加难度。2.2 环境准备创建控制台工程与命名空间这份例程我用的是 .NET 6 及以上版本控制台项目直接使用顶层语句代码量更少也更容易阅读。先创建两个独立的控制台工程一个当服务端一个当客户端dotnet new console -n TcpServer dotnet new console -n TcpClient创建完两个工程后打开 Program.cs把需要的命名空间引进来。服务端和客户端都是同样的三个using System.Net; using System.Net.Sockets; using System.Text;System.Net.Sockets 提供 TcpListener、TcpClient、NetworkStream 这些核心类System.Net 里是 IPAddress 和 IPEndPointSystem.Text 用来做字符串和字节数组的互转。很多第一次写 C# TCP 的人会漏掉 System.Text然后发现自己拿到的 byte[] 不知道怎么变成中文消息其实一句 Encoding.UTF8.GetString 就能解决。这三个命名空间就够跑通整个例程不需要额外装 NuGet 包。为了让两个工程同时运行建议直接在终端里开两个窗口分别进入目录执行 dotnet run。后面所有联调操作都基于这个双窗口模式。3. 服务端实现监听端口与数据收发的完整代码服务端是整个例程的主角负责监听端口、接受客户端连接、收发数据。我习惯把服务端拆成两个部分一部分是主流程只管建立监听和接受连接另一部分是单连接处理逻辑负责具体的数据读写。这样后面加心跳、加并发限制都不需要改动主流程。3.1 服务端主流程创建监听器与接受连接先看完整的服务端核心代码我把它写在 Program.cs 里using System.Net; using System.Net.Sockets; using System.Text; TcpListener listener new TcpListener(IPAddress.Any, 9000); listener.Start(); Console.WriteLine($服务端启动监听端口: 9000); while (true) { TcpClient client await listener.AcceptTcpClientAsync(); Console.WriteLine($客户端接入: {client.Client.RemoteEndPoint}); _ HandleClientAsync(client); } async Task HandleClientAsync(TcpClient client) { using (client) using (NetworkStream stream client.GetStream()) { byte[] buffer new byte[1024]; int read; while ((read await stream.ReadAsync(buffer, 0, buffer.Length)) 0) { string msg Encoding.UTF8.GetString(buffer, 0, read); Console.WriteLine($收到: {msg}); byte[] reply Encoding.UTF8.GetBytes($服务端已收到: {msg}); await stream.WriteAsync(reply, 0, reply.Length); } Console.WriteLine(客户端断开); } }这里有几个关键点要说明。IPAddress.Any 表示绑定本机所有网卡地址局域网内任何一台机器都能通过你的本机 IP 连进来如果只想本机测试改成 IPAddress.Loopback 更安全。端口我选了 9000避开 80、443 这类常被占用的端口你也可以换成 5000 到 10000 之间任意一个。AcceptTcpClientAsync 在没有客户端连接时一直挂起等待来一个连接就返回一个 TcpClient 实例。我用了_ HandleClientAsync(client)这种写法意思是告诉编译器这个 Task 不需要 await让它后台跑主循环立刻回到 AcceptTcpClientAsync 等待下一个客户端。注意 HandleClientAsync 内部要把异常处理做好否则这个未观察的 Task 异常会在某些情况下导致程序静默崩溃。一个简单可靠的做法是在读循环外加 try/catch把异常信息打到控制台。读循环里 ReadAsync 返回 0 表示对端正常关闭了连接返回大于 0 表示读到数据。这里最容易踩的一个认知坑是TCP 是流不是消息。ReadAsync 一次返回的数据不一定正好是一条完整业务消息可能粘了多条也可能只读了半条。这个下一章专门讲。3.2 并发边界多个客户端同时连接怎么办上面的写法每来一个客户端就开一个异步任务.NET 线程池会负责调度同时接入几十个客户端没有问题。但对联调工具来说还是要防止并发数失控。我通常会给服务端加一个 SemaphoreSlim 限制最大并发连接数SemaphoreSlim semaphore new SemaphoreSlim(50); async Task HandleClientAsync(TcpClient client) { await semaphore.WaitAsync(); try { using (client) using (NetworkStream stream client.GetStream()) { // 收发逻辑与上面一致 } } finally { semaphore.Release(); } }SemaphoreSlim 初始值 50 表示最多 50 个连接同时在处理第 51 个客户端会在 WaitAsync 处排队直到前面某个连接释放。这个限制很重要如果你的程序收到大量短连接而且每个连接处理时间较长不限制并发时线程池会被占满后面所有连接都会变慢看起来像网络卡死。实际参数值根据你机器的负载能力调整联调工具 50 足够生产环境这个数字要重新评估。还有一个细节服务端程序退出时要记得调用 listener.Stop() 释放端口。控制台程序直接关窗口时操作系统会回收但如果你在 IDE 里反复启动调试端口可能被上一次残留进程占住这个坑在第五章里专门讲。4. 客户端实现连接服务端与消息收发模型客户端相对简单但同样有要注意的地方。很多初学者把客户端写成“先发送、再接收、结束”跑一次就退这其实忽略了 TCP 通信中接收是一个持续过程。我会先给你看最简的同步收发例程再补一个后台接收模型这两个形态覆盖了大多数场景。4.1 客户端连接与发送Connect 之后做什么客户端的核心是 TcpClient 加 NetworkStream。来看代码using System.Net.Sockets; using System.Text; using TcpClient client new TcpClient(); await client.ConnectAsync(127.0.0.1, 9000); Console.WriteLine(已连接服务端); NetworkStream stream client.GetStream(); for (int i 0; i 5; i) { string msg $第 {i 1} 条消息; byte[] data Encoding.UTF8.GetBytes(msg); await stream.WriteAsync(data, 0, data.Length); Console.WriteLine($发送: {msg}); byte[] buffer new byte[1024]; int read await stream.ReadAsync(buffer, 0, buffer.Length); string reply Encoding.UTF8.GetString(buffer, 0, read); Console.WriteLine($服务端返回: {reply}); await Task.Delay(1000); } client.Close();ConnectAsync 的第一个参数是服务端 IP本机调试用 127.0.0.1跨机器联调时改成服务端那台机器的局域网 IP比如 192.168.1.100。如果服务端没启动你会看到 SocketException提示目标计算机积极拒绝这属于正常现象先启服务端再启客户端即可。这里需要说清楚一个常见误区WriteAsync 之后立刻 ReadAsync 是可行的因为 TCP 是全双工的。但要注意读写交错不要写成“循环里先写 100 次再读 100 次”会把自己堵死。因为服务端回显是按请求来的如果客户端只发不收服务端发送缓冲区写满后就阻塞了整个链路卡住。这个例程里交替发收是最稳妥的节奏。NetworkStream 不是线程安全的不要在多个线程里同时对一个 stream 做 WriteAsync会让两条消息的字节交错对端解析全乱。客户端如果需要同时收发正确做法是发送由一个线程负责接收由另一个线程负责而不是多个线程抢同一个写方向。4.2 后台接收与断线识别上面的例程在主流程里接收适合“一问一答”这种模式。但真实场景里服务端经常会主动推送数据比如状态变化、告警信息。这时候客户端必须在后台持续读流不能阻塞在主流程。我通常用 Task.Run 开一个接收循环Task.Run(async () { byte[] buffer new byte[1024]; try { while (true) { int read await stream.ReadAsync(buffer, 0, buffer.Length); if (read 0) { Console.WriteLine(服务端已断开); break; } string message Encoding.UTF8.GetString(buffer, 0, read); Console.WriteLine($收到: {message}); } } catch (Exception ex) { Console.WriteLine($接收异常: {ex.Message}); } });这个后台循环里 ReadAsync 会一直挂着等待数据直到服务端关闭连接返回 0或者连接异常抛异常。注意 catch 一定要写很多人不写这个 catch结果服务端重启时客户端直接闪过一个异常就毫无反应了调试时还得靠异常窗口才看到问题。实际踩过坑的人都知道这个接收循环最容易出现的一种“玄学”现象是看起来连接还活着服务端却已经死掉了客户端这边 ReadAsync 一直挂着不报错。这是因为 TCP 断线如果靠强制断电或进程杀死对端根本没有机会发 FIN 包客户端浑然不知。这个问题靠读循环本身解不了必须靠 KeepAlive 或应用层心跳第六章会给出具体方案。5. 避坑与排查TCP/IP 例程最常翻车的五个现场这章全是血泪经验。TCP/IP 例程写出来容易跑稳定难而且坑都长得很像不排查个半天根本看不出问题。我把最常见的五类现象、原因和解决办法列在下面每一类都对应真实项目里遇到过的情况。5.1 粘包与半包TCP 是流协议不是消息协议现象客户端连着发两条消息服务端一次读出来两条拼在一起的字符串或者客户端发了一个很长的字符串服务端分好几次才读完每次只读到一段话的片段。原因TCP 只保证字节按顺序到达不保证消息边界。你调用一次 WriteAsync 发送的字节对端可能一次读到也可能分三次读到反过来两次 WriteAsync 的数据也可能被合并成一次读取。这是 TCP 本身的设计不是 BUG。解决在应用层给每条消息加一个长度前缀。最常见做法是用 4 字节整数表示消息体的字节长度先发长度再发内容。发送端代码string msg 这是一条完整的业务消息; byte[] body Encoding.UTF8.GetBytes(msg); byte[] header BitConverter.GetBytes(body.Length); await stream.WriteAsync(header, 0, 4); await stream.WriteAsync(body, 0, body.Length);接收端要先把完整的 4 字节长度读出来再按长度把消息体读完整。这里有个关键点“把完整的 4 字节读完”这件事本身也可能需要循环因为一次 ReadAsync 只保证读到你请求的字节数不保证全部到位。正确写法是async Taskbyte[] ReadFullAsync(NetworkStream stream, int length) { byte[] result new byte[length]; int offset 0; while (offset length) { int n await stream.ReadAsync(result, offset, length - offset); if (n 0) throw new EndOfStreamException(连接被关闭); offset n; } return result; }调用方式统一为先 ReadFullAsync 读 4 字节得到长度再 ReadFullAsync 读完整消息体。我把这个模式固化成了工具方法凡是接手新的 TCP 联调项目第一件事就是把收发两端都改成这个协议格式。需要注意字节序问题。BitConverter 默认按本机字节序如果服务端和客户端都是 Windows x86/x64一般没问题但如果一端是 ARM 板子另一端是 PC或者跨语言通信长度字段最好用固定大端序。C# 里可以用 BinaryPrimitives.WriteInt32BigEndian 和 ReadInt32BigEndian避免平台差异。血泪教训我曾经调了一天半的协议最后发现只是字节序反了数据长度算出来是 33554432 这种离谱数字。5.2 端口被占用上次的进程还占着监听端口现象服务端启动直接抛 SocketException提示“通常每个套接字地址(协议/网络地址/端口)只允许使用一次”或者 Address already in use。原因端口被其他进程占用了最常见的是上一次在 Visual Studio 里按了停止但调试宿主进程没退出或者有另一个程序恰好用了同一个端口。解决先查端口占用情况再杀掉对应进程。Windows 命令netstat -ano | findstr 9000 taskkill /PID 12345 /Fnetstat 输出里最后一列就是进程 IDtaskkill 直接结束它。注意这条命令会把指定 PID 的进程强制杀掉执行前确认它不是你的数据库或者别的服务。如果端口被系统保留区间占用换一个端口更省事比如 9001、9002。我的习惯是统一在代码里把监听端口做成常量或者配置文件参数避免每次改端口都要重新编译。5.3 防火墙拦截局域网内连不上本机却一切正常现象客户端和服务端都在同一台机器上用 127.0.0.1 联调没问题换成局域网 IP 后客户端 ConnectAsync 一直超时或者直接被拒绝。原因Windows 防火墙默认拦截外部设备的入站连接你的监听端口没有入站规则别人的 TCP SYN 包到不了服务端进程。解决在防火墙入站规则里增加一条“允许 TCP 端口 9000 入站”。操作路径是“控制面板 - Windows Defender 防火墙 - 高级设置 - 入站规则 - 新建规则”选端口填 9000允许连接。如果是在内网开发环境里自己调试直接关闭防火墙也能跑通但我不建议在公网服务器上这么做那是把肉送到门口。还有一个细节防火墙弹窗第一次出现时勾选“专用网络”并按“允许访问”很多局域网联调问题其实就卡在这一步。5.4 半开连接服务端强杀后客户端毫无感知现象客户端后台接收循环里 ReadAsync 一直挂着服务端进程被强制结束或者拔掉网线客户端没有任何异常也没有返回 0数据管道像死了一样。原因TCP 连接在物理链路断开或者对端进程被强杀时并不会立刻发送 FIN 包客户端读不到任何结束信号表现就是无限阻塞。解决给 TcpClient 设置 KeepAlive 是在代码层面能快速补上的一个手段。Socket 层面开启 KeepAlive 后系统会按 TCP 规范定时发送探测包对端无响应时连接会被操作系统判定为断开ReadAsync 就会抛异常或返回 0。设置方式client.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true);KeepAlive 默认探测周期较长Windows 上通常要两小时级别联调时等它生效不现实。所以更实用的方案是应用层心跳也就是第六章要讲的定时发心跳包。这个坑几乎所有人都会遇到我建议一开始就把心跳设计进去而不是等到现场出问题了再去查。5.5 跨线程更新 UI上位机必踩的 Invoke 陷阱现象把 TCP 例程改造成 WinForms 或 WPF 上位机后在接收线程里直接设置 textBox.Text运行时抛 InvalidOperationException提示调用线程无法访问此对象。原因UI 控件只能在创建它的 UI 线程里更新网络接收线程是线程池里的工作线程直接操作控件属于非法跨线程访问。控制台程序打印中文没问题不代表上位机也能这么干。解决用控件自带的 Invoke 或 BeginInvoke 把更新动作切回 UI 线程。WPF 和 WinForms 都有这套机制示例textBox1.BeginInvoke(new Action(() { textBox1.AppendText(message Environment.NewLine); }));BeginInvoke 是异步投递接收线程不会因为 UI 卡顿而阻塞比 Invoke 更适合高频数据刷新。做上位机的人很多是从控制台例程起步的这一条可以帮你少走两天弯路。6. 进阶技巧心跳、断线重连与例程验证清单跑通基础例程之后我强烈建议把心跳和断线重连补上这两个功能直接决定例程能不能在真实环境里用起来。没有心跳半开连接会让客户端看起来还活着其实已经死了没有重连网络抖动一次就得人工重启客户端。心跳的最简实现是客户端定时发送固定内容服务端收到后复位超时计时器。客户端每隔 3 秒发送一个心跳帧while (true) { byte[] ping Encoding.UTF8.GetBytes(PING); await stream.WriteAsync(ping, 0, ping.Length); await Task.Delay(3000); }注意心跳帧要和业务消息区分否则服务端会把 PING 当成业务数据打印出去。更好的做法是把心跳也放进长度前缀协议里用一个消息类型字段区分类似“0x01 表示心跳、0x02 表示业务数据”的设计。服务端则记录每个连接最后一次心跳时间超时比如 10 秒未收到就主动断开并关闭槽位。断线重连的代码要放在异常处理里客户端 ReadAsync 抛异常后执行重连循环while (true) { try { using TcpClient client new TcpClient(); await client.ConnectAsync(127.0.0.1, 9000); await RunClientAsync(client); } catch (Exception ex) { Console.WriteLine($连接断开: {ex.Message}); await Task.Delay(3000); } }重连的间隔不要固定不变至少做成递增重试比如第一次等 1 秒、第二次等 2 秒、最多等 10 秒封顶避免服务端还没起来时客户端疯狂重连打日志。另外每次重连前新建 TcpClient不要复用旧的连接失败后的对象状态不可靠复用只会带来莫名其妙的异常。最后给你一份我每次写完例程都要跑一遍的验证清单照着过一遍基本能覆盖常见问题检查项操作通过标准基本连通先启服务端再启客户端双方能互发消息控制台能看到收发日志多客户端并发同时启动 3 个客户端实例各自发送消息三个客户端都能独立收发互不干扰粘包处理客户端连续发送 100 条短消息服务端按条完整解析不拼接、不截断强杀服务端直接关闭服务端进程观察客户端客户端在心跳超时或读异常后进入重连循环防火墙拦截换局域网 IP 连接连不通时能定位到防火墙端口规则问题我从那以后凡是写 TCP 例程都强制把长度前缀协议、心跳和断线重连这三件事一起放进去哪怕只是给同事临时做联调用的小工具也不偷懒。这套习惯帮我避掉了无数次“现场连不上”“发大包卡死”“进程死了还显示在线”的救火。希望帮到你这份完整例程打包好了直接下载就能跑。本文还有配套的精品资源点击获取
返回列表