C# TCP异步编程实战:双向收发服务器与客户端完整源码

发布时间:2026/10/12 6:03:56
C# TCP异步编程实战:双向收发服务器与客户端完整源码 简介一份基于C#的TCP客户端与服务器双向通信示例工程面向学习C#网络编程的初中级开发者。它演示了如何通过TcpListener和TcpClient实现互发消息并由同一个程序在客户端界面点击按钮弹出服务器窗体覆盖了即时通讯、远程控制等常见网络应用的基础模型帮助读者从零搭建一个可运行的TCP通信程序。压缩包共118个文件大小仅183KB包含C#源码(.cs)、解决方案与工程文件(.sln/.csproj)、界面资源(.resx)、应用配置(.config)、可执行文件(.exe)及文本说明等。文件类型集中且工程规模精简便于直接打开编译和逐段理解尤其适合课堂实验或入门实操。目前已有713人学习下载。通过本项目读者可以掌握服务器监听、连接建立、消息收发、异常处理及资源释放的完整流程同时观察单程序内多窗体交互的实现方式。在此基础上稍加扩展还能延伸到文件传输、多客户端管理等功能为系统学习C#网络编程和后续开发实际通信系统打下扎实基础。1. C# 网络协议自学与联调一套能双向发消息的 TCP 服务器 / 客户端源码很多人学 C# 网络编程第一步就倒在 Sockets 的坑里照着网上的例子写了个服务器结果客户端只发不收或者收一次就卡死。这个资源不一样它把「客户端和服务器互相发消息」做成了闭环——两端各有一个独立的发送通道和接收通道不存在单向阻塞的问题。你下载下来本地跑一个控制台就能看到两端实时收发的效果。这套代码适合三类人刚学完 C# 语法想接触网络协议的新手、要交课程设计的学生、以及想快速搭一个内网联调工具的在职开发者。核心就三件事TCP 连接的建立、异步收发模型、断开重连的兜底处理。2. TCP 服务器与客户端先把连接模型和 C# 异步机制搞清楚2.1 TCP 协议的关键特性为什么服务器要先 Listen客户端要先 ConnectTCP 是面向连接的流式协议通信前必须先经过三次握手。这个握手过程在 C# 里被封装成了两个动作服务器调用TcpListener.Start()进入监听状态客户端调用TcpClient.ConnectAsync()发起连接请求。握手完成后两端各自持有一个NetworkStream流对象之后的收发都基于这个流进行。这套资源的核心设计思路是服务器端用AcceptTcpClientAsync循环接受客户端接入每接进来一个客户端就开一个独立任务去处理它的收发客户端则用一个主任务负责发送一个后台任务负责接收。这样做的好处是收发互不阻塞——你敲一行字发给服务器同时服务器返回的消息也能立刻在屏幕上打印出来。C# 的网络编程为什么适合用来理解这些流程因为TcpListener和TcpClient把 socket 层面的地址绑定、端口监听、连接握手都封装好了你不需要去操作Socket.Bind()、Socket.Listen()这些底层细节精力可以集中在业务逻辑上。对于刚入门的人来说这是最平滑的学习路径先学会用封装好的类把数据从 A 端送到 B 端再回头去理解底层协议栈比一上来就啃原始 Socket API 要高效得多。2.2 为什么选 TcpListener / TcpClient 而不是直接操作 Socket直接操作Socket类不是不行但有几个现实问题手动处理接受循环时容易漏掉异常分支、缓冲区管理容易踩内存越界的坑、异步回调写起来代码膨胀得很厉害。而TcpListener和TcpClient已经把这些封装进去了内部仍然调用的是 socket 相关 API但对外暴露的模型更接近业务逻辑。这套资源里服务器端用的就是TcpListenerNetworkStream的组合客户端用TcpClientNetworkStream。代码量少读起来清晰适合作为课程设计或项目起点。实际生产环境里如果要做高并发一般会考虑SocketAsyncEventArgs或直接上System.Net.Sockets的异步 API但这套资源的目标不是压榨极限性能而是把 TCP 通信流程跑通。你先跑通了后面再优化也不迟。2.3 收发模型选型同步阻塞 vs 异步并发项目里最关键的选型是收发模型。用同步方式收发意味着ReadAsync会阻塞当前线程直到数据到达。如果服务器只有一个主线程那在等待数据期间就没法处理别的客户端客户端也一样如果接收逻辑写在主线程里那么你输入消息的过程中服务器发来的数据要等你输入完后才被处理这就是常见的「卡死」现象。这个资源采用的是混合模型用户输入走同步的Console.ReadLine()网络收发走异步任务。核心逻辑用async/await包住ReadAsync和WriteAsync等数据到达或写入完成时自动回到调用线程不占额外的阻塞线程。我一般会建议初学者在课堂上写同步版本体验流程然后在作业或项目里强制改用异步版本——这两者的差距只有在两个窗口同时收发时才能真正体会到。3. 服务器端代码拆解监听、接受连接、广播消息3.1 服务器主循环从启动监听到接受客户端先看服务器端的完整入门代码这段代码可以直接跑通「接收一个客户端并回复消息」的场景using System.Net; using System.Net.Sockets; using System.Text; // 1. 创建监听器绑定本机所有 IP 地址的 9000 端口 TcpListener listener new TcpListener(IPAddress.Any, 9000); listener.Start(); Console.WriteLine(服务器已启动等待客户端连接...); // 2. 用无限循环接受客户端请求 while (true) { TcpClient client await listener.AcceptTcpClientAsync(); _ HandleClientAsync(client); // 每个客户端开一个独立处理任务 } static async Task HandleClientAsync(TcpClient client) { // 3. 拿到客户端的网络流对象 NetworkStream stream client.GetStream(); byte[] buffer new byte[1024]; Console.WriteLine($客户端已连接: {client.Client.RemoteEndPoint}); // 4. 循环读取客户端发来的数据 while (true) { int read await stream.ReadAsync(buffer, 0, buffer.Length); if (read 0) break; // 客户端关闭连接 string message Encoding.UTF8.GetString(buffer, 0, read); Console.WriteLine($收到: {message}); // 5. 把同样的数据回发给客户端 byte[] reply Encoding.UTF8.GetBytes($服务器回执: {message}); await stream.WriteAsync(reply, 0, reply.Length); } client.Close(); Console.WriteLine(客户端已断开); }逻辑说明这个服务器用一个while循环持续调用AcceptTcpClientAsync()每来一个客户端就开一个HandleClientAsync任务。第 4 步的ReadAsync是异步读读到 0 字节表示客户端正常断开了连接这时跳出循环释放资源。参数说明端口号 9000 是随便选的只要你本机没有其他程序占用即可实际使用时建议改成 1024 以上的高位端口避开系统保留端口。缓冲区大小 1024 字节是指单次读取的最大长度如果一条消息超过这个长度会被分成多次读这是后文要说的粘包问题的根源。3.2 多客户端管理用字典维护在线列表上面的代码只能一对一回显无法在多个客户端之间广播消息。这个资源里提供了更进一步的做法用一个线程安全的字典维护所有在线客户端收到某客户端的消息后遍历字典转发给其他人。private static readonly ConcurrentDictionarystring, TcpClient Clients new(); static async Task HandleClientAsync(TcpClient client) { string clientId Guid.NewGuid().ToString(N)[..8]; // 生成8位短ID Clients[clientId] client; Console.WriteLine($[{clientId}] 加入聊天室当前在线: {Clients.Count}); NetworkStream stream client.GetStream(); byte[] buffer new byte[1024]; try { while (true) { int read await stream.ReadAsync(buffer, 0, buffer.Length); if (read 0) break; string message Encoding.UTF8.GetString(buffer, 0, read); Console.WriteLine($[{clientId}] {message}); await BroadcastAsync($[{clientId}] {message}, clientId); } } catch (IOException) { Console.WriteLine($[{clientId}] 连接异常断开); } finally { Clients.TryRemove(clientId, out _); client.Dispose(); // 释放资源而不是只Close } } static async Task BroadcastAsync(string message, string excludeId) { byte[] bytes Encoding.UTF8.GetBytes(message); foreach (var pair in Clients) { if (pair.Key excludeId) continue; // 不转发给发送者自己 try { await pair.Value.GetStream().WriteAsync(bytes, 0, bytes.Length); } catch { // 发送失败的客户端下次循环再处理移除 } } }逻辑说明ConcurrentDictionary保证多任务并发写入时不会抛异常。每个客户端接入时生成一个短 ID 作为键断开时在finally块里移除。广播时排除发送者自己避免自己收到自己的消息再显示一遍。关键参数Guid.NewGuid().ToString(N)[..8]取前 8 位十六进制字符作为临时 ID可读性比整数自增 ID 好一些。client.Dispose()比client.Close()更彻底它连底层 socket 的资源一起释放。这里我一般设置服务器在启动时打印本机 IP 地址方便局域网内其他机器连接排查。3.3 断线检测与异常兜底为什么读不到 0 字节就是断了TCP 是流协议没有消息边界也没有心跳机制。如果客户端崩溃或物理断网服务器端的ReadAsync不会返回 0而是直接抛出IOException。网络正常断开时对端发送 FIN 包流读到末尾返回 0。所以检测对方的连接状态要么捕获异常要么依赖超时机制。这个资源里采用的是「try-catch-finally」的兜底结构。捕获到IOException就认为连接异常立即从在线字典里移除并释放资源。实际生产环境还会额外设置ReceiveTimeout或定期发送心跳包来检测半开连接但作为课程设计或基础工具这套异常处理已经足够。4. 客户端实现连接、发送、接收三件事的完整闭环4.1 客户端入门代码连接服务器并双向收发客户端的结构比服务器简单核心就是三件事连接、发消息、收消息。这里的关键设计是接收逻辑放在独立任务里而不是和发送逻辑串在一起。using System.Net.Sockets; using System.Text; Console.Write(请输入服务器 IP本地测试填 127.0.0.1: ); string? ip Console.ReadLine(); if (string.IsNullOrEmpty(ip)) ip 127.0.0.1; using TcpClient client new TcpClient(); await client.ConnectAsync(ip, 9000); // 连接服务器的9000端口 Console.WriteLine($已连接服务器: {ip}:9000); NetworkStream stream client.GetStream(); // 1. 后台任务持续接收服务器消息 _ ReceiveAsync(stream); // 2. 主线程循环发送用户输入 while (true) { string? line Console.ReadLine(); if (line exit) break; byte[] bytes Encoding.UTF8.GetBytes(line); await stream.WriteAsync(bytes, 0, bytes.Length); } client.Close(); static async Task ReceiveAsync(NetworkStream stream) { byte[] buffer new byte[1024]; 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}); } }逻辑说明using TcpClient声明了生命周期退出作用域时自动释放底层 socket。接收任务在连接建立后立即启动之后主线程只负责读用户输入和发送。这样你在控制台里输入消息时服务器下发的消息会实时打印在下一行两端不互相阻塞。参数说明ConnectAsync的第一个参数是服务器 IP 地址本机调试用127.0.0.1即可如果要跨机器测试必须填服务器的局域网 IP。端口必须和服务器监听端口一致。ReadAsync返回 0 表示连接正常关闭返回大于 0 的值表示读取到的字节数但一次读到的字节数可能小于消息实际长度——这就是缓冲区和数据包长度不一致的问题。4.2 双向通信的架构一收一发为什么能同时进行双向通信的可行核心在两点NetworkStream.ReadAsync是异步的不占后续代码的执行接收任务和发送逻辑分别运行在不同的异步上下文里。C# 的async方法在遇到await时会释放当前线程数据没到之前线程不会被占用所以发送线程和接收线程可以共存。很多初学者想不通「我把接收写在Console.ReadLine()后面为什么收不到消息」因为ReadLine是同步阻塞的你输入完之前程序根本跑不到接收代码。而把接收放到_ ReceiveAsync(stream)里后这个异步任务立即启动收到数据就打印彻底绕过了用户输入造成的阻塞点。4.3 客户端断线重连暴力的 while 重试 vs 带退避的重试客户端有可能会因为服务器重启、网络波动而断开。这个资源的客户端提供了一种简单直接的重连策略检测到断开后弹出提示并自动重新连接。while (true) { try { await TryConnectAsync(); break; // 连接成功则跳出重连循环 } catch (SocketException ex) { Console.WriteLine($连接失败: {ex.Message}); Console.WriteLine(3 秒后重试...); await Task.Delay(3000); } } static async Task TryConnectAsync() { using TcpClient client new TcpClient(); await client.ConnectAsync(127.0.0.1, 9000); NetworkStream stream client.GetStream(); _ ReceiveAsync(stream, client); while (true) { string? line Console.ReadLine(); if (line exit) break; byte[] bytes Encoding.UTF8.GetBytes(line); await stream.WriteAsync(bytes, 0, bytes.Length); } }逻辑说明连接失败时SocketException被捕获然后固定等待 3 秒重试这个方案简单到可以直接交给新手使用。生产环境一般会做指数退避——第一次等 1 秒第二次 2 秒第三次 4 秒避免服务器恢复前疯狂重试打满客户端 CPU。这个资源的代码用了最简单的等间隔重试我一般会补一句如果要在后台服务里用把Task.Delay换成Random抖动的指数退避会更稳。5. 避坑与常见问题排查TCP 收发最容易踩的五个深坑5.1 同时收发时卡死为什么客户端只发不收现象客户端发消息给服务器没问题但服务器回的消息总是等很久才显示甚至永远不显示。原因接收代码写进了主线程且放在了ReadLine之后。主线程被输入阻塞接收逻辑得不到执行。这是初学者最容易犯的问题——消息收不到时不是去查网络而是先看收发逻辑是否被同步阻塞卡住了。解决把接收逻辑独立成一个Task用_ ReceiveAsync(stream)启动和主线程的发送并行。改完后发送和接收各跑各的互不牵制。5.2 粘包与半包缓冲区读到的消息出现拼接或截断现象客户端连续发两条消息「你好」「世界」服务器收到变成长度不定的「你好世界」或「你」「好世界」。原因TCP 是流协议不存在消息边界。发送方可能把多条小消息合并成一个 TCP 段接收方也可能在缓冲区不够时把一个逻辑消息拆成多次读取。默认的 1024 字节缓冲区更加剧了这个问题。解决在消息前后附加长度头或分隔符。最简单实用的是「包头 包体」协议前 4 字节用BitConverter写入消息长度接收时先读 4 字节拿到长度再循环读够完整包体。// 发送端先写长度再写内容 byte[] body Encoding.UTF8.GetBytes(message); byte[] header BitConverter.GetBytes(body.Length); // 固定4字节 await stream.WriteAsync(header, 0, 4); await stream.WriteAsync(body, 0, body.Length); // 接收端先读长度再按长度读内容 byte[] lenBuf new byte[4]; await ReadFullAsync(stream, lenBuf, 4); int length BitConverter.ToInt32(lenBuf, 0); byte[] bodyBuf new byte[length]; await ReadFullAsync(stream, bodyBuf, length);5.3 网络异常断开后服务器不自知现象客户端直接关掉控制台服务器端的ReadAsync半天不返回在线列表里一直残留那条记录。原因TCP 断开时对端会发 FIN 包正常流程下服务器读到 0 字节后立刻捕获。但如果客户端所在的网络异常拔网线、断电FIN 包根本没机会发出去服务器只能在下次写入数据时才能感知到连接已经失效。解决给NetworkStream设置ReadTimeout或者在服务器加一个定时器定期向所有客户端发送心跳探测。心跳最稳妥——每隔 30 秒发一个空包或特定标识符收不到回执就把客户端踢下线。课程设计里如果能加这个功能答辩时会明显加分。5.4 防火墙拦截导致局域网客户端连不上现象本机 127.0.0.1 怎么跑都正常换成局域网 IP 就连不上报由于目标计算机积极拒绝。原因Windows 防火墙默认拦截所有外部程序监听的端口程序第一次监听时弹出的权限确认被点掉了。解决打开「防火墙高级设置」在入站规则里放行TCP 9000端口或者直接将编译后的 exe 加入白名单。如果只在同一台机器上测试就不存在这个坑一旦要跨机器联调这一步是程序之外最关键的配置。我一般会在服务器启动时直接打印一条提示告诉使用者「如果客户端连不上先检查防火墙 9000 端口」。5.5 端口被占用导致监听失败现象服务器启动时直接报SocketException: 通常每个套接字地址只允许使用一次。原因上一个服务器进程没有正确释放资源或者该端口已被其他程序占用。解决启动前先查端口占用netstat -ano | findstr :9000 taskkill /PID 占用进程PID /F在代码层面给监听器加 socket 复用选项最省事listener.Server.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);然后Start()。这样即使上次进程没完全释放也能快速重新监听。6. 把 Demo 改成实用工具两条小改进让控制台聊天室真正可用第一个改进是消息乱码问题。这套资源默认用 UTF-8 编码在目标平台基本都是 utf-8 编码的控制台上没问题。但如果你用 Windows 的 GBK 控制台看到了乱码把Encoding.UTF8换成Encoding.Default就行。服务器和客户端的编码必须一致这一点比想象中的坑更常见。第二个改进是消息体的长度边界问题。上面的代码用 4 字节整数做长度头支持最大 2GB 的单条消息够用了。如果要支持更长的流数据可以改用 8 字节long。这里有个经验控制台聊天场景用 4 字节长度头就够了而且BitConverter的字节序在小端机器上不用额外处理跨平台部署到 Unity 或移动端时才需要手动统一字节序。第三个技巧是给客户端加一个在线状态提示。NetworkStream本身没有IsConnected属性判断客户端是否还在线最朴素的办法就是看ReadAsync的返回值。我在项目里通常的做法是在接收任务里加一个connected状态变量任何一次读操作返回 0 或者抛异常就把它置为 false发送前检查这个变量避免写进一个已断开连接的流。我最早做课程设计的时候没听懂老师说的「网络编程要站在异步的门槛上往前迈一步」于是全用同步Read和Write结果一个客户端发消息就把整台服务器卡死。后来被逼着重写了三版才把异步收发、粘包处理、异常断线这三件事揉进代码里。从那以后我每次写 TCP 项目都强制走一遍「异步收发、长度头、断线检测、防火墙检查」这四步流程下面这份资源里的代码就是那版改顺手的产物希望对你有帮助。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询