暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

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

云原生数据库 2023-05-25
162

在这篇文章中,我对异步和多线程编程在Rust、Go、Java、C#、Python、Node.js和Elixir等流行语言中的内存消耗进行了比较。

不久前,我需要比较一些处理大量网络连接的计算机程序的性能。我发现这些程序的内存消耗差异巨大,甚至超过了20倍。有些程序仅消耗100 MB左右的内存,而其他一些在处理1万个连接时却达到了接近3 GB的内存消耗。不幸的是,这些程序非常复杂,而且在功能上也有所不同,因此很难直接进行比较并得出有意义的结论,因为这不是一个公平的比较。这促使我想到创建一个合成基准测试。

基准测试
我在各种编程语言中创建了以下程序:
让我们启动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();
    }
    另外两个版本使用了异步编程,一个使用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 提供了虚拟线程的预览,这是与 goroutines 类似的概念。因此,我创建了基准测试的两个变体。我也很好奇 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#
            C# 与 Rust 类似,对 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
                    所有程序均在可用的发布模式下运行。其他选项保持默认设置。

                    结果 
                    最小占用内存 
                    让我们从一些小的测试开始。由于一些运行时需要一些内存供自身使用,我们先只启动一个任务。
                    图1:启动一个任务所需的峰值内存 

                    我们可以看到,程序明显分为两组。

                    使用静态编译为本机二进制的Go和Rust程序需要非常少的内存。而在托管平台或解释器上运行的其他程序消耗更多的内存,尽管在这种情况下Python表现得非常好。这两组程序之间的内存消耗差异大约是一个数量级。
                    令我惊讶的是,.NET在内存占用方面表现最差,但我猜这可能可以通过一些设置进行调优。如果有任何窍门,请在评论中告诉我。我没有看到调试模式和发布模式之间有太大的区别。
                    1000个任务
                    图2:启动10,000个任务所需的峰值内存 
                    这里有一些令人惊讶的结果!大家可能都预计到了线程在这个基准测试中表现不佳。对于Java线程来说,情况确实如此,它们实际上消耗了将近250 MB的内存。但是在Rust中使用的本机Linux线程似乎足够轻量级,在10,000个线程的情况下,内存消耗仍然低于许多其他运行时的空闲内存消耗。异步任务或虚拟(绿色)线程可能比本机线程更轻量级,但是在只有10,000个任务的情况下,我们不会看到这种优势。我们需要更多的任务。
                    另一个令人惊讶的结果是Go。Goroutines应该非常轻量级,但实际上它们消耗的内存超过了Rust线程所需内存的50%。老实说,我预计Go会有更大的优势。因此,我得出结论,在10,000个并发任务下,线程仍然是相当具有竞争力的选择。Linux内核在这方面肯定做对了一些事情。
                    Go在之前的基准测试中所拥有的微小优势也已经消失,现在它的内存消耗比最好的Rust程序高出6倍以上。它还被Python超越。
                    最后一个令人惊讶的是,当任务数达到10,000个时,.NET的内存消耗并没有显著增加。可能它只是使用了预分配的内存,或者它的空闲内存使用量非常高,10,000个任务对它来说并不重要。
                    10万个任务
                    我无法在我的系统上启动100,000个线程,所以线程的基准测试必须被排除在外。可能可以通过更改系统设置来进行一些调整,但经过尝试了一个小时后,我放弃了。因此,在100,000个任务下,你可能不想使用线程。
                    图3:启动100,000个任务所需的峰值内存 
                    在这一点上,Go程序不仅被Rust超越,还被Java、C#和Node.js超越。
                    而Linux下的.NET可能有点作弊,因为它的内存使用量仍然没有增加。;) 我不得不再次核实它是否确实启动了正确数量的任务,但事实上确实如此。它仍然在大约10秒后退出,因此不会阻塞主循环。神奇!干得好,.NET。
                    100万个任务 
                    现在让我们来进行极限测试。
                    在100万个任务下,Elixir因为** (SystemLimitError) 达到系统限制而放弃了。其他语言仍然坚持下来。
                    图4:启动100万个任务所需的峰值内存 
                    最后,我们看到了C#程序内存消耗的增加。但它仍然非常有竞争力。它甚至成功略微超过了一个Rust运行时!
                    Go和其他语言之间的差距增大了。现在,Go相对于获胜者的差距超过12倍。它也比Java多花费了2倍的内存,这与JVM是一个内存贪婪的普遍看法和Go是轻量级的观点相矛盾。
                    Rust的tokio保持了无敌的地位。这并不令人意外,毕竟我们在10万个任务时已经见识过它的表现。


                    总结 
                    正如我们观察到的,大量并发任务可能会消耗大量内存,即使这些任务并不执行复杂的操作。不同的语言运行时在权衡上存在差异,有些在处理少量任务时轻量高效,但在处理数十万任务时扩展性不佳。相反,其他具有较高初始开销的运行时可以轻松处理高负载。需要注意的是,并非所有运行时都能在默认设置下处理非常大量的并发任务。
                    本次比较仅关注内存消耗,而其他因素如任务启动时间和通信速度同样重要。值得注意的是,在100万个任务下,我观察到任务启动的开销变得明显,大多数程序需要超过12秒才能完成。






                    文章转载自云原生数据库,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

                    评论