3

I have several goroutines in my golang program, one of which has an infinite loop that runs a ticker for 20 milliseconds. Inside this goroutine there are no heavy operations and shared data, however, the ticker does not work accurately - the response spread is from 15 to 40 milliseconds, and I need to be as accurate as possible to 20. I assume this is due to competition with other coroutines. The ticker procedure is:

func (v *vocoderFile) sendAudioLoop() {
    targetInterval := 20 * time.Millisecond
    for {

        startTime := time.Now()

        // Some procedure.....

        elapsed := time.Since(startTime)
        if elapsed < targetInterval {
            time.Sleep(targetInterval - elapsed)
        }
    }
}

Also I have tested other variant - similar result:

func (v *vocoderFile) sendAudioLoop() {
    ticker := time.NewTicker(20 * time.Millisecond)
    for {
        <-ticker.C

        // Some procedure.....
    }
}

How can I make sendAudioLoop working more monotonic?

eglease
  • 2,445
  • 11
  • 18
  • 28
Roman Kazmin
  • 931
  • 6
  • 18

1 Answers1

0

I think you need to establish for yourself the acceptable lower and upper bounds for your program's timing needs, and then over-sample and test for the correct interval:

const (
    lower = 19_900 * time.Microsecond
    upper = 20_100 * time.Microsecond
)

// pulse does something every 20ms +/-.1ms
func pulse() {
    var (
        last    = time.Now()
        elapsed time.Duration
    )

    ticker := time.NewTicker(500 * time.Microsecond)
    for now := range ticker.C {
        elapsed = now.Sub(last)
        if elapsed > lower {
            // Some procedure...
            last = now
        }
    }
}

Even though the ticker's period of 500µs is coarser than the margin of +/- 100µs, I haven't seen interval errors in a test run of 1000 pulses (20s).

I don't know much about Go's scheduler, and the internals of timing. I read this issue on the performance of Sleep and Ticker, and that gave me some ideas about performance and patterns. This SO, Sleep() or Timer(), Go idiomatic?, gave me the idea that I should probably just use Timer.

I've included a complete program to generate about 1000 test pulses and some stats like:

...5s elapsed
...5s elapsed
...5s elapsed
...5s elapsed
1000 pulses: 19.941ms...20.061916ms

Some times (like launching the Activity Monitor in macOS, mid-run), my system was stressed enough to be late by a factor of 3:

...
9 too-long pulses:
  20.173042ms
  20.294166ms
  20.314125ms
  20.333667ms
  20.611917ms
  21.762083ms
  22.614583ms
  22.809292ms
  58.68725ms

Usually, though, even with the CPU pegged at 100%, the Ticker was within my 100µs bounds.

func main() {
    done := time.After(20*time.Second + 1*time.Millisecond) // run time limit
    tickTock := time.NewTicker(5 * time.Second)             // incremental print msg

    go randomWork() // pegs CPU at about 100%

    chP := make(chan time.Duration)
    go pulse(chP)

    pulses := make([]time.Duration, 0)
loop:
    for {
        select {
        case d := <-chP:
            pulses = append(pulses, d)
        case <-tickTock.C:
            fmt.Println("...5s elapsed")
        case <-done:
            break loop
        }
    }

    printStats(pulses)
}

const (
    lower = 19_900 * time.Microsecond
    upper = 20_100 * time.Microsecond
)

// pulse does something every 20ms +/-.1ms
func pulse(ch chan<- time.Duration) {
    var (
        last    = time.Now()
        elapsed time.Duration
    )

    ticker := time.NewTicker(500 * time.Microsecond)
    for now := range ticker.C {
        elapsed = now.Sub(last)
        if elapsed > lower {
            // Some procedure...
            ch <- elapsed
            last = now
        }
    }
}

func randomWork() {
    for {
        x := 0
        for i := 0; i < rand.Intn(1e7); i++ {
            x = i
        }
        x = x // silence "unused x" error
    }
}

func printStats(pulses []time.Duration) {
    sort.Slice(pulses, func(i, j int) bool { return pulses[i] < pulses[j] })

    tooShort := make([]time.Duration, 0)
    tooLong := make([]time.Duration, 0)
    for _, d := range pulses {
        if d < lower {
            tooShort = append(tooShort, d)
        }
        if d > upper {
            tooLong = append(tooLong, d)
        }
    }

    fmt.Printf("%d pulses: %v...%v\n", len(pulses), pulses[0], pulses[len(pulses)-1])

    if len(tooShort) > 0 {
        fmt.Printf("%d too-short pulses:\n", len(tooShort))
        for _, d := range tooShort {
            fmt.Printf("  %v\n", d)
        }
    }

    if len(tooLong) > 0 {
        fmt.Printf("%d too-long pulses:\n", len(tooLong))
        for _, d := range tooLong {
            fmt.Printf("  %v\n", d)
        }
    }
}
Zach Young
  • 10,137
  • 4
  • 32
  • 53