主旨思想
- 首先要构造一个关于文件描述符的列表,将要监听的文件描述符添加到该列表中
- 调用一个系统函数(select),监听该列表中的文件描述符,直到这些描述符中的一个或者多个进行I/O操作时,该函数才返回
- 这个函数是阻塞
- 函数对文件描述符的检测的操作是由内核完成的
- 在返回时,它会告诉进程有多少(哪些)描述符要进行I/O操作
函数说明
-
概览
#include <sys/time.h> #include <sys/types.h> #include <unistd.h> #include <sys/select.h> int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout); // 将参数文件描述符fd对应的标志位设置为0 void FD_CLR(int fd, fd_set *set); // 判断fd对应的标志位是0还是1, 返回值 : fd对应的标志位的值,0,返回0, 1,返回1 int FD_ISSET(int fd, fd_set *set); // 将参数文件描述符fd 对应的标志位,设置为1 void FD_SET(int fd, fd_set *set); // fd_set一共有1024 bit, 全部初始化为0 void FD_ZERO(fd_set *set);
-
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
-
通过
man select
查看帮助 -
参数
- nfds : 委托内核检测的最大文件描述符的值 + 1(+1是因为遍历是下标从0开始,for循环<设定)
- readfds : 要检测的文件描述符的读的集合,委托内核检测哪些文件描述符的读的属性
- 一般检测读操作
- 对应的是对方发送过来的数据,因为读是被动的接收数据,检测的就是读缓冲区
- 是一个传入传出参数
- writefds : 要检测的文件描述符的写的集合,委托内核检测哪些文件描述符的写的属性
- 委托内核检测写缓冲区是不是还可以写数据(不满的就可以写)
- exceptfds :检测发生异常的文件描述符的集合,一般不用
- timeout:设置的超时时间 (具体见下方select参数列表说明)
- NULL : 永久阻塞,直到检测到了文件描述符有变化
tv_sec = tv_usec = 0
, 不阻塞tv_sec > 0,tv_usec > 0
:阻塞对应的时间
-
返回值
- -1:失败
- >0(n):检测的集合中有n个文件描述符发生了变化
-
-
select参数列表说明
-
fd_set:是一块固定大小的缓冲区(结构体), sizeof(fd_set)=128,即对应1024个比特位
-
timeval:结构体类型
struct timeval { long tv_sec; /* seconds */ long tv_usec; /* microseconds */ };
工作过程分析
-
初始设定
-
- 设置监听文件描述符,将fd_set集合相应位置为1
-
调用select委托内核检测
-
内核检测完毕后,返回给用户态结果
代码实现
- select中需要的监听集合需要两个
一个是用户态真正需要监听的集合rSet
一个是内核态返回给用户态的修改集合tmpSet - 需要先判断监听文件描述符是否发生改变
如果改变了,说明有客户端连接,此时需要将新的连接文件描述符加入到rSet
,并更新最大文件描述符
如果没有改变,说明没有客户端连接 - 由于select无法确切知道哪些文件描述符发生了改变,所以需要执行遍历操作,使用FD_ISSET判断是否发生了改变
- 如果客户端断开了连接,需要从rSet中清除需要监听的文件描述符
- 程序存在的问题:中间的一些断开连接后,最大文件描述符怎么更新?
==估计不更新,每次都会遍历到之前的最大值处==
- 服务器端代码:
#include <stdio.h> #include <arpa/inet.h> #include <stdlib.h> #include <unistd.h> #include <string.h> #include <sys/select.h> #define SERVERIP "127.0.0.1" #define PORT 6789 int main() { // 1. 创建socket(用于监听的套接字) int listenfd = socket(AF_INET, SOCK_STREAM, 0); if (listenfd == -1) { perror("socket"); exit(-1); } // 2. 绑定 struct sockaddr_in server_addr; server_addr.sin_family = PF_INET; // 点分十进制转换为网络字节序 inet_pton(AF_INET, SERVERIP, &server_addr.sin_addr.s_addr); // 服务端也可以绑定0.0.0.0即任意地址 // server_addr.sin_addr.s_addr = INADDR_ANY; server_addr.sin_port = htons(PORT); int ret = bind(listenfd, (struct sockaddr*)&server_addr, sizeof(server_addr)); if (ret == -1) { perror("bind"); exit(-1); } // 3. 监听 ret = listen(listenfd, 8); if (ret == -1) { perror("listen"); exit(-1); } // 创建读检测集合 // rSet用于记录正在的监听集合,tmpSet用于记录在轮训过程中由内核态返回到用户态的集合 fd_set rSet, tmpSet; // 清空 FD_ZERO(&rSet); // 将监听文件描述符加入 FD_SET(listenfd, &rSet); // 此时最大的文件描述符为监听描述符 int maxfd = listenfd; // 不断循环等待客户端连接 while (1) { tmpSet = rSet; // 使用select,设置为永久阻塞,有文件描述符变化才返回 int num = select(maxfd + 1, &tmpSet, NULL, NULL, NULL); if (num == -1) { perror("select"); exit(-1); } else if (num == 0) { // 当前无文件描述符有变化,执行下一次遍历 // 在本次设置中无效(因为select被设置为永久阻塞) continue; } else { // 首先判断监听文件描述符是否发生改变(即是否有客户端连接) if (FD_ISSET(listenfd, &tmpSet)) { // 4. 接收客户端连接 struct sockaddr_in client_addr; socklen_t client_addr_len = sizeof(client_addr); int connfd = accept(listenfd, (struct sockaddr*)&client_addr, &client_addr_len); if (connfd == -1) { perror("accept"); exit(-1); } // 输出客户端信息,IP组成至少16个字符(包含结束符) char client_ip[16] = {0}; inet_ntop(AF_INET, &client_addr.sin_addr.s_addr, client_ip, sizeof(client_ip)); unsigned short client_port = ntohs(client_addr.sin_port); printf("ip:%s, port:%d\n", client_ip, client_port); FD_SET(connfd, &rSet); // 更新最大文件符 maxfd = maxfd > connfd ? maxfd : connfd; } // 遍历集合判断是否有变动,如果有变动,那么通信 char recv_buf[1024] = {0}; for (int i = listenfd + 1; i <= maxfd; i++) { if (FD_ISSET(i, &tmpSet)) { ret = read(i, recv_buf, sizeof(recv_buf)); if (ret == -1) { perror("read"); exit(-1); } else if (ret > 0) { printf("recv server data : %s\n", recv_buf); write(i, recv_buf, strlen(recv_buf)); } else { // 表示客户端断开连接 printf("client closed...\n"); close(i); FD_CLR(i, &rSet); break; } } } } } close(listenfd); return 0; }
客户端代码:
-
#include <stdio.h> #include <arpa/inet.h> #include <stdlib.h> #include <unistd.h> #include <string.h> #define SERVERIP "127.0.0.1" #define PORT 6789 int main() { // 1. 创建socket(用于通信的套接字) int connfd = socket(AF_INET, SOCK_STREAM, 0); if (connfd == -1) { perror("socket"); exit(-1); } // 2. 连接服务器端 struct sockaddr_in server_addr; server_addr.sin_family = PF_INET; inet_pton(AF_INET, SERVERIP, &server_addr.sin_addr.s_addr); server_addr.sin_port = htons(PORT); int ret = connect(connfd, (struct sockaddr*)&server_addr, sizeof(server_addr)); if (ret == -1) { perror("connect"); exit(-1); } // 3. 通信 char recv_buf[1024] = {0}; while (1) { // 发送数据 char *send_buf = "client message"; write(connfd, send_buf, strlen(send_buf)); // 接收数据 ret = read(connfd, recv_buf, sizeof(recv_buf)); if (ret == -1) { perror("read"); exit(-1); } else if (ret > 0) { printf("recv server data : %s\n", recv_buf); } else { // 表示客户端断开连接 printf("client closed...\n"); } // 休眠的目的是为了更好的观察,放在此处可以解决read: Connection reset by peer问题 sleep(1); } // 关闭连接 close(connfd); return 0; }
高并发优化思考
-
问题
- 每次都需要利用FD_ISSET
轮询[0,maxfd]
之间的连接状态,如果位于中间的某一个客户端断开了连接,此时不应该再去利用FD_ISSET轮训,造成资源浪费 - 如果在处理客户端数据时,某一次read没有对数据读完,那么造成重新进行下一次时select,获取上一次未处理完的文件描述符,从0开始遍历到maxfd,对上一次的进行再一次操作,效率十分低下
-
解决
-
考虑到
select
只有1024
个最大可监听数量,可以申请等量客户端数组
初始置为-1,当有状态改变时,置为相应文件描述符
此时再用FD_ISSET
轮训时,跳过标记为-1的客户端,加快遍历速 -
对于问题二:对读缓存区循环读,直到返回
EAGAIN
再处理数据存在问题(缺点)
- 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
- 同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大
- select支持的文件描述符数量太小了,默认是1024
- fds集合不能重用,每次都需要重置