- 投稿日:2021-01-18T04:18:58+09:00
GoのHTTP実装を読んだ知見をまとめる~FIFOを5通りで実装する~
はじめに
引き続き、GoのHTTP実装と絡めて書く。今日はFIFO実装について。
経緯はこちらRuss CoxによるHTTPクライアントのFIFO実装が工夫されていて非常に興味深かった。
コメントを見ると、Okasaki's purely functional queueを元に、少し工夫したものらしい。今回はそれらに触れた上でGoでFIFOを5通りの方法で実装していきたいと思う。
実装およびテストコードはGitHubにあげてあります。
FIFO実装
実装方法を紹介していく。
ここでは、簡単なため、自分が実装した型名をもとに紹介していきます。TwoListSlice実装はほぼコピペなので実装は正しいはずです。その他は何も参考にせずに実装したものなのでバグがあるかもしれません。悪しからず。
SList
とてもシンプルな実装で、ただのsingly linked listです。
各要素は値と次要素へのリンクを保持し、リスト構造としてはheadとtailを保持します。もっともオーソドックスな実装なため説明は省きますが、一点補足すると、
PopFront
時に返却する要素のリンクをnilで上書きしています。これはGCにより回収されるようにするためで、Goの標準ライブラリのdoubly linked list実装を参考にしたものです。PList
これは単純にSListに対して
PushBack
とPopFront
時に全要素をコピーして作り直します。
SListのテストコードに書いたようにSListはpersistentでないため、キューAをキューBに代入した後キューAの操作がキューBに影響を与えてしまいます。これを単純に回避するために全要素を操作時に毎回コピーしています。
この実装は明らかに重く、TwoListの方が良い実装です。
TwoList
これはOkasaki's purely functional queueの中で紹介されているpersistentなFIFO実装です。
本に興味はありますが私は読んでません。20分でわかるPurely Functional Data Structuresがとてもわかりやすく、TwoListの詳細な説明はそちらに譲ります。
TwoListだけであれば5分くらいで理解できると思います。銀行家キューが少し難しく、それ以降は自分はよく理解していません。
簡単にエッセンスを説明すると、キューを内部的に二つもちます。
head
から始まるキュー、tail
から始まるキューを持ち、それぞれsingly linked list(それぞれリストの先頭要素のみ保持すればよいのがSListとは異なる)とします。
PushBack
時にはtail
から始まるキューの先頭(末尾ではない)に挿入します。PopFront
時にはhead
から始まるキューの先頭から取得しますが、空の場合はtail
から始まるキューをreverseしてhead
から始まるキューとしてswitchingしてから先頭から取得します。これだけでpersistentになるのすごくないですか??
騙された気分になりましたが、「persistentにするには、更新操作において、元構造から辿れるデータには更新は発生させないようにする」のがポイントと自分は理解しました。reverseするのは一見計算量として不利になりそうですが、そうでもありません。また、さらなる改良をあり、そのあたりは先ほどの資料をみると良いと思います。
NaiveListSlice
リストではなく、goのSliceを利用した実装です。テストからわかるようにpersistentではありません。goのsliceは内部的に配列を参照し、データを共有するためです。
十分にシンプルなのでそのままコードを載せて説明は省略します。package fifo type NaiveListSlice []interface{} func (l *NaiveListSlice) Len() int { return len(*l) } func (l *NaiveListSlice) PushBack(v interface{}) { *l = append(*l, v) } func (l *NaiveListSlice) PopFront() interface{} { list := *l if len(list) == 0 { return nil } v := list[0] *l = list[1:] return v }TwoListSlice
Russ CoxによるHTTPクライアントのFIFO実装で利用されている実装です。
^のコメントをそのまま載せちゃいます。// A wantConnQueue is a queue of wantConns. type wantConnQueue struct { // This is a queue, not a deque. // It is split into two stages - head[headPos:] and tail. // popFront is trivial (headPos++) on the first stage, and // pushBack is trivial (append) on the second stage. // If the first stage is empty, popFront can swap the // first and second stages to remedy the situation. // // This two-stage split is analogous to the use of two lists // in Okasaki's purely functional queue but without the // overhead of reversing the list when swapping stages. head []*wantConn headPos int tail []*wantConn }コメントの通りですが、TwoListと同様に二つのキューを利用しており、Sliceで実現します。ポイントは
headPos
を利用して、sliceを進めさせないこと(NaiveListSliceでいうところの*l = list[1:]
はしない)- switchingは発生するが、その際にreverseが発生しないこと
だと思いました。なお、テストからわかるように、この構造自体はpersistentではないので注意が必要です。
この構造はNaiveListSliceのような単純なFIFO実装と比べるとメモリを使い回すことができ、
PushBack
/PopFront
操作が一定回数以上続くような場合に良いと思われます。とても単純な
PushBack
とPopFront
が交互に一回ずつ続くケースを考えてみましょう。NaiveListSliceの実装では、
PushBack
時のappend
において毎回1要素分のメモリ確保が発生します。なお、append
実装に詳しくありませんが、自分の環境では1 -> 2 -> 4-> 8とメモリを確保していたのでそれ前提になります。TwoListSlice実装では、最初の2回の
PushBack
のみ1要素分のメモリ確保を行い、その後は確保済みのメモリを使いまわします。逆にいうと、使い回すからこそpersistentになりえないです。
HTTP実装
TwoListSliceですが、HTTPクライアントの
idleConnWait
とconnsPerHostWait
に利用されています。両方とも接続先のホスト(厳密にはconnectMethodKey)ごとに保持されます。それぞれ簡単に説明します。
idleConnWait
はidleとなるconnectionを待つキューです。idleなコネクションプールではなく、待つキューであることに注意してください。idleなコネクションプールは全体の制限である
MaxIdleConns(デフォルト100)
とホストごとの制限であるMaxIdleConnsPerHost(デフォルト2)
により制限があります。クライアントはリクエストを行う際、idleなコネクションが利用できないとわかると、
idleConnWait
に「コネクションが欲しいという情報」をPushBack
した後、接続先に対してdial
します。dial
の結果を受け取るより先に、idleConnWait
での待ち順番が回り、idleなコネクションが利用できるようになった場合にはそれを利用し、不要となったdial
結果のコネクションはidleなコネクションとして他のリクエストに再利用されます。そうでない場合は普通にdial
の結果のコネクションをリクエストに利用します。
connsPerHostWait
はもっと簡単です。MaxConnsPerHost(デフォルト無効)
によりホストごとのコネクション数が制限されていて、その制限を超過する場合、接続を試みる前にconnsPerHostWait
にPushBack
されます。待ち順番が回り、接続を試ることが許可された場合、PopFront
されます。では、なぜこれら二つのFIFOキュー実装にTwoListSlice構造を利用したのでしょうか。
それは、リクエストが一時的に多くなり、
PushBack
とPopFront
が連続的に行われ、キューがなかなか空にならないことを想定し、メモリを使いまわせるためだと思います。実際、この実装がされた当初のレビューの際には以下のようにRuss Coxがコメントしてました。
I wanted to do something that would work well for an extreme case where there are many requests queued up on a small per-host limit, so I was careful in how I wrote the wantConnQueue,
おわりに
本当はキリもいいですし、銀行家キューも実装して「FIFOを6通りで実装する」としたかったのですが、銀行家キューは「遅延評価」、「メモ化」などの実装を関数型でないGoでおこなうのがやや大変そうだったので今回はあきらめました。
そのうち、銀行家キューも実装しようとおもってます。