1前言
这篇文章是应网友之邀所写,主要描述一下我们访问网站时, 从输入网址到最后浏览器呈现内容,中间发生了什么。
之前写过两篇文章《我是一个网卡》,《我是一个路由器》描述了一个电脑如何通过DHCP、ARP、NAT等上式获取IP、然后访问网络的过程,主要专注在传输层和网络层。
今天的文章主要专注于应用层,我拿了一个很简单的网络结构来讲。假定本机已经获取了IP地址,各种网络基础设施已经准备好了。
由于知识点太多,我肯定会漏掉部分内容,欢迎在留言中补充, 以后我会根据大家建议再写文章扩展。
2准备
当你在浏览器中输入网址(例如www.coder.com)并且敲了回车以后, 浏览器首先要做的事情就是获得coder.com的IP地址,具体的做法就是发送一个UDP的包给DNS服务器,DNS服务器会返回coder.com的IP, 这时候浏览器通常会把IP地址给缓存起来,这样下次访问就会加快。
比如Chrome, 你可以通过chrome://net-internals/#dns来查看。
有了服务器的IP, 浏览器就要可以发起HTTP请求了,但是HTTP Request/Response必须在TCP这个“虚拟的连接”上来发送和接收。
想要建立“虚拟的”TCP连接,TCP邮差需要知道4个东西:(本机IP, 本机端口,服务器IP, 服务器端口),现在只知道了本机IP,服务器IP, 两个端口怎么办?
本机端口很简单,操作系统可以给浏览器随机分配一个, 服务器端口更简单,用的是一个“众所周知”的端口,HTTP服务就是80, 我们直接告诉TCP邮差就行。
经过三次握手以后,客户端和服务器端的TCP连接就建立起来了! 终于可以发送HTTP请求了。
之所以把TCP连接画成虚线,是因为这个连接是虚拟的, 详情可参见之前的文章《TCP/IP之大明邮差》,《张大胖的Socket》
3Web服务器
一个HTTP GET请求经过千山万水,历经多个路由器的转发,终于到达服务器端(HTTP数据包可能被下层进行分片传输,略去不表)。
Web服务器需要着手处理了,它有三种方式来处理:
(1) 可以用一个线程来处理所有请求,同一时刻只能处理一个,这种结构易于实现,但是这样会造成严重的性能问题。
(2) 可以为每个请求分配一个进程/线程,但是当连接太多的时候,服务器端的进程/线程会耗费大量内存资源,进程/线程的切换也会让CPU不堪重负。
(3) 复用I/O的方式,很多Web服务器都采用了复用结构,例如通过epoll的方式监视所有的连接,当连接的状态发生变化(如有数据可读), 才用一个进程/线程对那个连接进行处理,处理完以后继续监视,等待下次状态变化。 用这种方式可以用少量的进程/线程应对成千上万的连接请求。
(码农翻身注:详情参见《Http Server:一个差生的逆袭》)
我们使用Nginx这个非常流行的Web服务器来继续下面的故事。
对于HTTP GET请求,Nginx利用epoll的方式给读取了出来, Nginx接下来要判断,这是个静态的请求还是个动态的请求啊?
如果是静态的请求(HTML文件,JavaScript文件,CSS文件,图片等),也许自己就能搞定了(当然依赖于Nginx配置,可能转发到别的缓存服务器去),读取本机硬盘上的相关文件,直接返回。
如果是动态的请求,需要后端服务器(如Tomcat)处理以后才能返回,那就需要向Tomcat转发,如果后端的Tomcat还不止一个,那就需要按照某种策略选取一个。
例如Ngnix支持这么几种:
轮询:按照次序挨个向后端服务器转发
权重:给每个后端服务器指定一个权重,相当于向后端服务器转发的几率。
ip_hash: 根据ip做一个hash操作,然后找个服务器转发,这样的话同一个客户端ip总是会转发到同一个后端服务器。
fair:根据后端服务器的响应时间来分配请求,响应时间段的优先分配。
不管用哪种算法,某个后端服务器最终被选中,然后Nginx需要把HTTP Request转发给后端的Tomcat,并且把Tomcat输出的HttpResponse再转发给浏览器。
由此可见,Nginx在这种场景下,是一个代理人的角色。
5应用服务器
Http Request终于来到了Tomcat,这是一个由Java写的、可以处理Servlet/JSP的容器,我们的代码就运行在这个容器之中。
如同Web服务器一样, Tomcat也可能为每个请求分配一个线程去处理,即通常所说的BIO模式(Blocking I/O 模式)。
也可能使用I/O多路复用技术,仅仅使用若干线程来处理所有请求,即NIO模式。
不管用哪种方式,Http Request 都会被交给某个Servlet处理,这个Servlet又会把Http Request做转换,变成框架所使用的参数格式,然后分发给某个Controller(如果你是在用Spring)或者Action(如果你是在Struts)。
剩下的故事就比较简单了(不,对码农来说,其实是最复杂的部分),就是执行码农经常写的增删改查逻辑,在这个过程中很有可能和缓存、数据库等后端组件打交道,最终返回HTTP Response,由于细节依赖业务逻辑,略去不表。
根据我们的例子,这个HTTP Response应该是一个HTML页面。
6归途
Tomcat很高兴地把Http Response发给了Ngnix 。
Ngnix也很高兴地把Http Response 发给了浏览器。
发完以后TCP连接能关闭吗?
如果使用的是HTTP1.1, 这个连接默认是keep-alive,也就是说不能关闭;
如果是HTTP1.0,要看看之前的HTTP Request Header中有没有Connetion:keep-alive,如果有,那也不能关闭。
7浏览器再次工作
浏览器收到了Http Response,从其中读取了HTML页面,开始准备显示这个页面。
但是这个HTML页面中可能引用了大量其他资源,例如js文件,CSS文件,图片等,这些资源也位于服务器端,并且可能位于另外一个域名下面,例如static.coder.com。
浏览器没有办法,只好一个个地下载,从使用DNS获取IP开始,之前做过的事情还要再来一遍。不同之处在于不会再有应用服务器如Tomcat的介入了。
如果需要下载的外部资源太多,浏览器会创建多个TCP连接,并行地去下载。
但是同一时间对同一域名下的请求数量也不能太多,要不然服务器访问量太大,受不了。所以浏览器要限制一下, 例如Chrome在Http1.1下只能并行地下载6个资源。
当服务器给浏览器发送JS,CSS这些文件时,会告诉浏览器这些文件什么时候过期(使用Cache-Control或者Expire),浏览器可以把文件缓存到本地,当第二次请求同样的文件时,如果不过期,直接从本地取就可以了。
如果过期了,浏览器就可以询问服务器端,文件有没有修改过?(依据是上一次服务器发送的Last-Modified和ETag),如果没有修改过(304 Not Modified),还可以使用缓存。否则的话服务器就会被最新的文件发回到浏览器。
当然如果你按了Ctrl+F5,会强制地发出GET请求,完全无视缓存。
注:在Chrome下,可以通过 chrome://view-http-cache/ 命令来查看缓存。
现在浏览器得到了三个重要的东西:
1.HTML ,浏览器把它变成DOM Tree
2. CSS, 浏览器把它变成CSS Rule Tree
3. JavaScript, 它可以修改DOM Tree
浏览器会通过DOM Tree和CSS Rule Tree生成所谓“Render Tree”,计算每个元素的位置/大小,进行布局,然后调用操作系统的API进行绘制,这是一个非常复杂的过程,略去不表。
到目前为止,我们终于在浏览器中看到了www.coder.com的内容。
到了断开连接的时候, 还得考虑友好分手!
张大胖说: “果然是复杂多了, listenfd ,从名称看就是为了要监听而创建的socket描述符吧, bind 是干嘛? 嗯, 我猜是为了声明说我要占用这个端口了啊, 你们都别用了, listen函数才是真正开始监听了。
当年DARPA(美国国防部先进研究项目局)和一个叫做BBN的公司签署了一个合同,要把TCP/IP协议加入到Berkeley Unix当中, 当研究生Bill Joy 看到BBN写的TCP/IP实现时, 觉得非常差劲,拒绝把它加入内核, 后来干脆卷起袖子自己实现了一个高性能的TCP/IP栈, 这个协议栈至今是互联网的基石。
别人问他是怎么实现这么复杂的软件的, 这位大神说: “很简单啊, 你只需要看看协议, 然后把代码写出来就行了”
好感
在这个忙碌的城市里, 我虽然没和她见过面, 但我们已经聊过很多次了。
与其说是聊天,倒不如说是通信, 每次我想给她说话时, 我就把消息放到一块共享内存里边, 然后就离开运行车间, 让她或者别人去使用CPU。 等我再次进来的时候,她回复的消息就已经在那个共享内存中了。
有无数次,我离开的时候都想偷偷的看一眼, 希望接下来运行的是她,可是这个城市严格的规则让我的希望只是奢望。
操作系统把我们这些进程严格的隔离, 他通过虚拟内存的机制,让每个进程都有一块虚拟的、独立的地址空间, 从而成功的制造了一个假象 : 让大家以为内存中只有一个程序在运行。
当我在就绪队列中等待的时候,也被严格禁止和别人交谈, 我经常环顾四周,希望能够看到她的身影, 可是这个系统的进程成千上万, 究竟哪个是她?
也许我见过她,但是根本认不出来。
我和她越聊越多, 对她的好感就越深, 有一次我给她发的消息等了100毫秒都没有回复,把我都快急疯了。
她很喜欢听我讲故事,尤其是那个编号为0x3704 的线程,每次她都会说: 唉,那些线程可真可怜。 我就吓唬她说: 有一天我们的机器也会重启的, 到时候估计你也认不出我来了。 她说没事的, 只要我能通过共享内存给你发消息,我就知道你就在这个城市里。
(码农翻身注: 0x3704的故事在《我是一个线程》里)
分离
这样的日子过了一天又一天, 我想见到她的愿望越来越迫切了。
我悄悄给了CPU很多好处, 希望CPU能描述下她的样子,方便我去找她, 可是CPU运算速度太快, 阅人无数,但就是没有记忆力。
CPU说: 你还是去问操作系统老大吧, 看看你喜欢的女孩到底什么样。
问操作系统? 还是算了吧, 互相隔离是我们城市的铁规, 弄不好他会把我kill掉。
圣诞节前的平安夜, 我打算正式向她表白, 像往常一样 , 我从共享内存里收到了她的信, 急切的拆开信封, 看到了里边的第一句话: 我要走了,以后不能和你通信了......
刹那间,我第一次感觉到了什么叫做五雷轰顶,灵魂出鞘, 我脑子一片空白, 张大了嘴巴呆呆站在那里, 时间长达20毫秒。
CPU看到了我的异常, 因为这么长时间的指令都是NOP, 什么都不做, 这是非常罕见的。
CPU好心的提醒我: 嗨,老兄,你怎么了? 你的时间片快用完了啊!
我的灵魂慢慢归位,意识到信还没有读完, 赶紧接着往下看: “ 我马上要搬到另外一个城市去了,你要想找我的话,切记下面的IP地址和端口号,用socket和我通信”
我明白了,到另外一个城市那就意味者要搬离我们现在的电脑了, 也许是这个城市太拥挤, CPU/内存/硬盘已经不堪重负, 有一批程序需要被搬离到另外一个电脑中。
虽然我和她一直没机会见面, 但我知道我们就住在一个城市, 有时候也许只是擦肩而过, 她就在我的身边, 这好歹给我一点点安慰。
现在,连这一点点的安慰都没有了, 对了,她说的这个socket 是什么东西。
CPU说: “那是网络编程, 你看人家对你还是有情意的, 临走了还给你留下联系方式, 快去学学怎么用Socket吧”
当晚我就失眠了,半夜爬起来翻看一页页和她的通信记录 (很庆幸我把通信记录都保存到了文件中),脑海里回想着这么多天以来幸福的日子,一直到天亮。
网络
为了早日和她联系, 我奋发图强学习网络编程, 理解TCP/IP, 把我自己逐渐的加上对Socket的支持。
一个CPU月以后, 我这个程序终于完成了从共享内存到Socket的改造,激动人心的时刻到来了。
作为一个客户端, 我颤抖着双手向她发起了Socket请求, TCP携带着数据包慢吞吞的走向她所在的城市, 等了好久TCP才完成了三次握手, 这网络可是真慢啊。
我赶紧发送第一个消息: 你好,好久不“见”。
等了足足有1000毫秒, 对我来说仿佛是一个世纪, 才收到让我激动无比的回信 : “啊, 你终于来了 。我在这里等了你好久了,你怎么现在才联系我 ?”
我不好意思的说: “我很笨, 学习socket 太慢了”
又过了一个世纪,我才收到回复, 这网络真是慢的令人抓狂啊。
不管如何, 终于和她联系上了, 这让我开心无比。
原来我们一天能通信上千次, 现在可好, 有10次就不错了, 再也不能像原来那样痛快的讲故事了, 既来之则安之, 反正网络很慢, 现在每次我都会写一封巨长无比的信, 把我的思念之情全部倾诉在其中, 漫长的等待以后再去读她的长长的回复。
原来我们通过内存来中转消息的时候, 是通过操作系统来做同步操作的, 这能防止读写的冲突。
可是通过网络通信就完全乱掉了, 经常会出现我说我的, 她说她的, 闹的很不愉快。
后来我和她只好协商了一个协议, 约定好消息的次序和格式, 这才算解决了问题。
(码农翻身注: 这其实就是基于socket的应用层协议)
Web
我明白我和她已经不可能在一起了, 每天的socket通信已经让我满足。
可是有一天当我照例发起socket的请求的时候, TCP的连接竟然告诉我 "超时" 了, 这是从来没有发生的事情,难道这一次要彻底失去她了吗?
我冒着风险,马上把异常报给了操作系统老大, 老大尝试了一下说: “我ping了一下, 网络是通的, 估计是你那从未见面的小女朋友不想理你了, 悄悄的换了一个你不知道的端口吧。”
我斩钉截铁的说: 那绝对不可能, 她不是这样的人。
可是迟迟没有消息, 我每天都会试图连接一下, 每次都是超时, 没有她的日子生活都是灰色的, 不断的煎熬让我快要绝望了。
终于有一天, 有一个U盘从她的城市来到我们这里, 告诉了我们一个惊人的消息,她所在的城市安装了防火墙,现在除了几个特定的端口(例如80,443...) 之外, 都不允许访问了。
我一下子松了口气, 怪不得, 她告诉我的端口不是80和443, 被封掉了, 我自然连接不上了。
我问U盘: “那我想和女朋友通信, 该怎么办?”
U盘说: 很简单啊, 你和你女朋友都可以包装成Web 服务啊, 这样都是通过Http(80端口)或者Https(443端口)来访问的, 这样防火墙是允许的啊。
好吧, 为了和她联系上, 马上抛弃socket, 开始向Web服务进化。
一个Web服务首先要有一个endpoint , 其实就是就是一个URL , 描述了这个Web服务的地址。
其次确定Web服务的描述方式和数据传输方式, 我先是选了WSDL 和 SOAP , 研究了一下才发现这哥俩太繁琐了,都是XML, 很多冗余的数据标签, 我想这将会极大的影响我和她的通信效率, 还是换成简单的HTTP GET/POST + JSON吧, 很简洁,能充分的表达我的相思之情。
我把我这个Web服务的地址和格式协议告诉U盘, 恳请U盘带到那个城市,再把女朋友的Web服务描述带回来。
我欣喜的发现,我和她不约而同的选择了轻量级的HTTP+ JSON, 看来虽然隔着千山万水,我们的心意还是相通的。
这样的准备工作足足干了6个CPU月, 但我并不觉得累, 因为希望一直在前边召唤。
这是一个晴朗的日子,一切工作准备就绪,马上就要联系了, 这一次我的心情反而平静了下来, 因为我坚信她肯定在那边等着我。
我通过HTTP向她发出了呼叫, HTTP的报文被打包在TCP报文段中, 又被放到IP层数据报中, 最后形成链路层的帧, 通过网卡发了出去。
在意料之中的漫长等待以后, 我看到了期待已久地回复: 我们终于又“见”面了 !
我回答:“是啊, 真是太不容易了”
“不知道将来我们会不会再分开?” 她担忧的说。
“未来会如何? 我也不知道,还是牢牢地把握住现在吧! 我相信我们的心会一直在一起,什么都无法阻止! ”
(完)
我这个进程和她不在一个机器上, 虽然相距243毫秒,但是这并不是阻碍我们交往的理由, 我每天都通过socket 和她来通信,诉说相思之情。
不要惊讶我用时间来表示距离,人类好像也是用光年来表示宇宙间的距离吧? 在我们计算机世界, 距离不是有意义的标识,时间才是! 你看我和纽约相距1万多公里, 我和那里的机器沟通只需要花费466毫秒, 但是和北京的另外一个机器沟通竟然需要743毫秒! 可见距离近是不管用的!
最近黑客猖獗, 我和她通信的时候总是有一种被偷看的感觉, 实际上确实是这样, 那些只有我们才可以知道的悄悄话被别人偷窥,甚至曝光了。
我和她商量着要保护隐私,要对我们来往的信件加密, 可我听说加密需要密钥, 这个密钥必须双方都得事先知道才行, 我用密钥加密,她用同样的密钥解密。
那问题就来了: 加密解密算法是公开的, 但是密钥是私有的,当我们俩通过网络协商密钥时, 黑客可能就把密钥也给偷看了, 那加密就毫无用处。
这可真是伤脑筋, 我说:“要不我到你那儿去一趟? 正好看看你, 你可以面对面的把密钥告诉我。”
她说: “你晕头了吧, 你一个进程怎么可能从一个机器来到另外一个机器?”
我自知失言,马上补救: “ 这样吧, 我们机器上有个U盘, 要不我把密钥写到那里, 这样将来可以Copy到你的机器上”
“那更不行了, Copy到U盘上更容易泄露,速度还慢! ”
我是没辙了,长时间的沉默。
她突然说:“我想起来了,我们机器有个进行数学计算的进程,知识渊博,我去问问他”
我焦急地等待,不知过了多少毫秒, 女朋友终于兴冲冲的回来了: “那个数学进程小帅哥真是厉害,我简直佩服死了, 他告诉我了一个非常简单的办法 , 能解决我们的密钥生成问题”
我心里略微不爽,但还是耐着性子,一边听她说,一边写了下来,这个算法确实很简单, 举个例子来说是这样的:
1. 首先我和她先协定一个质数 p=17以及另外一个数字g=3, 这两个数字是公开的, 黑客拿去也没有问题
2. 我选择一个随机的秘密数字x = 15, 计算a = g15 mod p并发送给她。
a = 315 mod 17 = 6.
这个a=6也是公开的
3. 她选择一个随机的秘密数字y=13, 计算b = g13 mod p并发送给我。
b = 313 mod 17 = 12.
这个b=12也是公开的
4. 我拿到她发给我的b = 12 , 计算s = b x mod p ->1215 mod 17 = 10
5. 她拿到我发给她的a = 6, 计算s = a y mod p -> 613 mod 17 = 10
(注:例子来源于wikipedia, 红色表示数字一定要保密, 绿色表示数字可以公开)
最后神奇的魔法发生了, 我们两个得到了同样的值 s = 10!
这个s 的值只有我们两个才知道, 其实就是密钥了, 可以用来做加密解密了( 当然,这只是一个例子,实际的密钥不会这么短), 我们俩的通讯从此就安全了。
“可是为什么会这样呢” 我问道。
“数学家小帅哥说了, 原因很简单,(gx mod p)y mod p 和 (gy mod p)x mod p 是相等的! ”
“那黑客不能从公开传输的 p = 17, g = 3, a = 6 , b = 12 推算出s = 10 吗?” 我问道。
“当然不能, 不过前提是需要使用非常大的p , x, y, 这样以来,即使黑客动用地球上所有的计算资源, 也推算不出来。 ”
我虽然心里不爽, 但还是暗自佩服那个数学家, 他竟然能想到把一些数字给公布出去,不怕黑客窃取, 还不泄露最终的密钥。解决了在一个不安全的通信环境下,生成密钥进行加密和解密的问题。
好吧,就用这个方法来加密通信吧。
后记:文中描述的算法叫做Diffie-Hellman Key Exchange算法, 发明人是 Whitfield Diffie 和 Martin Hellman ,他们于2015年获得了计算机科学领域的最高奖:图灵奖,以表彰他们对密码学和当今互联网安全的巨大贡献。因为他们的创造,引发了对一个新的密码学领域,即非对称密钥算法的探索,而非对称密钥算法可以看作现代密码学的基础。
推荐阅读:《两个程序的爱情故事》
检查很快, 他很快就放行了: “我们这也是为了大家安全, 好了, 通过了, 等会儿见。”
(点开图片,放大看, 很多细节噢)
经过了5个拦截器, 我们终于来到了LoginAction的跟前。
只是次序和刚才不同: 先是Validation, 然后是Parameter, FileUpload, I18n , Exception , 和刚才进来的时候完全倒过来了!
(点开图片,放大看, 很多细节噢)
我有点明白了, 这些家伙们只是都是在LoginAction执行之前拦截我们一下, 在LoginAction 之后再拦截我们一下。
特别感谢: 网友blindingdark 提供的形神具备的配图, 这可是先在纸上手绘, 然后扫描变成图片编辑,很不容易啊 :-)
我刚毕业那会儿,国家还是包分配工作的, 我的死党小明被分配到了一个叫数据库的大城市,天天都可以坐在高端大气上档次的机房里, 在那里专门执行SQL查询优化 , 工作稳定又舒适;
隔壁宿舍的小白被送到了编译器镇,在那里专门把C源文件编译成EXE程序, 虽然累,但是技术含量非常高, 工资高,假期多。
我成绩不太好,典型的差生,四级补考了两次才过, 被发配到了一个不知道什么名字的村庄,据说要处理什么HTTP请求, 这个村庄其实就是一个破旧的电脑, 令我欣慰的是可以上网,时不时能和死党们通个信什么的。
不过辅导员说了, 我们都有光明的前途。
1
Http Server 1.0
HTTP是个新鲜的事物, 能够激起我一点点工作的兴趣, 不至于沉沦下去。
一上班,操作系统老大扔给我一大堆文档: “这是HTTP协议, 两天看完!”
我这样的英文水平, 这几十页的英文HTTP协议我不吃不喝不睡两天也看不完, 死猪不怕开水烫,慢慢磨吧。
两个星期以后, 我终于大概明白了这HTTP是怎么回事: 无非是有些电脑上的浏览器向我这个破电脑发送一个预先定义好的文本(Http request), 然后我这边处理一下(通常是从硬盘上取一个后缀名是html的文件), 然后再把这个文件通过文本方式发回去(http response), 就这么简单。
唯一麻烦的实现, 我得请操作系统给我建立Http层下面的TCP连接通道, 因为所有的文本数据都得通过这些TCP通道接收和发送, 这个通道是用socket建立的。
弄明白了原理,我很快就搞出了第一版程序, 这个程序长这个样子:
(码农翻身注: 详情参加文章《张大胖的socket》)
看看, 这些socket, bind, listen , accept... 都是操作系统老大提供的接口, 我能做的也就是把他们组装起来: 先在80端口监听, 然后进入无限循环,如果有连接请求来了,就接受(accept),创建新的socket, 最后才可以通过这个socket来接收,发送http数据。
老大给我的程序起了个名称, Http Server, 版本1.0 。
这个名字听起来挺高端的, 我喜欢。
我兴冲冲的拿来实验, 程序启动了, 在80端口“蹲守”, 过了一会儿就有连接请求了, 赶紧Accept ,建立新的socket, 成功 ! 接下来就需要从socket 中读取Http Request了。
可是这个receive 调用好慢, 我足足等了100毫秒还没有响应 ! 我被阻塞(block)住了!
操作系统老大说: “别急啊, 我也在等着从网卡那里读数据,读完以后就会复制给你。 ”
我乐的清闲, 可以休息一下。
可是操作系统老大说:“别介啊, 后边还有很多浏览器要发起连接, 你不能在这儿歇着啊。”
我说不歇着怎么办? receive调用在你这里阻塞着, 我除了加入阻塞队列, 让出CPU让别人用还能干什么?
老大说: “唉, 大学里没听说过多进程吗? 你现在很明显是单进程, 一旦阻塞就完蛋了, 想办法用下多进程, 每个进程处理一个请求! ”
老大教训的是, 我忘了多进程并发编程了。
2
Http 2.0 :多进程
多进程的思路非常简单,当accept连接以后,对于这个新的socket , 不在主进程里处理, 而是新创建子进程来接管。 这样主进程就不会阻塞在receive 上, 可以继续接受新的连接了。
我改写了代码, 把Http server 升级为V2.0, 这次运行顺畅了很多, 能并发的处理很多连接了。
这个时候Web 刚刚兴起, 我这个Http Server 访问的人还不多, 每分钟也就那么几十个连接发过来,我轻松应对。
由于是新鲜事物, 我还有资本给搞数据库的小明和做编译的小白吹吹牛, 告诉他们我可是网络高手。
没过几年, Web迅速发展, 我所在的破旧机器也不行了, 换成了一个性能强悍的服务器, 也搬到了四季如春的机房里。
现在每秒中都有上百个连接请求了, 有些连接持续的时间还相当的长,所以我经常得创建成百上千的进程来处理他们,每个进程都得耗费大量的系统资源, 很明显操作系统老大已经不堪重负了。
他说: “咱们不能这么干了, 这么多进程,光是做进程切换就把我累死了。”
“要不对每个Socket连接我不用进程了, 使用线程? ”
“可能好一点, 但我还是得切换线程啊, 你想想办法限制一下数量吧。”
我怎么限制? 我只能说同一时刻,我只能支持x个连接, 其他的连接只能排队等待了。
这肯定不是一个好的办法。
3
Http Server 3.0 : Select模型
老大说: “我们仔细合计合计, 对我来说,一个Socket连接就是一个所谓的文件描述符(File Descriptor ,简称 fd , 是个整数) , 这个fd 背后是一个简单的数据结构, 但是我们用了一个非常重量级的东西-- 进程 --来表示对它的读写操作, 有点浪费啊。”
我说: “要不咱们还切换回单进程模型? 但是又会回到老路上去, 一个receive 的阻塞就什么事都干不了了”
“单进程也不是不可以, 但是我们要改变一下工作方式。”
“改成什么?” 我想不透老大在卖什么关子。
“你想想你阻塞的本质原因, 还不是因为人家浏览器还没有把数据发过来, 我自然也没法给你, 而你又迫不及待的想去读, 我只好把你阻塞。 在单进程情况下, 一阻塞,别的事儿都干不了。“
“对,就是这样”
“所以你接受了客户端连接以后, 不能那么着急的去读, 咱们这么办, 你的每个socket fd 都有编号, 你把这些编号告诉我, 就可以阻塞休息了 ”
我问道:“这不和以前一样吗? 原来是调用receive 时阻塞, 现在还是阻塞”
“听我说完, 我会在后台检查这些编号的socket, 如果发现这些socket 可以读写, 我会把对应的socket 做个标记, 把你唤醒去处理这些socket 的数据, 你处理完了,再把你的那些socket fd 告诉我, 再次进入阻塞,如此循环往复。”
我有点明白了: “ 这是我们俩的一种通信方式, 我告诉你我要等待什么东西, 然后阻塞, 如果事件发生了, 你就把我唤醒, 让我做事情。”
“对, 关键点是你等我的通知, 我把你从阻塞状态唤醒后, 你一定要去遍历一遍所有的socket fd,看看谁有标记, 有标记的做相应处理。 我把这种方式叫做 select ”
我用select的方式改写了Http server, 抛弃了一个socket请求对于一个进程的模式, 现在我用一个进程就可以处理所有的socket了。
4
Http Server4.0 : epoll
这种称为select的方式运行了一段时间, 效果还不错, 我只管把socket fd 告诉老大, 然后等着他通知我就行了。
有一次我无意中问老大:“我每次最多可以告诉你多少个socket fd?”
“1024个”
“那就是说我一个进程最多只能监控1024个socket了? ”
"是的, 你可以考虑多用几个进程啊"
这倒是一个办法, 不过"select"的方式用的多了, 我就发现了弊端, 最大的问题就是我从阻塞中恢复以后,需要遍历这1000多个socket fd, 看看有没有标志位需要处理。
实际的情况是, 很多socket 并不活跃, 在一段时间内浏览器并没有数据发过来, 这1000多个socket 可能只有那么几十个需要真正的处理, 但是我不得不查看所有的socket fd, 这挺烦人的。
难道老大不能把那些发生了变化的socket 告诉我吗?
我把这个想法给老大说了下, 他说:“嗯, 现在访问量越来越大, select 方式已经不满足要求, 我们需要与时俱进了, 我想了一个新的方式,叫做epoll”
“看到没有, 使用epoll和select 其实类似“ 老大接着说 : ” 不同的地方是第3步和第4步, 我只会告诉你那些可以读写的socket , 你呢只需要处理这些'ready' 的socket 就可以了“
“看来老大想的很周全, 这种方式对我来说就简单的多了。 ”
我用epoll 把Http Server 再次升级, 由于不需要遍历全部集合, 只需要处理哪些有变化的, 活跃的socket 文件描述符, 系统的处理能力有了飞跃的提升。
我的Http Server 受到了广泛的欢迎, 全世界有无数人在使用, 最后死党数据库小明也知道了, 他问我:“ 大家都说你能轻松的支持好几万的并发连接, 真是这样吗? ”
我谦虚的说: “过奖, 其实还得做系统的优化啦。”
他说:“厉害啊,你小子走了狗屎运了啊。”
我回答: “毕业那会儿辅导员不是说过吗, 每个人都有光明的前途。”
(完)
1前言
这篇文章是应网友之邀所写,主要描述一下我们访问网站时, 从输入网址到最后浏览器呈现内容,中间发生了什么。
之前写过两篇文章《我是一个网卡》,《我是一个路由器》描述了一个电脑如何通过DHCP、ARP、NAT等上式获取IP、然后访问网络的过程,主要专注在传输层和网络层。
今天的文章主要专注于应用层,我拿了一个很简单的网络结构来讲。假定本机已经获取了IP地址,各种网络基础设施已经准备好了。
由于知识点太多,我肯定会漏掉部分内容,欢迎在留言中补充, 以后我会根据大家建议再写文章扩展。
2准备
当你在浏览器中输入网址(例如www.coder.com)并且敲了回车以后, 浏览器首先要做的事情就是获得coder.com的IP地址,具体的做法就是发送一个UDP的包给DNS服务器,DNS服务器会返回coder.com的IP, 这时候浏览器通常会把IP地址给缓存起来,这样下次访问就会加快。
比如Chrome, 你可以通过chrome://net-internals/#dns来查看。
有了服务器的IP, 浏览器就要可以发起HTTP请求了,但是HTTP Request/Response必须在TCP这个“虚拟的连接”上来发送和接收。
想要建立“虚拟的”TCP连接,TCP邮差需要知道4个东西:(本机IP, 本机端口,服务器IP, 服务器端口),现在只知道了本机IP,服务器IP, 两个端口怎么办?
本机端口很简单,操作系统可以给浏览器随机分配一个, 服务器端口更简单,用的是一个“众所周知”的端口,HTTP服务就是80, 我们直接告诉TCP邮差就行。
经过三次握手以后,客户端和服务器端的TCP连接就建立起来了! 终于可以发送HTTP请求了。
之所以把TCP连接画成虚线,是因为这个连接是虚拟的, 详情可参见之前的文章《TCP/IP之大明邮差》,《张大胖的Socket》
3Web服务器
一个HTTP GET请求经过千山万水,历经多个路由器的转发,终于到达服务器端(HTTP数据包可能被下层进行分片传输,略去不表)。
Web服务器需要着手处理了,它有三种方式来处理:
(1) 可以用一个线程来处理所有请求,同一时刻只能处理一个,这种结构易于实现,但是这样会造成严重的性能问题。
(2) 可以为每个请求分配一个进程/线程,但是当连接太多的时候,服务器端的进程/线程会耗费大量内存资源,进程/线程的切换也会让CPU不堪重负。
(3) 复用I/O的方式,很多Web服务器都采用了复用结构,例如通过epoll的方式监视所有的连接,当连接的状态发生变化(如有数据可读), 才用一个进程/线程对那个连接进行处理,处理完以后继续监视,等待下次状态变化。 用这种方式可以用少量的进程/线程应对成千上万的连接请求。
(码农翻身注:详情参见《Http Server:一个差生的逆袭》)
我们使用Nginx这个非常流行的Web服务器来继续下面的故事。
对于HTTP GET请求,Nginx利用epoll的方式给读取了出来, Nginx接下来要判断,这是个静态的请求还是个动态的请求啊?
如果是静态的请求(HTML文件,JavaScript文件,CSS文件,图片等),也许自己就能搞定了(当然依赖于Nginx配置,可能转发到别的缓存服务器去),读取本机硬盘上的相关文件,直接返回。
如果是动态的请求,需要后端服务器(如Tomcat)处理以后才能返回,那就需要向Tomcat转发,如果后端的Tomcat还不止一个,那就需要按照某种策略选取一个。
例如Ngnix支持这么几种:
轮询:按照次序挨个向后端服务器转发
权重:给每个后端服务器指定一个权重,相当于向后端服务器转发的几率。
ip_hash: 根据ip做一个hash操作,然后找个服务器转发,这样的话同一个客户端ip总是会转发到同一个后端服务器。
fair:根据后端服务器的响应时间来分配请求,响应时间段的优先分配。
不管用哪种算法,某个后端服务器最终被选中,然后Nginx需要把HTTP Request转发给后端的Tomcat,并且把Tomcat输出的HttpResponse再转发给浏览器。
由此可见,Nginx在这种场景下,是一个代理人的角色。
5应用服务器
Http Request终于来到了Tomcat,这是一个由Java写的、可以处理Servlet/JSP的容器,我们的代码就运行在这个容器之中。
如同Web服务器一样, Tomcat也可能为每个请求分配一个线程去处理,即通常所说的BIO模式(Blocking I/O 模式)。
也可能使用I/O多路复用技术,仅仅使用若干线程来处理所有请求,即NIO模式。
不管用哪种方式,Http Request 都会被交给某个Servlet处理,这个Servlet又会把Http Request做转换,变成框架所使用的参数格式,然后分发给某个Controller(如果你是在用Spring)或者Action(如果你是在Struts)。
剩下的故事就比较简单了(不,对码农来说,其实是最复杂的部分),就是执行码农经常写的增删改查逻辑,在这个过程中很有可能和缓存、数据库等后端组件打交道,最终返回HTTP Response,由于细节依赖业务逻辑,略去不表。
根据我们的例子,这个HTTP Response应该是一个HTML页面。
6归途
Tomcat很高兴地把Http Response发给了Ngnix 。
Ngnix也很高兴地把Http Response 发给了浏览器。
发完以后TCP连接能关闭吗?
如果使用的是HTTP1.1, 这个连接默认是keep-alive,也就是说不能关闭;
如果是HTTP1.0,要看看之前的HTTP Request Header中有没有Connetion:keep-alive,如果有,那也不能关闭。
7浏览器再次工作
浏览器收到了Http Response,从其中读取了HTML页面,开始准备显示这个页面。
但是这个HTML页面中可能引用了大量其他资源,例如js文件,CSS文件,图片等,这些资源也位于服务器端,并且可能位于另外一个域名下面,例如static.coder.com。
浏览器没有办法,只好一个个地下载,从使用DNS获取IP开始,之前做过的事情还要再来一遍。不同之处在于不会再有应用服务器如Tomcat的介入了。
如果需要下载的外部资源太多,浏览器会创建多个TCP连接,并行地去下载。
但是同一时间对同一域名下的请求数量也不能太多,要不然服务器访问量太大,受不了。所以浏览器要限制一下, 例如Chrome在Http1.1下只能并行地下载6个资源。
当服务器给浏览器发送JS,CSS这些文件时,会告诉浏览器这些文件什么时候过期(使用Cache-Control或者Expire),浏览器可以把文件缓存到本地,当第二次请求同样的文件时,如果不过期,直接从本地取就可以了。
如果过期了,浏览器就可以询问服务器端,文件有没有修改过?(依据是上一次服务器发送的Last-Modified和ETag),如果没有修改过(304 Not Modified),还可以使用缓存。否则的话服务器就会被最新的文件发回到浏览器。
当然如果你按了Ctrl+F5,会强制地发出GET请求,完全无视缓存。
注:在Chrome下,可以通过 chrome://view-http-cache/ 命令来查看缓存。
现在浏览器得到了三个重要的东西:
1.HTML ,浏览器把它变成DOM Tree
2. CSS, 浏览器把它变成CSS Rule Tree
3. JavaScript, 它可以修改DOM Tree
浏览器会通过DOM Tree和CSS Rule Tree生成所谓“Render Tree”,计算每个元素的位置/大小,进行布局,然后调用操作系统的API进行绘制,这是一个非常复杂的过程,略去不表。
到目前为止,我们终于在浏览器中看到了www.coder.com的内容。