
简介面向C#初学者的TCP/IP网络编程入门资料以最简单例程演示客户端与服务端通信的完整流程。资源聚焦System.Net命名空间下TcpListener与TcpClient的核心用法从创建Socket、指定地址族与套接字类型、绑定端口、启动监听到客户端发起连接、彼此Send/Receive交换数据均有清晰示例适合新手快速建立Socket编程概念。压缩包共55个文件以12个cs源码文件、8个exe可执行程序、6个txt说明文档为主另含项目配置、资源文件等可直接编译运行或对照阅读。整个资源包仅450KB轻量精简便于下载与本地实践。已有206人学习下载内容覆盖服务端与客户端两端实现并附有相关示例工程读者可结合源码理解TCP/IP通信机制为后续处理多线程连接、异常与编码等问题打下基础。1. TCP/IP C# 最简单例程把客户端和服务端的最小闭环跑通就赢了一半第一次被要求写 TCP/IP C# 通信时我心里没底。当时搜到的例程要么塞满了 async/await要么提前加了序列化和加密根本没法一眼看懂谁发了什么、谁收到了什么。后来我把问题拆到极限一个服务端、一个客户端、一条消息、一个回执用 TcpListener 和 TcpClient 两个类不到 80 行就把整条链路跑通了。这个最简单例程解决的是 TCP/IP 编程里最核心的问题——让两个进程通过 TCP/IP 协议互相传递信息选型和代码逻辑一眼能看穿适合刚接触 C# 网络编程的人也适合需要快速验证网关或上位机联调的人。下面按服务端、客户端、避坑、验证四步讲透这条链路。2. 选型逻辑先立住为什么“最简单例程”用 TcpListener / TcpClient而不是裸 Socket2.1 TcpListener / TcpClient 与 Socket 的封装关系少写了什么多得到了什么几乎所有 C# TCP/IP 例程的核心都会出现在 System.Net.Sockets 命名空间里最底层的类型是 Socket。Socket 直接对应操作系统的 TCP 端点你可以拿它 Bind、Listen、Accept、Send、Receive功能最完整但步骤最琐碎。TcpListener 是服务端的封装它把 Bind 和 Listen 收敛进构造函数把 Accept 返回的 Socket 进一步包成 TcpClient。TcpClient 再往上走一步把 Connect、GetStream、Read/Write 收敛成接近业务语义的方法。对比“客户端连服务端”这个动作裸 Socket 至少要三行var socket new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); socket.Connect(new IPEndPoint(IPAddress.Parse(127.0.0.1), 9000)); var stream new NetworkStream(socket, ownsSocket: true);而用 TcpClient 只需要两行var client new TcpClient(); await client.ConnectAsync(127.0.0.1, 9000); var stream client.GetStream();封装带来的最大收益是把 AddressFamily、SocketType、ProtocolType 的“三件套”收敛成主机名字符串加一个端口你不需要为了最小例程去理解 socket 的状态机和标志位。但它的后门也在.Client属性能拿到底层 Socket真要调 KeepAlive、LingerState 和 SO_REUSEADDR 时往下操作一层就行。我的习惯是能封装就不裸写省下精力对协议和边界做思考。协议设计上还有一个经常被忽略的点谁先讲话。TCP 三次握手完成后两端都能立刻发数据没有绝对的服务端先来或客户端先来。HTTP 是客户端先发请求、服务端再回响应很多内网调试工具也沿用这一套。这个例程我默认客户端先发、服务端收下再回执好处是对故障连不上时先怀疑网络和监听连上了没消息时就看谁卡在 Read。2.2 同步阻塞还是异步最简单例程里我选同步原因有三个写 C# 服务端最容易陷入的误区是一上来就 async/await 包全套。.NET 官方建议网络 IO 尽量异步这个建议在并发场景没有错但最简单例程要的是“一眼看清控制流”。同步阻塞模型有三个不可替代的好处。第一执行顺序和阅读顺序完全一致。AcceptTcpClient() 卡住就是没有客户端进来Read 卡住就是对方没发数据。断点调试时你能清晰看到线程停在哪个系统调用上。异步模型里等待期间线程会回线程池新手常常困惑“为什么这里的代码顺序跳来跳去”。第二最简例程不需要同时服务多个客户端。一个客户端、一问一答同步没有任何性能问题反而代码更短。下面这种循环结构是服务端最常见的骨架while (true) { var client listener.AcceptTcpClient(); // 阻塞等新连接 Task.Run(() HandleClient(client)); // 丢给线程池处理 }主线程永远回到 Accept单个连接的收发放在线程池里这就是从教学到生产最近的距离。以后并发上来了再把 HandleClient 改成 async 版本边界不会变。第三同步方法自带超时语义。TcpClient.ReceiveTimeout 和 SendTimeout 直接按毫秒设置超时后 Read/Write 抛 IOException这个异常模型对排查来说太方便了。异步版本要通过 CancellationToken 自己管理超时代码量翻倍。所以我的结论是验证链路用同步做产品再换异步。不是异步不好是两件事的目标不同“最简单”应该让心智负担最小。2.3 TCP 是流不是包字节边界和网络字节序写协议前必须知道TCP 不是把“写入的数据块”原样交给对端的。它只保证字节顺序和可靠交付不保证消息边界。服务端调一次 Read 读到的可能是客户端一次 Write 的部分数据也可能是两次 Write 的合并。这就是“半包/粘包”的根源。在设计最简单的收发时单条短消息很难触发这个问题但一旦你要连续发多条就会翻车。业界最常见的解法有三种固定长度帧、长度前缀、分隔符。分隔符法代码最少例如在每条消息末尾追加\n接收端循环判断字节值等于 10 就切分坑在消息本身不能包含分隔符固定长度适合字段固定的传感器数据比如每帧 32 字节粘包后按 32 字节切分即可长度前缀法最通用先发 4 字节消息体长度再发内容是绝大多数正式协议的做法。最小例程我建议先不实现但心里必须知道边界这事等第 5 章我再用踩坑经验来补刀。网络字节序同理。C# 的 BitConverter 默认用本机字节序x86/ARM 通常是小端很多嵌入式设备或旧协议按大端传输。最简例程如果只传 UTF-8 文本完全不用关心字节序一旦你开始传 Int32 作为长度前缀或消息类型就要用 IPAddress.HostToNetworkOrder / NetworkToHostOrder 做转换。否则客户端解出来的长度五花八门那个场景会让你抓狂。3. 服务端最小例程监听、接受、读取一条跑出第一个 TCP 端点3.1 环境与最小项目结构.NET 6/8 控制台一个 Program.cs 就够先建一个空控制台项目命令和目录结构如下mkdir TcpDemoServer cd TcpDemoServer dotnet new console --framework net8.0 dotnet run默认模板自带 ImplicitUsings 和 NullableSystem.Net.Sockets 是 .NET 基础类库的一部分不需要装任何 NuGet 包。这对新手很友好在企业内网没有外网源时也可以直接编译。项目里只有两个文件csproj 和 Program.cs后者就是全部源码。如果你用的是 .NET Framework 老项目4.x代码大体也能用但 TcpListener.AcceptTcpClientAsync 等异步方法要自己包装我在这篇不展开。建议直接用 .NET 6理由只是少踩平台差异。3.2 服务端核心代码监听本机 9000 端口接受一次读一条回一条完整服务端代码直接把 Program.cs 覆盖即可using System.Net; using System.Net.Sockets; using System.Text; var listener new TcpListener(IPAddress.Loopback, 9000); listener.Start(10); Console.WriteLine($服务端已启动监听 {listener.LocalEndpoint}); TcpClient client listener.AcceptTcpClient(); Console.WriteLine($客户端已连接{client.Client.RemoteEndPoint}); using (client) using (NetworkStream stream client.GetStream()) { byte[] buffer new byte[1024]; int received stream.Read(buffer, 0, buffer.Length); string message Encoding.UTF8.GetString(buffer, 0, received); Console.WriteLine($收到{message}); byte[] response Encoding.UTF8.GetBytes($服务端已收到{message}); stream.Write(response, 0, response.Length); Console.WriteLine($已回复{message}); } Console.ReadKey();这个例程的执行顺序一句话就能概括绑定端口、等待连接、读一次、回一次、按任意键退出。它是刻意不写接收循环的好让你把注意力集中在一次会话的完整生命周期上。关键参数说明IPAddress.Loopback等价 127.0.0.1只监听本机回路局域网内其他机器访问不到。要对外服务时改成IPAddress.Any0.0.0.0。Start(10)里的 10 是 backlog 队列长度也就是“内核最多帮你排队的半成品连接数”不是最大连接数。超出后新客户端会被拒绝。GetStream()返回 NetworkStreamDispose 时会连带关闭 TcpClient 和底层 Socket所以 using 收拢得很干净。stream.Read是阻塞读读到 0 个字节表示对方已关闭连接这个条件后面排错环节会反复用到。接收缓冲区 1024 是刻意设小让你更容易在连续收发时看出“一次读不全”的边界。3.3 启动顺序与验证先起服务端再起客户端顺序反了必报错客户端连接一个没有监听者的端口得到的异常是“由于目标计算机积极拒绝无法连接”而不是超时。这个错误提示太容易引起误判了。正确操作步骤是先dotnet run启动服务端看到“服务端已启动”用系统命令确认端口在监听再启动客户端。Windows 下netstat -ano | findstr 9000Linux / macOS 下lsof -i :9000输出里出现 LISTENING 状态就代表绑定成功。如果执行完服务端程序没有输出或者命令看不到该端口优先怀疑三件事程序崩了、防火墙拦了、上一次的进程还在占用端口。如果你不确认防火墙可以把程序杀干净再试一次多数时候就能复现。4. 客户端最小例程连接、发送、回执验证“客户端和服务端”的完整闭环4.1 客户端核心代码两次方法调用完成连接一次读写完成会话下面这个客户端程序运行前提是服务端已经在 9000 端口监听using System.Net.Sockets; using System.Text; using var client new TcpClient(); await client.ConnectAsync(127.0.0.1, 9000); Console.WriteLine($已连接本地端点{client.Client.LocalEndPoint}); using NetworkStream stream client.GetStream(); string message 你好服务端我是第一个客户端; byte[] sendBuffer Encoding.UTF8.GetBytes(message); await stream.WriteAsync(sendBuffer, 0, sendBuffer.Length); Console.WriteLine($已发送{message}); byte[] recvBuffer new byte[1024]; int length await stream.ReadAsync(recvBuffer, 0, recvBuffer.Length); string response Encoding.UTF8.GetString(recvBuffer, 0, length); Console.WriteLine($收到服务端回复{response});客户端其实只有三段动作连上、写、读。new TcpClient()只创建空壳ConnectAsync传入主机名和端口后才真正发起 TCP 三次握手WriteAsync把字节塞进 Socket 的发送缓冲区ReadAsync阻塞等对端数据返回读取长度。参数说明连接地址写死 127.0.0.1因为服务端绑的是 Loopback。如果服务端改绑IPAddress.Any客户端要连内网 IP 才能互通。这里的 ReceiveTimeout 没有设置意味着 Read 永远等下去。排查问题阶段我建议补上client.ReceiveTimeout 5000;5 秒没回执就抛 IOException避免“看似卡死”的假象。客户端缓冲区也用了 1024和服务端保持一致。两端缓冲区不要求一致但差距太大时更容易出现读写不对称导致的误判。4.2 客户端与服务端配对的关键点IP、端口、缓冲区、编码只要下面任何一个对不上就会复现各种玄学故障。按表格逐项核对半小时内能定位大部分连接问题。配置项服务端客户端说明监听/连接地址IPAddress.Any 或 Loopback127.0.0.1 或内网 IP服务端绑 Loopback 时外部不可连接端口90009000必须一模一样错一个就连不上编码UTF-8UTF-8中文乱码基本都是编码不一致接收缓冲区1024/40961024/4096过小容易半包过大徒增内存编码这点特别容易忽略。在中文 Windows 上Encoding.Default默认是 GBK而跨平台程序里我习惯所有文本统一 UTF-8。例程里两个 Program.cs 都显式用了Encoding.UTF8就是为了避免这个坑。协议移植到别的语言时编码规则最好写进协议文档。4.3 一次例程跑通后立刻升级为循环 Accept服务端不能只服务一个连接第 3 章的代码只能服务一个客户端处理完就往下走。要让它持续服务只需要在最外层包一个 while(true)把单连接的逻辑挪到一个方法里var listener new TcpListener(IPAddress.Any, 9000); listener.Start(10); while (true) { TcpClient client await listener.AcceptTcpClientAsync(); _ Task.Run(() ProcessClient(client)); } static async Task ProcessClient(TcpClient client) { using (client) using (NetworkStream stream client.GetStream()) { byte[] buffer new byte[1024]; int received await stream.ReadAsync(buffer, 0, buffer.Length); Console.WriteLine($收到{Encoding.UTF8.GetString(buffer, 0, received)}); // 处理业务写回消息 } }这一步做完服务端就从“一次性玩具”变成了“能持续监听的小服务”。Task.Run把每个连接放进线程池主循环回到 Accept。这个结构在几十个并发连接内完全够用也是很多正式上位机的基础骨架。后续再演进就是把 ProcessClient 做成状态机、加入连接管理和心跳。5. 常见问题与避坑连接被重置、端口占用、粘包半包、收发卡死5.1 现象一客户端读数据时报“远程主机强迫关闭了一个现有的连接”现象客户端调用 Read / ReadAsync 时抛 IOException内部消息是“远程主机强迫关闭了一个现有的连接”服务端那边不打印任何异常安静地退出了。原因这是 TCP 里最经典的“对端关闭连接但你还在读”的场景。最常见的是服务端在 using 块结束就 Dispose 了 TcpClient而客户端还没来得及把数据读完或者客户端发送完消息立即关闭服务端 Read 返回 0代码没对 0 字节做处理。解决收发双方都要约定“关闭即结束”。服务端的读取循环必须写成这样int read; while ((read stream.Read(buffer, 0, buffer.Length)) 0) { // 处理 buffer[0..read] } // 到这里说明对方已经有序关闭写端Read返回 0 是正常的连接关闭信号不是错误。如果你把 0 当成有效数据处理会得到空消息也会制造后续奇怪的故障。发送方这边不要在服务端还没回执前立刻 Dispose等协议走完、双方都关闭了再结束。5.2 现象二服务端第二次启动时报“地址已被使用”现象第一次运行服务端程序后关闭窗口再次dotnet run立刻抛 SocketException提示“通常每个套接字地址只允许使用一次”。原因TCP 连接在主动关闭后会进入 TIME_WAIT 状态保留约 60 秒端口还没完全释放。此时新的监听器要重新绑定同一个端口默认会失败。解决在 Start 之前给底层 Socket 加上 SO_REUSEADDRvar listener new TcpListener(IPAddress.Any, 9000); listener.Server.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); listener.Start(10);这行代码在开发调试阶段几乎是必需品否则每次重启服务端都要等一分钟心态很容易崩。如果已经加了这行还是报占用用 netstat -ano 找到 PID八成是上一次的进程没有真正退出去任务管理器杀进程不要盲目改端口。5.3 现象三客户端连续发两条消息服务端只读到一条“连体消息”现象客户端先发“hello”再发“world”服务端一次 Read 读出来的内容是“helloworld”。或者反过来客户端发了一条长消息服务端一次只读到前半段。原因TCP 是流协议这种问题属于最典型的“粘包/半包”。这是协议设计问题不是 TCP bug。正因如此我才在选型章节反复强调边界设计。解决我给最小例程推荐“长度前缀”方案——发每条消息前先写 4 个字节表示消息体长度网络字节序大端再写消息体。接收端先读满 4 字节得到长度 n再循环读满 n 字节才算读完一条消息。代码示意static async Task WriteMessageAsync(NetworkStream stream, string message) { byte[] body Encoding.UTF8.GetBytes(message); byte[] header BitConverter.GetBytes(IPAddress.HostToNetworkOrder(body.Length)); await stream.WriteAsync(header, 0, 4); await stream.WriteAsync(body, 0, body.Length); }接收端也要先把 4 字节读满再根据长度读满 body。这个方案可以同时解决粘包和半包也是绝大多数成熟协议在传输层的通用做法。如果觉得复杂在一问一答且单条消息远小于读缓冲区的前提下你可以暂时忽略但一定要知道它存在。5.4 现象四服务端 Read 一直阻塞不返回或客户端收不到响应现象客户端已经调用了 WriteAsync而服务端没有任何输出Read 卡住不动或者服务端正常读了、也写了但客户端就是收不到响应。原因分两种情况。第一种客户端 Write 之后没有 Flush/关闭数据停留在内核发送缓冲区TCP 栈可能因为等待更多数据而不立即发出服务端自然读不到。NetworkStream 的 Flush 本质是空操作真正有效的是把写缓冲区数据推入网卡的临界操作这通常在 Dispose 时完成所以用 using 收尾很重要。第二种服务端和客户端缓冲区大小不匹配或者服务端 Read 只读了一次就结束循环还没等到完整数据。还有一股隐藏原因心跳和超时缺失。两边谁都不主动关闭又没有数据流动连接就是一潭死水读超时时间默认无限大程序看起来像假死。解决Debug 的时候先把两端超时加上client.ReceiveTimeout 5000; client.SendTimeout 5000;然后再检查发送逻辑。没有 KeepAlive 的长连接尽量设计成“客户端定期发心跳、服务端定期回执”才能让死连接尽早暴露。NetworkStream 本身的读超时抛出的 IOException 会清晰地告诉你是哪一步卡住这比盲猜玄学要高效得多。6. 从例程到能交付自动回显、超时与心跳、还有一张日志表6.1 用“多轮回显”自测客户端跑十条服务端逐条回把最简单的例程升级为可靠方案第一步不是加协议而是加一个回显自测。客户端循环发 10 条带编号的消息服务端收一条回显一条两边都打印编号和内容任何一条缺失、乱序、错位都说明链路某处不稳。这个小脚本是我做上位机联调时的保留动作比人肉点一遍可靠得多。6.2 加超时与心跳让“一发一收”变成能撑住的长连接一发一收在八成场景够用但要做长连接必须用心跳。协议里定义一条 0x01 或 “PING”客户端每 15 秒发一次服务端在 30 秒内没收到任何数据就判定连接死亡。超时时间按业务容忍度来工控场景我常用 10 秒对 30 秒不是越大越好。超时判死后主动关闭连接让上层重连。这一步做完例程就算具备了基础的保活能力。6.3 日志落盘把网络黑匣子变成可以复盘的白盒最后建议每一条收发都写日志。不需要复杂的 logging 框架一个静态方法就够了static void Log(string tag, string message) { string line ${DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} [{tag}] {message}; File.AppendAllText(tcp.log, line Environment.NewLine); }在客户端和服务端的连接建立、发送、接收、关闭、异常五个点各打一条时间戳精确到毫秒。线上出问题后只需要对比两端日志就能知道消息在哪一段丢了是没发出去、没收到、还是收到了但解析失败。这个习惯帮我省过不少远程排障的时间希望你也能养成。最后还是那句老话网络通信不怕慢怕的是出了问题不知道在哪一段。把最小闭环跑通、把边界和超时想清楚剩下的都是按规矩来。希望帮到你。本文还有配套的精品资源点击获取