Go 1.18 で追加された net/netip パッケージ
- Authors
- Name
- ごとれん
- X
- @ren510dev
目次
- 目次
- はじめに
- 特徴
- netip パッケージの登場背景
- 比較処理の非効率性
- メモリアロケーションの発生
- UDP パフォーマンスの低下
- netaddr.IP 型の提案
- 命名規則の議論
- netip.Addr 型の構造
- addr uint128
- z *intern.Value
- intern.Value 型
- なぜ構造体管理なのか
- 省メモリ
- メモリアロケーションが発生しない
- イミュータブル
- == 演算子で比較可能
- map の key に使用可能
- net.IP 型との互換性
- 依存関係
- UDP 関連のメソッド
- 相互型変換
- netip.Addr 型の使い方
- netip.Addr 型の表示
- netip.Addr 型の生成
- netip.Addr 型の変換
- アドレスの比較
- アドレス種別の判別
- IP アドレスの操作
- Addr の Marshal と Unmarshal
- netip.AddrPort の構造
- netip.AddrPort の使い方
- AddrPort 型の生成
- アドレスポートの表示と判定
- アドレスポートの比較
- AddrPort の Marshal と Unmarshal
- netip.Prefix の構造
- netip.Prefix の使い方
- Prefix 型の生成
- 操作
- プレフィックスの比較
- プレフィックスベースの表示
- Prefix の Marshal と Unmarshal
- まとめ
- 参考・引用

はじめに
Go 1.18 は "our biggest change ever to the language" と言われるほど、Go 言語誕生以来、最大の仕様拡張が行われました。

特に注目を浴びたのは Generics でしょうか。 以前の Go は型パラメータを持たず、汎用的なデータ構造や関数を実装する際は、interface{}
や型アサーションを使用するため、冗長な書き方が必要でした。
これに対し、Go Generics では、型パラメータを活用して汎用化することができるため、型安全性や再利用性が大幅に向上し、コードを簡素化することができます。
ここで、T int | float64
という型セットを定義すると、型パラメータ T
は、int
と float64
の両方の型を振る舞うことができます。
そして、もう一つ Go 1.18 では Go のパフォーマンスを向上させる重要なパッケージが追加されました。
それは netip パッケージ の登場です。 netip パッケージの追加は Go でネットワークプロトコルを書いている身として非常に画期的だと思いました。
これまで、Go を用いて IP アドレスやパケット処理を実装する際は、主に net パッケージを使用していましたが、言語的な特性により、処理やパフォーマンスに関していくつかの課題がありました。
netip パッケージは、net パッケージの課題を解決し、より使いやすく、より IP 操作のパフォーマンスを向上させるための機構が追加されています。
今回のブログでは、netip の導入背景を踏まえ、パッケージの特徴、互換性、メソッドの使い方等、従来の net パッケージと何が変わったのかを紹介したいと思います。
特徴
Compared to the net.IP type, Addr type takes less memory, is immutable, and is comparable (supports == and being a map key).
netip パッケージに追加されている Addr
型には、従来の net パッケージの IP
型に対して次のような特徴があります。
- 省メモリ
- メモリアロケーションが発生しない
- イミュータブル
==
演算子で比較可能- map の key に使用可能
netip パッケージの登場背景
netip パッケージの追加は、元 Go チーム、現在 Tailscale, Inc. の bradfitz 氏 からの提案で始まりました。
この Issue は実際に bradfitz 氏 が提案した、net パッケージにおける IP アドレスの表現方式を改善するために、inet.af/netaddr パッケージ を標準ライブラリに取り込み、netaddr.IP
型を導入するというものでした。
ここでは net.IP
の課題と対応策について、次のように言及されています。
比較処理の非効率性
net.IP
は可変長のバイトスライス([]byte
)で表現されているため、Go の組み込みの比較演算子(==
)を直接使用できません。 そのため、IP アドレスを比較するたびに手動でバイト列の要素を 1 つずつチェックする必要があります。
例えば、通常の整数や構造体であれば ==
を使って直接比較できるところを、net.IP
では bytes.Equal
を使って比較しなければなりません。
bytes.Equal
はスライスのすべてのバイトを走査するため、比較操作が遅くなります。
また、==
を使えないため、可読性が悪くなりバグを引き起こしやすいという実装上の課題も生じます。
メモリアロケーションの発生
net.IP
はスライスなので、値をコピーする際にメモリアロケーションが発生することで不要なメモリリソースを喰う可能性があります。
例えば、IP アドレスを map の key として使用する場合、スライスのポインタを格納するため、不要なメモリアロケーションが発生しやすくなります。
スライスは直接、map の key として扱うことができないため IP
型を string
型にキャストしてから格納します。
ここで ip.String()
を呼び出すと、内部的には新たに string
型 16byte 分を確保するため、追加のメモリアロケーションが発生します。
UDP パフォーマンスの低下
比較処理の非効率性や不要なメモリアローケーションにより、特に ベストエフォート指向でコネクションレスに大量のパケットを処理する UDP ではパフォーマンス低下が顕著 になります。
Tailscale は WireGuard という OSS がベースになっています。 WireGuard は Go で実装された次世代の VPN ソリューションで、トンネリング の構築に UDP を使用します。

また、HTTP/3 の普及が進む中、Google が開発した QUIC:Quick UDP Internet Connections も Go で実装されており、UDP ベースで動作します。

今回、特に netip パッケージに惹かれたのはこの部分で、僕が研究開発に携わっている CYPHONIC:CYber PHysical Overlay Network over Internet Communication という Peer-to-Peer に基づくゼロトラストセキュリティネットワークを構築するためのオーバーレイネットワークプロトコルも Go で実装しており、UDP ベースとなっています。

netaddr.IP
型の提案
netaddr.IP
型は固定長の構造体(struct
)を用いることで、上記の問題を解決するというものです。
inet.af/netaddr は、現 net/netip の前身にあたり、元々 Tailscale で導入されていた IP 操作に特化したパッケージです。
netaddr.IP
型は、構造体を用いることで、==
演算子による比較ができます。
また、固定長な構造体のコピーはスタックを使用するため、ヒープ領域を使用するスライスと比較してメモリリソースの削減や Go コンパイラの最適化が可能です。
命名規則の議論
当初の提案では、netaddr.IP
という名称が検討されました。 これは、Go の net パッケージにある net.IP
に対応する形で、新たな IP
型として導入しようという試みです。
しかし、netaddr というパッケージ名は net パッケージと独立しているものの、addr.IP
という命名には違和感があるとの意見が出ました。
その後、シンプルな ip.Addr
という名称が提案されましたが、今度は ip をパッケージ名にしてしまうと、変数名として一般的に使用される ip
と衝突する可能性があるという問題が指摘されました。
その結果、最終的に netip.Addr
という名称が採用されました。 この命名により、net パッケージとは明確に分離されつつも、IP アドレス型の表現として適切であると判断されました。
また、net パッケージ自体が既に大きく複雑になっているため、新たな IP 型を独立した netip パッケージとして切り出すことは合理的だとされました。
実際に利用する際は、net/netip
をインポートします。
ちなみに前進の netaddr パッケージは非推奨となり、2023 年 5 月にはリポジトリもアーカイブされていました。

netip.Addr
型の構造
netip.Addr
型は構造体で、IPv4 アドレス、IPv6 アドレスとゾーンインデックス を表現します。 addr
は IPv4 アドレスまたは IPv6 アドレスを管理し、z
フィールドはゾーン(後述)を管理します。
net パッケージでは net.IP
型とゾーンを持つ net.IPAddr
型をそれぞれ定義しているのに対して、netip.Addr
型は構造体で両方を管理しています。
addr uint128
uint128
は、netip パッケージに定義されている 128bit(16byte)を表現するカスタム型です。
IPv6 アドレスは 128bit(16byte)で構成されており、z
フィールドが v4(IPv4)の場合には ::ffff:192.168.1.1
のような IPv4-Mapped IPv6 Address の形式で保持されます。
- IPv4-Mapped IPv6 Address
ここで、IPv6 アドレスが 0011:2233:4455:6677:8899:aabb:ccdd:eeff
の場合、addr.hi = 0x0011223344556677
/ addr.lo = 0x8899aabbccddeeff
とそれぞれ保存されます。
IPv6 は 128bit(16 オクテット)で表現されるため [16]byte
のバイト配列を使用すれば良いのではと思うかもしれませんが、uint64
2 つで表現すると、64bit CPU では 2 つのレジスタで IPv6 を表現できます。 そのため、メモリアクセスやビット演算のオーバーヘッドが少なくなり、IP アドレスに対する算術演算を高速化できます。

- 比較が簡単
- ビット演算(マスク処理)の簡略化
これをバイト配列で表現した場合、16 回の for ループで AND 演算を取る必要があります。
ベンチマーク結果:
z *intern.Value
z
フィールドは、z0
、z4
、z6noz
変数が定義されており、z0
は不正な IP アドレス、z4
は IPv4 アドレス、z6noz
はゾーンインデックスを持たない IPv6 アドレスを示します。 さらに z
フィールドには、任意のゾーンインデックスを表す文字列を保存することが可能で、その際はゾーンインデックス付きの IPv6 アドレスであることを示します。
ゾーンインデックス(Zone Index) とは、パケットを送信するネットワークインターフェースを指定するための識別子です。 パケットはルーティングテーブルの情報を参照して IP アドレスのゾーンに応じて、送信するネットワークインターフェースを決定します。
ゾーンインデックスは、特に IPv6 の LLA(Link Local Address) 等において重要になります。 IPv6 ネットワークでは、仕組み上、異なるネットワークに同一の LLA が存在する場合があります。
例:
- eth0(ネットワーク 1)に接続された機器 A の IP:
fe80::1ff:fe23:4567:890a
- eth1(ネットワーク 2)に接続された機器 B の IP:
fe80::1ff:fe23:4567:890a
この場合、送信先の IP アドレスが同じであるため、どのネットワークインターフェースを使用するべきか、アドレスのみでは判断できません。
そこで、fe80::1ff:fe23:4567:890a%eth0
や fe80::1ff:fe23:4567:890a%eth1
のように IPv6 アドレスの末尾に %
ゾーンインデックスを指定することで、ネットワークインターフェースを指定します。 これにより、インターフェースが接続する先のネットワークを一意に特定することができます。
ここで、fe80::1ff:fe23:4567:890a%eth0
の場合、netip.Addr
型の z
フィールドに eth0
という値が格納されます。
これは ping6
コマンドでも同様です。 ping6
は宛先 IP アドレスの末尾に、%
で使用する送信元のインターフェースが指定されます。
intern.Value
型
z
フィールドは intern.Value
型です。
intern.Value
は以下のような型定義を取るカスタム型です。
ここで、[0]func()
は長さゼロの関数型配列です。 Go では、ゼロ長の配列自体はメモリを消費せず、ただの型制約として機能します。 関数型 func()
は比較不可能(non-comparable)な型であるため、これを言わばダミーフィールドとして Value
構造体内に含めることで Value
自体を Go のコンパイラが比較できないようにするというトリックを使っています。 つまり、Value
は 常にポインタ *Value
で管理されることになります。
また、Go 1.18 以降、any
型は interface{}
のエイリアス型であり、どんな値でも格納することが可能です。 cmpVal
には、比較対象となる値を格納することで間接的に比較を可能にします。
Addr
型が Value
構造体を採用する理由は、string
型よりも 8byte 分メモリフットプリントを小さくするためです。
Go の string
型は、ポインタ + 長さ で構成され、それぞれが 8byte となっています。
つまり、string
型の比較には、ポインタと長さの両方を使用する必要があるわけです。
一方の、*intern.Value
の場合、ポインタの参照先に実際の文字列が格納されることで、同じ文字列は同じポインタとなる ため、比較がポインタのアドレスのみで可能となります。 これにより、ポインタアドレスを比較することで同一文字列であるかを確認できます。
なぜ構造体管理なのか
netip.Addr
型は構造体を採用することで次のようなメリットがあります。
省メモリ
従来の net パッケージの net.IP
型は合計 40byte、ゾーンを持つ net.IPAddr
型は 56byte でしたが、netip パッケージの netip.Addr
型は 24byte とメモリフットプリントが小さくなっています。
net.IP
型は、スライスであるため、長さや容量を管理する len
や cap
の情報を持つ Slice Header の 24byte が必要となります。 これは IPv6 アドレスの 16byte よりも大きい値です。

netip.Addr
型は構造体を採用することで、この問題を解決しています。
メモリアロケーションが発生しない
netip.Addr
型は値レシーバ(func (a T) Method() {}
)を採用しています。
Go ランタイムの仕組み上、ポインタを持つ変数はヒープに割り当てられやすいですが、値渡し(Call by value)であればエスケープ解析によってスタック上で処理されます。

これにより、ヒープアロケーションが発生しないためガベージコレクションの負担も抑えることができます。
また、他の関数に渡す場合も Call by value となります。
イミュータブル
イミュータブル(不変性)とは、関数または値の呼び出し先を書き換えても、呼び出し元の値が変わらない という性質です。
netip.Addr
型のメンバ変数はパッケージ外に非公開となっており、実体に対する値レシーバが用意されています。 値レシーバのコールはスタック上の操作となるため、呼び出し元の値は不変(Immutable)です。

==
演算子で比較可能
net.IP
型はスライスであるため直接的な比較ができませんが、netip.Addr
型は構造体であるため、==
演算子で比較ができます。
map の key に使用可能
The comparison operators == and != must be fully defined for operands of the key type;
map の key は比較可能な型である必要があるため、netip.Addr
型では key として利用できます。
net.IP
型との互換性
依存関係
Go の開発チームは将来的に、過去の API は内部で netip.Addr
型を使い、入出力だけを net.IP
型に変換して返したいと考えているようですが、Go 1.18 のリリース時点ではこの変更は入っていません。
また、net → netip への依存はありますが、逆はありません。
net.IP
型の IP アドレスに関する操作メソッドは、基本的に netip.Addr
型でも提供 されています。
以下は、与えられた IP アドレス(netip.Addr
型)がプライベート IP アドレスであるかを判定するメソッドの例です。
注意すべきは、net.IP
型を利用していた機能のすべてが netip.Addr
型で提供されているわけではないという点です。
例えば、LookupNetIP
メソッドのように既存機能とほぼ同じ機能で、netip.Addr
型を返すようにしたものもありますが、すべてのメソッドに存在するわけではありません。
UDP 関連のメソッド
本来の目的であった UDP 関連の処理を高速化するためにいくつかのメソッドが追加されました。
例えば、net パッケージの UDPConn
をレシーバに取る 以下のメソッドは netip.Addr
型を使用して高速化しています。
ReadFromUDPAddrPort
ReadMsgUDPAddrPort
WriteToUDPAddrPort
WriteMsgUDPAddrPort
相互型変換
net.IP
型と netip.Addr
型は相互に変換が可能です。
- net.IP → netip.Addr
ここで ::ffff:192.168.1.1
は IPv4-mapped IPv6 形式であるため、元の IPv4 アドレスを取り出すには Unmap を使用します。
- netip.Addr → net.IP
netip.Addr
型の使い方
netip.Addr 型に関連する関数やメソッドを紹介します。
netip.Addr
型の表示
netip.Addr
型の生成
netip.Addr
型の変換
アドレスの比較
アドレス種別の判別
IP アドレスの操作
Addr
の Marshal と Unmarshal
netip.AddrPort
の構造
netip.AddrPort
型は、netip.Addr
型とともに port
を持っています。
netip.AddrPort
の使い方
AddrPort
型の生成
アドレスポートの表示と判定
アドレスポートの比較
netip.Addr
型と同様に ==
演算子で比較可能です。
AddrPort
の Marshal と Unmarshal
netip.Prefix
の構造
netip.Prefix
は、CIDR(Classless Inter-Domain Routing)を用いて IP アドレスをネットワークプレフィックスで管理する構造体です。
CIDR とは、クラスレスネットワークにおいて IP アドレスを 192.168.1.0/24 のように表現することです。
ここで、/24 は 192.168.1 までの 24bit がネットワークアドレスを表し、残りの 8bit がホストアドレスを表すことを意味します。
この時、192.168.1.0 が ip
フィールドに保持され、24 が bits
フィールドに保持されます。
netip.Prefix
の使い方
Prefix
型の生成
操作
プレフィックスの比較
netip.Addr
型 や netip.AddrPort
型と同様に ==
で比較可能です。
プレフィックスベースの表示
Prefix
の Marshal と Unmarshal
まとめ
今回のブログでは Go 1.18 で追加された netip パッケージについて紹介しました。
netip パッケージには、構造体で定義された Addr
型が追加されており、バイトスライスを使用する net パッケージ IP
型と比べて、メモリリソースやオーバーヘッドの削減、IP 操作に関する処理の簡素化が実現されています。 netip パッケージは UDP コネクションを使用する既存のメソッドへの導入が進められており、今後も net パッケージの内部処理は Addr
型への移行が進むと考えられます。
現在、研究開発している CYPHONIC も、これを気に net パッケージから netip パッケージに移行していきたいと思います。