C语言socket编程-select
前言
在早期的Unix系统中,每个进程都有一个单独的I/O流,这种方式效率低下,因为每个I/O操作都需要等待数据的到来。为了提高效率,Unix引入了select函数,它可以同时监听多个文件描述符,随着网络通信的发展,select函数也被广泛应用于网络编程中,它可以同时监听多个网络连接,当其中任意一个连接有数据到来时,就可以进行相应的处理,从而实现高效的网络通信。
一、简介
在socket编程中,select是一种多路复用 I/O 模型,它可以同时监视多个文件描述符的读写状态,当某个文件描述符就绪时,select会返回该文件描述符,从而实现异步 I/O。
二、select的工作原理
1.工作原理:
- 服务端初始化socket,绑定端口,监听端口
- 将sockfd和clientpool中有效的句柄添加到fdset中,调用select等待句柄就绪
- 客户端初始化socket,发送建链请求
- 服务端select中感知到sockfd已就绪,函数返回,调用accept与客户端完成建链,并生成connfd,再将connfd添加到clientpool中
- 客户端向服务端发送数据
- 服务端遍历clientpool,接收数据,并向客户端返回响应
- 客户端传输数据完成后断开连接
- 服务端收到断链事件后,将句柄从clientpool中移除
2.fd_set数据模型
- 假设fd_set长度为1字节(实际使用时根据操作系统而定),fd_set中每一位对应一个句柄fd,1字节长度的fd_set可以表示8个句柄
- 初始化fd_set set; FD_ZERO(&set),set可以表示为0000 0000
- 若有fd = 1;执行FD_SET(fd, &set)后,set可以表示为0000 0010
- 若再加入fd = 3, fd = 5; 执行FD_SET后,set可以表示为0010 1010
- 执行select(sockFd, &set, NULL, NULL, NULL)后代码会阻塞在这里等待句柄就绪
- 若此时句柄1和句柄5都发生可读事件,则select函数返回,set此时为0010 0010。没有事件发生的句柄3被清空
三、常用函数及说明
void FD_ZERO(fd_set *set); // 初始化集合,使所有位都置为0
void FD_SET(int fd, fd_set *set); // 将句柄fd添加到集合中
void FD_CLR(int fd, fd_set *set); // 从集合中删除句柄fd
int FD_ISSET(int fd, fd_set *set); // 判断句柄fd是否在集合中
四、代码示例
#include <stdio.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/select.h>
#include <errno.h>
#define ERR 1
#define OK 0
#define ADDR "127.0.0.1" // 本地IP
#define PORT 23 // 本地端口
#define BACKLOG 1024 // 监听队列长度
#define MAX_SIZE 1024 // 接收数据缓冲区长度
#define CLIENT_NUM 10 // 可以同时连接的客户端数量
int g_clientFds[CLIENT_NUM] = {0};
/**
* @brief 输出fd_set当前存储状态
*/
void PrintFdSet(fd_set *fdset)
{
for (int i = 0; i < FD_SETSIZE; i++) {
if (FD_ISSET(i, fdset)) {
printf("1");
} else {
printf("0");
}
}
printf("\n");
}
int main()
{
int sockFd, newSockFd, maxFd, iRet, flag;
unsigned int iLocakAddr;
struct sockaddr_in localAddr, remoteAddr;
socklen_t addrlen = 0;
char buf[MAX_SIZE] = {0};
fd_set serFdset;
// 创建socket
sockFd = socket(AF_INET, SOCK_STREAM, 0);
if (sockFd < 0) {
printf("create socket failed, errno: %d\n", errno);
return ERR;
}
printf("socket fd: %d\n", sockFd);
// 配置地址信息
localAddr.sin_family = AF_INET;
localAddr.sin_port = htons(PORT);
inet_pton(AF_INET, ADDR, &iLocakAddr);
localAddr.sin_addr.s_addr = iLocakAddr;
// 绑定地址和socket
iRet = bind(sockFd, &localAddr, sizeof(localAddr));
if (iRet < 0) {
printf("bind socket failed, errno: %d\n", errno);
return ERR;
}
// 监听socket链接请求
iRet = listen(sockFd, BACKLOG);
if (iRet < 0) {
printf("listen failed, errno: %d\n", errno);
return ERR;
}
// 循环处理消息
while (1) {
// 重置fdset
FD_ZERO(&serFdset);
// 添加标准输入句柄
FD_SET(0, &serFdset);
if (maxFd < 0) {
maxFd = 0;
}
// 添加server监听句柄
FD_SET(sockFd, &serFdset);
if (maxFd < sockFd) {
maxFd = sockFd;
}
// 添加已经建链的通信句柄
for (int i = 0; i < CLIENT_NUM; i++) {
printf("client[%d] fd[%d]\n", i, g_clientFds[i]);
if (g_clientFds[i] != 0) {
FD_SET(g_clientFds[i], &serFdset);
if (maxFd < g_clientFds[i]) {
maxFd = g_clientFds;
}
}
}
PrintFdSet(&serFdset);
// 等待消息
iRet = select(maxFd + 1, &serFdset, NULL, NULL, NULL);
if (iRet < 0) {
printf("select failed, errno: %d\n", errno);
continue;
} else {
if (FD_ISSET(sockFd, &serFdset)) {
// 有新的客户端连接
newSockFd = accept(sockFd, &remoteAddr, &addrlen);
flag = -1;
if (newSockFd < 0) {
printf("accept failed, errno: %d\n", errno);
} else {
// 将新的连接句柄存储在client池中
for (int i = 0; i < CLIENT_NUM; i++) {
if (g_clientFds[i] <= 0) {
g_clientFds[i] = newSockFd;
flag = i;
break;
}
}
if (flag >= 0) {
printf("new client[%d] fd[%d] connect succ\n", flag, newSockFd);
} else {
char *fullMsg = "the client pool is full";
iRet = write(newSockFd, fullMsg, strlen(fullMsg));
if (iRet < 0) {
printf("write msg failed, errno: %d\n", errno);
}
}
}
}
}
// 读取client发送的消息
for (int i = 0; i < CLIENT_NUM; i++) {
if (g_clientFds[i] != 0 && FD_ISSET(g_clientFds[i], &serFdset)) {
iRet = read(g_clientFds[i], buf, MAX_SIZE);
if (iRet < 0) {
printf("read msg failed, fd: %d, errno: %d\n", g_clientFds[i], errno);
} else if (iRet > 0) {
printf("recv msg: %s\n", buf);
write(g_clientFds[i], "this is server msg!", strlen("this is server msg!"));
} else {
// 客户端主动断链后,将句柄移除
printf("client[%d] fd[%d] disconnection\n", i, g_clientFds[i]);
FD_CLR(g_clientFds[i], &serFdset);
close(g_clientFds[i]);
g_clientFds[i] = 0;
}
}
}
}
close(sockFd);
return OK;
}
五、问题总结
问:为什么select函数的第一个参数需要是fdmax+1
答:这个参数可以理解为fd_set集合的下标,如果当前fdmax为8,那么fd_set应表示为1 0000 0000,此时如果只遍历8位,会导致最高位被忽略,所以需要给fdamx+1,保证所有句柄都可以被检测到
总结
select函数是c语言网络编程实现io多路复用的方式之一,它是将已连接的socket都放到一个句柄集合(fd_set)中,然后调用 select 函数将句柄集合拷贝到内核里,让内核来检查是否有网络事件产生,检查是通过遍历集合的方式,当检查到有事件产生后,将此socket标记为可读或可写,接着再把整个句柄集合拷贝回用户态里,然后用户态还需要再通过遍历集合的方式找到就绪的句柄,做后续处理。这种方式的优缺点如下:
优点:
- 可以同时处理多个I/O操作,可以大大提高程序的并发性能
- select函数可以监视多个文件描述符,可以实现同时监视多个网络连接
- select函数可以设置超时时间,可以避免网络连接的阻塞
- select函数可以处理异常事件,如对端关闭连接等
缺点:
- select函数需要维护一个大的文件描述符集合,当文件描述符集合很大时,会导致效率下降
- select函数每次调用都需要将文件描述符集合从用户空间拷贝到内核空间,当文件描述符集合很大时,会导致系统开销较大
- select函数不能直接处理大量的并发连接,因为每次调用select函数都需要扫描整个文件描述符集合,效率较低
- select函数的可移植性较差,不同操作系统的实现方式不同,需要编写不同的代码。
基于以上影响,select在当前网络编程中使用的场景相对较小,但对于初学者来说,select是最直观可以了解到io多路复用机制的一种模型