TLS1.3/QUIC 是怎样做到 0-RTT 的

《低延迟与用户体验杂谈》中,我们提到了TLS1.3以及QUIC协议可以有效的降低传输层的延迟,改善用户体验。而TLS1.3和QUIC降低用户延迟有一个共同点,那就是在提供同等功能特性下,减少Round-Trip Time (RTT)次数。你甚至会经常听到QUIC和TLS1.3的拥趸说TLS1.3和QUIC可以做到0-RTT,基本消灭了(安全)传输层的延迟。事实真的是这样吗?

TLS RTT

我们先看一下TLS1.2 (以ECDH为例)的建立加密连接的过程:

从上图可以看出,在发送应用数据之前,TLS安全传输层需要经过2-RTT才能完成握手。如果是恢复会话,TLS1.2可以采用Session IDSession Ticket进行快速握手:

从上图可以看到,在使用Session Ticket的情况下,需要1-RTT。

那么TLS1.3是如何进一步优化的呢?

TLS 1.3完全握手

在完全握手情况下,TLS 1.3需要1-RTT建立连接。与TLS1.2有两点不同:握手过程中移除了ServerKeyExchangeClientKeyExchange, DH (Diffie-Hellman) 参数通过 key_share 传输。

TLS1.3恢复会话握手

TLS1.3恢复会话可以直接发送加密后的应用数据,不需要额外的TLS握手。因此,我们常说的TLS1.3 0-RTT握手其实是指恢复加密传输层会话不需要额外的RTT。但是,在第一次进行完全握手时,是需要1-RTT的。

与TLS1.2比较,无论是完全握手还是恢复会话,TLS1.3均比TLS1.2少1-RTT。因此,TLS1.3从被提上draft草案开始获得了各方面的好评。从nginx和Chrome支持此特性的速度可见一斑。

但是TLS1.3也并不完美。TLS 1.3 O-RTT无法保证前向安全性(Forward secrecy). 简单讲就是,如果当攻击者通过某种手段获取到了Session Ticket Key,那么该攻击者可以解密以前的加密数据。

这个问题不是TLS1.3独有,TLS1.2也存在这个问题。对于TLS1.3来说,要缓解该问题可以通过设置ServerConfiguration中的Expiration Date字段,使得与Session Ticket Key相关的DH静态参数在短时间内过期(一般几个小时)。

使用TLS最多的场景是HTTPS和HTTP/2. 以HTTP/2为例,一个完整的 HTTP Request需要经历的RTT如下:

HTTP/2 over TLS1.2首次连接 HTTP/2 over TLS1.2连接复用 HTTP/2 over TLS1.3首次连接 HTTP/2 over TLS1.3连接复用
DNS解析 1-RTT 0-RTT 1-RTT 0-RTT
TCP握手 1-RTT 0-RTT 1-RTT 0-RTT
TLS握手 2-RTT 1-RTT 1-RTT 0-RTT
HTTP Request 1-RTT 1-RTT 1-RTT 1-RTT
总计 5RTT 2-RTT 4-RTT 1-RTT

从上表可以看出:

  1. 首次连接发起HTTP请求是非常昂贵的。因此,如果你是用HTTPS作一些不可告人的代理应用的话,一定尽量保持长连接,避免频繁建立新连接。
  2. 连接的multlplexing(多路复用)可以有效减少RTT次数,降低延迟。在连接复用的情况下,TLS1.3将整个http request的RTT降低到了1个,达到理论的最小值。

QUIC RTT

  1. 当客户端首次发起QUIC连接时,客户端想服务器发送一个client hello消息,服务器回复一个server reject消息。该消息中有包括server config,类似于TLS1.3中的key_share交换。这需要产生1-RTT. 事实上,QUIC加密协议的作者也明确指出当前的QUIC加密协议是「注定要死掉的」(destined to die), 未来将会被TLS1.3代替。只是在QUIC提出来的时候,TLS1.3还没出生?,这只是一个临时的加密方案。
  2. 当客户端获取到server config以后,就可以直接计算出密钥,发送应用数据了,可以认为是0-RTT。密钥推导可以参见Key derivation.
  3. 因此,QUIC握手除去首次连接需要产生1-RTT,理论上,后续握手都是0-RTT的。
  4. 假设1-RTT=100ms, QUIC建立安全连接连接的握手开销为0ms, 功能上等价于TCP+TLS, 但是握手开销比建立普通的TCP连接延迟都低:

    (正常体为首次建立连接的延迟,粗体部分为后续握手的延迟)

HTTP/2 over TLS 1.3 vs QUIC

当前,QUIC普遍的应用场景是 HTTP/2 over QUIC. 我们以一个完整的 HTTP Request需要经历的RTT为例,比较TLS1.3与QUIC的RTT开销:

HTTP/2 over TLS1.3首次连接 HTTP/2 over TLS1.3连接复用 HTTP/2 over QUIC首次连接 HTTP/2 over QUIC连接复用
DNS解析 1-RTT 0-RTT 1-RTT 0-RTT
TCP握手 1-RTT 0-RTT
TLS握手 1-RTT 0-RTT
QUIC握手 1-RTT 0-RTT
HTTP Request 1-RTT 1-RTT 1-RTT 1-RTT
总计 4RTT 1-RTT 3-RTT 1-RTT

从上表可以看出:HTTP/2 over QUIC相比HTTP/2 over TLS 1.3最大的优势是首次连接是的RTT开销降低了1-RTT(25%)。在连接复用的情况,QUIC的RTT开销与TLS1.3相等。

前面我们提到,QUIC(UDP+QUIC Crypto)在功能层面等价于TCP+TLS, 并且其加密协议(QUIC Crypto)未来会被TLS1.3代替。而在连接复用的情况下QUIC与TLS1.3的RTT开销旗鼓相当,那么是否意味着在HTTP/2的应用场景下,TCP+TLS1.3就可以完全替代QUIC呢?答案是否定的,主要原因如下:

  1. QUIC从数据包级别解决了队头阻塞的问题,而TCP+TLS只能解决http request级别的队头阻塞问题。具体原理可以参见《当我们在谈论HTTP队头阻塞时,我们在谈论什么》.
  2. QUIC与HTTP/2结合更加紧密,比如HTTP/2的stream, frame, Header Compression都可以直接映射到QUIC的stream, packet, Header Compression. 从协议分层看这会使得应用层和传输层紧耦合,但是在HTTP/2这个具体场景下,QUIC可以接管HTTP/2的拥塞控制,相较于HTTP/2 over TLS 1.3会受到TCP和HTTP/2两层的拥塞控制来说,无疑具有更直接有效的控制策略。并且,QUIC的拥塞控制是应用层可插件化的。虽然你也可以为TCP编写拥塞控制模块,但那是一个内核模块,稍不注意就会让系统崩溃。如果你有兴趣的话,实现一个QUIC的BBR拥塞控制模块是很容易的。
  3. QUIC支持连接迁移(Connection Migration),即在客户端切换网络IP以后,也能够保持连接不间断。这对于移动网络这种漫游网络特别友好,再加上QUIC在首次连接上RTT比TLS1.3低25%,因此会有更好的用户体验。虽然Multipath TCP, MPTCP也可以吃支持连接迁移,但这受限于操作系统和网络协议栈,要想普及,任重道远。
  4. QUIC支持Forward Error Correction,利用一定的数据包冗余,提供丢包时恢复丢包数据的能力。减少了包重传的数量,进而减少延迟,提高带宽利用率。如果你使用过kcptun,你会发现它也采用了FEC,对于弱网和高丢包环境下,效果尤其好。

当然,QUIC也是有缺点的。比如很多运营商对UDP的QoS级别是比TCP低的,甚至只允许DNS 53端口的UDP包,其他UDP包都是被封掉的。在IDC中,UDP的优先级一般也比TCP低。因此,即使以后大规模普及QUIC,也不会完全替代TCP+TLS,毕竟需要确保在QUIC不可用时,可以回落到TCP+TLS.

总结

在首次连接的时候,无论是TLS1.3还是QUIC都需要1-RTT,在连接复用的情况下,两者才能做到0-RTT. 在HTTP/2的应用场景中,QUIC可以有效的降低首次连接的RTT次数,并且支持连接迁移、FEC以及更加灵活高效的拥塞控制,因此可以在移动网络、弱网环境提供低延迟的用户体验。

扩展阅读

Golang 获取 goroutine id 完全指南

在Golang中,每个goroutine协程都有一个goroutine id (goid),该goid没有向应用层暴露。但是,在很多场景下,开发者又希望使用goid作为唯一标识,将一个goroutine中的函数层级调用串联起来。比如,希望在一个http handler中将这个请求的每行日志都加上对应的goid以便于对这个请求处理过程进行跟踪和分析。

关于是否应该将goid暴露给应用层已经争论多年。基本上,Golang的开发者都一致认为不应该暴露goid(faq: document why there is no way to get a goroutine ID),主要有以下几点理由:

  1. goroutine设计理念是轻量,鼓励开发者使用多goroutine进行开发,不希望开发者通过goid做goroutine local storage或thread local storage(TLS)的事情;
  2. Golang开发者Brad认为TLS在C/C++实践中也问题多多,比如一些使用TLS的库,thread状态非常容易被非期望线程修改,导致crash.
  3. goroutine并不等价于thread, 开发者可以通过syscall获取thread id,因此根本不需要暴露goid.

官方也一直推荐使用context作为上下文关联的最佳实践。如果你还是想获取goid,下面是我整理的目前已知的所有获取它的方式,希望你想清楚了再使用。

  1. 通过stack信息获取goroutine id.
  2. 通过修改源代码获取goroutine id.
  3. 通过CGo获取goroutine id.
  4. 通过汇编获取goroutine id.
  5. 通过汇编获取goroutine id.

在开始介绍各种方法前,先看一下定义在src/runtime/runtime2.go中保存goroutine状态的g结构:

其中goid int64字段即为当前goroutine的id。

1. 通过stack信息获取goroutine id

原理非常简单,将stack中的文本信息”goroutine 1234″匹配出来。但是这种方式有两个问题:

  1. stack信息的格式随版本更新可能变化,甚至不再提供goroutine id,可靠性差。
  2. 性能较差,调用10000次消耗>50ms。

如果你只是想在个人项目中使用goid,这个方法是可以胜任的。维护和修改成本相对较低,且不需要引入任何第三方依赖。同时建议你就此打住,不要继续往下看了。

2. 通过修改源代码获取goroutine id

既然方法1效率较低,且不可靠,那么我们可以尝试直接修改源代码src/runtime/runtime2.go中添加Goid函数,将goid暴露给应用层:

这个方式能解决法1的两个问题,但是会导致你的程序只能在修改了源代码的机器上才能编译,没有移植性,并且每次go版本升级以后,都需要重新修改源代码,维护成本较高。

3. 通过CGo获取goroutine id

那么有没有性能好,同时不影响移植性,且维护成本低的方法呢?那就是来自Dave Cheney的CGo方式:

文件id.c:

文件id.go:

完整代码参见junk/id.

这种方法的问题在于你需要开启CGo, CGo存在一些缺点,具体可以参见这个大牛的cgo is not Go. 我相信在你绝大部分的工程项目中,你是不希望开启CGo的。

4. 通过汇编获取goroutine id

如果前面三种方法我们都不能接受,有没有第四种方法呢?那就是通过汇编获取goroutine id的方法。原理是:通过getg方法(汇编实现)获取到当前goroutine的g结构地址,根据偏移量计算出成员goid int的地址,然后取出该值即可。

项目goroutine实现了这种方法。需要说明的是,这种方法看似简单,实际上因为每个go版本几乎都会有针对g结构的调整,因此goid int64的偏移并不是固定的,更加复杂的是,go在编译的时候,传递的编译参数也会影响goid int64的偏移值,因此,这个项目的作者花了非常多精力来维护每个go版本g结构偏移的计算,详见hack目录。

这个方法性能好,原理清晰,实际使用上稳定性也不错(我们在部分不太重要的线上业务使用了这种方法)。但是,维护这个库也许真的太累了,最近发现作者将这个库标记为“DEPRECATED”,看来获取goroutine id是条越走越远的不归路?

5. 通过汇编获取goroutine id

虽然方法4从原理和实际应用上表现都不错,但是毕竟作者弃坑了。回到我们要解决的问题上:我们并不是真的一定要获取到goroutine id,我们只是想获取到goroutine的唯一标识。那么,从这个角度看的话,我们只需要解决goroutine标识唯一性的问题即可。

显然,上面作者也想清楚了这个问题。他新开了一个库go-tls, 这个库实现了goroutine local storage,其中获取goroutine id的方式是:用方法4的汇编获取goroutine的地址,然后自己管理和分配goroutine id。由于它获取到的并不是真正的goroutine id,因此我将之称为goroutine id。其实现的核心代码如下:

  1. 获取g结构地址。
  2. 分配伪goroutine id.

这种方式基本没有什么不能接受的hack实现,从原理上来说也更加安全。但是获取到不是你最开始想要的goroutine id,不知你能否接受?

小结

获取goroutine id是一条不归路,目前也没有完美的获取它的方式。如果你一定要使用goroutine id,先想清楚你要解决的问题是什么,如果没有必要,建议你不要走上这条不归路。尽早在团队中推广使用context, 越早使用越早脱离对goroutine id的留恋和挣扎。

Credit

理工男是如何选择iPhone 8 (Plus)和iPhone X的

上一台手机是iPhone 6 Plus 64G, 使用到现在将近快3年半。6p在升级iOS 10以后,速度就算不上流程了,使用微信和支付宝的时候尤其明显。特别是使用支付宝付款的时候,经常卡在story board画面,没少受后面排队哥们白眼。

现在(2018.03)并不算是购买苹果这代手机最好的时机,因为一般最好的价格都是出现在双十一。而且本着早买早享受的原则,现在入手其实体验周期也间接变短了。这次考虑换机原因只有一个:6p实在是不能战斗下去了。虽然并不喜欢这代的iPhone,但是,也只能矮子里面拔将军了:选iPhone 8 (Plus)还是iPhone X?

习惯性的打开哈乎看了一下,纠结着两个选项的人好像还挺多的:

  • 你会买 iPhone 8 还是 iPhone X?
  • 你会选择买iPhone X还是iPhone 8,或者其他手机?
  • iPhone 8 (Plus) 和 iPhone X 你会买哪个?为什么?
  • iphone X 和 iphone 8/8plus 哪个更值得买?
  • iPhone 8/8p/X,不知道选哪个?

其实上面的问题只会越看越不知道自己选什么,因为每个人都有自己的需求和背景。

这里我就不卖关子,直接给出自己的选择:iPhone 8 Plus, 容量看自己的需要,因为自己的6p是64G且容量将满,因此选的是256G。主要理由如下:

face id解锁交互用在了错误的场景

face id解锁是一个自己无论如何也无法接受的交互方式。我仔细体验过同事的iPhone X,成功率尚可,但是做不到「存在于无形之中」。人脸解锁如果做不到这一点,就是一个半成品。

这一点类似于汽车行业之前大规模使用触屏代替物理按键(呃,好吧,马斯克同学还在坚持在特斯拉车型中大规模使用触屏……),不是说触屏控制本身存在技术障碍,而是在这个场景中使用了一个高成本且不合适的技术。

我甚至无法想象晚上在被窝中要解锁iPhone X时,必须要让手机的主动探照光闪瞎狗眼的酸辣情景。那么,什么场景是适合face id的呢?我认为是在结合其他id因子的安全领域,比如在大额支付时,使用touch id和face id进行双重校验。

iPhone X的全面屏是个笑话,全面屏最终都会变成笑话

齐刘海问题肯定算不上优点,但是问题并不大(好吧,其实苹果开发者还是挺吐槽这个刘海带来的额外开发工作量的……)。重点是,全面屏在当前阶段并不是一个手机尺寸上能够提升用户体验的发展方向。

以前手机尺寸的发展方向是变薄,有些厂家甚至以此作为卖点。但是几年下来,还有哪个厂家将自家机器薄作为卖点?就连以前乔帮主健在时,发布会现场直击从文件袋里拿出来的iPad Air也不在突出它薄的数值了。

空口无凭,我们看一下苹果历代16款手机的厚度数据对比:

结果是不是很意外?

  • 从iPhone 5开始,苹果手机的厚度基本就没有太大变化。
  • iPhone 6是迄今为止,苹果最薄的手机。
  • iPhone 6开始,苹果手机厚度是逐年上升的!
  • 更加有意思的是,iPhone X是水果这5年来最厚的手机?

那么这个问题跟全面屏有什么关系呢?我们先看一下全面屏的定义:

全面屏从字面上解释就是手机的正面全部都是屏幕,手机的四个边框位置都是采用无边框设计,追求接近100%的屏占比。但其实到目前为止没有一个严格意义上的全面屏幕定义,而比较主流的看法是要有18:9的屏幕,正面取消任何实体或者触摸按键,只有一块完整的屏幕,而且屏幕占比至少要达到80%以上。显然目前有很多手机虽然也号称全面屏幕,但屏幕占比却没有达到这个要求,所以看上去边框很宽,并没有特别好的效果。

所以,仔细想想,你就发现,在现阶段各家手机屏占比越做越高的今天,全面屏已经基本成为或接近事实,并不需要为了迎合这个概念而调整手机本来已经验证多年的设计,尤其是正面的按键和触感器排列。这一点上,锤子手机罗永浩是深谙其道的,他发布的坚果 2 Pro手机宣传语就很准确「Almost 全面屏」?

因此,当前阶段,行业对全面屏的疯狂追捧跟当年恨不得手机能够用来切菜的追逐是如此神似。当然,也有头脑清醒的人只是跟着喊口号,但是并没有真的跳进坑里,比如老罗。

手机是需要握持使用的,两边的边框是一定会有所保留的,无论是iPhone X还是小米的MIX系列。有意思的是,这两个厂家疯狂宣传他们全面屏的同时,却没有告诉你,他们把手机两旁的边框做了轻微加高和立面加厚处理,当你进行从屏幕边缘划入屏幕操作时,感觉手指被一个圆铁丝顶了一下一样,还不如以前「正常屏」手机顺滑。而iPhone X的全面屏还有一个额外的槽点:话说你提高屏占比是为了显示更多的信息吧,但是你看看iPhone X现在的屏幕显示情况,很多情况下屏幕下方很大一截都是空的,要这铁棒有何用?

小结苹果的全面屏:

  • 全面屏是一个伪概念,在当今屏占比越来越高的今天,全面屏就是个概念和幌子。
  • 在屏占比提到到一定程度以后,对用户体验影响的提高就很小了,甚至会降低用户体验。
  • 如果一定要为了全面屏而改变正面的一些布局,无疑是舍本逐末、因小失大的做法。

iPhone X的优点

但并不是说iPhone X就全无优点。

结合其深度传感器使用的Animoji可能如当初Emoji一样影响整个行业,前提是深度传感器能够迅速降低成本并普及。

摄像头双重防抖对于喜欢拍照的同学来说更是锦上添花。

OLED屏幕显示效果主观观感的确非常赞。

选择iPhone 8 Plus的困扰

  • 没有辨识度。其实换手机有好几天了,但是同事根本没有发现我换了手机。但是,iPhone早就过了可能装X的年代了,所以也没觉得损失什么。
  • 多年使用同一个设计ID的产品。6p用了三年多,8p估计也会用2年以上,意味着6年都用着一个外观的产品。这点自己并不排斥,要知道设计不是一直向前的,有时候还会轻微倒退。正如我一直觉得ThinkPad T61/T400的设计是一代经典,因此我现在还在用着这10年前的笔记本电脑做一些简单的文档处理和网页浏览。
  • 手机重量历代之最,用久了手腕疼。但是有些人觉得这才是质感
  • 玻璃后盖易碎。但是有些人认为这才叫温润如玉。

其实上面说的都谈不上困扰,只是你选了8p以后需要考虑能否接受的小妥协。

为什么不选择iPhone 7 (Plus)?

选iPhone 7 (Plus)的唯一原因当然是价格。但是,作为一名理工男+参数党,通过研究苹果这一代的芯片发现,iPhone 7可能并不适合自己。

从上图可以发现,这两代CPU的制程一个是16nm, 一个是10nm. (苹果历代CPU参数可以参见这里。 无论是理论还是实际测试,A11芯片都比A10芯片能效更高,续航时间更长。而iPhone 8 Plus更是做到了在电池容量比iPhone 7 Plus少10%左右的情况下,续航反而增加了一个多小时。

另一方面,A11采用的是两个高性能+4个能效核的设计,比A10多两个效能核。理论上A11的CPU跑分会更高。使用Geekbench 4也应证了这一点:

单核性能提升23%,多核性能提升76%!

之于iPhone 8 Plus加入的无线充电,聊胜于无吧。倒是快充功能在紧急场合能够发挥一点作用。

另一方面,由于自己换机频率不高,因此,对于自己而言,iPhone 8 Plus更加适合自己。而且这几天购买正好碰到3.8女王节活动,直降1000,性价比也不算低。

总结

不选iPhone X有很多理由,选择iPhone 8 Plus则是因为被动选择。虽然当前的iPhone X只是一个半成品,但是库克肯定认为它是以后苹果手机的发展方向,自己对下一代iPhone X也没有任何期待。因此iPhone 8就是苹果以iPhone 6为设计语言的一代产品的绝唱了,这也算是自己选8的一个原因。