C# TCP编程实战:搭建可双向通信的客户端与服务器
2026/9/18 21:35:12 网站建设 项目流程

简介:使用C#语言实现的传输控制协议客户端与服务器互发消息的完整工程包,主要面向C#网络编程初学者以及需要快速掌握网络通信基础的技术人员。该资源解决了客户端和服务器之间双向实时通信的核心需求,整个工程由单一程序实现,在客户端界面中点击按钮即可弹出服务器界面,操作直观、便于理解连接和收发流程。压缩包内共有一百一十八个文件,以源代码文件、可执行程序、解决方案文件及项目配置文件为主,同时包含少量示例与资源文件,整体大小仅一百八十三千字节,结构清晰、轻便易用。目前已有七百一十人学习浏览,适合希望动手实践传输控制协议原理、学习网络监听器与客户端类具体用法的开发者。通过完整源码,可直接查看连接建立、消息发送与接收、界面联动等关键实现,并编译运行验证通信效果,为后续开发即时通讯、远程控制等应用打下扎实基础。 做设备对接那会儿,我最常用到的组合就是C#和TCP。现场的设备只认TCP,不认HTTP,协议文档写得含糊,只给了端口号和几个十六进制命令。那时候我直接在Visual Studio里用C#写了Server和Client两个控制台工程,一边做模拟器,一边调设备,整个过程比自己预想的顺畅,也比预想的坑多。今天这篇就把这套C#实现TCP客户端和服务器的完整思路、代码骨架和踩坑记录整理出来。

这篇文章适用的人群很明确:正在给设备写上位机的、想用C#做局域网即时通讯的、以及刚接触网络编程但不想一上来就啃Socket底层文档的开发者。看完之后你能自己搭出一个Server和Client可以互相发消息的最小可用框架,并且知道怎么处理断线、粘包、资源释放这些真正影响上线稳定性的问题,而不是只停留在能连上、能发一个字符串的阶段。

1. 从“能连上”到“能双向收发”:先搞清楚TCP到底给了你什么

很多人第一次写TCP的时候,脑子里只有一个模糊的概念:客户端连服务器,然后发消息。这个理解不算错,但不够用。TCP不是一个“发一条消息过去,对面收一条”的请求响应模型,它是一条全双工的数据管道。建立连接之后,客户端和服务器各自拥有一根可以随时写入数据的端到端通道,双方都能主动发,也都能随时收。

这跟HTTP有本质区别。HTTP是单向的,客户端请求,服务器响应,服务器没办法在没有任何请求的情况下主动推数据给客户端。而TCP没有这个限制,所以做设备控制、聊天、实时数据推送、上位机采集这类场景,TCP是更自然的选择。你不需要像HTTP那样定时轮询,也不需要依赖WebSocket这种后来在TCP之上包出来的协议。直接用TCP,你控制的是最底层的数据流转。

C#做这块的优势在哪儿?第一,Socket和TcpClient/TcpListener这套封装已经帮你把底层细节屏蔽得差不多了,你不用直接跟WinSock的句柄打交道。第二,async/await异步模型写起来非常顺手,处理并发连接的时候不像传统同步代码那样容易把自己锁死。第三,Visual Studio的调试和诊断工具很成熟,线上出了问题,抓dump、看线程、查网络连接状态,都有现成的链条。

不过有个前提,你得理解TCP的两个特性,否则后面写代码会走弯路。

一个特性是“流式数据”。TCP不像UDP,它没有一个天然的“消息”边界。你调用一次Send发出去的字节,可能和对面的数据混在一起,也可能被拆成好几个TCP包到达,接收方不知道哪几个字节是一条完整数据。这就是所谓的粘包和拆包问题,后面会专门说。

另一个特性是“连接状态不可靠”。TCP的断开不是立刻通知你的。如果网线拔掉、对端断电,你本地的TcpClient在很长一段时间里可能毫无察觉,直到你尝试发送数据或者等待读超时,才发现连接已经死了。所以写TCP程序,不能指望框架帮你兜底,必须自己做心跳检测和超时处理。

理解了这两个特性,再来写代码,思路就清晰了:TCP给你的是一个双向字节流,你要在这个字节流之上自己定义消息的边界,同时要时刻假设连接随时会断,而不能假设它一直健康。

2. 先建一个最简Server:从监听端口到处理连接请求

服务器端的核心类有三个:TcpListener负责监听,TcpClient代表一个已建立的客户端连接,NetworkStream负责读写。我用TcpListener在指定IP和端口上开启监听,等于对操作系统说:把这个端口给我,谁来连都送到我这里。

写一个最简单的监听循环,一般是这样:

var listener = new TcpListener(IPAddress.Any, 8888); listener.Start(); Console.WriteLine("服务器已启动,监听端口 8888"); while (true) { TcpClient client = await listener.AcceptTcpClientAsync(); _ = HandleClientAsync(client); }

关键点在第一行的IPAddress.Any。这个表示监听本机所有网卡的8888端口,无论是局域网地址、回环地址还是公网地址,只要数据包到达本机这个端口,都能进到监听队列。如果你只绑定了127.0.0.1,那就只能本机自己连,局域网其他机器是连不进来的。做设备对接的时候,很多现场问题就是在这里出的:代码在开发机上跑得好好的,部署到工控机上就找不到服务器,查了半天发现绑定地址不对。

HandleClientAsync里是这个连接的完整生命周期。每个客户端接入后,我开一个独立的任务去处理它,这样主循环可以继续等待新的连接。这件事看起来简单,但它背后的问题其实不小:如果每个连接都独占一个线程,连接数一多,线程调度和上下文切换的开销就会成为瓶颈。所以C#里我强烈建议你用异步方式,而不是直接在AcceptTcpClientAsync之后写一个Thread.只处理一个客户端消息的简化版大概是这个形状:

private static async Task HandleClientAsync(TcpClient client) { Console.WriteLine($"客户端 {client.Client.RemoteEndPoint} 已连接"); using var stream = client.GetStream(); byte[] buffer = new byte[4096]; int bytesRead = 0; while ((bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length)) > 0) { string message = Encoding.UTF8.GetString(buffer, 0, bytesRead); Console.WriteLine($"收到: {message}"); byte[] response = Encoding.UTF8.GetBytes("服务器收到: " + message); await stream.WriteAsync(response, 0, response.Length); } Console.WriteLine($"客户端 {client.Client.RemoteEndPoint} 已断开"); }

注意我用的是ReadAsync,而不是同步的Read。为什么?因为同步Read会一直阻塞当前线程直到有数据,如果你在UI程序里这么写,界面会直接卡死。而且同步阻塞在线程池上,本质上是浪费了一个线程资源在傻等,异步则把这个等待交给了OS,不占用线程。这就是为什么我建议你这个项目一开始就用async/await,不要图省事写成同步代码。

还有个细节:上面的循环里,ReadAsync返回0意味着对端关闭了连接,断开循环。但TCP属于可靠传输层,ReadAsync会阻塞等待数据。如果对端一直不发消息,这个await就会一直挂着。这时候你就得想想,如果客户端“静默断开”,服务器要等多久才算超时?我后面会写一个带超时机制的接收循环,这里先留一个悬念。

3. 让Server能主动发言:给每个连接建立管理上下文

如果你只想做“客户端请求,服务器应答”,上面那个代码就够用了。但我们要的是“Server和Client可以互相发消息”,这意味着服务器不能只被动地等客户端请求,还得能在任何时候向某个客户端、甚至所有客户端推送消息。这就产生了两个新的设计需求:一是服务器要能记住当前有哪些客户端在线上,二是服务器要能在主线程里随时向指定客户端写入数据。

我常用的做法是把每个客户端包装成一个会话对象,统一管理。会话对象里至少包含三样东西:TcpClient实例、NetworkStream实例、以及这个连接的状态信息。我再用一个ConcurrentDictionary长期记录所有在线会话,以客户端的标识为key。这样做的好处是,之后无论是服务器主动给特定客户端发指令,还是广播给所有客户端,都只需要查一下字典就能拿到目标会话。

下面是我在项目里用过的简化版会话管理:

public class ClientSession { public string ClientId { get; set; } public TcpClient TcpClient { get; set; } public NetworkStream Stream => TcpClient.GetStream(); private readonly byte[] _buffer = new byte[8192]; public async Task ReceiveLoopAsync() { try { while (true) { int bytesRead = await Stream.ReadAsync(_buffer, 0, _buffer.Length); if (bytesRead == 0) { Console.WriteLine($"{ClientId} 正常关闭"); break; } string msg = Encoding.UTF8.GetString(_buffer, 0, bytesRead); Console.WriteLine($"[{ClientId}] {msg}"); } } catch (Exception ex) { Console.WriteLine($"{ClientId} 异常断开: {ex.Message}"); } finally { OnlineSessions.TryRemove(ClientId, out _); TcpClient.Dispose(); } } public async Task SendAsync(string message) { byte[] data = Encoding.UTF8.GetBytes(message); await Stream.WriteAsync(data, 0, data.Length); } }

然后服务器的主循环,接入后立刻把它加进字典,再开任务跑接收循环:

TcpClient client = await listener.AcceptTcpClientAsync(); var session = new ClientSession { ClientId = Guid.NewGuid().ToString("N"), TcpClient = client }; OnlineSessions[session.ClientId] = session; _ = session.ReceiveLoopAsync();

有了这层封装,服务器主动发消息的代码就不需要在各个回调之间传递裸的TcpClient了:

await session.SendAsync("服务器主动下发的指令");

这里有一个容易忽略的细节:NetworkStream并不是线程安全的。如果一个Connection的接收循环正在读数据,另一个线程又往同一个Stream里写数据,理论上读写方向是可以并行的,因为TCP是全双工的。但你千万不要同时开两个线程都去写,或者都去读这个同一个流。多线程同时写同一个NetworkStream,底层TCP包会被交错写入,出现数据错乱。如果你有多个业务线程可能同时向同一个客户端发消息,那就得加锁,或者把发送操作统一排队到一个发送队列里。

我自己的习惯是:每个Session内部维护一个发送锁或者发送任务队列。所有发送动作都走同一个入口,谁先谁后由队列决定,绝不让两个线程同时调WriteAsync。这个习惯在并发量小的聊天程序里看不出来,但在设备控制、指令下发这种频率高的场景里,能避免很多诡异的数据错乱。

4. Client侧同样要面对异步读写:别把重连做成“死循环轰炸”

客户端的实现比服务器简单,但它有自己的难点,主要是连接管理和重连策略。很多人写客户端只觉得Connect成功就完事了,真正到现场跑起来,网络抖动一下程序就退出了,回头还怪TCP不稳定。其实TCP本身很稳定,你缺的是断线后的恢复机制。

客户端的连接代码通常长这样:

var client = new TcpClient(); await client.ConnectAsync("192.168.1.100", 8888); Console.WriteLine("已连接服务器");

TcpClient在连接失败时,抛出的异常一般是SocketException。如果你没有捕获它,应用就直接崩了。所以最简单的重连逻辑至少要包含一个捕获和延迟重试:

while (!connected) { try { await client.ConnectAsync(serverIp, serverPort); connected = true; } catch (SocketException ex) { Console.WriteLine($"连接失败,10秒后重试: {ex.Message}"); await Task.Delay(TimeSpan.FromSeconds(10)); } }

这个循环看起来没什么问题,但我见过不少人在重试逻辑里踩坑:重试延迟写成100毫秒,然后客户端在服务器重启的几分钟里疯狂地每秒尝试几十上百次连接,把服务器端口打到暂挂。你如果没控制过重试频率,一台设备出问题也许无所谓,但现场几十上百台设备一起重连,服务器端会瞬间被SYN包拥堵淹没。

所以我的重连策略是:指数退避加抖动。第一次重试等1秒,第二次等2秒,第三次等4秒,直到一个上限比如30秒,然后在每次延迟上再加一个随机的小偏移,防止多个客户端同时进入重连状态产生同步风暴。这个做法虽然不是TCP本身的机制,但它直接影响你的程序在弱网环境下的存活率。

客户端的接收循环和服务器是一样的,也是ReadAsync循环。但在客户端这边,有一个和服务器不太一样的点:如果你想在界面上显示消息,你需要把接收到的数据dispatch到UI线程。如果你用WinForm或者WPF,可以直接用Invoke,但更现代的做法是用IProgress[T]或者把接收任务跑在后台并把消息塞进Channel,再由UI订阅。如果不用WinForm,而是做一个控制台工具或者服务,那你也要考虑日志输出的线程安全问题,多线程往Console里WriteLine会交错,影响排查问题的效率。

另外,客户端在断线恢复之后,通常需要做一次业务层的重新握手,比如重新登录设备、重新提交设备标识。TCP层面的重连成功,不代表业务状态同步了。这个很容易被忽略,建议你在重连成功之后,主动发送一条注册消息,服务器收到后重新把设备纳入会话管理。

5. 双向互通最关键的一块:定义消息边界,解决粘包和半包

前面反复提到TCP是流式的,现在具体说说。客户端连续发送了两条消息:“hello”和“world”。对服务器来说,它从ReadAsync里读到的数据可能是一次就收到了“helloworld”,也可能是先收到“hel”,再收到“loworld”。这就是粘包和拆包。

如果你只是做一个开发demo,随便发个短字符串,可能永远不会踩到这个坑。但一旦你开始发JSON、发文件、发设备协议报文,数据量大一点,这个问题立刻暴露。我见过一个同事做的通信模块,测试的时候小数据一切正常,一上真实数据就乱码,他死活找不到原因,其实问题不出在编码,而出在他从未定义过消息边界。

解决这个问题思路其实很简单:在发送数据之前,先告知对端“我接下来要发多少字节”。接收端先读够这个长度,再读正文。这就是“长度前缀法”,也是TCP通信里最常用的协议设计方式之一。

发送端的做法:

var bodyBytes = Encoding.UTF8.GetBytes(jsonMessage); var lengthBytes = BitConverter.GetBytes(bodyBytes.Length); // 4字节 await stream.WriteAsync(lengthBytes, 0, 4); await stream.WriteAsync(bodyBytes, 0, bodyBytes.Length);

接收端相应地先读4个字节,解析出长度,再读对应长度的正文。这里有个微妙的地方:一次ReadAsync不保证能把4个字节全部读出来,也不保证能把整个body全部读出来。你必须写一个“完整读取”的逻辑:

private static async Task<byte[]> ReadExactlyAsync(NetworkStream stream, int count) { byte[] buffer = new byte[count]; int totalRead = 0; while (totalRead < count) { int read = await stream.ReadAsync(buffer, totalRead, count - totalRead); if (read == 0) throw new EndOfStreamException("连接已关闭"); totalRead += read; } return buffer; }

有了ReadExactlyAsync,接收循环就变成了:

while (true) { byte[] header = await ReadExactlyAsync(stream, 4); int bodyLength = BitConverter.ToInt32(header, 0); byte[] body = await ReadExactlyAsync(stream, bodyLength); string message = Encoding.UTF8.GetString(body); ProcessMessage(message); }

这一层一旦做好,整个通信体系就稳了。你可以在这之上堂而皇之地使用JSON、XML甚至自定义二进制协议,完全不用担心边界错乱。

还有一个方案是“分隔符法”,也就是用\r\n或者某个特殊字符作为消息的结束符。这种方式在文本协议里很常见,比如原始的FTP协议、SMTP协议,很多都是按行分割。它的好处是实现简单,调试也直观,缺陷是消息内容里不能出现分隔符,否则会把消息截断。如果你想传输二进制内容,分隔符方案就不太合适了。我在自己的项目里统一使用长度前缀法,因为它在二进制和文本场景下都通用,而且性能开销只多了4个字节。

6. 服务器主动发送的两个设计点:给连接加个身份标识,消息才能精准送达

前面说到服务器用ConcurrentDictionary管理了所有在线会话,但客户端连进来的时候,服务器怎么知道它是哪台设备?你不能假设网络连接本身携带业务信息。TCP层只知道IP和端口,不知道这台设备是一号流水线的传感器还是二号工位的PLC。

所以我在客户端连接成功之后,要求它立即发送一条“注册消息”,带上设备ID或者客户端ID。服务器收到之后,不是把它当普通业务消息处理,而是把它当作会话的标识,从那一刻起,这个设备ID就与TcpClient实例绑定。后面的所有主动推送,都是按照这个设备ID来找会话的。

注册这一步虽然多了一次交互,但它带来了几个好处。第一个好处是业务解耦,服务器不再依赖IP来识别设备。第二个好处是支持了断线重连后的重新标识,客户端重连后发一条新的注册消息,服务器这边就能把新连接替换掉旧连接。第三个好处是方便后续做权限控制,你完全可以只放行注册消息里携带的合法设备ID。

服务器向特定会话发送消息时,直接在发送锁的保护下写数据。如果发送过程抛了异常,说明对端已经断开,那就顺手把会话从字典里移除,否则下次再尝试发送时,你会对着一堆僵尸连接反复写失败。用了一段TcpClient你就会发现,TcpClient.Connected这个属性真的不太可靠。它在很多情况下反映的是上一次I/O操作时的状态,而不是真实的连接状态。唯一可靠的检测方式是发送一个探测消息,或者让接收循环正常返回。所以我在代码里从来不做提前判断,而是依赖发送时的异常作为断开信号。

7. 断线检测与优雅关闭:别让进程退出后端口还被占用

做网络程序最容易忽略的是关闭顺序,但在现场这是非常致命的一个问题。如果你异常退出服务器程序,没有把托管的TcpClient、NetworkStream一一释放,你会看到这样的情况:程序已经结束,但端口还是被占用着,怎么启动都报“地址已在使用中”。这个现象在Windows上特别常见,原因是TCP连接关闭后进入了TIME_WAIT状态,旧连接还没彻底消失,新的监听就起不来。

解决这个问题的措施有三层。第一层是正常执行流程中,必须用using或者try/finally保证TcpClient和NetworkStream会被Dispose。第二层是异常退出时,操作系统最终会回收句柄,但如果你的程序是长时间运行的守护服务,我建议你注册退出事件,在退出前统一遍历所有在线会话,显式调用Close。第三层是如果要立刻重启监听而不想等待系统释放端口,可以在TcpListener.Start之前设置一个Socket选项:允许端口复用。不过这个选项要慎用,开在服务器上没问题,如果开在客户端上,反而可能让多个客户端实例绑到同一个端口,引起混乱。

优雅关闭不只是让服务器释放资源,客户端也一样。客户端如果直接关掉进程,TCP连接会以RST的方式断开,服务器端会收到异常。虽然我可以处理这个异常,但连接并没有进入正常的四步挥手。某些物联网场景里,设备端的PLC对通信异常很敏感,希望看到完整关闭流程。所以客户端在退出之前,先调用TcpClient.GetStream().Close(),再调用TcpClient.Close(),让服务器端读到0字节并走正常的关闭路径。这样做的好处是服务器可以在finally里安全地清理资源,不会留下一堆“已断开但状态还在”的会话记录。

8. 老项目里攒下来的几个实践经验

最后聊一些代码之外的积累。TCP通信本身不难,难点全在边界和异常处理上。你多写几个项目就会发现,稳定性和功能量往往是反比关系,越简单的越稳,加了越多花哨的功能,越容易出漏子。

第一个经验:上线前一定要做断网模拟。别只测功能,功能测通了不算完。你要在客户端连着服务器的时候,手动断开网线,等半分钟再插回去,看客户端能不能自己重连,服务器能不能清理掉那条僵死会话。如果这个场景跑不通,那你这个系统到了客户现场,大概率坚持不过第一个月。

第二个经验:日志要分方向记录。收发双向的数据都要有日志,但记住日志和生产消息要隔离。我做设备上位机的时候,通信日志单独放到一个文件,业务日志放另一个文件,不然排查的时候会累死。日志里要有时间戳、方向、字节数组、十六进制或UTF8编码后的内容。很多协议问题你从业务层面看不出端倪,但看十六进制一眼就清楚了。

第三个经验:不要把“心跳检测”当成可选项。TCP本身没有应用层心跳,很多连接看起来还在,实际上对端早就没响应了。我的做法是每隔10秒或15秒发送一个空的“心跳请求”帧,接收端如果连续三次没收到心跳,就判定连接失效。在服务器端,则给接收循环加了超时机制:如果一段时间内没读到数据,就主动释放会话。这样不会出现大量僵尸连接堆积。

第四个经验:使用CancellationToken来优雅取消接收循环,而不是靠bool标志位。用bool的话,你在等待ReadAsync的时候根本没法退出,必须先关闭流让ReadAsync抛异常才能退出,这种方式不仅丑陋而且容易卡住。CancellationToken配合ReadAsync的重载,能让你在收到退出信号时立刻停止阻塞,干净利落地结束任务。

说到底,C#写TCP客户端和服务器,门槛真的不高。只要你把accept循环、接收循环、协议边界和资源释放四件事想清楚,剩下的就是业务层代码了。我做设备对接的时候,这套东西在开发机上几分钟就能跑通,真正花时间去啃的,反而是协议文档和现场网络环境的问题。希望这篇文章能帮你把前面这几个基础桩打牢,后面遇到复杂业务时不用再回头补课。

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

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

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

立即咨询