运维八一 运维八一
首页
运维杂记
编程浅尝
周积跬步
专栏
生活
关于
收藏
  • 分类
  • 标签
  • 归档
Source (opens new window)

运维八一

运维,运维!
首页
运维杂记
编程浅尝
周积跬步
专栏
生活
关于
收藏
  • 分类
  • 标签
  • 归档
Source (opens new window)
  • Go

    • 前言

    • Go基础知识

    • Go基本语法

    • 实战项目:简单web服务

    • 基本数据类型

    • 内置运算符

    • 分支和循环

    • 函数 function

    • 结构体 struct

    • 方法 method

    • 实战项目:跟踪函数调用链

    • 接口 interface

    • 并发 concurrency

    • 指针

    • 实战项目:实现轻量级线程池

    • 实战项目:实现TCP服务器

      • 什么是网络编程
      • 理解问题
      • 技术预研与储备
        • 3. 技术预研与储备
          • 3.1 TCP Socket 编程模型
          • 3.2 Go 语言 socket 编程模型
          • 3.3 常用的基于 socket 的网络 I/O 操作
          • 3.3.1 socket 监听 (listen) 与接收连接 (accept)
          • 3.3.2 向服务端建立 TCP 连接
          • 3.3.3 全双工通信
          • 3.3.4 Socket 读操作
          • 3.3.5 Socket 写操作
          • 3.3.6 并发 Socket 读写
          • 3.3.7 Socket 关闭
      • 设计与实现
      • 优化
    • go常用包

    • Gin框架

    • go随记

  • Python

  • Shell

  • Java

  • Vue

  • 前端

  • 编程浅尝
  • Go
  • 实战项目:实现TCP服务器
lyndon
2022-06-07
目录

技术预研与储备

# 3. 技术预研与储备

识别技术点:

  • 首先,需要了解 socket 编程模型。
  • 其次,一旦通过 socket 将双方的连接建立后,剩下的就是通过网络 I/O 操作在两端收发数据了,学习基本网络 I/O 操作的方法与注意事项也必不可少。
  • 最后,任何一端准备发送数据或收到数据后都要对数据进行操作,由于 TCP 是流协议,需要了解针对字节的操作。

# 3.1 TCP Socket 编程模型

网络 I/O 模型定义的是应用线程与操作系统内核之间的交互行为模式。通常用 **阻塞(Blocking)/非阻塞(Non-Blocking)**来描述网络 I/O 模型。

阻塞 / 非阻塞,是以内核是否等数据全部就绪后,才返回(给发起系统调用的应用线程)来区分的。

如果内核一直等到全部数据就绪才返回,这种行为模式就称为阻塞。

如果内核查看数据就绪状态后,即便没有就绪也立即返回错误(给发起系统调用的应用线程),那么这种行为模式则称为非阻塞。

常用的网络 I/O 模型:

  • 阻塞 I/O(Blocking I/O)

阻塞 I/O 是最常用的模型,应用线程与内核之间的交互行为模式:

img

在阻塞 I/O 模型下,当用户空间应用线程,向操作系统内核发起 I/O 请求后(一般为操作系统提供的 I/O 系列系统调用),内核会尝试执行这个 I/O 操作,并等所有数据就绪后,将数据从内核空间拷贝到用户空间,最后系统调用从内核空间返回。而在这个期间内,用户空间应用线程将阻塞在这个 I/O 系统调用上,无法进行后续处理,只能等待。

因此,在这样的模型下,一个线程仅能处理一个网络连接上的数据通信。即便连接上没有数据,线程也只能阻塞在对 Socket 的读操作上(以等待对端的数据)。虽然这个模型对应用整体来说是低效的,但对开发人员来说,这个模型却是最容易实现和使用的,所以,各大平台在默认情况下都将 Socket 设置为阻塞的。

  • 非阻塞 I/O(Non-Blocking I/O)

非阻塞 I/O 模型下,应用线程与内核之间的交互行为模式:

img

和阻塞 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 多路复用模型。

应用线程与内核之间的交互行为模式:

img

在这种模型下,应用线程首先将需要进行 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等,以帮助开发者简化开发复杂性,降低心智负担。

# 3.2 Go 语言 socket 编程模型

socket 编程模型对比:

阻塞 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 描述符发起read操作时,如果这个 Socket 对应的连接上还没有数据,运行时就会将这个 Socket 描述符加入到 netpoller 中监听,同时发起此次读操作的 Goroutine 会被挂起。直到 Go 运行时收到这个 Socket 数据可读的通知,Go 运行时才会重新唤醒等待在这个 Socket 上准备读数据的那个 Goroutine。而这个过程,从 Goroutine 的视角来看,就像是 read 操作一直阻塞在那个 Socket 描述符上一样。而且,Go 语言在网络轮询器(netpoller)中采用了 I/O 多路复用的模型。考虑到最常见的多路复用系统调用 select 有比较多的限制,比如:监听 Socket 的数量有上限(1024)、时间复杂度高,等等,Go 运行时选择了在不同操作系统上,使用操作系统各自实现的高性能多路复用函数,比如:Linux 上的 epoll、Windows 上的 iocp、FreeBSD/MacOS 上的 kqueue、Solaris 上的 event port 等,这样可以最大程度提高 netpoller 的调度和执行性能。

# 3.3 常用的基于 socket 的网络 I/O 操作

# 3.3.1 socket 监听 (listen) 与接收连接 (accept)

socket 编程的核心在于服务端,而服务端有着自己一套相对固定的套路:Listen+Accept。

在这套固定套路的基础上,服务端程序通常采用一个 Goroutine 处理一个连接,它的大致结构如下:

 func handleConn(c net.Conn) {
     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)
     }
 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

在这个服务端程序中,在第 12 行使用了 net 包的 Listen 函数绑定(bind)服务器端口 8888,并将它转换为监听状态,Listen 返回成功后,这个服务会进入一个循环,并调用 net.Listener 的 Accept 方法接收新客户端连接。

在没有新连接的时候,这个服务会阻塞在 Accept 调用上,直到有客户端连接上来,Accept 方法将返回一个 net.Conn 实例。通过这个 net.Conn,可以和新连上的客户端进行通信。这个服务程序启动了一个新 Goroutine,并将 net.Conn 传给这个 Goroutine,这样这个 Goroutine 就专职负责处理与这个客户端的通信了。而 net.Listen 函数很少报错,除非是监听的端口已经被占用,那样程序将输出类似这样的错误:

bind: address already in use
1

当服务程序启动成功后,可以通过 netstat 命令,查看端口的监听情况:

$netstat -an|grep 8888    
tcp46       0      0  *.8888                 *.*                    LISTEN     
1
2

# 3.3.2 向服务端建立 TCP 连接

一旦服务端按照 Listen + Accept 结构成功启动,客户端便可以使用net.Dial或net.DialTimeout向服务端发起连接建立的请求:

conn, err := net.Dial("tcp", "localhost:8888")
conn, err := net.DialTimeout("tcp", "localhost:8888", 2 * time.Second)
1
2

Dial 函数向服务端发起 TCP 连接,这个函数会一直阻塞,直到连接成功或失败后,才会返回。

DialTimeout 带有超时机制,如果连接耗时大于超时时间,这个函数会返回超时错误。

对于客户端来说,连接的建立还可能会遇到几种特殊情形:

第一种情况:网络不可达或对方服务未启动。

如果传给Dial的服务端地址是网络不可达的,或者服务地址中端口对应的服务并没有启动,端口未被监听(Listen),Dial几乎会立即返回类似这样的错误:

dial error: dial tcp :8888: getsockopt: connection refused
1

第二种情况:对方服务的 listen backlog 队列满。

当对方服务器很忙,瞬间有大量客户端尝试向服务端建立连接时,服务端可能会出现 listen backlog 队列满,接收连接(accept)不及时的情况,这就会导致客户端的Dial调用阻塞,直到服务端进行一次 accept,从 backlog 队列中腾出一个槽位,客户端的 Dial 才会返回成功。

不同操作系统下 backlog 队列的长度是不同的:

macOS:

$sysctl -a|grep kern.ipc.somaxconn
kern.ipc.somaxconn: 128
1
2

Ubuntu Linux:backlog 队列的长度值与系统中net.ipv4.tcp_max_syn_backlog的设置有关。

极端情况下,如果服务端一直不执行accept操作,那么客户端不会一直阻塞。如果服务端运行在 macOS 下,那么客户端会阻塞大约 1 分多钟,才会返回超时错误:

dial error: dial tcp :8888: getsockopt: operation timed out
1

而如果服务端运行在 Ubuntu 上,客户端的Dial调用大约在 2 分多钟后提示超时错误,这个结果也和 Linux 的系统设置有关。

第三种情况:若网络延迟较大,Dial 将阻塞并超时。

如果网络延迟较大,TCP 连接的建立过程(三次握手)将更加艰难坎坷,会经历各种丢包,时间消耗自然也会更长,这种情况下,Dial函数会阻塞。如果经过长时间阻塞后依旧无法建立连接,那么Dial也会返回类似getsockopt: operation timed out的错误。

在连接建立阶段,多数情况下Dial是可以满足需求的,即便是阻塞一小会儿也没事。但对于那些需要有严格的连接时间限定的 Go 应用,如果一定时间内没能成功建立连接,程序可能会需要执行一段“错误”处理逻辑,所以,这种情况下,我们使用DialTimeout函数更适合。

# 3.3.3 全双工通信

一旦客户端调用 Dial 成功,客户端与服务端之间就建立起一条全双工的通信通道。通信双方通过各自获得的 Socket,可以在向对方发送数据包的同时,接收来自对方的数据包。

系统层面对这条全双工通信通道的实现原理:

img

任何一方的操作系统,都会为已建立的连接分配一个发送缓冲区和一个接收缓冲区。

以客户端为例,客户端会通过成功连接服务端后得到的 conn(封装了底层的 socket)向服务端发送数据包。这些数据包会先进入到己方的发送缓冲区中,之后,这些数据会被操作系统内核通过网络设备和链路,发到服务端的接收缓冲区中,服务端程序再通过代表客户端连接的 conn 读取服务端接收缓冲区中的数据,并处理。反之,服务端发向客户端的数据包也是先后经过服务端的发送缓冲区、客户端的接收缓冲区,最终到达客户端的应用的。

# 3.3.4 Socket 读操作

Go 语言使用者只需采用 Goroutine+ 阻塞 I/O 模型,就可以满足大部分场景需求。Dial 连接成功后,会返回一个 net.Conn 接口类型的变量值,这个接口变量的底层类型为一个 *TCPConn:

//$GOROOT/src/net/tcpsock.go
type TCPConn struct {
    conn
}
1
2
3
4

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,那么无论后续的 Read 操作是否超时,只要我们不重新设置 Deadline,那么后面与这个 socket 有关的所有读操作,都会返回超时失败错误。

结合 SetReadDeadline 设置的服务端一般处理逻辑:

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]))
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

如果要取消超时设置,可以使用 SetReadDeadline(time.Time{})实现。

# 3.3.5 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)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

客户端每次调用 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
.... ...
*/
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51

第二种情况:写入部分数据。

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
1
2
3
4

显然,Write并不是在 655360 这个地方阻塞的,而是后续又写入 24108 个字节后发生了阻塞,服务端 Socket 关闭后,客户端又写入 24108 字节后,才返回的broken pipe错误。由于这 24108 字节数据并未真正被服务端接收到,程序需要考虑妥善处理这些数据,以防数据丢失。

第三种情况:写入超时。

如果非要给 Write 操作增加一个期限,可以调用 SetWriteDeadline 方法。

比如,改造客户端源码,在新客户端源码中的 Write 调用之前,增加一行超时时间设置代码:

conn.SetWriteDeadline(time.Now().Add(time.Microsecond * 10))
1

然后先后启动服务端与新客户端,可以看到写入超时的情况下,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
1
2
3
4
5
6
7
8
9

在 Write 方法写入超时时,依旧存在数据部分写入(仅写入 24108 个字节)的情况。另外,和 SetReadDeadline 一样,只要我们通过 SetWriteDeadline 设置了写超时,那无论后续 Write 方法是否成功,如果不重新设置写超时或取消写超时,后续对 Socket 的写操作都将以超时失败告终。

# 3.3.6 并发 Socket 读写

Goroutine 的网络编程模型,决定了存在着不同 Goroutine 间共享conn的情况。

从应用的角度上,并发 read 操作和 write 操作的 Goroutine 安全的必要性:

  • 对于 Read 操作而言,由于 TCP 是面向字节流,conn.Read无法正确区分数据的业务边界,因此,多个 Goroutine 对同一个 conn 进行 read 的意义不大,Goroutine 读到不完整的业务包,反倒增加了业务处理的难度。

    每次 Read 操作都是有 lock 保护的。多个 Goroutine 对同一conn的并发读,不会出现读出内容重叠的情况,但是一旦采用了不恰当长度的切片作为 buf,很可能读出不完整的业务包,这反倒会带来业务上的处理难度。比如一个完整数据包:world,当 Goroutine 的读缓冲区长度 < 5 时,就存在这样一种可能:一个 Goroutine 读出了“worl”,而另外一个 Goroutine 读出了"d"。

  • 对于 Write 操作而言,是有多个 Goroutine 并发写的情况。

    每次 Write 操作都是受 lock 保护,直到这次数据全部写完才会解锁。因此,在应用层面,要想保证多个 Goroutine 在一个conn上 write 操作是安全的,需要一次 write 操作完整地写入一个“业务包”。一旦将业务包的写入拆分为多次 write,那也无法保证某个 Goroutine 的某“业务包”数据在conn发送的连续性。

# 3.3.7 Socket 关闭

通常情况下,当客户端需要断开与服务端的连接时,客户端会调用 net.Conn 的 Close 方法关闭与服务端通信的 Socket。

客户端主动关闭了 Socket,服务端的Read调用要区分“有数据关闭”和“无数据关闭”两种情况:

  • “有数据关闭”是指在客户端关闭连接(Socket)时,Socket 中还有服务端尚未读取的数据。在这种情况下,服务端的 Read 会成功将剩余数据读取出来,最后一次 Read 操作将得到io.EOF错误码,表示客户端已经断开了连接。
  • “无数据关闭”情形下,服务端调用的 Read 方法将直接返回io.EOF。不过因为 Socket 是全双工的,客户端关闭 Socket 后,如果服务端 Socket 尚未关闭,这个时候服务端向 Socket 的写入操作依然可能会成功,因为数据会成功写入己方的内核 socket 缓冲区中,即便最终发不到对方 socket 缓冲区也会这样。因此,当发现对方 socket 关闭后,己方应该正确合理处理自己的 socket,再继续 write 已经没有任何意义了。
上次更新: 2022/06/12, 15:48:09
理解问题
设计与实现

← 理解问题 设计与实现→

最近更新
01
ctr和crictl显示镜像不一致
03-13
02
alpine镜像集成常用数据库客户端
03-13
03
create-cluster
02-26
更多文章>
Theme by Vdoing | Copyright © 2015-2024 op81.com
苏ICP备18041258号-2
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式