Parallel work and a console do not mix on their own: every thread that writes to the console writes over the others. ConsoleMonster gives the screen to one thread and lets every other thread send it updates, so a live view stays whole however many workers report to it, and output that is not a terminal gets whole lines in order. The guarantees table at the end names the test behind each concurrency guarantee.
Twelve threads, one console
Each worker writes its progress with Write-Host as it goes. The pieces of different workers land in each other's lines.
web09 starting ... done web10 swapping ... done ... done web01 swapping ... done ... done web11 startingweb04 starting ... done ... done web05 swapping ... done web08 starting ... done ... done web10 startingweb12 swapping ... done web07 starting ... done ... done web12 starting ... done ... done ... done ... done web01 starting ... done ... done ... done ... done web05 starting ... done ... done
# Twelve servers patched in parallel, each writing its progress straight to the console (PowerShell 7).
$servers = 1..12 | ForEach-Object { 'web{0:d2}' -f $_ }
$servers | ForEach-Object -ThrottleLimit 12 -Parallel {
foreach ($step in 'copying', 'stopping', 'swapping', 'starting') {
Write-Host "$_ $step" -NoNewline
Start-Sleep -Milliseconds (Get-Random -Minimum 40 -Maximum 240)
Write-Host ' ... done'
}
}// Twelve servers patched in parallel, each writing its progress straight to the console.
string[] servers = Enumerable.Range(1, 12).Select(n => $"web{n:00}").ToArray();
string[] steps = { "copying", "stopping", "swapping", "starting" };
await Parallel.ForEachAsync(servers, new ParallelOptions { MaxDegreeOfParallelism = 12 }, async (server, token) => {
foreach (string step in steps) {
Console.Write($"{server} {step}");
await Task.Delay(Random.Shared.Next(40, 240), token);
Console.WriteLine(" ... done");
}
});Full program
// Twelve servers patched in parallel, each writing its progress straight to the console.
string[] servers = Enumerable.Range(1, 12).Select(n => $"web{n:00}").ToArray();
string[] steps = { "copying", "stopping", "swapping", "starting" };
await Parallel.ForEachAsync(servers, new ParallelOptions { MaxDegreeOfParallelism = 12 }, async (server, token) => {
foreach (string step in steps) {
Console.Write($"{server} {step}");
await Task.Delay(Random.Shared.Next(40, 240), token);
Console.WriteLine(" ... done");
}
});The recording ran the PowerShell script. This C# program does the same with the library and compiles as shown; its timings are its own.
The same work through a dashboard
Each runspace updates its own lane by name and Write-Host goes to the log panel. The session draws the screen about 15 times a second from one thread.
Patching the web farm 00:00:00 ╭─ Servers ─────────────────────────────────────────────────────────────────────────── 12 at once ─╮ │ ✓ web01 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100% ✓4 ✗0 ▴0 done in 585ms │ │ ⠼ web02 ━━━━━━━━━━━━━━━━━━━━━━━━──────── 75% ✓3 ✗0 ▴0 starting │ │ ⠴ web03 ━━━━━━━━━━━━━━━━━━━━━━━━──────── 75% ✓3 ✗0 ▴0 starting │ │ ✓ web04 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100% ✓4 ✗0 ▴0 done in 687ms │ │ ✓ web05 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100% ✓4 ✗0 ▴0 done in 838ms │ │ ⠇ web06 ━━━━━━━━━━━━━━━━━━━━━━━━──────── 75% ✓3 ✗0 ▴0 starting │ │ ⠏ web07 ━━━━━━━━━━━━━━━━━━━━━━━━──────── 75% ✓3 ✗0 ▴0 starting │ │ ⠋ web08 ━━━━━━━━━━━━━━━━━━━━━━━━──────── 75% ✓3 ✗0 ▴0 starting │ │ ✓ web09 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100% ✓4 ✗0 ▴0 done in 644ms │ │ ✓ web10 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100% ✓4 ✗0 ▴0 done in 747ms │ │ ⠸ web11 ━━━━━━━━━━━━━━━━━━━━━━━━──────── 75% ✓3 ✗0 ▴0 starting │ │ ⠼ web12 ━━━━━━━━━━━━━━━━━━━━━━━━──────── 75% ✓3 ✗0 ▴0 starting │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─ Log ────────────────────────────────────────────────────────────────────────────────────────────╮ │ 00:00 web04 done │ │ 00:00 web10 done │ │ 00:00 web05 done │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ ctrl+c stop
# The same twelve servers, each runspace updating its own lane; Write-Host from any of them goes to the log panel.
$servers = 1..12 | ForEach-Object { 'web{0:d2}' -f $_ }
$dashboard = New-ConsoleDashboard -Title 'Patching the web farm' {
New-ConsolePanel -Title 'Servers' -Caption '12 at once' { New-ConsoleLanes -Name servers -Lane $servers -Total 4 }
New-ConsolePanel -Title 'Log' -Fill { New-ConsoleLog }
}
$servers | Start-ConsoleDashboard $dashboard -Parallel -ThrottleLimit 12 {
param($Server)
foreach ($step in 'copying', 'stopping', 'swapping', 'starting') {
Update-ConsoleLane -Name servers -Key $Server -Status Running -Detail $step
Start-Sleep -Milliseconds (Get-Random -Minimum 40 -Maximum 240)
Update-ConsoleLane -Name servers -Key $Server -Result Pass
}
Write-Host "$Server done"
Update-ConsoleLane -Name servers -Key $Server -Complete
}Full program
Import-Module ConsoleMonster
# The same twelve servers, each runspace updating its own lane; Write-Host from any of them goes to the log panel.
$servers = 1..12 | ForEach-Object { 'web{0:d2}' -f $_ }
$dashboard = New-ConsoleDashboard -Title 'Patching the web farm' {
New-ConsolePanel -Title 'Servers' -Caption '12 at once' { New-ConsoleLanes -Name servers -Lane $servers -Total 4 }
New-ConsolePanel -Title 'Log' -Fill { New-ConsoleLog }
}
$servers | Start-ConsoleDashboard $dashboard -Parallel -ThrottleLimit 12 {
param($Server)
foreach ($step in 'copying', 'stopping', 'swapping', 'starting') {
Update-ConsoleLane -Name servers -Key $Server -Status Running -Detail $step
Start-Sleep -Milliseconds (Get-Random -Minimum 40 -Maximum 240)
Update-ConsoleLane -Name servers -Key $Server -Result Pass
}
Write-Host "$Server done"
Update-ConsoleLane -Name servers -Key $Server -Complete
}// The same twelve servers, each worker updating its own lane through a handle; Console.WriteLine from any of them
// goes to the log panel.
string[] servers = Enumerable.Range(1, 12).Select(n => $"web{n:00}").ToArray();
string[] steps = { "copying", "stopping", "swapping", "starting" };
Dashboard dashboard = new DashboardBuilder("Patching the web farm")
.Panel("Servers", new Lanes(), out Lanes lanes, caption: "12 at once")
.Panel("Log", new LogRegion(), fill: true)
.BuildDashboard();
foreach (string server in servers) {
lanes.Add(server, server, steps.Length);
}
using (LiveSession session = new LiveSession(dashboard)) {
await session.RunAsync(async (s, cancel) => {
LanesHandle handle = lanes.Handle(s);
try {
await Parallel.ForEachAsync(servers, new ParallelOptions { MaxDegreeOfParallelism = 12, CancellationToken = cancel }, async (server, token) => {
foreach (string step in steps) {
handle.Start(server, step);
await Task.Delay(Random.Shared.Next(40, 240), token);
handle.Advance(server, StatusKind.Pass);
}
Console.WriteLine(server + " done");
handle.Complete(server);
});
} catch (OperationCanceledException) {
// Ctrl+C: the session has already stopped the view; the workers just stop.
}
});
}Full program
using ConsoleMonster.Building;
using ConsoleMonster.Live;
using ConsoleMonster.Styling;
using ConsoleMonster.Widgets;
// The same twelve servers, each worker updating its own lane through a handle; Console.WriteLine from any of them
// goes to the log panel.
string[] servers = Enumerable.Range(1, 12).Select(n => $"web{n:00}").ToArray();
string[] steps = { "copying", "stopping", "swapping", "starting" };
Dashboard dashboard = new DashboardBuilder("Patching the web farm")
.Panel("Servers", new Lanes(), out Lanes lanes, caption: "12 at once")
.Panel("Log", new LogRegion(), fill: true)
.BuildDashboard();
foreach (string server in servers) {
lanes.Add(server, server, steps.Length);
}
using (LiveSession session = new LiveSession(dashboard)) {
await session.RunAsync(async (s, cancel) => {
LanesHandle handle = lanes.Handle(s);
try {
await Parallel.ForEachAsync(servers, new ParallelOptions { MaxDegreeOfParallelism = 12, CancellationToken = cancel }, async (server, token) => {
foreach (string step in steps) {
handle.Start(server, step);
await Task.Delay(Random.Shared.Next(40, 240), token);
handle.Advance(server, StatusKind.Pass);
}
Console.WriteLine(server + " done");
handle.Complete(server);
});
} catch (OperationCanceledException) {
// Ctrl+C: the session has already stopped the view; the workers just stop.
}
});
}The recording ran the PowerShell script. This C# program does the same with the library and compiles as shown; its timings are its own.
Run it
Install-Module ConsoleMonster -Scope CurrentUser
./threads-dashboard.ps1See also: Start-ConsoleDashboard
The same script in CI
With CI detected, the same dashboard writes a whole line for each lane start and finish and each log entry instead of redrawing, in the order the updates were applied, then prints its final state.
[00:00] web04 started [00:00] web02 done [00:00] web02 PASS: 4 passed, 0 failed, 0 warnings in 706ms [00:00] web12 started [00:00] web10 started [00:00] web09 started [00:00] web07 done [00:00] web07 PASS: 4 passed, 0 failed, 0 warnings in 692ms [00:00] web01 started [00:00] web06 started [00:00] web05 done [00:00] web05 PASS: 4 passed, 0 failed, 0 warnings in 734ms [00:00] web08 started [00:00] web09 done [00:00] web09 PASS: 4 passed, 0 failed, 0 warnings in 720ms [00:00] web03 started [00:00] web01 done [00:00] web01 PASS: 4 passed, 0 failed, 0 warnings in 801ms [00:00] web08 done [00:00] web06 done [00:00] web08 PASS: 4 passed, 0 failed, 0 warnings in 787ms [00:00] web06 PASS: 4 passed, 0 failed, 0 warnings in 815ms [00:00] web03 done [00:00] web03 PASS: 4 passed, 0 failed, 0 warnings in 854ms [00:00] web04 started [00:01] web12 done [00:01] web12 PASS: 4 passed, 0 failed, 0 warnings in 835ms [00:01] web10 done [00:01] web10 PASS: 4 passed, 0 failed, 0 warnings in 866ms
# The same twelve servers, each runspace updating its own lane; Write-Host from any of them goes to the log panel.
$servers = 1..12 | ForEach-Object { 'web{0:d2}' -f $_ }
$dashboard = New-ConsoleDashboard -Title 'Patching the web farm' {
New-ConsolePanel -Title 'Servers' -Caption '12 at once' { New-ConsoleLanes -Name servers -Lane $servers -Total 4 }
New-ConsolePanel -Title 'Log' -Fill { New-ConsoleLog }
}
$servers | Start-ConsoleDashboard $dashboard -Parallel -ThrottleLimit 12 {
param($Server)
foreach ($step in 'copying', 'stopping', 'swapping', 'starting') {
Update-ConsoleLane -Name servers -Key $Server -Status Running -Detail $step
Start-Sleep -Milliseconds (Get-Random -Minimum 40 -Maximum 240)
Update-ConsoleLane -Name servers -Key $Server -Result Pass
}
Write-Host "$Server done"
Update-ConsoleLane -Name servers -Key $Server -Complete
}Full program
Import-Module ConsoleMonster
# The same twelve servers, each runspace updating its own lane; Write-Host from any of them goes to the log panel.
$servers = 1..12 | ForEach-Object { 'web{0:d2}' -f $_ }
$dashboard = New-ConsoleDashboard -Title 'Patching the web farm' {
New-ConsolePanel -Title 'Servers' -Caption '12 at once' { New-ConsoleLanes -Name servers -Lane $servers -Total 4 }
New-ConsolePanel -Title 'Log' -Fill { New-ConsoleLog }
}
$servers | Start-ConsoleDashboard $dashboard -Parallel -ThrottleLimit 12 {
param($Server)
foreach ($step in 'copying', 'stopping', 'swapping', 'starting') {
Update-ConsoleLane -Name servers -Key $Server -Status Running -Detail $step
Start-Sleep -Milliseconds (Get-Random -Minimum 40 -Maximum 240)
Update-ConsoleLane -Name servers -Key $Server -Result Pass
}
Write-Host "$Server done"
Update-ConsoleLane -Name servers -Key $Server -Complete
}How it works
- One thread draws. A live session starts its own render thread. It applies updates to the widgets, composes the frame and writes only the cells that changed. Your work runs on your threads and never touches the screen.
- Any thread posts. Handles (
lanes.Handle(session),table.Handle(session)),session.Postand the PowerShellUpdate-Console*commands put an update into one lock-free first-in, first-out queue. Each thread's updates keep their order; updates from different threads have no set order relative to each other, and none is merged into a later one. The queue has no size limit, so posting does not wait for the screen; a flood that outpaces the render thread for long grows the queue in memory instead. - Updates land between frames. About 15 times a second (
LiveSessionOptions.FramesPerSecond, 1 to 60) the render thread applies the waiting updates in order, a time-boxed batch per frame (about 40 ms at 15 fps; the rest go into the next one), then draws once. Every update accepted before the session stops is applied by the end; one addressed to a widget name the view does not have is dropped and counted inEventsDropped. - Console output is captured. While a session draws on the console,
Console.OutandConsole.Errorare replaced by writers that collect whole lines from any thread (unlessCaptureConsole = false; a second capturing session fails to start).Console.Writefragments are kept per thread until that thread ends the line, so two threads writing pieces never share a line; a message that continues on another thread should go out as oneWriteLine. With a log region the lines become log entries. Without one, an inline area prints them above itself, and a full-screen view shows the latest in a short tail at the bottom (at most six rows and a third of the screen) and prints them all to the scrollback when it ends. - One write is one block. Each
Terminal.Write,Terminal.WriteLinesorTerminal.WriteMarkupLinecall is written whole, so two threads writing widgets never mix their lines. While a session owns the console, the block is queued whole and printed above an inline area, after a full-screen view ends, or in stream mode in apply order; if the session is already stopping, the caller writes the block itself, whole and once. - Stopping. When the work ends or throws, the session stops admitting updates, applies the ones already queued, draws the final frame and restores the terminal (console writers, cursor and the Windows console mode) before the exception reaches you. The first Ctrl+C does the same for the view and cancels
session.Cancellation; your work keeps running until it checks that token, and what it posts afterwards is dropped and counted inEventsDropped.HandleCancelKey = falseleaves Ctrl+C to your program. A widget callback that blocks the render thread pastStopTimeoutdelays only the final drain and screen cleanup; console capture is restored on time. A normal run ends withGetCounters()showing nothing dropped. - Not a terminal. Redirected output and CI get stream mode: each change worth a line becomes one whole line, in the order the updates were applied: every lane start and finish, status changes, table rows, finished progress and log entries, with a heartbeat summary every ten seconds. Console output is written as it arrives, in the same order, with or without a log region.
In PowerShell
The smallest case: eight runspaces at once, each writing with Write-Host. The lines go to the log panel, never over the screen:
Import-Module ConsoleMonster
$dashboard = New-ConsoleDashboard -Title 'Workers' {
New-ConsolePanel -Title 'Log' -Fill { New-ConsoleLog }
}
1..8 | Start-ConsoleDashboard $dashboard -Parallel -ThrottleLimit 8 {
param($Number)
Start-Sleep -Milliseconds (Get-Random -Maximum 800)
Write-Host "worker $Number finished"
}
Start-ConsoleDashboard -Parallel runs a script block per input item in a runspace pool (-ThrottleLimit). Each runspace gets $ConsoleSession, so Update-ConsoleLane, Update-ConsoleStatus, Update-ConsoleTable, Write-ConsoleLog and the other update commands reach the dashboard by widget name. Write-Host, warnings and errors from those runspaces (and verbose and debug messages when their preference is on) go to the dashboard's log (a New-ConsoleLog; without one they take the same path as console output: above an -Inline area, into the short tail at the bottom of a full-screen dashboard and the scrollback after it, or as lines in stream mode); progress records go to its activity list.
Import-Module ConsoleMonster
$servers = 'web01', 'web02', 'web03', 'web04'
$dashboard = New-ConsoleDashboard -Title 'Patching' {
New-ConsolePanel -Title 'Servers' { New-ConsoleStatusList -Name servers -Item $servers }
New-ConsolePanel -Title 'Log' -Fill { New-ConsoleLog }
}
$servers | Start-ConsoleDashboard $dashboard -Parallel -ThrottleLimit 4 {
param($Server)
Update-ConsoleStatus -Name servers -Key $Server -Status Running -Detail 'patching'
Start-Sleep -Milliseconds (Get-Random -Minimum 200 -Maximum 900)
Write-Warning "$Server needs a reboot"
Update-ConsoleStatus -Name servers -Key $Server -Status Pass -Detail 'patched'
}
- Variables of the calling script are not visible inside a runspace, and
$using:does not work here: pass values with-ArgumentListand aparam()block, or as the input items. - Change widgets through the update commands, never by calling a widget object's own methods from a runspace.
Invoke-ConsoleRun(also calledForEach-ConsoleItem) with-Paralleldraws its own run view: a summary bar, a cell per item and the failures. While it shows, host messages, warnings, information, verbose and debug messages and errors from the workers appear in its log, and theirWrite-Progressin an activity list; each worker's progress IDs are its own, so every worker can use-Id 0. Results keep input order (unless-Unordered), and the native stream records (warnings, errors, information, verbose and debug messages) are also delivered when the view ends, so-WarningVariableand the pipeline still get them. Its workers receive the caller's variables with$using:name. UseStart-ConsoleDashboard -Parallelwhen workers should update widgets of your own.
In C#
Workers update widgets through handles taken from the session. Everything below is safe from any thread while the session runs:
LanesHandle,StatusListHandle,LiveTableHandle,ProgressHandleand the other handles fromwidget.Handle(session);session.Post,session.Write(a log line) andsession.Region(name).Set(widget), which swaps aSlot's content between two frames;Console.WriteLine, captured as described above;- the
ConsoleMonster.Extensions.Loggingprovider, which writes into the log region of the running session (an entry with an exception is written as ordinary output instead: above an inline area, or after a full-screen view).
The C# tab of the dashboard demo above uses Parallel.ForEachAsync with a LanesHandle. Code deeper down does not need the session at all: an ambient TerminalScope flows with the code it wraps, across await and Task.Run, and TerminalScope.Report, Advance and SetState go to the widget and key the scope names (TerminalScope.Log to the session's log). Outside a scope they do nothing, so the same code runs without a dashboard.
using ConsoleMonster.Building;
using ConsoleMonster.Live;
using ConsoleMonster.Widgets;
string[] servers = { "web01", "web02", "web03" };
Dashboard dashboard = new DashboardBuilder("Patching")
.Panel("Servers", new Lanes(), out Lanes lanes)
.BuildDashboard();
foreach (string server in servers) {
lanes.Add(server, server, 2);
}
using (LiveSession session = new LiveSession(dashboard)) {
session.Run(s => Parallel.ForEach(servers, server => {
// Patch knows nothing about the dashboard: the scope says where its reports go.
using (s.Scope(lanes.Name, server)) {
Patch(server);
}
}));
}
static void Patch(string server) {
TerminalScope.Report("copying files");
Thread.Sleep(300);
TerminalScope.Advance();
TerminalScope.Report("restarting");
Thread.Sleep(300);
TerminalScope.Advance();
TerminalScope.SetState("pass");
}
ConsoleRun.ForEachAsync is the ready-made form: a throttle (RunOptions.ThrottleLimit), a per-item timeout the body observes through its token, failures kept per item, and the run view; see Live runs.
Under load
The stress test runs 32 workers posting updates in bursts while four more threads write to Console.Out and Console.Error at random. When it ends it fails unless every posted update was applied, nothing was dropped and the log holds exactly the stray lines that were written, then prints its counters; the recording below is that run. The benchmarks measure what one frame costs to compose and write.
Stress test · 32 workers · random Console.WriteLine · 12s 00:00:05 ╭─ Workers ─────────────────────────────────────────────────────────────────────╮ ╭─ Session ──────────────────────────╮ │ ⠧ worker 01 ━━───────────── 15% ✓ 13500 ✗ 465 ▴ 1035 scoring item 149… │ │ Updates post… 465,130 │ │ ⠇ worker 02 ━━───────────── 15% ✓ 13493 ✗ 451 ▴ 1056 resolving item 1… │ │ Updates appl… 463,515 │ │ ⠏ worker 03 ━━───────────── 15% ✓ 13486 ✗ 453 ▴ 1061 writing item 149… │ │ Update rate 81,959/s │ │ ⠋ worker 04 ━━───────────── 15% ✓ 13531 ✗ 442 ▴ 1027 scoring item 149… │ │ Frames 85 │ │ ⠙ worker 05 ━━───────────── 15% ✓ 13485 ✗ 453 ▴ 1062 resolving item 1… │ │ Frame rate 15.1 fps │ │ ⠹ worker 06 ━━───────────── 15% ✓ 13493 ✗ 428 ▴ 1079 writing item 149… │ │ CPU 0% (0.0 │ │ ⠸ worker 07 ━━───────────── 15% ✓ 13446 ✗ 490 ▴ 1064 scoring item 149… │ │ cores) │ │ ⠼ worker 08 ━━───────────── 15% ✓ 13568 ✗ 435 ▴ 997 resolving item 1… │ │ CPU share 0.0% of 32 │ │ ⠴ worker 09 ━━───────────── 15% ✓ 13540 ✗ 423 ▴ 1037 writing item 149… │ │ cores │ │ ⠦ worker 10 ━━───────────── 15% ✓ 13519 ✗ 442 ▴ 1039 scoring item 149… │ │ Captured lin… 95 │ │ ⠧ worker 11 ━━───────────── 15% ✓ 13558 ✗ 430 ▴ 1012 resolving item 1… │ │ Dropped / er… 0 / 0 │ │ ⠇ worker 12 ━━───────────── 15% ✓ 13486 ✗ 452 ▴ 1062 writing item 149… │ │ ── updates/s ───────────────────── │ │ ⠏ worker 13 ━━───────────── 15% ✓ 13471 ✗ 468 ▴ 1061 scoring item 149… │ │ █▆▇▆▇▇▇▆▇▇▇ │ │ ⠋ worker 14 ━━───────────── 15% ✓ 13543 ✗ 451 ▴ 1006 resolving item 1… │ │ ███████████ │ │ ⠙ worker 15 ━━───────────── 15% ✓ 13512 ✗ 447 ▴ 1041 writing item 149… │ │ ███████████ 82k/s │ │ ⠹ worker 16 ━━───────────── 15% ✓ 13463 ✗ 455 ▴ 1082 scoring item 149… │ │ ── CPU % (100% = one core) ─────── │ │ ⠸ worker 17 ━━───────────── 15% ✓ 13456 ✗ 482 ▴ 1062 resolving item 1… │ │ 214.9%│ ⢠⢣ │ │ ⠼ worker 18 ━━───────────── 15% ✓ 13484 ✗ 477 ▴ 1039 writing item 149… │ │ │ ⡎ ⢇ │ │ ⠴ worker 19 ━━───────────── 15% ✓ 13504 ✗ 444 ▴ 1052 scoring item 149… │ │ │ ⢰⠁ ⠘⡄ │ │ ⠦ worker 20 ━━───────────── 15% ✓ 13525 ✗ 399 ▴ 1076 resolving item 1… │ │ │ ⢀⠇ ⠸⡀ │ │ ⠧ worker 21 ━━───────────── 15% ✓ 13582 ✗ 432 ▴ 986 writing item 149… │ │ 0%│⠒⠊⠉⠑⠒⠤⠤⣀⣀⣀⣀⣀⣀⣀⣀⣀⡠⠔⠊ ⠑⠢⢄ │ │ ⠇ worker 22 ━━───────────── 15% ✓ 13560 ✗ 419 ▴ 1021 scoring item 149… │ │ └─────────────────────────── │ │ ⠏ worker 23 ━━───────────── 15% ✓ 13487 ✗ 444 ▴ 1069 resolving item 1… │ │ │ │ ⠋ worker 24 ━━───────────── 15% ✓ 13524 ✗ 423 ▴ 1053 writing item 149… │ │ │ │ ⠙ worker 25 ━━───────────── 15% ✓ 13588 ✗ 440 ▴ 972 scoring item 149… │ │ │ │ ⠹ worker 26 ━━───────────── 15% ✓ 13499 ✗ 488 ▴ 1013 resolving item 1… │ │ │ │ ⠸ worker 27 ━━───────────── 15% ✓ 13471 ✗ 494 ▴ 1035 writing item 149… │ │ │ │ ⠼ worker 28 ━━───────────── 15% ✓ 13486 ✗ 438 ▴ 1076 scoring item 149… │ │ │ │ ⠴ worker 29 ━━───────────── 15% ✓ 13488 ✗ 495 ▴ 1017 resolving item 1… │ │ │ │ ⠦ worker 30 ━━───────────── 15% ✓ 13573 ✗ 432 ▴ 995 writing item 149… │ │ │ │ ⠧ worker 31 ━━───────────── 14% ✓ 13483 ✗ 464 ▴ 1013 scoring item 149… │ │ │ │ ⠇ worker 32 ━━───────────── 14% ✓ 13460 ✗ 474 ▴ 1026 resolving item 1… │ │ │ ╰───────────────────────────────────────────────────────────────────────────────╯ ╰────────────────────────────────────╯ ╭─ Captured output ────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 00:05 FakeLib: connection pool at 752% capacity │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ Ctrl+C stop
- Updates posted in 13.0 s
- 1,011,657
- Average update rate
- 77,934/s
- Frames drawn
- 195 (15.0 fps)
- Dropped updates and errors
- 0 / 0
- CPU, share of one core
- 21.1%
STRESS seconds=13.0 workers=32 posted=1011657 avgRate=77934/s peakRate=86921/s frames=195 fps=15.0 cpuOneCore=21.1% cpuMachine=0.7% captured=209 dropped=0 errors=0 runtime=.NET 8.0.32// A stress test: 32 workers post thousands of updates a second while a noisy "library" writes to Console.Out and
// Console.Error from four threads. Workers never touch the console; the session draws about 15 frames a second and
// captures the stray lines into the output panel. Run it for N seconds: dotnet run -- 12
int seconds = args.Length > 0 ? int.Parse(args[0], CultureInfo.InvariantCulture) : 12;
const int Workers = 32;
Lanes lanes = new Lanes("workers") { MaxLabelWidth = 10, ShowDetail = true };
for (int i = 0; i < Workers; i++) {
lanes.Add("w" + i, "worker " + (i + 1).ToString("00", CultureInfo.InvariantCulture), 100000);
}
KeyValueList stats = new KeyValueList("stats")
.Add("Updates posted", "0")
.Add("Updates applied", "0")
.Add("Update rate", "-")
.Add("Frames", "0")
.Add("Frame rate", "-")
.Add("CPU", "-")
.Add("CPU share", "-")
.Add("Captured lines", "0")
.Add("Dropped / errors", "0 / 0");
Sparkline rate = new Sparkline("rate").WithHeight(3).WithUnit("/s");
LineChart cpu = new LineChart("cpu").WithHeight(6).WithMin(0).WithUnit("%");
cpu.AddSeries("cpu", ThemeToken.Accent2);
Dashboard dashboard = new DashboardBuilder("[bold]Stress test[/]")
.Subtitle(Workers + " workers · random Console.WriteLine · " + seconds + "s")
.Columns(c => c
.Panel("Workers", lanes, 2.2)
.Panel("Session", new Rows(stats, new Rule("updates/s"), rate, new Rule("CPU % (100% = one core)"), cpu), 1))
.Panel("Captured output", new LogRegion { PreferredHeight = 6 }, fill: true)
.BuildDashboard();
string result = string.Empty;
using (LiveSession session = new LiveSession(dashboard)) {
session.Run(s => result = Drive(s));
}
Console.WriteLine(result);
string Drive(LiveSession session) {
// … 105 more lines in the full programFull program
using System.Diagnostics;
using System.Globalization;
using ConsoleMonster.Building;
using ConsoleMonster.Layout;
using ConsoleMonster.Live;
using ConsoleMonster.Styling;
using ConsoleMonster.Widgets;
// A stress test: 32 workers post thousands of updates a second while a noisy "library" writes to Console.Out and
// Console.Error from four threads. Workers never touch the console; the session draws about 15 frames a second and
// captures the stray lines into the output panel. Run it for N seconds: dotnet run -- 12
int seconds = args.Length > 0 ? int.Parse(args[0], CultureInfo.InvariantCulture) : 12;
const int Workers = 32;
Lanes lanes = new Lanes("workers") { MaxLabelWidth = 10, ShowDetail = true };
for (int i = 0; i < Workers; i++) {
lanes.Add("w" + i, "worker " + (i + 1).ToString("00", CultureInfo.InvariantCulture), 100000);
}
KeyValueList stats = new KeyValueList("stats")
.Add("Updates posted", "0")
.Add("Updates applied", "0")
.Add("Update rate", "-")
.Add("Frames", "0")
.Add("Frame rate", "-")
.Add("CPU", "-")
.Add("CPU share", "-")
.Add("Captured lines", "0")
.Add("Dropped / errors", "0 / 0");
Sparkline rate = new Sparkline("rate").WithHeight(3).WithUnit("/s");
LineChart cpu = new LineChart("cpu").WithHeight(6).WithMin(0).WithUnit("%");
cpu.AddSeries("cpu", ThemeToken.Accent2);
Dashboard dashboard = new DashboardBuilder("[bold]Stress test[/]")
.Subtitle(Workers + " workers · random Console.WriteLine · " + seconds + "s")
.Columns(c => c
.Panel("Workers", lanes, 2.2)
.Panel("Session", new Rows(stats, new Rule("updates/s"), rate, new Rule("CPU % (100% = one core)"), cpu), 1))
.Panel("Captured output", new LogRegion { PreferredHeight = 6 }, fill: true)
.BuildDashboard();
string result = string.Empty;
using (LiveSession session = new LiveSession(dashboard)) {
session.Run(s => result = Drive(s));
}
Console.WriteLine(result);
string Drive(LiveSession session) {
LanesHandle lanesHandle = lanes.Handle(session);
KeyValueHandle statsHandle = stats.Handle(session);
SparklineHandle rateHandle = rate.Handle(session);
SparklineHandle cpuHandle = cpu.Handle(session);
long captured = 0;
using CancellationTokenSource stop = CancellationTokenSource.CreateLinkedTokenSource(session.Cancellation);
Thread[] workers = new Thread[Workers];
for (int i = 0; i < Workers; i++) {
int id = i;
workers[i] = new Thread(() => Worker(id, lanesHandle, stop.Token)) { IsBackground = true };
workers[i].Start();
}
Thread[] noisy = new Thread[4];
for (int i = 0; i < noisy.Length; i++) {
int id = i;
noisy[i] = new Thread(() => FakeLibrary(id, stop.Token, ref captured)) { IsBackground = true };
noisy[i].Start();
}
Process process = Process.GetCurrentProcess();
Stopwatch wall = Stopwatch.StartNew();
TimeSpan cpuStart = process.TotalProcessorTime;
TimeSpan lastCpu = cpuStart;
double lastWall = 0;
long lastPosted = 0;
double peakRate = 0;
while (wall.Elapsed.TotalSeconds < seconds && !session.Cancellation.IsCancellationRequested) {
Thread.Sleep(500);
process.Refresh();
double now = wall.Elapsed.TotalSeconds;
TimeSpan cpuNow = process.TotalProcessorTime;
double interval = now - lastWall;
double core = (cpuNow - lastCpu).TotalMilliseconds / (interval * 1000) * 100;
long posted = session.EventsPosted;
double perSecond = (posted - lastPosted) / interval;
peakRate = Math.Max(peakRate, perSecond);
lastCpu = cpuNow;
lastWall = now;
lastPosted = posted;
rateHandle.Add(perSecond);
cpuHandle.Add(core, "cpu");
statsHandle.Set("Updates posted", NumberFormat.Grouped(posted));
statsHandle.Set("Updates applied", NumberFormat.Grouped(session.EventsApplied));
statsHandle.Set("Update rate", NumberFormat.Grouped(perSecond) + "/s");
statsHandle.Set("Frames", NumberFormat.Grouped(session.FramesRendered));
statsHandle.Set("Frame rate", (session.FramesRendered / now).ToString("0.0", CultureInfo.InvariantCulture) + " fps");
statsHandle.Set("CPU", core.ToString("0", CultureInfo.InvariantCulture) + "% (" + (core / 100).ToString("0.0", CultureInfo.InvariantCulture) + " cores)");
statsHandle.Set("CPU share", (core / Environment.ProcessorCount).ToString("0.0", CultureInfo.InvariantCulture) + "% of " + Environment.ProcessorCount + " cores");
statsHandle.Set("Captured lines", NumberFormat.Grouped(Interlocked.Read(ref captured)));
statsHandle.Set("Dropped / errors", session.EventsDropped + " / " + session.ApplyErrors);
}
stop.Cancel();
foreach (Thread t in workers) {
t.Join();
}
foreach (Thread t in noisy) {
t.Join();
}
for (int i = 0; i < Workers; i++) {
lanesHandle.Complete("w" + i, StatusKind.Pass);
}
Thread.Sleep(300);
process.Refresh();
double elapsed = wall.Elapsed.TotalSeconds;
double avgCore = (process.TotalProcessorTime - cpuStart).TotalMilliseconds / (elapsed * 1000) * 100;
return string.Format(CultureInfo.InvariantCulture,
"STRESS seconds={0:0.0} workers={1} posted={2} avgRate={3:0}/s peakRate={4:0}/s frames={5} fps={6:0.0} cpuOneCore={7:0.0}% cpuMachine={8:0.0}% captured={9} dropped={10} errors={11} runtime={12}",
elapsed, Workers, session.EventsPosted, session.EventsPosted / elapsed, peakRate, session.FramesRendered, session.FramesRendered / elapsed,
avgCore, avgCore / Environment.ProcessorCount, Interlocked.Read(ref captured), session.EventsDropped, session.ApplyErrors,
System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription);
}
void Worker(int id, LanesHandle handle, CancellationToken token) {
string key = "w" + id;
Random random = new Random(id * 7919);
string[] steps = { "parsing", "resolving", "evaluating", "scoring", "writing" };
handle.Start(key, "starting");
int n = 0;
while (!token.IsCancellationRequested) {
for (int burst = 0; burst < 40; burst++) {
double roll = random.NextDouble();
StatusKind outcome = roll < 0.03 ? StatusKind.Fail : roll < 0.1 ? StatusKind.Warn : StatusKind.Pass;
handle.Advance(key, outcome, (n++ % 97 == 0) ? steps[random.Next(steps.Length)] + " item " + n : null);
}
Thread.Sleep(1);
}
}
void FakeLibrary(int id, CancellationToken token, ref long captured) {
Random random = new Random(1000 + id);
string[] messages = {
"FakeLib: cache miss for key {0}", "FakeLib: retrying request {0} (attempt 2)", "FakeLib: connection pool at {0}% capacity",
"FakeLib: GC pause {0} ms", "FakeLib: flushed {0} records"
};
while (!token.IsCancellationRequested) {
Thread.Sleep(random.Next(40, 400));
string text = string.Format(CultureInfo.InvariantCulture, messages[random.Next(messages.Length)], random.Next(1, 999));
if (random.NextDouble() < 0.15) {
Console.Error.WriteLine("ERROR " + text);
} else {
Console.WriteLine(text);
}
Interlocked.Increment(ref captured);
}
}What is and is not thread-safe
| Safe from any thread while a session runs | Not safe from several threads |
|---|---|
Handles from widget.Handle(session), session.Post, session.Write, session.Region(name).Set | Calling a widget object's own methods (table.Set, lanes.Add, log.Write) while the session or another thread uses it |
Console.WriteLine, Console.Error.WriteLine and Console.Write fragments (kept per thread until the line ends) | One message split across several threads (send it as one WriteLine) |
Terminal.Write, Terminal.WriteLines and Terminal.WriteMarkupLine (each call one whole block) | Drawing your own InlineArea from several threads |
TerminalScope reports in any task or thread that inherited the scope | |
The Update-Console* commands in Start-ConsoleDashboard -Parallel runspaces | Widget objects changed from a runspace instead of through the update commands |
Build a widget fully before the session starts, then change it only through handles or update commands while it runs. A handle used after the session stopped is harmless: the update is dropped and counted in EventsDropped.
Guarantees and their tests
Each concurrency guarantee, the tests that prove it and where those tests run. The table is generated from the repository's guarantees document on every build, so the page and the document always match.
| Guarantee | Proved by | Runs on |
|---|---|---|
| Complete concurrent one-shot blocks | Terminal | .NET Framework 4.7.2.NET 8.NET 10 |
| One admission per block and preserved final inline line | Session, Session in the same file | .NET Framework 4.7.2.NET 8.NET 10 |
| Exactly-once shutdown fallback to actual OS stdout | Stop | .NET Framework 4.7.2.NET 8.NET 10 |
| Shutdown fallback cannot split draining blocks; restoration/summary writes flush once | Shutdown, Shutdown | .NET Framework 4.7.2.NET 8.NET 10 |
| A late block does not wait for user rendering after Stop or StopTimeout, and flushes once afterward | Stopping_ in the same file | .NET Framework 4.7.2.NET 8.NET 10 |
| Default output from a frame/summary callback cannot deadlock with a concurrent Snapshot request | Render, forced frame and summary schedules in isolated processes | .NET Framework 4.7.2.NET 8.NET 10 |
| Whole stream lines in apply order, many producers | Stream_ | .NET Framework 4.7.2.NET 8.NET 10 |
| Posting proceeds with blocked Apply; every accepted flood event drains | Flood_ in the same file | .NET Framework 4.7.2.NET 8.NET 10 |
| Producer order and serialized normal application under stress | Concurrent | .NET Framework 4.7.2.NET 8.NET 10 |
| ThrottleLimit bounds ForEachAsync bodies | For, limits 1, 3, and 8 | .NET Framework 4.7.2.NET 8.NET 10 |
| Concurrent handles, SummaryBar partial updates, and queued Slot swaps | Handles | .NET Framework 4.7.2.NET 8.NET 10 |
| Per-thread fragments, CR/LF, and exactly-once tail flushing | Concurrent, Carriage, Complete_ | .NET Framework 4.7.2.NET 8.NET 10 |
| Progressive no-log output and bounded full-screen tail with scrollback | Stream, Full | .NET Framework 4.7.2.NET 8.NET 10 |
| Strict malformed markup remains literal and later log events still arrive | Unclaimed in the same file, stream and full screen | .NET Framework 4.7.2.NET 8.NET 10 |
| LF/CRLF host, warning, and colored messages retain lines without a log | No in the same file; preserves multiline host and warning messages without a log in <Mode> with <LineEnding> | .NET Framework 4.7.2.NET 8.NET 10PowerShell 5.1 and 7 |
| Concurrent log entries retain exception details and scopes | Concurrent | .NET Framework 4.7.2.NET 8.NET 10 |
| All session log entries arrive; exceptions reach the configured explicit sink | Concurrent in the same file | .NET Framework 4.7.2.NET 8.NET 10 |
| Parallel dashboard/run host, warning, nested progress, and native results | routes host, warning and nested progress from <CommandName> without changing result records, all three command names | PowerShell 5.1 and 7 |
| Progress whose parent has not arrived contributes as a top-level activity | Activities_, including a child updating after parent completion | .NET Framework 4.7.2.NET 8.NET 10 |
| First actual Ctrl+C rejects later posts and restores terminal/writers | First, isolated Windows console; First, portable cancellation | .NET Framework 4.7.2.NET 8.NET 10 |
| Stress proves zero drops and actual captured line identities | Stress and the real stress example's delivered-line assertions | .NET Framework 4.7.2.NET 8.NET 10 |
| Native Format-Wide fitting | matches native Wide column fitting for <LayoutName>, including boundary widths and groups; direct-host proof in Assert-ConcurrencyHost.ps1 | PowerShell 5.1 and 7 |
These tests assert identities, output, ordering, and synchronization. Watchdogs bound a deadlock, not throughput. Core tests run on net472, net8.0 and net10.0; PowerShell tests run on 5.1 and 7. Source: Docs/Concurrency-Guarantees.md on GitHub.