目录
高并发服务器的三种方式:
- 阻塞等待--消耗资源(如多线程多进程实现)
- 非阻塞忙轮询--消耗cpu
- 多路IO转接(内核监听多个文件描述符的属性(读写缓冲区)变化,如果某个文件描述符的读缓冲区变化了,这个时候就是可以读了,将这个事件告知应用层)
多路IO转接三种方式:select(windows, 跨平台)、poll(少用)、epoll(linux)。
多路IO转接服务器也叫做多任务IO服务器。该类服务器实现的主旨思想时,不再由应用程序自己监视客户端连接,取而代之由内核替应用程序监视文件。
1、select实现
select能监听文件描述符个数受限于FD_SETSIZE,一般为1024,单纯改变进程打开的文件描述符个数并不能改变select监听文件个数。
解决1024以下客户端时使用select是很合适的,但是如果客户端过多,select采用的是轮询模型,会大大降低服务器响应效率,不应在select上投入更多精力。
1.1 基本原理:
- select核心实现原理是位图,select总共有三种位图,分别为读,写,异常位图。用户程序预先将socket文件描述符注册至读,写,异常位图,然后通过select系统调用轮询位图中的socket的读,写,异常事件。
- 内核通过轮询方式获取读,写,异常位图中注册的socket文件事件,如果检测到有socket文件处于就绪状态,则会将socket对应的事件设置到输出位图,等所有位图中的socket都被轮询完,会统一将输出位图通过copy_to_user函数复制到输入位图,并且覆盖掉输入位图注册信息(也就是用户初始化的位图被内核修改)。
- select轮询完所有位图,如果未检测到任何socket文件处于就绪状态,根据超时时间确定是否返回或者阻塞进程。
- socket检测到读,写,异常事件后,会通过注册到socket等待队列的回调函数poll_wake将进程唤醒,唤醒的进程将再次轮询所有位图。
- select返回时会将剩余的超时时间通过copy_to_user覆盖原来的超时时间。
1.2 API:
#include<sys/select.h>
#include<sys/time.h>
#include<sys/type.h>
#include<unistd.h>
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
功能:监听多个文件描述符的属性变化(读、写、异常)
void FD_CLR(int fd, fd_set *set);
int FD_ISSET(int fd, fd_set *set);
void FD_SET(int fd, fd_set *set);
void FD_ZERO(fd_set *set);
参数:
nfds : 最大文件描述符+1
readfds : 需要监听的读的文件描述符存放的集合
writefds : 需要监听的写的文件描述符存放的集合 NULL
exceptfds : 需要监听的异常的文件描述符存放的集合 NULL
timeout : 多长时间监听一次 固定的事件,限时等待 NULL永久监听
struct timeval{
long tv_sec; // seconds
long tv_usec; // microseconds
};
返回值:返回的是变化的文件描述符的个数
注意:变化的文件描述符会存在监听的集合中,未变化的文件描述符会被删除
1.3 代码:
#include<stdio.h>
#include<string.h>
#include<unistd.h>
#include<stdlib.h>
#include<sys/time.h>
#include"wrap.h"
#include<sys/select.h>
#define PORT 8800
int main()
{
// 创建监听套接字、绑定
int lfd = tcp4bind(PORT, NULL);
// 监听
Listen(lfd, 128);
int maxfd = lfd;
fd_set oldset, rset;
FD_ZERO(&oldset);
FD_ZERO(&rset);
FD_SET(lfd, &oldset);
// 循环调用select,并处理发生变化的文件描述符
while(1)
{
rset = oldset;
int n = select(maxfd+1, &rset, NULL, NULL, NULL); // n为发生变化文件描述符的数量
if(n < 0)
{
perror("select error:");
break;
}
else if(n == 0) // 超时
{
continue;
}
else // 有文件描述符发生变化
{
// lfd变化
if(FD_ISSET(lfd, &rset))
{
struct sockaddr_in cliaddr;
socklen_t len = sizeof(cliaddr);
char ip[16] = "";
int cfd = Accept(lfd, (struct sockaddr*)&cliaddr, &len);
printf("new client ip = %s; port = %d\n", inet_ntop(AF_INET, &cliaddr.sin_addr.s_addr, ip ,16),
ntohs(cliaddr.sin_port));
// 将cfd添加至oldset集合中,以下次监听
FD_SET(cfd, &oldset);
// 更新maxfd
if(cfd > maxfd)
maxfd = cfd;
// 只有lfd变化,continue
if(--n == 0)
continue;
}
}
// cfd变化
for(int i = lfd + 1; i <= maxfd; i++)
{
// 如果i文件描述符在rset中
if(FD_ISSET(i, &rset))
{
char buf[1500]= "";
int ret = Read(i, buf, sizeof(buf));
if(ret < 0)
{
perror("read error:");
close(i);
FD_CLR(i, &oldset);
continue;
}
else if(ret == 0)
{
printf("client close.\n");
close(i);
FD_CLR(i, &oldset);
}
else
{
printf("%s\n", buf);
write(i, buf, ret);
}
}
}
}
return 0;
}
1.4 优缺点
优点:跨平台
缺点: 文件描述符1024的限制
只是返回变化的文件描述符的个数,具体哪个变化需要遍历
每次都需要将需要监听的文件描述符集合由应用层拷贝到 内核
效率低:
假设现在4-1023个文件描述符需要监听,但是5-1000这些文件描述符关闭了?
假设现在4-1023个文件描述符需要监听,但是只有5,1002发来消息。
2、poll实现
2.1 工作流程
- 用户空间程序调用poll函数,并传入了一个pollfd结构数组,以及数组的大小和超时时间等参数。
- 内核遍历该数组,检查每个文件描述符所对应的I/O事件是否发生
- 如果有文件描述符的I/O事件发生,就在相应的pollfd结构中设置相应的标志位。
- poll函数返回给用户空间,并通知哪些文件描述符已经就绪。
- 用户空间程序根据返回的结果进行相应的处理
2.1 API
#include<poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
功能:监听多个文件描述符的属性变化
参数:
fds: 监听的数组的首元素地址
nfds: 数组的有效元素的最大下标+1
timeout: 超时事件 -1为永久监听 >=0为限时等待
数组元素:
struct pollfd
{
int fd; // 需要监听的文件描述符
short events; // 需要监听文件描述符什么事件 POLLIN读事件、POLLOUT写事件
short revents; // 返回监听到的事件 同上
}
2.2 代码
#include<stdio.h>
#include<string.h>
#include<stdlib.h>
#include<unistd.h>
#include<netinet/in.h>
#include<arpa/inet.h>
#include<poll.h>
#include<errno.h>
#include<ctype.h>
#include"wrap.h"
#define MAXLINE 80
#define SERV_PORT 8000
#define OPEN_MAX 1024
int main()
{
int i, j, maxi, lfd, cfd, sockfd;
int nready;
ssize_t n;
char buf[MAXLINE], str[INET_ADDRSTRLEN];
socklen_t clilen;
struct pollfd client[OPEN_MAX]; // 定义poll数组
struct sockaddr_in cliaddr, servaddr;
lfd = Socket(AF_INET, SOCK_STREAM, 0);
int opt = 1;
setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); // 设置端口复用
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(SERV_PORT);
Bind(lfd, (struct sockaddr *)&servaddr, sizeof(servaddr));
Listen(lfd, 128);
client[0].fd = lfd; // 要监听的第一个文件描述符,存入client[0]
client[0].events = POLLIN; // lfd监听普通读事件
for(i = 1; i < OPEN_MAX; i++)
client[i].fd = -1; // 用-1初始化client[]里剩下元素 0也是文件描述符,不能用来初始化
maxi = 0;
while(1)
{
nready = poll(client, maxi+1, -1); // 阻塞监听是否有客户端链接请求
if(client[0].revents & POLLIN)
{
clilen = sizeof(cliaddr);
cfd = Accept(lfd, (struct sockaddr *)&cliaddr, &clilen); // 接收客户端请求
printf("received from %s at PORT %d\n",
inet_ntop(AF_INET, &cliaddr.sin_addr, str, sizeof(str)),
ntohs(cliaddr.sin_port));
for(i = 1; i<OPEN_MAX; i++)
if(client[i].fd < 0)
{
client[i].fd = cfd; // 找到client[]中空闲的位置,存放accept返回的cfd
break;
}
if(i == OPEN_MAX) // 达到最大客户端数
{
perror("too many clients");
continue;
}
client[i].events = POLLIN; // 设置刚刚返回的cfd,监控读事件
if(i > maxi)
maxi = i;
if(--nready <= 0)
continue;
}
for(i = 1; i<=maxi; i++)
{
if((sockfd = client[i].fd) < 0)
continue; // 找到第一个大于0的
if(client[i].revents & POLLIN)
{
if((n = Read(sockfd, buf, MAXLINE)) < 0)
{
// connection reset by client
if(errno == ECONNRESET) // 收到RST标志
{
printf("client[%d] aborted connection\n", i);
close(sockfd);
}
}
else if(n == 0)
{
printf("client[%d] closed connection\n", i);
close(sockfd);
client[i].fd = -1;
}
else
{
for(j = 0; j < n; j++)
buf[j] = toupper(buf[j]);
Write(sockfd, buf, n);
}
if(--nready <= 0)
break;
}
}
}
return 0;
}
3、epoll实现
3.1 API
3.1.1 epoll_create
int epoll_creat(int size);
功能:创建一个epoll对象
参数:size取大于0即可
返回值:成功返回epoll对象epfd。
小于0表示创建失败
3.1.2 epoll_ctl
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
功能:epoll_ctl函数用于增加、删除、修改epoll事件,epoll事件会存储于内核epoll结构体红黑树中。
参数:
epfd:epoll文件描述符
op:操作码
EPOLL_CTL_ADD: 插入事件
EPOLL_CTL_DEL: 删除事件
EPOLL_CTL_MOD: 修改事件
fd:epoll事件绑定的套接字文件描述符
event:epoll事件结构体。
返回值:
成功:返回0。
失败:返回-1,并设置errno。
struct epoll_event
{
uint32_t events; // epoll事件
epoll_data_t data;
};
typedef union epoll_data
{
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
}epoll_data_t;
epoll_ctl函数增加epoll事件时,系统默认注册EPOLLERR和EPOLLHUP事件
3.1.3 epoll_wait
epoll就绪事件处理示例:
1、注册epoll事件
struct epoll_event ev; ev.data.fd = sock_fd; ev.event = EPOLLIN; // 注册EPOLLIN事件 epoll_ctl(efd, EPOLL_CTL_ADD, sock_fd, &ev);
2、就绪epoll事件
res = EPOLLIN | EPOLLRDNORM;
3、epoll_wait获取事件
events = (EPOLLIN | EPOLLERR | EPOLLHUP) & (EPOLLIN | EPOLLRDNORM) = EPOLLIN;
注意:只有注册的事件才能通过epoll_wait获取
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
功能:epoll_wait用于监听套接字事件。
参数:
epfd: epoll文件描述符。
events: epoll事件数组。
maxevents:epoll事件数组长度
timeout:超时时间
小于0:一直等待。
等于0:立即返回。
大于0:等待超时时间返回,单位毫秒
返回值:
小于0:出错。
等于0:超时。
大于0:返回就绪事件个数。
3.2 代码
#include<stdio.h>
#include<string.h>
#include<unistd.h>
#include<stdlib.h>
#include<sys/epoll.h>
#include"wrap.h"
int main()
{
// 创建套接字并绑定
int lfd = tcp4bind(8000, NULL);
// 监听
Listen(lfd, 128);
// 创建树
int epfd = epoll_create(1);
// 将lfd上树
struct epoll_event ev, evs[1024];
ev.data.fd = lfd;
ev.events = EPOLLIN;
epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &ev);
// while监听套接字事件
while(1)
{
int nready = epoll_wait(epfd, evs, 1024, -1); // epoll_wait监听套接字事件, nready为就绪事件的数量,evs为epoll事件数组(用于接收)
if(nready < 0)
{
perror("");
break;
}
else if(nready == 0) // 超时
continue;
else // 有文件描述符变化
{
for(int i = 0; i < nready; i++)
{
// 判断lfd变化,并且是读事件变化
if(evs[i].data.fd == lfd && evs[i].events & EPOLLIN)
{
struct sockaddr_in cliaddr;
char ip[16] = "";
socklen_t len = sizeof(cliaddr);
int cfd = Accept(lfd, (struct sockaddr *)&cliaddr, &len); // 提取新的连接
printf("new client ip = %s port = %d\n", inet_ntop(AF_INET, &cliaddr.sin_addr.s_addr, ip, 16),
ntohs(cliaddr.sin_port));
// 将cfd上树
ev.data.fd = cfd;
ev.events = EPOLLIN;
epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &ev);
}
else if(evs[i].events & EPOLLIN) // cfd变化,而且是读事件变化
{
char buf[1024] = "";
int n = read(evs[i].data.fd, buf, sizeof(buf));
if(n < 0) // 出错,cfd下树
{
perror("");
epoll_ctl(epfd, EPOLL_CTL_DEL, evs[i].data.fd, &evs[i]);
}
else if(n == 0) // 客户端关闭
{
printf("client close\n");
close(evs[i].data.fd);//将cfd关闭
epoll_ctl(epfd, EPOLL_CTL_DEL, evs[i].data.fd, &evs[i]); // 下树
}
else
{
printf("%s\n", buf);
write(evs[i].data.fd, buf, n);
}
}
}
}
}
return 0;
}
3.3 epoll两种工作方式
1、监听读缓冲区的变化
水平(LT)触发:只要读缓冲区有数据就会触发epoll_wait。
边沿(ET)触发:数据来一次,epoll_wait只触发一次。
2、监听写缓冲区的变化
水平触发:只要可以写,就会触发。
边沿触发:数据从有到无,就会触发。
LT模式只不过比ET模式多执行了一个步骤,就是当epoll_wait获取完就绪队列epoll事件后,LT模式会再次将epoll事件节点再次添加到就绪队列。
默认设置都是水平触发,水平触发如果数据一次性都不干净,就需要多次系统调用,浪费资源,一般都会设置为边沿触发,改为边沿触发只需要将监听事件或上一个宏:
ev.events = EPOLLIN | EPLLET; // 监听读事件并设置为ET模式
设置完之后,则需要在读数据的时候一次性将数据读完,不然会出现读不完的情况。此时只需要将read那里加一个while循环。如果read读完了缓冲区的数据之后会阻塞,所以需要将其设置为非阻塞:
int flags = fcntl(cfd, F_GETFL); // 获取cfd的标志位
flags |= O_NONBLOCK;
fcntl(cfd, F_SETFL, flags); // 将cfd设置为非阻塞