上周提到了 tun/tap 转发框架的数据通道结构和优化 tun/tap 转发性能优化,涉及 RingBuffer,packetization 等核心话题。我也给出了一定的数据结构以及处理逻辑,但竟然没有高尚的 epoll,本文说说它,因为它不适合。
epoll 作为 select,poll 的升级替代,它的优势在于 “大量描述符场景,主动通知 IO 事件而无需遍历查找 IO 事件”,这意味着在少量描述符场景,epoll 并无优势,反而增加复杂性,但复杂性并没什么大不了的,本文主要强调,epoll 本质上是在串行处理 I 和 O,这导致双向流量的严重性能问题。
万事皆有因,异步多路复用机制提供的能力是 “将对描述符的 I 和 O 同时复用到同一个线程中”,select,poll,epoll 本质上都是一回事,它们作为一个整体,适合做什么,不适合做什么,要搞清楚,而不能将它们看做不同的东西,因为这样一来,你很容易陷入 epoll 降维打击 select,进而万能无敌的陷阱。
epoll 擅长业务消息的分拣处理,仅分拣消息后交由专门的线程,或直接处理短消息,但对于 tun/tap 等构建的隧道上的持续流量,同一个 socket 的 recv,send 在同一个循环体中会导致半双工问题,且同一 socket 的 recv 和 send 间,以及不同 socket 的 recv/send 间串行会导致饥饿,这需要引入一个复杂的公平调度机制来解决。总而言之,这种非 “多路复用”问题,epoll 很难应对。
比如,epoll_wait 循环体中处理 POLLIN,POLLOUT 的 if 判断的位置会直接影响公平性,同时涉及 ET,LT,编码调度,若非如此,持续的 I 会饿死 O,反之亦然,而对于持续流量,将调度交给系统调度器何乐而不为。
对于 I 和 O,一心不可二用,持续的双向隧道流量需要的恰恰是解复用,即将同一个描述符的 I 和 O 解复用到不同线程,而不是复用,所以选型时第一要务就应该排除掉 select,poll,epoll,libevent 等异步多路复用技术,而为每一个 socket 简单地创建两个线程分别作阻塞 I 和 O 几乎是唯一选择,但这由于太简单而显得 low,展示不出自己运用复杂技术的能力,进而选择 epoll 等多路复用的错误技术,然后再陷入持续优化的深渊,早干嘛去了。
编程者使用 epoll 处理隧道倒不是都为了炫技,有些也属于拿着锤子找钉子。受大环境教化对产线工人产出效率的倡导,大多数编程者更熟悉高级框架和高级库的相关 API,底层的 thread_create 则早就抛到九霄云外去了,从不知或已遗忘了返朴归真的方法论。
对于高级 API,我的态度还是度量时间尺度,平衡你编码调试的时间和代码运行的时间,但前提是你一定要深入理解这个高级 API 的底层,它解决了什么问题,适合做什么,不适合做什么。调用高级 API 肯定增加了程序运行时间,多一个指令就多一个指令的时延,但直接使用底层 API 却对编程者有极高的要求,否则就会延迟代码发布和上线时间,同时增加维护和 bugfix 时间,要平衡这两者。
言归正传,既然不使用 epoll,选择了简单创建两个线程,就又涉及线程相关的高级技巧,可谓到处都是坑。
都知道线程比进程更轻量,鞋城更轻量,但为引出线程库,协程这些高级概念,线程创建,销毁的管理开销必须要被诟病,这似乎是引出一个新技术的惯例,于是就在编程者中形成一种新范式,即涉及服务器端的多线程,一定要用线程库,协程,就像涉及多个 socket 的 IO 一定要用 epoll 一样。进程,线程,协程的纠缠,与 select,poll,epoll 几乎无异。
但线程库,鞋城同样不适合 tun/tap 隧道。理由和态度依然是度量并平衡时间尺度。
类似 web 服务器 mpm,若采用多线程,为每一个简单的 request/response 花 80us 创建一个线程并随后在 100us 后花 30us 销毁,确实是一笔很昂贵的开销,线程池被提出解决该问题,资源池化的典型套路,但对于隧道,持续的双向流哪怕仅存活 1s,创建,销毁线程不到 200us 的开销都显得微不足道,为什么引入复杂性呢,多花几小时甚至更久的时间调试线程池,再算上维护和 bugfix 时间,创建多少个线程才能偿还,而你的代码在线上的生命周期甚至熬不到那么久。
总结一下,对于隧道,很简单,不用 epoll,线程池,协程,这些高尚货,要纯性能就别高尚,让高尚去诉诸软件工程和项目。
隧道场景远比服务器场景更简单,它只是在两个方向的固定分发,所以只要下面就够了:
void readtun_thread(void *arg)
{while(1) {block = dequeue(pool);len = read_tun(block);enqueue(tun2socket, block);}
}
void writesocket_thread(void *arg)
{while(1) {block = dequeue(tun2socket);len = write_socket(block); // 后续再 batch 优化enqueue(pool, block);}
}
void readsocket_thread(void *arg) {...}
void writetun_thread(void *arg) {...}
浙江温州皮鞋湿,下雨进水不会胖。