Go: Performance Hit of Waiting for Multiple Channels

huangapple go评论79阅读模式
英文:

Go: Performance Hit of Waiting for Multiple Channels

问题

我今天发现了一件让我有点困惑的事情,我想向社区请教一下,看看我是否遗漏了什么,或者只是设计得不好。

使用情况:我有一个输入通道,我希望一个Go协程等待该通道上的值。如果上下文被取消,就退出。可选地,如果等待一定时间而没有接收到输入,则运行回调函数。

我从这样的代码开始:

func myRoutine(ctx context.Context, c <-chan int, callbackInterval *time.Duration, callback func() error) {
	var timeoutChan <-chan time.Time
	var timer *time.Timer
	if callbackInterval != nil {
		// 如果设置了回调间隔,创建一个定时器
		timer = time.NewTimer(*callbackInterval)
		timeoutChan = timer.C
	} else {
		// 如果没有设置回调间隔,创建一个永远不会提供值的通道
		timeoutChan = make(<-chan time.Time, 0)
	}

	for {
		select {

		// 处理上下文取消
		case <-ctx.Done():
			return

		// 处理超时
		case <-timeoutChan:
			callback()

		// 处理通道中的值
		case v, ok := <-c:
			if !ok {
				// 通道已关闭,退出
				return
			}

			// 处理v
			fmt.Println(v)
		}

		// 重置超时定时器(如果有的话)
		if timer != nil {
			if !timer.Stop() {
				// 参见timer.Stop()的文档,了解为什么需要这样做
				<-timer.C
			}
			// 重置定时器
			timer.Reset(*callbackInterval)
		    timeoutChan = timer.C
		}
	}
}

这种设计看起来很好,因为我认为在select中没有办法有一个条件性的case(我认为使用reflect可能是可能的,但通常非常慢),所以我没有使用两个不同的select(一个带有定时器,一个不带),也没有使用if来选择它们之间的情况,而是在一个select中使用了一个永远不会提供值的定时器通道。保持DRY(Don't Repeat Yourself)原则。

但是后来我开始担心这样做会对性能产生影响。当我们不使用定时器时,在select中是否会因为有这个额外的通道(代替定时器通道)而减慢应用程序的速度?

因此,我决定进行一些测试来进行比较。

package main

import (
	"context"
	"fmt"
	"reflect"
	"time"
)

func prepareChan() chan int {
	var count int = 10000000

	c := make(chan int, count)

	for i := 0; i < count; i++ {
		c <- i
	}
	close(c)
	return c
}

func oneChan() int64 {
	c := prepareChan()

	foundVal := true
	start := time.Now()
	for {
		select {
		case _, foundVal = <-c:
			break
		}
		if !foundVal {
			break
		}
	}
	ms := time.Since(start).Milliseconds()
	fmt.Printf("1 Chan - Standard: %dms\n", ms)
	return ms
}

func twoChan() int64 {
	c := prepareChan()

	neverchan1 := make(chan struct{}, 0)

	foundVal := true
	start := time.Now()
	for {
		select {
		case _, foundVal = <-c:
			break
		case <-neverchan1:
			break
		}
		if !foundVal {
			break
		}
	}
	ms := time.Since(start).Milliseconds()
	fmt.Printf("2 Chan - Standard: %dms\n", ms)
	return ms
}

func threeChan() int64 {
	c := prepareChan()

	neverchan1 := make(chan struct{}, 0)
	neverchan2 := make(chan struct{}, 0)

	foundVal := true
	start := time.Now()
	for {
		select {
		case _, foundVal = <-c:
			break
		case <-neverchan1:
			break
		case <-neverchan2:
			break
		}
		if !foundVal {
			break
		}
	}
	ms := time.Since(start).Milliseconds()
	fmt.Printf("3 Chan - Standard: %dms\n", ms)
	return ms
}

func fourChan() int64 {
	c := prepareChan()

	neverchan1 := make(chan struct{}, 0)
	neverchan2 := make(chan struct{}, 0)
	neverchan3 := make(chan struct{}, 0)

	foundVal := true
	start := time.Now()
	for {
		select {
		case _, foundVal = <-c:
			break
		case <-neverchan1:
			break
		case <-neverchan2:
			break
		case <-neverchan3:
			break
		}
		if !foundVal {
			break
		}
	}
	ms := time.Since(start).Milliseconds()
	fmt.Printf("4 Chan - Standard: %dms\n", ms)
	return ms
}

func oneChanReflect() int64 {
	c := reflect.ValueOf(prepareChan())

	branches := []reflect.SelectCase{
		{Dir: reflect.SelectRecv, Chan: c, Send: reflect.Value{}},
	}

	start := time.Now()
	for {
		_, _, recvOK := reflect.Select(branches)
		if !recvOK {
			break
		}
	}
	ms := time.Since(start).Milliseconds()
	fmt.Printf("1 Chan - Reflect: %dms\n", ms)
	return ms
}

func twoChanReflect() int64 {
	c := reflect.ValueOf(prepareChan())
	neverchan1 := reflect.ValueOf(make(chan struct{}, 0))

	branches := []reflect.SelectCase{
		{Dir: reflect.SelectRecv, Chan: c, Send: reflect.Value{}},
		{Dir: reflect.SelectRecv, Chan: neverchan1, Send: reflect.Value{}},
	}

	start := time.Now()
	for {
		_, _, recvOK := reflect.Select(branches)
		if !recvOK {
			break
		}
	}
	ms := time.Since(start).Milliseconds()
	fmt.Printf("2 Chan - Reflect: %dms\n", ms)
	return ms
}

func threeChanReflect() int64 {
	c := reflect.ValueOf(prepareChan())
	neverchan1 := reflect.ValueOf(make(chan struct{}, 0))
	neverchan2 := reflect.ValueOf(make(chan struct{}, 0))

	branches := []reflect.SelectCase{
		{Dir: reflect.SelectRecv, Chan: c, Send: reflect.Value{}},
		{Dir: reflect.SelectRecv, Chan: neverchan1, Send: reflect.Value{}},
		{Dir: reflect.SelectRecv, Chan: neverchan2, Send: reflect.Value{}},
	}

	start := time.Now()
	for {
		_, _, recvOK := reflect.Select(branches)
		if !recvOK {
			break
		}
	}
	ms := time.Since(start).Milliseconds()
	fmt.Printf("3 Chan - Reflect: %dms\n", ms)
	return ms
}

func fourChanReflect() int64 {
	c := reflect.ValueOf(prepareChan())
	neverchan1 := reflect.ValueOf(make(chan struct{}, 0))
	neverchan2 := reflect.ValueOf(make(chan struct{}, 0))
	neverchan3 := reflect.ValueOf(make(chan struct{}, 0))

	branches := []reflect.SelectCase{
		{Dir: reflect.SelectRecv, Chan: c, Send: reflect.Value{}},
		{Dir: reflect.SelectRecv, Chan: neverchan1, Send: reflect.Value{}},
		{Dir: reflect.SelectRecv, Chan: neverchan2, Send: reflect.Value{}},
		{Dir: reflect.SelectRecv, Chan: neverchan3, Send: reflect.Value{}},
	}

	start := time.Now()
	for {
		_, _, recvOK := reflect.Select(branches)
		if !recvOK {
			break
		}
	}
	ms := time.Since(start).Milliseconds()
	fmt.Printf("4 Chan - Reflect: %dms\n", ms)
	return ms
}

func main() {
	oneChan()
	oneChanReflect()
	twoChan()
	twoChanReflect()
	threeChan()
	threeChanReflect()
	fourChan()
	fourChanReflect()
}

测试结果如下:

1 Chan - Standard: 169ms
1 Chan - Reflect: 1017ms
2 Chan - Standard: 460ms
2 Chan - Reflect: 1593ms
3 Chan - Standard: 682ms
3 Chan - Reflect: 2041ms
4 Chan - Standard: 950ms
4 Chan - Reflect: 2423ms

它与通道的数量成线性关系。事后看来,我想这是有道理的,因为它必须对每个通道进行快速轮询以查看是否有值。如预期的那样,使用reflect要慢得多。

无论如何,我的问题是:

  1. 这个结果是否让其他人感到惊讶?我本来期望它会使用基于中断的设计,无论select中有多少个通道,它都能保持相同的性能,因为它不需要轮询每个通道。

  2. 鉴于我尝试解决的原始问题(在select中有一个“可选”情况),最好/首选的设计是什么?答案是否只是使用两个不同的select,一个带有定时器,一个不带?当我有2或3个条件/可选定时器用于不同的情况时,这会变得非常混乱。

编辑:
@Brits建议使用nil通道来表示“永远不返回值”,而不是使用初始化的通道,即使用var neverchan1 chan struct{}代替neverchan1 := make(chan struct{}, 0)。以下是新的性能结果:

1 Chan - Standard: 221ms
1 Chan - Reflect: 1639ms
2 Chan - Standard: 362ms
2 Chan - Reflect: 2544ms
3 Chan - Standard: 376ms
3 Chan - Reflect: 3359ms
4 Chan - Standard: 394ms
4 Chan - Reflect: 4123ms

仍然有影响,尤其是从一个通道到两个通道,但是在第二个通道之后,性能影响要比使用初始化的通道小得多。

不过,我仍然想知道这是否是最佳解决方案...

英文:

I discovered something today that threw me for a bit of a loop, and I wanted to run it by the community to see if I'm missing something, or perhaps just designing things poorly.

Use case: I have an input channel, and I want a Go routine to wait for values on that channel. If the context is canceled, exit out. Optionally, also run a callback if it's been waiting a certain amount of time for an input without receiving one.

I started with code like this:

func myRoutine(ctx context.Context, c &lt;-chan int, callbackInterval *time.Duration, callback func() error) {
	var timeoutChan &lt;-chan time.Time
	var timer *time.Timer
	if callbackInterval != nil {
		// If we have a callback interval set, create a timer for it
		timer = time.NewTimer(*callbackInterval)
		timeoutChan = timer.C
	} else {
		// If we don&#39;t have a callback interval set, create
		// a channel that will never provide a value.
		timeoutChan = make(&lt;-chan time.Time, 0)
	}

	for {
		select {

		// Handle context cancellation
		case &lt;-ctx.Done():
			return

		// Handle timeouts
		case &lt;-timeoutChan:
			callback()

		// Handle a value in the channel
		case v, ok := &lt;-c:
			if !ok {
				// Channel is closed, exit out
				return
			}

			// Do something with v
			fmt.Println(v)
		}

		// Reset the timeout timer, if there is one
		if timer != nil {
			if !timer.Stop() {
				// See documentation for timer.Stop() for why this is needed
				&lt;-timer.C
			}
			// Reset the timer
			timer.Reset(*callbackInterval)
		    timeoutChan = timer.C
		}
	}
}

This design seemed nice because there's no way (as far as I can tell) to have a conditional case in a select (I think it's possible with reflect, but that's usually super slow), so instead of having two different selects (one with the timer when a timer is needed, one without) and an if to select between them, I just did one select where the timer channel never provides a value if a timer isn't desired. Keep it DRY.

But then I started wondering about the performance impacts of this. When we're not using a timer, would it slow down the application to have this extra channel in the select that never gets a value (in place of the timer channel)?

So, I decided to do some testing to compare.

package main

import (
	&quot;context&quot;
	&quot;fmt&quot;
	&quot;reflect&quot;
	&quot;time&quot;
)

func prepareChan() chan int {
	var count int = 10000000

	c := make(chan int, count)

	for i := 0; i &lt; count; i++ {
		c &lt;- i
	}
	close(c)
	return c
}

func oneChan() int64 {
	c := prepareChan()

	foundVal := true
	start := time.Now()
	for {
		select {
		case _, foundVal = &lt;-c:
			break
		}
		if !foundVal {
			break
		}
	}
	ms := time.Since(start).Milliseconds()
	fmt.Printf(&quot;1 Chan - Standard: %dms\n&quot;, ms)
	return ms
}

func twoChan() int64 {
	c := prepareChan()

	neverchan1 := make(chan struct{}, 0)

	foundVal := true
	start := time.Now()
	for {
		select {
		case _, foundVal = &lt;-c:
			break
		case &lt;-neverchan1:
			break
		}
		if !foundVal {
			break
		}
	}
	ms := time.Since(start).Milliseconds()
	fmt.Printf(&quot;2 Chan - Standard: %dms\n&quot;, ms)
	return ms
}

func threeChan() int64 {
	c := prepareChan()

	neverchan1 := make(chan struct{}, 0)
	neverchan2 := make(chan struct{}, 0)

	foundVal := true
	start := time.Now()
	for {
		select {
		case _, foundVal = &lt;-c:
			break
		case &lt;-neverchan1:
			break
		case &lt;-neverchan2:
			break
		}
		if !foundVal {
			break
		}
	}
	ms := time.Since(start).Milliseconds()
	fmt.Printf(&quot;3 Chan - Standard: %dms\n&quot;, ms)
	return ms
}

func fourChan() int64 {
	c := prepareChan()

	neverchan1 := make(chan struct{}, 0)
	neverchan2 := make(chan struct{}, 0)
	neverchan3 := make(chan struct{}, 0)

	foundVal := true
	start := time.Now()
	for {
		select {
		case _, foundVal = &lt;-c:
			break
		case &lt;-neverchan1:
			break
		case &lt;-neverchan2:
			break
		case &lt;-neverchan3:
			break
		}
		if !foundVal {
			break
		}
	}
	ms := time.Since(start).Milliseconds()
	fmt.Printf(&quot;4 Chan - Standard: %dms\n&quot;, ms)
	return ms
}

func oneChanReflect() int64 {
	c := reflect.ValueOf(prepareChan())

	branches := []reflect.SelectCase{
		{Dir: reflect.SelectRecv, Chan: c, Send: reflect.Value{}},
	}

	start := time.Now()
	for {
		_, _, recvOK := reflect.Select(branches)
		if !recvOK {
			break
		}
	}
	ms := time.Since(start).Milliseconds()
	fmt.Printf(&quot;1 Chan - Reflect: %dms\n&quot;, ms)
	return ms
}

func twoChanReflect() int64 {
	c := reflect.ValueOf(prepareChan())
	neverchan1 := reflect.ValueOf(make(chan struct{}, 0))

	branches := []reflect.SelectCase{
		{Dir: reflect.SelectRecv, Chan: c, Send: reflect.Value{}},
		{Dir: reflect.SelectRecv, Chan: neverchan1, Send: reflect.Value{}},
	}

	start := time.Now()
	for {
		_, _, recvOK := reflect.Select(branches)
		if !recvOK {
			break
		}
	}
	ms := time.Since(start).Milliseconds()
	fmt.Printf(&quot;2 Chan - Reflect: %dms\n&quot;, ms)
	return ms
}

func threeChanReflect() int64 {
	c := reflect.ValueOf(prepareChan())
	neverchan1 := reflect.ValueOf(make(chan struct{}, 0))
	neverchan2 := reflect.ValueOf(make(chan struct{}, 0))

	branches := []reflect.SelectCase{
		{Dir: reflect.SelectRecv, Chan: c, Send: reflect.Value{}},
		{Dir: reflect.SelectRecv, Chan: neverchan1, Send: reflect.Value{}},
		{Dir: reflect.SelectRecv, Chan: neverchan2, Send: reflect.Value{}},
	}

	start := time.Now()
	for {
		_, _, recvOK := reflect.Select(branches)
		if !recvOK {
			break
		}
	}
	ms := time.Since(start).Milliseconds()
	fmt.Printf(&quot;3 Chan - Reflect: %dms\n&quot;, ms)
	return ms
}

func fourChanReflect() int64 {
	c := reflect.ValueOf(prepareChan())
	neverchan1 := reflect.ValueOf(make(chan struct{}, 0))
	neverchan2 := reflect.ValueOf(make(chan struct{}, 0))
	neverchan3 := reflect.ValueOf(make(chan struct{}, 0))

	branches := []reflect.SelectCase{
		{Dir: reflect.SelectRecv, Chan: c, Send: reflect.Value{}},
		{Dir: reflect.SelectRecv, Chan: neverchan1, Send: reflect.Value{}},
		{Dir: reflect.SelectRecv, Chan: neverchan2, Send: reflect.Value{}},
		{Dir: reflect.SelectRecv, Chan: neverchan3, Send: reflect.Value{}},
	}

	start := time.Now()
	for {
		_, _, recvOK := reflect.Select(branches)
		if !recvOK {
			break
		}
	}
	ms := time.Since(start).Milliseconds()
	fmt.Printf(&quot;4 Chan - Reflect: %dms\n&quot;, ms)
	return ms
}

func main() {
	oneChan()
	oneChanReflect()
	twoChan()
	twoChanReflect()
	threeChan()
	threeChanReflect()
	fourChan()
	fourChanReflect()
}

And the results:

1 Chan - Standard: 169ms
1 Chan - Reflect: 1017ms
2 Chan - Standard: 460ms
2 Chan - Reflect: 1593ms
3 Chan - Standard: 682ms
3 Chan - Reflect: 2041ms
4 Chan - Standard: 950ms
4 Chan - Reflect: 2423ms

It scales linearly with the number of channels. In hindsight, I suppose this makes sense, as it must be doing a fast loop to poll each channel to see if it has a value? As expected, using reflect is far slower.

In any case, my questions are:

  1. Does this result surprise anyone else? I would have expected it would use a interrupt-based design that would allow it to maintain the same performance regardless of the number of channels in the select, as it wouldn't need to poll each channel.

  2. Given the original problem that I was trying to solve (an "optional" case in a select), what would be the best/preferred design? Is the answer just to have two different selects, one with the timer and one without? That gets awfully messy when I have 2 or 3 conditional/optional timers for various things.

EDIT:
@Brits suggested using a nil channel for "never returning a value" instead of an initialized channel, i.e. using var neverchan1 chan struct{} instead of neverchan1 := make(chan struct{}, 0). Here are the new performance results:

1 Chan - Standard: 221ms
1 Chan - Reflect: 1639ms
2 Chan - Standard: 362ms
2 Chan - Reflect: 2544ms
3 Chan - Standard: 376ms
3 Chan - Reflect: 3359ms
4 Chan - Standard: 394ms
4 Chan - Reflect: 4123ms

There's still an effect, most noticeably from one channel in the select to two, but after the second one the performance impact is much smaller than with an initialized channel.

Still wondering if this is the best possible solution though...

答案1

得分: 1

根据评论,使用一个“从不提供值的通道”与select的替代方法是使用一个nil通道(“永远不准备好通信”)。根据以下示例,将neverchan1 := make(chan struct{}, 0)替换为var neverchan1 chan struct{}(或neverchan1 := chan struct{}(nil)):

func twoChan() int64 {
    c := prepareChan()

    var neverchan1 chan struct{} // was neverchan1 := make(chan struct{}, 0)

    foundVal := true
    start := time.Now()
    for {
        select {
        case _, foundVal = <-c:
            break
        case <-neverchan1:
            break
        }
        if !foundVal {
            break
        }
    }
    ms := time.Since(start).Milliseconds()
    fmt.Printf("2 Chan - Standard: %dms\n", ms)
    return ms
}

这样可以显著缩小差距(使用4个通道版本,差距更大 - 我的机器比你的慢一点):

4 Chan - Standard: 1281ms
4 Chan - Nil: 394ms

这是最好的解决方案吗?

不是的;但这可能涉及一些汇编代码!你可以尝试一些可能改进的方法(这里有一些非常粗略的示例),但它们的有效性将取决于一系列因素(实际情况与人为测试用例的性能通常有很大差异)。

此时,我会问:“优化这个函数对整个应用程序有什么影响?”除非节省几个纳秒会产生实质性的差异(即提高利润!),否则我建议在此处停止,直到你:

  • 确认存在需要解决的问题
  • 可以在实际场景中对代码进行性能分析(此外,总的来说,我认为“可读性胜过速度”)。
英文:

As per the comments an alternative to using select with a channel that "channel never provides a value" is to use a nil channel ("never ready for communication"). Replacing neverchan1 := make(chan struct{}, 0) with var neverchan1 chan struct{} (or neverchan1 := chan struct{}(nil)) as per the following example:

func twoChan() int64 {
c := prepareChan()
var neverchan1 chan struct{} // was neverchan1 := make(chan struct{}, 0)
foundVal := true
start := time.Now()
for {
select {
case _, foundVal = &lt;-c:
break
case &lt;-neverchan1:
break
}
if !foundVal {
break
}
}
ms := time.Since(start).Milliseconds()
fmt.Printf(&quot;2 Chan - Standard: %dms\n&quot;, ms)
return ms
}

This significantly narrows the gap (using 4 channel version as the difference is greater - my machine is a bit slower than yours):

4 Chan - Standard: 1281ms
4 Chan - Nil: 394ms

> is the best possible solution

No; but that would probably involve some assembler! There are a number things you could do that may improve on this (here are a few very rough examples); however their effectiveness is going to depend on a range of factors (real life vs contrived test case performance often differs significantly).

At this point I would be asking "what impact is optimising this function going to have on the overall application?"; unless saving a few ns will make a material difference (i.e. improve profit!) I'd suggest stopping at this point until you:

huangapple
  • 本文由 发表于 2022年7月30日 04:31:46
  • 转载请务必保留本文链接:https://go.coder-hub.com/73170736.html
匿名

发表评论

匿名网友

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen:

确定