ᕕ( ᐛ )ᕗ Jimyag's Blog

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

Last modified:

文件服务器是探索操作系统 I/O 方法的绝佳途径,因为我们能够编写一个相对简单但能同时涵盖网络和磁盘 I/O 的程序。随着需求的扩展,我们还可以对其进行无止境的优化与增强(例如动态压缩、完整性检查、Keep-Alive、上传功能、内存管理等)。

虽然 C 语言是传统的底层探索工具,但在现代高并发场景下,Go 语言以其独特的运行时调度成为了高性能网络服务的主力。本文将带你通过 Go 语言重新审视三种 I/O 模型:同步多协程模式(借助 Go 的调度器底层包装)、基于原生系统调用的 epoll 事件驱动模型,以及 Linux 最前沿的真正异步 I/O 接口 io_uring


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

在网络与高性能存储引擎中,epollio_uring 分别代表了两个时代的顶峰。要理解它们为何能获得极高吞吐量,我们必须拆解它们在内核态的数据结构与调用开销。

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 彻底消除了每次 I/O 操作的系统调用(Syscall)开销。其核心在于用户空间与内核空间共享的内存区域:

 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 直接映射在用户态和内核态之间共享的,因此在向队列填充请求和读取结果时,不需要进行任何数据拷贝,并且利用无锁循环队列设计(通过内存屏障操作 head 和 tail 指针),做到了真正意义上的零拷贝和零系统调用交互。

io_uring 的三种运行模式

  • 中断驱动模式(Interrupt Driven):默认模式。用户态填充 SQE 后,调用 io_uring_enter 系统调用通知内核有新请求,内核工作线程执行完 I/O 后通过中断通知。
  • 轮询模式(Polled):专门针对高速块存储(如 NVMe SSD)。内核无需通过中断获取完成状态,而是通过轮询(Polled)硬件驱动来检测就绪状态,延迟极低。
  • 内核提交轮询模式(Kernel Submission Queue Polling, SQPOLL):极其强大的高性能模式。开启此标志后,内核会在后台启动一个专用内核线程(io_wq)不断轮询(Poll)SQ 环。用户态程序只需要把请求写入 SQ 环中,内核线程会自动检测并带走请求执行。在这个过程中,用户程序不需要发起任何一个系统调用,就完成了全部的 I/O 操作。

共享的基础代码

在展示具体的 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 不仅能实现网络非阻塞,也能解决 epoll 对磁盘文件的短板,实现真正的磁盘 I/O 异步化,保证 Go 应用程序不会由于读取超大磁盘文件发生运行时 M 线程被内核卡住的现象。

此外,在系统调用频次上,epoll 每次触发读写都会产生多次系统调用;而 io_uring 可以通过批量提交(在一个 Syscall 中最多提交 $QUEUE_DEPTH 个 SQE 请求),极大降低用户态与内核态的频繁切换开销。在高并发和大量请求合并场景中,io_uring 相比 epoll 有着更高的吞吐量优势。

然而,对于大多数普通的 Go 网络程序来说,标准库自带的基于 Goroutine-per-Connection 的 Netpoller 已足够高效,手动构建底层 I/O 环仅适合在需要压榨磁盘文件读取性能极限或需要极致减少系统调用开销的超高并发底层代理网关中使用。


参考资源

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