I recently had use for a recurring job in a simple Go web application. Due to limitations on the number of processes that can access a database file in Bolt I couldn't use cron itself. Instead of grabbing the first and biggest dependency I could find, I made use of the built-in time.Ticker to create a poor man's in-process cronjob.
At it's most basic, you can get a recurring job running with just a goroutine and a ticker:
func Repeat(interval time.Duration) {
ticker := time.NewTicker(interval)
go func() {
for {
t := <-ticker.C
fmt.Println("tick", t)
}
}()
}
This function first creates a ticker that will send a message on
the channel ticker.C
at a regular interval given as
a time.Duration
. It then starts a goroutine that loops
forever, waiting for messages on the ticker channel. Whenever a message (a
time.Time
denoting when the tick occured) is received, it just
prints the timestamp and the loop starts over again.
To make this a bit more useful, we can modify the function to accept another function that we will call whenever a tick occurs:
func Repeat(f func(), interval time.Duration) {
ticker := time.NewTicker(interval)
go func() {
for {
<-ticker.C
f()
}
}()
}
We can use this to e.g. update a counter every second:
func main() {
c := 0
go Repeat(func() {
c++
println(c)
}, 1*time.Second)
time.Sleep(5*time.Second)
}
If all we need is to do *something* at a set interval and don't care about what happens when the application is asked to stop, we don't have to take this any further. This will run and do it's thing until the process terminates. But let's say we want to do some cleanup before the application shuts down. Maybe we want to signal some other service that we're dropping the connection, or maybe we want to clean up some temporary files. We need some way to signal our loop that it's time to stop:
func Repeat(f func(), i time.Duration) chan bool {
t := time.NewTicker(i)
done := make(chan bool, 1)
go func() {
for {
select {
case <-t.C:
f()
case <-done:
t.Stop()
return
}
}
}()
return done
}
Here we've added another channel, done
, that we can use to pass
messages to our goroutine. We're also making use of select
which let's us wait on multiple channels at the same time. Remember that for
every tick we receive a message on t.C
. When that happens, we
execute f
just like before. However, if we receive a message
on the new done
channel, we'll instead stop the ticker and
return from the loop. We can extend this to also call a finalizer function
before returning, allowing us to perform any necessary cleanup.
Now, putting this all together we can create a little Worker
that can be reused whenever we need to run something on a set schedule. It's
in no way as powerful as cron, and the scheduling options is limited to the
ticker interval, but it can get the job done in a lot of cases. Here, I've
created a struct to hold the function to call on every tick, the cleanup
function to call at the very end, the interval at which it's supposed to
run, as well as the ticker itself and the channel to signal that we're done.
I've also separated out the run loop into it's own method, and added
Start()
and Stop()
methods to manage the lifecycle
of the worker. We also add a nil guard before calling the finalizer in order
to allow us to simply not pass a finalizer if it's not needed. I've deliberatly
left out the nil guard before calling w.Act()
, becuase not
passing that to the worker would make it completely pointless.
package main
import "time"
type Worker struct {
Act func()
Finalize func()
Interval time.Duration
done chan bool
ticker *time.Ticker
}
func (w *Worker) Start() {
w.ticker = time.NewTicker(w.Interval)
w.done = make(chan bool, 1)
go w.run()
}
func (w *Worker) run() {
for {
select {
case <-w.ticker.C:
w.Act()
case <-w.done:
return
}
}
}
func (w *Worker) Stop() {
w.ticker.Stop()
w.done <- true
if w.Finalize != nil {
w.Finalize()
}
}
And finally, a toy example utilizing the Worker
to increment
a counter, and in the end reset it to 0 again:
package main
import "time"
func main() {
c := 0
w := &Worker{
Act: func() { c++ },
Finalize: func() { c = 0 },
Interval: 99 * time.Millisecond,
}
w.Start()
println(c) // 0
time.Sleep(1 * time.Second)
println(c) // 10
time.Sleep(1 * time.Second)
println(c) // 20
w.Stop()
println(c) // 0
}
From here, it's possible to add functionality for e.g. restarting a stopped
worker. We can also connect the worker's lifecycle to some API endpoint, and
by utilizing the Reset()
function of the ticker we can even change
the interval the worker is running on without stopping our webserver.
So there it is; a pretty straight-forward (and useful!) job runner in 30-something lines of Go. It's not cron, and I'm not even sure it deserves to be called a poor man's cronjob, but it does the trick and I've currently got some variation of this running in several different services at the time of writing. If you want the full working toy example you can grab it here.