找回密码
 立即注册
首页 业界区 业界 C#网络编程(五)----细嗦TCP粘包

C#网络编程(五)----细嗦TCP粘包

予捻 2025-6-2 23:58:52
前情提要

四层网络模型各司其职,消息(SDU)在进入每一层时都会多加一个报头(PCI),这个PCI记录着该SDU的一些关键统计信息。SDU+PCI合并起来就组成一个完整的消息,简称为PDU

  • 链路层:帧(Frame)头部作用
    源 MAC 地址 和 目的 MAC 地址,用于在局域网内通信唯一标识设备,实现数据在物理链路上的传输
  • 网络层:数据包(Packet)头部作用
    包含 源IP 地址和 目的IP 地址,用户在互联网中实现端到端通信
  • 传输层:段(Segment)/数据报(Datagram)头部作用
    包含 源端口号 和 目的端口号,用于标识同一主机上的不同应用程序
  • 应用层:消息(Message)头部作用
    应用层协议根据具体需求定义头部格式,用于实现特定的业务逻辑
1.png

详见https://www.cnblogs.com/lmy5215006/p/18838393
数据切片

把互联网比作一条水管,那么这条水管是有一定的粗细的,而水管的粗细决定了流量的大小。因此,当我们发送"Hello World"时,当水管粗时,可以一次性发送完毕,当水管细时,就需要拆解成'He','llo','Wo','rld'。
决定"水管粗细"的由底层的数据链路层决定,为一个MTU(通常为1500Byte),当网络层(IP)数据包1500byte就需要拆分为两个数据包。
MTU(Maximum Transmission Unit,最大传输单元)
举个例子:MTU为1500byte,IP头为20Byte,IP层传输了一个3000Byte的数据包

  • 第一个数据包
    IP Header:20byte
    Payload:1480byte
    Total:1500byte
  • 第二个数据包
    IP Header:20byte
    Payload:3000byte-1480byte=1520byte (超过MTU,需要再次分片,实际1480)
    Total:1500byte
  • 第三个数据包
    IP Header:20byte
    Payload:40byte
    Total:60byte
由此可以看到,一次传输被拆分成了三次,每个分配重复携带IP Header,增加了额外的传输冗余,且分配需要重组,增加了延迟与丢包风险。
MTU与MSS

为了缓解上述的问题,传输层的TCP协议通过MSS(Maximum Segment Size,最大段大小)来避免IP分片。

  • 三次握手协商
    三次握手时,双方通过SYN报文交换MSS值,确保Segment 不超过MSS。
    MSS=MTU-IP Header-TCP Header,比如MTU=1500,那么MSS=1500-20-20=1460byte。
  • 路径MTU发现
    IP协议通过ICMP协议探测传输路径中的最小MTU,动态调整数据包大小以减少分片。
通过动态协商MTU,使得IP数据包使用不会超过MTU的值,从而避免了分片。不会出现MTU=1500,IP数据包3000Byte的现象。
2.png

粘包

人生就像打电话,不是你挂就是我挂。
切片也是,你链路层倒是爽了,不用拆包了,但传输层就遭殃了,因为总要有人负重前行。
TCP是一种面向连接,可靠的,基于字节流的传输层通信协议,因为基于字节流的特点,数据由01组成,所以当我们在互联网中传输"hello World"时,是以0101010101这种格式的字节流发送。
这些二进制数据,对于接收端来说,不知道要接收多少才能组成一个消息,因此其本质是应用层消息边界在TCP流中消失。
粘包发生的原因如下:

  • 数据包过小
    当Segment特别小,没有达到MSS的标准,TCP的Nagle算法会原地等待200ms,等下一个包一起发送。
  • 数据包过大
    如果下一个包来之后,超过了MSS的标准,则会拆包。
  • 接收端读取数据不及时
    接收端处理不及时,导致在Buff中好几个Segment的先后粘在一起,导致接收端无法区分。
3.png

说白了,粘包不是TCP的设计缺陷,而是一种取舍。
眼见为实

4.png

如何解决粘包

既然原理知道了,那么解决它也很简单.

  • 消息定长
    每个数据包的长度固定,那么接收端只要固定读取特定长度的二进制流即可区分。
  • 分割符标记
    通过特殊标记作为头/尾,比如EOF,回车,0xffffff等。当接收端处理到特殊标记时,就知道消息读取完了。
  • 头部包含长度字段
    一般会配合上面的分割符标记来加强,在Header中加入消息长度,然后就如同消息定长一样读取即可。
如果某个数据里正好有EOF怎么办?
还有标志位作为兜底,发送端在发送时加入16校验和(对完整数据进行CRC),以供接收端校验。
如同文件的MD5一样,下载完成后校验MD5,避免错误。
5.png

眼见为实


  • 消息定长
  1.                     //方法1,消息定长,假设消息固定长度为10
  2.                     if(length==10)
  3.                     {
  4.                         var temp = new byte[length];
  5.                         Array.Copy(buffer, 0, temp, 0, length);
  6.                         this.Dispatcher.Invoke(() =>
  7.                         {
  8.                             Message.Add(Encoding.ASCII.GetString(temp));
  9.                         });
  10.                     }
复制代码
6.png


  • 分割符标记
  1.                     //方法2,分割符标记,假设分割符是|
  2.                     var eof = Encoding.UTF8.GetBytes("|");
  3.                     int eofIndex = Array.IndexOf(buffer, eof[0], 0, length);
  4.                     if (eofIndex > 0)
  5.                     {
  6.                         var temp = new byte[length];
  7.                         Array.Copy(buffer, 0, temp, 0, length);
  8.                         this.Dispatcher.Invoke(() =>
  9.                         {
  10.                             Message.Add(Encoding.ASCII.GetString(temp).Substring(0,eofIndex));
  11.                         });
  12.                     }
复制代码
7.png


  • 头部包含长度字段
  1.                                         //方法3,头部包含长度
  2.                     var temp = new byte[length];
  3.                     Array.Copy(buffer, 0, temp, 0, length);
  4.                     var str= Encoding.UTF8.GetString(temp);
  5.                     var headerLength = int.Parse(str.Substring(0, 2));
  6.                     this.Dispatcher.Invoke(() =>
  7.                     {
  8.                         Message.Add(str.Substring(2, headerLength)) ;
  9.                     });
复制代码
8.png

注意,这里只是示例,只是简单的把黏在一起的数据拆分,并把多余的丢弃。正式环境还有很多要额外处理的。
FAQ

UDP会有粘包问题吗?

不会,原因如下:

  • UDP 不存在合并数据的机制
    Datagram是独立的数据单元,是最小单位,包含完整的Head和Payload。接收方要设置合理的缓冲区来接收,否则数据会丢失。
    当Datagram,网络层(IP层)会对其分片,但传输层本身不处理分片。
  • Datagram边界明确
    UDP 协议保证接收方可以通过数据报的长度字段(头部中 Length 字段)准确区分每个数据包的起始和结束。
简单来说,UDP自己都不保证消息消息完整,就算发生粘包又怎么样呢?
网络层会有粘包问题吗?

虽然我们在传输层明确了MSS会小于MTU(1500byte),避免了IP层的大包分片,但还会有漏网之鱼。比如在动态路径MTU发现中,发现某个路由器MTU只有500byte,那么IP层也需要对数据包进行切片。
9.png

再此之后会重新协商,传输层会调整MSS为500byte。
回到正题,那么网络层会有粘包问题吗?
答案是不会,再次强调一遍粘包本质是应用层消息边界在TCP流中消失。网络层只负责数据切片以及数据重组,它不关心里面的内容是什么,只是数据的搬运工,因此不会发生粘包。
关闭Nagle算法会减少粘包吗?

关闭Nagle算法会减少粘包,因为小的数据包会立即发送,而不是等200ms。
但治标不治本,接收方处理速度慢也是粘包的一个原因。
源码,包含了完整的粘包处理

10.png

MainWindow
  1. <Window x:
  2.         xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  3.         xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
  4.         xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
  5.         xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
  6.         xmlns:local="clr-namespace:WpfSocketServer"
  7.         mc:Ignorable="d"
  8.         Title="WPF Socket 服务器(粘包处理)" Height="500" Width="800">
  9.     <Grid Margin="10">
  10.         <Grid.RowDefinitions>
  11.             <RowDefinition Height="Auto"/>
  12.             <RowDefinition Height="*"/>
  13.             <RowDefinition Height="Auto"/>
  14.         </Grid.RowDefinitions>
  15.         
  16.         <StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0 0 0 10">
  17.             <Button x:Name="btnStart" Content="启动服务器" Width="100" Click="BtnStart_Click"
  18.                     Margin="0 0 10 0" Padding="5"/>
  19.             <TextBlock x:Name="txtStatus" Text="状态:未启动" VerticalAlignment="Center"/>
  20.             <Button Grid.Column="0" Width="100"  Margin="10 0 0 0" Padding="5" Content="清空" Click="Button_Click">
  21.                
  22.             </Button>
  23.         </StackPanel>
  24.         
  25.         <ListView Grid.Row="1" ItemsSource="{Binding Messages}" Margin="0 5 0 5">
  26.             <ListView.View>
  27.                 <GridView>
  28.                     <GridViewColumn Header="时间" Width="120"
  29.                                     DisplayMemberBinding="{Binding Time}"/>
  30.                     <GridViewColumn Header="来源" Width="150"
  31.                                     DisplayMemberBinding="{Binding Source}"/>
  32.                     <GridViewColumn Header="内容" Width="450"
  33.                                     DisplayMemberBinding="{Binding Content}"/>
  34.                 </GridView>
  35.             </ListView.View>
  36.         </ListView>
  37.         
  38.         <TextBlock Grid.Row="2" Text="注:使用长度前缀法(4字节头部)处理粘包,支持多客户端连接"
  39.                    Foreground="Gray" FontSize="12"/>
  40.     </Grid>
  41. </Window>
复制代码
MainWindow.xaml.cs
  1. using System.Windows;
  2. using System.Net;
  3. using WpfApp1;
  4. namespace WpfSocketServer
  5. {
  6.     public partial class MainWindow : Window
  7.     {
  8.         private readonly MainViewModel _viewModel = new MainViewModel();
  9.         private SocketServer _socketServer;
  10.         public MainWindow()
  11.         {
  12.             InitializeComponent();
  13.             DataContext = _viewModel; // 绑定 ViewModel
  14.         }
  15.         // 启动/停止服务器按钮点击事件
  16.         private async void BtnStart_Click(object sender, RoutedEventArgs e)
  17.         {
  18.             if (_socketServer == null || !_socketServer.IsRunning())
  19.             {
  20.                 btnStart.Content = "停止服务器";
  21.                 // 启动服务器(监听 127.0.0.1:6666)
  22.                 _socketServer = new SocketServer(_viewModel);
  23.                 await _socketServer.StartAsync(new IPEndPoint(IPAddress.Loopback, 6666));
  24.                
  25.             }
  26.             else
  27.             {
  28.                 // 停止服务器
  29.                 _socketServer.Stop();
  30.                 btnStart.Content = "启动服务器";
  31.             }
  32.         }
  33.         /// <summary>
  34.         /// 清空ViewList
  35.         /// </summary>
  36.         /// <param name="sender"></param>
  37.         /// <param name="e"></param>
  38.         private void Button_Click(object sender, RoutedEventArgs e)
  39.         {
  40.             if (_viewModel.Messages?.Count > 0)
  41.             {
  42.                 _viewModel.Messages.Clear();
  43.             }
  44.             else
  45.             {
  46.                 MessageBox.Show("已经为空", "tips");
  47.             }
  48.         }
  49.     }
  50.     // 扩展:检查服务器是否运行(添加到 SocketServer 类)
  51.     public static class SocketServerExtensions
  52.     {
  53.         public static bool IsRunning(this SocketServer server)
  54.         {
  55.             return server?._isRunning ?? false;
  56.         }
  57.     }
  58. }
复制代码
MessageItem
  1. using System;
  2. using System.Collections.Generic;
  3. using System.Collections.ObjectModel;
  4. using System.ComponentModel;
  5. using System.Linq;
  6. using System.Runtime.CompilerServices;
  7. using System.Text;
  8. using System.Threading.Tasks;
  9. namespace WpfApp1
  10. {
  11.     // 消息项模型(用于 ListView 显示)
  12.     public class MessageItem : INotifyPropertyChanged
  13.     {
  14.         public DateTime Time { get; set; }    // 时间戳
  15.         public string? Source { get; set; }    // 客户端来源(IP:Port)
  16.         public string? Content { get; set; }   // 消息内容
  17.         public event PropertyChangedEventHandler PropertyChanged;
  18.         protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
  19.         {
  20.             PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
  21.         }
  22.     }
  23.     // 主 ViewModel
  24.     public class MainViewModel : INotifyPropertyChanged
  25.     {
  26.         private ObservableCollection<MessageItem> _messages = new ObservableCollection<MessageItem>();
  27.         // 消息集合(绑定到 ListView)
  28.         public ObservableCollection<MessageItem> Messages
  29.         {
  30.             get => _messages;
  31.             set
  32.             {
  33.                 _messages = value;
  34.                 OnPropertyChanged();
  35.             }
  36.         }
  37.         public event PropertyChangedEventHandler PropertyChanged;
  38.         protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
  39.         {
  40.             PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
  41.         }
  42.     }
  43. }
复制代码
WpfSocketServer
  1. using System;
  2. using System.Collections.Generic;
  3. using System.Net;
  4. using System.Net.Sockets;
  5. using System.Text;
  6. using System.Threading.Tasks;
  7. using System.Windows;
  8. using WpfApp1;
  9. namespace WpfSocketServer
  10. {
  11.     public class SocketServer : IDisposable
  12.     {
  13.         private Socket _serverSocket;
  14.         private readonly MainViewModel _viewModel;
  15.         public bool _isRunning;
  16.         public SocketServer(MainViewModel viewModel)
  17.         {
  18.             _viewModel = viewModel;
  19.         }
  20.         // 启动服务器(异步)
  21.         public async Task StartAsync(IPEndPoint endPoint)
  22.         {
  23.             if (_isRunning) return;
  24.             try
  25.             {
  26.                 _serverSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
  27.                 _serverSocket.Bind(endPoint);//绑定端口
  28.                 _serverSocket.Listen(5); // 最大等待连接数
  29.                 _isRunning = true;
  30.                 UpdateStatus("服务器已启动,等待客户端连接...");
  31.                 // 异步接受客户端连接(循环)
  32.                 while (_isRunning)
  33.                 {
  34.                     Socket clientSocket = await _serverSocket.AcceptAsync();
  35.                     var clientInfo = clientSocket.RemoteEndPoint?.ToString();
  36.                     UpdateMessage(new MessageItem
  37.                     {
  38.                         Time = DateTime.Now,
  39.                         Source = clientInfo,
  40.                         Content = "客户端已连接"
  41.                     });
  42.                     // 为每个客户端启动独立接收任务
  43.                     _ = Task.Run(() => HandleClientAsync(clientSocket));
  44.                 }
  45.             }
  46.             catch (Exception ex)
  47.             {
  48.                 if (_isRunning) // 非主动停止时显示错误
  49.                     UpdateStatus($"服务器异常: {ex.Message}");
  50.             }
  51.         }
  52.         /// <summary>
  53.         /// 核心逻辑:
  54.         /// 当滑动窗口的数据不足以组成一个完整的数据包时(header+body),暂时保留当前数据,等候后续追加。
  55.         /// </summary>
  56.         /// <param name="clientSocket"></param>
  57.         /// <returns></returns>
  58.         private async Task HandleClientAsync(Socket clientSocket)
  59.         {
  60.             var clientInfo = clientSocket.RemoteEndPoint?.ToString();
  61.             List<byte> receiveBuffer = new List<byte>(); // 处理粘包的缓冲区,可以使用Memory<byte>来优化,我偷个懒。
  62.             try
  63.             {
  64.                
  65.                 byte[] buffer = new byte[1024];
  66.                 while (clientSocket.Connected)
  67.                 {
  68.                     // 从内核态的滑动窗口中读取字节流到缓冲区
  69.                     int readBytesLength = await clientSocket.ReceiveAsync(buffer, SocketFlags.None);
  70.                     if (readBytesLength == 0) break;
  71.                     UpdateMessage(new MessageItem
  72.                     {
  73.                         Time = DateTime.Now,
  74.                         Source = clientInfo,
  75.                         Content = $"本次接收原始字节数: {readBytesLength}(可能包含多个包)"
  76.                     });
  77.                     receiveBuffer.AddRange(new ArraySegment<byte>(buffer, 0, readBytesLength));
  78.                     while (receiveBuffer.Count >= 4)
  79.                     {
  80.                         // 解析head,得出body长度
  81.                         byte[] headerBytes = receiveBuffer.GetRange(0, 4).ToArray();
  82.                         if (BitConverter.IsLittleEndian) Array.Reverse(headerBytes);
  83.                         int bodyBytesLength = BitConverter.ToInt32(headerBytes, 0);
  84.                         //数据如果不足够,则退出循环,保留当前数据,等待下次接收新数据后再次尝试解析
  85.                         if (receiveBuffer.Count < (4 + bodyBytesLength)) break;
  86.                         // 解析body
  87.                         byte[] bodyBytes = receiveBuffer.GetRange(4, bodyBytesLength).ToArray();
  88.                         string message = Encoding.UTF8.GetString(bodyBytes);
  89.                         //数据如果足够,则提取数据、更新 UI,并从缓冲区移除已处理的数据。
  90.                         UpdateMessage(new MessageItem
  91.                         {
  92.                             Time = DateTime.Now,
  93.                             Source = clientInfo,
  94.                             Content = $"解析后数据包: {message}"
  95.                         });
  96.                         // 移除已处理的数据
  97.                         receiveBuffer.RemoveRange(0, 4 + bodyBytesLength);
  98.                     }
  99.                 }
  100.                 // 客户端断开连接
  101.                 UpdateMessage(new MessageItem
  102.                 {
  103.                     Time = DateTime.Now,
  104.                     Source = clientInfo,
  105.                     Content = "客户端断开连接"
  106.                 });
  107.             }
  108.             catch (Exception ex)
  109.             {
  110.                 UpdateMessage(new MessageItem
  111.                 {
  112.                     Time = DateTime.Now,
  113.                     Source = clientInfo,
  114.                     Content = $"接收异常: {ex.Message}"
  115.                 });
  116.             }
  117.             finally
  118.             {
  119.                 clientSocket.Close();
  120.             }
  121.         }
  122.         // 安全更新 UI 消息(通过 Dispatcher)
  123.         private void UpdateMessage(MessageItem message)
  124.         {
  125.             Application.Current.Dispatcher.Invoke(() =>
  126.             {
  127.                 _viewModel.Messages.Add(message);
  128.             });
  129.         }
  130.         // 更新状态文本
  131.         private void UpdateStatus(string text)
  132.         {
  133.             Application.Current.Dispatcher.Invoke(() =>
  134.             {
  135.                 // 假设窗口中 txtStatus 是 TextBlock,需在窗口后台绑定此方法
  136.                 // 实际使用中可通过 ViewModel 暴露状态属性
  137.                 if (Application.Current.MainWindow is MainWindow mainWindow)
  138.                     mainWindow.txtStatus.Text = $"状态:{text}";
  139.             });
  140.         }
  141.         // 停止服务器
  142.         public void Stop()
  143.         {
  144.             _isRunning = false;
  145.             _serverSocket?.Close();
  146.             UpdateStatus("服务器已停止");
  147.         }
  148.         // 释放资源
  149.         public void Dispose()
  150.         {
  151.             Stop();
  152.         }
  153.     }
  154. }
复制代码
产生粘包的Client[code]using System.Net.Sockets;using System.Net;using System.Text;namespace SocketReceiveClient{    internal class Program    {        static void Main()        {            var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);            client.Connect(IPAddress.Loopback, 6666); // 连接你的 WPF 服务器            Console.WriteLine("已连接服务器,开始发送粘包数据...");            // 连续发送 3 个小数据包(间隔 50ms,触发 TCP 粘包)            for (int i = 0;i
您需要登录后才可以回帖 登录 | 立即注册