Windowsの非同期I/Oと、OVERLAPPED構造体の寿命

The Old New Thingの「Ready... cancel... wait for it! (part 2)」を読んだ。

この記事の結論はこうだ。

I/O完了ルーチンを使う場合、OVERLAPPED構造体はI/O完了ルーチンの実行が終わるまで解放してはならない。一般的には、OVERLAPPED構造体をスタックではなくヒープに割り当て、I/O完了ルーチンの最後で破棄するようにする。

普通そうするだろ、という話は置いておいて。

part1の復習

なんでこんなことを問題にしているんだろう? と不思議に思ったが、この記事は以下のような少し特殊なケースを前提にしているように思う。

  • したいことは、タイムアウトつきのブロッキングI/Oを行う関数を作ること。
  • タイムアウトしたときのキャンセルを可能にするために、非同期I/Oを使う。
  • 関数は、I/Oが成功、失敗、タイムアウトのいずれかで抜ける。I/O実行中に抜けることはない。
  • 楽しようと思って、OVERLAPPED構造体をスタックに置いちゃってる。

具体的には、part1 の記事に載っているコードである。以下に引用してみる。
part1の最後の対応を入れてあるのは、part2で問題にしているのはこの後の話だからだ。

 HANDLE h = ...; // handle to file opened as FILE_FLAG_OVERLAPPED
 OVERLAPPED o;
 BYTE buffer[1024];
 InitializeOverlapped(&o); // creates the event etc
 if (ReadFile(h, buffer, sizeof(buffer), NULL, &o) ||
     GetLastError() == ERROR_IO_PENDING) {
  if (WaitForSingleObject(o.hEvent, 1000) != WAIT_OBJECT_0) {
   // took longer than 1 second - cancel it and give up
   CancelIo(h);
   WaitForSingleObject(o.hEvent, INFINITE); // added
   // Alternatively: GetOverlappedResult(h, &o, TRUE);
   return WAIT_TIMEOUT;
  }
  ... use the results ...
 }
 ...

CancelIoの後にWaitForSingleObjectが必要なのは、CancelIoもI/O完了の一つの理由だから。CancelIoを呼べばI/Oが無かったことになるわけではない。だから、キャンセルした後もカーネルがI/Oを完了するまで、OVERLAPPED構造体を破棄するのを待っているわけだ。

part2 - I/O完了ルーチン

part1 の2番目のExerciseは、ReadFileではなくてReadFileExを使う場合には、これでは不完全である、ということだった*1

重要な違いは、ReadFileExではI/O完了ルーチンを指定できること。

I/O完了ルーチンは、I/Oが完了した後、スレッドのAPCキューに積まれる。I/Oが完了したからといって、すぐにスレッドに割り込むとプログラマが困る、とWindowsの設計者は考えたのだろう。積まれたI/O完了ルーチンは、スレッドがalertable stateになったときに呼び出される。Windowsは、待機を伴うAPIを呼ぶときにalertable stateになります、とプログラマが指定したときにのみ、APCキューに積まれたAPCが呼ばれる親切設計である。何気なくSleepしたらいきなり別の関数が呼び出されて面食らうようなことはない。

しかし上のコードでは、関数内でalertable stateに入らない。I/O完了ルーチンを指定すると、それが実行されないまま関数を抜けてしまう。当然、OVERLAPPED構造体も破棄される。しかし、I/O完了ルーチンはそのOVERLAPPED構造体のアドレスと一緒にAPCキューに積まれて、実行されるときを待っている…。

教訓

まとめると、part1とpart2で扱われた内容は、OVERLAPPED構造体の寿命だ。

  • part1では、カーネルからI/O完了通知を受け取るまではOVERLAPPED構造体を破棄してはならないこと。
  • part2では、I/O完了ルーチンを指定したら、それが実行されるまではOVERLAPPED構造体を破棄してはならないこと。

が指摘されていた。そして注意すべきは、キャンセルもまたI/O完了の要因の一つということであった。

*1:元記事のコメントでも指摘されているように、上のコードは、単純にReadFileをReadFileExに変えただけではきちんと動かない。ReadFileExはo.hEventを無視するので。しかし、それはここでは問題にしない。おそらく、それほど丁寧に書かれた記事ではないのだろう。