IO

qianmoQqianmoQ· 更新于 2026-10-05· 阅读 12 分钟· 0 次阅读

登录后可跨设备保存划线和私人笔记登录

IO

了解 bRPC 的 IO。

通常有三种操作 IO 的机制:

  • 阻塞 IO:一旦发起 IO 操作,调用线程就会一直阻塞到 IO 结束,这是一种同步 IO,例如 posix 的 read 和 write 的默认行为。
  • 非阻塞 IO:如果没有数据可读,或写入数据过多,原本会阻塞的 API 会立即返回一个错误码。非阻塞 IO 常与 IO 多路复用(Linux 中的 poll、select、epoll,或 BSD 中的 kqueue)配合使用。
  • 异步 IO:发起读或写操作时附带一个回调,IO 完成后该回调会被调用,例如 Windows 中的 OVERLAPPED + IOCP。Linux 的原生 AIO 只支持文件。

在 Linux 中,非阻塞 IO 通常用于提升 IO 并发能力。当 IO 并发较低时,非阻塞 IO 不一定比阻塞 IO 更高效,因为后者完全由内核处理。read/write 这类系统调用经过高度优化,效率可能更高。但当 IO 并发增加时,阻塞 IO“一线程一阻塞”的缺点就暴露出来了:内核不断在各线程之间切换以执行有效工作,一个 CPU 核心可能只执行了一点点工作就被切换给另一个线程,导致 CPU 缓存得不到充分利用。此外,大量线程会降低依赖线程局部变量的代码的性能,例如 tcmalloc。一旦 malloc 变慢,程序的整体性能也会随之下降。与之相对,非阻塞 IO 通常由相对较少的事件分发线程和工作线程(运行用户代码)组成,这些线程被不同任务复用(换句话说,部分调度工作被移到了用户态)。事件分发器和工作线程同时运行在不同的 CPU 核心上,无需在内核中频繁切换。由于不需要大量线程,线程局部变量也能得到更充分的利用。所有这些因素使得非阻塞 IO 比阻塞 IO 更快。但非阻塞 IO 也有自身的问题,其中之一是系统调用更多,例如 epoll_ctl。由于 epoll 以红黑树实现,epoll_ctl 并不是一个很快的操作,在多线程环境中尤其如此。严重依赖 epoll_ctl 的实现往往会遇到多核可扩展性问题。非阻塞 IO 还必须解决大量多线程问题,产生的代码也比阻塞 IO 更复杂。

接收消息

消息是从连接上读取的有界二进制数据,它可能来自上游客户端的请求,也可能来自下游服务器的响应。brpc 使用一个或多个 EventDispatcher(称为 EDISP)来等待文件描述符上的事件。与常见的「IO 线程」不同,EDISP 并不负责读写。IO 线程的问题在于,一个线程在同一时刻只能读取一个 fd,当一个 IO 线程中的多个 fd 都很繁忙时,其他的读取就会被延迟。多租户、复杂的负载均衡以及 Streaming RPC 使这个问题更加严重。在高负载下,某个 fd 上规律性的长时间延迟会拖慢该 IO 线程中所有的 fd,造成更多的长尾。

由于 epoll(在开发 brpc 时)的一个 bug 以及 epoll_ctl 的开销,EDISP 采用边缘触发模式。收到事件后,与该 fd 关联的一个原子变量会原子地加一。如果该变量在加一之前为零,就会启动一个 bthread 来处理该 fd 的数据。运行 EDISP 的 pthread worker 会让出给新创建的 bthread,使其尽快开始读取并获得更好的缓存局部性。而 EDISP 所在的 bthread 则会被迁移到另一个 pthread 上继续运行,这正是 bthread 中的 work stealing 机制。要确切理解该原子变量的工作方式,可以先阅读 原子指令,再查看 Socket::StartInputEvent。这些方法使得针对同一个 fd 的事件分发是 wait-free 的。

InputMessenger 负责切分消息,并通过可定制的回调处理不同格式的数据。Parse 回调从二进制数据中切分消息,其运行时间相对稳定;Process 则进一步解析消息(例如使用 protobuf 解析),并调用用户的回调,这些回调的运行时间各不相同。如果从该 fd 中读取到 n(n > 1)条消息,InputMessenger 会启动 n-1 个 bthread 分别处理前 n-1 条消息,而在原地处理最后一条消息。InputMessenger 会逐一尝试各种协议。由于一条连接通常只有一种类型的消息,InputMessenger 会记住当前协议,以免下次再逐个尝试。

由此可见,来自不同 fd 甚至同一 fd 的消息在 brpc 中是并发处理的,这使得 brpc 擅长处理大消息,并能在高负载下减少处理来自不同来源的消息时的长尾。

发送消息

消息是写入连接的有界二进制数据,可能是对上游客户端的响应,也可能是发往下游服务器的请求。多个线程可以同时向同一个 fd 发送消息,但对 fd 的写入并非原子操作,因此如何高效地对不同线程的写入进行排队是一项关键技术。brpc 使用一种特殊的无等待 MPSC 链表来解决该问题。所有待写数据都被放入单链表的一个节点中,其 next 指针指向一个特殊值(Socket::WriteRequest::UNCONNECTED)。当某个线程想要写出数据时,它会先尝试用该节点与链表头(Socket::_write_head)进行原子交换。如果交换前的头为空,则调用者获得写权限,并就地一次性写出数据;否则必然有另一个线程正在写入。调用者将 next 指针指向返回的头,从而使链表连接起来。正在写入的线程稍后会看到新的头,并写出新数据。

这种方法使写入竞争成为无等待的。虽然获得写权限的线程在原理上既非无等待也非无锁,并且可能被一个仍处于 UNCONNECTED 状态的节点阻塞(发出写入的线程在原子交换之后、设置 next 指针之前被操作系统换出,这只隔了一条指令的执行时间),但这种阻塞在实践中极少发生。在当前实现中,如果数据无法在一次调用中全部写完,会创建一个 KeepWrite bthread 来写出剩余数据。这一机制相当复杂,其原理如下图所示。详情请阅读 socket.cpp。

img

由于 brpc 中的写入总能在短时间内完成,调用线程可以更快地处理新任务,后台的 KeepWrite 线程也能在一次批量中写入更多任务,从而形成流水线,提高高吞吐下的 IO 效率。

Socket

Socket 包含与 fd 相关的数据结构,是 brpc 中最复杂的结构之一。该结构的独特之处在于,它使用 64 位的 SocketId 来引用 Socket 对象,从而便于在多线程环境中使用 fd。常用方法:

  • Create:创建一个 Socket 并返回其 SocketId。
  • Address:根据 id 获取 Socket,并将其封装进会被自动释放的 unique_ptr(SocketUniquePtr) 中。当 Socket 被置为失败状态时,返回的指针为空。只要 Address 返回非空指针,就保证在该指针析构之前其内容不会改变。该函数是无等待的。
  • SetFailed:将某个 Socket 标记为失败,对应的 SocketId 上调用 Address() 将返回空指针(直到健康检查恢复该 socket)。当不再有人引用该 Socket 时,它会被回收。该函数是无锁的。

我们能看到,Socket 在引用计数方面类似于 shared_ptr](http://en.cppreference.com/w/cpp/memory/shared_ptr),而 SocketId 则类似于 weak_ptr](http://en.cppreference.com/w/cpp/memory/weak_ptr)。独特的 SetFailed 防止了 Socket 被寻址,从而使引用计数最终能够归零。单纯使用 shared_ptr/weak_ptr 无法保证这一点。例如,当服务器需要在请求仍频繁到来时退出,Socket 的引用计数可能不会归零,服务器也就无法快速停止。此外,weak_ptr 无法直接放入 epoll 的 data 中,而 SocketId 可以。这些因素促成了 Socket 的设计,它自 2014 年以来一直稳定且极少改动。

使用 SocketUniquePtr 还是 SocketId,取决于是否需要强引用。例如,Controller 在 RPC 内部被大量使用,与 Socket 有频繁交互,因此它使用 SocketUniquePtr。Epoll 通知的是文件描述符(fd)上的事件,已被回收的 socket 所产生的事件可以忽略,因此 epoll 使用 SocketId。

只要 SocketUniquePtr 有效,其中封装的 Socket 就不会发生变化,因此用户无需关心竞争条件和 ABA 问题,操作共享 socket 更加安全。这种方式还避免了隐式引用计数,使内存所有权更加清晰,从而产出质量更高的程序。brpc 大量使用 SocketUniquePtr 和 SocketId 来简化相关问题。

事实上,Socket 管理的不仅是原生 fd,还包括其他资源,例如 SelectiveChannel 中的 SubChannel 也由 Socket 管理,使得 SelectiveChannel 选择 SubChannel 就像普通 channel 选择下游服务器一样。这个虚拟的 Socket 甚至实现了健康检查。流式 RPC 同样使用 Socket,以复用无等待写入的代码。

全貌

img


最后修改于 2022 年 2 月 26 日:brpc 网站 1.0 修复概览页面中的链接跳转问题 (14eec1ac1)

评论

登录后参与评论

正在加载评论…