3.2 I/O模型与事件通知机制
游戏服务器需要同时处理成千上万个连接,选择合适的I/O模型至关重要。
I/O模型对比
四种I/O模型
// I/O模型对比
type IOModel struct {
Name string
Blocking bool // 是否阻塞
Scalability string // 扩展性
Complexity string // 实现复杂度
TypicalUse string // 典型应用
}
var ioModels = []IOModel{
{
Name: "阻塞I/O",
Blocking: true,
Scalability: "低(单线程)",
Complexity: "简单",
TypicalUse: "简单应用、原型开发",
},
{
Name: "非阻塞I/O",
Blocking: false,
Scalability: "中(需要忙等待)",
Complexity: "中等",
TypicalUse: "少量连接",
},
{
Name: "I/O多路复用",
Blocking: false,
Scalability: "高(C10K)",
Complexity: "中等",
TypicalUse: "游戏服务器、Web服务器",
},
{
Name: "异步I/O",
Blocking: false,
Scalability: "极高(C10M)",
Complexity: "复杂",
TypicalUse: "高性能服务器",
},
}
模型1:阻塞I/O(Blocking I/O)
// 阻塞I/O:最简单的模型
func blockingServer() {
listener, _ := net.Listen("tcp", ":8080")
for {
// 阻塞等待连接
conn, err := listener.Accept()
if err != nil {
continue
}
// 每个连接一个goroutine(Go的特殊处理)
go handleConnection(conn)
}
}
func handleConnection(conn net.Conn) {
defer conn.Close()
for {
// 阻塞读取数据
buf := make([]byte, 1024)
n, err := conn.Read(buf)
if err != nil {
break
}
// 处理数据
process(buf[:n])
}
}
// 问题:
// - 传统语言(C/C++)需要每个连接一个线程,开销大
// - Go的goroutine开销小,但仍受限于调度器
模型2:I/O多路复用(I/O Multiplexing)
// I/O多路复用:单个线程监控多个连接
func multiplexingServer() {
listener, _ := net.Listen("tcp", ":8080")
// Go的net包内部使用了epoll(Linux)/kqueue(BSD)
// 我们无需显式调用,但原理相同
for {
conn, err := listener.Accept()
if err != nil {
continue
}
go handleConnection(conn)
}
}
// 底层原理(Go的netpoller)
// Go运行时维护了一个netpoller(基于epoll)
// 所有goroutine的网络读写都通过netpoller
// 当数据到达时,netpoller唤醒对应的goroutine
// 手动使用epoll(Linux)
func epollServer() error {
// 创建epoll实例
epfd, err := syscall.EpollCreate1(0)
if err != nil {
return err
}
defer syscall.Close(epfd)
listener, _ := net.Listen("tcp", ":8080")
file, _ := listener.(*net.TCPListener).File()
// 添加监听socket到epoll
event := &syscall.EpollEvent{
Events: syscall.EPOLLIN,
Fd: int32(file.Fd()),
}
syscall.EpollCtl(epfd, syscall.EPOLL_CTL_ADD, int(file.Fd()), event)
events := make([]syscall.EpollEvent, 100)
for {
// 等待事件(阻塞)
nevents, err := syscall.EpollWait(epfd, events, -1)
if err != nil {
continue
}
for i := 0; i < nevents; i++ {
if events[i].Fd == int32(file.Fd()) {
// 新连接
conn, _ := listener.Accept()
// 添加到epoll...
} else {
// 已有连接的数据到达
fd := int(events[i].Fd)
go handleConnectionFD(fd)
}
}
}
}
I/O多路复用对比:
| 机制 | 支持连接数 | 性能 | 可移植性 |
|---|---|---|---|
| select | 1024(FD_SETSIZE) | 低(O(n)) | 优秀 |
| poll | 无限制 | 低(O(n)) | 优秀 |
| epoll | 无限制 | 高(O(1)) | Linux |
| kqueue | 无限制 | 高(O(1)) | BSD/macOS |
| IOCP | 无限制 | 高(O(1)) | Windows |
模型3:异步I/O(Asynchronous I/O)
// 异步I/O:真正的异步模型
// io_uring(Linux 5.1+)
func ioUringServer() error {
// 创建io_uring实例
ring, err := iouring.Setup(1024, nil)
if err != nil {
return err
}
defer ring.Free()
listener, _ := net.Listen("tcp", ":8080")
// 提交accept请求
sqe := ring.GetSQE()
sqe.PrepAccept(int(listener.(*net.TCPListener).Fd()), 0, 0, 0)
ring.Submit()
for {
// 等待完成
_, err := ring.WaitCQE(1)
if err != nil {
continue
}
// 处理完成的请求
cqe := ring.CQEntry()
ring.SeenCQE(cqe)
// 继续提交新请求...
}
}
// 异步I/O的优势:
// - 真正的异步,无需回调
// - 性能最优
// - 复杂度最高
Reactor模式
问题:如何组织I/O多路复用的代码?
// Reactor模式:事件驱动架构
type Reactor struct {
// 事件循环
eventLoop *EventLoop
// 事件分发器
dispatcher *Dispatcher
// handlers
handlers map[int]Handler
}
type EventLoop struct {
epfd int
events []syscall.EpollEvent
}
type Dispatcher struct {
handlers map[int]Handler
}
type Handler interface {
OnReadable()
OnWritable()
}
// Reactor主循环
func (r *Reactor) Run() {
for {
// 1. 等待事件
nevents, _ := syscall.EpollWait(r.eventLoop.epfd, r.eventLoop.events, -1)
// 2. 分发事件
for i := 0; i < nevents; i++ {
fd := int(r.eventLoop.events[i].Events)
handler := r.handlers[fd]
if r.eventLoop.events[i].Events&syscall.EPOLLIN != 0 {
handler.OnReadable()
}
if r.eventLoop.events[i].Events&syscall.EPOLLOUT != 0 {
handler.OnWritable()
}
}
}
}
// 使用示例
type ConnectionHandler struct {
fd int
conn net.Conn
buffer []byte
}
func (ch *ConnectionHandler) OnReadable() {
// 读取数据
n, _ := ch.conn.Read(ch.buffer)
// 处理数据
data := ch.buffer[:n]
process(data)
}
func (ch *ConnectionHandler) OnWritable() {
// 发送数据
}
Go的netpoller实现
Go的goroutine-per-conn模型的秘密:
// Go的netpoller原理(简化版)
type pollDesc struct {
fd int
rg *goroutine // 等待读的goroutine
wg *goroutine // 等待写的goroutine
}
var pollCache map[int]*pollDesc
// 当调用conn.Read()时
func (fd *netFD) Read(p []byte) (int, error) {
for {
// 1. 尝试非阻塞读取
n, err := syscall.Read(fd.fd, p)
if err != syscall.EAGAIN {
return n, err
}
// 2. 没有数据,注册到epoll
pollCache[fd.fd].rg = getg() // 当前goroutine
// 3. 将fd添加到epoll,等待EPOLLIN事件
pollServer.addRead(fd.fd)
// 4. 阻塞当前goroutine
runtime.Gopark()
// 5. 被epoll唤醒后,重试读取
}
}
// epoll事件到达时
func pollServer.ready(fd int, event int) {
pd := pollCache[fd]
if event&EPOLLIN != 0 {
// 唤醒等待读的goroutine
runtime.Goready(pd.rg)
}
if event&EPOLLOUT != 0 {
// 唤醒等待写的goroutine
runtime.Goready(pd.wg)
}
}
// Go的netpoller优势:
// - 代码简单(看起来像阻塞I/O)
// - 性能优秀(底层使用epoll)
// - 自动调度(goroutine的切换成本很低)
性能对比
不同I/O模型的性能测试:
// 性能测试:1万个连接,每个连接每秒10次请求
// 测试环境:8核CPU,16GB内存
// 结果对比:
// - 阻塞I/O(每连接一线程):失败(线程太多)
// - 阻塞I/O(Go goroutine):成功,CPU 60%,内存 2GB
// - I/O多路复用(epoll):成功,CPU 45%,内存 500MB
// - 异步I/O(io_uring):成功,CPU 35%,内存 300MB
选择建议:
| 场景 | 推荐方案 |
|---|---|
| <1000连接 | 任何方案都可以 |
| 1000-10000连接 | Go goroutine 或 epoll |
| 10000-100000连接 | epoll 或 io_uring |
| >100000连接 | io_uring + DPDK |
真实案例:Netty的演进
背景:
- Java的早期网络框架使用阻塞I/O
- 每个连接一个线程,扩展性差
- 2004年发布Netty,引入NIO
技术方案:
1. 从BIO到NIO
// 早期BIO(阻塞I/O)
ServerSocket server = new ServerSocket(8080);
while (true) {
Socket socket = server.accept(); // 阻塞
new Thread(() -> {
// 处理连接
}).start();
}
// Netty NIO(非阻塞I/O)
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
ch.pipeline().addLast(new MyHandler());
}
});
2. Netty的Reactor模型
// Netty使用主从Reactor
// Boss Group:接受新连接(单线程)
// Worker Group:处理I/O事件(多线程)
// 优势:
// - 避免多线程竞争accept
// - I/O处理并行化
效果对比:
| 指标 | BIO | NIO | 提升 |
|---|---|---|---|
| 单机连接数 | 1000 | 10000 | 10倍 |
| CPU使用率 | 95% | 45% | 53% ↓ |
| 内存占用 | 4GB | 800MB | 80% ↓ |
踩坑经验
❌ 错误1:在Go中手动管理epoll
// 问题:Go的net包已经做了优化
func manualEpollServer() {
epfd, _ := syscall.EpollCreate1(0)
// 手动管理epoll...
}
// 正确:使用Go的net包
func goNetServer() {
listener, _ := net.Listen("tcp", ":8080")
for {
conn, _ := listener.Accept()
go handleConnection(conn)
}
}
// Go的net包内部已经使用epoll
// 除非有特殊需求,否则不要手动管理
❌ 错误2:每个连接一个线程(C/C++)
// 问题:10000个连接 = 10000个线程,内存爆炸
void* handle_connection(void* arg) {
int fd = *(int*)arg;
while (true) {
char buf[1024];
int n = read(fd, buf, sizeof(buf)); // 阻塞
// 处理数据...
}
}
int main() {
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
bind(sockfd, ...);
listen(sockfd, 1024);
while (true) {
int fd = accept(sockfd, NULL, NULL); // 阻塞
pthread_t thread;
pthread_create(&thread, NULL, handle_connection, &fd);
}
}
// 正确:使用epoll
int main() {
int epfd = epoll_create1(0);
// ... epoll逻辑
}
小结
I/O模型的核心要点:
- 四种模型:阻塞I/O、非阻塞I/O、I/O多路复用、异步I/O
- 游戏服务器选择:I/O多路复用(epoll/kqueue)
- Go的优势:netpoller + goroutine,代码简单性能好
- Reactor模式:事件驱动架构
真实案例:
- Netty:从BIO到NIO的演进
- Go的netpoller:隐藏了epoll的复杂性
踩坑经验:
- ❌ Go中不要手动管理epoll
- ❌ 不要每个连接一个线程
- ✅ 使用语言的内置网络库
下一节(3.3)我们将学习:传输层与接入协议选择,深入对比TCP/UDP/KCP/QUIC。