☰
C# TCP/IP网络编程:TcpListener/TcpClient完整例程与避坑指南
2026/10/8 8:28:35 网站建设 项目流程

简介:面向初学网络编程的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 而不是裸 Socket

TcpListener 和 TcpClient 都位于 System.Net.Sockets 命名空间,内部包装的是同一个 Socket,只是把常见的流程收敛了。裸 Socket 写服务端要自己处理 Bind、Listen、BeginAccept、EndAccept、BeginReceive、EndReceive,一个完整的收发流程配齐事件回调,至少两百行起步,而且新手很容易在异步回调里把上下文搞丢。TcpListener 帮你把 Bind 和 Listen 封装成了构造参数加 Start(),接受连接只要一句 AcceptTcpClientAsync;TcpClient 把 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 和 IPEndPoint;System.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 Task<byte[]> 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 /F

netstat 输出里最后一列就是进程 ID,taskkill 直接结束它。注意这条命令会把指定 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 例程,都强制把长度前缀协议、心跳和断线重连这三件事一起放进去,哪怕只是给同事临时做联调用的小工具也不偷懒。这套习惯帮我避掉了无数次“现场连不上”“发大包卡死”“进程死了还显示在线”的救火。希望帮到你,这份完整例程打包好了,直接下载就能跑。

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

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

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

立即咨询