Span 与 Memory连续内存视图、生命周期与所有权系列C# 与常用数据结构源码剖析 · 线性结构篇版本边界C# 12、.NET 8CoreCLR 私有布局和 JIT 优化以具体 runtime build 为准核心纠偏切片通常不复制不代表整个处理流程必然零分配一、Span 解决的是“看一段内存”不是“拥有一段内存”数组切片若复制会分配新数组并移动元素string.Substring通常创建新 string。Span/ReadOnlySpan 用起点和长度描述一段连续区域Slice只形成子视图int[] values new int[1024];Spanint window values.AsSpan(100, 50);ReadOnlySpanchar host https://example.test.AsSpan(8, 12);这两次视图创建本身通常不分配后备存储也不复制元素。但源数组/string已存在后续ToArray/ToString会分配委托/async/接口适配也可能分配底层若来自池或native owner还要支付租借和释放。准确说法是“view creation/certain slicing avoids allocation”。Span不拥有内存不能延长栈/原生缓冲寿命Memory也通常不拥有只是可存堆的描述符。每个 API 都要回答谁拥有底层存储view可用多久何时可写什么时候归还/释放。二、Span 的概念模型与 ref struct 限制Span的语义可理解为两个量指向第一个T的 byref 元素长度。// 概念模型不是 .NET 8逐字源码或稳定布局。public readonly ref struct ConceptualSpanT{private readonly ref T _reference;private readonly int _length;}实际字段、padding和sizeof不是公共ABI不能写死“64位一定16字节”。byref可以指向托管对象内部、栈或native地址JIT/GC按托管引用安全规则处理。Span被声明为 ref struct使编译器阻止它逃入普通托管堆对象或跨越无法保证寿命的挂起点。C# 12常见限制不能装箱为object/接口不能作为普通class字段不能放进数组或一般ListSpanT不能跨 await/yield活跃也不能被lambda捕获。它可以作为局部、参数、返回值和另一个 ref struct字段但 ref safety会检查返回/逃逸范围。static Spanint Invalid(){Spanint local stackalloc int[4];return local; // 编译错误返回会超过栈存储寿命}static Spanint Valid(int[] array) array.AsSpan();新语言版本可能放宽 ref struct泛型/interface的部分使用但本文固定C#12不倒推后续规则。安全原则始终是byref不能活得比referent久。2.1 没有数组协变C#数组历史上允许引用类型协变string[]可视为object[]写入错误类型时运行时抛异常。Span是可写view不允许这种转换否则可通过 Span把object写进string[]。ReadOnlySpan在某些引用类型转换上存在安全协变支持与版本规则但不要凭数组经验推断以C#12/.NET8编译结果和API签名核验。泛型方法通常要求精确TSpanstring不能随意当Spanobject。这避免每次写入数组类型检查也保持类型安全。三、来源决定生命周期3.1 托管数组byte[] buffer new byte[4096];Spanbyte body buffer.AsSpan(128, 512);GC保证数组在Span有效期内可达并在移动时正确处理byref。Span不会阻止其他别名修改数组。返回数组到池/交给并发写者会破坏逻辑所有权即使引用安全仍不悬空。3.2 stackallocSpanbyte scratch stackalloc byte[256];scratch.Clear();stackalloc在当前栈帧分配离开作用域/方法后失效不由GC回收。大小来自输入时可能导致栈溢出应设置小而明确的上限大缓冲回退池/堆。内容是否初始化、SkipLocalsInit影响等按语言/runtime验证敏感数据应显式清理。Spanbyte scratch length 256? stackalloc byte[length]: new byte[length]; // 大路径会分配托管数组这种条件表达式说明“使用Span”不代表不分配。3.3 stringstring只公开ReadOnlySpancharReadOnlySpanchar input id42;activetrue;int separator input.IndexOf(;);ReadOnlySpanchar first input[..separator];view让原string保持可达对一个巨大string保留很小的Memory/间接owner可能延长整个源寿命。ReadOnly只禁止通过该view写string本身有不可变契约对于数组来源则不代表底层不可变。3.4 非托管内存unsafe static void UseNative(int length){void* pointer NativeMemory.Alloc((nuint)length);try{Spanbyte bytes new(pointer, length);bytes.Clear();}finally{NativeMemory.Free(pointer);}}Span不知道pointer容量、是否已释放或是否并发修改。unsafe调用者负责长度乘法溢出、对齐、释放一次、异常安全和view不逃逸。GC不会扫描native内存中的托管引用不要用 Span随意包装未受runtime管理的原生槽位。四、切片、索引与边界检查span[start..end]/Slice创建新描述符起点偏移、长度缩短底层共享。范围非法抛相应越界异常长度按T元素计不是字节。空Span也可合法指向末端概念位置但不能索引。static int Sum(ReadOnlySpanint values){int sum 0;for (int i 0; i values.Length; i)sum checked(sum values[i]);return sum;}JIT可能内联索引器并消除可证明冗余的边界检查但不是所有循环都保证。别名、复杂控制流、切片方式和runtime版本会影响优化。通过 BenchmarkDotNet DisassemblyDiagnoser/SharpLab核验不能把概念伪汇编当.NET8固定输出。对ref T索引返回可原位修改foreach (ref int value in values.AsSpan())value * 2;ReadOnlySpan索引返回只读引用/值语义阻止通过该路径写底层数组的其他引用仍可写所以它不是线程安全快照。五、Memory可以存进状态机但仍要保证 owner存活Memory是普通readonly struct概念上保存可被GC追踪的owner/array、起点和长度可作类字段、泛型参数并跨await。.Span在同步片段中取得当前viewstatic async Taskint ReadAndParseAsync(Stream stream,Memorybyte buffer,CancellationToken token){int count await stream.ReadAsync(buffer, token);return Parse(buffer.Span[..count]); // Span只在await之后的同步区间使用}“Memory可跨await”不保证底层owner永远有效。若Memory来自ArrayPool调用方不能在I/O完成前Return来自MemoryManager/native mmap时不能提前Dispose并发写者也可能改变内容。ReadOnlyMemory与ReadOnlySpan只限制通过该view修改。若它包装byte[]持有数组别名的人仍可写。需要不可变消息必须复制/转移独占所有权或使用真正不可变存储。Memory没有直接按元素索引的主要API路径通常短暂取Span。不要在await前取得Span并在await后使用C#12编译器会拒绝活跃ref-like值跨挂起。六、MemoryManager 与 IMemoryOwner把生命周期写进APIMemoryManagerT用于将自定义存储native、mmap、特殊pool暴露为Memory实现需要提供GetSpan、Pin/Unpin、Dispose等。IMemoryOwnerT表达“持有一段Memory并负责Dispose”的租约下图把“存储”、“所有者”和“视图”分开。MemoryT和SpanT都可以继续切片但它们不因此获得独立生命周期当 owner 释放、归还或重用底层存储后先前派生的所有视图都不应再使用。flowchart LROWNER[Owner or lease]STORAGE[Array, pooled buffer, or native storage]MEMORY[Memory view: storable and async-safe as a value]SPAN[Span view: synchronous ref-like access]SLICE[Derived slices]ASYNC[In-flight async operation]END[Dispose, Return, or storage reuse]OWNER --|controls lifetime| STORAGESTORAGE --|exposes| MEMORYMEMORY --|temporary Span property| SPANMEMORY --|slice| SLICESPAN --|slice| SLICEMEMORY --|must remain valid until completion| ASYNCASYNC --|completion permits release| ENDOWNER --|releases storage| ENDEND -. invalidates further logical use .- MEMORYEND -. invalidates further logical use .- SPANEND -. invalidates further logical use .- SLICE图中的“invalidates”是所有权契约不是说每种误用都会立即抛异常。数组池归还后可能很快被另一个租用者改写native owner 释放后则可能直接成为悬空地址。static async Task ConsumeAsync(IMemoryOwnerbyte owner,CancellationToken token){using (owner){Memorybyte memory owner.Memory;await FillAsync(memory, token);Parse(memory.Span);}}谁创建谁Dispose并非总规则API文档必须写“所有权是否转移”。方法接收IMemoryOwner可能只借用也可能接管签名本身不能完整表达。异步并发时最后一个使用者完成后才能Dispose。自定义MemoryManager要维护Dispose后GetSpan/Pin行为、并发Pin计数、异常回滚、slice边界、T对GC引用限制、native释放和finalizer/SafeHandle。它是基础设施不是普通切片所需。Memory可由manager构造且内部编码是runtime私有实现不应反射_object/_index/_length或写死三字段布局。七、Pin给native稳定地址但要缩短时间memory.Pin()返回MemoryHandle可能固定托管数组或调用MemoryManager.Pin获得native地址unsafe static void Send(Memorybyte memory){using MemoryHandle handle memory.Pin();NativeSend(handle.Pointer, memory.Length);}using结束前native调用可使用pointer若native异步保存地址handle必须活到回调结束并处理取消/错误。过早Dispose产生use-after-unpin/free。长期pin会限制CoreCLR压缩并可能加重碎片POH/专用native缓冲可用于已证明的长期场景。Pin不等于锁其他线程仍可能改数据。Pin也不让owner免于Dispose。空Memory的Pointer行为按API契约处理不盲目解引用。八、ArrayPool租借的是可变数组不是新数组byte[] rented ArrayPoolbyte.Shared.Rent(minimumLength);try{Spanbyte active rented.AsSpan(0, requiredLength);Fill(active);Consume(active);}finally{ArrayPoolbyte.Shared.Return(rented, clearArray: true);}Rent返回数组长度可能大于请求只能把逻辑有效长度传给下游。数组内容未承诺清零敏感数据要清理。Return后任何Span/Memory、native pointer或async操作都不得再使用下一租用者可能同时覆写。clearArray:true有成本且只按pool/API语义清理引用类型通常需要避免陈旧引用敏感byte数据是否必须清理应由安全协议决定。异常路径finally归还但若所有权已转给异步操作就不能当前作用域归还。池化减少部分分配却可能保留峰值数组、增加所有权bug和并发争用。用分配率、GC暂停与retained memory共同验证不把ArrayPool作为默认答案。九、迭代、接口与泛型限制Span提供pattern-based foreach不实现传统IEnumerableT因为接口转换会要求装箱/堆存储ref struct状态static int CountPositive(ReadOnlySpanint values){int count 0;foreach (int value in values)if (value 0) count;return count;}因此不能直接调用面向 IEnumerable的传统LINQ。.ToArray().Where(...)能工作但产生复制应使用Span自身搜索/比较API或显式循环。第三方“Span LINQ”要检查ref struct泛型设计和代码生成。C#12不能把Span作为任意泛型类型实参泛型约束也没有后续版本的allows ref struct机制。可以定义参数为Span的普通泛型方法static void ClearAllT(SpanT values) values.Clear();这里T是元素类型Span本身不是传给另一个普通泛型容器的实参。十、GC、managed references与native边界Span描述符通常是栈上/寄存器中的ref-like值不是独立堆对象若指向托管数组/stringJIT的GC info保证源在使用期间活着并更新内部byref。它不会让数据“离开GC”。Memory持有托管owner引用会延长整个owner寿命即使view很小。Span中T可包含托管引用清理/复制需写屏障。stackalloc要求元素类型满足相应安全限制不能stackalloc包含managed reference的任意T。MemoryMarshal.Cast只适用于满足API约束的无引用布局字节重解释还涉及大小整除、对齐、endianness不能绕过对象引用安全。native MemoryManager的Memory若被错误保存到owner Dispose之后普通Memory struct无法救场。SafeHandle/owner租约和测试负责寿命。十一、Sequence多段数据不能假装成一个Span网络管线常由多个segment组成。ReadOnlySequenceT表示零个、一个或多个连续段单段时可使用FirstSpan跨段时必须迭代/SequenceReader或复制。static long Sum(ReadOnlySequencebyte sequence){long sum 0;foreach (ReadOnlyMemorybyte segment in sequence)foreach (byte value in segment.Span)sum value;return sum;}ReadOnlySequence不是一个“大Span”索引/Position不是普通整数偏移切片必须使用其API。segments的owner在消费完成前必须存活Pipelines中调用AdvanceTo后之前buffer通常不应继续使用。需要连续native输入时才复制到单段并明确成本。Sequence与Memory解决不同层级Memory是单段可存堆viewSequence组合多段。不要为使用Span强行把分段I/OToArray。十二、字符串解析view减少子串不保证整体无分配static bool TryReadPair(ReadOnlySpanchar text,out int left, out int right){int comma text.IndexOf(,);if (comma 0){left right default;return false;}return int.TryParse(text[..comma], out left) int.TryParse(text[(comma 1)..], out right);}view和span parse避免创建两段substring。输入string仍已分配错误日志插值可能分配最终需要string键时仍要创建string。基准应比较同语义、消费结果并记录分配不把解析前I/O隐藏在一端。Span不能作为class字段保存token解析器可保存Range/offset或ReadOnlyMemory使用时取Span。保存Memory会保留整个原buffer长期AST只需小token时复制小string可能反而降低retained memory。十三、Unity、Mono、IL2CPP与Burst边界Unity能否使用具体Span/Memory API取决于Editor版本、API Compatibility Level、BCL和包不能用“2021.2完整支持”一行覆盖所有方法。目标Mono与IL2CPP分别做Player编译/测试Editor通过不代表AOT、裁剪和平台native互操作通过。IL2CPP会AOT生成C但不能把Span稳定描述为某个两字段C模板也不能保证边界检查全部优化。查看目标平台生成/反汇编和Profiler。C# ref safety仍在编译期适用unsafe生命周期错误在IL2CPP同样危险。Burst/Jobs使用NativeArray、NativeSlice等Unity.Collections容器及AtomicSafetyHandle/Allocator生命周期它们不是Span/Memory同义词。Burst支持的managed API和Span子集随版本变化Job字段不能随意放managed Memory。主线程可在受控同步区把NativeArray转为Span若API支持但不能让view跨Job调度/Dispose。UnityEngine.Object只可按引擎线程规则访问Span线程安全只取决于底层数据和别名。ArrayPool在Unity BCL中的行为/可用性也需核验游戏池应有容量和场景生命周期策略。十四、失败反例反例一返回stackalloc Spanref safety编译失败用调用者提供的Span或返回拥有存储的数组/owner。反例二ReadOnlyMemory当不可变消息生产者仍持有数组并修改。复制、转移独占owner或同步版本。反例三异步I/O开始后立即Return池数组I/O与下一租用者并发写。await完成后再Return或把owner转给异步操作。反例四长期保存小Memory切片它保留巨大源数组。复制小结果或调整分块owner。反例五无界stackalloc用户长度栈溢出不会被GC缓解。设置上限并回退池。反例六Pin后把pointer交给长期native回调handle已Dispose。让MemoryHandle寿命覆盖native使用并有取消/释放协议。反例七Sequence.FirstSpan当完整消息跨segment截断。按SequencePosition解析或显式合并。反例八为了LINQ先ToArray复制抵消view价值。直接循环或接受明确物化边界。十五、可复现实验与测试BenchmarkDotNet将以下场景分开SubstringParse、AsSpanParse、数组复制切片、Span Slice、ArrayPool、stackalloc小缓冲、Memory async同步/异步完成。所有用例输入相同并返回checksum记录.NET 8 runtime/SDK、C#12、Release、CPU、GC和完整分配。[MemoryDiagnoser]public class SliceBenchmarks{private readonly string _text 12345,67890;[Benchmark]public int Substrings(){int comma _text.IndexOf(,);return int.Parse(_text.Substring(0, comma)) int.Parse(_text.Substring(comma 1));}[Benchmark]public int Spans(){ReadOnlySpanchar text _text.AsSpan();int comma text.IndexOf(,);return int.Parse(text[..comma]) int.Parse(text[(comma 1)..]);}}用DisassemblyDiagnoser检查边界消除/内联不写固定机器码。用GC allocation事件确认view路径用heap snapshot验证小Memory保留大owner。池化压力测试覆盖异常、取消、double Return/use-after-return和敏感数据清理。编译负测试固定LangVersion 12验证Span字段/装箱/泛型容器/跨await/返回stackalloc被拒绝。Unity在Mono/IL2CPP Player测分配、帧时间和正确性Burst单独建立支持矩阵。十六、选型清单与结论数据是单段连续还是需要ReadOnlySequenceSpan/Memory只是借用owner和有效期已写入APIReadOnly只限制view底层别名是否可写已审查跨await保存MemorySpan只存在于同步区间stackalloc有小型上限native缓冲有finally/ownerArrayPool在最后使用者完成后归还逻辑长度不等于数组LengthPin作用域覆盖native使用且尽可能短小view不会意外保留巨大ownerref safety/C#12泛型限制未被unsafe绕过Unity Mono/IL2CPP/Burst分别验证“零分配”只针对经Profiler证明的具体路径。Span是受ref safety保护的连续内存借用切片常只调整起点和长度。Memory把描述符变成可存堆、可跨async的值却没有自动取得所有权MemoryManager/IMemoryOwner、Pin和ArrayPool把生命周期责任显式带回设计。ReadOnlySpan不等于底层不可变Memory可跨await不等于owner可提前释放Sequence也不等于单段Span。掌握这些边界后view可以减少复制和临时对象忽略所有权时它只会把显眼的分配问题换成更危险的竞态与悬空生命周期。下一篇Array vs List vs LinkedList vs Span 选型指南