C++游戏服务器框架笔记(一)_封装数据包类
C++游戏服务器框架笔记(二)_封装Socket类
C++游戏服务器框架笔记(三)_封装ByteBuffer类
C++游戏服务器框架笔记(四)_封装Select
因为设想的次系列服务器主要应用场景是linux系统下,支持Windows系统是为了更好的调试和开发,为了简单起见,我在windows下选择使用select()这一IO多路复用模型。
一、Select()函数信息:
微软官网的函数介绍:
#include <WinSock2.h>
int WSAAPI select(
[in] int nfds,
[in, out] fd_set *readfds,
[in, out] fd_set *writefds,
[in, out] fd_set *exceptfds,
[in] const timeval *timeout
);
参数
[in] nfds
已忽略。 仅包含 nfds 参数,以便与 Berkeley 套接字兼容。
[in, out] readfds
一个可选指针,指向一组要检查的套接字是否可读。
[in, out] writefds
一个可选指针,指向要检查的一组套接字是否可写。
[in, out] exceptfds
指向一组要检查错误的套接字的可选指针。
[in] timeout
选择等待的最大时间,以 TIMEVAL 结构的形式提供。 将 超时 参数设置为 null 以阻止操作。
typedef struct timeval {
long tv_sec;
long tv_usec;
} TIMEVAL, *PTIMEVAL, *LPTIMEVAL;
tv_sec
时间间隔,以秒为单位。
tv_usec
时间间隔(以微秒为单位)。 此值与 tv_sec 成员结合使用来表示非秒数的时间间隔值。
(我在代码中设置的时间是0)
返回值
select 函数返回已准备就绪且包含在fd_set结构中的套接字句柄总数、时间限制过期时为零;如果发生错误,则返回SOCKET_ERROR。 如果返回值SOCKET_ERROR,可以使用 WSAGetLastError 检索特定的错误代码。
错误代码 | 含义 |
---|---|
WSANOTINITIALISED | 在使用此函数之前,必须执行成功的 WSAStartup 调用。 |
WSAEFAULT | Windows 套接字实现无法为其内部操作或 readfd、writefd、exceptfd 或 timeval 参数不是用户地址空间的一部分。 |
WSAENETDOWN | 网络子系统失败。 |
WSAEINVAL | 超时值无效,或者所有三个描述符参数均为 null。 |
WSAEINTR | 阻止 Windows 套接字 1.1 调用通过 WSACancelBlockingCall 取消。 |
WSAEINPROGRESS | 正在执行阻止的 Windows 套接字 1.1 调用,或者服务提供商仍在处理回调函数。 |
WSAENOTSOCK | 描述符集之一包含不是套接字的条目。 |
select()相关宏
头文件 Winsock2.h 定义了FD_SETSIZE确定了fd_set限制的描述符的最大数目,windows下默认值是64,对于这个限制,我们可以在包含Winsock2.h前,重定义FD_SETSIZE来修改这个限制,不过不建议修改太大,太大的话效率会很低~
头文件 Winsock2.h 中定义了四个宏,用于操作和检查描述符集(fd_set):
FD_ZERO(set) 初始化set设置为空集,在使用之前,应该使用此宏清空set。
FD_CLR(fd, set) 从set中删除fd套接字。
FD_ISSET(fd, set) 检查fd是否为set的成员,返回true 或者false。
FD_SET(fd, set) 添加fd到set集。
现在来详细看下Windows下这些宏的具体实现,其实linux下也是大差不差的,只复杂一点点,
我们先看下Winsock2.h 中fd_set的结构:
//UINT_PTR unsigned long long or unsigned __int64
typedef UINT_PTR SOCKET;
//u_int ===> unsigned int
typedef struct fd_set {
u_int fd_count; /* how many are SET? */
SOCKET fd_array[FD_SETSIZE]; /* an array of SOCKETs */
} fd_set;
SOCKET就是Windows下套接字描述符的类型,UINT_PTR 类型,UINT_PTR有事unsigned long long类型,其实SOCKET就是一个长整数类型。
fd_set有两个成员:
fd_count :记录了当前fd_set中有多少关注的SOCKET描述符
fd_array :是一个SOCKET的数组,记录了当前fd_set中关注的SOCKET描述符值,也就是linux下的socket fd。这里FD_SETSIZE就是定义了这个数组的大小,所以前面说的最大限制,就是体现在这里。
FD_ZERO(set):
#define FD_ZERO(set) set->fd_count = 0
这个宏的实现很简单,就只是将fd_set结构中记录描述符数量的fd_count设置为0,select()内部通过判断fd_set的fd_count去操作fd_array,所以这里改成0后,就可以达到清空的效果,而没有再去把数组重新初始化掉。
FD_CLR(fd, set):
//头文件Winsock2.h中宏定义代码
#define FD_CLR(fd, set) do { \
u_int __i; \
for (__i = 0; __i < ((fd_set FAR *)(set))->fd_count ; __i++) { \
if (((fd_set FAR *)(set))->fd_array[__i] == fd) { \
while (__i < ((fd_set FAR *)(set))->fd_count-1) { \
((fd_set FAR *)(set))->fd_array[__i] = \
((fd_set FAR *)(set))->fd_array[__i+1]; \
__i++; \
} \
((fd_set FAR *)(set))->fd_count--; \
break; \
} \
} \
} while(0, 0)
//上面代码乍一看感觉复复杂杂的,现在来给简化一个格式,去除干扰我们阅读的细枝末节
//FD_CLR(fd, set) 宏展开
#define FD_CLR(fd, set) do {
u_int __i;
for (__i = 0; __i < set->fd_count ; __i++) {
if (set->fd_array[__i] == fd) {
while (__i < set->fd_count-1) {
set->fd_array[__i] = set->fd_array[__i+1];
__i++;
}
set->fd_count--;
break;
}
}
} while(0, 0)
我们看下面简化和宏展开后的代码,代码逻辑就比较清晰了,对于从fd_set中删除描述符的操作,使用的是数组元素的前移——for循环遍历fd_set的fd_array数组,找到当前需要删除的fd描述符,找到之后,用while循环依次将当前数组下标后面每一个元素向前移动一个元素位置,最后fd_count减1,完成删除操作。
FD_ISSET(fd, set)
//__WSAFDIsSet是windows的一个api 检测fd是否包含在set数组中
#define FD_ISSET(fd, set) __WSAFDIsSet(fd, set)
FD_SET(fd, set):
//头文件Winsock2.h中FD_SET宏定义代码
#define FD_SET(fd, set) do { \
u_int __i; \
for (__i = 0; __i < ((fd_set FAR *)(set))->fd_count; __i++) { \
if (((fd_set FAR *)(set))->fd_array[__i] == (fd)) { \
break; \
} \
} \
if (__i == ((fd_set FAR *)(set))->fd_count) { \
if (((fd_set FAR *)(set))->fd_count < FD_SETSIZE) { \
((fd_set FAR *)(set))->fd_array[__i] = (fd); \
((fd_set FAR *)(set))->fd_count++; \
} \
} \
} while(0, 0)
//FD_SET(fd, set) 宏展开如下
do {
u_int __i;
for (__i = 0; __i < set->fd_count; __i++) {
if (set->fd_array[__i] == fd) {
break;
}
}
if (__i == set->fd_count) {
if (set->fd_count < FD_SETSIZE) {
set->fd_array[__i] = fd;
set->fd_count++;
}
}
} while(0, 0)
我们看下面简化和宏展开后的代码, for循环遍历fd_set的数组fd_array,如果发现本次设置的fd描述符存在则break不做任何操作,如果不存在,则判断当前fd_set的fd_count是否已经达到FD_SETSIZE的最大限制数量,如果未达到则可以设置成功,如果达到最大限制数量则不作操作,设置无效。
这几个宏的实现,应该都是挺好理解的,不过大家应该也发现了为什么上面的宏定义都使用了do{......}while(0,0) 的结构,注意这里的是没有;分号结尾的
思考一下,答案稍后揭晓~~~~
好了,关于宏定义中为什么使用do{...}while(0)结构
先说结论:为了宏的健壮性
只是这样说,太宽泛了,我们举例说明一下:
第一种情况 我们按照普通的方式定义一个宏
#define T(a) func1(a);func2(a);
//如果调用者在if中调用这样使用
if(a > 0)
T(a);
else
func1(a);
//当以上代码宏展开后如下:
if(a > 0)
func1(a);func2(a);
else
func1(a);
//虽然该例中,但是因为if...else 如果if后面不加{},那么if的作用域只对紧跟的一条语句(即只能有一句;结尾的语句)生效
//那么上面的代码实际上应该是这样子:
if(a > 0)
func1(a);
func2(a);
else
func1(a);
//这里就很明显,else会找不到与之对应的if,导致宏展开之后的代码报错
第二种情况 既然直接裸奔不行,那就加上{}给定一个局部作用域
//以上宏改成:
#define T(a) {func1(a);func2(a);}
//然后调用者还是如上调用
if(a > 0)
T(a);
//宏展开:
if(a > 0)
{func1(a);func2(a);};
else
func1(a);
//展开后{}的后面多了一个; 我们知道if(){...}else...语法,如果if()后面加上{}的话,else前是不能有;符号的,
//然而在C/C++代码中在语句末尾加上;符号基本成习惯性了,所以如果调用者习惯性加上;上述代码就报错了,
//而使用do{}while(0)的形式 在后面加上;是不会报错的,所以会更加健壮。
if(a > 0)
do{func1(a);func2(a);}while(0);
else
func1(a);
OK 对于select()函数的了解应该差不多了,如果需要更详细的信息,可以再去搜索引擎看看其他大佬的文章,这里主要是说下我的封装。
为了将Windows下的select()和linux下的epoll()封装为统一接口,所以需要首先定义一个接口类,来约定统一接口和一些类型,做到不同平台,调用接口一致,内部实现根据平台不同的跨平台效果。
统一接口类(多路复用基类):
#ifndef __POLLBASE_H_
#define __POLLBASE_H_
#include <stdlib.h>
#include <map>
#include <vector>
//统一描述符的类型命名,都为SOCKET
#ifndef _WIN32
typedef int SOCKET;
#define INVALID_HANDLE_VALUE (-1)
#endif
//重定义Windows下FD_SETSIZE的大小
#ifdef _WIN32
#define FD_SETSIZE 1024
#include <WinSock2.h>
#else
#include <unistd.h>
#include <sys/time.h>
#include <sys/epoll.h>
#include <sys/select.h>
#endif
#define MAXEVENT 1024
//统一定义Windows下select()和linux下epoll()的事件的宏
#define NONE 0 //无
#define POLLREAD 1 //读事件
#define POLLWRITE 2 //写事件
#define POLLET 3 //边缘触发(用于EPOLL, 是否设置边缘触发,默认是水平触发)
//定义统一的事件结构
typedef struct PollEvent {
int event;
int fd;
}PollEvent;
//接口类(抽象类)
class PollBase
{
public:
virtual ~PollBase() {};
/*
创建(初始化)接口
size: 可关注的描述符数量
返回值:
0: 成功
-1: 失败
*/
virtual int Create(int size) = 0;
/*
为fd描述符添加关注的事件
fd: socket文件描述符
mask: 上面统一定义的事件宏(读事件、写事件)
返回值:
0: 成功
-1: 失败
*/
virtual int AddEvent(SOCKET fd, int mask) = 0;
/*
删除fd描述符关注的事件
fd: socket文件描述符
mask: 上面统一定义的事件宏(读事件、写事件)
返回值:
0: 成功
-1: 失败
*/
virtual int DelEvent(SOCKET fd, int mask) = 0;
/*
监听事件到来,IO多路复用的核心
active_events: 输出参数,如果有事件到来,则会将事件设置到active_events输出
timeout: 超时时间(秒)
返回值:
0:无事件
>0:到来事件的数量
*/
virtual int Poll(PollEvent* active_events, int timeout = 0) = 0;
};
#endif
统一定义了接口之后,现在来继承接口类封装Windows下的select(),类名:Select
Select.h
#ifndef _SELECT_H_
#define _SELECT_H_
#include "PollBase.h"
class Select :
public PollBase
{
public:
~Select();
int Create(int size);
int AddEvent(SOCKET fd, int event);
int DelEvent(SOCKET fd, int event);
int Poll(PollEvent* active_events, int timeout = 0);
private:
std::map<SOCKET, int> m_EventMap;
//可读事件集,存储关注的读事件
fd_set m_ReadSet;
//可写事件集,存储关注的写事件
fd_set m_WriteSet;
//临时可读事件集,每次调用select()之前,将m_ReadSet中的值拷贝到该临时事件集中
fd_set m_TmpReadSet;
//临时可写事件集,每次调用select()之前,将m_WriteSet中的值拷贝到该临时事件集中
fd_set m_TmpWriteSet;
};
#endif // !_SELECT_H_
Select.cpp
#include "Select.h"
#ifdef _WIN32
Select::~Select() {
}
int Select::Create(int size) {
//初始化m_ReadSet和m_WriteSet描述符集,这里的参数size,用不到,可以忽略
FD_ZERO(&m_ReadSet);
FD_ZERO(&m_WriteSet);
return 0;
}
int Select::AddEvent(SOCKET fd, int event) {
//判断是否是可读事件
if (event & POLLREAD) {
//为fd关注可读事件
FD_SET(fd, &m_ReadSet);
}
//判断是否可写事件
if (event & POLLWRITE) {
//为fd关注可写事件
FD_SET(fd, &m_WriteSet);
}
return 0;
}
int Select::DelEvent(SOCKET fd, int event) {
//判断是否是可读事件
if (event & POLLREAD) {
//删除fd的可读事件
FD_CLR(fd, &m_ReadSet);
}
//判断是否是可读事件
if (event & POLLWRITE) {
//删除fd的可写事件
FD_CLR(fd, &m_WriteSet);
}
return 0;
}
int Select::Poll(PollEvent* active_events, int timeout) {
//由于select()的fd_set参数都是传出参数,所以每次调用返回之后,内部的值都会变成到达的事件集,
//所以不可以直接将&m_ReadSet或者&m_WriteSet作参数传递,会导致记录的事件丢失
//每次都应该拷贝到临时描述符集中
memcpy(&m_TmpReadSet, &m_ReadSet, sizeof(m_ReadSet));
memcpy(&m_TmpWriteSet, &m_WriteSet, sizeof(m_WriteSet));
//设置超时时间
struct timeval time = {};
time.tv_usec = timeout * 1000;
int fd;
u_int i = 0, event_index = 0;
int num = select(0, &m_TmpReadSet, &m_TmpWriteSet, nullptr, &time);
if (num > 0) {
//如果存在可读事件,遍历临时可读事件集,并将事件添加到active_events中
for (i = 0; i < m_TmpReadSet.fd_count; ++i, ++event_index) {
fd = m_TmpReadSet.fd_array[i];
active_events[event_index].event = POLLREAD;
active_events[event_index].fd = fd;
m_EventMap[fd] = event_index;
}
//如果存在可写事件,遍历临时可写事件集,并将事件添加到active_events中
for (i = 0; i < m_TmpWriteSet.fd_count; ++i) {
fd = m_TmpWriteSet.fd_array[i];
//
if (m_EventMap[fd]){
//如果fd已存在读事件,则可以直接用|运算加上写事件
active_events[m_EventMap[fd]].event |= POLLWRITE;
}
else {
active_events[event_index].event = POLLWRITE;
active_events[event_index].fd = fd;
++event_index;
}
}
}
return num;
}
#endif
封装后的大概调用流程,后面linux下的epoll封装后,也是使用相同的调用流程,不需要再修改接口:
......
void EventLoop::Run() {
while (!m_Stop) {
memset(m_ActiveEvents, 0, m_Size);
//监听事件到来
int num = m_Poll->Poll(m_ActiveEvents);
if (num > 0) {
//处理事件
this->ActiveEvents(num);
}
}
}
......
以上就是对Windows下的select封装, 由于linux下也有select函数所以这里我特别使用#ifdef _WIN32宏将Select类包装起来,避免报错。
下一章 封装Epoll 请不要期待~~~