我们先假设各位小伙伴都没有亲自开发过类似的通信服务器,所以当理解完这个问题后,我们需要识别出解决这一问题可能使用到的技术点。不过这个问题并不复杂,我们可以很容易地识别出其中的技术点。
首先,前面说过 socket 是传输层给用户提供的编程接口,我们要进行的网络通信绕不开 socket,因此我们首先需要了解 socket 编程模型。
其次,一旦通过 socket 将双方的连接建立后,剩下的就是通过网络 I/O 操作在两端收发数据了,学习基本网络 I/O 操作的方法与注意事项也必不可少。
最后,任何一端准备发送数据或收到数据后都要对数据进行操作,由于 TCP 是流协议,我们需要了解针对字节的操作。
按照问题解决循环,一旦识别出技术点,接下来我们要做的就是技术预研与储备。在 Go 中,字节操作基本上就是 byte 切片的操作,这些用法我们在第 15 讲中已经学过了。所以,这一讲,我们就来学习一下 socket 编程模型以及网络 I/O 操作,为后两讲的设计与实现打稳根基,做好铺垫。
TCP Socket 编程模型
TCP Socket 诞生以来,它的编程模型,也就是网络 I/O 模型已几经演化。网络 I/O 模型定义的是应用线程与操作系统内核之间的交互行为模式。我们通常用阻塞(Blocking)/非阻塞(Non-Blocking)来描述网络 I/O 模型。
阻塞 / 非阻塞,是以内核是否等数据全部就绪后,才返回(给发起系统调用的应用线程)来区分的。如果内核一直等到全部数据就绪才返回,这种行为模式就称为阻塞。如果内核查看数据就绪状态后,即便没有就绪也立即返回错误(给发起系统调用的应用线程),那么这种行为模式则称为非阻塞。
常用的网络 I/O 模型包括下面这几种:
- 阻塞 I/O(Blocking I/O)
我们看到,在阻塞 I/O 模型下,当用户空间应用线程,向操作系统内核发起 I/O 请求后(一般为操作系统提供的 I/O 系列系统调用),内核会尝试执行这个 I/O 操作,并等所有数据就绪后,将数据从内核空间拷贝到用户空间,最后系统调用从内核空间返回。而在这个期间内,用户空间应用线程将阻塞在这个 I/O 系统调用上,无法进行后续处理,只能等待。
因此,在这样的模型下,一个线程仅能处理一个网络连接上的数据通信。即便连接上没有数据,线程也只能阻塞在对 Socket 的读操作上(以等待对端的数据)。虽然这个模型对应用整体来说是低效的,但对开发人员来说,这个模型却是最容易实现和使用的,所以,各大平台在默认情况下都将 Socket 设置为阻塞的。
- 非阻塞 I/O(Non-Blocking I/O)
和阻塞 I/O 模型正相反,在非阻塞模型下,当用户空间线程向操作系统内核发起 I/O 请求后,内核会执行这个 I/O 操作,如果这个时候数据尚未就绪,就会立即将“未就绪”的状态以错误码形式(比如:EAGAIN/EWOULDBLOCK),返回给这次 I/O 系统调用的发起者。而后者就会根据系统调用的返回状态来决定下一步该怎么做。
在非阻塞模型下,位于用户空间的 I/O 请求发起者通常会通过轮询的方式,去一次次发起 I/O 请求,直到读到所需的数据为止。不过,这样的轮询是对 CPU 计算资源的极大浪费,因此,非阻塞 I/O 模型单独应用于实际生产的比例并不高。
- I/O 多路复用(I/O Multiplexing)
为了避免非阻塞 I/O 模型轮询对计算资源的浪费,同时也考虑到阻塞 I/O 模型的低效,开发人员首选的网络 I/O 模型,逐渐变成了建立在内核提供的多路复用函数 select/poll 等(以及性能更好的 epoll 等函数)基础上的 I/O 多路复用模型。
这个模型下,应用线程与内核之间的交互行为模式如下图:
从图中我们看到,在这种模型下,应用线程首先将需要进行 I/O 操作的 Socket,都添加到多路复用函数中(这里以 select 为例),然后阻塞,等待 select 系统调用返回。当内核发现有数据到达时,对应的 Socket 具备了通信条件,这时 select 函数返回。然后用户线程会针对这个 Socket 再次发起网络 I/O 请求,比如一个 read 操作。由于数据已就绪,这次网络 I/O 操作将得到预期的操作结果。
我们看到,相比于阻塞模型一个线程只能处理一个 Socket 的低效,I/O 多路复用模型中,一个应用线程可以同时处理多个 Socket。同时,I/O 多路复用模型由内核实现可读 / 可写事件的通知,避免了非阻塞模型中轮询,带来的 CPU 计算资源浪费的问题。
目前,主流网络服务器采用的都是“I/O 多路复用”模型,有的也结合了多线程。不过,I/O 多路复用模型在支持更多连接、提升 I/O 操作效率的同时,也给使用者带来了不小的复杂度,以至于后面出现了许多高性能的 I/O 多路复用框架,比如:libevent、libev、libuv等,以帮助开发者简化开发复杂性,降低心智负担。
那么,在这三种 socket 编程模型中,Go 语言使用的是哪一种呢?我们继续往下看。
Go 语言 socket 编程模型
Go 语言设计者考虑得更多的是 Gopher 的开发体验。前面我们也说过,阻塞 I/O 模型是对开发人员最友好的,也是心智负担最低的模型,而 I/O 多路复用的这种通过回调割裂执行流的模型,对开发人员来说还是过于复杂了,于是 Go 选择了为开发人员提供阻塞 I/O 模型,Gopher 只需在 Goroutine 中以最简单、最易用的“阻塞 I/O 模型”的方式,进行 Socket 操作就可以了。
再加上,Go 没有使用基于线程的并发模型,而是使用了开销更小的 Goroutine 作为基本执行单元,这让每个 Goroutine 处理一个 TCP 连接成为可能,并且在高并发下依旧表现出色。
不过,网络 I/O 操作都是系统调用,Goroutine 执行 I/O 操作的话,一旦阻塞在系统调用上,就会导致 M 也被阻塞,为了解决这个问题,Go 设计者将这个“复杂性”隐藏在 Go 运行时中,他们在运行时中实现了网络轮询器(netpoller),netpoller 的作用,就是只阻塞执行网络 I/O 操作的 Goroutine,但不阻塞执行 Goroutine 的线程(也就是 M)。
这样一来,对于 Go 程序的用户层(相对于 Go 运行时层)来说,它眼中看到的 goroutine 采用了“阻塞 I/O 模型”进行网络 I/O 操作,Socket 都是“阻塞”的。
但实际上,这样的“假象”,是通过 Go 运行时中的 netpoller I/O 多路复用机制,“模拟”出来的,对应的、真实的底层操作系统 Socket,实际上是非阻塞的。只是运行时拦截了针对底层 Socket 的系统调用返回的错误码,并通过 netpoller 和 Goroutine 调度,让 Goroutine“阻塞”在用户层所看到的 Socket 描述符上。
socket 监听(listen)与接收连接(accept)
socket 编程的核心在于服务端,而服务端有着自己一套相对固定的套路:Listen+Accept。在这套固定套路的基础上,我们的服务端程序通常采用一个 Goroutine 处理一个连接,它的大致结构如下:
defer c.Close()
for {
// read from the connection
// ... ...
// write to the connection
//... ...
}
}
func main() {
l, err := net.Listen("tcp", ":8888")
if err != nil {
fmt.Println("listen error:", err)
return
}
for {
c, err := l.Accept()
if err != nil {
fmt.Println("accept error:", err)
break
}
// start a new goroutine to handle
// the new connection.
go handleConn(c)
}
}
在这个服务端程序中,我们在第 12 行使用了 net 包的 Listen 函数绑定(bind)服务器端口 8888,并将它转换为监听状态,Listen 返回成功后,这个服务会进入一个循环,并调用 net.Listener 的 Accept 方法接收新客户端连接。
在没有新连接的时候,这个服务会阻塞在 Accept 调用上,直到有客户端连接上来,Accept 方法将返回一个 net.Conn 实例。通过这个 net.Conn,我们可以和新连上的客户端进行通信。这个服务程序启动了一个新 Goroutine,并将 net.Conn 传给这个 Goroutine,这样这个 Goroutine 就专职负责处理与这个客户端的通信了。
向服务端建立TCP连接
一旦服务端按照上面的Listen + Accept结构启动成功,客户端便可以使用net.Dial或net.DialTimeout向服务端发起建立连接的请求:
conn, err := net.DialTimeout("tcp", "localhost:8888", 2 * time.Second)
Dial函数向服务端发起TCP连接,这个函数会一直阻塞,直到连接成功或失败后才返回。DialTimeout带有超时机制。对于客户端,连接的建立可能会遇到几种特殊情形。
1.网络不可达或对方服务未启动
2.对方服务的listen backlog队列满
不同操作系统backlog队列长度不同,macOS下:
$sysctl -a|grep kern.ipc.somaxconn
kern.ipc.somaxconn: 128
Ubuntu Linux下,backlog 队列的长度值与系统中net.ipv4.tcp_max_syn_backlog的设置有关。
3.网络延迟较大,Dial将阻塞并超时
如果网络延迟较大,TCP连接建立的过程(三次握手),会经历各种丢包,耗时延长和,dial函数会阻塞,长时间阻塞依旧无法建立连接,那么dial也会返回类似getsockopt: operation timed out的错误。
全双工通信
任何一方,都会为已建立的连接分配一个发送缓冲区和一个接收缓冲区。
以客户端为例,客户端会通过成功连接服务端后得到的 conn(封装了底层的 socket)向服务端发送数据包。这些数据包会先进入到己方的发送缓冲区中,之后,这些数据会被操作系统内核通过网络设备和链路,发到服务端的接收缓冲区中,服务端程序再通过代表客户端连接的 conn 读取服务端接收缓冲区中的数据,并处理。
反之,服务端发向客户端的数据包也是先后经过服务端的发送缓冲区、客户端的接收缓冲区,最终到达客户端的应用的。
socket读操作
连接建立起来后,我们就要在连接上进行读写以完成业务逻辑。我们前面说过,Go 运行时隐藏了 I/O 多路复用的复杂性。Go 语言使用者只需采用 Goroutine+ 阻塞 I/O 模型,就可以满足大部分场景需求。Dial 连接成功后,会返回一个 net.Conn 接口类型的变量值,这个接口变量的底层类型为一个 *TCPConn:
type TCPConn struct {
conn
}
TCPConn 内嵌了一个非导出类型:conn(封装了底层的 socket),因此,TCPConn“继承”了conn类型的Read和Write方法,后续通过Dial函数返回值调用的Read和Write方法都是 net.conn 的方法,它们分别代表了对 socket 的读和写。
接下来,我们先来通过几个场景来总结一下 Go 中从 socket 读取数据的行为特点。
socket无数据的场景
连接建立后,如果客户端未发送数据,服务端会阻塞在 Socket 的读操作上,这和前面提到的“阻塞 I/O 模型”的行为模式是一致的。执行该这个操作的 Goroutine 也会被挂起。Go 运行时会监视这个 Socket,直到它有数据读事件,才会重新调度这个 Socket 对应的 Goroutine 完成读操作。
socket中有部分数据
如果 Socket 中有部分数据就绪,且数据数量小于一次读操作期望读出的数据长度,那么读操作将会成功读出这部分数据,并返回,而不是等待期望长度数据全部读取后,再返回。
举个例子,服务端创建一个长度为 10 的切片作为接收数据的缓冲区,等待 Read 操作将读取的数据放入切片。当客户端在已经建立成功的连接上,成功写入两个字节的数据(比如:hi)后,服务端的 Read 方法将成功读取数据,并返回n=2,err=nil,而不是等收满 10 个字节后才返回。
socket中有足够数据
如果连接上有数据,且数据长度大于等于一次Read操作期望读出的数据长度,那么Read将会成功读出这部分数据,并返回。这个情景是最符合我们对Read的期待的了。
我们以上面的例子为例,当客户端在已经建立成功的连接上,成功写入 15 个字节的数据后,服务端进行第一次Read时,会用连接上的数据将我们传入的切片缓冲区(长度为 10)填满后返回:n = 10, err = nil。这个时候,内核缓冲区中还剩 5 个字节数据,当服务端再次调用Read方法时,就会把剩余数据全部读出。
设置读操作超时
有些场合,对 socket 的读操作的阻塞时间有严格限制的,但由于 Go 使用的是阻塞 I/O 模型,如果没有可读数据,Read 操作会一直阻塞在对 Socket 的读操作上。
这时,我们可以通过 net.Conn 提供的 SetReadDeadline 方法,设置读操作的超时时间,当超时后仍然没有数据可读的情况下,Read 操作会解除阻塞并返回超时错误,这就给 Read 方法的调用者提供了进行其他业务处理逻辑的机会。
SetReadDeadline 方法接受一个绝对时间作为超时的 deadline。一旦通过这个方法设置了某个 socket 的 Read deadline,当发生超时后,如果我们不重新设置 Deadline,那么后面与这个 socket 有关的所有读操作,都会返回超时失败错误。
func handleConn(c net.Conn) {
defer c.Close()
for {
// read from the connection
var buf = make([]byte, 128)
c.SetReadDeadline(time.Now().Add(time.Second))
n, err := c.Read(buf)
if err != nil {
log.Printf("conn read %d bytes, error: %s", n, err)
if nerr, ok := err.(net.Error); ok && nerr.Timeout() {
// 进行其他业务逻辑的处理
continue
}
return
}
log.Printf("read %d bytes, content is %s\n", n, string(buf[:n]))
}
}
如果我们要取消超时设置,可以使用 SetReadDeadline(time.Time{})实现。
socket写操作
通过 net.Conn 实例的 Write 方法,我们可以将数据写入 Socket。当 Write 调用的返回值 n 的值,与预期要写入的数据长度相等,且 err = nil 时,我们就执行了一次成功的 Socket 写操作,这是我们在调用 Write 时遇到的最常见的情形。
socket 写操作遇到的特殊情形同样不少,我们也逐一看一下。
写阻塞
TCP 协议通信两方的操作系统内核,都会为这个连接保留数据缓冲区,调用 Write 向 Socket 写入数据,实际上是将数据写入到操作系统协议栈的数据缓冲区中。TCP 是全双工通信,因此每个方向都有独立的数据缓冲。当发送方将对方的接收缓冲区,以及自身的发送缓冲区都写满后,再调用 Write 方法就会出现阻塞的情况。
func main() {
log.Println("begin dial...")
conn, err := net.Dial("tcp", ":8888")
if err != nil {
log.Println("dial error:", err)
return
}
defer conn.Close()
log.Println("dial ok")
data := make([]byte, 65536)
var total int
for {
n, err := conn.Write(data)
if err != nil {
total += n
log.Printf("write %d bytes, error:%s\n", n, err)
break
}
total += n
log.Printf("write %d bytes this time, %d bytes in total\n", n, total)
}
log.Printf("write %d bytes in total\n", total)
}
客户端每次调用 Write 方法向服务端写入 65536 个字节,并在 Write 方法返回后,输出此次 Write 的写入字节数和程序启动后写入的总字节数量。
服务端的处理程序逻辑,我也摘录了主要部分,你可以看一下:
... ...
func handleConn(c net.Conn) {
defer c.Close()
time.Sleep(time.Second * 10)
for {
// read from the connection
time.Sleep(5 * time.Second)
var buf = make([]byte, 60000)
log.Println("start to read from conn")
n, err := c.Read(buf)
if err != nil {
log.Printf("conn read %d bytes, error: %s", n, err)
if nerr, ok := err.(net.Error); ok && nerr.Timeout() {
continue
}
}
log.Printf("read %d bytes, content is %s\n", n, string(buf[:n]))
}
}
... ...
我们可以看到,服务端在前 10 秒中并不读取数据,因此当客户端一直调用 Write 方法写入数据时,写到一定量后就会发生阻塞。你可以看一下客户端的执行输出:
2022/01/14 14:57:33 begin dial...
2022/01/14 14:57:33 dial ok
2022/01/14 14:57:33 write 65536 bytes this time, 65536 bytes in total
... ...
2022/01/14 14:57:33 write 65536 bytes this time, 589824 bytes in total
2022/01/14 14:57:33 write 65536 bytes this time, 655360 bytes in total <-- 之后,写操作将阻塞
后续当服务端每隔 5 秒进行一次读操作后,内核 socket 缓冲区腾出了空间,客户端就又可以写入了:
服务端:
2022/01/14 15:07:01 accept a new connection
2022/01/14 15:07:16 start to read from conn
2022/01/14 15:07:16 read 60000 bytes, content is
2022/01/14 15:07:21 start to read from conn
2022/01/14 15:07:21 read 60000 bytes, content is
2022/01/14 15:07:26 start to read from conn
2022/01/14 15:07:26 read 60000 bytes, content is
....
客户端(得以继续写入):
2022/01/14 15:07:01 write 65536 bytes this time, 720896 bytes in total
2022/01/14 15:07:06 write 65536 bytes this time, 786432 bytes in total
2022/01/14 15:07:16 write 65536 bytes this time, 851968 bytes in total
2022/01/14 15:07:16 write 65536 bytes this time, 917504 bytes in total
2022/01/14 15:07:27 write 65536 bytes this time, 983040 bytes in total
2022/01/14 15:07:27 write 65536 bytes this time, 1048576 bytes in total
.... ...
写入部分数据
Write 操作存在写入部分数据的情况,比如上面例子中,当客户端输出日志停留在“write 65536 bytes this time, 655360 bytes in total”时,我们杀掉服务端,这时我们就会看到客户端输出以下日志:
2022/01/14 15:19:14 write 65536 bytes this time, 655360 bytes in total
2022/01/14 15:19:16 write 24108 bytes, error:write tcp 127.0.0.1:62245->127.0.0.1:8888: write: broken pipe
2022/01/14 15:19:16 write 679468 bytes in total
显然,Write并不是在 655360 这个地方阻塞的,而是后续又写入 24108 个字节后发生了阻塞,服务端 Socket 关闭后,我们看到客户端又写入 24108 字节后,才返回的broken pipe错误。由于这 24108 字节数据并未真正被服务端接收到,程序需要考虑妥善处理这些数据,以防数据丢失。
写入超时
可以调用 SetWriteDeadline 方法。比如,我们可以将上面例子中的客户端源码拷贝一份,然后在新客户端源码中的 Write 调用之前,增加一行超时时间设置代码:
然后先后启动服务端与新客户端,我们可以看到写入超时的情况下,Write 方法的返回结果:
客户端输出:
2022/01/14 15:26:34 begin dial...
2022/01/14 15:26:34 dial ok
2022/01/14 15:26:34 write 65536 bytes this time, 65536 bytes in total
... ...
2022/01/14 15:26:34 write 65536 bytes this time, 655360 bytes in total
2022/01/14 15:26:34 write 24108 bytes, error:write tcp 127.0.0.1:62325->127.0.0.1:8888: i/o timeout
2022/01/14 15:26:34 write 679468 bytes in total
我们可以看到,在 Write 方法写入超时时,依旧存在数据部分写入(仅写入 24108 个字节)的情况。另外,和 SetReadDeadline 一样,只要我们通过 SetWriteDeadline 设置了写超时,那无论后续 Write 方法是否成功,如果不重新设置写超时或取消写超时,后续对 Socket 的写操作都将以超时失败告终。
并发Socket读写
type conn struct {
fd *netFD
}
// Network file descriptor.
type netFD struct {
pfd poll.FD
// immutable until Close
family int
sotype int
isConnected bool // handshake completed or use of association with peer
net string
laddr Addr
raddr Addr
}
netFD 中最重要的字段是 poll.FD 类型的 pfd,它用于表示一个网络连接。我也把它的结构摘录了一部分:
// $GOROOT/src/internal/poll/fd_unix.go
// FD is a file descriptor. The net and os packages use this type as a
// field of a larger type representing a network connection or OS file.
type FD struct {
// Lock sysfd and serialize access to Read and Write methods.
fdmu fdMutex
// System file descriptor. Immutable until Close.
Sysfd int
// I/O poller.
pd pollDesc
// Writev cache.
iovecs *[]syscall.Iovec
... ...
}
我们看到,FD类型中包含了一个运行时实现的fdMutex类型字段。从它的注释来看,这个fdMutex用来串行化对字段Sysfd的 Write 和 Read 操作。也就是说,所有对这个 FD 所代表的连接的 Read 和 Write 操作,都是由fdMutex来同步的。从FD的 Read 和 Write 方法的实现,也证实了这一点:
// $GOROOT/src/internal/poll/fd_unix.go
func (fd *FD) Read(p []byte) (int, error) {
if err := fd.readLock(); err != nil {
return 0, err
}
defer fd.readUnlock()
if len(p) == 0 {
// If the caller wanted a zero byte read, return immediately
// without trying (but after acquiring the readLock).
// Otherwise syscall.Read returns 0, nil which looks like
// io.EOF.
// TODO(bradfitz): make it wait for readability? (Issue 15735)
return 0, nil
}
if err := fd.pd.prepareRead(fd.isFile); err != nil {
return 0, err
}
if fd.IsStream && len(p) > maxRW {
p = p[:maxRW]
}
for {
n, err := ignoringEINTRIO(syscall.Read, fd.Sysfd, p)
if err != nil {
n = 0
if err == syscall.EAGAIN && fd.pd.pollable() {
if err = fd.pd.waitRead(fd.isFile); err == nil {
continue
}
}
}
err = fd.eofError(n, err)
return n, err
}
}
func (fd *FD) Write(p []byte) (int, error) {
if err := fd.writeLock(); err != nil {
return 0, err
}
defer fd.writeUnlock()
if err := fd.pd.prepareWrite(fd.isFile); err != nil {
return 0, err
}
var nn int
for {
max := len(p)
if fd.IsStream && max-nn > maxRW {
max = nn + maxRW
}
n, err := ignoringEINTRIO(syscall.Write, fd.Sysfd, p[nn:max])
if n > 0 {
nn += n
}
if nn == len(p) {
return nn, err
}
if err == syscall.EAGAIN && fd.pd.pollable() {
if err = fd.pd.waitWrite(fd.isFile); err == nil {
continue
}
}
if err != nil {
return nn, err
}
if n == 0 {
return nn, io.ErrUnexpectedEOF
}
}
}
每次 Write 操作都是受 lock 保护,直到这次数据全部写完才会解锁。因此,在应用层面,要想保证多个 Goroutine 在一个conn上 write 操作是安全的,需要一次 write 操作完整地写入一个“业务包”。一旦将业务包的写入拆分为多次 write,那也无法保证某个 Goroutine 的某“业务包”数据在conn发送的连续性。
同时,我们也可以看出即便是 Read 操作,也是有 lock 保护的。多个 Goroutine 对同一conn的并发读,不会出现读出内容重叠的情况,但就像前面讲并发读的必要性时说的那样,一旦采用了不恰当长度的切片作为 buf,很可能读出不完整的业务包,这反倒会带来业务上的处理难度。
socket关闭
“有数据关闭”是指在客户端关闭连接(Socket)时,Socket 中还有服务端尚未读取的数据。在这种情况下,服务端的 Read 会成功将剩余数据读取出来,最后一次 Read 操作将得到io.EOF错误码,表示客户端已经断开了连接。如果是在“无数据关闭”情形下,服务端调用的 Read 方法将直接返回io.EOF。
不过因为 Socket 是全双工的,客户端关闭 Socket 后,如果服务端 Socket 尚未关闭,这个时候服务端向 Socket 的写入操作依然可能会成功,因为数据会成功写入己方的内核 socket 缓冲区中,即便最终发不到对方 socket 缓冲区也会这样。因此,当发现对方 socket 关闭后,己方应该正确合理处理自己的 socket,再继续 write 已经没有任何意义了。
此文章为3月Day27学习笔记,内容来源于极客时间《Tony Bai · Go 语言第一课》。