• .NET上位机踩坑:为什么有时读取数据需要Sleep?


    前言

    大家好,我是 wacky。

    今天这篇文章,也是源自于一个真实的探讨。曾经有朋友说,做上位机开发对接PLC的时候,有时会出现玄学,读取不到完整的数据。这时就需要加一个Thread.Sleep(30),或者一个类似的时间间隔,就完全好了,至于为什么,完全搞不清楚。

    我相信应该不少人都遇到过类似的情况,包括我自己,曾经做数据采集的时候,也加过类似的代码,但是当时没想明白为什么,只是以为下位机返回数据存在延迟的现象。但是随着时间的推移和经验的积累,我认为任何问题都有迹可循,恰好近期在梳理工控系列文章的时候,想明白了这个问题,我认为有以下两种可能性:

    PLC扫描周期未到

    参考我们之前写的内容:.NET工控概念科普——PLC扫描周期:理解了它才算入了工控的门

    PLC是存在扫描周期的概念的,如果一个扫描周期过了,但是未到下一个扫描周期,这时你刚好发送了一个读取数据的指令,那么PLC即使收到了报文,也只会返回旧数据,因为没有把最新的值给到通信缓冲区。

    但是这种情况的可能性其实很低,因为我们的问题是"读不到完整的数据",而不是"一直读取的是旧数据",那么就要引入第二种可能性。

    TCP存在粘包/半包

    这个就是我们今天要探讨的重点了,因为TCP协议属于流式协议,它是没有报文边界的。所以这就会出现PLC发送了一包100字节,我们上位机可能会收到:

    • 全部完整数据;
    • 拆成两段(半包);
    • 两包数据合并到一起(粘包)。

    我们一般在上位机中接收数据的写法是这种:

    复制代码
    using System;
    using System.Net.Sockets;
    
    class Program
    {
        static void Main(string[] args)
        {
            // PLC IP和端口 Modbus TCP默认502
            string plcIp = "127.0.0.1";
            int port = 502;
    
            Socket socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
            try
            {
                socket.Connect(plcIp, port);
    
                byte[] recvBuffer = new byte[4096]; // 接收缓冲区大小
                int recvLen = socket.Receive(recvBuffer);
    
                byte[] result = new byte[recvLen];
                Array.Copy(recvBuffer, result, recvLen);
    
                string hexStr = BitConverter.ToString(result).Replace("-", " ");
    
                socket.Close();
            }
            catch (Exception ex)
            {
                Console.WriteLine("异常:" + ex.Message);
            }
            Console.ReadKey();
        }
    }
    复制代码

    看似没任何毛病,实际上这里面有两个很大的问题:第一个问题是socket.Receive的时候,我们读取的是socket内核缓冲区中已经到达的数据,有多少就会读多少,这个实际上不一定会填满数组;第二个问题是只处理本次读到的 recvLen 个字节,读完就结束,不会继续收剩下的数据,这样也就导致了数据可能是半包,并不全。

    这也就能解释为什么我们加入了Thread.Sleep(30)后会变得正常,因为这样可以让剩下的数据全部进入了缓冲区,下一次再Receive就可以拿到完整的帧。

    但是这样其实只是一个取巧的办法,并不靠谱,可能是30毫秒,也可能需要50~100毫秒,如果有网络波动、PLC负载高的情况时,这个方法可能就会失灵。而且不同的设备可能需要Sleep的时间也不同,做上位机开发时就得不停地根据不同的设备来回改,也是一件很麻烦的事。

    那么究竟要如何解决粘包或者半包的问题呢?这里要引入一个概念:环形缓冲区。

    环形缓冲区(Ring Buffer)

    环形缓冲区又称循环缓冲区或环形队列,是一种逻辑上首尾相连的固定大小数组结构。当数据写入缓冲区末尾时,会自动回绕到开头继续写入;读取数据时也可以循环读取,形成一个“环”的效果。它遵循FIFO(先进先出)原则,保证数据按写入顺序被读取 。

    环形缓冲区的设计中需要下面几个组件:

    • 缓冲区数组:固定大小的连续内存空间,用于存储数据。
    • 写指针:指向下一个写入的位置。
    • 读指针:指向下一个读取的位置。
    • 大小计数器:当前缓冲区有效字节数量。

    有了上述要素后,我们在设计环形缓冲区就有了以下示例:

    复制代码
    internal class RingBuffer
        {
            private readonly byte[] _buffer;
            private int _readPos;
            private int _writePos;
            private readonly object _lockObj = new object();
    
            public int Capacity => _buffer.Length;
            public int DataLen
            {
                get
                {
                    lock (_lockObj)
                    {
                        return (_writePos - _readPos + Capacity) % Capacity;
                    }
                }
            }
    
            public RingBuffer(int capacity)
            {
                _buffer = new byte[capacity];
                _readPos = 0;
                _writePos = 0;
            }
    
            public int Write(byte[] data, int offset, int len)
            {
                lock (_lockObj)
                {
                    if (len <= 0) return 0;
                    int free = Capacity - DataLen;
                    int realWrite = Math.Min(len, free);
                    for (int i = 0; i < realWrite; i++)
                    {
                        _buffer[_writePos] = data[offset + i];
                        _writePos = (_writePos + 1) % Capacity;
                    }
                    return realWrite;
                }
            }
    
            public bool Peek(byte[] target, int offset, int len)
            {
                lock (_lockObj)
                {
                    if (DataLen < len) return false;
                    int pos = _readPos;
                    for (int i = 0; i < len; i++)
                    {
                        target[offset + i] = _buffer[pos];
                        pos = (pos + 1) % Capacity;
                    }
                    return true;
                }
            }
    
            public void Skip(int len)
            {
                lock (_lockObj)
                {
                    len = Math.Min(len, DataLen);
                    _readPos = (_readPos + len) % Capacity;
                }
            }
        }
    复制代码

    缓冲区一次性把byte数组全部分配,全程不需要进行扩容,这样可以有效减少GC压力。

    计算有效字节数量时,我们使用了(_writePos - _readPos + Capacity) % Capacity的方式去进行计算,这里的+ Capacity是为了防止_writePos < _readPos (写指针绕回了数组开头,写在前面,而读在后面),减法出现负数,取模会出错。

    然后我们的Write方法负责把socket收到的字节写入环形缓存,这里会先计算空余空间free,在缓冲区满的时候,会丢弃新来的数据。然后再逐个字节写入,写完后移动写指针,%Capacity来实现环形回绕。最后会返回实际写入的字节数量,用于其他逻辑做判断。

    这里我们还定义了一个Peek方法,主要是用于预览报文头,而不消费任何数据。如果数据不够,则直接返回false,保留字节,等待下一轮socket接收。这个Peek方法不会移动读指针,也不会删除缓存的数据,只有确认拿到了完整报文之后,才会调用Skip移动读指针,才算消费。

    Skip方法负责移动读指针,主要是为了在解析成功一整帧之后,读指针直接前进,代表这一段字节数据处理完成,可以丢弃。这里没有做任何的数组拷贝,只移动了索引,这样也保证了环形缓冲区的高效运行。

    总结一下所有的流程,大概是这样:

    image

     

    我们还在读写操作中加了锁,防止出现线程安全的问题。

    后记

    今天我们这篇文,从为什么要加Sleep开始讲起,引入了TCP粘包和半包产生问题的原因,并加入了环形缓冲区的概念。事实上环形缓冲区的设计并不复杂,主要就是固定数组+读写索引+模运算来实现循环,但它却可以有效解决粘包和半包的问题,不失为一个好的解决方案。

    所以我们后续在遇到难以解释的问题时,一定要抽时间想一想问题本质可能产生的原因。诚然,Sleep可以解决当下的问题,但保不了一世。希望每位开发人员都可以从这个问题中有所感悟,锻炼自己的抽象思维和设计能力,成长为更好的自己!诸君共勉!

    本文首发于我的公众号【wacky的碎碎念】,欢迎关注追更!

    qrcode_for_gh_7c93e745425f_258

     

  • 相关阅读:
    Python+Appium+Pytest+Allure自动化测试框架-安装篇
    【控制】动力学建模简介 --> 牛顿-欧拉 (Newton-Euler) 法和拉格朗日 (Lagrange) 法
    码蹄集 - MT3203 - 填坑:骗数据过的~_~
    java中获取主机信息InetAddress类的两种方法
    简述一下伪共享的概念以及如何避免
    Java中Iterator接口的简介说明
    深夜学习:有关Inner、Outer等相关词汇的理解
    模拟退火算法求解TSP问题(python)
    Autoware中的点云3D聚类算法,保姆级算法阅读注释,一看就懂,非常详细!
    拆解小鹏汽车财报:连续三个季度收入下滑,门店月均销量仅为8辆
  • 原文地址:https://www.cnblogs.com/wackysoft/p/22947367