设计一个高性能的网络服务器的时候可以用到I/O多路复用,意思就是通过一种机制,可以监听多个文件描述符,比如说socket,以此可以达到多个客户端同时请求连接到服务端,并可以进行数据的交互。select、poll、epoll等都是这类机制。当然,要实现多个客户端同时和服务端通信,多线程也是一种办法。
select服务器模型最大的优势是用户可以在单线程内同时处理多个客户端的IO请求,用户可以注册多个socket,然后不断调用select读取被激活的socket,然后进行数据交互。
select从本质上是同步IO,也就是读写操作并不是操作系统自己进行的,而是需要自己主动读写数据,且这个过程是阻塞的。
但是select也有缺点:
也因此select适用于连接数少并且连接都十分活跃的情况,select低效是因为每次它都需要轮询所有fd。但低效也是相对的,需要具体情况而定。
select模型
下面我以socket通信为基础,也就是以SOCKET变量为文件描述符。
首先,先看一下select创建函数
int
WSAAPI
select(
_In_ int nfds,
_Inout_opt_ fd_set FAR * readfds,
_Inout_opt_ fd_set FAR * writefds,
_Inout_opt_ fd_set FAR * exceptfds,
_In_opt_ const struct timeval FAR * timeout
);
简写一下就是:
int select (int nfds, fd_set *readset, fd_set *writeset,
fd_set *exceptset, const struct timeval * timeout);
可以看到一共有5个参数,并且中间3个参数都是fd_set类型,这里先说明fd_set。fd_set就是一个集合,里面装着文件描述符,下面简称叫做叫做fd。windows源码为:
#define FD_SETSIZE 64
typedef struct fd_set {
u_int fd_count; /* how many are SET? */
SOCKET fd_array[FD_SETSIZE]; /* an array of SOCKETs */
} fd_set;
其中fd_count表示当前集合中总的socket数量。有一个数组fd_array,包括可处理的最大socket数量,为64。
而在select模型中,文件描述符分:可读,可写,异常这三种情况,也分别对应select函数的第2,3,4个参数。如果只是要检测可读的文件描述符,那么只需要创建一个fd_set集合,作为第二个参数,另外两个写NULL即可。如果三种情况都要检测,那么就创建三个fd_set,分别对应三个参数。
fd_set实际上是一个bitmap,也就是一个位图,共有1024位。用来表征哪一个文件描述符被启用或者被监听。当有某个文件描述符 接收到数据,该位就会被置1。
select函数运行的时候,会把bitmap从用户态拷贝到内核态,在内核态来判断哪个fd可用。如果一直没有客户端来连接或者传数据,那么select就会默认一直阻塞,不会往下执行。
接下来看第一个和最后一个参数:
①第一个参数为最大文件描述符+1,注意到这里不是最大的文件描述符数量+1,而是最大的文件描述符的数值+1。以socket为例,就是存在集合中的最大socket的值+1,而不是socket最大数量+1,这点要区分开。
并且,在windows中这个值可以直接写0,因为windows会自动处理,但在Linux下,最好就自定义一个max来记录最大值,再把max+1作为第一个参数。
②最后一个参数为timeval,其函数原型是:
struct timeval {
long tv_sec; /* seconds */
long tv_usec; /* and microseconds */
};
如果该参数写NULL,就表示这是一个阻塞的select,只有当集合中某个文件描述符发生变化的时候才返回,没有的话就一直等待,阻塞在这个函数,不往下执行。
如果自定义一个timeval,就是指定一个等待的时间,在这个时间内没有事件发生,就会立即返回,接下去执行别的业务。
总结来说,select干的活就是:我们把文件描述符收集好放在一个集合里,select帮我们判断哪些fd有数据,并将其置位(FD_SET),然后就return。如果一直没有,就会阻塞(在最后一个参数为NULL的情况下)。
那我们接下去就要遍历这个集合,看看哪一个被置位了(FD_ISSET),然后我们就把被置位的fd拿去和客户端通信。
接下来列出一些windows中select中其他常用的函数:
FD_ZERO(set){
((fd_set FAR *)(set))->fd_count=0; //清空集合
}
FD_CLR(fd, set); //从集合(set)中删除给定的fd(文件描述符)
下面给出服务端select模型的一个简单测试,在本机测试:
#define WIN32_LEAN_AND_MEAN
#include <Windows.h>
#include <WinSock2.h>
#include <iostream>
#include <vector>
using namespace std;
using std::system;
enum {
CMD_LOGIN,
CMD_LOGIN_RES,
CMD_LOGOUT,
CMD_LOGOUT_RES,
CMD_ERROR
};
struct DataHeader { //数据头
short dataLength;
short cmd;
};
struct Login :public DataHeader { //数据体
Login() {
dataLength = sizeof(Login);
cmd = CMD_LOGIN;
}
char user_name[32];
char passwd[32];
};
struct Logout :public DataHeader { //数据体
Logout() {
dataLength = sizeof(Logout);
cmd = CMD_LOGOUT;
}
char user_name[32];
};
struct LoginRes :public DataHeader { //数据体
LoginRes() {
dataLength = sizeof(LoginRes);
cmd = CMD_LOGIN_RES;
result = CMD_LOGIN_RES;
}
int result;
};
struct LogoutRes :public DataHeader { //数据体
LogoutRes() {
dataLength = sizeof(LogoutRes);
cmd = CMD_LOGOUT_RES;
result = CMD_LOGOUT_RES;
}
int result;
};
//处理新连接上的客户端 数据交互
bool data_interact(SOCKET m_clientfd,const char* addr) {
DataHeader header = { }; //接收数据头
if (recv(m_clientfd, (char*)&header, sizeof(DataHeader), 0) <= 0) {
cout << "客户端" << addr << "已退出,任务结束" << endl;
return false;
}
switch (header.cmd) { //拿到数据头(Dataheader),下面再次接收要剔除sizeof(Dataheader)
case CMD_LOGIN: {
Login login = { };
if (recv(m_clientfd, (char*)&login + sizeof(DataHeader), sizeof(Login) - sizeof(DataHeader), 0) <= 0) {
cout << "接收客户端数据失败,原因是: " << WSAGetLastError() << endl;
break;
}
cout << "收到来自"<< addr <<"的命令: " << login.cmd << ",数据长度: " << login.dataLength
<< ",用户名: " << login.user_name << ",密码: " << login.passwd << endl;
LoginRes ret;
if (send(m_clientfd, (const char*)&ret, sizeof(LoginRes), 0) <= 0) {
cout << "向客户端发送数据失败..." << endl;
break;
}
}
break;
case CMD_LOGOUT: {
Logout logout = { };
if (recv(m_clientfd, (char*)&logout + sizeof(DataHeader), sizeof(Logout) - sizeof(DataHeader), 0) <= 0) {
cout << "接收客户端数据失败,原因是: " << WSAGetLastError() << endl;
break;
}
cout << "收到来自" << addr << "的命令: "<< logout.cmd << ",数据长度: " << logout.dataLength
<< ",用户名: " << logout.user_name << endl;
LogoutRes ret;
if (send(m_clientfd, (const char*)&ret, sizeof(LogoutRes), 0) <= 0) {
cout << "向客户端发送数据失败..." << endl;
break;
}
}
break;
default: {
header.cmd = CMD_ERROR;
header.dataLength = 0;
if (send(m_clientfd, (const char*)&header, sizeof(DataHeader), 0) <= 0) {
cout << "向客户端发送数据失败..." << endl;
break;
}
}
break;
}
}
以上是数据形式结构体的定义 和 一个data_interact函数,
这个函数是recv和send函数的封装,拿来作为服务端和客户端的数据交互。
main函数里是select的主体
SOCKET g_clients[20] = { 0 }; //装accept返回值的socket
int flag = 0; //判断是否有客户端发消息的标志
int main() {
WORD ver = MAKEWORD(2, 2);
WSADATA dat;
WSAStartup(ver, &dat); //windows下socket通信的启动函数
//
SOCKET m_listen = socket(AF_INET, SOCK_STREAM, 0); //监听socket
sockaddr_in sin = { 0 }; //服务端结构体
sin.sin_family = AF_INET;
sin.sin_port = htons(5000); // host to net unsigned short
sin.sin_addr.S_un.S_addr = htonl(INADDR_ANY); //如果是特定客户端连接, ...=inet_addr("");
const char* clientAddr = inet_ntoa(sin.sin_addr);
if (SOCKET_ERROR == bind(m_listen, (sockaddr*)&sin, sizeof(sin))) {
cout << "绑定失败!" << endl;
closesocket(m_listen);
return 0;
}
cout << "服务端绑定成功..." << endl;
if (listen(m_listen, 5) != 0) {
closesocket(m_listen);
return 0;
}
cout << "服务端开监听..." << endl;
fd_set fdRead,tempfd; //可读socket集合
FD_ZERO(&fdRead); //清空fdRead,其实就是将bitmap全部置0
FD_SET(m_listen, &fdRead); //添加监听socket到集合里,因为此时只有这一个socket
while (1) {
tempfd = fdRead; //保护fdRead原来的情况
int maxfd = m_listen; //select函数的第一个参数需要
timeval t = { 0,0 }; //完全非阻塞
int ret = select(maxfd + 1, &tempfd, NULL, NULL, &t);
if (ret < 0) {
perror("select异常,任务结束");
break;
}
//有文件描述符可读(有客户端发起请求)
int i=0;
if (FD_ISSET(m_listen, &tempfd)) { //判断是哪一个fd可读,也就是被置位,也即是否有数据
for (i = 0; i < 20; i++) {
if (g_clients[i] == 0) { //找到可用的坑位装新连接的客户端
break;
}
}
g_clients[i] = accept(m_listen, 0, 0); //将可用的m_listen拿来accept
if (g_clients[i] == SOCKET_ERROR) {
cout << "accept失败..." << endl;
exit(-1);
}
FD_SET(g_clients[i], &fdRead); //把新的accept返回的socket装进集合
if (maxfd < g_clients[i]) {
maxfd = g_clients[i]; //更新maxfd
}
cout << "客户端" << inet_ntoa(sin.sin_addr) << ",fd = " << g_clients[i] << "连接成功" << endl << endl;
}
else { //有客户端发消息
for (i = 0; i < 20; i++) {
if (FD_ISSET(g_clients[i], &tempfd)) { //找到发消息的客户端fd
flag = 1; //找到了被置位的那个连接socket
break;
}
}
if(flag == 1) { //拿到被置位的连接socket来数据交互
if (data_interact(g_clients[i], clientAddr) == false) {
closesocket(g_clients[i]);
FD_CLR(g_clients[i], &fdRead); //从集合中清除
g_clients[i] = 0; //让出坑位
flag = 0; //记得重置
}
flag = 0; //记得重置
}
}
}
for (int i = 0; i < 20; i++) {
closesocket(g_clients[i]);
}
WSACleanup();
getchar();
return 0;
}
我是在本机测试的,而且我定义的SOCKET数组大小是20(测试用就不用定义太多了,最多可以有1024位 ),现在开几个客户端试一试效果.
可以看到,我开了6个客户端,都可以顺利连接,并且完成数据交互。需要注意,我的程序是完全非阻塞的,因为我定义的timeval = {0,0}; 也就是只要客户端没数据过来,程序就不会停在select,而是继续执行下面的程序。有数据过来再返回。因此,大家可以灵活运动这个timeval,如果是非阻塞,可以在下面加多别的业务程序,比如主动向客户端发东西等等,充分提高CPU利用率。
最后再总结一下select服务器模型的应用大致步骤:
1.要先创建fd_set集合,拿来装socket,具体有三种:可读、可写、异常,根据自己的需要去处理。
2.这是单线程程序,一个服务端只有一个监听的socket,一开始创建fd_set的时候,就把监听的socket放进去(FD_SET),以监听有没有客户端请求连接。
3.接下来,就分为两种情况:①客户端从未连接过,那就进入第一个判定,触发了监听socket,用accept创建连接socket,并且放入fd_set和g_clients[ ]数组,更新maxfd。 ②客户端已经accept过,那当它发消息来的时候,就会被置位,我们判定是哪一个g_clients[ n ]被置位,找到之后拿出来做数据交互就可以了。
4.数据交互失败或者客户端退出之后,记得调用closesocket关闭socket。