Open4

MiniSock: libuvで実装する

okuokuokuoku

ひとまずlibuvを使ってMiniSockを実装してみる。libuvはWindowsとPOSIXの両方で動くので、今後それぞれのネイティブバックエンドを実装する上で有利と踏んだので。

Samsungの libtuv は更にNuttXのような組込みOSにも対応して似たAPIを提供するが、既にアーカイブされているようなので今回は不採用とした。libtuvは同じくSamsungに由来する iot.js https://iotjs.net に採用されている。

okuokuokuoku

セマンティクスの違い

全体的に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の接続完了処理をエミュレートすることにする。

okuokuokuoku

libuvにおける接続の流れ

今回処理したい接続には以下の4種がある:

  1. 相手先を指定するoutgoing TCP接続(TCP connect)
  2. 待ち受けincoming TCP接続(TCP listen)
  3. 相手先を指定するoutgoing UDP接続(UDP send)
  4. 待ち受けincoming UDP接続(UDP bind)

また、それぞれで相手先またはbindアドレスを名前指定できるため、名前解決が追加で必要になるケースもある。

通常のケースでは、送信元ポートはシステム任せのランダム選択になる。このような場合はポート番号としてゼロを選択する。

名前解決

OS標準の名前解決処理は非同期でないことが多いため、libuvは専用のリクエストフローを実装して抽象化、非同期化している。(例えば、glibcは独自拡張として非同期版の getaddrinfo_a を提供している。)

  1. uv_getaddrinfo でリクエスト uv_getaddrinfo_t を初期化してコールバックを待つ
  2. コールバックに渡される struct addrinfo を使用する
  3. 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のようなフラグが存在しない(後で足す)。

  1. uv_tcp_init でハンドル uv_tcp_t を初期化する
  2. uv_tcp_connect でリクエスト uv_connect_tを初期化しつつ connectする
  3. コールバックに渡されるステータスを検証する
  4. (通信処理を行う)
  5. uv_close でcloseを要求する。 close自体はリクエストではない ため失敗しないが、ハンドル uv_tcp_t を解放するにはコールバックを待つ必要がある。

TCP listen

TCPのlistenは殆どconnectと同様に処理できる。listenのコールバックは複数回呼ばれる可能性がある。

  1. uv_tcp_init でハンドル uv_tcp_t を初期化する
  2. uv_listen でlistenを開始する。 backlog に指定したぶんのバッファはカーネルが持ってくれるため、listenはリクエストではない。ただし失敗する可能性はある。
  3. コールバック内で uv_tcp_init したハンドルについて uv_accept して接続を確立する。libuvではコールバック内で1回はacceptできることを保証している。 ...何故?acceptまでに相手がcloseを発行してきたケースでは失敗しないんだろうか。。
  4. (通信処理やcloseを行う)

listenにはパラメタ backlog が存在し、接続を待たせられるクライアントの数を制約できるようになっている。とりあえず SOMAXCONN の適当な値(= 128)で固定しておくが考察の余地があるかもしれない。libuvはAPIとして SOMAXCONN 定数を提供していない。。はず。(なのでnode.jsとかでこの辺をどう処理しているのかは調査の余地がある)

UDP

UDP自体はステートレスなプロトコルではあるものの、通常カーネル側のsocketオブジェクトの作成/破棄は必要としている事が多く、libuvもそれに準じている。

  1. uv_udp_init でハンドル uv_udp_t を初期化する
  2. 必要に応じて uv_udp_bind でbindする。bindはリクエストではなく、コールバックも取らない。
  3. (通信処理やcloseを行う)

基本的なUDP対話の場合、 "送信元や送信元ポートはどうでも良いからとりあえずbindして欲しい" というbindを行う。これは、いわゆる INADDR_ANY と ポート番号ゼロで表現される。

okuokuokuoku

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_tuv_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のサポートが入っていて、その場合はコールバックのプロトコルが異なる。