1. はじめに
仕事でソケット通信を行うサーバーの性能テストを行うことになって、Cでプログラムを書くことになりました。
必要なのはクライアントですが、動作確認のため、サーバーも作成しています。
ひとまずWindows用で、できればLinuxでもそのうち動かせるようにという希望のため、機種依存部は全てラップしました。
昔ながらの同期のソケット通信が希望されていましたが、性能テストの対象がかなり性能の高いサーバーで、クライアントPCを複数台使っても性能が足りないかもしれないので、Windowsのさまざまな非同期処理を加えました。これらはWindows専用で作成しています。
非同期関連のソケット技術は主にサーバー向けとのことで、検索してもサーバの例だらけで、クライアントで使用している例はないため、まずサーバを作って理解しないとクライアントに利用できないため、サーバーも作成しています。
仕事向けにプログラムを記述したため、ソースをそのまま公開はできず、かなり大きくなったため公開用に1から書き直すのもつらいため、使用APIと、参考資料の場所、処理の概要・注意点等をまとめています。
時間が取れたらスレッドプールI/OとRIOのサンプルを作成して記事を書きます。
TCP IPv4を使用しています。IPv6は未確認です。
2. JMeter
Cのプログラムを書く前に、最初にJMeterでTCP Sampler(テキスト:TCPClientImpl、バイナリ:BinaryTCPClientImpl)にGroovyを使って値をセットしました。(JavaScriptやBeanShellだとうまく動かなかったので)
しかし、今回のサーバーはかなり高性能で、JMeterだとクライアントの性能が足りないので、使用するのをやめました。
3. C#
C#でも単純な同期の送受信を記述しましたが、これも使用せず。
4. C言語+Win32API
5通りの方法を考えて、現在、全ての構成を実装済みで、説明を記述中です。
ノンブロッキングI/Oは使用していません。Winsock非同期はWSAOVERLAPPED構造体を使用しています。
4.1. サーバー
- Winsock同期
- Winsock非同期+IOCP
- Winsock非同期+スレッドプールI/O
- Winsock+RIO+IOCP
- Winsock+RIO+スレッドプールI/O+スレッドプールWait(待機オブジェクト)
4.2. クライアント
- Winsock同期(主にLinuxと同じ機能だけ使用)
- Winsock非同期+IOCP
- Winsock非同期+スレッドプールTimer+スレッドプールI/O
- Winsock+RIO+IOCP
- Winsock+RIO+スレッドプールTimer+スレッドプールWait(待機オブジェクト)
サーバーで使用した機能に加えて、
1.,2.,4.は、送受信完了後に「送受信間隔-送受信にかかった時間」だけスリープ
3.,5.は、スレッドプールTimerを使用して送受信間隔毎に送信
を使用しています。
4.3. 各技術の対応Windowsバージョン
Winsockの使用バージョンは2.2です。かなり昔のWindowsから使用できます。
IOCP(I/O完了ポート)はWindows NT3.5以降で使用可能らしいです。
スレッドプールはWindows Vista、Windows Server 2008以降で使用可能な新しいスレッドプールAPIです。Windows XPにあった古いスレッドプールAPIとは異なります。
RIO(Registered I/O)は、Windows 8、Windows Server 2012以降で使用可能です。
最初いきなりスレッドプールI/Oを使用しようとしましたが、IOCPを知らないと使いこなせなかったため、IOCPを実現してからスレッドプールI/Oを完成させました。
4.4. 各技術の特徴
4.1. Winsock
UNIX系のソケットと同等の機能がベースです。
4.2. IOCP(I/O完了ポート)
送信、受信を非同期で実行し、送信、受信の完了を待ちます。
自分でスレッドを複数作成し、IOCPがCPUコア数に合わせたスレッドプールとして管理し、完了待ちの間に別の非同期作業を行うように制御されます。
スレッド切り替えのコンテキストスイッチによる無駄が減ります。
また、受信完了はカーネルモードで行われ、ユーザーモードとカーネルモードのコンテキストスイッチによる無駄が減ります。
4.3. スレッドプールI/O
新しいスレッドプールAPIにより、CPUコア数に合わせたスレッドプールが作成されます。
IOCPの待機処理が隠蔽され、I/O完了時にコールバック関数が呼ばれます。
4.4. RIO(Registered I/O)
事前にカーネルにバッファを登録することにより、毎回バッファを渡すのに比べてAPIのエラーチェックの削減、カーネルによるページロック等が行われ、IOCP単独よりもユーザーモードとカーネルモードのコンテキストスイッチによる無駄が減ります。
送受信完了は下記のいずれかで認識します。
- ポーリング
- IOCP
- イベント(スレッドプールWait(待機オブジェクト)等と併用)
Microsoftによると、主にサーバー向けの技術とされています。
今回、性能テストクライアントを作るので、クライアントにも使用します。
4.5. 送受信電文
送受信電文は、先頭2バイトがビッグエンディアンでその後の電文長です。
5. サーバー
全サーバー共通処理
- SetConsoleCtrlHandler() ※Ctrl+C、コンソールウィンドウクローズ処理を行うHandlerRoutineの追加
- listen()にはSOMAXCONNを指定(listen()からaccept()の間が最大数)
5.1. Winsock TCPサーバー
下記のAPIを使用しています。使用順に記述しています。WSAから始まるものはUNIX系にはなくWinsock独自のAPIです。
スレッドはコンフィグで指定した数だけ起動時に作成しています。各スレッドでaccept()を1回行っていて、それ以上はaccept()しないため、コンフィグ値は最大クライアント数よりも多くする必要があります。accept()する度にスレッドを作成するのであれば、この制限はありません。
5.1.1. main()関数
- CreateEvent() ※終了イベント
- SetConsoleCtrlHandler()
- WSAStartup() ※Linuxにはなし
- WSASocketW() ※socket()を使用するとWSA_FLAG_OVERLAPPEDが有効になるらしいので、WSASocket()でWSA_FLAG_OVERLAPPEDなしにしています。UNICODE文字セットでなくマルチバイト文字セットのプロジェクトなのですが、WSASocketA()を使用するとコンパイル時に警告が出力されるため、ここだけWSASocketW()を使用しています。
- htons()
- bind()
- listen()
- _beginthreadex() ※コンフィグで指定した数だけスレッドを作成する
- WaitForMultipleObjects() ※全スレッド終了待ち
- WSACleanup()
5.1.2. HandlerRoutine
- SetEvent() ※終了イベント。Ctrl+C押下、コンソール画面クローズ時
5.1.3. スレッド関数
- WSACreateEvent()
- WSAEventSelect() ※これを使わずにいきなりaccept()を呼んでもよいのですが、それだとaccept()が止まらないため、Ctrl+C押下時に綺麗にサーバーを落とすため、WSACreateEvent()でイベントオブジェクトを作成し、WSAEventSelect()でFD_ACCEPTとFD_RECVを指定して、Ctrl+C押下時にサーバー終了用のイベントオブジェクトをSetEvent()し、WSAWaitForMultipleEvents()にその2つを渡して待ちます。WSAWaitForMultipleEvents()にはWSACreateEvent()のハンドルだけでなく通常のハンドルも渡すことができます。
- WSAWaitForMultipleEvents()
- WSAResetEvent()
- accept()
recv() ※最初に受信バイト数に2バイトを指定し、2バイト受信するまで繰り返す。2バイト読み取れたらそのビッグエンディアンの値を電文長とし、その長さを受信するまで繰り返す。(2+電文長)バイトよりも長い受信バッファサイズを指定すると、ゴミデータが受信される。send() ※送信バイト数に(2+電文長)バイトを指定し、(2+電文長)バイト送信するまで繰り返す。- WSARecv() ※受信バイト数に受信バッファサイズを指定し、2バイト読み取れたらそのビッグエンディアンの値を電文長とし、その長さを受信するまで繰り返す。
- WSASend() ※送信バイト数に(2+電文長)バイトを指定し、(2+電文長)バイト送信するまで繰り返す。
- shutdown()
- closesocket() ※Linuxだとclose()
WSAEventSelect()を使用すると、何故かrecv()が非ブロッキングソケットでないのにWSAEWOULDBLOCKを返すことがあるので、その場合にはrecv()を再実行しています。
全てのスレッドでaccept()の代わりのWSAEventSelect()を実行して接続待ちをしています。
5.1.4. 内部関数AcceptClient()
5.2. IOCP TCPサーバー
スレッドはコンフィグで指定した数だけ起動時に作成しています。accept()でなくlpfnAcceptEx()を使用しているため、スレッド数はクライアント数と合わせる必要がありません。スレッド数は論理コア数程度がお勧めです。
5.2.1. main()関数
- SetConsoleCtrlHandler()
- CreateIoCompletionPort() ※初期化
- WSAStartup()
- WSASocketW() ※listenソケット。WSA_FLAG_OVERLAPPEDを指定
- CreateIoCompletionPort() ※listen socketを登録
- WSAIoctl() ※SIO_GET_EXTENSION_FUNCTION_POINTER、WSAID_ACCEPTEX
- WSASocketW() ※acceptソケット。WSA_FLAG_OVERLAPPEDを指定
- htons()
- bind()
- listen()
- _beginthreadex() ※コンフィグで指定した数だけスレッドを作成する
- WaitForMultipleObjects() ※全スレッド終了待ち
- WSACleanup()
5.2.2. HandlerRoutine
- PostQueuedCompletionStatus() ※Ctrl+C押下、コンソール画面クローズ時
5.2.3. スレッド関数
- WSASocketW() ※acceptソケット。WSA_FLAG_OVERLAPPEDを指定
- lpfnAcceptEx() ※AcceptEx()を直接呼ぶのではなく、WSAIoctl()で取得したポインタを使用する
- CreateIoCompletionPort() ※acceptソケット分
- GetQueuedCompletionStatus()
- WSARecv() ※WSA_IO_PENDINGは正常扱い。次のGetQueuedCompletionStatus()で目的の長さに足りなかったら、WSARecv()を再度実行する。読み取りサイズは受信バッファサイズを指定する。
- WSASend() ※WSA_IO_PENDINGは正常扱い。次のGetQueuedCompletionStatus()で目的の長さに足りなかったら、WSASend()を再度実行する。
- PostQueuedCompletionStatus() ※異常系で使用
lpfnAcceptEx()でacceptが完了した数だけWSAOVERLAPPED構造体を拡張した(structの最初にWSAOVERLAPPEDを持つ)ソケットコンテキストを持ちます。
全てのスレッドでlpfnAcceptEx()を実行して接続待ちしています。1スレッドでlpfnAcceptEx()で受信待ちし、クライアント接続時に新たに1本lpfnAcceptEx()を行うこともできますが、予め全てのスレッドで接続待ちを行うようにしました。
lpfnAcceptEx()は受信待ちに加えて、受信したデータを返却します。
5.2.4. 内部関数AcceptClient()
参考:
AcceptEx 関数 (mswsock.h) - Win32 apps | Microsoft Learn
https://learn.microsoft.com/ja-jp/windows/win32/api/mswsock/nf-mswsock-acceptex
Download Microsoft Windows SDK for Windows 7 and .NET Framework 4 from Official Microsoft Download Center
https://www.microsoft.com/en-us/download/details.aspx?id=8279
※IOCPサーバのサンプルコードがあります。これよりも新しいSDKにはサンプルコードが入っていません。このコードだとWSASend()とWSARecv()が一気に送受信成功する前提になっていて、通信が細切れになった場合の対策がないため、結果が足りない場合に再度WSASend()やWSARecv()を呼ぶのが安全だと思います。
5.3. スレッドプールTCPサーバー
新しいスレッドプールAPIは、Microsoftにサンプルコードがありますが、スレッドプールI/Oだけはリンクが切れていて参考になるソースがMicrosoftにありません。
スレッド数は新しいスレッドプールAPIの既定の数です。正確な仕様を見つけられていませんが、論理コア数程度のはずです。accept()でなくlpfnAcceptEx()を使用しているため、スレッド数はクライアント数と合わせる必要がありません。
5.3.1. main()関数
- InitializeThreadpoolEnvironment() ※SetThreadpoolCallbackCleanupGroup()に渡す必要があるため
- CreateThreadpoolCleanupGroup()
- WSASocketW() ※listenソケット。WSA_FLAG_OVERLAPPEDを指定
- CreateThreadpoolIo() ※listenソケットの分
- htons()
- listen()
- HeapAlloc()、GetProcessHeap() ※WSAOVERLAPPED構造体を拡張したソケットコンテキストを確保
- StartThreadpoolIo() ※lpfnAcceptEx()の直前で実行する。listenソケットの分。
- lpfnAcceptEx()
- CancelThreadpoolIo() ※異常系で使用する
- CreateThreadpoolIo() ※acceptソケットの分
- SetThreadpoolCallbackCleanupGroup()
関数化してI/Oコールバック関数(IoCompletionCallback)のAcceptEx()処理と共通化しています。
- CloseThreadpoolCleanupGroupMembers()
TODO:CreateThreadpoolIo()を呼び出す時にはPTP_CALLBACK_ENVIRONを渡して、最後のCloseThreadpoolCleanupGroupMembers()で全て待つようにする。
5.3.2. HandlerRoutine
5.3.3. I/O完了コールバック関数(IoCompletionCallback)
lpfnAcceptEx()、WSAReceive()、WSASend()の完了ルーチン。
- HeapAlloc()、GetProcessHeap() ※WSAOVERLAPPED構造体を拡張したソケットコンテキストを確保
- StartThreadpoolIo() ※AcceptEx()の直前で実行する
- lpfnAcceptEx()
- CancelThreadpoolIo() ※異常系で使用する
- CreateThreadpoolIo()
- SetThreadpoolCallbackCleanupGroup()
- StartThreadpoolIo() ※WSARecv()の直前で使用する
- WSARecv()
- CancelThreadpoolIo() ※異常系で使用する
- StartThreadpoolIo() ※WSASend()の直前で使用する
- WSASend()
- CancelThreadpoolIo() ※異常系で使用する
main()関数でlpfnAcceptEx()を行い、クライアント接続時に新たに1本lpfnAcceptEx()を行っています。
5.3.4. 内部関数AcceptClient()
参考:
スレッド プール API - Win32 apps | Microsoft Learn
https://learn.microsoft.com/ja-jp/windows/win32/procthread/thread-pool-api
スレッド プール関数の使用 - Win32 apps | Microsoft Learn
https://learn.microsoft.com/ja-jp/windows/win32/procthread/using-the-thread-pool-functions
TimerCallback callback function (Windows) | Microsoft Learn
https://learn.microsoft.com/ja-jp/previous-versions/windows/desktop/legacy/ms686790(v=vs.85)
IoCompletionCallback callback function (Windows) | Microsoft Learn
https://learn.microsoft.com/ja-jp/previous-versions/windows/desktop/legacy/ms684124(v=vs.85)
5.4. RIO+IOCP TCPサーバー
※プログラムは出来上がって、文章を修正中
RIOを検索するとわずかに出てくるサンプルはUDPだらけで、TCPがほとんどありませんでした。
APIの引数に問題があると、その後呼び出したAPIで分かりにくいエラー番号が出て、調査しづらいです。
スレッドはコンフィグで指定した数だけ起動時に作成しています。accept()でなくlpfnAcceptEx()を使用しているため、スレッド数はクライアント数と合わせる必要がありません。スレッド数は論理コア数程度がお勧めです。
5.4.1. main()関数
- SetConsoleCtrlHandler()
- CreateIoCompletionPort() ※初期化
- InitializeCriticalSectionAndSpinCount() ※CQ(完了キュー)
- WSAStartup()
- WSASocketW() ※listenソケット。WSA_FLAG_REGISTERED_IOを指定。WSA_FLAG_OVERLAPPEDは不要。
- CreateIoCompletionPort() ※listen socketを登録
- WSAIoctl() ※SIO_GET_MULTIPLE_EXTENSION_FUNCTION_POINTER、WSAID_MULTIPLE_RIO
- RIOCreateCompletionQueue() ※RIO_IOCP_COMPLETION
- htons()
- bind()
- listen()
- _beginthreadex() ※コンフィグで指定した数だけスレッドを作成する
- RIONotify()
- HeapAlloc() ※ソケットコンテキスト
- InitializeCriticalSectionAndSpinCount() ※RQ(リクエストキュー)
- WSASocketW() ※acceptソケット。WSA_FLAG_REGISTERED_IOを指定
- lpfnAcceptEx()
- RIOCreateRequestQueue() ※acceptソケットに対して行う
- GetSystemInfo()
- VirtualAllocEx()
- RIORegisterBuffer() ※受信バッファ
HeapAlloc() ※リクエストコンテキスト×受信バッファ数- RIOReceive() ※受信バッファの数だけ繰り返す。RIOReceiveEx()だと何故かエラーになった。
- GetSystemInfo()
- VirtualAllocEx()
- RIORegisterBuffer() ※送信バッファ
HeapAlloc() ※リクエストコンテキスト×送信バッファ数
- WaitForMultipleObjects() ※全スレッド終了待ち
- WSACleanup()
CreateIoCompletionPort()でのacceptソケットの登録は不要です。
acceptソケットに対して、完了キューとリクエストキューを1個ずつ作成します。これは、リクエストキュー作成時にはソケットと完了キューを渡しますが、完了キュー1個に対してリクエストキュー作成が1回しかできないためです。MicrosoftのRIOのページに「Completed I/O operations are inserted into a completion queue, and many different sockets can be associated with the same completion queue.」(完了した I/O 操作は完了キューに挿入され、多くの異なるソケットを同じ完了キューに関連付けることができます。)とあるのが分からない。 →lpfnAcceptEx()でaccept完了後にリクエストキューを作成したら大丈夫でした
完了キューを1個、acceptソケットに対してリクエストキューを1個ずつ作成します。
受信バッファ数と送信バッファ数は、TCPでaccept()したクライアントが1電文送信、サーバーが1電文受信、サーバーが1電文送信、クライアントが1電文受信を繰り返すのであれば、受信バッファ、送信バッファともに1個で問題ありません。UDPの場合は複数が良さそう。
WSASocketW()にWSA_FLAG_REGISTERED_IOを指定すると、WSAEventSelect()が必ずエラーになります。このため、acceptを中断するには、accept()を使わず、lpfnAcceptEx()を使用する必要があります。listenソケットにWSA_FLAG_OVERLAPPEDだけを指定すればWSAEventSelect()が使えそうですが、lpfnAcceptEx()が動くならそのほうが良いので、試していません。
5.4.2. HandlerRoutine
- PostQueuedCompletionStatus() ※Ctrl+C押下、コンソール画面クローズ時
5.4.3. スレッド関数
- GetQueuedCompletionStatus()
- EnterCriticalSection() ※CQ(完了キュー)
- RIODequeueCompletion()
- LeaveCriticalSection() ※CQ(完了キュー)
- RIONotify()
- RIODequeueCompletion()の結果が0ならGetQueuedCompletionStatus()まで戻る。
- EnterCriticalSection() ※RQ(リクエストキュー)
- RIOReceive() または RIOSend()
- LeaveCriticalSection() ※RQ(リクエストキュー)
- EnterCriticalSection()(CQ)まで戻る。但し、RIONotify()はやり直さない。(RIODequeueCompletion()後、GetQueuedCompletionStatus()の前にRIONotify()をできるだけ早く1回だけ行う)
- PostQueuedCompletionStatus() ※異常系で使用
RIORESULTのBytesTransferredが0の場合、StatusにWSAGetLastError()の値が入っている模様。
5.4.4. 内部関数AcceptClient()
参考:
Windows 8と2012 ServerのRegistered I/Oについての記事 - 平々毎々(アーカイブ)
https://matarillo.hatenadiary.jp/entry/20150920/p1
Windows Registered I/O (RIO) vs IOCP
https://www.slideshare.net/sm9kr/windows-registered-io-rio
Windows Registered I/O (RIO) Sample Code (Echo Server) · GitHub
https://gist.github.com/ujentus/5997058
※UDP。RIO系のAPIの異常系でGetLastError()を使っていますが、WSAGetLastError()を使うのが正しいです。厳密には送信バッファの未使用管理の追加が必要そう。TCPでクライアントと1電文ずつ送受信し合う場合には受信バッファ、送信バッファともに1個で大丈夫そうなので、未使用管理が不要。
Windows Registered I/O (RIO) Sample Code (Echo Server) · GitHub
https://gist.github.com/albertcheng/14af020390c1fe7679bd
※UDP。上のからforkされているけれども、変わらない。
Windows 8 Registered I/O Networking Extensions - AsynchronousEvents
http://www.serverframework.com/asynchronousevents/2011/10/windows-8-registered-io-networking-extensions.html
Windows 8 Registered I/O and I/O Completion Ports - AsynchronousEvents
http://www.serverframework.com/asynchronousevents/2011/10/windows-8-registered-io-and-io-completion-ports.html
corefxlab/RioTcpServer.cs at master · AlexGhiondea/corefxlab · GitHub
https://github.com/AlexGhiondea/corefxlab/blob/master/src/System.IO.Pipelines.Networking.Windows.RIO/RioTcpServer.cs
Microsoftの.NETチームが作成した実験コードのcorefxlab(RIO関連は削除済み)で、RIO関連が削除される前のfork版。C#。
5.5. RIOスレッドプールTCPサーバー
※実装済み。記述中。
RIOと新しいスレッドプールAPIを組み合わせた例はなかったので、新たに作りました。
APIの引数に問題があると、その後呼び出したAPIで分かりにくいエラー番号が出るのに加え、IoCompletionCallbackが呼ばれなかったりしたので、かなり厄介でした。
スレッド数は新しいスレッドプールAPIの既定の数です。正確な仕様を見つけられていませんが、論理コア数程度のはずです。accept()でなくlpfnAcceptEx()を使用しているため、スレッド数はクライアント数と合わせる必要がありません。
5.5.1. main()関数
- InitializeThreadpoolEnvironment() ※SetThreadpoolCallbackCleanupGroup()に渡す必要があるため
- CreateThreadpoolCleanupGroup()
- WSASocketW() ※listenソケット。WSA_FLAG_REGISTERED_IOを指定。WSA_FLAG_OVERLAPPEDでもWSA_FLAG_REGISTERED_IO|WSA_FLAG_OVERLAPPEDを指定しても変わらない。
- CreateThreadpoolIo() ※listenソケットの分
- htons()
- listen()
- 内部関数AcceptClient()
- CloseThreadpoolCleanupGroupMembers()
main()関数でlpfnAcceptEx()を行い、クライアント接続時に新たに1本lpfnAcceptEx()を行っています。
5.5.2. HandlerRoutine
5.5.3. I/O完了コールバック関数(IoCompletionCallback)
lpfnAcceptEx()の完了ルーチン。
- 内部関数AcceptClient()
- RIOCreateRequestQueue() ※acceptソケットに対して行う
- GetSystemInfo()
- VirtualAllocEx()
- RIORegisterBuffer() ※受信バッファ
- RIOReceive() ※受信バッファの数だけ繰り返す。RIOReceiveEx()だと何故かエラーになった。
- GetSystemInfo()
- VirtualAllocEx()
- RIORegisterBuffer() ※送信バッファ
- 内部関数ReceiveSend()
lpfnAcceptEx()が完了してからリクエストキューを作成しないとエラーになる。
5.5.4. 待機コールバック関数(WaitCallback)
RIOReceive()、RIOSend()の完了ルーチン。
- 内部関数ReceiveSend()
5.5.5. 内部関数AcceptClient()
- HeapAlloc()、GetProcessHeap() ※WSAOVERLAPPED構造体を拡張したソケットコンテキストを確保
- RIOCreateCompletionQueue() ※RIO_EVENT_COMPLETION
- WSASocketW() ※acceptソケット。WSA_FLAG_REGISTERED_IOを指定
- StartThreadpoolIo() ※lpfnAcceptEx()の直前で実行する。listenソケットの分。
- lpfnAcceptEx() ※受信サイズを0にすること。0にしないとおかしくなる。
- CancelThreadpoolIo() ※異常系で使用する
- CreateThreadpoolIo()
- SetThreadpoolCallbackCleanupGroup()
5.5.6. 内部関数ReceiveSend()
6. クライアント
全クライアント共通処理
- SetConsoleCtrlHandler() ※Ctrl+C、コンソールウィンドウクローズ処理追加
6.1. Winsock TCPクライアント
送信する電文の種類の数のスレッドを作成し、各スレッドが一定間隔毎に送信します。一定間隔毎の送信を行うために、(受信時刻-送信間隔)が送信間隔未満の場合には、(送信間隔-(受信時刻-送信間隔))だけスリープしてから次の送信を行います。
6.1.1. main()関数
- WSAStartup()
- WSASocketW()
- htons()
- _beginthreadex() ※コンフィグで指定した数だけスレッドを作成する
- WaitForMultipleObjects() ※全スレッド終了待ち
- WSACleanup()
6.1.2. スレッド関数
- send()
- recv()
- Sleep() ※「送受信間隔-(send()からrecv()にかかった時間)」だけスリープする
- closesocket()
6.2. IOCP TCPクライアント
送信する電文の種類の数のスレッドを作成し、各スレッドが一定間隔毎に送信します。一定間隔毎の送信を行うために、(受信時刻-送信間隔)が送信間隔未満の場合には、(送信間隔-(受信時刻-送信間隔))だけスリープしてから次の送信を行います。
IOCPの場合、論理コア数程度のスレッド数が理想ですが、電文の種類が多いとスレッド数が増加してスレッド切り替えが増えるため、効率が悪いです。
6.2.1. main()関数
- WSAStartup()
- CreateIoCompletionPort() ※初期化
- htons()
- _beginthreadex() ※コンフィグで指定した数だけスレッドを作成する
- WSASend()
- WaitForMultipleObjects() ※全スレッド終了待ち
- WSACleanup()
- PostQueuedCompletionStatus() ※Ctrl+Cのハンドラで使用
6.2.2. スレッド関数
- WSASocketW() ※送受信ソケット。WSA_FLAG_OVERLAPPEDを指定
- CreateIoCompletionPort() ※送受信ソケット分
- GetQueuedCompletionStatus()
- WSASend() ※WSA_IO_PENDINGは正常扱い。次のGetQueuedCompletionStatus()で目的の長さに足りなかったら、WSASend()を再度実行する。
- WSARecv() ※WSA_IO_PENDINGは正常扱い。次のGetQueuedCompletionStatus()で目的の長さに足りなかったら、WSARecv()を再度実行する。読み取りサイズは受信バッファサイズを指定する。
- Sleep() ※「送受信間隔-(send()からrecv()にかかった時間)」だけスリープする
- PostQueuedCompletionStatus() ※異常系で使用
6.3. スレッドプールTCPクライアント
送信する電文の種類の数のTimerCallbackを登録し、一定間隔毎に呼ばれたTimerCallbackが送信します。
スレッド数は新しいスレッドプールAPIの既定の数です。正確な仕様を見つけられていませんが、論理コア数程度のはずです。
6.3.1. main()関数
- InitializeThreadpoolEnvironment()
- CreateThreadpoolCleanupGroup()
- htons()
- SetThreadpoolTimer() ※送受信間隔指定
- CreateThreadpoolIo()
- SetThreadpoolCallbackCleanupGroup()
- CloseThreadpoolCleanupGroupMembers()
6.3.2. Timerコールバック関数(TimerCallback)
- CreateThreadpoolIo()
- SetThreadpoolCallbackCleanupGroup()
- SetThreadpoolTimer() ※異常系でTimerを削除するために使用する
- WSASend()
6.3.3. I/O完了コールバック関数(IoCompletionCallback)
WSASend()、WSAReceive()の完了ルーチン。
- StartThreadpoolIo() ※WSARecv()の直前で使用する
- WSARecv()
- CancelThreadpoolIo() ※異常系で使用する
- StartThreadpoolIo() ※WSASend()の直前で使用する
- WSASend()
- CancelThreadpoolIo() ※異常系で使用する
- SetThreadpoolTimer() ※送受信終了時と異常系でTimerを削除するために使用する
6.4. RIO+IOCP TCPクライアント
送信する電文の種類の数のスレッドを作成し、各スレッドが一定間隔毎に送信します。一定間隔毎の送信を行うために、(受信時刻-送信間隔)が送信間隔未満の場合には、(送信間隔-(受信時刻-送信間隔))だけスリープしてから次の送信を行います。
IOCPの場合、論理コア数程度のスレッド数が理想ですが、電文の種類が多いとスレッド数が増加してスレッド切り替えが増えるため、効率が悪いです。
6.4.1. main()関数
6.4.2. スレッド関数
6.5. RIOスレッドプールTCPクライアント
送信する電文の種類の数のTimerCallbackを登録し、一定間隔毎に呼ばれたTimerCallbackが送信します。
スレッド数は新しいスレッドプールAPIの既定の数です。正確な仕様を見つけられていませんが、論理コア数程度のはずです。
6.5.1. main()関数
6.5.2. Timerコールバック関数(TimerCallback)
6.5.3. 待機コールバック関数(WaitCallback)
7. ログ出力
サーバとクライアントの動作モードと別に、ログ出力の動作モードも指定できるようにしています。
7.1. 同期
1行毎にオープン、書き込み、クローズしています。オープンからクローズまでの間、Windowsではクリティカルセクションで排他を行います。Linuxではpthread_mutexの予定。
- fopen_s()
- fwrite()
- fclose()
7.2. 非同期
OVERLAPPED構造体を使用してログファイルに非同期書き込みを行います。
OVERLAPPED構造体を利用したコード例には、WriteFile()でERROR_IO_PENDING発生時にGetOverlappedResult()による同期待ちをよく見かけますが、それだと非同期にならないため、GetOverlappedResult()を呼ばないようにします。
WriteFile()は、OVERLAPPED構造体のOffsetとOffsetHighに0xFFFFFFFFを指定すると、ファイル末尾への追記となります。但し、この場合の注意点として、OVERLAPPED構造体はWriteFile()完了まで生存している必要があるのに加え、WriteFile()毎に別のものを渡す必要があります。
このため、WriteFile()の直前で、OVERLAPPED構造体のログ管理用拡張版構造体のメモリを確保し、WriteFile()完了時にそのメモリを解放します。OVERLAPPED構造体内のハンドルにイベントオブジェクトの指定は不要です。OffsetとOffsetHighを設定する前に、全領域をZeroMemory()で初期化します。
WriteFile()完了は、IOCPまたはスレッドプールI/Oで認識します。
HeapAlloc()等でメモリ確保・完了を行うと遅いため、リストで自己管理します。リストの排他にはInitializeCriticalSection()でなく、マルチコア用のInitializeCriticalSectionAndSpinCount()を使用します。
ログファイルのローテートの排他用に、SRW(スリムreader writerロック)を使用します。Windows Vista、Winodows Server 2008以降で使用可能です。WriteFile()時にreaderロック、ログローテート時にwriterロックを行います。
ログローテートは行数によるローテートで、ローテート行数までWriteFile()を呼び出したら、次のファイルをCreateFile()します。ローテート前のファイルに対するI/O完了が揃ってから、CloseHandle()を行います。
- InitializeSRWLock() ※SRW初期化
- AcquireSRWLockShared() ※共有ロック(readerロック)
- ReleaseSRWLockShared() ※共有アンロック(readerアンロック)
- AcquireSRWLockExclusive() ※排他ロック(writerロック)
- ReleaseSRWLockExclusive() ※排他アンロック(writerアンロック)
Winsock同期の場合には、OVERLAPPED構造体を解放するタイミングがないため、ログ出力モードは同期のみにしました。OVERLAPPED構造体解放専用のスレッドを1個用意すればよいのですが、そこまではしませんでした。
参考:
Slim Reader/Writer (SRW) Locks (スリム リーダー/ライター (SRW) ロック) - Win32 apps | Microsoft Learn
https://learn.microsoft.com/ja-jp/windows/win32/sync/slim-reader-writer--srw--locks
7.2.1 IOCP
- 予めCreateFile()をFILE_FLAG_OVERLAPPED指定ありで実行し、CreateIoCompletionPort()で関連付ける
- OVERLAPPED構造体のログ管理用拡張版を取得する
- WriteFile()実行
- ログローテート時にCloseHandle()(自動的にI/O完了ポートの参照がなくなる)し、CreateFile()をFILE_FLAG_OVERLAPPED指定ありで実行し、CreateIoCompletionPort()で関連付ける
ソケット通信とログ出力で同じGetQueuedCompletionStatus()で待つことになるため、ソケット通信とログ出力で完了キー(CompletionKey)を別の値にしてCreateIoCompletionPort()に渡し、GetQueuedCompletionStatus()が返ってきた後に分岐します。
TODO:
終了時の待ち合わせの説明
7.2.2. スレッドプールI/O
- 予めCreateFile()をFILE_FLAG_OVERLAPPED指定ありで実行し、CreateThreadpoolIo()を実行する
- OVERLAPPED構造体のログ管理用拡張版を取得する
- StartThreadpoolIo() ※WriteFile()の直前で実行する
- WriteFile()実行
- CancelThreadpoolIo() ※異常系で使用する
- ログローテート時にCloseThreadpoolIo()、CloseHandle()を実行し、CreateFile()をFILE_FLAG_OVERLAPPED指定ありで実行し、CreateThreadpoolIo()を実行する
ソケット通信とログ出力で別のコールバック関数(IoCompletionCallback)を指定します。
TODO:
終了時の待ち合わせの説明
8. Linux関連メモ
- select
- poll
- epoll
- libev ※UNIX系
- libuv ※Node.jsのために作られた。WindowsはIOCPをラップ
- io_uring ※RIO相当。Linuxカーネル5.1から(AlmaLinuxで9.0から)
参考:
第4回 libuv C10K〜C100K サーバサンプル [C++] | Netsphere Laboratories
https://www.nslabs.jp/libuv-c10k-server.rhtml