I/O复用简介
一般TCP服务器的运行模型如下:
客户端1先与服务器进行交互,这时候又有客户端2与客户端3与服务器建立连接,想要与服务器交互,但是由于服务器在与客户端1交互,所以客户端2、3就只能在listen()创建的内核的等待队列中等待
从上图可以看出问题:一个TCP服务器程序同时只能和一个客户端交互,其他链接上的客户端只能在TCP服务器的内核中等待。这个实现方式是不合理的,所以人们提出了I/O多路复用这个模型,通过记录I/O流的状态来同时管理多个I/O,可以提高服务器的吞吐能力
I/O复用:一个程序可以同时监听多个(上千个)文件描述符上是否有事件发生,如果某些文件描述符有事件发生,则去处理发生了事件的文件描述符,其他没有事件发生的文件描述符,则不予理睬
加上I/O复用之后的TCP服务器模型
I/O复用使程序能同时监听多个文件描述符,这对于提高程序的性能至关重要。通常,网络程序在下列情况下需要使用I/O复用技术:
- TCP服务器同时处理监听套接字和连接套接字
- 服务器要同时处理TCP请求和UDP请求
- 程序要同时处理多个套接字
- 客户端程序要同时处理用户输入和网络连接
- 服务器要同时监听多个端口
注意:I/O复用虽然能同时监听多个文件描述符,但它本身是阻塞的,并且当多个文件描述符同时就绪时,如果不采取额外的措施,程序就只能按顺序依次处理其中的每一个文件描述符,这使得服务器看起来好像是串行工作的。如果要提高并发处理的能力,可以配合使用多线程或多进程等编程方法
Linux下实现I/O复用的系统调用主要有:
- select
- poll
- epoll (Linux独有的一种I/O复用方式)
select
select系统调用:在一段指定时间内,监听用户感兴趣的文件描述符上的可读、可写和异常等事件。
用户通过3个参数分别传入感兴趣的可读、可写及异常等事件,内核通过对这些参数的在线修改来反馈其中的就绪事件。所以用户每次调用select都要重置这3个参数
select系统调用原型
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
-
nfds:所监听的所有文件描述符中的最大值 + 1,加一的原因是:文件描述符从0开始,使用nfds可以告知系统所需关注的文件描述符最大值到哪,提高内核执行效率
-
readfds,writefds,exceptfds:
作用①:监听的文件描述符的集合,分别是读事件的文件描述符集合,写事件的文件描述符集合,异常事件的文件描述符集合
作用②:在线修改:内核监听到某些文件描述符有事件发生时,也是通过这三个参数告知应用程序的。因此每次调用select之前必须重新设置这三个fd_set
-
timeout:定时时间,在这段时间内监听所有关注的文件描述符,如果定时时间到了,依旧没有事件就绪,select也会返回。如果需要select永久阻塞,则将timeout置为NULL,timeval结构体如下:
struct tmieval { long tv_sec; //秒数 long tv_usec; //微秒数 };
-
返回值:正常返回有事件发生的文件描述符的个数;超时返回0;出错返回负值。
由于select方法返回的是就绪个数,并不返回具体哪个就绪,所以在用户执行时还是需要再进行一次查询,查询具体哪个文件描述符就绪
select的参数 fd_set
typedef long int _fd_mask;
#define _NFDBITS (8 * (int)sizeof(_fd_mask))
typedef struct
{
_fd_mask _fds_bits[32];
#define _FDS_BITS(set) ((set)->_fds_bits)
}fd_set;
fd_set记录文件描述符的方式:按照位来记录,因为只关注发生没发生,所以0没发生,1发生
(32个元素,每个元素32位,所以一共1024bit,如下图,假如现在要记录文件描述符3,只需要1左移三位再执行或运算
由于位操作比较烦琐,可以通过下列宏函数来操作fd_set结构:
#include <sys/select.h>
FD_SET(int fd, fd_set *fdset) //设置fdset中的位fd 即把fd加入到fdset
FD_CLR(int fd, fd_set *fdset) //清除fdset的位fd
int FD_ISSET(int fd, fd_set *fdset)//判断fd在不在fdsetp上
FD_ZERO(int *fdset) //清除fdset中的所有位
用select实现TCP服务器
总体思路:
服务器端代码:
// ser.c
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/select.h>
#define FDNUMBER 1024 //文件描述符数组的大小
// 根据ip地址与端口号创建套接字,返回创建的套接字的文件描述符
int CreateSocket(char *ip, short port)
{
int listenfd = socket(AF_INET, SOCK_STREAM, 0);
if(listenfd == -1) return -1;
struct sockaddr_in ser_addr;
memset(&ser_addr, 0, sizeof(ser_addr));
ser_addr.sin_family = AF_INET;
ser_addr.sin_port = htons(port);
ser_addr.sin_addr.s_addr = inet_addr(ip);
int res = bind(listenfd, (struct sockaddr*)&ser_addr, sizeof(ser_addr));
if(res == -1) return -1;
res = listen(listenfd, 5);
if(res == -1) return -1;
return listenfd;
}
// 将所有的文件描述符设置到fd_set结构体变量上, 找到当前最大的文件描述符的值
int InitFdSet(int *all_fd, fd_set *set)
{
FD_ZERO(set);
int maxfd = -1;
int i = 0;
for(; i < FDNUMBER; ++i)
{
if(all_fd[i] != -1)
{
FD_SET(all_fd[i], set);
if(all_fd[i] > maxfd)
{
maxfd = all_fd[i];
}
}
}
return maxfd;
}
// 初始化文件描述符数组
void InitAllFd(int *all_fd)
{
int i = 0;
for(; i < FDNUMBER; ++i)
{
all_fd[i] = -1;
}
}
// 向all_fd数组中插入fd
void InsertFd(int *all_fd, int fd)
{
int i = 0;
for(; i < FDNUMBER; ++i)
{
if(all_fd[i] == -1)
{
all_fd[i] = fd;
return;
}
}
}
// 根据val在all_fd中进行删除,flag为1,则val为文件描述符, flag是0,则val是数组的下标
void DeleteFd(int *all_fd, int val, int flag)
{
if(flag)
{
int i = 0;
for(; i < FDNUMBER; ++i)
{
if(all_fd[i] == val)
{
all_fd[i] = -1;
return;
}
}
}
else
{
all_fd[val] = -1;
}
}
// 处理就绪的事件
void DealReadyEvent(int *all_fd, fd_set *set, int listenfd)
{
// 对所有就绪事件的处理还依旧是串行的
int i = 0;
for(; i < FDNUMBER; ++i)
{
if(all_fd[i] == -1) continue;
if(FD_ISSET(all_fd[i], set)) // 如果返回为真,说明这个文件描述符在set中,也就是文件描述符事件就绪
{
/*
tcp服务器程序的所有文件描述符可以分成两类: 监听客户端链接的文件描述符、 与一个客户端链接的文件描述符
如果是监听文件描述符就绪,处理就是accept
如果是链接文件描述符,处理就是recv/send
*/
if(all_fd[i] == listenfd)
{
struct sockaddr_in cli_addr;
socklen_t addr_len = sizeof(cli_addr);
int c = accept(listenfd, (struct sockaddr*)&cli_addr, &addr_len);
if(c < 0) continue;
// 将接收客户端链接的文件描述符添加到all_fd数组中
InsertFd(all_fd, c);
printf("Get New Clinet Link\n");
}
else
{
char buff[128] = {0};
int n = recv(all_fd[i], buff, 127, 0);
if(n <= 0) // recv失败,或者客户端关闭了链接
{
close(all_fd[i]);
printf("%d Over\n", all_fd[i]);
DeleteFd(all_fd, i, 0);
}
else
{
printf("%d: %s\n", all_fd[i], buff);
send(all_fd[i], "OK", 2, 0);
}
}
}
}
printf("DealReadyEvent over\n");
}
int main()
{
// 创建套接字
int listenfd = CreateSocket("192.168.133.132", 6000);
assert(listenfd != -1);
//记录当前程序所打开的所有的文件描述符
int all_fd[FDNUMBER];
InitAllFd(all_fd);
InsertFd(all_fd, listenfd);
while(1)
{
/*
select的三个参数:最大文件描述符值+1,关注读、写、异常事件的文件描述符集合fd_set,超时时间
*/
fd_set read_set;
int maxfd = InitFdSet(all_fd, &read_set);
struct timeval timeout = {5, 0};
int n = select(maxfd+1, &read_set, NULL, NULL, NULL);//&timeout);
printf("select return\n");
if(n < 0)
{
printf("select error\n");
continue;
}
if(n == 0)
{
printf("timeout\n");
continue;
}
DealReadyEvent(all_fd, &read_set, listenfd);
}
}
客户端代码:
// cli.c
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h> //字节序的转换
#include <arpa/inet.h> //IP地址转换
int main()
{
int sockfd = socket(AF_INET, SOCK_STREAM, 0);//创建套接字
assert(-1 != sockfd);
struct sockaddr_in ser_addr;
memset(&ser_addr, 0, sizeof(ser_addr));
ser_addr.sin_family = AF_INET;
ser_addr.sin_port = htons(6000);
ser_addr.sin_addr.s_addr = inet_addr("192.168.133.132");
int res = connect(sockfd, (struct sockaddr*)&ser_addr, sizeof(ser_addr));//指定连接的服务器端的 IP 地址和端口
assert(-1 != res);
while(1)
{
printf("input: ");
char buff[128] = {0};
fgets(buff, 127, stdin);
if(strncmp(buff, "end", 3) == 0)
{
break;
}
send(sockfd, buff, strlen(buff) - 1, 0);
memset(buff, 0, 128);
recv(sockfd, buff, 127, 0);
printf("%s\n", buff);
}
close(sockfd);
exit(0);
}
执行结果:
结果分析:
select调用最后一个参数定时时间timeout代码中设置为NULL
,若所监听的文件描述符都没有事件发生,select将永久阻塞
当有客户端发起链接,select返回,并且select修改了fd_set结构体,打印select return
,DealReadyEvent函数中accept()处理链接,打印Get New Client Link
,接着打印DealReadyEvent over
然后返回主函数while循环再次调用select,select再次阻塞,客户端这时候输入i am 1
,select调用返回,DealReadyEvent函数中recv接收客户端发送的数据,打印出4: i am 1
,处理结束后打印DealReadyEvent over
执行结果中服务器程序创建的与客户端链接的文件描述符为什么从4开始?
Linux上启动的进程默认会打开三个文件描述符:0:标准输入stdin;1:标准输出stdout;2:标准错误输出stderr
文件描述符3被服务器用socket()创建套接字返回的文件描述符占用
因此服务器程序创建的与客户端链接的文件描述符可取:4 – 1023(共1000个)
终端中使用
ulimit -a
可以查看当前的各种用户进程限制
select总结
1、最多只能监听1024个文件描述符(理论上),文件描述符从0开始,即最大值为1023,实际上最多监听的文件描述符数量要减去4
2、select只能关注3种事件类型:读事件、写事件、异常事件
3、内核会在线修改用户传递的关注事件的文件描述符的集合的变量(即修改fd_set结构),每次调用select之前都必须重新设置三个fd_set结构变量
4、select返回后,只是告诉用户程序有几个文件描述符就绪,但是并没有指定是具体的哪几个。因此用户程序就需要遍历所有的文件描述符来探测具体是哪些文件描述符处于就绪,所以时间复杂度为O(n)
5、用户程序得自己维护所有的文件描述符。然后,每一次调用select的时候,都需要将用户空间的fd_set集合传递给内核空间,select返回时,又需要将内核的fd_set传递给用户空间。这样调用select的时候,会存在两次的数据拷贝(如下内核代码所示),效率不是很高
6、select内核采用的是轮询的方式去监测哪些文件描述符上的事件就绪(底层是个for循环)
7、select的工作模式只能是LT模式(Level Trigger,电平触发)