c++应用网络编程之九Linux下的select模式
一、select模式
通过前面的分析,可以知道select模式是一个非常普遍的应用模式。尤其是在早期的一些网络通信,包括一些大型的网络应用,几乎全是基于select模式的。select模式支持常见的多种平台,所以这种网络服务端的开发模式也为广大的开发者所熟悉。
做为IO多路复用的基础应用,它主要解决了在C/S编程中,网络通信的阻塞问题。之所以叫IO多路复用,就是内核中可以监听多个文件描述符(Socket),当发现其IO操作就绪后可以进行网络通信。
二、特点
做为一种基础的网络通信模型它有优点如下:
1、既然是IO多路复用,它就可以可以同时处理多个套接字即支持较高的网络并发
2、事件驱动,不需要反复循环判断
3、跨平台,支持主流的OS
4、开发相对简单,维护容易
当然有优点就必然有缺点:
1、监听的文件描述符受限(一般是1024,但可以通过修改相关文件提高,不过一般不推荐)
2、效率低,需要每次遍历所有描述符。随着描述符的数量增加,效率会快速下降。而且每次事件发生都要进行内核与用户空间的数据拷贝,导致性能下降
3、一般不支持非阻塞IO。
三、数据结构和API
下面介绍和分析一下select模式的相关接口和数据结构,这里只针对Linux平台。看一下其相关代码:
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
//说明:
//nfds:表示待监听的描述符个数,也经常写作maxfd+1
//readfds:内核读事件的描述符集合,不关心可以设置为NULL
//writefds:写事件的描述符集合,不关心可以设置为NULL
//exceptfds:异常处理的描述符集合,不关心可以设置为NULL
//timeout:超时设置,NULL表示如果没有描述符就绪则永远等待;固定值,则为指定时间内如果没有就绪描述符则返回;0表示轮询检测。
//fd_set在内核中定义其实是下面的一个位数组:
typedef struct {
unsigned long fds_bits[__FD_SETSIZE / (8 * sizeof(long))];
} __kernel_fd_set;
struct timeval
{
long tv_sec; // 秒数
long tv_usec; // 微秒数
};
void FD_SET(int fd, fd_set *set); //设置文件描述符fd
void FD_CLR(int fd, fd_set *set); //清除集合的fd位,只清除一个
int FD_ISSET(int fd, fd_set *set); //判断文件描述符fd的设置状态
void FD_ZERO(fd_set *set); //清空所有描述符的位状态
上面的接口其实有些小细节,经常在面试时可能会被问到,比如为什么要描述符的最大数量要加1?有兴趣的可以在网上查询一下(其实就是和数组的长度一样)。网络通信其实是有一定的延袭性的,把一些细节认真分析一下,会有好处。
四、例程
一般在网上看到的都是Select做服务端,其实它也可以做为客户端编程。两样,Select也可以用来做为一种定时器,其它的一些用法,大家可以自行查找,这里举出一个服务端和一个客户端的例子:
//客户端
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <iostream>
#include <string.h>
#include <errno.h>
#include <string.h>
#define EXP_FD -1
constexpr int BUF_LEN = 1024;
int main()
{
int clifd = socket(AF_INET, SOCK_STREAM, 0);
if (clifd == EXP_FD)
{
return -1;
}
struct socksddr_in cliaddr;
cliaddr.sin_family = AF_INET;
cliaddr.sin_addr.s_addr = inet_addr("127.0.0.1");
cliaddr.sin_port = htons(8888);
if (-1 == connect(clifd, (struct socksddr_in*)&cliaddr, sizeof(cliaddr)))
{
std::cerr << "do not connect server!" << std::endl;
close(clifd);
return -1;
}
int ret = -1;
while (true)
{
fd_set recvSet;
FD_ZERO(&recvSet);
FD_SET(clifd, &recvSet);
ret = select(cleintfd + 1, &recvSet, NULL, NULL, NULL);
if (ret == -1)
{
if (errno != EINTR)
{
break;
}
}
else if (ret > 0)
{
if (FD_ISSET(clifd, &recvSet))
{
char recvbuf[BUF_LEN];
memset(recvbuf, 0, sizeof(recvbuf));
int count = recv(clifd, recvbuf, BUF_LEN, 0);
if (count < 0) {
if (errno != EINTR)
{
break;
}
}
else if (count == 0) {
std::cerr << "remote server close!" << std::endl;
break;
}
else {
std::cerr << "recv data len: " << count << std::endl;
}
}
}
else if (ret == 0)
{
continue;
}
}
close(clifd);
return 0;
}
本来是想把原来写的实际程序放上来,结果看了看程序,封装的太深,还加了不少的业务相关代码,所以只好简化成一个只是为描述Select模型的代码贴上来了。
//服务端
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <sys/time.h>
#include <iostream>
#include <string.h>
#include <vector>
#include <errno.h>
constexpr int BUF_LEN = 1024;
#define EXP_FD -1
int main()
{
//注意参数
int listenfd = socket(AF_INET, SOCK_STREAM, 0);
if (listenfd == EXP_FD)
{
std::cerr << "listen socket not create." << std::endl;
return -1;
}
//初始化
struct sockaddr_in serverAddr;
serverAddr.sin_family = AF_INET;
serverAddr.sin_addr.s_addr = htonl(INADDR_ANY);
serverAddr.sin_port = htons(8888);
if (bind(listenfd, (struct sockaddr *)&serverAddr, sizeof(serverAddr)) == -1)
{
std::cerr << "bind error." << std::endl;
close(listenfd);
return -1;
}
if (listen(listenfd, 3) == -1)
{
close(listenfd);
return -1;
}
std::vector<int> cliFds;
int maxfd;
while (true)
{
fd_set recvSet;
FD_ZERO(&recvSet);
FD_SET(listenfd, &recvSet);
maxfd = listenfd;
int fdsLen = cliFds.size();
for (int i = 0; i < fdsLen; ++i)
{
if (cliFds[i] != EXP_FD)
{
FD_SET(cliFds[i], &recvSet);
if (maxfd < cliFds[i])
{
maxfd = cliFds[i];
}
}
}
timeval tmv;
tmv.tv_sec = 2; tmv.tv_usec = 0;
//只处理读事件
int ret = select(maxfd + 1, &recvSet, NULL, NULL, &tmv);
if (ret == -1)
{
if (errno != EINTR)
{
break;
}
}
else
{
if (FD_ISSET(listenfd, &recvSet))
{
struct sockaddr_in cliaddr;
socklen_t cliaddrlen = sizeof(cliaddr);
//接受连接
int clifd = accept(listenfd, (struct sockaddr *)&cliaddr, &cliaddrlen);
if (clifd == EXP_FD)
{
break;
}
std::cerr << "accept err,fd: " << clifd << std::endl;
cliFds.emplace_back(clifd);
}
else
{
char recvbuf[BUF_LEN];
int fdsLen = cliFds.size();
for (int i = 0; i < fdsLen; i++)
{
if (cliFds[i] != EXP_FD && FD_ISSET(cliFds[i], &recvSet))
{
memset(recvbuf, 0, sizeof(recvbuf));
//接收数据
int count = recv(cliFds[i], recvbuf, BUF_LEN, 0);
if (count <= 0)
{
close(cliFds[i]);
cliFds[i] = EXP_FD;
continue;
}
std::cerr << "recv data len: "<< count << std::endl;
}
}
}
}
else if (ret == 0)
{
std::cerr << "timeout!" << std::endl;
continue;
}
}
//clear connected resource
int fdsLen = cliFds.size();
for (int i = 0; i < fdsLen; ++i)
{
if (cliFds[i] != EXP_FD)
{
close(cliFds[i]);
}
}
close(listenfd);
return 0;
}
这里有一个问题,就是在处理select等待超时的问题,一般客户端无所谓,简单设置成NULL即永远等待事件即可。但在服务端这样做的话,可能就降低了效率,那么到底这个超时时间设置为多少最为合适,需要开发者根据实际情况进行斟酌。包括客户端如果也有这种情况的话,都一样可以根据实际情况来确定一个最合适的超时时间。
相比于客户端,服务端麻烦在于要同时监听连接的客户端和处理已连接的客户端的数据通信,说白了就是增加了几个读写操作过程。这在后期的开发中,可以将其单独抽象出来,该增加线程就增加线程,该增加处理队列增加相关队列,比如下面的代码:
bool ServerSelect::CreateAcceptThread()
{
bool bRet = true;
this->_acceptThread = std::thread([&]()mutable throw()->bool{
//处理接收队列
SOCKET temp;
while (true)
{
//处理监听
SOCKADDR_IN sinClient;
int lenClient = sizeof(sinClient);
temp = accept(_listenSock, (SOCKADDR*)&sinClient, &lenClient);
//处理远端客户连接并保存入链表供处理和使用
std::shared_ptr<ClientData> pcd = GetHandle();
pcd->Socket = temp;
pcd->Port = ntohs(sinClient.sin_port);
pcd->Sip = inet_ntoa(sinClient.sin_addr);
AddClientHandle(pcd);
SetReadSocket();
}
return bRet;
});
return bRet;
}
不过不用着急,慢慢来,一天进步一点点。苟日新,日日新!
五、总结
虽然在现代的高并发网络通信中,几乎已经看不到select模式的身影,但这并不代表着它的作用很小。正如前面反复所讲,最合适的才是最好的。一个并发量不大,安全要求又高的场景下,可能一个普通程序员用Select模式就能搞定。但非要上一些epoll之类的“高端大气上档次”的模型,未必是一件好事情。
开发者习惯的东西未必是一个全面的知识,select模式既可以用在服务端也可以用在客户端,这估计让许多初学者可能跌了一下眼镜。虽然大多是在讲服务端编程时学习select模型,但这不代表着他只能应用在服务端。这也提醒开发者,不要把习惯当成一种必然,要勇于打破知识蚕茧,去探索外面未知的世界。