記事ディレクトリ
1. 5つのIOモデルの基本コンセプト
まず第一に、IO は待機中 + データのコピーです。以前にサーバーの read/recv インターフェイスを実装したことを思い出してください。そのとき、このインターフェイスにデータがある場合、コピーが完了した後に read/recv が返されると述べました。データがない場合、待機はブロックされ、待機の目的は、リソースの準備ができるまで待機し、リソースができたらデータをコピーすることです。
1. I/O のブロック
プロセスが recvfrom を呼び出してカーネル内のデータを読み取るシステム コールを行うとき、データの準備ができていない場合、recv は直接ブロックしてデータの準備ができるまで待ちます。データの準備ができたら、データは次からコピーされます。カーネルをユーザー空間にコピーすると、コピーが成功の指示を返します。
2. ノンブロッキング IO
プロセスが recvfrom を呼び出してカーネル内のデータを読み取るシステム コールを行うとき、データの準備ができていない場合、recv はノンブロッキングであるためエラー コードを返します。そのため、カーネルが正常に動作しているかどうかを確認するのに時間がかかります。データの準備ができているので、他のときにも使用できます。このプロセスにログの印刷など他の処理を実行させます。データの準備ができているかどうかを定期的に確認し、準備ができていない場合は、エラー コードを送信し、データをコピーします。カーネルをユーザー空間に送信し、準備ができたら成功を返します。
3. 信号駆動型 IO
データの準備ができていない場合は、プロセスに sigaction をキャプチャさせることができ、準備ができたらこのシグナルをキャプチャしてデータをコピーします。準備ができていない場合でも、他のことを行うことができます。
4. IO多重化
注: マルチウェイ転送の原則は、一度に複数のファイル記述子を待機することであるため、以前のインターフェイスは使用できず、新しい select システム コールを使用する必要があります。また、select、poll、epoll はすべて IO 中間ステップであり、成功した後でも、recvfrom を呼び出してデータをコピーできます。また、選択が成功するまで待機している限り、recvfrom はマルチウェイ転送中にブロックされなくなり、recvfrom はデータを直接コピーします。
5. 非同期 I/O
非同期 IO の原理は、システムにデータを待たせることです。データがある場合は、指定したバッファにコピーされます。私はバッファ内のデータを取得することだけを担当します。これは、以前の IO はすべて調理方法に焦点を当てているのに対し、非同期 IO は食べる方法にのみ焦点を当てており、食事がどのように提供されるかは考慮していないという事実に相当します。
2. IO の重要な概念
1.同期通信と非同期通信(同期通信/非同期通信)
2.ブロッキングとノンブロッキング
3. ノンブロッキング IO のコードデモ
まず、fcntl インターフェイスについて理解しましょう。
#include <unistd.h>
#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* arg */ );
void setNonBlock(int fd)
{
int n = fcntl(fd,F_GETFL);
if (n<0)
{
std::cerr<<"fcntl: "<<strerror(errno)<<std::endl;
return;
}
fcntl(fd, F_SETFL, n | O_NONBLOCK);
}
F_GETFD はファイル記述子のステータス フラグを取得し、関数は設定が失敗したことを示す -1 を返します。
次の図に示すように、F_SETFL は、読み取り設定や書き込み設定など、ファイル記述子のステータス フラグを設定できます。
最後の O_NONBLOCK は、ノンブロッキングに設定されたオプションです。ファイル記述子を非ブロッキングに設定する関数を作成した後、最初にブロッキング状態の結果を示し、次に非ブロッキング状態の結果を示します。
int main()
{
char buffer[1024];
while (true)
{
printf(">>> ");
fflush(stdout);
ssize_t s = read(0,buffer,sizeof(buffer)-1);
if (s>0)
{
buffer[s] = 0;
std::cout<<"echo# "<<buffer<<std::endl;
}
else if (s == 0)
{
std::cout<<"read end"<<std::endl;
break;
}
else
{
}
}
return 0;
}
無限ループで直接読み取り、最初にバッファーを作成し、次に 0 標準入力ファイル記述子のデータを独自のバッファーに読み取ります。読み取りが成功したら、ファイルの末尾に \0 を付けて出力します。結果を見ると、内容を標準入力ファイル記述子に出力しないと読み取り関数でブロックされるため、これがブロッキング読み取りであることがわかります。非ブロッキングの結果を見てみましょう。
int main()
{
char buffer[1024];
setNonBlock(0);
while (true)
{
printf(">>> ");
fflush(stdout);
ssize_t s = read(0,buffer,sizeof(buffer)-1);
if (s>0)
{
buffer[s] = 0;
std::cout<<"echo# "<<buffer<<std::endl;
}
else if (s == 0)
{
std::cout<<"read end"<<std::endl;
break;
}
else
{
}
sleep(1);
}
return 0;
}
まず、テスト中に >>> を印刷するには速すぎるため、記述子 0 をノンブロッキングとして設定します。デモンストレーションするために、1 秒間スリープします。
ファイルディスクリプタ番号0番に関数を入力しなくても無限ループで実行されることがわかり、反映された結果、入力しないと>>>記号が表示され続けます。が印刷され、>>> 記号も入力プロセス中に印刷されます。これはノンブロッキングです。データ入力を待つために読み取りインターフェイスをブロックする必要はなくなりました。
冒頭で、データの準備ができていない場合、ノンブロッキング IO はエラー コードを返すと述べたことを思い出してください。読み取りに失敗した場合、読み取りインターフェイスは -1 を返すことがわかっています。以下でそれを確認してみましょう:
結果から、エラー コード -1 が実際に返されたことがわかり、以下のエラーの原因を出力します。
-1 が返されますが、これはエラーではなく、リソースの準備ができていないことがわかります。実際、オペレーティング システムはいくつかのエラー コードを用意しています。
たとえば、EAGAIN はリソースの準備ができていないことを意味し、EINTR はデータが読み取られていないため中断されたことを意味しますが、これはエラーではありません。
実際、正しい書き方は上記のとおりです。このようにすると、現時点ではエラーはないが、リソースの準備ができていないことがわかります。
上記はノンブロッキングIOのコードデモですが、次にIOマルチチャネル転送のセレクトインターフェースを紹介します。
4. IO多重化選択
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
select は一度に複数のファイル記述子を待つことができ、ファイル記述子の本質は配列添字であるため、最初のパラメータはチェックされる最大のファイル記述子 + 1 です。+1 は、最下層がファイル記述子を走査するためです。 。
readfds、writefds、およびExceptfdsは、それぞれ、読み取りファイル記述子のセット、書き込みファイル記述子のセット、および例外ファイル記述子のセットです。
timeout は、select の待ち時間を設定するために使用される構造体です。timeval とは何かを見てみましょう。
それはどういう意味ですか。たとえば、timeout={0,0} を渡して非ブロッキング監視ファイル記述子を示し、timeout=nullptr はブロッキング監視ファイル記述子を示し、timeout={5,0} は 5 秒以内のブロッキング監視ファイル記述子を示します。 5 秒間のノンブロッキング戻り、その後のタイムアウト {5,0} は {0,0} になります。
たとえば、最初に示した読み取りブロックのコードでは、select で 5 と 0 を設定すると、5 秒以内に >>> のみが表示され、ユーザーの入力を待ち、5 秒後にエラー コードが返されます。戻ると、ノンブロッキングと同様に印刷を続けます >>>
select の戻り値が 0 より大きい場合は、複数のファイル記述子が準備できていることを意味します。戻り値が 0 に等しい場合は、タイムアウトによる戻りを意味します。戻り値が 0 未満の場合は、ファイル記述子が存在することを意味します。選択呼び出しでエラーが発生しました。
実際、fd_set タイプはビットマップです。ファイル記述子の読み取りイベントの準備が完了すると、ビットマップ内のファイル記述子の位置は 1 に設定されます。次の図に示すように、書き込みイベントと例外イベントは同じです。
これを呼び出すと、ユーザーはどのファイル記述子を考慮する必要があるかをカーネルに伝えます。
関数が実行されるとき、ビットマップ内のどのビットが 1 に設定されるかは、どのファイル記述子イベントの準備ができているかを表します。
以下はビットマップを操作するためのインターフェイスです。
void FD_CLR(int fd, fd_set *set); // 用来清除描述词组set中相关fd 的位
int FD_ISSET(int fd, fd_set *set); // 用来测试描述词组set中相关fd 的位是否为真
void FD_SET(int fd, fd_set *set); // 用来设置描述词组set中相关fd的位
void FD_ZERO(fd_set *set); // 用来清除描述词组set的全部位
上記のインターフェイスを理解した後、選択サーバーを実装します。
まず、ソケットの作成、バインド、リスニング、新しい接続の取得の 4 つのステップを関数にカプセル化します。
enum
{
SOCKET_ERR = 2,
USE_ERR,
BIND_ERR,
LISTEN_ERR
};
const uint16_t gport = 8080;
class Sock
{
private:
public:
const static int gbacklog = 32;
static int createSock()
{
// 1.创建文件套接字对象
int sock = socket(AF_INET, SOCK_STREAM, 0);
if (sock == -1)
{
logMessage(FATAL, "create socket error");
exit(SOCKET_ERR);
}
logMessage(NORMAL, "socket success %d",sock);
int opt = 1;
setsockopt(sock,SOL_SOCKET,SO_REUSEADDR | SO_REUSEPORT,&opt,sizeof(opt));
return sock;
}
static void Bind(int sock,uint16_t port)
{
struct sockaddr_in local;
bzero(&local, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(port);
local.sin_addr.s_addr = INADDR_ANY; // INADDR_ANY绑定任意地址IP
if (bind(sock, (struct sockaddr *)&local, sizeof(local)) < 0)
{
logMessage(FATAL, "bind socket error");
exit(BIND_ERR);
}
logMessage(NORMAL, "bind socket success");
}
static void Listen(int sock)
{
if (listen(sock, gbacklog) < 0)
{
logMessage(FATAL, "listen socket error");
exit(LISTEN_ERR);
}
logMessage(NORMAL, "listen socket success");
}
static int Accept(int listensock,std::string *clientip,uint16_t& clientport)
{
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
// sock是和client通信的fd
int sock = accept(listensock, (struct sockaddr *)&peer, &len);
// accept失败也无所谓,继续让accept去获取新链接
if (sock < 0)
{
logMessage(ERROR, "accept error,next");
}
else
{
logMessage(NORMAL, "accept a new link success");
*clientip = inet_ntoa(peer.sin_addr);
clientport = ntohs(peer.sin_port);
}
return sock;
}
};
TCP サーバーを実装する際のサーバーのすべての関数インターフェイスについて上記で説明しましたが、理解できない場合は、以下を参照してください。
namespace select_ns
{
static const int defaultport = 8080;
class SelectServer
{
private:
int _port;
int _listensock;
public:
SelectServer(int port = defaultport)
:_port(port)
,_listensock(-1)
{
}
void initServer()
{
_listensock = Sock::createSock();
Sock::Bind(_listensock,_port);
Sock::Listen(_listensock);
}
void start()
{
for (;;)
{
fd_set rfds;
FD_ZERO(&rfds);
// 把lsock添加到读文件描述符集中
FD_SET(_listensock, &rfds);
struct timeval timeout = {1, 0};
int n = select(_listensock+1,&rfds,nullptr,nullptr,&timeout);
switch (n)
{
case 0:
logMessage(NORMAL,"time out.....");
break;
case -1:
logMessage(WARNING,"select error,code: %d,err string: %s",errno,strerror(errno));
break;
default:
//说明有事件就绪了
logMessage(NORMAL,"get a new link");
break;
}
sleep(1);
/* std::string clientip;
uint16_t clientport = 0;
int sock = Sock::Accept(_listensock,&clientip,clientport);
if (sock<0)
{
continue;
}
//开始进行服务器的处理逻辑 */
}
}
~SelectServer()
{
if (_listensock != -1)
{
close(_listensock);
}
}
};
}
上記は、カプセル化されたインターフェイスを使用して選択サーバーを実装するためのフレームワークです。サーバー起動関数では、ファイル記述子ビットマップ読み取りオブジェクトを作成し、それを FD_ZERO で 0 に初期化する必要があります。ここでは、その方法を示しているだけであることに注意してください。実際、書き込みと例外は読み取りと同じです。ブロッキング読み取りを 1 秒以内に設定し、select の戻り値を 3 つのケースに分けます。 1. タイムアウトの選択 2. エラーの選択 3. イベントの準備ができたことを検出し、イベントの準備ができたら、それを出力します。では、実行してみましょう:
接続がない場合は time_out を出力する必要があり、接続がある場合は get new を出力します。
では、なぜこれほど多くの人が新しい印刷物を手に入れるのでしょうか? これは、この選択によって取得されたファイル記述子を処理しなかったためで、ビットマップ内のファイル記述子の値は常に 1 であるため、印刷が継続されます。次に、準備ができたファイル記述子を処理する処理関数を作成します。
void HanderEvent(fd_set &rfds)
{
if (FD_ISSET(_listensock, &rfds))
{
//listensock必然就绪
std::string clientip;
uint16_t clientport = 0;
int sock = Sock::Accept(_listensock, &clientip, clientport);
if (sock < 0)
{
return;
}
logMessage(NORMAL,"accept success [%s:%d]",clientip.c_str(),clientport);
}
}
listen ファイル記述子読み取りイベントの準備ができたら、新しい接続を取得し、クライアントの IP とポート番号を出力します。
では、実行してみましょう:
新しい接続が正常に取得されると、今度は以前のように繰り返し出力して新しい接続を取得するのではなく、新しい接続を待ち続けることがわかります。これは、読み取りイベントの準備ができており、このイベントを処理しているためです。 。
この点を扱った後, select に他のファイル記述子を処理させる方法を考えてみましょう. たとえば, 通信するには accept によって返されたファイル記述子を使用する必要があります. クライアントがデータを送信すると, サーバーはこのデータを表示できます. 実際にselect を使用すると、プログラマはすべての正当な fd を格納する配列を維持する必要があります。以下で実装してみましょう。
まず、配列とデフォルト値を作成します。これは、配列内のすべての要素を初期化するために使用されます。
fd_num は、この配列に格納できるファイル記述子の最大数を表し、この数は fd_set*8 と同じくらい大きくなります。
void initServer()
{
_listensock = Sock::createSock();
if (_listensock == -1)
{
logMessage(NORMAL,"createSock error");
return;
}
Sock::Bind(_listensock,_port);
Sock::Listen(_listensock);
fdarray = new int[fd_num];
for (int i = 0;i<fd_num;i++)
{
fdarray[i] = defaultfd;
}
fdarray[0] = _listensock;
}
初期化するときは、スペースを開けてすべての値を-1に初期化する必要があります(なぜ負の数なのでしょうか?ファイル記述子は0から始まるため、正の数である場合、特定のファイル記述子に影響を与える可能性があります)。スペースは開かれているため、必要ありません。それは破棄されるため、デストラクターがあります。もちろん、リッスンしているソケットは、初期化中に配列で管理する必要があります。
~SelectServer()
{
if (_listensock != -1)
{
close(_listensock);
}
if (fdarray)
{
delete[] fdarray;
fdarray = nullptr;
}
}
start 関数では、イベントの準備ができたらハンドラー関数を実行します。これは、配列を使用してすべてのファイル記述子を管理しているため、ハンドラー メソッドは次のようになります。
void HanderEvent(fd_set &rfds)
{
if (FD_ISSET(_listensock, &rfds))
{
//listensock必然就绪
std::string clientip;
uint16_t clientport = 0;
int sock = Sock::Accept(_listensock, &clientip, clientport);
if (sock < 0)
{
return;
}
logMessage(NORMAL,"accept success [%s:%d]",clientip.c_str(),clientport);
// 开始进行服务器的处理逻辑
// 将accept返回的文件描述符放到自己管理的数组中,本质就是放到了select管理的位图中
int i = 0;
for (i = 0; i < fd_num; i++)
{
if (fdarray[i] != defaultfd)
{
continue;
}
else
{
break;
}
}
if (i == fd_num)
{
logMessage(WARNING, "server is full ,please wait");
close(sock);
}
else
{
fdarray[i] = sock;
}
print();
}
}
最初のステップは、リッスンしているソケットの読み取りイベントの準備ができているかどうかを判断し、準備ができている場合にのみ、次の操作を実行します。新しい接続によって返された通信ソケットを取得したら、このソケットを select のビットマップに入れて管理する必要があるため、まず配列を走査して正当なファイル記述子を見つけます (デフォルト値が使用されている場合、その値は不正です)。 , 正当な記述子を見つけた後、まず、走査の過程で配列の終端に到達したかどうかを判断します。配列の終端に到達した場合、配列内のすべてのファイル記述子が正当であることを意味します。ログ配列がいっぱいで待機する必要があることを記録する必要があります。配列の末尾に到達していない場合は、accept によって返された新しいファイル記述子を配列の指定された位置に配置するだけです。その後、結果を確認しやすくするために print 関数を追加しました。
void print()
{
std::cout << "fd list: ";
for (int i = 0; i < fd_num; i++)
{
if (fdarray[i] != defaultfd)
{
std::cout << fdarray[i] << " ";
}
}
std::cout << std::endl;
}
この関数は正当なファイル記述子のみを出力します。もちろん、変更されていない場所が 1 か所あります。select の最初のパラメータを覚えておいてください。このパラメータは最大のファイル記述子 + 1 であるため、変更は次のようになります:
まず、最大のファイル記述子がリッスンしているソケットであると仮定し、次に配列を走査し、正当なファイル記述子を見つけ、その正当なファイル記述子を読み取りビットマップに追加して、それが maxfd より大きいかどうかを判断します。
新しい接続が到着するたびに、新しい接続のファイル記述子を配列に追加し、最後に配列はこれらの正当なファイル記述子を監視用に選択します。
コードの変更を続けて、選択したサーバーが通常の IO 通信をサポートできるようにしましょう。
すべてのファイル記述子を処理する必要があるため、ハンドラー関数の accept 部分をカプセル化し、さまざまなファイル記述子に従って対応する関数を実装します。
void HanderEvent(fd_set &rfds)
{
for (int i = 0;i<fd_num;i++)
{
//过滤掉非法的文件描述符
if (fdarray[i] == defaultfd)
continue;
//如果是listensock事件就绪,就去监听新连接获取文件描述符,如果不是listensock事件,那么就是普通的IO事件就绪了
if (FD_ISSET(fdarray[i], &rfds) && fdarray[i] == _listensock)
{
Accepter(_listensock);
}
else if (FD_ISSET(fdarray[i], &rfds))
{
Recver(fdarray[i],i);
}
else
{
}
}
}
listensock ファイル記述子の準備ができたら、新しい接続のリスニングを処理するために accept 関数を呼び出します。通常のファイル記述子の準備ができたら、データ読み取り関数を実行します。
void Accepter(int listensock)
{
// listensock必然就绪
std::string clientip;
uint16_t clientport = 0;
int sock = Sock::Accept(listensock, &clientip, clientport);
if (sock < 0)
{
return;
}
logMessage(NORMAL, "accept success [%s:%d]", clientip.c_str(), clientport);
// 开始进行服务器的处理逻辑
// 将accept返回的文件描述符放到自己管理的数组中,本质就是放到了select管理的位图中
int i = 0;
for (i = 0; i < fd_num; i++)
{
if (fdarray[i] != defaultfd)
{
continue;
}
else
{
break;
}
}
if (i == fd_num)
{
logMessage(WARNING, "server is full ,please wait");
close(sock);
}
else
{
fdarray[i] = sock;
}
print();
}
accept は先ほどのハンドラー関数のコードです。データの処理方法を直接説明します。
void Recver(int sock,int pos)
{
//注意:这样的读取有问题,由于没有定协议所以我们不能确定是否能读取一个完整的报文,并且还有序列化反序列化操作...
//由于我们只做演示所以不再定协议,在TCP服务器定制的协议大家可以看看
char buffer[1024];
ssize_t s = recv(sock,buffer,sizeof(buffer)-1,0);
if (s>0)
{
buffer[s] = 0;
logMessage(NORMAL,"client# %s",buffer);
}
else if (s == 0)
{
//对方关闭文件描述符,我们也要关闭并且下次不让select关心这个文件描述符了
close(sock);
fdarray[pos] = defaultfd;
logMessage(NORMAL,"client quit");
}
else
{
//读取失败,关闭文件描述符
close(sock);
fdarray[pos] = defaultfd;
logMessage(ERROR,"client quit: %s",strerror(errno));
}
//2.处理 request
std::string response = func(buffer);
//3.返回response
write(sock,response.c_str(),response.size());
}
まず第一に、データ処理に問題があります。通常の状況では、完全なメッセージを確実に読み取るためにカスタム プロトコルが必要であり、シリアル化と逆シリアル化が必要です。今日は、デモンストレーション目的でこれらのタスクを実行しません。 。データの読み取り後、サーバー上でエコー プリントを実行します。読み取りが失敗するか、クライアントがファイル記述子を閉じた場合、サーバーはこの時点で対応するファイル記述子も閉じる必要があり、ファイルを配列に記述します。文字が不正な状態に設定されているため、次の選択ではこのファイル記述子は監視されなくなります。クライアントからメッセージを取得したら、次の図に示すように、デモ用に新しく追加された関数 func を直接呼び出して処理します。
単にクライアントのメッセージを返しているように見えますが、実際には、この関数の機能はクライアントのリクエストを処理し、シリアル化と逆シリアル化の後にクライアントに応答を送信することです。
応答を取得したら、通信に使用するファイル記述子に直接それを書き戻します。このようにしてコードを変更したので、実行して見てみましょう。
プログラムが問題なく動作していることがわかります。
要約する
選択サーバーの特徴をまとめてみましょう。
1. 同時に選択できるファイルディスクリプタ数には上限があり、カーネル変更により上限が若干増えるだけで完全に解決するわけではありません。
2. 選択サーバーは、正当なファイル記述子を維持するためにサードパーティのアレイを使用する必要があります。
3. select のパラメータのほとんどは入力および出力タイプです。select を呼び出す前に、すべてのファイル記述子をリセットする必要があります。呼び出し後は、すべてのファイル記述子を確認して更新する必要もあります。これにより、トラバーサルのコストが発生します。
4. select の最初のパラメータが最大のファイル記述子 +1 なのはなぜですか? これは、ファイル記述子もカーネル レベルでトラバースする必要があるためです。
5. Select はビットマップを使用するため、頻繁にカーネル モードからユーザー モードに切り替え、ユーザー モードからカーネル モードに切り替えてデータを往復コピーするため、コピー コストの問題があります。
では、上記の問題を解決するにはどうすればよいでしょうか? 次のポーリング サーバーと epoll サーバーは、この問題を解決します。