Doll 3 — Pool — Quick Reference
See Deep Dive for hook examples, patterns, and extended explanations.
You get:
- Items that come back.
- Reuse without re-allocation.
- Policy hooks for flow control.
At some point allocations hurt. Not always. Only under pressure.
You add Pool.
First version is simple. It works.
Then:
- too many items
- or not enough
You add limits. You start to decide: keep or drop.
Reuse is not free. It needs policy.
THE FIRST RULE OF POOL
- Pool has many conditions, results, and rules.
- That is not a bug — it is the point.
- Pool tries to catch wrong combinations early — before they become silent failures.
- Pool is strong. Pool saves lives. (We are serious about the first part.)
The rule: check the result of every API call.
| API | Returns | "Ok" means |
|---|---|---|
pool_init |
nothing | no panic — bad input panics immediately |
pool_get |
Pool_Get_Result |
.Ok and m^ is non-nil |
pool_get_wait |
Pool_Get_Result |
.Ok and m^ is non-nil |
pool_put |
nothing | m^ is nil after the call — pool took it |
pool_close |
(list.List, ^PoolHooks) |
always succeeds — returned list is yours |
For pool_put: if m^ is still non-nil after the call, the pool is closed.
You own the item.
Dispose manually.
For pool_get / pool_get_wait: any result other than .Ok has a specific meaning.
See the result table below.
Recycler — your hooks for the pool
Builder from Doll 1 creates and destroys by id. Recycler extends that idea.
In standalone code (Doll 1–2), Builder creates and destroys directly.
In pooled code (Doll 3+), on_get and on_put take over that role.
Recycler replaces Builder when you have a pool.
Recycler adds:
- Reuse — reinitialize instead of destroy + create.
- Policy — decide whether to keep or drop.
- Counts —
in_pool_counttells how many items are idle. - Context —
ctxcarries your state. - Setup —
idsdeclares which item types this pool handles.
PoolHooks
PoolHooks :: struct {
ctx: rawptr, // user context — carries master or any state
// may be nil — pool passes it as-is
ids: [dynamic]int, // user-owned; non-empty, all > 0; user deletes in freeMaster
on_get: proc(ctx: rawptr, id: int, in_pool_count: int, m: ^MayItem),
on_put: proc(ctx: rawptr, in_pool_count: int, m: ^MayItem),
}
Two procedures only.
Both communicate through m.
Both are required.
ctx may be nil.
Pool passes it as-is.
Hook must handle nil ctx safely.
ids is a [dynamic]int owned by the user:
- Populate with
appendbefore callingpool_init. - Delete in
freeMasterbeforefree(master, alloc).
ctx is runtime — cannot be set in a :: compile-time constant.
Set it before calling pool_init.
on_get rule
Pool calls on_get on every pool_get.
Exception: Available_Only — on_get is never called.
Pool passes m^ as-is.
Hook decides what to do.
| Entry state | Meaning | Hook must |
|---|---|---|
m^ == nil |
no item available | create a new item, set node.id = id, set m^ |
m^ != nil |
recycled item | reinitialize for reuse |
in_pool_count: number of items with this id currently idle in the pool.
Not total live objects.
After on_get:
| Exit state | Meaning |
|---|---|
m^ != nil |
item ready — pool returns .Ok to caller |
m^ == nil |
pool returns .Not_Created to caller |
.Not_Created is not always an error.
Hook may return nil on purpose.
on_put rule
Called during pool_put, outside lock.
in_pool_count: current count of items with this id currently idle in the pool.
After on_put:
| Exit state | Meaning |
|---|---|
m^ == nil |
hook disposed it — pool discards |
m^ != nil |
pool stores it |
Hook rules
- All hooks are called outside the pool mutex — guaranteed. Hooks may therefore take their own locks without deadlock risk.
- Hooks must NOT call
pool_getorpool_put— the pool is in the middle of its work when a hook is called. A reentrant call sees inconsistent state and corrupts the pool silently, with no immediate error.
[itc: hook-reentrancy-guard]— To catch violations at runtime: use a@(thread_local) _pool_in_hook: bool— set before calling any hook, cleared after. Assert!_pool_in_hookon entry topool_get/pool_put. A pool struct field would not work — it would incorrectly block other threads callingpool_getconcurrently.
- Allocator stored in
ctxmust be thread-safe. ctxmust outlive the pool.
Pool API
Pool holds reusable items.
Works with ^PolyNode only.
Does not know your types.
Pool is just storage.
All lifecycle decisions live in PoolHooks.
Common behavior: All pool operations validate the handle's ID. If the ID is not POOL_ID (-2), the operation will panic.
Types
Pool :: ^PolyNode
Pool_Get_Mode :: enum {
Available_Or_New, // existing item if available, otherwise create
New_Only, // always create
Available_Only, // existing item only — no creation; on_get never called
}
Pool_Get_Result :: enum {
Ok, // item returned in m^
Not_Available, // Available_Only: no item stored — on_get was not called
Not_Created, // on_get ran and returned nil — may be deliberate or failure
Closed, // pool is closed
Already_In_Use, // m^ != nil on entry — caller holds an unreleased item
}
New / Init / Close
pool_new :: proc(alloc: mem.Allocator) -> Pool
pool_init :: proc(p: Pool, hooks: ^PoolHooks)
pool_close :: proc(p: Pool) -> (list.List, ^PoolHooks)
matryoshka_dispose :: proc(m: ^MayItem)
pool_init:
- Takes
^PoolHooks. - Pool stores the pointer.
- User keeps the struct.
pool_closerule:
- Returns all items currently stored in the pool as
list.List. - Returns
^PoolHooks— the pointer passed topool_init. - Pool zeros its internal hooks pointer on close.
- Post-close
pool_get/pool_putreturn.Closedor no-op. - Pool does not call
on_putduring close. The returned list is yours — handle each item as your shutdown strategy requires. - Calling
pool_closeon a pool created withpool_newbut never passed topool_initis safe — no hooks are registered so nothing is called. The pool handle is zeroed.
get — acquire ownership
| Mode | Behavior |
|---|---|
.Available_Or_New |
check free-list; call on_get on hit or miss |
.New_Only |
always call on_get with m^==nil; skip free-list |
.Available_Only |
free-list only; return .Not_Available if empty — on_get not called |
| Result | Meaning |
|---|---|
.Ok |
item acquired — m^ set to item |
.Not_Available |
.Available_Only and no item stored |
.Not_Created |
on_get ran and returned nil |
.Closed |
pool is closed |
.Already_In_Use |
m^ was non-nil on entry — release current item first |
Validation order
Both pool_get and pool_get_wait apply the same entry checks:
| Priority | Check | Result |
|---|---|---|
| 1 | id == 0 |
panic — zero id is always a programming error |
| 2 | m^ != nil |
.Already_In_Use — caller holds an unreleased item |
| 3 | pool closed | .Closed |
| 4 | id not in registered set (open pool only) |
panic — foreign id is a programming error |
| 5 | proceed with get logic | — |
get_wait — block until item available
Equivalent to pool_get(.Available_Only) but with blocking.
Never calls on_get — only waits for an item to be stored.
Warning: The item returned by pool_get_wait is in the state left by the last on_put call — not a freshly initialized state. Callers must reinitialize the item before use. This differs from pool_get(.Available_Or_New), which always calls on_get to ensure a fresh or reinitialized state.
pool_get_wait with timeout = 0 is the same as pool_get with Available_Only.
timeout |
Behavior |
|---|---|
== 0 |
non-blocking — returns .Not_Available immediately if no item stored |
< 0 |
blocks forever — waits until an item is put back or pool is closed |
> 0 |
blocks up to the duration — returns .Not_Available on expiry |
| Result | Meaning |
|---|---|
.Ok |
item acquired — m^ set to item |
.Not_Available |
no item stored (non-blocking or timeout expired) |
.Closed |
pool is closed, or closed while waiting |
.Already_In_Use |
m^ already holds an item — caller error |
If pool_close is called while a Master is waiting, all waiters wake and receive .Closed.
put — return to pool
How it works:
- Check
m.?.id: id == 0→ PANIC (zero is always invalid)id not in ids[]→ PANIC (not registered — programming error) > Implementation note: Odin'sinoperator does not work on[dynamic]int. Useslice.contains(hooks.ids[:], id).- Get
in_pool_countfor this id (under lock, then unlock) - Call
hooks.on_put(ctx, in_pool_count, m)— outside lock - If
m^is still non-nil → push to free-list, increment count, setm^ = nil(under lock)
Open pool → on_put decides: hook sets m^=nil (disposed) or leaves m^!=nil (stored).
Closed pool + valid id:
pool_putreturns withm^still non-nil. Caller owns the item. Must dispose manually. Does not panic.
defer pool_put — when is it safe?
pool_put with m^ == nil is always a no-op.
No id check. No panic.
This means defer pool_put can be placed immediately after m: MayItem, before pool_get:
m: MayItem
defer pool_put(p, &m) // [itc: defer-put-early] — safe: pool_put is no-op when m^ == nil
if pool_get(p, id, .Available_Or_New, &m) != .Ok {
return
}
// ... work ...
Three outcomes when defer pool_put runs:
m^ == nil(pool_get failed, or item was transferred) →pool_putis a no-op.m^ != nil(item was not transferred) →pool_putrecycles oron_putdisposes.m^ != nilwith unknown id or zero id →pool_putpanics — programming error.
Safe for valid ids. The panic is the correct behavior — it tells you exactly where the bug is.
[itc: defer-put-early]— candidate fordesign/sync/new-idioms.md.
put_all — return a chain
Walks the linked list starting at m^, calling pool_put on each node.
Panics on zero or unknown id in any node.
If the panic occurs on node N in a chain of M nodes, nodes N+1 through M are never returned to the pool and leak. Pre-validate all ids before calling pool_put_all if you need to avoid this.
ID Rules
- Every item id must be != 0. Zero is reserved/invalid.
pool_initreads valid ids fromhooks.ids.- User populates with
appendbefore callingpool_init. pool_putpanics onid == 0(open or closed).pool_putpanics on unknown id only when the pool is open.- Post-close the pool holds no hooks and cannot validate ids.
- Unknown id with closed pool leaves
m^non-nil. on_getsetsnode.idat allocation time.- Id values are user-defined integer constants — typically from an enum.