Skip to content

Instantly share code, notes, and snippets.

@neon-sunset
Last active September 9, 2024 12:45
Show Gist options
  • Save neon-sunset/8fcc31d6853ebcde3b45dc7a2af32127 to your computer and use it in GitHub Desktop.
Save neon-sunset/8fcc31d6853ebcde3b45dc7a2af32127 to your computer and use it in GitHub Desktop.
Наївне порівняння вартості асинхронних примітивів поміж C#, Go та Elixir

Дісклеймер

І горутини, і BEAM процеси не мають в своєму дизайні бути максимально легкими для короткоживучих операцій.
Окрім цього, і Go і BEAM мають т.зв. userspace goroutine/process preemption.

На відміну від них, .NET спирається на hill-climbing алогоритм та blocked worker detection які спавнять додаткові потоки тредпулу, які в свою чергу спираються на physical thread preemption та work-stealing для того щоб більша кількість work items (Task continuations) могла прогресувати у виконанні.

Імплементація Rust теж відрізняється тим що обробляє весь 1M фьючерів в скоупі однієї Task (тому що Task це coarse-grained примітив який в себе вміщає статично відомий розмір фьючерів. Cаме над Tasks оперують окремі worker threads в Tokio).

Це впливає на сценарії де той чи інший підхід матиме перевагу, в особливості стосовно scheduling fairness.

Цей експеримент сфокусований на кейс де ворклоад потребує масової логічної concurrency і вивчає особливості базового оверхеду для таких сценаріїв. Проте, даний підхід є наївним, ідіоматичні паттерни для досягнення мультиплексування такої кількості виконання логічних тредів між мовами будуть відрізнятись.

Фідбек стосовно імплементації та альтернативних кейсів заохочується

Сценарій

  • Заспавнити 1 мільйон процесів/горутин/тасок
  • Кожен з примітивів повинен дочекатись (одне з двох)
    • окремого таймера на 5с
    • спільного джерела яке очікує таймер на 5с
  • Дочекатись виконання процесів/горутин/тасок

Середовище

macOS 15.0 24A5331b arm64, M1 Pro 6P+2E
.NET SDK 9.0.100-rc.1.24414.7
Erlang/OTP 26 [erts-14.2.5] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1] [jit] [dtrace]
Elixir 1.17.2 (compiled with Erlang/OTP 26)
go version go1.23.0 darwin/arm64
cargo 1.80.1 (376290515 2024-07-16)
rustc 1.80.1 (3f5fd8dd4 2024-08-06)

Методологія

  • Скомпілювати наявним тулчейном в нативний додаток (за наявністю)
  • Обрати найкращий результат time ... з пʼяти послідовних спроб
  • Виміряти memory RSS під час очікування на таймер

Інтерпретація результатів

Memory RSS at wait - розмір памʼяті яку споживає процес під час очікування закінчення 5с паузи.

Executed in - загальний час на виконання від старту процесу до його виходу з кодом 0.

Usr time - ефективний час активної роботи CPU для виконання userspace коду. На multi-core системах максимальний usr time на секунду часу це 1s * CPU cores.

Sys time - ефективний час активної роботи CPU для виконання kernelspace коду (наприклад час проведений у кернел коді системних таймерів, виводу в консоль, тощо). Так само як usr time максимальне значення на секунду це 1s * CPU cores.

Таким чином, основні показники ефективності асинхронних примітивів будуть MEM + usr time, і потім executed in за умови що останній не відхиляється суттєво від 5c відмітки.

Окремий таймер

Go:
Зібрано go build tasks.go
Memory RSS at wait: 2.50 GB

time ./tasks 1000000 
spawning 1000000 tasks
done
________________________________________________________
Executed in    5.81 secs    fish           external
   usr time    3.18 secs   52.00 micros    3.18 secs
   sys time    0.74 secs  669.00 micros    0.74 secs

Elixir:
Зібрано elixirc tasks.ex
Memory RSS at wait: 2.75/3.31/4.00 GB (does not have stable value)

time elixir --erl "+P 1000001" -e Tasks.main 1000000 
spawning 1000000 processes
done

________________________________________________________
Executed in   10.14 secs    fish           external
   usr time   16.59 secs    0.07 millis   16.59 secs
   sys time   13.79 secs    1.03 millis   13.79 secs

C#:
Зібрано dotnet publish -o . (проект з темплейту dotnet new console --aot)
Memory RSS at wait: 272.8 MB

time ./Tasks 1000000 
spawning 1000000 tasks
done

________________________________________________________
Executed in    5.83 secs    fish           external
   usr time    3.99 secs    0.08 millis    3.99 secs
   sys time    0.24 secs    1.20 millis    0.24 secs

Rust:
Зібрано cargo build --release
Memory RSS at wait: 203.2 MB

time ./target/release/tasks 1000000
spawning 1000000 tasks
done

________________________________________________________
Executed in    5.48 secs      fish           external
   usr time  836.91 millis    0.09 millis  836.82 millis
   sys time   51.53 millis    1.49 millis   50.04 millis

Спільний таймер/канал/процес

Go:
Зібрано go build tasks-singlesource.go
Memory RSS at wait: 2.48 GB

time ./tasks-singlesource 1000000 
spawning 1000000 tasks
done

________________________________________________________
Executed in    5.29 secs    fish           external
   usr time    3.85 secs    0.07 millis    3.85 secs
   sys time    1.90 secs    1.14 millis    1.90 secs

Elixir:
Зібрано elixirc tasks-singlesource.ex
Memory RSS at wait: 3.41/3.72 GB (does not have stable value)

time elixir --erl "+P 1000001" -e TasksSingleSource.main 1000000 
spawning 1000000 processes
done

________________________________________________________
Executed in   11.26 secs    fish           external
   usr time   18.58 secs   39.00 micros   18.58 secs
   sys time   13.69 secs  476.00 micros   13.69 secs

C#:
Зібрано dotnet publish -o . (проект з темплейту dotnet new console --aot) Memory RSS at wait: 136.1 MB

time ./TasksSingleSource 1000000 
spawning 1000000 tasks
done

________________________________________________________
Executed in    5.26 secs    fish           external
   usr time    1.25 secs    0.10 millis    1.25 secs
   sys time    0.42 secs    1.19 millis    0.42 secs

Rust:
asynchronous sleep не є shareable в імплементації Tokio. Ідіоматичний Раст схильний до зменшеного шерінгу даних і більшої кількості комунікації через dedicated контейнери даних для цього. На відміну від Го ченел не є кор примітивом синхронізації в Расті тому ця entry лишається пустою. (автор програв borrow checker'у)

Tl;Dr

Параметр Go Elixir C# Rust
Окремий таймер
Execution time 5.81 сек 10.14 сек 5.83 сек 5.48 сек
Memory RSS at wait 2.50 GB 2.75-4.00 GB (нестабільно) 272.8 MB 203.2 MB
User time 3.18 сек 16.59 сек 3.99 сек 836.91 мсек
System time 0.74 сек 13.79 сек 0.24 сек 51.53 мсек
Спільний таймер/канал/процес
Execution time 5.29 сек 11.26 сек 5.26 сек -
Memory RSS at wait 2.48 GB 3.41-3.72 GB (нестабільно) 136.1 MB -
User time 3.85 сек 18.58 сек 1.25 сек -
System time 1.90 сек 13.69 сек 0.42 сек -

За ефективністю CPU:

  1. Rust
  2. C# shared delay
  3. Go per-goroutine sleep
  4. C# per-task delay
  5. Go shared wait by channel
  6. Elixir per-process sleep
  7. Elixir process wait

За ефективністю MEM:

  1. (Rust custom shared delay primitive???)
  2. C# shared delay
  3. Rust
  4. C# per-task delay
  5. Go (обидва варіанти)
  6. Elixir (обидва варіанти мають подібну варіацію споживання памʼяті)
[package]
name = "tasks"
version = "0.1.0"
edition = "2021"
[dependencies]
futures = "0.3.30"
tokio = { version = "1.40.0", features = ["full"] }
use futures::future::join_all;
use std::env;
use tokio::{
self,
time::{self, Duration},
};
#[tokio::main]
async fn main() {
let count: u32 = env::args().nth(1).unwrap().parse().unwrap();
println!("spawning {} tasks", count);
let futs = (0..count).map(|_| async {
time::sleep(Duration::from_secs(5)).await;
});
join_all(futs).await;
println!("done");
}
defmodule TasksSingleSource do
def main do
[process_count | _] = System.argv()
count = String.to_integer(process_count)
IO.puts "spawning #{count} processes"
timer_pid = spawn(fn ->
Process.sleep(5_000)
send(self(), :timer_done)
end)
1..count
|> Enum.map(fn _c ->
Task.async(fn ->
ref = Process.monitor(timer_pid)
receive do
{:DOWN, ^ref, :process, ^timer_pid, _reason} -> :ok
end
end)
end)
|> Task.await_many()
IO.puts "done"
end
end
package main
import (
"fmt"
"os"
"strconv"
"sync"
"time"
)
func main() {
if len(os.Args) < 2 {
fmt.Println("Please provide a count as an argument")
return
}
count, err := strconv.Atoi(os.Args[1])
if err != nil {
fmt.Println("Invalid count argument:", err)
return
}
fmt.Printf("spawning %d tasks\n", count)
var wg sync.WaitGroup
wg.Add(count)
ch := make(chan struct{})
go func() {
time.Sleep(5 * time.Second)
close(ch)
}()
for i := 0; i < count; i++ {
go func() {
<-ch
wg.Done()
}()
}
wg.Wait()
fmt.Println("done")
}
var count = int.Parse(args[0]);
Console.WriteLine($"spawning {count} tasks");
var tasks = Enumerable
.Range(0, count)
.Select(async _ => await Task.Delay(5_000));
await Task.WhenAll(tasks);
Console.WriteLine("done");
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PublishAot>true</PublishAot>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
</Project>
defmodule Tasks do
def main do
[process_count | _] = System.argv()
count = String.to_integer(process_count)
IO.puts "spawning #{count} processes"
1..count
|> Enum.map(fn _c ->
Task.async(fn ->
Process.sleep(5_000)
end)
end)
|> Task.await_many()
IO.puts "done"
end
end
package main
import (
"fmt"
"os"
"strconv"
"sync"
"time"
)
func main() {
if len(os.Args) < 2 {
fmt.Println("Please provide a count as an argument")
return
}
count, err := strconv.Atoi(os.Args[1])
if err != nil {
fmt.Println("Invalid count argument:", err)
return
}
fmt.Printf("spawning %d tasks\n", count)
var wg sync.WaitGroup
wg.Add(count)
for i := 0; i < count; i++ {
go func() {
time.Sleep(5 * time.Second)
wg.Done()
}()
}
wg.Wait()
fmt.Println("done")
}
var count = int.Parse(args[0]);
Console.WriteLine($"spawning {count} tasks");
var delay = Task.Delay(5_000);
var tasks = Enumerable
.Range(0, count)
.Select(async _ => await delay);
await Task.WhenAll(tasks);
Console.WriteLine("done");
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<RootNamespace>CSharp_SingleTimer</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PublishAot>true</PublishAot>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
</Project>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment