Case study
When More CPU Didn't Help: Tracing SQL Server THREADPOOL Starvation to a Single SET Option
A production Microsoft SQL Server would periodically stop responding.
There was no clear schedule and no obvious workload pattern. Between incidents, the same database could run normally, which made post-incident troubleshooting largely inconclusive.
Before we became involved, the client’s team had already tried the most obvious mitigation: increasing server capacity. More memory had been allocated and additional CPU cores had been added.
The outages continued.
We did not continue scaling the server. The fact that additional capacity had failed to change the behavior was useful evidence in itself. We started capturing what SQL Server was doing during an incident and followed the waits and blocking chain instead.
The root cause eventually came down to one unnecessary line inside a frequently executed stored procedure:
| |
Removing it stopped the incidents.
The code change was trivial. Finding it was not.
The problem
From the application side, the failure looked like a heavily overloaded database.
During an incident:
- new queries became extremely slow or stopped progressing;
- active and waiting requests accumulated rapidly;
- eventually more than a thousand sessions could be involved;
- the database became effectively unavailable;
- once the incident cleared, SQL Server returned to apparently normal operation.
Scaling the server may sound like the most reasonable response to this kind of symptom. If a database appears saturated, adding CPU or memory is an easy hypothesis to test.
In this case it had already been tested, before our investigation began, and it had not solved the problem.
That shifted the question from capacity to contention:
What was preventing requests from completing long enough for the server to run out of workers?
The first useful clue: THREADPOOL
The initial analysis showed THREADPOOL waits.
| |
SQL Server reports THREADPOOL when a task is waiting for a worker thread. Microsoft notes that this most commonly happens when executions take unusually long and reduce the number of workers available for other work.1
So THREADPOOL explained why the server eventually became unresponsive, but not what started the incident.
The visible failure looked roughly like this:
Changing max worker threads at this point would have treated the final stage of the failure rather than explained why so many workers were occupied.
Microsoft also describes max worker threads as an advanced option and recommends leaving SQL Server’s automatic configuration in place for most systems.2
We needed to go further upstream.
flowchart TD
A["SQL Server periodically becomes unresponsive"] --> B["Client team increases CPU and memory"]
B --> C["Incidents continue"]
C --> D["Techpipe investigation starts"]
D --> E["Capture requests, waits and blocking during an incident"]
E --> F["THREADPOOL detected"]
F --> G["Find why workers remain occupied"]
G --> H["Follow the blocking chain"]
H --> I["Blocked resource points to a stored procedure"]
I --> J["Investigate compile contention"]
Capturing the failure while it was happening
The incident was intermittent, so a normal performance snapshot taken ten minutes later was of limited value.
We added monitoring around the failure state and captured:
- active sessions and requests;
- request duration and status;
- current waits;
- blocking relationships;
- blocked resources.
Once an incident was captured, the scale of the pile-up became clear. A large number of sessions were waiting, but the blocking chain did not initially look like a typical SQL Server locking problem.
There was no obvious transaction sitting on a row or table lock for several minutes.
More confusingly, the session at the head of the blocking chain could appear without a useful wait type:
Looking only at the wait-type column did not tell us what that session needed.
The blocked resource did.
Following the resource instead of the wait type
The blocking information repeatedly pointed to the same object.
It was not a table or index. It was a stored procedure.
That changed the direction of the investigation.
SQL Server has a specific blocking pattern around stored procedure compilation. Microsoft documents cases where one session obtains an exclusive compile lock while other sessions trying to execute the same procedure wait behind it. The current blocker can show waittype = NULL, and the role of head blocker can move from one session to another as each compilation proceeds.3
The wait resource in this type of incident can identify the affected object as a compile resource:
| |
That matched the behavior we were seeing much better than the original assumption of general resource pressure.
The problem was now narrower:
Why were concurrent executions of this particular stored procedure repeatedly getting involved in compilation?
When compilation becomes a concurrency problem
Compiling a stored procedure is normally not a problem by itself.
SQL Server can compile a procedure, cache its execution plan, and reuse that plan for later executions. Under normal conditions, hundreds or thousands of calls do not imply hundreds or thousands of compilations.
The situation changes if the statement keeps requiring recompilation.
With enough concurrent callers, compilation can become a serialization point. Microsoft describes how compile locks can cause many sessions executing the same stored procedure to block while an exclusive compile lock is held.3
That gives a very different failure mode from one simply expensive query.
The individual procedure call does not have to consume extreme amounts of CPU.
Enough callers queueing behind the same point are sufficient.
In our case the failure path was becoming clearer:
We now understood the mechanism.
We still did not know why the procedure was recompiling.
flowchart TD
A["Many concurrent calls"] --> B["Frequently executed stored procedure"]
B --> C["SET ANSI_WARNINGS OFF"]
C --> D["SET option changes execution context"]
D --> E["Statement recompilation"]
E --> F["Compile-lock contention"]
F --> G["Concurrent executions queue"]
G --> H["Blocking chain grows"]
H --> I["Workers remain occupied"]
I --> J["Worker pool exhaustion"]
J --> K["THREADPOOL"]
K --> L["SQL Server becomes unresponsive"]
C --> M["Remove unnecessary SET statement"]
M --> N["Recompilation pattern disappears"]
N --> O["Compile contention stops"]
O --> P["Incidents stop"]
Extended Events identified the recompilation cause
This was where Extended Events provided the missing piece.
SQL Server exposes the event:
| |
for statement-level recompilation.
More importantly for this investigation, the event includes recompile_cause. Microsoft documents a number of possible values, including schema and statistics changes, deferred compilation, temporary-table changes, explicit OPTION (RECOMPILE), and 4:
| |
That was the reason captured for the affected procedure.
This eliminated several plausible paths of investigation at once. We were no longer looking for a schema deployment, statistics churn, plan-cache pressure, or an explicit recompile hint.
We started looking at the procedure’s execution context.
The root cause was one SET statement
The procedure contained:
| |
ANSI_WARNINGS is a session-level SET option. Microsoft documents that its value is applied at execution time and affects the current session.5
The Extended Events trace showed recompilation with:
| |
and inspection of the procedure led back to the ANSI_WARNINGS change.
At that point we checked whether the procedure actually depended on ANSI_WARNINGS OFF.
It did not.
The statement was leftover code and could be removed without changing the required procedure logic.
Before
After
The actual production change was essentially:
| |
There was no need to increase max worker threads, add more CPU, or redesign the procedure.
Result
After removing the unnecessary SET statement, the observed recompilation pattern stopped.
The compile contention disappeared with it. The large blocking chains stopped forming, worker starvation no longer developed, and the periodic SQL Server hangs did not return.
A server that had already been given more CPU and RAM was fixed by deleting one line of T-SQL.
The useful part of the case was not the size of the fix. It was the path used to find it.
The actual failure chain
Once all the evidence was available, the incident could be reconstructed from cause to symptom:
| |
CPU and memory pressure appeared near the end of this chain.
That is why the earlier resource increases did not remove the failure condition. They could change how much load the server tolerated before reaching the same state, but they did not remove the serialization point that created the queue.
Why this problem was easy to misdiagnose
Several things made the incident look more conventional than it was.
It was intermittent
Most of the useful evidence existed only while the database was failing. Once the blocking cleared, ordinary server metrics looked much less interesting.
THREADPOOL was real, but it was downstream
SQL Server really was short of available workers.
That observation was correct. It just was not the root cause.
The head blocker did not look obviously blocked
A blocker with an empty wait type does not immediately point toward compilation. Following the blocked resource was more useful than staring at wait_type.
There was no classic long-running transaction
Compile-lock contention can produce a moving blocking chain rather than one session holding the same database lock throughout the incident.3
The SQL was valid
Nothing crashed when the procedure encountered:
| |
The procedure worked during normal operation.
The failure only became visible when its execution behavior met enough concurrency.
That combination is exactly what makes intermittent database incidents expensive to diagnose from application symptoms alone.
A practical diagnostic sequence for THREADPOOL
When SQL Server periodically stops responding and THREADPOOL appears, we use it as a starting point rather than a diagnosis.
A useful investigation sequence is:
- Capture the server state during the incident.
- Check which requests are active and which are waiting.
- Build the blocking chain.
- Inspect
blocking_session_idand the actual blocked resource. - Check what is consuming or retaining workers.
- Do not discard a head blocker simply because its
wait_typeis empty. - If the blocked resource maps to a stored procedure, check for compile contention.
- Capture recompilation events with Extended Events.
- Inspect
recompile_cause. - Correlate that cause with the procedure code and session settings.
- Remove the source of contention before changing SQL Server capacity limits.
This sequence also avoids a common troubleshooting trap: treating a resource that happens to be exhausted as the component that needs to be enlarged.
Resource exhaustion is often several steps away from the defect
The same pattern appears in other database incidents.
A connection pool may fill because requests have stopped completing.
CPU may stay saturated because execution plans are unstable or statements are recompiling.
Storage latency may spike because a previously selective query has turned into a large scan.
Replication lag may be the visible problem while the actual change happened in write volume or transaction shape.
In this case, the visible metric was worker exhaustion.
The useful question was what kept the workers occupied.
Database troubleshooting should preserve the failure state
Intermittent production incidents are much easier to solve when the database is instrumented to retain evidence from the failure window.
For SQL Server, that usually means combining runtime session/request data with blocking information and targeted Extended Events rather than relying on a performance snapshot taken after recovery.
The investigation can then proceed from evidence:
That is also how we approach bounded database performance investigations at Techpipe: capture the failure first, reconstruct the dependency chain, and change the layer where the problem actually starts.
References
Microsoft Learn, sys.dm_os_wait_stats (Transact-SQL) — definition of
THREADPOOL. ↩︎Microsoft Learn, Server Configuration: max worker threads. ↩︎
Microsoft Learn, Troubleshoot blocking issues caused by compile locks. ↩︎ ↩︎ ↩︎
Microsoft Learn, Query Processing Architecture Guide —
sql_statement_recompileandrecompile_cause. ↩︎Microsoft Learn, SET ANSI_WARNINGS (Transact-SQL). ↩︎