Windows の STDIN を非同期で読み込む。

2010-12-22

こんにちわ。mattn です。
先日、Maki_Daisuke さんからお題を頂いた件の回答です。

https://gist.github.com/742977

use AnyEvent::Handle;

my $c = AE::cv;

my $out = AnyEvent::Handle->new(fh => \*STDOUT);

my $in; $in = AnyEvent::Handle->new(
    fh => \*STDIN,
    on_read => sub{
        my $hdl = shift;
        $out->push_write($hdl->rbuf . "\n");
        $hdl->rbuf = "";
    }
);
$out->push_write("Start\n");

$c->recv;

これを Windows で実行してもウンともスンとも行かないという現象。色々調べている内に Windows の標準入力は ReadFile 系非同期読み込みではちゃんと扱えない事が分かりました。正しく読み取るには ReadConsole という API を使う必要があります。
またコンソールはデフォルトでライン入力になっているので、そのあたりのオプションも変えながら読み取らないといけません。
まずここまでをコードで

sub STD_INPUT_HANDLE ()     { 0xfffffff6 }
sub FILE_TYPE_CHAR ()       { 0x0002 }
sub FILE_TYPE_PIPE ()       { 0x0003 }
sub ENABLE_PROCESS_INPUT () { 0x0001 }
sub ENABLE_LINE_INPUT ()    { 0x0002 }
sub ENABLE_ECHO_INPUT ()    { 0x0004 }

my $GetStdHandle = Win32::API->new('kernel32.dll',
    'GetStdHandle',
    'N',
    'N',
) or die ": $^E";
my $GetFileType = Win32::API->new('kernel32.dll',
    'GetFileType',
    'N',
    'N',
) or die ": $^E";
my $_kbhit = Win32::API->new('msvcrt.dll',
    '_kbhit',
    '',
    'I',
) or die ": $^E";
my $ReadConsole = Win32::API->new('kernel32.dll',
    'ReadConsoleA',
    'NPNPP',
    'N',
) or die ": $^E";
my $GetConsoleMode = Win32::API->new('kernel32.dll',
    'GetConsoleMode',
    'NP',
    'I',
) or die ": $^E";
my $SetConsoleMode = Win32::API->new('kernel32.dll',
    'SetConsoleMode',
    'NN',
    'I',
) or die ": $^E";

my $handle = $GetStdHandle->Call(STD_INPUT_HANDLE);
if ($GetFileType->Call($handle) eq FILE_TYPE_CHAR) {
    my $mode = 0;
    $GetConsoleMode->Call($handle, \$mode);
    $SetConsoleMode->Call( $handle,
        $mode &
        ~ENABLE_LINE_INPUT &
        ~ENABLE_ECHO_INPUT |
         ENABLE_PROCESS_INPUT );
    while (1) {
        if ($_kbhit->Call()) {
            my ($buf, $num) = (' ', 0);
            if ($ReadConsole->Call($handle, $buf, 1, \$num, 0)) {
                # 読み取れた
            }
        }
    }
}

こうなります。ただ自前でポーリングをしなければなりませんので厄介ですね。こんな時は

「使うと Marc Lehmann 先生に DIS られる」

という噂の threads を使いましょう。

async {
    while (1) {
        if ($_kbhit->Call()) {
            my ($buf, $num) = (' ', 0);
            if ($ReadConsole->Call($handle, $buf, 1, \$num, 0)) {
                # 読み取れた
            }
        }
    }
}->detach;

さぁこれを後はメインスレッド側で使えるようにしたいのですが、何を使うかでハマりました。結果でいうと、AnyEvent::Handle は fileno を使って処理するので tie や capture 的な物を使っても実現出来ませんでした。実際 tie して GETC もしくは READ にフックしてみましたが、いっこうに呼ばれませんでした。
せっかくここまで解析したのでもったいないと思ったので getc だけでも使えるようにしましょう。

package Win32::Async::Stdin;

use strict;
use warnings;
use threads;
use threads::shared;
use Win32::API;
use IO::Scalar;
our $VERSION = '0.01';

sub STD_INPUT_HANDLE ()     { 0xfffffff6 }
sub FILE_TYPE_CHAR ()       { 0x0002 }
sub FILE_TYPE_PIPE ()       { 0x0003 }
sub ENABLE_PROCESS_INPUT () { 0x0001 }
sub ENABLE_LINE_INPUT ()    { 0x0002 }
sub ENABLE_ECHO_INPUT ()    { 0x0004 }

my $GetStdHandle = Win32::API->new('kernel32.dll',
    'GetStdHandle',
    'N',
    'N',
) or die ": $^E";
my $GetFileType = Win32::API->new('kernel32.dll',
    'GetFileType',
    'N',
    'N',
) or die ": $^E";
my $_kbhit = Win32::API->new('msvcrt.dll',
    '_kbhit',
    '',
    'I',
) or die ": $^E";
my $ReadConsole = Win32::API->new('kernel32.dll',
    'ReadConsoleA',
    'NPNPP',
    'N',
) or die ": $^E";
my $GetConsoleMode = Win32::API->new('kernel32.dll',
    'GetConsoleMode',
    'NP',
    'I',
) or die ": $^E";
my $SetConsoleMode = Win32::API->new('kernel32.dll',
    'SetConsoleMode',
    'NN',
    'I',
) or die ": $^E";

my $inputs = &share([]);
tie *STDIN, 'Win32::Async::Tied::Stdin', $inputs;

my $handle = $GetStdHandle->Call(STD_INPUT_HANDLE);
if ($GetFileType->Call($handle) eq FILE_TYPE_CHAR) {
    my $mode = 0;
    $GetConsoleMode->Call($handle, \$mode);
    $SetConsoleMode->Call( $handle,
        $mode &
        ~ENABLE_LINE_INPUT &
        ~ENABLE_ECHO_INPUT |
         ENABLE_PROCESS_INPUT );
    async {
        while (1) {
            if ($_kbhit->Call()) {
                my ($buf, $num) = (' ', 0);
                if ($ReadConsole->Call($handle, $buf, 1, \$num, 0)) {
                    push @$inputs, $buf;
                }
            }
        }
    }->detach;
}

package Win32::Async::Tied::Stdin;
use strict;

sub TIEHANDLE {
    my ($class, $ref) = @_;
    bless { ref => $ref }, $class;
}

sub GETC {
    my $self = shift;
    shift @{$self->{ref}};
}

1;

こんな感じに配列のリファレンスをスレッド間共有してみました。使う側は

use Win32::Async::Stdin;

while () {
    my $a = getc(STDIN);
    warn $a if $a;
    # 高速ループするので sleep いれてね!
}

こんな感じでしょうか。あまり汎用性もなく、AnyEvent に食わせたり出来ないので有益では無いですが使う側のコードは幾分減らせるかと思いました。