プログラム全体フレームワーク#
概要#
プログラム全体は、クライアントとサーバーの 2 つの部分に分かれた、単一スレッドの非ブロッキングプログラムです。特定のポート(アプリポート、例えば 80)で内部ネットワーク上で動作する TCP プロトコルに基づくサービス(アプリ)を、外部ネットワークサーバー上の動的ポート(マッピングポート、例えば 44567)にマッピングできます。クライアントとサーバーの通信を実現するために、サーバーはクライアントと通信するためにポート(c-s ポート、例えば 7080)をリッスンする必要があります。NAT の存在のため、最初にクライアントはサーバーがリッスンしているポート(7080)に接続を確立するための connect を送信し、この接続を masterfd として、サーバーがクライアントへの接続要求を管理します。クライアントからのリクエストを受け取った後、サーバーは動的ポート(例えば 44567)をリッスンし、内部ネットワークサービスのマッピングポートとして、masterfd を介してポート情報をクライアントに送信します。その後、サーバーはリクエストを受けるたびに、masterfd を介してクライアントに接続を要求し、クライアントは c-s ポート(例えば 7080)へのデータフロー転送トンネルを確立し、サーバーは c-s ポートからマッピングポートへのデータフロー転送トンネルを確立します。両者の接続はポートフォワード機能を完了し、epoll を介してそのようなトンネルの動作を管理します。
データ構造#
クライアントとサーバーの内部では、常にデータを受信し、もう一方に転送するという同じことを行っています。したがって、これらの操作を完了するためのオブジェクトを抽象化できます。
// fdのラッピング
// buf:このfdがデータを受信するためのバッファ
// iptr, optrはデータの読み書き位置を記録するポインタ
// fd_fin, closed, connected: fdの状態を記録する変数
class fd_buf{
public:
int fd;
char buf[MAXLINE];
int iptr, optr;
bool fd_fin, closed, connected;
// uint32_t mod;
fd_buf() {
iptr = optr = 0;
fd_fin = false;
connected = false;
closed = false;
// mod = EPOLLIN;
}
fd_buf(int fd){
iptr = optr = 0;
fd_fin = false;
connected = false;
closed = false;
// mod = EPOLLIN;
this->fd = fd;
}
bool is_buf_empty() {
return iptr == optr;
}
bool is_buf_full() {
return iptr == MAXLINE;
}
};
// データ転送を完了するためのオブジェクト
// ユーザーの視点から見ると、一部はアップリンク、一部はダウンロード
class tunnel{
public:
fd_buf *fd_up, *fd_down;
double sum_up, sum_down;
tunnel() {
sum_up = 0;
sum_down = 0;
}
tunnel(int fd_user, int fd_app) {
sum_up = 0;
sum_down = 0;
fd_up = new fd_buf(fd_user);
fd_down = new fd_buf(fd_app);
}
int do_read(fd_buf* fd_buf_in, fd_buf* pair_fd_buf);
int do_write(fd_buf* fd_buf_out, fd_buf* pair_fd_buf);
void fd_user_read(){
int n = do_read(fd_up, fd_down);
sum_up += n;
}
void fd_app_read(){
do_read(fd_down, fd_up);
}
void fd_user_write(){
int n = do_write(fd_up, fd_down);
sum_down += n;
}
void fd_app_write(){
do_write(fd_down, fd_up);
}
bool is_user_fd(int fd){
return fd == fd_up->fd;
}
~tunnel() {
delete fd_down;
delete fd_up;
}
};
非ブロッキング#
なぜブロックするのか?それは、単一の接続に対して、その段階の作業が完了しないと、次のタスクが正しく完了できないからです。したがって、ブロックする必要があります。なぜ非ブロッキングを設定するのかというと、複数の接続を管理する必要があるからです。単一スレッドのプログラムでは、1 つの接続がブロックすると、他の接続も連鎖的にブロックされます。解決策の 1 つはマルチスレッドで、1 つの接続に 1 つのスレッドを割り当てることで、相互に影響を与えないようにします。もう 1 つは I/O 多重化で、epoll/select を使用して sockfd をリッスンし、準備が整った fd を返します。これにより、単一の接続が他の接続に影響を与えることはありません。TCP 通信に使用されるソケットプログラミングでは、connect、accept、read、write はすべてブロックする可能性がありますが、これらを非ブロッキングに設定することで性能を向上させ、単一の sock fd が他の sock fd をブロックするのを避けることができます。
setnonblocking 関数#
// マルチシステム互換のsetnonblocking
int setnonblocking(int fd) {
if (fd < 0) return -1;
int flags;
/* O_NONBLOCKがある場合、Posix方式で行う */
#if defined(O_NONBLOCK)
/* Fixme: O_NONBLOCKは定義されているが、SunOS 4.1.xとAIX 3.2.5では壊れている。 */
if (-1 == (flags = fcntl(fd, F_GETFL, 0)))
flags = 0;
return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
#else
/* そうでなければ、古い方法を使用する */
flags = 1;
return ioctl(fd, FIOBIO, &flags);
#endif
}
非ブロッキング connect#
ブロックの理由#
connect 関数は TCP の 3 ウェイハンドシェイクを引き起こし、サーバーに SYN を送信します。相手が一定の時間内に応答しない場合、クライアントは再試行を行い、最長で数十秒続きます。
非ブロッキングの設定方法#
connect 関数を呼び出す前に、setnonblocking (fd) を呼び出すだけです。
非ブロッキング connect の処理#
connect の前に fd を非ブロッキングに設定すると、TCP 接続が完了していない場合、最初に EINPROGRESS エラーが返されます。接続にエラーが発生した場合、fd は読み取り可能かつ書き込み可能になります。接続が完了した場合、fd は書き込み可能になります。一般的には、getsockopt () 関数を使用してエラーが返されるかどうかを確認し、接続が成功したかどうかを判断します。
int error;
socklen_t len = sizeof(error);
if(getsockopt(fd,SOL_SOCKET, SO_ERROR, &error, &len) == 0 && error == 0){
printf("接続成功\n");
}
else {
printf("接続失敗\n");
}
非ブロッキング accept#
ブロックの理由#
通常、epoll/select を使用すると、accept のブロックを考慮する必要はありません。常に接続イベントをリッスンしてから accept を呼び出します。しかし、特別な状況として、クライアントが接続を開始し、このイベントが epoll/select によって監視され、処理待ちのイベントリストに追加されたが、すぐには処理されなかった場合、クライアントがこの接続を中断すると、サーバーはこの接続を削除します。accept に入ると、プログラム全体がブロックされ、新しい接続が来るまで待機します。
処理方法#
- 対応する listenfd を非ブロッキングに設定します。
- accept 後に EWOULDBLOCK、ECONNABORTED、EPROTO、EINTR エラーを無視します。
非ブロッキング read と write#
読み取り可能および書き込み可能の判定条件#
読み取り可能
- 読み取るデータがある
- 相手から FIN パケットを受信
- 新しい接続が来た
- 処理待ちのエラー
書き込み可能
- 書き込み用のスペースがある
- 処理待ちのエラー
非ブロッキング処理#
setnonblocking (fd) 関数を呼び出すだけで、epoll を使用して対応する読み取りおよび書き込みイベントをリッスンします。対応するイベントをリッスンした後、エラーの中で EAGAIN および EWOULDBLOCK エラーを無視する必要があります。
EPOLL イベント駆動#
tunnel read の例を挙げます。
// 前提 fd&EPOLLIN, 対端fd_peerの状態は不明
n = read
if ( n < 0) {
// 非ブロッキングによるエラーは処理する必要はありません
if(errno != EAGAIN && errno != EWOULDBLOCK) {
// 現在の条件下で即座に処理できるものを先に処理し、
// 対端の状態およびアプリケーションのバッファ状態に基づいて分類処理します。
// fdを閉じると、epollは自動的にそのfdのリッスンを削除します。
close(fd)
// 現在のバッファが空でなく、対端が閉じていない場合、対端の書き込み可能をリッスンする必要があります。
if(!fd_pair.closed && fd.buf not empty ){
enable fd_peer EPOLLOUT
// fdがすでに閉じているため、対端の読み取りを閉じる必要があります。
disable fd EPOLLIN
}
}
}
else if (n == 0) {
if(fd_pair.buf not empty ) {
enable fd EPOLLOUT
}
if(!fd_pair.closed && fd.buf is empty) {
enable fd_pair EPOLLIN
}
}
else {
if(fd.buf is empty) {
if(fd_pair not full && !fd_pair.fd_fin) {
fd_pair EPOLLOUT|EPOLLIN
}
else {
enable fd_pair EPOLLOUT
}
}
if(fd.buf is full) {
if(fd_pair.buf not empty) {
enable fd EPOLLOUT
}
else{
disenable fd EPOLLOUT
}
}
}
TCP ストリームソケット特性の処理について#
TCP プロトコルは、データが順序通りに、誤りなく対端に送信されることを保証します(エラーが発生した場合は再試行またはエラー報告)。ただし、一度に送信したバイト数が、対端が一度に受信するバイト数とは限りません。TCP を基にアプリケーション層プロトコルを設計する際には、この特性に注意する必要があります。
一般的な解決策は 2 つあります。1 つは固定長メッセージで、一定の長さのバイトを 1 つのメッセージとし、受信時に受信したバイトをキャッシュし、指定された長さに達するまで対端のデータを受信し続けます。この方法は柔軟性が低く、複雑なアプリケーション層データの伝送には適していません。
もう 1 つはデータパケットのヘッダーを設計し、一部の内容をユーザーの関連プロトコルを処理するために使用し、送信時に長さフィールドを追加します。こうすることで、対端がデータを受信した後、まずヘッダーデータを解析し、データの長さを取得し、その長さのメッセージを受信した後に別のヘッダーを解析することができます。