普通视图

发现新文章,点击刷新页面。
昨天以前Halfrost's Field | 冰霜之地

后疫情时代下美国 CS Master 申请纪实

后疫情时代下美国 CS Master 申请纪实

21fall 申请季格外不同,因为 2020 年 1 月全世界爆发疫情以后,校园关闭,留学生滞留国内上网课。加上中美博弈,10043 总统令,相互关闭总领馆,停止办理所有非移民签证,增加 travel ban NIE(National Interest Exceptions) 限制,使得中国学生赴美留学变得难上加难。虽然国内很快控制住疫情,但是美国疫情曲线居高不下,使得准备赴美留学的国内家长十分担心。在这种特殊的后疫情时代下,留学申请有哪些困难呢?笔者完整的经历了一遭,这篇文章来详细聊聊。

笔者留学的目的以及起源在 2019 年年终总结里面详述了,每个人情况不同,并且充满主观因素,不在本篇文章里讨论了。关于 TOEFL 和 GRE 备考相关的事情写在 2020 年年终总结里了,本篇也不赘述了。本篇文章假设你已经下定决心要去美国读 CS Master 且已准备好 TOEFL & GRE 成绩,针对申请全过程,笔者所见所闻所想,与君分享心得与“收获”。如果读者打算今年申请,那么请静下心来耐心读完,一个过来人的踩坑经历一定会让你收获“颇丰”。

一. Why USA CS Master

后疫情时代下美国 CS Master 申请纪实

由于 10043 总统令的限制,七子和北邮限制签证,所以不少优秀的学子挤到欧陆学校了。欧陆有不少好学校,除去名气很大的牛剑,瑞士,德国都有顶尖的 CS 高校。具体排名可以见 QS World University Rankings

关于排行榜,有 2 个权威排行榜,一个是 Qs,一个是 USNews。在 Qs 榜单上,英国大学排名偏高,在 USNews 榜单上,美国大学排名偏高。

论排名,英美大学在 TOP10 上不分伯仲。但是再综合考虑毕业后就业环境,美国在 CS 这方面还是更胜一筹,因为有纽约和硅谷。回到择校上,七子和北邮可以选择欧陆,加拿大,日本,香港,新加坡。笔者对全球的大学基本都有了解,总结了下面这个表格:

明显优点 明显缺点
美国 GPA 不区分 985/211/双非 花销最大
英国 项目一般只有一年,花销小 GPA 区分 985/211/双非,看重本科出身
加拿大 CS TOP 100 的学校少,UoT 一枝独秀
日本 除去 TOEFL/IELTS,还需要考 N1
欧陆 除去 TOEFL/IELTS,还需要考小语种考试
香港 GPA 区分 985/211/双非,看重本科出身
新加坡 CS 就业环境普通
澳洲 CS 就业环境普通

如果上面明显缺点命中了你的缺点,建议仔细斟酌。例如语言天赋不强的人,还要去日本或者欧陆留学,2 门外语考试很折腾人;资金不充裕建议谨慎考虑美国,美国开销是最大的;如果你是双非出身,建议谨慎考虑英国和香港。

以笔者为例,笔者是非 985,所以谨慎考虑英国,香港,或者不考虑。因为在英国填写网申系统时,GPA 有区别:如果是 985 名校出身,GPA 80 分 OK,如果是 211 出身,GPA 85 分 OK,如果是双非出身,可能 GPA 95 分也不 OK。因为非名校会被放进单独的 pool 中。非名校想在英国翻身的机会很少,除去异常优秀的。笔者语言天赋一般,学英语都花了很多时间,所以直接排除日本和欧陆。笔者也很看重 CS 就业环境,所以也排除澳洲和新加坡。这样排除下来,最终的选择应该是美国和加拿大。美国的就业环境比加拿大更好,所以笔者更倾向美国。

不过笔者最终还是投了一所英国大学,一所新加坡大学,一所加拿大大学和 14 所美国大学。可能你会好奇,为什么明知申不上还要头铁往上冲?笔者是亲身实践以后发现上述规律的。如果时间能倒流,一定不会头铁往上冲。

英国/美国/新加坡/澳洲/加拿大 CS Master 又可分为 2 种类型,Courser-base 和 Research-based。Courser-base 偏就业向(但不是绝对),Research-based 偏可读 PhD 向(但也不是绝对)。如果读者对 Research 感兴趣,笔者建议可以直接申 PhD,保险起见的话,MS/PhD Research 一起申请。Research-based 的名称都是 Master of Science in Computer Science with thesis,只有这一种情况,即 MS 开头的。

笔者全部项目都是 Courser-base 的,在 Courser-base 中又可以细分为 Terminal 和非 Terminal 的。Terminal 代表终止,这个项目 100% 不能读 PhD,或者说这个项目读完对申 PhD 无帮助。例如:Master of Computer Science、Master of Engineering、Masters Program in Computer Science。更详细的 MSC@UCI, MSC@RICE, MPCS@UChicago, Meng EECS@UCB, Meng CS@Cornell Tech, MSE@CMU SCS。非 Terminal 的一般是 Master of Science in Computer Science without thesis,即缩写是 MS 开头的,中文翻译是理学硕士。这一类的项目是可以读 PhD 的,或者第一年结束后可以申本校的 PhD。

如果是找工方向,还需要注意项目中是否包含实习。如果有实习期,学校会在暑假给你 CPT,你便可以去实习。有些项目无实习,假期可能上课,例如 MSE@CMU SCS,假期上课,不给 CPT。无 CPT 的项目也能在美国找到工作,毕业后 90 天内找到工作即可开始使用 OPT。有 CPT 的项目无非是多了一次找工上岸的机会,因为 CPT 实习期间,表现良好,大概率可以拿到 return offer。如果你想在美国找工作留下来,选择一个带 CPT 实习的项目是刚需。看到此处,你应该对选哪个国家的大学,选哪种类型的 master,是否读 PhD,以及是否需要实习,心知肚明了。

二. 申请材料

如果你认识笔者,笔者什么背景你也了解。如果不了解笔者背景,那也不用了解了。笔者背景很差,双非出身,TOEFL 98+,GRE 320+3,GPA 3.82/4.0,Major 3.9/4.0。三维成绩不出众,可能中等偏下?

1. 本科背景

如果你的本科背景非常好,例如,清北 TOP2 出身,或者南京大学,上海交通大学,浙江大学,复旦大学出身,那么恭喜你,地球上大部分大学你都可以闭着眼睛申请,横着任性申。

后疫情时代下美国 CS Master 申请纪实

如果你的学校在上图中,英美都可以申。如果本科学校不在上图中,建议别考虑英国,因为你的出身不好。上图是陆本的排名。如果你是海本出身,那比陆本天生高人一档。举个例子,例如 UCR 不如清北,但是在申请美国 master 时,却更受招生老师的青睐。一是因为本科接受了全英文的课程教育,二是海本的 pool 优先级高于陆本的 pool。本科是 UCR 的申请者申请 UCLA,UCB 比本科是清北的申请者申请 UCLA,UCB 容易一些。再比如 Stanford,MIT,Harvard 在陆本中招生,只招清北 + 华五,有时候甚至华五都不招。但是 Stanford,MIT,Harvard 招很多海本的学生,即使学校综合排名落后于清北:

海本 > 清北 > NJU + ZJU + SJTU >> 其他华五 + 部分计算机特色学校(如北邮,华科)> 211 > 其他双非

如果你还在大学,本科背景也可以提升。可以考虑 Transfer,大一大二在国内读,大三大四 Transfer 到美本 TOP50 的学校去。本科毕业拿到美本的学历,对申请 Master 来说,提升了不止一个档了,性价比很高。大三大四在美国读书大概花费 100W RMB 左右。这条路拿到 TOP20 Master 学位总共需要 2 年,花费 200W RMB 左右。

如果你已经毕业,如果你家庭条件有很多钱资助你读书,可以考虑读美国的社区大学。相当于重新再读一个本科。美国社区大学大三也可以转学,再转到美本 TOP50 大学去。一般走这条路的人,都会选择读加州的社区大学,然后转学转到加州系的大学中的任一所,大四毕业以后即为人上人。这条路时间和金钱花费都很大,重读一个本科需要花费 4 年光阴,4 年在美国的花费也至少在 200W RMB 左右。200W 仅仅是开始,这刚刚拿到学士学位,再申 Master 读 1-2 年大概还需要 100-150W。这条路拿到 TOP20 Master 学位总共需要 6 年,花费 300-350W RMB 左右。

2. GPA

后疫情时代下美国 CS Master 申请纪实

GPA 作为三维成绩中重要一环,重要性不必多说。有些学校尤其看着 GPA,例如 USC,UoT,它们根据申请者的 GPA 高低从高往低排序,然后按照优先级从上往下发 offer。如果你还在大一,大二或者大三,请一定规划好时间,GPA 争取越高越好。如果本科学校允许刷 GPA,可以考虑大三修完所有课程,大四重选大一大二 GPA 低的课,重修刷 GPA。

有些学校要求学信网官方认证成绩单,有些学校要求网申阶段就快递成绩单原件。所以这些材料请提前准备好。去学位网上认证成绩单,翻译件也附带弄一份,具体操作认证完以后,付费再买一份英文版的即可。要求学信网认证成绩单的学校,例如 WUSTL,会要求你通过学信网给他们发送本科的材料,认证后的学位证书(中英文)+认证后的成绩单(中英文)。要求寄成绩单原件的学校,例如 UMich,NYU,需要你去本科学校联系老师盖好教务处的印章,用学校的信封封好,再封口处再次盖章,从学校寄出。多次盖章+学校寄出目的是为了保证中途没有启封并篡改。

关于 GPA 还有一件你必须知道的事。美国有一个 WES 教育机构,它专门做 GPA 认证的。经过它认证以后的 GPA,大概率比学校成绩单上的高。因为他们不认可国内的政治相关的课程,例如《马克思主义哲学原理》、《中国近现代史纲要》、《思想道德与法律基础》、《毛泽东思想和中国特色社会主义理论体系概论》,还有国内的体育与健康课。他们在重新计算 GPA 的时候,不会计算它们。这样算出来的 GPA 会被大学成绩单上的更高一些。所以能提交 WES 认证成绩单的学校,尽量提交 WES 认证,变相提高了自己的 GPA。虽然支持 WES 认证成绩单的学校越来越少,但是还存在。例如 CMU 有好几个项目都还支持 WES 认证。USC,NYU 不支持,他们只接收大学成绩单上原始 GPA。

3. TOEFL & GRE

后疫情时代下美国 CS Master 申请纪实

语言成绩不必多说,三维中的重中之重。不管你是自学,报班,一定请尽快考到想要的分数。因为 GT 分数的延后,导致申请全程过程拖沓就很不值得了。笔者英语也不厉害,学习无捷径。每个人的方法也不同。大多数看到这篇文章的读者语言分可能早已拿到了,所以这篇文章不浪费篇幅重点分析语言学习方面的经验了。感兴趣的读者可以翻一翻笔者 2019 年和 2020 年的年终总结。

4. 软背景

后疫情时代下美国 CS Master 申请纪实

软背景包含的内容比较多,暑研,顶会期刊论文,学科竞赛(ACM,CTF,MCM/ICM 等等),交换生,FLAAGTM 多段实习,其他全球奖项(Apple Scholarship 等等)

FLAAGTM = Facebook, LinkedIn, Amazon, Apple, Google, Twitter, Microsoft

关于软背景的提升,是八仙过海,各显神通。实力强劲的申请者是三维成绩顶尖,软背景也拉满:多次顶尖名校 MIT, Stanford, CMU, UCB 暑研,顶会论文若干篇,越多越好。学科竞赛金牌全部拿满,顶尖名校 Stanford,MIT,Harvard 交换生,FLAAGTM 多段实习经历。由于每个人精力有限,能力有限,尽自己所能争取拿到全世界含金量最高的奖项或者荣誉吧。

关于暑研,笔者想单独说明一下。暑研属于“奇兵”,虽然不是决定性因素,但是常常有出奇制胜的奇效。在笔者看来,暑研的主要目的并非是研究出成果,更多的是向名校的 Professor 展示自己的过程。在这一批暑研的学生中,如何让自己脱颖而出,出类拔萃,可能更“关键”。如果你足够优秀,暑研期间可以完成 PhD 的套磁,可以争取拿到 Professor 的 Strong Recommend 推荐信。有了这封推荐信,等秋季网申系统开放,第一时间申请该校,推荐信这一项能拉开同类竞争者很大差距。

最后请读者注意,如果三维成绩已成“定局”,GPA 刷不动了,GT 成绩也到瓶颈了,无法突破 110+/330+,那么请多花点时间提升软背景吧。Master 申请并非只看三维成绩,录取是评价综合实力的结果。硬背景既然定型了,那么软背景多努力努力吧~笔者三维成绩可谓“稀烂”,靠一些马马虎虎的工作经历拉“满”了软背景,最终也被 CMU 录取了。

5. CV、文书与推荐信

后疫情时代下美国 CS Master 申请纪实

CV 如实写即可。突出自己多方面的实力,学历,交换生,实习,暑研,顶会论文,竞赛奖项,等等软背景。

PS 文书需要根据每个学校的要求来写。不同学校不同项目,不同项目下还分不同 track,如何根据自身的特点去切合项目的要求,是写文书最需要考虑的问题。文书中一般会写自己的亮点经历,why school,why program。文书中 why school部分最能考验学生对这所学校是否了解。这涉及的是方方面面的,例如是否了解校园文化,是否了解各个导师研究课题的方向,是否了解哪些感兴趣的 lab 和 research group,是否了解学校在当地的名声与社会价值,等等。这些深层次的内容有些在学校的主页上,有些在学校的介绍视频中,有些在校园采访中。如果有心观看收集的话,自己也能找到一部分,这部分还是非常非常非常花时间的。笔者在了解各个学校,各个项目,各个学校内各个导师的研究偏好,花了特别多的时间,前前后后加起来总共有 2 周时间。

部分项目除了写 PS 以外,还要求 Video Essay / Video Interview,还有 Diversity Essay。这些部分也同样很重要。第一次录 Video Essay 比较迷茫,不知道录哪些内容。自己身上大部分的亮点在 CV 和 PS 中已经体现了。Essay 中又不允许重复。Diversity Essay 也很头疼,Diversity 算美国文化独有基因。你说你学术能力强,上十篇顶会学术论文,很独特;GPA/TOEFL/GRE 分数很高,很独特。这些都不是 Diversity。Diversity Essay 同样不能重复 CV 和 PS 中的内容,并且要求写 1000 words。这部分要根据学校的“基因”来写,有的学校服务社会,那么你过去经历中是否有社区服务相关的内容,如果有,可以写上去。有的学校引领当地的科技,那么你的过去是否存在体现自身 leadership 的案例,如果有,可以写上去。总的来说,Diversity Essay 还是比较难写。

最后是推荐信,推荐信基本要求 3 封。比较合适的组合是 2 封学术教授,1 封实习 leader。3 封推荐信尽量都要拿到强推信。如果有暑研,尽量找那所学校的教授帮你写推荐信,申请这所学校的时候,这封推荐信会占优势。学术教授尽量选择学术论文影响力很大的老师,如果校内有和你想要申请学校的教授联合研究发文的教授,优先选这个教授帮忙写推荐信。实习/工作 的 mentor/leader 推荐信同样的道理,优先选择海外知名的,如果没有,尽量选国内知名的大厂。

申请学校的教授强推 > 与申请学校招生老师或者教授有合作的教授强推 > 海外学术界知名教授 > 海外工业界知名 leader > 国内学术界知名教授 > 国内工业界知名 leader > 其他

推荐信这一块对于陆本的学生来说算“优势”,提前和关系好的教授打好招呼,给的推荐信都是强推。对于海本的学生来说,这部分有坑!海外不少教授嘴巴上说强烈推荐,有些是客套话,最终给的推荐信可能是平推甚至是毒推。海外很多教授比较耿直,有啥写啥。如果把你缺点曝光的比较多,可能就是一封毒推了。当然大部分教授都挺好,笔者这里只是想让大家留个心眼,堤防毒推。

陆本的学生还有一点需要注意的是,“防作弊”。这个案例是笔者在地里看到的。今年很多学校针对推荐信这块增加了反欺诈监测。例如,申请者包办 3 名推荐人,替他们帮自己写推荐信。推荐信的网页上会记录此次的 IP,上传文件的本地路径甚至主机名,提交时间。根据这 3 者可以判定推荐信是否是同一台机器上传。如果 3 封推荐信全部都由一台机器上传完成,甚至是同一时刻或者很短时间内上传完成,那么可以断定这名申请者自己包办了所有推荐信。正常的话根本不可能出现这种情况,正常流程应该是 3 台机器主机名都不同,上传时间之间相隔无规律,IP 也不同。除了这种上传时间和上传机器有“防作弊”检查,pdf 和 world 软件也会有检查。例如,申请者在自己的电脑上一手包办了 3 名推荐人的推荐信,在自己电脑上用 world 或者 pdf 编写的。在 mac 电脑或者 windows 电脑上使用 Microsoft office world 365,需要激活。一旦激活登录了邮箱账号以后,编辑过的 world 会在文件信息里面写入“来源”,“作者”,这两个信息。如果学校检查上传 world 或者 pdf 的文件信息,发现三个推荐信来自同一个人,基本可以断定是同一个人写的。正常情况三个推荐信不同电脑上生成的 world 和 pdf 文件信息里面的 “作者” 应该是不一样的。

笔者今年申请了 UoT,在面试环节,有专门针对推荐信的问答。有同学被招生老师问到:“从系统上看,你的这 3 封推荐信都在同一台电脑上上传的,请你解释为什么?”。很明显,招生老师怀疑推荐信的真实性,怀疑可能是该学生一手包办的。面试现场被问到这类问题特别紧张,气氛也特别尴尬。经地里同学自述,他回答说“因为老师很忙,我抱着电脑去办公室找他,催着他在我电脑上完成的”。这个答案看上去就不太好。笔者将这个看到的真实案例分享给大家,读者看完,一定要谨记申请材料的真实性,到底该怎么做,你也应该明白了。

三. 申请流程

1. 选校与选项目

后疫情时代下美国 CS Master 申请纪实

一般美国 TOP50 的 CS 院校,三维中 TOEFL 线是 100 左右,GRE 线是 320-325 左右,GPA 3.5+。三维成绩决定了选校 Level,成绩越高越好。笔者成绩很一般,又有自己的梦校,选校范围很大,从 TOP 1 - TOP 70 都选了。分了 4 个档,彩票(TOP 10),冲刺(TOP 10-20),主申(TOP 30 左右),保底(TOP 40-70)。地里有人把美国 CS Master 申请难度排了一个序:

Program
tier 0 MSCS@Standford, MSCS@MIT, MSCS@CMU, MSCS@UCB, MSCS@Princeton
tier 1 Meng@UCB, MSCS@UCLA, MSCS@UT Austin, Meng@Cornell Tech, MSCS@Wisconsin Madison, MSCS@Harvard
tier 1.5 MSCS@UIUC, MSCS@GaTech, MSCS@UPenn, MSCS@UM Ann Arbor
tier 2 MSCS@Columbia, MSCS@UCSD, MSCSE(COC)@GaTech, MSCS@Brown, MSCS@UMich, MSCS@Duke, MSCS@Dartmouth, MSCS@Yale, MSCS@Purdue, MSCS@Washington
tier 3 MCS@Rice, MSCS28@USC, MSCS@NYU, MSCS@NWU, MSCS@UCD, MCS@UCI, MSCS@JHU, MPCS@UChicago, MSCS@Virginia, MSCS@UCSB, MSCS@Stony Brook, MSCS@Virginia Tech

tier 0 是最难申请的。北美 4 大 CS 强校,CMU,MIT,Stanford,UC. Berkeley 实力是最顶级的,MSCS 真的很难申请。Princeton 为何与四大同在 tier 0 里?因为这个项目对口语要求很高,口语 25- 基本都拒了。学术背景要求也很高,据地里统计,这个项目近 2 年陆本没有几个人被录取。

tier 0-3 涵盖了美国 CS Master TOP40 的学校。从笔者录取结果来看,笔者的水平只够 tier3 和一些保底校。笔者今年买的 3 张梦幻彩票是,MSCS@CMU,MCDS@CMU,MSCS@Gatech,都被拒了。这个天梯往上爬确实不容易,每爬一层都不容易。大家可以根据自身的实力往上爬。

针对 EE,ECE,文科专业转 CS 的同学,一定要重点考虑转专业友好的项目,例如 MPCS@UChicago, CS Align@NEU, CS37@USC。这些项目是专门为转专业同学开设的。

关于拿 2 个 CS Master 学位的问题。如果你已经拿到了一个 CS Master,再次申请 CS Master,会遇到一个问题,招生老师需要你解释为何还要拿一个 Master 学位。目前笔者在 LinkedIn 上也没有见过拿了 2 个 CS Master 的大佬。笔者不负责任的揣测一下招生老师的心理:可能会优先把机会留给没有 CS Master 的申请者。

至于拿多个 Master 学位的问题与本文无关了。笔者也可以简单提一提。通常读完 CS Master 以后,OPT 3 年会抽签 H1B。如果没有抽中呢?又想留在美国,怎么办呢?继续再读一个 Master 或者申 PhD。如果 PhD 申请不到,就继续读一个 Master。通常可以选择和专业相关的,比如 CS 可以选择 DS。也可以选择和专业不相关的,比如再读一个 Music,Laws。还有一类 Master 几乎是花钱买 CPT。这类 Master 入学就有 CPT 可以用,也俗称 “day 1 CPT”,入学即可实习。实习期间就可以抽 H1B。每周到校几次即可。反正你有钱,让你苟在美国的方式还挺多的。题外话到此为止,言归正传。

2. 网申

填好网申材料以后,一定请认真检查每个 section,避免出现低级错误。有些学校的 PS 要求不在项目主页里,在网申系统里。所以尽快注册好账号,看好他们的要求(比如字体大小,行间距,字数等等),给自己合理安排时间。

这个阶段比较关键。一定要安排好 DDL 和投递策略。每个学校的投递窗口不同,有的是 rolling 的,有的是 2 轮。网申投递策略十分影响最终结果!学校第一轮普遍招人比较多,最想去的项目或者很有把握的项目都要赶第一轮投递。额外再加几所保底校也第一轮投递。笔者在 21fall 的申请中吃了亏。NEU 的 MSCS 在第一轮中选拔中,GPA 3.5/TOEFL 95/GRE 315 被录取了。笔者三维比这个高,按理来说录取稳了,但是没有录取,劝转 COE 了,转完以后果然录了。而且当时 MSCS@NEU 也非常奇怪,状态变更成 under review 的当天,同时 Rej 了。笔者一度怀疑招生老师并没有看材料,直接拒了。后来经过地里多方验证,这种当天 review 当天秒拒的行为很大程度是那一轮 rolling 招满了。笔者投 MSCS@NEU 是 2 月 3 号,Recommend Letter 是 2 月 10 号 Complete 的,2 月 13 号变成 under review,当天 Rej。NEU 第一轮投递时间是 2020 年 11 月。笔者的经历告诉你,如果你想申 NEU MSCS,建议网申一开启就把它投了。即使是保底,也先保住再说。由此可见,投递策略影响投递轮次,进而影响是否能拿到 offer。

第二轮可以投一些把握没有那么大的彩票校和少量主申校。当然,如果你语言成绩 9 月前刷好了,9 月全力写每个学校的文书,10 月把所有学校都投第一轮,也是非常不错的策略。

另外网申提交之前,请多多注意申请费 waive 的问题。有些学校申请了一个项目可以再送一个项目,即免除一个项目的 waive。NEU 和 USC 都有 waive 的选项。NEU 某些学院的项目之间可以 waive 申请费的。具体情况请看当年招生说明。USC 多个 CS 项目之间,最多可以 waive 一个项目。笔者比较傻,当时虽然知道可以 waive,但是缴费的时候 3 个项目全部交钱了。正确的操作应该是先提交一个项目,然后联系学院给一个优惠码,这样再提交第二个的时候,用优惠码 waive 掉申请费。然后再提交第三个项目并付费。USC 申请 3 个项目,实际上只需要花 2 份的申请费。希望作为后来人的读者,看到这里能汲取笔者的教训,少花一笔申请费。

3. 面试

不少项目有面试环节。意味着申请者在申请季期间不仅要赶 PS 文书,还需同时准备面试。笔者今年申请的项目中有好几个都有面试:MEng CS@Cornell, MSCS@WUSTL, MSE@CMU, MITS@CMU, MSCS@Columbia, MScAC@UoT。面试分为几种类型,一种是 Skype 语音面试,一种是 Kira 约面,一种是 Zoom 面对面,还有一种 Coding Test。Coding Test 类似 LeetCode,1 个小时 4 道题,写完代码自己测试,但无 OJ 评判对错,提交代码后不可更改。最终成绩根据完成时长,跑过 test case 组数共同决定。语音或视频面试的问题多种多样,简单的问题会问 why school,考察你对学校的了解程度,喜爱程度是否强烈。难一点的会根据 CV 问工作/实习经历,暑研经历,交换生经历,科研经历。更难一点的会问技术,例如问你了解哪些设计模式,每个设计模式分别适用于哪些场景。本科是否学习过算法课程,学习过哪些算法。是否学习过线性代数,概率论,如果学过,请讲讲矩阵的秩,逆矩阵,基变换等概念,贝叶斯定理是什么,数学期望,泊松分部,马尔可夫链的概念等等。数据结构中红黑树是如何旋转调整节点的……问技术或者数学方面的问题,笔者觉得最难的地方是如何用英语表达出来,而且能让面试官听懂。笔者被问到什么是桥接模式,桥接模式的定义是什么,以及哪些地方会用到桥接模式。用中文能完美解释,换成英文就很“坎坷”了。

这块和平时看纯英文的技术书看少了有关系。如果日常阅读技术文章是英文,耳濡目染,技术名词肯定都会了。面试回答问题时,遇到不知道如何描述的地方,会换种方式描述。但是这种方法不如一个精准的单词来的专业。有可能你说了一堆,但是面试官提示点出了关键的一个单词,你会豁然开朗,“对对对,我想说的就是这个”。对于想在技术面试中出类拔萃的同学应该知道怎么做了吧?日常阅读技术文章请接触大量英文。

4. 抉择 offer

一般发 offer 的时间在美国圣诞节之后,也就是第二年的 1 月中下旬以后。1 月下旬这波 offer 对应 11 月 15 号至 11 月 30 号期间投递的申请。大批量的 offer 集中在 2 月底,3 月初。接到 offer 以后请先确定好回复的日期,在这个日期前必须做决定,否则 offer 过期。接 offer 之后有些学校还有 deposit 占位费,有些学校占位费非常高,比如 NEU $750,Columbia $4800,NYU $500。占位费是拉高申请者反悔跳车去其他学校的门槛。如果你接了 offer,后期又反悔,对学校来说是非常渣男的行为。学校为了让申请者慎重做决定,增加高额占位费,而且也算给自身增加收入。例如接了 Columbia 的 offer,但是交完占位费一个月后你又收到了 Stanford 的 offer,于是你跳车去了 Stanford。学校增加了占位费的门槛,使得你跳车走了,学校也能白白赚走了你 $4800。当然,疫情期间有些学校因为想吸引更多的学生入学,取消了占位费。希望大家双方都相互友善吧,申请者不要欺负这种不收占位费的学校,随意撕票。学校也不要海王,死活不出结果,让申请者无限期的等待。

一般 offer 的 ddl 从 2 周到 2 个月不等。收到 offer 以后请尽快多了解一些这个项目的坑点,取舍它是否最适合自己。在多个 offer 中动态找到最适合自己的项目。瞻前顾后的焦虑会让人心态爆炸。请记住,世上没有最完美的项目,只有最适合自己的项目。如果不是 MSCS@Stanford 的 offer,其他项目基本都会有取舍。学校排名,地理位置,天气气候,课程设置,就业数据,校友人脉,教授研究方向等等因素都要考虑。

5. 办理 I-20 材料

确定下最终接哪所学校的 offer 以后,便要开始办理 I-20 材料。这个材料包括核对个人身份信息,财产证明,美本本科学生身份转换等等。对于陆本学生来说,主要是财产证明。学校会给我项目花费预算,需要你去银行开出大于这个预算的财产证明。I-20 材料非常重要,只有拿到了 I-20 才能开始办理美签。美签面签申请上需要填写 I-20 Number。不同学校办理速度不同,尽快提交材料,I-20 能早日到手。

以笔者 21fall 来说,笔者接的 CMU offer,办理 I-20 在提交材料以后还需要 4-6 周。其他学校,UIUC,NYU,Rice,WUSTL,Tufts,UTD 这些学校在申请者提交好 I-20 材料以后的一周内,都能下发 I-20。所以一定请尽早办好财产证明,尽早提交 I-20 申请所需材料,笔者建议,所有学校的网申结束以后,便可以开始准备自己的财产证明了,一般 16 个月的项目准备 70-90W 一定够用了。这样 offer 一来,确定好最终去哪所学校以后,当日便可提交财产证明材料,参加 I-20 排队。笔者收到 offer 以后才开始准备的 I-20,又花了一周。最终比我早一周提交材料的同学,比笔者早 10 天收到 I-20。笔者等了 7 周多的时间,接近 2 个月。因为中间还遇到了学校的纪念日放假了。所以尽早准备 I-20 的财产证明材料才是最正确的选择。尽早拿到 I-20 以后,办理面签都主动很多。

关于财产证明 funding 来源的问题,如果写 self-funding,真的是“大坑”。如果有重来一次的机会,笔者一定不写 self-funding,写 parental sponsorship。笔者一开始觉得写什么都无所谓,于是把钱都转到了自己名下的卡里,并且全部资金都办理了冻结手续。提交 I-20 以后,学校审核以后不通过,理由是“self-funding 需要提供全额的财产证明,需要补充材料”。I-20 申请材料上写的是需要提交第一年所有花费(学费+生活费+租费所有费用)的财产证明即可。但是这里如果是 self-funding,需要提供 2 年全额的所有花费(学费+生活费+租费所有费用),这个规定在学校的网站上并没有写,是隐藏规定!如果写了,笔者也不会选择 self-funding!选择了 self-funding 带来的后果是:

  1. 需要额外再准备第二年的材料证明,作为补充材料再提交一遍。
  2. 提交完补充材料以后,还要再等待一周的时间,学校审核补充材料。

这 2 条都很“要命”。第一条,如果你的存款不多,double 的财产证明可能会让你有点吃不消,150W 或者更多的财产证明对于普通家庭来说,一口气拿出来非常不容易,就算有这么一笔钱,也不太可能放在银行活期账户里,也会分散在各个投资理财,基金股票的账户中。(当然也有身价上十亿的家庭,150W 现金直接放在家里作为零花钱,这种人除外)从各种分散的账户中取钱提款,来回也需要花费几天的时间。

第二条,对于 CMU 审核 I-20 材料强行卡 6 周的学校来说,补充一次材料意味着你的 I-20 材料批下来的时间更长。重新提交材料以后,学校还要一周时间审核你的补充材料。最终材料审批下来的时间需要 7 周半,接近 2 个月 8 周的时间了。综上原因,如果你非要选择 self-funding,请一定在第一次提交材料时提前准备足够多的存款证明,不要中间来回折腾!

关于应届毕业大学生,正常的应该写 parental sponsorship,如果写 self-funding 反而 F-1 面签的时候会被拒,面签官 OV 问你,“你刚刚毕业,这 150W 现金哪里来的?”,也许你可以很好的解释(“这是我买彩票中大奖”,“这是我大学创业赚的第一桶金”,“这是我炒股票基金和电子货币赚来的”),但是如果你在新加坡面签,直接 Rej,如果在国内面签,可能被 Check 资金来源。所以应届毕业大学生请别犹豫,务必写 parental sponsorship。至于工作党,写哪个都可以,但是需要注意 一旦写了 self-funding 需要提供整个项目全额的财产证明。

6. F-1 面签

这是去美帝读书前的最后一步,拿到学校发给你的 I-20 材料以后,在美国使馆注册好自己的账号并填写 DS-160 以后,便可以预约 F-1 非移民面签了。需要准备的材料笔者整理了一下,如下:

序号 所需材料 注意事项
1 签证照片 请至正规照相馆拍摄,电子版尺寸为 51*51、白底,露出双耳,同时洗两张面签时携带
2 DS-160 确认页 请填写中文表格发送至 后期顾问邮箱
3 护照原件 本人需要签字
4 户口本原件 如申请人的户口与父母不在一个户口本上,则两个户口本都要提供
5 签证预约确认信及签证费 1008 人民币 可使用借记卡在线支付,或学生本人携带护照和预约编号在中信银行柜台缴纳,保留好收据
6 200 美金 SEVIS 费收据 提供双币信用卡信息,交费单据须在线打印,并保留
7 录取通知书 如学校没有邮寄通知书原件,可使用打印版
8 I-20 表原件 需要学生本人签名
9 英语考试证明原件 IELTS/TOEFL/GRE/GMAT等成绩单(可在网上打印),若已报名还未考试,可以网上打印报名信息
10 在读证明原件/学生证 适用于在读学生
11 毕业证/学位证原件 适用于毕业学生
12 成绩单原件 中英文原件
13 个人简历和学习计划 英文
14 担保人收入证明(如父母都在职则需要提供两人的) 收入证明模板另行发送至学生邮箱;打印在有单位抬头的信纸上,盖公章或财务章。父母双方的年薪建议在 20 万人民币以上。或依据自己单位的规定和格式开具,内容要包括:姓名、职务、任期,年总收入及组成部分、证明人等。
15 存款证明原件 建议 40 万人民币以上,与存单相符,有效期或冻结期需覆盖签证日期
16 房产证明原件 仍在还贷款的房产证不要提供;有多处房产可提供多个证明
17 存单或存折原件 存期不限,总金额应为通知书或 I-20 上显示的一年的总费用,建议 40 万人民币以上,越多越好
18 利息单原件 所提供存单如有转存记录或即将转存,请保留原来的取款利息单和原存单复印件,并提供
19 车产证明 行驶证复印件、购车发票原件
20 名片 如有,可提供,注意核对名片上职务、电话、地址与收入证明是否一致
21 房屋出租合同 房产证原件、合同原件、收据(如房屋租赁收入占家庭总收入较大比例)
22 全家福 证明申请人和担保人之间关系的辅助材料;清晰生活照即可
23 其它材料 其它任何你认为对证明学习能力、国内紧密联系、社会地位有所帮助的文件。
若担保人拥有个人公司,还须提供以下资料
24 营业执照 如申请人或担保人是公司法人
25 验资报告 如占有股份,需要提供
26 承包/合作合同 如果公司经营模式是挂靠或其他形式,需要提供
27 股东证明/分红证明 若有,并且占家庭收入较大比例可提供
28 税单 近2年如有大额税单,可提供

7. 赴美准备开学

后疫情时代下美国 CS Master 申请纪实

成功拿到 F-1 签证后,去美国之前可以把房子在网上租好,如果合租 2b2b,提前找好室友。如果需要买车,也可以开始预订了。一般学校对即将到校的国际生会有一些要求,比如疫苗方面的,体检方面的。按照各个学校的要求办好手续即可。一般大学的 Orientation 在 8 月 15 日 - 25 日期间。所以最好 8 月上旬到达美国,稍微适应几天,买点日常用品,便要开始 Orientation 了。如果首次前往美国,F-1 签证只能在 I-20 表上注明的入学日期前 30 天内入境。

四. 后疫情时代下的变化

自 2020 年 1 月武汉爆发疫情以后,全国 TOEFL / GRE 线下考场关闭。直到 2020 年 7 月才恢复。笔者是 8 月才抢到超偏远地区的稀有考位,真的太难了。9 月和 10 月在中国偏远城市来回往返赶考线下 TOEFL 和 GRE 考试。所幸在 11 月“结束”了战斗。其实笔者最终分数也不高,本来还打算再考 4 次,刷刷分。因各种赶 DDL,压缩了不少时间,又多考了 2 次,分数都不高,最后 2 次没有继续考了,取消考试。建议看到这里的读者能在 9 月前解决完 TOEFL 和 GRE 两门考试,分数达到 105+ 和 325+。笔者语言分数出分太拖沓了,姑且不能怪疫情影响,只能怪自己英语实力垃圾,如果自己实力强劲,9 月第一场线下考试就应该出分了。那么从网申阶段开始,聊聊后疫情时代下留学申请有哪些变化。

1. 申请人数暴增,内卷上天

全球疫情好转是导致 2021 年留学申请数量暴增 50% 的主因:2021年,我国实现了国内疫情的全面控制,多日实现全国零增情况,每日新增基本为外来输入病例。国内安全舒适的自由环境给众多学者造成了全球安全的暂时性假象,这也重新点燃了许多人的出国留学梦。

后疫情时代下美国 CS Master 申请纪实

侥幸心理导致 2021 年留学申请数量暴增 50% 的诱因:国际留学生学费是国外各大高校经费的主要来源之一。因此,2021 年世界各大高校逐渐放宽留学生入学政策来吸引国际留学生,哈佛、麻省、斯坦福等美国知名高校宣布不再强制要求提交 SAT、ACT、GRE 等标化考试成绩(入学必须考试)。除此之外,很多人存在 2021 年留学申请竞争不大,进入世界名校几率增加的侥幸心理。对于今年的申请学生而言,爬藤会变得更困难。这就导致申请人数暴增,院校不得不推迟录取时间。

20fall 这一届的留学生因为疫情没有出国,今年 21fall 他们继续申请,导致申请人数增加;20fall 由于手握一个 offer,再次申请 21fall 没有任何包袱,申请不上更好的学校,继续读当前的学校,如果能申请上更好的学校,21fall 即入学更好的学校。所以会出现手握 20fall offer 的学生继续申请 21fall 更好的项目,吃着碗里,看着锅里的情况。两届挤一届导致战况异常惨烈。

后疫情时代下美国 CS Master 申请纪实

宾夕法尼亚大学申请人数达到 55992 人,比上年增加了 34%!但学校并不打算扩招,这将导致录取率直降。

后疫情时代下美国 CS Master 申请纪实

普林斯顿大学的申请人数增加了 15%,哈佛大学收到了超过 57,000 份申请,增长了 42%,申请人数超出新高。纽约大学 NYU 更是创下了人数超十万这样惊人的本科申请量,其中 22000 来自于国际学生,较去年增加了 22%!这是纽约大学连续 14 年来创纪录的申请数量,在此期间,申请数量增长了一倍以上!

后疫情时代下美国 CS Master 申请纪实

后疫情时代下美国 CS Master 申请纪实

不过申请人数最高的还要数 UCLA,总申请人数达到 249855 人,比去年增加了 16.1%,国际生申请率增高了 10%。面对申请人数的暴增,负责招生的工作量也随之增多。哈佛、耶鲁、哥大、布朗、宾大、康奈尔、普林斯顿等八大藤校不得不将录取结果推迟至 4 月 6 日,斯坦福推迟至 4 月 9 日。

后疫情时代下美国 CS Master 申请纪实

这还只是本科申请人数的变化,研究生申请人数增长可以参考一亩三分地的数据:

后疫情时代下美国 CS Master 申请纪实

后疫情时代下美国 CS Master 申请纪实

同样的情况在英国也有出现。根据英国大学与学院入学服务机构(UCAS)公布的数据,截止 2021 年 1 月 15 日,全球共有 568330 人递交了英国本科入学申请,来自欧盟以外的申请人数达到创纪录的 73080 人,比 2019 年增加 14.7%,其中来自中国的申请人数增加了 33.8%。

后疫情时代下美国 CS Master 申请纪实

申请人数的上涨意味着录取率降低,竞争压力变大。一项调查结果显示,2020 年秋季的国际生入学人数下降了 16%,其中有近 4 万名国际学生在 2020 年秋季暂停入学,并将入学日期推迟到未来。美国研究生院理事会的调查也发现,尽管 2020 年秋季入学较 2019 年申请总人数增加了 3%,但因为疫情和旅行禁令影响,研究生注册人数下降了 43%,博士下降了 26%。这意味着国际生空缺急需弥补。

后疫情时代下美国 CS Master 申请纪实

较之名校,国际生注册人数降低对其他大学而言是一次巨大的挑战。没了学生,学校丧失部分经济来源,因此相较于竞争激烈的藤校,普通院校可能会进一步扩招。

内卷严重国内就业压力增加是导致 2021 年留学申请数量暴增 50% 的次因:伴随 2020 年大量海外青椒回国,国内就业压竞争力骤增,内卷严重,出现了国内高校应届博士毕业生只能做博后的境况,这也导致了大量应届毕业生选择了出国再造的现象。

内卷究竟有多卷呢?笔者举 2 个印象非常深刻的真实例子:

A 同学本科毕业于 UIUC CS,GPA 4.0/4.0 满绩点,TOEFL waive,GRE 339/340。顶会论文若干。FLAANG 实习。这三维成绩,软背景,以及海本 CS 顶尖院校,应该是 offer 收割机本机了。但是却“滑铁卢”了。A 同学申请 CMU MSCS,被拒。实在想不通今年被 CMU SCS MSCS 录取的究竟是什么神仙?

B 同学本科毕业于 CMU CS,GPA 3.9/4.0,TOEFL 115,GRE 330。软背景未知。申请 CMU MSCS 被拒。本校申本校都被拒。今年到底是多么的内卷???已经卷上天了。

2. 在家办公效率“不高”

因为疫情,美国大学校园关闭,所有大学都变成了"昂兰大学"(Online University) ,上课用 Zoom,招生老师也在家办公。在家办公带来的影响是效率不高。如果你很着急联系招生办,例如关联实体成绩单与网申系统的 Application ID。大概率你打电话是没人接的。因为电话打到学校,而老师在家办公。你无奈之下写邮件联系招生办的老师,但是老师回复邮件也很慢。一周 5 天都不理你。于是第二周继续写邮件催老师。第三周老师突然回复邮件,模板式的回复,“不要催!现在是申请高峰期,招生办已经满负荷工作中了。”笔者此时已无力挣扎,只能“佛系等待”,一个月以后,终于关联上了。有一说一,这个效率如果放在中国的公共服务体系里,一定会被人投诉。但是毕竟人家是美帝的工作习惯,你也无法抱怨,一周只工作 5 天,周末铁定不加班,工作日晚上到点就下班,也不清楚疫情之前他们效率是怎么样的。(有时候真的很想有 DING 一下,夺命连环 Call。当然这只能是想想,如果把招生老师惹烦了,谁催就拒谁。招生老师就是爷,得供着。)

吐槽归吐槽,标题上“不高”也是打了引号。理性的思考这个现象,背后也有它的道理。首先美国大学审核申请人材料,有他们一套很严格的审核流程。招生老师严格按照这个流程来办事,一环又一环,步骤非常严谨。再加上申请人数暴增也是事实,在家办公的影响,公布录取榜单的日期往后延迟 1 个月也可以理解的。

3. 冲坡办美签

本篇文章起笔于 2021 年 4 月,中国大陆美国使馆并未开放。读者看见这篇文章的时候可能中国大陆使馆已经开放了。笔者犹豫之下,还是决定保留这段,毕竟冲坡办美签是 20fall 和 21fall 这辈子都难忘的一段经历。

由于疫情的原因,国内使馆停止办美签业务。加上美国 NIE 规定限制,禁止入境美国之前 14 天入境过中国的旅客。那么去第三国办美签 + 洗白成为了刚需。20fall + 21fall 至少有 10W 留学生要去新加坡办签证。(变相繁荣了新加坡旅游业)

后疫情时代下美国 CS Master 申请纪实

入境新加坡前需要准备的材料

  1. 新加坡签证
    在某宝或某程找旅行社办理新加坡个人旅游签证,新加坡旅游签证的有效期一般 35 天至 2 年多次不等,签证的具体有效期以大使馆审批签发为准,签证拿到后自行打印出来,入境时携带即可。新加坡短期旅游签证默认停留时间为 30 天(与签证有效期无关),如果需要待超过 30 天,需要在入境后停留时间到期前通过 ICA 网站申请延期,否则,视为非法滞留。
  2. 护照(有效期 6 个月以上)
  3. 机票(往返或联程机票)
  4. Air Travel Pass(航空通行证)
    免费申请,申请地址。ATP二维码:

    后疫情时代下美国 CS Master 申请纪实


    申请 ATP 需要注意的是必须在前往新加坡前的最后 14 天内没有离开过中国大陆地区才可以申请。入境新加坡前 7-30 天都可以申请,审批时间大概 3 个工作日,申请成功后的有效期为 7 天(从预计入境新加坡日期开始算)。每张 ATP 只能使用一次,但申请次数不限。
  5. Covid-19 旅行保险(保额不低于 3 万新元)
    自 2021 年 1 月 31 日起,通过通过航空通行证(Air Travel Pass)和“快捷通道”安排进入新加坡的旅客,须提前购买保额不低于 3 万新币的旅行保险,用于支付在新期间可能产生的与新冠病毒相关的医疗和住院费用。旅客可自行选择购买新加坡或其他国家的相关保险,并注意随身携带相关保险材料(电子版或纸质版)备查。详情可见新加坡移民局有关网址

    后疫情时代下美国 CS Master 申请纪实

  6. SG Arrival Card(新加坡电子入境卡附加电子健康申报)
    需要在抵达新加坡前 3 天提交个人的电子健康申报,若航班变动需要重新提交一次。

    后疫情时代下美国 CS Master 申请纪实

  7. TraceTogether App(合力追踪)
    入境新加坡前必须下载并激活 TraceTogether App。

    后疫情时代下美国 CS Master 申请纪实

    • 苹果手机只要通过 AppStore 下载安装就可以了。
    • 安卓手机需要通过 Google Play 才能下载安装,但华为手机不能下载安装。
    • 国内登机前只看是否已经下载安装了 TraceTogether, 如果在国内激活不了,可以抵达樟宜机场入境前用新加坡手机号码进行激活(由于 TraceTogether 的网络限制原因,移动、联通、电信都无法接收验证码,所以下载安装后无法激活)
    • 激活 TraceTogether 程序后,注意手机不能关闭蓝牙,也不能手机关机在新加坡期间,必须全程开启 TraceTogether,在离境后,也必须将 App 内的信息保存 14 天。

      后疫情时代下美国 CS Master 申请纪实


      输入手机号码来获取验证码。

      后疫情时代下美国 CS Master 申请纪实


      TraceTogether 激活成功。
  8. Covid-19 Arrival PCR test 预约(提前预约机场核酸检测并提前付款)

    后疫情时代下美国 CS Master 申请纪实


    从中国前往新加坡前不强制核酸检测,落地樟宜机场后需要核酸检测,需要在进行预约并支付PCR检测费用($196 SGD)

    后疫情时代下美国 CS Master 申请纪实

  9. 预约符合要求的隔离酒店
    核酸结果出来之前,必须住在隔离酒店,可以在新加坡酒店协会获取相关酒店列表选择要预定的酒店,提前预定 1-2 晚。等核酸阴性之后,就可以自由行动,也可以更换居住的酒店。

    后疫情时代下美国 CS Master 申请纪实

在新加坡预约面签准备工作

  1. 转移CGI帐号到新加坡
    由于中国大陆和新加坡的签证预约系统都是 CGI 预约系统,不能重复注册。

    后疫情时代下美国 CS Master 申请纪实


    所以如果国内已经预约过面签的同学需要把国内的 CGI 帐号转移到新加坡,可以通过邮件或电话联系大使馆进行转移;如果没有预约过面签的同学可以重新在新加坡注册 CGI 预约帐号。
  2. 填写 DS-160B 表
  3. 在新加坡支付美签费用
    在美签官网支付签证费用,新加坡美签费是不支持线上信用卡支付,必须是新加坡银行卡或在当地用现金支付。

    后疫情时代下美国 CS Master 申请纪实

    后疫情时代下美国 CS Master 申请纪实


    如果有新加坡朋友,生成自己的 Personal CGI reference number 后保存 PDF 发给新加坡朋友帮忙支付,支付成功后让朋友把缴费收据发给你。如果在新加坡没有朋友,生成自己的 Personal CGI reference number 后打印此支付凭条,并携带前往任何一家新加坡邮政邮局进行现金缴费,并保留好缴费收据。
  4. 预约面签时间
    支付签证费成功第二个工作日后,签证费生效就可以预约面签时间,预约成功后会收到一个 Appointment Confirmation 预约确认信,打印出来,携带前往面签。重要:根据美国领事馆的 14 天 travel ban,在新加坡核酸测试阴性以及在当地逗留满 14 天后才能前往美国领事馆办理签证,如果落地新加坡为第 0 天,面签至少必须约在第 15 天。在新加坡面签以后,护照会交还给申请者。一般在面签后 3-5 个工作日内可收到签证中心发送的领取通知邮件,自行选择领取方式可自取或邮寄。最后就根据自己的实际情况是选择直接从新加坡前往美国或返回中国。

美国的 14 天旅游禁令依然生效,所以中国留学生前往美国还是需要在第三国停留 14 天以上才可以入境美国。

4. 政策变化无常

政策的变化很容易打乱一个人原有的安排。由于疫情和中美关系的影响,留学签证上的政策真的是时刻在变。笔者来盘点近几个月来的变化。

后疫情时代下美国 CS Master 申请纪实

2 月焦急等待。2021 年 2 月 11 号是除夕,在此之前的几天,笔者收到了 Cornell 的拒信。春节期间又接到了 NEU 和 CMU 的拒信。3 连拒产生了辍学警告。那段时间每天早上一起床要刷新邮箱,在地里看录取结果。中午午饭后刷新邮箱,晚上睡前刷新邮箱。网上流传了一个新名词,“焚刷匠”,指的是每天心急如焚,疯狂刷新邮件的人。这个词描述这个月的我太合适不过了。

3 月小激动。陆陆续续 CMU 开始放榜,笔者连续收到了 2 个 CMU offer 了。此时心态平静了很多,开始准备找新工作了。计划今年在国内干到年底,赚点钱,然后 22spring 去美国本土上课。如果秋季疫情不能好,考虑在国内上网课。

4 月焦急等待。CMU 突然宣布 offer 不能 defer 了。如果 9 月不能按时到校, offer 自动作废。这个政策一出来,扯到了不少申请人的神经。立即提交 I-20 材料,做好随时飞新加坡的打算。由于 CMU 的政策,导致接下来几个月的工作性质必须是远程的。这也打断了我去字节跳动入职的计划了。没有 CMU 这个政策,笔者 100% 入职字节跳动了。吃不到字节跳动国内的豪华食堂了,戴不了字节的网红工牌做人上人了。这个梦想只能去美帝 Tiktok 实现了。

5 月继续焦急等待。CMU 的 I-20 材料非常慢,需要等待 4-6 周,按照这个时间线,要 6 月才能拿到。5 月因为政策变更,中国留学生赴美解开了 NIE 豁免。周边不少订了新加坡机票的伙伴们纷纷觉得操蛋。5 月 4 号,中国使馆北京,上海,广州,沈阳开始处理 F-1 留学签证。再一次给这一届的留学生上了一课。本来已经打算好去新加坡的,新加坡旅行签,航空豁免,Airbnb 租房,电话卡,机票全部办理完了,突然国内开放签证了。还有不少在新加坡已经被 check 的同学哭晕在厕所。更有甚者,冲动至极的孩子,I-20 还没有到手,美签 CGI 就先注册到了新加坡,填好 DS-160 并缴费。无脑冲坡的下场是损失了这 1000 RMB。此时大批留学生开始转移美签 CGI 至国内。相关留学签证群,新加坡签证群那几天全部爆炸,短短一个小时不看微信群,就能累积 2000 条消息了。笔者也是无脑冲坡的一员,无奈之下也被迫转移 CGI。给使馆疯狂打电话。

  • 北京大使馆: 010-5679-4700
  • 上海领事馆: 021-5191-5200
  • 广州领事馆: 020-8390-9000
  • 沈阳领事馆: 024-3166-3400
  • 新加坡大使馆: +6 531 585 400
  • 美国大使馆: +1 703 665 1986

后疫情时代下美国 CS Master 申请纪实

经过 2 天煎熬的打电话,终于把 CGI 转回到 China mainland 了。国内上海的电话超时会自动断掉。大概 60 分钟无人接听自动会断,等待提示音友好,静音略带电流声。北京的电话不会断,可以无限等待,直到有人接听,等待提示音友好,正常电话嘟嘟声。新加坡大使馆也不会断,可以“无限等待”,直到有人接听,等待提示音不友好,是赛马的声音。本来等待期间就焦虑,再配赛马声音,很搞心态。笔者没有测试过新加坡能否无限等待,笔者等了 30 分钟自动断了,因为话费打没了,电话停机。新加坡电话属于国际长途,打通以后就开始扣费,即使无人接听,等待期间也扣钱,因为你打通了这个系统。笔者一开始不知道扣费规则,于是付费听了 30 分钟赛马声,一分钟一块钱,真的贵。笔者最终打通了美国使馆的电话,语言选择英文。中间有播报很多英文,有些可能听不懂,不过没有关系,讲的全是美签政策相关的东西,听不太懂也不所谓。当提示你按数字键选择时,依次 1-2-2-2-5 转人工(注意不是一次按完,分 5 次输出,电话提示音让你输入的时候再输入)。等待 10-20 分钟后,有人接听电话,"Could please help me to transfer my CGI from Singapore to China mainland?",“Sure, Of course.”。之后会让你报出护照号,名字以及字母拼写,生日等验证信息。大约 3 分钟便操作完成。

5. 一签

笔者的一签被拒签了。请了 2 天假从上海飞到北京安家楼,本以为可以好运,但是结果却恰恰相反。订的酒店很便宜,就在北京美国大使馆旁边。前一天晚上飞机落地后,淋着暴雨入住了酒店。

后疫情时代下美国 CS Master 申请纪实

北京美国大使馆真的很大,像一个超大的四合院。这是在外面看的样子。内部不准带手机,所以没有照片能展示给大家。6 月 17 号是笔者面签的日子。前一天暴雨,结果第二天早上就天晴了。还以为是好运呢。早上在酒店吃完早餐,坐电梯下楼,电梯里面都是学生,手上夹着文件夹。一看就知道是来面签的学生。我跟随着人群,来到了美领馆。上图是美领馆的后面,实际签证要围着这个建筑转一圈,走到它的正门。正门那里排队的学生一圈又一圈。由于 2020 年的疫情,20 fall 的学生都没有办法获得签证,在国内上了一年的网课。如今中国本土签证开放,算是释放了他们压抑许久的心情。我是预约的早上 7:45 面签。7:30 到使馆了,门口的人已经有 1000 多个学生了。排队是蛇形的,而且还绕了 3 大圈蛇形。20 届 + 21 届的学生对签证的需求量实在太大了。北京使馆也非常争气,火力全开。2 层楼总共 48 个窗口全部开放学生签证服务。进大使馆之前需要把随身的水瓶,雨伞,电子设备都存到对面的存包处。(存包处需要收费,如果有家长陪同,可以省下存包的钱)进大使馆只能带自己的面签材料。进门有安检,皮带上有金属物也会被查。进去以后第一道关是验 I-20 文件和护照信息。检查完以后继续排队,收集指纹,双手十个指头都需要按指纹。收集指纹结束,就到第二关,排队等待面签。现在有场控,安排你到哪个窗口去面签,自己无法选择。如果你发现你旁边窗口一直在发拒信,而场控又安排你到那个窗口,只能自求多福,欲哭无泪。我被安排到了一楼的一个靠墙边的窗口,面签官是亚裔男。由于是学生签证,所以全程必须用英文回答,检测你是否具有去美国读书的资格。面签官问了我很多问题。我推测我被挂的原因是,他问我留学资金是谁赞助的。我回答说我自己。然后他又问我工作几年了。我说快五年了。之后又草草问了一下问题。这里我可能就已经挂了。工作 5 年,有工作能力,并且攒了一大笔钱,还携带家属。移民倾向太多于强烈了!直接拒签!当前这些拒签理由都是我猜想的。拒签不会告诉我们理由。于是给了我下图的这个白单子就出来了。

后疫情时代下美国 CS Master 申请纪实

拒签理由是 214b。说我无法证明放弃在美国之外有无法放弃的居住条件。国内缺少约束力:职业,工作,学校,家庭及社会关系。拿了这个白纸以后,整个人都懵了。瞬间不知道怎么办。留学之路就此断绝了?走出使馆,阳光很大很炙热,但是我的内心却下起了倾盆大雨雨。回到酒店清理行李,然后拖着行李到了首都机场。这一路都不知道是怎么走过去了。脑袋一片空白,没了记忆,已经没了灵魂,肉体拖着到了机场。

后疫情时代下美国 CS Master 申请纪实

碰巧的是,今天还遇到了神舟十二号载人飞船发射,北京时间 2021 年 6 月 17 日。飞行乘组由航天员聂海胜、刘伯明和汤洪波三人组成。由于神十二的发射,首都机场实行空中管制。所有的航空线路停止客运飞机飞行。早上 11 点的飞机。一直等到下午 5 点才起飞。这一天是全国欢庆神州十二号成功发射的日子。但是我一个人一点都高兴不起来。坐在机场黑暗的小角落,心里默默流泪。

6. 二签

笔者由于一签的失败。回到上海开始反思二签怎么办。已经快到了 7 月了。距离开学只剩 8 周的时间了。在地里看了很多被拒签的帖子,和我情况类似的比较少。我也一直找不到好的突破点。只要想办法破除掉“缺少约束力”这个条件,二签才可以继续签。因为你再次申请面签,系统中会让你填写,这次申请和上次申请有哪些不同的地方。如果你把上次的材料原封不动的再提交上去,二签直接拒绝你。

后疫情时代下美国 CS Master 申请纪实

这次二签我没有携签,没有带我妹子一起去。打算一个人去签,让我妹子作为我在国内的约束力。留学存款我也改成是父母提供。还好我还留有 4 个月前的银行转账记录。这一点破除我携带大量个人资金准备移民的倾向。另外一个约束力是独生子+房产。说明上也写明自己读完书立即回国。(好像每个人都会这么写😂)一签战败安家楼,二签再战梅龙镇。上海的使馆地点在梅陇镇。签证那天我妹子陪着我一起到了使馆楼下。她帮我拿了包包,省了存包钱。流程和在北京使馆是完全一样的。面签排队的时候,我一直在观察旁边几个窗口的出签率。我这个队伍前面 2 个人都被 check 了。一个生物科学的女生和历史学的男生。轮到我面签了。先简短了问了几个个人信息的问题。然后面签官就让我等等。他在浏览我上次面签被拒签的理由。浏览了有 5 分多钟。看来上个面签官写了很多关于我的“坏话”啊。等待的时间真的很煎熬。我差点以为我还没面就要挂。这次的面签官是美国人。他看完我的“坏话”以后,就开始面试我了。问题全是针对上一个面签官写的我的坏话的问题。我小心翼翼的回答着。问了我为什么居住在上海却跑到北京去签证?为什么上次是 2 个人,这次却是一个人来面签?资金怎么证明不是我自己的,而是父母提供的?前前后后盘问了整整 1 个小时。我回答的口干舌燥。最后给我的结果是一张黄色的纸,check!让我回去等待,不需要补材料。我看我又要被拒签了。当场心态就崩了。我从面签窗口走出来,排在我后面的都没人了。因为我面了太长时间,场控把排在我后面的人都安排到其他窗口了。我拿着黄色单子下了楼。找到我的妹子。妹子非常焦急,问我为什么面了这么久?我约的面签是 7:30 分的。当我面完出来已经 9:30 了。她从我的脸色中看到了我不好的结果。check 这个结果其实是非常差的。如果当场再次被拒,那么我还可以继续约三签。现在 7 月了。被 check,check 需要等 8 周才能出结果。如果 8 周以后我被拒,那我已经没了三签的机会了。那个时候已经开学了。笔者能否去美帝读书全部压在了这次二签结果之上了。如果挂了,可能也没有笔者这篇经验分享了。笔者 6 月 29 日二签被 Check。直到 8 月 12 日才 Administrative Processing。

后疫情时代下美国 CS Master 申请纪实

后疫情时代下美国 CS Master 申请纪实

运气比较好的是 8 月 13 日紧接着 Issued 了。等我拿到签证护照的时候已经是 8 月 18 日了。那一周紧急的和一些好友告别,下周一 23 号就飞广州 - 首尔 - 旧金山了。如果读者也有在办理美签签证的话,我能给的建议是,祝好运!如果真的被拒签,请耐心,多签几次,还是有机会能过的。

7. 出入境海关体检,抢预约

在做飞机离开祖国之前,需要到出入境海关办理国际健康证明书。俗称,黄本本和红本本。

后疫情时代下美国 CS Master 申请纪实

因为 2020 年疫情搅乱了全世界的格局,大家对 COVID-19 疫苗接种异常敏感。不接种疫苗,或者无核酸检测阴性证明都无法上飞机。上海海关入出境体检实在太难约了!笔者 7 月底想着约 8 月的体检。根本约不上,全部都满了。后来在群里了解才得知,其他同学早在 4 月就提前约好了。比我提前 3 个月。难怪我抢不到号!所以建议要留学的同学,这个体检一定要早点预约!!不然就可能约不到了。笔者看到上海约不到,就去周边城市体检。先看了南京苏州,不巧的是,突然南京突发疫情。我去南京体检完可能就无法回到上海了。于是我又看杭州的出入境管理中心。成功约到了杭州的出入境体检。体检分 2 部分。一部分是基础检查,另外一部分是打疫苗。疫苗是学校要求打的,比如打流脑疫苗,水痘疫苗等等。每个学校不同,打的疫苗也不同。有些疫苗在国内还没有,因为这种病在中国就不存在,只存在于美国。所以有些疫苗需要入境美国以后再打。当然也可以选择全部都在入境美国以后再打。笔者在国内体检完,也在国内打了学校要求的疫苗。

如果你想省钱,建议在入境美国以后再打学校要求打的疫苗。因为每个学校的医保可以全额报销疫苗的这部分开销。在国内打疫苗全部自费。

8. 打新冠疫苗

新冠疫苗中国打和美国打,都可以。有不少学生会担心,如果中国打了 2 针疫苗。到了美国再打美国的疫苗,会冲突么?笔者已经接种完美国辉瑞的 2 针疫苗。也接种完中国的 2 针疫苗。目前一切正常。笔者在国内打的是科兴疫苗,第一针是 4 月 10 号打的,第二针是 5 月 15 号。隔了半年以后,10 月底在加州打了辉瑞第一针,11 月 29 继续打了第二针。目前四针都打完了,身体一切正常。至于还有同学犹豫打不打国内的疫苗,是否能入境美国以后立即接种美国的新冠疫苗?笔者给的答案是,建议先打国内的疫苗。因为在跨国转机过程中,有很多感染的风险。打上疫苗以后,给自己加一层保障。

9. 拔牙+配眼镜+核酸检测

拔牙这件事也很重要。需要拔智齿的最好也在国内拔掉。在美国拔牙要预约,拔牙周期很长,也很花钱。笔者有 2 颗智齿没有拔掉。拔智齿需要用到切骨刀,这个工具在全上海只有一个医院才有,需要预约。拔完还需要 3 周回复,并且这 3 周内不能打其他疫苗。笔者想起来拔智齿这件事有点晚。8 月才想起来。那么我要么选择拔智齿,不打学校要求打的疫苗。要么选择打学校要求打的疫苗,不拔智齿。笔者选择了后者。因为拔智齿也许 3 周恢复不好,临近开学还是不折腾了。从这件事情也说明,拔牙要尽早规划,3,4 月等 offer 的时候就去把牙齿改拔的拔掉吧。

如果有近视眼的同学,建议在国内配一副眼镜。在美国配眼镜需要先验光,再配眼镜。整个流程很长。还是乖乖的在国内配好一副新眼镜带来备用吧。

核酸检测这个不用说,飞机起来前 2 天预约好核酸检测。如果中间需要中转其他国家,主要看好每个国家的防疫政策。有的国家只允许过境 72 小时,但是不准入关。这种情况下,换飞机只能直挂,不能入关换成飞机。这些细节都要自己看清楚。

10. 起飞

飞机起飞没什么好说的。需要注意的是行李里面的物品。不要带美国海关违禁的物品,仔细查查药品是否是处方药。很多处方药都不允许带入美国境内。最后在入境美国之前需要填写入境单,如下图。

后疫情时代下美国 CS Master 申请纪实

五. 最终结果

后疫情时代下美国 CS Master 申请纪实

  • Decision: MSSE@CMU ECE
  • AD: MSSE@CMU ECE, MSE@CMU SCS, MSCS@Columbia, MPCS@UChicago, MSCS@USC, MSSE@USC, HPCS@USC, MCS@Rice, MSCS@WUSTL, MSSES@NEU COE, MSSSD@Tufts GSAS, MSCS@UTD
  • Rej: MCDS@CMU SCS, MITS@CMU SCS, MECS@Cornell Teach, MSCS@Geogia Tech, MSCS@NEU KCCS, MSCS@NYU Tandon, MSCS@UCSD CSE, MSCS@Stanford, MScAC@UoT, MScCS@UoE, MCCS@NUS

眼尖的读者会发现其中有英国,加拿大和新加坡的学校。确实,笔者今年 21fall 混申了。UoE 爱丁堡大学,我的一个托福老师毕业于这里,强烈我推荐申请,为了情面,我不得不申。UoT 多伦多大学,一个与我 20 年没有相见的发小在此工作。说来很巧,申请季突然联系上了,为了这份感情,我不得不申。NUS 新加坡国立大学,支持 go local,可以在武汉校区上课,如果美国疫情无法收拾,彻底无法出国,NUS 算最最最最终兜底的方案,可以呆在武汉校区上网课。

以上这些学校的申请费加起来挺贵的,平均一个项目是 $90,额外的可能还有 WES 认证,$30,每个项目 TOEFL 送分是 $20,GRE 送分 $27。平均一个项目是 $90+20+27=$137,笔者总共 23 个项目,光申请费就大约花费 $3151。

21fall 申请季也有不少遗憾,有 4 个项目被拒,笔者一直耿耿于怀。MSCS@NEU KCCS 如果 10 月底或者 11 月网申一开放,赶第一轮 rolling 立即就投,应该能被录取,这个项目没拿到 offer 算是今年申请季的一个“事故”。MSCS@NYU Tandon 是笔者想了很久的项目,今年 bar 卷上天了,实在无奈。MSCS@UCSD CSE 也是笔者想了很久的项目,做梦都在想是否能被录取,可惜最后还是被拒。MScAC@UoT 今年 GPA 的 bar 太高了,GPA < 3.9 的一律拒,唯有叹息。如果能重来的话,笔者可能还想试试这些项目 MCS@UCI,MSWE@UCI,MSCS@NYU Courant,MCS@TAMU,MSCS@Duke,MSE@JHU,MSCS@NWU,MSCS@Stony Brook University,MENG@UCLA,MENG@UCBerkley(不过这些可能都会被拒,纯属浪费申请费了,项目也不用申请太多,能被梦校录取即可)。

最后晒一晒 TOP50 的学校 offer 作为今年 21fall 申请季的结局吧。CMU 的 2 个 offer 是正式 offer,其他几个由于要交“巨额”占位费,或者不打算去的,最终 offer 都 decline 了。关于下面的 offer 都是 PDF 版截图截出来的,笔者有几点想说:

  1. WUSTL 是笔者今年申请季唯一一个作为 TOP50 学校没有占位费的。
  2. UChicago 的 offer 不要吐槽它的 AVI 像素,PDF 上 title 图片确实分辨率就是这样的。也许,也许图片加了个人身份信息编码呢。UChicago 的 offer 中居然还明文展示了用户 ID,笔者隐藏了。
  3. 每个 offer 的头图和正文之间有大量的空白,懂的人都懂,这之间有大量的个人隐私信息和个人姓名等信息,可能本篇文章阅读的人会比较多,所以隐去了这些私人信息。

后疫情时代下美国 CS Master 申请纪实

后疫情时代下美国 CS Master 申请纪实

后疫情时代下美国 CS Master 申请纪实

后疫情时代下美国 CS Master 申请纪实

后疫情时代下美国 CS Master 申请纪实

后疫情时代下美国 CS Master 申请纪实

后疫情时代下美国 CS Master 申请纪实

后疫情时代下美国 CS Master 申请纪实

后疫情时代下美国 CS Master 申请纪实

后疫情时代下美国 CS Master 申请纪实

后疫情时代下美国 CS Master 申请纪实

结尾

最后这段是额外加的。因为在地里看到一个关于出国留学的讨论:

  1. 大四出国留学是常规操作。
  2. (本/硕/博)毕业工作 1-5 年内,如果在国内北上广深一线城市拿到户口,并且拥有一套以上房产,出国工作/留学是人上人,人生赢家,体验生活。
  3. (本/硕/博)毕业工作 1-5 年内,如果在国内北上广深一线城市无户口无房产,出国工作/留学就是 loser。

我周围还真的有满足第二种情况的人。硕士毕业直接落户上海,工作 3-5 年后,硕3-硕5,在上海全额买了一套房产。真的很厉害。有读者可能疑问,一定要全额买么?北上广的房价太高,全额买必须靠父母。地里的讨论其实也包括贷款买房。但是如果你在北京或上海买房以后,再出国读书,读书期间不能打工,每月无收入进账,每个月的房贷只能靠之前自己的积蓄或者父母支撑,再加上美国每月房租和吃喝的开销。总开销不小。能支撑起这种开销的人或者家庭,在国内已经能算人上人了。

至于我,对号入座,是情况三,我就是 loser。


GitHub Repo:Halfrost-Field

Follow: halfrost · GitHub

Source: https://halfrost.com/halfrost_2021/

下一个五年计划起航 !

题记

下一个五年计划起航 !

考虑到本系列文章有部分新的读者,所以关于本系列文章名字的起源就不再赘述了,见这里《"星霜荏苒"名字诞生记》

注意这篇年终总结是 2020 年的,并非是 2021 年的。当你看到这篇文章的时候,可能到了 2021 年年底了。


疫情

下一个五年计划起航 !

2020 年一定是属于人类历史上最具有历史事件的一年。这一年发生了全球严重的公共卫生事件。武汉作为风暴的中心,也是舆论的中心。各种阴谋论鳞次栉比。好在在共产党的领导下,大概 3 个多月就使得武汉原有的确诊病例动态清零了。也因为这 3 个月的封城,武汉的经济受到“重创”。父母的餐馆也受到毁灭性的打击,关门倒闭了。家中经济也因此受到重创。

在今年 1 月 23 号,钟南山爷爷宣布新冠病毒人传人以后,我就退了高铁票没有回家。虽然人在上海,但是基本心还是牵挂着武汉。新闻 app 每隔几个小时的疫情情况推送,时刻都牵扯着我的心。今天这里新增十几例,明天那里新增十几例,弄得人胆战心惊。还记得我去药店买口罩,大雨天大家排了很长很长的队伍,每人每日限购 2 个 N95 口罩。疫情刚刚爆发的那段时间,口罩就如同命一样,不带口罩完全不敢出门。而且那个时候口罩还非常短缺,每天在家数着口罩过日子。口罩快没有了需要赶紧出门,走几条街去排队买口罩。网友们在网上发着暖心的漫画,暂时缓解着紧张情绪。

下一个五年计划起航 !

上图是全国的美食都来看望生病的热干面了。武汉每年 4 月是武汉大学看樱花🌸的季节。因为这次疫情的封城,大多数人都不能来看樱花了。感谢医务工作者们的大无畏精神,拯救了武汉这座城。当疫情过去之时就是请你们每个人来武汉看樱花之日。

下一个五年计划起航 !

另外一个比较深刻的是,中国人再次验证了基建狂魔的称号。仅仅用几天时间就将雷神山,火神山两个医院建立起来了。

下一个五年计划起航 !

其实火神和雷神的名字是有来源的。楚文化传说中的湖北乃古楚之地,而楚国人被认为是火神祝融的后代,祝融(帝喾)则是黄帝的子孙。人的肺部五行属金,火克金。 而荼毒人类肺部的新型冠状病毒惧怕高温,火神正好能驱瘟神,于是“火神山”之名应运而生。

至于雷神山,也是对应着中国民俗文化的传说:雷神是惩罚罪恶之神。凡有违背人伦法理且犯下不可饶恕罪责者,则将遭受五雷轰顶而毙亡。这便是“雷神山”一名的来历。今天的中国,举国坚定信心、同舟共济、科学防治、精准施策,一场防控阻击新型冠状病毒的人民战争正在取得胜利,此刻有火神山和雷神山的“加盟”,人们的精神力量更加充分!

由中建三局牵头,武汉建工、武汉市政、汉阳市政等 3 家企业参建的武汉蔡甸火神山医院,将用于集中收治新型肺炎患者,被称作武汉“小汤山医院”,在 2 月 3 日前建成。除夕夜,火神山医院施工现场灯火通明,各种机械开足马力,这是一场攻坚战,建设者争分夺秒,力争早日完工。网友们纷纷开启云监工模式。5000 多万网友在家里看着火神山和雷神山施工。

下一个五年计划起航 !

网友们还将这些工地上的车分别取了各自有爱的名字。吊车是“送高宗”,带钩子的是“小红”,挖掘机是“蓝忘机”,货车是“红牛哥哥”,叉车是“叉酱”,水泥搅拌机是“呕泥酱”,还有“多尔衮”,“白居易”,“摄政王”,“光武帝”,“黄太急”,“吴三桂”,“小小黄”,“小绿”。感谢网友们风趣幽默的名字,短暂治愈了我紧张的神经。

最终,武汉这座英雄的城市,还是顺利挺过了难关!剩下的亲人离去的伤痛,经济带来的创伤,都交给时间去愈合吧。

下一个五年计划起航 !

离职之后

经过 2020 年这次疫情,让我的人生轨迹也遭到了不可逆的“打击”,很多事情都回不到过去了,也不可能回到过去了。我在 6 月拿完年终奖以后,选择了辞职,去干一些自己立即想做的事情。离职了以后也没干什么惊天动地的事情,就全世界到处转转,放松放松。(这部分的故事就不细聊了。因为是疫情期间,聊旅行的事情有点违反中国防疫精神—— 非必要不旅行。)金钱确实很重要,但是如果和时间比起来,显得没那么重要了。读者可能有人不赞同我这观点,没关系,毕竟每个人的经历不尽相同,对一些事情的认识也不同。疫情也让我对以下一些“人生哲理”有了更深的认识:

  1. 人这辈子钱是赚不完的。当你赚完 100W 以后,马上就想着赚 500W。当赚完了 500W 以后,就想着赚 1 个亿,10 个亿。钱这个东西当然越多越好。所以赚钱的这条路上,永远没有终点。那赚钱的目的是为了什么呢?
  2. 赚钱对于我来说,是为了辅助我实现一些人生理想。我深知我是不可能像乔布斯那样,生来就是为了改变世界。我的生活的理想就是理想的生活。一般普通人赚钱的目的都一样,为了改善自己的生活,给自己下一代更好的生活,提高父母的生活质量等等。用更加高尚的说法说,就是想提高一下自己的社会阶级。我爸妈那一代是经历过文革的一代人。那一代人就连学习都是奢望,每天都在“斗地主”。(陈景润数学家完全是那个时代的“奇葩”) 所以我爸妈这一代的历史使命是让自己的孩子,也就是我这一代,读个好大学,读书不愁。我爸妈算是完成了他们的愿望,我读了一个大学(不过不是顶级大学)。那我这一代的历史使命是什么呢?一代肯定要比一代强。我的下一代肯定至少要硕士,博士了。(开玩笑的) 从社会阶级的层面来说,我爸妈那一代,是苦过来的一代,想读书却没有书可以读,基本都是高中文凭,之后随着国家的大生产运动,振兴工业。我爸妈那时还不算是小康。他们的历史使命是把我这一代向上送一个阶级,中产阶级。那么我这一代的历史使命至少能让孩子出生在中产阶级中,然后尽我所能让他享受更好的教育和医疗,助他实现他的人生理想。说了这么多,赚钱的目的也就出来了。我赚钱就是为了完成我的历史使命的。
  3. 既然赚钱这条路是永无止境的,那这条路上是否有一些节点呢?比如先赚 1 个亿为目标,那么先赚 500W 是否可以当做一个人生节点呢?我觉得是可以的。比如先赚到 500W 的时候,先去实现一些愿望。因为时间是不等人的。有些愿望在规定时间内实现不了,可能这辈子也实现不了。有人可能读到这里还不信。那我就随便说 2 个例子吧。如果一个中产阶级 30 岁之前没有环球旅行一次,那么这辈子之后再环球旅行的几率就不是很大了。很多人可能会说,“这并不是我的人生理想,我不用去实现它”。好,那再换一个。如果在爸妈 50-60 岁之前没能完成他们的一些愿望,可能之后就没法完成了,只能变成永恒的遗憾。这个时候,时间就是不等人的,它可不等你有没有赚到 1 个亿,到时间了,就无情的剥夺你实现愿望的权利。比如爸妈这辈子想去爬爬长城,想去布达拉宫看看,爸妈岁数大了以后,到时候想去高原都去不了了,因为身体不允许了。那么该怎么做呢?我自己选择在 30 岁的这个人生节点先实现一些愿望。这也是大家看到我离职实现一些人生愿望的原因了。30 岁之前是一个人生节点。因为这个时候有一些积蓄,可能还没有孩子,没有太多的负担。这个时候可以带爸妈去去拉萨,或者去冰岛看极光,回馈他们这辈子的养育之恩,也去实现一些他们认为是“奢望”的理想。30 岁之后,就有家庭,有了一些事业,可能一直到 40 岁之前都会一直在很忙碌中度过。
  4. 钱既然是赚不完的,那什么才是你这辈子最值钱的东西呢?是健康。一个健康的身体才是你无价的财富!我之前还没有这么深刻的认识到这个问题。赚钱是很重要,但是爱护自己,保护好自己的健康更加重要。在这次疫情面前,钱是那么的无力。至少目前的科学技术来看,钱是无法买命的。古有秦始皇终其一生寻求长生不老之药,今有富豪们研究永生的秘密。有钱人都会希望能永生,砸钱续命。但是起码目前看来,钱是无法使人永生的。即使未来哪天实现了,也会很昂贵,也不是一般中产阶级能去做的。“穷人”面对生死的时候,如果能拿出健康这一法宝,健康才是“穷人”的最无价之宝。

下一个五年计划起航 !

从 2019 年开始,一直到 2020 年 12 月,这期间周围有无数的朋友跳槽换工作,国内外大厂,大大小小什么公司的都有。换了好公司以后,朋友就会开始在朋友圈发新公司的招聘,宣传各种福利,下面评论清一色的酸柠檬🍋。他们也会私聊我或者在群里 @ 我,问我怎么还不换工作,什么时候看机会。我每次都谦虚的回答,“我技术这么菜,面不上贵司啊”,“我这么垃圾,贵司看不上我啊~”。实际上我只是想掩盖一下我小小的梦想。有不少猎头找我要我看机会,年前 1 月跳槽季,年后 2,3 月跳槽季,财年结束 5 月跳槽季,秋季 9,10 月跳槽季,年底 11,12 月跳槽季。一年下来只有 4,7,8 这三个月电话会少一点。有猎头删我好友的,有骂我傻逼的,组织架构调整了还不离职。有骂我技术垃圾的,两年 P6 这辈子你没救了。你们说的每一句话,我都看在眼里的。我也在心里无数次提醒自己,“你就是一个傻逼,为了这个留学的目标真的值得么?执着的大傻逼!”。“我的技术确实菜,前端,客户端,后端都只懂皮毛,至今啥都不精通。”

别人微信里的猎头应该都是这样的画风:“你好厉害啊,你的技术实力肯定能面上阿里巴巴的 P13/百度 T11/腾讯 18 级/字节 5-3,来试试吧,我已经帮你投简历了”,而我微信里的猎头的画风截然相反:“大傻逼,还不跳槽,你今年再不跳槽,这辈子你就废了!!你已经废了~”(一点都没夸张,你们别说不可能,只是你们没遇到),然后我转身想解释,他已经把我删了。 我们互不相识,一上来没聊几句,我的人生怎么就废了???我一直在心里默默精心规划着我的未来,5 年规划,10 年规划,就因为不跳槽,就废啦???也有“好心”猎头帮我规划的,“今年咱们跳到字节,明年咱们再跳一下腾讯,后年咱们再跳一下百度,只需3 年,你的职业就到达一定高度了!”。这个规划和我内心的规划有差距,我说“我有我自己的规划,今年暂时不跳槽”,猎头就会问,“你的规划是什么?”,我说保密。猎头就炸了,一顿狂喷我虚伪,“技术太垃圾跳不动也就算了,偏偏找个理由说不跳槽,我对你这么真诚,你这个人怎么这么虚伪??你人品有问题啊,垃圾~”,我内心一顿委屈。怎么还骂人了?10 分钟以后平复的情绪以后,想给他解释解释原因,对方已经把我删除了。2020 年我已经被好多“脾气有点暴躁”的猎头删好友了,我也被他们莫名的贴上了各种标签,“傻逼”,“没智商”,“人品差”,“虚伪”,“没规划”,“技术垃圾”,“这人废了”……看着他们说的这些话,我真的非常窝火,他们说的没一句是正确的。由于已经被删好友,所以我也没有解释的权利和机会了。这一次次的被人骂,都在我心里默默打气,“我要好好奋斗!今天我是你们眼里的大傻逼,臭垃圾,被你们唾弃瞧不起。明天我要成为其他猎头眼里的香饽饽,暖宝宝,让你们后悔来不及”。就这样,一口气,支撑了我一整年。

一开始我和一些身边的朋友说过我在学英语。慢慢的他们也忘记了。经常会有人问我,“博客怎么不更新了?”“最近在忙什么?”。我如果如实回答,答案就是“最近在研究天文学和地理”,“最近忙着备考托福”。一些人又会开始问了,“学天文和地理干嘛?”,“你考托福干嘛?要出国?要留学?”。这个时候我再回答什么,都会导致后面的对话异常尴尬。同事如果知道了,会偷偷私下传播,“霜神要准备出国了,他肯定要离职了,这个季度的 C 就给他吧,反正他要滚蛋了。”。如果 leader 知道了,oneone 的时候,估计大概率要我背 3.25 。猎头知道了,应该也不会再帮我推荐好机会了。刚开始准备托福的前几周,我恨不得让群友都知道我去学英语了,欢迎一起交流。但是我想通说出去的后果以后,我就闭嘴了。从那以后,朋友,同事,猎头问我,“最近忙啥”,“后面职业规划是啥”,“什么时候跳槽”,类似的问题,我都打马虎眼,“啊?哈。呀?哦。。哈哈哈。。”看到这里可能有读者问了。你为什么要瞒着全世界?你说出自己的留学规划也不是丢人的事情啊。那我反问一句,你跳槽前会让同事和 leader 知道么?肯定不会啊,他们和你利益相关,很有可能你 offer 没拿到,他们就知道了,接下来在你还没有找到下家的时候就把你开除了。这个时候你只能欲哭无泪了。在国内,上班看开源库的源码实现都会被人怀疑要跳槽,仔细询问业务逻辑就会有人怀疑你要跑路。更何况你在公司看技术书,看技术博客,学习,那肯定是跳槽实锤了。为什么自己的计划和打算要和怀疑你的同事一一述说呢?为什么要给自己徒增烦恼?留学和跳槽类似,并且还需要再瞒住朋友和猎头,因为你有可能还需要他们帮你内推。如果你都交代了,他们可能就不帮你内推了。可能又会有读者问,你都要留学了,为什么还在意找工作内推的事情?这就和你们不了解考研和留学有关,既然说到这里,那我就一口气说透吧。不管是考研还是留学,知道最终是否被录取的时间都是在第二年的春天,考研复试公布最终录取名单的时间是 4 月,留学发放 offer 的时间是 2-4 月。考研是每年的 12 月,留学申请的 deadline 一般都在 12-1 月,考研考完试,留学申请完,到知道最终是否被录取之间的这段时间,你是不是还要继续工作?你不用工作,就在家里玩,啃老?那你是有钱人家的孩子。我是穷人家的孩子,每个月还有房贷,信用卡,花呗各种账单等我去还款。考研 12 月考完,第二年 1 月到 9 月入学前,这有 8 个月的时间,肯定要继续工作赚钱啊。留学申请 1 月结束,到 8 月入学,中间也有 7 个月的时间,也要继续努力工作赚钱。而且你也不知道你能不能申请到如意的学校。万一没人要你呢?你还是要继续工作赚钱。有人说 7 个月也要赚钱?这 7 个月可以赚小 50W 啊,可以抵掉留学第一年的学费,不香么?50W 说不要就不要了?如果你说出你的留学意愿以后,同事,leader,朋友,猎头全部都知道了,试想你该如何继续找工作?我的朋友圈有一点广,就算下一家的 HR 不知道, 但是能瞒得过几天呢?业界一传播,几天以后全世界就都知道了,HR 知道了会怎么想?“都要留学了,还来我这里蹭?试用期就开除他”。别笑,人心隔把刀,我不能保证别人不这么想。但是我能保证我不说。我只要不说,就能杜绝所有可能发生的坏情况。所以这也就是为什么 2021 年了,你才看到我的 2019 年的年终总结。2019 年我开始准备托福,申请留学阶段是在 2020 年冬季。我提前把 2019 年的年终总结发布出来,大家一看我在准备托福考试,全天下人都会怀疑我要出国了,我有口也狡辩不清了。在拿到最终 offer 之前,我肯定什么都不能说,而且中美关系恶化,我就算拿到了美国大学的 offer,美签能不能顺利下签也是一个头疼的时候。我当时就下定决心,等我踏上美国国土的那一刻,我再向大家公布这一切,2019 年和 2020 年的年终总结都发布出来。以上就是我隐身 2 年的原因。我对我保守的结果非常满意。我的所有目标基本完成,无人干扰。

另外,我个人是保守性格,在结果未 100% 确定的时候,我都不想透露过程。如果明年 (2021 年) 1 月在我投递完留学申请以后,我就开始找新工作了。到 4 月出结果期间,我一直会努力工作,当做什么都没发生。如果 4 月拿到好结果,那就 6 月份提离职。如果没有拿到好结果,也可以继续再干着。试想,在你准备托福考试的时候就全天下的宣传,“老子要出国留学了!”,然后等到你申请结束,什么 offer 也没有的时候,是多么的尴尬?嘴巴上天天喊着很高调的人大多数都没成功,沉默不语低头做事的人大多数都成功了这是一个极度看重结果的社会,没有优秀的结果,谁会关注你的过程?知乎上你说你 1 天托福考到 118 分,会有大批人来围观。如果你说你花了 10 年把托福考到 100 分,肯定没人关注,你的经历是 total 失败的经历,毫无参考价值。你我都是不同人眼中的工具人,他人想从你这里学到成功的经验。在这个成王败寇,连第二名都会被遗忘的年代,无结果的努力最终只是感动了自己。我的性格就是这样,考虑周全,低调的准备完所有考试,然后拿到最好的结果以后,再来出来分享结果,丢出这一波王炸。综上,所以从 2019 年我就开始闭嘴了。大家也都不知道我忙啥去了。周围朋友跳槽的跳槽,删我好友的删我好友。我低头拿着工资,赚着奖金,拿着最好的 offer,最后还无缝对接去美国读书,难道不香么?没有任何人能破坏,也不会给机会让别人破坏我精心埋藏呵护的“完美”计划。这应该就是“闷声赚大钱”的真谛吧?(手动 @大左)。

Don’t tell anybody what your next move is. Just do it and shock them. And after shocking them, stay silent and plan your next move. And make it happen. Just keep shocking. Keep Enjoying. Keep Repeating it.

留学准备

今年也许还是有很多人不明白我为什么突然要离职,突然中断自己的“光明”的职业生涯,选择留学。下这个决定的心路历程在 2019 年已经想清楚了。去年就确定了走这条路了。但是走这条路的目的没有和读者们说的很明白。那么在这里,我发自内心的给出答案:

  1. 实现职业目标

  2. 满足自己的好奇心,想看看全世界最顶级的工程师平时工作是怎么样的?

  3. 自我发展

  4. 体验全新的异国文化或生活方式

  5. 获得更高的教学质量

  6. 获得一次冒险经历

  7. 结交新朋友或扩大专业社交圈

  8. 学习一门新语言(非编程语言)

世界那么大,生而为人,多出去看看外面的世界。为自己真正想要的生活和幸福奋斗吧。当你扩大了自己的格局以后,你真的会发现,优秀的人真的很多,自己渺小如一粒沙。你真的会发现望尘莫及的事情很多,你需要一直不断努力。你会发现生活不止与眼前,You only live once.

2019 年 8 月就开始准备托福考试了。很不幸的是,到年底也没有准备好。裸考了一次才 80 分。分数很不理想。于是今年继续准备托福刷分。

下一个五年计划起航 !

今年笔者一直在准备托福和 GRE 两个考试。但是因为疫情,线下考场全部关闭,直到 8 月才开放。中间这 7 个月一直都没有考试。原以为 1,2 月复习好托福就可以考试。哪知道突发疫情,线下考场关闭。一转眼就晃到了 8 月。家里人一直督促我提前复习好托福。等考场一开放立即“秒杀”考试。但是笔者还是懒,deadline 还是第一生产力。没有具体考试日期的约束,复习效率一直不高。一般大家考前一周的复习效率是最高的。因为知道一周后自己要上考场了,临时抱佛脚也是最认真的。笔者直到 8 月 15 号才约到第二次托福考试。考完这次托福考试以后便开始了 GRE 的复习备考。

实不相瞒,感觉 2020 年过的太快了。一转眼就过去了。每天看着全球各地这里那里的疫情播报,一年四季都带着口罩。什么事都没有干,一年就过去了。

关于 GRE 考试的复习备考。笔者没有太多的成功经验分享。笔者最终的分数也没有刷到 325 分。一般托福考到 100 分左右。GRE 裸考就有 315 分了。当然这个分数是完全不够的。GRE 对我来说最难的题目就是 Verbal 填空题了。这道题对词汇量的要求太高了。有很多 GRE 的单词是从小到大一直背单词的我们,一点都没见过的单词。因为这个考试的目的是用来考英语为母语的人的。而托福考试是用来考非英语为母语的人的。GRE 的阅读和托福阅读有一部分是相似的。GRE 数学要好好刷几套模拟题。不然应用题会坑死你,里面有很多隐藏条件如果看不出来,那么那道题必错。GRE 数学中还要注意一点,有些题如果给的条件不够,不能自己脑补条件。比如没有告诉男女比例是 1:1,那么涉及到男女比例相关的题目的时候,应该大胆的选择“此题无解”。此题无解这个选项非常具有迷惑性。因为你不知道是因为你的原因导致这道题解不出来,还是因为题目条件缺失导致。由于笔者专业对 GRE 写作要求不太高,3 分或者 3.5 分都可以接受。所以笔者 GRE 写作没有投入太多的时间。用托福写作的实力去写,马马虎虎就 3 分。所以 GRE 总的来说,想考高分,数学要尽量满分。填空要尽量拿高分。GRE 满分 340 分。数学 170,剩下的语文也是 170 分。语文想拿到 160 分以上就非常难了。一般能上 150 分的话,那 325 就很有戏了。GRE 的成败就在语文的 Verbal 上了。

GRE 和托福考试相同点是,都会加试 section。考试者不知道哪个是加试的 section,加试的 section 不算分,由于不知道哪个是加试,所以每个 section 依旧要认真对待。GRE 和托福考试不同点是,GRE 的考试难度会根据考试者做题情况,动态变化。例如考生第一个 section 做的特别差,那么第二个 section 就会变得很简单。但是每道题的分数也动态的变少了。在这种情况下,即使整个 section 全对,分数依旧不是 170 分。只有在 hard 模式下全对,分数才能是 170 分满分。如果考生第一个 section 状态特别好,做了全对。那么第二个 section 会变难。当你明显感觉到难度变难了,那么恭喜你,你开启了高分困难模式。虽然你做对的几率变小了。但是如果蒙对一题的分值也变多了。

笔者很幸运,被加试的是数学。数学比较简单。笔者最终得分是 169 分,还是被扣了一分。(知乎上说 GRE 数学做不到满分💯就不是中国人,😭我不是中国人)

GRE 的单词只能靠自己多背了。《要你命 3000》这个单词书先背个 5 遍。不然上考场肯定一脸懵,基本全靠蒙了。这本书背 5 遍以下是裸泳,10 遍是比基尼,15-20 遍才是上岸。

下一个五年计划起航 !

数学也有少量要背的单词。笔者背的是考满分 GRE 单词 app。数学必备只有 360 词。背完这些完全够用了。笔者把它们 GRE 的单词也刷了一遍。正序,逆序都刷了。app 上刷单词的好处是走到哪里都可以刷,比书本方便很多。等公交车,排队等地铁,中午吃饭排队等等碎片时间,只要拿出手机就可以刷几遍。删掉娱乐 app 和游戏 app 以后,刷单词的时间真的非常多。

下一个五年计划起航 !

最后,记单词这个体力+脑力活,需要每天都接受折磨。如果中断一两天,可能需要一周才能捡起来之前连续累积的记忆力。

下一个五年计划起航 !

关于 GRE 备考。作为过来人,我可以说几句关于心态上的鼓励的话,我也是这样走过来的。相信后来人也有可能遇到相同的问题。

备考 GRE 也是非常痛苦的一件事情。GREer 的苦就是你付出之后,你可能在一个为止的时间段当中,你看不到回报。但是成功和失败之间就是你是否能够挺过这段路。无数留学申请的孩子,都走过了这段路。很多人可能复习了好几个月,上考场之后还是发现没有一点进步,心态濒临崩溃,这些都是正常的。这个时候仿佛置身在一个黑暗的隧道中,没有一点光亮,非常委屈,非常孤独,甚至绝望。有时候还想放弃。但是请你记住,这段路跪着走完,它会带给你人生中最为宝贵的财富,这笔财富称之为,成长。这段路当你真正走到尽头的时候,走出黑暗的隧道,当阳光洒到你脸上的时候,你的嘴角才能扬起留学考试胜利,才有的那份骄傲。那一刻你会对自己说,留学无悔,青春不朽!

至于笔者留学申请的结果,今年的年终总结里面无法告知,明年 4,5 月才会出结果。明年的年终总结就总结一下整个留学季申请的种种吧。

最后

笔者的职业生涯的第一个 5 年结束了,全部是在上海奋斗的时光。按照我的规划,未来的 5 年是在全球奋斗的时光。将会解锁全球的“工作与学习的地方”。关于第二个 5 年规划,是继续读 PhD 呢?还是工作以后再申一个 MBA 呢?清华大学苏世民学院,UIUC MBA 项目等等看上去都是不错的选择。也有可能抵挡不住金钱的诱惑,先致富,财富自由以后继续旅居全球。笔者先不立 flag 了,还是等实现了以后再公布。

最后,老规矩,依旧是一些“只言片语”的感受分享一下作为年终总结的结尾吧。

  1. 生活,本就是一场渡劫,过程难免窘迫狼狈,但作为一个成年人,不管身上流血流汗,只要不下场,哪怕遇到大灾大难,未来一切皆有可能。
  2. 懒等于穷,就是这么直接。20 不勤,30 不立,40 不富,50 而衰靠子助。父母给的叫背景,自己打的叫江山,不要假装很努力,结果不会陪你一起演戏。
  3. 人生有两种苦,一种苦是自律的苦,一种苦是后悔的苦。自律的苦轻如鸿毛,后悔的苦重如泰山。
  4. 人与人之间的竞争表面上是车子房子面子。核心是能力资源人脉!本质是认知思维价值人品!
  5. 不要着急,一定不要放弃!繁花锦簇,硕果累累,需要过程。

好了,2020 年的【星霜荏苒】就到这里了。如有任何异议或者想讨论的地方,欢迎和我交流。

下一个五年计划起航 !

2020 年 7 月 1 日,于武汉 Wuhan。借用小福君的一幅插画,祝福全世界的人们都能富贵永驻,金刚护体,百毒不侵,天下太平。


GitHub Repo:Halfrost-Field

Follow: halfrost · GitHub

Source: https://halfrost.com/halfrost_2020/

面试中 LRU / LFU 的青铜与王者

面试中 LRU / LFU 的青铜与王者

已经 0202 年了,大厂面试手撸算法题已经是标配。第一轮就遇到手撸 LRU / LFU 的几率还是挺大的。LeetCode 上146. LRU Cache460. LFU Cache,LRU 是 Medium 难度,LFU 是 Hard 难度,面试官眼里认为这 2 个问题是最最最基础的。这篇文章就来聊聊面试中 LRU / LFU 的青铜与王者。

缓存淘汰算法不仅仅只有 LRU / LFU 这两种,还有很多种,TLRU (Time aware least recently used),PLRU (Pseudo-LRU),SLRU (Segmented LRU),LFRU (Least frequent recently used),LFUDA (LFU with dynamic aging),LIRS (Low inter-reference recency set),ARC (Adaptive Replacement Cache),FIFO (First In First Out),MRU (Most recently used),LIFO (Last in first out),FILO (First in last out),CAR (Clock with adaptive replacement) 等等。感兴趣的同学可以把这每一种都用代码实现一遍。

倔强青铜

面试官可能就直接拿出 LeetCode 上这 2 道题让你来做的。在笔者拿出标准答案之前,先简单介绍一下 LRU 和 LFU 的概念。

面试中 LRU / LFU 的青铜与王者

LRU 是 Least Recently Used 的缩写,即最近最少使用,是一种常用的页面置换算法,选择最近最久未使用的页面予以淘汰。如上图,要插入 F 的时候,此时需要淘汰掉原来的一个页面。

面试中 LRU / LFU 的青铜与王者

根据 LRU 的策略,每次都淘汰最近最久未使用的页面,所以先淘汰 A 页面。再插入 C 的时候,发现缓存中有 C 页面,这个时候需要把 C 页面放到首位,因为它被使用了。以此类推,插入 G 页面,G 页面是新页面,不在缓存中,所以淘汰掉 B 页面。插入 H 页面,H 页面是新页面,不在缓存中,所以淘汰掉 D 页面。插入 E 的时候,发现缓存中有 E 页面,这个时候需要把 E 页面放到首位。插入 I 页面,I 页面是新页面,不在缓存中,所以淘汰掉 F 页面。

可以发现,LRU 更新和插入新页面都发生在链表首,删除页面都发生在链表尾

LRU 要求查询尽量高效,O(1) 内查询。那肯定选用 map 查询。修改,删除也要尽量 O(1) 完成。搜寻常见的数据结构,链表,栈,队列,树,图。树和图排除,栈和队列无法任意查询中间的元素,也排除。所以选用链表来实现。但是如果选用单链表,删除这个结点,需要 O(n) 遍历一遍找到前驱结点。所以选用双向链表,在删除的时候也能 O(1) 完成。

由于 Go 的 container 包中的 list 底层实现是双向链表,所以可以直接复用这个数据结构。定义 LRUCache 的数据结构如下:

import "container/list"

type LRUCache struct {
    Cap  int
    Keys map[int]*list.Element
    List *list.List
}

type pair struct {
    K, V int
}

func Constructor(capacity int) LRUCache {
    return LRUCache{
        Cap: capacity,
        Keys: make(map[int]*list.Element),
        List: list.New(),
    }
}

这里需要解释 2 个问题,list 中的值存的是什么?pair 这个结构体有什么用?

type Element struct {
	// Next and previous pointers in the doubly-linked list of elements.
	// To simplify the implementation, internally a list l is implemented
	// as a ring, such that &l.root is both the next element of the last
	// list element (l.Back()) and the previous element of the first list
	// element (l.Front()).
	next, prev *Element

	// The list to which this element belongs.
	list *List

	// The value stored with this element.
	Value interface{}
}

在 container/list 中,这个双向链表的每个结点的类型是 Element。Element 中存了 4 个值,前驱和后继结点,双向链表的头结点,value 值。这里的 value 是 interface 类型。笔者在这个 value 里面存了 pair 这个结构体。这就解释了 list 里面存的是什么数据。

为什么要存 pair 呢?单单指存 v 不行么,为什么还要存一份 key ?原因是在 LRUCache 执行删除操作的时候,需要维护 2 个数据结构,一个是 map,一个是双向链表。在双向链表中删除淘汰出去的 value,在 map 中删除淘汰出去 value 对应的 key。如果在双向链表的 value 中不存储 key,那么再删除 map 中的 key 的时候有点麻烦。如果硬要实现,需要先获取到双向链表这个结点 Element 的地址。然后遍历 map,在 map 中找到存有这个 Element 元素地址对应的 key,再删除。这样做时间复杂度是 O(n),做不到 O(1)。所以双向链表中的 Value 需要存储这个 pair。

LRUCache 的 Get 操作很简单,在 map 中直接读取双向链表的结点。如果 map 中存在,将它移动到双向链表的表头,并返回它的 value 值,如果 map 中不存在,返回 -1。

func (c *LRUCache) Get(key int) int {
	if el, ok := c.Keys[key]; ok {
		c.List.MoveToFront(el)
		return el.Value.(pair).V
	}
	return -1
}

LRUCache 的 Put 操作也不难。先查询 map 中是否存在 key,如果存在,更新它的 value,并且把该结点移到双向链表的表头。如果 map 中不存在,新建这个结点加入到双向链表和 map 中。最后别忘记还需要维护双向链表的 cap,如果超过 cap,需要淘汰最后一个结点,双向链表中删除这个结点,map 中删掉这个结点对应的 key。

func (c *LRUCache) Put(key int, value int) {
	if el, ok := c.Keys[key]; ok {
		el.Value = pair{K: key, V: value}
		c.List.MoveToFront(el)
	} else {
		el := c.List.PushFront(pair{K: key, V: value})
		c.Keys[key] = el
	}
	if c.List.Len() > c.Cap {
		el := c.List.Back()
		c.List.Remove(el)
		delete(c.Keys, el.Value.(pair).K)
	}
}

总结,LRU 是由一个 map 和一个双向链表组成的数据结构。map 中 key 对应的 value 是双向链表的结点。双向链表中存储 key-value 的 pair。双向链表表首更新缓存,表尾淘汰缓存。如下图:

面试中 LRU / LFU 的青铜与王者

提交代码以后,成功通过所有测试用例。

面试中 LRU / LFU 的青铜与王者

LFU 是 Least Frequently Used 的缩写,即最不经常最少使用,也是一种常用的页面置换算法,选择访问计数器最小的页面予以淘汰。如下图,缓存中每个页面带一个访问计数器。

面试中 LRU / LFU 的青铜与王者

根据 LFU 的策略,每访问一次都要更新访问计数器。当插入 B 的时候,发现缓存中有 B,所以增加访问计数器的计数,并把 B 移动到访问计数器从大到小排序的地方。再插入 D,同理先更新计数器,再移动到它排序以后的位置。当插入 F 的时候,缓存中不存在 F,所以淘汰计数器最小的页面的页面,所以淘汰 A 页面。此时 F 排在最下面,计数为 1。

面试中 LRU / LFU 的青铜与王者

这里有一个比 LRU 特别的地方。如果淘汰的页面访问次数有多个相同的访问次数,选择最靠尾部的。如上图中,A、B、C 三者的访问次数相同,都是 1 次。要插入 F,F 不在缓存中,此时要淘汰 A 页面。F 是新插入的页面,访问次数为 1,排在 C 的前面。也就是说相同的访问次数,按照新旧顺序排列,淘汰掉最旧的页面。这一点是和 LRU 最大的不同的地方。

可以发现,LFU 更新和插入新页面可以发生在链表中任意位置,删除页面都发生在表尾

LFU 同样要求查询尽量高效,O(1) 内查询。依旧选用 map 查询。修改和删除也需要 O(1) 完成,依旧选用双向链表,继续复用 container 包中的 list 数据结构。LFU 需要记录访问次数,所以每个结点除了存储 key,value,需要再多存储 frequency 访问次数。

还有 1 个问题需要考虑,一个是如何按频次排序?相同频次,按照先后顺序排序。如果你开始考虑排序算法的话,思考方向就偏离最佳答案了。排序至少 O(nlogn)。重新回看 LFU 的工作原理,会发现它只关心最小频次。其他频次之间的顺序并不关心。所以不需要排序。用一个 min 变量保存最小频次,淘汰时读取这个最小值能找到要删除的结点。相同频次按照先后顺序排列,这个需求还是用双向链表实现,双向链表插入的顺序体现了结点的先后顺序。相同频次对应一个双向链表,可能有多个相同频次,所以可能有多个双向链表。用一个 map 维护访问频次和双向链表的对应关系。删除最小频次时,通过 min 找到最小频次,然后再这个 map 中找到这个频次对应的双向链表,在双向链表中找到最旧的那个结点删除。这就解决了 LFU 删除操作。

LFU 的更新操作和 LRU 类似,也需要用一个 map 保存 key 和双向链表结点的映射关系。这个双向链表结点中存储的是 key-value-frequency 三个元素的元组。这样通过结点中的 key 和 frequency 可以反过来删除 map 中的 key。

定义 LFUCache 的数据结构如下:


import "container/list"

type LFUCache struct {
	nodes    map[int]*list.Element
	lists    map[int]*list.List
	capacity int
	min      int
}

type node struct {
	key       int
	value     int
	frequency int
}

func Constructor(capacity int) LFUCache {
	return LFUCache{nodes: make(map[int]*list.Element),
		lists:    make(map[int]*list.List),
		capacity: capacity,
		min:      0,
	}
}

LFUCache 的 Get 操作涉及更新 frequency 值和 2 个 map。在 nodes map 中通过 key 获取到结点信息。在 lists 删除结点当前 frequency 结点。删完以后 frequency ++。新的 frequency 如果在 lists 中存在,添加到双向链表表首,如果不存在,需要新建一个双向链表并把当前结点加到表首。再更新双向链表结点作为 value 的 map。最后更新 min 值,判断老的 frequency 对应的双向链表中是否已经为空,如果空了,min++。

func (this *LFUCache) Get(key int) int {
	value, ok := this.nodes[key]
	if !ok {
		return -1
	}
	currentNode := value.Value.(*node)
	this.lists[currentNode.frequency].Remove(value)
	currentNode.frequency++
	if _, ok := this.lists[currentNode.frequency]; !ok {
		this.lists[currentNode.frequency] = list.New()
	}
	newList := this.lists[currentNode.frequency]
	newNode := newList.PushFront(currentNode)
	this.nodes[key] = newNode
	if currentNode.frequency-1 == this.min && this.lists[currentNode.frequency-1].Len() == 0 {
		this.min++
	}
	return currentNode.value
}

LFU 的 Put 操作逻辑稍微多一点。先在 nodes map 中查询 key 是否存在,如果存在,获取这个结点,更新它的 value 值,然后手动调用一次 Get 操作,因为下面的更新逻辑和 Get 操作一致。如果 map 中不存在,接下来进行插入或者删除操作。判断 capacity 是否装满,如果装满,执行删除操作。在 min 对应的双向链表中删除表尾的结点,对应的也要删除 nodes map 中的键值。

由于新插入的页面访问次数一定为 1,所以 min 此时置为 1。新建结点,插入到 2 个 map 中。


func (this *LFUCache) Put(key int, value int) {
	if this.capacity == 0 {
		return
	}
	// 如果存在,更新访问次数
	if currentValue, ok := this.nodes[key]; ok {
		currentNode := currentValue.Value.(*node)
		currentNode.value = value
		this.Get(key)
		return
	}
	// 如果不存在且缓存满了,需要删除
	if this.capacity == len(this.nodes) {
		currentList := this.lists[this.min]
		backNode := currentList.Back()
		delete(this.nodes, backNode.Value.(*node).key)
		currentList.Remove(backNode)
	}
	// 新建结点,插入到 2 个 map 中
	this.min = 1
	currentNode := &node{
		key:       key,
		value:     value,
		frequency: 1,
	}
	if _, ok := this.lists[1]; !ok {
		this.lists[1] = list.New()
	}
	newList := this.lists[1]
	newNode := newList.PushFront(currentNode)
	this.nodes[key] = newNode
}

总结,LFU 是由两个 map 和一个 min 指针组成的数据结构。一个 map 中 key 存的是访问次数,对应的 value 是一个个的双向链表,此处双向链表的作用是在相同频次的情况下,淘汰表尾最旧的那个页面。另一个 map 中 key 对应的 value 是双向链表的结点,结点中比 LRU 多存储了一个访问次数的值,即结点中存储 key-value-frequency 的元组。此处双向链表的作用和 LRU 是类似的,可以根据 map 中的 key 更新双向链表结点中的 value 和 frequency 的值,也可以根据双向链表结点中的 key 和 frequency 反向更新 map 中的对应关系。如下图:

面试中 LRU / LFU 的青铜与王者

提交代码以后,成功通过所有测试用例。

面试中 LRU / LFU 的青铜与王者

荣耀黄金

面试中如果给出了上面青铜的答案,可能会被追问,“还有没有其他解法?” 虽然目前青铜的答案已经是最优解了,但是面试官还想考察多解。

先考虑 LRU。数据结构上想不到其他解法了,但从打败的百分比上,看似还有常数的优化空间。笔者反复思考,觉得可能导致运行时间变长的地方是在 interface{} 类型推断,其他地方已无优化的空间。手写一个双向链表提交试试,代码如下:


type LRUCache struct {
	head, tail *Node
	keys       map[int]*Node
	capacity   int
}

type Node struct {
	key, val   int
	prev, next *Node
}

func ConstructorLRU(capacity int) LRUCache {
	return LRUCache{keys: make(map[int]*Node), capacity: capacity}
}

func (this *LRUCache) Get(key int) int {
	if node, ok := this.keys[key]; ok {
		this.Remove(node)
		this.Add(node)
		return node.val
	}
	return -1
}

func (this *LRUCache) Put(key int, value int) {
	if node, ok := this.keys[key]; ok {
		node.val = value
		this.Remove(node)
		this.Add(node)
		return
	} else {
		node = &Node{key: key, val: value}
		this.keys[key] = node
		this.Add(node)
	}
	if len(this.keys) > this.capacity {
		delete(this.keys, this.tail.key)
		this.Remove(this.tail)
	}
}

func (this *LRUCache) Add(node *Node) {
	node.prev = nil
	node.next = this.head
	if this.head != nil {
		this.head.prev = node
	}
	this.head = node
	if this.tail == nil {
		this.tail = node
		this.tail.next = nil
	}
}

func (this *LRUCache) Remove(node *Node) {
	if node == this.head {
		this.head = node.next
		if node.next != nil {
			node.next.prev = nil
		}
		node.next = nil
		return
	}
	if node == this.tail {
		this.tail = node.prev
		node.prev.next = nil
		node.prev = nil
		return
	}
	node.prev.next = node.next
	node.next.prev = node.prev
}

提交以后还真的 100% 了。

面试中 LRU / LFU 的青铜与王者

上述代码实现的 LRU 本质并没有优化,只是换了一个写法,没有用 container 包而已。

LFU 的另外一个思路是利用 Index Priority Queue 这个数据结构。别被名字吓到,Index Priority Queue = map + Priority Queue,仅此而已。

利用 Priority Queue 维护一个最小堆,堆顶是访问次数最小的元素。map 中的 value 存储的是优先队列中结点。

import "container/heap"

type LFUCache struct {
	capacity int
	pq       PriorityQueue
	hash     map[int]*Item
	counter  int
}

func Constructor(capacity int) LFUCache {
	lfu := LFUCache{
		pq:       PriorityQueue{},
		hash:     make(map[int]*Item, capacity),
		capacity: capacity,
	}
	return lfu
}

Get 和 Put 操作要尽量的快,有 2 个问题需要解决。当访问次数相同时,如何删除掉最久的元素?当元素的访问次数发生变化时,如何快速调整堆?为了解决这 2 个问题,定义如下的数据结构:

// An Item is something we manage in a priority queue.
type Item struct {
	value     int // The value of the item; arbitrary.
	key       int
	frequency int // The priority of the item in the queue.
	count     int // use for evicting the oldest element
	// The index is needed by update and is maintained by the heap.Interface methods.
	index int // The index of the item in the heap.
}

堆中的结点存储这 5 个值。count 值用来决定哪个是最老的元素,类似一个操作时间戳。index 值用来 re-heapify 调整堆的。接下来实现 PriorityQueue 的方法。

// A PriorityQueue implements heap.Interface and holds Items.
type PriorityQueue []*Item

func (pq PriorityQueue) Len() int { return len(pq) }

func (pq PriorityQueue) Less(i, j int) bool {
	// We want Pop to give us the highest, not lowest, priority so we use greater than here.
	if pq[i].frequency == pq[j].frequency {
		return pq[i].count < pq[j].count
	}
	return pq[i].frequency < pq[j].frequency
}

func (pq PriorityQueue) Swap(i, j int) {
	pq[i], pq[j] = pq[j], pq[i]
	pq[i].index = i
	pq[j].index = j
}

func (pq *PriorityQueue) Push(x interface{}) {
	n := len(*pq)
	item := x.(*Item)
	item.index = n
	*pq = append(*pq, item)
}

func (pq *PriorityQueue) Pop() interface{} {
	old := *pq
	n := len(old)
	item := old[n-1]
	old[n-1] = nil  // avoid memory leak
	item.index = -1 // for safety
	*pq = old[0 : n-1]
	return item
}

// update modifies the priority and value of an Item in the queue.
func (pq *PriorityQueue) update(item *Item, value int, frequency int, count int) {
	item.value = value
	item.count = count
	item.frequency = frequency
	heap.Fix(pq, item.index)
}

在 Less() 方法中,frequency 从小到大排序,frequency 相同的,按 count 从小到大排序。按照优先队列建堆规则,可以得到,frequency 最小的在堆顶,相同的 frequency,count 最小的越靠近堆顶。

在 Swap() 方法中,记得要更新 index 值。在 Push() 方法中,插入时队列的长度即是该元素的 index 值,此处也要记得更新 index 值。update() 方法调用 Fix() 函数。Fix() 函数比先 Remove() 再 Push() 一个新的值,花销要小。所以此处调用 Fix() 函数,这个操作的时间复杂度是 O(log n)。

这样就维护了最小 Index Priority Queue。Get 操作非常简单:

func (this *LFUCache) Get(key int) int {
	if this.capacity == 0 {
		return -1
	}
	if item, ok := this.hash[key]; ok {
		this.counter++
		this.pq.update(item, item.value, item.frequency+1, this.counter)
		return item.value
	}
	return -1
}

在 hashmap 中查询 key,如果存在,counter 时间戳累加,调用 Priority Queue 的 update 方法,调整堆。

func (this *LFUCache) Put(key int, value int) {
	if this.capacity == 0 {
		return
	}
	this.counter++
	// 如果存在,增加 frequency,再调整堆
	if item, ok := this.hash[key]; ok {
		this.pq.update(item, value, item.frequency+1, this.counter)
		return
	}
	// 如果不存在且缓存满了,需要删除。在 hashmap 和 pq 中删除。
	if len(this.pq) == this.capacity {
		item := heap.Pop(&this.pq).(*Item)
		delete(this.hash, item.key)
	}
	// 新建结点,在 hashmap 和 pq 中添加。
	item := &Item{
		value: value,
		key:   key,
		count: this.counter,
	}
	heap.Push(&this.pq, item)
	this.hash[key] = item
}

用最小堆实现的 LFU,Put 时间复杂度是 O(capacity),Get 时间复杂度是 O(capacity),不及 2 个 map 实现的版本。巧的是最小堆的版本居然打败了 100%。

面试中 LRU / LFU 的青铜与王者

提交以后,LRU 和 LFU 都打败了 100%。上述代码都封装好了,完整代码在 LeetCode-Go 中,讲解也更新到了 《LeetCode Cookbook》第三章的第三节 LRUCache第四节 LFUCache中。LRU 的最优解是 map + 双向链表,LFU 的最优解是 2 个 map + 多个双向链表。其实热身刚刚结束,接下来才是本文的重点

最强王者

在面试者回答出黄金级的问题了以后,面试官可能会继续追问一个更高级的问题。“如何实现一个高并发且线程安全的 LRU 呢?”。遇到这个问题,上文讨论的代码模板就失效了。要想做到高并发,需要考虑 2 点,第一点内存分配与回收 GC 一定要快,最好是 Zero GC 开销,第二点执行操作耗时最少。详细的,由于要做到高并发,瞬间的 TPS 可能会很大,所以要最快的分配内存,开辟新的内存空间。垃圾回收也不能慢,否则内存会暴涨。针对 LRU / LFU 这个问题,执行的操作是 get 和 set,耗时需要最少。耗时高了,系统吞吐率会受到严重影响,TPS 上不去了。再者,在高并发的场景中,一定会保证线程安全。这里就需要用到锁。最简单的选用读写锁。以下举例以 LRUCache 为例。LFUCache 原理类似。(以下代码先给出改造新增的部分,最后再给出完整版)

type LRUCache struct {
    sync.RWMutex
}

func (c *LRUCache) Get(key int) int {
	c.RLock()
	defer c.RUnlock()
	
	……
}

func (c *LRUCache) Put(key int, value int) {
	c.Lock()
  	defer c.Unlock()
  	
	……
}

上述代码虽然能保证线程安全,但是并发量并不高。因为在 Put 操作中,写锁会阻碍读锁,这里会锁住。接下来的优化思路很清晰,拆分大锁,让写锁尽可能的少阻碍读锁。一句话就是将锁颗粒化。

面试中 LRU / LFU 的青铜与王者

如上图,将一个大的临界区拆分成一个个小的临界区。代码如下:


type LRUCache struct {
    sync.RWMutex
    shards map[int]*LRUCacheShard
}

type LRUCacheShard struct {
  	Cap  int
	Keys map[int]*list.Element
	List *list.List
	sync.RWMutex
}

func (c *LRUCache) Get(key int) int {
	shard, ok := c.GetShard(key, false)
	if ok == false {
		return -1
	}
	shard.RLock()
	defer shard.RUnlock()
	
	……
}

func (c *LRUCache) Put(key int, value int) {
  	shard, _ := c.GetShard(key, true)
	shard.Lock()
	defer shard.Unlock()
	
	……
}

func (c *LRUCache) GetShard(key int, create bool) (shard *LRUCacheShard, ok bool) {
	hasher := sha1.New()
	hasher.Write([]byte(key))
	shardKey := fmt.Sprintf("%x", hasher.Sum(nil))[0:2]

	c.lock.RLock()
	shard, ok = c.shards[shardKey]
	c.lock.RUnlock()

	if ok || !create {
		return
	}

	//only time we need to write lock
	c.lock.Lock()
	defer c.lock.Unlock()
	//check again in case the group was created in this short time
	shard, ok = c.shards[shardKey]
	if ok {
		return
	}

	shard = &LRUCacheShard{
		Keys: make(map[int]*list.Element),
		List: list.New(),
	}
	c.shards[shardKey] = shard
	ok = true
	return
}

通过上述的改造,利用哈希把原来的 LRUCache 分为了 256 个分片(2^8)。并且写锁锁住只发生在分片不存在的时候。一旦分片被创建了,之后都是读锁。这里依旧是小瓶颈,继续优化,消除掉这里的写锁。优化代码很简单,在创建的时候创建所有分片。


func New(capacity int) LRUCache {
	shards := make(map[string]*LRUCacheShard, 256)
	for i := 0; i < 256; i++ {
		shards[fmt.Sprintf("%02x", i)] = &LRUCacheShard{
			Cap:  capacity,
			Keys: make(map[int]*list.Element),
			List: list.New(),
		}
	}
	return LRUCache{
		shards: shards,
	}
}

func (c *LRUCache) Get(key int) int {
	shard := c.GetShard(key)
	shard.RLock()
	defer shard.RUnlock()
	
	……
}

func (c *LRUCache) Put(key int, value int) {
  	shard := c.GetShard(key)
	shard.Lock()
	defer shard.Unlock()
	
	……
}

func (c *LRUCache) GetShard(key int) (shard *LRUCacheShard) {
  hasher := sha1.New()
  hasher.Write([]byte(key))
  shardKey :=  fmt.Sprintf("%x", hasher.Sum(nil))[0:2]
  return c.shards[shardKey]
}

到这里,大的临界区已经被拆分成细颗粒度了。在细粒度的锁内部,还包含双链表结点的操作,对结点的操作涉及到锁竞争。成熟的缓存系统如 memcached,使用的是全局的 LRU 链表锁,而 Redis 是单线程的所以不需要考虑并发的问题。回到 LRU,每个 Get 操作需要读取 key 值对应的 value,需要读锁。与此同时,Get 操作也涉及到移动最近最常使用的结点,需要写锁。Set 操作只涉及写锁。需要注意的一点,Get 和 Set 先后执行顺序非常关键。例如,先 get 一个不存在的 key,返回 nil,再 set 这个 key。如果先 set 这个 key,再 get 这个key,返回的就是不是 nil,而是对应的 value。所以在保证锁安全(不发生死锁)的情况下,还需要保证每个操作时序的正确性。能同时满足这 2 个条件的非带缓冲的 channel 莫属。先来看看消费 channel 通道里面数据的处理逻辑:

func (c *CLRUCache) doMove(el *list.Element) bool {
	if el.Value.(Pair).cmd == MoveToFront {
		c.list.MoveToFront(el)
		return false
	}
	newel := c.list.PushFront(el.Value.(Pair))
	c.bucket(el.Value.(Pair).key).update(el.Value.(Pair).key, newel)
	return true
}

还值得一提的是,get 和 set 的写操作有 2 种类型,一种是 MoveToFront,另外一种是当结点不存在的时候,需要先创建一个新的结点,并移动到头部。这个操作即 PushFront。笔者这里在结点中加入了 cmd 标识,默认值是 MoveToFront。

面试中 LRU / LFU 的青铜与王者

目前为止,下一步的优化思路确定使用带缓冲的 channel 了。用几个呢?答案是用 2 个。除去上面讨论的写入操作,还要管理 remove 操作。由于 LRU 逻辑的特殊性,它保证了移动结点和移除结点一定分开在双链表两端。也就是说在双链表两边同时操作,相互不影响。双链表的临界区范围可以进一步的缩小,可以缩小到结点级。最终方案就定下来了。用 2 个带缓冲的 channel,分别处理移动结点和删除结点,这两个 channel 可以在同一个协程中一起处理,互不影响。

func (c *CLRUCache) worker() {
	defer close(c.control)
	for {
		select {
		case el, ok := <-c.movePairs:
			if ok == false {
				goto clean
			}
			if c.doMove(el) && c.list.Len() > c.cap {
				el := c.list.Back()
				c.list.Remove(el)
				c.bucket(el.Value.(Pair).key).delete(el.Value.(Pair).key)
			}
		case el := <-c.deletePairs:
			c.list.Remove(el)
		case control := <-c.control:
			switch msg := control.(type) {
			case clear:
				for _, bucket := range c.buckets {
					bucket.clear()
				}
				c.list = list.New()
				msg.done <- struct{}{}
			}
		}
	}
clean:
	for {
		select {
		case el := <-c.deletePairs:
			c.list.Remove(el)
		default:
			close(c.deletePairs)
			return
		}
	}
}

最终完整的代码放在这里了。最后简单的跑一下 Benchmark 看看性能如何。

以下性能测试部分是面试结束后,笔者测试的。面试时写完代码,并没有当场 Benchmark。

go test -bench BenchmarkGetAndPut1 -run none -benchmem -cpuprofile cpuprofile.out -memprofile memprofile.out -cpu=8goos: darwin
goarch: amd64
pkg: github.com/halfrost/LeetCode-Go/template
BenchmarkGetAndPut1-8            368578              2474 ns/op             530 B/op         14 allocs/op
PASS
ok      github.com/halfrost/LeetCode-Go/template        1.022s

BenchmarkGetAndPut2 只是简单的全局加锁,会有死锁的情况。可以看到方案一的性能还行,368578 次循环平均出来的结果,平均一次 Get/Set 需要 2474 ns,那么 TPS 大约是 300K/s,可以满足一般高并发的需求。

最后看看这个版本下的 CPU 消耗情况,符合预期:

面试中 LRU / LFU 的青铜与王者

内存分配情况,也符合预期:

面试中 LRU / LFU 的青铜与王者

至此,你已经是王者了。

荣耀王者

这里是附加题部分。面试官问到这里就和 LRU/LFU 直接关系不大了,更多的考察的是如何设计一个高并发的 Cache。笔者之所以在这篇文章最后提一笔,是想给读者扩展思维。面试官会针对你给出的高并发版的 LRU 继续问,“你觉得你写的这个版本缺点在哪里?和真正的 Cache 比,还有哪些欠缺?”

在上一节“最强王者”中,粗略的实现了一个高并发的 LRU。但是这个方案还不是最完美的。当高并发高到一个临界值的时候,即 Get 请求的速度达到 Go 内存回收速度的几百倍,几万倍的时候。bucket 分片被清空,试图访问该分片中的 key 的 goroutine 开始分配内存,而先前的内存仍未完全释放,从而导致内存使用量激增和 OOM 崩溃。所以这种方法的性能不能随内核数量很好地扩展。

另外这种粗略的方式是以缓存数目作为 Cap 的,没有考虑每个 value 的大小。以缓存数目作为基准,是没法限制住内存大小的。如果高负载的业务,设置大的 Cap,极端的讲,每个 value 都非常大,几十个 MB,整体内存消耗可能上百 GB。如果是低负载的业务,设置很小的 Cap,极端情况,每个 value 特别小。总内存大小可能在 1KB。这样看,内存上限和下限浮动太大了,无法折中限制。

欠缺的分为 2 部分,一部分是功能性,一部分是性能。功能性方面欠缺 TTL,持久化。TTL 是过期时间,到时间需要删除 key。持久化是将缓存中的数据保存至文件中,或者启动的时候从文件中读取。

性能方面欠缺的是高效的 hash 算法,高命中率,内存限制,可伸缩性。

高效的 hash 算法指的是类似 AES Hash,针对 CPU 是否支持 AES 指令集进行了判断,当 CPU 支持 AES 指令集的时候,它会选用 AES Hash 算法。一些高效的 hash 算法用汇编语言实现的。

高命中率方面,可以参考 BP-Wrapper: A System Framework Making Any
Replacement Algorithms (Almost) Lock Contention Free
这篇论文,在这篇论文里面提出了 2 种方式:prefetching 和 batching。简单说一下 batching 的方式。在等待临界区之前,先填满 ring buffer。如该论文所述,借用 ring buffer 这种方式,几乎没有开销,从而大大降低了竞争。实现 ring buffer 可以考虑使用 sync.Pool 而不是其他的数据结构(切片,带区互斥锁等),原因是性能优势主要是由于线程本地存储的内部使用,而其他的数据结构没有这相关的 API。

内存限制。无限大的缓存实际上是不可能的。高速缓存必须有大小限制。如何制定一套高效的淘汰的策略就变的很关键。LRU 这个淘汰策略好么?针对不同的使用场景,LRU 并不是最好的,有些场景下 LFU 更加适合。这里有一篇论文 TinyLFU: A Highly Efficient Cache Admission Policy,这篇论文中讨论了一种高效缓存准入策略。TinyLFU 是一种与淘汰无关的准入策略,目的是在以很少的内存开销来提高命中率。主要思想是仅在新的 key 的估计值高于正要被逐出的 key 的估计值时才允许进入 Cache。当缓存达到容量时,每个新的 key 都应替换缓存中存在的一个或多个密钥。并且,传入 key 的估值应该比被淘汰出去的 key 估值高。否则新的 key 禁止进入缓存中。这样做也为了保证高命中率。

面试中 LRU / LFU 的青铜与王者

在将新 key 放入 TinyLFU 中之前,还可以使用 bloom 过滤器首先检查该密钥是否之前已被查看过。仅当 key 在布隆过滤器中已经存在时,才将其插入 TinyLFU。这是为了避免长时间不被看到的长尾键污染 TinyLFU。

面试中 LRU / LFU 的青铜与王者

关于到底选择 LRU 还是 LFU 还是 LRU + LFU ,这个话题比较大,展开讨论又可以写好几篇新文章了。感兴趣的读者可以看看这篇论文,Adaptive Software Cache Management ,从标题上看,自适应的软件缓存管理,就能看出它在探讨了这个问题。论文的基本思想是在主缓存段之前放置一个 LRU “窗口”,并使用爬山技术自适应地调整窗口大小以最大化命中率。A high performance caching library for Java 8 — Caffeine 已经取得了很好的效果。

面试中 LRU / LFU 的青铜与王者

可伸缩性方面,选择合适的缓存大小,可以避免 False Sharing,在多核系统中,其中不同的原子计数器(每个8字节)位于同一高速缓存行(通常为64字节)中。对这些计数器之一进行的任何更新都会导致其他计数器被标记为无效。这将强制为拥有该高速缓存的所有其他核心重新加载高速缓存,从而在高速缓存行上创建写争用。为了实现可伸缩性,应该确保每个原子计数器完全占用完整的缓存行。因此,每个内核都在不同的缓存行上工作。

面试中 LRU / LFU 的青铜与王者

最后看看 Go 实现的几个开源 Cache 库。关于这些 Cache 的源码分析,本篇文章就不展开了。(有时间可能会单独再开一篇文章详解)。感兴趣的读者可以自己查阅源码。

bigcache,BigCache 根据 key 的哈希将数据分为 shards。每个分片都包含一个映射和一个 ring buffer。每当设置新元素时,它都会将该元素追加到相应分片的 ring buffer 中,并且缓冲区中的偏移量将存储在 map 中。如果同一元素被 Set 多次,则缓冲区中的先前条目将标记为无效。如果缓冲区太小,则将其扩展直到达到最大容量。每个 map 中的 key 都是一个 uint32 hash,其值是一个 uint32 指针,指向该值与元数据信息一起存储的缓冲区中的偏移量。如果存在哈希冲突,则 BigCache 会忽略前一个键并将当前键存储到映射中。预先分配较少,较大的缓冲区并使用 map[uint32]uint32 是避免承担 GC 扫描成本的好方法。

freecache,FreeCache 通过减少指针数量避免了 GC 开销。 无论其中存储了多少条目,都只有 512 个指针。通过 key 的哈希值将数据集分割为 256 个段。将新 key 添加到高速缓存时,将使用 key 哈希值的低八位来标识段 ID。每个段只有两个指针,一个是存储 key 和 value 的 ring buffer,另一个是用于查找条目的索引 slice。数据附加到 ring buffer 中,偏移量存储到排序 slice 中。如果 ring buffer 没有足够的空间,则使用修改后的 LRU 策略从 ring buffer 的开头开始,在该段中淘汰 key。如果条目的最后访问时间小于段的平均访问时间,则从 ring buffer 中删除该条目。要在 Get 的高速缓存中查找条目,请在相应插槽 slot 中的排序数组中执行二进制搜索。此外还有一个加速的优化,使用 key 的哈希的 LSB 9-16 选择一个插槽 slot。将数据划分为多个插槽 slot 有助于减少在缓存中查找键时的搜索空间。每个段都有自己的锁,因此它支持高并发访问。

groupCache,groupcache 是​​一个分布式的缓存和缓存填充库,在许多情况下可以替代 memcached。在许多情况下甚至可以用来替代内存缓存节点池。groupcache 实现原理和本文在上一章节中实现的方式是一摸一样的。

fastcache,fastcache 并没有缓存过期的概念。仅在高速缓存大小溢出时才从高速缓存中淘汰 key 值。key 的截止期限可以存储在该值内,以实现缓存过期。fastcache 缓存由许多 buckets 组成,每个 buckets 都有自己的锁。这有助于扩展多核 CPU 的性能,因为多个 CPU 可以同时访问不同的 buckets。每个 buckets 均由一个 hash(key)->(key,value)的映射和 64KB 大小的字节 slice(块)组成,这些字节 slice 存储已编码的(key,value)。每个 buckets 仅包含 chunksCount 个指针。例如,64GB 缓存将包含大约 1M 指针,而大小相似的 map[string][]byte 将包含 1B指针,用于小的 key 和 value。这样做可以节约巨大的 GC 开销。与每个 bucket 中的单个 chunk 相比,64KB 大小的 chunk 块减少了内存碎片和总内存使用量。如果可能,将大 chunk 块分配在堆外。这样做可以减少了总内存使用量,因为 GC 无需要 GOGC 调整即可以更频繁地收集未使用的内存。

ristretto,ristretto 拥有非常优秀的缓存命中率。淘汰策略采用简单的 LFU,性能与 LRU 相当,并且在搜索和数据库跟踪上具有更好的性能。存入策略采用 TinyLFU 策略,它几乎没有内存开销(每个计数器 12 位)。淘汰策略根据代价值判断,任何代价值大的 key 都可以淘汰多个代价值较小的 key(代价值可以是自定义的衡量标准)。

以下是这几个库的性能曲线图:

在一小时内对 CODASYL 数据库的引用:

面试中 LRU / LFU 的青铜与王者

在商业站点上运行的数据库服务器,该服务器在商业数据库之上运行 ERP 应用程序:

面试中 LRU / LFU 的青铜与王者

循环访问模式:

面试中 LRU / LFU 的青铜与王者

大型商业搜索引擎响应各种 Web 搜索请求而启动的磁盘读取访问:

面试中 LRU / LFU 的青铜与王者

吞吐量:

面试中 LRU / LFU 的青铜与王者

面试中 LRU / LFU 的青铜与王者

面试中 LRU / LFU 的青铜与王者

推荐阅读

BP-Wrapper: A System Framework Making Any
Replacement Algorithms (Almost) Lock Contention Free

Adaptive Software Cache Management
TinyLFU: A Highly Efficient Cache Admission Policy
LIRS: An Efficient Low Inter-reference Recency Set Replacement Policy to Improve Buffer Cache Performance
ARC: A Self-Tuning, Low Overhead Replacement Cache

不甘当学渣,努力作学霸,最终是学民

题记

不甘当学渣,努力作学霸,最终是学民

考虑到本系列文章有部分新的读者,所以关于本系列文章名字的起源就不再赘述了,见这里《"星霜荏苒"名字诞生记》

当你看到这篇年终总结的时候,距离我上一篇年终总结整整过去了 500 多天。你一定很好奇这 500 天我干了哪些事情,为什么这篇文章的标题叫这个名字?


学生等级

为了解释文章标题,就要先解释 10 个词语。笔者将学生等级从高到低排序是:

等级 名词 解释
1 学魔 对学习走火入魔,癫狂状态,不做题会死掉。
2 学霸 隐匿在人间有头脑的高智商人物,社交范围广泛,融合契合度高,琴棋书画样样精通,高端大气上档次。
3 学神(学帝、学仙、学圣) 高大帅气,青春靓丽,不食人间烟火,天天游走在高难度的练习册当中却依然风华正茂。
4 学痞 他们上课睡觉,下课玩闹,但他们的成绩仍然很好。
5 学婊 每天都在玩,几乎不学习,每场考试结束后第一时间宣布自己要挂科了。但是考试成绩出来后,门门都拿第一。
6 学民 智商均衡,膜拜学霸,却瞧不起学渣等人物。他们只有一个信念,总有一天超越学霸,因此艰苦奋斗。
7 学弱 他们因为没日没夜地熬油点灯,已经身体虚弱,不堪重负。
8 学渣(学灰) 智商处于半疯癫状态,兢兢业业,刻苦学习,却总是不得志。
9 学残 智商处于全疯癫状态。他们已经被学习折磨得痛苦不堪,没有人样。
10 学沫 智商不够用,却也不是很努力,每天在混着日子。总是觉得能够不劳而获。
11 学水 已经不能用智商与努力来评判他们了,他们已经自甘堕落,自暴自弃好多年。

不甘当学渣,努力作学霸,最终是学民

笔者这一年和学魔,学霸一起学习,经过多次考试的锤炼,最终认定自己是学民。有时候人与人之间学习能力的差距,你不得不认。笔者给自己的定义是,认真学习但也非刻苦的程度。分数不低但也不是高分。


好了,至此我关于标题的解释都述说完了。接下来聊聊今年学习和生活中遇到的一些所见所想。还有读者好奇的我这一年在忙什么。本文都会一一讲清。至于每个人对自己的人生都有自己的选择,笔者的选择不一定对。在强者面前,我的选择可能就如小丑表演一般,滑稽而可笑。那本文就献丑了。

工作的变化

今年组里一个大佬离职了。于是我有机会带领 5-6 名工程师一起向前冲。当真正 owner 一个超大项目以后才能感受到皇冠的重。各种事情都需要亲力亲为的开会,沟通,拿决策。每个组都有各自的利益,会议桌上各自都怀着自己的目的,所有人都想要自己的利益最大化。如果是因为我的“谈判”失误,导致影响到了组里全年的绩效,我会非常自责。不过结果来看,马马虎虎。我们吃掉了好几个组的业务。回收了他们部分 KPI 绩效,也成功拿到了他们的机器资源。接下来打算分享几件个人觉得比较成功的案例:

(这部分的分享本来有 3000 字左右,但是由于读者看到这篇文章的时候,已经是过去快 2 年的事情了,有旧事重提“炫耀”的嫌疑,加上笔者后来也从这家公司离职了。故笔者把这段删除了。)

职业生涯的思考

去欧洲旅行,遇到德国人讲英语,我实在不习惯他们的口音,基本都听不懂。双方无法交流。特别尴尬。但是这次我还自己给自己找理由,毕竟是有德国口音,听不懂也正常。回国以后我也忘记了这段尴尬。

去澳洲旅行,遇到意大利人说英语,能听懂他说什么,但是当对方笑起来的时候,我并不明白他的笑点梗在哪里。这一次我又安慰自己,文化差异导致我们无法交流。回国以后我有意识的去了解了一下当地的风俗文化,不过一段时间以后,我又忘记了这段“痛”。

去迪拜旅行,和中东人聊天零星听得懂,但是自己说话对方听不懂,冲沙项目中有一个人英语可以和对方交流,于是她成为了团队的中心。我心里暗暗记住,我也要成为队伍的中心,这次经历彻底击碎我的底线。学习英语迫在眉睫。回国以后便开始制定学习计划。不然下次旅行还会被英语卡。

职业生涯如果以 5 年为界限,笔者即将到达第一个 5 年了。那么第一个 5 年做了哪些成就呢?这 5 年我对我交出的答卷并不满意。兜兜转转把客户端,前端,后端都摸了一遍。如果 5 年都专注一个领域,可能早就可以升到某个领域的技术专家了。既然开局稀烂了,那第二个 5 年必须做出一点改变,“扭转乾坤”。

在中国改革开放的大环境下,中国的市场涌入了大量的外企,给市场注入了很多新的思想。在这个地球村的时代,全球旅行不再是梦想。我已经打破了旅游的地理限制,我已经走遍了 30 多个国家,5 大洲遍布了我的足迹。接下来我在考虑,我能否打破工作上的地理限制呢?比如我能否在全球任意一个国家,通过我的本领找到一份能赚钱养活自己的工作?语言是必须优先解决的。工作中沟通交流必不可少。在我脑海中这个想法还没有成型的时候,突然某一天看到脉脉上双非本科毕业生晒 Google 的工作日常。确认过信息真实性以后,我也有了去硅谷打拼几年的想法,我要打破工作上的地理限制。我不是 985 毕业,但是依旧有自己的梦想。自己实力很菜,去不了 Google,去一个 startup 应该还可以。于是我萌生了去海外工作的想法。但是此时这个想法不是很强烈,还在徘徊中。

慢慢的,我又经历了一些事情:

不甘当学渣,努力作学霸,最终是学民

人是一个社会属性的生物,他的一些决定是参考了社会因素的。例如,家长们在一起聊天,A 家长对你爸妈说,“你看你们家的孩子这么没出息,你们怎么教育的?”,或者“你看我们家孩子,年薪 5000 多W,你们家孩子年薪才 10W,干一年顶你们家孩子干 500 年。”一般这些话爸妈听了,脸面上通常会一笑而过,也许不会和你说。但是这话要是自己听到了,肯定不是滋味。由于自己的不努力,或者不够成功,导致了自己的父母在外面被其他家长“踩”。凡是有上进心的人,一定会采取一些“绝地反击”的措施吧。“我是全村的希望”,这句话看上去那么的骄傲,背后其实反映了一种自豪,一种努力,不愧对父母的养育,自己的成功,自己的出类拔萃,也让父母在别人面前出人头地。当然不少父母也是低调的,自己孩子多么成功也不会在外人面前吹。但至少,这个孩子的成功没有给其他家长“踩”自己爸妈的机会!

不甘当学渣,努力作学霸,最终是学民

我通常在同行面前都会说自己是菜鸟。久而久之,大家觉得我带坏了一些风气,装弱。有些人也会觉得我是谦虚。我的花名是,霜菜,提交系统的时候,我赋予了这个花名一个含义,菜的含义是,谦逊为人,低调做事,山外有山,人外有人。读书读的越多,就会发现身边的同学都是优秀且不带优越感的人,他们明亮不刺眼,自信又懂得收敛,让你仰慕的同时又能给你能量。仔细反思,我觉得自己也许根上并不是谦虚,更多的可能是自卑。大学毕业以后,我因为不是 985 的学历,被某些独角兽公司扔简历到了垃圾桶,没有给面试机会;因为不是 985 的学历,被牛人鄙视是垃圾。考研报考了 985 学校,也因为种种原因最终失败。社会一次次的实打实的挫败着我,985 已然成为了我心头上一道深深的伤疤。这道伤什么时候会揭掉,我不知道,这道沟壑什么时候会翻篇,我也不知道,我唯一明白的是,我俯下身子,在地上爬,不想让大家看见我破损不堪的心灵,既然是垃圾了,还有什么资格平起平坐或者高人一等?

我遇到了一个 985 大佬来自学历的“鄙视”,我仔细反省以后,我觉得必须证明一下自己,打败内心不自信的“心魔”。这个心魔算是我职业生涯第一个 5 年最后的一个大 boss,也是职业生涯第二个 5 年的第一个 boss,所以我下定决心必须过了这一关。想证明自己不输于 985 学生,想证明自己的实力,需要从某些方面来证明。正好那段时间看见了一段话,“看程序员是否勤奋就看他的英语好不好,智商高不高就看他算法好不好”。那么我就觉得在这 2 方面上证明自己的实力。心里默默下定决心:等我下次面试,我一定也要去面“鄙视”我的那位 985 大佬所在的厂,当我作为他的同事,职级还要和他平起平坐。希望到了那个时候,他能不鄙视我非 985 的学历。人活着有时候就是为了一口气,你若不认输,不服输,不愿意承认自己是 loser,不愿当学渣,那就用行动证明给嘲笑过你的人看,去证明你也有“过人之处”~我就想用成绩来证明给天下人看,“虽然我不是学霸,但是我决不是学渣!”,我想撕掉心灵上学渣的这块疤。

以上就是我从迪拜回来到 7 月份这期间的一些心路历程。从萌生去海外工作的想法,到自我反省,自我认知,最后到下定决心证明自己。所以从 7 月开始,我决定开始了英语雅思和托福的备考。从今年 3 月 18 日开始,我开始刷 LeetCode。英语 + 算法两手抓。

在英语备考期间也有来自各个大厂大佬的面试邀请,有来自滴滴,腾讯,头条,阿里巴巴,百度,拼多多,美团等等大厂的面试邀请。(此处对 @孙源 大佬说声对不起🙏🏻,邀请了我好几次,我都没有说明原因,有些话当时实在不方便说出来,现在如果你看到这里,还请谅解我啊)也有来自大厂 HR 的面试邀请。我在这里和你们说一声对不起了,当时回复你们的都是“我有我自己的安排,对不起”。其实我是想去海外大厂干一段时间,多成长成长,和世界上最优秀的工程师切磋切磋,再以最好的状态入职大厂。看到这里,就是我一直隐藏着的答案。

此处也需要 @子奇@萧玉,这两个大佬也邀请我面试很久了。我一直委婉拒绝,可能由于和你们太熟了,导致我没法去你们那入职。🙏🏻还请 2 位大佬原谅我的当初的拒绝。还有 @阿里云大佬,@淘敏,@闲鱼大佬,@宗心的面试邀请,我实在非常不好意思。还有很多大佬也私信邀请过我,此处没法一一@,如果你们看到这里了,就请原谅小弟我吧。有不少人说我走了一步错棋,因为没有加入你们的团队。在此我也一并说声抱歉吧,小弟不加入只因为我觉得我还不配加入你们,你们都非常优秀,我还太菜了。人生还长,当我修炼好自己以后,未来再加入你们的机会很多!

不甘当学渣,努力作学霸,最终是学民

在提升英语的期间,也有太多的“诱惑”,有来自猎头的“嘲讽”:大概 19 年 7 月的时候,有一个猎头加我,说我工作快 5 年了,还没有到 P7,技术有点垃圾 ,在阿里也呆了 2 年了,可以出去看看机会。我当时心里好“憋屈”,我当时就想证明一下自己的实力。不过有“心魔”的我,当时也只能忍了,宛拒了,“我的技术实力还不行,面不上头条,我还需要在这里再磨炼几年”。朋友圈也有来自旅友的美景照片,有来自日本旅友的旅行邀请,有来自欧洲旅友的旅行邀请。有来自美洲朋友的旅行邀请。在没有战胜我内心心魔的时候,实在没心思去玩,但是每天刷到美景照片,心里实在痒痒。于是,我还是选择关闭朋友圈,减少诱惑。但是又发现旅游群里面还有诱惑,于是屏蔽群消息,又发现还会有一些私聊的诱惑,最终选择关闭微信了。这和某学习 app 每日推送说的一样。“微信不必每条都看,但是单词不能一日不背”。(不过好像不用微信,对生活好像影响不太大,工作中重要的消息都在钉钉上,和家人聊天都用的 iMessage,逢年过节上微信和朋友问候一下,发发红包送祝福)

我一度删掉了微信好几个月,屏蔽了所有外部消息。这也是为什么好多人发我消息,我都没回复的原因。并不是因为忙,并不是因为不想回,而是因为我没用微信了。微信上诱惑太多,加上我自己心理调节能力弱,我直接断舍离。微信群里经常会有人晒百万高薪,晒千万跑车,晒万亿豪宅,看了以后要么自己会酸柠檬,要么就恨自己有多没出息。多多少少心理上会有一些波动。这些心理波动对备考时期没有任何好处,至少对我来说。删掉微信以后,我每天只想我自己的事情。心理上至少做到了不以物喜不以己悲的境界。(每个人备考状态和自我调节能力不同,我的心路历程也许不能复制,只是写出来给大家参考,也可以当做是“笑料”,给大家笑笑。)

人需要沉淀,需要“静养”,弹簧的姿态压的越低,之后弹的越高。有些人说我是一个很自律的人。不过恰恰相反,我自己认为我是一个不太自律的人。一个大师和说了三个字,解决我不自律的问题,“戒,定,慧”。让我戒掉一些东西,节约时间,心定下来,产生一些学习的定力,最终会产生智慧。在群里聊天我会很不自律呢。群友的问题我看着就想回答。也可能工作 5 分钟,聊天 3 小时😂🙏所以我决定克制一下。

就这样,我朝着去海外工作的目标狂奔着。

决定留学

不甘当学渣,努力作学霸,最终是学民

在准备学英语之前,我去某英语培训机构裸测了一次雅思水平,居然才 5.5 分。扎心的是,老师还安慰我,“你快工作 5 年了,可能平时用英语不多吧,来上我们的课,带你提高英语水平”。我平时工作上每天打代码都是英语啊,这心里落差把我打至谷底。现在回头想想,这个英语培训机构给我的评分是真实的么?会不会故意打低分,“骗”我报班的套路?毕竟我没有参加过一次真实的雅思考试。再后来我就报班了。报了托福的培训班。😂由于雅思裸测 5.5 分,老师在课上叫我五分选手,(难道四舍五入不应该叫我六分选手么?)我经过一年的努力,我已经成功变成了八分选手。为了自嘲自己的超低水准以及提醒自己记住这段痛苦奋斗的日子,我现在仍然自称自己是五分选手。(这也是公众号名字的来源)

也因为雅思考的分数不高,使得我“怒转”复习备考托福。托福的口语是人机对话。你面对的是没有感情的机器,它不会因为你说错而产生尴尬气氛。雅思是人人对话,和真人对话时间,你说的如果对方听不懂,那会非常的尴尬。而且雅思听力里面也有我不擅长的听数字,填空等等题目形式。

不甘当学渣,努力作学霸,最终是学民

这时候我也发现,一步出国工作可能有点难,可以分为两步走。先在国内的外企工作一段时间,再转去外国本土工作。国内的有名气的外企有 FLAG,开始投简历咯。我开始刷题,准备英文简历,冲击外企。现实给我泼了很大一身凉水。收到了 HR 给我的反馈,“同学,对不起,这个岗位你的学历背景差了一点,我们的候选人目前都是清北交复的硕士博士。希望未来你还有机会加入我们,谢谢”。我不会在这里公开这是哪家企业,面试岗位不是 leader 岗,只是高级 SDE 岗位而已。我接到这个回复以后,脑袋飞速运转,“可能是我的技术实力不行,用学历差了这个理由婉拒我”“有可能真的是学历不定,看 HR 的语气,要招硕士起步的人才”。不管是哪个原因,我的世界都乌云密布。既然先在国内外企工作一段时间再办签证去海外的这条路已经不通了。那么只剩下通过留学这条路去海外了。到此时,笔者才确定了留学的这条路了。

今年 7 月 13 号,滑滑鸡即将前往 CMU 读 Master 了。我和瓜神一起和他吃了自助餐,送他去美国。餐桌上他向我传授了托福裸考 108 分的经验。这次吃饭是经典的“西瓜霜”聚餐。滑滑鸡的名字中带有“西”的同音字,瓜神就是“瓜”,而我的名字中带有“霜”。餐桌上我也许下了诺言,2020 年会去美国找滑滑鸡一起吃饭。如果有缘的话,更希望能成为 CMU 校友。希望我能早日兑现男人的承诺。

不甘当学渣,努力作学霸,最终是学民

写书

不甘当学渣,努力作学霸,最终是学民

很多人发现我从 2019 年就开始刷 LeetCode,其中有一小部分人怀疑我要跳槽,我的同事起初也怀疑过我要离职,但是看到我刷了半年以后都没动静,也就慢慢相信我刷题是因为爱。这一小部分人猜对了真相,不过我笑而不语。同事也都不明真相,真实情况是因为我没有通过面试,为了“掩盖”这个事实,我干脆一不做二不休继续刷。刷满一年以后,我就没有日日不间断了。现在我还隔三差五的刷 LeetCode 真的是用爱发电。刷算法是热爱,刷到世界充满爱!

我写书也是受到了 @欧长坤 大佬的影响,他的两本经典开源书令我收获颇丰。于是我也想写点开源书,回馈社区或者单纯分享知识。由于今年全年我都在复习英语和刷 LeetCode,于是决定写这两本书。

不甘当学渣,努力作学霸,最终是学民

这两个本书我都放在 github 上迭代更新。至于什么时候会印制成纸质实体书,还不确定。众所周知我目前还是一个 gopher,所以这两本书的网站必然要用 hugo 搭建。第一本书我用的是 wowchemy 主题,第二本书我用的是 hugo-book 自定义主题。项目代码也都开源在 github 上了。

写开源书(让我姑且称它为书)真的非常花时间,书和一篇博客不同,博客的目录只是单篇文章的主线,但是书的目录就不同了,它是整个知识体系的主线。笔者时常写到某篇文章的时候,突然想到另外一篇文章有问题,又去调整前面写的好几篇文章。为了每天能记住 200-300 个单词,我选择每天早上 6 点早起,先把 LeetCode 每日一题写完,然后把解题思路,代码,测试文件 push 到 github。如果快的话,6 点 30 分左右都能搞完。接下来吃早餐,7 点到 9 点是刷托福 TPO 和背单词的时间。我会把阅读的文章翻译一遍,记录和分析错题,精听听力文章等等。做完练习再把心得和方法等等内容 push 到 github。9 点半左右出门去公司上班。

常看 O’Reilly 动物书的同学一看这个封面就知道是向他们致敬。确实是这个目的。O’Reilly 的封面动物都是稀缺动物,并且画风都是黑白素描风。第一本书的封面上的动物是 Coyote。经常听托福听力的同学一看到这个单词就会觉得特别亲切了。之所以选这个动物作为封面也是这个原因。Coyote 在托福听力生物类中出现的频率比较高。既然此书是动物书,又和托福有关,那么选 Coyote 是理所应当了。

Coyote 翻译过来是土狼,或者郊狼。郊狼(学名:Canis latrans),也叫草原狼、丛林狼、北美小狼,是犬科犬属的一种,与狼是近亲。郊狼产于北美大陆的广大地区,北起阿拉斯加、南到巴拿马。欧洲探险家最初是在美国西南部发现这种动物。郊狼一般单独猎食,偶尔也会组成小型的群体。平均寿命为 6-10 年。郊狼在其大小、颜色和头部形状都十分相似濒危的红狼。 其英文 coyote 一词来自中美洲阿兹特克等部族所用的纳瓦特尔语单词 coyōtl,后经西班牙语传入到英语。

第二本 LeetCode 这本书的封面动物是孔雀。孔雀开屏的意义是希望大家刷完 LeetCode 以后,提高了自身的算法能力,在人生的舞台上开出自己的“屏”。全书配色也都是绿色,因为这是 AC 的颜色。

这两本书我会一直保持更新,直到我的托福考到一个理想的分数,LeetCode 刷到 500 题。当满足这两个条件的时候,便是你看到书的时刻了。

当你在读这篇文章的时候,第二本书应该已经开源了,所有代码都在 github repo 中,并且也是 public 的。但是第一本书可能难产了。并非笔者不想开源分享,而是笔者的托福分数没有考到满分。有一天去知乎上看了一眼,个个都是托福 120 分满分选手。我这种英语垃圾选手还是低调的找个地缝藏起来了。所以笔者这辈子都不打算开源第一本书了。

既然第一本书不打算公开了。那在这里放一些它的截图吧。记录一下今年我的一些努力时光。下图是 github private repo。

不甘当学渣,努力作学霸,最终是学民

接下来几张图是用 wowchemy 主题做的这本书配套的网站。

不甘当学渣,努力作学霸,最终是学民

不甘当学渣,努力作学霸,最终是学民

不甘当学渣,努力作学霸,最终是学民

不甘当学渣,努力作学霸,最终是学民

不甘当学渣,努力作学霸,最终是学民

艰辛的托福备考

实话说,备战托福对我来说,是比较花时间的。我妹子全程目睹了我“无比艰难”的备考过程,尤其是在职那段时间,备考太艰难了,加班到半夜到家以后还要开始读英语,睡觉都是听着 TPO 睡的。那段时间我还经常被公司安排 on call,很多次都有离职的冲动,换一个闲一点的公司。(最后还是咬牙坚持把一年干完,拿完年终奖走人了。)我妹子也从来不鼓励我,反而她一直都在劝我放弃,(这难道是反向操作的鼓励?),她经常说:“你看看人家滑滑鸡,不到 6 个月解决 GT 2 门,还都是高分,你呢?6 个月 GT 还没考到人家分数的 80%,还努力啥?坚持啥?”,我确实有过放弃的念头,但是一看我的学托福学雅思的时间投入,我就不能放弃了。走路都走一半了,这个时候放弃,过去的好几千小时岂不是白费了?大家都说,凡事要享受过程,不要在乎结果。“功利”的我偏偏非常在意结果,人一辈子的时间是短暂的,投入产出比不高的事情,优先级可以降低。于是我这个垃圾靠着不能放弃宝贵时间的前期投入,一直咬牙坚持。

开始我还不承认我是学渣,我觉得自己还算努力,算学弱应该不为过(学弱:学习很努力,但是分数很低)。经过几次 GT 考试的摧残以后,我自觉的把自己的身段从学弱放回了学渣。在我妹子眼里,滑滑鸡就是100%准学霸。这点我也心服口服。在被托福和 GRE 考试无情摧残了 4 个月以上时间的我,每次看到滑滑鸡的朋友圈,或者和他微信聊天,我内心真的充满了对学霸的敬佩。(如果有不服气的,可以立即报考托福和 GRE,3 个月内能达到托福 110+,GRE 330+ 都是学霸。)滑滑鸡这种学霸型,自己高分杀 G 砍 T,期间还有额外精力去实习赚钱,刷 GPA,四线操作切换自如。不仅无开销,还赚了几十万。有时候我一个人在家备考的下午,我就会翻翻自己的留学时间线和花销记录小本本,我和他的差距已经不是学渣和学霸这两级的差距,我觉得说我们相差四级都不为过😭这可能就是双非大学毕业的学渣中的学渣和顶级 985 毕业的学霸中的学霸的巨大差距吧。在托福和 GRE 这两门考试上,我是输的心服口服,我被虐的体无完肤,毫无脾气。

大家也都别参考我,我英语太垃圾,学习能力也很差。在职的时候公司加班比较忙,每天下班以后还要刷 LeetCode,写博客,写书,背 500 个托福单词,刷托福 TPO,背 GRE 单词,刷 GRE 题目。我实在是 hold 不住了。如果每天都要完成这些任务,每天我的睡眠时间只有一个小时。最后决定托福 + GRE + LeetCode 是每天必须完成,写博客和写书放在周末完成。由于在职复习和学习进度有点慢,所以我到今年年底也没有提交申请学校的申请。

虽然我的托福没有考到满分,但是依旧可以有一些经验可以和读者分享。

考试心态

托福考试是一种能力的测试,不是想办法出难题刁题(TPO只遇到过一次)为难大家。同时,考试也没有大家想象的那么难,我和考友的交流发现,考试改革以后,容错率仍然是挺高的。所以大家在对待考试的时候,不要对它产生惧怕心理,要藐视它,相信自己的付出一定能得到相应的分数。同时,是考试就有应试的技巧,无论是报课程,大家的攻略,还是每个人自己复习时候的心得,都是大家找到考试诀窍的方法。一定要对托福考试有一种熟悉感,一种我知道你想出什么题目,你在哪里出题的熟悉感,预判出题者的预判。这需要复习的时候有一颗热诚的心,要像对待女孩子一样了解她,学会去预判她的想法,做她希望你的事,而不是浪费精力做无关的事,或者作死。所以请大家复习的时候要有安排,有技巧,把精力放在最容易有效果的地方,多拿一分是一分。

托福考试分四个科目,读听说写四个方向,其实每一个方向都有一个共同的基础,那就是词汇。词汇不过关,100 分就是虚无缥缈的幻想。所以第一个月一定要沉下心来好好背单词。单词就用 KMF 词汇 app 和默默背单词,我是工作党,利用闲暇时光,目标是一天 300 个单词,白天争取在午休和吃饭的时候背好,不求背了就永远不忘,因为你会要背 3-4 遍。背单词其实是一种加速的过程,到后面几遍的时候,可能需要背的单词量也就一半不到,所以不用担心。

而听力是从 80 分到 100 分的最终助力,其实除了阅读,每个科目都和听力息息相关,每门课都要拿到高分,那需要能够精准的反映出听力的信息。我首考 74,我觉得很大的原因,除了第一次考试紧张和一些突发意外,最重要的就是没有重视听力,没有做好仔细听的心理准备,到时口语和写作综合题没有踩到所有的得分点,因此,大家一定要心理上要重视听力,考试的时候提醒自己保持听力注意力。无时无刻保持警惕感,去找到出题的点。

单词

我背单词都是用零碎时间背的,例如,上下班等车,坐地铁,午饭后散步,晚饭后闲逛。这些零碎时间都可以用来背单词。背单词不要一个单词记 5 分钟。正确的做法应该是多重复。一个单词看 20 秒就过,一天多重复 3-4 次。重复次数越多记的越牢。试想,情景一,一个陌生人和你打个招呼聊天 10 分钟,一年以后你还能记住他么?情景二,一个陌生人和你打招呼,每天都聊 1 秒钟,连续 365 天天天和你相见,一年以后你还能记住他么?很明显,每天都重复一定会让你记住他。以下是我的单词 app 的刷词记录:

不甘当学渣,努力作学霸,最终是学民

上图是笔者重新更新的截图。到 2019 年年底,连续打卡天数只有 190 天左右。笔者先用考满分单词 app 刷完所有托福词汇,并开启第二遍刷单词,差不多 10 月份的时候,开始用小站单词 app 开始刷它。当我把小站 app 所有单词都刷完一遍,我又开始用默默背单词了。如果你问我哪个 app 最好用?这个问题我其实回答不上来。因为我所有都用过,记忆单词有累加效果,并非某单一 app 促使我记住所有单词。其实这些单词 app 都差不多的。建议读者都试试,找一个适合自己的,刷起来吧。

时间规划

托福备考最好能速战速决,拖的时间越长,备考效率也不高。一般以 3-4 个月为周期最佳。

  • 第一个月:夯实基础阶段。

这个阶段是一个准备期,就是背单词,每天争取 300 个词。背单词的同时,可以开始做阅读 TPO 了,一天一套的练习。

  • 第二个月是复习高峰阶段。

除了写作和口语综合题以外,所有科目都要每天分配一定的时间去完成每日目标,单词继续 300 一天,阅读一天一个 TPO,听力一天一个 TPO,口语独立一天一题。这段时间是最痛苦的时间,因为程序员工作比较忙,大家一般都 23 点左右,如果加班,到家就 1-2 点了。所以睡前一定要合理安排好工作生活与学习。每天看着时间流逝,还是很抓狂的,但是大家一定要坚持下来,而对于学生党,这些工作量应该是很轻松能完成的。

  • 第三个月是冲刺阶段。

全题型都需要冲刺的阶段,重点可能会放到口语综合题和写作上面,一天的时间单词继续 250 一天,阅读一天一个 TPO,听力一天一个 TPO,口语独立一天一题,口语综合题争取一天一套(我的实际情况是考前大概做了 30 套口语),写作两天一套(考前做了 5 套)。至于每天的具体时间安排,因人而异,我可以说说我的周末安排。

​ 上午无论几点起床,一套阅读一套听力的 TPO 的模考和错题回顾。

​ 下午午休,醒来后一套写作。

​ 睡前完成口语 TPO 一套。

​ 其中找时间穿插背一背单词和背一下语料(口语和写作通用)。

有人觉得一天需要做两套 TPO,我觉得很难实现,毕竟做错题还要检查为什么错,基本上一天一套 TPO 差不多了,何况 TPO 资源有限,不能浪费,必须做一套反思一套。接下来单独说说 4 科的复习方法。

阅读

阅读是拿分重点,词汇是基础,所以单词一定要背几遍。

复习的时候,学会去找同义转换,要相信考试的时候,绝大多数题目还是在做原文的同义转换,快速定位关键词 KEYWORD,将原文和题目选项对照,所有题都做排除法,基本能保证很高的分数。我有两次考试,最后三题剩的时间都不多,最后分数 28,一方面说明同义转换的正确定,也说明了考试的容错率。

不甘当学渣,努力作学霸,最终是学民

每篇 TPO 阅读做完以后可以写一写反思。错题错在哪里了。然后精读一下文章,把长难句和不懂的单词查一查。笔者大概精读了 30 篇左右。考前 TPO 阅读都做完,因为有可能出现阅读题目变成听力题目,所以 TPO 刷完,最后问题不大。

不甘当学渣,努力作学霸,最终是学民

不甘当学渣,努力作学霸,最终是学民

听力

听力是从 80 分到 100 分最重要的助力。听力题是有容错率的。拿我的考试经验看,每次到时会遇到七八题需要思考一下二选一,两次考试分数都是 23 分,比自己预想的要高。听力复习最重要的是多听。

  1. 通过多听,培养英语的声音结构。

听力不是一个个单词听,每句话都有自己的主谓宾,考试考点无非就是考察主语或者宾语你有没有听到,少量题目是对整体或者段落的态度理解。

  1. 通过多听,熟悉段落结构,养成对出题点,问题,语气词,转折词的敏感性,在考试中助力自己能在出题点前提高自己的耳朵注意力。

每天一套 TPO,加考后精听,精听是边听边思考上面的两个要点,一遍通过。靠前两周,做到 1.2 倍速度做题没问题,复习平均分基本就是考试分数。做题的时候,坚持到底,不要因为一句或者一段没听清楚就放弃,很多情况下,听力题目在不理解听力素材的条件下也能回答。但是要做到有连续听六题的忍耐力和注意力。TPO 题目现在有可能成为你阅读的题目,所以大家一定要做完。

不甘当学渣,努力作学霸,最终是学民

关于笔记,我的历程是一文章五六个词,到一文章记得密密麻麻,到最后考试前一文章不超过 10 个词,其实最重要的内容是记下文章观点和逻辑。关键词,术语,不重要。考前听力 TPO 都做完,问题不大。分享几个听力的笔记。有些同学不做笔记也可以拿到高分,所以笔记并不是必须的,因人而异。

不甘当学渣,努力作学霸,最终是学民

不甘当学渣,努力作学霸,最终是学民

不甘当学渣,努力作学霸,最终是学民

不甘当学渣,努力作学霸,最终是学民

口语

考前一个月时间准备口语。如果你的口语真的说的不溜,可以考虑报个口语班,多练练,我只能说,语料是真的好用,2 个月的考试,独立口语会命中相似的语料。班上对每种题型的理解也比较清晰,也提供了答题需要注意的结构和模版,挺好用。

具体复习的时候,口语只能靠自己多说了,而且要厚着脸皮说,因为考试的时候,自信心非常重要,你身边可能有比你说的不好的,可能说得和老外一样好,这时候你如果心态疲软,会出现答题问题,越说越不自信,越说声音越小,越说语言越枯燥。

独立口语就是语料的积累,我为自己准备了多种语料,能面对交流,文化,科技,环保等内容的时候,有话可说。同时积累逻辑词,因果关系,递进关系,转折关系每种关系准备两三个词,通过结构和语料,共同搭建起自己一分钟的语言轰炸。考试的时候,保证自己能说完整完一个观点,包含态度,原因,举例,反方,总结。

综合口语更重要的其实是听力,能把考点都记下来。所以这种题型相对简单,考题结构也比较稳定,比听力题简单点。最后通过模版或者自己准备的逻辑词把重要考点说出来就可以。

关于发音练习,笔者这里推荐一个免费课程,lisa美语音标发音教程中文字幕(4.0高级技巧)

写作

写作我也不知道要不要建议大家准备模版,因为我第一次用了模版是 21,第二次没用模版是 24。当然,这也是因为我综合写作听力有一定专门的训练,所以可能是综合写作分数往上提升。

有模版,写起来会比较简单,字数,高级词汇也会有一点。

不用模版,也不是不行,因为考试最重要的是看你的大逻辑和提供的论点是否切题,是否符合你的态度。

另一个关于打字数度,我可以给大家一个参考,独立写作 380 字,综合写作 220 字,分数也是能拿到 24 了。有些大神就 600 起步的,最猛的有打到 700 字的,(这手速要多快?)我真的很佩服,他们拿 30 分合理。

关于拼写,我感觉这个对分数影响是很大的,考前要做到不超过 5 个拼写错误。平时不要用带拼写检查的软件练习写作!语料同口语一起准备,考前两周,做到两天一套 TPO,保证自己写作评分能在 4 分稳定,问题不大。写在最后,首考的同学要做好心理建设工作,因为真实考试和模考是会有不同的。听力有时会出现英式英语,口语第一题会有一种突然开始的感觉。

最后,复习到今年年底,笔者身边很多同学说自己撑不住了,是的,这确实是备考托福的过程中,最难熬的一段时光,就像在一条,狭窄的隧道中,前面只有一丝光亮。而后面,漆黑一片,早已没有了退路。放弃是不可能了。但想拼却又觉得就那点光亮,值得么?你会觉得别人都是在阳光下奔跑,十足的胜算,你的拼搏,都变得卑微,于是你开始犹豫,你每天也在看书,但是却没有了对于美好未来的一丝憧憬,你只是在想这段路,早点结束吧。“放我出去”,是你唯一的期待。但是你却忘了,这世界上没有人是容易的。哪有什么阳光下的奔跑,都是在这条隧道中,艰难前行。未来有一天,那些所谓成功的人,回忆走过的路时,他们一定会提到,当年他们也在一条隧道里,艰难地拼搏。你才突然想起,这个地方我也去过啊,狭路相逢勇者胜,不要妄自菲薄,未来成功投射到当下,只会是一丝光亮,每个人都一样,没有阳光明媚,只有微光一点,但你的努力,本就光芒万丈,你忘记了,这一路走来,不是未来给了你希望,而是你一直在给自己力量,你要去拥抱的,不是什么狗屁成功,成功就是一个贱人,他只会依附于强大的人,你要去遇见的是那个更好的自己,那个绝不服输,决不放弃的更强大的自己。其实,你看到的那一点光亮,也是那个自己给你的,不要让在隧道尽头等着你的那个自己失望,因为你要是放弃了,她就等不到你了,而成功这个小人,也会离她而去,拼搏吧!燃烧吧!去看见,去遇见,去拥抱,然后有一天你带着成功一起讲述,你就是从隧道里寻着一束光,找到她的。然后终有一天,你可以笑着去讲述那些曾经让你哭的瞬间。💪

关于旅行

不甘当学渣,努力作学霸,最终是学民

相比 2018 年去了 5 个国家来说,今年大幅减少了。利用五一假期去了迪拜,本来十一打算去美国常青藤学校看看,但是由于笔者托福备考进度慢了,所以十一没有出去,旅行的时间都用来上复习托福考试了。这是 2018 年年终总结里面写的新年愿望,现在看看,只完成了一部分,以后还是少立 flag,计划赶不上变化,脸疼。

2019 年的梦想是去迪拜完成 15000 米跳伞,去沙漠坐骆驼。2019 年旅行计划主要就是迪拜,欧洲和美国,去德法意瑞,看看欧洲列强们如今过的还好么;去美国体验体验常青藤学校浓厚的学术氛围;去迪拜看看白袍们有多么奢侈的生活,捡一捡丢在马路边的“垃圾”,兰博基尼,住一住七星级酒店。

一切源于年初的时候,我在世界地图上选择了 9 处比较浪漫的地方作为今年女友 18 岁的生日礼物,打算 2-3 年内完成这 9 处的打卡。我是一个不懂浪漫的穷人家的孩子,送不起房子,送不起车,只能送回忆了。既然作为 18 岁生日的礼物,那主题就叫 “勇敢者的游戏”吧。

不甘当学渣,努力作学霸,最终是学民

最终定下来是去迪拜,完成棕榈岛 15000 米跳伞和沙迦沙漠深处冲沙。强烈推荐跳伞项目,真的太好玩了。笔者跳伞的长视频发在 @ halfrost 抖音号上了,欢迎读者去观看。关于迪拜的酒店,强烈推荐全世界唯一的七星级酒店帆船酒店 Burj Al Arab,和六星级酒店亚特兰蒂斯 Atlantis The Palm,当然还推荐全世界唯一的八星级酒店,只不过不在迪拜,在阿布扎比,阿布扎比皇宫酒店(Emirates Palace)。下面 2 张图分别是亚特兰蒂斯和帆船酒店。

不甘当学渣,努力作学霸,最终是学民

不甘当学渣,努力作学霸,最终是学民

可能有读者会问上面两个图的拍摄角度为什么这么特殊。笔者是在直升机上拍的。在亚特兰蒂斯酒店旁边有一个直升机场,可以买一张票,笔者买的是 25 分钟的票,直升机会带你飞到市中心转一圈再飞回来。沿途会经过帆船酒店,黄金相框,世界岛,哈利法塔,所有经典景观都会让你从上空看一遍。最后围绕棕榈岛半圈,回到亚特兰蒂斯旁边的停机坪。

迪拜的亚特兰蒂斯酒店可以去玩它的水上公园。是免费的。它的水上公园真的非常好玩。住在亚特兰蒂斯的话,旁边也没有什么小店可以吃饭,吃喝都在酒店里面了。(住亚特兰蒂斯的人还考虑消费么?花钱就是快乐)亚特兰蒂斯里面很多自助餐厅,至于价位嘛,消费上不封顶。带多少钱都能在这里挥霍完。笔者非常“省吃俭用”的在这里住了几天。

帆船酒店就不用说了。纯金的马桶,每晚 13-20W 的高级套房,爱马仕香皂。一切都是奢华的顶配。出门可以预约劳斯莱斯幻影。帆船酒店的房间里面的私人管家服务周到,只要你有钱,不太过分的要求都能尽量满足。比如你想把 F1 赛车运到帆船酒店的顶楼直升机停机坪,玩漂移,都是可以的。(并非玩笑,是真的可以)

比帆船酒店还要再高一星级的皇宫酒店,笔者没有体验,只去吃了自助餐。皇宫酒店在阿布扎比,它的内部不一定有帆船酒店奢华,但是它的社会地位比帆船酒店高。它本来是专门招待各国顶级领导人的。当领导人来访问的时候,是不开放给普通游客的。如果你不住在皇宫酒店,还是可以单独约这里的自助餐的。自助餐全部都是米其林星级厨师纯手工制作。所以中午吃一个自助餐,吃 2-3 小时是很正常的。

迪拜其他的打卡地有,民俗村,运河,博物馆,黄金相框等等。

不甘当学渣,努力作学霸,最终是学民

上图是迪拜的民俗村,是迪拜最老的当地人住的地方。房间上面那些横着的柱子和风洞,是用来调节房间温度的,是一个天然空调。

不甘当学渣,努力作学霸,最终是学民

上图是黄金相框,个人觉得还是在直升机上看这个相框更有感觉。站在它的面前,你能看到相框里面的景色就只有天空了。

迪拜的朱美拉清真寺(Jumeirah Mosque)是不得不提的打卡地。笔者是五一去玩的,正好遇到斋戒期。去清真寺一定要符合宗教的服装要求,男女服装都有要求。男士和女士需穿着保守、宽松、不透明的衣服,最好选择长袖 (腕长),长裙子 (脚踝长度) 或裤子,进入清真寺前,有一个商店可以租这些衣服。

不甘当学渣,努力作学霸,最终是学民

不甘当学渣,努力作学霸,最终是学民

清真寺建筑群的内外墙壁用来自希腊和马其顿的汉白玉包裹而成,内部装饰金碧辉煌,错综复杂的雕刻和壁画令人赞叹!雪白的大理石圆顶及墙面,在阳光下隐隐发亮,清真寺前湛蓝的一池清水,不禁被这片圣洁之地所吸引。黄金柱头,简洁的柱子底座,大有彩色图腾花纹的柱身,加上具有标志性的拱形洞口,整个色调的把握,把阿拉伯文化淋漓尽致演绎到建筑之中,整个清真寺就是一个奢华艺术品!在这里,你还可以亲眼目睹世界上最大的大理石马赛克装饰和纯手工地毯,给你前所未有的视觉震撼。上两张图只有你亲临现场,才能被彻底的震撼到。相关的视频也记录在笔者的抖音里面了。

不甘当学渣,努力作学霸,最终是学民

上图是在直升机上拍的棕榈岛,这个就不用说了,世人都知道。还有一个没有完工的世界岛,是另外一个人造岛。笔者在直升机上没有拍到,当飞行员解说到世界岛了,我看了半天才意识到哪里是世界岛,最终错过拍照了。

最后一个打卡地就是世界最高的塔,哈利法塔。可以去 128 层的观光层往下看风景,有迪拜城市的全貌。笔者建议下午 3-4 点去,这样可以一直呆到 6 点日落的时候,和心爱的人在世界之巅一起看日落。值得一提的是哈利法塔的电梯也是世界最快的。笔者拍摄了它上升的速度,非常震撼,视频都在抖音号上。

不甘当学渣,努力作学霸,最终是学民

其他的活动就是消费活动咯。The Dubai Mall 肯定是必去的。它由 10 到 15 个 Mall 中 Mall 组成,一共将有大约 1200 个商店,有 16000 个停车位。此外,它还将有世界最大的水族馆,最大的黄金市场,奥运比赛规模的冰场,6 层楼高的巨幅屏幕影院,探险公园,沙漠喷泉等等。

迪拜购物中心单独占地 500 万平方英尺(约 46 万 5000 平方米),相当于 56 个足球场的面积,连同其所有辅助设施、附属建筑在内、总共占地 900 万平方英尺(约 83 万 6000 平方米),这些数据都刷新了世界纪录,超过了加拿大埃德蒙多市的得西埃商业中心和美国明尼苏达州布卢明顿市的美国购物中心。也许你对这些数字没有感受,那笔者这样描述吧。从早上商场开门,一直逛到晚上商场关门,逛整整一天,只能逛完其中一层,连续逛一周才能把整个购物中心逛完。

迪拜的茶余饭后的娱乐活动非常匮乏,没有中国的棋牌,麻将,广场舞等等活动。女性唯一的娱乐活动就是逛商场,消费。所以 The Dubai Mall 拥有全世界最新款的 LV 包包,拥有全世界最新款和最贵的奢侈品。只有这样才能满足迪拜女性饭后的娱乐需求。(这段不是开玩笑,说的是真实的)所以当你在购物中心看见一个白袍领着他的老婆们一顿买买买,一口气买 16 个 LV,4 辆劳斯莱斯的时候,别认为是人家败家,其实人家是在娱乐呢。(4 个老婆,每个老婆一辆劳斯莱斯,一年四季,每个季节都要一个 LV 包包。所以需要 16 个 LV,4 辆劳斯莱斯)在迪拜允许一夫多妻,但是必须对每个老婆都公平对待,你的爱要平均分给每个人。

好了,今年的旅行就说到这里了,大多数迪拜的旅行视频都在 @halfrost 的抖音号上。读者有空感兴趣的话可以去看看。希望明年笔者的托福和 GRE 可以考到满意的分数。考完了想去南美或者冰岛转一转。(立 flag 要打脸)

最后

一位大佬朋友圈写道:看程序员是否勤奋就看他的英语好不好,智商高不高就看他算法好不好。这句话我当时看到了很触动,默默的记在了心底。2019 年一年我就只做了 2 件事情,刷算法,学英语。我现在还不敢说我是优秀的程序员,但是我至少努力过。不辜负时光,无愧于自己。以上就是你们想要的答案,这就是我 2019 年的年终总结,里面揭秘了 98% 的人都不了解的事情。很多猎头迷惑的内容也都在里面了。感谢周围亲戚朋友的这 2 年的关心。这篇总结不是原子弹💥别太惊讶。

回过头来看,好像也没有做出什么牛逼的事情。就是下了一步小棋,布了一个小局。也不是什么传奇经历,无非是奋斗路上立 flag,达到 flag,再立 flag 的一滴滴汗水罢了。过去 2 年,我一直隐身于网络,也没人知道我立的这些 flag。大家的年终总结都是对自己今年 flag 完成度的复盘,而我没有勇气去直面被人嘲笑的梦想,如果最终梦想没有实现,flag 无非就是人们饭后的谈资,打肿我脸的笑柄。这也是近 2 年都没有看到我的年终总结公开出来的原因。最终的梦想总算完成了,也是时候可以把年终总结公开出来了,给关心我的读者和朋友一些交代了。(本文写于 2019 年年末,最后这一段修改于 2021年 3 月)

最后一些“只言片语”的感受分享一下作为年终总结的结尾吧。

  1. 不要向任何人诉苦。因为 20% 的人不关心,剩下 80% 的人听了会开心。
  2. 狮子从来不会关心一只羊的看法,不要在意别人怎么看你,你应该努力强大成为一只狮子。
  3. 要学会拒绝,没有人会感激你的善良,很多人只会得寸进尺。
  4. 不需要解释的不要解释,从你张嘴的那一刻你就已经输了。
  5. 当有人侮辱你的时候你要记住,狮子不会因为狗叫而回头。

好了,2019 年的【星霜荏苒】就到这里了。如有任何异议或者想讨论的地方,欢迎和我交流。

不甘当学渣,努力作学霸,最终是学民

2019 年 5 月 5 日,于迪拜 Dubai


GitHub Repo:Halfrost-Field

Follow: halfrost · GitHub

Source: https://halfrost.com/halfrost_2019/

深入 Go 并发原语 — Channel 底层实现

深入 Go 并发原语 — Channel 底层实现

作为 Go 并发原语的第一篇文章,一定绕不开 Go 的并发哲学。从 Tony Hoare 写的 Communicating Sequential Processes 这篇文章说起,这篇经典论文算是 Go 语言并发原语的根基。

一. What is CSP

CSP 的全程是 Communicating Sequential Processes,直译,通信顺序进程。这一概念起源自 1978 年 ACM 期刊中 Charles Antony Richard Hoare 写的经典同名论文。感兴趣的读者可以看 Reference 中的第一个链接看原文。在这篇文章中,Hoare 在文中用 CSP 来描述通信顺序进程能力,姑且认为这是一个虚构的编程语言。该语言描述了并发过程之间的交互作用。从历史上看,软件的进步主要依靠硬件的改进,这些改进可以使 CPU 更快,内存更大。Hoare 认识到,想通过硬件提高使得代码运行速度快 10 倍,需要付出 10 倍以上的机器资源。这并没有从根本改善问题。

1. 术语和一些例子

尽管并发相对于传统的顺序编程具有许多优势,但由于其会出错的性质,它未能获得广泛的欢迎。Hoare 借助 CSP 引入了一种精确的理论,可以在数学上保证程序摆脱并发的常见问题。Hoare 在他的 Learning CSP(这是计算机科学中引用第三多的神书!)一书中,使用“进程微积分”来表明可以处理死锁和不确定性,就像它们是普通进程中的最终事件一样。进程微积分是一种对并发系统进行数学化建模的方式,并且提供了代数法则来进行这些系统的变换来分析它们的不同属性,并发和效率。

为了防止数据被多线程破坏,Hoare 提出了临界区的概念。进程进入临界区后可以获得对共享数据的访问。在进入临界区之前,所有其他的进程必须验证和更新这一共享变量的值。退出临界区时,进程必须再次验证所有进程具有相同的值。

保持数据完整性的另一种技术是通过使用互斥信号量或互斥量。互斥锁是信号量的特定子类,它仅允许一个进程一次访问该变量。信号量是一个受限制的访问变量,它是防止并发中竞争的经典解决方案。其他尝试访问该互斥锁的进程将被阻止,并且必须等待直到当前进程释放该互斥锁。释放互斥锁后,只有一个等待的进程可以访问该变量,所有其他进程继续等待。

1970年代初期,Hoare 基于互斥量的概念开发了一种称为监视器的概念。根据 IBM 编写的 Java 编程语言 CSP 教程:

“A monitor is a body of code whose access is guarded by a mutex. Any process wishing to execute this code must acquire the associated mutex at the top of the code block and release it at the bottom. Because only one thread can own a mutex at a given time, this effectively ensures that only the owing thread can execute a monitor block of code.”

monitor 可以帮助防止数据被破坏和线程死锁。在 CSP 论文中为了说明清楚进程之间的通信,Hoare 利用 ?和 !号代表了输入和输出。!代表发送输入到一个进程,?号代表读取一个进程的输出。每个指令需要指定具体是一个输出变量(从一个进程中读取一个变量的情况),还是目的地(将输入发送到一个进程的情况)。一个进程的输出应该直接流向另一个进程的输入。

深入 Go 并发原语 — Channel 底层实现

上图是从 CSP 文章中截图的一些例子,Hoare 简单的举了下面这个例子:

[c:character; west?c ~ east!c] 

上述代码的意思是读取 west 输出的所有字符,然后把它们一个个的输出到 east 中。这个过程不断的重复,直到 west 终止。从描述上看,这一特性完完全全是 channel 的雏形。

2. 哲学家问题

文章的最后,回到了经典的哲学家问题。

深入 Go 并发原语 — Channel 底层实现

在哲学家问题中,Hoare 将 philosopher 的行为描述如下:

PHIL = *[... during ith lifetime ... --->,
THINK;
room!enter( );
fork(0!pickup( ); fork((/+ 1) rood 5)!pickup( );
EAT;
fork(i)!putdown( ); fork((/+ 1) mod 5)!putdown( );
room!exit( )
]

每个叉子由坐在两边的哲学家使用或者放下:

FORK =
*[phil(0?pickup( )--* phil(0?putdown( )
0phil((i - 1)rood 5)?pickup( ) --* phil((/- l) raod 5)?putdown( )
]

整个哲学家在房间中的行为可以描述为:

ROOM = occupancy:integer; occupancy .--- 0;
,[(i:0..4)phil(0?enter ( ) --* occupancy .--- occupancy + l
11(i:0..4)phil(0?exit ( ) --~ occupancy .--- occupancy - l
] 

决定如何向等待的进程分配资源的任务称为调度。Hoare 将调度分为两个事件:

  • processes 请求资源
  • 将资源分配给 processes

那么这个哲学家问题可以转换成 PHIL 和 FORK 这两个组件并发的过程:

[room::ROOM I [fork( i:0..4)::FORK I Iphil( i:0..4)::PHIL]. 

从请求到授予资源的时间就是等待时间。在 CSP 中,有几种技术可以防止无限的等待时间。

  • 限制资源使用并提高资源可用性。
  • 先进先出(FIFO)将资源分配给等待时间最长的进程。
  • 面包店算法Carnegie Melon. Bakery Algorithm

3. 缺陷

在确定性程序中,如果环境恒定,结果将是相同的。 由于并发基于非确定性,因此环境不会影响程序。给定所选的路径,程序则可以运行几次并收到不同的结果。为了确保并发程序的准确性,程序员必须能够在整体水平上考虑其程序的执行。

但是,尽管 Hoare 引入了正式的方法,但仍然缺少任何验证正确程序的证明方法。CSP 只能发现已知问题,而不能发现未知问题。虽然基于 CSP 的商业应用程序(例如ConAn)可以检测到错误的存在,但是不能检测没有错误的情况,(无法验证正确性)。尽管 CSP 为我们提供了编写可以避免常见并发错误的程序的工具,但是正确程序的证明仍然是 CSP 中尚未解决的领域。

4. 未来

CSP 在生物学和化学领域具有巨大的潜力,可以对自然界中的复杂系统进行建模。 由于该行业面临许多现存的逻辑问题,因此尚未在行业中广泛使用。在关于 CSP 开发 25 周年的会议上,Hoare 指出,尽管有许多由 Microsoft 资助的研究项目,但比尔·盖茨(Bill Gates)忽略了 Microsoft 何时能够将 CSP 的研究成果商业化的问题

Hoare 提醒他的听众,动态过程领域仍然需要更多的研究。当前,计算机科学界陷入了顺序思维的范式。随着 Hoare 建立正式的并发方法的基础,科学界已做好准备成为并行编程的下一个革命。

5. Go 并发哲学

在 Go 语言发布之前,很少有语言从底层为并发原语提供支持。大多数语言还是支持共享和内存访问同步到 CSP 的消息传递方法。Go 语言算是最早将 CSP 原则纳入其核心的语言之一。内存访问同步的方式并不是不好,只是在高并发的场景下有时候难以正确的使用,特别是在超大型,巨型的程序中。基于此,并发能力被认为是 Go 语言天生优势之一。追其根本,还是因为 Go 基于 CSP 创造出来的一系列易读,方便编写的并发原语。

Go 语言除了 CSP 并发原语以外,还支持通过内存访问同步。sync 与其他包中的结构体与方法可以让开发者创建 WaitGroup,互斥锁和读写锁,cond,once,sync.Pool。在 Go 语言的官方 FAQ 中,描述了如何选择这些并发原语:

为了尊重 mutex,sync 包实现了 mutex,但是我们希望 Go 语言的编程风格将会激励人们尝试更高等级的技巧。尤其是考虑构建你的程序,以便一次只有一个 goroutine 负责某个特定的数据。

不要通过共享内存进行通信建议通过通信来共享内存。(Do not communicate by sharing memory; instead, share memory by communicating)这是 Go 语言并发的哲学座右铭。相对于使用 sync.Mutex 这样的并发原语。虽然大多数锁的问题可以通过 channel 或者传统的锁两种方式之一解决,但是 Go 语言核心团队更加推荐使用 CSP 的方式。

深入 Go 并发原语 — Channel 底层实现

关于如何选择并发原语的问题,本文作为第一篇文章必然需要解释清楚。Go 中的并发原语主要分为 2 大类,一个是 sync 包里面的,另一个是 channel。sync 包里面主要是 WaitGroup,互斥锁和读写锁,cond,once,sync.Pool 这一类。在 2 种情况下推荐使用 sync 包:

  • 对性能要求极高的临界区
  • 保护某个结构内部状态和完整性

关于保护某个结构内部的状态和完整性。例如 Go 源码中如下代码:

var sum struct {
	sync.Mutex
	i int
}

//export Add
func Add(x int) {
	defer func() {
		recover()
	}()
	sum.Lock()
	sum.i += x
	sum.Unlock()
	var p *int
	*p = 2
}

sum 这个结构体不想将内部的变量暴露在结构体之外,所以使用 sync.Mutex 来保护线程安全。

相对于 sync 包,channel 也有 2 种情况:

  • 输出数据给其他使用方
  • 组合多个逻辑

输出数据给其他使用方的目的是转移数据的使用权。并发安全的实质是保证同时只有一个并发上下文拥有数据的所有权。channel 可以很方便的将数据所有权转给其他使用方。另一个优势是组合型。如果使用 sync 里面的锁,想实现组合多个逻辑并且保证并发安全,是比较困难的。但是使用 channel + select 实现组合逻辑实在太方便了。以上就是 CSP 的基本概念和何时选择 channel 的时机。下一章从 channel 基本数据结构开始详细分析 channel 底层源码实现。

以下代码基于 Go 1.16

二. 基本数据结构

channel 的底层源码和相关实现在 src/runtime/chan.go 中。

type hchan struct {
	qcount   uint           // 队列中所有数据总数
	dataqsiz uint           // 环形队列的 size
	buf      unsafe.Pointer // 指向 dataqsiz 长度的数组
	elemsize uint16         // 元素大小
	closed   uint32
	elemtype *_type         // 元素类型
	sendx    uint           // 已发送的元素在环形队列中的位置
	recvx    uint           // 已接收的元素在环形队列中的位置
	recvq    waitq          // 接收者的等待队列
	sendq    waitq          // 发送者的等待队列

	lock mutex
}

lock 锁保护 hchan 中的所有字段,以及此通道上被阻塞的 sudogs 中的多个字段。持有 lock 的时候,禁止更改另一个 G 的状态(特别是不要使 G 状态变成ready),因为这会因为堆栈 shrinking 而发生死锁。

深入 Go 并发原语 — Channel 底层实现

recvq 和 sendq 是等待队列,waitq 是一个双向链表:

type waitq struct {
	first *sudog
	last  *sudog
}

channel 最核心的数据结构是 sudog。sudog 代表了一个在等待队列中的 g。sudog 是 Go 中非常重要的数据结构,因为 g 与同步对象关系是多对多的。一个 g 可以出现在许多等待队列上,因此一个 g 可能有很多sudog。并且多个 g 可能正在等待同一个同步对象,因此一个对象可能有许多 sudog。sudog 是从特殊池中分配出来的。使用 acquireSudog 和 releaseSudog 分配和释放它们。

type sudog struct {

	g *g

	next *sudog
	prev *sudog
	elem unsafe.Pointer // 指向数据 (可能指向栈)

	acquiretime int64
	releasetime int64
	ticket      uint32

	isSelect bool
	success bool

	parent   *sudog     // semaRoot 二叉树
	waitlink *sudog     // g.waiting 列表或者 semaRoot
	waittail *sudog     // semaRoot
	c        *hchan     // channel
}

sudog 中所有字段都受 hchan.lock 保护。acquiretime、releasetime、ticket 这三个字段永远不会被同时访问。对 channel 来说,waitlink 只由 g 使用。对 semaphores 来说,只有在持有 semaRoot 锁的时候才能访问这三个字段。isSelect 表示 g 是否被选择,g.selectDone 必须进行 CAS 才能在被唤醒的竞争中胜出。success 表示 channel c 上的通信是否成功。如果 goroutine 在 channel c 上传了一个值而被唤醒,则为 true;如果因为 c 关闭而被唤醒,则为 false。

三. 创建 Channel

创建 channel 常见代码:

ch := make(chan int)

编译器编译上述代码,在检查 ir 节点时,根据节点 op 不同类型,进行不同的检查,如下源码:

func walkExpr1(n ir.Node, init *ir.Nodes) ir.Node {
	switch n.Op() {
	default:
		ir.Dump("walk", n)
		base.Fatalf("walkExpr: switch 1 unknown op %+v", n.Op())
		panic("unreachable")

	case ir.OMAKECHAN:
		n := n.(*ir.MakeExpr)
		return walkMakeChan(n, init)

	......
}

编译器会检查每一种类型,walkExpr1() 的实现就是一个 switch-case,函数末尾没有 return,因为每一个 case 都会 return 或者返回 panic。这样做是为了与存在类型断言的情况中返回的内容做区分。walk 具体处理 OMAKECHAN 类型节点的逻辑如下:

func walkMakeChan(n *ir.MakeExpr, init *ir.Nodes) ir.Node {
	size := n.Len
	fnname := "makechan64"
	argtype := types.Types[types.TINT64]

	if size.Type().IsKind(types.TIDEAL) || size.Type().Size() <= types.Types[types.TUINT].Size() {
		fnname = "makechan"
		argtype = types.Types[types.TINT]
	}

	return mkcall1(chanfn(fnname, 1, n.Type()), n.Type(), init, reflectdata.TypePtr(n.Type()), typecheck.Conv(size, argtype))
}

上述代码默认调用 makechan64() 函数。类型检查时如果 TIDEAL 大小在 int 范围内。将 TUINT 或 TUINTPTR 转换为 TINT 时出现大小溢出的情况,将在运行时在 makechan 中进行检查。如果在 make 函数中传入的 channel size 大小在 int 范围内,推荐使用 makechan()。因为 makechan() 在 32 位的平台上更快,用的内存更少。

makechan64() 和 makechan() 函数方法原型如下:

func makechan64(chanType *byte, size int64) (hchan chan any)
func makechan(chanType *byte, size int) (hchan chan any)

makechan64() 方法只是判断一下传入的入参 size 是否还在 int 范围之内:

func makechan64(t *chantype, size int64) *hchan {
	if int64(int(size)) != size {
		panic(plainError("makechan: size out of range"))
	}

	return makechan(t, int(size))
}

创建 channel 的主要实现在 makechan() 函数中:

func makechan(t *chantype, size int) *hchan {
	elem := t.elem

	// 编译器检查数据项大小不能超过 64KB
	if elem.size >= 1<<16 {
		throw("makechan: invalid channel element type")
	}
	// 检查对齐是否正确
	if hchanSize%maxAlign != 0 || elem.align > maxAlign {
		throw("makechan: bad alignment")
	}
    // 缓冲区大小检查,判断是否溢出
	mem, overflow := math.MulUintptr(elem.size, uintptr(size))
	if overflow || mem > maxAlloc-hchanSize || size < 0 {
		panic(plainError("makechan: size out of range"))
	}

	var c *hchan
	switch {
	case mem == 0:
		// 队列或者元素大小为 zero 时
		c = (*hchan)(mallocgc(hchanSize, nil, true))
		// Race 竞争检查利用这个地址来进行同步操作
		c.buf = c.raceaddr()
	case elem.ptrdata == 0:
		// 元素不包含指针时。一次分配 hchan 和 buf 的内存。
		c = (*hchan)(mallocgc(hchanSize+mem, nil, true))
		c.buf = add(unsafe.Pointer(c), hchanSize)
	default:
		// 元素包含指针时
		c = new(hchan)
		c.buf = mallocgc(mem, elem, true)
	}

    // 设置属性
	c.elemsize = uint16(elem.size)
	c.elemtype = elem
	c.dataqsiz = uint(size)
	lockInit(&c.lock, lockRankHchan)

	if debugChan {
		print("makechan: chan=", c, "; elemsize=", elem.size, "; dataqsiz=", size, "\n")
	}
	return c
}

上面这段 makechan() 代码主要目的是生成 *hchan 对象。重点关注 switch-case 中的 3 种情况:

  • 当队列或者元素大小为 0 时,调用 mallocgc() 在堆上为 channel 开辟一段大小为 hchanSize 的内存空间。
  • 当元素类型不是指针类型时,调用 mallocgc() 在堆上开辟为 channel 和底层 buf 缓冲区数组开辟一段大小为 hchanSize + mem 连续的内存空间。
  • 默认情况元素类型中有指针类型,调用 mallocgc() 在堆上分别为 channel 和 buf 缓冲区分配内存。

完成第一步的内存分配之后,再就是 hchan 数据结构其他字段的初始化和 lock 的初始化。值得说明的一点是,当存储在 buf 中的元素不包含指针时,Hchan 中也不包含 GC 关心的指针。buf 指向一段相同元素类型的内存,elemtype 固定不变。SudoG 是从它们自己的线程中引用的,因此垃圾回收的时候无法回收它们。受到垃圾回收器的限制,指针类型的缓冲 buf 需要单独分配内存。官方在这里加了一个 TODO,垃圾回收的时候这段代码逻辑需要重新考虑。

就是因为 channel 的创建全部调用的 mallocgc(),在堆上开辟的内存空间,channel 本身会被 GC 自动回收。有了这一性质,所以才有了下文关闭 channel 中优雅关闭的方法。

四. 发送数据

向 channel 中发送数据常见代码:

ch <- 1

编译器编译上述代码,在检查 ir 节点时,根据节点 op 不同类型,进行不同的检查,如下源码:

func walkExpr1(n ir.Node, init *ir.Nodes) ir.Node {
	switch n.Op() {
	default:
		ir.Dump("walk", n)
		base.Fatalf("walkExpr: switch 1 unknown op %+v", n.Op())
		panic("unreachable")

	case ir.OSEND:
		n := n.(*ir.SendStmt)
		return walkSend(n, init)

	......
}

walkExpr1() 函数在创建 channel 提到了,这里不再赘述。操作类型是 OSEND,对应调用 walkSend() 函数:

func walkSend(n *ir.SendStmt, init *ir.Nodes) ir.Node {
	n1 := n.Value
	n1 = typecheck.AssignConv(n1, n.Chan.Type().Elem(), "chan send")
	n1 = walkExpr(n1, init)
	n1 = typecheck.NodAddr(n1)
	return mkcall1(chanfn("chansend1", 2, n.Chan.Type()), nil, init, n.Chan, n1)
}

// entry point for c <- x from compiled code
//go:nosplit
func chansend1(c *hchan, elem unsafe.Pointer) {
	chansend(c, elem, true, getcallerpc())
}

walkSend() 函数中主要逻辑调用了 chansend1(),而 chansend1() 只是 chansend() 的“外壳”。所以 channel 发送数据的核心实现在 chansend() 中。根据 channel 的阻塞和唤醒,又可以分为 2 部分逻辑代码。接下来笔者讲 chansend() 代码拆成 4 部分详细分析。

1. 异常检查

chansend() 函数一开始先进行异常检查:

func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
    // 判断 channel 是否为 nil
	if c == nil {
		if !block {
			return false
		}
		gopark(nil, nil, waitReasonChanSendNilChan, traceEvGoStop, 2)
		throw("unreachable")
	}

	if debugChan {
		print("chansend: chan=", c, "\n")
	}

	if raceenabled {
		racereadpc(c.raceaddr(), callerpc, funcPC(chansend))
	}

	// 简易快速的检查
	if !block && c.closed == 0 && full(c) {
		return false
	}
......
}

chansend() 一上来对 channel 进行检查,如果被 GC 回收了会变为 nil。朝一个为 nil 的 channel 发送数据会发生阻塞。gopark 会引发以 waitReasonChanSendNilChan 为原因的休眠,并抛出 unreachable 的 fatal error。当 channel 不为 nil,再开始检查在没有获取锁的情况下会导致发送失败的非阻塞操作。

当 channel 不为 nil,并且 channel 没有 close 时,还需要检查此时 channel 是否做好发送的准备,即判断 full(c)

func full(c *hchan) bool {
	if c.dataqsiz == 0 {
		// 假设指针读取是近似原子性的
		return c.recvq.first == nil
	}
	// 假设读取 uint 是近似原子性的
	return c.qcount == c.dataqsiz
}

full() 方法作用是判断在 channel 上发送是否会阻塞(即通道已满)。它读取单个字节大小的可变状态(recvq.first 和 qcount),尽管答案可能在一瞬间是 true,但在调用函数收到返回值时,正确的结果可能发生了更改。值得注意的是 dataqsiz 字段,它在创建完 channel 以后是不可变的,因此它可以安全的在任意时刻读取。

回到 chansend() 异常检查中。一个已经 close 的 channel 是不可能从“准备发送”的状态变为“未准备好发送”的状态。所以在检查完 channel 是否 close 以后,就算 channel close 了,也不影响此处检查的结果。可能有读者疑惑,“能不能把检查顺序倒一倒?先检查是否 full(),再检查是否 close?”。这样倒过来确实能保证检查 full() 的时候,channel 没有 close。但是这种做法也没有实质性的改变。channel 依旧可以在检查完 close 以后再关闭。其实我们依赖的是 chanrecv() 和 closechan() 这两个方法在锁释放后,它们更新这个线程 c.close 和 full() 的结果视图。

2. 同步发送

channel 异常状态检查以后,接下来的代码是发送的逻辑。

func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
......

	lock(&c.lock)

	if c.closed != 0 {
		unlock(&c.lock)
		panic(plainError("send on closed channel"))
	}

	if sg := c.recvq.dequeue(); sg != nil {
		send(c, sg, ep, func() { unlock(&c.lock) }, 3)
		return true
	}

......

}

在发送之前,先上锁,保证线程安全。并再一次检查 channel 是否关闭。如果关闭则抛出 panic。加锁成功并且 channel 未关闭,开始发送。如果有正在阻塞等待的接收方,通过 dequeue() 取出头部第一个非空的 sudog,调用 send() 函数:

func send(c *hchan, sg *sudog, ep unsafe.Pointer, unlockf func(), skip int) {
	if sg.elem != nil {
		sendDirect(c.elemtype, sg, ep)
		sg.elem = nil
	}
	gp := sg.g
	unlockf()
	gp.param = unsafe.Pointer(sg)
	sg.success = true
	if sg.releasetime != 0 {
		sg.releasetime = cputicks()
	}
	goready(gp, skip+1)
}

send() 函数主要完成了 2 件事:

    1. 调用 sendDirect() 函数将数据拷贝到了接收变量的内存地址上
    1. 调用 goready() 将等待接收的阻塞 goroutine 的状态从 Gwaiting 或者 Gscanwaiting 改变成 Grunnable。下一轮调度时会唤醒这个接收的 goroutine。

深入 Go 并发原语 — Channel 底层实现

这里重点说说 goready() 的实现。理解了它的源码,就能明白为什么往 channel 中发送数据并非立即可以从接收方获取到。

func goready(gp *g, traceskip int) {
	systemstack(func() {
		ready(gp, traceskip, true)
	})
}

func ready(gp *g, traceskip int, next bool) {
......

	casgstatus(gp, _Gwaiting, _Grunnable)
	runqput(_g_.m.p.ptr(), gp, next)
	wakep()
	releasem(mp)
}

在 runqput() 函数的作用是把 g 绑定到本地可运行的队列中。此处 next 传入的是 true,将 g 插入到 runnext 插槽中,等待下次调度便立即运行。因为这一点导致了虽然 goroutine 保证了线程安全,但是在读取数据方面比数组慢了几百纳秒。

Read Channel Slice
Time x * 100 * nanosecond 0
Thread safe Yes No

所以在写测试用例的某些时候,需要考虑到这个微弱的延迟,可以适当加 sleep()。再比如刷 LeetCode 题目的时候,并非无脑使用 goroutine 就能带来 runtime 的提升,例如 509. Fibonacci Number,感兴趣的同学可以用 goroutine 来写一写这道题,笔者这里实现了goroutine 解法,性能方面完全不如数组的解法。

3. 异步发送

如果初始化 channel 时创建的带缓冲区的异步 Channel,当接收者队列为空时,这是会进入到异步发送逻辑:

func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
......

	if c.qcount < c.dataqsiz {
		qp := chanbuf(c, c.sendx)
		if raceenabled {
			racenotify(c, c.sendx, nil)
		}
		typedmemmove(c.elemtype, qp, ep)
		c.sendx++
		if c.sendx == c.dataqsiz {
			c.sendx = 0
		}
		c.qcount++
		unlock(&c.lock)
		return true
	}
	
......
}

如果 qcount 还没有满,则调用 chanbuf() 获取 sendx 索引的元素指针值。调用 typedmemmove() 方法将发送的值拷贝到缓冲区 buf 中。拷贝完成,需要维护 sendx 索引下标值和 qcount 个数。这里将 buf 缓冲区设计成环形的,索引值如果到了队尾,下一个位置重新回到队头。

深入 Go 并发原语 — Channel 底层实现

至此,两种直接发送的逻辑分析完了,接下来是发送时 channel 阻塞的情况。

4. 阻塞发送

当 channel 处于打开状态,但是没有接收者,并且没有 buf 缓冲队列或者 buf 队列已满,这时 channel 会进入阻塞发送。

func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
......

	if !block {
		unlock(&c.lock)
		return false
	}
	
	gp := getg()
	mysg := acquireSudog()
	mysg.releasetime = 0
	if t0 != 0 {
		mysg.releasetime = -1
	}
	mysg.elem = ep
	mysg.waitlink = nil
	mysg.g = gp
	mysg.isSelect = false
	mysg.c = c
	gp.waiting = mysg
	gp.param = nil
	c.sendq.enqueue(mysg)
	atomic.Store8(&gp.parkingOnChan, 1)
	gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanSend, traceEvGoBlockSend, 2)
	KeepAlive(ep)
......
}
  • 调用 getg() 方法获取当前 goroutine 的指针,用于绑定给一个 sudog。
  • 调用 acquireSudog() 方法获取一个 sudog,可能是新建的 sudog,也有可能是从缓存中获取的。设置好 sudog 要发送的数据和状态。比如发送的 Channel、是否在 select 中和待发送数据的内存地址等等。
  • 调用 c.sendq.enqueue 方法将配置好的 sudog 加入待发送的等待队列。
  • 设置原子信号。当栈要 shrink 收缩时,这个标记代表当前 goroutine 还 parking 停在某个 channel 中。在 g 状态变更与设置 activeStackChans 状态这两个时间点之间的时间窗口进行栈 shrink 收缩是不安全的,所以需要设置这个原子信号。
  • 调用 gopark 方法挂起当前 goroutine,状态为 waitReasonChanSend,阻塞等待 channel。
  • 最后,KeepAlive() 确保发送的值保持活动状态,直到接收者将其复制出来。 sudog 具有指向堆栈对象的指针,但 sudog 不能被当做堆栈跟踪器的 root。发送的数值是分配在堆上,这样可以避免被 GC 回收。

深入 Go 并发原语 — Channel 底层实现

这里提一下 sudog 的二级缓存复用体系。在 acquireSudog() 方法中:

func acquireSudog() *sudog {
	mp := acquirem()
	pp := mp.p.ptr()
	// 如果本地缓存为空
	if len(pp.sudogcache) == 0 {
		lock(&sched.sudoglock)
		// 首先尝试将全局中央缓存存一部分到本地
		for len(pp.sudogcache) < cap(pp.sudogcache)/2 && sched.sudogcache != nil {
			s := sched.sudogcache
			sched.sudogcache = s.next
			s.next = nil
			pp.sudogcache = append(pp.sudogcache, s)
		}
		unlock(&sched.sudoglock)
		// 如果全局中央缓存是空的,则 allocate 一个新的
		if len(pp.sudogcache) == 0 {
			pp.sudogcache = append(pp.sudogcache, new(sudog))
		}
	}
	// 从尾部提取,并调整本地缓存
	n := len(pp.sudogcache)
	s := pp.sudogcache[n-1]
	pp.sudogcache[n-1] = nil
	pp.sudogcache = pp.sudogcache[:n-1]
	if s.elem != nil {
		throw("acquireSudog: found s.elem != nil in cache")
	}
	releasem(mp)
	return s
}

上述代码涉及到 2 个新的重要的结构体,由于这 2 个结构体特别复杂,暂时此处只展示和 acquireSudog() 有关的部分:

type p struct {
......
	sudogcache []*sudog
	sudogbuf   [128]*sudog
......
}

type schedt struct {
......
	sudoglock  mutex
	sudogcache *sudog
......
}

sched.sudogcache 是全局中央缓存,可以认为它是“一级缓存”,它会在 GC 垃圾回收执行 clearpools 被清理。p.sudogcache 可以认为它是“二级缓存”,是本地缓存不会被 GC 清理掉。

chansend 最后的代码逻辑是当 goroutine 唤醒以后,解除阻塞的状态:

func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
......

	if mysg != gp.waiting {
		throw("G waiting list is corrupted")
	}
	gp.waiting = nil
	gp.activeStackChans = false
	closed := !mysg.success
	gp.param = nil
	if mysg.releasetime > 0 {
		blockevent(mysg.releasetime-t0, 2)
	}
	mysg.c = nil
	releaseSudog(mysg)
	if closed {
		if c.closed == 0 {
			throw("chansend: spurious wakeup")
		}
		panic(plainError("send on closed channel"))
	}
	return true
}

sudog 算是对 g 的一种封装,里面包含了 g,要发送的数据以及相关的状态。goroutine 被唤醒后会完成 channel 的阻塞数据发送。发送完最后进行基本的参数检查,解除 channel 的绑定并释放 sudog。

func releaseSudog(s *sudog) {
	if s.elem != nil {
		throw("runtime: sudog with non-nil elem")
	}
	if s.isSelect {
		throw("runtime: sudog with non-false isSelect")
	}
	if s.next != nil {
		throw("runtime: sudog with non-nil next")
	}
	if s.prev != nil {
		throw("runtime: sudog with non-nil prev")
	}
	if s.waitlink != nil {
		throw("runtime: sudog with non-nil waitlink")
	}
	if s.c != nil {
		throw("runtime: sudog with non-nil c")
	}
	gp := getg()
	if gp.param != nil {
		throw("runtime: releaseSudog with non-nil gp.param")
	}
	// 防止 rescheduling 到了其他的 P
	mp := acquirem() 
	pp := mp.p.ptr()
	// 如果本地缓存已满
	if len(pp.sudogcache) == cap(pp.sudogcache) {
		// 转移一半本地缓存到全局中央缓存中
		var first, last *sudog
		for len(pp.sudogcache) > cap(pp.sudogcache)/2 {
			n := len(pp.sudogcache)
			p := pp.sudogcache[n-1]
			pp.sudogcache[n-1] = nil
			pp.sudogcache = pp.sudogcache[:n-1]
			if first == nil {
				first = p
			} else {
				last.next = p
			}
			last = p
		}
		lock(&sched.sudoglock)
		// 将提取的链表挂载到全局中央缓存中
		last.next = sched.sudogcache
		sched.sudogcache = first
		unlock(&sched.sudoglock)
	}
	pp.sudogcache = append(pp.sudogcache, s)
	releasem(mp)
}

releaseSudog() 虽然释放了 sudog 的内存,但是它会被 p.sudogcache 这个“二级缓存”缓存起来。

chansend() 函数最后返回 true 表示成功向 Channel 发送了数据。

5. 小结

关于 channel 发送的源码实现已经分析完了,针对 channel 各个状态做一个小结。

Channel Status Result
Write nil 阻塞
Write 打开但填满 阻塞
Write 打开但未满 成功写入值
Write 关闭 panic
Write 只读 Compile Error

channel 发送过程中包含 2 次有关 goroutine 调度过程:

    1. 当接收队列中存在 sudog 可以直接发送数据时,执行 goready()将 g 插入 runnext 插槽中,状态从 Gwaiting 或者 Gscanwaiting 改变成 Grunnable,等待下次调度便立即运行。
    1. 当 channel 阻塞时,执行 gopark() 将 g 阻塞,让出 cpu 的使用权。

需要强调的是,通道并不提供跨 goroutine 的数据访问保护机制。如果通过通道传输数据的一份副本,那么每个 goroutine 都持有一份副本,各自对自己的副本做修改是安全的。当传输的是指向数据的指针时,如果读和写是由不同的 goroutine 完成的,那么每个 goroutine 依旧需要额外的同步操作。

五. 接收数据

从 channel 中接收数据常见代码:

tmp := <-ch
tmp, ok := <-ch

先看等号左边赋值一个值的情况,编译器编译上述代码,在检查 ir 节点时,根据节点 op 不同类型,进行不同的检查,如下源码:

// walkAssign walks an OAS (AssignExpr) or OASOP (AssignOpExpr) node.
func walkAssign(init *ir.Nodes, n ir.Node) ir.Node {
......

	switch as.Y.Op() {
	default:
		as.Y = walkExpr(as.Y, init)

	case ir.ORECV:
		// x = <-c; as.Left is x, as.Right.Left is c.
		// order.stmt made sure x is addressable.
		recv := as.Y.(*ir.UnaryExpr)
		recv.X = walkExpr(recv.X, init)

		n1 := typecheck.NodAddr(as.X)
		r := recv.X // the channel
		return mkcall1(chanfn("chanrecv1", 2, r.Type()), nil, init, r, n1)
		
......
}

as 是入参 ir 节点强制转化成 AssignStmt 类型。AssignStmt 这个类型是赋值的一个说明:

type AssignStmt struct {
	miniStmt
	X   Node
	Def bool
	Y   Node
}

Y 是等号右边的值,它是 Node 类型,里面包含 op 类型。walkAssign 是检查赋值语句,如果 Y.Op() 是 ir.ORECV 类型,说明是 channel 接收的过程。调用 chanrecv1() 函数。as.X 是赋值语句左边的元素,它是接收 channel 中的值,所以它必须是可寻址的。

当从 channel 中读取数据等号左边是 2 个值的时候,编译器在 walkExpr1 中检查这个赋值语句:

func walkExpr1(n ir.Node, init *ir.Nodes) ir.Node {
	switch n.Op() {
	default:
		ir.Dump("walk", n)
		base.Fatalf("walkExpr: switch 1 unknown op %+v", n.Op())
		panic("unreachable")
......

	case ir.OAS2RECV:
		n := n.(*ir.AssignListStmt)
		return walkAssignRecv(init, n)
		
......
}

n.Op() 是 ir.OAS2RECV 类型,将 n 强转成 AssignListStmt 类型:

type AssignListStmt struct {
	miniStmt
	Lhs Nodes
	Def bool
	Rhs Nodes
}

AssignListStmt 和 AssignStmt 作用一样,只是 AssignListStmt 表示等号两边赋值语句不再是一个对象,而是多个。回到 walkExpr1() 中,如果是 ir.OAS2RECV 类型,调用 walkAssignRecv() 继续检查。

func walkAssignRecv(init *ir.Nodes, n *ir.AssignListStmt) ir.Node {
	init.Append(ir.TakeInit(n)...)
	r := n.Rhs[0].(*ir.UnaryExpr) // recv
	walkExprListSafe(n.Lhs, init)
	r.X = walkExpr(r.X, init)
	var n1 ir.Node
	if ir.IsBlank(n.Lhs[0]) {
		n1 = typecheck.NodNil()
	} else {
		n1 = typecheck.NodAddr(n.Lhs[0])
	}
	fn := chanfn("chanrecv2", 2, r.X.Type())
	ok := n.Lhs[1]
	call := mkcall1(fn, types.Types[types.TBOOL], init, r.X, n1)
	return typecheck.Stmt(ir.NewAssignStmt(base.Pos, ok, call))
}

Lhs[0] 是实际接收 channel 值的对象,Lhs[1] 是赋值语句左边第二个 bool 值。赋值语句右边由于只有一个 channel,所以这里 Rhs 也只用到了 Rhs[0]。

//go:nosplit
func chanrecv1(c *hchan, elem unsafe.Pointer) {
	chanrecv(c, elem, true)
}

//go:nosplit
func chanrecv2(c *hchan, elem unsafe.Pointer) (received bool) {
	_, received = chanrecv(c, elem, true)
	return
}

综合上述的分析,2 种不同的 channel 接收方式会转换成 runtime.chanrecv1 和 runtime.chanrecv2 两种不同函数的调用,但是最终核心逻辑还是在 runtime.chanrecv 中。

1. 异常检查

chanrecv() 函数一开始先进行异常检查:

func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
	if debugChan {
		print("chanrecv: chan=", c, "\n")
	}

	if c == nil {
		if !block {
			return
		}
		gopark(nil, nil, waitReasonChanReceiveNilChan, traceEvGoStop, 2)
		throw("unreachable")
	}

	// 简易快速的检查
	if !block && empty(c) {
		if atomic.Load(&c.closed) == 0 {
			return
		}
		if empty(c) {
			// channel 不可逆的关闭了且为空
			if raceenabled {
				raceacquire(c.raceaddr())
			}
			if ep != nil {
				typedmemclr(c.elemtype, ep)
			}
			return true, false
		}
	}

chanrecv() 一上来对 channel 进行检查,如果被 GC 回收了会变为 nil。从一个为 nil 的 channel 中接收数据会发生阻塞。gopark 会引发以 waitReasonChanReceiveNilChan 为原因的休眠,并抛出 unreachable 的 fatal error。当 channel 不为 nil,再开始检查在没有获取锁的情况下会导致接收失败的非阻塞操作。

这里进行的简易快速的检查,检查中状态不能发生变化。这一点和 chansend() 函数有区别。在 chansend() 简易快速的检查中,改变顺序对检查结果无太大影响,但是此处如果检查过程中状态发生变化,如果发生了 racing,检查结果会出现完全相反的错误的结果。例如以下这种情况:channel 在第一个和第二个 if 检查时是打开的且非空,于是在第二个 if 里面 return。但是 return 的瞬间, channel 关闭且空。这样判断出来认为 channel 是打开的且非空。明显是错误的结果,实际上 channel 是关闭且空的。同理检查是否为空的时候也会发生状态反转。为了防止错误的检查结果,c.closed 和 empty() 都必须使用原子检查。

func empty(c *hchan) bool {
	// c.dataqsiz 是不可变的
	if c.dataqsiz == 0 {
		return atomic.Loadp(unsafe.Pointer(&c.sendq.first)) == nil
	}
	return atomic.Loaduint(&c.qcount) == 0
}

这里总共检查了 2 次 empty(),因为第一次检查时, channel 可能还没有关闭,但是第二次检查的时候关闭了,在 2 次检查之间可能有待接收的数据到达了。所以需要 2 次 empty() 检查。

不过就算按照上述源码检查,细心的读者可能还会举出一个反例,例如,关闭一个已经阻塞的同步的 channel,最开始的 !block && empty(c) 为 false,会跳过这个检查。这种情况不能算在正常 chanrecv() 里面。上述是不获取锁的情况检查会接收失败的情况。接下来在获取锁的情况下再次检查一遍异常情况。

func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
......
	lock(&c.lock)

	if c.closed != 0 && c.qcount == 0 {
		if raceenabled {
			raceacquire(c.raceaddr())
		}
		unlock(&c.lock)
		if ep != nil {
			typedmemclr(c.elemtype, ep)
		}
		return true, false
	}
......

如果 channel 已经关闭且不存在缓存数据了,则清理 ep 指针中的数据并返回。这里也是从已经关闭的 channel 中读数据,读出来的是该类型零值的原因。

2. 同步接收

同 chansend 逻辑类似,检查完异常情况,紧接着是同步接收。

func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
......

	if sg := c.sendq.dequeue(); sg != nil {
		recv(c, sg, ep, func() { unlock(&c.lock) }, 3)
		return true, true
	}
......

在 channel 的发送队列中找到了等待发送的 goroutine。取出队头等待的 goroutine。如果缓冲区的大小为 0,则直接从发送方接收值。否则,对应缓冲区满的情况,从队列的头部接收数据,发送者的值添加到队列的末尾(此时队列已满,因此两者都映射到缓冲区中的同一个下标)。同步接收的核心逻辑见下面 recv() 函数:

func recv(c *hchan, sg *sudog, ep unsafe.Pointer, unlockf func(), skip int) {
	if c.dataqsiz == 0 {
		if raceenabled {
			racesync(c, sg)
		}
		if ep != nil {
			// 从 sender 里面拷贝数据
			recvDirect(c.elemtype, sg, ep)
		}
	} else {
	    // 这里对应 buf 满的情况
		qp := chanbuf(c, c.recvx)
		if raceenabled {
			racenotify(c, c.recvx, nil)
			racenotify(c, c.recvx, sg)
		}
		// 将数据从 buf 中拷贝到接收者内存地址中
		if ep != nil {
			typedmemmove(c.elemtype, ep, qp)
		}
		// 将数据从 sender 中拷贝到 buf 中
		typedmemmove(c.elemtype, qp, sg.elem)
		c.recvx++
		if c.recvx == c.dataqsiz {
			c.recvx = 0
		}
		c.sendx = c.recvx // c.sendx = (c.sendx+1) % c.dataqsiz
	}
	sg.elem = nil
	gp := sg.g
	unlockf()
	gp.param = unsafe.Pointer(sg)
	sg.success = true
	if sg.releasetime != 0 {
		sg.releasetime = cputicks()
	}
	goready(gp, skip+1)
}

需要注意的是由于有发送者在等待,所以如果存在缓冲区,那么缓冲区一定是满的。这个情况对应发送阶段阻塞发送的情况,如果缓冲区还有空位,发送的数据直接放入缓冲区,只有当缓冲区满了,才会打包成 sudog,插入到 sendq 队列中等待调度。注意理解这一情况。

接收时主要分为 2 种情况,有缓冲且 buf 满和无缓冲的情况:

  • 无缓冲。ep 发送数据不为 nil,调用 recvDirect() 将发送队列中 sudog 存储的 ep 数据直接拷贝到接收者的内存地址中。

深入 Go 并发原语 — Channel 底层实现

  • 有缓冲并且 buf 满。有 2 次 copy 操作,先将队列中 recvx 索引下标的数据拷贝到接收方的内存地址,再将发送队列头的数据拷贝到缓冲区中,释放一个 sudog 阻塞的 goroutine。

    有缓冲且 buf 满的情况需要注意,取数据从缓冲队列头取出,发送的数据放在队列尾部,由于 buf 装满,取出的 recvx 指针和发送的 sendx 指针指向相同的下标。

深入 Go 并发原语 — Channel 底层实现

最后调用 goready() 将等待接收的阻塞 goroutine 的状态从 Gwaiting 或者 Gscanwaiting 改变成 Grunnable。下一轮调度时会唤醒这个发送的 goroutine。这部分逻辑和同步发送中一致,关于 goready() 底层实现的代码不在赘述。

3. 异步接收

如果 Channel 的缓冲区中包含一些数据时,从 Channel 中接收数据会直接从缓冲区中 recvx 的索引位置中取出数据进行处理:

func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
......

	if c.qcount > 0 {
		// 直接从队列中接收
		qp := chanbuf(c, c.recvx)
		if raceenabled {
			racenotify(c, c.recvx, nil)
		}
		if ep != nil {
			typedmemmove(c.elemtype, ep, qp)
		}
		typedmemclr(c.elemtype, qp)
		c.recvx++
		if c.recvx == c.dataqsiz {
			c.recvx = 0
		}
		c.qcount--
		unlock(&c.lock)
		return true, true
	}

	if !block {
		unlock(&c.lock)
		return false, false
	}
......

上述代码比较简单,如果接收数据的内存地址 ep 不为空,则调用 runtime.typedmemmove() 将缓冲区内的数据拷贝到内存中,并通过 typedmemclr() 清除队列中的数据。

深入 Go 并发原语 — Channel 底层实现

维护 recvx 下标,如果移动到了环形队列的队尾,下标需要回到队头。最后减少 qcount 计数器并释放持有 Channel 的锁。

4. 阻塞接收

如果 channel 发送队列上没有待发送的 goroutine,并且缓冲区也没有数据时,将会进入到最后一个阶段阻塞接收:

func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
......

	gp := getg()
	mysg := acquireSudog()
	mysg.releasetime = 0
	if t0 != 0 {
		mysg.releasetime = -1
	}
	mysg.elem = ep
	mysg.waitlink = nil
	gp.waiting = mysg
	mysg.g = gp
	mysg.isSelect = false
	mysg.c = c
	gp.param = nil
	c.recvq.enqueue(mysg)
	atomic.Store8(&gp.parkingOnChan, 1)
	gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanReceive, traceEvGoBlockRecv, 2)
......
  • 调用 getg() 方法获取当前 goroutine 的指针,用于绑定给一个 sudog。
  • 调用 acquireSudog() 方法获取一个 sudog,可能是新建的 sudog,也有可能是从缓存中获取的。设置好 sudog 要发送的数据和状态。比如发送的 Channel、是否在 select 中和待发送数据的内存地址等等。
  • 调用 c.recvq.enqueue 方法将配置好的 sudog 加入待发送的等待队列。
  • 设置原子信号。当栈要 shrink 收缩时,这个标记代表当前 goroutine 还 parking 停在某个 channel 中。在 g 状态变更与设置 activeStackChans 状态这两个时间点之间的时间窗口进行栈 shrink 收缩是不安全的,所以需要设置这个原子信号。
  • 调用 gopark 方法挂起当前 goroutine,状态为 waitReasonChanReceive,阻塞等待 channel。

深入 Go 并发原语 — Channel 底层实现

上面这段代码与 chansend() 中阻塞发送几乎完全一致,区别在于最后一步没有 KeepAlive(ep)。

func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
......

	// 被唤醒
	if mysg != gp.waiting {
		throw("G waiting list is corrupted")
	}
	gp.waiting = nil
	gp.activeStackChans = false
	if mysg.releasetime > 0 {
		blockevent(mysg.releasetime-t0, 2)
	}
	success := mysg.success
	gp.param = nil
	mysg.c = nil
	releaseSudog(mysg)
	return true, success
}

goroutine 被唤醒后会完成 channel 的阻塞数据接收。接收完最后进行基本的参数检查,解除 channel 的绑定并释放 sudog。

5. 小结

关于 channel 接收的源码实现已经分析完了,针对 channel 各个状态做一个小结。

Channel status Result
Read nil 阻塞
Read 打开且非空 读取到值
Read 打开但为空 阻塞
Read 关闭 <默认值>, false
Read 只读 Compile Error

chanrecv 的返回值有几种情况:

tmp, ok := <-ch
Channel status Selected Received
nil false false
打开且非空 true true
打开但为空 false false
关闭且返回值是零值 true false

received 值会传递给读取 channel 外部的 bool 值 ok,selected 值不会被外部使用。

channel 接收过程中包含 2 次有关 goroutine 调度过程:

  1. 当 channel 为 nil 时,执行 gopark() 挂起当前的 goroutine。
  2. 当发送队列中存在 sudog 可以直接接收数据时,执行 goready()将 g 插入 runnext 插槽中,状态从 Gwaiting 或者 Gscanwaiting 改变成 Grunnable,等待下次调度便立即运行。
  3. 当 channel 缓冲区为空,且没有发送者时,这时 channel 阻塞,执行 gopark() 将 g 阻塞,让出 cpu 的使用权并等待调度器的调度。

六. 关闭 Channel

关于 channel 常见代码:

close(ch)

编译器会将其转换为 runtime.closechan() 方法。

1. 异常检查

func closechan(c *hchan) {
	if c == nil {
		panic(plainError("close of nil channel"))
	}

	lock(&c.lock)
	if c.closed != 0 {
		unlock(&c.lock)
		panic(plainError("close of closed channel"))
	}

	if raceenabled {
		callerpc := getcallerpc()
		racewritepc(c.raceaddr(), callerpc, funcPC(closechan))
		racerelease(c.raceaddr())
	}
	
	c.closed = 1
......
}

关闭一个 channel 有 2 点需要注意,当 Channel 是一个 nil 空指针或者关闭一个已经关闭的 channel 时,Go 语言运行时都会直接 panic。上述 2 种情况都不存在时,标记 channel 状态为 close。

2. 释放所有 readers 和 writers

关闭 channel 的主要工作是释放所有的 readers 和 writers。

func closechan(c *hchan) {
......
	var glist gList

	for {
		sg := c.recvq.dequeue()
		if sg == nil {
			break
		}
		if sg.elem != nil {
			typedmemclr(c.elemtype, sg.elem)
			sg.elem = nil
		}
		if sg.releasetime != 0 {
			sg.releasetime = cputicks()
		}
		gp := sg.g
		gp.param = unsafe.Pointer(sg)
		sg.success = false
		if raceenabled {
			raceacquireg(gp, c.raceaddr())
		}
		glist.push(gp)
	}
......
}

上述代码是回收接收者的 sudog。将所有的接收者 readers 的 sudog 等待队列(recvq)加入到待清除队列 glist 中。注意这里是先回收接收者。就算从一个 close 的 channel 中读取值,不会发生 panic,顶多读到一个默认零值。

func closechan(c *hchan) {
......

	for {
		sg := c.sendq.dequeue()
		if sg == nil {
			break
		}
		sg.elem = nil
		if sg.releasetime != 0 {
			sg.releasetime = cputicks()
		}
		gp := sg.g
		gp.param = unsafe.Pointer(sg)
		sg.success = false
		if raceenabled {
			raceacquireg(gp, c.raceaddr())
		}
		glist.push(gp)
	}
	unlock(&c.lock)
......
}

再回收发送者 writers。回收步骤和回收接收者是完全一致的,将发送者的等待队列 sendq 中的 sudog 放入待清除队列 glist 中。注意这里可能会产生 panic。在第四章发送数据中分析过,往一个 close 的 channel 中发送数据,会产生 panic,这里不再赘述。

深入 Go 并发原语 — Channel 底层实现

3. 协程调度

最后一步更改 goroutine 的状态。

func closechan(c *hchan) {
......
	for !glist.empty() {
		gp := glist.pop()
		gp.schedlink = 0
		goready(gp, 3)
	}
......
}

最后会为所有被阻塞的 goroutine 调用 goready 触发调度。将所有 glist 中的 goroutine 状态从 _Gwaiting 设置为 _Grunnable 状态,等待调度器的调度。

4. 优雅关闭

“Channel 有几种优雅的关闭方法?” 这种问题常常出现在面试题中,究其原因是因为 Channel 创建容易,但是关闭“不易”:

  • 在不改变 Channel 自身状态的条件下,无法知道它是否已经关闭。“不易”之一,关闭时机未知。
  • 如果一个 Channel 已经关闭,重复关闭 Channel 会导致 panic。“不易”之二,不能无脑关闭。
  • 往一个 close 的 Channel 内写数据,也会导致 panic。“不易”之三,写数据之前也需要关注是否 close 的状态。
Channel Status Result
close nil panic
close 打开且非空 关闭 Channel;读取成功,直到 Channel 耗尽数据,然后读取产生值的默认值
close 打开但为空 关闭 Channel;读到生产者的默认值
close 关闭 panic
close 只读 Compile Error

那究竟什么时候关闭 Channel 呢?由上面三个“不易”,可以浓缩为 2 点:

  • 不能简单的从消费者侧关闭 Channel。
  • 如果有多个生产者,它们不能关闭 Channel。

解释一下这 2 个问题。第一个问题,消费者不知道 Channel 何时该关闭。如果关闭了已经关闭的 Channel 会导致 panic。而且分布式应用通常有多个消费者,每个消费者的行为一致,这么多消费者都尝试关闭 Channel 必然会导致 panic。第二个问题,如果有多个生产者往 Channel 内写入数据,这些生产者的行为逻辑也都一致,如果其中一个生产者关闭了 Channel,其他的生产者还在往里写,这个时候会 panic。所以为了防止 panic,必须解决上面这 2 个问题。

关闭 Channel 的方式就 2 种:

  • Context
  • done channel

Context 的方式在本篇文章不详细展开,详细的可以查看笔者 Context 的那篇文章。本节聊聊 done channel 的做法。假设有多个生产者,有多个消费者。在生产者和消费者之间增加一个额外的辅助控制 channel,用来传递关闭信号。

type session struct {
	done     chan struct{}
	doneOnce sync.Once
	data     chan int
}

func (sess *session) Serve() {
	go sess.loopRead()
	sess.loopWrite()
}

func (sess *session) loopRead() {
	defer func() {
		if err := recover(); err != nil {
			sess.doneOnce.Do(func() { close(sess.done) })
		}
	}()

	var err error
	for {
		select {
		case <-sess.done:
			return
		default:
		}

		if err == io.ErrUnexpectedEOF || err == io.EOF {
			goto failed
		}
	}
failed:
	sess.doneOnce.Do(func() { close(sess.done) })
}

func (sess *session) loopWrite() {
	defer func() {
		if err := recover(); err != nil {
			sess.doneOnce.Do(func() { close(sess.done) })
		}
	}()

	var err error
	for {
		select {
		case <-sess.done:
			return
		case sess.data <- rand.Intn(100):
		}
		
		if err != nil {
			goto done
		}
	}
done:
	if err != nil {
		log("sess: loop write failed: %v, %s", err, sess)
	}
}

func (sess *session) ForceClose() {
	sess.doneOnce.Do(func() { close(sess.done) })
}

消费者侧发送关闭 done channel,由于消费者有多个,如果每一个都关闭 done channel,会导致 panic。所以这里用 doneOnce.Do() 保证只会关闭 done channel 一次。这解决了第一个问题。生产者收到 done channel 的信号以后自动退出。多个生产者退出时间不同,但是最终肯定都会退出。当生产者全部退出以后,data channel 最终没有引用,会被 gc 回收。这也解决了第二个问题,生产者不会去关闭 data channel,防止出现 panic。

深入 Go 并发原语 — Channel 底层实现

总结一下 done channel 的做法:消费者利用辅助的 done channel 发送信号,并先开始退出协程。生产者接收到 done channel 的信号,也开始退出协程。最终 data channel 无人持有,被 gc 回收关闭。


Reference:

ACM Communicating sequential processes
Stanford project about csp

Go reflection 三定律与最佳实践

Go reflection 三定律与最佳实践

在计算机学中,反射式编程 reflective programming 或反射 reflection,是指计算机程序在运行时 runtime 可以访问、检测和修改它本身状态或行为的一种能力。用比喻来说,反射就是程序在运行的时候能够“观察”并且修改自己的行为。

Wikipedia: In computer science, reflective programming or reflection is the ability of a process to examine, introspect, and modify its own structure and behavior.

“反射”和“内省”(type introspection)在概念上有区别。内省(或称“自省”)机制仅指程序在运行时对自身信息(称为元数据)的检测;反射机制不仅包括要能在运行时对程序自身信息进行检测,还要求程序能进一步根据这些信息改变程序状态或结构。所以反射的概念范畴要大于内省。

在类型检测严格的面向对象的编程语言如 Java 中,一般需要在编译期间对程序中需要调用的对象的具体类型、接口(interface)、字段(fields)和方法的合法性进行检查。反射技术则允许将对需要调用的对象的消息检查工作从编译期间推迟到运行期间再现场执行。这样一来,可以在编译期间先不明确目标对象的接口(interface)名称、字段(fields),即对象的成员变量、可用方法,然后在运行根据目标对象自身的消息决定如何处理。它还允许根据判断结果进行实例化新对象和相关方法的调用。

反射主要用途就是使给定的程序,动态地适应不同的运行情况。利用面向对象建模中的多态(多态性)也可以简化编写分别适用于多种不同情形的功能代码,但是反射可以解决多态(多态性)并不适用的更普遍情形,从而更大程度地避免硬编码(即把代码的细节“写死”,缺乏灵活性)的代码风格。

反射也是元编程的一个关键策略

最常见的代码如下:

import "reflect"

func main() {
	// Without reflection
	f := Foo{}
	f.Hello()

	// With reflection
	fT := reflect.TypeOf(Foo{})
	fV := reflect.New(fT)

	m := fV.MethodByName("Hello")
	if m.IsValid() {
		m.Call(nil)
	}
}

反射看似代码更加复杂,但是能实现的功能更加灵活了。究竟什么时候用反射?最佳实践是什么?这篇文章好好讨论一下。

一. 基本数据结构和方法

在上一篇 Go interface 中,可以了解到普通对象在内存中的存在形式,一个变量值得我们关注的无非是两部分,一个是类型,一个是它存的值。变量的类型决定了底层 tpye 是什么,支持哪些方法集。值无非就是读和写。去内存里面哪里读,把 0101 写到内存的哪里,都是由类型决定的。这一点在解析不同 Json 数据结构的时候深有体会,如果数据类型用错了,解析出来得到的变量的值是乱码。Go 提供反射的功能,是为了支持在运行时动态访问变量的类型和值。

在运行时想要动态访问类型的值,必然应用程序存储了所有用到的类型信息。"reflect" 库提供了一套供开发者使用的访问接口。Go 中反射的基础是接口和类型,Go 很巧妙的借助了对象到接口的转换时使用的数据结构,先将对象传递给内部的空接口,即将类型转换成空接口 emptyInterface(数据结构同 eface 一致)。然后反射再基于这个 emptyInterface 来访问和操作实例对象的值和类型。

那么笔者就从数据结构开始梳理 Go 是如何实现反射的。在 reflect 包中,有一个描述类型公共信息的通用数据结构 rtype。从源码的注释上看,它和 interface 里面的 _type 是同一个数据结构。它们俩只是因为包隔离,加上为了避免循环引用,所以在这边又复制了一遍。

// rtype is the common implementation of most values.
// It is embedded in other struct types.
//
// rtype must be kept in sync with ../runtime/type.go:/^type._type.
type rtype struct {
	size       uintptr // 类型占用内存大小
	ptrdata    uintptr // 包含所有指针的内存前缀大小
	hash       uint32  // 类型 hash
	tflag      tflag   // 标记位,主要用于反射
	align      uint8   // 对齐字节信息
	fieldAlign uint8   // 当前结构字段的对齐字节数
	kind       uint8   // 基础类型枚举值
	equal func(unsafe.Pointer, unsafe.Pointer) bool // 比较两个形参对应对象的类型是否相等
	gcdata    *byte    // GC 类型的数据
	str       nameOff  // 类型名称字符串在二进制文件段中的偏移量
	ptrToThis typeOff  // 类型元信息指针在二进制文件段中的偏移量
}

相同的,所有类型的元信息也都复制了一遍:

type arraytype struct {
	typ   _type
	elem  *_type
	slice *_type
	len   uintptr
}

type chantype struct {
	typ  _type
	elem *_type
	dir  uintptr
}

所有基础类型都不再赘述,详情可见上一篇《深入研究 Go interface 底层实现》。下面来看看 Type interface 究竟涵盖了哪些有用的方法:

1. reflect.Type 通用方法

以下这些方法是通用方法,可以适用于任何类型。

// Type 是 Go 类型的表示。
//
// 并非所有方法都适用于所有类型。
// 在调用 kind 具体方法之前,先使用 Kind 方法找出类型的种类。因为调用一个方法如果类型不匹配会导致 panic
//
// Type 类型值是可以比较的,比如用 == 操作符。所以它可以用做 map 的 key
// 如果两个 Type 值代表相同的类型,那么它们一定是相等的。
type Type interface {
	
	// Align 返回该类型在内存中分配时,以字节数为单位的字节数
	Align() int
	
	// FieldAlign 返回该类型在结构中作为字段使用时,以字节数为单位的字节数
	FieldAlign() int
	
	// Method 这个方法返回类型方法集中的第 i 个方法。
	// 如果 i 不在[0, NumMethod()]范围内,就会 panic。
	// 对于一个非接口类型 T 或 *T,返回的 Method 的 Type 和 Func。
	// fields 字段描述一个函数,它的第一个参数是接收方,而且只有导出的方法可以访问。
	// 对于一个接口类型,返回的 Method 的 Type 字段给出的是方法签名,没有接收者,Func字段为nil。
	// 方法是按字典序顺序排列的。
	Method(int) Method
	
	// MethodByName 返回类型中带有该名称的方法。
	// 方法集和一个表示是否找到该方法的布尔值。
	// 对于一个非接口类型 T 或 *T,返回的 Method 的 Type 和 Func。
	// fields 字段描述一个函数,其第一个参数是接收方。
	// 对于一个接口类型,返回的 Method 的 Type 字段给出的是方法签名,没有接收者,Func字段为nil。
	MethodByName(string) (Method, bool)

	// NumMethod 返回使用 Method 可以访问的方法数量。
	// 请注意,NumMethod 只在接口类型的调用的时候,会对未导出方法进行计数。
	NumMethod() int

	// 对于定义的类型,Name 返回其包中的类型名称。
	// 对于其他(非定义的)类型,它返回空字符串。
	Name() string

	// PkgPath 返回一个定义类型的包的路径,也就是导入路径,导入路径是唯一标识包的类型,如 "encoding/base64"。
	// 如果类型是预先声明的(string, error)或者没有定义(*T, struct{}, []int,或 A,其中 A 是一个非定义类型的别名),包的路径将是空字符串。
	PkgPath() string

	// Size 返回存储给定类型的值所需的字节数。它类似于 unsafe.Sizeof.
	Size() uintptr

	// String 返回该类型的字符串表示。
	// 字符串表示法可以使用缩短的包名。
	// (例如,使用 base64 而不是 "encoding/base64")并且它并不能保证类型之间是唯一的。如果是为了测试类型标识,应该直接比较类型 Type。
	String() string

	// Kind 返回该类型的具体种类。
	Kind() Kind

	// Implements 表示该类型是否实现了接口类型 u。
	Implements(u Type) bool

	// AssignableTo 表示该类型的值是否可以分配给类型 u。
	AssignableTo(u Type) bool

	// ConvertibleTo 表示该类型的值是否可转换为 u 类型。
	ConvertibleTo(u Type) bool

	// Comparable 表示该类型的值是否具有可比性。
	Comparable() bool
}

2. reflect.Type 专有方法

以下这些方法是某些类型专有的方法,如果类型不匹配会发生 panic。在不确定类型之前最好先调用 Kind() 方法确定具体类型再调用类型的专有方法。

Kind Methods applicable
Int* Bits
Uint* Bits
Float* Bits
Complex* Bits
Array Elem, Len
Chan ChanDir, Elem
Func In, NumIn, Out, NumOut, IsVariadic
Map Key, Elem
Ptr Elem
Slice Elem
Struct Field, FieldByIndex, FieldByName,FieldByNameFunc, NumField

对专有方法的说明如下:

type Type interface {

	// Bits 以 bits 为单位返回类型的大小。
	// 如果类型的 Kind 不属于:sized 或者 unsized Int, Uint, Float, 或者 Complex,会 panic。
	//大小不一的Int、Uint、Float或Complex类型。
	Bits() int

	// ChanDir 返回一个通道类型的方向。
	// 如果类型的 Kind 不是 Chan,会 panic。
	ChanDir() ChanDir


	// IsVariadic 表示一个函数类型的最终输入参数是否为一个 "..." 可变参数。如果是,t.In(t.NumIn() - 1) 返回参数的隐式实际类型 []T.
	// 更具体的,如果 t 代表 func(x int, y ... float64),那么:
	// t.NumIn() == 2
	// t.In(0)是 "int" 的 reflect.Type 反射类型。
	// t.In(1)是 "[]float64" 的 reflect.Type 反射类型。
	// t.IsVariadic() == true
	// 如果类型的 Kind 不是 Func.IsVariadic,IsVariadic 会 panic
	IsVariadic() bool

	// Elem 返回一个 type 的元素类型。
	// 如果类型的 Kind 不是 Array、Chan、Map、Ptr 或 Slice,就会 panic
	Elem() Type

	// Field 返回一个结构类型的第 i 个字段。
	// 如果类型的 Kind 不是 Struct,就会 panic。
	// 如果 i 不在 [0, NumField()] 范围内,也会 panic。
	Field(i int) StructField

	// FieldByIndex 返回索引序列对应的嵌套字段。它相当于对每一个 index 调用 Field。
	// 如果类型的 Kind 不是 Struct,就会 panic。
	FieldByIndex(index []int) StructField

	// FieldByName 返回给定名称的结构字段和一个表示是否找到该字段的布尔值。
	FieldByName(name string) (StructField, bool)

	// FieldByNameFunc 返回一个能满足 match 函数的带有名称的 field 字段。布尔值表示是否找到。
	// FieldByNameFunc 先在自己的结构体的字段里面查找,然后在任何嵌入结构中的字段中查找,按广度第一顺序搜索。最终停止在含有一个或多个能满足 match 函数的结构体中。如果在该深度上满足条件的有多个字段,这些字段相互取消,并且 FieldByNameFunc 返回没有匹配。
	// 这种行为反映了 Go 在包含嵌入式字段的结构的情况下对名称查找的处理方式
	FieldByNameFunc(match func(string) bool) (StructField, bool)

	// In 返回函数类型的第 i 个输入参数的类型。
	// 如果类型的 Kind 不是 Func 类型会 panic。
	// 如果 i 不在 [0, NumIn()) 的范围内,会 panic。
	In(i int) Type

	// Key 返回一个 map 类型的 key 类型。
	// 如果类型的 Kind 不是 Map,会 panic。
	Key() Type

	// Len 返回一个数组类型的长度。
	// 如果类型的 Kind 不是 Array,会 panic。
	Len() int

	// NumField 返回一个结构类型的字段数目。
	// 如果类型的 Kind 不是 Struct,会 panic。
	NumField() int

	// NumIn 返回一个函数类型的输入参数数。
	// 如果类型的 Kind 不是Func.NumIn(),会 panic。
	NumIn() int

	// NumOut 返回一个函数类型的输出参数数。
	// 如果类型的 Kind 不是 Func.NumOut(),会 panic。
	NumOut() int

	// Out 返回一个函数类型的第 i 个输出参数的类型。
	// 如果类型的类型不是 Func.Out,会 panic。
	// 如果 i 不在 [0, NumOut()) 的范围内,会 panic。
	Out(i int) Type

	common() *rtype
	uncommon() *uncommonType
}

3. reflect.Value 数据结构

在 reflect 包中,并非所有的方法都适用于所有类型的值。具体的限制在方法说明注释里面有写。在调用特定种类的方法之前,最好使用 Kind 方法找出 Value 的种类。和 reflect.Type 一样,调用类型不匹配的方法会导致 panic。需要特殊说明的是 zero Value,zero Value 代表没有值。它的 IsValid() 方法返回 false,Kind() 方法返回 Invalid,String() 方法返回 “”,而剩下的所有其他方法均会 panic。大多数函数和方法从不返回 invalid value。如果确实返回了 invalid value,则其文档会明确说明特殊条件。

一个 Value 可以由多个 goroutine 并发使用,前提是底层的 Go 值可以同时用于等效的直接操作。 要比较两个 Value,请比较 Interface 相关方法的结果。 在两个 Value 上使用 ==,并不会比较它们表示的底层的值。

reflect 包里的 Value 很简单,数据结构如下:

type Value struct {
	// typ 包含由值表示的值的类型。
	typ *rtype

	// 指向值的指针,如果设置了 flagIndir,则是指向数据的指针。只有当设置了 flagIndir 或 typ.pointers()为 true 时有效。
	ptr unsafe.Pointer

	// flag 保存有关该值的元数据。最低位是标志位:
	//	- flagStickyRO: 通过未导出的未嵌入字段获取,因此为只读
	//	- flagEmbedRO:  通过未导出的嵌入式字段获取,因此为只读
	//	- flagIndir:    val保存指向数据的指针
	//	- flagAddr:     v.CanAddr 为 true (表示 flagIndir)
	//	- flagMethod:   v 是方法值。
    // 接下来的 5 个 bits 给出 Value 的 Kind 种类,除了方法 values 以外,它会重复 typ.Kind()。其余 23 位以上给出方法 values 的方法编号。如果 flag.kind()!= Func,代码可以假定 flagMethod 没有设置。如果 ifaceIndir(typ),代码可以假定设置了 flagIndir。
	flag
}

一个方法的 Value 表示一个相关方法的调用,就像一些方法接收者 r 调用 r.Read。typ + val + flag bits 位描述了接收者r,但是 Kind 标记位表示 Func(方法是函数),并且该标志的高位给出 r 的类型的方法集中的方法编号。

二. 反射的内部实现

这一章以 reflect.TypeOf() 和 reflect.ValueOf() 这两个基本的方法为例,看看底层源码究竟是怎么实现的。源码面前一切皆无秘密。

1. reflect.TypeOf() 底层实现

在 reflect 包中有一个重要的方法 TypeOf(),利用这个方法可以获得一个 Type 的 interface。通过 Type interface 可以获取对象的类型信息。

// TypeOf() 方法返回的 i 这个动态类型的 Type。如果 i 是一个 nil interface value, TypeOf 返回 nil.
func TypeOf(i interface{}) Type {
	eface := *(*emptyInterface)(unsafe.Pointer(&i))
	return toType(eface.typ)
}

func toType(t *rtype) Type {
	if t == nil {
		return nil
	}
	return t
}

上述方法实现非常简单,就是将形参转换成 Type interface。TypeOf() 方法第一行有一个强制类型转换,把 unsafe.Pointer 转换成了 emptyInterface。emptyInterface 数据结构如下:

// emptyInterface is the header for an interface{} value.
type emptyInterface struct {
	typ  *rtype
	word unsafe.Pointer
}

从上面数据结构可以看出,emptyInterface 其实就是 reflect 版的 eface,数据结构完全一致,所以此处强制类型转换没有问题。关于 eface 更详细的讲解见上一篇 interface 底层分析的文章。另外 TypeOf() 方法设计成返回 interface 而不是返回 rtype 类型的数据结构是有讲究的。一是设计者不希望调用者拿到 rtype 滥用。毕竟类型信息这些都是只读的,在运行时被任意篡改太不安全了。二是设计者将调用者的需求的所有需求用 interface 这一层屏蔽了,Type interface 下层可以对应很多种类型,利用这个接口统一抽象成一层。

值得说明的一点是 TypeOf() 入参,入参类型是 i interface{},可以是 2 种类型,一种是 interface 变量,另外一种是具体的类型变量。如果 i 是具体的类型变量,TypeOf() 返回的具体类型信息;如果 i 是 interface 变量,并且绑定了具体类型对象实例,返回的是 i 绑定具体类型的动态类型信息;如果 i 没有绑定任何具体的类型对象实例,返回的是接口自身的静态类型信息。例如下面这段代码:

import (
	"fmt"
	"reflect"
)

func main() {
	ifa := new(Person)
	var ifb Person = Student{name: "halfrost"}
    // 未绑定具体变量的接口类型 
	fmt.Println(reflect.TypeOf(ifa).Elem().Name())
	fmt.Println(reflect.TypeOf(ifa).Elem().Kind().String())
    // 绑定具体变量的接口类型 
	fmt.Println(reflect.TypeOf(ifb).Name())
	fmt.Println(reflect.TypeOf(ifb).Kind().String())
}

在第一组输出中,reflect.TypeOf() 入参未绑定具体变量的接口类型,所以返回的是接口类型本身 Person。对应的 Kind 是 interface。在第二组输出中,reflect.TypeOf() 入参绑定了具体变量的接口类型,所以返回的是绑定的具体类型 Student。对应的 Kind 是 struct。

Person
interface

Student
struct

toType() 方法中只是单独判断了一次是否为 nil。因为在 gc 中,唯一关心的是 nil 的 *rtype 必须转换成 nil Type。但是在 gccgo 中,这个函数需要确保同一类型的多个 *rtype 合并成单个 Type。

2. reflect.ValueOf() 底层实现

ValueOf() 方法返回一个新的 Value,根据 interface i 这个入参的具体值进行初始化。ValueOf(nil) 返回零值。

func ValueOf(i interface{}) Value {
	if i == nil {
		return Value{}
	}
	escapes(i)
	return unpackEface(i)
}

ValueOf() 的所有逻辑只在 escapes() 和 unpackEface() 这两个方法上。先来看 escapes() 的实现。这个方法目前注释还是 TODO 的状态,从名字上我们可以知道,它是为了防止变量逃逸,把 Value 的内容存到栈上。目前所有的内容还是存在堆中。放在堆中也有好处,具体好处可以见 chanrecv/mapassign 中,这里不细致展开。escapes() 源码实现如下:

func escapes(x interface{}) {
	if dummy.b {
		dummy.x = x
	}
}

var dummy struct {
	b bool
	x interface{}
}

dummy 变量就是一个虚拟标注,标记入参 x 逃逸了。这样标记是为了防止反射代码写的过于高级,以至于编译器跟不上了。ValueOf() 的主要逻辑在 unpackEface() 方法中:

func ifaceIndir(t *rtype) bool {
	return t.kind&kindDirectIface == 0
}

func unpackEface(i interface{}) Value {
	e := (*emptyInterface)(unsafe.Pointer(&i))
	// NOTE: don't read e.word until we know whether it is really a pointer or not.
	t := e.typ
	if t == nil {
		return Value{}
	}
	f := flag(t.Kind())
	if ifaceIndir(t) {
		f |= flagIndir
	}
	return Value{t, e.word, f}
}

ifaceIndir() 这个方法只是利用位运算取出特征标记位,表示 t 是否间接存储在 一个 interface value 中。unpackEface() 从名字上能看出它的目的,将 emptyInterface 转换成 Value。实现分为 3 步,先将入参 interface 强转成 emptyInterface,然后判断 emptyInterface.typ 是否为空,如果不为空才能读取 emptyInterface.word。最后拼装 Value 数据结构中的三个字段,*rtype,unsafe.Pointer,flag。

三. 反射三定律

著名的 《The laws of Reflection》 这篇文章里面归纳了反射的三定律。

1. 反射可以从接口值中得到反射对象

Go reflection 三定律与最佳实践

  • 通过实例获取 Value 对象,使用 reflect.ValueOf() 函数。
// ValueOf returns a new Value initialized to the concrete value
// stored in the interface i. ValueOf(nil) returns the zero Value.
func ValueOf(i interface{}) Value {
	if i == nil {
		return Value{}
	}
	// TODO: Maybe allow contents of a Value to live on the stack.
	// For now we make the contents always escape to the heap. It
	// makes life easier in a few places (see chanrecv/mapassign
	// comment below).
	escapes(i)

	return unpackEface(i)
}
  • 通过实例获取反射对象 Type,使用 reflect.TypeOf() 函数。
// TypeOf returns the reflection Type that represents the dynamic type of i.
// If i is a nil interface value, TypeOf returns nil.
func TypeOf(i interface{}) Type {
	eface := *(*emptyInterface)(unsafe.Pointer(&i))
	return toType(eface.typ)
}

2. 反射可以从反射对象中获得接口值

从 reflect.Value 数据结构可知,它包含了类型和值的信息,所以将 Value 转换成实例对象很容易。

Go reflection 三定律与最佳实践

  • 将 Value 转换成空的 interface,内部存放具体类型实例。使用 interface() 函数。
// Interface returns v's current value as an interface{}.
// It is equivalent to:
//	var i interface{} = (v's underlying value)
// It panics if the Value was obtained by accessing
// unexported struct fields.
func (v Value) Interface() (i interface{}) {
	return valueInterface(v, true)
}
  • Value 也包含很多成员方法,可以将 Value 转换成简单类型实例,注意如果类型不匹配会 panic。
// Int returns v's underlying value, as an int64.
// It panics if v's Kind is not Int, Int8, Int16, Int32, or Int64.
func (v Value) Int() int64 {
	k := v.kind()
	p := v.ptr
	switch k {
	case Int:
		return int64(*(*int)(p))
	case Int8:
		return int64(*(*int8)(p))
	case Int16:
		return int64(*(*int16)(p))
	case Int32:
		return int64(*(*int32)(p))
	case Int64:
		return *(*int64)(p)
	}
	panic(&ValueError{"reflect.Value.Int", v.kind()})
}

// Uint returns v's underlying value, as a uint64.
// It panics if v's Kind is not Uint, Uintptr, Uint8, Uint16, Uint32, or Uint64.
func (v Value) Uint() uint64 {
	k := v.kind()
	p := v.ptr
	switch k {
	case Uint:
		return uint64(*(*uint)(p))
	case Uint8:
		return uint64(*(*uint8)(p))
	case Uint16:
		return uint64(*(*uint16)(p))
	case Uint32:
		return uint64(*(*uint32)(p))
	case Uint64:
		return *(*uint64)(p)
	case Uintptr:
		return uint64(*(*uintptr)(p))
	}
	panic(&ValueError{"reflect.Value.Uint", v.kind()})
}

// Bool returns v's underlying value.
// It panics if v's kind is not Bool.
func (v Value) Bool() bool {
	v.mustBe(Bool)
	return *(*bool)(v.ptr)
}

// Float returns v's underlying value, as a float64.
// It panics if v's Kind is not Float32 or Float64
func (v Value) Float() float64 {
	k := v.kind()
	switch k {
	case Float32:
		return float64(*(*float32)(v.ptr))
	case Float64:
		return *(*float64)(v.ptr)
	}
	panic(&ValueError{"reflect.Value.Float", v.kind()})
}

3. 若要修改反射对象,值必须可修改

Go reflection 三定律与最佳实践

  • 指针类型 Type 转成值类型 Type。指针类型必须是 *Array、*Slice、*Pointer、*Map、*Chan 类型,否则会发生 panic。Type 返回的是内部元素的 Type。
// Elem returns element type of array a.
func (a *Array) Elem() Type { return a.elem }

// Elem returns the element type of slice s.
func (s *Slice) Elem() Type { return s.elem }

// Elem returns the element type for the given pointer p.
func (p *Pointer) Elem() Type { return p.base }

// Elem returns the element type of map m.
func (m *Map) Elem() Type { return m.elem }

// Elem returns the element type of channel c.
func (c *Chan) Elem() Type { return c.elem }
  • 值类型 Type 转成指针类型 Type。PtrTo 返回的是指向 t 的指针类型 Type。
// PtrTo returns the pointer type with element t.
// For example, if t represents type Foo, PtrTo(t) represents *Foo.
func PtrTo(t Type) Type {
	return t.(*rtype).ptrTo()
}

针对反射三定律的这个第三条,还需要特殊说明的是:Value 值的可修改性是什么意思。举例:

func main() {
	var x float64 = 3.4
	v := reflect.ValueOf(x)
	v.SetFloat(7.1) // Error: will panic.
}

如上面这段代码,运行以后会崩溃,崩溃信息是 panic: reflect: reflect.Value.SetFloat using unaddressable value,为什么这里 SetFloat() 会 panic 呢?这里给的提示信息是使用了不可寻址的 Value。在上述代码中,调用 reflect.ValueOf 传进去的是一个值类型的变量,获得的 Value 其实是完全的值拷贝,这个 Value 是不能被修改的。如果传进去是一个指针,获得的 Value 是一个指针副本,但是这个指针指向的地址的对象是可以改变的。将上述代码改成这样:

func main() {
	var x float64 = 3.4
	p := reflect.ValueOf(&x)
	fmt.Println("type of p:", p.Type())
	fmt.Println("settability of p:", p.CanSet())

	v := p.Elem()
	v.SetFloat(7.1)
	fmt.Println(v.Interface()) // 7.1
	fmt.Println(x)             // 7.1
}

在调用 reflect.ValueOf() 方法的时候传入一个指针,这样就不会崩溃了。输出符合逻辑:

type of p: *float64
settability of p: false
7.1
7.1

4. Type 和 Value 相互转换

Go reflection 三定律与最佳实践

  • 由于 Type 中只有类型信息,所以无法直接通过 Type 获取实例对象的 Value,但是可以通过 New() 这个方法得到一个指向 type 类型的指针,值是零值。MakeMap() 方法和 New() 方法类似,只不过是创建了一个 Map。
// New returns a Value representing a pointer to a new zero value
// for the specified type. That is, the returned Value's Type is PtrTo(typ).
func New(typ Type) Value {
	if typ == nil {
		panic("reflect: New(nil)")
	}
	t := typ.(*rtype)
	ptr := unsafe_New(t)
	fl := flag(Ptr)
	return Value{t.ptrTo(), ptr, fl}
}

// MakeMap creates a new map with the specified type.
func MakeMap(typ Type) Value {
	return MakeMapWithSize(typ, 0)
}
  • 需要特殊说明的一个方法是 Zero(),这个方法返回指定类型的零值。这个零值与 Value 结构的 zero value 不同,它根本不代表任何值。例如,Zero(TypeOf(42)) 返回带有 Kind Int 且值为 0 的值。返回的值既不可寻址,也不可改变。
// Zero returns a Value representing the zero value for the specified type.
// The result is different from the zero value of the Value struct,
// which represents no value at all.
// For example, Zero(TypeOf(42)) returns a Value with Kind Int and value 0.
// The returned value is neither addressable nor settable.
func Zero(typ Type) Value {
	if typ == nil {
		panic("reflect: Zero(nil)")
	}
	t := typ.(*rtype)
	fl := flag(t.Kind())
	if ifaceIndir(t) {
		var p unsafe.Pointer
		if t.size <= maxZero {
			p = unsafe.Pointer(&zeroVal[0])
		} else {
			p = unsafe_New(t)
		}
		return Value{t, p, fl | flagIndir}
	}
	return Value{t, nil, fl}
}
  • 由于反射对象 Value 中本来就存有 Tpye 的信息,所以 Value 向 Type 转换比较简单。
// Type returns v's type.
func (v Value) Type() Type {
	f := v.flag
	if f == 0 {
		panic(&ValueError{"reflect.Value.Type", Invalid})
	}
	if f&flagMethod == 0 {
		// Easy case
		return v.typ
	}

	// Method value.
	// v.typ describes the receiver, not the method type.
	i := int(v.flag) >> flagMethodShift
	if v.typ.Kind() == Interface {
		// Method on interface.
		tt := (*interfaceType)(unsafe.Pointer(v.typ))
		if uint(i) >= uint(len(tt.methods)) {
			panic("reflect: internal error: invalid method index")
		}
		m := &tt.methods[i]
		return v.typ.typeOff(m.typ)
	}
	// Method on concrete type.
	ms := v.typ.exportedMethods()
	if uint(i) >= uint(len(ms)) {
		panic("reflect: internal error: invalid method index")
	}
	m := ms[i]
	return v.typ.typeOff(m.mtyp)
}

5. Value 指针转换成值

Go reflection 三定律与最佳实践

  • 把指针的 Value 转换成值 Value 有 2 个方法 Indirect() 和 Elem()。
// Indirect returns the value that v points to.
// If v is a nil pointer, Indirect returns a zero Value.
// If v is not a pointer, Indirect returns v.
func Indirect(v Value) Value {
	if v.Kind() != Ptr {
		return v
	}
	return v.Elem()
}

// Elem returns the value that the interface v contains
// or that the pointer v points to.
// It panics if v's Kind is not Interface or Ptr.
// It returns the zero Value if v is nil.
func (v Value) Elem() Value {
	k := v.kind()
	switch k {
	case Interface:
		var eface interface{}
		if v.typ.NumMethod() == 0 {
			eface = *(*interface{})(v.ptr)
		} else {
			eface = (interface{})(*(*interface {
				M()
			})(v.ptr))
		}
		x := unpackEface(eface)
		if x.flag != 0 {
			x.flag |= v.flag.ro()
		}
		return x
	case Ptr:
		ptr := v.ptr
		if v.flag&flagIndir != 0 {
			ptr = *(*unsafe.Pointer)(ptr)
		}
		// The returned value's address is v's value.
		if ptr == nil {
			return Value{}
		}
		tt := (*ptrType)(unsafe.Pointer(v.typ))
		typ := tt.elem
		fl := v.flag&flagRO | flagIndir | flagAddr
		fl |= flag(typ.Kind())
		return Value{typ, ptr, fl}
	}
	panic(&ValueError{"reflectlite.Value.Elem", v.kind()})
}

从源码实现中可以看到,入参是指针或者是 interface 会影响输出的结果。

  • 将值 Value 转换成指针的 Value 只有 Addr() 这一个方法。
// Addr returns a pointer value representing the address of v.
// It panics if CanAddr() returns false.
// Addr is typically used to obtain a pointer to a struct field
// or slice element in order to call a method that requires a
// pointer receiver.
func (v Value) Addr() Value {
	if v.flag&flagAddr == 0 {
		panic("reflect.Value.Addr of unaddressable value")
	}
	// Preserve flagRO instead of using v.flag.ro() so that
	// v.Addr().Elem() is equivalent to v (#32772)
	fl := v.flag & flagRO
	return Value{v.typ.ptrTo(), v.ptr, fl | flag(Ptr)}
}

6. 总结

Go reflection 三定律与最佳实践

这一章通过反射三定律引出了反射对象,Type、Vale 三者的关系。笔者将其之间的关系扩展成了上图。在上图中除了 Tpye 和 interface 是单向的,其余的转换都是双向的。可能有读者有疑问,Type 真的就不能转换成 interface 了么?这里谈的是通过一个方法单次是无法转换的。在上篇 interface 文章中,我们知道 interface 包含类型和值两部分,Type 只有类型部分,确实值的部分,所以和 interface 是不能互转的。那如果就是想通过 Type 得到 interface 怎么办呢?仔细看上图,可以先通过 New() 方法得到 Value,再调用 interface() 方法得到 interface。借助 interface 和 Value 互转的性质,可以得到由 Type 生成 interface 的目的。

四. 优缺点与最佳实践

最后聊聊在 Go 中使用反射的优缺点和最佳实践。

1. 优点

  • 可以在一定程度上避免硬编码,提供灵活性和通用性。
  • 可以作为一个第一类对象发现并修改源代码的结构(如代码块、类、方法、协议等)。
  • 可以在运行时像对待源代码语句一样动态解析字符串中可执行的代码(类似 JavaScript 的 eval() 函数),进而可将跟 class 或 function 匹配的字符串转换成 class 或 function 的调用或引用。
  • 可以创建一个新的语言字节码解释器来给编程结构一个新的意义或用途。

2. 缺点

  • 此技术的学习成本高。面向反射的编程需要较多的高级知识,包括框架、关系映射和对象交互,以实现更通用的代码执行。
  • 同样因为反射的概念和语法都比较抽象,过多地滥用反射技术会使得代码难以被其他人读懂,不利于合作与交流。
  • 由于将部分信息检查工作从编译期推迟到了运行期,调用方法和引用对象并非直接的地址引用,而是通过 reflect 包提供的一个抽象层间接访问。此举在提高了代码灵活性的同时,牺牲了一点点运行效率。在项目性能要求较高的地方,一定要慎重考虑使用反射。
  • 由于逃避了编译器的严格检查,所以一些不正确的修改会导致程序 panic。

通过深入学习反射的特性和技巧,缺点可以尽量避免,但这需要非常多的时间和经验的积累。

3. 最佳实践

  • 在库和框架内部适当使用反射特性,将复杂的逻辑封装在内部,复杂留给自己,暴露给使用者的接口都是简单的。
  • 除去库和框架以外的业务逻辑代码没有必要使用反射。缺点在上面已经说过,这里不再赘述。
  • 如果上述 2 条依旧没有覆盖到的场景,不到万不得已,不把反射作为第一解决方法。

深入研究 Go interface 底层实现

深入研究 Go interface 底层实现

接口是高级语言中的一个规约,是一组方法签名的集合。Go 的 interface 是非侵入式的,具体类型实现 interface 不需要在语法上显式的声明,只需要具体类型的方法集合是 interface 方法集合的超集,就表示该类实现了这一 interface。编译器在编译时会进行 interface 校验。interface 和具体类型不同,它不能实现具体逻辑,也不能定义字段。

在 Go 语言中,interface 和函数一样,都是“第一公民”。interface 可以用在任何使用变量的地方。可以作为结构体内的字段,可以作为函数的形参和返回值,可以作为其他 interface 定义的内嵌字段。interface 在大型项目中常常用来解耦。在层与层之间用 interface 进行抽象和解耦。由于 Go interface 非侵入的设计,使得抽象出来的代码特别简洁,这也符合 Go 语言设计之初的哲学。除了解耦以外,还有一个非常重要的应用,就是利用 interface 实现伪泛型。利用空的 interface 作为函数或者方法参数能够用在需要泛型的场景里。

interface 作为 Go 语言类型系统的灵魂,Go 语言实现多态和反射的基础。新手对其理解不深刻的话,常常会犯下面这个错误:

func main() {
	var x interface{} = nil
	var y *int = nil
	interfaceIsNil(x)
	interfaceIsNil(y)
}

func interfaceIsNil(x interface{}) {
	if x == nil {
		fmt.Println("empty interface")
		return
	}
	fmt.Println("non-empty interface")
}

笔者第一次接触到这个问题是强转了 gRPC 里面的一个 interface,然后在外面判断它是否为 nil。结果出 bug 了。当初如果了解对象强制转换成 interface 的时候,不仅仅含有原来的对象,还会包含对象的类型信息,也就不会出 bug 了。

本文将会详细分解 interface 所有底层实现。

以下代码基于 Go 1.16

一. 数据结构

1. 非空 interface 数据结构

非空的 interface 初始化的底层数据结构是 iface,稍后在汇编代码中能验证这一点。

type iface struct {
	tab  *itab
	data unsafe.Pointer
}

tab 中存放的是类型、方法等信息。data 指针指向的 iface 绑定对象的原始数据的副本。这里同样遵循 Go 的统一规则,值传递。tab 是 itab 类型的指针。

// layout of Itab known to compilers
// allocated in non-garbage-collected memory
// Needs to be in sync with
// ../cmd/compile/internal/gc/reflect.go:/^func.WriteTabs.
type itab struct {
	inter *interfacetype
	_type *_type
	hash  uint32 // copy of _type.hash. Used for type switches.
	_     [4]byte
	fun   [1]uintptr // variable sized. fun[0]==0 means _type does not implement inter.
}

itab 中包含 5 个字段。inner 存的是 interface 自己的静态类型。_type 存的是 interface 对应具体对象的类型。itab 中的 _type 和 iface 中的 data 能简要描述一个变量。_type 是这个变量对应的类型,data 是这个变量的值。这里的 hash 字段和 _type 中存的 hash 字段是完全一致的,这么做的目的是为了类型断言(下文会提到)。fun 是一个函数指针,它指向的是具体类型的函数方法。虽然这里只有一个函数指针,但是它可以调用很多方法。在这个指针对应内存地址的后面依次存储了多个方法,利用指针偏移便可以找到它们。

由于 Go 语言是强类型语言,编译时对每个变量的类型信息做强校验,所以每个类型的元信息要用一个结构体描述。再者 Go 的反射也是基于类型的元信息实现的。_type 就是所有类型最原始的元信息。

// Needs to be in sync with ../cmd/link/internal/ld/decodesym.go:/^func.commonsize,
// ../cmd/compile/internal/gc/reflect.go:/^func.dcommontype and
// ../reflect/type.go:/^type.rtype.
// ../internal/reflectlite/type.go:/^type.rtype.
type _type struct {
	size       uintptr // 类型占用内存大小
	ptrdata    uintptr // 包含所有指针的内存前缀大小
	hash       uint32  // 类型 hash
	tflag      tflag   // 标记位,主要用于反射
	align      uint8   // 对齐字节信息
	fieldAlign uint8   // 当前结构字段的对齐字节数
	kind       uint8   // 基础类型枚举值
	equal func(unsafe.Pointer, unsafe.Pointer) bool // 比较两个形参对应对象的类型是否相等
	gcdata    *byte    // GC 类型的数据
	str       nameOff  // 类型名称字符串在二进制文件段中的偏移量
	ptrToThis typeOff  // 类型元信息指针在二进制文件段中的偏移量
}

有 3 个字段需要解释一下:

  • kind,这个字段描述的是如何解析基础类型。在 Go 语言中,基础类型是一个枚举常量,有 26 个基础类型,如下。枚举值通过 kindMask 取出特殊标记位。
const (
	kindBool = 1 + iota
	kindInt
	kindInt8
	kindInt16
	kindInt32
	kindInt64
	kindUint
	kindUint8
	kindUint16
	kindUint32
	kindUint64
	kindUintptr
	kindFloat32
	kindFloat64
	kindComplex64
	kindComplex128
	kindArray
	kindChan
	kindFunc
	kindInterface
	kindMap
	kindPtr
	kindSlice
	kindString
	kindStruct
	kindUnsafePointer

	kindDirectIface = 1 << 5
	kindGCProg      = 1 << 6
	kindMask        = (1 << 5) - 1
)
  • str 和 ptrToThis,对应的类型是 nameoff 和 typeOff。这两个字段的值是在链接器段合并和符号重定向的时候赋值的。
    深入研究 Go interface 底层实现
    链接器将各个 .o 文件中的段合并到输出文件,会进行段合并,有的放入 .text 段,有的放入 .data 段,有的放入 .bss 段。name 和 type 针对最终输出文件所在段内的偏移量 offset 是由 resolveNameOff 和 resolveTypeOff 函数计算出来的,然后链接器把结果保存在 str 和 ptrToThis 中。具体逻辑可以见源码中下面 2 个函数:
func resolveNameOff(ptrInModule unsafe.Pointer, off nameOff) name {}  
func resolveTypeOff(ptrInModule unsafe.Pointer, off typeOff) *_type {}

回到 _type 类型。上文谈到 _type 是所有类型原始信息的元信息。例如:

type arraytype struct {
	typ   _type
	elem  *_type
	slice *_type
	len   uintptr
}

type chantype struct {
	typ  _type
	elem *_type
	dir  uintptr
}

在 arraytype 和 chantype 中保存类型的元信息就是靠 _type。同样 interface 也有类似的类型定义:

type imethod struct {
	name nameOff
	ityp typeOff
}

type interfacetype struct {
	typ     _type     // 类型元信息
	pkgpath name      // 包路径和描述信息等等
	mhdr    []imethod // 方法
}

因为 Go 语言中函数方法是以包为单位隔离的。所以 interfacetype 除了保存 _type 还需要保存包路径等描述信息。mhdr 存的是各个 interface 函数方法在段内的偏移值 offset,知道偏移值以后才方便调用。

2. 空 interface 数据结构

空的 inferface{} 是没有方法集的接口。所以不需要 itab 数据结构。它只需要存类型和类型对应的值即可。对应的数据结构如下:

type eface struct {
	_type *_type
	data  unsafe.Pointer
}

从这个数据结构可以看出,只有当 2 个字段都为 nil,空接口才为 nil。空接口的主要目的有 2 个,一是实现“泛型”,二是使用反射。

二. 类型转换

举个具体的例子来说明 interface 是如何进行类型转换的。先来看指针类型转换。

1. 指针类型

package main

import "fmt"

func main() {
	var s Person = &Student{name: "halfrost"}
	s.sayHello("everyone")
}

type Person interface {
	sayHello(name string) string
	sayGoodbye(name string) string
}

type Student struct {
	name string
}

//go:noinline
func (s *Student) sayHello(name string) string {
	return fmt.Sprintf("%v: Hello %v, nice to meet you.\n", s.name, name)
}

//go:noinline
func (s *Student) sayGoodbye(name string) string {
	return fmt.Sprintf("%v: Hi %v, see you next time.\n", s.name, name)
}

利用 go build 和 go tool 命令将上述代码变成汇编代码:

$ go tool compile -S -N -l main.go >main.s1 2>&1

main 方法中有 3 个操作,重点关注后 2 个涉及到 interface 的操作:

  1. 初始化 Student 对象指针
  2. 将 Student 对象指针转换成 interface
  3. 调用 interface 的方法

Plan9 汇编常见寄存器含义:
BP: 栈基,栈帧(函数的栈叫栈帧)的开始位置。
SP: 栈顶,栈帧的结束位置。
PC: 就是IP寄存器,存放CPU下一个执行指令的位置地址。
TLS: 虚拟寄存器。表示的是 thread-local storage,Golang 中存放了当前正在执行的g的结构体。

先来看 Student 初始化的汇编代码:


0x0021 00033 (main.go:6)	LEAQ	type."".Student(SB), AX      // 将 type."".Student 地址放入 AX 中
0x0028 00040 (main.go:6)	MOVQ	AX, (SP)                     // 将 AX 中的值存储在 SP 中
0x002c 00044 (main.go:6)	PCDATA	$1, $0
0x002c 00044 (main.go:6)	CALL	runtime.newobject(SB)        // 调用 runtime.newobject() 方法,生成 Student 对象存入 SB 中
0x0031 00049 (main.go:6)	MOVQ	8(SP), DI                    // 将生成的 Student 对象放入 DI 中
0x0036 00054 (main.go:6)	MOVQ	DI, ""..autotmp_2+40(SP)     // 编译器认为 Student 是临时变量,所以将 DI 放在栈上
0x003b 00059 (main.go:6)	MOVQ	$8, 8(DI)                    // (DI.Name).Len = 8
0x0043 00067 (main.go:6)	PCDATA	$0, $-2
0x0043 00067 (main.go:6)	CMPL	runtime.writeBarrier(SB), $0
0x004a 00074 (main.go:6)	JEQ	78
0x004c 00076 (main.go:6)	JMP	172
0x004e 00078 (main.go:6)	LEAQ	go.string."halfrost"(SB), AX  // 将 "halfrost" 字符串的地址放入 AX 中
0x0055 00085 (main.go:6)	MOVQ	AX, (DI)                      // (DI.Name).Data = &"halfrost"
0x0058 00088 (main.go:6)	JMP	90
0x005a 00090 (main.go:6)	PCDATA	$0, $-1

先将 *_type 放在 (SP) 栈顶。然后调用 runtime.newobject() 生成 Student 对象。(SP) 栈顶的值即是 newobject() 方法的入参。

func newobject(typ *_type) unsafe.Pointer {
	return mallocgc(typ.size, typ, true)
}

PCDATA 用于生成 PC 表格,PCDATA 的指令用法为:PCDATA tableid, tableoffset。PCDATA有个两个参数,第一个参数为表格的类型,第二个是表格的地址。runtime.writeBarrier() 是 GC 相关的方法,感兴趣的可以研究它的源码。以下是 Student 对象临时对象 GC 的一些汇编代码逻辑,由于有 JMP 命令,代码是分开的,笔者在这里将它们汇集在一起。


0x0043 00067 (main.go:6)    PCDATA  $0, $-2
0x0043 00067 (main.go:6)    CMPL    runtime.writeBarrier(SB), $0
0x004a 00074 (main.go:6)    JEQ 78
0x004c 00076 (main.go:6)    JMP 172
......
0x00ac 00172 (main.go:6)	PCDATA	$0, $-2
0x00ac 00172 (main.go:6)	LEAQ	go.string."halfrost"(SB), AX
0x00b3 00179 (main.go:6)	CALL	runtime.gcWriteBarrier(SB)
0x00b8 00184 (main.go:6)	JMP	90
0x00ba 00186 (main.go:6)	NOP

78 对应的十六进制是 0x004e,172 对应的十六进制是 0x00ac。先对比 runtime.writeBarrier(SB) 和 $0 存的是否一致,如果相同则 JMP 到 0x004e 行,如果不同则 JMP 到 0x00ac 行。0x004e 行和 0x00ac 行代码完全相同,都是将字符串 "halfrost" 的地址放入 AX 中,不过 0x00ac 行执行完会紧接着调用 runtime.gcWriteBarrier(SB)。执行完成以后再回到 0x005a 行。

第一步结束以后,内存中存了 3 个值。临时变量 .autotmp_2 放在 +40(SP) 的地址处,它也就是临时 Student 对象。

深入研究 Go interface 底层实现

接下来是第二步,将 Student 对象转换成 interface。


0x005a 00090 (main.go:6)	MOVQ	""..autotmp_2+40(SP), AX
0x005f 00095 (main.go:6)	MOVQ	AX, ""..autotmp_1+48(SP)
0x0064 00100 (main.go:6)	LEAQ	go.itab.*"".Student,"".Person(SB), CX
0x006b 00107 (main.go:6)	MOVQ	CX, "".s+56(SP)
0x0070 00112 (main.go:6)	MOVQ	AX, "".s+64(SP)

经过上面几行汇编代码,成功的构造出了 itab 结构体。在汇编代码中可以找到 itab 的内存布局:

go.itab.*"".Student,"".Person SRODATA dupok size=40
	0x0000 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
	0x0010 0c 31 79 12 00 00 00 00 00 00 00 00 00 00 00 00  .1y.............
	0x0020 00 00 00 00 00 00 00 00                          ........
	rel 0+8 t=1 type."".Person+0
	rel 8+8 t=1 type.*"".Student+0
	rel 24+8 t=1 "".(*Student).sayGoodbye+0
	rel 32+8 t=1 "".(*Student).sayHello+0

itab 结构体的首字节里面存的是 inter *interfacetype,此处即 Person interface。第二个字节中存的是 *_type,这里是第一步生成的,放在 (SP) 地址处的地址。第四个字节中存的是 fun [1]uintptr,对应 sayGoodbye 方法的首地址。第五个字节中存的也是 fun [1]uintptr,对应 sayHello 方法的首地址。回顾上一章节里面的 itab 数据结构:

type itab struct {
    inter *interfacetype // 8 字节
    _type *_type         // 8 字节
    hash  uint32 		 // 4 字节,填充使得内存对齐
    _     [4]byte        // 4 字节
    fun   [1]uintptr     // 8 字节
}

现在就很明确了为什么 fun 只需要存一个函数指针。每个函数指针都是 8 个字节,如果 interface 里面包含多个函数,只需要 fun 往后顺序偏移多个字节即可。第二步结束以后,内存中存储了以下这些值:

深入研究 Go interface 底层实现

在第二步中新建了一个临时变量 .autotmp_1 放在 +48(SP) 地址处。并且利用第一步中生成的 Student 临时变量构造出了 *itab。值得说明的是,虽然汇编代码并没有显示调用函数生成 iface,但是此时已经生成了 iface。

type iface struct {
    tab  *itab
    data unsafe.Pointer
}

如上图,+56(SP) 处存的是 *itab,+64(SP) 处存的是 unsafe.Pointer,这里的指针和 +8(SP) 的指针是完全一致的。接下来就是最后一步,调用 interface 的方法。


0x0075 00117 (main.go:7)	MOVQ	"".s+56(SP), AX
0x007a 00122 (main.go:7)	TESTB	AL, (AX)
0x007c 00124 (main.go:7)	MOVQ	32(AX), AX
0x0080 00128 (main.go:7)	MOVQ	"".s+64(SP), CX
0x0085 00133 (main.go:7)	MOVQ	CX, (SP)
0x0089 00137 (main.go:7)	LEAQ	go.string."everyone"(SB), CX
0x0090 00144 (main.go:7)	MOVQ	CX, 8(SP)
0x0095 00149 (main.go:7)	MOVQ	$8, 16(SP)
0x009e 00158 (main.go:7)	NOP
0x00a0 00160 (main.go:7)	CALL	AX

先取出调用方法的真正对象,放入 (SP) 中,再依次将方法中的入参按照顺序放在 +8(SP) 之后。然后调用函数指针对应的方法。从汇编代码中可以看到,AX 直接从取出了 *itab 指针存的内存地址,然后偏移到了 +32 的位置,这里是要调用的方法 sayHello 的内存地址。最后从栈顶依次取出需要的参数,即算完成 iterface 方法调用。方法调用前一刻,内存中的状态如下,主要关注 AX 的地址以及栈顶的所有参数信息。

深入研究 Go interface 底层实现

栈顶依次存放的是方法的调用者,参数。调用格式可以表示为 func(reciver, param1)。

2. 结构体类型

指针类型和结构体类型在类型转换中会有哪些区别?这一节好好分析对比一下。测试代码和指针类型大体一致,只是类型转换的时候换成了结构体,方法实现也换成了结构体,其他都没有变。

package main

import "fmt"

func main() {
	var s Person = Student{name: "halfrost"}
	s.sayHello("everyone")
}

type Person interface {
	sayHello(name string) string
	sayGoodbye(name string) string
}

type Student struct {
	name string
}

//go:noinline
func (s Student) sayHello(name string) string {
	return fmt.Sprintf("%v: Hello %v, nice to meet you.\n", s.name, name)
}

//go:noinline
func (s Student) sayGoodbye(name string) string {
	return fmt.Sprintf("%v: Hi %v, see you next time.\n", s.name, name)
}

用同样的命令生成对应的汇编代码:

$ go tool compile -S -N -l main.go >main.s2 2>&1

对比相同的 3 个环节:

  1. 初始化 Student 对象
  2. 将 Student 对象转换成 interface
  3. 调用 interface 的方法

0x0021 00033 (main.go:6)	XORPS	X0, X0                       // X0 置 0
0x0024 00036 (main.go:6)	MOVUPS	X0, ""..autotmp_1+64(SP)     // 清空 +64(SP)
0x0029 00041 (main.go:6)	LEAQ	go.string."halfrost"(SB), AX
0x0030 00048 (main.go:6)	MOVQ	AX, ""..autotmp_1+64(SP)
0x0035 00053 (main.go:6)	MOVQ	$8, ""..autotmp_1+72(SP)
0x003e 00062 (main.go:6)	MOVQ	AX, (SP)
0x0042 00066 (main.go:6)	MOVQ	$8, 8(SP)
0x004b 00075 (main.go:6)	PCDATA	$1, $0

这段代码将 "halfrost" 放入内存相应的位置。上述代码 1-8 行,将字符串 "halfrost" 地址和长度 8 拷贝到 +0(SP),+8(SP) 和 +64(SP),+72(SP) 中。从这里可以了解到普通的临时变量在内存中布局是怎么样的。从上述汇编代码中可以看出,编译器发现这个变量只是临时变量,都没有调用 runtime.newobject(),仅仅是将它的每个基本类型的字段生成好放在内存中。

深入研究 Go interface 底层实现


0x004b 00075 (main.go:6)	CALL	runtime.convTstring(SB)
0x0050 00080 (main.go:6)	MOVQ	16(SP), AX
0x0055 00085 (main.go:6)	MOVQ	AX, ""..autotmp_2+40(SP)
0x005a 00090 (main.go:6)	LEAQ	go.itab."".Student,"".Person(SB), CX
0x0061 00097 (main.go:6)	MOVQ	CX, "".s+48(SP)
0x0066 00102 (main.go:6)	MOVQ	AX, "".s+56(SP)

上述代码生成了 interface。第 1 行调用了 runtime.convTstring()。

func convTstring(val string) (x unsafe.Pointer) {
	if val == "" {
		x = unsafe.Pointer(&zeroVal[0])
	} else {
		x = mallocgc(unsafe.Sizeof(val), stringType, true)
		*(*string)(x) = val
	}
	return
}

runtime.convTstring() 会从栈顶 +0(SP) 取出入参 "halfrost" 和长度 8。在栈上生成了一个字符串的变量,返回了它的指针放在 +16(SP) 中,并拷贝到 +40(SP) 里。第 4 行生成了 itab 的指针,这里和上一章里面一致,不再赘述。至此,iface 生成了,*itab 和 unsafe.Pointer 分别存在 +48(SP) 和 +56(SP) 中。

深入研究 Go interface 底层实现


0x006b 00107 (main.go:7)	MOVQ	"".s+48(SP), AX
0x0070 00112 (main.go:7)	TESTB	AL, (AX)
0x0072 00114 (main.go:7)	MOVQ	32(AX), AX
0x0076 00118 (main.go:7)	MOVQ	"".s+56(SP), CX
0x007b 00123 (main.go:7)	MOVQ	CX, (SP)
0x007f 00127 (main.go:7)	LEAQ	go.string."everyone"(SB), CX
0x0086 00134 (main.go:7)	MOVQ	CX, 8(SP)
0x008b 00139 (main.go:7)	MOVQ	$8, 16(SP)
0x0094 00148 (main.go:7)	CALL	AX

最后一步是调用 interface 方法。这一步和上一节中的流程基本一致。先通过 itab 指针找到函数指针。然后将要调用的方法的入参都放在栈顶。最后调用即可。此时内存布局如下图:

深入研究 Go interface 底层实现

看到这里可能有读者好奇,为什么结构体类型转换里面也没有 runtime.convT2I() 方法调用呢?笔者认为这里是编译器的一些优化导致的。

func convT2I(tab *itab, elem unsafe.Pointer) (i iface) {
	t := tab._type
	if raceenabled {
		raceReadObjectPC(t, elem, getcallerpc(), funcPC(convT2I))
	}
	if msanenabled {
		msanread(elem, t.size)
	}
	x := mallocgc(t.size, t, true)
	typedmemmove(t, x, elem)
	i.tab = tab
	i.data = x
	return
}

runtime.convT2I() 这个方法会生成一个 iface,在堆上生成 iface.data,并且会 typedmemmove()。笔者找了 2 个相关的 PR,感兴趣的可以看看。optimize convT2I as a two-word copy when T is pointer-shapedcmd/compile: optimize remaining convT2I calls。这里仅仅涉及类型转换,所以在内存中构造出 *itab 和 unsafe.Pointer 就够用了。编译器觉得没有必要调用 runtime.convT2I() 再构造出 iface 多此一举。

3. 隐式类型转换

日常开发中要注意隐式类型转换,一不小心会带来 bug。例如,自定义的 error 类型会因为隐藏的类型转换变为非 nil。代码如下:

package main

import "fmt"

type GrpcError struct{}

func (e GrpcError) Error() string {
	return "GrpcError"
}

func main() {
	err := cal()
	fmt.Println(err)            // 打印:<nil>
	fmt.Println(err == nil)     // 打印:false
}

func cal() error {
	var err *GrpcError = nil
	return err
}

项目中可能会把 gRPC 框架抛出来的错误再封装一层,将返回的错误信息可读性变得更强。殊不知一不小心会带来 bug。上述代码在 main 中判断 err 是否为 nil,答案是 false。error 是一个非空 interface,底层数据结构是 iface,尽管 data 是 nil,但是 *itab 并不为空,所以 err == nil 答案为 false。

看到这里可能就有读者想问,这种隐式转换有什么用。这个转换恰恰是一个精妙的设计。由本节前 2 节的内容,我们知道将一个对象传递给 interface{} 类型,编译器自动会将它转换成相关类型的数据结构。如果不这样设计,Go 语言设计者还需要再为它单独设计一套类型数据结构来支持反射特性。Go 语言设计者看到了 interface 的特点,基于它的动态类型转换实现了反射特性,事半功倍。

三. 类型断言 Type Assertion

作为 interface 另一个重要应用就是类型断言。针对非空接口和空接口,分别来看看底层汇编代码是如何处理的。

1. 非空接口

测试代码如下:

func main() {
	var s Person = &Student{name: "halfrost"}
	v, ok := s.(Person)
	if !ok {
		fmt.Printf("%v\n", v)
	}
}

利用相同的命令将上述代码转换成汇编代码。

go tool compile -S -N -l main.go >main.s3 2>&1

main 函数第一行生成 Student 对象的指针,并将它赋值给 Person 接口,这段代码在上一章中出现多次,对应的汇编代码也没有发生变化:


0x002f 00047 (main.go:8)	LEAQ	type."".Student(SB), AX
0x0036 00054 (main.go:8)	MOVQ	AX, (SP)
0x003a 00058 (main.go:8)	PCDATA	$1, $0
0x003a 00058 (main.go:8)	CALL	runtime.newobject(SB)
0x003f 00063 (main.go:8)	MOVQ	8(SP), DI
0x0044 00068 (main.go:8)	MOVQ	DI, ""..autotmp_7+80(SP)
0x0049 00073 (main.go:8)	MOVQ	$8, 8(DI)
0x0051 00081 (main.go:8)	PCDATA	$0, $-2
0x0051 00081 (main.go:8)	CMPL	runtime.writeBarrier(SB), $0
0x0058 00088 (main.go:8)	JEQ	95
0x005a 00090 (main.go:8)	JMP	529
0x005f 00095 (main.go:8)	LEAQ	go.string."halfrost"(SB), AX
0x0066 00102 (main.go:8)	MOVQ	AX, (DI)
0x0069 00105 (main.go:8)	JMP	107
0x006b 00107 (main.go:8)	PCDATA	$0, $-1
0x006b 00107 (main.go:8)	MOVQ	""..autotmp_7+80(SP), AX
0x0070 00112 (main.go:8)	MOVQ	AX, ""..autotmp_3+88(SP)
0x0075 00117 (main.go:8)	LEAQ	go.itab.*"".Student,"".Person(SB), CX
0x007c 00124 (main.go:8)	MOVQ	CX, "".s+120(SP)
0x0081 00129 (main.go:8)	MOVQ	AX, "".s+128(SP)

这里不再对上述代码进行分析,详细的见上一章。iface 结构体也生成了,在 +120(SP) ~ +128(SP) 处。到此内存布局情况如下图:

深入研究 Go interface 底层实现

接下来的代码是类型推断的关键代码。由于汇编代码过长,笔者将它拆成 2 部分。第一部分是类型断言的关键部分。


0x0089 00137 (main.go:9)	XORPS	X0, X0
0x008c 00140 (main.go:9)	MOVUPS	X0, ""..autotmp_4+152(SP)
0x0094 00148 (main.go:9)	MOVQ	"".s+120(SP), AX
0x0099 00153 (main.go:9)	MOVQ	"".s+128(SP), CX
0x00a1 00161 (main.go:9)	LEAQ	type."".Person(SB), DX
0x00a8 00168 (main.go:9)	MOVQ	DX, (SP)
0x00ac 00172 (main.go:9)	MOVQ	AX, 8(SP)
0x00b1 00177 (main.go:9)	MOVQ	CX, 16(SP)
0x00b6 00182 (main.go:9)	CALL	runtime.assertI2I2(SB)

在上述代码中,可以看到,为了调用 runtime.assertI2I2() 方法,连续在栈顶放入了 3 个参数。分别是 *interfacetype,*itab 和 unsafe.Pointer。对应 runtime.assertI2I2() 源码:

func assertI2I2(inter *interfacetype, i iface) (r iface, b bool) {
	tab := i.tab
	if tab == nil {
		return
	}
	if tab.inter != inter {
		tab = getitab(inter, tab._type, true)
		if tab == nil {
			return
		}
	}
	r.tab = tab
	r.data = i.data
	b = true
	return
}

上述代码中入参虽然是 2 个,但是 iface 可以拆成 2 个,即 *itab 和 unsafe.Pointer。所以栈顶连续的 +0(SP),+8(SP),+16(SP) 满足了函数入参的需求。上述代码逻辑很简单,如果 iface 中的 itab.inter 和第一个入参 *interfacetype 相同,说明类型相同,直接返回入参 iface 的相同类型,布尔值为 true;如果 iface 中的 itab.inter 和第一个入参 *interfacetype 不相同,则重新根据 *interfacetype 和 iface.tab 去构造 tab。构造的过程会查找 itabTable。如果类型不匹配,或者不是属于同一个 interface 类型,都会失败。getitab() 方法第三个参数是 canfail,这里传入了 true,表示构建 *itab 允许失败,失败以后返回 nil。回到 runtime.assertI2I2() 方法中,tab 构建失败以后为 nil,直接 return,导致外部接收到的 iface 是 nil,bool 也为 false。

第二部分无非是赋值部分,没有难度。


0x00bb 00187 (main.go:9)	MOVQ	24(SP), AX
0x00c0 00192 (main.go:9)	MOVQ	32(SP), CX
0x00c5 00197 (main.go:9)	MOVBLZX	40(SP), DX
0x00ca 00202 (main.go:9)	MOVQ	AX, ""..autotmp_4+152(SP)
0x00d2 00210 (main.go:9)	MOVQ	CX, ""..autotmp_4+160(SP)
0x00da 00218 (main.go:9)	MOVB	DL, ""..autotmp_5+71(SP)
0x00de 00222 (main.go:9)	MOVQ	""..autotmp_4+152(SP), AX
0x00e6 00230 (main.go:9)	MOVQ	""..autotmp_4+160(SP), CX
0x00ee 00238 (main.go:9)	MOVQ	AX, "".v+104(SP)
0x00f3 00243 (main.go:9)	MOVQ	CX, "".v+112(SP)
0x00f8 00248 (main.go:9)	MOVBLZX	""..autotmp_5+71(SP), AX
0x00fd 00253 (main.go:9)	MOVB	AL, "".ok+70(SP)

runtime.assertI2I2() 方法的返回值放在 +24(SP)、+32(SP)、+40(SP) 中。返回值是 3 个值,因为把 iface 拆成了 2 个值。注意这里 +40(SP) 用的是 MOVBLZX 命令,因为 bool 是 uint8,之后在移动过程中,也只用到了低 8 位,所以不是用的 DX 而是 DL。经过临时变量的转移,最终返回值放在了变量 v 和 ok 中。v 在内存里 +104(SP) ~ +112(SP),ok 在内存里 +70(SP)。

这里再提一点的是,如果类型推断是一个具体的类型,编译器会直接构造出 iface,而不会调用 runtime.assertI2I2() 构造 iface。例如下面的代码,类型推断处写的是具体的一个类型:

func main() {
	var s Person = &Student{name: "halfrost"}
	v, ok := s.(*Student)
	if !ok {
		fmt.Printf("%v\n", v)
	}
}

编译器在处理转换成汇编代码的时候,会做优化,不会再调用 runtime.assertI2I2() 查找 itabTable。具体处理逻辑见下面汇编代码。


0x0075 00117 (main.go:8)	LEAQ	go.itab.*"".Student,"".Person(SB), CX
0x007c 00124 (main.go:8)	MOVQ	CX, "".s+104(SP)
0x0081 00129 (main.go:8)	MOVQ	AX, "".s+112(SP)
0x0086 00134 (main.go:9)	MOVQ	$0, ""..autotmp_3+96(SP)
0x008f 00143 (main.go:9)	MOVQ	"".s+112(SP), AX
0x0094 00148 (main.go:9)	LEAQ	go.itab.*"".Student,"".Person(SB), CX
0x009b 00155 (main.go:9)	NOP
0x00a0 00160 (main.go:9)	CMPQ	"".s+104(SP), CX

上述代码中,先构造出 iface,其中 *itab 存在内存 +104(SP) 中,unsafe.Pointer 存在 +112(SP) 中。然后在类型推断的时候又重新构造了一遍 *itab,最后将新的 *itab 和前一次 +104(SP) 里的 *itab 进行对比。

小结:非空接口类型推断的实质是 iface 中 *itab 的对比。*itab 匹配成功会在内存中组装返回值。匹配失败直接清空寄存器,返回默认值。

2. 空接口

在来看看空接口的类型推断底层是怎么样的。测试代码如下:

func main() {
	var s interface{} = &Student{name: "halfrost"}
	v, ok := s.(int)
	if !ok {
		fmt.Printf("%v\n", v)
	}
}

利用相同的命令将上述代码转换成汇编代码。

go tool compile -S -N -l main.go >main.s4 2>&1

main 函数第一行生成 Student 对象的指针,并将它赋值给空接口,这段代码在上一章中出现多次,对应的汇编代码也没有发生变化:


0x002f 00047 (main.go:8)	XORPS	X0, X0
0x0032 00050 (main.go:8)	MOVUPS	X0, ""..autotmp_8+136(SP)
0x003a 00058 (main.go:8)	LEAQ	""..autotmp_8+136(SP), AX
0x0042 00066 (main.go:8)	MOVQ	AX, ""..autotmp_7+88(SP)
0x0047 00071 (main.go:8)	TESTB	AL, (AX)
0x0049 00073 (main.go:8)	MOVQ	$8, ""..autotmp_8+144(SP)
0x0055 00085 (main.go:8)	LEAQ	go.string."halfrost"(SB), CX
0x005c 00092 (main.go:8)	MOVQ	CX, ""..autotmp_8+136(SP)
0x0064 00100 (main.go:8)	MOVQ	AX, ""..autotmp_3+96(SP)
0x0069 00105 (main.go:8)	LEAQ	type.*"".Student(SB), CX
0x0070 00112 (main.go:8)	MOVQ	CX, "".s+120(SP)
0x0075 00117 (main.go:8)	MOVQ	AX, "".s+128(SP)

赋值给空接口,并不会新建临时变量,数据都存在栈上。上述代码执行完,就是组装了一个 eface 在内存中,内存布局如下:

深入研究 Go interface 底层实现

在第二章中,我们知道 eface 是空接口的数据结构,它包含 2 个字段:

type eface struct {
    _type *_type
    data  unsafe.Pointer
}

从内存中可以看到 eface 的 *_type 存在内存的 +120(SP) 处,unsafe.Pointer 存在了 +128(SP) 处。注意上图中,有多处的值是一样的,+88(SP),+96(SP),+128(SP),这 3 个地址下的值和 AX 寄存器中存的值是一样的,存的都是 +136(SP) 的地址值。再来看看空接口的类型推断汇编实现:


0x007d 00125 (main.go:9)	MOVQ	"".s+120(SP), AX
0x0082 00130 (main.go:9)	MOVQ	"".s+128(SP), CX
0x008a 00138 (main.go:9)	LEAQ	type.int(SB), DX
0x0091 00145 (main.go:9)	CMPQ	DX, AX
0x0094 00148 (main.go:9)	JEQ	155
0x0096 00150 (main.go:9)	JMP	423

从上面这段代码里面可以看出来,空接口的类型断言很简单,就是 eface 的第一个字段 *_type 和要比较类型的 *_type 进行对比,如果相同就准备接下来的返回值。


0x009b 00155 (main.go:9)	MOVQ	(CX), AX
0x009e 00158 (main.go:9)	MOVL	$1, CX
0x00a3 00163 (main.go:9)	JMP	165
0x00a5 00165 (main.go:9)	MOVQ	AX, ""..autotmp_4+80(SP)
0x00aa 00170 (main.go:9)	MOVB	CL, ""..autotmp_5+71(SP)
0x00ae 00174 (main.go:9)	MOVQ	""..autotmp_4+80(SP), AX
0x00b3 00179 (main.go:9)	MOVQ	AX, "".v+72(SP)
0x00b8 00184 (main.go:9)	MOVBLZX	""..autotmp_5+71(SP), AX
0x00bd 00189 (main.go:9)	MOVB	AL, "".ok+70(SP)

如果类型断言推断正确,就准备返回值,经过中间一些临时变量的传递,最终 v 保存在内存中 +72(SP) 处。ok 保存在内存 +70(SP) 处。最终内存中的状态如下所示:

深入研究 Go interface 底层实现


0x01a7 00423 (main.go:11)	XORL	AX, AX
0x01a9 00425 (main.go:11)	XORL	CX, CX
0x01ab 00427 (main.go:9)	JMP	165
0x01b0 00432 (main.go:9)	NOP

如果断言失败,清空 AX 和 CX 寄存器。AX 和 CX 中存的是 eface 结构体里面的 2 个字段。

小结:空接口类型推断的实质是 eface 中 *_type 的对比。*_type 匹配成功会在内存中组装返回值。匹配失败直接清空寄存器,返回默认值。

四. 类型查询 Type Switches

类型查询也是接口运算的一种。这一节详细分析一下类型查询的底层原理。首先需要解释的一点是,类型查询的对象必须是接口类型,因为一个具体的类型是固定的,声明以后就不会变化,所以具体类型的变量都不存在类型查询的运算。

先做一个约定,main 函数的第一行,不管是生成 Student 还是生成 *Student 对本节都没有影响。

	var s Person = &Student{name: "halfrost"}
	var s Person = Student{name: "halfrost"}

上面这 2 行都会生成 Person 这样的 interface 类型,生成过程中的区别在第二章类型转换中详细讲解了,这一章不再赘述。本章重点讲解的是下面 switch-case 的内容。

1. 非空接口

本节中将会用如下的代码进行深入研究。

func main() {
	var s Person = &Student{name: "halfrost"}
	switch s.(type) {
	case Person:
		person := s.(Person)
		person.sayHello("everyone")
	case *Student:
		student := s.(*Student)
		student.sayHello("everyone")
	case Student:
		student := s.(Student)
		student.sayHello("everyone")
	}
}

针对 Type Switches 还有一点需要说明的。case 后面可以跟非接口的类型名,也可以跟接口类型名。如上述代码,case 后面可以跟 Person 这个接口名,也可以跟 Student 这样非接口的类型名。接口变量和哪个类型先匹配上了,就是哪个类型了。例如先和 Person 匹配上了,那么 s 就是 Person 类型,不会继续往下匹配了。fallthrough 语句不能在 Type Switches 中使用。强行使用,编译器会报错,fallthrough statement out of placecompiler。这也符合常理,不会有一种类型能符合所有类型。将上述代码转换成汇编代码:

$ go tool compile -S -N -l main.go >main.s5 2>&1

生成的汇编代码生成 Person 类型的代码这里不再赘述,直接从 switch 开始分析。这里有 3 个 case,就分 3 部分分别分析。首先是第一部分,匹配 Person 类型。下图是此时内存中的情况:

深入研究 Go interface 底层实现


0x0086 00134 (main.go:9)	MOVQ	"".s+96(SP), AX
0x008b 00139 (main.go:9)	MOVQ	"".s+104(SP), CX
0x0090 00144 (main.go:9)	MOVQ	AX, ""..autotmp_8+128(SP)
0x0098 00152 (main.go:9)	MOVQ	CX, ""..autotmp_8+136(SP)
0x00a0 00160 (main.go:9)	TESTQ	AX, AX
0x00a3 00163 (main.go:9)	JNE	170
0x00a5 00165 (main.go:9)	JMP	750
0x00aa 00170 (main.go:9)	MOVL	16(AX), AX
0x00ad 00173 (main.go:9)	MOVL	AX, ""..autotmp_10+52(SP)

生成 Person 的 iface 在内存的 +128(SP) ~ +136(SP) 中。(16)AX 取出的是 *itab 中的 hash 值。然后存入 +52(SP) 中。接下来是匹配 case Person 的代码了。


0x00b1 00177 (main.go:10)	MOVQ	""..autotmp_8+128(SP), AX
0x00b9 00185 (main.go:10)	MOVQ	""..autotmp_8+136(SP), CX
0x00c1 00193 (main.go:10)	LEAQ	type."".Person(SB), DX
0x00c8 00200 (main.go:10)	MOVQ	DX, (SP)
0x00cc 00204 (main.go:10)	MOVQ	AX, 8(SP)
0x00d1 00209 (main.go:10)	MOVQ	CX, 16(SP)
0x00d6 00214 (main.go:10)	PCDATA	$1, $1
0x00d6 00214 (main.go:10)	CALL	runtime.assertI2I2(SB)
0x00db 00219 (main.go:10)	MOVBLZX	40(SP), AX
0x00e0 00224 (main.go:10)	MOVB	AL, ""..autotmp_9+51(SP)
0x00e4 00228 (main.go:10)	TESTB	AL, AL
0x00e6 00230 (main.go:10)	JNE	237
0x00e8 00232 (main.go:10)	JMP	383
0x00ed 00237 (main.go:10)	PCDATA	$1, $-1
0x00ed 00237 (main.go:10)	JMP	239

上述代码主要是调用 runtime.assertI2I2(),这个方法源码第三章类型推断里面分析过了,这里不再赘述。这个方法需要 2 个入参,分别是 *interfacetype, iface,DX 中放的是 type(Person) 的地址,即 *interfacetype,AX 和 CX 分别存的是 *Student 的 iface.*itab 和 iface.unsafe.Pointer,如果匹配上了,返回 bool 放在 AX 中。如果为 true,则表示 TESTB 不等,那么执行 JNE 237。如果为 false,代表与 Person 匹配失败,则表示 TESTB 相等,那么执行 JMP 383。先看匹配成功的情况,即 TESTB 不等:


0x00ed 00237 (main.go:10)	PCDATA	$1, $-1
0x00ed 00237 (main.go:10)	JMP	239
0x00ef 00239 (main.go:11)	XORPS	X0, X0
0x00f2 00242 (main.go:11)	MOVUPS	X0, ""..autotmp_5+160(SP)
0x00fa 00250 (main.go:11)	MOVQ	"".s+96(SP), AX
0x00ff 00255 (main.go:11)	MOVQ	"".s+104(SP), CX
0x0104 00260 (main.go:11)	LEAQ	type."".Person(SB), DX
0x010b 00267 (main.go:11)	MOVQ	DX, (SP)
0x010f 00271 (main.go:11)	MOVQ	AX, 8(SP)
0x0114 00276 (main.go:11)	MOVQ	CX, 16(SP)
0x0119 00281 (main.go:11)	PCDATA	$1, $0
0x0119 00281 (main.go:11)	CALL	runtime.assertI2I(SB)
0x011e 00286 (main.go:11)	MOVQ	24(SP), AX
0x0123 00291 (main.go:11)	MOVQ	32(SP), CX
0x0128 00296 (main.go:11)	MOVQ	AX, ""..autotmp_5+160(SP)
0x0130 00304 (main.go:11)	MOVQ	CX, ""..autotmp_5+168(SP)
0x0138 00312 (main.go:11)	MOVQ	AX, "".person+112(SP)
0x013d 00317 (main.go:11)	MOVQ	CX, "".person+120(SP)

在调用 Type Switches 之前,从内存图中可以看到 +96(SP) 存的是 *itab,+104(SP) 存的是 unsafe.Pointer。在调用 runtime.assertI2I() 方法之前先把 3 个入参都放在栈顶。(SP)、+8(SP)、+16(SP) 分别放的是 *interfacetype,*itab 和 unsafe.Pointer。runtime.assertI2I() 源码如下:

func assertI2I(inter *interfacetype, i iface) (r iface) {
	tab := i.tab
	if tab == nil {
		// explicit conversions require non-nil interface value.
		panic(&TypeAssertionError{nil, nil, &inter.typ, ""})
	}
	if tab.inter == inter {
		r.tab = tab
		r.data = i.data
		return
	}
	r.tab = getitab(inter, tab._type, false)
	r.data = i.data
	return
}

assertI2I() 方法比 assertI2I2() 方法返回值少了一个 bool 变量。所以函数名也少了一个 2。assertI2I() 方法比 assertI2I2() 方法更加危险,因为可能出现 panic。如果匹配上了,返回一个 iface,这个 iface 和入参的 iface 里面的值是一样的,也就是复制了一个。返回的 iface.*itab 放在 +112(SP) 中。接下来是调用方法的代码。


0x0142 00322 (main.go:12)	MOVQ	"".person+112(SP), AX
0x0147 00327 (main.go:12)	TESTB	AL, (AX)
0x0149 00329 (main.go:12)	MOVQ	32(AX), AX
0x014d 00333 (main.go:12)	MOVQ	"".person+120(SP), CX
0x0152 00338 (main.go:12)	MOVQ	CX, (SP)
0x0156 00342 (main.go:12)	LEAQ	go.string."everyone"(SB), CX
0x015d 00349 (main.go:12)	MOVQ	CX, 8(SP)
0x0162 00354 (main.go:12)	MOVQ	$8, 16(SP)
0x016b 00363 (main.go:12)	CALL	AX

上述代码在第二章类型转换中出现了多次,这里不再赘述了。主要是找到函数指针,然后将函数需要的入参放在栈顶,最后调用方法即可。回到 runtime.assertI2I2() 方法调用之后,如果为 bool 为 false,代表与 Person 匹配失败,则表示 TESTB 相等,那么执行 JMP 383。


0x017f 00383 (main.go:9)	CMPL	""..autotmp_10+52(SP), $309932300
0x0187 00391 (main.go:10)	JEQ	398
0x0189 00393 (main.go:10)	JMP	546

比较 +52(SP) 和 309932300,+52(SP) 是之前存的 hash 值。如果相等则跳转到 398。309932300 对应的十六进制是 0x1279310c,在内存中查找这个值,可以找到是 *Student 类型中 *itab 里面的的 hash 值。

go.itab.*"".Student,"".Person SRODATA dupok size=40
	0x0000 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
	0x0010 0c 31 79 12 00 00 00 00 00 00 00 00 00 00 00 00  .1y.............
	0x0020 00 00 00 00 00 00 00 00                          ........

如上图,内存中前 16 字节分别是 ,从 24 字节低 4 个字节开始是 hash 值,高 4 个字节是填充位,为了内存对齐的,这里全部填充了 0。如果 hash 值相同,代表匹配上了,那么 JEQ 398 。如果没有匹配上则 JMP 546。先看匹配上的情况:


0x018e 00398 (main.go:13)	LEAQ	go.itab.*"".Student,"".Person(SB), AX
0x0195 00405 (main.go:13)	CMPQ	""..autotmp_8+128(SP), AX
0x019d 00413 (main.go:13)	JEQ	418

hash 匹配上了只是第一步,还需要再匹配 *itab 是否相同。hash 和 *itab 两者都匹配完成,才算是走到了这个对应的 case 中。接下来是类型断言的过程:


0x01b5 00437 (main.go:14)	MOVQ	"".s+104(SP), AX
0x01ba 00442 (main.go:14)	MOVQ	"".s+96(SP), CX
0x01bf 00447 (main.go:14)	LEAQ	go.itab.*"".Student,"".Person(SB), DX
0x01c6 00454 (main.go:14)	CMPQ	CX, DX
0x01c9 00457 (main.go:14)	JEQ	464
0x01cb 00459 (main.go:14)	JMP	806
0x01d0 00464 (main.go:14)	MOVQ	AX, "".student+56(SP)

上述代码对应的是 main 函数中的第 7 行。

student := s.(*Student)

这里类型断言还做了一次 *itab 的对比,如果一致,则接下来进行方法调用前的入参准备工作,把所有的入参都放入栈顶。


0x01d5 00469 (main.go:15)	TESTB	AL, (AX)
0x01d7 00471 (main.go:15)	MOVQ	(AX), CX
0x01da 00474 (main.go:15)	MOVQ	8(AX), AX
0x01de 00478 (main.go:15)	MOVQ	CX, ""..autotmp_11+176(SP)
0x01e6 00486 (main.go:15)	MOVQ	AX, ""..autotmp_11+184(SP)
0x01ee 00494 (main.go:15)	MOVQ	CX, (SP)
0x01f2 00498 (main.go:15)	MOVQ	AX, 8(SP)
0x01f7 00503 (main.go:15)	LEAQ	go.string."everyone"(SB), AX
0x01fe 00510 (main.go:15)	MOVQ	AX, 16(SP)
0x0203 00515 (main.go:15)	MOVQ	$8, 24(SP)
0x020c 00524 (main.go:15)	PCDATA	$1, $0
0x020c 00524 (main.go:15)	CALL	"".Student.sayHello(SB)

用 *Student 指针调用 sayHello() 方法一共需要 4 个参数,分别放在了内存 (SP)、+8(SP)、+16(SP)、+24(SP) 的位置。依次放的内容是 *("halfrost")、8、*(everyone)、8 。最后调用方法的时候从栈顶取走这 4 个入参即完成调用。

回到 case 判断,如果没有匹配上,则会 JMP 546:


0x0222 00546 (main.go:9)	CMPL	""..autotmp_10+52(SP), $-736059430
0x022a 00554 (main.go:10)	JEQ	561

这段代码又是判断 hash 值。说明第二个 case 没有匹配上就开始匹配第三个 case。注意到这里打印出来的是有符号的十进制数,在计算 hash 的时候要换成十六进制。十进制 -736059430 换成二进制是 10101011110111110110000000100110,负数的反码是符号位不变,其他每一位取反,取反则为 11010100001000001001111111011001,负数的补码是反码 + 1,则补码是 11010100001000001001111111011010,转换成 16 进制即为 0xd4209fda。在内存中搜索 0xd4209fda,可以找到下面的内存布局:

go.itab."".Student,"".Person SRODATA dupok size=40
	0x0000 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
	0x0010 da 9f 20 d4 00 00 00 00 00 00 00 00 00 00 00 00  .. .............
	0x0020 00 00 00 00 00 00 00 00                          ........

可以看到 0xd4209fda 正好是 Student 类型对应 *itab 的 hash 值。 接下来的汇编代码和第二个 case 的代码逻辑完全一致,这里不再贴出完整的汇编代码。hash 值匹配上了以后,再进行 *itab 的匹配,两者都匹配成功,便会进入 case 里面,进行类型断言,类型断言还会再进行一次 *itab 的对比,如果对比相同,则会进行方法调用前的参数准备。将所有入参放入栈顶,最后调用方法。

如果上面每个 case 都匹配失败了,会 JMP 367,退出 Type Switches。


0x016f 00367 (main.go:9)	PCDATA	$1, $-1
0x016f 00367 (main.go:9)	MOVQ	192(SP), BP
0x0177 00375 (main.go:9)	ADDQ	$200, SP
0x017e 00382 (main.go:9)	RET

退出的汇编代码没有什么处理逻辑,就是清理现场,返回。小结:

  • Type Switches case 如果跟的是非空接口的类型名,则会调用 runtime.assertI2I2() 判断 case 是否匹配,如果匹配成功,进入 case 内部类型断言会再调用 runtime.assertI2I() 拿到 iface。
  • Type Switches case 如果跟的是非接口的类型名,则先根据 hash 值匹配类型,hash 匹配成功再匹配 *itab,两个都匹配成功才能进入 case 内部。进入以后的类型断言还会再判断一次 *itab 是否一致。

2. 空接口

再来看看空接口。本节中将会用如下的代码进行深入研究。

func main() {
	var s interface{} = &Student{name: "halfrost"}
	switch s.(type) {
	case Person:
		person := s.(Person)
		person.sayHello("everyone")
	case *Student:
		student := s.(*Student)
		student.sayHello("everyone")
	case Student:
		student := s.(Student)
		student.sayHello("everyone")
	}
}

用相同的命令将上述代码转换成汇编代码:

$ go tool compile -S -N -l main.go >main.s6 2>&1

由于有大量逻辑和非空接口是一样的,所以这里重点分析不同的地方。main 函数第一行生成 Student 的指针并且类型转换成 interface{} 类型,这段代码在第二章中出现了,这里不再赘述。先来看 main 函数第二行:


0x00b1 00177 (main.go:10)	MOVQ	""..autotmp_8+128(SP), AX
0x00b9 00185 (main.go:10)	MOVQ	""..autotmp_8+136(SP), CX
0x00c1 00193 (main.go:10)	LEAQ	type."".Person(SB), DX
0x00c8 00200 (main.go:10)	MOVQ	DX, (SP)
0x00cc 00204 (main.go:10)	MOVQ	AX, 8(SP)
0x00d1 00209 (main.go:10)	MOVQ	CX, 16(SP)
0x00d6 00214 (main.go:10)	PCDATA	$1, $1
0x00d6 00214 (main.go:10)	CALL	runtime.assertE2I2(SB)

可以看到上述汇编代码逻辑和非空接口的逻辑基本一致,只是调用的方法不同。非空接口调用的是 runtime.assertI2I2(),而非空接口这里调用的是 runtime.assertE2I2()。它的源码如下:

func assertE2I2(inter *interfacetype, e eface) (r iface, b bool) {
	t := e._type
	if t == nil {
		return
	}
	tab := getitab(inter, t, true)
	if tab == nil {
		return
	}
	r.tab = tab
	r.data = e.data
	b = true
	return
}

这段代码逻辑和 assertI2I2() 大体一致,只不过这里是把 eface 转换成 iface。通过调用 getitab() 方法,把 eface 中的 _type 组装成 *itab,再拼上 eface 的 data,即构成了 iface 了。成功匹配进入 case 以后,进行类型推断:


0x00fa 00250 (main.go:11)	MOVQ	"".s+96(SP), AX
0x00ff 00255 (main.go:11)	MOVQ	"".s+104(SP), CX
0x0104 00260 (main.go:11)	LEAQ	type."".Person(SB), DX
0x010b 00267 (main.go:11)	MOVQ	DX, (SP)
0x010f 00271 (main.go:11)	MOVQ	AX, 8(SP)
0x0114 00276 (main.go:11)	MOVQ	CX, 16(SP)
0x0119 00281 (main.go:11)	PCDATA	$1, $0
0x0119 00281 (main.go:11)	CALL	runtime.assertE2I(SB)

此处代码逻辑和非空接口也是一致的,只是调用的方法不同。这里调用的是 runtime.assertE2I() 方法:

func assertE2I(inter *interfacetype, e eface) (r iface) {
	t := e._type
	if t == nil {
		// explicit conversions require non-nil interface value.
		panic(&TypeAssertionError{nil, nil, &inter.typ, ""})
	}
	r.tab = getitab(inter, t, false)
	r.data = e.data
	return
}

runtime.assertE2I() 同 runtime.assertI2I() 一样都是“危险”方法,可能会发生 panic。方法返回 iface。再往下就是调用 sayHello() 方法,逻辑和非空接口完全一致,剩下的 2 个 case 匹配过程也和非空接口完全一致,这里就不分析了。

值得一提的是,在匹配非接口类型的 hash 值时,hash 值只和字段和方法有关,和字段内具体的值无关。也就是说非空接口和空接口这 2 次匹配 *Student 和 Student 类型 hash 值,都是一样的,0x1279310c 和 0xd4209fda。这是符合常理的,对象存储的字段值不同,并不改变对象的类型,只要类型完全一致,hash 值就相同。小结:

  • Type Switches case 如果跟的是空接口的类型名,则会调用 runtime.assertE2I2() 判断 case 是否匹配,如果匹配成功,进入 case 内部类型断言会再调用 runtime.assertE2I() 拿到 iface。

五. 动态派发

虽然 Go 并不是严格意义的面向对象语言,但是 Go 中 interface 可以动态派发方法,实现类似面向对象语言中的多态的特性。

多态是一种运行期的行为,它有以下几个特点:

  • 一种类型具有多种类型的能力
  • 允许不同的对象对同一消息做出灵活的反应
  • 以一种通用的方式对待个使用的对象
  • 非动态语言必须通过继承和接口的方式来实现

本节中的测试代码在前几章已经出现过,只是为单独挑出来提动态派发的概念。

func main() {
	var s Person = &Student{name: "halfrost"}
	s.sayHello("everyone")
}

将上述代码转换成汇编代码以后,根据汇编代码画出内存布局图,如下图:

深入研究 Go interface 底层实现

找到方法调用的汇编代码:


0x0075 00117 (main.go:7)    MOVQ    "".s+56(SP), AX
0x007a 00122 (main.go:7)    TESTB   AL, (AX)
0x007c 00124 (main.go:7)    MOVQ    32(AX), AX
0x0080 00128 (main.go:7)    MOVQ    "".s+64(SP), CX
0x0085 00133 (main.go:7)    MOVQ    CX, (SP)
0x0089 00137 (main.go:7)    LEAQ    go.string."everyone"(SB), CX
0x0090 00144 (main.go:7)    MOVQ    CX, 8(SP)
0x0095 00149 (main.go:7)    MOVQ    $8, 16(SP)
0x00a0 00160 (main.go:7)    CALL    AX

在上面代码中可以看到,为了调用动态派发的方法,AX 寄存器根据 *itab 里面存的 func 指针,做了一次寻址的过程,32(AX) 找到了要派发的方法的地址。然后将方法需要的入参都放入栈顶。如果此处不做动态派发,汇编代码会有什么不同的处理逻辑呢?把代码改成下面这样:

func main() {
	var s *Student = &Student{name: "halfrost"}
	s.sayHello("everyone")
}

转成汇编代码以后取出方法调用那一行的代码如下:


0x004b 00075 (main.go:20)	MOVQ	AX, (SP)
0x004f 00079 (main.go:20)	LEAQ	go.string."everyone"(SB), AX
0x0056 00086 (main.go:20)	MOVQ	AX, 8(SP)
0x005b 00091 (main.go:20)	MOVQ	$8, 16(SP)
0x0064 00100 (main.go:20)	PCDATA	$1, $0
0x0064 00100 (main.go:20)	CALL	"".(*Student).sayHello(SB)

可以看到代码少了方法寻址的过程,这里直接将入参放入栈顶,调用方法。

总结:fun 指针保存的是实体类型实现的函数列表首地址,可以通过寻址找到要调用的方法。当函数传入不同的实体类型时,调用的实际上是不同的函数实现,从而实现多态。

关于动态派发的过程,其实有 2 部分的性能损失,一部分是上面提到的,动态调用方法。这是一个函数指针的间接调用,还要经过地址偏移动态计算以后的跳转。还有一部分是构造 iface 的过程。在第一种动态派发的代码中,内存中构造了一个完整的 iface。而在第二种直接方法调用的代码中,并没有构造 iface,直接把入参放入栈顶,直接调用那个方法。针对这 2 部分的性能损失,可能有读者会担心损耗很大。在 Github 上有不少人公布了关于这里的性能测试代码。笔者不贴完整测试代码了。直接说结论:

  • 指针实现的动态派发造成的性能损失非常小,相对于一些复杂逻辑的处理函数,这点性能损失几乎可以忽略不计。
  • 结构体实现的动态派发性能损耗比较大。结构体在方法调用的时候需要传值,拷贝参数,这里导致性能损失比较大。

所以在开发中,所有动态派发的代码用指针来实现。

至此,所有关于 interface 底层原理的部分都讲解完了。interface 的应用将放在反射的文章里面讲解。

聊聊树状数组 Binary Indexed Tree

聊聊树状数组 Binary Indexed Tree

树状数组或二叉索引树(Binary Indexed Tree),又以其发明者命名为 Fenwick 树,最早由 Peter M. Fenwick 于 1994 年以 A New Data Structure for Cumulative Frequency Tables 为题发表在 SOFTWARE PRACTICE AND EXPERIENCE 上。其初衷是解决数据压缩里的累积频率(Cumulative Frequency)的计算问题,现多用于高效计算数列的前缀和,区间和。针对区间问题,除了常见的线段树解法,还可以考虑树状数组。它可以以 O(log n) 的时间得到任意前缀和 $ \sum_{i=1}^{j}A[i],1<=j<=N $,并同时支持在 O(log n)时间内支持动态单点值的修改(增加或者减少)。空间复杂度 O(n)。

利用数组实现前缀和,查询本来是 O(1),但是对于频繁更新的数组,每次重新计算前缀和,时间复杂度 O(n)。此时树状数组的优势便立即显现。

一. 一维树状数组概念

聊聊树状数组 Binary Indexed Tree

树状数组名字虽然又有树,又有数组,但是它实际上物理形式还是数组,不过每个节点的含义是树的关系,如上图。树状数组中父子节点下标关系是 $parent = son + 2^{k} $,其中 k 是子节点下标对应二进制末尾 0 的个数。

例如上图中 A 和 B 都是数组。A 数组正常存储数据,B 数组是树状数组。B4,B6,B7 是 B8 的子节点。4 的二进制是 100,4 + $2^{2} $ = 8,所以 8 是 4 的父节点。同理,7 的二进制 111,7 + $2^{0} $ = 8,8 也是 7 的父节点。

1. 节点意义

在树状数组中,所有的奇数下标的节点的含义是叶子节点,表示单点,它存的值是原数组相同下标存的值。例如上图中 B1,B3,B5,B7 分别存的值是 A1,A3,A5,A7。所有的偶数下标的节点均是父节点。父节点内存的是区间和。例如 B4 内存的是 B1 + B2 + B3 + A4 = A1 + A2 + A3 + A4。这个区间的左边界是该父节点最左边叶子节点对应的下标,右边界就是自己的下标。例如 B8 表示的区间左边界是 B1,右边界是 B8,所以它表示的区间和是 A1 + A2 + …… + A8。

$$ \begin{aligned} B_{1} &= A_{1} \\ B_{2} &= B_{1} + A_{2} = A_{1} + A_{2} \\ B_{3} &= A_{3} \\ B_{4} &= B_{2} + B_{3} + A_{4} = A_{1} + A_{2} + A_{3} + A_{4} \\ B_{5} &= A_{5} \\ B_{6} &= B_{5} + A_{6} = A_{5} + A_{6} \\ B_{7} &= A_{7} \\ B_{8} &= B_{4} + B_{6} + B_{7} + A_{8} = A_{1} + A_{2} + A_{3} + A_{4} + A_{5} + A_{6} + A_{7} + A_{8} \\ \end{aligned} $$

由数学归纳法可以得出,左边界的下标一定是 $i - 2^{k} + 1 $,其中 i 为父节点的下标,k 为 i 的二进制中末尾 0 的个数。用数学方式表达偶数节点的区间和:

$$B_{i} = \sum_{j = i - 2^{k} + 1}^{i} A_{j}$$

初始化树状数组的代码如下:

// BinaryIndexedTree define
type BinaryIndexedTree struct {
	tree     []int
	capacity int
}

// Init define
func (bit *BinaryIndexedTree) Init(nums []int) {
	bit.tree, bit.capacity = make([]int, len(nums)+1), len(nums)+1
	for i := 1; i <= len(nums); i++ {
		bit.tree[i] += nums[i-1]
		for j := i - 2; j >= i-lowbit(i); j-- {
			bit.tree[i] += nums[j]
		}
	}
}

lowbit(i) 函数返回 i 转换成二进制以后,末尾最后一个 1 代表的数值,即 $2^{k} $,k 为 i 末尾 0 的个数。我们都知道,在计算机系统中,数值一律用补码来表示和存储。原因在于,使用补码,可以将符号位和数值域统一处理;同时,加法和减法也可以统一处理。利用补码,可以 O(1) 算出 lowbit(i)。负数的补码等于正数的原码每位取反再 + 1,加一会使得负数的补码末尾的 0 和正数原码末尾的 0 一样。这两个数进行 & 运算以后,结果即为 lowbit(i):

func lowbit(x int) int {
	return x & -x
}

如果还想不通的读者,可以看这个例子,34 的二进制是 $(0010 0010)_{2}$,它的补码是 $(1101 1110)_{2}$。

$$ (0010 0010)_{2} \& (1101 1110)_{2} = (0000 0010)_{2} $$

lowbit(34) 结果是 $2^{k} = 2^{1} = 2$

2. 插入操作

树状数组上的父子的下标满足 $parent = son + 2^{k} $ 关系,所以可以通过这个公式从叶子结点不断往上递归,直到访问到最大节点值为止,祖先结点最多为 logn 个。插入操作可以实现节点值的增加或者减少,代码实现如下:

// Add define
func (bit *BinaryIndexedTree) Add(index int, val int) {
	for index <= bit.capacity {
		bit.tree[index] += val
		index += lowbit(index)
	}
}

3. 查询操作

树状数组中查询 [1, i] 区间内的和。按照节点的含义,可以得出下面的关系:

$$ \begin{aligned} Query(i) &= A_{1} + A_{2} + ...... + A_{i} \\ &= A_{1} + A_{2} + A_{i-2^{k}} + A_{i-2^{k}+1} + ...... + A_{i} \\ &= A_{1} + A_{2} + A_{i-2^{k}} + B_{i} \\ &= Query(i-2^{k}) + B_{i} \\ &= Query(i-lowbit(i)) + B_{i} \\ \end{aligned} $$

$B_{i} $ 是树状数组存的值。Query 操作实际是一个递归的过程。lowbit(i) 表示 $2^{k} $,其中 k 是 i 的二进制表示中末尾 0 的个数。i - lowbit(i) 将 i 的二进制中末尾的 1 去掉,最多有 $log(i) $ 个 1,所以查询操作最坏的时间复杂度是 O(log n)。查询操作实现代码如下:

// Query define
func (bit *BinaryIndexedTree) Query(index int) int {
	sum := 0
	for index >= 1 {
		sum += bit.tree[index]
		index -= lowbit(index)
	}
	return sum
}

二. 不同场景下树状数组的功能

根据节点维护的数据含义不同,树状数组可以提供不同的功能来满足各种各样的区间场景。下面我们先以上例中讲述的区间和为例,进而引出 RMQ 的使用场景。

1. 单点增减 + 区间求和

这种场景是树状数组最经典的场景。单点增减分别调用 add(i,v) 和 add(i,-v)。区间求和,利用前缀和的思想,求 [m,n] 区间和,即 query(n) - query(m-1)。query(n) 代表 [1,n] 区间内的和,query(m-1) 代表 [1,m-1] 区间内的和,两者相减,即 [m,n] 区间内的和。

LeetCode 对应题目是 307. Range Sum Query - Mutable327. Count of Range Sum

2. 区间增减 + 单点查询

这种情况需要做一下转化。定义差分数组 $C_{i} $ 代表 $C_{i} = A_{i} - A_{i-1} $。那么:

$$ \begin{aligned} C_{0} &= A_{0} \\ C_{1} &= A_{1} - A_{0}\\ C_{2} &= A_{2} - A_{1}\\ ......\\ C_{n} &= A_{n} - A_{n-1}\\ \sum_{j=1}^{n}C_{j} &= A_{n}\\ \end{aligned} $$

区间增减:在 [m,n] 区间内每一个数都增加 v,只影响 2 个单点的值:

$$ \begin{aligned} C_{m} &= (A_{m} + v) - A_{m-1}\\ C_{m+1} &= (A_{m+1} + v) - (A_{m} + v)\\ C_{m+2} &= (A_{m+2} + v) - (A_{m+1} + v)\\ ......\\ C_{n} &= (A_{n} + v) - (A_{n-1} + v)\\ C_{n+1} &= A_{n+1} - (A_{n} + v)\\ \end{aligned} $$

可以观察看, $C_{m+1}, C_{m+2}, ......, C_{n} $ 值都不变,变化的是 $C_{m}, C_{n+1} $。所以在这种情况下,区间增加只需要执行 add(m,v) 和 add(n+1,-v) 即可。

单点查询这时就是求前缀和了, $A_{n} = \sum_{j=1}^{n}C_{j} $,即 query(n)。

3. 区间增减 + 区间求和

这种情况是上面一种情况的增强版。区间增减的做法和上面做法一致,构造差分数组。这里主要说明区间查询怎么做。先来看 [1,n] 区间和如何求:

$$ A_{1} + A_{2} + A_{3} + ...... + A_{n}\\ \begin{aligned} &= (C_{1}) + (C_{1} + C_{2}) + (C_{1} + C_{2} + C_{3}) + ...... + \sum_{1}^{n}C_{n}\\ &= n * C_{1} + (n-1) * C_{2} + ...... + C_{n}\\ &= n * (C_{1} + C_{2} + C_{3} + ...... + C_{n}) - (0 * C_{1} + 1 * C_{2} + 2 * C_{3} + ...... + (n - 1) * C_{n})\\ &= n * \sum_{1}^{n}C_{n} - (D_{1} + D_{2} + D_{3} + ...... + D_{n})\\ &= n * \sum_{1}^{n}C_{n} - \sum_{1}^{n}D_{n}\\ \end{aligned} $$

其中 $D_{n} = (n - 1) * C_{n} $

所以求区间和,只需要再构造一个 $D_{n} $ 即可。

$$ \begin{aligned} \sum_{1}^{n}A_{n} &= A_{1} + A_{2} + A_{3} + ...... + A_{n} \\ &= n * \sum_{1}^{n}C_{n} - \sum_{1}^{n}D_{n}\\ \end{aligned} $$

以此类推,推到更一般的情况:

$$ \begin{aligned} \sum_{m}^{n}A_{n} &= A_{m} + A_{m+1} + A_{m+2} + ...... + A_{n} \\ &= \sum_{1}^{n}A_{n} - \sum_{1}^{m-1}A_{n}\\ &= (n * \sum_{1}^{n}C_{n} - \sum_{1}^{n}D_{n}) - ((m-1) * \sum_{1}^{m-1}C_{m-1} - \sum_{1}^{m-1}D_{m-1})\\ \end{aligned} $$

至此区间查询问题得解。

4. 单点增减 + 区间最值

线段树最基础的运用是区间求和,但是将 sum 操作换成 max 操作以后,也可以求区间最值,并且时间复杂度完全没有变。那树状数组呢?也可以实现相同的功能么?答案是可以的,不过时间复杂度会下降一点。

线段树求区间和,把每个小区间的和计算好,然后依次 pushUp,往上更新。把 sum 换成 max 操作,含义完全相同:取出小区间的最大值,然后依次 pushUp 得到整个区间的最大值。

树状数组求区间和,是将单点增减的增量影响更新到固定区间 $[i-2^{k}+1, i] $。但是把 sum 换成 max 操作,含义就变了。此时单点的增量和区间 max 值并无直接联系。暴力的方式是将该点与区间内所有值比较大小,取出最大值,时间复杂度 O(n * log n)。仔细观察树状数组的结构,可以发现不必枚举所有区间。例如更新 $A_{i} $ 的值,那么受到影响的树状数组下标为 $i-2^{0}, i-2^{1}, i-2^{2}, i-2^{3}, ......, i-2^{k} $,其中 $2^{k} < lowbit(i) \leqslant 2^{k+1} $。需要更新至多 k 个下标,外层循环由 O(n) 降为了 O(log n)。区间内部每次都需要重新比较,需要 O(log n) 的复杂度,总的时间复杂度为 $(O(log n))^2 $。

func (bit *BinaryIndexedTree) Add(index int, val int) {
	for index <= bit.capacity {
		bit.tree[index] = val
		for i := 1; i < lowbit(index); i = i << 1 {
			bit.tree[index] = max(bit.tree[index], bit.tree[index-i])
		}
		index += lowbit(index)
	}
}

上面解决了单点更新的问题,再来看区间最值。线段树划分区间是均分,对半分,而树状数组不是均分。在树状数组中 $B_{i} $ 表示的区间是 $[i-2^{k}+1, i] $,据此划分“不规则区间”。对于树状数组求 [m,n] 区间内最值,

  • 如果 $ m < n - 2^{k} $,那么 $ query(m,n) = max(query(m,n-2^{k}), B_{n}) $
  • 如果 $ m >= n - 2^{k} $,那么 $ query(m,n) = max(query(m,n-1), A_{n}) $
func (bit *BinaryIndexedTree) Query(m, n int) int {
	res := 0
	for n >= m {
		res = max(nums[n], res)
		n--
		for ; n-lowbit(n) >= m; n -= lowbit(n) {
			res = max(bit.tree[n], res)
		}
	}
	return res
}

n 最多经过 $(O(log n))^2 $ 变化,最终 n < m。时间复杂度为 $(O(log n))^2 $。

针对这类问题放一道经典例题《HDU 1754 I Hate It》

Problem Description
很多学校流行一种比较的习惯。老师们很喜欢询问,从某某到某某当中,分数最高的是多少。这让很多学生很反感。不管你喜不喜欢,现在需要你做的是,就是按照老师的要求,写一个程序,模拟老师的询问。当然,老师有时候需要更新某位同学的成绩。

Input
本题目包含多组测试,请处理到文件结束。
在每个测试的第一行,有两个正整数 N 和 M ( 0<N<=200000,0<M<5000 ),分别代表学生的数目和操作的数目。学生 ID 编号分别从 1 编到 N。第二行包含 N 个整数,代表这 N 个学生的初始成绩,其中第 i 个数代表 ID 为 i 的学生的成绩。接下来有 M 行。每一行有一个字符 C (只取'Q'或'U') ,和两个正整数 A,B。当 C 为 'Q' 的时候,表示这是一条询问操作,它询问 ID 从 A 到 B(包括 A,B)的学生当中,成绩最高的是多少。当 C 为 'U' 的时候,表示这是一条更新操作,要求把 ID 为 A 的学生的成绩更改为 B。

Output
对于每一次询问操作,在一行里面输出最高成绩。


Sample Input
5 6
1 2 3 4 5
Q 1 5
U 3 6
Q 3 4
Q 4 5
U 2 9
Q 1 5

Sample Output
5
6
5
9

读完题可以很快反应是单点增减 + 区间最大值的题。利用上面讲解的思想写出代码:

由于 OJ 不支持 Go,所以此处用 C 代码实现。这里还有一个 Hint,对于超大量的输入,scanf() 的性能明显优于 cin。

#include <iostream>
#include <stdio.h>
#include <stdlib.h>
using namespace std;
 
const int MAXN = 3e5;
int a[MAXN], h[MAXN];
int n, m;
 
int lowbit(int x)
{
	return x & (-x);
}
void updata(int x)
{
	int lx, i;
	while (x <= n)
	{
		h[x] = a[x];
		lx = lowbit(x);
		for (i=1; i<lx; i<<=1)
			h[x] = max(h[x], h[x-i]);
		x += lowbit(x);
	}		
}
int query(int x, int y)
{
	int ans = 0;
	while (y >= x)
	{
		ans = max(a[y], ans);
		y --;
		for (; y-lowbit(y) >= x; y -= lowbit(y))
			ans = max(h[y], ans);
	}
	return ans;
}
int main()
{
	int i, j, x, y, ans;
	char c;
	while (scanf("%d%d",&n,&m)!=EOF)
	{
		for (i=1; i<=n; i++)
			h[i] = 0;
		for (i=1; i<=n; i++)
		{
			scanf("%d",&a[i]);
			updata(i);
		}
		for (i=1; i<=m; i++)
		{
			scanf("%c",&c);
			scanf("%c",&c);
			if (c == 'Q')
			{
				scanf("%d%d",&x,&y);
				ans = query(x, y);
				printf("%d\n",ans);
			}
			else if (c == 'U')
			{
				scanf("%d%d",&x,&y);
				a[x] = y;
				updata(x);
			}
		}
	}
	return 0;
}

上述代码已 AC。感兴趣的读者可以自己做一做这道 ACM 的简单题。

5. 区间叠加 + 单点最值

看到这里可能有细心的读者疑惑,这一类题不就是第二类“区间增减 + 单点查询”类似么?可以考虑用第二类题的思路解决这一类题。不过麻烦点在于,区间叠加以后,每个单点的更新不是直接告诉增减变化,而是需要我们自己维护一个最值。例如在 [5,7] 区间当前值是 7,接下来区间 [1,9] 区间内增加了一个 2 的值。正确的做法是把 [1,4] 区间内增加 2,[8,9] 区间增加 2,[5,7] 区间维持不变,因为 7 > 2。这仅仅是 2 个区间叠加的情况,如果区间叠加的越多,需要拆分的区间也越多了。看到这里有些读者可能会考虑线段树的解法了。线段树确实是解决区间叠加问题的利器。笔者这里只讨论树状数组的解法。

聊聊树状数组 Binary Indexed Tree

当前 LeetCode 有 1836 题,Binary Indexed Tree tag 下面只有 7 题,218. The Skyline Problem 这一题算是 7 道 BIT 里面最“难”的。这道天际线的题就属于区间叠加 + 单点最值的题。笔者以这道题为例,讲讲此类题的常用解法。

聊聊树状数组 Binary Indexed Tree

要求天际线,即找到楼与楼重叠区间外边缘的线,说白了是维护各个区间内的最值。这有 2 个需要解决的问题。

  1. 如何维护最值。当一个高楼的右边界消失,剩下的各个小楼间还需要选出最大值作为天际线。剩下重重叠叠的小楼很多,树状数组如何维护区间最值是解决此类题的关键。
  2. 如何维护天际线的转折点。有些楼与楼并非完全重叠,重叠一半的情况导致天际线出现转折点。如上图中标记的红色转折点。树状数组如何维护这些点呢?

先解决第一个问题(维护最值)。树状数组只有 2 个操作,一个是 Add() 一个是 Query()。从上面关于这 2 个操作的讲解中可以知道这 2 个操作都不能满足我们的需求。Add() 操作可以改成维护区间内 max() 的操作。但是 max() 容易获得却很难“去除”。如上图 [3,7] 这个区间内的最大值是 15。根据树状数组的定义,[3,12] 这个区间内最值还是 15。观察上图可以看到 [5,12] 区间内最值其实是 12。树状数组如何维护这种最值呢?最大值既然难以“去除”,那么需要考虑如何让最大值“来的晚一点”。解决办法是将 Query() 操作含义从前缀含义改成后缀含义。Query(i) 查询区间是 [1,i],现在查询区间变成 $[i,+\infty) $。例如:[i,j] 区间内最值是 $max_{i...j} $,Query(j+1) 的结果不会包含 $max_{i...j} $,因为它查询的区间是 $[j+1,+\infty) $。这样更改以后,可以有效避免前驱高楼对后面楼的累积 max() 最值的影响。

具体做法,将 x 轴上的各个区间排序,按照 x 值大小从小到大排序。从左往右依次遍历各个区间。Add() 操作含义是加入每个区间右边界代表后缀区间的最值。这样不需要考虑“移除”最值的问题了。细心的读者可能又有疑问了:能否从右往左遍历区间,Query() 的含义继续延续前缀区间?这样做是可行的,解决第一个问题(维护最值)是可以的。但是这种处理办法解决第二个问题(维护转折点)会遇到麻烦。

再解决第二个问题(维护转折点)。如果用前缀含义的 Query(),在单点 i 上除了考虑以这个点为结束点的区间,还需要考虑以这个单点 i 为起点的区间。如果是后缀含义的 Query() 就没有这个问题了, $[i+1,+\infty) $ 这个区间内不用考虑以单点 i 为结束点的区间。此题用树状数组代码实现如下:


const LEFTSIDE = 1
const RIGHTSIDE = 2

type Point struct {
	xAxis int
	side  int
	index int
}

func getSkyline3(buildings [][]int) [][]int {
	res := [][]int{}
	if len(buildings) == 0 {
		return res
	}
	allPoints, bit := make([]Point, 0), BinaryIndexedTree{}
	// [x-axis (value), [1 (left) | 2 (right)], index (building number)]
	for i, b := range buildings {
		allPoints = append(allPoints, Point{xAxis: b[0], side: LEFTSIDE, index: i})
		allPoints = append(allPoints, Point{xAxis: b[1], side: RIGHTSIDE, index: i})
	}
	sort.Slice(allPoints, func(i, j int) bool {
		if allPoints[i].xAxis == allPoints[j].xAxis {
			return allPoints[i].side < allPoints[j].side
		}
		return allPoints[i].xAxis < allPoints[j].xAxis
	})
	bit.Init(len(allPoints))
	kth := make(map[Point]int)
	for i := 0; i < len(allPoints); i++ {
		kth[allPoints[i]] = i
	}
	for i := 0; i < len(allPoints); i++ {
		pt := allPoints[i]
		if pt.side == LEFTSIDE {
			bit.Add(kth[Point{xAxis: buildings[pt.index][1], side: RIGHTSIDE, index: pt.index}], buildings[pt.index][2])
		}
		currHeight := bit.Query(kth[pt] + 1)
		if len(res) == 0 || res[len(res)-1][1] != currHeight {
			if len(res) > 0 && res[len(res)-1][0] == pt.xAxis {
				res[len(res)-1][1] = currHeight
			} else {
				res = append(res, []int{pt.xAxis, currHeight})
			}
		}
	}
	return res
}

type BinaryIndexedTree struct {
	tree     []int
	capacity int
}

// Init define
func (bit *BinaryIndexedTree) Init(capacity int) {
	bit.tree, bit.capacity = make([]int, capacity+1), capacity
}

// Add define
func (bit *BinaryIndexedTree) Add(index int, val int) {
	for ; index > 0; index -= index & -index {
		bit.tree[index] = max(bit.tree[index], val)
	}
}

// Query define
func (bit *BinaryIndexedTree) Query(index int) int {
	sum := 0
	for ; index <= bit.capacity; index += index & -index {
		sum = max(sum, bit.tree[index])
	}
	return sum
}

此题还可以用线段树和扫描线解答。扫描线和树状数组解答此题,非常快。线段树稍微慢一些。

三. 常见应用

这一章节来谈谈树状数组的常见应用。

1. 求逆序对

给定 $ n $ 个数 $ A[n] \in [1,n] $ 的排列 P,求满足 $i < j $ 且 $ A[i] > A[j] $ 的数对 $ (i,j) $ 的个数。

这个问题就是经典的逆序数问题,如果采用朴素算法,就是枚举 i 和 j,并且判断 A[i] 和 A[j] 的值进行数值统计,如果 A[i] > A[j] 则计数器加一,统计完后计数器的值就是答案。时间复杂度为 $ O(n^{2}) $,这个时间复杂度太高,是否存在 $ O(log n) $ 的解法呢?

如果题目换成 $ A[n] \in [1,10^{10}] $,解题思路不变,只不过一开始再多加一步,离散化的操作。

假设第一步需要离散化。先把数列中的数按大小顺序转化成 1 到 n 的整数,将重复的数据编相同的号,将空缺的数据编上连续的号。使得原数列映射成为一个 1,2,...,n 的数组 B。注意,数组 B 中存的元素也是乱序的,是根据原数组映射而来的。例如原数组是 int[9,8,5,4,6,2,3,8,7,0],数组中 8 是重复的,且少了数字 1,将这个数组映射到 [1,9] 区间内,调整后的数组 B 为 int[9,8,5,4,6,2,3,8,7,1]。

再创建一个树状数组,用来记录这样一个数组 C(下标从1算起)的前缀和:若 [1, N] 这个排列中的数 i 当前已经出现,则 C[i] 的值为 1 ,否则为 0。初始时数组 C 的值均为 0。从数组 B 第一个元素开始遍历,对树状数组执行修改数组 C 的第 B[j] 个数值加 1 的操作。再在树状数组中查询有多少个数小于等于当前的数 B[j](即用树状数组查询数组 C 中的 [1,B[j]] 区间前缀和),当前插入总数 i 减去小于等于 B[j] 元素总数,差值即为大于 B[j] 元素的个数,并加入计数器。

func reversePairs(nums []int) int {
	if len(nums) <= 1 {
		return 0
	}
	arr, newPermutation, bit, res := make([]Element, len(nums)), make([]int, len(nums)), template.BinaryIndexedTree{}, 0
	for i := 0; i < len(nums); i++ {
		arr[i].data = nums[i]
		arr[i].pos = i
	}
	sort.Slice(arr, func(i, j int) bool {
		if arr[i].data == arr[j].data {
			if arr[i].pos < arr[j].pos {
				return true
			} else {
				return false
			}
		}
		return arr[i].data < arr[j].data
	})
	id := 1
	newPermutation[arr[0].pos] = 1
	for i := 1; i < len(arr); i++ {
		if arr[i].data == arr[i-1].data {
			newPermutation[arr[i].pos] = id
		} else {
			id++
			newPermutation[arr[i].pos] = id
		}
	}
	bit.Init(id)
	for i := 0; i < len(newPermutation); i++ {
		bit.Add(newPermutation[i], 1)
		res += (i + 1) - bit.Query(newPermutation[i])
	}
	return res
}

上述代码中的 newPermutation 就是映射调整后的数组 B。遍历数组 B,按顺序把元素插入到树状数组中。例如数组 B 是 int[9,8,5,4,6,2,3,8,7,1],现在往树状数组中插入 6,代表 6 这个元素出现了。query() 查询 [1,6] 区间内是否有元素出现,区间前缀和代表区间内元素出现次数和。如果有 k 个元素出现,且当前插入了 5 个元素,那么 5-k 的差值即是逆序的元素个数,这些元素一定比 6 大。这种方法是正序构造树状数组。

还有一种方法是倒序构造树状数组。例如下面代码:

	for i := len(s) - 1; i > 0; i-- {
		bit.Add(newPermutation[i], 1)
		res += bit.Query(newPermutation[i] - 1)
	}

由于是倒序插入,每次 Query 之前的元素下标一定比当前 i 要大。下标比 i 大,元素值比 A[i] 小,这样的元素和 i 可以构成逆序对。Query 查找 [1, B[j]] 区间内元素总个数,即为逆序对的总数。

注意,计算逆序对的时候不要算重复了。比如,计算当前 j 下标前面比 B[j] 值大的数,又算上 j 下标后面比 B[j] 值小的数。这样计算出现了很多重复。因为 j 下标前面的下标 k,也会寻找 k 下标后面比 B[k] 值小的数,重复计算了。那么统一找比自己下标小,但是值大的元素,那么统一找比自己下标大,但是值小的元素。切勿交叉计算。

LeetCode 对应题目是 315. Count of Smaller Numbers After Self493. Reverse Pairs1649. Create Sorted Array through Instructions

2. 求区间逆序对

给定 $ n $ 个数的序列 $ A[n] \in [1,2^{31}-1] $,然后给出 $ n \in [1,10^{5}] $ 次询问 $ [L,R] $,每次询问区间 $ [L,R] $ 中满足 $ L \leqslant i < j \leqslant R $ 且 $ A[i] > A[j] $ 的下标 $ (i,j) $ 的对数。

这个问题比上一题多了一个区间限制。这个区间的限制影响对逆序对的选择。例如:[1,3,5,2,1,1,8,9,8,6,5,3,7,7,2],求在 [3,7] 区间内的逆序数。元素 2 在区间内,比元素 2 大的元素只有 2 个。元素 3 和 5 在区间外,所以 3 和 5 不能参与逆序数的统计。比元素 2 小的元素也只有 2 个,黄色标识的 3 个 1 都比 2 小,但是第一个 1 不能算在内,因为它在区间外。

先将所有查询区间按照右端点单调不减排序,如下图所示。

这里也可以按照查询区间左端点单调不增排序。如果这样排序,下面构建树状数组需要倒序插入。并且查找的是下标靠后但是元素值小的逆序对。两者方法都可以实现,这里讲解选其中一种。

聊聊树状数组 Binary Indexed Tree

总的区间覆盖的范围决定了树状数组待插入数字的范围。如上图,总的区间位于 [1,12],那么下标为 0,13,14 的元素不需要理会,它们不会被用到,所以也不用插入到树状数组中。

求区间逆序对的过程中还需要利用到一个辅助数组 C[k],这个数组的含义是下标为 k 的元素,在插入到树状数组之前,比 A[k] 值小的元素有几个。举个例子,例如下标为 7 的元素值为 9 。C[7] = 6,因为当前比 9 小的元素是 3,5,2,1,1,8。这个辅助数组 C[k] 的意义是找到下标比它小,且元素值也比它小的元素个数。

由于这里选择区间右区间排序,所以构造树状数组插入是顺序插入。这样区间从左有右的查询可以依次得到结果。如上图中最下一行的图示,假设当前查询到了第 4 个区间。第 4 个区间包含元素值 1,8,9,8,6,5 。当前从左往右插入构造树状数组,已经插入了下标为 [1,10] 区间的元素值,即如图显示插入的数值。现在遍历查询区间内所有元素,Query(A[i] - 1) - C[i] 即为下标 i 在当前查询区间内的逆序对总个数。例如元素 9:

$$ \begin{aligned} Query(A[i] - 1) - C[i] &= Query(A[7] - 1) - C[7] \\ &= Query(9 - 1) - C[7] = Query(8) - C[7]\\ &= 9 - 6 = 3\\ \end{aligned} $$

插入 A[i] 元素构造树状数组在先,Query() 查询针对当前全局情况,即查询下标 [1,10] 区间内所有比元素 9 小的元素总数,不难发现所有元素都比元素 9 小,那么 Query(A[i] - 1) 得到的结果是 9。C[7] 是元素 9 插入到树状数组之前比元素 9 小的元素总数,是 6。两者相减,最终结果是 9 - 6 = 3。看上图也很容易看出来结果是正确的,在区间内比 9 下标值大且元素值比 9 小的只有 3 个,分别对应的下标是 8,9,10,对应的元素值是 8,6,5。

总结:

  1. 离散化数组 A[i]
  2. 对所有区间按照右端点单调不减排序
  3. 按照区间排序后的结果,从左往右依次遍历每个区间。依照从左往右的区间覆盖元素范围,从左往右将 A[i] 插入至树状数组中,每个元素插入之前计算辅助数组 C[i]。
  4. 依次遍历每个区间内的所有元素,对每个元素计算 Query(A[i] - 1) - C[i],累加逆序对的结果即是这个区间所有逆序对的总数。

3. 求树上逆序对

给定 $ n \in [0,10^{5}] $ 个结点的树,求每个结点的子树中结点编号比它小的数的个数。

树上逆序对的问题可以通过树的先序遍历可以将树转换成数组,令树上的某个结点 i,先序遍历到的顺序为 pre[i],i 的子结点个数为 a[i],则转换成数组后 i 管理的区间为 [pre[i], pre[i] + a[i] - 1],然后就可以转换成区间逆序对问题进行求解了。

四. 二维树状数组

树状数组可以扩展到二维、三维或者更高维。二维树状数组可以解决离散平面上的统计问题。

// BinaryIndexedTree2D define
type BinaryIndexedTree2D struct {
	tree [][]int
	row  int
	col  int
}

// Add define
func (bit2 *BinaryIndexedTree2D) Add(i, j int, val int) {
	for i <= bit2.row {
		k := j
		for k <= bit2.col {
			bit2.tree[i][k] += val
			k += lowbit(k)
		}
		i += lowbit(i)
	}
}

// Query define
func (bit2 *BinaryIndexedTree2D) Query(i, j int) int {
	sum := 0
	for i >= 1 {
		k := j
		for k >= 1 {
			sum += bit2.tree[i][k]
			k -= lowbit(k)
		}
		i -= lowbit(i)
	}
	return sum
}

如果把一维树状数组维护的是数轴上的统计问题,

聊聊树状数组 Binary Indexed Tree

那么二维数组维护的是二维坐标系下的统计问题。X 和 Y 分别都满足一维树状数组的性质。

聊聊树状数组 Binary Indexed Tree

线段树 Segment Tree 实战

线段树 Segment Tree 实战

线段树 Segment tree 是一种二叉树形数据结构,1977年由 Jon Louis Bentley 发明,用以存储区间或线段,并且允许快速查询结构内包含某一点的所有区间。

一个包含 $ n $ 个区间的线段树,空间复杂度为 $ O(n) $ ,查询的时间复杂度则为$ O(log n+k) $ ,其中 $ k $ 是符合条件的区间数量。线段树的数据结构也可推广到高维度。

一. 什么是线段树

以一维的线段树为例。

线段树 Segment Tree 实战

令 S 是一维线段的集合。将这些线段的端点坐标由小到大排序,令其为$ x_{1},x_{2},\cdots ,x_{m} $ 。我们将被这些端点切分的每一个区间称为“单位区间”(每个端点所在的位置会单独成为一个单位区间),从左到右包含:

$$
(-\infty ,x_{1}),[x_{1},x_{1}],(x_{1},x_{2}),[x_{2},x_{2}],...,(x_{m-1},x_{m}),[x_{m},x_{m}],(x_{m},+\infty )
$$

线段树的结构为一个二叉树,每个节点都代表一个坐标区间,节点 N 所代表的区间记为 Int(N),则其需符合以下条件:

  • 其每一个叶节点,从左到右代表每个单位区间。
  • 其内部节点代表的区间是其两个儿子代表的区间之并集。
  • 每个节点(包含叶子)中有一个存储线段的数据结构。若一个线段 S 的坐标区间包含 Int(N) 但不包含 Int(parent(N)),则节点 N 中会存储线段 S。

线段树 Segment Tree 实战

线段树是二叉树,其中每个节点代表一个区间。通常,一个节点将存储一个或多个合并的区间的数据,以便可以执行查询操作。

二. 为什么需要这种数据结构

许多问题要求我们基于对可用数据范围或区间的查询来给出结果。这可能是一个繁琐而缓慢的过程,尤其是在查询数量众多且重复的情况下。线段树让我们以对数时间复杂度有效地处理此类查询。

线段树可用于计算几何和地理信息系统领域。例如,距中心参考点/原点一定距离的空间中可能会有大量点。假设我们要查找距原点一定距离范围内的点。一个普通的查找表将需要对所有可能的点或所有可能的距离进行线性扫描(假设是散列图)。线段树使我们能够以对数时间实现这一需求,而所需空间却少得多。这样的问题称为平面范围搜索。有效地解决此类问题至关重要,尤其是在处理动态数据且瞬息万变的情况下(例如,用于空中交通的雷达系统)。下文会以线段树解决 Range Sum Query problem 为例。

线段树 Segment Tree 实战

上图即作为范围查询的线段树。

三. 构造线段树

假设数据存在 size 为 n 的 arr[] 中。

  1. 线段树的根通常代表整个数据区间。这里是 arr[0:n-1]。
  2. 树的每个叶子代表一个范围,其中仅包含一个元素。 因此,叶子代表 arr[0],arr[1] 等等,直到 arr[n-1]。
  3. 树的内部节点将代表其子节点的合并或并集结果。
  4. 每个子节点可代表其父节点所代表范围的大约一半。(二分的思想)

使用大小为 $ \approx 4 \ast n $ 的数组可以轻松表示 n 个元素范围的线段树。(Stack Overflow 对原因进行了很好的讨论。如果你还不确定,请不要担心。本文将在稍后进行讨论。)

下标为 i 的节点有两个节点,下标分别为 $ (2 \ast i + 1) $ 和 $ (2 \ast i + 2)$ 。

线段树 Segment Tree 实战

线段树看上去很直观并且非常适合递归构造。

我们将使用数组 tree[] 来存储线段树的节点(初始化为全零)。 下标从 0 开始。

  • 树的节点在下标 0 处。因此 tree[0] 是树的根。
  • tree[i] 的孩子存在 tree[2 * i + 1] 和 tree[2 * i + 2] 中。
  • 用额外的 0 或 null 值填充 arr[],使得 $ n = 2^{k} $ (其中 n 是 arr[] 的总长度,而 k 是非负整数。)
  • 叶子节点的下标取值范围在 $ \in [2^{k}-1, 2^{k+1}-2]$

线段树 Segment Tree 实战

构造线段树的代码如下:

// SegmentTree define
type SegmentTree struct {
	data, tree, lazy []int
	left, right      int
	merge            func(i, j int) int
}

// Init define
func (st *SegmentTree) Init(nums []int, oper func(i, j int) int) {
	st.merge = oper
	data, tree, lazy := make([]int, len(nums)), make([]int, 4*len(nums)), make([]int, 4*len(nums))
	for i := 0; i < len(nums); i++ {
		data[i] = nums[i]
	}
	st.data, st.tree, st.lazy = data, tree, lazy
	if len(nums) > 0 {
		st.buildSegmentTree(0, 0, len(nums)-1)
	}
}

// 在 treeIndex 的位置创建 [left....right] 区间的线段树
func (st *SegmentTree) buildSegmentTree(treeIndex, left, right int) {
	if left == right {
		st.tree[treeIndex] = st.data[left]
		return
	}
	midTreeIndex, leftTreeIndex, rightTreeIndex := left+(right-left)>>1, st.leftChild(treeIndex), st.rightChild(treeIndex)
	st.buildSegmentTree(leftTreeIndex, left, midTreeIndex)
	st.buildSegmentTree(rightTreeIndex, midTreeIndex+1, right)
	st.tree[treeIndex] = st.merge(st.tree[leftTreeIndex], st.tree[rightTreeIndex])
}

func (st *SegmentTree) leftChild(index int) int {
	return 2*index + 1
}

func (st *SegmentTree) rightChild(index int) int {
	return 2*index + 2
}

笔者将线段树合并的操作变成了一个函数。合并操作根据题意变化,常见的有加法,取 max,min 等等。

我们以 arr[] = [18, 17, 13, 19, 15, 11, 20, 12, 33, 25 ] 为例构造线段树:

线段树 Segment Tree 实战

线段树构造好以后,数组里面的数据是:

tree[] = [ 183, 82, 101, 48, 34, 43, 58, 35, 13, 19, 15, 31, 12, 33, 25, 18, 17, 0, 0, 0, 0, 0, 0, 11, 20, 0, 0, 0, 0, 0, 0 ]

线段树用 0 填充到 4*n 个元素。

LeetCode 对应题目是 218. The Skyline Problem303. Range Sum Query - Immutable307. Range Sum Query - Mutable699. Falling Squares

四. 线段树的查询

线段树的查询方法有两种,一种是直接查询,另外一种是懒查询。

1. 直接查询

当查询范围与当前节点表示的范围完全匹配时,该方法返回结果。否则,它会更深入地遍历线段树树,以找到与节点的一部分完全匹配的节点。

// 查询 [left....right] 区间内的值

// Query define
func (st *SegmentTree) Query(left, right int) int {
	if len(st.data) > 0 {
		return st.queryInTree(0, 0, len(st.data)-1, left, right)
	}
	return 0
}

// 在以 treeIndex 为根的线段树中 [left...right] 的范围里,搜索区间 [queryLeft...queryRight] 的值
func (st *SegmentTree) queryInTree(treeIndex, left, right, queryLeft, queryRight int) int {
	if left == queryLeft && right == queryRight {
		return st.tree[treeIndex]
	}
	midTreeIndex, leftTreeIndex, rightTreeIndex := left+(right-left)>>1, st.leftChild(treeIndex), st.rightChild(treeIndex)
	if queryLeft > midTreeIndex {
		return st.queryInTree(rightTreeIndex, midTreeIndex+1, right, queryLeft, queryRight)
	} else if queryRight <= midTreeIndex {
		return st.queryInTree(leftTreeIndex, left, midTreeIndex, queryLeft, queryRight)
	}
	return st.merge(st.queryInTree(leftTreeIndex, left, midTreeIndex, queryLeft, midTreeIndex),
		st.queryInTree(rightTreeIndex, midTreeIndex+1, right, midTreeIndex+1, queryRight))
}

线段树 Segment Tree 实战

在上面的示例中,查询的区间范围为[2,8] 的元素之和。没有任何线段可以完全代表[2,8] 范围。但是可以观察到,可以使用范围 [2,2],[3,4],[5,7],[8,8] 这 4 个区间构成 [8,8]。快速验证 [2,8] 处的输入元素之和为 13 + 19 + 15 + 11 + 20 + 12 + 33 = 123。[2,2],[3,4],[5,7] 和 [8,8] 的节点总和是 13 + 34 + 43 + 33 = 123。答案正确。

2. 懒查询

懒查询对应懒更新,两者是配套操作。在区间更新时,并不直接更新区间内所有节点,而是把区间内节点增减变化的值存在 lazy 数组中。等到下次查询的时候再把增减应用到具体的节点上。这样做也是为了分摊时间复杂度,保证查询和更新的时间复杂度在 O(log n) 级别,不会退化成 O(n) 级别。

懒查询节点的步骤:

  1. 先判断当前节点是否是懒节点。通过查询 lazy[i] 是否为 0 判断。如果是懒节点,将它的增减变化应用到该节点上。并且更新它的孩子节点。这一步和更新操作的第一步完全一样。
  2. 递归查询子节点,以找到适合的查询节点。

具体代码如下:

// 查询 [left....right] 区间内的值

// QueryLazy define
func (st *SegmentTree) QueryLazy(left, right int) int {
	if len(st.data) > 0 {
		return st.queryLazyInTree(0, 0, len(st.data)-1, left, right)
	}
	return 0
}

func (st *SegmentTree) queryLazyInTree(treeIndex, left, right, queryLeft, queryRight int) int {
	midTreeIndex, leftTreeIndex, rightTreeIndex := left+(right-left)>>1, st.leftChild(treeIndex), st.rightChild(treeIndex)
	if left > queryRight || right < queryLeft { // segment completely outside range
		return 0 // represents a null node
	}
	if st.lazy[treeIndex] != 0 { // this node is lazy
		for i := 0; i < right-left+1; i++ {
			st.tree[treeIndex] = st.merge(st.tree[treeIndex], st.lazy[treeIndex])
			// st.tree[treeIndex] += (right - left + 1) * st.lazy[treeIndex] // normalize current node by removing lazinesss
		}
		if left != right { // update lazy[] for children nodes
			st.lazy[leftTreeIndex] = st.merge(st.lazy[leftTreeIndex], st.lazy[treeIndex])
			st.lazy[rightTreeIndex] = st.merge(st.lazy[rightTreeIndex], st.lazy[treeIndex])
			// st.lazy[leftTreeIndex] += st.lazy[treeIndex]
			// st.lazy[rightTreeIndex] += st.lazy[treeIndex]
		}
		st.lazy[treeIndex] = 0 // current node processed. No longer lazy
	}
	if queryLeft <= left && queryRight >= right { // segment completely inside range
		return st.tree[treeIndex]
	}
	if queryLeft > midTreeIndex {
		return st.queryLazyInTree(rightTreeIndex, midTreeIndex+1, right, queryLeft, queryRight)
	} else if queryRight <= midTreeIndex {
		return st.queryLazyInTree(leftTreeIndex, left, midTreeIndex, queryLeft, queryRight)
	}
	// merge query results
	return st.merge(st.queryLazyInTree(leftTreeIndex, left, midTreeIndex, queryLeft, midTreeIndex),
		st.queryLazyInTree(rightTreeIndex, midTreeIndex+1, right, midTreeIndex+1, queryRight))
}

五. 线段树的更新

1. 单点更新

单点更新类似于 buildSegTree。更新树的叶子节点的值,该值与更新后的元素相对应。这些更新的值会通过树的上层节点把影响传播到根。

// 更新 index 位置的值

// Update define
func (st *SegmentTree) Update(index, val int) {
	if len(st.data) > 0 {
		st.updateInTree(0, 0, len(st.data)-1, index, val)
	}
}

// 以 treeIndex 为根,更新 index 位置上的值为 val
func (st *SegmentTree) updateInTree(treeIndex, left, right, index, val int) {
	if left == right {
		st.tree[treeIndex] = val
		return
	}
	midTreeIndex, leftTreeIndex, rightTreeIndex := left+(right-left)>>1, st.leftChild(treeIndex), st.rightChild(treeIndex)
	if index > midTreeIndex {
		st.updateInTree(rightTreeIndex, midTreeIndex+1, right, index, val)
	} else {
		st.updateInTree(leftTreeIndex, left, midTreeIndex, index, val)
	}
	st.tree[treeIndex] = st.merge(st.tree[leftTreeIndex], st.tree[rightTreeIndex])
}

线段树 Segment Tree 实战

在此示例中,下标为(在原始输入数据中)1、3 和 6 处的元素分别增加了 +3,-1 和 +2。可以看到更改如何沿树传播,一直到根。

2. 区间更新

线段树仅更新单个元素,非常有效,时间复杂度 O(log n)。 但是,如果我们要更新一系列元素怎么办?按照当前的方法,每个元素都必须独立更新,每个元素都会花费一些时间。分别更新每一个叶子节点意味着要多次处理它们的共同祖先。祖先节点可能被更新多次。如果想要减少这种重复计算,该怎么办?

线段树 Segment Tree 实战

在上面的示例中,根节点被更新了三次,而编号为 82 的节点被更新了两次。这是因为更新叶子节点对上层父亲节点有影响。最差的情况,查询的区间内不包含频繁更新的元素,于是需要花费很多时间更新不怎么访问的节点。增加额外的 lazy 数组,可以减少不必要的计算,并且能按需处理节点。

使用另一个数组 lazy[],它的大小与我们的线段树 array tree[] 完全相同,代表一个惰性节点。当访问或查询该节点时,lazy[i] 中保留需要增加或者减少该节点 tree[i] 的数量。 当 lazy[i] 为 0 时,表示 tree[i] 该节点不是惰性的,并且没有缓存的更新。

更新区间内节点的步骤:

  1. 先判断当前节点是否是懒节点。通过查询 lazy[i] 是否为 0 判断。如果是懒节点,将它的增减变化应用到该节点上。并且更新它的孩子节点。
  2. 如果当前节点代表的区间位于更新范围内,则将当前更新操作应用于当前节点。
  3. 递归更新子节点。

具体代码如下:


// 更新 [updateLeft....updateRight] 位置的值
// 注意这里的更新值是在原来值的基础上增加或者减少,而不是把这个区间内的值都赋值为 x,区间更新和单点更新不同
// 这里的区间更新关注的是变化,单点更新关注的是定值
// 当然区间更新也可以都更新成定值,如果只区间更新成定值,那么 lazy 更新策略需要变化,merge 策略也需要变化,这里暂不详细讨论

// UpdateLazy define
func (st *SegmentTree) UpdateLazy(updateLeft, updateRight, val int) {
	if len(st.data) > 0 {
		st.updateLazyInTree(0, 0, len(st.data)-1, updateLeft, updateRight, val)
	}
}

func (st *SegmentTree) updateLazyInTree(treeIndex, left, right, updateLeft, updateRight, val int) {
	midTreeIndex, leftTreeIndex, rightTreeIndex := left+(right-left)>>1, st.leftChild(treeIndex), st.rightChild(treeIndex)
	if st.lazy[treeIndex] != 0 { // this node is lazy
		for i := 0; i < right-left+1; i++ {
			st.tree[treeIndex] = st.merge(st.tree[treeIndex], st.lazy[treeIndex])
			//st.tree[treeIndex] += (right - left + 1) * st.lazy[treeIndex] // normalize current node by removing laziness
		}
		if left != right { // update lazy[] for children nodes
			st.lazy[leftTreeIndex] = st.merge(st.lazy[leftTreeIndex], st.lazy[treeIndex])
			st.lazy[rightTreeIndex] = st.merge(st.lazy[rightTreeIndex], st.lazy[treeIndex])
			// st.lazy[leftTreeIndex] += st.lazy[treeIndex]
			// st.lazy[rightTreeIndex] += st.lazy[treeIndex]
		}
		st.lazy[treeIndex] = 0 // current node processed. No longer lazy
	}

	if left > right || left > updateRight || right < updateLeft {
		return // out of range. escape.
	}

	if updateLeft <= left && right <= updateRight { // segment is fully within update range
		for i := 0; i < right-left+1; i++ {
			st.tree[treeIndex] = st.merge(st.tree[treeIndex], val)
			//st.tree[treeIndex] += (right - left + 1) * val // update segment
		}
		if left != right { // update lazy[] for children
			st.lazy[leftTreeIndex] = st.merge(st.lazy[leftTreeIndex], val)
			st.lazy[rightTreeIndex] = st.merge(st.lazy[rightTreeIndex], val)
			// st.lazy[leftTreeIndex] += val
			// st.lazy[rightTreeIndex] += val
		}
		return
	}
	st.updateLazyInTree(leftTreeIndex, left, midTreeIndex, updateLeft, updateRight, val)
	st.updateLazyInTree(rightTreeIndex, midTreeIndex+1, right, updateLeft, updateRight, val)
	// merge updates
	st.tree[treeIndex] = st.merge(st.tree[leftTreeIndex], st.tree[rightTreeIndex])
}

LeetCode 对应题目是 218. The Skyline Problem699. Falling Squares

六. 时间复杂度分析

让我们看一下构建过程。我们访问了线段树的每个叶子(对应于数组 arr[] 中的每个元素)。因此,我们处理大约 2 * n 个节点。这使构建过程时间复杂度为 O(n)。对于每个递归更新的过程都将丢弃区间范围的一半,以到达树中的叶子节点。这类似于二分搜索,只需要对数时间。更新叶子后,将更新树的每个级别上的直接祖先。这花费时间与树的高度成线性关系。

线段树 Segment Tree 实战

4*n 个节点可以确保将线段树构建为完整的二叉树,从而树的高度为 log(4*n + 1) 向上取整。线段树读取和更新的时间复杂度都为 O(log n)。

七. 常见题型

1. Range Sum Queries

线段树 Segment Tree 实战

Range Sum Queries 是 Range Queries 问题的子集。给定一个数据元素数组或序列,需要处理由元素范围组成的读取和更新查询。线段树 Segment Tree 和树状数组 Binary Indexed Tree (a.k.a. Fenwick Tree)) 都能很快的解决这类问题。

Range Sum Query 问题专门处理查询范围内的元素总和。这个问题存在许多变体,包括不可变数据可变数据多次更新,单次查询多次更新,多次查询

2. 单点更新

3. 区间更新

4. 区间合并

这类题目会询问区间中满足条件的连续最长区间,所以PushUp的时候需要对左右儿子的区间进行合并

  • POJ 3667 Hotel update:区间替换 query:询问满足条件的最左端点

5. 扫描线

这类题目需要将一些操作排序,然后从左到右用一根扫描线扫过去最典型的就是矩形面积并,周长并等题

6. 计数问题

在 LeetCode 中还有一类问题涉及到计数的。315. Count of Smaller Numbers After Self327. Count of Range Sum493. Reverse Pairs 这类问题可以用下面的套路解决。线段树的每个节点存的是区间计数。

// SegmentCountTree define
type SegmentCountTree struct {
	data, tree  []int
	left, right int
	merge       func(i, j int) int
}

// Init define
func (st *SegmentCountTree) Init(nums []int, oper func(i, j int) int) {
	st.merge = oper

	data, tree := make([]int, len(nums)), make([]int, 4*len(nums))
	for i := 0; i < len(nums); i++ {
		data[i] = nums[i]
	}
	st.data, st.tree = data, tree
}

// 在 treeIndex 的位置创建 [left....right] 区间的线段树
func (st *SegmentCountTree) buildSegmentTree(treeIndex, left, right int) {
	if left == right {
		st.tree[treeIndex] = st.data[left]
		return
	}
	midTreeIndex, leftTreeIndex, rightTreeIndex := left+(right-left)>>1, st.leftChild(treeIndex), st.rightChild(treeIndex)
	st.buildSegmentTree(leftTreeIndex, left, midTreeIndex)
	st.buildSegmentTree(rightTreeIndex, midTreeIndex+1, right)
	st.tree[treeIndex] = st.merge(st.tree[leftTreeIndex], st.tree[rightTreeIndex])
}

func (st *SegmentCountTree) leftChild(index int) int {
	return 2*index + 1
}

func (st *SegmentCountTree) rightChild(index int) int {
	return 2*index + 2
}

// 查询 [left....right] 区间内的值

// Query define
func (st *SegmentCountTree) Query(left, right int) int {
	if len(st.data) > 0 {
		return st.queryInTree(0, 0, len(st.data)-1, left, right)
	}
	return 0
}

// 在以 treeIndex 为根的线段树中 [left...right] 的范围里,搜索区间 [queryLeft...queryRight] 的值,值是计数值
func (st *SegmentCountTree) queryInTree(treeIndex, left, right, queryLeft, queryRight int) int {
	if queryRight < st.data[left] || queryLeft > st.data[right] {
		return 0
	}
	if queryLeft <= st.data[left] && queryRight >= st.data[right] || left == right {
		return st.tree[treeIndex]
	}
	midTreeIndex, leftTreeIndex, rightTreeIndex := left+(right-left)>>1, st.leftChild(treeIndex), st.rightChild(treeIndex)
	return st.queryInTree(rightTreeIndex, midTreeIndex+1, right, queryLeft, queryRight) +
		st.queryInTree(leftTreeIndex, left, midTreeIndex, queryLeft, queryRight)
}

// 更新计数

// UpdateCount define
func (st *SegmentCountTree) UpdateCount(val int) {
	if len(st.data) > 0 {
		st.updateCountInTree(0, 0, len(st.data)-1, val)
	}
}

// 以 treeIndex 为根,更新 [left...right] 区间内的计数
func (st *SegmentCountTree) updateCountInTree(treeIndex, left, right, val int) {
	if val >= st.data[left] && val <= st.data[right] {
		st.tree[treeIndex]++
		if left == right {
			return
		}
		midTreeIndex, leftTreeIndex, rightTreeIndex := left+(right-left)>>1, st.leftChild(treeIndex), st.rightChild(treeIndex)
		st.updateCountInTree(rightTreeIndex, midTreeIndex+1, right, val)
		st.updateCountInTree(leftTreeIndex, left, midTreeIndex, val)
	}
}

TLS Application-Layer Protocol Negotiation Extension

TLS Application-Layer Protocol Negotiation Extension

这篇文章我们主要来讨论讨论 Transport Layer Security (TLS) 握手中的 Application-Layer Protocol Negotiation 扩展。对于在同一 TCP 或 UDP 端口上支持多个应用程序协议的实例,此扩展允许应用程序层去协商将在 TLS 连接中使用哪个协议。

一. Introduction

应用层协议越来越多地封装在 TLS 协议 [RFC5246] 中。这种封装使应用程序可以使用几乎整个全球 IP 基础结构中已经存在的现有安全通信链路的 443 端口。

当单个服务器端端口号(例如端口 443)上支持多个应用程序协议时,客户端和服务器需要协商用于每个连接的应用程序协议。希望在不增加客户端和服务器之间的网络往返次数的情况下完成此协商,因为每次往返都会降低最终用户的体验。此外,允许基于协商的应用协议来选择证书将是有利的。

本文指定了 TLS 扩展,该扩展允许应用程序层在 TLS 握手中协商协议的选择。HTTPbis WG 要求进行这项工作,以解决通过 TLS 进行 HTTP/2([HTTP2])的协商。但是,ALPN 有助于协商任意应用程序层协议。

借助 ALPN,客户端会将支持的应用程序协议列表作为 TLS ClientHello 消息的一部分发送。服务器选择一个协议,并将所选协议作为 TLS ServerHello 消息的一部分发送。因此,可以在 TLS 握手中完成应用协议协商,而无需添加网络往返,并且允许服务器根据需要,将不同的证书与每个应用协议相关联。

二. Application-Layer Protocol Negotiation

1. The Application-Layer Protocol Negotiation Extension

定义了一个新的扩展类型("application_layer_protocol_negotiation(16)"),客户端可以在其 “ClientHello” 消息中包含该扩展类型。

   enum {
       application_layer_protocol_negotiation(16), (65535)
   } ExtensionType;

("application_layer_protocol_negotiation(16)") 扩展名的 "extension_data" 字段应包含 "ProtocolNameList" 值。

   opaque ProtocolName<1..2^8-1>;

   struct {
       ProtocolName protocol_name_list<2..2^16-1>
   } ProtocolNameList;

"ProtocolNameList" 按优先级从高到低包含客户端发布的协议列表。 协议是由 IANA 注册的不透明非空字节串命名的,如本文档第 6 节("IANA 注意事项")中所述。不能包含空字符串,并且不能截断字节字符串。

接收到包含 "application_layer_protocol_negotiation" 扩展名的 ClientHello 的服务器可以向客户端返回合适的协议选择作为响应。服务器将忽略它无法识别的任何协议名称。一个新的 ServerHello 扩展类型("application_layer_protocol_negotiation(16)") 可以在 ServerHello 消息扩展中返回给客户端。("application_layer_protocol_negotiation(16)") 扩展名的 "extension_data" 字段的结构与上述针对客户端 "extension_data" 的描述相同,只是 "ProtocolNameList" 必须包含一个 "ProtocolName"。

因此,ClientHello 和 ServerHello 消息中带有" application_layer_protocol_negotiation" 扩展名的完整握手具有以下流程(与 [RFC5246]的 7.3 节相比):

   Client                                              Server

   ClientHello                     -------->       ServerHello
     (ALPN extension &                               (ALPN extension &
      list of protocols)                              selected protocol)
                                                   Certificate*
                                                   ServerKeyExchange*
                                                   CertificateRequest*
                                   <--------       ServerHelloDone
   Certificate*
   ClientKeyExchange
   CertificateVerify*
   [ChangeCipherSpec]
   Finished                        -------->
                                                   [ChangeCipherSpec]
                                   <--------       Finished
   Application Data                <------->       Application Data

                                 Figure 1

   * Indicates optional or situation-dependent messages that are not always sent.

带有 "application_layer_protocol_negotiation" 扩展名的简短握手具有以下流程:

   Client                                              Server

   ClientHello                     -------->       ServerHello
     (ALPN extension &                               (ALPN extension &
      list of protocols)                              selected protocol)
                                                   [ChangeCipherSpec]
                                   <--------       Finished
   [ChangeCipherSpec]
   Finished                        -------->
   Application Data                <------->       Application Data

与许多其他 TLS 扩展不同,此扩展不建立会话的属性,仅建立连接的属性。当使用会话恢复或会话票证 [RFC5077] 时,此扩展的先前内容无关紧要,并且只用考虑新握手消息中的值。

2. Protocol Selection

期望服务器将具有优先级支持的协议列表,并且仅在客户端支持的情况下才选择协议。在这种情况下,服务器应该选择它所支持的,并且也是由客户端发布的最优先的协议。如果服务器不支持客户端传过来的协议,则服务器应以 "no_application_protocol" alert 错误回应。

   enum {
       no_application_protocol(120),
       (255)
   } AlertDescription;

在重新协商之前,ServerHello 的 "application_layer_protocol_negotiation" 扩展类型中标识的协议将此连接是确定的。服务器不会响应所选协议,并随后使用其他协议进行应用程序数据交换。

三. Design Considerations

ALPN 扩展旨在遵循 TLS 协议扩展的典型设计。具体而言,根据已建立的 TLS 体系结构,协商完全在 client/server hello 交换中执行。 ServerHello 的扩展 "application_layer_protocol_negotiation" 旨在确定连接中选择的协议(直到重新协商连接),并以纯文本形式发送,以允许网络元素在应用程序还没确定应用层协议的情况下,导致的 TCP 或 UDP 端口号不确定时,为连接提供差异化​​服务。通过将协议选择的所有权放在服务器上, ALPN 促进以下场景:证书选择或连接重新路由,这两者可能会基于协商的协议。

最终,通过在握手过程中以明文方式管理协议选择,ALPN 避免了在建立连接之前就隐藏协商协议而引入错误。如果需要隐藏协议,则在建立连接后进行重新协商(这将提供真正的 TLS 安全保证)将是首选方法。

四. Security Considerations

ALPN 扩展不会影响 TLS 会话建立或应用程序数据交换的安全性。ALPN 用于为与 TLS 连接关联的应用程序层协议提供一个外部可见的标记。从历史上看,可以从使用中的 TCP 或 UDP 端口号确定与连接关联的应用程序层协议。

打算通过添加新协议标识符来扩展协议标识符注册表的实现方和文档编辑者,应考虑到在 TLS 版本 1.2 及以下版本中,客户端以明文形式发送这些标识符。他们还应该考虑到,至少在接下来的十年中,预计浏览器通常会在初始 ClientHello 中使用这些早期版本的 TLS。

当此类标识符可能泄露个人可识别信息时,或当此类泄露可能导致概要分析或泄露敏感信息时,必须格外小心。如果这些标识符中的任何一个适用于此新协议标识符,则该标识符不应在清晰可见的 TLS 配置中使用,并且指定此类协议标识符的文档应建议避免这种不安全使用。

五. IANA Considerations

IANA 已更新其 "ExtensionType 值" 注册表,以包括以下条目:

      16 application_layer_protocol_negotiation

本文在现有的 "传输层安全性(TLS)扩展" 标题下为标题为“应用层协议协商(ALPN)协议 ID”的协议标识符建立了注册表。

此注册表中的条目需要以下字段:

  • Protocol:协议名称。
  • Identification Sequence:标识协议的一组精确的八位字节值。这可以是协议名称的 UTF-8 编码 [RFC3629]
  • Reference:对定义协议的规范的参考。

该注册表在 [RFC5226] 中定义的 "Expert Review" 策略下运行。建议指定的专家鼓励加入对永久性和易于获得的规范的引用,该规范能够创建所标识协议的可互操作的实现。

此注册表的初始注册集如下:

Protocol: HTTP/1.1
Identification Sequence:
0x68 0x74 0x74 0x70 0x2f 0x31 0x2e 0x31 ("http/1.1")
Reference: [RFC7230]

Protocol: SPDY/1
Identification Sequence:
0x73 0x70 0x64 0x79 0x2f 0x31 ("spdy/1")
Reference:
http://dev.chromium.org/spdy/spdy-protocol/spdy-protocol-draft1

Protocol: SPDY/2
Identification Sequence:
0x73 0x70 0x64 0x79 0x2f 0x32 ("spdy/2")
Reference:
http://dev.chromium.org/spdy/spdy-protocol/spdy-protocol-draft2

Protocol: SPDY/3
Identification Sequence:
0x73 0x70 0x64 0x79 0x2f 0x33 ("spdy/3")
Reference:
http://dev.chromium.org/spdy/spdy-protocol/spdy-protocol-draft3


Reference:

RFC 7301

GitHub Repo:Halfrost-Field

Follow: halfrost · GitHub

Source: https://halfrost.com/tls_alpn/

HPACK: Header Compression for HTTP/2

Table of Contents

1. Introduction

2. Compression Process Overview

3. Header Block Decoding

4. Dynamic Table Management

5. Primitive Type Representations

6. Binary Format

7. Security Considerations

8. References

HPACK: Header Compression for HTTP/2

这一章都是引用的论文,所以就不翻译了。

  • 8.1. Normative References
  • 8.2. Informative References

Appendix A. Static Table Definition

Appendix B. Huffman Code

Appendix C. Examples


Reference:

RFC 7541

GitHub Repo:Halfrost-Field

Follow: halfrost · GitHub

Source: https://halfrost.com/http2_rfc7541/

HTTP/2 HPACK 实际应用举例

HTTP/2 HPACK 实际应用举例

在上篇文章中,具体说明了 HPACK 算法中的 8 种场景(7 种 Name-value 的场景 + 1 种动态表更新场景)。

动态表大小更新有两种方式,一种是在 HEADERS 帧中直接修改(“001” 3 位模式开始),另外一种方式是通过 SETTINGS 帧中的 SETTINGS_HEADER_TABLE_SIZE 中设置的。

在介绍 HPACK 实际应用之前,需要先来看看静态表的定义和 HTTP/2 中霍夫曼编码的定义。

一. 静态表定义

静态表(请参阅第 2.3.1 节)包含一个预定义且不可更改的 header 字段列表。

静态表是根据流行网站使用的最频繁的 header 字段创建的,并添加了 HTTP/2 特定的伪 header 字段(请参见 [HTTP2]的 8.1.2.1 节)。对于具有一些频繁值的 header 字段,为每个这些频繁值添加了一个条目。对于其他标题字段,添加了带有空值的条目。

表 1 列出了构成静态表的预定义 header 字段,并提供了每个条目的索引。

Index Header Name Header Value
1 :authority
2 :method GET
3 :method POST
4 :path /
5 :path /index.html
6 :scheme http
7 :scheme https
8 :status 200
9 :status 204
10 :status 206
11 :status 304
12 :status 400
13 :status 404
14 :status 500
15 accept-charset
16 accept-encoding gzip, deflate
17 accept-language
18 accept-ranges
19 accept
20 access-control-allow-origin
21 age
22 allow
23 authorization
24 cache-control
25 content-disposition
26 content-encoding
27 content-language
28 content-length
29 content-location
30 content-range
31 content-type
32 cookie
33 date
34 etag
35 expect
36 expires
37 from
38 host
39 if-match
40 if-modified-since
41 if-none-match
42 if-range
43 if-unmodified-since
44 last-modified
45 link
46 location
47 max-forwards
48 proxy-authenticate
49 proxy-authorization
50 range
51 referer
52 refresh
53 retry-after
54 server
55 set-cookie
56 strict-transport-security
57 transfer-encoding
58 user-agent
59 vary
60 via
61 www-authenticate

二. 霍夫曼编码

1. 霍夫曼算法

如果每个字符都是等长的编码形式,是否有一种算法能保证大幅压缩这些数据?等长的编码形式首先面临的一个问题是如何避免解压的时候出现歧义误读。

霍夫曼在 1952 年发现了最优前缀码的算法,算法的核心思想是:出现概率比较大的符号采用较短的编码,概率较小的符号采用较长的编码。

举个例子,一篇文章中有很多单词,我们讲所有字母出现的频率都统计出来,以 a、b、c、d、e、f 这 6 个字母为例,它们的出现频率分别如下:

HTTP/2 HPACK 实际应用举例

第一步先从这些频率中选取频率最小的 2 个,进行合并。左子树小,右子树大。合并成新的节点以后,再放回原有的节点中。

HTTP/2 HPACK 实际应用举例

重复第一步,直到所有节点都合并到一棵树上。

HTTP/2 HPACK 实际应用举例

最后给每个左子树的指针上编码为 0,右子树的指针上编码为 1。以上 6 个字母的最终编码是 a = 0, b = 101, c = 100, d = 111, e = 1101, f = 1100 。

2. 霍夫曼编码在 HTTP/2 中的定义

当使用霍夫曼编码对字符串字面进行编码时,使用以下霍夫曼代码(请参见第 5.2 节)。

此霍夫曼代码是从大量 HTTP header 样本获得的统计信息中生成的。这是规范的霍夫曼代码(请参见 [CANONICAL]),需要进行一些调整以确保没有符号具有唯一的代码长度。

表中的每一行均定义用于表示符号的代码:

sym:要表示的符号。它是八位字节的十进制值,可能以ASCII表示形式为前缀。特定的符号 “EOS” 用于指示字符串字面的结尾。

code as bits:符号的霍夫曼编码,表示为以2为基的整数,在最高有效位(MSB)上对齐。

code as hex:符号的霍夫曼编码,以十六进制整数表示,在最低有效位(LSB)上对齐。

len:代表符号的代码的位数。

例如,符号 47 的代码(对应于 ASCII 字符 “/”)由 6 位 “0”,“1”,“1”,“0”,“0”,“0”组成。这对应于以 6 位编码的值 0x18(以十六进制表示)。

sym code as bits
aligned to MSB
code as hex
aligned to LSB
len in bits
( 0) |11111111|11000 1ff8 [13]
( 1) |11111111|11111111|1011000 7fffd8 [23]
( 2) |11111111|11111111|11111110|0010 fffffe2 [28]
( 3) |11111111|11111111|11111110|0011 fffffe3 [28]
( 4) |11111111|11111111|11111110|0100 fffffe4 [28]
( 5) |11111111|11111111|11111110|0101 fffffe5 [28]
( 6) |11111111|11111111|11111110|0110 fffffe6 [28]
( 7) |11111111|11111111|11111110|0111 fffffe7 [28]
( 8) |11111111|11111111|11111110|1000 fffffe8 [28]
( 9) |11111111|11111111|11101010 ffffea [24]
( 10) |11111111|11111111|11111111|111100 3ffffffc [30]
( 11) |11111111|11111111|11111110|1001 fffffe9 [28]
( 12) |11111111|11111111|11111110|1010 fffffea [28]
( 13) |11111111|11111111|11111111|111101 3ffffffd [30]
( 14) |11111111|11111111|11111110|1011 fffffeb [28]
( 15) |11111111|11111111|11111110|1100 fffffec [28]
( 16) |11111111|11111111|11111110|1101 fffffed [28]
( 17) |11111111|11111111|11111110|1110 fffffee [28]
( 18) |11111111|11111111|11111110|1111 fffffef [28]
( 19) |11111111|11111111|11111111|0000 ffffff0 [28]
( 20) |11111111|11111111|11111111|0001 ffffff1 [28]
( 21) |11111111|11111111|11111111|0010 ffffff2 [28]
( 22) |11111111|11111111|11111111|111110 3ffffffe [30]
( 23) |11111111|11111111|11111111|0011 ffffff3 [28]
( 24) |11111111|11111111|11111111|0100 ffffff4 [28]
( 25) |11111111|11111111|11111111|0101 ffffff5 [28]
( 26) |11111111|11111111|11111111|0110 ffffff6 [28]
( 27) |11111111|11111111|11111111|0111 ffffff7 [28]
( 28) |11111111|11111111|11111111|1000 ffffff8 [28]
( 29) |11111111|11111111|11111111|1001 ffffff9 [28]
( 30) |11111111|11111111|11111111|1010 ffffffa [28]
( 31) |11111111|11111111|11111111|1011 ffffffb [28]
' ' ( 32) |010100 14 [ 6]
'!' ( 33) |11111110|00 3f8 [10]
'"' ( 34) |11111110|01 3f9 [10]
'#' ( 35) |11111111|1010 ffa [12]
'$' ( 36) |11111111|11001 1ff9 [13]
'%' ( 37) |010101 15 [ 6]
'&' ( 38) |11111000 f8 [ 8]
''' ( 39) |11111111|010 7fa [11]
'(' ( 40) |11111110|10 3fa [10]
')' ( 41) |11111110|11 3fb [10]
'*' ( 42) |11111001 f9 [ 8]
'+' ( 43) |11111111|011 7fb [11]
',' ( 44) |11111010 fa [ 8]
'-' ( 45) |010110 16 [ 6]
'.' ( 46) |010111 17 [ 6]
'/' ( 47) |011000 18 [ 6]
'0' ( 48) |00000 0 [ 5]
'1' ( 49) |00001 1 [ 5]
'2' ( 50) |00010 2 [ 5]
'3' ( 51) |011001 19 [ 6]
'4' ( 52) |011010 1a [ 6]
'5' ( 53) |011011 1b [ 6]
'6' ( 54) |011100 1c [ 6]
'7' ( 55) |011101 1d [ 6]
'8' ( 56) |011110 1e [ 6]
'9' ( 57) |011111 1f [ 6]
':' ( 58) |1011100 5c [ 7]
';' ( 59) |11111011 fb [ 8]
'<' ( 60) |11111111|1111100 7ffc [15]
'=' ( 61) |100000 20 [ 6]
'>' ( 62) |11111111|1011 ffb [12]
'?' ( 63) |11111111|00 3fc [10]
'@' ( 64) |11111111|11010 1ffa [13]
'A' ( 65) |100001 21 [ 6]
'B' ( 66) |1011101 5d [ 7]
'C' ( 67) |1011110 5e [ 7]
'D' ( 68) |1011111 5f [ 7]
'E' ( 69) |1100000 60 [ 7]
'F' ( 70) |1100001 61 [ 7]
'G' ( 71) |1100010 62 [ 7]
'H' ( 72) |1100011 63 [ 7]
'I' ( 73) |1100100 64 [ 7]
'J' ( 74) |1100101 65 [ 7]
'K' ( 75) |1100110 66 [ 7]
'L' ( 76) |1100111 67 [ 7]
'M' ( 77) |1101000 68 [ 7]
'N' ( 78) |1101001 69 [ 7]
'O' ( 79) |1101010 6a [ 7]
'P' ( 80) |1101011 6b [ 7]
'Q' ( 81) |1101100 6c [ 7]
'R' ( 82) |1101101 6d [ 7]
'S' ( 83) |1101110 6e [ 7]
'T' ( 84) |1101111 6f [ 7]
'U' ( 85) |1110000 70 [ 7]
'V' ( 86) |1110001 71 [ 7]
'W' ( 87) |1110010 72 [ 7]
'X' ( 88) |11111100 fc [ 8]
'Y' ( 89) |1110011 73 [ 7]
'Z' ( 90) |11111101 fd [ 8]
'[' ( 91) |11111111|11011 1ffb [13]
'' ( 92) |11111111|11111110|000 7fff0 [19]
']' ( 93) |11111111|11100 1ffc [13]
'^' ( 94) |11111111|111100 3ffc [14]
'_' ( 95) |100010 22 [ 6]
'`' ( 96) |11111111|1111101 7ffd [15]
'a' ( 97) |00011 3 [ 5]
'b' ( 98) |100011 23 [ 6]
'c' ( 99) |00100 4 [ 5]
'd' (100) |100100 24 [ 6]
'e' (101) |00101 5 [ 5]
'f' (102) |100101 25 [ 6]
'g' (103) |100110 26 [ 6]
'h' (104) |100111 27 [ 6]
'i' (105) |00110 6 [ 5]
'j' (106) |1110100 74 [ 7]
'k' (107) |1110101 75 [ 7]
'l' (108) |101000 28 [ 6]
'm' (109) |101001 29 [ 6]
'n' (110) |101010 2a [ 6]
'o' (111) |00111 7 [ 5]
'p' (112) |101011 2b [ 6]
'q' (113) |1110110 76 [ 7]
'r' (114) |101100 2c [ 6]
's' (115) |01000 8 [ 5]
't' (116) |01001 9 [ 5]
'u' (117) |101101 2d [ 6]
'v' (118) |1110111 77 [ 7]
'w' (119) |1111000 78 [ 7]
'x' (120) |1111001 79 [ 7]
'y' (121) |1111010 7a [ 7]
'z' (122) |1111011 7b [ 7]
'{' (123) |11111111|1111110 7ffe [15]
' ' (124) |11111111|100 7fc
'}' (125) |11111111|111101 3ffd [14]
'~' (126) |11111111|11101 1ffd [13]
(127) |11111111|11111111|11111111|1100 ffffffc [28]
(128) |11111111|11111110|0110 fffe6 [20]
(129) |11111111|11111111|010010 3fffd2 [22]
(130) |11111111|11111110|0111 fffe7 [20]
(131) |11111111|11111110|1000 fffe8 [20]
(132) |11111111|11111111|010011 3fffd3 [22]
(133) |11111111|11111111|010100 3fffd4 [22]
(134) |11111111|11111111|010101 3fffd5 [22]
(135) |11111111|11111111|1011001 7fffd9 [23]
(136) |11111111|11111111|010110 3fffd6 [22]
(137) |11111111|11111111|1011010 7fffda [23]
(138) |11111111|11111111|1011011 7fffdb [23]
(139) |11111111|11111111|1011100 7fffdc [23]
(140) |11111111|11111111|1011101 7fffdd [23]
(141) |11111111|11111111|1011110 7fffde [23]
(142) |11111111|11111111|11101011 ffffeb [24]
(143) |11111111|11111111|1011111 7fffdf [23]
(144) |11111111|11111111|11101100 ffffec [24]
(145) |11111111|11111111|11101101 ffffed [24]
(146) |11111111|11111111|010111 3fffd7 [22]
(147) |11111111|11111111|1100000 7fffe0 [23]
(148) |11111111|11111111|11101110 ffffee [24]
(149) |11111111|11111111|1100001 7fffe1 [23]
(150) |11111111|11111111|1100010 7fffe2 [23]
(151) |11111111|11111111|1100011 7fffe3 [23]
(152) |11111111|11111111|1100100 7fffe4 [23]
(153) |11111111|11111110|11100 1fffdc [21]
(154) |11111111|11111111|011000 3fffd8 [22]
(155) |11111111|11111111|1100101 7fffe5 [23]
(156) |11111111|11111111|011001 3fffd9 [22]
(157) |11111111|11111111|1100110 7fffe6 [23]
(158) |11111111|11111111|1100111 7fffe7 [23]
(159) |11111111|11111111|11101111 ffffef [24]
(160) |11111111|11111111|011010 3fffda [22]
(161) |11111111|11111110|11101 1fffdd [21]
(162) |11111111|11111110|1001 fffe9 [20]
(163) |11111111|11111111|011011 3fffdb [22]
(164) |11111111|11111111|011100 3fffdc [22]
(165) |11111111|11111111|1101000 7fffe8 [23]
(166) |11111111|11111111|1101001 7fffe9 [23]
(167) |11111111|11111110|11110 1fffde [21]
(168) |11111111|11111111|1101010 7fffea [23]
(169) |11111111|11111111|011101 3fffdd [22]
(170) |11111111|11111111|011110 3fffde [22]
(171) |11111111|11111111|11110000 fffff0 [24]
(172) |11111111|11111110|11111 1fffdf [21]
(173) |11111111|11111111|011111 3fffdf [22]
(174) |11111111|11111111|1101011 7fffeb [23]
(175) |11111111|11111111|1101100 7fffec [23]
(176) |11111111|11111111|00000 1fffe0 [21]
(177) |11111111|11111111|00001 1fffe1 [21]
(178) |11111111|11111111|100000 3fffe0 [22]
(179) |11111111|11111111|00010 1fffe2 [21]
(180) |11111111|11111111|1101101 7fffed [23]
(181) |11111111|11111111|100001 3fffe1 [22]
(182) |11111111|11111111|1101110 7fffee [23]
(183) |11111111|11111111|1101111 7fffef [23]
(184) |11111111|11111110|1010 fffea [20]
(185) |11111111|11111111|100010 3fffe2 [22]
(186) |11111111|11111111|100011 3fffe3 [22]
(187) |11111111|11111111|100100 3fffe4 [22]
(188) |11111111|11111111|1110000 7ffff0 [23]
(189) |11111111|11111111|100101 3fffe5 [22]
(190) |11111111|11111111|100110 3fffe6 [22]
(191) |11111111|11111111|1110001 7ffff1 [23]
(192) |11111111|11111111|11111000|00 3ffffe0 [26]
(193) |11111111|11111111|11111000|01 3ffffe1 [26]
(194) |11111111|11111110|1011 fffeb [20]
(195) |11111111|11111110|001 7fff1 [19]
(196) |11111111|11111111|100111 3fffe7 [22]
(197) |11111111|11111111|1110010 7ffff2 [23]
(198) |11111111|11111111|101000 3fffe8 [22]
(199) |11111111|11111111|11110110|0 1ffffec [25]
(200) |11111111|11111111|11111000|10 3ffffe2 [26]
(201) |11111111|11111111|11111000|11 3ffffe3 [26]
(202) |11111111|11111111|11111001|00 3ffffe4 [26]
(203) |11111111|11111111|11111011|110 7ffffde [27]
(204) |11111111|11111111|11111011|111 7ffffdf [27]
(205) |11111111|11111111|11111001|01 3ffffe5 [26]
(206) |11111111|11111111|11110001 fffff1 [24]
(207) |11111111|11111111|11110110|1 1ffffed [25]
(208) |11111111|11111110|010 7fff2 [19]
(209) |11111111|11111111|00011 1fffe3 [21]
(210) |11111111|11111111|11111001|10 3ffffe6 [26]
(211) |11111111|11111111|11111100|000 7ffffe0 [27]
(212) |11111111|11111111|11111100|001 7ffffe1 [27]
(213) |11111111|11111111|11111001|11 3ffffe7 [26]
(214) |11111111|11111111|11111100|010 7ffffe2 [27]
(215) |11111111|11111111|11110010 fffff2 [24]
(216) |11111111|11111111|00100 1fffe4 [21]
(217) |11111111|11111111|00101 1fffe5 [21]
(218) |11111111|11111111|11111010|00 3ffffe8 [26]
(219) |11111111|11111111|11111010|01 3ffffe9 [26]
(220) |11111111|11111111|11111111|1101 ffffffd [28]
(221) |11111111|11111111|11111100|011 7ffffe3 [27]
(222) |11111111|11111111|11111100|100 7ffffe4 [27]
(223) |11111111|11111111|11111100|101 7ffffe5 [27]
(224) |11111111|11111110|1100 fffec [20]
(225) |11111111|11111111|11110011 fffff3 [24]
(226) |11111111|11111110|1101 fffed [20]
(227) |11111111|11111111|00110 1fffe6 [21]
(228) |11111111|11111111|101001 3fffe9 [22]
(229) |11111111|11111111|00111 1fffe7 [21]
(230) |11111111|11111111|01000 1fffe8 [21]
(231) |11111111|11111111|1110011 7ffff3 [23]
(232) |11111111|11111111|101010 3fffea [22]
(233) |11111111|11111111|101011 3fffeb [22]
(234) |11111111|11111111|11110111|0 1ffffee [25]
(235) |11111111|11111111|11110111|1 1ffffef [25]
(236) |11111111|11111111|11110100\ fffff4 [24]
(237) |11111111|11111111|11110101\ fffff5 [24]
(238) |11111111|11111111|11111010|10 3ffffea [26]
(239) |11111111|11111111|1110100 \ 7ffff4 [23]
(240) |11111111|11111111|11111010|11 3ffffeb [26]
(241) |11111111|11111111|11111100|110 7ffffe6 [27]
(242) |11111111|11111111|11111011|00 3ffffec [26]
(243) |11111111|11111111|11111011|01 3ffffed [26]
(244) |11111111|11111111|11111100|111 7ffffe7 [27]
(245) |11111111|11111111|11111101|000 7ffffe8 [27]
(246) |11111111|11111111|11111101|001 7ffffe9 [27]
(247) |11111111|11111111|11111101|010 7ffffea [27]
(248) |11111111|11111111|11111101|011 7ffffeb [27]
(249) |11111111|11111111|11111111|1110 ffffffe [28]
(250) |11111111|11111111|11111101|100 7ffffec [27]
(251) |11111111|11111111|11111101|101 7ffffed [27]
(252) |11111111|11111111|11111101|110 7ffffee [27]
(253) |11111111|11111111|11111101|111 7ffffef [27]
(254) |11111111|11111111|11111110|000 7fffff0 [27]
(255) |11111111|11111111|11111011|10 3ffffee [26]
EOS (256) |11111111|11111111|11111111|111111 3fffffff [30]

三. 例子

本章节包含一些示例,这些示例涵盖整数编码,header 字段表示以及使用和不使用霍夫曼编码的请求和响应的 header 字段的整个列表的编码。

1. 整数表示的示例

本节详细显示了整数值的表示形式(请参见第 5.1 节)。

(1). 使用 5 位前缀对 10 进行编码

10 小于 31(2^5-1),并使用 5 位前缀表示。

     0   1   2   3   4   5   6   7
   +---+---+---+---+---+---+---+---+
   | X | X | X | 0 | 1 | 0 | 1 | 0 |   10 stored on 5 bits
   +---+---+---+---+---+---+---+---+

(2). 使用 5 位前缀对 1337 进行编码

1337 大于 31(2^5-1),并使用 5 位前缀表示。5 位前缀使用其最大值(31)填充。

I = 1337 - (2^5 - 1) = 1306。I (1306) 大于等于 128。I % 128 == 26,26 + 128 == 154,154 用 8 位表示为: 10011010。I 现在是 10,(1306 / 128 == 10),用 8 位表示为: 00001010。

     0   1   2   3   4   5   6   7
   +---+---+---+---+---+---+---+---+
   | X | X | X | 1 | 1 | 1 | 1 | 1 |  Prefix = 31, I = 1306
   | 1 | 0 | 0 | 1 | 1 | 0 | 1 | 0 |  1306>=128, encode(154), I=1306/128
   | 0 | 0 | 0 | 0 | 1 | 0 | 1 | 0 |  10<128, encode(10), done
   +---+---+---+---+---+---+---+---+

(3). 从八位字节边界开始对 42 进行编码

42 小于 255 (2^8 - 1) 用 8 位前缀表示。

     0   1   2   3   4   5   6   7
   +---+---+---+---+---+---+---+---+
   | 0 | 0 | 1 | 0 | 1 | 0 | 1 | 0 |   42 stored on 8 bits
   +---+---+---+---+---+---+---+---+

2. header 字段表示的示例

本节显示了几个独立的表示示例。

(1). 带索引的字面 header 字段

header 字段表示使用字面名称 name 和字面值 value。header 字段将添加到动态表。

需要编码的 header 列表:

custom-key: custom-header

编码数据的十六进制表示:

   400a 6375 7374 6f6d 2d6b 6579 0d63 7573 | @.custom-key.cus
   746f 6d2d 6865 6164 6572                | tom-header

解码过程:

   40                                      | == Literal indexed ==
   0a                                      |   Literal name (len = 10)
   6375 7374 6f6d 2d6b 6579                | custom-key
   0d                                      |   Literal value (len = 13)
   6375 7374 6f6d 2d68 6561 6465 72        | custom-header
                                           | -> custom-key:
                                           |   custom-header

HTTP/2 HPACK 实际应用举例

由于 H 位传了 0,所以后面字符串用的字面形式表示,即 ASCII 码表示,通过查表可以知道,6375 7374 6f6d 2d6b 6579 表示的值是 custom-key,6375 7374 6f6d 2d68 6561 6465 72 表示的值是 custom-header。

编码后的动态表:

   [  1] (s =  55) custom-key: custom-header
         Table size:  55

解码后的 header 列表:

custom-key: custom-header

(2). 没有索引的字面 header 字段

header 字段表示使用索引名称 name 和字面值 value。header 字段未添加到动态表。

需要编码的 header 列表:

   :path: /sample/path

编码数据的十六进制表示:

   040c 2f73 616d 706c 652f 7061 7468      | ../sample/path

解码过程:

   04                                      | == Literal not indexed ==
                                           |   Indexed name (idx = 4)
                                           |     :path
   0c                                      |   Literal value (len = 12)
   2f73 616d 706c 652f 7061 7468           | /sample/path
                                           | -> :path: /sample/path

HTTP/2 HPACK 实际应用举例

由于 H 位传了 0,所以后面字符串用的字面形式表示,即 ASCII 码表示,通过查表可以知道,2f73 616d 706c 652f 7061 7468 表示的值是 /sample/path。由于 :path 存在于静态表中,所以只需要传 index = 4 即可。

编码后的动态表:

解码后的 header 列表:

   :path: /sample/path

(3). 从不索引的字面 header 字段

header 字段表示使用字面名称 name 和字面值 value。header 字段不会添加到动态表中,并且如果由中间件重新编码,则必须使用相同的表示形式。

需要编码的 header 列表:

   password: secret

编码数据的十六进制表示:

   1008 7061 7373 776f 7264 0673 6563 7265 | ..password.secre
   74                                      | t

解码过程:

   10                                      | == Literal never indexed ==
   08                                      |   Literal name (len = 8)
   7061 7373 776f 7264                     | password
   06                                      |   Literal value (len = 6)
   7365 6372 6574                          | secret
                                           | -> password: secret

HTTP/2 HPACK 实际应用举例

由于 H 位传了 0,所以后面字符串用的字面形式表示,即 ASCII 码表示,通过查表可以知道,7061 7373 776f 7264 表示的值是 password。7365 6372 6574 表示的值是 secret。

编码后的动态表:

解码后的 header 列表:

   password: secret

(4). 索引的 header 字段

header 字段表示使用静态表中的索引 header 字段。

需要编码的 header 列表:

   :method: GET

编码数据的十六进制表示:

   82                                      | .

解码过程:

   82                                      | == Indexed - Add ==
                                           |   idx = 2
                                           | -> :method: GET

HTTP/2 HPACK 实际应用举例

由于 :method 和 GET 都在静态表中,所以用静态表中的 index 即可。

编码后的动态表:

解码后的 header 列表:

   :method: GET

3. 没有霍夫曼编码请求的示例

本节显示了同一连接上与 HTTP 请求相对应的几个连续的 header 列表。

(1). 第一个请求

需要编码的 header 列表:

   :method: GET
   :scheme: http
   :path: /
   :authority: www.example.com

编码数据的十六进制表示:

   8286 8441 0f77 7777 2e65 7861 6d70 6c65 | ...A.www.example
   2e63 6f6d                               | .com

解码过程:

   82                                      | == Indexed - Add ==
                                           |   idx = 2
                                           | -> :method: GET
   86                                      | == Indexed - Add ==
                                           |   idx = 6
                                           | -> :scheme: http
   84                                      | == Indexed - Add ==
                                           |   idx = 4
                                           | -> :path: /
   41                                      | == Literal indexed ==
                                           |   Indexed name (idx = 1)
                                           |     :authority
   0f                                      |   Literal value (len = 15)
   7777 772e 6578 616d 706c 652e 636f 6d   | www.example.com
                                           | -> :authority:
                                           |   www.example.com

编码后的动态表:

   [  1] (s =  57) :authority: www.example.com
         Table size:  57

解码后的 header 列表:

   :method: GET
   :scheme: http
   :path: /
   :authority: www.example.com

(2). 第二个请求

需要编码的 header 列表:

   :method: GET
   :scheme: http
   :path: /
   :authority: www.example.com
   cache-control: no-cache

编码数据的十六进制表示:

   8286 84be 5808 6e6f 2d63 6163 6865      | ....X.no-cache

解码过程:

   82                                      | == Indexed - Add ==
                                           |   idx = 2
                                           | -> :method: GET
   86                                      | == Indexed - Add ==
                                           |   idx = 6
                                           | -> :scheme: http
   84                                      | == Indexed - Add ==
                                           |   idx = 4
                                           | -> :path: /
   be                                      | == Indexed - Add ==
                                           |   idx = 62
                                           | -> :authority:
                                           |   www.example.com
   58                                      | == Literal indexed ==
                                           |   Indexed name (idx = 24)
                                           |     cache-control
   08                                      |   Literal value (len = 8)
   6e6f 2d63 6163 6865                     | no-cache
                                           | -> cache-control: no-cache

编码后的动态表:

   [  1] (s =  53) cache-control: no-cache
   [  2] (s =  57) :authority: www.example.com
         Table size: 110

解码后的 header 列表:

   :method: GET
   :scheme: http
   :path: /
   :authority: www.example.com
   cache-control: no-cache

(3). 第三个请求

需要编码的 header 列表:

   :method: GET
   :scheme: https
   :path: /index.html
   :authority: www.example.com
   custom-key: custom-value

编码数据的十六进制表示:

   8287 85bf 400a 6375 7374 6f6d 2d6b 6579 | ....@.custom-key
   0c63 7573 746f 6d2d 7661 6c75 65        | .custom-value

解码过程:

   82                                      | == Indexed - Add ==
                                           |   idx = 2
                                           | -> :method: GET
   87                                      | == Indexed - Add ==
                                           |   idx = 7
                                           | -> :scheme: https
   85                                      | == Indexed - Add ==
                                           |   idx = 5
                                           | -> :path: /index.html
   bf                                      | == Indexed - Add ==
                                           |   idx = 63
                                           | -> :authority:
                                           |   www.example.com
   40                                      | == Literal indexed ==
   0a                                      |   Literal name (len = 10)
   6375 7374 6f6d 2d6b 6579                | custom-key
   0c                                      |   Literal value (len = 12)
   6375 7374 6f6d 2d76 616c 7565           | custom-value
                                           | -> custom-key:
                                           |   custom-value

编码后的动态表:

   [  1] (s =  54) custom-key: custom-value
   [  2] (s =  53) cache-control: no-cache
   [  3] (s =  57) :authority: www.example.com
         Table size: 164

解码后的 header 列表:

   :method: GET
   :scheme: https
   :path: /index.html
   :authority: www.example.com
   custom-key: custom-value

4. 有霍夫曼编码请求的示例

本节显示与上一节相同的示例,但对字面值 value 使用霍夫曼编码。

(1). 第一个请求

需要编码的 header 列表:

   :method: GET
   :scheme: http
   :path: /
   :authority: www.example.com

编码数据的十六进制表示:

   8286 8441 8cf1 e3c2 e5f2 3a6b a0ab 90f4 | ...A......:k....
   ff                                      | .

解码过程:

   82                                      | == Indexed - Add ==
                                           |   idx = 2
                                           | -> :method: GET
   86                                      | == Indexed - Add ==
                                           |   idx = 6
                                           | -> :scheme: http
   84                                      | == Indexed - Add ==
                                           |   idx = 4
                                           | -> :path: /
   41                                      | == Literal indexed ==
                                           |   Indexed name (idx = 1)
                                           |     :authority
   8c                                      |   Literal value (len = 12)
                                           |     Huffman encoded:
   f1e3 c2e5 f23a 6ba0 ab90 f4ff           | .....:k.....
                                           |     Decoded:
                                           | www.example.com
                                           | -> :authority:
                                           |   www.example.com

编码后的动态表:

   [  1] (s =  57) :authority: www.example.com
         Table size:  57

解码后的 header 列表:

   :method: GET
   :scheme: http
   :path: /
   :authority: www.example.com

(2). 第二个请求

需要编码的 header 列表:

   :method: GET
   :scheme: http
   :path: /
   :authority: www.example.com
   cache-control: no-cache

编码数据的十六进制表示:

   8286 84be 5886 a8eb 1064 9cbf           | ....X....d..

解码过程:

   82                                      | == Indexed - Add ==
                                           |   idx = 2
                                           | -> :method: GET
   86                                      | == Indexed - Add ==
                                           |   idx = 6
                                           | -> :scheme: http
   84                                      | == Indexed - Add ==
                                           |   idx = 4
                                           | -> :path: /
   be                                      | == Indexed - Add ==
                                           |   idx = 62
                                           | -> :authority:
                                           |   www.example.com
   58                                      | == Literal indexed ==
                                           |   Indexed name (idx = 24)
                                           |     cache-control
   86                                      |   Literal value (len = 6)
                                           |     Huffman encoded:
   a8eb 1064 9cbf                          | ...d..
                                           |     Decoded:
                                           | no-cache
                                           | -> cache-control: no-cache

编码后的动态表:

   [  1] (s =  53) cache-control: no-cache
   [  2] (s =  57) :authority: www.example.com
         Table size: 110

解码后的 header 列表:

   :method: GET
   :scheme: http
   :path: /
   :authority: www.example.com
   cache-control: no-cache

(3). 第三个请求

需要编码的 header 列表:

   :method: GET
   :scheme: https
   :path: /index.html
   :authority: www.example.com
   custom-key: custom-value

编码数据的十六进制表示:

   8287 85bf 4088 25a8 49e9 5ba9 7d7f 8925 | ....@.%.I.[.}..%
   a849 e95b b8e8 b4bf                     | .I.[....

解码过程:

   82                                      | == Indexed - Add ==
                                           |   idx = 2
                                           | -> :method: GET
   87                                      | == Indexed - Add ==
                                           |   idx = 7
                                           | -> :scheme: https
   85                                      | == Indexed - Add ==
                                           |   idx = 5
                                           | -> :path: /index.html
   bf                                      | == Indexed - Add ==
                                           |   idx = 63
                                           | -> :authority:
                                           |   www.example.com
   40                                      | == Literal indexed ==
   88                                      |   Literal name (len = 8)
                                           |     Huffman encoded:
   25a8 49e9 5ba9 7d7f                     | %.I.[.}.
                                           |     Decoded:
                                           | custom-key
   89                                      |   Literal value (len = 9)
                                           |     Huffman encoded:
   25a8 49e9 5bb8 e8b4 bf                  | %.I.[....
                                           |     Decoded:
                                           | custom-value
                                           | -> custom-key:
                                           |   custom-value

编码后的动态表:

   [  1] (s =  54) custom-key: custom-value
   [  2] (s =  53) cache-control: no-cache
   [  3] (s =  57) :authority: www.example.com
         Table size: 164

解码后的 header 列表:

   :method: GET
   :scheme: https
   :path: /index.html
   :authority: www.example.com
   custom-key: custom-value

5. 没有霍夫曼编码响应的示例

本节显示了同一连接上对应于 HTTP 响应的几个连续的 header 列表。 HTTP/2 设置参数 SETTINGS_HEADER_TABLE_SIZE 设置为 256 个八位字节的值,导致某些驱逐条目发生。

(1). 第一个响应

需要编码的 header 列表:

   :status: 302
   cache-control: private
   date: Mon, 21 Oct 2013 20:13:21 GMT
   location: https://www.example.com

编码数据的十六进制表示:

   4803 3330 3258 0770 7269 7661 7465 611d | H.302X.privatea.
   4d6f 6e2c 2032 3120 4f63 7420 3230 3133 | Mon, 21 Oct 2013
   2032 303a 3133 3a32 3120 474d 546e 1768 |  20:13:21 GMTn.h
   7474 7073 3a2f 2f77 7777 2e65 7861 6d70 | ttps://www.examp
   6c65 2e63 6f6d                          | le.com

解码过程:

   48                                      | == Literal indexed ==
                                           |   Indexed name (idx = 8)
                                           |     :status
   03                                      |   Literal value (len = 3)
   3330 32                                 | 302
                                           | -> :status: 302
   58                                      | == Literal indexed ==
                                           |   Indexed name (idx = 24)
                                           |     cache-control
   07                                      |   Literal value (len = 7)
   7072 6976 6174 65                       | private
                                           | -> cache-control: private
   61                                      | == Literal indexed ==
                                           |   Indexed name (idx = 33)
                                           |     date
   1d                                      |   Literal value (len = 29)
   4d6f 6e2c 2032 3120 4f63 7420 3230 3133 | Mon, 21 Oct 2013
   2032 303a 3133 3a32 3120 474d 54        |  20:13:21 GMT
                                           | -> date: Mon, 21 Oct 2013
                                           |   20:13:21 GMT
   6e                                      | == Literal indexed ==
                                           |   Indexed name (idx = 46)
   17                                      |   Literal value (len = 23)
   6874 7470 733a 2f2f 7777 772e 6578 616d | https://www.exam
   706c 652e 636f 6d                       | ple.com
                                           | -> location:
                                           |   https://www.example.com

编码后的动态表:

   [  1] (s =  63) location: https://www.example.com
   [  2] (s =  65) date: Mon, 21 Oct 2013 20:13:21 GMT
   [  3] (s =  52) cache-control: private
   [  4] (s =  42) :status: 302
         Table size: 222

解码后的 header 列表:

   :status: 302
   cache-control: private
   date: Mon, 21 Oct 2013 20:13:21 GMT
   location: https://www.example.com

(2). 第二个响应

从动态表中将(“:status”,“302”)header 字段驱逐出可用空间,以允许添加(“:status”,“307”)header 字段。

需要编码的 header 列表:

   :status: 307
   cache-control: private
   date: Mon, 21 Oct 2013 20:13:21 GMT
   location: https://www.example.com

编码数据的十六进制表示:

   4803 3330 37c1 c0bf                     | H.307...

解码过程:

   48                                      | == Literal indexed ==
                                           |   Indexed name (idx = 8)
                                           |     :status
   03                                      |   Literal value (len = 3)
   3330 37                                 | 307
                                           | - evict: :status: 302
                                           | -> :status: 307
   c1                                      | == Indexed - Add ==
                                           |   idx = 65
                                           | -> cache-control: private
   c0                                      | == Indexed - Add ==
                                           |   idx = 64
                                           | -> date: Mon, 21 Oct 2013
                                           |   20:13:21 GMT
   bf                                      | == Indexed - Add ==
                                           |   idx = 63
                                           | -> location:
                                           |   https://www.example.com

编码后的动态表:

   [  1] (s =  42) :status: 307
   [  2] (s =  63) location: https://www.example.com
   [  3] (s =  65) date: Mon, 21 Oct 2013 20:13:21 GMT
   [  4] (s =  52) cache-control: private
         Table size: 222

解码后的 header 列表:

   :status: 307
   cache-control: private
   date: Mon, 21 Oct 2013 20:13:21 GMT
   location: https://www.example.com

(3). 第三个响应

在处理此 header 列表期间,会从动态表中逐出几个 header 字段。

需要编码的 header 列表:

   :status: 200
   cache-control: private
   date: Mon, 21 Oct 2013 20:13:22 GMT
   location: https://www.example.com
   content-encoding: gzip
   set-cookie: foo=ASDJKHQKBZXOQWEOPIUAXQWEOIU; max-age=3600; version=1

编码数据的十六进制表示:

   88c1 611d 4d6f 6e2c 2032 3120 4f63 7420 | ..a.Mon, 21 Oct
   3230 3133 2032 303a 3133 3a32 3220 474d | 2013 20:13:22 GM
   54c0 5a04 677a 6970 7738 666f 6f3d 4153 | T.Z.gzipw8foo=AS
   444a 4b48 514b 425a 584f 5157 454f 5049 | DJKHQKBZXOQWEOPI
   5541 5851 5745 4f49 553b 206d 6178 2d61 | UAXQWEOIU; max-a
   6765 3d33 3630 303b 2076 6572 7369 6f6e | ge=3600; version
   3d31                                    | =1

解码过程:

   88                                      | == Indexed - Add ==
                                           |   idx = 8
                                           | -> :status: 200
   c1                                      | == Indexed - Add ==
                                           |   idx = 65
                                           | -> cache-control: private
   61                                      | == Literal indexed ==
                                           |   Indexed name (idx = 33)
                                           |     date
   1d                                      |   Literal value (len = 29)
   4d6f 6e2c 2032 3120 4f63 7420 3230 3133 | Mon, 21 Oct 2013
   2032 303a 3133 3a32 3220 474d 54        |  20:13:22 GMT
                                           | - evict: cache-control:
                                           |   private
                                           | -> date: Mon, 21 Oct 2013
                                           |   20:13:22 GMT
   c0                                      | == Indexed - Add ==
                                           |   idx = 64
                                           | -> location:
                                           |   https://www.example.com
   5a                                      | == Literal indexed ==
                                           |   Indexed name (idx = 26)
                                           |     content-encoding
   04                                      |   Literal value (len = 4)
   677a 6970                               | gzip
                                           | - evict: date: Mon, 21 Oct
                                           |    2013 20:13:21 GMT
                                           | -> content-encoding: gzip
   77                                      | == Literal indexed ==
                                           |   Indexed name (idx = 55)
                                           |     set-cookie
   38                                      |   Literal value (len = 56)
   666f 6f3d 4153 444a 4b48 514b 425a 584f | foo=ASDJKHQKBZXO
   5157 454f 5049 5541 5851 5745 4f49 553b | QWEOPIUAXQWEOIU;
   206d 6178 2d61 6765 3d33 3630 303b 2076 |  max-age=3600; v
   6572 7369 6f6e 3d31                     | ersion=1
                                           | - evict: location:
                                           |   https://www.example.com
                                           | - evict: :status: 307
                                           | -> set-cookie: foo=ASDJKHQ
                                           |   KBZXOQWEOPIUAXQWEOIU; ma
                                           |   x-age=3600; version=1

编码后的动态表:

   [  1] (s =  98) set-cookie: foo=ASDJKHQKBZXOQWEOPIUAXQWEOIU;
                    max-age=3600; version=1
   [  2] (s =  52) content-encoding: gzip
   [  3] (s =  65) date: Mon, 21 Oct 2013 20:13:22 GMT
         Table size: 215

解码后的 header 列表:

   :status: 200
   cache-control: private
   date: Mon, 21 Oct 2013 20:13:22 GMT
   location: https://www.example.com
   content-encoding: gzip
   set-cookie: foo=ASDJKHQKBZXOQWEOPIUAXQWEOIU; max-age=3600; version=1

6. 有霍夫曼编码响应的示例

本节显示与上一节相同的示例,但对字面值使用霍夫曼编码。HTTP/2 设置参数 SETTINGS_HEADER_TABLE_SIZE 设置为 256 个八位字节的值,导致某些驱逐事件发生。驱逐机制使用已解码字面值的长度,因此发生与上一节相同的驱逐。

(1). 第一个响应

需要编码的 header 列表:

   :status: 302
   cache-control: private
   date: Mon, 21 Oct 2013 20:13:21 GMT
   location: https://www.example.com

编码数据的十六进制表示:

   4882 6402 5885 aec3 771a 4b61 96d0 7abe | H.d.X...w.Ka..z.
   9410 54d4 44a8 2005 9504 0b81 66e0 82a6 | ..T.D. .....f...
   2d1b ff6e 919d 29ad 1718 63c7 8f0b 97c8 | -..n..)...c.....
   e9ae 82ae 43d3                          | ....C.

解码过程:

   48                                      | == Literal indexed ==
                                           |   Indexed name (idx = 8)
                                           |     :status
   82                                      |   Literal value (len = 2)
                                           |     Huffman encoded:
   6402                                    | d.
                                           |     Decoded:
                                           | 302
                                           | -> :status: 302
   58                                      | == Literal indexed ==
                                           |   Indexed name (idx = 24)
                                           |     cache-control
   85                                      |   Literal value (len = 5)
                                           |     Huffman encoded:
   aec3 771a 4b                            | ..w.K
                                           |     Decoded:
                                           | private
                                           | -> cache-control: private
   61                                      | == Literal indexed ==
                                           |   Indexed name (idx = 33)
                                           |     date
   96                                      |   Literal value (len = 22)
                                           |     Huffman encoded:
   d07a be94 1054 d444 a820 0595 040b 8166 | .z...T.D. .....f
   e082 a62d 1bff                          | ...-..
                                           |     Decoded:
                                           | Mon, 21 Oct 2013 20:13:21
                                           | GMT
                                           | -> date: Mon, 21 Oct 2013
                                           |   20:13:21 GMT
   6e                                      | == Literal indexed ==
                                           |   Indexed name (idx = 46)
                                           |     location
   91                                      |   Literal value (len = 17)
                                           |     Huffman encoded:
   9d29 ad17 1863 c78f 0b97 c8e9 ae82 ae43 | .)...c.........C
   d3                                      | .
                                           |     Decoded:
                                           | https://www.example.com
                                           | -> location:
                                           |   https://www.example.com

编码后的动态表:

   [  1] (s =  63) location: https://www.example.com
   [  2] (s =  65) date: Mon, 21 Oct 2013 20:13:21 GMT
   [  3] (s =  52) cache-control: private
   [  4] (s =  42) :status: 302
         Table size: 222

解码后的 header 列表:

   :status: 302
   cache-control: private
   date: Mon, 21 Oct 2013 20:13:21 GMT
   location: https://www.example.com

(2). 第二个响应

从动态表中将(“:status”,“302”)头字段驱逐出可用空间,以允许添加(“:status”,“307”)header 字段。

需要编码的 header 列表:

   :status: 307
   cache-control: private
   date: Mon, 21 Oct 2013 20:13:21 GMT
   location: https://www.example.com

编码数据的十六进制表示:

   4883 640e ffc1 c0bf                     | H.d.....

解码过程:

   48                                      | == Literal indexed ==
                                           |   Indexed name (idx = 8)
                                           |     :status
   83                                      |   Literal value (len = 3)
                                           |     Huffman encoded:
   640e ff                                 | d..
                                           |     Decoded:
                                           | 307
                                           | - evict: :status: 302
                                           | -> :status: 307
   c1                                      | == Indexed - Add ==
                                           |   idx = 65
                                           | -> cache-control: private
   c0                                      | == Indexed - Add ==
                                           |   idx = 64
                                           | -> date: Mon, 21 Oct 2013
                                           |   20:13:21 GMT
   bf                                      | == Indexed - Add ==
                                           |   idx = 63
                                           | -> location:
                                           |   https://www.example.com

编码后的动态表:

   [  1] (s =  42) :status: 307
   [  2] (s =  63) location: https://www.example.com
   [  3] (s =  65) date: Mon, 21 Oct 2013 20:13:21 GMT
   [  4] (s =  52) cache-control: private
         Table size: 222

解码后的 header 列表:

   :status: 307
   cache-control: private
   date: Mon, 21 Oct 2013 20:13:21 GMT
   location: https://www.example.com

(3). 第三个响应

在处理此 header 列表期间,会从动态表中逐出几个 header 字段。

需要编码的 header 列表:

   :status: 200
   cache-control: private
   date: Mon, 21 Oct 2013 20:13:22 GMT
   location: https://www.example.com
   content-encoding: gzip
   set-cookie: foo=ASDJKHQKBZXOQWEOPIUAXQWEOIU; max-age=3600; version=1

编码数据的十六进制表示:

   88c1 6196 d07a be94 1054 d444 a820 0595 | ..a..z...T.D. ..
   040b 8166 e084 a62d 1bff c05a 839b d9ab | ...f...-...Z....
   77ad 94e7 821d d7f2 e6c7 b335 dfdf cd5b | w..........5...[
   3960 d5af 2708 7f36 72c1 ab27 0fb5 291f | 9`..'..6r..'..).
   9587 3160 65c0 03ed 4ee5 b106 3d50 07   | ..1`e...N...=P.

解码过程:

   88                                      | == Indexed - Add ==
                                           |   idx = 8
                                           | -> :status: 200
   c1                                      | == Indexed - Add ==
                                           |   idx = 65
                                           | -> cache-control: private
   61                                      | == Literal indexed ==
                                           |   Indexed name (idx = 33)
                                           |     date
   96                                      |   Literal value (len = 22)
                                           |     Huffman encoded:
   d07a be94 1054 d444 a820 0595 040b 8166 | .z...T.D. .....f
   e084 a62d 1bff                          | ...-..
                                           |     Decoded:
                                           | Mon, 21 Oct 2013 20:13:22
                                           | GMT
                                           | - evict: cache-control:
                                           |   private
                                           | -> date: Mon, 21 Oct 2013
                                           |   20:13:22 GMT
   c0                                      | == Indexed - Add ==
                                           |   idx = 64
                                           | -> location:
                                           |   https://www.example.com
   5a                                      | == Literal indexed ==
                                           |   Indexed name (idx = 26)
                                           |     content-encoding
   83                                      |   Literal value (len = 3)
                                           |     Huffman encoded:
   9bd9 ab                                 | ...
                                           |     Decoded:
                                           | gzip
                                           | - evict: date: Mon, 21 Oct
                                           |    2013 20:13:21 GMT
                                           | -> content-encoding: gzip
   77                                      | == Literal indexed ==
                                           |   Indexed name (idx = 55)
                                           |     set-cookie
   ad                                      |   Literal value (len = 45)
                                           |     Huffman encoded:
   94e7 821d d7f2 e6c7 b335 dfdf cd5b 3960 | .........5...[9`
   d5af 2708 7f36 72c1 ab27 0fb5 291f 9587 | ..'..6r..'..)...
   3160 65c0 03ed 4ee5 b106 3d50 07        | 1`e...N...=P.
                                           |     Decoded:
                                           | foo=ASDJKHQKBZXOQWEOPIUAXQ
                                           | WEOIU; max-age=3600; versi
                                           | on=1
                                           | - evict: location:
                                           |   https://www.example.com
                                           | - evict: :status: 307
                                           | -> set-cookie: foo=ASDJKHQ
                                           |   KBZXOQWEOPIUAXQWEOIU; ma
                                           |   x-age=3600; version=1

编码后的动态表:

   [  1] (s =  98) set-cookie: foo=ASDJKHQKBZXOQWEOPIUAXQWEOIU;
                    max-age=3600; version=1
   [  2] (s =  52) content-encoding: gzip
   [  3] (s =  65) date: Mon, 21 Oct 2013 20:13:22 GMT
         Table size: 215

解码后的 header 列表:

   :status: 200
   cache-control: private
   date: Mon, 21 Oct 2013 20:13:22 GMT
   location: https://www.example.com
   content-encoding: gzip
   set-cookie: foo=ASDJKHQKBZXOQWEOPIUAXQWEOIU; max-age=3600; version=1

7. 一些抓包的例子

先来看看首次请求中 HPACK 是如何压缩 HEADERS 帧中的首部字段的。

HTTP/2 HPACK 实际应用举例

:method:GET 在静态表中的第 2 项。Name 和 Value 都已经存在了。所以直接用 2 即可表示这一项头部字段。

HTTP/2 HPACK 实际应用举例

相同的,:path:/ 在静态表中的第 4 项。Name 和 Value 都已经存在了。所以直接用 4 即可表示这一项头部字段。

HTTP/2 HPACK 实际应用举例

再来看看第二次请求中,同样是 :method:GET,和第一次请求一样,直接用 2 即可表示这一项头部字段。

HTTP/2 HPACK 实际应用举例

回到首次请求中,if-none-match 首部字段,在静态表中的第 41 项,但是静态表里面没有值。根据前一篇文章讲解的 HPACK 算法,压缩串以 01 开头,101001 是 41,10011110,第一个 1 代表是霍夫曼编码,0011110 代表 30,表明 value 是紧接着的 30 个字节里面的内容。

HTTP/2 HPACK 实际应用举例

还是首次请求,user-agent 首部字段,在静态表中的第 58 项,但是静态表里面没有值。根据前一篇文章讲解的 HPACK 算法,压缩串以 01 开头,111010 是 58,11011011,第一个 1 代表是霍夫曼编码,1011011 代表 91,表明 value 是紧接着的 91 个字节里面的内容。

HTTP/2 HPACK 实际应用举例

到了第二次请求中,user-agent 首部字段在动态表中已经存储了 name 和 value 了,所以直接命中动态表中第 86 项。1010110 代表的就是 86。这个例子可以很明显的看到,动态表大幅缩减了 header 大小。

对比同一个 HTTP/2 连接中的 2 次相同的请求。可以看到首部大小已经大幅减少了。

HTTP/2 HPACK 实际应用举例

在首次请求中,HAPCK 使得原有的头部减少了 44%。

HTTP/2 HPACK 实际应用举例

在第二次请求中,由于补充了动态表,HAPCK 使得原有的头部减少了 97%。

8. HPACK 优化效果

最后,让我们用工具具体测试一下 HPACK 的“威力”。可以使用 h2load 工具测试

以下分别是 3 个测试用例,第一个测试用例只请求一次,第二个测试用例请求二次,第三个测试用例请求三次,看每次测试用来能缩小头部字段开销。

HTTP/2 HPACK 实际应用举例

从上图中可以看到,请求越多,头部字段越来越小。

请求次数 首部字段占比 节约百分比
1 1.002% 29.89%
2 0.521% 63.75%
3 0.359% 75.04%
5 0.241% 83.28%
10 0.137% 90.48%
20 0.092% 93.65%
30 0.074% 94.85%
50 0.061% 95.75%
100 0.052% 96.39%

由此可以看出 HTTP/2 中的 HPACK 算法对 header 整体的压缩率还是非常不错的。


Reference:

RFC 7541

GitHub Repo:Halfrost-Field

Follow: halfrost · GitHub

Source: GHOST_URL/http2-hpack-example/

详解 HTTP/2 头压缩算法 —— HPACK

一. 简介

详解 HTTP/2 头压缩算法 —— HPACK

在 HTTP/1.1(请参阅[RFC7230])中,header 字段未被压缩。随着网页内的请求数增长到需要数十到数百个请求的时候,这些请求中的冗余 header 字段不必要地消耗了带宽,从而显着增加了延迟。

SPDY [SPDY] 最初通过使用 DEFLATE [DEFLATE] 格式压缩 header 字段来解决此冗余问题,事实证明,这种格式非常有效地表示了冗余 header 字段。但是,这种方法暴露了安全风险,如 CRIME(轻松实现压缩率信息泄漏)攻击所证明的安全风险(请参阅 [CRIME])。

本规范定义了 HPACK,这是一种新的压缩方法,它消除了多余的 header 字段,将漏洞限制到已知的安全攻击,并且在受限的环境中具有有限的内存需求。第 7 节介绍了 HPACK 的潜在安全问题。

HPACK 格式特意被设计成简单且不灵活的形式。两种特性都降低了由于实现错误而引起的互操作性或安全性问题的风险。没有定义扩展机制;只能通过定义完整的替换来更改格式。

1. 总览

详解 HTTP/2 头压缩算法 —— HPACK

本规范中定义的格式将 header 字段列表视为 name-value 对的有序集合,其中可以包括重复的对。名称和值被认为是八位字节的不透明序列,并且 header 字段的顺序在压缩和解压缩后保持不变。

header 字段表将 header 字段映射到索引值,从而得到编码。这些 header 字段表可以在编码或解码新 header 字段时进行增量更新。

在编码形式中,header 字段以字面形式表示或作为对 header 字段表中的一个 header 字段的引用。因此,可以使用引用和字面值的混合来编码 header 字段的列表。

字面值可以直接编码,也可以使用静态霍夫曼编码(最高压缩比 8:5)。

编码器负责决定将哪些 header 字段作为新条目插入 header 字段表中。解码器执行对编码器指定的 header 字段表的修改,从而在此过程中重建 header 字段的列表。这使解码器保持简单并可以与多种编码器互操作。

附录C 中提供了使用这些不同的机制表示 header 字段的示例。

注:在 HTTP/2 中,请求和响应标头字段的定义保持不变,仅有一些微小的差异:所有标头字段名称均为小写,请求行现在拆分成各个 :method、:scheme、:authority 和 :path 伪标头字段。

2. 约定

本文档中的关键字 “必须”,“不得”,“必须”,“应”,“应禁止”,“应”,“不应”,“建议”,“可以”和“可选”是 RFC 2119 [RFC2119] 中定义的。

所有数值均以网络字节顺序排列。 除非另有说明,否则值是无符号的。适当时以十进制或十六进制提供字面值。

3. 术语

本文使用以下术语:

Header Field:一个名称/值 name-value 对。名称和值都被视为八位字节的不透明序列。

Dynamic Table:动态表(请参阅第 2.3.2 节)是将存储的 header 字段与索引值相关联的表。该表是动态的,并且特定于编码或解码上下文。

Static Table:静态表(请参阅第 2.3.1 节)是将经常出现的 header 字段与索引值静态关联的表。该表是有序的,只读的,始终可访问的,并且可以在所有编码或解码上下文之间共享。

Header List:header 列表是 header 字段的有序集合,这些 header 字段经过联合编码,可以包含重复的 header 字段。HTTP/2 header 块中包含的 header 字段的完整列表是 header 列表。

Header Field Representation:header 字段可以编码形式表示为字面或索引(请参见第 2.4 节)。

Header Block:header 字段表示形式的有序列表,解码后会产生完整的 header 列表。

二. 压缩过程概述

本规范未描述编码器的具体算法。相反,它精确定义了解码器的预期工作方式,从而允许编码器产生此定义允许的任何编码。

1. Header List Ordering

HPACK 保留 header 列表内 header 字段的顺序。编码器必须根据其在原始 header 列表中的顺序对 header 块中的 header 字段表示进行排序。解码器必须根据其在 header 块中的顺序对已解码 header 列表中的 header 字段进行排序。

2. Encoding and Decoding Contexts

为了解压缩 header 块,解码器只需要维护一个动态表(参见第 2.3.2 节)作为解码上下文。不需要其他动态状态。

当用于双向通信时(例如在 HTT P中),由端点维护的编码和解码动态表是完全独立的,即请求和响应动态表是分开的。

3. Indexing Tables

HPACK 使用两个表将 header 字段与索引相关联。静态表(请参阅第 2.3.1 节)是预定义的,并包含公共 header 字段(其中大多数带有空值)。动态表(请参阅第 2.3.2 节)是动态的,编码器可以使用它来索引已编码 header 列表中重复的 header 字段。

这两个表被合并到一个用于定义索引值的地址空间中(请参阅第 2.3.3 节)。

(1) 静态表

静态表由 header 字段的预定义静态列表组成。其条目在附录 A 中定义。

(2) 动态表

动态表包含以先进先出的顺序维护的 header 字段列表。动态表中的第一个条目和最新条目在最低索引处,而动态表的最旧条目在最高索引处。

动态表最初是空的。当每个 header 块被解压缩时,将添加条目。动态表可以包含重复的条目(即,具有相同名称和相同值的条目)。因此,解码器不得将重复的条目视为错误。

编码器决定如何更新动态表,因此可以控制动态表使用多少内存。为了限制解码器的存储需求,动态表的 size 受到严格限制(请参见第 4.2 节)。

解码器在处理 header 字段表示列表时更新动态表(请参见第 3.2 节)。

(3) 索引地址空间

静态表和动态表被组合到单个索引地址空间中。

在 1 和静态表的长度(包括在内)之间的索引是指静态表中的元素(请参阅第 2.3.1 节)。

严格大于静态表长度的索引是指动态表中的元素(请参见第 2.3.2 节)。 减去静态表的长度即可找到动态表的索引。

严格大于两个表的长度之和的索引必须视为解码错误。

对于 s 的静态表 size 和 k 的动态表 size ,下图显示了整个有效索引地址空间

详解 HTTP/2 头压缩算法 —— HPACK

4. Header Field Representation

编码的 header 字段可以表示为索引或字面。

有索引的表示形式定义了一个 header 字段,作为对静态表或动态表中条目的引用(请参见第 6.1 节);字面表示形式通过指定其 name 和 value 来定义 header 字段。header 字段 name 可以用字面形式表示,也可以作为对静态表或动态表中条目的引用。header 字段 value 按字面表示。定义了三种不同的字面表示形式:

  • 在动态表的开头添加 header 字段作为新条目的字面表示形式(请参见第 6.2.1 节)。

  • 不将 header 字段添加到动态表的字面表示形式(请参见第 6.2.2 节)。

  • 不将 header 字段添加到动态表的字面表示形式,另外规定该 header 字段始终使用字面表示形式,尤其是在由中介程序重新编码时(请参阅第 6.2.3 节)。此表示旨在保护 header 字段值,这些 header 字段值通过压缩以后就不会受到威胁(有关更多详细信息,请参见第 7.1.3 节)。

为了保护敏感的 header 字段值(请参阅第 7.1 节),可以从安全考虑出发选择这些字面表示形式之一。

header 字段 name 或 header 字段 value 的字面表示可以直接或使用静态霍夫曼代码对八位字节序列进行编码(请参见第 5.2 节

三. header 块的解码

1. Header Block Processing

解码器顺序处理 header 块以重建原始 header 列表。

header 块是 header 字段表示形式的串联。第 6 节中介绍了不同的可能的 header 字段表示形式。

一旦 header 字段被解码并添加到重建的 header 列表中,就不能删除 header 字段。添加到 header 列表的 header 字段可以安全地传递到应用程序。

通过将结果 header 字段传递给应用程序,除了动态表所需的内存外,还需要使用最少的临时内存来实现解码器。

2. Header Field Representation Processing

在本节中定义了对 header 块进行处理以获得 header 列表的过程。为了确保解码将成功产生 header 列表,解码器必须遵守以下规则。

header 块中包含的所有 header 字段表示形式将按照它们出现的顺序进行处理,如下所示。有关各种 header 字段表示形式的格式的详细信息以及一些其他处理指令,请参见第 6 节

_indexed representation_需要执行以下操作:

  • 与静态表或动态表中被引用条目相对应的 header 字段被附加到解码后的 header 列表中。

动态表中未添加的 “_literal representation_” 需要执行以下操作:

  • header 字段被附加到解码的 header 列表中。

在动态表中添加了 “_literal representation_” 需要执行以下操作:

  • header 字段被附加到解码的 header 列表中。
  • header 字段插入在动态表的开头。这种插入可能导致驱逐动态表中的先前条目(请参见第 4.4 节)。

四. 动态表管理

详解 HTTP/2 头压缩算法 —— HPACK

为了限制解码器端的存储要求,动态表的 size 受到限制。

动态字典上下文有关,需要为每个 HTTP/2 连接维护不同的字典。

1. Calculating Table Size

动态表的 size 是其表项 size 的总和。条目的 size 是其 name 的长度(以八位字节为单位)(如第 5.2 节中所定义),value 的长度(以八位字节为单位)和 32 的总和。条目的 size 是使用其 name 和 value 的长度来计算的,而无需应用任何霍夫曼编码。

注意:额外的 32 个八位字节说明了与条目相关的估计开销。例如,使用两个 64 位指针引用条目的 name 和 value 以及使用两个 64 位整数来计数对该 name 和 value 的引用次数的条目结构,该数据结构将具有 32 个八位字节的开销。(64*2*2/8=32 字节)

2. Maximum Table Size

使用 HPACK 的协议确定允许编码器用于动态表的最大 size 。在 HTTP/2 中,此值由 SETTINGS_HEADER_TABLE_SIZE 设置来确定(请参见[HTTP2]的 6.5.2 节)。

编码器可以选择使用小于此最大 size 的容量(请参阅第 6.3 节),但是所选 size 必须保持小于或等于协议设置的最大容量。

动态表最大 size 的变化是因为动态表 size 的更新引起的(请参见第 6.3 节)。动态表 size 更新必须在更改动态表 size 之后的第一个 header 块的开头进行。在 HTTP/2 中,这遵循 settings 的确认(请参阅 [HTTP2]的 6.5.3 节)。

在传输两个 header 块之间,可能会发生多次最大表 size 的更新。如果在此间隔中,这个 size 更改一次以上的话,那么就必须在动态表 size 更新中,用信号通知在该间隔中出现的,最小的最大表 size 。一定会发出最终最大 size 的信号,从而导致最多两个动态表 size 的更新。这样可确保解码器能够基于动态表 size 的减小执行逐出(请参见第 4.3 节)。

使用此机制通过将最大 size 设置为 0,从动态表中完全清除条目,然后可以将其恢复。

HTTP/2 提倡使用尽可能少的连接数,头部压缩是其中一个重要的原因:在同一个连接上产生的请求和响应越多,动态字典累积的越全,头部压缩的效果就越好。

3. Entry Eviction When Dynamic Table Size Changes

只要减小了动态表的最大 size,就会从动态表的末尾逐出条目,直到动态表的 size 小于或等于最大 size 为止。

4. Entry Eviction When Adding New Entries

在将新条目添加到动态表之前,将从动态表的末尾逐出条目,直到动态表的 size 小于或等于(最大 size -新条目大小)或直到表为空。

如果新条目的 size 小于或等于最大 size,则会将该条目添加到表中。 尝试添加大于最大 size 的条目不是错误;尝试添加大于最大 size 的条目会导致该表清空所有现有条目,并导致表为空。

新条目可以引用动态表中条目 A 的 name,当将该新条目添加到动态表中时,该条目 A 将被逐出。请注意,如果在插入新条目之前从动态表中删除了引用条目,则应避免删除引用 name。

五. 基本类型表示

HPACK 编码使用两种原始类型:无符号的可变长度整数和八位字节串。

1. Integer Representation

整数用于表示 name 索引,header 字段索引或字符串长度。整数表示可以在八位字节内的任何位置开始。为了优化处理,整数表示总是在八位字节的末尾结束。

整数分为两部分:填充当前八位字节的前缀和可选的八位字节列表,如果整数值不适合该前缀,则使用这些可选的八位字节。前缀的位数(称为 N)是整数表示的参数。

如果整数值足够小,即严格小于 2^N-1,则将其编码在 N 位前缀中。

详解 HTTP/2 头压缩算法 —— HPACK

上图的例子中,N = 5,所以能表示的最大的整数是 2^5-1 = 31

如果整数数值大于 2^N-1,则将前缀的所有位设置为 1,并使用一个或多个八位字节的列表对减少了 2^N-1 的值进行编码。每个八位字节的最高有效位用作连续标志:除了列表中的最后一个八位字节,其值均设置为 1。八位字节的其余位用于对减小的值进行编码。

详解 HTTP/2 头压缩算法 —— HPACK

从八位字节列表中解码整数值是通过反转八位字节在列表中的顺序开始的。 然后,对于每个八位字节,将其最高有效位删除。八位字节的其余位被级联起来,结果值增加 2^N-1 以获得整数值。

前缀 size N 始终在 1 到 8 位之间。从八位字节边界开始的整数将具有 8 位前缀。

表示整数 I 的伪代码如下:

   if I < 2^N - 1, encode I on N bits
   else
       encode (2^N - 1) on N bits
       I = I - (2^N - 1)
       while I >= 128
            encode (I % 128 + 128) on 8 bits
            I = I / 128
       encode I on 8 bits

用于解码整数 I 的伪代码如下:

   decode I from the next N bits
   if I < 2^N - 1, return I
   else
       M = 0
       repeat
           B = next octet
           I = I + (B & 127) * 2^M
           M = M + 7
       while B & 128 == 128
       return I

附录 C.1 中提供了说明整数编码的示例。

整数表示形式允许使用不确定大小的值。编码器也可能发送大量的零值,这可能浪费八位字节,并可能使整数值溢出。超出实现限制的整数编码(值或八位字节长度)必须视为解码错误。基于实现方的约束,可以为整数的每种不同用途设置不同的限制。

2. String Literal Representation

header 字段 name 和 header 字段 value 可以表示为字符串字面量。可以通过直接编码字符串字面的八位字节或使用霍夫曼代码将字符串字面编码为八位字节序列(请参见[HUFFMAN]

详解 HTTP/2 头压缩算法 —— HPACK

字符串字面表示形式包含以下字段:

  • H:
    一位标志 H,指示字符串的八位字节是否经过霍夫曼编码。

  • String Length:
    用于编码字符串字面的八位字节数,编码为带有 7 位前缀的整数(请参阅第 5.1 节)。

  • String Data:
    字符串字面的编码数据。如果 H 为'0',则编码后的数据为字符串字面量的原始八位字节。如果 H 为'1',则编码数据为字符串字面量的霍夫曼编码。

使用霍夫曼编码的字符串字面量使用 附录 B 中定义的霍夫曼代码进行编码(有关示例,请参见 附录 C.4 中的示例以及 附录 C.6 中的响应示例)。编码的数据是与字符串字面的每个八位字节相对应的代码的按位级联。

由于霍夫曼编码的数据并不总是在八位字节的边界处结束,因此在其后插入填充,直到下一个八位字节的边界。为避免将此填充误解为字符串字面的一部分,使用了与 EOS(end-of-string)符号相对应的代码的最高有效位。

在解码时,编码数据末尾的不完整代码将被视为填充和丢弃。严格长于 7 位的填充必须被视为解码错误。与 EOS 符号的代码的最高有效位不对应的填充必须被视为解码错误。包含 EOS 符号的霍夫曼编码的字符串字面必须被视为解码错误。

六. 二进制格式

本节描述每种不同的 header 字段表示形式的详细格式以及动态表大小更新指令。

1. 索引 header 字段表示

索引 header 字段表示可标识静态表或动态表中的条目(请参见第 2.3 节)。

索引的 header 字段表示会将 header 字段添加到已解码的 header 列表中,如第 3.2 节所述。

详解 HTTP/2 头压缩算法 —— HPACK

上面这种情况对应的是 Name 和 Value 都在索引表(包括静态表和动态表)中

索引 header 字段以 1 位模式 “1” 开头,后跟匹配 header 字段的索引,以 7 位前缀的整数表示(请参阅第 5.1 节)。

不使用索引值 0。如果在索引 header 域表示中发现了索引值 0,则必须将其视为解码错误。

2. 字面 header 字段标识

header 字段表示形式包含字面 header 字段 value。header 字段名称 name 以字面形式提供,也可以通过引用静态表或动态表中的现有表条目来提供(请参见第 2.3 节)。

本规范定义了字面 header 字段表示形式的三种形式:带索引,不带索引以及从不索引。

(1). 带增量索引的字面 header 字段

具有增量索引表示形式的字面 header 字段会将 header 字段附加到已解码的 header 列表中,并将其作为新条目插入动态表中。

详解 HTTP/2 头压缩算法 —— HPACK

上面这种情况对应的是 Name 在索引表(包括静态表和动态表)中,Value 需要编码传递,并同时新增到动态表中

详解 HTTP/2 头压缩算法 —— HPACK

上面这种情况对应的是 Name 和 Value 都需要编码传递,并同时新增到动态表中

具有增量索引表示的字面 header 字段以 “01” 2 位模式开头。

如果 header 字段名称 name 与存储在静态表或动态表中的条目的 header 字段名称 name 匹配,则可以使用该条目的索引表示 header 字段名称 name。在这种情况下,条目的索引表示为带有 6 位前缀的整数(请参阅第 5.1 节)。此值一般为非零值。

否则,header 字段名称 name 表示为字符串字面(请参见第 5.2 节)。使用值 0 代替 6 位索引,后跟 header 字段名称 name。

两种形式的 header 字段名称 name 表示形式之后跟着的是以字符串字面表示的 header 字段值 value(参见第 5.2 节)。

(2). 不带索引的字面 header 字段

没有索引表示形式的字面 header 字段会使在不更改动态表的情况下将 header 字段附加到已解码的 header 列表中。

详解 HTTP/2 头压缩算法 —— HPACK

上面这种情况对应的是 Name 在索引表(包括静态表和动态表)中,Value 需要编码传递,并不新增到动态表中

详解 HTTP/2 头压缩算法 —— HPACK

上面这种情况对应的是 Name 和 Value 需要编码传递,并不新增到动态表中

没有索引表示的字面 header 字段以 “0000” 4 位模式开头。

如果 header 字段名称 name 与存储在静态表或动态表中的条目的 header 字段名称 name 匹配,则可以使用该条目的索引表示 header 字段名称 name。在这种情况下,条目的索引表示为带有 4 位前缀的整数(请参见第 5.1 节)。此值一般为非零值。

否则,header 字段名称 name 表示为字符串字面(请参见第 5.2 节)。使用值 0 代替 4 位索引,后跟 header 字段名称 name。

两种形式的 header 字段名称 name 表示形式之后跟着的是字符串字面的 header 字段值 value(参见第 5.2 节)。

(3). 从不索引的字面 header 字段

字面 header 字段永不索引表示形式会使得在不更改动态表的情况下将 header 字段附加到已解码的 header 列表中。中间件必须使用相同的表示形式来编码该 header 字段。

详解 HTTP/2 头压缩算法 —— HPACK

上面这种情况对应的是 Name 在索引表(包括静态表和动态表)中,Value 需要编码传递,并永远不新增到动态表中

详解 HTTP/2 头压缩算法 —— HPACK

上面这种情况对应的是 Name 和 Value 需要编码传递,并永远不新增到动态表中

字面 header 字段永不索引的表示形式以 “0001” 4 位模式开头。

当 header 字段表示为永不索引的字面 header 字段时,务必使用此特定字面表示进行编码。特别地,当一个对端发送了一个接收到的 header 域的时候,并且接收到的 header 表示为从未索引的字面 header 域时,它必须使用相同的表示来转发该 header 域。

此表示目的是为了保护 header 字段值 value,通过压缩来保护它们不会被置于风险之中(有关更多详细信息,请参见第 7.1 节)。

该表示形式的编码与不带索引的字面 header 字段相同(请参见第 6.2.2 节)。

3. 动态表大小更新

动态表 size 更新代表更改动态表 size。

详解 HTTP/2 头压缩算法 —— HPACK

动态表 size 更新从 “001” 3 位模式开始,然后是新的最大 size,以 5 位前缀的整数表示(请参阅第 5.1 节)。

新的最大 size 必须小于或等于协议使用 HPACK 确定的限制。超过此限制的值必须视为解码错误。在 HTTP/2 中,此限制是从解码器接收并由编码器(请参见 [HTTP2]的 6.5.3 节)确认的 SETTINGS_HEADER_TABLE_SIZE (请参见 [HTTP2]的 6.5.2 节)参数的最后一个值。

减小动态表的最大 size 会导致驱逐条目(先进先出)(请参见第 4.3 节)。

动态表大小更新有上述这两种方式,一种是在 HEADERS 帧中直接修改(“001” 3 位模式开始),另外一种方式是通过 SETTINGS 帧中的 SETTINGS_HEADER_TABLE_SIZE 中设置的。

七. 安全注意事项

本节介绍了 HPACK 的潜在安全隐患:

  • 将压缩用作基于长度的预测,以验证有关被压缩到共享压缩上下文中的加密的猜想。

  • 由于耗尽解码器的处理或存储容量而导致的拒绝服务。

1. 探测动态表状态

HPACK 通过利用 HTTP 等协议固有的冗余性来减少 header 字段编码的长度。这样做的最终目的是减少发送 HTTP 请求或响应所需的数据量。

攻击者可以探测用于编码 header 字段的压缩上下文,攻击者也可以定义要编码和传输的 header 字段,并在编码后观察这些字段的长度。当攻击者可以同时执行这两种操作时,他们可以自适应地修改请求,以确认有关动态表状态的猜想。如果将猜想压缩到较短的长度,则攻击者可以观察编码的长度并推断出猜测是正确的。

即使通过传输层安全性(TLS)协议(请参阅 [TLS12]),这也是有可能被攻击的,因为 TLS 为内容提供加密保护,但仅提供有限的内容长度保护。

注意:填充方案只能对具有这些功能的攻击者提供有限的保护能力,可能对攻击者的影响仅仅只是迫使他增加猜测的次数,来推测与给定猜测相关的长度。填充方案还可以通过增加传输的位数直接抵抗压缩。

诸如 CRIME [CRIME] 之类的攻击证明了这些攻击者的存在。特定攻击利用了 DEFLATE [DEFLATE] 删除基于前缀匹配的冗余这一事实。这使攻击者一次可以确定一个字符,从而将指数时间的 攻击减少为线性时间的攻击。

(1). 适用于 HPACK 和 HTTP

HPACK 通过强制猜测以匹配整个 header 字段值而不是单个字符,来缓解但不能完全阻止以CRIME [CRIME] 为模型的攻击。攻击者只能了解猜测是否正确,因此可以将攻击手段其简化为针对 header 字段值的蛮力猜测。因此,恢复特定 header 字段值的可行性取决于值的熵。结果是,具有高熵的值不太可能成功恢复。但是,低熵值仍然容易受到攻击。

每当两个互不信任的实体在单个 HTTP/2 连接上的接收和发送请求或响应时,就可能发生这种性质的攻击。如果共享的 HPACK 压缩器允许一个实体向动态表添加条目,而另一实体访问这些条目,则可以了解到表的状态。

当中间件发生以下情况时,就会出现来自互不信任实体的请求或响应:

  • 从单个连接上的多个客户端向原始服务器发送请求。

  • 从多个原始服务器获取响应,并将其在与客户端的共享连接上发送响应。

Web 浏览器还需要假设不同 Web 来源 [ORIGIN] 在同一连接上发出的请求是由互不信任的实体发出的。

(2). 减轻

要求 header 字段具有加密性的 HTTP 用户可以使用具有足以使猜测不可行的熵的值。但是,这作为通用解决方案是不切实际的,因为它会强制 HTTP 的所有用户采取措施减轻攻击。它将对使用 HTTP 的方式施加新的限制。

HPACK 的实现不是在 HTTP 用户上施加约束,而是可以约束压缩的应用方式,以限制动态表探测的潜力。

理想的解决方案基于正在构造 header 字段的实体来隔离对动态表的访问。添加到表中的 header 字段值将归因于一个实体,只有创建特定值的实体才能提取该值。

为了提高此选项的压缩性能,可以将某些条目标记为公共。例如,Web 浏览器可能使 Accept-Encoding header 字段的值在所有请求中都可用。

不太了解 header 字段出处的编码器可能会对具有许多不同值的 header 字段引入惩罚机制,如果攻击者大量尝试去猜测 header 字段值,触发惩罚机制,会导致 header 字段在将来的消息中不再与动态表实体进行比较。这样可以有效地防止了进一步的猜测。

注意:如果攻击者有一个可靠的方法来重新安装值,只是从动态表中删除与 header 字段相对应的条目可能是无效的攻击。例如,在网络浏览器中加载图像的请求通常包含 Cookie header 字段(此类攻击的潜在价值很高的目标),并且网站可以轻松地强制加载图像,从而刷新动态表中的条目。

该响应可能与 header 字段值的长度成反比。与更短的值相比,更短的值更可能以更快的速度或更高的概率将 header 字段标记为不再使用的动态表。

(3). 永不索引的字面

实现方也可以选择不对敏感 header 字段进行压缩,而是将其值编码为字面,从而保护它们。

仅仅只在避免在所有跃点上都进行压缩的情况下,拒绝生成 header 字段的索引表示才有效。永不索引的字面(请参阅第 6.2.3 节)可用于向中间件发出信号,指示有意将特定值作为字面发送。

中间件不得将使用永不索引的字面表示形式的值与将对其进行索引的另一个表示形式重新编码。如果使用 HPACK 进行重新编码,则必须使用永不索引的字面表示。

对于 header 字段使用从不索引的字面表示形式的选择取决于多个因素。由于 HPACK 不能防止猜测整个 header 字段值,因此攻击者更容易恢复短的或低熵的值。因此,编码器可能选择不索引具有低熵的值。

编码器还可能选择不为被认为具有很高价值或对恢复敏感的 header 字段(例如 Cookie 或授权 header 字段)的值增加索引。

相反,如果值被公开了,则编码器可能更喜欢索引值很小或没有值的 header 字段的索引值。例如,User-Agent header 字段在请求之间通常不会发生变化,而是发送到任何服务器。在这种情况下,确认已使用特定的 User-Agent 值提供的价值很小。

请注意,随着新的攻击不断被发现,这些决定使用永不索引的字面表示形式的标准将随着时间的推移而演变。

2. 静态霍夫曼编码

目前还没有针对静态霍夫曼编码的攻击。一项研究表明,使用静态霍夫曼编码表会造成信息泄漏; 但是,同一项研究得出的结论是,攻击者无法利用此信息泄漏来恢复任何有意义的信息量(请参阅 [PETAL]

动态的霍夫曼编码容易受到攻击!

3. 内存管理

攻击者可以尝试使端点耗尽其内存。HPACK 旨在限制端点分配的内存峰值和状态量。

压缩程序使用的内存量受到遵循 HPACK 协议的动态表中定义的最大 size 限制。在 HTTP/2 中,此值由解码器通过设置参数 SETTINGS_HEADER_TABLE_SIZE 来控制的(请参见 [HTTP2]的 6.5.2 节)。此限制既考虑了动态表中存储的数据大小,又考虑了少量的开销。

解码器可以通过为动态表的最大 size 设置适当的值来限制状态存储器的使用量。在 HTTP/2 中,这是通过为 SETTINGS_HEADER_TABLE_SIZE 参数设置适当的值来实现的。编码器可以通过发信号通知动态表 size 小于解码器允许的状态来限制其使用的状态存储器的数量(请参见第 6.3 节)。

编码器或解码器消耗的临时内存量可以通过顺序处理 header 字段来限制。实现方不需要保留 header 字段的完整列表。但是请注意,由于其他原因,应用程序可能有必要保留完整的 header 列表。即使 HPACK 不会强迫这种情况发生,应用程序约束也可能使得它变得有必要。

4. 实现方的限制

HPACK 的实现方需要确保整数的大值,整数的长编码或长字符串字面不会造成安全漏洞。

一个实现必须为它接受的整数值和编码长度设置一个限制(请参阅第 5.1 节)。同样,它必须为字符串字面设置一个限制长度(请参见第 5.2 节)。


Reference:

RFC 7541

GitHub Repo:Halfrost-Field

Follow: halfrost · GitHub

Source: GHOST_URL/http2-header-compression/

Hypertext Transfer Protocol Version 2 (HTTP/2)

Table of Contents

1. Introduction

2. HTTP/2 Protocol Overview

3. Starting HTTP/2

4. HTTP Frames

5. Streams and Multiplexing

6. Frame Definitions

7. Error Codes

8. HTTP Message Exchanges

9. Additional HTTP Requirements/Considerations

10. Security Considerations

11. IANA Considerations

12. References

Hypertext Transfer Protocol Version 2 (HTTP/2)

这一章都是引用的论文,所以就不翻译了。

  • 12.1. Normative References
  • 12.2. Informative References

Appendix A. TLS 1.2 Cipher Suite Black List

这一章是 TLS 1.2 中加入黑名单的加密套件


Reference:

RFC 7540

GitHub Repo:Halfrost-Field

Follow: halfrost · GitHub

Source: https://halfrost.com/http2_rfc7540/

HTTP/2 中的常见问题

HTTP/2 中的常见问题

以下是有关 HTTP/2 的常见问题。

一. 一般的问题

1. 为什么要修改 HTTP?

HTTP/1.1 在 Web 上已经服务了 15 年以上,但是它的缺点正在开始显现。加载网页比以往任何时候都需要更多资源(请参阅HTTP Archive’s page size statistics),并且要高效地加载所有这些资源非常困难,因为事实上,HTTP 只允许每个 TCP 连接有一个未完成的请求。

过去,浏览器使用多个 TCP 连接来发出并行请求。但是,这是有局限性的。如果使用的连接过多,则将适得其反(TCP 拥塞控制将被无效化,导致的用塞事件将会损害性能和网络),并且从根本上讲是不公平的(因为浏览器会占用许多本不该属于它的资源)。同时,大量请求意味着“在线”上有大量重复数据。

这两个因素都意味着 HTTP/1.1 请求有很多与之相关的开销。如果请求过多,则会影响性能。

这使得业界误解了“最佳实践”,进行诸如 spriting 图片合并,data: inlining 内联数据,Domain Sharding 域名分片和 Concatenation 文件合并之类的事情。这些 hack 行为表明协议本身存在潜在问题,在使用的时候会出现很多问题。

2. 谁制定了 HTTP/2?

HTTP/2 是由 IETFHTTP 工作组开发的,该工作组维护 HTTP 协议。它由许多 HTTP 实现者,用户,网络运营商和 HTTP 专家组成。

请注意,虽然我们的邮件列表托管在 W3C 网站上,但这并不是 W3C 的努力。但是,Tim Berners-Lee 和 W3C TAG 与 WG 的工作进度保持同步。

大量的人为这项工作做出了贡献,最活跃的参与者包括来自诸如 Firefox,Chrome,Twitter,Microsoft 的 HTTP stack,Curl 和 Akamai 等“大型”项目的工程师,以及许多诸如 Python、Ruby 和 NodeJS 之类的 HTTP 实现者。

要了解有关 IETF 的更多信息,请参见Tao of the IETF。您还可以在 Github 的贡献者图中了解谁为规范做出了贡献,以及谁在我们的实现列表中参与该项目。

3. HTTP/2 与 SPDY 有什么关系?

HTTP/2 第一次出现并被讨论的时候,SPDY 正逐渐受到实现者(例如 Mozilla 和 nginx)的青睐时,并且被当成对 HTTP/1.x 的重大改进。

在征求提案和进行选择过程之后,选择 SPDY/2 作为 HTTP/2 的基础。此后,根据工作组的讨论和实现者的反馈,进行了许多更改。在整个过程中,SPDY 的核心开发人员都参与了 HTTP/2 的开发,包括 Mike Belshe 和 Roberto Peon。2015 年 2 月,Google 宣布了其计划删除对 SPDY 的支持,转而支持 HTTP/2。

4. 是 HTTP/2.0 还是 HTTP/2?

工作组决定删除次版本(“.0”),因为它在 HTTP/1.x 中引起了很多混乱。换句话说,HTTP 版本仅表示网络兼容性,而不表示功能集或“亮点”。

5. HTTP/2 和 HTTP/1.x 的主要区别是什么?

在高版本的 HTTP/2 中:

  • 是二进制的,而不是文本的
  • 完全多路复用,而不是有序和阻塞
  • 因此可以使用一个连接进行并行处理
  • 使用头压缩​​来减少开销
  • 允许服务器主动将响应"推送"到客户端缓存中

6. 为什么 HTTP/2 是二进制的?

与诸如 HTTP/1.x 之类的文本协议相比,二进制协议解析起来更高效,更“紧凑”,并且最重要的是,它们比二进制协议更不容易出错,因为它们对空格处理,大写,行尾,空白行等的处理很有帮助。例如,HTTP/1.1 定义了四种不同的解析消息的方式。在 HTTP/2 中,只有一个代码路径。

HTTP/2 在 telnet 中不可用,但是我们已经有了一些工具支持,例如 Wireshark 插件

7. 为什么 HTTP/2 需要多路复用?

HTTP/1.x 存在一个称为“队头阻塞”的问题,指的是一次连接(connection)中,只提交一个请求的效率比较高,多了就会变慢。

HTTP/1.1 试图通过管道修复此问题,但是并不能完全解决问题(较大或较慢的响应仍会阻止其他问题)。此外,由于许多中间件和服务器未正确处理管线化,因此很难部署它。

这迫使客户使用多种试探法(通常是猜测法)来决定通过哪些连接提交哪些请求;由于页面加载的数据量通常是可用连接数的 10 倍(或更多),因此会严重影响性能,通常会导致被阻止的请求“泛滥”。

多路复用通过允许同时发送多个请求和响应消息来解决这些问题。甚至有可能将一条消息的一部分与另一条消息混合在一起。所以在这种情况下,客户端只需要一个连接就能加载一个页面。

8. 为什么只有一个 TCP 连接?

使用 HTTP/1,浏览器打开每个站点需要 4 个到 8 个连接。现在很多网站都使用多点传输,因此这可能意味着单个页面加载会打开 30 多个连接。

一个应用程序打开如此多的连接,已经远远超出了当初设计 TCP 时的预想。由于每个连接都会响应大量的数据,这会造成中间网络中的缓冲区溢出的风险,从而导致网络拥塞事件并重新传输。

此外,使用这么多连接还会强占许多网络资源。这些资源都是从那些“遵纪守法”的应用那“偷”的(VoIP 就是个很好的例子)。

9. 服务器推送的好处是什么?

当浏览器请求页面时,服务器将在响应中发送 HTML,然后需要等待浏览器解析 HTML 并发出对所有嵌入资源的请求,然后才能开始发送 JavaScript,图像和 CSS。

服务器推送可以通过“推送”它认为客户端需要的响应到其缓存中,来避免服务器的这种往返延迟。

但是,“推送”响应不是“神奇的”——如果使用不正确,可能会损害性能。正确使用 Server Push 是正在进行的实验和研究领域。

10. 为什么我们需要头压缩?

Mozilla 的 Patrick McManus 通过计算平均页面加载消息头的效果,生动地展示了这一点。

假设一个页面包含大约 80 个资源需要加载(在当今的 Web 中是保守的),并且每个请求具有 1400 字节的消息头(这并不罕见,这要归功于 Cookie,Referer 等),至少要 7 到 8 个来回去“在线”获得这些消息头。这还不包括响应时间——那只是从客户端那里获取到它们所花的时间而已。

这是因为 TCP 的慢启动机制造成的,根据已确认的数据包数量,从而对新连接上发送数据的进行限制——有效地限制了最初的几次来回可以发送的数据包数量。

相比之下,即使对报头进行轻微的压缩,这些请求也可以在一次往返(甚至一个数据包)内搞定。

这种额外开销是相当大的,尤其是考虑到对移动客户端的影响时,即使在网络状况良好的条件下,移动客户端的往返延迟通常也要几百毫秒。

11. 为什么选择 HPACK?

SPDY/2 建议每个方都使用单独的 GZIP 上下文进行消息头压缩,该方法易于实现且效率很高。

从那时起,一个重要的攻击方式 CRIME 诞生了,这种方式可以攻击加密文件内部的所使用的压缩流(如 GZIP)。

使用 CRIME,攻击者有能力将数据注入加密流中,并可以“探测”明文并恢复它。由于这是 Web,因此 JavaScript 使这成为可能,而且已经有了通过对受到 TLS 保护的 HTTP 资源的使用CRIME来还原出 cookies 和认证令牌(Toekn)的案例。

结果,我们无法使用 GZIP 压缩。没有找到适合该用例并且可以安全使用的其他算法,我们创建了一种新的,专门针对报头的压缩方案,该方案以粗粒度压缩模式运行;由于 HTTP 标头通常在消息之间不改变,因此仍然可以提供合理的压缩效率,并且更加安全。

12. HTTP/2 可以使 Cookie(或其他头字段)变得更好吗?

这一努力被许可在网络协议的一个修订版本上运行 —— 例如,HTTP 消息头、方法等等如何才能在不改变 HTTP 语义的前提下放到“网络上”。

这是因为 HTTP 被广泛使用。如果我们使用此版本的 HTTP 引入一种新的状态机制(例如之前讨论过的例子)或更改了核心方法(值得庆幸的是,尚未提出该方法),则意味着新协议与现有 Web 不兼容。

特别是,我们希望能够在不损失任何信息的情况下从 HTTP/1 转换为 HTTP/2。如果我们开始“清理”报头(并且大多数人会同意,因为 HTTP 报头很乱),将会出现很多与现有 Web 互操作性的问题。

这样做只会对新协议的普及造成麻烦。

综上所述,HTTP 工作组负责所有 HTTP,而不仅仅是 HTTP/2。这样,我们可以研究与版本无关的新机制,只要它们与现有 Web 向后兼容即可。

13. 非浏览器的 HTTP 用户呢?

如果非浏览器应用程序已经在使用 HTTP,则它们也应该能够使用 HTTP/2。

先前收到过 HTTP “APIs” 在 HTTP/2 中具有良好性能等特点这样的反馈,那是因为 API 不需要在设计中考虑诸如请求开销之类的问题。

话虽如此,我们正在考虑的改进的主要焦点是典型的浏览用例,因为这是该协议的核心用例。

我们的章程对此表示:

The resulting specification(s) are expected to meet these goals for common existing deployments of HTTP; in particular, Web browsing (desktop and mobile), non-browsers ("HTTP APIs"), Web serving (at a variety of scales), and intermediation (by proxies, corporate firewalls, "reverse" proxies and Content Delivery Networks). Likewise, current and future semantic extensions to HTTP/1.x (e.g., headers, methods, status codes, cache directives) should be supported in the new protocol.

正在制定的规范需要满足现在已经普遍部署了的 HTTP 的功能要求;具体来说主要包括,Web 浏览(桌面端和移动端),非浏览器(“HTTP APIs” 形式的),Web 服务(大范围的),还有各种网络中介(借助代理,企业防火墙,反向代理以及内容分发网络实现的)。同样的,对 HTTP/1.x 当前和未来的语义扩展 (例如,消息头,方法,状态码,缓存指令) 都应该在新的协议中支持。


Note that this does not include uses of HTTP where non-specified behaviours are relied upon (e.g., connection state such as timeouts or client affinity,and "interception" proxies); these uses may or may not be enabled by the final product.

值得注意的是,这里没有包括将 HTTP 用于非特定行为所依赖的场景中(例如超时,连接状态以及拦截代理)。这些可能并不会被最终的产品启用。

14. HTTP/2 是否需要加密?

否。经过广泛讨论,工作组尚未对新协议必须要使用加密(例如 TLS)达成共识,。

但是,一些实现已声明它们仅在通过加密连接使用 HTTP/2 时才支持 HTTP/2,并且当前没有浏览器支持未加密的 HTTP/2。

15. HTTP/2 如何提高安全性?

HTTP/2 定义了必需的 TLS 配置文件;这包括了版本,密码套件黑名单和使用的扩展。

有关详细信息,请参见规范

还讨论了其他机制,例如对 HTTP:// URL 使用 TLS(所谓的“机会主义加密”);参见 RFC 8164

16. 我现在可以使用 HTTP/2 吗?

在浏览器中,Edge,Safari,Firefox 和 Chrome 的最新版本都支持 HTTP/2。其他基于 Blink 的浏览器也将支持 HTTP/2(例如 Opera 和 Yandex Browser)。有关更多详细信息,请参见这里

还有几种可用的服务器(包括 AkamaiGoogleTwitter 的主要站点提供的 beta 支持),以及许多可以部署和测试的开源实现。

有关更多详细信息,请参见实现列表

17. HTTP/2 会取代 HTTP/1.x 吗?

工作组的目的是让那些使用 HTTP/1.x 的人也可以使用 HTTP/2,并能获得 HTTP/2 所带来的好处。他们说过,由于人们部署代理和服务器的方式不同,我们不能强迫整个世界进行迁移,所以 HTTP/1.x 仍有可能要使用了一段时间。

18. 会有 HTTP/3 吗?

如果通过 HTTP/2 引入的协商机制运行良好,支持新版本的 HTTP 就会比过去更加容易。

二. 实现相关的问题

1. 为什么规则会围绕 HEADERS frame 的 Continuation?

存在连续性是因为单个值(例如 Set-Cookie)可能超过 16KiB-1,这意味着它无法放入单个帧中。决定处理该问题的最不容易出错的方法是要求所有消息头数据都以一个接一个帧的方式传递,这使得解码和缓冲区管理也变得更加容易。

2. HPACK 状态的最小或最大大小是多少?

接收方始终控制 HPACK 中使用的内存量,并且可以将其最小设置为 0,最大值与 SETTINGS 帧中的最大可表示整数(当前为 2^32-1)有关。

3. 如何避免保持 HPACK 状态?

发送一个 SETTINGS 帧,将状态尺寸(SETTINGS_HEADER_TABLE_SIZE)设置到 0,然后 RST 所有的流,直到一个带有 ACT 设置位的 SETTINGS 帧被接收。

4. 为什么只有一个压缩/流控制上下文?

简单的说一下。

最初的提议里有流分组的概念,它可以共享上下文,流量控制等。虽然这将使代理受益(以及代理用户的体验),但这样做却增加了相当多的复杂性。所以我们就决定先以一个简单的东西开始,看看它会有多糟糕的问题,并且在未来的协议版本中解决这些问题(如果有的话)。

5. 为什么 HPACK 中有 EOS 符号?

HPACK 的霍夫曼编码,出于 CPU 效率和安全性的考虑,将霍夫曼编码的字符串填充到下一个字节边界;任何特定的字符串可能需要 0-7 位之间的填充。

如果单独考虑霍夫曼解码,那么任何比所需填充长的符号都可以工作;但是,HPACK 的设计允许按字节比较霍夫曼编码的字符串。通过要求将 EOS 符号的位用于填充,我们确保用户可以对霍夫曼编码的字符串进行字节比较,以确定是否相等。反过来,这意味着许多 headers 可以在不需要霍夫曼解码的情况下被解析。

6. 是否可以在不实现 HTTP/1.1 的情况下实现 HTTP/2?

是的,大部分情况都可以。

对于 TLS(h2)上的 HTTP/2 ,如果您未实现 http1.1 ALPN 标识符,则无需支持任何 HTTP/1.1 功能。

对于基于 TCP(h2c)的 HTTP/2 ,您需要实现初始 Upgrade 升级请求。

只支持 h2c 的客户端需要生成一个针对 OPTIONS 的请求,因为 “*” 或者一个针对 “/” 的 HEAD 请求,它们相当安全且易于构造。希望仅实现 HTTP/2 的客户端将需要将没有 101 状态码的 HTTP/1.1 响应视为错误。

只支持 h2c 的服务器可以使用一个固定的 101 响应来接收一个包含升级(Upgrade)消息头字段的请求。没有 h2c 升级令牌的请求可以通过包含 Upgrade 头字段的 505(不支持 HTTP 版本)状态码拒绝。不希望处理 HTTP/1.1 响应的服务器应在发送连接序言后,应该立即用 REFUSED_STREAM 错误码拒绝 stream 1,以鼓励客户端通过 upgraded 的 HTTP/2 连接重试请求。

7. 第 5.3.2 节中的优先级示例不正确吗?

是正确的。流 B 的权重为 4,流 C 的权重为 12。要确定这些流中的每一个接收的可用资源的比例,请将所有权重相加(16),然后将每个流的权重除以总权重。因此,流 B 获得了四分之一的可用资源,流C获得了四分之三。因此,如规范所述:流 B 理想地接收分配给流 C 的资源的三分之一

8. HTTP/2 连接需要 TCP_NODELAY 么?

有可能需要。即使对于仅使用单个流下载大量数据的客户端实现,仍将有必要向相反方向发送一些数据包以实现最大传输速度。如果未设置 TCP_NODELAY(仍允许 Nagle 算法),则传出数据包可能会保留一段时间,以允许它们与后续数据包合并。

例如,如果这样一个数据包告诉对等端有更多可用的窗口来发送数据,那么将其发送延迟数毫秒(或更长时间)会对高速连接造成严重影响。

三. 部署问题

1. 如果 HTTP/2 是加密的,我该如何调试?

有很多方法可以访问应用程序数据,但最简单的方法是 NSS keylogging 与 Wireshark 插件(包含在最新开发版本中)结合使用。这个方法对 Firefox 和 Chrome 均可适用。

2. 如何使用 HTTP/2 服务器推送

HTTP/2 服务器推送允许服务器无需等待请求即可向客户端提供内容。这可以改善检索资源的时间,特别是对于具有大带宽延迟产品的连接,其中网络往返时间占了在资源上花费的大部分时间。

推送基于请求内容而变化的资源可能是不明智的。目前,浏览器只会推送请求,如果他们不这样做,就会提出匹配的请求(请参阅 RFC 7234 的第 4 节)。

某些缓存不考虑所有请求头字段中的变化,即使它们在 Vary 头字段中。为了使推送资源被接收的可能性最大化,内容协商是最好的选择。基于 accept-encoding 报头字段的内容协商受到缓存的广泛尊重,但是可能无法很好地支持其他头字段。


Reference:

HTTP/2 Frequently Asked Questions

GitHub Repo:Halfrost-Field

Follow: halfrost · GitHub

Source: GHOST_URL/http2-frequently-asked-questions/

❌
❌