背景
前段时间在学习Redis和Nginx的实现,有了一些收获,特别是对其事件处理模式有较深的印象。不由得想起来,我们的服务,底层是怎么运行起来的?使用的什么事件处理方式?还有什么比较经典或优秀的事件处理模式呢?于是就开始了漫长的学习和探索,不断将自己学到和了解到的东西补充在文章中,形成了这篇文章。
本文从简单的socket通信过程开始,到I/O复用,再到高效的事件处理模式,最后讲Redis和Nginx事件及相关模块的实现,以及经典的网络库事件处理机制的实现。中间会穿插着一些案例作补充,以及简单的小知识比如零拷贝。文章较长,可以找自己感兴趣的部分看。如果有不同看法和疑惑,可以来找我讨论。
楔子
在我的理解中,所谓服务,其实就是接收一些信息,然后对信息进行处理,最后再输出一些信息的服务器程序。接收的信息可以是外部的,例如键盘事件、鼠标事件、信号、HTTP请求、RPC请求等;也可以是内部的,例如计时器等,如果要总结一下,就是三类:I/O事件、信号、定时事件,这其中在网络服务中,最重要的就是I/O事件(socket连接事件),最常见的就是HTTP请求和RPC请求引发的事件。事件处理机制,就是指服务来什么事件,就根据事件触发什么样的处理过程并返回。
而我们当前的服务中,PRC服务中,Java通常使用Spring Boot框架+Thrift框架,Go则使用KiteX框架。另外,WEB服务器中,Java使用了Spring Boot内置的Tomcat服务器。于是问题就出现了:我们使用这两种RPC框架时,Java程序的逻辑中,服务端提供接口是从@RequestMapping或@ThriftService进入,然后接下来对不同的函数调用进行不同的处理操作,这没有问题。但是这些服务就是使用单线程中处理吗?还是说是使用多线程?如果是多线程的话,谁来创建多线程,谁来监听网络事件,谁来触发线程处理工作事件呢?
答案显而易见,是我们的框架在做这件事。
另外,服务是运行在硬件之上的,这些硬件包括CPU(运算器)、内存(存储器)、硬盘(存储器)、IO设备(输入输出设备),是有限且珍贵的资源。如何能够更有效地利用这些资源,以最大限度处理尽可能多的事件,是我们所要做到的。通常情况下,服务性能瓶颈出现的地方都不一,就像木桶效应,哪块组成部分是弱点都会影响整体性能。
当前,服务器的高性能通常指该服务器可以处理多少并发请求。 一台服务器可能同时要处理数万甚至数十万的并发请求,而这些请求都会建立一条连接。并且,除去I/O事件外,其实还有定时事件、信号等其它内容。我们使用多线程/多进程的方式处理这些事件,但具体又应该怎样去做呢?
Socket通信过程
首先来说一下socket通信过程。
socket通信在大多时候其实是TCP连接过程。通常使用几个Linux系统调用:
socket()函数
int socket(int domain, int type, int protocol);
socket函数可用于根据指定的地址族、数据类型和协议来分配一个套接口的描述字及其所用的资源的函数。socket函数对应于普通文件的打开操作。普通文件的打开操作返回一个文件描述字,而socket()用于创建一个socket描述符(socket descriptor),它唯一标识一个socket。这个socket描述字跟文件描述字一样,后续的操作都有用到它,把它作为参数,通过它来进行一些读写操作。
第一个参数domain:即地址域,又称为协议族(family);第二个参数type:指定socket类型;第三个参数protocol指定协议。
bind()函数
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
bind()函数把一个地址族中的特定地址赋给socket。例如对应AF_INET、AF_INET6就是把一个ipv4或ipv6地址和端口号组合赋给socket。
通常服务器在启动的时候都会绑定一个众所周知的地址(如ip地址+端口号),用于提供服务,客户就可以通过它来接连服务器;而客户端就不用指定,有系统自动分配一个端口号和自身的ip地址组合。通常服务器端在listen之前会调用bind();客户端不会调用,而是在connect()时由系统随机生成一个。
listen()函数
int listen(int sockfd, int backlog);
listen函数的第一个参数即为要监听的socket描述字,第二个参数为相应socket可以排队的最大连接个数。socket()函数创建的socket默认是一个主动类型的,listen函数将socket变为被动类型的,等待客户的连接请求。
作为一个服务器,在调用socket()、bind()之后就会调用listen()来监听这个socket,如果客户端这时调用connect()发出连接请求,服务器端就会接收到这个请求。
connect()函数
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
connect函数的第一个参数即为客户端的socket描述字,第二参数为服务器的socket地址,第三个参数为socket地址的长度。客户端通过调用connect函数来建立与TCP服务器的连接。
accept()函数
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
accept函数的第一个参数为服务器的socket描述字,第二个参数为指向struct sockaddr *的指针,用于返回客户端的协议地址,第三个参数为协议地址的长度。如果accpet成功,那么其返回值是由内核自动生成的一个全新的描述字,代表与返回客户的TCP连接。
TCP服务器端依次调用socket()、bind()、listen()之后,就会监听指定的socket地址了。TCP客户端依次调用socket()、connect()之后就向TCP服务器发送了一个连接请求。TCP服务器监听到这个请求之后,就会调用accept()函数去接收请求,这样连接就建立好了。之后就可以开始网络I/O操作了,即类同于普通文件的读写I/O操作。
read()、write()等函数
连接之后,就可以通过IO函数进行读写操作了,这个层次的函数有很多种:read()/write()、recv()/send()、readv()/writev()、recvmsg()/sendmsg()、recvfrom()/sendto(),可以自行查阅含义。
close()函数
在服务器与客户端建立连接之后,会进行一些读写操作,完成了读写操作就要关闭相应的socket描述字,好比操作完打开的文件要调用fclose关闭打开的文件。
简单案例
服务端
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<errno.h>
#include<sys/types.h>
#include<sys/socket.h>
#include<netinet/in.h>
#define MAXLINE 4096
int main(int argc, char** argv)
{
int listenfd, connfd;
struct sockaddr_in servaddr;
char buff[4096];
int n;
//申请listenfd
if( (listenfd = socket(AF_INET, SOCK_STREAM, 0)) == -1 ){
printf("create socket error: %s(errno: %d)\n",strerror(errno),errno);
exit(0);
}
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(6666);
//把listenfd绑定上述地址和端口
if( bind(listenfd, (struct sockaddr*)&servaddr, sizeof(servaddr)) == -1){
printf("bind socket error: %s(errno: %d)\n",strerror(errno),errno);
exit(0);
}
//开始监听,最多排队十个
if( listen(listenfd, 10) == -1){
printf("listen socket error: %s(errno: %d)\n",strerror(errno),errno);
exit(0);
}
printf("======waiting for client's request======\n");
while(1){
//有连接进入,连接并获取新连接的socketfd
if( (connfd = accept(listenfd, (struct sockaddr*)NULL, NULL)) == -1){
printf("accept socket error: %s(errno: %d)",strerror(errno),errno);
continue;
}
//从新连接中读取信息
n = recv(connfd, buff, MAXLINE, 0);
buff[n] = '\0';
printf("recv msg from client: %s\n", buff);
//关闭该连接
close(connfd);
}
//关闭监听fd
close(listenfd);
}
客户端
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<errno.h>
#include<sys/types.h>
#include<sys/socket.h>
#include<netinet/in.h>
#define MAXLINE 4096
int main(int argc, char** argv)
{
int sockfd, n;
char recvline[4096], sendline[4096];
struct sockaddr_in servaddr;
if( argc != 2){
printf("usage: ./client <ipaddress>\n");
exit(0);
}
//初始化socketfd
if( (sockfd = socket(AF_INET, SOCK_STREAM, 0)) < 0){
printf("create socket error: %s(errno: %d)\n", strerror(errno),errno);
exit(0);
}
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(6666);
if( inet_pton(AF_INET, argv[1], &servaddr.sin_addr) <= 0){
printf("inet_pton error for %s\n",argv[1]);
exit(0);
}
//连接服务端fd
if( connect(sockfd, (struct sockaddr*)&servaddr, sizeof(servaddr)) < 0){
printf("connect error: %s(errno: %d)\n",strerror(errno),errno);
exit(0);
}
printf("send msg to server: \n");
fgets(sendline, 4096, stdin);
//发送msg
if( send(sockfd, sendline, strlen(sendline), 0) < 0){
printf("send msg error: %s(errno: %d)\n", strerror(errno), errno);
exit(0);
}
//关闭连接
close(sockfd);
exit(0);
}
以上就是一个简单的案例,服务端暴露端口进行监听连接,客户端申请连接,两者通信。
同时可以看出,连接之后得到的数据是最原始的传输数据,需要进行后续处理。例如如果是HTTP连接,就可以开始HTTP报文的解析和处理了等等。
C10K问题
当前在互联网公司中,服务几乎都是网络服务(socket连接,基于TCP/UDP),而当前的服务器多数基于多进程/多线程模型(为了提升CPU利用率)。基于多线程/进程模型处理事件时,一个直观的方式是,对每一个事件都创建一个线程/进程来处理,结束了之后再关掉。但当同时处理成千上万,甚至成上百万的事件/请求时,即使硬件条件跟得上的情况,仍然无法提供正常服务。创建的进程线程多了,数据拷贝频繁(缓存I/O、内核将数据拷贝到用户进程空间、阻塞),进程/线程上下文切换消耗大,导致操作系统崩溃。这就是经典的C10K问题。
要解决这一问题,从网络编程技术角度看,主要思路有两个:
1.尽可能多得增加进程/线程,硬件资源不够就补充;
2.用同一进程/线程来同时处理若干连接。
很显然,后者更为可行。
几种I/O模式->I/O多路复用
那么,怎么让一个进程/线程处理很多连接呢?这里就要考虑使用的I/O模型。那么,都有哪些I/O模型呢?
BIO
从最基础的IO操作谈起,比如read和write,通常IO操作都是阻塞I/O的,也就是说当你调用read时,如果没有数据收到,那么线程或者进程就会被挂起,直到收到数据,write也一样。这样的缺点就是,一个进程/线程只能处理一个连接,并且当连接增多时,遇到问题:1.线程/进程是有开销的,对于内存的消耗很大;2.进程/线程的切换是有CPU开销的,大量时间花在上下文切换,那么真正操作CPU的时间就很少。
NIO
这时就要引入非阻塞IO的概念,这个通过fcntl(linux操作文件特性函数)来设置。这时,当调用read时,如果有数据收到,就会返回数据,如果没有数据收到,就立刻返回一个错误,这样就不会阻塞线程了。但是有一个问题:依然需要不断轮询来看fd是否有内容,然后来读取或写入,CPU可能还是大量时间花费在查看上。
IO多路复用
接着就需要引入IO多路复用的概念。多路复用是指使用一个线程来检查多个文件描述符(socket)的就绪状态,比如调用select和poll函数,传入多个文件描述符,如果有一个文件描述符就绪,则返回,否则阻塞直到超时。得到就绪状态后进行真正的操作可以在同一个线程里执行,也可以启动线程执行(比如使用线程池)。这样,只需要一个线程一直轮询查看,其它线程/进程就可以各自处理就绪的逻辑;或者一个线程对就绪状态的fd处理,处理完之后继续轮询。
多路复用本质上解决了一个线程一次只能处理单个连接的问题,使用多路复用器可以管理N个socket。
epoll
在多路复用中有一个epoll的技术(linux系统下),通过epoll_create、epoll_ctl、epoll_wait三个系统调用,可以更高效地监听就绪事件。
epoll原理很多地方都讲过,其实简单来说就是epoll_create时创建一个红黑树记录所有的事件,创建一个链表记录就绪事件;epoll_ctl的时候,把fd放到对应的红黑树上并且给内核中断处理程序注册一个回调函数,告诉内核,如果这个句柄的中断到了,就把它放到就绪链表里;执行epoll_wait时,返回准备就绪链表里的fd即可。
epoll的优点:1.支持数量没有限制(除了系统fd数量限制);2.就绪链表里只有就绪事件,不需要轮询所有事件;3.有ET和LT两种模式,ET模式高效,LT模式安全;4.select、poll、epoll都需要内核把fd消息通知给用户空间,而epoll会通过内核于用户空间mmap同一块内存实现,减少不必要的内存拷贝。
其实除了select、poll、epoll之外,还有一些其他的多路复用模型,比如macos的kqueue等,它们的实现各不相同,优缺点也各不相同。事实上,经典的高并发服务器和高性能NIO框架中的多路复用器,都是对上面说的几个多路复用器的包装,只是不同场景、不同操作系统会调用不同的模型。
AIO
还有补充一个异步I/O的概念。异步IO(Asynchronous IO,AIO)指的是用户空间的线程变成被动接收者,而内核空间成为主动调用者。在异步IO模型中,当用户线程收到通知时,数据已经被内核读取完毕并放在了用户缓冲区内,内核在IO完成后通知用户线程直接使用即可。很像Java中的回调模式,用户进程(或者线程)向内核空间注册了各种IO事件的回调函数,由内核去主动调用。
高效的事件处理模式
讲完上面的几种I/O模式之后,接下来说一下几种高效的事件处理模式。在我的理解中,当前常用的事件处理模式都是基于I/O多路复用的。同步I/O模型通常用于Reactor模型,异步I/O模型则用于实现Proactor模式。 其中这两种模型还有许多变种,但无论是基础模型还是变种,其实都是基于对epoll的封装/使用,建立一个或多个I/O复用的机制来做的,区别在于这些模型的架构是如何将I/O复用和多线程/多进程搭配使用。
Reactor模式
Reactor 模式要求主线程(I/O 处理单元) 只负责监听文件描述符上是否有事件发生,有的话就立即将该事件通知工作线程(逻辑单元)。除此之外,主线程不做任何其他实质性的工作。读写数据,接受新的连接,以及处理客户请求均在工作线程中完成。
使用epoll实现的 Reactor 模式的工作流程是:
- 主线程往 epoll 内核事件表中注册 socket 上的读就绪事件。
- 主线程调用 epoll_wait 等待 socket 上有数据可读。
- 当 socket 上有数据可读时, epoll_wait 通知主线程。主线程则将 socket 可读事件放入请求队列。
- 睡眠在请求队列上的某个工作线程被唤醒,它从 socket 读取数据,并处理客户请求,然后往 epoll 内核事件表中注册该 socket 上的写就绪事件。
- 主线程调用 epoll_wait 等待 socket 可写。
- 当 socket 可写时,epoll_wait 通知主线程。主线程将 socket 可写事件放入请求队列。
- 睡眠在请求队列上的某个工作进程被唤醒,它往 socket 上写入服务器处理客户请求的结果。
工作线程从请求队列中取出事件后,将根据事件类型来决定如何处理它:对于可读事件,执行读数据和处理请求的操作;对于可写事件,执行写数据的操作。因此,Reactor 模式中没必要区分所谓的 “读工作线程” 和 “写工作线程”。
Proactor模式
与 Reactor 模式不同,Proactor 模式将所有 I/O 操作都交给主线程和内核处理,工作线程仅仅负责业务逻辑。 使用异步 I/O 模型(以 aio_read 和 aio_write为例)实现的 Proactor 模式的工作流程是:
- 主线程调用 aio_read 函数向内核注册 socket 上的读完成事件,并告诉内核用户读缓冲区的位置,以及读操作完成时如何通知应用程序(这里以信号为例)
- 主线程继续处理其他逻辑。
- 当 socket 上的数据被读入用户缓冲区后,内核将向应用程序发送一个信号,以通知应用程序数据已经可用。
- 应用程序预先定义好的信号处理函数选择一个工作线程来处理客户请求。工作线程处理完客户请求之后,调用 aio_write 函数向内核注册 socket 上的写完成事件,并告诉内核用户写缓冲区的位置,以及写操作完成时如何通知应用程序(仍然以信号为例)
- 主线程继续处理其他逻辑。
- 当用户缓冲区的数据被写入 socket 之后,内核将向应用程序发送一个信号,以通知应用程序数据已经发送完毕。
- 应用程序预先定义好的信号处理函数选择一个工作线程来做善后处理,比如决定是否关闭 socket。
- 连接 socket 上的读写事件是通过 aio_read/aio_write 向内核注册的,因此内核将通过信号来向应用程序报告连接 socket 上的读写事件。所以,主线程中的 epoll_wait 调用仅能用来检测监听 socket 上的连接请求事件,而不能用来检测 socket 上的读写事件。
连接 socket 上的读写事件是通过 aio_read/aio_write 向内核注册的,因此内核将通过信号来向应用程序报告连接 socket 上的读写事件。所以,主线程中的 epoll_wait 调用仅能用来检测监听 socket 上的连接请求事件,而不能用来检测 socket 上的读写事件。
其实经典Reactor模型和Proactor模型的区别主要在于:读写事件谁来处理。前者是工作线程在处理,后者是内核在处理。通常情况下不使用proactor模式。
几个Reactor模式变种
这里介绍几种Reactor的变种模式,并且用一个餐厅的例子来解释。
单Reactor单线程
单Reactor单线程是指所有的I/O操作都在同一个NIO线程上完成,该线程会进行一个事件循环,以事件驱动和事件回调的方式实现业务逻辑,该线程职责:
- 接收TCP连接;
- 读取socket中的内容
- 处理读取的内容;
- 将内容写入socket。
注意,这也是事件处理模式主要需要负责的几件事。
在该模式中,Reactor线程既要负责多路分离套接字,accept新连接,又要分派请求到处理器链中。适用于处理器链中业务处理组件能快速完成的场景。
特点是:只有一个线程,事件顺序处理,优先级得不到保证。redis单线程时即用此种方式。
例子:一个餐厅里只有一个既是前台也是服务员的人,负责接待客人,也负责把客人点的菜下达给厨师,自己还作为厨师做菜。
单Reactor多线程
该模式中,acceptor用于监听客户端请求并建立连接,以及读取和发送请求。Reactor读取请求之后,不在Reactor线程中进行处理/计算,而使用线程池来进行,这样可以充分利用多核CPU。但这种情况下,当出现百万客户端并发连接、或者服务端需要对客户端的握手信息进行安全认证等场景下,单独一个Reactor线程可能会存在性能不足问题,因此出现了第三种模型。
特点是:处理很快,但连接和发送读取信息可能成为瓶颈。
例子:一个餐厅里只有一个既是前台也是服务员的人,负责接待客人,也负责把客人点的菜下达给厨师。
主从Reactor多线程
该种模式下,mainReactor负责监听客户端请求,专门处理新连接的建立,将建立好的连接注册到subReactor,subReactor 监听已经成功的连接,当有新的事件发生时,会先对socket进行读取,然后调用连接相对应的Handler进行处理,并且负责发送信息。此模式下,监听事件、读写事件的监听是分离的,处理也是分开的,但连接事件的监听和处理是在一个reactor下的。
特点:各司其职,尽可能利用了性能。Nginx、memcached、Netty都采用了这种模式。(具体细节还会有所区别)
例子:一个餐厅里一个前台进行接待客人,几个服务员负责客人点的菜下达给厨师,厨师负责做菜。
主从Reactor+线程池
该种模式下,mainReactor负责监听客户端请求并建立新连接,将建立好的连接注册到subReactor上,subReactor监听已经成功的连接,当有新事件发生时,会先对socket进行读取,然后将事件放入线程池中处理,线程池将事件处理完,注册写事件,然后subReactor负责发送信息。此模式下,监听事件、读写事件的监听是分离的,处理同样是分离的。
正文
好了,以上即事件处理模式的背景,业界比较好的事件处理机制都有哪些,这些又都是怎么实现的呢?本节会先讲一下Redis和Nginx的事件处理机制,这种服务器程序的事件中,除了网络事件之外,还会有定时事件等内容。另外,还找了几个经典的网络库,如Netty、Netpoll、libevent,看下他们是怎么实现网络事件的处理的。
Redis
Redis是一个使用c语言开发的高性能key-value数据库。Redis服务器是一个事件驱动程序,需要处理两类事件,文件事件和定时事件。
这里先抛出一个经典问题:
Redis为什么那么快?
原因主要有三点:
- redis是纯内存操作:数据存放在内存中,内存的响应时间大约是100纳秒,这是Redis每秒万亿级别访问的重要基础。
- 单线程避免了线程切换和竞态产生的消耗。并且,redis是内存密集型,因此CPU不是瓶颈,不那么需要多线程。
- 非阻塞I/O:Redis采用epoll作为I/O多路复用技术的实现,再加上Redis自身的事件处理模型将epoll中的连接,读写,关闭都转换为了时间,不在I/O上浪费过多的时间。并且,多路复用可以让单线程快速处理多种事件。
那么,Redis的事件都有哪些,并且是如何运转的呢?
文件事件
Redis服务器通过套接字与其它客户端(或服务器)进行连接,比如发送命令、传递信息、同步等等,而文件事件就是服务器对套接字操作的抽象。服务器通过监听并处理这些事件来完成一系列网络通信操作。
Redis基于Reactor模式开发了自己的网络事件处理器,称为文件事件处理器。使用I/O多路复用程序同时监听多个套接字,并根据套接字当前执行的任务为套接字关联不同的事件处理器。当被监听的套接字准备好执行应答accept、读取read、写入write、关闭close等操作时,与之相应的文件事件就会产生,文件事件处理器就会调用套接字之前关联好的事件处理器来处理这些事。文件处理器以单线程方式运行。
文件事件处理器的构成主要四个部分:套接字、I/O多路复用程序、文件事件分派器(dispatcher)、事件处理器。
I/O多路复用程序负责监听套接字,并向文件事件分派器传送那些产生了事件的套接字。尽管多个文件事件可能会并发出现,但I/O多路复用程序总是会将所有套接字放在一个队列里,则会有序、同步的方式给分派器,然后再依次处理。
Redis的I/O多路复用程序,将select、epoll、evport、kqueue进行了封装,在程序开始前会根据运行的操作系统以及已经存在的库进行选择。文件事件的处理器主要是连接应答处理器(封装accept)、命令请求处理器(封装read)、命令回复处理器(封装write),以此也可以看出来redis是Reactor模式。其中,命令请求处理器读到命令之后解析并执行。
Redis6.0之后加入了多线程,多线程的实现机制为:
流程简述如下:
1、主线程负责接收建立连接请求,获取 socket 放入全局等待读处理队列
2、主线程处理完读事件之后,通过 RR(Round Robin) 将这些连接分配给这些 IO 线程
3、主线程阻塞等待 IO 线程读取 socket 完毕
4、主线程通过单线程的方式执行请求命令,请求数据读取并解析完成,但并不执行
5、主线程阻塞等待 IO 线程将数据回写 socket 完毕
6、解除绑定,清空等待队列
该设计有如下特点:
1、IO 线程要么同时在读 socket,要么同时在写,不会同时读或写
2、IO 线程只负责读写 socket 解析命令,不负责命令处理
上面两个图可以看出,Redis的多线程其实增加的都是I/O线程,是为了更快进行I/O以及命令解析,但真正对请求命令的执行依然是单线程操作的。
时间事件
时间事件分为定时任务和周期性任务,定时任务是让程序在指定的时间之后执行一次;周期性任务是让程序每隔一段时间就执行一次。目前Redis只使用周期性事件。
实现方式是:服务器将所有的时间事件都放在一个无序链表里,每当时间事件执行器运行时,都遍历整个链表,查找所有已到达的时间事件,并调用相应事件处理器。
正常模式下,Redis服务器只使用serverCron一个时间事件,这种情况下几乎链表退化成一个指针在使用,无序链表完全够用。如果时间事件较多的话,也可以考虑时间轮或时间堆算法可以来优化时间事件总体结构。
serverCron:Redis的一些操作需要周期性事件来完成,比如更新服务器的各类统计信息、清理数据库中的过期键值对、关闭和清理连接失效的客户端、尝试进行AOF或RDB持久化操作、master对slave的定期同步、集群模式下定期同步和链接测试等,这些定期操作由redis.c/serverCron函数负责执行。
事件的调度和执行
因为服务器同时存在文件事件和时间事件两种事件类型,因此服务器必须对这两种事件进行调度,决定何时处理文件事件,何时处理时间事件,以及花多少时间来处理。注意,这里还是以单线程的模式下。
事件的调度和执行由ae.c/aeProcessEvents函数负责,这里就直接放伪代码来表示:
def aeProcessEvents():
# 获取到达时间离当前时间最接近的时间事件
time_event = aeSearchNearestTimer()
# 计算最接近的时间事件距离到达还有多少毫秒
remaind_ms = timer_event.when - unix_ts_now()
# 如果事件已到达,那么remaind_ms的值可能为负数,将它设定为0
if remaind_ms < 0:
remaind = 0
# 根据remaind_ms的值,创建timerval结构
timeval = create_timeval_with_ms(remaind_ms)
# 阻塞并等待文件事件产生,最大阻塞事件由传入的timeval决定
# 如果remaind_ms的值为0,那么aeApiPoll调用之后马上返回,不阻塞
aeApiPoll(timeval)
# 处理所有已产生的文件事件
processFileEvents()
# 处理所有已到达的时间事件
processTimeEvents()
执行思路其实是:先处理文件事件、再处理时间事件。但是有一个很巧妙的原则:若时间事件将要到达,则那么aeApiPoll调用之后马上返回,不阻塞,快速处理所有产生的文件事件(也可能没有),然后处理所有已到达的时间事件;否则时间事件还早,就一直阻塞着直到有文件事件到来、或者时间事件临近(已经计算过timeval)。
将aeProcessEvents函数置于一个循环里,加上初始化和清理函数,就构成了Redis服务器的主函数,用伪代码表示:
def main():
# 初始化服务器
init_server()
# 一直处理事件,直到服务器关闭为止
while server_is_not_shutdown():
aeProcessEvents()
# 服务器关闭,执行清理操作
clean_server()
以上即是Redis的事件处理机制,以及Redis6.0多线程模型的一点补充,可以看出,更多还是使用单线程,但是使用了多路复用,就可以非阻塞、复用地处理各种事件。
Nginx
Nginx是一个使用c语言开发的优秀的开源HTTP服务器,主要功能是HTTP服务器、反向代理、 负载均衡 、动静分离。它的实现有几个特点:模块化、事件驱动、异步、非阻塞、多进程单线程。
作为服务器的优点有几个:1.处理响应请求很快;2.高并发连接;3.低内存消耗;4.具有很高的可靠性;5.高扩展性;6.热部署;7.自由的 BSD 许可协议。
Nginx因为是多进程的,且事件处理机制较为复杂,因此篇幅会很多。在本章节,内容大致是
- 首先快速给Nginx的架构、配置文件结构、模块,给一个大致的轮廓;
- 然后讲述Nginx的多进程处理模型,讲解三类进程的职责、进程的交互。
- 最后描述一下Nginx的事件模块,事件类型、四个池、模块初始化流程、事件处理机制等,且给出一个HTTP请求的处理流程;
Nginx架构
Nginx配置文件结构
nginx.conf 中的配置信息,根据其逻辑上的意义,对它们进行了分类,也就是分成了多个作用域,或者称之为配置指令上下文。不同的作用域含有一个或者多个配置项。
当前 Nginx 支持的几个指令上下文:
- main: Nginx 在运行时与具体业务功能(比如http服务或者email服务代理)无关的一些参数,比如工作进程数,运行的身份等。
- http: 与提供 http 服务相关的一些配置参数。例如:是否使用 keepalive 啊,是否使用gzip进行压缩等。
- server: http 服务上支持若干虚拟主机。每个虚拟主机一个对应的 server 配置项,配置项里面包含该虚拟主机相关的配置。在提供 mail 服务的代理时,也可以建立若干 server,每个 server 通过监听的地址来区分。
- location: http 服务中,某些特定的URL对应的一系列配置项。
- mail: 实现 email 相关的 SMTP/IMAP/POP3 代理时,共享的一些配置项(因为可能实现多个代理,工作在多个监听地址上)。
指令上下文,可能有包含的情况出现。例如:通常 http 上下文和 mail 上下文一定是出现在 main 上下文里的。在一个上下文里,可能包含另外一种类型的上下文多次。例如:如果 http 服务,支持了多个虚拟主机,那么在 http 上下文里,就会出现多个 server 上下文。
示例:
#定义Nginx运行的用户和用户组
user www www;
#Nginx进程数,建议设置为等于CPU总核心数。
worker_processes auto;
#全局错误日志定义类型,多个等级可并存,[ debug | info | notice | warn | error | crit ],从左到右错误信息越来越少;此指令可以在全局、http、server、location块中配置)
error_log /var/log/nginx/error.log notice;
error_log /var/log/nginx/error.log info;
#Nginx进程文件
pid /var/run/nginx.pid;
#一个Nginx进程打开的最多文件描述符数目,理论值应该是最多打开文件数(系统的值ulimit -n)与nginx进程数相除,但是nginx分配请求并不均匀,所以建议与ulimit -n的值保持一致。
worker_rlimit_nofile 65535;
#工作模式与连接数上限
events
{
#参考事件模型,use [ kqueue | rtsig | epoll | /dev/poll | select | poll ]; epoll模型是Linux 2.6以上版本内核中的高性能网络I/O模型,如果跑在FreeBSD上面,就用kqueue模型。
use epoll;
#单个进程最大连接数(最大连接数=连接数*进程数)参照getconf PAGESIZE
worker_connections 4096;
}
#设定http服务器
http
{
include mime.types; #文件扩展名与文件类型映射表
default_type application/json; #默认文件类型
#charset utf-8; #默认编码
client_header_buffer_size 32k; #上传文件大小限制
large_client_header_buffers 4 64k; #设定请求缓
client_max_body_size 8m; #设定请求缓
sendfile on; #开启高效文件传输模式,sendfile指令指定nginx是否调用sendfile函数来输出文件,对于普通应用设为 on,如果用来进行下载等应用磁盘IO重负载应用,可设置为off,以平衡磁盘与网络I/O处理速度,降低系统的负载。注意:如果图片显示不正常把这个改成off。
#autoindex on; #开启目录列表访问,合适下载服务器,默认关闭。
#server_tokens off; #关闭服务器版本号显示。
tcp_nopush on; #防止网络阻塞
tcp_nodelay on; #防止网络阻塞
keepalive_timeout 65; #长连接超时时间,单位是秒
#FastCGI相关参数是为了改善网站的性能:减少资源占用,提高访问速度。下面参数看字面意思都能理解。
fastcgi_connect_timeout 300;
fastcgi_send_timeout 300;
fastcgi_read_timeout 300;
fastcgi_buffer_size 64k;
fastcgi_buffers 4 64k;
fastcgi_busy_buffers_size 128k;
fastcgi_temp_file_write_size 128k;
#gzip模块设置
gzip on; #开启gzip压缩输出
gzip_min_length 1k; #最小压缩文件大小
gzip_buffers 4 16k; #压缩缓冲区
gzip_http_version 1.0; #压缩版本(默认1.1,前端如果是squid2.5类似应用请使用1.0)
gzip_comp_level 2; #压缩等级
gzip_types text/plain application/x-javascript text/css application/xml;
#压缩类型,默认就已经包含text/html,所以下面就不用再写了,写上去也不会有问题,但是会有一个warn。
gzip_vary on;
#limit_zone crawler $binary_remote_addr 10m; #开启限制IP连接数的时候需要使用
upstream test.com {
#upstream的负载均衡,weight是权重,可以根据机器配置定义权重。weigth参数表示权值,权值越高被分配到的几率越大。
server 192.168.80.121:80 weight=3;
server 192.168.80.122:80 weight=2;
server 192.168.80.123:80 weight=3;
}
#虚拟主机的配置
server
{
#监听端口
listen 80;
#域名可以有多个,用空格隔开
server_name localhost;
index index.html index.htm index.php;
root /html;
location ~ .*.(php|php5)?$
{
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
include fastcgi.conf;
}
#图片缓存时间设置
location ~ .*.(gif|jpg|jpeg|png|bmp|swf)$
{
expires 10d;
}
#JS和CSS缓存时间设置
location ~ .*.(js|css)?$
{
expires 1h;
}
#日志格式设定
log_format access '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" $http_x_forwarded_for';
#定义本虚拟主机的访问日志
access_log /var/log/nginx/ha97access.log access;
#对 "/" 启用反向代理
location / {
proxy_pass http://127.0.0.1:88;
proxy_redirect off;
proxy_set_header X-Real-IP $remote_addr;
#后端的Web服务器可以通过X-Forwarded-For获取用户真实IP
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
#以下是一些反向代理的配置,可选。
proxy_set_header Host $host;
client_max_body_size 10m; #允许客户端请求的最大单文件字节数
client_body_buffer_size 128k; #缓冲区代理缓冲用户端请求的最大字节数,
proxy_connect_timeout 90; #nginx跟后端服务器连接超时时间(代理连接超时)
proxy_send_timeout 90; #后端服务器数据回传时间(代理发送超时)
proxy_read_timeout 90; #连接成功后,后端服务器响应时间(代理接收超时)
proxy_buffer_size 4k; #设置代理服务器(nginx)保存用户头信息的缓冲区大小
proxy_buffers 4 32k; #proxy_buffers缓冲区,网页平均在32k以下的设置
proxy_busy_buffers_size 64k; #高负荷下缓冲大小(proxy_buffers*2)
proxy_temp_file_write_size 64k;
#设定缓存文件夹大小,大于这个值,将从upstream服务器传
}
#设定查看Nginx状态的地址
location /NginxStatus {
stub_status on;
access_log on;
auth_basic "NginxStatus";
auth_basic_user_file conf/htpasswd;
#htpasswd文件的内容可以用apache提供的htpasswd工具来产生。
}
#本地动静分离反向代理配置
#所有jsp的页面均交由tomcat或resin处理
location ~ .(jsp|jspx|do)?$ {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://127.0.0.1:8080;
}
#所有静态文件由nginx直接读取不经过tomcat或resin
location ~ .*.(htm|html|gif|jpg|jpeg|png|bmp|swf|ioc|rar|zip|txt|flv|mid|doc|ppt|pdf|xls|mp3|wma)$
{
expires 15d;
}
location ~ .*.(js|css)?$
{
expires 1h;
}
#nginx禁止访问所有.开头的隐藏文件设置
location ~* /.* {
deny all;
}
#定义错误提示页面
error_page 500 502 503 504 /50x.html;
location = /50x.html {
}
}
}
从配置文件可以看出,Nginx的功能非常多。
Nginx模块
Nginx 的模块从结构上分为核心模块、基础模块和第三方模块:
- 核心模块:HTTP模块、EVENT模块和MAIL模块
- 基础模块:HTTP Access模块、HTTP FastCGI模块、HTTP Proxy模块和HTTP Rewrite模块,
- 第三方模块:HTTP Upstream Request Hash模块、Notice模块和HTTP Access Key模块。
Nginx 的模块从功能上分为如下四类:
- Core(核心模块):构建nginx基础服务、管理其他模块。
- Handlers(处理器模块):此类模块直接处理请求,并进行输出内容和修改headers信息等操作。
- Filters (过滤器模块):此类模块主要对其他处理器模块输出的内容进行修改操作,最后由Nginx输出。
- Proxies (代理类模块):此类模块是Nginx的HTTP Upstream之类的模块,这些模块主要与后端一些服务比如FastCGI等进行交互,实现服务代理和负载均衡等功能。
Nginx 的核心模块主要负责建立 nginx 服务模型、管理网络层和应用层协议、以及启动针对特定应用的一系列候选模块。其他模块负责分配给web服务器的实际工作:
- 当Nginx发送文件或者转发请求到其他服务器,由Handlers(处理模块)或Proxies(代理类模块)提供服务;
- 当需要Nginx把输出压缩或者在服务端加一些东西,由Filters(过滤模块)提供服务。
Nginx模块处理流程
Nginx的多线程处理模型
服务器模块的大致结构
Nginx服务器的结构大致分为主进程、工作进程、后端服务器和缓存等部分。(多线程模式下)
三类进程
Nginx服务器有三大类进程:1.主进程;2.工作进程;3.缓存索引重建及管理进程。我们主要注意的是主进程和工作进程。而工作进程是主进程生成的,且会进行交互。接下来是三种进程的介绍:
主进程
Nginx服务器启动时运行的主要进程。它的主要功能是与外界通信和对内部其他进程进行管理,具体来说有以下几点:
- 读取Nginx配置文件并验证其有效性和正确性。
- 建立、绑定和关闭Socket。
- 按照配置生成、管理和结束工作进程。
- 接收外界指令,比如重启、升级及退出服务器等指令。
- 不中断服务,实现平滑重启,应用新配置。
- 不中断服务,实现平滑升级,升级失败进行回滚处理。
- 开启日志文件,获取文件描述符。
- 编译和处理Perl脚本。
- 循环处理信号。
工作进程
由主进程生成,生成数量可以通过Nginx配置文件指定,正常情况下生存于主进程的整个生命周期。ngx_process_t结构体(pid、status、channel、proc等)存放工作进程信息。该进程的主要工作有以下几项:
- 接收客户端请求。
- 将请求依次送入各个功能模块进行过滤处理。
- IO调用,获取响应数据。
- 与后端服务器通信,接收后端服务器处理结果。
- 数据缓存,访问缓存索引、查询和调用缓存数据。
- 发送请求结果,响应客户端请求。
- 接收主程序指令,比如重启、升级和退出等指令。
工作进程完成的工作还有很多,以上是主要的几项。可以看出,该进程是Nginx服务器提供Web服务、处理客户端请求的主要进程,完成了Nginx服务器的主体工作。因此,在实际使用中,应该重点监视工作进程的运行状态,保证Nginx服务器对外提供稳定的Web服务。
这里注意,Nginx 真正处理请求业务的是Worker之下的线程。
worker进程有一个ngx_cycle_s结构体,保存着很多例如配置文件、日志、内存池、连接池、事件池、监听池、进程同步文件、主机名等等内容。
worker进程中有一个ngx_worker_process_cycle()函数,执行无限循环,不断处理收到的来自客户端的请求,并进行处理,直到整个Nginx服务被停止。
worker 进程中,ngx_worker_process_cycle()函数就是这个无限循环的处理函数。在这个函数中,一个请求的简单处理流程如下:
- 操作系统提供的机制(例如 epoll, kqueue 等)产生相关的事件。(这里注意,每个worker进程有一个多路复用器)
- 接收和处理这些事件,如果接收到数据,则产生更高层的 request 对象。
- 处理 request 的 header 和 body。
- 产生响应,并发送回客户端。
- 完成 request 的处理。
- 重新初始化定时器及其他事件。
缓存索引重建及管理进程
上图中的Cache模块,主要由缓存索引重建(Cache Loader)和缓存索引管理(Cache Manager)两类进程完成工作。缓存索引重建进程是在Nginx服务启动一段时间之后(默认是1分钟)由主进程生成,在缓存元数据重建完成后就自动退出;缓存索引管理进程一般存在于主进程的整个生命周期,负责对缓存索引进行管理。
缓存索引重建进程完成的主要工作是,根据本地磁盘上的缓存文件在内存中建立索引元数据库。该进程启动后,对本地磁盘上存放缓存文件的目录结构进行扫描,检查内存中已有的缓存元数据是否正确,并更新索引元数据库。
缓存索引管理进程主要负责在索引元数据更新完成后,对元数据是否过期做出判断。
这两个进程维护的内存索引元数据库,为工作进程对缓存数据的快速查询提供了便利。
进程交互
Nginx服务器在使用Master-Worker模型时,会涉及主进程与工作进程(Master-Worker)之间的交互和工作进程(Worker-Worker)之间的交互。这两类交互都依赖于管道(channel) 机制,交互的准备工作都是在工作进程生成时完成的。
Master-Worker交互
工作线程是由主线程使用fork函数生成的。 Nginx服务器启动以后,主进程根据配置文件决定生成的工作进程的数量,然后建立一张全局的工作进程表用于存放当前未退出的所有工作进程。
在主进程生成工作进程后,将新生成的工作进程加入到工作进程表中,并建立一个单向管道并将其传递给该工作进程。该管道与普通的管道不同,它是由主进程指向工作进程的单向管道,包含了主进程向工作进程发出的指令、工作进程ID、工作进程在工作进程表中的索引和必要的文件描述符等信息。
主进程与外界通过信号机制进行通信,当接收到需要处理的信号时,它通过管道向相关的工作进程发送正确的指令。每个工作进程都有能力捕获管道中可读事件,当管道中有可读事件时,工作进程从管道读取并解析指令,然后采取相应的措施。这样就完成了Master-Worker的交互。
注意,这里是单向管道。
Worker-Worker交互
Worker-Worker交互在实现原理上和Master-Worker交互基本是一样的。只要工作进程之间能够得到彼此的信息,建立管道,即可通信。由于工作进程之间是相互隔离的,因此一个进程要想知道另一个进程的信息,只能通过主进程来设置了。
为了达到工作进程之间交互的目的,主进程在生成工作进程后,在工作进程表中进行遍历,将该新进程的ID以及针对该进程建立的管道句柄传递给工作进程表中的其他进程,为工作进程之间的交互做准备。每个工作进程捕获管道中可读事件,根据指令采取响应的措施。
当工作进程W1需要向W2发送指令时,首先在主进程给它的其他工作进程信息中找到W2的进程ID,然后将正确的指令写入指向W2的通道。工作进程W2捕获到管道中的事件后,解析指令并采取相应措施。这样就完成了Worker-Worker交互。
这里注意:工作进程的初始化是有顺序的,因此前面的可能拿不到前面的管道句柄,因此主进程在创建后面的进程时会通知已存在的工作进程。而且,工作进程监听的channel端和发送给其他的channel端也是确定的。
多进程启动过程
可以看出,主进程初始化之后开始对信号进行接收和处理循环、工作进程进行事件处理、缓存索引重建及管理进程负责各自内容。
进程模式的好处就是:进程之间是独立的,一个worker进程出现异常退出,其它worker是不受影响的。另外,独立进程也避免了一些不必要的锁操作,提高处理效率。
Nginx的事件
Nginx的Event模块主要做了两类工作:
- 对连接产生的事件进行高性能的调度处理,如socket的可读、可写事件触发时,对应的回调函数就会被调用。
- 对超时的连接做及时清理,如某socket的读、写超时,则及时关闭此socket。
Nginx 是以事件的触发来驱动的,事件驱动模型主要包括事件收集、事件发送、事件处理(即事件管理)三部分。在Nginx 的工作进程中主要关注的事件是IO网络事件和定时器事件。
Nginx的事件模块主要作用就是对产生连接的网络IO事件、超时事件进行高性能的处理。事件分为两类:服务器和客户端通信产生的事件称为“文件事件”;利用红黑树实现定时器,定时器到点触发的超时事件称为“时间事件”。
文件事件
文件事件涉及几个socket_fd:
- upstream_connect_fd,Nginx作为代理(本质客户端),与上游服务器建立TCP连接
- downstream_connect_fd:Nginx作为服务端,与下游服务器建立TCP连接
- listen_fd:Master进程启动时会对所有服务器监听的端口创建listen_fd(绑定指定的IP和Port)
- channel_fd:Master进程派生Worker进程时会创建管道channel_fd,以便与Worker进程通信
于此对应,文件事件主要分为以下几类:
- 基于listen_fd客户端请求建立TCP连接的accept事件(读事件);
- TCP建立连接后,upstream_connect_fd及downstream_connect_fd触发的读/写事件;
- 父/子进程通信时,channel_fd触发的读/写事件;
- TCP断开连接后的断开事件
为了管理好所有的事件,Nginx抽象出了ngx_event_s结构体,并维护了与连接池大小相同的读事件池和写事件池。
时间事件
Nginx配置文件中,不同维度的超时配置项多达60个。为了对所有超时事件进行统一管理,Nginx维护了一个定时器,以便按时触发各类事件。定时器的实现是通过一棵红黑树,把所有需要按时触发的事件按顺序存储起来的。这里可以关注下超时事件的添加、删除、查找、过期执行等函数。
四个池
上述事件,Nginx是怎么进行管理的呢?在nginx中,使用了四个池来对事件进行管理。
进程池
管理工作进程。进程池是由ngx_processes_t结构体(存放比如pid、status、channel[2]、proc函数指针、*data、*name等字段)组成的数组,存放在全局数组ngx_process中,主要是对Worker进程进行管理。Worker进程附带关联channel_fd,从而实现对channel_fd的管理。
监听池
存储所有监听fd。
监听池实际是由ngx_listening_s结构体(包含fd、*sockaddr、backlog、rcvbuf、sndbuf、reuseport等等字段)组成的数组,存储在全局变量ngx_cycle_s的listening字段中。Nginx中不同服务器可以监听不同的端口,主要是80和443,监听池可对监听不同端口后产生的listen_fd(监听套接字)进行管理。
连接池
存储和管理单个进程所有的连接fd,其实就是对TCP的封装,其中包括连接的 socket,读事件,写事件。
Nginx将连接管理抽象为ngx_connection_s结构体(包含*data、*read读事件处理结构体、*write写事件处理结构体、fd、*listening等字段),而连接池实际是由ngx_connection_s结构体组成的数组,存储在ngx_cycle_s的connections字段中。init_process阶段,Worker进程会提前初始化连接池,这样新请求到来之后无需再次申请内存,使后续连接更高效;通过线程池的大小来对并行连接数进行管控。
事件池
存储和管理读/写事件。
接上面的内容,ngx_connection_s中fd字段关联的socket_fd会产生读/写事件,不同连接的读/写事件触发后需要执行的任务不同,即需要执行的回调函数(nginx对于事件的处理都是使用回调函数)不同,每个连接也有差别。
nginx抽象出了一个结构体ngx_event_s(包含data、write、accept、instance、active、ready、error、timeout、handler等等字段),ngx_event_s结构体与ngx_connection_s结构体是一一映射关系,是对ngx_connection_s结构体的完善,其中ngx_connection_s中的read和write指向与之关联的读/写事件处理结构体。
总结
连接池、事件池、监听池都存储在ngx_cycle_s结构体(很重要的一个结构体,保存了一些配置文件、连接、内存池等等关键信息,每个进程有一个),且各池本质都是数组。连接池存储、管理着各类socket_fd。事件池大小与连接池大小相同,有读/写两个事件池,分别对应着连接池中每个socket_fd触发的可读和可写事件。不同类别的socket_fd在事件池中均有标识。监听池比较简单,存储着所有的监听fd,同时这些被监听的fd也会注册到连接池中。进程池存储着所有进程的PID,每个进程与父进程之间通过channel_fd保持通信,同时这些channel_fd也会注册到连接池。
Event模块初始化流程
初始化
Nginx对于多路复用器及socket_fd事件处理函数进行了封装。
初始化核心流程:
第一阶段:主进程调用fork函数启动子进程前,主要调用core模块的createConf、initConf、initModule函数,作用有:解析配置文件、创建配置结构体、对必需的结构体进行赋值操作、对event模块需要的全局变量初始化、初始化可复用的连接池队列、设置信号回调函数等;
第二阶段:主进程调用fork函数启动子进程后,调用所有模块(core、Event、http等)的init_process函数,主要是从进程维度对需要的变量及数据结构进行初始化。这其中,事件模块的初始化主要是ngx_event_process_init函数。
ngx_event_process_init函数核心流程如下:
- 初始化负载均衡锁相关变量(accept_mutex)
- 初始化两个队列,建立连接事件暂存队列与读/写事件暂存队列;worker进程在持有负载均衡锁时,会独占式得将listen_fd加入自己的epoll。客户端建立连接后,worker进程会优先处理暂存在队列中的建立连接事件,而读写事件会暂缓处理,等建立连接事件处理完了,再把listen_fd从epoll删除,此时才会处理读/写事件。这样避免了某个worker进程独占listen_fd事件过久。
- 初始化红黑树,将其用作时间事件的定时器。
- 调用ngx_event_init函数,创建ep_fd,并初始化event_list,该结构体主要用于调用epoll_wait时存储事件。(注意,这里是每个工作进程建一个epoll)
- 为连接池、事件池分配内存,并关联,然后存在ngx_cycle_s结构体中 。
- 遍历监听池,为所有listen_fd分配连接,注册在连接池中。
- 为所有listen_fd连接的读事件设置回调函数,使用ngx_event_accept(其实就是accept的包装)。
- 根据不同配置,选择性将listen_fd加入epoll。
事件循环
为了高性能地提供服务,Nginx针对网络I/O访问实现了异步执行。一次请求的完整生命周期其实被拆封成了多个时间段。每次请求遇到I/O阻塞时,会把相应的I/O对应的socket_fd放到epoll中监听起来。 等待I/O完成期间,Worker进程继续处理其它请求。每一段执行的唤醒都是由对应的I/O事件或时间时间触发的。事件循环则是处理事件的核心流程。
事件循环入口函数为ngx_worker_process_cycle(ngx_cycle_t *cycle, void data) * ,该函数首先使用ngx_event_process_init**进行初始化,然后设置进程名,之后开始调用ngx_process_events_and_timers(ngx_cycle_t *cycle)处理I/O事件和时间事件(所有的I/O事件和时间事件都是在此函数中处理的),最后对于几种信号例如退出、重启等进行处理。
ngx_process_events_and_timers函数主要对I/O读写事件和时间事件进行处理,其次根据负载均衡锁配置开启与否决定是否加锁。两种事件进行处理时,调用了两类函数:
- ngx_epoll_process_events函数,主要使用epoll_wait函数获取事件,然后针对事件类型,对事件进行回调处理。
- ngx_event_expire_timers函数,每次I/O事件处理完毕,会执行时间事件处理函数。主要逻辑是从红黑树中获取最小节点,与当前时间对比,执行事件小于当前时间的节点代表要马上处理。处理过程为首先计算对应事件首地址,打上过期标识1,然后执行对应事件的回调函数。回调函数中往往判断过期标识为1后,执行对应的清理动作,例如归还连接到连接池、断开TCP连接等。
极简伪代码:
def ngx_worker_process_cycle():
//初始化
ngx_event_process_init()
ngx_process_events_and_timers()
//对其余信号进行处理
def ngx_process_events_and_timers():
//判断负载均衡锁
ngx_epoll_process_events()//处理I/O事件
ngx_event_expire_timers()//处理超时事件
上面五个函数通过合适的调用顺序,完成了事件的循环。
简单来说,流程就是:
- 操作系统提供的机制(例如 epoll, kqueue 等)产生相关的事件。
- 接收和处理这些事件,如是接受到数据,则产生更高层的 request 对象。
- 处理 request 的 header 和 body。
- 产生响应,并发送回客户端。
- 完成 request 的处理。
- 重新初始化定时器及其他事件。
请求处理流程案例
这里举一个Nginx 作为反向代理时, Nginx 启动后,整个请求处理请求的例子。反向代理如果不理解的话可以Google一下,其实可以理解为,从下游来的指定地址的连接,经过nginx,传给指定的上游。
主要分为七步:
- Nginx监听某端口产生listen_fd,worker进程通过抢锁成功把listen_fd加到自己的epoll中,并以水平触发方式(LT)添加到epoll中以监听可读事件,此时该事件回调函数为ngx_event_accept。
- 客户端发起请求,与服务端TCP建立连接后,listen_fd可读事件触发,Worker进程调用epoll_wait获取到,执行回调函数ngx_event_accept连接,获得客户端新连接downstream_connect_fd,之后将该连接以边缘触发(ET)模式添加到epoll中,以监听可读事件。此时,回调函数为ngx_http_wait_request_handler。为避免客户端一直不发数据,会给该连接添加一个时间事件,超时后会清理连接。
- 客户端建立连接后开始发送数据。此时downstream_connect_fd的可读事件触发/worker进程循环调用epoll_wait函数,获取可读事件,执行回调函数ngx_http_wait_request_handler,从downstream_connect_fd中读取客户端数据。读取数据后,解析请求行、请求头、请求体等,并根据URI匹配location。作为反向代理,Nginx调用ngx_socket创建upstream_connect_fd并以非阻塞方式向上游服务器发起连接请求,并将该fd以边缘触发(ET)方式添加到epoll中,监听可读、可写、断开连接事件。读写事件的回调均设为ngx_http_upstream_handler。为避免一直连不到上下游服务器,Nginx会给该连接添加一个超时事件,过期清理。
- 与上游服务器三次握手,然后连接成功后,upstream_connect_fd的可写事件触发,调用epoll_wait获取写事件,执行回调函数ngx_http_upstream_handler,向upstream_connect_fd转发HTTP请求包。发送数据时,因为写缓冲区有限,若一次没发完会添加超时事件,超时则清理,避免写缓冲区一直被占用。成功发送完之后,需删除超时事件,避免连接被错误清理。然后给upstream_connect_fd增加一个读超时事件,避免上游一直不响应造成阻塞。
- 下游服务器在读超时时间内响应请求。此时upstream_connect_fd可读事件触发,继续调用epoll_wait获取读事件,执行回调函数ngx_http_upstream_handler。该函数从upstream_connect_fd中读取数据,再组装并向downstream_connect_fd中响应请求。
- 响应结束后进行连接清理。关闭downstream_connect_fd及upstream_connect_fd,同时关闭这两个fd注册的时间事件。关闭时会判断是否有keepalive机制,加入客户端有,则只会关闭upstream_connect_fd,并将downstream_connect_fd以边缘触发(ET)方式添加到epoll中,监听可读、断开连接事件。两个回调函数被设置为ngx_http_keepalive_handler。为避免长连接永久不清除,会加一个长连接时间事件。超时时间由keepalive_timeout配置指定,若超时则清理,释放资源。
- 超时后,对downstream_connect_fd进行清理,整个请求处理流程介绍。
补充1:nginx作为反向代理服务器时,其实对客户端像是服务器,而对上游服务器更像是客户端。
补充2:工作进程对I/O事件异步执行也利用了I/O复用,使更高效。通俗来说:每进来一个request,会有一个worker进程去处理。但不是全程的处理,而是处理到可能发生阻塞的地方,比如向上游(后端)服务器转发request,并等待请求返回。那么,这个处理的worker不会这么傻等着,他会在发送完请求后,注册一个事件:“如果upstream返回了,告诉我一声,我再接着干”。于是他就休息去了。此时,如果再有request 进来,他就可以很快再按这种方式处理。而一旦上游服务器返回了,就会触发这个事件,worker才会来接手,这个request才会接着往下走。这样就进行了复用。由于web server的工作性质决定了每个request的大部分生命都是在网络传输中,实际上花费在server机器上的时间片不多,这就是几个进程就能解决高并发的秘密所在。
本节留一个问题,为什么nginx在listen_fd时使用LT触发模式,而accept_fd使用ET触发模式呢?
进程初始化及事件处理机制
这里从进程初始化的维度讲流程,并且讲解“惊群现象”及避免方式。
- 首先,master进程一开始就会根据我们的配置,来建立需要listen的网络socket fd,然后fork出多个worker进程。
- 其次,根据进程的特性,新建立的worker进程,也会和master进程一样,具有相同的设置。因此,其也会去监听相同ip端口的套接字socket fd。
- 然后,这个时候有多个worker进程都在监听同样设置的socket fd,意味着当有一个请求进来的时候,所有的worker都会感知到。这样就会产生所谓的“惊群现象”。为了保证只会有一个进程成功注册到listenfd的读事件,nginx中实现了一个“accept_mutex”,类似互斥锁,只有获取到这个锁的进程,才可以去注册读事件(将该listen_fd加入到自己的epoll中)。
- 最后,监听成功的worker进程,读取请求,解析处理,响应数据返回给客户端,断开连接,结束。因此,一个request请求,只需要worker进程就可以完成。worker进程的ngx_worker_process_cycle()函数在上面已经写过了。
这里补充两点,一、这个accept_mutex可以使用mmap分配一块共享内存来做;二、多个worker进程竞争accept_mutex时,有可能会有一个进程一直胜出,这样就不均衡,因此nginx还有一个ngx_accept_disabled值,用于控制一个worker进程是否需要去竞争获取accept_mutex选项,进而获取accept事件。
一个HTTP请求的生命周期
事件处理机制讲完,这里补充一个,对于一个请求的生命周期。其实跟上面模块流程是呼应的。
从 Nginx 的内部来看,一个 HTTP Request 的处理过程涉及到以下几个阶段。
- 初始化 HTTP Request(读取来自客户端的数据,生成 HTTP Request 对象,该对象含有该请求所有的信息)。
- 处理请求头。
- 处理请求体。
- 如果有的话,调用与此请求(URL 或者 Location)关联的 handler。
- 依次调用各 phase handler 进行处理。
- 过滤、加工数据等
处理请求行和请求头时逐行读取,请求头会存在一个链表中,常见的还会放在哈希表中。读并解析好请求行和请求头后开始进行处理。
处理请求阶段, nginx 把http请求处理流程划分为了11个阶段:
- NGX_HTTP_POST_READ_PHASE: 读取请求内容阶段
- NGX_HTTP_SERVER_REWRITE_PHASE: Server 请求地址重写阶段
- NGX_HTTP_FIND_CONFIG_PHASE: 配置查找阶段:
- NGX_HTTP_REWRITE_PHASE: Location请求地址重写阶段
- NGX_HTTP_POST_REWRITE_PHASE: 请求地址重写提交阶段
- NGX_HTTP_PREACCESS_PHASE: 访问权限检查准备阶段
- NGX_HTTP_ACCESS_PHASE: 访问权限检查阶段
- NGX_HTTP_POST_ACCESS_PHASE: 访问权限检查提交阶段
- NGX_HTTP_TRY_FILES_PHASE: 配置项 try_files 处理阶段
- NGX_HTTP_CONTENT_PHASE: 内容产生阶段
- NGX_HTTP_LOG_PHASE: 日志模块处理阶段
NGX_HTTP_CONTENT_PHASE阶段,会将内容交给合适的handler进行处理,之后产生响应内容。
内容产生阶段完成以后,生成的输出会被传递到 filter 模块去进行处理。filter做的事例如gzip缩放,图像缩放等。
这样,一个HTTP Request的生命周期就结束了。
HTTP模块是Nginx的核心之一,这里更详细来说有更多内容,比如读取/解析请求行、请求头时是怎么做的;请求处理流程以模块为单位进行处理,各个阶段可以包含任意多个HTTP模块并以流水线的方式处理请求,每个阶段都是做什么的之类;这里就不详细讲了,感兴趣的话可以自行查询。
几个经典网络库的事件处理机制
网络库的职责是帮助简化高性能网络应用程序的开发。最基础的功能是提供高性能地网络通讯,优秀的网络框架比如netty还会提供基于事件和流水线的编程模型,可以让我们补充业务逻辑即可以制作一个服务端/客户端程序。使用网络库,我们可以实现HTTP服务器,也可以实现RPC服务。其实很多好的框架和应用都应用了这些经典网络库,例如Dubbo、grpc等RPC框架或RocketMQ等消息队列的底层都用到了Netty,而字节自研的PRC框架KiteX和HTTP框架Hertz则使用了自研的Netpoll网络库。接下来会讲一下它们各自的事件处理机制。
Netpoll
Netpoll是一个高性能NIO(Non-blocking I/O)网络库,大家应该都很熟悉,因为是公司内部开发的开源网络库,并且基于go。公司的KiteX(开源RPC框架)和Hertz(即将开源的HTTP框架)都是基于此开发的。
Netpoll的开发主要是因为之前kite这个框架依赖thrift底层库有一些问题,因此开发KiteX的时候,就希望使用自己的方案开发一个网络哭,这个网络库经过选型后,设计采用了epoll+buffer+task pool的方式。最终选型上借鉴了Netty的设计思路,在Evio的基础上大改。
关于Netpoll,公司里有一个详细的文档介绍,并且也有使用手册。文末有参考链接。本文主要是参考这篇文章,并且做了简单的总结和描述。
Netpoll Server结构设计
netpoll使用了主从Reactor结构,结构大体设计为三层:
1.EventLoop:构建对外使用的netpoll server,管理主从Reactor。
2.Reactor:管理fd,使用epoll模式。
3.Connection:管理连接,数据流收发(通过Buffer)和异步处理(通过NIO Task Pool)。
各层详细设计
总体设计
可以看出,EventLoop定位是netpoll的服务端。基于NIO主流的主从reactor设计,EventLoop维护一个MainReactor和众多SubReactor。
其中MainReactor管理listener的连接,当发现新连接到来时,将其封装为channel,并注册到SubReactor上。
SubReactor的个数可以自己配置,每个SubReactor都可以维护一组连接,接收请求、异步处理并写回响应。
主从Reactor
MainReactor只维护一个Listener,将Listener的文件描述符(fd)扔到Poll(Linux下为epoll)中监听。当新连接到来时(fd发生变化),建立新连接(net.Conn),并将Conn封装为Channel。最后通过loadbalance选取最合适的SubReactor,注册Channel并交由其管理。这里的图只写了一个SubReactor,其实对应多个。并且注意,poll默认设定为LT模式,读者思考一下为什么呢?
Connection模块
主要分为五个部分:
- Config:管理连接、配置、超时和保活等。
- Buffer:使用缓存分离网络I/O过程和业务处理过程,是实现NIO的关键。
- Pipeline:流水线,由业务控制,负责处理请求和响应以及执行业务逻辑。
- Filter:处理单元,处理上下行数据流。
- Context:Connection上下文,包含一些特定信息。
Pipeline异步工作流程
Pipeline工作流程:
- 当Buffer接收请求后,检查Connection中是否存在正在工作的task(检查chan!=nil)
- 不存在则创建新的task,并添加到NIO Task Pool中执行。
- task和buffer通过channel通信,读取请求数据流,解析数据。
- 如果解析不全,等待并设置超时
- 解析完整后进入Pipeline filters中(其实就是处理),并将响应信息encode写回Buffer。
- task通过callback通知SubReactor发送已写回的数据给对端,然后结束。
多路复用的Pipeline NIO task
Pipeline的异步模式中,我们指的是单条连接,阻塞式单线程处理(超时时间则杀死)的方式。而在Netpoll中,Pipeline设计又支持多路复用,通过增加一层多路复用协议filter,为数据包增加了id区分。通过识别id解析请求给子task,或者组装响应数据发送给对端,这样也实现了多路复用。
可以看到,上面的图片中,一条连接decode filter中的数据分了两个包,发给了两个task来处理。并且这两个task其实是在NIO task Pool中。这样,可以使一条连接得到并发处理。
One way支持
顾名思义,其实就是为Pipeline添加Filter时,只要不向out stream buffer里写数据,就只需要读取+处理,不需要返回数据。
Netpoll Client结构设计
Netpoll是为RPC服务设计的,既然是RPC服务,肯定既要有服务器(被调方),也要有客户端(调用方)。
client设计为连接池,上层代码通过创建netpoll连接池,使用Connection实现NIO client,设计结构和Server很像。
三个部分:
- ConnPool:多种连接池
- EventLoop:事件循环,拥有多个SubReactor,管理所有连接池内Connection的fd,使用epoll模式。
- Connection:管理连接,数据流收发(通过Buffer),异步处理和回调响应(通过NIO Task Pool)。
注意这里没有MainReactor,为什么呢?因为客户端负责发送请求,主动建立连接,不需要被动listen的Reactor啊。
NIO Client工作流程
工作流程如下:
- 一个请求打来,上层代码通过loadbalance等逻辑,选择最合适的ConnPool。
- 从ConnPool中获取或建立一个Connection,获取时会将其建立注册到SubReactor。
- 上层代码执行write,将请求写入Buffer并通知SubReactor,可写时发送请求数据。
- 上层代码进入等待阶段,设置超时
- SubReactor监听到读事件并获取相应数据,唤醒Connection执行Pipeline inbound(这里确实好像Netty)解析响应数据。
- Connection通过callback将响应数据通知给等待中的上层代码
- 上层代码拿到响应并返回,同时通知ConnPool回收连接。
一个Netpoll的简单案例
Server端
package main
import (
"context"
"time"
"code.byted.org/middleware/netpoll"
)
func main() {
var network, address string = "tcp", "127.0.0.1:8888"
// 创建 listener
listener, err := netpoll.CreateListener(network, address)
if err != nil {
panic("create netpoll listener fail")
}
// handle: 连接读数据和处理逻辑
var onRequest netpoll.OnRequest = handler
// options: EventLoop 初始化自定义配置项
var opts = []netpoll.Option{
netpoll.WithReadTimeout(1 * time.Second),
netpoll.WithIdleTimeout(10 * time.Minute),
netpoll.WithOnPrepare(nil),
}
// 创建 EventLoop
eventLoop, err := netpoll.NewEventLoop(onRequest, opts...)
if err != nil {
panic("create netpoll event-loop fail")
}
// 运行 Server
err = eventLoop.Serve(listener)
if err != nil {
panic("netpoll server exit")
}
}
// 读事件处理
func handler(ctx context.Context, connection netpoll.Connection) error {
return connection.Writer().Flush()
}
client端
package main
import (
"time"
"code.byted.org/middleware/netpoll"
)
func main() {
var network, address string = "tcp", "127.0.0.1:8888"
// 直接创建连接
conn, err := netpoll.DialConnection(network, address, 50*time.Millisecond)
if err != nil {
panic("dial netpoll connection fail")
}
// 通过 dialer 创建连接
dialer := netpoll.NewDialer()
conn, err = dialer.DialConnection(network, address, 50*time.Millisecond)
if err != nil {
panic("dialer netpoll connection fail")
}
// conn write & flush message
conn.Writer().WriteBinary([]byte("hello world"))
conn.Writer().Flush()
}
之后还有使用读取、控制Epoller个数和负载均衡、配置gopool协程池、初始化连接、超时时间、读事件回调、关闭连接、Buffer控制等等,具体可以看文档。
Netty
Netty是一个优秀的Java NIO客户端/服务器框架,基于Netty可以很快地开发网络服务器和客户端的应用程序。Dubbo、Elasticsearch、grpc等底层实现都使用了Netty。
这里顺嘴提一句Netty的架构
绿色的部分Core核心模块,包括零拷贝、API库、可扩展的事件模型。
橙色部分Protocol Support协议支持,包括Http协议、webSocket、SSL(安全套接字协议)、谷歌Protobuf协议、zlib/gzip压缩与解压缩、Large File Transfer大文件传输等等。
红色的部分Transport Services传输服务,包括Socket、Datagram、Http Tunnel等等。
这些是Netty的主要功能。
而Netty使用了主从Reactor的事件处理机制,可以看下面这幅图。
Netty抽象出BossGroup专门负责接收客户端的连接,WorkerGroup专门负责网络的读写,这两种Group都是NioEventLoopGroup,NioEventLoopGroup相当于一个循环事件组,组中含有多个事件循环,每个事件循环是NioEventLoop,NioEventLoop是一个不断循环的执行处理任务的线程(因此这里是EventLoop和线程是1:1的),每个NioEventLoop都有一个selector,用于监听绑定在其上的socket网络通讯。
其中,select为多路复用器,在不同的操作系统中,会有不同的poll/epoll等选择。channel为socket的封装。
Boos Group中的NioEventGroup中的循环执行步骤channel为socket的封装。有三个:
- 轮询accept事件
- 处理accept事件,与client建立连接,生成NioSocketChannel,并将其注册到某个Worker NIOEventLoop上的selector
- 处理任务队列的任务,即runAllTasks
每个Worker Group中的NioEventLoop循环执行步骤也有三个:
- 轮询I/O事件
- 处理I/O事件,即read、write
- 处理队列中的任务,即runAllTasks。这个处理的地方自己也可以定义线程池之类的。
这些就是Netty的网络事件处理模式。
除了高性能的处理之外,Netty还有一些其他的优点:
- 稳定性。 Netty 更加可靠稳定,修复和完善了 JDK NIO 较多已知问题,包括 select 空转导致 CPU 消耗 100%(设置超时事件并处理)、keep-alive 检测(心跳机制)等问题。
- 性能优化。对象池复用技术。 Netty 通过复用对象,避免频繁创建和销毁带来的开销。零拷贝技术。 除了操作系统级别的零拷贝技术外,Netty 提供了面向用户态的零拷贝技术,在 I/O 读写时直接使用 DirectBuffer,避免了数据在堆内存和堆外内存之间的拷贝。
- 便捷性。 Netty 提供了很多常用的工具,例如行解码器、长度域解码器等。如果我们使用JDK NIO包,那么这些常用工具都需要自己进行实现。
一个使用netty的简单例子
引入依赖
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
<version>4.1.20.Final</version>
</dependency>
创建服务器启动类
public class MyServer {
public static void main(String[] args) throws Exception {
//创建两个线程组 boosGroup、workerGroup
EventLoopGroup bossGroup = new NioEventLoopGroup();
EventLoopGroup workerGroup = new NioEventLoopGroup();
try {
//创建服务端的启动对象,设置参数
ServerBootstrap bootstrap = new ServerBootstrap();
//设置两个线程组boosGroup和workerGroup
bootstrap.group(bossGroup, workerGroup)
//设置服务端通道实现类型
.channel(EpollServerSocketChannel.class)
//设置线程队列得到连接个数
.option(ChannelOption.SO_BACKLOG, 128)
//设置保持活动连接状态
.childOption(ChannelOption.SO_KEEPALIVE, true)
//使用匿名内部类的形式初始化通道对象
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel socketChannel) throws Exception {
//给pipeline管道设置处理器
socketChannel.pipeline().addLast(new MyServerHandler());
}
});//给workerGroup的EventLoop对应的管道设置处理器
System.out.println("服务端已经准备就绪~");
//绑定端口号,启动服务端
ChannelFuture channelFuture = bootstrap.bind(6666).sync();
//对关闭通道进行监听
channelFuture.channel().closeFuture().sync();
} finally {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
}
}
创建服务端处理器
/**
* 自定义的Handler需要继承Netty规定好的HandlerAdapter
* 才能被Netty框架所关联,有点类似SpringMVC的适配器模式
**/
public class MyServerHandler extends ChannelInboundHandlerAdapter {
//channel中有信息时被回调
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
//获取客户端发送过来的消息
ByteBuf byteBuf = (ByteBuf) msg;
System.out.println("收到客户端" + ctx.channel().remoteAddress() + "发送的消息:" + byteBuf.toString(CharsetUtil.UTF_8));
}
//channel中信息读完时被回调
@Override
public void channelReadComplete(ChannelHandlerContext ctx) throws Exception {
//发送消息给客户端
ctx.writeAndFlush(Unpooled.copiedBuffer("收到!我也是财经支付~", CharsetUtil.UTF_8));
}
//发生异常被回调
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
//发生异常,关闭通道
ctx.close();
}
}
创建客户端启动类
public class MyClient {
public static void main(String[] args) throws Exception {
NioEventLoopGroup eventExecutors = new NioEventLoopGroup();
try {
//创建bootstrap对象,配置参数
Bootstrap bootstrap = new Bootstrap();
//设置线程组
bootstrap.group(eventExecutors)
//设置客户端的通道实现类型
.channel(NioSocketChannel.class)
//使用匿名内部类初始化通道
.handler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) throws Exception {
//添加客户端通道的处理器
ch.pipeline().addLast(new MyClientHandler());
}
});
System.out.println("客户端准备就绪~");
//连接服务端
ChannelFuture channelFuture = bootstrap.connect("127.0.0.1", 6666).sync();
//对通道关闭进行监听
channelFuture.channel().closeFuture().sync();
} finally {
//关闭线程组
eventExecutors.shutdownGracefully();
}
}
}
创建客户端处理器
public class MyClientHandler extends ChannelInboundHandlerAdapter {
//channel准备完毕后该回调函数会被调用
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
//发送消息到服务端
ctx.writeAndFlush(Unpooled.copiedBuffer("Hello,我是财经支付!", CharsetUtil.UTF_8));
}
//channel中有信息时会调用
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
//接收服务端发送过来的消息
ByteBuf byteBuf = (ByteBuf) msg;
System.out.println("收到服务端" + ctx.channel().remoteAddress() + "的消息:" + byteBuf.toString(CharsetUtil.UTF_8));
}
}
先启动服务端,再启动客户端,就可以看到结果:
通常,服务端的Netty启动器的启动流程有八个阶段:
- 创建反应器线程组,并赋值给ServerBootstrap启动器实例。(父子线程组)
- 设置通道的IO类型(比如epoll或者nio类型)
- 设置监听端口
- 设置传输通道的配置选项(如是否长连接keeplive)
- 装配子通道的Pipeline流水线(相当于放业务处理器,只有子通道需要,因为父通道是管连接的)
- 绑定服务器新连接的监听端口
- 自我阻塞,直到通道关闭的异步任务结束
- 关闭EventLoopGroup
以上正是我们所关注的。
Netty有很多组件,比如Bootstrap与ServerBootStrap启动器类、Channel通道、Selector、Pipeline流水线、ByteBuf缓冲区、ChannelHandleContext、taskQueue任务队列、scheduleTaskQueue、Future异步机制、EventLoopGroup、Encoder编码器和Decoder解码器等,这些组件构成了它的特性,这里就不详细解释了。
建议自己使用Netty写一个简单的服务端处理器来尝试一下,会加深理解,并且对于其组件也有更多的了解。
Libevent
libevent是一个事件触发的网络库,适用于windows、linux、bsd等多种平台,内部使用select、epoll、kqueue等系统调用管理事件机制。libevent 库实际上没有更换 select()、poll() 或其他机制的基础。而是使用对于每个平台最高效的高性能解决方案在实现外加上一个包装器(其实大家都是)。
Libevent的事件处理机制可以从上图中看到,事件主要分为读写事件/信号事件/时间事件,这些被放在IO复用器中,不断循环,出现事件后就放到active list中,遍历然后调用事件的回调函数。
其它
网络库就讲到这里,其实还有一些很经典的网络库,比如Evio、Boost、Muduo。但由于时间限制,并没有写在文章里,之后有时间会补充进来。另外,网络库的实现是有很多细节的,有机会的话,希望将这些网络库更详细的实现方式进行学习和汇总,了解它们各自来源和解决的问题,以及各自的优劣。
补充知识
C10K问题补充
大家都知道互联网的基础就是网络通信,早期的互联网可以说是一个小群体的集合。互联网还不够普及,用户也不多,一台服务器同时在线100个用户估计在当时已经算是大型应用了,所以并不存在什么 C10K 的难题。互联网的爆发期应该是在www网站,浏览器,雅虎出现后。最早的互联网称之为Web1.0,互联网大部分的使用场景是下载一个HTML页面,用户在浏览器中查看网页上的信息,这个时期也不存在C10K问题。
Web2.0时代到来后就不同了,一方面是普及率大大提高了,用户群体几何倍增长。另一方面是互联网不再是单纯的浏览万维网网页,逐渐开始进行交互,而且应用程序的逻辑也变的更复杂,从简单的表单提交,到即时通信和在线实时互动,C10K的问题才体现出来了。因为每一个用户都必须与服务器保持TCP连接才能进行实时的数据交互,诸如Facebook这样的网站同一时间的并发TCP连接很可能已经过亿。
早期的腾讯QQ也同样面临C10K问题,只不过他们是用了UDP这种原始的包交换协议来实现的,绕开了这个难题,当然过程肯定是痛苦的。如果当时有epoll技术,他们肯定会用TCP。众所周之,后来的手机QQ、微信都采用TCP协议。
实际上当时也有异步模式,如:select/poll模型,这些技术都有一定的缺点:如selelct最大不能超过1024、poll没有限制,但每次收到数据需要遍历每一个连接查看哪个连接有数据请求。
这时候问题就来了,最初的服务器都是基于进程/线程模型的,新到来一个TCP连接,就需要分配1个进程(或者线程)。而进程又是操作系统最昂贵的资源,一台机器无法创建很多进程。如果是C10K就要创建1万个进程,那么单机而言操作系统是无法承受的(往往出现效率低下甚至完全瘫痪)。如果是采用分布式系统,维持1亿用户在线需要10万台服务器,成本巨大,也只有Facebook、Google、雅虎等巨头才有财力购买如此多的服务器。
基于上述考虑,如何突破单机性能局限,是高性能网络编程所必须要直面的问题。这些局限和问题最早被Dan Kegel 进行了归纳和总结,并首次成系统地分析和提出解决方案,后来这种普遍的网络现象和技术局限都被大家称为 C10K 问题。
C10K问题其实也来自于早期计算机的设计思路,但既然我们已经基于当前的这些基础造好了那么多的基础设施和高楼大厦,就只能想出在当前计算机基础上的解决办法了。
小知识:零拷贝
在网络库和Nginx中,经常简单见到说使用零拷贝如何如何,可以大大增加效率,那么零拷贝究竟是什么呢?
首先说下DMA技术(直接内存访问( Direct Memory Access ) :在进行 I/O 设备和内存的数据传输的时候,数据搬运的工作全部交给 DMA 控制器,而 CPU 不再参与任何与数据搬运相关的事情,这样 CPU 就可以去处理别的事务。
就是说在最原始的IO过程中,全都要靠CPU来处理,这样对CPu的负担是很重的。因此有了DMA技术。
那么什么是零拷贝技术呢?
这一点要先看非零拷贝的实现方式:
可以看出,共发生了 4 次用户态与内核态的上下文切换,成本很高。尤其是进入了两次内核态,如果在高并发场景中,频繁进行内核和用户态的上下文切换,成本无疑是很高的。
那么,优化文件传输性能的办法就是:减少系统调用次数,也即减少陷入内核态的次数。
零拷贝技术实现的方式通常有 2 种:
- mmap + write
- sendfile
mmap + write
长相:
buf = mmap(file, len);
write(sockfd, buf, len);
read() 系统调用的过程中会把内核缓冲区的数据拷贝到用户的缓冲区里,于是为了减少这一步开销,我们可以用 mmap() 替换 read() 系统调用函数。mmap() 系统调用函数会直接把内核缓冲区里的数据「映射」到用户空间,这样,操作系统内核与用户空间就不需要再进行任何的数据拷贝操作。
- 应用进程调用了 mmap() 后,DMA 会把磁盘的数据拷贝到内核的缓冲区里。接着,应用进程跟操作系统内核「共享」这个缓冲区;
- 应用进程再调用 write(),操作系统直接将内核缓冲区的数据拷贝到 socket 缓冲区中,这一切都发生在内核态,由 CPU 来搬运数据;
- 最后,把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程是由 DMA 搬运的。
这样,通过使用 mmap() 来代替 read(), 可以减少一次数据拷贝的过程。
sendfile
在 Linux 内核版本 2.1 中,提供了一个专门发送文件的系统调用函数 sendfile()。
长相:
#include <sys/socket.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
它的前两个参数分别是目的端和源端的文件描述符,后面两个参数是源端的偏移量和复制数据的长度,返回值是实际复制数据的长度。
如下图:
首先,它可以替代前面的 read() 和 write() 这两个系统调用,这样就可以减少一次系统调用,也就减少了 2 次上下文切换的开销。
其次,该系统调用,可以直接把内核缓冲区里的数据拷贝到 socket 缓冲区里,不再拷贝到用户态,这样就只有 2 次上下文切换,和 3 次数据拷贝。
但这还不是最终版的零拷贝。
终极版如下图:
从 Linux 内核 2.4 版本开始起,对于支持网卡支持 SG-DMA 技术的情况下, sendfile() 系统调用的过程发生了点变化,具体过程如下:
- 第一步,通过 DMA 将磁盘上的数据拷贝到内核缓冲区里;
- 第二步,缓冲区描述符和数据长度传到 socket 缓冲区,这样网卡的 SG-DMA 控制器就可以直接将内核缓存中的数据拷贝到网卡的缓冲区里,此过程不需要将数据从操作系统内核缓冲区拷贝到 socket 缓冲区中,这样就减少了一次数据拷贝;
总结来说,零拷贝(Zero-copy)技术,因为我们没有在内存层面去拷贝数据,也就是说全程没有通过 CPU 来搬运数据,所有的数据都是通过 DMA 来进行传输的。
零拷贝技术的文件传输方式相比传统文件传输的方式,减少了 2 次上下文切换和数据拷贝次数,只需要 2 次上下文切换和数据拷贝次数,就可以完成文件的传输,而且 2 次的数据拷贝过程,都不需要通过 CPU,2 次都是由 DMA 来搬运。
所以,总体来看,零拷贝技术可以把文件传输的性能提高至少一倍以上。当前,在Nginx、Netty、Netpoll等应用或库中,都使用了零拷贝技术,大大增加了文件传输的性能。
参考资料
《Redis设计与实现》
《Nginx底层设计与源码剖析》