TCPは接続相手ごとに1本のコネクション(1つのsocket)を持ち、そこを通路として通信する。
しかし、UDPは異なる。1つのsocketであらゆる通信相手からのパケットを受け取る。 では、UDPで実装されたQUICはどのように接続相手ごとのコネクションを保持しているのか、気になったので調べた。
quic-goというGoによるQUIC実装がGitHubにあって、Goであれば(自分がなれているのもあって)読みやすそう!と思って読んだ。
Connection IDによる管理
QUICは1つの接続につき1つの Connection ID というものが割り振られていて、それで接続相手を識別する。
quic-goのクライアント実装を見ると、たしかに最初にConnection IDを作っていた。
srcConnID, err := generateConnectionID(config.ConnectionIDLength) if err != nil { return nil, err }
サーバー側の分岐処理
では、サーバー側は受け取ったUDPパケットをどのように接続相手ごとに分岐しているか、調べた。
packetHandlerMap
packetHandlerMap という構造体が Connection ID から接続相手ごとに適切な handler に分岐するという処理をしていた。
構造体の中身をみると、handlers
というConnectionID -> packetHandler のmapまさにそのものがあった!なるほど〜。
// The packetHandlerMap stores packetHandlers, identified by connection ID. // It is used: // * by the server to store sessions // * when multiplexing outgoing connections to store clients type packetHandlerMap struct { mutex sync.RWMutex conn net.PacketConn connIDLen int handlers map[string] /* string(ConnectionID)*/ packetHandler ...
UDPパケットの受信は packetHandlerMap::listen
でされていた。
func (h *packetHandlerMap) listen() { defer close(h.listening) for { buffer := getPacketBuffer() data := buffer.Data[:protocol.MaxReceivePacketSize] // The packet size should not exceed protocol.MaxReceivePacketSize bytes // If it does, we only read a truncated packet, which will then end up undecryptable n, addr, err := h.conn.ReadFrom(data) if err != nil { h.close(err) return } h.handlePacket(addr, buffer, data[:n]) } }
ここから handlePacket
という関数につながっていて、その中で
- すでに handlers に登録されている Connection ID であれば、handlerに投げる
- 未登録の Connection ID であれば、サーバーに投げる
handler, handlerFound := h.handlers[string(connID)] p := &receivedPacket{ remoteAddr: addr, rcvTime: rcvTime, buffer: buffer, data: data, } if handlerFound { // existing session handler.handlePacket(p) return } if data[0]&0x80 == 0 { go h.maybeSendStatelessReset(p, connID) return } if h.server == nil { // no server set h.logger.Debugf("received a packet with an unexpected connection ID %s", connID) return } h.server.handlePacket(p)
はじめて来た Connection ID の新規登録
packetHandlerMapに未登録の Connection ID を持つパケットが来たら、新規の接続相手なのでサーバー側に投げられて、Initial Packet として扱われる。
そこで新たにSessionが作られる、という流れのようだった。
connID, err := protocol.GenerateConnectionID(s.config.ConnectionIDLength) if err != nil { return nil, err } s.logger.Debugf("Changing connection ID to %s.", connID) sess := s.createNewSession( p.remoteAddr, origDestConnectionID, hdr.DestConnectionID, hdr.SrcConnectionID, connID, hdr.Version, ) if sess == nil { return nil, nil } sess.handlePacket(p)
Session
Session がQUICにおいて、ひとつの接続を表す単位と思われる。前述した Initial Packet でSessionが確立されたので、あとはこのSessionを使って今後やりとりすれば、UDPパケットでもコネクション指向のように双方向通信ができる。
作られた Session は sessionQueue
というchannelにたまっていくので、accept() メソッドでまるでTCPのコネクションのように取り出すことができる
func (s *baseServer) accept(ctx context.Context) (quicSession, error) { select { case <-ctx.Done(): return nil, ctx.Err() case sess := <-s.sessionQueue: atomic.AddInt32(&s.sessionQueueLen, -1) return sess, nil case <-s.errorChan: return nil, s.serverError } }
読んでみた所感
- サーバー側は Accept() を実装するなどして、まるでTCPのnet.Listenerのようにふるまう
- 内部ではUDPでいろいろやりつつも、使うユーザー側からみたら普通のコネクション指向のようにみえる、というのが美しくて良い
- 受信パケットや確立したSessionをためておく場所としてGoのchannelを使っている
- やっぱりこういう処理はGoだとやりやすい
- channelと、さらにcontextを使うことで外部からのキャンセルにも対応できているのがGoらしくて良い