init repo
This commit is contained in:
68
README.md
Normal file
68
README.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# Actor
|
||||
|
||||
每个 Actor 使用一个泛型 MPSC mailbox。队列不使用互斥锁或自旋锁。默认取得消费权的发送者在自己的 goroutine 中处理消息,直到 mailbox 清空,库不会创建后台 goroutine;也可以在构造时注入 dispatcher,将整个消费任务交给其他 goroutine 或已有线程池。
|
||||
|
||||
默认同步执行(inline):
|
||||
|
||||
```go
|
||||
var total int
|
||||
counter := actor.New(func(value int) {
|
||||
total += value
|
||||
}, actor.WithFixedCapacity[int](64), actor.WithBatchSize[int](64))
|
||||
|
||||
counter.Send(1) // 空闲时,在当前 goroutine 中处理。
|
||||
```
|
||||
|
||||
- `New(handler, opts...)`:创建 Actor。处理函数或 option 为空、固定容量或批量大小不为正数、dispatcher 接口或 `DispatcherFunc` 为空时 panic。
|
||||
- `Send(message)`:无返回值。发布消息后,取得消费权的发送者提交一次完整消费任务;已有任务排队或执行时仅入队。默认同步执行,返回不保证该消息已经处理,也不支持撤消。
|
||||
- `WithFixedCapacity[T](n)`:预分配并保留 `n` 个节点,默认 64。固定区全部消费后循环复用;不足时分配动态节点,因此这不是队列总容量限制。
|
||||
- `WithBatchSize[T](n)`:默认每消费 64 条消息调用 `runtime.Gosched()`。长链反转同样定期让出 CPU;单条处理函数不会被这个限制中断。
|
||||
- `WithPanicHandler[T](func(message T, value any, stack []byte))`:可选 panic 回调。每条处理函数及回调自身的 panic 独立恢复,之后继续处理后续消息;没有回调时也继续处理。
|
||||
- `WithDispatcher[T](Dispatcher)`:在构造时配置消费任务的执行方式,不提供运行中切换。调度策略不改变批量让出 CPU 的频率。
|
||||
|
||||
使用 `DispatcherFunc` 将消费任务交给其他 goroutine:
|
||||
|
||||
```go
|
||||
processed := make(chan int, 1)
|
||||
receiver := actor.New(func(value int) {
|
||||
processed <- value
|
||||
}, actor.WithDispatcher[int](actor.DispatcherFunc(func(task func()) {
|
||||
go task()
|
||||
})))
|
||||
|
||||
receiver.Send(42)
|
||||
value := <-processed // 用业务自己的同步方式等待结果。
|
||||
```
|
||||
|
||||
注入已有线程池,可以通过适配器接入其提交方法:
|
||||
|
||||
```go
|
||||
type taskPool interface {
|
||||
Submit(task func())
|
||||
}
|
||||
|
||||
type poolDispatcher struct {
|
||||
pool taskPool
|
||||
}
|
||||
|
||||
func (d poolDispatcher) Schedule(task func()) {
|
||||
d.pool.Submit(task)
|
||||
}
|
||||
|
||||
func newCounter(pool taskPool) *actor.Actor[int] {
|
||||
total := 0
|
||||
return actor.New(func(value int) {
|
||||
total += value
|
||||
}, actor.WithDispatcher[int](poolDispatcher{pool: pool}))
|
||||
}
|
||||
```
|
||||
|
||||
`Dispatcher` 只有 `Schedule(task func())` 方法。它可以同步或异步执行,但必须接受任务并最终恰好执行一次,不得拒绝、静默丢弃或 panic;共享实例还必须支持并发调用。上述线程池的 `Submit` 同样需要满足这个约定。mailbox 在调用 `Schedule` 前已取得消费权,任务尚未执行时的新消息不会重复提交任务;库不为违反调度契约的实现提供回退。自定义 dispatcher 接口中包含 nil 指针时,调用方负责保证其 `Schedule` 仍有效。
|
||||
|
||||
FIFO 按消息成功发布的原子操作排序,固定区与动态区共享这个顺序。生产者先预留固定位置并不会占据 FIFO 顺序;它在发布前暂停时,其他生产者仍能发布并处理消息。消费者通过原子交换取出一批节点,反转链接后按 FIFO 处理,不复制消息来扩容。
|
||||
|
||||
处理函数不会并发或重入。处理函数向自身 Actor 发送的消息只会入队,必须等当前处理结束才能执行;不要同步等待它的执行结果。向使用默认 dispatcher 的其他空闲 Actor 发送消息会同步进入对方的处理函数,因此也应避免循环等待和过深的同步调用链。持续繁忙的 mailbox 可能让同步 `Send` 一直不返回;`Gosched` 只让出 CPU,不会切换调度策略,也不会打断单条处理函数。
|
||||
|
||||
`Send` 复制的是 Go 值,不深复制其中的指针、切片或 map;调用方需自行同步对共享数据的访问。固定槽位消费后清零,动态节点清除引用后交由 GC 回收。Actor 的可变状态应只在处理函数中访问,或自行同步。Actor 必须通过 `New` 创建,开始使用后不可复制。
|
||||
|
||||
内部职责分别放在 `actor.go`(门面与组装)、`queue.go`(存储与 FIFO)、`mailbox.go`(消费权、消费循环与调度)、`invoker.go`(处理函数与 panic 隔离)、`dispatcher.go`(执行策略契约)和 `options.go`(构造配置)。不包含生命周期、取消或自动重试。
|
||||
34
actor.go
Normal file
34
actor.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package actor
|
||||
|
||||
// Actor serializes calls to a handler. By default the sender consumes messages
|
||||
// inline; WithDispatcher can provide another execution policy. An Actor must be
|
||||
// created with New and must not be copied after first use.
|
||||
type Actor[T any] struct {
|
||||
mailbox *mailbox[T]
|
||||
}
|
||||
|
||||
// New creates an Actor. A nil handler, nil option, invalid dispatcher, or
|
||||
// nonpositive fixed capacity or batch size causes a panic. The default
|
||||
// dispatcher runs inline and does not start any goroutine.
|
||||
func New[T any](handler func(T), opts ...Option[T]) *Actor[T] {
|
||||
if handler == nil {
|
||||
panic("actor: nil handler")
|
||||
}
|
||||
c := configure(opts)
|
||||
return &Actor[T]{mailbox: newMailbox(
|
||||
newQueue[T](c.fixedCapacity),
|
||||
invoker[T]{handler: handler, onPanic: c.onPanic},
|
||||
c.batchSize,
|
||||
c.dispatcher,
|
||||
)}
|
||||
}
|
||||
|
||||
// Send publishes a message. The sender that obtains consumption ownership
|
||||
// schedules a task to drain the mailbox. By default it runs synchronously;
|
||||
// a custom dispatcher may run it asynchronously. Other senders only enqueue.
|
||||
// Returning does not guarantee message completion. Sending to the same Actor
|
||||
// from its handler enqueues without reentering it. Concurrent sends are ordered
|
||||
// by their successful publication CAS.
|
||||
func (a *Actor[T]) Send(message T) {
|
||||
a.mailbox.send(message)
|
||||
}
|
||||
432
actor_test.go
Normal file
432
actor_test.go
Normal file
@@ -0,0 +1,432 @@
|
||||
package actor
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func await(t *testing.T, done <-chan struct{}) {
|
||||
t.Helper()
|
||||
timer := time.NewTimer(10 * time.Second)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-done:
|
||||
case <-timer.C:
|
||||
t.Fatal("actor operation did not finish")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewValidationAndDefaults(t *testing.T) {
|
||||
a := New(func(int) {})
|
||||
if len(a.mailbox.queue.fixed) != 64 || a.mailbox.batchSize != 64 {
|
||||
t.Fatalf("defaults: capacity=%d batch=%d", len(a.mailbox.queue.fixed), a.mailbox.batchSize)
|
||||
}
|
||||
tests := map[string]func(){
|
||||
"nil handler": func() { New[int](nil) },
|
||||
"nil option": func() { New(func(int) {}, nil) },
|
||||
"zero capacity": func() { New(func(int) {}, WithFixedCapacity[int](0)) },
|
||||
"negative capacity": func() { New(func(int) {}, WithFixedCapacity[int](-1)) },
|
||||
"zero batch": func() { New(func(int) {}, WithBatchSize[int](0)) },
|
||||
"negative batch": func() { New(func(int) {}, WithBatchSize[int](-1)) },
|
||||
"nil dispatcher": func() { New(func(int) {}, WithDispatcher[int](nil)) },
|
||||
"nil dispatcher function": func() {
|
||||
New(func(int) {}, WithDispatcher[int](DispatcherFunc(nil)))
|
||||
},
|
||||
}
|
||||
for name, construct := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Error("expected constructor panic")
|
||||
}
|
||||
}()
|
||||
construct()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdleSendHandlesSynchronously(t *testing.T) {
|
||||
returned, handled := false, false
|
||||
a := New(func(message int) {
|
||||
if returned {
|
||||
t.Error("handler entered after Send returned")
|
||||
}
|
||||
if message != 42 {
|
||||
t.Errorf("message=%d, want 42", message)
|
||||
}
|
||||
handled = true
|
||||
})
|
||||
a.Send(42)
|
||||
returned = true
|
||||
if !handled {
|
||||
t.Fatal("idle Send returned without handling its message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentSendSerialAndProducerFIFO(t *testing.T) {
|
||||
const producers, messages = 12, 1000
|
||||
type message struct{ producer, sequence int }
|
||||
var active atomic.Int32
|
||||
last := make([]int, producers)
|
||||
for i := range last {
|
||||
last[i] = -1
|
||||
}
|
||||
seen := make([]bool, producers*messages)
|
||||
count := 0
|
||||
a := New(func(m message) {
|
||||
if current := active.Add(1); current != 1 {
|
||||
t.Errorf("%d concurrent handlers", current)
|
||||
}
|
||||
defer active.Add(-1)
|
||||
if m.sequence != last[m.producer]+1 {
|
||||
t.Errorf("producer %d: received %d after %d", m.producer, m.sequence, last[m.producer])
|
||||
}
|
||||
last[m.producer] = m.sequence
|
||||
index := m.producer*messages + m.sequence
|
||||
if seen[index] {
|
||||
t.Errorf("duplicate message: %+v", m)
|
||||
}
|
||||
seen[index] = true
|
||||
count++
|
||||
}, WithFixedCapacity[message](8))
|
||||
start, done := make(chan struct{}), make(chan struct{})
|
||||
var workers sync.WaitGroup
|
||||
for producer := range producers {
|
||||
workers.Add(1)
|
||||
go func() {
|
||||
defer workers.Done()
|
||||
<-start
|
||||
for sequence := range messages {
|
||||
a.Send(message{producer, sequence})
|
||||
}
|
||||
}()
|
||||
}
|
||||
go func() {
|
||||
workers.Wait()
|
||||
close(done)
|
||||
}()
|
||||
close(start)
|
||||
await(t, done)
|
||||
if count != len(seen) {
|
||||
t.Errorf("handled %d messages, want %d", count, len(seen))
|
||||
}
|
||||
for index, received := range seen {
|
||||
if !received {
|
||||
t.Fatalf("missing message %d", index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFixedStorageReusesCompleteRounds(t *testing.T) {
|
||||
for _, capacity := range []int{1, 2, 4} {
|
||||
t.Run(strconv.Itoa(capacity), func(t *testing.T) {
|
||||
count := 0
|
||||
a := New(func(message int) {
|
||||
if message != count {
|
||||
t.Errorf("received %d, want %d", message, count)
|
||||
}
|
||||
count++
|
||||
}, WithFixedCapacity[int](capacity))
|
||||
first := &a.mailbox.queue.fixed[0]
|
||||
for i := range 1024 {
|
||||
a.Send(i)
|
||||
want := (i + 1) % capacity
|
||||
if got := a.mailbox.queue.reserved.Load(); got != uint64(want) || a.mailbox.queue.consumed != want {
|
||||
t.Fatalf("message %d: reserved=%d consumed=%d, want %d", i, got, a.mailbox.queue.consumed, want)
|
||||
}
|
||||
if &a.mailbox.queue.fixed[0] != first {
|
||||
t.Fatal("fixed storage was replaced")
|
||||
}
|
||||
for j := range a.mailbox.queue.fixed {
|
||||
if a.mailbox.queue.fixed[j].value != 0 || a.mailbox.queue.fixed[j].next != nil || !a.mailbox.queue.fixed[j].fixed {
|
||||
t.Fatalf("fixed slot %d was not cleared", j)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFixedAndDynamicFIFOAndClearedReferences(t *testing.T) {
|
||||
type message struct {
|
||||
id int
|
||||
reference *int
|
||||
}
|
||||
entered, release, done := make(chan struct{}), make(chan struct{}), make(chan struct{})
|
||||
var got []int
|
||||
var a *Actor[message]
|
||||
reference := 7
|
||||
a = New(func(m message) {
|
||||
got = append(got, m.id)
|
||||
if m.id == -1 {
|
||||
close(entered)
|
||||
<-release
|
||||
}
|
||||
if m.id == 1 {
|
||||
// All fixed slots are now reusable, but dynamic messages 2..9
|
||||
// already belong ahead of this new fixed-slot message.
|
||||
a.Send(message{10, &reference})
|
||||
}
|
||||
}, WithFixedCapacity[message](3))
|
||||
go func() {
|
||||
defer close(done)
|
||||
a.Send(message{-1, &reference})
|
||||
}()
|
||||
await(t, entered)
|
||||
for i := range 10 {
|
||||
a.Send(message{i, &reference})
|
||||
}
|
||||
var queued []*node[message]
|
||||
fixed, dynamic := 0, 0
|
||||
for n := a.mailbox.queue.head.Load(); n != nil; n = n.next {
|
||||
queued = append(queued, n)
|
||||
if n.fixed {
|
||||
fixed++
|
||||
} else {
|
||||
dynamic++
|
||||
}
|
||||
}
|
||||
if fixed != 2 || dynamic != 8 {
|
||||
t.Fatalf("queued storage: fixed=%d dynamic=%d", fixed, dynamic)
|
||||
}
|
||||
close(release)
|
||||
await(t, done)
|
||||
want := []int{-1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("FIFO: got %v, want %v", got, want)
|
||||
}
|
||||
for i, n := range queued {
|
||||
if n.value != (message{}) || n.next != nil {
|
||||
t.Errorf("consumed node %d retains a message or link", i)
|
||||
}
|
||||
}
|
||||
for i := range a.mailbox.queue.fixed {
|
||||
if a.mailbox.queue.fixed[i].value != (message{}) || a.mailbox.queue.fixed[i].next != nil {
|
||||
t.Errorf("fixed node %d retains a message or link", i)
|
||||
}
|
||||
}
|
||||
if a.mailbox.queue.head.Load() != nil {
|
||||
t.Error("mailbox retains a consumed batch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPausedReservationDoesNotBlockPublication(t *testing.T) {
|
||||
var got []int
|
||||
a := New(func(message int) { got = append(got, message) }, WithFixedCapacity[int](1))
|
||||
paused := a.mailbox.queue.reserveNode()
|
||||
// The reserved fixed slot has not been initialized or published. Other
|
||||
// senders must use dynamic storage and finish without waiting for it.
|
||||
a.Send(2)
|
||||
a.Send(3)
|
||||
if !reflect.DeepEqual(got, []int{2, 3}) {
|
||||
t.Fatalf("unpublished reservation blocked messages: %v", got)
|
||||
}
|
||||
if a.mailbox.queue.reserved.Load() != 1 || a.mailbox.queue.consumed != 0 {
|
||||
t.Fatal("fixed storage was reopened before its reserved node was consumed")
|
||||
}
|
||||
paused.value = 1
|
||||
a.mailbox.queue.publish(paused)
|
||||
a.mailbox.tryRun()
|
||||
if !reflect.DeepEqual(got, []int{2, 3, 1}) {
|
||||
t.Errorf("publication order: %v", got)
|
||||
}
|
||||
if a.mailbox.queue.reserved.Load() != 0 || a.mailbox.queue.consumed != 0 {
|
||||
t.Error("completed fixed round was not reopened")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicationWithReusedExpectedHead(t *testing.T) {
|
||||
var got []int
|
||||
a := New(func(message int) { got = append(got, message) }, WithFixedCapacity[int](2))
|
||||
first, second := a.mailbox.queue.reserveNode(), a.mailbox.queue.reserveNode()
|
||||
first.value, second.value = 1, 2
|
||||
a.mailbox.queue.publish(first)
|
||||
a.mailbox.queue.publish(second)
|
||||
paused := a.mailbox.queue.reserveNode() // Dynamic: it cannot prevent the fixed round ending.
|
||||
paused.value = 5
|
||||
expected := a.mailbox.queue.head.Load()
|
||||
paused.next = expected
|
||||
a.mailbox.tryRun()
|
||||
a.Send(3)
|
||||
reused := a.mailbox.queue.reserveNode()
|
||||
if reused != expected {
|
||||
t.Fatal("test did not reuse the old head address")
|
||||
}
|
||||
reused.value = 4
|
||||
a.mailbox.queue.publish(reused)
|
||||
// Emulate a producer resuming its original CAS after the same address has
|
||||
// become a new head. It only compares the pointer; no old links are read.
|
||||
if !a.mailbox.queue.head.CompareAndSwap(expected, paused) {
|
||||
t.Fatal("expected the paused publication CAS to succeed")
|
||||
}
|
||||
a.mailbox.tryRun()
|
||||
if !reflect.DeepEqual(got, []int{1, 2, 3, 4, 5}) {
|
||||
t.Errorf("reused head corrupted publication order: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZeroValueMessages(t *testing.T) {
|
||||
var got []*int
|
||||
a := New(func(message *int) { got = append(got, message) }, WithFixedCapacity[*int](1))
|
||||
a.Send(nil)
|
||||
a.Send(nil)
|
||||
if len(got) != 2 || got[0] != nil || got[1] != nil {
|
||||
t.Errorf("zero messages: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelfSendDoesNotReenter(t *testing.T) {
|
||||
var a *Actor[int]
|
||||
var got []int
|
||||
depth := 0
|
||||
a = New(func(message int) {
|
||||
depth++
|
||||
defer func() { depth-- }()
|
||||
if depth != 1 {
|
||||
t.Errorf("reentered handler at depth %d", depth)
|
||||
}
|
||||
got = append(got, message)
|
||||
if message == 0 {
|
||||
a.Send(1)
|
||||
a.Send(2)
|
||||
if !reflect.DeepEqual(got, []int{0}) {
|
||||
t.Errorf("self-send processed before handler returned: %v", got)
|
||||
}
|
||||
} else if message == 1 {
|
||||
a.Send(3)
|
||||
}
|
||||
})
|
||||
a.Send(0)
|
||||
if !reflect.DeepEqual(got, []int{0, 1, 2, 3}) {
|
||||
t.Errorf("self-send order: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerAndPanicCallbackRecovery(t *testing.T) {
|
||||
var a *Actor[int]
|
||||
var got, reported []int
|
||||
var values []any
|
||||
a = New(func(message int) {
|
||||
got = append(got, message)
|
||||
switch message {
|
||||
case 0:
|
||||
for i := 1; i <= 3; i++ {
|
||||
a.Send(i)
|
||||
}
|
||||
panic("first")
|
||||
case 1:
|
||||
panic("second")
|
||||
case 2:
|
||||
panic(nil)
|
||||
}
|
||||
}, WithPanicHandler(func(message int, value any, stack []byte) {
|
||||
reported = append(reported, message)
|
||||
values = append(values, value)
|
||||
if !bytes.Contains(stack, []byte("TestHandlerAndPanicCallbackRecovery")) {
|
||||
t.Error("panic callback did not receive the handler stack")
|
||||
}
|
||||
if message == 0 {
|
||||
panic("panic callback failed")
|
||||
}
|
||||
}))
|
||||
a.Send(0)
|
||||
a.Send(4)
|
||||
if !reflect.DeepEqual(got, []int{0, 1, 2, 3, 4}) {
|
||||
t.Errorf("processing stopped after panic: %v", got)
|
||||
}
|
||||
if !reflect.DeepEqual(reported, []int{0, 1, 2}) || values[0] != "first" || values[1] != "second" {
|
||||
t.Errorf("panic reports: messages=%v values=%v", reported, values)
|
||||
}
|
||||
if a.mailbox.running.Load() {
|
||||
t.Error("panic left consumption ownership held")
|
||||
}
|
||||
count := 0
|
||||
withoutCallback := New(func(int) { count++; panic("ignored") })
|
||||
withoutCallback.Send(0)
|
||||
withoutCallback.Send(1)
|
||||
if count != 2 {
|
||||
t.Errorf("panic without callback stopped processing: %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchedulingYieldsDuringConsumptionAndReversal(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
batch, queued int
|
||||
want []int
|
||||
}{
|
||||
{name: "default interval", batch: 64, queued: 130, want: []int{1, 1, 64, 128}},
|
||||
{name: "configured interval", batch: 4, queued: 9, want: []int{1, 1, 4, 8}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var a *Actor[int]
|
||||
processed := 0
|
||||
var yieldedAt []int
|
||||
a = New(func(message int) {
|
||||
if message != processed {
|
||||
t.Errorf("message=%d, want %d", message, processed)
|
||||
}
|
||||
processed++
|
||||
if message == 0 {
|
||||
for i := 1; i <= test.queued; i++ {
|
||||
a.Send(i)
|
||||
}
|
||||
}
|
||||
}, WithBatchSize[int](test.batch))
|
||||
a.mailbox.yield = func() { yieldedAt = append(yieldedAt, processed) }
|
||||
a.Send(0)
|
||||
if !reflect.DeepEqual(yieldedAt, test.want) {
|
||||
t.Errorf("yield checkpoints=%v, want %v", yieldedAt, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumerHandoffDoesNotLoseWakeupOrState(t *testing.T) {
|
||||
type message struct {
|
||||
id int
|
||||
entered, release chan struct{}
|
||||
}
|
||||
// This ordinary state belongs to the actor, whose consumer changes between
|
||||
// rounds. It needs no atomic access inside the serial handler.
|
||||
handled := 0
|
||||
a := New(func(m message) {
|
||||
if m.id != handled {
|
||||
t.Errorf("handoff: message=%d, want %d", m.id, handled)
|
||||
}
|
||||
handled++
|
||||
if m.entered != nil {
|
||||
close(m.entered)
|
||||
<-m.release
|
||||
}
|
||||
}, WithFixedCapacity[message](1), WithBatchSize[message](1))
|
||||
const rounds = 2000
|
||||
for round := range rounds {
|
||||
entered, release := make(chan struct{}), make(chan struct{})
|
||||
ownerDone, producerDone, start := make(chan struct{}), make(chan struct{}), make(chan struct{})
|
||||
go func() {
|
||||
defer close(ownerDone)
|
||||
a.Send(message{round * 2, entered, release})
|
||||
}()
|
||||
await(t, entered)
|
||||
go func() {
|
||||
defer close(producerDone)
|
||||
<-start
|
||||
a.Send(message{id: round*2 + 1})
|
||||
}()
|
||||
// Race publication against the old consumer leaving its final handler.
|
||||
// No sleeps or timing assumptions are needed to repeat this boundary.
|
||||
close(start)
|
||||
close(release)
|
||||
await(t, ownerDone)
|
||||
await(t, producerDone)
|
||||
}
|
||||
if handled != rounds*2 || a.mailbox.running.Load() || a.mailbox.queue.head.Load() != nil {
|
||||
t.Errorf("incomplete handoff: handled=%d running=%v", handled, a.mailbox.running.Load())
|
||||
}
|
||||
}
|
||||
25
dispatcher.go
Normal file
25
dispatcher.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package actor
|
||||
|
||||
// Dispatcher chooses where a mailbox's entire drain task executes. Schedule
|
||||
// may run the task inline or asynchronously. It must accept every task, execute
|
||||
// it exactly once eventually, and must not reject, silently discard, or panic.
|
||||
// Schedule must be safe for concurrent calls when shared by actors. The actor
|
||||
// does not provide a fallback for a dispatcher that violates this contract.
|
||||
type Dispatcher interface {
|
||||
Schedule(task func())
|
||||
}
|
||||
|
||||
// DispatcherFunc adapts a scheduling function to Dispatcher. A nil function is
|
||||
// rejected by New when supplied through WithDispatcher.
|
||||
type DispatcherFunc func(task func())
|
||||
|
||||
// Schedule delegates to f. The function must satisfy the Dispatcher contract.
|
||||
func (f DispatcherFunc) Schedule(task func()) {
|
||||
f(task)
|
||||
}
|
||||
|
||||
type inlineDispatcher struct{}
|
||||
|
||||
func (inlineDispatcher) Schedule(task func()) {
|
||||
task()
|
||||
}
|
||||
329
dispatcher_test.go
Normal file
329
dispatcher_test.go
Normal file
@@ -0,0 +1,329 @@
|
||||
package actor
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// queuedDispatcher deliberately postpones execution until the test takes a
|
||||
// task. Its mutex protects the test scheduler, not the actor's mailbox.
|
||||
type queuedDispatcher struct {
|
||||
mu sync.Mutex
|
||||
tasks []func()
|
||||
submitted int
|
||||
}
|
||||
|
||||
func (d *queuedDispatcher) Schedule(task func()) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
d.tasks = append(d.tasks, task)
|
||||
d.submitted++
|
||||
}
|
||||
|
||||
func (d *queuedDispatcher) stats() (submitted, pending int) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
return d.submitted, len(d.tasks)
|
||||
}
|
||||
|
||||
func (d *queuedDispatcher) take(t *testing.T) func() {
|
||||
t.Helper()
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if len(d.tasks) == 0 {
|
||||
t.Fatal("dispatcher has no pending task")
|
||||
}
|
||||
task := d.tasks[0]
|
||||
d.tasks[0] = nil
|
||||
d.tasks = d.tasks[1:]
|
||||
return task
|
||||
}
|
||||
|
||||
func TestQueuedDispatcherOwnsMailboxBeforeTaskStarts(t *testing.T) {
|
||||
d := &queuedDispatcher{}
|
||||
var got []int
|
||||
var active atomic.Int32
|
||||
a := New(func(message int) {
|
||||
if count := active.Add(1); count != 1 {
|
||||
t.Errorf("%d concurrent handlers", count)
|
||||
}
|
||||
defer active.Add(-1)
|
||||
got = append(got, message)
|
||||
}, WithFixedCapacity[int](4), WithDispatcher[int](d))
|
||||
for message := range 200 {
|
||||
a.Send(message)
|
||||
}
|
||||
if len(got) != 0 || !a.mailbox.running.Load() {
|
||||
t.Fatal("Send did not retain consumption ownership for the queued task")
|
||||
}
|
||||
if submitted, pending := d.stats(); submitted != 1 || pending != 1 {
|
||||
t.Fatalf("before execution: submitted=%d pending=%d, want 1, 1", submitted, pending)
|
||||
}
|
||||
d.take(t)()
|
||||
if len(got) != 200 || a.mailbox.running.Load() {
|
||||
t.Fatalf("first task did not finish draining: handled=%d", len(got))
|
||||
}
|
||||
for message, value := range got {
|
||||
if message != value {
|
||||
t.Fatalf("FIFO: got[%d]=%d", message, value)
|
||||
}
|
||||
}
|
||||
a.Send(200)
|
||||
if len(got) != 200 {
|
||||
t.Error("next wave ran before its task was executed")
|
||||
}
|
||||
if submitted, pending := d.stats(); submitted != 2 || pending != 1 {
|
||||
t.Fatalf("second wave: submitted=%d pending=%d, want 2, 1", submitted, pending)
|
||||
}
|
||||
d.take(t)()
|
||||
if len(got) != 201 || got[200] != 200 || a.mailbox.running.Load() {
|
||||
t.Error("second task did not drain the next wave")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelfSendAndYieldDoNotRescheduleActiveTask(t *testing.T) {
|
||||
d := &queuedDispatcher{}
|
||||
var a *Actor[int]
|
||||
var got []int
|
||||
depth, yields := 0, 0
|
||||
a = New(func(message int) {
|
||||
depth++
|
||||
defer func() { depth-- }()
|
||||
if depth != 1 {
|
||||
t.Errorf("reentered handler at depth %d", depth)
|
||||
}
|
||||
got = append(got, message)
|
||||
if message == 0 {
|
||||
a.Send(1001)
|
||||
}
|
||||
}, WithDispatcher[int](d))
|
||||
a.mailbox.yield = func() {
|
||||
yields++
|
||||
if !a.mailbox.running.Load() {
|
||||
t.Error("yield released consumption ownership")
|
||||
}
|
||||
if yields == 1 {
|
||||
a.Send(1000)
|
||||
}
|
||||
if submitted, pending := d.stats(); submitted != 1 || pending != 0 {
|
||||
t.Errorf("active task rescheduled: submitted=%d pending=%d", submitted, pending)
|
||||
}
|
||||
}
|
||||
for message := range 130 {
|
||||
a.Send(message)
|
||||
}
|
||||
d.take(t)()
|
||||
want := make([]int, 130, 132)
|
||||
for i := range want {
|
||||
want[i] = i
|
||||
}
|
||||
// The first reversal yields before handler 0 runs, so publication of 1000
|
||||
// precedes the self-send of 1001. Both follow the already detached batch.
|
||||
want = append(want, 1000, 1001)
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("yield/self-send publication order: got %v, want %v", got, want)
|
||||
}
|
||||
if yields == 0 || a.mailbox.running.Load() {
|
||||
t.Error("task did not yield and finish")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedDispatcherKeepsActorsIndependent(t *testing.T) {
|
||||
const actors, messages = 3, 100
|
||||
d := &queuedDispatcher{}
|
||||
var all [actors]*Actor[int]
|
||||
var entered, release [actors]chan struct{}
|
||||
var count [actors]int
|
||||
var active [actors]atomic.Int32
|
||||
for i := range actors {
|
||||
entered[i], release[i] = make(chan struct{}), make(chan struct{})
|
||||
all[i] = New(func(message int) {
|
||||
if current := active[i].Add(1); current != 1 {
|
||||
t.Errorf("actor %d has %d concurrent handlers", i, current)
|
||||
}
|
||||
defer active[i].Add(-1)
|
||||
if message != count[i] {
|
||||
t.Errorf("actor %d: message=%d, want %d", i, message, count[i])
|
||||
}
|
||||
count[i]++
|
||||
if message == 0 {
|
||||
close(entered[i])
|
||||
<-release[i]
|
||||
}
|
||||
}, WithFixedCapacity[int](4), WithDispatcher[int](d))
|
||||
for message := range messages {
|
||||
all[i].Send(message)
|
||||
}
|
||||
}
|
||||
var workers sync.WaitGroup
|
||||
for range actors {
|
||||
task := d.take(t)
|
||||
workers.Add(1)
|
||||
go func() {
|
||||
defer workers.Done()
|
||||
task()
|
||||
}()
|
||||
}
|
||||
for i := range actors {
|
||||
await(t, entered[i])
|
||||
all[i].Send(messages)
|
||||
}
|
||||
if submitted, pending := d.stats(); submitted != actors || pending != 0 {
|
||||
t.Errorf("shared dispatcher: submitted=%d pending=%d", submitted, pending)
|
||||
}
|
||||
for i := range actors {
|
||||
close(release[i])
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
workers.Wait()
|
||||
close(done)
|
||||
}()
|
||||
await(t, done)
|
||||
for i := range actors {
|
||||
if count[i] != messages+1 || all[i].mailbox.running.Load() {
|
||||
t.Errorf("actor %d did not finish independently: handled=%d", i, count[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatcherFuncAsynchronousConcurrentDrain(t *testing.T) {
|
||||
const producers, messages, selfMessages = 8, 400, 16
|
||||
type message struct{ producer, sequence int }
|
||||
var tasks sync.WaitGroup
|
||||
var submitted, active atomic.Int32
|
||||
d := DispatcherFunc(func(task func()) {
|
||||
submitted.Add(1)
|
||||
tasks.Add(1)
|
||||
go func() {
|
||||
defer tasks.Done()
|
||||
task()
|
||||
}()
|
||||
})
|
||||
entered, release := make(chan struct{}), make(chan struct{})
|
||||
last := make([]int, producers+1)
|
||||
seen := make([][]bool, producers+1)
|
||||
for producer := range producers + 1 {
|
||||
last[producer] = -1
|
||||
seen[producer] = make([]bool, messages+1)
|
||||
}
|
||||
handled, panics := 0, 0
|
||||
var a *Actor[message]
|
||||
a = New(func(m message) {
|
||||
if current := active.Add(1); current != 1 {
|
||||
t.Errorf("async actor has %d concurrent handlers", current)
|
||||
}
|
||||
defer active.Add(-1)
|
||||
if m.producer == -1 {
|
||||
a.Send(message{producers, 0})
|
||||
close(entered)
|
||||
<-release
|
||||
return
|
||||
}
|
||||
if m.sequence != last[m.producer]+1 || seen[m.producer][m.sequence] {
|
||||
t.Errorf("async producer %d: sequence=%d after %d", m.producer, m.sequence, last[m.producer])
|
||||
}
|
||||
last[m.producer] = m.sequence
|
||||
seen[m.producer][m.sequence] = true
|
||||
handled++
|
||||
if m.producer == producers {
|
||||
if m.sequence+1 < selfMessages {
|
||||
a.Send(message{producers, m.sequence + 1})
|
||||
}
|
||||
} else if m.sequence == 7 {
|
||||
panic(m.producer)
|
||||
}
|
||||
}, WithFixedCapacity[message](4), WithBatchSize[message](8), WithDispatcher[message](d),
|
||||
WithPanicHandler(func(m message, value any, stack []byte) {
|
||||
panics++
|
||||
if value != m.producer || len(stack) == 0 {
|
||||
t.Error("async panic callback lost message, panic value, or stack")
|
||||
}
|
||||
if m.producer == 0 {
|
||||
panic("callback failed")
|
||||
}
|
||||
}))
|
||||
a.Send(message{producer: -1})
|
||||
await(t, entered)
|
||||
var senders sync.WaitGroup
|
||||
for producer := range producers {
|
||||
senders.Add(1)
|
||||
go func() {
|
||||
defer senders.Done()
|
||||
for sequence := range messages {
|
||||
a.Send(message{producer, sequence})
|
||||
}
|
||||
}()
|
||||
}
|
||||
sendersDone := make(chan struct{})
|
||||
go func() {
|
||||
senders.Wait()
|
||||
close(sendersDone)
|
||||
}()
|
||||
await(t, sendersDone)
|
||||
if got := submitted.Load(); got != 1 {
|
||||
t.Errorf("queued/running async task submitted %d times", got)
|
||||
}
|
||||
close(release)
|
||||
awaitTasks := func() {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
tasks.Wait()
|
||||
close(done)
|
||||
}()
|
||||
await(t, done)
|
||||
}
|
||||
// Wait for the task itself to return, not just its final handler, before
|
||||
// inspecting ownership and the consumer's ordinary state.
|
||||
awaitTasks()
|
||||
if handled != producers*messages+selfMessages || panics != producers {
|
||||
t.Errorf("async drain: handled=%d panics=%d", handled, panics)
|
||||
}
|
||||
for producer := range producers {
|
||||
for sequence := range messages {
|
||||
if !seen[producer][sequence] {
|
||||
t.Fatalf("async missing producer=%d sequence=%d", producer, sequence)
|
||||
}
|
||||
}
|
||||
}
|
||||
if last[producers] != selfMessages-1 {
|
||||
t.Error("async self-send chain did not finish")
|
||||
}
|
||||
a.Send(message{0, messages})
|
||||
awaitTasks()
|
||||
if submitted.Load() != 2 || handled != producers*messages+selfMessages+1 {
|
||||
t.Error("async next wave did not schedule and finish")
|
||||
}
|
||||
if a.mailbox.running.Load() || !a.mailbox.queue.empty() {
|
||||
t.Error("async task returned with consumption ownership or queued messages")
|
||||
}
|
||||
for i := range a.mailbox.queue.fixed {
|
||||
n := &a.mailbox.queue.fixed[i]
|
||||
if n.value != (message{}) || n.next != nil {
|
||||
t.Errorf("async fixed node %d retains a message or link", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type nilSafeDispatcher struct{}
|
||||
|
||||
func (*nilSafeDispatcher) Schedule(task func()) { task() }
|
||||
|
||||
func TestCustomTypedNilDispatcherUsesItsContract(t *testing.T) {
|
||||
var dispatcher *nilSafeDispatcher
|
||||
handled := false
|
||||
a := New(func(int) { handled = true }, WithDispatcher[int](dispatcher))
|
||||
a.Send(0)
|
||||
if !handled {
|
||||
t.Error("valid typed-nil dispatcher was not called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInlineFixedStorageSendDoesNotAllocate(t *testing.T) {
|
||||
a := New(func(int) {}, WithFixedCapacity[int](4))
|
||||
if allocations := testing.AllocsPerRun(1000, func() { a.Send(1) }); allocations != 0 {
|
||||
t.Errorf("light-load inline Send allocated %v times per call", allocations)
|
||||
}
|
||||
}
|
||||
36
doc.go
Normal file
36
doc.go
Normal file
@@ -0,0 +1,36 @@
|
||||
// Package actor provides generic actors with a multi-producer, single-consumer
|
||||
// mailbox. By default a sender that acquires consumption ownership runs the
|
||||
// handler synchronously until the mailbox is empty, without starting or retaining
|
||||
// a goroutine. WithDispatcher can instead send the entire drain task to a custom
|
||||
// execution policy, such as an existing worker pool or another goroutine.
|
||||
// Concurrent senders only publish messages while a task is queued or running.
|
||||
//
|
||||
// Dispatcher.Schedule may execute synchronously or asynchronously, but must
|
||||
// accept each task and eventually execute it exactly once. It must not reject,
|
||||
// silently discard, or panic. Shared dispatchers must support concurrent calls.
|
||||
// Ownership is claimed before scheduling, so delayed task execution does not
|
||||
// cause duplicate submissions. Dispatchers are configured only at construction;
|
||||
// the actor does not provide a fallback for violations of their contract.
|
||||
//
|
||||
// Successful publication defines FIFO order, including across fixed and dynamic
|
||||
// storage. Concurrent calls have no ordering before their publication point. A
|
||||
// producer paused before publication does not block other producers. Pending
|
||||
// batches are detached atomically and reversed by relinking nodes, without
|
||||
// copying their messages. Fixed nodes are retained and reused after the entire
|
||||
// reservation round has been consumed; dynamic nodes are cleared and released
|
||||
// for garbage collection. The mailbox has no capacity limit.
|
||||
//
|
||||
// The same handler never runs concurrently or reentrantly. It may send to its own
|
||||
// Actor, but must not wait synchronously for a message queued to that Actor.
|
||||
// Sending to another idle Actor with the default dispatcher executes that
|
||||
// Actor's handler synchronously. A busy mailbox can keep its owning inline Send
|
||||
// call active indefinitely. Scheduling yields do not interrupt an individual
|
||||
// handler or change the dispatcher's execution policy. Send returning does not
|
||||
// guarantee that its message has been handled.
|
||||
//
|
||||
// Messages are copied as Go values, not deep-copied. Callers must synchronize any
|
||||
// subsequent access to referenced data that the handler may also access. Actor
|
||||
// state should be accessed only by its handler or with external synchronization.
|
||||
// Handler panics, and panics in the optional panic callback, are recovered so
|
||||
// processing can continue. There is no cancellation, close, or automatic retry.
|
||||
package actor
|
||||
27
invoker.go
Normal file
27
invoker.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package actor
|
||||
|
||||
import "runtime/debug"
|
||||
|
||||
type invoker[T any] struct {
|
||||
handler func(T)
|
||||
onPanic func(T, any, []byte)
|
||||
}
|
||||
|
||||
func (i *invoker[T]) receive(message T) {
|
||||
completed := false
|
||||
defer func() {
|
||||
if !completed {
|
||||
value := recover()
|
||||
if i.onPanic != nil {
|
||||
i.reportPanic(message, value, debug.Stack())
|
||||
}
|
||||
}
|
||||
}()
|
||||
i.handler(message)
|
||||
completed = true
|
||||
}
|
||||
|
||||
func (i *invoker[T]) reportPanic(message T, value any, stack []byte) {
|
||||
defer func() { _ = recover() }()
|
||||
i.onPanic(message, value, stack)
|
||||
}
|
||||
69
mailbox.go
Normal file
69
mailbox.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package actor
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// mailbox owns the single-consumer handoff and scheduling policy. running also
|
||||
// covers the interval after scheduling and before the task starts executing.
|
||||
type mailbox[T any] struct {
|
||||
queue *queue[T]
|
||||
invoker invoker[T]
|
||||
dispatcher Dispatcher
|
||||
task func()
|
||||
batchSize int
|
||||
yield func()
|
||||
running atomic.Bool
|
||||
}
|
||||
|
||||
func newMailbox[T any](q *queue[T], invoke invoker[T], batchSize int, dispatcher Dispatcher) *mailbox[T] {
|
||||
m := &mailbox[T]{
|
||||
queue: q,
|
||||
invoker: invoke,
|
||||
dispatcher: dispatcher,
|
||||
batchSize: batchSize,
|
||||
yield: runtime.Gosched,
|
||||
}
|
||||
// Reuse this method value for every scheduling request, without allocating
|
||||
// a closure on each Send or ownership handoff.
|
||||
m.task = m.drain
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *mailbox[T]) send(message T) {
|
||||
m.queue.push(message)
|
||||
m.tryRun()
|
||||
}
|
||||
|
||||
func (m *mailbox[T]) tryRun() {
|
||||
if m.running.CompareAndSwap(false, true) {
|
||||
m.dispatcher.Schedule(m.task)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mailbox[T]) drain() {
|
||||
processed := 0
|
||||
for {
|
||||
batch := m.queue.takeBatch(m.batchSize, m.yield)
|
||||
if batch == nil {
|
||||
m.running.Store(false)
|
||||
// A producer may have published while running was still true.
|
||||
// Either we reclaim ownership or that producer schedules a task.
|
||||
if m.queue.empty() || !m.running.CompareAndSwap(false, true) {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
for n := batch; n != nil; {
|
||||
message, next := m.queue.pop(n)
|
||||
n = next
|
||||
m.invoker.receive(message)
|
||||
processed++
|
||||
if processed == m.batchSize {
|
||||
processed = 0
|
||||
m.yield()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
71
options.go
Normal file
71
options.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package actor
|
||||
|
||||
const (
|
||||
defaultFixedCapacity = 64
|
||||
defaultBatchSize = 64
|
||||
)
|
||||
|
||||
type configuration[T any] struct {
|
||||
fixedCapacity int
|
||||
batchSize int
|
||||
onPanic func(T, any, []byte)
|
||||
dispatcher Dispatcher
|
||||
}
|
||||
|
||||
// Option configures an Actor during construction.
|
||||
type Option[T any] func(*configuration[T])
|
||||
|
||||
// WithFixedCapacity sets the number of retained mailbox nodes. The default is
|
||||
// 64. This is not a mailbox limit: further messages use dynamic nodes.
|
||||
func WithFixedCapacity[T any](capacity int) Option[T] {
|
||||
return func(c *configuration[T]) { c.fixedCapacity = capacity }
|
||||
}
|
||||
|
||||
// WithBatchSize sets how many messages are consumed between calls to
|
||||
// runtime.Gosched. Long pending-batch reversals also yield at this interval.
|
||||
// The default is 64, independent of the dispatcher.
|
||||
func WithBatchSize[T any](size int) Option[T] {
|
||||
return func(c *configuration[T]) { c.batchSize = size }
|
||||
}
|
||||
|
||||
// WithPanicHandler sets an optional callback for handler panics. It receives the
|
||||
// message, the recovered value, and a stack trace. Its own panics are recovered.
|
||||
// A nil callback disables panic reporting; messages still continue to drain.
|
||||
func WithPanicHandler[T any](handler func(message T, value any, stack []byte)) Option[T] {
|
||||
return func(c *configuration[T]) { c.onPanic = handler }
|
||||
}
|
||||
|
||||
// WithDispatcher chooses the construction-time scheduling policy. The default
|
||||
// dispatcher runs inline. New rejects a nil interface or nil DispatcherFunc.
|
||||
// Custom typed-nil implementations must themselves satisfy the Dispatcher
|
||||
// contract; their validity is the caller's responsibility.
|
||||
func WithDispatcher[T any](dispatcher Dispatcher) Option[T] {
|
||||
return func(c *configuration[T]) { c.dispatcher = dispatcher }
|
||||
}
|
||||
|
||||
func configure[T any](opts []Option[T]) configuration[T] {
|
||||
c := configuration[T]{
|
||||
fixedCapacity: defaultFixedCapacity,
|
||||
batchSize: defaultBatchSize,
|
||||
dispatcher: inlineDispatcher{},
|
||||
}
|
||||
for _, opt := range opts {
|
||||
if opt == nil {
|
||||
panic("actor: nil option")
|
||||
}
|
||||
opt(&c)
|
||||
}
|
||||
if c.fixedCapacity <= 0 {
|
||||
panic("actor: fixed capacity must be positive")
|
||||
}
|
||||
if c.batchSize <= 0 {
|
||||
panic("actor: batch size must be positive")
|
||||
}
|
||||
if c.dispatcher == nil {
|
||||
panic("actor: nil dispatcher")
|
||||
}
|
||||
if f, ok := c.dispatcher.(DispatcherFunc); ok && f == nil {
|
||||
panic("actor: nil dispatcher function")
|
||||
}
|
||||
return c
|
||||
}
|
||||
98
queue.go
Normal file
98
queue.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package actor
|
||||
|
||||
import "sync/atomic"
|
||||
|
||||
// queue separates message storage and FIFO publication from consumer ownership.
|
||||
// Only its current consumer may call takeBatch and pop.
|
||||
type queue[T any] struct {
|
||||
head atomic.Pointer[node[T]]
|
||||
|
||||
fixed []node[T]
|
||||
reserved atomic.Uint64
|
||||
consumed int // Accessed only by the current consumer.
|
||||
}
|
||||
|
||||
type node[T any] struct {
|
||||
value T
|
||||
next *node[T]
|
||||
fixed bool // Set at construction and never changed.
|
||||
}
|
||||
|
||||
func newQueue[T any](capacity int) *queue[T] {
|
||||
q := &queue[T]{fixed: make([]node[T], capacity)}
|
||||
for i := range q.fixed {
|
||||
q.fixed[i].fixed = true
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
func (q *queue[T]) push(message T) {
|
||||
n := q.reserveNode()
|
||||
n.value = message
|
||||
q.publish(n)
|
||||
}
|
||||
|
||||
func (q *queue[T]) reserveNode() *node[T] {
|
||||
capacity := uint64(len(q.fixed))
|
||||
for {
|
||||
index := q.reserved.Load()
|
||||
if index == capacity {
|
||||
return new(node[T])
|
||||
}
|
||||
if q.reserved.CompareAndSwap(index, index+1) {
|
||||
// A slot belongs to this producer only after the reservation CAS.
|
||||
return &q.fixed[index]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (q *queue[T]) publish(n *node[T]) {
|
||||
for {
|
||||
head := q.head.Load()
|
||||
n.next = head
|
||||
if q.head.CompareAndSwap(head, n) {
|
||||
// The consumer may immediately clear and reuse n. Do not access
|
||||
// any of its fields after this successful publication.
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (q *queue[T]) empty() bool {
|
||||
return q.head.Load() == nil
|
||||
}
|
||||
|
||||
func (q *queue[T]) takeBatch(batchSize int, yield func()) *node[T] {
|
||||
n := q.head.Swap(nil)
|
||||
var reversed *node[T]
|
||||
steps := 0
|
||||
for n != nil {
|
||||
next := n.next
|
||||
n.next = reversed
|
||||
reversed = n
|
||||
n = next
|
||||
steps++
|
||||
if steps == batchSize {
|
||||
steps = 0
|
||||
yield()
|
||||
}
|
||||
}
|
||||
return reversed
|
||||
}
|
||||
|
||||
func (q *queue[T]) pop(n *node[T]) (message T, next *node[T]) {
|
||||
next, message, fixed := n.next, n.value, n.fixed
|
||||
var zero T
|
||||
n.value = zero
|
||||
n.next = nil
|
||||
// Save everything first: reopening the fixed storage allows a producer to
|
||||
// reuse n immediately. Neither this method nor its caller may read it again.
|
||||
if fixed {
|
||||
q.consumed++
|
||||
if q.consumed == len(q.fixed) {
|
||||
q.consumed = 0
|
||||
q.reserved.Store(0)
|
||||
}
|
||||
}
|
||||
return message, next
|
||||
}
|
||||
37
queue_fuzz_test.go
Normal file
37
queue_fuzz_test.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package actor
|
||||
|
||||
import "testing"
|
||||
|
||||
func FuzzQueueOperations(f *testing.F) {
|
||||
seeds := [][]byte{
|
||||
{},
|
||||
{2, 3, 4}, // Singleton zero value.
|
||||
{0, 0, 8, 1, 3, 4, 2, 4, 3, 4}, // Publish reservations out of order.
|
||||
{2, 2, 2, 2, 3, 4, 2, 4, 2, 4, 4, 3, 4, 4}, // Partial batch, then republish.
|
||||
{2, 2, 2, 2, 2, 6, 4, 4, 2, 4, 4, 4, 3, 4}, // Publish during reversal.
|
||||
{0, 2, 2, 3, 4, 4, 1, 3, 4, 2, 3, 4}, // Delayed fixed reservation.
|
||||
}
|
||||
for capacity := range byte(5) {
|
||||
for _, seed := range seeds {
|
||||
f.Add(capacity, seed)
|
||||
}
|
||||
// Give every capacity a seed with repeated complete fixed rounds and
|
||||
// dynamic overflow, in addition to the partial-consumption seeds above.
|
||||
var cycles []byte
|
||||
for range 3 {
|
||||
count := [...]int{1, 2, 3, 8, 64}[capacity] + 2
|
||||
for range count {
|
||||
cycles = append(cycles, 2)
|
||||
}
|
||||
cycles = append(cycles, 3)
|
||||
for range count {
|
||||
cycles = append(cycles, 4)
|
||||
}
|
||||
}
|
||||
f.Add(capacity, cycles)
|
||||
}
|
||||
f.Fuzz(func(t *testing.T, capacity byte, operations []byte) {
|
||||
capacities := [...]int{1, 2, 3, 8, 64}
|
||||
runQueueModel(t, capacities[int(capacity)%len(capacities)], operations)
|
||||
})
|
||||
}
|
||||
648
queue_test.go
Normal file
648
queue_test.go
Normal file
@@ -0,0 +1,648 @@
|
||||
package actor
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func queueAwait(t *testing.T, done <-chan struct{}) {
|
||||
t.Helper()
|
||||
timer := time.NewTimer(10 * time.Second)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-done:
|
||||
case <-timer.C:
|
||||
t.Fatal("queue operation did not finish")
|
||||
}
|
||||
}
|
||||
|
||||
func queuePopInts(t *testing.T, q *queue[int], batch *node[int], want []int) {
|
||||
t.Helper()
|
||||
for _, expected := range want {
|
||||
if batch == nil {
|
||||
t.Fatalf("batch ended before message %d", expected)
|
||||
}
|
||||
retired := batch
|
||||
actual, next := q.pop(batch)
|
||||
batch = next
|
||||
if actual != expected {
|
||||
t.Fatalf("message=%d, want %d", actual, expected)
|
||||
}
|
||||
// These tests have no concurrent producer that could reuse the node
|
||||
// between pop and inspection.
|
||||
if retired.value != 0 || retired.next != nil {
|
||||
t.Fatal("consumed node retains its value or next link")
|
||||
}
|
||||
}
|
||||
if batch != nil {
|
||||
t.Fatal("batch contains unexpected messages")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueueEmptyAndCapacityBoundaries(t *testing.T) {
|
||||
for _, capacity := range []int{1, 2, 3, 8, 64} {
|
||||
for _, count := range []int{capacity - 1, capacity, capacity + 1, capacity*3 + 1} {
|
||||
t.Run(fmt.Sprintf("capacity=%d/messages=%d", capacity, count), func(t *testing.T) {
|
||||
q := newQueue[int](capacity)
|
||||
addresses := make([]*node[int], capacity)
|
||||
for i := range q.fixed {
|
||||
addresses[i] = &q.fixed[i]
|
||||
}
|
||||
for range 3 {
|
||||
if !q.empty() || q.takeBatch(4, runtime.Gosched) != nil {
|
||||
t.Fatal("new queue is not empty")
|
||||
}
|
||||
}
|
||||
want := make([]int, count)
|
||||
for i := range want {
|
||||
want[i] = i // Includes the zero value and singleton zero case.
|
||||
q.push(i)
|
||||
}
|
||||
fixed, dynamic := 0, 0
|
||||
for n := q.head.Load(); n != nil; n = n.next {
|
||||
if n.fixed {
|
||||
fixed++
|
||||
} else {
|
||||
dynamic++
|
||||
}
|
||||
}
|
||||
if fixed != min(count, capacity) || dynamic != max(0, count-capacity) {
|
||||
t.Fatalf("storage boundary: fixed=%d dynamic=%d", fixed, dynamic)
|
||||
}
|
||||
batch := q.takeBatch(4, runtime.Gosched)
|
||||
// empty describes the published stack, not the detached batch.
|
||||
for range 3 {
|
||||
if !q.empty() || q.takeBatch(4, runtime.Gosched) != nil {
|
||||
t.Fatal("detached messages remained on the published stack")
|
||||
}
|
||||
}
|
||||
queuePopInts(t, q, batch, want)
|
||||
for i := range q.fixed {
|
||||
if &q.fixed[i] != addresses[i] || !q.fixed[i].fixed || q.fixed[i].value != 0 || q.fixed[i].next != nil {
|
||||
t.Fatalf("fixed slot %d moved or retained consumed data", i)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueueNewMessageAtFixedStorageConsumptionStages(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
initial int
|
||||
consumed int
|
||||
wantFixed bool
|
||||
}{
|
||||
{name: "fixed_full", initial: 4},
|
||||
{name: "half_fixed_consumed_with_dynamic_messages", initial: 7, consumed: 2},
|
||||
{name: "all_fixed_consumed_with_dynamic_messages", initial: 7, consumed: 4, wantFixed: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
q := newQueue[int](4)
|
||||
for value := 1; value <= test.initial; value++ {
|
||||
q.push(value)
|
||||
}
|
||||
var batch *node[int]
|
||||
if test.consumed > 0 {
|
||||
batch = q.takeBatch(4, runtime.Gosched)
|
||||
for expected := 1; expected <= test.consumed; expected++ {
|
||||
if batch == nil {
|
||||
t.Fatal("initial batch ended before the requested consumption stage")
|
||||
}
|
||||
value, next := q.pop(batch)
|
||||
batch = next
|
||||
if value != expected {
|
||||
t.Fatalf("initial message=%d, want %d", value, expected)
|
||||
}
|
||||
}
|
||||
fixed, dynamic := 0, 0
|
||||
for n := batch; n != nil; n = n.next {
|
||||
if n.fixed {
|
||||
fixed++
|
||||
} else {
|
||||
dynamic++
|
||||
}
|
||||
}
|
||||
if fixed != 4-test.consumed || dynamic != 3 {
|
||||
t.Fatalf("remaining batch: fixed=%d dynamic=%d", fixed, dynamic)
|
||||
}
|
||||
}
|
||||
message := test.initial + 1
|
||||
q.push(message)
|
||||
published := q.head.Load()
|
||||
if published == nil || published.value != message || published.fixed != test.wantFixed {
|
||||
t.Fatalf("new message node=%+v, want value=%d fixed=%v", published, message, test.wantFixed)
|
||||
}
|
||||
if test.wantFixed && published != &q.fixed[0] {
|
||||
t.Fatal("completed fixed round did not reuse its first slot")
|
||||
}
|
||||
var remaining []int
|
||||
for value := test.consumed + 1; value <= test.initial; value++ {
|
||||
remaining = append(remaining, value)
|
||||
}
|
||||
if batch != nil {
|
||||
// Older dynamic messages stay ahead of the new message even
|
||||
// when that message has reused the fixed storage.
|
||||
queuePopInts(t, q, batch, remaining)
|
||||
remaining = nil
|
||||
}
|
||||
remaining = append(remaining, message)
|
||||
queuePopInts(t, q, q.takeBatch(4, runtime.Gosched), remaining)
|
||||
if !q.empty() {
|
||||
t.Fatal("new message left extra published data")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueueFixedRoundReopensOnlyAfterFullConsumption(t *testing.T) {
|
||||
q := newQueue[int](3)
|
||||
first := &q.fixed[0]
|
||||
for value := 1; value <= 7; value++ {
|
||||
q.push(value)
|
||||
}
|
||||
batch := q.takeBatch(4, runtime.Gosched)
|
||||
for expected := 1; expected <= 3; expected++ {
|
||||
value, next := q.pop(batch)
|
||||
batch = next
|
||||
if value != expected {
|
||||
t.Fatalf("fixed message=%d, want %d", value, expected)
|
||||
}
|
||||
if expected < 3 {
|
||||
if q.reserved.Load() != 3 || q.consumed != expected {
|
||||
t.Fatal("partially consumed fixed round was reopened")
|
||||
}
|
||||
q.push(7 + expected)
|
||||
if q.head.Load().fixed {
|
||||
t.Fatal("producer reused a fixed slot before the round finished")
|
||||
}
|
||||
}
|
||||
}
|
||||
if q.reserved.Load() != 0 || q.consumed != 0 {
|
||||
t.Fatal("fully consumed fixed round did not reopen")
|
||||
}
|
||||
q.push(10)
|
||||
if q.head.Load() != first {
|
||||
t.Fatal("new round did not reuse the original first fixed node")
|
||||
}
|
||||
// Fixed storage has reopened while the old batch still holds dynamic 4..7.
|
||||
queuePopInts(t, q, batch, []int{4, 5, 6, 7})
|
||||
queuePopInts(t, q, q.takeBatch(4, runtime.Gosched), []int{8, 9, 10})
|
||||
q.push(11)
|
||||
q.push(12)
|
||||
queuePopInts(t, q, q.takeBatch(4, runtime.Gosched), []int{11, 12})
|
||||
for round := range 32 {
|
||||
want := []int{round*3 + 13, round*3 + 14, round*3 + 15}
|
||||
for _, value := range want {
|
||||
q.push(value)
|
||||
}
|
||||
queuePopInts(t, q, q.takeBatch(4, runtime.Gosched), want)
|
||||
if q.reserved.Load() != 0 || q.consumed != 0 || &q.fixed[0] != first {
|
||||
t.Fatalf("fixed round %d did not complete in the same storage", round)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueuePublicationDuringBatchReversalRemainsInNextBatch(t *testing.T) {
|
||||
q := newQueue[int](2)
|
||||
for value := 1; value <= 6; value++ {
|
||||
q.push(value)
|
||||
}
|
||||
yields := 0
|
||||
batch := q.takeBatch(2, func() {
|
||||
yields++
|
||||
switch yields {
|
||||
case 1:
|
||||
if !q.empty() {
|
||||
t.Error("published stack was not detached before reversal")
|
||||
}
|
||||
q.push(100)
|
||||
case 2:
|
||||
q.push(101)
|
||||
}
|
||||
})
|
||||
if yields != 3 || q.empty() {
|
||||
t.Fatalf("reversal did not isolate new publications: yields=%d empty=%v", yields, q.empty())
|
||||
}
|
||||
for expected := 1; expected <= 2; expected++ {
|
||||
value, next := q.pop(batch)
|
||||
batch = next
|
||||
if value != expected {
|
||||
t.Fatalf("detached message=%d, want %d", value, expected)
|
||||
}
|
||||
q.push(101 + expected)
|
||||
}
|
||||
queuePopInts(t, q, batch, []int{3, 4, 5, 6})
|
||||
queuePopInts(t, q, q.takeBatch(2, runtime.Gosched), []int{100, 101, 102, 103})
|
||||
if !q.empty() {
|
||||
t.Fatal("new publications were lost or duplicated during reversal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueuePausedReservationDoesNotBlockPublishedMessages(t *testing.T) {
|
||||
q := newQueue[int](1)
|
||||
reserved, publish, done := make(chan struct{}), make(chan struct{}), make(chan struct{})
|
||||
releaseProducer := sync.OnceFunc(func() { close(publish) })
|
||||
t.Cleanup(releaseProducer)
|
||||
go func() {
|
||||
n := q.reserveNode()
|
||||
close(reserved)
|
||||
<-publish
|
||||
n.value = 1
|
||||
q.publish(n)
|
||||
close(done)
|
||||
}()
|
||||
queueAwait(t, reserved)
|
||||
q.push(2)
|
||||
q.push(3)
|
||||
queuePopInts(t, q, q.takeBatch(4, runtime.Gosched), []int{2, 3})
|
||||
if q.reserved.Load() != 1 || q.consumed != 0 || q.fixed[0].value != 0 {
|
||||
t.Fatal("unpublished fixed reservation was consumed or reused")
|
||||
}
|
||||
releaseProducer()
|
||||
queueAwait(t, done)
|
||||
queuePopInts(t, q, q.takeBatch(4, runtime.Gosched), []int{1})
|
||||
q.push(4)
|
||||
if q.head.Load() != &q.fixed[0] {
|
||||
t.Fatal("completed paused reservation did not allow fixed reuse")
|
||||
}
|
||||
queuePopInts(t, q, q.takeBatch(4, runtime.Gosched), []int{4})
|
||||
}
|
||||
|
||||
func TestQueueStaleHeadAfterDetachFailsCASAndRepublishes(t *testing.T) {
|
||||
q := newQueue[int](2)
|
||||
q.push(1)
|
||||
paused := q.reserveNode()
|
||||
paused.value = 2
|
||||
expected := q.head.Load()
|
||||
paused.next = expected
|
||||
queuePopInts(t, q, q.takeBatch(4, runtime.Gosched), []int{1})
|
||||
if q.head.CompareAndSwap(expected, paused) {
|
||||
t.Fatal("publication CAS accepted a detached old head")
|
||||
}
|
||||
q.push(3)
|
||||
// Real publish must replace paused.next, which still points at the retired
|
||||
// head, with the latest published stack after the failed stale attempt.
|
||||
q.publish(paused)
|
||||
queuePopInts(t, q, q.takeBatch(4, runtime.Gosched), []int{3, 2})
|
||||
if !q.empty() || q.reserved.Load() != 0 {
|
||||
t.Fatal("retry lost a message or held the completed fixed round")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueueStaleHeadAddressReuseCanSuccessfullyPublish(t *testing.T) {
|
||||
q := newQueue[int](1)
|
||||
q.push(1)
|
||||
paused := q.reserveNode()
|
||||
paused.value = 3
|
||||
expected := q.head.Load()
|
||||
paused.next = expected
|
||||
queuePopInts(t, q, q.takeBatch(4, runtime.Gosched), []int{1})
|
||||
q.push(2)
|
||||
if q.head.Load() != expected {
|
||||
t.Fatal("test did not reuse the old fixed head address")
|
||||
}
|
||||
if !q.head.CompareAndSwap(expected, paused) {
|
||||
t.Fatal("stale pointer CAS should match the current reused head")
|
||||
}
|
||||
q.push(4)
|
||||
queuePopInts(t, q, q.takeBatch(4, runtime.Gosched), []int{2, 3, 4})
|
||||
if !q.empty() || paused.value != 0 || paused.next != nil {
|
||||
t.Fatal("address reuse corrupted FIFO or retained the dynamic node")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueueReferencePayloadsAndRetiredNodesAreCleared(t *testing.T) {
|
||||
type payload struct {
|
||||
id int
|
||||
pointer *int
|
||||
bytes []byte
|
||||
values map[string]int
|
||||
}
|
||||
want := []payload{{}}
|
||||
for id := 1; id <= 3; id++ {
|
||||
value := id
|
||||
want = append(want, payload{id, &value, []byte{byte(id)}, map[string]int{"id": id}})
|
||||
}
|
||||
q := newQueue[payload](2)
|
||||
for _, message := range want {
|
||||
q.push(message)
|
||||
}
|
||||
var nodes []*node[payload]
|
||||
for n := q.head.Load(); n != nil; n = n.next {
|
||||
nodes = append(nodes, n)
|
||||
}
|
||||
batch := q.takeBatch(4, runtime.Gosched)
|
||||
for _, expected := range want {
|
||||
if batch == nil {
|
||||
t.Fatal("reference payload batch ended early")
|
||||
}
|
||||
actual, next := q.pop(batch)
|
||||
batch = next
|
||||
if !reflect.DeepEqual(actual, expected) || actual.pointer != expected.pointer {
|
||||
t.Fatalf("payload=%+v, want %+v", actual, expected)
|
||||
}
|
||||
if actual.id != 0 {
|
||||
if &actual.bytes[0] != &expected.bytes[0] {
|
||||
t.Fatal("slice payload was not passed as a Go value")
|
||||
}
|
||||
actual.values["consumer"] = actual.id
|
||||
if expected.values["consumer"] != actual.id {
|
||||
t.Fatal("map payload was not passed as a Go value")
|
||||
}
|
||||
}
|
||||
}
|
||||
if batch != nil || !q.empty() {
|
||||
t.Fatal("reference payload messages were duplicated")
|
||||
}
|
||||
for _, n := range nodes {
|
||||
if !reflect.DeepEqual(n.value, payload{}) || n.next != nil {
|
||||
t.Fatal("retired fixed or dynamic node retains payload references or a link")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueueConcurrentProducersAndContinuousConsumer(t *testing.T) {
|
||||
const producers, messages, chunk = 6, 400, 32
|
||||
type payload struct {
|
||||
producer, sequence, id int
|
||||
token *int
|
||||
check [3]uint64
|
||||
}
|
||||
makePayload := func(producer, sequence, id int) payload {
|
||||
return payload{producer, sequence, id, &id, [3]uint64{uint64(id), uint64(id) ^ 0x9e3779b9, uint64(id)*17 + 3}}
|
||||
}
|
||||
for _, capacity := range []int{1, 3, 8} {
|
||||
t.Run(fmt.Sprintf("capacity=%d", capacity), func(t *testing.T) {
|
||||
q := newQueue[payload](capacity)
|
||||
prefill := capacity + 3
|
||||
for sequence := range prefill {
|
||||
q.push(makePayload(-1, sequence, producers*messages+sequence))
|
||||
}
|
||||
held := q.takeBatch(4, runtime.Gosched)
|
||||
if !q.empty() || held == nil {
|
||||
t.Fatal("prefilled batch was not detached")
|
||||
}
|
||||
stop := make(chan struct{})
|
||||
stopWorkers := sync.OnceFunc(func() { close(stop) })
|
||||
t.Cleanup(stopWorkers)
|
||||
var ack [producers]chan struct{}
|
||||
var ready, senders sync.WaitGroup
|
||||
ready.Add(producers)
|
||||
senders.Add(producers)
|
||||
for producer := range producers {
|
||||
ack[producer] = make(chan struct{}, 1)
|
||||
go func() {
|
||||
defer senders.Done()
|
||||
reportReady := sync.OnceFunc(ready.Done)
|
||||
defer reportReady()
|
||||
for sequence := range messages {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
q.push(makePayload(producer, sequence, producer*messages+sequence))
|
||||
if (sequence+1)%chunk == 0 {
|
||||
if sequence+1 == chunk {
|
||||
reportReady()
|
||||
}
|
||||
// The next chunk cannot be produced until the single
|
||||
// consumer has processed this chunk's last message.
|
||||
select {
|
||||
case <-ack[producer]:
|
||||
case <-stop:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
readyDone, sendersDone := make(chan struct{}), make(chan struct{})
|
||||
go func() { ready.Wait(); close(readyDone) }()
|
||||
go func() { senders.Wait(); close(sendersDone) }()
|
||||
queueAwait(t, readyDone)
|
||||
consumedDone := make(chan struct{})
|
||||
fixedPops, consumed := 0, 0
|
||||
total := producers*messages + prefill
|
||||
go func() {
|
||||
defer close(consumedDone)
|
||||
defer stopWorkers()
|
||||
last := make([]int, producers+1)
|
||||
for i := range last {
|
||||
last[i] = -1
|
||||
}
|
||||
seen := make([]bool, total)
|
||||
batch := held
|
||||
for consumed < total {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
if batch == nil {
|
||||
batch = q.takeBatch(4, runtime.Gosched)
|
||||
if batch == nil {
|
||||
runtime.Gosched()
|
||||
continue
|
||||
}
|
||||
}
|
||||
fixed := batch.fixed
|
||||
message, next := q.pop(batch)
|
||||
batch = next
|
||||
if fixed {
|
||||
fixedPops++
|
||||
}
|
||||
producer := message.producer
|
||||
expectedID := producer*messages + message.sequence
|
||||
if producer == -1 {
|
||||
producer = producers
|
||||
expectedID = producers*messages + message.sequence
|
||||
}
|
||||
if producer < 0 || producer > producers || message.id < 0 || message.id >= total {
|
||||
t.Errorf("corrupt payload: %+v", message)
|
||||
return
|
||||
}
|
||||
if message.sequence != last[producer]+1 || message.id != expectedID || seen[message.id] {
|
||||
t.Errorf("FIFO/uniqueness: message=%+v previous=%d", message, last[producer])
|
||||
}
|
||||
if message.token == nil || *message.token != message.id || message.check != makePayload(message.producer, message.sequence, message.id).check {
|
||||
t.Errorf("incomplete published payload: %+v", message)
|
||||
}
|
||||
last[producer] = message.sequence
|
||||
seen[message.id] = true
|
||||
consumed++
|
||||
if producer < producers && (message.sequence+1)%chunk == 0 {
|
||||
select {
|
||||
case ack[producer] <- struct{}{}:
|
||||
case <-stop:
|
||||
return
|
||||
}
|
||||
}
|
||||
// Yield while producers have subsequent chunks to publish,
|
||||
// including after fixed nodes have become reusable.
|
||||
runtime.Gosched()
|
||||
}
|
||||
for id, present := range seen {
|
||||
if !present {
|
||||
t.Errorf("missing message %d", id)
|
||||
}
|
||||
}
|
||||
if batch != nil || !q.empty() {
|
||||
t.Error("consumer finished with extra queued messages")
|
||||
}
|
||||
}()
|
||||
queueAwait(t, consumedDone)
|
||||
queueAwait(t, sendersDone)
|
||||
if consumed != total {
|
||||
t.Fatalf("consumer stopped after %d messages, want %d", consumed, total)
|
||||
}
|
||||
if fixedPops < capacity*2 {
|
||||
t.Fatalf("test did not exercise multiple fixed rounds: %d fixed pops", fixedPops)
|
||||
}
|
||||
if !q.empty() {
|
||||
t.Fatal("published stack not empty after all producers and consumer finished")
|
||||
}
|
||||
for i := range q.fixed {
|
||||
if q.fixed[i].value != (payload{}) || q.fixed[i].next != nil {
|
||||
t.Fatalf("fixed slot %d retained a consumed payload or link", i)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// runQueueModel uses slices for the two observable FIFO domains: the published
|
||||
// queue and a separately held, possibly partially consumed batch. Reservations
|
||||
// have no position in either FIFO until explicitly published.
|
||||
func runQueueModel(t *testing.T, capacity int, operations []byte) {
|
||||
t.Helper()
|
||||
if len(operations) > 4096 {
|
||||
operations = operations[:4096]
|
||||
}
|
||||
type reservation struct {
|
||||
node *node[int]
|
||||
value int
|
||||
}
|
||||
q := newQueue[int](capacity)
|
||||
fixed := &q.fixed[0]
|
||||
var pending []reservation
|
||||
var published, detached []int
|
||||
var batch *node[int]
|
||||
nextID := 0
|
||||
nextMessage := func() int { value := nextID; nextID++; return value }
|
||||
publish := func(index int) {
|
||||
r := pending[index]
|
||||
q.publish(r.node)
|
||||
published = append(published, r.value)
|
||||
pending = append(pending[:index], pending[index+1:]...)
|
||||
}
|
||||
take := func(inject bool) {
|
||||
if batch != nil {
|
||||
return
|
||||
}
|
||||
detached, published = published, nil
|
||||
injected := false
|
||||
batch = q.takeBatch(2, func() {
|
||||
if inject && !injected {
|
||||
value := nextMessage()
|
||||
q.push(value)
|
||||
published = append(published, value)
|
||||
injected = true
|
||||
}
|
||||
})
|
||||
if (batch == nil) != (len(detached) == 0) {
|
||||
t.Fatalf("model take: batch present=%v detached=%v", batch != nil, detached)
|
||||
}
|
||||
}
|
||||
pop := func() {
|
||||
if batch == nil {
|
||||
if len(detached) != 0 {
|
||||
t.Fatalf("model retained detached messages without a batch: %v", detached)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(detached) == 0 {
|
||||
t.Fatal("queue has a batch absent from the reference model")
|
||||
}
|
||||
retired := batch
|
||||
actual, next := q.pop(batch)
|
||||
batch = next
|
||||
if actual != detached[0] {
|
||||
t.Fatalf("model pop: got %d, want %d", actual, detached[0])
|
||||
}
|
||||
detached = detached[1:]
|
||||
if retired.value != 0 || retired.next != nil || (batch == nil) != (len(detached) == 0) {
|
||||
t.Fatal("model pop: node not cleared or detached batch length differs")
|
||||
}
|
||||
}
|
||||
for step, operation := range operations {
|
||||
switch operation % 7 {
|
||||
case 0:
|
||||
value := nextMessage()
|
||||
n := q.reserveNode()
|
||||
n.value = value
|
||||
pending = append(pending, reservation{n, value})
|
||||
case 1:
|
||||
if len(pending) != 0 {
|
||||
publish(int(operation/7) % len(pending))
|
||||
}
|
||||
case 2:
|
||||
value := nextMessage()
|
||||
q.push(value)
|
||||
published = append(published, value)
|
||||
case 3:
|
||||
take(false)
|
||||
case 4, 5:
|
||||
pop()
|
||||
case 6:
|
||||
take(true)
|
||||
}
|
||||
if q.empty() != (len(published) == 0) {
|
||||
t.Fatalf("step %d: empty disagrees with published FIFO (detached=%v reserved=%d)", step, detached, len(pending))
|
||||
}
|
||||
if &q.fixed[0] != fixed || len(q.fixed) != capacity {
|
||||
t.Fatalf("step %d: fixed storage changed", step)
|
||||
}
|
||||
}
|
||||
// Publishing pending reservations in reverse order deliberately separates
|
||||
// reservation order from FIFO order, including when a batch is held.
|
||||
for len(pending) > 0 {
|
||||
publish(len(pending) - 1)
|
||||
}
|
||||
for batch != nil {
|
||||
pop()
|
||||
}
|
||||
for len(published) > 0 {
|
||||
take(false)
|
||||
for batch != nil {
|
||||
pop()
|
||||
}
|
||||
}
|
||||
if !q.empty() || q.takeBatch(2, runtime.Gosched) != nil {
|
||||
t.Fatal("model flush left published messages")
|
||||
}
|
||||
for i := range q.fixed {
|
||||
if q.fixed[i].value != 0 || q.fixed[i].next != nil {
|
||||
t.Fatalf("model flush retained data in fixed slot %d", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueueMatchesIndependentFIFOModel(t *testing.T) {
|
||||
for _, capacity := range []int{1, 2, 3, 8, 64} {
|
||||
for _, seed := range []int64{1, 17, 2026, 8675309} {
|
||||
t.Run(fmt.Sprintf("capacity=%d/seed=%d", capacity, seed), func(t *testing.T) {
|
||||
operations := make([]byte, 2048)
|
||||
_, _ = rand.New(rand.NewSource(seed)).Read(operations)
|
||||
runQueueModel(t, capacity, operations)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user