quic-goはどのようにUDPをコネクション指向で扱っているか

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
    }

https://github.com/lucas-clemente/quic-go/blob/ca469eb0b6de4cdf7d06462da73e242e154c3117/client.go#L228

サーバー側の分岐処理

では、サーバー側は受け取った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])
    }
}

https://github.com/lucas-clemente/quic-go/blob/ca469eb0b6de4cdf7d06462da73e242e154c3117/packet_handler_map.go#L224

ここから 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)

https://github.com/lucas-clemente/quic-go/blob/ca469eb0b6de4cdf7d06462da73e242e154c3117/packet_handler_map.go#L252-L272

はじめて来た 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)

https://github.com/lucas-clemente/quic-go/blob/ca469eb0b6de4cdf7d06462da73e242e154c3117/server.go#L388

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
    }
}

https://github.com/lucas-clemente/quic-go/blob/ca469eb0b6de4cdf7d06462da73e242e154c3117/server.go#L237

読んでみた所感

  • サーバー側は Accept() を実装するなどして、まるでTCPのnet.Listenerのようにふるまう
    • 内部ではUDPでいろいろやりつつも、使うユーザー側からみたら普通のコネクション指向のようにみえる、というのが美しくて良い
  • 受信パケットや確立したSessionをためておく場所としてGoのchannelを使っている
    • やっぱりこういう処理はGoだとやりやすい
    • channelと、さらにcontextを使うことで外部からのキャンセルにも対応できているのがGoらしくて良い