☰
C# TCP/IP最简单例程:TcpListener/TcpClient闭环通信与避坑指南
2026/10/8 8:28:32 网站建设 项目流程

简介:面向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,而不是裸 Socket

2.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 和 Nullable,System.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.Any(0.0.0.0)。
  • Start(10)里的 10 是 backlog 队列长度,也就是“内核最多帮你排队的半成品连接数”,不是最大连接数。超出后新客户端会被拒绝。
  • GetStream()返回 NetworkStream,Dispose 时会连带关闭 TcpClient 和底层 Socket,所以 using 收拢得很干净。
  • stream.Read是阻塞读,读到 0 个字节表示对方已关闭连接,这个条件后面排错环节会反复用到。
  • 接收缓冲区 1024 是刻意设小,让你更容易在连续收发时看出“一次读不全”的边界。

3.3 启动顺序与验证:先起服务端再起客户端,顺序反了必报错

客户端连接一个没有监听者的端口,得到的异常是“由于目标计算机积极拒绝,无法连接”,而不是超时。这个错误提示太容易引起误判了。正确操作步骤是:

  1. 先dotnet run启动服务端,看到“服务端已启动”;
  2. 用系统命令确认端口在监听;
  3. 再启动客户端。

Windows 下:

netstat -ano | findstr 9000

Linux / 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_REUSEADDR:

var 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); }

在客户端和服务端的连接建立、发送、接收、关闭、异常五个点各打一条,时间戳精确到毫秒。线上出问题后,只需要对比两端日志就能知道消息在哪一段丢了,是没发出去、没收到、还是收到了但解析失败。这个习惯帮我省过不少远程排障的时间,希望你也能养成。

最后还是那句老话:网络通信不怕慢,怕的是出了问题不知道在哪一段。把最小闭环跑通、把边界和超时想清楚,剩下的都是按规矩来。希望帮到你。

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

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

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

立即咨询