35、设计原则在网络库中的应用:Reactor/Proactor模式中的设计原则,回调与DIP

网络编程,说白了就是处理“数据来了”和“数据走了”这两件事。但怎么通知你?谁来干活?这里面的门道,直接决定了你的网络库是优雅还是拧巴。

我这些年看过不少网络库的代码,有的写得像一团乱麻,回调嵌回调,最后自己都搞不清数据流到哪了。有的则清晰得像一张地图,每个模块各司其职。区别在哪?就在于有没有用好设计原则,尤其是依赖倒置原则(DIP)

今天我们就聊聊,Reactor和Proactor这两种经典模式里,设计原则是怎么落地的。特别是回调机制,它和DIP到底是什么关系?

Reactor模式:来了活,你叫我

Reactor模式,你可以把它想象成一个前台接待员。你告诉前台:“如果张三来了,请通知我。”然后前台就坐在那里等。张三一到,前台就喊你:“嘿,张三来了,你去处理一下。”

这个“前台”就是Reactor。它负责监听事件(比如socket可读、可写),然后分发给你注册好的回调函数。

核心流程是这样的:

  1. 你把一个文件描述符(fd)和对应的回调函数注册到Reactor上。
  2. Reactor内部用一个事件循环(event loop)去轮询这些fd。
  3. 一旦某个fd有事件发生,Reactor就调用你之前注册的那个回调。

这里的关键是:谁依赖谁?

传统写法里,你的业务逻辑代码会直接调用底层的select或epoll。这就好比你自己去前台盯着,谁来了你亲自喊。耦合度很高,换个操作系统就得重写。

而Reactor模式把这件事倒过来了。底层的事件循环不再依赖你的业务代码,而是你的业务代码通过注册回调,反过来依赖底层的事件分发机制。这就是依赖倒置

核心要点:Reactor模式中,高层模块(业务逻辑)不依赖底层模块(事件循环),而是双方都依赖抽象(回调接口)。

Proactor模式:活干完了,你告诉我

Proactor模式则更进一步。它不只是通知你“张三来了”,而是直接把张三领到你面前,甚至帮你把茶都泡好了。

在Proactor模式里,你发起一个异步操作(比如异步读),然后就不用管了。操作系统内核帮你把数据读到缓冲区里,然后通知你:“数据已经准备好了,你直接用吧。”

这个模式在Windows的IOCP(I/O完成端口)上用得比较多。Linux下的libuv也支持类似的概念。

我曾在项目中用Proactor模式写过文件服务器。当时有个坑,就是异步操作的回调里,千万不能做耗时操作。因为回调是在内核线程里执行的,你卡住了,整个I/O线程池就跟着遭殃。

注意:Proactor模式虽然减少了用户态的等待,但回调的执行上下文需要特别小心。我曾经因为回调里加了一把锁,结果导致I/O线程池死锁,排查了一整天才找到原因。

回调与DIP:一对天生的搭档

你发现没有?无论是Reactor还是Proactor,回调都是核心机制。而回调的本质,就是依赖倒置的一种实现方式。

传统的函数调用是“我调用你”,高层调用底层。而回调是“你调用我”,底层反过来调用高层。这不就是DIP说的“高层模块不应该依赖底层模块,两者都应该依赖抽象”吗?

回调函数就是那个“抽象”。

举个例子:

// 传统写法:高层依赖底层
void handle_connection(int fd) {
    char buf[1024];
    int n = read(fd, buf, sizeof(buf)); // 直接调用底层read
    // 处理数据...
}

// 使用回调:底层依赖抽象
void on_data_ready(int fd, void* ctx) {
    // 底层通知我数据来了,我来处理
    char buf[1024];
    int n = read(fd, buf, sizeof(buf));
    // 处理数据...
}

// 注册回调
reactor->register(fd, EVENT_READ, on_data_ready, NULL);

你看,第一种写法里,handle_connection直接调用了read,耦合了系统调用。第二种写法里,on_data_ready只是一个回调,它不知道是谁调用的它,也不关心底层是epoll还是select。这就是解耦。

避坑指南:回调的陷阱

回调虽好,但用不好就是灾难。我总结了几条经验:

  • 回调里不要阻塞:Reactor的事件循环是单线程的,你阻塞了,其他事件就都等着。我曾经在回调里写了个日志,结果日志库内部用了锁,导致整个事件循环卡住。
  • 注意回调的生命周期:回调里捕获的lambda变量,一定要确保在回调执行时还活着。我见过有人捕获了局部变量的引用,结果回调执行时变量已经析构了,程序直接崩溃。
  • 回调的异常处理:回调里抛出的异常,如果没被捕获,会直接导致事件循环退出。所以回调里最好用try-catch包一层。

小技巧:我习惯在注册回调时,同时注册一个错误回调。这样即使出错了,也能优雅地处理,而不是直接崩溃。

SVG:Reactor与Proactor的核心逻辑对比

下面这张图,我画了Reactor和Proactor的核心流程对比。你可以直观地看到,两者的区别在于“谁负责干活”。

Reactor vs Proactor 核心流程对比 Reactor 模式 1. 注册 fd + 回调函数 2. Reactor 轮询事件 3. 事件到达,通知回调 4. 回调中执行 read/write Proactor 模式 1. 发起异步 read/write 2. 内核执行 I/O 操作 3. 数据已就绪,通知回调 4. 回调中直接处理数据 区别:Reactor 通知你“可以读了”,Proactor 通知你“已经读完了”

总结

Reactor和Proactor,本质上都是对事件驱动的封装。它们通过回调机制,实现了依赖倒置,让高层业务逻辑和底层I/O细节解耦。

我个人更倾向于在Linux下用Reactor,因为epoll的成熟度很高。而在Windows下,IOCP的Proactor模式性能确实更好。但不管用哪种,记住一点:回调是你的朋友,但别让它变成你的噩梦

嗯,关于回调的生命周期管理,其实还有很多细节可以聊。比如智能指针怎么用,weak_ptr怎么避免循环引用。这些我们后面再展开。


专注资料整理