Linux网络编程
1.网络基础
用户在应用层的各种请求最终会下达给操作系统,操作系统内除了进程管理、文件管理、内存管理、驱动管理之外,还有一个内嵌的软件协议栈,协议栈将用户的数据进行各种封包后,通过网卡将数据传递到网络当中,数据在网络内部经过各种路由转发,最终将数据传送到了目标服务器。
网络协议栈各部分所处位置:
- 应用层是位于用户层的。 这部分代码是由网络协议的开发人员来编写的,比如HTTP协议、HTTPS协议以及SSH协议等。
- 传输层和网络层是位于操作系统层的。 其中传输层最经典的协议叫做TCP协议,网络层最经典的协议叫做IP协议,这就是我们平常所说的TCP/IP协议。
- 数据链路层是位于驱动层的。 其负责真正的数据传输。
OSI七层模型和TCP/ip五层模型
2.网络套接字socket
网络套接字是操作系统提供的一种 进程间通信(IPC)机制,类似于双向管道,专为网络通信设计。它本质上是 一个抽象的操作系统对象,应用程序可以通过它 访问 操作系统的网络协议栈(如TCP/IP),实现跨主机的进程间通信。
套接字的核心作用
-
端点标识:
通过 IP地址 + 端口号 唯一标识通信的双方(如192.168.1.100:8080
)。 -
协议抽象:
统一支持TCP(可靠连接)、UDP(无连接)等传输层协议。 -
数据通道:
提供send()
/recv()
等标准接口读写数据,隐藏底层网络细节。
底层如何通过端口号 port找到对应进程的?
实际OS底层采用哈希的方式建立了端口号和进程PID或PCB之间的映射关系,当底层拿到端口号时就可以直接执行对应的哈希算法,然后就能够找到该端口号对应的进程
网络字节序采用的是大端
- 大端模式: 数据的高字节内容保存在内存的低地址处,数据的低字节内容保存在内存的高地址处。
为使网络程序具有可移植性,使同样的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);
我们的端口号是主机字节序,需要转为网络字节序。
socket常见API
创建套接字的函数叫做socket,该函数的函数原型如下:
int socket(int domain, int type, int protocol);_socketfd = socket(AF_INET, SOCK_DGRAM, 0);
参数说明:
- domain:创建套接字的域或者叫做协议家族,也就是创建套接字的类型。该参数就相当于
struct sockaddr
结构的前16个位。如果是本地通信就设置为AF_UNIX
,如果是网络通信就设置为AF_INET
(IPv4)或AF_INET6
(IPv6)。 - type:创建套接字时所需的服务类型。其中最常见的服务类型是
SOCK_STREAM
和SOCK_DGRAM
,如果是基于UDP的网络通信,我们采用的就是SOCK_DGRAM
,叫做用户数据报服务,如果是基于TCP的网络通信,我们采用的就是SOCK_STREAM
,叫做流式套接字,提供的是流式服务。 - protocol:创建套接字的协议类别。你可以指明为TCP或UDP,但该字段一般直接设置为0就可以了,设置为0表示的就是默认,此时会根据传入的前两个参数自动推导出你最终需要使用的是哪种协议。
返回值说明:
- 套接字创建成功返回一个文件描述符,创建失败返回-1,同时错误码会被设置。
socket函数底层做了什么?
当我们调用socket函数创建套接字时,实际相当于我们打开了一个“网络文件”,打开后在内核层面上就形成了一个对应的struct file结构体,同时该结构体被连入到了该进程对应的文件双链表,并将该结构体的首地址填入到了fd_array数组当中下标为3的位置,此时fd_array数组中下标为3的指针就指向了这个打开的“网络文件”,最后3号文件描述符作为socket函数的返回值返回给了用户。
每一个struct file结构体中维护了三个重要信息,文件的属性信息、操作方法以及文件缓冲区等。其中文件对应的属性在内核当中是由struct inode结构体来维护的,而文件对应的操作方法实际就是一堆的函数指针(比如read*和write*)在内核当中就是由struct file_operations结构体来维护的。而文件缓冲区对于打开的普通文件来说对应的一般是磁盘,但对于现在打开的“网络文件”来说,这里的文件缓冲区对应的就是网卡。
作为一款服务器来讲,如果只是把套接字创建好了,那我们也只是在系统层面上打开了一个文件,操作系统将来并不知道是要将数据写入到磁盘还是刷到网卡,此时该文件还没有与网络关联起来。
绑定的函数叫做bind,该函数的函数原型如下:
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);sockaddr_in local;memset(&local, 0, sizeof(local));local.sin_family=AF_INET;local.sin_port = htons(_port);local.sin_addr.s_addr = INADDR_ANY;int n = bind(_socketfd, (sockaddr *)&local, sizeof(local));if (n < 0){std::cerr << "绑定失败" << std::endl;exit(BIND_ERROR);}
客户端在通信时虽然也需要端口号,但客户端一般是不进行绑定的,客户端访问服务端的时候,端口号只要是唯一的就行了,不需要和特定客户端进程强相关。
如果客户端绑定了某个端口号,那么以后这个端口号就只能给这一个客户端使用,就是这个客户端没有启动,这个端口号也无法分配给别人,并且如果这个端口号被别人使用了,那么这个客户端就无法启动了。所以客户端的端口只要保证唯一性就行了,因此客户端端口可以由操作系统动态的进行设置。
TCP专有的接口:
监听套接字:(TCP,服务器),返回一个fd
int listen(int sockfd, int backlog);
参数说明:
- sockfd:需要设置为监听状态的套接字对应的文件描述符。
- backlog:全连接队列的最大长度。如果有多个客户端同时发来连接请求,此时未被服务器处理的连接就会放入连接队列,该参数代表的就是这个全连接队列的最大长度,一般不要设置太大,设置为5或10即可。
接收请求:(TCP,服务器)
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
accept函数返回的套接字是什么?
调用accept函数获取连接时,是从监听套接字当中获取的。如果accept函数获取连接成功,此时会返回接收到的套接字对应的文件描述符。
监听套接字与accept函数返回的套接字的作用:
监听套接字:用于获取客户端发来的连接请求。accept函数会不断从监听套接字当中获取新连接。
accept函数返回的套接字:用于为本次accept获取到的连接提供服务。监听套接字的任务只是不断获取新连接,真正为这些连接提供服务的套接字是accept函数返回的套接字,而不是监听套接字
建立连接:(TCP,客户端)
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
sockaddr结构
套接字提供了sockaddr_in
结构体和sockaddr_un
结构体,其中sockaddr_in
结构体是用于跨网络通信的,而sockaddr_un
结构体是用于本地通信的。
为了让套接字的网络通信和本地通信能够使用同一套函数接口,于是就出现了sockeaddr
结构体,该结构体与sockaddr_in
和sockaddr_un
的结构都不相同,但这三个结构体头部的16个比特位都是一样的,这个字段叫做协议家族。
当我们在传递在传参时,就不用传入sockeaddr_in或sockeaddr_un这样的结构体,而统一传入sockeaddr这样的结构体。在设置参数时就可以通过设置协议家族这个字段,来表明我们是要进行网络通信还是本地通信,在这些API内部就可以提取sockeaddr结构头部的16位进行识别,进而得出我们是要进行网络通信还是本地通信,然后执行对应的操作。此时我们就通过通用sockaddr结构,将套接字网络通信和本地通信的参数类型进行了统一。
整数ip和字符串ip
采用整数IP的方案表示一个IP地址只需要4个字节,并且在网络通信也能表示同样的含义,因此在网络通信时就没有用字符串IP而用的是整数IP,因为这样能够减少网络通信时数据的传送。
字符串IP和整数IP相互转换的方式
转换的方式有很多,比如我们可以定义一个位段A,位段A当中有四个成员,每个成员的大小都是8个比特位,这四个成员就依次表示IP地址的四个区域,一共32个比特位。
然后我们再定义一个联合体IP,该联合体当中有两个成员,其中一个是32位的整数,其代表的就是整数IP,还有一个就是位段A类型的成员,其代表的就是字符串IP。
将字符串IP转换成整数IP的函数叫做inet_addr,该函数的函数原型如下:
in_addr_t inet_addr(const char *cp);
将整数IP转换成字符串IP的函数叫做inet_ntoa,该函数的函数原型如下:
char *inet_ntoa(struct in_addr in); // sockaddr_in里面的sin_addr即为该类型in_addr // 使用实例:InetAddr(sockaddr_in addr): _addr(addr){_ip = inet_ntoa(addr.sin_addr); // 转为点分十进制_port = ntohs(addr.sin_port); // 转为本地字节序列}server.sin_addr.s_addr = inet_addr(serverip.c_str());
还有一些收发数据的接口,recv,send,write,read,recvfrom,sendto。
单执行流服务器的弊端
这服务端只有服务完一个客户端后才会服务另一个客户端。因为我们目前所写的是一个单执行流版的服务器,这个服务器一次只能为一个客户端提供服务。
当服务端调用accept函数获取到连接后就给该客户端提供服务,但在服务端提供服务期间可能会有其他客户端发起连接请求,但由于当前服务器是单执行流的,只能服务完当前客户端后才能继续服务下一个客户端。
客户端为什么会显示连接成功?
当服务端在给第一个客户端提供服务期间,第二个客户端向服务端发起的连接请求时是成功的,只不过服务端没有调用accept函数将该连接获取上来罢了。
实际在底层会为我们维护一个连接队列,服务端没有accept的新连接就会放到这个连接队列当中,而这个连接队列的最大长度就是通过listen函数的第二个参数来指定的,因此服务端虽然没有获取第二个客户端发来的连接请求,但是在第二个客户端那里显示是连接成功的。
如何解决?
单执行流的服务器一次只能给一个客户端提供服务,此时服务器的资源并没有得到充分利用,因此服务器一般是不会写成单执行流的。要解决这个问题就需要将服务器改为多执行流的,此时就要引入多进程或多线程或者线程池。同时引入多进程,我们需要在父进程中忽略SIGCHLD信号或者让孙子进程来执行程序,防止出现僵尸进程。
通讯流程总览
下图是基于TCP协议的客户端/服务器程序的一般流程:
关键是connect()和accept()的返回时机
初始化服务器
当服务器完成套接字创建、绑定以及监听的初始化动作之后,就可以调用accept函数阻塞等待客户端发起请求连接了。
服务器初始化:
建立连接
而客户端在完成套接字创建后,就会在合适的时候通过connect函数向服务器发起连接请求,而客户端在connect的时候本质是通过某种方式向服务器三次握手,因此connect的作用实际就是触发三次握手。
建立连接的过程:
- 调用socket,创建文件描述符。
- 调用bind,将当前的文件描述符和IP/PORT绑定在一起,如果这个端口已经被其他进程占用了,就会bind失败。
- 调用listen,声明当前这个文件描述符作为一个服务器的文件描述符,为后面的accept做好准备。
- 调用accept,并阻塞,等待客户端连接到来。
- 调用socket,创建文件描述符。
- 调用connect,向服务器发起连接请求。
- connect会发出SYN段并阻塞等待服务器应答(第一次)。
- 服务器收到客户端的SYN,会应答一个SYN-ACK段表示“同意建立连接”(第二次)。
- 客户端收到SYN-ACK后会从connect返回,同时应答一个ACK段(第三次)。
-
这个建立连接的过程,通常称为三次握手。
需要注意的是,连接并不是立马建立成功的,由于TCP属于传输层协议,因此在建立连接时双方的操作系统会自主进行三次协商,最后连接才会建立成功。
连接建立和连接被拿到用户层是两码事,accept函数实际不参与三次握手这个过程,因为三次握手本身就是底层TCP所做的工作。accept要做的只是将底层已经建立好的连接拿到用户层,当服务器三次握手成功,accept会返回,将拿到的连接返回给用户层,如果底层没有建立好的连接,那么accept函数就会阻塞住直到有建立好的连接。
断开连接
当双方通信结束之后,需要通过四次挥手的方案使双方断开连接,当客户端调用close关闭连接后,服务器最终也会关闭对应的连接。而其中一次close就对应两次挥手,因此一对close最终对应的就是四次挥手。
断开连接的过程:
- 如果客户端没有更多的请求了,就调用close关闭连接,客户端会向服务器发送FIN段(第一次)。
- 此时服务器收到FIN后,会回应一个ACK,同时read会返回0(第二次)。
- d返回之后,服务器就知道客户端关闭了连接,也调用close关闭连接,这个时候服务器会向客户端发送一个FIN(第三次)。
- 客户端收到FIN,再返回一个ACK给服务器(第四次)。
如果一个连接建立后不断开,那么操作系统就需要一直为其维护对应的数据结构,而维护这个数据结构是需要花费时间和空间的,因此当双方通信结束后就应该将这个连接断开,避免系统资源的浪费,这其实就是TCP比UDP更复杂的原因之一,因为TCP需要对连接进行管理。
3.认识协议
网络协议是通信计算机双方必须共同遵从的一组约定,比如怎么建立连接、怎么互相识别等。同时协议需要解决两个重要问题,如何进行有效载荷和数据的分离,交给上层的谁?
OSI七层模型中会话层 负责建立和断开通信连接(数据流动的逻辑通路)、管理传输层以下的分层
而表示层 设备固有数据格式和网络标准数据格式的转换,表示层与协议息息相关,让我们以何种形式去处理网络中到来的数据。(网络计算机器中收发都是结构体字符串,对 Request(int x, int y, char oper)进行序列化和反序列化)
协议也可以理解为互相发送的内容都是约定好的格式。
http协议
1.http协议以 url从客户端向服务器发送请求,HTTP是基于请求和响应的应用层访问
URL(Uniform Resource Lacator)叫做统一资源定位符,也就是我们通常所说的网址,是因特网的万维网服务程序上用于指定信息位置的表示方法。 url中可能带参数
HTTP请求协议格式如下:
其中,前面三部分是一般是HTTP协议自带的,是由HTTP协议自行设置的,而请求正文一般是用户的相关信息或数据,如果用户在请求时没有信息要上传给服务器,此时请求正文就为空字符串。
如何将HTTP请求的报头与有效载荷进行分离?
当应用层收到一个HTTP请求时,它必须想办法将HTTP的报头与有效载荷进行分离。对于HTTP请求来讲,这里的请求行和请求报头就是HTTP的报头信息,而这里的请求正文实际就是HTTP的有效载荷。
我们可以根据HTTP请求当中的空行来进行分离,当服务器收到一个HTTP请求后,就可以按行进行读取,如果读取到空行则说明已经将报头读取完毕,实际HTTP请求当中的空行就是用来分离报头和有效载荷的。
如果将HTTP请求想象成一个大的线性结构,此时每行的内容都是用\n
隔开的,因此在读取过程中,如果连续读取到了两个\n
,就说明已经将报头读取完毕了,后面剩下的就是有效载荷了。
获取浏览器的HTTP请求
我们可以写一个tcp服务器,启动之后不断accept连接,recv到客户端的请求交给上层的http服务器处理。如下是一个http请求示例:
HTTP响应协议格式如下:
HTTP的方法
GET方法和POST方法
GET方法一般用于获取某种资源信息,而POST方法一般用于将数据上传给服务器。但实际我们上传数据时也有可能使用GET方法,比如百度提交数据时实际使用的就是GET方法。
GET方法和POST方法都可以带参:
- GET方法是通过url传参的。
- POST方法是通过正文传参的。
HTTP的状态码
最常见的状态码,比如200(OK),404(Not Found),403(Forbidden请求权限不够),302(Redirect),504(Bad Gateway)。
重定向又可分为临时重定向和永久重定向,其中状态码301表示的就是永久重定向,而状态码302和307表示的是临时重定向。
临时重定向和永久重定向本质是影响客户端的标签,决定客户端是否需要更新目标地址。如果某个网站是永久重定向,那么第一次访问该网站时由浏览器帮你进行重定向,但后续再访问该网站时就不需要浏览器再进行重定向了,此时你访问的直接就是重定向后的网站。而如果某个网站是临时重定向,那么每次访问该网站时如果需要进行重定向,都需要浏览器来帮我们完成重定向跳转到目标网站。
进行临时重定向时需要用到Location字段,Location字段是HTTP报头当中的一个属性信息,该字段表明了你所要重定向到的目标网站。
我们这里要演示临时重定向,可以将HTTP响应当中的状态码改为307,然后跟上对应的状态码描述,此外,还需要在HTTP响应报头当中添加Location字段,这个Location后面跟的就是你需要重定向到的网页,比如我们这里将其设置为CSDN的首页。
//构建HTTP响应string status_line = "http/1.1 307 Temporary Redirect\n"; //状态行string response_header = "Location: https://www.csdn.net/\n"; //响应报头string blank = "\n"; //空行string response = status_line + response_header + blank; //响应报文//响应HTTP请求send(sock, response.c_str(), response.size(), 0);
HTTP常见的Header
HTTP常见的Header如下:
- Content-Type:数据类型(text/html等)。
- Content-Length:正文的长度。
- Host:客户端告知服务器,所请求的资源是在哪个主机的哪个端口上。
- User-Agent:声明用户的操作系统和浏览器的版本信息。
- Referer:当前页面是哪个页面跳转过来的。
- Location:搭配3XX状态码使用,告诉客户端接下来要去哪里访问。
- Cookie:用于在客户端存储少量信息,通常用于实现会话(session)的功能。
Cookie和Session
HTTP实际上是一种无状态协议,HTTP的每次请求/响应之间是没有任何关系的,但你在使用浏览器的时候发现并不是这样的。
从第一次登录认证之后,浏览器再向该网站发起的HTTP请求当中就会自动包含一个cookie字段,其中携带的就是我第一次的认证信息,此后对端服务器需要对你进行认证时就会直接提取出HTTP请求当中的cookie字段,而不会重新让你输入账号和密码了。
也就是在第一次认证登录后,后续所有的认证都变成了自动认证,这就叫做cookie技术。
内存级别&文件级别
cookie就是在浏览器当中的一个小文件,文件里记录的就是用户的私有信息。cookie文件可以分为两种,一种是内存级别的cookie文件,另一种是文件级别的cookie文件。
单纯的使用cookie是非常不安全的,因为此时cookie文件当中就保存的是你的私密信息,一旦cookie文件泄漏你的隐私信息也就泄漏。
所以当前主流的服务器还引入了SessionID这样的概念,当我们第一次登录某个网站输入账号和密码后,服务器认证成功后还会服务端生成一个对应的SessionID,这个SessionID与用户信息是不相关的。系统会将所有登录用户的SessionID值统一维护起来。
HTTPS VS HTTP
HTTP无论是用GET方法还是POST方法传参,都是没有经过任何加密的,因此早期很多的信息都是可以通过抓包工具抓到的。
为了解决这个问题,于是出现了HTTPS协议,HTTPS实际就是在应用层和传输层协议之间加了一层加密层(SSL&TLS),这层加密层本身也是属于应用层的,它会对用户的个人信息进行各种程度的加密。HTTPS在交付数据时先把数据交给加密层,由加密层对数据加密后再交给传输层。
证书+非对称加密+对称加密形成了最终的方案,服务器会去CA机构申领一份证书,第一次会把这个证书返回给客户端,客户端会从检查证书是否合法,以及是否被修改。证书中有一份公钥S,客户端生成 对称密钥R,用公钥加密R返回给服务器,服务器用S‘解密出R,然后都用对称密钥R通信。
常见的摘要算法有: MD5 和 SHA 系列
以 MD5 为例, 我们不需要研究具体的计算签名的过程, 只需要了解 MD5 的特点: 定⻓: 无论多⻓的字符串, 计算出来的 MD5 值都是固定⻓度 (16 字节版本或者32 字节版本)
UDP协议
从网络中获取的数据在进行向上交付时,在传输层就会提取出该数据对应的目的端口号,进而确定该数据应该交付给当前主机上的哪一个服务进程。
端口号范围划分
端口号的长度是16位,因此端口号的范围是0 ~ 65535:
0 ~ 1023:知名端口号。比如HTTP,FTP,SSH等这些广为使用的应用层协议,它们的端口号都是固定的。
1024 ~ 65535:操作系统动态分配的端口号。客户端程序的端口号就是由操作系统从这个范围分配的。
知名端口号
有些服务器是非常常用的,这些服务器的端口号一般都是固定的:
- ssh服务器,使用22端口。
- ftp服务器,使用21端口。
- telnet服务器,使用23端口。
- http服务器,使用80端口。
- https服务器,使用443端口。
netstat命令
netstat
是一个用来查看网络状态的重要工具。
其常见的选项如下:
- n:拒绝显示别名,能显示数字的全部转换成数字。
- l:仅列出处于LISTEN(监听)状态的服务。
- p:显示建立相关链接的程序名。
- t(tcp):仅显示tcp相关的选项。
- u(udp):仅显示udp相关的选项。
- a(all):显示所有的选项,默认不显示LISTEN相关。
查看TCP相关的网络信息时,一般选择使用nltp
组合选项。而查看UDP相关的网络信息时,一般选择使用nlup
组合选项。
UDP协议格式
- 16位源端口号:表示数据从哪里来。
- 16位目的端口号:表示数据要到哪里去。
- 16位UDP长度:表示整个数据报(UDP首部+UDP数据)的长度。
- 16位UDP检验和:如果UDP报文的检验和出错,就会直接将报文丢弃。
UDP如何将报头与有效载荷进行分离?
UDP的报头当中只包含四个字段,每个字段的长度都是16位,总共8字节。因此UDP采用的实际上是一种定长报头,UDP在读取报文时读取完前8个字节后剩下的就都是有效载荷了。
UDP如何决定将有效载荷交付给上层的哪一个协议?
UDP上层也有很多应用层协议,因此UDP必须想办法将有效载荷交给对应的上层协议,也就是交给应用层对应的进程。
应用层的每一个网络进程都会绑定一个端口号,服务端进程必须显示绑定一个端口号,客户端进程则是由系统动态绑定的一个端口号。UDP就是通过报头当中的目的端口号来找到对应的应用层进程的。
UDP报头本质
操作系统是C语言写的,而UDP协议又是属于内核协议栈的,因此UDP协议也一定是用C语言编写的,UDP报头实际就是一个位段类型。
UDP数据封装:
- 当应用层将数据交给传输层后,在传输层就会创建一个UDP报头类型的变量,然后填充报头当中的各个字段,此时就得到了一个UDP报头。
- 此时操作系统再在内核当中开辟一块空间,将UDP报头和有效载荷拷贝到一起,此时就形成了UDP报文。
UDP数据分用:
- 当传输层从下层获取到一个报文后,就会读取该报文的钱8个字节,提取出对应的目的端口号。
- 通过目的端口号找到对应的上层应用层进程,然后将剩下的有效载荷向上交付给该应用层进程。
面向数据报
应用层交给UDP多长的报文,UDP就原样发送,既不会拆分,也不会合并,这就叫做面向数据报。
比如用UDP传输100个字节的数据:
- 如果发送端调用一次sendto,发送100字节,那么接收端也必须调用对应的一次recvfrom,接收100个字节;而不能循环调用10次recvfrom,每次接收10个字节。
UDP的缓冲区
- UDP没有真正意义上的发送缓冲区,其内核"发送缓冲区" 仅暂存待发送的单个数据报。我们调用sendto会直接交给内核,由内核将数据传给网络层协议进行后续的传输动作。
- UDP具有接收缓冲区。但是这个接收缓冲区不能保证收到的UDP报的顺序和发送UDP报的顺序一致;如果缓冲区满了,再到达的UDP数据就会被丢弃。
- UDP的socket既能读,也能写,因此UDP是全双工的。
而TCP的缓冲区按字节流有序管理,支持重传和流控 |
UDP本身是会维护一个接收缓冲区的,当有新的UDP报文到来时就会把这个报文放到接收缓冲区当中,此时上层在读数据的时就直接从这个接收缓冲区当中进行读取就行了,而如果UDP接收缓冲区当中没有数据那上层在读取时就会被阻塞。因此UDP的接收缓冲区的作用就是,将接收到的报文暂时的保存起来,供上层读取。
DP协议报头当中的UDP最大长度是16位的,因此一个UDP报文的最大长度是64K(包含UDP报头的大小)。
然而64K在当今的互联网环境下,是一个非常小的数字。如果需要传输的数据超过64K,就需要在应用层 sendto() 之前进行手动分包,多次发送,并在接收端进行手动拼装。
TCP协议
协议格式
TCP报头当中各个字段的含义如下:
- 源/目的端口号:表示数据是从哪个进程来,到发送到对端主机上的哪个进程。
- 32位序号/32位确认序号:分别代表TCP报文当中每个字节数据的编号以及对对方的确认,是TCP保证可靠性的重要字段。
- 4位TCP报头长度:表示该TCP报头的长度,以4字节为单位。
- 6位保留字段:TCP报头中暂时未使用的6个比特位。
- 16位窗口大小:保证TCP可靠性机制和效率提升机制的重要字段。
- 16位检验和:由发送端填充,采用CRC校验。接收端校验不通过,则认为接收到的数据有问题。(检验和包含TCP首部+TCP数据部分)
- 16位紧急指针:标识紧急数据在报文中的偏移量,需要配合标志字段当中的URG字段统一使用。
- 选项字段:TCP报头当中允许携带额外的选项字段,最多40字节。
TCP报头当中的6位标志位:
- URG:紧急指针是否有效。
- ACK:确认序号是否有效。
- PSH:提示接收端应用程序立刻将TCP接收缓冲区当中的数据读走。
- RST:表示要求对方重新建立连接。我们把携带RST标识的报文称为复位报文段。
- SYN:表示请求与对方建立连接。我们把携带SYN标识的报文称为同步报文段。
- FIN:通知对方,本端要关闭了。我们把携带FIN标识的报文称为结束报文段。
TCP报头在内核当中本质就是一个位段类型,给数据封装TCP报头时,实际上就是用该位段类型定义一个变量,然后填充TCP报头当中的各个属性字段,最后将这个TCP报头拷贝到数据的首部,至此便完成了TCP报头的封装。
确认应答机制保证可靠性
每个消息都有一个ACK以及确认序号,它可以保证确认序号之前的消息一定被对方收到了。
超时重传机制保证可靠性
当发送缓冲区当中的数据被发送出去后,操作系统不会立即将该数据从发送缓冲区当中删除或覆盖,而会让其保留在发送缓冲区当中,以免需要进行超时重传,直到收到该数据的响应报文后,发送缓冲区中的这部分数据才可以被删除或覆盖。
超时重传的时间不能设置的太长也不能设置的太短。TCP为了保证无论在任何环境下都能有比较高性能的通信,因此会动态计算这个最大超时时间。
序号保证效率和有序性
一次可以发送多个报文,报文中第一个字节为起始序号,对方可以根据序号进行拼接。
窗口大小协调两边的发送速率
调用read和write进行文件读写时,并不是直接从磁盘读取数据,也不是直接将数据写入到磁盘上,而对文件缓冲区进行的读写操作。TCP中read和write也是直接拷贝在发送和接收缓冲区。
当发送端要将数据发送给对端时,本质是把自己发送缓冲区当中的数据发送到对端的接收缓冲区当中。但缓冲区是有大小的,如果接收端处理数据的速度小于发送端发送数据的速度,那么总有一个时刻接收端的接收缓冲区会被打满,这时发送端再发送数据过来就会造成数据丢包,进而引起丢包重传等一系列的连锁反应。
因此TCP报头当中就有了16位的窗口大小,这个16位窗口大小当中填的是自身接收缓冲区中剩余空间的大小,也就是当前主机接收数据的能力。
TCP是面向连接的
TCP的各种可靠性机制实际都不是从主机到主机的,而是基于连接的,与连接是强相关的。比如一台服务器启动后可能有多个客户端前来访问,如果TCP不是基于连接的,也就意味着服务器端只有一个接收缓冲区,此时各个客户端发来的数据都会拷贝到这个接收缓冲区当中,此时这些数据就可能会互相干扰。
而我们在进行TCP通信之前需要先建立连接,就是因为TCP的各种可靠性保证都是基于连接的,要保证传输数据的可靠性的前提就是先建立好连接。OS中存在大量连接,每个连接中都有各自的发送和接收缓冲区。
三次握手的过程
双方在进行TCP通信之前需要先建立连接,建立连接的这个过程我们称之为三次握手。
三次握手是验证双方通信信道的最小次数:
- 因为TCP是全双工通信的,因此连接建立的核心要务实际是,验证双方的通信信道是否是连通的。
- 而三次握手恰好是验证双方通信信道的最小次数,通过三次握手后双方就都能知道自己和对方是否都能够正常发送和接收数据。
- 同时最后一次报文注定无法保证一定到达,因此再多握手也没必要。
最后一次握手丢失
1. 客户端也需要短暂维护这个异常,但客户端的异常连接不会特别多,不像服务器,一旦多个客户端建立连接时都建立失败了,此时服务器端就需要耗费大量资源来维护这些异常连接。
2. 如果服务器端长时间收不到客户端发来的第三次握手,就会将第二次握手进行超时重传,此时客户端就有机会重新发出第三次握手。或者当客户端认为连接建立好后向服务器发送数据时,此时服务器会发现没有和该客户端建立连接而要求客户端重新建立连接。
两次握手为什么不行?
-
服务端无法确认客户端是否具备接收能力。
-
防止洪泛攻击,攻击者可伪造大量
SYN
报文耗尽服务端资源 - 两次握手无法区分 当前有效连接 和 延迟的历史连接请求,可能引发服务端资源浪费
三次握手时的状态变化
套接字和三次握手之间的关系
- 在客户端发起连接建立请求之前,服务器需要先进入LISTEN状态,此时就需要服务器调用对应listen函数。
- 当服务器进入LISTEN状态后,客户端就可以向服务器发起三次握手了,此时客户端对应调用的就是connect函数。
- 需要注意的是,connect函数不参与底层的三次握手,connect函数的作用只是发起三次握手。当connect函数返回时,要么是底层已经成功完成了三次握手连接建立成功,要么是底层三次握手失败。
- 如果服务器端与客户端成功完成了三次握手,此时在服务器端就会建立一个连接,但这个连接在内核的等待队列当中,服务器端需要通过调用accept函数将这个建立好的连接获取上来。accept函数本身也不参与三次握手
- 当服务器端将建立好的连接获取上来后,双方就可以通过调用read/recv函数和write/send函数进行数据交互了。
四次挥手的过程
还是以服务器和客户端为例,当客户端与服务器通信结束后,需要与服务器断开连接,此时就需要进行四次挥手。
- 第一次挥手:客户端向服务器发送的报文当中的FIN位被设置为1,表示请求与服务器断开连接。
- 第二次挥手:服务器收到客户端发来的断开连接请求后对其进行响应。
- 第三次挥手:服务器收到客户端断开连接的请求,且已经没有数据需要发送给客户端的时候,服务器就会向客户端发起断开连接请求。
- 第四次挥手:客户端收到服务器发来的断开连接请求后对其进行响应。
为什么是四次挥手?
- 由于TCP是全双工的,建立连接的时候需要建立双方的连接,断开连接时也同样如此。在断开连接时不仅要断开从客户端到服务器方向的通信信道,也要断开从服务器到客户端的通信信道,其中每两次挥手对应就是关闭一个方向的通信信道,因此断开连接时需要进行四次挥手。
- 需要注意的是,四次挥手当中的第二次和第三次挥手不能合并在一起,因为第三次握手是服务器端想要与客户端断开连接时发给客户端的请求,而当服务器收到客户端断开连接的请求并响应后,服务器不一定会马上发起第三次挥手,因为服务器可能还有某些数据要发送给客户端
两边都要close文件描述符才可以两边都关闭连接。
CLOSE_WAIT
- 双方在进行四次挥手时,如果只有客户端调用了close函数,而服务器不调用close函数,此时服务器就会进入CLOSE_WAIT状态,而客户端则会进入到FIN_WAIT_2状态。
- 但只有完成四次挥手后连接才算真正断开,此时双方才会释放对应的连接资源。如果服务器没有主动关闭不需要的文件描述符,此时在服务器端就会存在大量处于CLOSE_WAIT状态的连接,而每个连接都会占用服务器的资源,最终就会导致服务器可用资源越来越少。
- 因此如果不及时关闭不用的文件描述符,除了会造成文件描述符泄漏以外,可能也会导致连接资源没有完全释放,这其实也是一种内存泄漏的问题。
- 因此在编写网络套接字代码时,如果发现服务器端存在大量处于CLOSE_WAIT状态的连接,此时就可以检查一下是不是服务器没有及时调用close函数关闭对应的文件描述符。
TIME_WAIT状态存在的必要性:
- 客户端在进行四次挥手后进入TIME_WAIT状态,如果第四次挥手的报文丢包了,客户端在一段时间内仍然能够接收服务器重发的FIN报文并对其进行响应,能够较大概率保证最后一个ACK被服务器收到。
- 客户端发出最后一次挥手时,双方历史通信的数据可能还没有发送到对方。因此客户端四次挥手后进入TIME_WAIT状态,还可以保证双方通信信道上的数据在网络中尽可能的消散。
TCP协议规定,主动关闭连接的一方在四次挥手后要处于TIME_WAIT状态,等待两个MSL(Maximum Segment Lifetime,报文最大生存时间)的时间才能进入CLOSED状态。
MSL在RFC1122中规定为两分钟,但是各个操作系统的实现不同,比如在Centos7上默认配置的值是60s。
滑动窗口
发送方可以一次发送多个报文给对方,此时也就意味着发送出去的这部分报文当中有相当一部分数据是暂时没有收到应答的。发送缓冲区的第二部分就叫做滑动窗口,滑动窗口除了限定不收到ACK而可以直接发送的数据之外,滑动窗口也可以支持TCP的重传机制。
滑动窗口存在的最大意义就是可以提高发送数据的效率:
- 滑动窗口的大小等于对方窗口大小与自身拥塞窗口大小的较小值,因为发送数据时不仅要考虑对方的接收能力,还要考虑当前网络的状况。
- 我们这里先不考虑拥塞窗口,并且假设对方的窗口大小一直固定为4000,此时发送方不用等待ACK一次所能发送的数据就是4000字节,因此滑动窗口的大小就是4000字节。(四个段)
丢包问题
当发送端一次发送多个报文数据时,此时的丢包情况也可以分为两种。
情况一:数据包已经抵达,ACK丢包。
在发送端连续发送多个报文数据时,部分ACK丢包并不要紧,此时可以通过后续的ACK进行确认。
情况二: 数据包丢了。
- 如果发送端连续收到三次确认序号为1001的响应报文,此时就会将1001-2000的数据包重新进行发送。这种机制被称为“高速重发控制”,也叫做“快重传”。
- 快重传是能够快速进行数据的重发,当发送端连续收到三次相同的应答时就会触发快重传,而不像超时重传一样需要通过设置重传定时器,在固定的时间后才会进行重传。
拥塞控制
TCP不仅考虑了通信双端主机的问题,同时也考虑了网络的问题。
- 流量控制:考虑的是对端接收缓冲区的接收能力,进而控制发送方发送数据的速度,避免对端接收缓冲区溢出。
- 滑动窗口:考虑的是发送端不用等待ACK一次所能发送的数据最大量,进而提高发送端发送数据的效率。
- 拥塞窗口:考虑的是双方通信时网络的问题,如果发送的数据超过了拥塞窗口的大小就可能会引起网络拥塞。
粘包问题
什么是粘包?
- TCP是一个一个报文过来的,按照序号排好序放在缓冲区中。但站在应用层的角度,看到的只是一串连续的字节数据。那么应用程序看到了这么一连串的字节数据,就不知道从哪个部分开始到哪个部分,是一个完整的应用层数据包。
如何解决粘包问题
要解决粘包问题,本质就是要明确报文和报文之间的边界,由应用层读取。
- 对于UDP,如果还没有上层交付数据,UDP的报文长度仍然在,同时,UDP是一个一个把数据交付给应用层的,有很明确的数据边界。
- 对于定长的包,保证每次都按固定大小读取即可。
- 对于变长的包,可以在报头的位置,约定一个包总长度的字段,从而就知道了包的结束位置。比如HTTP报头当中就包含Content-Length属性,表示正文的长度。两个/r/n表示读取头部完成,再提取其中的Content-Length,读取一整个包。
- 对于变长的包,还可以在包和包之间使用明确的分隔符。因为应用层协议是程序员自己来定的,只要保证分隔符不和正文冲突即可。
解决TIME_WAIT状态引起的bind失败的方法
主动发起四次挥手的一方在四次挥手后,会进入TIME_WAIT状态。如果在有客户端连接服务器的情况下服务器进程退出了,就相当于服务器主动发起了四次挥手,此时服务器维护的连接在四次挥手后就会进入TIME_WAIT状态。
因为在TIME_WAIT期间,这个连接并没有被完全释放,也就意味着服务器绑定的端口号仍然是被占用的,此时服务器想要继续绑定该端口号启动,就只能等待TIME_WAIT结束。
但当服务器崩溃后最重要实际是让服务器立马重新启动,如果想要让服务器崩溃后在TIME_WAIT期间也能立马重新启动,需要让服务器在调用socket函数创建套接字后,继续调用setsockopt函数设置端口复用,这也是编写服务器代码时的推荐做法。
理解listen的第二个参数
在编写TCP套接字的服务器代码时,在进行了套接字的创建和绑定之后,需要调用listen函数将创建的套接字设置为监听状态,此后服务器就可以调用accept函数获取建立好的连接了。如果我们不用accept获取连接,这个连接就会在一个 队列之中。这个队列的长度+1就是listen的第二个参数。
当我们设置listen第二个参数为2时,会看到之后有三个连接成功建立。
IP协议
- 网络层解决的问题是,将数据从一台主机送到另一台主机,因此网络层解决的是主机到主机的问题。
- 一方传输层从上方进程拿到数据后,该数据贯穿网络协议栈进行封装和解包,最终到达对方传输层,此时对方传输层也会将数据向上交给对应的进程,因此传输层解决的是进程到进程的问题。
- 4位版本号(version):指定IP协议的版本(IPv4/IPv6),对于IPv4来说,就是4。
- 4位首部长度(header length):表示IP报头的长度,以4字节为单位。
- 6位总长度(total length):IP报文(IP报头+有效载荷)的总长度,用于将各个IP报文进行分离。
- 16位标识(id):唯一的标识主机发送的报文,如果数据在IP层进行了分片,那么每一个分片对应的id都是相同的。
- 3位标志字段:第一位保留,表示暂时没有规定该字段的意义。第二位表示禁止分片,表示如果报文长度超过MTU,IP模块就会丢弃该报文。第三位表示“更多分片”,如果报文没有进行分片,则该字段设置为0,如果报文进行了分片,则除了最后一个分片报文设置为0以外,其余分片报文均设置为1。
-
3位片偏移(framegament offset):分片相对于原始数据开始处的偏移,表示当前分片在原数据中的偏移位置,实际偏移的字节数是这个值× 8 \times 8×8得到的。因此除了最后一个报文之外,其他报文的长度必须是8的整数倍,否则报文就不连续了。
- 8位生存时间(Time To Live,TTL):数据报到达目的地的最大报文跳数,一般是64,每经过一个路由,TTL -= 1,一直减到0还没到达,那么就丢弃了,这个字段主要是用来防止出现路由循环。
- 8位协议:表示上层协议的类型。
- 6位首部检验和:使用CRC进行校验,来鉴别数据报的首部是否损坏,但不检验数据部分。
- 32位源IP地址和32位目的IP地址:表示发送端和接收端所对应的IP地址。
P如何将报头与有效载荷进行分离?
IP分离报头与有效载荷的方法与TCP是一模一样的,当IP从底层获取到一个报文后,虽然IP不知道报头的具体长度,但IP报文的前20个字节是IP的基本报头,并且这20字节当中涵盖4位首部长度。
- 因为传输层和网络层都是在操作系统内核当中实现的,数据在进行封装时操作系统会自行填充上对应的源IP地址和源端口号。
8位生存时间
报文在网络传输过程中,可能因为某些原因导致报文无法到达目标主机,比如报文在路由时出现了环路路由的情况,或者目标主机已经异常离线了,此时这个报文就成了一个废弃的游离报文。
为了避免网络当中出现大量的游离报文,于是在IP的报头当中就出现了一个字段,叫做8位生存时间(Time To Live,TTL)。8位生存时间代表的是报文到达目的地的最大报文跳数,每当报文经过一次路由,这里的生存时间就会减一,当生存时间减为0时该报文就会被自动丢弃,此时这个报文就会在网络中消散。
数据的分片和组装都是由IP层完成的
如果IP层要传送的数据超过了1500字节,那么就需要先在IP层对该数据进行分片,然后再将分片后的数据交给下层MAC帧进行发送。如果发送数据时在IP层进行了分片,那么当这些分片数据到达对端主机的IP层后就需要先进行组装,然后再将组装好的数据交付给上层传输层。
- 当IP将待发送的数据交给MAC帧后,MAC帧并不知道该数据是IP经过分片后的某个分片数据,还是一个没有经过分片的数据,MAC帧只知道它一次最多只能发送MTU大小的数据,如果IP交给MAC帧大于MTU字节的数据,那MAC帧就无法进行发送。
主机和路由器
- 主机:配有IP地址,但是不进行路由控制的设备。但实际现在几乎不存在不进行路由控制的设备了,就连你的笔记本也会进行路由控制。
- 路由器:既配有IP地址,又能进行路由控制。实际现在主流的路由器已经不仅仅具有路由的功能了,它甚至具备某些应用层的功能。
每个主机路由器内部会维护一个路由表,我们可以通过route
命令查看云服务器上对应的路由表。
Destination
代表的是目的网络地址。Gateway
代表的是下一跳地址。Genmask
代表的是子网掩码。Flags
中,U标志表示此条目有效(可以禁用某些条目)G标志表示此条目的下一跳地址是某个路由器的地址,没有G标志的条目表示目的网络地址是与本机接口直接相连的网络,不必经路由器转发。Iface
代表的是发送接口。
当IP数据包到达路由器时,路由器就会用该数据的目的IP地址,依次与路由表中的子网掩码 Genmask进行“按位与”操作,然后将结果与子网掩码对应的目的网络地址Destination进行比对,如果匹配则说明该数据包下一跳就应该跳去这个子网,此时就会将该数据包通过对应的发送接口Iface发出。
路由表生成算法
路由可分为静态路由和动态路由:
- 静态路由:是指由网络管理员手工配置路由信息。
- 动态路由:是指路由器能够通过算法自动建立自己的路由表,并且能够根据实际情况进行调整。
IP地址的构成
IP地址由网络号和主机号两部分构成:
- 网络号:保证相互连接的两个网段具有不同的标识。
- 主机号:同一网段内,主机之间具有相同的网络号,但是必须有不同的主机号。
可以在IP地址的后面加一个 /,并在 / 后面加上一个数字,这就表示从头数到第几位为止属于网络标识。
例如,下图中路由器连接了两个网段。对于网络标识来讲,同一网段内主机的网络标识是相同的,不同网段内主机的网络标识是不同的。而对于主机标识来讲,同一网段内主机的主机标识是不同的,不同网段内主机的主机标识是可以相同的。
DHCP协议
实际手动管理IP地址是一个非常麻烦的事情,当子网中新增主机时需要给其分配一个IP地址,当子网当中有主机断开网络时又需要将其IP地址进行回收,便于分配给后续新增的主机使用。
- DHCP是一个基于UDP的应用层协议,一般的路由器都带有DHCP功能,因此路由器也可以看作一个DHCP服务器。
- 当我们连接WiFi时需要输入密码,本质就是因为路由器需要验证你的账号和密码,如果验证通过,那么路由器就会给你动态分配了一个IP地址,然后你就可以基于这个IP地址进行各种上网动作了。
数据路由先以找目标网络为目的,那么在查找过程中就能一次排除大量和目标主机不在同一网段的主机,这样就可以大大提高检索的效率。
子网划分
各类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。
在此基础上出现了继续进行子网划分,需要借用主机号当中的若干位来充当网络号,此时为了区分IP地址中的网络号和主机号,于是引入了子网掩码(subnet mask)的概念。CIDR虽然在一定程度上缓解了IP地址不够用的问题,因为CIDR提高了IP地址的利用率,减少了浪费,但IP地址的绝对上限并没有增加。
因此一个数据在路由的时候,随着数据不断路由进入更小的子网,其网络号的位数是在不断变化的,准确来说其网络号的位数是在不断增加的,这也就意味着IP地址当中的主机号的位数在不断减少。最终当数据路由到达目标主机所在的网络时,就可以在该网络当中找到对应的目标主机并将数据交给该主机,此时该数据的路由也就结束了。
特殊的IP地址
并不是所有的IP地址都能够作为主机的IP地址,有些IP地址本身就是具有特殊用途的。
- 将IP地址中的主机地址全部设为0,就成为了网络号,代表这个局域网。
- 将IP地址中的主机地址全部设为1,就成为了广播地址,用于给同一个链路中相互连接的所有主机发送数据包。
- 127.*的IP地址用于本机环回(loop back)测试,通常是127.0.0.1。
如何解决IP地址不足问题
- 动态分配IP地址:只给接入网络的设备分配IP地址,因此同一个MAC地址的设备,每次接入互联网中,得到的IP地址不一定是相同的,避免了IP地址强绑定于某一台设备。
- NAT技术:能够让不同局域网当中同时存在两个相同的IP地址,NAT技术不仅能解决IP地址不足的问题,而且还能够有效地避免来自网络外部的攻击,隐藏并保护网络内部的计算机。
- Pv6:IPv6用16字节128位来表示一个IP地址,能够大大缓解IP地址不足的问题。但IPv6并不是IPv4的简单升级版,它们是互不相干的两个协议,彼此并不兼容,因此目前IPv6还没有普及。
如果一个组织内部组建局域网,IP地址只用于局域网内的通信,而不直接连到Internet上,理论上使用任意的IP地址都可以,但是RFC 1918规定了用于组建局域网的私有IP地址。
包含在这个范围中的,都称为私网IP,其余的则称为公网IP(或全局IP)。
- 10.*,前8位是网络号,共16,777,216个地址。
- 72.16.*到172.31.*,前12位是网络号,共1,048,576个地址。
- 192.168.*,前16位是网络号,共65,536个地址。
数据是如何发送到服务器的
路由器是连接两个或多个网络的硬件设备,在路由器上有两种网络接口,分别是LAN口和WAN口:
- LAN口(Local Area Network):表示连接本地网络的端口,主要与家庭网络中的交换机、集线器或PC相连。
- WAN口(Wide Area Network):表示连接广域网的端口,一般指互联网。
如果局域网内, 有多个主机都访问同一个外网服务器, 那么对于服务器返回的数据中, 目的 IP 都是相同的. 那么 NAT 路由器如何判定将这个数据包转发给哪个局域网的主机?
这时候 NAPT 来解决这个问题了. 使用 IP+port 来建立这个关联关系
正向代理(Forward Proxy) 是一种常见的网络代理方式, 它位于客户端和目标服务器之间, 代表客户端向目标服务器发送请求。 正向代理服务器接收客户端的请求, 然后将请求转发给目标服务器, 最后将目标服务器的响应返回给客户端。 通过这种方式, 正向代理可以实现多种功能, 如提高访问速度、 隐藏客户端身份、 实施访问控制等
企业网络管理:企业可以通过正向代理实现对员工网络访问的管理和控制.
跨境电商与海外访问:对于跨境电商或需要访问海外资源的企业和个人,正向代理可以帮助他们突破网络限制.
反向代理服务器是一种网络架构模式,其作为Web服务器的前置服务器,接收来自客户端的请求,并将这些请求转发给后端服务器,然后将后端服务器的响应返回
给客户端。这种架构模式可以提升网站性能、安全性和可维护性等
负载均衡:反向代理服务器可以根据配置的负载均衡策略,将客户端的请求分发到多个后端服务器上,以实现负载均衡。这有助于提升网站的整体性能和响应速度,
特别是在高并发场景下。
安全保护:反向代理服务器可以隐藏后端Web服务器的真实IP地址,降低其被接攻击的风险。同时,它还可以配置防火墙、访问控制列表(ACL)等安全策略,对客户端的请求进行过滤和限制,以保护后端服务器的安全。
缓存加速,动静分离,接攻击的风险。同时,它还可以配置防火墙、访问控制列表(ACL)等安全策略,对客户端的请求进行过滤和限制,以保护后端服务器的安全。
以太网协议
网络层IP提供的是跨网络发送数据的能力,传输层TCP是为数据发送提供可靠性保证的,而链路层解决的则是两台相连主机之间的通信问题。
不同局域网所采用的通信技术可能是不同的,常见的局域网技术有以下三种:
- 以太网:以太网是一种计算机局域网技术,一种应用最普遍的局域网技术。
- 令牌环网:令牌环网常用于IBM系统中,在这种网络中有一种专门的帧称为“令牌”,在环路上持续地传输来确定一个节点何时可以发送包。
- 无线LAN/WAN:无线局域网是有线网络的补充和扩展,现在已经是计算机网络的一个重要组织部分。
虽然网络中各个局域网所采用的通信技术可能的不同的,但是IP屏蔽了底层网络的差异,对于网络通信双方的IP层及其往上的协议来说,它们并不需要关心底层具体使用的是哪种局域网技术。
以太网通信原理
- “以太网”不是一种具体的网络,而是一种技术标准,它既包含了数据链路层的内容,也包含了一些物理层的内容。例如,以太网规定了网络拓扑结构,访问控制方式,传输速率等。
- 以太网中的网线必须使用双绞线,传输速率有10M,100M,1000M等。
以太网中所有的主机共享一个通信信道,当局域网中的一台主机发出数据后,该局域网中的所有主机都能够收到该数据。
碰撞避免算法
由于以太网中的所有的主机共享一个通信信道,因此在同一时刻只允许有一台主机发送数据,否则各个主机发送的数据就会相互干扰。站在系统的角度来看,这里各个主机所共享的通信信道就是一种临界资源,这个临界资源同一时刻只允许一台主机使用。
- 所谓的碰撞避免算法就是,当主机发送出去的数据产生碰撞时,该主机需要等待一段时间后再进行数据重发,在主机等待的时候就能够就能够尽可能让局域网当中的数据消散。
- 以太网通信的原理就像现实生活中开会一样,在开会过程中同一时刻只允许一个人发言,如果两个人突然同时说话,那么双方都会有礼貌的等待别人先说。
以太网帧格式如下:
- 源地址和目的地址是指网卡的硬件地址(也叫MAC地址),长度是48位,是在网卡出厂时固化的。
- 帧协议类型字段有三种值,分别对应IP协议、ARP协议和RARP协议。
- 帧末尾是CRC校验码。
MAC帧如何将报头与有效载荷进行分离?
以太网MAC帧的帧头和帧尾都是固定长度的,因此当底层收到一个MAC帧后,直接提取出MAC帧当中固定长度的帧头和帧尾,此时剩下的就是有效载荷了。
MAC帧如何决定将有效载荷交付给上层的哪一个协议?
以太网MAC帧对应的上层协议不止一种,因此在将MAC帧的报头和有效载荷分离后,还需要确定应该将分离出来的有效载荷交付给上层的哪一个协议。
在MAC帧的帧头当中有2个字节的类型字段,因此在分离出报头和有效载荷后,根据该字段将有效载荷交付给对应的上层协议即可。
我们可以通过ifconfig
命令来查看我们的ip地址和MAC地址。
认识MTU
MTU(Maximum Transmission Unit,最大传输单元)描述的是底层数据帧一次最多可以发送的数据量,这个限制是不同的数据链路层对应的物理层产生的。
- 以太网对应MTU的值一般是1500字节,不同的网络类型有不同的MTU,如果一次要发送的数据超过了MTU,则需要在IP层对数据进行分片(fragmentation)。
- 此外,以太网规定MAC帧中数据的最小长度为46字节,如果发送数据量小于46字节,则需要在数据后面补填充位,比如ARP数据包的长度就是不够46字节的。
MTU对UDP协议的影响
IP报头当中如果不携带选项字段,那么IP报头的长度就是20字节,而UDP采用的是定长的8字节报头,因此如果UDP一次携带的数据超过了 1500 − 20 − 8 = 1472 1500-20-8=14721500−20−8=1472 字节,此时数据就需要在IP层进行分片。分片会增加丢包的概率
对于TCP来说,分片也会增加TCP报文丢包的概率,但与UDP不同的是TCP丢包后还需要进行重传,因此TCP应该尽量减少因为分片导致的数据重传。最理想的情况下,MSS的值正好就是在数据不会在IP层进行分片的最大长度。 最好不分片!!
ARP协议
地址解析协议协议,是根据IP地址获取MAC地址的一个TCP/IP协议。
其中应用层最典型的协议有HTTP、HTTPS和DNS等,传输层最典型的协议有TCP和UDP,网络层最典型的协议就是IP,数据链路层最典型的协议就是MAC帧协议,但实际数据链路层还有两种协议叫做ARP和RARP。
ARP请求的过程
路由器D要将数据转发给同一局域网当中的主机B,前提是路由器D必须知道主机B的MAC地址,而现在路由器D只知道主机B的IP地址,因此路由器D现在需要向主机B发起ARP请求,然后等待主机B发送ARP应答得知主机B的MAC地址。
首先路由器D需要先构建ARP请求。
- 首先,因为路由器D构建的是ARP请求,因此ARP请求当中的op字段设置为1。
- ARP请求当中的硬件类型字段设置为1,因为当前使用的是以太网通信。
- ARP请求当中的协议类型设置为0800,因为路由器是要根据主机B的IP地址来获取主机B的MAC地址。
- RP请求当中的硬件地址长度和协议地址长度分别设置为6和4,因为MAC地址的长度是48位,IP地址的长度是32位。
- ARP请求当中的发送端以太网地址和发送端IP地址,对应就是路由器D的MAC地址和IP地址。
- ARP请求当中的目的以太网地址和目的IP地址,对应就是主机B的MAC地址和IP地址,但由于路由器D不知道主机B的MAC地址,因此将目的以太网地址的二进制序列设置为全1,表示在局域网中进行广播。
响应过程:利用局域网中主机ip是唯一的
局域网中所有主机的ARP层收到这个数据包后,发现ARP数据包当中的op字段为1,于是判定这是一个ARP请求,然后再提取出ARP数据包当中的目的IP地址字段,虽然局域网当中的所有主机都会将该数据包交给自己的ARP层,但最终只有主机B发现ARP数据包当中的目的IP地址与自己相同,因此只有主机B会对该ARP请求进行应答,而局域网当中的其他主机在识别到ARP数据包当中的目的IP地址与自己不匹配后,就会直接将这个ARP请求报文丢弃。
ARP应答当中的目的以太网地址和目的IP地址,对应就是路由器D的MAC地址和IP地址,因为路由器D发来的ARP请求当中告知了主机B它的MAC地址和IP地址,因此主机B是知道的。
ARP缓存表
实际不是每次要获取对方的MAC地址时都需要发起ARP请求,每次发起ARP请求后都会建立对应主机IP地址和MAC地址的映射关系,每台主机都维护了一个ARP缓存表,我们可以用arp -a
命令进行查看。
DNS协议
DNS协议将域名转换成ip地址。在浏览器中输入url
后,如果url
当中包含域名,则需要进行域名解析。
- 首先会在浏览器的DNS缓存中去查询是否有对应的记录,如果查询到记录就可以直接得到对应的IP地址,完成解析。如果在浏览器的DNS缓存中没有找到,就会去查询操作系统中的DNS缓存,之后就会去查找本地的hosts文件,然后去本地DNS服务器中查找,还有根DNS服务器,拿到顶级DNS服务器的IP地址。
- 本地DNS拿到顶级域名服务器的IP地址后,就会拿着域名去找顶级DNS服务器,顶级域名服务器会告诉本地DNS权威域名服务器的IP地址。
- 本地DNS服务器拿着域名去权威域名服务器中,查询域名对应的IP地址,最终将该域名对应的IP地址返回给浏览器,此时整个域名解析过程就完成了。
CMP功能
ICMP的主要功能包括:
- 确认IP包是否成功到达目标地址。
- 通知在发送过程中IP包丢弃的原因。
ping命令是基于ICMP协议实现的,通常用于测试本地主机与另一台主机之间的通信信道是否正常。例如,使用ping www.baidu.com
命令,测试本地主机与百度服务器之间的通信信道是否正常。
traceroute命令也是基于ICMP协议实现的,traceroute命令可以遍历数据包传送到目标主机所经过的所有路由器。
例如,使用traceroute www.baidu.com
命令,遍历数据包传送到百度服务器所经过的所有路由器。
原理简述:
- traceroute命令底层实际是通过增加存活时间(TTL)值来实现的。
- 因为每当数据包经过一个路由器,其TTL值就会减1,当TTL值减为0时对应路由设备就会将该数据包丢弃,并传送一个ICMP TTL数据包给发送主机。
- 因此traceroute命令底层可以发出多个数据包,并给这些数据包设置不同的TTL值,最后该主机就能够得到一连串的数据包路径。
Linux高级IO
- 对文件进行的读写操作本质就是一种IO,文件IO对应的外设就是磁盘。
- 对网络进行的读写操作本质也是一种IO,网络IO对应的外设就是网卡。
输入就是操作系统将数据从外设拷贝到内存的过程,操作系统一定要通过某种方法得知特定外设上是否有数据就绪。
- 操作系统实际采用的是中断的方式来得知外设上是否有数据就绪的,当某个外设上面有数据就绪时,该外设就会向CPU当中的中断控制器发送中断信号,中断控制器再根据产生的中断信号的优先级按顺序发送给CPU。
- 每一个中断信号都有一个对应的中断处理程序,存储中断信号和中断处理程序映射关系的表叫做中断向量表,当CPU收到某个中断信号时就会自动停止正在运行的程序,然后根据该中断向量表执行该中断信号对应的中断处理程序,处理完毕后再返回原被暂停的程序继续运行。
OS如何处理从网卡中读取到的数据包?
操作系统任何时刻都可能会收到大量的数据包,因此操作系统必须将这些数据包管理起来。操作系统从网卡当中读取到一个数据包后,会将该数据依次交给链路层、网络层、传输层、应用层进行解包和分用
但内核中的sk_buff并不像上面那样简单:
- 一方面,为了保证高效的网络报文处理效率,这就要求sk_buff的结构也必须是高效的。
- 另一方面,sk_buff结构需要被内核协议中的各个协议共同使用,因此sk_buff必须能够兼容所有的网络协议。因此sk_buff结构实际是非常复杂的
什么是高效的IO?
IO主要分为两步:
- 第一步是等,即等待IO条件就绪。
- 第二步是拷贝,也就是当IO条件就绪后将数据拷贝到内存或外设。
任何IO的过程,都包含“等”和“拷贝”这两个步骤,但在实际的应用场景中“等”消耗的时间往往比“拷贝”消耗的时间多,因此要让IO变得高效,最核心的办法就是尽量减少“等”的时间。
五种IO模型
实际这五个人的钓鱼方式分别对应的就是五种IO模型。
张三、李四、王五他们三个人的钓鱼的效率是一样的,他们只是等鱼上钩的方式不同而已,张三是死等,李四是定期检测浮漂,而王五是通过铃铛来判断是否有鱼上钩,他们单位时间会钓上来的鱼是一样多的。通过这里的钓鱼例子我们可以看到,阻塞IO、非阻塞IO和信号驱动IO本质上是不能提高IO的效率的,但非阻塞IO和信号驱动IO能提高整体做事的效率。
阻塞IO
阻塞IO就是在内核将数据准备好之前,系统调用会一直等待。
- 张三这种死等的钓鱼方式对应就是阻塞IO。
- 李四这种定时检测是否有鱼上钩的方式就是非阻塞IO。
- 王五这种通过设置铃铛得知事件是否就绪的方式就是信号驱动IO。
- 王五这种一次等待多个鱼竿上有鱼的钓鱼方式就是IO多路转接。
- 田七这种让别人帮自己钓鱼的钓鱼方式就是异步IO。
阻塞IO是最常见的IO模型,所有的套接字,默认都是阻塞方式。
- 比如当调用recvfrom或者函数从某个套接字上读取数据时,可能底层数据还没有准备好,此时就需要等待数据就绪,当数据就绪后再将数据从内核拷贝到用户空间,最后recvfrom函数才会返回。
- 在recvfrom函数等待数据就绪期间,在用户看来该进程或线程就阻塞住了,本质就是操作系统将该进程或线程的状态设置为了某种非R状态,然后将其放入等待队列当中,当数据就绪后操作系统再将其从等待队列当中唤醒,然后该进程或线程再将数据从内核拷贝到用户空间。
非阻塞IO
非阻塞IO就是,如果内核还未将数据准备好,系统调用仍然会直接返回,并且返回EWOULDBLOCK错误码。fcntl函数
void SetNonBlock(int fd)
{int fl = ::fcntl(fd, F_GETFL);if(fl < 0) {return;}fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}int main()
{char buffer[1024];SetNonBlock(0); // 将0设置为非阻塞while (true){// recvfrom也是同样如此ssize_t s = ::read(0, buffer, sizeof(buffer) - 1);if (s > 0){buffer[s] = 0;std::cout << "Echo# " << buffer << std::endl;}else{// 问题:我怎么知道是底层IO条件不就绪,还是读取错误了呢???// 底层IO条件就绪和读取错误采用的是同样返回值操作的if (errno == EWOULDBLOCK || errno == EAGAIN){std::cout << "底层数据没有就绪, 下次在试试吧! 做做其他事情!" << std::endl;sleep(1);continue;}else if (errno == EINTR){continue;}else{std::cout << "读取错误... s : " << s << " errno: " << errno << std::endl;sleep(1);}}}
}
信号驱动IO
信号驱动IO就是当内核将数据准备好的时候,使用SIGIO信号通知应用程序进行IO操作。当底层数据就绪的时候会向当前进程或线程递交SIGIO信号,因此可以通过signal或sigaction函数将SIGIO的信号处理程序自定义为需要进行的IO操作,当底层数据就绪时就会自动执行对应的IO操作。
信号的产生是异步的,但信号驱动IO是同步IO的一种。
- 我们说信号的产生异步的,因为信号在任何时刻都可能产生。
- 但信号驱动IO是同步IO的一种,因为当底层数据就绪时,当前进程或线程需要停下正在做的事情,转而进行数据的拷贝操作,因此当前进程或线程仍然需要参与IO过程。
IO多路转接
IO多路转接也叫做IO多路复用,能够同时等待多个文件描述符的就绪状态。
select实现
阻塞式等待所有设置进fd_set的文件描述符。
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
参数说明:
- nfds:需要监视的文件描述符中,最大的文件描述符值+1。
- readfds:输入输出型参数,调用时用户告知内核需要监视哪些文件描述符的读事件是否就绪,返回时内核告知用户哪些文件描述符的读事件已经就绪。
- writefds:输入输出型参数,调用时用户告知内核需要监视哪些文件描述符的写事件是否就绪,返回时内核告知用户哪些文件描述符的写事件已经就绪。
- exceptfds:输入输出型参数,调用时用户告知内核需要监视哪些文件描述符的异常事件是否就绪,返回时内核告知用户哪些文件描述符的异常事件已经就绪。
- timeout:输入输出型参数,调用时由用户设置select的等待时间,返回时表示timeout的剩余时间。
关键是fd_set结构,它是一个位图,我们可以把对应的文件描述符设置到位图当中,让它关心3号文件描述符上的读(写)事件,当事件就绪时,判断式listenfd就绪还是sockfd就绪。再将其转入(添加新fd)和(读数据)两种处理。
#pragma once
#include <iostream>
#include <string>
#include <memory>
#include "Socket.hpp"using namespace socket_ns;// select服务器要正确的编写,需要借助一个第三方数组来完成,保存合法的,所有的fd到数组中,方便后期批量化统一添加
class SelectServer
{const static int N = sizeof(fd_set) * 8;const static int defaultfd = -1;public:SelectServer(uint16_t port): _port(port),_listensock(std::make_unique<TcpSocket>()){InetAddr addr("0", _port);_listensock->BuildListenSocket(addr);for (int i = 0; i < N; i++){_fd_array[i] = defaultfd;}_fd_array[0] = _listensock->SockFd();}void AcceptClient(){// 我们今天只关心了读,而读有:listensock 和 nornam sockfdInetAddr clientaddr;int sockfd = _listensock->Accepter(&clientaddr); // 这里调用accept会不会阻塞呢??不会。因为事件已经就绪了if (sockfd < 0)return;LOG(DEBUG, "Get new Link, sockfd: %d, client info %s:%d\n", sockfd, clientaddr.Ip().c_str(), clientaddr.Port());// read/recv(sockfd); send(sockfd)?? 不能. 必须将新的fd,托管给select。如何托管呢???// 只要将新的fd添加到辅助数组中即可。int pos = 1;for (; pos < N; pos++){if (_fd_array[pos] == defaultfd)break;}if (pos == N){::close(sockfd); // sockfd->Close();LOG(WARNING, "server is full!\n");return;}else{_fd_array[pos] = sockfd;LOG(DEBUG, "%d add to select array!\n", sockfd);}LOG(DEBUG, "curr fd_array[] fd list : %s\n", RfdsToString().c_str());}void ServiceIO(int pos){char buffer[1024];ssize_t n = ::recv(_fd_array[pos], buffer, sizeof(buffer) - 1, 0); // 这里读取会不会被阻塞?不会if (n > 0){buffer[n] = 0; std::cout << "client say# " << buffer << std::endl;std::string echo_string = "[server echo]# ";echo_string += buffer;::send(_fd_array[pos], echo_string.c_str(), echo_string.size(), 0);}else if (n == 0){LOG(DEBUG, "%d is closed\n", _fd_array[pos]);::close(_fd_array[pos]);_fd_array[pos] = defaultfd;LOG(DEBUG, "curr fd_array[] fd list : %s\n", RfdsToString().c_str());}else{LOG(DEBUG, "%d recv error\n", _fd_array[pos]);::close(_fd_array[pos]);_fd_array[pos] = defaultfd;LOG(DEBUG, "curr fd_array[] fd list : %s\n", RfdsToString().c_str());}}void HandlerEvent(fd_set &rfds){for (int i = 0; i < N; i++){if (_fd_array[i] == defaultfd)continue;if (FD_ISSET(_fd_array[i], &rfds)){if (_fd_array[i] == _listensock->SockFd()){AcceptClient();}else{// 这里不就是普通的sockfd读事件就绪了吗?ServiceIO(i);}}}}void Loop(){while (true){// listensocket 等待新连接到来,等价于对方给我发送数据!我们作为读事件同一处理// 新连接到来 等价于 读事件就绪!// 首先要将listensock添加到select中!fd_set rfds;FD_ZERO(&rfds);int max_fd = defaultfd;for (int i = 0; i < N; i++){if (_fd_array[i] == defaultfd)continue;FD_SET(_fd_array[i], &rfds); // 将所有合法的fd添加到rfds中if (max_fd < _fd_array[i]){max_fd = _fd_array[i]; // 更新出最大的fd的值}}struct timeval timeout = {0, 0};// select 同时等待的fd,是有上限的。因为fd_set是具体的数据类型,有自己的大小!// rfds是一个输入输出型参数,每次调用,都要对rfds进行重新设定!int n = select(max_fd + 1, &rfds, nullptr, nullptr, /*&timeout*/ nullptr);switch (n){case 0:LOG(INFO, "timeout, %d.%d\n", timeout.tv_sec, timeout.tv_usec);break;case -1:LOG(ERROR, "select error...\n");break;default:LOG(DEBUG, "Event Happen. n : %d\n", n); // 底层有一个事件就绪,select为什么会一直通知我?因为:我们没有处理!HandlerEvent(rfds);break;}}}std::string RfdsToString(){std::string fdstr;for (int i = 0; i < N; i++){if (_fd_array[i] == defaultfd)continue;fdstr += std::to_string(_fd_array[i]);fdstr += " ";}return fdstr;}~SelectServer(){}private:uint16_t _port;std::unique_ptr<Socket> _listensock;int _fd_array[N]; // 辅助数组
};
select的优点
- 可以同时等待多个文件描述符,并且只负责等待,实际的IO操作由accept、read、write等接口来完成,这些接口在进行IO操作时不会被阻塞。
- select同时等待多个文件描述符,因此可以将“等”的时间重叠,提高了IO的效率。
select的缺点
- 每次调用select,都需要手动设置fd集合,从接口使用角度来说也非常不便。
- 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大。
- 同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大。
- select可监控的文件描述符数量太少。
Poll:
- poll可监控的文件描述符数量没有限制。
- 和select函数一样,当poll返回后,需要遍历fds数组来获取就绪的文件描述符。
I/O多路转接之epoll
epoll有三个相关的系统调用,分别是epoll_create、epoll_ctl和epoll_wait。
int epoll_create(int size);
参数说明:
- size:自从Linux2.6.8之后,size参数是被忽略的,但size的值必须设置为大于0的值。
返回值说明:
- epoll模型创建成功返回其对应的文件描述符,否则返回-1,同时错误码会被设置。
当不再使用时,必须调用close函数关闭epoll模型对应的文件描述符,当所有引用epoll实例的文件描述符都已关闭时,内核将销毁该实例并释放相关资源。
epoll_ctl函数用于向指定的epoll模型中注册事件,该函数的函数原型如下:
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
参数说明:
- epfd:指定的epoll模型。
- op:表示具体的动作,用三个宏来表示。
- fd:需要监视的文件描述符。
- event:需要监视该文件描述符上的哪些事件。
第二个参数op的取值有以下三种:
EPOLL_CTL_ADD
:注册新的文件描述符到指定的epoll模型中。EPOLL_CTL_MOD
:修改已经注册的文件描述符的监听事件。EPOLL_CTL_DEL
:从epoll模型中删除指定的文件描述符。
返回值说明:
- 函数调用成功返回0,调用失败返回-1,同时错误码会被设置。
第四个参数对应的struct epoll_event结构如下:
struct epoll_event结构中有两个成员,第一个成员events表示的是需要监视的事件,第二个成员data是一个联合体结构,一般选择使用该结构当中的fd,表示需要监听的文件描述符。
events的常用取值如下:
- EPOLLIN:表示对应的文件描述符可以读(包括对端SOCKET正常关闭)。
- EPOLLOUT:表示对应的文件描述符可以写。
- EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来)。
- EPOLLERR:表示对应的文件描述符发送错误。
EPOLLET
:将epoll的工作方式设置为边缘触发(Edge Triggered)模式。EPOLLONESHOT
:只监听一次事件,当监听完这次事件之后,如果还需要继续监听该文件描述符的话,需要重新将该文件描述符添加到epoll模型中。
epoll_wait函数
epoll_ctl函数用于收集监视的事件中已经就绪的事件,该函数的函数原型如下:
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
参数说明:
- epfd:指定的epoll模型。
- events:内核会将已经就绪的事件拷贝到events数组当中(events不能是空指针,内核只负责将就绪事件拷贝到该数组中,不会帮我们在用户态中分配内存)。
- maxevents:events数组中的元素个数,该值不能大于创建epoll模型时传入的size值。
- timeout:表示epoll_wait函数的超时时间,单位是毫秒(ms)。
参数timeout的取值:
- -1:epoll_wait调用后进行阻塞等待,直到被监视的某个文件描述符上的某个事件就绪。
- 0:epoll_wait调用后进行非阻塞等待,无论被监视的文件描述符上的事件是否就绪,epoll_wait检测后都会立即返回。
- 特定的时间值:epoll_wait调用后在直到的时间内进行阻塞等待,如果被监视的文件描述符上一直没有事件就绪,则在该时间后epoll_wait进行超时返回。
返回值说明:
- 如果函数调用成功,则返回有事件就绪的文件描述符个数。
- 如果timeout时间耗尽,则返回0。
- 如果函数调用失败,则返回-1,同时错误码会被设置。
epoll工作原理
红黑树和就绪队列
当某一进程调用epoll_create函数时,Linux内核会创建一个eventpoll结构体,也就是我们所说的epoll模型,eventpoll结构体当中包含 成员rbr和rdlist,一颗红黑树和一个就绪队列。
- epoll模型当中的红黑树本质就是告诉内核,需要监视哪些文件描述符上的哪些事件,调用epll_ctl函数实际就是在对这颗红黑树进行对应的增删改操作。
- epoll模型当中的就绪队列本质就是告诉内核,哪些文件描述符上的哪些事件已经就绪了,调用epoll_wait函数实际就是在从就绪队列当中获取已经就绪的事件。
红黑树和队列中的节点都是基于epitem结构,epitem结构当中的成员ffd记录的是指定的文件描述符值,event成员记录的就是该文件描述符对应的事件。红黑树是一种二叉搜索树,因此必须有键值key,而这里的文件描述符就天然的可以作为红黑树的key值。
回调机制
所有添加到红黑树当中的事件,都会与设备(网卡)驱动程序建立回调方法,这个回调方法在内核中叫ep_poll_callback。
- 对于select和poll来说,操作系统在监视多个文件描述符上的事件是否就绪时,需要让操作系统主动对这多个文件描述符进行轮询检测,这一定会增加操作系统的负担。
- 而对于epoll来说,操作系统不需要主动进行事件的检测,当红黑树中监视的事件就绪时,会自动调用对应的回调方法,将就绪的事件添加到就绪队列当中。
- 当用户调用epoll_wait函数获取就绪事件时,只需要关注底层就绪队列是否为空,如果不为空则将就绪队列当中的就绪事件拷贝给用户即可。
- 当不断有监视的事件就绪时,会不断调用回调方法向就绪队列当中插入节点,而上层也会不断调用epoll_wait函数从就绪队列当中获取节点,这是典型的生产者消费者模型。
- 由于就绪队列可能会被多个执行流同时访问,因此必须要使用互斥锁对其进行保护,eventpoll结构当中的lock和mtx就是用于保护临界资源的,因此epoll本身是线程安全的。
epoll服务器的代码:
- 根据调用epoll_wait时得到的返回值,来判断操作系统向revs数组中拷贝了多少个struct epoll_event结构,进而对这些文件描述符上的事件进行处理。
- 对于每一个拷贝上来的struct epoll_event结构,如果该结构当中的events当中包含读事件,则说明该文件描述符对应的读事件就绪,但接下来还需要进一步判断该文件描述符是监听套接字还是与客户端建立的套接字。
- 如果是监听套接字的读事件就绪,则调用accept函数将底层建立好的连接获取上来,并调用epoll_ctl函数将获取到的套接字添加到epoll模型当中,表示下一次调用epoll_wait函数时需要监视该套接字的读事件。
#include "socket.hpp"
#include <sys/epoll.h>#define BACK_LOG 5
#define SIZE 256
#define MAX_NUM 64class EpollServer{
private:int _listen_sock; //监听套接字int _port; //端口号int _epfd; //epoll模型
public:void HandlerEvent(struct epoll_event revs[], int num){for (int i = 0; i < num; i++){int fd = revs[i].data.fd; //就绪的文件描述符if (fd == _listen_sock&&revs[i].events&EPOLLIN){ //连接事件就绪struct sockaddr_in peer;memset(&peer, 0, sizeof(peer));socklen_t len = sizeof(peer);int sock = accept(_listen_sock, (struct sockaddr*)&peer, &len);if (sock < 0){ //获取连接失败std::cerr << "accept error" << std::endl;continue;}std::string peer_ip = inet_ntoa(peer.sin_addr);int peer_port = ntohs(peer.sin_port);std::cout << "get a new link[" << peer_ip << ":" << peer_port << "]" << std::endl;AddEvent(sock, EPOLLIN); //将获取到的套接字添加到epoll模型中,并关心其读事件}else if (revs[i].events&EPOLLIN){ //读事件就绪char buffer[64];ssize_t size = recv(fd, buffer, sizeof(buffer)-1, 0);if (size > 0){ //读取成功buffer[size] = '\0';std::cout << "echo# " << buffer << std::endl;}else if (size == 0){ //对端连接关闭std::cout << "client quit" << std::endl;close(fd);DelEvent(fd); //将文件描述符从epoll模型中删除}else{std::cerr << "recv error" << std::endl;close(fd);DelEvent(fd); //将文件描述符从epoll模型中删除}}}}
private:void AddEvent(int sock, uint32_t event){struct epoll_event ev;ev.events = event;ev.data.fd = sock;epoll_ctl(_epfd, EPOLL_CTL_ADD, sock, &ev);}void DelEvent(int sock){epoll_ctl(_epfd, EPOLL_CTL_DEL, sock, nullptr);}
};
epoll工作方式LT和ET
epoll有两种工作方式,分别是水平触发工作模式和边缘触发工作模式。
水平触发(LT,Level Triggered)
- 只要底层有事件就绪,epoll就会一直通知用户。
- 就像数字电路当中的高电平触发一样,只要一直处于高电平,则会一直触发。
epoll默认状态下就是LT工作模式。
- 由于在LT工作模式下,只要底层有事件就绪就会一直通知用户,因此当epoll检测到底层读事件就绪时,可以不立即进行处理,或者只处理一部分,因为只要底层数据没有处理完,下一次epoll还会通知用户事件就绪。
- select和poll其实就是工作是LT模式下的。
- 支持阻塞读写和非阻塞读写。
边缘触发(ET,Edge Triggered)
- 只有底层就绪事件数量由无到有或由有到多发生变化的时候,epoll才会通知用户。
- 就像数字电路当中的上升沿触发一样,只有当电平由低变高的那一瞬间才会触发。
如果要将epoll改为ET工作模式,则需要在添加事件时设置EPOLLET选项。
- 由于在ET工作模式下,只有底层就绪事件无到有或由有到多发生变化的时候才会通知用户,因此当epoll检测到底层读事件就绪时,必须立即进行处理,而且必须全部处理完毕,因为有可能此后底层再也没有事件就绪,那么epoll就再也不会通知用户进行事件处理,此时没有处理完的数据就相当于丢失了。
- ET工作模式下epoll通知用户的次数一般比LT少,因此ET的性能一般比LT性能更高,Nginx就是默认采用ET模式使用epoll的。
- 只支持非阻塞的读写。
ET工作模式下应该如何进行读写
因为在ET工作模式下,只有底层就绪事件无到有或由有到多发生变化的时候才会通知用户,这就倒逼用户当读事件就绪时必须一次性将数据全部读取完毕,当写事件就绪时必须一次性将发送缓冲区写满,否则可能再也没有机会进行读写了。
因此读数据时必须循环调用recv函数进行读取,写数据时必须循环调用send函数进行写入。
- 当底层读事件就绪时,循环调用recv函数进行读取,直到某次调用recv读取时,实际读取到的字节数小于期望读取的字节数,则说明本次底层数据已经读取完毕了。
- 但有可能最后一次调用recv读取时,刚好实际读取的字节数和期望读取的字节数相等,但此时底层数据也恰好读取完毕了,如果我们再调用recv函数进行读取,那么recv就会因为底层没有数据而被阻塞住。
- 而这里的阻塞是非常严重的,就比如我们这里写的服务器都是单进程的服务器,如果recv被阻塞住,并且此后该数据再也不就绪,那么就相当于我们的服务器挂掉了,因此在ET工作模式下循环调用recv函数进行读取时,必须将对应的文件描述符设置为非阻塞状态。
- 调用send函数写数据时也是同样的道理,需要循环调用send函数进行数据的写入,并且必须将对应的文件描述符设置为非阻塞状态。
强调: ET工作模式下,recv和send操作的文件描述符必须设置为非阻塞状态,这是必须的,不是可选的。
对比LT和ET
- 在ET模式下,一个文件描述符就绪之后,用户不会反复收到通知,看起来比LT更高效,但如果在LT模式下能够做到每次都将就绪的文件描述符立即全部处理,不让操作系统反复通知用户的话,其实LT和ET的性能也是一样的。
- 此外,ET的编程难度比LT更高。ET模式一定会以最快的速度把TCP缓冲区的数据读走。这样会给对方一个更大的ACK窗口,更高效。LT也可以实现ET的效果。
epoll ET服务器(Reactor模式)
Reactor反应器模式,也叫做分发者模式或通知者模式,是一种将就绪事件派发给对应服务处理程序的事件设计模式。
在epoll ET服务器中,我们需要处理如下几种事件:
- 读事件:如果是监听套接字的读事件就绪则调用accept函数获取底层的连接,如果是其他套接字的读事件就绪则调用recv函数读取客户端发来的数据。
- 写事件:写事件就绪则将待发送的数据写入到发送缓冲区当中。
- 异常事件:当某个套接字的异常事件就绪时我们不做过多处理,直接关闭该套接字。
当epoll ET服务器监测到某一事件就绪后,就会将该事件交给对应的服务处理程序进行处理。
Reactor模式的五个角色
在这个epoll ET服务器中,Reactor模式中的五个角色对应如下:
- 句柄:文件描述符。
- 同步事件分离器:I/O多路复用epoll。
- 事件处理器:包括读回调、写回调和异常回调。
- 具体事件处理器:读回调、写回调和异常回调的具体实现。
- 初始分发器:Reactor类当中的Dispatcher函数。
具体到代码中:我们先将监听 fd 构建一个connnection,每个connnection都有自己fd和读写异常方法以及其读写缓冲区,还需要有一个Reactor指针,我们将监听的getAccept函数作为读事件注册到reactor中的epoll中,当获取到连接时,我们通过连接找到Reactor并向它添加新的connection。reactor维护了fd到connection的map以及epoll,调用reactor的dispute方法,它会进行wait,依次判断得到的 _revs[i].events,根据其注册的 revents & EPOLLIN或者revents & EPOLLOUT,回调执行对应的注册方法。
Reactor核心工作是进行事件派发,内部对connection进行连接管理,还有一个epoll,根据wait底层接收到的事件进行事件的派发,派发给不同的上层connection对象,让他们做事件的处理(还可以继续传给上层),相当于一个容器,管理一个connection,每个connection有一个回指指针指回readtor. reator--connection(注册方法)---序列反序列化---业务处理层
#pragma once
#include<unordered_map>#include"Socket.hpp"
#include"Connection.hpp"
#include"Log.hpp"
#include"Epoller.hpp"using namespace socket_ns;
class Reactor
{const static int gnum=64;
public:Reactor():_isrunning(false){}void AddConnection(int fd, uint32_t events, func_t recver, func_t sender, func_t excepter){// 构建connection对象Connection *con =new Connection(fd);con->SetEvents(events);con->Register(recver,sender,excepter);// 底层注册对事件的关心_epoller.AddEvent(fd,con->Events());//connection中添加这个连接_connections.insert(std::make_pair(fd,con));}//改变读写事件void EnableReadWrite(int sockfd, bool readable, bool writeable){uint32_t events = (readable?EPOLLIN:0) | (writeable ? EPOLLOUT : 0) | EPOLLET;if(ConnectionIsExists(sockfd)){// 1. 修改我们写的connection关心的事件_connections[sockfd]->SetEvents(events);// 2. 写透到内核中_epoller.ModEvent(sockfd, events);}}void RemoveConnection(int sockfd){if(!ConnectionIsExists(sockfd)) return;_epoller.DelEvent(sockfd);delete _connections[sockfd];_connections.erase(sockfd);}bool ConnectionIsExists(int sockfd){auto it=_connections.find(sockfd);return it!=_connections.end();}void LoopOnce(int timeout){int n=_epoller.Wait(_revs,gnum,timeout);for(int i=0;i<n;i++){int sockfd=_revs[i].data.fd;uint32_t revents=_revs[i].events;if(revents&EPOLLHUP){revents|=(EPOLLIN|EPOLLOUT);}if (revents & EPOLLERR)revents |= (EPOLLIN | EPOLLOUT);if (revents & EPOLLIN){if(ConnectionIsExists(sockfd)&&_connections[sockfd]->_recver!=nullptr){_connections[sockfd]->_recver;}}if (revents & EPOLLOUT){// 如果对写事件进行关心,即发送缓冲区有空闲,则派发事件给send函数if (ConnectionIsExists(sockfd) && (_connections[sockfd]->_sender != nullptr)){_connections[sockfd]->_sender(_connections[sockfd]);}}}}void Dispatcher(){_isrunning=true;int timeout=3000;while(_isrunning){LoopOnce(timeout);// 处理其他事情Debug();}_isrunning=false;}void Debug(){std::cout << "------------------------------------" << std::endl;for(auto &connection : _connections){std::cout << "fd : " << connection.second->Sockfd() << ", ";uint32_t events = connection.second->Events();if((events & EPOLLIN) && (events & EPOLLET))std::cout << "EPOLLIN | EPOLLET, ";if((events & EPOLLOUT) && (events & EPOLLET))std::cout << "EPOLLOUT | EPOLLET";std::cout << std::endl;}std::cout << "------------------------------------" << std::endl;}~Reactor(){}private:std::unordered_map<int,Connection*> _connections;//不关心单个fd,而是connectionstruct epoll_event _revs[gnum];Epoller _epoller;bool _isrunning;
};