Goの非同期処理で気をつけると楽になるかもしれないこと

この記事は Gopher道場 Advent Calendar 2018 17日の記事です。

はじめに

第4回Gopher道場の ゴルーチンとチャネル のテーマの際にメンターを務めました。
いくつか質問を受けたり、課題をレビューする中で channel goroutine の扱いが慣れないために、動作が不安定だったり、 エラーの処理が難しくなってしまっているコードが散見されました。

Goに初めて触る人にとって channel goroutine はやはり難しいということだと感じましたので、 この記事では、Goに慣れていない人向けに、Goで非同期処理を書くときに気をつけることを紹介します。
少しでもGoで非同期処理を書く時に参考になればと思います。

非同期処理を記述するときに気をつけると嬉しいかもしれないこと

気をつけたほうが良い点は、シンプルさを保つために意識していることですが、もちろん常にそうすべきというわけではなく、 必要のない非同期で苦しまないために助けになる場合がある要素です。

  • 非同期にしない
  • channel をパッケージの外に渡さない
  • context.Context でちゃんとブロックしている箇所をキャンセル可能にする

非同期にしない

まず本当に非同期として記述する必要があるのか考え直してみましょう。

ライブラリとして提供する場合には、非同期になっていることで使いにくいことがあります。
ライブラリ利用者が使いやすい単位でメソッドを切り出して、利用者が同期 or 非同期を選択できるようにしておくと嬉しいです。
goroutineの数の調整なども利用者側ができたほうがよいでしょう。

// 処理対象の作成と処理を同じメソッドで実行しているので、利用者は処理を分けにくい
func Run(n int) []int {
    target := make([]int, n)
    for i := 0; i < n; i++ {
        target[i] = i
    }

    var wg sync.WaitGroup
    wg.Add(len(target))
    res := make([]int, len(target))
    for i, v := range target {
        go func(i, v int){
            defer wg.Done()
            time.Sleep(time.Second)
            res[i] = v * 3
        }(i, v)
    }
    wg.Wait()

    return res
}

非同期については利用者側に任せてしまうと下記のような実装になります。

type Data struct {
    Value int
}

// Eval が分けられる
// これを `go d.Eval()` で呼び出して利用する
func (d Data) Eval() int {
    time.Sleep(time.Second)
    return d.Value * 3
}

// GenTargets は軽い処理
func GenTargets(n int) []*Data {
    targets := make([]*Data, n)
    for i := 0; i < n; i++ {
        targets[i] = &Data{Value:i}
    }

    return targets
}

channel をパッケージの外に渡さない

channel はcloseであったり、バッファーの大きさであったり、扱いが難しい部分があります。
パッケージ内で作成した channel をパッケージの外から触れるようにしてしまうと、 考えないといけない状況が増えて辛いときもあるため、 channel はパッケージの中に閉じ込めると楽になるときがあります。

func Run() chan<- int {
    ch := make(chan int, 10)
    go func() {
        for i := range ch {
            fmt.Println("num:", i)
        }
    }()

    return ch
}

// mainがchanにデータ入れたりして頑張る
func main() {
    ch := Run()
    ch <- 1
    ch <- 2
    time.Sleep(time.Second)
}

下記は channel を閉じ込めた実装例です。

type Runner struct {
    ch chan int
}

func NewRunner(buf int) *Runner {
    return &Runner{
        ch: make(chan int, buf),
    }
}

func (r *Runner) Add(i int) {
    r.ch <- i
}

func (r *Runner) Run() {
    for i := range r.ch {
        fmt.Println("num:", i)
    }
}

// mainはchanを意識しない操作しかできない
func main() {
    runner := NewRunner(10)
    go runner.Run()
    runner.Add(1)
    runner.Add(2)

    time.Sleep(time.Second)
}

readのパターンに対してはcallbackとして渡してあげる実装も選択肢としてあります。

func Run() <-chan int{
    ch := make(chan int, 10)

    go func() {
        var count int
        for {
            time.Sleep(100 * time.Millisecond)
            count++
            ch <- count
        }
    }()

    return ch
}

// mainがchanを扱っている
func main() {
    ch := Run()
    for i := range ch {
        fmt.Println("num:", i)
    }
}

callbackで記述すると下記のようになります。

func Run(cb func(i int)) {
    ch := make(chan int, 10)

    go func() {
        var count int
        for {
            time.Sleep(100 * time.Millisecond)
            count++
            ch <- count
        }
    }()

    for i := range ch {
        cb(i)
    }
}

// mainはchanを意識しない
func main() {
    go Run(func(i int) {
        fmt.Println("num:", i)
    })

    time.Sleep(time.Second)
}

context.Context でちゃんとブロックしている箇所をキャンセル可能にする

context.Context は実行している処理をキャンセルすることができますが、それは適切に Context.Done() をハンドルした場合です。
これもやはり channel に慣れていないと、 Context.Done() を待つべき処理が良くない箇所に書いてしまうことがあるようなので、 気をつけたほうが良い点です。

例えば、 channelをパッケージの外に渡さない であげた例で context.Context を使用した良くない例を上げてみます。

type Runner struct {
    ch chan int
}

func NewRunner(buf int) *Runner {
    return &Runner{
        ch: make(chan int, buf),
    }
}

func (r *Runner) Add(i int) {
    // バッファーが満杯であり、ブロックしますが、途中でやめられない
    r.ch <- i
}

func (r *Runner) Run(ctx context.Context) {
    for i := range r.ch {
        select {
        case <-ctx.Done():  // r.chからの取り出しの単位でしかキャンセルができない
            fmt.Println("end")
            return
        default:
        }
     
        fmt.Println("num:", i)
    }
}

func main() {
    // このmainの処理において、Runでtimeoutは拾われることはほぼありません("end"という文字列は出力されない)
    ctx, cancel := context.WithTimeout(context.Background(), time.Second)
    defer cancel()

    runner := NewRunner(10)
    go runner.Run(ctx)
    runner.Add(ctx, 1)
    runner.Add(ctx, 2)

    time.Sleep(2 * time.Second)
}

channel は書き込みと読み込みでブロックする可能性があります。
そのブロックする箇所で select でちゃんと受け取れるようにしましょう。

type Runner struct {
    ch chan int
}

func NewRunner(buf int) *Runner {
    return &Runner{
        ch: make(chan int, buf),
    }
}

func (r *Runner) Add(ctx context.Context, i int) error {
    select {
    case r.ch <- i:
    case <-ctx.Done():
        return ctx.Err()
    }
}

func (r *Runner) Run(ctx context.Context) {
    for  {
        select {
        case i := <-r.ch:
            fmt.Println("num:", i)
        case <-ctx.Done():  // r.chに何も書き込まれることがなくても、キャンセルで即座に終了する
            fmt.Println("end")
            return
        }
    }
}

func main() {
    // ちゃんとこのtimeoutで終わるようになる("end"が出力される)
    ctx, cancel := context.WithTimeout(context.Background(), time.Second)
    defer cancel()

    runner := NewRunner(10)
    go runner.Run(ctx)
    _ = runner.Add(ctx, 1)
    _ = runner.Add(ctx, 2)

    time.Sleep(2 * time.Second)
}

これは元々 channel を処理している例なので、わざわざ最初のような書き方をすることはないのかもしれないですが、 まだ context パッケージがなかった頃のメソッドや、インターフェースを満たすために context.Context を受け取らないが、 ブロックする処理などを for n.Next() {...} などでブロックしつつloopするときには、それを待つための channel を作成して、 キャンセルがキャンセルしたいと思った時にできるようになったほうがよいでしょう。

まとめ

Goの channel goroutine を利用して、非同期処理を書いていくための気をつけることを書いてみました。 すべてのパターンに対してよい実装というわけではありませんが、Goを書くときの参考にしていただければと思います。

kuiperbeltでwebsocketする

この記事はOSS紹介 Advent Calendar 2017 の 25日目の記事です。


「サーバーからクライアントにPushしたい!」、そんな時に便利なツールの紹介です。

GitHub - mackee/kuiperbelt: Asynchronous Protocol Proxy

kuiperbeltはクライアントとWebSocketをつないでやり取りする部分を担ってくれます。

使い方

上記githubリポジトリにreleaseがあるので、ダウンロードできます。

readmeにある通りクライアントが接続してきた時と切断してきた時のcallback先を書いてあげて起動。 あとはアプリでcallback先を実装します。

kuiperbeltサーバーが複数台ある場合、どのクライアントがどのkuiperbeltに接続しているかはkuiperbelt側では管理していないので、アプリ側でconnectしてきた X-Kuiperbelt-Session とどのkuiperbeltが接続しているのかを記録してあげます。

あとはPushしたくなった時に X-Kuiperbelt-Session を指定して、kuiperbeltのsendを叩いてあげるとクライアントにPushされます。

詳しくは 日本語のREADME があるので、そちらを見てもらったほうが良いと思います。

嬉しいこと

  • WebSocketの接続がアプリ側の管理ではなくなる

アプリは(サービスの種類に依ると思いますが)、日に何回もデプロイしたりします。
その度にWebSocketが切断されてしまうと困りますし、再接続がまとめて流れてきてしまうと困ります。
kuiperbeltはWebSocket経由でのPushのみを薄く提供してくれているため、kuiperbeltの更新をしたい場合以外では落としたりすることはないので、これらのことを考えなくても良くなります。

  • クライアントからWebSocket経由で情報は送信できない

Pushする機能のみなので、クライアントからは送信できません。
用途にもよりますが、WebSocketでガンガン通信をするからオーバーヘッドを減らしたいということがなければ、クライアントからのリクエストは普通にAPI叩いてもらったほうがログ的にもサーバーの実装的にも複雑にならないので、嬉しいかなと思います。

ちょっと面倒なこと

  • 複数台あるkuiperbeltサーバーに対して、クライアントがどこに接続しているは保存しておかないといけない

X-Kuiperbelt-Session とサーバー内におけるアカウントの識別子を紐付けておくだけで送信できるようになると非常に嬉しいですが、今はまだできないです。
(kuiperbelt自体が単体のバイナリとして動作するようになっているので、当然ではありますが)

例えば、10人のクライアントに同じ情報を送信したい時に全員の接続しているを引いてこなければならないですが、それぞれが別々のkuiperbeltに接続している場合、アプリ側で10回別々のkuiperbeltのsendを叩いてあげないといけません。

いつかクラスタみたいなものが形成されて、一つのエンドポイントにまとめて投げたら、うまいことやってくれるとかになったら嬉しいですね!

まとめ

アプリケーションとしては、WebSocketについて考えず、普通のAPIエンドポイントを実装していくだけでクライアントにPushできる便利なKuiperbeltの紹介でした。

ISUCON7優勝しました [MSA]

ISUCON7優勝しました

ISUCON7

ISUCONとは

お題となるWebサービスを決められたレギュレーションの中で限界まで高速化を図るチューニングバトル、それがISUCONです。過去の実績も所属している会社も全く関係ない、結果が全てのガチンコバトルです。

運営・主催・協賛・関係者の皆様、非常に楽しいイベントでした。
本当にありがとうございます。

本戦

試合開始前

まず会場に行く途中に不幸な事故がありました。

本戦の問題

本戦の問題はクッキークリッカー+ソーシャルなゲームでした。
部屋を作成し、作成した部屋に入った人達で椅子をクリックして、アイテムを購入する。
そして、クリック/購入を部屋の参加者全員に伝えるためにwebsocketでほぼすべての通信をおこなう。

チームでの作業の流れ

チーム構成は下記のとおりです。

  • ken39arg => アプリ、インフラ
  • mizkei => アプリ
  • suzuki => インフラ

言語はGoを選択しました。

  1. レギュレーションをみんなで読む
  2. デプロイ出来るようにする
  3. とりあえず初期状態でベンチマーク実行
  4. nginxとMySQLのログ設定
  5. MySQL使って時間を取得していた箇所を修正
  6. 商品情報をキャッシュ
  7. 昼食
  8. websocketの接続先を分散させる
  9. 商品の価格などをキャッシュ
  10. 部屋ごとのゲーム状況のキャッシュ
  11. 購入可能アイテム判定時の無駄処理削除
  12. 4台でアプリを実行する
  13. 再起動後に不整合が発生しないように修正
  14. ログ消し
  15. そして、優勝
  16. 懇親会

流れだけ見るとキャッシュが多かったですが、不整合をおこさず、 再起動後も部屋への参加一人目が少し遅くなるのみで、再起動前の状況は再現されるので、 そこまでめちゃくちゃなことではないかなと思います。

レギュレーションをみんなで読む

とりあえずチームメンバーみんなで読みました。
websocketの接続先を指定する方法があるんだね、ぐらいのことは言ってました。

とりあえず初期状態でベンチマーク実行

とりあえず初期実装のgolangにアプリケーションを変更して、ベンチ回しました。
なんだかよく覚えていませんが、なんかわちゃわちゃして、実行するまで時間がかかってしまいました。

この間に私はアプリを読んでいましたが、big.Intが使用されており、ありえないほど大きな数字を扱っているので、MySQLやRedisでうまいことするのは無理そうという感想だけつぶやいていました。

何も触らない状態での実行でscoreは 10401

nginxとMySQLのログ設定(ken39arg, mizkei)

とりあえず、nginxとMySQLのスローログを吐くようにしました。
結果から言うとこの2つはほとんど意味はありませんでしたが、私が設定するのに手間取りました。

デプロイ出来るようにする(suzuki)

とりあえず、変更を反映する際にコマンド一発でできるようにしてもらいました。
これもケアレスミスなどあって、少し導入に時間がかかってしまいました。

MySQL使って時間を取得していた箇所を修正(ken39arg) 12:04

MySQLcurrent_time を利用して現在時刻を取得していた箇所があったため修正。
導入で色々もたついていたので、一個目の小さい変更すらもう昼を過ぎていました。

scoreの記録はなし(確か変化なかったと思います)

昼食

豪華なお弁当が支給されましたので、美味しくいただきました。

商品情報をキャッシュ(ken39arg) 12:31

購入する商品は変更のないデータであったため、起動時にメモリにのせるようにしました。

scoreは初期と全く同じ 10401

websocketの接続先を分散させる(mizkei) 13:07

ここまで弁当を美味しく食べるぐらいしか仕事していなかった私ですが、 websocketの接続先を分散させることにしました。

このアプリケーションでは最初にリクエストしてきた時にどこにwebsocketをつないで欲しいかをクライアントに返却することができます。
とりあえず部屋への参加リクエストが来たら、まずメモリにある情報から接続先の部屋を探し、なかった場合はMySQLに部屋名を挿入するようにしました。
また、MySQLへのインサートがduplicateだった場合(mysql.MySQLError.Number == 1062)はselectをして、接続先を引き、メモリに保存しました。
これにより、同じ部屋の人たちは同じサーバーに接続することができるようになるため、その後のキャッシュもしやすくなる予定でした。 もちろん、どれか一つのサーバーが再起動されてもDBから引いてくるので確実に同じサーバーになります。
ただ、websocket以外のリクエストはあまりにも少なかったので、ベンチマークがリクエストする対象は1つだけに設定しました。

この修正自体はscoreをあげるものではなかったので変わらず 9784

商品の価格などをキャッシュ(mizkei) 15:47

一つ前の修正からだいぶ時間が空いていますが、この間にken39argがMySQLを使ってなんとかうまく処理する方法を見つけようと模索し、私はうーんと唸っていました。

各サーバーのCPU、メモリ、帯域、スロークエリ、などなど、どれも原因ぽさがないので、どうしようかと思い、 とりあえずpprofを入れて、重いやつのキャッシュをはじめました。

商品の価格情報などは単純な値ではなく、計算式があり、購入やステータスを出すたびにそれを計算していたため、その計算が発生しなくなるよう、キャッシュをおこないました。

しかし、scoreは何の変化もなし

部屋ごとのゲーム状況のキャッシュ(ken39arg) 16:07

MySQLを利用して1時間ほど悩んで実装していたそうですが、駄目ということになり、その実装はマージされることなく、closeされました。

そこでだいぶ大きめのキャッシュ修正を入れました。
椅子をクリックした際に増加する椅子について、MySQLから引いていたデータをメモリに持たせるようにして、 集計にかかる時間を短縮しました。
websocketの接続先を分散させる修正で同一の部屋であれば、必ず同一のサーバーに接続しているので、部屋ごとの情報をキャッシュしました。

ただ、何回か一回は落ちてしまったので、なかなかマージできず、16時を過ぎて、ようやくマージされました。
また、ほぼ同時に商品購入履歴についてもキャッシュを導入しました。

ただし、scoreは落ちて 6504

購入可能アイテム判定時の無駄処理削除(mizkei) 16:39

アプリを書いていた私とken39argは何をしているかを確認するために、互いのPRを見るようにしていました。
そして、私はどう考えてもここまでの修正でスコアが上がることはあっても下がることはないだろう、 と思っていたため、なにか勘違いしている可能性があると思い、一人でレギュレーションを再度読みました。
ここで「1秒以内にステータスやリクエストが返ってこない場合、クライアントは切断します。」という記述を見つけました。
ISUCON常連であれば当然レギュレーションをしっかりと読んでいるでしょう。しかし、私達のチームはほぼ初心者の集まりなので、その部分が抜けていました。

ここまでの修正はスコア計算の計算式に入るaddIsuやbuyItemを高速化する修正がメインで、ステータスの取得は放置状態でした。
そこでステータスを計算する際に1ms毎の未来を1000ms先まで計算する処理(つまり、1000回ループする)で、 big.Intの比較処理があまりにも時間をとっていたので、その部分をループの外で計算しておき、計算を減らしました。

ここでステータスを返す処理が1秒に収まったのでしょう。 これまでのすべての変更が効いて、scoreは 48745

4台でアプリを実行する(ken39arg, suzuki) 16:47

DBのいるサーバーのリソースがだいぶ余っているので、DBのサーバーでもアプリを動かすようにしました。(suzuki)

また、DBのサーバーの負荷を上げ過ぎないようにするために、DBにたてたアプリにはwebsocketの接続は少なくなるようにして、4台で処理するようにしました。(ken39arg)

scoreは伸びて 57103 に達し、特別賞獲得。

壁を作りました。

再起動後に不整合が発生しないように修正(ken39arg) 17:11

椅子追加や商品購入のキャッシュは再起動で失われる実装になっているのは承知していたので、 レギュレーション通り、任意のタイミングで再起動されても問題ないように、 部屋作成(再入場)時にMySQLからデータの復元をおこなうようにしました。
この段階から再起動して、ベンチマークを回しつつ、再起動後もちゃんとデータが復元されることを確認していました。

ログ消し(ken39arg) 17:45

ログ出力やpprofなどを消しました。

そして、優勝

正直、運が良くて準優勝だろうという話をしていました。
理由として、2,3位のチームは30000点でしたが、ISUCON強者はまず2台で最適化して最後にスケールさせるという話を聞いており、前年も1時間を切ってから、スコアを伸ばしているので、最初から4台すべて使っていた私達のチームは10万点が必要なのでは、と思っていました。

結果は優勝で、非常に嬉しかったですが、まだできることがあったのでやりきったという感じはありませんでした。
あとまだ誰もに気づかれていないと思いますが、記念写真の時にトロフィーの裏表も間違えていたので、その点も心残りです。

懇親会

入刀しました。

懇親会で出題者の方に話を聞いて、私達のチームでもまだまだ全然想定されるところまで達していないことを知りました。
非常に悔しい。

まとめ

ISUCON7で優勝しました。
運営の方々はありがとうございました。本当にお疲れ様でした。
そして、私が社内でISUCON参加メンバーを募集した際に、手を上げてくださったチームメンバーの二人にも感謝です。
応募の締め切り一週間前までメンバーが揃っていなかったので、出場せずに終わるかなとかも思ってましたが、無事出場することができてよかったです。

ISUCON7 予選1日目1位通過しました

ISUCON7予選一日目 一位通過しました

ISUCON7

ISUCONとは

お題となるWebサービスを決められたレギュレーションの中で限界まで高速化を図るチューニングバトル、それがISUCONです。過去の実績も所属している会社も全く関係ない、結果が全てのガチンコバトルです。

運営の方々のご尽力もあり、今年のISUCON7も無事開催されました。
ISUCON7 開催&日程決定 本当にありがとうございます。
1日目、2日目共に開始時刻が遅延することにはなりましたが、競技中は問題はなく、快適に開発をすることができました。

遅延している間、チームでは、「今どんな発言も運営の人にとってはノイズみたいなものだから、黙って待って、開催出来たら感謝、振替で参加できなかったとしても、ここまで頑張ってくれたことに感謝しよう。」という話をしていました。
無事開催され、本当に運営の方々には感謝しています。

チーム「MSA」

私はチーム「MSA」として予選一日目に参加させていただきました。
社内でプログラミング経験者を募集して、まずsuzukiさんが一緒に出てくれることになり、応募締め切りの一週間ほど前にken39argさんが一日目であれば参加可能ということで、チーム結成となりました。
チーム名はそれぞれの苗字の頭文字からとっています。

チームでの役割としては、私はアプリを担当し、suzukiさんはインフラ、ken39argさんにはアプリを実装してもらいつつインフラもみてもらいました。
私も社会人3年目でweb開発はまだ2年も関わっていないぐらい、suzukiさんも新卒でしたので、ken39argさんに引っ張ってもらう感じでした。

事前準備

とりあえず、一緒にランチに行き、「こんな準備がいるかなー」とか「こんな問題がでるかなー」とか話していました。
これが予選の10日ほど前だったと思いますが、githubリポジトリを作成して、issueに当日の流れを書くぐらいで、チームで集まって過去問を解くなどはしていなかったので、正直色々もたつくだろうと思っていました。(実際ちょっともたついてしまいました。)

予選当日

先にISUCON7予選の問題について触れておくと、 isubata というアプリでした。
チャットアプリケーションである idobata が元ネタだと思います。
チームの作業の流れとしては下記のような流れでした。

  1. スローガンを決める
  2. レギュレーションと当日マニュアルをみんなで読む
  3. とりあえず初期状態でベンチマーク実行
  4. 構成確認
  5. gitにコードを含めて、変更後デプロイをコマンド実行でできるようにする
  6. nginxとmysqlのログを吐くようにする + mysqlのインデックス張る
  7. ユーザーのアイコン画像の情報がDBに入っていたので引き剥がす
  8. 既読管理によるDBへの集計クエリループ削除
  9. nginxでCache-Control設定
  10. メッセージ作成のループクエリ削除
  11. 画像のproxy方法の変更
  12. 終了2時間前にスコアが急激に下がって焦る
  13. 終了30分前からは反省を始める

(残念なことにISUCONについてはほぼほぼ初心者の集まりだったためスコアを記録するなどしていないので、どういうスコア推移になったのかはわかりません。)

スローガンを決める

開始が延期したこともあって、チームでの作戦を午前中に立てていました。
そこで決めたスローガンは「落ち着く」「いつも通りのことをする」「終了1時間前になったら変更をやめる」の3つでした。
特にISUCON用に準備していたわけでもないので、いつもやっていること以外はやっても不安定になるだけだろうということで、チャレンジはしないことにしました。

レギュレーションと当日マニュアルをみんなで読む

どこまで変えていいのか、最後の測定は再起動などどうなるのか、知っておかないとスコア残らないなんてことにもなるので、じっくり読みました。

とりあえず初期状態でベンチマーク実行

初期でちゃんとアプリケーションが動くかどうか確認するため、golangに切り替えてベンチマークを実行しました。

構成確認

最初の構成を確認して、appが2台とdbが1台であることを確認しました。
1台だけcpu、メモリなどが違うなどの偏りはありませんでした。

gitにコードを含めて、変更後デプロイをコマンド実行でできるようにする (@suzuki)

gitにコードをpushし、makeコマンドのみで変更の反映ができるようにしました。

nginxとmysqlのログを吐くようにする (@ken39arg)

alpとpt-query-digestで解析できるように、nginxのアクセスログmysqlのslowlogを書き出す用に設定しました。
結果から言うと、準備不足で最後までpt-query-digestの実行はできず、コードを見つつ、slowlogを目でざっと追ってindex張るなどしました。

ユーザーのアイコン画像の情報がDBに入っていたので引き剥がす (アプリ: @mizkei、インフラ: @suzuki)

ユーザのアイコン画像がDBにBLOBで入っていたため、それを引き剥がしました。
アプリとしては特に難しいこともないので、 POST /profilemysqlにデータを入れていた箇所をファイルに書き出すようにして、 /initialize でデフォルトユーザのアイコンをDBから引き出して、ファイルに保存しました。

インフラとしては、初期の状態ではDBサーバのリソースがだいぶ余っていたのでappもおいてしまい、DB+appのサーバーに POST /profile をproxyして、静的ファイルは全てそこにおいておき、 /icons へのリクエストをそちらに流すようにしました。

既読管理によるDBへの集計クエリループ削除 (@ken39arg)

isubataではチャンネルのメッセージに対して、個々のユーザがどこまで読んでいるかを記録し、チャンネルの一覧部分で、未読件数をバッジで表示していました。
この未読件数はチャンネルのループに対して、メッセージの集計クエリがループするというだいぶ重いものになっていました。
isubataではメッセージの削除機能は存在していなかったため、メッセージの総数などはテーブルのカラムに追加して、ループクエリは削除しました。
これも /initialize でデフォルトのメッセージをカウントしてテーブルの更新をしていました。

nginxでCache-Control設定 (@suzuki)

イコン画像などは変更がなければ返す必要すらないので、304 not modifiedになるようにnginxで設定を行いました。

メッセージ作成のループクエリ削除 (@mizkei)

メッセージ作成用の関数に jsonifyMessage という、いかにもjson用の構造体など作っていますよ、みたいな名前のやつがいましたが、こいつは中でクエリを実行していて、ループクエリしていました。
そこでぱぱっとクエリのループを消して、よしベンチ、と思ったらfail。
メッセージに紐づくユーザを引いてくるという処理なのですが、元のクエリではuserのidは引いていなかったので、紐付けるidがないという状況でした。

私のくだらないミスで、10分で終わる修正が50分になり、 ken39argさんにも何がおかしいか見てもらうなど時間を使ってしまいました。

画像のproxy方法の変更 (@suzuki + @ken39arg)

ここでボトルネックがDB+appサーバーのリソースに移っていたので、appを消し去り、DBのみとして、他2台でappの処理をすべて行うようにしました。
イコン画像の処理はメモリを使うこともあって、1台で POST /profile を受けることはせず、それぞれ保存して、nginxからtry_fileで存在しなかったら、もう片方にproxyするようにしました。 ここでスコアは46万点を超えました。

終了2時間前にスコアが急激に下がって焦る

残念なことにここで私達の修正は終わりました。19:30を過ぎた頃だったと思います。(一応1日目の終了時刻は21:15ぐらい)
再起動試験をしつつ、DBのチューニングにようやく取り掛かろうというところで、急にスコアが30万まで下がりました。
原因がわからず、もう一度回すと20万、10万と下がっていきました。
なんだ?と思っていたら、appサーバー2台がswapし始めていました。
今まで変更のたびに再起動していましたが、DBのみの修正でapp再起動しななかったのですが、連続でベンチを実行することでメモリが太っていくことがわかったのです。

これは推測でしかありませんが、アイコン画像のアップロードについて、ioutil.ReadAllしている箇所があり、これが良くなかったのかなと思っています。
普段はReadAllなんてほぼほぼしないですが、ボトルネックにもなっていなかったし、画像データのsha1からファイル名を作っていたりと、修正が若干面倒だったので、放置していたのがよくなかった。

(fujiwara組の記事にありますが、GOGCの設定でも充分に防げていたようです。)

終了40分前からは反省を始める

原因はわかったので、修正どうするという話になりました。
とりあえず、数回のベンチマークであれば30万点程度までの落ち込みで済んでいたので、アプリの再起動だけして、放置しました。
不安ではありましたが、無茶な変更してfailするよりはこのままにしようという決断をしました。
nginxやmysqlのログも切らずにそのままにして放置。

その間「これがよくなかったか。」「気づけたよなー」という話をずっとして過ごしていました。
私もken39argさんも「落ち着く」ができておらず、アプリの修正でくだらないミスをしていたりして、時間の浪費があったので、これはなくしたかったという反省をしていました。

まとめ

結果として私達のチームは一日目予選一位で通過しました。
ISUCON7 本戦出場者決定のお知らせ
ただ、全体としては5位であり、まだまだ改善できるポイントはチームの役割も含めてたくさんありました。
決勝で勝つために予選での反省点を活かしていきます。

最高に楽しいイベントを開催して頂いている運営の方々に感謝です。
決勝もよろしくお願いいたします。

ISUCON6に参加してきました

いい感じにスピードアップすることを目的とする大会「ISUCON」の第6回予選に参加しました。
結果としては、予選敗退でした。
初めての参加でしたが、苦しさと楽しさを感じることができて非常に勉強になりました。

やったことまとめ

ISUCON前

チームは同じ職場のtkyshmさんとgurisugiさんと組みました。
チーム名は「RedComp」。【赤】と【粉】です。 私はアプリを担当し、tkyshmさんがインフラ周り、gurisugiさんが司令塔といったような役割でした。

tkyshmさんと私はISUCONへの参加が初めてだったため、gurisugiさんに指導をしてもらいつつ、 pixiv private isuconと去年のISUCON過去問を解きました。

大体は業務でやっていることと変わらず、MySQLでちゃんとインデックスはって、それを使うようにし、 ループクエリにならないようにするなどをしました。
普段の業務はPerlなので、それをGoでさっと書けるようにしました。
また、redisを使った実装なども一通り触り、準備は十分かなという気になっていました。

ISUCON

ISUCON予選本番でやったことは下記のようなことです。

  • azureサーバ構築
  • Goのコード読み
  • MySQL周りの修正
  • isudaとisutarを一つに
  • Perlへの切り替え

動いていたサービス自体の概要としては、あるキーワードについて、その内容が書かれている。
記事の書かれたキーワードが他の記事に含まれているときにはそのキーワードのページへのリンクが作成される。
つまりははてな
各ユーザはスターをつけることができる。

この中で私がやったことをつらつらと書いていきます。

Goコード読み

最初にGoのファイルの有るディレクトリを開いたときに、いくつかgoファイルがあり、 main分割してるのかと思ったのですが、開いてみると、isudaとisutarという2つにmain関数が存在していました。
systemdで確認するとisupamというserviceも存在しており、これはなかなかに面白いのでは、と思っていました。

ざっとコードを読んで、isutarはスター(お気に入り?)のようなものを管理しているだけで、別れている必要がなく、 一つにするのも簡単だなと思いました。
テンプレートの中にループクエリがあるわけでもなく、メインの処理の中にも一つぐらいしか、ループクエリはありませんでした。

この時私は、「インデックスをはって、isudaとisutarを一つにすれば、とりあえず、スコアは跳ね上がるだろうなー」とか思ってました。

goのコードを変更せずにベンチを回すとタイムアウトの減点で0点でした。

isudaとisutarを一つに

isutarは処理はスターの登録とスターの取得でした。

登録の際にはisudaの方に記事があるかを確認してから登録し、取得の際にはisudaからisutarにリクエストが飛ぶなど、 相互依存の関係にありました。

とりあえず、gurisugiさんにisutarのテーブルをisudaのdbと同じところに作成してもらいました。
スターの取得処理は単純にキーワードで引いているだけだったため、isutarからisudaにコピーするだけ。
スター登録処理もisudaからポストしていた値をそのままスターのテーブルにinsertするだけ。
初期化処理もtruncateするだけ。
一瞬で統合は終わりました。

この間にtkyshmさんがキーワードの長さをdbに登録するようにしてくれていたので、それを使って処理をするように書き換えて、 統合自体はこの処理をmergeした後にしようと思っていました。
結果から言えば、統合したコードは最後までmasterにはいることはありませんでした。

キーワードの長さをdbに登録するようにしてもベンチはまだ0点。

redisからkeywordを取ってくる

キーワードをリンクにするのが重い、ということでループクエリになっていたキーワード取得の部分をredisから引くようにしました。

キーワードの長さは既にdbに格納されるようになっていましたので、初期化処理の際にキーワードを値、キーワードの長さをスコアにするようにredisに格納しました。
キーワードのinsertの際はredisにも値が格納されるようにし、ループクエリとなっていた箇所はredisからの所得に置き換わりました。

しかし、これをしても点数は200点程度。初期実装のperlですら1800点ほど出しており、もう焦りが私を満たしていました。

regexpをなくす

これ以上はどうにもということで、pprofを打ち込んだのですが、圧倒的にregexpの置換が支配的でした。
みんなのGo言語にもありますが、Goは簡単な正規表現の範囲では、他の言語の正規表現実装よりも遅いです。

MustCompileしてReplaceしている箇所を消して、strings.Replaceに変えました。
ただ、これではキーワードの分だけ記事内容を全部舐めて置換する操作は消えないので、非常に重い。

予選が終了した後にtwitterでも多く流れていましたが、Replacerで置換するのが良いです。

傍観者と化す

この時点で既に16時半ごろでしたが、gurisugiがperlでぱぱっとキーワードの取得の部分をredisにしてくれました。
この修正でいきなり4万点までいき、isudoとisutarを統合したら7万まで点数は伸びました。(同時になんかちょこちょことした修正はしましたが、おそらく大きくは効いていない)

goでやったことと全く同じことをしただけでこの差でしたので、もう私の心は壊れかけていました。
現場では、「ごおおおおおおおおおおおおおおおおおおおおおおおおおおおおおふぁあああああああああああああああああああ」と叫んでいるだけの存在となり、 ただただ申し訳なかったです。

まとめ

私はあまりにもGoを書きたいという思いが強すぎた。
このような自体をおこさないようにするため、普段の業務からGoを書くようにしたい。(願望)

goを捨てる決断をするときも、私はまだ心の中で「待ってろよ!必ず戻って来てみせる!!」とも思ってました。
非常に悔しかったですね。また開催されるときには挑戦したい。

一緒にチームを組んでくれた二人には非常に感謝しています。
運営の方々には、このような楽しいイベントを開催して頂いて、本当にありがとうございます。

現場で使える実践テクニック「みんなのGo言語」

現場で使える実践テクニック「みんなのGo言語」

著者の一人であるfujiwaraさんから献本をいただきました。
fujiwaraさん、著者の方々、および技術評論社様ありがとうございます。

各章について、簡単に所感を書いていきます。

はじめに

「はじめに」にはGoの利点が簡単にまとまっています。
LL言語のような手軽さ、パフォーマンス、およびシンプルさなど、 この2ページを読んだだけでGoを使ってみようという気になるのには十分ではないでしょうか。

第1章

第1章はGoを使い始める準備の章です。

紹介されているツールはどれも気持ちよくGoを書いていくために有用なものばかりなので、 入れておいて損なものはないでしょう。

ファイル分割やパッケージ分割の考え方なども示されています。
多く語られているわけではありませんが、最小かつ十分な例と共に説明されており、 Goで少し大きなコードを書くことになった時には、非常に参考になると思います。

「現場で使える実践テクニック」ということで、vendoringやMakefileの書き方も綺麗に紹介されています。
ケルトンとして参考にするには十分なものであると思います。

"Goに入ってはGoに従え"、節のタイトルになっており、よく目にする言葉です。
Goで文法を勉強した後に何気なく書いていると陥ってしまうことなどが示されています。
mapがスレッドセーフではない点など、文法を勉強した後に一度目を通して意識しておきたい点が押さえられています。

第2章

第2章はGoをマルチプラットフォーム対応にしていくための注意点を示してくれる章です。

Goではクロスコンパイルができますが、愚直に書いているだけでは、 プラットフォームによっては予期しない動作をすることがあります。
この章では、マルチプラットフォームで動かすために知っているべきことを紹介してくれるので、 CLIツールを作るときなどに参考になると思います。

また、マルチプラットフォームで動作させるための注意点の中に、 Goのテクニックも織り交ざって紹介されているので、 "マルチプラットフォームはまだあとでいいや"と思わず、読んで欲しい章だと思います。

第3章

第3章は現場で実用となるアプリをGoで書くときのポイントについて、解説されています。

ある程度の大きさのアプリケーションを動かすことになれば、大量のログやデータのやり取りが発生します。
その中で、io.Writerやio.Readerをラップすることで、バッファリングをうまく制御し、 大量のデータをやり取りするときにでも問題なく処理ができるようにするテクニックが紹介されています。
また、問題が起こった時など、人間が調査する必要がある点についても言及し、 人に見やすいアプリケーションを作るポイントも紹介されています。

io.WriterなどはGoを書いている中で最も多く利用するインターフェースの一つではないかと思います。
テストの時に出力先をBufferにして、出力を確認するするなど非常に便利に利用できます。

また、安全なアプリケーションの終了の仕方も載っており、 1.7でxパッケージからcontextパッケージになったcontextを利用したキャンセルなどのパターンも紹介されています。

第4章

第4章はGoでのCLIツール作成についてです。

Goはクロスコンパイルが簡単に行うことができ、シングルバイナリで配布することができます。
その点から、CLIツールを作成することにも非常に向いています。
この章では、どのようなパッケージの構成でCLIツールを作成していくのかに始まり、 flagパッケージを利用して、コマンドラインオプションの設定などを解説していきます。

特にflagパッケージの実装を追っていき、独自のコマンドラインオプションの型を追加する節は、 読んでいるだけで楽しく興奮してくるような面白さがあります!!

また、CLIツールは誰か(作成した自分も含む)が使い続けていく点をあげ、 メンテしやすく、使いやすくなるようなポイントを示してくれています。

第5章

第5章はreflectについてです。

この本を読んでこれからGoを始めようとしている方は、この章は一旦さらっと読むだけでも良いかもしれません。
この章でも言及されていますが、必要となるまで利用しないようにするのが、reflectだと思います。
しかし、現実の問題に対処していくとなると、いずれ必要になることでもあります。
その時には、この章は問題に対処するため、非常に有用な情報を与えてくれるでしょう。

上記のようには書きましたが、reflectについて非常に丁寧に書いてくださっていますので、 reflectを使いはじめるとしたら、この章は参考になることばかり載っていると思います。
reflect.SelectCaseを初めて知り、reflectについての自分の知識のなさを実感しました。

reflectを利用した際のパフォーマンスについても書かれており、ちゃんと良くない部分にもフォーカスしています。

第6章

第6章はGoでのテストについてです。

Goでのテスト、ベンチマーク、及びExampleの使い方などが紹介されています。
Exampleはdocsでコードの確認が簡単にでき、書かれていると他の人がdocsを見た時に非常に助かるものです。

また、並列が得意なGoにおいてのData Raceの検知方法や、 ちょっと面倒なMockの方法など解説されています。

1.7で追加されたテストの機能についても所々で触れられています。


全体まとめ

私の感想としては、チームでGoを使いはじめるのであれば、第一章は必読と言って良いと思います。
Goのコードを書き始める前に読んでおくことで、スムーズに開発に入って行くことができるでしょう。

どの章も有用なテクニックが多く示されており、Goを書いていく上では、 「プログラミング言語Go」と並んで有用な図書であると私は思いました。

Goのシンプルさもあってか、小さくまとまったコードサンプルも多く載っており、 読んでいてわかりやすく、楽しい本でもありました。

裏表紙や各ページの余白部分に怪人(?)やその手下(?)のような絵があり、 「Gopher君達は彼らと戦っているんだろうなー」とか思いながら読んでました。

elmを使ってゲームを作ってみた

まとめ

  • elmlangを使って、10年ぐらい前にガラケーでこんなゲームをやっていたという記憶を元に簡単なゲームを作ってみた
  • Haskellっぽいけど、そんなにHaskell書けなくても問題ない
  • html、css、およびjsに関する知識が必要ないので簡単に作れる
  • コンパイルのエラーも丁寧でシンプルなので、英語がそんなに得意じゃなくてもできる
  • ゲームをつくる場合はSignal(通常のページならAddress)を知る必要がある

背景

  • elmlangのversionが0.16になり、かなり使えるようになってきたという噂をきいたので、なんか作ってみたかった
  • jsを長い間触っていなかったので、リハビリがしたかった(結果的にjsを触ることはなかった)

目的

  • 単純なゲームをつくることでelmlangでの開発を一通り体験する

実装

  • 準備

elmlangのversionは0.16

単純に公式のライブラリを使って1ファイルを生成するだけなら下記を知っているだけで大丈夫

# npmでインストールできる
$ npm install -g elm
# 下記のコマンドでelmファイルからhtmlを生成できる
$ elm-make main.elm --output=main.html
  • ソース

ぱっと見てわかりにくいかなというところだけ載せておきます

残りはこちらで確認してください

delta = Signal.map Time.inMilliseconds <| Time.fps 30

input : Signal Input
input =
  Signal.sampleOn delta <|
    Signal.map4 Input
      Keyboard.space
      Keyboard.arrows
      initialSeed
      delta

上記のコードでは30fpsのタイミングでキーボードのspaceや矢印を取得して、状態の更新に利用しています。Signalはリストとして扱うことはできないのでmap、map2...map5まで用意されています。上記のコードでは4つのシグナルをまとめています

elmには関数宣言でのパターンマッチがないので、空リストの場合などはcase文でかく必要があります

viewMoji : Moji -> Form
viewMoji ({x, y, char, scolor}as moji) =
  text (Text.fromString char |> Text.color scolor)
    |> move (x, y)
    |> scale 4

上記のコードにある"|>"は"x |> f = f x"という意味なので、表示させる要素の色や大きさなど複数指定していく時に便利です。逆の"<|"もあります

Signalの(~)と(<~)ですが、version0.16で削除されているので使えません。私はこれで結構時間を使ってしまいました(importの仕方が違うのかとか調べていました) remove (~) and (<~)

ゲーム

  • 動作はchromeでしか確認していません
  • このページだと矢印キーでページが動いてしまうので、別ページで開いて遊んでみてください

URL

遊び方
  • キーでスタート
  • restartは未実装
  • 黒い文字は文字の示す方向の矢印キーを押す

"右"なら→、"下"なら↓

  • 赤い文字は流れているラインの方向を押す

一番左を流れる文字は←、真ん中は⇣

文字が上でも赤色で左のラインを流れていたら、←を押す

問題点

  • キーが押されている間は継続的にSignalが発行されてしまう -> 同じ方向の入力が連続するときに一回押すだけですべて処理されてしまう

おそらくChangeかキーが押されたときのSignalとmergeすればいいはずだが、まだ実装はできていない

  • ゲームのレベルが上がった時の文字の流れる速度がうまく調整できていない

うまく調整できる計算式があれば、ちゃんとしたゲームっぽくなると思う

  • ソースが汚い

Haskellもそんなに書けないし、elmlangも1日しか触っていないので無駄が多いコードになってると思います(是非、助言や疑問点があったら指摘をしていただきたいです)

参考