Socket编程(四)——IO复用和更多IO函数
select函数实现的IO复用
想要一次与多个客户端通信,可以使用之前提到过的多进程的方法。但是多个进程会各自占有独立的内存空间,每次CPU调度进程时切换上下文需要耗费很多时间。而IO复用技术可以减少需要使用的服务端进程数,使用尽量少的资源来争取尽量高的通信效率。
书中举的例子是学生向老师提问的例子,如果要回答10名学生的问题就需要分配10名教室,IO复用就是可以使用一位“超级教师”,他可以应对所有学生的提问,但代价是每次学生提问前都要举手。
理解select函数
select函数会监视被传递给它的一组文件描述符,这个文件描述符保存于fd_set
中,按位来指示该文件描述符是否需要被监视。而fd_set
变量的注册或更改操作都需要由以下的宏函数来完成:
1 |
|
理解了fd_set
的概念后,下面介绍select函数
1 |
|
select函数参数看似很复杂,实际上不难理解,第二、第三、第四个参数其实都可以看成是一维的布尔数组,而第一个参数就指定了数组的大小。注意,由于数组下标是连续的,所以即使只监视一个文件描述符为n,也要将maxfd
设置为n+1。
最后一个参数的一个定义如下的结构体:
1 |
|
select函数调用时会进入阻塞状态,只有当监视的文件描述符发生变化时才会返回,最后一个参数就是为了防止时间过久。
使用select函数
select函数设计的几个细节其实让人很摸不着头脑。注意到select函数除了第一个参数外都是传递指针,大多数情况下这意味着函数将要修改参数的值,select函数正是如此。传递给select函数的fd_set
变量都会被初始化为0,待函数返回后,这些fd_set
中发生变化(读取、可写、异常)的对应的位才会被置为1,所以每次传递给select的参数都要被复制一遍。同样,每次执行完后timeout
函数都会被替换为超时的剩余时间。
下面是调用select函数的大致流程,由于select函数的设计问题,调用较为复杂,之后会介绍更优的epoll函数:
1 |
|
select函数虽然封装了一部分内容,但需要我们做的事还是很多的。这样看来,select的设计看起来也没有多高明,但是其思路确实非常令人有启发的。
基于Windows平台的select
除了包含的头文件不同外,select函数在Windows平台和Linux平台下是几乎完全相同的,只是fd_set
结构体的声明有所不同。
1 |
|
Windows的fd_set
并非像Linux一样是按位操作的,这是因为Windows中句柄并非从零开始,且无规律可循,所以采用了这样的无符号整型数组。
比select更优的epoll函数
上面介绍的Linux下的select有以下两点不合理的地方:
- 每次调用完select都要遍历所有文件描述符
- 每次调用select前都要向该函数复制一份文件描述符对象
epoll函数的特点正好弥补了这两个缺点,epoll使用如下结构体来将发生事件的文件描述符集合到一起:
1 |
|
epoll函数的文件描述符集合的保存空间称为“epoll例程”,下面这个函数创建一个epoll例程:
1 |
|
生成例程后,需要在其内部注册监视对象文件描述符,此时使用epoll_ctl
函数。
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 |
|
这段代码将sockfd
注册到例程epfd
中。下面是events中可以保存的常量:
- EPOLLIN: 需要读取数据的情况
- EPOLLOUT: 输出缓冲为空,可以立即发送数据的情况
- EPOLLPRI: 收到OOB数据的情况
- EPOLLRDHUP: 断开连接或半关闭的情况,这在边缘触发方式下非常有用
- EPOLLERR:发送错误的情况
- EPOLLET:以边缘触发的方式得到时间通知
- EPOLLONESHOT:发生一次事件后,相应的文件描述符不再收到事件通知,需要向
epoll_ctl
函数第二个参数传递EPOLL_CTL_MOD
再次设置事件。
最后是与select有着同等地位的epoll_wait
函数:
1 |
|
条件触发和边缘触发
这两个名词似乎有些像数字电路中的每次,实际上这两个词的意思和数电中的意思相差不大。条件触发就是只要缓冲区有数据就会一直通知该事件,而边缘触发只有在有新数据到来时才会通知该事件。开启边缘触发有以下三个关键步骤:
1、读取errno变量验证错误原因
errno变量在头文件error.h
中,当read函数发现输入缓冲没有数据可以读入时不仅会返回-1,还会在errno中保存EAGAIN变量。在读取完缓冲区内容后,如果errno的值为EAGAIN,则可以结束读取。
2、更改套接字属性为非阻塞
更改套接字属性需要先获得套接字原来的属性,然后将非阻塞的属性通过位运算添加:
1 |
|
3、将文件描述符改为边缘触发
正如上文所描述的,将epoll_event
结构体中的events
设置为对应的值即可,注意,这里仍需要位运算操作:
1 |
|
由于边缘触发只会在数据到来时通知一次,所以需要使用一个while循环在每次有新数据到来时读完缓冲区中的所有数据,这是与条件触发不一样的另一个地方。详细代码可以参考书本。
更多IO函数
Linux中的send和recv
Linux平台下其实也有send和recv这两个函数,与Windows平台下并无区别。
1 |
|
最后一个参数是可选项,下面是可选项的种类以及含义:
- MSG_OOB:传送带外数据
- MSG_PEEK:验证输入缓冲中是否存在接收的数据
- MSG_DONTROUTE:数据传输过程中不参照路由表
- MSG_DONTWAIT:调用IO函数时不阻塞
- MSG_WAITALL:防止函数返回,直到接收全部请求的字节数
MSG_OOB说明
OOB数据译为紧急数据,但是实际上并不会比普通数据快,只是在TCP的报头会有一个URG指针用于表示“紧急”的数据。“紧急”的数据会在到来时给进程发送一个SIGURG
信号,如果进程为该信号注册了对应的处理函数,就可以通过这个处理函数来完成一些内容。
换言之,紧急数据并不是在网络传输时更优先的数据(但是紧急数据经过的通信路径可能确实不同),而是告诉程序员需要优先处理的数据。
检查输入缓冲
同时设置MSG_PEEK
和MSG_DONTWAIT
选项可以验证输入缓冲中是否存在接收的数据。前者使得调用的recv函数不会删除缓冲区的内容,后者使得recv使用非阻塞式IO。
readv和writev函数
这两个函数的功能是对数据进行整合,可以理解为将原来散开的数据聚合到一起发送。下面是函数的原型:
1 |
|
其实这两个函数没有什么特别的地方(至少暂时我还没发现),也许就是改成用for循环来发送或读取数据罢了。