技术预研与储备
# 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 是最常用的模型,应用线程与内核之间的交互行为模式:
在阻塞 I/O 模型下,当用户空间应用线程,向操作系统内核发起 I/O 请求后(一般为操作系统提供的 I/O 系列系统调用),内核会尝试执行这个 I/O 操作,并等所有数据就绪后,将数据从内核空间拷贝到用户空间,最后系统调用从内核空间返回。而在这个期间内,用户空间应用线程将阻塞在这个 I/O 系统调用上,无法进行后续处理,只能等待。
因此,在这样的模型下,一个线程仅能处理一个网络连接上的数据通信。即便连接上没有数据,线程也只能阻塞在对 Socket 的读操作上(以等待对端的数据)。虽然这个模型对应用整体来说是低效的,但对开发人员来说,这个模型却是最容易实现和使用的,所以,各大平台在默认情况下都将 Socket 设置为阻塞的。
- 非阻塞 I/O(Non-Blocking I/O)
非阻塞 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等,以帮助开发者简化开发复杂性,降低心智负担。
# 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)
}
}
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)
2
Dial 函数向服务端发起 TCP 连接,这个函数会一直阻塞,直到连接成功或失败后,才会返回。
DialTimeout 带有超时机制,如果连接耗时大于超时时间,这个函数会返回超时错误。
对于客户端来说,连接的建立还可能会遇到几种特殊情形:
第一种情况:网络不可达或对方服务未启动。
如果传给Dial的服务端地址是网络不可达的,或者服务地址中端口对应的服务并没有启动,端口未被监听(Listen),Dial几乎会立即返回类似这样的错误:
dial error: dial tcp :8888: getsockopt: connection refused
第二种情况:对方服务的 listen backlog 队列满。
当对方服务器很忙,瞬间有大量客户端尝试向服务端建立连接时,服务端可能会出现 listen backlog 队列满,接收连接(accept)不及时的情况,这就会导致客户端的Dial调用阻塞,直到服务端进行一次 accept,从 backlog 队列中腾出一个槽位,客户端的 Dial 才会返回成功。
不同操作系统下 backlog 队列的长度是不同的:
macOS:
$sysctl -a|grep kern.ipc.somaxconn
kern.ipc.somaxconn: 128
2
Ubuntu Linux:backlog 队列的长度值与系统中net.ipv4.tcp_max_syn_backlog的设置有关。
极端情况下,如果服务端一直不执行accept操作,那么客户端不会一直阻塞。如果服务端运行在 macOS 下,那么客户端会阻塞大约 1 分多钟,才会返回超时错误:
dial error: dial tcp :8888: getsockopt: operation timed out
而如果服务端运行在 Ubuntu 上,客户端的Dial调用大约在 2 分多钟后提示超时错误,这个结果也和 Linux 的系统设置有关。
第三种情况:若网络延迟较大,Dial 将阻塞并超时。
如果网络延迟较大,TCP 连接的建立过程(三次握手)将更加艰难坎坷,会经历各种丢包,时间消耗自然也会更长,这种情况下,Dial函数会阻塞。如果经过长时间阻塞后依旧无法建立连接,那么Dial也会返回类似getsockopt: operation timed out的错误。
在连接建立阶段,多数情况下Dial是可以满足需求的,即便是阻塞一小会儿也没事。但对于那些需要有严格的连接时间限定的 Go 应用,如果一定时间内没能成功建立连接,程序可能会需要执行一段“错误”处理逻辑,所以,这种情况下,我们使用DialTimeout函数更适合。
# 3.3.3 全双工通信
一旦客户端调用 Dial 成功,客户端与服务端之间就建立起一条全双工的通信通道。通信双方通过各自获得的 Socket,可以在向对方发送数据包的同时,接收来自对方的数据包。
系统层面对这条全双工通信通道的实现原理:
任何一方的操作系统,都会为已建立的连接分配一个发送缓冲区和一个接收缓冲区。
以客户端为例,客户端会通过成功连接服务端后得到的 conn(封装了底层的 socket)向服务端发送数据包。这些数据包会先进入到己方的发送缓冲区中,之后,这些数据会被操作系统内核通过网络设备和链路,发到服务端的接收缓冲区中,服务端程序再通过代表客户端连接的 conn 读取服务端接收缓冲区中的数据,并处理。反之,服务端发向客户端的数据包也是先后经过服务端的发送缓冲区、客户端的接收缓冲区,最终到达客户端的应用的。
# 3.3.4 Socket 读操作
Go 语言使用者只需采用 Goroutine+ 阻塞 I/O 模型,就可以满足大部分场景需求。Dial 连接成功后,会返回一个 net.Conn 接口类型的变量值,这个接口变量的底层类型为一个 *TCPConn:
//$GOROOT/src/net/tcpsock.go
type TCPConn struct {
conn
}
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]))
}
}
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)
}
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
.... ...
*/
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
2
3
4
显然,Write并不是在 655360 这个地方阻塞的,而是后续又写入 24108 个字节后发生了阻塞,服务端 Socket 关闭后,客户端又写入 24108 字节后,才返回的broken pipe错误。由于这 24108 字节数据并未真正被服务端接收到,程序需要考虑妥善处理这些数据,以防数据丢失。
第三种情况:写入超时。
如果非要给 Write 操作增加一个期限,可以调用 SetWriteDeadline 方法。
比如,改造客户端源码,在新客户端源码中的 Write 调用之前,增加一行超时时间设置代码:
conn.SetWriteDeadline(time.Now().Add(time.Microsecond * 10))
然后先后启动服务端与新客户端,可以看到写入超时的情况下,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
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 已经没有任何意义了。