I/O复用使得程序能同时监听多个文件描述符,这对提高程序的性能至关重要。
通常,网络程序在下列情况下需要使用I/O复用技术:
客户端
- 客户端程序要同时处理多个socket。比如非阻塞connect技术。
- 客户端程序要同时处理用户输入和网络连接。比如聊天室程序。
服务器
- TCP服务器要同时处理监听socket和连接socket。这是I/O复用使最多的场合。
- 服务器要同时处理TCP请求和UDP请求。
- 服务器要同时监听多个端口,或者处理多种服务。
阻塞性:需要指出的是,I/O复用虽然能同时监听多个文件描述符,但它本身是阻塞的。并且当多个文件描述符同时就绪时,如果不采取额外的措施,程序就只能按顺序依次处理其中的每一个文件描述符,这使得服务器程序看起来像是串行工作的。如果要实现并发,只能使用多进程或多线程等编程手段。
Linux下实现I/O复用的系统调用主要有select、poll和epoll。
select系统调用
select系统调用的用途是:在一段指定时间内,监听用户感兴趣的文件描述符上的可读、可写和异常等事件。(由内核执行)
select API
#include<sys/select.h>
int select(int nfds,fd_set*readfds,fd_set*writefds,fd_set*exceptfds,struct timeval*timeout);
-
nfds
:指定被监听的文件描述符的总数。它通常被设置为select监听的所有文件描述符中的最大值加1,因为文件描述符是从0开始计数的。 -
readfds、writefds和exceptfds参数分别指向可读、可写和异常等事件对应的文件描述符集合。应用程序调用select函数时,通过这3个参数传入自己感兴趣的文件描述符。select调用返回时,内核将修改它们来通知应用程序哪些文件描述符已经就绪。这3个参数是fd_set结构指针类型。
fd_set
结构体的定义如下:
#include<typesizes.h>
#define__FD_SETSIZE 1024
#include<sys/select.h>
#define FD_SETSIZE__FD_SETSIZE
typedef long int__fd_mask;
#undef__NFDBITS
#define__NFDBITS(8*(int)sizeof(__fd_mask))
typedef struct
{
#ifdef__USE_XOPEN
__fd_mask fds_bits[__FD_SETSIZE/__NFDBITS];
#define__FDS_BITS(set)((set)->fds_bits)
#else
__fd_mask__fds_bits[__FD_SETSIZE/__NFDBITS];
#define__FDS_BITS(set)((set)->__fds_bits)
#endif
}fd_set;
由以上定义可见,fd_set结构体仅包含一个整型数组,该数组的每个元素的每一位(bit)标记一个文件描述符。fd_set能容纳的文件描述符数量由FD_SETSIZE指定,这就限制了select能同时处理的文件描述符的总量。
由于位操作过于烦琐,我们应该使用下面的一系列宏来访问fd_set结构体中的位:
#include<sys/select.h>
FD_ZERO(fd_set*fdset);/*清除fdset的所有位*/
FD_SET(int fd,fd_set*fdset);/*设置fdset的位fd*/
FD_CLR(int fd,fd_set*fdset);/*清除fdset的位fd*/
int FD_ISSET(int fd,fd_set*fdset);/*测试fdset的位fd是否被设置*/
timeout
参数用来设置select函数的超时时间。它是一个timeval结构类型的指针,采用指针参数是因为内核将修改它以告诉应用程序select等待了多久。不过我们不能完全信任select调用返回后的timeout值,比如调用失败时timeout值是不确定的。
timeval结构体的定义如下:
struct timeval
{
long tv_sec;/*秒数*/
long tv_usec;/*微秒数*/
};
由以上定义可见,select
给我们提供了一个微秒级的定时方式:
- 如果给
timeout
变量的tv_sec
成员和tv_usec
成员都传递0
,则select
将立即返回。 - 如果给
timeout
传递NULL
,则select
将一直阻塞,直到某个文件描述符就绪。
select
的返回值
- 成功时,返回就绪(可读、可写和异常)文件描述符的总数。
- 如果在超时时间内没有任何文件描述符就绪,
select
将返回0
。 - 失败时,返回
-1
并设置errno
。
如果在 select
等待期间,程序接收到信号,则 select
立即返回 -1
,并设置 errno
为 EINTR
。
文件描述符就绪条件
哪些情况下文件描述符可以被认为是可读、可写或者出现异常,对于 select
的使用非常关键。
在网络编程中,下列情况下 socket
可读:
socket
内核接收缓存区中的字节数大于或等于其低水位标记SO_RCVLOWAT
。此时我们可以无阻塞地读该socket
,并且读操作返回的字节数大于 0。socket
通信的对方关闭连接。此时对该socket
的读操作将返回 0。- 监听
socket
上有新的连接请求。 socket
上有未处理的错误。此时我们可以使用getsockopt
来读取和清除该错误。
下列情况下 socket
可写:
socket
内核发送缓存区中的可用字节数大于或等于其低水位标记SO_SNDLOWAT
。此时我们可以无阻塞地写该socket
,并且写操作返回的字节数大于 0。socket
的写操作被关闭。对写操作被关闭的socket
执行写操作将触发一个SIGPIPE
信号。socket
使用非阻塞connect
连接成功或者失败(超时)之后。socket
上有未处理的错误。此时我们可以使用getsockopt
来读取和清除该错误。
网络程序中,select
能处理的异常情况只有一种:socket
上接收到带外数据。
主旨思想
- 首先要构造一个关于文件描述符的列表,将要监听的文件描述符添加到该列表中
- 调用一个系统函数(
select
),监听该列表中的文件描述符,直到这些描述符中的一个或者多个进行I/O操作时,该函数才返回- 这个函数是阻塞
- 函数对文件描述符的检测的操作是由内核完成的
- 在返回时,它会告诉进程有多少(哪些)描述符要进行I/O操作
工作过程分析
- 初始设定
- 设置监听文件描述符,将
fd_set
集合相应位置为1
- 调用
select
委托内核检测
- 内核检测完毕后,返回给用户态结果
代码实现
注意事项
select
中需要的监听集合需要两个- 一个是用户态真正需要监听的集合
rSet
- 一个是内核态返回给用户态的修改集合
tmpSet
- 一个是用户态真正需要监听的集合
- 需要先判断监听文件描述符是否发生改变
- 如果改变了,说明有客户端连接,此时需要将新的连接文件描述符加入到
rSet
,并更新最大文件描述符 - 如果没有改变,说明没有客户端连接
- 如果改变了,说明有客户端连接,此时需要将新的连接文件描述符加入到
- 由于
select
无法确切知道哪些文件描述符发生了改变,所以需要执行遍历操作,使用FD_ISSET
判断是否发生了改变 - 如果客户端断开了连接,需要从
rSet
中清除需要监听的文件描述符 - 程序存在的问题:中间的一些断开连接后,最大文件描述符怎么更新?=>估计不更新,每次都会遍历到之前的最大值处,解决方案见高并发优化思考
服务端
#include <stdio.h>
#include <arpa/inet.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/select.h>#define SERVERIP "127.0.0.1"
#define PORT 6789int main()
{// 1. 创建socket(用于监听的套接字)int listenfd = socket(AF_INET, SOCK_STREAM, 0);if (listenfd == -1) {perror("socket");exit(-1);}// 2. 绑定struct sockaddr_in server_addr;server_addr.sin_family = PF_INET;// 点分十进制转换为网络字节序inet_pton(AF_INET, SERVERIP, &server_addr.sin_addr.s_addr);// 服务端也可以绑定0.0.0.0即任意地址// server_addr.sin_addr.s_addr = INADDR_ANY;server_addr.sin_port = htons(PORT);int ret = bind(listenfd, (struct sockaddr*)&server_addr, sizeof(server_addr));if (ret == -1) {perror("bind");exit(-1);}// 3. 监听(TCP连接队列的上限)ret = listen(listenfd, 8);if (ret == -1) {perror("listen");exit(-1);}// 创建读检测集合// rSet用于记录正在的监听集合,tmpSet用于记录在轮训过程中由内核态返回到用户态的集合fd_set rSet, tmpSet;// 清空FD_ZERO(&rSet);// 将监听文件描述符加入FD_SET(listenfd, &rSet);// 此时最大的文件描述符为监听描述符int maxfd = listenfd;// 不断循环等待客户端连接while (1) {tmpSet = rSet;// 使用select,设置为永久阻塞,有文件描述符变化才返回int num = select(maxfd + 1, &tmpSet, NULL, NULL, NULL);if (num == -1) {perror("select");exit(-1);} else if (num == 0) {// 当前无文件描述符有变化,执行下一次遍历// 在本次设置中无效(因为select被设置为永久阻塞)continue;} else {// 首先判断监听文件描述符是否发生改变(即是否有客户端连接)if (FD_ISSET(listenfd, &tmpSet)) {// 4. 接收客户端连接struct sockaddr_in client_addr;socklen_t client_addr_len = sizeof(client_addr);int connfd = accept(listenfd, (struct sockaddr*)&client_addr, &client_addr_len);if (connfd == -1) {perror("accept");exit(-1);}// 输出客户端信息,IP组成至少16个字符(包含结束符)char client_ip[16] = {0};inet_ntop(AF_INET, &client_addr.sin_addr.s_addr, client_ip, sizeof(client_ip));unsigned short client_port = ntohs(client_addr.sin_port);printf("ip:%s, port:%d\n", client_ip, client_port);FD_SET(connfd, &rSet);// 更新最大文件符maxfd = maxfd > connfd ? maxfd : connfd;}// 遍历集合判断是否有变动,如果有变动,那么通信char recv_buf[1024] = {0};for (int i = listenfd + 1; i <= maxfd; i++) {if (FD_ISSET(i, &tmpSet)) {ret = read(i, recv_buf, sizeof(recv_buf));if (ret == -1) {perror("read");exit(-1);} else if (ret > 0) {printf("recv server data : %s\n", recv_buf);write(i, recv_buf, strlen(recv_buf));} else {// 表示客户端断开连接printf("client closed...\n");close(i);FD_CLR(i, &rSet);break;}}}}}close(listenfd);return 0;
}
客户端
#include <stdio.h>
#include <arpa/inet.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>#define SERVERIP "127.0.0.1"
#define PORT 6789int main()
{// 1. 创建socket(用于通信的套接字)int connfd = socket(AF_INET, SOCK_STREAM, 0);if (connfd == -1) {perror("socket");exit(-1);}// 2. 连接服务器端struct sockaddr_in server_addr;server_addr.sin_family = PF_INET;inet_pton(AF_INET, SERVERIP, &server_addr.sin_addr.s_addr);server_addr.sin_port = htons(PORT);int ret = connect(connfd, (struct sockaddr*)&server_addr, sizeof(server_addr));if (ret == -1) {perror("connect");exit(-1);}// 3. 通信char recv_buf[1024] = {0};while (1) {// 发送数据char *send_buf = "client message";write(connfd, send_buf, strlen(send_buf));// 接收数据ret = read(connfd, recv_buf, sizeof(recv_buf));if (ret == -1) {perror("read");exit(-1);} else if (ret > 0) {printf("recv server data : %s\n", recv_buf);} else {// 表示客户端断开连接printf("client closed...\n");}// 休眠的目的是为了更好的观察,放在此处可以解决read: Connection reset by peer问题sleep(1);}// 关闭连接close(connfd);return 0;
}
高并发优化思考
问题
- 每次都需要利用
FD_ISSET
轮训[0, maxfd]
之间的连接状态,如果位于中间的某一个客户端断开了连接,此时不应该再去利用FD_ISSET
轮训,造成资源浪费 - 如果在处理客户端数据时,某一次read没有对数据读完,那么造成重新进行下一次时select,获取上一次未处理完的文件描述符,从0开始遍历到maxfd,对上一次的进行再一次操作,效率十分低下
解决
- 考虑到
select
只有1024
个最大可监听数量,可以申请等量客户端数组- 初始置为-1,当有状态改变时,置为相应文件描述符
- 此时再用
FD_ISSET
轮训时,跳过标记为-1的客户端,加快遍历速度
- 对于问题二:对读缓存区循环读,直到返回
EAGAIN
再处理数据
存在问题(缺点)
- 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
- 同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大
- select支持的文件描述符数量太小了,默认是1024,如果链接客户端过多,select 采用的是轮询模型,会大大降低服务器响应效率,不应在 select 上投入更多精力。
- fds集合不能重用,每次都需要重置
参考
《Linux高性能服务器编程》
《牛客C++实战 webserver笔记》
connect及bind、listen、accept背后的三次握手
select源码剖析