温馨提示,在学习本篇之前,您需要对网络模型、TCP\UDP有初步了解
网络基础知识
网络字节序和主机字节序
主机字节序(Host Byte Order)
主机字节序是指计算机系统在内存中存储多字节数据(如整数、浮点数等)时的字节排列顺序。不同的计算机体系结构可能使用不同的字节序,主要有两种:
大端字节序(Big-endian):高位字节存储在低地址处,低位字节存储在高地址处。例如,整数0x12345678在大端字节序中存储为:
地址: 0x00 0x01 0x02 0x03
数据: 0x12 0x34 0x56 0x78
小端字节序(Little-endian):低位字节存储在低地址处,高位字节存储在高地址处。例如,整数0x12345678在小端字节序中存储为:
地址: 0x00 0x01 0x02 0x03
数据: 0x78 0x56 0x34 0x12
虽然大端字节序对于人类的阅读上更加友好,但是在大部分平台,都是使用的小端字节序,这个主要是历史原因导致的。
在计算机发展早期,电子设备的处理能力并不强,而使用大段字节序意味着读取数据的时候,需要不断跳跃地址,然后逆向读取,才能正确解析数据,对于性能孱弱的电子设备无疑增加了不必要的计算量,对效率低影响很多。而且内存中的数据是给计算机读的,又不是给人读的,所以也没有必要按照人类阅读习惯。
现在的硬件性能对于这种操作带来的开销已经完全可以忽略不计,但是由于历史遗留的习惯以及兼容性问题,所以依旧普遍采用小端字节序,但是并不意味着大端字节序就完全没有作用,它在网络通信中运用的比较广泛。
网络字节序(Network Byte Order)
网络字节序是指在网络传输中使用的标准字节排列顺序。为了确保不同计算机系统之间的数据传输一致性,网络协议(如TCP/IP)规定使用大端字节序作为网络字节序。这意味着在网络传输中,高位字节总是先传输。
由于不同计算机系统可能使用不同的主机字节序,在进行网络通信时,需要将主机字节序转换为网络字节序,反之亦然。常用的转换函数包括:
htonl()
:将32位整数从主机字节序转换为网络字节序(Host to Network Long)。htons()
:将16位整数从主机字节序转换为网络字节序(Host to Network Short)。ntohl()
:将32位整数从网络字节序转换为主机字节序(Network to Host Long)。ntohs()
:将16位整数从网络字节序转换为主机字节序(Network to Host Short)。
它们通常被包含在头文件 #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);
网络地址和端口
- 网络地址:
- 网络地址通常是指IP地址,用于标识网络中的设备。IP地址有两种版本:IPv4和IPv6。
- IPv4地址:由四个8位字节组成,通常表示为点分十进制格式(例如,192.168.1.1)。
- IPv6地址:由128位组成,通常表示为冒号分隔的十六进制格式(例如,2001:0db8:85a3:0000:0000:8a2e:0370:7334)。
- 网络地址通常是指IP地址,用于标识网络中的设备。IP地址有两种版本:IPv4和IPv6。
- 端口:
- 端口是用于标识特定进程或服务的数字。端口号范围从0到65535,其中0到1023是保留端口,通常用于系统服务和特权服务。
在IPV4时期有一个函数可以转换点分十进制的ipv4字符串类型的地址转为网络字节序的二进制形式,不过这个函数已经过时了,所以并不推荐:
in_addr_t inet_addr(const char *cp);
在现代网络编程中,inet_pton()
和inet_ntop()
是更加主流和推荐的地址转换函数。它们比inet_addr()
和inet_ntoa()
更健壮,并且支持IPv4和IPv6地址。
inet_pton()
函数
inet_pton()
函数用于将文本形式的IP地址转换为网络字节序的二进制形式。它支持IPv4和IPv6地址。
函数原型
#include <arpa/inet.h>
int inet_pton(int af, const char *src, void *dst);
函数参数
af
:地址族,可以是AF_INET
(用于IPv4)或AF_INET6
(用于IPv6)。src
:指向一个以文本形式表示的IP地址的字符串。dst
:指向一个缓冲区,用于存储转换后的二进制形式的IP地址。
函数返回值
- 成功时,返回1。
- 失败时,返回0(表示输入的字符串不是有效的IP地址)或-1(表示发生了错误,并设置
errno
)。
示例
#include <stdio.h>
#include <arpa/inet.h>
int main() {
const char *ip_str = "192.168.1.1";
struct in_addr ip_addr;
if (inet_pton(AF_INET, ip_str, &ip_addr) == 1) {
printf("IP address in network byte order: %u\n", ip_addr.s_addr);
} else {
printf("Invalid IP address\n");
}
return 0;
}
inet_ntop()
函数
inet_ntop()
函数用于将网络字节序的二进制形式的IP地址转换为文本形式。它支持IPv4和IPv6地址。
函数原型
#include <arpa/inet.h>
const char *inet_ntop(int af, const void *src,char *dst, socklen_t size);
函数参数
af
:地址族,可以是AF_INET
(用于IPv4)或AF_INET6
(用于IPv6)。src
:指向一个以二进制形式表示的IP地址。dst
:指向一个缓冲区,用于存储转换后的文本形式的IP地址。size
:缓冲区的大小。
函数返回值
- 成功时,返回指向文本形式IP地址的指针。
- 失败时,返回
NULL
,并设置errno
。
示例
#include <stdio.h>
#include <arpa/inet.h>
int main() {
struct in_addr ip_addr;
char ip_str[INET_ADDRSTRLEN];
ip_addr.s_addr = htonl(0xC0A80101); // 192.168.1.1 in network byte order
if (inet_ntop(AF_INET, &ip_addr, ip_str, INET_ADDRSTRLEN) != NULL) {
printf("IP address in text form: %s\n", ip_str);
} else {
printf("Conversion failed\n");
}
return 0;
}
套接字编程基础
什么是套接字?
本质上来讲,套接字是一个抽象的东西,可以将其理解为通信接口,事实上它是一个处于应用层与与网络层(也可以说是网络层及以下层次)之间的抽象层,它为应用层提供一个接口。
套接字可以看作是一种“打包”机制,它将网络通信的各种元素(如IP地址、端口号、协议类型等)打包在一起。然后,应用程序可以通过这个套接字来发送和接收数据,就像通过一个“通信端口”进行通信一样。
但是,需要注意的是,套接字本身并不包含要发送的消息数据。消息数据是通过套接字发送和接收的,但是数据本身是独立于套接字的。你可以把套接字想象成一个邮局的信箱,你可以通过信箱来发送和接收信件,但是信件(即消息数据)是独立于信箱的。
而我们在实际交流中说的套接字是什么呢?比如创建一个新的套接字。
口语中提到的套接字往往是指一个套接字对象,或者说是一个数据结构,因为套接字本质是一个API层,创建API这种说法显然是不合理的。而在口语中并没有分得很细。而是通过API创建一个套接字对象并获取这个对象的描述符。
套接字创建与关闭
socket函数
该函数的作用是创建一个套接字,套接字通过文件描述符操作。但是这个并不是生成了一个套接字的物理文件,而是被抽象成了一种只存在于内存中的虚拟文件,它提供了一种标准的接口,使得应用程序可以使用类似于读写文件的方式来进行网络通信。这个函数的相当于在新开的邮局安装一个新的空邮箱。
故而套接字的关闭同样按照文件的逻辑进行操作,使用close进行关闭,传入的参数就是socket产生的文件描述符号
函数原型
#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
函数参数
int domain
:这个参数用于指定网络协议的类型。常见的值有:AF_INET
:IPv4网络协议AF_INET6
:IPv6网络协议AF_UNIX
:UNIX域协议(用于同一台机器上的进程间通信)
int type
:这个参数用于指定套接字的类型。常见的值有:SOCK_STREAM
:提供面向连接的、可靠的数据流。通常用于TCP协议。SOCK_DGRAM
:提供无连接的、不可靠的数据报服务。通常用于UDP协议。
int protocol
:这个参数用于指定具体的协议。通常我们设置为0,让系统自动选择协议。例如,当domain
参数为AF_INET
,type
参数为SOCK_STREAM
时,protocol
会被设置为TCP协议。
函数返回值
- 如果
socket()
函数成功,它会返回一个非负整数,这个整数就是新创建的套接字的文件描述符。 - 如果失败,它会返回-1,并设置全局变量
errno
来表示错误原因。
绑定套接字
bind函数
bind()
函数是套接字编程中非常重要的一个函数,主要用于将套接字绑定到一个特定的IP地址和端口号。而bind()
函数则相当于给这个邮箱分配了一个具体的地址。这个地址就像是邮局的门牌号,使得其他人(即其他的网络主机或进程)可以找到这个邮箱,并向其中发送或从中接收邮件(即网络数据)。
函数原型
#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr,socklen_t addrlen);
函数参数
int sockfd
:这是由socket()
函数返回的套接字文件描述符。它是bind()
函数的第一个参数,用于指定需要绑定的套接字。const struct sockaddr *addr
:这是一个指向结构体sockaddr
的指针,该结构体中包含了你希望套接字绑定的IP地址和端口号。注意,我们通常不直接使用该结构体。socklen_t addrlen
:这是sockaddr
结构体的大小,通常是sizeof(struct sockaddr)
。
对于第二个参数struct sockaddr
来说,我们通常情况下并不会使用这个结构体,因为struct sockaddr
是一个通用的套接字地址结构,它被设计为可以处理任何类型的网络地址,包括IPv4、IPv6等。所以使用起来不清晰而且操作麻烦,所以往往会使用更加细化的结构体。被细化的结构体是与struct sockaddr
的设计兼容,所以在传参的时候强转就可以了。
在ipv4网络下,我们使用struct sockaddr_in
结构体,它是专门用于处理IPv4地址的。它的结构比struct sockaddr
更清晰,更容易使用。它的定义如下:
#include <netinet/in.h>
struct sockaddr_in {
short sin_family; // 地址族(Address Family),也就是地址类型,该结构体应该填写为AF_INET
unsigned short sin_port; // 端口号,使用网络字节序
struct in_addr sin_addr; // IPv4地址结构体
char sin_zero[8]; // 未使用,一般填充为0,其存在的主要原因是为了让sockaddr_in和sockaddr保持大小相同
};
//其中,struct in_addr是一个用于表示IPv4地址的结构体,它只有一个成员:
struct in_addr {
unsigned long s_addr; // IPv4地址,使用网络字节序
};
在ipv4网络下,我们使用struct sockaddr_in6
结构体:
struct sockaddr_in6 {
sa_family_t sin6_family; // AF_INET6
in_port_t sin6_port; // 端口号
uint32_t sin6_flowinfo; // IPv6流信息
struct in6_addr sin6_addr; // IPv6地址
uint32_t sin6_scope_id; // Scope ID
};
举例:(ipv4)
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
int main() {
int sockfd;
struct sockaddr_in addr;
// 创建套接字
sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd == -1) {
perror("Failed to create socket");
return 1;
}
// 设置地址结构体
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET; // 使用IPv4地址
addr.sin_port = htons(12345); // 设置端口号(注意网络字节序)
addr.sin_addr.s_addr = inet_addr("192.168.1.1"); // 设置IP地址
// 绑定套接字
if (bind(sockfd, (struct sockaddr *)&addr, sizeof(addr)) == -1) {
perror("Failed to bind socket");
return 1;
}
// 现在,套接字已经绑定到了指定的地址和端口上
printf("Socket bound to %s:%d\n", inet_ntoa(addr.sin_addr), ntohs(addr.sin_port));
// 关闭套接字
close(sockfd);
return 0;
}
函数返回值
- 如果
bind()
函数调用成功,它会返回0。 - 如果调用失败,它会返回-1,并通过
errno
变量提供错误信息。
TCP编程
三次握手与四次挥手
三次握手
- 客户端:生成一个报文,并启用报文的SYN同步标识请求连接,同时随机生成一个初始序列号
a
。 - 服务端:接收到请求后,生成一个报文,启用报文的SYN同步标识与ACK确认标识,确认连接请求,并随机生成一个初始序列号
b
。同时,将客户端发送来的序列号a
加1(即a+1
)作为确认号返回。 - 客户端:接收到服务端的SYN-ACK报文后,生成一个报文,启用ACK确认标识,确认服务端的序列号
b
,并将其加1(即b+1
)作为确认号,同时发送自己的序列号a+1
。
核心点1:为什么要使用序列号?
首先网络世界存在大量不可预估的干扰,它们可能造成数据包的延迟或者丢失,甚至数据包发送顺序和接收顺序相反。而接收方也可能会因为两个完全相同的包而不知道如何处理。比如,客户端发送了一个连接请求,服务端接收后回应,但是因为某种延迟,客户端迟迟没有收到,于是它再次发送一个连接请求,结果前脚发完后脚收到了回复。而服务端这里却又收到了连接请求,那么它不知道客户端是什么意思,是没有收到自己的回复包还是想建立两个连接。
那么必须要存在某种机制能控制接收循序,还能是包的一个标识,那么序列号就可以完美解决这种问题。
客户端与服务器是随机生成的一个值而不是固定值,是为了以增加安全性,防止预测攻击。
不过,有说法认为服务器为了节约内存,选择不存储自己的序列号,而是采用某种依赖为客户端信息生成的可预测序列号,这种说法我没有找到强有力证据,但我并不能否定这种说法是错误的,所以还是将这种说法放了上来。
核心点2:为什么要使用+1作为确认号?
实际上,使用+1
作为确认号确实是TCP协议中的一个规定,目的是为了确保数据包的顺序和完整性。如果只从实现的理论上讲,任何能够唯一标识并验证数据包顺序的机制都可以使用,比如+5
、+10
等,甚至可以使用复杂的算法生成确认号,但是只要保证该确认号是可以被统一约定下验证信息被接收就行。
然而,使用+1
有其简洁和高效的优点,便于实现和理解。它确保每个数据包都能被正确确认,并且在实际应用中已经被广泛验证为有效的机制。
核心点3:为什么是3次握手?不能2次,5次吗?
三次握手的原因如此,比较形象的讲就是两个在信号不好的地方打电话
- 张三:今天晚上6点到我家吃饭,听到了吗?
- 李四:我有点事,7:30可以吗?
- 张三:没问题!
从这个情景中可以知道,双方交换信息至少要3次,如果张三没有回答“没问题”,那么李四就不知道张三是否知道自己有事,那么结果会导致张三6点准备了饭菜,结果李四7:30过来饭都凉了,搞得两人都不开心。
那么是否可以大于3次呢?事实上也没问题,比如在之后:
- 张三:今天晚上6点到我家吃饭,听到了吗?
- 李四:我有点事,7:30可以吗?
- 张三:没问题!
- 李四:收到你说的“没问题”了。
- 张三:收到你说 你收到了我说的没问题了
然后可以无限套娃。但是有什么意义呢?双方都确认了消息,之后就是白白浪费话费。
我们很清楚的知道,在第三次张三说没问题后,李四收到了消息。那么张三知道饭局推迟,李四本来就主动推迟饭局,它当然知道这个事,所以就不需要李四的回复。李四只需要知道张三知道这个消息就可以了。
同样的,理论上tcp三次握手,也可以使用五次握手,但是这不过是白白浪费服务器资源。
不过可能有读者对于最后一次握手有疑问:李四没有回复,那么张三也就不知道自己回复的“没问题”是否被李四收到,那么按道理来说应该有第四次握手。那么如何解决这一个问题呢?接下来依旧是情节模拟
- 张三:今天晚上6点到我家吃饭,听到了吗?
- 李四:我有点事,7:30可以吗?
- 张三:滋滋滋。
李四没有听到张三说了啥,只听到了噪声,那么李四会怎么做?它会隔几秒重新问一次,直到张三回复成功。
- 李四:我有点事,7:30可以吗?
- 张三:滋滋滋。
- 李四:我有点事,7:30可以吗?
- 张三:滋滋滋。
- 李四:我有点事,7:30可以吗?
- 张三:知道了。
这个过程是反复执行第二次握手。
四次挥手
客户端和服务器都能建立四次挥手,这里使用客户端申请断开连接为例子
- 客户端发送FIN报文:客户端申请断开连接,生成一个报文,并在这个报文中启用FIN结束标志,生成一个序列号为
a
写入。 - 服务端发送ACK报文:服务端收到FIN报文后,生成一个报文,并在这个报文中启用ACK确认标志,生成一个序列号
k
,和客户端的序列号a+1
作为确认号。 - 服务端发送FIN报文:服务端再次生成一个报文,并在这个报文中启用FIN结束标志和ACK确认标志,生成一个自己的序列号
w
,和客户端的序列号a+1
作为确认号。 - 客户端发送ACK报文:客户端收到服务端的FIN报文后,生成一个报文,并在这个报文中启用ACK确认标志,发送序列号
a+1
和确认号w+1
。等待一段时间后关闭(2MSL)。
核心点1:序列号和三次挥手一样是随机生成的吗?
并不是,事实上,在客户端和服务端分别独自维护一个报文序列。对于客户端来说,在第一次挥手时它的序列号是基于最后一个数据包的序列号生成的,通常情况下是最后一个已发送数据包的序列号加上数据包的长度生成的一个数。而服务端也是如此,第二次挥手的序列号是基于服务端的最后一个已发送数据包的序列号加上数据包的长度生成,而第三次挥手的序列号是基于第二次挥手的数据包的序列号加上数据包的长度生成。
先前在三次握手的时候讲解过序列号的作用。而同样确认信号也是基于上一次挥手的序列号+1,但是为什么它不像三次挥手一样使用随机数作为序列号呢?因为三次挥手双方并没有数据需要发送,所有所有的序列号均可用,而四次挥手的时候,有可能存在待发送数据,以及数据在发送中还没有到达。如果使用随机数可能导致冲突。
核心点2:为什么要四次挥手而不是三次?
因为与建立连接不同,挥手的时候,不能确保所有的数据都已经传输完成,所以在第二次与第三次挥手之间是为了处理这些数据。举一个例子,妈妈在忙家务的妈妈想让在客厅打游戏的张三去拿个快递。
- 妈妈:张三,帮我拿个快递。
- 张三:好,等我打完这一局。
- 张三:妈妈我打完了,请告诉我取件码?
- 妈妈:好,取件码是1234。
这里可以看到,张三的游戏没有打完,他需要一点点时间结束这一句游戏。所以这两次挥手不能合并成一次。
那么为什么不需要第五次挥手让妈妈知道张三拿到了取件码呢?依旧是场景模拟,比如妈妈可能在打扫厨房,没有听到张三打完游戏了。或者妈妈说完后张三没有听清。那么张三会隔几秒后重新问一次取件码是什么。直到自己听清后,张三就直接出门了。
核心点3:为什么客户端四次挥手完成后要等待一段时间?
这个时间2MSL,MSL是Maximum Segment Lifetime英文的缩写,中文可以译为“报文最大生存时间”,他是任何报文在网络上存活的最长时间,超过这个时间报文将被丢弃。等待2倍这个时间,就是避免第三次挥手没有听清,而等待一段时间。
情景模拟:张三打完游戏后问取件码,妈妈回复了1234.但是这个时候妈妈以防张三没有听清,停下来手中的活,等几秒看看张三是不是会再问一遍。如果张三没有问那么就是收到了消息出门了,妈妈过几秒就会继续做家务,如果张三没听清,就会再问一遍,妈妈就会将取件码再告诉他。
建立TCP连接
listen函数
listen()
函数的主要作用是将一个主动套接字(active socket)转换为一个被动套接字(passive socket)。这样做的目的是为了让套接字能够接受来自客户端的连接请求。
在网络编程中,主动套接字和被动套接字的概念是非常重要的:
- 主动套接字:是指那些用于发起连接请求的套接字。在客户端应用程序中,我们通常会创建一个主动套接字,然后使用
connect()
函数来发起对服务器的连接请求。 - 被动套接字:是指那些用于接受连接请求的套接字。在服务器应用程序中,我们需要创建一个被动套接字,然后使用
listen()
函数来监听客户端的连接请求。
当我们在服务器端创建一个新的套接字时,这个套接字默认是一个主动套接字。但是,服务器通常不会主动去连接客户端,而是等待客户端来连接。因此,我们需要调用listen()
函数将这个主动套接字转换为被动套接字。
一旦套接字变为被动套接字,它就可以接受来自客户端的连接请求了。当一个客户端连接请求到达时,服务器可以使用accept()
函数来接受这个连接请求,并创建一个新的套接字来与客户端进行通信。
这个函数并不是直接响应连接请求,它的作用仅仅是将一个套接字由主动状态转换为被动状态,所以通常被服务器端使用。
简单来说,就是这个函数将服务器套接字转换为了监听套接字(Listening Socket)。在此之后,这个套接字将不是再处理数据信息,它的作用是负责监听来自客户端的连接请求而不是数据处理请求
函数原型
#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
int listen(int sockfd, int backlog);
函数参数
sockfd
:这是由socket()
函数返回的套接字描述符。backlog
:这是在套接字完成三次握手并准备好被接受的连接队列中允许的最大数量。如果到达的连接请求超过了队列的大小,客户端可能会收到ECONNREFUSED错误,或者,如果内核支持的话,请求可能会被忽略。换句话说,backlog参数指定的是等待队列的大小,也就是在服务器还没有调用accept函数接受连接时,可以在队列中等待的连接请求的最大数量。如果服务器处理速度小于客户端申请速度,那么等待队列中的连接请求会逐渐增多。如果连接请求的数量超过了backlog指定的大小,那么额外的连接请求可能会被忽略,或者客户端可能会收到ECONNREFUSED错误。同时,服务器实际上正在连接的客户端的数量并不受backlog参数的限制,而是取决于服务器的硬件性能和操作系统配置。所以,服务器可能正在与100台客户端交互,但是它只支持最多5个客户端等待。
函数返回值
listen()
函数的返回值是一个整数。如果函数成功,它将返回0。- 如果发生错误,它将返回-1,并设置全局变量
errno
以指示错误类型。listen()
函数可能会在以下几种情况下返回错误:- 套接字未绑定:如果你在调用
listen()
函数之前没有使用bind()
函数将套接字绑定到一个地址,那么listen()
函数会返回错误。 - 套接字不是一个流套接字:
listen()
函数只能用于流套接字(即SOCK_STREAM类型的套接字)。如果你尝试在一个数据报套接字(即SOCK_DGRAM类型的套接字)上调用listen()
函数,它会返回错误。 - 文件描述符无效:如果提供给
listen()
函数的文件描述符不是一个有效的套接字描述符,那么它会返回错误。 - 套接字已连接:如果套接字已经连接到另一个套接字,那么在这个套接字上调用
listen()
函数会返回错误。 - 内存不足:如果系统没有足够的内存来处理新的连接,那么
listen()
函数可能会返回错误。
- 套接字未绑定:如果你在调用
accept函数
accept函数是Linux网络编程中的一个重要函数,主要用于处理基于连接的套接字(如SOCK_STREAM和SOCK_SEQPACKET)的连接请求。当服务器端监听到客户端的连接请求时,accept函数会返回一个新的套接字,用于与客户端进行通信。
这个函数的应用场景通常为服务器端,在默认情况下,这个函数如果没有接收到连接请求会处于阻塞状态,不过这个可以进行设置,调整为不阻塞模式。
补充——为什么需要建立新的套接字(即连接套接字 Connected Socket)
我们先假设一个情况,服务器只有监听套接字会怎么样?
现在有两台客户端连接一个服务器,那么服务端的监听套接字同时与两个客户端套接字相连,也就是说,两个客户端套接字同享一个监听套接字缓冲区。
此时会出现一种情况,如果我的客户端1发送消息,监听套接字缓冲区刚刚写入一半消息,客户端2发送了消息,于是,客户端2的消息也被写入了一部分进入了缓冲区,那么两者的数据发送了冲突,意味着这个消息已经作废。
这个时候有人要问了,那么我可以让一个消息写完再写另外一个。
那么假设有1000000台客服端同时访问一个服务端,假设服务器性能足够的情况下,固定处理一个消息耗时1秒,那么也就意味着,当客户端会排出长队,最后一个客户端的请求需要等到1000000秒后响应,差不多11天半。那么解决方案是什么呢?
在服务器中建立一个专属的套接字与一个客户端一对一进行处理,在这个情况下,依旧假设服务器性能足够的情况下,就变成所有的客户端请求并行计算,那么最终只需要1秒解决所有客户的请求。
尽管在实际上,服务器的性能有限,那么也依旧效率比逐一处理高出几何倍。
函数原型
#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
函数参数
sockfd
:用来标识服务端套接字,也就是listen函数中设置为监听状态的套接字。addr
:是一个传出参数,用来保存客户端套接字对应的内存空间变量(包括客户端IP和端口信息等)。如果应用层不需要记录客户端的IP和端口号,则可以设置为NULL。addrlen
:这是一个传入传出参数,传入时为函数调用时提供参数addr的长度,传出时为客户端地址结构体的实际长度。如果addr
为NULL
,那么这个值也为NULL
首先需要了解的一点是,sockfd是类似于一个”指针“,它指向操作系统内部生成的一个用来标识服务器端的套接字,这个套接字用于监听和接受客户端的连接请求。而函数的返回值是一个通信描述符(通常称为acceptfd
),这个描述符是从sockfd派生出来的,它指向于操作系统内部生成一个有关客户端的套接字,而addr
参数则更像是一个备忘录,如果你想将这个有关客户端的套接字记录下来,你可以传入一个空套接字地址,那么函数会复制一个副本到传入的地址中,如果不需要则填写NULL
。
函数返回值
- 成功:返回一个服务器用于以后通信的“通信描述符”。
- 失败:返回-1
connect函数
这个函数主要用于客户端向服务端发送连接申请。
函数原型
#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
int connect(int sockfd, struct sockaddr *serv_addr, int addrlen);
函数参数
- sockfd:通过调用socket()函数获得的套接字的文件描述符。
- serv_addr:指向一个结构体的指针,该结构体包含了我们想要连接的服务端的地址和端口信息。这个结构体可以是sockaddr_in(用于IPv4)或sockaddr_in6(用于IPv6)。
- addrlen:serv_addr指向的结构体的大小。
其中的参数sockfd是类似于一个”指针“,它指向操作系统内部生成的一个用来标识客户端的套接字,该套接字用于与服务器建立连接。而serv_addr是对服务器的一个套接字描述,而操作系统内部存在一个连接表(CCB),这个表中记录了关于客户端套接字和服务端地址的信息,serv_addr传入的信息会被复制到这个连接表中,注意并不是直接使用serv_addr的地址,而是这个地址中的值的副本,也就是说,即便在serv_addr在函数执行完成后销毁了,也不会影响到操作系统内部的数据。
函数返回值
- 成功:返回0,表示连接已经成功建立。
- 失败:返回-1,并会设置全局错误变量errno。
TCP协议下的客户端与服务端工作流程:
套接字准备:
- 二元组套接字——监听套接字(Client Socket):包含客户端的 IP 地址和端口号
- 二元组套接字——客户端套接字(Listening Socket):包含服务器的 IP 地址和端口号
- 四元组套接字——连接套接字 (Connected Socket):包含客户端的 IP 地址、客户端的端口号、服务器的 IP 地址和服务器的端口号
过程:
客户端:
客户端生成二元组客户端套接字,通过使用connect函数,将传入的服务端的套接字地址结构体的信息写入操作系统内部负责维护的连接表中,并通过连接表信息和客户端套接字信息组合,向服务器发送一个连接请求。
服务端:
服务端生成二元组监听套接字,并使用bind绑定了服务器端口和ip,使用listen将套接字转化为被动请求类型,通过accpet等待连接。当数据信息被服务端接收到后,服务器的网络栈会判断消息类型。
如果为连接请求,则立即将消息转移到监听套接字中,监听套接字收到连接请求,激活accept函数,生成一个包含客户端信息与服务端信息的四元连接套接字,并写入操作系统内部维护的一张hash表。
如果网络栈判断消息为数据包,那么就会通过hash表查找对应的连接套接字,之后将数据包转移到该连接套接字中。
数据的发送和接收
send函数
这个函数是发送函数,它能将缓冲区的消息发送出去。但是存在一个需要注意的点:send函数存在发送限制,也就是说,它不一定会将缓冲区全部发送出去,但是它会返回一个发送字节总数。发送上限通常取决于多个因素,包括网络状况、套接字缓冲区的大小以及操作系统的实现等。
在大多数系统上,默认的发送缓冲区大小通常在几千字节到几十千字节之间。例如,Linux系统上的默认发送缓冲区大小通常是16KB或64KB。
函数原型
#include <sys/types.h>
#include <sys/socket.h>
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
函数参数
sockfd
:套接字文件描述符,标识一个已连接的套接字。buf
:指向要发送的数据缓冲区的指针。len
:要发送的数据长度,以字节为单位。flags
:发送数据时的标志,可以是以下值的组合:0
:默认值,没有特殊标志。MSG_DONTROUTE
:数据包不经过路由表,直接发送到本地网络。MSG_OOB
:发送带外数据。MSG_DONTWAIT
:非阻塞发送,如果套接字不可写,立即返回。MSG_NOSIGNAL
:发送时不产生SIGPIPE
信号。它通常在以下情况下产生:当你尝试向一个已经关闭的套接字发送数据时,系统会向进程发送一个SIGPIPE
信号。而这个信号会导致进程异常终止。
函数返回值
- 成功时,返回实际发送的字节数。
- 失败时,返回 -1,并设置
errno
以指示错误类型。
示例:
size_t total_bytes_sent = 0;//已发送的数据
size_t bytes_to_send = strlen(buffer);//总数据
// 循环发送消息,直到所有数据都被发送
while (total_bytes_sent < bytes_to_send) {
//发送数据,并记录当前成功发送的字节数
ssize_t bytes_sent = send(sockfd, buffer + total_bytes_sent, bytes_to_send - total_bytes_sent, 0);
//发送失败判断
if (bytes_sent == -1) {
perror("send failed");
break;
}
//更新当前发送的数据总数
total_bytes_sent += bytes_sent;
}
recv函数
接收函数和发送函数一样,它可能不会一次性接受所有的数据,返回值表示实际接收的字节数。需要在循环中调用 recv
直到所有数据接收完毕。它的接收上限往往和发送上限一致
函数原型
#include <sys/types.h>
#include <sys/socket.h>
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
函数参数
sockfd
:套接字文件描述符,标识一个已连接的套接字。buf
:指向接收数据缓冲区的指针。len
:缓冲区的长度,以字节为单位。flags
:接收数据时的标志,可以是以下值的组合:0
:默认值,没有特殊标志。MSG_PEEK
:查看数据但不从缓冲区中移除。MSG_WAITALL
:等待所有数据到达后再返回。MSG_DONTWAIT
:非阻塞接收,如果没有数据可读,立即返回。MSG_OOB
:接收带外数据。
函数返回值
- 成功时,返回实际接收的字节数。
- 连接关闭时,返回 0。
- 失败时,返回 -1,并设置
errno
以指示错误类型。
连接的关闭
套接字在Linux系统中已经被抽象成为文件,所以可以使用close(文件描述符)的方式断开连接。通常情况下,由客户端进行连接的断开,当客户端进行close操作后,会激活四次挥手操作,服务端的读写函数会返回响应的错误值,用于判断客户端已经断开。
推荐服务端接收到消息后显式调用close关闭连接套接字,因为这会显式回收资源。
UDP编程
UDP(用户数据报协议)是一种简单的传输层协议,它的设计初衷是简单高效,主要特点如下:
- 无连接:UDP是无连接的协议,这意味着在发送数据之前不需要建立连接。每个数据包(称为数据报)都是独立的。
- 不可靠传输:UDP不保证数据包的送达顺序,也不保证数据包的送达。数据包可能会丢失、重复或乱序。
- 轻量级:由于UDP不需要建立和维护连接,它的开销比TCP小,适用于需要快速传输数据的场景。
- 无流量控制和拥塞控制:UDP没有TCP那样的流量控制和拥塞控制机制,因此在网络拥塞时,UDP的数据包可能会被丢弃。
UDP适用于以下场景:
- 实时应用:如视频会议、在线游戏和实时音频传输。
- 简单查询:如DNS查询。
- 广播和多播:如网络发现协议。
尽管UDP有其局限性,但在需要快速传输且对可靠性要求不高的场景中,它是一个非常有效的选择。
连接与关闭
UDP不需要经历TCP的三次握手和挥手,它只需要在套接字对象生成后,使用发送数据即可。同理关闭也不需要经历四次挥手。
数据发送与接收
sendto函数
函数原型
#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);
函数参数
sockfd
:套接字描述符,表示要发送数据的套接字。msg
:指向要发送的数据的指针。len
:要发送的数据的长度(字节数)。flags
:发送标志,通常设置为0。to
:指向目标地址的指针,包含目标IP地址和端口号。tolen
:目标地址的长度。
函数返回值
- 发送成功返回实际发送出去的字节数量。
- 发送失败返回-1。
示例
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
int main() {
int sockfd;
struct sockaddr_in servaddr;
// 创建UDP套接字
sockfd = socket(AF_INET, SOCK_DGRAM, 0);
// 设置目标地址
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(8080);
inet_pton(AF_INET, "127.0.0.1", &servaddr.sin_addr);
// 要发送的数据
const char *message = "Hello, UDP!";
// 发送数据
sendto(sockfd, message, strlen(message), 0, (const struct sockaddr *)&servaddr, sizeof(servaddr));
// 关闭套接字
close(sockfd);
return 0;
}
recvfrom函数
函数原型
#include <sys/types.h>
#include <sys/socket.h>
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,struct sockaddr *src_addr, socklen_t *addrlen);
函数参数
sockfd
:套接字描述符,表示要接收数据的套接字。buf
:指向用于存放接收到的数据的缓冲区的指针。len
:缓冲区的大小(以字节为单位)。flags
:控制接收行为的标志,通常设置为0。src_addr
:指向sockaddr
结构的指针,用于保存发送数据的源地址。addrlen
:一个值-结果参数,开始时应设置为src_addr
缓冲区的大小,返回时会被修改为实际地址的长度(以字节为单位)。
函数返回值
- recvfrom函数的返回值是接收到的字节数。如果接收成功,函数返回接收到的字节数。
- 如果接收失败,函数返回-1,并且错误原因会存储在errno中。
示例
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
int main() {
int sockfd;
struct sockaddr_in servaddr, cliaddr;
char buffer[1024];
socklen_t len;
int n;
// 创建UDP套接字
sockfd = socket(AF_INET, SOCK_DGRAM, 0);
// 设置服务器地址
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = INADDR_ANY;
servaddr.sin_port = htons(8080);
// 绑定套接字
bind(sockfd, (const struct sockaddr *)&servaddr, sizeof(servaddr));
// 接收数据
len = sizeof(cliaddr);
n = recvfrom(sockfd, (char *)buffer, 1024, 0, (struct sockaddr *)&cliaddr, &len);
buffer[n] = '\0';
printf("Client : %s\n", buffer);
// 关闭套接字
close(sockfd);
return 0;
}
高级主题
IO多路复用
前置
在实际的服务器开发中,往往需要考虑并发情况。为了应对这个情况,不断迭代出了新的技术,所有的新技术并不是一蹴而就的,而是在前人的基础上不断创新。我的风格是知其然要知其所以然,知识不能硬灌,所以这个板块依旧采用跟随技术迭代的方式讲解。
在了解IO复用之前,需要首先探讨最初的想法。假设你需要做一个高并发的服务器。那么首先怎么想呢?
我给每一个客户端创建一个进程,一对一VIP服务,完美结局了并发问题。这确实是一个好方法,但是问题是服务器的资源是有限的,进程的开辟与切换需要消耗很大的资源。如果用户一多,服务器直接卡死。
于是你开始寻找更好的想法,如果进程切换的资源开销大,那么试试线程如何呢?
于是试着给每一个客户端开辟一个线程,相当于用将5星服务员换成了2星服务员,但是还是完美的解决了问题,但是随着服务器客户端访问人数的增多,终于用户并发到达了十万级别,终于资源再次耗尽,服务器还是卡死了,因为即便线程的资源远远小于进程,海量的线程累加的资源依旧是不可小觑的。
那么无脑扩展线程的方法不能解决,那么换个角度想,提高线程质量而不是提高线程数量?
于是你仔细的分析了单线程中影响效率的方面,然后你发现当你使用recv
函数的时候,由于指定客户端没有发送消息,那么线程会在这里阻塞。那么我是否可以使用非阻塞的recv
呢?但是在尝试之后,你发现高频率的recv
造成了高负担的系统调用,尤其在用户数量开始增多后,高负担的系统调用极大增加了CPU的负担。
那么是否有办法让客户端出现请求的时候,通知我进行recv
呢?这样极大减少了系统调用的负担,极大提高线程利用效率,并且可以更好应对高并发场景。
于是你想到了将所有的文件描述符都给内核,让内核帮助你监听,当哪个客户端出现消息,那么就告诉你处理哪个客户端,由此,以select
、poll
、epoll
这样的API函数诞生了。在保证处理时间不要过长的前提下,对线程的利用到达了极致,有效提高了服务器的并发能力。
情景模拟在这里就停止了,事实上后续还有技术迭代,比如多线程与IO多路复用技术结合,这是为了应对新的问题——所有的并发都压在一个线程身上,你的32核处理器中,一个核心快要算炸了,另外31个核还在摸鱼,所谓”一核有难,多核围观”。不过这个不在本文章的讨论范围内。
那么接下来就开始讲解提高线程利用率的IO复用技术,以下提到的策略主要有select函数、poll函数以及epoll函数集合
select函数
select
语句用于在一组文件描述符上进行多路复用,以便能够同时监视多个文件描述符是否有数据可读、可写或异常等事件发生。虽然该函数的作用对象是文件,但是通常使用在网络中。
而网络上常见的异常事件比如
- 数据外带:带外数据(Out-of-band data) 是指在TCP连接中传输的紧急数据。它通常用于传递重要的、需要优先处理的信息。带外数据通过现有的连接传输,但具有更高的优先级。TCP协议通过设置URG(紧急)标志和紧急指针来标识带外数据
- 条件错误:指在文件描述符上发生的各种错误情况。例如:
- 连接重置(Connection reset):当对端关闭连接时,可能会触发错误条件。
- 连接拒绝(Connection refused):当尝试连接到一个没有监听的端口时,可能会触发错误条件。
- 网络不可达(Network unreachable):当网络不可达时,可能会触发错误条件。
它可以设置一个等待时间,在指定时间对文件事件进行监听,如果超时没有任何事件发生,函数会立即返回,如果在指定时间内有事件发生,则立刻返回。
简单来说,select可以在指定时间里(也可以设置为永久阻塞)监听一个文件集合的是否有被其他程序申请对某一个或者多个文件进行读写,或者文件出现异常。
不过select函数在默认情况下最多能同时监听1024个文件(如果想修改这个限制,必须要去Linux源代码修改#define FD_SETSIZE 1024
这个代码,将后面的值修改自己想要的值后进行源代码编译),注意,Linux使用位图映射文件描述符,这1024个文件实际上是一个总数为128个字节的数据,当这些字节横向排列成一行,那么从左往右数,分别是第0个位,第1个位,第2个位… 那么就意味着可以使用这种方法代表文件,比如文件描述符0,文件描述符1,文件描述符2。这种方案大量节省了内存,并且将选中一位就能代表一个文件,速度也能更快。但是0 、1、2分别对应标准输入(stdin)、标准输出(stdout)和标准错误(stderr),这些是由系统保留和使用的。
函数原型
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds,fd_set *exceptfds, struct timeval *timeout);
函数参数
nfds
:监视的文件描述符数量,通常是所有文件描述符中最大值加一。这个很好理解,因为文件描述符是从0开始的,那么总数就是描述符加1,不过不要超过最大监视数量,否则可能会引发未定义的问题。readfds
:监视可读事件的文件描述符集合。writefds
:监视可写事件的文件描述符集合。exceptfds
:监视异常事件的文件描述符集合。timeout
:超时时间,可以指定NULL
表示无限等待。
先谈一谈fd_set,它是一个巨大的连续的数组,所以对数组中指定的位操作有点麻烦,所以系统提供了一些宏来操作fd_set
,包括:
FD_ZERO(fd_set *set):
:将fd_set
变量清零,使集合中不含任何文件描述符。FD_SET(int fd, fd_set *set)
:将文件描述符fd
加入到fd_set
集合中。FD_CLR(int fd, fd_set *set)
:将文件描述符fd
从fd_set
集合中移除。FD_ISSET(int fd, fd_set *set)
:检查文件描述符fd
是否在fd_set
集合中,如果在则返回非零值,只有这个宏函数有返回值,上述三个是没有的。
它的使用方法比较特别,拿readfds
举例,这个传入参数是通知内核哪些文件描述符是需要被监控的,然后你要将这个参数中对应的位置设置为1,注意,可以一次性写入多个需要监控的文件。而内核发现读取信号后,它会立即返回,而传入的参数会被修改,在信号发生的文件描述符的位置置1,但是会完全覆盖之前的值。
简单来说,就是内核记住了你要监控的文件描述符,返回的结果标记了哪些信号被触发。至于为什么要内核监听,是因为避免频繁的系统调用会造成很大的资源开销,一次性交给内核,就避免了系统在内核态与用户态之间反切换。
举一个例子:为了方便就用1个字节作为演示(实际上默认情况有1024个字节)。初始的参数readfd
是00000000
,现在你希望检查文件描述符3
和文件描述符6
是否出现了可读信号,于是这个字节被设置为了00010010
。然后将这个参数放入了函数中,此时文件描述符3
出现了可读信号,但是文件描述符6
没有出现可读信号,函数立即返回,而你传入的参数readfd
会被修改为00010000
。此时你只需要判断自己输入的文件描述符位置是不是1,就可以知道是否发生了可读信号。
然后你分析了文件描述符3
和文件描述符6
对应的位置,得出结论,文件描述符3
发生了信号,而文件描述符6
没有。但是此时还有另外一个问题,那就是你的原参数已经被改变了,所以下一次进行select函数
之前,必须要对参数进行重新设置。
接下来讲讲timeout
参数,实际上它是一个结构体:
struct timeval {
long tv_sec; // 秒
long tv_usec; // 微秒
};
你可以通过这个参数设置等待时间,不过要注意的是,必要初始化,否则随机的初值可能引发时间的干扰。
函数返回值
- 返回值为正数:表示有多少个文件描述符已经就绪,可以进行读、写或有异常事件。
- 返回值为 0:表示在指定的超时时间内没有任何文件描述符就绪。
- 返回值为 -1:表示发生了错误,此时可以通过
errno
来获取具体的错误信息。
解析
需要补充的是,内核并不会直接使用你传入的fd_set参数,它会完整的复制一份这个参数,即便你没有使用到它的上限。
接下来内核会进行监控,并且根据实际情况按照当对应的文件描述符出现指定信号,就会对fd_set参数修改,这个时候修改是内核中的参数,然后修改完成之后,内核会将新的参数从内核中复制到传入的fd_set参数中。
为了知道哪些文件出现了信号,开发者需要遍历自己所有录入过的文件,然后确定是否产生了信号。
服务器会将select函数
放在while
中。那么纵观全局,整个操作就会涉及到了多次初始化、内核与用户态直接到数据复制,以及为了获取信号,开发者不得不在所有文件中反复遍历。而且select的监控存在上限。
所以可以得出结论:这个函数适用于文件较少的情况。
整体总结select函数的执行过程
- 用户将希望关注的文件描述符设置进入
fd_set
参数中。 - 内核将参数完整的复制到内核中,并阻塞
select
函数,此时内核将监听对应缓冲区是否出现信号,内核采用的是无差别高速遍历所有的文件描述符,直到最大的文件描述符,而且不管这个描述符是否希望被开发者监听。比如只希望监听3和9,但是内核实际上会监听0-9的所有文件描述。 - 出现信号后,内核将结果从内核拷贝到参数中,并返回出现了就绪信号的总数。
- 用户根据返回结果进行轮询,确认哪些文件描述符出现了信号。然后重新设置文件描述符并在此调用
select
,因为fd_set
参数无法重用
示例
#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <string.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/select.h>
int main(){
int sock_id = socket(AF_INET, SOCK_STREAM, 0);
if(sock_id == -1){
perror("套接字生成出错");
return 0;
}
struct sockaddr_in addr;
memset(&addr,0,sizeof(struct sockaddr_in));
addr.sin_family = AF_INET;
addr.sin_port = htons(8888);
addr.sin_addr.s_addr = inet_addr("127.0.0.1");
if(bind(sock_id,(struct sockaddr*)&addr,sizeof(addr))){
perror("套接字绑定出错");
return 0;
}
if(listen(sock_id,5)){
perror("转化为监听套接字失败");
return 0;
}
//定义一个缓冲区
char text[50];
//定义一个fd_set并将监听套接字放入其中
fd_set readfds;
FD_ZERO(&readfds);
FD_SET(sock_id, &readfds);
//定义一个最大文件描述符
int max_fd = sock_id;
//定义一个副本,作为每次调用后恢复的参考
fd_set readfds_source = readfds;
while(1){
printf("正在监听客户端请求\n");
//select函数阻塞,获取出现的文件描述符出现信号的个数
int r_count = select(max_fd + 1,&readfds,NULL,NULL,NULL);
//判断监听套接字是否出现可读
if(FD_ISSET(sock_id,&readfds)){
//如果可读就意味着出现了连接请求
//进行连接
int afd = accept(sock_id,NULL,NULL);
if(afd != -1){
FD_SET(afd, &readfds_source);
max_fd = (afd > max_fd ? afd : max_fd);
}
}
//检查连接套接字
for(int i = 0;i < max_fd + 1;i++){
//如果这个值不是监听套接字,并且它被select检测到
if( i != sock_id && FD_ISSET(i,&readfds)){
int r_recv = recv(i,text,sizeof(text),MSG_NOSIGNAL);
if(r_recv == -1){
perror("recv函数出现错误");
return 0;
}
if( r_recv == 0){
//如果这个连接套接字断开连接,那么就从参数中剔除
FD_CLR(i, &readfds_source);
}
if( r_recv > 0){
printf(" >>>客户端发送信息:%s\n",text);
}
}
}
//恢复readfds参数
readfds = readfds_source;
}
}
运行结果
客户端连接后发送了一个[ hello ]
正在监听客户端请求
>>>客户端发送信息:hello
poll函数
pool
函数与select
函数类似,都是用于在一组文件描述符上进行多路复用,以便能够同时监视多个文件描述符是否有数据可读、可写或异常等事件发生。同时也广泛运用在网络编程中。
依旧使用优化的思想,在select函数中,遇到了最主要的问题就是文件描述符上限的限制,如果要更改上限必须要重新修改源代码并且重新编译,显然是一个非常糟糕的决定,于是poll函数改进了这一点,消除了上限限制,直接使用文件描述符本身。
为了应对设置的描述符会被重置,所以将这个参数设计为了结构体,并且返回值和预期值分开,避免了反复复制消耗的时间和冗余的代码。
函数原型
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
函数参数
fds
: 指向一个pollfd
结构体数组的指针,每个数组元素都是一个struct pollfd
结构,用于指定测试某个给定的文件描述符的条件。nfds
: 数组fds
的最大有效数的下标的编号+1。timeout
: 等待的毫秒数。如果为负值,poll
将无限期地等待。如果为0,则表示不等待
参数fds结构体
struct pollfd {
int fd; // 文件描述符
short events; // 等待的事件
short revents; // 实际发生的事件
};
fd:
- 这是你想要监视的文件描述符。例如,可以是一个套接字、一个文件描述符或标准输入输出等。
- 如果你不想监视某个文件描述符,可以将其设置为负值。
events: 这是一个位掩码,用于指定你想要监视的事件类型。你可以通过按位或操作符(|
)组合多个事件类型,常用的事件类型包括:
- POLLIN: 表示文件描述符可以读取数据(包括普通数据和优先数据)。
- POLLRDNORM: 表示文件描述符可以读取普通数据。
- POLLRDBAND: 表示文件描述符可以读取优先数据。
- POLLPRI: 表示文件描述符有紧急数据可读。
- POLLOUT: 表示文件描述符可以写入数据。
- POLLWRNORM: 表示文件描述符可以写入普通数据。
- POLLWRBAND: 表示文件描述符可以写入优先数据。
以上最常用的是POLLIN
、POLLOUT
两种
revents: 这是一个位掩码,用于返回实际发生的事件类型,poll
函数在返回时会设置这个字段,以指示哪些事件实际发生了,你可以检查这个字段来确定哪些事件发生了。常用的返回事件类型包括
- POLLIN: 表示文件描述符可以读取数据(包括普通数据和优先数据)。
- POLLRDNORM: 表示文件描述符可以读取普通数据。
- POLLRDBAND: 表示文件描述符可以读取优先数据。
- POLLPRI: 表示文件描述符有紧急数据可读。
- POLLOUT: 表示文件描述符可以写入数据。(注意是检查是否可写,就是写缓冲区是否满了,而不是是否写入)
- POLLWRNORM: 表示文件描述符可以写入普通数据。
- POLLWRBAND: 表示文件描述符可以写入优先数据。
- POLLERR: 表示文件描述符发生错误。
- POLLHUP: 表示文件描述符挂起。
- POLLNVAL: 表示文件描述符无效。
函数返回值
- 成功时,返回
revents
域不为0的文件描述符个数。 - 超时或无事件发生时,返回0。
- 失败时,返回-1,并设置
errno
。
解析
这个函数的诞生在很大程度上修改了select函数的不足之处,但是却出现了一些其他的问题,首当其冲就是这个函数不支持windows系统,跨平台性降低。
在全局视角下,这个函数在依旧存在局限性,它同样需要内核完整复制需要使用到的结构体数组,虽然不是将所有的结构体复制,但是在结构体本体的空间较大,而且在返回的时候也需要经历一轮复制,从内核态转移到用户态。epoll的本质是一个链表,它依旧无法拜托线性遍历的诅咒,开发者需要对整个有效的部分的结构体进行完整的遍历,获取到哪些值被激活,对于结果遍历依旧需要大量的时间。
事实上这个函数用的比较少,因为它像是发展过程中一个进化不全的产物,而且无法跨winodws这样的主流平台,使用更广阔的是后续的epoll
函数
总得来说,这个函数对select函数的部分缺点进行了改进,相对select在处理规模更大的并发下有良好的适应,但是在巨大规模的并发下,依旧和select一样效率会变得底下。
整体总结poll函数的执行过程
- 用户设置结构体数组,并将文件描述符写入其中
- 内核会将用户设置的有效结构体数组完整复制到内核中,但是在内核中以链表的形式存储。然后让poll函数阻塞。
- 内核以轮询的方式监听结构体表,当发生信号时,它将整个表从内核态完整的复制到用户态,返回出现信号的数量,并解除阻塞。
- 用户需要使用轮询的方式寻找触发信号的文件描述符。
示例
#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <string.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <poll.h>
int main(){
int sock_id = socket(AF_INET, SOCK_STREAM, 0);
if(sock_id == -1){
perror("套接字生成出错");
return 0;
}
struct sockaddr_in addr;
memset(&addr,0,sizeof(struct sockaddr_in));
addr.sin_family = AF_INET;
addr.sin_port = htons(8888);
addr.sin_addr.s_addr = inet_addr("127.0.0.1");
if(bind(sock_id,(struct sockaddr*)&addr,sizeof(addr))){
perror("套接字绑定出错");
return 0;
}
if(listen(sock_id,5)){
perror("转化为监听套接字失败");
return 0;
}
//定义一个缓冲区
char text[50];
//定义描述符数组
struct pollfd fds[1024];
//初始化所有的描述符
for(int i = 0;i<1024;i++){
fds[i].fd = -1;//初始化为这个值是因为负数属于不合法文件描述符
fds[i].events = POLLIN;//设置监听输入情况
}
//将监听套接字写入
fds[0].fd = sock_id;
//初始化最大下标
int max_id = 0;
while(1){
printf("正在监听客户端请求\n");
//poll函数阻塞,获取出现的文件描述符出现信号的个数
int r_count = poll(fds,max_id+1,-1);
if(r_count == -1 ){
perror("poll函数出错");
return 0;
}
printf("正在检查是否出现新连接请求\n");
//检查是否出现了新的连接请求
if(fds[0].revents & POLLIN){
int afd = accept(sock_id,NULL,NULL);
if(afd != -1){
//找一个空位写入
for(int i = 1;i<1024;i++){
if(fds[i].fd == -1){
fds[i].fd = afd;
max_id = (i > max_id ? i : max_id);
break;
}
}
}
printf("maxid = %d fds = %d\n",max_id,fds[max_id].fd);
}
printf("正在检查是否有客户端发送消息\n");
//检查连接套接字
for(int i = 1;i < max_id + 1;i++){
//如果这个值不是监听套接字,并且它被select检测到
if(fds[i].fd != -1 && (fds[i].revents & POLLIN)){
int r_recv = recv(fds[i].fd,text,sizeof(text),MSG_NOSIGNAL);
if(r_recv == -1){
perror("recv函数出现错误");
return 0;
}
if( r_recv == 0){
//如果这个连接套接字断开连接,那么就从参数中剔除
fds[i].fd = -1;
}
if( r_recv > 0){
printf(" >>>客户端发送信息:%s\n",text);
}
}
}
}
}
执行结果
客户端连接后发送了一个[ hello ]
正在监听客户端请求
>>>客户端发送信息:hello
epoll函数集合
epoll
是Linux内核提供的一种高效的I/O多路复用机制,相比于select
和poll
,epoll
在处理大量文件描述符时性能更好。
与select
和poll
系统调用不同,epoll
是事件驱动型操作。
它是继poll
函数之后迭代出的一个高性能函数集合,它并不是一个函数。接下来先看看函数集合中的具体还函数,这个函数集合的解析请按这个(–> 跳转),
epoll_create
函数
该函数的作用是创建一个epoll实例,并返回这个实例的文件描述符。
函数原型
#include <sys/epoll.h>
int epoll_create(int size);
函数参数
- int size:这个参数用于指定
epoll
实例建议可以监视的文件描述符的数量上限。这仅仅是一个建议,因为后续操作系统会更具需要动态调整。注意:这仅仅是Linux早期版本size参数具有该功能,从Linux 2.6.8这个参数并不起作用,操作系统完全忽略了该参数,该参数成为了低版本内核兼容的产物,属于历史遗留。但是它的值必须要大于0。
不过在现代编程编程中,存在一个改进版本的实例创建函数:epoll_create1(int flags)
函数。同时也是更为推荐使用的函数
该函数的参数如下:
0
:不设置任何标志,就和上述的epoll_create(int size)
作用一样。EPOLL_CLOEXEC
:在子进程中自动关闭 epoll 文件描述符。这对于避免文件描述符泄漏非常有用。这段话比较难以理解,通俗点讲,当一个进程创建子进程的情况下,子进程就复制了之前父进程执行过的代码,以及变量中的值。也就说,子进程可以直接访问父进程创建的epoll实例,但是两个进程访问同一个实例会发生什么呢?举几个例子:(1)子进程关闭了某个文件描述符,而后父进程访问这个被关闭的文件描述符。(2)子进程和父进程同时访问同一个文件描述符。(3)也许子进程不需要这些实例,那么保存在实例中的文件描述符可能因为没有被子进程close而导致描述符泄露。显然这些情况站在父进程的角度看,导致程序出现异常甚至崩溃。此时EPOLL_CLOEXEC
参数可以让子进程在创建的时候,面向子进程关闭epoll实例,那么子进程无法访问到父进程的epoll,从而实现更为安全的控制。(–>示例跳转)
它的返回值与epoll_create(int size)一致。
函数返回值
- 成功时,返回一个指向新创建的
epoll
实例的文件描述符。 - 失败时,返回-1,并设置
errno
以指示错误类型
示例
epoll_create1(int flags)的EPOLL_CLOEXEC
参数效果。
#include <sys/epoll.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <sys/types.h>
int main() {
// 创建 epoll 文件描述符,并设置 EPOLL_CLOEXEC 参数
int epoll_fd = epoll_create1(EPOLL_CLOEXEC);
if (epoll_fd == -1) {
perror("epoll实例生成失败");
return 1;
}
pid_t pid = fork();
if (pid == -1) {
perror("子进程创建失败");
close(epoll_fd);
return 1;
}
if (pid == 0) { // 子进程
// 尝试访问父进程的 epoll 文件描述符,监控标准输入
if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, STDIN_FILENO, NULL) == -1) {
perror("在子进程使用了epoll_ctl出现错误");
} else {
printf("子进程成功访问epoll实例\n");
}
exit(0);
} else { // 父进程
// 等待子进程结束
wait(NULL);
printf("子进程结束\n");
}
// 关闭 epoll 文件描述符
close(epoll_fd);
return 0;
}
执行结果
在子进程使用了epoll_ctl出现错误: Bad address
子进程结束
epoll_ctl
函数
该函数的作用是操作epoll实例,不过本质上是对epoll实例中的套接字进行管理。函数提供了三种操作方式:增、删、改。
函数原型
#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
函数参数
- epfd:由
epoll_create
返回的epoll
实例的文件描述符。 - op:操作类型,可以是以下三个宏之一:
EPOLL_CTL_ADD
:将新的文件描述符添加到epoll
实例中。如果反复添加同一个文件描述符会导致函数报错EPOLL_CTL_MOD
:修改已经注册的文件描述符的事件。EPOLL_CTL_DEL
:从epoll
实例中删除文件描述符。
- fd:要操作的文件描述符。
- event:指向
epoll_event
结构体的指针,用于描述要监视的事件。
epoll_event
结构体:
struct epoll_event {
uint32_t events; // 事件类型
epoll_data_t data; // 用户数据
};
其中 events常用事件类型有以下选项:
- EPOLLIN:表示对应的文件描述符可以读(包括对端SOCKET正常关闭)。
- EPOLLOUT:表示对应的文件描述符可以写。(注意是检查是否可写,就是写缓冲区是否满了,而不是是否写入)
- EPOLLPRI:表示对应的文件描述符有紧急的数据可读(带外数据)。
- EPOLLERR:表示对应的文件描述符发生错误。
- EPOLLHUP:表示对应的文件描述符被挂断。
- EPOLLET:将
epoll
设为边缘触发(Edge Triggered)模式。 - EPOLLONESHOT:只监听一次事件,当监听完这次事件后,如果还需要继续监听这个文件描述符,需要再次将其添加到
epoll
实例中。
其中最常用的是EPOLLIN
和EPOLLOUT
而data参数是一个联合体,但是要注意的是,内核不会对data参数也就是用户数据进行任何操作,它会原封不动返回给开发者,开发者必须自己负责传入的数据的是否被自己解析,该参数定义如下:
typedef union epoll_data {
void *ptr; //可以存储一个指向用户自定义数据结构的指针。
int fd;//可以存储一个文件描述符。
uint32_t u32;//可以存储一个 32 位的整数。
uint64_t u64;//可以存储一个 64 位的整数。
} epoll_data_t;
函数返回值
- 成功时返回0。
- 失败时返回-1,并设置
errno
以指示错误类型。
示例1-1
增加操作
// 创建 epoll 实例
int epfd = epoll_create(10); //建议实例描述符上限为10,实际上这个值会被忽略
if (epfd == -1) {
return EXIT_FAILURE;
}
struct epoll_event ev = {
.events = EPOLLIN, // 监听读事件
.data.fd = sock_fd // 将套接字文件描述符存储在 data 中
};
// 操作函数赋值
if (epoll_ctl(epfd, EPOLL_CTL_ADD, sock_fd, &ev) == -1) {
perror("epoll_ctl: EPOLL_CTL_ADD");
close(epfd);
return EXIT_FAILURE;
}
// 其他代码...
close(epfd);
修改操作
struct epoll_event ev;
ev.events = EPOLLOUT; // 修改为监听写事件
ev.data.fd = sockfd; // 仍然是同一个套接字
if (epoll_ctl(epfd, EPOLL_CTL_MOD, sockfd, &ev) == -1) {
perror("epoll_ctl: EPOLL_CTL_MOD");
exit(EXIT_FAILURE);
}
删除操作
if (epoll_ctl(epfd, EPOLL_CTL_DEL, sockfd, NULL) == -1) {
perror("epoll_ctl: EPOLL_CTL_DEL");
exit(EXIT_FAILURE);
}
epoll_wait
函数
- 高效性:
epoll_wait
可以同时监视大量文件描述符,适用于高并发场景。 - 灵活性:支持多种事件类型,如可读、可写、错误等。
- 可扩展性:可以动态添加或删除监视的文件描述符。
函数原型
#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);
函数参数
epfd
:由epoll_create
或epoll_create1
创建的 epoll 文件描述符。events
:指向epoll_event
结构体数组的指针,用于返回发生的事件。maxevents
:期望捕获的事件的最大数量,必须大于 0。timeout
:等待事件发生的超时时间,单位为毫秒。取值为:-1
:无限等待,直到有事件发生。0
:立即返回,不等待。- 大于
0
:等待指定的毫秒数。
函数返回值
- 成功时,返回发生的事件数量。
- 超时时,返回
0
。 - 失败时,返回
-1
并设置errno
以指示错误。
综合示例1.1
#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <string.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <sys/epoll.h>
int main(){
int sock_id = socket(AF_INET, SOCK_STREAM, 0);
if(sock_id == -1){
perror("套接字生成出错");
return 0;
}
struct sockaddr_in addr;
memset(&addr,0,sizeof(struct sockaddr_in));
addr.sin_family = AF_INET;
addr.sin_port = htons(8888);
addr.sin_addr.s_addr = inet_addr("127.0.0.1");
if(bind(sock_id,(struct sockaddr*)&addr,sizeof(addr))){
perror("套接字绑定出错");
return 0;
}
if(listen(sock_id,5)){
perror("转化为监听套接字失败");
return 0;
}
//定义一个缓冲区
char text[50];
//初始化一个epoll实例
int epfd = epoll_create(10);//该参数被忽略,只需大于0即可
if(epfd == -1){
perror("epoll实例创建失败");
close(sock_id);
return 0;
}
//设置事件,将将监听套接字加入epoll实例中。
struct epoll_event epevent = {
.events = EPOLLIN, //监听可读事件
.data.fd = sock_id //用户数据
};
if(epoll_ctl(epfd,EPOLL_CTL_ADD,epevent.data.fd,&epevent)){
perror("监听套接字添加失败,epool_ctl函数执行出错");
close(sock_id);
close(epfd);
return 0;
}
//创建一个事件数组用于存储返回的结果
struct epoll_event reepevent[1024];
//定义数量时间数组的总数量
int num = sizeof(reepevent)/sizeof(reepevent[0]);
while(1){
printf("等待客户端信号\n");
//阻塞,等待信号,获取触发信号的总数
int event_count = epoll_wait(epfd,reepevent,num,-1);
//遍历触发信号的总数
for(int i = 0;i<event_count;++i){
//检查是否是监听套接字出现信号
if(reepevent[i].data.fd == sock_id){
printf("客户端发出连接请求\n");
//如果是监听套接字出现信号,那么就建立新的连接套接字
int fd = accept(sock_id,NULL,NULL);
if(fd == -1){
//函数执行出错
perror("连接套接字建立出错");
close(sock_id);
continue;
}
//建立完成,加入epoll实例中,事实上,虽然epoll_ctl需要的是一个指针,但是它会将其中的值从用户态复制到内核态,所以即使代码中的temp被销毁也不是影响到系统中保存的值。
struct epoll_event temp = {
.events = EPOLLIN,
.data.fd = fd
};
if(epoll_ctl(epfd,EPOLL_CTL_ADD,temp.data.fd,&temp) == -1){
perror("epoll实例添加失败");
close(fd);
continue;
}
}
//否则就是连接套接字出现了信号
else{
printf("客户端发来消息\n");
int r_recv = recv(reepevent[i].data.fd,text,sizeof(text),MSG_NOSIGNAL);
//检查函数执行是否出错
if(r_recv == -1){
perror("recv函数执行出错");
continue;
}
//检查对方是否断开连接
else if(r_recv == 0){
//从epoll中删除实例
if(epoll_ctl(epfd,EPOLL_CTL_DEL,reepevent[i].data.fd,NULL) == -1){
return 0;
}
close(reepevent[i].data.fd);
}
//处理客户端请求
else{
printf(" >>>客户端传来消息:[%s]\n",text);
}
}
}
}
}
执行结果1.1
等待客户端信号
客户端发出连接请求
等待客户端信号
客户端发来消息
>>>客户端传来消息:[hello]
等待客户端信号
epoll函数集合的解析
迭代升级
首先这个函数解决了select
函数和poll
函数应对高并发效率的主要问题,前面分析过,select
和epoll
在返回结果后,由于线性表的特性不得不遍历整个结果集合寻找产生信号的文件描述符的轮询机制,而epoll
采用了红黑树的结构,在面对高并发的场景迎刃有余。
并且它从内核态返回用户态的时候,不再需要复制整个表,而是将产生消息的文件描述符生成一个队列返回,节省了整体复制消耗的时间,开发者可以直接遍历这个队列,而队列中的每个元素都是需要处理的,没有任何冗余的处理。
同时考虑到了并发场景,这个函数集新加了一个新的特性,epoll函数集是线程安全的,比如你可以在调用epoll_ctl的同时调用epoll_wait。
epoll
提供了两种触发模式:边沿触发(Edge-Triggered, ET) 和 水平触发(Level-Triggered, LT)。这两种模式在处理事件时有不同的行为:
水平触发模式(Level-Triggered, LT)
水平触发模式是默认模式,简单来讲,就是当事件触发后比如读信号,如果传入有100个字节可读,但是你本轮循环只读了50个字节,那么在下一轮循环后,由于缓冲区还剩余部分字节,读信号会被再次触发。
这种模式的好处就像是一个非常贴心的助理,它会时刻提醒你没有完成的任务。这将使你的开发变得简单,不需要担心出现遗漏的问题。
但是缺点就是:重复的触发读信号事件,会消耗系统资源,相对来说效率比较低,这种策略也就是用效率换遍历。
边沿触发模式(Edge-Triggered, ET)
边沿触发模式需要显示声明,简单来讲,依旧用读信号举例,如果事件触发后,有100个字节需要读取,但是仅仅读取了50个字节,那么在下一轮循环中,即使缓冲区存在剩余的50个字节,也不会再触发信号,除非有新的字节传入。
这也就意味着它只会让传入的信号触发一次。开发者必须要在本次循环中完成缓冲区数据的读取。当然即使信号只会触发一次,但是缓冲区的数据并不会丢失,这也就可能让开发者解析数据的时候,造成数据干扰的情况。
不过有代价自然有相应的补偿,没有了相同事件反复触发,大大提高了运行效率。
片段示例
struct epoll_event event;
event.events = EPOLLIN | EPOLLET; // 设置边沿触发模式
event.data.fd = sock_id;
if (epoll_ctl(epfd, EPOLL_CTL_ADD, sock_id, &event) == -1) {
perror("epoll_ctl: sock_id");
exit(EXIT_FAILURE);
}
如果边沿触发想重新设置水平触发,需要将event.events = EPOLLIN | EPOLLET;
写成event.events = EPOLLIN;
然后用修改操作写入即可。
不过还要补充的一点是,这种模式通常和非阻塞IO进行搭配使用,因为阻塞型IO会导致数据为空时阻塞,这显然与提高效率的初衷相反。
其实从效率的角度说,也不单单只有epoll的边沿触发使用非阻塞IO,所有的IO复用函数都和非阻塞IO搭配比较合适,因为用户的数据长度不可能总是相同,而服务器缓冲区也不可能无限大,所以往往会进行多次读取,那么这种情况下就很难避免产生阻塞。
设置为非阻塞函数
#include <fcntl.h>
//将一个套接字转换为非阻塞型
int set_nonblocking(int fd) {
//获取当前传入套接字的类型
int flags = fcntl(fd, F_GETFL, 0);
if (flags == -1) {
perror("套接字信息获取失败");
return -1;
}
//给套接字添加非阻塞属性
flags |= O_NONBLOCK;
//将新的套接字信息写入套接字中
if (fcntl(fd, F_SETFL, flags) == -1) {
perror("套接字设置失败");
return -1;
}
return 0;
}
而非阻塞的函数通常可以使用错误值比如 if (errno == EAGAIN)
判断是否缓冲区为空。
整体总结epoll函数的执行过程
- 用户创意一个epoll实例
- 用户在合适的时候定义一个事件结构体,并交由内核从用户态拷贝到内核态,同时内核会为每一个文件描述符指定一个回调函数,用于之后的将就绪事件放入就绪链表中。
- 内核让epoll_wait中阻塞,内部维护一个双向链表结构体的就绪表,一棵红黑树用来存放文件描述符,一个等待队列存放阻塞进程。
- 当有信号出现时,对应的文件描述符会通过回调函数被放入就绪链表中,避免了不必要的轮询,然后内核检查阻塞队列中的进程,就绪的文件描述符属于该进程,那么该进程会被唤醒。
信号驱动IO
信号驱动I/O是一种基于信号的I/O模型,主要用于在数据准备好时通知进程进行I/O操作。以下是信号驱动I/O的工作原理和特点:
- 注册信号处理函数:进程首先需要通过系统调用
sigaction
向内核注册一个信号处理函数,并设置文件描述符为非阻塞模式。 - 等待信号:当文件描述符上有数据可读时,内核会发送一个信号(通常是SIGIO)通知进程。
- 处理信号:进程在信号处理函数中执行相应的I/O操作,如读取数据。
优点
- 非阻塞:进程在等待I/O事件时不会被阻塞,可以执行其他任务,提高了CPU利用率。
- 实时响应:信号到来后立即处理I/O事件,响应速度快。
缺点
- 复杂性:编程相对复杂,需要处理信号相关的问题。
- 频繁信号:当数据量较大时,信号频繁产生,可能会影响性能
信号驱动I/O主要用于UDP套接字。在UDP协议中,信号驱动I/O可以通过信号机制来处理I/O事件。当数据到达时,内核会发送一个信号(通常是SIGIO)通知进程进行I/O操作。
相比之下,信号驱动I/O在TCP套接字上几乎没有使用,因为TCP需要处理更多的事件,如连接建立、连接确认、数据确认和断开连接等,这些事件都会触发信号,增加了处理的复杂性。
sigaction函数
函数原型
#include <signal.h>
int sigaction(int signum, const struct sigaction *act,struct sigaction *oldact);
函数参数
signum
:指定信号,可以是任何有效的信号,除了SIGKILL
和SIGSTOP
。act
:如果非 NULL,则为signum
安装新的动作。oldact
:如果非 NULL,则保存之前的动作。
struct sigaction
结构
struct sigaction {
void (*sa_handler)(int);//指定信号处理函数,这是最常用的信号处理函数指针。它指向一个接受信号编号作为参数的函数
void (*sa_sigaction)(int, siginfo_t *, void *);//指定信号处理函数,但提供更多信息,当 sa_flags 中设置了 SA_SIGINFO 标志时,使用 sa_sigaction 而不是 sa_handler。它可以接收更多的参数,包括信号信息和上下文。
sigset_t sa_mask;
int sa_flags;
void (*sa_restorer)(void);//用于恢复信号处理程序的内部使用,这个字段通常由内核使用,用户代码一般不需要设置或使用它
};
sa_handler
:指定信号处理函数,可以是SIG_DFL
(默认动作)、SIG_IGN
(忽略信号)或指向信号处理函数的指针。sa_sigaction
:如果sa_flags
中指定了SA_SIGINFO
,则使用此字段代替sa_handler
。sa_mask
:指定在信号处理程序执行期间应被阻塞的信号。sa_flags
:修改信号行为的标志,可以是以下值的按位或:SA_NOCLDSTOP
:如果signum
是SIGCHLD
,则子进程停止时不接收通知。SA_NOCLDWAIT
:如果signum
是SIGCHLD
,则子进程终止时不变成僵尸进程。
在void (*sa_sigaction)(int, siginfo_t *, void *);
字段中,第一个参数代表收到的信号,第二个参数是一个结构体,该结构体定义如下:
typedef struct {
int si_signo; /* 信号编号 */
int si_errno; /* 与信号相关的错误码 */
int si_code; /* 信号的来源或原因 */
pid_t si_pid; /* 发送信号的进程 ID */
uid_t si_uid; /* 发送信号的用户 ID */
void *si_addr; /* 引发故障的内存地址 */
int si_status; /* 退出状态或信号 */
union sigval si_value; /* 信号携带的值 */
} siginfo_t;
第三个参数void *
指向的是一个ucontext_t结构体,这个结构体保存的是信号发生时的上下文信息(如寄存器状态)。
函数返回值
- 成功时:返回
0
。 - 失败时:返回
-1
,并设置errno
以指示错误类型。
示例
#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
#include <fcntl.h>
int listenfd;
volatile int read_flag = 0;
static char buf[256] = {0};
void do_sigio(int sig) {
struct sockaddr_in cli_addr;
int clifd, clilen;
read_flag = 1;
memset(buf, 0, sizeof(buf));
recvfrom(listenfd, buf, sizeof(buf), 0, (struct sockaddr *)&cli_addr, &clilen);
printf("Received message: %s\n", buf);
sendto(listenfd, "Reply", sizeof("Reply"), 0, (struct sockaddr *)&cli_addr, sizeof(cli_addr));
read_flag = 0;
}
int main(int argc, char *argv[]) {
struct sockaddr_in serv_addr;
listenfd = socket(AF_INET, SOCK_DGRAM, 0);
bzero((char *)&serv_addr, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(7779);
serv_addr.sin_addr.s_addr = INADDR_ANY;
struct sigaction sigio_action;
memset(&sigio_action, 0, sizeof(sigio_action));
sigio_action.sa_handler = do_sigio;
sigaction(SIGIO, &sigio_action, NULL);
fcntl(listenfd, F_SETOWN, getpid());
int flags = fcntl(listenfd, F_GETFL, 0);
flags |= O_ASYNC;
fcntl(listenfd, F_SETFL, flags);
bind(listenfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr));
while (1) {
sleep(2);
}
close(listenfd);
return 0;
}
异步IO
在Linux网络编程中,异步I/O(Asynchronous I/O)是一种高效的I/O模型。与同步I/O不同,异步I/O允许用户线程在发起I/O操作后立即返回并继续执行其他任务,而不需要等待I/O操作完成。
异步I/O的工作原理
- 发起请求:用户线程调用异步I/O函数(如
aio_read
或aio_write
)发起I/O操作。 - 立即返回:内核立即返回,表示I/O请求已成功发起,用户线程可以继续执行其他任务。
- 内核处理:内核在后台处理I/O操作,包括数据准备和数据拷贝。
- 通知完成:当I/O操作完成后,内核通过信号(如SIGIO)通知用户线程,用户线程可以处理完成的I/O操作
异步I/O的优点
- 高性能:由于用户线程在等待I/O操作时可以执行其他任务,异步I/O提高了系统的并发性和响应性。
- 非阻塞:I/O操作的两个阶段(发起请求和数据拷贝)都不会阻塞用户线程,内核自动完成这些操作
异步I/O在UDP和TCP两者中都有广泛的应用,主要因为它能够提高系统的并发性和响应速度。
在异步I/O模型中,I/O操作通常是非阻塞的。这意味着当进程发起I/O请求时,它不会被挂起等待I/O操作完成,而是立即返回,进程可以继续执行其他任务。异步I/O模型的特点是内核会在I/O操作完成后通知进程,这样进程可以在I/O操作完成时处理结果
aio_read函数
函数原型
#include <aio.h>
int aio_read(struct aiocb *aiocbp);
函数参数
aiocbp:指向aiocb
结构体的指针,该结构体包含了异步I/O操作的相关信息。
结构体定义:
struct aiocb {
int aio_fildes; // 文件描述符
off_t aio_offset; // 文件偏移量
volatile void *aio_buf; // 数据缓冲区
size_t aio_nbytes; // 要传输的字节数
int aio_reqprio; // 请求优先级
struct sigevent aio_sigevent; // 信号事件
int aio_lio_opcode; // 列表I/O操作码
};
成员说明
- aio_fildes:文件描述符,表示要进行异步I/O操作的文件或设备。
- aio_offset:文件偏移量,表示从文件的哪个位置开始读或写。
- aio_buf:指向数据缓冲区的指针,用于存储读入的数据或要写入的数据。
- aio_nbytes:要传输的字节数,表示读或写操作的字节数。
- aio_reqprio:请求优先级,通常设置为0。
- aio_sigevent:异步操作完成时的通知机制,可以设置为信号通知或线程回调。
- aio_lio_opcode:列表I/O操作码,用于批量I/O操作时指定操作类型(如读或写)
其中struct sigevent
定义如下:
struct sigevent {
int sigev_notify; // 通知类型
int sigev_signo; // 信号编号
union sigval sigev_value; // 信号值
void (*sigev_notify_function)(union sigval); // 通知函数 (SIGEV_THREAD)
pthread_attr_t *sigev_notify_attributes; // 通知属性
pid_t sigev_notify_thread_id; // 线程ID (Linux特有)
};
成员说明:
- sigev_notify:指定通知类型,可以是以下值之一:
SIGEV_NONE
:不进行任何通知。SIGEV_SIGNAL
:通过发送指定的信号(sigev_signo
)通知进程。SIGEV_THREAD
:通过调用指定的通知函数(sigev_notify_function
)通知进程,类似于启动一个新线程。- SIGEV_THREAD_ID:通知特定线程(Linux特有)
- sigev_signo:信号编号,当
SIGEV_NOTIFY
为SIGEV_SIGNAL
时使用。 - sigev_value:信号值,可以是整数或指针,用于传递附加信息。
- sigev_notify_function:通知函数,当
SIGEV_NOTIFY
为SIGEV_THREAD
时使用。 - sigev_notify_attributes:通知属性,用于设置新线程的属性。
- sigev_notify_thread_id:线程ID,当SIGEV_NOTIFY为SIGEV_THREAD_ID时使用(Linux特有)
一个简单的示例是
struct aiocb cb;
char buffer[1024];
memset(&cb, 0, sizeof(struct aiocb));
cb.aio_fildes = fd; // 文件描述符
cb.aio_buf = buffer; // 数据缓冲区
cb.aio_nbytes = sizeof(buffer); // 要读取的字节数
cb.aio_offset = 0; // 文件偏移量
cb.aio_sigevent.sigev_notify = SIGEV_NONE; // 不使用信号通知
函数返回值
成功时返回0,并将请求排队。
失败时返回-1,并设置errno以指示错误类型
aio_write函数
函数原型
#include <aio.h>
int aio_write(struct aiocb *aiocbp);
函数参数
aiocbp:指向aiocb
结构体的指针,该结构体包含了异步I/O操作的相关信息。
结构体定义:
struct aiocb {
int aio_fildes; // 文件描述符
off_t aio_offset; // 文件偏移量
volatile void *aio_buf; // 数据缓冲区
size_t aio_nbytes; // 要传输的字节数
int aio_reqprio; // 请求优先级
struct sigevent aio_sigevent; // 信号事件
int aio_lio_opcode; // 列表I/O操作码
};
函数返回值
成功时返回0,并将请求排队。
失败时返回-1,并设置errno以指示错误类型
aio_error函数
函数原型
#include <aio.h>
int aio_error(const struct aiocb *aiocbp);
函数参数
aiocbp:指向aiocb
结构体的指针,该结构体包含了异步I/O操作的相关信息。
结构体定义:
struct aiocb {
int aio_fildes; // 文件描述符
off_t aio_offset; // 文件偏移量
volatile void *aio_buf; // 数据缓冲区
size_t aio_nbytes; // 要传输的字节数
int aio_reqprio; // 请求优先级
struct sigevent aio_sigevent; // 信号事件
int aio_lio_opcode; // 列表I/O操作码
};
函数返回值
- 成功:返回0,表示异步I/O操作已成功完成。
- 失败:返回与同步I/O操作相同的错误状态(如
read
、write
和fsync
子例程中描述的错误状态)。
aio_return函数
函数原型
#include <aio.h>
ssize_t aio_return(struct aiocb *aiocbp);
函数参数
aiocbp:指向aiocb
结构体的指针,该结构体包含了异步I/O操作的相关信息。
结构体定义:
struct aiocb {
int aio_fildes; // 文件描述符
off_t aio_offset; // 文件偏移量
volatile void *aio_buf; // 数据缓冲区
size_t aio_nbytes; // 要传输的字节数
int aio_reqprio; // 请求优先级
struct sigevent aio_sigevent; // 信号事件
int aio_lio_opcode; // 列表I/O操作码
};
函数返回值
- 成功:返回与同步
read
、write
、fsync
或fdatasync
调用相同的值。 - 失败:返回-1,并设置
errno
以指示错误类型。
综合示例
#include <aio.h>
#include <arpa/inet.h>
#include <fcntl.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>
#define PORT 8080
#define BUFFER_SIZE 1024
void aio_completion_handler(sigval_t sigval) {
struct aiocb *req = (struct aiocb *)sigval.sival_ptr;
if (aio_error(req) == 0) {
ssize_t bytes_transferred = aio_return(req);
printf("Transferred %zd bytes\n", bytes_transferred);
} else {
perror("aio_error");
}
}
int main() {
int server_fd, new_socket;
struct sockaddr_in address;
int opt = 1;
int addrlen = sizeof(address);
char buffer[BUFFER_SIZE] = {0};
// 创建套接字
if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) {
perror("socket failed");
exit(EXIT_FAILURE);
}
// 绑定端口
if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt))) {
perror("setsockopt");
exit(EXIT_FAILURE);
}
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons(PORT);
if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
perror("bind failed");
exit(EXIT_FAILURE);
}
if (listen(server_fd, 3) < 0) {
perror("listen");
exit(EXIT_FAILURE);
}
if ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t *)&addrlen)) < 0) {
perror("accept");
exit(EXIT_FAILURE);
}
// 初始化aiocb结构体
struct aiocb cb;
memset(&cb, 0, sizeof(struct aiocb));
cb.aio_fildes = new_socket;
cb.aio_buf = buffer;
cb.aio_nbytes = BUFFER_SIZE;
cb.aio_offset = 0;
cb.aio_sigevent.sigev_notify = SIGEV_THREAD;
cb.aio_sigevent.sigev_notify_function = aio_completion_handler;
cb.aio_sigevent.sigev_notify_attributes = NULL;
cb.aio_sigevent.sigev_value.sival_ptr = &cb;
// 发起异步读操作
if (aio_read(&cb) == -1) {
perror("aio_read");
close(new_socket);
close(server_fd);
return 1;
}
// 等待异步读操作完成
while (aio_error(&cb) == EINPROGRESS) {
// 可以在这里执行其他任务
}
ssize_t bytes_read = aio_return(&cb);
if (bytes_read == -1) {
perror("aio_return");
} else {
printf("Read %zd bytes: %s\n", bytes_read, buffer);
}
// 发起异步写操作
const char *response = "Hello from server";
memset(&cb, 0, sizeof(struct aiocb));
cb.aio_fildes = new_socket;
cb.aio_buf = (void *)response;
cb.aio_nbytes = strlen(response);
cb.aio_offset = 0;
cb.aio_sigevent.sigev_notify = SIGEV_THREAD;
cb.aio_sigevent.sigev_notify_function = aio_completion_handler;
cb.aio_sigevent.sigev_notify_attributes = NULL;
cb.aio_sigevent.sigev_value.sival_ptr = &cb;
if (aio_write(&cb) == -1) {
perror("aio_write");
close(new_socket);
close(server_fd);
return 1;
}
// 等待异步写操作完成
while (aio_error(&cb) == EINPROGRESS) {
// 可以在这里执行其他任务
}
ssize_t bytes_written = aio_return(&cb);
if (bytes_written == -1) {
perror("aio_return");
} else {
printf("Written %zd bytes\n", bytes_written);
}
close(new_socket);
close(server_fd);
return 0;
}