nginxの非同期I/Oとキャッシュ周りの実装について

nginx-1.0.14のソースを見ていく。非同期I/Oをどのようにくししているのか非常に興味がある。まずは、リクエストを受け取った後、どのようにファイルを非同期で読み込みそれをキャッシュとして扱っていくのか、また、非同期であることの優位性をどのように実装しているのかを紐解いていった。

まずは以下の「ngx_http_file_cache_read()」関数でキャッシュの読み込みや更新を行っている。

[program lang=’c’ escaped=’true’]

static ngx_int_t
ngx_http_file_cache_read(ngx_http_request_t *r, ngx_http_cache_t *c)
{
    time_t                         now;
    ssize_t                        n;
    ngx_int_t                      rc;
    ngx_http_file_cache_t         *cache;
    ngx_http_file_cache_header_t  *h;

    n = ngx_http_file_cache_aio_read(r, c);

    if (n < 0) {
         return n;
     }

 /* 省略 */

    c->buf->last += n;
    c->valid_sec = h->valid_sec;
    c->last_modified = h->last_modified;
    c->date = h->date;
    c->valid_msec = h->valid_msec;
    c->header_start = h->header_start;
    c->body_start = h->body_start;

    r->cached = 1;

/* 省略 */

    return NGX_OK;
}

[/program]

「ngx_http_file_cache_read()」の中では、「ngx_http_file_cache_aio_read()」でファイルのキャッシュを非同期で読み込みに行っているようだ。

[program lang=’c’ escaped=’true’]

static ssize_t
ngx_http_file_cache_aio_read(ngx_http_request_t *r, ngx_http_cache_t *c)
{
#if (NGX_HAVE_FILE_AIO)
    ssize_t                    n;
    ngx_http_core_loc_conf_t  *clcf;

/* 省略 */

    n = ngx_file_aio_read(&c->file, c->buf->pos, c->body_start, 0, r->pool);

    if (n != NGX_AGAIN) {
        return n;
    }

/* 省略 */

    r->main->blocked++;
    r->aio = 1;

    return NGX_AGAIN;

/* 省略 */

}

[/program]

この中で、「ngx_file_aio_read()」がファイルの非同期I/Oのための関数だ。さて、返り値は何をみてるのかと想像しながら「ngx_file_aio_read()」の内部を見ていく。この関数はLinuxにおけるカーネルレベルでのAIOサポートの場合と、Glibcのユーザー空間でのAIO実装の2通りのソースがあるので、より汎用性の高い今回はGlibcのAIOを見ていく。もちろん、Linuxでのカーネルレベルのサポートの方が処理は早いはず。

[program lang=’c’ escaped=’true’]

ssize_t
ngx_file_aio_read(ngx_file_t *file, u_char *buf, size_t size, off_t offset,
    ngx_pool_t *pool)
{
    int               n;
    ngx_event_t      *ev;
    ngx_event_aio_t  *aio;

    if (!ngx_file_aio) {
        return ngx_read_file(file, buf, size, offset);
    }

    aio = file->aio;

/* 省略 */

    aio->aiocb.aio_fildes = file->fd;
    aio->aiocb.aio_offset = offset;
    aio->aiocb.aio_buf = buf;
    aio->aiocb.aio_nbytes = size;
#if (NGX_HAVE_KQUEUE)
    aio->aiocb.aio_sigevent.sigev_notify_kqueue = ngx_kqueue;
    aio->aiocb.aio_sigevent.sigev_notify = SIGEV_KEVENT;
    aio->aiocb.aio_sigevent.sigev_value.sigval_ptr = ev;
#endif
    ev->handler = ngx_file_aio_event_handler;

    n = aio_read(&aio->aiocb);

    if (n == -1) {
        n = ngx_errno;

        if (n == NGX_EAGAIN) {
            return ngx_read_file(file, buf, size, offset);
        }

        ngx_log_error(NGX_LOG_CRIT, file->log, n,
                      "aio_read(¥"%V¥") failed", &file->name);

        if (n == NGX_ENOSYS) {
            ngx_file_aio = 0;
            return ngx_read_file(file, buf, size, offset);
        }

        return NGX_ERROR;
    }

/* 省略 */

    return ngx_file_aio_result(aio->file, aio, ev);
}

[/program]

すると、「ngx_file_aio_read()」の中で、

[program lang=’c’ escaped=’true’]

n = aio_read(&aio->aiocb);

[/program]

が実行されている。これは、非同期にファイルのreadを行う処理で、実行後はブロックもされない。その間は、他の処理を行うことができる。そして、readが終わったかは自動で通知されるのではなく、「ngx_file_aio_read()」の最終行の、

[program lang=’c’ escaped=’true’]

return ngx_file_aio_result(aio->file, aio, ev);

[/program]

で完了の確認を行っている。さらに細かく見ていく。

[program lang=’c’ escaped=’true’]

static ssize_t
ngx_file_aio_result(ngx_file_t *file, ngx_event_aio_t *aio, ngx_event_t *ev)
{
    int        n;
    ngx_err_t  err;

    n = aio_error(&aio->aiocb);

    /* 省略 */

    if (n == NGX_EINPROGRESS) {
        if (ev->ready) {
            ev->ready = 0;
            ngx_log_error(NGX_LOG_ALERT, file->log, n,
                          "aio_read(¥"%V¥") still in progress",
                          &file->name);
        }

        return NGX_AGAIN;
    }

    n = aio_return(&aio->aiocb);

    if (n == -1) {
        err = ngx_errno;
        aio->err = err;
        ev->ready = 1;

        ngx_log_error(NGX_LOG_CRIT, file->log, err,
                      "aio_return(¥"%V¥") failed", &file->name);
        return NGX_ERROR;
    }

/* 省略 */

    return n;
}

[/program]

「ngx_file_aio_result()」の中で、

[program lang=’c’ escaped=’true’]

n = aio_error(&aio->aiocb);

[/program]

が実行されており、これはファイルのreadが完了しているかどうかを確認している。ここで返り値がEINPROGRESSであれば、処理は未完了であり、それ以外は完了している。nginxではそれをNGX_EINPROGRESSと定義しているようだ(異常な場合も含む)。未完了の場合は、NGX_AGAINを返しており、完了していれば、

[program lang=’c’ escaped=’true’]

n = aio_return(&aio->aiocb);

[/program]

によって、読み取ったファイルのバイト数を返している。

結果、大元の「ngx_http_file_cache_read()」の中で呼ばれていた「ngx_http_file_cache_aio_read()」の、さらに中で呼ばれていた「ngx_file_aio_read()」は読み取ったファイルのバイト数、又は、aio_readの処理中であるかを非同期で返している事が分かった。また、ngx_http_cache_t構造体にaio_read()したファイルの情報を格納しておく。そして、このように一旦読み込んだファイルはngx_http_cache_t構造体にキャッシュの期間や必要な情報を格納して、nginx上のキャッシュの共有メモリに保存する方向に処理を進める。

また、そのタイミングで古いキャッシュの更新処理を行っておく。

[program lang=’c’ escaped=’true’]

static ngx_int_t
ngx_http_file_cache_read(ngx_http_request_t *r, ngx_http_cache_t *c)
{

/* 省略 */

    now = ngx_time();

    if (c->valid_sec < now) {
         ngx_shmtx_lock(&cache->shpool->mutex);

        if (c->node->updating) {
            rc = NGX_HTTP_CACHE_UPDATING;

        } else {
            c->node->updating = 1;
            c->updating = 1;
            rc = NGX_HTTP_CACHE_STALE;
        }

        ngx_shmtx_unlock(&cache->shpool->mutex);

        ngx_log_debug3(NGX_LOG_DEBUG_HTTP, r->connection->log, 0,
                       "http file cache expired: %i %T %T",
                       rc, c->valid_sec, now);

        return rc;
    }

/* 省略 */

}

[/program]

また、aioが未完了だった場合は、「ngx_http_file_cache_read()」事態がその状態「NGX_AGAIN」を返す。ここの実装「n < 0」にしてるの分かり難い。明示的に「NGX_AGAIN」の場合はここを通るように書いた方が分かりやすいと思う。

[program lang=’c’ escaped=’true’]

static ngx_int_t
ngx_http_file_cache_read(ngx_http_request_t *r, ngx_http_cache_t *c)
{
/* 省略 */

    n = ngx_http_file_cache_aio_read(r, c);

    if (n < 0) {
        return n;
    }

/* 省略 */

}

[/program]

それを受け取った「ngx_http_upstream_cache()」が「NGX_BUSY」として、処理を再度一からやり直すように見える。

[program lang=’c’ escaped=’true’]

static ngx_int_t
ngx_http_upstream_cache(ngx_http_request_t *r, ngx_http_upstream_t *u)
{
    ngx_int_t          rc;
    ngx_http_cache_t  *c;

    c = r->cache;

/* 省略 */

    rc = ngx_http_file_cache_open(r);

    ngx_log_debug1(NGX_LOG_DEBUG_HTTP, r->connection->log, 0,
                   "http upstream cache: %i", rc);

    switch (rc) {

/* 省略 */

    case NGX_AGAIN:

        return NGX_BUSY;
}

[/program]

以下のように、「ngx_http_upstream_cache()」でNGX_BUSYの場合は「ngx_http_upstream_init_request()」を再帰的に実行しているからである。

[program lang=’c’ escaped=’true’]

static void
ngx_http_upstream_init_request(ngx_http_request_t *r)
{
/* 省略 */
    if (u->conf->cache) {
        ngx_int_t  rc;

        rc = ngx_http_upstream_cache(r, u);

        if (rc == NGX_BUSY) {
            r->write_event_handler = ngx_http_upstream_init_request;
            return;
        }
/* 省略 */
}

[/program]

考察

ようやく、非同期I/Oを使った場合の優位性が見えてきたように思う。ここからは考察だが、これは、vmwareのSCSI Reservationでも取り入れられたオプティミスティックロックのような処理に似ているように思った。

ファイルの大小や負荷の高低によらずI/O完了までを常にブロックしてI/O可能な状態を待つよりは、ほとんどの場合、非同期にI/Oを動作させておいて、I/O完了後の処理を行う時にはすでにI/O処理は完了している場合が多いだろうから、細かいブロックはせずにaio_readが完了している前提で非同期にその他の処理を行っておく。もし、I/Oが未完了であった場合のみ最初からトライし直すのでその場合の処理は遅くなるかもしれないが、トータルではそいう状況は少ないだろうという楽観的な考えの元に、ロックを最小限に抑えているように思われる。このような考え方によって、非同期I/Oをフル活用して処理を高速にしていると思われる。

ひょっとすると、

  • IOwaitの負荷が高い状況
  • ファイルサイズの大きいファイルのダウンロード
  • ランダムな静的ファイルへのアクセス

のような状況では、aio_readが間に合わずNGX_BUSYが続出し、キャッシュにも載せられず再度I/Oの実施が頻繁に起きてしまい、急激にパフォーマンスが落ちるかもしれない。オプティミスティックロックの弱点が出るような上記の状況では、非同期I/Oを利用した実装は逆にコストが高くなってしまうかもしれない。

以上が、非同期のファイル読み込みからキャッシュ生成までのnginxの流れである。全てにおいて、非同期I/Oを意識した実装がなされている。