Skip to content

Add coroutines #97

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions coro/coro.go
Original file line number Diff line number Diff line change
@@ -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
})
}
78 changes: 78 additions & 0 deletions coro/coro_bench_test.go
Original file line number Diff line number Diff line change
@@ -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{}{})
}
2 changes: 2 additions & 0 deletions devtools/internal/lib/github_com-elgopher-pi.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

91 changes: 91 additions & 0 deletions examples/coroutine/coroutine.go
Original file line number Diff line number Diff line change
@@ -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()
}
}
184 changes: 184 additions & 0 deletions examples/iterator/main.go
Original file line number Diff line number Diff line change
@@ -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
}
}
81 changes: 81 additions & 0 deletions examples/iterator2/main.go
Original file line number Diff line number Diff line change
@@ -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
}
}
92 changes: 92 additions & 0 deletions internal/bench/iter_bench_test.go
Original file line number Diff line number Diff line change
@@ -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
}
58 changes: 58 additions & 0 deletions iterator.go
Original file line number Diff line number Diff line change
@@ -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
}
}
59 changes: 59 additions & 0 deletions iterator_test.go
Original file line number Diff line number Diff line change
@@ -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")
})
}