Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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多路复用对比

机制支持连接数性能可移植性
select1024(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处理并行化

效果对比

指标BIONIO提升
单机连接数10001000010倍
CPU使用率95%45%53% ↓
内存占用4GB800MB80% ↓

踩坑经验

❌ 错误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模型的核心要点:

  1. 四种模型:阻塞I/O、非阻塞I/O、I/O多路复用、异步I/O
  2. 游戏服务器选择:I/O多路复用(epoll/kqueue)
  3. Go的优势:netpoller + goroutine,代码简单性能好
  4. Reactor模式:事件驱动架构

真实案例

  • Netty:从BIO到NIO的演进
  • Go的netpoller:隐藏了epoll的复杂性

踩坑经验

  • ❌ Go中不要手动管理epoll
  • ❌ 不要每个连接一个线程
  • ✅ 使用语言的内置网络库

下一节(3.3)我们将学习:传输层与接入协议选择,深入对比TCP/UDP/KCP/QUIC。