When building embedded applications with Zephyr RTOS, efficient task management is crucial. Zephyr offers several concurrency primitives such as threads, timers, and message queues. Among these, work queues stand out as a lightweight and flexible mechanism to defer and schedule work outside interrupt context.
In this post, we’ll explore what work queues are, why they’re useful, and how to use them effectively in Zephyr-based applications.
What Are Work Queues?
A work queue is essentially a kernel thread in Zephyr that processes "work items" asynchronously. Each work item is a function callback that gets executed in the context of the work queue thread.
This allows developers to:
- Offload processing from interrupt handlers to a thread context.
- Schedule work for deferred execution (either immediately or after a delay).
- Avoid blocking operations inside ISRs, where only minimal and time-critical actions should be performed.
Think of it like a "to-do list" for your application: ISRs or other parts of your program can drop tasks into the list, and the work queue thread will pick them up and run them.
Work Queues vs. Threads
You might wonder why not just create a dedicated thread for each job?
The advantage of work queues is that multiple work items can be handled by the same kernel thread, reducing memory footprint. Instead of spawning several threads, you just submit work to a queue and let a single thread execute them sequentially.
This makes work queues ideal for:
- Event-driven designs
- Interrupt offloading
- Deferred processing tasks
- Applications where memory efficiency matters
Built-in System Work Queue
Zephyr provides a system work queue by default. You don’t need to create one yourself; just initialize work items and submit them.
Here’s a minimal example:
#include <zephyr/kernel.h>
#include <zephyr/logging/log.h>
LOG_MODULE_REGISTER(workq_example, LOG_LEVEL_INF);
static void my_work_handler(struct k_work *work)
{
LOG_INF("Work item executed!");
}
K_WORK_DEFINE(my_work, my_work_handler);
void main(void)
{
LOG_INF("Submitting work item...");
k_work_submit(&my_work);
}
What happens here?
- We define a work item object
my_work
using (K_WORK_DEFINE
) at compiled time. - The handler
my_work_handler
is attached to that object - Inside
main()
, we submit the work item to the system work queue usingk_work_submit(&my_work)
- The work handler (
my_work_handler
) runs in the context of the system work queue thread.
You could also declare a struct k_work
manually and initialize it at run time
static struct k_work my_work;
static void my_work_handler(struct k_work *work)
{
LOG_INF("Work item executed!");
}
void main(void)
{
k_work_init(&my_work, my_work_handler);
k_work_submit(&my_work);
}
Delayed Work Items
Sometimes you don’t want the work to run immediately but after a delay. For this, Zephyr provides delayed work:
#include <zephyr/kernel.h>
#include <zephyr/logging/log.h>
LOG_MODULE_REGISTER(delayed_work, LOG_LEVEL_INF);
static void delayed_handler(struct k_work *work)
{
LOG_INF("Delayed work executed after 5 seconds!");
}
K_WORK_DELAYABLE_DEFINE(my_delayed_work, delayed_handler);
void main(void)
{
LOG_INF("Submitting delayed work...");
k_work_schedule(&my_delayed_work, K_SECONDS(5));
}
This will execute the handler after 5 seconds. You can also reschedule or cancel delayed work.
Creating Your Own Work Queue
While the system work queue is convenient, sometimes you need a dedicated work queue for specific tasks (e.g., separating time-sensitive tasks from background ones).
Here’s how to create a custom work queue:
#include <zephyr/kernel.h>
#include <zephyr/logging/log.h>
LOG_MODULE_REGISTER(custom_workq, LOG_LEVEL_INF);
#define STACK_SIZE 1024
#define PRIORITY 5
K_THREAD_STACK_DEFINE(my_stack, STACK_SIZE);
static struct k_work_q my_work_q;
static void my_custom_handler(struct k_work *work)
{
LOG_INF("Custom work executed!");
}
K_WORK_DEFINE(my_custom_work, my_custom_handler);
void main(void)
{
k_work_queue_init(&my_work_q);
k_work_queue_start(&my_work_q, my_stack,
K_THREAD_STACK_SIZEOF(my_stack),
PRIORITY, NULL);
LOG_INF("Submitting to custom work queue...");
k_work_submit_to_queue(&my_work_q, &my_custom_work);
}
This way, you have full control over the stack size, thread priority, and scheduling of your work queue.
When to Use Work Queues
Work queues are not always the best fit, but they shine in these situations:
- Interrupt offloading: e.g., handle a sensor reading in an ISR, then process data in work queue.
- Background tasks: like logging, housekeeping, or network packet parsing.
- Deferrable actions: tasks that can wait a few milliseconds without impacting system responsiveness.
- Shared resources: when you want sequential execution of tasks that access the same hardware.
Real-World Example: Bluetooth Advertising
Now let's look at a practical case: Bluetooth advertising.
In Zephyr's Bluetooth stack, callbacks may run in ISR context. You should not restart advertising or perform complex operations directly in these callbacks. Instead, use a work queue to safely offload the task.
Example: Restarting Advertising with Work Queue
#include <zephyr/kernel.h>
#include <zephyr/bluetooth/bluetooth.h>
#include <zephyr/bluetooth/hci.h>
#include <zephyr/bluetooth/gap.h>
#include <zephyr/bluetooth/conn.h>
#include <zephyr/logging/log.h>
LOG_MODULE_REGISTER(main, LOG_LEVEL_INF);
#define DEVICE_NAME CONFIG_BT_DEVICE_NAME
#define DEVICE_NAME_LEN (sizeof(DEVICE_NAME) - 1)
static struct k_work adv_work;
static const struct bt_data ad[] = {
BT_DATA_BYTES(BT_DATA_FLAGS, (BT_LE_AD_GENERAL | BT_LE_AD_NO_BREDR)),
BT_DATA(BT_DATA_NAME_COMPLETE, DEVICE_NAME, DEVICE_NAME_LEN),
};
static void adv_work_handler(struct k_work *work)
{
int err = bt_le_adv_start(BT_LE_ADV_CONN_FAST_2, ad, ARRAY_SIZE(ad), NULL, 0);
if (err) {
LOG_ERR("Advertising failed to start (err %d)", err);
return;
}
LOG_INF("Advertising successfully started");
}
static void advertising_start(void)
{
k_work_submit(&adv_work);
}
static void connected(struct bt_conn *conn, uint8_t err)
{
if (err) {
LOG_ERR("Connection failed, err 0x%02x", err);
return;
}
LOG_INF("Connected");
}
static void disconnected(struct bt_conn *conn, uint8_t reason)
{
LOG_INF("Disconnected, reason 0x%02x", reason);
}
static void recycled_cb(void)
{
LOG_INF("Connection object available from previous conn. Disconnect is complete!");
advertising_start();
}
BT_CONN_CB_DEFINE(conn_callbacks) = {
.connected = connected,
.disconnected = disconnected,
.recycled = recycled_cb
};
void main()
{
LOG_INF("Initialising bluetooth...");
int err = bt_enable(NULL);
if (err) {
LOG_ERR("Bluetooth init failed (err %d)", err);
return -1;
}
LOG_INF("Bluetooth initialised");
k_work_init(&adv_work, adv_work_handler);
advertising_start();
k_sleep(K_FOREVER);
}
Best Practices
- Keep handlers short: Work queue handlers run sequentially in a single thread. Long-running tasks may block others.
- Use delayed work carefully: Cancel delayed work when it’s no longer needed to save resources.
- Separate concerns: Use multiple work queues when you need different priorities or want to isolate critical jobs.
- Logging and debugging: Always use Zephyr’s logging module inside work handlers for better traceability.
Conclusion
Work queues in Zephyr provide a powerful and efficient mechanism for deferring and scheduling work outside interrupt context. They reduce the need for extra threads, save memory, and simplify handling background tasks.
By leveraging the system work queue or creating your own, you can build responsive and resource-efficient embedded applications on Zephyr RTOS.