前言
这里介绍三种高并发服务器模型,分别是select 、poll和 epoll。重点为epoll,为接下来reactor做铺垫。
select
多路IO技术: select, 同时监听多个文件描述符, 将监控的操作交给内核去处理.
int select(int nfds, fd_set * readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
返回值:
成功返回发生变化的文件描述符的个数
失败返回-1, 并设置errno值.
函数介绍:委托内核监控该文件描述符对应的读,写或者错误事件的发生
数据类型fd_set::文件描述符集合——本质是位图
- nfds: 最大的文件描述符+1 readfds: 读集合, 是一个传入传出参数
传入: 指的是告诉内核哪些文件描述符需要监控
传出: 指的是内核告诉应用程序哪些文件描述符发生了变化
- writefds: 写文件描述符集合(传入传出参数)
- execptfds: 异常文件描述符集合(传入传出参数)
- timeout:
NULL--表示永久阻塞, 直到有事件发生
0 --表示不阻塞, 立刻返回, 不管是否有监控的事件发生
>0--到指定事件或者有事件发生了就返回
select-API
void FD_CLR(int fd, fd_set *set);
将fd从set集合中清除.
int FD_ISSET(int fd, fd_set *set);
判断fd是否在集合中
返回值: 如果fd在set集合中, 返回1, 否则返回0.
void FD_SET(int fd, fd_set *set);
将fd设置到set集合中.
void FD_ZERO(fd_set *set);
初始化set集合.
调用select函数其实就是委托内核帮我们去检测哪些文件描述符有可读数据
,可写
,错误发生
;
select的优缺点
select优点:
- 1 一个进程可以支持多个客户端
- 2 select支持跨平台
select缺点:
- 1 代码编写困难
- 2 会涉及到用户区到内核区的来回拷贝
- 3 当客户端多个连接, 但少数活跃的情况, select效率较低
例如:作为极端的一种情况, 3-1023文件描述符全部打开, 但是只有1023有发送数据, select就显得效率低下 - 4最大支持1024个客户端连接
select最大支持1024个客户端连接不是有文件描述符表最多可以支持1024个文件描述符限制的,而是由FD_SETSIZE=1024
限制的.
FD_SETSIZE=1024 fd_set使用了该宏, 当然可以修改内核, 然后再重新编译内核, 一般不建议这么做.
以下是select的实现代码:
#include <sys/types.h>
#include <sys/socket.h>
#include <stdlib.h>
#include <sys/socket.h>
#include <string.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <unistd.h>
#include <errno.h>
#include <stdio.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <signal.h>
#include <pthread.h>
int main()
{
char buf[1024]={0};
//socket()
int socketfd= socket(AF_INET,SOCK_STREAM,0);
if(socketfd<0){
return -1;
}
//bind()
struct sockaddr_in addr;
addr.sin_family=AF_INET;
addr.sin_port= htons(8002);
addr.sin_addr.s_addr=htonl(INADDR_ANY);
int bindret=bind(socketfd,(struct sockaddr *)&addr,sizeof(addr));
if(bindret<0){
return -2;
}
//listen()
int listenret=listen(socketfd,128);
if(listenret<0){
return -3;
}
//set select
fd_set rse,cprse;
FD_ZERO(&rse);
FD_ZERO(&cprse);
FD_SET(socketfd,&rse);
int max_fd=socketfd;
while(1){
cprse=rse;
int nretse=select(max_fd+1,&cprse,NULL,NULL,NULL);
if(nretse<0){
return -9;
}
if(FD_ISSET(socketfd,&cprse)){
int confd=accept(socketfd,NULL,NULL);
if(confd<0){
return -9;
}
printf("have new con\n");
FD_SET(confd,&rse);
if(confd>max_fd)max_fd=confd;
if(--nretse==0)continue;
}
for(int i=socketfd+1;i<=max_fd;i++){
if(FD_ISSET(i,&cprse)){
memset(buf,0,sizeof(buf));
int n=read(i,buf,sizeof(buf));
if(n<=0){
printf("have con break\n");
close(i);
FD_CLR(i,&rse);
}
else if(n>0){
printf("buf=[%s]\n",buf);
write(i,"OK\n",4);
}
if(--nretse==0)continue;
}
}
}
close(socketfd);
return 0;
}
将想要被监听的文件描述符先添加到集合,然后调用select进行轮询,也就是内部一个个查找,如果有文件描述符可读,可写,异常等,就会返回一个集合
,这个集合上有的文件描述符也就是发生了事件的文件描述符,传入传出参数的意思就是这样,传出来的时候和传进去的时候不一样,会在原有的传入的东西上面做修改
,这样就需要提前备份
好一份内容,不然传入再传出的时候就会发生改变。当有连接到来的时候,就是socketfd这个文件描述符有可读事件
发生,当某个客户端有数据发来的时候对应的文件描述符就有可读事件
发生。
poll
poll跟select类似, 监控多路IO, 但poll不能跨平台
.
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
- fds: 传入传出参数, 实际上是一个
结构体数组
。
struct pollfd
{
int fd; /* file descriptor */ 监控的文件描述符
short events; /* requested events */ 要监控的事件---不会被修改
short revents; /* returned events */ 返回发生变化的事件 ---由内核返回
};
fds.events:
POLLIN---->读事件
POLLOUT---->写事件
。。。。。。。
- nfds: 数组实际有效内容的个数
- timeout: 超时时间, 单位是毫秒.
-1: 永久阻塞, 直到监控的事件发生
0: 不管是否有事件发生, 立刻返回
>0: 直到监控的事件发生或者超时
返回值:
成功:返回就绪事件的个数
失败: 返回-1
若timeout=0, poll函数不阻塞,且没有事件发生, 此时返回-1, 并且errno=EAGAIN, 这种情况不应视为错误.
这个函数的第一个参数的结构体类型
struct pollfd
{
int fd; /* file descriptor */ 监控的文件描述符
short events; /* requested events */ 要监控的事件---不会被修改
short revents; /* returned events */ 返回发生变化的事件 ---由内核返回
};
说明:
1 当poll函数返回的时候, 结构体当中的fd和events没有发生变化, 究竟有没有事件发生由revents来判断, 所以poll是请求和返回分离.
2 struct pollfd结构体中的fd成员若赋值为-1
, 则poll不会监控.
3 相对于select, poll没有本质上的改变; 但是poll可以突破1024的限制
.
在/proc/sys/fs/file-max查看一个进程可以打开的socket描述符上限.
如果需要可以修改配置文件: /etc/security/limits.conf
加入如下配置信息, 然后重启终端即可生效.
* soft nofile 1024
* hard nofile 100000
soft和hard分别表示ulimit命令可以修改的最小限制和最大限制
以下是poll的代码实现:
int main()
{
char buf[1024]={0};
//socket()
int socketfd= socket(AF_INET,SOCK_STREAM,0);
if(socketfd<0){
return -1;
}
//bind()
struct sockaddr_in addr;
addr.sin_family=AF_INET;
addr.sin_port= htons(8002);
addr.sin_addr.s_addr=htonl(INADDR_ANY);
int bindret=bind(socketfd,(struct sockaddr *)&addr,sizeof(addr));
if(bindret<0){
return -2;
}
//listen()
int listenret=listen(socketfd,128);
if(listenret<0){
return -3;
}
//set poll
struct pollfd plfd[1024];
memset(&plfd,0,sizeof(plfd));
plfd[0].fd=socketfd;
plfd[0].events=POLLIN;
for(int i=1;i<1024;i++){
plfd[i].fd=-1;
}
int max_po=0;
while(1){
int nready=poll(plfd,max_po+1,-1);
if(nready<0){
return -9;
}
if(plfd[0].revents & POLLIN){
int confd=accept(socketfd,NULL,NULL);
if(confd<0){
return -9;
}
printf("have new con\n");
for(int i=1;i<=max_po+1;i++){
if(plfd[i].fd==-1){
plfd[i].fd=confd;
plfd[i].events=POLLIN;
if(i==max_po+1)max_po++;
break;
}
}
}
for(int i=1;i<=max_po;i++){
if(plfd[i].revents & POLLIN){
memset(buf,0,sizeof(buf));
int n=read(plfd[i].fd,buf,sizeof(buf));
if(n<=0){
printf("have con break\n");
close(plfd[i].fd);
plfd[i].fd=-1;
}
else if(n>0){
printf("buf=[%s]\n",buf);
write(plfd[i].fd,"OK\n",4);
}
}
}
}
close(socketfd);
return 0;
}
这里我没有清空结构体中revents的内容,因为每次返回回来之后有事件发生它就会有值,没有事件发生内核就会把这里相应的值清空
,打个比方:我这里没有对它进行处理如果还有残留的话就算没有新连接到来,socketfd还会显示有事件发生,这样accept()函数就会阻塞在那,但是没有阻塞,实验证明这个代码可以正常运行。
epoll
epoll是将检测文件描述符的变化委托给内核去处理, 然后内核将发生变化的文件描述符对应的事件返回给应用程序。这个的底层是红黑树加链表,后续文章会具体介绍epoll原理,简单理解就是把fd作为节点,把要监控的节点都挂在树上,返回有事件发生的节点。
epoll-API
int epoll_create(int size);
函数说明: 创建一个树根
参数说明:
- size: 最大节点数, 此参数在linux 2.6.8已被忽略, 但必须传递一个大于0的数.无论大于0的数填几都只是创建一个树根,不限制最大节点数。
返回值:
成功: 返回一个大于0的文件描述符, 代表整个树的树根.
失败: 返回-1, 并设置errno值.
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
函数说明: 将要监听的节点在epoll树上添加, 删除和修改
返回值:
成功返回 0
失败返回-1
- epfd: epoll树根
- op:
EPOLL_CTL_ADD: 添加事件节点到树上
EPOLL_CTL_DEL: 从树上删除事件节点
EPOLL_CTL_MOD: 修改树上对应的事件节点
- fd: 事件节点对应的文件描述符
- event: 要操作的事件节点
struct epoll_event {
uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
- event.events常用的有:
EPOLLIN: 读事件
EPOLLOUT: 写事件
EPOLLERR: 错误事件
EPOLLET: 边缘触发模式
- event.fd: 要监控的事件对应的文件描述符
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
函数说明:等待内核返回事件发生
参数说明:
- epfd: epoll树根
- events: 传出参数, 其实是一个事件结构体数组
- maxevents: 数组大小
- timeout:
-1: 表示永久阻塞
0: 立即返回
>0: 表示超时等待事件
返回值:
成功: 返回发生事件的个数
失败: 若timeout=0, 没有事件发生则返回; 返回-1, 设置errno值,
epoll_wait的events是一个传出参数, 调用epoll_ctl传递给内核什么值,当epoll_wait返回的时候, 内核就传回什么值,不会对struct event的结构体变量的值做任何修改。
epoll的两种工作模式
epoll的两种模式ET和LT模式
水平触发LT: 高电平代表1
只要缓冲区中有数据, 就一直通知
边缘触发ET: 电平有变化就代表1
缓冲区中有数据只会通知一次, 之后再有数据才会通知.(若是读数据的时候没有读完, 则剩余的数据不会再通知, 直到有新的数据到来)
epoll默认是水平触发LT
,在需要高性能的场景下,可以改成边缘ET非阻塞
方式来提高效率。
ET模式由于只通知一次, 所以在读的时候要循环读, 直到读完, 但是当读完之后read就会阻塞, 所以应该将该文件描述符设置为非阻塞模式(fcntl函数)。如果不循环读,读完的话还好,没读完的话数据就会留在读缓冲区
,影响数据的接收和判断。read函数在非阻塞模式下读的时候, 若返回-1, 且errno为EAGAIN, 则表示当前资源不可用, 也就是说缓冲区无数据(缓冲区的数据已经读完了); 或者当read返回的读到的数据长度小于
请求的数据长度时,就可以确定此时缓冲区中已没有数据可读了,也就可以认为此时读事件已处理完成。
以下是epoll的代码实现:
int main()
{
char buf[1024]={0};
//socket()
int socketfd= socket(AF_INET,SOCK_STREAM,0);
if(socketfd<0){
return -1;
}
//bind()
struct sockaddr_in addr;
addr.sin_family=AF_INET;
addr.sin_port= htons(8002);
addr.sin_addr.s_addr=htonl(INADDR_ANY);
int bindret=bind(socketfd,(struct sockaddr *)&addr,sizeof(addr));
if(bindret<0){
return -2;
}
//listen()
int listenret=listen(socketfd,128);
if(listenret<0){
return -3;
}
//set epoll
int epofd= epoll_create(1);
if(epofd<0){
return -1;
}
struct epoll_event node[1024];
struct epoll_event temp;
memset(&node,0,sizeof(node));
temp.events=EPOLLIN;
temp.data.fd=socketfd;
int retc= epoll_ctl(epofd,EPOLL_CTL_ADD,socketfd,&temp);
if(retc<0) {
return -1;
}
while(1){
int nready= epoll_wait(epofd,node,1024,-1);
if(nready<0){
return -9;
}
for(int i=0;i<nready;i++){
if(node[i].data.fd==socketfd){
int confd=accept(socketfd,NULL,NULL);
if(confd<0){
return -9;
}
printf("have new con\n");
temp.events=POLLIN;
temp.data.fd=confd;
epoll_ctl(epofd,EPOLL_CTL_ADD,confd,&temp);
}else {
memset(buf,0,sizeof(buf));
int n=read(node[i].data.fd,buf,sizeof(buf));
if(n<=0) {
printf("have con break\n");
close(node[i].data.fd);
epoll_ctl(epofd,EPOLL_CTL_DEL,node[i].data.fd,NULL);
}else {
printf("buf=[%s]\n",buf);
write(node[i].data.fd,"OK\n",4);
}
}
}
}
close(socketfd);
return 0;
}
epoll的优点:
1.性能高,百万并发不在话下,而select就不行
epoll的缺点:
1.不能跨平台,linux下的