1. 什么是IO多路转接
IO操作方式有两种
- 阻塞等待
- 优点:不占用CPU时间片
- 缺点:同一时刻只能处理一个操作,效率低下
- 非阻塞(忙轮询)
- 优点是提高了程序的执行效率,缺点是需要占用更多的CPU和系统资源
- 只有一个任务时
- 多个任务
对于非阻塞方式多任务的场景,也就是上图中的情况,解决方法是使用IO多路转接技术,常用的IO多路转接技术包括select/poll/epoll。
- select/poll —— 实现方式为线性表遍历
- 在通信的时候,委托内核去检测连接到server的client,有哪些client是在通信的,比如说有10个client连接,但是只有6个发送了数据,要把这6个client找出来,这个工作由内核去做。但是内核只能给出发送数据的client的个数6,至于是哪6个client,需要进程自己去遍历。
- 在这两种方式下,可以这么理解,select 代收员比较懒, 她只会告诉你有几个快递到了,但是具体是哪个快递,你需要挨个遍历一遍。
- 实际上,多路转接就是进程委托内核去做一些事情,在进程中只要调用select/poll/epoll就可以了,这样就实现了多任务的处理。
- epoll —— 通过红黑树实现
- epoll代收快递员很勤快,她不仅会告诉你有几个快递到了,还会告诉你是哪个快递公司的快递。
通过上面介绍已经大体了解了多路转接是什么,那么多路转接技术是怎么工作的呢?
- 先构造一张有关文件描述符的列表,将要监听的文件描述符添加到该表中。(类似于阻塞信号集)
- 然后调用一个函数,监听该表中的文件描述符,直到这些描述符表中的一个进行I/O操作时,该函数才返回。(select/poll/epoll)
- 该函数为阻塞函数
- 函数对文件描述符的检测操作是由内核完成的
- 在返回时,它告诉进程有多少(哪些)描述符要进行I/O操作。
- 文件描述符对应的是内核缓冲区,监听文件描述符,实际上就是监听内核缓冲区的read区,因为read区有数据就说明有进程给我发送数据。
- select/poll会返回发生IO操作的进程个数;
- epoll返回发生IO操作的进程个数,以及是哪些进程。
2. IO多路转接技术——select详解
(1)select()函数详解
- 函数原型
int select( int nfds, fd_set *readfds, /*传入传出参数 | 传入传出参数:传入函数之前,指针指向的内存就已经有值了,函数执行完毕后,这个内存的值可能发生变化,并通过指针传递出来。*/ fd_set *writefds, fd_set *exceptfds, struct timeval *timeout );
- 函数参数
- nfds:要检测的文件描述符中最大的fd+1 —— 可以直接传1024(文件描述符最大是1023,+1就是1024),因为内核要做遍历,所以它需要一个最大值来作为遍历的终点。
- readfds:读集合,重点关注,因为判断其他进程有没有给当前发送数据就是看读缓冲区有没有数据,读缓冲区有数据说明有进程连接并发送数据通信,这是被动的,是当前进程无法预知的,所以要把文件描述符放入到读集合中,让内核检测读缓冲区什么时候有数据。也就是告诉内核,只检测文件描述符对应的读缓冲区。——我们想知道对方有没有发数据,所以让内核检测文件描述符对应的读缓冲区是否有数据,所以要把文件描述符放到读集合中。读集合的类型是一个fd_set(fd_set数据类型在内核中是用一个数组实现的,数组大小是1024),这个集合所能存放的文件描述符的个数最大是1024个。内核检测的方式,是把这些文件描述符放到一个线性表中,然后遍历线性表。
- 文件描述符集类型:fd_set readfds;fd_set数据类型的内核代码如下,通过下面的内核代码可以看出,使用select多路转接的时候,最多只能委托内核检测1024个文件描述符,这是内核决定的。
- writefds: 写集合,写是进程主动动作,不需要去检测,一般传NULL。(写集合作用:让内核只检测文件描述符对应的写缓冲区)
- exceptfds: 异常集合,不关心异常传NULL(让内核只检测文件描述符是否发生异常),如果想要捕捉对文件描述符的异常操作就要把它加到异常集合中。
- timeout: 设置select是否阻塞
- NULL: 永久阻塞
- 当检测到fd变化的时候返回(缓冲区数据变化)
- struct timeval timeout;
- timeout.tv_sec = 10,阻塞10s,10s后不管fd是否变换,都会返回,也就是说,只有到达指定时间才会返回。
- timeout.tv_usec = 0;
settitimer() struct { long tv_sec; long tv_usec; }; /*赋值的时候,秒和微秒都要赋值,因为最终结果是二者之和,否则得到的就是一个随机数。*/
- 函数返回值
- 检测的文件描述符集合中,只要有一个fd变化了,select函数就返回。
- 有几个文件描述符发生变化,就返回几,然后再通过遍历,把变化的fd找出来。
(2)文件描述符操作函数
- 全部清空
- void FD_ZERO(fd_set *set); //所有标志位清0
- 从集合中删除某一项
- void FD_CLR(int fd, fd_set *set); //在set中清除fd
- 将某个文件描述符添加到集合
- void FD_SET(int fd, fd_set *set);
- 判断某个文件描述符是否在集合中
- int FD_ISSET(int fd, fd_set *set); //fd对应集合中的标志位是0则返回0,是1就返回1
(3)使用select函的优缺点
- 优点:
- 跨平台
- 缺点:
- 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大(内核态到用户态的频繁切换,以及fd集合从用户态和内核态之间的复制)。
- 同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大,客户端越多select的效率越低,并且随着进程的增多,效率下降的越来越快。——对于前两个缺点,poll和select都有这两个缺点,但是epoll没有,因为select/poll在用户和内核有两块内存,所以需要来回复制,而epoll是内核和用户使用同一块共享内存。
- select支持的文件描述符数量太小了,默认是1024。poll不受1024的影响,但是poll不可以跨平台,其他方面二者差不多。(select中的fd_set是用数组实现的,而poll用的是链表实现的,所以不受限制。epoll就更厉害了,用的是树来实现的)。——实际上进程中文件描述符最多是1024个,这个数字是可以修改的,只要修改相应的配置文件,重启电脑就好了。
(4)select工作过程分析
首先假设客户端A、B、C、D、E、 F连接到服务器,分别对应文件描述符 3、4、100、101、102、103(fd都是server端的,每有一个client连接到server,都会产生一个用于通信的fd)。
现在,server通过select函数来委托内核去检测客户端ABCDEF是否给server发数据了。
- fd_set reads, temp; —— 文件描述符表reads,存放在用户空间;内核会拷贝一份,复制到内核区。因为在内核中会修改这个表并覆盖原来的reads,所以我们需要提前备份一下原始表temp。
- FD_SET(3, &reads); —— 调用6次把3、4、100、101、102、103依次加入reads集合。
- select(103+1, &reads, NULL, NULL, NULL);
- 103+1表示要检测的文件描述符中数字最大的fd+1,来指定遍历的终点。
- reads是传入传出参数,内核会对拿到的初始表进行修改,根据读缓冲区是否有数据将相应的位分别置1或者清0,然后用修改后的表覆盖传入的初始表reads,并作为传出参数传出。
在上面的图中
- 文件描述符0、1、2分别是标准输入、标准输出、标准错误,所以供我们使用的文件描述符是从数字3开始的。
- 被修改后的表在内核中,它会再一次拷贝,并放到用户区,且覆盖原来的reads,这时候的reads是内核处理后的(fd变化则保留1,否则清0),所以只要遍历reads,就可以找出发送数据的client,reads相应位值为1的文件描述符对应的client发送了数据。那么我们就对应的执行read操作,去读数据。
- select中传入的参数nfds是104,所以内核会遍历检测0-103文件描述符,先检测文件描述符标志位是不是1,如果是1再去检测fd对应的读缓冲区有没有数据,有数据说明和该fd通信的client发送数据了。
- client连接server的时候会进行三次握手,发送FIN数据包到server的监听文件描述符lfd对应的读缓冲区中。所以,要想知道有没有client发出连接请求,就要把lfd放到读集合中,让内核去检测。也就是说,有没有连接请求也是委托内核去检测。
(5)select多路转接代码实现
#include <stdio.h> #include <unistd.h> #include <stdlib.h> #include <sys/types.h> #include <string.h> #include <sys/socket.h> #include <arpa/inet.h> #include <ctype.h> int main(int argc, const char* argv[]) { if(argc < 2) { printf("eg: ./a.out port\n"); exit(1); } struct sockaddr_in serv_addr; socklen_t serv_len = sizeof(serv_addr); int port = atoi(argv[1]); // 创建套接字 int lfd = socket(AF_INET, SOCK_STREAM, 0); // 初始化服务器 sockaddr_in memset(&serv_addr, 0, serv_len); serv_addr.sin_family = AF_INET; // 地址族 serv_addr.sin_addr.s_addr = htonl(INADDR_ANY); // 监听本机所有的IP serv_addr.sin_port = htons(port); // 设置端口 // 绑定IP和端口 bind(lfd, (struct sockaddr*)&serv_addr, serv_len); // 设置同时监听的最大个数 listen(lfd, 36); printf("Start accept ......\n"); struct sockaddr_in client_addr; socklen_t cli_len = sizeof(client_addr); // 最大的文件描述符 int maxfd = lfd; // 文件描述符读集合 fd_set reads, temp; // init 初始化 FD_ZERO(&reads); FD_SET(lfd, &reads); while(1) { // 委托内核做IO检测 temp = reads; //在Linux下maxfd必须写正确,要及时更新;在Windows下可以随便写 int ret = select(maxfd+1, &temp, NULL, NULL, NULL); if(ret == -1) { perror("select error"); exit(1); } // 客户端发起了新的连接 // 用于监听的文件描述符有且只有1个lfd,lfd对应位为1,说明有新的连接请求 if(FD_ISSET(lfd, &temp)) { // 接受新连接,返回一个用于通信的cfd,并加入到原始的读集合reads(备份) // 接受连接请求 - accept不阻塞 //因为只要进入if语句,就说明有新连接 int cfd = accept(lfd, (struct sockaddr*)&client_addr, &cli_len); if(cfd == -1) { perror("accept error"); exit(1); } char ip[64]; printf("new client IP: %s, Port: %d\n", inet_ntop(AF_INET, &client_addr.sin_addr.s_addr, ip, sizeof(ip)), ntohs(client_addr.sin_port)); // 将cfd加入到待检测的读集合中 - 下一次就可以检测到了 // 下次循环的时候,如果cfd发生变化就可以检测到,当前循环是检测不到的,这也说明select是异步的。 FD_SET(cfd, &reads); // 更新最大的文件描述符//maxfd决定了内核遍历检测的范围 maxfd = maxfd < cfd ? cfd : maxfd; } // 已经连接的客户端有数据到达 // 需要遍历去判断哪个client通信的cfd发生了变化(说明通信了),变化则read读取数据。 // i为啥是从lfd+1开始的? // 因为lfd是第一个创建的文件描述符,而文件描述符创建的规则是当前最小空闲,所以lfd+1应该就是第一个用于通信的文件描述符cfd。 for(int i=lfd+1; i<=maxfd; ++i) { if(FD_ISSET(i, &temp)) { char buf[1024] = {0}; int len = recv(i, buf, sizeof(buf), 0); if(len == -1) { perror("recv error"); exit(1); } else if(len == 0) { printf("客户端已经断开了连接\n"); close(i); // 从读集合中删除 FD_CLR(i, &reads); } else { printf("recv buf: %s\n", buf); send(i, buf, strlen(buf)+1, 0); //strlen(buf)不包括'\0',所以需要+1,并且前提是buf已经被初始化为0 //必须把'\0'发出去来表示字符串结束,否则数据可能出错(比实际数据长),出现乱码 } } } } close(lfd); return 0; }
图书推荐 -《精通嵌入式Linux编程》
书名:《精通嵌入式Linux编程》
出版社:清华大学出版社
本书的每一章都介绍嵌入式Linux的一个主要领域。它描述知识背景,以便你可以了解一般原则,它还包括详细的有效示例来说明这些领域中的操作。
京东购买链接:点击直达
当当购买链接:点击直达
内容简介:《精通嵌入式Linux编程》详细阐述了与嵌入式Linux开发相关的基本解决方案,主要包括初识嵌入式Linux开发、关于工具链、引导加载程序详解、配置和构建内核、构建根文件系统、选择构建系统、使用Yocto进行开发、Yocto技术内幕、创建存储策略、现场更新软件、连接设备驱动程序、使用分线板进行原型设计、init程序、使用BusyBoX nunit启动、管理电源、打包Python程序、了解进程和线程、管理内存、使用GDB进行调试、性能分析和跟踪、实时编程等内容。此外,该书还提供了相应的示例、代码,以帮助读者进一步理解相关方案的实现过程。
《精通嵌入式Linux编程》适合作为高等院校计算机及相关专业的教材和教学参考书,也可作为相关开发人员的自学用书和参考手册。