Go 1.12 TLS 1.3 简单测试

《TLS 1.3 当前(2018.10)支持与部署之现状》中,我们提到 Go 将在 1.12 中支持 TLS 1.3. 作为一个 Gopher, 终于在前几天盼来了 golang 1.12 的发布。

但是从 release 日志看,本次对选择性的部分支持 TLS 1.3, 且默认处于关闭状态:

Go 1.12 adds opt-in support for TLS 1.3 in the crypto/tls package as specified by RFC 8446. It can be enabled by adding the value tls13=1 to the GODEBUG environment variable. It will be enabled by default in Go 1.13.

如果要开启 TLS 1.3, 需要设置环境变量:GODEBUG=tls13=1.

本次发布的 TLS 1.3 的 cipher suite 无法配置,也不支持 0-RTT 模式:

TLS 1.3 cipher suites are not configurable. All supported cipher suites are safe, and if PreferServerCipherSuites is set in Config the preference order is based on the available hardware.

Early data (also called “0-RTT mode”) is not currently supported as a client or server.

要知道,没有 0-RTT 的 TLS 1.3 是没有灵魂的,对本次版本的失望那是肯定的。但是依然在第一时间升级了 Go 版本,简单测试了一下国内网络环境下 TLS 1.3 与 1.2 的握手延迟。如果对 TLS 1.3 握手延迟还不太熟悉,可以参见拙文《TLS1.3/QUIC 是怎样做到 0-RTT 的》 以及 TLS 1.3 Handshake Protocol.

测试代码

需要说明的是,这个测试是不严谨,里面没有考虑 cipher suite 以及 early data 的差异。测试结果定性意义大于定量意义。

测试结果

测试使用的目标服务器地址是 blog.cloudflare.com:443, 我的网络环境下,ping 延迟为 226 ms (1-RTT).

从结果看,有如下结论:

  1. TLS 1.3 平均比 TLS 1.2 建立连接的延迟低约 1-RTT, 跟理论分析是吻合的。但是,
  2. 从 max 项可以看出,部分时候国内到目标服务器网络不稳定带来的波动比 TLS 本身协议优化的 RTT 大的多。因此,稳定高延迟的网络链路有时候比低延迟高抖动的网络更有实际意义。
  3. TLS 建立在 TCP 基础上,TCP 的握手延迟在 TLS 层面是优化不掉的,或者说不是 TLS 的管辖范围,因此,在允许的情况下,尽量复用连接。

Go 中如何准确地判断和识别各种网络错误

Go 自带的网络标准库可能让很多第一次使用它的人感慨,这个库让网络编程的门槛低到了令人发指的地步。然而,封装层次与开发人员的可控性往往是矛盾的。Go 的网络库封装程度算是一个不错的折衷,绝大部分时候,我们只需要调用 Dial, Read, Write Close 几个基本操作就可以了。

但是,网络是复杂的。我们有时候需要细致的处理网络中的各种错误,根据不同的错误进行不同的处理。比如我们遇到一个网络错误时,需要区分这个错误是因为无法解析 host ip, 还是 TCP 无法建立连接,亦或是读写超时。一开始的时候,我们的写法可能是这样的:

    errString := err.Error()
    fmt.Println(errString)
    switch {
    case strings.Contains(errString, "timeout"):
        fmt.Println("Timeout")
    case strings.Contains(errString, "no such host"):
        fmt.Println("Unknown host")
    case strings.Contains(errString, "connection refused"):
        fmt.Println("Connection refused")
    default:
        fmt.Printf("Unknown error:%s", errString)
    }

这种根据错误信息进行字符串匹配进行判断的方法有非常明显的局限性:该错误信息依赖于操作系统,不同的操作系统对于同一错误返回的字符串信息可能是不同的。因此,这种判断网络错误类型的方法是不可靠的。那么有没有一种准确而可靠的判断各种网络错误的方式呢?答案是肯定的。

我们知道在 Go 中,error 是一个内建的 interface 类型:

type error interface {
        Error() string
}

要准确判断不同的错误类型,我们只需要类型断言出其错误类型即可。

在 Go 的网络标准库中,错误类型被统一封装为 net.Errorinterface 类型:

type Error interface {
        error
        Timeout() bool   // Is the error a timeout?
        Temporary() bool // Is the error temporary?
}

net.Error 类型的具体 concrete 类型又被封装为 net.OpError 类型:

type OpError struct {
        // Op is the operation which caused the error, such as
        // "dial", "read" or "write".
        Op string

        // Net is the network type on which this error occurred,
        // such as "tcp" or "udp6".
        Net string

        // For operations involving a remote network connection, like
        // Dial, Read, or Write, Source is the corresponding local
        // network address.
        Source Addr

        // Addr is the network address for which this error occurred.
        // For local operations, like Listen or SetDeadline, Addr is
        // the address of the local endpoint being manipulated.
        // For operations involving a remote network connection, like
        // Dial, Read, or Write, Addr is the remote address of that
        // connection.
        Addr Addr

        // Err is the error that occurred during the operation.
        Err error
}

其中,net.OpError.Err 可能是以下几种类型:

*os.SyscallError 错误比较特殊,与具体操作系统调用有关:

type SyscallError struct {
        Syscall string
        Err     error
}

对于我们关心的网络错误,SyscallError.Err 一般为 sys.Errno 类型,与网络错误相关的常用值有:

  • syscall.ECONNREFUSED
  • syscall.ETIMEDOUT

看到这里,你可能忍不住要吐槽 Go 这种错误嵌套处理了,事实上,官方也意识到了这种错误处理的问题,在 Go 2中,可能会出现新的错误和异常处理方式,可以参见 GopherChina 2018 keynote 点评: RETHINKING ERRORS FOR GO 2.

当前阶段,我们依然要直面这种错误处理方式。为了方便大家理解 Go 网络标准库中处理错误的方式,我们把上面的错误嵌套整理了一张关系图:

明白了网络标准库中处理错误的逻辑,判断和识别各种类型的网络错误就非常简单了:对网络错误进行类型断言。以我们团队主要关心的 DNS 解析错误、TCP 无法建立连接、读写超时为例,判断逻辑可以是这样:

func isCaredNetError(err error) bool {
    netErr, ok := err.(net.Error)
    if !ok {
        return false
    }

    if netErr.Timeout() {
        log.Println("timeout")
        return true
    }

    opErr, ok := netErr.(*net.OpError)
    if !ok {
        return false
    }

    switch t := opErr.Err.(type) {
    case *net.DNSError:
        log.Printf("net.DNSError:%+v", t)
        return true
    case *os.SyscallError:
        log.Printf("os.SyscallError:%+v", t)
        if errno, ok := t.Err.(syscall.Errno); ok {
            switch errno {
            case syscall.ECONNREFUSED:
                log.Println("connect refused")
                return true
            case syscall.ETIMEDOUT:
                log.Println("timeout")
                return true
            }
        }
    }

    return false
}

这种错误判定方式除了能解决最开始提到的可靠性和准确性问题,也具有良好的普适性。即基于 net 的其他标准库,如 net/http 也支持这种错误判断方式。

扩展阅读

Go 中一个非典型不加锁读写变量案例分析

前段时间在 v2 看到一个关于并发读写变量的问题:go 一个线程写, 另外一个线程读, 为什么不能保证最终一致性。帖子中给出的例子非常简单(稍作修改)main.go

package main

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

var i = 0

func main() {
    runtime.GOMAXPROCS(2)
    go func() {
        for {
            fmt.Println("i am here", i)
            time.Sleep(time.Second)
        }
    }()
    for {
        i += 1
    }
}

既然是问题贴,直接运行的结果应该是出乎大多数人预料的:

╰─➤  go run main.go                                                                                                                                     1 ↵
i am here 0
i am here 0
i am here 0
i am here 0
i am here 0
i am here 0
...

帖子的回复比较多,涉及的信息量相对杂乱,爬完楼反而感觉没有看懂。这里就不卖关子,直接给出脱水后的结论:出现上面结果的原因是 go 的编译器把代码 i 自加 1 的 for 循环优化掉了。要验证这一点也很简单,我们使用 go tool objdump -s 'main\.main' main 查看编译出的二进制可执行文件的汇编代码:

╰─➤  go tool objdump -s 'main\.main' main
TEXT main.main(SB) /Users/liudanking/code/golang/gopath/src/test/main.go
  main.go:11        0x108de60       65488b0c25a0080000  MOVQ GS:0x8a0, CX
  main.go:11        0x108de69       483b6110        CMPQ 0x10(CX), SP
  main.go:11        0x108de6d       7635            JBE 0x108dea4
  main.go:11        0x108de6f       4883ec18        SUBQ $0x18, SP
  main.go:11        0x108de73       48896c2410      MOVQ BP, 0x10(SP)
  main.go:11        0x108de78       488d6c2410      LEAQ 0x10(SP), BP
  main.go:12        0x108de7d       48c7042402000000    MOVQ $0x2, 0(SP)
  main.go:12        0x108de85       e8366bf7ff      CALL runtime.GOMAXPROCS(SB)
  main.go:13        0x108de8a       c7042400000000      MOVL $0x0, 0(SP)
  main.go:13        0x108de91       488d05187f0300      LEAQ go.func.*+115(SB), AX
  main.go:13        0x108de98       4889442408      MOVQ AX, 0x8(SP)
  main.go:13        0x108de9d       e8fe13faff      CALL runtime.newproc(SB)
  main.go:20        0x108dea2       ebfe            JMP 0x108dea2
  main.go:11        0x108dea4       e8c7dffbff      CALL runtime.morestack_noctxt(SB)
  main.go:11        0x108dea9       ebb5            JMP main.main(SB)
  :-1           0x108deab       cc          INT $0x3
  :-1           0x108deac       cc          INT $0x3
  :-1           0x108dead       cc          INT $0x3
  :-1           0x108deae       cc          INT $0x3
  :-1           0x108deaf       cc          INT $0x3

TEXT main.main.func1(SB) /Users/liudanking/code/golang/gopath/src/test/main.go
  main.go:13        0x108deb0       65488b0c25a0080000  MOVQ GS:0x8a0, CX
  main.go:13        0x108deb9       483b6110        CMPQ 0x10(CX), SP
  main.go:13        0x108debd       0f8695000000        JBE 0x108df58
  main.go:13        0x108dec3       4883ec58        SUBQ $0x58, SP
  main.go:13        0x108dec7       48896c2450      MOVQ BP, 0x50(SP)
  main.go:13        0x108decc       488d6c2450      LEAQ 0x50(SP), BP
  main.go:15        0x108ded1       0f57c0          XORPS X0, X0
  main.go:15        0x108ded4       0f11442430      MOVUPS X0, 0x30(SP)
  main.go:15        0x108ded9       0f11442440      MOVUPS X0, 0x40(SP)
  main.go:15        0x108dede       488d059b020100      LEAQ runtime.types+65664(SB), AX
  main.go:15        0x108dee5       4889442430      MOVQ AX, 0x30(SP)
  main.go:15        0x108deea       488d0d0f2d0400      LEAQ main.statictmp_0(SB), CX
  main.go:15        0x108def1       48894c2438      MOVQ CX, 0x38(SP)
  main.go:15        0x108def6       488d1583fb0000      LEAQ runtime.types+63872(SB), DX
  main.go:15        0x108defd       48891424        MOVQ DX, 0(SP)
  main.go:15        0x108df01       488d1d107c0c00      LEAQ main.i(SB), BX
  main.go:15        0x108df08       48895c2408      MOVQ BX, 0x8(SP)
  main.go:15        0x108df0d       e84eddf7ff      CALL runtime.convT2E64(SB)
  main.go:15        0x108df12       488b442410      MOVQ 0x10(SP), AX
  main.go:15        0x108df17       488b4c2418      MOVQ 0x18(SP), CX
  main.go:15        0x108df1c       4889442440      MOVQ AX, 0x40(SP)
  main.go:15        0x108df21       48894c2448      MOVQ CX, 0x48(SP)
  main.go:15        0x108df26       488d442430      LEAQ 0x30(SP), AX
  main.go:15        0x108df2b       48890424        MOVQ AX, 0(SP)
  main.go:15        0x108df2f       48c744240802000000  MOVQ $0x2, 0x8(SP)
  main.go:15        0x108df38       48c744241002000000  MOVQ $0x2, 0x10(SP)
  main.go:15        0x108df41       e85a9dffff      CALL fmt.Println(SB)
  main.go:16        0x108df46       48c7042400ca9a3b    MOVQ $0x3b9aca00, 0(SP)
  main.go:16        0x108df4e       e87d27fbff      CALL time.Sleep(SB)
  main.go:15        0x108df53       e979ffffff      JMP 0x108ded1
  main.go:13        0x108df58       e813dffbff      CALL runtime.morestack_noctxt(SB)
  main.go:13        0x108df5d       e94effffff      JMP main.main.func1(SB)
  :-1           0x108df62       cc          INT $0x3
  :-1           0x108df63       cc          INT $0x3
  :-1           0x108df64       cc          INT $0x3
  :-1           0x108df65       cc          INT $0x3
  :-1           0x108df66       cc          INT $0x3
  :-1           0x108df67       cc          INT $0x3
  :-1           0x108df68       cc          INT $0x3
  :-1           0x108df69       cc          INT $0x3
  :-1           0x108df6a       cc          INT $0x3
  :-1           0x108df6b       cc          INT $0x3
  :-1           0x108df6c       cc          INT $0x3
  :-1           0x108df6d       cc          INT $0x3
  :-1           0x108df6e       cc          INT $0x3
  :-1           0x108df6f       cc          INT $0x3

显然,

    for {
        i += 1
    }

直接被优化没了。我们可以在语句 i += 1 添加一个其他语句来避免被优化掉:

    for {
        i += 1
        time.Sleep(time.Nanosecond)
    }

重新运行程序,运行结果“看似正确”了:

╰─➤  go run main.go                                                                                                                                     1 ↵
i am here 30
i am here 1806937
i am here 3853635
i am here 5485251
...

显然,如此修改之后,这段代码并非真正正确。因为变量 i 存在并发读写,即 data race 的问题。而 data race 场景下,go 的行为是未知的。程序员最讨厌的几件事中,不确定性必居其一。因此,一步小心写出 data race 的bug,调试起来是不太开心的。这里的例子因为只有几行代码,我们可以目测定位问题。如果代码规模比较大,我们可以借助 golang 工具链中的 -race 参数来排查该类问题:

╰─➤  go run -race main.go                                                                                                                               2 ↵
==================
WARNING: DATA RACE
Read at 0x0000011d4318 by goroutine 6:
  runtime.convT2E64()
      /usr/local/go/src/runtime/iface.go:335 +0x0
  main.main.func1()
      /Users/liudanking/code/golang/gopath/src/test/main.go:15 +0x7d

Previous write at 0x0000011d4318 by main goroutine:
  main.main()
      /Users/liudanking/code/golang/gopath/src/test/main.go:20 +0x7f

Goroutine 6 (running) created at:
  main.main()
      /Users/liudanking/code/golang/gopath/src/test/main.go:13 +0x53
==================
i am here 1
i am here 558324
i am here 1075838

除了在 go run 上可以使用 -trace, 其他几个常用的golang工具链指令也支持这个参数:

$ go test -race mypkg    // to test the package
$ go run -race mysrc.go  // to run the source file
$ go build -race mycmd   // to build the command
$ go install -race mypkg // to install the package

需要说明的是, -trace 并不保证能够检查出程序中所有的 data race, 而检查出 data race 则必然存在。说起来比较绕,大家记住它跟布隆过滤器 (Bloom Filter) 的真值表是一样的就对了。

而要把最开始提到的代码改对,方法有很多,我们可以使用 The Go Memory Model 推荐的 sync 包中的读写锁即可:

package main

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

var i = 0

func main() {
    runtime.GOMAXPROCS(2)
    mtx := sync.RWMutex{}
    go func() {
        for {
            mtx.RLock()
            fmt.Println("i am here", i)
            mtx.RUnlock()
            time.Sleep(time.Second)
        }
    }()
    for {
        mtx.Lock()
        i += 1
        mtx.Unlock()
        time.Sleep(time.Nanosecond)
    }

扩展阅读