第3章 进程模型
一般情况下,在启动Nginx后系统将出现多个Nginx进程,每个进程各司其责共同完成对客户端请求处理响应的任务。这些进程各自负责哪些业务逻辑、它们之间是否有交互以及如何交互等是本章将要介绍的内容。
3.1 整体架构
如前面介绍的那样,正常执行起来后的Nginx会有多个进程,最基本的有master_process(即监控进程,也叫主进程)和worker_process(即工作进程),还可能会有Cache相关进程。这些进程之间会相互进行通信,以传递一些信息(主要是监控进程往工作进程传递)。除了自身进程之间的相互通信,Nginx还凭借强悍的功能模块与外界四通八达,比如通过upstream与后端Web服务器通信、依靠fastcgi与后端应用服务器通信等。一个较为完整的整体框架结构如图3-1所示。
图3-1 Nginx整体框架结构图
3.2 核心进程模型
Nginx的进程模型和现在大多数后台服务程序一样,按职责将进程分成监控进程和工作进程两类,启动Nginx的主进程将充当监控进程,而由主进程fork()出来的子进程则充当工作进程。工作进程的任务自然是完成具体的业务逻辑,而监控进程充当整个进程组与用户的交互接口,同时对工作进程进行监护,比如如果某工作进程意外退出,监控进程将重新fork()生成一个新的工作进程。Nginx也可以单进程模型执行,在这种进程模型下,主进程就是工作进程,此时没有监控进程,单进程模型比较简单且官方建议仅供开发与测试使用,所以下面主要分析多进程模型。
分析Nginx多进程模型的入口为主进程的ngx_master_process_cycle()函数,在该函数做完信号处理设置等之后就会调用一个名为ngx_start_worker_processes()的函数用于fork()产生出子进程(子进程数目通过函数调用的第二个实参指定),子进程作为一个新的实体开始充当工作进程的角色执行ngx_worker_process_cycle()函数,该函数主体为一个无限for ( ;; )循环,持续不断地处理客户端的服务请求,而主进程继续执行ngx_master_process_cycle()函数,也就是作为监控进程执行主体for ( ;; )循环,这自然也是一个无限循环,直到进程终止才退出。服务进程基本都是这种写法,所以不用详述,下面先看看图3-2所示的这个模型。
图3-2 Nginx的核心进程模型框图
图3-2表现得很清晰,监控进程和每个工作进程各有一个无限for ( ;; )循环,以便进程持续的等待和处理自己负责的事务,直到进程退出。
3.2.1 监控进程
监控进程的无限for ( ;; )循环内有一个关键的sigsuspend()函数调用,该函数的调用使得监控进程的大部分时间都处于挂起等待状态,直到监控进程接收到信号为止。当监控进程接收到信号时,信号处理函数ngx_signal_handler()就会被执行。我们知道信号处理函数一般都要求足够简单,所以在该函数内执行的动作主要也就是根据当前信号值对相应的旗标变量做设置,而实际的处理逻辑必须放在主体代码里来进行,所以该for ( ;; )循环接下来的代码就是判断有哪些旗标变量被设置而需要处理的,比如ngx_reap(有子进程退出?)、ngx_quit或ngx_terminate(进行要退出或终止?值得注意的是,虽然两个旗标都是表示结束Nginx,不过ngx_quit的结束更优雅,它会让Nginx监控进程做一些清理工作且等待子进程也完全清理并退出之后才终止,而ngx_terminate更为粗暴,不过它通过使用SIGKILL信号能保证在一段时间后必定被结束掉)、ngx_reconfigure(重新加载配置)等。当所有信号都处理完时又挂起在函数sigsuspend()调用处继续等待新的信号,如此反复,构成监控进程的主要执行体。
82: 代码片段3.2.1-1,文件名: ngx_process_cycle.c 83: void 84: ngx_master_process_cycle(ngx_cycle_t *cycle) 85: { 86: … 146: for ( ;; ) { 147: … 170: sigsuspend(&set); 171: … 177: if (ngx_reap) { 178: … 184: if (!live && (ngx_terminate || ngx_quit)) { 185: … 188: if (ngx_terminate) { 189: … 210: if (ngx_quit) { 211: … 212: } 213: …
3.2.2 工作进程
工作进程的执行主体与监控进程类似,不过工作进程既然名为工作进程,那么它的主要关注点就是与客户端或后端真实服务器(此时Nginx作为中间代理)之间的数据可读/可写等I/O交互事件,而不是进程信号,所以工作进程的阻塞点是在像select()、epoll_wait()等这样的I/O多路复用函数调用处,以等待发生数据可读/可写事件,当然,也可能被新收到的进程信号中断。关于I/O多路复用的更多细节,后续章节会详细讲解。
721: 代码片段3.2.2-1,文件名: ngx_process_cycle.c 722: static void 723: ngx_worker_process_cycle(ngx_cycle_t *cycle, void *data) 724: { 725: … 780: for ( ;; ) { 781: 782: if (ngx_exiting) { 783: … 806: ngx_process_events_and_timers(cycle); 807: 808: if (ngx_terminate) { 809: … 810: } 811: …
在代码片段3.2.2-1中,通过函数ngx_process_events_and_timers()调到对应的事件监控阻塞点,即(以epoll_wait()为例)
ngx_process_events_and_timers() -> ngx_process_events()/ngx_epoll_process_ events() ->e poll_wait()
函数epoll_wait()会阻塞等待,一旦有事件发生或收到信号就会立即返回,工作进程也就开始对发生的事件进行逐个处理,关于这部分的具体逻辑,我们暂且不说,等到第7章再看。
3.3 Cache进程模型
如果Nginx开启了缓存功能,比如Proxy Cache,那么Nginx还将创建另外两个Cache相关进程。编写类似如下的Nginx配置文件。
17: 代码片段3.3-1,文件名: nginx.conf 18: worker_processes 1; 19: 20: http { 21: … 22: proxy_cache_path /data/nginx/cache/one levels=1:2 keys_zone=one:10m; 23: 24: server { 25: listen 80; 26: location / { 27: proxy_cache one; 28: proxy_cache_valid 200302 10m; 29: proxy_pass http://load_balance; 30: } 31: }
以该配置文件启动Nginx后,我们就能看到如下4个进程。
[root@localhost nginx-1.2.0]# ps auxf | grep nginx | grep -v grep root 16126 0.0 0.115460576 ? Ss 18:42 0:00 nginx: master process objs/nginx -c /usr/local/nginx/conf/nginx.conf nobody 16127 0.0 0.215636928 ? S 18:42 0:00 \_ nginx: worker process nobody 16128 0.0 0.215612912 ? S 18:42 0:00 \_ nginx: cache manager process nobody 16129 0.0 0.215612804 ? S 18:42 0:00 \_ nginx: cache loader process
从前面的介绍中,我们已经知道master process和worker process各自的功能和内部逻辑,而cache manager process(Cache管理进程)与cache loader process(Cache加载进程)则是与Cache缓存机制相关的进程。它们也是由主进程创建,对应的模型框图如图3-3所示。
图3-3 Nginx的Cache进程模型框图
Cache进程不处理客户端请求,也就没有监控的I/O事件,而其处理的是超时事件,在ngx_process_events_and_timers()函数内执行的事件处理函数只有ngx_event_expire_timers()函数。
3.3.1 Cache管理进程
Cache管理进程与Cache加载进程的主流程都是ngx_cache_manager_process_cycle()函数,但是它们附带的参数不同。管理进程执行到函数ngx_cache_manager_process_cycle()内时,传递的data为ngx_cache_manager_ctx。
68: 代码片段3.3.1-1,文件名: ngx_process_cycle.c 69: static ngx_cache_manager_ctx_t ngx_cache_manager_ctx = { 70: ngx_cache_manager_process_handler, "cache manager process", 0 71: };
结构体ngx_cache_manager_ctx_t的定义如下。
29: 代码片段3.3.1-2,文件名: ngx_process_cycle.h 30: typedef struct { 31: ngx_event_handler_pt handler; 32: char *name; 33: ngx_msec_t delay; 34: } ngx_cache_manager_ctx_t;
再看函数ngx_cache_manager_process_cycle()的具体代码。
1282:代码片段3.3.1-3,文件名: ngx_process_cycle.c 1283:static void 1284:ngx_cache_manager_process_cycle(ngx_cycle_t *cycle, void *data) 1285: { 1286: ngx_cache_manager_ctx_t *ctx = data; 1287: 1288: void *ident[4]; 1289: ngx_event_t ev; 1290: … 1297: ngx_close_listening_sockets(cycle); 1298: 1299: ngx_memzero(&ev, sizeof(ngx_event_t)); 1300: ev.handler = ctx->handler; 1301: ev.data = ident; 1302: ev.log = cycle->log; 1303: ident[3] = (void *) -1; 1304: … 1309: ngx_add_timer(&ev, ctx->delay);
Cache管理进程不接收客户端请求,所以在代码第1297行关闭了监听套接口。其他代码创建了一个事件对象并设置了对应的超时事件。注意两点。第一,代码第1303行并没有特别的设定功能,仅只是因为事件对象的data字段一般挂载的是connect对象,此处设置为-1刚好是把connect对象的fd字段设置为-1,以避免在其他代码里走到异常逻辑。第二,此处ctx->delay为0,因此立即超时,执行对应的函数。
ngx_process_events_and_timers() -> ngx_event_expire_timers() -> ngx_cache_ manager_process_handler()
函数ngx_cache_manager_process_handler()的处理很简单,它会调用每一个磁盘缓存管理对象的manager()函数,然后重新设置事件对象的下一次超时时刻后返回。
1328:代码片段3.3.1-4,文件名: ngx_process_cycle.c 1329:static void 1330:ngx_cache_manager_process_handler(ngx_event_t *ev) 1331: { 1332: … 1338: path = ngx_cycle->pathes.elts; 1339: for (i = 0; i < ngx_cycle->pathes.nelts; i++) { 1340: 1341: if (path[i]->manager) { 1342: n = path[i]->manager(path[i]->data); 1343: … 1347: } 1348: } 1349: … 1354: ngx_add_timer(ev, next * 1000); 1355: }
对于我们这里的示例,对应的manager()函数为ngx_http_file_cache_manager()函数,这是Nginx在调用函数ngx_http_file_cache_set_slot()解析配置指令proxy_cache_path时设置的回调值。函数ngx_http_file_cache_manager()做了两件事情,首先删除已过期的缓存文件,然后检查缓存文件总大小是否超限,如果超限则进行强制删除。代码如下。
1312:代码片段3.3.1-5,文件名: ngx_http_file_cache.c 1313:static time_t 1314:ngx_http_file_cache_manager(void *data) 1315: { 1316: … 1321: next = ngx_http_file_cache_expire(cache); 1322: … 1326: for ( ;; ) { 1327: … 1336: size = cache->sh->size; 1337: … 1336: if (size < cache->max_size) { 1337: return next; 1338: } 1339: 1340: wait = ngx_http_file_cache_forced_expire(cache); 1341: 1342: if (wait > 0) { 1343: return wait; 1344: } 1345: … 1349: } 1350: }
代码逻辑容易理解,代码第1342行的判断为真则表示当前缓存文件(如果存在)都在使用中,所以需直接返回等待,避免CPU空旋for ( ;; )循序导致CPU计算能力的浪费。
总结来说,Cache管理进程的任务就是清理超时缓存文件,限制缓存文件总大小,这个过程反反复复,直到Nginx整个进程退出为止。
3.3.2 Cache加载进程
以3.3-1配置代码执行的Nginx在一开始会有4个进程,但在一段时间后,Cache加载进程将消失,这是因为Cache加载进程的功能是在Nginx正常启动后(具体是60秒)将磁盘中上次缓存的对象加载到内存中。可以看到,这个过程是一次性的,所以当Cache加载进程完成它的加载任务后也就自动退出了。
Cache加载进程执行的到ngx_cache_manager_process_cycle()为止的上层函数调用与Cache管理进程一致,但在该函数内设置的事件对象回调函数为ngx_cache_loader_process_handler()。
72: 代码片段3.3.2-1,文件名: ngx_process_cycle.c 73: static ngx_cache_manager_ctx_t ngx_cache_loader_ctx = { 74: ngx_cache_loader_process_handler, "cache loader process", 60000 75: };
事件对象的超时时间为60000毫秒。函数ngx_cache_loader_process_handler()执行的是每一个磁盘缓存管理对象的loader()回调函数。
1357:代码片段3.3.2-2,文件名: ngx_process_cycle.c 1358:static void 1359:ngx_cache_loader_process_handler(ngx_event_t *ev) 1360: { 1361: … 1367: path = cycle->pathes.elts; 1368: for (i = 0; i < cycle->pathes.nelts; i++) { 1369: … 1374: if (path[i]->loader) { 1375: path[i]->loader(path[i]->data); 1376: ngx_time_update(); 1377: } 1378: } 1379: 1380: exit(0); 1381: }
注意代码第1380行的exit(0)函数调用,可见Cache加载进程的执行逻辑是一次性的。同样的设置流程,对于我们这里的示例,对应的loader()函数被设置为ngx_http_file_cache_loader()函数,该函数给磁盘缓存管理对象对应路径下已有的缓存文件建立对应的红黑树,从而让Nginx可以继续使用上次缓存的文件。
3.4 进程通信
运行在多进程模型的Nginx在正常工作时,自然就会有多个进程实例,例如,图3-4是在配置worker_processes 4;情况下的显示,Nginx设置的进程title能很好地帮助我们区分监控进程与工作进程,不过带上选项f的ps命令以树目录的形式打印各个进程信息也能帮助我们做这个区分。多进程联合工作必定要牵扯到进程之间的通信问题,下面就来看看Nginx是如何做的(仅关注监控进程与工作进程)。
图3-4 Nginx进程树
采用socketpair()函数创造一对未命名的UNIX域套接字来进行Linux下具有亲缘关系的进程之间的双向通信是一个非常不错的解决方案。Nginx就是这么做的,先看fork()生成新工作进程的ngx_spawn_process()函数以及相关代码。
21: 代码片段3.4-1,文件名: ngx_process.h 22: typedef struct { 23: ngx_pid_t pid; 24: int status; 25: ngx_socket_t channel[2]; 26: … 27: } ngx_process_t; 28: … 47: #define NGX_MAX_PROCESSES 1024 35: 代码片段3.4-2,文件名: ngx_process.c 36: ngx_process_t ngx_processes[NGX_MAX_PROCESSES]; 37: 86: ngx_pid_t 87: ngx_spawn_process(ngx_cycle_t *cycle, ngx_spawn_proc_pt proc, void *data, 88: char *name, ngx_int_t respawn) 89: { 90: … 117: if (socketpair(AF_UNIX, SOCK_STREAM, 0, ngx_processes[s].channel) == -1) 118: … 186: pid = fork(); 187: …
在该函数进行fork()之前,先调用了socketpair()创建一对socket描述符存放在变量ngx_processes[s].channel内(其中s标志在ngx_processes数组内第一个可用元素的下标,比如最开始产生第一个工作进程时,可用元素的下标s为0),而在fork()之后,由于子进程继承了父进程的资源,那么父子进程就都有了这一对socket描述符,而Nginx将channel[0]给父进程使用,channel[1]给子进程使用,这样分别错开地使用不同socket描述符,即可实现父子进程之间的双向通信。
图3-5 利用socketpair()创建socket描述符对
除此之外,对于各个子进程之间,也可以进行双向通信。如前面所述,父子进程的通信channel设定是自然而然的事情,而子进程之间的通信channel设定就涉及进程之间文件描述符(socket描述符也属于文件描述符)的传递,因为虽然在后生成的子进程通过继承的channel[0]能够往在前生成的子进程发送信息,但在前生成的子进程无法获知在后生成子进程的channel[0]而不能发送信息,所以在后生成的子进程必须利用已知的在前生成子进程的channel[0]进行主动告知。
在子进程的启动初始化函数ngx_worker_process_init()里,会把ngx_channel(也就是channel[1])加入到读事件监听集里,对应的回调处理函数为ngx_channel_handler()。
834: 代码片段3.4-3,文件名: ngx_process_cycle.c 835: static void 836: ngx_worker_process_init(ngx_cycle_t *cycle, ngx_uint_t priority) 837: { 838: … 994: if (ngx_add_channel_event(cycle, ngx_channel, NGX_READ_EVENT, 995: ngx_channel_handler) 996: == NGX_ERROR) 997: { 998: …
而在父进程fork()生成一个新子进程后,就会立即通过ngx_pass_open_channel()函数把这个子进程的相关信息告知给其前面已生成的子进程。
430: 代码片段3.4-4,文件名: ngx_process_cycle.c 431: static void 432: ngx_pass_open_channel(ngx_cycle_t *cycle, ngx_channel_t *ch) 433: { 434: 436: for (i = 0; i < ngx_last_process; i++) { 437: … 453: ngx_write_channel(ngx_processes[i].channel[0], 454: ch, sizeof(ngx_channel_t), cycle->log); 455: } 456: }
其中参数ch里包含了刚创建的新子进程(假定为A)的pid、进程信息在全局数组里存储下标、socket描述符channel[0]等信息,这里通过for循环遍历所有存活的其他子进程,然后调用函数ngx_write_channel()通过继承的channel[0]描述符进行信息主动告知,而收到这些消息的子进程将执行设置好的回调函数ngx_channel_handler(),把接收到的新子进程A的相关信息存储在全局变量ngx_processes内。
1066:代码片段3.4-5,文件名: ngx_process_cycle.c 1067:static void 1068:ngx_channel_handler(ngx_event_t *ev) 1069: { 1070: … 1126: case NGX_CMD_OPEN_CHANNEL: 1127: … 1132: ngx_processes[ch.slot].pid = ch.pid; 1133: ngx_processes[ch.slot].channel[0] = ch.fd; 1134: break; 1135: …
这样,前后子进程都有了对方的相关信息,相互通信也就没有问题了。这其中有一些具体实现细节没有提到,这里也不打算详说,直接看一下表3-1中的实例,就以上面显示的各个父子进程为例。
表3-1 Nginx父子进程通信Channel实例
表3-1中,{a, b}分别表示channel[0]和channel[1]的值,-1表示这之前是描述符,但在其后被主动close()掉了,0表示这一直都无对应的描述符,其他数字表示对应的描述符值。每一列数据都表示该列所对应进程与其它进程进行通信的描述符,如果当前列所对应进程为父进程,那么它与其它进程进行通信的描述符都为channel[0];如果当前列所对应进程为子进程,那么它与父进程进行通信的描述符为channel[1],与其它子进程进行通信的描述符都为channel[0]。比如,带*的{3, 7}表示如果父进程8706向子进程8707发送消息,需使用channel[0],即描述符3,它的channel[1]为7,没有被close()关闭掉,但一直也都没有被使用,所以没有影响,不过按道理应该关闭才是。而带**的{-1, 7}表示如果子进程8707向父进程8706发送消息(注意该数据所处的行位置,如果是子进程8709与父进程8706进行通信,那么使用的描述符将是带#3的{-1, 11}所对应的channel[1],即描述符11),需使用channel[1],即描述符7,它的channel[0]为-1表示已经close()关闭掉了(Nginx某些地方调用close()时并没有设置对应变量为-1,我这里为了更好说明,将已经close()掉的描述符全部标记为-1了)。
越是后生成的子进程,其channel[0]与父进程的对应channel[0]值相同的越多,因为基本都是继承而来,但前面生成的子进程的channel[0]是通过传递获得的,所以与父进程的对应channel[0]不一定相等。比如如果子进程8707向子进程8710发送消息,需使用channel[0],即描述符10,而对应的父进程channel[0]却是12。虽然它们在各自进程里表现为不同的整型数字,但在内核里表示同一个描述符结构,即不管是子进程8707往描述符10写数据还是父进程8706往描述符12写数据,子进程8710都能通过描述符13正确读取到这些数据,至于子进程8710怎么识别它读到的数据是来自子进程8707还是父进程8706,就得靠其收到的数据特征(比如pid字段)来做标记区分。
最后,就目前Nginx代码来看,子进程并没有往父进程发送任何消息,子进程之间也没有相互通信的逻辑。也许是因为Nginx有其他一些更好的进程通信方式,比如共享内存等,所以这种channel通信目前仅做为父进程往子进程发送消息使用。但由于有这个基础在这,如果未来要使用channel做这样的事情,的确是可以的。
3.5 共享内存
共享内存是Linux下进程之间进行数据通信的最有效方式之一,而Nginx就为我们提供了统一的操作接口来使用共享内存。
在Nginx里,一块完整的共享内存以结构体ngx_shm_zone_t来封装表示,其中包括的字段有共享内存的名称(shm_zone[i].shm.name)、大小(shm_zone[i].shm.size)、标签(shm_zone[i].tag)、分配内存的起始地址(shm_zone[i].shm.addr)以及初始回调函数(shm_zone[i].init)等。
24: 代码片段3.5-1,文件名: ngx_cycle.h 25: typedef struct ngx_shm_zone_s ngx_shm_zone_t; 26: … 27: struct ngx_shm_zone_s { 28: void *data; 29: ngx_shm_t shm; 30: ngx_shm_zone_init_pt init; 31: void *tag; 32: };
这些字段大都容易理解,只有tag字段需要解释一下,因为看上去它和name字段有点重复,而事实上,name字段主要用作共享内存的唯一标识,它能让Nginx知道我想使用哪个共享内存,但它没法让Nginx区分我到底是想新创建一个共享内存,还是使用那个已存在的旧的共享内存。举个例子,模块A创建了共享内存sa,模块A或另外一个模块B再以同样的名称sa去获取共享内存,那么此时Nginx是返回模块A已创建的那个共享内存sa给模块A/模块B,还是直接以共享内存名重复提示模块A/模块B出错呢?不管Nginx采用哪种做法都有另外一种情况出错,所以新增一个tag字段做冲突标识,该字段一般也就指向当前模块的ngx_module_t变量即可。这样在上面的例子中,通过tag字段的帮助,如果模块A/模块B再以同样的名称sa去获取模块A已创建的共享内存sa,模块A将获得它之前创建的共享内存的引用(因为模块A前后两次请求的tag相同),而模块B则将获得共享内存已做他用的错误提示(因为模块B请求的tag与之前模块A请求时的tag不同)。
当我们要使用一个共享内存时,总会在配置文件里加上该共享内存的相关配置信息,而Nginx在进行配置解析的过程中,根据这些配置信息就会创建对应的共享内存,不过此时的创建仅仅只是代表共享内存的结构体ngx_shm_zone_t变量的创建,这具体实现在函数shared_memory_add()内。另外从这个函数中,我们也可以看到Nginx使用的所有共享内存都以list链表的形式组织在全局变量cf->cycle->shared_memory下,在创建新的共享内存之前会先对该链表进行遍历查找以及冲突检测,对于已经存在且不存在冲突的共享内存可直接返回引用。以ngx_http_limit_req_module模块为例,它需要的共享内存在配置文件里以limit_req_zone配置项出现。
limit_req_zone $binary_remote_addr zone=one:10m rate=1r/s;
Nginx在进行配置解析时,遇到limit_req_zone配置项则调用其对应的处理函数ngx_http_limit_req_zone(),而在该函数内又将继续调用函数shared_memory_add()创建对应的ngx_shm_zone_t结构体变量并加入到全局链表内。
ngx_http_limit_req_zone() -> ngx_shared_memory_add() -> ngx_list_push()
共享内存的真正创建是在配置文件全部解析完后,所有代表共享内存的结构体ngx_shm_zone_t变量以链表的形式挂接在全局变量cf->cycle->shared_memory下,Nginx此时遍历该链表并逐个进行实际创建,即分配内存、管理机制(比如锁、slab)初始化等。
398: 代码片段3.5-2,文件名: ngx_cycle.c 399: /* create shared memory */ 400: 401: part = &cycle->shared_memory.part; 402: shm_zone = part->elts; 403: 404: for (i = 0; /* void */ ; i++) { 405: … 467: if (ngx_shm_alloc(&shm_zone[i].shm) != NGX_OK) { 468: … 471: if (ngx_init_zone_pool(cycle, &shm_zone[i]) != NGX_OK) { 472: … 475: if (shm_zone[i].init(&shm_zone[i], NULL) != NGX_OK) { 476: ... 477: }
其中函数ngx_shm_alloc()是共享内存的实际分配,针对当前系统可提供接口,可以是mmap或shmget等。而ngx_init_zone_pool()函数是共享内存管理机制的初始化,因为共享内存的使用涉及到另外两个主题:第一,既然是共享内存,那么必然是多进程共同使用,所以必须考虑互斥问题;第二,Nginx既以性能著称,那么对于共享内存自然也有其独特的使用方式,虽然我们可以不用(在马上要介绍到的init回调函数里做覆盖处理即可),但在这里也默认都会以这种slab的高效访问机制进行初始化。关于这两点,这里暂且略过,待后续再做讨论。
回调函数shm_zone[i].init()是各个共享内存所特定的,根据使用方的自身需求不同而不同,这也是我们在使用共享内存时需特别注意的函数。继续看实例ngx_http_limit_req_module模块的init函数ngx_http_limit_req_init_zone()。
398: 代码片段3.5-3,文件名: ngx_http_limit_req_module.c 399: static ngx_int_t 400: ngx_http_limit_req_init_zone(ngx_shm_zone_t *shm_zone, void *data) 401: { 402: ngx_http_limit_req_ctx_t *octx = data; 403: … 398: if (octx) { 399: … 608: ctx->shpool = octx->shpool; 609: … 608: return NGX_OK; 609: } 610: 611: ctx->shpool = (ngx_slab_pool_t *) shm_zone->shm.addr; 612: … 608: ctx->sh = ngx_slab_alloc(ctx->shpool, sizeof(ngx_http_limit_req_ shctx_t)); 609: …
函数ngx_http_limit_req_init_zone()的第二个参数data表示“旧”数据,在进行重新加载配置时(即Nginx收到SIGHUP信号)该值将不为空。如果旧数据可继续使用,那么可直接返回NGX_OK;否则,需根据自身模块逻辑对共享内存的使用做相关初始化,比如ngx_http_limit_req_module模块,在第634、642行直接使用默认已初始化好的slab机制,进行内存的分配等。当函数ngx_http_limit_req_init_zone()正确执行结束,一个完整的共享内存就已创建并初始完成,接着要做的就是共享内存的使用,这即回到前面提到的两个主题:互斥与slab。
要解决互斥问题,无非就是利用锁机制,强制同一时刻只能有一个进程在访问共享内存,其基本原理就是利用共享的简单资源(比如最简单的原子变量)来代表复杂资源,一个进程在需要操作复杂资源之前先获得对简单资源的使用权限。因为简单资源足够简单,对它的使用权限的获取往往只有一步或几步,所以更容易避免冲突。这个应该是容易理解的,比如一个需要100步的操作肯定比一个只需要3步的操作更容易发生冲突(每一步需要的复杂度相同),因为前一种情况可能会发生一个进程在进行了99步后却因另外一个进程发出动作而失败的情况,而后一种情况的进程执行完3步后就已经获得完全使用权限了。
要讲清楚Nginx互斥锁的实现,如果不结合具体的代码恐怕是不行的,因为都是一些细节上的考量,比如根据各种不同的CPU架构选择不同的汇编指令、使用不同的共享简单资源(原子变量或文件描述符),并没有什么特别难以理解的地方,查CPU手册和系统Man手册很容易懂,所以具体实现这里暂且不讲。Nginx互斥锁的使用非常简单,提供的接口函数以及含义如表3-2。
表3-2 Nginx互斥锁接口函数
3.6 slab机制
Nginx的slab机制与Linux的slab机制在基本原理上并没有什么特别大的不同(当然,相比而言,Linux的slab机制要复杂得多),简单来说也就是基于两点:缓存与对齐。缓存意味着预分配,即提前申请好内存并对内存做好划分形成内存池,当我们需要使用一块内存空间时,Nginx就直接从已经申请并划分好的内存池里取出一块合适大小的内存即可,而内存的释放也是把内存返还给Nginx的内存池,而不是操作系统;对齐则意味着内存的申请与分配总是按2的幂次方进行,即内存大小总是为8、16、32、64等,比如,虽然只申请33个字节的内存,但也将获得实际64字节可用大小的内存,这的确存在一些内存浪费,但对于内存性能的提升是显著的,更重要的是把内部碎片也掌握在可控的范围内。
Nginx的slab机制主要是和共享内存一起使用,前面提到对于共享内存,Nginx在解析完配置文件,把即将使用的共享内存全部以list链表的形式组织在全局变量cf->cycle->shared_memory下之后,就会统一进行实际的内存分配,而Nginx的slab机制要做的就是对这些共享内存进行进一步的内部划分与管理。关于这点,从函数ngx_slab_init()的逻辑即可初见端倪。不过在此之前,先看看ngx_init_zone_pool()函数对它的调用。
916: 代码片段3.6-1,文件名: ngx_slab.c 917: static ngx_int_t 918: ngx_init_zone_pool(ngx_cycle_t *cycle, ngx_shm_zone_t *zn) 919: { 920: u_char *file; 921: ngx_slab_pool_t *sp; 922: 923: sp = (ngx_slab_pool_t *) zn->shm.addr; 924: … 937: sp->end = zn->shm.addr + zn->shm.size; 938: sp->min_shift = 3; 939: sp->addr = zn->shm.addr; 940: … 960: ngx_slab_init(sp); 961: …
函数ngx_init_zone_pool()是在共享内存分配好后进行的初始化调用,而该函数内又调用了本节介绍的重点对象slab的初始化函数ngx_slab_init();,此时的情况如图3-6所示。
图3-6 共享内存初始布局图
可以看到此时共享内存的开始部分内存已经被用作结构体ngx_slab_pool_t的存储空间,这相当于是slab机制的额外开销(overhead),后面还会看到其他额外开销,任何一种管理机制都有自己的一些控制信息需要存储,所以这些内存使用是无法避免的。共享内存剩下的部分才是被管理的主体,slab机制对这部分内存进行两级管理,首先是page页,然后是page页内的slab块(通过slot对相等大小的slab块进行管理,为了区分slab机制,下面以slot块来指代这些slab块),也就是说slot块是在page页内存的再一次管理。
在继续对slab机制分析之前,先看看下面这个表格里记录的一些变量以及其对应的值,因为它们可以帮助我们对后面内容的理解。这些变量会根据系统环境的不同而不同,但一旦系统环境确定,那么这些值也就将都是一些常量值,表3-3基于的系统环境在本书最开始有统一介绍,这里不再赘述。
表3-3 常变量的值与描述
再来看slab机制对page页的管理,初始结构示意图如图3-7所示。
图3-7 slab机制的page页管理
slab机制对page页的静态管理主要体现在ngx_slab_page_t[K]和page[N] 这两个数组上,需要解释几点。
第一,虽然是一个页管理结构(即ngx_slab_page_t元素)与一个page内存页相对应,但因为有对齐消耗以及slot块管理结构体的占用(图中的ngx_slab_page_t[n]数组),所以实际上页管理结构体数目比page页内存数目要多,即图中的ngx_slab_page_t[N]到ngx_slab_page_t[K-1],这些结构体完全被忽视,我们也不用去管它们,只是需要知道有这些东西的存在。
第二,如何根据页管理结构page获得对应内存页的起始地址p?计算方法如下。
384: 代码片段3.6-2,文件名: ngx_slab.c 385: p = (page - pool->pages) << ngx_pagesize_shift; 386: p += (uintptr_t) pool->start;
对照前面图示来看这很明显,无需过多解释;相反,根据内存页的起始地址p也能计算出其对应的页管理结构page。
第三,对齐是指实际page内存页按ngx_pagesize大小对齐,从图中看就是原本的start是那个虚线箭头所指的位置,对齐后就是实线箭头所指的位置,对齐能提高对内存页的访问速度,但这有一些内存浪费,并且末尾可能因为不够一个page内存页而被浪费掉,所以在ngx_slab_init()函数的最末尾有一次最终可用内存页的准确调整。
75: 代码片段3.6-3,文件名: ngx_cycle.c 76: void 77: ngx_slab_init(ngx_slab_pool_t *pool) 78: { 79: … 130: m = pages - (pool->end - pool->start) / ngx_pagesize; 131: if (m > 0) { 132: pages -= m; 133: pool->pages->slab = pages; 134: } 135: …
代码第130行计算的m值如果大于0,说明对齐等操作导致实际可用内存页数减少,所以后面的if语句进行判断调整。
page页的静态管理结构基本就是如此了,再来看page页的动态管理,即page页的申请与释放,这就稍微麻烦一点,因为一旦page页被申请或释放,那么就有了相应的状态:使用或空闲。先看空闲页的管理,Nginx对空闲page页进行链式管理,链表的头节点pool->free,初始状态下的链表情况如图3-8所示。
图3-8 slab机制的空闲页管理链表
这是一个有点特别的链表,它的节点可以是一个数组,比如图3-8中的ngx_slab_page_t[N]数组就是一个链表节点,这个数组通过第0号数组元素,即ngx_slab_page_t[0],接入到这个空闲page页链表内,并且整个数组的元素个数也记录在这个第0号数组元素的slab字段内。
如果经历如下几步内存操作:子进程1从共享内存中申请1页,子进程2接着申请了2页,然后子进程1又释放掉刚申请的1页,那么空闲链表各是一个什么状态呢?逐步来看。
子进程1从共享内存中申请1页,如图3-9所示。
图3-9 子进程1从共享内存中申请1页
子进程2接着申请了2页,如图3-10所示。
图3-10 子进程2接着申请了2页
然后子进程1又释放掉刚申请的1页,如图3-11所示。
图3-11 子进程1又释放掉刚申请的1页
释放的page页被插入到链表头部,如果子进程2接着释放其拥有的那2页内存,那么空闲链表结构将如图3-12所示。
图3-12 空闲链表结构示意图
可以看到,Nginx对空闲page页的链式管理不会进行节点合并,不过关系不大,毕竟page页既不是slab机制的最小管理单元,也不是其主要分配单元。对处于使用状态中的page页,也是采用的链式管理,在介绍其详细之前,需先来看看slab机制的第二级管理机制,即slot块,这样便于前后的连贯理解。
slot块是对每一页page内存的内部管理,它将page页划分成很多小块,各个page页的slot块大小可以不相等,但同一个page页的slot块大小一定相等。page页的状态通过其所在的链表即可辨明,而page页内各个slot块的状态却需要一个额外的标记,在Nginx的具体实现里采用的是位图方式,即一个bit位标记一个对应slot块的状态,1为使用,0为空闲。
根据slot块的大小不同,一个page页可划分的slot块数也不同,从而需要的位图大小也不一样。前面提到过,每一个page页对应一个名为ngx_slab_page_t的管理结构,该结构体有一个uintptr_t类型的slab字段。在32位平台上(也就是本书讨论的设定平台),uintptr_t类型占4个字节,即slab字段有32个bit位。如果page页划分的slot块数小于等于32,那么Nginx直接利用该字段充当位图,这在Nginx内叫exact划分,每个slot块的大小保存在全局变量ngx_slab_exact_size以及ngx_slab_exact_shift内。比如,1个4KB的page页,如果每个slot块大小为128字节,那么恰好可划分成32块。图3-13是这种划分下的一种可能的中间情况。
图3-13 page页可能存在的slot块数划分情况
如果划分的每个slot块比ngx_slab_exact_size还大,那意味着一个page页划分的slot块数更少,此时当然也是使用ngx_slab_page_t结构体的slab字段作为位图。由于比ngx_slab_exact_size大的划分可以有很多种,所以需要把其具体的大小也记录下来,这个值同样也记录在slab字段里。这样做是可行的,由于划分总是按2次幂增长,所以比ngx_slab_exact_size还大的划分至少要减少一半的slot块数,因此利用slab字段的一半bit位即可完整表示所有slot块的状态。具体点说就是:slab字段的高端bit用作位图,低端bit用于存储slot块大小(仅存其对应的移位数)。代码如下。
378: 代码片段3.6-4: ngx_slab.c 379: page->slab = ((uintptr_t) 1 << NGX_SLAB_MAP_SHIFT) | shift;
如果申请的内存大于等于ngx_slab_max_size,Nginx直接返回一个page整页,此时已经不在slot块管理里,所有无需讨论。下面来看小于ngx_slab_exact_size的情况,此时slot块数目已经超出了slab字段可表示的容量。比如假设按8字节划分,那么1个4KB的page页将被划分为512块,表示各个slot块状态的位图也就需要512个bit位,一个slab字段明显是不足够的,所以需要为位图另找存储空间,而slab字段仅用于存储slot块大小(仅存其对应的移位数)。
另找的位图存储空间就落在page页内,具体点说是其划分的前面几个slot块内。接着刚才说的例子,512个bit位的位图,即64个字节,而一个slot块有8个字节,所以就需要占用page页的前8个slot块用作位图。一个按8字节划分slot块的page页初始情况如图3-14所示。
图3-14 按8字节划分slot块的page页初始情况
由于前几个slot块一开始就被用作位图空间,所以必须把它们对应的bit位设置为1,表示其状态为使用。
不论哪种情况,都有了slot块的大小以及状态,那对slot块的分配与释放就水到渠成了。下面回到slab机制的最后一个话题,即对处于使用状态中的page页的链式管理。其实很简单,首先,根据每页划分的slot块大小,将各个page页加入到不同的链表内。在我们这里设定的平台上,也就是按8、16、32、64、128、256、512、1024、2048一共9条链表,在ngx_slab_init()函数里有其初始化。
102: 代码片段3.6-5,文件名: ngx_slab.c 103: n = ngx_pagesize_shift - pool->min_shift; 104: 105: for (i = 0; i < n; i++) { 106: slots[i].slab = 0; 107: slots[i].next = &slots[i]; 108: slots[i].prev = 0; 109: }
假设申请一块8字节的内存,那么slab机制将一共分配page那么多页,将它按8字节做slot划分,并且接入到链表slots[0]内,相关示例(表示这只是其中一处实现)代码如下。
352: 代码片段3.6-6,文件名: ngx_slab.c 353: page->slab = shift; 354: page->next = &slots[slot]; 355: page->prev = (uintptr_t) &slots[slot] | NGX_SLAB_SMALL; 356: 357: slots[slot].next = page;
page->prev按4字节对齐,所以末尾两位可以用做他用,这里用于标记当前slot划分类型为NGX_SLAB_SMALL,如图3-15所示。
图3-15 NGX_SLAB_SMALL类型的slot划分
继续申请8字节的内存不会分配新的page页,除非刚才那页page(暂且称之为页A)被全是使用完,一旦页A被使用完,它会被拆除出链表,相关示例代码如下。
232: 代码片段3.6-7,文件名: ngx_slab.c 233: prev = (ngx_slab_page_t *) 234: (page->prev & ~NGX_SLAB_PAGE_MASK); 235: prev->next = page->next; 236: page->next->prev = page->prev; 237: 238: page->next = NULL; 239: page->prev = NGX_SLAB_SMALL;
第234行是过滤掉末尾的标记位,以获得正确的前一节点的地址,如图3-16所示。
图3-16 页A使用完后拆除出链表
如果仍然继续申请8字节的内存,那么Nginx的slab机制必须分配新的page页(暂且称之为页B),类似于前面介绍的那样,页B会被加入到链表内,此时链表中只有一个节点,但如果此时页A释放了某个slot块,它又会被加入到链表中,终于形成了具有两个节点的链表,相关示例代码(变量page指向页A)如下,如图3-17所示。
图3-17 页A重新加入到链表
455: 代码片段3.6-8,文件名: ngx_slab.c 456: page->next = slots[slot].next; 457: slots[slot].next = page; 458: 459: page->prev = (uintptr_t) &slots[slot] | NGX_SLAB_SMALL; 460: page->next->prev = (uintptr_t) page | NGX_SLAB_SMALL;
3.7 信号处理
通过对signal信号的处理,使得Nginx支持与用户进行信息交互,从而实现某些特定功能,比如在不中止Nginx服务的情况下更新配置。在前面曾简单提到过Nginx各类进程对信号的处理,下面详细来看。
3.7.1 准备工作
Nginx对所有发往其自身的信号进行了统一管理,其封装了一个对应的ngx_signal_t结构体来描述一个信号。
13: 代码片段3.7.1-1,文件名: ngx_process.c 14: typedef struct { 15: int signo; 16: char *signame; 17: char *name; 18: void (*handler)(int signo); 19: } ngx_signal_t;
其中字段signo也就是对应的信号值,比如SIGHUP、SIGINT等,当然这是宏,其具体在库头文件signal.h内有定义,比如宏SIGHUP就是数值1。
[root@localhost ~]# grep SIGHUP /usr/include/*/signal.h /usr/include/asm-generic/signal.h:#define SIGHUP 1 /usr/include/asm/signal.h:#define SIGHUP 1
字段signame为信号名,信号值所对应宏的字符串,比如"SIGHUP"。字段name和信号名不一样,名称表明该信号的自定义作用,即Nginx根据自身对该信号的使用功能而设定的一个字符串,比如SIGHUP用于实现“在不中止Nginx服务的情况下更新配置”的功能,所以对应的该字段为"reload"。字段handler,处理信号的回调函数指针,未直接忽略的信号,其处理函数全部为函数ngx_signal_handler()。
有了描述单个信号的结构体后,Nginx定义了一个ngx_signal_t数组类型的全局变量signals,把它将要处理的信号全部罗列在其中,看其中的几个元素示例。
38: 代码片段3.7.1-2,文件名: ngx_process.c 39: ngx_signal_t signals[] = { 40: { ngx_signal_value(NGX_RECONFIGURE_SIGNAL), 41: "SIG" ngx_value(NGX_RECONFIGURE_SIGNAL), 42: "reload", 43: ngx_signal_handler }, 44: … 80: { SIGPIPE, "SIGPIPE, SIG_IGN", "", SIG_IGN }, 81: 82: { 0, NULL, "", NULL } 83: };
ngx_signal_value()、ngx_value()等几个都是宏,虽然展开后也很简单,但是它用到了一点额外的知识,如表3-4所示。另外,在C语言代码中,以空格隔开的连续的多个字符串会自动连接,比如两个字符串"SIG" "HUP"将自动组合为"SIGHUP"。
表3-4 字符串宏操作
有了上面这些介绍,那么对于如下所示的signals[0]的各个字段值就很容易理解了。
{1, "SIGHUP", "reload", ngx_signal_handler}
对于signals数组的后面几个元素,其回调函数为SIG_IGN,表示忽略该信号,这和不做设置是不一样的。如果不做设置,那么将按系统默认的处理进行,而这里主动设置为忽略,也是对其的一种处理方式。数组最后一个元素的各个字段为0或NULL,这是把它当末尾哨兵使用,这是不定数组的惯用手法,以便后续能方便地对它做遍历(因为结束条件的判断就由哨兵把持即可)。
3.7.2 设置生效
做好了准备工作,接下来就要对它们进行设置以便生效,进而在Nginx收到信号时能调用对应的回调函数进行处理。
在Nginx的启动流程里,有一个下面这样的函数调用。
main() -> ngx_init_signals()
即由函数ngx_init_signals()完成信号的设置工作。
282: 代码片段3.7.2-1,文件名: ngx_process.c 283: ngx_int_t 284: ngx_init_signals(ngx_log_t *log) 285: { 286: ngx_signal_t *sig; 287: struct sigaction sa; 288: 289: for (sig = signals; sig->signo != 0; sig++) { 290: ngx_memzero(&sa, sizeof(struct sigaction)); 291: sa.sa_handler = sig->handler; 292: sigemptyset(&sa.sa_mask); 293: if (sigaction(sig->signo, &sa, NULL) == -1) { 294: ngx_log_error(NGX_LOG_EMERG, log, ngx_errno, 295: "sigaction(%s) failed", sig->signame); 296: return NGX_ERROR; 297: } 298: } 299: 300: return NGX_OK; 301: }
了解API函数sigaction()的话,上面代码很容易看懂,在这里我们也看到了signals数组末尾哨兵的功能。
对信号进行设置并生效是在fork()函数调用之前进行的,所以工作进程等都能受此作用。当然,一般情况下,我们不会向工作进程等子进程发送控制信息,而主要是向监控进程父进程发送,父进程收到信号做相应处理后,再根据情况看是否要把信号再通知到其他所有子进程。
3.7.3 处理实例
本小节以惯用的“在不间断Nginx服务的情况下更新Nginx使用配置”为例,来看下Nginx的整个处理过程。
我们知道,在一般情况下,Nginx主进程总是阻塞在sigsuspend()函数调用点,以等待接收信号,通过pstack命令可以验证这一情况(注意:命令后半句的前后并不是单引号字符,而是反引号)。
[root@localhost ~]# pstack `cat /usr/local/nginx/logs/nginx.pid` #0 0x00fe6424 in __kernel_vsyscall () #1 0x00af0ec7 in sigsuspend () from /lib/libc.so.6 #2 0x08070b29 in ngx_master_process_cycle () #3 0x0804b81f in main ()
如果Nginx主进程被直接kill-9掉了,那么函数sigsuspend()将得不到返回,此时Nginx相关子进程也无法获得主进程发送的信号(因为主进程根本就没机会做这个动作),我们这里不考虑这种情况。如果Nginx主进程收到并捕获了信号,比如用户通过kill命令进行信号发送。
[root@localhost ~]# kill -s SIGHUP 'cat /usr/local/nginx/logs/nginx.pid'
那么函数sigsuspend()将在对应的信号处理函数执行完之后才返回继续处理,这点很重要,看Nginx的信号处理回调函数ngx_signal_handler()。
349: 代码片段3.7.3-1,文件名: ngx_process.c 350: case ngx_signal_value(NGX_RECONFIGURE_SIGNAL): 351: ngx_reconfigure = 1; 352: action = ", reconfiguring"; 353: break;
对信号的处理非常的简单,仅根据其收到的信号对相应的全局变量进行置位操作,这符合信号处理函数要求简单快速的一般特点。
函数ngx_signal_handler()处理完后返回,进而函数sigsuspend()返回,从而主进程可以执行后面的代码。
145: 代码片段3.7.3-2,文件名: ngx_process_cycle.c 146: for ( ;; ) { 147: … 170: sigsuspend(&set); 171: … 227: if (ngx_reconfigure) { 228: ngx_reconfigure = 0; 229: … 260: } 261: … 284: if (ngx_noaccept) { 285: ngx_noaccept = 0; 286: ngx_noaccepting = 1; 287: ngx_signal_worker_processes(cycle, 288: ngx_signal_value(NGX_SHUTDOWN_SIGNAL)); 289: } 290: }
第170行的函数sigsuspend()返回后,后面对全局变量进行判断,发现置位了,那么就开始处理,当然,在处理前先把该全局变量复位,以免下次重复进入。如代码第287行所示,如果有必要,会利用函数ngx_signal_worker_processes()再把信号值发送给子进程,而子进程收到信号的处理也大致类似,这无需多说。
这里要注意一下信号发送函数ngx_signal_worker_processes(),它首先通过父子进之间的channel调用函数ngx_write_channel()进行信号传递,当这种方法失败时才利用kill()函数,相关代码逻辑也比较简单,略过不提。
另外,除了通过kill命令向Nginx发送信号外,Nginx本身封装了几个对信号的发送工作,这主要是通过Nginx的命令行参数选项提供的,比如stop,quit,reopen,reload,而其内部实现也是通过读取当前正在执行的Nginx进程所对应的nginx.pid文件,获得它对应的pid,然后调用kill()函数进行信号发送,对应的函数调用流程为
main() -> ngx_signal_process() -> ngx_os_signal_process()
由于nginx.pid文件路径可以通过配置指令pid指定。如果当前执行的Nginx进程与准备发送信号的Nginx程序使用的是不同的配置,并且配置文件中指定的nginx.pid文件路径不同,那么将可能出现发送失败的情况。比如,当前正在执行的Nginx进程使用的配置是nginx.conf.old,指定的pid路径为pid /usr/local/nginx/conf/nginx.pid.old;,而此时用默认配置执行nginx -s reload将获得如下错误。
[root@localhost nginx-1.2.0]# objs/nginx -s reload nginx: [error] open() "/usr/local/nginx/logs/nginx.pid" failed (2: No such file or directory)