目录

网络-20 Socket 编程与 IO 模型

前置阅读:网络-07 TCP 连接管理 本文讲 Socket 这套 API 本身,以及它和 TCP 状态机的对应关系。五种 IO 模型与 select/poll/epoll 的细节见 Linux IO,本文不重复,只讲它们和网络编程的接口。 Go 的 HTTP 连接池、分层超时和服务端优雅退出见 网络-23 Go HTTP 客户端与服务端实战

1. Socket 是什么

Socket(套接字)不是协议,而是一组 API——是操作系统提供给应用程序的、用来使用 TCP/IP 协议栈的编程接口。每种语言都有对应的封装。

它的位置在应用层与传输层之间:把 TCP/IP 那一套复杂的状态机、缓冲区、重传逻辑,抽象成 read / write 这样近似文件操作的简单接口。这也是 Unix “一切皆文件” 哲学的体现——在 Linux 里 socket 就是一个文件描述符(fd)。

所以下面这句话是错的:“这个服务用的是 Socket 协议”。没有 Socket 协议,只有 TCP 协议、UDP 协议,以及用来操作它们的 Socket API。

2. 服务端与客户端的调用流程

服务端                                客户端
  |                                     |
socket()   创建 fd                   socket()
  |                                     |
bind()     绑定 IP:Port                 |
  |                                     |
listen()   转为监听状态                 |
  |         状态: CLOSED → LISTEN       |
  |                                     |
accept()   阻塞等待连接 ◄────────── connect()  发起三次握手
  |                                     |    状态: → SYN_SENT → ESTABLISHED
  | 返回一个新的 fd(连接 fd)          |
  | 状态: → SYN_RCVD → ESTABLISHED      |
  |                                     |
read()/write()  ◄──── 数据传输 ────► write()/read()
  |                                     |
close()    ◄──── 四次挥手 ────►      close()

几个容易混淆的点:

1. accept() 返回的是一个新的 fd

监听 fd 和连接 fd 是两个不同的东西。监听 fd 只负责接受连接,永远不用来收发数据;每个 accept() 返回一个新的连接 fd,代表一条具体的 TCP 连接。这就是为什么一个服务端进程能同时处理成千上万个连接——一个监听 fd + N 个连接 fd。

2. listen() 的 backlog 就是全连接队列

listen(fd, backlog);

这个 backlog 参数指定的正是 网络-07 里讲的全连接队列(accept queue)长度,实际生效值取它与 net.core.somaxconn 的较小值。三次握手是内核自动完成的,完成后连接就进入这个队列排队,等着应用调用 accept() 来取。

这意味着一个重要事实:即使你的程序还没调用 accept(),客户端的握手也已经成功了。所以"队列满了导致客户端超时"这种故障,在应用层日志里是完全看不到的。

3. close()shutdown() 不同

  • close(fd):关闭两个方向,并递减 fd 引用计数(fork 后多进程共享时,只有计数归零才真正发 FIN)。
  • shutdown(fd, SHUT_WR):只关闭写方向,发出 FIN 但仍可继续读——这就是 网络-07 里讲的半关闭

3. 内核缓冲区:write() 返回不代表发出去了

每个 TCP socket 在内核里有两个缓冲区:

  • 发送缓冲区(SO_SNDBUF)write()/send() 做的事只是把数据从用户空间拷贝到这个缓冲区,然后就返回了。返回成功不代表对方收到了,甚至不代表已经发出去了——真正何时发送由 TCP 的拥塞控制和滑动窗口决定(见 网络-09)。
  • 接收缓冲区(SO_RCVBUF):对端发来的数据先由内核收下并缓存在这里,read() 做的事就是从这个缓冲区拷贝到用户空间。这个缓冲区的剩余空间就是 TCP 首部里通告给对方的接收窗口(rwnd)

理解这一点能解释很多现象:

  • 为什么 write() 成功了对方却没收到 —— 数据还在发送缓冲区里,或者在网络中丢了;
  • 为什么会有粘包 —— 多次 write() 的数据在发送缓冲区里连成了一片字节流(见 网络-08);
  • 为什么 write()阻塞 —— 发送缓冲区满了(对方处理不过来,窗口降到 0);
  • 为什么需要优雅关闭 —— 直接 close() 可能丢弃接收缓冲区里还没读的数据并发出 RST。

4. 阻塞、非阻塞与 IO 多路复用

Socket 默认是阻塞的:accept()read() 在没有数据时会一直挂着。这带来了经典的并发模型演进:

模型 做法 问题
阻塞 + 单线程 一次只服务一个连接 完全无法并发
阻塞 + 每连接一线程 accept 后开一个线程 C10K:一万连接就是一万线程,内存和上下文切换扛不住
IO 多路复用 一个线程用 epoll 监视上万个 fd 需要非阻塞 + 事件驱动,代码复杂

把 socket 设为非阻塞:

int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);

此时 read() 在无数据时立刻返回 -1 并置 errno = EAGAIN(或 EWOULDBLOCK),程序不必挂住,可以去干别的——但也因此需要一个机制来知道"什么时候真的有数据了",这就是 select / poll / epoll 的用途。

这三者的区别、epoll 的水平触发与边缘触发、以及五种 IO 模型的完整讨论,见 Linux IO,此处不重复。

5. UDP Socket 的调用流程

UDP 无连接,不需要 listenaccept 和三次握手:

服务端                              客户端
socket(SOCK_DGRAM)                 socket(SOCK_DGRAM)
bind(IP:Port)                      可选 bind
recvfrom()       ◄──────────────  sendto()
sendto()         ──────────────►  recvfrom()

Go 中一个最小 UDP 服务:

conn, err := net.ListenUDP("udp", &net.UDPAddr{Port: 9000})
if err != nil {
    log.Fatal(err)
}
defer conn.Close()

buf := make([]byte, 2048)
for {
    n, peer, err := conn.ReadFromUDP(buf)
    if err != nil {
        log.Printf("read udp: %v", err)
        continue
    }
    if _, err := conn.WriteToUDP(buf[:n], peer); err != nil {
        log.Printf("write udp: %v", err)
    }
}

UDP 保留报文边界:一次 WriteToUDP 对应一条数据报,一次 ReadFromUDP 最多读取一条。接收缓冲区太小时,多出的部分会被截断,不会留给下一次读取。 所以协议必须限制最大报文,并让应用缓冲区足够大。

UDP 的 connect 不建立网络连接,它只是把默认对端记录在本地 socket,并让内核过滤其他来源、把 ICMP 错误关联到该 socket。Go 的 net.DialUDP 返回 UDPConn 后可以直接 Read/Write,但网络上仍没有握手和连接状态。

6. 短读、短写与错误语义

Socket 是字节流接口,不是消息队列:

  • Read(p) 返回多少就处理多少;n > 0 时即使同时返回 err,也要先处理这 n 个字节;
  • TCP 的一次 Read 可能只得到半条消息,也可能包含多条消息;
  • Write 返回 n < len(p) 时,未写入部分要继续处理,不能静默丢掉;
  • 非阻塞 fd 的 EAGAIN 表示“现在没准备好”,不是连接坏了;
  • io.EOF 表示对端正常关闭了写方向,本地仍可能可以写;
  • connection reset by peer 表示收到了 RST,是异常终止;
  • broken pipe 常见于对端已经关闭后本地继续写。

应用协议应使用定长、分隔符或长度前缀定义消息边界。读取 4 字节长度头再读取 body:

var header [4]byte
if _, err := io.ReadFull(conn, header[:]); err != nil {
    return err
}

size := binary.BigEndian.Uint32(header[:])
if size > 4<<20 { // 最大 4 MiB,防止恶意长度导致 OOM
    return fmt.Errorf("message too large: %d", size)
}

body := make([]byte, size)
_, err := io.ReadFull(conn, body)

长度上限不可省略。攻击者可以声明一个几 GB 的长度,让服务端分配巨量内存,即使后续一个字节都不发送。

7. 超时、背压与常用 Socket 选项

7.1 Deadline 是绝对时间

SetReadDeadline/SetWriteDeadline 设置的是绝对截止时间,不是“每次操作的超时时长”。长连接每完成一次成功读写后,需要按协议策略刷新:

if err := conn.SetReadDeadline(time.Now().Add(30 * time.Second)); err != nil {
    return err
}
n, err := conn.Read(buf)
if err != nil {
    if errors.Is(err, os.ErrDeadlineExceeded) {
        return fmt.Errorf("peer idle: %w", err)
    }
    return err
}

net.Dialer.DialContext 的 context 控制拨号过程;连接建立后,取消最初的 context 不会自动中断裸 net.Conn 上的后续 Read。需要由上层在取消时关闭连接或设置一个已经到期的 deadline。net/http 已经替请求做了这层集成。

7.2 背压必须一路传回入口

发送方 Write 成功只代表数据进入内核发送缓冲区。当对端读取变慢,接收窗口缩小,最终本地发送缓冲区填满,Write 就会阻塞或超时——这就是 TCP 把背压传回应用的方式。

真正危险的是应用在 socket 前面堆了一个无界 channel/队列:网络已经变慢,生产者却继续把消息塞进内存,背压到不了入口,最终 OOM。服务端应该使用有界队列,并明确选择:

  • 阻塞上游;
  • 拒绝新请求;
  • 丢弃允许丢的数据;
  • 超时取消。

7.3 常用选项

选项 作用 Go 中的入口
TCP_NODELAY 关闭 Nagle,降低小消息延迟 TCPConn.SetNoDelay;Go 默认开启
SO_KEEPALIVE 内核级空闲连接探测 TCPConn.SetKeepAliveConfig
SO_RCVBUF/SO_SNDBUF 接收/发送缓冲区 SetReadBuffer/SetWriteBuffer
SO_REUSEADDR 允许特定情况下快速重新绑定地址 Go 监听器在支持的平台会处理常用设置
SO_REUSEPORT 多个 socket 绑定同一地址并由内核分流 ListenConfig.Control 或第三方库,不能盲开

Keepalive 只能证明 TCP 对端是否还响应,不能证明业务处理正常;HTTP/gRPC/WebSocket 仍需要应用层超时或心跳。

8. Go 是怎么做的:netpoller

用 Go 写网络程序时,你看到的代码是这样的——看起来完全是阻塞式的

ln, _ := net.Listen("tcp", ":8080")
for {
    conn, _ := ln.Accept()        // 看起来阻塞
    go func(c net.Conn) {         // 每连接一个 goroutine
        defer c.Close()
        buf := make([]byte, 4096)
        for {
            n, err := c.Read(buf) // 看起来阻塞
            if err != nil { return }
            c.Write(buf[:n])
        }
    }(conn)
}

这段代码同时具备两个看似矛盾的优点:写法是"每连接一线程"的直观模型,性能却是 epoll 事件驱动的水平。原因是 Go 运行时做了一层转换,叫 netpoller

  1. net.Listen 创建的 fd 实际被设为非阻塞,并注册到 epoll(Linux)/ kqueue(BSD)/ IOCP(Windows);
  2. 当你调用 c.Read() 而数据还没到时,返回的是 EAGAIN。运行时不会让线程阻塞,而是把当前 goroutine 挂起gopark),把 M(OS 线程)交还给调度器去跑别的 goroutine;
  3. 运行时的调度循环会调用 netpoll() 检查就绪事件,发现某个 fd 可读后,唤醒对应的 goroutinegoready)放回运行队列;
  4. 对你的代码来说,c.Read() 就像是"阻塞了一会儿然后返回了"。

所以:阻塞的是 goroutine,不是线程。 一万个连接就是一万个 goroutine(每个初始栈仅 2KB),底层只有几个 OS 线程和一个 epoll 实例。这就是 Go 在网络服务领域的核心优势——把 epoll 的性能和阻塞式编程的可读性同时给了你

由此也能理解几个 Go 网络编程的实践要点:

  • 不需要自己写 epoll 循环,标准库已经做了,手写反而更慢;
  • 超时要用 SetDeadline 而不是自己起定时器——它由 netpoller 统一管理,不额外占用 goroutine:
conn.SetReadDeadline(time.Now().Add(30 * time.Second))
  • goroutine 泄漏是主要风险:连接不 Close()Read 不设超时,goroutine 就会永远挂在那里等一个永不到来的事件。用 pprof 看 goroutine 数量是排查这类问题的第一步。

9. 本章面试题

Socket 是协议吗?它和 TCP、WebSocket 是什么关系?

不是协议,是操作系统提供的一组 API,位于应用层与传输层之间。TCP 是协议,Socket 是使用这个协议的接口。WebSocket 是应用层协议(与 HTTP 平级),它底层同样通过 Socket API 收发数据。

详见上文「Socket 是什么」和 网络-19

listen() 的 backlog 参数是什么含义?队列满了会怎样?

它指定**全连接队列(accept queue)**的长度,实际生效值为 min(backlog, net.core.somaxconn)

队列满时,Linux 默认可能忽略最后 ACK 并重传 SYN+ACK;客户端一侧可能已经进入 ESTABLISHED,随后发送的数据发生重传和超时。tcp_abort_on_overflow=1 时会更积极地回 RST。无论哪种情况,连接没有被应用 accept(),所以应用日志通常看不到。

详见 网络-07 的半连接与全连接队列一节。

write() 返回成功,是否意味着对方已经收到数据?

不意味着。write() 只是把数据从用户空间拷贝到内核的发送缓冲区就返回了,实际发送时机由 TCP 的滑动窗口和拥塞控制决定,更不代表对端应用已经 read() 走了。

要确认对方收到,只能靠应用层的确认(ACK 消息、业务响应)。

close() 和 shutdown() 有什么区别?

close() 关闭读写两个方向,并递减 fd 引用计数(多进程共享时计数归零才真正发 FIN)。 shutdown(fd, SHUT_WR) 只关闭写方向,发出 FIN 但仍可继续读,形成半关闭

需要"我发完了,但还要接收你的剩余数据"时用 shutdown

Go 里每个连接开一个 goroutine,为什么能撑住上万连接?

因为 Go 运行时的 netpoller:fd 实际是非阻塞的并注册到 epoll,Read 遇到 EAGAIN 时挂起的是 goroutine 而非 OS 线程,线程被交还调度器去跑其他 goroutine,事件就绪后再唤醒。

所以底层只有少量 OS 线程 + 一个 epoll 实例,而每个 goroutine 初始栈仅 2KB。写法是阻塞式的,运行时是事件驱动的。

详见上文「Go 是怎么做的:netpoller」。

TCP 和 UDP 的 Socket 调用流程有什么区别?UDP connect 建连接了吗?

TCP 服务端需要 bind → listen → accept,客户端 connect 会触发三次握手。UDP 服务端通常只需 bind → recvfrom/sendto,没有 listen/accept

UDP connect 只在本地记录默认对端、过滤其他来源并便于关联 ICMP 错误,网络上没有握手,也不会产生可靠连接。

Deadline 和 context 有什么区别?取消 context 会自动中断 net.Conn.Read 吗?

SetReadDeadline/SetWriteDeadline 直接作用于 socket IO,而且设置的是绝对时间DialContext 的 context 只控制拨号过程;裸连接建立后,取消最初的 context 不会自动唤醒正在阻塞的 Read。

需要在取消时关闭连接或设置已到期的 deadline。net/http 已经把请求 context 与底层连接/HTTP 流的取消集成起来。