Time Control You Should Know in Golang

Exploring Time Control in Go: A Comprehensive Guide

Edison failed 10,000 times before he made the electric light. Do not be discouraged if you fail a few times.
— Napoleon Hill

Medium Link: Time Control You Should Know in Golang | by Wesley Wei | Jun, 2024 | Programmer’s Career

Hello, here is Wesley, Today’s article is about time control in Go. Without further ado, let’s get started.💪

Introduction of the Problem

Recently, I encountered a requirement where my program needs to schedule some tasks periodically, requiring fine-grained control over time. Let’s start with the program’s component architecture diagram:

image.png

The specific requirement is that the manager needs to periodically trigger task allocation, and the worker also needs to periodically fetch tasks from the database and execute them. Additionally, it is crucial that the execution time for both manager and worker is fixed.

For example, let’s consider the manager executing tasks for 20 minutes and the worker executing tasks for 60 minutes from a time perspective:
image.png

It is clear that both manager and worker require periodic execution of tasks. In my career using Go, I have encountered many scenarios related to time control. This seems like a good opportunity to summarize and share my experiences.

So, how should we control time in Go? What are some use cases? How should I implement this specific requirement?

Time Control

In the process of summarizing, I found that time control can be roughly divided into two types of scenarios:

  1. Timeout and resource recovery scenarios (channel+context, channel + time.After/time.NewTimer)
  2. Periodic scenarios (channel + time.After/time.NewTimer, cron package)

Different scenarios require control of different aspects, let’s continue to look below.

channel + context

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
func main() {
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(time.Millisecond*800))
defer cancel()
done := make(chan bool)
go func(ctx context.Context) {
ctx, cancel := context.WithTimeout(ctx, time.Duration(time.Millisecond*100))
defer cancel()
// Call downstream interface
fmt.Println("Call downstream interface")
// if success
done <- true
}(ctx)

// do something
select {
case <-done:
fmt.Println("call successfully!!!")
// do something
return
case <-ctx.Done():
fmt.Println("timeout or do something else")
// retry or recycle resource
return
}
}

Better Go Playground

The main logic is to use context for time control. The main goroutine sets an 800 millisecond timeout, if the sub-goroutine does not complete execution, we wait at most 800 milliseconds. If the timeout is exceeded, we can consider retrying or recycling resources. If the sub-goroutine makes a successful request, we can continue the main goroutine’s business logic.

It can be seen that the focus of time control in this scenario is on request timeout and resource recovery.

PS: For more in-depth content on context, you can refer to my other article:

channel + time.After/time.NewTimer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func main() {
ch := make(chan struct{}, 1)
go func() {
fmt.Println("do something...")
time.Sleep(4 * time.Second)
ch <- struct{}{}
}()

select {
case <-ch:
fmt.Println("thing done")
case <-time.After(3 * time.Second):
fmt.Println("timeout")
}
}

Better Go Playground

Using time.After can also be used for timeout control. However, every time time.After is called, a new timer and corresponding channel are created, and there is no good resource recovery method. I do not recommend this usage.

To avoid potential memory leaks, a common practice is to use time.NewTimer instead of time.After, as shown below:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
func main() {
exitCh := make(chan struct{})
ch := make(chan struct{}, 1)
timer := time.NewTimer(3 * time.Second)

// Listen for specified signal
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)

go func() {
fmt.Println("do something...")
time.Sleep(4 * time.Second)
ch <- struct{}{}
}()

// In a goroutine, listen to os.Interrupt and SIGTERM signals
go func() {
<-sig
fmt.Println("Received an interrupt, stopping services...")
close(exitCh) // Close exitCh when an interrupt signal is received
}()

for {
select {
case <-ch:
fmt.Println("done")
if !timer.Stop() {
<-timer.C
}
return
case <-timer.C:
fmt.Println("timeout")
return
case <-exitCh:
fmt.Println("Service exiting")
if !timer.Stop() {
<-timer.C
}
// Execute your resource recovery code here ...
return
}
}
}

Better Go Playground

Compared to context, I think context is a more elegant method because it can be combined with context’s features to make finer control (such as the main goroutine canceling, and the sub-goroutine also canceling). However, the time channel method is a simpler approach, and the two have different applications in timeout control scenarios.

Of course, the time channel method can also be used for periodic work.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// Define a task, use an empty struct as a simple example here
type Task struct{}

func worker(taskCh <-chan *Task) {
for task := range taskCh {
// Execute the task
// For demonstration, assume each task takes 1 second
time.Sleep(2 * time.Second)
_ = task
fmt.Println("run task")
}
}

func main() {
taskCh := make(chan *Task, 100)

// Start 3 worker goroutines to perform tasks
for i := 0; i < 3; i++ {
go worker(taskCh)
}

// Create a timer, execute once every second
timer := time.NewTicker(500 * time.Millisecond)

go func() {
for range timer.C {
// Every time the timer signal is received, print the current length of the queue
fmt.Printf("Current task queue length is: %d\n", len(taskCh))
}
}()

// Simulate continuous task adding
for {
taskCh <- &Task{}
time.Sleep(500 * time.Millisecond) // Simulate adding a task every 500 milliseconds
}
}

Note: In actual code, you might need a better way to stop the timer and exit the program. The for loop in this example will keep running until the program is forcefully terminated.
Also, the task in this example is an empty struct. You will need to define a task structure that fits your business requirements in real-world cases.

Better Go Playground

This example can be used to observe the normal operation of your service. It will periodically check the length of the task channel, if the length is zero for a long time, then our tasks are likely to have occurred an exception.

cron package

Periodically observing the length of the task channel is a lightweight task, but what about more complex scenarios? The answer is the cron package.

Callers may register Funcs to be invoked on a given schedule. Cron will run them in their own goroutines.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
c := cron.New()
c.AddFunc("0 30 * * * *", func() { fmt.Println("Every hour on the half hour") })
c.AddFunc("@hourly", func() { fmt.Println("Every hour") })
c.AddFunc("@every 1h30m", func() { fmt.Println("Every hour thirty") })
c.Start()
...
// Funcs are invoked in their own goroutine, asynchronously.
...
// Funcs may also be added to a running Cron
c.AddFunc("@daily", func() { fmt.Println("Every day") })
...
// Inspect the cron job entries' next and previous run times.
inspect(c.Entries())
...
c.Stop() // Stop the scheduler (does not stop any jobs already running).

For more information, refer to: cron package - github.com/robfig/cron - Go Packages

Real-world application

My requirement can obviously be implemented using the cron package.
image.png

After thinking about it, I made the above implementation. The manager triggers a task allocation once when the process starts, and subsequently triggers it every hour. This way, the worker can make better use of time to execute more tasks.

Summary

Whether it’s the time pacakage time or the cron pacakage, they both rely on the power of channels. I am grateful for the design of golang context and channels, which makes developing time control-related code easier.

More

While summarizing the article, I thought of the following questions:

  1. Why is there a need for periodic triggering of tasks?
    1. Periodicity is to prevent the accumulation of self-tasks and to dynamically do more tasks within each period. Within each period, we try to do as many tasks as possible based on the maximum capacity of the downstream interface.
  2. In the Go 1.23 Release Notes, it is mentioned that “Timer’s and Ticker’s that are no longer referred to by the program become eligible for garbage collection immediately.” How is this achieved? Why wasn’t it immediately recycled before?
  3. In a select statement, when messages from <-done and <-ctx.Done() occur simultaneously, which one will be chosen? Why?
1
2
3
4
5
6
7
8
9
10
select {
case <-done:
fmt.Println("call successfully!!!")
// do something
return
case <-ctx.Done():
fmt.Println("timeout or do something else")
// retry or recycle resource
return
}

Regarding the second and third points, feel free to share your thoughts.

References

cron package - github.com/robfig/cron - Go Packages


More Series Articles about You Should Know In Golang:

https://wesley-wei.medium.com/list/you-should-know-in-golang-e9491363cd9a

And I’m Wesley, delighted to share knowledge from the world of programming. 

Don’t forget to follow me for more informative content, or feel free to share this with others who may also find it beneficial. it would be a great help to me.

See you in the next article. 👋

中文文章: https://programmerscareer.com/zh-cn/golang-time-control/
Author: Wesley Wei – Twitter Wesley Wei – Medium
Note: Originally written at https://programmerscareer.com/golang-time-control/ at 2024-06-23 18:26. If you choose to repost or use this article, please cite the original source.

Welcome to the AI Revolution: My Guide to Essential Tools and Concepts Introduce iter package in Go 1.23

Comments

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×