zstream: zstream_queue bug fixes and simplifications - #18898
Conversation
5f0e074 to
ecf8d4a
Compare
There was a problem hiding this comment.
Pull request overview
This PR refactors cmd/zstream/zstream_queue.c to simplify the zstream queue thread-pool lifecycle and locking, while improving robustness around queue index advancement and item workspace allocation/alignment.
Changes:
- Removes thread-pool “spindown” logic and associated locking complexity; pool is initialized once and retained for process lifetime.
- Locks queue scoring and adjusts signaling/locking order around the shared “enqueued” condition to reduce race potential.
- Refactors index sweeping (
advance_indexes()), changes queue workspace allocation to be 8-byte aligned, and separates slot vs. item workspace allocations.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
ecf8d4a to
2ec7e9c
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 1 out of 1 changed files in this pull request and generated 1 comment.
Suppressed comments (3)
cmd/zstream/zstream_queue.c:626
- Queue item storage is freed via
queue->zq_slots[0].qs_item, relying on the invariant that allqs_itempointers come from a single contiguous allocation and that index 0 always points to the base. This ownership model is implicit and fragile (e.g., future changes to per-slot allocation or different wiring would silently breakfree()). Consider storing the baseitemspointer explicitly instruct zstream_queue(e.g.,zq_items_base) and freeing that, which makes ownership and lifetime unambiguous.
free(queue->zq_slots[0].qs_item);
free(queue->zq_slots);
queue->zq_slots = NULL;
cmd/zstream/zstream_queue.c:180
- This now allows
zstream_queue_set_num_threads()to settp_num_threadsbelowMIN_THREADS(only warning), but the auto-detected path still enforcesMAX(..., MIN_THREADS). That creates inconsistent behavior depending on whether the value is user-set vs auto-detected. To keep semantics consistent, either (a) clamp user-providednup toMIN_THREADS, or (b) remove theMIN_THREADSenforcement from the auto-detected path if the minimum is no longer required.
} else if (n < MIN_THREADS) {
warnx("using only %u threads may limit performance, setting "
"anyway...", n);
} else if (n > 256) {
warnx("num_threads = %u seems suspiciously high, setting "
"anyway...", n);
}
pool.tp_num_threads = n;
cmd/zstream/zstream_queue.c:633
- With
thread_pool_spindown()removed and no equivalent path whentp_num_queuesreaches 0, worker threads andpool.tp_threadsappear to persist indefinitely after the last queue is destroyed. If this code is used in a long-running process that can create/destroy queues multiple times, this changes lifecycle behavior and can retain resources (threads, stack memory) unexpectedly. Consider either restoring a safe spindown when the last queue is destroyed, or introducing an explicit global/thread-pool shutdown API/atexit cleanup to ensure resources can be reclaimed deterministically.
if (pool.tp_num_queues > 0) {
/* Gaps are not allowed in the tp_queues array */
zstream_queue_t **qscan = &pool.tp_queues[0];
int i = pool.tp_num_queues;
7260aa9 to
9043a2e
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 1 out of 1 changed files in this pull request and generated 2 comments.
Suppressed comments (2)
cmd/zstream/zstream_queue.c:626
- Freeing the item storage via
queue->zq_slots[0].qs_itemcouples destruction logic to an implicit invariant (single contiguous item allocation and slot[0] pointing to its base). To make ownership explicit and less fragile, store the allocateditemsbase pointer in the queue struct (e.g.,zq_items_base) and free that directly inzstream_queue_destroy().
free(queue->zq_slots[0].qs_item);
free(queue->zq_slots);
cmd/zstream/zstream_queue.c:490
pthread_cond_wait()is a cancellation point. Previously, this file used a cleanup handler wrapper to ensure the mutex is released if a waiting thread is cancelled. If worker thread cancellation is still possible (now or in future changes), cancellation during this wait can exit while holdingtp_enqueue_mutex, risking deadlock. Either ensure worker threads are never cancelled and enforce that invariant, or restore a cleanup-handler approach / temporarily disable cancellation around waits.
pthread_cond_wait(&pool.tp_enqueued,
&pool.tp_enqueue_mutex);
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 1 out of 1 changed files in this pull request and generated 3 comments.
Suppressed comments (4)
cmd/zstream/zstream_queue.c:251
- Rounding item strides to 8 bytes doesn’t guarantee correct alignment for
queue_item_ton all platforms/ABIs (e.g., ifqueue_item_trequires >8-byte alignment). This can lead to undefined behavior whenqp_processdereferencesqs_item. Prefer rounding up to_Alignof(queue_item_t)(or_Alignof(max_align_t)ifqueue_item_tis opaque) rather than a hard-coded8.
size_t qpis_rounded = P2ROUNDUP(params->qp_item_size, 8);
uint8_t *items = safe_malloc(params->qp_queue_length * qpis_rounded);
for (int i = 0; i < params->qp_queue_length; i++) {
queue->zq_slots[i].qs_item =
(queue_item_t *)(items + i * qpis_rounded);
}
cmd/zstream/zstream_queue.c:627
- Freeing the item-storage block via
queue->zq_slots[0].qs_itemcreates a fragile coupling between slot 0 and the allocation base address. A more robust approach is to store theitemsbase pointer explicitly instruct zstream_queue(e.g.,zq_items_base) and free that, which avoids relying on slot contents never changing and makes ownership clearer.
free(queue->zq_slots[0].qs_item);
free(queue->zq_slots);
queue->zq_slots = NULL;
cmd/zstream/zstream_queue.c:176
- This changes the API behavior from rejecting values
< MIN_THREADSto allowing them. If other code relies onMIN_THREADSas a correctness/sizing invariant (not just a performance recommendation), this can introduce subtle scheduling/throughput issues. Consider either (a) preserving the previous hard validation for< MIN_THREADS, or (b) enforcingpool.tp_num_threads = MAX(n, MIN_THREADS)even for user-provided values and adjusting the warning messaging accordingly.
} else if (n == 0) {
errx(1, "number of threads must be at least 1");
} else if (n < MIN_THREADS) {
warnx("using only %u threads may limit performance, setting "
"anyway...", n);
} else if (n > 256) {
cmd/zstream/zstream_queue.c:634
- With
thread_pool_spindown()removed, the worker threads andpool.tp_threadsallocation are never torn down when the last queue is destroyed. In long-running processes or repeated create/destroy cycles, this can cause permanent thread/resource retention. Consider adding an explicit thread-pool shutdown path (e.g., whentp_num_queuesreaches 0) that cleanly stops workers and freespool.tp_threads, or provide a documented global “fini” API for callers to release resources deterministically.
if (pool.tp_num_queues > 0) {
/* Gaps are not allowed in the tp_queues array */
zstream_queue_t **qscan = &pool.tp_queues[0];
int i = pool.tp_num_queues;
This PR makes several changes to `zstream_queue.c` aimed at bulletproofing and simplification. ### Remove thread pool spindown This PR removes code that decomissioned worker threads once the last remaining queue had completed. The interlock between this operation and the creation of new queues complicated the locking system significantly and is known to have introduced at least two subtle locking bugs. With this change, the thread pool will be created once and retained until the process exits. ### Remove lock-free queue scoring The code is designed to work correctly even without scoring threads holding the queue mutexes of the queues they're examining. However, I've confirmed through performance testing that lock-free operation buys essentially nothing in terms of performance. The code doesn't really change, but it now locks around the scoring, ensuring that there is no possibility of skew among the observed queue indexes. Score skew is still possible (and expected) among queues. ### No condition signals without locks `assign_queue_and_get_work()` now retains the enqueue mutex until after signaling the "enqueued" condition if a scoring run determines that there is likely work available for more than one worker. ### Optimize advancement of the "claim" index for no-work items `advance_completion_index()` has been renamed `advance_indexes()` and sweeps both the "claim" and "completed" indexes. This does not affect correctness, but it reduces the number of empty worker loops. Formerly, advancement of the "claim" index only occured while a thread was actively collecting jobs. ### Call `advance_indexes()` even on `zstream_queue_fini()` Enqueues of zero-cost items now consistently trigger a call to `advance_indexes()`. ### Round up requested item sizes to a pessimistic boundary Queues formerly allocated their item workspaces as a single block indexed by slot number. That caused alignment problems when API clients requested peculiarly-shaped buffers. The requested size is now rounded up to a pessimistic boundary (the _Alignof of a worst-case union). ### Two-block memory allocation for new queues Previously, `zstream_queue` allocated memory for queue slots and the item workspaces to which they point as a single block. The code is more readable when these are separate allocations. Signed-off-by: Garth Snyder <garth@garthsnyder.com>
9043a2e to
b0fcfb0
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Suppressed comments (2)
cmd/zstream/zstream_queue.c:180
zstream_queue_set_num_threads(0)now fatals, butthread_pool_spinup()explicitly treatstp_num_threads == 0as the "use one thread per core" default. Since the header docs also describe 0 as the implicit default, rejecting 0 here changes the valid parameter contract and makes it impossible to explicitly reset/request the default behavior.
if (pool.tp_threads != NULL) {
errx(1, "thread pool size must be set before creating queues");
} else if (n == 0) {
errx(1, "number of threads must be at least 1");
} else if (n < MIN_THREADS) {
cmd/zstream/zstream_queue.c:235
zstream_queue_create()rejectsqp_batch_budget == 0, butclaim_batch()still contains explicit logic for theqp_batch_budget == 0case (first_and_only). Either0is meant to be a supported value (e.g., force single-item batches) or the special-case should be removed; as written, the validation makes that code path unreachable and may break callers that previously relied on0being valid.
VERIFY3P(params->qp_process, !=, NULL);
VERIFY3P(params->qp_cost, !=, NULL);
VERIFY3U(params->qp_item_size, >, 0);
VERIFY3U(params->qp_batch_budget, >, 0);
VERIFY3U(params->qp_queue_length, >, 0);
This PR makes several changes to
zstream_queue.caimed at bulletproofing and simplification. There are no API changes.Remove thread pool spindown
This PR removes code that decomissioned worker threads once the last remaining queue had completed. The interlock between this operation and the creation of new queues complicated the locking system significantly and is known to have introduced at least two subtle locking bugs. With this change, the thread pool will be created once and retained until the process exits.
Remove lock-free queue scoring
The code is designed to work correctly even without scoring threads holding the queue mutexes of the queues they're examining. However, I've confirmed through testing that lock-free operation buys essentially nothing in terms of performance. The code doesn't really change, but it now locks around the scoring, ensuring that there is no possibility of skew among the observed queue indexes. Score skew is still possible (and expected) among queues.
No condition signals without locks
assign_queue_and_get_work()now retains the enqueue mutex until after signaling the "enqueued" condition if a scoring run determines that there is likely work available for more than one worker.Optimize advancement of the "claim" index for no-work items
advance_completion_index()has been renamedadvance_indexes()and sweeps both the "claim" and "completed" indexes. This does not affect correctness, but it reduces the number of empty worker loops. Formerly, advancement of the "claim" index only occured while a thread was actively collecting jobs.Call
advance_indexes()even onzstream_queue_fini()Enqueues of zero-cost items now consistently trigger a call to
advance_indexes().Round up requested item sizes to a pessimistic boundary
Queues formerly allocated their item workspaces as a single block indexed by slot number. That caused alignment problems when API clients requested peculiarly-shaped buffers. The requested size is now rounded up to a pessimistic boundary.
Two-block memory allocation for new queues
Previously,
zstream_queueallocated memory for queue slots and the item workspaces to which they point as a single block. The code is more readable when these are separate allocations.Motivation and context
I was finally able to catch a deadlock on a GitHub test VM and get a multi-threaded backtrace. It was about what I expected, related to thread pool spindown. This feature is largely hygienic and is just as well done away with.
How has this been tested?
I've been running this in conjunction with the
zstream selftestpatch, which covers queues extensively. Here, I have inverted the commit order so that this one comes first. The selftests will come right after, but in the interim queues are still used by some of the existing ZTS tests.Types of Changes
Checklist
Signed-off-by.