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

Posted InCerry

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了各个语言运行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可能有作弊,因为它的内存使用仍然没有增加。

Go vs Erlang

因为 云巴 系统对高并发、低延迟的需求,我们对各个语言、平台做了很多的调研比较工作。这自然就包括致力于开发高并发应用的 Go 和 Erlang。

并发

Erlang 的高并发通过轻量级 进程(process)实现,每一个进程都有独立的状态记录。

另外,使用 goroutine 要注意,goroutine 运行完毕后,占用的内存放回内存池备用,不会释放。

对于每一个任务都需要有独立状态的场景,Erlang 的 process 更有优势。

抢占式调度

Erlang 的任务调度器有一个 reduction budget 的概念。进程的任何操作都会造成预算消耗,包括 函数调用、调用 BIF、进程堆垃圾回收、ETS 读写、发消息(目标邮箱堆积的消息越多,消耗越大)。Erlang 的 正则表达式库 也被做了修改以支持 reductions。所以如果进程在长时间执行正则表达式匹配,也一样会消耗 reductions,也会被抢占。

Go 之前的调度器只在 syscall 发生时调度,优化后可以在任何函数调用时调度。但是要注意,如果在 goroutine 里写一个死循环,Go 的调度器不能有效抢占,同一个调度器的 其他 goroutine 会被挂起。

垃圾回收

像 Java 一样,Go 的垃圾回收是全局的,这意味着一旦垃圾回收被触发,所有的 goroutine 都会被暂停,造成一段时间的业务延迟。

Erlang 的垃圾回收是 进程 级别的,每一个进程都有自己独立的垃圾回收器,一个进程的垃圾回收被触发,不会造成其他进程被挂起。相对来说带来的业务延迟小。

错误处理

Erlang 的每一个进程都有 进程 ID (PID),同时也可以给进程注册名字,也就是说每一个进程都有独立的身份,可以有效的监控每一个进程的状态。进程异常退出时,可以捕捉到退出事件,并重启进程(参见 otp 的 supervisor/worker)。

Go 的 goroutine 没有身份识别,goroutine 的状态没办法监控。

动态反射

Erlang 动态语言的特点,使它天然支持 REPL,另外 Erlang 支持 remote shell,我们可以在 Erlang 运行时,连接到 remote shell 与任何一个进程交互。这些特性对一个需要长期运行的复杂系统的维护带来了极大的便利。开发阶段也能有一些便利。

Go 是静态语言,不支持 REPL。

静态编译

Erlang 是动态语言,有所有动态语言的所有缺点:

运行速度慢

不能做早期错误检查,需要依赖全覆盖单元测试

代码规模大了,给编写带来困扰

Erlang 现在也引入了 spec,对函数的参数返回值在编译时做类型检查,但是跟静态语言比起来效果差的很远。

不过正是因为是动态语言,Erlang 实现了运行时代码替换,这个特性对一个需要长时间运行的工业级产品,是一个非常重要的功能。

Go 是静态语言,运行速度快,编译时做严格的类型检查,可以避免很多隐患。

框架

Erlang 的 OTP 框架支持服务器端开发常见的几种模式(applications, supervisors, wokers),方便代码的组织。

Go 暂时没看到类似的框架。

第三方库支持

Go 是一个相对比较新的语言,虽说现在很多项目都开始支持 Go,但很多第三方库的成熟度暂时不如 Erlang。

总结

对于要求低延迟、高并发的后端服务,我们近期还是采用 Erlang 为主。但使用 Erlang 的过程中,Erlang 缺乏静态检查的手段,也是一个很麻烦的问题,目前的做法是要求大家都使用 IntelliJ IDEA 编写代码,可以通过 IDE 提前发现部分语言问题。

同时我们会持续关注 Go 的发展。

=====================================================

感谢 Coding 对本微信的支持。Coding.net 是一个面向开发者的云端开发平台,目前提供代码托管、运行空间、质量控制、项目管理等功能。

以上是关于各个语言运行100万个并发任务需要多少内存?的主要内容,如果未能解决你的问题,请参考以下文章

Go处理每分钟100万个请求

不是语言之争--Go vs Erlang

100万并发连接服务器笔记之Java Netty处理1M连接会怎么样

Go vs Erlang

Golang vs Erlang - 转

Go vs Erlang(Yunba.IO)