网络-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 无连接,不需要 listen、accept 和三次握手:
服务端 客户端
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:
net.Listen创建的 fd 实际被设为非阻塞,并注册到 epoll(Linux)/ kqueue(BSD)/ IOCP(Windows);- 当你调用
c.Read()而数据还没到时,返回的是EAGAIN。运行时不会让线程阻塞,而是把当前 goroutine 挂起(gopark),把 M(OS 线程)交还给调度器去跑别的 goroutine; - 运行时的调度循环会调用
netpoll()检查就绪事件,发现某个 fd 可读后,唤醒对应的 goroutine(goready)放回运行队列; - 对你的代码来说,
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 流的取消集成起来。
xingliuhua