在Go语言的实际开发中,并发HTTP请求是提升批量接口调用效率的常用方案,但并发场景下的错误处理和资源管理如果处理不当,很容易引发程序异常或者资源泄漏问题。很多开发者在编写相关代码时,会忽略错误传递、超时控制、连接复用等细节,导致程序稳定性不足。

并发HTTP请求的基础实现
Go标准库的net/http包本身支持HTTP请求,结合goroutine和channel就能实现基础的并发请求逻辑。下面是一个简单的并发请求示例,同时发起3个HTTP请求并收集结果:
package main
import (
"fmt"
"io"
"net/http"
"sync"
)
// 定义请求结果结构体,包含响应内容和错误信息
type RequestResult struct {
URL string
Body string
Err error
}
func main() {
urls := []string{
"http://ipipp.com/api/test1",
"http://ipipp.com/api/test2",
"http://ipipp.com/api/test3",
}
results := make(chan RequestResult, len(urls))
var wg sync.WaitGroup
// 启动goroutine并发发起请求
for _, url := range urls {
wg.Add(1)
go func(u string) {
defer wg.Done()
result := RequestResult{URL: u}
// 发起HTTP请求
resp, err := http.Get(u)
if err != nil {
result.Err = err
results <- result
return
}
// 确保响应体被关闭,避免资源泄漏
defer resp.Body.Close()
// 读取响应内容
body, err := io.ReadAll(resp.Body)
if err != nil {
result.Err = err
results <- result
return
}
result.Body = string(body)
results <- result
}(url)
}
// 等待所有goroutine执行完成,关闭结果channel
go func() {
wg.Wait()
close(results)
}()
// 收集所有请求结果
for res := range results {
if res.Err != nil {
fmt.Printf("请求 %s 失败: %vn", res.URL, res.Err)
continue
}
fmt.Printf("请求 %s 成功,响应长度: %dn", res.URL, len(res.Body))
}
}
并发场景下的错误处理要点
并发请求中的错误处理需要注意几个核心问题,避免错误被忽略或者程序异常退出。
错误传递与收集
goroutine内部的错误无法直接返回给调用方,因此需要通过channel将错误传递到主goroutine中统一处理。上面的示例中,我们将错误封装到RequestResult结构体中,通过缓冲channel传递给主流程,这样就不会丢失任何请求的错误信息。
避免panic导致程序崩溃
并发场景下如果某个goroutine发生panic,没有 recover 的话会导致整个程序退出。我们可以在goroutine入口处添加 recover 逻辑:
go func(u string) {
defer func() {
if r := recover(); r != nil {
result := RequestResult{URL: u}
result.Err = fmt.Errorf("请求发生panic: %v", r)
results <- result
wg.Done()
}
}()
defer wg.Done()
// 原有请求逻辑
}()
超时错误处理
默认的http.Get没有超时限制,如果目标服务响应缓慢,会导致goroutine长时间阻塞。我们需要给HTTP请求设置超时时间,使用http.Client的超时配置:
// 创建带超时的HTTP客户端,超时时间设置为3秒
client := &http.Client{
Timeout: 3 * time.Second,
}
resp, err := client.Get(u)
if err != nil {
// 超时错误会被捕获,err会包含超时相关信息
result.Err = err
results <- result
return
}
资源管理的关键措施
并发请求中的资源管理不当很容易引发内存泄漏、文件描述符耗尽等问题,需要重点关注以下几个方面。
响应体的关闭
无论HTTP请求是否成功,只要http.Response对象被返回,就必须关闭其Body字段,否则会造成连接泄漏。即使请求返回错误,也需要判断resp是否为nil再执行关闭操作:
resp, err := client.Get(u)
if err != nil {
result.Err = err
results <- result
return
}
// 先判断resp不为nil再关闭
if resp != nil {
defer resp.Body.Close()
}
goroutine泄漏规避
如果channel没有正确关闭,或者goroutine因为阻塞无法退出,就会造成goroutine泄漏。上面的示例中我们使用了sync.WaitGroup等待所有请求完成,再通过单独的goroutine关闭结果channel,确保没有goroutine被阻塞在往channel写数据的操作上。
连接池配置
Go的http.Client默认会复用TCP连接,但是默认的空闲连接回收时间是90秒,并发量高的时候可以适当调整连接池参数,提升资源利用率:
client := &http.Client{
Timeout: 3 * time.Second,
Transport: &http.Transport{
// 最大空闲连接数
MaxIdleConns: 100,
// 每个host的最大空闲连接数
MaxIdleConnsPerHost: 20,
// 空闲连接最长存活时间
IdleConnTimeout: 30 * time.Second,
},
}
常见错误与最佳实践
在实际开发中,开发者经常会犯几个典型错误,需要特别注意:
- 不要在goroutine中使用外部的循环变量,上面的示例中我们把url作为参数传入goroutine,就是避免闭包捕获循环变量的问题
- 不要使用无缓冲的channel传递结果,否则如果主goroutine没有及时读取,会导致发起请求的goroutine阻塞,造成泄漏
- 批量请求的时候如果不需要所有请求都成功,可以使用
context的取消机制,当某个请求失败或者超时时,取消其他还在执行的请求
使用context控制并发请求取消的示例如下:
func main() {
urls := []string{
"http://ipipp.com/api/test1",
"http://ipipp.com/api/test2",
"http://ipipp.com/api/test3",
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
results := make(chan RequestResult, len(urls))
var wg sync.WaitGroup
for _, url := range urls {
wg.Add(1)
go func(u string) {
defer wg.Done()
result := RequestResult{URL: u}
req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
if err != nil {
result.Err = err
results <- result
return
}
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
result.Err = err
results <- result
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
result.Err = err
results <- result
return
}
result.Body = string(body)
results <- result
}(url)
}
go func() {
wg.Wait()
close(results)
}()
for res := range results {
if res.Err != nil {
fmt.Printf("请求 %s 失败: %vn", res.URL, res.Err)
continue
}
fmt.Printf("请求 %s 成功,响应长度: %dn", res.URL, len(res.Body))
}
}
通过上面的方式,当整体超时或者某个请求触发取消时,所有使用该context的请求都会快速失败,避免无效的资源消耗。