Skip to content

Commit c5f78e1

Browse files
committed
Initial pool implementation, updated examples and docs
1 parent 2ac5b2f commit c5f78e1

File tree

18 files changed

+686
-126
lines changed

18 files changed

+686
-126
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ ols.json
2323
# Local Session Data
2424
mailbox_init.md
2525
design/STATUS.md
26+
design/heap-pool-plan.md
2627

2728
# Documentation tools
2829
/tools/tmp_pkg_repo

README.md

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,8 @@ Odin has [channels](https://pkg.odin-lang.org/core/sync/chan/). Use them if they
4343

4444
**mbox** helps when you need:
4545

46-
- **Zero allocations**: No copying. It links your struct directly.
47-
- **Recycling**: Use the same message over and over
46+
- **Zero copies**: No data copying. It links your struct directly.
47+
- **Recycling**: Use a pool to reuse messages with zero allocations per send.
4848
- **nbio**: Wakes the `nbio` loop when a message arrives.
4949
- **Timeouts**: Stop waiting after a certain time.
5050
- **Interrupts**: Wake a thread without sending a message. One-time signal.
@@ -94,12 +94,13 @@ Both are thread-safe. Both have zero allocations for sending or receiving.
9494

9595
| Example | Description |
9696
| :--- | :--- |
97-
| [Endless Game](examples/endless_game.odin) | 4 threads pass a single message in a circle. Millions of turns with zero overhead. |
97+
| [Endless Game](examples/endless_game.odin) | 4 threads pass a single heap-allocated message in a circle. |
9898
| [Negotiation](examples/negotiation.odin) | Request and reply between a worker thread and an `nbio` loop. |
9999
| [Life and Death](examples/lifecycle.odin) | Full flow: from allocation to cleanup. |
100-
| [Stress Test](examples/stress.odin) | Many threads sending thousands of messages to one receiver. |
100+
| [Stress Test](examples/stress.odin) | Many producers, one consumer, pool-based message recycling. |
101101
| [Interrupt](examples/interrupt.odin) | How to wake a waiting thread without sending a message. |
102102
| [Close](examples/close.odin) | Stop the game and get back all unprocessed messages. |
103+
| [Master](examples/master.odin) | Pool + mailbox owned by one struct. Coordinated shutdown. |
103104

104105
---
105106

@@ -110,15 +111,21 @@ They are just small tips to show you the game...
110111

111112
## Quick start
112113

114+
> **Warning**: Never send stack-allocated messages across threads.
115+
> The stack frame can be freed before the receiving thread reads the message.
116+
> Always allocate messages on the heap (`new`) or use a pool.
117+
113118
### Basic Send and Receive
114119

115120
```odin
116121
// sender thread:
117-
msg := My_Msg{data = 42}
118-
mbox.send(&mb, &msg)
122+
msg := new(My_Msg)
123+
msg.data = 42
124+
mbox.send(&mb, msg)
119125
120126
// receiver thread:
121127
got, err := mbox.wait_receive(&mb, 100 * time.Millisecond)
128+
if got != nil { free(got) }
122129
```
123130

124131
### Interrupt a Waiter
@@ -195,11 +202,40 @@ for node := list.pop_front(&remaining); node != nil; node = list.pop_front(&rema
195202
}
196203
```
197204

205+
## Pool
206+
207+
For high-throughput use, recycle messages with the companion `pool` package.
208+
209+
```odin
210+
import pool_pkg "path/to/odin-mbox/pool"
211+
212+
// Setup:
213+
p: pool_pkg.Pool(My_Msg)
214+
pool_pkg.init(&p, initial_msgs = 64, max_msgs = 256)
215+
216+
// Sender: get from pool, fill, send.
217+
msg := pool_pkg.get(&p)
218+
msg.data = 42
219+
mbox.send(&mb, msg)
220+
221+
// Receiver: receive, use, return to pool.
222+
got, err := mbox.wait_receive(&mb)
223+
if err == .None { pool_pkg.put(&p, got) }
224+
225+
// Cleanup:
226+
pool_pkg.destroy(&p)
227+
```
228+
229+
See [design/mbox_examples.md](design/mbox_examples.md) for the MASTER pattern (pool + mailbox, coordinated shutdown).
230+
231+
---
232+
198233
## Best Practices
199234

200235
1. **Ownership.** Once you send a message, don't touch it. It belongs to the mailbox until someone receives it.
201-
2. **Cleanup.** Use `close()` to stop. Undelivered messages are returned to you—it is now safe to free or reuse them.
202-
3. **Threads.** Always wait for threads to finish (`thread.join`) before you free the mailbox itself.
236+
2. **Heap.** Always use heap-allocated messages across threads. Never use stack allocation.
237+
3. **Cleanup.** Use `close()` to stop. Undelivered messages are returned to you—free or return to pool.
238+
4. **Threads.** Always wait for threads to finish (`thread.join`) before you free the mailbox itself.
203239

204240

205241
---

_notes.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,7 @@ context—Odin’s Most Misunderstood Feature
2424

2525
Odin build doc pages magic
2626
https://github.com/laytan/odin-tree-sitter
27+
28+
29+
Add core:nbio#6124
30+
https://github.com/odin-lang/Odin/pull/6124

design/mailbox_design.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,20 @@ You only get ownership back when you `receive()` it or get it from `close()`.
193193
Always wait for all threads to finish (`thread.join`) before you free the mailbox itself.
194194
The mailbox must stay alive as long as any thread can still access it.
195195

196-
### 4. nbio loop initialization
196+
### 4. Message lifetime
197+
Never use stack-allocated messages for inter-thread communication.
198+
The stack frame can be freed before the receiving thread reads the message.
199+
200+
Three ownership patterns:
201+
202+
1. **Heap**: `new` to allocate, `free` after receive. Simple. Good for low-frequency use.
203+
2. **Pool**: `pool.get` / `pool.put`. High-throughput recycling. Zero allocations during the run.
204+
3. **MASTER**: one struct owns both pool and mailbox. One shutdown call handles everything.
205+
206+
"Zero copies" means mbox does not copy message data. It does not mean zero allocations.
207+
You still allocate message objects. mbox just links them.
208+
209+
### 5. nbio loop initialization
197210
On some platforms (like macOS/kqueue), `nbio.wake_up` requires that the event loop has been ticked at least once to register the internal wake-up event in the kernel.
198211

199212
Before starting any threads that might call `send_to_loop` or `close_loop`, call `nbio.tick(0)` on the loop thread. This ensures the loop is fully initialized and ready to receive wake-up signals from other threads.

design/mbox_examples.md

Lines changed: 98 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ mbox is intrusive. The message struct is the node. No extra allocation per messa
66

77
## 1. Basic send and receive
88

9+
Allocate messages on the heap. Never pass stack-allocated messages across threads.
10+
911
```odin
1012
import mbox "path/to/odin-mbox"
1113
import list "core:container/intrusive/list"
@@ -15,18 +17,22 @@ Msg :: struct {
1517
data: int,
1618
}
1719
18-
// Sender:
19-
msg := Msg{data = 42}
20-
ok := mbox.send(&mb, &msg)
20+
// Sender thread:
21+
msg := new(Msg)
22+
msg.data = 42
23+
ok := mbox.send(&mb, msg)
2124
2225
// Receiver (non-blocking poll):
2326
got, err := mbox.wait_receive(&mb, 0) // err == .Timeout means no message
27+
if got != nil { free(got) }
2428
2529
// Receiver (blocking, infinite wait):
2630
got, err := mbox.wait_receive(&mb)
31+
if got != nil { free(got) }
2732
2833
// Receiver (blocking, with timeout):
2934
got, err := mbox.wait_receive(&mb, 100 * time.Millisecond)
35+
if got != nil { free(got) }
3036
```
3137

3238
---
@@ -162,23 +168,32 @@ See `examples/negotiation.odin` for a working version of this pattern.
162168

163169
## 6. High-throughput stress pattern
164170

165-
Many producers, one consumer.
171+
Many producers, one consumer. Use a pool for zero-allocation recycling.
166172

167173
```odin
174+
import pool_pkg "path/to/odin-mbox/pool"
175+
176+
// Setup:
177+
shared_pool: pool_pkg.Pool(Msg)
178+
pool_pkg.init(&shared_pool, initial_msgs = N, max_msgs = N)
179+
168180
// Consumer thread:
169181
for {
170182
msg, err := mbox.wait_receive(&mb)
171183
if err == .Closed { break }
172-
// process msg
184+
pool_pkg.put(&shared_pool, msg) // return to pool
173185
}
174186
175187
// Each producer thread:
176-
for i in 0 ..< N {
177-
mbox.send(&mb, &msgs[i])
188+
for _ in 0 ..< N / P {
189+
msg := pool_pkg.get(&shared_pool)
190+
if msg != nil {
191+
mbox.send(&mb, msg)
192+
}
178193
}
179194
180-
// After all producers finish (capture remaining if drain is needed — see Pattern 8):
181-
_, _ = mbox.close(&mb)
195+
// After done:
196+
pool_pkg.destroy(&shared_pool)
182197
```
183198

184199
See `examples/stress.odin` for a working version of this pattern.
@@ -254,3 +269,77 @@ This pattern is perfect for:
254269
- Games where one "entity" is manipulated by many systems.
255270

256271
See `examples/endless_game.odin` for the full implementation.
272+
273+
---
274+
275+
## 10. Pool usage (init / get / put / destroy)
276+
277+
Use a pool when you send many messages and want to avoid repeated heap allocations.
278+
279+
```odin
280+
import pool_pkg "path/to/odin-mbox/pool"
281+
282+
// Setup (once, before any threads start):
283+
p: pool_pkg.Pool(Msg)
284+
pool_pkg.init(&p, initial_msgs = 64, max_msgs = 256)
285+
286+
// Sender thread: take from pool, fill data, send.
287+
msg := pool_pkg.get(&p)
288+
if msg != nil {
289+
msg.data = 42
290+
mbox.send(&mb, msg)
291+
}
292+
293+
// Receiver thread: receive, use, return to pool.
294+
got, err := mbox.wait_receive(&mb)
295+
if err == .None && got != nil {
296+
// use got.data
297+
pool_pkg.put(&p, got)
298+
}
299+
300+
// Cleanup (after all threads are done):
301+
pool_pkg.destroy(&p)
302+
```
303+
304+
Rules:
305+
- A message is either in the pool OR in a mailbox. Never both.
306+
- Call destroy only after all threads have stopped using the pool.
307+
- put() on a full pool frees the message instead of returning it.
308+
309+
---
310+
311+
## 11. MASTER pattern (pool + mailbox, coordinated shutdown)
312+
313+
One struct owns the pool and the mailbox. One shutdown call handles everything.
314+
315+
Key rule: drain the mailbox BEFORE destroying the pool.
316+
If you destroy the pool first, the messages still in the mailbox become dangling pointers.
317+
318+
```odin
319+
import pool_pkg "path/to/odin-mbox/pool"
320+
321+
Master :: struct {
322+
pool: pool_pkg.Pool(Msg),
323+
inbox: mbox.Mailbox(Msg),
324+
}
325+
326+
master_init :: proc(m: ^Master) -> bool {
327+
return pool_pkg.init(&m.pool, initial_msgs = 8, max_msgs = 64)
328+
}
329+
330+
master_shutdown :: proc(m: ^Master) {
331+
// 1. Close inbox. Get back any undelivered messages.
332+
remaining, _ := mbox.close(&m.inbox)
333+
334+
// 2. Return them to pool — not free, pool owns them.
335+
for node := list.pop_front(&remaining); node != nil; node = list.pop_front(&remaining) {
336+
msg := container_of(node, Msg, "node")
337+
pool_pkg.put(&m.pool, msg)
338+
}
339+
340+
// 3. Now safe to destroy pool.
341+
pool_pkg.destroy(&m.pool)
342+
}
343+
```
344+
345+
See `examples/master.odin` for a working version of this pattern.

doc.odin

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22
Package mbox is an inter-thread communication library for Odin.
33
44
Core concepts:
5-
- Zero allocations: Messages are linked, not copied.
5+
- Zero copies: Messages are linked, not copied.
66
- Intrusive: Your message struct must have a field named "node" of type "list.Node".
7-
- Thread-safe: Designed for high-concurrency flows.
7+
- Thread-safe: Safe to use from multiple threads.
88
99
Mailbox types:
1010
- Mailbox($T): For worker threads. Blocks until a message arrives.

docs/README.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ Odin has [channels](https://pkg.odin-lang.org/core/sync/chan/). Use them if they
2626

2727
**mbox** helps when you need:
2828

29-
- **Zero allocations**: No copying. It links your struct directly.
30-
- **Recycling**: Use the same message over and over.
29+
- **Zero copies**: No data copying. It links your struct directly.
30+
- **Recycling**: Use a pool to reuse messages with zero allocations per send.
3131
- **nbio**: Wakes the `nbio` loop when a message arrives.
3232
- **Timeouts**: Stop waiting after a certain time.
3333
- **Interrupts**: Wake a thread without sending a message. One-time signal.
@@ -74,12 +74,16 @@ Both are thread-safe. Both have zero allocations for sending or receiving.
7474

7575
Check the [examples/](https://github.com/g41797/odin-mbox/tree/main/examples) directory for:
7676

77-
- **Endless Game**: 4 threads pass a single message in a circle.
77+
- **Endless Game**: 4 threads pass a single heap-allocated message in a circle.
7878
- **Negotiation**: Request and reply between a worker thread and an `nbio` loop.
7979
- **Life and Death**: Full flow: from allocation to cleanup.
80-
- **Stress Test**: Many threads sending thousands of messages to one receiver.
80+
- **Stress Test**: Many producers, one consumer, pool-based message recycling.
8181
- **Interrupt**: How to wake a waiting thread without sending a message.
8282
- **Close**: Stop the game and get back all unprocessed messages.
83+
- **Master**: Pool + mailbox owned by one struct. Coordinated shutdown.
84+
85+
> **Note**: Always use heap-allocated messages across threads.
86+
> Never send stack-allocated messages. Use `new`/`free` or the `pool` package.
8387
8488
## Credits
8589

examples/close.odin

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,21 @@ import list "core:container/intrusive/list"
55
import "core:thread"
66
import "core:time"
77

8-
// close_example shows how to stop the game and get all messages back.
8+
// close_example shows how to stop a mailbox and get all undelivered messages back.
99
close_example :: proc() -> bool {
1010
mb: mbox.Mailbox(Msg)
1111

12-
// --- Part 1: close() wakes waiters ---
13-
// We use an empty mailbox to ensure the waiter blocks.
12+
// --- Part 1: close() wakes a blocked waiter ---
1413
err_result: mbox.Mailbox_Error
1514
t := thread.create_and_start_with_poly_data2(&mb, &err_result, proc(mb: ^mbox.Mailbox(Msg), res: ^mbox.Mailbox_Error) {
1615
_, err := mbox.wait_receive(mb)
1716
res^ = err
1817
})
1918

20-
// Wait for the thread to be inside wait_receive.
19+
// Wait for the thread to enter wait_receive.
2120
time.sleep(10 * time.Millisecond)
2221

23-
// Close the empty mailbox. This must wake the waiter with .Closed.
22+
// Close the empty mailbox. Waiter must wake with .Closed.
2423
_, was_open := mbox.close(&mb)
2524
if !was_open {
2625
return false
@@ -34,23 +33,26 @@ close_example :: proc() -> bool {
3433
}
3534

3635
// --- Part 2: close() returns undelivered messages ---
37-
// Reset the mailbox for a fresh start.
38-
mb = {}
39-
40-
// Create two messages.
41-
a := Msg{data = 1}
42-
b := Msg{data = 2}
43-
36+
mb = {}
37+
38+
// Allocate two messages on the heap.
39+
a := new(Msg)
40+
a.data = 1
41+
b := new(Msg)
42+
b.data = 2
43+
4444
// Send them. Mailbox now owns the references.
45-
mbox.send(&mb, &a)
46-
mbox.send(&mb, &b)
45+
mbox.send(&mb, a)
46+
mbox.send(&mb, b)
4747

4848
// Close and get all undelivered messages back.
4949
remaining, _ := mbox.close(&mb)
5050

51-
// Verify we got both references back.
51+
// Free each returned message.
5252
count := 0
5353
for node := list.pop_front(&remaining); node != nil; node = list.pop_front(&remaining) {
54+
msg := container_of(node, Msg, "node")
55+
free(msg)
5456
count += 1
5557
}
5658

0 commit comments

Comments
 (0)