你之所以问这样的问题。是因为你认为只有多线程分别接收connection才可以更快,就像过去的tomcat那样,同时开多个线程来响应。
web container的多线程模型
然而多线程其实并不是最好的一种解决方案,多线程首先不能创建的太多,创建多了消耗很大。比如线程之间的上下文切换成本是非常高的。
其实还有一种消耗更少,可以完美替代多线程模型的io模型,那就是操作系统底层的IO多路复用。在这种情况下,只需要一个线程来响应请求。然后进来的IO被以文件描述符的方式来新建。也就是由多个文件描述符的这种方式来替代多线程的方式。
IO多路复用(IO Multiplexing)
redis就是使用的这种方式,就是使用IO多路复用的方式。所以你的观念需要更新,那就是除了多线程,还有一种更屌的方式,那就是多路复用。
说到多路复用。其实多路复用也有好几种,select、poll、epoll、evport、kqueue。
下面主要介绍三种:
1、select。
2、poll。
3、epoll。
以上三种是在内核机制上对文件描述符(file descriptor)集合进行轮询的三种方式(过去是多线程,现在流行多文件描述符,Linux/Unix系统不是有句名言吗?一切皆文件,说就是文件描述符。)。本质上就是三个函数,下面分别介绍。
Select
这种方式就是有一个叫select的函数,如下:
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
读、写、异常fd_set
调用select函数后,便开始阻塞,也就是block了,一直到指定的文件描述符列表中有就绪的文件描述符(读取描述符、写入描述符、异常描述符)或超时,成功返回后,然后循环修改文件描述符列表,使其只包含准备就绪的文件描述符。理解select模型的关键在于理解fd_set,为说明方便,取fd_set长度为1字节,fd_set中的每一bit可以对应一个文件描述符fd。则1字节长的fd_set最大可以对应8个fd。
以下是官网的一个例子:
#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <wait.h>
#include <signal.h>
#include <errno.h>
#include <sys/select.h>
#include <sys/time.h>
#include <unistd.h>
#define MAXBUF 256
void child_process(void)
{
sleep(2);
char msg[MAXBUF];
struct sockaddr_in addr = {0};
int n, sockfd,num=1;
srandom(getpid());
/* Create socket and connect to server */
sockfd = socket(AF_INET, SOCK_STREAM, 0);
addr.sin_family = AF_INET;
addr.sin_port = htons(2000);
addr.sin_addr.s_addr = inet_addr("127.0.0.1");
connect(sockfd, (struct sockaddr*)&addr, sizeof(addr));
printf("child {%d} connected \n", getpid());
while(1){
int sl = (random() % 10 ) + 1;
num++;
sleep(sl);
sprintf (msg, "Test message %d from client %d", num, getpid());
n = write(sockfd, msg, strlen(msg)); /* Send message */
}
}
int main()
{
char buffer[MAXBUF];
int fds[5];
struct sockaddr_in addr;
struct sockaddr_in client;
int addrlen, n,i,max=0;;
int sockfd, commfd;
fd_set rset;
for(i=0;i<5;i++)
{
if(fork() == 0)
{
child_process();
exit(0);
}
}
sockfd = socket(AF_INET, SOCK_STREAM, 0);
memset(&addr, 0, sizeof (addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(2000);
addr.sin_addr.s_addr = INADDR_ANY;
bind(sockfd,(struct sockaddr*)&addr ,sizeof(addr));
listen (sockfd, 5);
for (i=0;i<5;i++)
{
memset(&client, 0, sizeof (client));
addrlen = sizeof(client);
fds[i] = accept(sockfd,(struct sockaddr*)&client, &addrlen);
if(fds[i] > max)
max = fds[i];
}
while(1){
FD_ZERO(&rset);
for (i = 0; i< 5; i++ ) {
FD_SET(fds[i],&rset);
}
puts("round again");
select(max+1, &rset, NULL, NULL, NULL);
for(i=0;i<5;i++) {
if (FD_ISSET(fds[i], &rset)){
memset(buffer,0,MAXBUF);
read(fds[i], buffer, MAXBUF);
puts(buffer);
}
}
}
return 0;
}
总之,select之后,就要进行循环就绪的文件描述符然后处理事情。
Poll
也是一种系统调用。和select不同的是poll函数的参数被包装了一下:
int poll (struct pollfd *fds, unsigned int nfds, int timeout);
改成直接传入一个结构pollfd数组,这个pollfd定义如下:
struct pollfd {
int fd;
short events;
short revents;
};
这样就是针对每个文件描述符,是包装成一个pollfd类型,然后把事件填充进去。成功返回后,然后循环检出revents字段。
把上面的例子改成基于poll的,如下:
for (i=0;i<5;i++)
{
memset(&client, 0, sizeof (client));
addrlen = sizeof(client);
pollfds[i].fd = accept(sockfd,(struct sockaddr*)&client, &addrlen);
pollfds[i].events = POLLIN;
}
sleep(1);
while(1){
puts("round again");
poll(pollfds, 5, 50000);
for(i=0;i<5;i++) {
if (pollfds[i].revents & POLLIN){
pollfds[i].revents = 0;
memset(buffer,0,MAXBUF);
read(pollfds[i].fd, buffer, MAXBUF);
puts(buffer);
}
}
}
就和select一样,我们需要循环检查每个pollfd,去看对应的文件描述符是否准备就绪。但是已经不需要在每次迭代中都去构建集合。
Poll vs Select
1、poll不再限制最大量。你可以从poll函数的参数中看到,select里边是有最大量限制的。
2、poll()对于大值文件描述符更有效。 想象一下,通过select()观察值为900的单个文件描述符 - 内核必须检查每个传入集的每个bit位,直到第900位。poll是数组来管理fd,而select是用bit位来存放。
3、select的文件描述符数量是有最大量限制的。fd_set的最大size是1024。每个fd是1bit,所以fd_set就是一个32个整数数组(32 *32bit = 1024 bits)。
struct fd_set{
long int fd_sets[32];
}
select的fd_set
4、select的文件描述符集合每次迭代都要重新构建,而poll是输入的时候就提前分类好了,所以在迭代的时候就不需要重新分拣和构建了。
5、select更加的通用,而poll在有些Unix系统上是不支持的。
Epoll
这个词是event poll的意思。它是select和poll的增强版。就是更屌的。使用select和poll的时候,每次调用函数后,就无法再添加socket了。而epoll可以动态的添加和删除socket,也就是文件描述符。
epoll把自己的任务分成了三步走:
上面的例子可以改成epoll来实现:
struct epoll_event events[5];
int epfd = epoll_create(10);
...
...
for (i=0;i<5;i++)
{
static struct epoll_event ev;
memset(&client, 0, sizeof (client));
addrlen = sizeof(client);
ev.data.fd = accept(sockfd,(struct sockaddr*)&client, &addrlen);
ev.events = EPOLLIN;
epoll_ctl(epfd, EPOLL_CTL_ADD, ev.data.fd, &ev);
}
while(1){
puts("round again");
nfds = epoll_wait(epfd, events, 5, 10000);
for(i=0;i<nfds;i++) {
memset(buffer,0,MAXBUF);
read(events[i].data.fd, buffer, MAXBUF);
puts(buffer);
}
}
epoll的文件描述符都怎么存的呢?它用的是红黑树来存储。
epoll的文件描述符是存储在红黑树中,每个节点的结构叫做epitem(此图采自网上)
Poll vs EPoll
Redis支持四种多路复用
好,上面简单介绍了select、poll、epoll。
现在我们来看看redis的内部的多路复用实现。
可以看到ae_开头的就是redis针对多路复用的四种实现。分别是
epoll、evport、kqueue、select。除了epoll和select,还有evport和kqueue。
evport:Solaris 10的新特性。
kqueue:最初是在FreeBSD 4.1中被引入,后来支持了NetBSD, OpenBSD, DragonflyBSD和 macOS。
由此我们可以推断redis的这四种实现是为了满足不同的操作系统而开发的。
关于IO多路复用
IO多路复用这个词听着并不能让你有什么感觉,如果你是第一次听的话,还是回归到英文:IO Multiplex。就是“多个IO使用起来”。和本文开头对应起来就是以前是多个线程搞事情,现在是一个线程下,多个IO搞事情。
关于文件描述符
In Unix and related computer operating systems, a file descriptor (FD, less frequently fildes) is an abstract indicator (handle) used to access a file or other input/output resource, such as a pipe or network socket. File descriptors form part of the POSIX application programming interface.
根据英文原文,file decriptor就是一个抽象。是一个什么的抽象呢?是对访问一个file或者其他input 或output 资源的抽象,比如pipe或者网络socket。也就是说是对socket的抽象,那socket就是一种IO,所以IO多路复用使用的就是文件描述符来搞。
总结
select更通用,但有fd_set的1024限制,并且返回的是所有的fd,需要你自己去检查哪些是就绪的。poll不再限制size,并且参数传入也是一个数组,而且迭代时不需要每次都去重新构建fd_set,但却无法动态的添加和删除socket。而epoll是最强大的,可以动态添加删除socket,而且返回的fd_set是已经就绪了的fd,无须你再循环检查,但epoll是针对Linux的,部分操作系统不支持。redis内部提供了四种多路复用实现,除了select和epoll外,还实现了基于evport和kqueue的reactor模型。redis是单线程,却如此快,主要是因为它是基于操作系统底层的IO多路复用来实现的响应模型,也就是基于文件描述符,这是一种比多线程模型性能更好的服务端响应实现。