彻底解释Linux select的1024限制(select真的受1024限制吗?不!)

很多很多年前,我被面试 为什么select调用最多只支持1024个文件描述符?

我没有答出来,我甚至不知道select到底是干什么的。

又过了很多年,我用这个问题面试了别人…

在当时,我心里已经有了会令自己满意的预期答案,我预期的大概就是:

  • Linux内核的宏限制了fd_set最多只支持1024…

为了避免talk is cheap,我还能show you the code:

1
2
3
4
5
6
// include/uapi/linux/posix_types.h
#define __FD_SETSIZE    1024

typedef struct {
    unsigned long fds_bits[__FD_SETSIZE / (8 * sizeof(long))];
} __kernel_fd_set;

嗯,是的,那段时间我也和很多人一样,读过几段Linux内核源码,并且读懂了,就开始觉得自己什么都懂了。

言归正传,如果你想突破fd_set此1024的限制,重新编译内核咯!


事情已经过去很多年了,事后想想这事,感觉有点丢人,我竟然曾经以我读过Linux内核源码而唬人,我曾经也是一个源码分析者,在还没有深入理解一个问题时,就片面地以源码为依据信口开河。

竟然扯什么Linux源码,竟然让人家去看文档,还要什么重新定义__FD_SETSIZE的值之后重新编译内核,丢人啊丢人!

这么简单的事情竟然没有想到自己去试试!自己试一下不就知道了吗?干嘛天天听别人怎么说就认为那样就是对的。嗯,我确实应该猛怼那时的自己了,如果我能遇见,我定然掘心自食!

妥妥学院派转变为了工程派。

我早就已经无实验不作文了,所以今天我作文,那必定是有些可以看得见摸得着的东西的。

select真的受1024限制吗?

这么简单的事情,试一下就知道了。以下的实验先以Linux平台为例。

我们从fd_set的定义以及FD_SET宏的定义可知,fd_set就是一个位图数组,而FD_SET就是一个位图set操作,我不分析源码,直接做下面的实验:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <stdio.h>
#include <stdlib.h>

int main(int argc, char **argv)
{
    int i = 2000, j;
    unsigned char gap = 0x12; // 该值作为锚点,被位运算覆盖。
    fd_set readfds;
    unsigned char *addr = &gap;
    int sd[2500];
    unsigned long dist;
    unsigned total;

    printf("gap value is :0x%x\n", gap);
    // dist的含义就是readfds和附近gap之间的空间大小,即readfds最大的可用空间。
    dist = (unsigned long)&gap - (unsigned long)&readfds;
    FD_ZERO(&readfds);
    // dist*8 + 1即让readfds越界1个bit。
    // 由于gap为0x12,二进制10010,越界1个bit,可以预期FD_SET会置位0x12的最低位。
    // 结果就是0x13
    for (j = 0; j < dist*8 + 1; j++) {
        sd[j] = j;
        FD_SET(sd[j], &readfds);
    }
    printf("j %d .", j);
    printf("after FD_SET. gap value is :0x%01x   bytes space:%d\n", gap, dist);
}

看下执行结果:

1
2
3
[root@localhost select]# ./set
gap value is :0x12
j 1145 .after FD_SET. gap value is :0x13   bytes space:143

符合预期。

这意味着,实际上 FD_SET宏根本不管是否越界以及越界的后果,fd_set也并非严格限制在1024.

事实上,如果你真的去分析源码,也确实如此:

1
2
3
4
5
6
7
8
9
10
11
// /usr/include/sys/select.h
#define FD_SET(fd, fdsetp)  __FD_SET (fd, fdsetp)

// /usr/include/sys/select.h
#define __NFDBITS   (8 * (int) sizeof (__fd_mask))
#define __FD_ELT(d) ((d) / __NFDBITS)
#define __FD_MASK(d)    ((__fd_mask) 1 << ((d) % __NFDBITS))

// /usr/include/bits/select.h
#define __FD_SET(d, set) \
  ((void) (__FDS_BITS (set)[__FD_ELT (d)] |= __FD_MASK (d)))

可以看出,没有任何limit 1024的限制!只是单纯的去置位!

如果上面的例子没能展示越界覆盖的效果,那么看下面的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <stdio.h>
#include <stdlib.h>

char stub = 0x65; // ascii码的'e'

int main(int argc, char **argv)
{
    int i = 2000, j;
    unsigned char *pgap = &stub;
    fd_set readfds;
    int sd[2500];
    unsigned long dist;
    unsigned total;
    // 我们从头到尾不去touch pgap
    printf("gap value is :%c\n", *pgap);
    FD_ZERO(&readfds);
    for (j = 0; j < dist*8 + 1; j++) {
        sd[j] = j;
        FD_SET(sd[j], &readfds);
    }
    printf("gap value is :%c\n", *pgap);
}

至始至终我们没有去操作pgap这个指针,预期FD_SET会越界覆盖掉pgap指针:

1
2
3
[root@localhost select]# ./null
gap value is :e
段错误

覆盖掉了pgap指针,当然就段错误了。

至于readfds和其stack下到底有有多少空间,那就看1024和对齐限制共同起作用了。在我们的实验里,它就是:

1
&pgap - &readfds;

具体如何覆盖,覆盖哪些变量,取决于局部变量在stack上的布局。

现在的结论是, FD_SET超过1024的值,会造成越界,只是这个越界可能不会有致命的后果(比如你再也不会touch gap,pgap…)。

这就是所谓select的manual上的以下说辞:

The behavior of these macros is undefined if a descriptor value is less than zero or
greater than or equal to FD_SETSIZE, which is normally at least equal to the maximum num-
ber of descriptors supported by the system.

OK,我们已经知道FD_SET会越界,那么下一步,FD_SET设置了超过1024的文件描述符后,它会正常起作用码?

再来个实验即可验证,以下的实验,我们把变量从stack上拿开,以避开fd_set越界的影响:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#include <stdio.h>
#include <stdlib.h>
#include <netdb.h>
#include <sys/socket.h>

#define SIZE 1200
// 这些变量不再放在栈上,以防被覆盖。
int i = 1001, j;
int sd[SIZE];
struct sockaddr_in serveraddr;
int main(int argc, char **argv)
{
    // 使readfds在第一个,覆盖掉我们不再care about的内存.
    fd_set readfds;
    int childfd;

    FD_ZERO(&readfds);
    for (j = 0; j < SIZE; j++) {
        sd[j] = socket(AF_INET, SOCK_STREAM, 0);
        serveraddr.sin_family = AF_INET;
        serveraddr.sin_addr.s_addr = htonl(INADDR_ANY);
        serveraddr.sin_port = htons(i++);
        bind(sd[j], (struct sockaddr *) &serveraddr, sizeof(serveraddr));
        listen(sd[j], 5);
        FD_SET(sd[j], &readfds);
    }

    while (1) {
        // select 超过1024的...
        if (select(1100, &readfds, 0, 0, 0) < 0) {
            perror("ERROR in select");
        }
        for (j = 0; j < SIZE; j++) {
            if (FD_ISSET(sd[j], &readfds)) {
                childfd = accept(sd[j], NULL, NULL);
                printf("#### %d\n", j);
                close(childfd);
            }
        }
    }
}

很显然,select的参数1100超过了1024,那么结果如何呢?

1
2
3
4
5
[root@localhost ~]# telnet 127.0.0.1 2050
Trying 127.0.0.1...
Connected to 127.0.0.1.
Escape character is '^]'.
Connection closed by foreign host.

成功连接,事实说明,文件描述符超过了1024依然OK:

1
2
[root@localhost select]# ./selectserver
#### 1049

到底有多少文件描述符传入了select调用,取决于其第一个参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <stdio.h>
#include <stdlib.h>

int num = 1024;
int i;

int main(int argc, char **argv)
{
    fd_set readfds;

    num = atoi(argv[1]);
    FD_ZERO(&readfds);
    for (i = 0; i < num; i++) {
        FD_SET(i, &readfds);
    }

    if (select(num, &readfds, 0, 0, 0) < 0) {
        perror("ERROR in select");
    }
}

执行便知道:

1
2
3
4
5
6
[root@localhost select]# strace -e trace=select ./num 1234
select(1234, [0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 ... 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233], NULL, NULL, NULL) = -1 EBADF (Bad file descriptor)
# 忽略这个error,因为我并没有真的创建socket
ERROR in select: Bad file descriptor
--- SIGSEGV {si_signo=SIGSEGV, si_code=SEGV_MAPERR, si_addr=0xffffffffffffff07} ---
+++ killed by SIGSEGV +++

这一切均发生在用户态。

1024的限制,只是POSIX的约定,你不遵守,那就自行承受越界吧!

内核态如何?说实话,内核态看到的fd_set只是位图本身,它没有做任何限制。

如果你想突破1024的限制,如何来做?

  • 使用malloc/mmap分配堆内存即可!要多大有多大!

我们来试一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#include <stdio.h>
#include <stdlib.h>
#include <netdb.h>
#include <sys/socket.h>

int num = 1024;

int main(int argc, char **argv)
{
    // 我们把变量搬回stack,因为不会被覆盖了!
    unsigned char *pgap = (unsigned char *)&num;
    fd_set *readfds;
    int childfd;
    int i = 1000, j;
    int sd[10000];
    struct sockaddr_in serveraddr;

    readfds = (fd_set *)malloc(8000/8);
    num = atoi(argv[1]);

    FD_ZERO(readfds);
    printf("pgap :%p\n", pgap);
    for (j = 0; j < num; j++) {
        sd[j] = socket(AF_INET, SOCK_STREAM, 0);
        serveraddr.sin_family = AF_INET;
        serveraddr.sin_addr.s_addr = htonl(INADDR_ANY);
        serveraddr.sin_port = htons(i++);
        bind(sd[j], (struct sockaddr *) &serveraddr, sizeof(serveraddr));
        listen(sd[j], 5);
        FD_SET(sd[j], readfds);
    }
    printf("after setting, pgap :%p\n", pgap);

    while (1) {
        if (select(num, readfds, 0, 0, 0) < 0) {
            perror("ERROR in select");
        }
        for (j = 0; j < num; j++) {
            if (FD_ISSET(sd[j], readfds)) {
                childfd = accept(sd[j], NULL, NULL);
                printf("#### %d\n", j);
                close(childfd);
            }
        }
    }
}

来来来:

1
2
3
4
5
[root@localhost select]# ulimit -a |grep open
open files                      (-n) 20000
[root@localhost select]# ./a.out 5000
pgap :0x601084
after setting, pgap :0x6010840xb1b010

TCP连接一下:

1
2
3
4
5
6
7
8
9
10
[root@localhost ~]# telnet 127.0.0.1 3050
Trying 127.0.0.1...
Connected to 127.0.0.1.
Escape character is '^]'.
Connection closed by foreign host.

[root@localhost select]# ./a.out 5000
pgap :0x601084
after setting, pgap :0x6010840xb1b010
#### 2050

月雪皮鞋!


接下来,我们看看Windows平台select的行为。

我并没有Windows的环境,平时也根本不会涉及Windows平台的开发和调试,可能事情进展的有点犹豫,结果或许狼狈,见谅。

如果说Linux平台下都喜欢以读过Kernel源码而自诩,那么Windows对应平台下,那就是MSDN了,和不喜欢Linux源码分析一样,我也同样不喜欢看MSDN文档。(当然,我没有资格评论Windows平台任何形而上的东西,所以就少说点。)

所以我只能在我的Win8虚拟机里下载一个Dev-C++来简单折腾。我的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <stdio.h>
#include <stdlib.h>
#include <windows.h>

int var = 0x1234; // Linux测试代码复制而来,不必关注。
/* run this program using the console pauser or add your own getch, system("pause") or input loop */
int main(int argc, char *argv[]) {

    fd_set fset;
   
    printf("size of fset:%d   %d\n", sizeof(fset), FD_SETSIZE);
   
    FD_ZERO(&fset); // 此处下断点来观测FD_SET的行为!
   
    FD_SET(0, &fset);
    FD_SET(1, &fset);
    FD_SET(3, &fset);
   
    return 0;
}

其实一开始的测试代码不是上面这个,而是Linux的那个覆盖测试,然而当我发现无论我怎么折腾,都无法达到覆盖的效果后,我决定先把Windows平台的fd_set结构体的简单操作摸清楚再说,于是我就改成了上面的代码,先把fset的大小以及FD_SETSIZE打印出来看看,确认一下Windows平台到底有没有1024的限制。

令我惊讶的是,Windows平台的FD_SETSIZE竟然只有64(而不是1024)!然而fd_set却有520字节那么大!

约莫估算一下,大概就是64*8=512字节的量级,还差8字节到520,我大致可以猜到Windows是用 字节图 实现了Linux位图的效果。

所谓的字节图其实和位图原理差不多,只是更加高层,可以用统一的方式实现字节操作,毕竟高效率的位操作要考虑很多跨平台的特征。

让我们走debug模式在FD_ZERO处设断点,然后单步跟踪确认一下:
在这里插入图片描述
OK,完美!这么简单的数据结构,有点经验的就可以将数据结构直接hack出来。

由此可见,Windows确实限制了select的最大文件描述符个数,即FD_SETSIZE=64这么多。如果我们想突破这个限制,怎么办呢?

这玩意儿比Linux更简单,不就是个FD_SETSIZE宏定义吗?改了便是!
在这里插入图片描述
OK,这下谜底彻底揭开了。

我们来简单总结一下:

  • Linux平台的select fd_set
    • Posix接口限制为1024个文件描述符。
    • fd_set的位索引就是文件描述符索引。
    • Linux 内核没有任何限制。
    • Posix的1024限制很容易在栈上突破,但可能会越界造成数据覆盖。
    • Posix的1024限制需要用堆内存突破限制,想要多大有多大。
    • select调用中fd_set的大小取决于第一个参数。
  • Windows
    • windows.h默认限制为64个文件描述符。
    • fd_set的数组元素为文件描述符,数组下标为文件描述符索引。
    • 内核是否限制未知,没debug过,搞不定。
    • windows.h的64限制无法直接突破,需要在include头文件前重新定义FD_SETSIZE。
    • select调用中fd_set大小取决于其结构体的fd_count字段。

经过这番探究,不要再相信什么 Linux内核源码摆在眼前,一览无余 之类的言论了,根本不是这样子的好吗,除了Linux内核源码,还有glibc,还有各种中间的库,甚至还有你的bug,甚至你都不一定使用Linux,…事情的复杂性远超Linux内核源码覆盖的范围。

所以,确实,能读懂Linux内核,写两句注释作为源码分析,没什么大不了的。

我在Linux平台的实验全部基于2.6.8,2.6.18,2.6.32以及3.10,Windows平台的实验基于Windows XP,Win7/8,甚至玩DoS,都是老式的平台,不求新,不发patch不挖洞,不玩儿社区不求闻达于经理,坦坦荡荡手艺人。


浙江温州皮鞋湿,下雨进水不会胖。