AIGC标识 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 有三个硬性要求:

  1. fd 必须是非阻塞的,否则最后一次读完数据的 read 会一直阻塞在那里;
  2. 必须在一次事件里循环读到 EAGAIN,否则剩下的数据要等下一条边的到来;
  3. 用于 listen fd 时,必须循环 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。三条路径会把事件捞起来:

  1. findRunnable 发现本地队列和全局队列都没活干时,调用阻塞式 netpoll(delay),此时 M 就扮演了 epoll 事件循环的角色;
  2. sysmon 兜底。runtime/proc.go 里的判据是 lastpoll+10*1000*1000 < now,也就是大约 10 ms 没轮询过网络,就做一次非阻塞 netpoll(0),把就绪的 goroutine 注入队列。这条路径覆盖了"所有 P 都在忙业务、没人轮询"的窗口;
  3. 带超时的读写走 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 模式下是否有漏读。这三处出了问题,加机器通常没用。

posted @ 2026-10-21 09:24  FfHUCisI  阅读(60)  评论(0)    收藏  举报