· Nordic  · 5 min read

Understanding Work Queues in Zephyr RTOS

Work queues in Zephyr RTOS are a lightweight way to defer work, move processing out of interrupt context, and keep applications responsive without creating extra threads. In this article, we explain how work queues work, explore system and custom queues, show how to schedule delayed tasks, and demonstrate a real-world Bluetooth advertising example where work queues ensure safe and efficient operation.

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 using k_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.

  • embedded-systems
  • zephyr
  • zephyr-rtos
  • work-queue
  • bluetooth
  • bluetooth-advertising
  • interrupt-handling

Related articles

View All Articles »

Introduction to Zephyr Build System

Getting started with the nRF52832 in the nRF Connect SDK requires understanding how Zephyr’s build system works. This post introduces the core components of building applications, including CMake, west, and key configuration files like prj.conf and CMakeLists.txt. We walk through how west orchestrates the build process, how to configure project features, and provide a step-by-step example of building and flashing a simple Hello World application. By mastering these fundamentals, you’ll be ready to develop scalable and maintainable embedded applications with Zephyr.

Controlling WS2812 LED Strips with SPI on nRF52832 using nRF Connect SDK

WS2812 LEDs, also known as NeoPixels, are popular for creating colorful lighting effects with just a single data line. The challenge lies in their strict timing requirements, which can be tricky to handle on microcontrollers. In this post, we’ll show how to control a WS2812 LED strip on an nRF52832 custom board using the nRF Connect SDK. By repurposing the SPI driver to generate the precise waveforms, we can drive the LEDs reliably without bit-banging.

Build Configurations & Kconfig in nRF Connect SDK

Build configurations in the nRF Connect SDK let you fine-tune your application at compile time, controlling everything from logging verbosity to Bluetooth Low Energy parameters. In this post, we explore how to use prj.conf and menuconfig to manage Kconfig options, highlight common configurations such as logging, peripherals, and BLE settings, and walk through a practical example of enabling debug logging and tuning BLE buffers for higher performance.

Understanding Device Tree in nRF Connect SDK

Device Tree in nRF Connect SDK is a structured way of describing hardware, making your applications more portable and maintainable. Learn about DT hierarchy, nodes, properties, overlays, and practical examples like blinking LEDs, reading buttons, and adding custom sensors.