Skip to content

Commit f3d63c4

Browse files
committed
fix: guard-free recycling frame allocator, survives thread-exit teardown
Rework the recycling frame allocator so its per-thread pool is trivially destructible and constant-initialized. This removes the guard variable the compiler placed on the thread_local in local() (checked on every allocate/free), making the hot path a bare TLS load, and it fixes a thread-exit crash. Previously the pool held a non-trivial destructor that drained its cached blocks at thread exit. On MinGW toolchains that thread_local teardown can run twice, the second time on the already freed pool block, so the drain read a poisoned bucket count and indexed wild (observed as an IOCP-thread heap corruption once multi-threaded io_context tests began exiting worker threads, surfaced under gcov). With no pool destructor, that teardown cannot double-run. Cached blocks are reclaimed off the hot path instead: per thread by a janitor armed once (from the slow paths, and behind a guard-free flag in deallocate_fast) whose destructor drains via local() and is naturally idempotent (a second run finds empty buckets) and never touches its own storage; and the global pool by its holder's destructor at process exit. Adopts the guard-free allocator design from perf/frame-alloc-guard-free. Adds a thread-exit teardown regression test.
1 parent 0d3dd90 commit f3d63c4

3 files changed

Lines changed: 120 additions & 64 deletions

File tree

include/boost/capy/ex/recycling_memory_resource.hpp

Lines changed: 26 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -33,15 +33,6 @@ namespace capy {
3333
This is the default allocator used by run_async when no allocator
3434
is specified.
3535
36-
@note This resource honors only the default new alignment
37-
(`__STDCPP_DEFAULT_NEW_ALIGNMENT__`, typically
38-
`alignof(std::max_align_t)`). The alignment argument passed to
39-
`do_allocate`/`do_deallocate` (and to `allocate_fast`/`deallocate_fast`)
40-
is ignored; backing storage comes from `::operator new`. Over-aligned
41-
requests are therefore not satisfied. This is sufficient for coroutine
42-
frame allocation but means the resource cannot be used where
43-
over-aligned memory is required.
44-
4536
@par Thread Safety
4637
Thread-safe. The thread-local pool requires no synchronization.
4738
The global pool uses a mutex for cross-thread access.
@@ -80,7 +71,7 @@ class BOOST_CAPY_DECL recycling_memory_resource : public std::pmr::memory_resour
8071
struct bucket
8172
{
8273
std::size_t count = 0;
83-
void* ptrs[bucket_capacity];
74+
void* ptrs[bucket_capacity] = {};
8475

8576
void* pop() noexcept
8677
{
@@ -114,12 +105,12 @@ class BOOST_CAPY_DECL recycling_memory_resource : public std::pmr::memory_resour
114105
{
115106
bucket buckets[num_classes];
116107

117-
~pool()
118-
{
119-
for(auto& b : buckets)
120-
while(b.count > 0)
121-
::operator delete(b.pop());
122-
}
108+
// No destructor: a non-trivial dtor forces a guard variable on the
109+
// thread_local in local(), checked on every alloc/free. Constant
110+
// initialization plus a trivial dtor makes that access a bare TLS
111+
// load. Cached blocks are instead reclaimed explicitly: per-thread
112+
// by arm_thread_cleanup() at thread exit, and the global pool by
113+
// global()'s holder destructor at process exit.
123114
};
124115

125116
static pool& local() noexcept
@@ -134,31 +125,23 @@ class BOOST_CAPY_DECL recycling_memory_resource : public std::pmr::memory_resour
134125
void* allocate_slow(std::size_t rounded, std::size_t idx);
135126
void deallocate_slow(void* p, std::size_t idx);
136127

137-
public:
138-
/** Destructor.
128+
// Register a thread-exit callback that drains this thread's local
129+
// pool back to the OS. Called only off the hot path: unconditionally
130+
// from the slow paths, and once per thread from deallocate_fast
131+
// behind a guard-free flag.
132+
static void arm_thread_cleanup() noexcept;
139133

140-
Releases any blocks still held in this resource's thread-local
141-
pool for the calling thread. Blocks held in the process-wide
142-
global pool, and in other threads' thread-local pools, are
143-
released when those pools are destroyed.
144-
*/
134+
public:
145135
~recycling_memory_resource();
146136

147137
/** Allocate without virtual dispatch.
148138
149139
Handles the fast path inline (thread-local bucket pop)
150140
and falls through to the slow path for global pool or
151141
heap allocation.
152-
153-
@param bytes The number of bytes to allocate.
154-
155-
@return A pointer to the allocated storage.
156-
157-
@note The second (alignment) argument is ignored; only the
158-
default new alignment is honored. See the class-level note.
159142
*/
160143
void*
161-
allocate_fast(std::size_t bytes, std::size_t /*alignment*/)
144+
allocate_fast(std::size_t bytes, std::size_t)
162145
{
163146
std::size_t rounded = round_up_pow2(bytes);
164147
std::size_t idx = get_class_index(rounded);
@@ -175,17 +158,9 @@ class BOOST_CAPY_DECL recycling_memory_resource : public std::pmr::memory_resour
175158
Handles the fast path inline (thread-local bucket push)
176159
and falls through to the slow path for global pool or
177160
heap deallocation.
178-
179-
@param p Pointer previously returned by `allocate_fast`
180-
(or `do_allocate`) on a resource that compares equal to this one.
181-
182-
@param bytes The size, in bytes, originally requested for `p`.
183-
184-
@note The third (alignment) argument is ignored; only the
185-
default new alignment is honored. See the class-level note.
186161
*/
187162
void
188-
deallocate_fast(void* p, std::size_t bytes, std::size_t /*alignment*/)
163+
deallocate_fast(void* p, std::size_t bytes, std::size_t)
189164
{
190165
std::size_t rounded = round_up_pow2(bytes);
191166
std::size_t idx = get_class_index(rounded);
@@ -194,36 +169,27 @@ class BOOST_CAPY_DECL recycling_memory_resource : public std::pmr::memory_resour
194169
::operator delete(p);
195170
return;
196171
}
172+
// Guard-free flag (constinit bool, trivial dtor): arms thread-exit
173+
// cleanup exactly once for any thread that caches via deallocate,
174+
// including consumer threads that never hit a slow path.
175+
static thread_local bool armed = false;
176+
if(!armed)
177+
{
178+
armed = true;
179+
arm_thread_cleanup();
180+
}
197181
auto& lp = local();
198182
if(lp.buckets[idx].push(p))
199183
return;
200184
deallocate_slow(p, idx);
201185
}
202186

203187
protected:
204-
/** Allocate storage (`std::pmr::memory_resource` interface).
205-
206-
Forwards to `allocate_fast`. The alignment argument is ignored;
207-
see the class-level note.
208-
209-
@param bytes The number of bytes to allocate.
210-
211-
@return A pointer to the allocated storage.
212-
*/
213188
void*
214-
do_allocate(std::size_t bytes, std::size_t /*alignment*/) override;
215-
216-
/** Deallocate storage (`std::pmr::memory_resource` interface).
217-
218-
Forwards to `deallocate_fast`. The alignment argument is ignored;
219-
see the class-level note.
189+
do_allocate(std::size_t bytes, std::size_t) override;
220190

221-
@param p Pointer previously returned by `do_allocate`.
222-
223-
@param bytes The size, in bytes, originally requested for `p`.
224-
*/
225191
void
226-
do_deallocate(void* p, std::size_t bytes, std::size_t /*alignment*/) override;
192+
do_deallocate(void* p, std::size_t bytes, std::size_t) override;
227193

228194
bool
229195
do_is_equal(const memory_resource& other) const noexcept override

src/ex/recycling_memory_resource.cpp

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,29 +9,79 @@
99

1010
#include <boost/capy/ex/recycling_memory_resource.hpp>
1111

12+
#include <new>
13+
1214
namespace boost {
1315
namespace capy {
1416

17+
// Instance destruction does nothing: the resource is stateless (all pools
18+
// are static). Global-pool cleanup is owned by global()'s holder, exactly
19+
// as in the original where the global pool's own destructor drained it,
20+
// independent of any instance lifetime.
1521
recycling_memory_resource::~recycling_memory_resource() = default;
1622

23+
void
24+
recycling_memory_resource::arm_thread_cleanup() noexcept
25+
{
26+
struct janitor
27+
{
28+
~janitor()
29+
{
30+
// Return this thread's cached blocks to the OS so nothing
31+
// leaks at thread exit. The pool has a trivial dtor, so its
32+
// thread-local storage is still valid here.
33+
auto& lp = local();
34+
for(auto& b : lp.buckets)
35+
while(b.count > 0)
36+
::operator delete(b.pop());
37+
}
38+
};
39+
static thread_local janitor j;
40+
(void)j;
41+
}
42+
1743
recycling_memory_resource::pool&
1844
recycling_memory_resource::global() noexcept
1945
{
20-
static pool p;
21-
return p;
46+
// Holder gives the global pool a destructor (the trivial-dtor pool type
47+
// itself must stay guard-free for local()). Runs unconditionally at
48+
// process exit, mirroring the original global pool destructor, and is
49+
// locked because a worker thread may still be in a slow path. This path
50+
// is cold (slow paths only), so the holder's guard costs nothing hot.
51+
struct holder
52+
{
53+
pool p;
54+
55+
~holder()
56+
{
57+
std::lock_guard<std::mutex> lock(global_mutex());
58+
for(auto& b : p.buckets)
59+
while(b.count > 0)
60+
::operator delete(b.pop());
61+
}
62+
};
63+
static holder h;
64+
return h.p;
2265
}
2366

2467
std::mutex&
2568
recycling_memory_resource::global_mutex() noexcept
2669
{
27-
static std::mutex mtx;
28-
return mtx;
70+
// Never destroyed: it is locked during process-exit teardown (in
71+
// global()'s holder destructor), after a function-local `static
72+
// std::mutex` could itself have been destroyed. Placement-new into
73+
// static storage owns no heap allocation, so there is nothing to leak.
74+
alignas(std::mutex) static unsigned char storage[sizeof(std::mutex)];
75+
static std::mutex* const mtx =
76+
::new(static_cast<void*>(storage)) std::mutex();
77+
return *mtx;
2978
}
3079

3180
void*
3281
recycling_memory_resource::allocate_slow(
3382
std::size_t rounded, std::size_t idx)
3483
{
84+
arm_thread_cleanup();
3585
{
3686
std::lock_guard<std::mutex> lock(global_mutex());
3787
if(auto* p = global().buckets[idx].pop(local().buckets[idx]))

test/unit/ex/recycling_memory_resource.cpp

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
#include <boost/capy/ex/recycling_memory_resource.hpp>
1212

1313
#include <cstddef>
14+
#include <thread>
1415
#include <vector>
1516

1617
#include "test_suite.hpp"
@@ -69,11 +70,50 @@ class recycling_memory_resource_test
6970
drain();
7071
}
7172

73+
void
74+
testThreadExitTeardown()
75+
{
76+
// Each worker fills its thread-local pool with cached blocks and
77+
// then exits, running the pool's thread-exit drain. This is the
78+
// path that must survive the pool teardown being reached once
79+
// per exiting thread (and, on MinGW, potentially reached twice).
80+
// Repeat across many short-lived threads so a teardown fault
81+
// has ample opportunity to surface.
82+
constexpr int threads = 32;
83+
constexpr int rounds = 8;
84+
constexpr std::size_t bytes = 128; // size class 1
85+
constexpr std::size_t align = 8;
86+
87+
for(int r = 0; r < rounds; ++r)
88+
{
89+
std::vector<std::thread> ts;
90+
ts.reserve(threads);
91+
for(int t = 0; t < threads; ++t)
92+
{
93+
ts.emplace_back([] {
94+
recycling_memory_resource mr;
95+
std::vector<void*> ptrs;
96+
for(int i = 0; i < 40; ++i)
97+
ptrs.push_back(mr.allocate_fast(bytes, align));
98+
// Return them so the thread-local pool caches blocks
99+
// that its exit-time drain must release.
100+
for(void* p : ptrs)
101+
mr.deallocate_fast(p, bytes, align);
102+
});
103+
}
104+
for(auto& t : ts)
105+
t.join();
106+
}
107+
108+
BOOST_TEST_PASS();
109+
}
110+
72111
void
73112
run()
74113
{
75114
testIsEqual();
76115
testSlowPaths();
116+
testThreadExitTeardown();
77117
}
78118
};
79119

0 commit comments

Comments
 (0)