前言
记得我们之前写的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,来调试你的并发服务器,就像对顺序程序那样。
- 并且事件驱动设计常常比基于进程的设计要高效得多,因为它们不需要进程上下文切换来调度新的流。