文章

计算机网络

最近看到了两篇文章,对计算机网络的梳理还是非常高屋建瓴的:

  • https://www.ruanyifeng.com/blog/2012/05/internet_protocol_suite_part_i.html
  • https://www.ruanyifeng.com/blog/2012/06/internet_protocol_suite_part_ii.html

当年学计算机网络的时候学的还行,但整体知识量比较大,当时的知识又比较有限,所以有很多东西并没有完全理解。可能只是记住了,或者理解了一部分,所以现在全忘了。看完这两篇梳理之后,一下子感觉整个计算机网络的脉络又清晰起来,再加上现在会的东西多了,理解起来并不怎么费力。还有一些不太明白的地方,回来赶紧翻了当年的教材,谢希仁的计算机网络第六版,有一种醍醐灌顶的感觉。诚然,有些地方还是不能做到完全理解,但整本书的知识至少理解掌握精华部分了。现在想结合上面的梳理,把自己的理解表达出来,供日后翻阅。

  1. 层级
    1. 为什么要分层
  2. 物理层
  3. 数据链路层
    1. MAC地址
    2. 广播
    3. 帧协议
  4. 网络层
    1. IP地址
    2. IP协议 - 站在前人的肩膀上
    3. ARP协议 - 寻找ip对应的mac
    4. 网络间转发 - 网络层的新功能
      1. 计算下一跳
      2. 路由表查询优化 - trie tree
    5. 路由表的生成
    6. VPN
    7. NAT
  5. 传输层
    1. 端口号
    2. UDP
    3. TCP
      1. 可靠通信
      2. 数据结构
      3. 拥塞控制
      4. 建立连接 - 三次握手
      5. 断开连接 - 四次挥手
      6. 为什么到了tcp(传输层)才能保证可靠传输
  6. 应用层
    1. DNS
    2. DHCP
    3. P2P
  7. IPv6
    1. IPv4 <-> IPv6
  8. 感想

层级

计算机网络的层级,按照事实上的标准,其实就是TCP/IP模型,而不是OSI的七层网络模型。一般划分为五层:

  1. 物理层:发送实质上的比特流;
  2. 数据链路层:使用帧frame封装比特流,以帧为单位发送;
  3. 网络层:使用ip协议发送网络内容,进行主机之间的通信,所以这一层引入了ip地址的概念,代表主机的逻辑地址。而ip协议在进入下一层时会被拆解成帧发送;
  4. 传输层:使得两个主机上的进程之间进行通信,所以引入了端口的概念。比如TCP或UDP协议,协议内容在进入下一层之后,被切分为IP数据包发送。查看UDP协议的内容,更能理解什么叫“引入了端口的概念”;
  5. 应用层:不同的网络应用程序之间交互的规则,所以定制了各种协议,用来代表数据的格式。比如HTTP协议,规定了网页传输的格式,处理网页的浏览器和服务器就要按照这个协议发送内容。再比如SMTP协议,邮箱的服务器和客户端要按照这个规则发送内容;

为什么要分层

其实跟写代码一样,如果已经有了read功能,用来读取一个char,现在想封装一层readFile读取一个文件,直接依赖现成的read功能不就好了吗?至于read是怎么读到一个char的,是从ssd读的还是普通硬盘读的,这些细节上层就不用操心了,完全交给read去解决。

ip数据包就是这样,只关心怎么在抽象的网络层上发送ip数据包,不关心到了链路层怎么拆成mac帧,再组装回ip数据包。

再往上,抽象的概念就更明显了:TCP只管解决怎么保证可靠传输的问题,至于拆成ip数据包之后,怎么在网络层发送的,不用操心。

应用层的协议,比如http,只管规定http的格式应该是怎样的就好了。至于底层(网络层)怎么保证报文不丢失的,不用管。

所以各个层的功能就像是软件的接口,这里的接口实际就是协议,和软件工程层面的复用有差不多的意义:

  1. 复用底层实现;
  2. 层与层解耦,比如有一天发现http协议不太行,制定了http2,其它层完全不用管。同理哪天有一个协议实现了tcp相同的功能,同时有着更高的性能,那传输层直接使用这个协议替代tcp就好了,其它层不用操心,因为使用的接口没有变;
  3. 使设计更方便:设计http、smtp的时候,专注于自己业务的实现就行了。

所以学习计算机网络要有这种抽象的概念,看某一层的功能的时候,专注于本层就好,不用再去想数据包到了其它层会怎样,否则容易混乱

  • https://www.zhihu.com/question/26424507/answer/315373293

物理层

物理层没太多可说的,就是通过光缆、电话线等传输数据。发送的都是0或1的比特流。

数据链路层

比特流一个一个去接收,收多少算结束?不好判断。不如把比特流以一定格式发出,相当于一组比特数据作为一块发出,接收完一块,就可以解析这一块的内容了,更方便。这里的“一块”,就是一个帧frame。

MAC地址

帧在以太网中发送非常方便。以太网现在其实就是局域网的代名词。在一个局域网中,每个网络设备都有一个网卡,通过网卡接收帧。网卡都有属于自己独一无二的地址,称为MAC地址。

MAC地址有6 byte,或者说48 bit:

  1. 前24 bit是生产网卡的厂商的id;
  2. 后24 bit为该厂商生产的网卡的id。

所以理论上,一个厂商最多生产2^24=16777216块网卡。

广播

帧怎么发到目标网卡?当然是使用MAC地址。帧带有目的MAC地址,代表接受者的地址。不过发送者并不知道怎么把帧给到接受者,不如直接在局域网上广播,让每个网卡都收到帧。如果接受者发现MAC地址写的不是自己,丢掉就好了。

听起来似乎有点儿傻,为什么要进行广播呢? 数据链路层#为什么局域网要使用广播

帧协议

数据链路层的帧结构很简单:

  1. header:
    • 目的地址:接收者的mac地址,6 byte,因为mac地址6 byte;
    • 源地址:发送者的mac地址,6 byte;
    • 类型:不管;
  2. body:发送的数据内容;
  3. 帧校验序列FCS:CRC检验,用于校验帧的数据有没有被破坏;

按照规定,帧大小为64 byte ~ 1518 byte:

  • https://www.zhihu.com/question/21524257/answer/118266374

所以去掉头尾,放数据的地方只能是46~1500 byte了。1500 byte就被称为MTU,最大传输单元(maximum transmission unit)

传的包太大,一直占用网络,其他主机怎么发?类似于CPU时间片切分的太大了。另外,大包丢包之后代价也大。分成很多个小包,丢一个补一个就行了。所以有了MTU限制。

MAC帧和其他协议比起来有个明显的特点:协议里没有字段表明该帧的实际长度。那接受者怎么知道自己收了一个完整的帧?这和MAC帧所处的层级有关:它是物理层发送的最基本的帧,发送完一个帧,网络接口上的电压不会再变化了(既不发0,也不发1),就代表帧结束了。

网络层

数据链路层使用广播给某个MAC发数据,很简单,但只适用于规模很小的局域网。当世界上都在使用网络时,总不能每发一个帧,就给世界上所有的网卡都广播一下吧?那网络肯定要炸了。

假设MAC地址是扎堆的,比如所有0开头的在一个地方聚集,所以1开头的地方在另一个地方聚集,那是不是只要往某个地方发数据包就好了?0开头的网卡就不至于收到地址是1开头的数据包了。

但实际情况是,设备是流动的。北京的一台电脑可能被拿到上海,地理位置就变动了。也就是说,没法保证0开头的网卡永远和其他0开头的网卡待在一起。

MAC地址就好像身份证号:在一个地方出生的人有相同的前缀,但人是流动的。不能因为一个人的身份证号是412728开头,就认为现在去河南还能找到他,因为他现在可能已经定居北京了。

MAC地址这个实体地址是没办法有“分堆”的功能了,但这个想法很不错。所以可以使用虚拟地址,所有虚拟地址开头一致的主机认为在一个地方。这个虚拟地址就是IP地址。

这就是所谓“网络层”,逻辑上,使用ip地址把网络分为一个个的子网络,然后在不同子网之间传送数据

IP地址

目前广泛采用的是第四版,所以是IPv4。ipv4地址有4 byte,32 bit。一般用点分十进制表示。

4byte很好记,因为点分十进制正好有四块,每块1byte

ip地址一般看做两部分:

  1. 前一部分代表网络,称为网络号,就像MAC地址的厂商号一样;
  2. 后一部分代表这个网络里的主机,称为主机号,就好像MAC地址的id一样;

但和MAC地址不同的地方在于:ip地址的网络和主机的划分不是固定位数

为了方便,以1111这四位二进制举例:

  1. 如果以一三分,代表网络号为1的网络里的id=7的主机;
  2. 如果以二二分,代表网络号为3的网络里的id=3的主机;

所以ip地址必须知道网络号划分到哪一位才是有意义的,一般用子网表示,代表ip的前多少位为网络号

注意:子网掩码可以是不连续的1,所以传递的未必是“前多少位是网络号”的意思。但为了方便,一般都是以连续的1表示,就很方便的看出“前多少位是网络号”。只要前多少位相同,我们就是一个网内的

IP协议 - 站在前人的肩膀上

网络层并没有破坏数据链路层的架构,而是利用现有的数据链路层:它不是给数据链路层加个新字段存储ip地址,而是把整个ip数据包整个作为MAC帧的数据部分,利用帧传输ip数据包。

这样的好处就是,数据链路层根本不用管上面的层发生了啥,只管按照之前的逻辑收发帧就行了。

举个不那么贴切的例子:这就好像一个服务已经能通过收发xml格式的消息和别人通信了,但是现在想给服务添加进来一个现成的新的模块,代码是通过json交互的。需要改掉现有的xml格式通信吗?或者需要改掉新模块里的json格式吗?都可以,但最简单的方式,就是把要处理的json放在xml的某个字段,然后让新模块从这个json里解析数据,数据的收发实际上底层还是通过xml格式。也就是json on xml的效果。不去动底层xml的收发逻辑,就完全不用管底层收发的细节了。完美!

实际上再想想,xml本质上也是依赖更底层的字节流读取。我们说“通过xml格式交互”的时候,要考虑底层是怎么读字节的吗?不用。这就是站在前人的肩膀上

ipv4 header结构。ip协议也是分两部分,只说比较重要的字段:

  • header:
    • 总长度:该ip数据包的长度,2 byte;
    • id;
    • offset;
    • ttl:time to live,最多能经过多少路由器。防止一个包在网络里不断兜圈子,永不消失;
    • protocol:1byte,标志着数据部分封装的是别的协议比如41代表着数据部分实际是个ipv6的报文
    • 源地址:接受者的ip地址,4 byte;
    • 目的地址:发送者的ip地址,4 byte;
  • body:数据;

ip协议有字段指明ip数据包有多长。因为一个帧完全可能封装两个ip数据包发送,如果没有字段指明第一个包有多长,就不知道两个包从哪里分开了。

长度字段有2byte空间记录数值,单位为byte,所以2^16 byte = 64 kB,也就是说ip数据包最大64kB大小。但一般都不会发送这么大。

又因为MAC帧的数据区最大1500 byte,所以如果超出1500B,ip数据包就要切分,交给多个MAC帧传输。为了能再组装回来,ip协议有id和offset字段,同一个id的分片属于同一个ip数据包,offset代表该分片在原数据包的位置。这样就可以把多个分片重新去掉header,取出body,就可以拼成一个完整的body了。

ARP协议 - 寻找ip对应的mac

既然ip数据包依托mac帧,那ip数据包在被mac帧封装之后,一定要写上目的mac地址。目的mac地址怎么找?或者说,怎么知道ip对应的mac地址?

分情况讨论。首先要看目标ip和自己的ip有没有在同一个网络,方法就是使用自己的子网掩码分别和自己的ip、目标ip做计算,查看结果是否一样,如果一样则是同一个网络

如果是同网络,就可以使用ARP(Address Resolution Protocol)在局域网内根据ip查找MAC地址:

  1. 我的ip是x1,mac是y1,想知道ip为x2的主机的mac地址;
  2. 广播;
  3. 目标主机收到后,单播回复:我是ip为x2的主机,mac是y2;

然后两个人就相互把ip和mac的映射记下来了。但有一定时间限制,比如20分钟,之后就过期了,因为主机可能网卡坏了换了一块,mac地址就变了!下次自己的映射表如果查不到这个ip了,就再arp一遍。

这样就可以给帧写上mac地址了。然后呢?然后这不就转化为了第一个问题了嘛:怎么发送mac帧——在一个村子里,直接广播就行了。

之所以千方百计找到ip对应的mac地址,不就是想利用数据链路层的功能,用链路层的接口来发送封装了ip数据包的mac帧嘛!

如果是不同网络呢?那就需要把帧交给路由器,然后由路由器发送到目的ip所在的网络。这个就不是局域网内的数据链路层所关心的了。所以这个时候直接把mac地址填成由arp协议解析到的路由器的地址就行了。老方法——广播mac帧,路由器会收到mac帧。

一个ARP协议抓包实例:

  • https://blog.csdn.net/chen1415886044/article/details/112209275

网络间转发 - 网络层的新功能

广播只适用于局域网内的报文转发,因为前人只有以太网(局域网)这个概念。不同的子网就要使用路由的方式发送了,这是网络层新加的功能。

但是即使是路由,还是利用了前人的功能:把路由器以及路由器直连的网络、其他路由器也看成一个局域网。那这个路由数据包的功能,也能继续由数据链路层承担!

路由器有多个端口,每个端口连接一个网络。它负责把从一个网络收到的mac帧发到其他的地方:可能是一个路由器,也可能直接就是目标主机。

路由器判断发到哪里,使用的是自己的路由表:

  1. 如果路由器显示,自己的的另一个口恰好连着目标网络,那直接就可以把mac帧发到目标网络里的目标主机上了;
  2. 如果路由表显示,应该发给另一个路由器,那就发给路由器;

所以路由表至少由两个条目组成:

  1. 目标网络:实际上,目标网络由ip地址和子网掩码两个字段来判断,所以路由表条目里至少有三个字段;
  2. 下一跳地址:可能是自己的另一个端口,代表直接交付;也可能是另一个路由器的地址;

先不考虑路由表是怎么来的。假设现在有了一个这么神奇的路由表,路由器知道该往哪里发送mac帧了,能直接发吗?

显然不行!因为主机把mac帧发给它的时候,帧的目的mac地址写的是路由器的地址。现在路由器收到了帧,它得把帧的目的mac地址改成下一个接收帧的主机或者路由器的帧地址,这是数据链路层的规定。此时,寻找ip对应的mac,用的依然还是arp协议。

所以arp协议实际有四种场景:

  1. 发送mac帧的是主机,寻找的目的主机在同局域网,使用arp协议直接找到目的主机的mac地址;
  2. 发送mac帧的是主机,寻找的目的主机不在同局域网,但两个局域网连着同一个路由器。arp协议找到路由器的mac地址;
  3. 发送mac帧的是路由器,寻找的目的主机在直连的局域网。arp协议找到目的主机的mac地址;
  4. 发送mac帧的是路由器,寻找的目的主机不在直连的局域网。由路由表得到需要发送的路由器,arp协议找到目的路由器的mac地址;

所以现在每经过一个路由器,都要经历:

  1. 拆包,找出目的ip;
  2. 根据路由表寻找下一个接收者;
  3. arp协议获取下一个接受者的mac地址;
  4. 将帧的目的mac地址替换为新找到的mac地址;

不麻烦吗?麻烦。但是这个麻烦是值得的,这个硬件地址转换工作是自动完成的,而上层应用只需要指定一个ip地址就行了,下面这些操作根本不需要关心。毕竟一开始我们就讨论了:直接用网卡地址通信,是不可能的。

计算下一跳

ip数据包通过以下规则决定下一跳要去哪儿:

  1. 提取目的ip;
  2. 和路由表里的每个条目比较:与上mask,看和目标网络是否一致,一致就发往该条目里记录的下一跳;

这个下一跳可能是路由器直连的一个网络,也可能是路由器直连的下一个路由器。

路由表的最后一条应该是一个默认路由:指明如果和上面的条目都不匹配,应该发到哪儿。如果都不匹配又没有默认路由,ip数据包会被丢弃

注意这里说的是“ip数据包”,因为现在是网络层,操作的都是ip数据包。

如果有好几个都匹配呢?那当然是找最长前缀匹配:能匹配的网络中,网络号越长,越精确。

就好像想去北京海淀,一个地方指明去北京,一个地方指明去北京海淀,当然是选择第二个。不然按照第一个走到了北京,可能走到了房山,还得继续找怎么去海淀。

路由表查询优化 - trie tree

本来路由表记录的条目也不多,也就几十,或者上百。再多就很少见了。但是无奈处理一个数据包的时间实在太短了:10Gbit/s的网络,假设分组平均长2000 bit,一秒就能发送500万个,每个分组处理时间200ns。

此时,如果顺次和路由表匹配,和处理包的总时间相比,就显得太耗时了。如果这里优化不好,发送数据包的速度可能会减半,意味着网速减半啊!这能忍?

所以一般使用二叉特里树binary trie tree。和字典树一个意思,不过一个英文的字典树是26叉的特里树,而路由表里的ip每个bit都是0或1,所以组成的是2叉的trie tree。

之前做匹配加速发现了trie tree,现在再看trie tree无比亲切……

路由表的生成

路由表这么好用,到底是怎么生成的呢?

网桥转发表的生成方式类似,是让路由器去自己发现。路由表根据局域网和外部网的不同,采用了不同的生成方式:

  • 内部网关协议Interior Gateway Protocol,IGP:
    • Routing Information Protocol,RIP,路由信息协议;
    • Open Shortest Path First,OSPF,开放最短路径优先;
  • 外部网关协议External Gateway Protocol,EGP:
    • Border Gateway, Protocol,BGP,边界网关协议

主要说说RIP吧,最好理解。

RIP生成路由表,其实就是想知道从哪个路由器传送报文,能离目的地“最近”。这个最近并不一定是路程上的最近,而是指“出发点和目的地之间经过的路由器最少”,或者说经过最少的“下一跳”。

RIP报文有三个字段:到目的网络N,距离d,下一跳经过谁:

  1. 收到路由器X发来的RIP报文,把下一跳改成X,d+1。X到N需要d,那我要是想通过X到N需要d+1;
  2. 把修改后的报文跟自己的已有条目比:
    • 没有到N的条目?那现在有了,通过X就可以了。记下此条目;
    • 已经有到N的条目了?那和经过X到N哪个更近?X的更近就记下这个。否则还用我原来的条目;

非常简单。

VPN

Private Network,专用网络,在两地之间单拉一根线,作为专用线。但一般单位哪有这实力!所以Virtual Private Network,虚拟专用网络就出现了:两地之间通信仿佛是专用网络,至少逻辑上看起来是专用网络,但其实走的还是公网。

比如两地的网络,a路由器负责10.1.0.0网段,里面有主机x,另一地的b路由器负责10.2.0.0网段,里面有主机y。两地的路由器a和b都有公网ip,同时对各自的内网用私有ip:

  1. 主机x给另一个地方的主机y发消息,a先收到x的消息,然后把整个消息加密,再重新用一个IP数据报封装它,目的地址为b的ip;
  2. 新的IP数据报通过公网发到b;
  3. b收到之后剥掉外层的IP数据报,取出里面的内容,解密,得到原始数据报;
  4. 然后按照原始数据报的目的地址将数据报交给主机y;

整个流程,主机x一直以为主机y和它在同一个内部专用网里,它不知道实际上数据报通过公网发给了另一地的y。所以说是虚拟专用网。

当然,这种路由器“加密、封装一层新的数据报”、“解密、恢复原数据报”的行为,不是路由器的默认行为。所以需要专用的路由器、专用的软件,才能实现这种功能

但并不是所有场景都是两个网段之间的VPN,比如remote access VPN,是员工出差,通过pc上的VPN软件在自己的pc和公司内网建立VPN隧道。

NAT

Network Address Translation,网络地址转换。这个就非常常用了:一个路由器下的局域网都是用私有ip:

  • 小的局域网用192.168.x.x;
  • 大的公司局域网用10.x.x.x;

路由器负责将私有ip转换为自己的公网ip,发给外部服务器,如果路由器有多个外部公网ip,可以把一个私有ip对应为一个公网ip。但这样其实最多只能支持和公网ip数目相同的私有主机数。

所以又有了新技术NAPT,Network Address and Port Translation,把传输层的port概念也引入了进来:对于一个内部私有ip发的ip数据包,路由器记下它的私有ip和port,转为新的外部ip和port。这样的话,一个外部ip+两个port,就可以给两个内网主机的ip+port做转换了。

但这必须使用NAPT路由器。和VPN路由器一样,所有和标准行为不一样的路由器等设备,都需要专门的软硬件支持这些功能。

局域网主机上的进程就好像变成了NAPT路由器的一个进程一样。

但这个方法曾经也有争议,因为把传输层的东西引入到了网络层。破坏了接口设计。

可以把路由器理解为多叉树的父节点,连接的每一个网段是它的子节点,通过一个网口相连。一个路由器可以有很多个网口,如果把一个网段看成一个村子,那路由器就是村与村的联络员:路由器连接多个网络,联络员联络多个村子;一个网络可能连着几个路由器,一个村子有多个联络员。

现在要和村子外的人通信,不可能像之前一样给村里每个人寄件(广播)。所以:

  1. 先判断一下目标人所在的村子id和自己是不是同一个(子网掩码计算网络号);
  2. 如果是同一个村,继续按之前的方式广播寄件;
  3. 如果不是,就交给联络员;
  4. 联络员发现是自己联络的隔壁村的,就直接在隔壁村广播寄件;
  5. 如果不是隔壁村的,就问一下和自己联络的村子有交集的别的联络员,问问他们知不知道怎么送过去(路由表的生成),知道的话就交给他;
  6. 如果没人知道,扔了;

同时,为了采用之前广播的方式在村里寄件,寄的东西还用之前的方式封装。如果之前的快递盒放不下,就把件拆成好几个件,分别寄过去。别人收到了再按照ip协议里的id和offset组装。

传输层

网络层能把数据发送到主机,但通信的真正端点并不是主机,而是主机里的进程,所以只把数据交到主机还不够,还要有一种机制能区分出是哪个进程收发的数据。现有的ip地址只能标识出主机,所以引入了端口这个概念。

每个通信的进程占用一个端口,ip:port就能唯一标识通信的端点——进程。ip:port连接了两个点,就像两个插头之间连了一根线一样,所以又被称为套接字。

端口号

端口号有2byte,16bit,所以最多有2^16=65536种表示,0不能用,所以实际有65535个。

  • 服务器端口号:当进程以server进程形式存在时,最好端口号是固定的,方便client连接。所以把一些端口号留给server程序用:
    • 系统端口号:1023及以下。基本的server服务使用的,大家都知道,就不再占用这些了。比如http server进程使用80端口;
    • 登记端口号:1024-49151,也是留给server用的,但是没有规定默认给谁用。谁想用谁用,比如elastic search默认服务端口号是9200。如果想固定留给自己,需要在IANA按规定注册登记,所以叫登记端口号;
  • 客户端端口号:49152-65535,client进程也要用端口号,这些是留给他们的。client随机选一个端口号,用完就结束了,所以被称为短暂端口号;

UDP

User Datagram Protocol。上层应用发的消息叫datagram,UDP协议UDP一次封装上层交下来的一个datagram,加上端口号,发给网络层交给IP协议进一步封装。

看一下UDP协议的格式,更能理解什么是“传输层引入了端口号这一概念”

UDP报文的头部就四块内容:

  • source port:来自发送方的哪个port。因为port是2 byte,所以这里就是2 byte空间;
  • destination port:发往主机的哪个port;
  • length:UDP数据报长度;
  • checksum:upd数据报的校验和;

非常简单,几乎就多了个存储端口号的数据结构!udp协议在网络层就交给IP协议包装了。ip协议指明了双方主机地址,udp协议指明了双方port。

我觉得协议应该包括两部分

  1. 数据结构长啥样;
  2. 标准处理逻辑是怎样的,包括怎么使用这些数据结构;

UDP协议是无连接的,所以适合大量client连接的情况。发送前不需要建立连接,直接就发了。既然没有创建连接,发完后也不需要释放连接,直接就停发了。

UDP没有重发策略,所以很适合实时应用,比如直播、实时会议。中间有丢包就丢了。而TCP不允许丢包,丢包会重发,如果直播使用TCP,反而导致收到重发的之前的数据包,导致跟不上实时的节奏了。

TCP

Transmission Control Protocol,传输控制协议。所以TCP主要是搞可靠交付的。

TCP是面向连接的,所以传输前要建立连接,传输后要断开连接

TCP提供全双工通信,且有接收缓存和发送缓存,先放缓存,到一定程度之后再向上交付或向下层发出。

可靠通信

如何创建可靠通信?一个简单的做法就是a发一个包,b返回一个响应说收到了,然后a再发下一个。这叫做停止等待协议。如果一切顺利,那这一套没问题。

如果b的确认a没收到怎么办?那就再加个机制,超时重传:如果b的响应a一段时间没收到,a就默认包丢了,重发刚刚的包。

超时重传能解决包真丢了的情况。那如果包其实没丢,只是b确认晚了,或者b的确认包丢了怎么办?假设b之前真的收到a的包,现在又收到一次,那就直接扔了就好了。同时再给a发个确认包,告诉a我收到了。如果一开始b的确认包没丢,只是迟到了怎么办?a收到之前的确认包,不管就行了。反正收到了确认包a就知道b收到消息了。

通过停止等待 + 超时重传 + 上述确认机制,TCP就可以在不可靠的信道上实现可靠通信了:即保证包一定都能给到b。

现在想想上述机制都需要哪些东西:

  • TCP包需要一个id,唯一标识一个包,这样才能重发这个包、才知道b确认收到的是哪个包;

但停止等待效率太低了:a发一个,b确认一个。所以不如a发一堆,b给出这一堆的确认。实际上tcp约定的是连续确认:a发了10个包,如果b确认收到了9号包,代表前8个也收到了。否则假设4没收到,b就算收到了5-9,也只给出3的确认包,a就重发4-9。

评价:这样效率确实高了,但是只要中间丢一个,一重发就要重发一堆。假设信道质量很差,这种方式负担就太大了,还不如一个一个的停止等待协议。如果信道还比较正常,这样传送(相当于流水线并行传送包)的效率会很高。

数据结构

TCP的报文数据结构,给实现tcp的功能提供了支持

  • 两个端口号:这个就不说了,传输层就是干这个的,所以传输层所有的协议都得有两个port;
  • sequence number:就是刚刚说的id。但是序号只有4 byte,32 bit,也就是说最多标记2^32=4GB的包。那如果一个连接发的数据超过4GB怎么办,序号不就要重复了?其实tcp的这个id并不要求永远不重复,只要当前连接上发送的包id不重复就行。连接上不可能有4GB的数据等待确认……所以够了;
  • acknowledge number,确认号,放的是下一个包的sequence number,代表之前的包我都收到了
  • ACK:1 bit,为1时上述acknowledge number有效。连接建立后所有的包的ACK都要为1,因为连接创建后就要开始确认了
  • SYN:1 bit,为1时代表这是一个请求建立连接的报文;
  • FIN:1 bit,为1时代表这是一个请求终止连接的报文;
  • window size,窗口大小:告诉对方,你一次发一堆包,应该是多少个。窗口可根据情况不断变化,所以是动态的。当窗口内的包都发完了,还没有收到确认,就不能接着发了。当收到一些确认,就可以再发一些,相当于窗口向后滑动了,所以叫滑动窗口
  • checksum:不说了;

按理来说,像上面的acknowledge number应该是TCP response协议里的。但是tcp协议就一个,而不是拆成request和response,所以请求和响应里需要的字段都需要定义在这个大一统的tcp协议里

拥塞控制

tcp除了使用ack保证可靠传输的特点,还有拥塞控制的特点。毕竟它叫Transmission Control协议。

怎么控制?正因为有ack,所以tcp是有反馈的!根据反馈决定发送的速率

  1. 慢开始:拥塞窗口一开始设置小点儿,根据对方ACK的回复质量,推断二者的信道质量,如果好的话,拥塞窗口设置大点儿,即“发送时的并行度可以加大点儿”;如果超时了,说明网络质量不行,拥塞窗口缩减为1,再慢慢开始;
  2. 快重传:一个发一堆,如果中间丢一个,后面一堆都要重发。如果中间丢了一个,等到ack超时的时间到了,才知道后面发的这一堆都白发了,还要全部重传。那不如你提前告诉我,别让我再接着发后面的了。我赶紧把丢的给你补上。那接收方怎么知道中间那个是丢了呢,也可能是慢了一会儿,还在路上呢。无所谓,就当我白重传了一个中间的包,那也比真丢了之后,后面一堆都要重传强吧!新约定比如收到2之后,3还没收到就收到4了,那接收方再发个2的ack。之后收到5,再发个2的ack。连收三次2的ack,接收方就先不发后面的了,先把3给重发一次;
  3. 快恢复:使用快重传之后,包不一定真丢了,可能只是没到。网不一定真堵了,要不然怎么会3个ack全都收到了对不对?所以不能把快重传发生时当做超时失败的情况,直接把拥塞窗口调成1,一夜回到解放前。而是认为网还可以,只是出了点儿意外,所以拥塞窗口先减半。这样拥塞窗口恢复起来也比从1开始恢复要快很多;

建立连接 - 三次握手

都知道建立连接要三次握手,为什么要三次握手?两次不行吗,为什么还要多一次?

假设两次就成功。比如a发了连接请求,b没收到,a又发了一个,然后正常连上,最后断开。此时a一开始发的连接建立请求到了,b以为又要建立一次连接,向b发送ack。假设没有第三次确认,那这个ack就不需要确认,b直接就建好连接等a发数据了。结果等半天都不见a发数据……就浪费服务器资源了。三次握手则不会这样。

根据介绍tcp数据结构时说的:

  1. 建立连接的请求发起方,是第一个包,所以SYN=1,ACK=0(第一个包,ACK如果是1,是对谁的ack?):client发了,进入SYN-SENT
  2. b的ack,SYN还是1,因为连接还没建立呢,都得是1。ack也是1。server进入SYN-RECV
  3. client再发ack,进入ESTABLISH
  4. server收到ack,也进入ESTABLISH;

所以client和server各有两种状态:

1
2
3
4
5
                    [S: LISTEN]
C: SYN_SENT
                    S: SYN_RECV
C: ESTABLISHED
                    S: ESTABLISHED

tcp的状态是有限状态机,可以把控tcp到了什么状态,非常有用。比如当系统出现大量SYN-RECV,可能是遭受SYN flood攻击了

  1. 攻击者只发SYNC,不发ack实际和server建立连接;
  2. 攻击者发送假ip的sync,server找这个ip的机器建立连接,这个ip并没有发sync,自然不会和server建立连接;

syn flood导致服务器保留资源给半开放的连接,导致没有资源接收正常client的连接了。

  • https://en.wikipedia.org/wiki/SYN_flood

断开连接 - 四次挥手

为什么断开需要四次才能挥手?

  • a:我不爱你了;
  • b:我知道了;

两人断开了吗?不是的,b还爱着a呢,那b肯定还会给a发消息

  • b:我也不爱你了;
  • a:我知道了;

现在b也不爱a了,两个人彻底断开了

tcp连接是全双工的,所以client和server都要各发一次FIN分别关闭两个方向。毕竟一个方向关闭后,另一个方向的数据可能还没发完。

所以一个方向断开,两次就够了。但是建立可是用了三次!为什么断开只是两次,如果也是三次,不就是六次挥手了?因为下面说的2MSL。

因为断开不像建立连接。假设第一个断开

发断开请求,FIN=1:

  1. a:我不爱你了,FIN=1,进入FIN-WAIT-1;
  2. b:知道了。ACK=1,进入CLOSE-WAIT;
  3. a:进入FIN-WAIT-2;

过了一段b也想放弃了:

  1. b:累了,我也不爱你了。FIN=1,进入LAST-ACK;
  2. a:我知道了。ACK=1,进入TIME-WAIT;
  3. b收到ack,CLOSED;
  4. 2MSL后,a看没动静了,也CLOSED;

LAST-ACK这名字太草了,just one last dance…

主动关闭方的的TIME-WAIT阶段是2MSL(Maximum Segment Lifetime,最大报文生存时间,Segment:tcp报文):人家等着last ack呢,那我这个lask ack必须给到,不能让对方傻等!怕自己(主动关闭方)的last ack对方没收到,一个MSL后对方会超时重传FIN,两个MSL内自己肯定能收到这个FIN,那就再发ACK,再次进入TIME_WAIT阶段,再等两个MSL,没回应了代表彻底关闭了。在此期间,端口不可用

也因为2MSL的存在,网络中所有关于本连接的报文都消失了。所以不会出现三次握手那种“迟到的syn包又来了”的情况,因此不需要“六次挥手”

MSL要大于等于TTL,Time To Live(ip头中的一个域,标志ip数据报可以经过的最大路由数,跳数,为0则丢弃),rfc793规定MSL的时间是2min,对于现在的网络,这太长了!In practice, 2 MSL is usually used for 30 seconds, 1 minute and 2 minutes,一般是60s。

网上一般说/proc/sys/net/ipv4/tcp_fin_timeout是WAIT_TIME时间,但很容易查出来这个是FIN-WAIT-2的时间。

这个评论里提到了这个问题,里面说了 TIME_WAIT的时间硬编码在net/tcp.h里了,是60s: http://lxr.linux.no/linux+v2.6.18/include/net/tcp.h#L106-

所以如果server端为主动关闭方,server会出现TIME-WAIT状态,可能会持续60s,在此期间,端口不可用。那就没法再重新启动server了,否则就会报the address is already in use错误,端口占用

所以又有了SO_REUSEADDR这个参数,允许使用TIME_WAIT状态的port,233:https://stackoverflow.com/a/3229926/7676237

或者设置/proc/sys/net/ipv4/tcp_tw_recycle这个文件夹下面是各种linux关于网络的可调参数

server端主动关闭,TIME_WAIT:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ netstat -anp | grep 9012
(Not all processes could be identified, non-owned process info
 will not be shown, you would have to be root to see it all.)
tcp6       0      0 10.108.160.44:49913     10.105.132.125:9012     ESTABLISHED 77730/java          
tcp6       0      0 10.108.160.44:38745     10.108.160.57:9012      ESTABLISHED 77730/java          
tcp6       0      0 10.108.160.44:38536     10.108.160.58:9012      ESTABLISHED 77730/java          

// server端主动关闭
$ kill -9 77730

$ netstat -anp | grep 9012
(Not all processes could be identified, non-owned process info
 will not be shown, you would have to be root to see it all.)
tcp6       0      0 10.108.160.44:49913     10.105.132.125:9012     TIME_WAIT   -                   
tcp6       0      0 10.108.160.44:38745     10.108.160.57:9012      TIME_WAIT   -                   
tcp6       0      0 10.108.160.44:38536     10.108.160.58:9012      TIME_WAIT   -

为什么到了tcp(传输层)才能保证可靠传输

从底层,比如物理层或者数据链路层就保证可靠传输不行吗?这样上层协议岂不就不需要这么麻烦了?

可以当然是可以,但是从数据链路层或者物理层保证可靠,对网络设备的要求就高了,网络的造价就大大增加了。电信网就是这样的。但是互联网的设计者认为,电信网之所以这么做,是因为电信网的终端是电话,电话非常简单,如果线路不能保证可靠,电话也没有办法处理差错。但是网络的终端是电脑,电脑是智能的。通过上层协议保证可靠,相当于使用软件保证可靠,比搞一个硬件保证可靠成本大大降低。要不然网络也不会像现在这么发达。

太睿智了!!!

应用层

经过一层又一层的堆叠,终于实现了完整的功能,把数据包从一台主机的某个应用,送到另一台主机的某个应用。现在只需要关心两个应用之间怎么交流就行了,也就是说,给两个应用之间交换的内容定义一些格式,这些格式和格式的处理方式,就是一种应用层协议。

因为有着各种不同用途的应用,所以应用层的协议也是五花八门,分别完成不同的事情。

DNS

Linux - dig & DNS

DHCP

Dynamic Host Configuration Protocol,动态主机配置协议。连到Internet的主机,必须先配置的内容:

  • ip地址;
  • 子网掩码;
  • 默认路由器ip;
  • DNS ip;

这些可以交给人配,但是最好别交给人配置,涉及概念太多,最好自动配置了。DHCP协议就是干这个的。

主机使用DHCP DISCOVER报文,向DHCP服务器请求上述信息。但是它不知道DHCP服务器在哪儿,所以报文的目的IP地址是广播地址全F,目的mac地址也是全F,源IP地址全0,源MAC地址是自己的mac地址。DHCP服务器收到之后就会回应它上述信息。

P2P

Peer to Peer。

一般下载东西:搞个服务器,客户端连接服务器下载。服务器挂了,就没法下载了。客户端多了,服务器就撑不住了。

第一代P2P:数据是分布的,但数据的注册是集中地。所有运行的client都要给中央服务器上报自己拥有的文件。client需要下载的时候,先问问server文件在哪儿,server给个列表,client去其他client那儿下载。所以数据的传输是分布式的。

第二代P2P:全分布式。不需要集中式的目录服务器,而是采用洪泛法在client之间进行查询。但因为查询范围有限,所以对文件的定位效果比不上集中式的目录服务器。

第三代P2P:分散定位、分散传输。把文件拆成碎块,下载一个文件就是从不同用户那里收集不同的文件块,再拼成一个文件,所以可以并行下载。而且因为以块为单位,下载完一个块就可以作为上传者给别人下载了。同时也使用了分布式server,作为文件的注册中心,client至少连上其中一个server才能知道去哪些用户那里下载文件。

IPv6

ipv6把地址从ipv4的4byte提升到了16byte,由32bit提升到了128bit。所以ipv6的报文,header里光两个ip地址就要占掉32byte。

冒号分十六进制,每部分2byte,所以分了8块。

IPv4 <-> IPv6

两个协议的结构是不一样的,也不可能某个时间点突然把所有ipv4换成ipv6,两种协议的共存是必然的。怎么在网络中同时使用两种协议?

  1. 双栈协议dual stack:也就是把ipv4和ipv6来回转换,如果发送的是ipv6,中间的路由器只支持ipv4,那就把ipv6转成ipv4。到达目的地后,再转成ipv6。这就跟父类转子类一样,ipv6 header里一些特有的东西肯定就丢了;
  2. 隧道技术tunneling:把整个ipv6的包封装为ipv4的数据部分,同时ipv4的header里的protocol字段标明封装的是个ipv6,然后由ipv4协议运输。整个ipv6包就像是在ipv4的隧道里运输一样。需要转成ipv6的时候,就把ipv4剥了,取出ipv6报文。

第二种方式有点儿像PPPoE,TODO同一层的协议相互封装。显然,上述两种方法,都需要路由器具有相应转换或者拆封包功能。

底层协议的改变不像上层协议。比如应用层协议,pop3过时了那就再定义个imap,pop3就可以弃用了。但是底层协议的变动,可能意味着底层设备的变动,所以要比上层协议的更新换代慢很多。

感想

想来想去,想学好计算机网络,对“抽象”一定要有一定的理解。没有好的抽象意识,就会在探讨一层的时候,想起另一层的事情,搞来搞去就混晕了。经过这么多年Java代码的开发,个人对抽象算是有一定程度的理解了,回头再看计算机网络,这一理解让人获益匪浅。

本文由作者按照 CC BY 4.0 进行授权