在写 nginx 的 filter 模块时候需要比较深入去了解chain 和 buf的一些细节以及数据流处理过程。
就结构而言,这两个结构不算复杂
struct ngx_chain_s {
ngx_buf_t *buf;
ngx_chain_t *next;
};
struct ngx_buf_s {
u_char *pos;
u_char *last;
off_t file_pos;
off_t file_last;
u_char *start; /* start of buffer */
u_char *end; /* end of buffer */
ngx_buf_tag_t tag;
ngx_file_t *file;
ngx_buf_t *shadow;
/* the buf's content could be changed */
unsigned temporary:1;
/*
* the buf's content is in a memory cache or in a read only memory
* and must not be changed
*/
unsigned memory:1;
/* the buf's content is mmap()ed and must not be changed */
unsigned mmap:1;
unsigned recycled:1;
unsigned in_file:1;
unsigned flush:1;
unsigned sync:1;
unsigned last_buf:1;
unsigned last_in_chain:1;
unsigned last_shadow:1;
unsigned temp_file:1;
/* STUB */ int num;
};
很明显可以看出,chain是起到一个链条的作用,把要往外发送到数据串起来,buf是一个缓冲区块管理的结构,里面记录了这个缓冲区的起始、结束的位置,已经发送的数据的游标,还包括了一些标记位,这些标记位很重要。
至此,我们脑海里面大致可以有一个这样的印象,每个chain下面挂接一个buf,而chain是用next这个成员指针一个接一个连起来,nginx内部会将逐个chain传给 filter 模块处理,filter 模块会有多个,每个filter 模块处理完了就会把处理好的chan传给下一个filter,这个在很多资料里面都有提过了,这里着重讨论的是这样设计目的是什么,在写一个filter 模块的时候有什么难点需要考虑。
其实,我们可以把chain看成是诸如视频中的“帧”的概念,我们知道处理视频数据流一般都会按“帧”来处理,但是帧的大小一般是固定的,而且帧与帧之间是相对独立的。可是作为http数据流,chain与chain之间往往会发生比较多的关联。
举个反面的简单例子:chunked_filter ,这个filter就需要在chain之间插入chunk的信息,这个问题还好办,在chain之间插入新chain就可以了。
可是多数情况下是不那么简单的,再看一些文本替换的模块如sub_filter需要面对什么问题,我们知道假设有一个字符串 aabbccddeef f,如果我们要匹配替换bbcc的话当字符串放在是一个线性内存的空间的话是很容易的事情,但是如果把字符串分成两段,aabb ccddeeff 分别放在两个不同的chain的时候,问题就变得稍复杂了,如果是自己设计状态机去匹配,那就是先发现bb,然后记住这个状态,再去下一个chain去尝试查找cc,这是一种办法;如果是用正则去匹配,就得先把两个chain的内容先做合并,再做匹配,在这里如何处理好两个,两个以上的chain之间的数据就需要做额外多的工作了。
要知道,在系统运行的过程中,完整的http正文并不是无时无刻、任何情况下放在chain链上供我们的filter模块访问的,在filter中chain链的机制只是方便我们去增补我们数据,而不是提供一个完整的http正文给我们随意操作的。这个理由我想很简单,如果所有要发送到内容都在chain链上了,那么要做什么修改当然都很方便,但是占用内存就十分可观了,我们可以推算一下,我们都知道nginx可以轻松接入10k连接,乘一下每请求的数据大小就不难得同一时刻要消耗多少内存了。再一个很重要的一点就是,如果数据不在本地而是从代理端过来的话,如果想用户尽早接受到数据,也应该是一边接收一边发送,而不是全部接收完了再发送给用户。
事实上,多数的filter在实现上都是在原有的chain前后放入自己的chain就能满足需求了,例如上面提到的 chunked,sub,还有ssi 的filter实现都这样做,之前网络上面的入门教程也是给出例子的,这样做好处是逻辑简单,足够方便,只要chain链不搞乱,程序基本不会出大问题,但是,如果我们想在filter里面做更多的事情,这样显然这些例子是不能满足我们的需要。
我认为:在http数据流之中不加限制的的插入chain或者把一个chain拆成几个chain以达到filter的作用是不恰当的,这里有首先有两个考虑:
1、每级filter在处理的时候都需要遍历一次传入的chain链,说到这里,很有必要分享一个数据默认配置下经过我测试,一个1M左右文件,如果nginx是读取本地文件的话会产生大约30多个chain,如果是反向代理访问的话,会有200多个chain,我们试想下,在反向代理的请求中如果一个 filter 做了2、3次chain分裂那将会有接近上千个chain产生,这个数量是比较客观的,要知道,其他 filter 也会产生chain分裂的,当然了,我们也可以写一个filter 来合并chain。
2、一般情况下,在发送数据的时候unix如果用 write 或 sendfile 每次只能发送一块数据,如果chain很多的话,例如达到了 1000 以上,这个就比较影响效率的了,最坏情况下,1000个chain要调用1000次syscall才能发送出去,这个消耗不能被忽视的;当然了,nginx这里是有一些优化的,在发送的模块里面内部会把内存相邻的两个chain并成一个内存块来发送,而且经过观察,其实nginx是调用writev 这个syscall来发送数据的,这样可以减少很多进出内核的次数。
我相信,既然会搞出1k个chain那肯定有场景会搞出10k个chain,对于一个接入10k连接的Server来说,由chain链的造成的代价更是不容忽视的,我们不要忘记,我们是如何对 select() 这个接口在c10k下的效率耿耿于怀的,因为这里同样也是要做大量的遍历。
分析到这里,我想基本上应该可以有一个结论:一个请求的生命周期中chain的数量要适度控制,filter在处理的数据的时候要尽量避免分裂出新的chain插入chain链中,如果能在原有的buf上面修改数据是上策,但是这个理想的情况我认为也是有点不大现实的,因为 chain下的 buf 大小是被认为是不可变长的,你可以把其中内容经过filter占用的内存空间小了,这个可以,但是,要是经过filter处理后变大怎么办?那就只能把chain下的 buf 换掉了,或者把这个chain drop掉,换一个新chain上来。
换chain或者换buf这个办法看起来在较多的情景下都能适用,这里有不少问题需要注意
首先,以sub的模块为例:
static ngx_int_t ngx_http_sub_body_filter(ngx_http_request_t *r, ngx_chain_t *in);
如接口所示,在 filter 中我们只能得到一个chain,并且可以在这个chain前面或者后面插入chain,但是当前请求的chain链是在别的地方,当前模块用正常途径是访问不到的,不要指望 ngx_http_request_t *r 里面有可以访问当前请求chain链的指针,我看过了,没有。因此我们不能指望通过修改当前的chain链去摘掉一个chain及下面的buf。
但是,如果企图对这个chain置之不理,直接往下级传入一个新chain,会有两个问题:
1、在上层调用的地方会认为这个原来的chain没发送出去,然后整个请求就挂死在这里。
2、chain的内存没有被及时释放,这样会造成内存堆积。
对于第二个问题,我们可以验证下,可以写一个空的 filter ,然后把每次传入的chain的buf的pos成员地址打印出来看看,当然这个前提是这个http的正文body要足够大,使得nginx会分拆成若干个chain来处理,我们会发现pos的地址多数情况下都是同一个地址,因此我们可以知道chain跟buf肯定是被复用了,当一个chain被发送出去之后,就会被认为是空chain再度被投入循环使用,就像一个一个水桶接力运水一样。用猪又的话来说,这种流式的设计是很省内存的。
讨论到这里,我们就会发现要实现一个优雅的filter 这也不行,那也不行,确实如此,这里是需要花很多心思去考虑周详,在各种利弊之间取得一个平衡。
后面有时间再分享下,策略上,我们有哪些好思路。
待续,未完。