计算机网络
上图表示的是网络分层结构,并在每一层中都给出了一些协议作为例子。我们可以看到,所有层级中只有网络层只包含了一个协议,也就是 IP 协议。之所以我们需要这样一个瓶颈,是因为如果没有一个能够统一所有服务的协议、我们就无法将所有网络连接在一起,这正是网络层的意义。
在五层模型中,你可以以网络层为界,将五层分为上下两半。上一半被用来实现应用所需的数据共享功能,而下一半则用来实现网络中数据的传输。
上一节中我们提到了网络设计三大基本原则之一的端到端原则,其基本思想是网络设计只需要支持最基本的功能,也就是路由和转发,因此网络层与其下面的数据连接层、物理层只负责实现路由和转发。IP 作为网络层唯一的协议对于大家是比较熟悉的,IP 地址能够被用来在全球范围内确定一个主机的位置;路由器就是利用 IP 地址选择合适的链接传送数据的。数据连接层与 IP 层不同,只能被用于一个不需要路由器就能够被连接的子网中,不同的链接之间由交换机连接,交换机根据每个网络设备唯一的 MAC 地址传送数据包。
鉴于网络层及其以下的层面只被用来实现路由和转发,它们不能解决链接错误、数据错误带来的可靠性的问题;并且在一台主机上有多个进程同时使用网络时,网络层也不能够做到将数据包派送给需要该数据的应用。因此传输层就被用来实现这两个功能。
需要注意的是,并不是所有的应用都需要百分之一百的可靠性——一个协议想要在一个不可靠的网络上实现可靠的数据传输就需要重复传输丢失或错误的数据包,这势必会使得应用获取数据的速度下降。UDP 就是一个不保证可靠性的传输层协议,它一般被用于实现视频聊天等时效性较重的应用。TCP 是一个可靠的数据传输协议,在后面的章节中我们会具体的讲到它们的实现方法。
基于传输层实现的是应用层。应用层的目的是根据不同的服务需求提供具体的服务,它包含了我们熟悉的 HTTP,SSH,FTP 等协议。这一层中,数据包的概念已经不再适用;应用将数据看作是一段连续的字节,可以被完整地发送和接收。为了支持这一抽象,传输层需要将数据包合为一段连续的数据,提供给应用层。本章中我们还会讲到这一层中的 RPC 协议;上一章的结尾我们提到了远程文件系统正是通过 RPC 协议实现的,我们在后面的课节里会重新回顾这个主题。
练习
传输层
前几节中我们已经向你介绍了网络的分层和各个层级的功能;就像我们前面提到的那样,网络层、数据连接层和物理层负责的是网络中数据的传输,而传输层和应用层则负责向需要数据的应用提供服务;由于操作系统的作用之一也是为用户程序提供服务,在这门课程中,我们主要关注的是后者。这一节中我们就来看一看两个非常常见的传输层协议,UDP 与 TCP。
我们已经知道,网络架构中每个层级都基于它的下一个层级实现,由于网络层只有 IP 一个协议,UDP 和 TCP 都是基于 IP 实现的;你可以假设 IP 已经实现了找到数据包从一个 IP 地址传送到另一个 IP 地址需要的路径,但它不能保证数据包在传送过程中不被丢失或其内容不产生错误,它也无法将数据包直接传送给需要数据的应用,因此一个传输层协议的标头必须包含这些内容。在学习 UDP 和 TCP 的标头实际包含的内容以前,让我们先来预测一下它们的标头应该包含什么。
UDP和TCP
由于 TCP 比 UDP 复杂许多,我们可以先来了解一下 UDP。UDP 全称为 User Datagram Protocol,即用户数据报协议。UDP 协议的标头只包含了发送方和接收方的端口号、数据长度、以及非强制的校验和。由于 UDP 的标头中不包含唯一确定数据包所属的数据流信息和数据包在数据流中的位置,UDP 协议一次只能发送一个数据包。不仅如此,它只保证校验和不符的数据包会被丢弃,而不保证没有传送到的数据包会被重新传送,因此它不能保证可靠性。
与 TCP 相比,UDP 的优势在于,它不需要在系统中保存链接的状态,且建立连接速度快、标头所占空间小(只有 8 字节),因此像视频聊天、游戏等时效性很强且能够处理数据丢失的应用就可能使用 UDP 协议进行传输。除此以外,用于通过主机名称获得 IP 地址的 DNS 和用于在加入网络时获得地址的 DHCP 使用的也都是 UDP,这是因为这些协议需要传输的内容本来就很短,如果花费很多时间在建立连接上,效率反而会降低。
与 UDP 不同,TCP 不仅可以将数据拆分成多个数据包可靠地传输到目的地,而且还能控制网络拥塞。下面我们就来看一看 TCP 是如何实现这些功能的。
前面我们已经提到,UDP 相对于 TCP 的优点在于它建立连接所花费的时间短、且无需在系统中储存连接的状态,这两点正是保证 TCP 的可靠性的关键因素。简单地说,TCP 在传输一段数据时会先建立一个连接,然后将数据拆分为数据包、逐个发送;在所有数据包都确认被收到以前,TCP 会一直保存这个连接的状态,然后在双方都传送完所有数据后 TCP 会发送结束连接的请求,在双方都确认结束连接后连接才会被关闭。
下图表示的是 TCP 数据包的结构,其中绿色背景的部分是 TCP 标头:
它的前两个字段,发送端口、接收端口,和后面的校验和是与 UDP 相同的。与 UDP 明显不同的是序列号与窗口大小这两个字段,我们接下来就重点来讲解着两个字段的作用。我们前面已经提到,TCP 可以将数据拆分后发送,并在另一端重新组装起来,这就要求我们有某种办法知道每个数据包属于哪个数据流、在数据流的什么位置。这就是序列号的作用。
在 TCP 建立连接时,第一个发出的数据包是 SYN 数据包,它被用来使得对方得知自己的起始序列号。注意,起始序列号是随机的!这是因为同一个 IP 地址下的同一个端口可能被重复利用,那么上一次连接留存下来的数据包可能还在网络中没有到达,这时候如果使用随机的其实序列号,那么两次连接的数据包就不会被混在一起。为了表明第一个数据包是 SYN 数据包,数据包中的标志字段会标明数据包的类型,这个类型可以包括 SYN,ACK,FIN 等六种类型,我们在后面会讲到其它类型的数据包。
接收到 SYN 数据包的主机会回复一个 SYNACK 数据包,其中既包含了自己的其实序列号,也包含了对方的起始序列号加 1 ,后者被存储在确认号字段里,表示对方可以开始向自己发送数据。
连接的发起者在收到 SYNACK 数据包后会回复一个 ACK 数据包,将对方的起始序列号加 1 存在确认号字段里,表示对方可以向自己发送数据,这样连接建立的过程才算完成。由于这个过程涉及到三个数据包(SYN + SYNACK + ACK)的交换,它被形象地称为 “三次握手”(Three-way Handshake)。
在连接开始后,双方就开始给对方发送数据。发送数据时,TCP 使用的是基于窗口的发送方法。窗口指的是一个以数据包数量来衡量大小固定的区间。假设窗口大小为 w,,TCP 在发送数据包时,一次只允许w个数据包处于传输过程中。如果接受方已经接受到某个数据包,那么它就会回复一个 ACK 数据包,表示已经接收到了这个数据包,但是这个 ACK 承认的并不是这个数据包本身,而是所有到目前为止接受到的数据包中由起始数据包开始序号连续的数据包的最后一个序号加 1。例如,如果接受方收到了 1,2,4,3 这个序列,那么在数据包 2 到达时,ACK 会回复 2 +1 = 3,当数据包 4 到达时,ACK 仍然会回复 3,因为 4 与之前的数据包不连续。当 3 到达时,所有到达的数据包组成了一个连续的段,其最终的序号是 4,因此 ACK 会回复 5。这种 ACK 被称为 累计确认(cumulative ACK)。
需要注意的是,TCP 发送的数据包实际不是被编号的;它们的序列号等于起始序列号与这个数据包在数据中的偏移字节数之和。例如,如果每个数据包包含了 1400 字节的内容,那么第二个数据包的序列号会是第一个数据包的序列号加上 1400。
ACK 在 TCP 中既保证了可靠传输、又帮助 TCP 控制窗口大小、避免网络拥塞。TCP 传输的过程中,系统会设定一个计时器,它在每次有新的数据包被承认时会被重置。如果计时器超时,那么最新的 ACK 序列号代表的数据包就会被重新发送,超时时限会变为原来的 2 倍,窗口的大小也会随之减小为原来的一半。随着每个数据包被承认,窗口都会向前移动;在成功传输一个窗口的内容后,窗口大小会加 1,这样我们就可以在充分利用带宽的同时保证不使网络过载。
数据传输完毕后,TCP 连接的双方还会经过一个“四次挥手”的过程来结束一端连接。这一过程包含了 FIN,ACK,FIN,ACK 四个数据包。之所以在一方发送 FIN 后另一方不能直接回复 FINACK,是因为这一方的数据可能还没有发送完毕。注意,在最后一个 ACK 被收到后,ACK 的发送方会等待一段时间再清除自己有关这个连接的数据,你明白这是为什么吗?(Hint:如果最后一个 ACK 数据包丢失了会怎么样?)
为什么TCP协议终止链接要四次?
1、当主机A确认发送完数据且知道B已经接受完了,想要关闭发送数据口(当然确认信号还是可以发),就会发FIN给主机B。
2、主机B收到A发送的FIN,表示收到了,就会发送ACK回复。
3、但这是B可能还在发送数据,没有想要关闭数据口的意思,所以FIN与ACK不是同时发送的,而是等到B数据发送完了,才会发送FIN给主机A。
4、A收到B发来的FIN,知道B的数据也发送完了,回复ACK, A等待2MSL以后,没有收到B传来的任何消息,知道B已经收到自己的ACK了,A就关闭链接,B也关闭链接了。
A为什么等待2MSL,从TIME_WAIT到CLOSE?
在Client发送出最后的ACK回复,但该ACK可能丢失。Server如果没有收到ACK,将不断重复发送FIN片段。所以Client不能立即关闭,它必须确认Server接收到了该ACK。Client会在发送出ACK之后进入到TIME_WAIT状态。Client会设置一个计时器,等待2MSL的时间。如果在该时间内再次收到FIN,那么Client会重发ACK并再次等待2MSL。所谓的2MSL是两倍的MSL(Maximum Segment Lifetime)。MSL指一个片段在网络中最大的存活时间,2MSL就是一个发送和一个回复所需的最大时间。如果直到2MSL,Client都没有再次收到FIN,那么Client推断ACK已经被成功接收,则结束TCP连接。
也就是说至于四次挥手,同样也是基于以上的原理。尤其是通信双方都可以独立关闭自己的通信通道,也就是半关闭。
client先发送FIN告知对方我已经完成数据发送了,server回复ack来确定我知道了。这样一个流程,就关闭了client的发送信息通道。但是还可以接受。
server此时已经知道接收不到client的数据了,但是还可以给它发送数据。如果server也没有啥数据要发送给对方了,server也会以FIN标志位发送一个信息给client,client接到后,也会传递一个ack表示知道了。这样子,双方都完成了关闭。
Socket套接字
假如我们已经在
host_name
这一变量中存储了服务器的名字,并在 portno
中存储了我们需要使用的协议对应的端口号,那么下面的代码是一个用户需要的基本的代码:
int socketfd;
struct sock_addr_in server_addr;
struct hostent *server;
socketfd = socket(AF_INET, SOCK_STREAM, 0);
server = gethostbyname(host_name);
bzero((char *) &serv_addr, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
bcopy((char *)server->h_addr,
(char *)&serv_addr.sin_addr.s_addr,
server->h_length);
serv_addr.sin_port = htons(portno);
int result = connect(socketfd,(struct sockaddr *) &serv_addr,sizeof(serv_addr));
if (result<0) {
printf("Error!");
return -1;
}
/* 从这里开始我们就可以用 read() 和 write() 读写 socket 中传输的内容 */
在建立 socket 时,我们使用了 AF_INET 和 SOCK_STREAM 这两个参数。AF_INET 表示这个 socket 使用的是 IPv4 协议;SOCK_STREAM 表示这个 socket 能够提供一个可靠的双向字节流,正是这个抽象允许我们像使用管道一样使用这个 socket。在这两个参数的位置我们也可以代入其它的值,有兴趣的同学可以在 这个网页 上详细了解socket()
系统调用的用法。
我们通过 gethostbyname()
这个方法通过服务器的名字获得了它的 IP 地址(这一过程是通过 DNS 实现的,有兴趣的同学可以自己查找相关的资料),然后将这一地址和我们使用的端口号储存到server_addr
中,再将这个结构作为输入值作为参数代入到 connect()
中,我们就可以与服务器连接了。
与用户相对应的,一个基本的服务器至少需要如下的代码:
int sockfd;
struct sock_addr_in server_addr;
sockfd = socket(AF_INET, SOCK_STREAM, 0);
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = INADDR_ANY;
serv_addr.sin_port = htons(portno);
int result = bind(sockfd, (struct sockaddr *) &serv_addr,
sizeof(serv_addr));
if (result<0) {
printf("Error!");
return -1;
}
listen(sockfd, 5);
while (true) {
new_sock = accept(sockfd, NULL, NULL);
/* 此处我们可以建立一个新的线程来处理这个新的连接 */
}
在服务器端,我们输入bind()
的参数包含的sin_addr.s_addr是INADDR_ANY,这是因为服务器应该接受来自所有地址的连接请求。如果你想要了解bind()的详细用法,那么你可以去看 这个网页 上对于这一系统调用的介绍。
socket 对于其中传送的内容没有限制,因此它本身并不受一个应用层协议的限制;你可以用 socket 实现包括 HTTP 在内的很多协议。从下一节开始我们就将进入应用层,学习一个被用来实现分布式系统的重要的应用层协议,RPC。
RPC与分布式文件系统
上一节中我们讲到了基于 RPC 实现的 NFS,它的特点是使用 write‑through 高速缓存,且不在内存中存储任何状态。Andrew File System(AFS)是另一个分布式文件系统。在 AFS 中,服务器会在内存中存储每个文件被哪些用户打开,这样当其中一个用户对文件进行修改后,服务器就可以通知剩下的用户这个文件已经被修改。与 NFS 不同,AFS 的 write‑through 高速缓存只在close()
被执行时才将被修改的文件写入服务器的磁盘;任何打开了一个文件的用户在关闭文件之前也不会获得文件的新版本,只有在他们关闭文件后再次打开时才能看到与服务器同步的最新版本。有兴趣的同学可以看一看卡内基梅隆大学这一文件系统的发明者所著的 论文。
练习
分布式系统的可靠性
前两节中我们认识了两种分布式文件系统 NFS 和 AFS。通过分析这两种文件系统我们可以看出,在分布式文件系统中可能存在很多可靠性的问题。服务器崩溃、磁盘崩溃等问题都可能造成文件系统的数据损失。磁盘崩溃这一问题可以通过储存多个不会同时出错的备份来解决,但服务器崩溃导致的状态丢失、操作中断的问题就比较复杂了。假如一个文件系统的服务器在执行用户请求的过程中出现崩溃,而用户无法得知这一事实,用户就会按照请求已成功执行来运行,这时系统中就会出现问题;更糟糕的是,如果系统在写入数据的过程中崩溃,系统中就可能出现被部分修改的数据,这一部分数据就无法再被正常使用。
为了避免这些问题,我们需要一种机制帮助我们记录全部已经完成的操作和正在进行的操作,以便于我们在重启后可以恢复到崩溃前的状态。为了了解这种机制,我们首先需要理解一个概念:事务(transaction)。
下图表示的是 2PC 中协调者和工人的状态变化关系:
键值存储
HTTP超文本传输协议
请求内容的长度通过 Content-Length 进行限定,在这个请求中,正文为 个字节。除了 Content-Length
,标头中还有很多参数信息。
标头参数
-
User‑Agent
有关浏览器(或其他 HTTP 客户端)的信息 -
Accept
浏览器可处理的页面类型 -
Accept‑Language
浏览器可处理的自然语言 -
Accept‑Charset
浏览器可接受的字符集 -
Host
服务器的 DNS 名称 -
Referer
发出请求前的 URL -
Connection
标识是否需要持久连接,keep‑alive 表示请求是 HTTP1.1,默认进行持久连接 -
Keep‑Alive
显示此次 HTTP 连接的 keep‑alive 时间,在此期间内,连接不会断开,以避免不断重新建立连接 -
Content‑Type
请求或响应的 MIME(Multipurpose Internet Mail Extensions,用于描述消息内容类型的标准)类型,比如 application/x‑www‑form‑urlencoded 表示表单数据,text/html 表示 html 页面数据 -
Content‑Length
请求或响应的正文长度,单位是字节