Go 与 C# 对比 第一篇: Goroutines 与 Async-Await

Go 与 C# 对比 第一篇: Goroutines 与 Async-Await

我将写一个系列的文章,来对比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代码如下:

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()
}

C# 代码:

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,现在可以通过Nuget来获取它,目前的版本是0.1
  • 为了公平起见,C#中除了通道的测试外,我还用了一个异步任务的测试,代码里面,每个task等待他的"input"task, 在得到的数字上自增1,并返回自增后的结果。
  • C#有一个预热的功能,Go没有,预热的逻辑会导致任何小的函数第一次执行的时候,都会花费更长的时间。

原始结果对比:

  • 第一次执行,Go和C#的时间基本相同。
  • 第二次 Go快了很多,大概提升了3.4倍,C#没有执行第二次,因为他的速度始终是一样的。
  • 基于任务版本的C#代码,仍然是Go第二次执行的两倍时长。

所以为啥Go第二次执行这么快嘞? 解释起来很简单,当你启动goroutine的时候,Go需要分配8K的堆内存给他,而这些内存可以重用,所以第二次的时候,不需要分配更多的内存给他,证明图如下:

Memory Detail

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 编写更加健壮/真实的测试。

翻译

原文链接:Go vs C#, part 1: Goroutines vs Async-Await

译者:JYSDeveloper

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,236评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,867评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,715评论 0 340
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,899评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,895评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,733评论 1 283
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,085评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,722评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,025评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,696评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,816评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,447评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,057评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,009评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,254评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,204评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,561评论 2 343

推荐阅读更多精彩内容