用 TcpClient
和 TcpListener
做最简双向通信
这是 C# 里最常用、最可控的 TCP 通信方式。不依赖高层封装(比如
HttpClient),能直接读写字节流,适合自定义协议、实时数据传输等场景。
关键点:客户端用
TcpClient连接,服务端用
TcpListener监听;双方都通过
NetworkStream读写;必须手动处理粘包/拆包(除非你只传固定长度消息)。
常见错误现象:
TcpClient.Connect()报
SocketException(目标主机不可达、端口被拒绝)、
NetworkStream.Read()阻塞卡死(对方没发完或已断连但未触发异常)、中文乱码(没统一编码,比如一方用
UTF8一方用
ASCII)。 服务端监听必须调用
Start(),否则
AcceptTcpClient()会一直阻塞或抛异常 每次
Read()返回的是实际读到的字节数,不能假设一次读完全部数据;建议用循环读取直到满足预期长度或遇到结束标记 发送前建议先
stream.Write()再
stream.Flush(),尤其在非
AutoFlush=true的包装流中 务必在
finally或
using中关闭
TcpClient和
NetworkStream,否则连接会残留(TIME_WAIT 状态堆积)
/* 服务端示例(控制台) */
var listener = new TcpListener(IPAddress.Any, 8080);
listener.Start();
Console.WriteLine("等待连接...");
using var client = await listener.AcceptTcpClientAsync();
using var stream = client.GetStream();
<p>var buffer = new byte[1024];
int bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length);
string msg = Encoding.UTF8.GetString(buffer, 0, bytesRead);
Console.WriteLine($"收到: {msg}");</p><p>string reply = "OK";
await stream.WriteAsync(Encoding.UTF8.GetBytes(reply), 0, reply.Length);</p>异步操作别忘了 await
,也别漏掉超时控制
同步方法(如
Connect()、
Read())会阻塞线程,在服务端高并发或客户端 UI 线程中极易卡死。C# 的
TcpClient和
TcpListener都提供了完整的异步方法族(
ConnectAsync、
AcceptTcpClientAsync、
ReadAsync、
WriteAsync),必须配合
await使用。
但异步不等于无风险:如果对端突然断网,
ReadAsync可能无限期挂起(TCP Keep-Alive 默认关闭)。所以生产环境必须设超时。 设置
TcpClient.Client.ReceiveTimeout和
SendTimeout(单位毫秒),注意:这些只对同步方法生效 异步操作推荐用
CancellationToken+
Task.TimeoutAfter()(.NET 6+)或
Task.WhenAny()包装 不要依赖
client.Connected判断连通性——它只反映上次操作的状态,网络中断后该属性仍可能返回
true真正可靠的检测是尝试读/写并捕获
IOException或
SocketException(如
WSAETIMEDOUT、
WSAECONNRESET)
UdpClient
不是替代方案,别混用场景
有人看到 “TCP/IP” 就下意识查
UdpClient,这是典型误解。
UdpClient走的是 UDP 协议,无连接、不保证顺序、不保证送达。它适合广播、音视频推流、心跳包等容忍丢包的场景,但绝不能用来替代 TCP 做可靠命令交互或文件传输。
如果你的需求是“发一条指令,等一个确定响应”,就必须用
TcpClient;用
UdpClient后发现收不到回复、多次重发、自己实现 ACK 机制……说明你已经踩进协议误用的坑了。
UdpClient.Send()成功只表示数据进了系统发送缓冲区,不代表对方收到
UdpClient.Receive()是阻塞式,且每次只收一个 UDP 包(最大约 64KB),不会自动拼包 TCP 的流量控制、拥塞避免、重传机制都是内建的;UDP 全要你自己补
跨平台和 .NET 版本要注意 System.Net.Sockets
的行为差异
.NET 5+ 统一了 Socket API,但某些细节仍有区别:比如
TcpClient.Client.DuplicateAndClose()在 Linux 上不支持;
TcpListener构造函数传
IPAddress.Any在 macOS 上可能绑定失败(需改用
IPAddress.IPv6Any并启用 IPv6);
NetworkStream.ReadAsync在 .NET Core 3.1 之前不支持取消令牌。
更隐蔽的问题是 DNS 解析:Windows 下
TcpClient.Connect("localhost", 8080) 通常走 IPv4,Linux/macOS 可能优先走 IPv6,导致连接被防火墙拦截或服务端没监听对应地址族。
显式指定地址族:用 new IPEndPoint(IPAddress.Parse("127.0.0.1"), 8080) 替代字符串主机名
检查目标端口是否真被监听:netstat -an | grep 8080(Linux/macOS)或
netstat -ano | findstr :8080(Windows) 防火墙常拦新端口:Linux 用
ufw allow 8080,Windows 检查“高级安全 Windows 防火墙”入站规则 Docker 容器内访问宿主服务,别用
localhost,改用
host.docker.internal(Docker Desktop)或宿主真实 IP
实际写的时候,最容易被忽略的是:没有处理半关闭连接(fin 包到达但 socket 还开着)和读取不完整数据包的边界逻辑。哪怕只是调试,也要在
ReadAsync后检查返回值是否为 0(对端关闭),并设计好自己的消息头(比如前 4 字节存长度)来应对粘包。
