Socket编程(四)——IO复用和更多IO函数

select函数实现的IO复用

想要一次与多个客户端通信,可以使用之前提到过的多进程的方法。但是多个进程会各自占有独立的内存空间,每次CPU调度进程时切换上下文需要耗费很多时间。而IO复用技术可以减少需要使用的服务端进程数,使用尽量少的资源来争取尽量高的通信效率。

书中举的例子是学生向老师提问的例子,如果要回答10名学生的问题就需要分配10名教室,IO复用就是可以使用一位“超级教师”,他可以应对所有学生的提问,但代价是每次学生提问前都要举手。

理解select函数

select函数会监视被传递给它的一组文件描述符,这个文件描述符保存于fd_set中,按位来指示该文件描述符是否需要被监视。而fd_set变量的注册或更改操作都需要由以下的宏函数来完成:

1
2
3
4
FD_ZERO(fd_set * fdset); // 将所有位置零
FD_SET(int fd, fd_set * fdset); // 注册文件描述符fd的信息(将第fd位设置为1)
FD_CLR(int fd, fd_set * fdset); // 清除fd的信息(将第fd位设置为0)
FD_ISSET(int fd, fd_set * fdset); // 返回第fd位的真值

理解了fd_set的概念后,下面介绍select函数

1
2
3
4
5
6
7
8
9
/*
* maxfd: 监视的文件描述符的数量
* readset: 需要关注“有待读取数据”的文件描述符
* writeset: 需要关注“是否可传输无阻塞数据”的文件描述符
* exceptset: 关注的发生异常的文件描述符
* timeout: 等待时间的上限
* 返回值:错误返回-1,超时返回0。正常时返回发生事件的文件描述符数量。
*/
int select(int maxfd, fd_set * readset, fd_set * writeset, fd_set * exceptset, const struct timeval * timeout);

select函数参数看似很复杂,实际上不难理解,第二、第三、第四个参数其实都可以看成是一维的布尔数组,而第一个参数就指定了数组的大小。注意,由于数组下标是连续的,所以即使只监视一个文件描述符为n,也要将maxfd设置为n+1。

最后一个参数的一个定义如下的结构体:

1
2
3
4
5
struct timeval
{
long tv_sec; //seconds
long tv_usec; //microseconds
}

select函数调用时会进入阻塞状态,只有当监视的文件描述符发生变化时才会返回,最后一个参数就是为了防止时间过久。

使用select函数

select函数设计的几个细节其实让人很摸不着头脑。注意到select函数除了第一个参数外都是传递指针,大多数情况下这意味着函数将要修改参数的值,select函数正是如此。传递给select函数的fd_set变量都会被初始化为0,待函数返回后,这些fd_set中发生变化(读取、可写、异常)的对应的位才会被置为1,所以每次传递给select的参数都要被复制一遍。同样,每次执行完后timeout函数都会被替换为超时的剩余时间。

下面是调用select函数的大致流程,由于select函数的设计问题,调用较为复杂,之后会介绍更优的epoll函数:

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
fd_set reads,cpy_reads;
FD_ZERO(&reads);
FD_SET(serv_sock,&reads);
fd_max = serv_sock;

while(1)
{
cpy_reads=reads; // 复制需要监听的描述符,否则会被清零
timeout.tv_sec=5;
if((fd_num=select(fd_max+1,&cpy_reads,0,0,&timeout))==-1)
break;

for(int i=0;i<fd_max+1;i++)
{
if(FD_ISSET(i,&cpy_reads)) // 遍历数组,检查每一位是否发生变化
{
if(i==serv_sock) // 服务端套接字发生变化,就是有新的连接请求
{
clnt_sock=accept(...);
FD_SET(clnt_sock,&reads); // 将客户端套接字纳入监听的范围
fd_max=max(fd_max,clnt_sock);
}
else
{
str_len=read(i,buf,BUF_SIZE);
if(str_len==0)
{
FD_CLR(i,&reads); // 关闭客户端套接字
close(i);
}
}
}
}
}

select函数虽然封装了一部分内容,但需要我们做的事还是很多的。这样看来,select的设计看起来也没有多高明,但是其思路确实非常令人有启发的。

基于Windows平台的select

除了包含的头文件不同外,select函数在Windows平台和Linux平台下是几乎完全相同的,只是fd_set结构体的声明有所不同。

1
2
3
4
5
typedef struct fd_set
{
u_int fd_count;
SOCKET fd_array[FD_SETSIZE];
}

Windows的fd_set并非像Linux一样是按位操作的,这是因为Windows中句柄并非从零开始,且无规律可循,所以采用了这样的无符号整型数组。

比select更优的epoll函数

上面介绍的Linux下的select有以下两点不合理的地方:

  • 每次调用完select都要遍历所有文件描述符
  • 每次调用select前都要向该函数复制一份文件描述符对象

epoll函数的特点正好弥补了这两个缺点,epoll使用如下结构体来将发生事件的文件描述符集合到一起:

1
2
3
4
5
6
7
8
9
10
11
12
13
struct epoll_event
{
__uint32_t events;
epoll_data_t data;
}

typedef union epoll_data
{
void * ptr;
int fd;
__uint32_t u32;
__uint64_t u64;
}epoll_data_t;

epoll函数的文件描述符集合的保存空间称为“epoll例程”,下面这个函数创建一个epoll例程:

1
2
int epoll_create(int size);
// 成功时返回文件描述符,失败时返回-1

生成例程后,需要在其内部注册监视对象文件描述符,此时使用epoll_ctl函数。

1
2
3
4
5
6
7
8
/*
* epfd:用于注册监视对象的例程的文件描述符
* op:用于指定操作
* fd: 需要注册的监视对象文件描述符
* event: 监视对象的事件类型
*/
int epoll_ctl(int epfd, int op, int fd, struct epoll_event * event);
// 成功时返回0,失败时返回-1

epoll_ctl(A,EPOLL_ADD,B,C)用于在“例程A中注册文件描述符B,主要目的是监视参数C中的时间”。第二个参数有以下三种常量:

  • EPOLL_CTL_ADD:注册文件描述符
  • EPOLL_CTL_DEL:删除文件描述符
  • EPOLL_CTL_MOD:更改关注事件发生情况

当希望在例程中删除文件描述时,应该向第四个参数传递NULL

epoll_event结构体可以用于保存发生事件的文件描述符集合,也可以在epoll例程中注册文件描述符时用于注册关注的事件。下面语句可以说明这个用处:

1
2
3
4
struct epoll_event event;
event.events = EPOLLIN;
event.data.fd = sockfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &event);

这段代码将sockfd注册到例程epfd中。下面是events中可以保存的常量:

  • EPOLLIN: 需要读取数据的情况
  • EPOLLOUT: 输出缓冲为空,可以立即发送数据的情况
  • EPOLLPRI: 收到OOB数据的情况
  • EPOLLRDHUP: 断开连接或半关闭的情况,这在边缘触发方式下非常有用
  • EPOLLERR:发送错误的情况
  • EPOLLET:以边缘触发的方式得到时间通知
  • EPOLLONESHOT:发生一次事件后,相应的文件描述符不再收到事件通知,需要向epoll_ctl函数第二个参数传递EPOLL_CTL_MOD再次设置事件。

最后是与select有着同等地位的epoll_wait函数:

1
2
3
4
5
6
/*
* epfd:例程的文件描述符
* events:保存发生事件的文件描述符集合的结构体地址(可以看成一个数组)
*/
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
// 成功时返回保存的最大事件数,失败时返回-1

条件触发和边缘触发

这两个名词似乎有些像数字电路中的每次,实际上这两个词的意思和数电中的意思相差不大。条件触发就是只要缓冲区有数据就会一直通知该事件,而边缘触发只有在有新数据到来时才会通知该事件。开启边缘触发有以下三个关键步骤:

1、读取errno变量验证错误原因

errno变量在头文件error.h中,当read函数发现输入缓冲没有数据可以读入时不仅会返回-1,还会在errno中保存EAGAIN变量。在读取完缓冲区内容后,如果errno的值为EAGAIN,则可以结束读取。

2、更改套接字属性为非阻塞

更改套接字属性需要先获得套接字原来的属性,然后将非阻塞的属性通过位运算添加:

1
2
int flag = fcntl(fd, F_GETFL,0);
fcntl(fd, F_SETFL, flag|O_NONBLOCK);

3、将文件描述符改为边缘触发

正如上文所描述的,将epoll_event结构体中的events设置为对应的值即可,注意,这里仍需要位运算操作:

1
event.events = EPOLLIN|EPOLLET;

由于边缘触发只会在数据到来时通知一次,所以需要使用一个while循环在每次有新数据到来时读完缓冲区中的所有数据,这是与条件触发不一样的另一个地方。详细代码可以参考书本。

更多IO函数

Linux中的send和recv

Linux平台下其实也有send和recv这两个函数,与Windows平台下并无区别。

1
2
ssize_t send(int sockfd, const void * buf, size_t nbytes, int flags);
ssize_t recv(int sockfd, void * buf, size_t nbytes, int flags);

最后一个参数是可选项,下面是可选项的种类以及含义:

  • MSG_OOB:传送带外数据
  • MSG_PEEK:验证输入缓冲中是否存在接收的数据
  • MSG_DONTROUTE:数据传输过程中不参照路由表
  • MSG_DONTWAIT:调用IO函数时不阻塞
  • MSG_WAITALL:防止函数返回,直到接收全部请求的字节数

MSG_OOB说明

OOB数据译为紧急数据,但是实际上并不会比普通数据快,只是在TCP的报头会有一个URG指针用于表示“紧急”的数据。“紧急”的数据会在到来时给进程发送一个SIGURG信号,如果进程为该信号注册了对应的处理函数,就可以通过这个处理函数来完成一些内容。

换言之,紧急数据并不是在网络传输时更优先的数据(但是紧急数据经过的通信路径可能确实不同),而是告诉程序员需要优先处理的数据。

检查输入缓冲

同时设置MSG_PEEKMSG_DONTWAIT选项可以验证输入缓冲中是否存在接收的数据。前者使得调用的recv函数不会删除缓冲区的内容,后者使得recv使用非阻塞式IO。

readv和writev函数

这两个函数的功能是对数据进行整合,可以理解为将原来散开的数据聚合到一起发送。下面是函数的原型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include<sys/uio.h>
/*
* filedes: 发送的文件描述符
* iov: 结构体数组的地址,结构体iovec中包含待发送数据的位置和大小信息
* invcnt: 数组长度
*/
ssize_t writev(int filedes, const struct iovec* iov, int iovcnt);
// 成功时返回发送的字节数,失败时返回-1

struct iovec
{
void * iov base; // buffer adress
size_t iov_len; // buffer size
}

// 与writev相似
ssize_t readv(int filedes, const struct iovec * iov, int iovcnt);

其实这两个函数没有什么特别的地方(至少暂时我还没发现),也许就是改成用for循环来发送或读取数据罢了。


Socket编程(四)——IO复用和更多IO函数
http://zhouhf.top/2023/03/14/Socket编程(四)——IO复用和更多IO函数/
作者
周洪锋
发布于
2023年3月14日
许可协议