Doll 3 — Pool — Deep Dive
See Quick Reference for API signatures and contracts.
Safety: Handle Validation
All pool operations (pool_init, pool_get, pool_get_wait, pool_put, pool_put_all, pool_close) validate the Pool handle. If the PolyNode.id is not POOL_ID (-2), the operation will panic immediately. This prevents accidentally using a data item or a mailbox as a pool.
Recycler — from Builder to hooks
You already have Builder from Doll 1.
Builder creates and destroys by id.
Recycler extends that idea.
Recycler adds:
- Reuse — reinitialize instead of destroy + create.
- Policy — decide whether to keep or drop.
The same creation and destruction logic from Builder lives in on_get and on_put.
Hook examples
master_on_get :: proc(ctx: rawptr, id: int, in_pool_count: int, m: ^MayItem) {
master := (^Master)(ctx)
if m^ == nil {
// no item available — create new one using master.alloc
switch ItemId(id) {
case .Chunk:
c := new(Chunk, master.alloc)
c.id = id
m^ = (^PolyNode)(c)
case .Progress:
p := new(Progress, master.alloc)
p.id = id
m^ = (^PolyNode)(p)
}
} else {
// recycled item — reinitialize using master fields
ptr, ok := m^.?
if !ok { return } // Should not happen if m^ != nil
switch ItemId(ptr.id) {
case .Chunk: (^Chunk)(ptr).len = 0
case .Progress: (^Progress)(ptr).percent = 0
}
}
}
master_on_put :: proc(ctx: rawptr, in_pool_count: int, m: ^MayItem) {
master := (^Master)(ctx)
if m == nil || m^ == nil { return }
node := m^
#partial switch FlowId(node.id) {
case .Chunk:
if in_pool_count > 400 {
free((^Chunk)(node), master.alloc)
m^ = nil // dispose — pool will not store
}
case .Progress:
if in_pool_count > 128 {
free((^Progress)(node), master.alloc)
m^ = nil // dispose — pool will not store
}
}
// m^ still non-nil here → pool stores it
}
Standalone Recycler use
Recycler without Pool is valid.
It is Builder with policy.
User calls on_get and on_put directly.
User decides keep or drop without pool storage.
Why panic on unknown id?
A foreign id on pool_put is almost always a bug:
- wrong cast earlier
- wrong pool
- memory corruption
- use-after-free
Silent recycling would create silent leaks or use-after-free later.
A loud panic during development is better than hunting ghosts in production.
Zero is always invalid because it is the zero value of int.
An uninitialized PolyNode would have id == 0.
Panicking on zero catches missing initialization immediately.
Setup example
FlowId :: enum int {
Chunk = 1, // must be != 0
Progress = 2,
}
// Setup: populate hooks.ids before pool_init
append(&hooks.ids, int(FlowId.Chunk))
append(&hooks.ids, int(FlowId.Progress))
p := pool_new(alloc)
pool_init(p, &hooks)
Master with Pool — extending Doll 2's Master
In Doll 2, Master held Builder and mailbox references.
Now Master holds Pool and Recycler (PoolHooks) too.
Builder from Doll 1 becomes the basis for your hooks.
The same creation and destruction logic lives in on_get and on_put.
Master :: struct {
pool: Pool,
hooks: PoolHooks,
inbox: Mailbox,
alloc: mem.Allocator,
// ... other state ...
}
newMaster :: proc(alloc: mem.Allocator) -> ^Master {
m := new(Master, alloc)
m.alloc = alloc
m.hooks = PoolHooks{
ctx = m,
on_get = master_on_get,
on_put = master_on_put,
}
append(&m.hooks.ids, int(FlowId.Chunk))
append(&m.hooks.ids, int(FlowId.Progress))
m.pool = pool_new(alloc)
pool_init(m.pool, &m.hooks)
m.inbox = mbox_new(alloc)
return m
}
freeMaster :: proc(master: ^Master) {
// Required order: close → process remaining → dispose → free ctx (master).
// Freeing master before pool_close causes use-after-free in hooks.
// 1. close pool — get back stored items
nodes, _ := pool_close(master.pool)
// 2. process remaining and dispose all returned items
// NOTE: dispose nodes before freeing other Master resources.
for {
raw := list.pop_front(&nodes)
if raw == nil { break }
// dispose node — master knows how
}
// 3. teardown pool
m_pool: MayItem = (^PolyNode)(master.pool)
matryoshka_dispose(&m_pool)
// 4. close and process remaining mailbox
remaining := mbox_close(master.inbox)
// process remaining remaining...
// 5. teardown mailbox
m_mb: MayItem = (^PolyNode)(master.inbox)
matryoshka_dispose(&m_mb)
// 6. delete ids dynamic array (user-owned)
delete(master.hooks.ids)
// 7. free Master last — save alloc first
alloc := master.alloc
free(master, alloc)
}
Pool borrows hooks — pointer, not copy.
freeMaster owns the full teardown.
Pre-allocating (Seeding the Pool)
To avoid runtime latency, pre-allocate before starting Masters:
master := newMaster(context.allocator)
for _ in 0..<100 {
m: MayItem
if pool_get(master.pool, int(FlowId.Chunk), .New_Only, &m) == .Ok {
pool_put(master.pool, &m) // put back immediately — goes to free-list
}
}
New_Only always calls on_get with m^==nil, forcing creation even when items are stored.
Pool Get Modes — examples
Mode is a per-call parameter of pool_get.
Not a pool-wide setting.
// Normal operation — use stored item if available, create if not
pool_get(master.pool, int(FlowId.Chunk), .Available_Or_New, &m)
// Force creation — use for seeding or when you want a guaranteed fresh item
pool_get(master.pool, int(FlowId.Chunk), .New_Only, &m)
// Stored only — use in no-alloc paths
// Returns .Not_Available if no item stored — on_get not called
if pool_get(master.pool, int(FlowId.Chunk), .Available_Only, &m) != .Ok {
// no item stored — handle: skip, back off, or call pool_get_wait
}
Patterns
Builder to Pool — simplest upgrade from Doll 2
Replace Builder.ctor/dtor calls with pool_get/pool_put.
Same patterns, now with recycling.
Doll 2 sender:
Doll 3 sender:
m: MayItem
defer pool_put(p, &m) // [itc: defer-put-early]
if pool_get(p, int(FlowId.Chunk), .Available_Or_New, &m) != .Ok {
return
}
// fill
mbox_send(mb, &m)
// m^ is nil after send — defer pool_put is a no-op
Backpressure
on_put checks in_pool_count.
Too many idle items → dispose.
// in master_on_put:
if in_pool_count > 400 {
free((^Chunk)(node), master.alloc)
m^ = nil // dispose — pool will not store
}
Start simple.
Add limits when it hurts.
Full lifecycle with mailbox
flowchart LR
SM["Sender Master<br/>(pool)"] -->|send| MB[(mailbox)]
MB -->|receive| RM["Recv Master<br/>(pool)"]
Setup:
FlowId :: enum int { Chunk = 1, Progress = 2 }
master := newMaster(context.allocator)
defer freeMaster(master)
Sender Master:
m: MayItem
defer pool_put(master.pool, &m) // [itc: defer-put-early]
if pool_get(master.pool, int(FlowId.Chunk), .Available_Or_New, &m) != .Ok {
return
}
// fill
ptr, ok := m.?
if !ok { return }
c := (^Chunk)(ptr)
c.len = fill(c.data[:])
// transfer
if mbox_send(mb, &m) != .Ok {
return // send failed — defer pool_put recycles
}
// m^ is nil — transfer done — defer pool_put is a no-op
Receiver Master:
m: MayItem
defer pool_put(master.pool, &m) // safety net
if mbox_wait_receive(mb, &m) != .Ok {
return
}
ptr, ok := m.?
if !ok { return }
switch FlowId(ptr.id) {
case .Chunk:
c := (^Chunk)(ptr)
process_chunk(c)
pool_put(master.pool, &m) // explicit return — defer is no-op
case .Progress:
pr := (^Progress)(ptr)
update_progress(pr)
pool_put(master.pool, &m) // explicit return — defer is no-op
}
Why both defer pool_put and per-case pool_put?
- Per-case
pool_putis the normal path — it setsm^ = nil. - After that, the deferred
pool_putruns and seesm^ == nil— becomes a no-op. - The
deferis a safety net for paths you did not anticipate. - Belt and suspenders — intentional.
Shutdown:
remaining := mbox_close(mb)
for {
raw := list.pop_front(&remaining)
if raw == nil { break }
poly := (^PolyNode)(raw)
polynode_reset(poly) // required: batch pop does not reset
m: MayItem = poly
pool_put(master.pool, &m)
if m^ != nil {
// pool was already closed — dispose manually
}
}
freeMaster(master)
What you can build with all three layers
- Compression pipeline — chunks flow from reader Master to worker Masters and back, recycled through Pool.
- Game engine — entities, bullets, particles allocated from Pool, dispatched across Masters, recycled on death.
- Network server — request buffers from Pool, dispatched to handler Masters, response buffers returned to Pool.
- Streaming processor — data flows through a chain of Masters, Pool absorbs allocation spikes.
Same vocabulary at every level: get → fill → send → receive → put back.
Only the hooks grow when you need control.