Skip to content

Do NOT use async-executor and older edge-executor for now (WAS: embassy-time-driver: Deadlock when timer callback races with executor's concurrent_queue push) #630

Description

@torkleyy

TL;DR: Do NOT use async-executor or edge-executor V <= 0.4.1 because the queues they use (crossbeam-queue and concurrent-queue) do NOT work with multi-prio threads and lead to priority inversion).

Until (or if) the problem in async-executor is confirmed and fixed, consider using:

  • embassy-executor
  • edge-executor from GIT
  • The executor from the futures crate

See this for more details.

====================================

I've been hitting intermittent deadlocks when using the embassy-time-driver feature with async-executor. After a lot of funny watchdog resets, firmware hangs and jtag debugging, I was able to trace it back to a priority inversion between the ESP-IDF timer task and the async executor.

The Problem

The timer callback in embassy_time_driver runs on the ESP-IDF timer task (high priority) and directly calls schedule_next_expiration(), which eventually invokes waker.wake() for expired timers.

These wakers originate from async-executor, which internally uses concurrent_queue for its task queue.

When the executor (running at normal priority) is in the middle of a concurrent_queue::push() operation and a timer fires, the following sequence occurs:

  1. The executor is mid-push to concurrent_queue (at a block boundary in the queue's internal state).
  2. The timer task preempts and runs the callback.
  3. The callback calls waker.wake(), which attempts to push to the same concurrent_queue.
  4. concurrent_queue detects contention and calls busy_wait(), which invokes std::thread::yield_now().
  5. On FreeRTOS, yield_now() maps to vTaskDelay(0), which only yields to equal or higher priority tasks.
  6. The lower-priority executor never resumes execution, resulting in a deadlock.

Relevant Code

esp-idf-svc/src/timer.rs

Lines 484 to 489 in 71bb200

.timer(move || {
static_self
.inner
.lock()
.borrow_mut()
.schedule_next_expiration()

The schedule_next_expiration() call chains into embassy_time_queue_utils::Queue::next_expiration(), which calls waker.wake() for each expired timer.

Environment

  • ESP32 (dual-core; reproduces on single-core as well)
  • async-executor as the runtime
  • Heavy timer usage (multiple concurrent Timer::after() calls)

Potential Fix Direction

The timer callback should not call waker.wake() directly, since it executes at high priority. A possible approach:

  • The timer callback signals a semaphore and returns immediately.
  • A helper task, running at the same priority as the executor, waits on the semaphore and calls schedule_next_expiration().
  • With equal priorities, vTaskDelay(0) should correctly yield between tasks.

Not sure if there's a simpler fix, if you have an idea please let me know...
I think this is the same problem as #619

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    • Status
      Done

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions