通俗易懂,C#如何安全、高效地玩轉任何種類的內存之Span。

  作爲.net程序員,使用過指針,寫過不安全代碼嗎?

  爲什麼要使用指針,什麼時候需要使用它?

  如果能很好地回答這兩個問題,那麼就能很好地理解今天了主題了。C#構建了一個託管世界,在這個世界裏,只要不寫不安全代碼,不操作指針,那麼就能獲得.Net至關重要的安全保障,即什麼都不用擔心;那如果我們需要操作的數據不在託管內存中,而是來自於非託管內存,比如位於本機內存或者堆棧上,該如何編寫代碼支持來自任意區域的內存呢?這個時候就需要寫不安全代碼,使用指針了;而如何安全、高效地操作任何類型的內存,一直都是C#的痛點,今天我們就來談談這個話題,講清楚What、How和Why,讓你知其然,更知其所以然,以後有人問你這個問題,就讓他看這篇文章吧,呵呵。

  what-痛點是什麼?

  回答這個問題前,先總結一下如何用C#操作任何類型的內存:

  託管內存(managed memory)

  var mangedMemory=new Student();

  很熟悉吧,只需使用new操作符就分配了一塊託管堆內存,而且還不用手工釋放它,因爲它是由垃圾收集器(GC)管理的,GC會智能地決定何時釋放它,這就是所謂的託管內存。默認情況下,GC通過複製內存的方式分代管理小對象(size<85000 bytes),而專門爲大對象(size>=85000 bytes)開闢大對象堆(LOH),管理大對象時,並不會複製它,而是將其放入一個列表,提供較慢的分配和釋放,而且很容易產生內存碎片。

  棧內存(stack memory)

  unsafe{

  var stackMemory=stackalloc byte[100];

  }

  很簡單,使用stackalloc關鍵字非常快速地就分配好了一塊棧內存,也不用手工釋放,它會隨着當前作用域而釋放,比如方法執行結束時,就自動釋放了。棧內存的容量非常小(ARM、x86和x64計算機,默認堆棧大小爲1 MB),當你使用棧內存的容量大於1M時,就會報StackOverflowException異常,這通常是致命的,不能被處理,而且會立即幹掉整個應用程序,所以棧內存一般用於需要小內存,但是又不得不快速執行的大量短操作,比如微軟使用棧內存來快速地記錄ETW事件日誌。

  本機內存(native memory)

  IntPtr nativeMemory0=default(IntPtr),nativeMemory1=default(IntPtr);

  try

  {

  unsafe

  {

  nativeMemory0=Marshal.AllocHGlobal(256);

  nativeMemory1=Marshal.AllocCoTaskMem(256);

  }

  }

  finally

  {

  Marshal.FreeHGlobal(nativeMemory0);

  Marshal.FreeCoTaskMem(nativeMemory1);

  }

  通過調用方法Marshal.AllocHGlobal或Marshal.AllocCoTaskMem來分配非託管堆內存,非託管就是垃圾回收器(GC)不可見的意思,並且還需要手工調用方法Marshal.FreeHGlobal or Marshal.FreeCoTaskMem釋放它,千萬不能忘記,不然就產生內存碎片了。

  拋磚引玉-痛點

  首先我們設計一個解析完整或部分字符串爲整數的API,如下:

  public interface IntParser

  {

  //allows us to parse the whole string.

  int Parse(string managedMemory);

  //allows us to parse part of the string.

  int Parse(string managedMemory,int startIndex,int length);

  //allows us to parse characters stored on the unmanaged heap/stack.

  unsafe int Parse(char*pointerToUnmanagedMemory,int length);

  //allows us to parse part of the characters stored on the unmanaged heap/stack.

  unsafe int Parse(char*pointerToUnmanagedMemory,int startIndex,int length);

  }

  從上面可以看到,爲了支持解析來自任何內存區域的字符串,一共寫了4個重載方法。

  接下來在來設計一個支持複製任何內存塊的API,如下:

  public interface MemoryblockCopier

  {

  void Copy<T>(T[]source,T[]destination);

  void Copy<T>(T[]source,int sourceStartIndex,T[]destination,int destinationStartIndex,int elementsCount);

  unsafe void Copy<T>(void*source,void*destination,int elementsCount);

  unsafe void Copy<T>(void*source,int sourceStartIndex,void*destination,int destinationStartIndex,int elementsCount);

  unsafe void Copy<T>(void*source,int sourceLength,T[]destination);

  unsafe void Copy<T>(void*source,int sourceStartIndex,T[]destination,int destinationStartIndex,int elementsCount);

  }

  腦袋蒙圈沒,以前C#操縱各種內存就是這麼複雜、麻煩。通過上面的總結如何用C#操作任何類型的內存,相信大多數同學都能夠很好地理解這兩個類的設計,但我心裏是沒底的,因爲使用了不安全代碼和指針,這些操作是危險的、不可控的,根本無法獲得.net至關重要的安全保障,並且可能還會有難以預估的問題,比如堆棧溢出、內存碎片、棧撕裂等等,微軟的工程師們早就意識到了這個痛點,所以span誕生了,它就是這個痛點的解決方案。

  how-span如何解決這個痛點?

  先來看看,如何使用span操作各種類型的內存(僞代碼):

  託管內存(managed memory)

  var managedMemory=new byte[100];

  Span<byte>span=managedMemory;

  棧內存(stack memory)

  var stackedMemory=stackalloc byte[100];

  var span=new Span<byte>(stackedMemory,100);

  本機內存(native memory)

  var nativeMemory=Marshal.AllocHGlobal(100);

  var nativeSpan=new Span<byte>(nativeMemory.ToPointer(),100);

  span就像黑洞一樣,能夠吸收來自於內存任意區域的數據,實際上,現在,在.Net的世界裏,Span就是所有類型內存的抽象化身,表示一段連續的內存,它的API設計和性能就像數組一樣,所以我們完全可以像使用數組一樣地操作各種內存,真的是太方便了。

  現在重構上面的兩個設計,如下:

  public interface IntParser

  {

  int Parse(Span<char>managedMemory);

  int Parse(Span<char>,int startIndex,int length);

  }

  public interface MemoryblockCopier

  {

  void Copy<T>(Span<T>source,Span<T>destination);

  void Copy<T>(Span<T>source,int sourceStartIndex,Span<T>destination,int destinationStartIndex,int elementsCount);

  }

  上面的方法根本不關心它操作的是哪種類型的內存,我們可以自由地從託管內存切換到本機代碼,再切換到堆棧上,真正的享受玩轉內存的樂趣。

  why-爲什麼span能解決這個痛點?

  淺析span的工作機制

  先來窺視一下源碼:

  我已經圈出的三個字段:偏移量、索引、長度(使用過ArraySegment<byte>的同學可能已經大致理解到設計的精髓了),這就是它的主要設計,當我們訪問span表示的整體或部分內存時,內部的索引器會按照下面的算法運算指針(僞代碼):

  ref T this[int index]

  {

  get=>ref((ref reference+byteOffset)+index*sizeOf(T));

  }

  整個變化的過程,如圖所示:

  上面的動畫非常清楚了吧,舊span整合它的引用和偏移成新的span的引用,整個過程並沒有複製內存,而是直接返回引用,因此性能非常高,因爲新span獲得並更新了引用,所以垃圾回收器(GC)知道如何處理新的span,從而獲得了.Net至關重要的安全保障,而這些都是span內部默默完成的,開發人員根本不用擔心,非託管世界依然美好。

  正是由於span的高性能,目前很多基礎設施都開始支持span,甚至使用span進行重構,比如:System.String.Substring方法,我們都知道此方法是非常消耗性能的,首先會創建一個新的字符串,然後在複製原始字符串的字符集給它,而使用span可以實現Non-Allocating、Zero-coping,下面是我做的一個基準測試:

  使用String.SubString和Span.Slice分別截取長度爲10和1000的字符串前一半,從中指標Mean可以看出方法SubString的耗時隨着字符串長度呈線性增長,而Slice幾乎保持不變;從指標Allocated Memory/Op可以看出,方法Slice並沒有被分配新的內存,實踐出真知,可以預見Span未來將會成爲.Net下編寫高性能應用程序的重要積木,應用前景也會非常地廣,微服務、物聯網都是它發光發熱的好地方。

  基準測試示例

  總結

  看完本篇博客,應該對Span的What、Why、How瞭如指掌了,那麼我的目的就達到了,不懂的同學可以多讀幾遍,下一篇,我將會暢談Span的應用場景、優缺點,讓大家能夠安全高效地使用好它,大家也可以在評論留言自己的應用場景,我會在寫下一篇博客時多多參考。

  最後

  如果有什麼疑問和見解,歡迎評論區交流。

  如果你覺得本篇文章對您有幫助的話,感謝您的【推薦】。

  如果你對高性能編程感興趣的話可以關注我,我會定期的在博客分享我的學習心得。

  歡迎轉載,請在明顯位置給出出處及鏈接。

  延伸閱讀

  https://www.michenggw.com /dotnet/corefxlab/blob/master/docs/specs/span.md

  https://msdn.microsoft.com/en-us/magazine/mt814808

  https://www.gcyL157.com .com/dotnet/BenchmarkDotNet/pull/492

  https://www.dfgjpt.com/  .com/dotnet/coreclr/issues/5851

  https://www.tianjiuyule178.com adamsitnik.com/Span

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章