Linux Select多路复用
阻塞与非阻塞
阻塞IO
- 阻塞:进程会一直阻塞,直到数据拷贝完成。应用程序调用一个I/O函数,导致应用程序阻塞,等待数据准备好。 如果数据没有准备好,一直等待….数据准备好了,从内核拷贝到用户空间,I/O函数返回成功指示。这个是linux系统默认的I/O下操作模式,也是最常见的I/O操作模式。也就是说,如果你创建了一个套接字,想要使用非阻塞模式,那么你需要进行设置,因为你默认的是阻塞模式。下面会详细讲到。
#include <sys/types.h>
#include <sys/socket.h>
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
当你创建了套接字,bind和listen后,以及使用accept连接上后就会使用这个recv函数去等待数据到来,等对方通过socket将数据发送到你的内核,你的内核就会通知你有数据到来,此时你这个函数就会返回,返回的是你接受数据的字节数,否则一直会在阻塞状态等待数据的到来。
非阻塞IO
- 非阻塞当我们告诉内核,如果数据没有到来,你立马给我返回,不用等待数据了。设置成非阻塞的方法如下。
fcntl(sockfd, F_SETFL, flags | O_NONBLOCK); //设置成非阻塞模式;
其实非阻塞模式的使用并不普遍,因为非阻塞模式会浪费大量的CPU资源。
举个例子:
- 阻塞相当于你去饭店吃饭,点菜以后需要等待菜做好。如果你选择坐到旁边等待,此时相当于阻塞;
- 如果你不选择等而是取一边玩手机或者逛其他商店,那么你就要每隔一段时间来饭店看看菜到底做好没,这就相当于非阻塞。
多路复用技术
- 阻塞模式是最常用的一种I/O模式,从socket的角度出发,一个client去连接连接一个server端,server端往往需要等待客户端的访问,那么如果此时我们使用阻塞模式的话,如果只使用一个线程的话,如果此时第一个连接过来了,那么你调用了recv()函数,阻塞在等待消息这里,此时如果第二个客户端连接的话,因为你的程序一直在这里等着,无法处理这个连接请求,那么此时你的连接数量是1.那么如果我们使用对线程机制,对每个客户端都使用一个线程,那么如果有大量的客户连接的话,服务端就要创建大量的线程,linux操作系统本身对文件描述符的个数有限制,即使没有限制,大量的线程创建和套接字创建也会消耗linux的资源。此时多路复用就诞生了,在我理解,多路复用就是可以在一个线程中监测多个套接字,比如select,poll,epoll,当这些套接字(文件描述符)中的任意一个进入有数据到来,以上三个函数就会返回,之后进入数据处理状态。
select函数
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
int select(int maxfd, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
参数说明、
- maxfds:是一个整数值,是指集合中所有文件描述符的范围,即所有文件描述符的最大值加1,不能错。在linux系统中,select的默认最大值为1024。设置这个值的目的是为了不用每次都去轮询这1024个fd,假设我们只需要几个套接字,我们就可以用最大的那个套接字的值加上1作为这个参数的值,当我们在等待是否有套接字准备就绪时,只需要监测maxfd+1个套接字就可以了,这样可以减少轮询时间以及系统的开销。
- readfds:首先需要明白,fd_set是什么数据类型,有一点像int,又有点像struct,其实,fd_set声明的是一个集合,也就是说,readfs是一个容器,里面可以容纳多个文件描述符,把需要监视的描述符放入这个集合中,当有文件描述符可读时,select就会返回一个大于0的值,表示有文件可读;writefds和readfs类似,表示有一个可写的文件描述符集合,当有文件可写时,select就会返回一个大于0的值,表示有文件可写;fd_set* errorfds同上面两个参数的意图,用来监视文件错误异常文件。
- timeout:这个参数一出来就可以知道,可以选择阻塞,可以选择非阻塞,还可以选择定时返回。当将timeout置为NULL时,表明此时select是阻塞的;当将tineout设置为timeout->tv_sec = 0,timeout->tv_usec = 0时,表明这个函数为非阻塞;当将timeout设置为非0的时间,表明select有超时时间,当这个时间走完,select函数就会返回。从这个角度看,个人觉得可以用select来做超时处理,因为你如果使用recv函数的话,你还需要去设置recv的模式,麻烦的很。
struct timeval{
long tv_sec; /*秒 */
long tv_usec; /*微秒 */
}
void FD_ZERO(fd_set *set);
void FD_SET(int fd, fd_set *set);
void FD_CLR(int fd, fd_set *set);
int FD_ISSET(int fd, fd_set *set);
对fd_set的理解:fd_set可以理解为一个二进制位图集合,那么集合就会有一个数量,在<sys/select.h>总定义了一个常量FD_SETSIZE,默认为1024,也就是说在这个集合内默认最多有1024个文件描述符,但是通常你用不了这么多,你通常只是关心maxfds个描述符。也就是说你现在有maxfds个文件描述符在这个集合里,那么我怎么知道集合里的哪个文件描述符有消息来了呢?你可以将fd_set中的集合看成是二进制bit位,一位代表着一个文件描述符。0代表文件描述符处于睡眠状态,没有数据到来;1代表文件描述符处于准备状态,可以被应用层处理。我觉得select函数可以分下面几步进行理解
在你开始监测这些描述符时,你先将这些文件描述符全部置为0
当你需要监测的描述符置为1
使用select函数监听置为1的文件描述符是否有数据到来
当状态为1的文件描述符有数据到来时,此时你的状态仍然为1,但是其他状态为1的文件描述如果没有数据到来,那么此时会将这些文件描述符置为0
当select函数返回后,可能有一个或者多个文件描述符为1,那么你怎么知道是哪个文件描述符准备好了呢?其实select并不会告诉你说,我哪个文件描述符准备好了,他只会告诉你他的那些bit为位哪些是0,哪些是1。也就是说你需要自己用逻辑去判断你要的那个文件描是否准备好了
理解了上面几步的话,下面这些宏就比较好理解了。
- FD_ZERO:将指定集合里面所有的描述符全部置为0,在对文件描述符集合进行设置前,必须对其进行初始化,如果不清空,由于在系统分配内存空间后,通常并不作清空处理,所以结果是不可知的
- FD_SET:用于在文件描述符集合中增加一个新的文件描述符,将相应的位置置为1
- FD_CLR:用来清除集合里面的某个文件描述符
- FD_ISSET:用来检测指定的某个描述符是否有数据到来。- 那么假如在我们的程序中有5个客户端已经连接上了服务器,这个时候突然有一条数据过来了。select返回了,但是此时你并不知道是哪个客户发过来的消息,因为你每个客户发过来的消息都是一样重要的。所以你没法去只针对一个套接字使用FD_ISSET,你需要做的是用一个循环去检测(FD_ISSET)到底是哪一个客户发过来的消息,因为如果此时你监测一个套接字的话,其他客户的信息你会丢失。这个也是select的一个缺点,你需要去检测所有的套接字,看看这个套接字到底是谁来的数据。
select模型的缺点
- 如果只对系统提供的事件集合FD_SET做遍历,当监听集合很多但是只有个别几个有事件发生时,遍历哪个文件描述符有事件的过程会消耗很多无用资源。
- 但是这不是select相比于poll,epoll效率就低
- epoll适用于连接较多,活动数量较少的情况。
epoll为了实现返回就绪的文件描述符,维护了一个红黑树和好多个等待队列,内核开销很大。如果此时监听了很少的文件描述符,底层的开销会得不偿失;
epoll中注册了回调函数,当有时间发生时,服务器设备驱动调用回调函数将就绪的fd挂在rdllist上,如果有很多的活动,同一时间需要调用的回调函数数量太多,服务器压力太大。 - select适用于连接较少的情况。
当select上监听的fd数量较少,内核通知用户现在有就绪事件发生,应用程序判断当前是哪个fd就绪所消耗的时间复杂度就会大大减小。
select服务器代码流程
(参考黑马,实现客户端写,服务端读并把小写转大写,再写回客户端的功能)
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <arpa/inet.h>
#include <ctype.h>
#define SERV_PORT 6666
int main(int argc, char *argv[])
{
int listenfd, connfd;
char buf[BUFSIZ], str[INET_ADDRSTRLEN];
struct sockaddr_in clie_addr, serv_addr;
socklen_t clie_addr_len;
listenfd = socket(AF_INET, SOCK_STREAM, 0);
int opt = 1;
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); //SO_REUSEADDR允许重用本地地址端口
bzero(&serv_addr, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
serv_addr.sin_port = htons(SERV_PORT);
bind(listenfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr));
listen(listenfd, 128);
fd_set rset, allset;
int ret, maxfd = 0;
maxfd = listenfd;
FD_ZERO(&allset); //集合全部设为0
FD_SET(listenfd, &allset);
while (1)
{
rset = allset;
ret = select(maxfd + 1, &rset, NULL, NULL, NULL); //返回有事件发生的个数
if (FD_ISSET(listenfd, &rset))
{
clie_addr_len = sizeof(clie_addr);
connfd = accept(listenfd, (struct sockaddr *)&clie_addr, &clie_addr_len);
printf("received from %s at PORT %d\n", inet_ntop(AF_INET, &clie_addr.sin_addr, str, sizeof(str)), ntohs(clie_addr.sin_port));
FD_SET(connfd, &allset); //将一个集合中的事件“加入”
if (maxfd < connfd)
maxfd = connfd;
if (ret == 1)
continue; //说明select只返回一个,并且只是listenfd,无需后续执行
}
int n = 0;//read读到的字节数
for (int i = listenfd + 1; i < maxfd + 1; ++i)
{
if (FD_ISSET(i, &rset)) //判断文件描述符是否在集合中
{
n = read(i, buf, sizeof(buf));
if (n == 0)
{
close(i);
FD_CLR(i, &allset); //将一个集合中的事件“拿走”
}
else if (n == -1)
{
}
else
for (int j = 0; j < n; ++j)
buf[j] = toupper(buf[j]);
write(i, buf, n);
write(STDOUT_FILENO, buf, n);
}
}
}
close(listenfd);
return 0;
}