使用带有取消的上下文,Go 例程不会终止

Posted

技术标签:

【中文标题】使用带有取消的上下文,Go 例程不会终止【英文标题】:Using context with cancel, Go routine doesn't terminate 【发布时间】:2021-10-31 23:33:57 【问题描述】:

我是 Go 和 Go 并发的新手。一旦找到具有给定 ID 的成员,我正在尝试使用 Go 上下文取消一组 Go 例程。

一个组存储一个客户列表,每个客户都有一个成员列表。我想并行搜索所有客户及其所有成员,以找到具有给定 ID 的成员。一旦找到此成员,我想取消所有其他 Go 例程并返回发现的成员。

我尝试了以下实现,使用 context.WithCancel 和 WaitGroup。

但是这不起作用,并且无限期挂起,永远不会超过 waitGroup.Wait() 行,但我不确定为什么。

func (group *Group) MemberWithID(ID string) (*models.Member, error) 
    found := make(chan *models.Member)
    ctx := context.Background()
    ctx, cancel := context.WithCancel(ctx)
    defer cancel()
    var waitGroup sync.WaitGroup

    for _, client := range group.Clients 
        waitGroup.Add(1)

        go func(clientToQuery Client) 
            defer waitGroup.Done()

            select 
            case <-ctx.Done():
                return
            default:
            

            member, _ := client.ClientMemberWithID(ID)
            if member != nil 
                found <- member
                cancel()
                return
            

         (client)

    

    waitGroup.Wait()

    if len(found) > 0 
        return <-found, nil
    

    return nil, fmt.Errorf("no member found with given id")

【问题讨论】:

【参考方案1】:

found 是一个无缓冲通道,因此在它上面发送会阻塞,直到有人准备好接收它为止。

您的main() 函数将是从它接收的函数,但仅在waitGroup.Wait() 返回之后。但这将阻塞,直到所有启动的 goroutine 调用 waitGroup.Done()。但这在他们返回之前不会发生,直到他们可以发送 found 才会发生。这是一个僵局。

如果您将found 更改为缓冲,即使main() 尚未准备好从其接收(与缓冲区一样多的值),也将允许在其上发送值。

但是您应该在waitGroup.Wait() 返回之前收到来自found 的信息。

另一种解决方案是为found 使用1 的缓冲区,并在found 上使用非阻塞发送。这样,第一个(最快的)goroutine 将能够发送结果,而其余的(假设我们使用的是非阻塞发送)将简单地跳过发送。

还要注意,调用cancel()的应该是main(),而不是每个单独启动的goroutine。

【讨论】:

哦,我明白了,我怎么知道在找到成员时从 main 调用取消? 在这种情况下,我什至不会使用Context:你并没有真正使用它。 client.ClientMemberWithID 不使用它,所以在这里它相当没用,只会使事情复杂化。如果您在执行数据库查询时传递/使用它会很有用。【参考方案2】:

对于这种用例,我认为sync.Once 可能比频道更适合。当你找到第一个非 nil 成员时,你想做两件不同的事情:

    记录您找到的成员。 取消剩余的 goroutine。

缓冲通道可以轻松完成 (1),但会使 (2) 变得更复杂一些。但是sync.Once 非常适合在第一次发生有趣的事情时做两件不同的事情!


我还建议汇总重要错误,以便在数据库连接失败或发生其他重要错误时报告比no member found 更有用的内容。你也可以使用sync.Once


综合起来,我希望看到这样的东西 (https://play.golang.org/p/QZXUUnbxOv5):

func (group *Group) MemberWithID(ctx context.Context, id string) (*Member, error) 
    ctx, cancel := context.WithCancel(ctx)
    defer cancel()

    var (
        wg sync.WaitGroup

        member    *Member
        foundOnce sync.Once

        firstNontrivialErr error
        errOnce            sync.Once
    )

    for _, client := range group.Clients 
        wg.Add(1)
        client := client // https://golang.org/doc/faq#closures_and_goroutines
        go func() 
            defer wg.Done()

            m, err := client.ClientMemberWithID(ctx, id)
            if m != nil 
                foundOnce.Do(func() 
                    member = m
                    cancel()
                )
             else if nf := (*MemberNotFoundError)(nil); !errors.As(err, &nf) 
                errOnce.Do(func() 
                    firstNontrivialErr = err
                )
            
        ()
    
    wg.Wait()

    if member == nil 
        if firstNontrivialErr != nil 
            return nil, firstNontrivialErr
        
        return nil, &MemberNotFoundErrorID: id
    
    return member, nil

【讨论】:

使用缓冲通道而不是 sync.Once (play.golang.org/p/blsYV_eMgN_X) 的等效方法也可以,但对我来说似乎有点冗长。 非常感谢这个解决方案,sync.Once 似乎真的很有用,我以前没听说过

以上是关于使用带有取消的上下文,Go 例程不会终止的主要内容,如果未能解决你的问题,请参考以下文章

没有取消传播的上下文

上下文因超时而取消但计算未中止?

网络自动播放策略更改恢复上下文不会取消静音

Go 语言入门很简单:什么是上下文

ZeroMQ 套接字 Recv() 抛出“上下文已终止”异常 - 为啥以及如何恢复?

Go进阶:上下文context