最近对Nginx源码比较感兴趣,借助于强大的VS Code,我一步一步,似魔鬼的步伐,开始了Nginx的探索之旅。关于 VS Code 如何调试 Nginx 可参考上篇文章《VS CODE 轻松调试 Nginx》。
一. 引言
Nginx 其实无需做太多介绍,作为业界知名的高性能服务器,被广大互联网公司应用,阿里的 Tegine 就是基于 Nginx 开发的。
Nginx 基本上都是用来做负载均衡、反向代理和动静分离。目前大部分公司都采用 Nginx 作为负载均衡器。作为 LBS,最基本的要求就是要支持高并发,毕竟所有的请求都要经过它来进行转发。
那么为什么 Nginx 拥有如此强大的并发能力呢?这便是我感兴趣的事情,也是这篇文章所要讲的事情。但是标题是《动手打造Nginx多进程架构》,难道这篇文章却只是简单的源码分析?
这几天研究 Nginx 过程中,我常常陷于Nginx 复杂的源码之中,不得其解,虽然也翻了一些资料和书籍,但是总觉得没有 get 到精髓,就是好像已经理解了,但是对于具体流程和细节,总是模模糊糊。于是趁着周末,花了小半天,再次梳理了下Nginx 多进程事件的源码,仿照着写了一个普通的 Server,虽然代码和功能都非常简单,不过刚好适合于读者了解Nginx,而不至于陷于丛林之中,不知方向。
二. 传统 Web Server 架构
让我们来思考下,如果让你动手打造一个 web 服务器,你会怎么做?
第一步,监听端口
第二步,处理请求
监听端口倒是很简单,处理请求该怎么做呢?不知道大家上大学刚开始学c语言的时候,老师有没有布置过聊天室之类的作业?那时候我其实完全靠百度来完成的:开启端口监听,死循环接收请求,每接收一个请求就直接开个新线程去处理。
![2819184-85f27254561a2342.jpg](https://i-blog.csdnimg.cn/blog_migrate/f01f818c03693333d84c16978fee2759.webp?x-image-process=image/format,png)
这样做当然可以,也很简单,完全满足了我当时的作业要求,其实目前很多web服务器,诸如tomcat之类,也都是这样做的,为每个请求单独分配一个线程。那么这样做,有什么弊端呢?
最直接的弊端就是线程数量开的太多,会导致 CPU 在不同线程之间不断的进行上下文切换。CPU 的每次任务切换,都需要为上一次任务保存一些上下文信息(如寄存器的值),再装载新任务的上下文信息,这些都是不小的开销。
第二个弊端就是CPU利用率的下降,考虑当前只有一个线程的情况,当线程在等待网络 IO 的时候其实是处于阻塞状态,这个时候 CPU 便处于空闲状态,这直接导致了 CPU 没有被充分利用,简直是暴殄天物!
这种架构,使 Web 服务器从骨子里,就对高并发没有很好的承载能力!
三. Nginx 多进程架构
Nginx 之所以可以支持高并发,正是因为它摒弃了传统 Web 服务器的多线程架构,并充分利用了 CPU。
Nginx采用的是 单Master、多Worker 架构,顾名思义,Master 是老板,而 Worker 才是真正干活的工人阶层。
我们先来看下 Nginx 接收请求的大概架构。
![2819184-130838438022379e.jpg](https://i-blog.csdnimg.cn/blog_migrate/5d11ab4e7a094262d925f2aaa06b567a.webp?x-image-process=image/format,png)
乍一看,好像和传统的 Web Server 也没啥区别啊,不过是右边的 Thread 变成了 Worker 罢了。这其实正是 Nginx 的精妙之处。
Master 进程启动后,会 fork 出 N 个 Worker 进程,N 是 可配置的,一般来说,可以设置为服务器核心数,设置更大值也没有太多意义,无非是会增加 CPU 进程切换的开销。
每个Worker 进程都会监听来自客户端的请求,并进行处理,与传统 Web Server 不同的是,Worker 进程不会对于每个请求都分配一个单独线程去处理,而是充分利用了IO多路复用 的特性。
如果读者之前没有了解或者使用过IO多路复用,那确实该好好充充电了。Android 中的 Looper、Java 著名的开源库 Netty,都是基于多路复用,所谓多路复用,与同步阻塞IO最大的区别就是,一个进程可以同时处理多个IO操作,当 某个IO 操作 Ready 时,操作系统会主动通知进程。
Nginx 正是使用了这样的思想,虽然同时有很多请求需要处理,但是没必要为每个请求都分配一个线程啊。哪个请求的网络 IO Ready 了,我就去处理哪个,这样不就可以了吗?何必创建一个线程在那傻傻的等着。
举个不恰当的例子,服务器就好比是学校,客户端好比是学生,学生有不会的问题就会问老师。
- 对于传统的 Web 服务器,每个学生,学校都会派一个老师去服务,一个学校可能有几千个学生,那岂不是要雇几千个老师,校领导怕是连工资都发不出来了吧。仔细想想,每个学生不可能随时都在提问吧,总得休息下吧!那学生休息时,老师干嘛呢?白拿工资还不干活。
- 对于Nginx,它就不给老师闲的机会啦,学校有几间办公室,就雇几个老师,有学生提问时,就派一个老师解答,所以一个老师会负责很多学生,哪个学生举手了,他就去帮助哪个学生解决问题。
这里有读者怕是会疑惑,如果哪个学生一直霸占着老师不放怎么办?这样老师不就没有机会去解答其他同学的问题了吗?如果作为一个负责业务处理的 Web 服务器,Nginx这种架构确实可能出现这样的问题,但是要记住,Nginx主要是用来做负载均衡的,他的主要任务是接收请求、转发请求,所以它的业务处理其实就是将请求再转发给其他的服务器,那么接收用IO多路复用,转发也用 IO 多路复用不就行了。
四. 源码分析
基于最新 1.15.5 版本
4.1 整体运行机制
一切都从 main()开始。
nginx 的 main()方法中有不少逻辑,不过对于今天我要讲的事情来说,最重要的就是两件事:
- 创建套接字,监听端口;
- Fork 出 N 个 Worker 进程。
监听端口没什么太多逻辑,我们先来看看 Worker 进程的诞生:
static void
ngx_start_worker_processes(ngx_cycle_t *cycle, ngx_int_t n, ngx_int_t type)
{
ngx_int_t i;
ngx_channel_t ch;
....
for (i = 0; i < n; i++) {
ngx_spawn_process(cycle, ngx_worker_process_cycle,
(void *) (intptr_t) i, "worker process", type);
......
}
}
这里主要是根据配置的 Worker 数量,创建出对应数量的 Worker 进程,创建 Woker 进程调用的是 ngx_spawn_process(),第二个参数 ngx_worker_process_cycle 就是子进程的新起点。
static void
ngx_worker_process_cycle(ngx_cycle_t *cycle, void *data)
{
......
for ( ;; ) {
......
ngx_process_events_and_timers(cycle);
......
}
}
上面的代码省略了一些逻辑,只保留了最核心的部分。ngx_worker_process_cycle ,正如其名,在其内部开启了一个死循环,不断调用 ngx_process_events_and_timers()。
void
ngx_process_events_and_timers(ngx_cycle_t *cycle)
{
......
if (ngx_use_accept_mutex) {
if (ngx_accept_disabled > 0) {
ngx_accept_disabled--;
} else {
if (ngx_trylock_accept_mutex(cycle) == NGX_ERROR) {
return;
}
......
}
}
......
(void) ngx_process_events(cycle, timer, flags);
......
}
这里最后调用了ngx_process_events()来接收并处理事件。
ngx_process_events()在不同平台指向不同的 IO 处理模块,比如Linux上为epoll,而在Mac OS上指向的其实是kqueue模块中的ngx_kqueue_process_events()。
static ngx_int_t
ngx_kqueue_process_events(ngx_cycle_t *cycle, ngx_msec_t timer,
ngx_uint_t flags)
{
int events, n;
ngx_int_t i, instance;
ngx_uint_t level;
ngx_err_t err;
ngx_event_t *ev;
ngx_queue_t *queue;
struct timespec ts, *tp;
n = (int) nchanges;
nchanges = 0;
......
events = kevent(ngx_kqueue, change_list, n, event_list, (int) nevents, tp);
......
for (i = 0; i < events; i++) {
......
ev = (ngx_event_t *) event_list[i].udata;
switch (event_list[i].filter) {
case EVFILT_READ:
case EVFILT_WRITE:
......
break;
case EVFILT_VNODE:
ev->kq_vnode = 1;
break;
case EVFILT_AIO:
ev->complete = 1;
ev->ready = 1;
break;
......
}
......
ev->handler(ev);
}
return NGX_OK;
}
上面其实就是一个比较基本的 kqueue 使用方式了。说到这里,我们就不得不说下 kqueue 的使用方式了。
kqueue 主要依托于两个 API:
// 创建一个内核消息队列,返回队列描述符
int kqueue(void);
// 用途:注册\反注册 监听事件,等待事件通知
// kq,上面创建的消息队列描述符
// changelist,需要注册的事件
// changelist,changelist数组大小
// eventlist,内核会把返回的事件放在该数组中
// nevents,eventlist数组大小
// timeout,等待内核返回事件的超时事件,NULL 即为无限等待
int kevent(int kq,
const struct kevent *changelist, int nchanges,
struct kevent *eventlist, int nevents,
const struct timespec *timeout);
我们回过头再来看看上面 ngx_kqueue_process_events()中代码,其实也就是在调用kevent()等待内核返回消息,收到消息后再进行处理。这里消息处理主要是进行ACCEPT、READ、WRITE等。
所以从整体来看,Nginx事件模块的运行就是 Worker 进程在死循环中,不断等待内核消息队列返回事件消息,并加以处理的一个过程。
4.2 惊群问题
到这里我们一直在讨论一个单独的 Worker 进程运行机制,那么每个 Worker 进程之间有没有什么交互呢?
回到上面的 ngx_process_events_and_timers()中,在每次调用 ngx_process_events()等待消息之前,Worker 进程都会进行一个 ngx_trylock_accept_mutex()操作,这其实就是多个 Worker 进程之间在争夺监听资格的过程,是 Nginx 为了解决惊群问题而设计出的方案。
所谓惊群,其实就是如果有多个Worker进程同时在监听内核消息事件,当有请求到来时,每个Worker进程都会被唤醒,去accept同一个请求,但是只能有一个进程会accept成功,其他进程会accept失败,被白白的唤醒了,就像你再睡觉时被突然叫醒,却发现压根没你啥事,你说气不气人。
为了解决这个问题,Nginx 让每个Worker 进程在监听内核消息事件前去竞争一把锁,只有成功获得锁的进程才能去监听内核事件,其他进程就乖乖的睡眠在锁的等待队列上。当获得锁的进程处理完accept事件,就会回来释放掉这把锁,这时所有进程又会同时去竞争锁了。
为了不让每次都是同一个进程抢到锁,Nginx 设计了一个小算法,用一个因子ngx_accept_disabled 去 平均每个进程获得锁的概率,感兴趣的同学可以自己看下这块源码。
五. 动手打造 Nginx 多进程架构
终于到DIY的环节了,这里我基于 MacOS 平台来开发,IO多路复用也是选用上面所讲的 kqueue。
5.1 创建进程锁,用于抢到监听事件资格
mm = (mt*)mmap(NULL,sizeof(*mm),PROT_READ|PROT_WRITE,MAP_SHARED|MAP_ANON,-1,0);
memset(mm,0x00,sizeof(*mm));
pthread_mutexattr_init(&mm->mutexattr);
pthread_mutexattr_setpshared(&mm->mutexattr, PTHREAD_PROCESS_SHARED);
pthread_mutex_init(&mm->mutex,&mm->mutexattr);
5.2 创建套接字,监听端口
// 创建套接字
int serverSock =socket(AF_INET, SOCK_STREAM, 0);
if (serverSock == -1)
{
printf("socket failed\n");
exit(0);
}
//绑定ip和端口
struct sockaddr_in server_addr;
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(9999);
server_addr.sin_addr.s_addr = inet_addr("127.0.0.1");
if(::bind(serverSock, (struct sockaddr*)&server_addr, sizeof(server_addr)) == -1)
{
printf("bind failed\n");
exit(0);
}
//启动监听
if(listen(serverSock, 20) == -1)
{
printf("listen failed\n");
exit(0);
}
5.3 创建多个 Worker 进程
// fork 出 3 个 Worker 进程
int result;
for(int i = 1; i< 3; i++){
result = fork();
if(result == 0){
startWorker(i,serverSock);
printf("start worker %d\n",i);
break;
}
}
5.4 启动Worker 进程,监听 IO 事件
void startWorker(int workerId,int serverSock)
{
// 创建内核事件队列
int kqueuefd=kqueue();
struct kevent change_list[1]; //想要监控的事件的数组
struct kevent event_list[1]; //用来接受事件的数组
//初始化所需注册事件
EV_SET(&change_list[0], serverSock, EVFILT_READ, EV_ADD | EV_ENABLE, 0, 0, 0);
// 循环接受事件
while (true) {
// 竞争锁,获取监听资格
pthread_mutex_lock(&mm->mutex);
printf("Worker %d get the lock\n",workerId);
// 注册事件,等待通知
int nevents = kevent(kqueuefd, change_list, 1, event_list, 1, NULL);
// 释放锁
pthread_mutex_unlock(&mm->mutex);
//遍历返回的所有就绪事件
for(int i = 0; i< nevents;i++){
struct kevent event =event_list[i];
if(event.ident == serverSock){
// ACCEPT 事件
handleNewConnection(kqueuefd,serverSock);
}else if(event.filter == EVFILT_READ){
//读取客户端传来的数据
char * msg = handleReadFromClient(workerId,event);
handleWriteToClient(workerId,event,msg);
}
}
}
}
5.5 开启多个 Client 进程测试
运行结果:
![2819184-666c9d42e334a25c.jpg](https://i-blog.csdnimg.cn/blog_migrate/f53a0442faf64f45006b6855bd29b70d.webp?x-image-process=image/format,png)
哈哈,基本实现了我的要求。
Demo 源码见:https://github.com/HalfStackDeveloper/LearnNginx
六. 总结
Nginx 之所以有强大的高并发能力,得益于它与众不同的架构设计,无论是多进程还是 IO 多路复用,都是 Nginx 不可或缺的一部分。研究 Nginx 源码十分有趣,但是看源码和动手写又是两回事,看源码只能大概了解脉络,只有自己操刀,才能真正理解和运用!