diff --git a/coro/coro.go b/coro/coro.go new file mode 100644 index 00000000..2afb952d --- /dev/null +++ b/coro/coro.go @@ -0,0 +1,108 @@ +package coro + +import ( + "slices" +) + +const routineCancelled = "coroutine cancelled" + +type Yield func() + +func New(resume func(yield Yield)) *Routine[struct{}] { + return WithReturn(func(y YieldReturn[struct{}]) { + resume(func() { + y(struct{}{}) + }) + }) +} + +type YieldReturn[V any] func(V) + +func WithReturn[V any](resume func(YieldReturn[V])) *Routine[V] { + r := &Routine[V]{ // 1 alloc + resumed: make(chan struct{}), // 1 alloc + done: make(chan V), // 1 alloc + status: Suspended, + } + go r.start(resume) // 3 allocs + + return r +} + +type Routine[V any] struct { + done chan V + resumed chan struct{} + status Status +} + +func (r *Routine[V]) start(f func(YieldReturn[V])) { // 1 alloc + defer r.recoverAndDestroy() + + _, ok := <-r.resumed // 2 allocs + if !ok { + panic(routineCancelled) + } + + r.status = Running + f(r.yield) +} + +func (r *Routine[V]) yield(v V) { + r.done <- v + r.status = Suspended + if _, ok := <-r.resumed; !ok { + panic(routineCancelled) + } +} + +func (r *Routine[V]) recoverAndDestroy() { + p := recover() + if p != nil && p != routineCancelled { + panic("coroutine panicked") + } + r.status = Dead + close(r.done) +} + +func (r *Routine[V]) Resume() (value V, hasMore bool) { + if r.status == Dead { + return + } + + r.resumed <- struct{}{} + value, hasMore = <-r.done + return +} + +func (r *Routine[V]) Status() Status { + return r.status +} + +func (r *Routine[V]) Cancel() { + if r.status == Dead { + return + } + + close(r.resumed) + <-r.done +} + +type Status string + +const ( + // Normal Status = "normal" // This coroutine is currently waiting in coresume for another coroutine. (Either for the running coroutine, or for another normal coroutine) + Running Status = "running" // This is the coroutine that's currently running - aka the one that just called costatus. + Suspended Status = "suspended" // This coroutine is not running - either it has yielded or has never been resumed yet. + Dead Status = "dead" // This coroutine has either returned or died due to an error. +) + +type Routines []*Routine[struct{}] + +func (r Routines) ResumeAll() Routines { + for _, rout := range r { + rout.Resume() + } + return slices.DeleteFunc(r, func(r *Routine[struct{}]) bool { + return r.Status() == Dead + }) +} diff --git a/coro/coro_bench_test.go b/coro/coro_bench_test.go new file mode 100644 index 00000000..22e8dd8d --- /dev/null +++ b/coro/coro_bench_test.go @@ -0,0 +1,78 @@ +package coro_test + +import ( + "testing" + + "github.com/elgopher/pi/coro" +) + +func BenchmarkNew(b *testing.B) { + b.ReportAllocs() + + var r *coro.Routine[struct{}] + + for i := 0; i < b.N; i++ { + r = coro.New(f2) // 7 allocs :( 4us on windows :( But on linux it is 1us and 5 allocs! + } + + _ = r +} + +func BenchmarkCreate(b *testing.B) { + b.ReportAllocs() + + var r *coro.Routine[struct{}] + + for i := 0; i < b.N; i++ { + r = coro.WithReturn(f) // 6 allocs :( 4us on windows :( But on linux it is 1us and 5 allocs! + } + + _ = r +} + +func BenchmarkResume(b *testing.B) { + b.ReportAllocs() + + var r *coro.Routine[struct{}] + + for i := 0; i < b.N; i++ { + r = coro.WithReturn(f) // 6 allocs + r.Resume() // 1 alloc, 0.8us :( + } + _ = r +} + +func BenchmarkResumeUntilFinish(b *testing.B) { + b.ReportAllocs() + + var r *coro.Routine[struct{}] + + for i := 0; i < b.N; i++ { + r = coro.WithReturn(f) // 6 allocs + r.Resume() // 1 alloc, 0.8us :( + r.Resume() // 1 alloc, 0.8us :( + } + _ = r +} + +func BenchmarkCancel(b *testing.B) { + b.ReportAllocs() + + var r *coro.Routine[struct{}] + + for i := 0; i < b.N; i++ { + r = coro.WithReturn(f) // 6 allocs + r.Cancel() // -2 alloc???? + } + _ = r +} + +//go:noinline +func f2(yield coro.Yield) { + yield() +} + +//go:noinline +func f(yield coro.YieldReturn[struct{}]) { + yield(struct{}{}) +} diff --git a/devtools/internal/lib/github_com-elgopher-pi.go b/devtools/internal/lib/github_com-elgopher-pi.go index 29571c18..6344afc2 100644 --- a/devtools/internal/lib/github_com-elgopher-pi.go +++ b/devtools/internal/lib/github_com-elgopher-pi.go @@ -87,6 +87,8 @@ func init() { "Button": reflect.ValueOf((*pi.Button)(nil)), "Controller": reflect.ValueOf((*pi.Controller)(nil)), "Font": reflect.ValueOf((*pi.Font)(nil)), + "Iterator": reflect.ValueOf((*pi.Iterator)(nil)), + "Iterators": reflect.ValueOf((*pi.Iterators)(nil)), //"Int": reflect.ValueOf((*pi.Int)(nil)), // TODO Generic constraints not supported by Yaegi yet "MouseButton": reflect.ValueOf((*pi.MouseButton)(nil)), "PixMap": reflect.ValueOf((*pi.PixMap)(nil)), diff --git a/examples/coroutine/coroutine.go b/examples/coroutine/coroutine.go new file mode 100644 index 00000000..5eaa4093 --- /dev/null +++ b/examples/coroutine/coroutine.go @@ -0,0 +1,91 @@ +package main + +import ( + "math/rand" + "net/http" + + "github.com/elgopher/pi" + "github.com/elgopher/pi/coro" + "github.com/elgopher/pi/ebitengine" +) + +var coroutines coro.Routines + +func main() { + go func() { + http.ListenAndServe("localhost:6060", nil) + }() + + pi.Update = func() { + if pi.MouseBtnp(pi.MouseLeft) { + //r := movePixel(pi.MousePos) + for j := 0; j < 8000; j++ { // (~6-9KB per COROUTINE). Pico-8 has 4000 coroutines limit + r := coro.New(func(yield coro.Yield) { + sleep(10, yield) + moveHero(10, 120, 5, 10, yield) + sleep(20, yield) + moveHero(120, 10, 2, 10, yield) + }) + coroutines = append(coroutines, r) // complexCoroutine is 2 coroutines - 12-18KB in total + } + } + } + + pi.Draw = func() { + pi.Cls() + coroutines = coroutines.ResumeAll() + //devtools.Export("coroutines", coroutines) + } + + ebitengine.Run() +} + +func movePixel(pos pi.Position, yield coro.Yield) { + for i := 0; i < 64; i++ { + pi.Set(pos.X+i, pos.Y+i, byte(rand.Intn(16))) + yield() + yield() + } +} + +func moveHero(startX, stopX, minSpeed, maxSpeed int, yield coro.Yield) { + anim := coro.WithReturn(randomMove(startX, stopX, minSpeed, maxSpeed)) + + for { + x, hasMore := anim.Resume() + pi.Set(x, 20, 7) + if hasMore { + yield() + } else { + return + } + } +} + +// Reusable coroutine which returns int. +func randomMove(start, stop, minSpeed, maxSpeed int) func(yield coro.YieldReturn[int]) { + pos := start + + return func(yield coro.YieldReturn[int]) { + for { + speed := rand.Intn(maxSpeed - minSpeed) + if stop > start { + pos = pi.MinInt(stop, pos+speed) // move pos in stop direction by random speed + } else { + pos = pi.MaxInt(stop, pos-speed) + } + + if pos == stop { + return + } else { + yield(pos) + } + } + } +} + +func sleep(iterations int, yield coro.Yield) { + for i := 0; i < iterations; i++ { + yield() + } +} diff --git a/examples/iterator/main.go b/examples/iterator/main.go new file mode 100644 index 00000000..44db7abe --- /dev/null +++ b/examples/iterator/main.go @@ -0,0 +1,184 @@ +package main + +import ( + "fmt" + "math/rand" + "net/http" + _ "net/http/pprof" + + "github.com/elgopher/pi" + "github.com/elgopher/pi/ebitengine" +) + +var iterators pi.Iterators + +func main() { + go func() { + http.ListenAndServe("localhost:6060", nil) + }() + + pi.Update = func() { + //if pi.MouseBtn(pi.MouseLeft) { + // iterators = append(iterators, movePixel(pi.MousePos)) + //} + if pi.MouseBtnp(pi.MouseLeft) { + for j := 0; j < 8000; j++ { // 250bytes per coroutine + iterators = append(iterators, complexIteratorAlternative()) // 30x faster than coroutines + } + } + } + + pi.Draw = func() { + pi.Cls() + iterators = iterators.Next() + fmt.Println(len(iterators)) + } + + ebitengine.MustRun() +} + +func movePixel(pos pi.Position) pi.Iterator { + i := 0 + return func() bool { + if i == 128 { + return false + } + + if i%2 == 0 { // draw pixel every 2 frames + pi.Set(pos.X+i, pos.Y+i, byte(rand.Intn(16))) + } + + i++ + + return true + } +} + +func moveHero(startX, stopX, minSpeed, maxSpeed int) pi.Iterator { + anim := randomMove(startX, stopX, minSpeed, maxSpeed) + finished := false + + return func() bool { + if finished { + return false + } + + x, hasNext := anim() + if !hasNext { + finished = true + } + pi.Set(x, 20, 7) + return hasNext + } +} + +// Reusable iterator which returns int +func randomMove(start, stop, minSpeed, maxSpeed int) func() (int, bool) { + pos := start + + return func() (int, bool) { + speed := rand.Intn(maxSpeed - minSpeed) + if stop > start { + pos = pi.MinInt(stop, pos+speed) // move pos in stop direction by random speed + } else { + pos = pi.MaxInt(stop, pos-speed) + } + + return pos, pos != stop + } +} + +func complexIterator() pi.Iterator { + return pi.Sequence( + sleep(10), + moveHero(10, 120, 5, 10), + sleep(20), + moveHero(120, 10, 2, 10), + ) +} + +func complexIteratorAlternative() pi.Iterator { + sleep10 := sleep(10) // + 2 allocations + move := moveHero(10, 120, 5, 10) // + 4 allocations + sleep20 := sleep(20) // + 2 allocations + moveBackwards := moveHero(120, 10, 2, 10) // + 4 allocations + + return func() bool { + if sleep10() { + return true + } + if move() { + return true + } + if sleep20() { + return true + } + if moveBackwards() { + return true + } + return false + } +} + +func complexIterator2() pi.Iterator { + return pi.Sequence( + sleep(90), + func() bool { + fmt.Println("After 90 frames") + return false + }, + ) +} + +// this is better than complexIterator2 +func complexIterator3() pi.Iterator { + sleep := sleep(90) + + return func() bool { + if sleep() { + return true + } + + fmt.Println("After 90 frames") + return false + } +} + +// this is better event better than complexIterator3 +func complexIterator4() pi.Iterator { + i := 0 + + return func() bool { + switch { + case i < 90: + i++ + return true + case i == 90: + fmt.Println("After 90 frames") + } + + return false + } +} + +func finishOnNextCall() bool { + return false +} + +func sleep(iterations int) pi.Iterator { + if iterations <= 0 { + return finishOnNextCall + } + + i := 0 + + return func() bool { + if i == iterations { + return false + } + + i++ + + return i != iterations + } +} diff --git a/examples/iterator2/main.go b/examples/iterator2/main.go new file mode 100644 index 00000000..a405c2a9 --- /dev/null +++ b/examples/iterator2/main.go @@ -0,0 +1,81 @@ +package main + +import ( + "math" + "math/rand" + + "github.com/elgopher/pi" + "github.com/elgopher/pi/ebitengine" +) + +var iterators pi.Iterators + +func main() { + for i := 0; i < 32; i++ { + pos := pi.Position{X: rand.Intn(128), Y: rand.Intn(128)} + radius := rand.Intn(9) + 1 + color := byte(rand.Intn(15)) + 1 + + iterators = append(iterators, animateBall(pos, radius, color)) + } + + pi.Draw = func() { + pi.Cls() + iterators = iterators.Next() + } + + ebitengine.MustRun() +} + +func animateBall(currentPos pi.Position, radius int, color byte) pi.Iterator { + var moveBall func() (pi.Position, bool) // moveBall will have move iterator which calculates new position on each call + framesStoodStill := 0 + + return func() bool { + pi.CircFill(currentPos.X, currentPos.Y, radius, color) + + notMoving := moveBall == nil + if notMoving { + framesStoodStill++ + if framesStoodStill == 90 { + // It's time to move the ball to a new position + newPos := pi.Position{X: rand.Intn(128), Y: rand.Intn(128)} + moveBall = move(currentPos, newPos) + framesStoodStill = 0 + } + + return true // animateBall iterator never ends + } + + var hasNext bool + currentPos, hasNext = moveBall() // run iterator which returns new position + if !hasNext { // hasNext = false means that iterator was finished + moveBall = nil + } + + return true // animateBall iterator never ends + } +} + +const speed = 2 + +func move(from, to pi.Position) func() (pi.Position, bool) { + dy := float64(to.Y - from.Y) + dx := float64(to.X - from.X) + distance := math.Sqrt(math.Pow(dx, 2) + math.Pow(dy, 2)) + x, y := float64(from.X), float64(from.Y) + + stepX := speed * dx / distance + stepY := speed * dy / distance + steps := int(distance / speed) + step := 0 + + return func() (pi.Position, bool) { + x += stepX + y += stepY + step++ + + newPos := pi.Position{X: int(x), Y: int(y)} + return newPos, steps != step // iterator will finish if steps == step + } +} diff --git a/internal/bench/iter_bench_test.go b/internal/bench/iter_bench_test.go new file mode 100644 index 00000000..3521137b --- /dev/null +++ b/internal/bench/iter_bench_test.go @@ -0,0 +1,92 @@ +package bench_test + +import ( + "testing" + + "github.com/elgopher/pi" +) + +func BenchmarkCreate(b *testing.B) { + b.ReportAllocs() + + var f pi.Iterator + for i := 0; i < b.N; i++ { + f = stopImmediately() // 0 allocs, 1ns + } + _ = f +} + +func BenchmarkCreate2(b *testing.B) { + b.ReportAllocs() + + var f pi.Iterator + for i := 0; i < b.N; i++ { + f = stopAfterOneYield() // 2 allocs, 38ns (still 100x faster than coro.Routine) + } + _ = f +} + +func BenchmarkCreate3(b *testing.B) { + b.ReportAllocs() + + var f pi.Iterator + for i := 0; i < b.N; i++ { + obj := &coroutineObject{} + f = obj.Resume // 2 allocs, 38ns + } + _ = f +} + +func BenchmarkResume(b *testing.B) { + b.ReportAllocs() + + for i := 0; i < b.N; i++ { + r := stopAfterOneYield() // 2 allocs, 38ns + r() // 0 allocs, 2ns + } +} + +func BenchmarkIteratorsAppend(b *testing.B) { + b.ReportAllocs() + var iterators pi.Iterators + for i := 0; i < 1024; i++ { + iterators = append(iterators, stopImmediately()) + } + iterators = iterators.Next() + + b.ResetTimer() + + for i := 0; i < b.N; i += 1024 { + for j := 0; j < 1024; j++ { + iterators = append(iterators, stopAfterOneYield()) + } + iterators = iterators.Next() + } + + _ = iterators +} + +//go:noinline +func stopImmediately() pi.Iterator { + return func() bool { + return false + } +} + +//go:noinline +func stopAfterOneYield() pi.Iterator { + i := 0 + return func() bool { + i++ + return i <= 1 + } +} + +type coroutineObject struct { + i int +} + +func (c *coroutineObject) Resume() bool { + c.i++ + return c.i <= 1 +} diff --git a/iterator.go b/iterator.go new file mode 100644 index 00000000..2087f322 --- /dev/null +++ b/iterator.go @@ -0,0 +1,58 @@ +// (c) 2023 Jacek Olszak +// This code is licensed under MIT license (see LICENSE for details) + +package pi + +// Iterator is a function that performs the next iteration step. Function +// returns true if there are still steps to be performed. Function +// returns false if the iterator has finished. +type Iterator func() bool + +// Iterators is a slice of iterators that can be run in bulk. +type Iterators []Iterator + +// Next runs the next step of all iterators. Deletes those that have ended. +// The new iterator slice is returned from the function. +func (r Iterators) Next() Iterators { + return sliceDelete(r, func(i Iterator) bool { + return !i() + }) +} + +// Following function is taken from slices packages, from Go 1.21 stdlib: slices.DeleteFunc. Please use the original version when Go in Pi is 1.21. +func sliceDelete(iterators Iterators, del func(i Iterator) bool) Iterators { + // Don't start copying elements until we find one to delete. + for i, v := range iterators { + if del(v) { + j := i + for i++; i < len(iterators); i++ { + v = iterators[i] + if !del(v) { + iterators[j] = v + j++ + } + } + return iterators[:j] + } + } + return iterators +} + +// Sequence is hard to debug. I cant put a breakpoint! +// TODO REMOVE IT. This thing is too much of an abstraction! +func Sequence(iterators ...Iterator) Iterator { + iteratorIdx := 0 + + return func() bool { + if len(iterators) == iteratorIdx { + return false + } + + hasNext := iterators[iteratorIdx]() + if !hasNext { + iteratorIdx++ + } + + return len(iterators) != iteratorIdx + } +} diff --git a/iterator_test.go b/iterator_test.go new file mode 100644 index 00000000..cea95d45 --- /dev/null +++ b/iterator_test.go @@ -0,0 +1,59 @@ +// (c) 2023 Jacek Olszak +// This code is licensed under MIT license (see LICENSE for details) + +package pi_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/elgopher/pi" +) + +func TestIterators_Next(t *testing.T) { + finishImmediately := func() bool { + return false + } + + t.Run("should run iterator", func(t *testing.T) { + executionCount := 0 + i := func() bool { + executionCount++ + return true + } + var iterators pi.Iterators + iterators = append(iterators, i) + // when + _ = iterators.Next() + // then + assert.Equal(t, 1, executionCount) + }) + + t.Run("should remove finished iterator", func(t *testing.T) { + var iterators pi.Iterators + iterators = append(iterators, finishImmediately) + // when + iterators = iterators.Next() + // then + assert.Empty(t, iterators) + }) + + t.Run("should remove first and last iterator", func(t *testing.T) { + neverFinish := func() bool { + return true + } + + var iterators pi.Iterators + iterators = append(iterators, finishImmediately) + iterators = append(iterators, neverFinish) + iterators = append(iterators, finishImmediately) + // when + iterators = iterators.Next() + // then + require.Len(t, iterators, 1) + hasNext := iterators[0]() + assert.True(t, hasNext, "remaining iterator should be neverFinish") + }) +}