代理服务器中的内容防拷贝技术

代理服务器,广义上包括正向反向代理,httpcache等等,他们都要处理一个问题,就是从上游取得数据让后发往下游。这块有许多细节要处理,特别是如何避免内容的多次拷贝。ngixn在这方面做得很不错,我们拿它来分析下,希望能学到一些东西。
nginx的这块处理主要发生在两个函数,或者说一对函数:ngx_event_pipe_read_upstream和ngx_event_pipe_write_downstream。这里不讨论他们在upstream扮演的角色,我们只看他们是如何漂亮的操作这些复杂的流程,协调各种buffer和chain。

在读取后端数据时,nginx使用的是recv_chain,这种接口使用的是chain,所以在调用之前毫无疑问需要准备相应的chain来作为接收道具,其中一个便是p->free_raw_bufs(p类型为ngx_event_pipe_t)。这些chain对于recv_chain来说并不一定能完全用完,nginx在这么一个循环里面处理:

        cl = chain;
        p->free_raw_bufs = NULL;

        while (cl && n > 0) {

            ngx_event_pipe_remove_shadow_links(cl->buf);

            size = cl->buf->end - cl->buf->last;

            if (n >= size) {
                cl->buf->last = cl->buf->end;
                if (p->input_filter(p, cl->buf) == NGX_ERROR) {
                    return NGX_ABORT;
                }

                n -= size;
                ln = cl;
                cl = cl->next;
                ngx_free_chain(p->pool, ln);

            } else {
                cl->buf->last += n;
                n = 0;
            }
        }

        if (cl) {
            for (ln = cl; ln->next; ln = ln->next) { /* void */ }

            ln->next = p->free_raw_bufs;
            p->free_raw_bufs = cl;
        }

上面这块代码里面,将当前recv_chain用完的chain回收到pool里去,剩下的重新放回p->free_raw_bufs(即代码中的ngx_free_chain(p->pool, ln))。这里有个问题就是,recv_chain用的那些chain,不是还没有进一步处理嘛,怎么就回收了?其实回收的是chain而不是buffer,两者的身份不一样,chain相当于火车皮,buffer才是实际运输的原料。所以这里火车皮确实是用完了,我们还到回收器中备用。那么buffer呢?看这句p->input_filter(p, cl->buf),这里面就是处理的实际buffer,我们以ngx_http_proxy_copy_filter为例来看看里面到底发生了什么,当然含有其他input_filter。
static ngx_int_t
ngx_http_proxy_copy_filter(ngx_event_pipe_t *p, ngx_buf_t *buf)
{
    ngx_buf_t           *b;
    ngx_chain_t         *cl;
    ngx_http_request_t  *r;

    if (buf->pos == buf->last) {
        return NGX_OK;
    }

    if (p->free) {
        cl = p->free;
        b = cl->buf;
        p->free = cl->next;
        ngx_free_chain(p->pool, cl);

    } else {
        b = ngx_alloc_buf(p->pool);
        if (b == NULL) {
            return NGX_ERROR;
        }
    }

    ngx_memcpy(b, buf, sizeof(ngx_buf_t));
    b->shadow = buf;
    b->tag = p->tag;
    b->last_shadow = 1;
    b->recycled = 1;
    buf->shadow = b;

    cl = ngx_alloc_chain_link(p->pool);
    if (cl == NULL) {
        return NGX_ERROR;
    }

    cl->buf = b;
    cl->next = NULL;

    if (p->in) {
        *p->last_in = cl;
    } else {
        p->in = cl;
    }
    p->last_in = &cl->next;

    if (p->length == -1) {
        return NGX_OK;
    }

    ...
    return NGX_OK;
}
这里面实际上是为每个buffer生成了一个所谓的影子(即shadow buffer),然后将这个shadow挂到p->in上面。当然开始的时候,这个shadow buffer需要我们alloc空间,后面p->free中会有很多现成的buffer给我们用。这里注意shadow buffer的设置:
    ngx_memcpy(b, buf, sizeof(ngx_buf_t)); 
    b->shadow = buf;
    b->tag = p->tag;
    b->last_shadow = 1;
    b->recycled = 1;
    buf->shadow = b;
原始buffer,我们称为raw buffer,整体赋值给影子buffer(成员也被一并拷贝),即shadow buffer。然后两者通过shadow成员进行了相互关联。那么怎么区分谁是raw buffer,谁才是“真正”的影子呢?答案是last_shadow,这个诡异的东西下面会进一步讨论。
 
到这里我们看到,从后端读取的原始数据,还在原来的buffer里面。现在又多了出来一串影子,他们搬上新火车皮,搭上了p->in这趟列车。在继续讨论之前,先把这个p的几个成员贴出来:
struct ngx_event_pipe_s {
    ...
    ngx_chain_t       *free_raw_bufs;
    ngx_chain_t       *in;
    ngx_chain_t      **last_in;

    ngx_chain_t       *out;
    ngx_chain_t       *free;
    ngx_chain_t       *busy;
    ...
}
在后续接收内容,申请更多raw buffer受阻时,会将p->in中的数据写到临时文件中,这些曾经的shadow(指p->in),也会被搁到p->free中,这个东西可以看做是shadow buffer的回收站(当然在回收的时候,shadow之间需要断绝关系,因为那都是些往事了),当你需要一个buffer做shadow时,可以先来这里瞧瞧有没有货,没有再去求助于pool_alloc。还有那些原先保存数据的raw buffer也应该还到free_raw_bufs中,内存得到了重用。而写到磁盘文件中的数据,也会抽象成buffer(in_file标记),只不过它会挂到p->out上去。结构中的last_in不用说了,就是指向p->in中的最后一个chain。
 
通过上面的分析,我们得知待处理的buffer可能在p->in或者p->out中,接下来就是将它们发送出去了,你会看到ngx_event_pipe_write_to_downstream就是专业干这活的。这里主要描述大体的轮廓和个别细节,而不去注释代码。不是我懒,而且当你对一个过程的轮廓有所了解之后,读代码会变得异常容易,这样我省去了指头儿的磨损,你也能学到更多的东西,而不只是“hi honey, open your mouth!”。
 
先发p->out。为什么?本来数据在p->in中,后面不是由于特殊状况都写到临时文件中(即p->out管理的)嘛。会不会p->out有数据,p->in里面也有呢?当然会,正是由于p->out解围,才腾出了数量可观的buffer来给后续的处理使用。实际的发送动作,则是通过p->output_filter(p->output_ctx, out)来完成。这个就不说了,说起来没完,不懂的可以google。
 
当然了我们想发的数据可能不会一次发完,也就是说p->in或者p->out的数据可能一部分发出去一部分却没有。已经发出去的,相关的chain会放到p->free中,被憋住的那些放到p->busy中(busy!着急啊。。)。这些事都发生在ngx_chain_update_chains中,可以去仔细读读代码。很明显,这些p->free里面都是些shadow buffer,马甲而已。既然数据已经发去出了,实际的raw buffer也应该被回收到p->free_raw_bufs。
总之这两块差不多就这些大的流程,如果还有人对这块流程有些迷糊,希望我画画图来进一步描述一下的话,说明你可能在某些地方给暂时绊住了,再读读代码或者留言讨论下,反正有一个事实就是我很懒,不想画图(说实话这块画画图好理解一些!)。
剩下的就是细节了,比如buf结构体中的各种标记,这里将它们列出(引自 http://tengine.taobao.org/book/chapter_4.html):
        unsigned         recycled:1; /* 内存可以被输出并回收 */
        unsigned         in_file:1;  /* buffer的内容在文件中 */
        /* 马上全部输出buffer的内容, gzip模块里面用得比较多 */
        unsigned         flush:1;
        /* 
         * 基本上是一段输出链的最后一个buffer带的标志,标示可以输出,
         * 有些零长度的buffer也可以置该标志
         */
        unsigned         sync:1;
        /* 所有请求里面最后一块buffer,包含子请求 */
        unsigned         last_buf:1;
        /* 当前请求输出链的最后一块buffer         */
        unsigned         last_in_chain:1;
        /* shadow链里面的最后buffer,可以释放buffer了 */
        unsigned         last_shadow:1;
        /* 是否是暂存文件 */
        unsigned         temp_file:1;
对于这些标记,怎么确定它们的用处和含义呢?没什么别的办法,第一,找到哪些地方对他们进行了设置(置位与清零)。第二,找到哪些地方对他们进行了判断处理,当然还需要对这些标记在这两个地方的上下文有所了解,总之挺麻烦的。依我看里面那个sync标记最含糊,字面上是同步,但是在使用的时候,却丝毫没发现跟同步有什么关系,****(脏话)。
 
还有一个函数ngx_event_pipe_remove_shadow_links,它里面有个处理:
ngx_event_pipe_remove_shadow_links(ngx_buf_t *buf)
{
    ...
    while (!b->last_shadow) {
        next = b->shadow;

        b->temporary = 0;
        b->recycled = 0;

        b->shadow = NULL;
        b = next;
    }
    ...
}
看起来这些buf似乎通过shadow构成了一个链,这怎么解释?前面的分析里面完全没有相关的过程。哈哈,看这里:ngx_http_proxy_chunked_filter
在这个函数里面,每个含有原始数据的raw buf(即ngx_http_proxy_chunked_filter第二个参数buf)中,可能包含多个chunk块,他们每一个都用一个新的buf来管理,彼此之间通过shadow连在一起,而最后一个buf(被设置了last_shadow)会跟这个raw buf建立shadow关系。为啥这个buf才是真正的raw buf的shadow呢?因为真正的shadow buf的成员跟raw buf是完全镜像的,而这些管理中间chunk的那些buf虽然也自称是“shadow”,但是他们的却只是映射了raw buf的一部分,半成品而已。换句话说真正的shadow都是last_shadow。
由于chunk造成的这种内部关系对外界是透明的(是一种input_filter),所以我们在拿到一些free buf来用处理的时候,要去除这种潜在的shadow关联,因为那都是些过去的事,账一直没有算清罢了。而ngx_event_pipe_remove_shadow_links就是担当这种清理工的角色。
 
nginx的这套对接方式只是使用在将上游的响应转发到下游的阶段,对于含有请求体的请求,如post请求,却没用使用这套机制,我在想为什么?应该是这样子吧,nginx将上游数据往下游分发是无条件的,拿到请求以后要尽可能快的给下游吐数据。而对于post请求,可能需要得到上游的批准("100-continue"响应),又需要尽快将下游提交的数据接受完。一般情况下不需要第一时间去跟上游沟通,nginx就选择先将请求体给接收完,然后再做后面的事。不过也有很多需求是希望将读到的内容第一个时间交给上游,那怎么办?
tengine在他们1.5.0版本里实现了no buffered request body sent,即收到部分请求体即可以转发给后端服务器,作者是姚伟斌。
Tengine-1.5.0 [2013-07-31]
...
Feature: 增加了请求体不缓存到磁盘的机制,HTTP代理和FastCGI模块收到部分请求体即可以转发给后端服务器 [yaoweibin]
...
我粗略看了下实现,大概的样子是在request里引入了free,busy,out等chain成员,然后模仿了一些前面我们讨论的关于上游往下游转发的一些逻辑。更多细节可以到 http://tengine.taobao.org/changelog_cn.html#1_5_0查看。
 
在我们的cache系统中,没有去参考nginx这套逻辑。为什么呢,难道你们有货?还真有!我们用了系统调用splice,所谓的零拷贝技术。这东西最开始出现在linux 2.6.17版本中,这个版本是在2006年release的,而我们cache项目刚好也是在那个时候开始搞,刚好排上用场!何必再去用nginx那套复杂机制,自找没趣。反观nginx,最早开源出来大概是在2002年,那个时候还是linux 2.6.0x的时代,实在没有多少牛B的东西可用。我认为Igor也是去搜寻过的,估计两手空空,所以才决定自己搞起。nginx的这套逻辑,淘宝给起了一个名字叫buffer防拷贝技术,刚开始听说的时候瞬间被唬住了。后来才知道就是上面说的那套东西。。。
 
扯远了。这里稍微说一下splice的用法:
splice() moves data between two file descriptors without copying between kernel address space and user address space. It transfers up to len bytes of data from the file descriptor fd_in to the file descriptor fd_out, where one of the descriptors must refer to a pipe.
/* splice必须用到pipe,所以开始的时候你要初始化一个pipe */
int pdf[2];
pipe(pfd);

/* 假设rfd接收数据的socket fd, wfd是输出数据的socket fd */

/* 将rfd的数据读到pfd[1]中,当然数据转移的过程发生在内核 */
splice(rfd, NULL, pfd[1], NULL, size, flags);

/* 从pfd[0]中,可以将rfd写入的数据发送到wfd */
splice(pfd[0], NULL, wfd, NULL, size, flags);
其他的参数请查阅man手册,网上也有很多示例代码,大家可以学习下。哎,技术的进步让码农干活越来越轻松了。此刻要是没有内核支持的那些零拷贝技术,你有信心写出igro那套逻辑吗?反正我没有,如果你有那个能力,请麻烦留言告诉我,我们做个朋友。

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值