0%

网络编程第六章

网络编程基础(6):多路复用技术

  • 1.select/poll/epoll三种方案的使用&区别
  • 2.I/O多路复用,结合多线程编程的技术手段

基本概念:

什么叫I/O多路复用?作用是什么?

所谓I/O多路复用,是指单一I/O为不同的客户服务,实现同一进程,同时监听多个文件描述符。

其作用是,不需要为每一个客户单独建立新的处理进程,节省大量的资源。

一:select

作用:在一段指定的时间内,监听用户感兴趣的文件描述符上的事件(包括:可读,可写,异常等)

具体使用中的API

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
 #include <sys/select.h>
int select(int nfds, fd_set* readfds, fd_set* writefds, fd_set* exceptfds, struct timeval* timeout);
/*
nfds:被监听的文件描述符总数, 通常是设置为最大的文件描述符加一,因为从0开始计数文件描述符
readfds: 可读文件描述符集合
writefds: 可写文件描述符集合
exceptfds: 异常事件文件描述符集合
timeout: 设置select函数超时时间,但是当调用失败时,timeout的值是不确定的
*/

/*
select调用成功时,返回就绪的文件总数。
超时则返回0
失败时返回-1
*/

/*如果select等待期间,程序收到信号,那么select立即返回-1,同时设置errno为EINTR*/

具体使用时的处理逻辑

select函数返回正整数说明,有描述符就绪。因为其内部机制是,当调用完成后,监听的位如果发生变化,那么依然置为1,未发生变化,置为0.所以对经过select监听的集合进行遍历,依然为1的部分就是就绪的描述符。

下面观察一个Linux下采用I/O复用的逻辑代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
int main(int argc, char *argv[])
{
//定义监听集合
fd_set reads;
//初始化fd_set变量都为0
FD_ZERO(&reads);
//监视文件描述符serv_sock
FD_SET(serv_sock, &reads);
fd_max = serv_sock;

while (1)
{
//每次调用select剩余位会被初始化为零,所以需要保存初始值
cpy_reads = reads;
//初始化超时
timeout.tv_sec = 5;
timeout.tv_usec = 5000;
//select
fd_num = select(fd_max + 1, &cpy_reads, 0, 0, &timeout);
for //查找状态变化的文件描述符
{
if (FD_ISSET(i, &cpy_reads)) //有变化
{
//服务器端套接字有变化
if (i == serv_sock) // connection request!
{
accept(serv_sock, (struct sockaddr *)&clnt_adr, &adr_sz);//受理客户端连接请求
FD_SET(clnt_sock, &reads); //注册客户端套接字文件描述符

}
else //接受字符串还是断开连接
{
if (str_len == 0) // close request!
{
FD_CLR(i, &reads); //clear
}
else // read message!
{
// echo!
}
}
}
}
}
close(serv_sock);
return 0;
}

分析其过程:

先定义监听集合,初始化集合,注册集合, 监听变化,判断变化类型, 处理变化内容。重新更新监听集合,继续监听…….

二:poll

核心API

1
2
3
4
5
6
7
8
9
10
11
12
13
struct pollfd{
int fd;
short events; /*注册的事件*/
short revents; /*实际发生的事件,由内核来填充*/
};

#include <poll.h>
int poll(struct pollfd* fds, nfds_t nfds, int timeout);

/*
分析上面的数据结构可以看到,pollfd类型中既包含了文件描述符,也指定了事件的类型
在具体的事件中,其用法和select几乎一致,也是对整个监听的集合进行遍历判断。
*/

三:epoll

是Linux中特有的I/O复用技术,采用一组函数来设计实现多路复用的功能。

具体实现方案

1
2
3
4
5
6
#include <sys/epoll.h>
int epoll_creat(int size); //创建epoll

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); //向epoll内核注册事件

int epoll_wait(int epfd, struct epoll_event* events, int maxevents, int timeout); //等待事件发生
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
//详细解释版

//epoll_creat函数介绍
#include <sys/epoll.h>

int epoll_creat(int size);
/*
成功时返回epoll文件描述符,失败时返回-1, 因为也是一种文件描述符,所以关闭时依然是调用close();
size并不起作用,只是一个参考,Linux内核会自动指定。
另->通常将该函数创建的epoll文件描述符称为“epoll例程”。
它是文件描述符保存空间!!!
*/

//epoll_ctl函数介绍
#include <sys/epoll.h>

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
/*
成功时返回0,失败时返回-1;
epfd: epoll例程的文件描述符
op: 指定监视对象的添加、删除、更改等操作
fd: 需要注册的监视对象文件描述符
event: 监视对象的事件类型
*/

//epoll_ctl函数使用实例
struct epoll_event event;
event.events= EPOLLIN;
event.data.fd= sockfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &event);
/**
上面代码代表向epoll例程epfd注册sockfd同时监视其是否有文数据需要读取的事件。
函数第二参数的固定样式:
EPOLL_CTL_ADD:将文件描述符注册到epoll例程
EPOLL_CTL_DEL:将文件描述符从epoll例程删除
EPOLL_CTL_MOD:更改文件描述符的关注事件

events的固定样式:
EPOLLIN: 需要读取数据的情况
EPOLLOUT: 输出缓存为空,可以立即发送数据的情况
EPOLLPRI: 收到OOB数据的情况
EPOLLRDHUP: 断开连接或半关闭的情况,常用于边缘触发方式
EPOLLERR: 发生错误的情况
EPOLLET: 以边缘触发的方式得到事件通知
EPOLLONESHOT:发生一次事件后,不再接受事件通知,需要向epoll_ctl函数第二个参数中重新传递EPOLL_CTL_MOD再次设置事件。
**/

//epoll_wait函数介绍
#include <sys/epoll.h>

int epoll_wait(int epfd, struct epoll_event *event, int maxevents, int timeout);
/*
成功时返回发生事件的文件描述符数,失败时返回-1.
epfd — epoll文件描述符
events — 表示被激活的事件,该结构体里面包含发生事件对应的文件描述符
maxevents — 表示第二个参数中可以保存的最大的事件数
timeout — 设置等待超时,单位是毫秒,设置成-1时,epoll_wait()会一直阻塞,直到有事件发生。
*/

struct epoll_event {
__uint32_t events; //在此指定描述符需要发生何种事件才会触发。
epoll_data_t data; //在此结构体下记录需要查看监视文件描述符
}

具体的实现案例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
#include<stdio.h>
#include<unistd.h>
#include<string.h>
#include<stdlib.h>
#include<sys/select.h>
#include<sys/socket.h>
#include<arpa/inet.h>
#include<sys/epoll.h>
#define BUF_SIZE 30


void error_handling(char* message);


int main(int argc,char* argv[])
{
    int serv_sock,clnt_sock;
    struct sockaddr_in serv_addr,clnt_addr;
    int clnt_addr_sz;
    int epoll_fd;
    struct epoll_event event;
    struct epoll_event* pevents;
/*
此处的两个变量各有用处
event用于注册文件描述符时,指定文件描述符、关注的事件
pevents用于保存发生事件的文件描述符集合的结构体地址值
*/
    int fd_num,i;
    int str_len;
    char buf[BUF_SIZE];
    if(argc!=2)
    {
        printf("Uasge %s <port>\n",argv[0]);
        exit(1);
    }


    //创建socket
    serv_sock=socket(AF_INET,SOCK_STREAM,0);
    //准备地址
    memset(&serv_addr,0,sizeof(serv_addr));
    serv_addr.sin_family=AF_INET;
    serv_addr.sin_addr.s_addr=htonl(INADDR_ANY);
    serv_addr.sin_port=htons(atoi(argv[1]));
    //绑定
    if(bind(serv_sock,(struct sockaddr*)&serv_addr,sizeof(serv_addr))==-1)
    {
        error_handling("bind error");
    }
    //监听
    if(listen(serv_sock,5)==-1)
    {
        error_handling("listen error");
    }
    //创建epoll描述符,以及用来接收发生变化事件的数组,空间占用50个字节
    epoll_fd=epoll_create(50);
    pevents=malloc(sizeof(struct epoll_event)*50);
    //利用epoll_ctl注册socket描述符
    event.events=EPOLLIN;
    event.data.fd=serv_sock;
    epoll_ctl(epoll_fd,EPOLL_CTL_ADD,serv_sock,&event);
    while(1)
    {
        //等待socket状态的变化,返回发生状态变化的文件描述符数
        fd_num=epoll_wait(epoll_fd,pevents,50,-1);
        puts("wait succeed");
        for(i=0;i<fd_num;i++)
        {
            if(serv_sock==pevents[i].data.fd)
            {
                clnt_addr_sz=sizeof(clnt_addr);
                clnt_sock=accept(serv_sock,(struct sockaddr*)&clnt_addr,&clnt_addr_sz);
                puts("accept succeed");
                //把客户端添加到监视事件中
                event.events=EPOLLIN;
                event.data.fd=clnt_sock;
                epoll_ctl(epoll_fd,EPOLL_CTL_ADD,clnt_sock,&event);
            }
            else
            {
                str_len=read(pevents[i].data.fd,buf,BUF_SIZE);
                if(str_len==0)
                {
                    epoll_ctl(epoll_fd,EPOLL_CTL_DEL,pevents[i].data.fd,NULL);
                    close(pevents[i].data.fd);
                }
                else
                {
                    printf("client:%s\n",buf);
                    write(pevents[i].data.fd,buf,str_len);
                }
            }
        }




    }
    close(epoll_fd);
    close(serv_sock);
    return 0;
}


void error_handling(char* message)
{
    fputs(message,stderr);
    fputc('\n',stderr);
    exit(1);
}

LT&&ET的区别

在epoll中,对文件描述符有两种操作方式:水平触发&边沿触发。

采用LT的描述符,epoll_wait检测到有事件发生,可以暂时不用处理,因为还会重复提醒,直到该事件被处理

采用ET的描述符,只会提醒一次,相对来说,效率会高很多。

注意:每一个使用ET模式的文件描述符都应该是非阻塞的,因为:如果文件描述符阻塞,那么读或者写操作会因为没有后续的事件而一直阻塞。

四:结合多线程编程聊EPOLLONESHOT

设想一个场景:

采用多线程(进程)编程时,某一个线程在读取完某个socket数据后开始处理这个数据,而在处理数据的时候该socket上又有了新的数据可读,此时另一个线程被唤醒处理读取这些数据。于是会出现,两个线程同时操作一个socket的情景。这个可能会造成额外的线程竞争,应该尽量避免。采取EPOLLONESHOT事件来避免这种情况。

当注册了EPOLLONESHOT事件,操作系统最多为其触发一次。之后再想触发则需要使用epoll_cnt函数来重置事件,这样可以避免多个线程同时处理一个套接字。同时,我们也要注意到在利用这个特性时,当某一线程处理完事件之后,应该立即重置,这样以后才可以在新事件发生时被处理。

五:三种I/O复用的对比

需要注意的一点:
因为回调相对而言是要花费更多资源的,当活动连接比较多的时候,epoll_wait的效率未必高很多。epoll适用于连接数量多,但活动连接较少的情况。