A workqueue is a thread that executes functions submitted to a work queue from other threads or from interrupts.
What happens if the event detected by the interrupt requires a slow operation afterward? For example, when pressing a button, you might want to update an I2C display and save the event to SPI flash memory.
Writing to I2C and SPI takes time. Sometimes it requires waiting for the bus to be free (Mutex) or waiting for a byte to be sent. You cannot do this inside the ISR. If you try, you can block the system or cause a “Kernel Panic”.
To solve this dilemma, Zephyr offers us the perfect tool: the System Workqueue.
What is a workqueue?
A Workqueue is not a strange concept. In reality, it is simply a Thread that the Kernel creates automatically at boot.
This thread has a very boring job:
- It checks if there are functions (“work items”) in its queue.
- If there are, it takes the function and executes it.
- If there are none, it goes to sleep.
Since the workqueue executes the handler in a thread, it can use thread APIs. However, blocking the system workqueue delays other jobs sharing that queue.
Therefore, the strategy is:
- ISR (Fast): Detects the event (button) and “submits the work” to the queue. Exits immediately.
- Workqueue (Slow): Wakes up, takes the work, and executes the heavy task (I2C write) calmly.
This is called Bottom-Half processing or deferred processing.
Using the System Workqueue
Zephyr already comes with a default Workqueue configured, called the System Workqueue. For most simple tasks, this is the one we will use.
We need two steps:
Define the work: Create a k_work structure and tell it which function to execute.
Submit the work: Call k_work_submit from our interrupt.
Practical Example: From Button to Console
Let’s simulate that console printing (printk) is a heavy task we don’t want to perform in the ISR.
#include <zephyr/kernel.h>
#include <zephyr/drivers/gpio.h>
#include <zephyr/logging/log.h>
#include <errno.h>
LOG_MODULE_REGISTER(demo_workqueue, LOG_LEVEL_INF);
/* Button definition (assuming sw0 alias in devicetree) */
#define SW0_NODE DT_ALIAS(sw0)
static const struct gpio_dt_spec button = GPIO_DT_SPEC_GET(SW0_NODE, gpios);
static struct gpio_callback button_cb_data;
/* 1. Define the work structure */
struct k_work my_work;
/* This is the function that does the heavy work (runs in THREAD context) */
void my_work_handler(struct k_work *item)
{
/* We are now in thread context. Keep this work brief
because it shares the system workqueue with other elements. */
LOG_INF("Processing button event");
}
/* This is the ISR (runs in INTERRUPT context) */
void button_pressed(const struct device *dev, struct gpio_callback *cb,
uint32_t pins)
{
/* WE CANNOT SLEEP HERE. */
/* We just submit the work to the queue. It is instantaneous. */
k_work_submit(&my_work);
}
int main(void)
{
if (!gpio_is_ready_dt(&button)) {
return -ENODEV;
}
/* Initialize the work before enabling the interrupt. */
k_work_init(&my_work, my_work_handler);
if (gpio_pin_configure_dt(&button, GPIO_INPUT) < 0) {
return -EIO;
}
gpio_init_callback(&button_cb_data, button_pressed, BIT(button.pin));
if (gpio_add_callback(button.port, &button_cb_data) < 0) {
return -EIO;
}
if (gpio_pin_interrupt_configure_dt(&button, GPIO_INT_EDGE_TO_ACTIVE) < 0) {
return -EIO;
}
LOG_INF("System ready. Press the button.");
return 0;
}If you press the button many times while the same work object is pending, the requests are merged: fifty copies of the same work are not saved.
Delayed Work (k_work_delayable)
Sometimes we don’t want to delegate work for “right now”, but for “in 500ms”.
“Wait, weren’t Timers for that?”
Yes, but remember: timer callbacks run in interrupt context. If you want to execute something slow periodically or with a delay, timers are not suitable. You need a delayable work.
It is the perfect combination: The precision of a Timer + The safe context of a Thread.
struct k_work_delayable my_delayed_work;
void my_delayed_handler(struct k_work *item)
{
LOG_INF("2 seconds have passed and I can use a Mutex here.");
}
int main(void) {
k_work_init_delayable(&my_delayed_work, my_delayed_handler);
/* Schedule for 2 seconds from now */
k_work_schedule(&my_delayed_work, K_MSEC(2000));
return 0;
}This is extremely useful for software button Debounce:
- Button interrupt ->
k_work_reschedule(..., K_MSEC(50)), to restart the timer with each bounce. - After 50ms the handler executes (in a thread), checks if the button is still pressed, and acts.
System Workqueue or Custom Workqueue?
The system workqueue is a resource shared by the application and by components that choose to use it.
- If the task is brief, you can use the system workqueue.
- If it will wait for a long time, create your own queue to avoid delaying other elements.
In that case, create your own Workqueue:
K_THREAD_STACK_DEFINE(my_wq_stack, 1024);
struct k_work_q my_wq;
/* In main: */
k_work_queue_start(&my_wq, my_wq_stack, K_THREAD_STACK_SIZEOF(my_wq_stack),
5, NULL);Elements destined for this queue are submitted with k_work_submit_to_queue(&my_wq, &work_item).