[翻譯]Go與C#對比 第三篇:編譯、運行時、類型系統、模塊和其它的一切


Go vs C#, Part 3: Compiler, Runtime, Type System, Modules, and Everything Else | by Alex Yakunin | ServiceTitan — Titan Tech | Medium

譯者注

本文90%通過機器翻譯,另外10%譯者按照自己的理解進行翻譯,和原文相比有所刪減,可能與原文並不是一一對應,但是意思基本一致。

譯者水平有限,如果錯漏歡迎批評指正

本文發表於2020年1月,當時使用的.NET Core版本應該是3.1,Go版本應該是1.13版本。而現在.NET版本已經到6 Pre5,Go也到了1.16,經過這么多版本的迭代,Go和.NET的性能都有很大提高,所以數據僅供參考,當然也歡迎大家能在新的版本上跑一下最新的結果發一篇帖子出來。

譯者@Bing Translator@InCerry,另外感謝@曉青@賈佬@曉晨@黑洞@maaserwen@帥張@3wlinecode@huchenhao百忙之中抽出時間幫忙review和檢查錯誤。

原文鏈接:https://medium.com/servicetitan-engineering/go-vs-c-part-3-compiler-runtime-type-system-modules-and-everything-else-faa423dddb34


這一個系列中還有其他兩篇文章:

想知道誰在這里嗎?請一直讀到最后。


這是本系列中最后一篇,希望是最有趣的一篇。第一篇和第二篇主要研究了Golang的協程和幾乎無暫停的GC,這篇文章補充了所有缺失的部分。

相似性

兩種語言的相似性:

  • 可以編譯成本機代碼
  • 可以在多個平台上運行
  • 依賴於垃圾收集
  • 支持模塊【.NET中是程序集(assemblies)】
  • 支持類【在Go中叫結構(structs)】,接口【interfaces】和函數指針【function pointers .NET中叫委托(delegates)】
  • 提供一套錯誤處理的選項
  • 支持異步執行
  • 擁有豐富的基礎類庫
  • 具有類似的運行時性能

但是在這些功能的實現上,差異多於相同之處。讓我們跳到這一部分 😃

編譯

Go編譯成本機二進制文件,也就是說它的二進制文件是與它所編譯的操作系統“捆綁”在一起的【作者應該指的是平台相關性】。

.NET Core默認編譯跨平台的二進制文件,你需要通過.NET Core Runtime 的 "dotnet [executable]"命令來運行程序;這些二進制文件包含了MSIL代碼,這是一種類似Native Code的代碼,通過.NET的JIT【即時編譯器】來編譯。JIT編譯的效率很高,它緩存了以前編譯的模塊【當你安裝.NET Core時,BCL的大多數模塊都被預先編譯和緩存了】,它的速度很快,默認情況下,它在第一次調用時生成沒有復雜優化的代碼,當它發現方法被頻繁調用時,就會生成一個優化的版本【分層編譯】。也就是說你可以免費得到一個"輕量級的PGO【Profile Guided Optimization】"。

你一樣可以使用.NET Native來制作一個完全AOT的本地二進制文件【應該是指NGenNativeAOT】。

垃圾回收

在表面上,兩者非常相似,但是他們的實現過程存在巨大的差異。

.NET的GC是針對於吞吐量(內存分配速率)和運行時性能進行優化的【.NET GC 分代+標記整理算法】:

  • 它是分代的,這也就意味着它的構建對CPU的緩存非常友好,當你的代碼在運行時,它最近分配或者使用過的對象很可能都在CPU的L0(那是0代的位置)或者在L1緩存中(那是1代的位置)。
  • 因為它是一個具有整理功能的分代GC,所以在C#中分配內存的消耗很低:基本上就是一個指針自增+比較,也就是說堆內存和棧內存分配消耗一樣的小。
  • 劣勢也是因為它需要進行整理。它分配的每個對象可能會在堆中移動幾次(每代(Gen0~Gen2)之間的轉移 + Full GC),更糟糕的是,整理意味着.NET必須修正它所移動的任何對象的引用指針【由於整理后對象地址發生變化,需要重新建立引用】,對象可能在CPU寄存器中,在棧上或者在隊中,所以它在整理時需要更長的暫停時間來進行修正工作【詳情可參考垃圾回收過程】。

與之相反,GO的垃圾回收被設計成幾乎無暫停的【Go GC】:

  • 它對緩存不那么友好,老實說,除了讓相同類型的對象彼此能靠近外,它對緩存不友好。
  • Go沒有分代GC,所以每一次GC都是Full GC,如果你的應用程序快速的分配和取消引用對象,你更有可能看到OOM或者分配失敗,因為GC沒有足夠快的掃描對象圖來釋放未引用的對象。
  • 但是它同樣沒有整理和指針修復帶來的問題,這意味着Go應該有一個完全無暫停的GC,Go需要為每個指針都花費一點額外的時間(如在GC的"標記"階段,標記每個目標引用都為"活着"狀態,當然,細節是很復雜的)。但是Go並不是從一開始就沒有暫停,在2017年左右,開發者設法將其暫停時間減少到毫秒內。

Go中低停頓時間是用犧牲性能的代價來替換的,如果你想了解更多的細節,請翻閱本系列的第二篇,不過那里的比較是在.NET Core 2.1下進行的,我計划下周分享我最新的更新,將使用.NET Core 3.1和Go 1.13.6,但是初步來看,差距很大。

下圖,突發分配速度-單位GB/s(越大越好)

image-20210619123004361

  • 在單線程突發內存分配測試中,C#要快4.5倍。當線程數量到到48個時,差異將增長到23倍。C#每秒7.7億次分配與Go的0.34億次分配。
  • 在分配速度與.NET上的線程數和核心數呈線性關系(測試機上1至48核心),至於Go,它在12至36個線程范圍內達到最大值,但是當它接近48個線程時,分配速率下降了近40%(0.33億次)。

對於分配速度和STW暫停時間,我們可以比較在48核上使用36個線程分配32GB靜態集合的結果:

  • .NET 10.05GB/s的分配速度,最大暫停時間2.6秒,百分之99.99%的暫停時間72ms。
  • Go 2.89GB/s的分配速度,最大暫停時間0.1秒,99.99%的暫停時間46ms。
  • "我們可以比較…."意味着這是Go在128GB上能夠完成的最復雜的測試;它在每一次的測試中都以OOM的方式崩潰(靜態集合>=1GB 和 線程數=48/48核心)。此外,它在(靜態集合>=64GB, 線程數=36/48核心)上使Windows桌面管理器奔潰,目前為止還不清楚怎么回事,感覺它是在OOM時是凍結了而不是終止所以導致Windows桌面管理器崩潰。
  • .NET Core完成了所有測試,並且沒有OOM。

下圖:持續分配速度-單位GB/S(越大越好)

下圖:最大STW停頓時間-單位ms(越小越好)

下圖:STW停頓時間-99.99%基線 單位ms(越小越好)

如果你對詳情感興趣,可以點擊鏈接看原始的測試數據【機器配置:AMD 線程撕裂者3960x 和 128GB內存】,現在只有Windows上的測試。

模塊

同樣的,表面上一樣,但是本質上非常不同。

Go中的相關概念:

  • 包【Package】:一個有源代碼的文件夾。所以添加一個包意味着你在你的項目中添加更多的源代碼。每個包只有在它或者它依賴的包發生變更時才會被重新編譯。只有Go關注包編譯版本,你甚至不應該知道它的存在。包最終會產生庫或者可執行的文件,盡管庫沒有明確的編譯結果,它們最終是以源代碼的形式被使用的。
  • 模塊【Module】(1.13版本新增):一個包含了模塊的版本、所有依賴關系、源碼和.mod的文件夾。它可以被發布到Go模塊庫。

同樣,在C#中也有3個與模塊相關的概念:

  • 項目【Project】:一個包含了C#文件 + .csproj文件的文件夾,該文件描述了他所有依賴關系和程序集的屬性。

  • 程序集【Assembly】:它是一個項目編譯結果,它包含了MSIL代碼+描述它的元數據(方法、類型等)。記住,.NET依靠它的JIT編譯器來運行代碼,所以基本上.NET程序集像C語言中的.obj(或.o) + .h/.hpp文件的混合體。它們不存儲源代碼,盡管所有的符號和編譯后的實現都在那里。同樣,程序集可以庫,也可以是可執行文件,或者兩者都是(沒有什么可以阻止你從包含入口的程序中導入任何你想要的東西【作者應該是說可以通過Assembly.Load在運行時加載程序集,通過反射來調用程序集的方法,或者直接在項目中依賴一個可執行文件】)。

  • NuGet包【Nuget package】:一個.nuget文件(實際上它是一個zip壓縮的文件),包含.NET程序集 + 其它你想要的東西 + .nuspec文件,這樣的文件通常被發布到公共的NuGet倉庫中,你也可以使用私人倉庫。通常情況下,你引用NuGet包而不是你C#解決方案中的項目(.csproj文件);當你的項目被構建時(例如使用"dotnet build"命令),它會自動下載和安裝。但由於NuGet格式與.NET無關,也可以使用其它工具如Chocolatey將其用於自己的包。

所以兩者的區別就是Go的軟件包含有源代碼,而.NET的包沒有源碼嗎?

不,最大的區別是.NET可以在運行時加載和卸載程序集,將其中的類型與當前的主程序集整合在一起,特備是以下這幾種情況。

  • 插件:你可以在你的應用程序中聲明一個IMyAppPlugi接口,實現一個邏輯從Plugins文件夾中加載所有程序集,在那里創建所有實現了IPlugin類型的實例,並通過IPlugin.Embed(myApp)方式調用。這就是為什么.NET應用程序是具有擴展性的。
  • 運行時代碼生成:.NET有Reflection.Emit LambdaExpression.Compile 方法(它底層使用Reflection.Emit)。兩者都可以生成動態的程序集,而且幾乎是實時的。你可以生成任何.NET代碼,這個新代碼可以使用當前運行時內所有類型,也可以生成自己的類型。這一特性被大量用於加速復雜的邏輯(所有主要的.NET序列化程序都使用這一特性;編譯后的Regex實現性能讓其它語言的實現方式都望塵莫及,包括Go)或者依賴注入的邏輯(大多數的IOC容易都依賴它),這也使得AOP方案成為可能。
  • 代碼層面的自檢:由於你的代碼可以訪問應用程序任何部分的MSIL和元數據,你的代碼可以自檢(像Cecil這樣的工具對此幫助很大);例如,生成在GPU上並行運行的版本(查看這個樣例子ILGPU)。
  • 所有這些都使一些奇怪的(但顯然是相當有趣的)場景成為可能:例如,即使是那些從來沒有想過要擴展的應用程序,也因為這個而以黑客的方式得到擴展。我所知道的最明顯的現代例子是Beat Saber,過去兩年中最流行的VR游戲,我是它的忠實粉絲。不同的人為它制作了50多個插件和20000多個社區制作的地圖,盡管這個游戲沒有官方的插件API。怎么做到的?嗯,它主要是一個.NET應用程序 - Beat Saber是建立在Unity上的,它使用C#/.NET作為其主要的 "腳本 "語言。有許多針對.NET的開源工具(Fody, Harmony)能夠對已經編譯好的程序集進行后處理,以嵌入、改變或刪除你喜歡的東西。所以有人為Beat Saber制作了BSIPA,它將插件的調用端點直接嵌入到游戲程序集中,並確保游戲在啟動時加載插件。Viola! Oculus Quest版本的Beat Saber有一個類似的mod(BMBF),即使Quest運行在Android上(但Unity for Android仍然運行.NET)。

Go提供了"插件"包,技術上允許你動態加載.so文件,但是:

  • 只能工作在Linux和Mac OS上
  • 主機和插件的編譯環境必須完成相同,特別是所有包引用必須完全匹配。
  • 還有很多其他的缺點,所以 "很多人誤解了插件今天能做什么。他們目前不容易使第三方為你的應用程序制作插件;[......]在實踐中,只有原始構建系統可以可靠地構建插件。這些問題充滿了人們在構建環境中發現的所有小差異。"
  • 這就解釋了為什么Hashicorp(Terraform、Consul、Vault等背后的公司--他們要求第三方供應商提供一種編寫插件的方法)依靠他們自己的插件API在子進程中托管插件並通過IPC調用它們。

類、結構、接口

C# 同時具有結構體(值類型):

  • 類總是生活在堆中,結構體生活在調用棧和堆中。如果生活在堆中,要么是作為其它類的字段,或者以裝箱的形式存在。因此"new"關鍵字:對於類,進行堆分配+調用它的構造函數。對於結構體:只是調用構造函數,此時已經為結構體保留了內存空間(在當前的棧上,或者堆上的類或結構體中)
  • 類永遠都是引用傳遞,結構體默認是值傳遞,你可以通過(in/ref/out parameter/ref return/ref struct,在有些情況下手受限的)【詳情戳我】進行引用傳遞。
  • 類可以有虛方法也可以繼承其他類,結構體沒有虛方法也不能繼承。
  • 當打包成數組時,結構體需要的內存大小就是它各個字段每項的大小。類的話是指針(64位系統占用8字節)+ 顯然還有它本身實例的內存。
  • 每個實例在堆中都有兩個指針,一個指向虛方法表(類型描述符)和一個系統保留的指針大小的數據(存儲一個用來比較的偽隨機值+為GC和同步保留幾個byte)。
  • 所有接口類型的值都需要一個指針。

然后Go只有結構體,但是:

  • 它們支持通過嵌入的方式進行集成【組合模式】。
  • 結構體能存在於堆中或者調用棧上:
  • 默認的情況下,你創建結構體時不會明確的指定它應該在哪創建,逃逸分析可以幫助編譯器決定其放置在哪里,調用棧或者堆上。據我所知它可以把它放在調用棧上,然后在移動到堆上,你也可以明確的在堆上分配結構體。
  • Go中的堆存儲對象沒有對象頭,因此結構體在goroutine棧、堆、其他結構體的字段以及數組/片中占用相同的空間。沒有堆頭意味着沒有好的方法來實現此類對象的基於引用的相等比較。如果你沒有發現其中的聯系,不要擔心,我將在后面的 "相等【Equality:相等性,平等,按照上下文意思判斷兩個實例是否一樣】"部分解釋。
  • 結構體沒有虛方法,但是結構體可以實現接口。所以你可以將一個結構體強轉成接口。
  • 有趣的是,它的接口類型的值需要兩個指針(所以它們在64位的平台上需要16字節或者兩個CPU寄存器):第一個指針指向底層的結構體,而第二個指針指向接口的方發表,所以類型信息在.NET上是和對象一起呆着,因為類型信息在實例header里面。而Go中類型信息是通過指針指向的。

這兩種方法都有明顯的優點和缺點。

  • 總的來說,Go中的結構體與.NET中的結構體工作原理非常類似。只是.NET中的結構體還需要有一些改進(嵌入+轉換到接口時不需要裝箱)。
  • .NET需要更多的時間來調用接口成員(雖然它緩存了對接口方法表的引用,但是仍然需要更多的時間)。
  • Go需要更多的空間來傳遞接管口的引用(在寄存器中,在調用棧中,在數組和切片中,等等)。

這里值得一提的是,Go:

  • 需要從幾乎所有可能失敗的方法中返回"err"值(錯誤類型,這是一個接口)。
  • 總是通過調用堆棧來傳遞值,而不是通過寄存器。另外,請注意每個調用的不尋常的“序言”,它檢查堆棧擴展的潛在需求【作者應該是說Go為了實現協程的協作式搶占,sysmon 協程標記某個協程運行過久,需要切換出去,該協程在運行函數時會檢查棧標記,然后讓出當前線程給其它協程用,詳情可以看這篇文章】。這是Go實現協程付出的代價,其它大多數的靜態語言在每次調用時都不做這樣的額外檢查【據我所知,在Go 1.14版本通過SIGURG信號的方式實現了異步搶占,但是不清楚會不會帶來其他性能問題】。
  • 因此,這個額外的 "err "需要在調用棧上增加16字節。此外,從調用中得到 "err "的代碼必須對 "err == nil "進行額外的檢查......這種每個調用的 "額外"(調用棧上的16字節+兩次比較)是不是有點太昂貴了?

還有一些其它看法:

  • 在Go中,接口字段的大小超過了機器字的大小,所以它不能被原子化地更新。我不確定這是否會造成任何大的問題,但我知道在.NET中經常會有指針被原子化更新(例如,指向一些共享的不可變模型的根)。盡管在大多數情況下,將接口指針包裝成一個結構並使用它的指針的解決方法可能是可行的--只是訪問速度會慢一點(解決一個額外的指針)+ 更新時需要額外分配。
  • 從好的方面看,這個功能(似乎--我沒有檢查過)允許Go將任何結構(例如存儲在數組中或另一個結構的字段中)投向它所支持的接口,而不需要裝箱。對於.NET來說,這是不可能的(盡管你可以在通用方法中實現類似的功能,也就是說,有一些變通方法可以讓你在類似情況下擺脫額外的分配)。

總體而言,Go模式似乎更簡單/更有吸引力:

  • 沒有對象頭(我猜如果需要分代GC,你還是需要對象頭)。
  • 沒有值類型和引用類型。
  • 結構體嵌入+接口的集成似乎更容易理解,而且更接近於底層。

但這一切都不足以成為交易的障礙;此外,Go也有自己的問題,例如,我發現了逃逸分析有一些缺陷;早些時候,我寫過關於切片的一個類似問題,下面的 "相等性 "部分描述了另一個問題。因此,我覺得可能會有更多這樣的問題......盡管我對它還不夠了解,不能肯定地聲稱這一點。

目前的結論是兩者打平。

錯誤處理

C# 使用"經典"的異常處理,如果你對此細節感興趣,可以查看我的這篇Exception Handling 101文章。

Go 選擇了一條相當獨特的道路,有兩種選擇:

  • 顯式錯誤傳遞:有一個優雅的約定,一個可能失敗的方法返回的最后一個值必須是"err"(錯誤類型),如果一切正常,則為nil(空指針),如果不正常,則為某個對象,調用者必須明確的檢查nil。
  • 還有defer、panic和recover,這是不優雅的失敗處理。

但是我不得不提的是,Go的模式顯然更耗性能:

  • .NET經典的異常處理方式只有在發生異常的時候才會有性能損耗,否則幾乎沒有性能損耗。
  • 相反,Go的異常處理模式讓你的程序為每一個返回"err"的調用和每一個"defer"買單。

最后,如果panic→recover模式與常規異常處理沒有太大區別,您是否仍然覺得到處返回“錯誤”的最初想法在概念上仍然是好的——否則為什么你需要兩者?

相等性(==, !=)

它在.NET和Go中的工作方式完全不同。

首先,簡單介紹一下:相等性通常需要的兩個操作:

  • 比較兩個實例是否相等
  • 以符合相等性的方式來計算實例的hashcode【實例相等hashcode必然相等】

這意味着如果實例相等那么hashcode必須是相等的,對於不相等的實例則hashcode極有可能不相等(它們是可能相等的,這被稱為哈希碰撞)。換句話說,如果你比較實例的hash值,如果它們不相等,那么實例肯定不相等;如果hash值相等,那也說明不了什么,這些實例也可能不相等。

最后,對於不可變的實例來說,hash值不應該隨時間而變化。Set、Map和集合都依賴於hash值,如果你把(key1,value1)放到一個hashmap中,然后key1的hash值改變了,那么map[key1]將查找不到value1。

所有這一切意味着相等和Hash對於可變對象幾乎沒有意義——除非你在 Equals 和 GetHashCode 操作中只使用它們的不可變部分:

  • 如果沒有GC壓縮,內存中的對象地址就符合“不可變部分”的特征。它對於每個對象都是唯一的,而且永遠不會改變。
  • 還有一些對象從其公共API方面看是不可變的,但其內部狀態是可變的,例如,因為它們緩存了一些東西。例如,它可能是你自己的字符串包裝器,它緩存了字符串的哈希代碼以避免重新計算(假設你處理的字符串可能很長)。它的全部狀態是可變的,但其中公開的部分是不可變的。這就是為什么你可以為它實現相等性和哈希代碼的計算。

在.NET上,相等性大多是用戶定義的,你必須為結構體(逐值傳遞類型)手動編碼,而且【詳情可以看如何重寫Equals方法】:

  • 通常情況下,你會將大部分的結構標記為只讀(不可變)。GetHashCode和Equals可以直接的實現。
  • 如果你在寫非只讀結構,你應該應用我上面描述的規則,即理想情況下,只比較不可變的部分。
  • Visual Studio和Rider可以自動生成Equals和GetHashCode的實現。

與結構體相反,類(pass-by-reference類型)自動獲得基於引用的平等:如果兩個引用指向同一個實例,那么它們就是平等的。通常情況下,你不會改變這一點,盡管你可以。

  • 基於引用的平等需要在具有壓縮GC的語言中進行一些額外的處理。你不能假設指針在未被觸動的情況下保留其價值--指針在堆壓縮時被GC修正。這個問題給基於指針的平等帶來了額外的問題:也許你可以實現比較(你需要原子地讀取和比較兩個指針),但你如何計算哈希值,它必須對同一個指針保持不變,即使它改變了?
  • 在.NET中,這個 "額外 "是一個存儲在對象頭中的偽隨機數,它作為一個哈希代碼用於引用平等。不幸的是,我不知道它是如何計算的,盡管它很可能是由對象地址和一些種子(很可能是一個加法)衍生出來的,這些種子會隨着時間的推移而變化(如果你有壓縮,可能會匹配到很多地址)。

但在GO中卻非常不同,在GO中,總是進行結構上相等比較。我猜這是由於兩個因素。

  • 所有的結構都表現得像是通過值傳遞的,盡管指針是在幕后傳遞的。由於指針是你在這里不應該考慮的東西,從相等性的角度來看,忽略它也是合乎邏輯的。
  • 我寫道,基於引用的相等性需要頭或類似的東西,在一個有壓縮GC的語言中。盡管Go還沒有壓縮的GC,但它保留了在未來添加它的可能性。這就是為什么它明確地禁止你假設指針是穩定的。但是由於Go中的所有對象都沒有頭,基於引用的相等性在這里是不可能的。
  • 這樣做的后果之一是接口的相等性如何:如果底層實例具有相同的類型,並且在結構上是相等的,它們就是相等的。對於比較而言,在.NET和Java中,如果且僅當它們屬於同一個實例時,接口是相等的(即基於引用的相等)。

此外,在Go中:

  • 你不能重寫相等性的工作方式,即使對於你自己的類型。結構相等性是你所擁有的全部【作者的意思應該是只沒辦法通過自定義的方式兩個實例是否相等,比如在一個結構中有id,name這些字段,在業務場景中只要id字段相等就認為是相等,C#可通過重寫Equals實現,Go則不行】。
  • 沒有標准的哈希函數/API用於相等判斷,也沒有辦法在Go中調用map類型使用的內部哈希函數,所以如果你需要為你自己的集合提供哈希,Go不能幫你解決這個問題。而且據我所知,甚至關於如何暴露它的討論也還沒有結束
  • 似乎沒有辦法讓例如map依賴你自己的相等比較器(有時你需要這樣做),而且說實話,我不知道如何實現一個假設相等總是結構性的變通方法,例如,即使你開始用你自己的包裝器替代鍵,包裝器仍然不能覆蓋他們自己的平等/哈希,所以...

像往常一樣,有利有弊。

  • 在這里,Go勝在比較簡單:是的,Go里面更容易理解平等的作用。
  • 而在其他方面上都輸了:有很多非常通用的情況下,你確實需要一個自定義的相等判斷邏輯或基於引用的相等性判斷。

基礎類庫

這里最顯著的區別是.NET BCL有相當數量的方法和接口被C#編譯器特別對待(盡管編譯器並沒有尋找特定的接口。它尋找的是具有相同名稱的方法)。一些例子【這可能說的就是鴨子類型】。

  • IDisposable/IAsyncDisposable:在 "using "語句中使用,提供對資源處置的支持/(類似stream.Close的情況)。在實現中,編譯器會尋找是否存在Dispose/DisposeAsync方法。如果你需要處理托管或非托管資源,你要實現這些接口中的一個。
  • IEnumerable<T> & IAsyncEnumerable<T>:在 "foreach "循環和帶有 "yield return "的方法中使用,提供對序列枚舉的支持。在實現中,編譯器會尋找GetEnumerator方法。
  • Task/Task<T>/ValueTask/ValueTask<T>:用於 "await "表達式,提供對異步完成通知的支持。在現實中,編譯器會尋找GetAwaiter方法。
  • Enumerable/Queryable.Select/Where/...(數十種其他擴展方法):用於LINQ表達式(見 "from"、"where"、"select"、"group "及其他關鍵字);編譯器將這些表達式轉換為方法調用鏈。
  • IEquatable<T>和IComparable<T>接口:NET中所有的通用集合都依賴它們來測試相等或相對順序。特別是,Dictionary<TKey, TValue>使用IEquatable<T>來比較相等和獲取HashCode。
  • 即使是最基本的類型,Object也提供了GetHashCode()和Equals(...),你可以在子類中重寫,+ GetType()和其他一些你可以調用的方法。

相反,Go只為系統類型(切片、映射等)提供語言支持(即特殊語法),但沒有任何接口或類型可以實現或擴展,這些都是語言所支持的。

主要內容:

  • C#與它的BCL很好地結合在一起。相等性/哈希、序列/LINQ、資源釋放--所有這些都被C#部分地支持。
  • Go采取了不同的方式,盡可能少地提供這種集成。

兩種語言中存在的其他類似特征

  • Go的切片(Slice)約等於.NET中的Span
  • 擴展方法:非常類似,你可以自由地將方法 "附加 "到Go或者.NET中的任何結構(類)和接口上。
  • 這兩種語言都支持不安全指針/不安全代碼。

類似的反模式/設計錯誤

  • 這兩種語言都有null/nil指針的十億美元的錯誤,但C#在幾個月前通過nullable引用類型解決大部分問題【作者應該是指null指針是個坑爹的東西,絕大多數的問題都是因為null指針】。
  • 大括號,為什么,為什么不只是縮進? 😃【我覺得大括號挺好的,(逃】

C#中缺少的Go功能

  • 類似Go的異步執行模型,下面有專門的章節介紹goroutines和async-await【這其實本質是stackcopy和stackless協程實現方式的區別】。
  • 公共/私有成員的約定而不是額外的關鍵字,C# 成員聲明中的修飾符的數量有時甚至會嚇到老手:“protected internal static readonly 真的嗎? 真的”【我也覺得這個比較復雜,常用的也就public protected private】 。
  • 主要就是這樣。

Go中缺少的C#功能

拿起一杯咖啡,這個列表很長。

  1. 泛型 - 說實話,這很重要。如果你看看其他任何現代靜態編譯語言,泛型都在那里。而我擔心要把它們添加到Go中是相當困難的,主要是因為其靜態類型系統。我將進一步展開,但這的主要后果是:
    • 在Go上設計真正有效的通用數據結構和算法比較困難(盡管它的一些特性,主要是接口的實現方式,部分地緩解了這一問題)。
    • 很明顯,由於這個原因,你在編譯器支持的類型檢查方面受到了更多限制。同樣,這不是你不能沒有的東西,但泛型和類型檢查是(可以說)開發人員越來越傾向於使用TypeScript而不是JavaScript的主要原因。
    • 有一種觀點認為Go中沒有添加泛型以使事情更簡單 ,這顯然不是真的。 泛型根本就沒有那么容易實現。 在一種旨在在運行時具有靜態類型系統的語言中。 在這種情況下,它不是添加,而是一次重大的重構; 此外,這可能是 Go 路線圖上最基本的功能。 這解釋了為什么泛型是在大約 2.5 年前宣布的,但仍然沒有與之相關的 PR/問題(我盡了最大努力尋找這個;也許我錯了)。
    • 泛型影響着你寫的一切,但最主要的是--你的BCL。老實說,你應該早些加入它們,而不是晚些--你越是等待,你的BCL的大部分內容就會在你加入它們之后變得過時。.NET的前4年(2002年...2006年)沒有泛型,而且有些遺留問題仍然存在(例如Hashtable和System.Collections的其他非類型集合/接口仍然在BCL中--甚至在.NET Core中)。而Go現在已經有10年歷史了。
  2. Lambda表達式;更確切地說,Go提供了匿名函數(閉包),但沒有對參數進行類型推理,所以依賴它們的代碼看起來很丑
  3. 序列生成器(帶有 "yield return "的方法)。
  4. LINQ(語言集成查詢):有一些模塊試圖為Go實現LINQ-to-Enumerable。但即使快速瀏覽一下這些例子,也會發現那里既不方便,性能也不好。
  • 缺少Lambda會讓你寫更多的代碼。
  • 缺少的泛型使你把每個函數參數從interface{}類型(它類似於C#中的Object)轉換為它的實際類型,這是對函數的每次調用都要評估的標准。
  • 編譯器不能幫助你進行任何類型檢查--所有的序列都有相同的類型(像.NET中的IEnumerable)。
  1. 操作符重載,在某些情況下相當有用(例如,像BigInteger和Vector 這樣的類型明顯受益於此;重載==和≠也很常見)。
  2. 所有這些都意味着DSL(特定領域的語言)在Go中更難構建。相反,它們在C#中很容易建立,而F#,它的引號、計算工作流和類型提供者,簡直是DSL構建者的天堂。但是DSL重要嗎?好吧,這里有一些關於.NET的DSL的例子:
    • WebSharper將任何F#底阿媽(在F#上特別裝飾的代碼)轉化為JS,有效地將F#本身變成DSL。
    • ILGPU(免費軟件)、AleaGPU (商業的,雖然對消費級GPU來說是免費的)和Hybridizer(商業的)這樣的項目使你能夠在任何.NET語言上編寫CUDA內核(即在GPU上運行你的代碼),或者使用GPU以高度並行的方式處理你的數據,而這是不需要使用任何其他語言。
    • LINQ數據提供者形成了另一個子集,實際上是建立在C#之上的DSL--這就是你在那里主要用來訪問和處理數據的東西。LINQ to enumerable的使用相當頻繁,可能,在每一個處理某種序列的其他方法中。如果你對它很熟悉,寫幾行代碼而不是像".GroupBy(x ⇒ x.Name).OrderBy(g ⇒ -g.Count).ToList() "這樣的單行字,感覺相當不方便。
  3. 元組,肯定也很有用。注意,Go中的多個返回值完全不是一回事;Tuples+out參數是你在C#中類似情況下使用的。
  4. Nullable<T>/Option<T>類型,我想Go需要泛型來做這個,所以......
  5. 表達式樹--LINQ-to-Queryables/LINQ提供者的一個重要部分。
  6. 模式匹配。
  7. 枚舉?迭代器?
  8. 特性【Attributes】,同樣是相當頻繁使用的功能。
  9. "using "關鍵字/IDisposable接口,顯然Go沒實現是一個很大的失誤
  10. SIMD【單指令多數據流,可以利用CPU的指令集如SEE、Avx2等等】內建支持,是的,它們可以幫助你在一些問題上達到近乎C++的速度

而且還有很多不那么重要的功能:

  • 動態綁定
  • 字符串插值
  • 屬性自動完成
  • 匿名類型
  • 事件,雖然不是什么大問題,我想Rx正在到處取代事件,而且委托足以實現你自己的這些版本
  • 索引和范圍、范圍表達式、輸出參數、默認參數值、默認接口方法、只讀成員、nameof表達式...

異步執行 第一部分回顧

如果你對goroutines和async-await的詳細比較感興趣,請查看第一部分【比較結果已經比較老了,不過還具有一些參考意義,文章開始放了國內大佬的翻譯版本】,要點是:

  • Go吊打了C#,如果我們比較C#和Go異步編程的便利性,你在Go中基本上是免費獲得的(就編碼而言),盡管你為這種便利性付出了每一次調用的一小部分性能。
  • 如果你可以看到數以百計的async-await語句,你也會對C#感到滿意。但在學習了Go中的工作方式后,你不會喜歡那里的async-await,盡管幾乎所有其他語言都使用同樣的模型(async-await)【C++、Rust、Js、Python都實現await/async】。
  • 值得一提的是,C#中的async-await機制允許你實現你自己的類似任務的對象,例如你自己的輕量級任務。感覺就像一個加分項,雖然到目前為止我還沒有使用過這個:)。
  • 因為執行模型非常不同,所以性能很難比較。沒有最近的基准;至於過去的基准(大約1-2年的時間),C#和Go非常接近。

Sequences, Rx, IAsyncEnumerable<T>

本節主要是為了證明為什么goroutines幾乎和泛型一樣重要。

.NET BCL至少提供3種類型的序列:

  • IEnumerable<T>是用於交互式("拉")和同步序列的。調用者(通常--通過 "foreach "循環)從一個序列中 "拉 "出項目,這使得枚舉者做一些工作來提供它。它是同步的,因為所有的處理程序都是同步的。
  • IObservable<T>是用於反應式編程("推")和同步序列的。調用者將項目(事件)"推送 "到一個事件序列中,其訂閱者因此而運行一些計算(例如,在他們自己的序列中產生項目)。
  • IAsyncEnumerable<T>是反應式-交互式同步-異步序列。它的調用者可以異步地等待這樣一個流中的下一個項目,因此它的處理程序既可以是同步的也可以是異步的(而且,你不會為此付出很大的代價--IAsyncEnumerator<T>.MoveNext()返回ValueTask<bool>,也就是說,同步調用不應該有分配)。
  • 除此之外,C#還提供了一種特殊的語法糖,允許你以一種非常方便的方式編寫返回IEnumerable<T>和IAsyncEmumerable<T>(序列生成器)的方法(使用 "yield return "來返回下一個項目)。

所以C#在這里有很多花哨的東西,Go則沒有。現在有一個你可能沒想到的說法:任何Go的序列實現都會自動提供所有這3種序列類型的特征。等等,什么?好吧,Go中的任何函數都是同步的和異步的。所以要在Go里面創建一個反應式的序列,你需要:

  • 一個反應式的項目生產者+一個類似IEnumerator的類型,在其MoveNext()方法中等待生產者通道中的下一個項目。
  • 一個IEnumerable.Consume()方法,在一個新創建的goroutine中枚舉該序列,直到結束。

但是(一個很大的 "但是")。Go沒有泛型,沒有lambdas,所以沒有類型檢查,語法更加冗長,需要把每個處理程序的參數投到它的實際類型,性能更差,等等,也就是說,我所描述的只是一個夢,現實要黑暗得多。

向語言開發人員提出幾個問題:

  • 你如何證明人類,而不是機器必須處理與異步編程相關的相當愚蠢的工作--假設我們現在寫的幾乎所有代碼至少是潛在的異步的?
  • 為什么Go是唯一能很好解決這個問題的語言?從表面上看,goroutines肯定比泛型更容易實現。那么為什么其他語言的開發者忽視了簡單復制一個好的解決方案的機會呢?

運行時性能

總的來說,它是相似的。但值得一提的是,目前C#在大多數測試中幾乎吊打了Go @Computer Language Benchmark Game

下圖運行速度,越大越好

C#唯一輸掉的測試是數學問題,這有點令人驚訝。迅速檢查一下就會發現。

  • “pidigits”依賴於一個大整數的外部庫,也就是說,它更像是一個對這個庫的性能測試+外部函數調用測試。
  • "mandelbrot "的第一個 "for "循環假設Vector<double>.Count(硬件SIMD寄存器的雙倍數大小)總是2,盡管實際上它與硬件有關,在現代CPU上它至少應該是4,最有可能的是,這個測試僅僅由於一個錯誤而慢了2倍。
  • "n-body "不使用SIMD--無論是C#還是Go。這就解釋了為什么兩者的性能都很低(+/-JIT時間,這實際上包括在C#的每一個計時中),也解釋了為什么C++(7.30s)在這方面領先這么多:它的代碼通過SIMD內在因素進行了大量的優化。Rust如此,Fortran也是如此--也就是說,所有優秀的性能都依賴於SIMD。

速度系數的幾何平均值是1.53倍,它是顯著的。

你要知道:幾年前,C#在這些測試中落后於Java--但這主要是因為CLBG上的所有測試都是在Ubuntu上運行的,而Mono(開源的跨平台.NET運行時,曾經比.NET Framework慢了2倍)是在.NET Core之前在Ubuntu上運行C#代碼的唯一方法。最后,.NET Core本身明顯比.NET Framework 4.X快--即使在Windows上。

后記

為什么開發人員會從一種編程語言轉到另一種?有大量的因素:

  • 該語言是否越來越流行?

  • 學習曲線有多陡峭?

  • 我是否能找到一份需要這種語言的好工作?

  • 它能為我的下一個項目提供理想的性能嗎?

  • 我喜歡它的語法嗎?

而一旦你有了更多的經驗,你肯定會在自己的類似列表中增加一項:你每天要寫多少丑陋的代碼來解決你的典型問題。

這就是為什么我非常喜歡C#(F#也是,但這是另一個故事):

  • LINQ和IEnumerable<T>的方法調用比一組嵌套的 "for "循環短得多--此外,它們同樣快速,更容易閱讀和理解。
  • 泛型允許你有一個單一的抽象實現,對其任何類型的參數都同樣有效,所以你不必手工維護一組版本,這些版本大多是相互模仿的。
  • 我顯然可以繼續下去,但是......。

我開始研究Go,希望能看到類似的東西。盡管Go有近乎完美的異步編程模型(你所有的代碼都自動既是同步的又是異步的),而且我們現在寫的大部分代碼都有可能是異步的,但這足夠嗎?

老實說,不,一點也不。如果你忽略了goroutines,就很難找到其他令人信服的理由來使用這種語言。

而且不幸的是,不僅僅是我在抱怨--還有很多人在抱怨。我強烈建議你去看看《Go:好的、壞的和丑的》,不幸的是,我是在已經寫好這份文件的時候才發現它的,否則,它可能會大大縮短。這正是我對Go的感覺:它是好、壞、丑的混合體。這篇文章中的兩句名言。

......看起來Go的設計發生在一個平行宇宙中[......],在那里,90年代和2000年代發生在編譯器和編程語言設計中的大部分事情都沒有發生。

......一方面,我可以談論幾個小時關於Go是多么可怕的事情。另一方面,Go顯然是一種非常好的語言。

所以我目前對C#與Go的立場是。

  • 目前,.NET幾乎在所有方面都處於領先地位,唯一大的例外是異步執行。
  • 如果.NET實現了Go風格的同步-異步執行模型,我就找不到令人信服的技術理由去看Go了,你可能會注意到,Go中的其他東西幾乎都不如.NET,盡管它當然還有其他一些(但要小得多)的瑰寶。
  • 同樣,一旦Go實現了泛型和lambdas,我肯定會開始更加關注它。但說實話,它需要的東西太多了......

如果你了解我,你也知道我是個愛貓人士

請注意,對於地鼠先生來說,情況並不像看起來那么糟糕--如果你沒有注意到,他在這張照片上用槍對付貓先生。所以他絕對是安全的,而貓顯然有點害怕。誰知道呢--也許再過幾年,地鼠先生再多長幾磅,他甚至不需要槍了 😃

Go在人們心目中的簡單性對開發者來說無疑是有吸引力的。我在挖掘各種文檔和例子時的觀察是,在Goland有很多真正的編程大師,也有純粹的高手。像這樣的帖子(其作者顯然不了解Go的獨特之處,但仍對其大加贊賞)讓我覺得自己又回到了.NET的早期,我確信我過去也寫過類似的關於.NET的東西:)

如果.NET核心團隊@微軟--在忙於增加數以千計的ReadWriteLockUnlockAcquireReleaseCopyPasteAsync重載的同時,贏得了每一場微小的戰斗,卻因為忽略了一次解決所有這些問題的機會而輸掉了戰爭,這實在令人遺憾。

同樣,如果Golang團隊再花幾年時間繼續容忍沒有泛型、相當糟糕的分配/GC性能("為了微小的STW暫停花費了多少? 一切。"),以及相當多的其他領域(例如,感覺Golang "否認 "了函數式編程,簡單地避免了與FP有關的任何東西:),那就太可惜了。).

因此,如果你喜歡這個系列和/或想引起微軟和谷歌對這里強調的一些問題的注意,請分享/加注/發送給你認識的有影響力的人 😃

P.S. 查看我們的新項目。Stl.Fusion是一個適用於.NET Core和Blazor的開源庫,力爭成為您的實時應用程序的第一選擇。它的統一狀態更新管道確實很獨特,讓人心動。


另外插播一個小廣告img

[蘇州-同程旅行] - .NET后端研發工程師

招聘中級及以上工程師,優秀應屆生也可以,我會全程跟進,從職位匹配,到面試建議與准備,再到面試流程和每輪面試的結果等。大家可以直接發簡歷給我。

工作職責
負責全球前三中文在線旅游平台機票業務系統的研發工作,根據需求進行技術文檔編寫和編碼工作

任職要求

  • 擁有至少1年以上的工作經驗,優秀的候選人可放寬
  • 熟悉.NET Core和ASP.Net Core
  • C#基礎扎實,了解CLR原理,包括多線程、GC等
  • 有DDD 微服務拆分 重構經驗者優先
  • 能對線上常見的性能問題進行診斷和處理
  • 熟悉Mysql Redis MongoDB等數據庫中間件,並且進行調優
  • 必須有扎實的計算機基礎知識,熟悉常用的數據結構與算法,並能在日常研發中靈活使用
  • 熟悉分布式系統的設計和開發,包括但不限於緩存、消息隊列、RPC及一致性保證等技術
  • 海量HC 歡迎投遞~

薪資福利

  • 月薪:15K~30K 根據職級不同有所不同
  • 年假:10天帶薪年假 春節提前1天放假 病假有補貼
  • 年終:根據職級不同有 2-4 個月
  • 餐補:有餐補,自有食堂
  • 交通:有打車報銷
  • 五險一金:基礎五險一金,12%的公積金、補充醫療、租房補貼等
  • 節日福利:端午、中秋、春節有節日禮盒
  • 通訊補貼:根據職級不同,每個月有話費補貼 50~400

簡歷投遞方式

大家把簡歷發到我郵箱即可,記得一定要附上聯系(微信 or 手機號)方式喲~

郵箱(這是啥格式大家都懂):aW5jZXJyeUBmb3htYWlsLmNvbQ==


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM