1.网络协议栈
一般一个主机内的应用(进程)进行通信,直接在操作系统层面进行 进程交互即可。而不同位置两台主机进行通信需要通过网线传输信号,因此 这些通信的数据为网络数据,而网络数据进程传输必须从应用层依次向下包装一直到物理层。
操作系统中的进程管理、文件管理、内存管理、驱动管理是隶属于系统部分的,系统部分的核心工作就是管理好各种软硬件资源,对上提供一个良好稳定的运行环境。现代操作系统由系统和网络两部分组成。
操作系统中除了有这四大管理模块,还有与网络协议栈有着密切的关系。网络协议栈主要负责数据的通信,其自顶向下可分为四层,分别是应用层、传输层、网络层、数据链路层。
网络协议栈各部分所处位置:
- 应用层是位于用户层的。 这部分代码是由网络协议的开发人员来编写的,比如HTTP协议、HTTPS协议以及SSH协议等。他通过各种系统调用 处理tcp层接收的数据
- 传输层和网络层是位于操作系统层的。 其中传输层最经典的协议叫做TCP协议,网络层最经典的协议叫做IP协议,这就是我们平常所说的TCP/IP协议。
- 数据链路层是位于驱动层的。 其负责真正的数据传输。
Linux内核包含的代码:TCP处理 → IP处理 → 网卡驱动
协议分层
协议分层的好处
网络协议栈设计成层状结构,其目的就是为了将层与层之间进行解耦,保证代码的可维护性和可扩展性。每一层都有自己的功能。看上去就像是层与层通信。
OSI七层模型
上面我们说的是TCP/IP四层协议,而实际当初那个站出来的人定的协议叫做OSI七层协议:
- OSI(Open System Interconnection,开放系统互联)七层网络模型称为开方式系统互联参考模型,是一个逻辑上的定义和规范。
- SI把网络从逻辑上分为了七层,每一层都有相关的、相对应的物理设备,比如路由器,交换机。
- OSI七层模型是一种框架性的设计方法,其最主要的功能就是帮助不同类型的主机实现数据传输,比如手机和电视之间数据的传输。
- OSI七层模型最大的优点是将服务、接口和协议这三个概念明确地区分开来,概念清楚,理论也比较完整,通过七个层次化的结构模型使不同的系统不同的网络之间实现可靠的通讯。
但是,OSI七层模型既复杂又不实用,所以后来在具体实现的时候就对其进行了调整,于是就有了我们现在看到的TCP/IP四层协议。
TCP/IP五层(或四层)模型
TCP/IP是一组协议的代名词,它还包括许多协议,共同组成了TCP/IP协议簇。TCP/IP通讯协议采用了五层的层级结构,每一层都呼叫它的下一层所提供的网络来完成自己的需求。
- 物理层: 负责光/电信号的传递方式。比如现在以太网通用的网线(双绞线)、早期以太网采用的同轴电缆(现在主要用于有线电视)、光纤,现在的WiFi无线网使用的电磁波等都属于物理层的概念。物理层的能力决定了最大传输速率、传输距离、抗干扰性等。集线器(Hub)就是工作在物理层的。
- 数据链路层: 负责设备之间的数据帧的传送和识别。例如网卡设备的驱动、帧同步、冲突检测(如果检测到冲突就自动重发)、数据差错校验等工作。数据链路层底层的网络通信标准有很多,如以太网、令牌环网、无线LAN等。交换机(Switch)就是工作在数据链路层的。
- 网络层: 负责地址管理和路由选择。例如在IP协议中,通过IP地址来标识一台主机,并通过路由表的方式规划出两台主机之间数据传输的线路(路由)。路由器(Router)就是工作在网络层的。
- 传输层: 负责两台主机之间的数据传输。例如传输控制协议(TCP),能够确保数据可靠的从源主机发送到目标主机。
- 应用层: 负责应用程序间沟通。比如简单电子邮件传输(SMTP)、文件传输协议(FTP)、网络远程访问协议(Telnet)等。我们的网络编程主要就是针对应用层的。
数据包封装和分用
- 不同协议层对数据包有不同的称谓,在传输层叫做段(segment),在网络层叫做数据报(datagram),在链路层叫做帧(frame)。
- 应用层数据通过协议栈发到网络上,每层协议都要加上一个数据首部(header),称为封装(Encapsulation)。
下图为数据封装的过程:
如何将报头与有效载荷进行分离?
协议栈的每一层都要从数据中提取对应的报头信息,而要将数据中的报头提取出来,首先就需要明确报头与有效载荷之间的界限,这样才能将它们进行分离。而每一层添加报头时都是将报头添加到数据的首部的,因此我们只要知道了报头的大小,就能够讲报头和有效载荷进行分离。
获取报头大小的方法通常有两种:
- 定长报头。顾名思义就是报头的大小是固定的。
- 自描述字段。报头当中提供了一个字段,用来表示报头的长度。
实际上每个协议都要提供一种方法,让我们获取到报头的大小,这样我们才能在解包时将报头与有效载荷进行分离。
如何判断发送出去的数据是否发生了碰撞?
因为发送到局域网当中的数据是所有主机都能够收到的,因此当一个主机将数据发送出去后,该主机本身也是能够收到这个数据的。当该主机收到该数据后就可以将其与之前发送出去的数据进行对比,如果发现收到的数据与之前发送出去的数据不相同,则说明在发送过程中发生了碰撞。
发生碰撞后是如何处理的?
当一个主机发现自己发送出去的数据产生了碰撞,此时该主机就要执行“碰撞避免”算法。“碰撞避免”算法实际很简单:当一个主机发送出去的数据产生了碰撞,那么该主机可以选择等一段时间后,再重新发送该数据。
每个主机如何判断该数据是否是发送给自己的?
在局域网中发送的数据实际叫做MAC数据帧,在这个MAC数据帧的报头当中会包含两个字段,分别叫做源MAC地址和目的MAC地址。
每一台计算机都至少配有一张网卡,而每一张网卡在出厂时就已经内置了一个48位的序列号,我们将这个序列号称之为“MAC地址”,这个MAC地址是全球唯一的。
跨网络的两台主机通信
局域网之间都是通过路由器连接起来的,因此一个路由器至少能够横跨两个局域网。而这些被路由器级联局域网都认为,该路由器就是本局域网内的一台主机,因此路由器可以和这些局域网内的任意一台主机进行直接通信。
比如局域网1当中的主机A想要和局域网2当中的主机H进行通信,那么主机A可以先将数据发送给路由器,然后路由器再将数据转发给局域网2当中的主机H。
路由器为什么能够“认路”?
根据路由表和 目的ip字段
以太网
局域网技术
不同局域网所采用的通信技术可能是不同的,常见的局域网技术有以下三种:
- 以太网:以太网是一种计算机局域网技术,一种应用最普遍的局域网技术。
- 令牌环网:令牌环网常用于IBM系统中,在这种网络中有一种专门的帧称为“令牌”,在环路上持续地传输来确定一个节点何时可以发送包。
- 无线LAN/WAN:无线局域网是有线网络的补充和扩展,现在已经是计算机网络的一个重要组织部分。
以太网帧格式如下:
- 源地址和目的地址是指网卡的硬件地址(也叫MAC地址),长度是48位,是在网卡出厂时固化的。
- 帧协议类型字段有三种值,分别对应IP协议、ARP协议和RARP协议。
- 帧末尾是CRC校验码。
举个例子
假设局域网当中的主机A想要将IP数据报发送给同一局域网当中的主机B,那么主机A封装MAC帧当中的目的地址就是主机B的MAC地址,源地址就是主机A的MAC地址,而帧协议的类型对应就是0800,紧接着就是要发送的IP数据报,帧尾部分对应就是CRC校验。
当主机A将该MAC帧发送到局域网当中后,局域网当中的所有主机都可以收到这个MAC帧,包括主机A自己。
- 主机A收到该MAC帧后,可以对收到的MAC帧进行CRC校验,如果校验失败则说明数据发送过程中产生了碰撞,此时主机A就会执行碰撞避免算法,后续进行MAC帧重发。
- 主机B收到该MAC帧后,提取出MAC帧当中的目的地址,发现该目的地址与自己的MAC地址相同,于是在CRC校验成功后就会将有效载荷交付给上层IP层进行进一步处理。
认识MTU
MTU(Maximum Transmission Unit,最大传输单元)描述的是底层数据帧一次最多可以发送的数据量,这个限制是不同的数据链路层对应的物理层产生的。
- 以太网对应MTU的值一般是1500字节,不同的网络类型有不同的MTU,如果一次要发送的数据超过了MTU,则需要在IP层对数据进行分片(fragmentation)。
- 此外,以太网规定MAC帧中数据的最小长度为46字节,如果发送数据量小于46字节,则需要在数据后面补填充位,比如ARP数据包的长度就是不够46字节的。
MUT对IP协议的影响
因为数据链路层规定了最大传输单元MTU,所以如果IP层一次要发送的数据量超过了MTU,此时IP层就需要先对该数据进行分片,然后才能将分片后的数据向下交付。
- IP层会将较大的数据进行分片,并给每个分片数据包进行标记,具体就是通过设置IP报头当中的16位标识、3位标志和13位片偏移来完成的。
- 由同一个数据分片得到的各个分片报文,所对应的IP报头当中的16位标识(id)都是相同的。
- 每一个分片报文的IP报头当中的3位标志字段中,第2位设置为0,表示允许分片,第3位用作结束标记(最后一个分片报文设置为0,其余分片报文设置为1)。
MTU对TCP协议的影响
对于TCP来说,分片也会增加TCP报文丢包的概率,但与UDP不同的是TCP丢包后还需要进行重传,因此TCP应该尽量减少因为分片导致的数据重传。
- TCP发送的数据报不能无限大,还是应该受制于MTU,我们将TCP的单个数据报的最大报文长度,称为MSS(Max Segment Size)。
- TCP通信双方在建立连接的过程中,就会进行MSS协商,最终选取双方支持的MSS值当中的较小值作为最终MSS。
ARP协议
地址解析协议(Address Resolution Protocol,ARP)协议,是根据IP地址获取MAC地址的一个TCP/IP协议。
ARP数据的格式
ARP请求的过程
首先路由器D需要先构建ARP请求。
- 首先,因为路由器D构建的是ARP请求,因此ARP请求当中的op字段设置为1。
- ARP请求当中的硬件类型字段设置为1,因为当前使用的是以太网通信。
- ARP请求当中的协议类型设置为0800,因为路由器是要根据主机B的IP地址来获取主机B的MAC地址。
- ARP请求当中的硬件地址长度和协议地址长度分别设置为6和4,因为MAC地址的长度是48位,IP地址的长度是32位。
- ARP请求当中的发送端以太网地址和发送端IP地址,对应就是路由器D的MAC地址和IP地址。
- ARP请求当中的目的以太网地址和目的IP地址,对应就是主机B的MAC地址和IP地址,但由于路由器D不知道主机B的MAC地址,因此将目的以太网地址的二进制序列设置为全1,表示在局域网中进行广播。
总结:
发起方构建ARP请求,以广播的方式发送给每一个主机。
每台主机都能识别接收,然后根据MAC帧的帧类型字段将有效载荷交付给每个主机的ARP层。
其他不相关主机立马根据目的IP,在自己的ARP协议内部丢弃ARP请求,只有目标主机会处理请求。
ARP缓存表
实际不是每次要获取对方的MAC地址时都需要发起ARP请求,每次发起ARP请求后都会建立对应主机IP地址和MAC地址的映射关系,每台主机都维护了一个ARP缓存表,我们可以用arp -a
命令进行查看。
网络编程套接字
理解源端口号和目的端口号
首先我们需要明确的是,两台主机之间通信的目的不仅仅是为了将数据发送给对端主机,而是为了访问对端主机上的某个服务。比如我们在用百度搜索引擎进行搜索时,不仅仅是想将我们的请求发送给对端服务器,而是想访问对端服务器上部署的百度相关的搜索服务。
socket通信的本质
现在通过IP地址和MAC地址已经能够将数据发送到对端主机了,但实际我们是想将数据发送给对端主机上的某个服务进程,此外,数据的发送者也不是主机,而是主机上的某个进程,比如当我们用浏览器访问数据时,实际就是浏览器进程向对端服务进程发起的请求。
socket通信本质上就是两个进程之间在进行通信,只不过这里是跨网络的进程间通信。比如逛淘宝和刷抖音的动作,实际就是手机上的淘宝进程和抖音进程在和对端服务器主机上的淘宝服务进程和抖音服务进程之间在进行通信。
因此进程间通信的方式除了管道、消息队列、信号量、共享内存等方式外,还有套接字,只不过前者是不跨网络的,而后者是跨网络的。
端口号
实际在两台主机上,可能会同时存在多个正在进行跨网络通信的进程,因此当数据到达对端主机后,必须要通过某种方法找到该主机上对应的服务进程,然后将数据交给该进程处理。
端口号(port)的作用实际就是标识一台主机上的一个进程。
- 端口号是传输层协议的内容。
- 端口号是一个2字节16位的整数。
- 端口号用来标识一个进程,告诉操作系统,当前的这个数据要交给哪一个进程来处理。
- 一个端口号只能被一个进程占用。
由于IP地址能够唯一标识公网内的一台主机,而端口号能够唯一标识一台主机上的一个进程,因此用IP地址+端口号就能够唯一标识网络上的某一台主机的某一个进程。
底层如何通过port找到对应进程的?
实际底层采用哈希的方式建立了端口号和进程PID或PCB之间的映射关系,当底层拿到端口号时就可以直接执行对应的哈希算法,然后就能够找到该端口号对应的进程。
网络字节序
网络中的大小端问题
由于发送端和接收端采用的分别是小端存储和大端存储,此时对于内存地址从低到高为44332211的序列,发送端按小端的方式识别出来是0x11223344,而接收端按大端的方式识别出来是0x44332211,此时接收端识别到的数据与发送端原本想要发送的数据就不一样了,这就是由于大小端的偏差导致数据识别出现了错误。
socket编程接口
socket常见API
创建套接字:(TCP/UDP,客户端+服务器)
int socket(int domain, int type, int protocol);
绑定端口号:(TCP/UDP,服务器)
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
sockaddr结构
sockaddr结构的出现
套接字不仅支持跨网络的进程间通信,还支持本地的进程间通信(域间套接字)。
在进行跨网络通信时我们需要传递的端口号和IP地址,而本地通信则不需要,因此套接字提供了sockaddr_in
结构体和sockaddr_un
结构体,其中sockaddr_in
结构体是用于跨网络通信的,而sockaddr_un
结构体是用于本地通信的。
简单的UDP网络程序
服务端创建套接字
我们把服务器封装成一个类,当我们定义出一个服务器对象后需要马上初始化服务器,而初始化服务器需要做的第一件事就是创建套接字。
socket函数
创建套接字的函数叫做socket,该函数的函数原型如下:
int socket(int domain, int type, int protocol);
参数说明:
- 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函数是被进程所调用的,而每一个进程在系统层面上都有一个进程地址空间PCB(task_struct
)、文件描述符表(files_struct
)以及对应打开的各种文件。而文件描述符表里面包含了一个数组fd_array
,其中数组中的0、1、2下标依次对应的就是标准输入、标准输出以及标准错误。
当我们调用socket函数创建套接字时,实际相当于我们打开了一个“网络文件”,打开后在内核层面上就形成了一个对应的struct file结构体,同时该结构体被连入到了该进程对应的文件双链表,并将该结构体的首地址填入到了fd_array数组当中下标为3的位置,此时fd_array数组中下标为3的指针就指向了这个打开的“网络文件”,最后3号文件描述符作为socket函数的返回值返回给了用户。
对于一般的普通文件来说,当用户通过文件描述符将数据写到文件缓冲区,然后再把数据刷到磁盘上就完成了数据的写入操作。而对于现在socket函数打开的“网络文件”来说,当用户将数据写到文件缓冲区后,操作系统会定期将数据刷到网卡里面,而网卡则是负责数据发送的,因此数据最终就发送到了网络当中。
服务端绑定
现在套接字已经创建成功了,但作为一款服务器来讲,如果只是把套接字创建好了,那我们也只是在系统层面上打开了一个文件,操作系统将来并不知道是要将数据写入到磁盘还是刷到网卡,此时该文件还没有与网络关联起来。
bind函数
绑定的函数叫做bind,该函数的函数原型如下:
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
struct sockaddr_in结构体
在绑定时需要将网络相关的属性信息填充到一个结构体当中,然后将该结构体作为bind函数的第二个参数进行传入,这实际就是struct sockaddr_in
结构体。
可以看到,struct sockaddr_in
当中的成员如下:
- sin_family:表示协议家族。
- sin_port:表示端口号,是一个16位的整数。
- sin_addr:表示IP地址,是一个32位的整数。
class UdpServer
{
public:UdpServer(std::string ip, int port):_sockfd(-1),_port(port),_ip(ip){};bool InitServer(){//创建套接字_sockfd = socket(AF_INET, SOCK_DGRAM, 0);if (_sockfd < 0){ //创建套接字失败std::cerr << "socket error" << std::endl;return false;}std::cout << "socket create success, sockfd: " << _sockfd << std::endl;//填充网络通信相关信息struct sockaddr_in local;memset(&local, '\0', sizeof(local));local.sin_family = AF_INET;local.sin_port = htons(_port);local.sin_addr.s_addr = inet_addr(_ip.c_str()); //绑定if (bind(_sockfd, (struct sockaddr*)&local, sizeof(sockaddr)) < 0){ //绑定失败std::cerr << "bind error" << std::endl;return false;}std::cout << "bind success" << std::endl;return true;}~UdpServer(){if (_sockfd >= 0){close(_sockfd);}};
private:int _sockfd; //文件描述符int _port; //端口号std::string _ip; //IP地址
};
整数IP存在的意义
网络传输数据时是寸土寸金的,如果我们在网络传输时直接以基于字符串的点分十进制IP的形式进行IP地址的传送,那么此时一个IP地址至少就需要15个字节,但实际并不需要耗费这么多字节。
IP地址实际可以划分为四个区域,其中每一个区域的取值都是0~255,而这个范围的数字只需要用8个比特位就能表示,因此我们实际只需要32个比特位就能够表示一个IP地址。
字符串IP和整数IP相互转换的方式
inet_addr函数
实际在进行字符串IP和整数IP的转换时,我们不需要自己编写转换逻辑,系统已经为我们提供了相应的转换函数,我们直接调用即可。
将字符串IP转换成整数IP的函数叫做inet_addr,该函数的函数原型如下:
in_addr_t inet_addr(const char *cp);
inet_ntoa函数
将整数IP转换成字符串IP的函数叫做inet_ntoa,该函数的函数原型如下:
char *inet_ntoa(struct in_addr in);
运行服务器
UDP服务器的初始化就只需要创建套接字和绑定就行了,当服务器初始化完毕后我们就可以启动服务器了。
服务器实际上就是在周而复始的为我们提供某种服务,服务器之所以称为服务器,是因为服务器运行起来后就永远不会退出,因此服务器实际执行的是一个死循环代码。由于UDP服务器是不面向连接的,因此只要UDP服务器启动后,就可以直接读取客户端发来的数据。
recvfrom函数
UDP服务器读取数据的函数叫做recvfrom,该函数的函数原型如下:
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen)
启动服务器函数
现在服务端通过recvfrom函数读取客户端数据,我们可以先将读取到的数据当作字符串看待,将读取到的数据的最后一个位置设置为'\0'
,此时我们就可以将读取到的数据进行输出,同时我们也可以将获取到的客户端的IP地址和端口号也一并进行输出。
void Start(){
#define SIZE 128char buffer[SIZE];for (;;){struct sockaddr_in peer;socklen_t len = sizeof(peer);ssize_t size = recvfrom(_sockfd, buffer, sizeof(buffer)-1, 0, (struct sockaddr*)&peer, &len);if (size > 0){buffer[size] = '\0';int port = ntohs(peer.sin_port);std::string ip = inet_ntoa(peer.sin_addr);std::cout << ip << ":" << port << "# " << buffer << std::endl;}else{std::cerr << "recvfrom error" << std::endl;}}}
sendto函数
UDP客户端发送数据的函数叫做sendto,该函数的函数原型如下:
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen)
简单的TCP网络程序
服务端监听
UDP服务器的初始化操作只有两步,第一步就是创建套接字,第二步就是绑定。而TCP服务器是面向连接的,客户端在正式向TCP服务器发送数据之前,需要先与TCP服务器建立连接,然后才能与服务器进行通信。
因此TCP服务器需要时刻注意是否有客户端发来连接请求,此时就需要将TCP服务器创建的套接字设置为监听状态。
listen函数
设置套接字为监听状态的函数叫做listen,该函数的函数原型如下:
int listen(int sockfd, int backlog);
参数说明:
- sockfd:需要设置为监听状态的套接字对应的文件描述符。
- backlog:全连接队列的最大长度。如果有多个客户端同时发来连接请求,此时未被服务器处理的连接就会放入连接队列,该参数代表的就是这个全连接队列的最大长度,一般不要设置太大,设置为5或10即可。
服务端获取连接
TCP服务器初始化后就可以开始运行了,但TCP服务器在与客户端进行网络通信之前,服务器需要先获取到客户端的连接请求。
accept函数
获取连接的函数叫做accept,该函数的函数原型如下:
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
accept函数返回的套接字是什么?
调用accept函数获取连接时,是从监听套接字当中获取的。如果accept函数获取连接成功,此时会返回接收到的套接字对应的文件描述符。
监听套接字与accept函数返回的套接字的作用:
- 监听套接字:用于获取客户端发来的连接请求。accept函数会不断从监听套接字当中获取新连接。
- accept函数返回的套接字:用于为本次accept获取到的连接提供服务。监听套接字的任务只是不断获取新连接,而真正为这些连接提供服务的套接字是accept函数返回的套接字,而不是监听套接字。
class TcpServer
{
public:void Start(){for (;;){//获取连接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, continue next" << std::endl;continue;}std::string client_ip = inet_ntoa(peer.sin_addr);int client_port = ntohs(peer.sin_port);std::cout<<"get a new link->"<<sock<<" ["<<client_ip<<"]:"<<client_port<<std::endl;}}
private:int _listen_sock; //监听套接字int _port; //端口号
};
客户端连接服务器
由于客户端不需要绑定,也不需要监听,因此当客户端创建完套接字后就可以向服务端发起连接请求。
connect函数
发起连接请求的函数叫做connect,该函数的函数原型如下:
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
单执行流服务器的弊端
单执行流的服务器
通过实验现象可以看到,这服务端只有服务完一个客户端后才会服务另一个客户端。因为我们目前所写的是一个单执行流版的服务器,这个服务器一次只能为一个客户端提供服务。
当服务端调用accept函数获取到连接后就给该客户端提供服务,但在服务端提供服务期间可能会有其他客户端发起连接请求,但由于当前服务器是单执行流的,只能服务完当前客户端后才能继续服务下一个客户端。
客户端为什么会显示连接成功?
当服务端在给第一个客户端提供服务期间,第二个客户端向服务端发起的连接请求时是成功的,只不过服务端没有调用accept函数将该连接获取上来罢了。
实际在底层会为我们维护一个连接队列,服务端没有accept的新连接就会放到这个连接队列当中,而这个连接队列的最大长度就是通过listen函数的第二个参数来指定的,因此服务端虽然没有获取第二个客户端发来的连接请求,但是在第二个客户端那里显示是连接成功的。
多进程版的TCP网络程序
我们可以将当前的单执行流服务器改为多进程版的服务器。
当服务端调用accept函数获取到新连接后不是由当前执行流为该连接提供服务,而是当前执行流调用fork函数创建子进程,然后让子进程为父进程获取到的连接提供服务。
捕捉SIGCHLD信号
实际当子进程退出时会给父进程发送SIGCHLD信号,如果父进程将SIGCHLD信号进行捕捉,并将该信号的处理动作设置为忽略,此时父进程就只需专心处理自己的工作,不必关心子进程了。
该方式实现起来非常简单,也是比较推荐的一种做法。
class TcpServer
{
public:void Start(){signal(SIGCHLD, SIG_IGN); //忽略SIGCHLD信号for (;;){//获取连接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, continue next" << std::endl;continue;}std::string client_ip = inet_ntoa(peer.sin_addr);int client_port = ntohs(peer.sin_port);std::cout << "get a new link->" << sock << " [" << client_ip << "]:" << client_port << std::endl;pid_t id = fork();if (id == 0){ //child//处理请求Service(sock, client_ip, client_port);exit(0); //子进程提供完服务退出}}}
private:int _listen_sock; //监听套接字int _port; //端口号
};
让孙子进程提供服务
我们也可以让服务端创建出来的子进程再次进行fork,让孙子进程为客户端提供服务, 此时我们就不用等待孙子进程退出了。
class TcpServer
{
public:void Start(){for (;;){//获取连接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, continue next" << std::endl;continue;}std::string client_ip = inet_ntoa(peer.sin_addr);int client_port = ntohs(peer.sin_port);std::cout << "get a new link->" << sock << " [" << client_ip << "]:" << client_port << std::endl;pid_t id = fork();if (id == 0){ //childclose(_listen_sock); //child关闭监听套接字if (fork() > 0){exit(0); //爸爸进程直接退出}//处理请求Service(sock, client_ip, client_port); //孙子进程提供服务exit(0); //孙子进程提供完服务退出}close(sock); //father关闭为连接提供服务的套接字waitpid(id, nullptr, 0); //等待爸爸进程(会立刻等待成功)}}
private:int _listen_sock; //监听套接字int _port; //端口号
};
多线程版的TCP网络程序
创建进程的成本是很高的,创建进程时需要创建该进程对应的进程控制块(task_struct)、进程地址空间(mm_struct)、页表等数据结构。而创建线程的成本比创建进程的成本会小得多,因为线程本质是在进程地址空间内运行,创建出来的线程会共享该进程的大部分资源,因此在实现多执行流的服务器时最好采用多线程进行实现。
class TcpServer
{
public:static void* HandlerRequest(void* arg){pthread_detach(pthread_self()); //分离线程//int sock = *(int*)arg;Param* p = (Param*)arg;Service(p->_sock, p->_ip, p->_port); //线程为客户端提供服务delete p; //释放参数占用的堆空间return nullptr;}void Start(){for (;;){//获取连接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, continue next" << std::endl;continue;}std::string client_ip = inet_ntoa(peer.sin_addr);int client_port = ntohs(peer.sin_port);std::cout << "get a new link->" << sock << " [" << client_ip << "]:" << client_port << std::endl;Param* p = new Param(sock, client_ip, client_port);pthread_t tid;pthread_create(&tid, nullptr, HandlerRequest, p);}}
private:int _listen_sock; //监听套接字int _port; //端口号
};
线程池版的TCP网络程序
引入线程池
实际要解决这里的问题我们就需要在服务端引入线程池,因为线程池的存在就是为了避免处理短时间任务时创建与销毁线程的代价,此外,线程池还能够保证内核充分利用,防止过分调度。
其中在线程池里面有一个任务队列,当有新的任务到来的时候,就可以将任务Push到线程池当中,在线程池当中我们默认创建了5个线程,这些线程不断检测任务队列当中是否有任务,如果有任务就拿出任务,然后调用该任务对应的Run函数对该任务进行处理,如果线程池当中没有任务那么当前线程就会进入休眠状态。 tcp网络服务器中就包含线程池,监听文件描述符和端口号。
class TcpServer
{
public:TcpServer(int port): _listen_sock(-1), _port(port), _tp(nullptr){}void InitServer(){//创建套接字_listen_sock = socket(AF_INET, SOCK_STREAM, 0);if (_listen_sock < 0){std::cerr << "socket error" << std::endl;exit(2);}//绑定struct 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;if (bind(_listen_sock, (struct sockaddr*)&local, sizeof(local)) < 0){std::cerr << "bind error" << std::endl;exit(3);}//监听if (listen(_listen_sock, BACKLOG) < 0){std::cerr << "listen error" << std::endl;exit(4);}_tp = new ThreadPool<Task>(); //构造线程池对象}void Start(){_tp->ThreadPoolInit(); //初始化线程池for (;;){//获取连接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, continue next" << std::endl;continue;}std::string client_ip = inet_ntoa(peer.sin_addr);int client_port = ntohs(peer.sin_port);std::cout << "get a new link->" << sock << " [" << client_ip << "]:" << client_port << std::endl;Task task(sock, client_ip, client_port); //构造任务_tp->Push(task); //将任务Push进任务队列}}
private:int _listen_sock; //监听套接字int _port; //端口号ThreadPool<Task>* _tp; //线程池
};
TCP协议通讯流程
三次握手的过程
四次挥手的过程
HTTP协议
HTTP(Hyper Text Transfer Protocol)协议又叫做超文本传输协议,是一个简单的请求-响应协议,HTTP通常运行在TCP之上。
认识URL
URL(Uniform Resource Lacator)叫做统一资源定位符,也就是我们通常所说的网址,是因特网的万维网服务程序上用于指定信息位置的表示方法。
一个URL大致由如下几部分构成:
三、服务器地址
www.example.jp
表示的是服务器地址,也叫做域名,比如www.alibaba.com
,www.qq.com
,www.baidu.com
。
需要注意的是,我们用IP地址标识公网内的一台主机,但IP地址本身并不适合给用户看。
四、服务器端口号
80
表示的是服务器端口号。HTTP协议和套接字编程一样都是位于应用层的,在进行套接字编程时我们需要给服务器绑定对应的IP和端口,而这里的应用层协议也同样需要有明确的端口号。
常见协议对应的端口号:
协议名称 | 对应端口号 |
---|---|
HTTP | 80 |
HTTPS | 443 |
SSH | 22 |
当我们使用某种协议时,该协议实际就是在为我们提供服务,现在这些常用的服务与端口号之间的对应关系都是明确的,所以我们在使用某种协议时实际是不需要指明该协议对应的端口号的,因此在URL当中,服务器的端口号一般也是被省略的。
五、带层次的文件路径
/dir/index.htm
表示的是要访问的资源所在的路径。访问服务器的目的是获取服务器上的某种资源,通过前面的域名和端口已经能够找到对应的服务器进程了,此时要做的就是指明该资源所在的路径。
HTTP协议格式
HTTP是基于请求和响应的应用层服务,作为客户端,你可以向服务器发起request,服务器收到这个request后,会对这个request做数据分析,得出你想要访问什么资源,然后服务器再构建response,完成这一次HTTP的请求。这种基于request&response这样的工作方式,我们称之为cs或bs模式,其中c表示client,s表示server,b表示browser。
HTTP请求协议格式
HTTP请求协议格式如下:
HTTP请求由以下四部分组成:
- 请求行:[请求方法]+[url]+[http版本]
- 请求报头:请求的属性,这些属性都是以
key: value
的形式按行陈列的。 - 空行:遇到空行表示请求报头结束。
- 请求正文:请求正文允许为空字符串,如果请求正文存在,则在请求报头中会有一个Content-Length属性来标识请求正文的长度。
其中,前面三部分是一般是HTTP协议自带的,是由HTTP协议自行设置的,而请求正文一般是用户的相关信息或数据,如果用户在请求时没有信息要上传给服务器,此时请求正文就为空字符串。
HTTP响应协议格式
HTTP响应协议格式如下:
当浏览器向服务器发起HTTP请求时,不管浏览器发来的是什么请求,我们都将这个网页响应给浏览器,此时这个html文件的内容就应该放在响应正文当中,我们只需读取该文件当中的内容,然后将其作为响应正文即可。
#include <iostream>
#include <fstream>
#include <string>
#include <cstring>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <arpa/inet.h>
using namespace std;int main()
{//创建套接字int listen_sock = socket(AF_INET, SOCK_STREAM, 0);if (listen_sock < 0){cerr << "socket error!" << endl;return 1;}//绑定struct sockaddr_in local;memset(&local, 0, sizeof(local));local.sin_family = AF_INET;local.sin_port = htons(8081);local.sin_addr.s_addr = htonl(INADDR_ANY);if (bind(listen_sock, (struct sockaddr*)&local, sizeof(local)) < 0){cerr << "bind error!" << endl;return 2;}//监听if (listen(listen_sock, 5) < 0){cerr << "listen error!" << endl;return 3;}//启动服务器struct sockaddr peer;memset(&peer, 0, sizeof(peer));socklen_t len = sizeof(peer);for (;;){int sock = accept(listen_sock, (struct sockaddr*)&peer, &len);if (sock < 0){cerr << "accept error!" << endl;continue;}if (fork() == 0){ //爸爸进程close(listen_sock);if (fork() > 0){ //爸爸进程exit(0);}//孙子进程char buffer[1024];recv(sock, buffer, sizeof(buffer), 0); //读取HTTP请求cout << "--------------------------http request begin--------------------------" << endl;cout << buffer << endl;cout << "---------------------------http request end---------------------------" << endl;#define PAGE "index.html" //网站首页//读取index.html文件ifstream in(PAGE);if (in.is_open()){in.seekg(0, in.end);int len = in.tellg();in.seekg(0, in.beg);char* file = new char[len];in.read(file, len);in.close();//构建HTTP响应string status_line = "http/1.1 200 OK\n"; //状态行string response_header = "Content-Length: " + to_string(len) + "\n"; //响应报头string blank = "\n"; //空行string response_text = file; //响应正文string response = status_line + response_header + blank + response_text; //响应报文//响应HTTP请求send(sock, response.c_str(), response.size(), 0);delete[] file;}close(sock);exit(0);}//爷爷进程close(sock);waitpid(-1, nullptr, 0); //等待爸爸进程}return 0;
}
说明一下:
- 实际我们在进行网络请求的时候,如果不指明请求资源的路径,此时默认你想访问的就是目标网站的首页,也就是web根目录下的index.html文件。
- 由于只是作为示例,我们在构建HTTP响应时,在响应报头当中只添加了一个属性信息Content-Length,表示响应正文的长度,实际HTTP响应报头当中的属性信息还有很多。
HTTP的方法
HTTP常见的方法如下:
方法 | 说明 | 支持的HTTP协议版本 |
---|---|---|
GET | 获取资源 | 1.0、1.1 |
POST | 传输实体主体 | 1.0、1.1 |
PUT | 传输文件 | 1.0、1.1 |
HEAD | 获得报文首部 | 1.0、1.1 |
其中最常用的就是GET方法和POST方法。
GET方法和POST方法
GET方法一般用于获取某种资源信息,而POST方法一般用于将数据上传给服务器。但实际我们上传数据时也有可能使用GET方法,比如百度提交数据时实际使用的就是GET方法。
GET方法和POST方法都可以带参:
- GET方法是通过url传参的。
- POST方法是通过正文传参的。
HTTP的状态码
HTTP的状态码如下:
类别 | 原因短语 | |
---|---|---|
1XX | Informational(信息性状态码) | 接收的请求正在处理 |
2XX | Success(成功状态码) | 请求正常处理完毕 |
3XX | Redirection(重定向状态码) | 需要进行附加操作以完成请求 |
4XX | Client Error(客户端错误状态码) | 服务器无法处理请求 |
5XX | Server Error(服务器错误状态码) | 服务器处理请求出错 |
最常见的状态码,比如200(OK),404(Not Found),403(Forbidden请求权限不够),302(Redirect),504(Bad Gateway)。
Redirection(重定向状态码)
重定向就是通过各种方法将各种网络请求重新定个方向转到其它位置,此时这个服务器相当于提供了一个引路的服务。
重定向又可分为临时重定向和永久重定向,其中状态码301表示的就是永久重定向,而状态码302和307表示的是临时重定向。
//构建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常见的Header
HTTP常见的Header如下:
- Content-Type:数据类型(text/html等)。
- Content-Length:正文的长度。
- Host:客户端告知服务器,所请求的资源是在哪个主机的哪个端口上。
- User-Agent:声明用户的操作系统和浏览器的版本信息。
- Referer:当前页面是哪个页面跳转过来的。
- Location:搭配3XX状态码使用,告诉客户端接下来要去哪里访问。
- Cookie:用于在客户端存储少量信息,通常用于实现会话(session)的功能。
Cookie和Session
HTTP实际上是一种无状态协议,HTTP的每次请求/响应之间是没有任何关系的,但你在使用浏览器的时候发现并不是这样的。
cookie是什么呢?而浏览器收到响应后会自动提取出Set-Cookie的值,将其保存在浏览器的cookie文件当中,此时就相当于我的账号和密码信息保存在本地浏览器的cookie文件当中。
因为HTTP是一种无状态协议,如果没有cookie的存在,那么每当我们要进行页面请求时都需要重新输入账号和密码进行认证,这样太麻烦了。
当认证通过并在服务端进行Set-Cookie设置后,服务器在对浏览器进行HTTP响应时就会将这个Set-Cookie响应给浏览器.而浏览器收到响应后会自动提取出Set-Cookie的值,将其保存在浏览器的cookie文件当中,此时就相当于我的账号和密码信息保存在本地浏览器的cookie文件当中。
现在主流做法是把sessionId保存在Cookie文件中。
HTTPS VS HTTP
早期很多公司刚起步的时候,使用的应用层协议都是HTTP,而HTTP无论是用GET方法还是POST方法传参,都是没有经过任何加密的,因此早期很多的信息都是可以通过抓包工具抓到的。
UDP协议
面向数据报
应用层交给UDP多长的报文,UDP就原样发送,既不会拆分,也不会合并,这就叫做面向数据报。
比如用UDP传输100个字节的数据:
- 如果发送端调用一次sendto,发送100字节,那么接收端也必须调用对应的一次recvfrom,接收100个字节;而不能循环调用10次recvfrom,每次接收10个字节。
UDP的缓冲区
- UDP没有真正意义上的发送缓冲区,其内核"发送缓冲区" 仅暂存待发送的单个数据报。我们调用sendto会直接交给内核,由内核将数据传给网络层协议进行后续的传输动作。
- UDP具有接收缓冲区。但是这个接收缓冲区不能保证收到的UDP报的顺序和发送UDP报的顺序一致;如果缓冲区满了,再到达的UDP数据就会被丢弃。
- UDP的socket既能读,也能写,因此UDP是全双工的。
当应用程序调用 sendto()
或 sendmsg()
时,内核会立即尝试将整个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可靠性机制和效率提升机制的重要字段。
三次握手时的状态变化
四次挥手的过程
CLOSE_WAIT
- 双方在进行四次挥手时,如果只有客户端调用了close函数,而服务器不调用close函数,此时服务器就会进入CLOSE_WAIT状态,而客户端则会进入到FIN_WAIT_2状态。
- 但只有完成四次挥手后连接才算真正断开,此时双方才会释放对应的连接资源。如果服务器没有主动关闭不需要的文件描述符,此时在服务器端就会存在大量处于CLOSE_WAIT状态的连接,而每个连接都会占用服务器的资源,最终就会导致服务器可用资源越来越少。
TIME_WAIT状态存在的必要性:
- 客户端在进行四次挥手后进入TIME_WAIT状态,如果第四次挥手的报文丢包了,客户端在一段时间内仍然能够接收服务器重发的FIN报文并对其进行响应,能够较大概率保证最后一个ACK被服务器收到。
- 客户端发出最后一次挥手时,双方历史通信的数据可能还没有发送到对方。因此客户端四次挥手后进入TIME_WAIT状态,还可以保证双方通信信道上的数据在网络中尽可能的消散。
TCP协议规定,主动关闭连接的一方在四次挥手后要处于TIME_WAIT状态,等待两个MSL(Maximum Segment Lifetime,报文最大生存时间)的时间才能进入CLOSED状态。
滑动窗口
发送方可以一次发送多个报文给对方,此时也就意味着发送出去的这部分报文当中有相当一部分数据是暂时没有收到应答的。发送缓冲区的第二部分就叫做滑动窗口,滑动窗口除了限定不收到ACK而可以直接发送的数据之外,滑动窗口也可以支持TCP的重传机制。
滑动窗口存在的最大意义就是可以提高发送数据的效率:
- 滑动窗口的大小等于对方窗口大小与自身拥塞窗口大小的较小值,因为发送数据时不仅要考虑对方的接收能力,还要考虑当前网络的状况。
- 我们这里先不考虑拥塞窗口,并且假设对方的窗口大小一直固定为4000,此时发送方不用等待ACK一次所能发送的数据就是4000字节,因此滑动窗口的大小就是4000字节。(四个段)
如何解决粘包问题
要解决粘包问题,本质就是要明确报文和报文之间的边界,由应用层读取。这个是该由应用层解决的问题。
- 对于UDP,如果还没有上层交付数据,UDP的报文长度仍然在,同时,UDP是一个一个把数据交付给应用层的,有很明确的数据边界。
- 对于定长的包,保证每次都按固定大小读取即可。
解决TIME_WAIT状态引起的bind失败的方法
当服务器崩溃后最重要实际是让服务器立马重新启动,如果想要让服务器崩溃后在TIME_WAIT期间也能立马重新启动,需要让服务器在调用socket函数创建套接字后,继续调用setsockopt函数设置端口复用,这也是编写服务器代码时的推荐做法。
IP协议
- 网络层解决的问题是,将数据从一台主机送到另一台主机,因此网络层解决的是主机到主机的问题。
- 一方传输层从上方进程拿到数据后,该数据贯穿网络协议栈进行封装和解包,最终到达对方传输层,此时对方传输层也会将数据向上交给对应的进程,因此传输层解决的是进程到进程的问题。
IP如何将报头与有效载荷进行分离?
IP分离报头与有效载荷的方法与TCP是一模一样的,当IP从底层获取到一个报文后,虽然IP不知道报头的具体长度,但IP报文的前20个字节是IP的基本报头,并且这20字节当中涵盖4位首部长度。
DHCP协议
实际手动管理IP地址是一个非常麻烦的事情,当子网中新增主机时需要给其分配一个IP地址,当子网当中有主机断开网络时又需要将其IP地址进行回收,便于分配给后续新增的主机使用。
- DHCP是一个基于UDP的应用层协议,一般的路由器都带有DHCP功能,因此路由器也可以看作一个DHCP服务器。
- 当我们连接WiFi时需要输入密码,本质就是因为路由器需要验证你的账号和密码,如果验证通过,那么路由器就会给你动态分配了一个IP地址,然后你就可以基于这个IP地址进行各种上网动作了。
Linux高级IO
- 对文件进行的读写操作本质就是一种IO,文件IO对应的外设就是磁盘。
- 对网络进行的读写操作本质也是一种IO,网络IO对应的外设就是网卡。
输入就是操作系统将数据从外设拷贝到内存的过程,操作系统一定要通过某种方法得知特定外设上是否有数据就绪。
- 操作系统实际采用的是中断的方式来得知外设上是否有数据就绪的,当某个外设上面有数据就绪时,该外设就会向CPU当中的中断控制器发送中断信号,中断控制器再根据产生的中断信号的优先级按顺序发送给CPU。
- 每一个中断信号都有一个对应的中断处理程序,存储中断信号和中断处理程序映射关系的表叫做中断向量表,当CPU收到某个中断信号时就会自动停止正在运行的程序,然后根据该中断向量表执行该中断信号对应的中断处理程序,处理完毕后再返回原被暂停的程序继续运行。
OS如何处理从网卡中读取到的数据包?
操作系统任何时刻都可能会收到大量的数据包,因此操作系统必须将这些数据包管理起来。操作系统从网卡当中读取到一个数据包后,会将该数据依次交给链路层、网络层、传输层、应用层进行解包和分用。最后将剩余数据交给 操作系统的接收缓冲区。
五种IO模型
实际这五个人的钓鱼方式分别对应的就是五种IO模型。
张三、李四、王五他们三个人的钓鱼的效率是一样的,他们只是等鱼上钩的方式不同而已,张三是死等,李四是定期检测浮漂,而王五是通过铃铛来判断是否有鱼上钩,他们单位时间会钓上来的鱼是一样多的。通过这里的钓鱼例子我们可以看到,阻塞IO、非阻塞IO和信号驱动IO本质上是不能提高IO的效率的,但非阻塞IO和信号驱动IO能提高整体做事的效率。
信号驱动IO
信号驱动IO就是当内核将数据准备好的时候,使用SIGIO信号通知应用程序进行IO操作。当底层数据就绪的时候会向当前进程或线程递交SIGIO信号,因此可以通过signal或sigaction函数将SIGIO的信号处理程序自定义为需要进行的IO操作,当底层数据就绪时就会自动执行对应的IO操作。
IO多路转接
IO多路转接也叫做IO多路复用,能够同时等待多个文件描述符的就绪状态。
select实现
阻塞式等待所有设置进fd_set的文件描述符。
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
select的优点
- 可以同时等待多个文件描述符,并且只负责等待,实际的IO操作由accept、read、write等接口来完成,这些接口在进行IO操作时不会被阻塞。
- select同时等待多个文件描述符,因此可以将“等”的时间重叠,提高了IO的效率。
select的缺点
- 每次调用select,都需要手动设置fd集合,从接口使用角度来说也非常不便。
- 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大。
- 同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大。
- select可监控的文件描述符数量有限。
I/O多路转接之epoll
epoll有三个相关的系统调用,分别是epoll_create、epoll_ctl和epoll_wait。
int epoll_create(int size);
返回值说明:
- epoll模型创建成功返回其对应的文件描述符,否则返回-1,同时错误码会被设置。
当不再使用时,必须调用close函数关闭epoll模型对应的文件描述符,当所有引用epoll实例的文件描述符都已关闭时,内核将销毁该实例并释放相关资源。
epoll_ctl函数用于向指定的epoll模型中注册事件,该函数的函数原型如下:
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
epoll_wait函数
epoll_ctl函数用于收集监视的事件中已经就绪的事件,该函数的函数原型如下:
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
epoll工作原理
红黑树和就绪队列
当某一进程调用epoll_create函数时,Linux内核会创建一个eventpoll结构体,也就是我们所说的epoll模型,eventpoll结构体当中包含 成员rbr和rdlist,一颗红黑树和一个就绪队列。
- epoll模型当中的红黑树本质就是告诉内核,需要监视哪些文件描述符上的哪些事件,调用epll_ctl函数实际就是在对这颗红黑树进行对应的增删改操作。
- epoll模型当中的就绪队列本质就是告诉内核,哪些文件描述符上的哪些事件已经就绪了,调用epoll_wait函数实际就是在从就绪队列当中获取已经就绪的事件。
回调机制
所有添加到红黑树当中的事件,都会与设备(网卡)驱动程序建立回调方法,这个回调方法在内核中叫ep_poll_callback。
- 对于select和poll来说,操作系统在监视多个文件描述符上的事件是否就绪时,需要让操作系统主动对这多个文件描述符进行轮询检测,这一定会增加操作系统的负担。
- 而对于epoll来说,操作系统不需要主动进行事件的检测,当红黑树中监视的事件就绪时,会自动调用对应的回调方法,将就绪的事件添加到就绪队列当中。
- 当用户调用epoll_wait函数获取就绪事件时,只需要关注底层就绪队列是否为空,如果不为空则将就绪队列当中的就绪事件拷贝给用户即可。
- 由于就绪队列可能会被多个执行流同时访问,因此必须要使用互斥锁对其进行保护,eventpoll结构当中的lock和mtx就是用于保护临界资源的,因此epoll本身是线程安全的。
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就会因为底层没有数据而被阻塞住。 因此需要非阻塞读写。
对比LT和ET
- 在ET模式下,一个文件描述符就绪之后,用户不会反复收到通知,看起来比LT更高效,但如果在LT模式下能够做到每次都将就绪的文件描述符立即全部处理,不让操作系统反复通知用户的话,其实LT和ET的性能也是一样的。
- 此外,ET的编程难度比LT更高。ET模式一定会以最快的速度把TCP缓冲区的数据读走。这样会给对方一个更大的ACK窗口,更高效。LT也可以实现ET的效果。
poll ET服务器(Reactor模式)
Reactor反应器模式,也叫做分发者模式或通知者模式,是一种将就绪事件派发给对应服务处理程序的事件设计模式。
Reactor模式的五个角色
在这个epoll ET服务器中,Reactor模式中的五个角色对应如下:
- 句柄:文件描述符。
- 同步事件分离器:I/O多路复用epoll。
- 事件处理器:包括读回调、写回调和异常回调。
- 具体事件处理器:读回调、写回调和异常回调的具体实现。
- 初始分发器:Reactor类当中的Dispatcher函数。