在学习网络编程的时候,是需要处理客户端的并发请求的。一般解决办法有1.多线程/多进程模型;2.多路复用模型。
多线程/多进程在这里不讨论,涉及到进程间通信的问题以及处理访问冲突的一些问题,之后会介绍。在这里主要记录一下多路复用模型。谈到多路复用模型,最简单的自然是select机制。但是我们也知道大型项目中从来不用select/poll,而是使用epoll。所以这篇文章将重点讲一下select机制、为什么select效率低、epoll。现在的网络框架或许还有其他的框架可以实现多路复用,但是我觉得select和epoll的原理对我们理解网络编程是由很大好处的。
为什么要用select机制
我们学的程序一般是“流水化”的程序,可以直接用流程图进行表示。select机制则打破了这样一种思维(使用了类似switch的思维),除了程序的思维上有所不同之外,就是解决阻塞问题。TCP进行三次握手连接,用户空间读数据read是从TCP缓存区读取、写数据write是写入TCP缓存区,而不是直接同网卡驱动进行交互的。当TCP缓存区内没有数据可读或者无法写入数据的话,就无法处理接收和发送这两个事件,调用进程就会阻塞,等待缓存区空闲之后继续执行。
程序阻塞比较好理解,类似我们执行system("pause")
和getchar
和scanf()
一样,程序会主动等待操作,这些都是驱动程序完成的。举个例子:如果我调用recv()函数等待接收数据,在我等待的这段时间由于程序会一直卡在recv()这里不会继续往后执行,所以我无法进行任何后续操作,这就是阻塞但是我们仔细想一想,我们上面提及的阻塞函数被"唤醒"是在键盘上有操作的时候就执行的;但是在网络程序中,我们如何真正实现唤醒?
等待队列
所以在这里我们先介绍一下等待队列,它是基于struct list_head
实现,包含一个头结点和若干个队列元素,结构体如下:
struct __wait_queue_head {
spinlock_t lock;
struct list_head task_list;
};
struct __wait_queue {
unsigned int flags;
void *private;
wait_queue_func_t func;
struct list_head task_list;
};
头结点包含一个指向第一个队列元素的指针和一个保护队列的自旋锁(spinlock_t lock),当插入新元素或删除旧元素对等待队列进行更新的时候,均使用自旋锁保证同步。这个锁机制不用过多了解,但是我们要知道这个锁只能锁一些轻量级的操作,他保护的资源连其他CPU都无法访问。等待队列的结构体中的private指针是指向进程的task_struct结构,这样在唤醒的时候就知道是哪个进程需要被唤醒;还有一个func函数,这个函数是用于回调,在唤醒该队列中的元素是调用;还有一个struct list_head task_list
结构体内结构体形成双向链表。
唤醒操作
st=>start: start
op=>operation: 进入睡眠判断
op1=>operation: 进入唤醒操作
op2=>operation: 定义一个新的wait_queue_t队列元素,并插入进等待队列中
op3=>operation: 设置进程状态
op4=>operation: 调用schedule(),让出CPU,调度其他进程
op5=>operation: 对队列中每一个元素,调用它的回调函数func
op6=>operation: 唤醒下一个进程
op7=>operation: 报错:无法找到回调函数
cond=>condition: 睡眠条件是否满足
cond2=>condition: 睡眠条件是否满足?
cond3=>condition: 回调函数返回非0
cond4=>condition: flag设置了WQ_FLAG_EXCLUSIVE标志
e=>end
st->op->cond
cond(no)->op1->cond2
cond(yes)->op2->op3->op4->op
cond2(yes)->op2
cond2(no)->cond3
cond3(yes)->cond4
cond3(no)->op7
cond4(yes)->e
cond4(no)->op6->op1
//睡眠函数
wait_event(wq, condition);
wait_event_timeout(wq, condition, timeout);
wait_event_interruptible(wq, condition);
wait_event_interruptible(wq, condition, timeout);
//唤醒函数
void wake_up(wait_event_head_t *queue);
void wake_up_interruptible(wait_event_head_t *queue);
flag参数会在prepare_to_wait函数中清除掉WQ_FLAG_EXCLUSIVE标志 。函数调用顺序如下:wait_event_interruptible -> DEFINE_WAIT -> prepare_to_wait -> schedule -> finish_wait
什么是select机制
select函数如下
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
其中nfds为待监听的套接字数量,一般为fd+1;此外还定义了一个timval的结构体用来进行超时设置,在指定时间内直接返回0;然后是fd_set 定义的三个结构(readfds,writefds,exceptfds),这里再介绍一下fd_set。
关于fd_set
#define __NFDBITS (8 * sizeof(unsigned long))
#define __FD_SETSIZE 1024
#define __FDSET_LONGS (__FD_SETSIZE/__NFDBITS)
typedef struct {
unsigned long fds_bits [__FDSET_LONGS];
} __kernel_fd_set;
void FD_SET(int fd, fd_set *set);
void FD_ZERO(fd_set *set);
void FD_CLR(int fd, fd_set *set);
int FD_ISSET(int fd, fd_set *set);
这是一个定义的结构体,在x86机器上,select最大支持1024个文件(__FD_SETSIZE
)。在fd_set中每个文件使用一个bit表示,因此共需要__FDSET_LONGS
个long型整数,放在一个数组里。
select使用
count=0
FD_ZERO(&res_rset)
for fd in read_set
if( readable(fd) )
count++
FDSET(fd, &res_rset)
//break
else
add_to_wait_queue
if count > 0
return count
else
wait_any_event
return count
我们来分析一下上面的伪代码,count赋值为0,然后利用FD_ZERO清零了res_rset;遍历所有套接字,在read_set里的套接字如果有可读的,就将count加1并将这个套接字取走,下一个fd若是仍然可读,继续执行上述步骤;如果不可读,加入等待队列,进入睡眠状态。但是这个算法中如何判断套接字可读和如何加入等待队列中?对于第二个问题是因为,如果我们使用的是wait_evet_interruptible的话,那么进程在第一个不可读套接字的时候就会进入睡眠状态,不再执行后续操作(监听其他文件)。因此,为了方便select/poll函数的实现,在Linux中规定每一个支持select/poll监听的文件所属设备驱动必须实现struct file_operations
中的poll函数。
poll函数
unsigned int (*poll) (struct file *filp, struct poll_table_struct *p);
poll函数一个是用来判断当前文件状态,并在返回值中进行标记:POLLIN为可读、POLLOUT为可写、POLLERR为出错、POLLHUP为文件尾;第二是用来对驱动程序的等待队列调用poll_wait函数。下面是poll_wait函数:
static inline void poll_wait(struct file * filp, wait_queue_head_t * wait_address, poll_table *p)
{
if (p && wait_address)
p->qproc(filp, wait_address, p);
}
通过poll函数已经可以确定文件(套接字的读写状态),在通过poll_wait函数中调用的poll_table的一个函数指针p->qproc,将进程加入到文件的等待队列中,具体的可以参照下列代码注释:
/* 1. poll_table存放在一个poll_wqueues结构体中 */
struct poll_wqueues {
poll_table pt;
struct poll_table_page *table;
struct task_struct *polling_task;/* 指向睡眠进程的task_struct */
int triggered;
int error;
int inline_index;
struct poll_table_entry inline_entries[N_INLINE_POLL_ENTRIES];
};
/* 2. 在do_select中调用poll_initwait初始化poll_wqueues */
void poll_initwait(struct poll_wqueues *pwq)
{
init_poll_funcptr(&pwq->pt, __pollwait); /* 初始化poll_table */
pwq->polling_task = current;
pwq->triggered = 0;
pwq->error = 0;
pwq->table = NULL;
pwq->inline_index = 0;
};
/* 3. p->qproc指向的就是__pollwait函数 */
static void __pollwait(struct file *filp, wait_queue_head_t *wait_address, poll_table *p)
{
struct poll_wqueues *pwq = container_of(p, struct poll_wqueues, pt);
struct poll_table_entry *entry = poll_get_entry(pwq);
if (!entry)
return;
get_file(filp);
entry->filp = filp;
entry->wait_address = wait_address;
entry->key = p->key;
init_waitqueue_func_entry(&entry->wait, pollwake);
entry->wait.private = pwq;
add_wait_queue(wait_address, &entry->wait);
}
select为管理所有监听的文件,为每个文件分配了一个poll_table_entry
结构。poll_table_entry
包含文件信息、等待队列头以及等待队列元素。代码中在_pollwait函数中还出现了pollwake函数
综上,select的整体流程是这样的:
1.拷贝用户空间的timeout对象到内核空间end_time,并重新设定时间值(标准化处理)
2.调用core_sys_select。过程如下:
2.1 根据传入的maxfd值,计算保存所有fd需要多少字节(每fd占1bit),然后判断是在栈上分配内存还是在堆中分配内存。共需要分配6个fdset:用户传入的in, out, exception以及要返回给用户的res_in,res_out和res_exception。
2.2 将3个输入fdset从用户空间拷贝到内核空间,并初始化输出的fdset为0;
2.3 调用do_select,获得返回值ret。do_select的工作就是初始化poll_wqueues对象,并调用驱动程序的poll函数。类似于我们写的简单的select。过程如下所示:
a.调用poll_initwait初始化poll_wqueues对象table,包括其成员poll_table;
b.如果用户传入的timeout不为NULL,但是设定的时间为0,那么设置poll_table指针wait(即 &table.pt)为NULL;
c.将in,out和exception进行或运算,得到all_bits,然后遍历all_bits中bit为1的fd,根据进程的fd_table查找到file指针filp,然后设置wait的key值(POLLEX_SET, POLLIN_SET,POLLIN_SET三者的或运算,取决于用户输入),并调用filp->poll(filp, wait),获得返回值mask。 再根据mask值检查该文件是否立即满足条件,如果满足,设置res_in/res_out/res_exception的值,执行retval++, 并设置wait为NULL。
d.在每遍历32(取决于long型整数的位数)个文件后,调用1次cond_resched(),主动寻求调度,可以等待已经遍历过的文件是否有唤醒的;
e.在遍历完所有文件之后,设置wait为NULL,并检查是否有满足条件的文件(retval值是否为0),或者是否超时,或者是否有未决信号,如果有那么直接跳出循环,进入步骤g;
f.否则调用poll_schedule_timeout,使进程进入睡眠,直到超时(如果未设置超时,那么是直接调用的schedule())。如果是超时后进程继续执行,那么设置pwq->triggered为0;如果是被文件对应的驱动程序唤醒的,那么pwq->triggered被设置为1.
g.最终,函数调用poll_freewait,将本进程从所有文件的等待队列中删掉,并删除分配的poll_table_page对象,回收内存,并返回retval值。
4.拷贝res_in, res_out和res_exception到传入的in, out, exception,并返回ret。
5.调用poll_select_copy_remaining,将剩余的timeout时间拷贝回用户空间。
为什么select效率较低
有三个理由:
- 可以同时监听的文件数量有限,最多1024个。这对要处理几十万并发请求的服务器来说,太少了!
- 每次调用select,都需要从0bit一直遍历到最大的fd,并且每隔32个fd还有调度一次(2次上下文切换)。而且在有多个fd的情况下,如果小的fd一直可读,那就会导致大的fd一直不会被监听。
- 内存复制开销。需要在用户空间和内核空间来回拷贝fd_set,并且要为每个fd分配一个poll_table_entry对象。
什么是epoll
epoll目前是在Linux下开发大规模并发网络程序的人们选择,这一篇博客中探讨了其他典型的模型(注入PPC、TPC、poll模型和select模型的缺点),其中select模型我们在上面已经介绍过。epoll的优点也是显而易见的,1.没有最大并发连接的限制,上限是最大可以打开文件的数目,这个数字一般远大于2048, 一般来说这个数目和系统内存关系很大,具体数目可以在cat /proc/sys/fs/file-max查到。2.效率提升,epoll最大的优点就在于它只管你“活跃”的连接,而跟连接总数无关,因此在实际的网络环境中,epoll的效率就会远远高于select和poll。 3.内存拷贝,epoll在这点上使用了“共享内存”,这个内存拷贝也省略了。
经人提醒,对fd更正一下:fd是文件描述符,在内核态与之对应的是struct file结构,可以看作是内核态的文件描述符。
在select机制中,当I/O时间发生时,select立即通知应用程序有时间到了需要去处理,应用程序则会轮询所有的FD(文件或者套接字)集合,测试每个FD是否有事件发生,并处理事件;select效率低也就低在这里,需要去轮询所有的FD。但是epoll不仅会告诉应用程序有I/O事件代劳,还会告诉应用程序一些相关信息,应用程序通过这些信息可以直接定位到事件。
epoll的设计和实现与select有很多不同的地方,epoll是在linux系统内核中申请了一个建议的文件系统,把原先的select/poll分成了三个部分:1.epoll_create()系统调用。此调用返回一个句柄,之后所有的使用都依靠这个句柄来标识。2.epoll_ctl()系统调用。通过此调用向epoll对象中添加、删除、修改感兴趣的事件,返回0标识成功,返回-1表示失败。3.epoll_wait()系统调用。通过此调用收集在epoll监控中已经发生的事件。
所以我们只需要在进程启动时建立一个epoll对象,然后在需要的时候向这个epoll对象添加或者删除连接。因为并没有遍历所有的fd,只用管添加的连接,所以效率比较高。
epoll机制实现思路
struct eventpoll{
....
/*红黑树的根节点,这颗树中存储着所有添加到epoll中的需要监控的事件*/
struct rb_root rbr;
/*双链表中则存放着将要通过epoll_wait返回给用户的满足条件的事件*/
struct list_head rdlist;
....
};
当某一进程调用epoll_create方法时,Linux内核会创建一个eventpoll结构体,这个结构体在上面的代码中,有两个成员rb_root与list_head对epoll的使用方式联系较大。每一个epoll独享都有一个独立的eventpoll结构体,用于存放通过epoll_ctl方法向epoll对象添加的事件,这些事件以红黑树的方式进行存放。(为什么是红黑树?因为红黑树高效,如果有重复的事件添加,可以非常高效的识别出来)。所有添加到epoll中的事件都会与设备(网卡)驱动程序建立回调关系,当相应的事件发生通过调用ep_poll_callback进行回调,将发生的事件添加到list_head定义的rdlist双链表中。
上一段中提到了"事件"这个概念,在epoll中,对于每一个事件,都会建立一个epitem结构体:
struct epitem{
struct rb_node rbn;//红黑树节点
struct list_head rdllink;//双向链表节点
struct epoll_filefd ffd; //事件句柄信息
struct eventpoll *ep; //指向其所属的eventpoll对象
struct epoll_event event; //期待发生的事件类型
}
当调用epoll_wait检查是否有事件发生时,我们只需要看eventpoll对象的relist双链表中,epitem结构体是否存在即可。如果rdlist不为空,则把发生的事件复制到用户态,同时将事件数量返回给用户。
下面列出关于epoll的相关操作:
epoll_create //创建一个epoll对象,一般epollfd = epoll_create()
epoll_ctl //(epoll_add/epoll_del的合体),往epoll对象中增加/删除某一个流的某一个事件
比如
epoll_ctl(epollfd, EPOLL_CTL_ADD, socket, EPOLLIN);//注册缓冲区非空事件,即有数据流入
epoll_ctl(epollfd, EPOLL_CTL_DEL, socket, EPOLLOUT);//注册缓冲区非满事件,即流可以被写入
epoll_wait(epollfd,...)//等待直到注册的事件发生
epoll程序的使用框架
通过在包含一个头文件#include <sys/epoll.h> 以及几个简单的API将可以大大的提高网络服务器的支持人数。
首先通过create_epoll(int maxfds)来创建一个epoll的句柄,其中maxfds为你epoll所支持的最大句柄数。这个函数会返回一个新的epoll句柄,之后的所有操作将通过这个句柄来进行操作。在用完之后,记得用close()来关闭这个创建出来的epoll句柄。
之后在网络主循环里面,每一帧的调用epoll_wait(int epfd, epoll_event events, int max events, int timeout)来查询所有的网络接口,看哪一个可以读,哪一个可以写了。基本的语法为: nfds = epoll_wait(kdpfd, events, maxevents, -1); 其中kdpfd为用epoll_create创建之后的句柄,events是一个epoll_event*的指针,当epoll_wait这个函数操作成功之后,epoll_events里面将储存所有的读写事件。max_events是当前需要监听的所有socket句柄数。最后一个timeout是 epoll_wait的超时,为0的时候表示马上返回,为-1的时候表示一直等下去,直到有事件范围,为任意正整数的时候表示等这么长的时间,如果一直没有事件,则范围。一般如果网络主循环是单独的线程的话,可以用-1来等,这样可以保证一些效率,如果是和主逻辑在同一个线程的话,则可以用0来保证主循环的效率。
epoll_wait范围之后应该是一个循环,遍历所有的事件。
//epoll框架
for( ; ; )
{
nfds = epoll_wait(epfd,events,20,500);
for(i=0;i<nfds;++i)
{
if(events[i].data.fd==listenfd) //有新的连接
{
connfd = accept(listenfd,(sockaddr *)&clientaddr, &clilen); //accept这个连接
ev.data.fd=connfd;
ev.events=EPOLLIN|EPOLLET;
epoll_ctl(epfd,EPOLL_CTL_ADD,connfd,&ev); //将新的fd添加到epoll的监听队列中
}
else if( events[i].events&EPOLLIN ) //接收到数据,读socket
{
n = read(sockfd, line, MAXLINE)) < 0 //读
ev.data.ptr = md; //md为自定义类型,添加数据
ev.events=EPOLLOUT|EPOLLET;
epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev);//修改标识符,等待下一个循环时发送数据,异步处理的精髓
}
else if(events[i].events&EPOLLOUT) //有数据待发送,写socket
{
struct myepoll_data* md = (myepoll_data*)events[i].data.ptr; //取数据
sockfd = md->fd;
send( sockfd, md->ptr, strlen((char*)md->ptr), 0 ); //发送数据
ev.data.fd=sockfd;
ev.events=EPOLLIN|EPOLLET;
epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev); //修改标识符,等待下一个循环时接收数据
}
else
{
//其他的处理
}
}
}