基础的网络编程模型中,套接字通常都是阻塞的,比如服务端listen
阻塞等待客户端来连接,建立连接后,recv
阻塞等待接收数据。而如果在等待接收数据的过程中,又有新的客户端连接,这时服务端无能为力,因为服务端还在阻塞等待接收上一个客户端的数据。有一种做法是将套接字设置为非阻塞的,调用即返回,不管套接字是否准备好(是否有数据可读/写),但这就需要不断轮询调用函数(因为服务端也不知道什么时候会来数据)如果客户端一直没有响应,服务端的轮询就会占用大量的CPU资源,效率低下。而另一种方法是采用IO复用:
这样的进程需要一种预先告知内核的能力,使得内核一旦发现进程指定的一个或多个I/O条件就绪
(也就是说输入已准备好被读取,或者描述符已能承接更多的输出),它就通知进程。这个能力称为I/O复用。
在Linux中IO复用有select
、poll
和epoll
,epoll
较前两种区别较大,下一篇总结。
它们都有这样的能力:同时监听多个文件描述符。
需要指出的是,I/O复用虽然能同时监听多个文件描述符,但它本身是阻塞的。并且当多个文件描述符同时就绪时,如果不采取额外的措施,程序就只能按顺序依次处理其中的每一个文件描述符,这使得服务器程序看起来像是串行工作的。如果要实现并发,只能使用多进程或多线程等编程手段。
select
先来看一下函数原型:
#include
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
从后往前:timeout
参数用来指定超时时间,和其他函数中设置超时时间的含义一样,select
本身是阻塞的,如果设置了超时时间,不管监听的文件描述符是否就绪都会返回。再往前三个fd_set *
类型的参数,分别指向可读、可写和异常等事件对应的文件描述符集合,我们在调用select
时,通过这三个参数分类传入需要被监听的文件描述符集合。nfds
是所有被select
监听的文件描述符中的最大值加1,注意不是描述符个数加1,而是描述符最大值加1。
更详细的,timeout
是一个结构体,包含秒数和微秒数:
struct timeval
{
long tv_sec;//秒数
long tv_usec;//微秒数
};
有些系统会修改该值,把值修改成剩余的时间。比如,超时设置是5秒,在文件描述符可用之前逝去了3秒,那么在调用返回时,tv_sec
的值就变为了2。有一些系统则不改变该值,因此,为了方便移植,我们在每次调用select
时最好重新设置该值。更进一步,我们还可以用select
的定时,来替代sleep()
,只需要将前几个参数都设置为零和空,并设置超时时间:
struct timeval tv;
tv.tv_sec = 0;
tv.tv_usec = 500;
select(0, NULL, NULL, NULL, &tv);
例:
#include
#include
int main() {
struct timeval tv;
tv.tv_sec = 5;
tv.tv_usec = 0;
select(0, NULL, NULL, NULL, &tv);
printf("%s", "hello world");
return 0;
}
通过一系列宏来往select
中添加感兴趣的文件描述符:
#include
void FD_CLR(int fd, fd_set *set); //清除fdset的位fd
int FD_ISSET(int fd, fd_set *set);//测试fdset的位fd是否被设置
void FD_SET(int fd, fd_set *set);//设置fdset的位fd
void FD_ZERO(fd_set *set);//清除fdset的所有位
fdset
是一个包含整形数组的结构体,该数组的每个元素的每一位(bit)标记一个文件描述符。数组是long int
类型,占8字节,数组大小是16,一个字节是8位,因此,select
所能支持的最大fd
数量便是8 x 8 x 16 = 1024个。这也是select
系统调用的缺点之一。
值得注意的是,select
函数返回时,内核会修改readfds
、writefds
、exceptfds
中的值(就绪的位被置为1,其余全置为0),便于FD_ISSET
来测试哪些描述符就绪。但那些我们加入的还没有就绪的文件描述符也被清0了,所以,每次重新调用select
函数时,都得再次把所有描述符集内所关心的位均置为1,这也是另一个select
系统调用的缺点。
宏的使用:打开描述符1、4、5的对应位:
fd_set rset;
FD_ZERO(&ret);
FD_SET(1, &ret);
FD_SET(4, &ret);
FD_SET(5, &ret);
关于select
的使用:(以下例子来自man手册,实际上最权威的文档便是你所使用系统的man手册)
#include
#include
#include
#include
#include
int
main(void)
{
fd_set rfds;
struct timeval tv;
int retval;
/* Watch stdin (fd 0) to see when it has input. */
FD_ZERO(&rfds);
FD_SET(0, &rfds);
/* Wait up to five seconds. */
tv.tv_sec = 5;
tv.tv_usec = 0;
retval = select(1, &rfds, NULL, NULL, &tv);
/* Don't rely on the value of tv now! */
if (retval == -1)
perror("select()");
else if (retval)
printf("Data is available now.\n");
/* FD_ISSET(0, &rfds) will be true. */
else
printf("No data within five seconds.\n");
exit(EXIT_SUCCESS);
}
用select
来监听标准输入文件描述符可读,设置超时时间为5秒。
select
在网络编程中的使用:
#include
#include
#include
#include
#include
#include
#include
using namespace std;
int main(void)
{
//初始化套接字
int listenfd = socket(AF_INET, SOCK_STREAM, 0);
if (listenfd < 0)
{
perror("socket");
return -1;
}
struct sockaddr_in address;
bzero(&address, sizeof(address));
address.sin_family = AF_INET;
address.sin_addr.s_addr = htonl(INADDR_ANY);
address.sin_port = htons(8080);
int ret = bind(listenfd, (struct sockaddr *)&address, sizeof(address));
if (ret < 0)
{
perror("bind");
return -1;
}
ret = listen(listenfd, 5);
if (ret < 0)
{
perror("listen");
return -1;
}
fd_set rfds;
struct timeval tv;
int maxfd;
vector<int> clientfd;
while (1)
{
FD_ZERO(&rfds);
//将listenfd加入select中,每次都要加入
FD_SET(listenfd, &rfds);
maxfd = listenfd;
//遍历clientfd
for (int i = 0; i < clientfd.size(); i++)
{
//监听每个client的可读事件
FD_SET(clientfd[i], &rfds);
//更新值最大的文件描述符
if (maxfd < clientfd[i])
{
maxfd = clientfd[i];
}
}
//设置超时时间
tv.tv_sec = 5;
tv.tv_usec = 0;
ret = select(maxfd + 1, &rfds, NULL, NULL, &tv);
if (ret == 0)
continue;
else
{
if (FD_ISSET(listenfd, &rfds))
{
struct sockaddr_in client_address;
socklen_t client_addrlength = sizeof(client_address);
int connfd = accept(listenfd, (struct sockaddr *)&client_address, &client_addrlength);
if (connfd < 0)
{
perror("accept");
}
printf("The connection is successful : %d\n", connfd);
clientfd.push_back(connfd);
}
else
{
for (int i = 0; i < clientfd.size(); i++)
{
if (FD_ISSET(clientfd[i], &rfds))
{
//如果有客户端的读事件
char buf[1024];
ret = recv(clientfd[i], buf, sizeof(buf) - 1, 0);
if (ret <= 0)
{
perror("recv");
}
printf("client: %d recv: %s\n", clientfd[i], buf);
}
}
}
}
}
for (int i = 0; i < clientfd.size(); i++)
{
close(clientfd[i]);
}
close(listenfd);
return 0;
}
poll
poll
系统调用和select
类似,也是在指定时间内轮询一定数量的文件描述符,以测试其中是否有就绪者。但它解决了一些select
的不足。
select
使用了基于文件描述符的三位掩码的解决方案,效率不高,poll
可以使用由nfds
个pollfd
结构体构成的数组,fds
指针指向该数组。并且poll
能处理的事件类型也比select
更加丰富。
函数原型:
#include
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events */
short revents; /* returned events */
};
每个pollfd
结构体指定一个被监视的文件描述符。可以给poll
传递多个pollfd
结构体,使它能够监视多个文件描述符。每个结构体的events
变量是要监视的文件描述符的事件的位掩码。用户可以设置该变量。revents
变量是该文件描述符的结果事件的位掩码。内核在返回时会设置revents
变量。events
变量中请求的所有事件都可能在revents
变量中返回。
比如:要监视某个文件描述符是否可读写,需要把events
设置成POLLIN | POLLOUT
。返回时,会检查revents
中是否有相应的标志位。如果设置了POLLIN
,文件描述符可非阻塞读;如果设置了POLLOUT
,文件描述符可非阻塞写。
举例:
#include
#include
#include
#define TIMEOUT 5
int main(void)
{
struct pollfd fds[2];
int ret;
//标准输入
fds[0].fd = STDIN_FILENO;
fds[0].events = POLLIN;
//标准输出
fds[1].fd = STDOUT_FILENO;
fds[1].events = POLLOUT;
ret = poll(fds, 2, TIMEOUT * 1000);
if (ret == -1) {
perror("poll");
return 1;
}
if (!ret) {
printf("%d seconds elapse.\n", TIMEOUT);
return 0;
}
if (fds[0].revents & POLLIN)
printf("stdin is readable\n");
if (fds[1].revents & POLLOUT)
printf("stdout is writable\n");
return 0;
}
运行,输出
./a.out
stdout is writable
当把一个文件重定向到标准输入后,输出
./a.out < test.c
stdin is readable
stdout is writable
同时,使用poll
无需重新重置pollfd
类型的事件集参数,因为内核每次修改的是pollfd
结构体的revents
成员,而events
成员保持不变。
参考资料
[1] 游双.Linux高性能服务器编程[M].北京:机械工业出版社,2013.
[2] 张远龙.C++服务器开发精髓[M].北京:电子工业出版社,2021.7
[3] 祝洪凯,李妹芳,付途译.Linux系统编程[M].北京:人民邮电出版社,2014.
[4] [美]理查德·史蒂文斯,[美]比尔·芬纳,[美]安德鲁·M. 鲁道夫 UNIX网络编程 卷1:套接字联网API 第3版[M].北京:人民邮件出版社,2019.10