DiagnosticsPart 2

clrstack Shows the Waiter and syncblk Names the Lock Holder

In a dotnet-dump of a contested lock, clrstack -all marked no thread as the owner. syncblk named it, and a suspended await appeared only in dumpasync.

A worn cast-iron machine with a rack of blank cream-coloured tags held in a steel rail between two rods, one tag tilted out of the row and coloured violet, hung on a copper wire loop. Braided cloth cables curl beneath it, and a window lets in soft light.

In short

  1. In a dotnet-dump of five managed threads, one holding a lock and one waiting for it, clrstack -all printed a full frame list for both and contained no marker of ownership anywhere.
  2. The waiter is recognisable from the stack, because its top managed frame is Monitor.Enter_Slowpath. The holder is not; it reads as a thread sleeping inside an ordinary method.
  3. syncblk returned one entry whose Owning Thread matched the holder's thread object. Its MonitorHeld column read 3 with a single waiter, so it is not a plain count of waiting threads.
  4. A chain of two async methods suspended on a task that never completes had no frame in clrstack -all, on any of three threads. dumpasync listed both state machines.
  5. A second collection of each scenario reproduced the same shape. The Heap dump type was used throughout; native frames are outside what dotnet-dump displays.

clrstack -all printed a complete frame list for a thread that held a lock and another for a thread waiting on it, and neither list said which one was the owner. The owner came from a different command reading a different structure in the same dump. A second question, which method is suspended at an await, is answered by a third command, because that method has no thread and therefore no stack to print.

What the dump holds

dotnet-dump collect --type Heap was used, which Microsoft’s reference describes as “a large and relatively comprehensive dump containing module lists, thread lists, all stacks, exception information, handle information, and all memory except for mapped images”. The same page states the limit that matters here: dotnet-dump “isn’t a native debugger so things like displaying native stack frames aren’t supported”. A stack read from it is the managed half of a thread, and clrstack -f does not add native frames when used with dotnet-dump either.

Deciding when to collect a dump at all is a separate problem, and what dotnet-counters actually reports for a queue bears on it. Here the dumps were taken at known instants of a process built to be in a known state.

A lock held and a lock wanted

The first process starts thread H, which takes a lock on a plain object and sleeps inside it forever, and then thread W, which locks the same object and blocks. Both are ordinary threads, not thread pool threads. The dump held five managed threads, and the two of interest read as follows in clrstack -all:

Text
OS Thread Id: 0x53
        Child SP               IP Call Site
0000FFFB617EE420 0000FFFF6351D1B0 System.Threading.Thread.Sleep(Int32)
0000FFFB617EE4F0 0000FFFF644028B4 CSharpMind.Lab.Slot08.LockScenario.HoldLockForever(System.Threading.ManualResetEventSlim) [/work/LockScenario.cs @ 44]
0000FFFB617EE520 0000FFFF644027F8 CSharpMind.Lab.Slot08.LockScenario+<>c__DisplayClass1_0.<Run>b__0() [/work/LockScenario.cs @ 16]
0000FFFB617EE540 0000FFFF6351B0C8 System.Threading.Thread.StartCallback()
OS Thread Id: 0x54
        Child SP               IP Call Site
0000FFFB60FDE430 0000FFFF63519988 System.Threading.Monitor.Enter_Slowpath(System.Object)
0000FFFB60FDE500 0000FFFF63519C60 System.Threading.Monitor.Enter(System.Object, Boolean ByRef)
0000FFFB60FDE520 0000FFFF644029CC CSharpMind.Lab.Slot08.LockScenario.WaitForLock() [/work/LockScenario.cs @ 50]
0000FFFB60FDE540 0000FFFF6351B0C8 System.Threading.Thread.StartCallback()

The InlinedCallFrame and DebuggerU2MCatchHandlerFrame lines, which carry no method name, are left out of this excerpt and were present in the dump output. A search of the whole clrstack -all output for owner, owning and holds returned nothing.

W is recognisable: a thread whose top managed frame is the monitor slow path is waiting for a monitor. H is not. It reads as a thread inside Sleep, at a line the source places inside a lock block, and only the source, read alongside the stack, says the lock is held. The clrthreads Lock Count column does not help, because it read -00001 on all five threads.

syncblk reads the SyncBlock table, which Microsoft’s SOS reference describes as holding “locking information for thread-safe operations”. It printed one entry:

Text
Index         SyncBlock MonitorHeld Recursion Owning Thread Info          SyncBlock Owner
    1 0000FFFB58001328            3         1 0000AAAABB4E3910 53   7   0000fffb6f00a2e8 System.Object
-----------------------------
Total           1
Free            0

The Owning Thread value, 0000AAAABB4E3910, is the thread object clrthreads lists for the thread with OS id 0x53, which is H. The owner type is System.Object. MonitorHeld is 3 with exactly one waiter, so the column is not a count of waiting threads, even though the dotnet-dump page describes it as “the number of threads waiting for the lock”. The table names who owns the lock. It does not name W, and no waiter count is derived from MonitorHeld, because the only value measured was 3 for one waiter. The Index and Total values changed between collections, 1 and 1 here and 2 and 2 in the repeat, while the held entry itself did not.

An await that has no thread

The second process calls an async method that awaits another, which awaits a TaskCompletionSource task that is never completed. Nothing runs on a pool thread, and the dump holds three threads: the main thread in Thread.Sleep, the finalizer and one runtime thread. clrstack -all for all three contains no frame of either async method; a search for AwaitScenario, Suspended and OuterChain returned 0. The chain is not on a stack because it is not on a thread.

dumpasync “traverses the garbage collected heap and looks for objects representing async state machines”, and it found both:

Text
STACK 1
0000fffb73009910 0000ffff682a9730 ( ) System.Threading.Tasks.Task
  0000fffb73009d18 0000ffff682af258 (0) CSharpMind.Lab.Slot08.AwaitScenario+<InnerSuspendedAsync>d__4
    0000fffb73009e80 0000ffff68354ba8 (0) CSharpMind.Lab.Slot08.AwaitScenario+<OuterChainAsync>d__3

The hanging Task sits at the top, the inner state machine continues from it, and the outer one continues from that. A dump read only through clrstack would report a process doing nothing, which is true of its threads and not of its work.

What this changes

The stack answers where each thread is. Two questions fall outside it, and each has its own command:

  • Who owns this lock: syncblk, then match the Owning Thread against clrthreads.
  • What is suspended at an await: dumpasync, because the state is on the heap.

A thread in Monitor.Enter_Slowpath with no holder visible in its own stack is the signal to run syncblk. A dump with few threads and an unexplained hang is the signal to run dumpasync.

Three limits apply. Only a lock on a plain object was measured; other synchronisation primitives were not tried and are not claimed to behave the same way. Native frames are absent by the tool’s own definition, so a thread blocked below the runtime shows less than the machine holds. And syncblk gives an owner and never the list of waiters; its MonitorHeld value was measured for one waiter only.

Take a dump you have already collected and run syncblk against it before assuming clrstack told the whole story.


Measured on .NET 10.0.12, SDK 10.0.401, Linux arm64 (Ubuntu 24.04) in a Docker container with the network disabled, --cap-add SYS_PTRACE and no CPU or memory limit, with 12 CPUs visible to the container, dotnet-dump 10.0.745401, dump type Heap. Each scenario was collected in the run quoted and again in a second run, and the output above is pasted from the first, with the Loading core dump line and the nameless frames omitted as stated. The target was a console application in which thread H and thread W, or the suspended chain, reached their state before the dump was taken; the collector waited for a readiness line rather than a fixed delay. No timing is reported, because none was measured. The dumps show managed state only: a thread’s native frames, and any lock type other than a monitor on a plain object, were not examined. The command descriptions quoted are from the Microsoft Learn pages for dotnet-dump and the SOS debugging extension, read on 30 September 2026.

Frequently asked

Questions this raises

Does clrstack show which thread holds a lock?

Not in the dump measured here. The holder's stack ended in Thread.Sleep and the waiter's in Monitor.Enter_Slowpath, and the text "owner", "owning" and "holds" appeared in neither. syncblk reports the owning thread for each SyncBlock that is held.

Can a thread waiting on a Monitor be told apart in clrstack?

Yes. Its top managed frame was System.Threading.Monitor.Enter_Slowpath, called from Monitor.Enter and then the application method. What the stack cannot give is the identity of the thread it is waiting for.

Why does a suspended await not appear in clrstack -all?

A method suspended at an await is not running on any thread. Its state lives in an async state machine object on the garbage-collected heap, which is what dumpasync walks.

Does syncblk list the waiting threads?

Not in this dump. It reported the owning thread and a MonitorHeld value of 3 for one waiter, and it named no waiter.

Arrow keys to move, Enter to open.