> 我将写一个系列的文章,来对比 C# 与 GO( 译者:就两篇 ),Go 的核心特性是 Goroutines, 这是一个非常棒的起点,C# 的替代方案是使用 Async/Await 来支持这个特性。
但是实现的方式上还是有一些差异的:
* C# 中对于 Async/Await 的实现是基于编译器提供的方法体,类似于 C# 对 IEnumerable<T> / IEnumerator<T> methods 的实现。编译器生成一个方法返回状态,返回值作为是否异步计算的标志。
* Goroutines 在 Go 中特别常见,当你开始使用 "Go" 关键字的语法糖的时候,所有神奇的关联魔法就开始执行了。Go 异步编程使用了一个轻量级的线程,实际上,一个线程使用了很小的堆 与一个能够异步等待读操作、暂停自身、释放操作系统线程相比较,前者肯定是更轻量级的。
* Go 中没有 "await" 的概念,取代的方式是使用通道 (Channel) 来进行通信,稍后我会解释为什么 Go 不需要这个概念。
* 这里还有很多不同的地方 ———— 我会在后续的很多地方提到它们,但是,整体上来讲,Async/Await 是构建在 C# 的平台之上的,也就是说,.NET CLR 对于这块内容不需要做额外的修改,Go 与之不同的是,goroutines 已经深度的集成在 Go 的运行时机制中。
接下来,我会做一些简单的测试:
* 创建 n 个 Goroutines,每个 Goroutines 在通道上面等待一个数字,并在他的基础上自增,并发送给输出通道。
* Goroutines 和 channels 连接在一起,因此发送给第一个通道的消息会被传送到最后一个通道上面。
**Go 代码如下:**
```go
package main
import (
"flag";
"fmt";
"time"
)
func measure(start time.Time, name string) {
elapsed := time.Since(start)
fmt.Printf("%s took %s", name, elapsed)
fmt.Println()
}
var maxCount = flag.Int("n", 1000000, "how many")
func f(output, input chan int) {
output <- 1 + <-input
}
func test() {
fmt.Printf("Started, sending %d messages.", *maxCount)
fmt.Println()
flag.Parse()
defer measure(time.Now(), fmt.Sprintf("Sending %d messages", *maxCount))
finalOutput := make(chan int)
var left, right chan int = nil, finalOutput
for i := 0; i < *maxCount; i++ {
left, right = right, make(chan int)
Go f(left, right)
}
right <- 0
x := <-finalOutput
fmt.Println(x)
}
func main() {
test()
test()
}
```
**CSharp 代码:**
```
using System;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
using System.Threading.Tasks.Channels;
namespace ChannelsTest
{
class Program
{
public static void Measure(string title, Action<int, bool> test, int count, int warmupCount = 1)
{
test(warmupCount, true); // Warmup
var sw = new Stopwatch();
GC.Collect();
sw.Start();
test(count, false);
sw.Stop();
Console.WriteLine($"{title}: {sw.Elapsed.TotalMilliseconds:0.000}ms");
}
static async void AddOne(WritableChannel<int> output, ReadableChannel<int> input)
{
await output.WriteAsync(1 + await input.ReadAsync());
}
static async Task<int> AddOne(Task<int> input)
{
var result = 1 + await input;
await Task.Yield();
return result;
}
static void Main(string[] args)
{
if (!int.TryParse(args.FirstOrDefault(), out var maxCount))
maxCount = 1000000;
Measure($"Sending {maxCount} messages (channels)", (count, isWarmup) => {
var firstChannel = Channel.CreateUnbuffered<int>();
var output = firstChannel;
for (var i = 0; i < count; i++) {
var input = Channel.CreateUnbuffered<int>();
AddOne(output.Out, input.In);
output = input;
}
output.Out.WriteAsync(0);
if (!isWarmup)
Console.WriteLine(firstChannel.In.ReadAsync().Result);
}, maxCount);
Measure($"Sending {maxCount} messages (Task<int>)", (count, isWarmup) => {
var tcs = new TaskCompletionSource<int>();
var firstTask = AddOne(tcs.Task);
var output = firstTask;
for (var i = 0; i < count; i++) {
var input = AddOne(output);
output = input;
}
tcs.SetResult(-1);
if (!isWarmup)
Console.WriteLine(output.Result);
}, maxCount);
}
}
}
```
## Go 输出内容:
```
C:\Projects\GoTest\src>go run ChannelsTest.go
Started, sending 1000000 messages.
1000000
Sending 1000000 messages took 3.5034779s
Started, sending 1000000 messages.
1000000
Sending 1000000 messages took 808.9572ms
```
## C# 输出内容:
```
C:\Projects\ChannelsTest>dotnet run -c Release -f netcoreapp1.1
1000000
Sending 1000000 messages (channels): 3545.006ms
1000000
Sending 1000000 messages (Task<int>): 1693.675ms
```
在我们讨论结果之前,关于我们的测试代码,有几个注意点我们在这里讨论一下:
* 这个测试主要是为了 Go 准备的,在 C# 中,不需要 channel 来进行同步通信,任务通常是通过彼此异步等待获取结果。而对于 Go 来说,只能通过 channel 来实现,所以这里是使用通道来测试。
* C# 没有公开实现的通道,我现在使用的是一个在之后会公开的测试版本的通道,名字叫做[System.Threading.Tasks.Channels](https://github.com/dotnet/corefxlab/blob/master/src/System.Threading.Tasks.Channels/README.md),现在可以通过[Nuget](https://dotnet.myget.org/feed/dotnet-corefxlab/package/nuget/System.Threading.Tasks.Channels/0.1.0-e160430-1) 来获取它 , 目前的版本是 0.1
* 为了公平起见,C# 中除了通道的测试外,我还用了一个异步任务的测试,代码里面,每个 task 等待他的 "input"task, 在得到的数字上自增 1,并返回自增后的结果。
* C# 有一个预热的功能,Go 没有,预热的逻辑会导致任何小的函数第一次执行的时候,都会花费更长的时间。
## 原始结果对比:
* 第一次执行,Go 和 C# 的时间基本相同。
* 第二次 Go 快了很多,大概提升了 3.4 倍,C# 没有执行第二次,因为他的速度始终是一样的。
* 基于任务版本的 C# 代码,仍然是 Go 第二次执行的两倍时长。
所以为啥 Go 第二次执行这么快嘞? 解释起来很简单,当你启动 Goroutine 的时候,Go 需要分配 8K 的堆内存给他 ( 译者注:不一定是都是 8K,不同版本大小不一样 ),而这些内存可以重用,所以第二次的时候,不需要分配更多的内存给他,证明图如下:
![Memory Detail](https://raw.githubusercontent.com/studygolang/gctt-images/master/go-vs-csharp/1780316-075137a9f9b487bb.png)
Go 为 1M 数量的 Goroutine 分配了近 9GB 的内存,假设每个 Goroutine consimes 至少需要 8KB,那么这些内存就大概达到了 8GB。
如果我们增加测试数量到 2M 的话,我的机器直接挂了 ( 内存不足 )。
所以两者的差距显而易见,让我们来思考一下为啥 C# 生成的这么慢:
* System.Threading.Tasks.Channels 目前还在测试阶段,性能上确实有一些不足,所以慢了两倍情有可原。
* 如果去掉通道的话,仍然有两倍以上的差距,虽然代码里有一个 `await Task.Yield()`, 但这个是必不可少的,.net 通过它来实现任务返回的。因此,他很快地调用堆方法并在 StackOverflowException 结束。在实际使用中,这不是一个问题,在异步代码里本来也不建议存在很长的递归链。不过,在这个测试里,它降低了 1.5 倍左右的性能。
* 尽管在 C# 中,task 是一个轻量级的对象,但是他仍然是需要堆分配内存的。状态本身也是一个引用类型。在现在的 GC 算法中,堆内存分配是很快地,但是他们可能比类似的堆扩展和调用慢 5-10 倍。
现在让我们修改一下测试的内容,将传递的消息降低到 20K,这个值比较接近于我们现实应用的最大值 ( 服务器上 Socket 的套接字接近 20K)
```
C:\Projects\ChannelsTest>go run ChannelsTest.go
Started, sending 20000 messages.
20000
Sending 20000 messages took 75.0496ms
Started, sending 20000 messages.
20000
Sending 20000 messages took 18.0513ms
C:\Projects\ChannelsTest>dotnet run -c Release -f netcoreapp1.1
20000
Sending 20000 messages (channels): 49.297ms
20000
Sending 20000 messages (Task<int>): 28.702ms
```
结果不难看出,两者很接近了:
* Go 第一次的表现不进入人意
* C# 比第二次的 Go 慢 1.5 倍
* C# 通道的方式慢 2.7 倍
数量换到 5K 的时候:
```
C:\Projects\ChannelsTest>go run C:\Projects\GoTest\src\ChannelsTest.go
Started, sending 5000 messages.
5000
Sending 5000 messages took 15.0399ms
Started, sending 5000 messages.
5000
Sending 5000 messages took 8.0213ms
C:\Projects\ChannelsTest>dotnet run -c Release -f netcoreapp1.1
5000
Sending 5000 messages (channels): 15.027ms
5000
Sending 5000 messages (Task<int>): 6.881ms
```
在这里,可以看到 C# 基于 task 的效果,比 Go 还要好,C# 的通道测试,比 Go 最好的状态慢 2 倍左右。
## 为啥 C# 使用 Task 的效果更好?
* Go 上的 5K 测试使用 ~ 5MB RAM,这仍然小于 Core i7 的 L3 缓存大小,但远远大于 L2 缓存大小 ; 另一方面,不太清楚为什么性能不如第二次访问时的性能好—— CPU 只缓存被访问的数据子集。
* c# 版本,是 prob。10x 内存效率更高,在这个测试中使用了 ~ 500KB 的 RAM,这更接近核心 i7 的 L2 缓存大小 ( 每个核心 256KB)
## Goroutines vs async-await 结论:
我们看一下最关键的不同点:
* **Goroutines 显然是更快**:在真实场景下,Go 可以得到 2.x 甚至 3.x 的速度提升,另一方面,两者还都是很高效的:在 C# 中你可以得到 1M/S 的 "awaits" 效果,Go 中大概是 2-3M,这个数字其实比较大了。举个例子:如果你在处理网络信息,这意味着 C# 的 core i7 处理器,每秒大概可以处理 10W 条信息,也就是说,在实际服务器上处理的消息更多。也就是说,这并不会成为瓶颈。
* **在实际生产中,C# 的性能很接近于 Goroutines**:C# 非常节省内存,大多数用于生产环境的应用比较依赖于他的内存集的大小。
* **8K 的 Goroutines 内存,意味着更容易产生 OOM 的错误**: 如果你的服务器通过 Goroutine 来处理所有的消息,但是服务器都在等待一些外部的服务。如果请求的频率非常高的话,那么根据上面的测试,非常容易产生 OOM 错误。2-3M 每条的数据,你需要大概 32GB 的内存。
* **默认情况下,C# 为异步调用做了很多别的事情,导致他比较慢**:需要提及的是,他通过异步等待调用链传递 ExecutionContext 和 SynchronizationContext ( 即,每个调用都有多个字典查找对应的线程本地变量 )
* **C# 的模式更健壮**:所有的异步代码都通过 async-await 封装,还有一些自带的原语,对取消的支持、同步等。我使用的通道库就是一个很好的例子 : 在 c# 中添加对通道的支持相对容易,但是类似于 async- wait in Go 的东西需要更多的样板代码。
* **C# 的代码更利于扩展**:实际上,你可以通过添加自己的调度程序,等待器,甚至自己的任务类型来实现你想要的功能。你如果真的对性能很敏感,你可以写一些非常轻量级的 task 或者用一些预先调度的任务 (ValueTask<T> 就是一个很好的例子,它现在是 . net 的一部分 )。另一个即将到来的特性是对异步序列 (async streams) 的支持——它也是基于相同的 API 集 ( 尽管它需要对 c# 编译器进行更改 )。
* **Goroutines 更简单易学**:你几乎不需要学习什么特别的东西,"Go" 关键字 + 通道就可以帮你完成你想要的一切。相反的是,C# 中的 async-await 对于异步编程来说有点难,你需要了解 Task / Task<T>,Task.Run() 和取消的最低限度。现实生活中的异步编程意味着您了解调度、.configurewait (false) 方法、task 如何工作、异常如何处理、何时使用 ValueTask 等等。这比 Go 要复杂得多
* **Goroutines 不需要考虑“ async all the way ”的问题**:Async-await 意味着,如果你设计了一个调用链 (A 调用 B,B 调用 C …… Y 调用 Z) 如果 A 和 Z 都是异步方法,B …… Z 也都必须是异步方法,否则将无法工作 ( 同步的 Y 无法等待 Z,同步的 X 也无法等待 Y)。而在 Go 中就不存在这个问题,你可以在任何的函数中通过通道获取数据,无论如何,他们都是异步的。这实际上是一个很大的优势,因为你不需要提前计划什么是异步的,而且你还可以写一个 Query() 的方法,帮助你获取到结果,程序可以根据需要来实现异步或者同步的操作,作为方法作者的你,却不需要关注这个。
* **Go 中的异步代码,开销更小**:意味着任何潜在的异步 API 在 . net 中都必须是异步的,也就是说,您需要在那里创建更多的异步任务,分配更多的堆,等等。
* **这也解释了为什么没有必要在 Go 中 async-await**:因为任何函数支持异步等待 ( 通道 ), 可以同时开始,任何函数返回一个常规结果里面可以运行一些异步逻辑——它需要的是另一个 Goroutine 开始 , 传递消息到一个新的通道 , 通过这个通道 , 等待结果。这就是为什么 Go 中的大多数 API 看起来都是同步的,实际上它们是异步的。
总的来说,实现方式差异很明显,所以影响也是非常显著的。在后续的文章中,我会用 async-await-goroutines 编写更加健壮 / 真实的测试。
via: https://medium.com/@alexyakunin/go-vs-c-part-1-goroutines-vs-async-await-ac909c651c11
作者:Alex Yakunin 译者:JYSDeveloper 校对:polaris1119
本文由 GCTT 原创翻译,Go语言中文网 首发。也想加入译者行列,为开源做一些自己的贡献么?欢迎加入 GCTT!
翻译工作和译文发表仅用于学习和交流目的,翻译工作遵照 CC-BY-NC-SA 协议规定,如果我们的工作有侵犯到您的权益,请及时联系我们。
欢迎遵照 CC-BY-NC-SA 协议规定 转载,敬请在正文中标注并保留原文/译文链接和作者/译者等信息。
文章仅代表作者的知识和看法,如有不同观点,请楼下排队吐槽
有疑问加站长微信联系(非本文作者))