WinSock 的 select 如何超过 64 个套接字限制?(三种方法)

在做跨平台网络编程时,Windows 下面能够对应 epoll/kevent 这类 reactor 事件模型的 API 只有一个 select,但是却有数量限制,一次传入 select 的 socket 数量不能超过FD_SETSIZE 个,而这个值是 64。

所以 java 里的 nio 的 select 在 Windows 也有同样的数量限制,很多移植 Windows 的服务程序,用了 reactor 模型的大多有这样一个限制,让人觉得 Windows 下的服务程序性能很弱。

那么这个数量限制对开发一个高并发的服务器显然是不够的,我们是否有办法突破这个限制呢?而 cygwin 这类用 Win32 API 模拟 posix API 的系统,又是如何模拟不受限制的 poll 调用呢?

当然可以,大概有三个方法让你绕过 64 个套接字的限制。

方法1:重定义 FD_SETSIZE

首先可以看 MSDN 中 winsock2 的select 帮助,这个FD_SETSIZE 是可以自定义的:

Four macros are defined in the header file Winsock2.h for manipulating and checking the descriptor sets. The variable FD_SETSIZE determines the maximum number of descriptors in a set. (The default value of FD_SETSIZE is 64, which can be modified by defining FD_SETSIZE to another value before including Winsock2.h.)

而在winsock2.h 中,可以看到这个值也是允许预先定义的:

#ifndef FD_SETSIZE#define FD_SETSIZE 64#endif

只要你在 include 这个winsock2.h 之前,自定义了FD_SETSIZE,即可突破 64 的限制,比如在 cygwin 的 poll 实现poll.cc,开头就重定义了FD_SETSIZE

#define FD_SETSIZE 16384        // lots of fds#include "winsup.h"#include <sys/poll.h>#include <sys/param.h>

定义到了一个非常大的 16384,最多 16K 个套接字一起 select,然后 cygwin 后面继续用 select 来实现 posix 中 poll 函数的模拟。

这个方法问题不大,但有两个限制,第一是到底该定义多大的FD_SETSIZE 呢?定义大了废内存,每次 select 临时分配又一地内存碎片,定义少了又不够用;其次是程序不够 portable,头文件哪天忘记了换下顺序,或者代码拷贝到其它地方就没法运行。

因此我们有了更通用的方法2。

(点击 more/continue 继续)

方法2:自定义 fd_set 结构体

这个方法更为通用,按照 MSDN 里的fd_set 定义:

typedef struct fd_set {  u_int  fd_count;  SOCKET fd_array[FD_SETSIZE];} fd_set, FD_SET, *PFD_SET, *LPFD_SET;

结构体里第一个成员fd_count 代表这个fd_set 里总共保存了多少个套接字,而后面的fd_array 数组则存储了各个套接字的值,它的大小由前面的FD_SETSIZE 宏决定,这决定了最大可存储数量。

我们来看看winsock2.h 中几个操作fd_set 的宏的实现:

#ifndef FD_ZERO#define FD_ZERO(set) (((fd_set *)(set))->fd_count=0)#endif

清空操作很简单,就是把fd_count 设置成零就行了,而增加一个套接字:

#define FD_SET(fd, set) do { u_int __i;\    for (__i = 0; __i < ((fd_set *)(set))->fd_count ; __i++) {\        if (((fd_set *)(set))->fd_array[__i] == (fd)) {\            break;\        }\    }\    if (__i == ((fd_set *)(set))->fd_count) {\        if (((fd_set *)(set))->fd_count < FD_SETSIZE) {\            ((fd_set *)(set))->fd_array[__i] = (fd);\            ((fd_set *)(set))->fd_count++;\        }\    }\    } while(0)

简单来讲就是先判断数组里是否已经包含,如果没包含并且fd_count 小于FD_SETSIZE 的话就追加到fd_array 后面并且增加fd_count 值。

那么方案就是用一个动态结构模拟这个fd_set 就行了,要用时直接强制类型转换成fd_set 指针传递给select 即可,微软的 devblogs 里一篇文章讲过这个方法:

但是它的实现是用模板做了个新的fd_set 结构体,一旦实例化就定死了,我给一个更好的跨平台实现:

#define ISOCK_ERECV     1       /* event - recv           */#define ISOCK_ESEND     2       /* event - send           */#define ISOCK_ERROR     4       /* event - error          *//* iselect: fds(fd set), events(mask), revents(received events) */int iselect(const int *fds, const int *events, int *revents,     int count, long millisec, void *workmem);

这个函数第一个参数fds 传入 fd 数组,然后events 传入对应 fd 需要捕获的事件,相当于 poll 里的 events,而revents 用于接受返回的事件,最后count 代表总共有多少个 fd,前面的参数模仿了 poll 函数只是没用struct pollfd 这个结构体表达而已。

最后一个参数workmem 代表需要用多少内存,如果为NULL 的话,这个函数不会调用下层的 select/poll 而会根据count 数量计算出需要用到的内存并返回给你,让你安排好内存,第二次调用时用workmem 传入内存指针:

int my_select1(const int *fds, const int *event, int *revent, int count, long millisec) {    int require = iselect(NULL, NULL, NULL, count, 0, NULL);    if (require > current_buffer_size) {        current_buffer = realloc(current_buffer, require);        current_buffer_size = require;    }    return iselect(fds, event, revent, count, millisec, current_buffer);}

这样用就行了,这个current_buffer 可以是一个全局变量,也可以放在你封装的 selector/poller 对象里。

或者栈上开辟一块空间,如果少量 select 就用栈空间,否则临时分配:

int my_select2(const int *fds, const int *event, int *revent, int count, long millisec) {    #define MAX_BUFFER_SIZE 2048    char stack[MAX_BUFFER_SIZE];    char *buffer = stack;    int require = iselect(NULL, NULL, NULL, count, 0, NULL);    if (require > MAX_BUFFER_SIZE) buffer = (char*)malloc(require);    int hr = iselect(fds, event, revent, count, millisec, buffer);    if (buffer != stack) free(buffer);    return hr;}

这样可以避免维护一个全局变量。

下面给出iselect 这个函数的实现,它能完全模拟 poll 的行为,突破FD_SETSIZE 的限制,并且在非 Windows 下用 poll 而 Windows 下用 select:

/* iselect: fds(fd set), events(mask), revents(received events) */int iselect(const int *fds, const int *events, int *revents, int count,     long millisec, void *workmem){    int retval = 0;    int i;    if (workmem == NULL) {        #if defined(__unix) || defined(__linux)        return count * sizeof(struct pollfd);        #else        size_t unit = 32;        size_t size = count * sizeof(SOCKET) + unit + 8;        return (int)(size * 3);        #endif    }        else {        #if defined(__unix) || defined(__linux)        struct pollfd *pfds = (struct pollfd*)workmem;        for (i = 0; i < count; i++) {            pfds[i].fd = fds[i];            pfds[i].events = 0;            pfds[i].revents = 0;            if (events[i] & ISOCK_ERECV) pfds[i].events |= POLLIN;            if (events[i] & ISOCK_ESEND) pfds[i].events |= POLLOUT;            if (events[i] & ISOCK_ERROR) pfds[i].events |= POLLERR;        }        poll(pfds, count, millisec);        for (i = 0; i < count; i++) {            int event = events[i];            int pevent = pfds[i].revents;            int revent = 0;            if ((event & ISOCK_ERECV) && (pevent & POLLIN))                 revent |= ISOCK_ERECV;            if ((event & ISOCK_ESEND) && (pevent & POLLOUT))                revent |= ISOCK_ESEND;            if ((event & ISOCK_ERROR) && (pevent & POLLERR))                revent |= ISOCK_ERROR;            revents[i] = revent & event;            if (revents[i]) retval++;        }        #else        struct timeval tmx = { 0, 0 };        size_t unit = 32;        size_t size = count * sizeof(SOCKET) + unit + 8;        FD_SET *fdr = (FD_SET*)(((char*)workmem) + 0);        FD_SET *fdw = (FD_SET*)(((char*)workmem) + size);        FD_SET *fde = (FD_SET*)(((char*)workmem) + size * 2);        void *dr, *dw, *de;        int maxfd = 0;        int j;        fdr->fd_count = fdw->fd_count = fde->fd_count = 0;        for (i = 0; i < count; i++) {            int event = events[i];            int fd = fds[i];            if (event & ISOCK_ERECV) fdr->fd_array[(fdr->fd_count)++] = fd;            if (event & ISOCK_ESEND) fdw->fd_array[(fdw->fd_count)++] = fd;            if (event & ISOCK_ERROR) fde->fd_array[(fde->fd_count)++] = fd;            if (fd > maxfd) maxfd = fd;        }        dr = fdr->fd_count? fdr : NULL;        dw = fdw->fd_count? fdw : NULL;        de = fde->fd_count? fde : NULL;        tmx.tv_sec = millisec / 1000;        tmx.tv_usec = (millisec % 1000) * 1000;        select(maxfd + 1, (fd_set*)dr, (fd_set*)dw, (fd_set*)de,             (millisec >= 0)? &tmx : 0);        for (i = 0; i < count; i++) {            int event = events[i];            int fd = fds[i];            int revent = 0;            if (event & ISOCK_ERECV) {                for (j = 0; j < (int)fdr->fd_count; j++) {                    if (fdr->fd_array[j] == (SOCKET)fd) {                         revent |= ISOCK_ERECV;                         break;                     }                }            }            if (event & ISOCK_ESEND) {                for (j = 0; j < (int)fdw->fd_count; j++) {                    if (fdw->fd_array[j] == (SOCKET)fd) {                         revent |= ISOCK_ESEND;                         break;                     }                }            }            if (event & ISOCK_ERROR) {                for (j = 0; j < (int)fde->fd_count; j++) {                    if (fde->fd_array[j] == (SOCKET)fd) {                         revent |= ISOCK_ERROR;                         break;                     }                }            }            revents[i] = revent & event;            if (revent) retval++;        }        #endif    }    return retval;}

这就是我目前用的方法,刚好一百多行,这个方法我测试过,在我的台式机上同时维护一万个 socket 连接问题不大,做 echo server,每个连接每秒一条消息往返,只是 CPU 占用回到 70% 左右。

对于 Windows 下的客户端程序,维护的连接不多,这个函数足够用;而对于服务端程序,则可以做到能跑,可以让你平时跑在 Linux 下的服务端程序保证能在 Windows 下正常工作,正常开发调试,不论连接有多少。

这个方法唯一问题是 CPU 占用过高,那么 Windows 下面是否有像 kevent/epoll 一样丝滑的异步事件模型,既能轻松 hold 上万的套接字,又不费 CPU 呢?当然有,但是在说方案三之前先说两个错误的例子。

错误的选择:WSAEventSelect 和 WSAAsyncSelect

不少人提过函数WSAEventSelect,它可以把套接字事件绑定到一个WSAEVENT 上面:

int WSAAPI WSAEventSelect(  [in] SOCKET   s,  [in] WSAEVENT hEventObject,  [in] long     lNetworkEvents);

这个WSAEVENT 是一个类似EVENT 的东西,看起来好像没有FD_SETSIZE 的个数限制,但问题WSAWaitForMultipleEvents 里你同样面临WSA_MAXIMUM_WAIT_EVENTS 的限制,在winsock2.h 里:

#define WSA_MAXIMUM_WAIT_EVENTS (MAXIMUM_WAIT_OBJECTS)

后面这个 MAXIMUM_WAIT_OBJECTS 的数量就是 64,你还是跳不开。另外一个函数WSAAsyncSelect 可以把 socket 事件关联到窗口句柄上:

int WSAAsyncSelect(  [in] SOCKET s,  [in] HWND   hWnd,  [in] u_int  wMsg,  [in] long   lEvent);

这的确没有个数限制了,问题是你需要一个窗口句柄HWND,你需要创建一个虚拟窗口,那么为了模拟 posix 的 poll 行为,你打算把这个虚拟窗口放哪里呢?它的消息循环需要一个独立的线程来跑么?

Unix 的哲学是一切皆文件,Windows 的哲学是一切皆窗口,没想到有一天写网络程序也要同窗口打交道了吧?总之也是个不太干净的做法。

方法3:用 iocp 实现 epoll

是的可以用 iocp 完全模拟实现 epoll,让你拥有一个高性能的 reactor 事件模型,轻松处理 10w 级别的套接字,听起来很诱惑但是很难实现,没关系,有人帮你做了:

这个 wepoll 的项目意在使用 iocp 实现 Windows 下的高性能 epoll,支持 vista 以后的系统,并且只有两个文件wepoll.hwepoll.c,十分方便集成,接口也是对应 epoll 的:

HANDLE epoll_create(int size);HANDLE epoll_create1(int flags);int epoll_close(HANDLE ephnd);int epoll_ctl(HANDLE ephnd,              int op,              SOCKET sock,              struct epoll_event* event);int epoll_wait(HANDLE ephnd,               struct epoll_event* events,               int maxevents,               int timeout);

完全跟 epoll 一样用就完事了,不过只支持 Level-triggered 不支持 Edge-triggered,不过有性能测试表明 Edge-triggered 并没有太大优势,且并不跨平台,其它平台的异步事件 API 大多也不兼容这个模式,所以用 Level-triggered 问题不大。

PS:libevent 新版本在 Windows 下就是用的这个 wepoll,应该是经过考验的项目了。

话题总结

那么假设你在做一个跨平台的 poll 模块,在 Windows 下上面的三套方案用哪套好呢?我的做法是内部实现的是第二套方案自定义fd_set,它可以兼容到 Windows 95,算是个保底的做法,同时提供插件机制,可以由外部实现来进行增强。

然后主程序检测到系统在 vista 以后,并且包含了 wepoll 的时候,把 wepoll 的实现做一层插件封装安装进去。

Loading

About skywind3000

PuTTY 本无树,MinGW 亦非台!
This entry was posted in网络编程 and tagged. Bookmark thepermalink.

Leave a ReplyCancel reply

Your email address will not be published.Required fields are marked*