并发TCP服务器 IO多路复用之select

前言

记得我们之前写的TCP客户端服务端通信小demo中有个小问题:accept函数和read函数一旦阻塞在其中一个中的话,另一个就没法对相应的请求做出响应,即如果在accept中等待一个连接请求,我们就不能响应输入的命令(接收数据)。类似地,如果在read中等待一个输入命令,我们就不能响应任何连接请求。但是一个并发服务器不可能只有一个客户端连接或者通信,它必须能对用户从标准输入键入的交互命令做出响应,也要能处理网络客户端发起的连接请求

针对这种困境的一个解决办法就是I/O多路复用技术。
基本的思路就是使用系统提供的IO多路复用函数(select、poll、epoll),要求内核挂起进程,会有一个线程不断去轮询监控多个文件描述符的状态,只有在一个或多个I/O事件发生后,才将控制返回给应用程序,真正调用实际的IO读写操作

初识select

系统提供select函数来实现多路复用输入/输出模型.

  • select系统调用是用来让我们的程序监视多个文件描述符的状态变化的;
  • 程序会停在select这里等待(被挂起),直到被监视的文件描述符有一个或多个发生了状态改变;
  • 当发现了某一个文件描述符就绪的时候 ,就会通知进程,让进程针对某一个描述符进行操作; 而内核继续监控其他描述符;

select函数原型

 #include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout); 
	参数解释:
		参数nfds是需要监视的最大的文件描述符值+1;(用于轮询时控制边界,且可以提高效率)
		readfds,writefds,exceptfds分别对应于需要检测的可读文件描述符的集合,可写文件描述符的集 合及异常文件描述符的集合;
		参数timeout为结构timeval,用来设置select监视的等待时间

关于fd_set结构
在这里插入图片描述
在这里插入图片描述
其实这个结构就是一个整数数组, 更严格的说, 是一个 “位图"或者说是"位向量”,使用位图中对应的位来表示要监视的文件描述符.通常也叫做描述符集合.
在这里插入图片描述
所以可以监控的文件描述符有1024个。

且系统还提供了一组操作fd_set的宏接口, 来比较方便的操作位图.

void FD_CLR(int fd, fd_set *set);  // 用来清除描述词组集合set中相关fd 的位
int FD_ISSET(int fd, fd_set *set); // 用来测试描述词组集合set中相关fd 的位是否为真
void FD_SET(int fd, fd_set *set);  // 用来设置描述词组集合set中相关fd的位
void FD_ZERO(fd_set *set);        //  用来清除描述词组集合set的全部位

timeval结构体

struct timeval
{
	__time_t tv_sec; //秒
	__suseconds_t tv_usec; //微秒
};

参数timeout取值:

  • NULL:则表示select没有timeout(类似于计时器),select将一直被阻塞,直到某个文件描述符上发生了事件;
  • 0:仅检测描述符集合的状态,然后立即返回,并不等待外部事件的发生。
  • 特定的时间值(大于0):如果在指定的时间段里没有事件发生,select将超时返回。

函数返回值:

  • 执行成功则返回文件描述符状态已改变的个数
  • 如果返回0代表在描述符状态改变前已超过timeout时间(等待超时),没有返回
  • 当有错误发生时则返回-1,错误原因存于errno,此时参数readfds,writefds, exceptfds和timeout的值变成不可预测。
    错误值可能为:
    EBADF 文件描述词为无效的或该文件已关闭
    EINTR 此调用被信号所中断
    EINVAL 参数n 为负值。
    ENOMEM 核心内存不足

理解select执行过程

理解select模型的关键在于理解fd_set,为说明方便,取fd_set长度为1字节(8bit),fd_set中的每一个bit可以对应一个文件描述符fd。则1字节长的fd_set最大可以对应8个fd.
(1)执行fd_set set; FD_ZERO(&set); 则set用位表示是0000 0000。
(2)若fd=5,执行FD_SET(fd,&set)后set变为0001,0000(第5位置为1)
(3)若再加入fd=2,fd=1,则set变为0001 0011 (第一和第二位置为1)
(4)执行select(6,&set,NULL,NULL,NULL) 阻塞等待 (6=5+1,最大位加一)
(5)若fd=1,fd=2上都发生可读事件,则select返回,此时set变为0000,0011。
注意:没有事件发生的fd=5被清空(即只返回有IO事件发生的,未发生的被清空),所以每次要重新更新一次读集合。

socket就绪条件

读就绪

  • socket内核中, 接收缓冲区中的字节数, 大于等于低水位标记SO_RCVLOWAT(程序员自己定义的buf字节大小). 此时可以无阻塞的读该文件描述符, 并且返回值大于0(读到的字节数);
  • socket TCP通信中, 对端关闭连接, 此时对该socket读, 则返回0;
  • 监听的socket上有新的连接请求;
  • socket上有未处理的错误;

写就绪

  • socket内核中, 发送缓冲区中的可用字节数(发送缓冲区的空闲位置大小), 大于等于低水位标记SO_SNDLOWAT, 此时可以无阻塞的写, 并且返回值大于0;
  • socket的写操作被关闭(close或者shutdown). 对一个写操作被关闭的socket进行写操作, 会触发SIGPIPE信号;
  • socket使用非阻塞connect连接成功或失败之后;
  • socket上有未读取的错误;

select使用示例: 检测标准输入输出

#include<stdio.h>
#include<unistd.h>
#include<sys/select.h>

int main()
{
  fd_set read_fds;//定义一个读描述符集合
  FD_ZERO(&read_fds);//设置为空
  FD_SET(0,&read_fds);//将标准输入描述符加进去


  while(1)
  {
    int ret=select(0+1,&read_fds,NULL,NULL,NULL);//阻塞监控
    if(ret<0)
    {
      perror("select");
      return 0;
    }
    if(FD_ISSET(0,&read_fds)) //判断是否有事件发生
    {
      char buf[1024]={0};//便进行IO操作
      read(0,buf,sizeof(buf)-1);
      printf("input buf : %s\n",buf);
    }
    FD_CLR(0,&read_fds);
    FD_SET(0,&read_fds);
  }
  return 0;
}

当只检测文件描述符0(标准输入)时,因为输入条件只有在你有输入信息的时候,才成立,所以如果一直不输入,就会一直阻塞在监视标准输入的逻辑中。

select优缺点

优点:

  • 1.select遵循的是posix标准,可以跨平台移植
  • 2.select的超时时间可以精确到微秒

缺点:

  • 1.select是轮询遍历的,监控的效率会随着文件描述符的增多而下降,且一旦事件响应体很大(处理时间很长),那么就会导致后续的事件迟迟得不到处理,并且会影响新的事件轮询
  • 2.select所能监控的文件描述符是有上限的,上限为1024, 取决于内核当中的_ FD_ SETSIZE宏的值
  • 3.select监控文件描述符的时候,需要将集合拷贝到内核当中,监控到文件描述符上有事件就绪的时候,同样需要从内核当中拷贝到用户空间,效率会受到影响
  • 4.select在返回就绪文件描述符的时候,会将未就绪的文件描述符移除掉,导致第二次在去监控的时候,需要重新添加
  • 5.select没有直接告诉我们哪一个文件描述符就绪了,一旦select返回,就需要我们自己在返回的事件集合当中用FD_ ISSET宏函数来判断哪个描述符准备好可以读了。

基于select的并发TCP服务器通信

在上次tcp的客户端服务器通信的基础上,使用IO多路转接来实现并发处理多个客户端的连接请求或者消息响应。

理论上基于select的并发TCP服务器的功能(main函数逻辑):

  • 1.创建套接字
  • 2.绑定地址信息
  • 3.将套接字传化为一个侦听套接字
  • 4.将侦听套接字添加到select的可读事件集合当中
  • 5.select监听(轮询监听)
  • 6.select返回,判断是侦听套接字来就绪,还是新的套接字就绪
    – 若是有新连接到来:
    accept 函数–》返回一个新的套接字描述符–》接着将其添加到可读事件集合中去
    –若是 有输入数据到来:
    recv函数–》read读取数据

Select功能封装 在SelectSvr.hpp中
成员函数:

  • 1.添加文件描述符到集合当中,且更新max_fd
  • 2.从集合当中删除文件描述符, 且更新max_fd
  • 3.监控接口 将监控到的有IO事件触发(IO就绪)的套接字组织在给tcp服务器与各个客户端通信使用的tcpsvr对象

成员变量:
int max_fd_; //表示监控的最大文件描述符的数值,用于轮询边界
fd_set readfds; //监控的可读事件集合

SelectSvr.hpp Select功能封装

#pragma once 
#include<stdio.h>
#include<unistd.h>
#include<sys/select.h>
#include<vector>
#include "tcpclass.hpp"
class SelectSvr
{
  public:
    SelectSvr()
    {
      max_fd_=-1;
      FD_ZERO(&readfds_);
    }
    ~SelectSvr()
    {}
    void Add(int fd)
    {
      //添加
      FD_SET(fd,&readfds_);
      //更新最大描述符
      if(fd>max_fd_)
      {
        max_fd_=fd;
      }
    }

    void Delete(int fd)
    {
      //删除
      FD_CLR(fd,&readfds_);
      //更新最大描述符 因为是在位向量里,都是有序的,所以从后往前找,直到有不为0的(就是此位图里最大的)
      for(size_t i=max_fd_;i>=0;i--)
      {
        if(FD_ISSET(i,&readfds_))
        {
          max_fd_=i;
          break;
        }
      }
    }

    bool Selectlisten(vector <Tcpsc>* v)//出参数组,保存就绪的文件描述符
    {
      //设置为带有超时时间的监听
      struct timeval tv;
      tv.tv_sec=2;
      tv.tv_usec=0;

      //由于select的副作用,返回时(只会返回有IO事件触发的描述符集合)即会把未被触发的事件清除,所以每次监视时必须传一个临时值
      fd_set tmp=readfds_;
      int ret = select( max_fd_+ 1, &tmp,NULL,NULL,&tv);
      if(ret<0){
        perror("select");
        return false;
      }
      else if(ret==0)
      {
        printf("select timeout\n");
        return false;
      }
      //正常检测到了,轮询描述符集合,找到被触发的IO事件描述符
      for(int i=0; i<max_fd_;i++)
      {
        if(FD_ISSET(i,&tmp))//返回就绪的文件描述符,用tcp类对象保存
        {
          Tcpsc ts;
          ts.Setfd(i);
          v->push_back(ts);
        }
      }
      return true;
    }

  private:
    int max_fd_;
    fd_set readfds_;

};



tcpclass.hpp套接字接口封装

#pragma once 
#include<cstdio>
#include<cstdlib>
#include<unistd.h>
#include<string>
#include<sys/socket.h>
#include<arpa/inet.h>
#include<iostream>
#include<netinet/in.h>
#include<sys/types.h>
using namespace std;

class Tcpsc
{
  public:
    Tcpsc()
    {
      sock_=-1;
    }
    ~Tcpsc()
    {

    }
    //创建套接字
    bool CreateSocket()
    {
      sock_=socket(AF_INET,SOCK_STREAM,IPPROTO_TCP);
      if(sock_<0)
      {
        perror("socket");
        return false;
      }
      int opt=1;
      setsockopt(sock_, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof (opt));//地址复用

      return true;
    }
    //绑定地址信息
    bool Bind(std::string& ip,uint16_t port)
    {
      struct sockaddr_in  addr;//组织成ipv4地址结构
      addr.sin_family =AF_INET;
      addr.sin_port=htons(port);
      addr.sin_addr.s_addr=inet_addr(ip.c_str());
      int ret=bind(sock_,(struct sockaddr*)&addr,sizeof(addr));
      if(ret<0)
      {
        perror("bind");
        return false;
      }
      return true;
    }
    //监听
    bool Listen(int backlog=5)
    {
      int ret=listen(sock_,backlog);
      if(ret<0)
      {
        perror("listen");
        return false;
      }
      return true;
    }

    //accept 服务器获取连接
    //bool Accept(struct sockaddr_in* peeraddr,int* newfd)
    //peeraddr :出参。保存的是客户端的地址信息,newfd:出参,表示完成连接的可以进行通信的新创建出来的套接字描述符
    
    bool Accept(struct sockaddr_in* peeraddr,Tcpsc* newsc)//这里用一个类的实例化指针,把数据传出去
    {
      socklen_t addrlen=sizeof(struct sockaddr_in);//记录地址信息长度
      int newserverfd=accept(sock_,(struct sockaddr*)peeraddr,&addrlen);
      if(newserverfd<0)
      {
        perror("accept");
        return false;
      }
      newsc->sock_=newserverfd;//传出去新创建出来的用来通信的套接字
      return true;
    }
    //connect 客户端调用来连接服务端
    bool Connect(string& ip,uint16_t port)
    {
      struct sockaddr_in addr;//还是先组织服务端地址信息
      addr.sin_family =AF_INET;
      addr.sin_port=htons(port);
      addr.sin_addr.s_addr=inet_addr(ip.c_str());
      int ret=connect(sock_,(struct sockaddr*)&addr,sizeof(addr));
      if(ret<0)
      {
        perror("connect");
        return false;
      }
      return true;
    }
    //因为是已经建立连接了的,所以参数就只是数据,和已完成连接的可以进行通信的socket套接字
    //发送数据
    bool Send(string& data)
    {
      int sendsize=send(sock_,data.c_str(),data.size(),0);
      if(sendsize<0)
      {
        perror("sned");
        return false;
      }
      return true;
    }
    //接收数据
    bool Recv(string* data)//出参,保留信息
    {
      char buf[1024]={0};
      int recvsize=recv(sock_,buf,sizeof(buf)-1,0);
      if(recvsize<0)
      {
        perror("recv");
        return false;
      }
      else if(recvsize==0)//对端已关闭close
      {
        printf("peer is close connect");
        return false;
      }
      (*data).assign(buf,recvsize);//赋值给传出型参数
      return true;
    }
    //关闭套接字
    void Close()
    {
        close(sock_);
        sock_=-1;
    }
    
    int Getfd()
    {
      return sock_;
    }
    void Setfd(int fd)
    {
      sock_=fd;
    }

  private:
    int sock_;
};

客户端连接操作及收发数据


#include"tcpclass.hpp"

int main(int argc,char* argv[])
{
  if(argc!=3)
  {
    printf("please enter true server_ip and port!");
      return 0;
  }
  string ip=argv[1];
  uint16_t port=atoi(argv[2]);
  Tcpsc sc;
  if(!sc.CreateSocket())
  {
    return 0;
  }
  if(!sc.Connect(ip,port))
  {
    return 0;
  }
  //连接完成,开始收发数据
  while(1)
  {
    //发送数据
    printf("cli say:");
    fflush(stdout);
    string buf;
    cin>>buf;
    sc.Send(buf);
    
    //接收服务端回复的数据
    sc.Recv(&buf);
    printf("server reply:%s\n",buf.c_str());
  }
  sc.Close();//其实进程结束后会自动关闭描述符的
  return 0;
}

main.cpp 基于select的并发TCP服务器

#include"selectsvr.hpp"
#define CHECK_RET(p) if(p!=true ) return -1; //bool返回值为假,返回ret为-1; 


int main()
{
  Tcpsc listen_ts;
  CHECK_RET(listen_ts.CreateSocket());
  string ip="0.0.0.0";
  CHECK_RET(listen_ts.Bind(ip,19999));
  CHECK_RET(listen_ts.Listen());

  SelectSvr ss;//描述符集合对象
  ss.Add(listen_ts.Getfd());//将侦听套接字加入集合

  //开始监控
  while(1)
  {
    //监控
    vector <Tcpsc>  v;
    if(!ss.Selectlisten(&v))
    {
      continue;
    }
    //监听函数返回了,说明有IO事件触发
    //判断是新连接还是数据到来
    for(size_t i=0;i<v.size();i++)
    {
        //与侦听套接字比较,看是不是侦听套接字的IO事件,若是,则是新连接,不是,则是数据到来
        if(v[i].Getfd() ==  listen_ts.Getfd())
        {
          //则调用accept函数,创建一个新连接供双方通信,且也放入套接字集合中去
          struct sockaddr_in peeraddr;//对端地址信息
          Tcpsc newaddr;
          listen_ts.Accept(&peeraddr,&newaddr);
          printf("响应一个新连接:[ip]:%s, [port]:%d\n",inet_ntoa(peeraddr.sin_addr),peeraddr.sin_port);

          //再将此连接对应的描述符放入到事件集合中,由select监控
          ss.Add(newaddr.Getfd());
        }
        else 
          //数据到来,用户层准备读取
        {
            string buf;
            int ret=v[i].Recv(&buf);
            if(!ret)
            {
              ss.Delete(v[i].Getfd());
              v[i].Close();
            }
            printf("客户端发来消息:%s\n",buf.c_str());
        }
    }
  }
  return 0;
}

在这里插入图片描述
基于IO多路复用的tcp服务器的优点:

  • 一个基于I/O多路复用的事件驱动服务器是运行在单一进程上下文中的,因此每个逻辑流都能访问该进程的全部地址空间。这使得在流之间共享数据变得很容易。一个与作为单个进程运行相关的优点是,你可以利用熟悉的调试工具,例如GDB,来调试你的并发服务器,就像对顺序程序那样。
  • 并且事件驱动设计常常比基于进程的设计要高效得多,因为它们不需要进程上下文切换来调度新的流。
  • 5
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值