【问题标题】:Is it possible to parse string without GC allocations?是否可以在没有 GC 分配的情况下解析字符串?
【发布时间】:2017-05-02 20:45:36
【问题描述】:

我需要从 Android 设备的内置 GPS 接收器中解析 NMEA 数据。我每秒几次以字符串形式接收此数据。我很好奇是否有可能在没有垃圾收集分配或解析字符串的情况下做到这一点,这是我可以问心无愧地调用GC.Collect() 的时刻之一?

正是我需要调用string.split() 和其他一些方法,如Substring() 并将结果转换为double.Parse()

我尝试通过转换为 char[] 来做到这一点,但这样 GC 分配会更大。

GPS NMEA 数据有很多句子,我需要每秒解析 2-3 个句子。下面是解析其中一个句子的示例代码 - $GPRMC

例句:

$GPRMC,081836,A,3751.65,S,14507.36,E,000.0,360.0,130998,011.3,E*62 $GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47 $GPGSA,A,3,32,27,03,193,29,23,19,16,21,31,14,,1.18,0.51,1.07*35

        // Divide the sentence into words
        string[] Words = sSentence.split(',');
        // Do we have enough values to describe our location?
        if (Words[3] != "" & Words[4] != "" &
            Words[5] != "" & Words[6] != "")
        {
            // example 5230.5900,N
            // 52°30.5900\N

            // Yes. Extract latitude and longitude


            //Latitude decimal

            double DegreesLat = double.Parse(Words[3].Substring(0, 2), NmeaCultureInfo);
            string[] tempLat = Words[3].Substring(2).ToString ().Split ('.');
            double MinutesLat = double.Parse (tempLat[0], NmeaCultureInfo);
            string SecLat = "0";
            if (tempLat.Length >= 2) {
                SecLat = "0."+tempLat[1];
            }
            double SecondsLat = double.Parse (SecLat, NmeaCultureInfo)*60;

            double Latitude = (DegreesLat + (MinutesLat / 60) + (SecondsLat/3600));


            //Longitude decimal

            double DegreesLon = double.Parse(Words[5].Substring(0, 3), NmeaCultureInfo);
            string[] tempLon = Words[5].Substring(3).ToString ().Split ('.');
            double MinutesLon = double.Parse (tempLon[0], NmeaCultureInfo);
            string SecLon = "0";
            if (tempLon.Length >= 2) {
            SecLon = "0."+tempLon[1];
            }
            double SecondsLon = double.Parse (SecLon, NmeaCultureInfo)*60;

            double Longitude = (DegreesLon + (MinutesLon / 60) + (SecondsLon/3600));

            // Notify the calling application of the change
            if (PositionReceived != null)
                PositionReceived(Latitude, Longitude);

【问题讨论】:

  • 你打算在哪里存储你的字符串?
  • 这是XY Problem 的一小部分,向我们展示您在做什么需要您每秒解析大量字符串,我们也许可以为您提供一个需要的替代解决方案没有(或至少不那么频繁)字符串解析。
  • 每当您认为需要致电GC.Collect() 时,您很可能会优化问题的错误结局。
  • @Filburt 他正在使用 Unity 3d,在新关卡加载的第一帧调用 GC.Collect() 是清理空间的好机会,因为玩家已经在等待关卡加载发生这样您就可以将集合的打嗝移动到您希望它发生的地方,而不是在游戏过程中空间不足时。
  • @ScottChamberlain 我需要解析从内置 Android 设备 GPS 接收器接收到的 NMEA 数据。

标签: c# unity3d garbage-collection string-parsing


【解决方案1】:

2020 年 6 月 2 日更新:从 netstandard2.1 开始,您可以将字符串替换为 ReadOnlySpan 并执行无需分配的任务。见https://docs.microsoft.com/en-us/dotnet/api/system.memoryextensions?view=netcore-3.1


你在问how could I manage strings without allocating space?。这是一个答案:你 always can use stackalloc 在没有 GC 压力的情况下在堆栈上分配 char[] 数组,然后创建最终字符串(如果需要)using char* 构造函数。但要小心,因为它是不安全的,而且你不可能只分配一个公共的char[]StringBuilder,因为 gen0 的收集几乎没有成本。

您有大量的代码,例如 Words[3].Substring(2).ToString ().Split ('.'),这些代码非常占用内存。只要修好它,你就是金子。但如果对你没有帮助,你必须拒绝使用Substring和其他分配内存的方法,并使用你自己的解析器。


让我们开始优化吧。首先,我们可以修复所有其他分配。你说你已经做过了,但这是我的变种:

private static (double Latitude, double Longitude)? GetCoordinates(string input)
{
    // Divide the sentence into words
    string[] words = input.Split(',');
    // Do we have enough values to describe our location?
    if (words[3] == "" || words[4] == "" || words[5] == "" || words[6] == "")
        return null;

    var latitude = ParseCoordinate(words[3]);
    var longitude = ParseCoordinate(words[5]);

    return (latitude, longitude);
}

private static double ParseCoordinate(string coordinateString)
{
    double wholeValue = double.Parse(coordinateString, NmeaCultureInfo);

    int integerPart = (int) wholeValue;
    int degrees = integerPart / 100;
    int minutes = integerPart % 100;
    double seconds = (wholeValue - integerPart) * 60;

    return degrees + minutes / 60.0 + seconds / 3600.0;
}

好的,我们假设它仍然很慢,我们想进一步优化它。首先,我们应该替换这个条件:

if (words[3] == "" || words[4] == "" || words[5] == "" || words[6] == "")
        return null;

我们在这里做什么?我们只想知道字符串是否包含一些值。我们可以在不解析字符串的情况下研究它。通过进一步的优化,如果出现问题,我们根本不会解析字符串。它可能看起来像:

private static (string LatitudeString, string LongitudeString)? ParseCoordinatesStrings(string input)
{
    int latitudeIndex = -1;
    for (int i = 0; i < 3; i++)
    {

        latitudeIndex = input.IndexOf(',', latitudeIndex + 1);
        if (latitudeIndex < 0)
            return null;
    }
    int latitudeEndIndex = input.IndexOf(',', latitudeIndex + 1);
    if (latitudeEndIndex < 0 || latitudeEndIndex - latitudeIndex <= 1)
        return null; // has no latitude
    int longitudeIndex = input.IndexOf(',', latitudeEndIndex + 1);
    if (longitudeIndex < 0)
        return null;
    int longitudeEndIndex = input.IndexOf(',', longitudeIndex + 1);
    if (longitudeEndIndex < 0 || longitudeEndIndex - longitudeIndex <= 1)
        return null; // has no longitude
    string latitudeString = input.Substring(latitudeIndex + 1, latitudeEndIndex - latitudeIndex - 1);
    string longitudeString = input.Substring(longitudeIndex + 1, longitudeEndIndex - longitudeIndex - 1);
    return (latitudeString, longitudeString);
}

现在,将它们组合在一起:

using System;
using System.Globalization;

namespace SO43746933
{
    class Program
    {
        private static readonly CultureInfo NmeaCultureInfo = CultureInfo.InvariantCulture;

        static void Main(string[] args)
        {
            string input =
                "$GPRMC,081836,A,3751.65,S,14507.36,E,000.0,360.0,130998,011.3,E*62 $GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47 $GPGSA,A,3,32,27,03,193,29,23,19,16,21,31,14,,1.18,0.51,1.07*35";
            var newCoordinates = GetCoordinatesNew(input);
            var oldCoorinates = GetCoordinatesOld(input);
            if (newCoordinates == null || oldCoorinates == null)
            {
                throw new InvalidOperationException("should never throw");
            }
            Console.WriteLine("Latitude: {0}\t\tLongitude:{1}", newCoordinates.Value.Latitude, newCoordinates.Value.Longitude);
            Console.WriteLine("Latitude: {0}\t\tLongitude:{1}", oldCoorinates.Value.Latitude, oldCoorinates.Value.Longitude);
        }

        private static (double Latitude, double Longitude)? GetCoordinatesNew(string input)
        {
            // Divide the sentence into words
            var coordinateStrings = ParseCoordinatesStrings(input);
            // Do we have enough values to describe our location?
            if (coordinateStrings == null)
                return null;

            var latitude = ParseCoordinate(coordinateStrings.Value.LatitudeString);
            var longitude = ParseCoordinate(coordinateStrings.Value.LongitudeString);

            return (latitude, longitude);
        }

        private static (string LatitudeString, string LongitudeString)? ParseCoordinatesStrings(string input)
        {
            int latitudeIndex = -1;
            for (int i = 0; i < 3; i++)
            {

                latitudeIndex = input.IndexOf(',', latitudeIndex + 1);
                if (latitudeIndex < 0)
                    return null;
            }
            int latitudeEndIndex = input.IndexOf(',', latitudeIndex + 1);
            if (latitudeEndIndex < 0 || latitudeEndIndex - latitudeIndex <= 1)
                return null; // has no latitude
            int longitudeIndex = input.IndexOf(',', latitudeEndIndex + 1);
            if (longitudeIndex < 0)
                return null;
            int longitudeEndIndex = input.IndexOf(',', longitudeIndex + 1);
            if (longitudeEndIndex < 0 || longitudeEndIndex - longitudeIndex <= 1)
                return null; // has no longitude
            string latitudeString = input.Substring(latitudeIndex + 1, latitudeEndIndex - latitudeIndex - 1);
            string longitudeString = input.Substring(longitudeIndex + 1, longitudeEndIndex - longitudeIndex - 1);
            return (latitudeString, longitudeString);
        }

        private static double ParseCoordinate(string coordinateString)
        {
            double wholeValue = double.Parse(coordinateString, NmeaCultureInfo);

            int integerPart = (int) wholeValue;
            int degrees = integerPart / 100;
            int minutes = integerPart % 100;
            double seconds = (wholeValue - integerPart) * 60;

            return degrees + minutes / 60.0 + seconds / 3600.0;
        }

        private static (double Latitude, double Longitude)? GetCoordinatesOld(string input)
        {
            // Divide the sentence into words
            string[] Words = input.Split(',');
            // Do we have enough values to describe our location?
            if (!(Words[3] != "" && Words[4] != "" &
                  Words[5] != "" && Words[6] != ""))
                return null;
            // example 5230.5900,N
            // 52°30.5900\N

            // Yes. Extract latitude and longitude


            //Latitude decimal

            var wholeLat = double.Parse(Words[3], NmeaCultureInfo);

            int integerPart = (int)wholeLat;
            int DegreesLat = integerPart / 100;
            string[] tempLat = Words[3].Substring(2).Split('.');
            int MinutesLat = integerPart % 100;
            string SecLat = "0";
            if (tempLat.Length >= 2)
            {
                SecLat = "0." + tempLat[1];
            }
            double SecondsLat = double.Parse(SecLat, NmeaCultureInfo) * 60;

            double Latitude = (DegreesLat + (MinutesLat / 60.0) + (SecondsLat / 3600.0));


            //Longitude decimal

            double DegreesLon = double.Parse(Words[5].Substring(0, 3), NmeaCultureInfo);
            string[] tempLon = Words[5].Substring(3).ToString().Split('.');
            double MinutesLon = double.Parse(tempLon[0], NmeaCultureInfo);
            string SecLon = "0";
            if (tempLon.Length >= 2)
            {
                SecLon = "0." + tempLon[1];
            }
            double SecondsLon = double.Parse(SecLon, NmeaCultureInfo) * 60;

            double Longitude = (DegreesLon + (MinutesLon / 60) + (SecondsLon / 3600));
            return (Latitude, Longitude);
        }
    }
}

它分配了 2 个临时字符串,但对于 GC 来说应该不是问题。您可能希望ParseCoordinatesStrings 返回(double, double) 而不是(string, string),通过将latitudeStringlongitudeString 设为不从方法返回的局部变量来最小化它们的生命周期。在这种情况下,只需将double.Parse 移动到那里。

【讨论】:

  • 您能解释一下如何使用 StringBuilder 进行拆分吗?我认为我们需要将 StringBuilder 转换为字符串(),这对于 GC 来说会更糟糕。 String.Split() 是我的代码中剩下的最后一个正在做 GC 的东西。
  • @seek StringBuilder 可以在您自己的解析器中使用。如果您对string.Split 有疑问,请不要使用它。在解析整个字符串并创建许多您从未使用过的部分时,您只需要 4 个参数。例如,您正在使用string 操作将32.758 拆分为32758,然后将其传递给MinutesLatSecondsLat。为什么不直接解析整个数字,然后对数字进行操作?
  • @AlexZhukovskiy 我在之前的评论中告诉过我已经更改了所有其他 GC 分配方法,只有一个出现问题的是 string[] Words = sSentence.split(','); - 我在解析其他 NMEA 句子时使用了这个拆分中的所有参数。说在没有任何其他解决方案的情况下不要使用它对我来说似乎不是一个答案。
  • @AlexZhukovskiy 看起来很有希望,但正如我所见,我们无法避免 GC 分配。使用您的方法 163 字节而不是 757 字节。我很好奇我是否可以每秒离开这样的分配 5-7 次......
  • 你唯一能做的就是用你自己的解析方法替换double.Parse,这不需要创建子字符串,但你将无法使用CultureInfo(否则你应该复杂化您的解析代码很多)并且可能会受到性能影响(因为解析代码的托管实现)。是的,每秒 1kb 的关联是非常低的水平。为什么需要进一步优化?
【解决方案2】:

Unity中的GC和解析,有两种处理方式:

传统方式

Unity 方式

两者都很好用,但一个听起来很简单,事实上,它真的很简单。

传统方式包括使用 C# 和 C++ 书中通常在其他软件中使用的许多技巧之一。它已经被其他答案中的其他人多次覆盖,因此,尽管听起来很便宜,但我不会在这里覆盖它。

Unity 方式是 Unity Technologies 开发人员解释的官方方式。 (通常在 GDC 的年度展览中进行解释。我将解释的方式在 Unity GDC 2016 期间进行了解释,即使在今天,它仍然是 Unity 中最优化的方式。

在解释如何使用 Unity 方式之前,我必须先解释一下 Unity GC 的工作原理,因为即使在今天,许多人仍然不清楚。 GC 就像一个从一开始就建立起来的块系统,只有在应用程序或软件关闭时才会清空。 (在 PC/Mac 上,与在移动设备上略有不同,但在 PC/Mac 上应用它确实会产生很大的不同。)每次您使用生成任何类型参数的任何类型的函数时,它都会创建一个新块在 GC 中。只要新数据小于以前的数据,一个块就可以被覆盖,但只要应用程序/软件正在运行,它就不能被删除。也就是说,这个系统要求你避免嵌套过多的数据,同时也要求你尽可能的嵌套数据。

这听起来可能很矛盾,但事实并非如此。它只是意味着你必须知道你在嵌套什么,这样你就可以尽可能少地嵌套,一次,必要的。嵌套是避免填满 GC 的关键。

对于这里提出的问题,最简单的解决方案是在 APP 启动时生成一个通用嵌套脚本(您可以使用 DontDestroyOnLoad(); 保留该脚本)。我通常在初始启动屏幕期间执行此操作。这就是为什么我不使用 Unity 的预制徽标闪屏,而是在它自己的场景中构建我自己的,这样我就可以启动我在整个应用程序中需要的所有 sweaks 和 pre-requires 静态属性。我通常用一个初始的假数据块填充这些静态属性,这样它们的块就足够大,可以容纳我扔在那里的任何东西。例如,如果您需要一个数组,请保留一个由 512 个整数或浮点数或字符串组成的数组,并用 1 个足够大(尤其是字符串)的假示例填充它们以保存您的实际数据。

在这个“通用”嵌套脚本中,您添加应该保存原始 GPS 数据(字符串)及其分割部分的参数(无论是字符串数组还是转换后的数据,如浮点数等)。每当您读取 GPS 数据(原始字符串)时,您总是将其存储在通用嵌套脚本中并覆盖前一个。 (如果你想保留之前的数据,我建议你只保留转换后的数据,而不是原始的 GPS 数据。为什么还要重新转换,对吧?)

理想情况下,您将所有转换调用和数据保存在通用嵌套脚本中。您只需要记住线性工作(意味着避免在单个帧中让多个脚本更改嵌套值),通常,拥有一个处理所有请求(停止/忽略重复请求)的 mastermind 函数。

为什么要这样做?这样,您可以用最少的最小值填充 GC,并一次又一次地重用其相同的内存块。这些内存块不需要被 GC 清除,因为它们一直在使用。几乎不会浪费块,并且块的大小正是您需要的大小,没有随机性(意味着无需为更大的数据创建新的更大的块)。

这是欧洲 GDC 2016 期间 Unity 优化展示的链接(带有确切观看有关内存管理和 GC 解释的时间戳):https://youtu.be/j4YAY36xjwE?t=1432

如果你想知道,是的,我自己在通用嵌套脚本中保留了一堆整数,当我什至只是调用 for() 来替换 foreach() 时总是使用这些整数(因为 foreach() 生成了一点不能重复使用的块,每次使用后总是被扔给 GC。)

【讨论】:

    猜你喜欢
    • 2011-02-03
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2012-02-23
    • 1970-01-01
    • 2019-05-03
    • 2017-03-22
    • 1970-01-01
    相关资源
    最近更新 更多