日記マン

動画広告プロダクトしてます。Go, Kubernetesが好きです。

GoはネットワークI/Oをどう実現しているか

goroutine自体はOSスレッドに多重化してスケジュールされる。
ランタイムでは goroutine関数をG、OSスレッドをM、GをMに割り当てるスケジューラをP、と名称し実装されている。

qiita.com

ではG全てがI/O実行を行いブロック状態になった場合、Gに紐づかれたMがそのままブロック状態になるのだろうか。
100個のgoroutineが同時にI/O待ちのときに、100個のOSスレッドがI/O待ちの状態になっているのか。

結論からいうとならない。なぜならラインタイム以下で非同期I/Oを実現している。
ネットワークI/Oの場合はGoの標準ライブラリではあたかもブロッキングI/Oのようなインターフェイスを提供しているが、
Goランタイム(で動くsysmonという特別なワーカー)が待ち状態のgoroutineのI/Oを非同期I/Oに変換している。
(Linux環境ではepoll(7)を使う)
そのためOSスレッド自体はネットワークI/O待ちのブロック状態にならずに別のgoroutineの実行を受け入れることができる。

epoll(7)

今回はOSが Linux の場合に限定する。
Linux では非同期I/Oの実現に epoll(7) が提供されている。
epoll_create(2), epoll_ctl(2), epoll_wait(2) を活用して非同期I/Oを実現できる。

alpha.mixi.co.jp

GoのネットワークI/O

GoでネットワークI/Oの例として net.Dial() によるTCP接続を掘り下げる。

// ユーザプログラム

conn, err := net.Dial("tcp", addr) // TCP接続開始し、この処理はブロックされる
// net/dial.go

func Dial(network, address string) (Conn, error) {
    var d Dialer
    return d.Dial(network, address)
}

func (d *Dialer) Dial(network, address string) (Conn, error) {
    return d.DialContext(context.Background(), network, address)
}

func (d *Dialer) DialContext(ctx context.Context, network, address string) (Conn, error) {
    // ...
    var c Conn
    if len(fallbacks) > 0 {
      c, err = sd.dialParallel(ctx, primaries, fallbacks)
  } else {
      c, err = sd.dialSerial(ctx, primaries)
  }
  // ...
}

func (sd *sysDialer) dialSerial(ctx context.Context, ras addrList) (Conn, error) {
    for i, ra := range ras {
        // ...
        c, err := sd.dialSingle(dialCtx, ra)
        // ...
    }
}

func (sd *sysDialer) dialSingle(ctx context.Context, ra Addr) (c Conn, err error) {
    // ...
    la := sd.LocalAddr
    switch ra := ra.(type) {
    case *TCPAddr:
        la, _ := la.(*TCPAddr)
        c, err = sd.dialTCP(ctx, la, ra)
    // ...
}
// net/tcp_posix.go

func (sd *sysDialer) dialTCP(ctx context.Context, laddr, raddr *TCPAddr) (*TCPConn, error) {
    // ...
    return sd.doDialTCP(ctx, laddr, raddr)
}

func (sd *sysDialer) doDialTCP(ctx context.Context, laddr, raddr *TCPAddr) (*TCPConn, error) {
    fd, err := internetSocket(ctx, sd.network, laddr, raddr, syscall.SOCK_STREAM, 0, "dial", sd.Dialer.Control)
    // ...
}
// net/ipsock_posix.go
func internetSocket(ctx context.Context, net string, laddr, raddr sockaddr, sotype, proto int, mode string, ctrlFn func(string, string, syscall.RawConn) error) (fd *netFD, err error) {
    // ...
    return socket(ctx, net, family, sotype, proto, ipv6only, laddr, raddr, ctrlFn)
}
// net/sock_posix.go
func socket(ctx context.Context, net string, family, sotype, proto int, ipv6only bool, laddr, raddr sockaddr, ctrlFn func(string, string, syscall.RawConn) error) (fd *netFD, err error) {
    // ...
    if err := fd.dial(ctx, laddr, raddr, ctrlFn); err != nil {
        // ...
    }
    // ...
}
// internal/poll/fd_poll_runtime.go

func (pd *pollDesc) init(fd *FD) error {
    serverInit.Do(runtime_pollServerInit)
    ctx, errno := runtime_pollOpen(uintptr(fd.Sysfd))
    // ...
}

最終的に epoll_create(2), epoll_ctl(2) を実行しファイルディスクリプタの作成とepollを登録し、
その後そのgoroutineの実行を中断し別のgoroutineに切り替えている。
あたかも同期的なブロックが行われたようにみえるが実際はepoll登録とgoroutine切り替えでまるでそれっぽく実現している。

// runtime/proc.go
 
func main() {
    // ...
    if GOARCH != "wasm" {
        systemstack(func() {
            newm(sysmon, nil)
        })
    }
    // ...
}
 
func sysmon() {
    // ...
    for {
        // ...
        if netpollinited() && lastpoll != 0 && lastpoll+10*1000*1000 < now {
            atomic.Cas64(&sched.lastpoll, uint64(lastpoll), uint64(now))
            list := netpoll(0) // non-blocking - returns list of goroutines
            // ...
        }
        // ...
    }
}

https://github.com/golang/go/blob/master/src/runtime/proc.go

// runtime/netpoll_epoll.go

func netpoll(delay int64) gList {
    // ...
retry:
    n := epollwait(epfd, &events[0], int32(len(events)), waitms)
    // ...
}

https://github.com/golang/go/blob/master/src/runtime/netpoll_epoll.go

Goプログラム実行時にsysmonを立ち上げる。sysmonはPに管理されずに一つのOSスレッド上で動く。
sysmonでは無限ループが実行され、短いスリープを挟みながら epoll_wait(2) を発行している。
ファイルディスクリプタが待ち状態から復活しReadyになればgoroutineをグローバルキューに戻す。

まとめ

GoではネットワークI/Oはユーザプログラム上では見かけ上あたかも同期I/Oのインターフェイスが提供されているが、
ランタイムレベルで非同期I/Oを実現(Linuxではepoll(7))するように変換し、OSスレッドを効率的に扱っている。
このおかげで開発者は非同期I/Oへの意識をすることなく同期的なネットワークI/Oを実行するシンプルなプログラムを記述でき、
これは実際にはネットワークI/Oへのブロック時に速やかに別のgoroutineにスイッチするため、OSスレッドの足を止めさせることなく効率的にCPU時間を利用できるようになっている。

平行処理をシンプルに書くことができる裏側で、ランタイム内部で色々工夫されているのがGoらしいデザインだなーと感心する。。

参考

xiaoxubeii.github.io