各个语言运行100万个并发任务需要多少内存?

这篇具有很好参考价值的文章主要介绍了各个语言运行100万个并发任务需要多少内存?。希望对大家有所帮助。如果存在错误或未考虑完全的地方,请大家不吝赐教,您也可以点击"举报违法"按钮提交疑问。

译者注:

原文链接:https://pkolaczk.github.io/memory-consumption-of-async/
Github项目地址:https://github.com/pkolaczk/async-runtimes-benchmarks

正文

在这篇博客文章中,我深入探讨了异步和多线程编程在内存消耗方面的比较,跨足了如Rust、Go、Java、C#、Python、Node.js 和 Elixir等流行语言。

不久前,我不得不对几个计算机程序进行性能比较,这些程序旨在处理大量的网络连接。我发现那些程序在内存消耗方面有巨大的差异,甚至超过20倍。有些程序在10000个连接中仅消耗了略高于100MB的内存,但另一些程序却达到了接近3GB。不幸的是,这些程序相当复杂,功能也不尽相同,因此很难直接进行比较并得出有意义的结论,因为这不是一个典型的苹果到苹果的比较。这促使我想出了创建一个综合性基准测试的想法。

基准测试

我使用各种编程语言创建了以下程序:

启动N个并发任务,每个任务等待10秒钟,然后在所有任务完成后程序就退出。任务的数量由命令行参数控制。

在ChatGPT的小小帮助下,我可以在几分钟内用各种编程语言编写出这样的程序,甚至包括那些我不是每天都在用的编程语言。为了方便起见,所有基准测试代码都可以在我的GitHub上找到。

Rust

我用Rust编写了3个程序。第一个程序使用了传统的线程。以下是它的核心部分:

let mut handles = Vec::new();
for _ in 0..num_threads {
    let handle = thread::spawn(|| {
        thread::sleep(Duration::from_secs(10));
    });
    handles.push(handle);
}
for handle in handles {
    handle.join().unwrap();
}

另外两个版本使用了async,一个使用tokio,另一个使用async-std。以下是使用tokio的版本的核心部分:

let mut tasks = Vec::new();
for _ in 0..num_tasks {
    tasks.push(task::spawn(async {
        time::sleep(Duration::from_secs(10)).await;
    }));
}
for task in tasks {
    task.await.unwrap();
}

async-std版本与此非常相似,因此我在这里就不再引用了。

Go

在Go语言中,goroutine是实现并发的基本构建块。我们不需要分开等待它们,而是使用WaitGroup来代替:

var wg sync.WaitGroup
for i := 0; i < numRoutines; i++ {
    wg.Add(1)
    go func() {
        defer wg.Done()
        time.Sleep(10 * time.Second)
    }()
}
wg.Wait()

Java

Java传统上使用线程,但JDK 21提供了虚拟线程的预览,这是一个类似于goroutine的概念。因此,我创建了两个版本的基准测试。我也很好奇Java线程与Rust线程的比较。

List<Thread> threads = new ArrayList<>();
for (int i = 0; i < numTasks; i++) {
    Thread thread = new Thread(() -> {
        try {
            Thread.sleep(Duration.ofSeconds(10));
        } catch (InterruptedException e) {
        }
    });
    thread.start();
    threads.add(thread);
}
for (Thread thread : threads) {
    thread.join();
}

下面是使用虚拟线程的版本。注意看它是多么的相似!几乎一模一样!

List<Thread> threads = new ArrayList<>();
for (int i = 0; i < numTasks; i++) {
    Thread thread = Thread.startVirtualThread(() -> {
        try {
            Thread.sleep(Duration.ofSeconds(10));
        } catch (InterruptedException e) {
        }
    });
    threads.add(thread);
}
for (Thread thread : threads) {
    thread.join();
}

C#

与Rust类似,C#对async/await也有一流的支持:

List<Task> tasks = new List<Task>();
for (int i = 0; i < numTasks; i++)
{
    Task task = Task.Run(async () =>
    {
        await Task.Delay(TimeSpan.FromSeconds(10));
    });
    tasks.Add(task);
}
await Task.WhenAll(tasks);

Node.JS

下面是 Node.JS:

const delay = util.promisify(setTimeout);
const tasks = [];

for (let i = 0; i < numTasks; i++) {
    tasks.push(delay(10000);
}

await Promise.all(tasks);

Python

还有Python 3.5版本中加入了async/await,所以可以这样写:

async def perform_task():
    await asyncio.sleep(10)


tasks = []

for task_id in range(num_tasks):
    task = asyncio.create_task(perform_task())
    tasks.append(task)

await asyncio.gather(*tasks)

Elixir

Elixir 也因其异步功能而闻名:

tasks =
    for _ <- 1..num_tasks do
        Task.async(fn ->
            :timer.sleep(10000)
        end)
    end

Task.await_many(tasks, :infinity)

测试环境

  • 硬件: Intel(R) Xeon(R) CPU E3-1505M v6 @ 3.00GHz
  • 操作系统: Ubuntu 22.04 LTS, Linux p5520 5.15.0-72-generic
  • Rust: 1.69
  • Go: 1.18.1
  • Java: OpenJDK “21-ea” build 21-ea+22-1890
  • .NET: 6.0.116
  • Node.JS: v12.22.9
  • Python: 3.10.6
  • Elixir: Erlang/OTP 24 erts-12.2.1, Elixir 1.12.2

所有程序在可用的情况下都使用发布模式(release mode)进行运行。其他选项保持为默认设置。

结果

最小内存占用

让我们从一些小的任务开始。因为某些运行时需要为自己分配一些内存,所以我们首先只启动一个任务。

图1:启动一个任务所需的峰值内存

我们可以看到,这些程序确实分为两组。Go和Rust程序,静态编译为本地可执行文件,需要很少的内存。其他在托管平台上运行或通过解释器消耗更多内存的程序,尽管在这种情况下Python表现得相当好。这两组之间的内存消耗差距大约有一个数量级。

让我感到惊讶的是,.NET某种程度上具有最差的内存占用,但我猜这可以通过某些设置进行调整。如果有任何技巧,请在评论中告诉我。在调试模式和发布模式之间,我没有看到太大的区别。

10k 任务

图2:启动10,000个任务所需的峰值内存

这里有一些意外发现!大家可能都预计线程将成为这个基准测试的大输家。这对于Java线程确实如此,实际上它们消耗了将近250MB的内存。但是从Rust中使用的原生Linux线程似乎足够轻量级,在10000个线程时,内存消耗仍然低于许多其他运行时的空闲内存消耗。异步任务或虚拟(绿色)线程可能比原生线程更轻,但我们在只有10000个任务时看不到这种优势。我们需要更多的任务。

另一个意外之处是Go。Goroutines应该非常轻量,但实际上,它们消耗的内存超过了Rust线程所需的50%。坦率地说,我本以为Go的优势会更大。因此,我认为在10000个并发任务中,线程仍然是相当有竞争力的替代方案。Linux内核在这方面肯定做得很好。

Go也失去了它在上一个基准测试中相对于Rust异步所占据的微小优势,现在它比最好的Rust程序消耗的内存多出6倍以上。它还被Python超越。

最后一个意外之处是,在10000个任务时,.NET的内存消耗并没有从空闲内存使用中显著增加。可能它只是使用了预分配的内存。或者它的空闲内存使用如此高,10000个任务太少以至于不重要。

100k 任务

我无法在我的系统上启动100,000个线程,所以线程基准测试必须被排除。可能这可以通过某种方式调整系统设置来实现,但尝试了一个小时后,我放弃了。所以在100,000个任务时,你可能不想使用线程。

在这一点上,Go程序不仅被Rust击败,还被Java、C#和Node.JS击败。

而Linux .NET可能有作弊,因为它的内存使用仍然没有增加。😉 我不得不仔细检查一下是否确实启动了正确数量的任务,果然,它确实做到了。而且它在大约10秒后仍然可以退出,所以它没有阻塞主循环。神奇!.NET干得好。

100万任务

现在我们来试试极限场景。

在100万个任务时,Elixir放弃了,提示 ** (SystemLimitError) a system limit has been reached。编辑:有些评论者指出我可以增加进程限制。在elixir启动参数中添加--erl '+P 1000000'后,它运行得很好。

图4:启动100万个任务所需的峰值内存

终于我们看到了C#程序内存消耗的增加。但它仍然非常具有竞争力。它甚至成功地稍稍击败了Rust的一个运行时!

Go与其他程序之间的差距扩大了。现在Go在胜利者面前输掉了超过12倍。它还输给了Java 2倍以上,这与人们普遍认为JVM是内存大户,而Go轻量的观念相矛盾。

Rust的tokio依然无可匹敌。在看过它在100k任务下的表现后,这并不令人惊讶。

最后的话

正如我们观察到的,大量的并发任务可能会消耗大量的内存,即使它们不执行复杂的操作。不同的编程语言运行时具有不同的取舍,有些在少量任务中表现轻量和高效,但在数十万个任务中的扩展性表现差。相反,其他一些具有高初始开销的运行时可以毫不费力地应对高负载。值得注意的是,并非所有运行时都能在默认设置下处理大量的并发任务。

这个比较仅关注内存消耗,而任务启动时间和通信速度等其他因素同样重要。值得注意的是,在100万个任务时,我观察到启动任务的开销变得明显,大多数程序需要超过12秒才能完成。敬请期待即将到来的基准测试,我将深入探讨其他方面。

评论区

评论区也有很多大佬给出了建议,比较有意思,所以也翻译了放在下方

JB-Dev

在C#实现中,你不需要调用Task.Run(...)。这会增加第二个任务延续的开销。

在没有额外开销的情况下,我观察到1M基准测试的内存使用量减少了一半以上,从428MB降到183MB(代码在这里:https://github.com/J-Bax/CS...

例如,不要使用Task.Run(...)而是这样做:

tasks.Add(Task.Delay(TimeSpan.FromMilliseconds(delayMillisec)));

Christoph Berger

Go的结果并不特别令人惊讶。

每个goroutine开始时的预分配栈为2KiB,所以一百万个goroutine消耗大约2GB(2,048 * 1,000,000字节)。

这与我使用go build&& /usr/bin/time -l ./goroutinememorybenchmark运行测试代码时得到的数字非常接近:

2044968960的最大常驻集大小

(我不确定图中的2,658 GB是如何测量出来的,但数量级是相同的。)

毫无疑问,为每个goroutine预分配一个栈使Go在与那些在真正需要时才分配任何线程本地内存的并发系统的语言相比处于劣势。 (附注:在这特定的上下文中,我将“线程”作为绿色或虚拟线程和goroutine的同义词。)

我想,在线程做有实质性工作的测试中,各种语言之间的差异可能会大大缩小。

D. Christoph Berger

我不完全了解后面发生了什么,但对于1000个Goroutine,Go只消耗每个goroutine约300字节。只有当它增加到每个3000 Goroutine时,它才开始每个Goroutine使用2KB。

Berger D.

你是如何得到这些测量结果的?

不幸的是,我无法复制你的发现。

为了获得较小数量的goroutine的更准确结果,我决定在每次测试运行结束后读取runtime.MemStats.StackInuse

结果:

所有1000个goroutines完成。
StackInuse:2,359,296(每个goroutine 2KB)
所有3000个goroutines完成。
StackInuse:6,586,368(每个goroutine 2KB)
所有10000个goroutines完成。
StackInuse:21,037,056(每个goroutine 2KB)
所有100000个goroutines完成。
StackInuse:205,127,680(每个goroutine 2KB)
所有1000000个goroutines完成。
StackInuse:2,048,622,592(每个goroutine 2KB)

Witek

对于Elixir / Erlang,默认进程限制为32k pids。可以通过解释器标志将其增加到20亿。例如:erl +P 4000000,我编写了一个小的Erlang程序来做你所做的事情(但确保在循环中不分配不必要的内存),并且在1百万个进程中峰值RSS使用量为2.7GiB。然而,这仍然非常人工和合成。Erlang默认为每个进程分配额外的堆,因为在现实生活中,您实际上会在进程中执行一些操作并需要一点内存,因此预先分配比以后分配更快。如果您真的想在这个愚蠢的基准测试中减少内存使用量,您可以传递选项以spawn_opt,或使用自定义+h选项启动解释器,例如。+h 10,或者+hms10(默认值为〜356)。这将将峰值RSS使用率从2.7 GiB降低到1.1 GiB。

译者注

本文比较了各个语言开启N个任务需要多少内存,如作者所说,这是一个很难去比较的东西,可以看到作者也已经尽力了,虽然不是那么严谨,但是也值得一看。

代码上有一些小问题,比如C#用的还没有用上.NET7.0版本,代码中Task.Run()完全是多余的,相较于其它语言多跑了两倍异步任务,修改这些后内存可以降低五分之三,另外配置用的默认配置,并没有发挥各个语言各自最大的优势,比如C#一开始默认预分配了100MB内存,另外Java等也没有设置对应的GCHeap参数来控制内存

总得来说C#的表现是非常亮眼的,在本次的100万任务测试中排名第二,仅仅落后于使用tokio的Rust,可见C#在高并发多任务等网络编程上还是有很大的优势。

.NET性能优化交流群

相信大家在开发中经常会遇到一些性能问题,苦于没有有效的工具去发现性能瓶颈,或者是发现瓶颈以后不知道该如何优化。之前一直有读者朋友询问有没有技术交流群,但是由于各种原因一直都没创建,现在很高兴的在这里宣布,我创建了一个专门交流.NET性能优化经验的群组,主题包括但不限于:

  • 如何找到.NET性能瓶颈,如使用APM、dotnet tools等工具
  • .NET框架底层原理的实现,如垃圾回收器、JIT等等
  • 如何编写高性能的.NET代码,哪些地方存在性能陷阱

希望能有更多志同道合朋友加入,分享一些工作中遇到的.NET性能问题和宝贵的性能分析优化经验。目前一群已满,现在开放二群。

如果提示已经达到200人,可以加我微信,我拉你进群: lishi-wk

另外也创建了QQ群,群号: 264167610,欢迎大家加入。文章来源地址https://www.toymoban.com/news/detail-466803.html

到了这里,关于各个语言运行100万个并发任务需要多少内存?的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处: 如若内容造成侵权/违法违规/事实不符,请点击违法举报进行投诉反馈,一经查实,立即删除!

领支付宝红包 赞助服务器费用

相关文章

  • 在 Swift 中使用 async let 并发运行后台任务

    Async/await 语法是在 Swift 5.5 引入的,在 WWDC 2021中的 Meet async/await in Swift 对齐进行了介绍。它是编写异步代码的一种更可读的方式,比调度队列和回调函数更容易理解。Async/await 语法与其他编程语言(如 C# 或 JavaScript)中使用的语法类似。使用 \\\"async let \\\"是为了并行的运行多个后

    2024年02月11日
    浏览(45)
  • Kotlin协程runBlocking并发launch,Semaphore同步1个launch任务运行

              需要注意,由于Kotlin与Java语言特性的细微差异,虽然同为Semaphore,上述代码如果引入的是 java.util.concurrent.Semaphore ,功能也能正常完成,但运行出来的结果会有小差异。Java版Semaphore会使某条线程较长时间独占CPU轮片,然后再让渡出去CPU,输出的表现就是A或B或C一

    2024年02月11日
    浏览(38)
  • 算法通关村第十五关——从10亿数字中寻找最小的100万个数字

    本题有三种常用的方法,一种是先排序所有元素,然后取出前100万个数,该方法的时间复杂度为O(nlogn)。很明显对于10亿级别的数据,这么做时间和空间代价太高。 第二种方式是采用选择排序的方式,首先遍历10亿个数字找最小,然后再遍历一次找第二小,然后再一次找第三小

    2024年02月11日
    浏览(45)
  • C语言程序运行需要的两大环境《C语言进阶》

    目录  程序的翻译环境和执行环境 翻译环境分为两部分,编译+链接 第一步:预编译(预处理) 第二步,编译 第三步:汇编 关于运行环境分为四点: 关于链接库 在 ANSI C(标准C) 的任何一种实现中,存在两个不同的环境。 *第1种是翻译环境。 在这个环境中源代码被转换为可

    2024年02月16日
    浏览(39)
  • 【AI实战】大语言模型(LLM)有多强?还需要做传统NLP任务吗(分词、词性标注、NER、情感分类、知识图谱、多伦对话管理等)

    大语言模型(LLM)是指使用大量文本数据训练的深度学习模型,可以生成自然语言文本或理解语言文本的含义。大语言模型可以处理多种自然语言任务,如文本分类、问答、对话等,是通向人工智能的一条重要途径。来自百度百科 发展历史 2020年9月,OpenAI授权微软使用GPT-3模

    2024年02月10日
    浏览(42)
  • 使用C/C++举例说明使用宏定义时需要注意的各个点

    好的,下面我将分别举例说明使用宏定义时需要注意的各个点: (a)宏名和替换文本之间用空格分开: #define MAX_VALUE 100 // 正确的宏定义,MAX_VALUE是宏名,100是替换文本 不要写成等号连接的形式,否则会导致编译错误: #define MAX_VALUE=100 // 错误的宏定义,不应使用等号连接宏

    2024年03月21日
    浏览(59)
  • 每块硬盘最多可以有几个扩展分区?各个扩展分区最多可以有多少个逻辑驱动器?请高手告知,谢谢!

    不同系统,不同分区方案,数量也有不同的。 Linux: 主分区最多4个 逻辑分区: SCSI 最多 16 个 IDE 最多 63 个 传统的分区方案(称为MBR分区方案)是将分区信息保存到磁盘的第一个扇区(MBR扇区)中的64个字节中,每个分区项占用16个字节,这16个字节中存有活动状态标志、文件系统标

    2023年04月14日
    浏览(47)
  • 做一个网站需要多少个技术人员?

    作为互联网从业者,这么多年来经常会碰到一个灵魂拷问,那就是“为什么一个网站需要那么多技术人员?”,尤其是提问者如果再追问一下“听说几个相关专业的学生一个课程的作业就是开发一个网站或者app,那为什么现在主流的网站或者app背后的公司,动辄就有上万人的

    2024年02月01日
    浏览(85)
  • 开发一个小程序商城需要多少钱?

    开发一个小程序商城需要多少钱?小程序商城的制作流程是什么?今天 CRMEB 就和大家来聊一聊开发小程序商城的方法以及费用,感兴趣的朋友赶紧来看一看! 方法一:源码开发(4 万左右) 开发小程序商城,首先我们要组织聘请完整专业的开发团队。其中要包含项目经理、

    2024年02月09日
    浏览(46)
  • 蜘蛛池搭建需要多少域名?全面解析!

    蜘蛛池是指为搜索引擎爬虫提供优质、可靠的页面,从而提高网站的收录和排名。在蜘蛛池搭建过程中,域名数量是一个非常重要的问题。那么,蜘蛛池搭建需要多少域名呢?本文将对这个问题进行全面解析。   首先,我们需要了解什么是蜘蛛池。蜘蛛池是一种专门用来托管

    2024年02月04日
    浏览(36)

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

博客赞助

微信扫一扫打赏

请作者喝杯咖啡吧~博客赞助

支付宝扫一扫领取红包,优惠每天领

二维码1

领取红包

二维码2

领红包