IO多路复用之select模型

前言

最近一段时间在研究redis,才发现自己基础不牢靠,所以最近一直在学习网络原理及c语言socket方面的知识,在此推荐前一段时间写的网络原理,虽然不太好,但是作为网络原理入个门是绰绰有余了,redis每秒的读写近十万,而且redis还是单线程的,这为它不需要考虑线程安全问题省了不少事,redis之所以这么快,就是因为他的io模型很优秀。

io从最开始的bio,发展到nio,aio再到优秀的网络通信框架mina,netty,这些都离不开io模型从select到poll再到epoll的一步步优化。
为了学习select,笔者也找了不少资料,视频,博客都找了,it圈懒人太多,博客大多复制粘贴,盗图盗文。然而我觉得还是有相当一部分的大神,只是我没找到他们的博客或是没有写博客的习惯。我之所以坚持写博客主要是为了自己学的东西有反馈,其次是为了大家方便。好了,下面进入正文
ps:看本文前最好先去研究笔者其他博文,对理解本文有帮助
借鉴bio不足研究nio
网络原理
c实现bio socket
c实现 多进程服务器
c实现多线程服务器

select模型

看过太多说select模型的,大多数是把unix几张io模型图放上来,再有就是搬概念,其实他们自己根本就不懂select模型,也鲜有人去写c语言的基于select 的scoket,特别是对于我们java程序员来说,研究c语言的有几个。下面我介绍select模型不会搬概念,我只说一些自己的理解,然后结合例子深度一行一行代码剖析他。在此推荐我之前的一篇博文单线程并发服务器,对于理解单线程实现并发服务器有很大帮助,和select的原理有点类似,只是层次不一样,但对于理解select有一定帮助

select 是linux内核提供给我们的一个函数,他可以做到的事情有当有读写事件发生的时候,会告诉我们有多少个事件就绪,但是他不会告诉我们具体是哪些事件就绪,需要我们自己去事件集一个一个遍历判断。你可能看不懂上面的描述,没关系,只要你自己把我的例子敲一遍应该就懂一半了。

select例子

select服务端

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define MAXLINE 4096
#define SERV_PORT 9999
int main(int argc, char *argv[])
{
	//文件描述符:标准输入是0,标准输出是1,标准错误是2
	// maxi:最大文件描述符的下标
	// maxfd:最大文件描述符
	//listenfd:建立socket生成的监听作用的文件描述符 
	// connfd:客户端连接后返回的文件描述符 ,可以用这个文件描述符和客户端通信
	//sockfd :是 client数组保存的 connfd
    int i, maxi, maxfd, listenfd, connfd, sockfd;
    //nready:就绪事件个数,client数组保存 所有客户端连接后返回的connfd ,FD_SETSIZE=1024 
    int nready, client[FD_SETSIZE];
    //read 返回 
    ssize_t n;
    //读集合,
    fd_set rset, allset;
    // 读写缓冲区 
    char buf[MAXLINE];
    //客户端ip 
    char cli_ip[INET_ADDRSTRLEN]; 
	//客户端地址长度    
    socklen_t cliaddr_len;
    //客户端地址,服务器地址 
    struct sockaddr_in cliaddr, servaddr;
	//服务器建立socket 
    listenfd = socket(AF_INET, SOCK_STREAM, 0);
	printf("listenfd:%d\n",listenfd);//listenfd=3 
	//清零 
    bzero(&servaddr, sizeof(servaddr));
    //ipv4 
    servaddr.sin_family= AF_INET;
    //ip 
    servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
    //port
    servaddr.sin_port= htons(SERV_PORT);
	
    bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr));
	//设置同时能够接受128个请求,注意是同一时间
    listen(listenfd, 128);
	printf("wait for connect...\n");
	//最大fd=3 
    maxfd = listenfd;
    //下标暂时没有,因为到这里还没有客户端连接 
    maxi = -1;       
	//初始化client数组 全部-1,因为到这里没有客户端连接             
    for (i = 0; i < FD_SETSIZE; i++)
        client[i] = -1;                
	//清空位图 
    FD_ZERO(&allset);
    //位图操作 数组元素某一位置为1 
    FD_SET(listenfd, &allset);        

    for ( ; ; ) {   
        rset = allset;
        //select 返回有多少个读就绪事件 
		// rset是传入传出参数,传入的时候 告诉select我需要监听哪些事件,传出的时候rset表示事件就绪集 
        nready = select(maxfd+1, &rset, NULL, NULL, NULL);
        printf("nready:%d\n",nready);
        if (nready < 0){
        	fputs("select error\n",stderr);
			exit(1);
        }
        //看看位图里有没有连接事件    
        if (FD_ISSET(listenfd, &rset)) {
        	//客户端地址长度 
            cliaddr_len = sizeof(cliaddr);
            //三次握手接收连接 返回connfd ,accept不阻塞 
            connfd = accept(listenfd, (struct sockaddr *)&cliaddr, &cliaddr_len);
            printf("connfd:%d\n",connfd);
            printf("received from %s at PORT %d\n",
                    inet_ntop(AF_INET, &cliaddr.sin_addr, cli_ip, sizeof(cli_ip)),
                    ntohs(cliaddr.sin_port));
        	//client数组记录下connfd 
            for (i = 0; i < FD_SETSIZE; i++)
                if (client[i] < 0) {
                    client[i] = connfd;
                    break;
                }
                //打印client数组 有效数据 
                int j;
        	for (j = 0; j < FD_SETSIZE; j++){
        		if(	 client[j] != -1){
        			printf("client index=%d value=%d \n",j,client[j]);   
        		}
        	}
       	
			
            if (i == FD_SETSIZE) {
                fputs("too many clients\n", stderr);
                exit(1);
            }
            //将位图 connfd 通过位图算法计算出来的所在位设为1 
            FD_SET(connfd, &allset); 
			//更新 maxfd       
            if (connfd > maxfd)
                maxfd = connfd; 
                //更新 maxi  
            if (i > maxi)
                maxi = i;       
            //如果 nready==0,说明没有事件了 ,那么没有必要再去读取客户端发送过来的数据了 
            if (--nready == 0)
                continue;
         
        }
        printf("maxi:%d\n",maxi);   
        printf("maxfd:%d\n",maxfd); 
		//处理客户端发送过来的数据  ,遍历多路io,这在以前bio是不可能的,bio只会在read死等数据 
        for (i = 0; i <= maxi; i++) {
        	// 这个if一般不会进去,因为 0~maxi的数据都是有效的 
            if ( (sockfd = client[i]) < 0)
                continue;
             printf("sockfd:%d\n",sockfd);
			 //看看位图里有没有客户端发送数据事件   
            if (FD_ISSET(sockfd, &rset)) {
            	//客户端断开连接 ,这个read不阻塞 
                if ( (n = read(sockfd, buf, MAXLINE)) == 0) {
                   	//关闭 sockfd
                    close(sockfd);
                    //把 allset里的 sockfd所在位 置为0 
                    FD_CLR(sockfd, &allset);
                  	//client数组清为-1 
                    client[i] = -1;
                } else {
                	//有数据发送 
                	printf("%s\n",buf);
                    int j;
                    for (j = 0; j < n; j++)
                        buf[j] = toupper(buf[j]);//转大写 
                    write(sockfd, buf, n);//回写过去 
                }
                if (--nready == 0)//如果 nready==0 ,没必要再去读数据了 
                    break;
            }
        }
    }
    close(listenfd);
    return 0;
}

客户端

#include<stdio.h>
#include<stdlib.h>
#include<errno.h>
#include<string.h>
#include<sys/types.h>
#include<netinet/in.h>
#include<sys/socket.h>
#include<sys/wait.h> 
#define DEST_PORT 9999//目标地址端口号 
#define DEST_IP "127.32.255.2"/*目标地址IP,这里设为本机,不一定非得是127.0.0.1,只要127开头并且不是127.0.0.0和127.255.255.255即可*/ 
#define MAX_DATA 100//接收到的数据最大程度 
 
int main(){
	int sockfd;
	struct sockaddr_in dest_addr;
	char buf[MAX_DATA];
 
	sockfd=socket(AF_INET,SOCK_STREAM,0);
	
	dest_addr.sin_family=AF_INET;
 	dest_addr.sin_port=htons(DEST_PORT);
	dest_addr.sin_addr.s_addr=inet_addr(DEST_IP);
	bzero(&(dest_addr.sin_zero),8);
	connect(sockfd,(struct sockaddr*)&dest_addr,sizeof(struct sockaddr));

	printf("connect success");
	while(1){
		char send_buf[512] = "";
		scanf("%s",&send_buf);
		write(sockfd,send_buf,sizeof(send_buf));
		
		read(sockfd,send_buf,sizeof(send_buf));
    	printf("client receive:%s\n",send_buf);
	}

	return 0;
} 

centos执行
gcc -o server.out server.c
gcc -o client.out client.c
得到执行文件server.out,client.out
用xshell 对一个虚拟机开两个item窗口,一个执行./server.out ,另一个执行./client.out,可以开多个客户端通信

select深度剖析

select函数

主要研究select和fd_set,关于socket相关api较简单,这里就不涉及了

select(int nfds,fd_set* rset,fd_set* wset,fd_set* eset,const struct timeval* time);
nfds = 最大文件描述符+1
rset = 读事件集合/位图
wset = 写事件集合/位图
eset = 异常事件集合/位图
time 等待I/o的最长时间,NULL表示一直等待
timeval结构体
struct timeval{
	long tv_sec; // seconds
	long tv_usec; // microseconds
}

fd_set在下面说,nfds较难理解
IO多路复用之select模型_io多路复用模型

0 1 2这三个数字linux内核自己要用,所以我们执行socket方法是返回的listenfd=3,此时若执行select则nfds =3+1=4
若此时客户端1来连接并且成功三次握手,再次调用select时 nfds 应=4+1=5,故我们的程序应该设置maxfd来保存这个最大的文件描述符,之所以select需要这个参数,是因为select轮询的时候需要上限。

fd_set位图

这个位图很难理解,我刚开始一直卡在这里,因为网上大多数人根本不懂位图,只会说一些概念
大多数博客都是像下面这样一笔带过

FD_CLR( s, *set)    //把位图set s位清零;
FD_ISSET( s, *set)  //检查文件描述符s是否存在与set中;
FD_SET( s, *set )   //把文件描述符s添加到set中;
FD_ZERO( *set )     //把set位图初始为空.

可是看完之后一点帮助都没有,为什么这么说,因为你完全不知道位图底层怎么实现的,导致服务器的代码你怎么也看不懂。

如何理解位图呢?
可以把位图理解为一张32*32的表格,表格里的数字不是0就是1,0~1023的数都可以扔到这张图里面,并且如果你把0到1023的数全都扔到这张图里面,你会发现这张表格全部变成1了。
那么如何将一个1024以内的数定位到这张表格的某个格子就显得尤为重要了?
IO多路复用之select模型_io多路复用模型_02
如上图所示:有32列,8行,还有24行省略。
假若文件描述符是3,该把位图哪个格子置为1呢,行号=3/32=0,列号=3%32=3,所以就是第一行上色的那个格子
又假若文件描述符是34,行号=34/32=1,列号=34%32=2,第二行上色的即是

在此特别感谢scope
他的文章对我理解位图帮助很大,以下是他的一些见解

#define __NFDBITS (8 * sizeof(unsigned long)) //每个ulong型可以表示多少个bit,c的ulong四个字节, 故__NFDBITS = 8*32
#define __FD_SETSIZE 1024 //socket最大取值为1024
#define __FDSET_LONGS (__FD_SETSIZE/__NFDBITS)//bitmap一共有1024个bit,共需要多少个ulong ,__FDSET_LONGS =1024/32=32
 
typedef struct {
    unsigned long fds_bits [__FDSET_LONGS]; //用ulong数组来表示bitmap,数组大小为32,即位图的32行,每一个数组元素32位,即位图的列
} __kernel_fd_set;
 
typedef __kernel_fd_set   fd_set;

//每个ulong为32位,可以表示32个bit。
//fd  >> 5 即 fd / 32,找到对应的ulong下标i;fd & 31 即fd % 32,找到在ulong[i]内部的位置
 
#define __FD_SET(fd, fdsetp)   (((fd_set *)(fdsetp))->fds_bits[(fd) >> 5] |= (1<<((fd) & 31)))             //设置对应的bit
#define __FD_CLR(fd, fdsetp)   (((fd_set *)(fdsetp))->fds_bits[(fd) >> 5] &= ~(1<<((fd) & 31)))            //清除对应的bit
#define __FD_ISSET(fd, fdsetp)   ((((fd_set *)(fdsetp))->fds_bits[(fd) >> 5] & (1<<((fd) & 31))) != 0)     //判断对应的bit是否为1
#define __FD_ZERO(fdsetp)   (memset (fdsetp, 0, sizeof (*(fd_set *)(fdsetp))))                             //memset bitmap

由此我又想起我以前实现过一个o(n)时间复杂度java数组判断重复的算法,就用到了位图的概念,只是自己一下没有联想起来

下面是java版位图去重

粒度大的位图
这种粒度太大了,且需要判断的数最大是多少,数组就得定义多大,核心思想是将arr数组元素值当作tmp的下标使用
例如arr={1,1,2}第二次循环就返回false回去了,因为下标1已经有人占掉了

	public final static int MAX = 1024;
    public static boolean isDuplicate(int[] arr){
            int[] tmp = new int[MAX];
            for(int i=0;i<arr.length;i++){
                if(tmp[arr[i]]==0){
                    tmp[arr[i]] = 1;
                }else{
                    return true;
                }
            }
            return false;
    }

粒度小的位图
例如 arr={3,3}
tmp = int[32]
第一次循环:
index = 3>>5 = 0,说明3应该放在第0行
pos = 3%32 = 3 ,说明3应该放在tmp[0]的第3位(从0开始算)
if不进
tmp[0]|=(1<<3)即tmp[0] = 8
第二次循环
index = 0
pos = 3
if(8&8!=0){
return true
}

 public static boolean isDuplicateByBit(int []arr){
        int[] tmp = new int[MAX/32];
        for(int i=0;i<arr.length;i++){
            int index = arr[i]>>5 ;//数组下标
            int pos = arr[i]%32;//数组下标pos对应的数的第多少位
            if((tmp[index]&((1<<pos)))!=0){
                return true;
            }
            tmp[index] |= (1<<pos);//将数组下标index对应的数的第pos位 置为1
        }
        return false;
    }

在看一下java版的FD_SET,FD_ISSET,FD_CLR

	//探测num第pos位是不是1(pos从0开始算)
    public static boolean FD_ISSET(int pos,int num){
        return (num&((1<<pos)))!=0;
    }
    public static int FD_SET(int pos,int num){
        return num |= (1<<pos);
    }
    public static int FD_CLR(int pos,int num){
        return  num &= ~(1<<pos);
    }
    public static void main(String[] args) {
        System.out.println(FD_ISSET(0,3));
        System.out.println(FD_SET(2,3));
        System.out.println(FD_CLR(1,30));
    }

上面对于pos这个数字只能是0~31,因为num只有32位

所以select 的unsigned long fds_bits []要想支持1024个文件描述符,这个数组大小就必须是32