1 什么是IO多路复用
IO多路复用
(IO Multiplexing)一种同步IO模型,单个进程/线程可以同时处理多个IO请求。一个进程/线程可以监视多个文件描述符;一旦某个文件描述符就绪,就能够通知应用程序进行相应的读写操作;没有文件句柄就绪时会阻塞应用程序,交出cpu。多路是指网络连接,复用指的是同一个进程/线程。
一个进程/线程虽然任一时刻只能处理一个请求,但是处理每个请求的事件时,耗时控制在 1 毫秒以内,这样 1 秒内就可以处理上千个请求,把时间拉长来看,多个请求复用了一个进程/线程,这就是多路复用,这种思想很类似一个 CPU 并发多个进程,所以也叫做时分多路复用。
1.1 为什么会出现IO多路复用
在网络编程中,服务端监听端口时的花费很大,有两种模式:同步阻塞(BIO)和同步非阻塞(NIO)。
1.1.1 同步阻塞(BIO)
服务端采用单线程,当accept一个请求后,若在recv和send调用时阻塞,将无法accept其他客户端请求,必须等待当前请求处理完成,无法处理并发。
单线程阻塞
//单线程阻塞
while(true)
{
int client_fd = accept(listenfd, &client_addr, 0);
if(client_fd) {
fds.append(client_fd);
}
for (fd in fds) {
// recv阻塞(会影响上面的accept)
if (recv(fd)) {
// logic
}
}
}
多线程阻塞
服务器端采用多线程,当accept一个请求后,使用一个新的线程进行recv
,可以完成并发处理,但随着请求数增加需要增加系统线程,大量的线程占用很大的内存空间,并且线程切换会带来很大的开销,10000个线程真正发生读写事件的线程数不会超过20%,每次accept
后都开一个线程也是一种资源浪费
//多线程阻塞
while(true)
{
int client_fd = accept(listenfd, &client_addr, 0);
//开启线程recv数据
// recv阻塞,服务端可以再次进入accept流程
if(recv(fd)) {
//process
}
}
1.1.2 同步非阻塞
服务器端当accept
一个请求后,加入fds
集合,每次轮询一遍fds
集合recv
(非阻塞)数据,没有数据则立即返回错误,每次轮询所有fd
(包括没有发生读写事件的fd
)会很浪费cpu。
while(1) {
// accept非阻塞(cpu一直忙轮询)
client_fd = accept(listen_fd)
if (client_fd != null) {
fds.append(client_fd)
}
for (fd in fds) {
// recv非阻塞
setNonblocking(client_fd)
if (len = recv(fd) && len > 0) {
}
}
}
1.2 现在的做法IO多路复用
服务端采用单线程进行select/poll/epoll
等系统调用获取活动fd
列表,遍历有事件的fd进行accept/recv/send
, 使得单线程处理并发。
只在调用select、poll、epoll
时阻塞,accept/recv
不阻塞,这样既能避免线程一直处于cpu
忙的状态,还能保证及时获取到活动的文件描述符
while(1) {
// 通过内核获取有读写事件发生的fd,只要有一个则返回,无则阻塞
// 整个过程只在调用select、poll、epoll这些调用的时候才会阻塞,accept/recv是不会阻塞
for (fd in select(fds)) {
if (fd == listen_fd) {
client_fd = accept(listen_fd)
fds.append(client_fd)
} elseif (len = recv(fd) && len != -1) {
// logic
}
}
}
1.2.1 select
//select函数接口
#include <sys/select.h>
#include <sys/time.h>
#define FD_SETSIZE 1024
#define NFDBITS (8 * sizeof(unsigned long))
#define __FDSET_LONGS (FD_SETSIZE/NFDBITS)
// 数据结构 (bitmap)
typedef struct {
unsigned long fds_bits[__FDSET_LONGS];
} fd_set;
// API
int select(int max_fd, fd_set *readset, fd_set *writeset, fd_set *exceptset, struct timeval *timeout
);// 返回值就绪描述符的数目
FD_ZERO(int fd, fd_set* fds) // 清空集合
FD_SET(int fd, fd_set* fds) // 将给定的描述符加入集合
FD_ISSET(int fd, fd_set* fds) // 判断指定描述符是否在集合中
FD_CLR(int fd, fd_set* fds) // 将给定的描述符从文件中删除
//selec使用示例
int main() {
/*
* 这里进行一些初始化的设置,
* 包括socket建立,地址的设置等,
* int listenfd = sockeet(AF_INET, ...);
*/
fd_set read_fs, write_fs;
struct timeval timeout;
int max = listenfd ;
int nfds = 0; // 记录就绪的事件数,可以减少遍历的次数
FD_ZERO(&read_fs);
FD_ZERO(&write_fs);
FD_SET(listenfd, &read_fs);
while (1) {
// 阻塞获取,每次需要把fd从用户态拷贝到内核态
fd_set tmp1 = read_fs;
fd_set tmp2 = write_fs;
nfds = select(listenfd + 1, &tmp1, &tmp2, NULL, &timeout);
for (int i = 0; i <= max && nfds; ++i) {
if (listenfd == i) {
int client = accept(listenfd, NULL, NULL, NULL);
FD_SET(client, &read_fd);
max = clientfd>max? clientfd: max;
}
if (FD_ISSET(i, &tmp1) && i != listenfd) {
// 这里处理read事件
char buf[1024];
int len = recv(i,buf,sizeof(buf),0);
if(0 == len)
{
FD_CLR(i, &read_fs);
close(i);
}
else
{
printf("%s\n",buf);
}
}
if (FD_ISSET(i, &tmp2) && i != listenfd) {
// 这里处理write事件
}
--nfds;
}
}
缺点:
- 单个进程打开的
fd
有限,通过FD_SETIZE
设置,默认1024
,且文件描述符的值最大就只能达到这么大 - 每次调用select都需要memset
- select后,需要通过遍历的方式才能确定活动的文件描述符,开销太大。
- 活动的文件描述符数组需要从用户态传进内核态,也有不小的消耗。
1.2.1 select的并发
主线程select,子线程执行recv.
#include <sys/select.h>
#include <pthread.h>
typedef struct fdinfo
{
int fd;
int *maxfd;
fd_set* rdset;
};
pthread_mutex_t mutex;
void* acceptconn(void* args)
{
fdinfo* fdinfo = (fdinfo*)(args);
int cfd= accept(fdinfo->fd, NULL, NULL);
pthread_mutex_lock(&mutex);
FD_SET(cfd, fdinfo->rdset);
*fdinfo->max = cfd>*fdinfo->max? cfd: *fdinfo->max;
pthread_mutex_unlock(&mutex);
free(fdinfo);
return NULL;
}
void* recv(void* args)
{
fdinfo* fdinfo = (fdinfo*)(args);
char buf[1024];
int len = recv(fdinfo->fd, buf,sizeof(buf),0);
if(-1 == len)
{
free(info);
printf("error\n");
exit(1);
}
if(0 == len)
{
pthread_mutex_lock(&mutex);
FD_CLR(fdinfo->fd, fdinfo->rdset);
pthread_mutex_unlock(&mutex);
close(fdinfo->fd);
free(info);
return NULL;
}
else
{
printf("%s\n",buf);
int ret = send(info->fd, buf, strlen(buf)+1, 0);
}
free(info);
return NULL;
}
int main() {
/*
* 这里进行一些初始化的设置,
* 包括socket建立,地址的设置等,
* int listenfd = sockeet(AF_INET, ...);
*/
fd_set read_fs, write_fs;
struct timeval timeout;
int max = listenfd ;
int nfds = 0; // 记录就绪的事件数,可以减少遍历的次数
FD_ZERO(&read_fs);
FD_ZERO(&write_fs);
FD_SET(listenfd, &read_fs);
pthread_mutex_init(&mutex,NULL);
while (1) {
// 阻塞获取,每次需要把fd从用户态拷贝到内核态
pthread_mutex_lock(&mutex);
fd_set tmp1 = read_fs;
fd_set tmp2 = write_fs;
pthread_mutex_unlock(&mutex);
nfds = select(listenfd + 1, &tmp1, &tmp2, NULL, &timeout);
for (int i = 0; i <= max && nfds; ++i) {
if (listenfd == i) {
pthread_t tid;
fdinfo *info = (fdinfo*)mallocc(sizeof(fdinfo));
pthread_create(&tid, NULL, acceptConn, info);
pthread_attach(tid);
}
if (FD_ISSET(i, &tmp1) && i != listenfd) {
pthread_t tid;
fdinfo *info = (fdinfo*)mallocc(sizeof(fdinfo));
pthread_create(&tid, NULL, recv, info);
pthread_attach(tid);
}
if (FD_ISSET(i, &tmp2) && i != listenfd) {
// 这里处理write事件
}
--nfds;
}
//
pthread_destory_mutex(&mutex);
}
1.2.2 poll
poll和函数类似,只是没有数量的限制,相对于sselect有优势,但是无法跨平台,所以一般不会使用。
//poll函数接口
#include <poll.h>
// 数据结构
struct pollfd {
int fd; // 需要监视的文件描述符
short events; // 需要内核监视的事件
short revents; // 实际发生的事件
};
// API
int poll(struct pollfd fds[], nfds_t nfds, int timeout);
1.2.2.1 使用实例
//poll使用示例
#define MAX_POLLFD_LEN 4096
int main() {
/*
* 在这里进行一些初始化的操作,
* 比如初始化数据和socket等。
*/
pollfd fds[MAX_POLLFD_LEN];
memset(fds, 0, sizeof(fds));
for(int i =0 ;i<MAX_POLLFD_LEN; ++i)
{
fds[i].fd = -1;
}
fds[0].fd = listenfd;
fds[0].events = POLLRDNORM;
int max = 1; // 队列的实际长度
int timeout = 0;
int current_size = max;
while (1) {
// 阻塞获取,每次需要把fd从用户态拷贝到内核态
int nfds = poll(fds, max, timeout);
if (fds[0].revents & POLLRDNORM) {
int connfd = accept(listenfd, NULL);
//将新的描述符添加到读描述符集合中
for(int i=0;i<MAX_POLLFD_LEN;++i)
{
if(fds[i].fd == -1) {
fds[i]=connfd;break;
}
}
}
// 每次需要遍历所有fd,判断有无读写事件发生
for (int i = 1; i <= max && nfds; ++i) {
if (fds[i].revents & POLLRDNORM) {
int ret = read(fds[i].fd, buf, MAXLINE);
if (n == 0) {
close(fds[i].fd);
fds[i].fd = -1;
}
} else {
// 这里处理write事件
}
--nfds;
}
}
}
缺点:
单个进程打开的fd
有限1024个每次调用select都需要memsetselect
后,需要通过遍历的方式才能确定活动的文件描述符,开销太大。- 活动的文件描述符数组需要从用户态传进内核态,也有不小的消耗。
1.2.3 epoll
通过epoll创建的数据区是内核和用户态共享的,不再需要拷贝。
且epoll是用链表组织的,有活动的文件描述符时仍然是将对应的结构体的置位,但是节点会直接指向下个活动节点,就不再需要遍历判断活动的文件描述符。
//epoll函数接口
#include <sys/epoll.h>
// 数据结构
// 每一个epoll对象都有一个独立的eventpoll结构体,用于存放通过epoll_ctl方法向epoll对象中添加进来的事件
struct eventpoll {
/*红黑树的根节点,这颗树中存储着所有添加到epoll中的需要监控的事件*/
struct rb_root rbr;
/*双链表中则存放着将要通过epoll_wait返回给用户的满足条件的事件*/
struct list_head rdlist;
};
// API
int epoll_create(int size); // 内核中间加一个 ep 对象,把所有需要监听的 socket 都放到 ep 对象中
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); // 负责把 socket 增加、删除到内核红黑树
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);// 负责检测可读队列,没有可读 socket 则阻塞进程
//epoll使用示例
int main(int argc, char* argv[])
{
/*
* 在这里进行一些初始化的操作,
* 比如初始化数据和socket等。
*/
epoll_event c;
// 内核中创建ep对象
int epfd=epoll_create(256);
// 需要监听的socket放到ep中
epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd,&event);
while(1) {
// 阻塞获取
int nfds = epoll_wait(epfd, events, 20, 0);
for(i=0;i<nfds;++i) {
if(events[i].data.fd==listenfd) {
// 这里处理accept事件
int connfd = accept(listenfd);
epoll_ctl(epfd, EPOLL_CTL_ADD, connfd, &event);
} else if (events[i].events&EPOLLIN) {
// 这里处理read事件
read(sockfd, BUF, MAXLINE);
//读完后准备写
epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&event);
} else if(events[i].events&EPOLLOUT) {
// 这里处理write事件
write(sockfd, BUF, n);
//写完后准备读
epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&event);
}
}
}
return 0;
}
1.2.3.3 LT模式
采用read操作对一个IO没有读取完数据,则下次epoll时还会得到活动的文件描述符
1.2.3.4 ET模式
read后,即使没有读完,下次epoll时若没有新的数据流进入则不会提示是活动的文件描述符