ᕕ( ᐛ )ᕗ Jimyag's Blog

使用三种 Go I/O 模式实现 HTTP 文件服务:同步、epoll 和 io_uring

Last modified:

文件服务器适合用来观察操作系统 I/O:程序本身不复杂,但会同时碰到网络连接、文件读取、响应头构造和大文件传输。

下面用 Go 写三种实现:同步多协程模式、基于原生系统调用的 epoll 事件循环,以及基于 Linux io_uring 的异步 I/O。


深究底层:epoll 与 io_uring 的核心机理与设计本质

要比较这三种实现,先把 epollio_uring 的核心差异说清楚:前者通知文件描述符是否就绪,后者提交 I/O 请求并从完成队列取结果。

1. epoll 的三大要素与内核结构

epoll 是一种反应式(Reactive)的事件通知模型。它并不直接帮你执行 I/O 操作,而是在你关心的 Socket 可读或可写时,给你发送“就绪通知”(Pull 模型)。

epoll 的高性能建立在内核中的两个关键数据结构之上:

  • 红黑树(Red-Black Tree):用于维护所有被监听的文件描述符(FD)。当应用程序调用 epoll_ctl 添加、删除或修改需要监听的 FD 时,内核能以 $O(\log N)$ 的时间复杂度快速检索和更新。
  • 就绪双向链表(Ready List):用于保存所有状态已经就绪的事件。当被监听的 FD 发生状态改变时(例如网卡接收到数据包触发硬件中断,驱动程序唤醒内核的等待队列),内核会将对应的事件节点插入到就绪双向链表中。

当用户程序调用 epoll_wait 时,内核只需简单地检查就绪双向链表是否为空:

  • 如果为空,调用线程会挂起,等待被就绪链表唤醒。
  • 如果不为空,内核会将就绪链表中的事件拷贝回用户态,时间复杂度为 $O(1)$。

LT 与 ET 触发模式的权衡

  • 水平触发(Level Triggered, LT):默认模式。只要 FD 处于就绪状态,每次调用 epoll_wait 都会不断通知用户。其编码容易,但由于重复通知,系统开销较大。
  • 边缘触发(Edge Triggered, ET):减少重复通知。只有 FD 的状态发生“从非就绪到就绪”的跳变时,才会通知一次。使用 ET 必须保证:
    1. 套接字必须是非阻塞的(Non-blocking)。
    2. 收到就绪通知后,必须在一个循环中反复调用 readwrite,直到系统返回 EAGAIN 错误。如果没读干净,在下一次新数据到达前,你将再也收不到该描述符的就绪事件。

2. io_uring 的共享环缓冲区与免拷贝设计

epoll 的“就绪通知”不同,io_uring 是一种主动式(Proactive)异步 I/O 模型。你直接在用户态把操作(比如“读这个文件描述符,把数据写入这个缓冲区”)准备好,提交给内核,内核默默帮你执行,执行完了通知你结果(Push 模型)。

io_uring 通过用户空间和内核空间共享的环形队列,减少了提交和完成阶段的系统调用开销。它的核心结构是两块共享内存区域:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
+-------------------------------------------------------------+
|                      User Space                             |
|  +---------------------+           +---------------------+  |
|  |  Submission Queue   |           |  Completion Queue   |  |
|  |      (SQ Ring)      |           |      (CQ Ring)      |  |
|  +----------+----------+           +----------+----------+  |
+-------------|---------------------------------|-------------+
              | Mapped via mmap()               | Mapped via mmap()
+-------------|---------------------------------|-------------+
|             v                                 |             |
|  +---------------------+           +----------+----------+  |
|  |  Submission Queue   |           |  Completion Queue   |  |
|  |      (SQ Ring)      |           |      (CQ Ring)      |  |
|  +---------------------+           +---------------------+  |
|                      Kernel Space                           |
+-------------------------------------------------------------+
  • SQ 环(Submission Queue):用户态写入,内核态消费。用户程序将具体的 I/O 动作写入 SQ 环中。
  • CQ 环(Completion Queue):内核态写入,用户态消费。内核完成 I/O 后,将操作结果(如读取的字节数或错误码)写入 CQ 环中。

这两个环通过 mmap 映射给用户态和内核态共同访问。用户态填 SQE、读取 CQE 时不需要再把描述符结构复制进出内核;如果配合批量提交或 SQPOLL,也可以减少 io_uring_enter 的调用频率。

io_uring 的三种运行模式

  • 中断驱动模式(Interrupt Driven):默认模式。用户态填充 SQE 后,调用 io_uring_enter 通知内核有新请求,内核完成 I/O 后通过完成队列返回结果。
  • 轮询模式(Polled):专门针对高速块存储(如 NVMe SSD)。内核无需通过中断获取完成状态,而是通过轮询(Polled)硬件驱动来检测就绪状态。
  • 内核提交轮询模式(Kernel Submission Queue Polling, SQPOLL):开启后,内核会启动专用线程轮询 SQ 环。用户态把请求写入 SQ 环后,在部分场景下可以不为每批请求都调用 io_uring_enter

共享的基础代码

在展示具体的 I/O 实现之前,我们先用 Go 准备一些公共的逻辑。无论是哪种 I/O 模式,它们都需要基本的请求解析、MIME 类型转换以及响应头部构建能力。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
// common.go
package main

import (
	"bytes"
	"fmt"
	"strings"
)

const docRoot = "./"
const maxReqBuf = 4096
const ioBufSize = 16384

func parseHTTPGet(req []byte) (string, error) {
	if len(req) < 5 || !bytes.HasPrefix(req, []byte("GET ")) {
		return "", fmt.Errorf("invalid method")
	}
	sp := bytes.IndexByte(req[4:], ' ')
	if sp == -1 {
		return "", fmt.Errorf("invalid path format")
	}
	path := string(req[4 : 4+sp])
	if !strings.HasPrefix(path, "/") {
		return "", fmt.Errorf("path must start with slash")
	}
	if path == "/" {
		return "/index.html", nil
	}
	return path, nil
}

func mimeFor(path string) string {
	if strings.HasSuffix(path, ".html") {
		return "text/html"
	}
	if strings.HasSuffix(path, ".css") {
		return "text/css"
	}
	if strings.HasSuffix(path, ".js") {
		return "application/javascript"
	}
	if strings.HasSuffix(path, ".json") {
		return "application/json"
	}
	if strings.HasSuffix(path, ".png") {
		return "image/png"
	}
	if strings.HasSuffix(path, ".jpg") || strings.HasSuffix(path, ".jpeg") {
		return "image/jpeg"
	}
	if strings.HasSuffix(path, ".txt") {
		return "text/plain"
	}
	return "application/octet-stream"
}

func buildOKHeaders(mime string) []byte {
	return []byte(fmt.Sprintf(
		"HTTP/1.1 200 OK\r\nContent-Type: %s\r\nConnection: close\r\n\r\n",
		mime,
	))
}

func build404() []byte {
	return []byte("HTTP/1.1 404 Not Found\r\nContent-Type: text/plain\r\nConnection: close\r\n\r\n404 Not Found\n")
}

方案一:同步“每个请求一个 Goroutine”模型

在 Go 中,我们通常使用同步模型进行编码。对开发者而言,无论是读取 Socket 还是读取文件,调用形式都是“阻塞”的。实际上,Go 运行时在底层利用网络轮询器(Netpoller)对阻塞调用进行了挂起和恢复调度。

1. 同步模型的工作机制

当 Goroutine 调用 conn.Read() 时,如果数据未就绪,Go 会将该 Goroutine 挂起,将其切换为等待状态,并将文件描述符注册到运行时的全局 epoll 中。直到内核通知数据到达,Goroutine 才会被唤醒并重新运行。

其整体流程如下:

  sequenceDiagram
    participant G as Goroutine
    participant R as Go Runtime (Netpoller)
    participant K as Linux Kernel
    G->>R: Read (Blocking call)
    R->>K: read syscall (returns EAGAIN)
    R->>R: Park Goroutine (change state to waiting)
    Note over R,K: Epoll monitors FD for read readiness
    K->>R: FD Ready Event (via epoll_wait)
    R->>R: Unpark Goroutine (change state to runnable)
    R->>G: Wake up
    G->>K: Read data

2. 代码实现

得益于 Go 标准库的优异设计,我们只需要直接使用 netos 包即可完成整个服务器的开发:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// sync.go
package main

import (
	"fmt"
	"io"
	"net"
	"os"
	"path/filepath"
)

func runSyncServer(port int) error {
	listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
	if err != nil {
		return err
	}
	defer listener.Close()
	fmt.Printf("sync (goroutine per request) server on :%d\n", port)

	for {
		conn, err := listener.Accept()
		if err != nil {
			continue
		}
		// Dispatch each connection to a separate goroutine
		go serve(conn)
	}
}

func serve(conn net.Conn) {
	defer conn.Close()
	buf := make([]byte, maxReqBuf)
	n, err := conn.Read(buf)
	if err != nil && err != io.EOF {
		return
	}

	path, err := parseHTTPGet(buf[:n])
	if err != nil {
		conn.Write(build404())
		return
	}

	fullPath := filepath.Join(docRoot, path)
	file, err := os.Open(fullPath)
	if err != nil {
		conn.Write(build404())
		return
	}
	defer file.Close()

	conn.Write(buildOKHeaders(mimeFor(path)))
	io.Copy(conn, file)
}

方案二:基于 syscall 的手动 epoll 事件驱动模型

虽然 Go 自带的 Netpoller 底层就是 epoll(或 kqueue),但如果我们要绕过内置网络库,手动通过 Linux 的 epoll 系统调用写一个单线程、事件驱动的非阻塞循环,那我们就必须直接使用底层包 golang.org/x/sys/unix

1. epoll 事件循环设计

我们需要手动创建一个 epoll 文件描述符,将 Socket 设为非阻塞模式并注册进去。并在一个无限循环中调用 unix.EpollWait,根据返回的就绪事件在读写缓冲区之间手动流转状态。

  flowchart TD
    Start(Start Epoll Loop) --> Wait[unix.EpollWait]
    Wait --> Loop{For each Event}
    Loop -->|Event.Fd == ListenFd| Accept[unix.Accept -> Non-blocking Socket -> Register to Epoll]
    Loop -->|Event.Events & EpollIn| Read[unix.Read -> Parse HTTP -> Modify to EpollOut]
    Loop -->|Event.Events & EpollOut| Write[unix.Write File -> Finish -> Close Conn]
    Accept --> LoopNext[Next Event]
    Read --> LoopNext
    Write --> LoopNext
    LoopNext --> Wait

2. 代码实现

在下面的 Go 代码中,我们模拟了底层的网络事件分配逻辑:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
// epoll.go
package main

import (
	"bytes"
	"fmt"
	"golang.org/x/sys/unix"
	"net"
	"os"
	"path/filepath"
)

type connection struct {
	fd     int
	fileFd int
	req    []byte
	res    []byte
	eof    bool
}

func runEpollServer(port int) error {
	addr := &unix.SockaddrInet4{Port: port}
	copy(addr.Addr[:], net.IPv4zero)

	sfd, err := unix.Socket(unix.AF_INET, unix.SOCK_STREAM, 0)
	if err != nil {
		return err
	}
	defer unix.Close(sfd)

	unix.SetsockoptInt(sfd, unix.SOL_SOCKET, unix.SO_REUSEADDR, 1)
	if err := unix.Bind(sfd, addr); err != nil {
		return err
	}
	if err := unix.Listen(sfd, 128); err != nil {
		return err
	}
	unix.SetNonblock(sfd, true)

	epfd, err := unix.EpollCreate1(0)
	if err != nil {
		return err
	}
	defer unix.Close(epfd)

	event := &unix.EpollEvent{
		Events: unix.EPOLLIN,
		Fd:     int32(sfd),
	}
	if err := unix.EpollCtl(epfd, unix.EPOLL_CTL_ADD, sfd, event); err != nil {
		return err
	}

	conns := make(map[int]*connection)
	events := make([]unix.EpollEvent, 64)
	fmt.Printf("epoll server on :%d\n", port)

	for {
		n, err := unix.EpollWait(epfd, events, -1)
		if err != nil {
			if err == unix.EINTR {
				continue
			}
			return err
		}

		for i := 0; i < n; i++ {
			fd := int(events[i].Fd)
			if fd == sfd {
				// Accept incoming connections
				for {
					nfd, _, err := unix.Accept(sfd)
					if err != nil {
						break
					}
					unix.SetNonblock(nfd, true)
					conns[nfd] = &connection{fd: nfd, fileFd: -1}
					ev := &unix.EpollEvent{
						Events: unix.EPOLLIN,
						Fd:     int32(nfd),
					}
					unix.EpollCtl(epfd, unix.EPOLL_CTL_ADD, nfd, ev)
				}
			} else {
				c := conns[fd]
				if (events[i].Events & unix.EPOLLIN) != 0 {
					onReadable(epfd, c)
				} else if (events[i].Events & unix.EPOLLOUT) != 0 {
					onWritable(epfd, c)
				} else {
					closeConnection(epfd, c, conns)
				}
			}
		}
	}
}

func onReadable(epfd int, c *connection) {
	buf := make([]byte, 1024)
	n, err := unix.Read(c.fd, buf)
	if n <= 0 || err != nil {
		closeConnection(epfd, c, nil)
		return
	}
	c.req = append(c.req, buf[:n]...)
	if bytes.Contains(c.req, []byte("\r\n\r\n")) {
		startResponse(epfd, c)
	}
}

func startResponse(epfd int, c *connection) {
	path, err := parseHTTPGet(c.req)
	if err != nil {
		c.res = build404()
		c.eof = true
	} else {
		fullPath := filepath.Join(docRoot, path)
		fileFd, err := unix.Open(fullPath, unix.O_RDONLY, 0)
		if err != nil {
			c.res = build404()
			c.eof = true
		} else {
			c.fileFd = fileFd
			c.res = buildOKHeaders(mimeFor(path))
		}
	}
	ev := &unix.EpollEvent{
		Events: unix.EPOLLOUT,
		Fd:     int32(c.fd),
	}
	unix.EpollCtl(epfd, unix.EPOLL_CTL_MOD, c.fd, ev)
}

func onWritable(epfd int, c *connection) {
	if len(c.res) > 0 {
		n, err := unix.Write(c.fd, c.res)
		if err != nil {
			closeConnection(epfd, c, nil)
			return
		}
		c.res = c.res[n:]
	}

	if len(c.res) == 0 {
		if c.eof {
			closeConnection(epfd, c, nil)
			return
		}
		buf := make([]byte, ioBufSize)
		n, err := unix.Read(c.fileFd, buf)
		if n <= 0 || err != nil {
			c.eof = true
			closeConnection(epfd, c, nil)
			return
		}
		c.res = buf[:n]
	}
}

func closeConnection(epfd int, c *connection, conns map[int]*connection) {
	unix.EpollCtl(epfd, unix.EPOLL_CTL_DEL, c.fd, nil)
	unix.Close(c.fd)
	if c.fileFd >= 0 {
		unix.Close(c.fileFd)
	}
	if conns != nil {
		delete(conns, c.fd)
	}
}

3. epoll 对普通文件的缺陷

正如我们在前面的讨论,epoll 系统调用不支持普通本地磁盘文件(尝试添加会导致 EPERM)。

因此,即便我们在上面的网络逻辑中使用 epoll 异步化了 Socket,但在 onWritable 方法中,调用 unix.Read(c.fileFd) 依旧会由于本地文件读取的同步机制而发生实质性的阻塞。


方案三:异步“内核驱动”的 io_uring 解决方案

io_uring 是 Linux 内核近年最亮眼的新特性。它使用两个位于内核和用户空间共享内存中的环缓冲区(SQ Ring 和 CQ Ring),用户把 I/O 操作提交至提交队列(SQE),内核默默在后台搞定并把结果写入完成队列(CQE)。

  sequenceDiagram
    participant U as Go Program (User Space)
    participant SQ as Submission Queue (Ring Buffer)
    participant K as Kernel Space (Asynchronous Worker)
    participant CQ as Completion Queue (Ring Buffer)
    U->>SQ: Fill SQE (e.g. Read file / Send socket)
    U->>K: io_uring_enter syscall (Submit batch)
    Note over K: Kernel process I/O asynchronously without blocking Go threads
    K->>CQ: Fill CQE (Completion results)
    U->>CQ: Reap CQE (Non-blocking or Wait)
    U->>U: Execute Callback

最可贵的是,io_uring 不仅在网络上表现出色,还能对普通文件执行真正的非阻塞异步读写。

1. Go 使用原生系统调用接入 io_uring 的设计

在 Go 语言中,为了直接接入 io_uring,我们需要通过 syscall.Syscall 直接调用 Linux 的内核系统调用。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
// uring.go (Conceptual Snippet for io_uring Setup in Go)
package main

import (
	"syscall"
	"unsafe"
)

// Linux amd64 io_uring system call numbers
const (
	sysIoUringSetup    = 425
	sysIoUringEnter    = 426
	sysIoUringRegister = 427
)

// Kernel data structures mapped into Go
type ioUringParams struct {
	sqEntries    uint32
	cqEntries    uint32
	flags        uint32
	sqThreadCpu  uint32
	sqThreadIdle uint32
	features     uint32
	resv         [4]uint32
	sqOffsets    ioSqringOffsets
	cqOffsets    ioCqringOffsets
}

type ioSqringOffsets struct {
	head        uint32
	tail        uint32
	ringMask    uint32
	ringEntries uint32
	flags       uint32
	array       uint32
	resv1       uint32
	resv2       uint64
}

type ioCqringOffsets struct {
	head        uint32
	tail        uint32
	ringMask    uint32
	ringEntries uint32
	overflow    uint32
	cqes        uint32
	flags       uint32
	resv1       uint32
	resv2       uint64
}

// io_uring SQE (Submission Queue Entry) layout
type ioUringSqe struct {
	opcode    uint8
	flags     uint8
	ioprio    uint16
	fd        int32
	off       uint64
	addr      uint64
	len       uint32
	rwFlags   uint32
	userData  uint64
	bufIndex  uint16
	personality uint16
	spliceFdIn int32
	pad2      [2]uint64
}

func setupUring(entries uint32) (int, *ioUringParams, error) {
	var params ioUringParams
	fd, _, errno := syscall.Syscall(
		sysIoUringSetup,
		uintptr(entries),
		uintptr(unsafe.Pointer(&params)),
		0,
	)
	if errno != 0 {
		return 0, nil, errno
	}
	return int(fd), &params, nil
}

利用 setupUring 返回的文件描述符,我们可以对内核环的 SQ 和 CQ 的虚拟地址进行 syscall.Mmap 映射,获取到在用户态和内核态之间零拷贝共享的读写数组。

2. 使用 Go 包进行 io_uring 服务器实现

为了避免在生产开发中手写过于底层的 mmap 与环数组同步逻辑,我们通常会选用纯 Go 编写的驱动包装类库(如 github.com/iceber/io_uring-go)来运行异步循环。下面是使用此类包装类库实现事件循环的基本流程结构:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
// uring_server.go
package main

import (
	"fmt"
	"net"
	"os"
	"path/filepath"
	"syscall"

	"github.com/iceber/io_uring-go"
)

type uringConnection struct {
	fd     int
	fileFd int
	req    []byte
	res    []byte
	eof    bool
}

func runUringServer(port int) error {
	// Initialize Go wrapper for io_uring with 256 queue size
	ring, err := iouring.New(256)
	if err != nil {
		return err
	}
	defer ring.Close()

	// Setup raw socket
	sfd, err := syscall.Socket(syscall.AF_INET, syscall.SOCK_STREAM, 0)
	if err != nil {
		return err
	}
	defer syscall.Close(sfd)

	syscall.SetsockoptInt(sfd, syscall.SOL_SOCKET, syscall.SO_REUSEADDR, 1)
	
	addr := &syscall.SockaddrInet4{Port: port}
	copy(addr.Addr[:], net.IPv4zero)
	if err := syscall.Bind(sfd, addr); err != nil {
		return err
	}
	if err := syscall.Listen(sfd, 128); err != nil {
		return err
	}

	fmt.Printf("uring server on :%d\n", port)
	conns := make(map[int]*uringConnection)

	for {
		// Submit non-blocking accept request into submission ring
		// Inside the loop, we reap completion queues asynchronously
		nfd, _, err := syscall.Accept(sfd)
		if err != nil {
			continue
		}

		c := &uringConnection{fd: nfd, fileFd: -1}
		conns[nfd] = c
		
		// In a production server, we submit read/write requests asynchronously to io_uring ring:
		// sqe := ring.PrepareRead(c.fd, readBuffer)
		// ring.Submit()
		// ring.Wait()
		
		// Async reading of static files:
		// sqe := ring.PrepareRead(c.fileFd, fileBuffer)
		// All scheduled synchronously from user space but executed asynchronously in kernel
		go handleUringConn(c) 
	}
}

func handleUringConn(c *uringConnection) {
	// Execute async logic using the submission queue wrappers
}

架构对比与考量

最后再看几个实现取舍。

1. 提交环溢出(Ring Overflow)的处理

由于 io_uring 的环缓冲区(Ring Buffer)大小在初始化时就是固定的,如果在极高吞吐场景下,Go 运行时派发 SQE 的速度远远超过了内核的消费和通知速度,会发生环溢出。

在生产级 Go 代码中,我们应该引入一个通道或进程内队列(In-memory queue)。当无法从空闲环中取得 SQE 时,将这些任务存入进程内队列;并在每次 io_uring_enter 执行后从 CQE 中释放了环空间时,优先消费和补充排队任务,从而避免请求丢失。

2. 为什么选择 io_uring

epoll 相比,io_uring 的优势是同一套接口可以覆盖网络和文件 I/O。对大文件服务来说,它可以把文件读取从“线程阻塞等待内核读完”改成“提交请求后等待 CQE”,减少 Go 运行时线程被同步文件 I/O 卡住的风险。

系统调用频次也是差异点。epoll 仍然需要围绕 epoll_waitreadwrite 组织调用;io_uring 可以批量提交 SQE、批量收 CQE。在请求可以合并、文件 I/O 占比高的场景里,这个模型更容易降低用户态和内核态之间的来回切换。

不过,对大多数 Go 网络程序来说,标准库基于 Goroutine-per-Connection 和 Netpoller 的模型已经够用。手动构建底层 I/O 环更适合文件 I/O 占比高、系统调用开销确实进入瓶颈的代理、网关或存储服务。


参考资源

#Go #Golang #Linux #I/O #Epoll #Io_uring #HTTP Server