地方エンジニアの学習日記

興味ある技術の雑なメモだったりを書いてくブログ。たまに日記とガジェット紹介。

【Go】クライアント切断を検知する

pkg.go.dev

Request.Context()を使うことでクライアントが切断したことをアプリケーションから検知することができる。一般的なhttpでの処理は以下のようになっている。そのうち5番まで処理が進んだ状態でそこから処理キャンセルをすることができるというのでどうやってるんだ?と思って調べた。

  1. クライアントがサーバーに接続を開始
  2. サーバーがクライアントからの接続を受け入れ
  3. クライアントがサーバーに接続を確立
  4. HTTPリクエストの送信
  5. HTTPリクエストの処理

アプリケーションがHTTPリクエストの処理中にクライアントが切断したらどうなるのか

HTTPリクエストを受け取った後にアプリケーションの処理をしているだけではアプリケーションはクライアントの切断に何もしなければ気づくことはできない。なぜならTCPの処理はカーネルが行なっていて切断をアプリケーションに通知は行なっていないためである。アプリケーションからコネクションが切断されていることを知るためにはアプリケーションがsocketをread(2)してEOFが返ってくるのかを確認する必要がある。

qiita.com

Goだとどうなのか

とってもシンプルなwebアプリケーションですがr.Contex()でcontextを生成するとクライアントの切断を検知することが可能になります。

func helloWorld(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    for {
        select {
        case <-ctx.Done(): // クライアントの切断時はこちらが実行される
            fmt.Println("error: ", ctx.Err())
            return
        default:
            fmt.Fprintf(w, "hello world3\n")
        }
    }
}

ソースを追っていく

http.ListenAndServe(":8080", nil)

のように呼び出すとserver.ListenAndServe()を内部的に呼び出す。net.Listen()を使ってlisten(2)を開始してsrv.Serve()を呼び出す

func (srv *Server) ListenAndServe() error {
        if srv.shuttingDown() {
                return ErrServerClosed
        }
        addr := srv.Addr
        if addr == "" {
                addr = ":http"
        }
        ln, err := net.Listen("tcp", addr)
        if err != nil {
                return err
        }
        return srv.Serve(ln)
}

上記のメソッドでaccept(2)までを行いコネクションが貼られるたびにgoroutineが起動し以下のメソッドが呼び出される。

// Serve a new connection.
func (c *conn) serve(ctx context.Context) {
    for {
        w, err := c.readRequest(ctx)
        if c.r.remain != c.server.initialReadLimitSize() {
            // If we read any bytes off the wire, we're active.
            c.setState(c.rwc, StateActive, runHooks)
        }

        if requestBodyRemains(req.Body) {
            registerOnHitEOF(req.Body, w.conn.r.startBackgroundRead)
        } else {
            w.conn.r.startBackgroundRead()
        }
}

バックグラウンドで実行されるのが以下のメソッド。

func (cr *connReader) startBackgroundRead() {
    cr.lock()
    defer cr.unlock()
    if cr.inRead {
        panic("invalid concurrent Body.Read call")
    }
    if cr.hasByte {
        return
    }
    cr.inRead = true
    cr.conn.rwc.SetReadDeadline(time.Time{})
    go cr.backgroundRead() // バックグラウンドでソケットのReadを実行する
}
func (cr *connReader) backgroundRead() {
    n, err := cr.conn.rwc.Read(cr.byteBuf[:])
    cr.lock()
    if n == 1 {
        cr.hasByte = true
    }
    if ne, ok := err.(net.Error); ok && cr.aborted && ne.Timeout() {
    } else if err != nil {
        cr.handleReadError(err) // errが入るのでここに到達する
    }
    cr.aborted = false
    cr.inRead = false
    cr.unlock()
    cr.cond.Broadcast()
}

handleReadErrorでコンテキストのキャンセルを実施する。これでクライアントの切断を検知して処理の中断を実装することができるようになっている。

func (cr *connReader) handleReadError(_ error) {
    cr.conn.cancelCtx() // コンテキストがキャンセルされる
    cr.closeNotify()
}

まとめ

HTTPハンドラの呼び出しとは別のgoroutineがソケットを監視することで実現されていました。他言語だとどうなっているのかも気になりました。nginxだとproxy_ignore_client_abortみたいなパラメータがあってクライアントの切断を同じような処理で監視しています。ただしupstreamのアプリケーションには通知を行うのみで通知を用いて何かをするような処理を実装しない限りは処理を継続して行なってしまうので注意が必要です。