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サーバーを立ち上げることができます。
- Socket
- Bind / Listen
- Accept
- Read / Write
- 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)とは
まず、ディスクリプタを理解する上で、「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
として扱われます。
出典: https://www.usna.edu/Users/cs/wcbrown/courses/IC221/classes/L09/Class.html
例えば、User SpaceからKernel Spaceにファイルの書き込みをしようとすると
- system call で、ファイルのディスクリプタを取得する。(open)
- system call で、取得したディスクリプタを指定して書き込む(write)
- 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サーバーのパフォーマンス上げるために気をつけることや、全体の開発手順についてまとめていきます。