Queue Sets & Advanced Queue Patterns¶
Level 1 Module 4 covered a task blocking on a single queue. Real systems often need a task to wait on several input sources at once — a command queue and a sensor queue and a semaphore — without spinning across them with short timeouts (which burns CPU and adds latency). FreeRTOS's answer is the queue set: a container that lets one task block on "any of these queues/semaphores has something" and then tells you which one fired.
Creating and using a queue set¶
QueueHandle_t cmdQueue, sensorQueue;
SemaphoreHandle_t buttonSem;
QueueSetHandle_t qset;
void setup(void) {
cmdQueue = xQueueCreate(4, sizeof(Command));
sensorQueue = xQueueCreate(8, sizeof(Reading));
buttonSem = xSemaphoreCreateBinary();
// capacity = sum of the lengths of everything you plan to add
qset = xQueueCreateSet(4 + 8 + 1);
// every member must be added to the set BEFORE anything is sent to it
xQueueAddToSet(cmdQueue, qset);
xQueueAddToSet(sensorQueue, qset);
xQueueAddToSet(buttonSem, qset);
}
void dispatcherTask(void *pv) {
for (;;) {
// blocks until ANY member has data, returns which one
QueueSetMemberHandle_t activated =
xQueueSelectFromSet(qset, portMAX_DELAY);
if (activated == cmdQueue) {
Command c;
xQueueReceive(cmdQueue, &c, 0); // guaranteed non-blocking now
handleCommand(c);
} else if (activated == sensorQueue) {
Reading r;
xQueueReceive(sensorQueue, &r, 0);
handleReading(r);
} else if (activated == buttonSem) {
xSemaphoreTake(buttonSem, 0);
handleButtonPress();
}
}
}
The pattern that trips people up: xQueueSelectFromSet only tells you
which handle became ready — you still must call the ordinary
xQueueReceive/xSemaphoreTake on that specific handle to actually
consume the item. Always use a 0 timeout there since the set already told
you data is present.
The rule that makes this work: single consumer per member¶
A queue (or semaphore) added to a set can still be written to normally by
any number of producers — that part is unchanged. But only the queue set
itself should be read from once a queue is a set member; a second task
directly calling xQueueReceive on a queue that's also in a set creates a
race where the set's internal notification and the direct read can
disagree about whether data is still present. Pick one consumer path per
queue: either "read it directly" or "read it via the set that contains it,"
never both.
Comparing alternatives: why not just poll everything?¶
// BAD: burns CPU, adds latency up to the poll period, doesn't scale
for (;;) {
if (xQueueReceive(cmdQueue, &c, 0) == pdPASS) { handleCommand(c); }
if (xQueueReceive(sensorQueue, &r, 0) == pdPASS) { handleReading(r); }
vTaskDelay(pdMS_TO_TICKS(10));
}
Polling with short timeouts trades CPU/power for latency, scales badly past two or three sources, and gets worse the tighter you make the poll period. A queue set gives you genuine blocking (zero CPU while idle) across an arbitrary number of sources with response latency bounded only by scheduling, not by a poll interval.
The other common alternative — one dedicated task per queue, each doing its
own xQueueReceive, sharing state via a mutex — works, but multiplies task
(and stack) count and reintroduces the shared-state synchronization problem
Module 4 (Level 1) queues exist to avoid. Queue sets let a single task
own the dispatch logic with no shared mutable state at all.
Traps¶
- Adding a queue to a set that already has items in it:
xQueueAddToSetfails (returnspdFAIL) if the queue is non-empty at the time you add it, because the set's internal accounting can't retroactively account for items already there. Always create → add to set → then start sending. - Removing from a set while items are pending:
xQueueRemoveFromSetalso requires the queue be empty first, for the same reason. Drain (or guarantee no further sends, then drain) before removing. - Reading a set member directly, bypassing the set: as above — this
desyncs the set's notification state from the queue's actual contents,
and can cause
xQueueSelectFromSetto return a handle with nothing left to receive, or to miss signaling a handle that has data. - Sizing the set too small: the set's capacity argument to
xQueueCreateSetmust be at least the sum of the queue lengths (and 1 per binary/counting semaphore) of everything you intend to add — too small and laterxQueueAddToSetcalls fail silently unless checked. - Using a queue set as a substitute for priority between sources: a
queue set gives you "something is ready," not "the highest-priority
thing is ready first" — if
cmdQueuemust always be checked beforesensorQueuewhen both are ready simultaneously, you still need that ordering logic in theif/elsechain afterxQueueSelectFromSetreturns (it returns exactly one ready member per call, in an unspecified but consistent underlying order — don't assume priority ordering from it). - ISR-safe use:
xQueueSelectFromSetFromISRexists but is unusual — sets are almost always consumed from task context; sending to a set member from an ISR uses the ordinary...FromISRsend/give calls on the member itself.
How It Actually Works¶
A queue set doesn't merge the data paths of its member queues/semaphores at
all — under the hood, every member still has its own independent buffer and
list of waiting tasks exactly as before. What a queue set actually is is one
more queue, holding not your data but handles to whichever member just
became non-empty: when you xQueueSend to a queue that has been added to a
set, the send operation, immediately after placing your item, also pushes
that queue's own handle onto the set's internal queue and wakes the task
blocked in xQueueSelectFromSet. That task then reads the returned handle
and calls a plain xQueueReceive on it to actually pull the data — two
queue operations happen per item instead of one, which is the real cost of
being able to block on N queues from a single task. The single-consumer
rule exists because if two tasks were both waiting in
xQueueSelectFromSet, whichever wakes first could drain the underlying
member queue before the second task's subsequent xQueueReceive finds
anything there, turning "selected" into a race rather than a guarantee.
Cheat sheet¶
| API | Purpose |
|---|---|
xQueueCreateSet(sumOfLengths) |
Create a set sized for everything you'll add |
xQueueAddToSet(queueOrSem, set) |
Register a member — queue/semaphore must be empty |
xQueueRemoveFromSet(queueOrSem, set) |
Deregister — member must be empty |
xQueueSelectFromSet(set, timeout) |
Block until any member is ready; returns which handle |
xQueueSelectFromSetFromISR(set) |
Non-blocking ISR variant |
Then: xQueueReceive(handle, &item, 0) |
Actually consume — always 0 timeout after a select |
| Alternative: polling loop | Simpler, wastes CPU, latency bounded by poll period |
| Alternative: one task per source + mutex | No CPU waste, but multiplies tasks and reintroduces shared-state sync |
Exercise¶
- Build the
dispatcherTaskabove with all three source types (two queues, one semaphore). Confirm sending to any one of them independently wakes the dispatcher and routes to the correct handler. - Deliberately violate the single-consumer rule: spawn a second task that
calls
xQueueReceive(sensorQueue, ...)directly while the dispatcher also hassensorQueuein a set. Send several items and see the set'sxQueueSelectFromSetmiscount or hang — explain why in terms of the set's internal notification bookkeeping. - Replace the dispatcher with a naive polling loop (as shown in the "bad" example) and measure both CPU usage (or, in the POSIX port, wall time spent not blocked) and worst-case response latency to a button press at three different poll periods. Compare against the queue-set version's latency.