일일 코드를 살펴보겠습니다.
1package main 2 3import ( 4 "bytes" 5 "encoding/json" 6 "fmt" 7 "io/ioutil" 8 "net" 9 "net/http" 10 "time" 11) 12 13var tr *http.Transport 14 15func init() { 16 tr = &http.Transport{ 17 MaxIdleConns: 100, 18 Dial: func(netw, addr string) (net.Conn, error) { 19 conn, err := net.DialTimeout(netw, addr, time.Second*2) //设置建立连接超时 20 if err != nil { 21 return nil, err 22 } 23 err = conn.SetDeadline(time.Now().Add(time.Second * 3)) //设置发送接受数据超时 24 if err != nil { 25 return nil, err 26 } 27 return conn, nil 28 }, 29 } 30} 31 32func main() { 33 for { 34 _, err := Get("http://www.baidu.com/") 35 if err != nil { 36 fmt.Println(err) 37 break 38 } 39 } 40} 41 42 43func Get(url string) ([]byte, error) { 44 m := make(map[string]interface{}) 45 data, err := json.Marshal(m) 46 if err != nil { 47 return nil, err 48 } 49 body := bytes.NewReader(data) 50 req, _ := http.NewRequest("Get", url, body) 51 req.Header.Add("content-type", "application/json") 52 53 client := &http.Client{ 54 Transport: tr, 55 } 56 res, err := client.Do(req) 57 if res != nil { 58 defer res.Body.Close() 59 } 60 if err != nil { 61 return nil, err 62 } 63 resBody, err := ioutil.ReadAll(res.Body) 64 if err != nil { 65 return nil, err 66 } 67 return resBody, nil 68}
가 하는 일은 상대적으로 간단합니다. 즉, requesthttp://www.baidu.com/
그런 다음 응답을 기다립니다. . http://www.baidu.com/
, 然后等待响应。
看上去貌似没啥问题吧。
代码跑起来,也确实能正常收发消息。
但是这段代码跑一段时间,就会出现 i/o timeout
的报错。
这其实是最近排查了的一个问题,发现这个坑可能比较容易踩上,我这边对代码做了简化。
实际生产中发生的现象是,golang
服务在发起http
调用时,虽然http.Transport
设置了3s
超时,会偶发出现i/o timeout
的报错。
但是查看下游服务的时候,发现下游服务其实 100ms
코드가 실행되면 정상적으로 메시지를 보내고 받을 수 있습니다.
I/O 시간 초과
오류. 🎜🎜🎜🎜🎜이 문제는 실제로 최근에 조사된 문제입니다. 이 함정이 밟기 쉬울 수 있다는 사실을 발견하여 여기에서 코드를 단순화했습니다. 🎜🎜실제 제작에서 나타나는현상은 golang
서비스 출시 중http
호출되면 http.Transport
는 3초
시간 초과, I/O 시간 초과
오류 보고. 🎜🎜그런데 다운스트림 서비스를 살펴보면 실제로는 다운스트림 서비스가100ms
가 반환되었습니다. 🎜🎜🎜🎜에 해당하는 메시지 본문 변경 분석이 매우 이상합니다. 서버 측 디스플레이 처리에 시간이 걸리는 것은 분명합니다. ;라인 높이: 상속 ;패딩: 2px 4px;테두리 반경: 4px;마진 오른쪽: 2px;마진 왼쪽: 2px;색상: rgb(255, 82, 82);배경: rgb(248, 248, 248 );">100ms, 클라이언트 시간 제한은 3s
, 왜 시간 초과 오류가 발생합니까 I/O 시간 초과
는 어떻습니까? 100ms
,且客户端超时设的是3s
, 怎么就出现超时报错 i/o timeout
呢?
这里推测有两个可能。
因为服务端打印的日志其实只是服务端应用层打印的日志。但客户端应用层发出数据后,中间还经过客户端的传输层,网络层,数据链路层和物理层,再经过服务端的物理层,数据链路层,网络层,传输层到服务端的应用层。服务端应用层处耗时100ms,再原路返回。那剩下的3s-100ms
可能是耗在了整个流程里的各个层上。比如网络不好的情况下,传输层TCP使劲丢包重传之类的原因。
网络没问题,客户端到服务端链路整个收发流程大概耗时就是100ms
서버 애플리케이션 계층에서 인쇄한 로그이기 때문입니다. 하지만 클라이언트 애플리케이션 계층에서 데이터를 보낸 후 클라이언트의 전송 계층, 네트워크 계층, 데이터 링크 계층 및 물리 계층
을 통과한 다음 물리 계층, 데이터 링크 계층, 네트워크 계층, 전송 계층을 통과합니다. 서버에서 서비스까지 애플리케이션 계층은. 서버 애플리케이션 계층은 100ms
이 소요된 후 원래 경로로 돌아갑니다. 나머지3s-100ms
클라이언트에서 전체 송수신 프로세스에는 문제가 없습니다. 서버에 도달하는 데는 약 시간이 걸립니다. 이는 약 100ms
. 클라이언트 처리 논리 문제로 인해 시간 초과가 발생합니다. 🎜🎜🎜🎜일반적으로 문제가 발생하면 대부분의 경우 기본 네트워크의 문제는 아닙니다. 자신의 문제라고 과감하게 의심하세요🎜. 구경하다. 🎜🎜🎜🎜🎜🎜패킷 캡처 결과🎜🎜🎜분석, 세 번의 악수 시작 부분(🎜빨간 상자가 그려진 곳🎜). 🎜
到最后出现超时报错 i/o timeout
(画了蓝框的地方)。
从time
那一列从7
到10
,确实间隔3s
。而且看右下角的蓝框,是51169
端口发到80
端口的一次Reset
连接。
80
端口是服务端的端口。换句话说就是客户端3s
超时主动断开链接的。
但是再仔细看下第一行三次握手到最后客户端超时主动断开连接的中间,其实有非常多次HTTP请求。
回去看代码设置超时的方式。
1 tr = &http.Transport{ 2 MaxIdleConns: 100, 3 Dial: func(netw, addr string) (net.Conn, error) { 4 conn, err := net.DialTimeout(netw, addr, time.Second*2) //设置建立连接超时 5 if err != nil { 6 return nil, err 7 } 8 err = conn.SetDeadline(time.Now().Add(time.Second * 3)) //设置发送接受数据超时 9 if err != nil { 10 return nil, err 11 } 12 return conn, nil 13 }, 14 }
也就是说,这里的3s
超时,其实是在建立连接之后开始算的,而不是单次调用开始算的超时。
看注释里写的是
SetDeadline sets the read and write deadlines associated with the connection.
모두가 알고 있습니다HTTP
는 응용 프로그램 계층 프로토콜이고 전송 계층은 TCP
프로토콜. HTTP
是应用层协议,传输层用的是TCP
协议。
HTTP协议从1.0
以前,默认用的是短连接
,每次发起请求都会建立TCP连接。收发数据。然后断开连接。
TCP连接每次都是三次握手。每次断开都要四次挥手。
其实没必要每次都建立新连接,建立的连接不断开就好了,每次发送数据都复用就好了。
于是乎,HTTP协议从1.1
之后就默认使用长连接
。具体相关信息可以看之前的 这篇文章。
那么golang标准库
里也兼容这种实现。
通过建立一个连接池,针对每个域名
建立一个TCP长连接,比如http://baidu.com
和http://golang.com
就是两个不同的域名。
第一次访问http://baidu.com
域名的时候会建立一个连接,用完之后放到空闲连接池里,下次再要访问http://baidu.com
的时候会重新从连接池里把这个连接捞出来复用
짧은 연결
, 요청이 이루어질 때마다 TCP 연결이 설정됩니다. 데이터를 보내고 받습니다. 그런 다음 연결을 끊습니다. 🎜🎜TCP 연결은 매번 3방향 핸드셰이크입니다. 각 연결 해제에는 4개의 웨이브가 필요합니다. 🎜🎜사실 매번 새로운 연결을 설정할 필요는 없습니다. 데이터가 전송될 때마다 설정된 연결이 끊어지지 않고 재사용되기만 하면 됩니다. 🎜🎜그래서 HTTP 프로토콜은 1.1 이후
에서는 기본적으로 사용됩니다. 긴 연결
. 구체적인 정보는 이전 기사를 참조하세요. 🎜🎜그러면golang 표준 라이브러리
도 이 구현과 호환됩니다. 🎜🎜연결 풀을 설정하여 각 도메인 이름
은 http://baidu.com
및 http://golang.com
은 두 개의 다른 도메인 이름입니다. 🎜🎜첫번째 방문http://baidu.com
도메인 이름을 사용할 때 연결이 설정됩니다. 사용 후 다음 위치에 배치됩니다. 유휴 연결 풀 및 다운로드를 다시 방문하세요.http://baidu.com
은 연결 풀에서 연결을 검색합니다재사용
. 🎜여담: 이는 또한 이 기사의 마지막 질문인 동일한 도메인 이름을 강조해야 하는 이유를 설명합니다. 도메인 이름은 연결을 설정하고 연결은 다음에 해당합니다. 읽기 고루틴 하나와 고루틴 쓰기 하나. 도메인 이름이 동일하기 때문에 결국 유출됩니다
3
고루틴. 도메인 이름이 다르면 유출됩니다1+2*N
코루틴,N
은 도메인 이름 수입니다.3
个goroutine,如果不同域名的话,那就会泄漏1+2*N
个协程,N
就是域名数。
假设第一次请求要100ms
,每次请求完http://baidu.com
后都放入连接池中,下次继续复用,重复29
次,耗时2900ms
。
第30
次请求的时候,连接从建立开始到服务返回前就已经用了3000ms
,刚好到设置的3s超时阈值,那么此时客户端就会报超时 i/o timeout
。
虽然这时候服务端其实才花了100ms
,但耐不住前面29次
100ms
, 각 요청이 완료됩니다. http://baidu.com
결국 연결 풀에 넣고 다음에 계속해서 재사용하세요.29 code> 번, 시간 소모적<code style="font-size: 상속;line-height: 상속;padding: 2px 4px;border-radius: 4px;margin-right: 2px;margin-left: 2px;color: rgb( 255, 82, 82);배경: rgb(248, 248, 248);">2900ms
. 🎜🎜30
요청, 연결은 설정 시점부터 서비스가 반환되기 전까지 사용되었습니다.3000ms
, 설정된 🎜3s🎜timeout 임계값에 막 도달하면 클라이언트는 이 시간에 시간 초과를 보고합니다.i/o 시간 초과
. 🎜🎜서버가 실제로 이 시간을 소비했지만100ms
, 그러나 전면을 견딜 수 없습니다 29번
을 더하면 긴 시간이 됩니다. 🎜也就是说只要通过 http.Transport
设置了 err = conn.SetDeadline(time.Now().Add(time.Second * 3))
,并且你用了长连接,哪怕服务端处理再快,客户端设置的超时再长,总有一刻,你的程序会报超时错误。
原本预期是给每次调用设置一个超时,而不是给整个连接设置超时。
另外,上面出现问题的原因是给长连接设置了超时,且长连接会复用。
基于这两点,改一下代码。
1package main 2 3import ( 4 "bytes" 5 "encoding/json" 6 "fmt" 7 "io/ioutil" 8 "net/http" 9 "time" 10) 11 12var tr *http.Transport 13 14func init() { 15 tr = &http.Transport{ 16 MaxIdleConns: 100, 17 // 下面的代码被干掉了 18 //Dial: func(netw, addr string) (net.Conn, error) { 19 // conn, err := net.DialTimeout(netw, addr, time.Second*2) //设置建立连接超时 20 // if err != nil { 21 // return nil, err 22 // } 23 // err = conn.SetDeadline(time.Now().Add(time.Second * 3)) //设置发送接受数据超时 24 // if err != nil { 25 // return nil, err 26 // } 27 // return conn, nil 28 //}, 29 } 30} 31 32 33func Get(url string) ([]byte, error) { 34 m := make(map[string]interface{}) 35 data, err := json.Marshal(m) 36 if err != nil { 37 return nil, err 38 } 39 body := bytes.NewReader(data) 40 req, _ := http.NewRequest("Get", url, body) 41 req.Header.Add("content-type", "application/json") 42 43 client := &http.Client{ 44 Transport: tr, 45 Timeout: 3*time.Second, // 超时加在这里,是每次调用的超时 46 } 47 res, err := client.Do(req) 48 if res != nil { 49 defer res.Body.Close() 50 } 51 if err != nil { 52 return nil, err 53 } 54 resBody, err := ioutil.ReadAll(res.Body) 55 if err != nil { 56 return nil, err 57 } 58 return resBody, nil 59} 60 61func main() { 62 for { 63 _, err := Get("http://www.baidu.com/") 64 if err != nil { 65 fmt.Println(err) 66 break 67 } 68 } 69}
看注释会发现,改动的点有两个
http.Transport
里的建立连接时的一些超时设置干掉了。
在发起http请求的时候会场景http.Client
,此时加入超时设置,这里的超时就可以理解为单次请求的超时了。同样可以看下注释
Timeout specifies a time limit for requests made by this Client.
到这里,代码就改好了,实际生产中问题也就解决了。
实例代码里,如果拿去跑的话,其实还会下面的错
1Get http://www.baidu.com/: EOF
这个是因为调用得太猛了,http://www.baidu.com
那边主动断开的连接,可以理解为一个限流措施,目的是为了保护服务器,毕竟每个人都像这么搞,服务器是会炸的。。。
解决方案很简单,每次HTTP调用中间加个sleep
间隔时间就好。
到这里,其实问题已经解决了,下面会在源码层面分析出现问题的原因。对读源码不感兴趣的朋友们可以不用接着往下看,直接拉到文章底部右下角,做点正能量的事情(点两下)支持一下。(疯狂暗示,拜托拜托,这对我真的很重要!)
用的go版本是1.12.7。
从发起一个网络请求开始跟。
1res, err := client.Do(req) 2func (c *Client) Do(req *Request) (*Response, error) { 3 return c.do(req) 4} 5 6func (c *Client) do(req *Request) { 7 // ... 8 if resp, didTimeout, err = c.send(req, deadline); err != nil { 9 // ... 10 } 11 // ... 12} 13func send(ireq *Request, rt RoundTripper, deadline time.Time) { 14 // ... 15 resp, err = rt.RoundTrip(req) 16 // ... 17} 18 19// 从这里进入 RoundTrip 逻辑 20/src/net/http/roundtrip.go: 16 21func (t *Transport) RoundTrip(req *Request) (*Response, error) { 22 return t.roundTrip(req) 23} 24 25func (t *Transport) roundTrip(req *Request) (*Response, error) { 26 // 尝试去获取一个空闲连接,用于发起 http 连接 27 pconn, err := t.getConn(treq, cm) 28 // ... 29} 30 31// 重点关注这个函数,返回是一个长连接 32func (t *Transport) getConn(treq *transportRequest, cm connectMethod) (*persistConn, error) { 33 // 省略了大量逻辑,只关注下面两点 34 // 有空闲连接就返回 35 pc := <-t.getIdleConnCh(cm) 36 37 // 没有创建连接 38 pc, err := t.dialConn(ctx, cm) 39 40}
这里上面很多代码,其实只是为了展示这部分代码是怎么跟踪下来的,方便大家去看源码的时候去跟一下。
最后一个上面的代码里有个 getConn
方法。在发起网络请求的时候,会先取一个网络连接,取连接有两个来源。
如果有空闲连接,就拿空闲连接
1/src/net/http/tansport.go:810 2func (t *Transport) getIdleConnCh(cm connectMethod) chan *persistConn { 3 // 返回放空闲连接的chan 4 ch, ok := t.idleConnCh[key] 5 // ... 6 return ch 7}
没有空闲连接,就创建长连接。
1/src/net/http/tansport.go:1357 2func (t *Transport) dialConn() { 3 //... 4 conn, err := t.dial(ctx, "tcp", cm.addr()) 5 // ... 6 go pconn.readLoop() 7 go pconn.writeLoop() 8 // ... 9}
当第一次发起一个http请求时,这时候肯定没有空闲连接,会建立一个新连接。同时会创建一个读goroutine和一个写goroutine。
注意上面代码里的t.dial(ctx, "tcp", cm.addr())
,如果像文章开头那样设置了 http.Transport
的
1Dial: func(netw, addr string) (net.Conn, error) { 2 conn, err := net.DialTimeout(netw, addr, time.Second*2) //设置建立连接超时 3 if err != nil { 4 return nil, err 5 } 6 err = conn.SetDeadline(time.Now().Add(time.Second * 3)) //设置发送接受数据超时 7 if err != nil { 8 return nil, err 9 } 10 return conn, nil 11},
那么这里就会在下面的dial
里被执行到
1func (t *Transport) dial(ctx context.Context, network, addr string) (net.Conn, error) { 2 // ... 3 c, err := t.Dial(network, addr) 4 // ... 5}
这里面调用的设置超时,会执行到
1/src/net/net.go 2func (c *conn) SetDeadline(t time.Time) error { 3 //... 4 c.fd.SetDeadline(t) 5 //... 6} 7 8//... 9 10func setDeadlineImpl(fd *FD, t time.Time, mode int) error { 11 // ... 12 runtime_pollSetDeadline(fd.pd.runtimeCtx, d, mode) 13 return nil 14} 15 16 17//go:linkname poll_runtime_pollSetDeadline internal/poll.runtime_pollSetDeadline 18func poll_runtime_pollSetDeadline(pd *pollDesc, d int64, mode int) { 19 // ... 20 // 设置一个定时器事件 21 rtf = netpollDeadline 22 // 并将事件注册到定时器里 23 modtimer(&pd.rt, pd.rd, 0, rtf, pd, pd.rseq) 24}
上面的源码,简单来说就是,当第一次调用请求的,会建立个连接,这时候还会注册一个定时器事件,假设时间设了3s
,那么这个事件会在3s
后发生,然后执行注册事件的逻辑。而这个注册事件就是netpollDeadline
。 注意这个netpollDeadline
,待会会提到。
设置了超时事件,且超时事件是3s后之后,发生。再次期间正常收发数据。一切如常。
直到3s
过后,这时候看读goroutine
,会等待网络数据返回。
1/src/net/http/tansport.go:1642 2func (pc *persistConn) readLoop() { 3 //... 4 for alive { 5 _, err := pc.br.Peek(1) // 阻塞读取服务端返回的数据 6 //... 7}
然后就是一直跟代码。
1src/bufio/bufio.go: 129 2func (b *Reader) Peek(n int) ([]byte, error) { 3 // ... 4 b.fill() 5 // ... 6} 7 8func (b *Reader) fill() { 9 // ... 10 n, err := b.rd.Read(b.buf[b.w:]) 11 // ... 12} 13 14/src/net/http/transport.go: 1517 15func (pc *persistConn) Read(p []byte) (n int, err error) { 16 // ... 17 n, err = pc.conn.Read(p) 18 // ... 19} 20 21// /src/net/net.go: 173 22func (c *conn) Read(b []byte) (int, error) { 23 // ... 24 n, err := c.fd.Read(b) 25 // ... 26} 27 28func (fd *netFD) Read(p []byte) (n int, err error) { 29 n, err = fd.pfd.Read(p) 30 // ... 31} 32 33/src/internal/poll/fd_unix.go: 34func (fd *FD) Read(p []byte) (int, error) { 35 //... 36 if err = fd.pd.waitRead(fd.isFile); err == nil { 37 continue 38 } 39 // ... 40} 41 42func (pd *pollDesc) waitRead(isFile bool) error { 43 return pd.wait('r', isFile) 44} 45 46func (pd *pollDesc) wait(mode int, isFile bool) error { 47 // ... 48 res := runtime_pollWait(pd.runtimeCtx, mode) 49 return convertErr(res, isFile) 50}
直到跟到 runtime_pollWait,这个可以简单认为是等待服务端数据返回。
1//go:linkname poll_runtime_pollWait internal/poll.runtime_pollWait 2func poll_runtime_pollWait(pd *pollDesc, mode int) int { 3 4 // 1.如果网络正常返回数据就跳出 5 for !netpollblock(pd, int32(mode), false) { 6 // 2.如果有出错情况也跳出 7 err = netpollcheckerr(pd, int32(mode)) 8 if err != 0 { 9 return err 10 } 11 } 12 return 0 13}
整条链路跟下来,就是会一直等待数据,等待的结果只有两个
有可以读的数据
出现报错
这里面的报错,又有那么两种
连接关闭
超时
1func netpollcheckerr(pd *pollDesc, mode int32) int { 2 if pd.closing { 3 return 1 // errClosing 4 } 5 if (mode == 'r' && pd.rd < 0) || (mode == 'w' && pd.wd < 0) { 6 return 2 // errTimeout 7 } 8 return 0 9}
其中提到的超时
,就是指这里面返回的数字2
,会通过下面的函数,转化为 ErrTimeout
, 而 ErrTimeout.Error()
其实就是 i/o timeout
。
1func convertErr(res int, isFile bool) error { 2 switch res { 3 case 0: 4 return nil 5 case 1: 6 return errClosing(isFile) 7 case 2: 8 return ErrTimeout // ErrTimeout.Error() 就是 "i/o timeout" 9 } 10 println("unreachable: ", res) 11 panic("unreachable") 12}
那么问题来了。上面返回的超时错误,也就是返回2的时候的条件是怎么满足的?
1 if (mode == 'r' && pd.rd < 0) || (mode == 'w' && pd.wd < 0) { 2 return 2 // errTimeout 3 }
还记得刚刚提到的 netpollDeadline
吗?
这里面放了定时器3s
到点时执行的逻辑。
1func timerproc(tb *timersBucket) { 2 // 计时器到设定时间点了,触发之前注册函数 3 f(arg, seq) // 之前注册的是 netpollDeadline 4} 5 6func netpollDeadline(arg interface{}, seq uintptr) { 7 netpolldeadlineimpl(arg.(*pollDesc), seq, true, true) 8} 9 10/src/runtime/netpoll.go: 428 11func netpolldeadlineimpl(pd *pollDesc, seq uintptr, read, write bool) { 12 //... 13 if read { 14 pd.rd = -1 15 rg = netpollunblock(pd, 'r', false) 16 } 17 //... 18}
这里会设置pd.rd=-1
,是指 poller descriptor.read deadline
,含义网络轮询器文件描述符的读超时时间, 我们知道在linux里万物皆文件,这里的文件其实是指这次网络通讯中使用到的socket。
这时候再回去看发生超时的条件就是if (mode == 'r' && pd.rd < 0)
。
至此。我们的代码里就收到了 io timeout
的报错。
http.Transport
에 설정된 시간 초과는 요청 시간 초과가 아니라 연결 시간 초과입니다. 그렇지 않으면 설명할 수 없는 io 시간 초과
오류. http.Transport
中设置超时,那是连接的超时,不是请求的超时。否则可能会出现莫名 io timeout
报错。
请求的超时在创建client
위 내용은 i/o 시간 초과, 이 net/http 패키지의 함정을 밟지 않기를 바랍니다.의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!