利用I/O多路复用系统调用select实现一个高效服务器模型

学习此篇文章之前,建议先点击👉五种I/O模型进行知识储备。


select只负责等待文件描述符就绪(可以同时等待多个),然后通知应用程序进行I/O读写操作。
就绪的三种情况:

  • 读就绪
  • 写就绪
  • 异常就绪

select认识

函数原型

#include <sys/select.h>
//函数原型
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
  • 参数nfds, 它的值是select监听的最大文件描述符数 + 1

参数readfds、writefds、exceptfds。

它们都是一个fd_set的结构体指针。
在这里插入图片描述
是一个输入输出型参数,通过位图中的比特位内容获得信息:

  • 作为输入:用户告诉内核,内核需要帮用户关心的文件描述符。
  • 作为输出:内核告诉用户,用户需要的文件描述符上面的等待事件就绪。

以readfds为例:
作为输入:用户告诉内核,内核需要帮用户关心的文件描述符。
作为输出:内核告诉用户,用户需要的文件描述符已经“读就绪”。

readfds只关心读,writefds只关心写,exceptfds只关心异常。

位图的操作是比较繁琐的,所以我们可以使用下面一系列宏来访问fd_set结构体中的位图:

#include <sys/select.h>
FD_ZERO(fd_set *fdset);         //清除fdset所有的位
FD_SET(int fd, fd_set *fdset);  //设置fdset的位fd
FD_CLR(int fd, fd_set *fdset); //清除fdset的位fd
int FD_ISSET(int fd, fd_set *fdset); //测试fdset中的位fd是否被设置

参数timeout

select,等待就绪可以设置成三种模式:
(1)只要不就绪,就不返回。(阻塞)
(2)只要不就绪,立马返回。(非阻塞)
(3)设置deadline,deadline之内就是(1),deadline之外就是(2)。

参数timeout就是用来设置deadline的,也就是设置select()的等待时间。
在这里插入图片描述
关于timeout的取值:

  • NULL:表示select()没有timeout, select会一直被阻塞,直到等待的某个文件描述符就绪。
  • 0:deadline为0S,仅仅检测文件描述符集合是否就绪,然后立刻返回,并不进行等待。
  • 特定的时间值:
    (1)如果在指定的时间段内没有文件描述符就绪,select等待超时了,就会返回。
    (2)如果在指定的时间段内有文件描述符就绪,会立刻返回,并将timeout设置成还剩下的时间。

返回值

  • select执行成功,返回就绪文件描述符的总个数。
  • select超时返回,返回0。
  • select失败,返回-1并设置errno, 此时readfds、writefds、exceptfds、timeout的值不可信。

errno可能被设置:

  • EINTR:select等待期间收到信号而中断。
  • EBADF:等待的文件描述符无效/该文件已经关闭。
  • ENOMEM:核心内存不足。

理解select执行过程

理解select的执行过程,关键在于fd_set。为了方便说明,取fd_set长度为1字节(8个比特位)。fd_set中的每一bit可以对应一个文件描述符fd。则1字节长度的fd_set可以对应8个文件描述符。

(1)执行fd_set fdset;FD_ZERO(&set); 则fdset用位表示0000 0000.
(2)假设fd = 5, 执行FD_SET(fd, &fdset); 后fdset变为 0010 0000(下标为5的位置为1)
(3)如果再加入fd = 2, fd = 1, 则set变为0010 0110
(4)执行select(6, &fdset, 0, 0, 0)阻塞等待
(5)若fd = 1, fd = 2上都可读,则select返回,此时fdset变为000 0110。

上面仅模拟了一次,由于select后面的几个参数是输入输出型参数,因此后面的每一次都需要对fdset重新设置。

文件描述符就绪条件

文件描述符在哪些情况下读就绪、写就绪、异常就绪(可读、可写、出现异常)对select的使用是非常关键的。

读就绪:

  • socket内核接收缓存区中的字节数>=低水位标记SO_RCVLOWAT。此时可以无阻塞读socket,并且读操作返回的字节数大于0.
  • socket TCP通信的对端关闭了连接。此时对socket的读操作将返回0
  • 监听socket上有新的连接请求。
  • socket上有未处理的错误。此时可以用getsockopt读取和清除该错误。

写就绪

  • socket内核发送缓存区中的字节数>=低水位标记SO_RCVLOWAT.此时可以无阻塞地写socket,并且写操作返回的字节数大于0
  • socket的写操作被关闭。对写操作被关闭的socket执行写操作将触发一个SIGPIPE信号。
  • socket使用非阻塞connect连接成功/失败(超时)之后。
  • socket上有未处理的错误。此时可以用getsockopt读取和清除该错误。

异常就绪

  • socket上接收到带外数据。

编码环节

准备环节

#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>

//命令格式:./server port
int main(int argc, char *argv[])
{
    int listen_sock = socket(AF_INET, SOCK_STREAM, 0);
    if(listen_sock < 0)
    {
        std::cerr << "create listen socket failed" << std::endl;
        return 1;
    }
    //描述要绑定的套接字地址
    struct sockaddr_in ServerAddr;
    ServerAddr.sin_family = AF_INET;
    ServerAddr.sin_port = htons(atoi(argv[1]));
    ServerAddr.sin_addr.s_addr = INADDR_ANY;

    if(bind(listen_sock, (struct sockaddr*)&ServerAddr, sizeof(ServerAddr)) < 0)
    {
        std::cerr << "listen sock bind failed" << std::endl;
        return 2;
    }
    if(listen(listen_sock, 5) < 0)
    {
    	std::cout << "server listen failed" << std::endl;
    	return 3;
    }
    return 0;
}

在没有引入I/O多路复用之前,我们在此之后的步骤就是: 监听socket监听,然后从全连接队列里面获取到来的新连接。


    while(true)
    {
        //输入输出型参数, 用于获取新连接的套接字地址和长度
        struct sockaddr_in peer;
        socklen_t len = sizeof(peer);
        int sock = accept(listen_sock, (struct sockaddr*)&peer, &len);
        if(sock < 0)
        {
            //获取失败
            continue;
        }
    }

accept()阻塞式地等待全连接队列里连接的到来,如果有连接到来,就从全连接队列里获取出来。


引入多路复用之后。站在多路复用的角度我们发现新连接到来,放入了监听socket的全连接队列内,全连接队列内就有数据了,简而言之就是监听socket的读事件就绪。当获取连接后,产生新的socket文件描述符

accept只能阻塞等待,而且不能够同时监听多个文件描述符,服务器开始的时候都只有监听socket,获取新的连接后就开始增加新的socket文件描述符,所以我们可以使用多路复用同时等待多个文件描述符,提高效率。

#define NUM (sizeof(fd_set) * 8)
//命令格式:./server port
int main(int argc, char *argv[])
{
    int listen_sock = socket(AF_INET, SOCK_STREAM, 0);
    if(listen_sock < 0)
    {
        std::cerr << "create listen socket failed" << std::endl;
        return 1;
    }
    //描述要绑定的套接字地址
    struct sockaddr_in ServerAddr;
    ServerAddr.sin_family = AF_INET;
    ServerAddr.sin_port = htons(atoi(argv[1]));
    ServerAddr.sin_addr.s_addr = INADDR_ANY;

    if(bind(listen_sock, (struct sockaddr*)&ServerAddr, sizeof(ServerAddr)) < 0)
    {
        std::cerr << "listen sock bind failed" << std::endl;
        return 2;
    }
     if(listen(listen_sock, 5) < 0)
    {
    	std::cout << "server listen failed" << std::endl;
    	return 3;
    }
    
    //使用一个数组存放该应用程序中可能会产生的文件描述符
    int fd_array[NUM];
    for(int i = 0; i < NUM; i++)
    {
        fd_array[i] = -1; //起初设置成为-1,表示这个位置不是文件描述符值。
    }
    //开始的时候都只有监听socket,将它的文件描述符值往数组里存放
    fd_array[0] = listen_sock;

    fd_set rset;
    while(true)
    {
        //找到此时程序中存在的最大文件描述符值,用于select()
        //同时可以把存在的fd都设置进rset
        int MaxFd = fd_array[0];
        FD_ZERO(&rset);
        for(int i = 0; i < NUM;i++)
        {
            if(fd_array[i] == -1)
            {
                //该位置没有文件描述符
                continue;
            }
            FD_SET(fd_array[i], &rset);
            if(MaxFd < fd_array[i])
            {
                MaxFd = fd_array[i];
            }
        }

        int n = select(MaxFd + 1, &rset, nullptr, nullptr, nullptr);

    }
    return 0;
}

把这个程序中所有的fd都交给select检测。
我们需要对select()的返回值做一个判断,因为它的返回值不同发生的情况就不同

//......
    while(true)
    {
        //找到此时程序中存在的最大文件描述符值,用于select()
        //同时可以把存在的fd都设置进rset
        int MaxFd = fd_array[0];
        FD_ZERO(&rset);
        for(int i = 0; i < NUM;i++)
        {
            if(fd_array[i] == -1)
            {
                //该位置没有文件描述符
                continue;
            }
            FD_SET(fd_array[i], &rset);
            if(MaxFd < fd_array[i])
            {
                MaxFd = fd_array[i];
            }
        }

        int n = select(MaxFd + 1, &rset, nullptr, nullptr, nullptr);
        switch(n)
        {
            case -1:
                std::cerr << "select error" << std::endl;
                break;
            case 0:
                std::cout << "select timeout" << std::endl;
                break;
            default:
                std::cout << "有fd对应的事件就绪了!" << std::endl;
                //
                break;
        }
    }
//......

经过select后,参数rset的位图设置有“哪些文件描述符读就绪了”。
所以我们可以使用FD_ISSET去判定此时该程序中的文件描述符哪些被设置进了rset的位图。

//......
            default:
                std::cout << "有fd对应的事件就绪了!" << std::endl;
                for(int i = 0; i < NUM; i++)
                {
                    if(fd_array[i] == -1)
                    {
                        continue; //这个位置没有文件描述符
                    }
                    //下面的就是该程序中合法存在的文件描述符
                    //判断某存在的文件描述符是否读就绪(经过select后,被设置进了rset)
                    if(FD_ISSET(fd_array[i], &rset))
                    {
                        
                    }
                }
                break;
//......

应用程序中存在的某文件描述符已经就绪,还要根据就绪的文件描述符分成两种情况:

  • (1)监听socket就绪。意味着有新的连接到来,可以直接使用accept获取。
  • (2)普通socket就绪。意味着可以对该socket进行读取操作了。
//......
if(FD_ISSET(fd_array[i], &rset))
{
	if(fd_array[i] == listen_sock)
	{
		//监听socket就绪,意味着有新连接到来
		std::cout << "listen_sock:" << listen_sock << " 新连接到来" << std::endl;
		//获取
		struct sockaddr_in peer;
		socklen_t len = sizeof(peer);
		int sock = accept(listen_sock, (struct sockaddr*)&peer, &len);
		if(sock < 0)
		{
			continue;
		}
		std::cout << "获取新连接成功" << std::endl;
    
		}
		else
		{
			//普通socket,可以对该socket进行读取操作

		}
}
//......

先关注监听socket就绪的情况。
当我们获取到新的连接,产生了新的文件描述符,我们仍然将这个文件描述符交予select做检测。
但是rset经过select过后,表示的含义就改变了。不再是应用程序中存在并且需要交给select做检测的文件描述符。但不要忘记fd_array数组,我们可以将新的fd放入fd_array数组中,下次循环的时候,rset的含义又变回去了,我们写的程序会帮助我们把这个fd设置进rset。

if (fd_array[i] == listen_sock)
{
	//监听socket就绪,意味着有新连接到来
	std::cout << "listen_sock:" << listen_sock << " 新连接到来" << std::endl;
	//获取
	struct sockaddr_in peer;
	socklen_t len = sizeof(peer);
	int sock = accept(listen_sock, (struct sockaddr *)&peer, &len);
	if (sock < 0)
	{
		continue;
	}
	std::cout << "获取新连接成功" << std::endl;
                        
	//这次将新产生的fd放入fd_array,目的是把新的fd也交给select检测
	int pos = 1;
	for(pos = 1; pos < NUM; pos++)
	{
		//在fd_array中找一个空位置用于存放新的fd
		if(fd_array[pos] == -1)
		{
			break;
		}
	}
	//判断是否为一个合法的位置
	if(pos < NUM)
	{
		std::cout << "新链接: " << sock << " 已经被添加到了数组[" << pos << "]的位置" << std::endl;
		fd_array[pos] = sock;
	}
	else
	{
		//fd_array数组此时满了 
		//表示服务器此时满载了,无法处理新的连接
		std::cout << "服务器已经满载了,关闭新的链接" << std::endl;
		close(sock);
	}
}

再来关注普通socket读就绪的情况。
普通socket读就绪,意味着可以对该socket进行读操作了。

//......
		close(sock);
	}
}
else
{
	//普通socket,可以对该socket进行读操作
	char recv_buffer[1024] = {0};
	ssize_t s = recv(fd_array[i] , recv_buffer, sizeof(recv_buffer) -1 , 0);
	if(s > 0)
	{
		//读成功
		
	}
	else if(s == 0)
	{
		//通信对方关闭了连接
	}	
	else
	{
		//读失败
	}
}
//......

完善一下

#include <unistd.h> //新增头文件
//...
else
{
	//普通socket,可以对该socket进行读操作
	char recv_buffer[1024] = {0};
	ssize_t s = recv(fd_array[i] , recv_buffer, sizeof(recv_buffer) -1 , 0);
	if(s > 0)
	{
		//读成功
		//在服务器标准输出流中打印
		recv_buffer[s] = '\0';
		std::cout << "client[ " << fd_array[i] << "]# " << recv_buffer << std::endl;
	}
	else if(s == 0)
	{
		//通信对方关闭了连接
		//关闭该fd,并在fd_array数组中去掉该fd
		std::cout << "sock: " << fd_array[i] << "关闭了, client退出啦!" << std::endl;
		close(fd_array[i]);
		std::cout << "已经在数组下标fd_array[" << i << "]"
				  << "中,去掉了sock: " << fd_array[i] << std::endl;
		fd_array[i] = -1;
	}
	else
	{
		//读失败
		close(fd_array[i]);
		std::cout << "已经在数组下标fd_array[" << i << "]"
				  << "中,去掉了sock: " << fd_array[i] << std::endl;
		fd_array[i] = -1;
	}
}
//......

最终的代码

#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <sys/select.h>
#include <unistd.h>

#define NUM (sizeof(fd_set) * 8)
//命令格式:./server port
int main(int argc, char *argv[])
{
    int listen_sock = socket(AF_INET, SOCK_STREAM, 0);
    if (listen_sock < 0)
    {
        std::cerr << "create listen socket failed" << std::endl;
        return 1;
    }
    //描述要绑定的套接字地址
    struct sockaddr_in ServerAddr;
    ServerAddr.sin_family = AF_INET;
    ServerAddr.sin_port = htons(atoi(argv[1]));
    ServerAddr.sin_addr.s_addr = INADDR_ANY;

    if (bind(listen_sock, (struct sockaddr *)&ServerAddr, sizeof(ServerAddr)) < 0)
    {
        std::cerr << "listen sock bind failed" << std::endl;
        return 2;
    }
     if(listen(listen_sock, 5) < 0)
    {
    	std::cout << "server listen failed" << std::endl;
    	return 3;
    }
    //使用一个数组存放该应用程序中可能会产生的文件描述符
    int fd_array[NUM];
    for (int i = 0; i < NUM; i++)
    {
        fd_array[i] = -1; //起初设置成为-1,表示这个位置不是文件描述符值。
    }
    //开始的时候都只有监听socket,将它的文件描述符值往数组里存放
    fd_array[0] = listen_sock;

    fd_set rset;
    while (true)
    {
        //找到此时程序中存在的最大文件描述符值,用于select()
        //同时可以把存在的fd都设置进rset
        int MaxFd = fd_array[0];
        FD_ZERO(&rset);
        for (int i = 0; i < NUM; i++)
        {
            if (fd_array[i] == -1)
            {
                //该位置没有文件描述符
                continue;
            }
            FD_SET(fd_array[i], &rset);
            if (MaxFd < fd_array[i])
            {
                MaxFd = fd_array[i];
            }
        }

        int n = select(MaxFd + 1, &rset, nullptr, nullptr, nullptr);
        switch (n)
        {
        case -1:
            std::cerr << "select error" << std::endl;
            break;
        case 0:
            std::cout << "select timeout" << std::endl;
            break;
        default:
            std::cout << "有fd对应的事件就绪了!" << std::endl;
            for (int i = 0; i < NUM; i++)
            {
                if (fd_array[i] == -1)
                {
                    continue; //这个位置没有文件描述符
                }
                //下面的就是该程序中合法存在的文件描述符
                //判断存在的文件描述符是否读就绪(经过select后,被设置进了rset)
                if (FD_ISSET(fd_array[i], &rset))
                {
                    if (fd_array[i] == listen_sock)
                    {
                        //监听socket就绪,意味着有新连接到来
                        std::cout << "listen_sock:" << listen_sock << " 新连接到来" << std::endl;
                        //获取
                        struct sockaddr_in peer;
                        socklen_t len = sizeof(peer);
                        int sock = accept(listen_sock, (struct sockaddr *)&peer, &len);
                        if (sock < 0)
                        {
                            continue;
                        }
                        std::cout << "获取新连接成功" << std::endl;
                        
                        //这次将新产生的fd放入fd_array,目的是把新的fd也交给select检测
                        int pos = 1;
                        for(pos = 1; pos < NUM; pos++)
                        {
                            //在fd_array中找一个空位置用于存放新的fd
                            if(fd_array[pos] == -1)
                            {
                                break;
                            }
                        }
                        //判断是否为一个合法的位置
                        if(pos < NUM)
                        {
                            std::cout << "新链接: " << sock << " 已经被添加到了数组[" << pos << "]的位置" << std::endl;
                            fd_array[pos] = sock;
                        }
                        else
                        {
                            //fd_array数组此时满了 
                            //表示服务器此时满载了,无法处理新的连接
                            std::cout << "服务器已经满载了,关闭新的链接" << std::endl;
                            close(sock);
                        }
                    }
                    else
                    {
                        //普通socket,可以对该socket进行读操作
                        char recv_buffer[1024] = {0};
                        ssize_t s = recv(fd_array[i] , recv_buffer, sizeof(recv_buffer) -1 , 0);
                        if(s > 0)
                        {
                            //读成功
                            //在服务器标准输出流中打印
                            recv_buffer[s] = '\0';
                            std::cout << "client[ " << fd_array[i] << "]# " << recv_buffer << std::endl;
                        }
                        else if(s == 0)
                        {
                            //通信对方关闭了连接
                            //关闭该fd,并在fd_array数组中去掉该fd
                            std::cout << "sock: " << fd_array[i] << "关闭了, client退出啦!" << std::endl;
                            close(fd_array[i]);
                            std::cout << "已经在数组下标fd_array[" << i << "]"
                                      << "中,去掉了sock: " << fd_array[i] << std::endl;
                            fd_array[i] = -1;
                        }
                        else
                        {
                            //读失败
                            close(fd_array[i]);
                            std::cout << "已经在数组下标fd_array[" << i << "]"
                                      << "中,去掉了sock: " << fd_array[i] << std::endl;
                            fd_array[i] = -1;
                        }
                    }
                }
            }
            break;
        }
    }
    return 0;
}

注意,如果你忘记去监听,并且让select等待监听socket,它总是立即返回1。

select的特点

  • 可监控的文件描述符个数取决去sizeof(fd_set)的值。
  • 将fd加入select监控集的还要使用一个数据结构array保存到select监控集中的fd
    (1)用于select返回后,array作为数据源和fd_set进行FD_ISSET判断
    (2)select返回后会把以前加入的但并无事件发生的fd清空,则每次开始select前都需要重新从array取得fd逐一加入,扫描array的同时取得fd最大值maxfd,用于select得第一个参数

select的缺点

  • 每次调用select,都需要手动设置fd集合,从接口使用角度看来非常的不方便
  • 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很大时也会很大
  • 每次调用select,都需要在内核遍历传递进来的所有fd,这个开销在fd很大时也会很大
  • select支持的文件描述符数量,相对于系统支持我们同时打开的文件符数量来说比较小
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

小小酥诶

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值