MiniSock: libuvで実装する
ひとまずlibuvを使ってMiniSockを実装してみる。libuvはWindowsとPOSIXの両方で動くので、今後それぞれのネイティブバックエンドを実装する上で有利と踏んだので。
Samsungの libtuv は更にNuttXのような組込みOSにも対応して似たAPIを提供するが、既にアーカイブされているようなので今回は不採用とした。libtuvは同じくSamsungに由来する iot.js https://iotjs.net に採用されている。
セマンティクスの違い
全体的にMiniSockの仕様の方をlibuvに合わせて曲げている(e.g. read操作を明示的なものに変更した)が、違いを実装側のロジックで吸収する選択をしたものもある。
socket作成と接続の統合
MiniSockでは、socketの作成と接続処理は1オペレーションとして統合している。libuv(や、通常のカーネル)では、socketの作成と接続処理は別々の操作であり、コントローラー側が1つ1つ明示的にステップを進める必要がある。
通常のアプリケーションでは、作成だけされて接続されていないsocketにはユースケースが無いため、今回はMiniSockの仕様を実装側でエミュレートする。
このMiniSockの仕様の重大なcaveatは、 エラーが発生した際の切り分けが困難 という点がある。ただ、それはOSをwarpするライブラリの宿命なので、ある程度は諦める必要がある。例えば専用のトレースメカニズムを用意しないとその辺のユースケースをカバーできないだろう。
UDP接続
本来、UDPは接続指向ではない"投げっぱなし"のプロトコルではあるものの、MiniSockはワーカー側にUDPソケットを作成することを"接続"と見做してTCPとUDPで同じ接続操作を要求している。
MiniSockは全てが非同期で、かつ待ちが発生すると強く仮定している(マイコンから専用のTCP/IPハードウェアを操作することを想定しているため)。一方、libuvはUDPに関しては即時のリクエストが可能と仮定している。このため、UDPの"接続"処理が即完了してしまう。
POSIXでは、UDPソケットの作成は socket
syscall (や bind
syscall)で実施し、送信元の側で操作が完結する。このため、UDPソケットの作成を非同期オペレーションにする必要がない。対照的に、TCP socketは connect
まで発行、受理されて完了するため、非同期操作として処理することになる。Windowsでも基本的にこれは同様で、libuvはこの点をAPIに反映している。
libuvは他のソケットのチェックを行う前の事前hookを登録できるため、そのhookでUDPの接続完了処理をエミュレートすることにする。
libuvにおける接続の流れ
今回処理したい接続には以下の4種がある:
- 相手先を指定するoutgoing TCP接続(TCP connect)
- 待ち受けincoming TCP接続(TCP listen)
- 相手先を指定するoutgoing UDP接続(UDP send)
- 待ち受けincoming UDP接続(UDP bind)
また、それぞれで相手先またはbindアドレスを名前指定できるため、名前解決が追加で必要になるケースもある。
通常のケースでは、送信元ポートはシステム任せのランダム選択になる。このような場合はポート番号としてゼロを選択する。
名前解決
OS標準の名前解決処理は非同期でないことが多いため、libuvは専用のリクエストフローを実装して抽象化、非同期化している。(例えば、glibcは独自拡張として非同期版の getaddrinfo_a
を提供している。)
-
uv_getaddrinfo
でリクエストuv_getaddrinfo_t
を初期化してコールバックを待つ - コールバックに渡される
struct addrinfo
を使用する -
uv_freeaddrinfo
でコールバックに渡された addrinfo を解放する
ライブラリがaddrinfoを確保する点に注意する必要がある。勝手に解放されないのは、libuvはコールバックの呼出し期間を越えてリクエストオブジェクトを使い回すことを想定しているため。(MiniSockはそうではない)
名前解決が完了したら、接続処理に進む。このとき、 addrinfo
構造体 は ai_next
フィールドで連結されたリンクリストになっていることに注意する。つまり、単一のDNS名について複数のアドレスが返却されることがあり、適切なアドレスを使用するのはアプリケーションの責任となる。
以降の接続に必要な struct sockaddr*
は ai_addr
フィールドに格納される。struct sockaddr
はPOD(Plain Old Data)と見做して良い(要出典)ためこれをコピーしておく。
ちなみに、 getaddrinfoに "127.0.0.1" のようなnumeric addressを渡すのは合法である ため、殆どのケースはDNS解決で処理できることになる。
TCP connect
TCPの外向き接続は 初期化 → フラグ設定 → connect の順で処理する。今のところMiniSockにはNo Delayのようなフラグが存在しない(後で足す)。
-
uv_tcp_init
でハンドルuv_tcp_t
を初期化する -
uv_tcp_connect
でリクエストuv_connect_t
を初期化しつつ connectする - コールバックに渡されるステータスを検証する
- (通信処理を行う)
-
uv_close
でcloseを要求する。 close自体はリクエストではない ため失敗しないが、ハンドルuv_tcp_t
を解放するにはコールバックを待つ必要がある。
TCP listen
TCPのlistenは殆どconnectと同様に処理できる。listenのコールバックは複数回呼ばれる可能性がある。
-
uv_tcp_init
でハンドルuv_tcp_t
を初期化する -
uv_listen
でlistenを開始する。backlog
に指定したぶんのバッファはカーネルが持ってくれるため、listenはリクエストではない。ただし失敗する可能性はある。 - コールバック内で
uv_tcp_init
したハンドルについてuv_accept
して接続を確立する。libuvではコールバック内で1回はacceptできることを保証している。 ...何故?acceptまでに相手がcloseを発行してきたケースでは失敗しないんだろうか。。 - (通信処理やcloseを行う)
listenにはパラメタ backlog
が存在し、接続を待たせられるクライアントの数を制約できるようになっている。とりあえず SOMAXCONN
の適当な値(= 128)で固定しておくが考察の余地があるかもしれない。libuvはAPIとして SOMAXCONN
定数を提供していない。。はず。(なのでnode.jsとかでこの辺をどう処理しているのかは調査の余地がある)
UDP
UDP自体はステートレスなプロトコルではあるものの、通常カーネル側のsocketオブジェクトの作成/破棄は必要としている事が多く、libuvもそれに準じている。
-
uv_udp_init
でハンドルuv_udp_t
を初期化する - 必要に応じて
uv_udp_bind
でbindする。bindはリクエストではなく、コールバックも取らない。 - (通信処理やcloseを行う)
基本的なUDP対話の場合、 "送信元や送信元ポートはどうでも良いからとりあえずbindして欲しい" というbindを行う。これは、いわゆる INADDR_ANY
と ポート番号ゼロで表現される。
libuvにおける通信の流れ
接続の確立以降は普通にread/writeすることになるが、非同期通信APIは普通のファイルの読み書きと大分ことなるフローで実施することになる。また、UDPは通常のファイルと異なりパケット境界を保持する都合から処理が複雑になる。
MiniSockではUDPのための特別な考察を現状提供していない。複数パケットを同時に取り扱う方法を用意しないとパフォーマンス的には厳しいだろう。
libuvの特徴として、全ての送信操作がsendv様のgather操作となっている。MiniSockでは単純のためscatter/gatherを現状定義していないので、libuvのこの特徴を生かすことができない。
TCPの送信
libuvはTCP以外にpipeやコンソールのような様々なバイト指向ストリームを uv_stream_t
に抽象化している。このため、read/writeのAPIはstream側に用意されていて、libuvのユーザは適宜 uv_tcp_t
を uv_stream_t
にキャストする必要がある。このキャストはCのstrict aliasing ruleとクッソ相性が悪いため、libuvは最適化を無効化することをユーザに推奨している。
送信には uv_write
を使用する。このAPIはscatter/gather型のAPIになっている。 コールバックが呼ばれるまでバッファを解放できない 点に注意する。
TCPの受信
受信方向は少々複雑で、バッファのアロケーションと受信結果のそれぞれでコールバックを供給する必要がある。
uv_read_start
APIでコールバックを設定し、 uv_read_stop
APIでコールバックを登録解除できる。 ... uv_read_stop
は失敗しないとされているが、コールバック内から呼ばれるとかいくらでも不味いケースは有るような。。
アロケーションコールバック は uv_buf_t
を1つ供給する。
バッファのサイズはlibuv側からも指示されるが、これを無視することもできる。
UDPの送信
UDPは通常の sendto
操作の代わりに uv_udp_send
APIを使う。このAPIも sockaddr
を取るが、ソケットに対して connect
を呼んだかどうかで用法が異なる。
... これ複数バッファを与えた場合は複数パケットになるんだろうか。。(MiniSockの現状の用法では複数バッファを与えることは無いけど。。) Linuxには専用の sendmmsg
syscallがあり、明示的に分けている。(recv側にはサポートがある。後述。)
UDPの受信
UDPの受信は uv_udp_recv_start
/ uv_udp_recv_stop
APIのペアで開始 / 停止する。用法は基本的にTCPのケースと同様だが、Linuxの recvmmsg
syscallのサポートが入っていて、その場合はコールバックのプロトコルが異なる。