Golang中Goroutine生成的最佳实践

Golang中Goroutine生成的最佳实践 我想了解创建 goroutine 的最佳实践,是在调用者函数中创建,还是在被调用者函数中创建?

在调用者函数中创建可以明确这是一个后台任务/goroutine。但在某些场景下,我们需要在被调用者函数中创建,以便更好地控制 goroutine(例如使用 Channel/WaitGroup)。

我想知道大家是否有遵循某种指导原则来做这个决定。

示例

type DataCollector struct {
  ...
}

func (d *DataCollector) Start() {
// 或者也可以在这里创建 goroutine
// go func() {
//   ...
// }
  ...
}

func main() {
  dataCollector := DataCollector{}
  go dataCollector.Start()
  ...
}

更多关于Golang中Goroutine生成的最佳实践的实战教程也可以访问 https://www.itying.com/category-94-b0.html

4 回复

另一种解决这个问题的方法是同时考虑可测试性和单一职责原则。Robert C. Martin(又称Bob大叔)建议将与并发相关的代码保持分离/隔离。以下方式更符合该建议:

func main() {
  dataCollector := DataCollector{}
  go dataCollector.Start()
  ...
}

我们在此获得的好处是,可以独立于任何同步原语来测试 Start()。也许测试并发代码非常具有挑战性。如果将并发排除在业务/应用层之外,您的业务/应用代码及测试将变得更易读、更易测试且更易于维护。

希望这有所帮助 🙂 干杯

更多关于Golang中Goroutine生成的最佳实践的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html


另一个经验法则是,在启动一个 goroutine 之前,要清楚它将如何停止。……而这正是我的问题所在。

我正在构建一个模仿 Pick 的数据库,它提供了一种编程语言。每个用户/会话都作为一个 goroutine 运行。使用 context 的方法会有效,除非遇到一个预期会长时间运行、超过一般 context 过期时间的程序。通常,你可能会通过一个 SELECT 语句来检查管理员是否在你的 QUIT 通道上发送了终止请求。但是,如果一个程序卡在无限循环中(例如,LABEL: GO TO LABEL),它就永远不会返回到执行检查的代码段。

至少这里有一个变通方法。只有在向后跳转时才会出现无限循环。方法调用也可以间接地实现同样效果。所以,至少你可以限制需要检查的位置和次数。

到目前为止,这是一段有趣的旅程。

这是一个很好的问题。我认为这取决于 Start 函数的行为以及它将如何被使用。有一个通用原则——除非必要,否则不要使用通道。

我喜欢在 main 函数中描述的那个选项。如果只是为了让 Start 并发运行,那么把决定权留给调用者。但是……

如果你想让 Start 函数可以被取消,那么你应该在其中传递上下文并在那里处理它。

func (d *DataCollector) Start(ctx context.Context) {
go func() { 
  doSomething(ctx)
  c <- struct{}
}()

select {
    case <-ctx.Done(): // cancelled
        return 
    case <-c:
        // everything went OK
    }
}

这取决于函数内部的逻辑。没有具体的上下文,很难给出一个通用的答案。例如,如果你只想启动收集器而不期望它们很快完成,那么采用 main() 中的方法即可。如果 Start 函数有更复杂的逻辑,例如调用一些 I/O 操作,你应该在 goroutine 中运行这些任务。

在Golang中,goroutine创建位置的选择确实需要根据具体场景权衡。以下是两种方式的对比和示例:

1. 调用者创建(显式控制)

调用者明确知道自己在启动并发任务,便于生命周期管理。

// 调用者创建goroutine
func main() {
    var wg sync.WaitGroup
    
    collector := NewDataCollector()
    
    // 调用者明确控制并发
    wg.Add(1)
    go func() {
        defer wg.Done()
        collector.Start()
    }()
    
    // 可以启动多个并等待
    wg.Wait()
}

2. 被调用者创建(封装并发)

被调用函数内部处理并发细节,对外提供同步接口。

type DataCollector struct {
    dataChan chan string
    done     chan struct{}
}

// 被调用者内部创建goroutine
func (d *DataCollector) Start() {
    go d.collect()
    go d.process()
}

func (d *DataCollector) collect() {
    for {
        select {
        case <-d.done:
            return
        case data := <-d.dataChan:
            // 处理数据
            _ = data
        }
    }
}

func (d *DataCollector) process() {
    // 处理逻辑
}

3. 实际场景示例

场景A:需要等待结果

// 调用者创建,便于同步
func ProcessBatch(items []string) []Result {
    var wg sync.WaitGroup
    results := make([]Result, len(items))
    
    for i, item := range items {
        wg.Add(1)
        go func(idx int, data string) {
            defer wg.Done()
            results[idx] = processItem(data)
        }(i, item)
    }
    
    wg.Wait()
    return results
}

场景B:长期运行的服务

// 被调用者创建,封装实现细节
type Server struct {
    listener net.Listener
    stop     chan struct{}
}

func (s *Server) Start() error {
    // 内部启动多个goroutine
    go s.acceptConnections()
    go s.monitorMetrics()
    go s.cleanupSessions()
    
    return nil
}

func (s *Server) acceptConnections() {
    for {
        select {
        case <-s.stop:
            return
        default:
            conn, err := s.listener.Accept()
            if err != nil {
                continue
            }
            go s.handleConnection(conn) // 每个连接一个goroutine
        }
    }
}

4. 指导原则

  • API设计角度:如果函数需要返回结果或可能被阻塞,让调用者决定是否并发
  • 封装角度:如果并发是实现细节,应在被调用者内部处理
  • 控制角度:需要协调多个goroutine时,调用者创建更合适
  • 错误处理:被调用者创建时需考虑panic恢复和错误传递
// 混合模式:提供同步和异步两种接口
type Processor struct {
    // ...
}

// 同步版本
func (p *Processor) ProcessSync(data Data) (Result, error) {
    return p.process(data)
}

// 异步版本(内部创建goroutine)
func (p *Processor) ProcessAsync(data Data) <-chan Result {
    resultChan := make(chan Result, 1)
    go func() {
        defer close(resultChan)
        result, err := p.process(data)
        if err == nil {
            resultChan <- result
        }
    }()
    return resultChan
}

选择哪种方式取决于具体需求:调用者创建提供更明确的控制,被调用者创建提供更好的封装性。在实际项目中,这两种模式经常结合使用。

回到顶部