乐观并发: TCP 与编程实践
一些路口的红绿灯就挺好,晚上车流量少时,黄灯常闪,车辆行为自行避让,白天切换到红停绿行,车辆行人严格避让。
继续看 TCP/IP 哑网络。端系统几乎无法获得任何哑网络的信息,甚至连想看看第二跳路由都要借用 TTL 过期报错,而不是天生支持。这尽力而为的特征决定了端到端传输协议的基本假设及其性能上限。
这次我从乐观并发的视角解释。
乐观并发控制(Optimistic Concurrency Control,OCC)先假设并发操作不会冲突,让大家(线程,数据包)先自由行动,最终在交付时,再检查是否发生了冲突,如果是就重试,直到正确交付或放弃。
与之相对的是编程者被惯常训练的悲观并发控制,先假设一定会冲突,一开始就设计复杂的同步机制(锁,信号量)。编程和网络协议设计的这种认知隔阂也多来自不得已,哑网络是根源,网络协议很想获得更多信息以保证高效,但尽力而为的网络为了扩展性等其它好处,不提供更多信息。
TCP 不知网络细节,只知承载它的网络是一个典型且不可靠的激烈并发环境,但 TCP 不会因为担心丢包,乱序就不让你发数据,它乐观地认为数据包通常能按序到达,TCP 假设一切正常,先让数据流动起来,这是乐观并发的核心,直到检测到丢包,重传即可。
这种无锁的乐观设计思想得以让网络和端各自发展而不耦合,甚至网络本身也经过了一个乐观并发的开始阶段,这里的例子是 CSMA/CD,和 TCP 的思想几乎一样,先假设没有冲突,发了再说,直到检测到冲突再退避重试。而主机并不需要关注这些,只管 send 即可,而数据包则可能在共享介质中冲突并重试,也可能在交换机 buffer 中排队。
来看两个争论。
前面讲 AIMD 的文章 无懈可击的 TCP AIMD 里提到人们普遍把 AIMD 吞吐率无法支撑超高性能网络的理由归结为 AIMD 行为本身,可对极低丢包率的约束无法达到明明就是介质的问题。类似的例子来自关于时延抖动的观点,在一个多级分发系统,时延抖动明明是一个无法避免的 generic 数学问题,却总被工程派认为可通过更高级的上层技术完全避免抖动。抖动来自于你的信息量不足以让你控制微突发,这本质上是统计系统固有的,而统计律是铁律。
以上争论用颇有哲学意味的乐观并发其实很好解释。
往往乐观来自于不完美,在不完美的环境甚至恶劣的环境,必须乐观才能生存,而不完美的环境说的就是统计复用环境。反之在尽力而为的统计复用环境采用完备但悲观的并发控制,比如 lossless 网络或 TSN(IEEE 802.1),巨大的成本将综合效能(性价比)拉低到毫无扩展性,如果互联网最初采用这种哲学,它将不可能如此成功。
有得必有失,不完美环境促使的乐观带来的收益不赘述,但它同时也约束了性能的上限。性能总关联时间,而时间的方差只有单向且积累的作用力,这个方向就是熵增,说到底就是时间不可倒流,信息不足的代价无法正负相抵。
统计复用环境从来不适合高性能,高性能一定要用精准的信息从一开始注入逆熵。所以,在 400Gbps 以太网上跑 AIMD 很吃力,或者试图完全消除时延抖动而无所得,这些都是 feature 而非 bug,若要适应这些场景,你需要更精确的信息,例如精确丢包率的介质,硬件时间戳等,而不是跑在主机上的技术或算法。
现在从网络走向主机,从完全未知走向完全可控的另一极端,对于并发编程而言,可不可以不设计那么复杂的同步机制,甚至取消任何多线程同步操作,而只对代码逻辑做好幂等性和错误校验,出错就重试,直到正确为止,学 TCP 的样子,可行吗?
人在特定领域做久了总能形成特定风格,我就经常写乐观并发风格的代码。我总是尽量避免多线程访问共享变量,或者将共享访问交给系统调度器和时间,只确保最终一致性,“允许它错但不能一直错”,“最终对了就行” 一直是我经常用的策略,这为我省了不少时间。
但并不是说这种做法总是对的。如果退避和重试的时间总是超过自旋等待的时间,那么一把 spinlock 就是高尚的,我也经常循着 spinlock 到 rwlock 再到 rcu 的步骤优化代码,而这并不浪费我太多时间,所以相比乐观并发,这就更划算,因为和哑网络不同,主机状态对程序而言完全可见,逆熵很便宜,所以用更精确的信息控制它就能换来高性能。
总之,涉及优化,就必须计算时间尺度,把工资通过工时也换算成时间,把最浪费的那部分时间省掉,就是优化。
看看 TCP 以外的乐观并发常用术,顺序锁:
- 为共享数据关联一个序列号(TCP 一致的术语),初始化为 0,该序列号在每次写前和写后都被修改;
- 写者先获取一个自旋锁,然后递增序列号,再修改数据,最后再次递增序列号并释放自旋锁;
- 读者读取数据前后分别读取该序列号,通过判断两次读取序列号是否为偶数并一致来判断在读取过程是否有写介入,从而决定是否重试读取;
顺序锁可以很简单地实现读写锁,适用于读大于写地场景,而这种不对称场景正是乐观并发的用武之地:
- 不必为很罕见发生的事情设计复杂的机制,赌它不发生即可;
思想就是这么个思想,实际使用中,Linux 内核大量使用了这种思想,比如为路由项 dst_entry 关联一个全局 version,并被与 socket 关联的 dst_entry cache 继承,只要路由配置被修改,则递增全局 version,诸如此类。
下面是我自己实现的另一个读写锁,经理读写锁,也是该思想的实例:
#include <stdio.h>
#include <stdint.h>
#include <stdatomic.h>
#include <unistd.h>atomic_uint rw_state = ATOMIC_VAR_INIT(0);
// jl 是 “经理” 的意思
void jl_read_lock()
{while (1) {unsigned int old = atomic_load_explicit(&rw_state, memory_order_relaxed), new_val;if (old & 1) // 等待写解锁continue;new_val = old + 2;// 若写介入则重试if (atomic_compare_exchange_weak_explicit(&rw_state,&old,new_val,memory_order_acquire,memory_order_relaxed))return;}
}void jl_read_unlock()
{atomic_fetch_sub_explicit(&rw_state, 2, memory_order_release);
}void jl_write_lock()
{while (1) {unsigned int old = atomic_load_explicit(&rw_state, memory_order_relaxed), desired;if ((old & 1) || (old > 1)) // 等待读或写解锁continue;desired = old | 1;// 若读或写介入则重试if (atomic_compare_exchange_weak_explicit(&rw_state,&old,desired,memory_order_acquire,memory_order_relaxed))return;}
}void jl_write_unlock() {atomic_fetch_and_explicit(&rw_state, ~(unsigned int)1, memory_order_release);
}
在宏观层面,我们看到一把锁,接口是简单的 read/write_lock/unlock 或更加对称的 spin_lock,但在微观层面上,它有两种实现:
- 操作 lock 字段前锁总线,所有竞争者确认并同意它锁总线后,操作才可进行;
- 操作 lock 字段时只保证自身原子性,操作后检查是否被中断,若是则重试;
这两种实现分别正是悲观和乐观的方法。事实上若不存在多核独享 cache,事情会简单得多,但在各级 cache 架构下,所有复杂性都来自 cache 一致性,这时两种实现背后便存在了信仰成分,cache 一致性问题值得以多大的程度被在乎。就像 TCP 诞生时一样,评估传输路径的质量有多高,这直接决定了传输策略是乐观还是悲观的,幸运的是,TCP 选择的乐观并发天然自带扩展性,这就是 TCP 在 40 年来对各种网络保持适应性的原因。
看 TCP 乐观并发的实现就发现很有趣,它其实就是通过序列号把对数据 “时序” 的共享拆成了对不同 “部分” 的独享,这就彻底解决了同步问题,至少将同步问题推迟到了不得不 “合并” 多个 “部分” 时,这个不得已的合并本身就有 batch 操作的意味,而 batch 操作是能降低元开销。
TCP 的灵活性或者说扩展性在于它能实时适应不同的 batch 操作能力,体现为 wnd=MIN(rwnd,cwnd)\text{wnd}=\text{MIN}(\text{rwnd},\text{cwnd})wnd=MIN(rwnd,cwnd),也就是不论网络还是主机的不同大小的 buffer,致使 TCP 从最初的 GBN 经 SR 到 MultiPath-TCP,底层乐观并发逻辑一直没变,变的只是参数。
基于此视角,历史上 TCP 处理不好乱序其实是 buffer 管理的问题,合理大小的 buffer 配合 RACK-TLP 并不伤害 TCP 的乐观并发,并且只要网络仍然尽力而为,这套乐观并发的逻辑就适合所有的端到端传输协议。
浙江温州皮鞋湿,下雨进水不会胖。