C#要实现基于TCP的Socket通信,最直接的方式是使用System.Net.Sockets命名空间下的Socket类。和TcpClient、TcpListener相比,Socket更接近操作系统提供的伯克利套接字接口,能够清楚看到监听、绑定、连接、收发和关闭这些底层动作。掌握Socket编程不仅能帮助理解TCP的工作方式,也能在需要精细控制套接字选项、缓冲区大小或超时行为时提供更大灵活性。本文从协议栈位置、服务端实现、客户端实现以及流式传输边界四个角度展开。

一、Socket是应用层与传输层之间的编程接口
Socket并不是某种新的网络协议,而是操作系统提供的一组接口。C#中的Socket类底层封装了Windows或Linux的套接字描述符,调用Bind、Listen、Accept、Connect、Receive、Send时,最终都会进入内核网络协议栈。以TCP为例,客户端调用Connect会触发三次握手,内核完成SYN、SYN ACK、ACK交换后,把连接放入已建立连接表。服务端调用Listen以后,内核维护一个连接等待队列,Accept只是从这个队列中取出一个已经完成握手的连接。
理解这一点可以避免一个常见误解:很多人以为Accept是在网络上读取客户端发来的连接请求,实际上三次握手由内核完成,Accept返回的是一个新的已连接套接字。监听套接字只负责接收连接,不负责传输数据。服务端每接受一个客户端,都应该使用Accept返回的Socket进行Receive和Send,而原始监听Socket继续监听新的连接。这也解释了为什么高并发服务端通常会让Accept在一个循环中执行,并为每个已连接套接字启动独立任务。
另一个底层细节是发送缓冲区和接收缓冲区。Send调用通常只是把数据从用户态拷贝到内核发送缓冲区,并不保证数据立刻发出。Receive调用也是从内核接收缓冲区拷贝数据。如果接收缓冲区为空,阻塞Socket会一直等待。因此,同步Socket编程虽然代码直观,但在处理多个客户端或长连接时容易造成线程阻塞。
二、C#实现TCP服务端的关键步骤
服务端程序需要依次完成创建套接字、绑定本地地址、开始监听、接受客户端连接、接收数据和发送响应。创建TCP套接字时使用AddressFamily.InterNetwork、SocketType.Stream和ProtocolType.Tcp。SocketType.Stream表示这是一个流式套接字,数据按字节流传输;ProtocolType.Tcp指定底层使用TCP协议。绑定地址通常使用IPAddress.Any监听所有网卡,端口选择大于1024的端口,避免与系统服务冲突。
调用Listen时传入的参数表示内核连接队列的最大长度。需要注意的是,这个参数并不是程序能同时处理的客户端数量,而是等待Accept的连接队列大小。即使某个客户端已经完成TCP三次握手,只要服务端没有调用Accept,它仍然会占用队列中的一个位置。如果队列满了,新的连接请求可能被拒绝或丢弃。
下面是一个最小化的TCP服务端示例。Accept会阻塞当前线程,直到有客户端连接进来。Receive会阻塞等待数据到达。收到数据后,服务端把消息长度回复给客户端,然后关闭连接。
using System;
using System.Net;
using System.Net.Sockets;
using System.Text;
class TcpServer
{
static void Main()
{
// 创建TCP套接字
Socket listener = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
// 绑定本机所有网卡的9000端口
IPEndPoint localEndPoint = new IPEndPoint(IPAddress.Any, 9000);
listener.Bind(localEndPoint);
// 进入监听状态,队列最大长度为100
listener.Listen(100);
Console.WriteLine("服务端已监听 0.0.0.0:9000");
// 阻塞等待客户端连接
Socket clientSocket = listener.Accept();
Console.WriteLine("客户端已连接:" + clientSocket.RemoteEndPoint);
byte[] buffer = new byte[4096];
int bytesRead = clientSocket.Receive(buffer);
string message = Encoding.UTF8.GetString(buffer, 0, bytesRead);
Console.WriteLine("收到消息:" + message);
string reply = "服务端已收到,消息长度=" + bytesRead;
byte[] replyBytes = Encoding.UTF8.GetBytes(reply);
clientSocket.Send(replyBytes);
// 关闭发送和接收
clientSocket.Shutdown(SocketShutdown.Both);
clientSocket.Close();
listener.Close();
}
}
该示例只处理一个客户端,实际使用时通常会把Accept和后续通信放入循环中。如果服务端在Receive时对端断开连接,Receive会返回0,这可以作为判断连接关闭的依据。服务端不能只依赖异常处理,因为正常关闭连接时Receive返回0而不会抛出异常。
三、C#实现TCP客户端
客户端实现比服务端简单,核心是创建Socket后调用Connect方法。Connect方法同样会阻塞线程,直到三次握手完成或超时。连接成功后,客户端可以通过Send发送字节数组,通过Receive接收服务端返回的数据。由于TCP是字节流协议,客户端发送的字符串需要先使用Encoding.UTF8.GetBytes转换成字节数组,接收到的字节数组也要使用Encoding.UTF8.GetString还原成字符串。
客户端连接本机服务端时,通常使用IPAddress.Parse解析地址,也可以使用IPAddress.Loopback表示127.0.0.1。端口必须与服务端Bind的端口一致。下面的示例发送一句话,然后读取服务端回复。如果服务端先关闭连接,Receive会返回0,此时客户端不应继续使用该Socket。
using System;
using System.Net;
using System.Net.Sockets;
using System.Text;
class TcpClientRaw
{
static void Main()
{
Socket client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
// 连接本机9000端口
IPEndPoint remoteEndPoint = new IPEndPoint(IPAddress.Parse("127.0.0.1"), 9000);
client.Connect(remoteEndPoint);
Console.WriteLine("已连接服务端");
string message = "来自客户端的请求";
byte[] data = Encoding.UTF8.GetBytes(message);
int sent = client.Send(data);
Console.WriteLine("已发送字节数:" + sent);
byte[] buffer = new byte[4096];
int bytesRead = client.Receive(buffer);
if (bytesRead > 0)
{
string reply = Encoding.UTF8.GetString(buffer, 0, bytesRead);
Console.WriteLine("收到回复:" + reply);
}
client.Shutdown(SocketShutdown.Both);
client.Close();
}
}
Connect方法也可以使用异步版本BeginConnect或ConnectAsync。对于界面程序来说,同步Connect会阻塞UI线程,导致窗口卡死。即使在控制台程序中,如果服务端不在线,Connect默认可能会等待较长时间,建议设置超时或使用异步方法。
四、字节流带来的粘包与半包问题
使用TCP时最常见的困惑是发送和接收次数不一致。客户端连续Send两次,每次发送一个完整字符串,服务端可能一次Receive就收到全部数据,也可能先收到一半,后收到另一半。这是因为TCP面向字节流,不保留应用层的消息边界。内核只负责按照字节顺序传输数据,不关心这些字节是否代表一条完整消息。
粘包是指多个发送操作的数据在接收缓冲区中连在一起,接收方一次读取到多条消息;半包是指一条消息被分成多次到达,接收方一次只读到部分数据。解决这个问题需要在应用层设计简单的协议。常见做法有三种:固定长度消息、特殊分隔符、长度前缀。其中长度前缀在二进制协议和网络通信中应用最广,因为它能准确表示消息长度且不容易与消息内容冲突。
下面的辅助方法展示了如何从Socket中精确读取指定长度的字节,用于实现长度前缀协议。发送方先发送4字节的长度,再发送消息体;接收方先读取4字节长度,再循环读取完整消息体。
static byte[] ReceiveExactly(Socket socket, int length)
{
byte[] buffer = new byte[length];
int offset = 0;
while (offset < length)
{
int received = socket.Receive(buffer, offset, length - offset, SocketFlags.None);
if (received == 0)
{
throw new Exception("连接在读取完整消息前已关闭");
}
offset += received;
}
return buffer;
}
对于生产环境,同步Socket还存在线程资源占用问题。每个连接占用一个线程,大量并发连接时线程切换开销会显著增加。C#提供了BeginAccept、BeginReceive等异步接口,以及Task-based的AcceptAsync、ReceiveAsync方法,还有高性能的SocketAsyncEventArgs。理解同步调用的底层行为之后,再迁移到异步模型会更加顺畅。
C# Socket编程TCP客户端TCP服务端修改时间:2026-08-26 08:46:07