ESP-IDF: one task, no pool locks¶
The component’s default root locks its arena and each of its sub-pools with the build’s
guard_t, an interrupt-masked critical section on a chip, because a default must be safe for any
number of tasks. A node whose graph is touched by one task only declares its own root with
tr::no_guard_t as the lock and injects it into its graph_t. The lock is a template argument,
so every lock and unlock compiles to nothing.
What to notice¶
It is a type, not a flag.
tr::mem::arena_root_t<tr::no_guard_t, N>is the default root’s own template with a different lock. Astatic_assertin the example pins that the lock is an empty type and that the root is never larger thantr::mem::mcu_root_t; the app prints both sizes. On a chip the build’s lock holds aportMUX_TYPEper sub-pool and per arena, so the difference shows; on thelinuxtarget’s unicore build both locks are one byte.The root is the app’s. Its region and free-list heads are the app’s
constinitarrays, so the root is constant-initialized and the linker map shows it as the app’s.bss. The graph is built over it (graph_t g(g_root)), and the default arena is not touched.An injected root serves every purpose itself. The graph derives no sub-pools from it (
derives_sub_pools()isfalse), so:stats.mem.values,.tablesand.netanswerSCHEMA_NOT_FOUNDon this node; the root’s own census is the number to read.This covers the pool, not the whole build. The graph’s vertex locks and value slot still use the build’s
guard_t. A single-threaded core build binds that totr::no_guard_tin itslibtracer/config_override.hpp; the ESP-IDF component generates that file and offers no Kconfig option for it.One task means one task. Anything else that reaches the graph, a link’s receive task included, makes
tr::no_guard_ta data race. This node has no link.
Source¶
1/*
2 * SPDX-License-Identifier: Apache-2.0
3 * SPDX-FileCopyrightText: Copyright 2026 avatarsd LLC
4 */
5
6/**
7 * @file
8 * @brief ONE CONCEPT — a node that runs on one task compiles its pool's locks away: an arena
9 * root over `tr::no_guard_t`, injected into the `graph_t`.
10 *
11 * The component's default root (`tr::mem::mcu_root_t`) locks its arena and each sub-pool with
12 * the build's `guard_t`, an interrupt-masked critical section on a chip, because a default has
13 * to be safe for any number of tasks. A node whose graph is touched by one task only declares
14 * its own root with `tr::no_guard_t` as the lock: each lock is then an empty type, every
15 * lock and unlock compiles to nothing, and the root is never larger. The lock is a template
16 * argument, so the choice costs nothing at run time and cannot be changed by mistake there.
17 *
18 * The root is the application's, over its own static region, so the linker map shows it as the
19 * app's `.bss`. An injected root serves every purpose itself: the graph derives no sub-pools
20 * from it. This covers the pool. The graph's own vertex locks and value slot still use the
21 * build's `guard_t`; a single-threaded core build binds that to `tr::no_guard_t` in its
22 * `libtracer/config_override.hpp`, which this component generates and does not expose.
23 */
24
25#include <array>
26#include <cstddef>
27#include <cstdio>
28#include <cstdlib>
29#include <span>
30#include <type_traits>
31
32#include "libtracer/guard.hpp"
33#include "libtracer/mem_arena.hpp"
34#include "libtracer/tracer.hpp"
35#include "sdkconfig.h"
36
37namespace {
38
39using tr::graph::path_t;
40using tr::graph::role_t;
41
42/** @brief Rows in the build's size-class table. */
43constexpr std::size_t kClasses = std::size(tr::graph::config_t::kSizeClasses);
44
45/** @brief The single-task root: the default root's shape with `tr::no_guard_t` as its lock. */
46using st_root_t = tr::mem::arena_root_t<tr::no_guard_t, kClasses>;
47
48static_assert(std::is_empty_v<tr::no_guard_t> && sizeof(st_root_t) <= sizeof(tr::mem::mcu_root_t),
49 "the lock is an empty type: it holds no state and its calls compile to nothing");
50
51/** @brief The app's arena region and the root's free-list heads, both zero-filled `.bss`. */
52alignas(64) constinit std::array<std::byte, 16384> g_region{};
53constinit std::array<void*, st_root_t::kHeads> g_heads{};
54
55/** @brief The root, constant-initialized over them. */
56constinit st_root_t g_root(
57 g_region, std::span<const std::size_t, kClasses>(tr::graph::config_t::kSizeClasses), g_heads);
58
59/** @brief Failed checks so far. */
60int g_failures = 0;
61
62/** @brief Print @p what with its verdict and count a failure. */
63void check(bool ok, const char* what) {
64 std::printf(" [%s] %s\n", ok ? "ok" : "FAIL", what);
65 if (!ok) ++g_failures;
66}
67
68/** @brief Print the verdict; on the `linux` target also exit with it, so CI can run this. */
69void finish() {
70 std::printf("RESULT %s\n", g_failures == 0 ? "ok" : "FAIL");
71#if CONFIG_IDF_TARGET_LINUX
72 std::exit(g_failures == 0 ? 0 : 1);
73#endif
74}
75
76} // namespace
77
78extern "C" void app_main(void) {
79 std::printf("root object: %zu B with tr::no_guard_t, %zu B with the build's guard_t\n",
80 sizeof(st_root_t), sizeof(tr::mem::mcu_root_t));
81
82 tr::graph::graph_t g(g_root); // every block this graph draws comes from g_root
83 const auto count = g.register_vertex(path_t("/counter"), role_t::STORED_VALUE);
84 for (std::uint32_t i = 1; i <= 100; ++i) {
85 const auto value = tr::view::over_bytes(std::as_bytes(std::span(&i, 1)), g.value_backend());
86 if (!value || !g.write(count, *value)) check(false, "write /counter");
87 }
88 const auto read = g.read(count);
89 check(read && (*read)->only().bytes().size() == sizeof(std::uint32_t),
90 "a hundred writes from one task, and the last one reads back");
91 check(!g.derives_sub_pools(), "an injected root serves every purpose itself");
92
93 const tr::mem::source_stats_t s = g_root.stats();
94 std::printf("app root: %zu of %zu bytes carved, %zu refusals\n", s.in_use, s.capacity,
95 s.refused);
96 check(s.in_use > 0 && s.refused == 0, "the graph drew from the app's root alone");
97 check(tr::mem::default_root().stats().in_use == 0, "and nothing from the default arena");
98 finish();
99}
Build and run¶
$ cd integrations/esp-idf/examples/concepts/single_threaded
$ idf.py set-target esp32c6 build
$ idf.py flash monitor # board-only
CI builds it for esp32c6, and also builds and runs it on the ESP-IDF linux target. See also:
a shared seam needs a thread-safe backend.