This commit is contained in:
louzefeng
2024-07-09 18:38:56 +00:00
parent 8bafaef34d
commit bf99793fd0
6071 changed files with 1017944 additions and 0 deletions

View File

@@ -0,0 +1,135 @@
<audio id="audio" title="第10讲 | UDP协议因性善而简单难免碰到“城会玩”" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/60/83/6046c29969d44eeb559bf99d0a366983.mp3"></audio>
讲完了IP层以后接下来我们开始讲传输层。传输层里比较重要的两个协议一个是TCP一个是UDP。对于不从事底层开发的人员来讲或者对于开发应用的人来讲最常用的就是这两个协议。由于面试的时候这两个协议经常会被放在一起问因而我在讲的时候也会结合着来讲。
## TCP和UDP有哪些区别
一般面试的时候我问这两个协议的区别大部分人会回答TCP是面向连接的UDP是面向无连接的。
什么叫面向连接什么叫无连接呢在互通之前面向连接的协议会先建立连接。例如TCP会三次握手而UDP不会。为什么要建立连接呢你TCP三次握手我UDP也可以发三个包玩玩有什么区别吗
**所谓的建立连接,是为了在客户端和服务端维护连接,而建立一定的数据结构来维护双方交互的状态,用这样的数据结构来保证所谓的面向连接的特性。**
例如,**TCP提供可靠交付**。通过TCP连接传输的数据无差错、不丢失、不重复、并且按序到达。我们都知道IP包是没有任何可靠性保证的一旦发出去就像西天取经走丢了、被妖怪吃了都只能随它去。但是TCP号称能做到那个连接维护的程序做的事情这个下两节我会详细描述。而**UDP继承了IP包的特性不保证不丢失不保证按顺序到达。**
再如,**TCP是面向字节流的**。发送的时候发的是一个流没头没尾。IP包可不是一个流而是一个个的IP包。之所以变成了流这也是TCP自己的状态维护做的事情。而**UDP继承了IP的特性基于数据报的一个一个地发一个一个地收。**
还有**TCP是可以有拥塞控制的**。它意识到包丢弃了或者网络的环境不好了,就会根据情况调整自己的行为,看看是不是发快了,要不要发慢点。**UDP就不会应用让我发我就发管它洪水滔天。**
因而**TCP其实是一个有状态服务**,通俗地讲就是有脑子的,里面精确地记着发送了没有,接收到没有,发送到哪个了,应该接收哪个了,错一点儿都不行。而**UDP则是无状态服务**。通俗地说是没脑子的,天真无邪的,发出去就发出去了。
我们可以这样比喻如果MAC层定义了本地局域网的传输行为IP层定义了整个网络端到端的传输行为这两层基本定义了这样的基因网络传输是以包为单位的二层叫帧网络层叫包传输层叫段。我们笼统地称为包。包单独传输自行选路在不同的设备封装解封装不保证到达。基于这个基因生下来的孩子UDP完全继承了这些特性几乎没有自己的思想。
## UDP包头是什么样的
我们来看一下UDP包头。
前面章节我已经讲过包的传输过程这里不再赘述。当我发送的UDP包到达目标机器后发现MAC地址匹配于是就取下来将剩下的包传给处理IP层的代码。把IP头取下来发现目标IP匹配接下来呢这里面的数据包是给谁呢
发送的时候我知道我发的是一个UDP的包收到的那台机器咋知道的呢所以在IP头里面有个8位协议这里会存放数据里面到底是TCP还是UDP当然这里是UDP。于是如果我们知道UDP头的格式就能从数据里面将它解析出来。解析出来以后呢数据给谁处理呢
处理完传输层的事情,内核的事情基本就干完了,里面的数据应该交给应用程序自己去处理,可是一台机器上跑着这么多的应用程序,应该给谁呢?
无论应用程序写的使用TCP传数据还是UDP传数据都要监听一个端口。正是这个端口用来区分应用程序要不说端口不能冲突呢。两个应用监听一个端口到时候包给谁呀所以按理说无论是TCP还是UDP包头里面应该有端口号根据端口号将数据交给相应的应用程序。
<img src="https://static001.geekbang.org/resource/image/2c/84/2c9a109f3be308dea901004a5a3b4c84.jpg" alt="" />
当我们看到UDP包头的时候发现的确有端口号有源端口号和目标端口号。因为是两端通信嘛这很好理解。但是你还会发现UDP除了端口号再没有其他的了。和下两节要讲的TCP头比起来这个简直简单得一塌糊涂啊
## UDP的三大特点
UDP就像小孩子一样有以下这些特点
第一,**沟通简单**,不需要一肚子花花肠子(大量的数据结构、处理逻辑、包头字段)。前提是它相信网络世界是美好的,秉承性善论,相信网络通路默认就是很容易送达的,不容易被丢弃的。
第二,**轻信他人**。它不会建立连接,虽然有端口号,但是监听在这个地方,谁都可以传给他数据,他也可以传给任何人数据,甚至可以同时传给多个人数据。
第三,**愣头青,做事不懂权变**。不知道什么时候该坚持,什么时候该退让。它不会根据网络的情况进行发包的拥塞控制,无论网络丢包丢成啥样了,它该怎么发还怎么发。
## UDP的三大使用场景
基于UDP这种“小孩子”的特点我们可以考虑在以下的场景中使用。
第一,**需要资源少,在网络情况比较好的内网,或者对于丢包不敏感的应用**。这很好理解,就像如果你是领导,你会让你们组刚毕业的小朋友去做一些没有那么难的项目,打一些没有那么难的客户,或者做一些失败了也能忍受的实验性项目。
我们在第四节讲的DHCP就是基于UDP协议的。一般的获取IP地址都是内网请求而且一次获取不到IP又没事过一会儿还有机会。我们讲过PXE可以在启动的时候自动安装操作系统操作系统镜像的下载使用的TFTP这个也是基于UDP协议的。在还没有操作系统的时候客户端拥有的资源很少不适合维护一个复杂的状态机而且因为是内网一般也没啥问题。
第二,**不需要一对一沟通,建立连接,而是可以广播的应用**。咱们小时候人都很简单,大家在班级里面,谁成绩好,谁写作好,应该表扬谁惩罚谁,谁得几个小红花都是当着全班的面讲的,公平公正公开。长大了人心复杂了,薪水、奖金要背靠背,和员工一对一沟通。
UDP的不面向连接的功能可以使得可以承载广播或者多播的协议。DHCP就是一种广播的形式就是基于UDP协议的而广播包的格式前面说过了。
对于多播我们在讲IP地址的时候讲过一个D类地址也即组播地址使用这个地址可以将包组播给一批机器。当一台机器上的某个进程想监听某个组播地址的时候需要发送IGMP包所在网络的路由器就能收到这个包知道有个机器上有个进程在监听这个组播地址。当路由器收到这个组播地址的时候会将包转发给这台机器这样就实现了跨路由器的组播。
在后面云中网络部分有一个协议VXLAN也是需要用到组播也是基于UDP协议的。
第三,**需要处理速度快,时延低,可以容忍少数丢包,但是要求即便网络拥塞,也毫不退缩,一往无前的时候**。记得曾国藩建立湘军的时候,专门招出生牛犊不怕虎的新兵,而不用那些“老油条”的八旗兵,就是因为八旗兵经历的事情多,遇到敌军不敢舍死忘生。
同理UDP简单、处理速度快不像TCP那样操这么多的心各种重传啊保证顺序啊前面的不收到后面的没法处理啊。不然等这些事情做完了时延早就上去了。而TCP在网络不好出现丢包的时候拥塞控制策略会主动的退缩降低发送速度这就相当于本来环境就差还自断臂膀用户本来就卡这下更卡了。
当前很多应用都是要求低时延的它们可不想用TCP如此复杂的机制而是想根据自己的场景实现自己的可靠和连接保证。例如如果应用自己觉得有的包丢了就丢了没必要重传了就可以算了有的比较重要则应用自己重传而不依赖于TCP。有的前面的包没到后面的包到了那就先给客户展示后面的嘛干嘛非得等到齐了呢如果网络不好丢了包那不能退缩啊要尽快传啊速度不能降下来啊要挤占带宽抢在客户失去耐心之前到达。
由于UDP十分简单基本啥都没做也就给了应用“城会玩”的机会。就像在和平年代每个人应该有独立的思考和行为应该可靠并且礼让但是如果在战争年代往往不太需要过于独立的思考而需要士兵简单服从命令就可以了。
曾国藩说哪支部队需要诱敌牺牲也就牺牲了相当于包丢了就丢了。两军狭路相逢的时候曾国藩说上没有带宽也要上这才给了曾国藩运筹帷幄城会玩的机会。同理如果你实现的应用需要有自己的连接策略可靠保证时延要求使用UDP然后再应用层实现这些是再好不过了。
## 基于UDP的“城会玩”的五个例子
我列举几种“城会玩”的例子。
### “城会玩”一网页或者APP的访问
原来访问网页和手机APP都是基于HTTP协议的。HTTP协议是基于TCP的建立连接都需要多次交互对于时延比较大的目前主流的移动互联网来讲建立一次连接需要的时间会比较长然而既然是移动中TCP可能还会断了重连也是很耗时的。而且目前的HTTP协议往往采取多个数据通道共享一个连接的情况这样本来为了加快传输速度但是TCP的严格顺序策略使得哪怕共享通道前一个不来后一个和前一个即便没关系也要等着时延也会加大。
而**QUIC**(全称**Quick UDP Internet Connections****快速UDP互联网连接**是Google提出的一种基于UDP改进的通信协议其目的是降低网络通信的延迟提供更好的用户互动体验。
QUIC在应用层上会自己实现快速连接建立、减少重传时延自适应拥塞控制是应用层“城会玩”的代表。这一节主要是讲UDPQUIC我们放到应用层去讲。
### “城会玩”二:流媒体的协议
现在直播比较火直播协议多使用RTMP这个协议我们后面的章节也会讲而这个RTMP协议也是基于TCP的。TCP的严格顺序传输要保证前一个收到了下一个才能确认如果前一个收不到下一个就算包已经收到了在缓存里面也需要等着。对于直播来讲这显然是不合适的因为老的视频帧丢了其实也就丢了就算再传过来用户也不在意了他们要看新的了如果老是没来就等着卡顿了新的也看不了那就会丢失客户所以直播实时性比较比较重要宁可丢包也不要卡顿的。
另外,对于丢包,其实对于视频播放来讲,有的包可以丢,有的包不能丢,因为视频的连续帧里面,有的帧重要,有的不重要,如果必须要丢包,隔几个帧丢一个,其实看视频的人不会感知,但是如果连续丢帧,就会感知了,因而在网络不好的情况下,应用希望选择性的丢帧。
还有就是当网络不好的时候TCP协议会主动降低发送速度这对本来当时就卡的看视频来讲是要命的应该应用层马上重传而不是主动让步。因而很多直播应用都基于UDP实现了自己的视频传输协议。
### “城会玩”三:实时游戏
游戏有一个特点,就是实时性比较高。快一秒你干掉别人,慢一秒你被别人爆头,所以很多职业玩家会买非常专业的鼠标和键盘,争分夺秒。
因而实时游戏中客户端和服务端要建立长连接来保证实时传输。但是游戏玩家很多服务器却不多。由于维护TCP连接需要在内核维护一些数据结构因而一台机器能够支撑的TCP连接数目是有限的然后UDP由于是没有连接的在异步IO机制引入之前常常是应对海量客户端连接的策略。
另外还是TCP的强顺序问题对战的游戏对网络的要求很简单玩家通过客户端发送给服务器鼠标和键盘行走的位置服务器会处理每个用户发送过来的所有场景处理完再返回给客户端客户端解析响应渲染最新的场景展示给玩家。
如果出现一个数据包丢失所有事情都需要停下来等待这个数据包重发。客户端会出现等待接收数据然而玩家并不关心过期的数据激战中卡1秒等能动了都已经死了。
游戏对实时要求较为严格的情况下采用自定义的可靠UDP协议自定义重传策略能够把丢包产生的延迟降到最低尽量减少网络问题对游戏性造成的影响。
### “城会玩”四IoT物联网
一方面物联网领域终端资源少很可能只是个内存非常小的嵌入式系统而维护TCP协议代价太大另一方面物联网对实时性要求也很高而TCP还是因为上面的那些原因导致时延大。Google旗下的Nest建立Thread Group推出了物联网通信协议Thread就是基于UDP协议的。
### “城会玩”五:移动通信领域
在4G网络里移动流量上网的数据面对的协议GTP-U是基于UDP的。因为移动网络协议比较复杂而GTP协议本身就包含复杂的手机上线下线的通信协议。如果基于TCPTCP的机制就显得非常多余这部分协议我会在后面的章节单独讲解。
## 小结
好了,这节就到这里了,我们来总结一下:
<li>
如果将TCP比作成熟的社会人UDP则是头脑简单的小朋友。TCP复杂UDP简单TCP维护连接UDP谁都相信TCP会坚持知进退UDP愣头青一个勇往直前
</li>
<li>
UDP虽然简单但它有简单的用法。它可以用在环境简单、需要多播、应用层自己控制传输的地方。例如DHCP、VXLAN、QUIC等。
</li>
最后,给你留两个思考题吧。
<li>
都说TCP是面向连接的在计算机看来怎么样才算一个连接呢
</li>
<li>
你知道TCP的连接是如何建立又是如何关闭的吗
</li>
欢迎你留言和讨论。趣谈网络协议,我们下期见!

View File

@@ -0,0 +1,167 @@
<audio id="audio" title="第11讲 | TCP协议因性恶而复杂先恶后善反轻松" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/50/32/50f570ae1182a6dd989401393329cc32.mp3"></audio>
上一节我们讲的UDP基本上包括了传输层所必须的端口字段。它就像我们小时候一样简单相信“网之初性本善不丢包不乱序”。
后来呢我们都慢慢长大了解了社会的残酷变得复杂而成熟就像TCP协议一样。它之所以这么复杂那是因为它秉承的是“性恶论”。它天然认为网络环境是恶劣的丢包、乱序、重传拥塞都是常有的事情一言不合就可能送达不了因而要从算法层面来保证可靠性。
## TCP包头格式
我们先来看TCP头的格式。从这个图上可以看出它比UDP复杂得多。
<img src="https://static001.geekbang.org/resource/image/64/bf/642947c94d6682a042ad981bfba39fbf.jpg" alt="">
首先源端口号和目标端口号是不可少的这一点和UDP是一样的。如果没有这两个端口号。数据就不知道应该发给哪个应用。
接下来是包的序号。为什么要给包编号呢?当然是为了解决乱序的问题。不编好号怎么确认哪个应该先来,哪个应该后到呢。编号是为了解决乱序问题。既然是社会老司机,做事当然要稳重,一件件来,面临再复杂的情况,也临危不乱。
还应该有的就是确认序号。发出去的包应该有确认,要不然我怎么知道对方有没有收到呢?如果没有收到就应该重新发送,直到送达。这个可以解决不丢包的问题。作为老司机,做事当然要靠谱,答应了就要做到,暂时做不到也要有个回复。
TCP是靠谱的协议但是这不能说明它面临的网络环境好。从IP层面来讲如果网络状况的确那么差是没有任何可靠性保证的而作为IP的上一层TCP也无能为力唯一能做的就是更加努力不断重传通过各种算法保证。也就是说对于TCP来讲IP层你丢不丢包我管不着但是我在我的层面上会努力保证可靠性。
这有点像如果你在北京,和客户约十点见面,那么你应该清楚堵车是常态,你干预不了,也控制不了,你唯一能做的就是早走。打车不行就改乘地铁,尽力不失约。
接下来有一些状态位。例如SYN是发起一个连接ACK是回复RST是重新连接FIN是结束连接等。TCP是面向连接的因而双方要维护连接的状态这些带状态位的包的发送会引起双方的状态变更。
不像小时候,随便一个不认识的小朋友都能玩在一起,人大了,就变得礼貌,优雅而警觉,人与人遇到会互相热情的寒暄,离开会不舍地道别,但是人与人之间的信任会经过多次交互才能建立。
还有一个重要的就是窗口大小。TCP要做流量控制通信双方各声明一个窗口标识自己当前能够的处理能力别发送的太快撑死我也别发的太慢饿死我。
作为老司机做事情要有分寸待人要把握尺度既能适当提出自己的要求又不强人所难。除了做流量控制以外TCP还会做拥塞控制对于真正的通路堵车不堵车它无能为力唯一能做的就是控制自己也即控制发送的速度。不能改变世界就改变自己嘛。
作为老司机,要会自我控制,知进退,知道什么时候应该坚持,什么时候应该让步。
通过对TCP头的解析我们知道要掌握TCP协议重点应该关注以下几个问题
<li>
顺序问题 ,稳重不乱;
</li>
<li>
丢包问题,承诺靠谱;
</li>
<li>
连接维护,有始有终;
</li>
<li>
流量控制,把握分寸;
</li>
<li>
拥塞控制,知进知退。
</li>
## TCP的三次握手
所有的问题,首先都要先建立一个连接,所以我们先来看连接维护问题。
TCP的连接建立我们常常称为三次握手。
A您好我是A。
B您好A我是B。
A您好B。
我们也常称为“请求-&gt;应答-&gt;应答之应答”的三个回合。这个看起来简单,其实里面还是有很多的学问,很多的细节。
首先,为什么要三次,而不是两次?按说两个人打招呼,一来一回就可以了啊?为了可靠,为什么不是四次?
我们还是假设这个通路是非常不可靠的A要发起一个连接当发了第一个请求杳无音信的时候会有很多的可能性比如第一个请求包丢了再如没有丢但是绕了弯路超时了还有B没有响应不想和我连接。
A不能确认结果于是再发再发。终于有一个请求包到了B但是请求包到了B的这个事情目前A还是不知道的A还有可能再发。
B收到了请求包就知道了A的存在并且知道A要和它建立连接。如果B不乐意建立连接则A会重试一阵后放弃连接建立失败没有问题如果B是乐意建立连接的则会发送应答包给A。
当然对于B来说这个应答包也是一入网络深似海不知道能不能到达A。这个时候B自然不能认为连接是建立好了因为应答包仍然会丢会绕弯路或者A已经挂了都有可能。
而且这个时候B还能碰到一个诡异的现象就是A和B原来建立了连接做了简单通信后结束了连接。还记得吗A建立连接的时候请求包重复发了几次有的请求包绕了一大圈又回来了B会认为这也是一个正常的的请求的话因此建立了连接可以想象这个连接不会进行下去也没有个终结的时候纯属单相思了。因而两次握手肯定不行。
B发送的应答可能会发送多次但是只要一次到达AA就认为连接已经建立了因为对于A来讲他的消息有去有回。A会给B发送应答之应答而B也在等这个消息才能确认连接的建立只有等到了这个消息对于B来讲才算它的消息有去有回。
当然A发给B的应答之应答也会丢也会绕路甚至B挂了。按理来说还应该有个应答之应答之应答这样下去就没底了。所以四次握手是可以的四十次都可以关键四百次也不能保证就真的可靠了。只要双方的消息都有去有回就基本可以了。
好在大部分情况下A和B建立了连接之后A会马上发送数据的一旦A发送数据则很多问题都得到了解决。例如A发给B的应答丢了当A后续发送的数据到达的时候B可以认为这个连接已经建立或者B压根就挂了A发送的数据会报错说B不可达A就知道B出事情了。
当然你可以说A比较坏就是不发数据建立连接后空着。我们在程序设计的时候可以要求开启keepalive机制即使没有真实的数据包也有探活包。
另外你作为服务端B的程序设计者对于A这种长时间不发包的客户端可以主动关闭从而空出资源来给其他客户端使用。
三次握手除了双方建立连接外,主要还是为了沟通一件事情,就是**TCP包的序号的问题**。
A要告诉B我这面发起的包的序号起始是从哪个号开始的B同样也要告诉AB发起的包的序号起始是从哪个号开始的。为什么序号不能都从1开始呢因为这样往往会出现冲突。
例如A连上B之后发送了1、2、3三个包但是发送3的时候中间丢了或者绕路了于是重新发送后来A掉线了重新连上B后序号又从1开始然后发送2但是压根没想发送3但是上次绕路的那个3又回来了发给了BB自然认为这就是下一个包于是发生了错误。
因而每个连接都要有不同的序号。这个序号的起始序号是随着时间变化的可以看成一个32位的计数器每4微秒加一如果计算一下如果到重复需要4个多小时那个绕路的包早就死翘翘了因为我们都知道IP包头里面有个TTL也即生存时间。
好了,双方终于建立了信任,建立了连接。前面也说过,为了维护这个连接,双方都要维护一个状态机,在连接建立的过程中,双方的状态变化时序图就像这样。
<img src="https://static001.geekbang.org/resource/image/c0/08/c067fe62f49e8152368c7be9d91adc08.jpg" alt="">
一开始客户端和服务端都处于CLOSED状态。先是服务端主动监听某个端口处于LISTEN状态。然后客户端主动发起连接SYN之后处于SYN-SENT状态。服务端收到发起的连接返回SYN并且ACK客户端的SYN之后处于SYN-RCVD状态。客户端收到服务端发送的SYN和ACK之后发送ACK的ACK之后处于ESTABLISHED状态因为它一发一收成功了。服务端收到ACK的ACK之后处于ESTABLISHED状态因为它也一发一收了。
## TCP四次挥手
好了,说完了连接,接下来说一说“拜拜”,好说好散。这常被称为四次挥手。
AB啊我不想玩了。
B你不想玩了啊我知道了。
这个时候还只是A不想玩了也即A不会再发送数据但是B能不能在ACK的时候直接关闭呢当然不可以了很有可能A是发完了最后的数据就准备不玩了但是B还没做完自己的事情还是可以发送数据的所以称为半关闭的状态。
这个时候A可以选择不再接收数据了也可以选择最后再接收一段数据等待B也主动关闭。
BA啊好吧我也不玩了拜拜。
A好的拜拜。
这样整个连接就关闭了。但是这个过程有没有异常情况呢?当然有,上面是和平分手的场面。
A开始说“不玩了”B说“知道了”这个回合是没什么问题的因为在此之前双方还处于合作的状态如果A说“不玩了”没有收到回复则A会重新发送“不玩了”。但是这个回合结束之后就有可能出现异常情况了因为已经有一方率先撕破脸。
一种情况是A说完“不玩了”之后直接跑路是会有问题的因为B还没有发起结束而如果A跑路B就算发起结束也得不到回答B就不知道该怎么办了。另一种情况是A说完“不玩了”B直接跑路也是有问题的因为A不知道B是还有事情要处理还是过一会儿会发送结束。
那怎么解决这些问题呢TCP协议专门设计了几个状态来处理这些问题。我们来看断开连接的时候的**状态时序图**。
<img src="https://static001.geekbang.org/resource/image/bf/13/bf1254f85d527c77cc4088a35ac11d13.jpg" alt="">
断开的时候我们可以看到当A说“不玩了”就进入FIN_WAIT_1的状态B收到“A不玩”的消息后发送知道了就进入CLOSE_WAIT的状态。
A收到“B说知道了”就进入FIN_WAIT_2的状态如果这个时候B直接跑路则A将永远在这个状态。TCP协议里面并没有对这个状态的处理但是Linux有可以调整tcp_fin_timeout这个参数设置一个超时时间。
如果B没有跑路发送了“B也不玩了”的请求到达A时A发送“知道B也不玩了”的ACK后从FIN_WAIT_2状态结束按说A可以跑路了但是最后的这个ACK万一B收不到呢则B会重新发一个“B不玩了”这个时候A已经跑路了的话B就再也收不到ACK了因而TCP协议要求A最后等待一段时间TIME_WAIT这个时间要足够长长到如果B没收到ACK的话“B说不玩了”会重发的A会重新发一个ACK并且足够时间到达B。
A直接跑路还有一个问题是A的端口就直接空出来了但是B不知道B原来发过的很多包很可能还在路上如果A的端口被一个新的应用占用了这个新的应用会收到上个连接中B发过来的包虽然序列号是重新生成的但是这里要上一个双保险防止产生混乱因而也需要等足够长的时间等到原来B发送的所有的包都死翘翘再空出端口来。
等待的时间设为2MSL**MSL**是**Maximum Segment Lifetime****报文最大生存时间**它是任何报文在网络上存在的最长时间超过这个时间报文将被丢弃。因为TCP报文基于是IP协议的而IP头中有一个TTL域是IP数据报可以经过的最大路由数每经过一个处理他的路由器此值就减1当此值为0则数据报将被丢弃同时发送ICMP报文通知源主机。协议规定MSL为2分钟实际应用中常用的是30秒1分钟和2分钟等。
还有一个异常情况就是B超过了2MSL的时间依然没有收到它发的FIN的ACK怎么办呢按照TCP的原理B当然还会重发FIN这个时候A再收到这个包之后A就表示我已经在这里等了这么长时间了已经仁至义尽了之后的我就都不认了于是就直接发送RSTB就知道A早就跑了。
## TCP状态机
将连接建立和连接断开的两个时序状态图综合起来就是这个著名的TCP的状态机。学习的时候比较建议将这个状态机和时序状态机对照着看不然容易晕。
<img src="https://static001.geekbang.org/resource/image/fd/2a/fd45f9ad6ed575ea6bfdaafeb3bfb62a.jpg" alt="">
在这个图中加黑加粗的部分是上面说到的主要流程其中阿拉伯数字的序号是连接过程中的顺序而大写中文数字的序号是连接断开过程中的顺序。加粗的实线是客户端A的状态变迁加粗的虚线是服务端B的状态变迁。
## 小结
好了,这一节就到这里了,我来做一个总结:
<li>
TCP包头很复杂但是主要关注五个问题顺序问题丢包问题连接维护流量控制拥塞控制
</li>
<li>
连接的建立是经过三次握手,断开的时候四次挥手,一定要掌握的我画的那个状态图。
</li>
最后,给你留两个思考题。
<li>
TCP的连接有这么多的状态你知道如何在系统中查看某个连接的状态吗
</li>
<li>
这一节仅仅讲了连接维护问题,其实为了维护连接的状态,还有其他的数据结构来处理其他的四个问题,那你知道是什么吗?
</li>
欢迎你留言和我讨论。趣谈网络协议,我们下期见!

View File

@@ -0,0 +1,232 @@
<audio id="audio" title="第12讲 | TCP协议西行必定多妖孽恒心智慧消磨难" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/16/28/1621d135d4d92756ca0d9440c0651c28.mp3"></audio>
我们前面说到玄奘西行,要出网关。既然出了网关,那就是在公网上传输数据,公网往往是不可靠的,因而需要很多的机制去保证传输的可靠性,这里面需要恒心,也即各种**重传的策略**,还需要有智慧,也就是说,这里面包含着**大量的算法**。
## 如何做个靠谱的人?
TCP想成为一个成熟稳重的人成为一个靠谱的人。那一个人怎么样才算靠谱呢咱们工作中经常就有这样的场景比如你交代给下属一个事情以后下属到底能不能做到做到什么程度什么时候能够交付往往就会有应答有回复。这样处理事情的过程中一旦有异常你也可以尽快知道而不是交代完之后就石沉大海过了一个月再问他说啊我不记得了。
对应到网络协议上,就是客户端每发送的一个包,服务器端都应该有个回复,如果服务器端超过一定的时间没有回复,客户端就会重新发送这个包,直到有回复。
这个发送应答的过程是什么样呢?可以是**上一个收到了应答,再发送下一个**。这种模式有点像两个人直接打电话,你一句,我一句。但是这种方式的缺点是效率比较低。如果一方在电话那头处理的时间比较长,这一头就要干等着,双方都没办法干其他事情。咱们在日常工作中也不是这样的,不能你交代你的下属办一件事情,就一直打着电话看着他做,而是应该他按照你的安排,先将事情记录下来,办完一件回复一件。在他办事情的过程中,你还可以同时交代新的事情,这样双方就并行了。
如果使⽤这种模式,其实需要你和你的下属就不能靠脑⼦了,⽽是要都准备⼀个本⼦,你每交代下属⼀个事情,双方的本子都要记录⼀下。
当你的下属做完⼀件事情,就回复你,做完了,你就在你的本⼦上将这个事情划去。同时你的本⼦上每件事情都有时限,如果超过了时限下属还没有回复,你就要主动重新交代⼀下:上次那件事情,你还没回复我,咋样啦?
既然多件事情可以一起处理那就需要给每个事情编个号防止弄错了。例如程序员平时看任务的时候都会看JIRA的ID而不是每次都要描述一下具体的事情。在大部分情况下对于事情的处理是按照顺序来的先来的先处理这就给应答和汇报工作带来了方便。等开周会的时候每个程序员都可以将JIRA ID的列表拉出来说以上的都做完了⽽不⽤⼀个个说。
## 如何实现一个靠谱的协议?
TCP协议使用的也是同样的模式。为了保证顺序性每一个包都有一个ID。在建立连接的时候会商定起始的ID是什么然后按照ID一个个发送。为了保证不丢包对于发送的包都要进行应答但是这个应答也不是一个一个来的而是会应答某个之前的ID表示都收到了这种模式称为**累计确认**或者**累计应答****cumulative acknowledgment**)。
为了记录所有发送的包和接收的包TCP也需要发送端和接收端分别都有缓存来保存这些记录。发送端的缓存里是按照包的ID一个个排列根据处理的情况分成四个部分。
第一部分:发送了并且已经确认的。这部分就是你交代下属的,并且也做完了的,应该划掉的。
第二部分:发送了并且尚未确认的。这部分是你交代下属的,但是还没做完的,需要等待做完的回复之后,才能划掉。
第三部分:没有发送,但是已经等待发送的。这部分是你还没有交代给下属,但是马上就要交代的。
第四部分:没有发送,并且暂时还不会发送的。这部分是你还没有交代给下属,而且暂时还不会交代给下属的。
这里面为什么要区分第三部分和第四部分呢?没交代的,一下子全交代了不就完了吗?
这就是我们上一节提到的十个词口诀里的“流量控制,把握分寸”。作为项目管理人员,你应该根据以往的工作情况和这个员工反馈的能力、抗压力等,先在心中估测一下,这个人一天能做多少工作。如果工作布置少了,就会不饱和;如果工作布置多了,他就会做不完;如果你使劲逼迫,人家可能就要辞职了。
到底一个员工能够同时处理多少事情呢在TCP里接收端会给发送端报一个窗口的大小叫**Advertised window**。这个窗口的大小应该等于上面的第二部分加上第三部分,就是已经交代了没做完的加上马上要交代的。超过这个窗口的,接收端做不过来,就不能发送了。
于是,发送端需要保持下面的数据结构。
<img src="https://static001.geekbang.org/resource/image/dd/44/dd67ba62279a3849c11ffc1deea25d44.jpg" alt="">
<li>
LastByteAcked第一部分和第二部分的分界线
</li>
<li>
LastByteSent第二部分和第三部分的分界线
</li>
<li>
LastByteAcked + AdvertisedWindow第三部分和第四部分的分界线
</li>
对于接收端来讲,它的缓存里记录的内容要简单一些。
第一部分:接受并且确认过的。也就是我领导交代给我,并且我做完的。
第二部分:还没接收,但是马上就能接收的。也即是我自己的能够接受的最大工作量。
第三部分:还没接收,也没法接收的。也即超过工作量的部分,实在做不完。
对应的数据结构就像这样。<br>
<br>
<img src="https://static001.geekbang.org/resource/image/9d/be/9d597af268016f67caa14178627188be.jpg" alt="">
<li>
MaxRcvBuffer最大缓存的量
</li>
<li>
LastByteRead之后是已经接收了但是还没被应用层读取的
</li>
<li>
NextByteExpected是第一部分和第二部分的分界线。
</li>
第二部分的窗口有多大呢?
NextByteExpected和LastByteRead的差其实是还没被应用层读取的部分占用掉的MaxRcvBuffer的量我们定义为A。
AdvertisedWindow其实是MaxRcvBuffer减去A。
也就是AdvertisedWindow=MaxRcvBuffer-((NextByteExpected-1)-LastByteRead)。
那第二部分和第三部分的分界线在哪里呢NextByteExpected加AdvertisedWindow就是第二部分和第三部分的分界线其实也就是LastByteRead加上MaxRcvBuffer。
其中第二部分里面,由于受到的包可能不是顺序的,会出现空档,只有和第一部分连续的,可以马上进行回复,中间空着的部分需要等待,哪怕后面的已经来了。
## 顺序问题与丢包问题
接下来我们结合一个例子来看。
还是刚才的图在发送端来看1、2、3已经发送并确认4、5、6、7、8、9都是发送了还没确认10、11、12是还没发出的13、14、15是接收方没有空间不准备发的。
在接收端来看1、2、3、4、5是已经完成ACK但是没读取的6、7是等待接收的8、9是已经接收但是没有ACK的。
发送端和接收端当前的状态如下:
<li>
1、2、3没有问题双方达成了一致。
</li>
<li>
4、5接收方说ACK了但是发送方还没收到有可能丢了有可能在路上。
</li>
<li>
6、7、8、9肯定都发了但是8、9已经到了但是6、7没到出现了乱序缓存着但是没办法ACK。
</li>
根据这个例子,我们可以知道,顺序问题和丢包问题都有可能发生,所以我们先来看**确认与重发的机制**。
假设4的确认到了不幸的是5的ACK丢了6、7的数据包丢了这该怎么办呢
一种方法就是**超时重试**也即对每一个发送了但是没有ACK的包都有设一个定时器超过了一定的时间就重新尝试。但是这个超时的时间如何评估呢这个时间不宜过短时间必须大于往返时间RTT否则会引起不必要的重传。也不宜过长这样超时时间变长访问就变慢了。
估计往返时间需要TCP通过采样RTT的时间然后进行加权平均算出一个值而且这个值还是要不断变化的因为网络状况不断地变化。除了采样RTT还要采样RTT的波动范围计算出一个估计的超时时间。由于重传时间是不断变化的我们称为**自适应重传算法****Adaptive Retransmission Algorithm**)。
如果过一段时间5、6、7都超时了就会重新发送。接收方发现5原来接收过于是丢弃56收到了发送ACK要求下一个是77不幸又丢了。当7再次超时的时候有需要重传的时候TCP的策略是**超时间隔加倍**。**每当遇到一次超时重传的时候,都会将下一次超时时间间隔设为先前值的两倍**。**两次超时,就说明网络环境差,不宜频繁反复发送。**
超时触发重传存在的问题是,超时周期可能相对较长。那是不是可以有更快的方式呢?
有一个可以快速重传的机制当接收方收到一个序号大于下一个所期望的报文段时就会检测到数据流中的一个间隔于是它就会发送冗余的ACK仍然ACK的是期望接收的报文段。而当客户端收到三个冗余的ACK后就会在定时器过期之前重传丢失的报文段。
例如接收方发现6收到了8也收到了但是7还没来那肯定是丢了于是发送6的ACK要求下一个是7。接下来收到后续的包仍然发送6的ACK要求下一个是7。当客户端收到3个重复ACK就会发现7的确丢了不等超时马上重发。
还有一种方式称为**Selective Acknowledgment** **SACK**。这种方式需要在TCP头里加一个SACK的东西可以将缓存的地图发送给发送方。例如可以发送ACK6、SACK8、SACK9有了地图发送方一下子就能看出来是7丢了。
## 流量控制问题
我们再来看流量控制机制,在对于包的确认中,同时会携带一个窗口的大小。
我们先假设窗口不变的情况窗口始终为9。4的确认来的时候会右移一个这个时候第13个包也可以发送了。
<img src="https://static001.geekbang.org/resource/image/af/87/af16ecdfabf97f696d8133a20818fd87.jpg" alt="">
这个时候假设发送端发送过猛会将第三部分的10、11、12、13全部发送完毕之后就停止发送了未发送可发送部分为0。
<img src="https://static001.geekbang.org/resource/image/e0/35/e011cb0e56f43bae942f0b7ab7407b35.jpg" alt="">
当对于包5的确认到达的时候在客户端相当于窗口再滑动了一格这个时候才可以有更多的包可以发送了例如第14个包才可以发送。
<img src="https://static001.geekbang.org/resource/image/f5/c2/f5a4fcc035d1bb2d7e11c38391d768c2.jpg" alt="">
如果接收方实在处理的太慢导致缓存中没有空间了可以通过确认信息修改窗口的大小甚至可以设置为0则发送方将暂时停止发送。
我们假设一个极端情况接收端的应用一直不读取缓存中的数据当数据包6确认后窗口大小就不能再是9了就要缩小一个变为8。
<img src="https://static001.geekbang.org/resource/image/95/9d/953e6706cfb5083e1f25b267505f5c9d.jpg" alt="">
这个新的窗口8通过6的确认消息到达发送端的时候你会发现窗口没有平行右移而是仅仅左面的边右移了窗口的大小从9改成了8。
<img src="https://static001.geekbang.org/resource/image/0a/1f/0a9265c63d5e0fb08c442ea0a7cffa1f.jpg" alt="">
如果接收端还是一直不处理数据则随着确认的包越来越多窗口越来越小直到为0。
<img src="https://static001.geekbang.org/resource/image/c2/4a/c24c414c31bd5deb346f98417ecdb74a.jpg" alt="">
当这个窗口通过包14的确认到达发送端的时候发送端的窗口也调整为0停止发送。
<img src="https://static001.geekbang.org/resource/image/89/cb/89fe7b73e40363182b13e3d9c9aa2acb.jpg" alt=""><br>
如果这样的话,发送方会定时发送窗口探测数据包,看是否有机会调整窗口的大小。当接收方比较慢的时候,要防止低能窗口综合征,别空出一个字节来就赶快告诉发送方,然后马上又填满了,可以当窗口太小的时候,不更新窗口,直到达到一定大小,或者缓冲区一半为空,才更新窗口。
这就是我们常说的流量控制。
## 拥塞控制问题
最后我们看一下拥塞控制的问题也是通过窗口的大小来控制的前面的滑动窗口rwnd是怕发送方把接收方缓存塞满而拥塞窗口cwnd是怕把网络塞满。
这里有一个公式 LastByteSent - LastByteAcked &lt;= min {cwnd, rwnd} ,是拥塞窗口和滑动窗口共同控制发送的速度。
那发送方怎么判断网络是不是慢呢这其实是个挺难的事情因为对于TCP协议来讲他压根不知道整个网络路径都会经历什么对他来讲就是一个黑盒。TCP发送包常被比喻为往一个水管里面灌水而TCP的拥塞控制就是在不堵塞不丢包的情况下尽量发挥带宽。
水管有粗细,网络有带宽,也即每秒钟能够发送多少数据;水管有长度,端到端有时延。在理想状态下,水管里面水的量=水管粗细 x 水管长度。对于到网络上,通道的容量 = 带宽 × 往返延迟。
如果我们设置发送窗口,使得发送但未确认的包为为通道的容量,就能够撑满整个管道。
<img src="https://static001.geekbang.org/resource/image/db/e6/db8510541662281175803c7f9d1fcae6.jpg" alt="">
如图所示假设往返时间为8s去4s回4s每秒发送一个包每个包1024byte。已经过去了8s则8个包都发出去了其中前4个包已经到达接收端但是ACK还没有返回不能算发送成功。5-8后四个包还在路上还没被接收。这个时候整个管道正好撑满在发送端已发送未确认的为8个包正好等于带宽也即每秒发送1个包乘以来回时间8s。
如果我们在这个基础上再调大窗口,使得单位时间内更多的包可以发送,会出现什么现象呢?
我们来想原来发送一个包从一端到达另一端假设一共经过四个设备每个设备处理一个包时间耗费1s所以到达另一端需要耗费4s如果发送的更加快速则单位时间内会有更多的包到达这些中间设备这些设备还是只能每秒处理一个包的话多出来的包就会被丢弃这是我们不想看到的。
这个时候我们可以想其他的办法例如这个四个设备本来每秒处理一个包但是我们在这些设备上加缓存处理不过来的在队列里面排着这样包就不会丢失但是缺点是会增加时延这个缓存的包4s肯定到达不了接收端了如果时延达到一定程度就会超时重传也是我们不想看到的。
于是TCP的拥塞控制主要来避免两种现象**包丢失**和**超时重传**。一旦出现了这些现象就说明,发送速度太快了,要慢一点。但是一开始我怎么知道速度多快呢,我怎么知道应该把窗口调整到多大呢?
如果我们通过漏斗往瓶子里灌水,我们就知道,不能一桶水一下子倒进去,肯定会溅出来,要一开始慢慢的倒,然后发现总能够倒进去,就可以越倒越快。这叫作慢启动。
一条TCP连接开始cwnd设置为一个报文段一次只能发送一个当收到这一个确认的时候cwnd加一于是一次能够发送两个当这两个的确认到来的时候每个确认cwnd加一两个确认cwnd加二于是一次能够发送四个当这四个的确认到来的时候每个确认cwnd加一四个确认cwnd加四于是一次能够发送八个。可以看出这是**指数性的增长**。
涨到什么时候是个头呢有一个值ssthresh为65535个字节当超过这个值的时候就要小心一点了不能倒这么快了可能快满了再慢下来。
每收到一个确认后cwnd增加1/cwnd我们接着上面的过程来一次发送八个当八个确认到来的时候每个确认增加1/8八个确认一共cwnd增加1于是一次能够发送九个变成了线性增长。
但是线性增长还是增长,还是越来越多,直到有一天,水满则溢,出现了拥塞,这时候一般就会一下子降低倒水的速度,等待溢出的水慢慢渗下去。
拥塞的一种表现形式是丢包需要超时重传这个时候将sshresh设为cwnd/2将cwnd设为1重新开始慢启动。这真是一旦超时重传马上回到解放前。但是这种方式太激进了将一个高速的传输速度一下子停了下来会造成网络卡顿。
前面我们讲过**快速重传算法**。当接收端发现丢了一个中间包的时候发送三次前一个包的ACK于是发送端就会快速地重传不必等待超时再重传。TCP认为这种情况不严重因为大部分没丢只丢了一小部分cwnd减半为cwnd/2然后sshthresh = cwnd当三个包返回的时候cwnd = sshthresh + 3也就是没有一夜回到解放前而是还在比较高的值呈线性增长。
<img src="https://static001.geekbang.org/resource/image/19/d2/1910bc1a0048d4de7b2128eb0f5dbcd2.jpg" alt="">
就像前面说的一样正是这种知进退使得时延很重要的情况下反而降低了速度。但是如果你仔细想一下TCP的拥塞控制主要来避免的两个现象都是有问题的。
**第一个问题**是丢包并不代表着通道满了,也可能是管子本来就漏水。例如公网上带宽不满也会丢包,这个时候就认为拥塞了,退缩了,其实是不对的。
**第二个问题**是TCP的拥塞控制要等到将中间设备都填充满了才发生丢包从而降低速度这时候已经晚了。其实TCP只要填满管道就可以了不应该接着填直到连缓存也填满。
为了优化这两个问题,后来有了**TCP BBR拥塞算法**。它企图找到一个平衡点,就是通过不断地加快发送速度,将管道填满,但是不要填满中间设备的缓存,因为这样时延会增加,在这个平衡点可以很好的达到高带宽和低时延的平衡。
<img src="https://static001.geekbang.org/resource/image/a2/4c/a2b3a5df5eca52e302b75824e4bbbd4c.jpg" alt="">
## 小结
好了,这一节我们就到这里,总结一下:
<li>
顺序问题、丢包问题、流量控制都是通过滑动窗口来解决的,这其实就相当于你领导和你的工作备忘录,布置过的工作要有编号,干完了有反馈,活不能派太多,也不能太少;
</li>
<li>
拥塞控制是通过拥塞窗口来解决的,相当于往管道里面倒水,快了容易溢出,慢了浪费带宽,要摸着石头过河,找到最优值。
</li>
最后留两个思考题:
<li>
TCP的BBR听起来很牛你知道他是如何达到这个最优点的嘛
</li>
<li>
学会了UDP和TCP你知道如何基于这两种协议写程序吗这样的程序会有什么坑呢
</li>
欢迎你留言和我讨论。趣谈网络协议,我们下期见!

View File

@@ -0,0 +1,146 @@
<audio id="audio" title="第13讲 | 套接字SocketTalk is cheap, show me the code" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/75/6f/75b47bb7d89a675b096c50ed84d2da6f.mp3"></audio>
前面讲完了TCP和UDP协议还没有上手过这一节咱们讲讲基于TCP和UDP协议的Socket编程。
在讲TCP和UDP协议的时候我们分客户端和服务端在写程序的时候我们也同样这样分。
Socket这个名字很有意思可以作插口或者插槽讲。虽然我们是写软件程序但是你可以想象为弄一根网线一头插在客户端一头插在服务端然后进行通信。所以在通信之前双方都要建立一个Socket。
在建立Socket的时候应该设置什么参数呢Socket编程进行的是端到端的通信往往意识不到中间经过多少局域网多少路由器因而能够设置的参数也只能是端到端协议之上网络层和传输层的。
在网络层Socket函数需要指定到底是IPv4还是IPv6分别对应设置为AF_INET和AF_INET6。另外还要指定到底是TCP还是UDP。还记得咱们前面讲过的TCP协议是基于数据流的所以设置为SOCK_STREAM而UDP是基于数据报的因而设置为SOCK_DGRAM。
## 基于TCP协议的Socket程序函数调用过程
两端创建了Socket之后接下来的过程中TCP和UDP稍有不同我们先来看TCP。
TCP的服务端要先监听一个端口一般是先调用bind函数给这个Socket赋予一个IP地址和端口。为什么需要端口呢要知道你写的是一个应用程序当一个网络包来的时候内核要通过TCP头里面的这个端口来找到你这个应用程序把包给你。为什么要IP地址呢有时候一台机器会有多个网卡也就会有多个IP地址你可以选择监听所有的网卡也可以选择监听一个网卡这样只有发给这个网卡的包才会给你。
当服务端有了IP和端口号就可以调用listen函数进行监听。在TCP的状态图里面有一个listen状态当调用这个函数之后服务端就进入了这个状态这个时候客户端就可以发起连接了。
在内核中为每个Socket维护两个队列。一个是已经建立了连接的队列这时候连接三次握手已经完毕处于established状态一个是还没有完全建立连接的队列这个时候三次握手还没完成处于syn_rcvd的状态。
接下来服务端调用accept函数拿出一个已经完成的连接进行处理。如果还没有完成就要等着。
在服务端等待的时候客户端可以通过connect函数发起连接。先在参数中指明要连接的IP地址和端口号然后开始发起三次握手。内核会给客户端分配一个临时的端口。一旦握手成功服务端的accept就会返回另一个Socket。
这是一个经常考的知识点就是监听的Socket和真正用来传数据的Socket是两个一个叫作**监听Socket**,一个叫作**已连接Socket**。
连接建立成功之后双方开始通过read和write函数来读写数据就像往一个文件流里面写东西一样。
这个图就是基于TCP协议的Socket程序函数调用过程。
<img src="https://static001.geekbang.org/resource/image/78/2d/78d145a72f9473fc1fyy0847d9b8212d.jpg" alt="">
说TCP的Socket就是一个文件流是非常准确的。因为Socket在Linux中就是以文件的形式存在的。除此之外还存在文件描述符。写入和读出也是通过文件描述符。
在内核中Socket是一个文件那对应就有文件描述符。每一个进程都有一个数据结构task_struct里面指向一个文件描述符数组来列出这个进程打开的所有文件的文件描述符。文件描述符是一个整数是这个数组的下标。
这个数组中的内容是一个指针指向内核中所有打开的文件的列表。既然是一个文件就会有一个inode只不过Socket对应的inode不像真正的文件系统一样保存在硬盘上的而是在内存中的。在这个inode中指向了Socket在内核中的Socket结构。
在这个结构里面,主要的是两个队列,一个是**发送队列**,一个是**接收队列**。在这两个队列里面保存的是一个缓存sk_buff。这个缓存里面能够看到完整的包的结构。看到这个是不是能和前面讲过的收发包的场景联系起来了
整个数据结构我也画了一张图。<br>
<img src="https://static001.geekbang.org/resource/image/60/13/604f4cb37576990b3f836cb5d7527b13.jpg" alt="">
## 基于UDP协议的Socket程序函数调用过程
对于UDP来讲过程有些不一样。UDP是没有连接的所以不需要三次握手也就不需要调用listen和connect但是UDP的交互仍然需要IP和端口号因而也需要bind。UDP是没有维护连接状态的因而不需要每对连接建立一组Socket而是只要有一个Socket就能够和多个客户端通信。也正是因为没有连接状态每次通信的时候都调用sendto和recvfrom都可以传入IP地址和端口。
这个图的内容就是基于UDP协议的Socket程序函数调用过程。<br>
<img src="https://static001.geekbang.org/resource/image/6b/31/6bbe12c264f5e76a81523eb8787f3931.jpg" alt="">
## 服务器如何接更多的项目?
会了这几个基本的Socket函数之后你就可以轻松地写一个网络交互的程序了。就像上面的过程一样在建立连接后进行一个while循环。客户端发了收服务端收了发。
当然这只是万里长征的第一步,因为如果使用这种方法,基本上只能一对一沟通。如果你是一个服务器,同时只能服务一个客户,肯定是不行的。这就相当于老板成立一个公司,只有自己一个人,自己亲自上来服务客户,只能干完了一家再干下一家,这样赚不来多少钱。
那作为老板你就要想了,我最多能接多少项目呢?当然是越多越好。
我们先来算一下理论值,也就是**最大连接数**系统会用一个四元组来标识一个TCP连接。
```
{本机IP, 本机端口, 对端IP, 对端端口}
```
服务器通常固定在某个本地端口上监听等待客户端的连接请求。因此服务端端TCP连接四元组中只有对端IP, 也就是客户端的IP和对端的端口也即客户端的端口是可变的因此最大TCP连接数=客户端IP数×客户端端口数。对IPv4客户端的IP数最多为2的32次方客户端的端口数最多为2的16次方也就是服务端单机最大TCP连接数约为2的48次方。
当然服务端最大并发TCP连接数远不能达到理论上限。首先主要是**文件描述符限制**按照上面的原理Socket都是文件所以首先要通过ulimit配置文件描述符的数目另一个限制是**内存**按上面的数据结构每个TCP连接都要占用一定内存操作系统是有限的。
所以,作为老板,在资源有限的情况下,要想接更多的项目,就需要降低每个项目消耗的资源数目。
### 方式一:将项目外包给其他公司(多进程方式)
这就相当于你是一个代理在那里监听来的请求。一旦建立了一个连接就会有一个已连接Socket这时候你可以创建一个子进程然后将基于已连接Socket的交互交给这个新的子进程来做。就像来了一个新的项目但是项目不一定是你自己做可以再注册一家子公司招点人然后把项目转包给这家子公司做以后对接就交给这家子公司了你又可以去接新的项目了。
这里有一个问题是,如何创建子公司,并如何将项目移交给子公司呢?
在Linux下创建子进程使用fork函数。通过名字可以看出这是在父进程的基础上完全拷贝一个子进程。在Linux内核中会复制文件描述符的列表也会复制内存空间还会复制一条记录当前执行到了哪一行程序的进程。显然复制的时候在调用fork复制完毕之后父进程和子进程都会记录当前刚刚执行完fork。这两个进程刚复制完的时候几乎一模一样只是根据fork的返回值来区分到底是父进程还是子进程。如果返回值是0则是子进程如果返回值是其他的整数就是父进程。
进程复制过程我画在这里。<br>
<img src="https://static001.geekbang.org/resource/image/18/d0/18070c00ff5d0082yy1fbc32b84e73d0.jpg" alt="">
因为复制了文件描述符列表而文件描述符都是指向整个内核统一的打开文件列表的因而父进程刚才因为accept创建的已连接Socket也是一个文件描述符同样也会被子进程获得。
接下来子进程就可以通过这个已连接Socket和客户端进行互通了当通信完毕之后就可以退出进程那父进程如何知道子进程干完了项目要退出呢还记得fork返回的时候如果是整数就是父进程吗这个整数就是子进程的ID父进程可以通过这个ID查看子进程是否完成项目是否需要退出。
### 方式二:将项目转包给独立的项目组(多线程方式)
上面这种方式你应该也能发现问题,如果每次接一个项目,都申请一个新公司,然后干完了,就注销掉这个公司,实在是太麻烦了。毕竟一个新公司要有新公司的资产,有新的办公家具,每次都买了再卖,不划算。
于是你应该想到了,我们可以使用**线程**。相比于进程来讲,这样要轻量级的多。如果创建进程相当于成立新公司,购买新办公家具,而创建线程,就相当于在同一个公司成立项目组。一个项目做完了,那这个项目组就可以解散,组成另外的项目组,办公家具可以共用。
在Linux下通过pthread_create创建一个线程也是调用do_fork。不同的是虽然新的线程在task列表会新创建一项但是很多资源例如文件描述符列表、进程空间还是共享的只不过多了一个引用而已。
<img src="https://static001.geekbang.org/resource/image/a3/64/a36537201678e08ac83e5410562d5f64.jpg" alt="">
新的线程也可以通过已连接Socket处理请求从而达到并发处理的目的。
上面基于进程或者线程模型的其实还是有问题的。新到来一个TCP连接就需要分配一个进程或者线程。一台机器无法创建很多进程或者线程。有个**C10K**它的意思是一台机器要维护1万个连接就要创建1万个进程或者线程那么操作系统是无法承受的。如果维持1亿用户在线需要10万台服务器成本也太高了。
其实C10K问题就是你接项目接的太多了如果每个项目都成立单独的项目组就要招聘10万人你肯定养不起那怎么办呢
### 方式三一个项目组支撑多个项目IO多路复用一个线程维护多个Socket
当然,一个项目组可以看多个项目了。这个时候,每个项目组都应该有个项目进度墙,将自己组看的项目列在那里,然后每天通过项目墙看每个项目的进度,一旦某个项目有了进展,就派人去盯一下。
由于Socket是文件描述符因而某个线程盯的所有的Socket都放在一个文件描述符集合fd_set中这就是**项目进度墙**然后调用select函数来监听文件描述符集合是否有变化。一旦有变化就会依次查看每个文件描述符。那些发生变化的文件描述符在fd_set对应的位都设为1表示Socket可读或者可写从而可以进行读写操作然后再调用select接着盯着下一轮的变化。
### 方式四一个项目组支撑多个项目IO多路复用从“派人盯着”到“有事通知”
上面select函数还是有问题的因为每次Socket所在的文件描述符集合中有Socket发生变化的时候都需要通过轮询的方式也就是需要将全部项目都过一遍的方式来查看进度这大大影响了一个项目组能够支撑的最大的项目数量。因而使用select能够同时盯的项目数量由FD_SETSIZE限制。
如果改成事件通知的方式,情况就会好很多,项目组不需要通过轮询挨个盯着这些项目,而是当项目进度发生变化的时候,主动通知项目组,然后项目组再根据项目进展情况做相应的操作。
能完成这件事情的函数叫epoll它在内核中的实现不是通过轮询的方式而是通过注册callback函数的方式当某个文件描述符发送变化的时候就会主动通知。<br>
<img src="https://static001.geekbang.org/resource/image/d6/b1/d6efc5c5ee8e48dae0323de380dcf6b1.jpg" alt="">
如图所示假设进程打开了Socket m, n, x等多个文件描述符现在需要通过epoll来监听是否这些Socket都有事件发生。其中epoll_create创建一个epoll对象也是一个文件也对应一个文件描述符同样也对应着打开文件列表中的一项。在这项里面有一个红黑树在红黑树里要保存这个epoll要监听的所有Socket。
当epoll_ctl添加一个Socket的时候其实是加入这个红黑树同时红黑树里面的节点指向一个结构将这个结构挂在被监听的Socket的事件列表中。当一个Socket来了一个事件的时候可以从这个列表中得到epoll对象并调用call back通知它。
这种通知方式使得监听的Socket数据增加的时候效率不会大幅度降低能够同时监听的Socket的数目也非常的多了。上限就为系统定义的、进程打开的最大文件描述符个数。因而**epoll被称为解决C10K问题的利器**。
## 小结
好了,这一节就到这里了,我们来总结一下:
<li>
你需要记住TCP和UDP的Socket的编程中客户端和服务端都需要调用哪些函数
</li>
<li>
写一个能够支撑大量连接的高并发的服务端不容易需要多进程、多线程而epoll机制能解决C10K问题。
</li>
最后,给你留两个思考题:
<li>
epoll是Linux上的函数那你知道Windows上对应的机制是什么吗如果想实现一个跨平台的程序你知道应该怎么办吗
</li>
<li>
自己写Socket还是挺复杂的写个HTTP的应用可能简单一些。那你知道HTTP的工作机制吗
</li>
欢迎你留言和我讨论。趣谈网络协议,我们下期见!