powershelldba.de

SQL Server Wait Statistics Explained

Every task on SQL Server spends its life alternating between running and waiting. sys.dm_os_wait_stats records every wait since the last restart, but the raw numbers lie to you unless you know how to read them. Here's what the DMV actually means, and a script that applies the filtering for you.

What a "Wait" Actually Is

A SQL Server worker thread runs a task until it needs something it doesn't have yet: a data page not in the buffer pool, a lock held by another session, a memory grant, a parallel sibling thread to catch up. At that point the task leaves the CPU and moves to the waiter list. When the resource becomes available, it doesn't go straight back to running, it goes to the runnable queue first and waits its turn for a scheduler. Only then does it resume.

SQL Server tracks both segments separately:

sys.dm_os_wait_stats aggregates both since the instance last started, or since the last DBCC SQLPERF('sys.dm_os_wait_stats', CLEAR).

SELECT TOP 20
    wait_type,
    waiting_tasks_count,
    wait_time_ms,
    signal_wait_time_ms,
    wait_time_ms - signal_wait_time_ms AS resource_wait_time_ms
FROM sys.dm_os_wait_stats
WHERE waiting_tasks_count > 0
ORDER BY wait_time_ms DESC;

Why the Raw Numbers Are Misleading

Run that query on any instance with a few weeks of uptime and SOS_WORK_DISPATCHER or PAGEIOLATCH_SH will sit at the top with numbers in the millions. That doesn't mean the instance is unhealthy, it means the instance has been up for a while. Cumulative wait time grows monotonically from startup; a big number by itself tells you nothing about whether it's a problem right now.

Three ways the raw view misleads:
  • Background noise dominates. A large share of every instance's cumulative waits are idle system waits like LAZYWRITER_SLEEP, SLEEP_TASK, BROKER_TASK_STOP, or on 2019+ SOS_WORK_DISPATCHER. These are the scheduler and background workers idling, not the workload struggling.
  • Sum without average hides the real signal. PAGEIOLATCH_SH with a 2 ms average is healthy storage, even if the cumulative sum has climbed to 75,000 seconds over a few weeks of uptime. The average per wait, not the total, is what tells you whether storage is actually slow.
  • Uptime, not workload, drives comparisons. Comparing raw sums between two instances with different uptime is comparing apples to a much bigger pile of apples. You need a delta over a known window, not a lifetime total.

Reading Wait Types by Category

Wait types map to resource categories, and each category has a different remedy. A handful account for most real diagnoses:

Category Common wait types What it usually means
I/O PAGEIOLATCH_SH/EX, WRITELOG, IO_COMPLETION Storage subsystem is slow to serve reads (PAGEIOLATCH) or the log is slow to flush (WRITELOG)
Locking LCK_M_X, LCK_M_S, LCK_M_U, LCK_M_IX Blocking between sessions, often a long-running transaction holding locks another needs
Latch PAGELATCH_EX/SH, LATCH_EX/SH Contention on an in-memory structure, classically tempdb allocation pages or a hot insert point
Parallelism CXPACKET, CXCONSUMER, CXSYNC_PORT Parallel query threads waiting on each other, often a symptom of MAXDOP/cost threshold tuning or plan issues, not the root cause itself
Memory RESOURCE_SEMAPHORE, RESOURCE_SEMAPHORE_QUERY_COMPILE, CMEMTHREAD Queries waiting on memory grants, points at memory pressure or queries requesting oversized grants
CPU SOS_SCHEDULER_YIELD, THREADPOOL SOS_SCHEDULER_YIELD points at CPU pressure; THREADPOOL is worker-thread starvation and is never harmless
Network ASYNC_NETWORK_IO The client is slow to consume rows sent back, often the app fetching row-by-row instead of in batches, less often the network itself

CXPACKET Is Not Automatically a Problem

This is the wait type most often misdiagnosed. CXPACKET simply means parallel threads had to synchronize, which happens by design in every parallel plan. A parallel query with a skewed data distribution will always show some CXPACKET in a healthy system. The question isn't whether it appears, it's whether it accounts for a meaningful share of total wait time. A wait type that's 2% of the total isn't the story; one that's 40% of the total, alongside high CPU, usually is.

Signal Wait Percentage: the CPU Pressure Signal

The one number worth tracking on its own is the instance-wide signal wait percentage:

SELECT
    CAST(100.0 * SUM(signal_wait_time_ms) / SUM(wait_time_ms) AS DECIMAL(5,2)) AS SignalWaitPct
FROM sys.dm_os_wait_stats
WHERE wait_time_ms > 0;

This is the share of total wait time spent runnable-but-waiting-for-a-scheduler, across every wait type combined. A healthy OLTP instance typically sits under 15-20%. Sustained values well above that mean tasks are ready to run but CPUs aren't available to run them, which is a scheduling/CPU capacity problem regardless of which individual wait type happens to show up highest in the list.

Snapshot Deltas, Not Lifetime Totals

Because the DMV is cumulative since startup, the useful move is almost always: capture a baseline, let the workload run for a defined window, capture again, and subtract. That turns "waits since 2019" into "waits during the 10 minutes the batch job was slow," which is the number that's actually diagnostic.

-- Baseline
SELECT wait_type, wait_time_ms, signal_wait_time_ms, waiting_tasks_count
INTO #before
FROM sys.dm_os_wait_stats WHERE waiting_tasks_count > 0;

-- ... let the workload run ...

SELECT
    a.wait_type,
    a.wait_time_ms - b.wait_time_ms AS delta_wait_ms,
    a.waiting_tasks_count - b.waiting_tasks_count AS delta_tasks
FROM sys.dm_os_wait_stats a
JOIN #before b ON a.wait_type = b.wait_type
WHERE a.wait_time_ms - b.wait_time_ms > 0
ORDER BY delta_wait_ms DESC;
🔧 Get-sqmWaitStatistics does this filtering automatically: Writing this query correctly every time, remembering the idle-wait ignore list, and computing averages and signal-wait share by hand gets old fast. Get-sqmWaitStatistics reads sys.dm_os_wait_stats, strips out the established SQLskills idle/background ignore list (Paul Randal's list, including the SQL 2016-2019 additions like SOS_WORK_DISPATCHER, QDS_*, and PARALLEL_REDO_*), and returns the top-N waits by actual time, each with its category and a threshold-based recommendation. A wait only gets a recommendation when its average duration or share of relevant wait time crosses a real threshold, for example PAGEIOLATCH_SH is only flagged once the average exceeds 10 ms, not just because the cumulative sum is large.

It also does the before/after snapshot delta for you:
$before = Get-sqmWaitStatistics -SqlInstance "SQL01" -SaveSnapshot
# ... run the workload you're investigating ...
Get-sqmWaitStatistics -SqlInstance "SQL01" -SnapshotBefore $before
Add -OutputPath to get a CSV and an HTML report out the other end, or -IncludeIdle if you specifically want the background waits back in the picture.

Full command reference: sqmSQLTool commands.

What Wait Statistics Can't Tell You

Wait stats tell you where time went in aggregate, not which query caused it. PAGEIOLATCH_SH at the top tells you storage reads are slow; it doesn't tell you which query is scanning the table that's causing it. Pair wait stats with:

Wait statistics tell you where to look next, not the final answer.

The Verdict

A high cumulative wait time by itself is not a diagnosis, it's a side effect of uptime. Filter out the idle/background noise, look at the average wait and the percentage share instead of the raw sum, watch signal wait percentage as your CPU pressure gauge, and prefer a before/after delta over a lifetime total whenever you're chasing a specific slow window.

If you're staring at sys.dm_os_wait_stats trying to decide whether a number is a problem, that filtering and thresholding is exactly what turns a raw DMV dump into an actual answer.