从上周就开始了两周的假期,每年的最后一周的主题总是反思和放松,并和过去几年一样,不准备写类似「年终总结」的东西,主要是因为写这个东西实在太累了,无法长期坚持,一想到要回顾一整年,感性上就觉得任务量巨大,能拖就拖吧,于是就拖了一年又一年。就算开始写了,也很难保证它真的是年终总结,生活中有无数的小事在塑造和影响着我们,而这些在年末的时候可能都想不起来了,留在我们记忆中的都是宏观意义上的大事。就算把每件事都写下来了,这些事之间很难有关联性,因为文章的时间跨度是一年,发生在一年内的事情并不代表有关联,于是作为作者需要起承转合来连接这些事情,也构成了写作一大难点。
从读者角度,要看作者一年的信息量,尤其是在赶上了这个年末各种总结堆积的时间,带来的结果是很难让读者认真地去看。
However,年终总结还是有好处的,最大的一个好处是当你和你做的事情、经历的事情保持了一定的距离以后,当初的情绪和冲击早已经烟消云散,能不被当初那个旋涡所带走,而是以一个第三方的视角来重新审视一件事,得到更加客观的视角,从而在事件中成长。
于是我就想了一个折中的办法,写「年终想法」,这是第一条,未来一周正好休假,写到哪算哪,没有任何负担,也不知道会写几条,可能这是第一条也是最后一条。如果刚好能写了4、5条那拼起来「年终总结」也就完成了,同时也不费心费力,正好尝试下。
(原文链接:2020感想(一):从上周就开始了两周的假…)
写作是一个很神奇的东西,当我在写(一)的时候,接下来的二三四大概写什么已经有一些模糊的主题了。
关于年终总结,再多写几句。本质来说就是总结和反思,而反思这件事情的时间跨度不能拖太久。以产品为例,如果一个产品从设计到原型到实现到测试上线,如果整个过程花一年的时间,过程中没有任何反馈,上线后必然会面对大量预期落空。合理的方式是从小开始,快速上线一个想法原型,得到一个反馈,调整后再得到反馈,在这个循环中不断改进自己的产品。年终总结也是如此,我比较推荐的方法是每隔一小段时间(具体时间因人而异)就花很小的精力做一下,做迭代式的总结和反思,这样不仅省事,也能及时调整,免得一年后才发现这件事原来有更好的做法。
第二点是人往往会陷入时间的陷阱,觉得在2021要有什么计划要做什么事情,当然这并没有什么问题,只是这些事情并不用等到2021才可以去做,时间是一个人类发明的概念,而真实的世界只有此时此刻,这一秒和下一秒没有任何区别。好好生活在当下,过好每一分每一秒,做好手头上的事情,做好生活中细微的小事,不过分担心还没有发生的事情。不要期待时间能改变什么,时间本身改变不了任何事情,是我们当下微小的努力、行动和思考,以及这些行动的反馈和结果,在慢慢改变我们自己和这个世界。
(原文链接:2020感想(二) 写作是一个很神奇的东西…)
2020对我而言最大的变化是休了三个月的长假,然后换了一个国家生活,来到了瑞士,加入了Google。
先来说说这三个月的假期,我一直觉得人一直处于工作状态不是一件理所应当的事情,比如在默认状态下,没有一些不可抗力的存在(如因生病必须离开工作),那么会期待这个人一直工作直到退休的年纪。我想尝试一下另外一种方式,工作了一段时间后,在基本了解自己过去几年在玩一个什么样的游戏后,短暂性地和自己所做的事保持一个距离,或者离开职场一段时间,用来总结、回顾和休息。有点类似导演、作家、艺人的做法,我相信导演也不是一年三百六十五天在持续地拍电影,而是有好的剧本有好的机会就集中一段时间把电影拍出来,然后需要长时间得思考和调整,甚至过好几年后再拍下一部电影。于是我就给自己了一个三个月的假期什么正经工作都不做,并用来思考一些根本性地问题,包括不限于:
当然我也深知这个“短暂离开职场”是一件有风险且需要勇气的事情,有太多世俗意义上的得失需要去考虑,并不是每个人都适合。
在这三个月离开职场的日子里,也发现了一些平时在职场中没有遇到的问题:
这些问题我猜也都是自由职业者考虑的问题,所以我也或多或少地了解了一些自由职业者面临的一些难处和困境。总之,这三个月给了我很大的调整自己的空间和思考时间,以及有时间认真思考一下这几年决策和做事的方法。结束了这段时间后,我就踏上了瑞士之旅。
今晚正好是跨年夜,祝大家元旦快乐。
(原文链接:2020感想(三) 2020对我而言最大的变化…)
这篇来写已经在国内工作了几年后,选择出国工作的想法和初衷,不会涉及过多的国外工作的体验,可能要再等一段时间才有能力来写这个话题。
在写下面这些文字前,由「历史往往是成功者来撰写的」类比,当下的自己是过去所有自己的成功者版本,由当下的那个自己来写下过去无数的自己所做的事情和做的决策,很可能会有很多“历史改写”以及无意识的偏见。人是一个很会自我欺骗、自我编故事的动物,所以我尽量以客观、真诚的视角来写作,不然就没意义了。
随着对自己认识的加深,我发现自己是一个「经历派」,想要在还年轻的时候去体验一下不同的生活和经历,于是抱着对不同文化的好奇,我决定尝试一下在一个不同的国家生活。另外我们做的几乎所有事的副作用都是在「自我探索」,在工作、学习、交流、讨论的时候进行自我观察:为什么我会有这个感觉,为什么会觉得别人冒犯到我了,我怎么会对这件事生气/开心。一切事情都是了解自己的一个机会,我又是一个比较喜欢自我探索的人,而把自己扔到一个新奇的、陌生的、甚至是困难的环境中去,没有比这个更能探索自我的事情了。
至于这个体验是好是坏,很多时候并不由我们自己决定。我是在欧洲的第二波疫情还没有爆发的时候过来的,此时每天新增的数字还在有效的控制范围中,还幻想着欧洲的疫情终于缓下来了,之后可以恢复正常的生活。结果后来我们都知道了,欧洲迎来了第二波猛烈的爆发,并且一直持续到了现在,也打破了我一周偶尔可以去办公室的节奏,瑞士疫情也更严重了,又进入了全面WFH(work from home)的阶段。对于我而言,这无疑是一个坏消息,一部分同事还没有线下见过面,这段时间的WFH经历告诉我,人是需要线下见面的,这样信任感才能慢慢建立起来,这也是对所有新入职同事的挑战和困难。
随着第二波疫情的到来也打破了我对很多事情的期待,比如之前计划着去周边国家的旅游,而现在的情况是边境也封锁了,英国的病毒变异的新闻又是连连不断,自己待在家里是目前最好的选择,实在想出游的话只能先在瑞士境内探索,好消息是瑞士有很多风景胜美的地方可以去。
Google的工作环境目前给我最大的感受是大家是在一个「心理安全区」(Psychological safety)的共识下工作的,就是鼓励大家问问题,鼓励大家把心里的困惑说出来,即使是显而易见的问题,也不用担心评价、批评或惩罚。我的同事第一周就把「冒充者综合征」(imposter syndrome)的心理现象分享给我,意思是大家或多或少在某个时刻会觉得自己不应该在这里工作,害怕别人发现自己是个冒充者而已,告诉我如果我现在或以后有这种想法的话,可以聊一聊。
在感想(三)里有些同学留言问想去面试需要准备什么,在这里做个调研,如果大家想看的话留个言或者给我发个私信,需求比较大的话我抽时间写几篇独立的文章。
不过相比如何面试,一个更重要的问题是「自己为什么要离开现在的环境」,我想特别说的一种情况是不要为了逃避而选择另一条路。一部分同学刚毕业不想工作,于是逃避工作去读研究生、结束仍后不知道干什么,逃避去读个博士了,我不是说读研读博本身不好,而是它们的动机问题;工作了发现自己不喜欢,逃避到另外一份工作,这种逃避式的换环境并不会解决问题,同样的问题在另一个环境中还会出现。或许我们生命中都会面临这种场合,觉得自己被什么东西困住了,这个东西可能是你的家庭、是你的工作、是你的婚姻,想要逃出去,去寻找那个“我真正喜欢的东西,我真正适合的东西”,于是我们不断地向外求,这种逃避并不会解决问题,手里拿着同样一个剧本,只不过演员换了而已。当我们发现换一个环境问题没有得到解决,会更加绝望。希望大家都可以在当前的环境中解决问题,而不是以引起问题的方式解决问题。
写到这里篇幅差不多了,之后有机会再写一些在这里生活和工作的其它感受。
(原文链接:2020感想(四) 这篇来写已经在国内工作…)
不出意外的话,这应该是这个系列短文的最后一篇了,简单写一写自己在这一年的一些心态变化。
说到心态,我慢慢意识到人生很大程度上是一个心态游戏。人是一个喜欢做自我批判的动物,类似的想法比如「这件事我是做不好的」,「别人一定会觉得我这件事做得很差劲」、「别人背地里一定是在嘲笑我的」经常出现在人们的心中,变成自己进步和做事的阻碍。结合我自己的经验我想说得是,首先这些都不是真的,都是一些主观扭曲化后的想法,完全和现实违背;其次,不用在乎别人是怎么想的,无论你做得好做得差,总有人议论你。耐心地做好自己的事情,从小事慢慢积累自信。If you want to win like a champion, you have to think like a champion.
另外,在这一年结束后明显觉得自己变柔软了,更有同情心了。对于一些人和事,也不会再去评判了,人往往容易在不了解事情全貌的情况下给出自己主观的判断、甚至是偏见。举个例子,如果一个人如果支支吾吾一个问题解释不清楚,人会下意识地认为是不是ta的水平问题,而真实的原因可能是因为某人在某次交流中批评了ta,导致ta在那个人面前说话就格外得害怕和紧张,还是一个心理问题而不是水平问题。
然后也明确了未来几年不断去寻找的、对我重要的东西:知识、眼界、思维方式、有益于双方的人际关系、偶尔彻夜的长谈交心、人与人之间真实的连接感、让人难忘的体验、保持身体健康和心情愉悦。
自己也慢慢放弃了对「确定性」这件事的执著,以前总是希望做事之前要有明确计划,要知道自己在干什么,而过去一整年的经历劈头盖脸地告诉我们,力所能及地做计划,做好当下的事才是最重要,至于未来怎么样,除了随机应变很难想到别的方法。我们都是在迷雾中行走,没有指南针没有地图,唯一能做的是摸索前进的同时,照顾好自己以及身边的人,thrive in ambiguity。
好了就写到这吧,在写这个系列之前犹豫过要不要写,不过在这个如此混乱无序的世界中发出自己的信号总是没错的,可能会对某些人有所帮助,也可能找到同类的人。祝愿看到这里的大家在未来的时间里,不要放过自己向这个世界发出信号的机会,找到志同道合的伙伴,在不平凡的一年过好我们平凡细微的生活。
(原文链接:2020感想(五) 不出意外的话,这应该是…)
]]>什么是SDK灵活性问题?想象这样一个场景:你正在为公司开发一个新系统,目前已经完成系统的基础功能,并对外发布SDK1.0,让公司内不同的团队可以接入你的系统。为了支持更多功能,会对系统进行迭代开发,此时SDK2.0就发布了,这会带来几个问题。
一个灵活的SDK可以在server端升级功能时,客户端的SDK不需要变化就可以直接新功能。redis提供了一种很好的思路,这里首先给不熟悉redis协议的朋友介绍一下它的基本原理。在redis-cli中输入set foo bar后,客户端并不会解析这条命令,而是把用户的输入完整地传给server端,当server解析第一个字符串发现是set后,就会调用对应的处理函数,最后返回结果给客户端。如果延用这种想法,那么就可以在server升级时SDK不需要更改就可以使用新功能。对于用户而言,只要输入最新功能对应的命令即可,没有升级SDK所带来的成本。另外,由于SDK本身并不解析命令,只负责传输,就不存在前文所说的因升级而维护多个SDK系统实现的问题。
为了实现这种想法,brpc需要一套类似的协议,并且SDK需要满足如下几点:
我们发现redis协议天生就满足上述三点:
如果brpc能直接支持redis server协议,就能实现上述所讨论的灵活SDK,于是就开始设计和实现,并在最近将一个稳定高效的实现合并到了主干。想尝试这个功能的话已经可以拉最新的master代码,阅读redis.h中的注释文档来构建一个redis server。
在实现中尝试了很多性能优化,这一段来简单说一下。熟悉redis的朋友都知道,消息会在server端按序处理,然后按序返回,而一些二进制协议则没有这种要求,这使得server端可以对这些请求进行并发,一个典型例子是http2.0,序列化的时候会带上一个stream_id,在server处理完消息后会将这个stream_id一并返回,从而使客户端可以找到该rpc的上下文。在实现中遇到的一个主要问题是是否要给redis命令的函数回调支持异步接口,如果支持异步接口,框架层面会递给用户一个回调对象done_closure,在用户做完所有操作后,调用done_closure.Run()就可以了,这种写法的好处是server端的消息处理可以是并发的,对消息不需要严格按序处理的场景可以受益,但由于用户可能随时调用done_closure.Run(),而客户端又要求消息按序返回,框架层面需要提供一种队列机制,后面到达的请求如果先处理好了,就需要等前面的请求处理好了发送出去后,才能发送出去。这种方法虽然灵活性很好,提供了异步接口可以并发,但是框架需要额外的维护成本,在实测中,性能并不是很好。第二版本放弃了异步接口,强制要求所有的消息需要顺序执行,实测中表现非常好,在绝大多数的场景单链接上只有一条消息,这种方式是可以接受的。另外考虑到现实中导致并发处理的往往是batch命令,例如在redis中多条命令同时到达server,针对这种场景,做了一个优化:递给用户一个bool变量表示是否是一批中的最后一个,用户根据这个变量来缓存消息(对应bool值为false)或是批量处理消息(对应bool值为true)。我们在开发中还进行了大量编码上的讨论,由于篇幅限制,更详细的可以看[1]。
听起来好像很完美了,客户端发送任意命令,服务端根据命令解析。然而实际场景中会遇到几个问题:
为了更好地理解,这里介绍一个可能的应用场景,在一个分布式key-value系统中的应用。这类系统一般的实现中包含一个metaserver集群,和数据节点集群,如果采取用户通过SDK方式来访问系统,这里做的事情简略来说是先访问metaserver得到数据节点分布等元信息,然后通过某种负载均衡的方式来访问数据节点,如果数据节点访问错误还得做重试,即使节点没挂,也会据业务层的错误码判断是否重试,总之这是一个非常复杂的SDK。如此复杂的SDK还得为每个语言都写一套,很难保证所有语言在某些逻辑上是完全一致的,不仅如此,多套语言的SDK的背后往往意味着巨大的维护成本。
作为解决方案,用brpc写一个redis proxy来做转发,从开发者的角度来说,只需要写一套C++ SDK然后集成到proxy中即可,大大减少了维护的成本和难度。由于SDK的功能全部下沉到了proxy,原来需要业务方升级SDK来支持的功能(如统计上报,改变重试策略等)也可以不依赖于业务方。从系统的用户角度来说,使用新功能的成本大大降低,并且不需要引入一个那么复杂的SDK也保证了程序的正确性和稳定性,SDK使用方式的学习成本也变低了。
在上述的分布式key-value系统的例子中引入了proxy后,还可以玩一些花哨的东西,比如自动缓存。在cache-aside缓存系统里,用户需要和数据库和缓存交互,很容易写出一些竞态的代码[2],而如果将缓存隐藏在proxy后,那么就可以将这部分涉及到更新缓存的代码下沉到proxy,大大减少了业务开发同学的心智负担,只需要像访问单机一样就可以访问缓存+DB的存储系统。
系统设计包含大量的取舍(tradeoff),不会存在一种在每个方面完胜的设计,使用brpc redis proxy也不会是银弹。例如,请求和回复都在proxy上做了一次停留,或多或少会引入延迟,带来一些性能上的消耗。若真的对性能有较高的要求,在部署上可以采取sidecar模式,优点是性能与使用原生SDK几乎是一样的,缺点是依然需要业务方去主动升级,但总比更换SDK方便一些。
总结 在本文中,分析了传统发布SDK的问题,提出了一种在brpc中实现灵活SDK的解决方案,讨论了实现中的取舍,并在一个应用场景中讨论了使用方式和效果。
Reference: [1] https://github.com/apache/incubator-brpc/pull/972 [2] https://coolshell.cn/articles/17416.html
感谢戈君、陈章义对本文的审校。
]]>假设现在有一个新的点击注册文案,想要测试它的效果,实验人员将实验分为对照组和实验组,给对照组看原来的文案,而给实验组看新的文档。实验结果是,对照组中的14500人中有1450人注册点击,而在实验组的14500人中有1600人点击注册,如何通过实验数据来判定新的文案是否优于老的文案?
主要看两个指标:第一个是实验是否统计显著(statistically significant);第二个是统计功效(statistical power)是否满足。
首先看实验是否统计显著,即pvalue是否小于显著性水平,使用的方法是假设检验。具体方法是当零假设(两个版本没有区别)成立时,算出得到观测数据的概率pvalue,如果这个概率小于显著性水平,则拒绝零假设,实验结果显著。pvalue具体计算方式如下: 假设p1,p2为两个版本的总体点击率,对控制组的样本均值设为X1bar,实验组的样本均值为X2bar,根据中心极限定理,X1bar和X2bar均满足正态分布。根据两个独立正态分布变量之和依旧是正态分布,X1bvar-X2bar也满足正态分布。于是有
然后建立零假设p1-p2=0,并计算z-score,如果z-score大于显著性水平所在的临界值,那么就拒绝原假设,即p1不等于p2。这里需要注意的是,就算拒绝了原假设,它也是有概率成立的,只是这个概率太小,一般显著性水平alpha设置在0.05,那么只能说95%的概率原假设是不成立的。如果pvalue小于alpha,但是真实结果是p1等于p2,这类错误叫做第一类错误,让alpha的值较小可以降低这类错误的发生。
第二个指标是统计功效。需要让这个指标达标,样本数量需要满足一定的要求。在实验开始前,先计算每个实验的分流数,让实验经过那么多流量后,该实验的统计功效才能得到满足,分流数的计算方法为(来自http://www.evanmiller.org/ab-testing/sample-size.html)
1 2 3 4 5 6 7 8 9 10 11 12 |
|
其中alpha为显著性水平,power_level是功效值,p是控制组基准值,delta为实验的最小差异。当实验经过那么多流量后,如果统计显著,那么该实验就是可信的;如果统计不显著,我们要确保这两个版本确实没有足够大的差异,即接受零假设p1=p2,此时会发生的一种情况是,p1不等于p2,只是这次实验中,p1-p2的值刚好落在假设检验的接受域中,这类错误称为第二类错误(接受了错误的原假设)。为了让第二类错误足够少,可以让p1-p2这个正态分布足够窄,那么落在Z_alpha之内的值的概率就足够小,此类情况的概率就足够小。
在以上两种指标都达标时,当实验结果显著时,它大概率就是显著的(显著性水平保证了第一类错误小于5%);当实验结果不显著时,它大概率就是不显著的(统计功效保证了对这个不显著的结果有多大的信心)。
实验组和控制组还可以计算置信区间,表示总体参数的范围。置信区间有两种解释方式。 第一种是计算一个结果本身的置信区间,比如Click/Impression=10/10000,那么它的置信区间为0.001±sqrt(0.1*0.9 / 10000) 第二种是计算某组相对控制组的提升百分比置信区间,我们采取这种方式展示。
不仅要看最终是否显著,还需要看趋势:试想有一个改进在白天可以提升100%,而在夜晚下降了20%,如果只看最终的结果,那么一定是显著的,但是通过看趋势我们完全可以看到晚上的异常值,从而更细粒度地发现问题。
1.保持持续学习
这一点永远是老生常谈的问题,任何行业任何职业都需要不断学习,像迭代产品一样进行自我迭代,这一点非常重要。而且好消息是,就我个人经验而言,如果把获得作为y轴,学习的付出作为x轴,这条曲线不是线性的,是一条斜率越来越大的曲线,也就是说已经跑起来的人会跑得越来越快。由于知识通常是有关联的,并且具备迁移性(一个思想可以在多个问题里使用),所以在相同努力的情况下,你原本学得越多,那么收获会越大。这听起来像投资,其实学习本身就是投资,可以形成利滚利。如何衡量学习的成果?让自己的速度跟上摩尔定律,即每18个月自己的综合能力需要翻倍。
2.深入思考以及持续深入思考的能力
这个世界上有很多会深入思考的人,但能持续性地深入思考是一个非常稀缺的品质。我自己也在不断地练习,我通常做法是自己和自己辩论,自己怼自己:这个设计/功能为什么要这么做?为什么不那样做?那样做的话有什么潜在问题,需要处理什么tradeoff,这个做法是最好的了吗,有没有更好的?另外一个练习是我会在碎片时间把脑子里queue里面的问题拿出来思考。一个广为流传的“好做法”是用碎片时间来阅读,但这个做法并不适合我,碎片时间根本读不进书,读书需要大片成段的专注时间,还需要做笔记,这还不是主要原因,更重要的是我需要大量的时间来把我之前的输入内化成自己的东西,碎片时间刚好可以做这件事情,很多时候我在地铁/健身/洗澡/散步的时候把某个问题想清楚了,这种体验非常好。另外还有一点,做技术的同学通常都很认真敬业,同时也很容易沉浸于技术的海洋中,但这个世界是紧紧互相联系的,读各种各样的书,然后思考各种类型的问题能让自己的思路开阔很多。对于一个问题自己心里有了一个初步的想法,觉得暂时想不出什么了的时候可以去干别的事了,下一次再想到这个问题的时候,可能就会有一个比较清晰的思路了。
3.学会写工业级代码,即高质量的代码设计和高性能高稳定的代码实现
我从去年年初至今参与了brpc(百度内部的rpc框架,现已开源)的开发和维护工作,代码实现和抽象能力进步了很多。一个self-contained例子是,有一次需要给brpc加QueryRemover的支持:给定一个QueryString,能通过迭代器的方式来遍历key,然后决定是否删除当前的key,遍历完后返回修改后的QueryString。用naive的方式要实现这个功能其实很简单,但这不是我们追求的,这里要考虑的是:各种corner case/错误输入的处理,内存分配做到最优,性能尽可能好,当什么key都不删除的情况下应该任何内存都不分配的。最后改了几次以后代码变成了这个样子。现在再回过头来看这段代码,脑子里已经不再是当初naive的想法了,而是intuitively这应该就是这么实现的。类似的例子还有很多,当自己已经尽全力写好一段代码但还是被code review打回,这是一件很好的事情,因为这说明要么是自己,要么是对方还有一些不知道自己不知道的东西存在,无论是哪种情况都会对一方产生正面的作用。看代码也是一个很好的学习方式,就我个人经验来说盲目地看代码很容易坚持不下来。一种理想的方式是项目中用到了某种技术,正好需要用到/借鉴某个开源代码,然后趁热打铁有目的地去学习源码会容易很多。怎么看代码也是有技巧的,先在脑子里想一下让自己来实现会怎么做,难点记下来,然后再去看代码作者怎么做。例如我前面提到的QueryRemover,有兴趣的朋友可以想一下让你来做会怎么做,然后看一下我们的实现。目前brpc有许多known issues需要开发和解决,欢迎大家领一个走然后给我们提pr :)
4.区分问题的表象和root cause
发现了一个bug,调试发现是某个模块A报的错,那这个bug的表象就是出在模块A,但有些时候root cause可能不出在模块A,真正的原因是模块B中一个已经隐藏了很久的race condition,甚至可能是一个kernel bug。而如何提高这个区分能力,一方面在于经验,更重要的一方面在于调试能力。调试其实是有一套固定流程/方法论的,首先分析现象(问题的表象),复现bug,如果是一个随机出现的bug,那就加大压力,给系统造成瓶颈,增加bug出现概率(减小工作线程数也可以给系统造成瓶颈,但不推荐这种做法,因为如果这是个多线程bug,那减少线程会降低bug出现的概率),竭尽所能以后如果还是无法复现,那就带log去线上去复现,等复现的时候要抓住现场,然后解决问题,再次上线验证,最后总结bug。每一次bug,特别是线上问题,都是一个很好的总结反思的机会,一定是流程上的某一环节出问题了。
5.对问题/数字敏感,一个优秀的开发者一定是敏感的
我的工作导师jamesge曾经和我说,一个程序在controlC退出的时候只要卡了一下下,就一定是哪里出问题了。还有类似的问题比如请求超时,很容易想到的解决方案是把超时时间调长,但某些情况下这只是掩盖了问题,如果有些本来应该很快返回函数处理地慢了一些,必有猫腻。不要拖,立刻去找到root cause,否则就是为以后埋坑。
6.锻炼抽象代码的能力,代码要尽可能低耦合高内聚,易拓展易维护
如果来了一个超紧急功能,我更倾向于的做法是好好评估一下,加下班做好代码抽象,而不是先粗糙实现然后加个TODO: refine this code,大部分情况下就渐渐就忘记了,变成以后的技术债了,更何况通常也不会有这种“今天开发明天上线”的需求。从软件开发的一开始就要保证每一次代码CheckIn都是高质量的,做好规范,一次写烂了就会发生第二次,然后慢慢地整个项目里都是烂代码,这就是破窗效应。在过去几年我C++写的比较多,以前有一段时间以为,功能本身的实现是需要关注的东西,而现在的观点是功能本身实现是开发中最基础最简单的东西,难的是代码抽象、性能压榨和对象的生命周期管理。另外不能偷懒,该重构的地方就立刻去重构,目的是降低代码的复杂度。让代码易维护的核心在于降低复杂度,这是一个很大的topic,推荐看《代码大全2》,最好的一本讲如何写好代码的书。
7.对自己设置高标准
曾经看到一个说法,叫面向离职编程,不是让你离职,意思就是说,你要把你的每一次代码提交,文档撰写、团队讨论都要以像在交接工作的态度来完成。写代码是写作的一个派生类,精神的传递是写作的目的之一,所以写代码同样也有这个目的,让别人看到你的代码能被惊艳到。能决定你能走多远的下限靠两样东西,基础和hardwork。当对自己设定了较高的标准,hardwork是一件为了达到标准而自然而然发生的事情,随之而来的是慢慢拥有owner意识,之后就会主动去思考目前系统的问题,然后提出问题并去推动。自己给自己找需求,自己就是产品经理,开发者,测试。当拥有一些owner意识以后,就会去把玩目前的系统,corner case、压力、异常、极限情况下的表现都会去尝试测一下,没准线上就会遇到类似的情况。对于一个系统,别人看来已经完美了,正常运行不会挂了,但真正对问题把控到位的人是不会这么想的,他会感觉整个系统都是潜在问题,担心晚上随时会挂掉,第二天迫不及待起床要优化。
8.随时应对变化
不太喜欢打标签,比如你是前端,他是后端,更喜欢的说法是,我是一名开发,目前正在做XXX相关的事情。话里的意思是,随时准备好公司/环境/时代的改变,做技术转型,不要抱着自己已有的东西死死不放,那会是优势也是枷锁,同时也要意识到有些东西是沉没成本,一个理性人在考虑未来决策的时候不应该过多地考虑沉没成本。另外我认为现在是一个最适合学机器学习的时间,它不会像之前的安卓ios大数据那样成为一时的议论焦点,然后慢慢热潮退去,对于这一点我的行动是从两年前开始正经地系统学习,不算早但也不算太晚,就算以后不做这一行也要了解未来的趋势。现在最缺的不是研究人员,去看看每年顶会的投稿量就知道了,太夸张了,而是能把技术落地的人。
9.8/2法则
花20%的时间去了解一个领域80%,而不是花全部的时间去了解一个领域。你在一个领域花的时间越多,你的边际收益会越小,当边际成本大于边际收益的时候,如果没有特殊的理由,就该换一个领域去深入了,不同领域的思维碰撞没准也会产生更有价值的想法。
10.保持谦虚,保持开放的心态
从大了看,人类只是宇宙中的蝼蚁,而从长远看,人类的历史相对宇宙而言只是昙花一现,更何况我们每个人只是生活在自己有限的圈子里,没有理由去骄傲自大,有太多未知的东西了,自己引以为傲的东西可能就是别人的习以为常。我们唯一能做得,就是找到厉害的人,向他们学习,了解他们的想法,思考过后形成融合,让自己的想法和思路更加开阔,结果就是你会变得更厉害,然后会认识更多更厉害的人,形成良性循环。能看到和他人的差距然后去提升自己最后看到自己身上的成长,这件事本身就让人激动。
最后一点,也是最重要的:
11.自信自律,早早进行各方面的积累,形成马太效应
自信是,一件事还没有做呢,就知道自己可以做成。这和谦虚并不矛盾,谦虚的本质是保持开放和学习的心态。这里有个问题,怎么培养自己长期持续的自信?有一个小方法,建立一本自己的成功笔记本,把自己做成功的事记在里面,多小的事都可以,每当受挫失落的时候都拿出来看一下,就会原地复活的。当自信慢慢培养起来后,就会慢慢出现一种解决问题的惯性思维,可以理解为强者思维。遇到一个问题,拥有强者思维的人不会找借口,会去找要解决问题所缺少的东西,比如他会想,只要搞定了xx、yy和zz,那么这件事就搞定了。
未来的路还很长,认准的事不要过早放弃,2018希望各位朋友都顺利,生活开心。
(同时发布于知乎)
]]>之前看到的几乎所有的地方(包括我自己)都是这么写的:
1
|
|
后来校招面试百度的时候,一位叫戈君的面试官无意中告诉我这么写是错的,我回来想了一想,果然是不对的。 各位读者如果以前一直都是这么写的,不妨先不要继续看下去,自己思考一下错在什么地方。
为了说明错在哪里,我们举一个简单的例子: 我们假设rand()的返回的最大值是9,N等于7,那么rand()返回[0,6]时,直接返回该值;如果返回[7,9]时,则返回[0,2],我们可以看出,返回0的概率是2/10,因为rand()得到0和7都会使结果为0,同理1和2的概率也是2/10,但是[3,6]中的数概率为1/10。这显然不符合均匀分布的定义。
稍稍想想可以发现:问题出在了N不能整除rand()返回的范围,使得最后“少了一段”。 试想上述情况中N=5,就不会发生这种情况,每一个值出现的概率都是2/10。
为了解决这个问题,思路是这样的:假设rand()的返回值范围是[0, M),我们需要在这个范围划分出N个bucket,然后随机一个数,看这个数落到哪个bucket里,那么就返回这个bucket的标号。每份的bucket的长度L为M/N,那么这个范围中最后还剩余M%N。 如果rand() < M - M%N,那么就返回该值/ L;否则就重试一遍,直到达到上述条件。
用C++写出来代码是这样的:
1 2 3 4 5 6 7 8 9 10 |
|
这样返回值就是满足在[0,N-1]上的均匀分布。
另外,想到了一道面试题:函数rand5可以返回在[1,5]上的一个随机数,满足均匀分布,如何用这个rand5来实现一个rand7(即可以返回[1,7]上的一个随机数)?这个题的本质和上述思想是一致的,读者诸君不妨想想如何求解。
]]>答案是具体情况需具体分析。
一部分朋友觉得用锁会影响性能,其实锁指令本身很简单,影响性能的是锁争用(Lock Contention1),导致scalability非常差2。什么叫锁争用,就是两个线程都想进入临界区,但只能有一个线程能进去,这样就影响了并发度。有兴趣的朋友可以去看看glibc中pthread_mutex_lock的源码实现,在没有contention的时候,就是一条CAS指令,内核都没有陷入;在contention发生的时候,选择陷入内核然后睡觉,等待某个线程unlock后唤醒(详见Futex)。
“只有一个线程在临界区”这件事对lockfree也是成立的,只不过所有线程都可以进临界区,最后只有一个线程可以make progress,其它线程再做一遍。
所以contention在有锁和无锁编程中都是存在的,那为什么无锁有些时候会比有锁更快?他们的不同体现在拿不到锁的态度:有锁的情况就是睡觉,无锁的情况就不断spin。睡觉这个动作会陷入内核,发生context switch,这个是有开销的,但是这个开销能有多大3呢,当你的临界区很小的时候,这个开销的比重就非常大。这也是为什么临界区很小的时候,换成lockfree性能通常会提高很多的原因。
再来看lockfree的spin4,一般都遵循一个固定的格式:先把一个不变的值X存到某个局部变量A里,然后做一些计算,计算/生成一个新的对象,然后做一个CAS操作,判断A和X还是不是相等的,如果是,那么这次CAS就算成功了,否则再来一遍。如果上面这个loop里面“计算/生成一个新的对象”非常耗时并且contention很严重,那么lockfree性能有时会比mutex差。另外lockfree不断地spin引起的CPU同步cacheline的开销也比mutex版本的大。
lockfree的意义不在于绝对的高性能,它比mutex的优点是使用lockfree可以避免死锁/活锁,优先级翻转等问题。但是因为ABA problem、memory order5等问题,使得lockfree比mutex难实现得多。
除非性能瓶颈已经确定,否则还是乖乖用mutex+condvar,等到以后出bug了就知道mutex的好了。如果一定要换lockfree,请一定要先profile,profile,profile!确保时间花在刀刃上。
http://preshing.com/20111118/locks-arent-slow-lock-contention-is/↩
A classic paper on how different locking alternatives do and don’t scale: “The Performance of Spin Lock Alternatives for Shared-Memory Multiprocessors”↩
context switch的开销不仅仅是push和pop寄存器,它还引发了cache、TLB、branch predictor等CPU状态的丢失,具体如何测量CS的值,请参考“lmbench: Portable tools for performance analysis”↩
有同学问无锁和自旋锁有什么区别,不都是在一个循环里spin吗?自旋锁的本质还是应用层的锁,当一个线程持有锁后,被调度出去了,其它线程还是无法继续,而lockfree不是这样的,它可以保证至少一个线程make progress↩
关于memory order,这是个不错的入门资料:http://www.chongh.wiki/categories/High-performance/↩
今年三月底,我被邀请回高中母校嘉二中给高一的学生做了一个20分钟的讲座,名字叫《如果我回到高一,我会做……》。听母校的老师说,现在的高一学生,也就是00后,个性太张扬,有非常强的自主意识,希望有一个还是学生的学长能够给他们一点启发。个性张扬从我的角度来看是一件非常好的事情,正是因为他们还这么年轻,才会初生牛犊不怕虎。等经历了更多,犯了一些错,踩一些坑,自然就会成长或者收敛一些。所以我倒没有老师那样的担心,毕竟每一代人都有各自老师眼中的大问题,但每一代都成长得非常好。
所以在我选题方面也是很纠结的,毕竟讲得太心灵鸡汤了怕对他们压根没用,讲得太偏实际操作了怕他们这个年龄也不懂,考虑到高一学生专注集中度,我只讲了三点,三点我认为对他们可能会有帮助的地方。精简了文字后,以下是我的演讲主体内容:
学弟学妹你们好。
先自我介绍一下,我是二中2007届的学生,在二中度过了非常快乐的三年,在10年的时候通过自主招生进入了上海交大,我大一入学学的专业是船舶,后来因为兴趣的原因转专业到了计算机,大四的时候直接本系保研,所以我现在是一名研二的学生,会在明年毕业。
// 不讲人生经验,因为很多事情没有经历过就很难理解。比如说,我相信在座的大多数同学都有一个逼你们穿秋裤的妈,很不情愿,嫌妈妈很烦,10年后等你们做父母了,就会体会到父母的良苦用心。今天说一些你们能做到的事情。
第一次接触抽象是我大学低年级学数据结构的时候,记得很清楚当时学一个概念叫抽象数据类型(abstract data type),大概意思就是一个数据结构,接口是一回事,实现是另一回事,比如栈,作为使用者你只需要知道它有push、pop、isEmpty等方法,但它的底层实现到底是array还是linked list,你是不需要知道的。
用通俗一点的话说,抽象就是你好好做自己的事,以及知道别人能帮你干什么事,至于别人是如何帮你完成的,你没必要知道。
这个简单的思想大大提高了开发者的效率,让开发者只专注于要解决的问题,而不是一些细枝末节的事。
孟岩在它的博客里也提到过“关注重点”这件事,虽然没有明显地提及抽象二字,但他的意思和抽象表达的意思是一样的:
我主张,在具备基础之后,学习任何新东西,都要抓住主线,突出重点。对于关键理论的学习,要集中精力,速战速决。而旁枝末节和非本质性的知识内容,完全可以留给实践去零敲碎打。
原因是这样的,任何一个高级的知识内容,其中都只有一小部分是有思想创新、有重大影响的,而其它很多东西都是琐碎的、非本质的。因此,集中学习时必须把握住真正重要那部分,把其它东西留给实践。对于重点知识,只有集中学习其理论,才能确保体系性、连贯性、正确性,而对于那些旁枝末节,只有边干边学能够让你了解它们的真实价值是大是小,才能让你留下更生动的印象。如果你把精力用错了地方,比如用集中大块的时间来学习那些本来只需要查查手册就可以明白的小技巧,而对于真正重要的、思想性东西放在平时零敲碎打,那么肯定是事倍功半,甚至适得其反。
最近发现,计算机专业的课程完全可以用抽象来解释:每一门课想做的事就是利用下层提供的接口,实现功能,然后再给上层提供接口。这样一层一层的抽象就构成了所有的专业课。
举一个例子来说明这个从上往下的层级抽象是如何组织的。
先来看最高层,问题。刚学编程的时候,会先学一门课导论课或者编程入门课,我当时的入门课叫做“程序设计”,课程内容是介绍一些问题,然后介绍一点编程语言的知识,作业是一些编程问题,比如八皇后、素性测试之类的比较常规的编程题。这门课的目的一般都是介绍“问题”的,介绍计算机科学有哪些有挑战的问题,让学生对计算机专业有一个感性的认识,而不是对特定编程语言或者算法的学习,所以这类课程一般用python来编程。
再往下一层,算法和数据结构。这一层的目的是学习/实现各种算法/数据结构,提供给上层功能。比如排序,问题解决者只要知道这里应该用快速排序,而不是选择排序,而把快速排序的实现留给这一层的开发者,从而使各种优化都可以对上层透明,比如小数组变插入排序、中位数取pivot、三向快速排序等,这些优化调用者完全不必要知道,他只需要知道:哇,这个库提供的快速排序还真快。
再往下一层,语言层,毕竟所有算法都要由某一门语言来实现。这一层的存在使得算法的设计可以脱离具体的语言。不同语言又提供了不同的抽象,像函数式语言就比命令式语言的抽象级高,更高的抽象级意味着更加专注问题本身(不需要考虑内存布局、CPU使用等)以及更少的代码量。
再往下一层,编译器/解释器。我们编写代码是用高级语言,而cpu上执行的是机器码,所以这个抽象层帮我们做了这个转化。这个抽象层的好处是,高级代码的编写者完全不需要知道这个程序所运行的操作系统和硬件平台,任何有该语言编译器/解释器的机器,程序都可以跑(从而实现了跨平台)。应用层开发者可以不用知道这个由高级语言到机器码的转化具体是怎么实现的,毕竟编译器优化的编写和优化完全是一个团队的工作量,开发者关注问题的解决,编译器负责转化出高效的机器码,各干各的,这正是抽象的重点。
再往下一层,操作系统。OS向开发者抽象了硬件(CPU、内存、Disk、NIC等),并且以syscall的形式向用户提供服务。OS的设计是最能体现抽象的,虚拟内存和进程让程序以为自己独占着内存和CPU,同时隔离了不同进程以防恶意进程;文件系统让用户可以方便地读取存储数据,而不需要直接操作底层的硬盘;文件描述符抽象了底层的设备(pipe/file/device/socket/…)。
再往下一层,ISA(Instruction set architecture),俗称软件与硬件的接口。这个俗称是非常形象的。指令集架构,说得简单点就是机器码,也可以理解为一个协议。ISA标准制定者指定一套指令集(比如x86、PowerPC、SPARC),然后编译器开发者需要根据这个标准/协议来编写对应的编译器;CPU制造商需要根据这个标准/协议来制造出支持这套ISA的CPU(比如intel的CPU支持x86/x86_64)。也就是说,软件/硬件都依照这个ISA来设计,那么就可以对接了。
再往下一层,组成原理和体系结构。这一层要做的事情是借助数字电路给它提供的功能(组合电路和锁存器),来设计一个能实现某种ISA的CPU,让编译器生成的指令可以在此CPU上运行。大学里一般会开一门叫“计算机组成原理”的课,一开始学单周期CPU的实现(取指、译码、执行……),为了提高效率又提出了流水线的实现。为了发掘更高的效率,之后又会学一门叫“计算机体系结构”的课,这门课的目的是为了发掘更高的并行,从而制造出更快的CPU。那这一层是如何用数字电路提供的功能?举两个典型例子:一、CPU为了做计算会有ALU模块,而ALU模块正是一个组合电路(输入确定那么输出确定);二、在流水线寄存器中每一个时钟上升沿都会保存输入的值,在这个时钟周期内组合电路会根据这个新值计算出结果传输到下一级流水线寄存器的输入,等待下一个时钟上升沿的到来,这里的流水线寄存器正是某种锁存器的实现,而CPU开发者并不需要这个数字电路模块是怎么实现。
再往下一层,数字电路。这门课的目的是教学生如何利用基本的门电路(与非或门)来实现一些高级的功能(译码器、多路复用器、锁存器、时序电路……),然后给上层提供功能。上数字电路课是一个用砖搭房子的过程,由基本的门电路开始,慢慢构造出复杂的电路。数字电路不需要关心基本的门电路是如何实现的,因为这正是模拟电路向上层提供的功能。
再往下一层,模拟电路。这一层实现了与或非等基本门电路。比如非门、与非门、或非门都可以通过若干个p/n型MOS晶体管构成,而与门可以通过连接一个与非门和非门构成,或门可以通过连接一个或非门和非门构成。很多同学都觉得模电对于计算机的同学不必要学,而我认为相反,它是你构建整个计算机抽象层级的基石。
再往下一层,就不是计算机领域研究的事了。
当然,还有很多专业课我并没有提及,比如网络,它是OS提供的功能,以文件描述符的形式提供给用户使用;在网络协议栈实现的细节里,又分了好几层抽象,这就是我们熟知的OSI七层网络模型(有时候被抽象为五层:Application、Transport、Network、DataLink、Physical)。各位可以自己回忆一下大学里上过哪些专业课,以及它应该放在抽象的哪一层上。
仔细一想会发现,几乎所有的技术书籍都尝试在解决某一层上的问题,利用下层提供的抽象,然后向上层提供功能。
让我们脱离计算机领域,再往高一点看,会发现整个计算机领域就是在为别的领域提供功能、并隐藏了细节:医疗、交通、餐饮、支付……
抽象,让生活变得更简单了一点。
]]>这门课主要讲的是操作系统原理与实践,具体分三个部分:lectures,labs and readings。 Lectures部分会通过介绍xv6来阐述OS原理,并解读xv6源码; Labs是这门课的实践部分,课程会提供一个OS的框架,但核心部分全部都是缺失的,需要学生来填写核心代码来实现这个OS; Readings部分会阅读一些paper来了解一些更深入、更有意思的topic。
一个很自然的问题是,它与别的OS课程的区别在哪里? 最大的一个区别是,这门课的实践部分比重非常非常大,而OS本身就是一个实践学科,所以6.828的编程练习会让你对OS的概念非常清楚。 具体来说,这门课一共有7个lab,写完这7个lab,一个操作系统就被写出来了(名字叫JOS,是专门为这门课设计的Exokernel,麻雀虽小五脏俱全),除此之外,老师们为了让学生了解不同OS的架构和风格,在讲课阶段会主要以xv6(一个教学的操作系统,它是Monolithic kernel)的代码讲解OS概念,也就是说,在学期末,你会自己编写一个Exokernel和完整阅读一个Monolithic kernel的代码。
虽然我本科阶段也上过OS,但大作业的量完全没有到达这个编码强度(当时交大的OS课大作业是编写一个toy文件系统),导致自己以前对很多OS的概念和实现细节都是只见树木不见森林。然后从去年12月初,一共用了三个月的时间把这门课刷完了,对OS的理解清楚了很多,以及本科学的很多知识点都串了起来。
基于这个原因,不管你是学生还是已经工作几年的前辈,如果你想了解一个小型OS具体到代码是如何实现的,这门课是首选。
介绍一下每个Lab的需要做的事情:
Lab1是熟悉的过程,需要学习QEMU模拟器的使用、开机启动流程、调试工具、bootloader、以及整个加载kernel的流程。做完这个lab会具备基本的内核调试能力,以及掌握开机到通电,bootloader是如何加载kernel的。
Lab2要完成JOS的的内存管理模块,需要学习一些计算机基础知识,如虚拟地址系统是如何工作的,地址空间是如何切分的,物理页面是如何管理的。做完这个lab将会给JOS添加最基本的内存管理功能,即Kernel其余模块需要物理页,这个模块可以分配出来。
Lab3为JOS添加进程的支持、异常/中断的支持、系统调用和页中断的支持。这个lab内容比较多,但收获也比较大,做完后会对从用户态陷入内核态,执行系统调用,然后返回这整个流程都非常清楚(不是泛泛的清楚,而是代码级别的清楚,这是和学概念不同的地方)。
Lab4为JOS添加多核支持、RR调度、COW的fork、抢占式内核、时钟中断和最基本的IPC机制。做完Lab3和Lab4,一个能用的OS已经出来了,但用途非常有限,因为没有文件系统和网络的支持,Lab5和Lab6就会做这两件事情。
Lab5为JOS添加基于Disk的文件系统、块缓存、实现键盘中断、修改一个shell支持最基本的功能。完成这个Lab后就可以在shell里面输入命令以及文件系统的支持了。JOS没有实现crash recovery,在xv6用log的方式实现了crash recovery,代码都非常好(虽然效率很差…课里会讲)。
Lab6为JOS添加网络的支持。网络部分主要分两个大块,一个是协议栈的编写,一个是驱动的编写。协议栈太复杂了,于是课程提供了现有的lwip库。我们需要实现驱动,这涉及到阅读intel的网卡硬件手册,这是本课程让人头痛的地方之一。完成驱动以后,需要完成用户态的network server(网络服务以用户进程的形式提供是Exokernel的特点之一,和Microkernel很像),还需要完成一个用户态的web server,完成之后经过QEMU的端口转发,在host机器可以访问qemu里面运行虚拟机的web server!第一次运行成功也是觉得非常神奇,因为OS从上到下,在此时此刻,全部打通了,可以向外提供服务了,并且这个OS的核心代码全部是在课程里完成的。
Lab7和Lab6只需要选择一个做,Lab7是一个需要组队的开放式课题。
不可否认的是有些lab真的非常让人头痛、一个bug可能会调很久甚至几天没有进展,但到最后收获的远多于付出。
如果你对上面的Lab有兴趣,那花三个月去上一下这门课会非常值。其实可能用不上三个月,我是跟着课程进度一点点做的,如果有一点OS基础可以直接做Lab,稍微加班加点一个月左右也可以完成了。
JOS还有很大的提升空间,归根结底这只一个“working”OS,离工业级使用还差很多,以下是我总结的一些改进点:
JOS和xv6的内存管理方式都是空闲链表,这导致如果内核想要连续的物理内存,将十分困难(因为碎片的存在,即使没有碎片,也将是O(n)的复杂度),所以linux采用了buddy system来解决碎片问题。(至于内核为什么需要连续的物理内存,请看我在V2EX上问网友的一个问题)
JOS和xv6的进程调度是最简单的RR方式,这使得它们无法应用在某些特殊的场景下,而且父进程很容易fork子进程把CPU时间全抢了。
JOS和xv6根本就没有磁盘调度算法,应用层来一个读写磁盘请求就直接由驱动发送给磁盘控制器。
JOS的文件系统效率差,且不支持crash recovery,即使是实现了crash recovery的xv6,效率也非常差。不过在文件系统设计中,performance(don’t write the disk)和safety(write the disk ASAP)本身就是一个tradeoff。
…
上完这门课后推荐看一下Rober Love的《Linux内核设计与实现》,会发现JOS到底哪里做得不好以及Linux是如何解决的。
]]>首先,比赛形式比较新颖,之前也参加过类似的马拉松以及听闻过很多XX编程马拉松,形式都是差不多的:接受组队报名,然后花上两天时间大家待在一个地方通宵编程实现一个和主办方做的事完全没有关系的创意,一般会在第二天下午开始做presentation,最后会根据评委打分决出获胜队伍。这种类型的比赛,根据我的观察,presentation是最重要的,其次是idea,最后才是技术。而这次的饿了么举办的马拉松分初赛和决赛,初赛前10名进入决赛,初赛需要实现一个功能,最后评测完全靠的是实力和技术,分数公开透明。虽然还不知道决赛题目是什么,但也令人期待。
其次,如果真的进了前10名,能去参加决赛,大家可能都是非常强的高手,想想也令人激动不是么。
于是和另外两位小伙伴一起参加了这次初赛。
初赛的题目是使用Python, Java, Go三种语言(任选其一)实现一个“限时抢购”功能。具体来说,就是实现一个web服务,对外提供一个restful接口,让模拟用户可以通过接口来抢购数据库中的食物。评分使用功能测试、性能测试的结果做为指标。功能测试就是看有没有超售,错误处理之类的,通过了功能测试才能进入性能测试这个打分的环节。
一个能跑的程序还是很容易实现的。其中有一个难点是,因为是分布式服务,所以我们必须保证下单并减库存的操作是原子的,传统的读-改-写在高并发情况下不适用,好在redis支持lua脚本是原子的,于是这个问题就解决了。
因为提供了redis,又是性能的比赛,所以我们决定放弃mysql,完全用redis来做。
因为负载均衡的存在用户请求可能被分发到任何一台应用服务器上,redis中需要存储一些共享的数据,如用户登录后的token,用户的购物车,用户的订单,以及最重要的食物库存,如下图所示:
KEY VALUE
---------------------------------------------------------
token:user hashmap: {<tokenid>: <userid>} // 检测token的合法性
user:order hashmap: {<userid>: <orderid>} // 获取某个user的订单,系统只允许一个用户下一单,所以这个hashmap能检测是否重复下单
cart:user hashmap: {<cartid>: <userid>} // 获取购物车是哪个user的
cart:<cartid> hashmap: {<foodid>: <cnt> } // 该购物车的食物及数量
order:user hashmap: {<orderid>: <userid>} // 这个order对应哪个user
order:<orderid> hashmap: {<foodid>: <cnt> } // 每一个订单包含的食物及数量
food:stock hashmap: {<foodid>: <stock>} // 食物的库存
一开始我们用python写了个能跑过测试的版本,能异步的地方都用了协程,但是分数还是比前十的队伍差了很多,于是我们怀疑是不是语言层面的问题导致了分数差那么多,于是我们队伍的小伙伴对python和go做了一个性能比较,其中每一次访问/
都会使redis调用一个脚本,这个脚本和我们下单的操作运算量差不多,结果如下:
LANG WEB REDIS METHOD TIME
---------------------------------------------------------
python tornado redis-py coroutine 1.406ms
python tornado redis-py sync 1.022ms
python aiohttp asyncio-redis coroutine 0.879ms
python falcon redis-py gunicorn 0.669ms
node express.js node-redis event 0.301ms
golang net/http gopkg.in/redis.v3 coroutine 0.245ms
我们发现在相同的环境下,go的性能是最好,虽然很不情愿,但不得不放弃python用go重写。事实证明,用go以后分数有明显上升。
食物的库存存在redis的hashmap中(foodid映射到stock),下单用lua脚本来做,查询库存就返回所有食物列表和相应库存。后来我们发现这个方法效率太低,每次查询库存都要返回所有的食物,即使很多食物库存都没有变化过,太浪费I/O。于是我们小伙伴提出了一个基于timestamp的方法,结果就是每次查询库存值传输某个时间之后变化的食物,大大减少了流量。
具体方法是这样做的:本地存储一个当前timestamp,并且redis端存储一个foodid到该食物最后更新时间,然后把(更新时间|foodid|foodstock)这个长整型存储在redis中的ordered set里,key就是更新时间,于是我们在查询库存的时候,只需查询从app的timestamp到正无穷的时间里有哪些元素就可以了,复杂度是log(N)的,然后根据这些元素的值就可以还原出id和stock值。
这是个非常精妙的思想,当时在腾讯实习的时候看过微信内部的一篇文章,说微信为了同步不同设备上的消息记录,用的就是这个时间戳的思想,差分传输而非全部。
下单操作就麻烦一些了,先根据id找到这个食物的最后更新时间,然后根据这个值从ordered set里拿到(更新时间|foodid|foodstock)这个长整型,分解出id和stock,减完库存还得删除原来的元素插入新的元素,这些都是log(N)的操作。
随着比赛的进行,我们发现自己的分数卡在一个瓶颈了。某一天晚上我突然发现,我们实现了系统的强一致性,而题目里根本没有要求我们这么做!我们用timestamp的目的是为了让查询库存节省流量,但是带来的tradeoff是每次下单的时候都有好几次log(N)操作(需要根据查询食物最后更新时间来找到这个食物的库存,而后者存在redis的ordered set里)。但是在测试的时候根本就没有测获得库存的强一致性,题目要求的是最终一致性,所以我们没必要实现地那么老实,只要一个goroutine隔个两秒去redis拉一下数据就可以了,然后我们的下单操作变得异常简单,把stock存成hashmap,只是几个简单的O(1)操作。
我们为了库存显示的强一致而牺牲了下单的效率,这明显是不值得的,我们宁愿下单快,但库存会有一些误差。这在实际工程也是合理的,网站显示库存还剩一个,但是你下单的时候发现库存为0,这是用户可以理解的,手慢了就被别人买掉了。但是这个妥协大大带来了下单峰值的上升。
整个比赛后期就是在不断优化的过程中,其中一个比较难的地方在于如何确定瓶颈,在redis?在I/O?还是在APP服务器的处理速度?最后几天就是在不断地对程序做profile然后性能优化然后再做profile。一个印象比较深的例子是有一个操作我在不断纠结要不要放到redis的lua脚本去做,如果放了会增加redis的负载,但是会减少网络I/O的时间,线上测出来的结果又是差不多的,那这个做还是不做?在比如reids线程池到底搞多少个才是合适的?
另外,把能缓存的数据全部缓存起来,性能会提高不少。
做这个项目中遇到了太多需要tradeoff的地方:
放在了github上。
我们的代码峰值是4600多单每秒,而饿了么大学(好像是内部员工组成的队伍)的实现是5000多单每秒,还是有400的差距。
在看了饿了么5000分的标准实现后,发现这400分的差距还是有原因的,为了性能redis里面存的越少越少,这样I/O次数就少,罗列一些我们组没有注意到的点:
token其实是不需要存redis的,token如果是userinfo的一个函数映射,那么每台机器都可以根据这个函数事先为每个user生成一个token,而不是随机字符串。比如md5(userId)作为token,然后在起服务器的时候就把token生成好,并且维护一个token到userinfo的映射(为了之后带token的请求找到该user),就完全不需要redis了。这样做虽然好,但token好像无法过期了?于是我问了一下主办方,给我发了一篇文章,思路非常好,把client当做database,这样本地就根本不用存一个用户相关的数据,比如token。虽然这篇文章表明了token可以不用存储,但没有回答token的生成和userinfo相关到底是不是一个好的选择?在我们的实现里,用了随机字符串来作为token,每次都是on-the-fly生成,效率明显会差一点
为了判断user是否只下了一单,在我们实现里用了user:order这个hashmap。官方没有这个数据结构,只维护了order存了哪些food和count(也是个hashmap),然后每个user的order被命名为o_<userId>
,所以只要用EXIST
命令看一下这个key存不存在就知道用户有没有重复下单了
把userId嵌到购物车Id里面,那么reids就不用存购物车Id到userId的映射了(值得吐槽的是判定购物车Id是不是有效的方法是传来的string是否满足他事先规定的pattern,这在现实中完全不能用啊)
我们的实现里下单时把购物车这个hashmap完整地复制到了订单这个hashmap,而官方只用了一条redis命令RENAME
官方实现里,每个用户下单的orderId竟然是事先生成好的,其值就等于userId,难道这个不应该是on-the-fly生成的么,这样的好处是下单返回成功和上述的RENAME
可以异步执行,这样的设计使得我们设计中的order:user成为累赘了,因为userId本身就是orderId,根本就不用这个查找
在官方实现里是怎么查询所有订单的呢?因为订单都是以o_
开头的,所以用redis的命令KEYS o_*
就可以获取所有以o_
开头的key,那么所有订单也自然就拿到了,这里需要(1+n)次(1次查询所有订单名字+根据返回的名字查询n个订单的详细内容)redis访问,但查询订单不在benchmark里,所以实现得耗时一些没有影响
官方用了go-reuseport这个库,即多进程端口复用,据说比单进程端口要性能好一些
官方把go的GC关掉了,据说效果不大,在我们的profile结果里GC也只占了很小一部分
结论就是,虽然官方实现有几点值得吐槽的地方(比如判定作弊的标准不太明确,导致可能有些选手即使想到了优化方法,然后觉得这个实际中不能用,就放弃了),为了性能牺牲掉了很多实用性(比如官方Go的实现没有可拓展性,假如来了一个需求说一个用户可以下多个订单,就要大改了),毕竟是个性能比赛。但确实有很多地方值得学习,根据这个特定的场景,redis数据库设计得非常简洁,大大减少了I/O次数(瓶颈),而且用了许多redis技巧和Go技巧,特别是发出异步redis请求的goroutine和channel的使用,对于初学Go的我帮助很大。
]]>所以有了这篇文章,记录下自己的一些改进,以及尽可能说清楚如何用C++实现一个高性能爬虫。
在继续往下看之前一定要先想清楚一个问题,现在用Python或者NodeJS可以非常快速地开发出一个爬虫,库齐全,开发成本非常低,那为什么还要用C来写爬虫?答案是这要看你的目的。如果你单纯是为了完成一个数据抓取的任务,当然是任务完成得越快越好,以后代码越好修改越好,首选就是那些库齐全的动态语言,但如果你的目的是为了理解底层系统,理解抓取数据的每一个环节,那么我的推荐是用C++写吧,并且所有轮子都自己造。我的目的是后者,所以选择了用C来写。既然所有轮子都自己造了,那这篇文章应该叫,如何不用任何第三方库,只用C/C++内建函数来完成一个网络爬虫。
用Python写会是什么样子?有Requests库来封装HTTP请求,有BeautifulSoup来解析HTML,大大减少了开发难度,你只需要知道爬虫的一般流程,很容易写出一个能跑的代码,用NodeJS也是一样的。
如果有读者不太清楚爬虫的原理,请先看一下这篇入门文章。
接下来简单说一个我的zhihu爬虫的原理,因为我的目标是爬下最高赞/最高关注这些类型的答案和问题,所以从用户主页出发是最好不过的,比如从用户主页点击“回答”,就可以看到用户的所有回答,然后抓下来,点击“提问”,就可以看到用户所有的提问。把所有用户的所有回答/提问都抓下来然后根据点赞数/关注数排序,就是我想要的结果。那所有用户怎么得到?从一个用户出发(即队列中的初始URL),把TA的所有关注的人和关注者都爬下来,不重复地放入URL队列中,等到当前用户处理完,再从URL队列里拿下一个用户,如此循环即可。
仔细想想,这个方法会有一个问题,如果一个人即不关注别人也不被别人关注,且不在初始URL队列中,那么这个用户的回答和提问永远不会被抓到。更一般的结论是,如果有用户群构成“孤岛”,那么这些用户群都不会被爬虫访问。举个例子,A、B互相关注,C、D互相关注,如果我们将A放入初始URL队列,那么爬虫只可能抓下A、B的数据,因为C、D构成了“孤岛”,怎么解决这个问题?
再想想,这个问题真的有必要解决吗?这个问题会对我们造成困扰的情况是,一个大V答了一个赞数很高的问题,但是TA竟然在某座“孤岛”上,如果我们称大部分人所构成的连通图叫主图,那么这个大V构成的“孤岛”和主图上的人一点关系都没有,即不被关注也不关注别人,这几乎是不可能的事情,所以这个问题不需要解决。
无论用Python或者C++写爬虫,底层都是一样的,都是和server建立若干个TCP连接,然后把HTTP请求写入这个TCP socket中,等待server的数据返回。为了高效处理I/O,在linux平台下需要用epoll(别的平台请用各自的机制)。
所以一个C++爬虫步骤大概是这样的,本质上就是一个事件循环(event loop):
初始化epoll,并和server建立TCP连接
从URL队列中拿出url,并准备好http请求
将http请求写入到这个TCP socket中,并把这个socket加入epoll中
检查活动事件(epoll_wait)
处理事件,读取HTML,解析HTML,处理HTML,然后把相关未处理过的URL放入URL队列中
回到第2步
先简单描述一下去年写的爬虫代码是怎么误人子弟的。
程序从队列里拿到一个URL后,需要去下载这个URL的页面,解析出我需要的数据,然后把它的下一层URL加入队列中。原来的爬虫代码就老老实实地实现了这个步骤,阻塞地等待页面下载完成,再去处理这个页面。其实这是很低效的,因为阻塞的这段时期我们什么都干不了,浪费了带宽。为什么不把队列里的其它URL请求一起发出去呢,然后有数据来了我就处理。这就是为什么爬虫为什么要用基于事件来写的原因。
这里需要理解爬虫这种程序的本质,它是网络I/O密集程序,不是CPU密集,而处理I/O密集最高效的做法就是事件循环。
所以我做的一个做大的改善就是把原来的阻塞爬虫改成了基于事件的爬虫,它得到的好处是可以完全把带宽跑满,爬取速度最大化。
除此之外,还有一个改善是把多线程模型改成了单进程模型。有同学可能会产生疑惑,难道利用多核还会比单核性能差?我们从以下两点来分析:
根据amdahl定律,对系统中一个模块的加速,不仅取决于加速比,还取决于这个模块在原来系统中占的比例。爬虫是I/O密集程序,绝大部分时间都花在了网络I/O上,CPU大部分时间是空闲的,所以提高CPU的利用率其实效果很小。
多线程会引入额外的开销,最大的开销可能就是锁了。比如你要把新的URL加入队列,这时候在多线程环境下肯定要对队列加锁。
那么问题就是,第一点所带来的性能提升和第二点所带来的开销,哪个更大一点?如果第二点大,我们果断要换成单进程。答案是看环境,我们极端点看,如果你的带宽无穷大,网络情况无穷好,那么请求一发出去立刻就回复了,这个网络I/O密集程序硬生生变成了CPU密集,多线程会好;如果你的带宽无穷小,那么锁带来的开销会占比更大,一个任务来了多线程之间还要竞争一下,单线程就直接处理了且没有锁的性能开销,用单线程会好。我们需要在不同的环境下选择最好的办法,不过一般来说,现实中最大的时间开销一定在网络I/O。
从TCP socket读取数据到把完整的HTML数据交付上层需要一个数据层,因为如果调用read返回EAGAIN时,这时是不知道到底有没有接受到完整的HTML,需要保存好当前读到的网页内容,并通过一个状态机来解析当前收到的数据,保存当前的状态,如果解析完成(读到全部数据了)就返回SUCCESS,否则就就返回ERROR,等待下一次数据来临,继续解析状态机。用动态语言不需要考虑这一点,会直接传递给用户层完整的数据。
请求得太快,知乎会返回429错误(即提示客户请求太多,稍后再试),这个问题怎么解决?乖乖地等待一段时间再去抓是一种浪费带宽的行为。服务器判断请求太多是看这个IP在一段时间的请求数太多了,如果我们IP分散为N个不同IP,那就解决这个问题了。这个方案叫动态IP或者代理IP。那么多IP意味着要花很多的钱,如果不愿意花钱,还是乖乖等一段时间再发请求吧。
爬虫里一个需求,要获得一个用户的所有关注的人和关注者,但这些东西都是通过ajax获取的,所以要写一个post请求来模拟ajax。其中post data里有一个hash_id和_xsrf,这两个值都在哪里可以获得?后来在该用户的主页的HTML里找到了这两个值。
怎么用C++解析HTML?比如上面一点提到的,我要找到这个页面里的hash_id,它可能是某个HTML元素的属性,怎么得到这个属性值?用过JQuery的同学这时会想,如果C++里面也有一个像JQuery那么好用的库该多好,直接写个选择器就获得属性的值了。我简单地调研了一下,C++还是有这样的库的。基于学习的目的,最好自己写一个这样的库,所以,问题来了,怎么实现一个HTML parser?或者更简单的,怎么实现一个正则匹配?
如何管理一个请求的周期,因为一个请求的周期中,状态太多了。为什么状态多,因为一个请求会涉及很多异步操作,首先获取该用户的答案页面,这时候要等待server的回复,处理完以后获得改用户所有关注的人和关注者的页面,也要等待server的回复,再把这些所有用户加入队列后,这个请求周期才算结束。
需要自己处理一些HTTP header的细节。比如不希望接受到HTTP response header里Transfer Encoding: chuncked回复,因为它显然没有Content-length直接获取到数据长度来得方便,该怎么办?再比如不希望接受到gzip处理过的数据,希望收到plain text,又该怎么办?
架构怎么设计。首先最底层是TCP层,上层应该封装一个数据接收层,再上层应该是HTML解析层,最后是事件循环层。这些层次/模块怎么做到耦合度最低?
网络异常怎么处理,比如read返回error(eg Connection reset by peer),或者EOF。EOF需要重新建立一个新的连接,然后继续前一个请求(或者说继续状态机)。
系统的掌控性。比如我们希望TCP连接数要控制在1000,完全可能很容易地实现。并且可以知道哪里会耗内存、CPU,底层在发生什么我们更容易知道。比如,在HTTP request header里写上Connection: keep-alive
可以让很多请求复用一个TCP连接,在用C++实现的时候,对应的实现方法很简单粗暴:从socket读完对方服务器发来的response后,再构造一个header发过去即可。
因为一些内建库的缺乏,并且出发点是学习,我们会重新造一些轮子,与此同时,提高了编程能力。 比如说读配置文件,格式是json,可以自己用C写个json parser。再比如上文提到的HTML parser,也可以用C写一个,还有基于epoll的事件循环,可以抽象成一个通用的网络库。有太多轮子可以造,要把其中任意一个轮子写好都是非常难的事情。
高性能。可能由于网络的大延迟使得这个优点不那么明显。
本文基于我一年多之前写的zhihu爬虫以及最近的大规模改进,总结了如何用C++编写的高效、基于事件驱动的知乎爬虫,同时也列出了用C++写爬虫时的一些难点与收获。
如果你有兴趣看看竟然有人用C++写了一个爬虫究竟是什么样子的,代码在这里。
]]>几乎每个人每天都要或多或少和Web服务器打交道,比较著名的Web Server有Apache Httpd、Nginx、IIS。这些软件跑在成千上万台机器上为我们提供稳定的服务,当你打开浏览器输入网址,Web服务器就会把信息传给浏览器然后呈现在用户面前。那既然有那么多现成的、成熟稳定的web服务器,为什么还要重新造轮子,我认为理由有如下几点:
夯实基础。一个优秀的开发者必须有扎实的基础,造轮子是一个很好的途径。学编译器?边看教材变写一个。学操作系统?写一个原型出来。编程这个领域只有自己动手实现了才敢说真正会了。现在正在学网络编程,所以就打算写一个Server。
实现新功能。成熟的软件可能为了适应大众的需求导致不会太考虑你一个人的特殊需求,于是只能自己动手实现这个特殊需求。关于这一点Nginx做得相当得好了,它提供了让用户自定义的模块来定制自己需要的功能。
帮助初学者容易地掌握成熟软件的架构。比如Nginx,虽然代码写得很漂亮,但是想完全看懂它的架构,以及它自定义的一些数据结构,得查相当多的资料和参考书籍,而这些架构和数据结构是为了提高软件的可伸缩性和效率所设计的,无关高并发server的本质部分,初学者会迷糊。而Zaver用最少的代码展示了一个高并发server应有的样子,虽然没有Nginx性能高,得到的好处是没有Nginx那么复杂,server架构完全展露在用户面前。
学网络编程,第一个例子可能会是Tcp echo服务器。 大概思路是server会listen在某个端口,调用accept等待客户的connect,等客户连接上时会返回一个fd(file descriptor),从fd里read,之后write同样的数据到这个fd,然后重新accept,在网上找到一个非常好的代码实现,核心代码是这样的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
|
完整实现在这里。如果你还不太懂这个程序,可以把它下载到本地编译运行一下,用telnet测试,你会发现在telnet里输入什么,马上就会显示什么。如果你之前还没有接触过网络编程,可能会突然领悟到,这和浏览器访问某个网址然后信息显示在屏幕上,整个原理是一模一样的! 学会了这个echo服务器是如何工作的以后,在此基础上拓展成一个web server非常简单,因为HTTP是建立在TCP之上的,无非多一些协议的解析。client在建立TCP连接之后发的是HTTP协议头和(可选的)数据,server接受到数据后先解析HTTP协议头,根据协议头里的信息发回相应的数据,浏览器把信息展现给用户,一次请求就完成了。
这个方法是一些书籍教网络编程的标准例程,比如《深入理解计算机系统》(CSAPP)在讲网络编程的时候就用这个思路实现了一个最简单的server,代码结构和上面的echo服务器代码类似,完整实现在这里,非常短,值得一读,特别是这个server即实现了静态内容又实现了动态内容,虽然效率不高,但已达到教学的目的。之后这本书用事件驱动优化了这个server,关于事件驱动会在后面讲。
虽然这个程序能正常工作,但它完全不能投入到工业使用,原因是server在处理一个客户请求的时候无法接受别的客户,也就是说,这个程序无法同时满足两个想得到echo服务的用户,这是无法容忍的,试想一下你在用微信,然后告诉你有人在用,你必须等那个人走了以后才能用。
然后一个改进的解决方案被提出来了:accept以后fork,父进程继续accept,子进程来处理这个fd。这也是一些教材上的标准示例,代码大概长这样:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
|
完整代码在这里。表面上,这个程序解决了前面只能处理单客户的问题,但基于以下几点主要原因,还是无法投入工业的高并发使用。
进程调度器压力太大。当并发量上来了,系统里有成千上万进程,相当多的时间将花在决定哪个进程是下一个运行进程以及上下文切换,开销非常大,具体的分析请看《A Design Framework for Highly Concurrent Systems》。
在heavy load下多个进程消耗太多的内存,在进程下,每一个连接都对应一个独立的地址空间;即使在线程下,每一个连接也会占用独立。此外父子进程之间需要发生IPC,高并发下IPC带来的overhead不可忽略。
换用线程虽然能解决fork开销的问题,但是调度器和内存的问题还是无法解决。所以进程和线程在本质上是一样的,被称为process-per-connection model。因为无法处理高并发而不被业界使用。
一个非常显而易见的改进是用线程池,线程数量固定,就没上面提到的问题了。基本架构是有一个loop用来accept连接,之后把这个连接分配给线程池中的某个线程,处理完了以后这个线程又可以处理别的连接。看起来是个非常好的方案,但在实际情况中,很多连接都是长连接(在一个TCP连接上进行多次通信),一个线程在收到任务以后,处理完第一批来的数据,此时会再次调用read,天知道对方什么时候发来新的数据,于是这个线程就被这个read给阻塞住了(因为默认情况下fd是blocking的,即如果这个fd上没有数据,调用read会阻塞住进程),什么都不能干,假设有n个线程,第(n+1)个长连接来了,还是无法处理。
怎么办?我们发现问题是出在read阻塞住了线程,所以解决方案是把blocking I/O换成non-blocking I/O,这时候read的做法是如果有数据则返回数据,如果没有可读数据就返回-1并把errno设置为EAGAIN,表明下次有数据了我再来继续读(man 2 read)。
这里有个问题,进程怎么知道这个fd什么时候来数据又可以读了?这里要引出一个关键的概念,事件驱动/事件循环。
如果有这么一个函数,在某个fd可以读的时候告诉我,而不是反复地去调用read,上面的问题不就解决了?这种方式叫做事件驱动,在linux下可以用select/poll/epoll这些I/O复用的函数来实现(man 7 epoll),因为要不断知道哪些fd是可读的,所以要把这个函数放到一个loop里,这个就叫事件循环(event loop)。示例代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
在这个while里,调用epoll_wait
会将进程阻塞住,直到在epoll里的fd发生了当时注册的事件。
这里有个非常好的例子来展示epoll是怎么用的。
需要注明的是,select/poll不具备伸缩性,复杂度是O(n),而epoll的复杂度是O(1),在Linux下工业程序都是用epoll(其它平台有各自的API,比如在Freebsd/MacOS下用kqueue)来通知进程哪些fd发生了事件,至于为什么epoll比前两者效率高,请参考这里。
事件驱动是实现高性能服务器的关键,像Nginx,lighttpd,Tornado,NodeJs都是基于事件驱动实现的。
结合上面的讨论,我们得出了一个事件循环+ non-blocking I/O + 线程池的解决方案,这也是Zaver的主题架构(同步的事件循环+non-blocking I/O又被称为Reactor模型)。 事件循环用作事件通知,如果listenfd上可读,则调用accept,把新建的fd加入epoll中;是普通的连接fd,将其加入到一个生产者-消费者队列里面,等工作线程来拿。 线程池用来做计算,从一个生产者-消费者队列里拿一个fd作为计算输入,直到读到EAGAIN为止,保存现在的处理状态(状态机),等待事件循环对这个fd读写事件的下一次通知。
Zaver的运行架构在上文介绍完毕,下面将总结一下我在开发时遇到的一些困难以及一些解决方案。 把开发中遇到的困难记录下来是个非常好的习惯,如果遇到问题查google找到个解决方案直接照搬过去,不做任何记录,也没有思考,那么下次你遇到同样的问题,还是会重复一遍搜索的过程。有时我们要做代码的创造者,不是代码的“搬运工”。做记录定期回顾遇到的问题会使自己成长更快。
答:这里涉及到了epoll的两种工作模式,一种叫边缘触发(Edge Triggered),另一种叫水平触发(Level Triggered)。ET和LT的命名是非常形象的,ET是表示在状态改变时才通知(eg,在边缘上从低电平到高电平),而LT表示在这个状态才通知(eg,只要处于低电平就通知),对应的,在epoll里,ET表示只要有新数据了就通知(状态的改变)和“只要有新数据”就一直会通知。
举个具体的例子:如果某fd上有2kb的数据,应用程序只读了1kb,ET就不会在下一次epoll_wait的时候返回,读完以后又有新数据才返回。而LT每次都会返回这个fd,只要这个fd有数据可读。所以在Zaver里我们需要用epoll的ET,用法的模式是固定的,把fd设为nonblocking,如果返回某fd可读,循环read直到EAGAIN(如果read返回0,则远端关闭了连接)。
答:用EPOLLONESHOT解决这个问题。在fd返回可读后,需要显式地设置一下才能让epoll重新返回这个fd。
答:对于1),此时该fd在事件循环里会返回一个可读事件,然后就被分配给了某个线程,该线程read会返回0,代表对方已关闭这个fd,于是server端也调用close即可。对于2),协议栈无法感知,SO_KEEPALIVE超时时间太长不适用,所以只能通过应用层timer超时事件解决。
答:Nginx把timer实现成了rbtree,这就很奇怪,timer模块需要频繁找最小的key(最早超时的事件)然后处理后删除,这个场景下难道不是最小化堆是最好的数据结构么?然后通过搜索得知阿里的Tengine将timer的实现了4-heap(四叉最小堆)。四叉堆是二叉堆的变种,比二叉堆有更浅的深度和更好的CPU Cache命中率。Tengine团队声称用最小堆性能提升比较明显。在Zaver中为了简化实现,使用了二叉堆来实现timer的功能。
GET /index.html HTT
就结束了,在blocking I/O里只要继续read就可以,但在nonblocking I/O,我们必须维护这个状态,下一次必须读到'P',否则HTTP协议解析错误。答:解决方案是维护一个状态机,在解析Request Header的时候对应一个状态机,解析Header Body的时候也维护一个状态机,Zaver状态机的时候参考了Nginx在解析header时的实现,我做了一些精简和设计上的改动。
答:HTTP header有很多,必然有很多个解析函数,比如解析If-modified-since
头和解析Connection
头是分别调用两个不同的函数,所以这里的设计必须是一种模块化的、易拓展的设计,可以使开发者很容易地修改和定义针对不同header的解析。Zaver的实现方式参考了Nginx的做法,定义了一个struct数组,其中每一个struct存的是key,和对应的函数指针hock,如果解析到的headerKey == key,就调hock。定义代码如下
1 2 3 4 5 6 7 |
|
答:Zaver将所有header用链表连接了起来,链表的实现参考了Linux内核的双链表实现(list_head),它提供了一种通用的双链表数据结构,代码非常值得一读,我做了简化和改动,代码在这里。
答:压力测试为了测量网站对高并发的承受程度,在哪个并发度会使网站挂掉。这个有很多成熟的方案了,比如http_load, webbench, ab等等。我最终选择了webbench,理由是简单,用fork来模拟client,代码只有几百行,出问题可以马上根据webbench源码定位到底是哪个操作使Server挂了,这就说到了我在做压力测试时遇到一个问题,然后看了一下Webbench的源码,就很快找到了问题所在(并且非常推荐C初学者看一看它的源码,只有几百行,但是涉及了命令行参数解析、fork子进程、父子进程用pipe通信、信号handler的注册、构建HTTP协议头的技巧等一些编程上的技巧)。
之前提到的那个问题是:用Webbech测试,Server在测试结束时挂了。
百思不得其解,不管时间跑多久,并发量开多少,都是在最后webbench结束的时刻,server挂了,所以我猜想肯定是这一刻发生了什么“事情”。 开始调试定位错误代码,我用的是打log的方式,后面的事实证明在这里这不是很好的方法,在多线程环境下要通过看log的方式定位错误是一件比较困难的事。最后log输出把错误定位在向socket里write对方请求的文件,也就是系统调用挂了,write挂了难道不是返回-1的吗?于是唯一的解释就是进程接受到了某signal,这个signal使进程挂了。于是用strace重新进行测试,在strace的输出log里发现了问题,系统在write的时候接受到了SIGPIPE,默认的signal handler是终止进程。SIGPIPE产生的原因为,对方已经关闭了这个socket,但进程还往里面写。所以我猜想webbench在测试时间到了之后不会等待server数据的返回直接close掉所有的socket。抱着这样的怀疑去看webbench的源码,果然是这样的,webbench设置了一个定时器,在正常测试时间会读取server返回的数据,并正常close;而当测试时间一到就直接close掉所有socket,不会读server返回的数据,这就导致了zaver往一个已被对方关闭的socket里写数据,系统发送了SIGPIPE。
解决方案也非常简单,把SIGPIPE的信号handler设置为SIG_IGN,忽略该信号即可。
目前Zaver还有很多改进的地方,比如:
本文介绍了Zaver,一个结构简单,支持高并发的http服务器。 基本架构是事件循环 + non-blocking I/O + 线程池。 Zaver的代码风格参考了Nginx的风格,所以在可读性上非常高。另外,Zaver提供了配置文件和命令行参数解析,以及完善的Makefile和源代码结构,也可以帮助任何一个C初学者入门一个项目是怎么构建的。
[1] https://github.com/zyearn/zaver
[2] http://nginx.org/en/
[3] 《linux多线程服务端编程》
[4] http://www.martinbroadhurst.com/server-examples.html
[5] http://berb.github.io/diploma-thesis/original/index.html
[6] rfc2616
[7] https://banu.com/blog/2/how-to-use-epoll-a-complete-example-in-c/
[8] Unix Network Programming, Volume 1: The Sockets Networking API (3rd Edition)
1
|
|
(其中9的意思是SIGKILL,完整的linux信号请看这里)之后你再用ps查看进程的时候,会发现那个进程已经被杀掉了。
本文将说明在LINUX系统下,用户在终端输入kill -9 <PID>
之后,整个系统到底发生了什么,我们将深入到内核代码。一开始我在想这个问题的时候遇到了一些问题,比如进程是怎么知道自己收到信号的?在执行进程工作代码的同时还要不断轮询有没有新到的信号吗?代价也太大了吧?那是不是基于什么异步通知的方案呢?在说明LINUX是怎么做的之前,先解释一点基础的概念。
我自己的理解:信号之于进程,就好比中断之于CPU,是一种信息传递的方式。官方的解释是A signal is an asynchronous notification sent to a process or to a specific thread within the same process in order to notify it of an event that occurred.
一个程序在运行的时候,你可以发各种信号给这个进程,进程对这个信号做出响应。比如你发个SIGKILL给一个进程,该进程就知道用户要杀死它,然后就会终止进程。
一个更常见的例子,你在终端运行一个进程以后,如果是非后台进程,它会在console输出一些log,这时候shell也不能接受输入了,这时候你按下control+c
,进程就被终止了,在这个过程中你就给这个进程发送了一个信号(SIGINT,interrupt signal),在默认情况下,是终止改进程。
那什么时候是非默认情况呢?这里需要引入信号处理器(signal handler)的概念,你可以为一部分信号编写特定的处理函数,比如在默认情况下,SIGINT是结束进程,你可以修改这个默认行为使它什么都不做(即一个空函数),但是有些信号的行为是无法修改的,比如SIGKILL。
在LINUX下有一个kill
的命令,第一次用的同学会以为这是一个“杀死”某个进程的命令,其实并不是很准确。这个命令的作用就是给指定PID的进程发送信号,到底发送什么信号也是由参数指定的,如果不指定信号,默认是发送SIGTERM,它的默认行为是终止进程。其实kill
也是个程序,它内部会调用system call的kill来发起真正信号传递过程。
更详细的介绍请man 2 kill
当你敲下命令,按下回车,程序就执行了,其实这里也是个很复杂的过程。涉及到了shell的运行原理,每一个shell的实现都不一样,但核心原理是不变的:fork
一个子进程,再调用execve
那一系列系统调用。想了解一个shell是怎么写的,我觉得最好的资料是《Unix/Linux编程实践教程》第八章。本文不会详细解释shell/fork/execve
,我会在另一篇博客里详细解释当你执行fork
时,系统发生了什么。
好了,基础知识差不多介绍完了,下面我们进入下一阶段。
我们先讲原理再深入实现细节。所有内核代码都基于3.16.3,本文出现的所有内核代码是我删除了一些错误处理,加锁,临界判断后的结果,所以是比较核心的代码。
执行kill -9 <PID>
,进程是怎么知道自己被发送了一个信号的?首先要产生信号,执行kill程序需要一个pid,根据这个pid找到这个进程的task_struct(这个是Linux下表示进程/线程的结构),然后在这个结构体的特定的成员变量里记下这个信号。
这时候信号产生了但还没有被特定的进程处理,叫做Pending signal。
等到下一次CPU调度到这个进程的时候,内核会保证先执行do\_signal
这个函数看看有没有需要被处理的信号,若有,则处理;若没有,那么就直接继续执行该进程。所以我们看到,在Linux下,信号并不像中断那样有异步行为,而是每次调度到这个进程都是检查一下有没有未处理的信号。
当然信号的产生不仅仅在终端kill的时候才产生的。总结起来,大概有如下三种产生方式:
kill -9 <PID>
,或者control+c
就是这种类型大概原理就是这个样子的,接下来我们来看一看内核的实现。
首先,你在shell里输入kill
这个命令,它本身就是个程序,是有源代码的,它的代码可以在Linux的coreutils里找到。代码很长,我就不全复制过来了,有兴趣的可以去仔细看看。它的核心代码是长这样的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
我们看到最后调用了系统调用kill
,其代码在Linux内核linux-3.16.3/kernel/signal.c
中实现。在看kill源码之前,先把这个函数最终要操作的结构体看一下,这个struct很长,只列出了信号相关的部分:
1 2 3 4 5 6 7 8 9 10 11 |
|
继续看kill系统调用,我将核心代码列在了下面,想看完整版的点这里。为了方便理解,我给核心逻辑增加了注释。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
|
因为这个kill_something_info
函数会根据pid的正负来决定是发给特定的进程还是一个进程组,我们下面主要来看发给一个特定进程的情况,即调用kill_pid_info
:
1 2 3 4 5 6 7 8 9 10 11 12 |
|
注意这个函数,出现了我们上文提到的task_strcut
,这个是Linux下表示每个进程/线程的结构体,根据struct pid
找到这个结构后,就调用了group_send_sig_info
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 |
|
可以看到,最终调用到__send_signal
,设置信号的数据结构,wake up需要处理信号的进程,整个信号传递的过程就结束了。这时候信号还没有被进程处理,还是一个pending signal。
内核调度到该进程时,会调用do_notify_resume
来处理信号队列中的信号,之后这个函数又会调用do_signal
,再调用handle_signal
,具体过程就不用代码说明了,最后会找到每一个信号的处理函数,问题是这个怎么找到?
还记得在上文提到的task_struct吗,里面有一个成员变量sighand_struct
就是用来存储每个信号的处理函数的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
|
其中sa_handler
就指向了信号的处理程序。
Linux提供了修改信号的处理函数的system call,具体如何使用这些system call不是本文的重点,如果你有兴趣可以参考《Computer System: A programmer’s perspective》8.5节或者参考资料[6],里面提供了非常详细的例子。
这篇文章基于Linux 3.16.3讲述了从shell敲下kill -9 <PID>
后整个系统发生了什么。主要涉及从用户态的shell程序开始,执行coreutils中kill,之后陷入到内核代码,分析了相关的数据结构,信号产生和传递的原理以及核心代码。
[1] http://en.wikipedia.org/wiki/Unix_signal
[2] http://stackoverflow.com/questions/1860175/how-does-a-process-come-to-know-that-it-has-received-a-signal
[3] http://www.linuxjournal.com/article/3985
[4] http://blog.csdn.net/walkingman321/article/details/6167435
[5] http://blog.csdn.net/morphad/article/details/9236975
[6] http://www.alexonlinux.com/signal-handling-in-linux
当年Javascript被Brendan Eich设计的目标之一就是简单,它只需要能在浏览器上做一些简单的判断和交互即可。如果引入了类,那这门语言就太复杂了。但还是需要一种方法来使对象和对象之间可以串起来,于是就诞生了原型链。那原型链到底是什么意思呢,简单来说,假设我想让B继承A,那么在Javascript中只需要设置B的原型为A即可,A自己也有它的原型,那么就构成了一个“原型链”。
更准确一点,我们来看一些代码。当时C++和Java都用new来新建一个类的instance,会调一个所谓的“构造函数”,Brendan Eich也沿袭了这个思想。因为没有类,new后面应该加什么名字呢,他决定加构造函数。
1 2 3 |
|
上面的代码定义了一个构造函数,它同时也是一个object(Javascript中所有东西都是object)。new一下这个构造函数会产生一个Chinese实例:
1 2 |
|
现在新问题来了,我们要为Chinese类加一个皮肤颜色的方法,好像这样就可以办到:
1 2 3 4 |
|
这样的结果就是所有的Chinese实例都会拥有一份skin的实例:
1 2 3 4 5 6 7 8 |
|
所以这种方式有一个缺点,Chinese类无法共享一个共有的属性和方法。我们希望有一个类似于“基类”的东西,所有中国人都可以共享这个基类对象。为了解决这个问题,Brendan Eich决定为构造函数设置一个prototype的属性(它是一个object),把所有需要让实例共享的属性和方法都放到prototype这个object里头去,这个prototype叫做实例的原型。实例对象一旦创建,将自动那个引用到构造函数的prototype对象。也就是说,一个实例的属性和方法有两种,一种是本地的,一种是原型的。还是上面的例子,然后把skin放到Chinese.prototype当中去:
1 2 3 4 |
|
之后访问实例的skin对象,javascript会先在本地属性中查找,若没有,则取原型中找。在这个例子中,在原型(也就是Chinese.prototype)中,找到了skin方法,所以如果想要修改所有Chinese实例的skin属性就非常容易了(好吧,就想象一下突然基因突变…),只要修改Chinese.prototype即可:
1 2 3 4 5 6 7 8 |
|
写到这里,有一个很自然的问题,实例能不能随意修改它的原型对象?答案是能也不能。假如typeof(原型属性)不是object(比如数字,字符串),那么不可修改;否则(比如[], {}),则能修改。
在Javascript中,是用原型来实现所谓的“继承”,一旦你理解了原型链,那么就几乎理解了Javascript中的继承。
理解了原型链,就可以讲javascript中的继承机制了。关于这两个主题,在搜索资料的时候发现阮一峰在他的两篇文章里写得已经非常清楚了: 构造函数的继承和 非构造函数的继承
[1] JavaScript语言精粹
[2] http://www.ruanyifeng.com/
[3] http://blog.vjeux.com/2011/javascript/how-prototypal-inheritance-really-works.html
举个数据库的例子。
学数据库理论都学过外键,简单来说就是一张表一行的某一项数据依赖于另一张表一项的数据。比如说用户信息有一列叫居住地,而居住地又存在另外一张表里,这时候,用户表的居住地就是个外键,指向居住地表的某一行。
那这个有什么用呢?外键提供了不同等级的约束。比如有一个场景,应用层想删除一个地方,但是如果这个地方有人住的话,就要删除失败,否则这个人就没地方住了。如果没有外键,那么解决办法是先select一下用户表,把所有人的居住地找出来,比对一下将要删除的居住地,发现有了,就删除失败,否则就跑去居住地表删数据。这里涉及了两次DB的读取,非常麻烦和不清晰,给应用层加了很多困难。理论上,应用层应该不管这些东西,直接delete,成功了就返回success,失败就返回一个error_code。
外键就可以完美解决这个问题。外键的RESTRICT约束保证了假如有用户表有一行的某个数据指向了居住表的某一行,那这一行就无法被删除。除了RESTRICT约束,还有别的可以选择,比如CASCADE约束,它的意思是如果被引用的数据删了,那么引用它的数据也同样被删掉。在我们的例子里,如果某个居住地被删了,那么住在这个地方的人也都被删了。选择哪种约束完全看场景需求了。
但外键也不是处处能用的,比如在互联网场景下,用户大并发高,外键的存在可能使数据库成为瓶颈。
关于更多外键的实战应用,也可以参考我的小伙伴专门写了一篇文章。
除了外键,还有个例子。如果一张表的非key列不能重复,可以在应用层用很dirty的方法解决,比如先读一次,如果要插入的数据已经在了,返回错误;否则插入。在DB层可以很好地解决,只要加个unique key就可以了。
一上手写node,不用第三方库,只要逻辑逻辑稍微复杂一点,就会出现回调里再回调,之后再回调…代码里有好几层回调。在这个项目中我们首页的展示依赖于很多块数据,而这些数据是不同回调函数的结果,这时候要等全部的数据来了再渲染首页。还有个场景,有n个回调函数,第i-1个回调的输出是第i个回调的输入。在上面两个情况下如果不用第三方库都会给代码编写调试和以后的维护带来极大的困难。
还好,很多node的第三方库解决了这个问题。比如主流的async,then.js,Promise,async用起来最简单,只是在callback上加了语法糖。then.js的链式API更流畅。出于易用性,我们选择了async,用法简单,刚好能解决我们的大部分需求。
因为这个项目是全程由我和我的小伙伴做的,所以我们要涉及很多除了开发以外的工作,与人的沟通。(上次Bjarne Stroustrup来学校做讲座的时候,他说的一句话让我记忆深刻,他说,在大型软件开发中,最困难的不是技术问题,而是people problem)导致最后20%的工作已经不仅仅是开发工作了,和客户的对齐,再修改等等会花掉大量的时间。所以不要天真地以后代码打完了就结束了,还有大量的非开发工作等着。
]]>把问题再说得清楚一些:NAT后面有一台设备,运行着linux,restful server跑在这台设备上,我们需要的做的是使它向外界暴露一个特定的ip和port,外网直接可以对这个ip和port进行请求(我们用curl)。问题大概是下面这个图的样子:
为了解决这个问题,大致有三种思路:
关于NAT打洞的细节请参考之前写的这篇博文。思路貌似很简单,在路由器转发表上打个洞,然后所有对这个port的请求都会转发到NAT后的server上。但这是个不能用的方案。
为什么不能用?考虑symmetric NAT的情况,这是四种NAT类型中最严格的一种,只要解决了这一种,那么NAT打洞就可以用。symmetric NAT的问题在于打完洞了以后这个洞两端的ip和port这四个值需要全部确定,restful server没问题,局域网ip和80端口都不变,但外网无法保证每次都用一个port,比如说curl命令,你无法保证curl每次都用相同的port来发请求。因为这个致命问题所以这个方法不能用。
这个方法网上资料很多。总结来说,需要一台公网服务器来做relay,比如ip是120.120.120.120。在内网机器上运行
1
|
|
12345是120.120.120.120的端口,然后访问120.120.120.120上的12345端口,都被重定向到本机的80端口。
但是这个方法不太稳定,连接一直会断,有一些工具来保证稳定性,比如autossh。
因为我们对ssh反向隧道内部详细机制和代码不了解,并且稳定性等等方面考虑的原因,打算自己写了一个类似的工具。其实道理很简单,比如公司需要开发一个网络程序,如果对libevent内部机制和代码不熟悉,谁敢用?出了事谁负责?所以现在很多公司都有自己的网络通信库。开源工具的特点是要满足大众,导致了每个功能可能都很平庸,而如果能自己写工具则能针对公司的业务特点来有所侧重。
ssh反向隧道的本质是长连接+请求转发,所以我们也要实现个类似的东西。需要多加两个模块:
client和relayServer之间通过长连接保持连接。relayServer暴露一个publicip和port,外界通过这个publicip和port请求relayServer。relayServer接受到请求之后,把请求通过长连接转发给NAT后面的client,client收到请求以后再访问同一网段的restful server。这样,我们对publicip:port请求,就等于对NAT内的restful server请求。
架构变为下图所示:
因为开发进度和难度等因素的考虑,我们选择了nodejs(关于node的优点和介绍在这篇文章里)来实现这个工具。最好的当然是C,效率上最高,但需要自己写网络库或者用第三方的网络库,而且不能现场调试,所以还是放弃了。
如何维持长连接?
虽然node有内置的net库,但不能保证high availability。所以要选择一个第三方已经包装好的连接库,能断线重连和错误处理的。我们选择了socket.io来实现这个长连接,好像有点大材小用的感觉。
最后这个代码放在了github上,这个代码对NAT后的所有server适用,不一定要是restful server,只要是在内网的server,有ip和port进行访问,都可以用。
]]>曾经在搭博客的时候,使用的是disqus作为评论插件,使用了一阵后发现它有两个缺点,首先,它的加载速度非常慢,其次,很多人都没有disqus账号导致要注册才能评论。于是想找个本土化的评论系统,满足我的加载速度要求和可以通过社交平台账号登陆然后评论的要求。
调查下来发现duoshuo这款评论插件比较符合,速度快,社交网站登录,还提供邮件提醒功能,就决定用它了!
问题很快就出现了,多说只有在一个用户第一次评论完,另外一个用户回复第一个用户的时候,才会有邮件通知说“xxx回复了你”,也就是说如果不点“回复某人”的按钮,而直接留言,多说是不会邮件提示你有留言了。不知道多说为什么不解决这个问题,流量太大不好解决?反正让人很不舒服。
那就让我们来解决这个问题。调查了下,大概有下面两种思路:
多说开放了获取某一篇文章评论的API,但这个API有个限制就是这篇文章一定要有个unique key才能获取。而这个unique key本身就是可有可无的,我现在所有的文章都没有这个key。如果要用API的话,要给所有的文章加上这个key,还得保证以后所有的文章都要自动生成这个key,当然手动也可以,就是太麻烦。
更麻烦的是,对于每篇新的文章,我都要在调API的地方注册这个新的key,之后才会获取到这篇文章的评论。太麻烦了,放弃这种方法。
登录多说的admin页面,会发现所有的评论都列在页面上,那直接把它们爬下来不就行了。定时运行爬虫脚本,如果这次爬的评论数比上次爬的多,那么说明有新的评论。
这里的爬虫是用nodejs写的,因为它发起一个自定义header的get请求实在太容易了。具体应该发什么,在浏览器中打开审查元素看一下浏览器发送请求带了哪些header就可以。admin页面如果不设置cookie是无法登录的,所以这里我们要模拟浏览器设置cookie来发送GET请求。马上试了下,发现爬下来的页面什么都没有,定神再看,它的评论列表是通过ajax拿到然后通过jquery的插件画出来的。
于是问题就变成了,模拟发送这个ajax,然后分析返回数据得出评论数,和上一次的结果比较,若大于,则有新评论。好,开始写程序,header比较重要,正确设置好cookie才能拿到数据,在我浏览器中是下面这样的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
返回的是一个json数据,其中有一个域值正好是评论数,拿到以后和原来的比较即可。还需要保存上一次运行的评论数结果,我将它保存在一个文件中,每次去读取。
既然我们已经知道了是否有新的评论,那么就要推送给用户,这里提供两个方案,短信和邮件。
飞信提供了一个免费的命令行工具,能通过它来发送短信。具体教程请参考这里;
Mutt是一个命令行邮件发送工具,能达到我们的目的,设置请参考我的wiki。
因为这是一个定时运行的脚本,所以要设置crontab任务。在cron中加入下列代码:
1
|
|
意思是每隔1分钟去检查一下是否有新评论,如果你的服务器压力太大,可以把时间改长。
至此,我们的推送功能就开发好了。全部的代码已经放在了github。
]]>一般的服务器Push技术包括:
基于 AJAX 的长轮询(long-polling)方式,服务器Hold一段时间后再返回信息,然后前端再往后端发起请求,以此往复
HTTP Streaming,通过iframe和script标签完成数据的传输,这种方法近来已经很少被使用
TCP 长连接
HTML5新引入的WebSocket,可以实现服务器主动发送数据至网页端,它和HTTP一样,是一个基于HTTP的应用层协议,跑的是TCP,所以本质上还是个长连接,双向通信,意味着服务器端和客户端可以同时发送并响应请求,而不再像HTTP的请求和响应
上述的1和2统称为comet技术,这里有个简单的介绍:Comet:基于 HTTP 长连接的“服务器推”技术
前些日子由于项目网站的需求,后台会产生一些提示用户的警告消息,为了实现在用户正常浏览网页的前提下,后台通知实时推送到前端显示。
综合调研下来,发现是nodejs的socket.io比较成熟地解决了这件事情。它是一个实时通信的框架,天生就是为了服务器端和客户端的双向通信。在它的官网上,提供了一些最简单的应用,其中一个就是多人在线聊天室,用socket.io实现的代码逻辑非常清楚。
socket.io是WebSocket的一个开源实现,对不支持websocket的浏览器降级成comet / ajax轮询,socket.io的良好封装使代码编写非常容易。
它的部署方案极其简单,结合express框架,按照官网文档,几行代码就实现了一个实时通信server。它和Nginx/apache共存,原来的服务器还是提供原来的功能,新增的这台实时通信server只负责消息的推送。
需要连上这台server也很简单,需要在前端js中加入:
1 2 3 4 |
|
实际上,利用服务器推送技术,我们能实现好多平常HTTP协议无法办到的事情。
上述所讨论的所有东西都是Web端的实时通信,未来是属于移动端的,显然移动端的实时推送也非常重要。
在移动端主要有以下两个技术:XMPP 和 MQTT,有兴趣的同学可以自己去了解一下。
]]>以前给主页index加了各种统计,字体,微博秀后,主页的载入速度实在太慢,加上本身的博客框架也需要优化,一直都没有时间做(其实是懒),最近终于受不了了,想要把主页的加载速度加快。这个博客的后台操作几乎没有,服务器是github pages,评论用的是第三方插件,所以优化余地只有前端。
有一个很好的工具叫做gtmetrix,这个网站能够根据你的网站提供一份评分和详细的优化策略,本网站在优化前的评分是这样的:
Waterfall是这个样子的:
这个没办法,query string是cnzz统计和微博识别我的唯一途径。而且若首页加入了微博秀,会有好几个带query string的http request。解决方案就是直接把微博秀给删了。
这个的意思是,一些可以缓存的静态资源expire时间太短,导致短时间内刷新需要重复加载。因为我用的是github pages,所以我去查了下怎么搞定这件事,结果stackoverflow上有个相同的问题,结论就是目前还不提供修改http header的方法。
浏览器对js的执行规则是,读到一个js文件就执行这个js文件,导致如果你把js文件放在html的head里的话,浏览器在构建DOM之前忙着执行js了,结果就是用户体验极差。正确的做法是先加载页面,然后再执行js。
一个有效的措施是把js放到页面的最下面,本博客用的是octopress框架,本身自带两个js,加上jquery,一个三个js文件,默认放在了head里,于是我把它放到了页面底端。
把一些小的css文件直接inline在html里。
这里说的是图片的压缩,这个图片主要来源是新浪的微博秀和多说的最近评论。后来干脆把它们全部删掉了。
我把www.zyearn.com重定向到了zyearn.com,目的就是为了不让两个地址的A记录同时解析到同一个ip,防止搜索引擎认为这是两个网站,所以这条忽略。
感觉没什么必要,静态文件不是很多,而且放在国内的cdn上国外访问就慢了。曾经把jquery换到了新浪的国内cdn,结果测下来国外访问太慢,还是换回了谷歌的cdn。
网上的建议是把若干个js合并成一个大的js,这样在组织结构上显然不好,牺牲一点点速度换来结构的清晰还是很有必要的,所以不合并了。
为了减少非必要的网络流量,网站以前用了两个字体,正文一个字体,标题一个字体,共500K,在拥堵网络环境下,很多时候都是网页加载好了,但字体还在加载,导致用到那个字体的地方的文字就显示不出来,于是干脆把花哨的字体全删了,默认的也挺好。
另外,好多问题都是由第三方插件引起的,比如首页的cnzz统计,微博秀,duoshuo的最近评论,除了统计留着其它的第三方都删掉了。
这是优化后的waterfall:
可以看出页面页面加载只有几百毫秒,从原来的29个请求降到了12个请求。图下方耗时的都是cnzz的东西了,不过这些都是发生在页面加载之后。实际上这是在国外测的速度,cnzz会显得慢一些,在国内cnzz的访问还是非常快的。
现在打开主页基本上不会有卡顿,目的基本实现了。
]]>详细说之前,我们先说说背景知识。
NAT(Network Address Translation)是将IP 数据包头中的IP 地址转换为另一个IP 地址的过程,通俗来讲,就是局域网,公用一个public IP。那为什么要有这个东西,NAT是用来解决什么问题的?
时光回到上个世纪80年代,当时的人们在设计网络地址的时候,觉得再怎么样也不会有超过32bits位长即232台终端设备联入互联网,再加上增加ip的长度(即使是从4字节增到6字节)对当时设备的计算、存储、传输成本也是相当巨大的,想象当年的千年虫问题就是因为不存储年份的前两位导致的,现在想想,不就几个byte吗?我一顿饭不吃就省了好几个G了,但在当时的确是相当稀缺的资源。
后来逐渐发现IP地址不够用了,然后就NAT就诞生了!(虽然ipv6也是解决办法,但始终普及不开来,而且未来到底ipv6够不够用,谁知道呢)。NAT的本质就是让一群机器公用同一个IP。这样就暂时解决了IP短缺的问题。其实NAT还有一个重要的用途,就是保护NAT内的主机不受外界攻击。
p2p(peer to peer)可以定义成终端之间通过直接交换来共享计算机资源和服务,而无需经过服务器的中转。它的好处是显而易见的,不用服务器中转,不需要受限于服务器的带宽,而且大大减轻了服务器的压力。p2p的应用包括IM(qq,MSN),bittorrent等等。
要实现p2p,我们要克服的就是NAT穿越。在现在的互联网环境下,一个终端一般都在一个NAT内,NAT会有一个网关路由,对外暴露一个public IP,那么两个都在NAT的终端怎么通信呢?我们不知道对方的内网IP,即使把消息发到对方的网关,然后呢?网关怎么知道这条消息给谁,而且谁允许网关这么做了?
NAT内的设备怎么和公网服务器通信?
假设路由器ip为1.2.3.4
,公网服务器ip为5.6.7.8
,内网机器192.168.0.240:5060
首先发给路由器1.2.3.4
,路由器分配一个端口,比如说54333,然后路由器代替内网机器发给服务器,即1.2.3.4:54333 -> 5.6.7.8:80
,此时 路由器会在映射表上留下一个“洞”,来自5.6.7.8:80
发送到1.2.3.4
的54333端口的包都会转发到192.168.0.250:5060
但不是所有发往1.2.3.4:54333的包都会被转发过去,不同的NAT类型有不同的做法。下面我们来看几种NAT的类型:
全锥形NAT
IP、端口都不受限。只要客户端由内到外打通一个洞之后(NatIP:NatPort -> A:P1),其他IP的主机(B)或端口(A:P2)都可以使用这个洞发送数据到客户端。
(图片均来自网络)
受限锥形NAT
IP受限,端口不受限。当客户端由内到外打通一个洞之后(NatIP:NatPort -> A:P1),A机器可以使用他的其他端口(P2)主动连接客户端,但B机器则不被允许。
端口受限锥型
IP、端口都受限。返回的数据只接受曾经打洞成功的对象(A:P1),由A:P2、B:P1发起的数据将不被NatIP:NatPort接收。
对称型NAT
对称型NAT具有端口受限锥型的受限特性。但更重要的是,他对每个外部主机或端口的会话都会映射为不同的端口(洞)。只有来自相同的内部地址(IP:PORT)并且发送到相同外部地址(X:x)的请求,在NAT上才映射为相同的外网端口,即相同的映射。
举个例子:
Client --> NatIP:Pa1 --> A:P1
Client --> NatIP:Pa2 --> A:P2
(而在前面的三种NAT中,只要client不变,那么留在路由器上的“洞”就不会变,symmetric NAT会变,端口变)
为什么要知道自己的NAT类型?这为之后的打洞做准备。RFC专门定义了一套协议来做这件事(RFC 5389),这个协议的名字叫STUN(Session Traversal Utilities for NAT),它的算法输出是:
问题:有两个需要互联的client A和client B
方案:
因为每一次连接端口都不一样,所以对方无法知道在对称NAT的client下次用什么端口。 无法完全实现p2p传输(预测端口除外),需要turn server做一个relay,即所有消息通过turn server转发
一方通过与full cone的一方的public ip和port直接与full cone通信,实现了p2p通信。
受限型一方向对方发送“打洞包”,比如”punching…”,对方收到后回复一个指定的命令,比如”end punching”,通信开始。这样做理由:受限型一方需要让IPA:portA的包进入,需要先向IPA:portA发包。实现了p2p通信。
我们通过上述的讨论可知,symmetric NAT好像不能实现p2p啊?其实不然,能实现,但代价太高,这个方法叫端口预测。
基本思路: