做爲.net程序員,使用過指針,寫過不安全代碼嗎?html
爲何要使用指針,何時須要使用它,以及如何安全、高效地使用它?git
若是能很好地回答這幾個問題,那麼就能很好地理解今天了主題了。C#構建了一個託管世界,在這個世界裏,只要不寫不安全代碼,不操做指針,那麼就能得到.Net相當重要的安全保障,即什麼都不用擔憂;那若是咱們須要操做的數據不在託管內存中,而是來自於非託管內存,好比位於本機內存或者堆棧上,該如何編寫代碼支持來自任意區域的內存呢?這個時候就須要寫不安全代碼,使用指針了;而如何安全、高效地操做任何類型的內存,一直都是C#的痛點,今天咱們就來談談這個話題,講清楚 What、How 和 Why ,讓你知其然,更知其因此然,之後有人問你這個問題,就讓他看這篇文章吧,呵呵。程序員
回答這個問題前,先總結一下如何用C#操做任何類型的內存:github
託管內存(managed memory )算法
var mangedMemory = new Student();
很熟悉吧,只需使用new
操做符就分配了一塊託管堆內存,並且還不用手工釋放它,由於它是由垃圾收集器(GC)管理的,GC會智能地決定什麼時候釋放它,這就是所謂的託管內存。默認狀況下,GC經過複製內存的方式分代管理小對象(size < 85000 bytes),而專門爲大對象(size >= 85000 bytes)開闢大對象堆(LOH),管理大對象時,並不會複製它,而是將其放入一個列表,提供較慢的分配和釋放,並且很容易產生內存碎片。編程
棧內存(stack memory )c#
unsafe{ var stackMemory = stackalloc byte[100]; }
很簡單,使用stackalloc
關鍵字很是快速地就分配好了一塊棧內存,也不用手工釋放,它會隨着當前做用域而釋放,好比方法執行結束時,就自動釋放了。棧內存的容量很是小( ARM、x86 和 x64 計算機,默認堆棧大小爲 1 MB),當你使用棧內存的容量大於1M時,就會報StackOverflowException
異常 ,這一般是致命的,不能被處理,並且會當即幹掉整個應用程序,因此棧內存通常用於須要小內存,可是又不得不快速執行的大量短操做,好比微軟使用棧內存來快速地記錄ETW事件日誌。api
本機內存(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誕生了,它就是這個痛點的解決方案。
先來看看,如何使用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
如今重構上面的兩個設計,以下:
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); }
上面的方法根本不關心它操做的是哪一種類型的內存,咱們能夠自由地從託管內存切換到本機代碼,再切換到堆棧上,真正的享受玩轉內存的樂趣。
淺析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<T>
是一種ref-like type
相似引用的結構體;從應用的場景上看,它是高性能的sliceable type
可切片類型;綜上所訴,Span是一種相似於數組的結構體,但具備建立數組一部分視圖,而無需在堆上分配新對象或複製數據的超能力。
看完本篇博客,若是理解了Span的What、Why、How,那麼做者佈道的目的就達到了,不懂的同窗建議多讀幾遍,下一篇,我將會進一步暢談Span的脾氣秉性,讓你們可以安全高效地使用好它。
從評論區交流發現,有的同窗誤解了span,表面上認爲只是對指針的封裝,從而繞過unsafe帶來的限制,避免開發人員直接面對指針而已,其實不是,下面咱們來看一個示例:
var nativeMemory = Marshal.AllocHGlobal(100); Span<byte> nativeSpan; unsafe { nativeSpan = new Span<byte>(nativeMemory.ToPointer(), 100); } SafeSum(nativeSpan); Marshal.FreeHGlobal(nativeMemory); // 這裏不關心操做的內存類型,即不用爲一種類型寫一個重載方法,就比如上面的設計同樣。 static ulong SafeSum(Span<byte> bytes) { ulong sum = 0; for(int i=0; i < bytes.Length; i++) { sum += bytes[i]; } return sum; }
看到了嗎,並無繞過unsafe,之前該如何用,如今仍是同樣的,span解決的是下面幾點:
它的目標是將來將成爲.Net下編寫高性能應用程序的重要積木。
若是有什麼疑問和看法,歡迎評論區交流。
若是你以爲本篇文章對您有幫助的話,感謝您的【推薦】。
若是你對.NET高性能編程感興趣的話能夠【關注我】,我會按期的在博客分享個人學習心得。
歡迎轉載,請在明顯位置給出出處及連接。
https://www.codemag.com/Article/1807051/Introducing-.NET-Core-2.1-Flagship-Types-Span-T-and-Memory-T
https://github.com/dotnet/coreclr/blob/master/src/System.Private.CoreLib/shared/System/Span.cs
https://blogs.msdn.microsoft.com/dotnet/2017/11/15/welcome-to-c-7-2-and-span
https://docs.microsoft.com/zh-cn/dotnet/api/system.span-1?view=netcore-2.2
https://blog.marcgravell.com/2017/04/spans-and-ref-part-2-spans.html
https://github.com/dotnet/corefxlab/blob/master/docs/specs/span.md
https://blog.marcgravell.com/2017/04/spans-and-ref-part-1-ref.html
https://channel9.msdn.com/Events/Connect/2017/T125
https://msdn.microsoft.com/en-us/magazine/mt814808
https://github.com/dotnet/BenchmarkDotNet/pull/492
https://github.com/dotnet/coreclr/issues/5851