V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
Mohanson
V2EX  ›  程序员

TCP 小知识: 假如服务端不调用 Accept() 会发生什么?

  •  1
     
  •   Mohanson · 2020-03-30 23:40:17 +08:00 · 3673 次点击
    这是一个创建于 1700 天前的主题,其中的信息可能已经有所发展或是发生改变。

    其实是我调式了 N 久的一个 BUG, 最后发现这原来是 TCP 的 Feature. 文章为我转我自己, 原文链接在底部.

    Socket: 假如服务端不调用 Accept?

    我相信绝大多数人都会写 TCP 的服务端代码, 就自己而言, 已经几乎机械式地在写如下代码(就如定式一般):

    ln, err := net.Listen("tcp", ":3000")
    for {
        conn, err := ln.Accept()
        ...
    }
    

    Good! conn 对象到手! 之后便可以安心地从 conn 对象中读取数据, 或写入数据.

    但是有没有考虑过一个问题, 如果在 Listen 后不调用 Accept, 会发生什么事? 这并非是无事找事的异想天开, 在现实中, 有很多种情况会导致代码 Accept 失败, 比如 too many open files 发生时.

    实验开始

    这是本次实验的服务端伪代码, 可以看到, 在 Listen 端口后, 代码只使用了一个循环 Sleep 将进程永久挂起.

    func main() {
    	listen, err := net.Listen("tcp", ":3000")
    	for {
    		time.Sleep(time.Second)
    	}
    }
    

    客户端伪代码主要执行三个步骤: 连接服务器, 等待 10 秒后向服务器发送数据, 关闭连接.

    func main() {
      conn, err := net.Dial("tcp", "127.0.0.1:3000")
      log.Println("Dial conn", conn, err)
    
      time.Sleep(time.Second * 10)
      n, err := io.WriteString(conn, "ping")
      log.Println("Write", n, "bytes,", "error is", err)
    
      err := conn.Close()
      log.Println("Close", err)
    }
    

    如此这般, 执行程序!

    2020/03/30 17:57:45 Dial conn &{{0xc0000a2080}}
    2020/03/30 17:57:45 Write 4 bytes, error is <nil>
    2020/03/30 17:57:45 Close <nil>
    

    客户端连接服务器成功未报错, 发送数据成功未报错, 关闭连接成功亦未报错. 重新执行客户端代码, 这次让我们在执行的时候用 netstat 工具查看连接状态. 这里分为三个步骤.

    客户端连接到服务器后

    tcp        0      0 127.0.0.1:56428         127.0.0.1:8080          ESTABLISHED 18063/client
    tcp        0      0 127.0.0.1:8080          127.0.0.1:56428         ESTABLISHED -
    

    客户端调用 Close 后

    tcp        0      0 127.0.0.1:56428         127.0.0.1:8080          FIN_WAIT2   -
    tcp        5      0 127.0.0.1:8080          127.0.0.1:56428         CLOSE_WAIT  -
    

    客户端进程退出后

    tcp        5      0 127.0.0.1:8080          127.0.0.1:56428         CLOSE_WAIT  -
    

    注意最后的 CLOSE_WAIT, 它将永远存在, 直到服务端进程退出.

    原理分析

    当客户端连接服务端后, 通过 netstat 看到连接状态为 ESTABLISHED, 这说明 TCP 三次握手已经成功, 也就是说 TCP 连接已经在网络上建立了起来. 可得知 TCP 握手并不是 Accept 函数的职责.

    阅读操作系统的 Accept 函数文档: http://man7.org/linux/man-pages/man2/accept.2.html, 在第一段落中有如下描述:

    It extracts the first connection request on the queue of pending connections for the listening socket, sockfd, creates a new connected socket, and returns a new file descriptor referring to that socket.

    翻译: 它从 connections 队列中取出第一个 connection, 并返回引用该 connection 的一个新的文件描述符.

    验证了我的想法, 无论是否调用 Accept, connection 都已经建立起来了, Accept 只是将该 connection 包装成一个文件描述符, 供程序 Read, Write 和 Close. 那么关于第二步为什么客户端能 Write 成功就很容易解释了, 因为 connection 早已被建立(数据应该被暂存在服务端的接受缓冲区).

    接着再分析 CLOSE_WAIT. 正常情况下 CLOSE_WAIT 在 TCP 挥手过程中持续时间极短, 如果出现则表明"被动关闭 TCP 连接的一方未调用 Close 函数". 观察下图的 TCP 挥手过程, 得知"即使被动关闭一方未调用 Close, 依然会响应 FIN 包发出 ACK 包", 因此主动关闭一方处于 FIN_WAIT2 是理所当然的.

                                  +---------+ ---------\      active OPEN
                                  |  CLOSED |            \    -----------
                                  +---------+<---------\   \   create TCB
                                    |     ^              \   \  snd SYN
                       passive OPEN |     |   CLOSE        \   \
                       ------------ |     | ----------       \   \
                        create TCB  |     | delete TCB         \   \
                                    V     |                      \   \
                                  +---------+            CLOSE    |    \
                                  |  LISTEN |          ---------- |     |
                                  +---------+          delete TCB |     |
                       rcv SYN      |     |     SEND              |     |
                      -----------   |     |    -------            |     V
     +---------+      snd SYN,ACK  /       \   snd SYN          +---------+
     |         |<-----------------           ------------------>|         |
     |   SYN   |                    rcv SYN                     |   SYN   |
     |   RCVD  |<-----------------------------------------------|   SENT  |
     |         |                    snd ACK                     |         |
     |         |------------------           -------------------|         |
     +---------+   rcv ACK of SYN  \       /  rcv SYN,ACK       +---------+
       |           --------------   |     |   -----------
       |                  x         |     |     snd ACK
       |                            V     V
       |  CLOSE                   +---------+
       | -------                  |  ESTAB  |
       | snd FIN                  +---------+
       |                   CLOSE    |     |    rcv FIN
       V                  -------   |     |    -------
     +---------+          snd FIN  /       \   snd ACK          +---------+
     |  FIN    |<-----------------           ------------------>|  CLOSE  |
     | WAIT-1  |------------------                              |   WAIT  |
     +---------+          rcv FIN  \                            +---------+
       | rcv ACK of FIN   -------   |                            CLOSE  |
       | --------------   snd ACK   |                           ------- |
       V        x                   V                           snd FIN V
     +---------+                  +---------+                   +---------+
     |FINWAIT-2|                  | CLOSING |                   | LAST-ACK|
     +---------+                  +---------+                   +---------+
       |                rcv ACK of FIN |                 rcv ACK of FIN |
       |  rcv FIN       -------------- |    Timeout=2MSL -------------- |
       |  -------              x       V    ------------        x       V
        \ snd ACK                 +---------+delete TCB         +---------+
         ------------------------>|TIME WAIT|------------------>| CLOSED  |
                                  +---------+                   +---------+
    

    最后, 当客户端进程退出后, 客户端保留的 FIN_WAIT2 状态自然被释放, 但服务端由于未获得 connection 的文件描述符无法主动调用 Close 函数, 因此服务端的 CLOSE_WAIT 将一直持续直到服务端进程退出.

    如何处理该类型 CLOSE_WAIT?

    在本文的例子中, 服务端没有能力进行处理(代码中没有拿到 conn), 因为 connection 归操作系统管.

    但是如果程序是因为 too many open files 等错误导致 Accept 失败, 那么当操作系统的文件描述符数量下降时 Accept 函数将可以成功, 因此应用程序可以拿到引用该 connection 的文件描述符, 在程序代码中按照正常逻辑 Close 掉该文件描述符即可释放该 connection.

    原文: http://accu.cc/content/go/socket_not_accept/

    12 条回复    2020-04-01 18:41:24 +08:00
    123444a
        1
    123444a  
       2020-03-31 07:48:46 +08:00 via Android
    这有什么好看的。。。linus 看到写这种代码的,直接开启暴龙模式
    chashao
        2
    chashao  
       2020-03-31 08:26:52 +08:00 via iPhone
    学习了
    zxCoder
        3
    zxCoder  
       2020-03-31 08:41:01 +08:00
    这个流程图咋画的,手动调的吗
    no1xsyzy
        4
    no1xsyzy  
       2020-03-31 09:33:02 +08:00
    绝大多数人都会写 TCP 的服务端代码 [来源请求]
    Mohanson
        5
    Mohanson  
    OP
       2020-03-31 10:00:23 +08:00 via Android
    @zxCoder tcp rfc 拷贝过来的
    paoqi2048
        6
    paoqi2048  
       2020-03-31 11:17:21 +08:00
    画这图费了不少精力吧?
    tomychen
        7
    tomychen  
       2020-03-31 14:01:49 +08:00
    你的假设只是在假定在用封装过 socket()场景,对于裸写过 socket()的人而言这种假定不存在。
    tcp socket 没有 accept()后面的事情是无法操作的

    所以这么写服务端代码,回去重看 socket 吧
    Mohanson
        8
    Mohanson  
    OP
       2020-03-31 14:31:24 +08:00
    @tomychen

    我做这个实验的起因是 accept 失败: 也就是你说的 "tcp socket 没有 accept()后面的事情是无法操作的". 我正是探究了如果 accept 失败(或没有 accept, 等效的) TCP 的表现是如何的.

    希望你在回复之前先看明白我做这个实验的目的.
    tomychen
        9
    tomychen  
       2020-03-31 14:54:13 +08:00
    @Mohanson

    我说的回去重看 socket 的意思,就是你看完了,连实验都没有必要再做了,是这么一个意思。
    不要以为我说这段话的时候是带情绪的,然则没有。

    我说写过裸 socket 的意思也在这里

    socket 里,tcp 所有的操作都归到一个 sockfd,windows 里 handle 的一个东西上。

    因为原生的每一步操作都是操作都依赖于上一个函数,环环相扣,每一个操失误都会导致下步走不下去。

    我说重修 socket 的意思就是,过度依赖封装导致忽视应有的基础。

    当然,你要觉得我这是无聊嘴炮,就继续你的。
    icexin
        10
    icexin  
       2020-03-31 19:51:55 +08:00   ❤️ 1
    listen fd 是通过 socket 函数创建出来的,可以类比 net.Listen,用裸 socket 是可以复现题主的场景的。
    nightwitch
        11
    nightwitch  
       2020-03-31 23:04:52 +08:00
    标准 posix APi 里面的 listen 函数带有一个 backlog 的参数,这个参数可以指定,在 listen 之后,accept 之前,有多少个 client 可以排队连接到这个 socket(Linux 的默认值是 128),也就是处于你说的状态,服务端没有调用 accept 客户端就已经申请 connect 了。 不过我猜,在 server 调用 accept 之前,客户端对处于排队状态的 socket 进行写入操作可能属于未定义行为。
    julyclyde
        12
    julyclyde  
       2020-04-01 18:41:24 +08:00
    @nightwitch accept 之前不存在这些 socket 吧
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   5939 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 26ms · UTC 02:58 · PVG 10:58 · LAX 18:58 · JFK 21:58
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.