Go 语言最吸引人的特性之一就是 goroutine,用 go 关键字就能轻松启动一个并发任务。但刚接触并发编程的人很快会发现一个问题:goroutine 没有返回值,写了 go doSomething() 之后,程序根本拿不到函数执行的结果。想要并行跑一堆函数,最后还要把每个函数的结果收集起来,这该怎么实现?这篇文章就从最基础的做法开始,一步步讲清楚 Go 中并行执行函数并安全获取返回值的几种主流方案。

为什么 goroutine 拿不到返回值
先看一个典型的错误写法:
package main
import "fmt"
func compute(x int) int {
return x * 2
}
func main() {
go compute(10) // 返回值被丢弃了
// fmt.Println(?) // 根本没有地方接收结果
fmt.Println("done")
}这段代码里 go compute(10) 启动了一个 goroutine,但 go 语句的表达式返回值会被直接丢弃,语言层面就不允许你写 go x := compute(10) 这种代码。更麻烦的是,主 goroutine 不会等待子 goroutine 结束,main 函数一返回,所有还没跑完的 goroutine 就被直接终止了。所以并行执行函数并获取返回值,本质上要解决两个问题:一是结果怎么从子 goroutine 传回主 goroutine,二是主 goroutine 怎么知道所有任务都完成了。
Go 官方给了一句非常著名的建议:不要通过共享内存来通信,而要通过通信来共享内存。这句话落实到具体问题上,就是用 channel 来传递结果。channel 天然是并发安全的,从多个 goroutine 向同一个 channel 发送数据不会产生数据竞争,接收端按顺序取出即可,这正是解决上述两个问题的标准工具。
用 channel 收集并行函数的返回值
最直接的方案:每个函数在结束前把结果发送到一个 channel,主 goroutine 再从 channel 里读取。先看单个函数的情况:
package main
import "fmt"
func compute(x int) chan int {
ch := make(chan int, 1) // 带缓冲,避免goroutine泄漏
go func() {
ch <- x * 2
}()
return ch
}
func main() {
result := compute(10)
fmt.Println(<-result) // 输出 20
}这种写法把 goroutine 的启动封装在函数内部,对外暴露一个 channel,调用者通过接收操作既拿到了结果,又天然实现了等待——如果结果还没算出来,接收操作会阻塞直到数据到达。这就是 Go 中非常经典的"异步句柄"模式,相当于其他语言里的 Future。
如果要并行执行多个函数并收集全部结果,可以配合 sync.WaitGroup 使用:
package main
import (
"fmt"
"sync"
)
func compute(x int) int {
return x * x
}
func main() {
inputs := []int{1, 2, 3, 4, 5}
results := make(chan int, len(inputs)) // 缓冲大小等于任务数
var wg sync.WaitGroup
for _, v := range inputs {
wg.Add(1)
go func(x int) { // 把v作为参数传入,避免闭包陷阱
defer wg.Done()
results <- compute(x)
}(v)
}
go func() {
wg.Wait()
close(results) // 所有任务完成后关闭channel
}()
for r := range results {
fmt.Println(r)
}
}这段代码有几个关键点值得展开。第一,results 的缓冲区大小设置成了 len(inputs),这样每个 goroutine 发送结果时都不会阻塞,即使主 goroutine 还没开始读也没关系。第二,用了一个单独的 goroutine 执行 wg.Wait() 然后 close(results),这是标准的收尾手法,保证 range 循环能在所有结果取完后正常退出,而不是一直阻塞。第三,go func(x int) 把循环变量 v 作为参数传进去,这一点非常重要——早期 Go 版本中循环变量是所有迭代共享的,如果直接在闭包里引用 v,很可能所有 goroutine 拿到的都是最后一个值。虽然 Go 1.22 之后循环变量语义改成了每次迭代独立,但为了兼容性和代码可读性,显式传参仍是推荐写法。
如果每个函数还需要返回错误,惯例是定义一个结构体把结果和错误打包在一起发送,这样一条 channel 就能承载完整的返回信息:
type Result struct {
Value int
Err error
}
func computeSafe(x int) Result {
if x < 0 {
return Result{Err: fmt.Errorf("负数不支持: %d", x)}
}
return Result{Value: x * x}
}使用 errgroup 简化并行控制与错误处理
手写 WaitGroup 加 channel 虽然可行,但代码量不小,而且错误处理要自己组织。golang.org/x/sync 包里的 errgroup 把这套流程封装得非常好用,特别适合"并行跑一批任务,任何一个失败就尽快返回错误"的场景:
package main
import (
"context"
"fmt"
"golang.org/x/sync/errgroup"
)
func fetch(id int) (string, error) {
if id == 3 {
return "", fmt.Errorf("id=%d 请求失败", id)
}
return fmt.Sprintf("data-%d", id), nil
}
func main() {
g, ctx := errgroup.WithContext(context.Background())
results := make([]string, 5)
for i := 1; i <= 5; i++ {
i := i
g.Go(func() error {
res, err := fetch(i)
if err != nil {
return err
}
results[i-1] = res // 各goroutine写不同下标,无竞争
return nil
})
}
if err := g.Wait(); err != nil {
fmt.Println("任务失败:", err)
_ = ctx
return
}
fmt.Println(results)
}errgroup 的几个设计点很值得学习。g.Go 会自动完成 Add 和 Done 的配对,不需要手动管理计数器。g.Wait 会等待所有任务结束,并返回第一个非 nil 的错误——只要有任务失败,你就能拿到那个错误,其他成功的任务也不会丢。配合 WithContext 得到的 ctx,一旦某个任务报错,ctx 会被取消,其他还在运行的任务可以监听这个信号提前退出,避免做无用功。这个模式在并行调用多个下游接口、并行查询多个数据源的场景里几乎是标配。
上面的例子里每个 goroutine 写 results 的不同下标,切片本身是预分配好的,不同下标的写入互不干扰,所以不需要加锁。但如果多个 goroutine 要写同一个变量,就必须引入互斥锁或者改用 channel 传递。判断标准很简单:跑一遍 go run -race,只要 race detector 没报数据竞争,写法就是安全的。
channel 与共享变量加锁的方案对比
除了 channel,另一种常见思路是让 goroutine 直接写共享变量,再用 sync.Mutex 保护:
package main
import (
"fmt"
"sync"
)
func main() {
var mu sync.Mutex
total := 0
var wg sync.WaitGroup
for i := 1; i <= 100; i++ {
wg.Add(1)
go func(x int) {
defer wg.Done()
mu.Lock()
total += x
mu.Unlock()
}(i)
}
wg.Wait()
fmt.Println(total) // 5050
}这种写法适合结果只需要聚合(比如求和、计数)的场景,锁的粒度小、代码直观。但缺点也明显:每次访问共享数据都要记得加锁,漏加一次就是隐蔽的数据竞争 bug;而且锁会让 goroutine 之间产生串行点,高并发下反而不如 channel 高效。
两类方案的适用边界可以这样总结:如果结果是"传给某个明确的接收者做后续处理",用 channel,语义清晰、无需手动加锁;如果结果只是简单的计数或聚合,且临界区极小,用 mutex 也可以接受。工程上还有一个组合技巧——分片统计,先把数据按 key 哈希到多个桶,每个 goroutine 只写自己的桶,最后再合并,这样既能并行又几乎不需要锁,是很多高性能聚合库的底层做法。
无论选哪种方案,有三条纪律建议始终遵守:第一,启动 goroutine 前想清楚它由谁负责等待和回收,绝不留"孤儿 goroutine";第二,向 channel 发送数据前确认有对应的接收方,否则任务越积越多就是内存泄漏;第三,任何并发写共享状态的代码都过一遍 -race 检测。把这三点养成习惯,Go 的并行编程其实比你想象的要简单可靠得多。
Go语言并发编程goroutine返回值channel同步修改时间:2026-09-14 13:01:00