おことわり
本記事ではglibcやwindows apiなどの様々な関数の使用方法等について書いていますが私は完全に理解しているわけではありません。
不正確な記述があるかもしれないことをご了承下さい。
(見つけたら訂正リクエストしてください)
はじめに
ここ1年ほどC++でsocketやssl/tlsなどのラッパーライブラリやhttp/http2/websocket/quic等の実装を何回も1から作り直すということを趣味にしている。
なかなか生産性のない趣味ではあるが意外と楽しいものである。
今回はその中で得た知見についてつらつらと書いていこうと思う。
最近のライブラリ
最近(?10年くらい前とかもあるが)の実用レベルのライブラリは
ほぼほぼnon-blocking IOとepollやIOCPなどの仕組みを使い、複数リクエストを
並行処理できるものが大半である(と思われる)。
またGo言語などのようにユーザーから見るとblockingなコードもruntimeレベルで
並行・並列化している例もある。
なので私の作るライブラリもこの例にそって全部non-blockingでepollやIOCPなどを
使う。
だが、あんまりC/C++でのsocketの非同期操作だけをまとめた記事は少ないように感じるのでそこら辺を書いていく。
Name Resolvement
getaddrinfo
まず,ソケット通信をするとなるとgethostbyname(非推奨)やgetaddrinfo等で
IPアドレスを取得する処理を書くと思う。
int getaddrinfo(const char *node, const char *service,
                const struct addrinfo *hints,
                struct addrinfo **res);
まあ今から作るならgetaddrinfo一択だと思うが
non-blockingなライブラリを作るときここが一番のネックである。
getaddrinfoはブロッキングするのである。
(まあ、ここは別にblockingしてもいいやと言うのであればパスしてもいい。)
この問題を解決するためには別の関数を使う必要があるのだがlinux(系?)とwindowsで方法が異なる。
getaddrinfo_a (linux)
まずlinuxではgetaddrinfo_aという関数を使う。
struct addrinfo {
    int              ai_flags;
    int              ai_family;
    int              ai_socktype;
    int              ai_protocol;
    socklen_t        ai_addrlen;
    struct sockaddr *ai_addr;
    char            *ai_canonname;
    struct addrinfo *ai_next; 
};
struct gaicb {
    const char            *ar_name;
    const char            *ar_service;
    const struct addrinfo *ar_request;
    struct addrinfo       *ar_result; 
};
int getaddrinfo_a(int mode, struct gaicb *list[],
                int nitems, struct sigevent *sevp);
この関数はgaicb構造体にgetaddrinfoの引数を突っ込んで渡すと、
別スレッドでgetaddrinfoを実行する。
引数のlistはgaicb**つまりgaicb構造体へのポインタの配列になるので、渡したあと完了するまでにgaicb構造体の寿命が尽きないよう注意する必要がある
// まとめて結果取得
int gai_suspend(const struct gaicb * const list[], int nitems,
                const struct timespec *timeout);
// 一個だけ結果取得
int gai_error(struct gaicb *req);
//要求キャンセル
int gai_cancel(struct gaicb *req);
そして,上記のgai_error関数などを使って結果を取得するというものである。
getaddrinfo_aのソースコード
まあ、ソースなどを見るに結局はマルチスレッドで待機キューを使って実装しているので、
スレッドをすべて自力で管理したいのであれば自力で似たようなコードを書くことになる。
GetAddrInfoExW (windows)
一方,WindowsではGetAddrInfoExWという関数を使う。
GetAddrInfoExAやマクロ定義のGetAddrInfoExなどもあるが基本的に上記関数で良い。
そもそもWindowsの~Aとか~WというのはUnicode過渡期に昔のコードをそのまま利用できるように考え出された苦肉の策であって、もはやUnicode全盛期の今では~W(UTF-16対応)関数を使うべきである。
// Windowsは整数型やポインタ型にいちいち別名がついてたりして紛らわしい
typedef const wchar_t* PCWSTR;
typedef unsigned int DWORD;
typedef struct addrinfoexW {
  int                ai_flags;
  int                ai_family;
  int                ai_socktype;
  int                ai_protocol;
  size_t             ai_addrlen;
  PWSTR              ai_canonname;
  struct sockaddr    *ai_addr;
  void               *ai_blob;
  size_t             ai_bloblen;
  LPGUID             ai_provider;
  struct addrinfoexW *ai_next;
} ADDRINFOEXW, *PADDRINFOEXW, *LPADDRINFOEXW;
INT WSAAPI GetAddrInfoExW(
  [in, optional] PCWSTR                             pName,
  [in, optional] PCWSTR                             pServiceName,
  [in]           DWORD                              dwNameSpace,
  [in, optional] LPGUID                             lpNspId,
  [in, optional] const ADDRINFOEXW                  *hints,
  [out]          PADDRINFOEXW                       *ppResult,
  [in, optional] timeval                            *timeout,
  [in, optional] LPOVERLAPPED                       lpOverlapped,
  [in, optional] LPLOOKUPSERVICE_COMPLETION_ROUTINE lpCompletionRoutine,
                 LPHANDLE                           lpHandle
);
なんかいろいろ引数があって分かりづらいのだが、ただnon-blockingにしたいだけなら、以下のように使う。
const wchar_t* hostname; // 解決したいホスト名
const wchar_t* service; // ポート番号("80"や"433"など)や"http"などの文字列
ADDRINFOEXW hint; // AF_INET6やSOCK_STREMなどのヒント 
ADDRINFOEXW* result;// 処理結果
timeval timeout; // タイムアウト
OVERLAPPED ol; // (Windowsの非同期操作でおなじみの) OVERLAPPED構造体
HANDLE cancel; // キャンセル時に使用するハンドル
// 適切に初期化....
// OVERLAPPED構造体のhEventにeventオブジェクトをセット
ol.hEvent = CreateEventW(nullptr, true, false, nullptr);
auto result = GetAddrInfoExW(hostname,service,0,nullptr,&hint,&result,&timeout,&ol,nullptr,&cancel);
// resultが0なら即時終了(=既にresultがセットされてる)
// resultがWSA_IO_PENDINGなら非同期実行中
// それ以外はエラー
そして非同期実行中(result==WSA_IO_PENDING)ならばWaitForSingleObject関数などを使ってol.hEventが発火するのを待ち、発火したらGetAddrInfoExOverlappedResult関数を使ってエラーを確認する。なおキャンセルはGetAddrInfoExCancel関数にcancelハンドルを渡すことで行える。
以上が名前解決をnon-blocking、非同期で行う方法である。
(もう疲れた...でもまだ名前解決しかしてない...)
Socket
WSASocket (windows)
さて次はソケットである。
まずはプラットフォーム共通でsocket関数を呼び出す....
と思いきや,WindowsでIOCPを利用するためには別途WSASocketW関数を使わなければならない。
SOCKET WSAAPI WSASocketW(
  [in] int                 af,
  [in] int                 type,
  [in] int                 protocol,
  [in] LPWSAPROTOCOL_INFOW lpProtocolInfo,
  [in] GROUP               g,
  [in] DWORD               dwFlags
);
第1から第3引数までは通常のソケット関数と同じだが、IOCPに対応させるためにはdwFlagsにWSA_FLAG_OVERLAPPEDフラグを設定する必要がある。(lpProtocolInfoはnullptr,gは0で良い)
そして、ソケットをnon-blockingにするためには、
ioctl関数を使う
auto sock=socket(AF_INET,SOCK_STREAM,0);
// u_longはsocket系ヘッダーで定義される数値型
u_long value=1; // trueの意 
ioctl(sock,FIONBIO,&value); // FIONBIO -> Flag IO Non-Blocking IOの略(多分)
しかし、ここもまたwindowsではioctlsocketという関数をioctl関数の代わりに使う。(引数などは同じ)
socket関数群はBSD socketがもとになっており共通部分も多いが、
細かいところを突っ込むと意外とプラットフォームごとに差異が多いものである。
connect
さて、次はclient側の流れを追ってみよう。
client側ではまずconnect関数を呼び出す。
int connect(int sockfd, const struct sockaddr *addr,
            socklen_t addrlen);
このとき出てくる、sockaddr構造体があるが、これはgetaddrinfo構造体(Name Resolvement参照)の.ai_addrメンバからから取得できる。
addrlenに当たるものは.ai_addrlenメンバから取得できる
この、sockaddr構造体というのは例えるならば抽象クラスのようなもので、基礎となるプロトコル(IPv4,IPv6など)に応じてsockaddr_inやsockaddr_in6などといった構造体をキャストして渡し、もとの構造体の大きさをaddrlenとして渡す。
getaddrinfoから取得できるものは予め適切な構造体が確保されてキャストされているためこのことについては考える必要はない。
通常、blockingモードでconnect関数を呼び出すと成功するまで返らないが、
non-blockingモードでは即時成功すれば0を返すがブロッキングすると-1を返し
errnoにEAGAINまたはEWOULDBLOCKを返す。
(プラットフォームによってEAGAINとEWOULDBLOCKの値が同じだったり違ったりする(らしい))
windows環境ではWSAGetLastError関数がWSAEWOULDBLOCKを返す。
ここからさきはerrnoと書いたらwindows環境ではWSAGetError関数を呼び出すという意味である。また、エラーコードにE~とかいたら、windows環境ではWSAE~となる。
select
そして、connect関数がblockingしたら、select、poll、epoll関数などを使って完了を待つ必要がある。ここでは、select関数を使った例を書く。
int select(int nfds, fd_set *readfds, fd_set *writefds,
           fd_set *exceptfds, struct timeval *timeout);
int  FD_ISSET(int fd, fd_set *set);
void FD_SET(int fd, fd_set *set);
void FD_ZERO(fd_set *set);
// sockはconnectを呼び出したソケット
timeval timeout{};
val.tv_sec = /*second*/;
val.tv_usec = /*micro second (u means `μ`)*/;
fd_set wset{}, excset{}; 
// 待機集合の初期化
FD_ZERO(&rset);
FD_ZERO(&excset);
// ソケットのセット
FD_SET(sock, &wset);
FD_SET(sock,&excset);
// 登録されたファイルディスクリプタのうち最も大きな番号のやつを第一引数に渡す
auto result = select(sock+1,nullptr,&wset,&excset);
if(result==-1){
    // select error
}
if(FD_ISSET(sock,&excset)) {
   // ソケットが異常closeしたなどエラーを検知
}
if(FD_ISET(sock,&wset)) {
   // 書き込み可能 = connect完了
}
このような感じである。
bind,listen
さて、次はサーバー側はどうだろうか。
サーバー側はbind、listen、accept関数を使う。
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
int listen(int sockfd, int backlog);
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
まず、bind関数だがこれはソケットにローカルのアドレスを割り当てる関数である。
まあ、語弊があるのは承知でいうとどのポート番号で接続を受けるかを設定する関数である。
というのも、大抵(すべて?)のケースではipアドレスはINADDR_ANYを指定してどこからでも受けるようにするからである。
これもgetaddrinfo関数のhost名にnullptrを渡し、hint.ai_flagsにAI_PASSIVEを指定して呼び出すとbind関数に適したsockaddr構造体を取得できる。
getaddrinfoって意外と万能
setsockopt
ここでいくつか設定しておくべきオプションというものを紹介する。
ソケットにはsetsockoptという関数でオプションを指定できる。
int setsockopt(int sockfd, int level, int optname,
               const void *optval, socklen_t optlen);
まずその1
u_long yes=1;
setsockopt(sock,SOL_SOCKET, SO_REUSEADDR,&yes,sizeof(yes));
これを指定することで、複数のソケットに同じアドレスをbindしてもエラーが出なくなる。
また、デバッグなどで何回もサーバーを立ち上げたり閉じたりしたときにbindでエラーが出なくなる。詳しくは下記などを参照。
2022/09/29追記
その後調べてみると、複数のソケットに同じアドレスをbindしてもエラーが出なくなるという挙動はwindowsのみで、linuxではTIME-WAITの状態でbindできるようにする(=これはwindowsのデフォルト)というオプションである。下記記事など参照。
そしてその2
u_long no=0;
setsockopt(sock,IPPROTO_IPV6, IPV6_V6ONLY, &no,sizeof(no));
これはIPv6のソケットでIPv4の接続をハンドルするかどうかを設定する。
0を設定することでIPv6のソケットでもIPv4の接続が受け入れられるようになる。
特にwindowsではデフォルトでは1(not 0)になっているので0に設定するべきである。
その後bindが成功したらlisten関数を呼び出して接続の受け入れを開始する。
backlog引数というのはTCPの状態がestablishedになってまだacceptされていないものを保持しておくキューのサイズである。一定以上の大きさを指定すると丸められる。
(ごめんなさい私はよく知りません)
accept,epoll (linux)
そしていよいよaccept関数である。
ついでなのでここでepollを使ってaccept待ちするコードを例示してみる。
#define EPOLL_CTL_ADD /*opaque*/
#define EPOLL_CTL_MOD /*opaque*/
int epoll_create(int size); // 非推奨
int epoll_create1(int flags);
typedef union epoll_data {
    void        *ptr;
    int          fd;
    uint32_t     u32;
    uint64_t     u64;
} epoll_data_t;
struct epoll_event {
    uint32_t     events;      /* epoll イベント */
    epoll_data_t data;        /* ユーザーデータ変数 */
};
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);  
int epoll_wait(int epfd, struct epoll_event *events,
               int maxevents, int timeout);
// sockはlisten済みソケット
// epollディスクリプターの作成
auto epoll_fd = epoll_create1_(EPOLL_CLOEXEC);
epoll_event event;
event.event = EPOLLIN; // 入力(=接続)を検知するフラグを設定
event.data.fd=sock;// ユーザーデータ(fdやその他構造体へのポインタなど)
// epollディスクリプタへの登録
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, sock, &event);
while(true) {
    epoll_event ev[64]{};
    auto result=epoll_wait(epoll_fd,ev,64,/*timeout*/);
    if(result==-1){
        // epoll error
    }
    for(auto i=0;i<result;i++) {
        auto fd = ev[i].data.fd;
        if(fd==sock){ 
            // クライアントの情報を格納する
            // sockaddr_storageが最大サイズのものだが、
            // sockaddr_in6を使えば大抵は大丈夫である
            sockaddr_storage st{}; 
            // inputとしてsizeof(st)
            // outputとして実際のsockaddr_*のサイズ
            // なお、socklen_tでおかないでintなどにすると
            // windowsとlinuxで型が違うので注意
            socklen_t len=sizeof(st);
            auto accepted = accept(sock,(sockaddr*)&st,&len);
            if(accepted==-1) {
                // skip?
            }
            // acceptedのソケットはnon-blockingになっていないため
            // ioctlなどでnon-blockingに設定する必要がある
            // 様々な処理...
         }
    }
}
まあ、selectのときと本質は変わらなくて、
- ソケットを待機集合に登録
 - 待機関数で待つ
 - 設定したフラグの条件がヒットしたら操作を行う
というような感じである。 
あとは、accept関数で受け取るソケットはnon-blockingではないので
別途ioctl関数などで設定をする必要がある。
(linux等ではaccept4という関数で初めからnon-blockingで取得できるオプションが有る(らしい))
TODO(on-keyday):AcceptEx(windows)を使ったコードを書く
send
さて、とりあえずconnectやacceptしたので
send関数にいこう...かと思いますがこれは調べればすぐ出るしそこまで
blockingすることもない(大量のデータを扱えばあるかもしれないがそうするくらいならもっと分割して送るべきである)ので割愛する。(まあ、対応したいならrecv系と同じような手段でできるので参考にされたし)
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
recv
そして次がrecv関数系でこちらはIOCP/epollとともによく使われると思う。
IOCPとepollの違い
世のライブラリで非同期ネットワークライブラリの基盤技術として
windowsではIO Completion Port(IOCP)、linuxではepoll APIが使われる。1
この2つの違いを一言でいうとすると、
完了まで面倒見るか、着信通知だけするか
といったところになると思う。(違ってたらごめんなさい)
まずIOCPはIO Completion Port の名が示す通り
IO処理が一段落完了してから通知が行われる。
具体的に言うと事前にバッファを用意しておき、
それを指定してIO処理を開始するとバッファを使ってIO処理の完了までを
OSがやってくれるというわけである。
一方のepollはpoll、selectの発展形と言うかたちで
あくまで「入力が来た」「出力が可能だ」といったことが通知されるだけで、実際にIO処理は行わずユーザーが行う必要がある。
しかし、(OSの内部動作はともかく)プログラム的には
ここらへんが違うくらいで
- IOの監視を設定
 - イベントを受け取ってから続行する
 
という基本的な流れ自体は共通するものがあり、上記の差異を理解すれば天と地ほど違うというようなことはないと思う。2 3 4
recv, epoll (linux)
さてでは、まずはepoll版から。
まず普通にrecvを行い、blockingした場合、epollに登録する。
また、今回はedge triggerバージョンを使う。
epollのlevel triggerとedge trigger
epollには2つの動作モード、levelトリガーとedgeトリガーというのがある。
levelトリガーはフラグで指定したイベントの条件が満たされる限り、
epoll_waitから同じeventが何度も提供される。
edgeトリガーはフラグで指定したイベントの条件が満たされたら一回だけ、
epoll_waitからeventが返される。
EPOLLIN(入力待ち)の例で説明すると、
- levelトリガー 受け取っていない入力がOSのバッファに残っている限りepoll_waitから返される。
 - edgeトリガーでは最初に入力を受け取ったときだけepoll_waitから返され、再び入力が受信されるまでたとえOSのバッファに残っていてもepoll_waitから返されない。
 
と言った違いがある。
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
// sockはconnect/accept済みのnon-blockingソケット。
// epoll_fd はepoll_create1で作成されたepollディスクリプター
// epoll_ctlで追加済
std::string buffer; 
while(true){
   char tmpbuf[1024];  
   auto res = recv(sock,tmpbuf,sizeof(tmpbuf),0);
   if(res==-1) {
      if(errno==EWOULDBLOCK||errno==EAGAIN) {
          constexpr auto edge_trigger=
                EPOLLIN |       // 入力待ち
                EPOLLET |      // EPOLLET = EPOLL Edge Triggerの略(多分)
                EPOLLONESHOT |  // 一回epoll_waitから返されたら再度有効化するまでepoll_waitから返されないようにするためのフラグ
                EPOLLEXCLUSIVE; // マルチプロセスサーバーのときに使うやつ(今回はいらない)(そしてよくわかってない)
          epoll_event event;
          event.event = edge_trigger;
          event.data.fd=sock;// ユーザーデータ
          epoll_ctl(epoll_fd,EPOLL_CTL_MOD,sock,&event); // 有効化
          // その後、別スレッドやここなどでepoll_waitを呼び待機
          // 発火したらevent.event&EPOLLINを調べてtrueなら再びrecv
      }
      // recv error
   }
   else if(res==0){
      break;
   } 
   buffer.append(tmpbuf,res);
}
サンプルコードが下手なことをお詫び申し上げます。
TODO(on-keyday):もっと良いものに治す
(そして、いよいよ疲れてきた。ただまだIOCPの説明が残っている....)
WSARecv, IO Completion Port (windows)
...続きましてIOCP版です。
まずIOCPの関数群
HANDLE WINAPI CreateIoCompletionPort(
  _In_     HANDLE    FileHandle,
  _In_opt_ HANDLE    ExistingCompletionPort,
  _In_     ULONG_PTR CompletionKey,
  _In_     DWORD     NumberOfConcurrentThreads
);
windowsのすごい(皮肉)ところはepoll_create1とepoll_ctlのEPOLL_CTL_ADD指定相当がこれ一つでできることである。
...と言うのは冗談でまずIOCPのハンドル作成は
auto iocp_handle = CreateIoCompletionPort(INVALID_HANDLE_VALUE, nullptr, 0, 0);
で作成し、ソケットの登録は
// sockはSOCKET型(≒std::uintptr_t)なのでキャストが必要
CreateIoCompletionPort((HANDLE)sock, iocp_handle, 0, 0);
で行う。(CompletionKeyは用途が謎すぎる。NumberOfConcurrentThreadsは0で良い)
でrecvだがこれもwindows独自関数のWSARecvという関数を使う。
typedef struct _WSABUF {
  ULONG len;
  CHAR  *buf;
} WSABUF, *LPWSABUF;
// WSAOVERLAPPEDはOVERLAPPED構造体に等しい
int WSAAPI WSARecv(
  [in]      SOCKET                             s,
  [in, out] LPWSABUF                           lpBuffers,
  [in]      DWORD                              dwBufferCount,
  [out]     LPDWORD                            lpNumberOfBytesRecvd,
  [in, out] LPDWORD                            lpFlags,
  [in]      LPWSAOVERLAPPED                    lpOverlapped,
  [in]      LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine
);
で使い方は次の通り。
ここでは、OVERLAPPED構造体の使い方に注目してほしい。
struct UserData {
    OVERLAPPED ol;// OVERLAPPED構造体
    SOCKET sock; // ユーザーデータ
}user;
user.sock=sock;
// sockはソケット
// WSARecvの引数用。引数はWSABUFの配列になっていて
// 複数のバッファを設定できる
// が、普通に使うだけなら1つで十分だと思う
WSABUF wsabuf;
char tmpbuf[1024];
wsabuf.buf=tmpbuf;
wsabuf.len=sizeof(tmpbuf);
// 受信バイト数
// だがIOCPを使うときは使わない。
// (設定しないとWSARecvがエラーになるので) TODO(on-keyday):要検証
DWORD transfered_byte;
// フラグ?(よくわかってない)
// ゼロをセットしておけば良い
DWORD flags=0;
auto result = WSARecv(sock,&wsabuf,1/*wsabufの数*/,&transfered_byte,&flags,&user.ol,nullptr);
if(result!=0){ // 0ならば既に受信完了で完了通知が配送されている
    if(WSAGetLastError()!=WSA_IO_PENDING){
        // error
    }
    // WSA_IO_PENDINGなら受信待機が開始され完了したら完了通知が配送される
}
そして待機部分ではGetQueuedCompletionStatusや~Ex関数を使う。
サンプルはExの方で書く。
BOOL GetQueuedCompletionStatus(
  [in]  HANDLE       CompletionPort,
        LPDWORD      lpNumberOfBytesTransferred,
  [out] PULONG_PTR   lpCompletionKey,
  [out] LPOVERLAPPED *lpOverlapped,
  [in]  DWORD        dwMilliseconds
);
typedef struct _OVERLAPPED_ENTRY {
  ULONG_PTR    lpCompletionKey;
  LPOVERLAPPED lpOverlapped;
  ULONG_PTR    Internal;
  DWORD        dwNumberOfBytesTransferred;
} OVERLAPPED_ENTRY, *LPOVERLAPPED_ENTRY;
BOOL WINAPI GetQueuedCompletionStatusEx(
  _In_  HANDLE             CompletionPort,
  _Out_ LPOVERLAPPED_ENTRY lpCompletionPortEntries,
  _In_  ULONG              ulCount,
  _Out_ PULONG             ulNumEntriesRemoved,
  _In_  DWORD              dwMilliseconds,
  _In_  BOOL               fAlertable
);
// GetQueuedCompletionStatusから返される値のセット
OVERLAPPED_ENTRY entries[64];
auto res = GetQueuedCompletionStatusEx(iocp_handle, entries, 64, &rem, time, false);
if (!res) {
    // 完了通知が0
}
for(auto i=0;i<res;i++) {
     auto data=reinterpret_cast<UserData*>(entries[i].lpOverlapped);
     auto sock=data->sock;
     // dataやsockの中身を利用してかく
     // 実用的にはWSARecvに渡したバッファなどもUserDataに入れる必要がある
}
このIOCPを使うとき特にみそとなるのがOVERLAPPED構造体の部分である。
まず、OVERLAPPED構造体を先頭に仕込んだユーザー定義構造体をつくり、そこにデータを突っ込んでWSARecvにOVERLAPPED構造体として渡す。
そして、GetQueuedCompletionStatusで受け取ったOVERLAPPED構造体を再びユーザー定義型にキャストするというふうにすることでデータをやり取りできる。
これによってrecv部分と完了を受け取る場所を分けてかけるようになる。
個人的にはこれはちょっと欠陥仕様なんじゃないかなと思う。
以上がrecvのコード群である。
(なんでこんな長いの....)
TODO(on-keyday):他の知見も書く
SSL/TLS
も、もうむり...zzz....
TODO(on-keyday): SSL/TLSライブラリについての知見を書く
おわりに
文章書くの疲れた....
なれないことはするべきじゃないな...
とまあ文章を書くのになれていないので読みづらい箇所があったと思われること改めてお詫び申し上げます。
そしてさして長くもないけど長い長いとぶつぶつ書いてあることをお詫び申し上げます。(書くのは数時間読むのは一瞬.......)
特にepollとIOCPについてもっとまとまっている記事があればよかったのですがいかんせんもう十年以上前の技術(らしい、10年前はまだプログラミングやってなかったからわからない)でさらにはライブラリ実装の奥深くに隠蔽されていて直に使うことはほぼないと思われるため調べるのはそこそこ大変でした。(いやまあmanとかmsdnがあるから人によっては簡単なんだろうけど)。
まあ、自分用のメモとして使えればいいかなと思います。
拙い文章と技術力ですが役に立てば幸いです。(立たなかったらごめんなさい)
宣伝(?)コーナー
これらの知見を使ってライブラリを作りました。
まあ、実用にはとても耐えられないと思いますが
実装の参考にはなるかもしれません
(epoll/IOCPはそれぞれlib/dnet/epoll.cpp,lib/dnet/winsock.cppで実装)
https://github.com/on-keyday/utils/tree/main/src/include/dnet
https://github.com/on-keyday/utils/tree/main/src/lib/dnet
- 
あとはBSDのkqueueといったのなどもあるがあいにくBSDにふれる機会がないのでなんともいえない ↩
 - 
まあ、もっと高度に最適化したいというのであればいろいろ差異があるとは思うが... ↩
 - 
epollの中身の説明についてはhttps://moriyoshi.hatenablog.com/entry/20090519/1242764245 など(13年前...)があるのでなんで早いかとかといったことは別で調べてください。 ↩
 - 
具体的な差分吸収の方法は既存のライブラリを参照。あとはC/C++言語ではないがGo言語のruntimeのコードなどもおすすめ。 ↩