前文 动态内存管理 中描述 过,Nginx 的 slab allcator 基本上相当于为了进行共享内存的管理而开发的一个近似 通用的内存管理器。本文将要详细分析一下 slab allocator 的创建、内存申请和释放 的实现过程。

slab allocator 代码中用了相当多的位操作,很大一部分操作和 slab allocator 的 分级相关。从 2^3 bytes开始,到 pagesize/2 bytes 为止,提供 2^3, 2^4, 2^5, ..., 2^(ngx_pagesize_shift - 1)ngx_pagesize_shift - 3 个内存片段 大小等级。

在分配时、Nginx 的 slab allocator 使用 BEST FIT 策略, 从和申请的内存块大小 最接近的 slot 中分配一个 chunk。同时,一个 slab 大小为了个页内存块大小 (通过 getpagesize 调用获得,一般为 4K)。

这种分级机制,会造成部分内存浪费 (internal fragmentation),但是大大降低了管理的 复杂性,代码实现起来也比较简单。也算是一种典型的拿空间换时间的样例了。

为了方便叙述,我们将

  • 用于表示内存大小分级的 ngx_slab_pool_t 称为 slot。整个分级区域称为 slots 数组

  • 用于实际内存分配的 pagesize 大小的内存块称为 slab。整个可用内存块区域称为 slab 数组

  • 用于管理 slabngx_slab_pool_t 称为 slab 管理结构体。整个管理结构 体区域称为 slab 管理结构体数组

  • slab 从切分出来的用于完于内存请求的内存块称为 chunk。用于跟踪整个 slabchunk 的使用情况的结构称为 bitmap

  • slot 的分级是按照 2 的幂次方来的,而用来计算 2 的幂次方时,移位操作又是最给 力的。我们将每个 slot 对应的幂值称为 shift,那么这个 slot 能够分配的 chunk 大小就是 1 << shift。这个 slot 申请来的 slab 可以切分出 1 << (ngx_pagesize_shift - shift)chunk

位操作

在分析真正开始代码分析之前,先来分析一下 slab allocator 用到的主要的几个位操 作。

  • 2 ^ pool->min_shiftslab allocator 能够分配的最小级别 chunk 大小。其 它级别的内存块按倍增加 (*2),相当于 1 << (min_shift++)

    pool->min_size = 1 << pool->min_shift;
    
  • slots 元素个数,即 slab allocator 的分级。 各分级对应的 shift 允许值区间 是 [pool->min_shift, ngx_pagesize_shift - 1],也就是说,slab allocator 总共 分了 ngx_pagesize_shift - pool->min_shift 个级。

    n = ngx_pagesize_shift - pool->min_shift;
    
  • slab allocator 的最大级别 chunk 大小是 pagesize1/2,由全局变量 ngx_slab_max_size 表示。在 32 位系统 pagesize4K 的环境下, ngx_slab_max_size 值是 2K

    ngx_slab_max_size = ngx_pagesize / 2;
    
  • 一个 uintptr_t 类型的变量作为 bitmap 来使用时,可以用来跟踪 8 * sizeof(uintptr_t)chunk 的使用状态。“刚刚好” 可以用一个 uintptr_t 空间的 bitmap 能够跟踪一个 slab 中所有的 chunk 时,chunk 的大小就是 ngx_slab_exact_size,它所属 slotshift 值就是 ngx_slab_exact_shift。 在 32位系统、pagesize4K 的环境下,ngx_slab_exact_size 值为 128Bngx_slab_exact_shift 值为 7。

    ngx_slab_exact_size = ngx_pagesize / (8 * sizeof(uintptr_t));
    for (n = ngx_slab_exact_size; n >>= 1; ngx_slab_exact_shift++);
    
  • slab allocator 中申请 size 大小的内存,根据 "BEST FIT" 策略,先要找到 chunk 大小和 size 最接近的 slot。 前两行找到 >= size 的最小 2 ^ shift 的数,由于最小级别 shiftpool->min_shift,那 shift - pool->min_shift 就 得到了对应 slotslots 中的偏移。

    shift = 1;
    for (s = size - 1; s >>= 1; shift++);
    slot = shift - pool->min_shift;
    
  • page 指向刚刚申请到的一个 slab 的管理结构体,其在管理结构体数组里的位置就 是 page - pool->pagesslab 管理结构和 slab 又是一一对应的,那么 slabslab 数组中的偏移也是 page - pool->pages。一个 slab 大小是 1 << ngx_pagesize_shift,那么就可以计算出 slab 的相对地址了。

    p = (page - pool->pages) << ngx_pagesize_shift;
    p += (uintptr_t) pool->start;
    
  • shift 对应的 slot 申请的 chunk 大小是 1 << shift1 << (ngx_pagesize_shift - shift) 又是此 slot 中一个 slab 可以切分出的 chunk 个数,一个 chunk 需要一个 bit 来跟踪使用状态,那么就需要 mapuintptr_t 来存储跟踪一个 slabchunk 使用状态的 bitmap 了。

    map = (1 << (ngx_pagesize_shift - shift)) / (sizeof(uintptr_t) * 8);
    
  • shift > ngx_slab_exact_shift 情况下,slab allocator 使用 page->slab 的高16位存储 bitmap,低 4 位存储 slab 所属 slotshift 值。所以,第二 行的 n 值含义是此 slotslab 可以切分出多少个 chunk。一个 chunk 状态 又需占用 bitmap 中的一个 bit,在 bitmap 全满时的状态就是 (1 << n) - 1

    n = ngx_pagesize_shift - (page->slab & NGX_SLAB_SHIFT_MASK);
    n = 1 << n;
    n = ((uintptr_t) 1 << n) - 1;
    mask = n << NGX_SLAB_MAP_SHIFT;
    
  • shift < ngx_slab_exact_shift 情况下,由于一个 uintptr_t 大小的 bitmap 无法跟踪所有 chunk 状态,slab allocator 直接占用 nchunk 存储 bitmap。这 nchunk 需要在 bitmap 中置为己使用状态。同时,这段代码紧接 着的逻辑还需要返回给调用者一个 chunk,故这个即将被使用的 chunk 也需要被置为 己使用状态。

    s = 1 << shift;
    n = (1 << (ngx_pagesize_shift - shift)) / 8 /s;
    bitmap[0] = (2 << n) - 1;
    
  • slabchunk 个数除于一个 uintptr_t 类型包含的 bit 数,得到这个 bitmap 需要占用个 uintptr_t 的连续空间。

    map = (1 << (ngx_pagesize_shift - shift)) / (sizeof(uintptr_t) * 8);
    
  • 因为结构体对齐的原因,ngx_slab_pool_tngx_slab_page_t 在 32 位系统中都 是按 4 字节对齐 (4-byte aligned),同时共享内存起始地址至少是 4 字节对齐的。所以 &slots[slot] 的值低2位是0,可用以用存储额外信息。

    page->prev = (uintptr_t) &slots[slot] | NGX_SLAB_SMALL;
    page->prev = (uintptr_t) &slots[slot] | NGX_SLAB_EXACT;
    page->prev = (uintptr_t) &slots[slot] | NGX_SLAB_BIG;
    
    prev = (ngx_slab_page_t *) (page->prev & ~NGX_SLAB_PAGE_MASK);
    
  • 由将要释放的内存块指针得到它所属的 slabslab 管理结构体索引。

    n = ((u_char *) p - pool->start) >> ngx_pagesize_shift;
    page = &pool->pages[n];
    

基本类型

  • 操作系统级共享内存块表示

    --------os/unix/ngx_shmem.h:15------
    typedef struct {
        u_char      *addr;  /* 共享内存块的起始地址 */
        size_t      size;   /* 共享内存块的大小 */
        ngx_str_t   name;
        ngx_log_t   *log;
        ngx_uint_t  exists;
    } ngx_shm_t;
    
  • Nginx 内部的共享内存块表示

    -------core/ngx_cycle.h:28----------
    struct ngx_shm_zone_s {
        void                    *data;   /* 存储模块相关数据,与内存分配无关 */
        ngx_shm_t               shm;     /* shm.addr 转换成 ngx_slab_pool_t
                                            使用 */
        ngx_shm_zone_init_pt    init;    /* 模块相关的初始化函数。比如,从
                                            slab_pool中申请内存,给 data 字
                                            段赋值等 */
        void                    *tag;    /* 共享内存块的所有者 */
    };
    typedef struct ngx_shm_zone_s ngx_shm_zone_t;
    
  • slab 管理结构体

    ------------core/ngx_slab.h:17----------
    struct ngx_slab_page_s {
        uintptr_t       slab;  /* 不同场景下用于:存储 bitmap、shift 值、
                                或者后续连续的空闲 slab 个数*/
        ngx_slab_page_t *next;
        uintptr_t       prev;  /* 双链表的前向指针,同时存储 `slab` 属性信
                                息 */
    };
    typedef struct ngx_slab_page_s ngx_slab_page_t;
    
  • slab allocator 主结构体

    ------------core/ngx_slab.h:24----------
    typedef struct {
        ...
        size_t          min_size;  /* 最小 chunk 字节数 */
        size_t          min_shift; /* 最小 chunk 字节数是 2 的多少次幂 */
        ngx_slab_page_t *pages;    /* slab 的管理结构体数组首地址 */
        ngx_slab_page_t free;      /* 空闲的 slab 管理结构体链表 */
        u_char          *start;    /* pagesize align'ed,slab 数组起始地址 */
        u_char          *end;      /* 整个共享内存块结束地址 */
        ...
        void            *data;     /* 用户数据 */
        void            *addr;     /* 整个共享内存块起始地址 */
    } ngx_slab_pool_t;
    

共享内存操作

下面的分析暂不考虑大内存块 (大小大于等于 ngx_slab_max_size) 的内存块申请和释放, 对它们的操作比较简单,不涉及到 slot 分级机制、bitmap 等逻辑。

初始化

Nginx 初始化完成后,各模块通过自己申请的内存块对应的ngx_shm_zone_t管理内存, ngx_slab_pool_t并不直接暴露在外,而是被间接转换后使用:

    -------http/modules/ngx_http_limit_req_module.c:494-------
    static ngx_int_t
    ngx_http_limit_req_init_zone(ngx_shm_zone_t *shm_zone, void *data)
    {
        ctx->shmpool = (ngx_slab_pool_t *) shm_zone->shm.addr;
        ctx->sh = ngx_slab_alloc(ctx->shmpool, sizeof(ngx_http_limit_req_shctx_t));
        ctx->shmpool->data = ctx->sh;

        ngx_rbtree_init(&ctx->sh->rbtree, &ctx->sh->sentinel,
                        ngx_http_limit_req_rbtree_insert_value);
        ngx_queue_init(&ctx->sh->queue);
    }

slab allocator 初始化完成后,slab allocator 的状态示意图如下:

初始化过程如下:

    -------core/ngx_cycle.c:881-------------
    static ngx_int_t
    ngx_init_zone_pool(ngx_cycle *cycle, ngx_shm_zone_t *zn)
    {
        sp = (ngx_slab_pool_t *)zn->shm.addr;
        sp->end = zn->shm.addr + zn->shm.size;
        sp->min_shift = 3;
        sp->addr = zn->shm.addr;

        ngx_slab_init(sp);
    }

    -------core/ngx_slab.c:76---------------
    void
    ngx_slab_init(ngx_slab_pool_t *pool)
    {
        ...
        pool->min_size = 1 << pool->min_shift;
        p = (u_char *) pool + sizeof(ngx_slab_pool_t);
        size = pool->end - p;

        ngx_slab_junk(p, size);
        ...
        slots = (ngx_slab_page_t *)p;
        n = ngx_page_size_shift - poo->min_shift;

        for (i = 0; i < n; i++) {
            slots[i].slab = 0;
            slots[i].next = &slots[i];
            slots[i].prev = 0;
        }

        p += n * sizeof(ngx_slab_page_t);

        pages = (ngx_uint_t) (size / (ngx_pagesize + sizeof(ngx_slab_page_t)));

        ngx_memzero(p, pages * sizeof(ngx_slab_page_t));

        pool->pages = (ngx_slab_page_t *) p;
        pool->free.prev = 0;
        pool->free.next = (ngx_slab_page_t *) p;

        pool->pages->slab = pages;
        pool->pages->next = &pool->free;
        pool->pages->prev = (uintptr_t) &pool->free;

        pool->start = (u_char *)
                      ngx_align_ptr((uintptr_t) p + pages * sizeof(ngx_slab_page_t),
                                     ngx_pagesize);

        m = pages - (pool->end - pool->start) / ngx_pagesize;
        if (m > 0) {
            pages -= m;
            pool->pages->slab = pages;
        }
        ...
    }

对上面代码的补充说明:

  • 每个 slot 都是一个 ngx_slab_pool_t 链表,初始链表只有一个元素, ngx_slab_page_t.next 指向自己。

  • 每个 slab 管理结构都和一个 slab 一一对应。整个共享内存可以有 (size / (ngx_pagesize + sizeof(ngx_slab_page_t)))slab

  • slots 占用空间也参与了 pages 值的计算 (包含在 size 里),然后再根据实际可 分配的 slab 个数 (按 pagesize aligned),再次修正 pages 值。个人感觉没看懂, 虽然关系不大,但是如果在第一次计算 pages 的时候使用了正确的 size,那 pages 就会变小,在对 slab 进行对齐时,很可能可以多挤出一个 slab 的空间。

  • 初始化完 pool->pagespool->free 后,按照实际可用的 slab (因为 slab 起始地址需要按 pagesize 对齐),调整 pool->pages->slab 值。

        m = pages - available_pages;
        if (m > 0) {
            pool->pages->slab = available_pages;
        }
    
  • slot 指向尚有空闲的 slab 管理结构组成的双向链表。已经完全用尽的 slab 会 处于游离状态,直到其上有 chunk 被释放后,slab 管理结构才再次被加到双向链表的 头部。

内存申请

内存分配主要由函数 ngx_slab_alloc_locked 完成,在分配开始时,先根据申请的内存 块大小 size 定位到需要使用的 slot (如果 size 大于 ngx_slab_max_size 的 话,直接从 slab 数组中申请整块或者连续多块 slab 返回给申请者)。

    if (size > pool->min_size) {
        shift = 1;
        for (s = size - 1; s >> 1; shift++) { /* void */ }
        slot = shift - pool->min_shift;

    } else {
        size = pool->min_size;
        shift = pool->min_shift;
        slot = 0;
    }

对上述代码的几点补充说明:

  • slab allocator 能分配的最小 chunk 大小为 pool->min_size,所有小于此大小 的内存申请请求,都按 pool->min_size 分配。其对应的 slotslots[0]

  • 大于 pool->min_size 的内存申请请求,根据 BEST FIT 策略,找到略微大于或等 于 sizeslot,从中再申请 chunk

slot 中分配 chunk

根据 bitmap 的存储位置,slot 又分为三个类别:

  • slot 对应的 shift 小于 ngx_slab_exact_shift 的,bitmap 存储于 slab 中的前几个 chunk 中。

  • slot 对应的 shift 等于 ngx_slab_exact_shift 的,bitmap 存储于 ngx_slab_page_t.slab 成员中。

  • slot 对就的 shift 大于 ngx_slab_exact_shift 的,bitmap 存储于 ngx_slab_page_t.slab 成员的高位中。

不管是哪一类 slotchunk 分配的基本流程都是:

  1. 找到 bitmap
  2. bitmap 中定位到第一个空闲的 chunk
  3. chunkbitmap 中的对应位置 1
  4. 如果,chunk 所在的 slab 已经不再有空闲 chunk,将此 slab 对应的管理结构 体从 slot 链表中摘除
  5. 返回 chunk 给申请者

篇幅起见 (已经很长了好不?),只对第一类 slot 的分配代码作详细分析,同时,

  • 这段代码在 slot 链表中存在有空闲 slab (slots[slot].next != &slots[slot]) 的前提下才会被执行。

  • slot 为空时 ,会直接执行下一章节 (slab 申请) 的代码。

按照上面总结的分配流程,首先需要定位 slabbitmap。对于 shift 小于 ngx_slab_exact_shiftslotslabbitmap 存储于前几个 chunk 中。

    --------core/ngx_slab.c:203---------------
    p = (page - pool->pages) << ngx_pagesize_shift;
    bitmap = (uintptr_t *) (pool->start + p);

    map = (1 << (ngx_pagesize_shift - shift)) / (sizeof(uintptr_t) * 8);

对上面代码的几点补充说明:

  • map > 1, 并且 bitmap 占用多个完整的 uintptr_t 类型空间。
    /* 其中 n 为 sizeof(uintptr_t) 的 2 的幂值,
       且 shift < ngx_slab_exact_shift */
    
    ngx_slab_exact_shift = ngx_pagesize_shift - (n + 3)
    
    2 ^ (ngx_pagesize_shift - shift) / (2 ^ n * 2 ^ 3)
        = 2 ^ (ngx_pagesize_shift - shift - (n + 3))
        > 2 ^ 0
        = 1
    

接下来,从 bitmap 中定位第一个空闲的 chunk。由于 bitmap 是按照连续的 uintptr_t 存储的,所以可以先按 uintptr_t 为基本单位,找到存在空闲位的 uintptr_t,然后再从 uintptr_t 中定位到具体空闲的位。

    ----------core/ngx_slab.c:209---------
    for (n = 0; n < map; n++) {
        if (bitmap[n] != NGX_SLAB_BUSY) {
            for (m = 1, i = 0; m; m <<= 1, i++) {
                if (bitmap[n] & m)) {
                    continue;
                }
    ...

再接下来,将 chunk 对应位在 bitmap 中置 1,再检查此 slab 是否已经完全被 分配完毕。其中,i 为空闲 chunk 空闲对应 bituintptr_t 中的序号,nuintptr_tbitmap 中的序号。

    -----------core/ngx_slab.c:218---------
    bitmap[n] |= m;

    i = ((n * sizeof(uintptr_t) * 8) << shift) + (i << shift);

    if (bitmap[n] == NGX_SLAB_BUSY) {
        for (n = n + 1; n < map; n++) {
            if (bitmap[n] != NGX_SLAB_BUSY) {
                p = (uintptr_t) bitmap + i;

                goto done;
            }
        }

        prev = (ngx_slab_page_t *) (page->prev & ~NGX_SLAB_PAGE_MASK);
        prev->next = page->next;
        page->next->prev = page->prev;

        page->next = NULL;
        page->prev = NGX_SLAB_SMALL;
    }

    p = (uintptr_t) bitmap + i;

    goto done;

对上述代码的几点说明:

  • (n * sizeof(uintptr_t) * 8) << shift 等于 (n * sizeof(uintptr_t) * 8) * 1 << shift, 1 << shift 是一个 chunk 大小,那 整个表达式的结果就是 nuintptr_t 表示的 chunk 地址范围。那么再加上 i << shift 就得到了第 n * sizeof(uintptr_t) * 8 + ibit 位对应的 chunk 起始地址。

slab 申请

如果 slot 链表中没有可分配 chunk 时,slab allocator 调用 ngx_slab_alloc_pagesslab 数组中申请一个新的 slab,按照 slot 对应级别 进行切分后,再从中拿到第一个空闲 chunk 返回给客户端。

pool->free 空闲 slab 对应管理结构组成的双向链表的头节点,它本身并不对应任何 slab。它的作用相当于一个 sentinel

    -------------core/ngx_slab.c:625--------
    for (page = pool->free.next; page != &pool->free; page = page->next) {
        ...
    }

同时,需要注意的就是,双向链表中的节点,有可能并不只对应一个 slab。其 ngx_slab_page_t.slab 字段的值就表示此 slab 管理结构对应 slab 后面连续 ngx_slab_page_t.slab -1slab 都由此 slab 管理结构负责跟踪。 slab allocator 之所以这么做,是为了满足那些超过一个 slab 大小的内存请求。

slab allocator 初始化完成之后,pool->free 指向的空闲 slab 双向链表里只 有一个 slab 管理结构节点,此节点跟踪所有的 slab

总结下来就是:

  • ngx_slab_page_t.slab 成员保存此结构对应的 slab 后面连续可用的 slab 个数。

  • 申请多个连续的 slab 时,连续 slab 对应的 ngx_slab_page_t (第一个除外), 都被设为 NGX_SLAB_PAGE_BUSY 标志,第一个 ngx_slab_page_t 被添加了 NGX_SLAB_PAGE_START 标志。

  • pool->free 链表只将空闲的 slab 管理结构链入其中。已经分配出去 (分配给了 某个 slot 或者直接返回给了用户) 的 slab 管理结构不在此链表中。

从新 slab 中分配 chunk

这个过程和 slot 中分配 chunk 描述的过程类似,具体执行逻辑也和 slot 的分类相关。 不同之处在于,刚刚从 slab 数组中申请到的 slab 还未进行初始化。

下面的分析也只针对 shift 小于 ngx_slab_exact_shiftslot

此类 slabbitmap 存储于 slab 内存中,并且占用 slab 的头几个 chunk。 那么,首先要确定的就是此 bitmap 的起始位置,和它需要占用几个 chunk

    -----------core/ngx_slab.c:334--------------
    p = (page - pool->pages) << ngx_pagesize_shift;
    bitmap = (uintptr *) (pool->start + p);

    s = 1 << shift;
    n = (1 << (ngx_pagesize_shift - shift)) / 8 / s;
    ...
    bitmap[0] = (2 << n) -1;
    map = (1 << (ngx_pagesize_shift - shift)) / (sizeof(uintptr_t) * 8);
    ...

n 表示 bitmap 要占用的 chunk 个数,map 表示 bitmap 要占用的 uintptr_t 个数。

随后,将此 slab 管理结构体挂接到 slot 链表的头部 (代码如果执行到这一步,已经 说明 slot 无空闲 chunk 可用,所以此时链表为空):

    ------------core/ngx_slab.c:352-------------
    page->slab = shift;
    page->next = &slots[slot];
    page->prev = (uintptr_t) &slots[slot] | NGX_SLAB_SMALL;

    slots[slot].next = page;

最后,返回分配给请求者的 chunk 首地址:

    ------------core/ngx_slab.c:358-------------
    p = ((page - pool->pages) << ngx_pagesize_shift) + s * n;
    p += (uintptr_t) pool->start;

    goto done;

Phew! 再上一张 slab allocator 初始化过后,刚刚分配出一个 chunk 后的状态图:

内存释放

内存释放的流程大致为:

  1. 校验释放的内存块 chunk 地址在不在 slab allocator 管理范围内
  2. 找到 chunk 所属的 slab 地址和类型
  3. 定位到 slabbitmap
  4. bitmap 中将 chunk 对应 bit 置 0
  5. 检查 slab 是否已经完全空闲。如果完全空闲,将 slab 释放回 slab 数组,此 slab 可能会再次被其它 slot 申请使用。
  6. 如果释放的 chunkslab 第一块空闲块,将 slab 加入 slot 链表头部

篇幅起见,只对第一类 slot 的释放代码作详细分析。

chunk 地址的检验,一方面验证一下此地址是否在 slab allocator 的地址范围内。 另外一方面,校验地址是否符合 slab allocator 的规则 (地址值是 chunk 大小的倍 数):

    ---------------core/ngx_slab.c:422------------
    if ((u_char *) p < pool->start || (u_char *) p > pool->end) {
        ...
    }

    ---------------core/ngx_slab.c:439------------
    if ((uintptr_t) p & (size - 1)) {
        ...
    }

然后,定位地址所在的 slab 地址和类型:

    --------------core/ngx_slab.c:427-------------
    n = ((u_char *) p - pool->start) >> ngx_pagesize_shift;
    page = &pool->pages[n];
    slab = page->slab;
    type = page->prev & NGX_SLAB_PAGE_MASK;

对上述代码补充说明的几点:

  • slab 起始地址在初始化时都按 ngx_pagesize 对齐过,所以它的值是 ngx_pagesize 的倍数。

我们只考虑第一类 slot,它的 typeNGX_SLAB_SMALL。下面再找到 bitmap 的 位置和大小:

    -------------core/ngx_slab.c:443--------------

    n = ((uintptr_t) p & (ngx_pagesize - 1)) >> shift;
    m = (uintptr_t) 1 << (n & (sizeof(uintptr_t) * 8 - 1));
    n /= (sizeof(uintptr) * 8);
    bitmap = (uintptr_t *) ((uintptr_t) p & ~(ngx_pagesize - 1));

对上述代码的几点补充:

  • n 值为 p 指向的 chunkslab 中的索引。 p & (ngx_pagesize - 1) 计算 出来了 chunk 相对于 slab 起始地址的相对地址。因为可以 chunk 索引 n 乘于 chunk 块大小 1 << shift 得到 chunk 相对于 slab 起始地址的相对地址,反过 来就能得到 n 值。

  • m 值是 n 值在一个 uintptr_t 对应位置 1 的 mask 值。 n & (sizeof(uintptr_t) * 8 - 1) 相当于 n % ((sizeof(uintptr) * 8)

后续的其它操作就不在分析了,基本操作和申请时大同小异。

最后

写得不好,多多包涵。写一篇文章,没想像中的轻松,写出的文字也没想像中的易懂。 但是,发现一个字一个字敲出来后,真的比之前理解的更深刻了一点。

希望我能继续下去吧。

Comments

不要轻轻地离开我,请留下点什么...

comments powered by Disqus

Published

Category

Nginx

Tags

Contact