GoでTCPサーバー上で独自プロトコルKey-Value Storeを実装する(知識編)

6月からDMM.comラボでミドルウェアを作るエンジニアインターンをしている@kawasin73です。
DMM.comラボではluaで実装されたKVS(キーバリューストア)を利用しています。
これは、TCPの上で独自プロトコルで通信しており、URIのPathがKeyとなり最長共通接頭辞検索をするKVSで、社内でluaの皮を被ったC言語で実装されたものが運用されています。
この度、このKVSをGo言語で再実装することになり、設計は既存のミドルウェアを踏襲した形で DMM.com ラボの方が行い、実装は僕がすることになりました。
Go言語の実装手法(goroutine や channel等)については僕が学びながらそれについて都度相談するというスタイルで行なっています。
その開発記を連載で投稿しようと思います。

第1回目の今回は、TCPサーバーの実装に必要な知識についてまとめていきます。
TCPサーバについてよく知らなかったのですが、DMM.comラボのメンターの方にTCPについて授業をしていただきました。
そのとき取ったメモから書き起こしていきます。

第1回 GoでTCPサーバー上で独自プロトコルKey-Value Storeを実装する(知識編)
第2回 GolangでハイパフォーマンスなTCPサーバーを実装する(下準備編)
第3回 Go言語でTCPのEchoサーバーを実直に実装する
第4回 Go言語でシグナルハンドリングをするTCPのEchoサーバーを実装する
第5回 Go言語でGraceful Shutdown可能なTCPのechoサーバーを実装する(その1)
第6回 Go言語でGraceful Shutdown可能なTCPのechoサーバーを実装する(その2)
第7回 GolangでTCPサーバーに再起動とGraceful Shutdownを実装する

TCP とは

TCPとは、Transmission Control Protocolの略で、トランスポート層のプロトコルです。
IPの上に成り立っており、データの送受信を定めたプロトコルです。

TCPと対立するプロトコルとしてよく挙げられるのが、UDP(User Datagram Protocol)です。
こちらもIPの上に成り立っており、データの送受信を定めたプロトコルです。

TCP と UDP

TCPは信頼性のあるプロトコル、UDPは信頼性は低いが高速なプロトコルと紹介されることが多いですが、代表的なものだと以下のような違いや特徴があります。詳しくは、ネットワークについて解説した本を読んでください。
信頼性については、この記事TCPとはなにか。~信頼性のある通信を確立させる役割~が参考になります。

TCP

  • シーケンス番号による順序保証
  • RTT予測
  • タイムアウト
  • keep alive (拡張機能でありデフォルトではオフになっています)

UDP

UDPにはTCPにあるような機能がない分、高速に通信をすることができます。

これ、ホントに実装するの?

と、ここまでTCPについて説明してきましたが、TCPに沿ったデータ通信の実装は、LinuxなどのOSであれば、OS側で実装してあります。OSからソケットを取得することで、TCPに沿ったデータ通信を行うことができます。

TCPサーバーの立ち上げ方

以下のステップでTCPサーバーを立ち上げることができます。

  1. Socket
  2. Bind / Listen
  3. Accept
  4. Read / Write
  5. Close

Socket

man page: https://linuxjm.osdn.jp/html/LDP_man-pages/man2/socket.2.html

sockfd = socket(int socket_family, int socket_type, int protocol);

Socket()を呼び出すと、ソケットのディスクリプタを取得できます。
ここからの話は、このディスクリプタを中心に立ち回っていきます。
ここでディスクリプタについて説明しておきます。

ディスクリプタ(Descriptor)とは

https://ja.wikipedia.org/wiki/%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E8%A8%98%E8%BF%B0%E5%AD%90

まず、ディスクリプタを理解する上で、「User Space」と「Kernel Space」を知る必要があります。
これらは、プログラムが実行される空間を表します。

User Spaceとは、普段僕たちがプログラミングしたプログラムが動く空間です。

Kernel Spaceとは、OS(カーネル)が動いている空間です。ファイルの操作(作成や書き込み、読み込み)などは、OS(カーネル)が行うものであり、Kernel Spaceで行われます。

僕たちがプログラミングをしている中で、ファイルに書き込んだり、読み込んだりしたくなることがあります。その時、User SpaceからKernel Spaceに対して、OSの機能の呼び出し(ファイルの書き込みなど)を行います。この呼び出しを、「system call」と呼びます。

さて、LinuxなどのUNIX系のOSでは、ファイルやディレクトリ、ソケットなどをディスクリプタとして扱います。
ファイルの書き込みなどは、User SpaceからKernel Spaceにsystem callを呼び出すときに、対象となるディスクリプタを指定して操作を行います。
ディスクリプタは、C言語の場合はintとして扱われます。

fileio1.png

出典: https://www.usna.edu/Users/cs/wcbrown/courses/IC221/classes/L09/Class.html

例えば、User SpaceからKernel Spaceにファイルの書き込みをしようとすると

  1. system call で、ファイルのディスクリプタを取得する。(open)
  2. system call で、取得したディスクリプタを指定して書き込む(write)
  3. system call で、取得したディスクリプタを閉じる(close)

を行うことになります。

ディスクリプタは、プロセスごとに0番から始まるテーブルを持ちます。また、プロセスごとに持てるディスクリプタの数には上限があり、ulimitコマンドでその上限を確認することができます。

ディスクリプタは、使い終わると解放されますが、もう一度作った時に空いた場所のディスクリプターを再利用され使うことがあります。案外ハマることもあるので注意が必要です。

Bind

man page: https://linuxjm.osdn.jp/html/LDP_man-pages/man2/bind.2.html

Socket() で取得したソケットディスクリプタには、ポート番号やアドレスなどが紐づいていないので、Bind()を行います。

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

指定したポート番号がすでに使われている場合などは、エラーが返ります。

Listen

man page: https://linuxjm.osdn.jp/html/LDP_man-pages/man2/listen.2.html

Bind()で、ポート番号などが紐づけられた後にListen()をして、外部からのアクセスを待ち受けます。

int listen(int sockfd, int backlog);

ここで指定するバックログ(backlog) について説明します。

新しくサーバーにアクセスあったときに、この次に説明するAccept()を呼び出すことで新しいコネクションを作成するのですが、Accept()が呼ばれるまで新しいアクセスは、OSが保留しています。その保留するアクセス数の最大値をバックログ(backlog)といいます。

大量アクセスがありAccept()が間に合わず、backlogの数を超えてアクセスが溜まってしまった場合は、クライアントに、ECONNREFUCEDエラーが返され、接続に失敗してしまいます。
なお、backlogは、net.core.somaxconnより大きな値を設定することはできないようです。
参照: net.core.somaxconnについて調べてみた

Accept

man page: https://linuxjm.osdn.jp/html/LDP_man-pages/man2/accept.2.html

新しいアクセスがあったときに、Accept()を呼ぶことでコネクションソケットを作成し、そのソケットを表す、ディスクリプタを返します。
クライアントとの通信は、Accept()で作成したディスクリプタを通じて行います。
Accept()を呼んだときに、アクセスがなかった場合は、EAGAINエラーが返ります。その時は再度Accept()を呼んで新しいアクセスに備えることになります。

int accept4(int sockfd, struct sockaddr *addr, socklen_t *addrlen, int flags);

Read / Write

man page (recv): https://linuxjm.osdn.jp/html/LDP_man-pages/man2/recv.2.html
man page (send): https://linuxjm.osdn.jp/html/LDP_man-pages/man2/send.2.html
man page (sendfile): https://linuxjm.osdn.jp/html/LDP_man-pages/man2/sendfile.2.html

Read系とWrite系の関数には複数あります。

Read Write 特徴
recv send シンプルなインターフェース
recvfrom sendto 主にUDPで使う クライアントのアドレスを指定する
recvmsg sendmsg ベクトル(配列)が送れる。補助データが遅れる
sendfile file descripterから直接Writeする
ssize_t recv(int sockfd, void *buf, size_t len, int flags);

ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
                 struct sockaddr *src_addr, socklen_t *addrlen);

ssize_t recvmsg(int sockfd, struct msghdr *msg, int flags);


ssize_t send(int sockfd, const void *buf, size_t len, int flags);

ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
               const struct sockaddr *dest_addr, socklen_t addrlen);

ssize_t sendmsg(int sockfd, const struct msghdr *msg, int flags);

ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count); 

send をする際は、User Spaceから、Kernel Spaceにデータをコピーする必要があります。
send_fileは、ファイルをそのまま送信するときに、ファイルディスクリプタ経由でwriteを行うことで、Kernel Spaceの中で完結するため、データのコピーコストを削減することができます。

Close

man page: https://linuxjm.osdn.jp/html/LDP_man-pages/man2/close.2.html

通信が終わったクライアントとの、ソケットディスクリプタをClose()することで、解放します。
また、サーバーをシャットダウンする時は、Listen()したソケットディスクリプタをClose()することでサーバーを終了することができます。

また、クライアントとのディスクリプタとサーバーのディスクリプタはそれぞれ独立しています。
なので、サーバーのディスクリプタをClose()した後も、クライアントとのディスクリプタで通信を続けることができます。Graceful Shutdownにこの特徴を使うことができます。

最後に

ここまで、TCPサーバーを立ち上げるための知識をまとめました。
それぞれの言語において多少の違いはありますが、大まかな流れは変わりません。
Go言語の場合は、net.Listen()で、Socket() Bind() Listen()を行います。

GO言語の場合は、細かいエラーの内容やオプションの設定はできないですが、GO言語のパッケージの内部で呼び出しているシステムコールの詳細については、「man page システムコール名」のようにググると調べることができます。

次回は、TCPサーバーのパフォーマンス上げるために気をつけることや、全体の開発手順についてまとめていきます。

Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account log in.