Zephyr Thread Join: Establish Completion Before Reclaiming Thread-Owned Objects

A worker’s final “done” semaphore is not proof that Zephyr has finished terminating the worker. The worker can signal, be preempted, and still have instructions or kernel exit processing ahead of it. Use a successful k_thread_join() as the kernel termination boundary before reusing its thread control storage or making completion-dependent reclamation decisions. Treat every nonzero join result as a failure to establish that boundary.

This guide covers upstream Zephyr 3.7.0, a manager thread and a known, initialized worker that normally exits by returning from its entry function. It excludes respawning an aborted thread as a recovery strategy and userspace object-permission edge cases. Shared callbacks, interrupts and peripheral activity may outlive a worker; join does not prove their quiescence.

Separate application completion from kernel termination

The versioned thread lifecycle guide explicitly warns that an application completion signal can be observed before kernel termination processing finishes. It also states that a terminating thread must release resources it owns; termination does not automatically reclaim mutexes or dynamically allocated memory.

These are independent obligations. The worker should finish its application cleanup before returning. The manager should synchronize with actual termination before reclaiming storage whose validity depends on that thread having stopped. A successful join is not a destructor that repairs omissions in the worker’s cleanup path.

Worker cleanup and optional done signal precede entry return and kernel termination; a manager can observe done too early, while successful join crosses the termination boundary before eligible storage is reused.
An application acknowledgment may precede final termination. Successful join establishes the kernel boundary, while external owners still need separate lifetime proof.

Interpret the result rather than the passage of time

The tagged join API distinguishes four useful results:

  • Zero: the target has exited or was not running under the API’s applicable state rules. For this guide’s known created worker, the termination wait succeeded.
  • -EBUSY: a no-wait attempt did not establish completion. It is a snapshot, not a promise that waiting a little longer will be enough.
  • -EAGAIN: the finite wait expired. The worker can still be active; its storage must remain valid.
  • -EDEADLK: a prohibited join dependency was detected, such as a blocking self-join or the direct reciprocal join case.

Do not treat -EAGAIN as permission to free the worker’s context and then ask it to stop again. A timeout changes the manager’s policy, not the worker’s lifetime. Preserve the thread object, stack allocation and arguments while diagnosing why the termination condition has not been reached.

The 3.7.0 scheduler implementation checks the already-dead and no-wait cases before checking self or reciprocal joins. Therefore, do not write a test expecting every self-join call to return the same error regardless of timeout. More importantly, the direct deadlock checks are not a complete graph analysis of application resources.

Review the manager’s dependency graph

Suppose the manager holds a mutex while joining a worker that needs that mutex for its final cleanup. The kernel’s self-join check cannot resolve this application dependency. A finite timeout may reveal it, but repeated retries while retaining the mutex preserve the problem. Release resources needed by the worker before waiting for its termination.

Likewise, a worker waiting for the manager to consume its last message cannot terminate while the manager waits exclusively in join. Define whether final results are copied before the join, stored in manager-owned memory, or collected afterward through an independently valid object. Do not leave the only shutdown progress action in the joining thread’s blocked path.

Classify what successful join permits

Object What must be established
Worker thread control storage Kernel termination completed and no application user still accesses it
Worker stack allocation Thread execution has ended; reclaim only through the matching allocation policy
Private heap blocks Named cleanup owner released them, or manager has an explicit post-join cleanup contract
Published payload or callback context All external users finished, independently of the worker’s join
Peripheral transfer buffer The relevant transfer and callback lifetime have ended

A join result does not validate an arbitrary or stale thread pointer. Keep a manager-owned association between the worker identity, its thread object and its storage. Do not recycle that association for a new worker while another participant may still join or inspect the previous one. If several managers can initiate reclamation, choose one authority rather than allowing each successful waiter to free the same allocation.

A hypothetical completion-race test

Consider a worker that writes its final result, gives a completion semaphore and then waits on a test-only gate before returning. A higher-priority manager receives the semaphore while the worker is still deliberately alive. A no-wait join should not establish termination at this point. The manager must retain storage, release the test gate, and only reclaim after a later successful join.

This test makes the race deterministic without actually freeing a live stack. It demonstrates why adding a small sleep after the done signal is not a reliable lifetime barrier. No target run of this test is claimed here.

  1. Verify the worker’s final cleanup path with no completion signal, then join it successfully.
  2. Exercise the gated signal-before-return sequence and confirm the manager keeps all dependent storage.
  3. Hold the gate beyond a finite join timeout; verify -EAGAIN causes diagnosis rather than reclamation.
  4. Test an already terminated worker and confirm repeated observation does not imply repeated ownership of cleanup.
  5. Test prohibited join dependencies in a controlled fixture, and separately inspect resource cycles that the kernel cannot detect.

For deployed code, record worker identity, join timeout, result and cleanup owner. A join failure should preserve enough state to investigate the outstanding dependency. A successful join should move the manager through one explicit reclamation transition, while keeping externally referenced objects alive until their own completion conditions are met.

Similar Posts