标签 https 下的文章

通过实例理解Go标准库http包是如何处理keep-alive连接的

img{512x368}

HTTP是如今互联网的基础协议,承载了互联网上的绝大部分应用层流量,并且从目前趋势来看,在未来10年,http仍然会是互联网应用的主要协议。Go语言自带“电池”,基于Go标准库我们可以轻松建立起一个http server处理客户端http请求,或创建一个http client向服务端发送http请求。

最初早期的http 1.0协议只支持短连接,即客户端每发送一个请求,就要和服务器端建立一个新TCP连接,请求处理完毕后,该连接将被拆除。显然每次tcp连接握手和拆除都将带来较大损耗,为了能充分利用已建立的连接,后来的http 1.0更新版和http 1.1支持在http请求头中加入Connection: keep-alive来告诉对方这个请求响应完成后不要关闭链接,下一次还要复用这个连接以继续传输后续请求和响应。后HTTP协议规范明确规定了HTTP/1.0版本如果想要保持长连接,需要在请求头中加上Connection: keep-alive,而HTTP/1.1版本将支持keep-alive长连接作为默认选项,有没有这个请求头都可以。

本文我们就来一起看看Go标准库中net/http包的http.Server和http.Client对keep-alive长连接的处理以及如何在Server和Client侧关闭keep-alive机制。

1. http包默认启用keep-alive

按照HTTP/1.1的规范,Go http包的http server和client的实现默认将所有连接视为长连接,无论这些连接上的初始请求是否带有Connection: keep-alive

下面分别是使用go http包的默认机制实现的一个http client和一个http server:

默认开启keep-alive的http client实现:

//github.com/bigwhite/experiments/http-keep-alive/client-keepalive-on/client.go
package main

import (
    "fmt"
    "io/ioutil"
    "net/http"
)

func main() {
    c := &http.Client{}
    req, err := http.NewRequest("Get", "http://localhost:8080", nil)
    if err != nil {
        panic(err)
    }
    fmt.Printf("%#v\n", *req)

    for i := 0; i < 5; i++ {
        resp, err := c.Do(req)
        if err != nil {
            fmt.Println("http get error:", err)
            return
        }
        defer resp.Body.Close()

        b, err := ioutil.ReadAll(resp.Body)
        if err != nil {
            fmt.Println("read body error:", err)
            return
        }
        fmt.Println("response body:", string(b))
    }
}

默认开启keep-alive的http server实现:

//github.com/bigwhite/experiments/http-keep-alive/server-keepalive-on/server.go
package main

import (
    "fmt"
    "net/http"
)

func Index(w http.ResponseWriter, r *http.Request) {
    fmt.Println("receive a request from:", r.RemoteAddr, r.Header)
    w.Write([]byte("ok"))
}

func main() {
    var s = http.Server{
        Addr:    ":8080",
        Handler: http.HandlerFunc(Index),
    }
    s.ListenAndServe()
}

现在我们启动上面的http server:

// server-keepalive-on目录下
$go run server.go

我们使用上面的client向该server发起5次http请求:

// client-keepalive-on目录下
$go run client.go
http.Request{Method:"Get", URL:(*url.URL)(0xc00016a000), Proto:"HTTP/1.1", ProtoMajor:1, ProtoMinor:1, Header:http.Header{}, Body:io.ReadCloser(nil), GetBody:(func() (io.ReadCloser, error))(nil), ContentLength:0, TransferEncoding:[]string(nil), Close:false, Host:"localhost:8080", Form:url.Values(nil), PostForm:url.Values(nil), MultipartForm:(*multipart.Form)(nil), Trailer:http.Header(nil), RemoteAddr:"", RequestURI:"", TLS:(*tls.ConnectionState)(nil), Cancel:(<-chan struct {})(nil), Response:(*http.Response)(nil), ctx:(*context.emptyCtx)(0xc00012c008)}
response body: ok
response body: ok
response body: ok
response body: ok
response body: ok

这期间server端输出的日志如下:

receive a request from: [::1]:55238 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
receive a request from: [::1]:55238 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
receive a request from: [::1]:55238 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
receive a request from: [::1]:55238 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
receive a request from: [::1]:55238 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]

我们简单分析一下两端的输出结果:

  • 从server端打印的请求的头部字段来看,客户端发来的请求header中并没有显式包含Connection: keep-alive,而仅有Accept-Encoding和User-Agent两个header字段;
  • server端处理的5个请求均来自同一个连接“[::1]:55238”,Server端默认保持了该连接,而不是在处理完一个请求后将连接关闭,说明两端均复用了第一个请求创建的http连接。

即便我们的client端每间隔5秒发送一次请求,server端默认也不会关闭连接(我们将fmt包缓冲log包,输出带有时间戳的日志):

// client-keepalive-on目录下
$go run client-with-delay.go
http.Request{Method:"Get", URL:(*url.URL)(0xc00016a000), Proto:"HTTP/1.1", ProtoMajor:1, ProtoMinor:1, Header:http.Header{}, Body:io.ReadCloser(nil), GetBody:(func() (io.ReadCloser, error))(nil), ContentLength:0, TransferEncoding:[]string(nil), Close:false, Host:"localhost:8080", Form:url.Values(nil), PostForm:url.Values(nil), MultipartForm:(*multipart.Form)(nil), Trailer:http.Header(nil), RemoteAddr:"", RequestURI:"", TLS:(*tls.ConnectionState)(nil), Cancel:(<-chan struct {})(nil), Response:(*http.Response)(nil), ctx:(*context.emptyCtx)(0xc00012c008)}
2021/01/03 12:25:21 response body: ok

2021/01/03 12:25:26 response body: ok
2021/01/03 12:25:31 response body: ok
2021/01/03 12:25:36 response body: ok
2021/01/03 12:25:41 response body: ok

// server-keepalive-on目录下
$go run server.go
2021/01/03 12:25:21 receive a request from: [::1]:58419 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
2021/01/03 12:25:26 receive a request from: [::1]:58419 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
2021/01/03 12:25:31 receive a request from: [::1]:58419 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
2021/01/03 12:25:36 receive a request from: [::1]:58419 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
2021/01/03 12:25:41 receive a request from: [::1]:58419 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]

2. http client端基于非keep-alive连接发送请求

有时候http client在一条连接上的数据请求密度并不高,因此client端并不想长期保持这条连接(占用端口资源),那么client端如何协调Server端在处理完请求返回应答后就关闭这条连接呢?我们看看在Go中如何实现这一场景需求:

//github.com/bigwhite/experiments/http-keep-alive/client-keepalive-off/client.go
... ...
func main() {
    tr := &http.Transport{
        DisableKeepAlives: true,
    }
    c := &http.Client{
        Transport: tr,
    }
    req, err := http.NewRequest("Get", "http://localhost:8080", nil)
    if err != nil {
        panic(err)
    }

    for i := 0; i < 5; i++ {
        resp, err := c.Do(req)
        if err != nil {
            fmt.Println("http get error:", err)
            return
        }
        defer resp.Body.Close()

        b, err := ioutil.ReadAll(resp.Body)
        if err != nil {
            fmt.Println("read body error:", err)
            return
        }
        log.Println("response body:", string(b))
        time.Sleep(5 * time.Second)
    }

}

http.Client底层的数据连接建立和维护是由http.Transport实现的,http.Transport结构有一个DisableKeepAlives字段,其默认值为false,即启动keep-alive。这里我们将其置为true,即关闭keep-alive,然后将该Transport实例作为初值,赋值给http Client实例的Transport字段。

接下来,我们使用这个client向上面那个http server发送五个请求,请求间间隔5秒(模拟连接空闲的状态),我们得到如下结果(从server端打印信息观察):

// 在client-keepalive-off下面
$go run client.go
2021/01/03 12:42:38 response body: ok
2021/01/03 12:42:43 response body: ok
2021/01/03 12:42:48 response body: ok
2021/01/03 12:42:53 response body: ok
2021/01/03 12:42:58 response body: ok

// 在server-keepalive-on下面

$go run server.go
2021/01/03 12:42:38 receive a request from: [::1]:62287 map[Accept-Encoding:[gzip] Connection:[close] User-Agent:[Go-http-client/1.1]]

2021/01/03 12:42:43 receive a request from: [::1]:62301 map[Accept-Encoding:[gzip] Connection:[close] User-Agent:[Go-http-client/1.1]]

2021/01/03 12:42:48 receive a request from: [::1]:62314 map[Accept-Encoding:[gzip] Connection:[close] User-Agent:[Go-http-client/1.1]]

2021/01/03 12:42:53 receive a request from: [::1]:62328 map[Accept-Encoding:[gzip] Connection:[close] User-Agent:[Go-http-client/1.1]]

2021/01/03 12:42:58 receive a request from: [::1]:62342 map[Accept-Encoding:[gzip] Connection:[close] User-Agent:[Go-http-client/1.1]]

从Server的输出结果来看,来自客户端的请求中增加了Connection:[close]的头字段,当收到这样的请求后,Server端便不再保持这一连接了。我们也看到上面日志中,每个请求都是通过不同的客户端端口发送出来的,显然这是五条不同的连接。

3. 建立一个不支持keep-alive连接的http server

假设我们有这样的一个需求,server端完全不支持keep-alive的连接,无论client端发送的请求header中是否显式带有Connection: keep-alive,server端都会在返回应答后关闭连接。那么在Go中,我们如何来实现这一需求呢?我们来看下面代码:

//github.com/bigwhite/experiments/http-keep-alive/server-keepalive-off/server.go

package main

import (
    "log"
    "net/http"
)

func Index(w http.ResponseWriter, r *http.Request) {
    log.Println("receive a request from:", r.RemoteAddr, r.Header)
    w.Write([]byte("ok"))
}

func main() {
    var s = http.Server{
        Addr:    ":8080",
        Handler: http.HandlerFunc(Index),
    }
    s.SetKeepAlivesEnabled(false)
    s.ListenAndServe()
}

我们看到在ListenAndServe前,我们调用了http.Server的SetKeepAlivesEnabled方法,并传入false参数,这样我们就在全局层面关闭了该Server对keep-alive连接的支持,我们用前面client-keepalive-on下面的client向该Server发送五个请求:

// 在client-keepalive-on下面
$go run client.go
http.Request{Method:"Get", URL:(*url.URL)(0xc000174000), Proto:"HTTP/1.1", ProtoMajor:1, ProtoMinor:1, Header:http.Header{}, Body:io.ReadCloser(nil), GetBody:(func() (io.ReadCloser, error))(nil), ContentLength:0, TransferEncoding:[]string(nil), Close:false, Host:"localhost:8080", Form:url.Values(nil), PostForm:url.Values(nil), MultipartForm:(*multipart.Form)(nil), Trailer:http.Header(nil), RemoteAddr:"", RequestURI:"", TLS:(*tls.ConnectionState)(nil), Cancel:(<-chan struct {})(nil), Response:(*http.Response)(nil), ctx:(*context.emptyCtx)(0xc00013a008)}
2021/01/03 13:30:08 response body: ok
2021/01/03 13:30:08 response body: ok
2021/01/03 13:30:08 response body: ok
2021/01/03 13:30:08 response body: ok
2021/01/03 13:30:08 response body: ok

// 在server-keepalive-off下面
$go run server.go
2021/01/03 13:30:08 receive a request from: [::1]:53005 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
2021/01/03 13:30:08 receive a request from: [::1]:53006 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
2021/01/03 13:30:08 receive a request from: [::1]:53007 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
2021/01/03 13:30:08 receive a request from: [::1]:53008 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
2021/01/03 13:30:08 receive a request from: [::1]:53009 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]

我们看到该Server在处理完每个请求后就关闭了传输该请求的连接,这导致client测不得不为每个请求建立一个新连接(从server输出的客户端地址和端口看出)。

4. 支持长连接闲置超时关闭的http server

显然上面的server处理方式“太过霸道”,对于想要复用连接,提高请求和应答传输效率的client而言,上面的“一刀切”机制并不合理。那么是否有一种机制可以让http server即可以对高密度传输数据的连接保持keep-alive,又可以及时清理掉那些长时间没有数据传输的idle连接,释放占用的系统资源呢?我们来看下面这个go实现的server:

//github.com/bigwhite/experiments/http-keep-alive/server-keepalive-with-idletimeout/server.go

package main

import (
    "log"
    "net/http"
    "time"
)

func Index(w http.ResponseWriter, r *http.Request) {
    log.Println("receive a request from:", r.RemoteAddr, r.Header)
    w.Write([]byte("ok"))
}

func main() {
    var s = http.Server{
        Addr:        ":8080",
        Handler:     http.HandlerFunc(Index),
        IdleTimeout: 5 * time.Second,
    }
    s.ListenAndServe()
}

从代码中我们看到,我们仅在创建http.Server实例时显式为其字段IdleTimeout做了一次显式赋值,设置idle连接的超时时间为5s。下面是Go标准库中关于http.Server的字段IdleTimeout的注释:

// $GOROOT/src/net/server.go

// IdleTimeout是当启用keep-alive时等待下一个请求的最大时间。
// 如果IdleTimeout为零,则使用ReadTimeout的值。如果两者都是
// 零,则没有超时。
IdleTimeout time.Duration

我们来看看效果如何,是否是我们期望那样的。为了测试效果,我们改造了client端,放在client-keepalive-on-with-idle下面:

//github.com/bigwhite/experiments/http-keep-alive/client-keepalive-on-with-idle/client.go
... ...
func main() {
    c := &http.Client{}
    req, err := http.NewRequest("Get", "http://localhost:8080", nil)
    if err != nil {
        panic(err)
    }

    for i := 0; i < 5; i++ {
        log.Printf("round %d begin:\n", i+1)
        for j := 0; j < i+1; j++ {
            resp, err := c.Do(req)
            if err != nil {
                fmt.Println("http get error:", err)
                return
            }
            defer resp.Body.Close()

            b, err := ioutil.ReadAll(resp.Body)
            if err != nil {
                fmt.Println("read body error:", err)
                return
            }
            log.Println("response body:", string(b))
        }
        log.Printf("round %d end\n", i+1)
        time.Sleep(7 * time.Second)
    }
}

client端请求分为5轮,轮与轮之间间隔7秒,下面是通信过程与结果:

// 在client-keepalive-on-with-idle下
$go run client.go
2021/01/03 14:17:05 round 1 begin:
2021/01/03 14:17:05 response body: ok
2021/01/03 14:17:05 round 1 end
2021/01/03 14:17:12 round 2 begin:
2021/01/03 14:17:12 response body: ok
2021/01/03 14:17:12 response body: ok
2021/01/03 14:17:12 round 2 end
2021/01/03 14:17:19 round 3 begin:
2021/01/03 14:17:19 response body: ok
2021/01/03 14:17:19 response body: ok
2021/01/03 14:17:19 response body: ok
2021/01/03 14:17:19 round 3 end
2021/01/03 14:17:26 round 4 begin:
2021/01/03 14:17:26 response body: ok
2021/01/03 14:17:26 response body: ok
2021/01/03 14:17:26 response body: ok
2021/01/03 14:17:26 response body: ok
2021/01/03 14:17:26 round 4 end
2021/01/03 14:17:33 round 5 begin:
2021/01/03 14:17:33 response body: ok
2021/01/03 14:17:33 response body: ok
2021/01/03 14:17:33 response body: ok
2021/01/03 14:17:33 response body: ok
2021/01/03 14:17:33 response body: ok
2021/01/03 14:17:33 round 5 end

// 在server-keepalive-with-idletimeout下
$go run server.go

2021/01/03 14:17:05 receive a request from: [::1]:64071 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]

2021/01/03 14:17:12 receive a request from: [::1]:64145 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
2021/01/03 14:17:12 receive a request from: [::1]:64145 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]

2021/01/03 14:17:19 receive a request from: [::1]:64189 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
2021/01/03 14:17:19 receive a request from: [::1]:64189 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
2021/01/03 14:17:19 receive a request from: [::1]:64189 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]

2021/01/03 14:17:26 receive a request from: [::1]:64250 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
2021/01/03 14:17:26 receive a request from: [::1]:64250 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
2021/01/03 14:17:26 receive a request from: [::1]:64250 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
2021/01/03 14:17:26 receive a request from: [::1]:64250 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]

2021/01/03 14:17:33 receive a request from: [::1]:64304 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
2021/01/03 14:17:33 receive a request from: [::1]:64304 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
2021/01/03 14:17:33 receive a request from: [::1]:64304 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
2021/01/03 14:17:33 receive a request from: [::1]:64304 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
2021/01/03 14:17:33 receive a request from: [::1]:64304 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]

我们看到:
- 在每轮内,client端的所有请求都是复用已建立的连接;
- 但每轮之间,由于Sleep了7秒,超出了server端idletimeout的时长,上一轮的连接被拆除,新一轮只能重建连接。

我们期望的效果实现了!

5. 一个http client可管理到多个server的连接

Go标准库的http.Client与一个server可不是一对一的关系,它可以实现一对多的http通信,也就是说一个http client可管理到多个server的连接,并优先复用到同一server的连接(keep-alive),而不是建立新连接,就像我们上面看到的那样。我们来创建一个向多个server发送请求的client:

//github.com/bigwhite/experiments/http-keep-alive/client-keepalive-on-to-multiple-servers/client.go
... ...
func main() {
    c := &http.Client{}
    req1, err := http.NewRequest("Get", "http://localhost:8080", nil)
    if err != nil {
        panic(err)
    }
    req2, err := http.NewRequest("Get", "http://localhost:8081", nil)
    if err != nil {
        panic(err)
    }

    for i := 0; i < 5; i++ {
        resp1, err := c.Do(req1)
        if err != nil {
            fmt.Println("http get error:", err)
            return
        }
        defer resp1.Body.Close()

        b1, err := ioutil.ReadAll(resp1.Body)
        if err != nil {
            fmt.Println("read body error:", err)
            return
        }
        log.Println("response1 body:", string(b1))

        resp2, err := c.Do(req2)
        if err != nil {
            fmt.Println("http get error:", err)
            return
        }
        defer resp2.Body.Close()

        b2, err := ioutil.ReadAll(resp2.Body)
        if err != nil {
            fmt.Println("read body error:", err)
            return
        }
        log.Println("response2 body:", string(b2))

        time.Sleep(5 * time.Second)
    }

}

我们建立两个默认的http server,分别监听8080和8081,运行上面client:

$go run client.go
2021/01/03 14:52:20 response1 body: ok
2021/01/03 14:52:20 response2 body: ok
2021/01/03 14:52:25 response1 body: ok
2021/01/03 14:52:25 response2 body: ok
2021/01/03 14:52:30 response1 body: ok
2021/01/03 14:52:30 response2 body: ok
2021/01/03 14:52:35 response1 body: ok
2021/01/03 14:52:35 response2 body: ok
2021/01/03 14:52:40 response1 body: ok
2021/01/03 14:52:40 response2 body: ok

server端的输出结果如下:

// server1(8080):
2021/01/03 14:52:20 receive a request from: [::1]:63871 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
2021/01/03 14:52:25 receive a request from: [::1]:63871 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
2021/01/03 14:52:30 receive a request from: [::1]:63871 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
2021/01/03 14:52:35 receive a request from: [::1]:63871 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
2021/01/03 14:52:40 receive a request from: [::1]:63871 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]

// server2(8081):
2021/01/03 14:52:20 receive a request from: [::1]:63872 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
2021/01/03 14:52:25 receive a request from: [::1]:63872 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
2021/01/03 14:52:30 receive a request from: [::1]:63872 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
2021/01/03 14:52:35 receive a request from: [::1]:63872 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
2021/01/03 14:52:40 receive a request from: [::1]:63872 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]

我们看到client同时支持与多个server进行通信,并针对每个server可以使用keep-alive的连接进行高效率通信。

本文涉及源代码可以在这里(https://github.com/bigwhite/experiments/tree/master/http-keep-alive)下载。


“Gopher部落”知识星球开球了!高品质首发Go技术文章,“三天”首发阅读权,每年两期Go语言发展现状分析,每天提前1小时阅读到新鲜的Gopher日报,网课、技术专栏、图书内容前瞻,六小时内必答保证等满足你关于Go语言生态的所有需求!星球首开,福利自然是少不了的!2020年年底之前,8.8折(很吉利吧^_^)加入星球,下方图片扫起来吧!

Go技术专栏“改善Go语⾔编程质量的50个有效实践”正在慕课网火热热销中!本专栏主要满足广大gopher关于Go语言进阶的需求,围绕如何写出地道且高质量Go代码给出50条有效实践建议,上线后收到一致好评!欢迎大家订阅!

我的网课“Kubernetes实战:高可用集群搭建、配置、运维与应用”在慕课网热卖中,欢迎小伙伴们订阅学习!

img{512x368}

我爱发短信:企业级短信平台定制开发专家 https://tonybai.com/。smspush : 可部署在企业内部的定制化短信平台,三网覆盖,不惧大并发接入,可定制扩展; 短信内容你来定,不再受约束, 接口丰富,支持长短信,签名可选。2020年4月8日,中国三大电信运营商联合发布《5G消息白皮书》,51短信平台也会全新升级到“51商用消息平台”,全面支持5G RCS消息。

著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。

Gopher Daily(Gopher每日新闻)归档仓库 – https://github.com/bigwhite/gopherdaily

我的联系方式:

  • 微博:https://weibo.com/bigwhite20xx
  • 微信公众号:iamtonybai
  • 博客:tonybai.com
  • github: https://github.com/bigwhite
  • “Gopher部落”知识星球:https://public.zsxq.com/groups/51284458844544

微信赞赏:
img{512x368}

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。

Go 1.14中值得关注的几个变化

可能是得益于2020年2月26日Go 1.14的发布,在2020年3月份的TIOBE编程语言排行榜上,Go重新进入TOP 10,而去年同期Go仅排行在第18位。虽然Go语言以及其他主流语言在榜单上的“上蹿下跳”让这个榜单的权威性饱受质疑:),但Go在这样的一个时间节点能进入TOP 10,对于Gopher和Go社区来说,总还是一个不错的结果。并且在一定层度上说明:Go在努力耕耘十年后,已经在世界主流编程语言之林中牢牢占据了自己的一个位置。

img{512x368}

图:TIOBE编程语言排行榜2020.3月榜单,Go语言重入TOP10

Go自从宣布Go1 Compatible后,直到这次的Go 1.14发布,Go的语法和核心库都没有做出不兼容的变化。这让很多其他主流语言的拥趸们觉得Go很“无趣”。但这种承诺恰恰是Go团队背后努力付出的结果,因此Go的每个发布版本都值得广大gopher尊重,每个发布版本都是Go团队能拿出的最好版本

下面我们就来解读一下Go 1.14的变化,看看这个新版本中有哪些值得我们重点关注的变化。

一. 语言规范

和其他主流语言相比,Go语言的语法规范的变化那是极其少的(广大Gopher们已经习惯了这个节奏:)),偶尔发布一个变化,那自然是要引起广大Gopher严重关注的:)。不过事先说明:只要Go版本依然是1.x,那么这个规范变化也是backward-compitable的

Go 1.14新增的语法变化是:嵌入接口的方法集可重叠。这个变化背后的朴素思想是这样的。看下面代码(来自这里):

type I interface { f(); String() string }
type J interface { g(); String() string }

type IJ interface { I; J }  ----- (1)
type IJ interface { f(); g(); String() string }  ---- (2)

代码中已知定义的I和J两个接口的方法集中都包含有String() string这个方法。在这样的情况下,我们如果想定义一个方法集合为Union(I, J)的新接口IJ,我们在Go 1.13及之前的版本中只能使用第(2)种方式,即只能在新接口IJ中重新书写一遍所有的方法原型,而无法像第(1)种方式那样使用嵌入接口的简洁方式进行。

Go 1.14通过支持嵌入接口的方法集可重叠解决了这个问题:

// go1.14-examples/overlapping_interface.go
package foo

type I interface {
    f()
    String() string
}
type J interface {
    g()
    String() string
}

type IJ interface {
    I
    J
}

在go 1.13.6上运行:

$go build overlapping_interface.go
# command-line-arguments
./overlapping_interface.go:14:2: duplicate method String

但在go 1.14上运行:

$go build overlapping_interface.go

// 一切ok,无报错

不过对overlapping interface的支持仅限于接口定义中,如果你要在struct定义中嵌入interface,比如像下面这样:

// go1.14-examples/overlapping_interface1.go
package main

type I interface {
    f()
    String() string
}

type implOfI struct{}

func (implOfI) f() {}
func (implOfI) String() string {
    return "implOfI"
}

type J interface {
    g()
    String() string
}

type implOfJ struct{}

func (implOfJ) g() {}
func (implOfJ) String() string {
    return "implOfJ"
}

type Foo struct {
    I
    J
}

func main() {
    f := Foo{
        I: implOfI{},
        J: implOfJ{},
    }
    println(f.String())
}

虽然Go编译器没有直接指出结构体Foo中嵌入的两个接口I和J存在方法的重叠,但在使用Foo结构体时,下面的编译器错误肯定还是会给出的:

$ go run overlapping_interface1.go
# command-line-arguments
./overlapping_interface1.go:37:11: ambiguous selector f.String

对于结构体中嵌入的接口的方法集是否存在overlap,go编译器似乎并没有严格做“实时”检查,这个检查被延迟到为结构体实例选择method的执行者环节了,就像上面例子那样。如果我们此时让Foo结构体 override一个String方法,那么即便I和J的方法集存在overlap也是无关紧要的,因为编译器不会再模棱两可,可以正确的为Foo实例选出究竟执行哪个String方法:

// go1.14-examples/overlapping_interface2.go

.... ....

func (Foo) String() string {
        return "Foo"
}

func main() {
        f := Foo{
                I: implOfI{},
                J: implOfJ{},
        }
        println(f.String())
}

运行该代码:

$go run overlapping_interface2.go
Foo

二. Go运行时

1. 支持异步抢占式调度

《Goroutine调度实例简要分析》一文中,我曾提到过这样一个例子:

// go1.14-examples/preemption_scheduler.go
package main

import (
    "fmt"
    "runtime"
    "time"
)

func deadloop() {
    for {
    }
}

func main() {
    runtime.GOMAXPROCS(1)
    go deadloop()
    for {
        time.Sleep(time.Second * 1)
        fmt.Println("I got scheduled!")
    }
}

在只有一个P的情况下,上面的代码中deadloop所在goroutine将持续占据该P,使得main goroutine中的代码得不到调度(GOMAXPROCS=1的情况下),因此我们无法看到I got scheduled!字样输出。这是因为Go 1.13及以前的版本的抢占是”协作式“的,只在有函数调用的地方才能插入“抢占”代码(埋点),而deadloop没有给编译器插入抢占代码的机会。这会导致GC在等待所有goroutine停止时等待时间过长,从而导致GC延迟;甚至在一些特殊情况下,导致在STW(stop the world)时死锁。

Go 1.14采用了基于系统信号的异步抢占调度,这样上面的deadloop所在的goroutine也可以被抢占了:

// 使用Go 1.14版本编译器运行上述代码

$go run preemption_scheduler.go
I got scheduled!
I got scheduled!
I got scheduled!

不过由于系统信号可能在代码执行到任意地方发生,在Go runtime能cover到的地方,Go runtime自然会处理好这些系统信号。但是如果你是通过syscall包或golang.org/x/sys/unix在Unix/Linux/Mac上直接进行系统调用,那么一旦在系统调用执行过程中进程收到系统中断信号,这些系统调用就会失败,并以EINTR错误返回,尤其是低速系统调用,包括:读写特定类型文件(管道、终端设备、网络设备)、进程间通信等。在这样的情况下,我们就需要自己处理EINTR错误。一个最常见的错误处理方式就是重试。对于可重入的系统调用来说,在收到EINTR信号后的重试是安全的。如果你没有自己调用syscall包,那么异步抢占调度对你已有的代码几乎无影响。

Go 1.14的异步抢占调度在windows/arm, darwin/arm, js/wasm, and plan9/*上依然尚未支持,Go团队计划在Go 1.15中解决掉这些问题

2. defer性能得以继续优化

Go 1.13中,defer性能得到理论上30%的提升。我们还用那个例子来看看go 1.14与go 1.13版本相比defer性能又有多少提升,同时再看看使用defer和不使用defer的对比:

// go1.14-examples/defer_benchmark_test.go
package defer_test

import "testing"

func sum(max int) int {
    total := 0
    for i := 0; i < max; i++ {
        total += i
    }

    return total
}

func foo() {
    defer func() {
        sum(10)
    }()

    sum(100)
}

func Bar() {
    sum(100)
    sum(10)
}

func BenchmarkDefer(b *testing.B) {
    for i := 0; i < b.N; i++ {
        foo()
    }
}
func BenchmarkWithoutDefer(b *testing.B) {
    for i := 0; i < b.N; i++ {
        Bar()
    }
}

我们分别用Go 1.13和Go 1.14运行上面的基准测试代码:

Go 1.13:

$go test -bench . defer_benchmark_test.go
goos: darwin
goarch: amd64
BenchmarkDefer-8              17873574            66.7 ns/op
BenchmarkWithoutDefer-8       26935401            43.7 ns/op
PASS
ok      command-line-arguments    2.491s

Go 1.14:

$go test -bench . defer_benchmark_test.go
goos: darwin
goarch: amd64
BenchmarkDefer-8              26179819            45.1 ns/op
BenchmarkWithoutDefer-8       26116602            43.5 ns/op
PASS
ok      command-line-arguments    2.418s

我们看到,Go 1.14的defer性能照比Go 1.13还有大幅提升,并且已经与不使用defer的性能相差无几了,这也是Go官方鼓励大家在性能敏感的代码执行路径上也大胆使用defer的原因。

img{512x368}

图:各个Go版本defer性能对比(图来自于https://twitter.com/janiszt/status/1215601972281253888)

3. internal timer的重新实现

鉴于go timer长期以来性能不能令人满意,Go 1.14几乎重新实现了runtime层的timer。其实现思路遵循了Dmitry Vyukov几年前提出的实现逻辑:将timer分配到每个P上,降低锁竞争;去掉timer thread,减少上下文切换开销;使用netpoll的timeout实现timer机制。

// $GOROOT/src/runtime/time.go

type timer struct {
        // If this timer is on a heap, which P's heap it is on.
        // puintptr rather than *p to match uintptr in the versions
        // of this struct defined in other packages.
        pp puintptr

}

// addtimer adds a timer to the current P.
// This should only be called with a newly created timer.
// That avoids the risk of changing the when field of a timer in some P's heap,
// which could cause the heap to become unsorted.

func addtimer(t *timer) {
        // when must never be negative; otherwise runtimer will overflow
        // during its delta calculation and never expire other runtime timers.
        if t.when < 0 {
                t.when = maxWhen
        }
        if t.status != timerNoStatus {
                badTimer()
        }
        t.status = timerWaiting

        addInitializedTimer(t)
}

// addInitializedTimer adds an initialized timer to the current P.
func addInitializedTimer(t *timer) {
        when := t.when

        pp := getg().m.p.ptr()
        lock(&pp.timersLock)
        ok := cleantimers(pp) && doaddtimer(pp, t)
        unlock(&pp.timersLock)
        if !ok {
                badTimer()
        }

        wakeNetPoller(when)
}
... ...

这样你的程序中如果大量使用time.After、time.Tick或者在处理网络连接时大量使用SetDeadline,使用Go 1.14编译后,你的应用将得到timer性能的自然提升

img{512x368}

图:切换到新timer实现后的各Benchmark数据

三. Go module已经production ready了

Go 1.14中带来的关于go module的最大惊喜就是Go module已经production ready了,这意味着关于go module的运作机制,go tool的各种命令和其参数形式、行为特征已趋稳定了。笔者从Go 1.11引入go module以来就一直关注和使用Go module,尤其是Go 1.13中增加go module proxy的支持,使得中国大陆的gopher再也不用为获取类似golang.org/x/xxx路径下的module而苦恼了。

Go 1.14中go module的主要变动如下:

a) module-aware模式下对vendor的处理:如果go.mod中go version是go 1.14及以上,且当前repo顶层目录下有vendor目录,那么go工具链将默认使用vendor(即-mod=vendor)中的package,而不是module cache中的($GOPATH/pkg/mod下)。同时在这种模式下,go 工具会校验vendor/modules.txt与go.mod文件,它们需要保持同步,否则报错。

在上述前提下,如要非要使用module cache构建,则需要为go工具链显式传入-mod=mod ,比如:go build -mod=mod ./...

b) 增加GOINSECURE,可以不再要求非得以https获取module,或者即便使用https,也不再对server证书进行校验。

c) 在module-aware模式下,如果没有建立go.mod或go工具链无法找到go.mod,那么你必须显式传入要处理的go源文件列表,否则go tools将需要你明确go.mod。比如:在一个没有go.mod的目录下,要编译一个hello.go,我们需要使用go build hello.go(hello.go需要显式放在命令后面),如果你执行go build .就会得到类似如下错误信息:

$go build .
go: cannot find main module, but found .git/config in /Users/tonybai
    to create a module there, run:
    cd .. && go mod init

也就是说在没有go.mod的情况下,go工具链的功能是受限的。

d) go module支持subversion仓库了,不过subversion使用应该很“小众”了。

要系统全面的了解go module的当前行为机制,建议还是通读一遍Go command手册中关于module的说明以及官方go module wiki

四. 编译器

Go 1.14 go编译器在-race和-msan的情况下,默认会执行-d=checkptr,即对unsafe.Pointer的使用进行合法性检查,主要检查两项内容:

  • 当将unsafe.Pointer转型为*T时,T的内存对齐系数不能高于原地址的

比如下面代码:

// go1.14-examples/compiler_checkptr1.go
package main

import (
    "fmt"
    "unsafe"
)

func main() {
    var byteArray = [10]byte{'a', 'b', 'c'}
    var p *int64 = (*int64)(unsafe.Pointer(&byteArray[1]))
    fmt.Println(*p)
}

以-race运行上述代码:

$go run -race compiler_checkptr1.go
fatal error: checkptr: unsafe pointer conversion

goroutine 1 [running]:
runtime.throw(0x11646fd, 0x23)
    /Users/tonybai/.bin/go1.14/src/runtime/panic.go:1112 +0x72 fp=0xc00004cee8 sp=0xc00004ceb8 pc=0x106d152
runtime.checkptrAlignment(0xc00004cf5f, 0x1136880, 0x1)
    /Users/tonybai/.bin/go1.14/src/runtime/checkptr.go:13 +0xd0 fp=0xc00004cf18 sp=0xc00004cee8 pc=0x1043b70
main.main()
    /Users/tonybai/go/src/github.com/bigwhite/experiments/go1.14-examples/compiler_checkptr1.go:10 +0x70 fp=0xc00004cf88 sp=0xc00004cf18 pc=0x11283b0
runtime.main()
    /Users/tonybai/.bin/go1.14/src/runtime/proc.go:203 +0x212 fp=0xc00004cfe0 sp=0xc00004cf88 pc=0x106f7a2
runtime.goexit()
    /Users/tonybai/.bin/go1.14/src/runtime/asm_amd64.s:1373 +0x1 fp=0xc00004cfe8 sp=0xc00004cfe0 pc=0x109b801
exit status 2

checkptr检测到:转换后的int64类型的内存对齐系数严格程度要高于转化前的原地址(一个byte变量的地址)。int64对齐系数为8,而一个byte变量地址对齐系数仅为1。

  • 做完指针算术后,转换后的unsafe.Pointer仍应指向原先Go堆对象
compiler_checkptr2.go
package main

import (
    "unsafe"
)

func main() {
    var n = 5
    b := make([]byte, n)
    end := unsafe.Pointer(uintptr(unsafe.Pointer(&b[0])) + uintptr(n+10))
    _ = end
}

运行上述代码:

$go run  -race compiler_checkptr2.go
fatal error: checkptr: unsafe pointer arithmetic

goroutine 1 [running]:
runtime.throw(0x10b618b, 0x23)
    /Users/tonybai/.bin/go1.14/src/runtime/panic.go:1112 +0x72 fp=0xc00003e720 sp=0xc00003e6f0 pc=0x1067192
runtime.checkptrArithmetic(0xc0000180b7, 0xc00003e770, 0x1, 0x1)
    /Users/tonybai/.bin/go1.14/src/runtime/checkptr.go:41 +0xb5 fp=0xc00003e750 sp=0xc00003e720 pc=0x1043055
main.main()
    /Users/tonybai/go/src/github.com/bigwhite/experiments/go1.14-examples/compiler_checkptr2.go:10 +0x8d fp=0xc00003e788 sp=0xc00003e750 pc=0x1096ced
runtime.main()
    /Users/tonybai/.bin/go1.14/src/runtime/proc.go:203 +0x212 fp=0xc00003e7e0 sp=0xc00003e788 pc=0x10697e2
runtime.goexit()
    /Users/tonybai/.bin/go1.14/src/runtime/asm_amd64.s:1373 +0x1 fp=0xc00003e7e8 sp=0xc00003e7e0 pc=0x1092581
exit status 2

checkptr检测到转换后的unsafe.Pointer已经超出原先heap object: b的范围了,于是报错。

不过目前Go标准库依然尚未能完全通过checkptr的检查,因为有些库代码显然违反了unsafe.Pointer的使用规则

Go 1.13引入了新的Escape Analysis,Go 1.14中我们可以通过-m=2查看详细的逃逸分析过程日志,比如:

$go run  -gcflags '-m=2' compiler_checkptr2.go
# command-line-arguments
./compiler_checkptr2.go:7:6: can inline main as: func() { var n int; n = 5; b := make([]byte, n); end := unsafe.Pointer(uintptr(unsafe.Pointer(&b[0])) + uintptr(n + 100)); _ = end }
./compiler_checkptr2.go:9:11: make([]byte, n) escapes to heap:
./compiler_checkptr2.go:9:11:   flow: {heap} = &{storage for make([]byte, n)}:
./compiler_checkptr2.go:9:11:     from make([]byte, n) (non-constant size) at ./compiler_checkptr2.go:9:11
./compiler_checkptr2.go:9:11: make([]byte, n) escapes to heap

五. 标准库

每个Go版本,变化最多的就是标准库,这里我们挑一个可能影响后续我们编写单元测试行为方式的变化说说,那就是testing包的T和B类型都增加了自己的Cleanup方法。我们通过代码来看一下Cleanup方法的作用:

// go1.14-examples/testing_cleanup_test.go
package main

import "testing"

func TestCase1(t *testing.T) {

    t.Run("A=1", func(t *testing.T) {
        t.Logf("subtest1 in testcase1")

    })
    t.Run("A=2", func(t *testing.T) {
        t.Logf("subtest2 in testcase1")
    })
    t.Cleanup(func() {
        t.Logf("cleanup1 in testcase1")
    })
    t.Cleanup(func() {
        t.Logf("cleanup2 in testcase1")
    })
}

func TestCase2(t *testing.T) {
    t.Cleanup(func() {
        t.Logf("cleanup1 in testcase2")
    })
    t.Cleanup(func() {
        t.Logf("cleanup2 in testcase2")
    })
}

运行上面测试:

$go test -v testing_cleanup_test.go
=== RUN   TestCase1
=== RUN   TestCase1/A=1
    TestCase1/A=1: testing_cleanup_test.go:8: subtest1 in testcase1
=== RUN   TestCase1/A=2
    TestCase1/A=2: testing_cleanup_test.go:12: subtest2 in testcase1
    TestCase1: testing_cleanup_test.go:18: cleanup2 in testcase1
    TestCase1: testing_cleanup_test.go:15: cleanup1 in testcase1
--- PASS: TestCase1 (0.00s)
    --- PASS: TestCase1/A=1 (0.00s)
    --- PASS: TestCase1/A=2 (0.00s)
=== RUN   TestCase2
    TestCase2: testing_cleanup_test.go:27: cleanup2 in testcase2
    TestCase2: testing_cleanup_test.go:24: cleanup1 in testcase2
--- PASS: TestCase2 (0.00s)
PASS
ok      command-line-arguments    0.005s

我们看到:

  • Cleanup方法运行于所有测试以及其子测试完成之后。

  • Cleanup方法类似于defer,先注册的cleanup函数后执行(比如上面例子中各个case的cleanup1和cleanup2)。

在拥有Cleanup方法前,我们经常像下面这样做:

// go1.14-examples/old_testing_cleanup_test.go
package main

import "testing"

func setup(t *testing.T) func() {
    t.Logf("setup before test")
    return func() {
        t.Logf("teardown/cleanup after test")
    }
}

func TestCase1(t *testing.T) {
    f := setup(t)
    defer f()
    t.Logf("test the testcase")
}

运行上面测试:

$go test -v old_testing_cleanup_test.go
=== RUN   TestCase1
    TestCase1: old_testing_cleanup_test.go:6: setup before test
    TestCase1: old_testing_cleanup_test.go:15: test the testcase
    TestCase1: old_testing_cleanup_test.go:8: teardown/cleanup after test
--- PASS: TestCase1 (0.00s)
PASS
ok      command-line-arguments    0.005s

有了Cleanup方法后,我们就不需要再像上面那样单独编写一个返回cleanup函数的setup函数了。

此次Go 1.14还将对unicode标准的支持从unicode 11 升级到 unicode 12 ,共增加了554个新字符。

六. 其他

超强的可移植性是Go的一个知名标签,在新平台支持方面,Go向来是“急先锋”。Go 1.14为64bit RISC-V提供了在linux上的实验性支持(GOOS=linux, GOARCH=riscv64)。

rust语言已经通过cargo-fuzz从工具层面为fuzz test提供了基础支持。Go 1.14也在这方面做出了努力,并且Go已经在向将fuzz test变成Go test的一等公民而努力。

七. 小结

Go 1.14的详细变更说明在这里可以查看。整个版本的milestone对应的issue集合在这里

不过目前Go 1.14在特定版本linux内核上会出现crash的问题,当然这个问题源于这些内核的一个已知bug。在这个issue中有关于这个问题的详细说明,涉及到的Linux内核版本包括:5.2.x, 5.3.0-5.3.14, 5.4.0-5.4.1。
本篇博客涉及的代码在这里可以下载。


我的网课“Kubernetes实战:高可用集群搭建、配置、运维与应用”在慕课网上线了,感谢小伙伴们学习支持!

我爱发短信:企业级短信平台定制开发专家 https://tonybai.com/
smspush : 可部署在企业内部的定制化短信平台,三网覆盖,不惧大并发接入,可定制扩展; 短信内容你来定,不再受约束, 接口丰富,支持长短信,签名可选。

著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。

Gopher Daily(Gopher每日新闻)归档仓库 – https://github.com/bigwhite/gopherdaily

我的联系方式:

微博:https://weibo.com/bigwhite20xx
微信公众号:iamtonybai
博客:tonybai.com
github: https://github.com/bigwhite

微信赞赏:
img{512x368}

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言精进之路1 Go语言精进之路2 商务合作请联系bigwhite.cn AT aliyun.com

欢迎使用邮件订阅我的博客

输入邮箱订阅本站,只要有新文章发布,就会第一时间发送邮件通知你哦!

这里是 Tony Bai的个人Blog,欢迎访问、订阅和留言! 订阅Feed请点击上面图片

如果您觉得这里的文章对您有帮助,请扫描上方二维码进行捐赠 ,加油后的Tony Bai将会为您呈现更多精彩的文章,谢谢!

如果您希望通过微信捐赠,请用微信客户端扫描下方赞赏码:

如果您希望通过比特币或以太币捐赠,可以扫描下方二维码:

比特币:

以太币:

如果您喜欢通过微信浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:
本站Powered by Digital Ocean VPS。
选择Digital Ocean VPS主机,即可获得10美元现金充值,可 免费使用两个月哟! 著名主机提供商Linode 10$优惠码:linode10,在 这里注册即可免费获 得。阿里云推荐码: 1WFZ0V立享9折!


View Tony Bai's profile on LinkedIn
DigitalOcean Referral Badge

文章

评论

  • 正在加载...

分类

标签

归档



View My Stats