ClickHouse's Thread Pool Wakes the Newest Idle Thread and Counts Events Per CPU
ClickHouse's thread pool is a stable priority queue behind one mutex with an intrusive LIFO stack of idle threads, and every event it counts lands in a cache-line-padded, per-CPU counter array.
TL;DR: The pool is one
boost::heap::priority_queuebehind one mutex, but the wake-up is notnotify_allon a shared condition variable — it is a per-thread CV plus an intrusive LIFO stack, so exactly one thread is woken and it is the one that ran most recently (better cache locality, no thundering herd). Every one of those transitions is counted, and the counters themselves are sharded per CPU on cache-line-padded rows.
Every server needs a thread pool, and the textbook one is a queue plus a condition variable:
producers consumers
| ^
v |
+---------+ push +---------+ pop |
| queue | --------> | mutex | -----+
+---------+ +---------+
| ^
v | notify_all
+---------------+
| N-1 sleeping |
| threads |
+---------------+It is simple, and it works. It also has two well-known pathologies, both of which a database hits hard:
- The thundering herd.
notify_allwakes every sleeping thread. On a pool with 10 000 threads and a queue that receives one small job, 9999 threads wake, contend for the mutex, find the queue empty, and go back to sleep. That is 9999 wakeups and 9999 context switches to do one unit of work. - The cache-line ping-pong. Whatever the workers were doing, they were probably doing it near each other in memory. Waking all of them scatters the working set across all of them.
ClickHouse’s pool is not a work-stealing pool (there is no per-thread deque, no stealing). It is something else: a shared, stable priority queue with a LIFO wake-up order and per-thread condition variables, plus a set of per-CPU counters that record every interesting transition.
This article walks the queue, the idle stack, the shutdown semantics, and then the counter machinery — because “how do you count every thread-pool event without paying for it” turns out to be the more interesting half.
The queue: a stable priority queue, not a deque
The pool’s job container is a single heap:
// src/Common/ThreadPool.h:238
boost::heap::priority_queue<JobWithPriority,boost::heap::stable<true>> jobs;boost::heap::stable<true> means the heap preserves insertion order among
equal-priority elements. That matters: without it, two jobs with the same
priority could be reordered arbitrarily, and ClickHouse relies on FIFO
ordering inside a priority class for things like replication ordering.
The element type carries a fair amount of state:
// src/Common/ThreadPool.cpp:230
class JobWithPriority
{
public:
using Job = std::function<void()>;
Job job;
Priority priority;
CurrentMetrics::Increment metric_increment;
ScopedDecrement available_threads_decrement;
DB::OpenTelemetry::TracingContextOnThread thread_trace_context;
/// Propagated like the tracing context: a job scheduled under `QueryCancellationBlocker` runs under one.
bool query_cancellation_blocked = false;
/// Call stacks of all jobs' schedulings leading to this one
std::vector<FramePointers> frame_pointers;
bool enable_job_stack_trace = false;
Stopwatch job_create_time;
// Deleted copy constructor and copy assignment operator
JobWithPriority(const JobWithPriority&) = delete;
JobWithPriority& operator=(const JobWithPriority&) = delete;
// Move constructor and move assignment operator
JobWithPriority(JobWithPriority&&) noexcept = default;
JobWithPriority& operator=(JobWithPriority&&) noexcept = default;
JobWithPriority(
Job job_, Priority priority_, CurrentMetrics::Metric metric,
const DB::OpenTelemetry::TracingContextOnThread & thread_trace_context_, bool query_cancellation_blocked_,
bool capture_frame_pointers, ScopedDecrement available_threads_decrement_)
: job(job_), priority(priority_), metric_increment(metric),
available_threads_decrement(std::move(available_threads_decrement_)),
thread_trace_context(thread_trace_context_), query_cancellation_blocked(query_cancellation_blocked_),
enable_job_stack_trace(capture_frame_pointers)
{
if (!capture_frame_pointers)
return;
/// Save all previous jobs call stacks and append with current
frame_pointers = DB::Exception::getThreadFramePointers();
frame_pointers.push_back(StackTrace().getFramePointers());
}
bool operator<(const JobWithPriority & rhs) const
{
return priority > rhs.priority; // Reversed for `priority_queue` max-heap to yield minimum value (i.e. highest priority) first
}
UInt64 elapsedMicroseconds() const
{
return job_create_time.elapsedMicroseconds();
}
};Three things to note.
The priority inversion is deliberate. operator< is reversed, because
boost::heap::priority_queue is a max-heap but ClickHouse wants the
smallest priority value to run first:
// src/Common/Priority.h:4
/// Common type for priority values.
/// Separate type (rather than `Int64` is used just to avoid implicit conversion errors and to default-initialize
struct Priority
{
using Value = Int64;
Value value = 0; /// Note that lower value means higher priority.
constexpr operator Value() const { return value; } /// NOLINT
};The Stopwatch in the element is a queue-latency timer. The worker
computes how long the job waited in the queue and reports it as a profile
event — that is how you distinguish “the pool is saturated” from “the job is
slow”.
The tracing context and the cancellation blocker travel with the job.
thread_trace_context is the OpenTelemetry context of the scheduler, so
the span parent is correct even though the job runs on a different thread.
query_cancellation_blocked does the same for query cancellation: a job
scheduled under a QueryCancellationBlocker inherits it.
The idle stack: the interesting part
The pool does not use one shared condition variable. Each worker thread owns one:
// src/Common/ThreadPool.h:91
/// Per-thread condition variable for LIFO idle scheduling.
/// Each idle thread waits on its own CV, so the scheduler can wake the
/// LIFO-selected thread directly without disturbing other idle threads.
/// This avoids the thundering herd that a shared CV with `notify_all` creates.
std::condition_variable cv;
/// LIFO idle thread scheduling flag.
/// When a new job is scheduled, the most recently idle thread is popped
/// from the LIFO stack, this flag is set to true, and only that thread's
/// CV is notified. The worker wait predicate also re-checks the real
/// pool state (queued jobs, shutdown, excess threads) as a safety net
/// against missed wake-ups, so a worker that wakes either directly or
/// through a spurious CV signal will still pick up pending work.
bool idle_wakeup_flag = false;
/// Intrusive links in the parent pool idle stack. They avoid allocations
/// in the worker idle path, which runs under `DENY_ALLOCATIONS_IN_SCOPE`.
ThreadFromThreadPool * idle_prev = nullptr;
ThreadFromThreadPool * idle_next = nullptr;
bool in_idle_stack = false;And the pool keeps an intrusive doubly-linked stack of the idle threads:
// src/Common/ThreadPool.h:243
/// Intrusive LIFO stack of idle threads for wake-up scheduling.
/// When a new job is scheduled, the most recently idle thread is woken first.
/// This concentrates work on fewer OS threads, improving CPU cache locality
/// and reducing memory fragmentation from allocator per-thread caches (e.g. jemalloc tcache).
/// See https://github.com/ClickHouse/ClickHouse/issues/10818
ThreadFromThreadPool * idle_thread_head = nullptr; /// Oldest idle thread.
ThreadFromThreadPool * idle_thread_tail = nullptr; /// Most recently idle thread.
size_t idle_thread_count = 0;Note “intrusive”: the links live inside the thread object, so pushing and popping the stack allocates nothing. That matters because the idle path runs with allocations forbidden:
// src/Common/ThreadPool.cpp:986
DENY_ALLOCATIONS_IN_SCOPE;The stack operations are six short functions:
// src/Common/ThreadPool.cpp:824
typename ThreadPoolImpl<Thread>::ThreadFromThreadPool * ThreadPoolImpl<Thread>::popNewestIdleThreadNoLock()
{
ThreadFromThreadPool * thread = idle_thread_tail;
if (thread)
removeIdleThreadNoLock(thread);
return thread;
}
template <typename Thread>
typename ThreadPoolImpl<Thread>::ThreadFromThreadPool * ThreadPoolImpl<Thread>::popOldestIdleThreadNoLock()
{
ThreadFromThreadPool * thread = idle_thread_head;
if (thread)
removeIdleThreadNoLock(thread);
return thread;
}
template <typename Thread>
void ThreadPoolImpl<Thread>::wakeIdleThreadNoLock(ThreadFromThreadPool * thread)
{
thread->idle_wakeup_flag = true;
thread->cv.notify_one();
}
template <typename Thread>
void ThreadPoolImpl<Thread>::wakeUpAllIdleThreadsNoLock()
{
while (ThreadFromThreadPool * thread = popOldestIdleThreadNoLock())
wakeIdleThreadNoLock(thread);
}So there are two ends: the tail is the most recently idle thread (popped for new work — LIFO), and the head is the oldest idle thread (popped first when the pool needs to shrink, or on shutdown).
The worker loop
The worker waits on its own CV, but with a predicate that re-checks the pool state as a safety net:
// src/Common/ThreadPool.cpp:1067
while (parent_pool.jobs.empty()
&& !parent_pool.finished
&& parent_pool.threads.size() <= std::min(parent_pool.max_threads, parent_pool.scheduled_jobs + parent_pool.max_free_threads))
{
idle_wakeup_flag = false;
parent_pool.pushIdleThreadNoLock(this);
cv.wait(lock, [this]
{
return idle_wakeup_flag
|| !parent_pool.jobs.empty()
|| parent_pool.finished
|| parent_pool.threads.size() > std::min(parent_pool.max_threads, parent_pool.scheduled_jobs + parent_pool.max_free_threads);
});
parent_pool.removeIdleThreadNoLock(this);
}That comment above it is a war story worth quoting, because it explains why
the predicate is not simply idle_wakeup_flag:
// src/Common/ThreadPool.cpp:1045
/// LIFO idle thread scheduling: link this thread into the intrusive
/// idle stack and wait on its own per-thread CV until selected. The
/// scheduler pops the most recently idle thread from the stack, sets
/// its `idle_wakeup_flag`, and notifies only that thread's CV.
///
/// The wait predicate also re-checks the real pool state. This is a
/// required safety net: a previous attempt to drop it
/// (commit `9322e66151ea`) deadlocked the
/// `SchedulerWorkloadResourceManager.DropNotEmptyQueueLong` unit test
/// under TSan. Even though every notifier
/// (`popNewestIdleThreadNoLock`, `wakeUpAllIdleThreadsNoLock`,
/// `wakeUpExcessIdleThreadsNoLock`) sets `idle_wakeup_flag` before
/// notifying the per-thread CV, there are interleavings where a
/// queued job becomes visible to this worker without a paired
/// notification. Re-checking the pool state in the predicate lets a
/// notified or spuriously-woken worker pick up such jobs (or observe
/// shutdown / a reduced thread limit) instead of sleeping forever.
The three conditions are: I was explicitly selected (the fast path), or there is work (the safety net), or the pool is shutting down / shrinking.
Then the job is popped:
// src/Common/ThreadPool.cpp:1096
/// boost::priority_queue does not provide interface for getting non-const reference to an element
/// to prevent us from modifying its priority. We have to use const_cast to force move semantics on JobWithPriority.
job_data = std::move(const_cast<JobWithPriority &>(parent_pool.jobs.top()));
parent_pool.jobs.pop();
ProfileEvents::increment(
std::is_same_v<Thread, GlobalThreadType> ? ProfileEvents::GlobalThreadPoolJobWaitTimeMicroseconds : ProfileEvents::LocalThreadPoolJobWaitTimeMicroseconds,
job_data->elapsedMicroseconds());And on the scheduling side, exactly one thread is woken:
// src/Common/ThreadPool.cpp:575
/// Select the most recently idle thread (LIFO order) and wake only that one
/// via its per-thread CV. Concentrating work on fewer threads improves cache
/// locality and lets excess threads exit naturally, freeing their allocator
/// caches. Notifying a single thread (instead of `notify_all` on a shared CV)
/// avoids a thundering herd where every idle thread wakes, contends for the
/// mutex, and goes back to sleep on every job schedule.
///
/// Notify while still holding `mutex`: once `idle_wakeup_flag` is set, a
/// spurious wakeup is enough for the worker to run and remove itself from
/// the pool, so the raw `ThreadFromThreadPool` pointer must not be used
/// after the lock is released.
if (ThreadFromThreadPool * thread_to_wake = popNewestIdleThreadNoLock())
wakeIdleThreadNoLock(thread_to_wake);The last paragraph is a subtle lifetime point: the woken thread may remove itself from the pool (see below), so the pointer must not be touched after the lock is dropped.
Why LIFO?
The rationale is in the header comment: concentrating work on fewer OS threads improves CPU cache locality. Concretely:
- The thread that just finished a job has its caches warm — the data it was working on is still in L1/L2. Handing it the next job of the same query is a hit.
- The allocator’s per-thread cache (jemalloc tcache) is warm on that thread, so allocations do not have to go to the arena.
- Other threads stay idle, and idle threads exit (see
wakeUpExcessIdleThreadsNoLock), releasing their stacks and their tcache arenas. A pool of 10 000 threads that only ever uses 32 of them should converge to 32 resident threads, not 10 000.
The downside is real: LIFO is unfair. A thread at the tail of the idle stack may starve while the head thread keeps getting work. In practice this does not matter because the pool has no per-thread affinity requirement — jobs are anonymous.
Shutdown semantics
finish() is not stop accepting work and drain. The queued jobs are
discarded:
// src/Common/ThreadPool.h:171
/// Stop accepting new jobs: further scheduling produces CANNOT_SCHEDULE_TASK, and the jobs that are
/// already queued but have not started yet are discarded instead of being run.
/// Jobs that are already running are neither interrupted nor waited for, call 'wait' to join them.
void finish();The implementation is a single negative sentinel:
// src/Common/ThreadPool.cpp:716
template <typename Thread>
void ThreadPoolImpl<Thread>::finishNoLock(const std::lock_guard<std::mutex> &)
{
finished = true;
/// scheduleImpl doesn't check for `finished` outside the critical section,
/// so we set remaining_pool_capacity to a large negative value
/// (e.g., -MAX_THEORETICAL_THREAD_COUNT) to signal that no new threads are needed.
/// This effectively prevents any new threads from being started during shutdown.
remaining_pool_capacity.store(-MAX_THEORETICAL_THREAD_COUNT, std::memory_order_relaxed);
/// Wake up all idle threads so they can see it and exit gracefully.
wakeUpAllIdleThreadsNoLock();
job_finished.notify_all();
}The remaining_pool_capacity trick is worth spelling out, because it shows
how the pool avoids taking the lock on the fast path. The pool keeps two
atomics:
// src/Common/ThreadPool.h:220
// Originally equals to max_threads, but changes dynamically.
// Decrements with every new thread started, increments when it finishes.
// If positive, then more threads can be started.
// When it comes to zero, it means that max_threads threads have already been started.
// it can be below zero when the threadpool is shutting down
std::atomic<int64_t> remaining_pool_capacity;
// Increments every time a new thread joins the thread pool or a job finishes.
// Decrements every time a task is scheduled.
// If positive, it means that there are more threads than jobs (and some are idle).
// If zero, it means that every thread has a job.
// If negative, it means that we have more jobs than threads.
std::atomic<int64_t> available_threads;scheduleImpl tries to create a new thread before taking the lock, using a
CAS on remaining_pool_capacity as the token:
// src/Common/ThreadPool.cpp:434
std::unique_ptr<ThreadFromThreadPool> new_thread;
// Load the current capacity
int64_t capacity = remaining_pool_capacity.load(std::memory_order_relaxed);
int64_t currently_available_threads = available_threads.load(std::memory_order_relaxed);
while (currently_available_threads <= 0 && capacity > 0)
{
if (remaining_pool_capacity.compare_exchange_weak(capacity, capacity - 1, std::memory_order_relaxed))
{
try
{
new_thread = std::make_unique<ThreadFromThreadPool>(*this);
break; // Exit the loop once a thread is successfully created.
}
catch (...)
{
// Failed to create the thread, restore capacity
remaining_pool_capacity.fetch_add(1, std::memory_order_relaxed);
std::lock_guard lock(mutex); // needed to change first_exception.
return on_error(fmt::format("failed to start the thread: {}", DB::getCurrentExceptionMessage(true)));
}
}
// capacity gets reloaded by (unsuccessful) compare_exchange_weak
currently_available_threads = available_threads.load(std::memory_order_relaxed);
}Because finishNoLock sets the capacity to a huge negative number, the
capacity > 0 test fails immediately and no thread is created — no lock, no
race, no extra check.
Counting every transition
The pool is instrumented at six points, and the counters are the only way to
know what a pool is actually doing. GlobalThreadPoolLockWaitMicroseconds is
incremented at three places — the scheduler’s lock acquisition, the worker’s
lock acquisition, and wait():
// src/Common/ThreadPool.cpp:460
{
Stopwatch watch;
std::unique_lock lock(mutex);
ProfileEvents::increment(
std::is_same_v<Thread, GlobalThreadType> ? ProfileEvents::GlobalThreadPoolLockWaitMicroseconds : ProfileEvents::LocalThreadPoolLockWaitMicroseconds,
watch.elapsedMicroseconds());GlobalThreadPoolJobs counts scheduled jobs, GlobalThreadPoolJobWaitTimeMicroseconds
the queue latency, GlobalThreadPoolExpansions/Shrinks the resizes,
GlobalThreadPoolThreadCreationMicroseconds the time spent waiting for a
freshly created worker to actually start running:
// src/Common/ThreadPool.cpp:896
Stopwatch watch2;
thread = Thread(&ThreadFromThreadPool::worker, this);
ProfileEvents::increment(
std::is_same_v<Thread, GlobalThreadType> ? ProfileEvents::GlobalThreadPoolThreadCreationMicroseconds : ProfileEvents::LocalThreadPoolThreadCreationMicroseconds,
watch2.elapsedMicroseconds());
ProfileEvents::increment(
std::is_same_v<Thread, GlobalThreadType> ? ProfileEvents::GlobalThreadPoolExpansions : ProfileEvents::LocalThreadPoolExpansions);Every one of these is a fetch_add on a shared counter, called from
arbitrarily many threads. Which brings us to the question the title
promises: how do you do that without the cache line bouncing between cores?
You can see all of it from SQL:
SELECT event, value
FROM system.events
WHERE event ILIKE '%ThreadPool%'
ORDER BY event;
-- event -- value
-- GlobalThreadPoolJobs -- 1283741
-- GlobalThreadPoolLockWaitMicroseconds -- 91382
-- GlobalThreadPoolJobWaitTimeMicroseconds -- 4471290
-- GlobalThreadPoolExpansions -- 12
-- GlobalThreadPoolShrinks -- 3
-- GlobalThreadPoolThreadCreationMicroseconds-- 84022GlobalThreadPoolJobWaitTimeMicroseconds / GlobalThreadPoolJobs is the
average time a job spent in the queue. If that number climbs while
GlobalThreadPoolLockWaitMicroseconds stays flat, the pool is saturated, not
contended — a distinction you cannot make without these counters.
The counters: sharded per CPU
ClickHouse’s ProfileEvents used to be a single flat array of std::atomic,
one counter per event. Every increment from every thread hit the same cache
line for a given event. With GlobalThreadPoolJobs incremented once per
scheduled job across 10 000 threads, that is a guaranteed ping-pong.
The fix is per-CPU sharding. The layout is computed at compile time:
// src/Common/ProfileEvents.cpp:1918
/// Row stride padded so each per-CPU row ends on a cache-line boundary. Without this the last
/// few events of one row share a cache line with the first events of the next row, causing
/// false sharing across CPUs at row boundaries.
constexpr size_t counts_per_cache_line = DB::CH_CACHE_LINE_SIZE / sizeof(Count);
static_assert((counts_per_cache_line & (counts_per_cache_line - 1)) == 0);
constexpr size_t per_cpu_stride = (static_cast<size_t>(END) + counts_per_cache_line - 1) & ~(counts_per_cache_line - 1);
/// Cell count for a layout: `cpus` padded rows, or a compact single row of raw events.
ALWAYS_INLINE inline size_t cellCount(uint32_t cpus)
{
return cpus ? static_cast<size_t>(cpus) * per_cpu_stride : static_cast<size_t>(END);
}
/// Cells are plain `Count` accessed via `std::atomic_ref`; over a suitably aligned object it is
/// lock-free and generates the same code as a `std::atomic` member (required_alignment == 8).
ALWAYS_INLINE inline std::atomic_ref<Count> cell(Count * counters, size_t cpu, Event event)
{
return std::atomic_ref<Count>(counters[cpu * per_cpu_stride + event]);
}So the array is cpus × per_cpu_stride cells, where per_cpu_stride is the
event count rounded up to a whole number of cache lines. Cell (cpu, event)
lives at cpu * per_cpu_stride + event. Two different CPUs writing two
different events are guaranteed to be on different cache lines.
The increment is then a relaxed atomic add into the current CPU’s row:
// src/Common/ProfileEvents.cpp:2080
void Counters::fetchAdd(Event event, Count amount, int32_t cpu)
{
const uint32_t rows = cpus.load(std::memory_order_relaxed);
if (rows)
{
/// `cpu` may be >= rows if a CPU above `MAX_CPUS` is online, and -1 on error. In both
/// cases, fall back to row 0 — still atomic, still correct, just with less cache locality.
const size_t row = (cpu >= 0 && static_cast<uint32_t>(cpu) < rows) ? static_cast<size_t>(cpu) : 0;
cell(counters, row, event).fetch_add(amount, std::memory_order_relaxed);
}
else
cell(counters, 0, event).fetch_add(amount, std::memory_order_relaxed);
}relaxed ordering, because a counter’s value is not synchronisation for
anything. And the CPU id comes from a TLS read, not a syscall:
// src/Common/PerCPU.h:39
ALWAYS_INLINE inline Int32 getCurrentCPU()
{
#if defined(OS_LINUX)
/// TLS read via glibc rseq on modern kernels (see glibc-compatibility/musl/sched_getcpu.c).
return sched_getcpu();Reading the counters sums the rows:
// src/Common/ProfileEvents.cpp:2045
Count Counters::load(Event event) const
{
const uint32_t rows = cpus.load(std::memory_order_relaxed);
if (!rows)
return cell(counters, 0, event).load(std::memory_order_relaxed);
Count sum = 0;
for (uint32_t s = 0; s < rows; ++s)
sum += cell(counters, s, event).load(std::memory_order_relaxed);
return sum;
}The atomic_ref trick
Look closely at the cell type. It is not std::atomic<Count>. It is a
plain Count accessed through std::atomic_ref. The comment explains why:
// src/Common/ProfileEvents.cpp:1943
/// Per-CPU storage for `global_counters`, cache-line aligned. `Count` is trivially default-
/// constructible, so this static array is a guaranteed zero-init BSS with no dynamic initializer:
/// valid before any dynamic initializer can touch `global_counters` (which points here), and only
/// touched rows fault in. An `atomic` element is not trivially constructible and would reintroduce
/// dynamic initialization for an array this large — hence plain `Count` cells + `atomic_ref`.
static_assert(std::is_trivially_default_constructible_v<Count>);
alignas(DB::CH_CACHE_LINE_SIZE) static Count global_counters_storage[PerCPU::MAX_CPUS * per_cpu_stride];The array is 1024 CPUs × per_cpu_stride UInt64s — with a few thousand
events that is tens of megabytes. If the elements were std::atomic<UInt64>,
the array would need a dynamic initializer (atomic’s constructor is not
trivially default-constructible in the required sense), which would mean a
static-initialization-order problem: some other translation unit’s dynamic
initializer could increment a counter before this one ran. By keeping the
storage plain and wrapping accesses in std::atomic_ref, the array stays in
BSS, is guaranteed zero, and is valid before any dynamic initialization.
The layout flips from single-row to per-CPU at static-init time:
// src/Common/ProfileEvents.cpp:1966
/// Switches `global_counters` to per-CPU layout during dynamic static init. Only `cpus` is
/// mutated — storage stays put, so both pre- and post-flip readers see a valid view of the same
/// BSS array (pre-flip increments land in row 0, which post-flip sums include). The store is
/// relaxed-atomic because a thread spawned by an earlier TU's dynamic initializer may already be
/// incrementing counters while this runs.
struct ProfileEventsPerCPUInitializer
{
ProfileEventsPerCPUInitializer()
{
const uint32_t cpus = PerCPU::getNumCPUs();
global_counters.cpus.store(cpus, std::memory_order_relaxed);
user_counters_cpus.store(cpus, std::memory_order_relaxed);
}
};
static const ProfileEventsPerCPUInitializer profile_events_per_cpu_initializer;“pre-flip increments land in row 0, which post-flip sums include” — that is the elegant part. The layout change is not a reset; the single row simply becomes “row 0” of a multi-row array, and the sum includes it.
The trace-log fast path
There is one more thing increment() does, and it is a nice example of
paying for the slow thing only when the slow thing is wanted:
// src/Common/ProfileEvents.cpp:2319
void Counters::increment(Event event, Count amount)
{
Counters * current = this;
bool send_to_trace_log = false;
const int32_t cpu = PerCPU::getCurrentCPU();
do
{
current->fetchAdd(event, amount, cpu);
/// Small optimization for quite a hot path.
/// Load with relaxed as it almost always returns null.
/// If non-null, add an acquire fence.
if (current->should_trace_array.load(std::memory_order_relaxed))
{
if (auto * trace_arr = current->should_trace_array.load(std::memory_order_acquire))
send_to_trace_log |= trace_arr[event].load(std::memory_order_relaxed);
}
send_to_trace_log |= current->trace_all_profile_events.load(std::memory_order_relaxed);
current = current->parent.load(std::memory_order_acquire);
} while (current != nullptr);
if (unlikely(send_to_trace_log))
DB::TraceSender::send(DB::TraceType::ProfileEvent, StackTrace(), {.event = event, .increment = amount});
}The common case is one relaxed load of a null pointer. The expensive path
(StackTrace() — a full symbolised backtrace!) is behind an unlikely
branch that is only taken when the user has explicitly asked for a specific
event to be traced.
Also note the parent chain. A Counters object has a parent pointer, and
an increment walks it. There are three levels: Thread, User, Global.
That is how a query’s own ProfileEvents get attributed to the user and then
to the server, with one fetch_add per level.
What “zero overhead” really means
The claim is not that counting is free — it is that it costs one uncontended
fetch_add on a cache line nobody else is touching. Compare:
shared counter (one cache line) per-CPU counters (one line each)
--------------------------------- ---------------------------------
core 0: LOCK XADD [L] <---+ core 0: LOCK XADD [L0]
core 1: LOCK XADD [L] <---+--+ core 1: LOCK XADD [L1]
core 2: LOCK XADD [L] <---+ | core 2: LOCK XADD [L2]
| | | | | | |
+----------+---------+ | +--------+--------+
ping-pong: the line no sharing:
leaves the core on every store each core owns
its own lineThe LOCK XADD is still there — atomics are atomics — but the line is in the
core’s own cache in M state, so the store never leaves the core. The
contention disappears; the instruction count does not.
Trade-offs
The LIFO wake-up:
- Wins: no thundering herd; cache locality; idle threads exit; a
notify_oneinstead ofnotify_all. - Costs: unfairness (a thread at the tail can starve); the pool must
maintain an intrusive list; the wake-up path is more code than
notify_all; andwait()must not wake idle threads, or LIFO ordering is destroyed — which is why it doesn’t:
// src/Common/ThreadPool.cpp:678
/// Do NOT wake idle threads here. The LIFO scheduling guarantees that every
/// queued job has an associated wake-up (either a notified thread popped from
/// the idle stack, or a newly-created thread that will see the job in its
/// first worker-loop iteration, or a busy thread that will see the queued
/// job when it finishes its current one). Waking all idle threads would
/// destroy LIFO ordering — the whole point of this pool design.
job_finished.wait(lock, [this] { return scheduled_jobs == 0; });The per-CPU counters:
- Wins: counting scales across cores; the array stays in BSS; the trace path is opt-in.
- Costs: reading a counter is now
O(cpus)instead ofO(1)(a few hundred cache-line reads — negligible for asystem.query_loginsert, but it is whyProfileEventssnapshots are not free); the storage iscpus × events × 8bytes of address space (mostly untouched, thanks to lazy faulting); andsched_getcpu()is called on every increment, which is a TLS read on Linux with rseq but a real instruction sequence on other platforms.
The priority queue:
- Wins: jobs can be prioritised (the
AsyncLoaderuses this to keep background merges from starving foreground work). - Costs: a heap is
O(log n)on push and pop where a deque would beO(1); and because the queue is shared, it serialises all producers behind one mutex. For a pool whose whole point is to be the place where
all the contention goes, that is the right trade — but it is a trade.