Linux 网络 IO 模型演进与 Epoll 内部机制
Linux 网络 IO 模型演进与 Epoll 内部机制
一、核心概念与架构设计
要理解 epoll,先要理解它替代的是什么。早期的 socket API 只提供阻塞语义:read 调用进去,线程就停在那里等数据。一个线程服务一个连接,C10K 之前够用,之后线程本身成了瓶颈。于是有了非阻塞 fd 加轮询,有了 select 和 poll 把多个 fd 的等待合并成一次系统调用,最后是 epoll 把"每次都要重新告诉内核我在等哪些 fd"这件事也省掉了。
这五种模型(阻塞、非阻塞、IO 多路复用、信号驱动、异步 IO)常被并列讲,但真正区分它们的是一条线:通知的是"可以读了"还是"已经读完了"。
select、poll、epoll、kqueue 全是就绪通知(readiness)。它们告诉你"这个 fd 现在读不会阻塞",数据还在内核缓冲区里,等着你自己read出来。io_uring、Windows IOCP 属于完成通知(completion)。你把请求连同用户态缓冲区一起提交,内核读完数据后通知你结果。
这条线决定了编程模型的复杂度。就绪模型下,应用要自己处理"读多少、读几次、什么时候算读干净";完成模型下,这些由内核负责,但应用要显式组织缓冲区与请求队列。Go 的运行时选择了就绪模型,把复杂性藏在调度器里,代价是这套抽象没法透明地套到完成模型上,后面会展开。
二、深度原理与底层剖析
2.1 select、poll、epoll 的差别在哪
| 对比项 | select | poll | epoll |
|---|---|---|---|
| 数据结构 | fd_set 位图 |
pollfd 数组 |
内核红黑树 + 就绪链表 |
| fd 数量上限 | FD_SETSIZE,通常 1024 |
无硬上限 | 无硬上限 |
| 每次调用开销 | 拷贝整个集合进内核,返回后遍历全部 fd | 同左 | 注册一次,epoll_wait 只返回就绪项 |
| 就绪通知复杂度 | O(n) | O(n) | O(就绪 fd 数) |
| 触发模式 | 只有水平触发 | 只有水平触发 | 水平触发与边沿触发都支持 |
| 重复使用 | 返回后需重建集合 | 需重置 revents | 无需重建 |
select 的 1024 上限来自用户态位图大小(FD_SETSIZE),内核本身没有这个限制,但这个限制在实际使用中足够致命。poll 去掉了数量限制,代价仍然是每次调用把整个 fd 数组拷进内核再逐项检查。
epoll 把"注册"和"等待"拆成两个系统调用。epoll_ctl 负责把 fd 挂进内核的红黑树,同时注册回调;当网卡收到数据、协议栈判定 fd 可读时,内核通过这个回调把 fd 挂到就绪链表上;epoll_wait 只做一件事,把就绪链表里的项拷到用户态数组。所以它的开销与总 fd 数无关,只与就绪数量有关。这也是 epoll 能在几十万连接上工作的原因。
epoll_wait 的第三个参数是超时;返回的 maxevents 决定单次最多取多少事件,服务端通常取几百到几千,避免长尾事件被饿死。
2.2 水平触发与边沿触发
水平触发(LT,默认)的语义是"只要 fd 还可读,每次 epoll_wait 都会返回它"。好处是不会漏,坏处是如果你读不完或不读,它会一直叫醒你。
边沿触发(ET,EPOLLET)只在状态发生变化的那一刻通知一次。"从无数据到有数据"是一次边沿,之后即使缓冲区里还堆着数据,只要你没把它读空、也没有新数据到达,就不会再通知。使用 ET 有三个硬性要求:
- fd 必须是非阻塞的,否则最后一次读完数据的
read会一直阻塞在那里; - 必须在一次事件里循环读到
EAGAIN,否则剩下的数据要等下一条边的到来; - 用于
listenfd 时,必须循环accept到EAGAIN,否则新连接会留在队列里等下一次边沿,而那次边沿可能很久以后才来。
2.3 Go 用的是哪种触发模式
这里有一处流传很广的错误说法。不少人写"Go 用水平触发的 epoll",实际去看源码,runtime/netpoll_epoll.go 注册事件时是这样的:
func netpollopen(fd uintptr, pd *pollDesc) uintptr {
var ev syscall.EpollEvent
ev.Events = syscall.EPOLLIN | syscall.EPOLLOUT | syscall.EPOLLRDHUP | syscall.EPOLLET
tp := taggedPointerPack(unsafe.Pointer(pd), pd.fdseq.Load())
*(*taggedPointer)(unsafe.Pointer(&ev.Data)) = tp
return syscall.EpollCtl(epfd, syscall.EPOLL_CTL_ADD, int32(fd), &ev)
}
EPOLLET 就在里面,注释也写得很直白:注册时必须挂边沿触发通知。BSD 和 macOS 上对应的 netpoll_kqueue.go 用的是 EV_CLEAR,那也是 kqueue 的边沿语义。
选边沿触发是为了减少无效唤醒:fd 一直可读时,一次通知就够了,不需要每轮 epoll_wait 都把它重新报一遍。随之而来的"必须读到 EAGAIN"这条纪律,由 internal/poll 里那层循环天然满足,用户写 conn.Read 时完全感知不到。
同一份文件里还有两个细节值得记:
func netpollinit() {
epfd, errno = syscall.EpollCreate1(syscall.EPOLL_CLOEXEC)
efd, errno := syscall.Eventfd(0, syscall.EFD_CLOEXEC|syscall.EFD_NONBLOCK)
ev := syscall.EpollEvent{Events: syscall.EPOLLIN}
*(**uintptr)(unsafe.Pointer(&ev.Data)) = &netpollEventFd
errno = syscall.EpollCtl(epfd, syscall.EPOLL_CTL_ADD, efd, &ev)
}
一是 EPOLL_CLOEXEC,防止 epoll fd 泄漏给子进程。二是一个 eventfd 被注册进同一个 epoll 实例,它的作用是打断阻塞中的 epoll_wait:当别的线程需要让 netpoller 立刻重新评估(比如刚创建了一个可运行 goroutine)时,往这个 eventfd 写 8 个字节,epoll_wait 就会立刻返回,随后走 netpollBreak 的处理分支。
2.4 就绪事件怎么变成 runnable 的 goroutine
epoll_wait 返回后,运行时要找到挂在这个 fd 上的 goroutine 并把它放回调度队列。EVENTS 里的 Data 字段在 Go 里存的是一个 tagged pointer,指向该 fd 的 pollDesc,进而找到等待中的 goroutine。三条路径会把事件捞起来:
findRunnable发现本地队列和全局队列都没活干时,调用阻塞式netpoll(delay),此时 M 就扮演了 epoll 事件循环的角色;sysmon兜底。runtime/proc.go里的判据是lastpoll+10*1000*1000 < now,也就是大约 10 ms 没轮询过网络,就做一次非阻塞netpoll(0),把就绪的 goroutine 注入队列。这条路径覆盖了"所有 P 都在忙业务、没人轮询"的窗口;- 带超时的读写走
pollDesc上的rt/wt定时器,到时唤醒等待者,netpollDeadline与netpollWriteDeadline就是这两个回调。
所以 Go 的 netpoller 不是一条独立的事件循环线程,而是"谁空闲谁来轮询"的共享组件。这一点在后面比较 Reactor 框架时很关键。
2.5 文件 IO 不走 netpoller
epoll 只对"可轮询"的 fd 有意义。普通文件的 fd 永远是可读可写的,epoll 注册它只会立刻返回就绪,没有任何等待的价值。Go 的处理办法是把文件操作当阻塞调用执行,由运行时的线程池承担,必要时 sysmon 会通过 retake 把陷入系统调用的 P 抢回来给别的 goroutine 用。
工程含义是:一个大量读本地文件的服务,即使连接数很少,也可能吃掉很多 OS 线程,而且这些线程在系统调用期间无法执行 Go 代码。想控制影响面,就把文件操作限制在固定的 worker 池里,或者用 io_uring 这类真正支持异步文件的接口(Go 标准库目前不提供)。
2.6 io_uring 为什么还没进标准库
io_uring 通过内核与用户态共享的两个环形队列(提交队列 SQ、完成队列 CQ)把 N+1 次系统调用压缩到 1 次甚至 0 次,还能提供真正的异步文件 IO。它对 Go 的诱惑很大:可以彻底去掉文件 IO 的线程池。
但它没有进标准库,原因在 Go 仓库那个开了五年多的 issue #31908 里讨论得很充分。第一是范式冲突:io_uring 的收益来自批处理,而 Go 的 net.Conn.Read 是独立阻塞调用,用户按连接写代码,运行时无法在不破坏 API 语义的前提下透明地把分散的请求收集成批;社区提出的"每个 P 一个 ring"方案会引入环争用、空闲 P 唤醒、P/M 切换状态管理等一堆复杂度。第二是安全现实:Docker 默认 seccomp 配置已经禁用 io_uring,Google 的生产服务器上也关着,一个在主流部署环境里默认不可用的特性很难成为运行时的基石。第三是接口仍在演进,标准库对稳定性的要求与之冲突。
结论是:Go 会继续挖 epoll 的潜力,标准库短期内不会转向完成模型。需要极致 IO 的场景,走用户态框架或直接调 liburing。
三、独创可运行代码演练
3.1 读缓冲大小决定系统调用次数(跨平台可运行)
第一个实验不碰 epoll,只回答一个具体问题:同样搬运 1 MiB 数据,不同读缓冲会带来多少次 read 调用,慢多少。
// 读缓冲大小如何影响系统调用次数与吞吐:
// 同一份 1 MiB 数据,分别用 4 KiB / 64 KiB / 256 KiB 的缓冲搬运,
// 统计 read 调用次数与最快一轮的耗时(走标准库 net,任何平台都能跑)。
package main
import (
"fmt"
"net"
"time"
)
const totalBytes = 1 << 20 // 每连接搬运 1 MiB
const rounds = 200 // 每种缓冲测 200 轮,取最快的一轮
func serve(ln net.Listener, payload []byte) {
for {
conn, err := ln.Accept()
if err != nil {
return
}
go func(c net.Conn) {
defer c.Close()
for sent := 0; sent < len(payload); {
n, err := c.Write(payload[sent:])
if err != nil {
return
}
sent += n
}
}(conn)
}
}
// drainConn 用固定大小的缓冲读满 totalBytes,返回 read 调用次数与耗时。
// 数据不足量时返回负耗时,表示这一轮不计入统计。
func drainConn(addr string, bufSize int) (int, time.Duration) {
conn, err := net.Dial("tcp", addr)
if err != nil {
panic(err)
}
defer conn.Close()
// 放大内核接收缓冲,让变量落在应用层读缓冲上,而不是 TCP 接收窗口上。
if tcp, ok := conn.(*net.TCPConn); ok {
_ = tcp.SetReadBuffer(1 << 20)
}
buf := make([]byte, bufSize)
calls, read := 0, 0
start := time.Now()
for read < totalBytes {
n, err := conn.Read(buf)
if n > 0 {
calls++
read += n
}
if err != nil {
if read < totalBytes {
return calls, -1
}
break
}
}
return calls, time.Since(start)
}
func main() {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
panic(err)
}
defer ln.Close()
payload := make([]byte, totalBytes) // 服务端一次性把 totalBytes 写完再断开
for i := range payload {
payload[i] = byte(i) // 避免全零页带来额外的优化空间
}
go serve(ln, payload)
addr := ln.Addr().String()
fmt.Printf("%-10s %12s %12s %14s\n", "读缓冲", "read 次数", "字节数", "最快一轮")
fmt.Printf("%-10s %12s %12s %14s\n", "--------", "---------", "--------", "----------")
for _, bs := range []int{4 << 10, 64 << 10, 256 << 10} {
best := time.Duration(0)
calls := 0
for i := 0; i < rounds; i++ {
c, d := drainConn(addr, bs)
if d < 0 {
continue
}
calls = c
if best == 0 || d < best {
best = d
}
}
fmt.Printf("%-10s %12d %12d %14s\n",
fmt.Sprintf("%d KiB", bs>>10), calls, totalBytes, best.Round(time.Microsecond))
}
}
本地实测(darwin/amd64,Go 1.25.9):
读缓冲 read 次数 字节数 最快一轮
-------- --------- -------- ----------
4 KiB 256 1048576 347µs
64 KiB 18 1048576 138µs
256 KiB 10 1048576 125µs
4 KiB 缓冲付出了 256 次 read,耗时是 64 KiB 缓冲的 2.5 倍。注意 64 KiB 那行是 18 次而不是 16 次,因为 TCP 是字节流,内核按 MSS 分段到达,读到的边界和写入的边界没有对应关系,最后一次往往不整齐。256 KiB 之后收益基本到顶,说明真正的成本已经转移到了拷贝与协议栈处理上。
3.2 最小 epoll 服务:水平触发与边沿触发对比(Linux)
第二个程序直接调 epoll 系统调用,把 LT 和 ET 的行为差异跑出来。它是 Linux 专用代码,用 syscall 包不引入任何第三方依赖。
//go:build linux
// 最小 epoll 回显服务,用来对比水平触发(LT)与边沿触发(ET)的通知行为:
//
// go run main.go -et=false # 水平触发:一次事件只读一次,内核会重复通知
// go run main.go -et=true # 边沿触发:必须读到 EAGAIN,否则剩余数据不再通知
//
// 只依赖标准库 syscall,无需第三方包。
package main
import (
"flag"
"fmt"
"io"
"log"
"net"
"os"
"sync/atomic"
"syscall"
"time"
)
// EPOLLET 在标准库 syscall 中是负的未定型常量(-0x80000000),
// 无法直接赋给 uint32 类型的 Events 字段,这里给出等价位写法。
const epollET = uint32(1) << 31
type server struct {
epfd int
listener int
addr string
et bool
waits atomic.Int64 // epoll_wait 返回次数
reads atomic.Int64 // 成功读取的次数
bytes atomic.Int64 // 回显的字节数
}
func newServer(et bool) *server {
s := &server{et: et}
// 1. 监听 socket 直接以非阻塞模式创建,ET 模式必须如此
fd, err := syscall.Socket(syscall.AF_INET,
syscall.SOCK_STREAM|syscall.SOCK_NONBLOCK|syscall.SOCK_CLOEXEC, 0)
if err != nil {
log.Fatalf("socket: %v", err)
}
if err := syscall.SetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_REUSEADDR, 1); err != nil {
log.Fatalf("setsockopt: %v", err)
}
if err := syscall.Bind(fd, &syscall.SockaddrInet4{Port: 0}); err != nil {
log.Fatalf("bind: %v", err)
}
if err := syscall.Listen(fd, 128); err != nil {
log.Fatalf("listen: %v", err)
}
// 2. 创建 epoll 实例并注册监听 fd
epfd, err := syscall.EpollCreate1(syscall.EPOLL_CLOEXEC)
if err != nil {
log.Fatalf("epoll_create1: %v", err)
}
s.epfd, s.listener = epfd, fd
sa, err := syscall.Getsockname(fd)
if err != nil {
log.Fatalf("getsockname: %v", err)
}
s.addr = fmt.Sprintf("127.0.0.1:%d", sa.(*syscall.SockaddrInet4).Port)
log.Printf("监听 %s,边沿触发=%v", s.addr, et)
s.add(fd, uint32(syscall.EPOLLIN)|s.etFlag())
return s
}
func (s *server) etFlag() uint32 {
if s.et {
return epollET
}
return 0
}
// add 注册 fd。EpollEvent 的 Data 联合体在 amd64 上由 Fd/Pad 两个 int32 承载,
// 这里直接用 Fd 携带 fd,省掉 unsafe 转换。
func (s *server) add(fd int, events uint32) {
ev := syscall.EpollEvent{Events: events, Fd: int32(fd)}
if err := syscall.EpollCtl(s.epfd, syscall.EPOLL_CTL_ADD, fd, &ev); err != nil {
log.Fatalf("epoll_ctl(ADD, fd=%d): %v", fd, err)
}
}
func (s *server) closeConn(fd int) {
_ = syscall.EpollCtl(s.epfd, syscall.EPOLL_CTL_DEL, fd, nil)
_ = syscall.Close(fd)
}
// serve 是事件循环本体:一次 epoll_wait 拿到一批就绪事件,逐个分发。
func (s *server) serve() {
events := make([]syscall.EpollEvent, 128)
for {
n, err := syscall.EpollWait(s.epfd, events, -1) // -1 表示不限时等待
if err != nil {
if err == syscall.EINTR {
continue
}
log.Fatalf("epoll_wait: %v", err)
}
s.waits.Add(1)
for i := 0; i < n; i++ {
fd := int(events[i].Fd)
if fd == s.listener {
s.acceptAll()
continue
}
s.handle(fd, events[i].Events)
}
}
}
// acceptAll 在 ET 模式下必须循环 accept 到 EAGAIN:
// 一次连接就绪的边沿只通知一次,只 accept 一个的话剩下的连接会一直挂在队列里。
func (s *server) acceptAll() {
for {
nfd, _, err := syscall.Accept4(s.listener, syscall.SOCK_NONBLOCK|syscall.SOCK_CLOEXEC)
if err != nil {
switch err {
case syscall.EAGAIN:
return // 队列空了,等下一个边沿
case syscall.EINTR:
continue
default:
log.Printf("accept4: %v", err)
return
}
}
s.add(nfd, uint32(syscall.EPOLLIN)|s.etFlag())
}
}
// handle 处理一个连接的读事件并原样回显。
// LT 模式每轮只读一块就返回,让内核继续通知;ET 模式必须读到 EAGAIN。
func (s *server) handle(fd int, events uint32) {
buf := make([]byte, 8<<10)
maxReads := 1
if s.et {
maxReads = -1 // 负数表示不设上限,一直读到 EAGAIN
}
halfClosed := events&syscall.EPOLLRDHUP != 0
for i := 0; maxReads < 0 || i < maxReads; i++ {
n, err := syscall.Read(fd, buf)
switch {
case n > 0:
s.reads.Add(1)
s.bytes.Add(int64(n))
// 教学示例:这里没有处理写回时的 EAGAIN 与半写,
// 真实服务需要为每条连接维护写缓冲并注册可写事件。
if _, werr := syscall.Write(fd, buf[:n]); werr != nil {
s.closeConn(fd)
return
}
case err == syscall.EAGAIN:
if halfClosed {
s.closeConn(fd) // 数据已读干净,且对端不再发送
}
return
case err == syscall.EINTR:
continue
case n == 0: // 对端正常关闭,读到 EOF
s.closeConn(fd)
return
default:
s.closeConn(fd)
return
}
}
if halfClosed {
s.closeConn(fd)
}
}
func (s *server) report() {
mode := "水平触发(默认)"
if s.et {
mode = "边沿触发 EPOLLET"
}
fmt.Printf("触发模式:%s\n", mode)
fmt.Printf("epoll_wait 返回次数:%d\n", s.waits.Load())
fmt.Printf("read 调用成功次数:%d\n", s.reads.Load())
fmt.Printf("回显字节数:%d\n", s.bytes.Load())
}
// runClient 建立一条连接,并发读取回显数据,等待全部字节返回。
func runClient(addr string, total int) error {
conn, err := net.Dial("tcp", addr)
if err != nil {
return err
}
defer conn.Close()
payload := make([]byte, total)
for i := range payload {
payload[i] = byte(i)
}
var got atomic.Int64
done := make(chan error, 1)
go func() {
buf := make([]byte, 32<<10)
for got.Load() < int64(total) {
n, err := conn.Read(buf)
if n > 0 {
got.Add(int64(n))
}
if err != nil {
if err == io.EOF {
done <- nil
return
}
done <- err
return
}
}
done <- nil
}()
for sent := 0; sent < len(payload); {
n, err := conn.Write(payload[sent:])
if err != nil {
return err
}
sent += n
}
select {
case err := <-done:
if err != nil {
return err
}
case <-time.After(5 * time.Second):
return fmt.Errorf("等待回显超时,已收到 %d 字节", got.Load())
}
fmt.Printf("客户端:发送 %d 字节,收到回显 %d 字节\n", total, got.Load())
return nil
}
func main() {
et := flag.Bool("et", false, "启用边沿触发(EPOLLET)")
kb := flag.Int("kb", 64, "客户端发送的负载大小(KiB)")
flag.Parse()
s := newServer(*et)
go s.serve()
if err := runClient(s.addr, *kb<<10); err != nil {
log.Printf("客户端: %v", err)
}
time.Sleep(200 * time.Millisecond) // 留出时间让服务端处理完最后一批事件
s.report()
os.Exit(0)
}
这段代码要用 Linux 运行,本地通过 GOOS=linux GOARCH=amd64 go build 与 go vet 校验(该文件带 //go:build linux 约束,在 macOS 上不参与编译)。跑起来后对比两种模式输出:水平触发模式下 epoll_wait 返回次数明显更多,因为一次读一块就回到循环,内核每个事件循环都重新报告可读;边沿触发模式下次数少得多,单次事件里把数据读到 EAGAIN 为止,read 次数与数据量成比例但通知次数降到最低。
如果在 ET 模式下把 maxReads 改成 1(也就是只读一次就返回),客户端就会发现回显停在半路上,剩余的字节永远等不到通知。这个现象在生产环境的典型形态是"连接假死":连接还在,CPU 不高,数据不流动。
四、生产踩坑与专家级调优建议
ET 模式的两个漏读点。 一是 listen fd 只 accept 一次,新连接卡在队列里;二是连接 fd 只 read 一次,数据留在内核缓冲区。两者都会表现为"偶发连接超时"或"连接假死",而且压力越大越容易出现,因为那时一次通知往往对应多条连接或更多数据。排查方法很直接:统计 accept/read 调用次数与字节数的比值,ET 模式下如果出现大量事件对应单次读取,就是漏读。
EPOLLRDHUP 与半关闭。 对端 shutdown(SHUT_WR) 时,你收到的是读方向的可读事件加 EPOLLRDHUP,而不是 EPOLLHUP。如果只判断 EPOLLHUP 就关连接,会丢掉最后一批数据。正确顺序是:见到 EPOLLRDHUP 就继续把数据读到 EAGAIN 或 EOF,读完再关。示例代码里的 halfClosed 标志就是这个意思。
写侧比读侧更容易出错。 读侧的 EAGAIN 意味着"读完了",写侧的 EAGAIN 意味着"对方还没收,剩下的没写出去"。返回值为 EAGAIN 时,已经写出去的字节数必须记下来,剩余部分进写缓冲,同时把 EPOLLOUT 注册上去。示例代码里为了聚焦主题省掉了这层逻辑,生产代码必须补上,否则大响应会静默截断。还有一个反向的坑:连接空闲时不要一直挂着 EPOLLOUT,可写是常态,epoll 会立刻返回它,事件循环就变成忙等。用完就取消注册。
背压要有上限。 写缓冲不能无限涨。对端慢速消费时,缓冲会一路涨到 OOM。工程做法是给每条连接设上限(按连接数分摊可用内存算出来),超限就断开连接或者丢消息,具体选哪个取决于业务语义,但必须有确定的行为,不能"就让它涨着"。
缓冲区大小是一个真实的调优项。 前面那组实测数据说明了差距:4 KiB 读缓冲下 1 MiB 数据要 256 次 read,换成 64 KiB 只要 18 次,耗时从 347µs 降到 138µs。常见的起步值是 16 KiB 到 64 KiB,配合 readv/writev 一次处理多个缓冲区。再往上加收益递减,而且会放大每个连接的内存占用,连接数乘以缓冲大小就是内存下限。
惊群需要显式处理。 多个线程或进程在同一个 listen fd 上等待时,新连接会唤醒所有等待者。Linux 提供了两个工具:EPOLLEXCLUSIVE 让内核只唤醒一个等待者,SO_REUSEPORT 让每个线程/进程持有自己的监听 socket 并由内核按四元组哈希分发。后者在下一节的 Reactor 实现里会用到。
别让文件 IO 伪装成网络 IO。 静态文件服务、日志落盘、镜像读取这类操作走的是阻塞路径,会占用 OS 线程。Go 服务上如果发现线程数(/sched/threads:threads)远高于 GOMAXPROCS,多半就是阻塞系统调用堆积。控制手段是限制并发度和改用批量写,而不是加机器。
五、核心总结
epoll 的核心机制是把 fd 注册与事件等待分离:注册时挂回调,就绪时进链表,等待时只取链表,因此开销与总连接数无关。它提供的是就绪通知,不是完成通知,这个区别决定了应用必须自己处理读多少、写多少。
Go 的 netpoller 用的是边沿触发(EPOLLET,BSD 系是 EV_CLEAR),配合 eventfd 打断等待,由空闲的 P 或 sysmon 的 10 ms 兜底来轮询。文件 IO 不在其中,走的是阻塞系统调用与线程池。
调优时优先看三件事:读缓冲大小决定的系统调用次数,写缓冲上限决定的背压行为,以及 ET 模式下是否有漏读。这三处出了问题,加机器通常没用。

浙公网安备 33010602011771号