一:背景
1. 講故事
這兩天工作上太忙沒有及時持續的文章產出,和大家說聲抱歉,前幾天群裡一個朋友在問什麼時候可以產出 Span 的下一篇,哈哈,這就來啦!讀過上一篇的朋友應該都知道 Span 統一了 .NET 程式 棧 + 託管 + 非託管
實現了三大塊記憶體的統一訪問,??,而且在 .net 底層 Library 中也是一等公民的存在,很多現有的類都提供了對 Span / ReadOnlySpan 的支援。
- String 對 Span / ReadOnlySpan 的支援
public sealed class String
{
[MethodImpl(MethodImplOptions.InternalCall)]
[NullableContext(0)]
public extern String(ReadOnlySpan<char> value);
}
- StringBuilder 對 Span / ReadOnlySpan 的支援
public sealed class StringBuilder : ISerializable
{
public unsafe StringBuilder Append(ReadOnlySpan<char> value)
{
if (value.Length > 0)
{
fixed (char* value2 = &MemoryMarshal.GetReference(value))
{
Append(value2, value.Length);
}
}
return this;
}
}
- Int 對 Span / ReadOnlySpan 的支援
public readonly struct Int32
{
public static int Parse(ReadOnlySpan<char> s, NumberStyles style = NumberStyles.Integer, IFormatProvider? provider = null)
{
NumberFormatInfo.ValidateParseStyleInteger(style);
return Number.ParseInt32(s, style, NumberFormatInfo.GetInstance(provider));
}
}
怎麼樣,這些通用 & 基礎的類都在大力對接 Span / ReadOnlySpan
,更別說複雜型別了,其地位不言自明哈,接下來我們就從 Span 本身的機制聊起。
二: Span 原理探究
1. Span 原始碼分析
靈活運用 Span 解決工作中的實際問題我相信大家應該沒什麼毛病了,有了這個基礎再從 Span 的原始碼 和 使用者態 和大家一起深度剖析,從原始碼開始吧。
public readonly ref struct Span<T>
{
internal readonly ByReference<T> _pointer;
private readonly int _length;
}
上面程式碼的 ref struct
可以看出,這個 Span 是隻可以分配在棧上的值型別,然後就是裡面的 _pointer 和 _length 兩個例項欄位,不知道看完這兩個欄位腦子裡是不是有一幅圖,大概是這樣的。
可以清晰的看出,Span 就是用來對映一段可以連續訪問的記憶體地址,空間大小由 length 控制,開始位置由 _pointer 指定,是不是像極了指標???,是的,語言團隊要保證你的程式高效能,還得照護你的人身安全,出了各種手段,真是煞費苦心! ???
2. Span 使用者態分析
雖然圖已經畫了,但還是有很多朋友希望眼見為實,必須實操演練,嘿嘿,無懼任何挑戰,那我先把上面的圖化成程式碼:
static void Main(string[] args)
{
var nums = new int[] { 1, 2, 3, 4, 5, 6 };
var span = new Span<int>(nums);
Console.ReadLine();
}
接下來我用 windbg 把執行緒棧中的 span 也找出來。
0:000> !clrstack -l
OS Thread Id: 0x181c (0)
Child SP IP Call Site
000000963277E5D0 00007ffc3e601434 ConsoleApp1.Program.Main(System.String[]) [E:\net5\ConsoleApp2\ConsoleApp1\Program.cs @ 13]
LOCALS:
0x000000963277E618 = 0x000001e956b8ab10
0x000000963277E608 = 0x000001e956b8ab20
從最後一行程式碼可以看出:span 的棧地址是 0x000000963277E608,棧內容是:0x000001e956b8ab20,按照圖的理論: 0x000001e956b8ab20 應該是 nums 陣列元素 1 的記憶體地址,可以用 dp 驗證一下。
0:000> dp 0x000001e956b8ab20
000001e9`56b8ab20 00000002`00000001 00000004`00000003
000001e9`56b8ab30 00000006`00000005 00000000`00000000
000001e9`56b8ab40 00007ffc`3e6c4388 00000000`00000000
從上面三行記憶體地址來看,陣列的:1,2,3,4,5,6
依次排列,有些朋友可能有點小疑問,為啥 nums 的記憶體地址不是指向陣列元素 1 的呢? 那我來普及一下吧,先用 dp 喚出陣列的記憶體地址。
0:000> dp 0x000001e956b8ab10
000001e9`56b8ab10 00007ffc`3e69f090 00000000`00000006
000001e9`56b8ab20 00000002`00000001 00000004`00000003
000001e9`56b8ab30 00000006`00000005 00000000`00000000
可以看出,第一排為: 00007ffc3e69f090 0000000000000006
, 前面的 8 byte 表示 陣列 的 方法表地址,後面的 8byte 表示 6 ,也就是說陣列有 6個元素,不信的話我截一張圖:
span 是由 _pointer + length 組成的,剛才的 _pointer 也給大家演示了,那 length 的值在哪裡呢? 因為 span 是 struct,所以需要用 dp 把剛才的執行緒棧最小的棧地址打出來就可以了。
到這裡,我覺得我講的已經夠清楚了,如果還有點懵的話可以仔細想一想哈。
三:Span 在 String 和 List 的實踐
Span的應用場景真的是太多了,不可能在這篇一一列舉,這裡我就舉兩個例子吧,讓大家能夠感受到 Span 的強大即可。
1. 在 String 上的應用
案例:如何高效的計算出使用者輸入的值 10+20
?
1) 傳統 Substring 做法
傳統的做法很簡單,擷取唄,程式碼如下:
static void Main(string[] args)
{
var word = "10+20";
var splitIndex = word.IndexOf("+");
var num1 = int.Parse(word.Substring(0, splitIndex));
var num2 = int.Parse(word.Substring(splitIndex + 1));
var sum = num1 + num2;
Console.WriteLine($"{num1}+{num2}={sum}");
Console.ReadLine();
}
結果是很輕鬆的算出來了,但你仔細想想這裡是不是有點什麼問題,比如說為了從 word 中扣出 num,我用了兩次 SubString,就意味著會在 託管堆 上生成兩個 string,如果說我執行 1w 次話,那託管堆上會不會有 2w 個 string 呢? 修改程式碼如下:
for (int i = 0; i < 10000; i++)
{
var num1 = int.Parse(word.Substring(0, splitIndex));
var num2 = int.Parse(word.Substring(splitIndex + 1));
var sum = num1 + num2;
}
然後看一下 託管堆 上 String 的個數
0:000> !dumpheap -type String -stat
Statistics:
MT Count TotalSize Class Name
00007ffc53a81e18 20167 556538 System.String
託管堆上有 20167 個,挺恐怖的,真的是給 GC 添麻煩哈,這裡還有 167 個是系統自帶的,接下來的問題是有沒有辦法替換 SubString 從而不生成臨時string呢?
2) 新式 Span 做法
如果看懂了 Span 結構圖,你就應該會使用 _pointer + length 將 string 進行切片處理,對不對,程式碼如下:
for (int i = 0; i < 10000; i++)
{
var num1 = int.Parse(word.AsSpan(0, splitIndex));
var num2 = int.Parse(word.AsSpan(splitIndex));
var sum = num1 + num2;
}
然後在 託管堆 驗證一下,是不是沒有 臨時 string 了?
0:000> !dumpheap -type String -stat
Statistics:
MT Count TotalSize Class Name
00007ffc53a51e18 167 36538 System.String
可以看到就只有 167 個系統字串,效能也得到了不小的提升,???。
2. 在 List 上的應用
平時用 Span 的時候,更多的會應用到 Array 上面,畢竟 Array 在託管堆上是連續記憶體,方便 Span 在上面畫一個可視視窗,其實不僅僅是 Array,從 .NET5 開始在 List 上畫一個檢視也是可以的,截圖如下:
因為 List 的 CURD 會導致底層的 Array 忽長忽短或重新分配,也就無法實現物理上的連續記憶體,所以 Span 應用到 List 之後,希望List是不可變的,這也是官方的建議。
四:總結
總的來說,Span 在 .NET 底層框架中的地位是越來越顯著了,相信 netCore 追求更高更快的效能上 Span 一定大有可為,大家趕緊學起來,???
更多高質量乾貨:參見我的 GitHub: dotnetfly