
C#
是一种非常灵活的语言。 在它上面,您不仅可以编写后端或桌面应用程序。 我使用C#
处理科学数据,这对使用该语言的工具提出了某些要求。 尽管netcore
抓住了议程(考虑到在netstandard2.0
之后,语言和运行时的大多数功能都不会netframework
到netframework
),但我还是继续处理旧项目。
在本文中,由于clr
的特殊性,我考虑了Span<T>
一种非显而易见(但可能是期望的)应用,以及netframework
和netcore
中Span<T>
的实现之间的差异。
免责声明1本文中的代码段决不打算用于实际项目中。
提出的(牵强?)问题的解决方案是一种概念证明。
无论如何,通过在项目中实施此操作,您将自担风险并承担风险。
免责声明2我绝对确定,在某些情况下,这肯定会在膝盖处射杀某人。
C#
类型安全旁路不太可能导致任何问题。
出于明显的原因,我并未在所有可能的情况下测试此代码,但是,初步结果看起来很有希望。
为什么我完全需要Span<T>
?
Spen允许您以更方便的形式使用unmanaged
类型的数组,从而减少了必要的分配数量。 尽管几乎完全没有BCL
netframework
中的跨度支持,但是可以使用System.Memory
, System.Buffers
和System.Runtime.CompilerServices.Unsafe
获得多种工具。
在我的遗留项目中,跨度的使用是有限的,但是,我发现它们是一个不明显的用法,同时会增加类型安全性。
这是什么应用程序? 在我的项目中,我使用从科学工具获得的数据。 这些是图像,通常是T[]
的数组,其中T
是非unmanaged
基本类型之一,例如Int32
(aka int
)。 为了正确地将这些映像序列化到磁盘,我需要支持1981年提出的极为不便的旧格式 ,此后几乎没有任何变化。 这种格式的主要问题是BigEndian 。 因此,为了写入(或读取) T[]
的未压缩数组,您需要更改每个元素的字节序。 琐碎的任务。
有哪些明显的解决方案?
- 我们遍历数组
T[]
,调用BitConverter.GetBytes(T)
,扩展这几个字节,复制到目标数组。 - 我们遍历数组
T[]
,以new byte[] {(byte)((x & 0xFF00) >> 8), (byte)(x & 0x00FF)};
的形式执行欺诈行为new byte[] {(byte)((x & 0xFF00) >> 8), (byte)(x & 0x00FF)};
(应在双字节类型上工作),写入目标数组。 - *但是
T[]
是数组吗? 元素是连续的,对不对? 因此,您可以进行所有操作,例如Buffer.BlockCopy(intArray, 0, byteArray, 0, intArray.Length * sizeof(int));
。 该方法将数组复制到该数组,而忽略类型检查。 只需要不要错过界限和分配。 结果,我们混合了字节。 - *他们说
C#
是(C++)++
。 因此,启用/unsafe
, fixed(int* p = &intArr[0]) byte* bPtr = (byte*)p;
现在您可以绕过源数组的字节表示形式,快速更改字节序并将块写入磁盘(添加stackalloc byte[]
或ArrayPool<byte>.Shared
为中间缓冲区ArrayPool<byte>.Shared
),而无需为整个新的字节数组分配内存。
第4点似乎可以解决所有问题,但是显式使用unsafe
上下文和使用指针在某种程度上完全不同。 然后Span<T>
助我们一臂之力。
Span<T>
Span<T>
技术上讲, Span<T>
应该提供用于处理内存图的工具,就像通过指针进行操作一样,同时无需“固定”内存中的数组。 具有数组边界的此类GC
感知指针。 一切都很好,很安全。
一件事-尽管System.Runtime.CompilerServices.Unsafe
丰富,但Span<T>
被钉为Span<T>
类型T
假设spen本质上是1 +长度的指针,那么如果您拉出指针,将其转换为另一种类型,重新计算长度并创建新的跨度该怎么办? 幸运的是,我们有public Span<T>(void* pointer, int length)
。
让我们编写一个简单的测试:
[Test] public void Test() { void Flip(Span<byte> span) {} Span<int> x = new [] {123}; Span<byte> y = DangerousCast<int, byte>(x); Assert.AreEqual(123, x[0]); Flip(y); Assert.AreNotEqual(123, x[0]); Flip(y); Assert.AreEqual(123, x[0]); }
比我应该立即意识到这里有问题的高级开发人员。 测试会失败吗? 通常情况下,答案取决于 。
在这种情况下,它主要取决于运行时。 在netcore
测试应该可以进行,但是在netframework
,测试如何netframework
。
有趣的是,如果您删除了一些论文,则该测试将在100%的情况下开始正常运行。
让我们做对。
1我错了 。
正确答案:视情况而定
为什么结果取决于 ?
让我们删除所有不必要的内容并在此处编写这样的代码:
private static void Main() => Check(); private static void Check() { Span<int> x = new[] {999, 123, 11, -100}; Span<byte> y = As<int, byte>(ref x); Console.WriteLine(@"FRAMEWORK_NAME"); Write(ref x); Write(ref y); Console.WriteLine(); Write<int, int>(ref x, "Span<int> [0]"); Write<byte, int>(ref y, "Span<byte>[0]"); Console.WriteLine(); Write<int, int>(ref Offset<int, object>(ref x[0], 1), "Span<int> [0] offset by size_t"); Write<byte, int>(ref Offset<byte, object>(ref y[0], 1), "Span<byte>[0] offset by size_t"); Console.WriteLine(); GC.Collect(0, GCCollectionMode.Forced, true, true); Write<int, int>(ref x, "Span<int> [0] after GC"); Write<byte, int>(ref y, "Span<byte>[0] after GC"); Console.WriteLine(); Write(ref x); Write(ref y); }
Write<T, U>
方法接受类型T
,读取第一个元素的地址,并通过此指针读取一个类型U
元素U
换句话说, Write<int, int>(ref x)
将输出内存中的地址+数字999。
普通Write
打印一个数组。
现在关于As<,>
方法:
private static unsafe Span<U> As<T, U>(ref Span<T> span) where T : unmanaged where U : unmanaged { fixed(T* ptr = span) return new Span<U>(ptr, span.Length * Unsafe.SizeOf<T>() / Unsafe.SizeOf<U>()); }
C#
语法现在通过隐式调用Span<T>.GetPinnableReference()
方法来支持此fixed
状态记录。
在x64
模式下的netframework4.8
上运行此方法。 我们看看会发生什么:
LEGACY [ 999, 123, 11, -100 ] [ 231, 3, 0, 0, 123, 0, 0, 0, 11, 0, 0, 0, 156, 255, 255, 255 ] 0x|00|00|02|8C|00|00|2F|B0 999 Span<int> [0] 0x|00|00|02|8C|00|00|2F|B0 999 Span<byte>[0] 0x|00|00|02|8C|00|00|2F|B8 11 Span<int> [0] offset by size_t 0x|00|00|02|8C|00|00|2F|B8 11 Span<byte>[0] offset by size_t 0x|00|00|02|8C|00|00|2B|18 999 Span<int> [0] after GC 0x|00|00|02|8C|00|00|2F|B0 6750318 Span<byte>[0] after GC [ 999, 123, 11, -100 ] [ 110, 0, 103, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 ]
最初,两个跨度(尽管类型不同)的行为均相同,并且Span<byte>
本质上表示原始数组的字节视图。 您需要什么。
好的,让我们尝试将跨度的开始位置更改为一个IntPtr
的大小(或x64
上的2 X int
)并读取。 我们得到数组的第三个元素和正确的地址。 然后我们将收集垃圾...
GC.Collect(0, GCCollectionMode.Forced, true, true);
此方法中的最后一个标志要求GC
压缩堆。 调用GC.Collect
GC
将移动原始本地数组。 Span<int>
反映了这些更改,但是我们的Span<byte>
继续指向旧地址,现在尚不清楚该地址。 一次拍摄自己所有膝盖的好方法!
现在,让我们看看在netcore3.0.100-preview8
上调用的完全相同的代码片段的结果。
CORE [ 999, 123, 11, -100 ] [ 231, 3, 0, 0, 123, 0, 0, 0, 11, 0, 0, 0, 156, 255, 255, 255 ] 0x|00|00|01|F2|8F|BD|C6|90 999 Span<int> [0] 0x|00|00|01|F2|8F|BD|C6|90 999 Span<byte>[0] 0x|00|00|01|F2|8F|BD|C6|98 11 Span<int> [0] offset by size_t 0x|00|00|01|F2|8F|BD|C6|98 11 Span<byte>[0] offset by size_t 0x|00|00|01|F2|8F|BD|BF|38 999 Span<int> [0] after GC 0x|00|00|01|F2|8F|BD|BF|38 999 Span<byte>[0] after GC [ 999, 123, 11, -100 ] [ 231, 3, 0, 0, 123, 0, 0, 0, 11, 0, 0, 0, 156, 255, 255, 255 ]
据我所知,一切正常,并且运行稳定 。 压缩后,两个西班牙都更改其指针。 太好了! 但是,现在如何使其在遗留项目中起作用?
Jit内在
我完全忘记了对spans的支持是通过intrinsik在netcore
实现的。 换句话说, netcore
甚至可以创建指向数组片段的内部指针,并在GC
移动它时正确更新链接。 在netframework
,跨度的nuget
实现是拐杖。 实际上,我们有两种不同的用法:一种是从数组创建的,并跟踪其链接,第二种是从指针的,并且不知道其指向什么。 移动原始数组后,span指针将继续指向传递给其构造函数的指针所指向的位置。 为了进行比较,这是netcore
中span的示例实现:
readonly ref struct Span<T> where T : unmanaged { private readonly ByReference<T> _pointer;
和在netframework
:
readonly ref struct Span<T> where T : unmanaged { private readonly Pinnable<T> _pinnable; private readonly IntPtr _byteOffset; private readonly int _length; }
_pinnable
包含对该数组的引用,如果将一个引用传递给构造函数,则_byteOffset
包含一个移位(甚至整个数组的跨度也可能与数组在内存中的表示方式有关,具有一些非零移位)。 如果将void*
指针传递给构造函数,则将其简单地转换为绝对_byteOffset
。 Span将被固定在内存区域,并且所有实例方法的条件都非常丰富,例如if(_pinnable is null) {/* */} else {/* _pinnable */}
。 在这种情况下该怎么办?
怎么做是不值得的,但我还是做了
本节专门介绍netframework
支持的各种实现,这些实现允许netframework
Span<T> -> Span<U>
,并保留所有必要的链接。
我警告您:这是异常编程的区域,可能存在基本错误,并且最终出现未定义的行为
方法1:天真
如示例所示,指针的转换将不会在netframework
上提供所需的结果。 我们需要_pinnable
值。 好的,我们将通过撤消私有字段来发现反射(非常糟糕,而且并非总是可能的),我们将其写成新的文字,我们会很高兴的。 只有一个小问题:spen是一个ref struct
,它既不能是泛型参数,也不能打包到object
。 标准反射方法将需要一种或另一种方法来将跨度推入参考类型。 我没有找到简单的方法(甚至考虑了对私有领域的反思)。
方法2:我们需要更深入
我之前已经完成了所有工作( [1] , [2]和[3] )。 Spen是一种结构,无论T
为何T
三个字段占用相同的内存量( 在同一体系结构上 )。 如果[FieldOffset(0)]
怎么办? 言归正传。
[StructLayout(LayoutKind.Explicit)] ref struct Exchange<T, U> where T : unmanaged where U : unmanaged { [FieldOffset(0)] public Span<T> Span_1; [FieldOffset(0)] public Span<U> Span_2; }
但是,当您启动程序时(或者,当尝试使用类型时), TypeLoadException
遇到了TypeLoadException
-泛型不能为LayoutKind.Explicit
。 好的,没关系,让我们走一条艰难的道路:
[StructLayout(LayoutKind.Explicit)] public ref struct Exchange { [FieldOffset(0)] public Span<byte> ByteSpan; [FieldOffset(0)] public Span<sbyte> SByteSpan; [FieldOffset(0)] public Span<ushort> UShortSpan; [FieldOffset(0)] public Span<short> ShortSpan; [FieldOffset(0)] public Span<uint> UIntSpan; [FieldOffset(0)] public Span<int> IntSpan; [FieldOffset(0)] public Span<ulong> ULongSpan; [FieldOffset(0)] public Span<long> LongSpan; [FieldOffset(0)] public Span<float> FloatSpan; [FieldOffset(0)] public Span<double> DoubleSpan; [FieldOffset(0)] public Span<char> CharSpan; }
现在您可以执行以下操作:
private static Span<byte> As2(Span<int> span) { var exchange = new Exchange() { IntSpan = span }; return exchange.ByteSpan; }
该方法仅解决一个问题_length
字段按原样复制,因此在转换int
> byte
字节跨度比实际数组小4倍。
没问题:
[StructLayout(LayoutKind.Sequential)] public ref struct Raw { public object Pinnable; public IntPtr Pointer; public int Length; } [StructLayout(LayoutKind.Explicit)] public ref struct Exchange { [FieldOffset(0)] public Raw RawView; }
现在,通过RawView
您可以访问每个单独的跨度字段。
private static Span<byte> As2(Span<int> span) { var exchange = new Exchange() { IntSpan = span }; var exchange2 = new Exchange() { RawView = new Raw() { Pinnable = exchange.RawView.Pinnable, Pointer = exchange.RawView.Pointer, Length = exchange.RawView.Length * sizeof<int> / sizeof<byte> } }; return exchange2.ByteSpan; }
如果您忽略使用肮脏的把戏,它就会按预期工作。 减号-无法创建转换器的通用版本,您必须对预定义类型感到满意。
方法3:疯狂
像任何普通的程序员一样,我喜欢使事情自动化。 为任何一对unmanaged
类型编写转换器的需求并没有使我满意。 可以提供什么解决方案? 是的,让CLR
为您编写代码。
如何实现呢? 有不同的方式,有文章 。 简而言之,该过程如下所示:
创建一个构建构建器->创建一个模块构建器->构建一个类型-> {字段,方法等}->在输出中,我们得到Type
的实例。
为了确切了解类型应该是什么样(这是一个ref struct
),我们使用任何ildasm
类型的工具。 就我而言,它是dotPeek 。
创建一个类型生成器看起来像这样:
var typeBuilder = _mBuilder.DefineType($"Generated_{typeof(T).Name}", TypeAttributes.Public | TypeAttributes.Sealed | TypeAttributes.ExplicitLayout
现在是田野。 由于长度不同,我们无法将Span<T>
直接复制到Span<U>
,因此我们需要为每种类型的演员创建两种类型
[StructLayout(LayoutKind.Explicit)] ref struct Generated_Int32 { [FieldOffset(0)] public Span<Int32> Span; [FieldOffset(0)] public Raw Raw; }
在这里Raw
可以用手声明并重用。 不要忘记IsByRefLikeAttribute
。 使用字段,一切都很简单:
var spanField = typeBuilder.DefineField("Span", typeof(Span<T>), FieldAttributes.Private); spanField.SetOffset(0); var rawField = typeBuilder.DefineField("Raw", typeof(Raw), FieldAttributes.Private); rawField.SetOffset(0);
就是这样,最简单的类型已经准备就绪。 现在缓存程序集模块。 自定义类型被缓存在例如字典中( T -> Generated_{nameof(T)}
)。 我们创建一个包装程序,根据两种类型的TIn
和TOut
生成两种类型的帮助程序并在跨度上执行必要的操作。 但是有一个。 就像反射的情况一样,几乎不可能在跨度(或其他ref struct
)上使用它。 还是我没有找到简单的解决方案 。 怎么样
救援人员
反射方法通常看起来像这样:
object Invoke(this MethodInfo mi, object @this, object[] otherArgs)
它们不包含有关类型的信息,因此,如果您可以接受装箱(=包装),则不会有问题。
在我们的例子中, otherArgs
和otherArgs
必须包含一个ref struct
,我无法绕开它。
但是,有一种更简单的方法。 假设一个类型具有getter和setter方法(不是属性,而是手动创建的简单方法)。
例如:
void Generated_Int32.SetSpan(Span<Int32> span) => this.Span = span;
除了方法之外,我们还可以声明一个委托类型(在代码中明确声明):
delegate void SpanSetterDelegate<T>(Span<T> span) where T : unmanaged;
我们必须这样做,因为标准动作必须具有Action<Span<T>>
签名,但是不能将spenes用作通用参数。 但是, SpanSetterDelegate
是绝对有效的委托。
创建必要的委托。 为此,请执行标准操作:
var mi = type.GetMethod("Method_Name");
现在, spanSetter
可以用作spanSetter(Span<T>.Empty);
。 至于@this
2 ,这是我们的动态类型的实例,当然是通过Activator.CreateInstance(type)
,因为该结构具有一个没有参数的默认构造函数。
因此,最后一个领域-我们需要动态生成方法。
2您可能会注意到这里出了点问题Activator.CreateInstance()
打包ref struct
实例。 请参阅下一节的结尾。
认识Reflection.Emit
我认为方法可以使用Expression
生成,因为 简单的获取器/设置器的主体实际上包含几个表达式。 我选择了另一种更直接的方法。
如果您查看一个简单的getter的IL代码,则会看到类似( Debug
, X86
, netframework4.8
)的内容。
nop ldarg.0 ldfld /* - */ stloc.0 br.s /* */ ldloc.0 ret
有很多地方可以停止和调试。
在发行版本中,只有最重要的部分保留:
ldarg.0 ldfld /* - */ ret
实例方法的null参数是... this
。 因此,以下是用IL编写的:
1)下载
2)加载字段值
3)带回来
只是吗 Reflection.Emit
有一个特殊的重载,除了操作码之外,还需要一个字段描述符参数。 与我们之前收到的一样,例如spanField
。
var getSpan = type.DefineMethod("GetSpan", MethodAttributes.Public | MethodAttributes.HideBySig, CallingConventions.Standard, typeof(Span<T>), Array.Empty<Type>()); gen = getSpan.GetILGenerator(); gen.Emit(OpCodes.Ldarg_0); gen.Emit(OpCodes.Ldfld, spanField); gen.Emit(OpCodes.Ret);
对于setter来说,它有点复杂,您需要将其加载到堆栈上,加载函数的第一个参数,然后在字段中调用write指令,但不返回任何内容:
ldarg.0 ldarg.1 stfld ret
在Raw
字段中完成此过程之后,声明了必要的委托(或使用标准委托),我们得到了一个动态类型和四个访问器方法,从中生成了正确的泛型委托。
我们编写一个包装器类,该包装器类使用两个泛型参数( TIn
, TOut
)接收引用相应(缓存的)动态类型的Type
实例,此后,它创建每种类型的一个对象并生成四个泛型委托,即
void SetSpan(Span<TIn> span)
以将源范围写入结构Raw GetRaw()
以Raw
结构读取跨度的内容void SetRaw(Raw raw)
将修改后的Raw
结构写入第二个对象Span<TOut> GetSpan()
返回具有正确设置和重新计算的字段的所需类型的跨度。
有趣的是,动态类型实例需要创建一次。 创建委托时,对这些对象的引用将作为@this
参数传递。 这违反了规则。 Activator.CreateInstance
返回object
。 显然,这是由于动态类型本身未 type.IsByRef
类似 ref
的事实( type.IsByRef
Like == false
),但是可以创建 类似 ref
的字段。 显然,这种限制存在于语言中,但CLR
消化。 在非标准使用的情况下,膝盖可能会在这里开枪。 3
因此,我们得到了一个泛型类型的实例,该实例包含四个委托和两个对动态类实例的隐式引用。 连续执行相同的种姓时,可以重用委托和结构。 为了提高性能,我们再次缓存(已经是类型转换器)对(TIn, TOut) -> Generator<TIn, TOut>
。
笔触是最后一个:我们给出类型, Span<TIn> -> Span<TOut>
public Span<TOut> Cast(Span<TIn> span) {
结论
有时出于体育兴趣的考虑,您可以绕过语言的某些限制并实现非标准功能。 当然,后果自负。 值得注意的是,动态方法允许您完全放弃指针和unsafe / fixed
上下文,这可能是一个好处。 明显的缺点是需要反射和类型生成。
对于那些读到最后的人。
天真的基准测试结果这一切有多快?
我在一个愚蠢的场景中比较了种姓的速度,这种场景不能反映出这种种姓和跨度的实际/潜在用途,但至少可以给出速度的概念。
Cast_Explicit
, 2 . ;Cast_IL
3 , Generator<TIn, TOut>
, , ;Cast_IL_Cached
Generator<TIn, TOut>
, - , .. ;Buffer
, , . .
— int[N]
N/2
.
, , . , . , , . , unmanaged
.
BenchmarkDotNet=v0.11.5, OS=Windows 10.0.18362 Intel Core i7-2700K CPU 3.50GHz (Sandy Bridge), 1 CPU, 8 logical and 4 physical cores [Host] : .NET Framework 4.7.2 (CLR 4.0.30319.42000), 32bit LegacyJIT-v4.8.3815.0 Clr : .NET Framework 4.7.2 (CLR 4.0.30319.42000), 32bit LegacyJIT-v4.8.3815.0 Job=Clr Runtime=Clr InvocationCount=1 UnrollFactor=1
聚苯乙烯
3 , ref
, , . ( ) . ref
structs,
static Raw Generated_Int32.GetRaw(Span<int> span) { var inst = new Generated_Int32() { Span = span }; return inst.Raw; }
, Reflection.Emit
. , ILGenerator.DeclareLocal
.
static Span<int> Generated_Int32.GetSpan(Raw raw);
delegate Raw GetRaw<T>(Span<T> span) where T : unmanaged; delegate Span<T> GetSpan<T>(Raw raw) where T : unmanaged;
, , ref
— . 因为 ,
var getter = type.GetMethod(@"GetRaw", BindingFlags.Static | BindingFlags.Public).CreateDelegate(typeof(GetRaw<T>), null) as GetRaw<T>;
—
Raw raw = getter(Span<TIn>.Empty); Raw newRaw = convert(raw); Span<TOut> = setter(newRaw);
UPD01: