Skip to content

Commit

Permalink
Add coroutines and iterators
Browse files Browse the repository at this point in the history
  • Loading branch information
elgopher committed Aug 26, 2023
1 parent c05b59d commit ae8a598
Show file tree
Hide file tree
Showing 9 changed files with 753 additions and 0 deletions.
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()
}
}
Loading

0 comments on commit ae8a598

Please sign in to comment.