select函数详解

说明:本文整合网络资源和man帮助文档,请酌情参考。

fengjingtu

背景

select函数是实现IO多路复用的一种方式。

什么是IO多路复用?

举一个简单地网络服务器的例子,如果你的服务器需要和多个客户端保持连接,处理客户端的请求,属于多进程的并发问题,如果创建很多个进程来处理这些IO流,会导致CPU占有率很高。所以人们提出了I/O多路复用模型:一个线程,通过记录I/O流的状态来同时管理多个I/O

select只是IO复用的一种方式,其他的还有:poll,epoll等。

说明

定义
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

/* According to POSIX.1-2001 */
#include <sys/select.h>

/* According to earlier standards */
#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);

void FD_CLR(int fd, fd_set *set);
int FD_ISSET(int fd, fd_set *set);
void FD_SET(int fd, fd_set *set);
void FD_ZERO(fd_set *set);
介绍、

select()函数允许程序监视多个文件描述符,等待所监视的一个或者多个文件描述符变为“准备好”的状态。所谓的”准备好“状态是指:文件描述符不再是阻塞状态,可以用于某类IO操作了,包括可读,可写,发生异常三种。


我们使用select来监视文件描述符时,要向内核传递的信息包括:
​ 1、我们要监视的文件描述符个数
​ 2、每个文件描述符,我们可以监视它的一种或多种状态,包括:可读,可写,发生异常三种。
​ 3、要等待的时间,监视是一个过程,我们希望内核监视多长时间,然后返回给我们监视结果呢?
​ 4、监视结果包括:准备好了的文件描述符个数,对于读,写,异常,分别是哪儿个文件描述符准备好了。

参数说明

nfds:是一个整数值, 表示集合中所有文件描述符的范围,即所有文件描述符的最大值+1。在windows中不需要管这个。

linux select第一个参数的函数: 待测试的描述集的总个数。 但要注意, 待测试的描述集总是从0, 1, 2, …开始的。 所以, 假如你要检测的描述符为8, 9, 10, 那么系统实际也要监测0, 1, 2, 3, 4, 5, 6, 7, 此时真正待测试的描述符的个数为11个, 也就是max(8, 9, 10) + 1
注意:
​ 1、果你要检测描述符8, 9, 10, 但是你把select的第一个参数定为8, 实际上只检测0到7, 所以select不会感知到8, 9, 10描述符的变化。
​ 2、果你要检测描述符8, 9, 10, 且你把select的第一个参数定为11, 实际上会检测0-10, 但是, 如果你不把描述如0 set到描述符中, 那么select也不会感知到0描述符的变化。
​ 所以, select感知到描述符变化的必要条件是, 第一个参数要合理, 比如定义为fdmax+1, 且把需要检测的描述符set到描述集中。

fd_set:
一个文件描述符集合保存在fd_set变量中,可读,可写,异常这三个描述符集合需要使用三个变量来保存,分别是 readfds,writefds,exceptfds。我们可以认为一个fd_set变量是由很多个二进制构成的数组,每一位表示一个文件描述符是否需要监视。

对于fd_set类型的变量,我们只能使用相关的函数来操作。

1
2
3
4
void FD_CLR(int fd, fd_set *set);//清除某一个被监视的文件描述符。
int FD_ISSET(int fd, fd_set *set);//测试一个文件描述符是否是集合中的一员
void FD_SET(int fd, fd_set *set);//添加一个文件描述符,将set中的某一位设置成1;
void FD_ZERO(fd_set *set);//清空集合中的文件描述符,将每一位都设置为0;

使用案例:

1
2
3
4
5
6
7
8
9
fd_set readfds;
int fd;
FD_ZERO(&readfds)//新定义的变量要清空一下。相当于初始化。
FD_SET(fd,&readfds);//把文件描述符fd加入到readfds中。
//select 返回
if(FD_ISSET(fd,&readset))//判断是否成功监视
{
//dosomething
}

readfds:
监视文件描述符的一个集合,我们监视其中的文件描述符是不是可读,或者更准确的说,读取是不是不阻塞了。
writefds:
监视文件描述符的一个集合,我们监视其中的文件描述符是不是可写,或者更准确的说,写入是不是不阻塞了。
exceptfds:
用来监视发生错误异常文件

timeout

1
2
3
4
struct timeval{
long tv_sec;//秒
long tv_usec;//微秒
}

timeout表示select返回之前的时间上限。
如果timeout==NULL,无期限等待下去,这个等待可以被一个信号中断,只有当一个描述符准备好,或者捕获到一个信号时函数才会返回。如果是捕获到信号,select返回-1,并将变量errno设置成EINTR。

如果timeout->tv_sec==0 && timeout->tv_sec==0 ,不等待直接返回,加入的描述符都会被测试,并且返回满足要求的描述符个数,这种方法通过轮询,无阻塞地获得了多个文件描述符状态。

如果timeout->tv_sec!=0 || timeout->tv_sec!=0 ,等待指定的时间。当有描述符复合条件或者超过超时时间的话,函数返回。等待总是会被信号中断。

原理

理解select模型的关键在于理解fd_set,为说明方便,取fd_set长度为1字节,fd_set中的每一bit可以对应一个文件描述符fd。则1字节长的fd_set最大可以对应8个fd。
​ 执行fd_set set;FD_ZERO(&set);则set用位表示是0000,0000。
​ 若fd=5,执行FD_SET(fd,&set);后set变为0001,0000(第5位置为1)
​ 若再加入fd=2,fd=1,则set变为0001,0011
​ 执行select(6,&set,0,0,0)阻塞等待
​ 若fd=1,fd=2上都发生可读事件,则select返回,此时set变为0000,0011。注意:没有事件发生的fd=5被清空。

返回值

成功时:返回三中描述符集合中”准备好了“的文件描述符数量。
超时:返回0
错误:返回-1,并设置 errno

EBADF:集合中包含无效的文件描述符。(文件描述符已经关闭了,或者文件描述符上已经有错误了)。
EINTR:捕获到一个信号。
EINVAL:nfds是负的或者timeout中包含的值无效。
ENOMEM:无法为内部表分配内存。

pselect
1
2
3
4
5
#include <sys/select.h>

int pselect(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, const struct timespec *timeout,
const sigset_t *sigmask);

select和pselect有三个主要的区别:

1、select超时使用的是struct timeval,用秒和微秒计时,而pselect使用struct timespec ,用秒和纳秒。

1
2
3
4
struct timespec{
time_t tv_sec;//秒
long tv_nsec;//纳秒
}

2、select会更新超时参数timeout 以指示还剩下多少时间,pselect不会。

3、select没有sigmask参数.
sigmask:这个参数保存了一组内核应该打开的信号(即:从调用线程的信号掩码中删除)

当pselect的sigmask==NULL时pselect和select一样

当sigmask!=NULL时,等效于以下原子操作:

1
2
3
4
sigset_t origmask;
sigprocmask(SIG_SETMASK, &sigmask, &origmask);
ready = select(nfds, &readfds, &writefds, &exceptfds, timeout);
sigprocmask(SIG_SETMASK, &origmask, NULL);

接收信号的程序通常只使用信号处理程序来引发全局标志。全局标志将指示事件必须被处理。在程序的主循环中。一个信号将导致select和pselect返回-1 并将erron=EINTR。

我们经常要在主循环中处理信号,主循环的某个位置将会检查全局标志,那么我们会问:如果信号在条件之后,select之前到达怎么办。答案是select会无限期阻塞。

这种情况很少见,但是这就是为什么出现了pselect。因为他是类似原子操作的。

举个栗子:

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
47
48
49
50
51
52
53
static volatile sig_atomic_t got_SIGCHLD = 0;

static void
child_sig_handler(int sig)
{
got_SIGCHLD = 1;
}

int
main(int argc, char *argv[])
{
sigset_t sigmask, empty_mask;
struct sigaction sa;
fd_set readfds, writefds, exceptfds;
int r;

sigemptyset(&sigmask);
sigaddset(&sigmask, SIGCHLD);
if (sigprocmask(SIG_BLOCK, &sigmask, NULL) == -1) {
perror("sigprocmask");
exit(EXIT_FAILURE);
}

sa.sa_flags = 0;
sa.sa_handler = child_sig_handler;
sigemptyset(&sa.sa_mask);
if (sigaction(SIGCHLD, &sa, NULL) == -1) {
perror("sigaction");
exit(EXIT_FAILURE);
}

sigemptyset(&empty_mask);

for (;;) { /* main loop */
/* Initialize readfds, writefds, and exceptfds
before the pselect() call. (Code omitted.) */

r = pselect(nfds, &readfds, &writefds, &exceptfds,
NULL, &empty_mask);
if (r == -1 && errno != EINTR) {
/* Handle error */
}

if (got_SIGCHLD) {
got_SIGCHLD = 0;

/* Handle signalled event here; e.g., wait() for all
terminated children. (Code omitted.) */
}

/* main body of program */
}
}

总结

select()可以同时监视多个描述符,如果他们没有活动,则正确地将进程置于休眠状态。Unix程序员们经常要处理多个文件描述符的I/O,他们的数据流可能是间歇性的。如果只创建read或者write会导致程序阻塞。

在我们使用select的时候,需要注意:

1、我们应该总是设置timeout=0,因为如果没有可用的数据,程序在运行时间里将无视可做。依赖超时的代码通常是不可移植,并且很难调试。

2、nfds的值一要准备且适当。

3、如果在调用完select之后,你不想检查结果,也不想做出适当的响应,那么文件描述符不需要添加到集合中。

4、select返回后,所有的文件描述符都应该被检查,看看他们是否准备好了。

5、read,recv,write,send,这几个函数不一定读/写你所请求的全部数据。如果他们读/写全部数据,是因为低流量负载和快速流。情况并非重视如此,应该处理你的函数仅管理发送或接收单个字节的情况。

6、除非你真的确信你有少量的数据要处理,否则不要一次只读一个字节,当你每次都能缓冲的时候,尽可能多的读取数据是非常低效的。

7、read,recv,write,send和select都会有返回-1的情况,并set errno的值。这些errno必须被恰当的处理。如果你的程序不会接收到任何信号,那么errno永远都不会等于EINTR,如果你的程序并不会设置非阻塞IO,那么errno就不会等于EAGAIN。

8、调用read,recv,write,send,不要使buffer的长度为0;

9、如果read,recv,write,send调用失败,并且返回的errno不是7中说的那两种情况,或者返回0,意思是“end-of-file”,这种情况下我们不应再将文件描述符传递给select。

10、每次调用select之前,timeout都用重新设置。

11、由于select()修改其文件描述符集,如果调用在循环中使用,则必须在每次调用之前重新初始化这些集。

大多数的操作系统都支持select。相比于试图用线程,进程,IPCS,信号,内存共享等方式来解决问题,select函数更有效且轻松。系统调用poll和select相似,在监视稀疏文件集合的时候更加有效。poll现在也在被广泛的使用,但没有select简便。linux专用的epoll在监视大连数据时比select和poll更加有效。

案例

案例1

下面是”man select “帮助文档中案例:

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
#include <stdio.h>
#include <stdlib.h>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>

int main(void)
{
fd_set rfds;//定义一个能保存文件描述符集合的变量
struct timeval tv;//定义超时时间
int retval;//保存返回值

/* Watch stdin (fd 0) to see when it has input. */
/* 监测标准输入流(fd=0)看什么时候又输入*/
FD_ZERO(&rfds);//初始化集合
FD_SET(0, &rfds);//把文件描述符0加入到监测集合中。

/* Wait up to five seconds. */
/* 设置超时时间为5s */
tv.tv_sec = 5;
tv.tv_usec = 0;

/*调用select函数,将文件描述符集合设置成读取监测 */
retval = select(1, &rfds, NULL, NULL, &tv);
/* Don't rely on the value of tv now! */
/* 这时候的tv值是不可依赖的 */

/*根据返回值类型判断select函数 */
if (retval == -1)
perror("select()");
else if (retval)
printf("Data is available now.\n");
/* FD_ISSET(0, &rfds) will be true. */
/* 因为值增加了一个fd,如果返回值>0,则说明fd=0在集合中。*/
else
printf("No data within five seconds.\n");

exit(EXIT_SUCCESS);
}

案例2

下面是”man select_tut “帮助文档中案例:

这个例子更好的说明了select函数的作用,这是一个TCP转发相关的程序,从一个端口转发到另一个端口

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
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <sys/time.h>
#include <sys/types.h>
#include <string.h>
#include <signal.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <errno.h>

static int forward_port;

#undef max
#define max(x,y) ((x) > (y) ? (x) : (y))

static int listen_socket(int listen_port)
{
struct sockaddr_in a;
int s;
int yes;

if ((s = socket(AF_INET, SOCK_STREAM, 0)) == -1) {
perror("socket");
return -1;
}
yes = 1;
if (setsockopt(s, SOL_SOCKET, SO_REUSEADDR,
(char *) &yes, sizeof(yes)) == -1) {
perror("setsockopt");
close(s);
return -1;
}
memset(&a, 0, sizeof(a));
a.sin_port = htons(listen_port);
a.sin_family = AF_INET;
if (bind(s, (struct sockaddr *) &a, sizeof(a)) == -1) {
perror("bind");
close(s);
return -1;
}
printf("accepting connections on port %d\n", listen_port);
listen(s, 10);
return s;
}

static int connect_socket(int connect_port, char *address)
{
struct sockaddr_in a;
int s;

if ((s = socket(AF_INET, SOCK_STREAM, 0)) == -1) {
perror("socket");
close(s);
return -1;
}

memset(&a, 0, sizeof(a));
a.sin_port = htons(connect_port);
a.sin_family = AF_INET;

if (!inet_aton(address, (struct in_addr *) &a.sin_addr.s_addr)) {
perror("bad IP address format");
close(s);
return -1;
}

if (connect(s, (struct sockaddr *) &a, sizeof(a)) == -1) {
perror("connect()");
shutdown(s, SHUT_RDWR);
close(s);
return -1;
}
return s;
}

#define SHUT_FD1 do { \
if (fd1 >= 0) { \
shutdown(fd1, SHUT_RDWR); \
close(fd1); \
fd1 = -1; \
} \
} while (0)

#define SHUT_FD2 do { \
if (fd2 >= 0) { \
shutdown(fd2, SHUT_RDWR); \
close(fd2); \
fd2 = -1; \
} \
} while (0)

#define BUF_SIZE 1024

int main(int argc, char *argv[])
{
int h;
int fd1 = -1, fd2 = -1;
char buf1[BUF_SIZE], buf2[BUF_SIZE];
int buf1_avail, buf1_written;
int buf2_avail, buf2_written;

//我们希望调用主函数的时候,要指明,本地端口,发送端口,还有发送的ip地址
if (argc != 4) {
fprintf(stderr, "Usage\n\tfwd <listen-port> "
"<forward-to-port> <forward-to-ip-address>\n");
exit(EXIT_FAILURE);
}

// 忽略SIGPIPE这个信号,这个信号常出现在网络编程中,访问一个已经关闭的文件描述符时候出现。
signal(SIGPIPE, SIG_IGN);

//确定发送端口
forward_port = atoi(argv[2]);

//监听本地端口
h = listen_socket(atoi(argv[1]));
if (h == -1)
exit(EXIT_FAILURE);

for (;;) {
int r, nfds = 0;
fd_set rd, wr, er;

FD_ZERO(&rd);
FD_ZERO(&wr);
FD_ZERO(&er);
FD_SET(h, &rd);
// 获取nfds的值。并把fd1,fd2分别加入到,可读,可写,异常监视集合中去。
nfds = max(nfds, h);
if (fd1 > 0 && buf1_avail < BUF_SIZE) {
FD_SET(fd1, &rd);
nfds = max(nfds, fd1);
}
if (fd2 > 0 && buf2_avail < BUF_SIZE) {
FD_SET(fd2, &rd);
nfds = max(nfds, fd2);
}
if (fd1 > 0 && buf2_avail - buf2_written > 0) {
FD_SET(fd1, &wr);
nfds = max(nfds, fd1);
}
if (fd2 > 0 && buf1_avail - buf1_written > 0) {
FD_SET(fd2, &wr);
nfds = max(nfds, fd2);
}
if (fd1 > 0) {
FD_SET(fd1, &er);
nfds = max(nfds, fd1);
}
if (fd2 > 0) {
FD_SET(fd2, &er);
nfds = max(nfds, fd2);
}

//开始监视
r = select(nfds + 1, &rd, &wr, &er, NULL);

if (r == -1 && errno == EINTR)
continue;

if (r == -1) {
perror("select()");
exit(EXIT_FAILURE);
}

if (FD_ISSET(h, &rd)) {
unsigned int l;
struct sockaddr_in client_address;

memset(&client_address, 0, l = sizeof(client_address));
r = accept(h, (struct sockaddr *) &client_address, &l);
if (r == -1) {
perror("accept()");
} else {
SHUT_FD1;
SHUT_FD2;
buf1_avail = buf1_written = 0;
buf2_avail = buf2_written = 0;
fd1 = r;
fd2 = connect_socket(forward_port, argv[3]);
if (fd2 == -1)
SHUT_FD1;
else
printf("connect from %s\n",
inet_ntoa(client_address.sin_addr));
}
}

/* NB: read oob data before normal reads */

if (fd1 > 0)
if (FD_ISSET(fd1, &er)) {
char c;

r = recv(fd1, &c, 1, MSG_OOB);
if (r < 1)
SHUT_FD1;
else
send(fd2, &c, 1, MSG_OOB);
}
if (fd2 > 0)
if (FD_ISSET(fd2, &er)) {
char c;

r = recv(fd2, &c, 1, MSG_OOB);
if (r < 1)
SHUT_FD2;
else
send(fd1, &c, 1, MSG_OOB);
}
if (fd1 > 0)
if (FD_ISSET(fd1, &rd)) {
r = read(fd1, buf1 + buf1_avail,
BUF_SIZE - buf1_avail);
if (r < 1)
SHUT_FD1;
else
buf1_avail += r;
}
if (fd2 > 0)
if (FD_ISSET(fd2, &rd)) {
r = read(fd2, buf2 + buf2_avail,
BUF_SIZE - buf2_avail);
if (r < 1)
SHUT_FD2;
else
buf2_avail += r;
}
if (fd1 > 0)
if (FD_ISSET(fd1, &wr)) {
r = write(fd1, buf2 + buf2_written,
buf2_avail - buf2_written);
if (r < 1)
SHUT_FD1;
else
buf2_written += r;
}
if (fd2 > 0)
if (FD_ISSET(fd2, &wr)) {
r = write(fd2, buf1 + buf1_written,
buf1_avail - buf1_written);
if (r < 1)
SHUT_FD2;
else
buf1_written += r;
}

/* check if write data has caught read data */

if (buf1_written == buf1_avail)
buf1_written = buf1_avail = 0;
if (buf2_written == buf2_avail)
buf2_written = buf2_avail = 0;

/* one side has closed the connection, keep
writing to the other side until empty */

if (fd1 < 0 && buf1_avail - buf1_written == 0)
SHUT_FD2;
if (fd2 < 0 && buf2_avail - buf2_written == 0)
SHUT_FD1;
}
exit(EXIT_SUCCESS);
}

上面的程序可以应用于大多数类型的TCP连接,包括telnet服务器对OOB信号的转发。它处理了同时在两个方向上流动这一棘手问题。你可能会想,使用连个进程不是更有效吗?事实上使用两个进程会更复杂。另一个想法是使用fcntl设置非阻塞的I/O使用,这也有弊端,因为它使用非常低效的超时。

这个程序不能处理同时有多个连接的情况,但很容易扩展。你只需要为每个连接创建一个buffer。当前的程序中,新的连接会导致旧的连接被覆盖丢弃。