EAGAIN错误与文件描述符非阻塞标志

TUN调试 / 14人浏览

凌晨三点,我盯着终端里疯狂滚动的日志,手指在键盘上悬停。屏幕上最后一行赫然写着:

[ERROR] orderbook_insert: Resource temporarily unavailable (fd=23, errno=11)

那是EAGAIN。我知道它意味着什么——文件描述符23上的非阻塞写操作失败了,内核告诉我“现在不行,你等会儿再来”。但我的撮合引擎没有等,它直接把这笔订单扔进了黑洞。那是一个市价卖单,价值三个比特币,在2024年3月的行情里,差不多二十万美元。

我瘫在椅子上,脑子里只有一个念头:为什么我没有处理EAGAIN?

一切从那个“高性能”的承诺开始

三个月前,我们的虚拟币交易平台拿到了A轮融资。CTO老张拍着胸脯说,新架构要上“全异步非阻塞I/O”,所有核心路径都用epoll驱动,文件描述符全部打上O_NONBLOCK标志。他说这是C10M问题的标准解法,是高频交易系统的标配。

我当时刚入职,负责撮合引擎的开发。听到“非阻塞”三个字,第一反应是:酷。第二反应是:应该不难吧?毕竟教科书里写得明明白白——非阻塞模式下,如果操作不能立即完成,系统调用会返回-1,errno被设置为EAGAIN或EWOULDBLOCK。你只需要判断这个返回值,然后重试或者把任务挂起,等事件循环通知你fd可读可写了再继续。

听起来简单得像个if语句。但现实是,在每秒处理十万笔订单的压力下,任何一个被忽略的EAGAIN都可能引发连锁灾难。

那个让我失眠的文件描述符

我们的撮合引擎架构大概是这样的:每个交易对有一个独立的订单簿,订单簿的读写通过一个共享内存区域加一个事件驱动管道来同步。管道两端是epoll管理的非阻塞fd。当新的限价单进来时,写入端通过管道通知读取端去更新订单簿。

那个出事的fd 23,就是某个热门交易对的管道写入端。它被设置成了O_NONBLOCK,因为老张说“不能让一个慢速的写入阻塞整个事件循环”。理论上,如果管道缓冲区满了,write()应该返回-1并设置EAGAIN,然后我们的代码应该把这个写请求放入一个待发送队列,等epoll通知管道可写时再重试。

但我们的代码里,负责管道写入的那个函数长这样:

c ssize_t n = write(fd, buf, len); if (n < 0) { // 出错了,记日志 log_error("write failed: %s", strerror(errno)); return -1; }

看出来问题了吗?它把EAGAIN当作一个普通的错误处理了——记一条日志,然后返回-1。调用方看到返回-1,以为写入彻底失败了,于是直接丢弃了这个订单通知。

更致命的是,EAGAIN发生的频率远比我以为的高。在高频交易时段,当某个交易对突然出现大量订单涌入时,管道缓冲区会迅速填满。理论上,管道缓冲区在Linux上默认是65536字节,看起来不小对吧?但我们的订单通知结构体每个大约128字节,也就是说缓冲区只能容纳512个未处理的通知。当事件循环因为某个耗时操作(比如订单簿的平衡树重平衡)延迟了几毫秒时,512个槽位瞬间就被撑爆了。

EAGAIN不是错误,它是内核在说“慢点”

那晚我查了一整夜的资料,终于明白了EAGAIN的真正含义。在非阻塞I/O的语境下,EAGAIN不是失败,而是“资源暂时不可用,请重试”。它是内核在告诉你:你的生产速度超过了消费速度,需要做流量控制。

处理EAGAIN的正确姿势,从来不是记日志然后放弃。而是:

  1. 把未完成的操作缓存起来:维护一个待发送队列,当write返回EAGAIN时,把数据放入队列。
  2. 注册可写事件:通过epoll_ctl把fd的EPOLLOUT事件加入监听。
  3. 在可写回调中重试:当epoll_wait返回EPOLLOUT事件时,从队列中取出数据重新写入。

听起来复杂,但这是非阻塞编程的基石。我们之前跳过这一步,相当于在高速公路上把“前方拥堵请绕行”的提示牌当成了“此路不通”的封路通知。

重写那个出错的函数

第二天,我重写了管道写入的代码。核心逻辑变成了这样:

c ssizet writetopipe(int fd, struct ordernotification notif) { // 先尝试直接写入 ssize_t n = write(fd, notif, sizeof(notif)); if (n == sizeof(*notif)) { return 0; // 成功 }

if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) {     // 缓冲区满了,加入待发送队列     int ret = enqueue_pending(fd, notif);     if (ret < 0) {         log_error("enqueue_pending failed for fd %d", fd);         return -1;     }     // 注册EPOLLOUT事件     struct epoll_event ev;     ev.events = EPOLLOUT | EPOLLET;     ev.data.fd = fd;     epoll_ctl(epoll_fd, EPOLL_CTL_MOD, fd, &ev);     return 0; // 不是错误,只是延迟了 }  // 真正的错误 log_error("write to fd %d failed: %s", fd, strerror(errno)); return -1; 

}

然后在事件循环的可写回调里:

c void handle_write(int fd) { struct pending_queue *q = get_pending_queue(fd); while (!queue_empty(q)) { struct order_notification *notif = queue_front(q); ssize_t n = write(fd, notif, sizeof(*notif)); if (n == sizeof(*notif)) { queue_pop(q); free(notif); } else if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) { break; // 还是写不进去,等下一次EPOLLOUT } else { // 真正的错误,清理队列 log_error("fatal write error on fd %d: %s", fd, strerror(errno)); clear_queue(q); break; } } // 如果队列空了,取消EPOLLOUT监听 if (queue_empty(q)) { struct epoll_event ev; ev.events = EPOLLIN | EPOLLET; ev.data.fd = fd; epoll_ctl(epoll_fd, EPOLL_CTL_MOD, fd, &ev); } }

这段代码上线后,EAGAIN相关的日志从每天几千条降到了零。不是因为EAGAIN不发生了,而是因为我们正确地处理了它。管道缓冲区满的情况依然存在,但现在数据被缓存起来,等消费者处理完旧数据、缓冲区腾出空间后,再继续写入。

那些被EAGAIN吞噬的订单去哪了

修复之后,我开始复盘那次丢失三个比特币的事故。其实当时还有更隐蔽的问题——不只是管道写入,我们的TCP连接也用了非阻塞模式,而且同样没有正确处理EAGAIN。

虚拟币交易平台和用户客户端之间的通信是通过WebSocket实现的,底层是TCP套接字。TCP的发送缓冲区也会满,特别是在网络拥堵或者客户端消费速度慢的时候。如果send()返回EAGAIN而你直接丢弃了数据,那么用户就会收到不完整的行情推送,甚至订单成交回报丢失。

想象一下:你下了一个买入1个比特币的订单,系统撮合成功了,但成交回报因为EAGAIN被丢弃了。你看到订单状态还是“未成交”,于是又下了一个买入单。结果两个单子都成交了,你多了1个比特币的仓位,而你的保证金根本不够。这就是所谓的“订单丢失导致的双重成交”问题,在虚拟币交易所的历史上,因为这个原因爆仓的散户不计其数。

非阻塞标志不是银弹

另一个让我印象深刻的事情是,O_NONBLOCK标志本身并不是问题,问题在于我们以为设置了它就万事大吉。非阻塞I/O的真正含义是:调用不会阻塞当前线程,但你必须自己管理操作的完成状态。这就像你叫了一份外卖,外卖员告诉你“我不等你开门,放门口就走”,然后你就得自己时不时开门看看外卖到了没有(轮询),或者装一个门铃(事件通知)。

很多刚接触非阻塞编程的人会犯一个错误:他们以为非阻塞就是“永远不会失败”,或者“失败了就重试一次”。实际上,非阻塞I/O的正确使用需要配合事件循环、状态机和重试机制,是一个完整的编程范式,而不是一个简单的标志位。

从EAGAIN到系统设计的反思

那次事故之后,我在团队内部做了一次分享,主题就是“EAGAIN与文件描述符非阻塞标志的正确打开方式”。我画了一张图,展示了数据从用户下单到订单簿更新的完整路径,标出了所有可能发生EAGAIN的点:

  1. WebSocket发送缓冲区:向用户推送行情和成交回报时
  2. 内部管道:不同线程/进程之间传递订单通知时
  3. 日志文件写入:异步日志写入时(我们用的是自己的异步日志库)
  4. 数据库连接:虽然数据库连接通常用阻塞模式,但在某些异步框架里也会遇到

每个点都需要显式地处理EAGAIN,否则就是在系统里埋雷。

老张听完之后沉默了很长时间,然后说了一句让我至今记忆犹新的话:“我们一直在追求极致的性能,却忘了性能的代价是你要为每一个可能的失败路径写代码。非阻塞I/O把控制权交给了开发者,但很多人只拿到了权力,没拿到责任。”

一个更极端的教训

后来我在一个技术社群里看到一个更极端的案例。某二线交易所的做市商程序因为EAGAIN处理不当,导致在比特币价格暴跌时,做市商的撤单指令被大量丢弃。正常情况下,做市商看到市场剧烈波动,会迅速撤销已有的限价单来规避风险。但因为非阻塞TCP发送返回了EAGAIN,而他们的代码直接跳过了这些撤单请求,结果做市商的订单全部以远低于市场价的价格成交,瞬间亏损了上千个比特币。

那个做市商后来起诉了交易所,理由是交易所提供的API文档没有明确指出非阻塞模式下的EAGAIN处理要求。虽然最终庭外和解了,但这个案例在圈内流传了很久,成为每个虚拟币系统架构师必读的“反面教材”。

如何优雅地处理EAGAIN

经过这些教训,我总结了一套处理EAGAIN的实用原则,现在分享给你:

理解你的资源边界

每个文件描述符背后都有内核缓冲区,缓冲区的大小是有限的。TCP的发送缓冲区可以通过setsockoptSO_SNDBUF选项调整,管道的缓冲区可以通过fcntlF_SETPIPE_SZ调整。但不管怎么调,总有满的时候。设计系统时,要假设缓冲区会满,而不是假设它永远够用。

建立背压机制

当EAGAIN频繁发生时,说明生产者太快了。这时候需要把压力传回去。比如在撮合引擎里,如果管道写入频繁返回EAGAIN,就应该减慢从网络读取新订单的速度,或者使用一个有界队列来缓冲传入的订单,当队列满时直接拒绝新订单(返回“系统繁忙,请稍后重试”)。

区分EAGAIN和真正的错误

这是一个老生常谈但总被忽视的点。EAGAIN的errno值是11(在Linux上),EWOULDBLOCK在某些系统上也是11,在另一些系统上是35。永远不要用errno == EAGAIN来判断,应该用errno == EAGAIN || errno == EWOULDBLOCK,或者直接用(errno == EAGAIN),因为POSIX规定这两个值可以相等。

更重要的是,不要把所有负返回值都当成错误。有些代码会写:

c if (write(fd, buf, len) < 0) { // 错误处理 }

这在阻塞模式下没问题,但在非阻塞模式下,EAGAIN会让write返回-1,而它并不是真正的错误。正确的做法是:

c ssize_t n = write(fd, buf, len); if (n < 0) { if (errno == EAGAIN || errno == EWOULDBLOCK) { // 重试或缓存 } else { // 真正的错误 } }

使用高级抽象

如果你用的是C++,可以考虑用Boost.Asio或者libuv这样的库,它们内部已经处理了EAGAIN。如果你用的是Go,它的net包在非阻塞模式下会自动重试EAGAIN。但如果你像我一样用纯C写高性能系统,那就得自己处理每一个细节。

那三个比特币的后续

事故发生后,我们花了三天时间回放所有日志,找到了那个被丢弃的订单。幸运的是,那个市价卖单最终在另一个交易对里被部分成交了,实际损失没有三个比特币那么多——大约0.3个比特币,折合两万美元左右。

公司承担了这笔损失,因为是我们系统的问题。老张在周会上说:“这次事故的学费是两万美元,但学到的东西值两百万。从今天开始,所有涉及非阻塞I/O的代码,必须通过EAGAIN处理的code review。”

现在,每当我看到新人写非阻塞代码时,我都会想起那个凌晨三点的终端屏幕。我会告诉他们:EAGAIN不是错误,它是内核在说“慢点,等等我”。如果你听到了这句话却假装没听见,那才是真正的错误。

在虚拟币这个每秒波动几个百分点的世界里,每一毫秒的延迟都意味着真金白银的得失。但比延迟更可怕的,是那些被忽略的EAGAIN——它们不会立即爆炸,而是像定时炸弹一样埋在系统深处,等着在最关键的时刻给你致命一击。

所以,如果你也在写虚拟币交易系统,请善待每一个EAGAIN。它值得你为它写十行代码,而不是一行日志。

版权声明:

作者: 最新鸿蒙OS VPN免费节点分享

链接: https://harmonyosvpn.com/tun-debug/eagain-nonblocking-flag.htm

来源: harmonyosvpn.com

文章版权归作者所有,未经允许请勿转载。

最新文章

归档

标签