1.套接字的超时控制
TCP和UDP这两种协议都要求通信双方创建socket套接字,尤其是TCP协议是面向连接的、可靠的、基于字节流的全双工通信,所以要求通信双方要建立连接之后才可以收发数据。流程如下:
首先客户端向服务器发起连接请求,然后服务器接受客户端的连接请求,此时完成TCP三次握手,表示成功建立连接。但是还存在两种情况:
服务器默认设置是阻塞模式,服务器运行之后如果一直没有客户端发起连接请求,此时服务器会阻塞在accept()函数的位置,这样会导致服务器程序无法继续向下执行。
把服务器的套接字设置为非阻塞模式,如果服务器没有监听到有客户端发起连接请求,则服务器的accept()函数会立刻返回。
这两种情况都过于绝对,所以Linux系统提供了超时机制避免这两种情况导致程序逻辑异常。
超时控制属于套接字文件的属性选项之一,需要调用setsockopt()函数进行设置,规则如下:
可以看到,当套接字设置为非阻塞模式并且设置了超时时间,超时时间内没有收发任何数据并且超时时间到达,则会立刻返回-1。如果超时时间设置为0,则操作永远不会超时。
另外,如果要设置超时时间,需要利用名称叫做struct timeval的结构体,该结构体定义在time.h头文件中。
可以看到结构体有2个成员,成员tv_sec指的是秒数,成员tv_usec指的是微秒数,如果服务器端设置了接收超时,并且假设超时时间为n秒,则有两种情况:
提示:如果服务器还未和客户端建立连接,并且服务器处于监听状态,则最多等待对端n秒。
提示:如果服务器已经和客户端建立连接,则服务器等待客户端发送数据的时间最多是n秒。
设计程序,要求服务器设置超时时间为10s,如果客户端10s之内没有发送数据,则服务器退出。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <errno.h>
#define PORT 8080
#define BUFFER_SIZE 1024
#define TIMEOUT_SECONDS 10 // 超时时间设置为10秒
int main() {
int server_fd, new_socket;
struct sockaddr_in address;
int addrlen = sizeof(address);
char buffer[BUFFER_SIZE] = {0};
struct timeval timeout;
// 1. 创建TCP套接字
if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) {
perror("套接字创建失败");
exit(EXIT_FAILURE);
}
// 2. 配置服务器地址
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons(PORT);
// 3. 绑定套接字到端口
if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
perror("绑定失败");
exit(EXIT_FAILURE);
}
// 4. 监听连接
if (listen(server_fd, 3) < 0) {
perror("监听失败");
exit(EXIT_FAILURE);
}
printf("服务器启动,监听端口 %d...\n", PORT);
printf("超时时间设置为 %d 秒,如果客户端%ds内无数据则退出\n",
TIMEOUT_SECONDS, TIMEOUT_SECONDS);
// 5. 接受客户端连接
if ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen)) < 0) {
perror("接受连接失败");
exit(EXIT_FAILURE);
}
printf("客户端 %s:%d 已连接\n",
inet_ntoa(address.sin_addr), ntohs(address.sin_port));
// 6. 设置接收超时时间
timeout.tv_sec = TIMEOUT_SECONDS; // 秒
timeout.tv_usec = 0; // 微秒
if (setsockopt(new_socket, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)) < 0) {
perror("设置超时失败");
close(new_socket);
exit(EXIT_FAILURE);
}
// 7. 等待接收客户端数据
printf("等待客户端发送数据...\n");
ssize_t valread = recv(new_socket, buffer, BUFFER_SIZE, 0);
if (valread < 0) {
// 检查是否是超时错误
if (errno == EAGAIN || errno == EWOULDBLOCK) {
printf("超时!客户端在%d秒内未发送数据\n", TIMEOUT_SECONDS);
} else {
perror("接收数据失败");
}
} else if (valread == 0) {
printf("客户端主动关闭了连接\n");
} else {
printf("收到客户端数据: %.*s\n", (int)valread, buffer);
}
// 8. 关闭连接
printf("服务器退出\n");
close(new_socket);
close(server_fd);
return 0;
}
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define PORT 8080
#define SERVER_IP "127.0.0.1" // 服务器IP地址
#define BUFFER_SIZE 1024
int main() {
int sock = 0;
struct sockaddr_in serv_addr;
char buffer[BUFFER_SIZE] = {0};
// 1. 创建TCP套接字
if ((sock = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
perror("套接字创建失败");
exit(EXIT_FAILURE);
}
// 2. 配置服务器地址
memset(&serv_addr, '0', sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(PORT);
// 转换服务器IP为二进制格式
if (inet_pton(AF_INET, SERVER_IP, &serv_addr.sin_addr) <= 0) {
perror("无效的服务器IP地址");
exit(EXIT_FAILURE);
}
// 3. 连接服务器
if (connect(sock, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) < 0) {
perror("连接服务器失败");
exit(EXIT_FAILURE);
}
printf("已成功连接到服务器 %s:%d\n", SERVER_IP, PORT);
printf("请输入要发送的数据(或等待超过10秒测试超时): ");
// 4. 读取用户输入并发送
if (fgets(buffer, BUFFER_SIZE, stdin) != NULL) {
send(sock, buffer, strlen(buffer), 0);
printf("数据已发送: %s\n", buffer);
} else {
printf("未输入任何数据\n");
}
// 5. 关闭连接
close(sock);
return 0;
}
2.服务器的调度策略
不管是基于UDP实现的服务器或者是基于TCP实现的服务器,都需要处理多个客户端请求。
2.1基于UDP实现
由于UDP协议不需要连接,因此服务端只需要一个套接字便可与任意个客户端通信,但默认的套接字是阻塞型的,这意味着当客户端没有发来消息的时候,服务端如果调用 recvfrom() 将会进入不限期阻塞而无法做别的事情。
2.2基于TCP实现
对于TCP服务器而言,每当有一个远端的客户端发起连接成功后,服务端这边都会新增一个已连接套接字与之匹对,那么随着客户端连接的越来越多,服务端要处理的套接字也随之增多,如何同时妥善处理这些套接字成为必须解决的问题。
这就涉及到服务器要采用何种策略对客户端进行调度,服务器的调度策略也被称为服务器IO模型,指的是当一个服务端网络程序需要同时处理多个套接字时,采取什么方案去妥善处理它们。
2.2.1非阻塞轮询
一种方案就是将所有的套接字都设置为非阻塞模式,这样就不用担心客户端不发出数据导致服务器端卡死的问题,但是非阻塞套接字也无法妥善地告知服务器数据何时到达,所以服务器需要不断地尝试读取客户端数据,这就是采用轮询方式实现。
由于非阻塞模式属于套接字的属性,而套接字在Linux系统属于文件,所以通过Linux提供了fcntl()函数可以设置或者获取套接字文件的属性。
注意:为了防止套接字文件的原有属性被破坏,所以一定要先用 F_GETFL 获取套接字已有属性,然后通过位或运算加上非阻塞属性 O_NONBLOCK,然后再用 F_SETFL 进行设定。
设计一个程序,要求创建UDP套接字,并设置UDP套接字为非阻塞模式,然后采用轮询方式来接收客户端信息。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <errno.h>
#define PORT 8080
#define BUFFER_SIZE 1024
#define POLL_INTERVAL 1 // 轮询间隔(秒)
int main() {
int sockfd;
struct sockaddr_in server_addr, client_addr;
socklen_t client_len = sizeof(client_addr);
char buffer[BUFFER_SIZE];
int flags;
// 1. 创建UDP套接字
if ((sockfd = socket(AF_INET, SOCK_DGRAM, 0)) < 0) {
perror("套接字创建失败");
exit(EXIT_FAILURE);
}
// 2. 设置套接字为非阻塞模式
if ((flags = fcntl(sockfd, F_GETFL, 0)) < 0) {
perror("获取文件状态标志失败");
close(sockfd);
exit(EXIT_FAILURE);
}
if (fcntl(sockfd, F_SETFL, flags | O_NONBLOCK) < 0) {
perror("设置非阻塞模式失败");
close(sockfd);
exit(EXIT_FAILURE);
}
// 3. 绑定端口
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = INADDR_ANY;
server_addr.sin_port = htons(PORT);
if (bind(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
perror("绑定失败");
close(sockfd);
exit(EXIT_FAILURE);
}
printf("非阻塞UDP服务器启动,监听端口 %d...\n", PORT);
printf("采用轮询方式接收数据,间隔 %d 秒\n", POLL_INTERVAL);
printf("按Ctrl+C退出程序\n");
// 4. 轮询方式接收数据
while (1) {
memset(buffer, 0, BUFFER_SIZE);
// 非阻塞接收数据
ssize_t recv_len = recvfrom(sockfd, buffer, BUFFER_SIZE, 0,
(struct sockaddr*)&client_addr, &client_len);
if (recv_len > 0) {
// 成功接收数据
printf("\n收到来自 %s:%d 的数据:\n",
inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));
printf("内容:%s\n", buffer);
printf("长度:%zd 字节\n", recv_len);
}
else if (recv_len < 0 && errno != EAGAIN && errno != EWOULDBLOCK) {
// 发生错误(非超时)
perror("接收数据出错");
}
// 若recv_len < 0且错误码为EAGAIN/EWOULDBLOCK,说明当前无数据,属于正常情况
// 轮询间隔
sleep(POLL_INTERVAL);
printf("."); // 打印轮询标记
fflush(stdout); // 立即刷新输出
}
// 5. 关闭套接字(实际不会执行到这里)
close(sockfd);
return 0;
}
2.2.2多任务并发
多任务并发模型就是利用多进程或者多线程来达到同时处理多个套接字的目的。一般而言,进程用于具有较完整逻辑块的整合。如果只是处理网络套接字的数据,那么一般使用多线程。
对于UDP而言,由于不存在连接的问题,因此服务端一个UDP套接字可以接收任意的客户端发来的数据,可直接将该套接字交由一条专用于收发数据的线程管理即可。
对于TCP而言,首先创建一条专门的线程处理监听套接字,用于随时监听和接受客户端的连接请求。另外由于每当有一个客户端连接成功,服务端都会产生一个新的连接套接字来与之通信,那么就应该每产生一个套接字就分配一条线程与之对应,便可形成多任务并发的服务器IO模型。
提示:可以使用链表来记录客户端的信息,当客户端断开连接,则从链表中删除客户端信息。
2.2.3异步信号
指的是用信号来驱使服务器妥善处理多个远端套接字,每当远端有数据到达,那么就在本端触发信号,然后利用信号的异步特性来处理这些远端信息。
注意: SIGIO信号 默认会杀死目标进程,因此必须要设定其响应函数。另外,SIGIO信号由内核针对套接字产生,而内核套接字可以在多个应用层程序中有效(例如父进程将套接字遗传给各个子进程),因此必须指定该信号的宿主。
默认情况下,套接字收到数据时不会触发 SIGIO信号,所以必须将套接字设定为异步工作模式,它才会触发该信号。
由于不管套接字收到何种数据,内核都会触发 SIGIO信号,但是这种方式却不适用于 TCP 协议。
因此在 TCP 中,当客户端发来连接请求、普通数据、数据回执,甚至是断开请求、断开请求的回执等等情况,都触发一样的信号,这就使得服务端光凭这一个信号无法知道下一步要做什么,因此信号驱动的服务器模型,一般只适用于UDP协议。
2.2.4多路复用
多路复用指的是通过某个特定的接口来同时监听多路阻塞IO,这就达到既无需多进程多线程,又可以同时处理多个阻塞套接字的目的。
Linux系统中提供了两个函数select()或poll()实现同时监控多个套接字,当发现一个或多个套接字的某种状态就绪(读状态、写状态)时,再调用相应的函数去处理的过程。
三个套接字集合分别关注三个不同的就绪状态,如果需要同时监控某个套接字sockfd的不同就绪状态,则需要将此套接字放入相应的套接字集合中。
参数说明
nfds
:需要监控的最大文件描述符值 + 1(因为文件描述符从 0 开始编号)。
readfds
:需要监控 “可读” 事件的文件描述符集合(传入 NULL
表示不监控)
writefds
:需要监控 “可写” 事件的文件描述符集合(传入 NULL
表示不监控)。
exceptfds:需要监控 “异常” 事件的文件描述符集合(传入 NULL 表示不监控)。
异常场景:套接字发生错误、带外数据到达等。
timeout:超时时间(struct timeval 类型):
NULL:永久阻塞,直到有事件发生。
tv_sec=0 且 tv_usec=0:立即返回,不阻塞。
其他值:最多阻塞指定的时间,超时后返回 0。
返回值:
成功:返回发生事件的文件描述符总数(可读、可写、异常事件的总和)。
失败:返回
-1
,并设置errno
(如被信号中断则errno=EINTR
)。超时:返回
0
(没有任何事件发生)。
select()
通过 文件描述符集合(fd_set
) 管理需要监控的文件描述符,配合以下宏操作:
宏 |
作用 |
---|---|
|
清空集合,初始化所有位为 0。 |
|
将指定文件描述符加入集合。 |
|
将指定文件描述符从集合中移除。 |
|
检查指定文件描述符是否在集合中(事件是否发生)。 |
基本使用流程
// 1. 初始化文件描述符集合
fd_set readfds;
FD_ZERO(&readfds); // 清空集合
// 2. 添加需要监控的文件描述符(如服务器套接字 server_fd)
FD_SET(server_fd, &readfds);
int max_fd = server_fd; // 记录最大文件描述符
// 3. (可选)设置超时时间(5秒)
struct timeval timeout;
timeout.tv_sec = 5;
timeout.tv_usec = 0;
// 4. 调用 select() 等待事件
int activity = select(max_fd + 1, &readfds, NULL, NULL, &timeout);
if (activity < 0 && errno != EINTR) {
perror("select 失败");
}
// 5. 处理事件
if (activity == 0) {
printf("select 超时\n");
} else {
// 检查 server_fd 是否有可读事件(新连接)
if (FD_ISSET(server_fd, &readfds)) {
// 处理新连接(accept())
}
// 检查其他文件描述符(如客户端套接字)
for (int i = 0; i < max_fd; i++) {
if (FD_ISSET(i, &readfds)) {
// 处理数据接收(recv())或断开连接
}
}
}
注意:当 select() 返回时,集合中未处于就绪状态的套接字将被自动清零,因此如果要重复监控它们就需要重新设置这些套接字集合。
以下是一个基于 select()
实现的 TCP 多路复用服务器实例,能够同时处理多个客户端连接并进行通信:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/select.h>
#include <errno.h>
#define PORT 8080
#define MAX_CLIENTS 10 // 最大客户端数量
#define BUFFER_SIZE 1024
int main() {
int server_fd, new_socket;
struct sockaddr_in address;
int addrlen = sizeof(address);
char buffer[BUFFER_SIZE];
// 客户端套接字数组
int client_sockets[MAX_CLIENTS] = {0};
// 读文件描述符集合
fd_set readfds;
// 1. 创建TCP服务器套接字
if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) {
perror("socket creation failed");
exit(EXIT_FAILURE);
}
// 2. 设置服务器地址
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons(PORT);
// 3. 绑定套接字到端口
if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
perror("bind failed");
exit(EXIT_FAILURE);
}
// 4. 监听连接
if (listen(server_fd, 3) < 0) {
perror("listen failed");
exit(EXIT_FAILURE);
}
printf("TCP多路复用服务器启动,监听端口 %d...\n", PORT);
printf("支持最多 %d 个客户端同时连接\n", MAX_CLIENTS);
while (1) {
// 5. 清空并初始化文件描述符集合
FD_ZERO(&readfds);
// 将服务器套接字加入集合
FD_SET(server_fd, &readfds);
int max_sd = server_fd; // 记录最大文件描述符
// 6. 将活跃的客户端套接字加入集合
for (int i = 0; i < MAX_CLIENTS; i++) {
int sd = client_sockets[i];
if (sd > 0) {
FD_SET(sd, &readfds);
}
if (sd > max_sd) {
max_sd = sd; // 更新最大文件描述符
}
}
// 7. 调用select等待活动发生,超时时间为NULL表示无限等待
int activity = select(max_sd + 1, &readfds, NULL, NULL, NULL);
if ((activity < 0) && (errno != EINTR)) {
perror("select error");
}
// 8. 检查服务器套接字是否有新连接
if (FD_ISSET(server_fd, &readfds)) {
if ((new_socket = accept(server_fd, (struct sockaddr *)&address,
(socklen_t*)&addrlen)) < 0) {
perror("accept failed");
exit(EXIT_FAILURE);
}
// 打印新连接信息
printf("新连接,套接字描述符: %d,IP: %s,端口: %d\n",
new_socket, inet_ntoa(address.sin_addr), ntohs(address.sin_port));
// 将新客户端加入数组
int i;
for (i = 0; i < MAX_CLIENTS; i++) {
if (client_sockets[i] == 0) {
client_sockets[i] = new_socket;
printf("添加到客户端列表中的位置 %d\n", i);
break;
}
}
if (i == MAX_CLIENTS) {
printf("客户端数量已达上限,拒绝连接\n");
close(new_socket);
}
}
// 9. 检查客户端套接字是否有数据
for (int i = 0; i < MAX_CLIENTS; i++) {
int sd = client_sockets[i];
if (FD_ISSET(sd, &readfds)) {
// 读取客户端数据
ssize_t valread = recv(sd, buffer, BUFFER_SIZE, 0);
if (valread == 0) {
// 客户端断开连接
getpeername(sd, (struct sockaddr*)&address, (socklen_t*)&addrlen);
printf("客户端断开连接,IP: %s,端口: %d\n",
inet_ntoa(address.sin_addr), ntohs(address.sin_port));
close(sd);
client_sockets[i] = 0;
}
else if (valread > 0) {
// 处理接收到的数据
buffer[valread] = '\0';
printf("从客户端 %d 收到数据: %s\n", sd, buffer);
// 简单回显数据给客户端
send(sd, buffer, strlen(buffer), 0);
}
else {
// 接收错误
perror("recv failed");
close(sd);
client_sockets[i] = 0;
}
}
}
}
return 0;
}