计算机网络
- 网络协议初识
- 协议分层
- OSI七层模型
- TCP/IP五层模型--初识
- 网络中的地址管理
- IP地址
- MAC地址
- 网络传输基本流程
- 网络编程套接字
- 预备知识
- 网络字节序
- socket编程
- UDP socket
- TCP socket
- 地址转换函数
- Jsoncpp
- 进程间关系与守护进程
- 进程组
- 会话
- 控制终端
- 作业控制
- 守护进程
- 网络命令
- TCP/IP五层模型
- 应用层
- HTTP协议
- URL
- HTTP协议格式
- HTTP的方法
- HTTP的状态码
- HTTP的常见Header
- HTTP cookie与HTTP session
- HTTP cookie
- HTTP session
- HTTPS协议原理
- 传输层
- UDP
- TCP
- TCP协议端的格式
- 确认应答机制
- 超时重传机制
- 连接管理机制
- 建立连接
- 断开连接
- 滑动窗口
- 流量控制
- 拥塞控制
- 延迟应答
- 粘包问题
- TCP全连接队列
- tcpdump抓包
- 网络层
- 协议格式
- IP报文的分片和组装
- 网段划分
- 网络路由
- 数据链路层
- 以太网帧格式
- MTU
- ARP协议
- ARP协议格式
- ARP欺骗
- 局域网转发
- 其他协议和技术
- NAT技术
- DNS
- ICMP协议
- 代理服务器
- 正向代理
- 反向代理
- NAT 和代理服务器
- 内网穿透
- 内网打洞
- 高级IO
- 五种IO模型
- IO多路转接
- select
- poll
- epoll
网络协议初识
协议分层
使用网络进行通信的一般是2台不同的主机,伴随着通信主机双方物理距离的变长,势必会带来一系列的问题:
- ①数据处理问题:通信双方拿到数据后该如何处理数据
- ②可靠性问题:如何保证数据在长距离传输过程中不会丢失,同时保证对方接收到的数据顺序不会错乱
- ③主机定位问题:通信双方如何快速定位对方主机,已将数据发送给对方
- ④数据包局域网转发问题:数据在网络中是一个节点接着一个节点往下转发最后到达目的主机的,如何将数据从一个节点转发到下一个节点
基于以上问题,人们提出了一系列的解决方案,最后提出了网络协议,该方案对网络进行了分层,层与层之间是解耦合的,可以随时替换和维护,可拓展性强,维护方便。
OSI七层模型
OSI(Open System Interconnection,开放系统互连)七层网络模型称为开放式系统互联参考模型,是一个逻辑上的定义和规范,把网络从逻辑上分为了7层,每一层都有相关对应的物理设备,比如路由器,交换机等。OSI 七层模型是一种框架性的设计方法,其只是提供了一种标准,并没有提供最后的具体实现,其最主要的功能使就是帮助不同类型的主机实现数据传输,它的最大优点是将服务、接口和协议这三个概念明确地区分开来,概念清楚,理论也比较完整,通过七个层次化的结构模型使不同的系统不同的网络之间实现可靠的通讯。
但是,人们后来实践起来的时候发现将5、6、7层分开实现困难,既复杂又不实用,最后人们将这三层进行了合并,统称为应用层,这就是TCP/IP五层模型,由于我们很少考虑物理层,故其也称TCP/IP四层模型。
TCP/IP五层模型–初识
TCP/IP五层模型中每一层都有特定的功能,以解决前面所说的数据在网络中传输的问题:
- 物理层:负责光/电信号的传递方式,比如现在以太网通用的网线(双绞线)、早期以太网采用的同轴电缆(现在主要用于有线电视)、光纤,现在的wifi无线网使用电磁波等都属于物理层的概念。物理层的能力决定了最大传输速率、传输距离、抗干扰性等。集线器(Hub)工作在物理层
- 数据链路层:用于解决问题④,负责设备之间的数据帧的传送和识别。例如网卡设备的驱动、帧同步(即从网线上检测到什么信号算作新帧的开始)、冲突检测(如果检测到冲突就自动重发)、数据差错校验等工作,有以太网、令牌环网、无线LAN等标准。交换机(Switch)工作在数据链路层。
- 网络层:用于解决问题③,负责地址管理和路由选择。例如在IP协议中,通过IP地址来标识一台主机,并通过路由表的方式规划出两台主机之间的数据传输的线路(路由)。路由器(Router)工作在网路层(现在不仅仅是在网络层工作)
- 传输层:用于解决问题②,负责两台主机之间的数据传输。如传输控制协议(TCP),能够确保数据可靠的从源主机发送到目标主机
- 应用层:用于解决问题①,负责应用程序间沟通,如简单电子邮件传输(SMTP)、文件传输协议(FTP)、网络远程访问协议(Telnet)等,我们的网络编程主要就是针对应用层
一般而言,一台主机实现了从传输层到物理层的全部内容;对于一台路由器,它实现了从网络层到物理层;对于一台交换机,它实现了从数据链路层到物理层;对于集线器,它只实现了物理层;但是现在这些并不绝对,很多交换机也实现了网络层的转发,很多路由器也实现了部分传输层的内容(比如端口转发)。
Linux和Windows尽管是不同的操作系统,但他们都遵循TCP/IP协议,故二者之间可以直接进行通信(协议的本质就是约定好的结构化的数据,只要数据结构保持一致,Linux和Windows对协议的具体实现可以不同)。
网络中的地址管理
IP地址
IP协议有两个版本:IPv4和IPv6,我们这里只考虑IPv4。IP地址是在IP协议中,用来标识网络中不同主机的地址,对于IPv4来说, IP地址是一个4字节,32位的整数,我们通常也使用 “点分十进制” 的字符串表示IP地址,例如 192.168.0.1 ,用点分割的每一个数字表示一个字节, 每一个数字范围是 0 - 255。
MAC地址
MAC地址用来识别数据链路层中相连的节点,长度为48比特位,6个字节,一般用16进制数字加上冒号的形式来表示,例如:08:00:27:03:fb:19,MAC地址在网卡出厂时就确定了,不能修改,通常是全球唯一的(虚拟机中的mac地址不是真实的mac地址,可能会冲突,也有些网卡支持用户配置mac地址)
用户可以使用Linux指令 ifconfig
查看 MAC地址
IP 地址描述的是网络中一条路径的起点和终点,MAC 地址描述的是路途上的相邻两个站点之间的起点和终点。
网络传输基本流程
不同的协议层对数据包有不同的称谓,在应用层叫请求(request)/响应(respone),在传输层叫做段(segment)或数据段,在网络层叫做数据报 (datagram),在链路层叫做帧(frame)或数据帧。应用层数据通过协议栈发到网络上时,每层协议都要加上一个数据首部(header)(也称为协议报头),该过程称为封装(Encapsulation),首部信息中包含了一些类似于首部有多长、载荷(payload)有多长、上层协议是什么等信息,数据封装成帧后发到传输介质上,到达目的主机后每层协议再剥掉相应的首部,根据首部中的 “上层协议字段” 将数据交给对应的上层协议处理,该过程称为解包与分用(解包即在当前协议层中将报头与有效载荷分离,分用即将有效载荷交付上层协议处理)。
网络编程套接字
预备知识
在认识网络套接字之前,我们先来学习一下端口号,现在我们知道可以利用ip地址将一个主机的数据发送给另一个主机,但目的主机上有那么多的进程(可以理解为有很多程序,例如既打开了微信,又打开了QQ),目的主机接收到数据后该交给哪一个进程来处理呢,我们这里使用端口号(port)来标识唯一一个进程,其是一个2字节的16位整数,用来告诉操作系统要将该数据交给哪一个进程,通常一个进程通常可以绑定多个端口号,但一个端口号只能唯一绑定一个进程,因此利用ip地址和端口号就可以唯一确定计算机网络中的一个进程,而在ip数据报报头中有2个IP地址,分别叫做源ip地址、目的ip地址,用来唯一标识互联网中的源主机和目的主机,在传输协议层的数据段报头中有2个端口号,分别叫做源端口号和目的端口号,分别用来标识源主机的唯一进程和目的主机的唯一进程,所以网络通信的本质就是进程间通信。需要注意的是:尽管技术上可以使用进程的pid代替端口号,但这无疑会增加网络的os的耦合度,因此该方法不被采用。
其中0-1023被称为知名端口号(Well-Know Port Number),只有root用户可以绑定,HTTP, FTP, SSH 等这些广为使用的应用层协议,他们的端口号都是固定的,一些常用的服务器,都是用以下这些固定的端口号:
- ssh 服务器,使用 22 端口
- ftp 服务器,使用 21 端口
- http 服务器,使用 80 端口
- https 服务器,使用 443
用户执行以下命令就可以查看知名端口号
cat /etc/services
1024-65535是客户端程序的端口号,就是可以由os动态为进程分配的端口号。
这里再简单认识一些传输层协议:
1.TCP协议
对TCP协议(Transmission Control Protocol 传输控制协议)的特点如下:
- 传输层协议
- 有连接
- 可靠传输
- 面向字节流
TCP协议保证可靠性,一旦数据发送出错就会重发,意味着TCP协议更为复杂,同时其数据是面向字节流的,即目的主机可能是先接收到数据的一部分,接着再读取到另一部分,需要自行对数据拼接,其通常用于对数据出错丢包不容忍的场景,如网络支付。
2.UDP协议
UDP协议(User Datagram Protocol 用户数据报协议)的特点如下:
- 传输层协议
- 无连接
- 不可靠传输
- 面向数据报
UDP协议不保证可靠性,一旦数据发送出错不做任何处理,意味着UDP协议较为简单,同时其数据是面向数据报的,即目的主机接收到的一定是一个完整的数据,其通常用于对数据丢包可以容忍的场景,如网络聊天。
网络字节序
我们已经知道,内存中的多字节数据相对于内存地址有大端和小端之分,磁盘文件中的多字节数据相对于文件中的偏移地址也有大端小端之分,网络数据流同样有大端小端之分,那么如何定义网络数据流的地址呢?
发送主机通常将发送缓冲区中的数据按内存地址从低到高的顺序发出,接收主机把从网络上接到的字节依次保存在接收缓冲区中,也是按内存地址从低到高的顺序保存,因此,网络数据流的地址应这样规定:先发出的数据是低地址,后发出的数据是高地址。
TCP/IP协议规定:网络数据流应采用大端字节序,即低地址高字节,不管这台主机是大端机还是小端机,都会按照这个TCP/IP规定的网络字节序来发送/接收数据,如果当前发送主机是小端,就需要先将数据转成大端,否则就忽略,直接发送即可。
为使网络程序具有可移植性,使同样的C代码在大端和小端计算机上编译后都能正常运行,可以调用以下库函数做网络字节序和主机字节序的转换:
#include <arpa/inet.h>
uint32_t htonl(uint32_t hostlong);
uint16_t htons(uint16_t hostshort);
uint32_t ntohl(uint32_t netlong);
uint16_t ntohs(uint16_t netshort);
这些函数名很好记,h表示host,n表示network,l表示32位长整数,s表示16位短整数,例如htonl表示将32位的长整数从主机字节序转换为网络字节序,例如将IP地址转换后准备发送。
如果主机是小端字节序,这些函数将参数做相应的大小端转换然后返回;如果主机是大端字节序,这些函数不做转换,将参数原封不动地返回。
socket编程
socket编程相关接口可以用于多种用途:本地通信、跨网络通信、网络管理等,只不过我们一般用于网络通信中,其他用途可以使用其他的接口替换。
UDP socket
1.创建socket文件描述符:
#include <sys/types.h>
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
如果成功,socket() 函数返回一个非负整数,即新创建的套接字的文件描述符。这个描述符用于后续的网络操作,如绑定地址(bind)、监听连接(listen,仅对 SOCK_STREAM 套接字)、接受连接(accept)、发送数据(send 或 sendto)、接收数据(recv 或 recvfrom)等。如果失败,socket() 函数返回 -1,并设置全局变量 errno
以指示错误原因。参数说明:
domain
:指定套接字使用的协议族。常用的协议族有 AF_INET(IPv4 地址)和 AF_INET6(IPv6 地址)。
type
:指定套接字的类型。常用的类型有 SOCK_STREAM(流式套接字,如 TCP),SOCK_DGRAM(数据报套接字,如 UDP),以及 SOCK_RAW(原始套接字,允许对较低级别的协议如 IP 或 ICMP 直接访问)。使用UDP协议时传 SOCK_DGRAM
即可
protocol
:指定使用的特定协议。大多数情况下,该参数可以设置为 0,让系统自动选择该类型套接字对应的默认协议。
无论是客户端还是服务端,都需要先创建网络套接字的文件描述符。
2.绑定端口号:
#include <sys/types.h>
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr,socklen_t addrlen);
成功时,bind 函数返回0。失败时,返回-1,并设置全局变量 errno
以指示错误原因,参数说明:
sockfd
:要绑定的套接字的文件描述符。
addr
:指向特定协议的地址结构的指针,该结构包含了要绑定的IP地址和端口号。
addrlen
:addr 参数的长度,通常使用 sizeof() 操作符来获取。
sockaddr
结构体说明:
sockaddr是一个通用的套接字地址结构体,用于存储套接字的地址信息。它在 <sys/socket.h>
头文件中定义,不过我们通常使用它的派生结构体 sockaddr_in
,在使用时可以将派生结构体指针强转为 sockaddr
指针,我们保证这种转换是安全的。
sockaddr_in
:
#include<netinet/in.h>
#include<arpa/inet.h>
struct sockaddr_in {
sa_family_t sin_family; // 地址族,对于 IPv4 来说总是 AF_INET
uint16_t sin_port; // 端口号,网络字节序
struct in_addr sin_addr; // IPv4 地址
// 在某些系统上,可能还有用于填充的字节,以确保结构体大小与 sockaddr 一致
// 但这通常不是显式声明的字段
};
// 其中,in_addr 结构体通常定义如下:
struct in_addr {
uint32_t s_addr; // IPv4 地址,网络字节序
};
sockaddr_in 是网络编程中用于表示 IPv4 套接字地址的结构体。它是在 <netinet/in.h> 或 <arpa/inet.h>(取决于操作系统和编译器)头文件中定义的,是 sockaddr 结构体的一个派生或特定于协议的版本。sockaddr_in 结构体提供了存储 IPv4 地址、端口号以及地址族(对于 IPv4 来说,地址族总是 AF_INET)的字段。
需要注意在填充 sockaddr_in
结构体时需要将主机字节序转为网络字节序,同时也不推荐绑定任何一个确定的IP地址(一台机器可以拥有多个ip地址),而是使用任意绑定,即将s_addr字段填充0或INADDR_ANY(宏值为0),相当于绑定了0.0.0.0,表示任意绑定。如果我们绑定的ip地址是127.0.0.1,则表示本地环回,即数据只是走一遍网络协议栈,并不发送出去,通常用于代码测试。
为了安全起见,可以在定义 sockaddr_in
结构体后使用 bzero()
将该结构体置0:
#include <string.h>
void bzero(void *s, size_t n);
sockaddr
还有其他的派生结构体,如 sockaddr_in6
用于处理 IPv6 地址和端口号,sockaddr_un
用于本地通信。
服务端需要调用bind()显示绑定,但客户端不需要这样显示绑定(注意是不需要用户去手动显示绑定,而不是不需要绑定,os在底层会在客户端首次发送数据时自动帮我们进行绑定),尽管用户也可以选择显示绑定,但我们不建议这么做,因为可能会出现多个进程显示绑定同一个端口号的情况,就会导致只有一个进程可以绑定端口号,从而导致异常。
如果用户用的是自己购买的云服务器进行socket编程,需要用户自己到购买的云服务器开通端口号才可以对服务器的端口号进行绑定。
3.读写数据:
#include <sys/types.h>
#include <sys/socket.h>
//向套接字写数据
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,const struct sockaddr *dest_addr, socklen_t addrlen);
//从套接字读数据
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,struct sockaddr *src_addr, socklen_t *addrlen);
- ①sendto()
成功时,sendto 返回发送的字节数,失败时,返回-1,并设置全局变量 errno :以指示错误原因。
sockfd
:是已打开的套接字描述符。
buf
:指向要发送数据的缓冲区。
len
:是要发送数据的长度。
flags
:通常设置为0,但可以用于控制发送操作的行为(如设置消息边界)。
dest_addr
:是一个指向 sockaddr 结构体的指针,包含了目标地址和端口信息。
addrlen
:是 dest_addr 的长度。
- ②recvfrom()
成功时,recvfrom 返回实际接收到的字节数,失败时,返回-1,并设置全局变量 errno 以指示错误原因。
sockfd
:是接收数据的套接字描述符。
buf
:是指向存储接收数据的缓冲区的指针。
len
:是缓冲区的大小,即最多可以接收多少字节的数据。
flags
:通常设置为0,但可以用于控制接收操作的行为(如设置消息边界标志等)。
src_addr
:是一个指向 sockaddr 结构体的指针,用于存储发送方的地址信息。调用前,这个结构体可以是未初始化的,调用后,它将被填充为发送方的地址信息。
addrlen
:是一个指向整数的指针,它包含了src_addr结构体的大小。在调用之前,应该设置为这个结构体的大小(如sizeof(struct sockaddr_in)对于IPv4),函数返回时,它可能包含了实际存储在src_addr中的地址信息的大小。
TCP socket
1.创建socket文件描述符:
#include <sys/types.h>
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
用于打开一个网络通讯端口,成功返回一个文件描述符,失败返回-1,参数说明:
domain
:指定套接字使用的协议族。常用的协议族有 AF_INET(IPv4 地址)和 AF_INET6(IPv6 地址)。
type
:指定套接字的类型。对于TCP协议传 SOCK_STREAM
即可
protocol
:指定使用的特定协议。大多数情况下,该参数可以设置为 0,让系统自动选择该类型套接字对应的默认协议。
2.服务端设置地址复用
在旧的服务器进程终止后(也可能是因为BUG导致服务器意外终止),需要等待一段时间才能重新绑定相同的地址和端口号,原因与TCP协议的设计有关,特别是与TCP连接的终止过程和“TIME_WAIT”状态有关。地址复用(Address Reuse)是指在网络编程中,允许在同一台主机上的多个套接字(Socket)绑定到同一个网络地址和端口。在TCP/IP网络中,通常情况下,一个端口在同一时间只能被一个套接字绑定。地址复用技术允许在特定条件下违反这一规则,这样在服务器终止后,其可以快速重启保持之前的端口号。
#include<sys/socket.h>
int setsockopt(int sockfd, int level, int optname, const voi*optval, socklen_t optlen);
setsockopt函数是一个用于设置套接字选项的函数,如果设置套接字选项成功,setsockopt函数返回0。这表示指定的套接字选项已经被成功设置,如果设置套接字选项失败,setsockopt函数返回-1,参数说明:
sockfd
:套接字文件描述符,用于标识要设置选项的套接字。
level
:指定选项的级别,它决定了optname的解释。常见的级别包括SOL_SOCKET(套接字级别选项)、IPPROTO_TCP(TCP协议级别选项)等。
optname
:指定要设置的选项的名称,如缓冲区大小、超时设置、广播选项等,可以通过设置 SO_REUSEADDR 套接字选项来允许地址复用。
optval
:一个指向存储选项值的缓冲区的指针,该值将被设置到套接字上。
optlen
:optval缓冲区的长度,用于确保传递了正确的数据大小。
使用示例:
int sockfd;
int opt = 1; //用于将SO_REUSEADDR设置为1, 如果要设置地址复用,必须将SO_REUSEADDR 设置为1
// 创建套接字(省略了错误检查和细节)
sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) {
perror("socket creation failed");
exit(EXIT_FAILURE);
}
// 设置SO_REUSEADDR选项
if (setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) < 0) {
perror("setsockopt");
close(sockfd); // 出错时关闭套接字
exit(EXIT_FAILURE);
}
3.绑定端口号:
#include <sys/types.h>
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr,socklen_t addrlen);
与UDP的绑定用法一样,其注意事项也与UDP的绑定一样
4.服务端开启监听:
#include <sys/types.h>
#include <sys/socket.h>
int listen(int sockfd, int backlog);
让sockfd处于监听状态,用于服务端等待客户端connect()链接服务端,成功返回0,失败返回-1,参数说明:
sockfd
:指向一个已打开的套接字,用于进行监听
backlog
:用于定义内核应该为相应套接字排队的最大连接数。即当服务器繁忙时,新的连接请求会被放入队列中,直到服务器准备好接受连接(即调用 accept 函数)。
5.客户端发起连接:
#include <sys/types.h>
#include <sys/socket.h>
int connect(int sockfd, const struct sockaddr *addr,socklen_t addrlen);
用于客户端向服务器发起连接,以建立客户端与服务器之间的连接,成功返回0,失败返回-1,参数说明:
sockfd
:这是客户端套接字的文件描述符,它标识了一个打开的套接字。这个套接字之前应该通过 socket 函数创建,但尚未与任何服务器建立连接。
addr
:这是一个指向 sockaddr 结构体的指针,该结构体包含了服务器的地址信息。由于 sockaddr 是一个通用结构体,实际使用时通常会使用 sockaddr_in(对于 IPv4 地址)或 sockaddr_in6(对于 IPv6 地址)等更具体的结构体,并通过类型转换后传递给 connect 函数,具体可以参考前面UDP socket对struct sockaddr的介绍。
addrlen
:这个参数指定了 addr 指向的地址结构体的长度。对于 sockaddr_in 来说,这个长度通常是 sizeof(struct sockaddr_in)
6.服务端获取链接:
#include <sys/types.h>
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
用于服务端获取客户端使用connect()发起的连接,成功返回一个文件描述符,用于数据读写,失败返回-1并设置对应错误码,参数说明:
sockfd
:这是监听套接字的文件描述符
addr
:这是一个输出型参数,用于接收客户端的地址信息。如果调用者对此信息不感兴趣,可以将其设置为 NULL。如果提供了非 NULL 的地址,accept 函数会将客户端的地址填充到该结构体中。
addrlen
:这是一个指向 socklen_t 变量的指针,该变量在调用 accept 之前应该被设置为 addr 指向的地址结构体的长度。在 accept 返回时,该变量会被更新为实际接收到的地址长度。
7.数据读写:
#include <unistd.h>
//向套接字写数据
ssize_t write(int fd, const void *buf, size_t count);
//从套接字读数据,但无法得到发送方的信息
ssize_t read(int fd, void *buf, size_t count);
需要注意的是在TCP中并不是直接利用sockfd进行消息读写的,而是对accept()获取连接成功后的文件描述符进行读写,同时当客户端断开连接后,服务端需要使用 close()
关闭该文件描述符,以防止文件描述符泄漏。
地址复用:
地址转换函数
在IPv4中IP地址有2中表示方法:32位整型表示的IP地址和点分十进制风格的IP地址,我们通常需要将IP地址在二者之间进行相互转换,下面介绍一些转换函数:
1.字符串风格转整型风格:
①
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
in_addr_t inet_addr(const char *cp);
用于将点分十进制的 IPv4 地址字符串(如 “192.168.1.1”)转换为 网络字节顺序
的整数(in_addr_t 类型)的函数,如果输入字符串是一个有效的 IPv4 地址,则 inet_addr 函数返回该地址的网络字节顺序表示(in_addr_t 类型)。如果输入字符串不是一个有效的 IPv4 地址,或者包含无法识别的字符,则函数返回 INADDR_NONE(通常是 0xFFFFFFFF),表示转换失败,参数说明:
cp
:指向一个字符串,该字符串包含了需要转换的 IPv4 地址
②
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
int inet_aton(const char *cp, struct in_addr *inp);
用于将点分十进制的 IPv4 地址字符串(如 “192.168.1.1”)转换成网络字节顺序
的整型形式,并存储在 struct in_addr 结构体中,如果输入字符串是一个有效的 IPv4 地址,则 inet_aton 函数返回非零值(通常为 1),如果输入字符串不是一个有效的 IPv4 地址,则函数返回零,参数说明:
cp
:指向一个字符串,该字符串包含了需要转换的 IPv4 地址
inp
:指向 struct in_addr 结构体的指针,该结构体用于存储转换后的二进制形式的 IP 地址。struct in_addr 通常包含一个无符号 32 位整数(uint32_t),用于表示网络字节顺序的 IP 地址
struct in_addr 的具体实现可能因操作系统而异,但最常见的定义如下所示:
struct in_addr
{
uint32_t s_addr; // 这是一个无符号 32 位整数,用于存储 IP 地址
};
③
#include <arpa/inet.h>
int inet_pton(int af, const char *src, void *dst);
用于将点分十进制(对于 IPv4)或十六进制字符串(对于 IPv6)的 IP 地址转换为 网络字节顺序
的二进制形式的函数。这个函数比 inet_addr 函数更加灵活和强大,因为它同时支持 IPv4 和 IPv6 地址,并且提供了更明确的错误处理机制。如果转换成功,函数返回 1;如果输入的字符串不是一个有效的 IP 地址(对于指定的地址族),函数返回 0;如果发生错误(例如,无效的地址族),函数返回 -1,并设置 errno 以指示错误原因。参数说明:
af
:地址族(Address Family),指定要转换的地址类型。对于 IPv4,这个值应该是 AF_INET;对于 IPv6,这个值应该是 AF_INET6。
src
:指向一个字符串,该字符串包含了需要转换的 IP 地址(例如,对于 IPv4 是 “192.168.1.1”,对于 IPv6 是 “2001:0db8:85a3:0000:0000:8a2e:0370:7334”)。
dst:指向用于存储转换后结果的缓冲区的指针。对于 IPv4 地址,这个缓冲区应该足够大以存储一个 struct in_addr(或等效的 uint32_t)的值;对于 IPv6 地址,这个缓冲区应该足够大以存储一个 struct in6_addr 的值。
2.整型风格转字符串风格:
①
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
char *inet_ntoa(struct in_addr in);
用于将网络字节顺序的 IPv4 地址(存储在 struct in_addr 结构体中)转换为点分十进制的字符串形式的函数。函数返回一个指向静态分配的字符数组的指针,该数组包含了输入地址的点分十进制表示。需要注意的是,这个返回的指针指向的是一个静态分配的内存区域,这意味着每次调用 inet_ntoa 时,它都会覆盖上一次调用的结果。参数说明:
in
: 是一个 struct in_addr 类型的结构体,它包含了一个网络字节顺序的 IPv4 地址。
需要注意的是inet_ntoa() 函数并不是线程安全的,因为它使用了静态分配的缓冲区来存储结果字符串,该静态缓冲区不需要用户去释放。在多线程环境中,建议使用 inet_ntop() 函数作为替代,该函数提供了更多的灵活性和线程安全性。
②
#include <arpa/inet.h>
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
用于将网络地址(IPv4 或 IPv6)从网络字节顺序转换为点分十进制(对于 IPv4)或十六进制字符串(对于 IPv6)的函数。这个函数比 inet_ntoa 更灵活且安全,因为它允许你指定一个缓冲区来存储转换后的字符串,从而避免了使用静态内存的问题,这在多线程环境中尤为重要。返回一个指向 dst 的指针,如果转换失败,则返回 NULL,并设置 errno 以指示错误原因。参数说明:
af
:地址族,AF_INET 表示 IPv4 地址,AF_INET6 表示 IPv6 地址。
src
:指向包含网络字节顺序地址的缓冲区的指针。对于 IPv4,它是一个指向 struct in_addr 的指针;对于 IPv6,它是一个指向 struct in6_addr 的指针。
dst
:指向用于存储转换后字符串的缓冲区的指针。
size
:dst 缓冲区的大小(以字节为单位)。
这些函数也比较好记,a表示地址(address),即点分十进制形式的IP地址;n表示网络(network),即网络字节序的IP地址;p译为表示(presentation),即人类可读的表示法,可用于点分十进制的 IPv4 地址或冒号分隔的 IPv6 地址
同时需要注意前面这些所有函数(包括字符串转整型)所用到的或返回的整型风格的IP地址都是网络字节序的。
Jsoncpp
由于Tcp协议是面向字节流的,不保证接收方能一次接收到一个完整的数据报,因此需要用户在应用层将数据的格式进行约定(即自定义协议或者私有协议),对数据进行包装后再将数据发送给对方,对方接收到数据化再按照约定对数据进行解析,解析出一个完整的报文后才交付上层让上层对数据进行处理。考虑到系统复杂性和维护成本,同时为了服务的拓展性,一般发送方会将数据jason化,将数据结构或对象转换为一种格式,以便在网络上传输或存储到文件中,再将数据按照自定义协议进行包装通过tcp协议发送到网络。
Jsoncpp 是一个用于处理 JSON 数据的 C++ 库。它提供了将 JSON 数据序列化为字符串以及从字符串反序列化为 C++ 数据结构的功能。Jsoncpp 是开源的,广泛用于各种需要处理 JSON 数据的 C++ 项目中,其安装方法如下:
ubuntu:sudo apt-get install libjsoncpp-dev
Centos: sudo yum install jsoncpp-devel
在编译时需要添加选项 -ljsoncpp
1.序列化:
①使用 Json::Value 的 toStyledString 方法:
#include <jsoncpp/json/json.h>
Json::Value root;
root["name"] = "joe";
root["sex"] = "男";
std::string s = root.toStyledString();
该方法使用简单,将 Json::Value 对象直接转换为格式化的 JSON 字符串
②. 使用 Json::StreamWriter:
#include <iostream>
#include <string>
#include <sstream>
#include <memory>
#include <jsoncpp/json/json.h>
int main()
{
Json::Value root;
root["name"] = "joe";
root["sex"] = "男";
Json::StreamWriterBuilder wbuilder; // StreamWriter 的工厂
std::unique_ptr<Json::StreamWriter> writer(wbuilder.newStreamWriter());
std::stringstream ss;
writer->write(root, &ss);
std::cout << ss.str() << std::endl;
return 0;
}
该方法提供了更多的定制选项,如缩进、换行符等
③使用 Json::FastWriter:
#include <iostream>
#include <string>
#include <sstream>
#include <memory>
#include <jsoncpp/json/json.h>
int main()
{
Json::Value root;
root["name"] = "joe";
root["sex"] = "男";
Json::FastWriter writer;
std::string s = writer.write(root);
std::cout << s << std::endl;
return 0;
}
该方法比 StyledWriter 更快,因为它不添加额外的空格和换行符。
2.反序列化:
①使用 Json::Reader:
#include <iostream>
#include <string>
#include <jsoncpp/json/json.h>
Json::Reader reader;
Json::Value root;
// 从字符串s中读取 JSON 数据
if (reader.parse(s,root))
{
// 解析成功,访问 JSON 数据
std::string name = root["name"].asString();
int age = root["age"].asInt();
std::string city = root["city"].asString();
}
该方法提供详细的错误信息和位置,方便调试。
②使用 Json::CharReader 的派生类:
- 在某些情况下,可能需要更精细地控制解析过程,可以直接使用Json::CharReader 的派生类。
- 但通常情况下,使用 Json::parseFromStream 或 Json::Reader 的 parse方法就足够了。
进程间关系与守护进程
进程组
每一个进程除了有一个进程 ID(PID)之外还属于一个进程组。进程组是一个或者多个进程的集合,一个进程组可以包含多个进程。 每一个进程组也有一个唯一的进程组 ID(PGID),并且这个 PGID 类似于进程 ID,同样是一个正整数,可以存放在 pid_t 数据类型中。通常由一个进程组之间的进程相互配合完成某个特定的任务。bash进程是一个独立的进程组,当用户新建一个会话时,该会话也会有一个自己的-bash进程,也是自成一个独立的进程组。
可以使用指令 ps -eo pid,pgid,ppid,comm 查看进程的相关信息,包括进程id,进程组id等信息,其中:
- e 选项表示 every 的意思,表示输出每一个进程信息
- o 选项以逗号操作符(,)作为定界符,可以指定要输出的列
每一个进程组都有一个组长进程,进程组组长可以创建一个进程组或者创建该组中的进程,而进程组的生命周期为从进程组创建开始到其中最后一个进程离开为止,即只要某个进程组中有一个进程存在,则该进程组就存在,这与其组长进程是否已经终止无关。
会话
会话其实和进程组息息相关,当我们打开shell连接远端服务器时,shell会为我们打开一个终端文件,然后新启动一个-bash进程,这就相当于建立了一个会话,会话可以看成是一个或多个进程组的集合,一个会话可以包含多个进程组,其内部至少有一个进程即 -bash 进程,每一个会话也有一个会话 ID(SID),一般为-bash进程的id。
在该会话下运行一个可执行程序时,该可执行程序就会变成一个与该会话相关联的进程,如果我们想另起一个新会话,让当前会话下的可执行程序称为新会话下的进程,从而与当前会话取关联,可以使用函数:
#include <unistd.h>
pid_t setsid(void);
创建会话成功返回 SID,失败返回-1
调用该函数的进程会变成新会话的会话首进程,此时新会话中只有唯一的一个进程,而且进程会变成进程组组长,新进程组 ID 就是当前调用进程 ID,该进程没有控制终端,如果在调用 setsid 之前该进程存在控制终端,则调用之后会切断联系。
需要注意的是:这个接口如果调用进程原来是进程组组长,则会报错,为了避免这种情况,我们通常的使用方法是先调用 fork 创建子进程,父进程终止,子进程继续执行,因为子进程会继承父进程的进程组 ID,而进程 ID 则是新分配的,就不会出现错误的情况。
控制终端
在 UNIX 系统中,用户通过终端登录系统后得到一个 Shell 进程,这个终端成为 Shell进程的控制终端。控制终端是保存在 PCB 中的信息,我们知道 fork 进程会复制 PCB中的信息,因此由 Shell 进程启动的其它进程的控制终端也是这个终端。默认情况下没有重定向,每个进程的标准输入、标准输出和标准错误都指向控制终端,进程从标准输入读也就是读用户的键盘输入,进程往标准输出或标准错误输出写也就是输出到显示器上。
每一个终端都是一个文件,他们在目录 ==/dev/pts/==下,如/dev/pts/0和/dev/pts/1都是一个终端文件,可以像普通文件一样读写和重定向。
在同一个会话中,任何时候都只允许运行一个前台进程组,但可以同时运行多个后台进程组,而只有前台进程组才可以与控制终端进行交互,Linux中的命令就是通过创建一个前台进程去执行,而bash进程转为后台进程了,此时该命令执行期间,我们再次向终端输入命令是不会有响应的,因为只有前台进程可以与终端交互,bash进程此时已经转为后台进程了没有接收到任何输入,我们可以在命令后面加上&,表示在执行该命令的进程直接转为后台进程。
作业控制
作业是针对用户来讲的,用户完成某项任务会启动一些进程,一个作业既可以只包含一个进程,也可以包含多个进程,进程之间互相协作完成任务。
Shell 分前后台来控制作业或者进程组。一个前台作业可以由多个进程组成,一个后台作业也可以由多个进程组成,Shell 可以同时运⾏一个前台作业和任意多个后台作业,这称为作业控制。
例如下列命令就是一个作业,它包括两个命令,在执⾏时 Shell 将在前台启动由两个进程组成的作业:
cat /etc/filesystems | head -n 5
放在后台执⾏的程序或命令称为后台命令,可以在命令的后面加上&符号从而让Shell 识别这是一个后台命令,后台进程执行完后会返回一个作业号以及一个进程号(PID)。
可以使用指令 jobs 查看后台作业,选项-l 则显示作业的详细信息,选项-p 则只显示作业的 PID。以下是返回的一些符号和状态解释:
+: 表示该作业号是默认作业
-:表示该作业即将成为默认作业
无符号: 表示其他作业
对于一个用户来说,只能有一个默认作业(+),同时也只能有一
个即将成为默认作业的作业(-),当默认作业退出后,该作业会成为默认作业。
作业状态:
可以使用指令 fg 作业号 将该作业转为后台作业,如果想将一个作业转为前台作业,先使用ctrl+z将当前作业暂停,其就会自动转为后台作业,接着使用指令 bg 作业号 启动该作业。
守护进程
当我们开发了一个服务端后,我们是在一个会话下面启动运行该服务端的,如果该会话退出,会话内的任务也会退出,因此我们就需要为该服务端进行新建一个会话,该会话只有服务端这一个进程,此时该进程就会称为一个守护进程(也称为精灵进程,本质就是一个孤儿进程),该进程独立运行,通常不与任何用户交互,这样就保证了服务端运行时的稳定性。我们将一个进程变为守护进程的一般步骤为:
- 忽略可能引起程序异常退出的信号
signal(SIGCHLD, SIG_IGN);
signal(SIGPIPE, SIG_IGN);
- 让自己不要成为组长
if (fork() > 0)
exit(0);
- 设置让自己成为一个新的会话, 后面的代码其实是子进程在走
setsid();
- 每一个进程都有自己的 CWD,可以选择将当前进程的 CWD 更改成为 /根目录
chdir(目录);
- 已经变成守护进程啦,不需要和用户的输入输出,错误进行关联了,可以直接关闭对应文件描述符
close(0);
close(1);
close(2);
还可以选择重定向到文件dev_null中
int fd = open(dev_null, O_RDWR);
if (fd > 0)
{
dup2(fd, 0);
dup2(fd, 1);
dup2(fd, 2);
close(fd);
}
输入到文件dev_null的数据会被忽略,从该文件读取数据会读到空,如果需要,推荐使用重定向这种方法。
网络命令
1. netstat
netstat(Network Status,网络状态)是一个用来查看网络状态的重要工具,用法为:
netstat [选项]
常用选项:
- n 拒绝显示别名,能显示数字的全部转化成数字
- l 仅列出有在 Listen (监听) 的服務状态
- p 显示建立相关链接的程序名
- t (tcp)仅显示tcp相关选项
- u (udp)仅显示udp相关选项
- a (all)显示所有选项,默认不显示LISTEN相关
2. pidof
用于通过进程名查看进程id,用法为:
pidof [选项] [进程名称]
- -s:仅返回一个进程号。如果有多个与指定名称匹配的进程在运行,pidof将只返回第一个找到的进程的PID。
- -c:仅显示具有相同“root”目录的进程。这个选项只对root用户有效,它可以帮助用户筛选出具有特定根目录的进程。
- -x:显示由脚本开启的进程。这个选项允许用户查找由shell脚本或其他程序启动的进程。
- -o:指定不显示的进程ID。用户可以通过这个选项来排除某些特定的进程ID,以避免它们在输出中出现
3. ping
ping(Packet Internet Groper,网络包探测器)是一个程序,用于验证网络的连通性,同时也会统计响应时间和 TTL(IP 包中的Time To Live,生存周期),ping 命令基于 ICMP, 是在网络层,使用时ping 命令会先发送一个 ICMP Echo Request 给对端,对端接收到之后会返回一个 ICMP Echo Reply,根本不关心端口号,用法为:
ping [选项] [目标主机的IP地址或域名]
常用选项有:
-
-t:持续发送ping请求,直到手动停止。
-
-n:指定发送的ping请求次数。默认情况下,ping命令会发送4个请求。例如,“ping -n 10 192.168.1.1”将发送10个数据包进行测试。
-
-l(或-s,具体取决于操作系统):设置发送的数据包大小。默认情况下,ping命令发送的数据包大小为32字节。通过指定大小,可以测试网络在不同数据包大小下的性能。
-
-a:将IP地址解析为主机名。当目标主机的IP地址不容易记忆时,可以使用这个参数将IP地址转换为更易于理解的主机名。例如,“ping -a 192.168.1.1”可能会返回类似“Pinging example-PC [192.168.1.1]”的信息。
-
-w:设置超时时间(某些操作系统中可能不支持此参数)。Ping命令默认的超时时间是一定的毫秒数(如400毫秒)。通过指定超时时间,可以更灵活地控制ping命令的等待响应时间。例如,“ping -w 1000 192.168.1.1”将超时时间设置为1000毫秒
4. traceroute
traceroute(Trace Route,路由追踪),用于显示数据包从源主机到目的主机所经过的路由器路径,它也是基于ICMP协议实现的,不关心端口号,用法为:
traceroute [目标主机IP地址或域名]
常用选项有:
- -n:禁用域名解析,只显示IP地址,这可以加快命令的执行速度,特别是在DNS解析较慢或不可用时。
- -m <跳数>:设置检测数据包的最大存活数值TTL的大小,即限制追踪的最大跳数。
- -w <秒数>:设置等待每个路由器响应的超时时间。如果路由器在指定时间内没有响应,则显示超时消息。
- -q <次数>:指定每个跳数发送的数据包数量。默认情况下,Traceroute会发送多个数据包以获得更准确的延迟统计信息。
- -I(仅Unix/Linux):使用ICMP Echo请求代替UDP数据包进行追踪。这在某些情况下可能更有用,特别是当目标主机对UDP数据包有特定过滤规则时。
5. telnet
Telnet(TErminaL NETwork)是一种基于文本的远程登录协议,允许用户通过网络连接到远程计算机,并在远程计算机上执行命令,用法为:
telnet [IP地址] [端口号]
它默认使用端口号是23
6.ssh
SSH(Secure Shell)命令是一种用于远程登录、文件传输及远程命令执行的强大工具,它通过网络在客户端和服务器之间建立安全的加密连接,用法为:
ssh [选项] 用户名@主机名或IP地址
默认使用的端口号是22
7.ARP
ARP命令是用于操作和管理计算机网络中的ARP(Address Resolution Protocol,地址解析协议)缓存的命令。ARP协议的主要功能是将网络中的IP地址解析为目标硬件地址(MAC地址),以保证通信的顺利进行,用法为:
arp [选项] [参数]
常用选项和参数:
- -a:显示所有接口的当前ARP缓存表,将列出计算机中存储的已解析IP地址和相应的MAC地址。
- -d InetAddr [IfaceAddr]:删除指定IP地址的ARP缓存项。InetAddr代表要删除的IP地址,IfaceAddr代表指派给该接口的IP地址(可选)。如果未指定IfaceAddr,则使用第一个适用的接口。
- -s InetAddr EtherAddr [IfaceAddr]:向ARP缓存添加静态项。InetAddr代表IP地址,EtherAddr代表物理地址(MAC地址),IfaceAddr代表指派给该接口的IP地址(可选)。通过此命令添加的项属于静态项,它们不会因ARP缓存超时而被删除。
- -g:与-a相同,用于显示本地ARP缓存表。在某些操作系统中,可以使用-g选项命令来显示ARP缓存表。
- -n:显示本地ARP缓存表,但不解析主机名。这个命令可以显示ARP缓存表,但不会尝试将IP地址解析为主机名。
TCP/IP五层模型
应用层
开发者写的一个个用于解决实际问题的网络程序都是在应用层进行开发,由于其下一层传输层可能是基于TCP也可能是基于UDP,因此应用层对不同的传输层协议有不同的处理方法:对传输层协议为UDP时,由于UDP是面向数据报的,开发者不需要在应用层添加自定义协议保证接收到一个完整的数据报,但为了维护方便,通常会在应用层对数据进行json序列化和反序列化;对传输层协议为TCP,由于TCP是面向字节流的,不保证接收方能一次就接收到一个完整的数据报,因此需要开发者在应用层添加自定义协议保证接收到一个完整的数据报再进行处理,为了维护方便,其也要在应用层对数据进行json序列化和反序列化。
HTTP协议
我们经常说应用层的协议是由开发者自己定制的,其实前人已经定制好了一些现成的且非常好用的协议,可以直接供我们参考使用,其中比较常用的就是HTTP协议,即超文本传输协议(https与http是类似的,只不过https会对文本进行加密处理),其是基于TCP的应用层协议。
URL
我们平时所说的网址,其实就是URL(Uniform Resource Locator,统一资源定位符)
片段标识符不是发送给服务器的,而是由浏览器解析并在客户端内部使用,以便直接定位到页面内的某个特定元素或执行与锚点相关联的JavaScript代码,可以忽略。
总之,除了服务器地址和目标文件外,其余的都可以省略,在HTML中,服务器地址也可以省略,表示请求服务器ip地址与当前HTML所属ip地址一致。
省略协议方案名,则默认使用的协议是HTTP协议
省略端口号,浏览器默认根据协议选择端口号,HTTP协议默认访问的服务器端口号为80,HTTPS协议默认访问的服务器端口号为443。
省略文件路径,但目标文件名不能省,表示请求的文件路径为‘/’,服务端需要自己解析‘\’表示那个文件路径
像/?:等这样的字符,已经被url当做特殊意义理解了(在URL中?表示接下来是参数,参数之间以&分隔,如果后面还有其他内容则用#分隔表示参数部分结束),因此这些字符不能随意出现,如果某个参数中需要带有这些特殊字符,就必须先对特殊字符进行转义,转义的规则如下:
将需要转码的字符转为16进制,然后从右到左,取4位(不足4位直接处理),每2位做一位,前面加上%,编码成%XY格式,例如+的ASCII码是43,被转义成 %2B。
HTTP协议格式
1.HTTP协议请求格式:
[方法]+[空格]+[URL]+[空格]+[版本号]+[换行符]+[请求属性]+[换行符]+[空行(其实就是换行符)]+[请求正文]
其中版本号可以忽略不写,请求属性也称为Header,是用冒号分割的键值对,如果有多组属性,也需要使用换行符进行分割,空行是用于表示Header部分结束,如果没有在URL中指定请求资源的文件路径,默认请求资源路径是:“/”,此时服务端如果解析到请求路径是:“/”,服务端开发者可以自定义返回一个主网页供客户端使用,正文部分也称为Body,Body允许为空字符串,如果Body存在,则要在Header中加一个Content-Length属性来标识Body的长度。
例如以下一个http请求:
POST http//example.edu.cn/main.html HTTP/1.1
Host: example.edu.cn
Content-Length: 15
example_request
2.HTTP协议应答格式:
[版本号]+[空格]+[状态码]+[空格]+[状态码解释]+[换行符]+[请求的属性]+[换行符]+[空行]+[应答正文]
正文部分也称为Body,Body允许为空字符串,如果Body存在,则要在Header中加一个Content-Length属性来标识Body的长度,如果服务器返回了一个html页面(HTML是一种用于创建网页内容的标记语言),那么html页面内容就是在body中,浏览器获取html内容后会根据获得的内容构建渲染网页呈现给用户,关于HTML的学习和使用可以参考:form表单 或 HTML
例如以下的一个http响应:
HTTP/1.1 200 OK
Content-language: zh-CN
Content-Length: 16
example_response
HTTP的方法
1.GET方法:
用于请求URL指定的资源,也可以以URL的形式向服务端提交参数,即在URL中?表示接下来是参数,参数之间以&分隔,使用这种方法时但不建议上传的参数太大。
2.POST方法:
用于传输实体的主体,通常用于提交表单数据,可以在正文中向服务器上传参数,可以上传参数的大小比GET大,且更加私密(注意是私密,不等同安全,无论哪种方法上传参数,都可以通过网络抓包工具抓到)。
3.PUT方法:
用于传输文件,将请求报文主体中的文件保存到请求URL指定的位置,在某些情况下,如RESTfulAPI中,可以用于更新资源
4.HEAD方法:
与GET方法类似,但不返回报文主体部分,仅返回响应头,用于确认URL的有效性及资源更新的日期时间等
5.DELETE方法:
用于删除文件,按请求URL删除指定的资源,是PUT的相反方法
6.OPTIONS方法:
用于查询针对请求URL指定的资源支持的方法,返回允许的方法,如GET、POST等
这些方法中比较常用的方法是POST和GET。
HTTP的状态码
最常见的状态码,比如200(OK),404(NotFound),403(Forbidden),302(Redirect,重定向),504(BadGateway)
以下是仅包含重定向相关状态码的表格:
当服务器返回 HTTP 301 状态码时,表示请求的资源已经被永久移动到新的位置,在这种情况下,服务器会在响应中添加一个 Location 头部,用于指定资源的新位置。这个 Location 头部包含了新的 URL 地址,浏览器会自动重定向到该地址,例如:
HTTP/1.1 302 Found\r\n
Location: https://www.new-url.com\r\n
当服务器返回 HTTP 302 状态码时,表示请求的资源临时被移动到新的位置,同样地,服务器也会在响应中添加一个 Location 头部来指定资源的新位置。浏览器会暂时使用新的 URL 进行后续的请求,但不会缓存这个重定向,例如:
HTTP/1.1 302 Found\r\n
Location: https://www.new-url.com\r\n
总之,无论是 HTTP 301 还是 HTTP 302 重定向,都需要依赖 Location 选项来指定资源的新位置。这个 Location 选项是一个标准的 HTTP 响应头部,用于告诉浏览器应该将请求重定向到哪个新的 URL 地址。
HTTP的常见Header
Content-Type
: 数据类型(text/html 等) ,详细信息可以查看Content-Type
Content-Length
: Body 的长度
Host
: 客户端告知服务器, 所请求的资源是在哪个主机的哪个端口上;
User-Agent
: 声明用户的操作系统和浏览器版本信息;
referer
当前页面是从哪个页面跳转过来的;
Location
: 搭配 3xx 状态码使用, 告诉客户端接下来要去哪里访问; Cookie
: 用于在客户端存储少量信息. 通常用于实现会话(session)的功能;
以上是HTTP常用的Header,更多的Header如下:
HTTP 中的 Connection 字段是 HTTP 报文头的一部分,它主要用于控制和管理客户端与服务器之间的连接状态,核心作用是管理持久连接(也称为长连接):持久连接允许客户端和服务器在请求/响应完成后不立即关闭 TCP 连接,以便在同一个连接上发送多个请求和接收多个响应。
在 HTTP/1.1 协议中,默认使用持久连接。当客户端和服务器都不明确指定关闭连接时,连接将保持打开状态,以便后续的请求和响应可以复用同一个连接。
在 HTTP/1.0 协议中,默认连接是非持久的。如果希望在 HTTP/1.0
上实现持久连接,需要在请求头中显式设置 Connection: keep-alive。
语法格式:
- Connection: keep-alive:表示希望保持连接以复用 TCP 连接。
- Connection: close:表示请求/响应完成后,应该关闭 TCP 连接
如果用户通过浏览器访问服务端,浏览器会自动发起以下请求获取网站图标:
GET /favicon.ico HTTP/1.1
favicon.ico 是一个网站图标,通常显示在浏览器的标签页上、地址栏旁边或收藏夹中。这个图标的文件名 favicon 是 “favorite icon” 的缩写,而 .ico 是图标的文件格式,浏览器在发起请求的时候,也会为了获取图标而专门构建 http 请求。
HTTP cookie与HTTP session
Http协议本身是无状态和无连接的(无连接即http协议在客户端收到服务端的应答之后就会断开连接,后面引入了持久连接来解决该性能问题,无状态即协议本身不缓存任何客户端历史上的请求信息),如果服务端想要识别用户身份,就需要用到cookie和session技术(也合称为会话管理技术)。
HTTP cookie
HTTP Cookie(也称为 Web Cookie、浏览器 Cookie 或简称 Cookie)是服务器发送到用户浏览器并保存在浏览器上的一小块数据,之后在浏览器向同一服务器再次发起请求时cookie被携带并发送到服务器上。通常,它用于告知服务端两个请求是否来自同一浏览器,如用户认证和会话管理、 跟踪用户行为、保持用户的登录状态、记录用户偏好等。
当用户第一次访问网站时,服务器会在响应的 HTTP 头中设置 Set-Cookie字段,用于发送 Cookie 到用户的浏览器,浏览器在接收到 Cookie 后,会将其保存起来(可能是内存级存储,也可能是文件级存储,通常是按照域名进行本地存储),在之后的请求中,浏览器会自动在 HTTP 请求头中携带 Cookie 字段,将之前保存的 Cookie 信息发送给服务器。
cookie有会话 Cookie与持久 Cookie(Persistent Cookie)之分(即内存级cookie和文件级cookie):
- 会话 Cookie(Session Cookie):在浏览器关闭时失效。
- 持久 Cookie(Persistent Cookie):带有明确的过期日期或持续时间,可以跨多个浏览器会话存在,可以长时间保存。
如果 cookie 是一个持久性的 cookie,那么它其实就是浏览器相关的特定目录下的一个文件。但直接查看这些文件可能会看到乱码或无法读取的内容,因为 cookie 文件通常以二进制或 sqlite 格式存储。一般在查看cookie时,直接在浏览器对应的选项中直接查看即可。
HTTP 存在一个报头选项:Set-Cookie, 可以用来进行给浏览器设置 Cookie值。在 HTTP 响应头中添加该header并设置好对应的值,客户端(如浏览器)获取该响应后自行设置并保存,如下设置一个cookie:
Set-Cookie: key=value; expires=Thu, 18 Dec 2024 12:00:00 UTC; path=/; domain=.example.com; secure; HttpOnly
key和value时一对键值对,key被用作cookie的名称,value是cookie的值,其余的是cookie的属性,如果需要多个key–value键值对,只能多次添加Set-Cookie并设置好对应属性,而不能放在一个Set-Cookie中。cookie的属性是可选的,每个 Cookie 属性都以分号(;)和空格( )分隔,属性和值之间使用等号(=)分隔,如果 Cookie 的名称或值包含特殊字符(如空格、分号、逗号等),则需要进行 URL 编码,关于各个属性的解释:
expires=<date>
:设置 Cookie 的过期日期/时间,如果未指定此属性,则 Cookie 默认为会话 Cookie,即当浏览器关闭时cookie就过期。时间格式必须遵守 RFC 1123 标准,具体格式样例:
Tue, 01 Jan 2030 12:34:01 GMT/UTC
GMT(格林威治标准时间)和 UTC(协调世界时)是两个不同的时间标准,但它们在大多数情况下非常接近。
GMT 是格林威治标准时间的缩写,它是以英国伦敦的格林威治区为基准的世界时间标准,不受夏令时或其他因素的影响,通常用于航海、航空、科学、天文等领域,其计算方式是基于地球的自转和公转。
UTC 全称为“协调世界时”,是国际电信联盟(ITU)制定和维护的标准时间,UTC 的计算方式是基于原子钟,而不是地球的自转,因此它比 GMT 更准确,现在用的时间标准,多数全球性的网络和软件系统将其作为标准时间。因此我们推荐使用UTC。
path=<some_path>
:定义 Cookie 的作用范围。如果设置为根路径/,意味着 Cookie对服务端域名下的所有路径都可用。
domain=<domain_name>
:指定哪些主机可以接受该 Cookie,默认为设置它的主机,点前缀(.)表示包括所有子域名。
secure
:仅当使用 HTTPS 协议时才发送 Cookie。这有助于防止
Cookie 在不安全的 HTTP 连接中被截获。
HttpOnly
:标记 Cookie 为 HttpOnly,意味着该 Cookie 不能被
客户端脚本(如 JavaScript)访问。这有助于防止跨站脚本攻击(XSS)。
HTTP session
使用cookie时,由于这些用户私密数据在浏览器(用户端)保存,非常容易被人盗取,如果写入的是用户的私密数据,比如用户名密码等,就很容易被非法用户冒充用户登录服务端,其次就是用户在cookie中的私密数据可以被解析读取,从而造成私密信息泄漏。为了解决这些问题,引入了HTTP session,一般用于用户认证和会话管理、存储用户的临时数据(如购物车内容)、实现分布式系统的会话共享(通过将会话数据存储在共享数据库或缓存中)等。
HTTP Session 是服务器用来跟踪用户与服务器交互期间用户状态的机制。由于 HTTP协议是无状态的(每个请求都是独立的),因此服务器需要通过 Session 来记住用户的信息。当用户首次访问网站时,服务器会为用户创建一个唯一的 Session ID,并通过Cookie 将其发送到客户端。客户端在之后的请求中会携带这个 Session ID,服务器通过 Session ID 来识别用户,从而获取用户的会话信息。服务器通常会将 Session 信息存储在内存、数据库或缓存中。
总的来说就是服务端用一条记录记录了用户信息,并用一个唯一值与该记录相关联,接着服务端将该唯一值通过cookie保存在用户浏览器端,当用户请求服务时客户端将该唯一值发给服务端,服务端就可以利用该唯一值在自己的数据库中寻找用户的记录,最后做对应的处理。
由于用户私密信息的记录是保存在服务端,而服务端一般都有不错的安全防护,因此这样基本可以解决用户私密信息泄漏问题。服务端还可以通过检测session是否异常,以决定是否让session失效,例如检测到访问用户的地址发生改变就判定session异常,这样就算浏览器的cooki被盗取了,服务端让该session失效非法用户就无法冒充用户了。
因此Cookie 是存储在客户端的,而 Session 是存储在服务器端的。它们各有优缺点,通常在实际应用中会结合使用,以达到最佳的用户体验和安全性。
HTTPS协议原理
HTTP 协议内容都是按照文本的方式明文传输的,明文数据会经过路由器、wifi 热点、通信服务运营商、代理服务器等多个物理节点,如果信息在传输过程中被劫持,传输的内容就完全暴露了。劫持者还可以篡改传输的信息且不被双方察觉,这就是中间人攻击,所以我们有对信息进行加密的需求,由此产生了HTTPS协议。HTTPS 也是一个应用层协议,是在 HTTP 协议的基础上引入了一个加密层。
加密就是把明文(要传输的信息)进行一系列变换生成密文,解密就是把密文再进行一系列变换还原成明文,在这个加密和解密的过程中,往往需要一个或者多个中间的数据,辅助进行这个过程,这样的数据称为密钥。
常见的加密方式有2种:对称加密和非对称加密
- 对称加密
对称加密就是采用单钥密码系统的加密方法,只要同一个密钥就可以同时用作信息的加密和解密,这种加密方法也称为单密钥加密,其特征就是加密和解密所用的密钥是相同的。常见对称加密算法有:DES、3DES、AES、TDEA、Blowfish、RC2 等,采用这种加密方式有许多优点:计算量⼩、加密速度快、加密效率高。
对称加密其实就是通过同一个 “密钥” , 把明文加密成密文, 并且也能把密文解密成明文,例如以下一个简单的对称加密:按位异或
假设明文 a = 1234,密钥 key = 8888
则加密 a ^ key 得到的密文 b 为 9834. 然后针对密文 9834 再次进行运算 b ^ key,得到的就是原来的明文 1234. (对于字符串的对称加密也是同理, 每一个字符都可以表⽰成一个数字),按位异或只是最简单的对称加密,HTTPS 中并不是使用按位异或。
- 非对称加密
非对称加密就是需要两个密钥来进行加密和解密的加密方法,这两个密钥分别是是公开密钥(public key,简称公钥,允许被别人获取)和私有密钥(private key,简称私钥,绝对不能泄漏),通常是一个用于加密,另一个用于解密(例如通过公钥对明文加密变成密文,再通过私钥对密文解密,变成明文,这种用法下只有私钥可以对公钥加密出来的密文进行正确解密。也可以反着用,通过私钥对明文加密变成密文,通过公钥对密文解密,变成明文,这种用法下只有私钥加密出来的密文才能被公钥正确解密)。常见非对称加密算法有:RSA,DSA,ECDSA等,其数学原理比较复杂,涉及到一些数论相关的知识。这种加密方法算法强度复杂,安全性依赖于算法与密钥,加密解密速度没有对称加密解密的速度快(通常是慢很多)。
下面我们通过对比几种解决方案,以便得到最佳解决方案:
- 方案1:只采用对称加密
双方想进行对称加密通信,当客户端生成对称密钥C后,由于服务端不知道该密钥,因此客户端就必须要先将该密钥C发送给服务端,此时中间人就可以直接窃取到该密钥C,所以后续双方利用密钥C进行通信时,中间人都可以利用窃取到的密钥C将密文解密成明文。因此这种方案不可行。
- 方案2:只采用非对称加密
客户端生成公钥C和私钥C’后,想用C进行加密,C’进行解密,于是客户端将公钥C发送给服务端,服务端和中间人都得到了公钥C,当服务端用得到的C对应答加密返回给客户端时,尽管中间人有公钥C,但其没有私钥C’,就无法对该密文进行解密,因此目前来看服务端到客户端的通信是安全的(后面会说明其实这也并不安全),但客户端到服务端的通信依旧可以被中间人窃取解读,而且通信采用非对称加密,通信速度慢,因此这种方案不可行。
- 方案3:双方都采用非对称加密
基于方案2,我们可以让客户端生成公钥C和私钥C’,服务端也生成自己的公钥S和私钥S’,双方在通信前先进行公钥交换,中间人进行得到了公钥C和S,但由于没有对应的私钥自然就无法解读后续加密的密文,因此目前来看服务端到客户端的通信是安全的(同方案2一样,后面会说明其实这也并不安全),而且通信双方都采用非对称加密,通信速度十分缓慢,因此这种方案不可行。
- 方案4:采用非对称加密和对称加密
服务端生成自己的公钥S和私钥S’,客户端先获取服务端的公钥S,接着生成对称密钥X,通过S将对称密钥X加密发送给服务端,服务端就可以利用S’对密文解密得到对称密钥X,这样就只有客户端和服务端知道对称密钥X,此后双方就可以利用对称密钥X进行通信了,这种加密方式目前看来是安全的,而且通信时采用对称加密,通信速度快,接下来说明为什么这种加密方式不安全:
这也说明了为什么方案2和方案3的通信是不安全的,因为中间人完全可以冒充客户端和服务器向另一方发送自己的公钥。
- 方案5:采用非对称加密+对称加密+证书认证
现在我们清楚方案4的致命缺陷在于客户端不清楚一开始接收到的公钥是服务端的还是中间人的,如果客户端可以甄别出接收到的公钥是服务端的再将对称密钥X利用得到的公钥加密发送给服务端,那么中间人就无法解密出密钥X,此后的客户端和服务端就可以安全的通信了。
在了解方案5之前我们需要知道什么是数字指纹、数据签名和证书。
数据指纹,或者说数据摘要,其基本原理是利用单向散列函数(Hash 函数)对信息进行运算,生成一串固定长度的数字摘要(两个不同的信息,有可能算出相同的摘要,但是概率非常低),数字指纹并不是一种加密机制(因为没有解密),从摘要很难反推原信息,而且源字符串只要改变一点点,最终得到的数据摘要值都会差别很大,通常用来进行数据对比,以判断数据有没有被篡改,摘要常见算法有:MD5、SHA1、SHA256、SHA512 等。
数据签名就是签名者(只有CA机构有资格成为签名者)利用自己的私钥对一份数据(一般是数据摘要,这样可以缩小签名密文的长度,加快数字签名的验证签名的运算速度)进行加密形成的一份密文。
证书就是一份明文数据拼接上该数据对应的签名。
服务端在使用 HTTPS 前,先生成公钥和私钥,私有服务端持有,然后服务端负责人向 CA 机构申领一份数字证书(申请证书的时候,需要在特定平台生成,例如CSR,会同时生成一对密钥对,即公钥和私钥。这对密钥对就是用来在网络通信中进行明文加密以及数字签名的。其中公钥会随着 CSR 文件,一起发给 CA 进行权威认证,私钥服务端自己保留,用来后续进行通信),CA机构会根据申请者提交的域名、公钥等信息根据自己的私钥生成一份签名,再将签名和提交的明文信息拼接就形成了一份CA证书。
当客户端首次向服务端发起请求时,服务端直接给客户端返回一份CA证书,客户端(可以认为是浏览器)一般内置了CA机构对应的公钥,其会用该公钥对签名解密得到一份数据摘要A,然后使用同样的哈希算法对明文数据进行运算得到一份数据摘要B,客户端只需要比较A和B是否相等就可以知道该证书是否被修改,未修改就可以根据证书得到服务端的公钥,然后采用方案4的思路进行通信就可以了。
由于客户端使用的是CA机构的公钥解密,因此中间人对证书的任何修改客户端都可以知道,例如中间人向篡改明文数据中的公钥,由于中间人没有CA机构的私钥自然就无法将篡改后的明文数据形成对应的签名。尽管中间人也可以向CA机构申请一份证书,然后利用该申请的证书冒充服务端发给客户端,但客户端只需要再检查对应的域名是否正确就可以识别出是否是服务端返回的证书。
因此方案5可以保证通信安全,且通信速度也较快。
HTTPS 工作过程中涉及到的密钥有三组:
第一组(非对称加密):用于校验证书是否被篡改,CA机构持有私钥,客户端持有公钥(操作系统包含了可信任的 CA 认证机构有哪些,同时持有对应的公钥),服务器在客户端请求时,返回携带签名的证书,客户端通过这个公钥进行证书验证,保证证书的合法性,进一步保证证书中携带的服务端公钥权威性。
第⼆组(非对称加密):用于协商生成对称加密的密钥,客户端用收到的 CA 证书中的公钥(是可被信任的)给随机生成的对称加密的密钥加密,传输给服务器,服务器通过私钥解密获取到对称加密密钥。
第三组(对称加密):客户端和服务器后续传输的数据都通过这个对称密钥加密解密。
传输层
UDP
UDP协议端的格式如下:
源/目的端口号
:表示数据是从哪个进程来,到哪个进程去
16位UDP长度
:表示整个数据报(UDP 首部+UDP 数据)的长度,以字节为单位,其至少为8字节,否则该UDP报文被视为无效,可以知道整个UDP报文的最大长度是64K,如果用户想要发送的数据超过了64K,需要自己手动实现对报文进行分片,然后手动实现拼装接收到的报文。
16位UDP检验和
:用于检验UDP报文是否出错,如果校验和出错,就会直接丢弃。其实就是发送方先按照一定的规则对这个UDP报文进行计算得到一个检验和,接收方也按照相同的规则对接收到的报文进行计算,如果如果计算出来的检验和与UDP数据报上携带的检验和不一致,则认为UDP数据包传输出错。
UDP的特点如下:
- 无连接:知道目的的 IP 和端口号就直接进行传输,不需要建立连接
- 不可靠:没有确认机制,没有重传机制,如果因为网络故障该段无法发到对方, UDP 协议层也不会给应用层返回任何错误信息
- 面向数据报:不能够灵活的控制读写数据的次数和数量,应用层交给 UDP 多长的报文, UDP 原样发送,既不会拆分,也不会合并,例如:用 UDP传输100 个字节的数据,如果发送端调用一次 sendto,发送100个字节,那么接收端也必须调用对应的一次 recvfrom,接收100个字节,而不能循环调用 10 次 recvfrom,每次接收 10 个字节。
需要注意的是UDP 没有真正意义上的发送缓冲区,调用 sendto 会直接交给内核,由内核将数据传给网络层协议进行后续的传输动作,但UDP 具有接收缓冲区,但是这个接收缓冲区不能保证收到的 UDP 报的顺序和
发送 UDP 报的顺序一致,如果缓冲区满了,再到达的 UDP 数据就会被丢弃。
基于UDP的应用层协议有:
- NFS:网络文件系统
- TFTP:简单文件传输协议
- DHCP:动态主机配置协议
- BOOTP:启动协议(用于无盘设备启动)
- DNS:域名解析协议
TCP
tcp一个文件描述符对用2个缓冲区,支持全双工通信,udp不是全双工,但可以在应用层添加特性来保证全双工。
TCP协议端的格式
TCP协议端的格式如下:
源/目的端口号
:表示数据是从哪个进程来,到哪个进程去
4位首部长度
:表示该 TCP 头部有多少个32位bit(有多少个4字节),所以TCP 头部最大长度是15 * 4 = 60字节
6位标志位
:
- URG:Urgent Pointer,紧急指针是否有效
- ACK:Acknowledgment,确认序号是否有效
- PSH:Push,提示接收端应用程序立刻从 TCP 缓冲区把数据读走
- RST:Reset,对方要求重新建立连接,我们把携带 RST 标识的报文称为复位报文段
- SYN:Synchronize,请求建立连接; 我们把携带 SYN 标识的称为同步报文段
- FIN:Finish,通知对方本端要关闭了,我们称携带 FIN 标识的报文为结束报文段
这些标志位是为了区分报文类型,例如有的报文是为了建立连接,有的是断开连接,有的则是正常通信等,以便对方决定接下来的动作
16位校验和
:发送端填充, CRC 校验. 接收端校验不通过, 则认为数据有问题. 此处的检验和不光包含 TCP 首部, 也包含 TCP 数据部分,如果检验出错则丢弃当前报文,在ACK应答中要求重新发送该报文
16位紧急指针
:紧急指针字段的值为紧急数据最后一个字节相对于TCP报文段首部的序列号的偏移量。通过将该偏移量与序列号相加,可以计算出紧急数据最后一个字节的序号,从而定位紧急数据在报文段中的位置,由于紧急数据的处理可能会打断正常的TCP流量控制机制,并且TCP协议本身并没有为紧急数据提供额外的保护(如重传机制),因此在实际应用中需要谨慎使用
40字节头部选项
:为TCP连接提供了额外的功能和灵活性,使得TCP能够适应不同的网络环境和应用需求,从而提高网络传输的效率和可靠性,最大为40字节
其余字段后面作解释。
TCP的报文格式之所以比UDP的复杂很多,就是因为TCP要保证可靠性传输,其引入了各种机制来保证tcp的可靠性。
确认应答机制
现在主机A向主机B发送了一个tcp报文段,那么主机A怎么确定主机B是否收到了报文呢?最朴素的做法就是主机B在接收到报文后向主机A发送一个应答报文,告诉主机A自己已经收到报文了,如果主机A收到了主机B的应答,主机A就可以保证主机B一定收到了报文。tcp的应答报文就是将报头的ACK标志位置1,因此因此应答报文也称为ACK报文。
现在又来了一个新问题:如果tcp接连发送了2个报文,由于报文在网络中可能会选择不同的路径,从而造成这2个报文到达主机B的顺序不一定与主机A的发送顺序一致,那主机B如何保证接收报文的顺序呢?这就需要用到tcp报头的32位序号和32位确认序号。
tcp层有两个缓冲区,一个发送缓冲区,一个接收缓冲区(其实send,recv等调用就是拷贝函数,将数据拷贝到发送缓冲区或从接收缓冲区拷贝数据出去),要发送的数据会提前放到发送缓冲区中,我们把该缓冲区想象成一个大数组,tcp会为每一个数据进行编号,这就是序列号。
主机A在发送数据时会在报头的32位序号中填充该TCP报文段中第一个字节的序号,主机B在收到报文后按照序号的大小排序就可以确定接收到的数据报的顺序,同时主机B会在应答报文的32位确认序号中填充下一个期望接收的字节的序号,即告诉主机A下次该从哪里发。
通过确认应答机制,主机B就可以告诉主机A自己已经收到了那些数据,下一次主机A应该从哪里开始发。
超时重传机制
主机A通过接收主机B的应答来确认B已经接收到的报文,如果主机A没有接收到B的应答呢?tcp的解决办法是如果超过一定的时间主机A没有接收到主机B的应答,就对没有应答的报文进行重发,那么该如何确定多长时间算超时呢?
应对策略是找到一个最小的时间,保证确认应答一定能在这个时间内返回,但是这个时间的长短随着网络环境的不同是有差异的,如果超时时间设的太长,会影响整体的重传效率,如果超时时间设的太短,有可能会频繁发送重复的包。TCP 为了保证无论在任何环境下都能比较高性能的通信,选择动态计算这个超时时间, Linux中(BSD Unix 和 Windows 也是如此),超时以 500ms 为一个单位进行控制,每次判定超时重发的超时时间都是 500ms 的整数倍,例如如果重发一次之后仍然得不到应答,等待 2*500ms 后再进行重传,如果仍然得不到应答就等待 4*500ms 进行重传,依次类推,以指数方式递增,当累计到一定的重传次数, TCP 认为网络或者对端主机出现异常,强制关闭连接。
现在又来了一个问题,主机A没有收到应答,不一定是主机A发送的数据段丢失了,有可能是主机B接收到了A的报文,但应答丢失了,也有可能是应答正阻塞在某个路由中导致超时,无论哪种情况,主机A都会选择重发导致主机B收到多份重复的报文,主机B该如何对报文去重呢?
解决办法很简单,根据接收到的报文的32位序号就可以做到去重的功能了。所有接收到的数据段都会先放在tcp层的接收缓冲区中,如果数据段还在接收端的TCP缓冲区中,但此时又收到了一个具有相同序列号的数据段,TCP接收端会检查新收到的数据段的序列号。如果序列号与缓冲区中某个已接收但尚未被上层应用读取的数据段序列号相同,则认为该数据段是重复的,此时直接丢弃新来的数据段就可以了;如果某个数据段已经被上层读走了,由于TCP的32位序列号是一个连续的、递增的序列,用于标识每个数据段的顺序,即使某个数据段已经被上层应用读取,TCP接收端仍然会跟踪序列号的状态,如果数据段的序列号已经超过了它最近一次发送的ACK中的序列号,tcp就认为该数据段是重复的,直接丢弃这个数据段就可以了。
连接管理机制
我们知道tcp是面向连接的,那么tcp是如何保证连接已建立好了的呢?正常情况下,tcp要经历3次握手建立连接,4次挥手断开连接。
建立连接
tcp三次握手的流程为:第一,客户端先向服务端发送一个携带SYN标志位的报文,以告诉服务端自己有建立连接的请求,第二,服务端接收到客户端请求建立连接的报文后,发送一个携带ACK和SYN标志位的报文,以对客户端发来的报文做应答,同时表示自己也希望和客户端建立连接,第三,客户端收到服务端的报文后,发送一个ACK报文以对服务端发来的报文做应答。
如果一开始报文①就丢失了导致服务端没有收到,服务端自然就不会应答,客户端超时收不到应答就会重新发送报文①,如果第一次握手一直不成功,达到最大重传次数客户端就会放弃连接。
如果报文②丢失了,客户端收不到应答就会重新发送报文①,如果客户端在达到最大重传次数后仍未收到服务端的报文②,客户端会断开TCP连接。对于服务端来说,服务端等不到客户端的应答③,服务端就会重新发送报文②,如果服务端在达到最大重传次数后仍未收到客户端的ACK报文③,服务端会释放之前为这次连接分配的资源。
如果报文③丢失了,服务端等不到客户端的应答,服务端就会重新发送报文②,这样客户端就会重发报文③,如果服务端在达到最大重传次数后仍未收到客户端的ACK报文③,它会释放之前为这次连接分配的资源,并关闭连接。此时还存在一种情况,由于此时客户端认为连接已经建立好了,其可能会直接向服务端发送数据,在这种服务端在没有收到ACK报文就接收到客户端的数据的情况下,服务端会给客户端发送一个携带RST标志位的报文,通知客户端重新建立连接,当客户端收到RST报文时,它会意识到连接已经被强制关闭,并且通常会释放与该连接相关的所有资源,如果客户端需要继续与服务端通信,它将需要重新进行TCP的三次握手来建立一个新的连接。
如果三次握手顺利,客户端和服务端都有一次报文的收发,这样双方都可以确定自己发送的报文对方可以收到,对方发送的报文自己也可以收到,从而确认信道是健康的,同时确保了双方的TCP都是愿意通信的。需要注意的是三次握手不是保证100%建立连接,而是经历过三次握手之后,双方都认为连接建立好了。而且第一次握手和第二次握手的报文不允许携带数据,允许第三次握手的报文携带数据,这样做一方面是第一次握手和第二次握手双方都不认为连接已经建立,另一方面是为了阻止恶意攻击:客户端可能会直接向服务端发送大量携带垃圾数据的SYN报文,而服务端只能被动的接收这些垃圾数据,从而使服务端遭受攻击。
至于我们为什么选择三次握手而不选择一次或两次握手,是因为这种方式双方不能保证或者只有一方可以保证信道健康,而不选择四次或者更多次握手单纯就是因为没有必要。
tcp在进行连接的过程中,服务端和客户端都存在相应的状态的变化:
- 服务端的状态变化:
- ①
[CLOSED -> LISTEN]
服务器端调用 listen 后进入 LISTEN 状态, 等待客户端连接 - ②
[LISTEN -> SYN_RCVD]
一旦监听到连接请求(同步报文段),就将该连接放入内核等待队列中, 并向客户端发送 SYN +ACK确认报文 - ③
[SYN_RCVD -> ESTABLISHED]
服务端一旦收到客户端的确认报文,就进入ESTABLISHED 状态,可以进行读写数据了
- 客户端的状态变化:
- ①
[CLOSED -> SYN_SENT]
客户端调用 connect,发送同步报文段 - ②
[SYN_SENT -> ESTABLISHED]
connect 调用成功,即客户端接收到服务端的确认报文,则进入 ESTABLISHED 状态,可以开始读写数据
断开连接
TCP四次挥手的过程为:第一,客户端先向服务端发送一个携带FIN标志位的报文,以告诉服务端自己现在希望断开连接;第二,服务端收到客户端请求断开连接的报文后,向客户端发送一个ACK应答报文,表示自己已收到客户端断开连接的请求;第三,服务端也向客户端发送一个携带FIN标志位的报文,表示自己要和客户端断开连接;第四,客户端收到服务端请求断开连接的报文后发送一个ACK报文,表示自己已收到服务端断开连接的请求。
需要注意的是,发送FIN报文只是表示自己在断开连接之前不再向对方发送数据了,但这期间依然可以正常接收数据。
如果报文①丢失了导致服务端没有收到客户端希望断开连接的请求,服务端自然就不会应答,客户端超时收不到应答就会重新发送报文①,如果重传次数达到了最大值,但仍然没有收到ACK应答报文,那么客户端可能会认为连接已经不可恢复,于是会关闭连接。
如果报文②丢失了,客户端触发重传机制,重发报文①,如果重传次数达到了最大值,但仍然没有收到ACK应答报文,那么客户端可能会认为连接已经不可恢复,于是会关闭连接。
如果报文③丢失了,对服务端来说,它迟迟收不到客户端的应答,则触发服务端的超时重传,于是服务端重发报文③,如果在重传多次后,服务端仍然无法收到客户端的ACK报文,服务端会断开连接。对客户端来说,客户端会等待一段时间,默认通常是60秒,如果仍未收到服务端的报文③,客户端会自动关闭连接。
如果报文④丢失了,对服务端来说,它迟迟收不到客户端的应答,则触发服务端的超时重传,于是服务端重发报文③,如果在重传多次后,服务端仍然无法收到客户端的ACK报文,服务端会断开连接。对客户端来说,客户端在发送ACK报文后,它不会立即关闭连接,而是通常会进入TIME-WAIT状态,等待足够的时间(通常是2倍的MSL,即Maximum Segment Lifetime,最大报文段生存时间)以确保服务端能够收到ACK报文,客户端如果又收到了服务端的FIN报文,此时它会认为自己发送的ACK报文④丢失了,于是忽略当前接收到的报文,并重新发送一次ACK报文④以应答之前的FIN报文③,然后再等待上一段时间,当客户端在TIME-WAIT状态等待足够的时间后,就会正常关闭连接。
如果四次挥手顺利,双方都可以确认对方已经同意断开连接,那为什么不选择一次、两次或者三次挥手呢?
对于一次挥手,万一报文①丢失了,服务端无法知道客户端已经断开连接,其仍会向客户端发送数据,而客户端已经断开连接不会对这些报文作应答,服务端就会不断重传直至到达最大重传次数,这明显会造成网路资源的浪费,而且万一服务端还有一些重要数据没有发送给客户端,可鞥会对双方以后的运行造成影响。
对于二次挥手,由于ACK应答并不是代表服务端也要断开连接,仅仅只是服务端告诉客户端自己已经收到了客户端断开连接的请求,因此服务端依然可能向客户端发送数据。
对于三次挥手,尽管服务端也向客户端发送了FIN报文,告诉客户端自己不在向它发送数据了,同时自己也希望断开连接,但服务端无法确认客户端是否真正接收到了自己的FIN报文。这可能导致双方对连接状态的理解不一致。如果该FIN报文丢失了,客户端也不知道服务端已经断开连接了,它依旧觉得服务端也许还有数据会发送给自己,于是就会白白等待上一段时间才会知道服务端断开连接了然后自己也断开连接,而且如果客户端收到了服务端的FIN报文却不做应答,这不符合TCP协议的规定。总之,三次挥手不符合TCP协议的全双工通信特性和确保双方都能有序、安全地关闭连接的需求。
最后还有一个问题,那为什么不把第二次挥手和地三次挥手合并呢?因为服务端要及时回复第二次挥手的ACK报文,但其可能还有一些数据没有处理完,服务端需要等数据处理完并发送给客户端之后才能断开连接,因此第二次挥手和地三次挥手一般不能合并(从这里也可以看出,三次握手的本质是四次握手,只不过第二次和第三次握手合并了,这其实就是捎带应答,即ACK应答与响应数据一同携带发送到发送端,从而减少发送次数)。
tcp在断开连接的过程中,服务端和客户端都存在相应的状态的变化:
- 服务端状态转化:
- ①
[ESTABLISHED -> CLOSE_WAIT]
当客户端主动关闭连接(调用 close),服务器会收到结束报文段, 服务器返回确认报文段并进入CLOSE_WAIT状态 - ②
[CLOSE_WAIT -> LAST_ACK]
进入 CLOSE_WAIT后说明服务器准备关闭连接(需要处理完之前的数据),当服务器真正调用 close 关闭连接时,会向客户端发送FIN报文,此时服务器进入 LAST_ACK 状态,等待最后一个 ACK 到来(这个 ACK 是客户端确认收到了FIN报文) - ③
[LAST_ACK -> CLOSED]
服务器收到了客户端对 FIN 的 ACK应答,彻底关闭连接
- 客户端状态变化:
- ①
[ESTABLISHED -> FIN_WAIT_1]
客户端主动调用 close 时,向服务器发送结束报文段, 同时进入 FIN_WAIT_1状态 - ②
[FIN_WAIT_1 -> FIN_WAIT_2]
客户端收到服务器对结束报文段的确认,进入 FIN_WAIT_2状态,开始等待服务器的结束报文段 - ③
[FIN_WAIT_2 -> TIME_WAIT]
客户端收到服务器发来的结束报文段,进入TIME_WAIT状态,并发出ACK应答报文 - ④
[TIME_WAIT -> CLOSED]
客户端要等待一个 2MSL(Max Segment Life,报文最大生存时间)的时间,才会进入 CLOSED 状态
关于 TIME_WAIT:
TCP 协议规定,主动关闭连接的一方要处于 TIME_ WAIT 状态,等待两个MSL(maximum segment lifetime)的时间后才能回到 CLOSED 状态,如果是服务端终止了,那么服务端就是是主动关闭连接的一方,在
TIME_WAIT 期间其不能再次监听同样的 server 端口,这也是为什么服务端要设置地址复用的原因。MSL 在 RFC1122 中规定为两分钟,但是各操作系统的实现不同,在 Centos7 上默认配置的值是 60s,可以通过 cat /proc/sys/net/ipv4/tcp_fin_timeout 查看 msl 的值。
为什么是 TIME_WAIT 的时间是 2MSL呢?
MSL 是 TCP 报文的最大生存时间,如果 TIME_WAIT 持续存在 2MSL 的话,就能保证在两个传输方向上的尚未被接收或迟到的报文段都已经消失,否则服务器立刻重启,可能会收到来自上一个进程的迟到的数据,但是这种数据很可能是错误的,同时也是在理论上保证最后一个报文可靠到达(假设最后一个 ACK 丢失,那么服务器会再重发一个 FIN. 这时虽然客户端的进程不在了,但是 TCP 连接还在,仍然可以重发 ACK应答)。
关于 CLOSE_WAIT:
对于服务器上出现大量的 CLOSE_WAIT 状态,原因就是服务器没有正确的关闭socket,导致四次挥手没有正确完成,这是一个 BUG,只需要加上对应的 close 即可解决问题。
滑动窗口
在确认应答机制下,对每一个发送的数据段,都要给一个 ACK 确认应答,收到 ACK 后再发送下一个数据段,这样做明显导致数据传输效率低,性能较差,尤其是数据往返的时间较长的时候。于是我们想到,既然这样一发一收的方式性能较低,那么我们可以一次发送多条数据,就可以大大的提高性能(其实是将多个段的等待时间重叠在一起了),而接收方则对接收到的数据排序后放入其接收缓冲区中。
滑动窗口是TCP层发送缓冲区的一部分,窗口大小表示无需等待确认应答而可以继续发送数据的最大值,只有收到对方的ACK后,滑动窗口才会根据确认应答序号做相应的向右滑动
滑动窗口在内核中其实就是用两个指针来进行维护:
-
start_win:指向窗口左侧,等于收到的确认应答的32位确认序号,需要注意,如果对方回复了某个确认序号,那么就表示确认序号之前的数据对方都已经收到。
-
end_win:等于start_win加上窗口大小(该值大小为应答中的16窗口大小字段,是对方基于自己接收缓冲区还可以接收数据的能力填充的)
因此滑动窗口左侧的数据就表示发送方已经确定对方已经接收到的数据,在滑动窗口中的数据就表示可以发送或已经发送但还未确认对方已经收到的数据,滑动窗口右侧的数据表示待发送的数据。
如果发送的数据包丢失了,该包的32位序号是1001
那么接收方接下来的所有ACK应答的确认序号都是1001,表示1001之前的数据我都已经收到了,接下来请从1001开始发,当发送方连续三次收到了同样一个 “1001” 这样的应答,就会将序列号为1001的数据包重新发送,这个时候接收端收到了 1001 之后,再次返回的 ACK 就是 7001 了,因为 2001 - 7000的数据接收端其实之前就已经收到了,被放到了接收端操作系统内核的接收缓冲区中。这种机制被称为“高速重发控制”,也叫 “快重传”,当快重传之后依旧收到和之前相同的确认应答序号时,发送方会意识到可能存在问题,并采取相应的措施来确保数据的可靠传输和网络的稳定,包括继续重传丢失的报文段、关注接收方的窗口大小信息、采用拥塞控制机制以及根据网络状况调整发送策略等,如果问题无法解决,并且网络状况持续恶化,那么TCP最终可能会决定关闭连接。
快重传速度比超时重传快(因为其不需要等到超时没有收到ACK应答时才意识到数据丢了),需要注意的是,接收方只有收到至少3次相同的确认序号时才会触发快重传,由超时重传进行兜底。
如果部分ACK应答丢失了,其通常是不要紧的,因为可以通过后续的ACK应答知道接收方已经接收了那些数据,从而决定是否重发:如果接收方成功接收了某个数据报文段,并且后续还有数据报文段被成功接收,那么接收方会发送包含更高确认序号的ACK应答,这个ACK应答可以间接地告诉发送方,之前丢失ACK的那个数据报文段已经被成功接收了;如果发送方在一段时间内没有收到某个数据报文段的ACK应答,并且后续也没有收到包含更高确认序号的ACK应答,那么发送方会认为这个数据报文段可能丢失了,并触发超时重传或快重传机制来重新发送这个数据报文段。
到这里,我们也许还存在一些疑惑,即既然发送方已经知道接收方还可以接收多少数据,发送方为什么不把要发送的数据按接收方的最大接收限度一次发送过去,而是要分成几个小报文段再发送呢?
因为数据链路层规定单次收发的有效数据不超过一个MTU大小(该值一般为1500字节,可以通过指令 ifconfig 查看),当网络层发现上一层交付给它的数据较大时,它就会对数据进行分片,由接收方网络层对分好的片进行组装,而一旦某一个片丢失了,接收端的网络层就认为整个数据段都丢失了,发送方就需要重发所有的片,网络层分片过多,明显会增加丢包的概率,导致重发浪费资源,因此需要在传输层提前将数据分成小段,避免网络层过多分片。
流量控制
接收端处理数据的速度是有限的,如果发送端发的太快,导致接收端的缓冲区被打满,这个时候如果发送端继续发送,就会造成丢包,继而引起不必要的丢包重传等等一系列连锁反应,造成网络资源浪费,因此 TCP 支持根据接收端的处理能力,来决定发送端的发送速度,这个机制就叫做流量控制(Flow Control)。
接收端将自己可以接收的缓冲区大小放入 TCP 首部中的 “16位窗口大小” 字段, 通过 ACK 应答通知发送端(16 位数字最大表示 65535,实际上,TCP 首部 40 字节选项中还包含了一个窗口扩大因子 M,实际窗口大小是窗口字段的值左移 M 位),窗口大小字段越大,说明网络的吞吐量越高,当接收端一旦发现自己的缓冲区快满了,就会将窗口大小设置成一个更小的值通知给发送端,发送端接受到这个窗口之后,就会减慢自己的发送速度,如果接收端缓冲区满了,就会将窗口置为 0,这时发送方不再发送数据,但是需要定期发送一个窗口探测数据段,使接收端把窗口大小告诉发送端,在极端情况下,如果连接长时间无法恢复,发送方可能会选择关闭连接。
需要注意,TCP滑动窗口的大小并不总是等于对方接收缓冲区可以接收数据的大小。虽然接收窗口的大小通常基于接收缓冲区的大小来设置,但它还会受到其他因素的影响,如应用程序的读取速度和网络拥塞状况等。因此,在实际的网络通信中,发送方需要动态地调整自己的发送窗口大小,以适应接收方的接收能力和网络状况的变化,因此TCP滑动窗口的大小是一个动态变化的参数,它反映了接收方当前愿意接收的数据量以及网络拥塞状况等多个因素的综合影响。
拥塞控制
如果在刚开始阶段就发送大量的数据,可能会引发问题,因为网络上有很多的计算机,如果当前的网络状态已经比较拥堵,在不清楚当前网络状态下,贸然发送大量的数据,很有可能导致网络状况雪上加霜,为此TCP 引入慢启动机制:先发少量的数据探探路,摸清当前的网络拥堵状态,再决定按照多大的速度传输数据。
TCP引入了一个称为拥塞窗口的概念,其实就是一个数字,当单次发送数据的大小小于该值时,不会导致网络阻塞,如果大于该值,则可能会导致网络堵塞,因此每次发送数据包的时候,将拥塞窗口和接收端主机反馈的窗口大小做比较,取较小的值作为实际发送的窗口。
我们希望拥塞窗口慢启动后可以随传输轮次较快增长,但又不至于增长过快,因此我们把拥塞窗口的增长进行分段,先以指数增长,超过某个慢启动阈值(ssthresh)后以线性方式增长。
故发送方开始发送的时候拥塞窗口大小为 1,在TCP连接建立之初,慢启动阈值通常被设置为一个相对较大的值(如接收窗口的大小),在每次超时重发的时候,慢启动阈值会变成原来的一半,同时将传输轮次置0(相当于拥塞窗口置回1),慢启动阈值不会无限减小,通常会设定某个最小值,在某些TCP拥塞控制算法(如CUBIC算法)中,当网络状况良好且没有发生拥塞时,慢启动阈值可能会随着时间的推移而逐渐增加。
拥塞控制需要在有限的资源和无限的需求之间找到平衡点,归根结底是TCP协议想尽可能快的把数据传输给对方,但是又要避免给网络造成太大压力的折中方案。
延迟应答
如果接收数据的主机立刻返回ACK应答,这时候返回的窗口可能比较小,也许等一会儿接收方就把数据从缓冲区取走了,那么这时可以返回的滑动窗口的值就比较大。当然了,不是所有的包都可以延迟应答,其是有一定的限制的:
- 数量限制:每隔N个包就应答一次
- 时间限制:超过最大延迟时间就应答一次
具体的数量和超时时间,操作系统不同之间存在差异,一般N取2,延迟时间取200ms,保证理想情况下在该延迟时间内应答不会触发发送方的超时重传机制。
粘包问题
首先要明确,粘包问题中的包是指的应用层的数据包,在 TCP 的协议头中,没有如同 UDP 一样的 “报文长度” 这样的字段,但是有一个序号这样的字段,站在传输层的角度,TCP 是一个一个报文过来的,按照序号排好序放在接收缓冲区中,但站在应用层的角度,看到的只是一串连续的字节数据,那么应用层该如何将一连串的字节数据分割成一个个完整的请求呢?
解决思路很简单,只需要明确两个请求之间的边界即可。对于定长的请求,只需要保证每次都按固定大小读取即可,当然了一般客户端请求都是变长的,对于变长的包, 可以在包头的位置,约定一个包总长度的字段,从而就知道了包的结束位置,还可以在包和包之间使用明确的分隔符(例如使用自定义的应用层协议,只要保证分隔符不和正文冲突即可)。
对于 UDP 其不存在粘包问题,因为如果还没有上层交付数据,UDP 是有报文长度的,有很明确的数据边界,如果站在应用层的站在应用层的角度,使用 UDP 的时候要么收到完整的 UDP 报文,要么不收,不会出现“半个”的情况。
TCP全连接队列
在建立TCP连接时,如果三次握手已经完成,但服务器正处于繁忙状态无暇立即调用accetp获取连接,此时该连接就会被放到TCP全连接队列当中,而全连接队列的长度是有限的,由服务端开启listen监听时的第二个参数backlog
决定,全连接队列的长度为 backlog+1 ,如果全连接队列已经满了,服务器依旧无暇将连接获取走,此时又来了一个新连接,当前连接就无法进入 established 状态,则该连接就会失败。需要注意的是,所有的连接都会先被放到全连接队列中,再由上层调用accept将该连接获取走,因此全连接队列的长度不宜过小,否则服务端一忙,只有几个连接可以被放到全连接队列中,剩余的连接都会失败,这对大部分客户来说就是一请求服务就失败,客户尝试几次之后就会放弃请求,此后尽管服务端空闲了,但客户可能已经没有请求服务的意愿了,从而增加服务器的闲置率,但全连接队列的长度也不宜过长,如果队列很长,处于队列后面的连接需要很久才可以被accept,可客户不会等这么久,大部分等了一会就直接断开连接了,但这期间为了维护这些连接服务端是花费了资源的,例如内存、cpu等。
其实Linux 内核协议栈为一个TCP连接管理使用两个队列:
- 半链接队列(用来保存处于 SYN_SENT 和 SYN_RECV 状态的请求)
- 全连接队列(accpetd 队列)(用来保存处于 established 状态,但是应用层没有调用 accept 取走的请求)
一些不完整的连接会被放到半连接队列中,连接完整后就放到全连接队列中等待上层accept。
下面我们从内核角度简单理解一下全连接队列:
当新建立一个连接时,就是创建了一个struct tcp_sock(它不需要理会自己的全连接队列)对象,并将该对象放到Listen套接字的全连接队列中,当上层调用accept时,则就分配一个文件描述符,把该连接对象放到该文件描述符下面的struct socket中,最后将该文件描述符返回上层,上层只需要对文件描述符操作,就可以进行数据收发了。
tcpdump抓包
我们可以使用tcpdump这个工具可以对网络进行抓包(包括UDP报文),在使用时需要root用户权限。
在Ubuntu中,安装指令如下:
sudo apt-get update
sudo apt-get install tcpdump
在 Red Hat 或 CentOS 系统中,可以使用以下命令安装:
sudo yum install tcpdump
常见的使用方法如下:
使用 tcpdump 的时候,有些主机名会被云服务器解释成为随机的主机名,如果不想要,就添加 -n 选项
- 捕获所有网络接口上的 TCP 报文
sudo tcpdump -i any tcp
-i any 字段指定捕获所有网络接口上的数据包,i 可以理解成为 interface 的意思,tcp 字段指定捕获 TCP 协议的数据包。
- 捕获特定源或目的 IP 地址的 TCP 报文
使用 host 关键字可以指定源或目的 IP 地址,同时可以使用 and 关键字连接两个条件
//捕获源 IP 地址为192.168.1.100 的 TCP 报文
sudo tcpdump src host 192.168.1.100 and tcp
//捕获目的 IP 地址为 192.168.1.200 的 TCP 报文
sudo tcpdump dst host 192.168.1.200 and tcp
//同时指定源和目的 IP 地址
sudo tcpdump src host 192.168.1.100 and dst host 192.168.1.200 and tcp
- 捕获特定端口的 TCP 报文
使用 port 关键字可以指定端口号
//捕获端口号为 80 的 TCP 报文
sudo tcpdump port 80 and tcp
- 保存捕获的数据包到文件
使用 -w 选项可以将捕获的数据包保存到文件中,以便后续分析
sudo tcpdump -i eth0 port 80 -w data.pcap
把捕获到的 HTTP 流量保存到名为 data.pcap 的文件中,pcap 后缀的文件通常与 PCAP(Packet Capture)文件格式相关,是一种用于捕获网络数据包的文件格式。
- 从文件中读取数据包进行分析
使用 -r 选项可以从文件中读取数据包进行分析
tcpdump -r data.pcap
- 捕获指定网络接口上的 TCP 报文
//捕获某个特定网络接口(如 eth0)上的 TCP 报文
sudo tcpdump -i eth0 tcp
wireshark 是 windows 下的一个网络抓包工具,虽然 Linux 命令行中有 tcpdump 工具同样能完成抓包,但是 tcpdump 是纯命令行界面,使用起来不如 wireshark 方便,如果需要可以在wireshark下载下载 wireshark,具体使用教程自行网上参考。
在TCP小节的最后,我们考虑一些TCP异常情况:
- 进程终止:进程终止会释放文件描述符,仍然可以发送 FIN,和正常关闭没有什么区别
- 机器重启: 和进程终止的情况相同
- 机器掉电/网线断开:其中一端一开始不会意识到对方出现问题,会认为连接还在,一旦该端有写入操作,就会发现连接已经不在了,就会发送RST报文段来异常关闭连接(与TCP的正常关闭过程不同,发送RST报文段关闭连接时,不需要等待缓冲区的包都发送出去,也不需要接收端发送ACK报文段来确认),即使没有写入操作,TCP 自己也内置了一个保活定时器(一般为2个小时,没有结合到应用层具体的服务,因此显得不合理),如果双方在保活定时器设置的时间没有数据交换,TCP就会认为连接可能已经不再有效或对方可能已经异常,TCP会发送一个探查报文段(也称为保活探测报文)给对方,以检查对方是否仍然在线并能够响应,如果对方在线并正常响应了这个探查报文段,那么TCP会收到回应,并重新复位保活定时器,以继续维持连接的有效性。如果对方没有响应这个探查报文段,TCP可能会继续发送多个探查报文段(具体的次数和间隔时间取决于TCP的实现和配置),如果在发送完所有探查报文段后仍未收到任何响应,TCP就会认为连接已经失效,并主动释放这个TCP连接。
另外,应用层的某些协议,也有一些这样的检测机制,例如 HTTP 长连接中,也会定期检测对方的状态,例如使用 QQ时,在 QQ 断线之后,也会定期尝试重新连接。
总之,TCP 之所以这么复杂就是因为其要保证可靠性,同时又尽可能的提高性能。
通过以下机制提供可靠性:
- 校验和
- 序列号(按序到达)
- 确认应答
- 超时重发
- 连接管理
- 流量控制
- 拥塞控制
通过以下机制提高性能:
- 滑动窗口
- 快速重传
- 延迟应答
- 捎带应答
基于 TCP 应用层协议:
- HTTP
- HTTPS
- SSH
- Telnet
- FTP
- SMTP
网络层
网络层的主要作用为根据目标主机的IP在网络中进行路由选择,即寻找一条合适的传输路径,其有很大概率将报文转运到目标主机,配合上TCP的重发策略就可以保证数据传输的可靠性。
协议格式
4 位版本号(version)
:指定 IP 协议的版本,对于 IPv4 来说是 4,对于 IPv6 来说是 6
4 位头部长度(header length)
:IP 头部的长度是多少个 32bit, 也就是 4*length 个字节,其中length(4bit)表示最大的数字是 15,因此 IP 报头最大长度是 60 字节
8 位服务类型(Type Of Service)
:3 位优先权字段(已经弃用),4 位 TOS 字段,1 位保留字段(必须置为 0),以下是文心一言对这些字段的解释:
16 位总长度(total length)
:整个IP 数据报的长度,以字节为单位
16 位标识(id)
:用于唯一的标识主机发送的报文,确保了每个IP数据报在网络中的唯一性,从而避免了混淆和冲突。如果传输层交付下来的报文被网络层分片了,那么每一个片里面的这个 id 都是相同的,此时还可以确保接收端能够准确地识别出哪些分片属于同一个原始数据报。
3 位标志字段
:第一位保留(现在不用,以后可能要用到);第二位(DF(Don’t Fragment)位)置为 1 表示禁止分片,这时候如果报文长度超过 MTU,该报文就会被丢弃,并向发送方发送一个ICMP消息通知;第三位(MF(More Fragment)位)用于指示当前数据报是否是分片数据报序列中的最后一个分片,如果是最后一个分片则置0,否则置1,以帮助接收端正确地重组分片数据报,从而恢复原始的IP数据报
13 位分片偏移(framegament offset)
:是分片相对于原始 IP 报文开始处的偏移,其实就是在表示当前分片在原报文中处在哪个位置,实际偏移的字节数是这个值乘8得到的. 因此,除了最后一个报文之外,其他报文的长度必须是 8 的整数倍(否则报文就不连续了)
8 位生存时间(Time To Live, TTL)
:数据报到达目的地的最大报文跳数,该值一般是64. 每次经过一个路由, TTL 就减1,如果一直减到 0 还没到达目标主机,那么就直接丢弃该报文,这个字段主要是用来防止出现路由循环。
8 位协议
:表示上层协议的类型
16 位头部校验和
:使用 CRC 进行校验,来鉴别头部是否损坏,数据部分由上层传输层检查,以减少计算量
32 位源地址和 32 位目标地址
:表示发送端和接收端的IP地址
选项字段(不定长, 最多 40 字节)
:用于提供了额外的功能,允许发送方在IP数据报中嵌入一些可选的信息
IP报文的分片和组装
数据链路层规定网络层交付给它的单个报文长度不能超过MTU(Maximum Transmission Unit,该值一般是1500字节),除去IP报头的20字节,网络层单次接收的传输层交付给它的报文长度如果超过1480字节,网络层就要对报文进行分片,再由接收方的网络层进行组装,但如果其中任何一个分片丢失了,接收方的网络层就无法组装这个报文,自然不会将报文交付传输层,最后发送方只能进行重传。因此网络层分片会增加丢包重传的概率,不建议让网络层进行分片,一般是在传输层就控制好报文的长度。
- 分片
网络层分片时
- 每一个分片都要有自己的IP报头,报头中的16位标识符填充的是相同的值,这样接收方网络层就知道哪些分片是属于同一个报文。
- 每一个分片的3位标志位的MF标志位置1,但最后一个分片的MF标志位置0,这样接收方网络层就知道哪个分片是在报文末尾
- 每一个分片的13位分片偏移填充该分片的有效载荷在原始报文中的偏移量除以8,因此每一个分片的有效载荷的长度必须都是8的整数倍(最后一个分片除外),这样做是因为13位分片偏移字段只有13位,而IP报文的总长度有16位,偏移量除以8 就可以保证13位分片偏移可以覆盖整个报文。
- 组装
接收方网络层接收到报文后,只需要判断MF标志位为1或13片偏移不为0就可以知道当前报文是一个分片,需要进行组装。
组装时只需要按照片偏移对所有的片从小到大进行排序,如果找不到片偏移为0的片,就可以确定原始报文的第一片丢了,如果最后一片的MF标志位不为0,就可以确定原始报文的最后一片丢了,如果前一片的13位偏移加上前一片的有效载荷除以8不等于当前片的13位偏移就可以确定中间的某一片丢失了。如果确认没有片丢失,就可以直接组装排好序的片交付上层了。
网段划分
IP 地址分为两个部分,网络号和主机号
- 网络号:保证相互连接的两个网段具有不同的标识
- 主机号:同一网段内,主机之间具有相同的网络号,但是必须有不同的主机号
不同的子网其实就是把网络号相同的主机放到一起,如果在子网中新增一台主机,则这台主机的网络号和这个子网的网络号一致,但是主机号必须不能和子网中的其他主机重复,因此通过合理设置主机号和网络号就可以保证在相互连接的网络中,每台主机的 IP 地址都不相同。但手动管理子网内的 IP是一个相当麻烦的事情,为此产生了有一种叫做 DHCP的技术,能够自动的给子网内新增主机节点分配 IP 地址,避免了手动管理 IP 的不便,一般的路由器都带有 DHCP 功能,因此路由器也可以看做一个 DHCP 服务器。
那么问题来了,IP地址只有32个比特位,给网络号和主机号分配多少个比特位才是合适的呢,即怎么进行网段划分呢?
常见的网段划分有静态划分、子网划分等
- 静态划分
过去曾经提出一种划分网络号和主机号的方案,把所有 IP 地址分为五类:
- A 类 0.0.0.0 到 127.255.255.255
- B 类 128.0.0.0 到 191.255.255.255
- C 类 192.0.0.0 到 223.255.255.255
- D 类 224.0.0.0 到 239.255.255.255
- E 类 240.0.0.0 到 247.255.255.255
随着 Internet 的飞速发展,这种划分方案的局限性很快显现出来,大多数组织都申请 B 类网络地址,导致 B 类地址很快就分配完了,而 A 类却浪费了大量地址,且申请了一个 B 类地址,理论上一个子网内能允许 6 万 5 千多个主机(A 类地址的子网内的主机数更多),然而实际网络架设中,不会存在一个子网内有这么多的情况. 因此大量的 IP 地址都被浪费掉了。
- 无分类编址CIDR
针对以上情况后面提出了新的划分方案,称为 CIDR(Classless Interdomain Routing)。
引入一个额外的子网掩码(subnet mask)来区分网络号和主机号,子网掩码也是一个 32 位的正整数,在二进制中0和1不允许交替出现(即连续的1之后必须是连续的0,其中0或1的数量可以为0),将 IP 地址和子网掩码进行“按位与”操作,得到的结果就是网络号,而网络号和主机号的划分与这个 IP 地址是 A 类、B 类还是 C 类无关,例如:
可见,只要对IP 地址与子网掩码做与运算可以得到网络号,主机号从全 0 到全 1 就是子网的地址范围。IP 地址和子网掩码还有一种更简洁的表示方法,例如 140.252.20.68/2,表示 IP 地址为140.252.20.68, 子网掩码的高 24 位是 1,也就是 255.255.255.0。
这样,通过增缩掩码的宽度(即增减掩码中1的数量)就可以控制该网段中的IP的数量,以合理分配IP。
由此出现了一些特殊的IP地址:
- 将 IP 地址中的主机地址全部设为 0就成为了网络号,代表这个局域网,用于标识一个特定的网络或子网
- 将 IP 地址中的主机地址全部设为 1就成为了广播地址,用于给同一个链路中相互连接的所有主机发送数据包
- 127.*的 IP 地址用于本机环回(loop back)测试,通常是 127.0.0.1
主机号为全0或全1的IP地址有特殊用途,不能分配给主机使用。
网络路由
路由在复杂的网络结构中,找出一条通往终点的路线,在了解路由之前,我们先了解一下私有IP和公网IP。我们将IP地址分为私有IP和公网IP,并允许私有IP在不同网段内可以重复,但不允许在同一个网段中出现相同的私有IP,而公网IP在网络中必须是唯一的,同时也不允许在公网中出现私有IP。
RFC 1918 规定了以下类型的IP地址为私有 IP 地址:
- 10.*,共 16,777,216 个地址
- 172.16.*到 172.31.*,共 1,048,576 个地址
- 192.168.*,共 65,536 个地址
包含在这个范围中的都是私有 IP,其余的则称为全局 IP(或公网 IP),原则上处于不同的子网中的2台私有IP的主机不能直接通信,必须通过公网上的服务器进行转发(例如QQ的服务器)。
我们的网路世界中的主机之间并不是一张任意连接的图,这些主机是有一定的层次结构的(类似于国家、省、市、镇、乡一样的层次结构),一般是由运营商进行合理规划:
一个路由器一般配置有两个 IP 地址(更通常的情况是拥有一个WAN口和多个LAN口),一个是WAN(Wide Area Network)口IP,一个是LAN(Local Area Network)口IP(也称为子网IP,路由器的子网IP其实都是一样的,通常都是192.168.1.1),其中路由器LAN 口IP与其下一级的路由器的WAN口IP或普通主机的IP处于同一网段中(相当于它们在同一个局域网中,之间可以互相通信),WAN口IP与上一级路由器的LAN口IP相连或该WAN口IP直接就是一个公网IP,这样路由器就连接了2个不同的子网。每一个家用路由器,其实都是运营商路由器的子网中的一个节点,而运营商路由器可能会有很多级,最外层的运营商路由器的WAN 口 IP 就是一个公网 IP。
子网内的主机需要和外网进行通信时,路由器将 IP 首部中的 IP 地址进行替换(替换成 WAN 口 IP),这样逐级替换,最终数据包中的 IP 地址成为一个公网 IP。这种技术称为 NAT(Network Address Translation,网络地址转换)技术,这样公网中就不会出现私有IP了,因此如果希望我们自己实现的服务器程序能够在公网上被访问到,就需要把程序部署在一台具有公网IP 的服务器上,而这样的服务器可以在阿里云或腾讯云上进行购买。
路由的过程就是不断地问路,一跳一跳(Hop by Hop)的往下一站走:当 IP 数据包到达路由器时,路由器会先查看目的 IP以决定这个数据包是直接发送给目标主机还是需要发送给下一个路由器,如此反复,一直到达目标 IP 地址为止。
那么路由器是怎么知道数据包是直接发送给目标主机还是需要发送给下一个路由器呢?
路由器自己内部会维护一张路由表,类似于下面的表,可以使用指令 route 查看:
Destination
:目的网络地址
Gateway
:下一跳地址,为*意味着目标主机与本机在同一子网中可以直接访问
Genmask
:子网掩码
Flags
:U 标志表示此条目有效(可以禁用某些条目),G标志表示此条目的下一跳地址是某个路由器的地址,没有 G 标志的条目表示目的网络地址是与本机接口直接相连的网络,不必经路由器转发。
Metric
:用以确定到达目的地的最佳路径的计量标准,用于在多个可能的路由中选择最佳路径,根据Metric值来判断哪条路径是最优的
Ref
:指示有多少其他路由项引用了这个路由项
Use
:表示该路由项被路由软件查找或使用的次数
Iface
:发送接口
当路由器得到一个报文后,它会用报文中的目的IP地址与子网掩码Genmask做与运算,如果结果与Destination一致,就通过其对应的发送接口将数据发送出去,否则继续往下匹配,如果路由表中其它行都不匹配时,就按缺省路由条目规定的接口发送到下一跳地址。
当路由器新增一个LAN口子网或WAN口子网时,路由表会为该子网生成一个新增一条记录,路由表可以由网络管理员手动维护(静态路由),也可以通过一些算法自动生成(动态路由),例如距离向量算法、LS 算法、Dijkstra 算法等。
现在我们更加具体的看看数据包路由的过程:
现在手机的数据链路层有一个数据包,目标主机是公网中的京东的服务器,该数据会先被发送到与手机相连的路由器中,路由器拿到该数据包后,取出目的IP地址,根据路由表决定向上层路由器交付还是交付到子网中的某个主机,如此层层交付,到达服务商的公网出口路由器,此时数据包的源IP地址会被替换层该路由器的WAN口IP地址,继续向上交付直至目标主机,目标主机应答时,根据数据包的源IP地址就可以将数据返回到请求时的服务商的公网出口路由器,通过NAT技术(后面讲)就可以把数据返回到手机上。
数据链路层
现在我们已经知道在复杂的网络结构中是如何找出一条通往终点的路线的了,那么数据是如何在线路中两个节点进行转发的呢?
以太网是当前应用最广泛的局域网技术,它不是一种具体的网络,而是一种技术标准,既包含了数据链路层的内容,也包含了一些物理层的内容,例如它规定了网络拓扑结构、访问控制方式、传输速率等,以及以太网中的网线必须使用双绞线,和以太网并列的还有令牌环网、无线LAN 等。
以太网帧格式
源地址和目的地址
:当前主机网卡和下一跳主机网卡的硬件地址(也叫 MAC 地址),该地址的长度是 48 位,是在网卡出厂时固化的
类型
:帧协议类型字段有三种值,分别对应 IP(Internet Protocol)、ARP(Address Resolution Protocol)、RARP(Reverse Address Resolution Protocol)
- IP:表示该帧封装的是IP数据包
- ARP:表示该帧封装的是ARP数据包,用于将IP地址解析为对应的MAC地址。ARP数据包包括ARP请求包和ARP应答包两种类型,分别用于发送查询请求和接收应答回复。
- RARP:表示该帧封装的是RARP数据包,RARP协议是ARP协议的逆向过程,用于将MAC地址解析为对应的IP地址,现在RARP协议已经逐渐被DHCP等更先进的协议所取代,因此在现代网络中遇到RARP数据包的可能性较低
CRC
:循环冗余校验(Cyclic Redundancy Check),用于检验整个数据帧是否传输出错
MTU
以太网规定以太网帧中的数据长度(即有效载荷,不包括帧头)最小为46字节,最大为1500字节,ARP 数据包的长度不够46字节的,要在后面补填充位,这个最大值1500称为以太网的最大传输单元(MTU,Maximum Transmission Unit),不同的网络类型有不同的MTU,可以用指令 ifconfig 查看MTU、MAC地址和IP地址。如果链路层接收到一个超过MTU限制的报文时,它通常会通知网络层该报文的大小超过了MTU限制,并请求网络层对报文进行分片处理。
- MTU 对 IP 协议的影响:
由于数据链路层 MTU 的限制,对于较大的 IP 数据包要进行分包,即将较大的 IP 包分成多个小包,并给每个小包打上标签,到达对端时再将这些小包会按顺序重组拼装到一起返回给传输层,一旦这些小包中任意一个小包丢失,接收端的重组就会失败,但是 IP 层不会负责重新传输数据,而是由传输层进行重发。
- MTU 对 UDP 协议的影响:
一旦 UDP 携带的数据超过 1472(1500 - 20(IP 首部) - 8(UDP 首部)),那么就会在网络层分成多个 IP 数据报,这多个 IP 数据报有任意一个丢失,都会引起接收端网络层重组失败,整个报文就丢失了,因此如果 UDP 数据报在网络层被分片整个数据被丢失的概率会大大增加。
- MTU 对于 TCP 协议的影响
TCP 的一个数据报也受制于 MTU,单个TCP数据报的最大消息长度,称为 MSS(Max Segment Size)
通信双方在三次握手发送 SYN 报文的时会进行 MSS 协商,以确保数据传输的顺利进行。最理想的情况下, MSS 的值正好是在网络层不会被分片处理的最大长度,双方在 TCP 头部写入自己能支持的 MSS 值,然后双方得知对方的 MSS 值之后,选择较小的作为最终 MSS,而MSS 的值就是在 TCP 首部的 40 字节变长选项中(kind=2)。
ARP协议
在链路的2个节点网络通信时时,数据包首先是被网卡接收到再到上层协议的,如果接收到的数据帧的硬件地址与本机不符,则直接丢弃该数据帧,因此在通讯前必须获得目的主机的硬件地址(MAC地址)。
ARP(Address Resolution Protocol,地址解析协议) 不是一个单纯的数据链路层的协议,而是一个介于数据链路层和网络层之间的协议,它建立了主机 IP 地址 和 MAC 地址 的映射关系。
ARP协议格式
ARP协议的格式如下:
硬件类型
:数据链路层的网络类型,1 为以太网;
协议类型
:要转换的地址类型,0x0800 为 IP 地址
硬件地址长度
:对于以太网地址为 6 字节
协议地址长度
:对于 IP 地址为 4 字节
op
:字段为 1 表示 ARP 请求,op 字段为 2 表示 ARP 应答
注意到源 MAC 地址、目的 MAC 地址在以太网首部和 ARP 请求中各出现一次,对于链路层为以太网的情况是多余的,但如果链路层是其它类型的网络则有可能是必要的。
当前主机获取下一个节点的MAC地址的过程为:
源主机发出 ARP 请求,在以太网帧首部的以太网目的地址填充FF:FF:FF:FF:FF:FF,表示广播,这个请求会被广播到本地网段,以询问IP地址是下一跳主机的硬件地址是多少,目的主机接收到广播的 ARP 请求,会先查看OP字段以判断该报文是ARP请求还是ARP应答,接着发现其中的目的 IP 地址与本机相符,它会将自己的硬件地址填写在应答包中,然后将该ARP应答数据包给源主机,这样源主机就获取了下一跳的MAC地址。
为了网络性能,每台主机都维护一个 ARP 缓存表,可以用指令 arp -a 查看,缓存表中的表项是有过期时间的(一般为 20 分钟),如果 20 分钟内没有再次使用某个表项,则该表项失效,下次需要的话要发 ARP 请求来获得目的主机的硬件地址。
ARP欺骗
正常情况下网络通信是这样的:
主机A在自己的ARP缓存表中记录IP地址ipR和macR的映射关系,如果有报文的下一跳IP地址是ipR,直接向macR主机发送即可;同时路由器R在自己的ARP缓存表中记录IP地址ipA和macA的映射关系,如果有报文的下一跳IP地址是ipA,直接向macA主机发送即可。
但此时来了一个中间人主机M,IP地址是ipM,MAC地址是macM,它大量向主机A发送ARP应答报文,告诉主机A现在ipR对应的MAC地址是macM,主机A对此不知情,就对自己的ARP缓存表进行了更新,同理,中间人M对路由器R进行相同的操作,R就认为ipA对应的MAC地址是macM:
这样主机A发往路由器R和路由器R返回给主机A的报文都要经过中间人M,M就成功窃取到它们的通信数据:
局域网转发
同一个局域网中的主机是可以直接进行通信的:当一台主机发送数据帧时,该局域网的所有主机都可以接收到,但这些主机会拿得到的数据帧的帧头的MAC地址与自己的MAC地址做比对,以决定是忽略还是接收这个数据帧,最终只有MAC地址匹配的主机会选择接收这个数据帧。
在局域网中是不允许多台主机同时发送消息的,任何时候都只允许一台主机向局域网中发送数据帧(相当于局域网就是一个临界资源),否则就会发生数据碰撞,导致数据混淆无法区分。不同网络对该问题的解决方案不同,以以太网为例,以太网允许数据碰撞的发生,发送数据的主机会检测是否发送了碰撞,如果检测到了数据碰撞,当前主机就会暂停数据的发送,等待一个合适的时间之后重新发送数据帧。相比之下,令牌环网的解决方案就比较简单:它为当前的局域网分配一个令牌,只有持有令牌的主机才可以向局域网中发送数据,这类似于一个互斥锁。
至于数据帧是如何发送到局域网中,以什么信号形式怎么传输到目标主机,目标主机又是如何将这些信号识别成一个数据帧的等等问题,这些都是物理层的内容,本文不做讨论。
其他协议和技术
NAT技术
我们知道,IP 地址(IPv4)是一个 4 字节 32 位的正整数,一共只有 2的32 次方 个 IP地址,大概是 43 亿左右,而 TCP/IP 协议规定,每个主机都需要有一个 IP 地址,难道只有 43 亿台主机能接入网络吗?而且实际上,由于一些特殊的 IP 地址的存在数量远不足 43 亿,另外 IP 地址也并非是按照主机台数来配置的,而是每一个网卡都需要配置一个或多个 IP 地址。尽管CIDR 在一定程度上缓解了 IP 地址不够用的问题(提高了利用率, 减少了浪费,但是 IP地址的绝对上限并没有增加), IP地址仍然不是很够用,目前时候有三种方式来解决该问题:
-
动态分配 IP 地址:只给接入网络的设备分配 IP 地址,因此同一个 MAC 地址的设备,每次接入互联网中得到的 IP 地址不一定是相同的,这种方法任然只是缓解了该问题,没有从根本上解决问题。
-
IPv6:IPv6 并不是 IPv4 的简单升级版,它们是互不相干的两个协议,彼此并不兼容,IPv6 用 16 字节 128 位来表示一个 IP 地址,该方法几乎从根本上解决了问题,但遗憾的是目前 IPv6 还没有普及。
-
NAT (Network Address Translation,网络地址转换)技术
在路由器内部,有一张路由器自动生成的用于地址转换的表,当数据包到达某个路由器后,如果是源主机第一次向目的主机发送数据,该表就会新增一条记录,记录的是数据包的源IP地址和目的IP地址的映射关系,接着数据包的源IP地址会被替换成当前路由器的WAN口IP,如此层层替换,直到数据包的源IP地址被替换成一个公网IP(运营商公网出口路由器的WAN口IP)。这样当数据包返回时,利用该表就可以溯源上一个主机,层层溯源就可以将数据包返回到原始发送数据的主机。
NAT 路由器将源地址从 10.0.0.10 替换成全局的 IP 202.244.174.37,当服务器的数据包返回到路由器时,它会把目标 IP 从 202.244.174.37 替换回10.0.0.10,这样数据包就成功返回到原始发送数据的主机了。
如果局域网内,有多个主机都访问同一个外网服务器,那么对于服务器返回的数据中,目的 IP 都是相同的,那么 NAT 路由器如何判定将这个数据包转发给哪个局域网的主机?
这时候 NAPT (Network Address Port Translation,网络地址端口转换,是NAT的变体)来解决这个问题,它使用 IP+port 来建立这个映射关系。
当源主机第一次向目的主机发送数据时,它会拿数据包中的源IP地址、源端口号与该路由器的WAN口IP地址与新端口号(该新端口号路由器自行分配,但保证唯一性)做映射生成一条新纪录添加到转换表中,接着把数据包的源IP地址和端口号替换成路由器的WAN口IP地址与新端口号。这样数据包返回时就可以找到原始发送数据的主机。
例如有下面这样一个网络:
M和B在一个子网中,N和C在一个子网中,三个路由器A、B、C在同一个子网中,其路由器A的WAN口IP是一个公网IP,服务器处于公网中,其端口号为11,M和N的私有IP相同,它们的端口号也相同都是7,现在他们都向服务器S发起请求,M发出的报文会经过B到A最后抵达S,N发出的报文会经过C到A最后抵达S,他们建立的转换表大致如下:
这种映射关系也是由路由器自动维护的,例如在 TCP 的情况下,建立连接时就会生成这个表项,在断开连接后就会删除这个表项。
由于NAT依赖转换表,所以它存在一定的缺陷:
- 无法从NAT外部向内部服务器建立连接
- 装换表的生成和销毁都需要额外开销
- 通信过程中一旦NAT设备异常,即使存在热备(一种备份策略,它在主系统正常运行时,备份系统也处于运行状态,并且随时准备接管主系统的工作),所有的TCP连接也都会断开
通过NAT技术,就允许了私有IP的重复,极大的缓解了IPv4的IP地址不足问题,但公网IP数量依旧是有限的,因此可以预见IPv6仍将是未来的主流。
DNS
TCP/IP 中使用 IP 地址和端口号来确定网络上的一台主机的一个程序,但是 IP 地址不方便记忆,于是人们想到用主机名(即一串字符串)来标识网络中的IP,并且使用 hosts 文件来描述主机名和 IP 地址的关系。
最初是通过互连网信息中心(SRI-NIC)来管理这个 hosts 文件的,但如果一个新服务主机要接入网络或者某个服务主机 IP 变更,都需要到信息中心申请变更 hosts 文件,其他计算机也需要定期下载更新新版本的 hosts 文件才能正确上网,这样就显得十分不方便,于是产生了 DNS 系统。
DNS(Domain Name System,域名系统)是一整套从域名映射到 IP 的系统,如果新计算机接入网络则将这个信息注册到数据库中,用户输入域名的时候,会自动查询 DNS 服务器,由 DNS 服务器检索数据库,得到对应的 IP 地址。但是至今我们的计算机上仍然保留了 hosts 文件,在域名解析的过程中仍然会优先查找hosts 文件的内容。一般一个组织的系统管理机构自己会维护系统内的每个主机的 IP 和主机名的对应关系。
域名是分级的,各个部分使用 .
连接,例如域名:www.baidu.com
- com:顶级域名,
com
表示这是一个企业域名,同级的还有net
(网络提供商),org
(非盈利组织) 等 - baidu:一级域名(或称为顶级子域),用于标识其在互联网上的身份和位置,一般是公司名或者组织名
- www:World Wide Web,万维网,二级域名,这只是一种习惯用法,之前人们在使用域名时,往往命名成类似于ftp.xxx.xxx或者www.xxx.xxx 这样的格式,来表示主机支持的协议
域名结构是树状结构:
树的最顶端代表根服务器,根的下一层就是由我们所熟知的.com、.net、.cn等通用域和.cn、.uk等国家域组成,被称为顶级域。网上注册的域名基本都是二级域名(2个层级),比如http://baidu.com、http://taobao.com等等二级域名,它们基本上是归企业和运维人员管理,接下来是三级或者四级域名,这里不多赘述。
域名解析的过程如图:
解析成功后,系统或者浏览器一般都会进行DNS缓存。
可以使用dig工具分析DNS过程,Centos下安装dig工具:
yum install bind-utils
使用方法很简单,可以参考dig使用,例如
dig www.baidu.com
ICMP协议
ICMP协议(Internet Control Message Protocol,互联网控制消息协议)是一个网络层协议,主要用于在IP网络中发送错误报告以及其他需要注意的信息。
ICMP 主要功能包括
- 确认 IP 包是否成功到达目标地址
- 通知在发送过程中 IP 包被丢弃的原因
ICMP 也是基于 IP 协议工作的它只能搭配 IPv4 使用,如果是 IPv6 需要使用 ICMPv6
在源主机向目标主机发送报文时,当目标主机或中途某个节点出现某些错误,如目标主机断电关机,而源主机是无法获取这些情况的,如果源主机希望了解这些情况,就需要距离故障节点最近的节点向源主机返回一个汇报情况的报文
ICMP协议格式如下:
类型的值如下:
可以大致将报文分为2类,一类是通知出错原因,一类是用于诊断查询。
代理服务器
代理服务器又分为正向代理和反向代理,是一种应用比较广的技术,例如
- 翻墙:广域网中的代理
- 负载均衡:局域网中的代理
正向代理用于请求的转发(例如借助代理绕过反爬虫),反向代理往往作为一个缓存。
正向代理
正向代理(Forward Proxy)是一种常见的网络代理方式,它位于客户端和目标服务器之间,代表客户端向目标服务器发送请求。正向代理服务器接收客户端的请求,然后将请求转发给目标服务器,最后将目标服务器的响应返回给客户端。通过这种方式,正向代理可以实现多种功能,如提高访问速度、隐藏客户端身份、实施访问控制等。
-
工作原理
客户端将请求发送给正向代理服务器,正向代理服务器接收请求,并根据配置进行处理,如缓存查找、内容过滤等,然后将处理后的请求转发给目标服务器,目标服务器对接收到的请求做处理,并将响应返回给正向代理服务器,最后正向代理服务器将响应返回给客户端。 -
功能特点
- 缓存功能:正向代理服务器可以缓存经常访问的资源,当客户端再次请求这些资源时,可以直接从缓存中获取,提高访问速度。
- 内容过滤:正向代理可以根据预设的规则对请求或响应进行过滤,如屏蔽广告、阻止恶意网站等。
- 访问控制:通过正向代理,可以实现对特定网站的访问控制,如限制员工在工作时间访问娱乐网站。
- 隐藏客户端身份:正向代理可以隐藏客户端的真实 IP 地址,保护客户端的隐私。
- 负载均衡:在多个目标服务器之间分配客户端请求,提高系统的可扩展性和可靠性。
- 应用场景
- 企业网络管理:企业可以通过正向代理实现对员工网络访问的管理和控制,确保员工在工作时间内专注于工作,避免访问不良网站或泄露公司机密。
- 公共网络环境:在公共场所如图书馆、学校等提供的网络环境中,通过正向代理可以实现对网络资源的合理分配和管理,确保网络使用的公平性和安全性。
- 内容过滤与保护:家长可以通过设置正向代理来过滤不良内容,保护孩子免受网络上的不良信息影响。
- 提高访问速度:对于经常访问的网站或资源,正向代理可以通过缓存机制提高访问速度,减少网络延迟。
- 跨境电商与海外访问:对于跨境电商或需要访问海外资源的企业和个人,正向代理可以帮助他们突破网络限制,顺畅地访问海外网站和资源。
反向代理
反向代理服务器是一种网络架构模式,其作为 Web 服务器的前置服务器,接收来自客户端的请求,并将这些请求转发给后端服务器,然后将后端服务器的响应返回给客户端。这种架构模式可以提升网站性能、安全性和可维护性等
- 基本原理
反向代理服务器位于客户端和 Web 服务器之间,当客户端发起请求时,它首先会到达反向代理服务器。反向代理服务器会根据配置的规则将请求转发给后端的 Web服务器,并将 Web 服务器的响应返回给客户端。在这个过程中,客户端并不知道实际与哪个 Web 服务器进行了交互,它只知道与反向代理服务器进行了通信。CDN(Content Delivery Network,内容分发网络)就是采用了反向代理的原理。
- 应用场景
- 负载均衡:反向代理服务器可以根据配置的负载均衡策略,将客户端的请求分发到多个后端服务器上,以实现负载均衡。这有助于提升网站的整体性能和响应速度,特别是在高并发场景下。
- 安全保护:反向代理服务器可以隐藏后端 Web 服务器的真实 IP 地址,降低其被直接攻击的风险。同时,它还可以配置防火墙、访问控制列表(ACL)等安全策略,对客户端的请求进行过滤和限制,以保护后端服务器的安全。
- 缓存加速:反向代理服务器可以缓存后端 Web 服务器的响应内容,对于重复的请求,它可以直接从缓存中返回响应,而无需再次向后端服务器发起请求。这可以大大减少后端服务器的负载,提升网站的响应速度。
- 内容过滤和重写:反向代理服务器可以根据配置的规则对客户端的请求进行过滤和重写,例如添加或删除请求头、修改请求路径等。这有助于实现一些特定的业务需求,如 URL 重写、用户认证等。
- 动静分离:在大型网站中,通常需要将静态资源和动态资源分开处理。通过将静态资源部署在反向代理服务器上,可以直接从反向代理服务器返回静态资源的响应,而无需再次向后端服务器发起请求。这可以大大提升静态资源的访问速度。
NAT 和代理服务器
路由器往往都具备 NAT 设备的功能,通过 NAT 设备进行中转,完成子网设备和其他子网设备的通信过程。代理服务器看起来和 NAT 设备有一点像。客户端向代理服务器发送请求,代理服务器将请求转发给真正要请求的服务器,服务器返回结果后,代理服务器又把结果回传给客户端,那么 NAT 和代理服务器的区别有哪些呢?
- 从应用上讲, NAT 设备是网络基础设备之一,解决的是 IP 不足的问题,代理服务器则是更贴近具体应用,比如通过代理服务器进行翻墙,另外像迅游这样的加速器,也是使用代理服务器
- 从底层实现上讲, NAT 是工作在网络层,直接对 IP 地址进行替换,代理服务器往往工作在应用层
- 从使用范围上讲,NAT 一般在局域网的出口部署,代理服务器可以在局域网做,也可以在广域网做,也可以跨网
- 从部署位置上看,NAT 一般集成在防火墙,路由器等硬件设备上,代理服务器则是一个软件程序,需要部署在服务器上.
内网穿透
如果我在自己家里的电脑上部署了一个小服务S,如果我现在在学校,我该怎么取得家里电脑的服务呢?直接拿家里电脑的私有IP进行访问肯定是不行的,此时我们需要在公网上部署一个服务M,先让家里的小服务S向服务M发起一个TCP请求,这样S和M之间就有了一条通信链路,此后其他客户端向M发起的请求都可以转发给S,S处理请求后将请求结果返回M,再由M转交给客户端。像M这种服务,现在已经有很多现成的了,如frps等。
其实利用一个中间服务器的转发功能,将请求从一个网络转发到另一个网络。
内网打洞
现在有2个客户端主机A和B,A和B都向公网中的服务器M发起tcp连接,这样A和B到各自出口路由器的链路上都建立起了NAT转换表,此时服务器M做了这样一件事:将A的出口路由器的IP和端口号告诉B,同时也将B的出口路由器的IP和端口号告诉A,此后,A和B都直接向得到的IP+port发送报文,A和B就实现了互相通信,而且通信不必再经过服务器M,这就极大的减轻了服务器M的压力,QQ通信就是利用了这个原理。
高级IO
IO的本质就是等待条件就绪+数据拷贝,如果单位时间内等的比重越低,那么IO的效率就越高。
五种IO模型
- 阻塞IO
阻塞 IO就是在内核将数据准备好之前,系统调用会一直等待,所有的套接字,默认都是阻塞方式,陷入阻塞IO的线程不占用CPU资源,此期间线程无法往下推进执行后续代码。
- 非阻塞IO
非阻塞 IO就是如果内核还未将数据准备好,系统调用会直接返回并且返回EWOULDBLOCK 错误码,此时线程就可以往后继续执行其他代码。但非阻塞 IO 往往需要程序员循环的方式反复尝试读写文件描述符,这个过程称为轮询,这对 CPU 来说是较大的浪费,一般只有特定场景下才使用。
文件描述符默认都是阻塞IO,我们通过系统调用 fcntl 将某个文件描述符设置为非阻塞IO。
#include <unistd.h>
#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* arg */ );
fcntl的返回值与执行的命令有关。如果操作成功,则返回特定于命令的值(如新的文件描述符、文件描述符标记、文件状态标记等)。如果操作失败,则返回-1,并设置errno以指示错误类型,参数说明:
fd
:需要设置的文件描述符
cmd
:操作指令,取值如下
- F_DUPFD:复制一个现有的描述符(cmd=)
- F_GETFD 或 F_SETFD:获得/设置文件描述符标记、
- F_GETFL 或 F_SETFL:获得/设置文件状态标记
- F_GETOWN 或 F_SETOWN:获得/设置异步 I/O 所有权
- F_GETLK,F_SETLK 或 F_SETLKW:获得/设置记录锁
如下,我们就实现了将一个文件描述符设置为非阻塞:
void setNonBlock(int fd) {
int fl = fcntl(fd, F_GETFL); // 获取文件描述符的当前状态标记
if (fl < 0) {
perror("fcntl");
return;
}
fcntl(fd, F_SETFL, fl | O_NONBLOCK); // 设置文件描述符为非阻塞模式
}
- 信号驱动IO
信号驱动 IO就是内核将数据准备好的时候,使用 SIGIO 信号通知应用程序进行 IO操作。此期间线程可以往后继续执行其他代码,等到 SIGIO 信号来临时再进行IO和数据处理。
- IO多路转接
IO 多路转接也称为IO多路复用,能够同时等待多个文件描述符的就绪状态,如果其中任何一个文件描述符就绪了,系统调用就直接返回。
- 异步IO
异步IO就是由内核在数据拷贝完成时,通知应用程序(而信号驱动是告诉应用程序何时可以开始拷贝数据,其将数据从内核空间拷贝到用户空间仍需要一段时间,期间线程时处于阻塞状态的)。此期间线程可以往后继续执行其他代码,等到内核通知线程时直接进行数据处理。
在前面的IO模型中,前四种属于同步IO,其中IO多路转接的方式效率最高,因为它一次等待多个文件描述符,平均下来单位时间内等的比重较低。
纪录锁、系统 V 流机制、I/O 多路转接、readv 和writev 函数以及存储映射 IO(mmap)都统称为高级IO,我们主要讨论I/O 多路转接。
IO多路转接
select
系统提供 select 函数来实现多路复用输入/输出模型,它可以用来让我们的程序监视多个文件描述符的状态变化的,当程序执行到select时,程序会停在这里等待,直到被监视的文件描述符有一个或多个发生了状态改变。
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set*exceptfds, struct timeval *timeout);
其返回值如下:
- 正值:当select函数返回正值时,表示有相应数量的文件描述符已经就绪(如果此次数据没有读完,下次select会立即就绪),可以进行I/O操作。这个正值就是就绪文件描述符的总数。调用者可以通过检查传入的文件描述符集合(readfds、writefds、exceptfds)来确定哪些文件描述符已经就绪。
- 零:如果select函数返回0,则表示在指定的超时时间内,没有任何文件描述符就绪。这通常意味着所有的I/O操作都在等待中,或者已经设置的超时时间已经到期。
- 负值:当select函数返回-1时,表示调用过程中发生了错误。此时,通常会设置全局变量errno来指示具体的错误类型。例如,如果select函数被信号中断,errno可能会被设置为EINTR。
参数说明:
nfds
:要监视的文件描述符集合中的最大值加1。文件描述符是从0开始计数的整数,因此这个参数实际上是文件描述符集合中最大文件描述符的值加1。
readfds
:用于指定要监视其读状态变化的文件描述符集合。如果对某个文件描述符的读状态感兴趣,则应该将其添加到这个集合中。
writefds
:用于指定要监视其写状态变化的文件描述符集合。如果对某个文件描述符的写状态感兴趣,则应该将其添加到这个集合中。
exceptfds
:用于指定要监视其异常状态变化的文件描述符集合。如果某个文件描述符的异常状态感兴趣,则应该将其添加到这个集合中。
timeout
:指向timeval结构的指针,用于指定select函数的超时时间。timeval结构包含两个成员:tv_sec(秒)和tv_usec(微秒),取值如下:
- NULL值:表示select函数将一直阻塞,直到有文件描述符就绪或发生错误。
- 0值:如果将tv_sec和tv_usec都设置为0,则select函数将立即返回,而不进行任何阻塞。
- 正值:如果tv_sec和/或tv_usec设置为正值,则select函数将在指定的超时时间内阻塞,等待文件描述符就绪。如果在超时时间内有文件描述符就绪,则select函数将返回;否则,在超时后返回。
其中fd_set结构体的定义如下:
其实这个结构就是一个整数数组,更严格的说,是一个 “位图”,使用位图中对应的位来表示要监视的文件描述符,系统提供了一组操作 fd_set 的接口,以方便用户操作这个位图:
void FD_CLR(int fd, fd_set *set); // 用来清除set中fd的位
int FD_ISSET(int fd, fd_set *set); // 用来检测fd是否在set中
void FD_SET(int fd, fd_set *set); // 用来将fd设置到set中
void FD_ZERO(fd_set *set); // 用来清除set的全部位
select的特点:
- 可监控的文件描述符个数取决于 sizeof(fd_set)的值,一般
sizeof(fd_set)=512,每 bit 表示一个文件描述符,则支持的最大文件描述符是 512*8=4096,可以通过某些手段调整fd_set 的大小,这可能涉及到重新编译内核 - select返回后,fd_set结构体中对应事件依旧没有就绪的位会被置0,因此将 fd 加入 select 监控集的同时,还要再使用一个数据结构 array 保存放到 select监控集中的 fd,一是用于在 select 返回后,array 作为源数据和 fd_set 进行 FD_ISSET 判断,二是 select 返回后会把以前加入的但并无事件发生的 fd 清空,则每次开始select 前都要重新从 array 取得 fd 逐一加入fd_set结构体(应先用FD_ZERO将该结构体清空),扫描 array 的同时取得 fd 最大值 maxfd,用于 select 的第一个参数。
select的缺点:
- 每次调用 select都需要手动设置 fd 集合(本质原因就是对应的fd_set结构体既作为输入参数又作为输出参数,输入和输出重合),从接口使用角度来说也非常不便
- 每次调用 select,都需要把 fd 集合从用户态拷贝到内核态,这个开销在 fd 很多时会很大(因为文件描述符集合是保存在用户态的内存空间中的。但是,为了检查这些文件描述符是否有I/O事件发生,内核需要在其自己的内存空间中访问这些文件描述符),同时每次调用 select 都需要在内核遍历传递进来的所有 fd,这个开销在 fd 很多时也很大(如果遍历完所有被监视的文件描述符后没有发现任何I/O事件发生,并且没有指定超时(或者超时时间未到),那么select函数通常会进入阻塞状态,进程会挂起(即进入睡眠状态),直到某个被监视的文件描述符上有I/O事件(如可读、可写或异常事件)发生,或者超时时间到达)
- select 支持的文件描述符数量太小
最后,我们看看网络中socket文件描述符的读写就绪和异常就绪情况:
- socket读就绪:
- socket 内核中,接收缓冲区中的字节数大于等于低水位标记
SO_RCVLOWAT,此时可以无阻塞的读该文件描述符,并且返回值大于 0 - socket TCP 通信中,对端关闭连接,此时对该 socket 读,则返回 0
- 监听的 socket 上有新的连接请求
- socket 上有未处理的错误
- socket写就绪:
- socket 内核中,发送缓冲区中的可用字节数(发送缓冲区的空闲位置大小)大于等于低水位标记 SO_SNDLOWAT,此时可以无阻塞的写,并且返回值大于 0
- socket 的写操作被关闭(close 或者 shutdown),对一个写操作被关闭的 socket进行写操作,会触发 SIGPIPE 信号
- socket 使用非阻塞 connect 连接成功或失败之后
- socket 上有未读取的错误
- 异常就绪:
- socket 上收到带外数据
poll
同select一样,poll是系统提供的用来实现多路复用输入/输出模型的函数,可以让我们的程序监视多个文件描述符的状态变化。当程序执行到poll时,程序会停在这里等待,直到被监视的文件描述符有一个或多个发生了状态改变。
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
// pollfd 结构
struct pollfd {
int fd; /* file descriptor 文件描述符 */
short events; /* requested events 要监控的事件类型 */
short revents; /* returned events 实际发生的事件类型 */
};
该函数调用成功时,poll 返回发生事件的文件描述符数量。如果在调用时所有文件描述符都不满足条件,并且 timeout 毫秒已经过去,则返回 0。如果调用失败,则返回 -1,并设置 errno 以指示错误类型。参数说明:
fds
:是一个 poll 函数监听的结构列表,每一个元素中,包含了三部分内容:文件描述符, 监听的事件集合, 返回的事件集合
nfds
:表示 fds 数组的长度
timeout
:表示 poll 函数的超时时间,单位是毫秒(ms)
events 和 revents 的取值:
我们一般只关心POLLIN和POLLOUT,即读事件和写事件。
poll的优点:
- 不同于 select 使用三个位图来表示三个 fdset 的方式,poll 使用一个 pollfd 的指针实现,pollfd 结构包含了要监视的事件和发生的事件,不再使用 select“参数-值”传递的方式,接口使用比 select 更方便
- poll 并没有最大数量限制 (但是数量过大后性能也是会下降)
poll的缺点:
- 和 select 函数一样,poll 返回后,需要轮询 pollfd 来获取就绪的描述符,当监听的文件描述符数目增多时,十分影响性能
- 每次调用 poll 都需要把大量的 pollfd 结构从用户态拷贝到内核中,同时连接的大量客户端在一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的增长,其效率也会下降
总之,poll不需要像select一样在每次调用时都重新初始化文件描述符集,也没有文件描述符数量限制,解决了select的大部分缺点问题,但随着文件描述符数量的增加所带来的监视性能下降问题依旧没有解决。
epoll
epoll是为处理大批量句柄而作了改进的 poll,它是在 2.5.44 内核中被引进的,几乎具备了之前所说的一切优点,被公认为 Linux2.6 下性能最好的多路 I/O 就绪通知方法。
1. 创建一个epoll句柄:
#include <sys/epoll.h>
int epoll_create(int size);
成功时,epoll_create 返回一个非负的文件描述符,该描述符用于后续对 epoll 实例的操作(如添加、删除监控的文件描述符,以及等待事件)。失败时,返回 -1,并设置 errno 以指示错误原因。参数说明:
size
:在较新的 Linux 内核版本中该参数已经被忽略,它曾经用于指示期望监控的文件描述符数量。即使现在被忽略,许多程序仍然传递一个值(如 1024)作为 size,以保持向后兼容性。
需要注意,在使用完之后,必须调用 close()关闭返回的文件描述符。
2. epoll事件注册:
#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event*event);
成功时epoll_ctl 返回 0,失败时返回 -1,并设置 errno 以指示错误原因,参数说明:
epfd
:创建epoll句柄时返回的文件描述符
op
:要执行的操作,可以是以下三个值之一:
- EPOLL_CTL_ADD:向 epoll 实例中添加一个新的文件描述符进行监控。
- EPOLL_CTL_DEL:从 epoll 实例中删除一个文件描述符,停止对其的监控。
- EPOLL_CTL_MOD:修改一个已经存在于 epoll 实例中的文件描述符的监控事件。
fd
:要添加、删除或修改的文件描述符。
event
:指向一个 epoll_event 结构体的指针,该结构指定了要监控的事件类型及与文件描述符相关联的数据(对于 EPOLL_CTL_ADD 和 EPOLL_CTL_MOD 操作)。对于 EPOLL_CTL_DEL 操作,此参数可以是 NULL。
其中 struct epoll_event 结构体定义如下:
struct epoll_event {
__uint32_t events; // 要监控的事件类型(如读、写、异常等)
epoll_data_t data; // 与文件描述符相关联的数据,可以是 `void *`、`int` 或 `uint32_t` 类型
};
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
events
即我们想要监视的文件描述符的事件的集合,它的取值有:
EPOLLIN
: 表示对应的文件描述符可以读 (包括对端 SOCKET 正常关闭)EPOLLOUT
: 表示对应的文件描述符可以写EPOLLPRI
: 表示对应的文件描述符有紧急的数据可读 (这里应该表示有带外数据到来)EPOLLERR
: 表示对应的文件描述符发生错误EPOLLHUP
: 表示对应的文件描述符被挂断EPOLLET
: 将 EPOLL 设为边缘触发(Edge Triggered)模式, 这是相对于水平触发(Level Triggered)来说的EPOLLONESHOT
:只监听一次事件, 当监听完这次事件之后, 如果还需要继续监听这个 socket 的话, 需要再次把这个 socket 加入到 EPOLL 队列里
data
字段是一个epoll_data_t类型的联合体,允许我们存储与文件描述符相关联的任意类型的数据,这个数据在事件发生时会被返回,以便我们识别是哪个文件描述符触发了事件,并据此执行相应的操作,也可以让开发者实现复杂的事件处理逻辑,并优化程序的性能和响应速度。它提供了四种方式来存储与文件描述符相关联的数据:
ptr
:一个通用指针,可以指向任何类型的数据。fd
:一个文件描述符,这在某些情况下很有用,比如当你想要将事件与另一个文件描述符相关联时。u32
:提供了32位的无符号整数存储,可以用于存储任何可以表示为整数的数据。u64
:提供了64位的无符号整数存储,可以用于存储任何可以表示为整数的数据。
3. epoll事件等待:
#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event * events, intmaxevents, int timeout);
如果成功epoll_wait 返回就绪事件的数量,这个值可能小于或等于 maxevents;如果 timeout 到期且没有事件发生则返回 0;失败时返回 -1,并设置 errno 以指示错误原因。参数说明:
epfd
:创建epoll句柄时返回的文件描述符
events
:指向一个 epoll_event 结构数组的指针,该数组用于存储就绪事件的信息,调用者需要分配足够的空间来存储最多 maxevents 个事件
maxevents
:events 数组可以存储的最大事件数。这个值也限制了 epoll_wait 可以一次性返回的最大事件数
timeout
:等待事件的超时时间(以毫秒为单位),如果 timeout 为 -1,epoll_wait 将无限期地阻塞,直到有事件发生,如果 timeout 为 0,epoll_wait 将立即返回,即使没有任何事件发生(这种情况下,返回值将是 0),如果 timeout 大于 0,epoll_wait 将阻塞最多 timeout 毫秒,然后返回。
下面简单介绍一下epoll的工作原理:
每一个 epoll 句柄都有一个独立的 eventpoll 结构体,当我们使用epoll_create创建一个epoll句柄时,Linux内核就会创建一个evenpoll对象,该结构体比较重要成员就是rdllist(一个就绪队列链表)和rbr(一颗红黑树)。当我们使用epoll_ctl将一个新的文件描述符添加到epoll实例中时,就是在该红黑树中新增一个节点(epitem对象),因此使用epoll_ctl对epoll实例的操作就是对该红黑树进行增删查改,这样重复添加的事件就可以通过红黑树而高效的识别出来(红黑树的插入时间效率是 lgn,其中 n 为树的高度)。所有添加到 epoll 中的事件都会与设备(网卡)驱动程序建立回调关系,当响应的事件发生时会调用这个回调方法(内核中这个回调方法叫 ep_poll_callback),它会将发生的事件严格按照发生的先后顺序添加到 rdllist 双链表中。
struct epitem{
struct rb_node rbn;//红黑树节点
struct list_head rdllink;//双向链表节点
struct epoll_filefd ffd; //事件句柄信息
struct eventpoll *ep; //指向其所属的 eventpoll 对象
struct epoll_event event; //期待发生的事件类型
}
我们注意到epitem中有一个成员struct list_head rdllink,如果回调函数想将事件就绪节点加入到就绪链表,只需要将利用rdllink指针就可以将当前节点链入到就绪队列中,从而省去了拷贝的开销(如果是监视多个事件但只有某些事件就绪了,内核并会创建一个简化的结构体或数据结构(通常包含事件类型和对应的文件描述符),来表明当前就绪的这些事件,并将其链入到就绪队列中)。
当调用 epoll_wait 检查是否有事件发生时,只需要检查 eventpoll 对象中的 rdlist 双链表中是否有 epitem 元素即可,如果 rdlist 不为空,则把发生的事件复制到用户态,同时将事件数量返回给用户(由于就绪链表中的节点数量通常相对较少,这个操作的时间复杂度近似O(1))。
epoll的优点:
3. 接口使用方便,虽然拆分成了三个函数,但是反而使用起来更方便高效,不需要每次循环都设置关注的文件描述符,也做到了输入输出参数分离开
4. 数据拷贝轻量:只在合适的时候调用 EPOLL_CTL_ADD 将文件描述符结构拷贝到内核中(例如当添加新的文件描述符到监视列表时),这个操作并不频繁(而 select/poll 都是每次循环都要进行拷贝)
5. 事件回调机制:避免使用遍历,而是使用回调函数的方式将就绪的文件描述符结构加入到就绪队列中,epoll_wait 返回直接访问就绪队列就知道哪些文件描述符就绪,这个操作时间复杂度 O(1),即使文件描述符数目很多,效率也不会受到影响
6. 没有数量限制:文件描述符数目无上限
epoll的工作方式:
epoll 有 2 种工作方式:水平触发(LT)和边缘触发(ET)
-
水平触发 Level Triggered 工作模式:
当 epoll 检测到某个文件描述符上事件就绪的时候,可以不立刻进行处理或者只处理一部分,例如,读缓冲区有2K数据,可以只读 1K 数据,缓冲区中还剩 1K 数据,在第二次调用epoll_wait 时,epoll_wait 会立刻返回并通知该文件描述符读事件就绪,直到缓冲区上所有的数据都被处理完,epoll_wait 才不会立刻返回,epoll 默认状态下就是 LT 工作模式。 -
边缘触发 Edge Triggered 工作模式:
当 epoll 检测到某个文件描述符上事件就绪时,必须立刻处理,如上面的例子中,如果只读了 1K 的数据,缓冲区还剩 1K 的数据,在第二次调用epoll_wait 的时候,epoll_wait 不会立刻返回了,此后如果没有通知用户不敢贸然读取缓冲区中剩下的数据(因为用户只知道自己读了1K的数据,但不确定自己读的这1K究竟是缓冲区数据的一部分还是全部),除非缓冲区中数据变多,epoll_wait 才会返回。也就是说ET 模式下,文件描述符上的事件就绪后只有一次处理机会(注意不是指一次read的机会)。 Nginx (一个高性能的HTTP和反向代理web服务器)默认采用ET 模式使用 epoll
使用 ET 能够减少 epoll_wait 触发的次数,因此ET 的性能比 LT 性能更高(这也意味着ET 的代码复杂程度更高),其实在 LT 情况下如果能做到每次就绪的文件描述符都立刻处理,不让这个就绪被重复提示的话,它们的性能是一样的。
最后需要注意的是,使用 ET 模式的 epoll,需要将文件描述设置为非阻塞,这个不是接口上的要求,而是 “工程实践” 上的要求(LT工作方式没有这个要求,可以对文件描述符的阻塞读写和非阻塞读写),因为ET模式下,epoll只通知我们一次缓冲区中有没有数据可以读写,在使用read读数据时,我们不能保证一次就把缓冲区的数据刚好读完,需要循环多次读取,如果该文件描述符是阻塞读取的话,最后一次读取必然会阻塞,如果我们将该文件描述符设为非阻塞,最后一次读取如果没有出错返回值必定是0(只要检查错误码errno == EWOULDBLOCK || errno == EAGAIN就可以知道是否是出错返回。如果在一个设置为非阻塞模式的文件描述符读写失败时就会将错误码设置为 EWOULDBLOCK;如果在非阻塞模式下尝试读取一个当前没有数据的文件或套接字,或者向一个当前无法写入数据的文件或套接字写入数据,就会将错误码设置为 EAGAIN。而且每一个线程都有自己的错误码error),此时就不会被阻塞,接下来就可以继续调用epoll_wait等待下次缓冲区数据到来。如果是LT模式,只有epoll_wait返回表示事件就绪我们才进行读写,无论该文件描述符是阻塞还是非阻塞,我们都不会被阻塞。
在使用epoll时,对于读,我们要先设置对某个文件描述符的监视,读条件就绪了再读;对于写,我们一般是直接向文件描述符写,如果写完了,那么此次写就结束了,如果写出错返回(在非阻塞模式下写缓冲区满会返回EAGAIN或EWOULDBLOCK错误码,当然也有极小的概率此时数据刚刚好写完了),意味着写条件不满足,此时再用epoll设置对该文件描述符写事件的监视,写完后就解除对该写事件的监视。这样做是因为一般缓冲区大部分时间都是空的,直接读很可能会读到空,而直接写很可能立即就成功了。
epoll 的使用场景:
epoll 的高性能是有一定的特定场景的,如果场景选择的不适宜,epoll 的性能可能适得其反。对于多连接且多连接中只有一部分连接比较活跃时,比较适合使用 epoll,例如典型的一个需要处理上万个客户端的服务器、各种互联网 APP 的入口服务器就很适合 epoll。如果只是系统内部,服务器和服务器之间进行通信,只有少数的几个连接,这种情况下用epoll 就并不合适(epoll在内核中需要维护一些数据和结构,在连接数较少时,这些开销是不值得的,此时使用epoll并不能带来性能上的显著提升),因此要根据具体需求和场景特点来决定使用哪种 IO 模型。