How Much Memory Do 1 Million Concurrent Tasks Use Across Different Languages?
This benchmark measures the peak memory consumption of one, ten‑thousand, one‑hundred‑thousand and one‑million concurrent tasks in Rust, Go, Java, C#, Node.js, Python and Elixir, revealing surprising differences such as Go using far more memory than Rust and .NET showing unusually low usage.
Introduction
The author created a set of programs that launch N concurrent tasks, each sleeping for ten seconds, then exit. The number of tasks is supplied via a command‑line argument. All implementations are available in a public GitHub repository.
Benchmark Implementations
Rust
Three variants were written: a traditional thread version and two async versions using tokio and async‑std. The thread version creates a Vec of handles, spawns a thread that sleeps for ten seconds, pushes the handle, and joins all handles after the loop.
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();
}The tokio async version creates a Vec of tasks, spawns each with task::spawn, sleeps asynchronously, and then awaits each task.
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();
}Go
Goroutines are launched inside a for loop, each sleeping for ten seconds, and a sync.WaitGroup is used to wait for completion.
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
Two versions were written: a classic thread version and a virtual‑thread version (preview in JDK 21). Both create a list of Thread objects, start them, and join after the loop.
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();
}The virtual‑thread version replaces new Thread with Thread.startVirtualThread, keeping the rest identical.
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#
Async/await is used with Task.Run to create tasks that delay for ten seconds, then Task.WhenAll waits for all.
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
const delay = util.promisify(setTimeout);
const tasks = [];
for (let i = 0; i < numTasks; i++) {
tasks.push(delay(10000));
}
await Promise.all(tasks);Python
async def perform_task():
await asyncio.sleep(10)
tasks = []
for _ in range(num_tasks):
task = asyncio.create_task(perform_task())
tasks.append(task)
await asyncio.gather(*tasks)Elixir
tasks = for _ <- 1..num_tasks do
Task.async(fn ->
:timer.sleep(10000)
end)
end
Task.await_many(tasks, :infinity)Test Environment
CPU: Intel Xeon E3‑1505M v6 @ 3.00 GHz
OS: Ubuntu 22.04 LTS
Rust 1.69, Go 1.18.1, Java OpenJDK 21‑ea, .NET 6.0.116, Node v12.22.9, Python 3.10.6, Elixir 1.12.2 (Erlang/OTP 24)
All programs were compiled/run in release mode with default settings.
Results
Minimum Memory (1 task)
Two groups emerge: native‑compiled Rust and Go binaries use very little memory, while managed runtimes (Java, .NET, Node, Python, Elixir) consume an order of magnitude more. Surprisingly, .NET shows the highest usage among the managed group.
10 000 Tasks
Java threads consume ~250 MiB, confirming the expectation that threads are memory‑hungry. Rust native threads remain lightweight, staying below many runtimes' idle memory. Go’s goroutine memory is about 50 % higher than Rust’s, contrary to the belief that goroutines are extremely cheap.
100 000 Tasks
Thread‑based benchmarks cannot be run at this scale on the author’s machine. Go loses its earlier advantage and consumes over six times the memory of the best Rust program, and is also outperformed by Python.
1 000 000 Tasks
Elixir crashes with a system‑limit error unless the Erlang VM is started with +P 1000000. C#’s memory usage rises but remains competitive, even slightly beating one Rust runtime. Go’s memory consumption grows dramatically, exceeding Rust by more than twelve times and Java by over two times. The tokio runtime still shows the lowest memory footprint among async solutions.
Conclusion
Running a large number of concurrent tasks can consume substantial memory even when the tasks perform no work. Languages with low‑overhead runtimes (Rust native threads, Rust async) stay efficient at scale, while others incur high per‑task costs that become prohibitive beyond a few hundred thousand tasks. The benchmark focuses solely on memory; latency and throughput are left for future work.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Architect's Guide
Dedicated to sharing programmer-architect skills—Java backend, system, microservice, and distributed architectures—to help you become a senior architect.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
