再谈nginx的worker子进程

1,418 views
Skip to first unread message

Jinhua Luo

unread,
Aug 26, 2015, 11:25:03 PM8/26/15
to openresty
hi。

 
众所周知,nginx分为多个worker子进程(一般每个CPU核一个worker),每个子进程独立去accept新连接。
一旦连接进入了某个worker之后,就只能被这个worker来处理了,这种连接的分配并不是以每个worker的工作负载来决定的。
假设每个连接都是长连接,连接内的每个请求的处理都是CPU-bound类型,例如jpeg到webp的图片格式转换,图片大小是随机的,所以处理时间在1ms到10s的范围里浮动,那么这时候就有问题了,假设A worker和B worker都accept了10个连接,而B worker里面的请求很快做完了,A worker的某个图片还在处理中,要等3s后才能处理完,而A的请求队列里还有3个请求在等待,但却不能切换到B去处理,导致明明有CPU资源也用不上。
如果使用多线程,线程间抢夺请求来处理,这样整体性能会好很多,平均响应时间也会小很多。
对于http 2.0来说,每个连接内的多个请求可以独立进行处理和响应,上述问题会更加严重。

 > 众所周知,nginx分为多个worker子进程(一般每个CPU核一个worker),每个子进程独立去accept新连接。
> 一旦连接进入了某个worker之后,就只能被这个worker来处理了,这种连接的分配并不是以每个worker的工作负载来决定的。
事实上,如果某个 worker 进程较忙的话,就会由其他 worker 进程 accept 新连接。毕竟繁忙的 worker 响应
accept 事件的机会同比小一些。这里重要的是,禁掉 accept_mutex 配置,因为它会导致各 worker accept
新连接不够均衡:
    http://nginx.org/en/docs/ngx_core_module.html#accept_mutex
同时不要开启 multi_accept 配置选项。
> 假设每个连接都是长连接,连接内的每个请求的处理都是CPU-bound类型,例如jpeg到webp的图片格式转换,图片大小是随机的,所以处理时间在1ms到10s的范围里浮动,那么这时候就有问题了,假设A
> worker和B worker都accept了10个连接,
你这里的前提就不成立,显然 CPU 使用率不同的 nginx worker 进程响应 accept 事件的频率也不尽相同。
> 对于http 2.0来说,每个连接内的多个请求可以独立进行处理和响应,上述问题会更加严重。
http 2.0 和 spdy 在协议设计上面确实很容易被攻击者滥用而导致服务器过载。所以一般最好在做累活的 nginx 之前放一个专用的
https 网关 nginx 实例(二者之间可以通过 unix domain socket 进行本地通信,这样也利于规避 SSL
计算引入的额外的阻塞效应)。


这里我再澄清一下,我知道繁忙的worker能接受的连接数同比会降低,在连接层面,我觉得是worker间是能均衡的。但是我这里指的是长连接进入某个worker后,连接内的请求被处理的不均匀,而请求无法在worker之间迁移。
举个例子,某个时刻A worker很繁忙,所以它只接受了1个新连接,而B worker比较空闲,它接受了10个新连接,在那个时刻,这样的分配是合理的。
但是,如果A的那1个连接里面的请求对应的处理时间比那B的那10个连接的请求要大的话,就会导致CPU资源分配不均匀了。
例如A的那1个连接里的多个请求平均处理时间是3s,而B的10个连接的请求平均处理时间为3ms,那到达某个时间点,B worker做完所有请求了,而A worker里面还有大量请求被堆积,但却没法被迁移到B worker去处理。
总而言之,我的问题就是nginx的worker之间的调度单位是连接,而不是请求,所以如果连接对应的是长连接,并且连接里面的请求对应的处理时间浮动很大很随机,那么nginx的处理能力就有问题了。
http1.1的长连接尚且如此,那http2.0天生就利用长连接来干活,连接内的请求互相独立,这个问题就更严重了。
反观golang的goroutine设计,每个goroutine可以用来表示一个请求的处理,而goroutine可以在多个线程(对应多个CPU核)之间迁移,使得每个CPU核都能够被充分利用,这样的模型会不会比nginx要好呢?


我还有一点困惑,nginx使用的是进程模型,每个进程的地址空间是独立的,那么进程间需要共享数据的时候就要用到共享内存。但进程间对共享内存的操作绝大多数都不是原子操作,例如nginx的slab,分配一块内存的操作分为很多个步骤,要对共享内存中不同的位置进行写。假设进程在执行操作期间崩溃了,有些步骤没做完,例如重置指针的值,就会使得共享内存处于一个不一致的状态,这种状态是没法恢复的,因为其他进程无法知道这个进程留下什么烂摊子给它收拾,也没有一个进程为此负责,因为进程间都是对等的,所以这个不一致的状态很难被察觉和调试的,而系统继续运行会产生很多意想不到的错误;另外,操作前后难免要上锁解锁,例如pthread锁(文件锁倒是进程退出后OS可以帮你清理的,但相比pthread锁更耗时,因为涉及系统调用和内核介入),那么崩溃后,这个锁没法被重置,导致其他等待该锁的进程被锁死。所以一般多进程程序如果要对共享内存读写,都是由一个主控进程来进行写,其他进程来读,这样才可以避免上述情况。nginx似乎没考虑到这个问题?

Yichun Zhang (agentzh)

unread,
Aug 26, 2015, 11:45:08 PM8/26/15
to openresty
Hello!

2015-08-27 11:25 GMT+08:00 Jinhua Luo:
> 这里我再澄清一下,我知道繁忙的worker能接受的连接数同比会降低,在连接层面,我觉得是worker间是能均衡的。但是我这里指的是长连接进入某个worker后,连接内的请求被处理的不均匀,而请求无法在worker之间迁移。

连接在不同 worker 进程之间的动态迁移无疑极大地增加了实现的复杂度,且即使实现,开销也不会小,导致没有多少实际价值。如果你偶尔有一些计算密度非常高的任务,或许将这些任务委托给
nginx 新引入的线程池来执行更合适一些。比如 ngx_lua 模块未来会暴露线程池的 Lua API.

> 反观golang的goroutine设计,每个goroutine可以用来表示一个请求的处理,而goroutine可以在多个线程(对应多个CPU核)之间迁移,使得每个CPU核都能够被充分利用,这样的模型会不会比nginx要好呢?
>

最一般的多线程模型确实在理论上更为灵活,但其工程代价也是高昂的。确保线程安全通常会耗费大量的开发者时间,也极易引入 bug. 我很高兴我在
nginx 的上下文中几乎不用考虑线程同步和线程安全的问题。

是采用多进程模型还是一般的多线程模型本身就是一种工程上的折衷。较新的 nginx 发布引入内部线程池也是一种折衷。

> 我还有一点困惑,nginx使用的是进程模型,每个进程的地址空间是独立的,那么进程间需要共享数据的时候就要用到共享内存。但进程间对共享内存的操作绝大多数都不是原子操作,例如nginx的slab,分配一块内存的操作分为很多个步骤,要对共享内存中不同的位置进行写。假设进程在执行操作期间崩溃了,

在 nginx worker 进程持有共享内存的锁的期间发生内存错误的可能性是极小的,同时这里的锁同步方式也足够简单。因为这样的代码本身就很少。而一般的多线程模型,则任意一处内存问题都会导致整个服务进程崩溃。

当然了,作为服务进程,本身就不应有任何内存问题。一旦发现,就应第一时间修复 :)

> 那么崩溃后,这个锁没法被重置,导致其他等待该锁的进程被锁死。

事实上,nginx 的 master 进程对此有保护,会在持有锁的 worker 进程导常退出后清理它持有的
mutex,所以并不会导致锁死。见 nginx 核心中的 ngx_process_get_status() 函数。

Regards,
-agentzh

Jinhua Luo

unread,
Aug 27, 2015, 12:45:08 AM8/27/15
to openresty
感谢回复!
其实我也正想提及nginx线程池,确实,不仅仅只能阻塞进行的block device io可以委托给这些线程做,也可以将cpu bound的任务委托给它们做。
只是现在ngx_lua没有导出API,暂时没法在lua层面去利用。

再引申说一下,如果引入线程,那么共享内存的问题就更凸显了。
目前worker进程是单线程,确实只要确保对共享内存操作的那一系列步骤不出问题即可,如果有问题,确实可以归类为内存问题。
但是如果引入工作线程后,如果worker附属的工作线程遇到问题而崩溃,那么就有可能使得worker主线程恰好在操作共享内存期间退出,这时候就会遇到我之前说的状态不一致的烂摊子问题。
当然,我也觉得大部分导致线程/进程崩溃的原因都是bug(小部分情况是恶意去kill或者内存不足被内核kill),所以修复了就好,只是我很想强调一下这些“危险窗口”的存在。

在 2015年8月27日星期四 UTC+8上午11:45:08,agentzh写道:

Yichun Zhang (agentzh)

unread,
Aug 27, 2015, 1:11:18 AM8/27/15
to openresty
Hello!

2015-08-27 12:45 GMT+08:00 Jinhua Luo:
> 其实我也正想提及nginx线程池,确实,不仅仅只能阻塞进行的block device io可以委托给这些线程做,也可以将cpu
> bound的任务委托给它们做。
> 只是现在ngx_lua没有导出API,暂时没法在lua层面去利用。
>
> 再引申说一下,如果引入线程,那么共享内存的问题就更凸显了。

在线程池内部去访问共享内存感觉是对线程池的一种滥用。只有相对独立的计算,比如多媒体数据的处理和复杂的科学计算,才应委托给线程池。否则便是滥用。

ngx_lua 暴露出 API 也不会允许在 OS 线程处理程序中使用所有现有的 Lua API.
出于显然易见的原因。只有确保线程安全的代码才应放进那个上下文中去执行。之前已经提过了,线程池并不能包治百病,需要谨慎使用。

Regards,
-agentzh

Jinhua Luo

unread,
Aug 27, 2015, 1:36:53 AM8/27/15
to openresty
章哥误解了,我的意思并非是从线程池的线程去访问共享内存,这当然是不妥当的,也没有这个必要,线程池本身执行的任务应该是特定的独立逻辑。
我指的是worker进程本来是单线程,所以如果对共享内存操作期间出问题,那肯定是共享内存操作本身的代码问题,这些代码只要确保没有bug,那么就没问题了。
但如果引入线程池后,这些线程是从worker进程pthread_create出来的吧?也就是说worker进程此时是多线程了,那么如果这些多出来的线程本身执行的时候出问题了(不是访问共享内存,而是做自己的事情),就会导致整个worker进程退出,如果退出的时刻恰好是worker进程(主线程)自身在访问共享内存,那就有问题了。
当然我可能多虑了,一切问题都是bug,只要经过严格测试,一般情况也不会有问题。

在 2015年8月27日星期四 UTC+8下午1:11:18,agentzh写道:

Yichun Zhang (agentzh)

unread,
Aug 27, 2015, 1:47:24 AM8/27/15
to openresty
Hello!

2015-08-27 13:36 GMT+08:00 Jinhua Luo:
> 但如果引入线程池后,这些线程是从worker进程pthread_create出来的吧?也就是说worker进程此时是多线程了,那么如果这些多出来的线程本身执行的时候出问题了(不是访问共享内存,而是做自己的事情),就会导致整个worker进程退出,如果退出的时刻恰好是worker进程(主线程)自身在访问共享内存,那就有问题了。

是的,这也是为什么放入线程池的计算也应该是经过良好定义和相对独立的。这样出问题的可能性也不会很大。

> 当然我可能多虑了,一切问题都是bug,只要经过严格测试,一般情况也不会有问题。
>

这也是为什么我们要引入 Lua 这层抽象的原因。尽量避免引入不安全的代码。

Regards,
-agentzh
Reply all
Reply to author
Forward
0 new messages