一 IO基本概念
- IO 是什么?
IO的全称是input/ouput, 在计算机的语境中是用于描述计算与外界进行输入输出,例如敲击键盘输入,显示器输出打印信息, 客户端向服务端发送数据等都是IO。 - IO多路复用是什么
IO多路复用主要是通过单个进程或线程去监视多个文件描述符,复用是单个进程或线程,主要应用于服务端高并发的程序中。而所谓的多路一般是指多个tcp连接。如果连接的数量不多,也通过多线程的方式去维护每个tcp/udp连接 - 思考: 为什么会用出现多路复用的情况,这是为了提升系统的并发性和响应性,由于传统式的IO具有阻塞性。通过一个线程或进程来监视多个文件描述符,当其中某个文件描述符有可读或者可写事件时,系统会通知应用程序进行相应的处理,这种处理机制大大提升了系统的并发性。传统式的阻塞IO方式可能导致线程长时间被阻塞,而IO多路复用则可以通过非阻塞的方式进行IO操作
二 IO多路复用的几种方式
1. select
- 函数原型
int select (int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
- 参数
nfds: 需要检测的文件描述符数量
readfds: 检测可读事件文件描述符的集合, 通过调用FD_SET
添加可读事件,也可通过过FD_CLR
清除可读事件
writefds: 检测可写事件的文件描述符的集合
exceptfds: 检测异常事件的文件描述集合
timeout :0:无论是否有事件发生,函数调用完后立即返回,-1:阻塞等待,直到有事件发生或有错误发生 - 涉及的相关接口
// 这里的fd 实际使用都是以 句柄 传入
FD_ZERO(fd_set *fdset); // 将set清零使集合中不含任何fd
FD_SET(int fd, fd_set *fdset); // 将fd加入set集合
FD_CLR(int fd, fd_set *fdset); // 将fd从set集合中清除
FD_ISSET(int fd, fd_set *fdset); // 检测fd是否在set集合中,不在则返回0
- 功能: 用于监听可读、可写、异常套接字集合的事件,如果存在多个事件,则返回事件数量。
- 代码示例
#include <stdio.h>
#include <stdlib.h>
#include <sys/select.h>
#include <sys/time.h>
#include <unistd.h>
#include <netinet/in.h>
#include <string.h>
#include <poll.h>
#include <errno.h>
#include <sys/types.h>
#include <sys/socket.h>
#define SER_PORT 6000
#define BACKLOG 7
#define CLI_NUM 20
#define MAXLINE 2048
char buff[1024] = {0};
int main(int argc, char const *argv[])
{
int ser_fd;
struct sockaddr_in ser_addr, cli_addr;
int cli_addr_len;
int cli_fds[CLI_NUM] = {0};
ser_fd = socket(AF_INET, SOCK_STREAM, 0);
if (ser_fd == -1)
{
perror("socket failed\n");
return -1;
}
memset(&ser_addr, 0, sizeof(ser_addr));
ser_addr.sin_family = AF_INET;
ser_addr.sin_port = htons(SER_PORT);
ser_addr.sin_addr.s_addr = htonl(INADDR_ANY);
if (bind(ser_fd, (const struct sockaddr *)&ser_addr, sizeof(ser_addr)) < 0)
{
perror("bind error");
return -1;
}
if (listen(ser_fd, BACKLOG) < 0)
{
perror("listen error");
return -1;
}
#define FDS_MAX_NUM 1024
fd_set rset, tmp;
FD_ZERO(&rset);
FD_SET(ser_fd, &rset);
int maxfd = ser_fd;
int cli[FDS_MAX_NUM];
int i;
for(i = 0; i < FDS_MAX_NUM; i++){
cli[i] = -1;
}
struct timeval timeout;
timeout.tv_sec = 1;
timeout.tv_usec = 0;
while(1){
tmp = rset;
int nready = select(maxfd + 1, &tmp, NULL, NULL, &timeout);
if(FD_ISSET(ser_fd, &tmp)){
int cli_fd = accept(ser_fd, NULL, NULL);
if(cli_fd < 0){
continue;
}
printf("new connection \n");
for(i = 0; i < FDS_MAX_NUM; i++){
if(cli[i] == -1){
cli[i] = cli_fd;
FD_SET(cli_fd, &rset);
if(maxfd < cli_fd){
maxfd = cli_fd;
}
break;
}
}
if(--nready == 0){
continue;
}
}
for(i = 0; i < FDS_MAX_NUM; i++){
char buf[1024] = {0};
if(FD_ISSET(cli[i], &tmp)){
int nbytes = recv(cli[i], buf, 1024, 0);
if(nbytes == 0){
FD_CLR(cli[i], &rset);
close(cli[i]);
}else if(nbytes > 0){
printf("recv: %s\n", buf);
}
}
}
}
return 0;
}
- 优点:
- 相比较采用多线程的网络IO,可以减少性能消耗
- 可移植性比较好,大部分平台可以支持select 函数调用
- 可以进行多个IO的处理,比如同时有多个客户端请求,服务端采用select函数进行事件监听,一旦有事件,select 函数返回事件数量,可以用 数组的方式存储客户端套接字,在通过轮询数组的方式,去找到触犯事件的套接字
- 缺点:
- 与select 相关的函数比较多,select参数比较多,比较不好理解
- 每次都要从用户态拷贝事件集合到内核态, 比如代码示例的while循环中频繁地调用select函数,将事件集从用户态拷贝到内核态,比较消耗性能
- select监听的文件描述符的数量是有限的,一般来说是1024个,可以通过观察fd_set类型可知
#define __FD_SETSIZE 1024
typedef long int __fd_mask;
#define __NFDBITS (8 * (int) sizeof (__fd_mask))
typedef struct{
__fd_mask __fds_bits[__FD_SETSIZE / __NFDBITS];
} fd_set;
2. poll
- 函数原型
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
- 参数
struct pollfd fds
struct pollfd{
int fd; //文件描述符
short events; //等待的事件
short revents; //实际发生了的事件
}
nfds_t nfds: 需要检测的文件描述符数量
int timeout: 监听事件的事件,-1: 立即返回 , 0:
- 返回值
大于0:poll()返回的结构体revents 域不为0的文件描述符数量
0:如果在超时前没有任何事件发生, 返回0
-1:失败 - 代码示例
#include <stdio.h>
#include <stdlib.h>
#include <sys/select.h>
#include <sys/time.h>
#include <unistd.h>
#include <netinet/in.h>
#include <string.h>
#include <poll.h>
#include <errno.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/epoll.h>
#define SER_PORT 6000
#define BACKLOG 7
#define CLI_NUM 20
#define MAXLINE 2048
int main(int argc, char const *argv[])
{
int ser_fd;
struct sockaddr_in ser_addr, cli_addr;
int cli_addr_len;
int cli_fds[CLI_NUM] = {0};
ser_fd = socket(AF_INET, SOCK_STREAM, 0);
if (ser_fd == -1)
{
perror("socket failed\n");
return -1;
}
memset(&ser_addr, 0, sizeof(ser_addr));
ser_addr.sin_family = AF_INET;
ser_addr.sin_port = htons(SER_PORT);
ser_addr.sin_addr.s_addr = htonl(INADDR_ANY);
if (bind(ser_fd, (const struct sockaddr *)&ser_addr, sizeof(ser_addr)) < 0)
{
perror("bind error");
return -1;
}
if (listen(ser_fd, BACKLOG) < 0)
{
perror("listen error");
return -1;
}
#define FD_NUM_MAX 20
struct pollfd fds[FD_NUM_MAX] = {0};
int i = 0, maxfd = ser_fd;
for(i = 0; i < FD_NUM_MAX; i++){
fds[i].fd = -1;
}
fds[ser_fd].fd = ser_fd;
fds[ser_fd].events = POLLIN;
char buf[1024] = {0};
while(1){
int nready = poll(fds, FD_NUM_MAX, -1); //-1 阻塞等待, 0 ,非阻塞, 大于0, 等待时间
if(fds[ser_fd].revents & POLLIN){ //监听到新的连接
int clifd = accept(fds[ser_fd].fd, NULL, NULL);
if(clifd < 0){
continue;
}
fds[clifd].fd = clifd;
fds[clifd].events = POLLIN;
maxfd = clifd;
if(--nready == 0){
continue;
}
}
for(i = ser_fd + 1; i < maxfd; i++){
if(fds[i].fd >0 && fds[i].revents & POLLIN){
int nbytes = read(fds[i].fd, buf, 1024);
if(nbytes <= 0){ //说明连接关闭了
printf("cli %d: cli closed\n", fds[i].fd);
fds[i].fd = -1;
close(fds[i].fd);
continue;
}
printf("buf: %s\n", buf); //如果从客户端ctl + c 这一行将会一直输出buf
memset(buf, 0, 1024);
}
}
}
return 0;
}
- 优点:
- 接口使用比较简单,只有poll接口。可以使用struct pollfd 类型的变量将监听的套接字和事件类型进行封装一起即可。不需要关注可读可写的事件集合,只需在events上进行标注,通过revent 进行观察事件即可
- 监听的套接字数量没有被设定上限。
- 缺点:
- 与select 一样需要频繁的将监听的事件集合从用户态拷贝到内核态, 内核态在进行轮询遍历是否有对应的事件发生
3. epoll
- 函数原型
int epoll_create(int size)
功能: 创建一个epoll句柄,size 用来告诉内核监听的数量,实际开发中,默认填写1
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *evnet);
功能:epoll事件注册函数, 根据op 的不同,执行相应的操作,不如向epoll对象中添加修改或者删除对应的事件
参数:
epfd: epoll 句柄
op: EPOLL_CTL_ADD:注册新的fd 到epfd,
EPOLL_CTL_MOD: 修改已经注册的fd监听事件,
EPOLL_CTL_DEL:从epfd 中删除一个fd
fd: 需要监听的文件描述符
event: 告诉内核需要监听的事件
struct epoll_event{
__uint32_t events;
epoll_data_t data;
}
其中events可以用以下几个宏的集合
EPOLLIN: 表示对应的文件描述符可读(所谓的可读,是内核态的已经准备好数据)。
EPOLLOUT:表示对应的文件描述可写。
EPOLLET: 边沿触发,相较于EPOLLLT只会触发一次。触发的条件为内核缓冲区的数据从无到有,不论内核缓冲区的数据是否被应用层读完, 只触发一次。如果想要从内核缓冲区读取完数据,可以使用while(1){ int nsize = recv(fd, buffer, buffer_size, 0); if(!nsize) break;}
不断地从内核缓冲区读取数据。
EPOLLLT: 水平触发,如果内核态的数据的没有被读取,就会一直触发可读事件,为epoll 的默认触发方式。
返回值:0: 成功,-1:失败
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
功能:等待事件发生
参数:
epfd: epoll 的描述符
events: 分配好的epoll_event 结构体数组,epoll 将会把发生的事件复制到events数组中
maxevents: 表示events数组的大小
timeout: 表示在没有检测到事件发生时最多等待的时间。如果timeout为0,表示立即放回不用返回;如果为-1,表示阻塞等待,内核缓冲区有数据
- 代码示例
#include <stdio.h>
#include <stdlib.h>
#include <sys/select.h>
#include <sys/time.h>
#include <unistd.h>
#include <netinet/in.h>
#include <string.h>
#include <poll.h>
#include <errno.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/epoll.h>
#define SER_PORT 6000
#define BACKLOG 7
#define CLI_NUM 20
#define MAXLINE 2048
int main(int argc, char const *argv[])
{
int ser_fd;
struct sockaddr_in ser_addr, cli_addr;
int cli_addr_len;
int cli_fds[CLI_NUM] = {0};
ser_fd = socket(AF_INET, SOCK_STREAM, 0);
if (ser_fd == -1)
{
perror("socket failed\n");
return -1;
}
memset(&ser_addr, 0, sizeof(ser_addr));
ser_addr.sin_family = AF_INET;
ser_addr.sin_port = htons(SER_PORT);
ser_addr.sin_addr.s_addr = htonl(INADDR_ANY);
if (bind(ser_fd, (const struct sockaddr *)&ser_addr, sizeof(ser_addr)) < 0)
{
perror("bind error");
return -1;
}
if (listen(ser_fd, BACKLOG) < 0)
{
perror("listen error");
return -1;
}
int epfd = epoll_create(1);
if(epfd < 0){
return -1;
}
struct epoll_event ev, events[1024] = {0};
ev.data.fd = ser_fd;
ev.events = EPOLLIN;
epoll_ctl(epfd, EPOLL_CTL_ADD, ser_fd, &ev); //添加服务端的套接字在监听事件集合中
while(1){
int nready = epoll_wait(epfd, events, 1024, -1);
if(nready < 0){
continue;
}
int i = 0;
for(i = 0; i < nready; i++){
int connfd = events[i].data.fd;
if(connfd == ser_fd){
int clifd = accept(ser_fd, NULL, NULL);
if(clifd <= 0){
continue;
}
ev.events = EPOLLIN|EPOLLET; //添加边沿出触发(内核缓冲区的数据从无到有,不论内核缓冲区的数据是否被应用层读完, 只触发一次)
ev.data.fd = clifd;
epoll_ctl(epfd, EPOLL_CTL_ADD, clifd, &ev);
}else if(events[i].events & EPOLLIN){
char buff[1024] = {0};
int nbyte = recv(connfd, buff, 1024, 0);
if(nbyte == 0){
printf("close\n");
epoll_ctl(epfd, EPOLL_CTL_DEL, events[i].data.fd, NULL);
close(events[i].data.fd);
}else if(nbyte > 0){
printf("recv: %s\n", buff);
}
}
}
}
#endif
return 0;
}
- 优点
- 相较于select/poll ,不用频繁地将所有的监听事件集合从用户态拷贝到内核态,通过使用epoll_ctl()可以增加或减少要监听的fd
- 监听的套接字数量没有被设定上限
- IO的效率不随fd的数量的增加而线性下降,只会活跃的fd 进行操作
- 缺点
- 跨平台性不太好,只能在linux 下使用
- 调用逻辑相较于select还是有点复杂点