Goroutineハンター、それは逃げ出したgoroutine達を捕まえるため、日夜戦い続けるエンジニア達のことである。Goroutineハンターは本番環境でOOM Killerが発動するたびに呼び出され、逃げ出したすべてのgoroutineを捕まえるまで家にかえることが出来ない。しかし、あなたが書いた何気ないコードによって、今日もまた新しいgorutine達が野に放たれるのであった。

Goroutineリークとの戦い

Goを使用してある程度規模のプログラムを書くと、必ず問題になるのがgoroutineのリークである。goで生まれたgoroutineが、何らかの理由で正常に終了しない場合、それは「リーク」していると見なされる。リークしたgoroutineはプロセスが続く限り永遠にリソースを手放さないため、リークしたgoroutineが蓄積するに従って、プログラムのパフォーマンスは低下していく。大抵の場合、最終的にはメモリを使い果たし、OOM Killerによってプロセスごと停止することになる。

Gouroutineリークを起こさないためには、goroutineのライフサイクルに対して非常に神経質になる必要がある。ガベージコレクタが自動で不要なオブジェクトを回収してくれる時代にも関わらず、goroutineに関しては人間が全ての条件を考慮して適切なコードを書く必要がある。

経験豊富なGoプログラマーはgo構文の使用に対して慎重であるべきだと理解している。一方で、Goを始めたばかりのプログラマーは「Goといえばgoroutineとchannelである」という認識からか、比較的カジュアルにgo構文を使う傾向にあるようだ。そして大抵の場合、そのようにして生まれたgoroutine達は永遠の生を謳歌することになる。

Goroutineのリークは他のリソースリークと同様に、機能上の問題として露見することが少ない。そのため、機能要件に対するテストを十分に行っていても事前に問題を発見することが出来ないケースも多い。現時点においてgoroutineリークを予防するためには(非常に残念なことに)開発者の心がけに依存せざる得ない部分が大きい。

Goroutineユーザーの心得

ここでは、様々な経験から得られた、gorotuineをリークさせないための「心得」を共有したい。もちろんこれらの心得は絶対的なものではないし、あなたが熟練のGoプログラマであれば、当たり前すぎるように感じたり、あるいは同意できないことも含まれているかもしれない。これらの心得は、絶対的なルールというよりも、むしろgoroutineの安全な扱いに難儀している開発者への最初のヒントになればいいと思っている。

[1] Goroutineを作らない

Goroutineを作らなければリークもしない。当たり前のように思われるが、非常に重要な点である。

この心得は「ブロックする関数を恐れない」と言い換えることも出来る。特にGo初心者の書いたコードに見られるのだが、関数を(親切心で)非ブロック化するために、本来必須ではないgoroutineを作ってしまうケースがある。しかし、ブロックを受け入れるかgoで並列化するかは呼び出し側に任せればいい話であって、関数を提供する側がわざわざ考えることではない。関数がブロックすることを恐れてはならないし、基本的にはブロックするべきである。goを使うのは、どうしてもそれが必要になったときにしよう。

[2] Goroutineを作るときは同じ関数内で終了させる

Goroutineの終了を、そのgoroutineを生成したのとは別の関数に委ねるのは危険な兆候である。例えば、コンストラクタ関数でgoroutineを生成し、Close()や他の関数でエラーの状況に応じてgoroutineに停止シグナルを送るようなコードは危険度が高い。structのフィールドにcloseChcancelなどが出現する場合、goroutineのライフサイクルが長過ぎる可能性がある。Goroutineのライフサイクルが拡大すれば拡大するほど、そのgoroutineがリークするリスクは高まっていく。

恐ろしいことに、機能がシンプルなうちはこの危険な兆候に気が付かないことがよくある。しかし、エラー処理や新しい機能を追加していくうちに、最初は小さかったgoroutineが段々と手に負えないモンスターに成長していくのだ。

context.Contextとブロックする関数を用いると、goroutineのライフサイクル管理は非常に簡単になる。後ほど紹介する。

[3] Channelよりもsync.Mutexsync.WaitGroupで簡単に実装できないか考える

Goといえばgoroutineとchannel。しかし、channelを適切に扱い続けることは、実際のところ比較的難しい。キャパシティの考慮漏れでwriteが出来ずにgoroutineがスタックすることはよくあるし、イベントループ内に巨大なselectが乱立してコードの見通しが悪くなることもよくある。channelの使い方に起因したgoroutineリークは事実として非常に多い。

とはいえ、channelを使用しないと適切に実装できないケースももちろん存在する。重要なのはchannelを使うために必要以上にコードを複雑化させないことである。

[4] 1つのstructに詰め込みすぎない

1つのstruct内に複数のmutexやchannelが存在する場合は、異なる何かを混ぜて管理している可能性が高い。結果として、goroutineの管理が混乱し、リークが発生しやすい。それぞれを細かい単位に分轄して別々に管理できないかを考えるべきだ。

[5] goroutineリークもテストする

残念ながら標準ライブラリにはgoroutineリークを検出するための便利な機能がないが、サードパーティ製のツールはいくつか存在する。たとえば、etcdに含まれるtestutilのリークチェッカーは簡単に使用することが出来る。goroutineの数をチェックするだけの簡易なテストであればruntime.NumGoroutine()を使って簡単に自作することも出来る。

また、テスト時にはgo lintgo vetも実行することで、怪しいコードの警告を受けることも出来るので活用するとよい。

[6] goroutineの数を監視する

たとえば、pprofパッケージを使用すれば、現在存在しているgoroutineの数を簡単に監視することが出来る。本番環境で致命的な問題が発生する前に、モニタリングによってgouroutineの不審な増加を検出できる環境を作るべきである。

プロダクション環境でOOM Killerが発動するまでリークに気がつくことができないのは、控えめに言ってお花畑だ。

実践編

Goroutineハンターの残業時間を削減するために、上記の心得を実践したサンプルコードを書いてみよう。

Activateパターンのご紹介

以下では、ある値をリモートサーバに送信(Put())し、その値を定期的なKeepalive()によって維持するエージェント「Statefull」を例として話をすすめる。

// Some sort of client library
type Client interface {
    // Put tries to store a value into the remote endpoint.
    // The value will be removed from the endpoint after some period of time.
    // You need to call Keepalive() with the lease ID returned to keep it on the endpoint.
    Put(ctx context.Context, value []byte) (lease int, err error)

    // Keepalive send a keep alive packet to the remote endpoint for a lease ID.
    Keepalive(ctx context.Context, lease int) error
}

// Statefull is something that keeps a value in the remote store.
type Statefull struct {
    client Client

    requestTimeout time.Duration
    kaInterval     time.Duration

    mutex sync.Mutex
    lease int
}

// NewStatefull creates a new instance of Statefull.
func NewStatefull(client Client) *Statefull {
    return &Statefull{
        client: client,

        requestTimeout: time.Second * 10,
        kaInterval:     time.Second,
    }
}

// Activate keeps the last value in the remote store,
// returns an error when gets any error from the remote server while keepaliving.
// Cancel ctx to abort.
func (s *Statefull) Activate(ctx context.Context) error {
    for {
        select {
        case <-time.After(s.kaInterval):
        case <-ctx.Done():
            return nil // we don't return ctx.Err() here
        }

        s.mutex.Lock()
        lease := s.lease
        s.mutex.Unlock()

        if lease == 0 {
            continue
        }

        err := s.client.Keepalive(s.withTimeout(ctx), lease)
        if err != nil {
            return errors.Wrapf(err, "keepalive failed")
        }
    }
}

// Put updates the value to be stored in the remote server.
// The previous value will be removed from the server after some duration.
func (s *Statefull) Put(ctx context.Context, value []byte) error {
    lease, err := s.client.Put(s.withTimeout(ctx), value)
    if err != nil {
        return errors.Wrapf(err, "put request failed")
    }

    s.mutex.Lock()
    s.lease = lease
    s.mutex.Unlock()

    return nil
}

// Current returns the lease ID currently being keptalive.
func (s *Statefull) Current() int {
    s.mutex.Lock()
    defer s.mutex.Unlock()

    return s.lease
}

func (s *Statefull) withTimeout(base context.Context) context.Context {
    ctx, _ := context.WithTimeout(base, s.requestTimeout)
    // go vet complains, but we can ignore :(
    return ctx
}

これはなんの変哲もないコードであるが、なんの変哲もないことが重要である。このコードにはgochannelも存在しない。Keepalive()を定期実行するためのActivate()は単なるforループなので、当然ブロックする。しかし、この関数がブロックしている間はタスクがエラーなく進んでいることが保証されているし、エラーが起きれば返り値としてその値を直接得ることが出来る。また、ブロックの解除はctxをキャンセルすることで任意にコントロール可能だ。Activate()は無限ループを伴う長期タスクなので、どうしてもgoを使用して非ブロック化したくなるが、ここで我慢することが重要である。

上記のようなコードにわざわざ「Activateパターン」という名前をつけるのは大げさすぎるが、ここでは便宜上そのように呼ぶこととする。

このコードを使用する側は以下のようになる。ここで初めてgoが登場する。

func user(ctx context.Context) error {
    cctx, cancel := context.WithCancel(ctx)
    defer cancel()

    s0 := NewStatefull(newClient())
    go func() {
        defer cancel() // to abort all the actions uses s0 below

        err := s0.Activate(cctx)
        if err != nil {
            log.Printf("s0 stopped: %s", err)
        }
    }()

    err := s0.Put(cctx, []byte("some value"))
    if err != nil {
        return errors.Wrapf(err, "first put failed")
    }

    log.Printf("Current lease: %d", s0.Current())

    // more actions with cctx here

    // wait for cancel or the exit of the goroutine
    <-cctx.Done()

    return nil
}

この例では、user()に渡されたctxがキャンセルされるまで、関数内でPut()された値が維持される。仮にキープアライブが何らかの理由で失敗した場合、ctxのキャンセルを待たずに即座に関数が終了する。

この関数で重要なのは、goによって生成されるgoroutineを、同じ関数内で終了していることだ(cctxが必ずキャンセルされる)。このようにしてgoroutineのライフサイクルを関数内に閉じ込めることで、関数の中で生まれたgoroutineのライフサイクル管理を考える必要が無くなる。さらに、この関数自体もcontextがキャンセルされるまでブロックする関数であるので(関数名は違うものの)Activateパターンを実装している。つまり、この関数をさらに使用する側にも再帰的に同じパターンを適用できるため、プログラム全体に渡ってgoroutineの管理を同じ方法で行うことが出来る。

さて、上のコードではActivate()から返るエラー処理が若干手抜きである。現実的には、Activate()のエラーについても、ログを出力するより戻り値として返したいところである。ここでは、channelを使うとgoroutineからのエラーを受け取ることが出来る。たとえば、以下のようなコードだ。

func userErr(ctx context.Context) error {
    cctx, cancel := context.WithCancel(ctx)
    defer cancel()

    errs := make(chan error, 1) // don't forget to increase when you add more gouroutines


    s0 := NewStatefull(newClient())
    go func() {
        defer cancel()
        errs <- s0.Activate(cctx)
    }()

    err := s0.Put(cctx, []byte("some value"))
    if err != nil {
        return errors.Wrapf(err, "first put failed")
    }

    log.Printf("Current lease: %d", s0.Current())

    // more actions with cctx here

    select {
    case err := <-errs:
        return err
    case <-cctx.Done():
        return nil
    }
}

ところで、簡単のため、上記の例では生成したgouroutineの終了待ち合わせは行っていない。可能であれば、生成した全てのgoroutineの終了を確認してから関数を抜けるのが安全面では好ましいが、待ち合わせ処理を適切に記述するのは案外難しい面もある。実際に、待ち合わせ処理の記述に失敗してgouroutineがリークするケースもかなり多い。必要に応じて、sync.WaitGrouperrgroupを使用することで、なるべく簡潔に記述することが望まれる。特にchannelを用いて終了の待ち合わせをする際は、channel(上の例ではerrs)のキャパシティに注意が必要である。後の変更で別のgouroutineを追加する際にキャパシティの変更を忘れると、gouroutineがエラーをwriteできずにリークしてしまう。

Activateパターンは、心得の[1]、[2]そして[3]を満たすことが出来る。また、mutexで管理する変数が増えたり、mutex自体の数が増えてしまった場合は、[4]に従って個別の機能ごとに細かく分轄することもできる。この場合、Activateパターンを再帰的に適用することによって、細かい単位を安全に管理することが出来る。

大切なのは、必要以上に複雑にしないということだ。必須ではないgoroutineを多用する必要はどこにもないし、大抵の処理は1つの関数の中で完遂できる。

contextについて考える

Goroutineの管理と関係が深いcontextについても少し考えたい。contextの導入で世界的にGoのコードがきれいになりつつあるが、コードの隅にはまだまだ闇が存在する。

Start()/Stop()context.Context

Go1.7から標準パッケージに導入されたcontext.Contextによって、従来存在したStart()Stop()のペアによる長期タスクの管理はほぼレガシー扱いとなった。現在ではcontextをcancel()することによってタスクを終了するのが一般的である。

しかし、contextの使用が主流になったことによって、今度はstruct自体にcontextを保持するようなコードが登場し始めた。

type LongTask struct {
    ctx context.Context
    cancel context.CancelFunc
}

func NewLongTask(ctx context.Context) *LongTask {
    // ...
}

このようなコードは比較的危険である。contextがstructに紐付いている場合、goroutineの管理もstruct全体にまたがることになりやすい。繰り返しになるが、goroutineのライフサイクルは小さく閉じた方が安全である。これはすなわち、contextのライフサイクルも小さく閉じたほうが安全と言える。エラー処理の観点からも、Activateパターンを使ったほうが見通しの良いコードになる可能性が高い。

cancel()は何を停止するのか

ところで、contextの「cancel()が何を停止すべきなのか」という部分には曖昧さが依然として残っている。

一般的に言えば、「そのcontextを受け取ってブロックしている関数をキャンセルする」という原則を維持すると、安全なコードになる。一方で、(ブロックしない)関数内で生成されたgouroutineを終了するために、その関数に与えられたcontextを使いまわすケースも散見される。

たとえば以下のような、途中経過を出力する長期タスクの場合、Activateパターンの単純な適用が難しい。こういった場合に、バックグラウンドで稼働するgoroutineをどのように停止するかについてのイディオム、あるいはコンセンサスは存在していないように思われる。

func BackgroundTask(ctx context.Context) (<-chan Report, error) {
    progress := make(chan Report, 1)

    err := someInitialization(ctx)
    if err != nil {
        return nil, err
    }

    go func() {
        defer close(progress)

        for {
            // do we really want to use ctx in this goroutine?
            select {
            case <-ctx.Done():
                return
            case <-time.After(someInterval):
            }

            report, err := someAction(ctx)
            if err != nil {
                return
            }

            progress <- report
        }
    }()

    return progress, nil
}

このコードで難しいのは、初期化処理部分とgoroutineのキャンセルを別々に管理できないところにある。someInitialize()のデッドラインを外部から与えたい場合、ctxにセットするしかない。一方で、もし本当にデッドラインをセットしてしまうと生成されたgoroutineもデッドラインで停止してしまう。また、別の問題点として、goroutine内で発生したらエラーを返す方法も定式化されていない(恐らくはReport.Errのようなものを使うことになる)。

無理矢理にActivateパターンを当てはめるのであれば、自分でgoする代わりにその関数を返して、ユーザー側で呼んでもらうことも出来なくもない。下のような形だ。

type Continue func(ctx context.Context) error

func BackgroundTask(ctx context.Context) (<-chan Report, Continue, error) {
    progress := make(chan Report)

    err := someInitialization(ctx)
    if err != nil {
        return nil, nil, err
    }

    cont := func(ctx context.Context) error {
        defer close(progress)

        for {
            select {
            case <-ctx.Done():
                return nil
            case <-time.After(someInterval):
            }

            report, err := someAction(ctx)
            if err != nil {
                return err
            }

            progress <- report
        }
    }

    return progress, cont, nil
}

このようなコードにすると、cancel()を「今ブロックしているコードをキャンセルする」ためだけに使うことが出来る。また、エラーの処理も比較的しやすくなる。ただ、これで全体コードが良くなるかというと、そこはバランス次第としか言いようがないように思われる。

どうしてもキャンセルを分離したい場合は、BackgroundTask()context.Contextを2つ渡すこともできる。たとえば、先のStatefullを改造してActivate()の代わりに、Put()内でキープアライブ用のgoroutineを作ることも出来る。この場合、生成されたgoroutineはkaCtxがキャンセルされるまで生き続ける。このレベルであれば複雑さとしては許容範囲であるが、若干kaCtxのキャンセルが忘れられる未来が見えなくもない。

func (s *Statefull) Put(ctx context.Context, kaCtx context.Context, value []byte) (err error, lease int, kaError <-chan error) {
    lease, err = s.client.Put(s.withTimeout(ctx), value)
    if err != nil {
        return 0, errors.Wrapf(err, "put request failed"), nil
    }

    errs := make(chan error, 1)

    go func() {
        defer close(errs)

        for {
            select {
            case <-time.After(s.kaInterval):
            case <-kaCtx.Done():
                return
            }

            err := s.client.Keepalive(s.withTimeout(ctx), lease)
            if err != nil {
                errs <- errors.Wrapf(err, "keepalive failed")
                return
            }
        }
    }()

    return lease, nil, errs
}

だれがタイムアウトを決めるのか

contextが導入されて以来、人々はタイムウト処理を忘れつつある。もう少し正確に言うと、タイムアウト処理を呼び出し側に押し付けることを学びつつある。しかし、これは本当に良い傾向なのだろうか。

タイムアウトの適正値を決めるのはいつも難しい。とくに第三者によってカプセル化されたパッケージを使う場合は尚更だ。内部がどうなっているのか(ソースを読まないと)分からないのに、利用者が適切なタイムアウト値を決定することは可能だろうか。

以下のコードは、最近よくあるスタイルで書かれたクライアントライブラリのコードだ。put()get()はRawメソッドであり、どこかに定義されている(きっとHTTPなりgRPCリクエストを発行するのであろう)。

type Client struct {}

func NewClient() *Client {
    return &Client{}
}

// Put stores a value associated with a key name
func (c *Client) Put(ctx context.Context, key string, value string) error {
    return put(ctx, key, value)
}

// PutWhenChanged stores a value with a key name only when the value is not equal to the current value in the store.
func (c *Client) PutWhenChanged(ctx context.Context, key string, value string) error {
    resp, err := get(ctx, key)
    if err != nil {
        return err
    }

    if resp != value {
        return put(ctx, key, value)
    }

    return nil
}

さて、Put()はRawメソッドを1回、PutWithChanged()はRawメソッドを2回呼んでいる。単純に考えて、2倍Rawメソッドを呼ぶのであれば、全体のタイムアウトが2倍になっても良さそうである。しかし、利用者側は実装の詳細は知らないのであるから、最適なタイムアウト値を設定することは難しい。

contextが導入されて以来、context以外でタイムアウトを表現するのはなんとなくダサいと思われがちである。しかし、現実的に考えると、Clientのフィールドに1リクエストあたりのタイムアウト値を保持しておき、ライブラリ内で細かくタイムアウトを設定した方が使いやすいAPIになる。

たとえば、以下のコードでは、ライブラリ使用者はリクエストのタイムアウトについて考える必要が少なくなる。

var (
    DefaultRequestTimeout = time.Second
)

type Option func(c *Client)

// you can use this option to override the default timeout
func WithRequestTimeout(d time.Duration) Option {
    return func(c *Client) {
        c.requestTimeout = d
    }
}

type Client struct {
    requestTimeout time.Duration
}

func NewClient(opts ...Option) *Client {
    c := &Client{
        requestTimeout: DefaultRequestTimeout,
    }

    for _, opt := range opts {
        opt(c)
    }

    return c
}

func (c *Client) Put(ctx context.Context, key string, value string) error {
    err := put(c.withTimeout(ctx), key, value)
    if err != nil {
        return errors.Wrapf(err, "put request failed")
    }

    return nil
}

func (c *Client) PutWhenChanged(ctx context.Context, key string, value string) error {
    resp, err := get(c.withTimeout(ctx), key)
    if err != nil {
        return errors.Wrapf(err, "failed to get the current value")
    }

    if resp != value {
        err := put(c.withTimeout(ctx), key, value)
        if err != nil {
            return errors.Wrapf(err, "failed to put the new value")
        }
    }

    return nil
}

func (c *Client) withTimeout(base context.Context) context.Context {
    ctx, _ := context.WithTimeout(base, c.requestTimeout)
    // go vet complains if you use _ here, hmm...
    return ctx
}

この場合、各メソッドに渡すcontextは、例えばプロセス自体のシャットダウンだったり、厳密なデッドラインが設定されている時にだけキャンセルされることになる。内部のタイムアウトは内部で解決されていることから、利用者側が細かく考える必要が無い。

あなたの関数はctx.Err()を返すべきなのか

ctx.Err()の扱いは実のところ結構に難しい。<-ctx.Done()した後に無意識にreturn ctx.Err()すると、思わぬところで非常に使いにくいAPIが完成する事がある。たとえば、etcdのclientv3ライブラリは、異常時に帰ってくるエラーがほぼ全てcontext deadline exceedeになっていて、トラブル時に何が起きているのか分からない時代があった(今でもそういうところがある)。何か問題が起こると内部でリトライしている間にcontextがタイムアウトするわけだが、ctx.Err()には「どの処理がなぜ完遂できなかったのか」という情報が無いので、これをそのまま返されてしまうと利用者側には問題解決の手段が全く無くなってしまう。

基本的な傾向として、contextを単なる終了フラグとして使っている場合(上のActivateパターンの例のように、selectで意図的に待っているケースなど)は、ctx.Err()よりもnilを返したほうが良いことが多い。また、エラーはなるべくそのまま返さずに、errors.Wrpaf()などで、「どの処理が」の部分を補足するとエラーログが読みやすくなるし、受け取り側を混乱させなくて済む(後述)。

野生のcontext.DeadlineExceededcontext.Canceledを信頼して失敗する

ctx.Err()に関連してもう1つ気をつけなければいけないのは、あなたは関数から戻ってきたcontext.DeadlineExceededcontext.Canceledに依存したコードを書いてはいけないということだ。

contextを受け取る関数がcontext.Canceledを返したとして、それは必ずしもあなたのcontextがキャンセルされたことを意味しない。上でも触れたとおり、世の中には自分で発生させたctx.Err()をそのまま返してくるようなライブラリが存在するため、たまたま内部で使っている別の(あるいは派生した)contextがキャンセルされただけの可能性がある。

err := someFunc(ctx)
if err == context.DeadlineExceeded || err == context.Canceled {
    // ctx might not have been canceled yet!
}

自分のcontextがキャンセルされているかは、必ず自分のcontextに対してErr()することで判断しなければならない。さもなくば予想外の場所で処理が停止してしまうことがある。

逆に、あなたの関数が内部でタイムアウトやキャンセルを行う際は、生のctx.Err()を返すと利用者を混乱させてしまうことがある。前にも書いたとおり、このような場合は適当にラップしてから返したほうが安全性が高まる。

番外編:channelで頑張りたい

正直に言えば私自身は大量のチャンネルを使ってバグの無いコードを書く自信が無い。試しにActivateパターンの紹介で使用した簡単な例を非ブロック化し、mutexの代わりにchannelのみで実装してみた。このコードはまともに動くだろうか。

type Statefull struct {
    client Client

    requestTimeout time.Duration
    kaInterval     time.Duration

    lease int
    putCh chan int
    getCh chan int
}

func NewStatefull(client Client) *Statefull {
    return &Statefull{
        client: client,

        requestTimeout: time.Second * 10,
        kaInterval:     time.Second,

        putCh: make(chan int, 1),
        getCh: make(chan int),
    }
}

func (s *Statefull) Run(ctx context.Context) <-chan error {
    errs := make(chan error, 1) // must be 1!!!

    go func() {
        lease := 0

        err := func() error {
            for {
                select {
                case lease = <-s.putCh:
                    continue
                case s.getCh <- lease:
                    continue
                case <-time.After(s.kaInterval):
                case <-ctx.Done():
                    return nil
                }

                if lease == 0 {
                    continue
                }

                err := s.client.Keepalive(s.withTimeout(ctx), lease)
                if err != nil {
                    return errors.Wrapf(err, "keepalive failed")
                }
            }
        }()

        errs <- err

        // maybe you need to drain putCh and getCh here
    }()

    return errs
}

func (s *Statefull) Put(ctx context.Context, value []byte) error {
    lease, err := s.client.Put(s.withTimeout(ctx), value)
    if err != nil {
        return errors.Wrapf(err, "put request failed")
    }

    s.putCh <- lease
    return nil
}

func (s *Statefull) Current() int {
    return <-s.getCh
}

func (s *Statefull) withTimeout(base context.Context) context.Context {
    ctx, _ := context.WithTimeout(base, s.requestTimeout)
    return ctx
}

このコードの問題点には少なくとも幾つかの問題点がある。

Put()Current()もイベントループに依存するため、処理の並列度が全く上がらない。Keepalive()が何らかの理由で遅延すると、全てのクライアントが影響を受けてしまう。

さらに、Put()Current()も本質的にブロックしたままスタックする可能性がある。イベントループが停止するとこれらの関数でブロックされていたクライアントはそのまま放置されてしまうだろう。putChgetChをドレインすれば解決することも可能であろうが、いつまでドレインしていていればいいのか、という別の問題がある。

これらの問題も、いろいろな工夫をすれば恐らく解決することが可能であろう。しかし、現実的に言えば、単純にmutexを使ったほうが簡単かつ確実な実装になるだろう。

さいごに

この文章で紹介したActivateパターンは単なる1例であって、他に安全な方法があるのであればなんでもよい。とにかく、あなたの会社のgoroutineハンターが過労死してしまう前に、なんとかしてgoroutineリークを起こさないベストプラクティスを確立してあげてほしい。