目录

Linux-34 socket API 与 Go 网络编程:一个 fd 如何代表一条连接

目录

上一篇沿着收包路径,从网卡 DMA、NAPI、sk_buff 一直走到 socket 接收队列。这一篇站到应用侧:为什么一个普通整数 fd 能代表一条跨机器的 TCP 连接,而 ReadWriteClose 几个看似简单的调用背后却有如此多状态?

socket API 的成功之处在于把网络通信纳入 Unix 文件描述符模型:应用可以用类似文件的接口读写连接,又通过地址、协议和 option 表达网络特性。它的危险也来自这种简洁——TCP 是全双工字节流,不是文件、消息队列或一次请求;write 成功不代表对端处理,read 返回短数据不代表一个业务消息结束,close 也不总是“双方立刻消失”。

先看五个问题:

  1. socket() 创建的 fd 指向什么?为什么 socket 可以被 read/write、epoll、dup 和 fork 共同操作?
  2. bindlistenacceptconnect 分别改变哪一段内核状态?为什么 accept 返回一个新 fd?
  3. TCP 为什么必然出现短读、短写、粘包、半关闭与 reset?应用如何在字节流上恢复消息边界?
  4. Go 的 net.Conn 看起来阻塞,runtime 如何借助 nonblocking fd、netpoll 和 deadline 保持 goroutine 可扩展?
  5. SO_REUSEADDRSO_REUSEPORT、keepalive、buffer、TCP_NODELAY 各解决什么,为什么不存在一组通用“高性能参数”?

1. socket 为什么也是文件描述符

1.1 socket() 先创建一个内核通信端点

典型 TCP 服务端从下面的调用开始:

int fd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0);

参数分别表达:

AF_INET       -> IPv4 地址族
SOCK_STREAM   -> 有序可靠字节流语义
IPPROTO_TCP   -> 通常由前两者推导为 TCP
NONBLOCK      -> 调用不能立即完成时返回 EAGAIN/EINPROGRESS
CLOEXEC       -> exec 新程序时不要意外继承 fd

返回的整数只是当前进程 fd table 的下标。它指向一个 open file description,再关联内核 struct socket、协议层 struct sock 以及 TCP 控制块等状态。

process fd table
  fd 7
    -> struct file
       -> struct socket
          -> struct sock / TCP state
             local address
             peer address
             send/receive queues
             sequence/window/timers
             wait queue and callbacks

因此 socket 能复用 Unix fd 的引用计数、权限边界、poll、dup、fork 和 close 模型,同时由 socket 层提供地址与协议操作。

1.2 fd 是能力句柄,不是连接永久身份

拿到 fd 的进程就拥有对该打开对象执行相应操作的能力。dup 后多个 fd 可指向同一个 socket;fork 后父子进程也可能共同持有它。只有最后一个引用关闭,底层对象才进入真正释放流程。

反过来,整数 fd 会快速复用:

fd 7 -> connection A
close(7)
accept() -> fd 7 -> connection B

若异步回调只保存整数 7,延迟事件可能误作用到 B。成熟服务器会让连接对象包含 generation、所有权或引用生命周期,并让关闭操作在负责该连接的 event loop 中串行完成。

1.3 socket 与普通文件相同和不同的地方

共同点:

  • 都由 fd 引用,可 read/write/close
  • 都能设置 O_NONBLOCKFD_CLOEXEC
  • 都可被 epoll/poll 观察
  • 都有内核对象、等待队列和引用计数

差异:

  • TCP 没有可任意 seek 的文件偏移
  • 连接有远端状态、拥塞控制、重传和超时
  • read==0 表示对端发送方向 EOF,不等于本端不能继续写
  • write 只把字节交给本机协议栈,不表示远端应用已消费
  • 错误可能在早先异步网络事件后,延迟到下一次调用才报告

“everything is a file”是统一接口的抽象,不是说所有 fd 都有磁盘文件语义。


2. 服务端状态机:bind、listen、accept

2.1 bind:声明本地地址,不是开始接客

struct sockaddr_in addr = {
    .sin_family = AF_INET,
    .sin_port = htons(8080),
    .sin_addr.s_addr = htonl(INADDR_ANY),
};
bind(fd, (struct sockaddr *)&addr, sizeof(addr));

bind 将 socket 与本地地址/端口建立关系。0.0.0.0:8080 表示匹配本 network namespace 中适用的所有本地 IPv4 地址,并不是一个可以路由到的真实远端地址。

绑定可能失败:

EADDRINUSE      端口/地址与已有 socket 冲突
EADDRNOTAVAIL   地址不属于当前 namespace/interface
EACCES          低端口权限或安全策略限制

不显式 bind 的客户端在 connect 时由内核选择源地址和临时端口。服务端通常显式 bind,才能提供稳定入口。

2.2 listen:从主动 socket 变为监听 socket

listen(fd, 4096);

listen 让流式 socket 进入被动监听状态,并建立 TCP 建连所需的数据结构。上一篇已区分 SYN 请求队列和完成握手后等待应用的 accept queue。backlog 影响后者容量,但最终值还受 net.core.somaxconn、内核实现和语言 runtime 截断规则影响。

listen 成功只表示内核可接收连接请求,不代表应用已经拿到任何连接;监听 fd 不承载某个客户端的业务字节。

2.3 accept 为什么返回新 fd

int cfd = accept4(lfd, NULL, NULL, SOCK_NONBLOCK | SOCK_CLOEXEC);

监听 socket 必须持续存在并接受后续客户端。每个已建立 TCP 连接又需要独立四元组、序号、窗口、缓冲和错误状态,所以 accept 从完成队列取出一个子 socket,并给它分配新 fd:

listen fd 3: 0.0.0.0:8080
  |
  +-- accept -> fd 8: 10.0.0.10:8080 <-> 10.0.0.21:52100
  +-- accept -> fd 9: 10.0.0.10:8080 <-> 10.0.0.22:43812

关闭 fd 8 不影响监听 fd 3 或 fd 9。关闭监听 fd 会停止接收新连接,但已 accept 的连接仍由各自引用和状态决定。

2.4 非阻塞 accept 必须处理一组结果

for (;;) {
    int cfd = accept4(lfd, NULL, NULL, SOCK_NONBLOCK | SOCK_CLOEXEC);
    if (cfd >= 0) {
        register_connection(cfd);
        continue;
    }
    if (errno == EINTR)
        continue;
    if (errno == EAGAIN || errno == EWOULDBLOCK)
        break;
    if (errno == EMFILE || errno == ENFILE) {
        enter_overload_mode();
        break;
    }
    log_accept_error(errno);
    break;
}

ET 模式要 drain 到 EAGAIN;LT 模式也常批量 accept 以摊销事件成本。EMFILE 是进程 fd 用尽,ENFILE 是系统打开文件表压力。它们不是普通瞬时网络错误:监听 fd 可能持续可读,若立即重试会形成错误忙循环。


3. 客户端 connect:阻塞完成与异步完成

3.1 connect 会隐式选择本地端点

connect(fd, (struct sockaddr *)&server, sizeof(server));

若 socket 尚未 bind,内核依据路由选择源 IP,并从临时端口范围选源端口,然后发 SYN、等待握手。阻塞 socket 通常等到连接成功、超时或失败;非阻塞 socket 常立即返回 -1/EINPROGRESS

nonblocking connect
  -> EINPROGRESS
  -> epoll 等 EPOLLOUT/ERR
  -> getsockopt(SOL_SOCKET, SO_ERROR)
       0          成功
       ECONNREFUSED / ETIMEDOUT / ... 失败

“fd 可写”不等于 connect 一定成功;失败也会令等待结束。必须读取 SO_ERROR 得到最终结果。

3.2 为什么不能只靠应用 sleep 后重试

TCP 自身有 SYN 重传和超时策略。应用还应有业务 deadline,因为内核默认超时可能远长于请求可接受延迟。连接超时与 DNS、TLS handshake、请求响应超时应分开统计:它们发生在不同阶段,统一报“timeout”会让排障失去方向。

DNS deadline
  -> TCP connect deadline
     -> TLS handshake deadline
        -> request write deadline
           -> response header/body deadline

总 deadline 可以约束整个操作,但可观测性应记录具体阶段。

3.3 临时端口为什么会耗尽

客户端连接由四元组区分。对固定目标大量新建短连接时,本地源 IP 和临时端口是有限组合;加上 TIME_WAIT,可能找不到可用端口,出现 EADDRNOTAVAIL 等失败。

一个源 IP -> 一个固定目标 IP:port
可用源端口数量有限
连接创建速率很高 + 生命周期/TIME_WAIT 较长
  -> 临时端口压力

优先修复连接复用:HTTP keep-alive、连接池、HTTP/2 多路复用;必要时再做多源 IP、代理/负载均衡架构和受验证的端口范围规划。盲目缩短 TIME_WAIT 是把协议安全边界换成偶发故障。


4. TCP 是字节流:短读、粘包与消息边界

4.1 一次 Write 不对应一次 Read

发送方:

conn.Write([]byte("hello"))
conn.Write([]byte("world"))

接收方可能看到:

Read 1 -> "hell"
Read 2 -> "oworld"


Read 1 -> "helloworld"

或更多其他切分

TCP 保证字节有序可靠,不保存应用 Write 调用边界。网卡 MTU、TCP 分段、GSO/GRO、拥塞窗口、发送缓冲、接收时机都会改变 chunk。所谓“粘包”不是 TCP 把包错误粘住,而是应用没有定义字节流上的消息 framing。

4.2 应用协议必须自己 framing

常见方式:

固定长度:每条记录恰好 N 字节
分隔符:HTTP/1 header 行使用 CRLF
长度前缀:[4-byte length][payload]
自描述协议:能从语法确定消息结尾
连接关闭:某些简单协议用 EOF 表示 body 结束

长度前缀解析必须限制最大值,否则攻击者或错误客户端可声明超大长度,诱导内存分配或长期等待。

var n uint32
if err := binary.Read(conn, binary.BigEndian, &n); err != nil {
    return err
}
if n > 8<<20 {
    return errors.New("frame too large")
}
buf := make([]byte, n)
_, err := io.ReadFull(conn, buf)

io.ReadFull 的意义是循环读取到指定长度或返回明确错误;不能假设一次 Read(buf) 填满 buf。

4.3 短写也必须处理

POSIX write/send 可成功写入少于请求长度,尤其在非阻塞 socket、信号、资源压力或自定义 wrapper 下。正确发送要维护剩余区间:

while (off < len) {
    ssize_t n = send(fd, buf + off, len - off, MSG_NOSIGNAL);
    if (n > 0) { off += n; continue; }
    if (n < 0 && errno == EINTR) continue;
    if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
        queue_remaining(buf + off, len - off);
        enable_epollout(fd);
        break;
    }
    fail_connection();
    break;
}

Go 的 net.Conn.Write 契约通常在 n < len(p) 时返回非 nil error,但调用者仍应按接口检查 nerr。更高层可用 io.Copy/io.WriteString,也不能忽略返回错误。


5. 关闭不是一个动作:FIN、RST 与半关闭

5.1 EOF 只关闭一个方向

TCP 全双工连接有两个独立方向。对端发送 FIN 表示“以后不再发送字节”,本端 Read 在读完队列后返回 0, EOF;本端仍可能继续 Write 响应。

client -- request bytes + FIN --> server
client <-- response bytes ------ server

这适合“发送完请求后关闭写方向,但继续等响应”的协议。Unix 可用 shutdown(fd, SHUT_WR);Go *net.TCPConn 提供 CloseWriteCloseRead

5.2 close、shutdown 与 linger

  • close(fd):释放当前 fd 引用;最后一个引用关闭时启动协议关闭语义
  • shutdown(SHUT_WR):发送方向不再接受新数据,通常触发 FIN
  • shutdown(SHUT_RD):本地停止接收语义,具体未读数据处理需谨慎
  • SO_LINGER:改变 close 在未发送数据存在时的行为,错误设置可能阻塞或产生 RST

应用层“优雅关闭”通常不是只调用 close:先停止新请求,等待在途请求,按协议发送响应/GOAWAY,设置上限时间,最终才关闭连接。网络对端不配合时必须有 deadline,否则 shutdown 可以无限拖延。

5.3 RST 表达异常终止

RST 表示连接状态无法继续,例如向不存在的端口连接、对端已无对应 socket、某些 abortive close,或在一方认为连接有效时另一方已丢失状态。应用可能看到:

ECONNRESET:读取时发现对端 reset
EPIPE:向已关闭方向写
SIGPIPE:传统 Unix 对 EPIPE 写入还可能发信号

Go runtime 通常把它们转为 error;错误文本还可能被 net.OpError 包装。不能依赖字符串比较,应使用 errors.Iserrors.Asnet.Error 及平台 errno 语义分类。

5.4 未读数据时粗暴关闭的风险

若应用在接收缓冲仍有对端数据时异常关闭,内核可能用 RST 告诉对端这些数据未被应用接受。对端此前成功 write 的字节仍可能最终得到 reset;这再次说明 write 成功只表示本地内核接收,不是远端业务 commit。

需要可靠业务确认的协议必须由应用返回 ACK/响应,并定义重试与幂等,不能把 TCP ACK 当作“订单已写数据库”。


6. 阻塞、非阻塞与 deadline 的真实关系

6.1 阻塞是线程语义,不是协议语义

阻塞 socket 的 read 在没有数据时让调用线程睡眠;非阻塞 socket 返回 EAGAIN。二者收到的 TCP 字节、重传和顺序保证相同,区别是等待责任在哪里。

blocking:内核把当前线程挂到 socket wait queue
nonblocking + epoll:应用事件循环统一等待很多 socket
io_uring:应用先提交具体请求,再等 completion

Go 对应用提供阻塞风格,却在 runtime 内部使用 nonblocking fd + netpoll,因此阻塞一个 goroutine 通常不会阻塞一个内核线程。

6.2 超时与 deadline 不一样

“每次操作最多 5 秒”与“整个请求必须在绝对时刻前结束”不同。若每轮读取都重置 5 秒,慢客户端可以每 4.9 秒发一个字节,永久占住连接。绝对 deadline 能限制总时间:

deadline := time.Now().Add(10 * time.Second)
if err := conn.SetDeadline(deadline); err != nil {
    return err
}

SetDeadline 同时影响读写;SetReadDeadlineSetWriteDeadline 分别控制方向。deadline 是 fd 状态,会影响当前和后续操作,成功后不会自动清除:

conn.SetReadDeadline(time.Now().Add(5 * time.Second))
// 完成带时限阶段后清除
conn.SetReadDeadline(time.Time{})

6.3 deadline 如何在 Go runtime 中生效

Go 不需要为每个连接创建一个睡眠线程。runtime 的 poll descriptor 关联读/写等待 goroutine和定时器:

G 调 Read -> recv 返回 EAGAIN -> G park
  |
  +-- fd readiness 先到 -> netpoll 唤醒 -> 重试 recv
  |
  +-- deadline 先到 -> timer 唤醒 -> 返回 timeout error

readiness 与 timer 可能竞争,runtime 必须防止同一 waiter 被错误重复唤醒,并识别 deadline generation。应用层同样要接受边界竞态:超时发生时,部分字节可能已经读写,返回的 n 不能忽略。

6.4 context 不会自动中断所有 Conn 操作

普通 net.Conn.Read 不接收 context.Context。取消 context 只有在调用栈显式观察它、设置 deadline 或关闭连接时才能中断阻塞操作。http.Request.Context() 由 HTTP 栈绑定连接生命周期,但自定义协议必须自己设计:

func readWithContext(ctx context.Context, c net.Conn, p []byte) (int, error) {
    done := make(chan struct{})
    defer close(done)

    go func() {
        select {
        case <-ctx.Done():
            _ = c.SetReadDeadline(time.Now())
        case <-done:
        }
    }()
    return c.Read(p)
}

这个示例展示机制,但每次 Read 创建 goroutine 并不适合高频生产代码。更常见做法是协议/请求级统一设置 deadline,或由 connection owner 在取消时关闭连接。


7. Go net 包怎样映射 socket 生命周期

7.1 服务端:Listen 与 Accept

ln, err := net.Listen("tcp", ":8080")
if err != nil {
    return err
}
defer ln.Close()

for {
    conn, err := ln.Accept()
    if err != nil {
        if errors.Is(err, net.ErrClosed) {
            return nil
        }
        log.Printf("accept: %v", err)
        continue
    }
    go handle(conn)
}

net.Listen 完成 socket、必要 option、bind 和 listen;Accept 返回新的 net.Conn。runtime 在 Linux 上将 fd 纳入 netpoll。API 简洁不代表资源无限:每连接 goroutine 很轻,但 fd、socket buffer、TLS 状态、业务队列和栈仍占内存。

7.2 防止连接与 goroutine 泄漏

handler 必须明确所有退出路径:

func handle(c net.Conn) {
    defer c.Close()
    _ = c.SetDeadline(time.Now().Add(30 * time.Second))

    r := bufio.NewReader(c)
    for {
        line, err := r.ReadString('\n')
        if err != nil {
            if !errors.Is(err, io.EOF) {
                log.Printf("read: %v", err)
            }
            return
        }
        if len(line) > 64<<10 {
            return
        }
        if _, err := io.WriteString(c, "ok\n"); err != nil {
            return
        }
    }
}

注意这个示例的 ReadString 仍可能在遇到分隔符前积累数据;严肃协议应使用有上限的 framing reader,而不只是读完后检查长度。deadline 应按协议阶段更新,不能机械给长连接设置固定 30 秒总寿命。

7.3 net.Dialer 比裸 net.DialTimeout 更可控

d := net.Dialer{
    Timeout:   3 * time.Second,
    KeepAlive: 30 * time.Second,
}
conn, err := d.DialContext(ctx, "tcp", "example.com:443")

DialContext 让 DNS/候选地址/connect 与 context 共同受控,但具体阶段和并行地址策略由 Go 版本与 resolver 决定。生产观测要区分 DNS、connect、TLS、首字节,不能只统计整个 Dial。

连接池还必须设置:最大空闲数、每目标最大连接、空闲超时、连接寿命和等待队列。无限建连不是“没有池配置”的中性默认,而是把背压转成端口、fd 和下游过载。

7.4 SyscallConn 是逃生口,不是常规路径

Go 提供 TCPConn.SyscallConn() 在受控回调中访问底层 fd,以设置标准库未暴露的 option。不能调用 File() 拿 fd 后随意和 net.Conn 并发操作:dup、blocking mode、runtime poll ownership 与 close 生命周期会变复杂。

raw, err := tcpConn.SyscallConn()
if err != nil { return err }
err = raw.Control(func(fd uintptr) {
    // 回调内调用平台 setsockopt;只做不会阻塞的控制操作
})

平台相关 option 应封装在带 build tag 的小范围代码中,并用故障测试验证,而不是让业务代码依赖 fd 整数。


8. Socket option:每个开关都在交换东西

8.1 SO_REUSEADDR 与 SO_REUSEPORT 不是同义词

SO_REUSEADDR 常用于服务重启后重新绑定本地地址,但具体冲突规则受操作系统、socket 状态与绑定地址影响;它不是让任意两个活跃服务安全监听同一地址。

SO_REUSEPORT 允许多个监听 socket 绑定同一地址/端口,由内核将新流分配给其中一个,常用于每 worker 独立 listener、降低共享锁与改善 CPU 局部性。代价是负载分布、滚动升级和一致性更复杂。

REUSEADDR:主要改变地址复用/重绑判定
REUSEPORT:建立一组并行监听 socket 做流量分配

不同平台语义不完全相同,跨平台程序不能照搬 Linux 假设。

8.2 TCP_NODELAY:延迟与小包效率的取舍

Nagle 算法试图在存在未确认小数据时合并后续小写,减少 tiny packet。TCP_NODELAY 禁用这种等待,使交互式小消息更快发出,但可能增加包率、header 开销和拥塞。

它也不是所有延迟的根源:应用缓冲、TLS record、调度、Delayed ACK、拥塞和下游处理都可能更重要。Go TCP 连接的默认行为和版本需查对应文档;优化前应用级 trace 应证明延迟卡在发送聚合而非别处。

8.3 SO_KEEPALIVE 不是请求 deadline

TCP keepalive 在连接长时间空闲后发送探测,以发现断网、NAT 状态丢失或对端崩溃。其默认 idle、interval、count 常很长,适合清理失效连接,不适合保证“请求 2 秒内返回”。

业务 deadline:限制一次请求/阶段能等多久
TCP keepalive:探测长期空闲连接是否仍存活
应用 heartbeat:还能验证协议进程是否健康及携带业务状态

三者目的不同,可同时存在。

8.4 SO_RCVBUF/SO_SNDBUF 不是越大越好

更大 socket buffer 可吸收 bandwidth-delay product 和瞬时调度抖动,提高长肥网络吞吐;也会让每连接潜在内存、排队延迟和慢消费者积压增加。Linux 还有 autotuning、上限和记账规则,getsockopt 看到的值可能与用户设置呈现不同关系。

调优前先看:带宽、RTT、拥塞窗口、应用读写速率、内存压力和连接数量。十万连接每条多占几百 KB,远比单连接 benchmark 的收益昂贵。

8.5 option 的设置时机也有语义

有些 option 要在 bind/listen 前设置,有些作用于监听 socket并可能被子 socket 继承,有些必须对 accept 后的连接设置。错误时机可能导致无效、只影响新连接或直接报错。应使用最小实验和 getsockopt 验证当前内核/语言版本,而不是假定配置文件出现该名字就已生效。


9. UDP 反例:同一个 socket API,不同传输语义

9.1 UDP 保留 datagram 边界

int fd = socket(AF_INET, SOCK_DGRAM, 0);
recvfrom(fd, buf, sizeof(buf), 0, &peer, &peerlen);

UDP 的一次发送对应一个 datagram;接收 buffer 太小时,多余部分可能被截断,而不是下一次继续读取。因此 TCP 的 io.ReadFull framing 方法不能原样用于 UDP。

UDP 无连接握手不等于没有状态:connect 一个 UDP socket 可固定默认 peer、过滤接收来源并让错误报告更直接,但不会建立 TCP 式可靠通道。

9.2 UDP send 成功更弱

sendto 成功只表示本机接受 datagram,不能保证网络交付、顺序、唯一性或远端监听。ICMP 错误也可能异步出现或不出现。需要可靠性的协议必须在应用/上层协议实现重传、序号、拥塞控制和认证;QUIC 正是在 UDP 上构建这些机制,而不是“UDP 天生更快且无代价”。

9.3 Go PacketConn 的并发边界

Go 的 net.PacketConnReadFrom/WriteTo 保留报文与地址。多个 goroutine 可依据接口文档并发调用,但应用仍需处理每报文 buffer 生命周期、最大 datagram、来源验证、放大攻击和队列过载。无连接服务器不能因为没有 accept 就无限接收来源。


10. 错误处理:不要把所有网络失败都重试

10.1 错误必须结合操作阶段

相同 errno 在不同阶段的意义不同:

阶段 典型错误 常见含义
bind EADDRINUSE 地址冲突或复用规则不满足
connect ECONNREFUSED 目标主动拒绝/无监听
connect ETIMEDOUT 握手或网络路径未在期限内完成
accept EMFILE 进程 fd 耗尽
read EOF 对端发送方向正常关闭
read ECONNRESET 对端/路径异常 reset
write EPIPE 对端已关闭相关方向
nonblocking IO EAGAIN 当前暂不可完成,不是永久失败

重试策略必须区分瞬时资源、永久配置、过载和未知结果。比如 POST 写到一半连接 reset,客户端无法仅凭 TCP error 知道服务端是否已提交;自动重试会重复业务,必须依赖幂等 key 或事务查询。

10.2 Go 中保留错误链

n, err := conn.Read(buf)
if err != nil {
    if errors.Is(err, os.ErrDeadlineExceeded) {
        // deadline
    }
    var ne net.Error
    if errors.As(err, &ne) && ne.Timeout() {
        // 网络超时类别;仍应记录操作阶段
    }
    var op *net.OpError
    if errors.As(err, &op) {
        log.Printf("op=%s net=%s source=%v addr=%v err=%v",
            op.Op, op.Net, op.Source, op.Addr, op.Err)
    }
}
_ = n // err 非 nil 时 n 仍可能大于 0

不要只比较 err.Error();文本可因 OS、Go 版本和包装层变化。日志应记录方向、local/remote address、阶段、deadline、已传字节和连接 ID,同时避免泄露敏感 payload。

10.3 重试要有预算、退避和抖动

立即无限重试会把一次下游故障放大成连接风暴:

下游变慢
  -> 请求超时
  -> 所有客户端立即重连重试
  -> SYN/accept/handler 队列更满
  -> 下游更慢

正确策略包括有限次数、指数退避、随机 jitter、全局 retry budget、熔断/并发限制,并且只重试语义安全或具备幂等保护的操作。


11. 线上排查:从状态与所有权入手

11.1 先看 socket 状态分布

# 汇总 TCP 状态
ss -s

# 查看服务端口监听、队列与进程
ss -ltnp '( sport = :8080 )'

# 查看已建立连接的队列与 TCP 信息
ss -tinp state established '( sport = :8080 )'

# 各状态数量
ss -tan | awk 'NR>1 {count[$1]++} END {for (s in count) print s, count[s]}'

Recv-Q/Send-Q 对监听 socket 和已连接 socket 含义不同。已建立连接上持续增大的 Recv-Q 常表示应用没及时读,Send-Q 积压可能是对端慢、网络拥塞或应用写入超过发送能力。监听 socket 则要结合 backlog 和内核版本解释。

11.2 fd 多不等于一定泄漏

ls /proc/"$PID"/fd | wc -l
cat /proc/"$PID"/limits | grep -i 'open files'
lsof -nP -p "$PID" | grep -E 'TCP|UDP'

长连接服务本来就有大量 fd。泄漏的证据是:连接业务生命周期结束后 fd/连接对象不回落、状态分布异常、goroutine 同步增长、引用 owner 找不到。应结合连接建立/关闭计数,而不是看到 5 万 fd 就报警。

11.3 端口耗尽与 TIME_WAIT

cat /proc/sys/net/ipv4/ip_local_port_range
ss -tan state time-wait | wc -l
ss -tan state syn-sent | wc -l

# 按目标聚合连接,定位是否对单一 upstream 高频建连
ss -Htan | awk '{print $5}' | sort | uniq -c | sort -nr | head

命令输出列受 ss 版本、IPv6 格式和选项影响,聚合脚本只适合作为线索。进一步应从应用连接池指标确认新建率、复用率、池等待和关闭原因。

11.4 syscall 与 Go 观测结合

# 短时查看网络调用与错误;strace 会扰动时序
sudo strace -ff -ttT -p "$PID" \
  -e trace=accept4,connect,recvfrom,sendto,shutdown,close

# CPU / goroutine / block 线索
curl -o cpu.pprof 'http://127.0.0.1:6060/debug/pprof/profile?seconds=30'
curl -o goroutines.txt 'http://127.0.0.1:6060/debug/pprof/goroutine?debug=2'
go tool pprof -http=:8081 cpu.pprof

Go runtime 可能使用 read/writerecvfrom/sendto 或其他变体,系统调用选择会随版本和协议变化。抓不到某个调用不代表没有网络 IO;先用概要跟踪确认实际路径,再缩小过滤范围。


12. 常用命令知识点扩展

短参 长参 英文全称 作用
ss -a ss --all all 显示监听和非监听 socket
ss -l ss --listening listening 只显示监听 socket
ss -n ss --numeric numeric 不解析主机名与服务名
ss -p ss --processes processes 显示 socket 所属进程
ss -i ss --info internal TCP information 显示拥塞、RTT 等 TCP 信息
lsof -n lsof -n no hostname conversion 不做主机名解析
lsof -P lsof -P no port conversion 端口保持数字显示
strace -T strace --syscall-times syscall times 显示系统调用耗时
strace -tt strace --absolute-timestamps timestamps 显示更精细时间戳

场景配方:

# 谁占用了 8080
ss -ltnp '( sport = :8080 )'

# 哪些进程持有某端口连接
lsof -nP -iTCP:8080

# 观察连接状态每秒变化
watch -n 1 'ss -s; ss -tan state syn-recv | wc -l'

13. 面试题

Q:socket fd 在内核中指向什么?

fd 是进程 fd table 的整数下标,指向 struct file,再关联 socket 抽象、协议层 struct sock 和 TCP 状态。它继承 fd 的引用计数、dup/fork、poll 和 close 能力;整数会复用,不能把 fd number 当永久连接身份,异步程序还需要 owner、generation 和安全回收协议。

Q:bind、listen、accept 分别做什么?

bind 把 socket 与本地地址/端口关联;listen 将流式 socket 变为被动监听端点并建立建连/完成队列;accept 从已完成握手队列取出一个子 socket,返回代表具体客户端连接的新 fd。监听 fd 继续接收新连接,子 fd 各自保存四元组和传输状态。

Q:非阻塞 connect 返回 EINPROGRESS 后如何判断结果?

把 fd 注册到 epoll 等待可写/错误;事件到达后调用 getsockopt(SOL_SOCKET, SO_ERROR)。返回 0 才表示握手成功,ECONNREFUSEDETIMEDOUT 等表示失败。可写仅表示 connect 已结束,不保证成功。

Q:为什么一次 TCP Write 不对应一次 Read?

TCP 提供有序可靠字节流,不保留应用调用边界。分段、合并、缓冲和调度可让两次 Write 被一次 Read 取走,也可让一次 Write 分多次 Read。协议必须用固定长度、分隔符、长度前缀或语法定义 framing,并限制消息最大长度。

Q:Read 返回 EOF 后还能 Write 吗?

可能可以。EOF 表示对端已用 FIN 关闭其发送方向,本端接收队列已读完;TCP 是全双工的,本端发送方向可能仍开放,可继续写剩余响应。是否应继续由应用协议决定。RST 或本地错误则可能使连接无法继续。

Q:write 成功为什么不等于远端业务成功?

write 通常只表示本机内核接受了字节;后面还有发送队列、网络、对端 TCP 接收、对端应用读取和业务事务。即使 TCP ACK 也只确认远端协议栈收到字节,不确认数据库提交。业务需要应用级响应、请求 ID、幂等和查询/恢复协议。

Q:Go net.Conn 看起来阻塞,为什么不会一连接占一个线程?

runtime 把网络 fd 设为 nonblocking。Read/Write 遇到 EAGAIN 时 park 当前 goroutine,并由 netpoller 用 epoll 统一等待;fd ready 后对应 goroutine 被 goready 再次尝试 syscall。等待单位是 goroutine,不是固定内核线程,但 fd、buffer 和业务状态仍消耗资源。

Q:Go deadline 与 context cancel 有什么关系?

deadline 是 net.Conn 的读写状态,runtime timer 到期会唤醒等待者并令操作返回超时;context 只是取消信号,普通 Conn 方法不会自动观察它。调用方需要用 DialContext、高层 API,或在取消时设置 deadline/关闭连接。deadline 会影响当前和后续操作,阶段结束后要更新或清除。

Q:SO_KEEPALIVE 能替代请求超时吗?

不能。keepalive 用长周期探测空闲 TCP 连接是否仍存活;请求 deadline 限制一次业务操作可等待多久;应用 heartbeat 还能验证对端进程和协议状态。三者时间尺度和故障语义不同。

Q:TCP_NODELAY 是否应该总是开启?

它禁用 Nagle 对小写的等待,可能降低交互延迟,也可能增加小包率、CPU 和网络开销。应用缓冲、TLS、Delayed ACK、调度或拥塞也可能才是主因。应根据消息模式与 profile 测试,不能把它当通用高性能开关。

Q:SO_REUSEADDR 与 SO_REUSEPORT 的区别?

在 Linux 常见语义下,REUSEADDR 主要放宽地址冲突和重启重绑规则;REUSEPORT 允许多个独立 listener 绑定同一地址/端口,由内核分流新连接。后者可改善多 worker 局部性,但带来分布、升级和状态协调问题;精确规则还依平台和设置时机。

Q:客户端临时端口耗尽应优先怎么解决?

先减少对同一目标的短连接创建:使用连接池、keep-alive、HTTP/2/QUIC 多路复用,并限制并发与重试风暴;再评估多源 IP、代理分片和合理端口范围。要结合 TIME_WAIT、连接新建率和目标分布验证,不能先粗暴缩短 TCP 状态。

Q:如何判断慢连接发生在哪一侧?

看已连接 socket 的 Recv-Q/Send-Q、RTT/重传、TCP window、应用读写指标和 goroutine 状态。服务端 Recv-Q 增长通常说明服务未及时读;Send-Q 增长可能是对端未读、拥塞或本端写过快;两者都要与 CPU quota、handler/downstream 延迟及对端观测对齐,单个队列值不能独立定责。

Q:网络错误为什么不能全部自动重试?

错误可能发生在请求未发、部分发送、远端已执行但响应丢失等不同点;客户端仅凭 reset/timeout 无法知道业务是否提交。无限重试还会放大故障。只应对语义安全或有幂等 key 的操作,在有限预算、退避、jitter、并发限制下重试。


小结

  • socket fd 是进程能力句柄,背后关联 file、socket、协议控制块与收发队列;fd number 会复用,异步系统必须管理对象生命周期
  • bind 选择本地端点,listen 建立被动监听状态,accept 为每条完成握手的连接返回独立 fd;监听队列容量不能替代及时 accept
  • 非阻塞 connect 的 EINPROGRESS 不是失败,等待结束后必须读取 SO_ERROR;短连接高创建率还会遭遇临时端口与 TIME_WAIT 压力
  • TCP 是有序字节流,不保留 Write 边界;协议必须 framing,并正确处理短读、短写、长度上限和部分成功
  • FIN 只关闭一个方向,EOF 后可能仍可写;RST 表示异常终止,TCP 成功也不等于业务事务成功
  • Go 用 nonblocking fd、epoll、gopark/goready 提供阻塞风格 net.Conn;deadline 由 runtime timer 参与,context 本身不会自动中断普通 Conn
  • REUSEADDR、REUSEPORT、NODELAY、keepalive 和 socket buffer 都在交换启动语义、CPU、延迟、内存或故障检测,没有通用最优组合
  • TCP 与 UDP 共用 socket 抽象,却分别提供字节流与 datagram 语义,应用不能混用读取和可靠性假设
  • 排查应联合 socket 状态、队列、fd、端口、syscall、Go profile 与业务阶段,并为重试建立幂等、预算和退避

下一篇讲 虚拟网络:容器为什么能通 —— network namespace、veth pair、bridge、路由、NAT 与 conntrack 如何拼出容器网络,为什么容器里的 localhost 不是宿主机,以及一次容器出网和端口映射分别经过哪些内核对象。