Go 语言
标准库、反射和 cgo
TCP Socket 网络编程模型

理解 TCP Socket 网络编程模型

网络通信是服务端程序必不可少也是至关重要的一部分。基于 TCP Socket 的通信是网络编程的主流。 我们可以看到 Go 语言中 net 包及其子目录下的包(http)均自带高频和刚需的主角光环。

TCP Socket 编程是最常见的网络编程。在 POSIX 标准发布后,Socket 得到了各大主流操作系统平台的很好支持。

网络编程模型

网络 I/O 模型定义的是应用程序与内核数据交换的方式。我们通常用阻塞和非阻塞来描述网络 I/O 模型。

阻塞和非阻塞是以内核是否等数据全部就绪才返回来区分的。如果内核一直等到全部数据就绪才返回,那么就是阻塞的。 如果内核查看数据就绪状态后即便没有就绪也立即返回错误,那么就是非阻塞的。

常用的网络 I/O 模型有:阻塞 I/O 模型、非阻塞 I/O 模型、I/O 多路复用模型、异步 I/O 模型。

阻塞 I/O 模型

block-io-model

非阻塞 I/O 模型

non-block-io-model

select I/O 多路复用模型

select-io-model

epoll I/O 多路复用模型

epoll-io-model

信号驱动 I/O 模型

signal-io-model

异步 I/O 模型

async-io-model

在大多数情况下,Go 开发者无须关心 Socket 是不是阻塞,也无须亲自将 Socket 文件描述符的回调函数注册到类似 select/epoll 这样的系统调用中。

一个典型的 Go 网络服务端程序大致如下:

func handleConn(conn net.Conn) {
    defer conn.Close()
    for {
        buf := make([]byte, 512)
        n, err := conn.Read(buf)
        if err != nil {
            log.Println(err)
            return
        }
        log.Println(string(buf[:n]))
    }
}
 
func main() {
    listener, err := net.Listen("tcp", "localhost:8080")
    if err != nil {
        log.Fatal(err)
    }
    for {
        conn, err := listener.Accept()
        if err != nil {
            log.Println(err)
            continue
        }
        go handleConn(conn)
    }
}

在 Go 程序的用户层看来,goroutine 好像是采用了“阻塞 I/O 模型”进行网络 I/O 操作,Socket 都是阻塞的。 但实际上,这样的假象是 Go 运行时中的 netpoller 网络轮询器通过 I/O 多路复用机制模拟出来的。

// src/net/socke_cloexec.go
 
func sysSocket(family, sotype, proto int) (int, error) {
    ...
    if err = syscall.SetNonblock(s, true); err != nil {
        poll.CloseFunc(s)
 
        return -1, os.NewSyscallError("setnonblock", err)
    }
    ...
}

只是运行时拦截了针对底层 Socket 的系统调用返回的错误码,并通过 netpoller 和 goroutine 调度让 goroutine 阻塞在用户层所看到的 Socket 描述符上。

Go 语言在 netpoller 中采用了 I/O 多路复用模型。Go 运行时选择了在不同操作系统上使用操作系统各自实现的高性能多路复用函数,比如 linux 上的 epoll、 window 上的 iocp、FreeBSD/macOS 上的 kqueue 和 Solaris 上的 event ports 等。

TCP 连接的建立

众所周知,建立 TCP Socket 连接需要经历客户端和服务端三次握手过程。在连接的建立过程中,服务端是一个标准的 Listen+Accept 结构。而客户端 Go 语言使用 net.Dial 函数发起连接建立。

conn, err := net.Dial("tcp", "localhost:8080")
if err != nil {
    // 处理错误
}
// 连接建立成功,可以进行读写操作

使用带有超时机制的 net.DialTimeout 函数:

conn, err := net.DialTimeout("tcp", "localhost:8080", 10 * time.Second)
if err != nil {
    // 处理错误
}
// 连接建立成功,可以进行读写操作

对于客户端来说,建立连接可能会遇到如下几种情形:

  • 网络不可达或对方服务未启动;
  • 对方服务的 listen backlog 队列满了;
  • 若网络延时较大,Dial 将阻塞并超时。

Socket 读写

Dial 连接成功后会返回一个 net.Conn 接口类型的变量值,这个值的底层类型是未一个 *TCPConn

// src/net/tcpsock_posix.go
type TCPConn struct {
    conn
    pd      pollDesc
    r       *bufio.Reader
    w       *bufio.Writer
    err     error
    isRead  bool
    isWrite bool
}

TCPConn 内嵌了一个非导出类型 conn,因此继承了 conn 类型的 Read 和 Write 方法。

我们来看一下 TCPConnRead 方法的几个场景:

1、Socket 中无数据:

连接建立后,如果客户端未发送数据,服务端会阻塞在 Socket 的读操作上。执行该读操作的 goroutine 会被挂起。Go 运行时会监视该 Socket, 直到其有数据读事件才会重新调度该 Socket 对应的 goroutine 完成读操作。

2、Socket 中有部分数据:

如果 Socket 有部分数据就绪,而且数据量小于一次读操作所期望读出的数据长度,那么读操作将会成功读出这部分数据并返回,而不是等待期望长度数据全部读取后再返回。

客户端代码:

func main() {
    conn, err := net.Dial("tcp", "localhost:8080")
    if err != nil {
        log.Fatal(err)
    }
    defer conn.Close()
    conn.Write([]byte("hi"))
    time.Sleep(1 * time.Second)
}

服务端代码:

func handleConn(conn net.Conn) {
    defer conn.Close()
    for {
        buf := make([]byte, 10)
        n, err := conn.Read(buf)
        if err != nil {
            log.Println(err)
            return
        }
        log.Println(string(buf[:n]))
    }
}

3、Socket 中有足够多的数据:

如果连接上有数据,且数据量大于一次读操作所期望读出的数据长度,那么读操作将会成功读出这部分数据并返回,而不是等待期望长度数据全部读取后再返回。

如果客户端发送的内容长度为15字节,服务端传给 Read 的切片长度为 10,因此服务端执行一次 Read 只会读取 10 字节的数据,网络上还剩 5 字节数据,服务端会再次调用 Read 方法读取剩下的 5 字节数据。

4、Socket 关闭:

这里要分有数据关闭和无数据关闭:

  • 有数据关闭:如果 Socket 连接上有数据,那么读操作会成功读出数据,第二次执行读操作如果没有数据,如果客户端已经关闭了,那么读操作会立即返回错误 io.EOF。
  • 无数据关闭:如果 Socket 连接上无数据,如果客户端已经关闭了,那么读操作会立即返回错误 io.EOF。

5、读操作超时:

不会出现读出部分数据且返回超时的错误情况。要么成功读取所有数据,要么读取超时,读出数据长度为 0。

6、成功写:

所谓“成功写”指的是 Write 调用返回的 n 与预期写入的数据长度相等,且 err == nil

7、写阻塞:

TCP 通信连接两端的操作系统内核都会为该连接保留数据缓冲区,一端调用 Write 后,实际上数据是写入操作系统协议栈的数据缓冲区中的。 TCP 是全双工通信,因此每个方向都有独立的数据缓冲区。当发送方将对方的接收缓冲区及自身的发生缓冲区都写满后,Write 将会阻塞。

8、写入部分数据:

如果 Write 调用返回的 n 小于预期写入的数据长度,那么说明写入操作是部分成功的。但是服务端需要对此进行特殊处理。

9、写入超时:

在 Write 之前增加一行代码设置超时时间:

conn.SetWriteDeadline(time.Now().Add(10 * time.Second))

即便写入超时了,依旧存在数据部分写入的情况。所以调用 Read 和 Write 依旧要结合这两个方法返回的 n 和 err 的结果来做正确处理。

10、goroutine 安全的并发读写:

每次 Write 操作都是受锁保护的,直到此次数据全部写完。要想保证多个 goroutine 在一个 conn 上的 Write 操作是安全的,需要每一次 Write 操作完整地写入一个业务包。

Read 操作也是有锁保护的,多个 goroutine 对同一个 conn 的并发度不会读出内容重叠的情况,但内容断点依运行时调度来随机确定的。 存在一个业务包数据三分之一的内容被第一个 goroutine 读出,三分之二的内容被第二个 goroutine 读出的情况。

Socket 属性

Go 提供的 socket options 接口也是有必要的属性设置,包括:SetKeepAlive、SetKeepAlivePeriod、SetLinger、SetNoDelay、SetReadBuffer、SetReadDeadline、SetWriteBuffer、SetWriteDeadline。

不过上面的方法是 TCPConn 类型的,而不是 Conn 类型的。所以需要进行类型断言操作:

conn, err := net.Dial("tcp", "localhost:8080")
if err != nil {
    log.Fatal(err)
}
tcpConn := conn.(*net.TCPConn)
tcpConn.SetKeepAlive(true)
tcpConn.SetKeepAlivePeriod(10 * time.Second)

对于 listener 的监听 Socket,Go 默认设置了 SO_REUSEADDR 选项,这样可以在服务端程序重启后快速重用端口。