记一次go的http.client高并发踩坑记

最早在写求索的时候就遇到了同样的问题,但是因为求索并发并不是特别高,所以不是每次都能复显,我也不想一直挂着dlv去等。
最近在写一些扫描、爆破的小工具,遇到相同的问题,折腾了2天终于解决记录一下。
大概会出现这样的错误

net/http.(*persistconn).writeloop(0xc00b3498c0)

其实是3个问题

1. 创建大量client之后导致的协程溢出

这个问题有俩个原因

1.1 重复创建transport

默认使用http.Default.Get/http.NewRequest,都会创建一个新的transport,导致进行了大量的不必要的开销。
其实transport是可以复用的,本身http.client就是一个请求池

var transport http.Transport
func init() {
    transport = http.Transport{
        TLSClientConfig: &tls.Config{
            InsecureSkipVerify: true,
        },
    }
}
func (h *Http) Execute() *http.Response {
    defer func() {
        if err := recover(); err != nil {
            log.Println(err)
        }
    }()
    var err error
    //复用transport
    h.HttpClient.Transport = &transport
    h.HttpResponse, err = h.HttpClient.Do(h.HttpRequest)
    if err != nil {
        log.Println("[!] Http Execute Error : ", err)
        h.HttpResponse = nil
        return nil
    }
    return h.HttpResponse
}

1.2 HttpResponse.Body未完成完全读取

一般是开了大量的goroutine去执行http.client,但是最后导致client没有自动回收、关闭,导致goroutine的崩溃

goroutine 退出需要满足:

  • body 读取完毕
  • request 主动 cancel
  • request context Done 状态 true
  • 当前的 persistConn 关闭

比较常见的场景就是我们只去获取了header,并没有去读取body,然后就return了,据说不把body读完也会导致同样的问题
为了以防万一,我们把request也设置一个context进行手动Done

// 新建一个请求
func (h *Http) New(method, urls string) error {
    var err error
    //设置一个context
    h.Ctx, h.CtxCancel = context.WithCancel(context.Background())
    ....
    h.HttpRequest, err = http.NewRequest(h.HttpRequestType, h.HttpRequestUrl, h.HttpBody)
    h.HttpRequest.WithContext(h.Ctx)
    return err
}
// 关闭请求与body
func (h *Http) Close() {
    defer func() {
        if err := recover(); err != nil {
            log.Println(err)
        }
    }()
    
    if h.HttpResponse != nil {
       //读取内容并关闭body
        h.readAll()
    }
    if h.CtxCancel != nil {
       //主动停止request
        h.CtxCancel()
    }

}

2. 内存溢出

内存溢出的原因是因为一般我们读body都是这样的

resp:=make([]byte,512)
ioutil.ReadAll(HttpResponse.Body,resp)

这样每次读取一个新的都会进行一次makeslice操作,导致内存使用率增大,最后被系统强制killer

解决方案就是使用sync.Pool,创建一个byte的pool,每次都从pool里面去取空闲的出来使用

var pool sync.Pool
func init(){
//初始化一个pool
    pool = sync.Pool{
        New: func() interface{} {
            return bytes.NewBuffer(make([]byte, 4096))
        },
    }
}
.....
func (h *Http) readAll() ([]byte, error) {
    //获取一个新的,如果不存在则会调用new创建
    buffer := pool.Get().(*bytes.Buffer)
    buffer.Reset()
    defer func() {
        if buffer != nil {
            //重新放回去
            pool.Put(buffer)
            buffer = nil
        }
    }()
    if h.HttpResponse == nil {
        return nil, fmt.Errorf("HttpResponse is nil")
    }
    if h.HttpResponse.Body == nil {
        return nil, fmt.Errorf("HttpResponse.Body is nil")
    }
    _, err := io.Copy(buffer, h.HttpResponse.Body)
    if err != nil && err != io.EOF {
        //log.Printf("readAll io.copy failure error:%v \n", err)
        return nil, fmt.Errorf("readAll io.copy failure error:%v", err)
    }
    defer h.HttpResponse.Body.Close()
    return buffer.Bytes(), nil
}

参考资料

https://barbery.me/post/2019-08-02-fix-goroutine-memory-leak/
https://sanyuesha.com/2019/09/10/go-http-request-goroutine-leak/
http://xiaorui.cc/archives/7172
https://riptutorial.com/go/example/16314/sync-pool
https://studygolang.com/articles/16282

标签: none